되돌릴 수 있어야 보내는 것
자신의 서비스를 프로덕션에 직접 올리는 개발자, 그리고 운영을 따로 두지 않는 소규모 팀의 엔지니어라면 이 글에서 하나의 원칙을 가져갈 수 있습니다. 프로덕션 배포의 진짜 질문은 ‘올려지는가’가 아니라, ‘잘못됐을 때 얼마나 빨리 되돌릴 수 있는가’입니다. 되돌릴 곳이 준비되기 전에 보내는 배포는 도박이고 되돌릴 곳이 항상 있는 배포는 연습해 온 루프의 다음 바퀴입니다.
배포를 대하는 흔한 마음은 용기의 문제이고 매 배포를 긴장하는 이벤트로 만듭니다. 이 글이 주장하는 것은 그 이벤트의 소멸입니다. 기계가 아티팩트의 신원을 기억하고 저장소가 돌아갈 자리를 보관하고 순서가 배포를 하나의 명령으로 압축하면, 올리는 순간은 더 이상 결단이 아닙니다.
이 논증에서 가장 비싼 부분은 파이프라인이 아니라 데이터입니다. 코드는 교체를 따라 돌아갈 수 있지만 새 코드가 돌던 동안 쓰인 데이터는 그대로 남습니다. 이 논증의 뒷부분은 기계가 혼자서는 살리지 못하는 지점, 데이터의 모양으로 갑니다.
글의 핵심 개념을 형상화했습니다.
밤 10시의 롤백은 존재하지 않는다
밤 9시 40분, 누군가 절반만 고친 기능을 main에 머지합니다. 개발에서는 돌아갔으니까 괜찮다는 생각입니다. 밤 10시, 프로덕션 서버에서 git pull을 하고 의존성을 설치해 프로세스를 재시작합니다. 배포가 완료되었습니다. 밤 10시 20분, 에러 로그가 차기 시작합니다. 익숙한 오류를 보고 되돌리려는데, 되돌릴 수가 없습니다.
그때 서버가 그전에 어떤 버전이었는지 아무도 모릅니다. 10시에 설치한 의존성은 최신 버전이라, 1시간 전의 상태 자체가 불분명합니다. 빠진 것은 코드도 실력도 아닙니다. 신원이 있는 아티팩트입니다. 지금 뭐가 돌고 있나, 그전에는 뭐가 돌았나, 그때의 의존성은 뭐였나. 세 질문 모두에 답이 없습니다.
설명할 수 없는 상태는 되돌릴 수 없는 상태입니다. 이 문장을 전제로 삼겠습니다. 이후의 모든 기계는 세 문장이 사실이라는 것을 목표로 합니다. 지금 도는 것을 알 수 있고 그전의 것을 다시 가져올 수 있고 돌아가는 것은 하나의 조작으로 끝난다는 것을요.
‘조심해서 하면 된다’는 답은 통하지 않습니다. 조심은 상태이지 시스템이 아닙니다. 서버에서 코드를 직접 고치는 일, lockfile 없이 의존성을 설치하는 일, 배포 대신 재시작으로 문제를 넘기는 습관. 하나씩은 작은 지름길이지만 다 합치면 지금 프로덕션의 상태를 설명할 수 없는 상태가 됩니다. 설명할 수 없는 상태는 되돌릴 수 없는 상태입니다.

