처음 메신저로 호출이 왔을 때만 해도 내 일이 아니라고 생각했다. 고객사 서버가 노후화돼서 장비를 교체하는 작업이었고, 내가 맡은 건 단순했다. “웹 프로세스만 다시 올려서 살려두면 된다.” 그게 전부였다. 웹 애플리케이션 쪽은 손댈 게 없었고, 실제로 배포하고 띄우니 화면도 잘 떴다.
게다가 기존에 쓰던 환경을 구성 그대로 새 서버로 옮긴 작업이었다. DB 스키마도, 데이터도, 설정도 똑같았다. “있던 걸 그대로 들고 왔는데 DB에서 문제가 날 리 없다”는 게 모두의 암묵적 전제였다. 결과적으로 이 전제가 진단을 가장 오래 지연시킨 함정이었다.
문제는 그날 오후부터였다. 서비스를 재시작하면 한동안은 응답이 빨랐다. 그런데 딱 한 시간쯤 지나면 서비스가 통째로 죽었다. 재시작하면 또 멀쩡해지고, 한 시간 뒤 또 죽고. 이 패턴이 하루에도 몇 번씩 반복됐다. 웹 쪽은 멀쩡한데 서비스는 죽는, 전형적으로 책임 소재가 애매한 장애였다.
1일 차 — “웹은 문제없는데요”
솔직히 첫날의 나는 소극적이었다. 내 팀 서비스가 아니었고, 웹 프로세스는 정상이었다. 로그를 봐도 애플리케이션 단에서 터지는 예외는 없었다. 그래서 “인프라 교체하면서 뭔가 빠진 것 같다”는 선에서 타 팀에 공을 넘겼다.
지금 와서 가장 후회하는 지점이 여기다. 증상이 명확한데도 “내 영역 밖”이라는 이유로 한 발 물러서 있었던 것. 그 사이 장애는 계속 재발했고, 고객사 운영팀은 한 시간마다 수동으로 프로세스를 재시작하고 있었다. 이 상태로 첫 이틀이 흘러갔다.
트래픽 자체가 폭증한 것도 아니었기 때문에 “갑자기 부하가 늘었다”는 가설은 처음부터 설득력이 약했다.
2일 차 — 재시작 후 한 시간이라는 단서
이틀째 같은 패턴이 반복되자, 더는 미룰 수 없어 본격적으로 붙었다. 가장 먼저 주목한 건 시간이었다. 재시작 직후엔 빠르고, 시간이 지날수록 느려지다가 한 시간쯤에 죽는다. 부하가 일정한데 시간이 갈수록 악화된다면, 이건 “어딘가에 무언가가 쌓이고 있다”는 신호다.
쌓이는 후보는 보통 셋이다. 메모리 누수, 커넥션 누수, 그리고 락(lock) 누적. 힙 메모리는 모니터링상 안정적이었다. 그래서 의심은 자연스럽게 커넥션과 락으로 좁혀졌다.
죽기 직전 시점에 DB 세션 상태를 떠봤다. 그림이 선명했다. 활성 커넥션이 풀 상한까지 꽉 차 있었고, 상당수가 특정 쿼리를 실행한 채 락을 기다리며 멈춰 있었다. 한두 개가 락을 오래 쥐고, 그 뒤로 같은 자원을 노리는 쿼리들이 줄줄이 대기에 걸리는 구조였다.
-- 누가 누구를 막고 있는지 확인 (MySQL 8.0 기준)
SELECT
r.trx_id AS waiting_trx,
r.trx_mysql_thread_id AS waiting_thread,
r.trx_query AS waiting_query,
b.trx_id AS blocking_trx,
b.trx_query AS blocking_query
FROM performance_schema.data_lock_waits w
JOIN information_schema.innodb_trx r ON r.trx_id = w.requesting_engine_transaction_id
JOIN information_schema.innodb_trx b ON b.trx_id = w.blocking_engine_transaction_id;
블로킹 체인의 맨 앞에 선 쿼리를 보니, 인덱스를 타지 않고 수십만 건을 풀스캔하는 조회였다. 평소엔 몇 초 안에 끝나니 아무도 신경 쓰지 않던 쿼리. 문제는 이게 락을 쥔 채 오래 머무는 시간이 곧 다른 트랜잭션의 대기 시간이 된다는 데 있었다.
왜 하필 한 시간이었나
여기서 퍼즐이 맞춰졌다. 락이 한 번 길게 걸리면 그 뒤 요청들이 밀린다. 밀린 요청은 커넥션을 점유한 채 대기한다. 점유된 커넥션이 늘면 풀에 여유가 줄고, 남은 커넥션에 요청이 몰리니 경합이 더 심해진다. 이 악순환이 시간이 갈수록 눈덩이처럼 중첩되다가, 커넥션 풀이 완전히 고갈되는 순간 서비스가 응답을 못 하고 죽었다. 그 임계점에 도달하는 데 걸린 시간이 대략 한 시간이었던 것이다.
재시작이 “약”처럼 보였던 이유도 같다. 프로세스를 내리면 쌓여 있던 커넥션과 대기 트랜잭션이 전부 초기화된다. 깨끗한 상태에서 다시 쌓이기 시작하니, 또 한 시간을 버티다 무너졌다. 근본 원인을 손대지 않은 재시작은 타이머를 0으로 돌리는 것에 불과했다.
그런데 진짜 의문은 따로 있었다. 환경을 그대로 옮겼는데, 왜 하필 지금 터졌을까. 답은 옵티마이저 통계에 있었다.
블로킹의 출발점이 된 그 쿼리는 사실 기존 운영 환경에서도 인덱스가 없는 상태였다. 그런데 별 탈 없이 돌아갔다. 서비스 초기부터 오랜 시간에 걸쳐 데이터가 쌓이면서 통계 정보도 함께 축적됐고, 옵티마이저가 그 통계를 바탕으로 그럭저럭 괜찮은 실행 계획을 골라줬던 것이다. 인덱스가 없어도 운이 좋게 버텨온 쿼리였던 셈이다.
문제는 새 DB로 데이터를 통째로 새로 적재하면서 발생했다. 갓 적재된 테이블은 통계가 제대로 잡혀 있지 않거나 카디널리티 추정이 실제와 어긋나기 쉽다. 옵티마이저가 잘못된 통계로 비효율적인 실행 계획(풀스캔)을 선택하기 시작했고, 그동안 아슬아슬하게 버티던 이 쿼리가 임계선을 넘었다. 웹 코드는 한 줄도 바뀌지 않았는데 증상이 터진 진짜 배경이 여기에 있었다.
3일 차 — 인덱스로 락의 수명을 끊다
원인을 잡고 나서는 빨랐다. 블로킹의 출발점이 된 풀스캔 쿼리에 적절한 복합 인덱스를 추가했다. WHERE 조건과 정렬 컬럼 순서를 고려해 인덱스를 설계했고, 적용 전후로 실행 계획을 확인했다.
-- 풀스캔(type: ALL)을 인덱스 레인지 스캔으로 전환
ALTER TABLE order_history
ADD INDEX idx_status_created (status, created_at);
-- 적용 후 반드시 EXPLAIN으로 type, key, rows 확인
EXPLAIN SELECT ... FROM order_history
WHERE status = 'PENDING' ORDER BY created_at;
그런데 인덱스를 추가하고 EXPLAIN을 다시 떠봤더니, 옵티마이저가 새로 만든 인덱스를 여전히 타지 않고 풀스캔을 고집했다. 앞서 말한 통계 문제 때문이었다. 옵티마이저 입장에선 잘못된 카디널리티 추정상 인덱스가 손해라고 판단한 것이다. 정석은 통계를 다시 수집하는 것(ANALYZE TABLE)이지만, 장애가 진행 중인 상황에서 가장 확실하고 즉각적인 조치는 인덱스를 강제로 태우는 힌트였다.
-- 옵티마이저가 인덱스를 외면할 때, 힌트로 강제
SELECT ... FROM order_history FORCE INDEX (idx_status_created)
WHERE status = 'PENDING' ORDER BY created_at;
힌트를 적용하자 효과는 즉각적이었다. 풀스캔이 사라지자 해당 쿼리의 실행 시간이 15초대에서 100ms로 떨어졌고, 락을 쥐고 있는 시간 자체가 짧아지니 뒤에서 대기하던 트랜잭션의 블로킹이 연쇄적으로 풀렸다. 인덱스와 힌트 하나가 블로킹 체인 전체를 끊은 셈이다. 적용 후 서비스는 한 시간이 아니라 며칠이 지나도 죽지 않았다.
하지만 여기서 멈추지 않았다. 한 시간을 끌다 죽는 패턴이 결국 “한 쿼리가 임계점을 넘기는 순간 전체가 무너지는” 구조라면, 비슷한 시한폭탄이 더 있을 가능성이 컸다. 그래서 자주 호출되는 쿼리 중 응답이 느린 것들을 전수 조사했다.
슬로우 쿼리 로그를 며칠 치 모아 호출 빈도와 평균 응답 시간을 교차로 봤다. 호출이 잦으면서 느린 쿼리부터 우선순위를 매겨, 락 경합을 일으킬 만한 후보들에 차례로 인덱스를 잡거나 조회 범위를 좁혔다. 당장 터지지 않았어도 언제든 같은 방식으로 풀이 고갈될 수 있는 쿼리들이었다.
다시 한다면 다르게 할 것
이 장애는 기술적으로 어려운 문제가 아니었다. 락 대기를 확인하고, 블로킹의 시작점을 찾고, 인덱스를 잡는다. 익숙한 절차다. 그런데도 3일을 끌었다. 이유는 단 하나, 초반에 “내 팀 일이 아니다”라며 소극적으로 대응했기 때문이다.
장애의 책임 소재와 원인 분석은 별개다. 웹 프로세스가 멀쩡하다는 건 “내 코드에 버그가 없다”는 뜻이지, “내가 도울 게 없다”는 뜻이 아니었다. 증상이 DB 락과 커넥션 풀을 가리키고 있었다면, 그건 소속과 무관하게 백엔드 개발자가 가장 빠르게 짚어낼 수 있는 영역이었다.
같은 상황을 다시 만난다면, 첫날 죽기 직전의 DB 세션부터 떠볼 것이다. “재시작하면 살아나고 시간이 지나면 죽는다”는 단서 하나만으로도 무언가가 쌓이고 있다는 건 충분히 의심할 수 있었다. 결국 장애를 길게 끈 건 기술의 한계가 아니라 태도의 문제였고, 그게 이번 일에서 가장 오래 남은 교훈이다.