SpringBoot와 JPA를 도입한 프로젝트에서 개발자가 겪는 가장 흔한 성능 이슈는 단연코 데이터베이스 I/O 병목이다.
편리함 뒤에 숨겨진 JPA의 동작 방식을 이해하지 못하면, 간단한 조회 로직 하나가 수백 번의 쿼리를 유발하는 재앙을 초래한다.
특히 엔티티 간의 연관관계를 다루는 페치(Fetch) 전략은 애플리케이션의 응답 속도를 결정짓는 핵심 요소다.
본 글에서는 왜 실무에서 EAGER(즉시 로딩)가 금기시되는지, 그리고 LAZY(지연 로딩)를 Hibernate 프록시 메커니즘과 함께 어떻게 제어해야 N+1 문제를 해결할 수 있는지 분석한다.
즉시 로딩(EAGER)의 함정: 예측 불가능한 쿼리 지옥
많은 주니어 개발자들이 JPA Specification을 학습할 때, "연관된 데이터를 편하게 가져오기 위해" FetchType.EAGER를 설정하곤 한다.
하지만 이는 실무 트래픽을 감당해야 하는 프로덕션 환경에서는 시한폭탄과 같다.
@ManyToOne, @OneToOne의 기본값은 EAGER다. 이를 명시적으로 LAZY로 변경하지 않으면, 의도치 않은 조인 쿼리가 발생하여 DB 커넥션 풀을 고갈시킬 수 있다.
즉시 로딩은 엔티티를 조회할 때 연관된 모든 엔티티를 조인(Join)하여 한 번에 가져온다. 언뜻 보면 좋아 보이지만, 비즈니스 로직에서 연관 엔티티가 전혀 필요 없는 경우에도 불필요한 조인을 수행한다. 더 심각한 문제는 JPQL을 사용할 때 발생한다.
지연 로딩(LAZY)과 프록시, 그리고 N+1 해결책
성능 최적화의 제1원칙은 "모든 연관관계에 지연 로딩(LAZY)을 적용하라"이다.
FetchType.LAZY를 설정하면, JPA는 실제 엔티티 대신 가짜 객체인 프록시(Proxy)를 주입한다.
이후 실제 데이터가 필요한 시점(예: getTeam().getName())에 DB 쿼리가 실행된다.
하지만 지연 로딩만으로는 모든 문제가 해결되지 않는다. 리스트 조회 시 발생하는 악명 높은 N+1 문제(1번의 조회 쿼리 후 N번의 추가 쿼리 발생)가 기다리고 있기 때문이다.
이를 해결하기 위한 가장 확실한 방법은 Fetch Join을 사용하는 것이다.
// Bad Practice: EAGER 사용 또는 일반 지연 로딩 조회
// 결과: Member 조회 1회 + 각 Member의 Team 조회 N회 발생 (N+1 문제)
@Entity
public class Member {
@ManyToOne(fetch = FetchType.LAZY) // 실무 필수 설정
@JoinColumn(name = "team_id")
private Team team;
}
// Good Practice: JPQL Fetch Join 사용
// 결과: 단 한 번의 쿼리로 Member와 Team을 조인해서 가져옴
public interface MemberRepository extends JpaRepository<Member, Long> {
@Query("select m from Member m join fetch m.team")
List<Member> findAllWithTeam();
}
전략 비교 분석
다음은 Spring Data JPA 환경에서 각 전략이 시스템 리소스에 미치는 영향을 비교한 것이다.
| 전략 | 동작 시점 | 장점 | 단점 (Critical) |
|---|---|---|---|
| EAGER | 엔티티 조회 즉시 | 데이터 누락 방지 | 불필요한 조인 발생, N+1 문제 유발 가능성 매우 높음 |
| LAZY | 실제 사용 시점 | 초기 로딩 속도 빠름 | 초기화 시점에 쿼리 발생 (N+1), LazyInitializationException 주의 |
| Fetch Join | 명시적 조회 시점 | N+1 문제 완벽 해결 | 페이징 쿼리 시 메모리 부하 주의 (BatchSize로 보완) |
LAZY로 설정한다. 그리고 성능상 함께 조회해야 하는 곳에만 Fetch Join 또는 @EntityGraph를 적용하여 튜닝한다.
Conclusion
JPA 성능 최적화는 결국 "필요한 시점에 필요한 만큼만 쿼리를 날리는 것"으로 귀결된다.
즉시 로딩(EAGER)은 개발 단계에서는 편할지 몰라도 운영 단계에서는 성능 장애의 주범이 된다.
무조건적으로 지연 로딩(LAZY)을 기본 전략으로 채택하고, 성능 병목이 발생하는 구간(N+1)을 모니터링하여 Fetch Join으로 핀 포인트 최적화를 수행하는 것이 정답이다.
지금 당장 엔티티 코드를 열어 @ManyToOne 어노테이션에 fetch = FetchType.LAZY가 누락되어 있는지 확인해보길 바란다.
Post a Comment