리얼월드 서버의 미제사건, 원인 모를 과부하 해결하기

Yechanny
Uniquegood
Published in
16 min readMay 27, 2021

부제: Azure 클라우드 실전 Troubleshooting

리얼월드 서버는 Microsoft Azure 클라우드 위에서 돌아가고 있습니다. 클라우드 서비스를 이용하는 것이 직접 물리적인 서버를 운영하는 것보다 관리하기 편하다는 건 요즘 개발하시는 분이라면 모두 많이 들어보셨을 것입니다. 저희도 같은 이유로 클라우드를 사용하고 있는데요, 실제로 리얼월드 서비스를 운영하면서 겪었던 재미있는(?) 이슈와 이를 해결하기 위한 고군분투의 과정, 그리고 그 과정 중에 사용했던 Microsoft Azure의 기능들을 소개합니다.

문제가 발생하다

리얼월드를 2018년 7월 처음 정식 오픈한 이후, 리얼월드 서버에는 만성적인 질환이 하나 생겼었습니다. 가끔 알 수 없는 이유로 서버가 요청들을 빠르게 처리하지 못 하고 매우 매우 느려지는 현상이었습니다. 그 문제가 한 번 발생하기 시작하면 CPU 사용량이 급증하고 요청들이 쌓이고 쌓여 결국 서버 전체가 마비되어 아무런 요청도 처리하지 못 하는 상태가 되는, 그런 무시무시한 현상이었습니다. 그럴 때마다 제가 즉각적으로 할 수 있는 건 서버의 인스턴스를 늘리거나(스케일-아웃) 아예 재시작하는 것뿐이었죠.

Azure App Service(웹 호스팅 서비스)에서는 CPU/Memory 사용량 기준으로 자동 크기 조절하는 것을 지원합니다. 하지만 그때 당시 자동 크기 조절을 어떤 식으로 설정해야 할지 감을 잡지 못해서 수동으로 인스턴스 수를 조절했었습니다.

슬라이더를 이용해 수동으로 인스턴스 수를 조절했습니다.

이러한 노력에도 불구하고 사후 대응만으로는 문제가 해결되지 않았습니다. 무엇보다 인스턴스 늘리는 것도 비용이기 때문에 근본적인 문제의 원인을 찾기 위한 노력을 병행했습니다.

실제 상황 발생 당시 화면. 인스턴스를 8개까지 늘려도 일부의 CPU 사용량만 급증하는 것을 볼 수 있습니다.

기존의 경험에서 원인 찾기

사실 리얼월드를 운영하면서 이렇게 서버가 요청을 처리하지 못하는 상황이 또 있었습니다. 2018년 9월쯤 어떤 단체 행사에서 400명 정도의 인원이 동시에 접속하는 상황이었는데요, 리얼월드에 가입도 하지 않았던 사람들이 앱을 처음 쓰게 되면서 초기 설정 로딩 - 가입 - 로그인까지 한 번에 처리하느라 매우 느려지는 문제였습니다. 이때는 일단 상황을 해결하기 위해 Azure SQL Database의 성능도 최대로 올렸고, App Service도 스케일-업했었습니다.

Azure SQL Database도 슬라이더를 이용해 손쉽게 성능 조절을 할 수 있습니다. 너무 손쉬워서 문제였지만요(?)
App Service는 몇 가지 스케일-업 옵션을 제공하는데, 중간이 없어서 아쉽습니다. S1, 2, 3는 각각 2배씩 가격 차이가 납니다.

여담이지만 이때 이 문제를 급하게 해결하겠다고 데이터베이스 성능을 정말 손가락 클릭만으로 설정할 수 있는 최대치로 바로 설정했었는데요, 이게 무려 하루에 65만 원짜리 성능 옵션이었습니다. 한 달 아니고 하루에 65만 원이요. 당시 리얼월드 서버 비용으로 한 달에 딱 40만 원 정도가 나왔었는데 하루에 65만 원짜리라는 걸 나중에 알고 나서 정말 후들거렸었습니다 ^^; 다행히 딱 하루 동안만 저 설정을 유지해서 추가적인 지출은 막았지만 좀 더 빨리 원상복구 시켰으면 어땠을까 아쉬움이 남았었습니다.

