3장. 성능을 좌우하는 DB 설계와 쿼리
성능에 핵심인 DB
호출 빈도가 높은 기능에 풀 스캔을 유발하는 쿼리가 존재할 경우 응답 시간이 지연된다.
사용자가 조금만 증가해도 DB 서버의 CPU 사용률이 90% 가까이 되는 현상이 발생할 수 있음.
이로 인해, 사용자 경험 측면에서 불만을 일으킬 수 있음.
트래픽이 증가하면서 풀 스캔으로 인한 DB 부하도 커졌고 서버 응답 시간도 지속적으로 길어진다. 적절한 방법을 통해 풀 스캔이 일어나지 않도록 유도하거나, 캐시 같은 방법을 이용할 수도 있다.
인덱스 설계
일반적인 시스템에서는 조회 기능의 실행 비율이 쓰기 기능보다 훨씬 높다.
조회와 관련된 쿼리를 실행할 때 조건절에서 사용되는 컬럼에 대해 주의깊게 보자.
풀 스캔이 발생하지 않도록 하려면 조회 패턴을 기준으로 인덱스를 설계하면 된다.
단일 인덱스와 복합 인덱스
SELECT *
FROM activityLog
WHERE userId = 123 and activityDate = '2024-07-31'
ORDER BY activityDate DESC;
위와 같이 activityLog
테이블에 많은 데이터가 존재하는 상황에서 성능 문제가 없으려면 userId
를 포함한 인덱스가 필요하다. 여기서 고민할 점은 activityDate
를 포함할 것인지 아닌지에 대한 것이다.
단일 인덱스: userId 만 인덱스로 사용
복합 인덱스: (userId, activityDate)를 인덱스로 사용
이러한 상황에서 사용자당 가질 수 있는 데이터가 얼마나 될지 가늠해보면 어떤 인덱스를 사용해야 할지 판단하는 데 도움이 된다.
개별 사용자 기준 1주일에 하루 정도 방문, 평균 활동 데이터가 5건이라면?
1년을 활동해야 260건
5년을 꾸준히 활동해야 1,500건
⇒ 이 정도 데이터 건수라면 userId
컬럼만 사용하는 단일 인덱스로도 심각한 문제는 발생하지 않는다.
매일 방문하고 30번 이상의 활동을 하는 회원이라면?
1년이면 1만 건이 넘는 활동 로그 데이터가 쌓임
⇒ 이렇게 회원들의 활동성이 좋다면 (userId, activityDate)
컬럼을 조합한 복합 인덱스 사용을 고려해야 한다.
선택도(Selectivity)를 고려한 인덱스 컬럼 선택
인덱스를 생성할 때는 선택도가 높은 컬럼을 골라야 한다.
선택도는 인덱스에서 특정 컬럼의 고유한 값 비율을 나타낸다. (PK의 경우 선택도가 높다)
선택도가 높을수록 인덱스를 이용한 조회 효율이 높아진다.
선택도가 낮아도 인덱스 컬럼으로 적합한 상황도 있다. 작업 큐를 구현한 테이블이 이에 해당된다.
CREATE TABLE jobqueue (
jobid VARCHAR(16) NOT NULL PRIMARY KEY,
satus CHAR(1) NOT NULL, # W(대기), P(처리 중), C(완료)
# ...
);
위의 작업 큐 테이블의 특성상 대부분의 데이터의 status 컬럼 값이 C(완료)
상태이고, 적은 수의 데이터만 W(대기)
, P(처리 중)
를 값으로 갖는다. 고유한 값이 3개만 존재하므로 선택도가 낮은 컬럼인 것이다.
커버링 인덱스
커버링 인덱스는 특정 쿼리를 실행하는 데 필요한 컬럼을 모두 포함하는 인덱스를 말한다.
Real MySQL 8.0 에서 학습했던 것 처럼 전체적인 동작 과정을 이해하면 좋을 것 같음.
-- idx_member_email_name: (email, name)
SELECT email, name
FROM member
WHERE email = '[email protected]';
인덱스는 조회 속도를 빠르게 해주지만 데이터 추가, 변경, 삭제 시에는 인덱스 관리에 따른 추가 비용(시간)이 있기 때문에 효과가 적은 인덱스를 추가하면 오히려 성능이 나빠질 수 있다.
조회 성능 개선 방법
인덱스가 아니어도 조회 성능을 개선할 방법이 존재한다.
미리 집계하기
다음 기능을 제공하는 간단한 설문 조사 기능을 만든다고 하자.
각 설문은 질문이 4개로 고정되어 있다.
회원은 각 설문 조사마다 '좋아요'를 누를 수 있다.
설문 조사 목록을 보여줄 때 답변 수와 좋아요 수를 표시한다.
이 상황에서 목록을 표시할 때 설문에 답현한 회원 수와 좋아요 수를 표시한다는 요건이 있다면 쿼리는 아래와 같을 것이다.
SELECT s.id, s.subject,
(SELECT count(*)
FROM answer a
WHERE a.surveyId = s.id) AS answerCnt,
(SELECT count(*)
FROM liked l
WHERE l.surveyId = s.id) AS likeCnt
FROM survey s
ORDER BY id DESC
LIMIT 30;
쿼리 시간: 목록 조회 + (답변자를 세는 서브 쿼리 * 30) + (좋아요 수를 세는 서브 쿼리 * 30)
⇒ 적지 않은 시간이 걸릴 것이며, 트래픽이 몰리기 시작한다면 조회 속도가 급격히 느려질 것이다.
이렇듯 count나 sum 같은 집계 쿼리를 조회 시점에 실행하면 발생하는 성능 문제는 집계 데이터를 미리 계싼해서 별도 컬럼에 저장하는 방식을 활용할 수 있다.
읽어볼 거리
별도의 집계성 테이블을 두고 주기적 배치 처리로 실행해도 괜찮아 보이긴 하는데 실제로는 어떻게 진행하는지 궁금함.
DB 장비 확장하기
DB의 성능을 높여 개선할 수 있지만, 조회 트래픽 비중이 높은 서비스의 경우, 주 DB - 복제 DB (Primary-Replica) 구조를 사용해 처리량을 효과적으로 증가시킬 수도 있다.

