본문 바로가기

Trouble Shooting/episode 4. dev

API 호출 성능 최적화 (feat. 사영 - Projection은 어디서나 보여요)

문제 상황

클라이언트가 자주 사용하는 특정 API가 있는데, 이게 체감상 로딩이 느린것 같다라는 얘기를 들었다.

 

실제로 내가 사용해봐도, 부쩍 느려진 느낌이다.

그래서 일단 AOP를 통해 @PerformanceCheck를 구현하고, API에서 걸리는 총 시간을 측정하기로 했다.

아래의 코드 예시는 실제 코드가 아니라, 해당 상황을 재현하기 위해 사용된 코드이다.

 

문제 원인 파악

정량적 상황 파악 파악을 위해 다음의 준비를 가진다.

간편한 사용과 시간 체크라는 관심사 분리를 위해서 Spring AOP를 통한 어노테이션을 생성하고,

이를 통해 성능체크를 진행하려 한다. 자세한 내용은 이전에 관련 글을 써두었기에, 이를 참조하기 바란다.

https://namucy.tistory.com/81

 

Aspect Oriented Programming

요즘 DB와 Transaction에 대한 공부를 하면서 AOP라는 흥미로운 개념에 대해 배우게 됐다.방식 자체도 특이하지만 기존에 배웠던 Lombok과 유사한 것처럼 보여서 해당 개념을 설명하고 어떤 부분이 다

namucy.tistory.com

 

 

우선 Spring MVC의 최전방 진입점, controller에서의 성능이 다음과 같았다.

(위의 getShuttleBoardingInfoTest 메서드는 Controller가 호출하는 Service의 메서드 /  아래의 메서드가 컨트롤러의 메서드이다.)

이를 조합해 보았을때, controller에서 API 호출 시 응답에 걸리는 평균 시간이 약 192ms 임을 알 수 있다.

 

상세한 상황 파악

해당 메서드에서 호출하는 모든 메서드에 대해서 걸리는 시간을 파악해야 한다.

호출하는 모든 메서드에 PerformanceCheck 어노테이션을 달아야 한다. 다음처럼 말이다.

@PerformanceCheck(className = true)
@Transactional
public GetShuttleBoardingInfoResponseDto getShuttleBoardingInfoTest(Long teamId, String date) {
    LocalDate converted = DateConverter.getLocalDate(date);

    Team team = teamRepository.findById(teamId)
                .orElseThrow(() -> new CustomException(CustomExceptionType.TEAM_NOT_EXIST, teamId));
    List<MemberManagement> items = managementRepository.findBoardingInfo(converted, teamId);
    items.addAll(managementRepository.findBoardingToDelay(converted, teamId));
    items.addAll(managementRepository.findBoardingToOthers(converted, teamId));
    return GetShuttleBoardingInfoResponseDto.of(team, items);
}

 

findById + findBoardingInfo + findBoardingToDelay + findBoardingToOthers로 DB 조회가 이뤄지고

 

각각의 조회시간이 이만큼 걸림을 알 수 있다.

팀 정보 조회는 어차피 validation을 위해 필요하므로 판단에서 제외하고, 

날짜 변환은 사진에 생략되었는데 2~3 ms가 걸렸고,

맨 마지막에 DTO 변환을 위한 정적 팩토리 메서드는 약 50~60ms가 걸렸다.

 


Minor Trouble Shooting

Spring AOP를 통한 이 Performance Check 메서드는 한가지 단점이 있는데,

그것은 바로 클래스 단위에서 이루어지는 static 메서드에서는 사용이 불가능하다는 것이다.

Spring AOP는 기본적으로 인스턴스 단위에서 메서드를 가로채는 프록시 방식을 사용한다.

나누어 보자면 객체의 인터페이스를 구현하는 JDK Dynamic Proxy 방식과,

객체를 상속한 Proxy 객체를 생성하는 CGLIB 크게 두 가지 방식이 있다.