위 사례 때는 그냥 그 시점의 단순 조치로 근본적인 해결 없이 넘어갔었는데, 이런 비슷한 사례가 2019년 초에 한 번 더 생겼었습니다. tvN 방송 프로그램인 대탈출과의 이벤트 콜라보로 유저가 급격하게 몰려들면서 발생했던 문제였는데요, 이때는 Azure에서 제공하는 모니터링 솔루션인 Application Insights를 통해 원인을 분석해보았습니다.

Application Insights를 이용하면 어떤 시점에 요청 처리 시간이 오래 걸렸고 그 시점에 어떤 API에 요청들이 있었는지 확인할 수 있습니다.

Application Insights에서 API별로 처리에 얼마나 오래 걸리는지 확인하고 원인을 찾을 수 있습니다.

그리고 문제가 될법한 API를 선택하면 그 API에 해당하는 요청 샘플을 골라 세부 사항을 확인할 수 있습니다. 여기서 장점은 웹서버(Azure App Service)에서 요청을 받아 다른 종속 된 서비스(아래 화면에서는 Azure SQL Database)를 호출할 때 얼마나 대기시간이 있는지, 그 사이에 시간은 얼마나 걸리는지 확인할 수 있습니다.

이 API에서는 DB 호출이 세 번 일어나는 걸 확인할 수 있습니다. 서버에서 설정을 조금 해주면 쿼리 내용도 확인할 수 있습니다.

Application Insights의 이 기능을 활용해서 서버가 느려지던 시점에 어떤 요청이 가장 많이 시간을 잡아먹었는지 체크해 보았습니다. 확인해보니 앱을 처음 켰을 때 프로모션 스플래시 스크린과 앱의 버전 정보, 그리고 강제 업데이트 필요성 여부 등을 체크하는 초기화 API가 가장 오래 걸렸습니다. 그리고 그 요청의 종속성으로 DB 쿼리가 8번이나 일어나는 것도 확인했습니다. 방금 나열한 프로모션 스플래시 스크린 정보, OS별 앱의 버전 정보, OS별 강제 업데이트 필요성 여부 등의 정보를 매번 DB에 쿼리 해서 가져오는 것이었습니다.

버전 정보, 스플래시 스크린 정보 등 리얼월드 서비스의 설정을 저장하는 테이블은 레코드의 수도 얼마 안 되고 단순히 각 설정을 Key-Value 쌍으로 저장하는 구조였습니다. 하지만 한 요청에서 너무 많이 호출했고, 앱을 켤 때마다 호출되는 API이다 보니 같은 요청이 동시다발적으로 들어와서 병목이 되었던 것입니다.

이 문제를 해결하는 방법은 간단했습니다. 저희는 두 가지 방법을 떠올렸는데요, 하나는 초기화 API에 필요한 설정값들을 한데 모아 JSON 형태로 저장하여 관리하는 방법이었고, 다른 하나는 DB에서 불러온 값을 메모리에 캐시 해두는 것이었습니다. 어차피 설정값이 자주 바뀌는 값은 아니었기 때문이었죠. 그리고 해결책으로 “진리의 둘 다”를 적용했습니다.

초기화 API에 필요한 설정값들을 모아 JSON 형태로 만들었고, 이 JSON 문자열을 서버 메모리 캐시에 한 번, 그리고 Azure Cache for Redis에 한 번 캐시 하도록 조치를 취했습니다. 이 사례만 본다면 굳이 Azure에 호스팅 되는 캐시 DB를 추가로 사용할 필요 없이 원래 사용 중이던 DB의 설정 테이블에 Key-Value 쌍을 추가하면 되는 문제였지만, 앞으로 캐시 할 것들이 많이 생길 것으로 예상하고 또 웹 서버의 인스턴스 수가 늘어나더라도 캐시가 유지 될 수 있도록 별도의 캐시 서버를 두게 되었습니다.

해치웠나?

위와 같은 과거 사례를 떠올리며 병목이 되는 부분을 열심히 찾아보았습니다. 그리고 혹시나 해 캐싱이 가능한 부분에는 전부 캐싱 처리를 해두었습니다. 하지만 그런다고 해결되는 문제가 아니었습니다.

문제의 상황이 되면 특정 API만 느려지는 게 아니라 서버 전체가 느려지는 바람에 API들의 평균 처리 시간이 전부 상승해버렸습니다. 그런데도 Application Insights를 통해 의심이 가는 부분을 계속 찾아냈었고, 하나 의심이 되는 부분을 찾았습니다. 바로 미션 목록을 조회하는 API와 퀘스트 목록을 조회하는 API였습니다.

