데이터베이스는 데이터가 많아지면 왜 느려질까?

데이터베이스가 느려지는 이유를 인덱스, 연결 풀, 트랜잭션과 잠금의 원리로 나누어 쉽게 설명한다.

대용량 트래픽 시리즈 3/7

서비스는 어떻게 100만 사용자를 버틸까? — 대용량 트래픽과 데이터베이스를 공부하며 이해한 것들

1편 — 100만 사용자가 정말 동시에 접속할까? · 2편 — 사용자가 몰리면 서버는 어디부터 느려질까?

책이 100만 권 있는 도서관에서 제목 하나만 알고 특정 책을 찾아야 한다고 생각해 보겠습니다. 책이 100권이라면 첫 번째 책장부터 차례로 살펴보는 방법도 가능하지만, 100만 권이라면 하루 안에 끝내기 어렵습니다.

그래서 도서관에는 제목과 책의 위치를 연결한 검색 목록이 있습니다. 목록에서 위치를 먼저 찾은 뒤 해당 책장으로 가면 모든 책을 확인하지 않아도 됩니다.

데이터베이스에도 이 검색 목록과 비슷한 Index(인덱스 또는 색인)가 있습니다. 그렇다면 데이터가 많아지면 데이터베이스는 반드시 느려질까요?

답은 반드시 그렇지는 않다입니다. 데이터의 총량보다 요청 하나를 처리하기 위해 얼마나 많이 읽어야 하는지, 먼저 들어온 일을 얼마나 기다려야 하는지가 더 중요합니다.

책이 가득한 도서관에서 작은 검색 카드를 따라 한 권의 책을 찾는 모습

검색 목록은 책을 없애지 않습니다. 대신 어느 책장부터 살펴봐야 하는지 알려 주어 확인할 범위를 줄입니다.

이 글은 대용량 트래픽과 데이터베이스를 공부하며 이해한 내용을 제 언어로 다시 정리한 학습 기록입니다. 제가 실제 100만 사용자 서비스를 운영하며 측정한 결과를 소개하는 글은 아닙니다.

첫 번째 원인: 필요한 데이터를 찾기 위해 너무 많이 읽는다

회원의 이메일 주소로 계정을 찾는 다음 쿼리를 생각해 보겠습니다.

SELECT *
FROM users
WHERE email = 'student@example.com';

email을 찾는 데 알맞은 인덱스가 없다면 데이터베이스는 원하는 행을 찾기 위해 테이블의 많은 행을 앞에서부터 확인할 수 있습니다. 이를 Full Table Scan(테이블 전체 탐색)이라고 합니다.

도서관에서 모든 책의 표지를 차례로 읽는 것과 비슷합니다. 회원 수가 늘어날수록 확인해야 하는 후보도 많아질 수 있습니다.

반대로 email에 적절한 인덱스가 있다면 데이터베이스는 이메일 값과 데이터 위치를 연결한 별도 구조를 따라갑니다. 모든 회원 정보를 순서대로 읽는 대신 검색 범위를 먼저 좁힐 수 있습니다.

다만 인덱스가 있다고 언제나 한 번에 답을 찾는 것은 아닙니다. 여러 사람이 같은 값을 가진 열인지, 검색 조건이 인덱스의 순서와 맞는지, 전체 데이터 중 얼마나 많은 행을 가져오는지에 따라 데이터베이스가 선택하는 방법은 달라집니다.

인덱스가 언제나 한 단계 만에 답을 찾게 해 주는 것은 아닙니다. 쿼리 조건, 데이터 분포, 인덱스 구조에 따라 달라지지만 확인해야 할 데이터의 양을 줄일 수 있는 탐색 경로를 제공합니다.

핵심은 “인덱스가 있으면 무조건 빠르다”가 아닙니다. 요청 하나가 읽어야 하는 데이터의 양을 줄일 수 있는 탐색 경로가 있는가가 중요합니다.

인덱스는 공짜가 아니다

도서관에 새 책이 들어오면 검색 목록에도 제목과 위치를 추가해야 합니다. 책을 다른 자리로 옮기면 목록도 고쳐야 합니다.

데이터베이스의 인덱스도 같습니다. 특정 값을 찾거나 정렬하고, 일부 범위를 검색할 때 도움이 될 수 있지만 별도의 저장 공간이 필요합니다.

데이터를 추가하거나 수정하거나 삭제할 때는 관련 인덱스도 함께 바뀌어야 합니다. 사용하지 않는 인덱스가 많으면 읽기보다 쓰기 작업이 무거워지고, 어떤 인덱스를 유지해야 하는지 관리하는 일도 늘어납니다.