그러나 이 둘 중 어느 쪽도 클래스 단위에서 적용되는 static 메서드에 대해 적용될 수 없다. 

 

그렇기 때문에 static method에 대해서는 따로 메서드에 Stopwatch 객체를 구현해 두는 방식으로 사용해야 한다.


 

 

따로 비동기를 통한 메서드처리를 진행하지 않았기에 이 메서드들은 하나의 connection, 하나의 Thread내에서 호출되었다.

 

그러면 이 service 메서드에서 find 메서드 (SELECT 쿼리들)에 약 100~120 ms가,

그리고 return 하기 위한 DTO 변환에는 약 50~60 ms가,

그리고 나머지 addAll과 convert, findById와 같은 단일 조회가 나머지 시간을 사용했다.


나머지 3개의 조회에 대해서 하나 하나 쿼리 플랜을 분석했다.

기본적으로 아래의 형식에서 크게 벗어나지는 않기에 W.L.O.G. 첫 번째만 분석해서 본다면

EXPLAIN ANALYZE
SELECT mm.*
FROM member_management mm
WHERE date = '2025-02-14'
  AND team_id = 1
  AND attendance_type IN (
                            'A', 'B', 'C',
                            'D', 'E', 'F'
    )
  AND boarding_time IS NOT NULL
  AND boarding_type = 'SHUTTLE'
ORDER BY boarding_time;

 

-> Sort row IDs: mm.boarding_time  (cost=370 rows=8343) (actual time=23.6..23.7 rows=96 loops=1)
    -> Filter: ((mm.boarding_type = 'SHUTTLE') and (mm.`date` = DATE'2025-02-14') and (mm.attendance_type in ('A','B','C','D','E','F')) and (mm.boarding_time is not null))  (cost=370 rows=8343) (actual time=0.221..23.4 rows=96 loops=1)
        -> Index lookup on mm using FKl8oh8vl46m57qaorsq0veamqr (team_id=1)  (cost=370 rows=8343) (actual time=0.134..21.5 rows=17376 loops=1)

대략적으로 10개의 팀이 고루 분포되어 있어, 10개의 팀에 대해 인덱싱을 추가했으나

위처럼 1개의 팀에 대해서도 약 1.7만개의 row가 존재했다. 그렇기에 

이 인덱싱 결과들에 대한 조회와 Filter가 동시에 진행하는 것을 볼 수가 있다. (그래서 시간이 중복되서 겹치는 것을 볼 수 있다.)

 

더보기

참고로, cardinallity에 따른 복합 인덱싱을 구성할 수 있으나 이게 적용이 안됐었다.

기본적으로 현재 테이블의 경우, attendance_type이 총 9개이므로 Cardinallity가 9이지만

실질적으로 데이터의 비율은 A, B이 대부분이고 나머지가 매우 작게 분포되어있기 때문에

기형적인 휴리스틱을 가진 데이터로 형성이 되어있다.

맨 처음에 team_id 와 attendance_type을 통한 복합 column indexing을 적용 했으나,

 

쿼리 옵티마이저가 통계상 team_id와 attendance_type을 사용한 인덱싱이 아닌

team_id만을 통한 인덱싱을 사용하는 것이 더 빠르다고 판단한 듯 하다.

 

참고로 force 옵션을 통해서 indexing을 강제로 적용하는 방식도 있다고는 한다.

정리하자면, 적절하지 않은 컬럼 데이터의 인덱싱

DTO 변환 과정에서 생기는 시간 소요가 주요한 원인이라고 판단했고, 다음의 3가지 방식을 통해 API 성능을 최적화 했다.

 

 

문제 해결 방법 (1)

기존의 date를 사용하지 않고 연, 월, 일 string column을 추가하고 이를 indexing했다.