머신을 떠나는 것은 코드가 아니라 빌드입니다
Git에 들어 있는 것은 텍스트입니다. 텍스트는 어디에서도 실행되지 않습니다. 그 텍스트를 특정 실행 가능 형태로 바꾸는 작업이 빌드이고 배포는 그 결과물을 프로덕션에 옮겨 실행하는 행위로 정의됩니다. 학술적으로만 들리는 구분이지만 프로덕션 사고를 다루는 방식은 이 구분이 정합니다.
서버에서 빌드하면 두 가지가 불분명해집니다. 먼저 실행 대상입니다. git pull은 main 브랜치의 최신 커밋을 가져오므로, 실행되는 것은 내가 테스트한 버전이 아니라 지금 브랜치에 있는 무엇이든입니다. 다음으로 실행 환경입니다. 의존성이 서버에서 새로 설치되고 서버의 OS와 패키지 매니저는 개발 머신과 다릅니다. 소스 코드만 공유하는 다른 프로그램 둘이 됩니다. 프로덕션에서 빌드하지 마세요. 한 곳에서 빌드하고 그 결과물을 가져다 실행하세요.
그 결과물, 아티팩트는 신원을 갖습니다. 버전 번호, Git 커밋 해시, 빌드 시각, 빌드한 쪽입니다. Docker 이미지라면 이름이 myapp:1.4.2-3f9a1c2처럼 붙습니다. 버전은 사람이 읽는 것이고 해시는 기계가 검증하는 것입니다. 롤백할 때 ‘작주 버전’으로 돌아가지 않습니다. 특정 이미지 식별자를 다시 가리키는 것입니다. 담는 것은 코드, 실행에 필요한 모든 의존성, 시작과 종료 스크립트입니다. 담지 않는 것은 프로덕션 특정 설정 값, 데이터, 상태입니다. 설정과 데이터는 환경이 제공하고 이 분리가 뒤의 롤백 교체의 조건이 됩니다.
신원이 있는 아티팩트는 다시 만들 수 있어야 합니다. lockfile은 코드입니다. 빌드의 부속산물이 아니므로 소스 관리에 함께 커밋하고 Dockerfile의 FROM python:3.12 같은 약한 고정 대신 digest까지 고정합니다. 같은 소스에서 같은 아티팩트가 다시 나올 때, 화요일에 보고된 버그를 수요일 아침 같은 버전에서 구동하며 디버깅을 시작합니다. 버전 번호도 사람이 매기지 않습니다. 빌드가 매깁니다. 1.4.2에 버그가 있으면 고쳐 다시 쓰지 않습니다. 1.4.3을 만듭니다. 한 번 쓴 번호는 재사용하지 않습니다.
저장소는 이미지 저장소, tarball이라면 버킷 하나 정도로 단순해도 됩니다. 여기서 결정적인 규칙은 불변성입니다. 같은 이름의 아티팩트는 절대 바뀌면 안 됩니다. 내용이 조용히 바뀌는 순간 ‘돌아가는 것으로 확인된 버전’은 사라집니다. 지금 도는 것과 최근 몇 개는 남아 있어야 하고 아티팩트를 지우는 순간은 그 버전으로 되돌릴 권리를 포기하는 순간입니다. 서비스 하나짜리 팀이라면, CI 기계에서의 빌드와 버킷 하나, 한 줄의 배포 명령이 전부입니다.