좌우 스크롤 해서 미션 목록을 볼 수 있습니다.

위 두 API를 포함해서 리얼월드의 많은 API들은 사용자의 플레이 상황에 맞는 화면을 표시해줘야 하므로 각 항목 이외에 각 항목이 사용자에게 어떻게 보여야 할지 판단하는 맥락을 함께 불러와야 합니다. 쉽게 말해, 사용자A가 있고 미션1, 미션2, 미션3이 있다면 미션1이 사용자A에게 어떻게 보여야 하는지에 대한 정보, 미션2가 사용자A에게 어떻게 보여야 하는지에 대한 정보, 미션3이 사용자A에게 어떻게 보여야 하는지에 대한 정보를 DB에 저장해 두고 불러와야 한다는 것입니다. 이걸 저희는 Mission-User Context라고 부르고 있습니다.

저는 여기서 문제가 발생한 거로 생각했었습니다. 왜냐하면 이 API 호출 한 번에 무려 미션의 개수+1번 만큼 쿼리가 일어나고 있었거든요. 네, 흔히 알려진 N+1 쿼리 문제였습니다. 미션마다 미션에 해당하는 Mission-User Context를 불러오기 위한 쿼리들이었습니다. 간단하게 해결될 수 있는 문제였지만 생각보다 간단하게 해결되지는 않았는데요, N+1 쿼리 문제를 해결한 방법에 대해서는 너무 길어질 것 같으니 다른 포스트로 소개해 드릴게요!

그래서 해치웠나?

이걸로 문제를 해결했을까요? 위와 같은 진단과 해결책을 사용했었지만 결국 이 문제는 해결되지 못 했었습니다. 그래서 이번엔 Azure App Service에서 제공해주는 문제 해결 기능을 활용해보려고 했습니다. Azure App Service에서는 문제 진단과 해결을 위한 도구들을 제공해주고 있습니다. 가용성과 성능 도구들로 요청은 실패하지 않고 잘 처리가 되는지, CPU나 메모리 사용량이 어떠한지 등 전반적인 가용성과 성능을 확인할 수 있고 여기서 적절한 조치도 안내해 줍니다.

가용성과 성능을 확인할 수 있는 기능을 제공해줍니다.

그리고 문제를 진단할 수 있게 도와주는 여러 도구도 제공해줍니다. 여기에는 특정 조건(많은 메모리 사용량, 긴 요청 소요 시간 등)이 되면 프로세스를 재시작하는 기능이나 메모리 덤프 등을 확인하는 기능도 제공됩니다.

메모리 덤프 기능이 있습니다.

이 중에 저는 페이스북 Korea Azure User Group에서의 조언을 받아 메모리 덤프를 떠서 확인해보면 어떨까 했었습니다. 그래서 메모리 덤프를 떴지만 제가 여기서 얻어낼 수 있는 정보는 전무했지요. 일단 너무 난해하기도 했고, 어찌어찌 읽어본다고 해도 특별히 이상한 점을 찾기는 어려웠습니다.

그래서 도대체 뭐가 문제지?

이 문제는 정말 “간헐적”으로 발생했기 때문에 재현을 하거나 원인을 찾기가 쉽지 않았습니다. 그러다 다른 팀분께서 특정 게임을 플레이하다가 너무 느려지는 부분이 있다고 해서 그 부분을 집중적으로 파악해 보았습니다. 이상하게 미션 목록을 불러올 때 너무 느리게 불러와 지는 것이었습니다. 그리고 너무 느리다 보니 다시 시도해보려고 뒤로 갔다가 다시 미션 목록을 불러오는 과정을 몇 번 반복했습니다.

그랬더니 문제의 상황이 발생했습니다. 요청 하나만 느려지는 게 아니라 서버 전체적으로 느려지는 현상이었습니다. 결국 미션을 불러오는 데에 문제가 있다고 생각하고 그 부분을 DB에서 직접 쿼리해봤습니다. 정말 가장 단순한, JOIN 하나 없는 SELECT ~ FROM ~ WHERE 쿼리였습니다. SELECT * FROM Missions WHERE ScenarioId = 123 그런데 이것마저도 너무 느렸습니다. 이상해서 미션 하나하나 쿼리해보니 특정 미션 레코드에서 지나치게 느려지는 현상을 찾았습니다.

