이 글에서 얻는 것

  • N+1이 “Lazy가 나빠서”가 아니라, 조회 패턴과 객체 그래프 로딩 방식이 맞지 않아서 생긴다는 걸 이해합니다.
  • “로그에 쿼리가 많이 찍힌다” 수준을 넘어, 어떤 관계/어떤 코드에서 N+1이 터지는지 재현하고 진단할 수 있습니다.
  • Fetch Join, Batch Size, DTO 조회(프로젝션) 중 무엇을 언제 쓰는지 선택 기준이 생깁니다.

0) N+1은 ‘성능 문제’이자 ‘설계 문제’다

N+1은 단순히 “쿼리가 많이 나간다”가 아니라,

  • 트래픽이 늘수록 폭발적으로 느려지고,
  • DB 커넥션 풀/락/캐시까지 연쇄적으로 흔드는

대표적인 실무 장애 원인입니다. 그래서 원인과 해결 루틴을 “습관”으로 만들어두는 게 중요합니다.

1. N+1 문제란?

1.1 문제 발생 시나리오

엔티티 구조:

@Entity
public class Team {
    @Id @GeneratedValue
    private Long id;

    private String name;

    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<>();
}

@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;

    private String username;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "team_id")
    private Team team;
}

문제 코드:

@Service
public class TeamService {

    @Transactional(readOnly = true)
    public void printTeamsAndMembers() {
        // 1. Team 조회 (1번의 쿼리)
        List<Team> teams = teamRepository.findAll();

        System.out.println("==== Teams loaded ====");

        // 2. 각 Team의 members 조회 (N번의 쿼리)
        for (Team team : teams) {
            System.out.println("Team: " + team.getName());

            // ⚠️ 여기서 추가 쿼리 발생!
            List<Member> members = team.getMembers();

            for (Member member : members) {
                System.out.println("  Member: " + member.getUsername());
            }
        }
    }
}

실행 SQL:

-- 1. Team 조회 (1번)
SELECT * FROM team;

-- 결과: 5개 Team

-- 2. 각 Team의 Member 조회 (5번)
SELECT * FROM member WHERE team_id = 1;
SELECT * FROM member WHERE team_id = 2;
SELECT * FROM member WHERE team_id = 3;
SELECT * FROM member WHERE team_id = 4;
SELECT * FROM member WHERE team_id = 5;

-- 총 6번의 쿼리! (1 + N)
-- Team이 100개면 101번의 쿼리 실행!

왜 N+1이라고 부를까?

1: 첫 번째 쿼리 (Team 전체 조회)
N: Team 개수만큼 추가 쿼리 (각 Team의 Members 조회)

총 1 + N번의 쿼리 실행

1.2 FetchType.EAGER일 때도 발생

@Entity
public class Member {
    @ManyToOne(fetch = FetchType.EAGER)  // ❌ EAGER로 변경해도 해결 안 됨!
    private Team team;
}

// EAGER 사용 시
List<Member> members = memberRepository.findAll();

// 실행 SQL:
SELECT * FROM member;  // 1번
SELECT * FROM team WHERE id = 1;  // N번
SELECT * FROM team WHERE id = 2;
SELECT * FROM team WHERE id = 3;
...

EAGER의 문제점:

  1. N+1 문제 여전히 발생
  2. 불필요한 데이터까지 항상 로딩 (성능 저하)
  3. 예측하기 어려운 쿼리 발생

권장: 항상 LAZY 사용 + 필요 시 Fetch Join


2. 해결 방법

2.1 Fetch Join (JPQL)

가장 일반적인 해결 방법:

public interface TeamRepository extends JpaRepository<Team, Long> {

    // ✅ Fetch Join으로 해결
    @Query("SELECT t FROM Team t JOIN FETCH t.members")
    List<Team> findAllWithMembers();
}

// 사용
List<Team> teams = teamRepository.findAllWithMembers();
for (Team team : teams) {
    team.getMembers().forEach(member ->
        System.out.println(member.getUsername())
    );
}

실행 SQL:

-- 단 1번의 쿼리로 해결!
SELECT t.*, m.*
FROM team t
INNER JOIN member m ON t.id = m.team_id;

주의: 페이징 처리 시 문제

// ❌ 페이징 + Fetch Join = 메모리에서 페이징
@Query("SELECT t FROM Team t JOIN FETCH t.members")
Page<Team> findAllWithMembers(Pageable pageable);

// 실행 SQL:
SELECT t.*, m.*
FROM team t
INNER JOIN member m ON t.id = m.team_id;
-- LIMIT 없음! 모든 데이터를 메모리에 로드 후 페이징

