개발 공부

[SODA] 최근 질문 목록 조회 API 성능 개선하기

MOON달 2025. 6. 7. 22:08
728x90
반응형

성능 개선 전 로그 분석

최근 질문 목록 조회 (/articles/my) API 실행 시 로그를 파악하면 아래의 기능들이 있었다.

  1. 인증 및 사용자 정보 조회
    • JWT 인증 후 auth_id 기반으로 Member 조회
    • 비교적 빠름
  2. 질문 목록 조회
    • 특정 member_id가 작성한 삭제되지 않은 질문을 createdAt 기준으로 3개 가져옴
    • article, stage, project 테이블 join
    • 쿼리 자체가 크게 느리지는 않음
  3. 질문 전체 개수 조회
    • member_id가 작성한 삭제되지 않은 질문 전체 개수 조회
    • article, stage 테이블 join
    • 쿼리 자체가 크게 느리지는 않음

⇒ 쿼리 실행 시간 외 다른 부분에서 시간이 소요되고 있음을 파악할 수 있었다.

그래서 각 단계 별 실행 시간을 측정하여 병목지점을 찾으려고 로그를 추가하였다.

 

 

 

 

 

단계별 실행 시간 측정

// ArticleFacade.java

public Page<MyArticleListResponse> getMyArticles(Long memberId, Long projectId, Pageable pageable) {
	StopWatch internalStopWatch = null; // 내부 측정용 StopWatch

    if (log.isDebugEnabled()) {
		internalStopWatch = new StopWatch("ArticleFacade.getMyArticles_InternalDetails");
		log.debug("--- ArticleFacade.getMyArticles (Internal Details) START --- memberId: {}, projectId: {}, pageable: {}", memberId, projectId, pageable);
    }

	// 1. ArticleService 호출 시간 측정
	if (internalStopWatch != null) internalStopWatch.start("Call_ArticleService.findMyArticlesData");
	Page<Tuple> tuplePage = articleService.findMyArticlesData(memberId, projectId, pageable);
    if (internalStopWatch != null && internalStopWatch.isRunning()) internalStopWatch.stop();

	// 2. ArticleResponseBuilder 호출 시간 측정
	if (internalStopWatch != null) internalStopWatch.start("Call_ArticleResponseBuilder.buildMyArticleListPage");
 	Page<MyArticleListResponse> response = articleResponseBuilder.buildMyArticleListPage(tuplePage);
  	if (internalStopWatch != null && internalStopWatch.isRunning()) internalStopWatch.stop();

	if (log.isDebugEnabled() && internalStopWatch != null) {
    	log.debug("--- ArticleFacade.getMyArticles (Internal Details) END ---");
        log.debug("Internal Performance Timings for ArticleFacade.getMyArticles:\n{}", internalStopWatch.prettyPrint());
	}
	return response;
}

 

이런 식으로 controller, facade, service, repository, builder 코드에 stopwatch를 사용하여 시작하는 시간과 끝나는 시간 등을 분석하는 코드들을 추가해서 측정해주었다. 이런 코드들은 AI의 도움을 받았다 ㅎ

 

분석 결과

  1. 전체 API 응답 시간 (ControllerLoggingAspect 기준): 310 ms
  2. 컨트롤러 내부 측정 시간 (ArticleController StopWatch): 297 ms
    • Call_ArticleFacade.getMyArticles: 290 ms (98%) - 대부분의 시간이 Facade 호출에 소요.
    • Create_ResponseEntity: 6 ms (2%) - 응답 객체 생성 시간은 매우 짧음.
  3. Facade 내부 측정 시간 (ArticleFacade StopWatch): 251 ms
    • Call_ArticleService.findMyArticlesData: 245 ms (98%) - 대부분의 시간이 Service 호출에 소요.
    • Call_ArticleResponseBuilder.buildMyArticleListPage: 5 ms (2%) - 빌더 호출 시간은 매우 짧음.
  4. Service 내부 측정 시간 (ArticleService StopWatch): 245 ms
    • Call_ArticleProvider.findMyArticlesData: 245 ms (100%) - 모든 시간이 Provider 호출에 소요.
  5. Provider 내부 측정 시간 (ArticleProvider StopWatch): 244 ms
    • Call_ArticleRepository.findMyArticlesData: 244 ms (100%) - 모든 시간이 Repository 호출에 소요.
  6. Repository 내부 측정 시간 (ArticleRepositoryImpl StopWatch): 233 ms
    • ContentQueryFetch: 214 ms (92%) - 가장 많은 시간을 차지하는 구간
    • CountQueryFetch: 16 ms (7%)
    • PageCreation: 2 ms (1%)
  7. Builder 내부 측정 시간 (ArticleResponseBuilder StopWatch): 2 ms
    • TotalBuilderProcessing_MyArticleList: 0.03 ms (2%) - 이 task는 거의 의미 없음.
    • TupleToDtoMapping_MyArticleList: 2 ms (98%) - DTO 매핑 자체는 매우 빠름.