그리고 그 원인이 되는 컬럼까지 알게 되었습니다. Description 컬럼이었는데, 이 컬럼을 포함해서 불러오면 몇 배나 더 느리게 불러와 지는 것이었습니다. 그 Description의 내용을 보니 미션의 내용을 표시하기 위한 HTML 소스가 있었고, 그 HTML 소스 중에 이미지를 Base64 인코딩한 데이터가 들어가 있는 것을 확인했습니다. 이미지 URL도 아니고 데이터 그 자체가 DB 컬럼에 저장되어 있는 것부터 심각한 문제였는데, 데이터를 Base64 인코딩하여 텍스트로 저장하면 1.3배 정도나 뻥튀기가 되니 불필요한 낭비까지 하고 있었던 겁니다. 그러니까, nvarchar(MAX) 컬럼이라고 정말 아무 데이터나 텍스트로 막 저장해버린 것이죠. 리얼월드 오리지널 제작팀이 게임을 만들 때 미션에 들어가는 이미지를 보통 200kb 내외의 png 파일로 만드는데 이게 1.3배 뻥튀기되어 260kb의 데이터가 DB 컬럼에 저장되는 것이었지요.

SQL Server Database(Azure SQL Database)에서는 nvarchar(MAX) 컬럼에 최대 2GB의 데이터까지 저장할 수 있다고 합니다. 그러니까, 원래 사용하는 데에는 문제가 없는 게 맞습니다. 하지만 정확한 원인은 모르더라도 실제 사용하는 데에는 문제가 있었습니다. 여기서 정말 근본적인 원인, “왜 지원되는 범위 내의 사용인데도 불구하고 정상적인 성능이 나오지 않는가”를 파악하기 위해 더 시간을 쓸 수도 있었을 것입니다. 하지만 그러지 않았습니다.

이 판단에는 두 가지 이유가 있었는데요, 하나는 애초에 이미지 파일이 Base64 인코딩되어 컬럼에 텍스트로 포함되어 있는 상황이 부적절한 상황이라 생각했기 때문이었고, 다른 하나는 이렇게 이미지를 부적절한 방법으로 업로드 하게 만드는 원인인 리얼월드 스튜디오의 사용자가 늘어나고 있는 상황이어서 빠른 대응이 필요했기 때문입니다. Base64 인코딩된 이미지를 컬럼에 넣어 사용하는 건 원하는 기능이 아니었기에 심도 있게 원인을 파악하고 고치는 데에 시간을 쓰기보다는 원래 생각했던 방향대로 작동하도록 구현을 빠르게 고치는 게 맞는 방향이라고 생각했습니다.

이번엔 진짜 해치우자!

이 문제를 근본적으로 해결하기 위해서는 두 가지 작업이 필요했습니다. 하나는 이미 Base64로 인코딩되어 저장된 데이터들을 스토리지 서비스(Azure Blob Storage)에 하나하나 업로드하여 업로드된 URI로 Base64 인코딩된 Data URI 텍스트를 교체하는 작업이었습니다. 이 작업은 전체적으로 한 번만 이뤄지면 되는 일이었고 실시간이 중요한 작업도 아니라서 배치 작업으로 처리했습니다.

배치 작업

일단 데이터베이스에서 Base64 Data URI를 포함하는 것들을 찾아냈습니다. 물론 한 번에 찾은 건 아니고 구간을 나눠서 데이터를 쿼리했지요. 그리고 찾아낸 레코드마다 정규식을 사용해 Data URI의 Base64 인코딩된 데이터를 추출하였습니다. Data URI는 다음과 같은 구조를 가집니다.

data:<MIME>;base64,<DATA>

예를 들면 다음과 같습니다.

data:image/png;base64,iVBORw0K...

여기서 MIMEimage/png타입, 즉 png 이미지라는 걸 알 수 있고요, 데이터는 iVBORw0K로 시작하는 데이터가 있는 걸 알 수 있습니다. 저 데이터 부분을 Base64 디코딩해서 바이너리 데이터 파일로 저장하면 그냥 png 이미지가 되는 것입니다. 간단하게 아래 정규식으로 데이터를 분리해낼 수 있었습니다

/"data:(?<mime>image\\/.+?);base64,(?<data>.+?)"/

