통계 서버를 구축하며!

Jason Kang
Uniquegood
Published in
13 min readOct 20, 2021

소개

안녕하세요! 유니크굿컴퍼니에서 백엔드 개발을 맡고 있는 강현우 라고 합니다!

지난번에는 사용자의 급격한 증가로 인해서 여러 부하 테스트를 진행하고, 그 결과에 알맞는 최적화를 진행한 적이 있었는데요, 이번에도 비슷한 사례로 ‘통계 서버’에 대한 작업을 하였습니다. 이 통게서버를 구축하면서 겪었던 경험들을 공유하려고 합니다!

통계 서버 도입 필요성

리얼월드 사용자들의 게임 플레이 동향을 확인하기 위해서 리얼월드 관리자 서비스에는 통계 데이터 다운로드 기능을 제공하고 있었습니다. 그 기능은 다운로드 버튼이 눌릴 때 실시간으로 플레이 데이터를 가져와 Join하고, 엑셀로 변환’ 하고 있었습니다. 실시간으로 플레이 데이터를 가져와 Join하는 부분이 핵심적인 내용입니다.

자, 실시간으로 데이터를 가져와 Join합니다. 심지어 그냥 Join도 아니고, Join 안에 Join이 있는 이중 Join입니다. 사용자가 10, 100, 1000명일 때는 지연에 대해서 별로 인지하지 못하는 수준이겠지만, 만약 Join해야 되는 데이터가 몇만, 혹은 몇십만이면 어떨까요? 실제로 그런 일이 일어났습니다. 관리자 서비스에서 제가 직접 몇십만개의 데이터를 다운 받으려고 하니 5분이 지나도 응답은 오지 않았고, 500 Internal Server Error과 함께 서버가 아에 다운되었습니다. (그 당시에 관리자 서버가 갑자기 다운 되어서 다른 팀들에서 제보도 들어왔었죠..! 범인은 저였답니다.)

최근 리얼월드의 사용자가 이전에 비해서 많이 증가하였고, 현재도 꾸준하게 증가하고 있는 시점에서 사용자가 적을 때 사용할 수 있었던 구조는 더 이상 사용이 불가능한 상황이었습니다. 즉, 구조적인 변경이 필연적이었습니다.

구조, 그리고 구조

맨 처음에는 두가지를 고민 했는데요, 바로 쿼리 부분을 분석해서 어떻게든 최적화 하는 방안을 찾아내는 방식과 책임 개발자님께서 제안하신 아에 통계 서버를 새로 만들어서 이벤트 기반 구조를 가져가는 부분이었습니다. 간단한 방법은 어떻게든 쿼리 부분을 단순하게 최적화 해서 문제를 해결하는 방법이 가장 리소스도 적게 들겠지만, 현재 데이터베이스에서 관련 자료들을 가져오는 절차가 아래와 같이 꽤 DB의 자원을 많이 소모하는 방식이었습니다.

  • 사용자 정보 가져오기
  • 사용자가 어떠한 게임을 플레이 했는지 ‘플레이 데이터’ 가져오기
  • ‘플레이 데이터’에 해당하는 게임이 어떤 게임인지 가져오기

이렇게 세가지 데이터를 Join해서 가져옵니다. 이 중에서 어떤 게임을 언제 플레이 했는지가 다 대 다(N:N) 관계를 이루는 테이블의 정보입니다. 이중 Join을 포기 하려면 억지로라도 실 서버에 있는 데이터베이스 구조를 바꿔야 하는 상황인데… 통계 처리만을 위해서 실 서버에 있는 데이터베이스 구조를 바꾸는 것이 상당히 하이리스크(아니면 맥시멈 리스크?) 이기도 하고, 조금 억지스러운 부분이 없지 않아 있어서 이 생각은 과감히 버렸습니다.

그래서 결국에는 사용자 가입, 사용자 정보 변경, 게임 플레이 시작, 게임 종료이 5가지의 이벤트를 정의하여 각 이벤트가 발생할 때 마다 통계 데이터를 따로 만들기로 결정했습니다.

