들어가며

우테코 방학 동안 개인 블로그에 모니터링을 구축했습니다.

기존에는 성능 개선을 할 때 주로 코드를 먼저 살펴보았습니다. 코드에서 반복 쿼리와 같이 성능에 영향을 줄 수 있는 부분을 발견하면 이를 개선하고, 전후 성능을 비교하는 방식이었습니다.

그런데 모니터링을 연결하자 이전에는 보이지 않던 문제가 보이기 시작했습니다.

그중 하나가 GET /api/v1/posts였습니다. 평균 응답 시간은 453ms였지만 p95는 1.62초까지 증가하고 있었습니다.

느린 요청의 Trace를 따라가 보니 게시물 수에 비례해 동일한 조회가 반복되고 있었습니다. 이를 일괄 조회하도록 변경한 결과, 운영 환경에서 평균 응답 시간은 449ms → 45.2ms, p95는 1.51s → 94.3ms로 감소했습니다.

코드에서 문제를 먼저 찾은 것이 아니라, 모니터링을 통해 실제 운영 환경의 문제를 발견하고 원인을 추적한 뒤 개선 결과까지 다시 확인할 수 있었습니다.

이번 글에서는 모니터링에서 발견한 API의 성능 문제를 어떻게 정의하고, 원인을 찾아 개선했는지 적어보려고 합니다.


1. 평균 453ms와 p95 1.62초의 차이

최근 며칠간의 API 지표를 분석해보니 개선이 필요한 API를 확인할 수 있었습니다.

GET /api/v1/posts는 1,682번 호출되었는데, 평균 응답 시간이 453ms인 반면 p95 응답 시간은 1.62초였습니다.

p95란?

전체 요청의 응답 시간을 작은 값부터 큰 값 순으로 나열했을 때 95번째 백분위에 위치하는 값입니다.

p95가 1.62초라면 전체 요청의 약 95%가 1.62초 이내에 처리되었다는 의미입니다.

평균만 봤다면 단순히 조금 느린 API라고 생각할 수 있었습니다. 하지만 p95까지 함께 확인하니 일부 요청의 응답 시간이 크게 증가하고 있다는 사실을 확인할 수 있었습니다.

1,682건의 요청에서 평균 응답 시간은 453ms였지만 p95는 1.62초로 약 3.6배 차이가 있었습니다.

물론 이 수치만으로 성능 문제라고 단정할 수는 없습니다.

따라서 요청별 응답 시간의 편차가 왜 발생하는지 확인하기 위해 느린 요청의 Trace를 분석해보기로 했습니다.


2. 게시물 수에 비례하는 반복 조회

2.1 Trace에서 발견한 반복 조회

GET /api/v1/posts를 자세히 확인하기 위해 Trace를 관측해보았습니다.

먼저 2026년 9월 11일 발생한 느린 요청입니다. 해당 요청의 응답 시간은 1.92초였습니다.

확인 결과 개별 Query의 실행 시간에서 특별한 병목은 발견되지 않았습니다.

대신 PostTagRepository.findPostTagsByPost_Id와 PostRepository.findPostsByCategoryAndIsPublishedTrueOrderByPublishedAtDesc가 전체 게시물 수만큼 반복 호출되고 있었습니다.

그렇다면 빠르게 처리된 요청은 어떨까요?

2026년 9월 12일 발생한 동일 API의 요청을 확인했습니다. 해당 요청의 응답 시간은 228.74ms였습니다.

느린 요청보다 8배 이상 빠르게 처리되었지만, 여기에서도 게시물 수만큼 동일한 조회 메서드가 반복 호출되고 있었습니다.

즉, 느린 요청과 빠른 요청 모두에서 게시물 수에 비례해 동일한 조회가 반복되는 구조를 확인할 수 있었습니다.

따라서 API의 속도 저하 문제를 다음과 같이 정의했습니다.

속도 저하 문제 정의

게시물 목록을 조회할 때 각 게시물마다 태그와 카테고리의 게시물 수를 개별적으로 조회하면서, 게시물 수에 비례해 추가 쿼리가 반복 실행되고 있다.


2.2 코드에서 확인한 반복 조회의 원인

Trace에서 발견한 반복 조회가 실제 코드에서는 어떻게 발생하고 있는지 확인해보았습니다.

