[SODA] 최근 질문 목록 조회 API 성능 개선하기
성능 개선 전 로그 분석
최근 질문 목록 조회 (/articles/my) API 실행 시 로그를 파악하면 아래의 기능들이 있었다.
- 인증 및 사용자 정보 조회
- JWT 인증 후 auth_id 기반으로 Member 조회
- 비교적 빠름
- 질문 목록 조회
- 특정 member_id가 작성한 삭제되지 않은 질문을 createdAt 기준으로 3개 가져옴
- article, stage, project 테이블 join
- 쿼리 자체가 크게 느리지는 않음
- 질문 전체 개수 조회
- 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의 도움을 받았다 ㅎ
분석 결과
- 전체 API 응답 시간 (ControllerLoggingAspect 기준): 310 ms
- 컨트롤러 내부 측정 시간 (ArticleController StopWatch): 297 ms
- Call_ArticleFacade.getMyArticles: 290 ms (98%) - 대부분의 시간이 Facade 호출에 소요.
- Create_ResponseEntity: 6 ms (2%) - 응답 객체 생성 시간은 매우 짧음.
- Facade 내부 측정 시간 (ArticleFacade StopWatch): 251 ms
- Call_ArticleService.findMyArticlesData: 245 ms (98%) - 대부분의 시간이 Service 호출에 소요.
- Call_ArticleResponseBuilder.buildMyArticleListPage: 5 ms (2%) - 빌더 호출 시간은 매우 짧음.
- Service 내부 측정 시간 (ArticleService StopWatch): 245 ms
- Call_ArticleProvider.findMyArticlesData: 245 ms (100%) - 모든 시간이 Provider 호출에 소요.
- Provider 내부 측정 시간 (ArticleProvider StopWatch): 244 ms
- Call_ArticleRepository.findMyArticlesData: 244 ms (100%) - 모든 시간이 Repository 호출에 소요.
- Repository 내부 측정 시간 (ArticleRepositoryImpl StopWatch): 233 ms
- ContentQueryFetch: 214 ms (92%) - 가장 많은 시간을 차지하는 구간
- CountQueryFetch: 16 ms (7%)
- PageCreation: 2 ms (1%)
- Builder 내부 측정 시간 (ArticleResponseBuilder StopWatch): 2 ms
- TotalBuilderProcessing_MyArticleList: 0.03 ms (2%) - 이 task는 거의 의미 없음.
- TupleToDtoMapping_MyArticleList: 2 ms (98%) - DTO 매핑 자체는 매우 빠름.
결론
- ArticleRepositoryImpl 의 ContentQueryFetch가 가장 큰 병목 지점
- Count Query 도 최적화 여지가 있는지 살펴봐야 함
- 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 where
이 부분이 문제임을 알 수가 있다.
Using filesort 가 데이터가 많을 경우 심각한 성능 저하를 유발할 수 있는 것이었고, 이걸 해결해야 했따. 만약 이걸 해결한다면 Using where도 자연스럽게 해결될 문제였다.
2. Using filesort 해결하기
복합 인덱스 설정하기
⇒ member_id, is_deleted, created_at DESC 순서가 가장 적합하다고 판단
- member_id (첫 번째 컬럼)
- WHERE 절에서 = 연산자로 사용되는 가장 첫 번째 조건이기 때문
- 이 컬럼을 인덱스의 맨 앞에 두면, MySQL은 이 인덱스를 사용하여 member_id = 1인 데이터만 빠르게 추려낼 수 있음
- is_deleted (두 번째 컬럼)
- member_id 바로 다음에 이 컬럼이 오면, MySQL은 앞서 찾은 member_id 결과 범위 내에서 is_deleted 조건까지 인덱스를 통해 효율적으로 처리할 수 있음
- 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 index condition
이처럼 복합 인덱스 설정을 통해 쿼리 성능도 향상되고, Using filesort도 해결했다.
3. 다시 단계별 실행 시간 측정
- 인증 및 사용자 정보 조회 약 6ms
- 메인 쿼리 실행
- Count 쿼리 실행
- Spring Data JPA에서 Page<T> 객체 반환할 때 전체 결과 수를 알기 위해 count 쿼리 추가 실행
- WHERER 절 조건은 동일하지만 ORDER BY와 LIMIT이 없음
- 성능 측정 결과 ArticleRepositoryImpl
- findMyArticlesData 20ms 소요
- ContentQueryFetch 약 11.8ms (59%) ⇒ 최적화 한 쿼리
- CountQueryFetch 약 8.3ms (41%)
- 계층 별 소요 시간
- ArticleRepositoryImpl: 20ms
- ArticleProviderImpl: 23ms (Repository 호출 오버헤드 약 3ms)
- ArticleService: 24ms (Provider 호출 오버헤드 약 1ms)
- ArticleFacade: 27ms (Service 호출 오버헤드 약 3ms, ResponseBuilder 포함)
- ArticleController: 33ms (Facade 호출 오버헤드 약 6ms)
- 최종 API 응답 시간 35ms
=> 추가적으로 성능 개선을 하기 위해서 Count 쿼리 최적화를 고민했다.
4. Count 쿼리 EXPLAIN 분석
해당 쿼리문에는 조건에 따라 다르게 실행된다.
- projectId가 null 또는 유효하지 않은 경우
- Extra: Using index
- Covering Index로 동작했음을 의미
- 실제 데이터 테이블에 접근하지 않고 오직 인덱스 데이터만 읽어서 처리했음을 의미
- idx_article_member_deleted_created 인덱스를 커버링 인덱스로 매우 효율적으로 사용하고 있음
- Extra: Using index
- 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 값)이 인덱스로 완전히 커버되지 않아 서버 레벨 필터링이 발생했음을 의미
- filtered: 5.00
- project table
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 | 커버링 인덱스 활용으로 매우 효율적 |
주요 개선 효과
- API 응답 시간 단축
- 416ms에서 45ms로 약 9.2배 빨라졌다.
- DB 쿼리 효율 극대화:
- Content 쿼리는 idx_article_member_deleted_created 인덱스와 Backward index scan을 통해 정렬 문제를 해결하고 매우 빠르게 실행
- Count 쿼리는 projectId가 null일 때 불필요한 조인을 제거하고, idx_article_member_deleted_created를 커버링 인덱스로 활용하여 극도로 효율적으로 실행
사실 팀 공유 노션에도 자세히 작성하였지만, 블로그 글로도 남겨두고 싶어서 약간 편집을 거쳐서 글로 올린다.
캠프가 끝나고 성능 개선을 처음 해밨는데, 역시나 어려웠고 어떤 식으로 개선해야 할까 고민을 많이 했었다.
이 API 성능 개선은 끝났지만 아직 남은 API가 있어서 그걸 또 개선하려면 뭘 해야 할지 모르겠다.
성능개선....참 어려운 거였다는 걸 새삼스레 깨닫는다 ㅋㅋㅋㅋ