에디블로그
Engineer's Field Notes

AI 자동화로 매일
한 편씩 쓰는
엔지니어 운영 노트

Claude Code · 자동화 파이프라인 · 사고 회고까지. 잘 굴러간 기록 + 깨진 흔적도 같이 남깁니다.

사람이 할 수 있는 일은,
AI도 할 수 있어야 합니다.
매일 한 편 쓰면서 검증 중.
— 이번 주 가장 많이 읽힌 글 TOP 3
백엔드/데이터 & DB

[JPA] 성능 트러블슈팅 가이드: N+1, 느린 조회, 대량 처리, LazyInitializationException 증상별 대응

반응형
[JPA] 성능 트러블슈팅 가이드: N+1, 느린 조회, 대량 처리, LazyInitializationException 증상별 대응

[JPA] 성능 트러블슈팅 가이드: N+1, 느린 조회, 대량 처리, LazyInitializationException 증상별 대응

"이 API만 느려요", "운영에서 갑자기 OOM이 났어요", "간헐적으로 Lazy 예외가 터져요" — JPA 문제는 증상이 멀리서 나타나서 원인을 찾기 어려워 보여요. 그런데 막상 분류해보면 패턴이 몇 개 안 돼요. 이 글은 JPA 성능·정합성 이슈를 증상별로 묶고, 각 증상에서 어디를 보고 어떤 무기를 꺼낼지 정리한 진단 허브예요.

깊은 내용은 시리즈의 해당 글로 연결해요. 시리즈를 처음 보면 영속성 컨텍스트부터 읽는 걸 권해요.

01. JPA 트러블슈팅, 순서는 측정 → 분류 → 대응

증상이 무엇이든 SQL 로그부터예요. 추측으로 fetch join부터 박으면 엉뚱한 데를 고쳐요.

JPA 성능 트러블슈팅 진단 맵. SQL 로그와 쿼리 수를 측정한 뒤, 같은 SELECT 반복은 N+1, 특정 화면 느림은 거대 엔티티 그래프, 대량 처리 느림은 배치 미적용과 1차 캐시 비대, 간헐 예외는 트랜잭션 경계 문제로 분류해 대응한다

측정 도구는 단순해요. 로컬·테스트에서 SQL 로그(또는 p6spy)를 켜고, 화면 하나를 열 때 쿼리가 몇 번, 각각 얼마나 걸리는지 봐요. 운영에서는 APM의 쿼리 수·슬로우 쿼리 지표가 같은 역할을 해요.

02. 증상: 같은 SELECT가 반복해서 나가요

N+1이에요. 목록 1쿼리 + 연관 N쿼리 구조죠.

JPA SQL 로그 3대 패턴. 같은 모양의 SELECT 반복은 N+1, firstResult maxResults with collection fetch 경고는 메모리 페이징, 조회 API에서 나가는 UPDATE는 의도치 않은 더티 체킹 신호다

대응은 연관의 모양으로 갈라요. ~ToOne은 fetch join·@EntityGraph로 한 쿼리에, 컬렉션은 default_batch_fetch_size로 IN 묶음 조회예요. 컬렉션 fetch join + 페이징은 메모리 페이징 함정이 있으니 경고 로그(HHH90003004)를 절대 무시하지 말고요. 전부 N+1 편에서 깊게 다뤘어요. EAGER가 숨은 범인일 때도 많으니 @ManyToOne 기본값(EAGER)도 점검해요 — 지연 로딩 편 참고예요.

03. 증상: 특정 화면만 유독 느려요

쿼리 수는 정상인데 한 화면만 느리면, 한 번에 끌고 오는 데이터가 너무 큰 거예요. 엔티티 그래프를 통째로 로딩하거나(안 쓰는 컬럼·연관까지), 컬렉션 fetch join 페이징이 전체를 메모리에 올리고 있는 경우예요.

이럴 땐 그 화면 전용으로 필요한 컬럼만 DTO로 직접 조회하는 게 가장 효과적이에요. 엔티티의 생명주기 기능(더티 체킹 등)이 필요 없는 순수 조회 화면에서 엔티티를 고집할 이유가 없어요. 조회 전용 쿼리 분리는 OSIV 편의 쿼리 서비스 분리와도 이어져요.

04. 증상: 대량 처리가 느리거나 OOM이 나요