어차피 <img src="data:image/png;base64,data..."> 에서 추출하는 것이기 때문에 따옴표까지 정규식에 넣어서 추출했고, 정제된 데이터를 예상하고 추출하는 것이었기 때문에 .+?를 이용해 느슨한 조건으로 추출했습니다.

이렇게 추출한 바이너리 데이터를 굳이 파일로 한 번 더 저장하기보다는 바로 Azure Blob Storage에 업로드 했습니다. 그리고 업로드 하면 생성되는 URI를 추출한 Data URI 자리에 그대로 집어넣어 주었습니다. 이제 HTML 코드가 아래처럼 훨씬 짧아졌습니다.

<!-- AS-IS -->
<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAu8AAAeFCAYAAAAu1CjKAAAA....">
<!-- TO-BE -->
<img src="<https://sample.blob.core.windows.net/container/file.png>">

리얼월드 스튜디오 작업

다른 작업 하나는 리얼월드 게임을 저작하는 도구인 리얼월드 스튜디오에서 이미지를 Base64 Data URI로 업로드 하지 못 하도록 하는 일이었습니다. 리얼월드 스튜디오의 WYSIWYG 편집기는 Quill을 사용하고 있는데, 여기에 툴바를 커스터마이징 하여 이미지 업로드 버튼을 구현해둔 상태였습니다.

그러나 사용자들은 익숙한 방법을 먼저 시도하기 마련이었습니다. 이미지 파일을 드래그 앤 드랍해서 편집기에 삽입하거나 이미지 데이터 자체를 Ctrl + C / Ctrl + V 해서 에디터에 삽입하는 경우가 많았습니다. 사용자들에게 물어보니 툴바의 업로드 버튼을 누르는 것보다 훨씬 편하고 익숙하다고 하더군요. 제가 따로 이미지를 드래그 앤 드랍하거나 붙여넣는 때에 대한 작동을 정의한 적은 없지만, Quill에서 Base64 Data URI로 이미지를 삽입하도록 기본적으로 작동하고 있었습니다. 이게 문제의 원인이었죠.

이를 수정해서 이미지를 드래그 앤 드랍하거나 붙여넣기 했을 때도 업로드 버튼을 눌렀을 때와 마찬가지로 파일이 업로드되도록 하였습니다.

해치웠다!

네, 이렇게 리얼월드 서버가 만들어지던 시절부터 있었던 고질적인, 그러나 발견하기 어려웠던 미제사건을 해치웠습니다! 👏👏👏👏 이렇게 조치를 취한 이후 지금까지 동일한 문제가 발생한 적이 없었습니다! 물론 해결하는 과정 중에 발생했던 부차적인 궁금증이 완전히 해결되지는 않았습니다. DB로부터 비교적 큰 데이터를 받아올 때 어떤 문제가 있기에 CPU 사용량이 급증하는지까지는 파악하지 못 해 아쉽긴 합니다.

그럼에도 이 문제를 해결하면서 Azure에서 제공하는 다양한 분석 도구와 친해질 수 있었고, 해결하는 과정 중에 좋은 부작용으로 N+1 쿼리 문제도 해결하게 되었습니다. 그리고 미션/퀘스트 등에 대한 사용자의 맥락(Mission-User Context 등)을 어떻게 하면 더 효율적으로 저장하고 불러올 수 있을지 새로운 방법을 찾도록 고민하게 만들어주기도 했습니다. 겪을 땐 정말 스트레스를 많이 받았지만 고치고 나니 다 좋은 경험이 된 것 같습니다.

서버가 죽으면(…) 제게 전화가 오도록 설정해뒀습니다. 알려줘서 고마웠고 다시는 전화 하지 말자!

서버의 안정성을 확보하는 것은 어떤 서비스든 중요한 일입니다. 동시에 서비스가 발전함에 따라 서버도 발전해가야 하는 것은 당연합니다. 서버를 안정적으로 유지하면서도 새로운 기능들을 추가하고 새로운 비즈니스 상황에 대응하기 위해 우리 서버 개발자들은 항상 더 나은 모습을 그리고 있습니다. 아직 갈 길이 멀지만 복잡하게 로직이 얽힌 문제를 해결하기 위해 단계적으로 마이크로 서비스 아키텍처로의 전환이나 이벤트 기반 아키텍처로의 전환을 적용하는 것등을 고민 하고 있습니다. 리얼월드의 발전과 서버 개발자들에게 에 많은 관심과 응원 부탁드립니다!

--

--