결론

  1. ArticleRepositoryImpl 의 ContentQueryFetch가 가장 큰 병목 지점
  2. Count Query 도 최적화 여지가 있는지 살펴봐야 함
  3. DB 쿼리 실행이 전체 성능에 큰 영향을 미치고 있음

 

 

 

 

1.  ContentQuery의 EXPLAIN 분석

이 때 처음으로 Workbench에서 쿼리문의 EXPLAIN을 분석하는 경험을 해봤다.

EXPLAIN
SELECT
	a1_0.id,
    a1_0.title,
    p1_0.id AS project_id,
    p1_0.title AS project_title,
    s1_0.id AS stage_id,
    s1_0.name AS stage_name,
    a1_0.created_at
FROM
	article a1_0
JOIN
	stage s1_0 ON s1_0.id = a1_0.stage_id
JOIN
	project p1_0 ON p1_0.id = s1_0.project_id
WHERE
	a1_0.member_id = 1
    AND a1_0.is_deleted = 0
ORDER BY
	a1_0.created_at DESC
LIMIT 0, 3;

 

이게 content query 문이고, 실행해본 결과 아래와 같은 결과를 얻을 수 있었다.

 

이 실행 계획을 분석해보면,

  • type: ref
    • WHERE a1_0.member_id = ? AND a1_0.is_deleted = ? 조건에 사용된 인덱스일 가능성이 높음
    • const(상수)나 eq_ref(고유 키 조인)보다는 안 좋지만, range나 ALL(풀 스캔)보다는 훨씬 좋음
  • Extra: Using where; Using filesort:
    • Using where
      • 인덱스로 필터링할 수 없는 조건이 WHERE 절에 남아있어, 스토리지 엔진에서 데이터를 가져온 후 MySQL 서버 레벨에서 추가 필터링을 수행했음을 의미
      • is_deleted 조건이 인덱스에 포함되지 않았거나, 인덱스의 뒷부분에 있어 효율적으로 사용되지 못했을 수 있음
    • Using filesort
      • 가장 큰 문제
      • ORDER BY a1_0.created_at DESC 절을 처리하기 위해 인덱스를 사용하지 못하고, 별도의 정렬 작업(filesort)을 수행했음을 의미
      • 데이터가 많을 경우 심각한 성능 저하를 유발

이 부분이 문제임을 알 수가 있다.

Using filesort 가 데이터가 많을 경우 심각한 성능 저하를 유발할 수 있는 것이었고, 이걸 해결해야 했따. 만약 이걸 해결한다면  Using where도 자연스럽게 해결될 문제였다.

 

 

 

 

 

 

2. Using filesort 해결하기

복합 인덱스 설정하기

⇒ member_id, is_deleted, created_at DESC 순서가 가장 적합하다고 판단

  1. member_id (첫 번째 컬럼)
    • WHERE 절에서 = 연산자로 사용되는 가장 첫 번째 조건이기 때문
    • 이 컬럼을 인덱스의 맨 앞에 두면, MySQL은 이 인덱스를 사용하여 member_id = 1인 데이터만 빠르게 추려낼 수 있음
  2. is_deleted (두 번째 컬럼)
    • member_id 바로 다음에 이 컬럼이 오면, MySQL은 앞서 찾은 member_id 결과 범위 내에서 is_deleted 조건까지 인덱스를 통해 효율적으로 처리할 수 있음
  3. created_at (세 번째 컬럼)
    • WHERE 절의 조건(member_id, is_deleted)을 모두 만족하는 데이터들이 이미 created_at 순서로 정렬되어 있도록 하기 위함
    • 이렇게 되면 MySQL은 별도의 filesort 작업 없이 인덱스에 정렬된 순서대로 데이터를 읽기만 하면 되므로 ORDER BY a1_0.created_at DESC가 매우 빠르게 처리
// Article.java

