신입 개발자, DB를 최적화 하다! 2편

Jason Kang
Uniquegood
Published in
8 min readSep 29, 2021

소개

안녕하세요, 유니크굿컴퍼니에서 리얼월드 서버 개발을 하고 있는 개발자 강현우라고 합니다!

1편에서 작성한 ‘최적화’ 문제에 이어서 저희가 직면한 2번째, 그리고 3번째 이슈를 공유하고, 저희가 어떠한 방식으로 해결했는지 공유하려고 합니다!

문제가 무엇인가요?

사용자가 채팅방 목록을 조회할 때, 많은 시간이 소요된다는 문제점을 확인했습니다. 실제로 부하 테스트를 진행한 결과, 분당 100번 조회하는 적은 양의 부하 테스트인데도 불구하고 ‘타임아웃’으로 HTTP 상태 코드 500이 반환되는 현상을 확인했습니다.

이번 목록 조회 API는 웹 서버에서 무언가 크게 작업하는 것이 아니라 대부분 데이터베이스에 의존하는 것으로 확인했습니다. 이러한 부분을 미루어 보아 결론적으로 웹 서버에서 부하를 감당하지 못하는 것이 아니고, 데이터베이스의 부하로 인해 웹 서버에서 타임아웃이 걸리는 것을 확인했습니다.

그렇다면, 기존 쿼리는 어떻게 구성?!

기존 쿼리는 다음과 같이 구성되어 있습니다.

Original Query

1편에서 보았던 쿼리와 달리 크게 복잡하거나 오버헤드가 많이 커 보이지는 않는 쿼리입니다. 실제로 Left Join도 한번 일어나고, 중첩 쿼리는 1번 일어나게 되어 있습니다. 쿼리 실행 계획을 보면 다음과 같습니다.

쿼리 분석기 등장!

예측된 DB 작업 비용은 약 CPU Cost 기준 0.3 정도로, 쿼리 자체가 그렇게 크게 영향을 끼치는 부분은 아니었습니다.

문제점

“작업 비용이 매우 적지만, 왜 분당 100번 정도의 요청도 못 받아들이는가?” 라는 고민을 서버 개발자분들과 계속 고민을 했었는데요, 데이터베이스 전체 행을 조회해 보니, 약 600만 건 정도가 조회되었습니다.

즉 데이터가 생각보다 많이 쌓여 있어 현재보다 더 최적화를 이루어 내야 하는 상황이었습니다. 심지어 사용자들이 해당 기능을 사용할 때면 데이터베이스 사용량이 급격하게 증가하지만 이게 유지가 되면서 떨어지지 않는 현상들을 심심찮게 보았습니다. 문제가 되는 데이터베이스 쿼리를 계속 실행하고 있으면, 다른 정상적인 쿼리들도 느려지거나 실행이 되지 않기 때문에 빠른 해결이 필요한 상황이었습니다. 하지만 해결을 해야 되는 상황에서, 이러한 의문점이 들었습니다. ‘과연 600만 건의 데이터가 데이터베이스 입장에서 많은 건가..?’

분석 과정

현재 쿼리 실행 계획을 살펴보면, 쿼리 비용 중 대부분이 ‘Join’에 이용되는 외래키 검색과 클러스터 된 키의 lookup에 사용된 것을 확인했습니다.

클러스터형 인덱스

방금 분석 과정에서 클러스터 된 키의 lookup에 사용되었다고 언급했는데요, 클러스터형 인덱스는 다음과 같은 특징을 가지고 있습니다.

  • 테이블 당 단 ‘하나의 인덱스’만 지정할 수 있음.
  • PK가 설정된 경우, 그 열은 자동으로 클러스터형 인덱스가 생성됨.
  • 데이터가 물리적으로 정렬된 형태
  • 즉 인덱스에 대한 데이터가 실제로 존재하는 형태이며
  • 데이터가 물리적으로 항상 정렬되어 있기 때문에, Insert/Update/Delete 등 데이터가 변경되는 작업이 비클러스터형 인덱스보다는 상대적으로 큽니다.
  • 다만, 인덱스에 대해서 정렬도 되어 있고, 실제 데이터(물리적 데이터)가 있는 형태이기 때문에 검색은 비클러스터형 인덱스보다는 상대적으로 빠릅니다.

