티스토리 뷰

0. @OneToOne 의 양방향 연관 관계

 

다대일, 일대다, 다대다 와는 다르게 @OneToOne 매핑은 연관관계를 매핑할 수 있는 선택지가 두가지 있습니다. 아래와 같이 Post 와 PostDetail 엔티티가 있다고 생각해봅시다. 

@Entity
class Post {
    @OneToOne(mappedBy = "detail")
    @LazyToOne(value = LazyToOneOption.NO_PROXY)
    private PostDetail detail;
}

@Entity
class PostDetail {
    @OneToOne(fetch = FetchType.LAZY)
    @LazyToOne(value = LazyToOneOption.FALSE)
    private Post post;
}

이 경우 비즈니스 로직상 더 자주 조회를 하는 엔티티는 Post 입니다. 이를 메인 엔티티라고 하겠습니다. 반면 PostDetail 의 경우는 서브엔티티라고 하겠습니다. 둘의 관계가 OneToOne 으로 주어질때 둘중 어떤 엔티티를 연관관계의 주인으로 하느냐를 선택해야합니다.

Post 를 연관관계의 주인으로 하는 방식은 엔티티의 관계를 조금더 객체 지향적으로 생각한 결과이고 PostDetail 을 연관관계 주인으로 생각한 것은 join 에 대해 거부감이 없는 전통적인 데이터베이스 엔지니어들이 선호하는 방식입니다. 

둘의 장단점도 분명 존재합니다. OneToOne 은 만약 둘이 연관관계를 가진다면 반드시 하나의 관계만 가져야 한다는 것을 의미하지 둘이 반드시 연관관계를 가지고 있어야 하는 것을 보장하지는 않습니다. 그리고 위의 엔티티 사이에는 다음과 같은 명제가 성립합니다.

"PostDetail 은 반드시 특정 하나의 Post 의 디테일한 정보를 담고 있다" 이련 명제가 성립하는 경우에는 PostDetail 이 존재하지만 Post 가 존재하지 않는 경우는 없습니다. 반면 역으로 Post 가 존재하지만 PostDetail 이 존재하는 경우는 가능합니다. 

 

 만약 Post 를 연관관계의 주인으로 설정한다면 PostDetail 의 id 를 외래키로 갖는 컬럼에는 Null 값이 존재할수도 있습니다. 이런 경우Post -> PostDetail 방향으로 조회를 해오는 부분에서는 성능적으로 문제가 없겠지만 PostDetail -> Post 방향으로의 쿼리에서는 성능이슈가 발생할 수 있습니다.  또한 나중에 하나의 Post 가 여러개의 PostDetail 을 가질 수 있다 와 같이 비즈니스 명세가 바뀌게 된다면 데이터베이스 설계의 전면수정도 각오해야합니다.


1. @OneToOne 에서 Proxy 객체

 jpa hibernate 를 이용하여 프로젝트를 진행할때 @OneToOne 의 양방향 관계를 이용해야 하는 상황이 발생합니다. 이때 연관관계의 주인 엔티티에서는 Lazy loading 이 정상적으로 작동하지만 mappedby 로 관계를 만들어준 반대편의 엔티티에서는 항상 Eager 방식으로 작동하게 됩니다. 

 

위의 예시의 Post와 PostDetail 에서 다음과 같은 상황을 생각해봅시다. (모든 코드는 이해를 돕기위한 예시로 약식으로 작성되었습니다. )

@Autowired
EntityManager em;
@Autowired
PostRepository postRepository;
// 약식으로 작성한 코드입니다. 
// Post와 PostDetail 을 하나씩 생성해 서로 연결한뒤 영속성 컨텍스트에 저장하고 컨텍스트를 비웠습니다.
Post post = new Post(1L);
PostDetail postDetail = new PostDetail(1L, post);

postRepository.save(post)
postDetailRepository.save(postDetail)

em.flush()
em.clear()


PostRepository.findById(1L) // Post 조회 쿼리 두번 실행
em.flush()
em.clear()

// PostDetail 조회 쿼리 한번 실행
PostDetail postDetail = PostDetailRepository.findById(1L) 

// Post 의 name 필드가 필요하기때문에 Lazy Loading 으로 쿼리한번 더 실행됨
postDetail.getPost().getName()

Post 와 PostDetail 을 서로 관계를 맺어준뒤, 영속성컨텍스트를 비워줍니다. 이후 Post 를 조회해오는 메서드를 실행시키면

select * from post p where p.id = 1;
select * from post_detail pd where pd.post_id = 1;

