TIL — 2026.09.11
Spring JPA 작업 중 배운 것 정리.
- N+1 문제 → GROUP BY + DTO 프로젝션 해결
- 외부 API 랭킹 데이터 가공 (RestClient)
1. N+1 문제 → GROUP BY + DTO 프로젝션
문제 상황
게임 목록 API에서 게임별 덱 카드 수(deckSize) 응답 필요.
가장 단순한 방법:
for (Game game : games) {
int deckSize = runCardRepository.findAllByGameOrderByIdAsc(game).size();
}
게임마다 쿼리 1번씩 발생. 목록 조회 1번(N) + 게임별 카드 수 조회 N번 = 총 N+1번.
게임 3개면 4번, 100개면 101번. 데이터가 늘어날수록 쿼리 수가 비례 증가하는 게 문제.
해결
핵심: 반복문으로 N번 조회하지 말고, ID를 모아서 GROUP BY로 한 번에 집계.
@Query("SELECT new com.gamebasic.runcard.dto.DeckCount(rc.game.id, COUNT(rc)) " +
"FROM RunCard rc " +
"WHERE rc.game IN :games " +
"GROUP BY rc.game.id")
List<DeckCount> countByGames(@Param("games") List<Game> games);
SELECT new 패키지.클래스(...) 생성자 표현식 사용 → GROUP BY 결과를 Object[] 대신 DTO로 바로 반환.
서비스단에서 Map<gameId, cardCount>로 변환 후 매칭.
Map<Long, Long> deckSizeMap = deckCountList.stream()
.collect(Collectors.toMap(DeckCount::getGameId, DeckCount::getCardCount));
deckSizeMap.getOrDefault(game.getId(), 0L); // 카드 0장인 게임 대비 기본값 필수
결과: 쿼리 "목록 조회 1번 + 집계 1번" = 총 2번 고정. 게임 개수와 무관.
삽질 포인트
① 문자열 이어붙이기 공백 누락
- 여러 줄 JPQL을 +로 연결할 때 공백을 빠뜨리면 ))FROM, rcWHERE처럼 토큰이 붙어서 파싱 깨짐
- 줄마다 공백 필수
② @Param 누락 / 이름 불일치
- 쿼리의 :games와 메서드 파라미터명이 안 맞으면, 컴파일 옵션(-parameters)에 따라 조용히 실패 가능
- @Param("games") 명시 필요
③ : 와 파라미터 이름 사이 공백 (IN : games)
- Hibernate 자체 파서는 공백 허용, 정상 인식
- Spring Data JPA의 파라미터 사전 스캔 로직은 공백 미허용 → 바인딩 누락
- 결과: Hibernate가 "파라미터는 있는데 값이 없다"는 QueryParameterException 발생
- :games처럼 붙여 쓸 것
검증
spring.jpa.show-sql: true
logging.level.org.hibernate.SQL: debug
N+1 버전과 GROUP BY 버전을 각각 실행, 게임 개수를 바꿔가며 쿼리 수 로그 비교.
- N+1 버전: 게임 수만큼 증가
- GROUP BY 버전: 게임 수 무관, 고정
핵심 정리 연관 데이터는 반복문으로 하나씩 조회 금지. 필요한 ID를 모아 GROUP BY + DTO 프로젝션으로 한 번에 집계한 뒤 Map으로 매칭.
2. 외부 API 랭킹 가공 (RestClient)
전체 흐름
외부 API (JSON)
↓ RestClient.get().retrieve().body(RankingSource.class)
RankingSource (원본 DTO 트리)
↓ 1차 필터 — 순위 대상만
eligible
↓ 2차 필터 — 정상 기록 규칙 검증
valid
↓ 정렬 + 플레이어별 중복 제거
distinctByPlayer
↓ 순위 부여 + 응답 DTO로 재조립
RankingResponse
개념 1 — RestClient는 Bean으로 등록해서 DI로 쓴다
@Configuration
public class RestClientConfig {
@Bean
public RestClient restClient() {
return RestClient.create();
}
}
@Component
@RequiredArgsConstructor
public class RankingClient {
private final RestClient restClient;
public RankingSource fetch() {
return restClient.get().uri(SOURCE_URL).retrieve().body(RankingSource.class);
}
}
retrieve().body(클래스) 한 줄이 응답 JSON을 그 클래스 객체로 자동 변환. 내부적으로 Jackson이 JSON 필드명과 DTO(record) 컴포넌트명을 매칭해서 역직렬화.
개념 2 — 원본 DTO는 도메인 구조를 그대로 반영
JSON 중첩 구조를 그대로 따라가는 DTO 트리로 설계:
RankingSource
├─ Meta (season, generatedAt, schemaVersion, totalRecords)
└─ records: List<RankingRecord>
├─ client: ClientInfo
├─ player: PlayerInfo
├─ run: RunInfo (seed, status, clearedFloor, durationSeconds, finalHp, floors)
├─ bossFight: BossFight (10층 미클리어면 null)
└─ deck: Deck (size, cards)
값이 정해진 필드(status, cardType)도 enum이 아니라 String으로 받는다. 검증은 서비스 로직에서 별도로 수행 — DTO는 구조만 담당, 값의 유효성 판단은 담당하지 않는다는 역할 분리.
개념 3 — 필터링은 2단계 파이프라인
List<RankingRecord> eligible = source.records().stream()
.filter(this::isEligible) // 순위 대상 여부
.toList();
List<RankingRecord> valid = eligible.stream()
.filter(this::isValid) // 정상 기록 규칙 검증
.toList();
int excludedCount = eligible.size() - valid.size();
- isEligible: status == CLEARED && clearedFloor == 10 같은 "애초에 순위 계산 대상인가"
- isValid: 클리어 시간, 남은 HP, 덱 크기, 카드 타입, 획득 층, 보스 페이즈, 마무리 카드 등 "값 자체가 말이 되는가"
이렇게 단계를 나누면 excludedCount(순위 대상이었는데 이상 기록이라 제외된 수)를 뺄셈 한 줄로 구할 수 있다는 게 포인트.
개념 4 — 정렬은 Comparator 체이닝, 중복 제거는 Set
List<RankingRecord> sorted = valid.stream()
.sorted(Comparator
.comparing((RankingRecord r) -> r.run().durationSeconds())
.thenComparing((RankingRecord r) -> r.run().finalHp(), Comparator.reverseOrder())
.thenComparing(RankingRecord::id))
.toList();
1차 기준이 같을 때만 2차, 3차 기준으로 넘어가는 다단계 정렬.
Set<String> seenPlayerIds = new HashSet<>();
for (RankingRecord record : sorted) {
if (seenPlayerIds.add(record.player().id())) {
distinctByPlayer.add(record);
}
}
이미 정렬된 리스트를 앞에서부터 순서대로 훑으면서, Set.add()가 true(처음 보는 값)일 때만 채택하는 방식으로 "플레이어당 가장 좋은 기록 하나만" 남긴다.
개념 5 — 응답 DTO는 원본을 그대로 노출하지 않고 필요한 값만 재조립
private RankingEntry toEntry(RankingRecord record, int rank) {
return new RankingEntry(
rank,
record.player().name(),
record.run().durationSeconds().intValue(),
record.run().finalHp(),
record.bossFight().totalTurns(),
record.deck().cards().size()
);
}
외부 API의 원본 구조(RankingSource, RankingRecord)와 클라이언트에 내려주는 응답 구조(RankingResponse, RankingEntry)를 분리. 내부 처리용 DTO와 외부 공개용 DTO를 다른 타입으로 두면, 명세가 바뀌거나 값이 추가돼도 서로 영향을 덜 주고받는다는 게 이 구조의 장점.
'Today I Learn' 카테고리의 다른 글
| TIL - 2026.09.10 (0) | 2026.09.10 |
|---|