N+1 문제와 해결 전략에서 @BatchSize를 간단히 다뤘다. 이 문서에서는 batch 조회가 내부적으로 어떻게 동작하는지, 그리고 상황에 따라 어떤 batch 패턴을 골라야 하는지를 깊이 있게 정리한다.

@BatchSize의 동작 원리

@BatchSize는 지연 로딩된 프록시를 초기화할 때, 개별 쿼리 대신 IN 절로 묶어서 한 번에 가져오는 전략이다. N+1을 완전히 없애는 게 아니라, 쿼리 횟수를 1 + ceil(N / batchSize)로 줄인다.

팀 50개를 조회하고, 각 팀의 멤버에 접근하는 상황을 보자. @BatchSize(size = 25)가 적용되어 있다.

sequenceDiagram autonumber actor App as 애플리케이션 participant JPA as Hibernate participant DB as 데이터베이스 App->>JPA: findAll() 호출 rect rgb(232, 248, 232) JPA->>DB: SELECT * FROM team DB-->>JPA: 팀 50개 반환 Note right of DB: 쿼리 1개 end Note over App, DB: 첫 번째 팀의 멤버에 접근하는 순간 rect rgb(240, 248, 255) App->>JPA: team1.getMembers() JPA->>DB: SELECT * FROM member
WHERE team_id IN (1,2,...,25) DB-->>JPA: 팀 1~25의 멤버 일괄 반환 Note right of DB: 쿼리 1개 (25개 묶음) end rect rgb(240, 248, 255) App->>JPA: team26.getMembers() JPA->>DB: SELECT * FROM member
WHERE team_id IN (26,27,...,50) DB-->>JPA: 팀 26~50의 멤버 일괄 반환 Note right of DB: 쿼리 1개 (25개 묶음) end
  • 초록 영역 : 최초 팀 목록 조회. 여기까진 일반 조회와 동일하다.
  • 파랑 영역 : 핵심이다. team1.getMembers()를 호출하면 Hibernate가 "아직 초기화 안 된 프록시가 25개 더 있네"라고 판단하고, 현재 것 포함 최대 25개를 IN 절로 묶어 한 번에 가져온다. 팀 50개이므로 쿼리 2번이면 끝난다.

@BatchSize 없이 N+1이 발생하면 쿼리 51개가 날아갈 상황을, 3개로 줄인 것이다.

IN 절 패딩 전략

Hibernate 6(Spring Boot 3.x)부터는 IN 절의 파라미터 개수를 2의 거듭제곱으로 패딩한다. 이걸 이해해야 왜 실행된 SQL의 ? 개수가 기대와 다른지 알 수 있다.

@BatchSize(size = 100)으로 설정했는데, 실제로 초기화할 프록시가 37개라고 하자. 패딩 없이는 IN (?, ?, ..., ?) 안에 물음표가 37개 들어간다. 하지만 패딩이 켜져 있으면 37보다 큰 가장 가까운 2의 거듭제곱인 64개로 맞춘다. 나머지 27자리는 이미 포함된 ID 중 하나를 반복해서 채운다.

왜 이렇게 할까? DB는 쿼리를 파싱한 결과를 캐싱하는데, IN 절의 파라미터 개수가 바뀔 때마다 새로운 쿼리로 취급한다. 패딩이 없으면 37개짜리, 42개짜리, 51개짜리 쿼리가 각각 따로 캐싱된다. 패딩을 적용하면 쿼리 형태가 2, 4, 8, 16, 32, 64, 128로 고정되므로 DB의 쿼리 플랜 캐시 히트율이 올라간다.

# 패딩 비활성화 (필요한 경우)
spring:
  jpa:
    properties:
      hibernate:
        query:
          in_clause_parameter_padding: false

대부분의 경우 기본값(패딩 활성화)을 그대로 두는 게 좋다.

글로벌 설정 vs 어노테이션

batch fetch size를 적용하는 방법은 두 가지다.

글로벌 설정

spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100

모든 지연 로딩 연관관계에 일괄 적용된다. 별도 코드 수정 없이 N+1을 전역으로 줄이는 기본 방어막 역할을 한다.

어노테이션

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

특정 연관관계에만 다른 사이즈를 적용할 때 쓴다. 글로벌 설정보다 우선한다.

실무에서는 글로벌 default_batch_fetch_size를 100~1000으로 설정하고, 특별한 경우에만 @BatchSize 어노테이션으로 오버라이드하는 전략이 일반적이다. 사이즈가 너무 크면 IN 절이 길어져서 DB 쿼리 파싱 비용이 올라가고, 너무 작으면 쿼리 횟수가 늘어난다.

적정 사이즈는 대상 테이블의 row 크기와 평균 조회 건수에 따라 다르지만, 대부분의 애플리케이션에서 100이면 충분하다. 1000을 넘기는 건 Oracle의 IN 절 제한(1000개) 등 DB별 제약과 충돌할 수 있으니 주의가 필요하다.

Fetch Join과 BatchSize 비교

둘 다 N+1 해결 전략이지만, 작동 방식과 적합한 상황이 다르다.

