설정 사고는 git에 흔적을 남기지 않는다
클라우드 서비스를 직접 운영하면서 환경 변수 하나를 고칠 때마다 가슴이 조여지는 사람이라면, 이 글은 그 불안에 대한 체계를 줍니다. 결론부터 말씀드리겠습니다. 설정 사고가 비싼 이유는 그 값이 코드가 지나는 문인 리뷰와 테스트와 파이프라인을 한 번도 거치지 않았기 때문입니다. 그래서 원인은 git 히스토리에 없고 되돌릴 수도 없습니다.
토요일 밤 11시 반, 결제 성공 메일이 나가지 않는다는 알림이 울립니다. 오후 9시에 평소처럼 배포를 마쳤고 CI는 초록이었습니다. 그런데 프로덕션에서만 메일이 멈췄습니다. 원인은 한 줄입니다. 전날 소규모 리팩토링에서 메일 전송용 시크릿의 환경 변수 이름을 바꿨는데, 프로덕션에는 여전히 구 이름으로 값이 남아 있었기 때문입니다. 서버는 죽지 않았습니다. 그냥 침묵했습니다. 이 이야기는 비슷한 패턴의 사고 몇 건을 섞어 재구성한 것입니다.
코드 버그와 설정 사고는 생기는 순간이 다릅니다. 코드 버그는 리뷰와 테스트를 못 지나면 프로덕션에 올라가지 못합니다. 설정 사고는 리뷰 테이블 위에 아예 오르지 않습니다. 환경 변수 한 줄은 누군가의 콘솔 작업으로 남습니다. 원인은 히스토리 밖, 값의 세계에 있습니다.
글의 핵심 개념을 형상화했습니다.
사고는 네 가지 얼굴로 온다
설정 장애는 대개 네 가지 얼굴을 하고 있습니다. 첫째는 드리프트입니다. 환경마다 값이 조금씩 달라지는 상태입니다. 타임아웃이 개발 환경에서는 2초, 스테이징에서는 30초, 프로덕션에서는 300초인데, 누가 언제 왜 이렇게 만들었는지는 아무도 모릅니다. 나중에 값을 고치려다 다른 환경의 값을 모른 채 덮어쓰는 일로 이어집니다. 차이는 천천히 벌어지므로 아무도 이상하게 느끼지 못합니다.
둘째는 시크릿 누출입니다. 데이터베이스 비밀번호가 git 히스토리에 남거나, 에러 로그에 접속 문자열이 찍히거나, 스크린샷에 계정이 그대로 노출되는 경우입니다. 문제는 누출 자체보다, 누출 이후에도 그 값이 계속 유효하다는 점입니다. 값은 죽지 않습니다. 당신이 잊기만 하면 됩니다.
셋째는 환경 혼동입니다. 스테이징과 프로덕션이 같은 시크릿을 공유하는 경우, 일단 스테이징에서 돌려보자는 말이 프로덕션 데이터를 건드리는 일이 됩니다. 경계가 없으니 실수가 없다는 뜻은 아니고 실수할 문이 늘 열려 있다는 뜻입니다.
넷째는 배포 결합입니다. 코드와 설정이 항상 함께 움직이면 값 한 줄을 바꾸려면 배포를 한 번 해야 하고 배포를 하면 의도하지 않은 것도 함께 바뀝니다. 바뀌어야 할 값은 굳어 있고, 굳어져야 할 값은 흔들립니다.
네 얼굴의 공통점은 발견이 늦다는 것입니다. 대가는 결과가 고객에게 닿은 뒤에야 청구됩니다. 설정 사고는 조용히 일어납니다.

대가는 장애가 아니라 매일의 마찰이다
설정의 대가는 장애로만 오지 않습니다. 장애는 눈에 보이니까 오히려 고마울 정도입니다. 더 비싼 비용은 매일의 마찰입니다. 가장 흔한 마찰은 확인의 시간입니다. 어떤 값이 어디에 있는지 몰라 5분, 10분, 40분을 씁니다. 한 번에 크지 않지만 그 확인이 하루 3번씩 한 달을 이어지면 생산력을 깎는 고정 비용이 됩니다. 값 20개 남짓의 서비스를 혼자 운영하는 팀이 주당 30분에서 1시간을 확인에 쓰는 것은 흔한 수준입니다 [추정].
두 번째 마찰은 온보딩입니다. 새로운 사람이 들어오면 코드베이스를 읽기 전에 먼저 필요한 것은 값의 지도입니다. 어떤 환경에서 돌고 어떤 값이 어디에 주입되고 시크릿은 어디에 있는가. 이 답이 문서화되어 있지 않으면 온보딩은 구전으로만 진행됩니다. 구전은 한 사람이 퇴직하면 끊깁니다.
세 번째 마찰은 불안입니다. 환경 값을 하나 고치려 할 때, 시크릿을 하나 추가하려 할 때 드는 그 느낌. 다른 환경에 영향이 갈까, 누가 이 값을 알고 있을까, 90일 뒤에 뭐가 터질까. 이 불안은 체계 부재의 증세입니다. 체계가 있으면 불안은 사라지지 않더라도 확인 가능한 형태로 줄어듭니다.
비용은 값의 개수만으로는 정해지지 않습니다. 환경의 개수와 값의 개수가 곱해집니다. 값 10개에 환경 2개면 20개의 조합이고 환경 5개면 50개입니다. 혼자 운영하는 사람이라면 값을 줄이는 것보다 환경을 먼저 다듬는 것이 늘 이롭습니다.

