💻 프로젝트/트러블 슈팅

[QRoad] 서버는 빠른데 로컬만 느린 API, N+1 병목 줄이기

SoloQuest 2026. 4. 4. 04:26

최근 /api/admin/publications/{paperId} 상세 조회 API를 확인하던 중, 서버에서는 크게 느리지 않은데 로컬에서 유독 지연이 큰 현상을 발견했다.
처음에는 단순 환경 차이일 수 있다고 생각했지만, 데이터를 바꿔가며 호출해 보니 패턴이 분명했다.

  • 기사 38개인 케이스: 약 18초
  • 기사 51개인 케이스: 약 28초

측정값 자체는 환경에 따라 달라질 수 있다.
다만 기사 수가 늘어날수록 지연이 크게 증가한다는 점이 명확해서, 이 구간을 병목으로 보고 로직을 점검했다.


원인: N+1 쿼리 패턴

상세 조회 로직에서 기사 목록을 가져온 뒤, 각 기사마다 키워드를 개별 조회하고 있었다.
즉, 기사 N개면 키워드 조회가 N번 추가되는 구조였다.

이런 N+1 패턴은 데이터가 적을 때는 잘 드러나지 않지만, 데이터가 쌓일수록 응답 시간이 빠르게 나빠질 수 있다.


개선: 배치 조회로 전환

개선 방향은 단순했다.

  1. 기사별 키워드 개별 조회 제거
  2. 기사 ID 목록을 모아 IN (...)으로 키워드를 한 번에 조회
  3. 조회 결과를 articleId -> keywords 형태로 그룹핑해 DTO 조합

Before:

  • 기사마다 키워드 조회 반복

After:

  • 키워드 일괄 조회 후 메모리 그룹핑

핵심은 반복 조회를 묶음 조회로 바꿔 DB 왕복 횟수를 줄인 것이다.


적용 코드 (요약)

Repository: 기사 ID 목록으로 키워드 일괄 조회

@Query("""
    SELECT ak.article.id, ak.keyword.name
    FROM ArticleKeywordEntity ak
    WHERE ak.article.id IN :articleIds
""")
List<Object[]> findArticleIdAndKeywordNameByArticleIds(@Param("articleIds") List<Long> articleIds);
 

Service: 조회 결과를 articleId 기준으로 그룹핑

private Map<Long, List<String>> getKeywordsForArticles(List<ArticleEntity> articles) {
    if (articles == null || articles.isEmpty()) {
        return Collections.emptyMap();
    }

    List<Long> articleIds = articles.stream()
            .map(ArticleEntity::getId)
            .toList();

    List<Object[]> rows = articleKeywordRepository.findArticleIdAndKeywordNameByArticleIds(articleIds);

    Map<Long, List<String>> keywordsByArticleId = new HashMap<>();
    for (Object[] row : rows) {
        Long articleId = ((Number) row[0]).longValue();
        String keywordName = (String) row[1];
        keywordsByArticleId
                .computeIfAbsent(articleId, ignored -> new ArrayList<>())
                .add(keywordName);
    }
    return keywordsByArticleId;
}

정리

이번 개선의 핵심은 “몇 초 줄였다”보다,
데이터 증가에 따라 지연이 커지던 구조를 안정적으로 바꿨다는 점에 있다.

성능 이슈는 큰 장애가 나서 확인하기보다,
작은 이상 패턴을 보였을 때 바로 구조를 점검하는 게 훨씬 효과적이라는 걸 다시 느꼈다.