비 클러스터형 인덱스

비 클러스터형 인덱스는 다음과 같은 특징을 가지고 있습니다.

  • 테이블 당 여러 개의 인덱스를 만들 수 있습니다.
  • 물리적인 데이터가 직접 매핑 되어 있는 것이 아닌, 데이터를 가리키는 ‘포인터’가 매핑 되어있습니다.
  • 즉, 인덱스에 대해서 실제 데이터가 정렬된 형태가 아닙니다.
  • Insert/Update/Delete 등 데이터가 변경되는 작업 비용이 상대적으로 클러스터형 인덱스보다 적습니다.
  • 다만, 실제 데이터가 매핑 되어 있는 형태가 아니기 때문에 검색 비용은 상대적으로 클러스터형 인덱스보다 큽니다.
  • 하지만 인덱스가 적용되어 있지 않은 곳보다는 몇 배는 빠르겠죠!

추가적으로 MSSQL에서는 미리 정의한 테이블 열에 대해서 클러스터형 인덱스처럼 데이터를 물리적으로 저장하고, 가지고 있을 수 있습니다.

해당 내용을 ‘포괄열 인덱스’ 라고 하는데, 이에 대한 자세한 내용은 여기를 참고해 주세요!

원인과 첫 번째 해결 방안

이전의 데이터베이스 구조에서는 이미 비클러스터형 인덱스가 걸려 있었고, 여타 다른 데이터베이스처럼 키에 대한 데이터 포인터가 저장되어 있어 포인터를 따라가 실제 데이터를 찾아가야 하는 형식입니다. 그러나 이러한 비 클러스터형 인덱스에 대한 실제 값을 찾아가는 과정에서 생각보다 높은 비용과 지연시간이 발생하였고, 근본적으로 저희는 해당 문제를 해결해야 했습니다.

위 ‘비 클러스터형 인덱스’ 부분에서 잠깐 언급한 내용이 핵심이었습니다. MSSQL 특징으로 비클러스터형 인덱스에서 함께 저장할 데이터(열)를 정의하고, 저장할 수 있습니다. 다시 말해서 다른 데이터베이스는 비 클러스터형 인덱스가 걸려 있는 열을 쿼리하면 포인터를 따라가 데이터를 ‘찾는’ 과정이 필요합니다. 하지만 MSSQL에서는 저장할 데이터 열만 잘 정의하는 경우 같은 쿼리를 실행했을 때, 인덱스 내에 저장된 데이터를 사용하면 되기 때문에 데이터를 찾는 과정이 없습니다. 그래서 이전에 비 클러스터링 인덱스를 설정했을 때보다 더더욱 빠른 성능 향상을 노려볼 수 있었습니다.

따라서 저희는 비클러스터형 인덱스를 생성하되, 특정 열들에 대한 데이터를 같이 저장할 수 있도록 다음과 같이 변경했습니다.

비 클러스터형 인덱스와 데이터 열을 정의하는 방법
위 EFCore 코드가 쿼리로 변환됩니다!

두 번째 해결 방안 - 쿼리 분리(조인 제거)

대부분의 채팅 방에는 2인의 인원이 존재하므로, Join을 제거하고, 채팅방 참여자 정보를 구하는 쿼리를 따로 분리하는 작업을 진행했습니다.

분리된 쿼리

즉, 메시지 리스트를 구해오고, 각 채팅방마다 채팅방 참여자 정보를 불러오는 방식으로 쿼리를 분리하였습니다. 50개의 데이터마다 채팅방 참여자 정보를 불러오지만, EFCore와 MSSQL 데이터베이스가 알아서 캐싱하기에 크게 문제가 되지 않는 부분을 확인했으나, 다음에는 우리가 직접 조절할 수 있는 캐시를 도입하는 것을 고민하고 있습니다.

과연 결과는?!

두 가지 해결 방안을 적용하고 나서 다시 한번 부하 테스트를 진행해 보았습니다. 이 전에는 같은 조건에 분당 100회의 요청도 버티지 못했지만, 이번 문제들을 해결하고 나서 최소 분당 3,000회 이상, 평균 약 6,000회, 최대(피크) 9,000회까지 버티는 것을 확인했습니다. 최소 30배, 최대 90배까지 성능이 향상된 것이죠!