따라서 “모든 열에 인덱스를 만들면 가장 빠르다”는 말은 맞지 않습니다. 실제로 자주 사용하는 검색 조건과 데이터 분포를 보고 필요한 인덱스를 골라야 합니다.

이때 EXPLAIN은 데이터베이스가 쿼리를 어떤 순서와 방법으로 처리할지 보여주는 실행 계획 확인 도구입니다. 어떤 인덱스를 후보로 보았는지, 대략 얼마나 많은 행을 살펴볼 것으로 예상하는지 확인한 뒤 변경 전후를 비교할 수 있습니다.

EXPLAIN의 결과도 실제 실행시간 그 자체는 아닙니다. 실행 계획과 실제 응답시간, 읽은 행의 수를 함께 봐야 합니다.

두 번째 원인: 데이터베이스로 들어가는 문이 부족하다

학교 상담실에 상담 선생님이 열 명 있다고 생각해 보겠습니다. 학생 백 명이 동시에 찾아와도 한 번에 상담받을 수 있는 학생 수는 제한됩니다. 나머지는 상담실 앞에서 기다려야 합니다.

애플리케이션이 데이터베이스에 일을 요청하려면 Database Connection(데이터베이스 연결)이 필요합니다. 매번 새 연결을 만들고 닫으면 네트워크 연결과 준비 과정이 반복됩니다.

그래서 많은 애플리케이션은 Connection Pool(데이터베이스 연결 풀)을 사용합니다. 일정한 수의 연결을 미리 준비해 두고, 요청이 빌려 쓴 뒤 반납하도록 재사용하는 방식입니다.

연결이 모두 사용 중이면 새 요청은 연결이 돌아올 때까지 기다립니다. 쿼리가 빨라도 연결을 빌리는 데 오래 걸리면 사용자가 받는 응답은 느립니다.

그렇다고 연결 풀의 숫자를 크게 만들면 언제나 빨라지는 것도 아닙니다. 상담 선생님의 수는 그대로인데 상담실 문만 수백 개로 늘린다고 상담이 빨라지지 않는 것과 같습니다.

연결이 너무 많으면 데이터베이스가 동시에 더 많은 쿼리를 받아 CPU, 메모리, 디스크 같은 자원을 두고 경쟁할 수 있습니다. 연결마다 서버 자원도 필요합니다.

따라서 연결 풀은 애플리케이션 서버 한 대의 숫자만 보고 정할 수 없습니다. 서버가 여러 대라면 모든 서버의 연결을 합친 수, 쿼리가 연결을 사용하는 시간, 데이터베이스가 감당하는 동시 작업량을 함께 측정해야 합니다.

세 번째 원인: 여러 요청이 같은 데이터를 바꾸려고 한다

학교 매점에 음료가 한 개 남았는데 두 학생이 같은 순간에 구매 버튼을 눌렀다고 생각해 보겠습니다. 두 요청이 모두 재고를 1개라고 읽고 각각 성공 처리하면, 실제로는 한 개뿐인 음료를 두 명에게 팔았다고 기록할 수 있습니다.

데이터베이스는 이런 잘못된 중간 상태를 막기 위해 Transaction(트랜잭션)을 사용합니다. 트랜잭션은 여러 작업을 하나의 완전한 묶음으로 처리하는 단위입니다.

예를 들어 재고를 확인하고 1개 줄인 뒤 주문을 기록하는 작업은 함께 성공해야 합니다. 중간에 실패했다면 일부만 남기지 않고 원래 상태로 되돌릴 수 있어야 합니다.

동시에 같은 데이터를 바꾸려는 순서를 조정할 때는 Lock(잠금)이 사용됩니다. 한 요청이 재고를 확인하고 수정하는 동안 충돌할 수 있는 다른 요청을 잠시 기다리게 하는 장치입니다.

흐름을 단순하게 줄이면 다음과 같습니다.

  1. 재고가 1개 남아 있습니다.
  2. 두 학생이 동시에 구매 버튼을 누릅니다.
  3. 한 요청이 먼저 재고를 확인하고 수정합니다.
  4. 다른 요청은 잠금이 풀릴 때까지 기다립니다.
  5. 두 번째 요청은 바뀐 재고를 확인하고 품절로 처리할 수 있습니다.

잠금은 데이터의 정확성을 보호하지만 기다리는 시간을 만듭니다. 같은 상품이나 계좌처럼 인기 있는 한 행에 요청이 몰리거나, 트랜잭션이 너무 많은 일을 한꺼번에 묶고 오래 끝나지 않으면 그 뒤의 요청도 오래 기다립니다.

기다림은 때로 문제가 아니라 정확성을 지키기 위해 지불하는 비용입니다. 문제는 그 기다림이 예상보다 길어질 때 발생합니다.

