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

Jason Kang
Uniquegood
Published in
7 min readSep 13, 2021

소개

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

최근 들어, 저희 리얼월드 앱의 ‘크라임씬 시리즈’가 오픈됨에 따라서 리얼월드의 사용자가 상당수 많이 늘어난 것을 확인했습니다. 분명 사용자가 많이 늘어난 것은 회사도, 개발자도 모두 좋아해야되는 일지만 그만큼 더더욱 책임감을 가지고 서비스를 영향 없이 끌어나가야 된다는 의미로 통하기도 합니다.
그러나, 최근 사용자가 많이 늘어나면서 서버에 트래픽이 몰리고 있고, 많은 트래픽이 특정 기능에 집중되는 경향이 있어 성능 저하나 타임아웃 등의 문제가 발생하고 있습니다.

Azure 클라우드에서 작동하는 서버의 인스턴스를 충분히 늘려서(Scale-Out) 해결하는 방법도 있겠지만, 비용적으로 비효율적이기도 하고 특히 데이터베이스와 연결되는 부분은 웹 서버를 늘려서 해결되는 문제가 아닙니다. 물론 데이터베이스의 처리량, 즉 DTU를 늘리는 수직 스케일링(Scale-Up)을 해서 이 문제를 해결할 수도 있겠지만 수직 스케일링을 하면서 발생되는 비용도 만만치 않다는 단점이 있습니다.

따라서, 서버에서 부하가 많이 걸리는 API가 어디인지, 그리고 실제로 사용되는 데이터베이스 쿼리는 무엇인지 이 두 부분에 대해서 조사를 진행 했습니다. 그 결과로 2개의 API와 쿼리문이 많은 부하를 감당하고 있음을 확인 했습니다. 이 글에서는 문제점 2개 중 하나의 문제점을 분석하고, 해결하는 과정을 설명 드리려고 합니다! (나머지 문제점은 이 글 다음편에 이어질 예정입니다.)

문제가 무엇인가요?

리얼월드에는 사용자가 챗봇에게 메시지를 보내는 ‘엑션’을 취했을 때, 그에 대한 ‘리엑션’으로 챗봇이 해당 메시지를 인식하고 그에 맞는 답장을 사용자에게 보내는 기능이 있습니다. 해당 리엑션을 실행하려면 챗봇이 사용자에게 메시지를 보내기 위해서 챗봇과 사용자 단 두명이 1:1로 있는 방을 조회 해야 되는 상황이 발생합니다. 그러나, 챗봇이 사용자에게 채팅을 전송하기 위해서 ‘챗봇과 사용자 단 두명만 있는 채팅 방을 조회하는’ 쿼리가 컴퓨팅 자원을 너무 많이 소모하는 것을 확인했습니다.

빨간 막대기가 문제의 쿼리…

참여자 정보

먼저 기존 구조를 설명하기 전에, 핵심이 되는 개념인 ‘참여자 정보’에 대해서 설명할 필요가 있을 것 같습니다.

참여자 정보란, 어떤 채팅 방에 누가 참여했는지를 담고 있는 N:N 관계에서의 연결 테이블(Join Table) 입니다. 리얼월드에서는 채팅방에 참석할 수 있는 주체가 사용자와 챗봇 두 분류가 있으나 챗봇인 경우 챗봇 ID를, 사용자일 경우에는 사용자 ID를 담아 구분하고 있습니다.

현재 서비스에서는 채팅방에 챗봇과 사용자가 한명씩 접속할 수 있는 1:1 구조를 이루고 있기 때문에, 채팅방에는 총 2개의 참여자 정보를 가지고 있게 구성되어 있습니다.

기존 구조

앞서 참여자 정보는 ‘사용자 ID와 챗봇의 ID’를 각각 하나씩 가지고 있어 채팅방에는 총 2개의 참여자 정보를 가지고 있다고 언급한 바 있습니다.
따라서, 기존에는 채팅방을 찾기 위해서 다음과 같은 필터를 걸어 채팅방 정보를 찾았습니다.

  • 채팅방에 있는 참여자 정보가 총 2개이다.
  • 참여자 정보가 나타내는 유저의 ID가 현재 요청의 유저이다.
  • 사전에 정의되어 있는 챗봇 ID가 존재한다.

기존 구조의 한계점

앞서 해당 필터를 하나의 쿼리로 몰아넣으면 상당히 길어진다는 것을 짐작할 수 있습니다. 실제로 위 ‘기존 구조’에 나와있는 부분을 모두 반영해서 EF Core 쿼리로 작성하면 다음과 같은 MSSQL 쿼리가 생성됩니다.

EF Core로 작성된 코드
EF Core로 작성된 코드가 MS-SQL 쿼리문으로 변환 되었을 때…

중첩 쿼리(Nested Query)와, Left Join까지 붙어있는 복잡한 쿼리입니다.

아래 사진은 위 쿼리를 실행했을 때, 실행 계획을 나타내주는 그림인데요, 그림이 작지만, 작게 봐도 많이 복잡하게 생겼습니다.

