LoGin
article thumbnail
반응형

 

JPA의 관심이 세계적으로 뜨거워지면서 많은 사람들이 공부하고 많은 조직이 JPA를 도입하고 있습니다.

 

JPA 지연로딩에 대해 많은 사람들이 강조하는데 나름 이해하고 공부한 내용을 정리해 보았습니다.

 

지연 로딩이란?

JPA는 엔티티(Entity) 간의 연관 관계를 자동으로 매핑해 주는 편리한 기능을 제공합니다.

이때 성능을 위해 fetch 전략을 설정하게 되는데, 가장 대표적인 것이 '지연 로딩(Lazy Loading)'입니다.

 

지연 로딩은 데이터를 실제로 사용할 때까지 SQL 쿼리를 실행하지 않는 방식입니다. 예를 들어,

@Entity
class Member(
    @Id @GeneratedValue val id: Long? = null,

    val name: String,

    @ManyToOne(fetch = FetchType.LAZY)
    val team: Team
)

Entity가 있다고 가정하고, member.team에 접근할 때까지 Team 데이터는 로딩되지 않습니다.

대신 프록시 객체를 들고 있게 되죠.

 

 

val member = memberRepository.findById(1L).get()
// team 데이터는 아직 쿼리 안 나감
val teamName = member.team.name // 여기서 쿼리 발생!

 

또,

LAZY를 기재하지 않으면 기본값인 EAGER 즉시 로딩이죠. 팀 데이터가 필요 없는데도 팀 테이블까지 조회하는 겁니다.
멤버가 많으면 그만큼 계속 조회하겠죠...
Lazy를 많이 사용하는데

그래서...

 

뭐가 문제 일까?

지연 로딩은 성능 최적화에 좋지만, 제대로 다루지 않으면 오히려 심각한 성능 저하를 초래합니다.

대표적인 문제가 바로 N+1 쿼리 문제입니다.

 

예를 들어, 아래와 같이 멤버 리스트를 조회하고 각 멤버의 팀명을 출력하는 코드를 보면

val members = memberRepository.findAll()
members.forEach {
    println(it.team.name)
}

 

SQL이 나가는 모습을 보면

  • select * from member → 전체 멤버 조회 (1회)
  • 각 멤버마다 select * from team where id =? → N번 쿼리 발생

즉, 총 N+1개의 쿼리가 발생합니다. 멤버 100명이면 쿼리도 101개가 나가게 되는 것이죠.

지연 로딩을 잘못 쓴 사례입니다.

 

추가로 설명하자면

val members = queryFactory
    .selectFrom(member)
    .fetch()

members.forEach {
    println(it.team.name) // 여기서 문제 발생!
}

 

아무 이상 없어 보이는 코드에서 내부적으로 N+1 문제가 발생하게 됩니다.

  1. selectFrom(member)는 member만 가져옴 → SQL 1번
  2. it.team.name을 호출하면 → JPA는 team을 지연로딩(LAZY)으로 별도 쿼리 실행
  3. member가 10명이면 team 쿼리도 10번 더 실행됨.

fetchJoin을 처음에 놓치게 되는 이유가 LAZY니까 자동으로 성능이 좋은 줄 알고 사용하거나, 실무 데이터가 작으면 문제가 안느 지고 결국 운영에서 대용량 리소스를 경험하게 되면 느껴지겠죠...

 

 

 

 

그렇다면...

 

해결 방법은?

여러 해결방법이 있지만 제가 해결한 방법은 Fetch Join 방법입니다.

QueryDSL을 사용하는데

queryFactory
    .selectFrom(member)
    .join(member.team).fetchJoin()
    .fetch()

fetchJoin()을 사용하면 한 번의 쿼리로 연관된 엔티티를 모두 가져올 수 있어, N+1 문제를 해결할 수 있습니다.

 

 

개인적으로

구현체(Impl) QueryDSL에서 DTO로 바로 뽑는 방식을 사용하긴 합니다.

queryFactory
    .select(
        Projections.constructor(
            MemberDto::class.java,
            member.id,
            member.name,
            team.name
        )
    )
    .from(member)
    .join(member.team, team)
    .fetch()

이렇게 하면 member와 team을 join 한 후, 필요한 컬럼만 SQL에서 바로 select 해서 DTO로 매핑하기 때문에

처음부터 필요한 데이터만 가져오므로 프록시 객체나 지연 로딩을 사용할 일이 아예 없죠

 

대신 JPA 함수 네이밍 쿼리 자동완성 기능 사용할 땐 고려해야겠죠.

728x90
반응형
profile

LoGin

@LoGinShin

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!