1. 들어가며
앞서 우리는 JPA의 기본 개념과 프록시, 그리고 로딩 전략(LAZY / EAGER)이 무엇인지 살펴보았다. 이제부터는 이 개념들이 실제 서비스 코드에서 어떤 문제를 만들어내는지, 그리고 그 문제를 어떻게 해결할 수 있는지를 본격적으로 다뤄보려 한다. 이번 장의 핵심 주제는 JPA를 사용하는 개발자라면 반드시 한 번쯤은 마주하게 되는 N+1 문제다.
2. N+1 문제란 무엇인가
N+1 문제는 JPA에서 연관 관계를 가진 엔티티를 조회할 때, 의도보다 훨씬 많은 SQL이 실행되는 현상을 의미한다. 이름 그대로 한 번의 조회 쿼리(1) 이후에, 연관된 엔티티를 가져오기 위해 N번의 추가 쿼리가 실행되는 구조다. 가장 전형적인 흐름은 다음과 같다.
- 특정 엔티티 목록을 조회하는 쿼리가 1번 실행된다.
- 조회된 엔티티 각각에 대해 연관 엔티티를 조회하는 쿼리가 N번 실행된다.
결과적으로 총 1 + N번의 SQL이 실행되며, 데이터 건수가 많아질수록 성능 저하는 기하급수적으로 커진다. 이를 학교 상황에 비유해보자. 담임 선생님이 한 반 학생 30명의 성적을 확인해야 한다고 가정해보자. 학생 명단은 한 번에 조회할 수 있지만, 각 학생의 성적은 개별 파일로 따로 관리되고 있다면 다음과 같은 일이 벌어진다.
-- 학생 목록 조회
SELECT * FROM students WHERE class_id = 1;
-- 학생별 성적 조회 (30번 반복)
SELECT * FROM grades WHERE student_id = 1;
SELECT * FROM grades WHERE student_id = 2;
...
SELECT * FROM grades WHERE student_id = 30;
학생이 30명이면 31번의 쿼리가 실행되고, 학생이 1,000명이라면 1,001번의 쿼리가 실행된다. 반면 학생과 성적을 조인해서 한 번에 조회할 수 있다면, 단 하나의 쿼리로 모든 정보를 가져올 수 있다.
SELECT *
FROM students s
JOIN grades g ON s.id = g.student_id
WHERE s.class_id = 1;
이처럼 N+1 문제는 연관 데이터를 어떻게 로딩하느냐에 따라 성능을 극단적으로 갈라놓는 요소다. 문제를 해결하기 위해서는 먼저 JPA가 언제, 어떤 방식으로 SQL을 실행하는지를 정확히 이해해야 한다.
3. N+1 사례(1) – 지연 로딩과 컬렉션 반복 접근
지연 로딩(LAZY)은 연관 엔티티를 실제로 사용할 때까지 조회를 미루는 전략이다. 개념 자체는 매우 합리적이지만, 컬렉션을 반복적으로 접근하는 코드와 결합되면 N+1 문제의 가장 흔한 원인이 된다. 다음은 Hotel과 Room이 1:N 관계를 맺고 있는 상황이다. 모든 호텔을 조회한 뒤, 방이 하나 이상 있는 호텔만 필터링하는 서비스 코드다.
@Transactional(readOnly = true)
public List<Hotel> getAvailableHotels() {
List<Hotel> hotels = repository.findAll();
return hotels.stream()
.filter(hotel -> !hotel.getRooms().isEmpty())
.collect(Collectors.toList());
}
겉보기에는 단순한 코드지만, 실행 로그를 보면 다음과 같은 흐름이 나타난다.
-- Hotel 조회 (1번)
SELECT id, name FROM hotel;
-- Room 조회 (Hotel 수만큼 반복)
SELECT * FROM room WHERE hotel_id = ?;
SELECT * FROM room WHERE hotel_id = ?;
이유는 명확하다.
- findAll() 호출 시점에는 Hotel만 조회되고, rooms는 프록시 상태로 남아 있다.
- 스트림에서 hotel.getRooms()에 접근하는 순간 프록시가 초기화된다.
- 이 작업이 호텔 수(N)만큼 반복되며, 결과적으로 N번의 추가 쿼리가 실행된다.
즉, 지연 로딩된 컬렉션을 루프 안에서 접근하는 순간 N+1 문제는 거의 필연적으로 발생한다.
4. N+1 사례(2) – 즉시 로딩(EAGER)이라고 안전하지 않다
그렇다면 지연 로딩 대신 즉시 로딩(EAGER)을 사용하면 문제가 해결될까? 직관적으로는 연관 엔티티를 한 번에 조인해서 가져올 것처럼 보인다.
@OneToMany(mappedBy = "hotel", fetch = FetchType.EAGER)
private List<Room> rooms;
하지만 findAll()과 같은 JPQL 기반 조회를 사용하면, 즉시 로딩임에도 불구하고 여전히 N+1 문제가 발생할 수 있다. 이유는 JPQL이 문자열 기반 쿼리이며, 엔티티에 설정된 로딩 전략이 항상 하나의 JOIN SQL로 변환된다는 보장이 없기 때문이다. Spring Data JPA의 findAll()은 내부적으로 다음과 같은 형태의 JPQL을 생성한다.
SELECT e FROM Entity e
이 과정에서 Hibernate는 우선 엔티티 자체를 조회한 뒤, 즉시 로딩이 설정된 연관 컬렉션을 추가 쿼리로 로딩할 수 있다. 즉시 로딩은 “같은 트랜잭션 안에서 바로 로딩한다”는 의미이지, “항상 조인으로 한 번에 가져온다”는 의미가 아니다.
✅ EAGER TYPE을 사용하면 모두 JOIN을 하는것처럼 생각하지만, JPQL과 함께 사용되는 경우에는 EAGER 설정이 무시될 수 있음.
5. 해결 전략(1) - @BatchSize 사용
@BatchSize는 Hibernate 전용 기능으로, 지연 로딩 시 연관 엔티티를 IN 절로 묶어서 조회하도록 한다.
@BatchSize(size = 2)
@OneToMany(mappedBy = "hotel", fetch = FetchType.LAZY)
private List<Room> rooms;
이 설정을 적용하면, 호텔이 5개일 때 Room 조회 쿼리는 다음과 같이 실행된다.
SELECT * FROM room WHERE hotel_id IN (?, ?);
SELECT * FROM room WHERE hotel_id IN (?, ?);
SELECT * FROM room WHERE hotel_id = ?;
N번의 쿼리가 3번으로 줄어들며, N+1 문제를 어느 정도 완화할 수 있다. 다만 배치 크기만큼 항상 미리 로딩되기 때문에, 실제로 필요하지 않은 데이터까지 가져올 수 있다는 단점이 있다. 배치 크기 설정은 반드시 튜닝이 필요하다.
6. 해결 전략(2) - Fetch Join 사용
Fetch Join은 JPQL에서 제공하는 표준 기능으로, 연관 엔티티를 JOIN으로 함께 조회한다.
SELECT h FROM Hotel h JOIN FETCH h.rooms
이 방식을 사용하면 Hotel과 Room을 단 하나의 SQL로 조회할 수 있으며, N+1 문제를 가장 확실하게 제거할 수 있다. 다만 다음과 같은 제약을 반드시 고려해야 한다.
- 1:N 관계에서 조인 결과 row 수가 증가한다.
- 여러 컬렉션을 동시에 Fetch Join하면 row 폭증이나 예외가 발생한다.
- 컬렉션 Fetch Join은 페이징이 사실상 불가능하다.
따라서 Fetch Join은 단일 조회에는 매우 강력하지만, 범용적인 해법은 아니다.
7. 해결 전략(3) - @EntityGraph 사용
@EntityGraph는 JPA 표준 기능으로, 리포지토리 메서드 단위로 Fetch Join을 선언할 수 있다.
@EntityGraph(attributePaths = "rooms")
List<Hotel> findAll();
내부적으로는 Fetch Join과 동일하게 동작하므로 N+1 문제를 효과적으로 해결할 수 있다. 다만 Fetch Join과 동일한 제약(페이징 불가, 다중 컬렉션 문제)을 그대로 가진다는 점, 그리고 문자열 기반 경로 지정으로 인해 리팩터링 시 취약하다는 점을 유의해야 한다.
8. N+1 사례 (3) – 비효율적인 OneToOne 연관 관계 설계
앞서 살펴본 N+1 사례들은 주로 로딩 전략의 설정이나 JPQL 사용에서 비롯된 문제들이었다. 이번에 다룰 사례는 근본적으로 다른 차원의 문제다. 연관 관계 자체를 비효율적으로 설계했을 때 발생하는 구조적인 N+1 문제다. JPA에서 제공하는 여러 연관 관계 중에서도 @OneToOne 관계는 외래 키(FK)의 위치와 로딩 전략 설정에 따라 구조적으로 N+1 문제가 발생하기 쉬운 특징을 가지고 있다. 특히 FK가 연관 엔티티 쪽 테이블에 존재하는 경우, 지연 로딩을 설정했더라도 기대와 다르게 추가 쿼리가 반복적으로 실행될 수 있다.
8.1 OneToOne 연관 관계에서 발생하는 문제 상황
8.1.1 기본 개념: @OneToOne이란 무엇인가?
@OneToOne은 "하나 대 하나" 관계를 의미한다. 실생활에서의 예를 들면:
- 한 사람(사원)에게는 하나의 사원증만 존재한다
- 한 컴퓨터에는 하나의 모니터만 연결되어 있다
- 한 차량에는 하나의 번호판만 부여된다
이러한 관계를 데이터베이스에서 구현할 때는 두 가지 설계 방식이 존재한다.
8.1.2 FK 위치에 따른 두 가지 설계 방식
방식 A: 외래키(FK)를 주 엔티티 테이블에 두기
컴퓨터 테이블
ID | 이름 | 모니터_ID(FK) ← FK 소유
1 | 맥북프로 | 100
2 | 데스크탑 | 101
모니터 테이블
ID | 크기
100 | 27인치
101 | 32인치
→ 컴퓨터가 모니터의 ID를 직접 알고 있다.
방식 B: 외래키(FK)를 연관 엔티티 테이블에 두기
컴퓨터 테이블
ID | 이름
1 | 맥북프로
2 | 데스크탑
모니터 테이블
ID | 크기 | 컴퓨터_ID(FK) ← FK 소유
100 | 27인치 | 1
101 | 32인치 | 2
→ 모니터가 컴퓨터의 ID를 알고 있다.
문제는 방식 B(외래키가 모니터 테이블에 있는 경우)에서 발생한다.
8.1.3 문제의 코드 예시
다음은 Computer와 Monitor 간의 양방향 @OneToOne 매핑 예시다.
// Computer 엔티티 - FK를 소유하지 않음
@Entity
public class Computer {
@Id
private Long id;
@OneToOne(mappedBy = "computer", fetch = FetchType.LAZY)
private Monitor monitor; // 지연 로딩으로 설정
}
// Monitor 엔티티 - FK를 소유함
@Entity
public class Monitor {
@Id
private Long id;
@OneToOne(fetch = FetchType.EAGER) // 기본값: 즉시 로딩
@JoinColumn(name = "computer_id")
private Computer computer;
}
이 구조에서 모든 Computer를 조회하는 findAll() 쿼리를 실행하면, 예상과 달리 다음과 같은 쿼리 흐름이 발생한다.
8.1.4 예상 vs 실제 실행 결과
개발자의 예상:
- Computer 테이블을 조회하는 쿼리가 1번 실행된다.
- 끝!
실제 발생하는 쿼리:
- Computer 테이블을 조회하는 쿼리 1번 실행
- Computer 1에 대한 Monitor 조회 쿼리 1번 실행
- Computer 2에 대한 Monitor 조회 쿼리 1번 실행
- Computer 3에 대한 Monitor 조회 쿼리 1번 실행
- ... (Computer N개라면 N번 실행)
특이한 점은 코드 상에서 monitor 필드에 접근하지 않았음에도 불구하고 Monitor를 조회하는 쿼리가 발생한다는 것이다. 이는 단순한 로딩 전략 설정 문제라기보다는, JPA 프록시의 동작 방식과 FK 위치가 맞지 않기 때문에 발생하는 구조적인 문제다.
8.1.5 단방향 vs 양방향: 어떤 경우에 문제가 발생하는가?
경우 1: 단방향 @OneToOne (문제 없음)
@Entity
public class Computer {
@OneToOne
@JoinColumn(name = "monitor_id") // Computer가 FK 소유
private Monitor monitor;
}
// Monitor는 Computer를 알지 못함
→ 이 경우는 문제가 발생하지 않는다. Computer가 Monitor의 ID를 직접 알고 있기 때문이다.
경우 2: 양방향 @OneToOne (문제 발생)
@Entity
public class Computer {
@OneToOne(mappedBy = "computer") // mappedBy 사용 = FK 소유하지 않음
private Monitor monitor;
}
@Entity
public class Monitor {
@OneToOne
@JoinColumn(name = "computer_id") // FK 소유 = 관계의 주인
private Computer computer;
}
→ 여기서 문제가 발생한다! FK가 Monitor에 있고, Computer는 mappedBy로 따라가는 입장이기 때문이다.
핵심은 양방향 관계에서 mappedBy를 사용하는 쪽(관계의 주인이 아닌 쪽)이 지연 로딩 문제에 취약하다는 점이다.
8.2 왜 지연 로딩인데도 N+1이 발생할까?
8.2.1 JPA 프록시의 작동 원리
JPA의 지연 로딩은 프록시(Proxy)를 기반으로 동작한다. 프록시는 다음과 같은 전제 조건을 가진다:
- 프록시는 생성 시점에 연관 엔티티의 ID(PK)를 알고 있어야 한다
- 실제 데이터가 필요해질 때, 이 ID를 기반으로 DB에서 엔티티를 조회한다
8.2.2 구조적 문제: ID 정보의 부재
문제의 구조에서는 Computer 테이블에 Monitor의 ID를 알 수 있는 컬럼이 존재하지 않는다. FK가 Monitor 테이블에 있기 때문에, Computer를 조회하는 시점에는 연관된 Monitor의 PK를 알 수 없다.
| Computer ID | Monitor ID |
| 1 | ???? |
| 2 | ???? |
이로 인해 Hibernate는 다음과 같은 선택을 하게 된다:
- "연관된 Monitor의 ID를 모르니까 프록시를 만들 수 없겠네?"
- "그럼 어쩔 수 없이 지금 당장 Monitor를 조회해보자"
8.2.3 문제를 더 악화시키는 즉시 로딩의 악순환
여기에 더해, Monitor → Computer 연관 관계는 즉시 로딩으로 설정되어 있다. 이 때문에 Monitor를 조회하는 순간 다시 Computer와의 JOIN이 발생한다. 결과적으로 다음과 같은 비효율적인 쿼리 흐름이 만들어진다:
- Computer 목록 조회 (쿼리 1번)
- 각 Computer마다 Monitor 조회 (쿼리 N번)
- Monitor 조회 시 다시 Computer JOIN (N번의 쿼리 내부에서 발생)
이 문제는 단순히 지연 로딩 옵션을 EAGER에서 LAZY로 바꾸는 것만으로는 해결되지 않는다. 근본 원인은 연관 관계 설계 자체에 있다.
8.3 Fetch Join을 통한 1차적인 해결
8.3.1 Fetch Join의 작동 방식
이 문제를 처음 마주했을 때 가장 먼저 떠올릴 수 있는 해결책은 Fetch Join이다. Computer와 Monitor를 한 번의 쿼리로 함께 조회하도록 명시하면, 앞서 발생했던 N+1 문제는 즉시 사라진다.
@Query("SELECT c FROM Computer c JOIN FETCH c.monitor")
List<Computer> findAllWithMonitor();
이 쿼리는 Hibernate에게 다음과 같이 지시한다:
"이번 조회에서는 지연 로딩 여부와 상관없이 Computer와 Monitor를 반드시 함께 가져와라"
8.3.2 Fetch Join의 효과
그 결과, Computer와 Monitor는 하나의 JOIN 쿼리로 조회되고, Computer 개수와 무관하게 쿼리는 단 한 번만 실행된다. 즉, Fetch Join은 현재 발생한 N+1 문제를 빠르게 해결해주는 효과적인 방법이다.
8.3.3 Fetch Join의 한계
하지만 여기서 중요한 점을 짚고 넘어가야 한다. Fetch Join은 '이 특정 조회 하나'에 대한 해결책일 뿐, 연관 관계 설계 자체의 문제를 근본적으로 해결해주지는 않는다는 점이다. Fetch Join을 사용하지 않는 다른 조회 메서드에서는 여전히 동일한 N+1 문제가 발생할 수 있다. 즉, 현재 구조는 여전히 "Fetch Join을 쓰지 않으면 위험한 연관 관계"로 남아 있게 된다.
8.4 Fetch Join만으로는 부족한 이유
8.4.1 실무에서의 적용 한계
Fetch Join은 특정 쿼리에서 성능 문제를 해결하는 데는 매우 유용하지만, 모든 조회에 항상 Fetch Join을 적용할 수는 없다. 다음과 같은 경우에는 적용이 어렵거나 부적합하다:
- 단순 목록 조회: 모든 데이터가 필요하지 않은 경우
- 페이징이 필요한 화면: Fetch Join과 페이징의 호환성 문제
- 여러 조건에 따라 재사용되는 쿼리: 상황에 따라 JOIN이 필요할 수도 있고 필요 없을 수도 있는 경우
8.4.2 근본적인 해결의 필요성
Fetch Join을 매번 적용하는 것은 코드의 복잡성을 증가시키고, 실수할 가능성을 높인다. 무엇보다 중요한 것은, 설계 단계에서 예방할 수 있는 문제를 런타임에서 패치로 해결하고 있다는 점이다. 따라서 OneToOne 관계에서는 단순히 Fetch Join을 추가하는 것을 넘어서, 외래 키(FK)의 위치와 연관 관계 설계 자체를 함께 고민해야 한다.
8.5 FK 위치에 따른 설계 선택과 트레이드오프
@OneToOne 관계에서 FK를 어디에 두느냐는 단순한 DB 설계 문제가 아니라, JPA의 로딩 방식과 직결되는 중요한 선택이다.
8.5.1 FK를 Computer 테이블에 두는 경우 (방식 A)
@Entity
public class Computer {
@OneToOne
@JoinColumn(name = "monitor_id") // Computer 테이블에 FK 생성
private Monitor monitor;
}
@Entity
public class Monitor {
@OneToOne(mappedBy = "monitor") // 이제 Monitor가 mappedBy
private Computer computer;
}
장점:
- 지연 로딩이 정상적으로 동작한다
- Computer를 조회하는 시점에 이미 "연관된 Monitor가 누구인지"를 알고 있기 때문에 프록시 생성이 가능하다
- Monitor에 실제로 접근하는 시점에만 조회 쿼리가 실행된다
단점:
- 추후 비즈니스 요구가 변경되어 "One Computer – Multiple Monitor" 구조로 확장해야 한다면, 테이블 구조 자체를 변경해야 한다
8.5.2 FK를 Monitor 테이블에 두는 경우 (방식 B - 문제의 구조)
@Entity
public class Computer {
@OneToOne(mappedBy = "computer") // mappedBy 사용
private Monitor monitor;
}
@Entity
public class Monitor {
@OneToOne
@JoinColumn(name = "computer_id") // Monitor 테이블에 FK 생성
private Computer computer;
}
장점:
- 확장성 측면에서 유리하다
- 1:1 관계가 나중에 1:N 관계로 바뀌더라도 테이블 구조를 크게 변경하지 않아도 된다
단점:
- JPA 관점에서 문제가 된다
- Computer를 조회하는 시점에 연관된 Monitor의 ID를 알 수 없기 때문에 프록시를 만들 수 없다
- 지연 로딩을 설정했음에도 구조적으로 지연 로딩이 깨지기 쉬운 상태가 된다
8.6 실무에서는 어떻게 접근할까?
이 지점에서 많은 개발자들이 같은 결론에 도달한다: "이건 로딩 전략으로 해결할 문제가 아니라 연관 관계 설계 문제구나"
8.6.1 가장 추천하는 접근법: @ManyToOne + UNIQUE 제약 (⭐⭐⭐)
실무에서 가장 선호되는 방법은 데이터베이스 수준에서는 UNIQUE 제약으로 1:1 관계를 보장하되, JPA 매핑은 @ManyToOne으로 처리하는 것이다.
// Computer 엔티티
@Entity
public class Computer {
@Id
private Long id;
@OneToMany(mappedBy = "computer") // 1:N 관계처럼 매핑
private List<Monitor> monitors; // 실제로는 1개만 존재
}
// Monitor 엔티티
@Entity
public class Monitor {
@Id
private Long id;
@ManyToOne // N:1 관계처럼 매핑
@JoinColumn(name = "computer_id", unique = true) // UNIQUE 제약!
private Computer computer;
}
이 방식의 장점:
- 지연 로딩이 완벽하게 동작한다: 프록시 관련 이슈가 전혀 없다
- JPQL, 페이징, 배치 로딩과의 궁합이 좋다: 모든 JPA 기능을 문제없이 사용할 수 있다
- 확장성을 확보할 수 있다: 나중에 "한 컴퓨터에 여러 모니터"로 변경하기 쉽다
- 성능 최적화가 용이하다: 필요한 경우에만 JOIN을 사용할 수 있다
8.6.2 데이터베이스 설계와 객체 설계의 분리
이 방식의 핵심 철학은 데이터베이스 설계와 객체 설계를 의도적으로 분리하는 것이다:
- 데이터베이스 레벨: UNIQUE 제약조건으로 1:1 관계 보장
- 객체 모델 레벨: @ManyToOne으로 유연한 매핑
- 실제 비즈니스 로직: 컬렉션을 사용하지만, 실제로는 단일 객체만 다룸
8.6.3 체크리스트: @OneToOne 설계 시 고려사항
실무에서 @OneToOne 관계를 설계할 때는 다음 체크리스트를 참고하는 것이 좋다:
- 단방향으로 충분한가? → 단방향이면 문제 없음
- 양방향이 필수적인가? → 필수가 아니라면 단방향으로
- FK 위치는 어디가 적절한가? → 확장성을 고려하여 결정
- @ManyToOne + UNIQUE로 대체할 수 있는가? → 대체 가능하면 이 방식 추천
- 성능 테스트를 진행했는가? → 실제 데이터량으로 N+1 발생 여부 확인
8.7 결론: 설계의 중요성
@OneToOne 관계에서 발생하는 N+1 문제는 단순한 설정 문제가 아니다. 이는 데이터베이스의 물리적 설계(FK 위치)와 객체의 논리적 설계(연관 관계 매핑)가 서로 조화를 이루지 못할 때 발생하는 구조적 문제다.
이러한 문제를 해결하기 위해서는:
- 문제의 근본 원인을 이해해야 한다: 프록시 동작 방식과 FK 위치의 관계
- 임시방편이 아닌 근본적인 해결책을 모색해야 한다: Fetch Join은 해결책이 아닌 우회책
- 실무에서 검증된 패턴을 활용해야 한다: @ManyToOne + UNIQUE 패턴의 장점 이해
- 설계 단계에서 미리 예방해야 한다: 성능 문제는 런타임에서 발견되기 전에 설계 단계에서 해결하는 것이 가장 효과적이다
결국, 좋은 JPA 설계란 단순히 애너테이션을 적절히 배치하는 것을 넘어서, 데이터베이스의 물리적 구조와 객체의 논리적 구조가 어떻게 상호작용하는지를 깊이 이해하는 데서 시작한다.
'Spring > JPA' 카테고리의 다른 글
| [Optimization-5] JPA: 벌크 연산의 이해와 ID 생성 전략의 상관관계 (0) | 2025.12.26 |
|---|---|
| [Optimization-4] JPA: N+1 모니터링 시스템 구축하기 (0) | 2025.12.26 |
| [Optimization-2] 기초(2) - 성능 최적화의 열쇠, 프록시와 로딩 전략 (0) | 2025.12.26 |
| [Optimization-1] 기초(1) - 현대 백엔드 개발의 표준, JPA의 본질 (0) | 2025.12.26 |
| [Practice-6] 단뱡향/양방향 선택 기준 (⭐⭐⭐) (0) | 2025.12.25 |
