서버 증설 없이 처리하는 대규모 트래픽
안녕하세요. 저는 토스의 광고 제품과 플랫폼을 개발하는 서버 개발자 함종현입니다. 저는 토스에서 라이브 쇼핑 보기 서비스를 담당하고 있어요.
라이브 쇼핑 보기 서비스란
라이브 쇼핑 보기는 토스의 “혜택” 탭에 있는 서비스예요. 라이브 쇼핑 보기를 통해서 유저는 상품을 구매하면 포인트를 적립 받을 수 있고, 광고주는 빠르게 상품 물량을 소진시킬 수 있어요.
라이브 쇼핑 보기 서비스를 론칭하는 날, 예상했던 것보다 굉장히 많은 유저가 들어왔어요. 그 후에도 매일 신규 유저가 늘었고, 서비스의 성장이 눈에 띄게 보였죠. 그러면서 자연스럽게 라이브 방송하는 광고주도 많아졌습니다.
급격하게 성장하는 서비스가 겪는 문제
이렇게 라이브 쇼핑 보기 서비스는 피크 시간대 동시 접속자 수는 분당 수십만 명, 포인트 지급 요청 API 요청은 초당 수십만 건이 오는 서비스로 성장했는데요. 급격히 늘어난 트래픽은 성장하는 서비스 서버에 치명적일 수 있어요. 서버가 트래픽을 유연하게 처리하지 못하면 유저에게 안 좋은 경험을 제공하고, 최악의 상황에서는 이탈할 수도 있기 때문이에요.
트래픽이 급격히 늘어나면 쓰레드가 밀리는 것부터 시작해서 데이터베이스와 캐시 시스템, 다른 서버와 같이 사용하는 서버 애플리케이션, 게이트웨이 등에서 장애가 발생할 수 있는데요. Redis와 같은 캐시 서비스의 메모리 사용량이나 CPU 사용량이 늘어나면 캐시가 누락되어 데이터베이스에 큰 부하를 줄 수 있어요. 데이터베이스 장애는 데이터를 오염하거나 다른 서비스의 영향을 줄 수 있고요.
간단히 서버 증설로 증가한 트래픽을 모두 처리할 수 있다면 가장 좋겠지만 고민해야 하는 점이 몇 가지 더 있습니다. 증설 비용 규모, 그리고 특정 시점에만 트래픽이 몰리면 그 외 시간에는 자원이 낭비될 수 있다는 점이 크고요. 서버 증설 만으로 해결할 수 없는 다른 문제가 생길 수도 있어요.
늘어나는 트래픽을 잘 처리하기 위해 서버 개발자는 어떤 고민을 해야 할까요?
라이브 쇼핑 서버가 만났던 문제
먼저 라이브 쇼핑 서버가 겪었던 몇 가지 문제를 소개하고 해결책을 살펴볼게요.
1. Redis 과부하 문제
매일 정각에 수십만 명의 유저에게 방송 리스트, 포인트 적립 내역 등을 Redis에 저장하고 읽었는데요. 유저가 늘면서 커맨드와 캐싱하는 데이터 양이 급격히 늘어났어요. CPU가 커맨드를 너무 느리게 처리하거나 데이터가 너무 많으면 Redis에 과부하가 생길 수 있는데요. Redis의 과부하는 데이터베이스의 부하로 이어질 수 있기 때문에 심각한 문제입니다.
Redis 과부하를 방지하려면 Redis가 캐싱하는 데이터와 읽고 쓰는 시점을 체크해야 하는데요. 먼저 Redis가 캐싱하는 데이터를 두 가지로 분류할 수 있어요. 모든 유저에게 동일하게 보이는 Universal Data와 유저 별로 다르게 사용되는 User-Specific Data입니다. 토스 라이브 쇼핑 서비스에서 전자는 모든 유저가 볼 수 있는 방송 리스트, 방송 상세 정보 등을 캐싱하고 후자는 방송 시청 여부 등을 캐싱합니다.
Universal Data 문제 & 해결책
Universal Data는 트래픽이 늘어날수록 한 개의 키에서 GET 커맨드가 굉장히 많이 발생하는데요. Redis에 많은 읽기 요청을 보내면 Redis의 CPU가 부하를 겪어요. 이로 인해 Redis의 커맨드 요청이 증가하고 Network IO도 증가합니다. 커맨드가 밀려 Redis에서 지연이 발생할 수 있고, 이는 Redis를 사용하는 모든 곳에서 지연이 발생합니다.
이러한 문제는 웹 서버에서 Local Cache를 사용해서 Universal Data를 서버 내에서 전부 캐싱하는 방법으로 Redis의 사용량을 줄일 수 있습니다. 또한 Universal Data의 빠른 캐시 초기화가 중요하다면, Redis의 Pub/Sub 기능을 사용해 Local Cache 초기화에 대한 비동기 메시지를 받아 로컬 캐시를 초기화할 수 있어요.
User-Specific Data 문제 & 해결책
User-Specific Data는 유저가 늘어날수록 캐싱해야 하는 데이터가 많아지고, 데이터의 크기가 커질수록 Redis 메모리 사용량이 늘어난다는 문제가 생길 수 있습니다. 유저가 늘어나면 메모리 사용량이 늘어나는 건 당연하지만, 데이터 하나의 크기가 작아진다면 메모리 사용량도 효과적으로 줄어듭니다.
즉, Redis에 DTO와 같은 데이터를 저장할 때 다양한 압축 방법을 활용해서 데이터 크기를 최소화해야 합니다. 단, 너무 작은 크기의 데이터를 압축하면 오히려 데이터가 커질 수 있으니 주의해야 해요.
Redis 과부하로 데이터 처리를 못하게 된다면 Fallback 로직도 고려해야 합니다. 만약 거대한 트래픽이 그대로 데이터베이스에 가게 된다면, 데이터베이스 서버에서 큰 장애가 발생할 수 있기 때문이죠.
2. 선착순 포인트 지급과 데이터베이스 과부하 문제
서비스는 계속 성장하면서 유저 인입도 늘고 광고주들도 더 많은 라이브 쇼핑 광고를 집행했어요. 방송이 많아질수록 더 많은 포인트를 받을 수 있어서 방송이 많은 시간에는 유저가 더 많이 들어오고, 포인트 지급 요청도 더욱 많아졌습니다.
포인트 지급 요청 기능을 개발할 때는 아래 네 가지 요소를 반드시 고려해야 했습니다.
- 한 유저에게 포인트가 중복 지급돼서는 안됩니다.
- 유저가 포인트가 지급되었다는 걸 즉시 인지할 수 있어야 합니다.
- 포인트가 지급되었다는 걸 토스의 포인트 지급 내역 원장에 기록할 수 있어야 합니다.
- 선착순에 들지 못하면 포인트 지급을 하면 안 됩니다.
첫 번째 문제인 포인트 중복 지급은 간단하게 생각하면 아래와 같은 로직을 적용하여 해결할 수 있습니다.
- 포인트 지급 API 요청이 오면 유저에게 포인트가 지급된 후 특정 저장소에 지급되었다는 내역을 생성합니다.
- 포인트 지급 API 요청이 오면 특정 저장소에 지급되었다는 내역이 있는지 확인하고, 이미 지급된 경우 지급되지 않도록 분기를 구현합니다.
간단해보이는 로직이지만 고려할 부분이 꽤 많습니다.
먼저 API 요청이 연속으로 2개가 들어오는 현상을 막기 위해 RedLock을 통해 API 요청에 대한 Distributed Lock을 걸어 주어야 합니다. 그 이유는 API 요청이 연속으로 2개가 오게 되면, 포인트가 지급되었다는 내역을 저장소에 넣기 전에 각 서버에서 2개의 포인트 지급 요청이 처리되기 때문이에요.
다음으로 두 번째 문제의 해결 방법인데요. 포인트가 지급 되었다는 걸 즉시 인지하고, 중복 지급 여부에 대해 체크할 수 있도록 Redis와 같은 캐시 시스템에 포인트 적립 내역을 하나의 키에 Append하고, 데이터베이스에 적립 내역을 저장해야 합니다. 데이터베이스에 적립 내역을 바로 넣지 못하는 이유는, 순간적으로 트래픽이 올라갈 때 데이터베이스에서 버틸 수 있는 Insert QPS가 넘어 데이터베이스 과부하로 이어질 수 있어요.
따라서 데이터베이스에 Insert할 때에는 Kafka를 통해 비동기로 Insert하고, Consumer에서 Throttling을 걸어 최대 QPS에 도달하지 않도록 조절해야 합니다. 지급 내역을 Insert할 때 포인트 지급 내역이 원장에 쌓이기에 이 부분도 해결돼요.
마지막으로 선착순 포인트 지급 문제는 Redis에서 지원하는 Increment 커맨드를 통해 리워드 지급 인원을 더하여 지정된 Cap에 도달하였는지 체크하는 방법으로 해결할 수 있습니다. 도달하지 못하였다면 리워드를 지급하고 Increment를 하는 반면, 도달한 경우 리워드를 지급하지 않도록 구현해야 합니다.
Redis는 Single-Thread로 동작하기에 Increment의 경우 Thread-Safe한 동작인데요. 단시간에 너무 많은 Increment 커맨드를 요청하면 Redis의 Thread가 밀려 CPU가 상승해요. 이 부분의 경우 Local Cache에서 Counting한 후 특정 시점에 ScheduleJob으로 Redis에 Flush하는 방법으로 성능 이슈를 개선할 수 있어요. 하지만 이 방법은 Hard하게 Capping하는 방식은 아니어서 서비스에서 추구하는 선착순의 개념과 일치한지 확인해야 합니다.
3. API 중복 요청 및 Gateway 과부하 문제
피크 타임에 라이브 쇼핑 보기 서비스에 트래픽이 너무 많이 올라와서 트래픽을 최소화할 수 있는 방법을 찾던 도중, 중복 요청을 제거하는 것과 API 요청을 1개로 합치는 방법을 적용해 봤어요.
API 중복 요청은 서비스가 성장할수록 큰 독이 됩니다. 피크 트래픽이 커지면 커질수록 중복 요청에서 발생하는 부하는 더더욱 커지게 돼요. 유저 1명에게서 중복 요청이 N개 만큼 오게 되면, 서비스가 성장할수록 의미 없는 중복 요청의 수가 매우 늘어나게 되는데요. 중복 요청은 결국 게이트웨이, 웹서버, Redis, 데이터베이스를 포함해서 한 개의 API 요청을 처리하는 모든 컴포넌트에 부하가 생깁니다.
특히 중복 요청은 토스 서버로 오는 모든 요청 값과 응답 값을 암호화하는 Gateway에 부하를 발생시키고, 이는 전체 토스 서비스에 영향을 미칠 수 있어요. API 중복 요청은 분석하기가 까다롭고, 해결하기 위해서는 클라이언트 개발자와 지속적으로 소통하면서 서비스와 유저 경험에 영향이 없도록 해야 합니다.
먼저 중복 요청을 분석하기 위해 라이브 쇼핑 보기 트래픽 중에 API 요청이 가장 많은 유저 ID 기준으로 API 요청 기록을 확인했어요. 그리고 단 시간에 중복으로 요청되는 API가 어떤 상황에서 재현되는지 체크했습니다. 재현된 중복 요청은 클라이언트 개발자와 커뮤니케이션하여 제거하고, 중복 요청을 제거한 후에 서비스에 문제가 없는지, 실제로 API 요청이 눈에 띄게 줄었는지 모니터링했어요.
그리고 API를 합쳐서 응답을 보낼 수 있는 상황이라면, 적극적으로 합쳐야 합니다.
라이브 쇼핑 보기 서비스에 접속하면 방송 리스트 API, 포인트 지급 예정 API, 공지사항 API 총 3개의 API를 동시에 요청하도록 구현되어 있었는데요. 3개의 API를 동시에 요청하면 피크 트래픽의 규모가 더 커지고, API 요청/응답에서 발생하는 오버헤드가 더 커져요. 또한 Gateway 부하도 늘어납니다.
해당 API는 한 개의 API 로 묶을 수 있을 수 있어, /view
라는 API로 묶었고, 결과적으로 피크 트래픽의 규모를 50%나 줄일 수 있었어요.
하지만 묶기 어려운 API도 있죠. 응답 시간이 긴 API 의 경우 1개의 API로 합치게 되면 API 응답이 길어지게 되어 문제가 발생할 수 있습니다. 상황에 따라 합칠 수 있는 API는 합치고, 분리해야 하는 API는 분리해서 사용해야 합니다.
성능 개선의 이터레이션
빠르게 성장하는 서비스라면 앞서 이야기 한 문제 외에도 많은 문제가 생길 수 있는데요. 성능 개선의 이터레이션을 지속적으로 반복하면 문제를 초기에 발견하고 해결해서 서비스 운영에 장애를 막을 수 있습니다.
- 서버 모니터링
- 문제점 파악
- 해결책 제안 및 적용
- 카나리 배포 후 서버 모니터링
위 네 가지를 이터레이션한다면, 서버를 증설하기 전에 다시 한번 증설 여부에 대해 고민할 수 있고, 서버를 증설한 후에 개선이 된다면 많은 리소스가 절약될 수 있어요.
그 외에도 프로파일러를 통해 성능 모니터링을 지속적으로 하고, 메모리나 연산이 많은 부분을 최적화하면서 서버 리소스를 최적화할 수 있어야 돼요. 또한 중복으로 실행되는 로직이 있는지도 지속적으로 체크하여 불필요한 로직을 모두 없애야 합니다. 그리고 최적화한 버전을 Canary로 배포하면서 장애가 발생하는지, 이전 버전과 비교하여 성능 개선이 유의미한지 모니터링을 해야 합니다.
맺음말: 모니터링 구축을 기본으로
성능 개선의 이터레이션을 진행할 때에도 시작과 끝은 모니터링입니다. 제품이 성장할 때 서버 개발자는 모니터링 환경을 먼저 구축해야 합니다. 서버를 포함해서 서버에 연결된 각 컴포넌트(Redis, DB, Kafka 등)와 서비스 지표(PV, UV, 리텐션 등)를 실시간으로 모니터링할 수 있고, 문제가 발생할 수 있는 특정 수준까지 온 경우 Alert 을 줄 수 있도록 말이죠.
서비스를 사용하는 유저가 장애로 인해 서비스를 사용하지 못하거나 유저 경험이 좋지 않다면, 유저 리텐션이나 신규 유입 지표에서 안 좋은 영향을 미치고, 서비스의 성장을 막을 수 있어요. 또, 서비스가 성장하면서 발생하는 장애를 모니터 할 때 Metric은 원인과 결과를 예상하고 해결책을 제시할 수 있는 매우 중요한 지표입니다.
성능 개선의 이터레이션을 반복하여 급격하게 성장하는 서비스에서도 장애와 성능 이슈를 유연하게 대처할 수 있길 바랍니다.