결정된 구조

위 언급과 같이 저희는 총 5가지의 이벤트를 정의했습니다.

  • 사용자 가입
  • 사용자 정보 변경(i.e 닉네임, 이메일 등)
  • 사용자 탈퇴
  • 게임 플레이 시작
  • 게임 종료

각 이벤트가 발생할 때 마다 이벤트 메시지를 Azure Service Bus의 각 이벤트의 토픽(Topic)에 보내고(Publish), 이벤트의 토픽을 구독(Subscribe) 하고 있는 통계 서버가 각 이벤트 별로 데이터를 병합(Aggregation)해 통계용 데이터베이스에 업데이트 하는 것을 기본 구조로 가닥을 잡았습니다. 이벤트가 발생할 때 마다 실시간으로 데이터를 병합하고 정리하기 때문에, DB에 저장 돼 있는 정보들이 최신 상태를 유지할 수 있다는 장점도 있습니다. 그리고 관리자 분들이 플레이 통계 데이터를 다운 받으려고 하실 때, 매 요청 마다 이중 Join을 하는게 아니라 통계 데이터를 그냥 날짜별로 가져오기만 하면 되기 때문에, 쿼리 자체도 단순해지는 효과를 볼 수 있다는게 저희의 기대였습니다.

예상치 못한 문제

이러한 이벤트 구조를 가져간다는 것 자체도 처음 해보는 작업이기 때문에 저에게 있어서 상당히 흥미롭고, 설레는 작업이었습니다. 그와 동시에 이번 통계 서버(와 Azure Service Bus/데이터베이스) 를 제가 담당해서 거의 모든 것을 혼자서 구축하기 때문에 정말 탄탄한 서비스를 만들어야 되겠다고 몇번이고 되뇌이기도 했습니다. 그런데 말입니다…

첫 나흘 동안 구현과 유닛 테스트를 모두 마치고 한번 테스트 삼아서 이벤트 각 1000개씩(총 5000개의 이벤트 메시지) 쌓아놓고 한번 한번에 통계 서버를 틀어 봤는데, 아주 재미있는 일이 일어났습니다.

바로, ‘펑’ 하고 터진 것이죠.

구조를 다시 변경 하다 — Pt. 1

이벤트를 동시다발적으로 구독하고 소비하다 보니까, 통계용 데이터베이스를 업데이트 할 때 데이터 일관성 자체가 보장이 안되는 문제가 첫 번째로 발생 했습니다. 무엇보다 메시지 사이의 선후 관계(사용자 가입 이벤트 발생 이후 게임 시작 이벤트가 들어와야 정상적으로 작동하는 등)가 아주 큰 걸림돌이 되었습니다. 저희는 이 통계 서버가 잠시 죽어도 다시 복구 되었을 때 쌓인 이벤트 메시지를 제대로 처리 해야 되었고, 토픽의 메시지가 비동기적으로 처리되는 이상, 통계 서버에서 처리 되는 이벤트의 순서가 무조건 보장되지는 않았기 때문입니다.

따라서, 비즈니스 로직은 유지하되, 메시지를 일정 시간동안 쌓아두었다가 10분 단위로 쌓인 이벤트 메시지를 메시지 사이의 선후 관계, 즉 의존성(Dependency) 순서에 따라서 순차적으로 처리하는 방안을 고려했습니다. 같은 조건으로 이벤트를 몇천개, 몇만개를 만들어놓고, 메시지 처리를 시작해 보니 데이터 일관성도 보장 되었고, 따로 서버가 터지는 일은 없었습니다. 이 구조면 될 줄 알았는데 말이죠..

구조를 다시 변경 하다 — Pt. 2