파이프라인은 하나의 규칙을 지키는 기계입니다
전형적인 순서입니다. 린트와 정적 분석, 단위 테스트와 통합 테스트, 빌드와 아티팩트 생성, 아티팩트를 저장소에 push, 환경에 배포, 배포 후 검증, 알림입니다. 작은 서비스라면 테스트와 빌드와 배포와 검증만으로도 충분합니다. 가치는 각 단계의 통과 기준에서, 전체가 하나의 명령으로 다시 돌아간다는 사실에서 옵니다.
순서가 중요한 이유는 반대로 보면 명확합니다. 빌드 뒤에 테스트를 하면, 검증할 대상이 고정되어 있지 않아 의미가 없습니다. 아티팩트를 저장소에 올리기 전에 배포하면, 롤백할 곳이 사라집니다. 파이프라인이 지키는 핵심 규칙은 그래서 이것입니다. 저장소에 없는 아티팩트는 배포할 수 없다.
build:
needs: test
push:
needs: build
deploy:
needs: push
environment: production
deploy가 push를 필요로 하는 한 줄이 수호 장치입니다. environment: production 한 줄은 사람 한 번의 승인을 요구할 수 있는 자리이고, 소규모 팀에게는 가장 확실한 리스크 통제입니다. 스테이징이 있다면 같은 파이프라인이 먼저 스테이징을 통과한 뒤에 프로덕션 승인으로 넘깁니다. 프로덕션에 올라가는 빌드가 방금 통과한 빌드와 같은 아티팩트가 됩니다.
통과 기준은 관대하면 안 됩니다. 테스트는 실패가 하나라도 있으면 실패이고 스킵된 테스트가 쌓이면 기준은 무너집니다. 빌드는 이름에 커밋이 들어 있고 저장소에 같은 이름이 없어야 통과입니다. push는 성공 메시지로 통과되지 않습니다. 다시 pull할 수 있어야 통과입니다. 배포는 프로세스가 올랐다고 성공이 아닙니다. 헬스체크가 통과한 뒤에 성공입니다. 검증의 기준은 ‘뭐든 답하면 됨’이 아닙니다. 핵심 경로가 특정한 상태 코드와 형식으로 돌아오는 것이 기준입니다. 기준이 느슨할수록 사고는 어느 누가 눈치 챌 때까지 밀려납니다.
파이프라인이 완성되면 배포는 한 줄, ./deploy.sh가 됩니다. 모든 단계가 멱등이고 노트북 없이 CI 환경에서 돌아가야 합니다. 이 한 줄은 롤백 명령과 짝을 이룹니다. 배포는 ./deploy.sh, 롤백은 ./rollback.sh. 둘 다 한 줄이고 저장소의 아티팩트에서 동작합니다. 되돌리기가 한 명령으로 안 되면, 그 파이프라인은 편도 티켓입니다. 실패는 반드시 보이게 하고 성공 알림은 보너스, 실패 알림은 필수입니다. 조용히 실패한 배포에서 프로덕션은 옛 버전으로 돌고, 팀은 새 버전으로 알고 있기 때문입니다.

환경은 장소가 아니라 계약입니다
사람들은 환경을 기계로 떠올립니다. 개발 서버, 스테이징 서버, 프로덕션 서버. 하지만 코드에게는 기계가 보이지 않습니다. 코드가 보는 것은 대화할 수 있는 서비스 목록, 읽을 수 있는 값, 건드릴 수 있는 데이터입니다. 환경은 코드가 같은 질문을 했을 때 듣는 답들의 집합입니다. 그래서 답을 확인하는 기준은 서버가 같은가가 아닙니다. 코드가 같은 질문을 했을 때 같은 답을 듣는가가 기준입니다.
소규모 SaaS를 보면 형태가 잡힙니다. 웹 앱 하나, 데이터베이스, 이메일 워커. 개발은 노트북의 로컬 DB에서, 워커는 콘솔에 인쇄만 하고 API 키는 테스트 키입니다. 스테이징은 프로덕션과 같은 제공사의 작은 규격 DB와 테스트 모드의 진짜 키를 쓰는 VM 하나입니다. 프로덕션은 실제 사용자의 데이터가 있는 곳입니다. 세 곳의 코드는 같은 빌드이고 다른 것은 환경이 주는 값들입니다. DB 주소, API 키, 워커의 ‘버리기 아니면 보내기’.
설정 값은 환경이 시작할 때 주는 입력이어야 합니다. 코드 안에 박아 두는 것이 아닙니다. 세 바구니로 나눕니다. 빌드 시 상수는 코드에, 런타임 설정은 환경 변수에, 시크릿은 시크릿 매니저에 둡니다. 필요한 모든 변수를 빈 값으로 나열한 .env.example 파일을 리포지토리에 커밋하면, 다음 달의 본인이 그 파일만 보면 서비스가 무엇을 요구하는지 압니다. 시크릿은 배포 단계에서만 주입해 빌드 산출물이나 로그에 남지 않게 합니다. 코드에 if env == “prod” 같은 환경 이름 분기는 피합니다.
계약이 가장 잘 깨지는 곳은 둘입니다. 설정과 데이터. 새 버전이 새로운 환경 변수를 기대하는데 프로덕션에 그 변수가 없으면, 롤백이 안 되는 상황이 만들어집니다. 규칙은 순서에 있습니다. 먼저 환경에 새 값을 놓고 전환 기간 동안에는 옛 동작과 호환되는 기본값을 남겨 두고, 그 다음에 새 코드를 올립니다. 데이터 쪽의 순서는 마지막 섹션에서 다룹니다.
실질적인 질문은 따로 둔 환경 없이 검증할 수 없는 계약이 무엇인지를 묻는 것입니다. 통상 프로덕션에만 존재하는 실제 설정, 데이터 마이그레이션의 동작, 실제 트래픽에서만 나타나는 지연과 부하입니다. 서비스가 돈을 다루지 않고 마이그레이션이 작다면 스테이징을 생략해도 됩니다. 대신 배포 직후 검증 스크립트와, 짧은 되돌림 시간이 대안입니다.
스테이징을 두기 시작하면 어긋남은 시간문제입니다. 드리프트는 구조의 문제입니다. 성실함의 문제가 아닙니다. 두 환경을 같은 방식으로 만들게 합니다. 인프라를 코드로 쓰고 스테이징과 프로덕션이 저장소의 같은 아티팩트를 끌어오게 하고, 같은 스모크 테스트를 두 환경에서 돌립니다. 스테이징에서만 통과하는 검증은 프로덕션을 보증하지 못합니다.