@Table(name = "article", indexes = {
    @Index(name = "idx_article_member_deleted_created", columnList = "member_id, is_deleted, created_at")
})

 

복합 인덱스를 설정한다면, 스캔해야 하는 데이터 개수가 줄어들어거 적합하다고 판단했다.

그런데 고민해야 할 부분은 조회 성능을 최적화하면서 다른 API에 영향을 미칠 수 있기 때문이다.

그래서 복합 인덱스 설정한 뒤에 질문 생성 API 속도도 추가로 측정해보았다. 그런데 이전과 비교해서 유의미하게 느려지지 않았으므로 그냥 이 API 성능 개선을 위해 복합 인덱스를 설정해주었다.

 

위 코드처럼 Article Entity에 추가해주면 복합인덱스가 설정된다.

 

생성 후 확인

SHOW INDEX FROM article;

 

 

제대로 잘 생성되는 걸 확인할 수 있다.

다시 EXPLAIN 실행

  • type: range
    • ref → range 변경
    • member_id = ? AND is_deleted = ? 조건이 인덱스의 첫 두 컬럼에 대해 등치(=) 비교로 사용되지만, ORDER BY created_at DESC LIMIT 3 때문에 created_at 컬럼에 대해서는 특정 범위를 스캔(가장 큰 값부터 3개)하는 형태로 동작
    • 또는 member_id와 is_deleted가 고정된 상태에서 created_at을 스캔하는 것을 range로 표현
  • Extra: Using index condition; Backward index scan;
    • Using index condition
      • 인덱스 조건 푸시다운
      • is_deleted 조건을 스토리지 엔진 레벨에서 인덱스를 사용하여 필터링했음
      • MySQL 서버로 데이터를 모두 가져온 후 필터링하는 것보다 효과적
    • Backward index scan
      • ORDER BY created_at DESC를 처리하기 위해 created_at 컬럼에 대해 인덱스를 역방향으로 스캔했음을 의미
      • 별도의 정렬 작업(filesort) 필요 없어짐

이처럼 복합 인덱스 설정을 통해 쿼리 성능도 향상되고, Using filesort도 해결했다.

 

 

 

 

 

 

 

 

3. 다시 단계별 실행 시간 측정

  1. 인증 및 사용자 정보 조회 약 6ms
  2. 메인 쿼리 실행
  3. Count 쿼리 실행
    • Spring Data JPA에서 Page<T> 객체 반환할 때 전체 결과 수를 알기 위해 count 쿼리 추가 실행
    • WHERER 절 조건은 동일하지만 ORDER BY와 LIMIT이 없음
  4. 성능 측정 결과 ArticleRepositoryImpl
    • findMyArticlesData 20ms 소요
    • ContentQueryFetch 약 11.8ms (59%) ⇒ 최적화 한 쿼리
    • CountQueryFetch 약 8.3ms (41%)
  5. 계층 별 소요 시간
    • ArticleRepositoryImpl: 20ms
    • ArticleProviderImpl: 23ms (Repository 호출 오버헤드 약 3ms)
    • ArticleService: 24ms (Provider 호출 오버헤드 약 1ms)
    • ArticleFacade: 27ms (Service 호출 오버헤드 약 3ms, ResponseBuilder 포함)
    • ArticleController: 33ms (Facade 호출 오버헤드 약 6ms)
  6. 최종 API 응답 시간 35ms

=> 추가적으로 성능 개선을 하기 위해서 Count 쿼리 최적화를 고민했다.

 

 

 

 

 

 

 

4. Count  쿼리 EXPLAIN 분석

해당 쿼리문에는 조건에 따라 다르게 실행된다. 

  1. projectId가 null 또는 유효하지 않은 경우
    • Extra: Using index
      • Covering Index로 동작했음을 의미
      • 실제 데이터 테이블에 접근하지 않고 오직 인덱스 데이터만 읽어서 처리했음을 의미
    • idx_article_member_deleted_created 인덱스를 커버링 인덱스로 매우 효율적으로 사용하고 있음
  2. projectId가 유효한 경우
    • project table
      • covering index 사용
    • article table
      • key가 member_id에 대한 외래키 인덱스 선택
      • Extra: Using where
        • 인덱스로 모든 WHERE 조건을 커버하지 못하고 추가 필터링을 수행함
        • is_deleted 조건이 Using where로 처리되고 있음
    • stage table
      • filtered: 5.00
        • stage 테이블과 조인한 후 WHERE p.id = ? 만족하는 행이 매우 적을 것으로 예상함을 의미
      • Extra: Using where
        • stage 테이블에 대한 추가적인 WHERE 조건 (project id 값)이 인덱스로 완전히 커버되지 않아 서버 레벨 필터링이 발생했음을 의미

 