어느 정도 개발을 완료하고 이제 기존 마스터 데이터베이스에 있는 정보를 통계 데이터베이스로 마이그레이션 하기 위한 플랜을 백엔드 개발자들 분께 브리핑을 하고 있었는데, 조금 더 꼼꼼하게 따져 보니, 마이그레이션을 위한 SELECT * 쿼리를 마스터 서버의 데이터베이스에서 실행할 때, UPDATE/WRITE(Insert) 쿼리는 그대로 진행 되는 것을 확인했습니다. 즉, 마이그레이션을 진행하는 도중에 메시지가 도착할 수도 있다는 이야기 이죠. 사실 이 문제는 그렇게 문제가 아니었습니다. 마이그레이션이 끝난 후, 함수 서버를 실행하면 어차피 마이그레이션 중간에 생겼던 이벤트도 모두 처리하는 로직이었으니, 데이터도 제대로 반영 될 것이라고 생각했습니다. 다만 진짜 문제점은, 아무래도 사람이 배포를 하다보니까 통계 서버의 배포와 마이그레이션 시작, 그리고 이벤트 발생이 정확히 같은 시간에 이루어질 수는 없다는 점이었습니다. 이 각각의 시간 사이에 이벤트가 발생하는 경우, 어떠한 데이터를 사용해야되는지 충돌이 나는 상태였습니다.(이렇게 같은 데이터가 존재하는 경우 병합하는 과정이 일절 없었습니다.) 또는 아예 메인 서버를 짧은 시간 중단하고(다운타임) 마이그레이션을 잠깐 진행할 수는 있겠지만, 서비스를 잠시라도 중단한다는 결정은 저희 팀 뿐만 아니라 다른 팀의 업무 관계자 분들과 긴밀하게 소통해야 하는 일이고 무엇보다 플레이어의 불편을 초래하는 일입니다. 따라서 이러한 방식을 원하지 않았기 때문에 이 방안 또한 과감하게 생각에서 지웠습니다.

그래서, 다시 한번 구조를 변경 했습니다.

  • 메시지 별로 순서를 두지 않음. 즉, 선행되어야 하는 정보가 있으면 이 필요 정보를 임시 정보로 만들고, 선행되어야 하는 이벤트가 이후에 도착하는 경우, 두 이벤트 데이터를 병합하는 과정 개발
  • 이벤트 메시지를 쌓아두어 한번에 처리하지 않고, 메시지를 발송하고 수신할 때마다 이벤트 메시지를 처리하는 방식
  • 각 이벤트 별로 정말 ‘필요한’ 데이터만 업데이트 하도록 변경 ⇒ 이벤트 별로 최소한의 업데이트만 진행

또 다른 문제가 생기다

결국 위 방식으로 최종 결정을 내린 뒤에 다시 몇천, 몇만개씩 이벤트를 던져보고, 통계 서버를 실행 시키니, 데이터가 아예 꼬여버리는 문제가 발생했습니다. 다음 사진과 같은 상황입니다.

먼저, A/B이벤트는 같은 DB 레코드에 대한 이벤트입니다. 사용자로 따지면, 같은 사용자인 의미이죠!

자, A/B 이벤트 모두 C시점에 모델이 수정 되었습니다. 모델을 수정했으면, 데이터베이스에 저장 해야되는데, A이벤트는 D시점에 저장되고, B이벤트는 E시점에 저장됩니다. 그러면 여기서 질문을 하나 던질 수 있습니다. “ORM을 사용하고, 위와 같은 상황이면, 최신인 E시점에 저장한 데이터는 D 시점에 저장한 내용을 포함하는가?” 라는 질문을 던질 수 있는데, 몇만개씩 넣고 테스트 하다보니까, ‘Nope’라는 답을 하게 되었습니다.

조금 더 정리해서, ORM이 E시점에 데이터베이스에 있는 데이터를 기준으로 update 쿼리를 만들게 될 텐데, 바꾸면 안되는 정보까지 바꾸게 되는 것이죠. 더 풀어 쓰자면 다음과 같습니다.

  • 사용자 정보 수정 이벤트와 게임 시작 이벤트가 동시에 처리 된다고 할 때
  • 두 모델 처음에 다 사용자 정보를 가져옵니다.
  • 한쪽은 사용자 정보를 수정하고, 한쪽은 게임 시작을 처리합니다.
  • 사용자 정보 변동이 게임 시작 이벤트보다 먼저 데이터 업데이트 진행
    => 이 때, 변경된 사용자 정보가 데이터베이스에 반영 되어있습니다.
  • 프로젝트 시작 관련해서 데이터베이스에 업데이트 진행
    => 이 때, 이전에 변경된 사용자 정보는 ‘사용자 정보 이벤트’ 전으로 덮어씌워집니다.
    => 다시 말해, 저장된 데이터는 게임 시작 처리만 된 데이터가 저장된 것이죠.

