목차
JPA는 SQL을 직접 작성하지 않고도 데이터베이스 작업을 처리할 수 있게 해주는 기술이다. repository.findById(1L) 같은 간단한 메서드 호출만으로도 복잡한 SQL이 실행되어 객체를 가져다준다. 하지만 이 편리함 뒤에는 'N+1 문제'라는 함정이 숨겨져 있다. 당연하게 사용하던 JPA가 어떻게 데이터베이스에 과부하를 주는지, 이 문제를 어떻게 해결할 수 있는지 비유를 통해 알아보자.
1. N+1 문제란 무엇인가?
N+1 문제는 JPA를 통해 연관 관계가 설정된 엔티티를 조회할 때 발생하는 대표적인 성능 문제다. 이름 그대로 처음 1번의 쿼리로 원하는 엔티티 목록을 가져왔지만, 이 엔티티들이 각자 연관된 하위 엔티티에 접근할 때마다 추가적인 쿼리가 N번 발생하는 현상을 말한다.
1.1. 예시
N+1 문제를 식당의 단체 주문에 비유할 수 있다.
(N+1 상황: 비효율적인 주문)
1. 총무팀 직원이 식당에 가서 "우리 팀 5명(N=5) 식사 주문할게요"라고 요청한다. (1번의 쿼리)
2. 식당은 "알겠습니다" 하고 일단 5개의 메인 메뉴만 준비해서 가져다준다.
3. 그런데 5명의 팀원이 각자 자기 메뉴를 받고 나서야, "저 숟가락이 없네요", "저도 젓가락이 없어요" 라고 개별적으로(N번) 식당에 요청하기 시작한다.
4. 식당은 팀원 1에게 숟가락을 가져다주고, 팀원 2에게 젓가락을 가져다준다. 이 과정을 5번 반복한다.
결과: 총무팀은 메인 메뉴를 받는 쿼리 1번과 각 팀원이 수저를 요청하는 쿼리 5번, 총 1+5=6번의 요청(쿼리)을 식당(DB)에 보낸다.
1.2. 문제 상황
이 상황을 팀과 멤버의 1:N 관계로 구현해 보자. Team 엔티티는 List<Member>를 가지고 있다.
// Team 엔티티
@Entity
public class Team {
@Id @GeneratedValue
private Long id;
private String name;
// 1:N 관계 Lazy Loading(지연 로딩)
@OneToMany(mappedBy = "team")
private List<Member> members = new ArrayList<>();
// ... getters
}
// Member 엔티티
@Entity
public class Member {
@Id @GeneratedValue
private Long id;
private String name;
@ManyToOne
private Team team;
// ...
}
이제 모든 팀과 그 팀에 속한 멤버들의 이름을 출력하는 코드를 작성해 보자.
// N+1 문제가 발생하는 로직
@Service
@Transactional
public class TeamService {
private final TeamRepository teamRepository;
// ... 생성자 ...
public void printAllTeamMembers() {
// 1. [쿼리 1번 발생] 모든 팀 조회
// (예: 5개의 팀이 조회됨)
// SELECT * FROM team;
List<Team> teams = teamRepository.findAll();
// 2. [쿼리 N번 발생] 각 팀의 멤버 목록에 접근
for (Team team : teams) {
System.out.println("팀: " + team.getName());
// 3. (지연 로딩) 실제 members 리스트에 접근하는 순간,
// 팀별로 멤버를 조회하는 추가 쿼리 발생
// SELECT * FROM member WHERE team_id = 1; (team A)
// SELECT * FROM member WHERE team_id = 2; (team B)
// SELECT * FROM member WHERE team_id = 3; (team C)
// SELECT * FROM member WHERE team_id = 4; (team D)
// SELECT * FROM member WHERE team_id = 5; (team E)
for (Member member : team.getMembers()) {
System.out.println(" 멤버: " + member.getName());
}
}
}
}
findAll()로 팀을 조회하는 쿼리 1번과 5개의 팀에 대한 멤버를 조회하는 쿼리 5번이 추가로 발생하여 총 6번의 쿼리가 실행된다. 만약 팀이 100개라면 1+100=101번의 쿼리가 데이터베이스를 조회를 요청하게 된다.
1.3. 문제 원인
이 문제는 JPA의 @OneToMany나 @ManyToMany의 기본 fetch 설정인 지연 로딩 때문에 발생한다.
지연 로딩은 엔티티를 조회할 때 연관된 엔티티를 즉시 가져오지 않고 team.getMembers()처럼 실제로 해당 엔티티에 접근하는 시점에 SQL을 실행해 가져오는 전략이다. 이는 당장 필요하지 않은 데이터까지 모두 불러오는 낭비를 막기 위한 효율적인 방법이지만 위 예시처럼 연관 데이터를 반복문 안에서 사용할 경우 N+1 문제를 유발하는 주된 이유가 된다.
2. N+1 문제 해결 방안
N+1 문제는 연관된 엔티티를 '어떻게 한 번에 잘 가져올 것인가'의 문제다. 식당 비유로 돌아가서 비효율적인 주문을 개선해 보자.
2.1. 해결책 1: Fetch Join
가장 고전적이고 확실한 방법이다. JPQL을 사용하여 조인 쿼리를 명시적으로 작성하는 것이다.
비유: "수저까지 미리 챙겨주세요"
총무팀 직원이 주문할 때 "우리 팀 5명 메인 메뉴 5개 주시고요, 모두 수저 세트도 같이 챙겨서 한 번에 가져다주세요"라고 명확하게 요청한다. 식당은 1번의 요청(쿼리)으로 메인 메뉴 5개와 수저 5세트를 모두 준비해 한 번에 서빙한다.
코드 구현: TeamRepository에 JPQL을 직접 작성한다.
// TeamRepository.java
public interface TeamRepository extends JpaRepository<Team, Long> {
// JPQL을 사용해 Team과 Member를 함께 가져온다.
@Query("SELECT t FROM Team t JOIN FETCH t.members")
List<Team> findAllWithMembers();
}
// TeamService.java
public void printAllTeamMembers() {
// 1. [쿼리 1번 발생]
// SELECT ... FROM team t INNER JOIN member m ON t.id = m.team_id;
// 단 한 번의 조인 쿼리로 Team과 Member 데이터를 모두 가져온다.
List<Team> teams = teamRepository.findAllWithMembers();
for (Team team : teams) { // (추가 쿼리 발생 안 함)
System.out.println("팀: " + team.getName());
for (Member member : team.getMembers()) { // (추가 쿼리 발생 안 함)
System.out.println(" 멤버: " + member.getName());
}
}
}
장점: 강력하고 확실하다. 원하는 시점에 원하는 데이터만 골라서 조인할 수 있다.
단점: JPQL을 직접 작성해야 하는 번거로움이 있다. 1:N 관계에서 Fetch Join을 사용하면 데이터가 뻥튀기되거나 중복될 수 있으며 페이징 처리 시 문제가 발생할 수 있다. (JPA는 경고 로그를 띄우며 메모리에서 페이징을 시도한다.)
2.2. 해결책 2: @EntityGraph
JPQL 작성 없이 필요한 연관 관계를 즉시 로딩하도록 지정하는 어노테이션이다.
비유: "단체 주문서 옵션 체크"
총무팀 직원이 단체 주문서(Repository 메서드)에 있는 수저 포함'옵션에 체크(@EntityGraph)만 한다. 식당(JPA)이 이 옵션을 보고 알아서 수저를 챙겨다 준다.
코드 구현: Repository 메서드에 @EntityGraph를 추가하고 함께 가져올 필드 이름을 attributePaths에 지정한다.
// TeamRepository.java
public interface TeamRepository extends JpaRepository<Team, Long> {
// findAll 메서드를 실행할 때 members 속성은 EAGER처럼 가져오게 함
@EntityGraph(attributePaths = {"members"})
@Query("SELECT t FROM Team t") // JPQL은 단순하게 유지 (JpaRepository의 findAll()에도 적용 가능)
List<Team> findAllWithMembersUsingEntityGraph();
// JpaRepository 기본 메서드에도 적용 가능
@Override
@EntityGraph(attributePaths = {"members"})
List<Team> findAll();
}
장점: JPQL 없이 깔끔하게 조인을 적용할 수 있다.
단점: Fetch Join과 마찬가지로 페이징 처리 시 문제가 발생할 수 있다.
2.3. 해결책 3: 배치 사이즈
N+1 자체를 없애는 것이 아니라 N번의 추가 쿼리를 한 번 또는 몇 번의 IN 쿼리로 최적화하는 방식이다.
비유: "수저 주문 모아서 한 번에 가져오기"
1. 총무팀이 메인 메뉴 5개를 주문한다. (쿼리 1)
2. 팀원 5명이 각자 수저를 요청한다
3. 식당 직원이 "아, 5명이 다 수저가 필요하구나"라고 인지하고, 주방에 가서 5개의 수저 세트를 한 번에 트레이(IN 절)에 담아 가져온다. (쿼리 1)
코드 구현: application.yml (또는 properties)에 글로벌 설정을 추가한다.
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100 # (100 ~ 1000 사이의 값을 권장)
이 설정을 추가하면, team.getMembers()로 지연 로딩이 발생하는 첫 시점에 JPA는 나머지 Team 객체들의 ID를 모아서 IN 절 쿼리를 날린다.
-- 1. findAll() 쿼리
SELECT * FROM team; (Team A, B, C, D, E 반환)
-- 2. Team A의 getMembers() 호출 시
-- (batch_size 만큼 ID를 모아서 IN 쿼리를 날림)
SELECT * FROM member WHERE team_id IN (1, 2, 3, 4, 5);
결과적으로 100개의 팀이 있더라도 쿼리는 단 2번(1 + 1)만 발생한다.
장점: 코드를 전혀 수정하지 않고 설정 하나로 N+1 문제를 꽤나 효과적으로 해결한다. 지연 로딩의 이점을 살리면서 페이징 처리에도 문제가 없다.
단점: 조인으로 가져오는 것보다 쿼리가 1번 더 나간다. (1+1)
3. (비권장) 즉시 로딩은 왜 답이 아닐까?
N+1 문제를 피하기 위해 FetchType을 EAGER로 설정하는 방법도 있다.
// Team.java
@OneToMany(mappedBy = "team", fetch = FetchType.EAGER) // 비권장
private List<Member> members = new ArrayList<>();
비유: "묻지도 따지지도 않고 항상 풀세트"
총무팀 직원이 몇 명을 주문하든, 심지어 "커피 1잔만 주세요"라고 해도 식당이 "저희는 무조건 메인 메뉴와 수저 풀세트를 함께 드려요"라며 항상 모든 것을 다 가져다준다.
EAGER 전략은 해당 엔티티를 조회할 때 항상 연관된 엔티티를 조인해서 가져온다. N+1은 피할 수 있지만 아래와 같은 더 큰 문제를 발생시킨다.
- 성능 저하: Team 정보만 필요한 순간에도 불필요한 Member 조인 쿼리가 발생한다.
- 예측 불가능성: 여러 EAGER 관계가 얽히면 개발자가 의도하지 않은 조인 쿼리가 발생하여 애플리케이션의 전체적인 성능을 망가뜨릴 수 있다.
- 유연성 부재: N+1은 Fetch Join 등으로 해결여부를 선택할 수 있지만 EAGER는 선택의 여지가 없다.
4. 결론
JPA의 N+1 문제는 편의성을 우선하다가 간과하기 쉬운 대표적인 성능 문제중 하나라고 생각된다.
- 관심사 분리: JPA를 사용하더라도 실행되는 SQL에는 항상 관심을 가져야 한다.
- 전략적 선택: 모든 연관 관계는 기본적으로 지연 로딩으로 설정하는 것이 원칙이다.
- 문제 해결: N+1이 발생하는 특정 로직에는 Fetch Join이나 @EntityGraph를 사용해 즉시 로딩을 적용한다.
페이징 처리가 필요하거나 애플리케이션 전반의 N+1을 완화하고 싶다면 배치 사이즈 옵션 적용을 하는것을 권장한다.
DI가 객체 간의 결합도를 낮추는 설계의 핵심인것처럼 N+1 문제의 해결은 데이터 접근의 효율성을 높여주는 JPA 활용의 중요한 부분이라고 생각된다.
'공부메모 & 오류해결 > Spring Boot' 카테고리의 다른 글
| [JPA] DTO 없이 엔티티로 응답하면 안될까? (0) | 2025.11.07 |
|---|---|
| [Spring] 제어의 역전(Inversion of Control, IoC) 이란 무엇일까? (1) | 2025.11.05 |
| [Spring] 의존성 주입(Dependency injection, DI) 이란 무엇일까? (0) | 2025.11.04 |
| [Spring Boot] 실시간검색어 구현하기 (3) | 2025.07.13 |
| [Spring Boot] Gzip 압축을 통해 로딩 성능 최적화 하기 (0) | 2025.01.14 |
남건욱's 공부기록