1. 도입
분명 스프링 강의를 들을 때 제일 집중해서 듣고 이해도 잘 되었던게 연관관계 매핑이었는데, 회사에서 사용하려고 하니 헷갈리는 부분이 너무너무 많았다.. JPA는 잘 사용하면 편리하지만 아무렇게나 쓰면 나도 모르는 쿼리가 많이 발생할 수 있는 것 같다.
팀에서 @Transactional 애노테이션을 모든 곳에 붙이지 않고, 한 트랜잭션으로 묶을 필요가 있는 경우에만 붙이기로 결정해서 쿼리와 서비스 단에서 연관관계에 있는 엔티티들을 어떻게 조회할지 고민이 있었다. 만약 쿼리에서 필요한 엔티티를 모두 조회하지 않고 지연 로딩을 활용한다면, 연관관계에 있는 엔티티가 실제 조회될 때 트랜잭션이 필요했기 때문이다.
그래서 동기랑 이것저것 테스트하면서 정리한 내용을 블로그에도 정리해보려고 한다.
Spring Boot, JPA, Kotlin, QueryDSL을 사용하고 있다.
2. 지연 로딩 vs 즉시 로딩
JPA를 사용할 때 많이 사용하는 연관관계는 일대다(OneToMany), 다대일(ManyToOne) 연관관계다.
공부할 때는 ~ToOne 연관관계에서 FetchType을 LAZY로 지정하여, 지연 로딩을 사용하는 것이 좋다는 말을 받아들이기만 했는데, 실무에서 사용하다 보면 즉시 로딩을 사용하는 게 더 나아 보이는 경우가 생긴다. 두 엔티티가 대부분 같이 조회되어야 할 때가 그렇다.
그래서 큰 고민을 하지 않으면, EAGER 타입으로 즉시 로딩을 하는 게 더 깔끔해 보이기도 한다. 근데 왜 실무에서는 즉시 로딩을 지양해야 한다고 말하는 걸까?
일단, 일대다 연관관계와 다대일 연관관계로 나누어서 설명하려고 한다.
ManyToOne
JPA에서 @ManyToOne 연관관계를 사용하면, 기본 설정은 즉시 로딩이다. 처음에 즉시 로딩이면 연관관계에 있는 엔티티를 한꺼번에 조회할텐데 N+1 문제가 왜 발생하는 건지 의문을 가졌다.
테스트 코드로 돌려봐도 추가 조회 쿼리가 계속 실행되었기 때문이다.
분명 이전에 강의를 들었을 때, 즉시 로딩을 사용하면 엔티티가 같이 조회되었던 것 같은데.. 뭐지? 싶어서 좀 찾아봤다.
일단 JPA의 Entity Manager를 사용하는 경우, 즉시 로딩을 할 때 연관관계에 있는 엔티티까지 한꺼번에 조회하는 건 맞다.
@Entity
class Parent(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long,
val name: String,
)
@Entity
class Child(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long,
val name: String,
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = "parent_id")
val parent: Parent
)
이런 연관관계를 가진 엔티티가 있다고 가정할 때, child를 조회하면 parent도 함께 조회된다
@Test
fun test()
{
val parent = Parent(name = "parent")
parentRepository.save(parent)
val child = Child(name = "child", parent = parent)
childRepository.save(child)
em.flush()
em.clear()
val child2 = childRepository.findById(child.id!!).get()
}
select
c1_0.id,
c1_0.name,
p1_0.id,
p1_0.name
from
Child c1_0
left join
Parent p1_0
on p1_0.id=c1_0.parent_id
where
c1_0.id=?
그러나 findById가 아닌 다른 쿼리 메소드를 정의하면 N+1 문제가 발생한다
@Test
fun test2() {
val parent = Parent(name = "parent")
parentRepository.save(parent)
val child = Child(name = "child", parent = parent)
childRepository.save(child)
val parent2 = parentRepository.save(Parent(name = "parent2"))
val child2 = childRepository.save(Child(name = "child", parent = parent2))
em.flush()
em.clear()
childRepository.findByName("child")
}
Hibernate:
select
c1_0.id,
c1_0.name,
c1_0.parent_id
from
Child c1_0
where
c1_0.name=?
Hibernate:
select
p1_0.id,
p1_0.name
from
Parent p1_0
where
p1_0.id=?
Hibernate:
select
p1_0.id,
p1_0.name
from
Parent p1_0
where
p1_0.id=?
여기서 혼란을 겪었다.. 결론은 JPA가 쿼리를 생성하는 내부 동작의 차이에 있다.
- findById: 내부적으로 EntityManager의 find 메소드를 호출하여 데이터베이스에서 엔터티를 직접 가져온다. 이는 JPA에서 제공하는 표준 메소드로, 주로 엔터티의 식별자를 사용하여 빠르게 단일 엔티티를 가져올 때 사용된다.
- 쿼리 메소드: 내부적으로 해당 메소드에 맞는 JPQL 쿼리를 생성하여 실행한다. 이 JPQL이 생성되는 과정에서 연관관계에 있는 엔티티를 하나의 쿼리로 조회함을 명시하지 않으면 즉시 로딩을 할 때, 연관관계에 있는 엔티티를 조회하기 위해 추가 쿼리가 실행된다.
- QueryDSL도 JPQL 쿼리를 생성하기 때문에 동일하다
결론!
ManyToOne
|
OneToMany
사실 OneToMany도 같은 원리이다. parent를 findById로 조회할 경우, child가 한꺼번에 조회된다. 다른 쿼리 메소드로 조회할 경우 child를 조회하기 위한 추가 쿼리가 실행된다.
그러나 OneToMany에서는 페치 조인을 자유롭게 사용하기 어렵다. 먼저 페이징을 할 때, 페치 조인을 통해 데이터를 가져오면 Many에 해당하는 개수만큼 수가 늘어난다. 물론 이는 distinct를 사용하여 해결할 수 있다.
또한, OneToMany에 해당하는 여러 엔티티를 한꺼번에 조회하는 경우 페치 조인을 사용할 수 없다.
org.hibernate.loader.MultipleBagFetchException: cannot simultaneously fetch multiple bags:
일대다 연관관계에 있는 엔티티를 Set으로 바꾸면 해결할 수는 있지만, List 타입을 사용해야 하는 경우에는 다른 방법을 생각해야 한다. 그래서 Batch Size를 활용하여, N+1 문제를 방지해야 한다
다시 결론..!
OneToMany
|
3. QueryDSL
QueryDSL을 사용할 때 조인을 명시하지 않아도 조인이 필요한 경우에는 자동으로 조인 쿼리가 추가된다. 쿼리를 짜다보면 아무 생각 없이 조인을 추가하게 될 때가 많은데, 개인적으로 정한 규칙(?)은 다음과 같다.
조인을 명시해야 하는 경우
- ManyToOne 연관관계에서 페치 조인이 필요한 경우
- DTO 조회나 쿼리 조건에서 연관관계에 있는 필드가 필요한 경우 (id 제외)
- 연관관계에 없는 세타 조인
조인을 명시하지 않아도 되는 경우
- 일대다 연관관계에서 Lazy Loading 사용
- 연관관계에 있는 id 필드만 필요한 경우
4. 마무리
QueryDSL이나 JPA는 정말 편하지만, 내가 의도치 않은대로 쿼리가 실행되는 경우가 많아서 사용에 주의가 필요한 것 같다.
특히 실무에서는 findById보다는 직접 조건을 넣거나 쿼리를 생성하여 JPQL로 사용하는 경우가 많기 때문에 즉시 로딩 사용에 더 주의가 필요하다는 걸 느꼈다
연관관계도 잘 활용하면 편하고 좋지만 외래키를 사용하여 추가적인 쿼리로 조회하는 게 더 깔끔하고 명시적인 경우도 있는 것 같다. 역시 편하다고 다 좋은 게 아님 ...! 어쨌든 사용할 땐 잘 알고 사용하자
'BackEnd > Spring' 카테고리의 다른 글
[Spring/JPA] @Version을 통한 낙관적 락(Optimistic Lock) (1) | 2023.06.10 |
---|---|
[Spring] Kotlin + Spring boot 프로젝트에 Redis 적용 (1) | 2023.03.14 |
[Spring] 스프링 요청 파라미터 - Reqeust Param (required, defaultValue) (0) | 2022.12.12 |
[Spring] 로깅 logging (0) | 2022.12.12 |
[Spring] 스프링 MyBatis 연동 (0) | 2022.11.01 |