롤백은 되돌리는 것이 아니라 교체하는 것입니다
많은 팀에서 롤백은 비상 조치, 깨진 뒤의 마지막 수단입니다. 되돌리기는 순서가 복잡하고 매 단계마다 실패할 수 있습니다. 이 글이 제안하는 롤백은 교체입니다. 트래픽을 저장소에 이미 있는 이전 아티팩트로 가리키는 것. 코드를 되돌리는 것이 아닙니다. 가리키는 것을 바꾸는 것입니다.
교체가 유효한 이유는 셋입니다. 이전 아티팩트는 이미 테스트를 통과해 돌아가는 것으로 확인된 버전입니다. 교체는 단일 조작입니다. 트래픽을 뒤집거나, 프로세스를 다시 올리거나. 실패 모드가 단순합니다. 교체 자체가 안 되면, 예전 것이 여전히 도는 것입니다. 그래서 설계의 질문은 하나로 좁혀집니다. 교체를 깨끗하게 하려면 무엇이 필요한가.
| 전략 | 필요한 조건 | 롤백의 모양 |
|---|---|---|
| rolling | 리플리카와 헬스체크 | 개별 인스턴스 재배포 |
| blue/green | 두 스택의 여력 | 트래픽 되돌리기 |
| canary | 트래픽 분할과 지표 | 비율을 0으로 |
인스턴스가 하나뿐이라면, blue/green은 한 머신으로도 됩니다. 옛것과 새것을 서로 다른 포트에 올리고 검증이 지나면 리버스 프록시 한 줄만 바꿔 겁니다. 새 아티팩트를 새 포트에 올리고 스모크를 돌리고 nginx upstream을 바꿔 reload하고 몇 분을 관찰합니다. 문제가 있으면 upstream을 다시 뒤집고, 없으면 옛것을 내립니다. 사용자 트래픽을 실제로 건드리는 것은 한 줄뿐입니다.
언제 롤백할지는 배포 전에 정해 둡니다. ‘방금 올린 건데, 아마 괜찮을 것이다.’라는 기다림 사이, 작은 문제가 사고로 커집니다. 5xx 비율이 배포 전의 두 배로 오르면 당장 조사하고 빠른 설명이 없으면 롤백합니다. 스모크가 깨졌거나 핵심 경로 지표가 떨어졌으면, 토론하지 말고 먼저 롤백합니다. 데이터가 잘못됐을 가능성만 있어도 먼저 롤백합니다. 롤백할 곳과 한 줄 명령과 런북이 이미 준비돼 있으면, 결정에서 복구까지 약 10분이 됩니다.
롤백은 끝이 아니라 같은 루프의 한 바퀴입니다. 깨진 아티팩트와 로그와 커밋은 모두 저장소에 남아 있습니다. 고친 빌드는 새 번호, 1.4.4로 push되고 원래 루프를 그대로 걷습니다. 롤백을 했다는 이유로 단계를 건너뛰면, 건너뜀 단계를 다시 사람의 기억에 맡기는 것입니다. 절차는 한 페이지짜리 런북에 두고 분기에 한 번 프로덕션이 아닌 환경에서 실제로 돌려 봅니다. 그리고 배포를 작게, 자주 합니다. 200개 변경을 한 번에 실으면 롤백의 후보도 200개이고 10개를 실으면 후보는 10개입니다. 자주 옮기는 팀은 빨리 되돌리는 팀이고 그건 같은 근육입니다.
교체가 깨지는 유일한 지점은 데이터입니다
교체는 코드의 문제를 해결합니다. 하지만 새 코드가 돌고 있는 동안에 쓰인 데이터는 그대로 남아 있습니다. 코드를 돌려도, 새 코드에게서 나온 데이터는 사라지지 않습니다. 그래서 교체의 진짜 전제는 하나입니다. 새 코드가 옛 코드가 읽을 수 없는 데이터를 쓰면 안 됩니다.
두 예를 보면 선이 보입니다. 새 버전이 주문 JSON에 discount_type 필드를 추가하면, 옛 버전은 모를 필드를 무시하므로 문제없습니다. 그런데 amount 필드의 형식을 정수에서 문자열로 바꾼다면, 정수로 읽던 옛 버전은 롤백한 순간 깨집니다. 차이는 ‘추가’와 ‘기존의 의미를 바꾸기’입니다. 롤백 설계는 전자를 허용하고 후자를 금합니다.
실용적인 규칙은 ‘쓰기는 옛 형식으로, 읽기는 새 형식으로’입니다. 스키마 변경도 같은 정신인 expand/contract로 갑니다. 새 컬럼을 nullable로 추가하면 옛 코드는 그 존재를 알지 못하고 새 코드는 둘 다 읽고, 기존 데이터는 테이블을 잠그지 않는 백그라운드 잡으로 배치째 옮깁니다. 옛 필드를 읽는 쪽이 모두 넘어온 다음 릴리스에서야 옛 구조를 제거합니다. 축약이 먼저 나가면, 새 코드를 배포하기 전에 롤백할 곳이 사라집니다.
이미 새 형식으로 쓰인 데이터가 있다면 선택지는 둘입니다. 백필의 역을 돌려 새 형식 데이터를 옛 형식으로 다시 쓰는 잡을 실행하거나, 옛 코드의 읽기 레이어만 두 형식 다 이해하게 패치하는 것입니다. 둘 다 데이터의 모양에 대한 판단이고 배포 전에 해야 합니다. 새 형식 데이터가 롤백된 세계에서 어떻게 살아남을지를 못 물었다면, 롤백은 데이터의 문제를 다음 날 아침에 남깁니다.
두 번째 안전망은 기능 플래그입니다. 위험한 기능은 코드를 올려 두되 플래그는 꺼 두고 일부 사용자만 켜고 지켜 봅니다. 이상하면 플래그만 끄면 되고 아티팩트 전체를 바꾸는 것보다 빠릅니다. 다만 플래그는 만능이 아닙니다. 쓰지 않는 기능이 쌓입니다. 플래그에는 만료일을 붙여 다음 릴리스에서 함께 지웁니다. 3개월 넘게 제자리에 있는 플래그는 부채입니다.
이 논증을 한 문장으로 되돌리면 되돌릴 수 있어야 보내는 것입니다. 빌드가 신원을 만들고 저장소가 불변으로 보관하고 파이프라인이 순서를 지키고, 계약이 설정과 데이터의 자리를 정합니다. 롤백할 곳이 항상 준비되어 있다면, 보내는 순간은 도박이 아니라 연습해 온 루프의 다음 바퀴입니다.
참고 자료
- 빌드·릴리스·런의 분리, 설정을 환경으로 주입하는 방식: The Twelve-Factor App
- 아티팩트를 다이제스트로 고정하는 불변 신원: OCI Image Spec
- 롤백을 트래픽 전환(교체)으로 설계하는 방식: Blue Green Deployment (Martin Fowler)
- rolling update와 롤백의 동작: Kubernetes Deployments