본문으로 건너뛰기
Advertisement

실전 고수 팁 — JPA 실무 성능 최적화의 3대 핵심 (N+1 방어, OSIV, 읽기 전용)

JPA를 쓰면 개발 속도가 비약적으로 상승하지만, 실무에서 DB 쿼리 개수와 트랜잭션 수명 제어에 실패하면 곧바로 대규모 장애로 직결됩니다.

1. N+1 문제 완벽 방어: Fetch Join 남용의 한계점

JPA 실무의 영원한 적, N+1 문제(1개의 쿼리로 N개의 자식 데이터가 1건씩 따로 질의되는 현상)를 방어하기 위해 가장 많이 쓰이는 것이 Fetch Join입니다. 하지만 Fetch Join은 페이징(Pagination)과 1:N 컬렉션 뼈대를 동시에 사용하면 DB 메모리가 아닌 애플리케이션 메모리 선상에서 풀스캔 정렬을 시도해 Out Of Memory 장애를 유발합니다.

💡 실무 해결책: 일대다 페이징 조합은 Fetch Join이 아니라 반드시 hibernate.default_batch_fetch_size 옵션을 글로벌로 적용해야 합니다.

spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 1000 # 지연 로딩 발생 시 개별 쿼리가 아닌 1000건 IN 절로 묶어 송신

이 옵션 하나만으로 N+1 쿼리가 통째로 1000개 단위의 WHERE id IN (?, ?...) 쿼리로 병합되어 DB 부하가 극적으로 해소됩니다.

2. OSIV (Open Session In View) 실무 옵션: 끄는 것이 원칙

스프링 부트는 기본적으로 OSIV = true로 동작합니다. 이 말은 컨트롤러나 뷰(View) 템플릿 단계에서도 계속 DB 커넥션(영속성 컨텍스트)을 잡고 유지해 지연 로딩(Lazy Loading)을 편안하게 해준다는 뜻입니다. 그러나 대규모 애플리케이션에서 사용자가 API 응답을 받기 전, 또는 외부 결제 API를 막 기다리는 중에도 DB 커넥션 풀을 놓지 않아 순식간에 Connection Pool 고갈 장애가 발생합니다.

spring:
jpa:
open-in-view: false # 실무에서는 필수로 false 선언!

💡 정책: false로 끈 상태에서, 지연 로딩은 오직 반드시 @Transactional이 선언된 서비스(Service) 계층 안에서 모두 초기화(DTO 변환)하여 컨트롤러로 넘겨주는 패턴이 필수적입니다.

3. 읽기 전용 트랜잭션(@Transactional(readOnly = true))

데이터 단순 조회(GET API) 서비스 클래스나 메서드 위에는 항상 통상적인 @Transactional 대신 readOnly = true를 달아주는 것이 백엔드 엔지니어의 핵심 매너입니다.

@Service
@Transactional(readOnly = true)
public class UserQueryService {

public UserDto getUser(Long id) { ... }
}
  1. JPA 스냅샷 미생성: JPA가 영속성 컨텍스트를 스캔해 Update 쿼리를 자동 발사하기 위한 메모리(Snapshot) 본을 만들지 않으므로 자원이 100% 절약됩니다.
  2. DB 부하 분산(Master-Slave): DB가 Replication 구조로 되어 있다면, readOnly 덕분에 자동으로 읽기 전용 쿼리가 슬레이브(Replica) DB로 라우팅되어 메인 쓰기 DB의 부하를 지켜줍니다.
Advertisement