Meloming
· 8 min read

비용을 줄이려 넣은 S3 Lifecycle 한 줄, 그리고 403이라는 가면

조현우
조현우

CEO & Fullstack Engineer

1. 다시, Vibe Infra

지난 글 Vibe Infra로 장애를 만들고, 장애에서 배우다에서는 AI로 인프라를 운영하다 DB 인증이 끊긴 이야기를 했습니다. 그때의 결론은 AI를 신뢰하는 것이 아니라 신뢰할 수 있는 경계를 만드는 것이었습니다.

이번은 성격이 조금 다릅니다. 서비스가 멈추지도 않았고, 알럿이 울릴 조건도 아니었습니다. 화면에서는 아무 일도 일어나지 않았고, 그동안 S3는 정해진 규칙을 정확히 수행하고 있었습니다. 규칙의 범위가 의도보다 넓었을 뿐입니다.

시작은 비용을 줄이려고 넣은 Lifecycle 규칙 한 줄이었습니다.


2. 증상은 엉뚱한 곳에서 나왔습니다

이 사고는 “클립이 안 열린다”는 신고로 발견되지 않았습니다. 전혀 다른 작업을 하다가 걸려 넘어졌습니다.

멜로밍에는 라이브 방송에서 신청곡 구간을 잘라 클립으로 만드는 실험적인 파이프라인이 있습니다. 이 클립을 재생할 때 쓰는 presigned URL의 발급 경로를 정리하던 중이었습니다.

그런데 검증 단계에서 이상한 조합이 나왔습니다. presigned URL은 정상적으로 발급되는데, 그 URL로 실제 객체를 받으면 계속 403 AccessDenied가 돌아왔습니다.

발급 요청 -> 200 (presigned URL 정상 발급)
해당 URL  -> 403 AccessDenied

처음에는 IAM 권한을 의심했습니다. 발급 주체에 준 S3 권한이 부족한 것 아닌가. 그런데 권한 설정에는 이상이 없었습니다.


3. 403이 가리킨 것은 권한이 아니었습니다

여기에 S3의 함정이 하나 있습니다.

s3:ListBucket 권한이 없는 주체가 존재하지 않는 객체를 요청하면, S3는 404가 아니라 403을 돌려줍니다.

객체가 있는지 없는지를 알려주는 것 자체가 정보입니다. 그래서 나열 권한이 없는 주체에게는 “없음(404)“조차 “접근 불가(403)“로 가려서 응답합니다. 응답 코드의 차이만으로 버킷 안에 어떤 키가 있는지 훑어보는 것을 막기 위한 동작입니다.

즉 이 403은 권한이 모자란다는 뜻이 아니라, 요청한 객체가 이미 그 자리에 없다는 뜻이었습니다.

여기서 방향이 완전히 바뀌었습니다. 권한을 더 파는 대신 버킷의 Lifecycle 설정을 열어봤고, 원인은 거기에 있었습니다.


4. 범인은 범위를 지정하지 않은 규칙 한 줄이었습니다

recording-buffer 버킷은 성격이 다른 두 종류의 데이터를 prefix로만 나눠 함께 담고 있었습니다.

prefix내용성격
sessions/녹화 원본 세그먼트처리가 끝나면 불필요한 임시 데이터
clips/추출된 결과물(mp4, 썸네일)그보다 오래 쓰이는 산출물

2026년 6월, 버퍼 저장 비용을 줄이려고 이 버킷에 7일 만료 규칙을 넣었습니다.

rule {
  id     = "expire-after-7-days"
  filter {}                # prefix 미지정 = 버킷 전체
  expiration { days = 7 }
}

filter {}는 prefix가 비어 있어서 버킷 안의 모든 객체에 적용됩니다. 의도는 sessions/ 버퍼를 7일 뒤에 정리하는 것이었지만, 실제 적용 범위는 clips/를 포함한 버킷 전체였습니다.

의도와 범위 사이의 이 간극이 사고의 전부입니다. 그리고 이 간극은 코드만 봐서는 잘 보이지 않습니다. 규칙은 문법적으로 정상이고 이름도 expire-after-7-days로 정직합니다. 무엇이 7일 뒤에 지워지는지를 규칙이 아니라 버킷의 실제 키 구조가 결정하기 때문입니다.

근본 원인은 규칙보다 한 단계 위에 있습니다. 지워도 되는 데이터와 지키고 싶은 데이터가 같은 버킷을 prefix로만 나눠 쓰고 있었다는 것. 버킷 단위로 동작하는 정책은 그 prefix 경계를 알지 못합니다.


5. 왜 바로 드러나지 않았을까요

규칙은 6월 말에 들어갔는데 문제가 확인된 것은 7월 5일입니다. 그사이에 신호가 없었던 이유는 마스킹이 두 겹으로 걸려 있었기 때문입니다.

첫 번째 마스킹은 앞단에 있었습니다. 파이프라인이 돌아가는 동안에는 만료로 사라진 자리를 새로 만들어진 결과물이 계속 채웠습니다. 목록에는 언제나 최근 결과물이 있으니, 오래된 것이 조용히 빠져나가도 드러나지 않습니다.

그런데 만료 규칙이 들어가기 하루 전, 비용 절감을 위해 자동 클립 파이프라인을 의도적으로 중단했습니다. 신규 공급이 끊긴 것입니다. 보충이 없는 상태에서 만료만 계속되자, 남아 있던 결과물이 생성일 순서대로 빠져나가기 시작했습니다.