서로가 가진 자원이 풀리기만 기다려 아무도 진행하지 못하는 상태를 Deadlock(교착 상태)이라고 합니다. 이번 글에서는 깊게 다루지 않지만, 애플리케이션은 데이터베이스가 한 트랜잭션을 중단했을 때 안전하게 처리할 준비가 필요합니다.

데이터베이스가 느릴 때 먼저 확인할 것

환자가 배가 아프다고 말했는데 검사도 하지 않고 수술부터 하는 의사는 없습니다. 데이터베이스도 캐시, 복제, 샤딩 같은 큰 기술을 넣기 전에 어디에서 일을 많이 하고 어디에서 기다리는지 확인해야 합니다.

  • 쿼리 응답시간: 어떤 쿼리가 평소보다 오래 걸리는지 확인합니다.
  • 읽은 행과 반환한 행: 결과는 한 행인데 지나치게 많은 행을 확인하지 않았는지 봅니다.
  • 연결 풀 대기시간: 쿼리를 실행하기 전 연결을 빌리는 데 시간이 쌓이는지 확인합니다.
  • 잠금 대기시간: 다른 트랜잭션이 가진 잠금 때문에 멈춘 요청이 있는지 봅니다.
  • CPU·메모리·디스크 사용량: 계산, 메모리 부족, 데이터 읽기 중 어느 자원이 바쁜지 확인합니다.
  • Slow Query Log(느린 쿼리 로그): 정한 기준보다 오래 걸린 쿼리를 모아 반복되는 문제를 찾습니다.
  • EXPLAIN 실행 계획: 데이터베이스가 어떤 순서와 탐색 방법을 선택할 것으로 보는지 확인합니다.

Sharding(샤딩)은 데이터를 여러 데이터베이스에 나누어 저장하는 방식입니다. 매우 큰 규모에서 필요한 선택지가 될 수 있지만, 데이터를 어디에 둘지 정하고 여러 조각을 함께 조회하며 장애를 처리하는 새로운 복잡성이 생깁니다.

데이터가 조금 늘었다는 이유만으로 바로 샤딩하면 아직 확인하지 않은 느린 쿼리는 그대로 남고 운영할 데이터베이스만 많아질 수 있습니다. 먼저 읽는 양과 기다리는 시간을 측정해야 하는 이유입니다.

흔히 하는 잘못된 해결

느린 원인을 찾지 않고 익숙한 기술부터 적용하면 비용만 다른 곳으로 옮길 수 있습니다.

  • 모든 열에 인덱스 만들기: 저장 공간과 쓰기 비용이 늘고 실제로 사용되지 않을 수 있습니다.
  • 연결 풀을 무조건 크게 만들기: 데이터베이스가 처리할 수 있는 양보다 많은 일을 동시에 보내 자원 경쟁을 키울 수 있습니다.
  • 캐시부터 넣기: 느린 쓰기나 잠금 대기는 남고, 원본과 캐시를 맞추는 문제가 새로 생깁니다.
  • 데이터가 늘자마자 샤딩하기: 분산된 데이터를 운영하는 복잡성을 너무 일찍 떠안게 됩니다.
  • 평균 응답시간만 보기: 잠금이나 연결을 오래 기다린 일부 요청을 평균이 가릴 수 있습니다.

기술을 고르기 전에 먼저 물어야 합니다. 요청은 데이터를 너무 많이 읽고 있는가, 연결을 기다리는가, 아니면 같은 데이터를 바꾸려는 다른 요청을 기다리는가?

데이터의 양보다 읽는 양과 기다리는 시간을 보자

핵심은 세 줄로 정리할 수 있습니다.

  1. 데이터가 많아도 적절한 탐색 구조가 있으면 필요한 데이터를 더 적게 읽을 수 있습니다.
  2. 인덱스, 연결, 잠금은 속도와 정확성을 돕지만 각각 저장 공간, 자원 경쟁, 대기시간이라는 비용이 있습니다.
  3. 데이터베이스가 느리면 기술을 추가하기 전에 읽는 양과 기다리는 시간을 먼저 측정해야 합니다.

데이터베이스는 단순히 데이터가 많다는 이유만으로 느려지는 것이 아닙니다. 요청 하나가 읽어야 하는 데이터가 많아지거나, 동시에 너무 많은 요청이 몰리거나, 여러 요청이 같은 데이터를 차지하려 할 때 느려집니다.

다음 편에서는 MySQL 하나로 모든 일을 처리하지 않고, Redis·Elasticsearch·MongoDB처럼 서로 다른 저장소를 사용하는 이유를 살펴봅니다.

다음 글 — 왜 같은 데이터를 여러 곳에 저장할까?

참고한 1차 자료