이 글은 총 6편의 오디 - 폴링 로직 리팩터링 시리즈 중 마지막 여섯 번째 글입니다.
[오디 -폴링 로직 리팩터링]
1. Warm up Code로 CPU 스파이크 해결하기 feat) JIT Compiler
2. 계정 로드 밸런싱으로 Request Failed를 잡아보자
3. 트랜잭션에서 외부 API를 분리하여 응답 속도를 낮춰보자
그럼 약 한달간의 실험설계 부터 리팩터링까지의 여정을 다시금 돌아보겠습니다.
이번 리팩터링은 약 1년전에 우테코 레벨4 성능 부하 테스트를 통해 발견한 문제를 해결하기 위해 기획되었습니다.
당시 각자 여유가 없어 급하게 부하테스트만 하고 넘어간 부분이 있어 '폴링이 부정확하다' 정도의 인식만 있었지 명확한 원인을 특정하고 개선하는 과정까지 나아가지 못했습니다.
1. 문제 인식과 목표 설정
1-1) 무엇이 문제였나

- 요청 실패가 발생 : 오디세이 API 호출 시 60%가량 되는 요청 실패 발생
- 10-13s 되는 외부 API 응답 속도 : 10분 간격으로 외부 API 호출시점에 요청 시간이 10-13s 정도로 매우 느려짐
- TPS가 갑자기 줄어드는 문제 : 10분 간격으로 외부 API 호출시점에 TPS가 10 > 3.3으로 줄어듦
1-2) 무엇을 달성하고자 했나
- 회원 81명이 있기 때문에 최소 100명 동시 약속을 가용가능한 환경을 만들고 싶다
- Request Fail : 0%
- CPU : 30% 이하
- Memory: 50% 이하
- Request Duration
- 일반 폴링 로직 시 30ms 이하
- 10분간격으로 API 100개 동시 호출 스파이크가 튈 때 : 200ms 이하
2. 안전한 폴링 로직을 위해 던진 3가지 질문과 나의 답
그렇게 마음먹고 막상 부하테스트를 시작하려니 CPU 스파이크가 뛰어 부하테스트 결과가 왜곡되는 문제가 지속되었습니다. 직접 EC2에 접속해 원인을 특정할 수 있었고 자바 컴파일 과정에 대해 이해하는 시간이기도 했다. 자세한 문제 해결과정은 다음 링크에서 확인할 수 있습니다.
2-1) 요청 실패를 0%로 만들려면? -> 오디세이 초당 호출량을 준수하도록 계정을 로드밸런싱한다
원인
먼저 요청 실패의 원인은 오디세이 API에서 어뷰징 방지를 위해 초당 호출량을 제한해두고 있었기 때문이었습니다.
해결
따라서 약 6개의 계정을 만들어 오디세이 apiKey를 순회하면서 초당 호출량을 제한하도록 개선하였습니다.
그 결과, 요청 실패가 없어지는 모습을 볼 수 있었습니다.


2-2) 응답시간을 줄이려면? -> 비동기 + 이벤트 리스닝으로 외부 API를 트랜잭션에서 분리하자
원인
하나의 트랜잭션이 매우 길었습니다. 특히 10분간격으로 외부 API를 호출할 때는 트랜잭션 점유시간이 길어지면서 Hikaricp Connection Pool이 급속도로 고갈되었습니다.

해결
- 비동기 + 이벤트 리스닝 방식으로 트랜잭션 범위에서 외부 API를 분리하였습니다.


결과
- 고갈되었던 HikariCP Conenction이 5개 정도를 유지하였습니다.
- 외부 API 호출 시점에 TPS는 10을 유지하였습니다.
- 외부 API 호출 시점에 응답시간이 1300ms 정도에서 평균 100ms 내로 대폭개선되었습니다.


2-3) 2초 이내로 100개의 외부 API 호출 완료하려면 -> 스파이크에 적절한 커스텀 설정
원인
외부 API 호출 시점에 큐(사이즈 : 50)에 태스크가 쌓이면서 API 호출 시점의 싱크가 맞지 않는 현상이 발생했습니다.
약속 화면에서 어떤 참여자는 업데이트된 결과를 빠르게 보여주는 반면, 다른 약속 참여자들은 약 몇 초 뒤에 업데이트 된 결과를 받아볼 수 있게 되었습니다. 외부 API 호출 시 태스크를 담당하는 비동기 스레드풀 최적화의 필요성을 느꼈습니다.

