-> 블로그 이전

[JPA] 3. 일대일 연관관계 (@OneToOne)

2022. 7. 3. 15:44Language`/JPA

일대일 관계는 양쪽이 서로 하나의 관계만 가지는 관계이다

따라서 일대일 관계에서는 "어느 쪽이든 FK를 가질 권리"를 보유하고 있다

 

참고로 일대일 연관관계에서 FK에는 반드시 UNIQUE도 추가해줘야 한다
왜냐하면 UNIQUE 제약조건을 추가해주지 않으면 일대일이 아니라 일대다가 되기 때문이다

1. 주 테이블 FK

일반적으로 "개발자" 입장에서는 주 테이블에 FK가 있는 것을 선호한다

왜냐하면 주 테이블에 FK가 있어야 더 편리하게 매핑할 수 있기 때문이다

  • 대상 테이블에 FK가 존재한다면 본인이 아닌 상대방의 column을 관리해야 하기 때문

 

(1) 주 테이블 FK - 단방향

일반적으로 생각해보면 "User는 Ticket 하나를 소유할 수 있다"이므로 User를 "주테이블"로 생각해서 매핑해보자

 

User(1 - 주인)

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Table(name = "user")
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "user_id")
    private Long id;

    private String name;

    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "ticket_id", unique = true)
    private Ticket ticket;
}
  • 일대일 매핑이므로 User입장에서는 동일한 Ticket을 여러장 가질 수 없으므로 "반드시" Ticket에 대한 unique=true를 설정해야 한다

Ticket(1 - 주인 X)

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Table(name = "ticket")
public class Ticket {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "ticket_id")
    private Long id;

    private String ticketName;

    private int seatNumber;
}

 


(2) 주 테이블 FK - 양방향

User (1 - 주인)

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Table(name = "user")
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "user_id")
    private Long id;

    private String name;

    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "ticket_id", unique = true)
    private Ticket ticket;

    public void buyTicket(Ticket ticket){
        this.ticket = ticket;
        ticket.registerUser(this);
    }
}

Ticket (1 - 주인 X)

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Table(name = "ticket")
public class Ticket {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "ticket_id")
    private Long id;

    private String ticketName;

    private int seatNumber;

    @OneToOne(fetch = FetchType.LAZY, mappedBy = "ticket")
    private User user;

    public void registerUser(User user){
        this.user = user;
    }
}

Locker쪽에도 Member라는 참조를 걸어주었고 Locker는 주인이 아니기 때문에 "mappedBy"로 매핑을 해주었다

  • mappedBy가 존재하는 Entity는 주인이 아니라고 생각하면 된다

※ 일대일 매핑 지연로딩 이슈

현재 DB 데이터 현황

먼저 주인 테이블인 User에 대해서 조회해보자

User findUser = em.find(User.class, 1L);
System.out.println("===== findUser의 ticket 조회 =====");
System.out.println(findUser.getTicket());
Hibernate: 
    select
        user0_.user_id as user_id1_1_0_,
        user0_.name as name2_1_0_,
        user0_.ticket_id as ticket_i3_1_0_ 
    from
        user user0_ 
    where
        user0_.user_id=?
===== findUser의 ticket 조회 =====
Hibernate: 
    select
        ticket0_.ticket_id as ticket_i1_0_0_,
        ticket0_.seatNumber as seatnumb2_0_0_,
        ticket0_.ticketName as ticketna3_0_0_ 
    from
        ticket ticket0_ 
    where
        ticket0_.ticket_id=?
Ticket{id=1, ticketName='성시경 - 축가', seatNumber=1}

>> 일대일 매핑 연관관계 주인에 대한 조회는 정상적으로 "지연로딩이 작동"한 것을 확인할 수 있다

 

그러면 이제 주인이 아닌 Ticket에 대한 조회를 해보자

Ticket findTicket = em.find(Ticket.class, 1L);
System.out.println("===== findTicket의 소유자(User) 조회 =====");
System.out.println(findTicket.getUser());
Hibernate: 
    select
        ticket0_.ticket_id as ticket_i1_0_0_,
        ticket0_.seatNumber as seatnumb2_0_0_,
        ticket0_.ticketName as ticketna3_0_0_ 
    from
        ticket ticket0_ 
    where
        ticket0_.ticket_id=?
        
Hibernate: 
    /* load OneToOne.domain.User */ select
        user0_.user_id as user_id1_1_0_,
        user0_.name as name2_1_0_,
        user0_.ticket_id as ticket_i3_1_0_ 
    from
        user user0_ 
    where
        user0_.ticket_id=?
===== findTicket의 소유자(User) 조회 =====
User{id=1, name='userA'}

>> 주인이 아닌 엔티티에 대한 조회에서는 "지연로딩이 아닌 즉시로딩"이 된 점을 확인할 수 있다

이러한 이유는 다음과 같다

일단 기본적으로 JPA는 객체의 참조가 "프록시 기반"으로 동작한다.

여기서 1:N관계의 경우 컬렉션 형태로 참조할 프록시 객체를 감싸고 있기 때문에 그 객체가 null이라도 참조할 때는 문제가 없다

하지만 1:1관계null이 허용되는 경우 프록시 형태로 null객체를 반환할 수 없다

 

따라서 JPA 구현체(HIbernate)는 1:1관계에서 fetchType을 LAZY로 설정했어도 무조건적인 지연로딩이 되는 것이 아니다

  • 위의 경우가 대표적인 1:1 연관관계의 즉시로딩 이슈이다

JPA에서 1:1관계의 지연로딩 발동 조건은 다음과 같다

  1. nullable이 허용되지 않는 1:1 관계
  2. 양방향이 아닌 "단방향 1:1 관계"
  3. @PrimaryKeyJoin은 허용 X

여기서 가장 중요한 조건은 (2번)이다

1:1에서 양방향 관계로 설정하고 주인이 아닌 엔티티에 대한 조회를 할 경우 지연로딩이 아닌 "즉시로딩"이 되기 때문에 JPA의 고전적인 N+1문제가 발생할 수 있다

 

이에 대한 해결책은 3가지 정도로 볼 수 있다

  1. 꼭 필요한 상황이 아니라면 "엔티티간 1:1 관계"를 맺지 않도록 설정
  2. 1:1관계가 필요한 상황이라면 "부모 테이블이 FK를 가지도록 설계"
  3. 1:N관계로 변환될 여지가 있다면 처음부터 1:N 관계로 설계

 


※ 주 테이블 FK관리의 장·단점

<장점>
- 주 테이블만 조회하더라도 대상 테이블에 데이터가 존재하는지 확인할 수 있다
- JPA 매핑이 편리하다

<단점>
- 값이 없으면 FK에 NULL을 허용해야 한다

 


2. 대상 테이블 FK

이번에는 주 테이블 Member가 아닌 대상 테이블 Locker가 FK를 관리하도록 해보자

대상 테이블이 FK를 관리하는 방식은 "DBA"가 선호하는 방식이다

 

(1) 대상 테이블 FK - 단방향

사실 대상 테이블에 FK가 있는 단방향 관계는 JPA에서 애초에 지원을 하지 않는다

그리고 이러한 모양으로 매핑할 수 있는 방법도 없다

<해결방안>
1. 단방향 관계를 아예 Ticket → User로 수정 (이러면 주 테이블이 Locker가 되는 것)
2. 양방향 관계로 만들고 Ticket를 연관관계의 주인으로 설정

 


(2) 대상 테이블 FK - 양방향

User (1 - 주인 X)

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Table(name = "user")
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "user_id")
    private Long id;

    private String name;

    @OneToOne(fetch = FetchType.LAZY, mappedBy = "user")
    private Ticket ticket;
    
    public void buyTicket(Ticket ticket){
        this.ticket = ticket;
    }
}

Ticket (1 - 주인 O)

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Table(name = "ticket")
public class Ticket {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "ticket_id")
    private Long id;

    private String ticketName;

    private int seatNumber;

    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "ticket_id")
    private User user;
    
    public void registerUser(User user){
        this.user = user;
        user.buyTicket(this);
    }
}

 

대상 테이블에 FK를 둔 일대일 양방향 관계도 마찬가지로 즉시로딩 이슈가 발생할 수 있다
→ 주인인 Ticket을 대상으로 한 조회는 "지연로딩" 정상 작동
→ 주인이 아닌 User를 대상으로 한 조회는 "지연로딩이 아닌 즉시로딩" 작동

 

※ 대상 테이블 FK관리의 장·단점

<장점>
- 주 테이블 : 대상 테이블의 관계를 일대다로 변경할 때 테이블 구조를 유지하고 변경할 수 있다

<단점>
- "프록시 기능의 한계"로 인해서 FetchType.LAZY로 설정해도 항상 "즉시 로딩"이 된다

 


"일대일 매핑 즉시로딩은 프록시의 한계 때문에 발생하는 문제이다"

→ 이 한계는 "bytecode instrumentation"을 사용하면 해결할 수 있긴 하지만 프록시 관련 포스팅은 추후에 예정

  • 간단히 설명하자면 Java의 Byte Code에 직접 수정을 가해서 지연로딩을 구현하도록 하는 기법이다