각 값은 네 질문으로 거처를 정한다
이 마찰을 줄이는 첫 단계는 앱이 소비하는 모든 값에 거처를 정하는 일입니다. 값은 네 층으로 나뉩니다. 모든 환경에서 같고 운영 중에도 바뀌지 않는 값은 코드 상수입니다. 기본 페이지 크기나 재시도 횟수입니다. 환경마다 다르지만 새는 것 자체로는 큰 손해가 없는 값은 환경 설정입니다. 데이터베이스 호스트, 로깅 레벨, 외부 엔드포인트. 새면 접근이나 위조를 바로 가능하게 하는 값은 시크릿입니다. 비밀번호, API 토큰, 서명 키. 배포를 거치지 않고 서비스의 행동을 켜고 끄는 값은 피쳐 플래그입니다.
판정 절차는 질문 네 개입니다. 먼저 환경마다 값이 다른가. 다르면 환경 설정입니다. 같다면 상수인데, 지금은 같다는 말은 영원히 같다는 말이 아닙니다. 다음 분기에 지역을 하나 더 열면 다르게 됩니다. 상수로 두는 이점은 가독성뿐이므로, 확신이 없으면 환경 설정으로 넘깁니다.
둘째, 이 값이 새면 손해를 보는가. 네라고 답하면 시크릿입니다. 손해가 즉시 발생하지 않아도, 다른 값과 조합되면 문을 여는 열쇠가 될 수 있습니다. 관리자 계정의 사용자 이름은 새도 아무 일 없어 보이지만, 비밀번호와 결합하면 공격의 절반이 끝난 상태입니다. 손해가 나는 메커니즘이 있는지로 판정합니다.
셋째, 배포를 하지 않고도 바꿔야 하는가. 그렇다면 피쳐 플래그입니다. 환경 설정도 런타임에 바꿀 수 있으니, 변경 주기입니다. 한 달에 한 번 정도면 설정이고 한 주에 여러 번이면 플래그입니다. 주기가 불분명하면 설정에 두고 관찰합니다. 층을 옮기는 것은 값의 이동이지, 새 값의 생성이 아닙니다.
넷째, 사람이나 테넌트마다 다른가. 다르면 데이터입니다. 설정이 아닙니다. 고객사별로 결제 게이트웨이가 다른데 그 값을 환경 변수로 올리려 하면, 고객사가 한 개 늘어날 때마다 변수가 하나 늘고 변수가 늘 때마다 모든 절차가 무거워집니다. 환경 변수는 서비스 하나당 값이 하나입니다.
오배치도 네 가지 모양으로 옵니다. 시크릿을 .env 파일에 커밋하는 것, 기능의 켜짐과 꺼짐을 코드 상수로 가두는 것, 테넌트 값을 환경 변수로 올리는 것, 민감하지 않은 값을 시크릿으로 격리하는 것까지입니다. 회색 지대의 값이 보인다면, 먼저 둘로 쪼개질 수 있는지 확인합니다. 엔드포인트와 뒤에 붙는 토큰은 주기가 다른 값 둘이고 하나로 묶으면 짧은 주기의 값이 긴 주기의 값을 끌어안고 돌아갑니다. 층이 정해지면 장소가 정해지고, 장소가 정해지면 검색이 불필요해집니다.

