제가 개발 중인 랭킹 시스템은 유저가 앱에 접속했을 때 처음 바라보는 화면이기 때문에 높은 TPS를 견딜 수 있어야 합니다. 또한 장 시간에 따라(증권사앱) TPS가 들쭉날쭉해서... 급격하게 증가하는 TPS도 견딜 수 있어야 해서 다양한 최적화 전략을 사용하고 있습니다. 하지만 기존에 사용하던 최적화 방법으로는 랭킹에 신규 기능을 추가하는 게 불가능했습니다(왜 그런지는 뒤에서 설명하겠습니다). 또한 기존의 최적화 방법이 전혀 도움 되지 않았을뿐더러, 불필요한 TPS를 생성하는, 즉 self DDOS를 유발하고 있었음을 알게 되었습니다.
기존 랭킹 시스템
랭킹 시스템은 아래와 같은 요구사항을 만족해야 합니다.
- 장 시간에 따라 다르지만 최대 4000 TPS
- 유저가 처음 보는 화면이기 때문에 짧은 latency
- Upstream 서버(ranking 서버)에 과도한 부하를 발생시키면 안 됨
유저의 요청이 순간적으로 몰리는 경우 발생하는 cache stampede 현상도 고려하여 시스템을 설계하였습니다. 그 결과 스케줄러로 랭킹 정보를 미리 메모리에 캐시하고 유저 유청은 캐시로부터 꺼내와서 응답하는 방식으로 랭킹 시스템을 구현했습니다.
랭킹 시스템의 스케줄러는 다음과 같이 동작했습니다.
- 2초 주기로 upstream 서버(ranking 서버)의 랭킹 정보를 조회하여 메모리에 캐싱
- 유저의 요청은 메모리의 캐시를 조회하여 응답
하지만 이 방식에는 아래와 같은 문제점이 존재합니다.
- 장 시간에 관계없이 항상 2초 주기로 upstream 서버의 랭킹 API를 호출. 호출이 적은 장 시간에도 불필요한 트래픽이 계속 발생
- 조회해야 하는 랭킹의 수(또는 조합의 수)가 증가할수록 upstream 서버의 API 호출 횟수가 급격히 증가. Dashboard 서버의 수도 많았기 때문에 scheduler로 인해 발생하는 TPS가 높음
위 설계는 랭킹 시스템이 복잡해지기 전까지 정상적으로 동작했습니다. 하지만 랭킹 시스템을 고도화하고 새로운 기능(거래 위험 종목 포함 여부, 기간 설정 등)이 추가되면서 스케줄러로 캐시 해야 하는 랭킹 조합의 수가 기하급수적으로 증가했습니다. 단순 계산으로만 해도 기존 시스템 TPS의 20 ~ 30배가 발생했기에 구조 개선이 필요했습니다.
구조 개선 작업
구조 개선을 위한 다양한 아이디어를 주고받았습니다. 스케줄러와 redis pub/sub을 활용해 upstream 서버의 부하를 최소화하고 redis에서 대신 처리하자부터... 하지만 스케줄러가 존재하는 이상 TPS를 줄일 수 있는 방법은 없었습니다. 다양한 아이디어가 오가던 와중 문득 "왜 스케줄러를 사용하고 있지?"라는 생각이 들었습니다. 클라이언트가 호출했을 때 upstream 서버 API를 호출해서 in-memory에 캐싱하면 되는 거 아닌가라는 생각이 들었습니다. 다만 우리가 스케줄러를 도입한 이유는 cache stampede 현상 때문이었습니다. 하지만 cache stampede가 정말 발생하는지를 확인해 본 적은 없었습니다. Cache stampede가 발생하지 않는데 스케줄러를 도입해서 불필요한 최적화를 진행한 게 아닐까?라는 생각이 들었습니다.
Cache stampede가 자주 발생하는 상황은 다음과 같습니다.
- 유저의 요청이 한 번에 몰릴 때, 즉 순간적인 TPS가 높을 때
- 요청을 처리하는데 오래 걸릴 때, 즉 여기서는 ranking 서버의 API가 요청을 처리하는 게 오래 걸리는 경우입니다.
Dashboard 서버의 경우 최대 TPS가 5000이라 가정하겠습니다(보통 4000 TPS 정도 나옵니다). Ranking 서버의 API 평균 응답 속도를 보니 10 ~ 20ms였습니다. 운영 환경에서 실행 중인 Dashboard 서버의 수(pod 수)는 100개(100 ~ 120개) 정도 되니 서버당 50 TPS를 처리한다고 볼 수 있습니다. 단순 계산으로 20ms 당(1s를 50으로 나누면 20ms) 1 TPS가 발생합니다. 그런데 Ranking 서버의 응답 속도가 10 ~ 20ms이면.... 물론 단순 계산이기는 하지만 cache stampede가 발생할 확률이 낮은 걸 알 수 있습니다.
Cache stampede 현상이 발생할 확률이 낮았지만 운영환경에서는 예상하지 못한 온갖 현상이 발생합니다. 따라서 cache stampede를 어느 정도는 방지할 수 있으면서도 ranking 서버에 부하를 주지 않는 방법을 생각해야 했습니다. 결과적으로 probability early expiration를 적용하기로 했습니다(Probability Early Expiration 상세 설명).
일반적인 캐시는 만료 시간이 존재합니다. 만료 시간이 지나면 해당 캐시가 무효화되면서 삭제됩니다. Probability early expiration은 만료 시간에 도달하기 전에 캐시를 갱신합니다(만료시간이 얼마나 남았는지에 따라 갱신 확률이 달라집니다). 캐시 만료 이전에 갱신하기 때문에 cache stampede 현상을 방지할 수 있습니다(단점으로는 로직이 약간 복잡해지고 추가적인 연산이 발생한다는 점입니다).
자, 이제 해결 방법에 대해 충분히 고민했으니 적용해보고 결과를 확인해서 우리의 가정이 맞는지 확인이 필요합니다. Probability early expiration을 적용 후 다음과 같은 결과를 얻을 수 있었습니다.
- 기존 스케줄링 방식에 비해 upstream 서버의 API 호출 수가 전반적으로 낮아짐
- Scheduling으로 인한 불필요한 API 호출 수가 줄어들어 장 시간이 아닌 경우에 낮은 TPS 유지됨
- 랭킹에 다양한 기능이 추가될 수 있는 구조가 됨
TPS 외에 별개로 cache 만료 비율 등 다양한 지표를 보고 있습니다만 cache stampede 현상으로 인해 upstream 서버에 과도한 부하가 발생하는 현상은 관찰할 수 없었습니다. 그래서 다음 스텝으로 probability early expiration 로직을 제거해서 테스트를 진행해 보고자 합니다. Probability early expiration도 결국 cache stampede 방지를 위해 기존 캐시 만료 시간보다 짧은 만료 시간을 가져가므로... probability early expiration을 걷어낼 수 있으면 걷어내는 게 upstream 서버 API 호출 횟수를 줄일 수 있습니다.
결론
Latency, upstream 서버 부하 낮추기, cache stampede 등의 다양한 위험 요소를 제거하기 위해 설계 초기부터 최적화를 진행하였습니다. 하지만 그 최적화들이 오히려 더 큰 부하를 발생시켰고 불필요한 비용을 유발하고 있었습니다. 불필요한 최적화는 운영 비용뿐 아니라 신규 기능 추가가 어려워지거나 최적화를 걷어내는 작업이 필요하게 될 수 있습니다. 따라서 최적화를 시작하기 전에 측정을 통해 그 최적화가 정말 필요한지 고민이 꼭 필요합니다. 다양한 최적화를 해봤지만 최적화를 걷어내는 작업은 이번이 처음이라 제 나름 새롭고 즐거운 경험이었습니다.
'Others' 카테고리의 다른 글
터미널 로딩 속도 개선하기 (0) | 2024.07.06 |
---|---|
소프트웨어 개발과 인지 편향 현상(Cognitive Bias) (0) | 2023.10.07 |