본문으로 건너뛰기
팀MySQL(RDS) · 2,637만 행/27GB 감사 로그 · 327만 행 번역 테이블 · 백필 15,333건

DB 포화 원인 분석 — 계측으로 좁히고 쿼리·인덱스로 푼 3건

Platform Part 리더 · 원인 분석·수정·재발 방지 직접 수행

2026-04-01 — 2026-06-30

RDS CPU·IO 포화 3건을 Performance Insights·EXPLAIN으로 원인 쿼리까지 좁혀 해결: 백필 처리량 26배(9.3→247건/분), 2,637만 행 감사 로그의 최장 36분 풀스캔 제거(EXPLAIN rows=3)

성과 지표

백필 처리량
이전: 9.3건/분이후: 247건/분
26배 · 15,333건 완료, 대기 0건
Lambda 실행 (p50)
이전: 63.2s이후: 1.36s
p99 67.0s→2.99s
감사 로그 조회 (live)
이전: 최장 36분 풀스캔이후: EXPLAIN rows=3
인덱스 복구·엔티티 선언
번역 감지 실행 (sandbox)
이전: 100~224초 · 10초 주기와 겹침이후: 버전별 쿼리 · 실행당 상한 500건
live EXPLAIN 버전 1개 39ms(일괄 10.1초)
DB 포화 원인 분석 — 계측으로 좁히고 쿼리·인덱스로 푼 3건 project cover image

문제 해결 과정

1

마케팅 지표 아카이브 백필 중 Lambda 실행이 63초, 처리량이 분당 9.3건에 머물러 남은 작업의 ETA가 15시간이었습니다. 처음 세운 가설은 대형 bulk insert 트랜잭션(540MB)이었지만 RDS CPU가 99.8%에 붙어 있었습니다.

  1. 증상: CPU 99.8% · 9.3건/분 · ETA 15시간
  2. 기각: 가설: 540MB bulk insert 트랜잭션
  3. 계측: Performance Insights: 조회 쿼리 1개가 부하 99.5%
  4. 원인: 30일 N+1 · DATE()로 인덱스 미사용 · 전체 컬럼 select
  5. 조치: IN() 단일 배치 · 날짜 직접 비교 · 필요 컬럼만
  6. 결과: 247건/분(26배) · p50 63.2s→1.36s
해결 과정

Performance Insights에서 부하의 99.5%가 일자별 최신 결과를 찾는 조회 쿼리 하나에서 나오는 것을 확인했습니다. 30일을 하루씩 도는 N+1, 날짜 컬럼을 DATE()로 감싸 인덱스를 타지 못하는 조건, 압축된 대용량 결과 컬럼 전체 select가 겹쳐 있었습니다. 30일을 IN() 단일 배치로 조회하고 날짜는 직접 비교하며 필요한 컬럼만 select한 뒤, 일자별 최신 버전은 메모리에서 골랐습니다. bulk insert도 500건 단위로 나눴습니다.

결과

Lambda 실행 p50 63.2s→1.36s(p99 67.0s→2.99s), 처리량 9.3→247건/분(26배). 백필은 15,333건 완료·대기 0건으로 끝났고, 작업이 끝난 뒤 RDS CPU는 유휴 5~7%였습니다.

2

live 콘솔의 감사 로그 화면을 한 번 열 때마다 2,637만 행/27GB 테이블을 풀스캔했고, 동시 쿼리 4개가 최장 36분 쌓이면서 IO 포화와 INSERT commit 지연이 생겼습니다.

  1. 증상: 2,637만 행 풀스캔 · 최장 36분 · IO 포화
  2. 계측: 복합 인덱스가 사라진 경위 추적
  3. 원인: 자동 생성 마이그레이션이 미선언 인덱스를 drift로 DROP
  4. 조치: 엔티티에 선언 · 27GB DDL 저부하 시간 선생성
  5. 결과: EXPLAIN type=range, rows=3
해결 과정

부모 ID 조건에 쓰던 복합 인덱스가 사라진 경위를 추적해, 이전 자동 생성 마이그레이션이 엔티티에 선언되지 않은 인덱스를 drift로 보고 DROP한 것을 찾았습니다. 복구 마이그레이션의 CREATE INDEX에도 ALGORITHM/LOCK 구문 오류가 있었습니다. 인덱스를 엔티티에 선언해 다시 지워지지 않게 하고, 27GB online DDL은 저부하 시간대에 먼저 수동 생성한 뒤 배포 마이그레이션이 이미 있는 인덱스를 no-op으로 넘기게 했습니다.

결과

live에 인덱스가 생성되고 마이그레이션 기록이 남은 것을 확인했고, EXPLAIN이 type=range, rows=3으로 바뀌어 풀스캔 장애가 해소됐습니다. 실제 DDL을 실행하는 통합 테스트도 추가했습니다.

3

sandbox에서 번역 변경 감지 스케줄러가 도는 동안 DB Read IOPS가 190~470/s(평소 10/s 미만)로 포화됐고, 로그인 처리가 10초를 넘겨 로드밸런서 타임아웃이 나면서 일부 사용자가 약 60초간 로그인하지 못했습니다.

  1. 증상: Read IOPS 190~470/s · 일부 사용자 약 60초 로그인 불가
  2. 계측: 10초 주기에 쿼리 100~224초 · 실행 중첩
  3. 원인: 159개 버전을 IN 하나로 묶어 풀스캔
  4. 조치: 버전별 쿼리 · 페이지네이션 · 실행당 500건 상한
  5. 결과: 실행당 길이 상한 · 총 쿼리 시간은 크게 줄지 않음
