2장. 코드형 인프라와 배포 자동화 (중수: Terraform·IaC)
학습 목표
- 수동 콘솔 작업(ClickOps)의 한계를 설명하고, Terraform의 선언적(Declarative) 모델과 상태 파일(
terraform.tfstate)이 작동하는 원리를 설명한다. - S3 원격 백엔드(Remote Backend)와 상태 잠금(State Locking:
use_lockfile및 DynamoDB)을 구성해 동시 실행으로 인한 상태 파손을 막는다. - 재사용 가능한 모듈(Module)을 설계하고, 디렉터리 기반 환경 분리와 워크스페이스(Workspace)의 트레이드오프를 비교해 프로덕션 격리 구조를 고른다.
terraform plan출력에서 제자리 수정(~)과 파괴 후 재생성(-/+, Replace)을 구분하고,lifecycle { prevent_destroy = true }와moved·import블록으로 데이터 유실 없이 리팩터링한다.sensitive = true나 Ephemeral 리소스가 상태 파일 암호화를 대신하지 못하는 이유를 설명하고, 상태 파일 접근 통제와 비밀 주입 구조를 설계한다.- 인프라 드리프트(Drift)가 발생하는 원인을 찾고, 무단 수동 변경을 되돌리거나 긴급 핫픽스를 코드로 편입하는 절차를 실행한다.
- 장기 IAM 액세스 키 없이 GitHub Actions OIDC(
AssumeRoleWithWebIdentity)와 최소 조건(sub,aud)으로 안전한 Terraform 배포 파이프라인을 구축한다.
들어가며
클라우드 서비스를 처음 시작할 때는 누구나 웹 브라우저로 AWS, Cloudflare, Supabase 콘솔에 로그인해 마우스로 버튼을 눌러 자원을 만듭니다. 보안 그룹에 포트를 하나 열고, S3 버킷을 만들고, DNS 레코드를 추가하는 데 5분이면 충분합니다. 이른바 **클릭옵스(ClickOps)**입니다.
그런데 서비스가 자라나 스테이징 환경을 똑같이 하나 더 만들어야 하거나, 새벽 장애 중에 누군가 급히 바꾼 방화벽 설정 때문에 다음 날 다른 기능이 멈추면 클릭옵스는 곧바로 재앙이 됩니다. "지난달에 김 대리가 콘솔에서 체크박스 하나를 켰다는데 어디였지?", "스테이징에서는 잘 되는데 왜 프로덕션에서만 권한 오류가 날까?", "실수로 라우팅 테이블을 지웠는데 원래 설정값이 뭐였는지 아무도 모른다." 마우스 클릭에는 Git 커밋 기록도, 코드 리뷰(PR)도, 자동화된 되돌리기(Rollback)도 남지 않기 때문입니다.
**코드형 인프라(Infrastructure as Code, IaC)**와 그 표준 도구인 Terraform은 인프라를 마우스 클릭의 결과물이 아니라 **버전 관리되는 선언적 코드(.tf)**로 바꿉니다. 하지만 Terraform 역시 도구의 작동 원리를 모른 채 쓰면 마우스 클릭보다 더 무서운 사고를 낼 수 있습니다. terraform apply 한 번에 운영 데이터베이스가 삭제 후 재생성(-/+)되어 데이터가 증발하거나, 두 명의 엔지니어가 동시에 명령을 돌리다 상태 파일이 깨지거나, Git 저장소에 올린 terraform.tfstate 파일을 통해 데이터베이스 마스터 비밀번호가 평문으로 유출되기도 합니다. 이 장에서는 Terraform을 안전하고 예측 가능하게 다루기 위한 열 가지 핵심 개념과 실전 CI/CD 자동화 설계를 배웁니다.
핵심 개념
선언적(Declarative) 모델과 Terraform 핵심 워크플로
인프라를 자동화하는 방식은 크게 두 가지로 나뉩니다.
- 명령형(Imperative, 절차적) 방식: 셸 스크립트나 AWS CLI처럼 "어떻게(How)" 할지를 순서대로 지시합니다. 예를 들어 "보안 그룹을 생성하라 → 443 포트를 열어라 → 서버 2대를 띄워라"라고 적습니다. 문제는 스크립트를 두 번 실행했을 때입니다. 이미 보안 그룹이 존재한다며 오류가 나거나, 서버가 2대 더 만들어져 총 4대가 되어 버립니다.
- 선언적(Declarative) 방식: Terraform(HCL, HashiCorp Configuration Language)처럼 **"최종적으로 어떤 상태여야 하는가(What)"**만 적어 둡니다. "443 포트가 열린 보안 그룹 1개와 서버 2대가 있어야 한다"고 선언하면, 현재 서버가 0대일 때는 2대를 새로 만들고, 이미 2대가 있을 때는 아무 일도 하지 않으며(멱등성, Idempotency), 3대가 있을 때는 1대를 줄이고, 포트 번호를 8443으로 고치면 그 차이(Diff)만 계산해 반영합니다.
Terraform의 표준 실행 주기는 세 단계로 돌아갑니다.
terraform init: 작업 디렉터리를 초기화하고, 선언된 클라우드 프로바이더 플러그인(AWS, Cloudflare, Supabase 등)과 모듈을 내려받으며, 상태 파일을 저장할 백엔드(Backend)를 연결합니다.terraform plan: 실제 클라우드 자원을 아직 건드리지 않고, (가) 내가 작성한.tf코드(원하는 상태), (나) 상태 파일(terraform.tfstate, 마지막으로 기억하는 상태), (다) 클라우드 API를 호출해 확인한 실제 인프라의 현재 상태를 비교해 "무엇을 추가(+), 수정(~), 삭제(-), 교체(-/+)할 것인지" 실행 계획서를 보여 줍니다.terraform apply: 검토한 계획서를 바탕으로 클라우드 API를 호출해 변경을 실제로 적용하고, 그 결과를 상태 파일에 기록합니다.
상태 파일(terraform.tfstate)의 역할과 구조
Terraform이 선언적 마법을 부릴 수 있는 비결은 바로 **상태 파일(terraform.tfstate)**에 있습니다.
내 코드에 resource "aws_instance" "web" { ... }라고 적혀 있을 때, Terraform은 이 블록이 AWS 계정에 떠 있는 수백 대의 EC2 인스턴스 중 구체적으로 어느 인스턴스(i-0a1b2c3d4e5f)를 가리키는지 어떻게 알까요? 바로 첫 apply 때 생성된 실제 클라우드 자원 ID와 속성값들을 JSON 형태의 terraform.tfstate 파일에 장부처럼 기록해 두기 때문입니다.
상태 파일은 세 가지 결정적인 역할을 합니다.
| 역할 | 설명 | 상태 파일이 손상·유실되면 생기는 일 |
|---|---|---|
| 코드와 실물 자원 매핑 | .tf의 논리적 주소(aws_db_instance.main)와 클라우드의 물리적 ID(arn:aws:rds:...)를 1:1로 연결 |
이미 운영 중인 DB가 있는 줄 모르고 똑같은 자원을 새로 만들려다 충돌하거나 중복 생성함 |
| 의존성 그래프 보존 | 어떤 자원이 다른 자원을 참조하는지 기록해 삭제·교체 순서를 정확히 계산 | 코드에서 블록을 지웠을 때 무엇을 어떤 순서로 삭제해야 하는지 추적 불가 |
| 성능과 변경 감지 기준선 | 클라우드 API로부터 마지막으로 확인한 속성 스냅샷을 보관 | 매번 모든 클라우드 자원을 전수 스캔해야 하거나 드리프트 기준점을 잃음 |
이처럼 상태 파일은 인프라의 심장부이므로 절대로 손으로 직접 열어 JSON 텍스트를 편집해서는 안 되며, 로컬 노트북 디스크에만 두고 팀원끼리 메신저로 주고받아서도 안 됩니다.
원격 백엔드(Remote Backend)와 상태 잠금(State Locking)
혼자 연습할 때는 로컬 폴더에 terraform.tfstate가 생겨도 되지만, 두 명 이상이 일하거나 CI/CD 파이프라인이 돌아가는 순간 **원격 백엔드(Remote Backend)**와 **상태 잠금(State Locking)**은 선택이 아닌 필수입니다.
만약 엔지니어 A와 엔지니어 B가 각자 노트북에서 같은 인프라에 대해 동시에 terraform apply를 실행하면 무슨 일이 벌어질까요? A의 프로세스가 읽은 상태와 B의 프로세스가 읽은 상태가 서로 덮어써지면서(Race Condition), 한쪽이 만든 자원이 상태 파일에서 증발(고아 자원 발생)하거나 상태 JSON 자체가 깨져 버립니다.
이를 막기 위해 상태 파일은 **버전 관리와 암호화가 켜진 중앙 원격 저장소(예: AWS S3, Terraform Cloud, GCS)**에 두고, 누군가 plan이나 apply를 실행하는 동안에는 다른 실행이 끼어들지 못하게 **상태 잠금(State Locking)**을 겁니다.
terraform {
backend "s3" {
bucket = "company-prod-tfstate-apne2"
key = "network/prod/terraform.tfstate"
region = "ap-northeast-2"
encrypt = true
use_lockfile = true
}
}
AWS S3 백엔드의 상태 잠금 방식은 두 가지를 모두 알아 두어야 합니다.
- 전통적 방식 (
dynamodb_table): S3 버킷과 별도로LockID(문자열 파티션 키)를 가진 DynamoDB 테이블을 만들어 조건부 쓰기(Conditional Write)로 잠금을 관리했습니다. 기존 인프라와 레거시 코드에서 여전히 널리 쓰입니다. - 현대적 S3 네이티브 잠금 (
use_lockfile = true): S3가 조건부 쓰기(If-None-Match헤더를 통한 객체 원자적 생성)를 지원함에 따라, 최신 Terraform에서는 별도의 DynamoDB 테이블을 만들지 않고도use_lockfile = true옵션 하나로 S3 버킷 안에.tflock잠금 파일을 원자적으로 생성해 상태를 안전하게 잠글 수 있습니다.
또한 S3 버킷에는 반드시 **버전 관리(Bucket Versioning)**와 서버 측 암호화(SSE-S3 또는 SSE-KMS), 그리고 **퍼블릭 액세스 완전 차단(Block Public Access)**을 켜 두어야 합니다. 실수로 상태가 잘못 덮어써지더라도 S3 객체의 이전 버전으로 즉시 복구할 수 있기 때문입니다.
주의
간혹 CI 파이프라인이 비정상 종료되어 잠금이 풀리지 않았다고 해서, 동료가 지금 apply를 돌리고 있는지 확인도 하지 않은 채 무조건 -lock=false 옵션을 붙여 실행하거나 terraform force-unlock <LOCK_ID>를 남발하는 경우가 있습니다. -lock=false는 잠금 안전장치를 끄는 위험한 옵션이며, force-unlock은 실제로 실행 중인 다른 프로세스가 하나도 없음을 100% 확인했을 때만 최후의 수단으로 써야 합니다.
모듈(Module) 설계와 버전 고정
인프라 코드가 수천 줄로 늘어나면 같은 패턴(예: VPC + 서브넷 + 보안 그룹, 또는 표준 S3 버킷 + 암호화 + 로그 설정)이 여러 곳에서 반복됩니다. Terraform의 **모듈(Module)**은 관련된 리소스 묶음을 함수처럼 재사용 가능한 패키지로 추상화한 단위입니다.
- 입력 변수 (
variables.tf,variable블록): 모듈 외부에서 주입받는 설정값입니다.type과validation블록을 써서 허용되지 않는 리전이나 잘못된 CIDR 형식이 들어오면plan단계에서 즉시 실패하게 만듭니다. - 로컬 값 (
locals블록): 모듈 내부에서만 계산해 쓰는 상수·조합 표현식입니다. - 출력 값 (
outputs.tf,output블록): 모듈이 만든 자원의 ID나 도메인 주소처럼 상위(루트) 모듈이나 다른 모듈에 전달할 결과값입니다.
외부 공개 레지스트리나 사내 Git 저장소의 모듈을 가져다 쓸 때 가장 중요한 보안·안정성 원칙은 **버전 고정(Version Pinning)**입니다.
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.16.0"
name = "prod-vpc"
cidr = "10.20.0.0/16"
}
버전을 지정하지 않거나 너무 넓은 범위로 열어 두면, 어느 날 사소한 수정을 배포하려 terraform init -upgrade를 돌렸을 때 모듈의 메이저 버전이 올라가면서 하위 리소스 구조가 바뀌어 프로덕션 자원이 통째로 교체(-/+)될 수 있습니다. 프로덕션 환경에서는 정확한 버전(또는 검증된 사내 Git 커밋 SHA·태그)으로 고정하고, 의존성 잠금 파일인 .terraform.lock.hcl을 반드시 Git 저장소에 커밋해 모든 개발자와 CI 러너가 동일한 해시의 프로바이더 바이너리를 쓰도록 강제해야 합니다.
환경 격리: 디렉터리별 상태 분리 대 워크스페이스(Workspace)
개발(dev), 스테이징(staging), 프로덕션(prod) 세 환경을 Terraform으로 관리할 때 구조를 어떻게 나누어야 할까요? 두 가지 방법이 있으며, 둘의 차이와 한계를 명확히 구분해야 합니다.
| 비교 항목 | 디렉터리·백엔드 분리 (envs/dev, envs/prod) |
단일 디렉터리 + CLI 워크스페이스 (terraform workspace) |
|---|---|---|
| 상태 파일 격리 | 완전히 다른 S3 버킷·경로, 서로 다른 IAM 권한 부여 가능 | 같은 백엔드 버킷 아래 env:/dev/..., env:/prod/...로 경로만 나뉨 |
| 코드·버전 차이 수용 | staging에서 새 모듈 버전을 먼저 시험한 뒤 며칠 후 prod에 반영 가능 |
모든 워크스페이스가 완전히 동일한 .tf 코드와 모듈 버전을 공유함 |
| 작업자 실수 위험 | envs/dev 폴더 안에서는 죽어도 prod 자원을 건드릴 수 없음 |
현재 선택된 워크스페이스가 prod인 줄 모르고 dev인 줄 착각해 destroy/apply 실행 위험 |
| 권장 용도 | 프로덕션과 비프로덕션의 영구적 환경 격리 (정석) | 한 개발자가 PR마다 일시적인 테스트 환경을 찍어내고 지우는 용도 |
즉 프로덕션(prod) 환경은 반드시 별도의 디렉터리, 별도의 상태 파일 백엔드, 그리고 가급적 별도의 클라우드 계정(AWS Account / 프로젝트)과 전용 IAM 역할로 물리적·논리적 경계를 분리해야 합니다. 공통 코드는 재사용 모듈(modules/app-stack)로 빼고, envs/stage/main.tf와 envs/prod/main.tf에서 각자 그 모듈을 호출하면서 자기만의 백엔드와 버전을 지정하는 구조가 가장 안전합니다.
terraform plan 정밀 해독과 파괴적 변경 차단(prevent_destroy)
코드형 인프라 사고의 90%는 terraform plan이 출력한 경고를 제대로 읽지 않고 습관적으로 yes를 입력하거나 -auto-approve로 밀어붙일 때 일어납니다. plan 출력에서 리소스 앞에 붙는 기호 네 가지는 반드시 외워야 합니다.
| 기호 | 의미 | 클라우드에서 실제로 일어나는 일 | 위험도 |
|---|---|---|---|
+ (Create) |
새로 생성 | 코드에 새로 추가된 자원을 클라우드에 생성 | 낮음 (비용·할당량 확인) |
- (Destroy) |
완전 삭제 | 코드에서 제거된 자원을 클라우드에서 영구 삭제 | 매우 높음 (데이터 소멸) |
~ (Update in-place) |
제자리 수정 | 자원을 지우지 않고 설정값(예: 태그, 보안 그룹 규칙, 용량 확장)만 API로 변경 | 중간 (순단 여부 확인) |
-/+ 또는 +/- (Replace) |
파괴 후 재생성 (교체) | 기존 자원을 완전히 삭제(-)한 뒤 새 자원을 생성(+)함. 자원 ID와 내부 데이터가 초기화됨! |
치명적 (DB·볼륨·고정 IP 유실) |
왜 나는 코드에서 한 줄만 고쳤는데 ~(제자리 수정)가 아니라 -/+(파괴 후 재생성)가 뜰까요? 클라우드 벤더의 API가 그 속성의 변경을 허용하지 않기(Immutable 속성) 때문입니다. 예를 들어 AWS RDS 데이터베이스의 식별자나 암호화 설정 변경, EC2 인스턴스의 서브넷(subnet_id)이나 AMI 변경, S3 버킷의 이름(bucket) 변경은 기존 자원을 유지한 채 바꿀 수 없으므로 Terraform이 # forces replacement라는 주석과 함께 -/+를 띄웁니다.
상태를 가진 핵심 자원(운영 데이터베이스, 영구 스토리지 버킷, 암호화 KMS 키, 핵심 VPC)이 실수로라도 삭제되거나 교체(-/+)되는 것을 코드 수준에서 원천 봉쇄하는 안전장치가 바로 **lifecycle 블록의 prevent_destroy = true**입니다.
resource "aws_db_instance" "production" {
identifier = "aidan-prod-postgres"
engine = "postgres"
instance_class = "db.r6g.large"
lifecycle {
prevent_destroy = true
}
}
이렇게 선언해 두면, 누군가 실수로 DB의 불변 속성을 건드려 교체(-/+)가 유발되거나 리소스 블록을 지웠을 때 Terraform이 plan 단계에서 즉시 에러(Instance cannot be destroyed)를 내며 멈춥니다. 또한 무중단 교체가 필요한 웹 서버나 인증서처럼 새 자원을 먼저 만든 뒤 기존 자원을 지워야 하는 리소스에는 create_before_destroy = true를 사용하고, 외부 오토스케일러가 수시로 바꾸는 속성(예: 현재 컨테이너 개수 desired_capacity)은 ignore_changes = [desired_capacity]로 지정해 불필요한 덮어쓰기를 방지합니다.
무중단 리팩터링과 기존 자원 편입: moved 블록과 import 블록
인프라 코드를 정리하다 보면 리소스 이름을 aws_s3_bucket.old_name에서 aws_s3_bucket.assets로 바꾸거나, 루트에 있던 리소스를 하위 모듈(module.storage.aws_s3_bucket.assets) 안으로 옮기고 싶을 때가 있습니다.
이때 코드에서 이름만 쓱 바꾸고 terraform plan을 돌리면 충격적인 결과가 나옵니다. Terraform은 이름이 바뀐 줄 모르고 **"옛날 이름(old_name)은 삭제(-)하고, 새 이름(assets)은 새로 생성(+)하겠다"**는 계획을 세웁니다! 그대로 apply하면 운영 중인 S3 버킷이나 데이터베이스가 삭제됩니다.
과거에는 엔지니어가 터미널에서 terraform state mv 명령어를 수동으로 쳐서 상태 파일을 고쳤지만, 이는 PR 리뷰로 검증할 수 없고 다른 팀원이나 CI 파이프라인과 동기화하기 어려웠습니다. 현대 Terraform에서는 코드 안에 moved 블록을 선언해 안전하게 리팩터링합니다.
moved {
from = aws_s3_bucket.old_name
to = module.storage.aws_s3_bucket.assets
}
moved 블록을 커밋하면 terraform plan은 자원을 삭제·생성하는 대신 상태 파일 안의 주소표만 안전하게 옮긴다는 메시지를 보여 주며, 실물 클라우드 자원은 단 1초의 중단도 없이 그대로 유지됩니다.
마찬가지로 과거에 콘솔에서 마우스로 만들었던 기존 클라우드 자원을 Terraform 관리 아래로 데려올 때는 선언적 import 블록을 사용합니다.
import {
to = aws_s3_bucket.legacy_uploads
id = "company-legacy-uploads-prod"
}
import 블록을 적고 terraform plan -generate-config-out=generated.tf를 활용하거나 리소스 블록을 맞춘 뒤 plan을 실행해 차이(~나 -/+)가 0건(No changes)이 되도록 코드를 맞춘 다음 apply하면, 기존 자원을 건드리지 않고 상태 파일에 안전하게 편입할 수 있습니다.
상태 파일의 평문 비밀 노출 문제와 민감 변수 관리
Terraform 보안에서 가장 많이 오해하는 사실이 하나 있습니다.
"변수나 출력에 sensitive = true를 붙이면 terraform.tfstate 파일 안에서도 암호화되거나 숨겨진다"는 믿음은 완전히 틀렸습니다!
sensitive = true가 하는 일은 오직 terraform plan과 terraform apply를 실행할 때 터미널 화면(콘솔 로그)에 값이 (sensitive value)로 가려져 출력되게 하는 것뿐입니다. Terraform이 다음번 plan에서 값이 바뀌었는지 비교하려면 원본 값을 알아야 하므로, 일반 리소스 속성으로 들어간 데이터베이스 비밀번호, TLS 개인키, API 토큰은 terraform.tfstate JSON 파일 안에 글자 그대로 평문(Plaintext)으로 저장됩니다.
따라서 상태 파일 보안을 위해서는 다음 네 가지 원칙을 반드시 함께 적용해야 합니다.
terraform.tfstate와.terraform/디렉터리는 어떤 경우에도 Git에 커밋하지 않습니다(.gitignore에 필수 등록).- 원격 백엔드(S3 등) 저장소 자체를 KMS 등으로 암호화(
encrypt = true)하고, 상태 파일을 읽을 수 있는 IAM 권한을 배포 파이프라인과 최소한의 관리자 역할로 엄격히 제한합니다. 상태 파일 열람 권한은 곧 인프라 내 모든 비밀값 열람 권한과 같습니다. - Terraform 코드에서 비밀번호를 직접 생성하거나 평문 변수로 넘기는 대신, 클라우드 관리형 마스터 암호 관리 기능(예: RDS의
manage_master_user_password = true)을 쓰거나 런타임에 애플리케이션이 Secrets Manager / Parameter Store / Vault에서 직접 비밀을 읽어오도록 설계해 상태 파일에 비밀값 자체가 적히지 않게 합니다. - 최신 Terraform(1.10+)이 지원하는 임시(Ephemeral) 변수·리소스(
ephemeral = true,ephemeral블록) 및 쓰기 전용 속성(Write-only attributes, 예:password_wo)을 사용하면, 실행 중에만 비밀값을 읽어들여 대상 API에 전달하고 상태 파일과 플랜 파일에는 아예 기록하지 않도록 만들 수 있습니다.
드리프트(Drift) 감지와 복구 절차
**드리프트(Configuration Drift)**란 코드(.tf)와 상태 파일이 기억하는 인프라의 모습과, 실제 클라우드에서 돌아가고 있는 인프라의 모습이 서로 어긋난 상태를 말합니다.
왜 드리프트가 생길까요? 가장 흔한 원인은 새벽 장애 대응 중 당직 엔지니어가 급한 불을 끄려고 AWS나 Cloudflare 콘솔에 직접 들어가 보안 그룹 포트, 인스턴스 사양, DNS 레코드, WAF 규칙을 마우스로 바꾼 뒤 코드에 반영하지 않은 경우입니다.
드리프트를 방치하면, 한 달 뒤 전혀 다른 기능을 배포하려고 누군가 terraform apply를 돌리는 순간 Terraform이 콘솔에서 수동으로 바꿨던 핫픽스 설정을 "코드와 다르다"며 원래대로 덮어써 버려(Revert) 장애가 재발합니다.
드리프트에 대응하는 올바른 절차는 다음과 같습니다.
- 정기적 드리프트 탐지: CI 스케줄러(예: 매일 아침)에서
terraform plan -detailed-exitcode(변경 없음0, 오류1, 드리프트/변경 감지 시2반환) 또는terraform plan -refresh-only를 실행해 코드 없이 클라우드에서 바뀐 내역이 있으면 즉시 슬랙·티켓으로 경보를 보냅니다. - 원인 판별과 두 갈래 복구:
- 무단 변경이나 실수인 경우: 현재
.tf코드를 기준으로terraform apply를 실행해 클라우드 상태를 다시 코드의 정의대로 되돌립니다(코드로 수렴). - 유지해야 할 정당한 긴급 핫픽스인 경우: 먼저
.tf코드를 수정해 콘솔에서 바꾼 실제 설정과 똑같이 맞춘 뒤,terraform plan을 돌려 **추가 변경이 없음(No changes. Your infrastructure matches the configuration.)**을 확인하고 커밋합니다.
- 무단 변경이나 실수인 경우: 현재
GitHub Actions OIDC를 통한 무비밀키(Keyless) CI/CD 파이프라인
과거에는 GitHub Actions에서 Terraform으로 AWS 인프라를 배포하기 위해 IAM 사용자(User)를 만들고 장기 액세스 키(AWS_ACCESS_KEY_ID와 AWS_SECRET_ACCESS_KEY)를 발급해 GitHub Secrets에 넣어 두었습니다. 이 방식은 치명적인 약점이 있습니다. 한번 발급된 정적 키는 만료되지 않아서, 만약 CI 로그 설정 실수나 서드파티 액션 공급망 공격으로 키가 한 번만 유출되면 공격자가 언제 어디서든 우리 클라우드 전체를 장악할 수 있고, 키를 주기적으로 회전하는 일도 번거롭습니다.
현대 클라우드 보안의 표준은 장기 정적 키를 완전히 없애고 **OIDC(OpenID Connect) 기반 단기 토큰 교환(AssumeRoleWithWebIdentity)**을 쓰는 것입니다.
작동 흐름은 다음과 같습니다.
- AWS IAM에 GitHub의 OIDC 발급자(
https://token.actions.githubusercontent.com)를 신뢰하는 자격 증명 공급자(Identity Provider)로 등록합니다. - GitHub Actions 워크플로에
permissions: { id-token: write, contents: read }를 부여하면, 작업이 실행될 때 GitHub가 "이 요청은org/repo저장소의main브랜치에서 돌고 있는 워크플로가 맞다"는 전자서명된 단기 OIDC JWT를 발급합니다. - 워크플로가 이 JWT를 AWS STS(
AssumeRoleWithWebIdentity)에 제시하면, AWS는 IAM 역할의 신뢰 정책(Trust Policy) 조건을 대조한 뒤 1시간 남짓만 유효한 임시 자격 증명을 내어줍니다. 저장할 장기 비밀키가 아예 존재하지 않습니다!
여기서 시험과 실무에서 가장 중요한 핵심은 IAM 역할 신뢰 정책의 Condition 절을 얼마나 좁게 설정하느냐입니다.
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": "repo:acme-corp/infra-live:ref:refs/heads/main"
}
}
}
]
}
만약 sub(Subject) 조건을 빠뜨리면 전 세계 모든 GitHub 사용자의 아무 저장소나 우리 AWS 역할을 빌려 쓸 수 있게 됩니다! 또한 StringLike로 "repo:acme-corp/infra-live:*"처럼 모든 브랜치에 프로덕션 apply 권한이 있는 역할을 열어 두면, 아무 개발자나 피처 브랜치에서 워크플로 파일(.github/workflows/deploy.yml)을 고쳐 PR만 올려도 프로덕션 관리자 권한을 획득할 수 있습니다. 따라서 다음의 PR plan / main 병합 후 apply 분리 원칙을 반드시 지켜야 합니다.
| 파이프라인 단계 | 트리거 시점 | 사용하는 IAM 역할과 sub 조건 |
권한 범위 |
|---|---|---|---|
PR 검증 (plan) |
Pull Request 생성·갱신 | terraform-planner 역할 (pull_request 또는 전용 plan 환경 제한) |
읽기 전용(Read-Only) + 상태 잠금·조회 권한. PR 코멘트에 plan 결과만 게시 |
프로덕션 반영 (apply) |
코드 리뷰 승인 후 main 브랜치 병합 |
terraform-applier 역할 (ref:refs/heads/main 또는 승인 보호된 environment:production으로 엄격 제한) |
실제 리소스 생성·수정·삭제 및 상태 파일 쓰기 권한 |
깊이 읽기
PR 기반 인프라 자동화(Atlantis 패턴)의 공격 표면과 플랜 파일 무결성
"PR을 열면 자동으로 terraform plan을 돌려 코멘트로 보여 주고, 리뷰어가 승인하면 apply한다"는 워크플로는 매우 편리하지만, 공격자의 눈으로 보면 아주 위험한 함정이 숨어 있습니다.
terraform plan은 단순한 문법 검사기가 아닙니다. plan 단계에서도 외부 모듈과 프로바이더 코드를 내려받아 실행하고, data "external" 데이터 소스나 로컬 표현식을 평가하며, 원격 상태 파일을 전부 메모리에 읽어 들입니다.
만약 외부인이 포크(Fork)해서 올린 PR이나 아직 코드 리뷰를 거치지 않은 브랜치 코드에 대해 자동으로 프로덕션 자격 증명을 물려 terraform plan을 실행하게 두면 어떻게 될까요? 악의적인 PR 작성자가 .tf 파일에 상태 파일의 비밀값을 읽어 외부 웹훅으로 전송하는 데이터 소스를 한 줄 적어 넣는 것만으로도, apply를 하기도 전에 plan 단계에서 이미 프로덕션 상태 파일 속 비밀이 모두 탈취됩니다!
따라서 안전한 PR 자동화 파이프라인은 세 가지 방어선을 둡니다.
- 외부 포크 저장소에서 올라온 PR에서는 클라우드 자격 증명이 필요한 워크플로를 자동 실행하지 않습니다.
- PR의
plan역할은 실제 리소스를 변경할 수 없는 최소 읽기 권한만 가지며, 워크플로 파일(.github/workflows/*)이나 백엔드 설정 자체를 변경하는 PR은 인프라 관리자의 리뷰 후에만 실행되게 보호합니다(GitHub Environments의 Required Reviewers 활용). plan을 검토한 시점과 실제로apply를 실행하는 시점 사이에 다른 변경이 끼어드는 것(TOCTOU)을 막기 위해,terraform plan -out=tfplan.binary로 생성한 **이진 플랜 산출물(Plan Artifact)**의 해시를 검증해terraform apply tfplan.binary로 정확히 검토된 변경만 반영되게 합니다(이때tfplan.binary파일 안에도 상태 파일과 마찬가지로 평문 비밀값이 들어 있으므로 절대 공개 아티팩트 저장소에 올리면 안 됩니다).
대규모 인프라에서 상태 파일 폭발 반경(Blast Radius) 줄이기
초기에는 네트워크(VPC), 데이터베이스(RDS), 쿠버네티스/앱 클러스터, DNS/Cloudflare 설정을 단 하나의 디렉터리와 하나의 terraform.tfstate 파일에 몰아넣기 쉽습니다(모놀리식 상태).
그러나 리소스가 수백 개로 늘어나면 단일 상태 파일은 세 가지 큰 문제를 일으킵니다.
plan속도 저하와 API 레이트 리밋:plan한 번을 돌릴 때마다 수백 개 자원의 현재 상태를 클라우드 API로 조회하느라 10분 이상 걸리고 API 호출 한도에 걸립니다.- 잠금 병목: 프런트엔드 엔지니어가 CDN 캐시 규칙 하나를 고치려고
plan을 돌리는 동안, 데이터베이스 엔지니어는 상태 잠금에 막혀 아무 작업도 할 수 없습니다. - 폭발 반경(Blast Radius) 확대: 누군가 앱 설정 하나를 잘못 건드려 상태가 꼬이면 VPC와 운영 데이터베이스까지 한꺼번에 위험에 노출됩니다.
해법은 **변경 주기와 생명주기(Lifecycle), 그리고 소유 팀에 따라 상태 파일을 계층별로 쪼개는 것(State Segmentation)**입니다. 예를 들어 (1) 1년에 몇 번 안 바뀌는 01-network(VPC, 서브넷, ZTNA 커넥터), (2) 데이터 보존이 최우선인 02-data(RDS, S3, KMS), (3) 하루에도 수차례 바뀌는 03-app-edge(Cloudflare 규칙, 앱 라우팅, 워커)로 디렉터리와 상태 파일을 분리합니다. 하위 계층이 상위 계층의 VPC ID나 서브넷 ID를 참조해야 할 때는 하드코딩 대신 terraform_remote_state 데이터 소스(또는 SSM Parameter Store / 태그 기반 data 조회)로 읽기 전용 참조만 연결하면 폭발 반경을 각 계층 안으로 완벽히 가둘 수 있습니다.
실무 장면
솔빛모빌리티: 리소스 이름 변경이 부른 DB 삭제 위기와 moved·prevent_destroy
물류 배차 AI 플랫폼을 운영하는 '솔빛모빌리티'의 데브옵스 엔지니어는 초기에 급하게 작성된 Terraform 코드를 깔끔하게 정리하는 리팩터링 작업을 맡았다. 그는 루트 모듈에 있던 aws_db_instance.db1이라는 촌스러운 리소스 이름을 aws_db_instance.logistics_primary로 바꾸고, 스토리지 크기를 100GB에서 200GB로 늘리는 PR을 올렸다.
CI 파이프라인이 출력한 terraform plan 요약 마지막 줄에는 Plan: 1 to add, 0 to change, 1 to destroy.라고 적혀 있었다. 리뷰어는 스토리지 용량을 늘리는 사소한 변경이라고만 생각하고 자세히 보지 않은 채 승인 버튼을 눌렀다. 다행히 수동 apply 직전에 시니어 엔지니어가 플랜 로그를 다시 훑어보다가 등골이 서늘해졌다. Terraform이 이름 변경을 "기존 운영 DB(db1) 완전 삭제(-) 후 빈 새 DB(logistics_primary) 생성(+)"으로 해석한 것이었다! 만약 그대로 반영되었다면 최근 배차 기록 수십만 건이 담긴 운영 데이터베이스가 증발할 뻔했다.
팀은 즉시 배포를 멈추고 세 가지 조치를 적용했다.
moved블록으로 무중단 주소 변경: 코드에moved { from = aws_db_instance.db1, to = aws_db_instance.logistics_primary }를 추가했다. 다시terraform plan을 돌리자1 to destroy, 1 to add가 사라지고, 상태 파일 내 주소 이동과 함께 스토리지 크기만 제자리 수정(~,1 to change)되는 안전한 계획이 출력되었다.prevent_destroy의무화: 모든 프로덕션 데이터베이스, S3 버킷, KMS 키 리소스에lifecycle { prevent_destroy = true }를 추가했다. 이제 누군가 실수로 리소스 이름을 바꾸거나 불변 속성을 건드려 교체(-/+)가 발생하면plan단계에서 즉시 에러로 빌드가 실패한다.- CI PR 코멘트 하이라이트: PR 코멘트 봇이
to destroy나must be replaced(-/+)가 1건이라도 포함되면 빨간색 경고 배너를 띄우고 인프라 리드의 별도 승인을 요구하도록 파이프라인을 고쳤다.
다온핀테크: 퇴사자 정적 AWS 키 유출 위협을 끝낸 GitHub OIDC 전환과 드리프트 정상화
금융 데이터 분석 기업 '다온핀테크'는 보안 감사에서 두 가지 지적을 받았다. 첫째, GitHub Actions에 등록된 AWS_SECRET_ACCESS_KEY가 2년 전에 퇴사한 엔지니어 명의로 발급된 장기 IAM 사용자 키였고 마지막 회전 기록도 없었다. 둘째, 지난 분기 디도스(DDoS) 대응 당시 보안팀이 AWS 콘솔에서 직접 수정한 WAF 및 보안 그룹 규칙 12건이 Terraform 코드에 반영되지 않아, 개발팀이 무서워서 두 달째 terraform apply를 못 돌리고 있었다.
인프라 팀은 1주일 동안 두 문제를 순서대로 해결했다.
| 과제 | 실행 절차 | 검증 결과 |
|---|---|---|
| 1. 드리프트 흡수 | terraform plan으로 콘솔 수동 변경 12건을 전수 목록화 → 보안팀과 검토해 유지할 규칙 10건은 .tf 코드에 똑같이 작성하고 신규 생성 자원은 import 블록으로 편입, 불필요한 임시 규칙 2건은 제거 |
terraform plan 실행 시 No changes 도달, 이후 매일 09시 스케줄 워크플로로 plan -detailed-exitcode 감시 |
| 2. S3 상태 잠금 정비 | 기존 로컬/수동 백엔드를 버전 관리·KMS 암호화가 켜진 S3 백엔드(use_lockfile = true)로 통합하고 상태 파일 읽기 권한을 최소 역할로 제한 |
동시 실행 시 .tflock으로 즉시 대기·차단 확인 |
| 3. OIDC 무비밀키 전환 | AWS IAM에 GitHub OIDC 공급자 등록 → PR용 읽기 역할(plan)과 ref:refs/heads/main 전용 반영 역할(apply) 분리 생성 → 기존 장기 IAM 사용자 키 비활성화 후 삭제 |
GitHub Secrets에 저장된 클라우드 장기 비밀키 0개, 모든 배포가 1시간 만료 임시 토큰으로만 동작 |
자주 틀리는 지점
- "
sensitive = true를 붙였으니terraform.tfstate파일을 사내 공유 폴더나 Git에 올려도 비밀번호가 안 보인다." 가장 위험한 착각입니다.sensitive = true는 터미널 출력만 가릴 뿐, 상태 파일 안에는 값이 평문으로 적힙니다. 상태 파일은 절대 Git에 올리지 말고 암호화된 원격 백엔드에서 엄격한 접근 통제로 지켜야 합니다. - "
terraform plan에서-/+기호는 설정을 조금 고친다는 뜻이니 안심하고apply해도 된다." 제자리 수정은~기호입니다.-/+는 **기존 자원을 완전히 삭제하고 새로 만든다(Replace)**는 뜻이므로, 데이터베이스나 스토리지에 뜨면 데이터 유실과 직결됩니다. - "리소스 이름을 바꿀 때는
.tf파일에서 이름만 고치고apply하면 Terraform이 알아서 이름을 바꿔 준다." 코드에서 리소스 식별자만 바꾸면 Terraform은 기존 자원을 삭제(-)하고 새 자원을 생성(+)합니다. 반드시moved블록을 함께 적어 상태 파일의 매핑을 옮겨야 합니다. - "워크스페이스(
terraform workspace new prod)만 나누면 개발 환경과 프로덕션 환경이 완벽히 격리된다." CLI 워크스페이스는 같은 코드와 같은 백엔드 저장소 설정을 공유하며 작업자가 현재 워크스페이스를 착각하기 쉽습니다. 프로덕션 환경은 디렉터리·백엔드·IAM 역할을 분리하는 것이 안전합니다. - "GitHub Actions OIDC를 설정할 때 IAM 신뢰 정책에
aud: sts.amazonaws.com만 넣으면 안전하다."sub(저장소 이름과 브랜치/환경) 조건을 검증하지 않으면 다른 GitHub 저장소의 워크플로도 내 IAM 역할을 가로채(AssumeRole) 사용할 수 있습니다. - "콘솔에서 급하게 바꾼 설정은 Terraform이 다음번 배포 때 알아서 눈치채고 보존해 준다." Terraform은 코드(
.tf)를 정답(Source of Truth)으로 삼습니다. 콘솔에서 수동으로 바꾼 설정(드리프트)을 코드에 반영하지 않으면 다음apply때 가차 없이 덮어써져 사라집니다.
한눈에 정리
| 핵심 주제 | 시험과 실무에서 기억할 핵심 문장 |
|---|---|
| 선언적 모델과 State | .tf 코드는 원하는 최종 상태를 선언하고, terraform.tfstate는 코드와 실제 클라우드 자원 ID를 매핑한다 |
| 원격 백엔드와 잠금 | 상태 파일은 버전 관리·암호화가 켜진 S3 등 원격 백엔드에 두고 use_lockfile = true(또는 DynamoDB)로 동시 실행을 잠근다 |
plan 기호 구분 |
+ 생성, - 삭제, ~ 제자리 수정(In-place), -/+ 파괴 후 재생성(Replace, 데이터 유실 위험) |
| 삭제 방지 안전장치 | 핵심 DB·버킷·키에는 lifecycle { prevent_destroy = true }를 걸어 실수로 인한 삭제·교체를 plan에서 차단한다 |
| 무중단 리팩터링·편입 | 코드 내 이름·모듈 경로 변경은 moved 블록으로, 수동 생성 자원의 코드 편입은 import 블록으로 처리한다 |
| 상태 파일 보안 | sensitive = true는 콘솔 출력만 가린다. 상태 파일에는 평문이 남으므로 Git 커밋 금지, 백엔드 KMS 암호화, Ephemeral/쓰기 전용 속성을 활용한다 |
| 환경 분리 | 프로덕션은 워크스페이스 공유 대신 별도 디렉터리·상태 파일·클라우드 계정/IAM 역할로 폭발 반경을 격리한다 |
| 드리프트 대응 | 정기 plan -detailed-exitcode로 감지하고, 무단 변경은 apply로 되돌리며 정당한 핫픽스는 코드에 먼저 반영해 No changes를 맞춘다 |
| CI/CD OIDC | 장기 액세스 키 대신 AssumeRoleWithWebIdentity를 쓰되, 신뢰 정책에서 aud와 정확한 sub(저장소·main 브랜치/환경)를 검증한다 |
공식 문서로 확인하기
- HashiCorp Terraform — Purpose of Terraform State: 상태 파일이 필요한 이유와 메타데이터 매핑 구조.
- HashiCorp Terraform — S3 Backend Configuration: S3 원격 백엔드 암호화와
use_lockfile/ DynamoDB 상태 잠금 설정. - HashiCorp Terraform — The lifecycle Meta-Argument:
prevent_destroy,create_before_destroy,ignore_changes동작 원리. - HashiCorp Terraform — Refactoring (
moved) &importBlocks: 무중단 리소스 주소 변경과 선언적 임포트. - HashiCorp Terraform — Sensitive Data in State & Ephemeral Values: 상태 파일 내 민감 정보 평문 저장 문제와 보호 대책.
- GitHub Docs — Configuring OpenID Connect in Amazon Web Services: OIDC 토큰 발급과 IAM 신뢰 정책(
sub,aud) 구성.
셀프 체크
2-1. 팀원 두 명이 각자 노트북에서 같은 인프라를 대상으로 동시에 terraform apply를 실행했을 때 발생할 수 있는 문제를 설명하고, AWS S3 백엔드에서 이를 예방하기 위해 켜야 하는 기능 두 가지(잠금과 복구 대비)를 쓰라.
2-2. terraform plan 출력에서 리소스 앞에 붙는 ~ 기호와 -/+ 기호의 차이를 설명하고, 운영 중인 데이터베이스 리소스에서 실수로 -/+나 -가 실행되지 않도록 HCL 코드 안에 넣어야 하는 설정 블록을 쓰라.
2-3. 운영 중인 리소스의 이름을 .tf 코드에서 aws_instance.app에서 aws_instance.api_server로 바꾼 뒤 곧바로 apply하면 어떤 일이 벌어지는지 설명하고, 서비스 중단 없이 이름만 안전하게 바꾸기 위해 추가해야 하는 블록을 쓰라.
2-4. 변수에 sensitive = true를 설정했음에도 불구하고 terraform.tfstate 파일을 공개 Git 저장소에 커밋하면 모든 비밀값이 유출되는 이유를 설명하고, 상태 파일의 비밀 노출을 막기 위한 보안 대책을 두 가지 이상 쓰라.
2-5. 새벽 장애 중에 당직자가 AWS 콘솔에서 직접 보안 그룹에 포트 규칙을 추가(핫픽스)했고, 이 규칙을 앞으로도 계속 유지하기로 결정했다. 다음 배포 때 이 규칙이 사라지지 않게 하려면 Terraform 코드를 어떻게 처리하고 무엇을 확인해야 하는지 순서대로 설명하라.
2-6. 설계 과제: GitHub Actions에서 장기 AWS 액세스 키 없이 Terraform을 실행하려 한다. (1) PR이 열렸을 때 도는 워크플로와 (2) main 브랜치에 병합되었을 때 도는 워크플로의 IAM 역할을 왜 분리해야 하는지 설명하고, 프로덕션 반영(apply) 역할의 OIDC 신뢰 정책(Condition)에서 반드시 검사해야 할 클레임 두 가지를 쓰라.
실습 과제
실습
terraform plan 위험 판독과 무중단 리팩터링 코드 작성하기 (45분)
- 현재 루트 모듈에 선언된 운영 데이터베이스
resource "aws_db_instance" "legacy_db"와 S3 버킷resource "aws_s3_bucket" "legacy_bucket"이 있다고 가정한다. - 두 자원을 삭제하거나 재생성(
-/+)하지 않고 이름을 각각aws_db_instance.prod_primary와module.storage.aws_s3_bucket.prod_assets로 옮기는moved블록 두 개를 작성한다. - 두 리소스 정의에 실수로 인한 삭제를 막는
lifecycle설정을 추가하고, S3 백엔드 블록(encrypt = true,use_lockfile = true)을 함께 작성한다. - 만약 동료의 PR에서
aws_db_instance.prod_primary must be replaced (-/+)가 떴을 때 확인해야 할 체크리스트 3개 항목을 적는다.
완료 기준: moved 블록의 from/to 주소가 정확하고, prevent_destroy = true와 S3 잠금 설정이 모두 포함되어 있으며, 교체(-/+) 유발 속성을 찾는 검토 기준이 명시되어 있다.
실습
GitHub Actions OIDC 최소 권한 신뢰 정책(Trust Policy) 설계하기 (45분)
- 사내 저장소
trappist-org/cloud-infra를 위한 두 개의 AWS IAM 역할(tf-pr-plan-role,tf-main-apply-role)의 신뢰 정책 JSON을 각각 작성한다. - 두 역할 모두
Action은sts:AssumeRoleWithWebIdentity로 지정하고,aud조건이sts.amazonaws.com인지 검사한다. tf-main-apply-role의sub조건은 오직main브랜치(repo:trappist-org/cloud-infra:ref:refs/heads/main) 또는 보호된production환경(repo:trappist-org/cloud-infra:environment:production)에서만 일치하도록 제한한다.- 왜
tf-main-apply-role에"repo:trappist-org/cloud-infra:*"같은 와일드카드를 쓰면 안 되는지 공격 시나리오를 세 문장으로 적는다.
완료 기준: 두 IAM 신뢰 정책 JSON에 aud와 sub 조건이 정확히 들어가 있고, 피처 브랜치 PR을 통한 권한 탈취 시나리오와 방어 원리가 설명되어 있다.