JPA @OneToOne 슬기롭게 사용하기

JPA @OneToOne 슬기롭게 사용하기

사용하기 까다로운 @OneToOne ! 어떻게 사용해야 할까?


💥 @OneToOne 이슈


JPA(Hibernate)를 실무에서 사용해보신 분이라면 아시겠지만, @OneToOne은 지연로딩 관련 이슈가 존재한다.

따라서 이를 사용하면 N+1 문제가 곧잘 터져나와 사용하기가 여간 까다로운게 아니다.

@OneToOne으로 지연로딩을 하기 위해서는 절대적으로 아래의 조건이 성립해야만 한다


  • optional=false 옵션이 있어야 한다 (=NOT NULL)


이 조건이 성립한다면 아래의 경우에 지연로딩이 가능해진다


  • @OneToOne 단방향
  • @OneToOne 양방향이지만 관계의 주인쪽에서 조회를 한 경우


즉, 어떻게 해도 양방향 매핑 시 관계의 주인이 아닌곳에서 작업이 들어가면 N+1 문제가 터져나온다.

또한 추가로 관계의 주인이 아닌녀석은 기본적으로 읽기 전용(Read Only)이기 때문에, CUD(Create, Update, Delete)도 제대로 되지 않는 경우가 생길 수 있다.


확인해보자



@Table
@Entity
public class Member {

    @Id
    @GeneratedValue
    private Long id;

    private String name;

    @OneToOne(
        mappedBy = "member",
        fetch = FetchType.LAZY,
        cascade = CascadeType.ALL,
        optional = false
    )
    private Locker locker;

    public void setLocker(Locker locker) {
        if (locker == null) {
            if (this.locker != null) {
                this.locker.setMember(null);
            }
        }
        else {
            locker.setMember(this);
        }
        this.locker = locker;
    }

}

@Table
@Setter
@Entity
public class Locker {

    @Id
    @GeneratedValue
    private Long id;
    
    private String thing;

    @OneToOne(fetch = FetchType.LAZY)
    private Member member;

}


관계의 주인은 Locker이다.

optional=false를 명시해주어 매핑된 객체가 NULL일 경우의 수를 배제해주었다.

이는 Hibernate가 EAGER로 동작해 N+1 문제가 발생할 여지를 차단한것과 같은 의미이다.


관계 매핑에 NOT NULL 조건이 없다면 Hibernate는 데이터베이스에 쿼리해보지 않고서는 매핑된 객체에 프록시를 채워주어야 할지 NULL을 넣어야 할지 알 수 없습니다.


📜 OneToOne에 대해서


이 상태에서 아주 간단한 테스트를 진행해보자.

Locker가 관계의 주인이므로, Locker를 쿼리하면 N+1 문제가 발생하지 않아야 한다.

따라서 select 쿼리는 단 한번만 발생해야 한다.



@DataJpaTest
@Rollback(false)
class MemberTest {

    @Autowired
    TestEntityManager em;

    @BeforeEach
    void setUp() {
        Locker locker = new Locker();
        Member member = new Member();
        member.setLocker(locker);
        em.persist(member);
        em.flush(); // insert 두번 발생
        em.clear();
    }

    @Test
    void find() throws Exception {
        em.find(Locker.class, 1L); // select 한번 발생
    }

}


Hibernate: 
    insert 
    into
        member
        (name, id) 
    values
        (?, ?)
Hibernate: 
    insert 
    into
        locker
        (member_id, thing, id) 
    values
        (?, ?, ?)
Hibernate: 
    select
        locker0_.id as id1_0_0_,
        locker0_.member_id as member_i3_0_0_,
        locker0_.thing as thing2_0_0_ 
    from
        locker locker0_ 
    where
        locker0_.id=?


MemberLocker의 관계는 양방향이며, Member는 관계의 주인이 아니다.

따라서 Member를 통해 쿼리하면 N+1 문제가 발생해야만 한다.

즉, select 쿼리가 두번 발생할 것이다.



@DataJpaTest
@Rollback(false)
class MemberTest {

    @Autowired
    TestEntityManager em;

    @BeforeEach
    void setUp() {
        Locker locker = new Locker();
        Member member = new Member();
        member.setLocker(locker);
        em.persist(member); // insert 두번 발생
        em.flush();
        em.clear();
    }

    @Test
    void find() throws Exception {
        // em.find(Locker.class, 1L);
        em.find(Member.class, 1L); // select 두번 발생
    }

}


Hibernate: 
    insert 
    into
        member
        (name, id) 
    values
        (?, ?)
Hibernate: 
    insert 
    into
        locker
        (member_id, thing, id) 
    values
        (?, ?, ?)
Hibernate: 
    select
        member0_.id as id1_1_0_,
        member0_.name as name2_1_0_ 
    from
        member member0_ 
    where
        member0_.id=?
Hibernate: 
    select
        locker0_.id as id1_0_0_,
        locker0_.member_id as member_i3_0_0_,
        locker0_.thing as thing2_0_0_ 
    from
        locker locker0_ 
    where
        locker0_.member_id=?