projectId가 유효한 경우의 성능 개선을 하려고 하다가 또다른 고민이 생겼다.

projectId가 API 호출시에는 항상 null이기 때문이다. 그래서 아래의 이유들로 projectId가 유효한 경우 최적화 고려는 뒤로 미루었다.

  • /articles/my API가 "내가 작성한 모든 글 목록"을 보여주되, 각 글이 어떤 프로젝트에 속하는지 정보를 함께 제공하는 역할
  • API를 호출할 때 특정 projectId로 결과를 필터링하는 기능은 현재 사용되지 않고 있기 때문
  • 대신 projectId 유무에 따른 조인 동적화만 적용함

 

 

 

 

 

 

5. projectId 유무에 따른 조인 동적화 적용

    private Long executeCountQuery(Long authorId, Long projectId) {
        JPAQuery<Long> countQuery = queryFactory
            .select(article.count())
            .from(article);

        // projectId 유효할 때만 stage, project join
        if (projectId != null && projectId > 0) {
            countQuery.join(article.stage, stage)
                .join(stage.project, project)
                .where(project.id.eq(projectId));
        }

        countQuery.where(
            article.member.id.eq(authorId),
            article.isDeleted.isFalse()
        );

        Long total = countQuery.fetchOne();
        return (total == null) ? 0L : total;
    }

 

projectId가 유효할 때만 stage, project를 조인하도록 하고 null인 경우에 불필요하게 조인하지 않도록 조건을 나누었다.

 

 

 

 

 

 

 

6. 최종 결과 (성능 개선 전 vs 후)

항목 개선 전 (416ms 응답) 개선 후  (45ms 응답) 개선 효과
인증 (Member 조회) 약 95ms 약 13ms 약 82ms 단축
(캐시 및 환경 영향 가능성)
Content 쿼리 추정 시간 약 71ms 약 8ms 약 63ms 단축
(인덱스 최적화의 직접적인 효과)
Count 쿼리 추정 시간 약 66ms
(조인 포함, 다른 작업 포함)
약 24ms
(조인 없음, 다른 작업 포함)
약 42ms 단축 (순수 쿼리 시간 비교 시 더 큰 차이, 조인 제거 및 커버링 인덱스 효과)
Count 쿼리 조인 stage 조인 발생 조인 없음 (projectId=null) 불필요한 조인 제거로 인한 성능 향상
DB 쿼리 총 소요 (추정) 약 137ms (Content+Count) 약 10ms 내외
(순수 쿼리 시간 합)
약 120ms 이상 단축 (추정치 기반)
최종 API 응답 시간 416ms 45ms 약 371ms 단축
(약 89.2% 성능 향상)
Content 쿼리 실행 계획 Using filesort 발생 Backward index scan filesort 제거, 정렬 성능 극대화
Count 쿼리 실행 계획 조인 포함,
인덱스 활용 미흡 가능성
Using index 커버링 인덱스 활용으로 매우 효율적

 

주요 개선 효과

  1. API 응답 시간 단축
    • 416ms에서 45ms로 약 9.2배 빨라졌다.
  2. DB 쿼리 효율 극대화:
    • Content 쿼리는 idx_article_member_deleted_created 인덱스와 Backward index scan을 통해 정렬 문제를 해결하고 매우 빠르게 실행
    • Count 쿼리는 projectId가 null일 때 불필요한 조인을 제거하고, idx_article_member_deleted_created를 커버링 인덱스로 활용하여 극도로 효율적으로 실행

 

 

 

 

 

 

 

 

 

 


 

사실 팀 공유 노션에도 자세히 작성하였지만, 블로그 글로도 남겨두고 싶어서 약간 편집을 거쳐서 글로 올린다.

캠프가 끝나고 성능 개선을 처음 해밨는데, 역시나 어려웠고 어떤 식으로 개선해야 할까 고민을 많이 했었다.

이 API 성능 개선은 끝났지만 아직 남은 API가 있어서 그걸 또 개선하려면 뭘 해야 할지 모르겠다.

성능개선....참 어려운 거였다는 걸 새삼스레 깨닫는다 ㅋㅋㅋㅋ

728x90