해결
- 평상 시에는 작은 코어 스레드를 유지하여 리소스를 최소한으로 사용
- maxPoolSize를 최대 120으로 잡고, 큐사이즈를 0으로 잡아 대기하지 않고 빠르게 스레드를 생성해 처리하도록 커스터마이즈
- 코어 스레드 별로 처리시간을 측정한 결과, 코어 스레드 10에서 2초 이내의 처리시간을 일관적으로 보여줌
| corePoolSize | 외부 API 100개 요청 평균 처리시간 |
CPU | 최대 Mem | avg pool size |
| 4 | 1.778초 | 5.65% | 52.6% | 74.5 |
| 10 | 1.654초 | 5.16% | 55.3% | 38 |
| 20 | 2.001초 | 6.35% | 50.6% | 42.5 |
- 최종적으로 다음의 스레드풀 옵션을 사용함
corePoolSize : 10
maxPoolSize : 100
queue Capacity : 0
3. 목적을 이루었나
다시 처음으로 돌아가 설정했던 임계점을 돌아보겠습니다. 현재 오디 서비스에는 회원 81명이 있기 때문에 최소 100명 동시 약속을 가용가능한 환경을 만들고 싶다는 바람으로 이 리팩터링을 진행해보았습니다.
| 목표 | 달성 여부 | 비고 |
| Request Fail 0% | 성공 | |
| CPU - 30% 이하 | 성공 | 평균 7% 이내로 사용 |
| Memory 50% 이하 | 실패 | 평균 51~53% 사용 |
| 응답시간 - 일반 폴링 로직 : 30ms 이하 | 성공 | 평균 10- 15ms 달성 |
| 응답시간 - 100개 API 동시 호출: 200ms 이하 | 성공 | 평균 100-150ms 이내 |
대부분의 목표를 달성했으나 메모리는 오히려 더 사용하게 되었습니다. 대부분 메모리 50% 이상을 임계점으로 CPU 복제에 들어가기도 하기 때문에 아쉬운 결과이기도 합니다. 아무래도 비동기 스레드풀을 하나 더 관리하게 됨으로써 맞이한 자연스러운 결과가 아닌가 싶습니다.
인프라 스펙 중에 Redis 가 있는데 추후에 해당 컴포넌트를 더 적극적으로 활용하여 Ec2 메모리를 아껴보고자 하는 생각도 있습니다.
4. 느낀 점
1. 후련함
영화 암살의 마지막 장면에는 안옥윤이 염석진을 처단하는 미션을 수행하며 다음과 같은 대사를 외칩니다.
"16년 전, 그 임무 지금 수행합니다."
1년 전에 인식한 문제를 지금에야 리팩터링하는 것이 어떤 의미가 있을까 하지만 그래도 마음의 짐을 덜어낸 것 같아 후련했습니다. 또한 그동안 운영과정에서 쌓인 데이터가 있기 때문에 운영 데이터를 기반으로 적절한 실험설계를 할 수 있다는 것이 좋았습니다.
2. 가용범위에 대한 부하테스트는 선택이 아닌 필수다
2차 유저 테스트를 진행 중에 서비스에서 제대로 API 호출 및 갱신이 되지 않아 유저 테스트를 취소하고 참여해준 분들에게 사과문을 돌린 적이 있었습니다. 그때에는 해당 문제가 안드로이드 문제라 생각하고 아쉬운 마음을 가진 채 넘어갔습니다.
그러나, 지금 돌아보면 당시 유저 테스트에서는 20명의 동시 시간대 약속을 테스트하고 있었고 이는 오디세이 초당 호출량 제한으로 인해 에러가 발생할 수도 있었겠다는 생각을 했습니다. 결국 단위테스트에만 의존하여 가용범위에 대한 부하테스트를 생략했기 때문에 겪었던 아쉬움이었습니다. 초기 유치하고자 하는 유저 범위가 있다면 해당 가용범위에 대해서는 꼭 부하테스트를 수행해야 겠다는 생각이 들었습니다.
3. 이론을 아는 것보다 실제로 코드에 적용하고 눈으로 확인하는 것은 중요하다
외부 API를 트랜잭션에서 분리해야하는 것의 중요성은 익히 들어 알고 있었습니다. 그러나 외부 API 딜레이가 0.1초 내외였기 때문에, 또 빠른 스프린트 개발에만 목메여 눈을 감고 넘어갔던 것이 사실입니다.
0.1초의 짧은 딜레이지만 인프라 환경에서는 꽤 장시간의 점유일 수 있겠다는 생각이 들었습니다. 실제로 Hikari CP DB Connection Pool이 급속도로 고갈되어 가는 것을 모니터링으로 인지한 것이 충격이었습니다. 또한, 외부 API를 트랜잭션에서 분리하자마자 거짓말같이 응답속도와 커넥션 풀이 유지되는 것을 눈으로 직접 확인하는 과정이 즐거웠습니다.
무언가에 대한 가설을 세우고, 이를 적절한 근거를 가지고 검증하며 개선해나아가는 리팩터링의 즐거움을 부하테스트와 매트릭을 통해 가장 극대화하여 즐길 수 있는 시간이 아니었나 싶습니다.
4. 성장했다는 자부심
우아한 테크코스에 자바도 모르는 상태로 들어가서 약 1년이 지났습니다. 사실 레벨4에 부하테스트를 주의깊게 지켜보지 않고 팀원 조조가 전담했던 이유는, 당시 제가 너무 많은 학습량으로 이미 무언가를 더 학습하고 받아들이기에 지쳐있는 상태였기 때문에 한 몫했던 것 같습니다. 그러나 그 당시 어려워보였던 개념들과 부하테스트를 지금의 나는 직접 설계하고 차근차근 해결해나아가는 과정을 홀로 할 수 있다는 사실에 자부심을 느꼈습니다.
남들에게는 어쩌면 당연하고 어렵지 않은 개념을 지금에야 습득한 것이냐 핀잔할 수 있겠지만, 약 2-3주간의 이 리팩터링이 제게는 성장했음을 느끼는 시간이었고 부하테스트에 대한 두려움을 줄여주는 시간이었습니다.
앞으로의 다른 서비스에도 이러한 실험을 직접 설계하고 적용해보고 싶다는 생각을 했습니다.
이것으로 6편에 걸친 오디 -폴링 로직 리팩터링 시리즈는 마무리 하려고 합니다.
여기까지 읽어주신 분들께 감사하다는 말씀 올립니다.
부족한 부분에 대한 댓글은 언제든지 환영입니다.
[오디 -폴링 로직 리팩터링]
1. Warm up Code로 CPU 스파이크 해결하기 feat) JIT Compiler
2. 계정 로드 밸런싱으로 Request Failed를 잡아보자