전체 게시물을 반환하는 메서드입니다.

public List<PostResponse> getAllPosts() {
    List<Post> allPosts = postRepositoryFacade.findAllPosts();
    return convertPostResponse(allPosts);
}

전체 게시물을 조회한 뒤 convertPostResponse()를 호출합니다.

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;
}

문제는 for문 내부에 있었습니다.

convertPostResponse()에서 모든 게시물을 순회하면서 PostTagRepository.findPostTagsByPost_Id()와 PostRepository.findPostsByCategoryAndIsPublishedTrueOrderByPublishedAtDesc()가 각각 호출되고 있었습니다.

게시물이 증가할수록 추가 조회 횟수도 함께 증가하는 구조였습니다.

Trace에서 발견한 **"게시물 수만큼 동일한 조회가 반복된다"**는 현상이 코드에서도 그대로 확인되었습니다.

모니터링을 통해 정의한 문제와 코드에서 발견한 원인이 일치했으므로, 이제 해당 부분의 반복 조회를 제거해보기로 했습니다.


3. 일괄 조회를 통한 쿼리 호출 횟수 감소

개선 방향은 필요한 데이터를 반복해서 하나씩 조회하지 않고 한 번에 가져오는 것이었습니다.

두 가지 방법을 고민했습니다.

개선 방안

  1. Post를 조회할 때 Tag도 함께 가져오는 쿼리를 만들어 사용한다.
  2. List<Long> postIds 등을 이용해 필요한 태그와 카테고리별 게시물 수를 한 번에 조회한 뒤 Map으로 변환한다.

저는 두 번째 방법을 선택했습니다.

Post를 조회할 때 항상 태그까지 가져오도록 변경하기보다, 현재 응답을 만드는 데 필요한 데이터를 별도의 일괄 조회로 가져오는 편이 기존 조회 로직을 크게 변경하지 않으면서 문제를 해결하기 적절하다고 판단했습니다.

또한 기존에는 게시물 수가 증가할수록 추가 조회 횟수도 함께 증가했지만, 일괄 조회를 사용하면 게시물 수에 비례하던 추가 쿼리 호출 횟수를 고정된 횟수로 줄일 수 있습니다.

여기서 쿼리 호출 횟수가 O(N)에서 O(1)로 줄었다는 것은 전체 API의 시간복잡도가 O(1)이 되었다는 의미가 아닙니다.

게시물 수 N에 따라 증가하던 DB 쿼리 호출 횟수가 고정된 횟수가 되었다는 의미입니다. 조회된 게시물을 순회하고 응답 객체를 만드는 작업은 여전히 N에 비례합니다.

개선 코드

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문을 돌 때마다 필요한 데이터를 DB에서 조회했습니다.

개선 후에는 먼저 게시물 ID와 카테고리를 모은 뒤, 필요한 태그와 카테고리별 게시물 수를 반복문 밖에서 일괄 조회하도록 변경했습니다.

이를 통해 게시물 수에 비례해 증가하던 추가 쿼리를 고정된 횟수의 쿼리로 줄였습니다.

하지만 반복 쿼리를 제거했다고 해서 실제 성능까지 개선되었다고 볼 수는 없습니다.

따라서 코드의 변경이 실제 응답 시간에도 영향을 주었는지 확인해보았습니다.


4. 로컬과 운영 환경에서의 성능 검증

코드에서 반복 조회를 제거했지만, 코드가 바뀌었다는 사실만으로 성능이 개선되었다고 판단할 수는 없습니다.

따라서 두 단계로 나누어 검증했습니다.

먼저 동일한 로컬 환경에서 개선 전후를 비교하고, 실제 배포 이후에는 운영 환경의 지표가 어떻게 변했는지 확인했습니다.


4.1 로컬에서 확인한 80% 이상의 응답 시간 감소

배포 전에 운영 환경과 동일한 데이터를 준비하고 k6를 이용해 개선 전후의 성능을 측정했습니다.

측정 조건

항목조건
환경MacBook M3
DBMySQL
데이터운영 환경과 동일
VU1
요청 횟수1,000회

측정 결과

지표개선 전개선 후변화
평균 응답 시간50.67ms9.98ms80.3% 감소
p95 응답 시간73.97ms12.86ms82.6% 감소
최대 응답 시간352.08ms154.74ms56.0% 감소
요청 성공률100%100%동일

