1. 사건의 시작
현재 저는 'SOPT' 라는 동아리에서 재밌고 편리한 동아리 활동을 위해 관련 서버를 운영하고 있습니다.
동아리 내에 몇몇의 행사를 신청하는 이벤트가 있었고, 당시에는 '솝커톤'이라는 행사를 신청하는 날이었습니다.
하지만 그 날 따라 처리속도가 유독 굉장히 느려진 것을 발견했습니다.
12시 정각에 행사 신청을 진행하였는데 12시 10분부터 슬랙으로 굉장히 많은 VoC가 들어왔습니다.
모두 여러 원인으로 인해 행사 신청이 정상적으로 동작되지 않았다는 VoC 였습니다.
아래 사진은 '솝커톤' 행사 신청 당시 들어왔던 VoC의 일부입니다.
이는 서비스 신뢰도에 치명적이었고, 다음 주에 있을 '2차 행사' 신청에도 문제를 줄 수 있었습니다.
따라서 정확한 원인 분석이 중요하였고, FE와 BE 모두 포스트모템을 진행하였습니다.
2. 문제 원인 분석
문제 원인을 분석해보는 과정은 매우 어려웠습니다. 이유는 아래와 같았습니다.
작년에는 문제가 없었는데 왜 이번에 문제가 생긴 것일까?
새로운 기능이나 로직이 개발된 경우에는 작년 코드와 비교하여 변경된 부분을 확인하면 됩니다.
하지만 이 때 당시에는 제가 들어온지 얼마되지 않았고, 행사 신청 로직을 변경하는 것은 굉장히 큰 부담이기에 손을 대지 못하고 있는 상황이었습니다. 행사 신청이 한 기수동안 많이 없기 때문에 한 번만 실수하더라도 서비스 신뢰도에 큰 문제를 줄 수 있기 때문입니다.
2-1. 프론트에서의 문제점
가장 먼저 프론트에서의 문제점을 분석해봤습니다. 프론트에서의 문제는 비교적 단순했습니다.
문제는 '행사 신청 API'와 '행사 신청 현황 조회 API'가 비동기적으로 동작하면서 발생했습니다. '행사 신청 API'를 호출한 후에는 요청이 처리될 때까지 로딩 바와 같은 대기 화면을 표시하는 동작이 필요했지만, 이러한 부분이 누락되었습니다. 이로 인해 '행사 신청 API'의 요청이 완료되지 않은 상태에서 '행사 신청 현황 조회 API'를 호출하게 되었고, 사용자에게 신청에 문제가 발생한 것처럼 보이게 했습니다. 그 결과, 사용자가 반복적으로 행사 신청 버튼을 누르게 되어 서버 부하가 증가하는 문제가 발생했습니다.
그림으로 표현하면 아래와 같습니다.
따라서 프론트에서는 기존에 비동기적으로 처리하던 로직을 변경했습니다.
"행사 신청 API" 동작중에는 로딩 화면을 보여주며, 해당 동작이 완료된 후에 "신청 현황 조회 API" 를 요청하도록 수정했습니다.
2-2. 백엔드에서의 문제점
서버에서도 토글 형식의 API로 로직이 잘못된 경우가 있었습니다.
하지만 근본적으로 이런 문제가 생긴 이유는 "서버의 부하로 응답시간이 느려져서 발생한 것" 이었습니다.
그럼 왜 서버의 응답시간이 느려졌을까요? 더 나아가 작년에는 문제가 생기지 않은 이유가 무엇일까?
가장 큰 원인은 DB 데이터 양이 증가함에 따라 쿼리 실행 속도가 많이 느려졌다는 것입니다.
2-3. DB 데이터에 따른 성능 비교 - 준비단계
DB 데이터 양이 증가함에 따라 실행속도가 느려지는 것은 당연합니다.
하지만 작년과 비교했을 때, 의미있는 차이가 있다면 해당 원인으로 귀결될 수 있습니다.
DB 데이터에 따른 성능 비교를 위해 아래와 같은 계획을 수립했습니다.
- Target 시스템의 범위
- caddy부터 RDS까지
- 부하테스트 데이터 케이스
- A 데이터 : 작년 데이터 추산치
- B 데이터 : 현재 운영 환경의 데이터 수
- 목푯값에 대한 성능 유지기간
- 모임 서비스의 특성을 고려했을 때, 10분으로 설정
- 제약 사항
- Jmeter나 nGrinder와 같은 JVM 어플리케이션의 경우 warm-up time이 필요.
- 보통 첫 결과는 무시한다고 함.
- 피크 트래픽 유저(vUser) : 200명
- 시나리오
- (약 11:50~12:10 사이에 가장 많은 요청 발생) 7분 동안 단순 모임 상세 조회 동작
- [솝커톤 시나리오] 모임 상세 조회 → 모임 신청 → 모임 상세 조회
- [솝커톤 시나리오] 모임 목록 조회 → 모임 상세 조회 → 모임 신청 → 모임 상세 조회
- [2차 행사 시나리오] 모임 신청 → 모임 상세 조회
2-4. DB 데이터에 따른 성능 비교 - 실행
테스트 상황
- 유저 : 200명 가정
- 무한요청한다고 가정(하나의 스레드 내에서 하나의 응답이 오면, 바로 다음 응답 보내는 방식)
- 7분 동안 부하
A 데이터
B 데이터
💡 데이터 수만 변경된 것인데 API 응답시간의 차이가 굉장히 심하다는 것을 확인할 수 있습니다.
저희는 이처럼 성능 비교를 통해 "작년에 비해 비대하게 많아진 데이터"를 문제의 트리거라고 생각했습니다.
하지만 초기 서비스에 비해 서비스가 성장하면서 데이터가 많아지는 것은 당연한 현상입니다.
따라서 데이터가 많아지더라도 안정적인 서버를 구축하는 것이 중요하다고 생각했습니다.
운이 좋게도? 저희 서비스는 동아리원들을 위한 서비스이기에 한 기수마다 데이터가 일정하게 쌓인다는 장점이 있었습니다.
다시 말해, 데이터의 양을 예측할 수 있다는 것입니다.
저희는 데이터 양을 가늠하여 설정하였고, 해당 데이터에서 안정적인 서버를 위한 성능개선 작업을 시작했습니다.
3. 성능 개선
3-1. 실제 실행됐던 쿼리 분석
해당 쿼리는 실제 장애 상황에서 있었던 쿼리입니다.
서브쿼리, DISTINCT, JOIN 등이 난무했습니다.
이러한 쿼리들이 TypeORM 에서 자동으로 생성된 쿼리였는데 정확히 어떤 원리와 기준으로 해당 쿼리가 만들어지는지는 알 수 없었습니다.
더 조사하면 알 수 있었지만, 당장 다음주까지 원인분석 및 액션플랜이 정해져야 했습니다. 또한, 앞으로 NestJS 가 아닌 Spring 으로 기술스택을 사용할 예정이기에 해당 API 를 Spring으로 마이그레이션하면서 성능 최적화를 시도했습니다.
3-2. 인덱스 설정
3-1에서 쿼리를 단순화 시키면서 인덱스 설정도 병행했습니다.
인덱스 설정은 가장 흔하게 할 수 있는 최적화 방식이지만, 저희 DB에는 인덱스가 설정되지 않았습니다.
SELECT a
FROM Apply a
JOIN User u ON a.user_id = u.id
WHERE a.meeting_id = :meetingId
AND a.status IN (0,1)
ORDER BY a.applied_date DESC;
모임 신청 과정에서 가장 큰 오버헤드를 가진 쿼리는 위와 같습니다.
저는 해당 쿼리에 대해 아래와 같이 인덱스를 설정했습니다.
- Apply 테이블에 복합 인덱스 설정 : (meetingId, status, applied_date DESC)
- User 테이블에 join 절에 사용되는 id를 인덱스로 설정
인덱스 순서는 meetingId -> status -> applied_date 순으로 설정했습니다.
meetingId의 중복도가 가장 적어 검색 범위를 훨씬 더 좁힐 수 있기에 가장 먼저 배치해뒀습니다.
또한, 정렬 작업도 효율적으로 하기 위해서 applied_date 도 인덱스로 설정했습니다. 이렇게 하면 데이터가 인덱스 레벨에서 이미 정렬되어 있으므로, 추가적인 정렬 작업이 필요 없어 성능이 크게 향상됩니다.
마지막으로, JOIN User u ON a.user_id = u.id에서 user_id와 id를 비교하므로, User.id에 대한 인덱스를 설정했습니다. 이렇게 하면 조인 시 두 테이블 간의 비교 작업이 빠르게 이루어질 수 있습니다.
a. 인덱스 설정 전 :
- Planning Time : 0.206 ms
- Execution Time : 38.036ms
b. 인덱스 설정 후 :
- Planning Time : 0.160 ms
- Execution Time : 0.132 ms
실행 계획은 호출 때마다 수치가 달라질 수 있기 때문에 총 10번을 수행했고, 아래와 같이 의미있는 결과를 얻을 수 있었습니다.
인덱스 설정 전, 실행시간 : 평균 30ms
인덱스 설정 후, 실행시간 : 평균 0.1ms
B-tree 자료구조를 사용하기에 순차탐색에 비해 훨씬 빨라진 속도를 확인할 수 있었습니다.
3-3. Redis 캐싱
특정 데이터를 조회할 때 거의 수정이 되지 않는 데이터가 있었습니다.
이러한 데이터는 트래픽이 몰릴 때 Redis와 같은 캐시에 저장하여 DB로의 병목현상을 방지할 수 있을 것 같았습니다.
그래서 캐시를 아래 정책들로 도입했습니다.
a. 캐시 읽기 전략 : Look-aside
- 캐시 읽기 전략으로는 Look-aside 방식으로 구현했습니다.
- 만약 Redis에 문제가 발생하더라도 DB에서 데이터를 불러올 수 있도록 하기 위함이었습니다.
- 또 다른 전략인 Read-through 방식은 어플리케이션이 Redis만 바라보고 있어도 된다는 장점이 있습니다. 하지만 SPOF 문제가 너무 치명적인이라고 생각하여 제외했습니다.
- 하지만 이렇게 구현 할 경우, 캐시 미스 상황에서 트래픽이 몰리면 DB 부하가 굉장히 심해진다는 단점이 있습니다. 다음 글에서 스탬피드 현상에 대한 고민을 작성해보겠습니다.
b. 캐시 쓰기 전략 : Write-Through
- 저희 서비스에서는 만약 쓰기가 일어날 경우, 캐시와 DB간의 동기화가 되는 것은 굉장히 중요합니다.
- 또한, 서비스 특성상 쓰기 이벤트가 정말 낮은 확률로 발생하기에 리소스 낭비가 크지 않을 거라 판단하여 Write-Through 방식을 채택했습니다.
c. Redis에 장애가 난다면? Redis failover : Sentinel
- 만약 Redis 서버에 문제가 생기더라도, DB로 데이터를 전달받을 수 있기 때문에 이론상 큰 문제가 없습니다.
- 하지만 Redis를 사용하지 않고 DB로 바로 요청하게 된다면, 서버 응답 시간이 굉장히 오래걸려 장애로 이어질 수 있습니다.
- 저희는 failover 전략으로 자동으로 failover가 가능해야 했기에 Sentinel 아키텍처를 사용했습니다.
- 어플리케이션에서는 센티널 노드만 바라보고 있고, 마스터 노드가 비정상이어도 자동으로 slave 노드를 바라보게 만들었습니다.
일반적인 상황일 때는 Master 노드를 바라보고 있습니다.
만약 마스터 노드에 문제가 생길 경우, Slave 1 노드로 자동으로 failover 합니다.
failover가 발생할 경우, Master 노드의 데이터를 Slave1이 해당 데이터를 복제합니다.
이러한 과정을 통해, 부하 상황에서 Redis 서버가 정상동작하지 않더라도 응답시간이 늦어지는 현상을 방지했습니다.
4. 결과
4-1. 성능 개선 결과
최종 성능 테스트
vUser : 300명 (실제 접속자 수 고려)
Loop Count : 50
정확성을 위해 단순히 1번 부하테스트를 진행하는 것이 아닌, 7회 반복하여 median 결과를 최종 결과로 설정했습니다.
개선 전
개선 후 - 86% 성능 향상
개선 전 (ms) | 개선 후 (ms) | |
Median | 3853 | 469 |
p90 | 4511 | 759 |
p95 | 4674 | 1030 |
평균 | 3827 | 526 |
전체적인 메트릭에서 괄목할만한 성능 최적화를 할 수 있었습니다!
특히 평균 응답시간에서는 3.8s -> 0.5s 로 약 86%의 성능을 향상시켰습니다.
이로 인해, 저희 서비스를 사용하시는 분들의 불편함을 조금이나마 해소할 수 있었습니다.
4-2. Sentinel failover 검증 결과
단순히 성능 최적화에서만 머무는 것이 아닌, 운영 관점에서도 검증이 필요하다고 생각했습니다.
저희는 3-3에서 설명했다시피 Sentinel 아키텍처의 Redis를 구성했습니다.
평소에는 정상동작하던 것도 부하 상황에서는 어떤 오류가 생길지 예측할 수 없습니다.
따라서 master 노드의 장애시 failover가 정상적으로 이뤄지는지, 정상적으로 이뤄지는데 얼마나 걸리는지 검증했습니다.
검증을 하기 위해서 1분동안 부하를 주면서 30초에 master Redis 를 강제종료 시켰습니다.
결과를 보면, 서버로부터 500 에러는 없었다는 것을 확인할 수 있습니다.
또한, Redis에 오류로 인한 DB 로의 접근도 아예 없었다는 것도 확인 가능합니다.
이는 slave 노드가 master 노드로부터 full sync 동작을 통한 동기화를 하기 때문입니다.
이를 통해 Sentinel 구조에서 Redis의 failover가 정상적으로 동작하는 것을 확인했습니다.
하지만 시간대별 응답시간을 보면, master redis 종료시점에 응답시간이 확 튄 것을 볼 수 있습니다.
이는 master 노드의 장애 감지 및 앞서 설명한 full sync 과정으로 생긴 오버헤드입니다.
이 과정에서 최대 1-2초의 지연시간이 발생하게 됩니다.
이처럼 Redis에 장애가 생기면 어느 정도의 지연시간이 발생합니다.
그럼에도 불구하고, DB에 접근하는 시간에 비해선 적은 시간이기 때문에 저희는 해당 아키텍처를 유지하고자 합니다.
5. 소감
서버 개발자로서 처음 장애를 겪고 이를 해결해 나가는 과정을 상세히 다뤄본 경험은 매우 값진 시간이었습니다. 초기에는 문제의 원인을 파악하는 데 어려움이 있었지만, 끊임없는 고민과 분석을 통해 점차 문제의 맥락을 파악하고, 결국 성능 최적화까지 이룰 수 있었습니다.
또한, 서버 개발자로서 사용자 불편을 직접 해결하는 기회가 많지 않았는데, 이번 작업을 통해 사용자의 불편함을 상당히 해소할 수 있었던 점이 가장 큰 성과라고 생각합니다.
비록 개선 작업을 진행했지만, 여전히 개선할 부분은 많다는 것을 느꼈습니다. 남은 기간 동안 선착순 신청 시스템을 더욱 효율적으로 개선하여 사용자 경험을 한층 더 향상시키기 위해 계속해서 작업을 진행할 예정입니다.
다음 글에서는 단순 조회에 그치지 않고, 데이터가 INSERT 되는 로직에 대한 최적화도 다룰 계획입니다.
지금보다 한 층 더 발전된 모습 기대해주세요 ~!
'BE > Spring' 카테고리의 다른 글
5만건 데이터, OOM 문제부터 110ms까지 개선 - 3편 (0) | 2025.03.25 |
---|---|
5만건 데이터, OOM 문제부터 110ms까지 개선 - 2편 (0) | 2025.03.08 |
5만건 데이터, OOM 문제부터 110ms까지 개선 - 1편 (1) | 2025.03.08 |
[Spring] 빈, 컨테이너 그게 뭔데? (0) | 2023.07.18 |
댓글