프로덕션 값이 바뀌는 길은 하나여야 한다
거처가 정해졌다면 다음 단계는 변경 경로입니다. 프로덕션 값이 바뀔 수 있는 길은 하나여야 하고 그 하나가 파이프라인입니다. 콘솔에 직접 로그인해서 값을 바꾸는 길은 닫아야 합니다. 금지하기 어렵다면 예외를 기록하는 규칙을 만듭니다. 언제, 누구, 왜, 어떤 값. 당일 기록하고 다음 주 파이프라인에 같은 변경을 머지합니다. 예외가 쌓이면 그것은 이미 드리프트의 목록입니다.
두 길을 나란히 놓으면 차이가 분명해집니다.
| 항목 | 콘솔 직접 편집 | PR과 파이프라인 |
|---|---|---|
| 리뷰 | 없음 | 있음 |
| 근거 | 누군가의 머릿속 | PR에 기록 |
| diff | 없음 | 스냅샷끼리 비교 |
| 히스토리 | 없음 | git에 남음 |
| 되돌리기 | 기억에 의존 | 값만 되돌리는 PR |
프로덕션의 타임아웃을 300에서 600으로 올리는 일을 예로 보자. 콘솔에서는 30초에 끝납니다. 이 구조에서는 다릅니다. 프로덕션 환경 파일에서 값을 고치고 PR을 엽니다. 리뷰어가 한 줄을 보고 600의 근거를 묻습니다. 머지하면 파이프라인이 프로덕션에 600을 주입하고 배포가 끝나면 지난 스냅샷과 diff가 비교됩니다. 변화는 타임아웃 한 줄이어야 하고, 스냅샷도 함께 갱신됩니다. 30초에 끝나는 일이 5단계가 됩니다. 하지만 30초로 끝나는 일에는 리뷰도, 근거도, diff도, 기록도 없습니다. 이 구조가 사는 것은 그 값을 다음에 다시 올릴 때, 지난 기록을 찾는 데 30초가 걸리지 않기 때문입니다.
환경 값 파일 자체도 코드처럼 리포지토리에 둡니다. 값은 환경별로 디렉토리로 나누고 시크릿은 제외합니다. 시크릿은 매니저에 있고 파이프라인이 배포 시점에 주입합니다. 빠지기 쉬운 지점은, 환경이 공유하는 것이 키라는 점입니다. 앱이 읽는 키 목록은 한 번 정의하고 모든 환경이 그 목록을 채웁니다.
채우지 못한 키는 시작할 때 실패해야 합니다. 키가 하나라도 없으면 앱이 죽는 구조입니다. 죽는 것이 좋습니다. 프로덕션에서 키가 없으면, 앱은 죽는 대신 엉뚱한 값을 쓰며 계속 돈이 됩니다. 설정 누락이 동작으로 둔갑하는 것보다, 시작 실패로 드러나는 것이 낫습니다. 반대 패턴은 기본 값 구조입니다. 키가 없으면 코드에 적어 둔 기본 값으로 조용히 넘어가는 한 줄은, 그 값을 주입하지 않은 모든 환경을 같은 값으로 만듭니다. 개발의 2초도 300이 되고 스테이징의 30초도 300이 됩니다. 기본 값은 환경의 차이를 지웁니다. 그리고 지워진 차이는 나중에 장애의 형태로 돌아옵니다.