이유는 크게 다음의 두 가지이다.

  1. 현재 서비스 특성 상 특정 날짜부터 날짜까지의 BETWEEN 단위로 검색하는 경우가 별로 없다.
    정확히는, 월간 단위로 검색하거나 (매월 1일부터 말일까지) / 혹은 어느 특정 날짜의 데이터만을 검색한다.
  2. 위의 쿼리플랜의 Filter에서 date를 filter로 삼아 검색할 경우, 해당하는 날짜 검색의 효율이 떨어진다고 생각했다.
    mysql은 날짜를 숫자로 저장하는데, 1번, 2번 두 개의 경우 기준 날짜에 부합하는 숫자를 찾는데 시간이 오래 걸릴 것이라고 판단했다.

따라서 월의 cardinallity는 12, 일의 cardinallity는 31이고, 월과 일의 경우 웬만하면 균등한 휴리스틱을 가진 데이터들이므로

연, 월, 일을 조합한 인덱싱이라면 효과적인 filter가 될 것이라고 생각했기 때문이다.

 

 

문제 해결 방법 (2)

projection을 통해서 dto 변환 시 추가되는 시간을 아예 배제했다.

projection이란, 테이블 전체의 컬럼 데이터를 전부 받는 것이 아니라

필요한 일부 부분의 데이터만을 받는 것이다.

 

수학에서의 projection과 완벽하게 들어 맞는 단어이다.

기존의 테이블에서 임의의 데이터가 10차원 데이터(컬럼이 10개)라 하자.

우리가 필요한 것은 그 중에 4개의 데이터만이라고 한다면, 나머지 6개를 받을 필요가 없지 않은가?

projection을 통해 받는 데이터는 6개의 데이터(column 정보)가 사라진 4차원 데이터가 되는 것이다.

수학에서의 사영의 정의와 정확히 일치하는 대목이다.

 

DTO 변환 과정에서 나머지 6개를 떨쳐내고 4개만 보내도록 변환하는데 필요한 시간이 상당히 길다는 것을 알았기 때문에

이러한 방식을 채택했다.

 

문제 해결 방법 (3)

projection을 취하되, 기존에 3개의 연, 월, 일로 분할되어있던 날짜를

다시 date로 합치는 비즈니스 로직을 db측으로 넘겨서 서비스 측에서는 거의 repository에서 받는 projection DTO를

controller까지 스루패스하는 급으로 로직을 줄였다.

 

 

 

결론 및 평가, 그리고 고찰

위의 세 가지 방식을 조합해서 얻어낸 결과는 드라마틱 했다.

API 평균 걸리는 시간이 약 33ms로 기존의 192ms 에서 약 1/6 수준으로 절감됐다. (아래의 3개 사진에서 아래쪽 메서드)

 

그러나 이 방식에는 치명적인 문제점이 존재한다.

이런 식의 최적화 방식은 재사용하기가 매우 힘들다는 것이다.

특히 projection과 비즈니스 로직을 DB측으로 넘기는 방식이 그렇다.

 

또한, 지금은 DB와 서버가 비교적 트래픽이 적어 이와 같은 방식이 최적화하는데 도움을 주었지만,

이것보다 더 복잡하고 대용량 트래픽에서도 위와 같은 방식이 항상 통하리라고는 보장할 수가 없을 것 같다.

 

또한, 연/월/일로 컬럼을 쪼개는 기능적인 방식도 항상 정답은 아니다. 지금처럼 한정되고 정형적인 검색조건만을

클라이언트가 요구했기 때문에 사용가능한 방법이라고 할 수 있다.

만약 시간대가 더욱 다양해지고, 정말 다양한 between 조회가 들어가게 된다면, 이와 같은 방식은 오래가지 못할 가능성이 높다.

 

만약 비즈니스가 어느 정도 정착되고 서비스의 안정화가 되었을 때

위와 같은 방식을 잘 섞어서 성능을 최적화 하는 방식으로 다시 한 번 진행해봐야 겠다.

 

'Trouble Shooting > episode 4. dev' 카테고리의 다른 글

Docker Compose with Github Action Troubleshooting  (1) 2024.11.05