옌의 로그

[Spring] JPA 연관관계 매핑과 성능 최적화 (Fetch Join vs. Open-in-view) 본문

스터디/스프링

[Spring] JPA 연관관계 매핑과 성능 최적화 (Fetch Join vs. Open-in-view)

dev-yen 2025. 8. 25. 00:33

 

1. 서론: 연관관계, 그리고 성능 고민

최근에 투입된 프로젝트는 다양한 도메인 간에 1:N 관계가 아주 많은 구조를 가지고 있었다.

상품과 카테고리, 유저와 구매내역, 상품과 메타데이터 등 대부분의 엔티티가 서로 깊게 연관돼 있었고,

그 연관관계들이 곧바로 화면 응답이나 API 출력으로 이어지다 보니,

JPA의 Lazy Loading, N+1 문제, fetch join, open-in-view 설정 같은 키워드들이 일상처럼 따라다녔다.

 

특히, 연관된 데이터를 DTO로 변환해야 하는 경우가 많았는데, 이때마다 트랜잭션이 이미 종료돼 있어서 Lazy 객체 접근 시 LazyInitializationException이 터지는 일이 많았다.

 

그 과정에서 자연스럽게 다음과 같은 질문이 생겼다:

  • 지연 로딩은 어떻게 최적화할 수 있을까?
  • 트랜잭션 범위를 어디까지 가져가야 할까?
  • Open-in-view를 유지해도 괜찮을까?
  • fetch join은 언제, 어떻게 쓰는 게 좋을까?

이번 글에서는 그 고민의 결과로 정리한 내용들을 바탕으로,

JPA 연관관계 매핑과 성능 최적화 전략: Fetch Join vs. Open-in-view를 비교해보려 한다.

 


 

2. Lazy Loading, 왜 문제인가?

기본적으로 JPA의 연관관계는 @OneToMany, @ManyToOne 등에서 지연 로딩(LAZY) 으로 설정하는 게 일반적이다.

이는 객체를 조회할 때, 연관된 엔티티를 즉시 불러오지 않고, 실제로 접근할 때 쿼리를 날리는 방식이다.

@Entity
public class Product {
    @ManyToOne(fetch = FetchType.LAZY)
    private Category category;
}

 

예시: 상품 리스트를 조회하는 경우

List<Product> products = productRepository.findAll();
for (Product product : products) {
    System.out.println(product.getCategory().getName());
}

위 코드는 Product를 조회하는 쿼리 1번 + 각 Category를 조회하는 쿼리 N번 = 총 N+1번의 쿼리가 발생한다.

 


 

3. Fetch Join이란?

Fetch Join 은 연관 엔티티를 SQL 조인으로 한 번에 조회하도록 하는 JPA의 기능이다.

 

 

예시

@Query("SELECT p FROM Product p JOIN FETCH p.category")
List<Product> findAllWithCategory();

이렇게 하면 쿼리는 단 1번, Product와 Category를 동시에 조회하게 된다.

 

 

장점

  • N+1 문제 해소
  • 트랜잭션 종료 이후에도 Lazy 객체 사용 가능 (이미 로딩됨)

 

 

단점

  • 복잡한 연관관계에서는 데이터 중복이 발생할 수 있다
    • 카디널리티 곱 문제 발생 (Cartesian Product) < 이건 추후에 포스팅 하겠다 . .
  • 여러 fetch join을 한 번에 사용할 수 없음 (JPA 제약사항)

 


 

4. Open-in-view란?

Open-in-view는 트랜잭션 범위를 컨트롤러까지 열어두는 방식이다.

즉, 서비스에서 트랜잭션이 끝났더라도, 컨트롤러에서 Lazy 객체를 접근할 수 있도록 영속성 컨텍스트를 유지시킨다.

 

 

Spring Boot 기본 설정

spring.jpa.open-in-view=true 로 기본 설정되어 있다.

 

 

장점

  • fetch join 없이도 연관 객체 접근 가능
  • 코드가 간결해짐 (DTO 변환 시도까지 고려 안해도 됨)

 

 

단점

  • 컨트롤러, 뷰 레이어에서 DB 연결을 유지 → 커넥션 낭비
  • Lazy 로딩 시점이 불명확 → 디버깅 어려움
  • 비즈니스 로직과 프레젠테이션 로직이 뒤섞일 수 있음

 

 

 

5. 비교 정리: 언제 무엇을 써야 할까?

항목 Fetch Join Open-in-view
트랜잭션 범위 서비스 계층 안에서 종료 컨트롤러까지 연장
Lazy 접근 가능 시점 서비스 계층 안 컨트롤러까지 가능
장점 성능 최적화, 명확한 제어 코드 간결, 초기 설정 쉬움
단점 복잡한 경우 조인 지옥 (카디널리티 곱 발생) 커넥션 점유, 버그 유발 위험
추천 상황 명확한 DTO 응답, 성능 중요 간단한 API, 작은 규모에서 OK

 


 

6. 실무에서 내가 겪은 상황

실제 내가 개발한 상품관리 시스템에서도 비슷한 고민이 있었다.

상품 리스트에 카테고리명, 등록자 정보 등 여러 연관 객체가 포함되어 있었고, 이를 JSON 응답으로 내려줘야 했다.

 

처음엔 Open-in-view를 열어두고 그대로 Entity → JSON 으로 넘겼지만, 다음과 같은 문제가 발생했다:

 

  • 페이지가 많아질수록 커넥션 수가 급격히 증가
  • 필드 하나만 접근해도 DB 쿼리가 발생해 예측이 어려움
  • DTO로 바꾸려고 하자 Lazy 로딩 문제로 LazyInitializationException 발생

 

→ 결국 fetch join + DTO 변환 방식으로 변경했고, 그때부터 응답 속도와 안정성이 확연히 좋아졌다.

 

 

7. 결론: 내가 선택한 방식

 

실무에서는 다음과 같은 원칙을 정했다:

 

  1. API 응답은 무조건 DTO로 분리한다.
  2. 서비스 계층 내에서 필요한 모든 데이터를 명시적으로 가져온다.
    (→ fetch join 또는 @EntityGraph 활용)
  3. Open-in-view는 false로 명시적으로 꺼둔다. (개발할 때만 켜두기 ㅎㅎ)
    (→ 커넥션 예측 가능성 확보)

 

결과적으로, 트랜잭션 경계가 명확해지고, 테스트와 유지보수가 쉬워졌다.

 


 

마무리하며..

  • Lazy 로딩은 기본적으로 좋은 선택이지만, 언제 로딩될지 명확하지 않으면 위험하다.
  • Open-in-view는 초기에 개발단계에선 편하지만, 운영으로 넘어가면서 꼭 꺼야한다.
  • fetch join은 명시적으로 필요한 연관 데이터를 가져오고, 트랜잭션 안에서 DTO로 변환하는 구조가 가장 이상적이다.
Comments