해결책

분명 마이그레이션 중에 이벤트가 발생하며, 이에 대해서는 메시지를 쌓아두다가 마이그레이션이 끝나면 한번에 적용 시켜야 되기 때문에 위에서 언급했던 것과 같이 동시성 문제는 어찌 되었든 발생 할 수 있는 확률이 있습니다. 따라서, 이 문제는 결국 필연적이었던 것이죠. 이 문제를 해결하기 위해 열심히 구글과 공식 문서들을 검색한 결과, 비관적 동시성과 낙관적 동시성을 경험(?) 하게 되었습니다.

비관적 동시성

비관적 동시성이란, 일단 무조건 동시성으로 인해서 데이터 충돌이 있을 것이라고 가정하고, Transaction 에 락부터 걸어버리는 방법입니다. 말 그대로 데이터 충돌이 있다고 가정하기 때문에 '비관적'이라고 볼 수 있습니다. 이 부분이 가장 확실하게 동시성을 처리할 수 있는 방법입니다.

EF Core에서는 다음과 같이 비관적 동시성을 달성할 수 있습니다

저에게는 신기(뮤텍스와 비슷하구나!)하기도 했고, 비관적 동시성을 코드에 적용했을 때 과연 어떤 결과가 나올지 궁금해 코드에 바로 적용을 해 보았습니다. 그러나 현재 통계 서버 코드의 구조는 Trigger ←→ Service ←→ Repository 레이어를 사용하고 있는데, 위와 같이 모든 Find — Update — Save를 한곳에 몰아넣게 되니까 코드 패턴의 경계가 허물어지는 상황이 생겨서 다른 방안인 ‘낙관적 동시성’을 적용해 보고 정 해결이 안된다면 비관적 동시성을 적용할 계획이었습니다.

낙관적 동시성 + MSSQL’s ROWVERSION

비관적 동시성과 반대로 낙관적 동시성이란, 데이터를 가져왔을 때의 데이터 버전과, Write(Update) 하려는 데이터버전이 서로 다른 경우에, 업데이트를 하지 않는 형식의 동시성을 의미합니다. 데이터를 버전, TimeStamp, 혹은 Byte Array(이진) 으로 관리할 수 있지만, MSSQL에서는 ROWVERSION(구 Timestamp)을 자체적으로 지원하고 있습니다. MSSQL의 ROWVERSION은 여러가지 데이터 타입 중에 'Byte Array'를 사용하며, ROWVERSION이 있는 행에서 어떠한 update/insert/write 작업이 발생했을 때, 해당 ROWVERSION을 버전 업 시켜줍니다. 주의할 점은 실질적인 데이터 업데이트가 없더라고 하더라도, UPDATE 관련 쿼리가 들어간 순간 ROWVERSION은 증가한다는 점입니다. 따라서 다음과 같은 시나리오로 데이터 동시성을 해결할 수 있습니다.

  • 두(A/B) 서비스에서 Version 1의 데이터 모델을 가져옴
  • A서비스가 먼저 비즈니스 로직을 수행 하고, 변경 내용 저장 ⇒ 데이터베이스는 ROWVERSION을 Version 2로 증가
  • B서비스가 비즈니스 로직을 수행하고, 변경 내용 저장 ⇒ 저장하는 코드 입장에서는 데이터베이스의 ROWVERSION이 1이라는 것으로 예상하고 있지만, 실제로는 ROWVERSION = 2임. ⇒ 쿼리 실패, 재시도 요망

재시도 이후에는 정상적으로 쿼리가 진행되면서 동시성 문제가 발생해도 큰 문제 없이 데이터 일관성을 간단하게 지킬 수 있습니다.

