들어가며
우테코 방학중에 블로그에 모니터링을 연결해보았습니다.
모니터링을 연결하니 문제가 보이기 시작했습니다.
이번 글은 모니터링을 통해 발견한 병목을 해결하는 과정을 적어볼 예정입니다.
1. API 하나가 수상하다
최근 며칠간의 API 지표를 분석해보니, 개선이 필요한 API를 확인할 수 있었습니다.

GET /api/v1/posts는 호출이 1682번 되었는데, 평균 응답 시간이 453ms인 반면에 응답 시간의 p95는 1.62s가 결렸습니다.
p95란?
전체 데이터(요청 등)를 작은 값부터 큰 값 순으로 나열했을 때, 95%에 해당하는 지점의 값
평균으로만 본다면 조금 느린 API네 라고 생각할 수 있지만, p95 수치를 보니 해당 API를 병목이 있는지 확인해볼 이유가 생겼습니다.
1,682건의 요청을 분석한 결과 평균 응답시간은 453ms였지만 p95는 1.62초로 약 3.6배 차이가 있었습니다. 이 수치만으로 성능 문제라고 판단하지 않고, 요청별 응답시간 편차가 발생하는 원인을 확인하기 위해 느린 요청의 Trace를 분석했습니다.
2. 너 왜 느려
GET /api/v1/posts를 자세히 확인하기 위해 Trace를 관측해보았습니다. 해당 요청은 26년 9월 11일 요청이고, 요청 응답 속도는 1.92s가 걸렸습니다.

확인 결과 Query의 속도에서 병목은 없었지만, PostTagRepository.findPostTagsByPost_Id과PostRepository.findPostsByCategoryAndIsPublishedTrueOrderByPublishedAtDesc 메서드가 전체 게시물 수만큼 반복 호출되며 병목을 일으키고 있었습니다.
느린 응답만 보면 반복 쿼리 문제가 있습니다. 이제는 빠른 응답을 확인해보겠습니다. 26년 9월 12일 요청이고, 요청 응답 속도는 228.74ms가 걸렸습니다.