해결 과정

스케줄러가 159개 버전을 IN 조건 하나로 묶어 번역 단어 테이블(live 기준 327만 행)을 풀스캔했고, 주기는 10초인데 쿼리는 100~224초씩 걸려 실행이 겹쳐 쌓였습니다. AI 번역 감지에서 쓰던 버전별 패턴을 옮겨 와 버전마다 단일 쿼리를 돌리고, 페이지네이션과 실행당 생성 요청 상한(500건)으로 한 실행의 길이를 제한했습니다. 수정은 공통 패키지 릴리스로 배포했고, 게임 서버에 적용할 때 필드 1,073개를 byte 단위로 비교해 하위 호환을 확인했습니다.

결과

live EXPLAIN에서 버전별 쿼리는 버전 1개 기준 39ms, 기존 159개 버전 일괄 쿼리는 10.1초로 측정됐습니다. 버전을 모두 돌면 한 실행의 총 쿼리 시간은 크게 줄지 않으므로, 개선의 핵심은 페이지네이션과 실행당 상한으로 한 실행의 길이를 제한한 것입니다. 통합 테스트는 모두 통과했습니다.

운영 한계

백필 건의 RDS CPU는 수정 전 99.8%만 비교 기준으로 쓰며, 이후 관측한 5~7%는 작업이 끝난 유휴 상태 값입니다.
번역 감지 건은 sandbox 장애입니다. 39ms는 live EXPLAIN의 버전 1개 표본이며, 버전을 모두 도는 한 실행의 총 쿼리 시간은 크게 줄지 않습니다.
감사 로그 건은 인덱스 복구로 끝났고, 대용량 감사 로그의 보관·파티셔닝 정책은 다루지 않았습니다.

프로젝트 설명

마케팅 지표 서비스와 게임 서버의 MySQL(RDS)에서 생긴 포화 3건을 같은 순서로 다뤘습니다. 부하를 만든 쿼리를 계측으로 먼저 특정하고, 쿼리 형태·인덱스·실행 단위를 고친 뒤, 같은 문제가 다시 생기는 경로를 막았습니다. 한 건은 live 장애, 한 건은 sandbox 장애, 한 건은 대량 백필 중 병목이며 환경은 항목마다 표기했습니다. 테이블·메서드 이름 같은 회사 내부 식별자는 일반 명칭으로 바꿨습니다.

주요 내용

  • 백필 중 RDS CPU 99.8% 포화: Performance Insights로 부하의 99.5%를 낸 조회 쿼리를 특정하고 N+1·DATE() 인덱스 미스를 제거해 처리량 9.3→247건/분(26배), Lambda p50 63.2s→1.36s
  • live 장애: 자동 생성 마이그레이션이 drift로 지운 인덱스 때문에 2,637만 행 감사 로그에서 최장 36분 풀스캔 — 인덱스 복구 후 EXPLAIN rows=3
  • sandbox 장애: 10초 주기 스케줄러가 159개 버전을 한 쿼리로 묶어 100~224초씩 겹쳐 돌며 Read IOPS 190~470/s 포화 — 버전별 쿼리와 실행당 상한으로 실행 길이를 제한
  • 재발 방지: 인덱스를 엔티티에 선언해 drift를 막고, 실제 DDL 통합 테스트와 실행당 생성 요청 상한(500건)을 추가

기술 선택 근거

  • ▶가설보다 계측: 백필 병목은 대형 트랜잭션을 의심했지만 Performance Insights가 조회 쿼리 하나를 가리켜, 쓰기 경로가 아니라 읽기 쿼리의 형태를 고쳤습니다.
  • ▶인덱스는 엔티티에 선언: 마이그레이션으로만 만든 인덱스는 다음 자동 생성 마이그레이션이 drift로 지울 수 있어, 스키마 기준을 엔티티에 두었습니다.
  • ▶27GB online DDL은 배포와 분리: 저부하 시간대에 먼저 수동 생성하고, 배포 마이그레이션은 이미 있는 인덱스를 no-op으로 넘겨 배포 중 장시간 DDL을 피했습니다.
  • ▶주기 작업은 작게 자르기: 감지 스케줄러를 버전별 쿼리·페이지네이션·실행당 상한으로 나눠 한 실행의 길이를 상한으로 제한했습니다.

깨달은 점

  • •첫 가설은 계측으로 확인하기 전까지 가설일 뿐이다. 부하의 99.5%를 낸 쿼리는 의심하던 곳에 있지 않았다.
  • •마이그레이션에만 있는 인덱스는 언제든 drift로 사라질 수 있다. 스키마의 기준은 엔티티에 둔다.
  • •주기 작업은 한 실행이 주기보다 길어지는 순간 겹쳐 쌓인다. 실행 단위를 잘게 나누고 상한을 둔다.

기술 스택

MySQLAWS RDSPerformance InsightsTypeORMAWS LambdaTypeScript