참고 사항으로, 채팅 방은 현재 약 20만개 정도가 존재하며, 채팅방의 참여자는 약 40만명 정도 되고 있습니다.

DB 작업이 복잡하고, 심지어 예상도에서 계산되는 비용이 ‘6’이나 되는 상태에, 채팅방과 채팅방 참여자가 많으면… 많은 로딩시간을 필요로 할 것이고, 실제로도 그러는 것을 확인한 상태입니다.

해결 방안!

저희는 2가지 해결 방안을 고민해 보았는데요, 첫 번째는 ‘쿼리 분리 및 쿼리 최적화’ 이고, 두번째는 이에 덧붙여서 ‘캐싱 적용’이었습니다.

해결 방안 — 쿼리 분리 및 쿼리 최적화

해결 이전의 쿼리에서는 모든 내용들을 단 ‘하나의’ 쿼리로 적용했었는데요, 하나의 쿼리가 아닌 문제를 쪼개서 생각해 보기로 했습니다. 즉, 쿼리를 여러개로 분리하였다는 의미가 됩니다.

다음은 저희가 분리한 로직입니다.

  • 채팅방 참여자 테이블에서 챗봇이 포함되어 있거나, 요청한 유저가 포함되어 있는 것 들을 채팅방 정보를 기준으로 Grouping하고, 그 그룹 항목의 갯수가 2인 것의 룸 정보를 가지고 옵니다.
  • 채팅방 정보와 챗봇의 아이디를 이용해서 봇이 참여하고 있는 ‘채팅방 참여자 정보’를 가져옵니다.

해당 로직을 EF Core 쿼리문으로 변경하고, 이를 MSSQL 쿼리로 변환한 결과, 다음과 같은 2개의 쿼리로 나뉜 것을 확인했습니다.

EF Core 코드
EF Core 코드가 MS-SQL 쿼리로 변경 되었을 때

위와 같이, 쿼리가 짧게 2개로 변경 되었고, 이 변경사항으로 쿼리 실행 계획은 다음과 같이 변경되었습니다.

전에는 예상 DB 작업 비용이 6이었지만, 총 작업 비용이 2로 감소했으며 이는 기존 대비 약 30% 정도의 수준입니다!

해결 방안 — 캐싱!

쿼리 최적화로 기존 비용 기준 1/3 수준까지 내린 것을 확인 했지만, 성능 수치가 아직도 2를 찍고 있는 것은 여전히 작지 않은 값입니다. 그리고 데이터베이스는 여전히 ‘데이터베이스 서버’에 호출합니다. 서버와 서버간 통신하는 Latency, 그리고 DB I/O Wait 등 성능이 아직 만족할 만한 수준은 아니었습니다.

‘쿼리를 애초에 사용하지 않으면 어떨까?’ 라는 질문에서 캐싱에 대한 논의가 시작 되었습니다. 즉, 데이터베이스를 아예 거치지 않고 바로 가져올 수 있다면, 훨씬 빠른 응답 속도를 낼 수 있다는 결론에 도달했습니다. 특히 저희는 1차적으로 로컬 메모리 캐시를 이용하고, 2차적으로 원격 Redis 캐시를 이용하고 있기 때문에 메모리 안에 있는 캐시에 hit이 된다면 데이터베이스를 불러오는 것 보다 훨씬 많은 성능 이득을 볼 수 있습니다. 나열하면 Memory Cache Lookup → Redis Cache Lookup → Database Lookup 순으로 구성되는 것인데, 메모리 캐시나 Redis Cache에서 Cache Hit이 발생된다면 데이터베이스에 매번 쿼리 하는 것 보다 성능 이점을 끌어올릴 수 있었습니다.

쿼리를 분리하다 보니, ‘챗봇의 참여자 정보 ID는 유저 정보와 챗봇 정보 쌍에 대해서 단 하나만 존재’한다는 사실을 알아 내었습니다. 다시 말해서 사용자 정보와 챗봇 정보 쌍을 기준으로 ‘챗봇의 참여자 정보 ID’를 유일하게 판별할 수 있는 것입니다. 따라서 캐시 접근 키를 ‘사용자 정보, 챗봇 정보’ 쌍으로 구성하고, 이를 통해서 ‘챗봇의 참여자 정보 ID’를 가져올 수 있게 캐시를 구성하였습니다

이로써 데이터베이스의 쿼리를 줄여서 성능 향상을 한번 이끌어 내고, 캐싱을 이용해 데이터베이스 쿼리를 실행하는 경우의 수를 줄임으로써 큰 성능 향상을 이끌어낼 수 있었습니다.

2편..?

다음 문제도 역시 DB 쿼리의 퍼포먼스로 인해서 문제가 발생 했는데요, 이 부분은 2편으로 다시 돌아와서 작성하려고 합니다.

감사합니다!

--

--

Jason Kang
Uniquegood

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