추가적으로 쿼리 분석기를 다시 써 보니..!

Tada! 훨씬 깔끔해졌습니다!

마지막 문제, 그리고 가장 걱정 되었던 문제.

위 문제 부분을 모두 정의하고, 해결에 완료해서 테스트 서버[혹은 샌드박스 환경]에서 모두 테스트를 완료했지만, ‘프로덕션’ 서버에 배포하는 것에는 문제가 있었습니다.

600만 데이터, 그리고 마이그레이션

흔히 EF Core에서 마이그레이션이란, 데이터베이스의 구조[열, 인덱스, 키 등]이 바뀐 경우, 그 구조를 데이터 유지하면서 업데이트 할 수 있는 편리한 기능입니다. 그리고 업데이트 작업을 저희가 코드를 직접 짜서 진행하는 것이 아니라, ef-core 툴과 Azure SQL 기능의 도움으로 안전하고 온라인으로 업데이트할 수 있습니다.

단, 이번 업데이트는 새로 인덱스를 생성하는 것인데, 인덱스를 생성하는 동안 인덱싱에 영향을 받는 테이블은 조회뿐만 아니라 모든 작업이 불가능하다는 것을 확인했습니다. 실제로 테스트 서버와 실험용 데이터베이스에서 600만개의 데이터와 함께 새로운 인덱스를 만들어 보니, 약 4분 이상이 소요되었습니다. 불행 중 다행은, 데이터베이스가 업데이트되는 상태에서도 인덱싱되는 테이블 이외에 다른 테이블의 모든 기능은 정상적으로 작동하는 것이었습니다.

즉, 인덱싱되는 기능들에 대해서는 ‘다운타임’이 생기는 문제가 발생했고, 이러한 다운타임을 최소화하거나 없애는 방법은 없을지 다시 고민해 보아야 했습니다.

해결책

종일 고민을 해 보았지만, 리얼월드 서비스 인프라의 현재 구조 상 다운타임을 아예 없앨 수는 없었습니다. 인덱스를 만들면서 부하가 많이 걸리고, 사용이 불가능한 건 필연적이었습니다. 따라서 이러한 다운타임을 최소한으로 줄이는 것을 목표로 해서 실험을 한번 진행해 보았습니다. 실험용 데이터베이스의 성능을 Azure에서 제공해줄 수 있는 최대치로 끌어올리고 인덱싱 작업을 진행 해 보았습니다. DTU 기준으로 200 DTU에서 3000DTU로 끌어올렸으며, 이는 기존 대비 약 15배에 해당합니다.(가격도 덩달아 오릅니다! ㅎㅎ..)

3000 DTU를 가지고 있는 상태에서 인덱싱을 진행하니, 마이그레이션 작업은 약 1분 50초 내로 완료되었고, 그 와중에 다른 테이블은 모두 쿼리가 작동하는 것을 확인했습니다.

다운타임을 기존 4분에서 1분 50초로 줄인 것도 많이 줄였다고 생각했지만, 그래도 사용 빈도가 낮지 않은 기능들이었기 때문에, 사람들이 사용하지 않는 시간대로 작업을 해야 하는 것도 매우 중요했습니다. 저희 통계를 살펴보니, 업무 시간 중에서는 출근 직후가 가장 적합한 시간이라고 판단되어서 업데이트하는 시간도 거의 출근 하자마자 업데이트하는 것으로 결정했습니다.

따라서 최종적으로 실 서버에 마이그레이션을 적용하기 이전에, 데이터베이스 성능을 최대치로 끌어올리고, 채팅 메시지 기능을 많이 사용하지 않는 시간대로 작업을 진행해서 무사히 작업을 마칠 수 있었습니다!

마치며

이렇게 저희 리얼월드 서버에서 발생한 급한 성능 문제 2가지를 해결했습니다! 앞으로 개발을 진행하다가 성능을 최적화할 수 있는 부분을 찾을 수 있다면 그 문제를 해결하고, 최적화 ‘n’편으로 다시 찾아뵙겠습니다!

--

--

Jason Kang
Uniquegood

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