세 줄 요약
특정 신청을 승인하면 안내 이미지가 붙은 문자가 나가야 하는데, 문자를 받지 못했다는 제보가 들어왔어요. 서버를 아무리 뒤져봐도 500도, 에러 알림도, 실패 카운트도 없었고 발송 기록의 상태값마저 곧 나갈 듯 평범한 발송 대기였어요.
문자는 안 갔는데 시스템은 어디에서도 아프다는 신호를 보내지 않았어요. 이 조용함이야말로 이번 장애의 정체였어요.
핵심 사실
첫 번째 실패: 레거시 스킴으로 시작하지 않으면 앞에 버킷을 붙이는 파서
우리는 파일 저장소를 한 클라우드의 오브젝트 스토리지에서 다른 스토리지로 옮기고 있었어요. 그 과정에서 파일 저장 경로의 스킴이 legacy://에서 new-storage://로 바뀌었어요. 업로드도 화면 표시도 멀쩡해서 마이그레이션에는 문제가 없어 보였어요.
문제는 문자 발송 코드에 있었어요. MMS를 보내려면 서버가 첨부 이미지를 먼저 내려받아야 하는데, 이 코드는 저장 경로 문자열을 레거시 스토리지 전용 클라이언트에 그대로 넘기고 있었어요. 그 클라이언트에는 이런 편의 로직이 들어 있었어요.
resolveLegacyObject(resourceLocation) {
// legacy:// 로 시작하지 않으면, 기본 버킷을 앞에 붙여준다
if (!resourceLocation.startsWith('legacy://'))
resourceLocation = `legacy://${LEGACY_DEFAULT_BUCKET}/${resourceLocation}`;
...
}
버킷 이름을 빼먹고 경로만 넘겨도 알아서 채워주려는 좋은 의도였어요. 하지만 이제 들어오는 값은 경로가 아니라 new-storage://로 시작하는, 스킴부터 다른 URL이었고 결국 이렇게 조립됐어요.
입력: new-storage://new-bucket/originals/.../image
조립: legacy://LEGACY_DEFAULT_BUCKET/new-storage://new-bucket/originals/.../image
있을 리 없는 경로가 만들어지자 스토리지는 당연히 No such object를 돌려줬어요. 이 에러는 발송 워커 로그에 꼬박꼬박 찍혔지만, 그 로그를 상시로 들여다보는 사람이 없었을 뿐이에요. 발송 기록에는 여전히 발송 대기만 남았어요.
스킴이 바뀐 시점과 문자가 끊긴 시점은 정확히 겹쳤어요. legacy://로 저장하던 마지막 달까지는 멀쩡했고, 새 스킴으로 넘어간 첫 달부터 전부 대기에 고착됐어요.
이번 장애를 가장 오래 숨긴 건 잘못된 입력을 거부하는 대신 그럴듯하게 고쳐 넘긴 친절한 로직이었어요. 관대한 파서는 틀린 값을 에러가 아닌 이상한 성공으로 바꿔 놓거든요.
두 번째 실패: 고쳤더니 일부 모바일 기기 사진만 또 안 나갔다
원인을 찾은 뒤 URL을 스킴 규칙에 따라 파싱해 provider·버킷·경로로 나누고, 각 스토리지에 맞춰 내려받도록 고쳤어요. 배포 후 문자가 실제로 나가는 것까지 확인했으니 끝난 줄 알았어요.
그런데 배포한 뒤에도 HEIC 첨부(일부 모바일 기기가 기본으로 찍는 사진 형식)만 계속 실패했어요. 원인은 파일 목록을 내려주는 코드에 숨어 있던 화면 표시용 임시방편이었어요. 브라우저가 HEIC를 직접 열지 못하니 파일이 HEIC이면 저장 경로를 덮어써서 변환 엔드포인트로 바꿔치기하고 있었어요.
if (파일이 HEIC 이미지) {
displayResourceLocation = `https://example.invalid/files/${fileIdentifier}`; // 프론트 표시용 hotfix
}
주석에는 “프론트가 전부 전환되면 지울 코드”라고 적혀 있었어요. 새 파서에는 이 https:// 값이 지원하지 않는 스킴이라 곧장 튕겨 나갔어요. 화면 표시를 위해 심어둔 hotfix가 몇 달 뒤, 아무 상관도 없는 문자 발송 경로에서 터진 셈이에요.
다행히 이 코드가 덮어쓴 건 표시용 저장 위치 하나뿐이었고, 원본 좌표(provider·버킷·경로)는 그대로 들고 있었어요. 그래서 덮어쓴 문자열이 아니라 원본 좌표를 바로 넘기도록 고쳤어요.
“나중에 지울 코드”가 왜 무서운지 이번에 실감했어요. 그런 코드는 자기가 태어난 곳이 아니라 아무도 예상하지 못한 먼 경로에서 조용히 기다리다가 터져요.
세 번째 실패: 발송 완료로 기록되는 미발송, 그리고 나도 속았다
이 글을 쓰려고 운영 데이터에서 장애 규모를 다시 뽑다가 가장 뜨끔한 사실을 발견했어요. 나는 수정 PR에 “대상 전체가 미발송”이라고 적어뒀는데, 다시 세어보니 storage 버그로 실제 막힌 건 그중 일부였어요.
큰 차이는 storage 버그와 무관한 크레딧 부족 건에서 생겼어요. 문자를 보내려면 발송 크레딧이 필요한데, 잔액이 없어 애초에 나갈 수 없던 문자들이었어요. 그 많은 건을 그저 “다른 원인”으로 넘길 일도 아니었어요. storage 버그를 쫓다가 덤으로 발견한 또 하나의 조용한 실패였어요.
크레딧이 없어 문자를 보내지 못하는 것 자체는 버그가 아니에요. 문제는 그때 코드가 상태를 남기던 방식이었어요.
// 크레딧 부족으로 발송 불가
messageDeliveryStatus = '발송 완료'; // 메시지 단위: 보냄
recipientDeliveryStatus = '발송 거부'; // 수신자 레코드: 거부(크레딧 부족)
메시지 단위 상태는 발송 완료였어요. 발송 대장을 메시지 기준으로 훑으면 이 건들은 “성공”으로 보여요. 실제로는 한 통도 나가지 않았는데 미발송이 성공처럼 집계에 남는, 관측의 구멍인 셈이에요.
이 구멍의 첫 희생자는 바로 나였어요. 나는 발송 완료 상태를 성공으로 세다가 거꾸로 미발송 집계에도 잘못 합쳐 전체 수치를 틀리게 만들었어요. 조용한 실패의 함정을 설명하려고 글을 쓰면서 정작 그 함정에 내가 먼저 빠져 있었어요.
storage 버그 하나를 파고들다가 실패를 성공처럼 남기는 상태값 마킹이라는 두 번째 구멍까지 함께 드러났어요. 세 실패는 생김새가 모두 달랐지만 공통점은 하나였어요. 전부 “보이는 상태”와 “실제”가 어긋나 있었어요.
정리
실패는 시끄럽게 남겨야 한다
코드보다 더 오래 고민한 건 수정 방향이었어요. 첨부 URL을 해석하지 못했을 때 이미지를 빼고 일반 문자로라도 보낼 것인가, 아니면 발송 자체를 실패시킬 것인가.
결론은 발송 자체를 실패시키는 쪽이었어요. 이미지를 조용히 빼고 보내면 그 건은 또 발송 완료로 찍히고, 이 장애는 다음 마이그레이션 때 똑같은 얼굴로 돌아올 테니까요. 실패는 시끄러워야 반복을 막을 수 있어요.
해석에 실패하면 메시지 식별자·파일 식별자·문제가 된 URL을 에러에 담고 발송을 멈추도록 했어요. 원인을 오히려 가리던 “지원하지 않는 스킴: https” 같은 메시지도 바꿨어요. 시스템에 보이는 상태를 실제 결과와 어긋나지 않게 만들고, 실패했을 때 누구나 그 사실과 원인을 알아볼 수 있도록 남겨야 한다는 게 이번 장애에서 얻은 교훈이에요.