EF Core에서는 데이터베이스 모델에 다음과 같은 Attribute만 추가해 주면 자동으로 ROWVERSION을 만들고 관리합니다.(코드 = MS 공식 문서)

위처럼 ROWVERSION을 추가해 줌과 동시에 부하테스트 목적으로 한번에 5~15만개 사이의 메시지를 Service Bus에 모두 집어넣고, 통계 서버를 실행시켜 데이터 일관성이 보장되는지 여러번 실험했습니다. 처음에는 5만개부터 시작해서 점차 늘려가며 15만개 정도까지 메시지를 한번에 소화시키게 했습니다. 상대적으로 낙관적 동시성은 비관적 동시성 보다 낮은 동시성을 가지고 있어서 데이터 일관성에 살짝 문제가 생길 수 있어서 조금 걱정했지만 다행히도 15만개의 메시지를 한번에 처리 시켜도 재시도 정책만 잘 적용하니 메시지가 완전히 소화 된 시점에서는 데이터베이스의 데이터 일관성이 유지되는 것을 확인했습니다.

배포 준비, 그리고 배포

코드도 최종적으로 완성 되었고, 한번에 최대 15만개라는 많은 메시지를 한번에 수용했을 때, 데이터 일관성도 보장되는 것을 확인 했으니 이제 메인 서버에 이벤트를 발생시키는 코드를 적용시키며 배포하고, 그리고 함수도 배포를 해야 되었습니다.

배포 뿐만 아니라, 기존에 리얼월드에서 사용하고 있던 데이터베이스의 내용도 마이그레이션할 준비가 되어야 했죠. 다행히도 마이그레이션과 관련된 부분은 백엔드 개발자들 모두가 심도있게, 그리고 치밀하게 계획을 세웠고, 미리 예상할 수 있는 모든 시나리오를 구상해 이에 대한 백업 플랜을 세웠습니다. 가령 마이그레이션 전-후 이벤트는 어떻게 처리할 것이며, 이벤트 consume시 충돌 케이스 해결 방안, 마이그레이션 중 데이터가 너무 많은 관계로 Timeout이 떴을 때 등등을 고려했습니다.

이번에 처음으로 독립적인 프로덕션 서버를 배포하면서 최대한 정확한 정보, 빠른 속도, 효율적인 구조를 구축하기 위해서 많은 고민과 에러가 났을 때에 대한 걱정도 많이 했었습니다. 다행히도, 서버를 배포하고 마이그레이션을 진행 할 때 별 다른 에러가 나지 않았고, 무난하게 지나가 정말 다행이라는 안숨의 한도를 내쉽니다 😂

최종적으로..

이번에 통계 서버를 만들고 배포하면서, 기존 통계 내용을 다운로드하는 것 보다 훨씬 빨라졌습니다. 최소한 중간에 다운받다가 서버가 죽거나, 10분씩이나 걸릴 일은 없어졌으나, 아직도 2분 내외의 시간이 소요되는것을 확인하고 있습니다. 제 성격 상 이것도 너무 오래걸린다고 생각해서 추후에는 데이터베이스 샤딩(혹은 파티셔닝), 관계와 인덱스 등 여러가지 기능들을 도입해서 레이턴시를 최대한 줄이는 것을 목표로 하고 있습니다!(물론, 통계 서버도 에러가 없는지 틈틈히 모니터링 해야겠죠!)

오늘은 서버를 배포/마이그레이션 한 날이기도 하면서, 동시에 논산훈련소를 가기 전날이기도 합니다. 훈련소에서 열심히 저 레이턴시 어떻게 줄일지 고민해 보고(??) 다시 복귀해 타이트한 성능 최적화를 계획하고 있습니다!

나중에 이 부분에 대해서 더 많은 최적화를 이루어내면 그에 관련된 글로 돌아오겠습니다. 감사합니다~!

--

--

Jason Kang
Uniquegood

Republic of Korea, ASP.NET Core Back-end developer of UniquegoodCompany.