로컬에서 실행했기 때문에 실제 운영 서버와 비교하면 전체적인 응답 시간은 빠르게 측정되었습니다.

따라서 로컬과 운영 환경의 절대적인 응답 시간을 비교하기보다는, 동일한 로컬 환경에서 개선 전후가 얼마나 달라졌는지를 확인하는 데 초점을 맞췄습니다.

그 결과 평균 응답 시간은 약 80.3%, p95는 약 82.6% 감소했습니다. 최대 응답 시간 역시 약 56% 감소했습니다.

동일한 조건에서 반복 쿼리를 제거한 뒤 응답 시간이 감소했음을 확인할 수 있었습니다.

하지만 로컬 환경에서의 결과만으로 실제 운영 환경에서도 동일하게 개선되었다고 판단할 수는 없습니다.

실제 운영 환경에서도 같은 변화가 나타나는지 확인해보았습니다.


4.2 운영에서 확인한 p95 93.8% 감소

개선한 코드를 운영 서버에 배포한 뒤 모니터링을 통해 실제 API 응답 시간이 어떻게 달라졌는지 확인했습니다.

개선 전 API 요청 속도

개선 후 API 요청 속도

배포 전후 모니터링 결과

실제 운영 지표에서도 뚜렷한 변화가 나타났습니다.

그래프를 보면 개선 전 구간에 비해 개선 후 구간에서 응답 시간 지표가 전반적으로 낮은 수준으로 형성된 것을 확인할 수 있습니다.

개선 이후에도 일부 구간에서 응답 시간이 상승하고 있지만, 개선 전과 비교하면 전체적으로 낮은 수준을 유지하고 있습니다.

추가로 더 정확한 개선 정도를 배포 전후의 메트릭을 이용해 AVG와 p95 값을 기준으로 비교했습니다.

지표개선 전개선 후개선량감소율
평균 응답 시간449ms45.2ms403.8ms 감소89.9% 감소
p95 응답 시간1.51s94.3ms1,415.7ms 감소93.8% 감소

측정값을 비교하면 평균 응답 시간은 449ms에서 45.2ms로 약 89.9% 감소했고, p95 응답 시간은 1.51초에서 94.3ms로 약 93.8% 감소했습니다.

특히 처음 문제를 발견하는 계기가 되었던 p95가 크게 감소했습니다. 개선 전에는 평균 응답 시간이 449ms인 상황에서 p95가 1.51초였지만, 개선 이후에는 평균 45.2ms, p95 94.3ms로 감소했습니다.

또한 평균과 p95의 차이도 함께 줄었습니다. 개선 전에는 평균 449ms와 p95 1.51초 사이에 약 3.4배의 차이가 있었지만, 개선 이후에는 평균 45.2ms와 p95 94.3ms로 약 2.1배의 차이를 보였습니다.

평균적인 요청뿐만 아니라 상대적으로 느리게 처리되던 요청의 응답 시간도 함께 감소했음을 확인할 수 있었습니다.


마치며

이전까지의 성능 개선은 주로 코드의 정적 분석에서 시작했습니다.

코드를 살펴보다 성능상 문제가 될 만한 부분을 발견하면 이를 개선하고, 전후 성능을 비교하는 방식이었습니다.

하지만 이런 방식으로는 발견한 문제가 실제 서비스에서도 개선이 필요한 문제인지, 그리고 개선 결과가 운영 환경에서도 유효한지 설명하기 어려웠습니다.

그래서 이번엔 운영 환경에서 수집한 데이터를 출발점으로 성능 문제를 분석했습니다.

단순히 코드에서 문제처럼 보이는 부분을 개선한 것이 아니라, 실제 서비스에서 발생한 문제를 데이터로 발견하고 원인을 설명한 뒤, 개선 효과까지 확인한 점에서 이전보다 더 의미 있는 성능 개선이었다고 생각합니다.

다음에는 지금 수집하고 있는 모니터링 데이터가 충분히 신뢰할 수 있는지, 이번 개선으로 인해 다른 병목이나 문제가 생기지는 않았는지도 확인해보려고 합니다.

긴 글 읽어주셔서 감사합니다! 피드백은 언제든 환영입니다ㅎㅎ