ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 1분안에 다 팔리는 선착순 구매 구현을 위한 고군분투 기록
    컴퓨럴/후로그람스 2023. 7. 25. 08:43

    HEADER

    이 글은 삽질의 기록이라 할 수 있다. 아래와 같은 사례로 고민중인 분들에게 도움이 되면 좋겠다. 

    • 평소에는 사람들이 거의 단순히 정보를 얻기 위해서 일정 인원만 방문하다가
    • 갑자기 특정시간에 판매가 이루어지면서 사용자가 폭증함

     

    시나리오와 문제사항

    이번에 참여하게 된 서비스는 사람들에게 제품을 선착순으로 판매하는 서비스였다. 3일동안 나눠서 판매 했었는데, 첫날에 서버거 터져버렸다… 순식간에 8000명 정도가 "동시에" 제품을 조회도 아니고 "사러" 들어온 것이다!!! 

    사람들에게 왜 구매 자체가 안되는 것이냐, 아직 완판 되었다는 안내도 없는데 왜 살 수 없나 하는 문의가 빗발쳤다. 나는 울기 직전이었다.

    안 돼, 안 바꿔 줘. 바꿀 생각 없어. 빨리 돌아가!

     애걔 이거 들어왔다고 지금 호들갑 떠는거야?? 라고 하면 뭐 회사에서는 이정도 인원의 갑작스런 유입은 대부분 버틸 수 있을 것이다. 회사에서는 이미 데이터베이스, 인스턴스 타입 등이 잘 설정되어있고 상황에 따라 적정한 양을 조정하기만 하면 되었다. 그리고 요런 큰 의사결정은 시니어 형님들이 이미 고대에 닦아 놓으셔서, 어떤 의사결정을 하시는지 옆에서 보고 아 이렇게 하는구나 하는 정도였다. 그런데 아예 내가 책임을 지고 해보니, 아예 다른 영역이었던 거시다 ㅎㄷㄷ

     예전에 했던 사이드 프로젝트들은 우리들끼리 만들고 커뮤니티에 홍보하면 몇 안되는 사람들이 놀러와서 구경하고 가는 정도였다. 인프라를 아무리 개떡같이 설정해도 큰 문제가 있는지 파악하지 못했었다. 그러다 보니 너무 순진한 생각을 한거다. 사람이 갑작스레 많이 들어오고, 구매라는 중요한 로직까지 돌아가는데도 면밀히 테스트 하지 않았다. 서버 Pod과 데이터베이스 Instance만 적당히 늘려두고 그냥 하늘에 절하면서 그냥 되기만을 기다리는 모양새였다. 진인사 없는 대천명 이었다.

    오 서버의 신님

     

    해결을 위한 분석 - 시뮬레이션

    사실 근본적 원인은 아무 근거 없이 잘 돌아가겠지 했던 나의 오만이다. 한번도 해본적은 없었지만, 당장 닥친 문제를 제대로 해결하기 위해서 적극적으로 방법을 찾으면서 테스트 해야했다.

    해당상황을 상상만으로 대응하기에는 생각지 못한 문제가 나타날 수 있기 때문에, 시뮬레이션으로 스트레스 테스트를 해보았다.

    첫번째 시뮬레이션 시도 - shell script

    시뮬레이션방식으로 처음에 shell script로 엄청나게 많은 http 요청을 해보는 방식을 활용했다. 그런데 이 방법은 한 사용자가 그냥 디도스 공격을 한것이지 여러 사용자가 공경하는 형태가 아니다. 한 script에서는 동시에 여러 커넥션이 연결하지 않고 응답을 다 받고나서 다음 요청을 하게 되기 때문이다. 여러 쓰레드로 요청하게 만들면 되긴 하는데, 귀찮았따. 왜 따로 툴을 쓰는지 알겠다 싶었다.

    두번째 시뮬레이션 시도 - locust

    시뮬레이션 가능한 툴들을 여러가지 찾아보다가, 내가 제일 편해하는 언어인 python의 locust로 시뮬레이션을 짜서 원하는 사용자를 원하는 속도로 팍 늘려서 테스트해보았다. 시뮬레이션 스크립트를 짜놓으면 메뚜기떼처럼 요청을 한다.

    메뚜기떼랑 너무 잘 맞는 의미다.

     

    사고의 원인

    db가 감당할수 있는 connection 보다 많은 연결 시도

    판매 당시를 회고해보면 순식간에 예상보다 훨씬 많은 사람들이 들어와서 CPU 사용량이 거의 폭발시점에 이르렀다(순간적으론 95퍼까지 찍었다…) 너무 놀란 나는 최대한 많이 수평 auto scaling( Horizontal Pod Autoscaler - hpa)을 해야겠다는 생각을 하고, 무지성으로 서버 인스턴스까지 추가하며 pod을 늘렸다.

    폭 발!

     정신없던 기억을 하나씩 끄집어 내보면, 딱 그시점부터 사용자들의 문의가 느리게 떠요에서 에러가 떠요로 바뀐 시점이다. 가장 크게 난 에러는 Too many Connections / connection aborted. 당시에도 에러를 발견해서 max connection을 늘려보려고 했으나 RDS에서는 max connection을 늘리려면, 인스턴스를 늘려야 하는 이슈가 있었다(털썩)

    코드단에서 커넥션이 한 쓰레드당 여러개 커넥션 풀이 생성되는 이슈

     시뮬레이션을 하면서 발견한 것인데, 서버 코드에서 설정한 connection pool의 max connection 보다 더 많은 connection이 DB에 연결되어있는 이슈가 있었다. 예를 들어 최대 10개 연결을 해야할 pod이 30개 connection을 연결하고 있었다. 찾아보니 코드상 문제가 있었다.디비 커넥션 얻는 부분을 싱글톤으로 만들지를 않아 파일별로 커넥션이 생성되는 이슈가 있었던 것이다...!!!

    CPU를 많이 잡아먹는 작업 

    확률에 따라서 제품을 뽑을수 있는 럭키드로우 로직이 구매쪽에 있었는데, 구매 당시에 각 레벨별로 확률을 가진 랜덤 함수를 돌리는 로직이 있었다. 예를 들어 A급이 10%이고 B급이 30% C급이 60%라고 하면 A, B, C 문자열이 해당 확률에 맞게 나오도록 만들어 두었다. 하지만 B급이 다 팔리면 다시 재고가 남은 A, C급을 찾는 로직이 돌아야 했고 이 loop가 꽤 큰 CPU를 낭비하게 되어서 메모리 이슈를 만들었다. 

     

    해결

    싱글톤으로 사용하도록 코드를 수정해보자

    Node.js에서 모듈은 처음 요청될 때 한 번 로드되고, 이후에는 해당 모듈이 다시 요청되더라도 캐싱된다. 즉, 이미 메모리에 로드된 모듈은 다시 파일에서 로드하지 않고 메모리에 있는 라이브러리를 사용하게 된다. 그러므로 싱글톤처럼 사용 가능하다. 별도 커넥션이 저장되어있는 모듈을 만들어두어서 import해 이미 메모리에 떠있는 데이터베이스 커넥션을 사용하도록 했다. 이를 통해 서버 설정보다 더 많은 connection이 만들어지는 문제가 해결 되었다.

    As-Is)

    // 파일별로 코드를 불러올 때마다 initialize 하면서 각가의 커넥션 생성
    const knex_config = require('../knexfile');
    
    const knex = require('knex')(knex_config[config.node_env]);

     

    To-Be)

    const _database = require('../utils/database'); 
    const knex = _database.knex;
    
    // 별도 파일 만들어서 불러오기(싱글톤 처럼 사용 가능하게 만든다)
    const knex_config = require('../knexfile');
    const config = require('../config/config.js');
    const knex = require('knex')(knex_config[config.node_env]);
    
    module.exports = {
    	knex: knex,
    }

    사실 좀더 프레임워크 레벨의 백엔드 라이브러리를 사용했다면요런 이슈는 없었을거같다. 

     

    인스턴스 몇대를 어떤 스펙으로 쓸지 합리적으로 정하자

    그 전 판매로 동시에 몇명이 들어오는지는 확인했었던 상태이기 때문에, 동시에 8000명이 접속해서 구매하는 것으로 테스트 해보았다.

    첫번째 시도. Memory 3840, CPU 1024 9개 Pod 

    빠르게 처리되기는 하나, 팟 자체가 적어서 커넥션이 부족해지는 문제가 있었다. 코드 설정단에서 Pool max connection을 늘려볼수는 있었으나, 간간이 커넥션 풀 부족 외의 에러들도 나오고 있던 상황이었다. 

    두번째 시도. Memory 1920, CPU 512 18 Pod

    그래서 일단 팟 크기를 줄이고 개수를 늘려보는 테스트를 해보는게 더 좋겠다는 생각이 들었다. 팟이 적당히 작고 여러개가되면 에러 처리에 더 좋다라는 답번을 보기도 했다. 에러에 더 유연하게 대처할수 있게 되는 것이다. 커넥션 풀 에러율이 줄어들었지만, 간간이 너무 요청이 많다보니 응답 속도가 점점 느려지는 문제가 있었다. 그래서 크게 양을 늘려보았다.

    세번째 시도. Memory 1920, CPU 512  54개 Pod 

     요렇게 구성해보니, 커넥션 풀 문제 없이 돌 수 있었다. 

    네번쨰 시도. Memory 1920, CPU 512 120개 pod
    사실 3번으로도 이미 충분히 잘 돌아가던 상황이었지만, 너무 두려웠다. 사람들이 이번엔 더 많이 들어올 수 도 있는 거였고, 다시는 멘붕 상황을 만들고싶지 않았다. 다행히도 우리 서비스 구성은 잠깐 동안 엄청나게 많이 판매하는 구조였기 때문에, 돈이 조금 더 들더라도 짧은 시간동안 많은 팟을 띄우고 빠르게 판매하는 것이 더 이득이라 판단했다. 

     

    적당한, 넉넉한 데이터베이스 확보

    데이터베이스에서 이슈가 되었던 것은 Max connection 이었다. Pod 개수와 Connection은 같이 따라가기 때문에 기본적으로 내가 최대한 늘릴 Pod 개수만큼의 Connection을 받을 수 있는 인스턴스 설정이 필요했다.

    기본적으로 한 Pod에서는 다음과 같은 설정을 하고 있었다.

    pool: { 
    	min: 2,
    	max: 10,
    }

    1개 Pod에서 10개의 connection을 가지도록 설정해 두었고, 내가 띄울 Pod은 120개였기 때문에 1200개의 Max Connection은 받아들일 수 있는 데이터베이스가 필요했다.

    t2.micro 66
    t2.small 150
    m3.medium 296
    t2.medium 312
    m3.large 609
    t2.large 648
    m4.large 648
    m3.xlarge 1237 << 요것이 필요하다
    r3.large 1258
    m4.xlarge 1320
    m2.xlarge 1412
    m3.2xlarge 2492
    r3.xlarge 2540

    그래서 1247개의 max connection을 버텨닐 수 있는 m3.xlarge 를 선택했다.

    CPU 많이 사용하는 코드 수정하기

    럭키드로우를 구매당시에 랜덤 로직이 돌지 않고, 미리 럭키드로우를 섞어두고 사용자가 접근하는 순서대로 하나씩 나눠주도록 수정했다. 어차피 사용자는 자신의 순서를 마음대로 조정할수 없기 때문이다. 이미 만들어져 있는 럭키 드로우 로직을 활용해서 랜덤하게 제품을 데이터베이스에 넣어두었고 락을 이용해 두 사용자가 함께 구매할 수 없도록 했다.

    Write database 연결을 줄이기

    Reader database의 장점은 horizontal scale out이 가능하다는 점이다. 그래서 보통 서비스를 만들 때 reader database와 writer database를 둘다 연결해두고, 로직상에서 읽기만 필요한 경우 reader database를 쓰도록 설정하기도 한다.

    하지만, 나는 며칠 안에 기존에 코드 로직을 수정하고 확인하는게 더 시간이 많이 걸리고 예상치 못한 에러가 생길 수 있겠다는 판단을 했다. 그래서 최대한 코드 단 수정 없이 해결할 방법을 고민하다가 Reader 역할을 하는 pod과 writer 역할을 하는 pod을 나누고 ALB에서 API endpoint에 따라 나누도록 구현했다.

    구매로직 외에 정말 데이터를 읽어오는 로직만 있는 API들을 다 분리해서, Read 역할을 하는 pod으로 넣어두고 무한정 늘려서 쓸수 있도록 했다.

     

    해결결과

    동시접속자가 두번쨰 판매에서 더 많은 사람이 들어왔는 되었었는데, 느리지도 않고 스무스하게 잘 팔렸다. 그때 엄청 빠르게 다 판매하고 대표님에게 수고했다는 얘기를 들었을때 얼마나 뿌듯했던지!!

     

    결론

    가장 중요한건 사용자가 많이 몰리 것으로 보이는 상황에서는 미리 테스트를 해봐야 한다는 것이다. 막연히 잘되겠지… 하고 넘어가는건 아무리 사이드 프로젝트라고 해도 직무태만이다. 

    회사에서 당연하다고 생각한 것들도 사실 여러 문제로 쌓아진 경우가 많을거다. 회사에 일을 하면서도 현재 결정의 배경을 알아보고 익히는 것도 꽤 괜찮은 공부방법일거라 생각한다. 요즘엔 왜 이게 이렇게 되었죠? 하면서 뜬금없이 잘 물어보고 있다. 

     

    아쉬운 점들

    구매하는 로직들이나, API 접근 등에서 queue 사용했어도 좋았겠다 하는 생각이 든다. database lock보다 더 간단하게 순서를 통제할 수 있었을거다. 하지만 이미 엎지러진 물이었고, 남은 시간이 너무 촉박했기 때문에 하지 못했다. 다음에는 활용해보고 싶다.

     

    읽은이들에게

    이 글을 보고 멍청하게 서버 한다는 놈이 이런것도 모른다고 생각한다면 정진하겠습니다 ㅜㅜ 실수로 배우는것 아니겠습니까!! 언제든 지적해주세요. 또 차츰차츰 성장하면서 과거 내가 구현한 것에 아쉬운 지점들을 많이 찾을 수 있었으면 좋겠습니다. 

     

    그냥 남긴 생각사항들

    생각보다 커진 사이드 프로젝트 - 이것은 메인이 아닐까

    가볍게 진행중이던 프로젝트를 도와주는 형태로 시작했었다. 오 그 정도면 재미있겠다 하고 합류했다. 근데, 점점 내가 맡는 일들이 많아지더니 프로젝트의 매인 개발자가 되어있었다.

    회사일은 충실히 해내고 원래 쉬어야 할 시간에 프로젝트 일을 하다 보니 정말 죽을맛이었다. 특히 출시 직전에는 퇴근하자마자 새벽 3~4시까지 일하고 아침에 8시에 다시 출근하는 미친 일정을 소화했다.

    쉬지 못하고 일하다 보니 몸이 점점 나빠지는것이 느껴졌다. 심장이 콩닥콩닥 빠르게 뛰고 몸에 힘이 없었다. 사이드 프로젝트를 한다는건 이럴 수 있다는걸 늘 감안하고 시작해야 한다... :memo

    사이드 프로젝트에서 배울수 있는 넓은 범위들

    늘 Docker 빌드부터 ECS, ALB까지 다 연결해서 내가 서비스를 만들고 사람들이 많이 들어오는 모습을 보고싶었는데, 이를 경험해 볼 수 있어서 너무 뿌듯했다. 회사에서도 도커 심지어 k8s도 사용하고 있지만, 내가 처음부터 세부적인 설정을 다 해볼수 있는 기회는 없었다. 회사에서는 이미 세부적인 설정은 다 되어있기 때문이다. 혼자서 다 해보니 세부적으로 생각하지 못한 부분들을 생각해 볼수 있었고, 이리저리 부딪히며 성장한걸 느낄수 있었다.

    게다가 프론트 리액트도 처음 개발해보았는데, 컨셉들을 배우고 개발하고 보니 드디어 나도 프론트 해볼수는 있다 정도 얘기는 할수 있는 정도가 되었다.

     

     

Designed by Tistory.