백엔드 엔지니어 이재혁
[JPA] N+1 본문
Team 테이블과 Team에 연관관계에 있는 Member 테이블이 있다고 하자.
Team 테이블에서 Team A, Team B, Team C의 모든 Member를 조회하고 싶다면, 보통 다음과 같은 쿼리를 실행할 것이다.
SELECT m.* FROM Team t
LEFT JOIN Member m ON t.id = m.team_id
WHERE t.name IN ('Team A', 'Team B', 'Team C');
하지만 JPA는 이렇게 조회하지 않는다.
JPA의 실행방법
SELECT * FROM Team
WHERE name IN ('Team A', 'Team B', 'Team C');
SELECT * FROM Member
WHERE team_id = 1;
SELECT * FROM Member
WHERE team_id = 2;
SELECT * FROM Member
WHERE team_id = 3;
- Team에서 Team 이름이 Team A, Team B, Team C인 팀들을 조회한다.
- 그 조회 결과를 통해 team_id 목록을 확보하고, 각 team_id를 따로 SELECT 한다.
따라서, N+1 정확히는 1+N (Team 조회 1번 + Member 조회 N번) 문제가 발생한다.
왜 이렇게 하지??
처음 N+1 문제를 확인했을 때는 굳이 이렇게 할 이유가??? 라는 생각이 들었다. JPA의 등장 배경부터 알아보자.
JPA의 등장 배경
JPA는 객체 지향과 관계 기반의 차이를 메우기 위해 등장했다.
- 애플리케이션은 객체 지향 언어(Java, C# 등)로 작성된다.
- 데이터는 관계형 데이터베이스(RDB)에 저장된다.
- 양쪽 모델의 차이:
- 객체: 상속, 연관관계 (참조 기반)
- RDB: 외래 키, 조인 (관계 기반)
이 차이를 매번 수작업 SQL로 풀다 보니 SQL 중심의 개발이 되고, 객체지향적인 장점을 살리기 어려움
JPA 객체 중심 개발
- SQL 대신 객체 그래프 탐색으로 데이터를 다룬다.
- 연관관계를 코드에서 참조로 표현할 수 있다.
예시)
기존: Member 테이블 Team_id라는 FK
JPA: FK 대신, Team 객체 안에 Collection 형태로 Member를 저장
개발자는 `team.getMembers()`처럼 객체 탐색을 사용
JPA는 불필요한 데이터를 불러오지 않기 위해 연관 엔티티를 필요할 때 불러오는 전략(LAZY)을 기본으로 둠
연관 관계 조회 실제 코드
실제 사용 예시를 보면, 우리는 다음과 같은 코드를 사용한다.
for (Team team : teams) { // 1. JPA는 이 시점에 Member 엔티티가 필요한지 모름
System.out.println("Team: " + team.getName());
for (Member member : team.getMembers()) { // 2. 이제서야 필요함을 안다
System.out.println(" -> Member: " + member.getUsername());
}
}
- JPA는 1번 시점에 Member 엔티티가 필요한지 모름
- 2번 시점이 되어서야 각 Member 엔티티가 필요함을 안다 (하나하나씩 조회)
엔티티마다 독립적으로 조회하게 되는 것이다. 참조 방식으로 조회할 때 Team과 Member를 개별적으로 조회하게 된다.
부작용 (N+1)
지연 로딩은 "연관 데이터는 실제로 접근하는 순간에 초기화한다"는 의미다.
불필요한 데이터를 미리 가져오지 않아 효율적일 수 있지만, 컬렉션 연관(예: Team.members)처럼 부모 엔티티 N개 각각에서 연관을 처음 접근하는 경우에는 문제가 된다.
이때 쿼리는 보통 이렇게 된다.
- 1번: 부모 목록 조회 (`SELECT * FROM team`)
- N번: 각 부모에 대해 연관 테이블 조회 (`SELECT * FROM member WHERE team_id=?`) × N
"언제 필요한지" 프레임워크가 예측할 수 없어서 기본적으로 최소한만 먼저 가져오도록(LAZY) 설계된 것이다.
중요한 점은 "LAZY 연관을 최초 접근할 때마다" 추가 쿼리가 실행된다는 것이다.
이 점만 기억해둔다면, JPA의 N+1이 어디서 발생하는지 판단할 수 있을 것이다.
해결 방법들
개발자가 미리 "이 데이터들이 필요해"라고 선언적으로 미리 알려주는 방식으로 N+1 문제를 해결한다.
1. Fetch Join / @EntityGraph
한 번의 쿼리로 필요한 연관까지 로딩하기
다음과 같이 `@EntityGraph`로 지정하면 members 하위 참조 객체도 같이 불러온다.
public interface TeamRepository extends JpaRepository<Team, Long> {
@EntityGraph(attributePaths = {"members"})
List<Team> findAll();
}
JPQL로 디테일하게 설정할 수도 있다.
public interface TeamRepository extends JpaRepository<Team, Long> {
@Query("select distinct t from Team t left join fetch t.members")
List<Team> findAllWithMembers();
}
2. Batch Fetching
전역 기본값 설정: `spring.jpa.properties.hibernate.default_batch_fetch_size=50`
개별 설정: `@BatchSize` 붙이기
N개를 IN 쿼리 몇 번으로 묶어 N+1을 크게 완화(예: N=100, 배치=50 → 추가 쿼리 2회)
@BatchSize(size = 50)
@OneToMany(mappedBy = "team", fetch = FetchType.LAZY)
private List<Member> members;
이 문제는 N+1 문제를 완전히 해결하는 것은 아니지만, 문제를 크게 완화해준다.
Fetch Join이 한 번에 너무 많이 조회하는 문제를 방지할 수 있는 중간점 해결책이라고 생각된다.
'Java' 카테고리의 다른 글
| [Java] Executor 프레임워크 심화 (0) | 2025.06.30 |
|---|---|
| [JUnit] Controller 단위 테스트 작성하기, 그리고 회고 (1) | 2025.06.29 |
| [Java] Executor 프레임워크 기본 (0) | 2025.06.25 |
| [Java] 컬렉션 프레임워크와 동시성 (0) | 2025.06.13 |
| [JAVA] CAS 락 구현 (0) | 2025.06.10 |