Weekly Paper

Weekly Paper #9

승주우에요 2026. 3. 23. 09:49

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();
  • 동작 과정:
  1. JPQL은 연관 관계를 무시하고 오직 SELECT * FROM Member라는 SQL만 생성하여 실행한다.

  2. DB로부터 회원 데이터를 가져와 영속성 컨텍스트에 담는다.

  3. 이후 연관된 객체(예: 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