디지털서비스 이슈리포트 2024-08호
이 글은 제가 NIA [한국지능정보사회진흥원]의 < 디지털서비스 이슈리포트 > 2024년 8월호에 기고한 글입니다. 원본 글 '클라우드 서비스 개발에서 데이터베이스 사용 최적화 하기'를 이곳 브런치에서도 공유합니다.
클라우드 서비스들을 이용해서 서비스를 만들게 되는 경우, 실제 사용자를 대상으로 서비스를 시작하게 되면 여러 가지 이슈가 발생하게 된다. 데모를 운영하고 있던 서비스에 동시에 여러 명의 사용자들이 예기치 않게 접속하게 되고, 각종 기능을 추가 혹은 변경하는 등의 과정을 겪을 때 원하는 기능이 구현되었음에도 여러 가지 예상하지 못했던 일이 일어나거나 보이지 않는 실수로 인한 부작용이 일어나고 한다.
이번 글에서는 게시판 류의 서비스를 만들어서 운영할 때 흔히 일어나는 실수에 대해서 살펴 보고, 어떤 점들을 고려하면서 풀어 나갈 것인지에 대해 정리해 보도록 하겠다. 데이터베이스를 사용함에 있어 SQL 이냐 NoSQL 이냐 혹은 어떤 제품을 쓰느냐는 논의의 대상이 아니고, 어떤 백엔드 프레임워크 혹은 어떤 컴퓨터 언어를 선택하는지도 여기서는 논의의 대상이 아니며, 데이터베이스를 접속해서 쓰는 과정을 SQL 명령 예제를 이용해서 이야기하도록 한다.
먼저 그림 1의 예제처럼 게시판의 간단한 목록 화면을 나타내는 예를 들어 본다. 기사가 담긴 article 테이블과 저자 정보가 담겨 있는 author 테이블이 있고, 기사 목록을 나타내는 화면은 아래와 같은 구문으로 구성할 수 있다.
이 때 총 사용자 수와 총 기사 수를 같이 표시하도록 화면이 추가 변경되는 경우 그 값을 구하기 위해 아래 그림 2. 와 같이 SELECT count 구문을 사용해서 구현할 수 있는데, 이 경우 매 화면마다 총 사용자 수를 구하기 위해 데이터베이스를 추가적으로 스캔해야 하기에 느려지는 주요한 요인이 된다.
전체 테이블을 스캔해서 얻을 수 있는 값의 경우 시간 복잡도 상에 O(N) 의 연산을 해야 하기에 데이터의 양이 많아질 수록 속도의 저하에 민감하게 된다. 이 경우 기사 목록을 얻기 위해서 한 번 스캔하는 일은 피할 수 없으나 아래 추가된 두 값을 얻기 위해서 도합 세 번의 스캔이 일어나는 것은 과한 연산이라 하겠다. 가장 정확한 값을 유지하기 위해서는 사용자가 원하는 시점에 매번 스캔하는 게 맞는 방법 중 하나이겠지만, 초기에는 별 문제가 없다가 데이터가 많아지는 경우 성능 저하의 원인으로 발견되는 경우가 종종 있게 된다.
실제 제품의 상황에서는 추가된 화면 구성 요소를 얼마나 정확하게 유지할 것인지 등에 대한 고민 등 훨씬 더 많은 고려사항들이 있겠지만, 이를 해결하는 방법으로 가장 직관적인 방법은 총 사용자 수와 총 기사 수 등은 다른 테이블에 만드는 것이다. 서버의 캐쉬나 메모리에 놓고 값을 유지하는 것도 좋은 방법이다. 매 번 사용자나 기사가 생성 혹은 삭제 시 최신 상태로 업데이트 되어야 하기에, 추가적인 코드들이 필요하게 되고, 이 때 추가되는 코드는 데이터베이스의 트랜잭션 혹은 원자적 갱신(atomic update)을 지원하고, 보장해야 한다.
앞에 예를 든 테이블들을 이용해서 작가 화면을 구성해 보도록 한다. 특정 작가에 대한 정보를 author 테이블에서 구하고, 이 작가의 이름이 “Jack” 이라고 했을 때 이 작가가 쓴 글들을 아래 그림 3.과 같이 모을 수 있다.
이 경우 article 테이블을 스캔하면서 작가 이름이 “Jack” 인 지를 검사해야 하므로 모든 기사를 열람하는 일이 일어난다. 해당 조건을 만족하는 모든 기사를 다 추려내기 위해서 필요한 스캔이지만, 데이터의 양이 많아지는 경우 불필요한 스캔이 이루어지고, 모든 기사를 훑어야 하기에 성능의 저하가 일어나게 된다. 이 역시 개발 초기에는 잘 나타나지 않다가 데이터가 많아지는 경우 급격한 성능 저하의 요인으로 나타나게 된다.
검색에서 조건으로 추려내는 기능을 적용하기 위해서는 인덱스 혹은 키를 추가하는 것이 필요하다. 이는 모든 아이템들을 훑는 O(N) 시간 복잡도를 O(log N)으로 탐색 혹은 검색을 할 수 있게 한다. AWS 오로라(Aurora) 같은 관계형 데이터베이스는 하나의 필드가 인덱스로 설정이 되면, 그 필드를 위한 비트리(B tree)혹은 비플러스트리(B+ tree)가 추가되며 검색을 지원하게 된다. 이 예제의 경우 아래 그림 4. 처럼 저자의 id 를 기사 테이블에 추가하고 이 값을 프라이머리 인덱스로 동작하게 수정하면 성능이 향상되고, 잠재적인 동명이인의 문제도 풀릴 수 있다.
SELECT 혹은 UPDATE 구문으로 사용할 때 WHERE 안에 들어오는 필드들은 가급적 인덱스나 키로 설정해 활용하기를 기대한다. 게시판의 경우 시간 정보나 저자의 아이디 등이 좋은 대상이 되며, 인덱스가 여러 개 있을 때는 앞에서부터 대상 항목들을 줄여 나가면서 적용이 되기에 이와 같은 방식으로 구문들이 사용되는 경우 향상의 폭이 클 수 있거나 반대로 인덱스를 추가해도 부가적인 향상이 적게 되기도 하기에, 실제 적용 전후를 비교하면서 판단해야 하겠다.
SELECT 문을 이용해서 열람 혹은 분석을 할 때, 테이블을 고정 시켜 놓고 분석하는 환경에서는 테이블들을 조인(JOIN)해서 원하는 분석들을 자유롭게 하게 되고, 이는 분석계에서 여러 개의 테이블들을 자유자재로 연결하고 싶을 때 효용이 높다. 이 방법을 이용해서 아래 그림 5. 는 변형된 작가 화면의 예시로 기사 라인 별로 작가의 이름을 찾아서 표시하는 예제를 구현한 것이고, 이 경우 JOIN 은 이용해서 아래와 같이 구현할 수 있다.
구문 상으로 문제가 없고, 정상적인 동작을 하지만, 사용자 수가 많아질 경우 두 테이블을 매번 합쳐서 연산을 해야 하기에 데이터가 많아지는 경우 INNER JOIN 이 포함된 구문에서 문제가 생기는 경우가 생길 수 있다. 대용량 데이터를 분석하는 경우의 분석계에서는 JOIN 이 유용한데 반해, 실제 운영계에서 사용자가 서비스의 반응이 빠르게 되기를 기대하는 환경에서 여러 테이블을 SELECT 조회 구문에서 연결해서 쓰는 것은 WHERE 구문의 사용 방법에 따라서 성능 하락이 있을 수 있다.
이 예제의 경우는 매 번 이름을 구하는 대신 작가 테이블을 열람하는 추가적인 SELECT 문을 도입하는 방법으로 성능 향상이 일어날 수 있다. 두 개 혹은 여러 개의 테이블을 붙여서 필요한 내용을 찾는 경우에 복잡한 SELECT 열람을 여러 개로 나누어 성능 향상을 도모하는 방법으로 이는 A/B 테스트 등의 방법으로 실제 비교해 보면서 나은 방법을 선택해야 하겠다.
데이터베이스를 사용할 때 SELECT 구문을 이용한 열람은 읽기 위주로 동작하고, 나머지 삽입, 수정, 삭제 등은 쓰기 연산이 동반된다. 분석만을 위한 분석계에서는 읽기만 고려하면 되지만, 실제 운영계의 경우 여러 가지 일들이 일어나는데 제품의 특성을 고려해서 준비해야 한다. 예를 들어 쇼핑몰에 갑자기 수십만의 사용자가 몰리게 되는 경우, 상품 열람은 제품 테이블을 읽기, 장바구니는 사용자 테이블에 쓰기, 제품 입고는 제품 테이블에 쓰기 등의 특성이 있을 것이고, 피크타임에 입고를 피한다든지 하는 방식으로 쓰기와 읽기를 분리하는 전략을 구현하기도 한다.
사용량이 많아지는 대용량 서비스를 고려할 때 많이 쓰는 개념이 데이터베이스 읽기와 쓰기 분리이다. 읽기는 동시에 여러 곳에서 일어나도 문제가 없지만, 동시에 여러 곳에서 쓰기가 일어나게 되면 데이터 정합성에 문제가 생기게 되는데, 이는 데이터베이스를 사용하는 경우 오래전부터 있어 왔던 이슈이기도 하다. 이를 대비하기 위해 클라우드에서 동작하는 모든 데이터베이스는 하나의 쓰기 전용 엔드포인트와 여러 개의 읽기 엔드포인트를 지원하고, 병목이 되는 테이블은 쓰기 작업을 직렬화 시키도록 한다. 사용자들이 많아지면서 만나는 가장 흔한 사고는, 읽기가 늘어 데이터베이스의 사용량이 한계가 오고, 타임아웃이 일어나면서 필요한 쓰기 작업이 되지 않는 일들인데, 스케일업, 스케일아웃 등의 방법으로 대응은 할 수 있도록 한다. 읽기는 추가적인 리소스들로 대응할 수 있지만, 쓰기를 지원하는 엔드포인트에서는 불필요한 읽기 기능이 없는지, 쓰기는 최적화 되어 있는지 등을 계속 체크해야 한다.
쓰기 연산의 경우 비용은 삭제(Delete)가 월등히 갱신(Update) 보다 높고, 삽입(Insert)가 가장 비용이 싸고, 실제 대용량 시스템에서 사용하는 경우 아래의 팁들을 추천한다.
삽입의 경우 벌크(BULK) 연산을 이용해서 가급적 여러 개의 정보를 한 데 묶어서 저장한다. 공용 데이터베이스 자원을 할당 받아 쓰는 개념이기에 한 번에 몰아서 하면 좋다.
WHERE 로 조건을 걸 때 인덱스가 적용되는지 확인한다.
수정과 삭제는 커서(cursor) 개념을 이용해서 1000개 정도씩 나누어 적용한다. WHERE 절에서 in 조건을 적용하면 벌크 업데이트의 효과를 기대할 수 있다.
삭제 연산이 있을 때 실제 데이터를 지우지 않고, Is_deleted 등의 필드를 이용해서 ‘지워진 효과'를 구현한다. 이후 점검시간이나 오프라인 작업으로 이를 지원한다.
앞에서 예제대로 쓰기와 읽기를 구분해서 운용하게 되면 필연적으로 일관성 체크에 문제가 생기게 된다. 정확한 정보가 저장되어 있는 쓰기 전용 엔드포인트의 내용이 읽기 전용 엔드포인트까지 동기화가 되지 않은 상태로 데이터베이스마다 물리적인 제약도 있고, 실제로는 보통 수초 정도의 차이가 있게 되어 어떨 때는 되고, 어떨 때는 되지 않는 일관성 문제로 정의되기도 한다. 이 때 아주 중요한 읽기 연산의 경우라면 쓰기 전용 엔드포인트를 잠시 빌려 쓰는 식으로 구현하기도 한다.
클라우드에서 시스템으로 이 문제를 해결하려 하는 방법으로는 캐시 서비스를 서비스와 데이터베이스 사이에 놓아 이 차이를 방지하는 방식으로 구현할 수 있다. 쓰기 연산은 캐시와 쓰기 데이터베이스 엔드포인트에 동시에 수행하고, 읽기 연산은 캐쉬에서 읽으며, 캐시 내에 데이터가 없을 때는 읽기 데이터베이스 엔드포인트에서 채우는 방식으로 구현한다.
대표적인 캐시 솔루션으로 레디스(Redis) 나 메모리캐시(MemCached), 엘라스틱캐시(ElasticCache) 등이 있으며, 실제 운영할 때 시스템의 메모리 사용량이나 적중률(hit ratio) 등을 꾸준히 모니터링해야 한다. 이 캐시 솔루션들은 실제 매우 비싸지만, 데이터베이스의 일관성을 지원하는 기능 이외에 다른 용도로 이미 많이 쓰이고 있을 가능성이 크기에, 자원을 공유하는 방식으로 이용된다.
서비스를 운영하는 과정에서 여러 가지 오프라인 서비스들이 필요한 경우들이 있다. 예를 들면 운영계의 데이터를 주기적으로 분석계로 보내는 일이 있는데, 분석계가 어떻게 연결되어 있느냐에 따라 데이터베이스의 어느 순간 스냅샷을 어떻게 전달할 지 등을 여러 가지 방법으로 운용한다. 대개 하루에 한 번 혹은 한 시간에 한 번 어떤 일들을 하게 되고, 비슷한 방식으로 크론잡(cronjob)의 형태로 여러 가지 일들을 수행할 수도 있다. 예를 들면 오래된 데이터를 법령에 따라 주기적으로 삭제해야 할 수도 있고, 짜임새 있는 자원 관리를 위한 경우도, 의무적으로 점검을 가져야 하는 경우도 있다.
그리고 비정기적으로 한 번씩 필요한 일들이 있을 때 원오프 작업이라고 부르는 고객응대에 해당하는 작업들이 필요하기도 하다. 사고 수습 혹은 그의 복구하는 과정이 있을 수도 있고, 클라우드에서 제공하는 유료 서비스를 쓰더라도 데이터베이스의 버전을 업그레이드 한다든지 하는 방식으로 규모있는 이전 작업도 계획하곤 한다. 이 두 경우 어느 경우든 기존에 동작하고 있는 데이터베이스에 주는 영향을 고려해서 진행해야 한다.
미처 권한이 정비되지 않은 회사들에서 가장 흔히 일어나는 실수 중의 하나가 누군가가 운영계 데이터베이스를 스캔하고 그것 때문에 데이터베이스의 CPU 가 소모되는 경우이다. 예를 들면 총 사용자의 숫자가 갑자기 궁금하게 된다면 테이블을 전부 스캔해야 하지만, 분석계가 구축이 되어 있다면 운영계 테이블에 읽기 부하를 주지 않고 구현할 수 있을 것이다.
그리고 데이터베이스의 종류에 따라서는 사용량에 따른 과금이 있는 경우 조금 더 주의가 필요하다. 스캔이 동반된 읽기 연산은 CPU 사용량의 증가를 가지고 오게 되기도 하고, SELECT 구문에서 전체 레코드를 가지고 와서 분석을 하면 필요한 필드만 가지고 오는 것에 비해 불필요한 네트워크 비용을 지불하게 되기도 한다. 데이터베이스와 파일 시스템 간의 상관 관계로 복잡하게 되는 경우 계산이 더 복잡해 지기도 하고, 약간의 실수가 원치 않는 비용을 만들어 내기도 하기에 많은 주의가 필요하다. 특히 SELECT 구문을 쓸 때 꼭 LIMIT 을 써서 불필요한 것을 줄이는 습관도 많은 도움이 된다.
실제 서비스를 만들어 운영해 온 경우 사용자가 몰리기 전까지는 별 이슈가 없었을 가능성이 많고, 실제로 개발 상의 속도를 얻기 위해 위에서 권하지 않은 스캔을 이용한 조회 등의 방법들을 이용해서 구현해 왔을 수도 있다. 이는 별다른 조치를 취하지 않는 경우 기술 부채라는 이름으로 알게 모르게 계속 빚의 형태로 운영될 것이고, 클라우드를 이용해서 스케일의 장점을 얻는 과정에서 사용자의 불편함 혹은 불필요한 비용의 형태로 돌아오게 된다.
다만 이 단계까지 오게 되는 경우면 이미 많은 사용자들이 사용하고 있다는 증거이기도 하기에 먼저 작은 축하의 마음을 건네고, 이후 다양한 도구들을 이용한 꾸준한 모니터링과 운영으로 다음 단계를 준비하시기를 바란다. 마지막으로 언급한 꾸준한 모니터링 시 유용하게 쓰이게 될 슬로우쿼리 분석기들의 예제들을 아래에 소개한다.
https://www.eversql.com/optimize-slow-queries-aws-performance-insights-eversql/
https://www.datadoghq.com/blog/database-performance-monitoring-datadog/