// 경고 로그:
// HHH000104: firstResult/maxResults specified with collection fetch;
// applying in memory!

페이징 해결 방법 1: ToOne 관계만 Fetch Join

// Member → Team (ManyToOne)
@Query("SELECT m FROM Member m JOIN FETCH m.team")
Page<Member> findAllWithTeam(Pageable pageable);

// ✅ ToOne 관계는 페이징 가능
SELECT m.*, t.*
FROM member m
INNER JOIN team t ON m.team_id = t.id
LIMIT 10 OFFSET 0;

페이징 해결 방법 2: @BatchSize

@Entity
public class Team {
    @OneToMany(mappedBy = "team")
    @BatchSize(size = 100)  // ✅ 100개씩 IN 절로 조회
    private List<Member> members = new ArrayList<>();
}

// 쿼리 실행:
// 1. Team 조회 (페이징 적용)
SELECT * FROM team LIMIT 10 OFFSET 0;

// 2. Members 조회 (IN 절로 배치)
SELECT * FROM member
WHERE team_id IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

// N+1 아님! 1 + 1 = 2번의 쿼리

2.2 @EntityGraph

Annotation 기반 Fetch Join:

public interface TeamRepository extends JpaRepository<Team, Long> {

    @EntityGraph(attributePaths = {"members"})
    List<Team> findAll();

    // 여러 연관관계 한 번에
    @EntityGraph(attributePaths = {"members", "coach"})
    List<Team> findAllWithMembersAndCoach();
}

Named EntityGraph:

@Entity
@NamedEntityGraph(
    name = "Team.withMembers",
    attributeNodes = @NamedAttributeNode("members")
)
public class Team {
    // ...
}

public interface TeamRepository extends JpaRepository<Team, Long> {
    @EntityGraph("Team.withMembers")
    List<Team> findAll();
}

@EntityGraph vs Fetch Join:

@EntityGraph:
- LEFT OUTER JOIN 사용
- 간결한 코드
- Spring Data JPA에서 제공

Fetch Join:
- INNER JOIN 또는 LEFT JOIN 선택 가능
- 복잡한 조건 추가 가능
- JPQL 작성 필요

2.3 Batch Size

글로벌 설정:

# application.yml
spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100  # 기본 Batch Size

엔티티별 설정:

@Entity
public class Team {
    @OneToMany(mappedBy = "team")
    @BatchSize(size = 50)  // 이 Team만 50개씩
    private List<Member> members = new ArrayList<>();
}

동작 원리:

List<Team> teams = teamRepository.findAll();

// 1. Team 조회
SELECT * FROM team;

// 2. Members를 100개씩 IN 절로 조회
// Team이 250개면 3번의 쿼리로 해결
SELECT * FROM member WHERE team_id IN (1, 2, ..., 100);
SELECT * FROM member WHERE team_id IN (101, 102, ..., 200);
SELECT * FROM member WHERE team_id IN (201, 202, ..., 250);

// N+1이 아니라 1 + ceil(N/100) 개의 쿼리

권장 Batch Size:

너무 작으면: 쿼리 수 증가
너무 크면: DB 부하, 메모리 증가

권장값: 100 ~ 1000
실무: 보통 100 사용

2.4 Projection (DTO 직접 조회)

JPQL로 DTO 바로 조회:

// DTO 정의
@Getter
@AllArgsConstructor
public class TeamMemberDto {
    private String teamName;
    private String memberName;
}

// Repository
public interface TeamRepository extends JpaRepository<Team, Long> {

    @Query("SELECT new com.example.dto.TeamMemberDto(t.name, m.username) " +
           "FROM Team t JOIN t.members m")
    List<TeamMemberDto> findTeamMemberDtos();
}

// 실행 SQL:
SELECT t.name, m.username
FROM team t
INNER JOIN member m ON t.id = m.team_id;

// ✅ 필요한 컬럼만 조회 (성능 최적화)
// ✅ 1번의 쿼리로 해결

QueryDSL 활용:

@Repository
@RequiredArgsConstructor
public class TeamQueryRepository {

    private final JPAQueryFactory queryFactory;

    public List<TeamMemberDto> findTeamMemberDtos() {
        return queryFactory
            .select(Projections.constructor(
                TeamMemberDto.class,
                team.name,
                member.username
            ))
            .from(team)
            .join(team.members, member)
            .fetch();
    }
}

3. 실전 시나리오별 해결 방법

3.1 시나리오 1: 단순 목록 조회

요구사항: Team 목록 + 각 Team의 Member 수

