diff --git "a/posts/db-\354\236\245\354\225\240\354\235\230-\354\247\204\354\247\234-\354\233\220\354\235\270/index.mdx" "b/posts/db-\354\236\245\354\225\240\354\235\230-\354\247\204\354\247\234-\354\233\220\354\235\270/index.mdx" new file mode 100644 index 00000000..9a9db1f7 --- /dev/null +++ "b/posts/db-\354\236\245\354\225\240\354\235\230-\354\247\204\354\247\234-\354\233\220\354\235\270/index.mdx" @@ -0,0 +1,271 @@ +최근 일주일 동안 DB 장애가 두 번 있었어요. +처음에는 애플리케이션 코드나 커넥션 풀 설정을 먼저 의심했어요. +응답 지연, CPU 급등, 타임아웃 같은 현상은 늘 코드와 인프라부터 떠올리게 하니까요. + +그런데 슬로우 쿼리를 따라가다 보니 결론은 달라졌어요. +이번 문제는 "코드가 비효율적이었다"보다 +"조회 패턴을 받쳐줄 인덱스가 없었다"에 더 가까웠어요. + +특히 `rows_examined`가 70만에 가까운 쿼리를 본 순간, 병목의 방향이 완전히 바뀌었어요. +이 글에서는 장애 상황에서 어떻게 원인을 좁혀갔는지, +왜 `avg_time`보다 `rows_examined`를 더 중요하게 봤는지, +그리고 인덱스를 어떤 기준으로 설계해야 하는지 정리해요. + +--- + +## 1. 왜 코드부터 의심했는가 + +당시 보였던 현상은 전형적인 장애 징후였어요. + +- API 응답 지연 +- DB CPU 급등 +- 커넥션 풀 대기 증가 +- 타임아웃 발생 + +처음에는 자연스럽게 애플리케이션 레이어를 먼저 봤어요. +최근 배포 코드, 커넥션 풀 설정, 재시도 로직, 타임아웃 설정을 우선 점검했어요. + +그런데 로그를 모아보니 공통점이 하나 있었어요. + +> 느린 요청의 뒤에는 항상 슬로우 쿼리가 있었고, +> 그 슬로우 쿼리의 뒤에는 풀스캔이 있었어요. + +이 시점부터 문제를 "코드 최적화"가 아니라 +"왜 이 쿼리가 인덱스를 타지 못했는가"로 다시 정의하기 시작했어요. + +**정리:** 장애 증상은 애플리케이션에서 보였지만, 원인 후보는 DB 실행 경로에 더 가까웠어요. + +--- + +## 2. 문제를 어떻게 정의했는가 + +무작정 튜닝부터 하지는 않았어요. +먼저 이번 대응의 우선순위를 정했어요. + +| 순서 | 목표 | 이유 | +| --- | --- | --- | +| 1 | 장애 원인 제거 | 반복 장애를 멈추는 게 가장 먼저였어요 | +| 2 | 장애 전파 차단 | 느린 쿼리가 다른 요청까지 밀어내고 있었어요 | +| 3 | 로그와 추적 개선 | 다음 장애 때 더 빨리 좁힐 기준이 필요했어요 | +| 4 | 테스트 보강 | 재현 가능한 검증 장치가 필요했어요 | +| 5 | 레거시 정리 | 근본 원인 제거 뒤에야 순서가 됐어요 | + +이 기준에서 가장 먼저 잡은 건 인덱스였어요. +장애가 난 시점에 가장 큰 비용을 만든 건 애플리케이션 한 줄보다, +DB가 읽어야 했던 행 수 자체였기 때문이에요. + +--- + +## 3. 슬로우 쿼리에서 무엇을 봤는가 + +먼저 `performance_schema`에서 평균 실행 시간이 긴 쿼리들을 모았어요. + +```sql +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_examined` | DB가 실제로 얼마나 많이 읽었는지 보여줘요 | +| `rows_sent` | 읽은 결과 중 실제로 반환된 양을 보여줘요 | + +문제 쿼리 하나는 형태가 아주 단순했어요. + +```sql +WHERE unique_id = ? +``` + +그런데 지표는 단순하지 않았어요. + +- 평균 실행 시간: 약 0.6초 +- `rows_examined`: 약 740,000 +- `rows_sent`: 거의 없음 + +이 숫자를 문장으로 바꾸면 이렇게 돼요. + +> 데이터 한 건을 찾기 위해, +> 매번 74만 건 가까이를 뒤지고 있었어요. + +모든 슬로우 쿼리가 인덱스 문제는 아니에요. +정렬, 조인 순서, 통계 정보, 락 경합이 원인일 수도 있어요. +하지만 이번 사례처럼 `rows_examined`가 비정상적으로 크고 +`rows_sent`는 거의 없는 경우에는, +"필요한 것보다 훨씬 많이 읽고 있다"는 신호가 아주 분명했어요. + +**정리:** DB 비용은 결과보다 읽은 양에서 먼저 커져요. + +--- + +## 4. 인덱스 현황과 실행계획을 확인했어요 + +슬로우 쿼리를 본 뒤에는 +"이 컬럼에 인덱스가 원래 있었는가"를 바로 확인했어요. + +```sql +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_NAME | indexed_columns | +| --- | --- | +| `PRIMARY` | `seq` | + +문제 쿼리는 `unique_id`로 찾고 있었는데, +인덱스는 `seq`에만 걸려 있었던 거예요. + +즉, 이번 조회 패턴 기준으로는 +기본키 인덱스가 있어도 아무 도움이 되지 않았어요. + +이후 `EXPLAIN`으로 실행계획을 확인했어요. + +```sql +EXPLAIN +SELECT ... +FROM activity_queue +WHERE unique_id = ?; +``` + +결과는 아래처럼 나왔어요. + +```text +type = ALL +key = NULL +``` + +이건 꽤 명확한 신호였어요. + +- `type = ALL`: 전체 행을 훑고 있어요 +- `key = NULL`: 선택된 인덱스가 없어요 + +즉, 추측이 아니라 실행계획 수준에서 풀스캔이 확인된 상태였어요. + +**정리:** 슬로우 쿼리 분석은 의심의 출발점이고, `EXPLAIN`은 확정의 단계예요. + +--- + +## 5. 인덱스는 컬럼이 아니라 쿼리 기준으로 설계해야 해요 + +이 경험에서 가장 크게 남은 기준은 여기에 있어요. + +> 인덱스는 컬럼 이름을 보고 추가하는 게 아니라, +> 실제 호출되는 쿼리 패턴을 보고 설계해야 해요. + +당시 자주 호출되던 조건은 대략 두 가지였어요. + +```sql +WHERE unique_id = ? +``` + +```sql +WHERE unique_id = ? AND type = ? AND status = ? +``` + +이 패턴을 보면 중요한 건 컬럼 목록이 아니라 +"어떤 순서로 동등 조건이 붙는가"예요. + +처음 떠올릴 수 있는 후보는 이런 식이었어요. + +```sql +(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 성능 문제는 코드 한 줄보다, +> 쿼리 패턴과 인덱스 설계가 어긋나는 순간부터 시작되는 경우가 많아요. + +다음에는 장애가 나서 인덱스를 보는 게 아니라, +쿼리를 작성하는 순간부터 어떤 실행계획을 원하고 있는지 먼저 생각하려고 해요. + +--- + +## 참고 + +- [인덱스 설계 20년 정리 - 기억보단 기록을](https://jojoldu.tistory.com/712) diff --git "a/posts/db-\354\236\245\354\225\240\354\235\230-\354\247\204\354\247\234-\354\233\220\354\235\270/meta.json" "b/posts/db-\354\236\245\354\225\240\354\235\230-\354\247\204\354\247\234-\354\233\220\354\235\270/meta.json" new file mode 100644 index 00000000..4896b5af --- /dev/null +++ "b/posts/db-\354\236\245\354\225\240\354\235\230-\354\247\204\354\247\234-\354\233\220\354\235\270/meta.json" @@ -0,0 +1,15 @@ +{ + "title": "DB 장애의 진짜 원인은 코드가 아니었다", + "slug": "db-outage-index-root-cause", + "description": "슬로우 쿼리와 rows_examined, EXPLAIN으로 병목을 좁히며 인덱스 설계 문제를 찾은 과정을 정리해요.", + "date": "2026-03-23", + "category": "Tech", + "tags": [ + "Database", + "MySQL", + "Performance", + "Index", + "Troubleshooting" + ], + "featured": false +}