1. JPA N+1 문제
✔️ N+1 문제란?
연관된 엔티티를 조회할 때 1번의 쿼리 + N번의 추가 쿼리가 발생하는 문제이다.
List<Member> members = memberRepository.findAll();
for (Member member : members) {
System.out.println(member.getTeam.getName());
}
👉 실제 쿼리 흐름
SELECT * FROM member;
--- N번 (각 member의 team 조회)
SELECT * FROM team WHERE id =?:
-> 데이터의 개수가 많아질수록 성능 급격히 저하됨!
🔥 JPA N+1 문제 발생 원인
1. 지연 로딩 (Lazy Loading)의 동작 방식
연관된 객체를 즉시 조회하지 않고, 실제 해당 객체에 접근(사용)하는 시점에 쿼리를 실행한다.
메인 엔티티 리스트를 조회한 후, 루프를 돌며 연관 객체를 사용하는 코드에서 객체 개수(N)만큼 추가 쿼리가 발생하여 성능이 급격히 저하된다.
2. JPQL의 실행 메커니즘 (핵심 원인)
- JPQL은 엔티티 객체를 대상으로 하지만, 실행 시점에는 작성된 쿼리문 자체를 SQL로 번역하여 실행하는 특성이 있습니다.
// 예시: 모든 회원을 조회하는 JPQL
@Query("SELECT m FROM Member m")
List<Member> findAllMembers();
- 동작 과정:
JPQL은 연관 관계를 무시하고 오직 SELECT * FROM Member라는 SQL만 생성하여 실행한다.
DB로부터 회원 데이터를 가져와 영속성 컨텍스트에 담는다.
이후 연관된 객체(예: Team) 데이터가 필요한 시점에, 영속성 컨텍스트에 데이터가 없으므로 회원 하나하나마다 연관 데이터를 조회하는 쿼리를 별도로 전송하게 된다.
-> JPQL은 연관 엔티티를 자동으로 JOIN해서 가져오지 않기 때문에, 개발자가 의도치 않은 추가 쿼리가 발생하는 것입니다.
👍 N+1 문제 해결 방법
1. ⭐️⭐️⭐️ Fetch Join
@Query("SELECT * FROM Member m JOIN FETCH m.team")
- 한 번의 쿼리로 해결
- SQL의 INNER JOIN을 사용하여 연관 객체까지 한 번에 가져오므로 쿼리 1번에 해결
2. EntityGraph
@EntityGraph(attributePaths={"team"})
- 코드 변경 없이 Fetch 전략 지정 가능
- 기본적으로 OUTER JOIN을 사용하며, JPQL에 복잡한 FETCH 키워드를 직접 쓰지 않아도 됨
- 메서드 이름 기반 쿼리나 기본 findAll 등에서도 간편하게 Fetch 전략을 변경 가능
3. Batch Size
@BatchSize(size=100)
- 여러 개를 한 번에 조회 (IN 쿼리)
- N번 발생할 쿼리를 IN 절을 사용하여 1 + (N/BatchSize)번으로 획기적으로 줄여줌
- N+1 문제를 완전히 제거하지는 못하지만, 쿼리 횟수를 예측 가능한 수준으로 제어할 수 있는 안전장치 역할을 함.
2. 트랜잭션 ACID와 격리성 문제
✔️ ACID란?
- Atomicity : 모두 성공 or 모두 실패
- Consistency : 데이터 무결성 유지
- Isolation : 트랜잭션 간 간섭 없음
- Durability : 영구 저장
격리성이 깨질 때 발생하는 문제
- Dirty Read: 트랜잭션 A가 수정 중인(아직 커밋되지 않은) 데이터를 트랜잭션 B가 읽는 현상. (B가 읽은 데이터가 나중에 롤백될 위험이 있음)
- Non-Repetable Read: 한 트랜잭션 내에서 같은 데이터를 두 번 조회했을 때, 그 값이 다른 현상. (중간에 다른 트랜잭션이 값을 수정하고 커밋함)
- Phantom Read : 한 트랜잭션 내에서 일정한 범위의 쿼리를 두 번 실행했을 때, 첫 번째 결과에 없던 '유령(Phantom)' 레코드가 나타나는 현상. (중간에 다른 트랜잭션이 데이터를 삽입함)
해결책
✅ 해결책: 트랜잭션 격리 수준 (Isolation Level)
격리 수준은 "하나의 트랜잭션이 수행될 때, 다른 트랜잭션이 수정 중인 데이터를 어디까지 볼 수 있게 할 것인가"를 결정합니다.
1. READ UNCOMMITTED (Level 0)
- 정의: 다른 트랜잭션이 커밋하지 않은 데이터를 읽는 것을 허용
- 특징: 가장 낮은 격리 수준으로, 성능은 가장 빠르지만 데이터 정합성이 최악
- 문제: Dirty Read, Non-Repeatable Read, Phantom Read가 모두 발생
2. READ COMMITTED (Level 1)
- 정의: 트랜잭션이 커밋된 데이터만 읽을 수 있습니다.
- 특징: Oracle, PostgreSQL의 기본 격리 수준이며, 가장 대중적으로 사용됩니다.
- 방지: Dirty Read를 해결합니다.
- 한계: 한 트랜잭션 내에서 같은 데이터를 다시 읽었을 때 값이 바뀔 수 있는 Non-Repeatable Read는 여전히 발생합니다.
3. REPEATABLE READ (Level 2)
- 정의: 트랜잭션이 시작되기 전에 커밋된 내용에 대해서만 조회를 보장합니다.
- 특징: MySQL(InnoDB)의 기본 격리 수준입니다. 자신의 트랜잭션 번호보다 낮은 시점의 데이터만 읽는 MVCC(Multi-Version Concurrency Control) 기술을 사용합니다.
- 방지: Non-Repeatable Read를 해결합니다.
- 한계: 새로운 레코드가 삽입되는 Phantom Read는 발생할 수 있습니다. (다만, MySQL InnoDB는 넥스트 키 락을 통해 이 수준에서도 Phantom Read를 상당 부분 방지합니다.)
4. SERIALIZABLE (Level 3)
- 정의: 여러 트랜잭션이 동시에 실행되더라도, 마치 순차적으로 하나씩 실행되는 것처럼 동작하게 합니다.
- 특징: 가장 엄격한 격리 수준으로, 읽기 작업 시에도 공유 잠금(Shared Lock)을 획득해야 합니다.
- 방지: 모든 부정합 문제(Dirty, Non-Repeatable, Phantom Read)를 완벽히 해결합니다.
- 한계: 동시 처리 성능이 급격히 떨어지므로, 극도의 데이터 정합성이 필요한 경우가 아니면 지양합니다.
-> 대부분 READ COMMITTED를 사용하고, 데이터 정합성이 중요하면 REPEATABLE READ, 금융/결제에서는 SERIALIZABLE
한줄 정리 : JPA는 편하지만, 내부 동작을 모르면 성능 이슈가 터지기 쉽다!!
'Weekly Paper' 카테고리의 다른 글
| Weekly Paper #12 (0) | 2026.05.11 |
|---|---|
| Weekly Paper #10 (0) | 2026.04.01 |
| Weekly Paper #7 (0) | 2026.02.27 |
| Weekly Paper #6 (0) | 2026.02.09 |
| Weekly Paper#5 (0) | 2026.02.02 |