요청 속도는 9배 이상 빨랐지만, 게시물 수만큼 같은 조회 메서드가 반복 호출 되고 있었습니다.
따라서 API의 속도 저하 문제를 전체 게시물 수만큼 같은 PostTagRepository.findPostTagsByPost_Id과PostRepository.findPostsByCategoryAndIsPublishedTrueOrderByPublishedAtDesc가 반복됨으로 정의했습니다.
속도 저하 문제 정의
전체 게시물 수만큼
PostTagRepository.findPostTagsByPost_Id과PostRepository.findPostsByCategoryAndIsPublishedTrueOrderByPublishedAtDesc이 반복
3. 어떤 코드가 문제야?
전체 게시물을 반환하는 메서드입니다.
public List<PostResponse> getAllPosts() {
List<Post> allPosts = postRepositoryFacade.findAllPosts();
return convertPostResponse(allPosts);
}
여기에는 convertPostResponse()라는 메서드가 호출되고 있는데요. 목적은 List<Post>를 List<PostResponse> 형식으로 변환하는 메서드입니다.
public List<PostResponse> convertPostResponse(List<Post> posts) {
List<PostResponse> postResponses = new ArrayList<>();
for (Post post : posts) {
List<Tag> tags = postTagService.findTagByPostId(post.getId());
List<String> tagNames = new ArrayList<>();
for (Tag tag : tags) {
tagNames.add(tag.getTagName());
}
postResponses.add(new PostResponse(
post.getId(),
post.getTitle(),
post.getSummary(),
post.getAuthor(),
post.getContent(),
post.getViews(),
post.getIsPublished(),
post.getPublishedAt(),
CategoryResponse.builder()
.categoryId(post.getCategory().getId())
.categoryName(post.getCategory().getCategoryName())
.categoryColor(post.getCategory().getCategoryColor())
.categoryDescription(post.getCategory().getCategoryColor())
.postCount(postRepositoryFacade.countPostByCategory(post.getCategory()))
.build(),
tagNames
));
}
return postResponses;
}
convertPostResponse()에서 모든 게시물 수만큼PostTagRepository.findPostTagsByPost_Id()과 PostRepository.findPostsByCategoryAndIsPublishedTrueOrderByPublishedAtDesc()를 각각 호출하고 있었습니다.
모니터링을 통해 정의한 문제가 코드에서도 정확히 일치했으므로, 위의 코드를 개선하면 병목이 해결될 것입니다.
4. 개선하는 방법은?
개선하는 방법은 조회하려는 데이터를 한 번에 가져오는 방법이었습니다.
개선 방안
- Post를 조회할 때, Tag도 한번에 가져오는 쿼리를 만들어 사용한다.
List<Long> postIds를 이용해 한 번에 태그와 게시물 수를Map<String, Integer>로 가져온다.
저는 1번 2번 고민을 하다가 2번 방식으로 선택했습니다. 이유는 Post를 조회할 때 항상 태그까지 가져오게되면, 추후 확장성에 영향이 있을 것 같고, 현재 게시물 수만큼 나가는 쿼리 횟수를 2번으로 줄이면 쿼리 호출 시간복잡도가 O(N)에서 O(1)로 감소하기 때문에 충분하다고 판단했습니다.
개선 코드
public List<PostResponse> convertPostResponse(List<Post> posts) {
List<PostResponse> postResponses = new ArrayList<>();
List<Long> postIds = new ArrayList<>();
List<Category> categories = new ArrayList<>();
for (Post post : posts) {
postIds.add(post.getId());
categories.add(post.getCategory());
}
Map<Long, List<String>> tagNamesByPostIds = postTagService.findTagNamesByPostIds(postIds);
Map<Long, Integer> postCountByCategoryIds = postRepositoryFacade.countPostsByCategory(categories);
for (Post post : posts) {
List<String> tagNames = tagNamesByPostIds.getOrDefault(post.getId(), new ArrayList<>());
postResponses.add(new PostResponse(
post.getId(),
post.getTitle(),
post.getSummary(),
post.getAuthor(),
post.getContent(),
post.getViews(),
post.getIsPublished(),
post.getPublishedAt(),
CategoryResponse.builder()
.categoryId(post.getCategory().getId())
.categoryName(post.getCategory().getCategoryName())
.categoryColor(post.getCategory().getCategoryColor())
.categoryDescription(post.getCategory().getCategoryDescription())
.postCount(postCountByCategoryIds.getOrDefault(post.getCategory().getId(), 0))
.build(),
tagNames
));
}
return postResponses;
}
위와 같이 쿼리 호출 구간을 for문 전으로 변경해 쿼리의 횟수를 줄였습니다.
5. 배포 전, 진짜 개선 됐을까?
저는 배포 전에 로컬에서 운영 환경에서 동일한 데이터를 가지고 K6를 이용해 측정해보았습니다.
측정 조건
- 환경: 맥북 M3
- DB: MySQL
- 데이터: 운영 환경과 동일
- VU: 1
- 요청 횟수: 1000번
측정 결과
| 지표 | 개선 전 | 개선 후 | 변화 |
|---|---|---|---|
| 평균 응답 시간 | 50.67 ms | 9.98 ms | 80.3% 감소 |
| p95 응답 시간 | 73.97 ms | 12.86 ms | 82.6% 감소 |
| 최대 응답 시간 | 352.08 ms | 154.74 ms | 56.0% 감소 |
| 요청 성공률 | 100% | 100% | 동일 |
로컬에서 하니 실제 운영 서버에서의 속도에 비해 전체적으로 빠르게 나왔습니다. 로컬 환경을 감안하더라도 응답 시간이 약 80% 항상 되었고, 최대 응답 시간도 50% 이상 향상됨을 확인할 수 있었습니다.
6. 배포 후, 실제로 관찰해보자!
해당 문제를 해결한 뒤에 배포를 해보고 실제모니터링을 이용해 문제가 개선 되었는지 확인해보았습니다.
개선 전 API 요청 속도

개선 후 API 요청 속도

| 지표 | 개선 전 | 개선 후 | 개선량 | 개선율 |
|---|---|---|---|---|
| 평균 응답시간 | 449 ms | 45.2 ms | 403.8 ms 감소 | 89.9% 감소 |
| p95 응답시간 | 1.51 s | 94.3 ms | 1,415.7 ms 감소 | 93.8% 감소 |
배포 전후 모니터링 결과

실제 지표를 확인해보니, 평균 응답 시간은 약 90% 향상, p95도 약 94% 향상으로 로컬에서 측정한 결과보다 더 큰 요청 응답 속도 향상이 있었습니다.
마치며
지금까지 성능 개선은 코드의 정적 분석을 통해 진행했습니다. 그렇다보니 API 속도 개선의 필요성이나 정말 개선되었는지를 확인하기 어려웠습니다. 하지만 모니터링을 구축하고 요청수를 기반으로 개선해야 할 API를 선택한 뒤, 속도 향상까지 직접 진행하니 모니터링의 중요성에 대해 알 수 있었습니다. 앞으로 서버에 대해 관측가능성을 확보하여 문제를 설명하고 더 나아가 개선을 하는