N+1과 @BatchMapping
GraphQL의 field resolver는 부모 객체마다 호출될 수 있습니다. 세션 100건의 stats를 각각 DB에 물으면 목록 SQL 1번 뒤에 100번의 집계 SQL이 따라옵니다. @BatchMapping은 한 GraphQL request에서 필요한 부모를 모아 하나의 조회로 바꾸고, 결과를 다시 각 부모에 연결합니다.
필드 해석이 반복되면 조회 수도 증폭된다
batch의 핵심은 단순히 IN query를 쓰는 것이 아니라 요청된 모든 부모에 결과를 돌려주는 것입니다. 집계 행이 없는 과목도 0으로 채워 map에 포함해야 합니다. list 반환을 쓰면 입력 순서와 결과 순서가 정확히 맞아야 하고, map 반환은 parent 객체를 key로 사용합니다.
StudySessionResponse는 record라 equals/hashCode가 안정적으로 제공되어 map key로 쓸 수 있습니다. mutable entity를 key로 쓰면 field 변경 후 hash가 바뀌어 결과를 찾지 못할 수 있으므로 전송 DTO를 부모 타입으로 유지합니다.
과목 통계를 부모 객체 묶음으로 적재한다
schema에 SubjectStats와 StudySession.stats를 우선 추가합니다. Java 부모 타입의 simple name은 StudySessionResponse이고 schema type은 StudySession이므로, @BatchMapping(typeName = "StudySession", field = "stats")를 명시하지 않으면 resolver가 다른 좌표에 등록됩니다. repository는 Spring Data Repository를 상속해 실제 bean이 되고 JPQL constructor projection으로 과목별 count와 sum을 한 번에 가져옵니다.
type SubjectStats {
subject: String!
sessions: Int!
minutes: Int!
}
extend type StudySession {
stats: SubjectStats!
}package com.andongmin.studylog.graphql;
public record SubjectStats(String subject, long sessions, long minutes) {
}package com.andongmin.studylog.graphql;
import java.util.Collection;
import java.util.List;
import java.util.UUID;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.Repository;
import org.springframework.data.repository.query.Param;
import com.andongmin.studylog.session.StudySessionEntity;
public interface SubjectStatsRepository
extends Repository<StudySessionEntity, UUID> {
@Query("""
select new com.andongmin.studylog.graphql.SubjectStats(
session.subject, count(session), sum(session.minutes))
from StudySessionEntity session
where session.subject in :subjects
group by session.subject
""")
List<SubjectStats> summarize(@Param("subjects") Collection<String> subjects);
}package com.andongmin.studylog.graphql;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.springframework.graphql.data.method.annotation.BatchMapping;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import com.andongmin.studylog.session.StudySessionResponse;
@Controller
public class StudySessionStatsController {
private final SubjectStatsRepository repository;
public StudySessionStatsController(SubjectStatsRepository repository) {
this.repository = repository;
}
@BatchMapping(typeName = "StudySession", field = "stats")
@PreAuthorize("hasAuthority('SCOPE_study.read')")
public Map<StudySessionResponse, SubjectStats> stats(
List<StudySessionResponse> sessions) {
Set<String> subjects = sessions.stream()
.map(StudySessionResponse::subject)
.collect(Collectors.toSet());
Map<String, SubjectStats> bySubject = repository.summarize(subjects).stream()
.collect(Collectors.toMap(SubjectStats::subject, Function.identity()));
return sessions.stream().collect(Collectors.toMap(
Function.identity(),
session -> bySubject.getOrDefault(session.subject(),
new SubjectStats(session.subject(), 0, 0))));
}
}N+1은 response 모양만으로는 보이지 않고 SQL 수로 보입니다. 반면 key 연결 실패는 SQL은 한 번만 나갔는데 다른 세션의 통계가 붙거나 null이 나오는 응답 오류로 드러납니다. 그래서 query 횟수와 parent별 결과를 함께 확인합니다.
SQL 횟수와 결과 순서를 함께 검증한다
컴파일 후 terminal A에서 SQL 로깅을 켜고 서버를 실행합니다. terminal B에서 studySessions { id subject stats { sessions minutes } }를 read token으로 호출했을 때, 세션 수와 관계없이 통계 JPQL은 한 번만 실행되어야 합니다. 응답에서 같은 subject는 같은 집계를 가지고, 집계 행이 없는 경우도 0으로 채워져야 합니다.
./mvnw -q -DskipTests compile
export LOCAL_JWT_SECRET="study-log-local-development-key-2026"
export SPRING_JPA_SHOW_SQL=true
SPRING_PROFILES_ACTIVE=local ./mvnw -q spring-boot:runexport LOCAL_JWT_SECRET="study-log-local-development-key-2026"
READ_TOKEN="$(java dev/LocalToken.java "$LOCAL_JWT_SECRET" "study.read")"
WRITE_TOKEN="$(java dev/LocalToken.java "$LOCAL_JWT_SECRET" "study.write")"
curl -s http://localhost:8080/graphql \
-H "Authorization: Bearer $WRITE_TOKEN" \
-H "Content-Type: application/json" --data-binary @create-session.json
curl -s http://localhost:8080/graphql \
-H "Authorization: Bearer $READ_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"query\":\"{ studySessions { subject stats { sessions minutes } } }\"}"세션 N개를 조회해도 과목 통계 repository 호출은 batch 한 번이어야 하고 반환 map은 모든 세션 key를 포함해야 합니다.DataLoader의 수명은 요청 경계에 둔다
Spring GraphQL이 @BatchMapping으로 만든 DataLoader는 GraphQL request 단위로 생성됩니다. 사용자·tenant·scope에 따라 결과가 달라질 수 있는 값을 전역 cache에 넣으면 다른 요청의 결과가 섞여 보안 문제가 됩니다. 요청 안의 중복 제거와 요청 간 cache를 구분합니다.
batch가 이득이 아닌 경우도 있다
부모가 한 건뿐이거나 연관 값이 이미 같은 query로 fetch된다면 batch가 추가 복잡도만 만들 수 있습니다. 조회 비용, 한 request의 부모 수, 연관 field가 선택되는 빈도를 보고 join fetch·projection·batch 중 하나를 선택합니다. GraphQL은 field가 요청되지 않으면 resolver도 실행하지 않는다는 점이 batch에 유리합니다.
현재까지는 요청 하나가 응답 하나로 끝났습니다. 다음에는 세션 완료 후 여러 건의 event를 오래 열린 HTTP SSE 연결로 보내며 속도 차이를 다룹니다.