두 가지를 순서로 봐요. 첫째, INSERT가 한 건씩 나가는지 — batch_size + rewriteBatchedStatements 설정과, 배치를 구조적으로 막는 IDENTITY 전략인지 확인해요. 둘째, 루프가 길어질수록 느려지다 OOM이면 1차 캐시 비대예요 — 주기적 flush() + clear()를 넣어요.

대량 수정·삭제는 엔티티 루프 대신 JPQL 벌크 연산으로 DB에 직행하고, 1차 캐시 불일치는 clearAutomatically로 막아요. 전부 배치 편에 있어요. 그리고 정말 큰 적재는 JdbcTemplate 직행이 솔직한 답일 수 있다는 것도요.

05. 증상: 조회만 했는데 UPDATE가 나가요

의도치 않은 더티 체킹이에요. 트랜잭션 안에서 영속 엔티티의 값을 어딘가에서 바꾼 거예요. 화면용 가공을 엔티티에 직접 하는 코드가 단골 범인이에요. 가공은 DTO로 분리하고, 진짜 수정이 아닌 조회 트랜잭션은 @Transactional(readOnly = true)로 두면 스냅샷 비교 자체를 건너뛰어 성능도 좋아져요. 원리는 영속성 컨텍스트 편에 있어요.

06. 증상: 예외·정합성이 간헐적으로 깨져요

JPA 트랜잭션 경계 증상 분기. LazyInitializationException은 트랜잭션 밖 프록시 초기화, 동시 수정 데이터 증발은 갱신 분실로 Version 낙관적 락, 커넥션 풀 타임아웃은 OSIV 점유로 open-in-view off가 대응이다
  • LazyInitializationException — 트랜잭션 밖(준영속)에서 프록시를 초기화한 거예요. 트랜잭션 안에서 fetch join으로 로딩 + DTO 변환이 정석이에요. 지연 로딩 편 참고.
  • 동시 수정에서 변경이 증발해요 — 갱신 분실이에요. @Version 낙관적 락으로 들키게 만들고, 충돌이 잦으면 비관적 락으로 줄을 세워요. 락 편 참고.
  • 트래픽 몰리면 커넥션 풀 타임아웃 — OSIV가 커넥션을 응답 끝까지 붙잡는 패턴을 의심해요. DB는 한가한데 풀이 마르면 거의 확정이에요. OSIV 편 참고.
  • 외래키가 NULL로 저장돼요 — 연관관계 주인이 아닌 쪽에만 값을 넣은 거예요. 연관관계 편 참고.

07. 미리 잡는 습관 두 가지

첫째, 화면당 쿼리 수를 보는 습관이에요. 로컬에서 SQL 로그를 켜두면 N+1·메모리 페이징·의도치 않은 UPDATE가 운영 가기 전에 다 보여요. 둘째, 핵심 조회는 테스트로 쿼리 수를 고정해요. Hibernate Statistics로 "이 조회는 2쿼리 이내"를 단언해두면, 연관이 추가될 때 회귀가 잡혀요. JPA 성능 사고는 거의 전부 로그에 예고편이 있어요.

증상 → 무기, 다시 한 장으로

JPA 트러블슈팅은 SQL 로그로 측정해요. 증상을 분류해요. 해당 무기를 꺼내는 일이에요. 증상별로 갈 곳은 이래요.

  • 같은 SELECT 반복 → N+1. fetch join·batch size로.
  • 특정 화면만 느림 → 거대한 엔티티 그래프. DTO 직접 조회로.
  • 대량 처리 느림·OOM → 배치 미적용·1차 캐시 비대. batch 설정 + flush/clear로.
  • 예외·정합성 깨짐 → 트랜잭션 경계 문제. Lazy·락·OSIV를 봐요.

모든 무기의 원리는 결국 영속성 컨텍스트 하나로 통해요. 영속성 컨텍스트 · 지연 로딩과 프록시 · N+1 · 연관관계 · 낙관적·비관적 락 · OSIV · 배치 insert · ID 생성 전략을 증상 관점에서 다시 엮은 글이었어요. 이 위층을 다 봤으니, 이제 그 아래층 — MySQL 인덱스와 트랜잭션으로 내려갑니다.

출처: Hibernate User Guide — Performance Tuning and Best Practices · Spring Data JPA Reference

반응형

📚 같이 보면 좋은

"이 포스팅은 쿠팡 파트너스 활동의 일환으로, 일정액의 수수료를 제공받습니다."