오늘은 API 응답 속도 개선기를 적어보겠습니다.
최근 SSE기반의 기능을 WebSocket으로 이전하면서 테스트 서버에서 응답 지연이 3초 이상이 걸리는 하나의 API를 발견하게 되었습니다.
(DB 환경: Aws t3 micro, mysql8.0, 각 테이블 당 100 ~2000만 건 데이터 적재)
문제
우리 '코딩해듀오' 서비스는 페어프로그래밍을 할 때 도움이 될만한 유틸리티를 지원하는 웹 서비스입니다.
각 사용자들은 페어프로그래밍을 시작할 때, 서비스 메인 화면에서 방을 만들고, 접속하여 여러 기능들을 사용할 수 있습니다.
생성한 방에 접속하면, 세션 잔여 타이머와 구현해야 할 TODO 리스트 그리고 참고용 레퍼런스 링크들을 저장하여 한눈에 확인할 수 있게 구성되어 있습니다.
이번에 문제 된 API는 방 접속 API였습니다. 기본 SSE 타이머를 Websocket으로 이전하면서 프론트로부터, '방을 접속할 때 투두 목록, 링크 목록, 타이머 정보, 페어 정보들을 개별의 API 요청을 하지 말고 하나로 묶어서 처리하면 더 효율적이지 않나?'라는 의견에 접속 시 화면에 그려야 할 모든 리소스를 반환해 주는 뚱뚱한 API가 하나 생겨나게 되었습니다.
오늘의 연구대상은 새로 생긴 뚱뚱한 API입니다.
문제 분석
우선 아래는 사진은 해당 API와 관련된 테이블들입니다.
한 API에서 많은 양의 정보를 제공해주어야 하기 때문에 7개의 테이블과 상호작용을 하게 됩니다.
설명한 바와 같이 여러 테이블에 쿼리를 하다 보니 저는 DB 쿼리부터 확인해봤어요.
PAIR_ROOM 쿼리 4번, TODO, CATEGORY, TIMER, REFERENCE_LINK 각 1번씩 쿼리는 하는데, 문제는 OPEN_GRAPH를 20번 쿼리함을 확인했습니다. '요 녀석 N+1 문제가 생겼구나' 느껴 엔티티 정보를 확인해 보았습니다.
문제의 엔티티들인데요. reference_link와 open_graph는 1대 1 단방향 연관관계로 되어 있으며 fetch 타입은 eager로 설정되어있어요.
참고로 open_graph는 각 링크들의 정보를 요약해서 볼 수 있게 해주는 정보들을 담고 있습니다.
'왜 1대 1 EAGER인데 둘로 나눴더라..' 생각해 보니 오픈그래프 기능은 개발 후에 사용자 피드백으로 추후에 추가된 기능이라 테이블에 컬럼을 추가하는 게 아니라 별도의 하나 테이블을 만들고 정보량도 크지 않으니 EAGER로 했던 팀 내 합의가 있었어요.
문제 해결 1: N+1
다른 리소스들은 PAIR_ROOM의 ACCESS_CODE라는 유니크 컬럼으로 방의 정보를 조회하고 각 방의 PK를 연관관계들의 FK로 조회하 오면 끝인데, OPEN_GRAPH는 한 번 더 REFERENCE_LINK의 PK로 조회를 해왔어야 했어요.
예를 들어, 각 방에 20개의 REFERENCE_LINK가 있으면 1번 REFERENCE_LINK 쿼리하고 그 값을 결과인 20개의 REFERENCE_LINK를 가져오면 PK로 다시 OPEN_GRAPH를 조회하는 20개의 JPQL을 생성시켜 문제가 발생한 것입니다.
이 친구는 페이징 쿼리가 발생할 여지가 전~혀 없다 판단하고 FETCH JOIN으로 해결해 주었습니다.
@Query("SELECT o FROM OpenGraphEntity o JOIN FETCH o.referenceLinkEntity WHERE o.referenceLinkEntity.pairRoomEntity =:pairRoomEntity")
List<OpenGraphEntity> findByPairRoomEntity(@Param(value = "pairRoomEntity") PairRoomEntity pairRoomEntity);
자 이렇게 N+1을 해결해 주니 28번의 SQL 쿼리가 8번으로 필요한 정보만을 쿼리 하도록 정상적으로 개선되었어요.
쿼리가 20개나 줄었으니 속도가 과연 드라미틱하게 빨라졌을까요?
그랬으면 좋았겠지만, 0.3초 정도의 개선이 끝이었네요. 🤔
문제 해결 2: Mysql 히스토그램 생성
애플리케이션 단에서의 문제를 해결했으니, 이제 MySQL을 건드려봅시다.
문제의 API에서 발생되는 8개의 쿼리를 모두 확인하니 결국 1개의 쿼리에서 지연이 발생됨을 확인했어요.
해당 쿼리는 PAIR_ROOM(페어방 정보) 테이블을 고유한 ACCESS_CODE라는 컬럼의 값으로 조회하는 쿼리였어요.
이 쿼리 수행 속도가 다른 쿼리에 비해 비약적으로 느려 실행 계획을 우선 확인해 볼게요.
실행 계획을 확인해 보니, 일단 Full table Scan을 한다는 것과 filter 예상 정확도가 10%로 되어있음을 확인했어요.
여기서 무엇보다 filtered 정보가 심각하게 왜곡되어 있음을 알 수 있었어요. 현제 테스트 목적의 DB는 PAIR_ROOM 100만 건에 유니크한 ACCESS_CODE가 각 레코드마다 개별적으로 있기에 10% 확률은 너무나도 왜곡되어 있었던 거죠.
히스토그램을 생성하고 실행계획이 바뀌나 먼저 확인해보고 싶었어요.
히스토그램을 생성하고 다시 실행 계획을 보니 filtered 확률은 상당히 떨어져 왜곡을 어느 정도 해석했지만, 역시나 Full table Scan으로 변동은 없음을 확인했습니다.
문제 해결 3: Index 생성
여기까지 왔으니 ACCESS_CODE로 된 인덱스를 생성해서 속도를 개선해 보기로 마음먹었습니다.
인덱스를 ACCESS_CODE 컬럼으로 생성한 기준은 다음과 같아요.
- 우리 서비스는 방 생성보다 조회 요청이 압도적으로 많을 것으로 예상됐어요.
- ACCESS_CODE는 페어룸을 식별하기 위해 사용하는 저희 서비스만의 일종의 비즈니스 키에요.
- PAIR_ROOM 테이블 조회 시 보통 모든 정보를 쿼리해와 사용해요.(커버링 인덱스 X)
이런저런 이유로 다른 컬럼 없이 ACCESS_CODE 컬럼의 인덱스를 생성해 주었어요.
자 이제 API 조회 속도를 확인해 볼까요?
확인해 보니 초반 3.96s ➝ 0.235s까지 개선이 되었네요.
마지막으로 인덱스가 적용된 실행 계획을 한번 더 확인해 보았어요.
실행계획의 type이 ref로 적절하게 인덱스를 타는 것을 확인했는데요.
"SELECT * FROM pair_room WHERE access_code =?" 형태의 쿼리라면 type을 'const'까지 최적화할 수 있겠다는 생각이 들었습니다.
그래서 찾아보니 비즈니스 키인 ACCESS_CODE의 UNIQUE 제약조건이 없었네요.😅
제약 조건을 추가하고 다시 확인해 보니 const 타입으로 잘 최적화되었습니다.
이제 마지막으로 API 응답지연을 확인해 봅시다.
결과적으로 3.96s ➝ 0.1876s까지 속도를 개선할 수 있었습니다.
결론
사실 처음 발생한 N+1 문제는 Fetch join 해결은 임시방편이라 생각해요. 왜냐하면 이 문제는 불필요한 정규화가 가져온 문제라고 생각하기 때문이에요. 그리고 쿼리 할 때 Join문 자제가 다른 쿼리에 비해 비교적 비용이 크기 때문에 역정규화를 통해 문제를 해결해 보는 방향도 좋을 거 같아요. 팀원들과 REFERENCE_LINK와 OPEN_GRAPH 테이블을 합치자고 이야기를 나눠봐야겠어요.
'삽질 보고서' 카테고리의 다른 글
테스트가 느려서 테스팅하기 싫을때 (0) | 2025.01.24 |
---|---|
Slack으로 코드리뷰 재촉하기 (0) | 2024.10.27 |
우리도 무중단 배포 할래요. (0) | 2024.10.27 |