공급자 사고의 대응은 키를 다시 발급하는 일로 끝나지 않는다. 같은 값이 어디에 쓰이고, 어떤 순서로 바뀌어야 서비스가 살아남는지 알아야 한다.
TL;DR
- 2026년 10월 21일(UTC), Railway가 고객 환경변수 저장 시스템의 비정상 활동을 알렸다.
- 일부 고객 환경변수가 복호화돼 유출됐을 가능성이 있다는 공지였다.
- 우리 AI 백엔드가 Railway에 있었고, 유출 범위는 그 서비스의 환경변수였다.
- 같은 비밀값이 Vercel과 로컬
.env에도 있어 Railway만 고쳐서는 대응이 끝나지 않았다. .env*의 비밀 후보 192개를 살펴 53개를 비밀로 분류했고, 교체 대상 41개를 정리했다.- Railway에 실제로 이름이 있던 유출 항목은 별칭을 포함해 21개 묶음이었다.
- 10월 24일 대응 시간에 교체한 비밀은 20개였다. DB 비밀번호는 전날 이미 바꿨다.
- 자동 클립보드 감시가 콘솔 주소를 비밀번호로 받아들여 AI 백엔드에 약 45분 장애를 냈다.
- 키마다 교체 순서를 나누고 웨이브로 배포해야 한 번의 사고가 연쇄 장애로 번지지 않는다.
41개는 교체 대상 목록의 크기이고, 20개는 당일 실제로 바꾼 수다. 두 숫자를 같은 뜻으로 쓰지 않았다.
1. 사건과 우리에게 닿은 범위
공급자 공지가 말한 것
10월 21일(UTC), Railway는 고객 환경변수 저장 시스템에서 비정상 활동을 탐지했다고 공지했다. 일부 고객 환경변수가 복호화돼 유출됐을 가능성이 있다는 설명이었다. Railway는 배포를 잠시 중단했다가 재개했고, 사고 전 발급된 API·프로젝트 토큰을 무효화했다. 버킷 자격증명도 교체했다. 여러 공급자와 협력해 노출 가능성이 있는 키를 선제 폐기했고, 사고 전 가입 고객에게 Railway에 저장한 키를 바꾸라고 권고했다. 이 자료만으로 공급자 내부의 침투 경로나 공격자가 환경변수를 읽은 방법을 확인할 수는 없다.
보도에는 Railway에 보관된 암호화폐 지갑 키가 탈취돼 약 37만 달러가 유출됐다는 주장이 나왔다. 탈취된 LLM API 키가 거래된다는 주장도 보도됐다. 두 내용은 제3자 보도에 담긴 주장이다. Railway가 확인한 사실과 구분해야 한다.
우리 서비스에 닿은 경로
AI 백엔드는 Railway에서 실행했다. API 서버와 웹은 Vercel에, 데이터베이스는 관리형 Postgres에, 캐시는 관리형 Redis에 있었다. 공급자 사고가 직접 닿은 범위는 Railway의 AI 백엔드 환경변수였다.
그러나 환경변수의 이름만 플랫폼별로 달랐을 뿐 같은 비밀값을 여러 곳에서 재사용하고 있었다. 따라서 사고 출처가 Railway라고 해서 Railway의 값만 바꾸면 끝나는 상황은 아니었다. 외부에서 그 값이 실제 악용됐는지는 이 글에서 단정하지 않는다.
접근 흔적을 확인할 감사 로그 검토와 신고 판단은 별도의 후속 작업으로 남겼다.
공급자 공지가 확인한 범위, 우리 서비스가 공유한 자격증명, 실제 악용 여부는 각각 분리해 기록해야 한다.
2. 왜 “키 하나 바꾸기”가 아니었나
저장 위치보다 사용 관계가 중요했다
한 운영 비밀값은 Railway 변수 하나로 끝나지 않았다. API·웹 배포 환경에도 같은 값이 들어 있었다.
┌─ Railway: AI 백엔드
│
같은 운영 비밀값 ────────┼─ Vercel: API 서버
├─ Vercel: 웹
└─ 로컬 .env 사본
└─ 일부는 운영 DB·Redis에 연결
하나의 키를 바꾸는 작업은 여러 플랫폼·파일·서비스에 걸친 변경이었다. 한 곳에서만 새 값으로 바꾸면 배포끼리 서로 다른 자격증명을 쓰게 된다. 반대로 모든 값을 한꺼번에 덮으면 개발용 값까지 운영에 올라갈 수 있었다.
이름이 다르면 같은 값인 줄 모른다
Railway에서는 한 이름으로, Vercel에서는 다른 이름으로 같은 값을 가리키는 변수가 있었다. .env에도 서로 다른 이름으로 복사된 별칭이 남아 있었다. 이름만 비교하면 같은 비밀을 공유하는 사용처를 놓친다.
이번 인벤토리는 이름과 함께 기존 운영값의 SHA-256 지문을 비교했다. 지문이 같은 값은 이름이 달라도 한 교체 묶음으로 연결했다. 화면에는 원래 값 대신 길이와 짧은 지문만 표시했다.
숫자는 서로 다른 집합을 가리킨다
저장소의 .env* 파일에서 비밀 후보 이름 192개를 찾았다. 이 중 53개를 비밀 등급으로 분류했고, 사용처와 영향도를 확인해 교체 대상 41개를 정리했다. Railway 환경변수에 실제로 있던 유출 대상은 별칭을 포함해 21개 묶음이었다.
41개는 교체 대상 목록, 21개는 사고 출처에서 확인한 항목 묶음이다. 대응 당일에 바꾼 비밀은 20개였다. DB 비밀번호는 사고 전날 이미 교체한 상태였다.
인벤토리 숫자 하나만 보고 “유출된 키 수”나 “완료한 교체 수”라고 부르면 실제 범위를 왜곡한다.
키 인벤토리는 변수 이름 목록이 아니라 값의 재사용 관계와 사용처를 보여줘야 한다.
3. 무엇부터 바꿀까: 공격자의 현재 권한
우리는 “이 키를 가진 공격자가 지금 당장 무엇을 할 수 있나”를 기준으로 순서를 정했다. 키의 이름이나 발급한 업체보다 공격에 쓰일 수 있는 권한이 우선순위를 정했다.
| 등급 | 착수 시점 | 영향 예시 |
|---|---|---|
| A | 즉시 | 계정·민감 데이터·파일·DB 접근 또는 내부 권한 악용 |
| B | 당일 | 직접 과금, 결제, 대량 발송, 외부 API 사용 |
| C | 주내 | 통역·번역·로그인·스토어 연동 기능 영향 |
| 보류 | 별도 전환 설계 후 | 데이터 식별·복호화·사람이 기억하는 기준값 |
A — 계정과 데이터에 바로 닿는 키
JWT 서명 키가 있으면 공격자는 임의 계정의 토큰을 만들 수 있다. Firebase Admin 서비스 계정은 민감 데이터 읽기·쓰기에 닿을 수 있다. AWS 액세스 키는 S3 파일 접근으로 이어질 수 있다.
비밀번호 재설정 링크 암호화 키가 있으면 계정 탈취 위험이 생긴다. 서버 간 내부 API 키와 DB 비밀번호도 A급으로 두었다. DB 비밀번호는 전날 바꿨으므로 당일 교체 웨이브에서는 완료된 항목으로 관리했다.
B — 과금과 대량 악용
결제 API, LLM API 세 곳, 메일 발송 API, 실시간 음성인식 API, Redis를 B급으로 분류했다. 이 값이 악용되면 직접 비용이 발생하거나 대량 발송·서비스 자원 사용으로 이어질 수 있었다.
C — 특정 연동 기능에 영향
WebSocket 토큰, TTS·스토어 제출 서비스 계정, 번역 API, 실시간 통화 SDK 인증서가 여기에 들었다. Google·Facebook 로그인 시크릿과 App Store Connect 키도 C급으로 분류했다. 이 등급은 위험이 없다는 뜻이 아니다.
A·B급을 먼저 안정화한 뒤 각 연동의 실제 영향과 교체 순서를 확인한다는 뜻이다.
보류 — 데이터 의미가 키에 묶여 있는 경우
과금 원장 해시 키를 바로 바꾸면 기존 거래의 HMAC 값과 새 거래의 값이 달라질 수 있다. 그 결과 중복 차감 방지 로직이 같은 거래를 알아보지 못할 수 있었다. 저장된 결제수단 암호화 키는 기존 데이터를 재암호화하기 전까지 폐기할 수 없었다.
사람이 기억해야 하는 마스터 비밀번호 기준값도 일반 API 키처럼 바꾸면 안 됐다. 민감 데이터에 닿는 키가 유출됐다면 GCP·Firebase·AWS 감사 로그에서 외부 접근을 확인해야 한다. 개인정보 유출 신고 의무와 72시간 기준의 적용 여부도 담당자가 검토해야 한다.
여기서는 법률 적용 여부를 단정하지 않는다.
긴급도는 키의 이름이 아니라 그 키로 가능한 행동과 피해 범위로 정한다.
4. 교체 유형 여섯 가지
같은 “비밀번호 교체”라도 새 값 발급 방식과 옛 값이 살아 있는 시간이 다르다. 교체 순서를 정하기 전에 키를 아래 여섯 유형 가운데 하나로 분류했다.
4.1 자동 발급 — 새 값을 먼저 만들 수 있다
CLI나 클라우드 도구가 새 값을 발급하는 유형이다. 직접 생성한 랜덤 비밀, Google Cloud 서비스 계정 키, API 키, AWS IAM 키가 예다.
새 키 발급 ──> 대상 환경에 반영 ──> 배포 1회 ──> 작동 확인 ──> 옛 키 비활성화
옛 키 사용 가능 ───────────────────────────────┘
배포 후 새 키의 작동을 확인하고 나서 옛 키를 비활성화한다. 가능하면 즉시 영구 삭제하지 않고 되돌릴 수 있는 상태 전환을 택한다. 예를 들어 서비스 계정 키는 비활성화하고, API 키는 30일 복구 기간이 있는 soft delete를 쓰며, AWS 키는 Inactive로 둔다.
확인 후 되돌릴 수 있는 폐기 동작까지 포함하면 계획된 교체 중 끊김은 없었다.
4.2 겹쳐 쓰기 — 두 키를 잠시 함께 살린다
공급자가 새 키를 추가해도 기존 키를 유지하는 유형이다. LLM API, PayPal의 두 번째 시크릿, Google OAuth 추가 시크릿, 통화 SDK 보조 인증서, App Store Connect, 메일 발송 API, 음성인식 API가 해당했다.
옛 키: ────────────────────────────────> 마지막에 삭제
새 키: 발급 ──> 반영 ──> 배포 ──> 확인
새 키를 먼저 만들고, 업체 API로 확인한 다음 환경에 반영한다. 배포와 작동 확인이 끝난 뒤에야 옛 키를 삭제한다. 두 자격증명을 잠깐 겹쳐 쓰는 방식은 전환 시간을 확보해 무중단 교체를 돕는다.
이번 대응에서는 음성인식 API의 옛 키를 새 키 배포보다 먼저 지웠다. 사전 확인에서 새 키는 HTTP 200, 운영에 들어 있던 옛 키는 403을 반환했다. 즉 배포가 끝날 때까지 운영의 음성인식 호출이 거부될 수 있는 상태였다. 새 키를 이미 검증해 둔 덕분에 확인 직후 배포했고, 약 1분 30초 뒤 운영이 새 키로 바뀌었다. 옛 키를 언제 지웠는지 기록이 없어 실제 단절 시간은 측정하지 못했다.
4.3 미리 정하기 — 우리가 새 값을 정하고 업체에서 바꾼다
새 값을 우리가 정할 수 있지만 업체에서 저장하는 순간 옛 값이 무효가 되는 유형이다. 관리형 Postgres 비밀번호가 여기에 해당했다.
새 비밀번호 생성
│
├──> Railway·Vercel·필요한 .env에 먼저 반영
│
└──> 배포 시작 ── 약 60초 ── DEPLOYING
│
└──> 업체에 새 비밀번호 저장
새 인스턴스 연결 확인
이 순서는 사고 전날 DB 비밀번호를 먼저 바꿀 때 CTO가 직접 고안해 실행한 방법이다. 모든 대상 환경에 새 값을 먼저 넣고 배포를 시작한 뒤, 배포가 진행되는 동안 업체 콘솔에서 같은 값으로 저장했다. 관찰된 서비스 중단은 약 2~3초였다.
이 방법이 통한 이유는 세 가지다. Railway는 새 인스턴스가 헬스 체크를 통과할 때까지 기존 인스턴스에 트래픽을 유지한다. Postgres는 비밀번호를 바꿔도 이미 맺은 세션을 끊지 않는다. 새 인스턴스는 DB 연결을 재시도하므로, 그 사이 업체 값이 바뀌면 연결에 성공한다. 반대로 업체에서 먼저 바꾸면 새 배포가 끝날 때까지 모든 서버가 DB를 쓰지 못한다.
이는 당시 우리 구성에서 확인한 동작이다. 다른 DB·배포 조합에서 같은 결과를 보장하지 않는다.
4.4 업체 먼저 — 기존 연결이 버티는 동안 값을 바꾼다
새 값을 먼저 배포하면 클라이언트가 다시 연결하지 못할 수 있는 유형이다. 관리형 Redis 비밀번호가 그랬다.
기존 인스턴스 ───── 기존 인증 연결 유지 ─────────────────┐
업체 비밀번호 변경 ──> 새 값으로 인증 확인 ──> 즉시 배포 ──┴─> 새 연결
우리 Redis 클라이언트는 약 3초 동안 열 번 재연결을 시도한 뒤 포기했고, 이후 다시 시도하지 않았다. 따라서 미리 정하기 유형처럼 새 값을 먼저 배포하면 새 인스턴스가 Redis에 연결하지 못한 채 남을 수 있었다. 업체에서 먼저 비밀번호를 바꾸면 이미 인증된 연결은 유지됐다.
새 값으로 AUTH만 확인하고 곧바로 배포했다. 당시 실측에서 오류는 0건이었고 새 인스턴스는 Redis 클라이언트 준비 완료 상태가 됐다. 같은 비밀번호 교체라도 클라이언트의 재연결 정책이 순서를 바꾼다.
4.5 즉시 무효 — 업체가 만든 랜덤 값을 바로 반영한다
Facebook 앱 시크릿처럼 업체가 새 값을 만들고 재설정 즉시 옛 값이 무효가 되는 유형이다. 새 값을 미리 정하거나 두 값을 겹쳐 쓸 수 없다.
사용량이 적은 시간 선택 ──> 업체에서 재설정
│ 옛 키 즉시 무효
└─> 새 값 가져오기 ──> 즉시 반영·배포 ──> 로그인 확인
사용량이 적은 시간을 고르고 재설정과 배포 사이를 최대한 짧게 만든다. 해당 로그인 기능은 약 1~2분 끊길 수 있었다. 이 시간 동안 다른 로그인이나 서비스 전체가 중단됐다고 확대해 말하지 않는다.
4.6 데이터 결합 — 일반 교체를 멈추고 버전을 도입한다
키가 이미 저장된 데이터의 식별값이나 복호화에 쓰인다면 즉시 폐기할 수 없다. 이 유형은 일반 교체 목록에서 보류했다.
쓰기: v1 ──> v1·v2 동시 검증 ──> v2 기록
읽기: v1·v2 모두 판별 ─────────> 데이터 이관 완료 ──> v1 폐기
HMAC 키가 과금 원장의 거래 식별에 쓰이면 키 교체가 중복 차감 방지에 영향을 줄 수 있다. 암호화 키라면 새 키로 저장 데이터를 재암호화하는 절차가 먼저 필요하다. 키 버전 필드를 두고 v1·v2를 병행 검증한 뒤 데이터를 이관하는 방식이 안전한 출발점이다.
이 설계를 처음부터 적용하지 못한 점은 다음 개선 항목으로 남았다.
교체 버튼을 누르기 전에 유형을 정해야 배포 순서와 중단 시간을 예측할 수 있다.
5. 로컬 키 교체 콘솔
사고 당일 만든 콘솔은 키 인벤토리와 교체 작업을 한 화면에서 다루는 로컬 도구였다. 오전의 v1에서 생긴 오염 사고를 반영해 오후에 v2를 설계했다.
로컬에서만 열리는 화면
서버는 127.0.0.1에만 바인딩했다. 실행할 때마다 새 접근 토큰을 만들고 URL에 붙였다.
http://127.0.0.1:<PORT>/?t=<ONE_TIME_TOKEN>
요청의 Host와 Origin도 확인해 DNS 리바인딩과 CSRF 위험을 줄였다. 콘텐츠 보안 정책은 default-src 'none'으로 시작했다. Node 내장 모듈만 사용했고 화면은 단일 HTML로 구성했다.
값은 화면·로그·상태 파일에 남기지 않는다
비밀값은 화면에 출력하거나 로그·상태 파일에 저장하지 않았다. 화면에는 값의 길이와 SHA-256 지문 앞 6자만 보였다. CLI에는 값을 명령행 인자로 넘기지 않고 표준 입력으로 전달했다.
이렇게 하면 프로세스 목록과 셸 기록에 값이 들어갈 가능성을 줄일 수 있다. 클립보드는 한 번 읽은 직후 비웠고, 오류 메시지에 알려진 비밀 문자열이 섞이면 hidden으로 치환했다.
업체에서 받은 값
│
└── 표준 입력 ──> 공급자 CLI
값 자체는 화면·로그·상태 파일에 저장하지 않음
값의 지문으로 결합 관계를 찾는다
콘솔은 기존 운영값의 SHA-256 지문을 비교해 이름이 다른 같은 값을 찾았다. Railway 변수와 로컬 .env를 값 기준으로 연결했다. Vercel은 연결된 변수 이름을 기준으로 같은 사용처를 찾았다.
한 번의 교체가 관련된 환경 모두에 반영되도록 묶었다. 로컬 .env는 기존 운영값의 지문이 같은 줄만 바꿨다. 개발용으로 다른 값을 쓰는 줄은 그대로 두었다.
우선순위 웨이브와 배포 횟수
A·B·C 등급을 웨이브로 묶었다. 각 변수 변경마다 자동 배포하지 않도록 --skip-deploys로 반영을 모았다. 한 웨이브에서 대상 값을 반영하고, 배포 한 번과 점검을 거친 뒤 옛 키를 폐기했다.
키마다 따로 배포하면 서버가 열 번 넘게 재시작될 수 있었다.
반영 전에 작동을 확인한다
검증할 수 있는 키는 업체의 무료 조회 API나 인증 요청으로 새 값이 작동하는지 먼저 확인했다. LLM API는 모델 목록, 결제는 OAuth 토큰, 음성인식 API는 모델 목록을 조회했다. Redis는 데이터 명령 없이 AUTH만 보냈다.
확인에 실패하면 운영 환경에 반영하지 않았다. 검증 API가 없는 키는 자동으로 정상 처리하지 않고 배포 후 로그 점검 대상으로 표시했다.
배포 성공과 서비스 정상은 다르다
배포 후에는 health 응답과 플랫폼 로그를 점검했다. DB 인증 실패, Redis WRONGPASS, API 키 인증 오류, OAuth 실패, AWS 액세스 키 오류처럼 유형별 패턴을 세었다. 저장한 것은 패턴별 줄 수뿐이었다. 로그 원문은 상태 파일에 남기지 않았다.
실제로 DB 인증에 실패해 AI 백엔드가 기동하지 못하는 동안에도 Railway 배포 상태는 SUCCESS였다. 배포 상태나 헬스 응답 하나만으로 확인을 끝내면 놓칠 수 있다.
로컬 값은 미리보기 뒤 이름을 지정해 반영한다
새 값을 .env에 넣으면 콘솔에서 먼저 변경될 키와 지문을 미리 볼 수 있게 했다. 그다음 반영할 변수 이름을 직접 지정했다. 실제 미리보기는 운영값이 아닌 로컬 개발용 WebSocket 토큰 키를 찾아냈다.
이름 지정 단계가 없었다면 그 값이 운영 환경에 반영될 뻔했다.
콘솔의 목적은 키 교체를 빨리하는 데 그치지 않는다. 잘못된 값이 조용히 들어가는 경로를 막아야 한다.
6. 우리가 낸 장애와 교정
자동 감시가 주소를 비밀번호로 읽었다
콘솔 v1은 업체 페이지를 열고 사용자가 값을 복사하면 클립보드를 감시해 자동 반영하는 흐름이었다. 검사는 “문자 16자 이상, 공백 없음” 정도로 느슨했다. DB 단계에서 사용자가 복사한 콘솔 자신의 주소가 DB 비밀번호로 받아들여졌다.
Redis 단계에서는 Redis 관리 페이지 주소가 Redis 비밀번호로 들어갔다. URL도 길고 공백이 없었다. 형식만 본 검사는 그 값을 막지 못했다.
클립보드 자동 감시
│
├─ 콘솔 주소 ─────> DB 비밀번호로 반영
└─ Redis 페이지 주소 ─> Redis 비밀번호로 반영
│
└─> AI 백엔드 재배포
DB 인증 실패·기동 불가
약 45분간 AI 백엔드가 중단됐다
장애는 10월 24일 09:18부터 10:05 KST까지 이어졌다. 약 45분 동안 AI 백엔드가 DB 인증에 실패해 기동하지 못했다. API 서버는 Vercel 팀 범위를 지정하지 않아 별도 재배포가 실패했다.
그 결과 이전 API 배포가 계속 살아 있었다. 이는 계획한 보호 장치가 아니라 우연히 남은 안전망이었다. 사용자가 올바른 DB 암호를 .env에 다시 넣고 지문과 형식을 확인했다.
올바른 값을 Railway와 Vercel에 반영한 뒤 AI 백엔드만 다시 배포해 복구했다.
v2에서 입력 경로를 바꿨다
클립보드 자동 감시를 완전히 없앴다. 사용자가 명시적으로 “가져오기” 버튼을 눌렀을 때만 한 번 읽는다. PEM 형식은 예외로 두고 URL·주소 형식을 거부하며 키마다 별도의 패턴을 적용했다.
가능한 키는 업체 API로 작동을 확인한 뒤 반영한다. 검증할 수 없는 키는 사후 로그에서 확인하도록 표시한다. 사용자 편의를 높인 자동화가 잘못된 값을 오래 붙잡으면, 조용한 오염 경로가 된다.
장애 원인을 입력값이 아니라 자동 감시와 느슨한 검증의 조합에서 찾고, 그 경로를 제거했다.
7. 현장에서 만난 작은 함정
환경 변수 옵션은 플랫폼마다 다르다
Vercel의 --sensitive 옵션은 개발 환경까지 대상으로 하는 변수와 함께 사용할 수 없었다. 중간에 변수 반영이 실패해 옵션을 빼고 다시 진행했다.
재배포에는 팀 범위가 필요했다
Vercel 재배포에서 팀 범위를 지정하지 않자 “Deployment belongs to a different team” 오류가 났다. 배포 CLI도 현재 계정과 프로젝트의 팀 범위를 명시해야 했다.
AWS CLI가 대화형 입력에서 멈췄다
AWS 로그인 과정은 리전을 대화형으로 물었다. 자동화 화면을 여러 번 눌러 같은 대기 프로세스 다섯 개가 남았다. 리전과 환경 설정은 비대화형으로 명시하고, 재시도 버튼에도 중복 실행을 막는 장치가 필요했다.
음성인식 API는 리전이 맞아야 했다
일본 리전 키를 미국 엔드포인트에 보내면 정상 키도 401을 반환했다. 검증 주소와 실제 사용 리전을 맞추고 나서야 키를 판단할 수 있었다.
새 키의 접두사가 익숙하지 않을 수 있다
LLM 콘솔에서 발급한 새 키는 기존에 예상하던 접두사와 달랐다. 키 문자열의 모양만 보고 거부하지 않고 업체의 작동 확인 결과를 기준으로 삼았다.
서비스 계정 키에는 다른 소비처가 있었다
스토어 제출용 서비스 계정 키는 서버 말고도 모바일 앱 자동 제출 같은 다른 자동화가 함께 쓰고 있을 수 있었다. 우리는 이 점을 옛 키를 비활성화한 뒤에야 떠올렸다. 비활성화 전에 같은 키의 다른 소비처를 먼저 찾아야 한다. 다행히 비활성화는 되돌릴 수 있는 동작이었다.
App Store Connect 키 파일은 다시 받을 수 없었다
키 파일은 한 번만 내려받을 수 있었다. 키를 폐기하기 전에 안전한 백업을 확인하고 교체 뒤 백업을 갱신해야 한다.
공급자별 CLI 옵션, 리전, 공유 소비처, 키 백업 방식은 사전 점검표로 고정해야 한다.
8. AI 에이전트와 함께 대응한 방식
대응 과정에서 AI 코딩 에이전트는 인벤토리 분석, 로컬 콘솔 작성, 배포 상태와 로그 점검 자동화를 도왔다. 에이전트 실행 환경의 안전장치는 에이전트가 비밀 저장소에 직접 값을 쓰는 동작을 차단했다. 우회하지 않고 도구의 역할을 바꿨다.
에이전트는 검증과 반영 도구를 만들고, 비밀값을 입력하는 단계는 사람이 버튼을 누르거나 명시 승인 뒤 실행하게 했다. 값은 에이전트 화면에 표시하지 않고 지문만 확인했다. 오류 메시지와 상태 기록에서도 원래 값을 숨겼다.
콘솔 v2는 명세를 먼저 쓰고 서버·화면·런북 작업을 세 에이전트에 나눠 맡겨 약 1시간 만에 만들었다. 메인 작업자는 명세와 결과를 대조하고 실제 통합 동작을 확인했다. 사고 정보 조사에서는 다른 문제가 있었다.
처음 맡긴 무료 리서치 에이전트는 사용 한도를 넘겨 멈췄다. 대체 에이전트는 사고를 확인할 수 없다고 잘못 보고했다. 공식 공지와 상태 정보, 보도를 직접 대조해 오류를 바로잡았다.
사고 사실은 1차 출처를 먼저 확인해야 한다.
에이전트는 비밀을 대신 다루는 주체가 아니라 사람이 통제할 도구와 검증 절차를 만드는 작업자로 뒀다.
9. 타임라인과 수치
10월 24일 KST 대응
| 시각 | 작업 |
|---|---|
| 06:45 | 공식 공지와 보도를 확인하고 사고 대응 시작 |
| 07:05 | CLI 정비 |
| 08:00~09:00 | 콘솔 v1 제작 |
| 09:00 | 콘솔로 키 교체 시작 |
| 09:18 | 잘못된 값 반영으로 AI 백엔드 DB 장애 발생 |
| 10:05 | 올바른 값 반영 후 AI 백엔드 복구 |
| 10:27 | A급 다섯 항목 완료, 옛 키 폐기 포함 |
| 11:01 | B급 LLM·결제·메일 API 반영 |
| 11:14 | 음성인식 API 반영 |
| 11:27 | Redis 교체, 관찰된 연결 끊김 0 |
| 11:32 | C급 자동 교체 네 항목 완료 |
| 11:53 | C급 수동 교체 네 항목 완료 |
| 12:49 | 콘솔 v2와 런북 정리 완료 |
핵심 교체 구간은 09:00부터 11:53까지 약 3시간이었다. 도구와 런북을 다듬는 작업은 12:49까지 이어졌다.
배포와 키 수
대응 당일 실제로 교체한 비밀은 20개였다. DB 비밀번호는 전날 이미 교체했다. AI 백엔드는 약 9회, API 서버는 6회, 웹은 2회 재배포했다.
Railway 배포 한 번은 약 3545초, Vercel API는 약 11.5분, 웹은 약 2~3분 걸렸다. 키마다 배포하지 않고 웨이브로 묶은 이유를 재배포 횟수에서도 확인할 수 있었다. 사람이 체감한 교체 작업은 약 3시간이었다.
도구가 없었다면 하루 종일 걸렸을 것이라는 평가는 CTO의 추정이지 비교 측정값은 아니다.
타임라인에는 장애 시간, 실제 교체 수, 대상 목록 수를 각각 적어야 성과를 과장하지 않는다.
10. 회고와 다음 작업
잘한 점
공식 공지와 1차 출처를 확인하고 사고 범위를 정했다. 키 이름뿐 아니라 지문을 사용해 여러 플랫폼과 로컬 사본의 결합 관계를 찾았다. 공격자가 할 수 있는 일을 기준으로 A·B·C 순서를 잡았다.
키 유형에 따라 발급·반영·배포·폐기 순서를 달리했다. 배포 횟수를 웨이브당 한 번으로 줄이고, 로그 원문 대신 오류 건수만 남겼다.
놓친 점
개발용 로컬 환경이 운영 DB와 Redis를 공유했다. 같은 운영 비밀값이 서로 다른 이름으로 복사된 범위를 평소 자동으로 보여주는 인벤토리가 없었다. 클립보드 자동 감시는 입력을 쉽게 만들었지만 복사한 값의 출처를 확인하지 않았다.
데이터 결합 키에 버전 정보가 없어 일반 교체 목록에서 제외해야 했다.
후속 작업
- 데이터 결합 비밀에 키 버전을 도입한다.
- 유출 기간의 클라우드·스토리지 감사 로그를 확인하고 신고 판단을 담당자와 마친다.
- 키 백업을 갱신한다.
- 가능한 서비스는 장기 비밀 대신 워크로드 ID나 단기 토큰을 검토한다.
- 클라이언트별 재연결 정책과 실제 재시도 시간을 문서화한다.
- 평시에 연 1~2회 키 교체 리허설을 한다.
공급자 공지 뒤에 처음 인벤토리를 만들면 이미 늦을 수 있다. 평소에 값의 사용 관계와 배포 시간을 알고 있어야 실제 대응 시간을 줄일 수 있다.
다음 사고의 시간을 줄이는 일은 사고가 나기 전에 인벤토리와 리허설을 준비하는 데서 시작한다.
11. 현장용 체크리스트
사고 직후 30분
- 업체 공식 공지와 상태 페이지에서 범위와 권고를 확인한다.
- 메일에 든 링크를 누르지 않고 평소 경로로 업체 콘솔을 연다.
- 유출 출처와 대상 환경, 유출된 변수 이름을 기록한다.
- 유출 출처에 실제 있던 변수와 같은 값을 쓰는 결합 항목을 구분한다.
- 같은 값이 다른 호스팅과 로컬
.env에 있는지 지문으로 찾는다. - 각 키로 공격자가 할 수 있는 일을 기준으로 A·B·C를 정한다.
- 민감 데이터 접근 키는 감사 로그 확인과 신고 판단 담당자를 정한다.
교체 실행
- 키마다 자동 발급·겹쳐 쓰기·미리 정하기·업체 먼저·즉시 무효·데이터 결합 중 유형을 고른다.
- 새 키를 운영에 넣기 전에 업체 API나 인증 요청으로 작동을 확인한다.
- A급 값을 먼저 반영하고 한 웨이브의 배포 횟수를 줄인다.
- 겹쳐 쓰기 키는 새 배포와 확인 뒤에 옛 키를 폐기한다.
- 업체에서 먼저 바꾸는 키는 클라이언트의 기존 연결과 재연결 정책을 확인한다.
- 값은 채팅·로그·명령행 인자·상태 파일에 남기지 않는다.
- 로컬
.env는 미리보기와 변수 이름 확인 뒤 반영한다. - 데이터에 결합된 키는 별도 버전 전환 계획이 생길 때까지 일반 교체에서 제외한다.
배포 후
- 플랫폼 배포 상태와 별도로 서비스 health를 확인한다.
- DB·Redis·API 키·OAuth·클라우드 인증 오류를 유형별로 점검한다.
- 로그 원문을 복사해 보관하지 말고 필요한 건수만 남긴다.
- 새 키가 실제로 쓰이는지 확인한 뒤 옛 키를 비활성화하거나 폐기한다.
- 다른 서비스와 자동화 작업에 같은 키가 남았는지 확인한다.
- 한 번만 내려받을 수 있는 키 파일의 백업을 확인한다.
평시 준비
- 비밀 인벤토리에 변수명, 소유자, 환경, 값의 결합 관계, 교체 유형을 기록한다.
- 장기 비밀을 줄이고 단기 자격증명이나 워크로드 ID를 검토한다.
- 데이터 결합 키에는 버전 필드를 둔다.
- 클라이언트 재연결 정책과 평균 배포 시간을 측정한다.
- 업체별 CLI 옵션과 리전, 복구 가능한 폐기 방법을 확인한다.
- 키 교체 훈련을 정기적으로 실행하고 런북을 갱신한다.
맺음
이번 대응에서 시간을 잡아먹은 일은 새 키를 만드는 일이 아니었다. 같은 값이 어디에 쓰이는지 찾고, 키별로 올바른 순서를 고르고, 새 값이 들어갔는지 검증하는 일이었다. 자동화는 키 값을 감추고 반복 작업을 줄이는 데 도움을 줬다.
동시에 v1의 클립보드 감시는 잘못된 입력을 운영 값으로 만드는 통로가 됐다. 다음에는 사람이 값을 통제하고, 도구는 연결 관계·교체 순서·검증 결과를 책임지는 구조로 시작하려 한다.
공급자 사고를 피할 수는 없어도, 키의 사용 관계와 교체 절차는 미리 준비할 수 있다.
참조 표준: 기술 사고 사후 분석(postmortem)의 영향·타임라인·원인·교정·교훈 구성, 자격증명 교체의 이중 키(overlap)·키 버전 관리 관행.