최근 /api/admin/publications/{paperId} 상세 조회 API를 확인하던 중, 서버에서는 크게 느리지 않은데 로컬에서 유독 지연이 큰 현상을 발견했다.
처음에는 단순 환경 차이일 수 있다고 생각했지만, 데이터를 바꿔가며 호출해 보니 패턴이 분명했다.
- 기사 38개인 케이스: 약 18초
- 기사 51개인 케이스: 약 28초
측정값 자체는 환경에 따라 달라질 수 있다.
다만 기사 수가 늘어날수록 지연이 크게 증가한다는 점이 명확해서, 이 구간을 병목으로 보고 로직을 점검했다.
원인: N+1 쿼리 패턴
상세 조회 로직에서 기사 목록을 가져온 뒤, 각 기사마다 키워드를 개별 조회하고 있었다.
즉, 기사 N개면 키워드 조회가 N번 추가되는 구조였다.
이런 N+1 패턴은 데이터가 적을 때는 잘 드러나지 않지만, 데이터가 쌓일수록 응답 시간이 빠르게 나빠질 수 있다.
개선: 배치 조회로 전환
개선 방향은 단순했다.
- 기사별 키워드 개별 조회 제거
- 기사 ID 목록을 모아 IN (...)으로 키워드를 한 번에 조회
- 조회 결과를 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);
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;
}
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;
}
정리
이번 개선의 핵심은 “몇 초 줄였다”보다,
데이터 증가에 따라 지연이 커지던 구조를 안정적으로 바꿨다는 점에 있다.
성능 이슈는 큰 장애가 나서 확인하기보다,
작은 이상 패턴을 보였을 때 바로 구조를 점검하는 게 훨씬 효과적이라는 걸 다시 느꼈다.
'💻 프로젝트 > 트러블 슈팅' 카테고리의 다른 글
| [KBO:NOTE] Docker Blue-Green 배포 구현 중 발생한 트러블슈팅 정리 (0) | 2026.03.15 |
|---|---|
| [QRoad]운영 전 API 부하 테스트를 진행하며 403을 마주하다 (문제 추적 과정 정리) (0) | 2026.03.02 |
| 나는 -i 옵션을 줬는데, 왜 다른 SSH 키가 사용됐을까? (0) | 2026.02.26 |
| [QRoad]PDF 기반 publication 업로드 구조 개선기 - 3편: Spring Boot 최소 구현 (0) | 2026.02.11 |
| [QRoad]PDF 기반 publication 업로드 구조 개선기 – 2편: S3 presigned URL 설계 (0) | 2026.02.11 |