// ❌ N+1 발생
@Transactional(readOnly = true)
public List<TeamDto> getTeams() {
    List<Team> teams = teamRepository.findAll();

    return teams.stream()
        .map(team -> new TeamDto(
            team.getName(),
            team.getMembers().size()  // ⚠️ N+1 발생!
        ))
        .collect(Collectors.toList());
}

// ✅ 해결: Fetch Join
@Query("SELECT t FROM Team t JOIN FETCH t.members")
List<Team> findAllWithMembers();

// ✅ 더 나은 해결: COUNT 서브쿼리
@Query("SELECT new com.example.dto.TeamDto(t.name, " +
       "(SELECT COUNT(m) FROM Member m WHERE m.team = t)) " +
       "FROM Team t")
List<TeamDto> findTeamsWithMemberCount();

3.2 시나리오 2: 페이징 처리

요구사항: Team 목록 페이징 + Members

// ❌ Fetch Join + 페이징 = 메모리 페이징
@Query("SELECT t FROM Team t JOIN FETCH t.members")
Page<Team> findAllWithMembers(Pageable pageable);

// ✅ 해결: @BatchSize
@Entity
public class Team {
    @OneToMany(mappedBy = "team")
    @BatchSize(size = 100)
    private List<Member> members = new ArrayList<>();
}

@Transactional(readOnly = true)
public Page<Team> getTeams(Pageable pageable) {
    return teamRepository.findAll(pageable);
    // 1. Team 페이징: SELECT * FROM team LIMIT 10
    // 2. Members 배치: SELECT * FROM member WHERE team_id IN (...)
}

3.3 시나리오 3: 깊은 연관관계

요구사항: Team → Members → Orders

@Entity
public class Team {
    @OneToMany(mappedBy = "team")
    private List<Member> members = new ArrayList<>();
}

@Entity
public class Member {
    @ManyToOne
    private Team team;

    @OneToMany(mappedBy = "member")
    private List<Order> orders = new ArrayList<>();
}

// ❌ N+1 연쇄 발생
List<Team> teams = teamRepository.findAll();
for (Team team : teams) {
    for (Member member : team.getMembers()) {  // N+1
        for (Order order : member.getOrders()) {  // N+1 또 발생!
            // ...
        }
    }
}

// ✅ 해결 1: 다중 Fetch Join
@Query("SELECT DISTINCT t FROM Team t " +
       "JOIN FETCH t.members m " +
       "JOIN FETCH m.orders")
List<Team> findAllWithMembersAndOrders();

// ✅ 해결 2: @BatchSize 계층적 설정
@Entity
public class Team {
    @OneToMany(mappedBy = "team")
    @BatchSize(size = 100)
    private List<Member> members;
}

@Entity
public class Member {
    @OneToMany(mappedBy = "member")
    @BatchSize(size = 100)
    private List<Order> orders;
}

// 쿼리 실행:
// 1. Team 조회
// 2. Members 배치 조회 (IN 절)
// 3. Orders 배치 조회 (IN 절)
// 총 3번의 쿼리로 해결!

3.4 시나리오 4: 양방향 연관관계

요구사항: 양방향 조회 시 N+1 방지

// Member → Team
@Transactional(readOnly = true)
public List<Member> getMembers() {
    // ✅ ManyToOne은 Fetch Join 쉬움
    return memberRepository.findAllWithTeam();
}

@Query("SELECT m FROM Member m JOIN FETCH m.team")
List<Member> findAllWithTeam();

// Team → Members
@Transactional(readOnly = true)
public List<Team> getTeams() {
    // ✅ OneToMany는 @BatchSize 권장
    return teamRepository.findAll();
}

@Entity
public class Team {
    @OneToMany(mappedBy = "team")
    @BatchSize(size = 100)
    private List<Member> members;
}

4. 성능 비교

4.1 테스트 환경

Team: 100개
Member: 각 Team당 10명 (총 1000명)

4.2 쿼리 수 비교

방법쿼리 수설명
N+1 (해결 전)101개1 (Team) + 100 (각 Team의 Members)
Fetch Join1개모든 데이터를 1번에 조회
@EntityGraph1개LEFT OUTER JOIN으로 조회
@BatchSize(100)2개1 (Team) + 1 (Members IN 절)
DTO Projection1개필요한 컬럼만 조회

4.3 응답 시간 비교 (실측)

환경: MySQL 8.0, Team 1000개, Member 10000명

N+1 (해결 전):
- 쿼리 수: 1001개
- 응답 시간: 3.2초

Fetch Join:
- 쿼리 수: 1개
- 응답 시간: 0.15초 (21배 빠름)

