본문 바로가기
Today I Learn

TIL — 2026.09.11

by 준모 2026. 9. 11.

TIL — 2026.09.11

Spring JPA 작업 중 배운 것 정리.

  1. N+1 문제 → GROUP BY + DTO 프로젝션 해결
  2. 외부 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