ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Toss | 서버 증설 없이 처리하는 대규모 트래픽
    📖 개발 공부 2024. 7. 27. 18:11
    토스 테크블로그를 읽고 쓰는 글입니다.
     

    서버 증설 없이 처리하는 대규모 트래픽

    늘어나는 트래픽을 잘 처리하기 위해 서버 개발자는 어떤 고민을 해야 할까요? “라이브 쇼핑 보기” 서비스에 대규모 트래픽이 들어오면서 겪은 문제와 해결책을 공유드려요.

    toss.tech

     

     

     

    급격하게 성장하는 서비스가 겪는 문제

    라이브 쇼핑 보기 서비스는 피크 시간대 동시 접속자 수는 분당 수십만 명, 포인트 지급 요청 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. 선착순 포인트 지급과 데이터베이스 과부하 문제

    서비스는 계속 성장하면서 유저 인입도 늘고 광고주들도 더 많은 라이브 쇼핑 광고를 집행했다. 방송이 많아질수록 더 많은 포인트를 받을 수 있어서 방송이 많은 시간에는 유저가 더 많이 들어오고, 포인트 지급 요청도 더욱 많아졌다.

     

    포인트 지급 요청 기능을 개발할 때는 아래 네 가지 요소를 반드시 고려해야 했다.

    • 한 유저에게 포인트가 중복 지급돼서는 안된다.
    • 유저가 포인트가 지급되었다는 걸 즉시 인지할 수 있어야 한다.
    • 포인트가 지급되었다는 걸 토스의 포인트 지급 내역 원장에 기록할 수 있어야 한다.
    • 선착순에 들지 못하면 포인트 지급을 하면 안 된다.

    첫 번째 문제인 포인트 중복 지급은 간단하게 생각하면 아래와 같은 로직을 적용하여 해결할 수 있다.

    1. 포인트 지급 API 요청이 오면 유저에게 포인트가 지급된 후 특정 저장소에 지급되었다는 내역을 생성한다.
    2. 포인트 지급 API 요청이 오면 특정 저장소에 지급되었다는 내역이 있는지 확인하고, 이미 지급된 경우 지급되지 않도록 분기를 구현한다.

    간단해보이는 로직이지만 고려할 부분이 꽤 많다.

     

    먼저 API 요청이 연속으로 2개가 들어오는 현상을 막기 위해 RedLock을 통해 API 요청에 대한 Distributed Lock을 걸어 주어야 한다.

     

    그 이유는 API 요청이 연속으로 2개가 오게 되면, 포인트가 지급되었다는 내역을 저장소에 넣기 전에 각 서버에서 2개의 포인트 지급 요청이 처리되기 때문이다.

     

    다음으로 두 번째 문제의 해결 방법이다. 포인트가 지급 되었다는 걸 즉시 인지하고, 중복 지급 여부에 대해 체크할 수 있도록 Redis와 같은 캐시 시스템에 포인트 적립 내역을 하나의 키에 Append하고, 데이터베이스에 적립 내역을 저장해야 한다. 데이터베이스에 적립 내역을 바로 넣지 못하는 이유는, 순간적으로 트래픽이 올라갈 때 데이터베이스에서 버틸 수 있는 Insert QPS가 넘어 데이터베이스 과부하로 이어질 수 있다.

     

    마지막으로 선착순 포인트 지급 문제는 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 중복 요청은 서비스가 성장할수록 큰 독이 된다. 피크 트래픽이 커지면 커질수록 중복 요청에서 발생하는 부하는 더더욱 커지게 된다. 중복 요청은 결국 게이트웨이, 웹서버, Redis, 데이터베이스를 포함해서 한 개의 API 요청을 처리하는 모든 컴포넌트에 부하가 생긴다.

    그래서 API를 합쳐서 응답을 보낼 수 있는 상황이라면, 적극적으로 합쳐야 한다.

    라이브 쇼핑 보기 서비스에 접속하면 방송 리스트 API, 포인트 지급 예정 API, 공지사항 API 총 3개의 API를 동시에 요청하도록 구현되어 있었는데, 3개의 API를 동시에 요청하면 피크 트래픽의 규모가 더 커지고, API 요청/응답에서 발생하는 오버헤드가 더 커진다. 또한 Gateway 부하도 늘어나게 된다.

     

    해당 API는 한 개의 API 로 묶을 수 있을 수 있어, /view라는 API로 묶었고, 결과적으로 피크 트래픽의 규모를 50%나 줄일 수 있었다.

    맺음말: 모니터링 구축을 기본으로

    성능 개선의 이터레이션을 진행할 때에도 시작과 끝은 모니터링이다. 제품이 성장할 때 서버 개발자는 모니터링 환경을 먼저 구축해야 한다. 서버를 포함해서 서버에 연결된 각 컴포넌트(Redis, DB, Kafka 등)와 서비스 지표(PV, UV, 리텐션 등)를 실시간으로 모니터링할 수 있고, 문제가 발생할 수 있는 특정 수준까지 온 경우 Alert 을 줄 수 있도록 말이다.

    서비스를 사용하는 유저가 장애로 인해 서비스를 사용하지 못하거나 유저 경험이 좋지 않다면, 유저 리텐션이나 신규 유입 지표에서 안 좋은 영향을 미치고, 서비스의 성장을 막을 수 있다. 또, 서비스가 성장하면서 발생하는 장애를 모니터 할 때 Metric은 원인과 결과를 예상하고 해결책을 제시할 수 있는 매우 중요한 지표이다.

     


    🚀 기술 문서를 읽고, 해볼만한 아이템 찾아보기

    LocalCache 도입

    Universal Data는 트래픽이 늘어날수록 한 개의 키에서 GET 커맨드가 굉장히 많이 발생한다. Redis에 많은 읽기 요청을 보내면 Redis의 CPU가 부하를 겪는다. 이로 인해 Redis의 커맨드 요청이 증가하고 Network IO도 증가한다. 커맨드가 밀려 Redis에서 지연이 발생할 수 있고, 이는 Redis를 사용하는 모든 곳에서 지연이 발생한다.

    이러한 문제는 웹 서버에서 Local Cache를 사용해서 Universal Data를 서버 내에서 전부 캐싱하는 방법으로 Redis의 사용량을 줄일 수 있다. 또한 Universal Data의 빠른 캐시 초기화 가 중요하다면, Redis의 Pub/Sub 기능을 사용해 Local Cache 초기화에 대한 비동기 메시지를 받아 로컬 캐시를 초기화할 수 있다.

    → 현재 진행중인 이벤트 데이터를 캐싱하기 위해 Redis를 사용하고 있는데, 배포 이후 Redis 커맨드 요청이 많다면 로컬 캐시 도입을 고민해봐야겠다. 로컬 캐시를 도입하면 Redis 부하를 줄이고, 응답 시간을 단축할 수 있다.

     

    Red Lock을 통해 중복 로직 실행 방지

    먼저 API 요청이 연속으로 2개가 들어오는 현상을 막기 위해 RedLock을 통해 API 요청에 대한 Distributed Lock을 걸어 주어야 한다. 그 이유는 API 요청이 연속으로 2개가 오게 되면, 포인트가 지급되었다는 내역을 저장소에 넣기 전에 각 서버에서 2개의 포인트 지급 요청이 처리되기 때문이다.

     

    → API 요청이 연속으로 오는 경우를 고려해봐야겠다. 불필요한 로직을 한 번 더 수행하게 할 수도 있기 때문이다. 현재는 DB에 쿼리를 해서 데이터가 있는지 확인 후, 없다면 로직을 수행하고 있는데, 분산락을 통해 아예 해당 로직을 수행하지 못하도록 할 수 있겠다.

     

    선착순 지급 +.+

    마지막으로 선착순 포인트 지급 문제는 Redis에서 지원하는 Increment 커맨드를 통해 리워드 지급 인원을 더하여 지정된 Cap에 도달하였는지 체크하는 방법으로 해결할 수 있다. 도달하지 못하였다면 리워드를 지급하고 Increment를 하는 반면, 도달한 경우 리워드를 지급하지 않도록 구현해야 한다.

     

    → INCR 이후의 결과로 지급을 판단하는 것이 중요하다. GET 명령어 이후에 지급을 하게 되면, 그 사이에 선착순이 끝날 수 있다. 현재 진행 중인 프로젝트에선 이런 니즈는 없지만, 추후에 고려해볼 만한 스펙이 나올 수 있다.

     

    반응형

    댓글

Designed by Tistory.