flowchart TD Start{"연관 데이터
조회 패턴은?"} Start -->|"항상 함께 쓴다"| Q1{"페이징이
필요한가?"} Q1 -->|"No"| FJ["Fetch Join"] Q1 -->|"Yes"| BS1["BatchSize"] Start -->|"여러 컬렉션
동시 로딩"| BS2["BatchSize"] Start -->|"쓸 때도 있고
안 쓸 때도 있다"| BS3["BatchSize"] Start -->|"전역 방어막"| Global["글로벌 설정"] style FJ fill:#E8F4F8,stroke:#2196F3,stroke-width:2px,color:#000 style BS1 fill:#FFF3E0,stroke:#FF9800,stroke-width:2px,color:#000 style BS2 fill:#FFF3E0,stroke:#FF9800,stroke-width:2px,color:#000 style BS3 fill:#FFF3E0,stroke:#FF9800,stroke-width:2px,color:#000 style Global fill:#F3E5F5,stroke:#9C27B0,stroke-width:2px,color:#000
  • Fetch Join (파랑) : JOIN으로 한 방에 가져온다. 쿼리 1개로 끝나지만, 컬렉션 페이징이 불가능하고 다중 컬렉션도 안 된다.
  • BatchSize (주황) : 지연 로딩을 유지하면서 IN 절로 묶는다. 페이징이 자유롭고, 연관 데이터를 안 쓰면 아예 쿼리가 안 나간다.
  • 글로벌 설정 (보라) : 코드 수정 없이 전역 적용. 나머지 전략과 조합해서 쓴다.

실제 쿼리 수를 비교하면 차이가 명확하다. 팀 50개, 각 팀의 멤버와 프로젝트를 함께 조회하는 상황이다.

xychart-beta title "조회 전략별 쿼리 수 비교 (팀 50개)" x-axis ["N+1", "Fetch Join", "BatchSize 25", "BatchSize 100"] y-axis "실행 쿼리 수" 0 --> 110 bar [101, 1, 5, 2]
  • N+1 : 1(팀) + 50(멤버) + 50(프로젝트) = 101개. 데이터가 늘면 선형으로 증가한다.
  • Fetch Join : 1개지만, 2개 컬렉션을 동시에 JOIN하면 MultipleBagFetchException이 발생한다. 실제로는 불가능한 경우가 많다.
  • BatchSize 25 : 1 + 2(멤버 50/25) + 2(프로젝트 50/25) = 5개.
  • BatchSize 100 : 1 + 1(멤버 50개 한 번) + 0~1(프로젝트) = 2~3개.
Fetch Join은 @ManyToOne 방향에서 가장 효과적이다. 결과 row가 뻥튀기되지 않기 때문이다. 반면 @OneToMany 컬렉션에서는 BatchSize가 더 안전한 선택이다.

수동 Batch 조회 패턴

@BatchSize는 지연 로딩 프록시에만 작동한다. 연관관계가 아닌 별도 리포지토리를 호출하는 경우에는 직접 IN 쿼리를 작성해야 한다.

대표적인 예가 프로젝트에서 만난 getUserSummary() 패턴이다. Comment의 author(User)는 fetchJoin으로 가져왔지만, 각 User의 Profile은 별도 리포지토리를 통해 조회해야 한다.

N+1이 발생하는 코드

comments.stream()
    .map(c -> CommentDto.from(c, getUserSummary(c.getAuthor())))  // 댓글마다 profile 쿼리 1개
    .toList();

private UserSummary getUserSummary(User user) {
    return UserSummary.from(user, profileRepository.findByUserId(user.getId())
            .orElseThrow(...));
}

댓글 10개면 profile 조회 쿼리가 10개 나간다. author가 중복되더라도 매번 쿼리를 날린다.

수동 batch 조회로 해결하는 흐름을 보자.

sequenceDiagram autonumber actor App as 서비스 participant Repo as ProfileRepository participant DB as 데이터베이스 Note over App: 댓글 목록에서 author ID 추출 rect rgb(232, 248, 232) App->>Repo: findByUserIds(authorIds) Repo->>DB: SELECT * FROM profile
WHERE user_id IN (?, ?, ...) DB-->>Repo: Profile 목록 반환 Note right of DB: 쿼리 단 1개 end Note over App: Map으로 변환 후 O(1) 조회 rect rgb(240, 248, 255) App->>App: authorId → Profile 매핑 Note over App: 댓글마다 Map에서
profile을 꺼냄 end
  • 초록 영역 : ID 목록을 모아서 IN 쿼리 1개로 profile을 한 번에 가져온다.
  • 파랑 영역 : 가져온 결과를 Map으로 변환해서, 각 댓글의 author ID로 O(1) 조회한다.

수동 batch 조회 코드

public CommentGetResponse getCommentsByCursor(CommentGetRequest request) {
    List<Comment> comments = commentRepository.getCommentsByCursor(request);
    // ... 커서 처리 생략 ...

    // 1. author ID를 중복 없이 추출
    List<UUID> authorIds = comments.stream()
            .map(c -> c.getAuthor().getId())
            .distinct()
            .toList();

    // 2. IN 쿼리로 profile을 한 번에 조회
    Map<UUID, Profile> profileMap = profileRepository.findByUserIds(authorIds).stream()
            .collect(Collectors.toMap(p -> p.getUser().getId(), p -> p));

    // 3. Map에서 꺼내서 DTO 변환
    List<CommentDto> dtos = comments.stream()
            .map(c -> {
                Profile profile = profileMap.get(c.getAuthor().getId());
                return CommentDto.from(c, UserSummary.from(c.getAuthor(), profile));
            })
            .toList();

    return new CommentGetResponse(dtos, ...);
}

핵심은 루프 밖에서 필요한 데이터를 한 번에 모아 가져오고, Map으로 변환하는 것이다. 이 패턴은 JPA 연관관계가 아닌 모든 N+1 상황에 적용할 수 있다.

리포지토리 쪽에서는 IN 쿼리를 제공해야 한다.

public interface ProfileRepository extends JpaRepository<Profile, UUID> {

    @Query("SELECT p FROM Profile p JOIN FETCH p.user u WHERE u.id IN :ids")
    List<Profile> findByUserIds(@Param("ids") List<UUID> ids);
}

JOIN FETCH p.user를 넣어서 profile → user 방향의 N+1도 미리 차단한다.

findAllById()는 Spring Data JPA의 기본 메서드지만, 대상이 Profile이 아니라 User ID로 조회하는 상황이므로 커스텀 쿼리가 필요하다. findAllById엔티티 자체의 PK로만 조회한다.

전략 선택 가이드

세 가지 batch 전략이 각각 어떤 상황에 맞는지 정리하면 다음과 같다.

flowchart TD Start{"N+1이
발생하는 곳은?"} Start -->|"엔티티 연관관계
(@OneToMany 등)"| Q1{"항상 함께
쓰는가?"} Q1 -->|"Yes, 페이징 없음"| FJ["Fetch Join"] Q1 -->|"Yes, 페이징 필요"| BS["@BatchSize"] Q1 -->|"No, 때에 따라"| Global["글로벌 BatchSize"] Start -->|"별도 리포지토리
호출 (profile 등)"| Manual["수동 IN 쿼리
+ Map 변환"] style FJ fill:#E8F4F8,stroke:#2196F3,stroke-width:2px,color:#000 style BS fill:#FFF3E0,stroke:#FF9800,stroke-width:2px,color:#000 style Global fill:#F3E5F5,stroke:#9C27B0,stroke-width:2px,color:#000 style Manual fill:#E8F8E8,stroke:#4CAF50,stroke-width:2px,color:#000
  • Fetch Join (파랑) : 연관관계 + 항상 함께 + 페이징 불필요. @ManyToOne 방향에서 가장 효과적.
  • @BatchSize (주황) : 연관관계 + 페이징 필요, 또는 다중 컬렉션.
  • 글로벌 BatchSize (보라) : 전역 방어막. 다른 전략과 조합.
  • 수동 IN 쿼리 (초록) : 연관관계가 아닌 별도 리포지토리 호출. Map 패턴과 함께 사용.
상황전략쿼리 수
@ManyToOne 항상 함께 사용Fetch Join1
@OneToMany 페이징 필요@BatchSize1 + ceil(N/size)
여러 컬렉션 동시 로딩@BatchSize1 + 컬렉션 수 × ceil(N/size)
별도 리포지토리 조회수동 IN 쿼리1 + 1
전역 방어막글로벌 설정자동 최적화

자주 하는 실수

루프 안에서 개별 조회

forstream().map() 안에서 repository.findById()를 호출하면 항상 N+1이다. fetchJoin이나 수동 batch로 루프 밖에서 한 번에 가져와야 한다.

[!DANGER] BatchSize를 너무 크게 설정

IN 절에 파라미터가 수천 개 들어가면 DB 쿼리 파싱 비용이 급증한다. Oracle은 IN 절에 1000개 제한이 있고, MySQL도 max_allowed_packet 설정에 걸릴 수 있다. 100~500 사이가 안전하다.

[!DANGER] 수동 batch에서 Map 키 충돌

Collectors.toMap()은 키가 중복되면 IllegalStateException을 던진다. 같은 author가 여러 댓글을 달면 profile이 중복으로 들어오는데, distinct()로 ID를 중복 제거하거나 toMap()의 세 번째 인자로 머지 함수를 넘겨야 한다.

```java

.collect(Collectors.toMap(p -> p.getUser().getId(), p -> p, (a, b) -> a));

```

[!DANGER] @BatchSize가 적용 안 되는 착각

@BatchSize지연 로딩 프록시 초기화 시점에만 작동한다. JPQL이나 QueryDSL에서 직접 조회하는 경우에는 효과가 없다. 직접 조회하는 쿼리에 N+1이 있다면 fetchJoin이나 수동 batch를 써야 한다.


관련 문서 : N+1 문제와 해결 전략, JPA Fetch 타입 완전 정복, 05 JPA fetch join