DB 병목 현상
DB 병목 현상 - 시스템의 전체 성능을 저하시킬 만큼 데이터베이스 처리 속도가 느려지는 상황을 말합니다. 쉽게 말해, 데이터베이스가 너무 바빠서 다른 시스템 구성 요소들은 일을 하고 싶어도 기다려야 하는 상황입니다.
왜 병목 현상이 일어날까?
1. 과도한 동시 접속
많은 사용자가 동시에 DB에 접속하면 커넥션 풀이 한계에 걸리기 때문에, 대기시간이 증가하게 됩니다.
2. 비효율적, 잘못된 인덱스 사용잘못된 칼럼에 인덱스를 사용하거나, 전체 테이블을 스캔하는 경우 속도가 저하되어 대기시간이 증가됩니다.
3. 비효율적인 쿼리인덱스를 타지 않거나, join이 많은 쿼리인 경우 대기시간이 증가됩니다.
4. 디스크 I/O 문제대용량 데이터 읽기 / 쓰기가 일어나는 경우 대기시간이 증가됩니다.
5. Lock 경합여러 트랜잭션이 수정을 동시에 진행하면 lock이 걸리기 때문에 대기 시간이 증가하게 됩니다.
6. 자원 고갈서버의 물리적 / 논리적 자원이 부족해지는 현상
해결방안
1. 과도한 동시 접속
a. 캐시 도입
자주 조회되는 데이터들은 캐시 메모리에 저장하여 DB까지의 접근이 안해도 되게 합니다.
b. Rate Limiting
한 사용자당 초당 요청 수를 제한하면 갑작스러운 트래픽 폭주를 제어할 수 있습니다.
c. 읽기 / 쓰기 작업 분리
쓰기 작업은 마스터에서 읽기 작업은 복제본에서 진행하도록 분산 시킨다면, 마스터 DB의 병목을 완화 할 수 있습니다.
2. 비효율, 잘못된 인덱스 사용
a. 복합 인덱스 활용
두 개 이상의 컬럼이 항상 같이 조건에 쓰이는 경우, 복합 인덱스를 써야 한 번의 인덱스로 해결 가능합니다.
b. 적절한 인덱스 사용
WHERE, JOIN, ORDER BY 등에 사용되는 컬럼에 인덱스를 추가하면 탐색 범위가 줄어듦니다.
3. 비효율적인 쿼리
a. 불필요한 join 제거
실제 화면에 필요 없는 테이블 JOIN을 줄이면 처리량이 감소 → 속도 향상
b. 뷰 활용
복잡한 쿼리 결과를 미리 만들어놓고 SELECT로만 사용 → 실시간 계산 감소
c. 쿼리 캐싱
일정 시간 동안 결과를 유지 → DB의 반복 쿼리 제거
4. 디스크 I/O 문제
a. 파티셔닝
오래된 데이터를 별도로 관리해 핫 데이터만 빠르게 접근 가능하게 함
b. 디스크 자원 업그레이드
5. Lock 경합
a. 낙관적 락
데이터에 버전 정보를 추가하고 충돌 시 재시도 → 락 없이 동시성 제어
b. 비동기 처리 도입
실시간 동기 처리 대신 메시지 큐로 비동기 처리하면 락 충돌이 완화됩니다.
예를 들면 주문 처리 후 재고 차감은 Kafka 메시지 소비자로 따로 처리하는 방안입니다.
6. 자원 고갈
a. 스케일 업, 스케일 아웃
b. 자원 모니터링 및 알림 설정
CloudWatch, Prometheus 등으로 메모리/CPU/디스크 이상 징후 실시간 감지 가능
실행 계획 분석
DB가 SQL을 실행할 때 어떤 경로로 데이터를 접근할지 미리 알려주는 계획표
- SQL 쿼리를 분석하여 인덱스를 사용할지, 테이블 전체를 스캔할지, 어떤 순서로 JOIN할지 등을 표시
- 주로 EXPLAIN 또는 EXPLAIN ANALYZE 명령어를 사용해 확인
왜 실행 계획을 분석할까?
- 느린 쿼리의 병목 지점을 정확히 파악
- 쿼리가 인덱스를 사용하지 않고 테이블 전체를 스캔하는지 확인
- 쿼리 최적화에 필요한 힌트를 제공
- DB가 실제 어떤 방식으로 데이터를 가져오는지를 "생각한 대로가 아닌 실제로" 확인
예시 1) 잘못된 쿼리
SELECT * FROM users WHERE DATE(created_at) = '2024-06-01';
EXPLAIN 결과:
type: ALL
possible_keys: idx_created_at
key: NULL
Extra: Using where
DATE 함수로 인해 인덱스 함수를 사용하지 못하고 있는 상황을 확인 할 수 있습니다.
-> 해결
SELECT * FROM users
WHERE created_at BETWEEN '2024-06-01 00:00:00' AND '2024-06-01 23:59:59';
예시 2) join 최적화
SELECT * FROM orders
JOIN users ON orders.user_id = users.id
WHERE users.email = 'abc@example.com';
EXPLAIN 결과:
- orders: type = ALL
- users: type = ref (인덱스 사용)
orders.user_id에 인덱스가 없어서 전체 조회를 하고 있기 때문에 index를 추가해줍니다.
캐싱 전략
1. 어플리케이션 캐시
애플리케이션 캐시는 말 그대로 서버 애플리케이션 내부에서 동작하는 캐시입니다.
Java, Python, Node.js 등의 애플리케이션 메모리 안에 데이터를 저장하고, 네트워크를 거치지 않고 즉시 접근할 수 있다는 장점이 있습니다. 예를 들어 자주 조회되는 유저 정보를 JVM 내부에서 LRU(Least Recently Used) 방식으로 캐싱하면, 데이터베이스에 접근하지 않고도 즉시 정보를 반환할 수 있습니다.
다만 애플리케이션 서버가 재시작되면 캐시가 사라지며, 멀티 인스턴스 환경에서 캐시 일관성을 유지하기 어렵다는 점이 단점입니다.
따라서 단일 서버나 고속 처리가 필요한 내부 연산에 적합한 방식입니다.
2. 클라이언트 캐시
클라이언트 캐시는 브라우저나 프론트엔드 앱에서 동작하는 캐시 방식입니다. 사용자가 페이지를 다시 방문하거나 뒤로가기를 했을 때 이전 데이터를 빠르게 불러오도록 하기 위해 사용됩니다.
HTTP 프로토콜의 Cache-Control, ETag, Last-Modified 같은 헤더를 활용하여 브라우저가 정적 리소스나 API 응답을 저장하고 재사용하게 할 수 있습니다.
예를 들어 상품 목록 API 응답이 변하지 않았다면, 서버는 304 Not Modified 응답만 보내주고 데이터는 브라우저에 저장된 값을 그대로 사용하게 됩니다.
이 방식은 네트워크 요청 자체를 줄일 수 있어 성능이 매우 뛰어나지만, 실시간 데이터나 사용자별로 바뀌는 정보에는 적합하지 않으며, 캐시 무효화 로직이 상대적으로 복잡합니다.
3. 데이터베이스 캐시
데이터베이스 캐시는 일반적으로 데이터베이스 앞단에 Redis, Memcached와 같은 인메모리 데이터 저장소를 두고, 쿼리 결과나 자주 조회되는 데이터를 별도로 저장하는 방식입니다. 이것이 실무에서 가장 널리 사용되는 캐시 방식입니다.
애플리케이션은 우선 캐시에 데이터가 있는지 확인하고, 없다면 데이터베이스에서 가져와 캐시에 저장한 뒤 반환하는 식의 흐름을 따릅니다. 이를 Cache-Aside 패턴이라고 부릅니다.
예를 들어, 로그인한 유저의 정보를 Redis에 "user:123" 형태로 캐싱해두면, 이후에는 DB에 접근하지 않고도 빠르게 응답할 수 있습니다.
또한 인기 상품 목록, 최근 본 항목, 추천 결과처럼 반복적으로 요청되는 데이터에도 매우 효과적입니다.
이 방식은 분산 환경에서도 잘 동작하고, 성능이 매우 뛰어나지만, 캐시 무효화 전략을 명확히 하지 않으면 데이터 일관성 문제가 생길 수 있습니다. TTL(Time-To-Live) 설정, 캐시 삭제 시점 제어, 데이터 갱신 방식 등을 잘 설계해야 안정적인 서비스가 가능합니다.
4. 쿼리 캐시
마지막으로 **쿼리 캐시(Query Cache)**는 데이터베이스 엔진 자체에서 동일한 쿼리의 결과를 자동으로 저장하고 재사용하는 기능입니다. 동일한 SELECT 문에 대해 이전 결과를 캐시에서 바로 반환하는 식으로 동작합니다.
그러나 이 방식은 단점이 많아 최신 DB에서는 거의 사용되지 않습니다.
그 이유는 관련 테이블에 데이터 변경이 일어나면 캐시가 전부 무효화되기 때문에 캐시 유지 비용이 매우 크고, 실시간성이 중요한 서비스에서는 오히려 성능 저하로 이어질 수 있기 때문입니다. 실제로 MySQL 8.0부터는 쿼리 캐시 기능이 완전히 제거되었습니다.
대신 Redis 같은 외부 캐시 시스템을 사용하는 것이 일반적입니다.
파티셔닝 (Partitioning)
수억 건 이상의 테이블에서는 테이블 자체를 쪼개서 관리하는 것이 필요합니다.
이때 사용하는 기법이 바로 파티셔닝(Partitioning)입니다.
파티셔닝은 하나의 테이블을 논리적으로 나누어, 쿼리 시 일부 파티션만 조회하도록 하여 성능을 높입니다.
- RANGE 파티셔닝: 시간 기준으로 나누기 (예: 월별 로그)
- LIST 파티셔닝: 값의 리스트로 나누기 (예: 지역, 카테고리)
- HASH 파티셔닝: 해시 값으로 균등 분산
예를 들어, 로그 데이터를 log_time 기준으로 월별 파티셔닝하면, 특정 달의 데이터만 빠르게 검색할 수 있습니다.
+) 실시간 분석과 대규모 통계 처리
많은 서비스가 성장하면서 마주하는 과제 중 하나는 사용자의 행동을 실시간으로 분석하고 시각화하는 것입니다.
운영자 대시보드, 관리자 페이지, 또는 실시간 통계 리포트와 같은 기능은 사용자 행동을 빠르게 파악하고 의사결정에 즉각 반영할 수 있게 해줍니다. 하지만 문제는 데이터의 양입니다. 수천만 건에 달하는 클릭 로그, 조회 기록, 주문 정보가 초 단위로 쌓이는 상황에서, 단순한 SQL 쿼리만으로는 실시간 처리가 불가능하거나 매우 느려지기 쉽습니다.
OLTP와 OLAP의 차이, 그리고 Pre-Aggregation, Materialized View 같은 핵심 기법을 중심으로 성능을 높이는 방법을 설명하겠습니다.
실시간 분석에 앞서, OLTP(Online Transaction Processing)와 OLAP(Online Analytical Processing)의 차이를 이해하는 것이 중요합니다.
항목 | OLTP | OLAP |
목적 | 빠른 트랜잭션 처리 (읽기/쓰기) | 대량의 데이터 분석 |
예시 | 주문 생성, 로그인 기록 | 일간 매출 통계, 사용자 행동 분석 |
DB 구조 | 정규화 중심, 작은 row | 비정규화 또는 큐브형 구조 |
쿼리 | 빠른 단건 쿼리 | 집계, 그룹핑, 다차원 분석 |
기술 스택 | MySQL, PostgreSQL 등 | Redshift, BigQuery, ClickHouse 등 |
운영 데이터베이스(OLTP)는 빠른 거래 처리에 최적화되어 있지만, 복잡한 집계나 통계 분석에는 적합하지 않습니다.
따라서 실시간 통계를 구현할 때는 보통 운영 DB → 분석 전용 DB 또는 데이터 웨어하우스로 흐름을 분리하게 됩니다.
실시간 분석을 위한 구조 설계: Pre-Aggregation
Pre-Aggregation(사전 집계)은 실시간 분석에서 가장 중요한 전략 중 하나입니다.
매 요청마다 전체 로그를 다시 계산하는 대신, 특정 시간 단위로 미리 계산된 통계 데이터를 저장하고 그 결과만 빠르게 조회하는 방식입니다. 예를 들어 다음과 같은 방식으로 집계를 수행할 수 있습니다:
- 사용자 행동 로그를 초 단위로 수집
- 30초 간격으로 “누가 어떤 버튼을 눌렀는지”를 집계
- 그 결과를 action_stats_minute 같은 별도 테이블에 저장
이 구조에서는 실시간 대시보드에서 복잡한 JOIN이나 COUNT 쿼리를 하지 않아도, 단순한 SELECT로 데이터를 빠르게 불러올 수 있습니다. 이 방식은 Redis나 Kafka Streams, Flink 같은 실시간 처리 도구와도 잘 결합됩니다.
Materialized View 활용
일부 데이터베이스는 Materialized View(물질화 뷰)라는 기능을 지원합니다.
이는 말 그대로 “쿼리 결과를 실제 테이블처럼 저장”해두고, 주기적으로 갱신하는 방식입니다.
복잡한 집계 쿼리를 매번 실행하지 않고, Materialized View에 저장된 결과만 조회하기 때문에 속도가 훨씬 빠릅니다.
Oracle, ClickHouse, BigQuery 등에서 지원되며, 주기적 갱신 또는 수동 갱신 방식으로 운용할 수 있습니다.
실시간 분석 시스템은 단순한 SQL 튜닝만으로는 성능을 보장할 수 없습니다.
데이터의 흐름을 분리하고, 미리 계산하거나 캐시하는 구조를 도입하는 것이 핵심입니다.
- OLTP와 OLAP의 역할을 구분하고
- 실시간 처리 흐름을 설계하며
- Pre-Aggregation이나 Materialized View 같은 전략을 적절히 조합하는 것
이러한 방식은 단지 성능 향상에 그치지 않고, 서비스 확장성과 유지보수성을 크게 향상시켜줍니다.