주의할 점
상태 변경 기능은 복제(replica) DB에서 조회하지 말자.
주 DB와 복제 DB는 순간적으로 데이터가 일치하지 않을 수 있다.
트랜잭션 문제가 발생할 수 있다.
데이터 불일치로 인해 발생할 수 있는 문제를 사전에 생각하고 사용하자.
몇 가지 주의 사항
배치 쿼리 실행 시간 증가
배치 프로그램을 통해 데이터를 일괄 조회하거나 집계하거나 생성하는 작업을 수행할 것이다. 하지만, 한 번에 처리하는 데이터가 많아질수록 일괄 처리용 쿼리의 실행 시간도 함께 증가한다.
특정 임계점을 넘어갈 경우 실행 시간이 예측할 수 없을 만큼 길어질 수 있다. 이러한 문제를 예방하기 위해 배치에서 사용하는 쿼리의 실행 시간을 지속적으로 추적해야 한다.
해결 방안
집계 쿼리 특성상 많은 데이터를 스캔한다는 특징을 생각해보자.
커버링 인덱스 활용
데이터를 일정 크기로 나눠 처리
실패와 트랜잭션 고려하기
DB 관련 코드를 작성할 때는 트랜잭션의 시작과 종료 경계를 명확히 설정했는지 반드시 확인하자.
한 번에 처리되어야할 작업인데 하나의 트랜잭션으로 묶지 않아 데이터가 꼬여버린 경우
적절한 상태 처리가 되지 않아, 사용자 입장과 시스템 상의 데이터가 불일치하는 경우
경우에 따라 일부 기능에서 오류가 발생해도 트랜잭션을 커밋해야 할 상황이 존재함.
회원가입 시 발송하는 메일이 전송되지 않았다고 회원가입 미처리가 된다면 맞는 처리일까?
특히, 외부 API와 DB 작업이 섞여있을 경우 트랜잭션 처리가 복잡해진다.
Last updated
Was this helpful?