시크릿은 값이 아니라 상태다
네 층 가운데 취급이 가장 특별한 것은 시크릿입니다. 시크릿은 새면 접근이나 위조를 가능하게 하는 값인데, 상태에 가깝습니다. 로테이션하면 구 값과 새 값이 한때 공존하기 때문입니다. 절반의 서비스가 새 값을 쓰고 절반이 구 값을 쓰는 그 동안, 어느 것이 유효한지는 인벤토리가 말해 줄 뿐입니다.
시크릿은 대개 다섯 길로 새어나갑니다. git 커밋, 로그, 캡처, 문서, 오래된 코드입니다. 히스토리에 한 번 남은 값은 현재 파일에서 지워도 지워진 것이 아닙니다. 히스토리를 고치기 시작하는 순간, 그 값을 이미 새어 나온 것으로 취급해야 합니다. DEBUG 모드에서 환경 변수를 통째로 찍는 코드, 에러 메시지에 섞여 올라오는 토큰, PR이나 채팅에 붙는 콘솔 스크린샷, 예로 쓰다 실제 키를 바로 옆에 붙인 런북까지. 문서는 오래 삽니다. 코드가 바뀌어도 문서는 한 해 전 값으로 남아 있습니다.
그래서 시크릿을 추가할 때 동작은 세 동작의 세트입니다. 예시 파일에 이름을 쓰고 시크릿 저장소에 값을 두고 인벤토리에 한 줄을 추가합니다. 그 한 줄에는 이름, 소유 서비스, 만료일, 마지막 로테이션 날짜, 보관 위치가 들어갑니다. 값을 두고 세 동작을 건너뛰는 순간, 그 값은 다음 누출 사고의 후보가 됩니다.
환경별로 키를 분리하는 것도 같은 이유입니다. 팀이 작을수록 아까워서 같은 키를 쓰려는 유혹이 강합니다. 키를 공유하면 로테이션이 멈춥니다. 키 한 번 바꿀 때 모든 환경에 동시에 반영해야 하고 한 환경이라도 늦으면 그 사이 구 키로 접속이 가능합니다. 사고의 원인도 가를 수 없고, 누출 범위도 키로 자를 수 없습니다. 프로덕션 로그에서 의심스러운 접속이 보이면 개발 환경에서 나온 것인지를 알아야 하는데, 같은 키면 그게 안 됩니다.
이미 새어 나갔다고 발견한 때는 순서가 중요합니다. 조사를 먼저 하면 안 됩니다. 조사가 몇 시간 걸리는 동안 구 값은 여전히 열쇠입니다. 먼저 무효화합니다. 그 다음 범위를 확인합니다. git 히스토리와 로그 서비스와 에러 트래커, 값이 공유된 채팅과 스크린샷과 문서. 시간순으로 적습니다. 같은 키를 다른 서비스나 계정에 쓰던 것이 있으면 함께 무효화합니다. 피해가 없었더라도 기록을 남기고 마지막으로 값을 내보낸 길에 가드를 둡니다. 공개 저장소에 오른 시크릿은 스캐너가 몇 분 안에 찾아냅니다. 찾아내는 데 몇 분이 걸린다면, 쓰는 데도 몇 분이 걸린다고 보면 됩니다.
10분이 드리프트를 잡는다
이상의 규칙을 한 문장으로 줄이면, 모든 값에 거처를 정하고 프로덕션 값을 바꿀 길은 하나만 남기고 매 배포마다 diff를 보는 일입니다. 드리프트는 의도해서 생기는 것이 아닙니다. 값을 조금 바꾸고 잊고 다른 환경은 모르게 따라 바꾸지 않고 차츰 벌어지는 차이입니다. 의도했지만 기록되지 않은 변경은 아직 후보입니다. 후보와 현행범을 가르는 경계선은 하나입니다. 다음 배포의 diff에 그 변경이 보이는가.
diff에 의도한 PR의 변경만 보이면 체계는 살아 있습니다. PR에 없는 변경이 보이면, 그 변경은 대개 세 곳에서 옵니다. 콘솔의 직접 편집, 로테이션 후에 안 넘어온 값, 누군가의 임시 조치입니다. 임시 조치는 특히 위험합니다. 사고를 막기 위해 넣은 값이 사고가 끝난 뒤에 남는 경우, 임시로 넣은 이유가 기록되지 않으면 값은 영구로 남습니다. 영구로 남은 값은 다음 사고의 시작입니다.
diff를 싼 것으로 만드는 것은 스냅샷입니다. 배포할 때 실제로 적용된 값의 스냅샷을 남깁니다. 시크릿은 마스킹해서. 이번 배포와 지난 배포를 비교하면, 의도한 PR이 아닌 변화는 드리프트로 멈춰 섭니다. diff가 없으면 인벤토리는 쌓이기만 하고 스냅샷은 아무도 보지 않습니다. 읽지 않은 diff는 없는 것과 같습니다.
그리고 매주 같은 요일에 10분을 씁니다. 시크릿 인벤토리에서 로테이션 기한이 한 달 안쪽인 것을 확인하고 활성 플래그의 주인과 기한을 확인하고, 프로덕션 변경 감사에서 파이프라인 밖의 변경이 없었는지 확인하고, 스테이징과 프로덕션의 스키마 검증이 통과했는지 확인합니다. 하나라도 확인 불가가 나오면, 그 주 30분을 더 씁니다. 확인 불가가 쌓이는 것이 드리프트입니다. 확인 가능한 상태를 유지하는 것이, 드리프트를 막는 상태입니다.
설정 규율은 반복입니다. 값마다 네 질문, 시크릿마다 세 동작, 프로덕션에는 길 하나, 배포마다 diff 하나, 주당 10분. 이 순서가 세 번만 돌아도, 값이 어디 있느냐는 질문의 대답은 빨라집니다. 오늘 밤 10분으로 시작할 수 있습니다. 앱이 읽는 환경 변수를 전부 목록으로 뽑고 옆에 세 칸만 채우면 됩니다. 값이 환경마다 다른가, 민감한 값인가, 최근 한 달 사이 바뀐 적 있나. 하나라도 모름으로 채워진 값이 있다면, 그것이 이번 주에 먼저 손대야 할 값입니다. 알 수 없다는 것 자체가, 그 값은 아직 아무 체계에도 속하지 않는다는 뜻입니다.
더 깊이 보고 싶으면, 이 논증을 확장한 전자책 설정 규율이 있습니다. 시크릿 대응 5단계, 플래그의 수명, 분기별 유출 훈련까지 33페이지입니다.
참고 자료
- 환경마다 주입되는 값으로서의 설정: The Twelve-Factor App
- 설정 드리프트의 정의와 탐지: AWS, What is Configuration Drift
- 공개 저장소의 시크릿 유출 탐지: GitHub, About secret scanning