Tech

DB 장애의 진짜 원인은 코드가 아니었다

약 10분조회수를 불러오고 있어요
#Database#MySQL#Performance#Index#Troubleshooting

최근 일주일 동안 DB 장애가 두 번 있었어요. 처음에는 애플리케이션 코드나 커넥션 풀 설정을 먼저 의심했어요. 응답 지연, CPU 급등, 타임아웃 같은 현상은 늘 코드와 인프라부터 떠올리게 하니까요.

그런데 슬로우 쿼리를 따라가다 보니 결론은 달라졌어요. 이번 문제는 "코드가 비효율적이었다"보다 "조회 패턴을 받쳐줄 인덱스가 없었다"에 더 가까웠어요.

특히 rows_examined가 70만에 가까운 쿼리를 본 순간, 병목의 방향이 완전히 바뀌었어요. 이 글에서는 장애 상황에서 어떻게 원인을 좁혀갔는지, 왜 avg_time보다 rows_examined를 더 중요하게 봤는지, 그리고 인덱스를 어떤 기준으로 설계해야 하는지 정리해요.


1. 왜 코드부터 의심했는가

당시 보였던 현상은 전형적인 장애 징후였어요.

  • API 응답 지연
  • DB CPU 급등
  • 커넥션 풀 대기 증가
  • 타임아웃 발생

처음에는 자연스럽게 애플리케이션 레이어를 먼저 봤어요. 최근 배포 코드, 커넥션 풀 설정, 재시도 로직, 타임아웃 설정을 우선 점검했어요.

그런데 로그를 모아보니 공통점이 하나 있었어요.

느린 요청의 뒤에는 항상 슬로우 쿼리가 있었고,
그 슬로우 쿼리의 뒤에는 풀스캔이 있었어요.

이 시점부터 문제를 "코드 최적화"가 아니라 "왜 이 쿼리가 인덱스를 타지 못했는가"로 다시 정의하기 시작했어요.

정리: 장애 증상은 애플리케이션에서 보였지만, 원인 후보는 DB 실행 경로에 더 가까웠어요.


2. 문제를 어떻게 정의했는가

무작정 튜닝부터 하지는 않았어요. 먼저 이번 대응의 우선순위를 정했어요.

순서목표이유
1장애 원인 제거반복 장애를 멈추는 게 가장 먼저였어요
2장애 전파 차단느린 쿼리가 다른 요청까지 밀어내고 있었어요
3로그와 추적 개선다음 장애 때 더 빨리 좁힐 기준이 필요했어요
4테스트 보강재현 가능한 검증 장치가 필요했어요
5레거시 정리근본 원인 제거 뒤에야 순서가 됐어요

이 기준에서 가장 먼저 잡은 건 인덱스였어요. 장애가 난 시점에 가장 큰 비용을 만든 건 애플리케이션 한 줄보다, DB가 읽어야 했던 행 수 자체였기 때문이에요.


3. 슬로우 쿼리에서 무엇을 봤는가

먼저 performance_schema에서 평균 실행 시간이 긴 쿼리들을 모았어요.

SELECT
    DIGEST_TEXT,
    COUNT_STAR AS exec_count,
    ROUND(SUM_TIMER_WAIT / 1000000000000, 3) AS total_time_sec,
    ROUND(AVG_TIMER_WAIT / 1000000000000, 3) AS avg_time_sec,
    SUM_ROWS_EXAMINED AS rows_examined,
    SUM_ROWS_SENT AS rows_sent
FROM performance_schema.events_statements_summary_by_digest
ORDER BY AVG_TIMER_WAIT DESC
LIMIT 20;

여기서 중요했던 건 avg_time_sec 하나만 보는 게 아니었어요.

지표왜 봤는가
avg_time_sec요청 하나가 얼마나 느린지 보여줘요
exec_count느린 쿼리가 얼마나 자주 호출되는지 보여줘요
rows_examinedDB가 실제로 얼마나 많이 읽었는지 보여줘요
rows_sent읽은 결과 중 실제로 반환된 양을 보여줘요

문제 쿼리 하나는 형태가 아주 단순했어요.

WHERE unique_id = ?

그런데 지표는 단순하지 않았어요.

  • 평균 실행 시간: 약 0.6초
  • rows_examined: 약 740,000
  • rows_sent: 거의 없음

이 숫자를 문장으로 바꾸면 이렇게 돼요.

데이터 한 건을 찾기 위해,
매번 74만 건 가까이를 뒤지고 있었어요.

모든 슬로우 쿼리가 인덱스 문제는 아니에요. 정렬, 조인 순서, 통계 정보, 락 경합이 원인일 수도 있어요. 하지만 이번 사례처럼 rows_examined가 비정상적으로 크고 rows_sent는 거의 없는 경우에는, "필요한 것보다 훨씬 많이 읽고 있다"는 신호가 아주 분명했어요.

정리: DB 비용은 결과보다 읽은 양에서 먼저 커져요.


4. 인덱스 현황과 실행계획을 확인했어요

슬로우 쿼리를 본 뒤에는 "이 컬럼에 인덱스가 원래 있었는가"를 바로 확인했어요.

SELECT
    TABLE_NAME,
    INDEX_NAME,
    GROUP_CONCAT(COLUMN_NAME) AS indexed_columns
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = 'tableName'
GROUP BY TABLE_NAME, INDEX_NAME;

문제 테이블인 activity_queue는 사실상 기본키만 있었어요.

INDEX_NAMEindexed_columns
PRIMARYseq

문제 쿼리는 unique_id로 찾고 있었는데, 인덱스는 seq에만 걸려 있었던 거예요.