@OneToOne에 대해 어느정도 이해하고 있고, 코드를 작성한 당사자는 이러한 문제에서 자유로울수도 있다.

하지만 이러한 속사정을 모르는 동료들은 실수할 가능성이 충분히 높다.

그리고 복잡한 실무환경에서 JPA를 사용하다 보면 코드만 봐서는 정확히 어떤 쿼리가 발생할 것인지 완벽하게 알기가 어렵다.

결국 코드를 실행하면서 모니터링을 해봐야지만 정확히 어떠한 쿼리가 발생하는지를 알 수 있다는 의미이다.

즉, 이러한 문제는 코드리뷰를 해도 사전에 찾아내기가 어렵다.


그렇다고 @OneToOne을 아예 안쓰기는 어렵다.

아무리 사용을 피하려 해도 간혹가다 사용해야하는 순간이 있을 수 있다.

그러면 어떻게 사용해야 이를 슬기롭게 사용할 수 있을까?


😁 슬기롭게 사용하기


우선 위 상황에서 테이블이 어떻게 만들어졌는지 DDL을 확인해보자.


Hibernate: 
    
    create table locker (
       id bigint not null,
        thing varchar(255),
        member_id bigint,
        primary key (id)
    )
Hibernate: 
    
    create table member (
       id bigint not null,
        name varchar(255),
        primary key (id)
    )


locker 테이블에는 pkidfkmember_id가 있음을 볼 수 있다.

근데 여기서 과연 pk로 잡혀있는 id컬럼이 필요할까?

필요하지 않다고 생각되는데도 불구하고 인덱스 컬럼을 두개나 잡고 있다.

이는 효율적이지 않다.

그렇다면 어떻게 할 수 있을까?


💡 @MapsId


javax.persistence패키지에는 @MapsId라는 어노테이션이 있다.

이는 fkpk로 사용할 수 있게 해준다.

두말할 것 없이 적용한 코드와 결과를 보자.



@Table
@Setter
@Entity
public class Locker {

    // 1: @GeneratedValue 제거
    @Id
    private Long id;
    
    private String thing;

    @MapsId // 2: @MapsId 추가
    @OneToOne(fetch = FetchType.LAZY)
    private Member member;

}

@Table
@Entity
public class Member {

    @Id
    @GeneratedValue
    private Long id;

    private String name;

    // 3: 양방향 매핑 -> 단방향 매핑으로 변경됨 (=기존 매핑 제거됨)

}


일단 Entity 클래스의 코드가 대폭 줄어들어 가독성이 크게 늘어났다.

코드에서 주목할 부분들은 다음과 같다.


  • 1: @GeneratedValue가 제거되었다
    • member 테이블의 pk(id)locker 테이블의 pk로 사용할 것이기 때문에 기본키 생성전략이 필요하지 않다
  • 2: @MapsId가 추가되었다
  • 3: 양방향 매핑에서 단방향 매핑으로 변경되었다 (=기존 매핑 제거됨)


그리고 가장 중요한 테이블에 대한 DDL을 확인해보자.


Hibernate: 
    
    create table locker (
       thing varchar(255),
        member_id bigint not null,
        primary key (member_id)
    )
Hibernate: 
    
    create table member (
       id bigint not null,
        name varchar(255),
        primary key (id)
    )
Hibernate: 
    
    alter table locker 
       add constraint FKcwdw46rsk7jstg14ey1ppkb1h 
       foreign key (member_id) 
       references member


우선 locker 테이블에 있던, 불필요하다고 생각되던 인덱스 컬럼이 제거되었다.

그리고 fk였던 member_idlocker테이블의 pk로 사용하게 되었음을 볼 수 있다.

이제 N+1 문제가 발생하던 코드를 다시 실행해보면 어떻게 될까?



@DataJpaTest
@Rollback(false)
class MemberTest {

    @Autowired
    TestEntityManager em;

    @BeforeEach
    void setUp() {
        Locker locker = new Locker();
        Member member = new Member();
        locker.setMember(member);
        em.persist(locker);
        em.flush();
        em.clear();
    }

    @Test
    void find() throws Exception {
        // em.find(Locker.class, 1L);
        em.find(Member.class, 1L);
    }

}


Hibernate: 
    insert 
    into
        member
        (name, id) 
    values
        (?, ?)
Hibernate: 
    insert 
    into
        locker
        (thing, member_id) 
    values
        (?, ?)
Hibernate: 
    select
        member0_.id as id1_1_0_,
        member0_.name as name2_1_0_ 
    from
        member member0_ 
    where
        member0_.id=?


아주 깔끔한 쿼리가 발생함을 확인할 수 있다.


✅ 결론


이러한 변경을 통해 다음과 같은 이점들을 얻을 수 있었다.


  1. 불필요한 인덱스 컬럼을 제거해 데이터베이스가 최적화되었다
  2. JPA 코드의 가독성이 큰 폭으로 개선되었다
  3. N+1 문제가 발생할수도 있는 여지를 완벽하게 차단했다



© 2022. All rights reserved.