@BatchSize(100):
- 쿼리 수: 11개
- 응답 시간: 0.18초 (18배 빠름)

DTO Projection:
- 쿼리 수: 1개
- 응답 시간: 0.08초 (40배 빠름, 필요 컬럼만 조회)

5. 실무 Best Practices

5.1 기본 원칙

// 1. 모든 연관관계는 LAZY로 설정
@ManyToOne(fetch = FetchType.LAZY)  // ✅
@OneToMany(fetch = FetchType.LAZY, mappedBy = "team")  // ✅

// 2. 글로벌 BatchSize 설정
spring.jpa.properties.hibernate.default_batch_fetch_size=100

// 3. 필요한 경우에만 Fetch Join
@Query("SELECT t FROM Team t JOIN FETCH t.members WHERE t.id = :id")
Optional<Team> findByIdWithMembers(@Param("id") Long id);

5.2 계층별 전략

Controller → Service:

@GetMapping("/teams")
public List<TeamResponse> getTeams() {
    // DTO로 변환하여 반환 (Lazy Loading 방지)
    return teamService.getTeams();
}

Service → Repository:

@Transactional(readOnly = true)
public List<TeamResponse> getTeams() {
    List<Team> teams = teamRepository.findAllWithMembers();

    return teams.stream()
        .map(TeamResponse::from)  // DTO 변환
        .collect(Collectors.toList());
}

Repository:

// 명확한 메서드명으로 Fetch 전략 명시
List<Team> findAll();  // Members는 Lazy
List<Team> findAllWithMembers();  // Fetch Join
List<Team> findAllWithMembersAndCoach();  // 다중 Fetch Join

5.3 쿼리 로그로 N+1 감지

쿼리 로깅 활성화:

# application.yml
spring:
  jpa:
    show-sql: true
    properties:
      hibernate:
        format_sql: true
        use_sql_comments: true

logging:
  level:
    org.hibernate.SQL: DEBUG
    org.hibernate.type.descriptor.sql: TRACE  # 파라미터 값 출력

p6spy로 쿼리 개수 확인:

implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.0'
# application.yml
decorator:
  datasource:
    p6spy:
      enable-logging: true

출력 예시:

# N+1 발생 시
[1] SELECT * FROM team;
[2] SELECT * FROM member WHERE team_id = 1;
[3] SELECT * FROM member WHERE team_id = 2;
...
[101] SELECT * FROM member WHERE team_id = 100;

# Batch Size 적용 시
[1] SELECT * FROM team;
[2] SELECT * FROM member WHERE team_id IN (1, 2, ..., 100);

5.4 테스트 코드로 검증

@SpringBootTest
@Transactional
class TeamServiceTest {

    @Autowired
    private TeamService teamService;

    @Autowired
    private DataSource dataSource;

    @Test
    void testNPlusOne() {
        // 쿼리 카운터 활성화
        QueryCounter queryCounter = new QueryCounter(dataSource);

        // 테스트 실행
        List<TeamResponse> teams = teamService.getTeams();

        // 쿼리 개수 검증
        int queryCount = queryCounter.getTotalQueryCount();
        assertThat(queryCount).isLessThanOrEqualTo(2);  // ✅ 2개 이하
    }
}

요약: 실무에서 기억할 것

N+1 문제 감각

  • “1번의 쿼리 + N번의 추가 쿼리”로 폭발합니다(데이터/트래픽이 커질수록 치명적).
  • FetchType.EAGER로 해결되지 않고, 오히려 예측하기 어려운 쿼리를 만들 수 있습니다.
  • 기본은 LAZY로 두고, “화면/유스케이스” 단위로 로딩 전략을 선택합니다.

해결 옵션(상황별 선택)

  • Fetch Join: 한 번에 당겨오되, 컬렉션/페이징 조합에 주의가 필요합니다.
  • @EntityGraph: 어노테이션 기반 Fetch Join(사용성 좋음).
  • @BatchSize: IN 절 배치 조회로 N을 줄이기(페이징과 함께 쓰기 쉬움).
  • DTO Projection: 필요한 컬럼만 가져오는 조회(성능/명확성↑, 대신 엔티티 그래프 활용↓).

운영/검증 루틴

  • Batch size는 전역/국소로 근거 있게 설정(무작정 크게 하면 IN 절/메모리 비용이 생길 수 있음)
  • ToOne은 Fetch Join/배치로 해결하기 쉬운 편, ToMany는 페이징/중복에 더 민감
  • 쿼리 로그/통계로 “쿼리 수”를 측정하고, 테스트로 회귀를 막는 습관이 중요합니다