하지만 postDetail 을 조회해 올때는 쿼리가 한번만 실행되며, 나중에 postDetail 에서 post 를 이용하는 시점이 되어서야 post 를 가져오는 쿼리가 실행됩니다. 

 

 이는 jpa 의 프록시 때문입니다. jpa 는 엔티티를 조회해올때 그 엔티티와 연관관계를 맺고 있는 객체를 처리하는 방법으로 프록시를 이용합니다. 프록시 객체로 포장된 연관관계 엔티티는 프록시라는 단어와 어울리게, 엔티티의 모든 정보를 통째로 데이터베이스에서 조회하여 가지고 있는 것이 아닌, 그 엔티티의 id 값만 가지고 있습니다. 그러다가 나중에 엔티티의 id 가 아닌 다른 필드의 데이터가 필요한 순간이 오면 가지고 있는 id 값을 이용하여 데이터베이스에서 추가적인 데이터를 가져옵니다.

 

 이렇게 프록시가 작동할 수 있는 이유는 연관관계의 주인이 되는 엔티티는 실제로 데이터베이스에서 상대편의 primary 키를 자신의 forign key 로 가지고 있기 때문입니다. 즉 연관관계의 주인은 상대방을 식별할 수 있는 식별자를 기본으로 자신의 column 으로 가지고 있고, 이로 인해 필요하다면 언제든지 상대방을 조회해 올 수 있습니다. 

 반면 연관관계의 주인이 아닌 엔티티는 기본적으로 상대의 식별자를 가지고 있지않습니다. 따라서 프록시 객체를 만들기 위해서는 자신의 id 를 이용해 연관관계의 주인 테이블에서 자신의 id 를 forign key 로 가지고 있는 연관관계의 주인을 찾아야 하고, 이렇게 찾은 연관관계 주인을 기반으로 프록시객체를 생성합니다. 연관관계의 주인을 찾아오는 과정에서 query 가 한번더 실행되고 이 과정에서 연관관계의 주인의 모든 정보를 조회해오기 때문에, 굳이 id만 가지고 있는 프록시 객체를 만들 이유가 없겠죠? 따라서 @OneToOne mappedby 를 이용하여 만들어준 양방향 관계에서는 fetch = Fetch.Lazy 로 설정을 해도 Eager 로 작동하게 됩니다.

 

 이런 동작방식은 N + 1 문제를 야기합니다. 예를 들어 쿼리 dsl 을 이용하여 다음과 같은 쿼리를 jpa 를 이용해 실행시켰다고 생각해보겠습니다. 

// 100 개의 post 조회
queryFactory
    .selectFrom(QPost.post)
    .orderBy(QPost.post.id.asc())
    .limit(100).fetch()
            
// 실제로 생성되는 쿼리 (1 + 100 개 생성)
select * from post p orderBy p.id limit 100;

select * from postDetail pd where pd.post_id = 1L
select * from postDetail pd where pd.post_id = 2L
...
select * from postDetail pd where pd.post_id = 100L

단순히 post 를 100개만 가져오는 쿼리 하나만 실행되는 것을 기대했지만 실제로는 post 100개 가져오는 쿼리 + postDetail 을 1개씩 가져오는 쿼리 100개로 총 101 번의 쿼리가 실행됩니다. 


2. @LazyToOne

위와 같은 OneToOne의 양방향 연관관계에서 Eager 로 작동하는 문제를 해결하기 위해 @LazyToOne 와 bytecode instrumentation 을 이용할 수 있습니다. 둘 중 하나만 사용하는 것이 아닌 둘을 동시에 사용해야 한다는 점에 주의해주세요.

 

이중 먼저 @LazyToOne 이 어떻게 작동하는지 알아보겠습니다. 

@LazyToOne 어노테이션은 value 파라미터를 설정할 수 있으며, 그 값은 아래 3가지 중 하나 입니다.

@Target({ElementType.METHOD, ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface LazyToOne {
	/**
	 * Specify the laziness option.
	 */
	LazyToOneOption value();
}
public enum LazyToOneOption {
    FALSE,
    PROXY,
    NO_PROXY
}

@LazyToOne 은 아래와 같이 연관 관계를 맺는 field 에 어노테이션으로 사용할 수 있습니다.

@Entity
class Post {
    @OneToOne(mappedBy = "detail")
    @LazyToOne(value = LazyToOneOption.NO_PROXY)
    private PostDetail detail;
}

@Entity
class PostDetail {
    @OneToOne(fetch = FetchType.LAZY)
    @LazyToOne(value = LazyToOneOption.FALSE)
    private Post post;
}

위와 같이 1대1 관계를 가진 엔티티가 있다고 가정해보겠습니다. PostDetail 이 연관관계의 주인이고, @OneToOne 에서 Lazy 로 설정되어 있기 때문에 지연 로딩 된다고 기대되지만 @LazyToOne 에 의해 실제로는 eagerly 하게 로딩됩니다.

 

@LazyToOne 은 내부적으로 다음과 같이 작동하기 때문에 기존의 로딩전략을 덮어쓰는 방식으로 적용됩니다.

LazyToOne lazy = property.getAnnotation(LazyToOne.class);
 
if ( lazy != null ) {
    toOne.setLazy(
        !(lazy.value() == LazyToOneOption.FALSE)
    );
     
    toOne.setUnwrapProxy(
        (lazy.value() == LazyToOneOption.NO_PROXY)
    );
}

그렇다면 @LazyToOne(value = LazyToOneOption.NO_PROXY) 를 이용하면 양방향 연관관계에서 연관관계주인 을 lazy 하게 로딩하도록 할 수 있을까요? 아쉽지만 @LazyToOne 만으로는 불가능합니다.

다음 포스트에서 bytecode instrumentation 을 추가로 설정하여 @LazyToOne 어노테이션을 이용해 OneToOne의 양방향관계에서 proxy를 만들지 않도록 설정해 N + 1 문제가 발생하지 않도록 해보겠습니다.


참고

1 @LazyToOne: https://vladmihalcea.com/hibernate-lazytoone-annotation/

 

Hibernate LazyToOne annotation - Vlad Mihalcea

Learn how the Hibernate LazyToOne annotation works and why you should use NO_PROXY lazy loading with bytecode enhancement.

vladmihalcea.com

2: byte instrumentation: https://vladmihalcea.com/maven-gradle-hibernate-enhance-plugin/

 

'jpa' 카테고리의 다른 글

[JPA] bytecode instrumentation 을 이용한 lazy loading 활성화-2  (0) 2022.02.25
댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/04   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30
글 보관함