즉, 이번 조회 패턴 기준으로는 기본키 인덱스가 있어도 아무 도움이 되지 않았어요.

이후 EXPLAIN으로 실행계획을 확인했어요.

EXPLAIN
SELECT ...
FROM activity_queue
WHERE unique_id = ?;

결과는 아래처럼 나왔어요.

type = ALL
key = NULL

이건 꽤 명확한 신호였어요.

  • type = ALL: 전체 행을 훑고 있어요
  • key = NULL: 선택된 인덱스가 없어요

즉, 추측이 아니라 실행계획 수준에서 풀스캔이 확인된 상태였어요.

정리: 슬로우 쿼리 분석은 의심의 출발점이고, EXPLAIN은 확정의 단계예요.


5. 인덱스는 컬럼이 아니라 쿼리 기준으로 설계해야 해요

이 경험에서 가장 크게 남은 기준은 여기에 있어요.

인덱스는 컬럼 이름을 보고 추가하는 게 아니라,
실제 호출되는 쿼리 패턴을 보고 설계해야 해요.

당시 자주 호출되던 조건은 대략 두 가지였어요.

WHERE unique_id = ?
WHERE unique_id = ? AND type = ? AND status = ?

이 패턴을 보면 중요한 건 컬럼 목록이 아니라 "어떤 순서로 동등 조건이 붙는가"예요.

처음 떠올릴 수 있는 후보는 이런 식이었어요.

(unique_id)
(unique_id, type, status)

다만 여기서 멈추면 안 돼요. 두 인덱스를 모두 추가하는 게 항상 정답은 아니기 때문이에요. 복합 인덱스는 왼쪽 prefix를 활용할 수 있어서, (unique_id, type, status) 하나만으로도 첫 번째 조회를 상당 부분 커버할 수 있어요.

그래서 실제 선택은 아래 기준을 같이 봐야 해요.

확인 기준왜 필요한가
실행계획 변경 여부정말 풀스캔이 인덱스 조회로 바뀌는지 확인해야 해요
카디널리티값이 너무 치우치면 기대만큼 효과가 안 날 수 있어요
쓰기 비용인덱스가 늘면 insert/update 비용도 같이 늘어요
다른 조회 패턴지금 쿼리 하나만 최적화하면 다른 경로가 손해볼 수 있어요

핵심은 "컬럼이 보이니 인덱스를 하나 더 만든다"가 아니에요. 실행계획이 어떻게 바뀌고, 그 대가가 무엇인지까지 같이 봐야 해요.

정리: 좋은 인덱스는 컬럼 나열이 아니라 쿼리 패턴과 비용의 균형으로 결정돼요.


6. 이 일은 DBA와 백엔드의 경계를 다시 생각하게 했어요

예전에는 인덱스를 운영 이슈가 터진 뒤에 보는 영역처럼 여긴 적도 있었어요. 그런데 이번 일을 겪고 보니 그 구분은 실무에서 그리 유용하지 않았어요.

장애가 발생한 순간에는 DBA처럼 실행계획과 읽기 비용을 봐야 했고, 문제를 예방하는 단계에서는 백엔드처럼 API 호출 패턴과 조회 조건을 이해해야 했어요.

둘 중 하나만으로는 부족했어요.

  • 실행계획만 보면 왜 이 쿼리가 자주 호출되는지 놓치기 쉬워요
  • 애플리케이션 코드만 보면 DB가 실제로 얼마나 많이 읽는지 놓치기 쉬워요

결국 인덱스는 운영 이슈가 아니라 설계 이슈이기도 했어요. 느려진 뒤에 고치는 일과, 느려지지 않게 만드는 일은 분리되지 않았어요.


7. 이번 경험 이후로 바뀐 기준

이후에는 쿼리와 인덱스를 따로 보지 않으려고 해요. 지금은 아래 기준을 기본값으로 두고 있어요.

  1. 새로운 조회 쿼리를 만들 때는 인덱스 후보를 같이 적어요.
  2. 배포 전 주요 쿼리는 EXPLAIN으로 한 번 더 확인해요.
  3. 슬로우 쿼리 모니터링은 평균 시간보다 rows_examined까지 같이 봐요.
  4. 인덱스를 추가할 때는 읽기 이득과 쓰기 비용을 같이 기록해요.
  5. 자주 쓰는 조회 패턴은 팀 문서에 남겨 다음 설계의 기준으로 삼아요.

특히 "기본키가 있으니 괜찮다"는 생각은 완전히 버리게 됐어요. 기본키 인덱스는 당연히 중요하지만, 문제 쿼리의 조건과 맞지 않으면 그 쿼리에는 없는 것과 크게 다르지 않았어요.


8. 마무리

이번 장애는 겉으로 보면 API 장애였어요. 하지만 실제로는 애플리케이션 코드보다, 쿼리와 인덱스 사이의 약속이 깨져 있었던 문제에 가까웠어요.

특히 rows_examined를 본 뒤에는 판단 기준이 선명해졌어요. DB는 결과를 반환하는 비용보다, 그 결과를 찾기 위해 얼마나 많이 읽었는지에서 먼저 무거워졌어요.

그래서 지금은 이렇게 생각해요.

DB 성능 문제는 코드 한 줄보다,
쿼리 패턴과 인덱스 설계가 어긋나는 순간부터 시작되는 경우가 많아요.

다음에는 장애가 나서 인덱스를 보는 게 아니라, 쿼리를 작성하는 순간부터 어떤 실행계획을 원하고 있는지 먼저 생각하려고 해요.


참고

댓글