방어적 엔지니어링: 재앙을 견디는 시스템
프로덕션 장애를 막는 방어적 엔지니어링 패턴을 소개합니다. 몰리 가드, 드라이런, 소프트 삭제, 확인 창이 통하지 않는 이유까지 정리했습니다.

중견 핀테크 기업의 한 주니어 엔지니어가 금요일 오후에 데이터베이스 마이그레이션 스크립트를 실행했습니다. 이 스크립트는 스테이징 환경에서 고아 레코드를 정리하기로 되어 있었습니다. 그런데 실제로는 프로덕션 데이터베이스에 연결되어 고객 거래 기록 420만 건을 삭제해 버렸습니다. 백업은요? 사흘 전 것이었습니다. 회사는 이후 72시간 동안 전면적인 장애 대응에 매달려, 결제 처리업체의 로그를 보면서 기록을 수작업으로 맞춰야 했습니다. 엔지니어링 인력 투입 비용, 고객 보상금, 규제 당국 보고까지 합치면 총비용이 40만 달러를 넘었습니다.
스크립트에는 환경 검사가 없었습니다. 드라이런 플래그도 없었고, 어떤 데이터베이스에 연결되어 있는지 보여주는 확인 프롬프트도 없었습니다. 연결 문자열은 환경 변수에서 가져왔는데, 그 변수는 2주 전 디버깅 세션 때문에 엔지니어의 노트북에서 우연히 프로덕션으로 설정되어 있었습니다. 이 실패들 하나하나는 모두 막을 수 있었습니다.
“정말 진행하시겠습니까?” 대화상자가 통하지 않는 이유
소프트웨어에서 가장 흔한 안전장치는 확인 대화상자입니다. 그리고 가장 쓸모없기도 합니다. 확인 피로(confirmation fatigue)에 관한 연구에 따르면, 사용자는 이런 “정말 진행하시겠습니까?” 창을 몇 번 마주치고 나면 거의 100%에 가깝게 그냥 넘겨버립니다. 프롬프트는 보이지 않는 존재가 되고, 이미 하기로 마음먹은 작업으로 가는 길목의 클릭 하나일 뿐이 됩니다.
문제는 확인 절차 자체가 나쁜 아이디어라는 데 있지 않습니다. 일반적인 확인 창에는 정보가 전혀 없다는 것이 문제입니다. “계속 진행하시겠습니까?”는 무엇을 하려는지 아무것도 알려주지 않습니다. 이렇게 비교해 보세요: “프로덕션 환경 prod-us-east-1의 transactions 테이블에서 4,217,893개 행을 삭제하려고 합니다. 확인하려면 데이터베이스 이름을 입력하세요.” 두 번째 버전은 지금 무슨 일이 일어나는지 실제로 읽도록 만듭니다. 이것이 과속 방지턱과 몰리 가드의 차이입니다.
몰리 가드란 무엇이고 왜 중요한가
“몰리 가드”라는 용어는 메인프레임 컴퓨터의 빅 레드 버튼 위에 씌운 물리적 플라스틱 덮개에서 나왔습니다. 실수로 시스템을 끄지 않도록 하기 위한 것이었는데, 그 버튼을 자꾸 누르던 한 프로그래머의 어린 딸 몰리의 이름에서 따왔다는 이야기가 전해집니다. 소프트웨어에서 몰리 가드는 파괴적인 작업을 실수로는 하기 어렵게 만들면서, 의도했을 때는 여전히 할 수 있게 해주는 모든 장치를 가리킵니다.
Linux 패키지 molly-guard가 바로 이 역할을 합니다. SSH 세션에서 shutdown, reboot, halt 명령을 가로채고, 종료하려는 머신의 호스트 이름을 입력하라고 요구합니다. 그냥 습관적으로 넘길 수 없고, 지금 어느 머신에 있는지 알고 있다는 것을 증명해야 합니다.
$ sudo reboot
Good grief! You're on a remote SSH session to prod-web-03.
Please type the hostname to confirm: prod-web-03
# Compare with the useless version:
$ sudo reboot
Are you sure? (y/n): y
# *types y without reading, muscle memory*
원칙은 간단합니다. 확인 절차는 작업을 제대로 이해하고 있다는 것을 증명하는 정보를 요구해야 합니다. “y”를 입력하는 것은 아무것도 증명하지 못합니다. 호스트 이름, 테이블 이름, 영향받는 레코드 수를 입력하는 것은 경고를 실제로 읽었다는 증거가 됩니다.
드라이런 모드: 쏘기 전에 먼저 보여주기
모든 파괴적인 작업에는 드라이런 모드가 있어야 합니다. 모범 사례라서 ‘있으면 좋다’는 뜻이 아닙니다. 나중에 없었던 것을 반드시 후회하게 될 것이라는 뜻입니다. 드라이런은 작업의 전체 로직을 실행하고, 실제로 일어날 일을 정확히 기록한 다음 멈춥니다. 부작용은 없고, 모든 것이 한눈에 보입니다.
이 패턴은 구현하기 어렵지 않습니다. 드라이런이 내장된 마이그레이션 스크립트 예시는 다음과 같습니다:
#!/bin/bash
set -euo pipefail
DRY_RUN=${DRY_RUN:-true} # Default to dry-run!
DB_HOST=$(get_db_host)
DB_NAME=$(get_db_name)
echo "Target: $DB_HOST / $DB_NAME"
echo "Mode: $([ "$DRY_RUN" = true ] && echo 'DRY RUN' || echo 'LIVE')"
ROWS=$(psql -h "$DB_HOST" -d "$DB_NAME" -t -c \
"SELECT count(*) FROM orphaned_records WHERE created_at < now() - interval '90 days'")
echo "Records to delete: $ROWS"
if [ "$DRY_RUN" = true ]; then
echo "Dry run complete. Set DRY_RUN=false to execute."
exit 0
fi
# Require explicit confirmation for live runs
read -p "Type '$DB_NAME' to confirm deletion: " CONFIRM
if [ "$CONFIRM" != "$DB_NAME" ]; then
echo "Aborted."
exit 1
fi
psql -h "$DB_HOST" -d "$DB_NAME" -c \
"DELETE FROM orphaned_records WHERE created_at < now() - interval '90 days'"
echo "Deleted $ROWS records."
세 가지를 눈여겨보세요. 기본값이 드라이런입니다. 즉, 파괴를 거부하는 게 아니라 파괴에 명시적으로 동의해야 합니다. 그리고 아무것도 하기 전에 대상 데이터베이스와 행 수를 보여줍니다. 실시간 모드에서도 데이터베이스 이름을 입력해야 합니다. 방어선이 세 겹입니다. 이 중 하나만 있었어도 글 처음에 말한 사고는 막을 수 있었습니다.
데이터베이스 마이그레이션 안전망
데이터베이스 마이그레이션은 어떤 배포 파이프라인에서도 위험도가 가장 높은 작업 중 하나입니다. 되돌리기 어려운 경우가 많고, 공유 상태를 대상으로 실행되며, 잘못 실행하면 애플리케이션 전체를 쓰러뜨릴 수 있습니다. 그런데도 대부분의 팀은 이를 그저 또 하나의 배포 단계처럼 취급합니다.
기본적인 것부터 철저한 것까지, 안전 장치의 계층 구조는 다음과 같습니다:
- 환경 검사 — 마이그레이션 스크립트가 실행 전에 의도한 환경을 대상으로 하는지 확인합니다. 당연해 보이지만, 이게 빠져 있는 경우가 생각보다 많아서 놀랄 겁니다.
- 마이그레이션 전 스냅샷 — 마이그레이션이 실행되기 전에 데이터베이스 스냅샷을 자동으로 생성합니다. 마이그레이션이 실패하거나 문제를 일으키면 몇 시간이 아니라 몇 분 안에 복원할 수 있습니다.
- 행 수 가드 — 마이그레이션이 N개 이상의 행에 영향을 준다면 명시적인 확인을 요구합니다. 예상치 못하게 수백만 행을 건드리는 마이그레이션은 거의 항상 버그입니다.
- 구문 타임아웃 — 마이그레이션 쿼리에 공격적인 타임아웃을 설정합니다. 45분 동안 돌아가는 마이그레이션은 테이블을 잠그고 성능을 떨어뜨립니다. 빨리 실패하게 하세요.
- 하위 호환성 유지만 허용 — 모든 마이그레이션이 현재 배포된 코드와 하위 호환되도록 강제합니다. 즉, 컬럼 이름 변경도, 기본값 없는 NOT NULL 추가도, 아직 참조되고 있는 컬럼 삭제도 안 됩니다.
# Rails example using strong_migrations gem
class AddPhoneToUsers < ActiveRecord::Migration[7.1]
def change
# This will raise an error with a safe alternative:
# add_column :users, :phone, :string, null: false
#
# Instead, do it in two steps:
add_column :users, :phone, :string
# Then in a separate migration after backfill:
# change_column_null :users, :phone, false
end
end
Rails의 strong_migrations, PostgreSQL용 squawk, MySQL용 skeema 같은 도구들은 위험한 패턴이 프로덕션에 도달하기 전에 잡아냅니다. 이런 도구를 쓰지 않는다면, 미묘한 마이그레이션 문제를 코드 리뷰에만 기대는 셈인데, 코드 리뷰어는 원래 놓치기 마련입니다.
소프트 삭제와 되돌리기 시간
하드 삭제는 확신이 가득한 순간에 내리는 영구적인 결정입니다. 문제는 그 확신이 종종 틀린다는 점입니다. 소프트 삭제는 레코드를 실제로 제거하지 않고 삭제 표시만 해둠으로써, 실수에서 복구할 수 있는 여유를 줍니다.
-- Hard delete: gone forever
DELETE FROM projects WHERE id = 4872;
-- Soft delete: recoverable
UPDATE projects
SET deleted_at = now(),
deleted_by = 'user:priya'
WHERE id = 4872;
-- Recovery is trivial
UPDATE projects
SET deleted_at = NULL,
deleted_by = NULL
WHERE id = 4872;
트레이드오프는 분명히 존재합니다. 소프트 삭제는 쿼리를 복잡하게 만들고(어디서든 WHERE deleted_at IS NULL이 필요합니다), 저장 공간을 늘리고, 데이터의 실제 상태에 대한 혼란을 만들 수 있습니다. 하지만 사용자가 만든 데이터처럼 실수로 삭제될 가능성이 있는 경우라면 이 트레이드오프는 감수할 가치가 있습니다. GitHub는 저장소를 90일 동안 실제로 삭제하지 않습니다. Slack은 규정 준수를 위해 삭제된 메시지를 보관합니다. Gmail 휴지통은 30일 후에 비워집니다. 이것들은 우연이 아니라 의도된 엔지니어링 결정입니다.
같은 원칙은 인프라에도 적용됩니다. EC2 인스턴스를 종료하는 대신 먼저 중지하세요. 데이터베이스를 삭제하는 대신 mydb_deleted_20260315처럼 이름을 바꾸고, 2주 뒤에 실제로 삭제하도록 캘린더 알림을 설정하세요. 중지된 인스턴스나 이름이 바뀐 데이터베이스를 며칠 남겨두는 비용은, 백업에서 복원하는 비용에 비하면 무시할 만합니다.
배포 안전: 카나리, 서킷 브레이커, 롤백
배포도 대부분의 팀이 충분히 조심해서 다루지 않는 파괴적 작업의 한 종류입니다. 잘못된 배포는 삭제된 데이터베이스만큼이나 효과적으로 프로덕션을 쓰러뜨릴 수 있고, 그런 일은 훨씬 자주 일어납니다.
최소한의 실용적인 배포 안전 설정에는 다음이 포함됩니다:
- 카나리 배포 — 트래픽의 1~5%만 새 버전으로 보냅니다. 에러율이 치솟으면 피해 범위가 커지기 전에 자동으로 롤백합니다.
- 자동 롤백 트리거 — 에러율, 지연 시간, 헬스 체크 실패에 대한 임계값을 정의하고, 이 조건이 충족되면 자동으로 롤백되게 하세요. 새벽 2시에 누군가 알아차리기를 기다리면 안 됩니다.
- 장애 중 배포 동결 — 활성 장애가 있다면 모든 배포를 막으세요. 불을 끄는 중에 관련 없는 변경을 배포하는 사람이 있는 것만큼 최악은 없습니다.
- 원클릭 롤백 — 롤백은 롤포워드보다 쉬워야 합니다. 롤백 과정이 서버에 SSH로 접속해 수동 명령을 실행하는 것이라면, 사실상 롤백 프로세스가 없는 겁니다.
# Kubernetes progressive delivery with Argo Rollouts
apiVersion: argoproj.io/v1alpha1
kind: Rollout
spec:
strategy:
canary:
steps:
- setWeight: 5
- pause: { duration: 5m }
- analysis:
templates:
- templateName: error-rate-check
- setWeight: 25
- pause: { duration: 5m }
- setWeight: 50
- pause: { duration: 10m }
- setWeight: 100
# Auto-rollback if error rate exceeds 1%
abortScaleDownDelaySeconds: 30
여기서 핵심 패턴은 점진적 확약입니다. 0%에서 한 번에 100%로 가지 않습니다. 작은 단계를 밟고, 각 단계를 검증하며, 매 단계마다 물러설 수 있는 능력을 유지합니다. 모든 인스턴스에 한꺼번에 배포하는 YOLO 배포보다 느리지만, 잘못된 배포를 모든 사용자에게 닿기 전에 잡아내는 순간, 추가로 쓴 그 몇 분이 고맙게 느껴질 겁니다.
아키텍처적 예방: 잘못된 행동을 불가능하게 만들기
가장 좋은 안전장치는 조심하라고 요구하지 않습니다. 잘못된 일을 구조적으로 불가능하게 만듭니다. 이것이 가드레일과 경고 표지판의 차이입니다.
- 불변 인프라 — 프로덕션 서버에 SSH로 접속할 수 없다면, 실수로 그곳에서 명령을 실행할 일도 없습니다. 배포가 항상 알려진 이미지에서 만든 새 인스턴스로 이루어진다면, 설정 드리프트도 생기지 않습니다.
- 최소 권한 접근 — 엔지니어는 노트북에 프로덕션 데이터베이스 자격 증명을 보관하면 안 됩니다. 절대로요. 감사 추적이 남는 임시 자격 증명을 발급하는 just-in-time 접근 도구를 사용하세요.
- 환경별 자격 증명 분리 — 스테이징과 프로덕션이 서로 다른 자격 증명 저장소를 쓴다면, 스테이징 도구로 프로덕션에 실수로 연결하는 일은 말 그대로 불가능합니다.
- 삭제 보호 — AWS에서는 EC2 인스턴스에 종료 보호를, RDS 데이터베이스에 삭제 보호를 켤 수 있습니다. 중요한 리소스라면 무조건 켜세요. 5초면 끝나는 설정 변경으로, 치명적인 실수의 한 유형을 아예 막을 수 있습니다.
명령어 하나로 프로덕션을 실수로 파괴할 수 있다면, 문제는 사람이 아니라 명령어 하나가 프로덕션을 파괴하도록 허용한 시스템입니다.
저는 팀들이 프로덕션 장애에 대응하면서 문서, 체크리스트, 교육을 더 늘리는 모습을 많이 봤습니다. 이런 것들도 도움은 되지만, 모두 사람이 완벽하기를 기대하는 방식입니다. 사람은 완벽하지 않습니다. 더 나은 대응은 실수가 애초에 일어날 수 없도록 시스템을 바꾸는 것입니다. 혹시 실수가 일어나더라도 피해 범위가 제한되고 복구가 빠르도록 만드는 것입니다.
안전을 최우선으로 하는 엔지니어링 문화 만들기
도구와 아키텍처도 중요하지만, 실제로 적용되는지는 문화가 결정합니다. 안전 장치를 부담이나 관료주의로 여기는 팀은 마감 압박이 올 때 그것들을 건너뛰게 됩니다. 그리고 마감 압박은 영원히 계속됩니다.
제가 본 가장 효과적인 패턴은 안전 장치를 ‘있으면 좋은 것’이 아니라 일급 엔지니어링 요구사항으로 취급하는 것입니다. 모든 파괴적인 작업은 설계 리뷰를 거쳐야 합니다. 그리고 그 리뷰에서 구체적으로 이렇게 물어야 합니다. 잘못된 대상에서 실행되면 어떻게 되나? 두 번 실행되면 어떻게 되나? 오래된 데이터로 실행되면 어떻게 되나? 어떻게 되돌릴 것인가?
비난 없는 사후 분석(blameless post-mortem)은 이제 기본입니다. 하지만 제가 더 중요하다고 생각하는 덜 흔한 방법은 사전 분석(pre-mortem)입니다. 위험한 변경을 배포하기 전에 팀이 모여서 이렇게 물어보세요. “이게 완전히 잘못됐다고 가정해 봅시다. 무슨 일이 일어났을까?” 사람들은 예측이 아니라 상상의 문제로 질문을 던지면 실패 모드를 놀라울 만큼 잘 찾아냅니다. 그렇게 찾아낸 실패들이 곧 여러분이 만들어야 할 안전 장치가 됩니다.
프로덕션 데이터베이스를 날려버린 그 주니어 엔지니어요? 지금도 회사에 있습니다. 지금은 팀에서 방어적 엔지니어링을 가장 적극적으로 옹호하는 사람 중 하나입니다. 그 사고는 개인의 잘못이 아니라 시스템의 실패였습니다. 그리고 지금 그 시스템은 훨씬 깨뜨리기 어려워졌습니다.


