티스토리 뷰
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/
2: byte instrumentation: https://vladmihalcea.com/maven-gradle-hibernate-enhance-plugin/
'jpa' 카테고리의 다른 글
[JPA] bytecode instrumentation 을 이용한 lazy loading 활성화-2 (0) | 2022.02.25 |
---|