flowchart LR
    A["파이프라인 중단<br/>신규 결과물 공급 정지"] --> C
    B["버킷 전체 7일 만료 규칙 적용<br/>clips/ 까지 대상 포함"] --> C
    C["보충 없이 만료만 진행<br/>생성일 순으로 드레인"] --> D["재생 불가로 표면화"]

두 결정은 각각 보면 정상적인 운영 판단입니다. 하나는 생성 중단, 하나는 버퍼 만료, 둘 다 비용을 줄이기 위한 조치였습니다. 위험은 둘 중 하나가 아니라 두 상태가 동시에 성립하는 지점에서 생겼고, 앞선 상태가 뒤이은 변경의 파괴력을 정확히 가려버렸습니다.

두 번째 마스킹은 뒷단에 있었습니다. 앞서 본 403입니다. 막상 재생이 깨졌을 때도 에러는 “객체 없음”이 아니라 “접근 불가”로 나타났기 때문에, 처음에는 데이터가 사라진 문제가 아니라 권한 문제처럼 보였습니다.

잠복한 문제가 어려운 이유가 여기에 있습니다. 원인이 심긴 시점과 증상이 나타나는 시점이 다르고, 그 증상마저 다른 얼굴을 하고 나타납니다.

영향 범위는 이렇습니다. 만료 기준인 생성 후 7일을 지난 추출 클립은 재생할 수 없는 상태가 됐습니다. 클립 메타데이터와 CDN 썸네일은 별도 경로에 있어 그대로 남았고, 버킷에 아직 남아 있던 결과물은 조치로 보존했습니다. 재생 외의 서비스 기능에는 영향이 없었고, 클립은 원본 방송에서 자동 생성되는 파생 결과물이며 원본 방송 자체는 외부 스트리밍 플랫폼에 있습니다.


6. 봉합

되돌릴 수 없는 삭제 앞에서 할 수 있는 일은 두 가지입니다. 더 지워지지 않게 막는 것, 그리고 같은 규칙이 다시 돌아오지 않게 못 박는 것.

먼저 출혈을 멈췄습니다. 만료 범위를 버킷 전체에서 sessions/ prefix로 한정했습니다.

rule {
  id     = "expire-sessions-buffer-after-7-days"
  filter { prefix = "sessions/" }   # sessions/ 만 만료
  expiration { days = 7 }
}

이렇게 하면 원래 의도했던 비용 목적은 그대로 달성됩니다. 용량을 차지하던 쪽은 녹화 원본 버퍼였고 추출 결과물은 그에 비해 훨씬 작았습니다. 지켜야 할 것은 작고 지워도 되는 것이 컸으므로, 결과물을 만료 대상에 넣을 이유가 처음부터 없었습니다.

그다음 이 변경을 Terraform 코드에 반영했습니다. 라이브 설정만 고치고 코드를 그대로 두면, 다음에 누군가 terraform apply를 하는 순간 옛 규칙이 그대로 원복됩니다. 고쳐놓은 것이 되돌아올 경로를 남겨두는 것은 고치지 않은 것과 크게 다르지 않습니다.


7. 남는 교훈

1. 공유 버킷에 버킷 전체 만료 규칙을 걸지 않습니다. 한 버킷에 임시 데이터와 지켜야 할 데이터가 prefix로만 나뉘어 공존한다면, 전체 만료 규칙은 그 경계를 무시합니다. 만료는 반드시 지워도 되는 prefix로 범위를 좁히고, 더 나아가 임시 버퍼와 결과물은 애초에 다른 버킷으로 분리하는 편이 안전합니다.

2. 되돌릴 수 없는 삭제에는 되돌릴 수 있는 안전망을 답니다. 버저닝이나 별도 백업이 있었다면 이 글은 훨씬 짧게 끝났을 것입니다. 복구가 불가능한 데이터가 들어올 수 있는 버킷인지 아닌지는 버킷을 만드는 시점에 판단해야 합니다.

3. 비용 절감 변경일수록 blast radius를 의심합니다. 사고를 만드는 것은 대담한 변경이 아니라 “안 쓰는 거 좀 정리하자”는 작은 최적화인 경우가 많습니다. 지우는 규칙은 언제나 무엇에 매칭되는지를 실제 키 구조와 대조한 뒤에 적용합니다.

4. 각각 안전한 두 변경이 만나면 위험해질 수 있습니다. 생성 중단도 버퍼 만료도 각각은 안전했습니다. 위험은 둘의 상호작용에서 생겼고, 한쪽이 다른 쪽의 증상을 가렸습니다. 변경을 검토할 때는 그 변경 자체만이 아니라 지금 시스템의 다른 상태와 어떻게 만나는지를 함께 봐야 합니다.

5. 403은 404의 가면일 수 있습니다. ListBucket 권한이 없으면 객체의 부재가 접근 거부로 나타납니다. 예상하지 못한 403을 만나면 권한을 파고들기 전에 대상 객체가 실제로 존재하는지부터 확인하세요.

그리고 지난 글의 결론은 여기서도 그대로입니다. 이번 사고의 추적과 봉합도 AI와 함께 진행했고, 하나의 403에서 근본 원인까지 역추적하는 일에서 AI는 빠르고 성실했습니다. 하지만 “이 버킷에는 성격이 다른 데이터가 섞여 있으니 전체 만료 규칙은 위험하다”는 판단은 문서가 아니라 우리 환경의 맥락에서 나옵니다. 그 맥락은 여전히 사람이 채워야 합니다.

지울 수 있는 권한에는, 되돌릴 수 있는 안전망을 함께 답니다.