~ / posts / developer
smith@lab:~/posts$ cat developer/terraform-plan-refresh-state-operations-guide.md

테라폼 plan 옵션 고르는 법, refresh·저장된 plan·상태 분리 판단표

terraform plan을 CI에 넣으려는데 -refresh=false를 붙여야 빨라지는지, -out 파일은 꼭 남겨야 하는지, 상태 파일 하나에 리소스를 어디까지 넣어도 되는지 감이 안 잡힐 때가 많아요. 결론부터 말하면 평소에는 옵션 없는 기본 plan, 리뷰한 계획 그대로 적용하려면 plan -out → apply 파일, 외부 변경을 코드 대신 상태에 받아들일 때만 -refresh-only 를 써요. 그리고 리뷰를 기다리는 동안 같은 상태에 다른 apply가 자주 끼어든다면 옵션이 아니라 상태 파일을 나눌 때예요.

이 글은 내부 계산 원리보다 실무 판단을 다뤄요. 옵션 판단표, CI 설정값, 테라폼 1.9.8로 잰 plan 시간과 "낡은 plan" 확률 계산, 증상별 확인 순서를 정리했어요.

광고

plan 옵션 판단표 — 언제 무엇을 붙이나

terraform plan 공식 문서에서 고민이 되는 옵션을 상황별로 묶으면 이래요.

상황고를 옵션피할 옵션과 이유
평소 코드 변경 리뷰기본 plan + -out=tfplan-refresh=false: 문서가 외부 변경을 무시해 불완전하거나 틀린 plan이 될 수 있다고 경고해요
누가 콘솔에서 고친 값을 코드 대신 받아들이기plan -refresh-only 후 apply -refresh-onlyterraform refresh: 확인 없이 바로 상태를 덮어써요
드리프트만 주기적으로 점검plan -refresh-only -detailed-exitcodeapply까지 자동으로 이어 붙이지 않아요
고장 난 객체 하나를 다시 만들기-replace=주소terraform taint: deprecated이고, 리뷰 전에 상태에 표시부터 남겨요
장애 복구 중 일부만 급히 적용-target=주소일상 사용: 문서가 드리프트와 혼란을 이유로 권하지 않아요
리소스가 수천 개라 refresh가 너무 느림먼저 상태 분리, 급하면 -minimal-refresh-refresh=false 상시 사용

-minimal-refresh는 테라폼 1.17.0 변경 기록(2026년 10월 21일)에 새로 들어간 옵션이에요. 바뀔 리소스만 refresh한 뒤 다시 계획해요. 공식 문서는 이 옵션도 -refresh=false처럼 변경이 계획되지 않은 리소스의 외부 변경은 무시한다고 적어요. 코드는 그대로인데 누가 콘솔에서 보안 그룹 규칙을 지웠다면 안 보여요. 그래서 "PR마다 -minimal-refresh, 하루 한 번은 전체 refresh"처럼 함께 둬야 안전해요.

terraform refresh는 refresh 명령 문서에서 deprecated이고, 사실상 terraform apply -refresh-only -auto-approve의 별칭이라 확인 없이 상태를 덮어써요. 문서는 대신 terraform apply -refresh-only로 변경을 보고 승인하라고 권해요.

CI에서 plan과 apply를 잇는 설정값

자동화에서는 plan 결과를 기계가 먼저 갈라야 해요. 이때 쓰는 것이 -detailed-exitcode예요. 공식 문서 기준으로 0은 변경 없음, 1은 오류, 2는 변경 있음이에요. 테라폼 1.9.8에서 확인한 결과도 변경 없을 때 0, 설정 한 줄을 바꾼 뒤 2였어요.

plan -out과 -detailed-exitcode로 종료 코드 0·1·2를 가르고, 변경이 있을 때만 리뷰 뒤 apply tfplan을 실행하며, 상태 serial이 바뀌어 낡은 plan이면 다시 plan으로 돌아가는 흐름도
종료 코드 2일 때만 리뷰로 넘기고, 리뷰한 파일을 그대로 적용해요

CI에 넣을 옵션을 정리하면 이래요.

옵션넣을 값이유
-input=false항상변수 입력을 기다리며 작업이 멈추지 않게 해요
-outtfplan 같은 파일리뷰한 계획만 적용하려면 필요해요. 없으면 apply가 처음부터 다시 계산해요
-detailed-exitcodeplan 단계에만0·1·2로 다음 단계를 가를 수 있어요
-lock-timeout예: 5m기본값이 0s라 잠금을 못 잡으면 곧바로 실패해요. 다른 작업이 짧게 쥐고 있을 때 기다리게 해요
-parallelism기본 10 유지올리면 빨라질 수 있지만 클라우드 API 속도 제한에 먼저 걸려요
-lock=false넣지 않음문서가 동시에 다른 사람이 실행하면 위험하다고 적어요

공식 문서는 민감한 값이 터미널에서 가려져 보여도 plan 파일에는 평문으로 저장된다며 민감한 산출물로 다루라고 적어요. CI 아티팩트라면 보관 기간을 짧게, 내려받는 권한은 apply 작업만으로 좁혀요. 리뷰용으로는 terraform show -json tfplan의 resource_changes만 보여 주면 충분하고, 한 줄로 긴 JSON은 JSON Formatter로 펼쳐서 actions 값을 확인하면 빨라요. 형식은 JSON Output Format 문서에 있어요.

숫자로 보는 plan 시간 — 직접 재 보기

리소스 수에 따라 plan이 얼마나 느려지는지 측정했어요. API 시간을 빼고 테라폼 자체 부담만 보려고 네트워크를 쓰지 않는 내장 terraform_data를 count로 늘렸어요. Apple Silicon 맥, 테라폼 1.9.8, 기본 parallelism 10에서 세 번 돌린 가운데 값이에요.

리소스 수별 terraform plan 걸린 시간 (API 호출 없음)
0510152025100개 · plan 시간 0.26초0.26100개500개 · plan 시간 0.61초0.61500개2000개 · plan 시간 3.5초3.52000개5000개 · plan 시간 23.9초23.95000개

리소스가 10배(500 → 5000) 늘 때 시간은 약 39배 늘었어요. 실제 클라우드라면 여기에 API 읽기 시간이 더해져요.

단위: 초 · 자료: 테라폼 1.9.8 · terraform_data · 세 번 실행한 가운데 값으로 직접 측정
표로 보기
구분plan 시간
100개0.26
500개0.61
2000개3.5
5000개23.9

같은 실험에서 상태 파일 크기는 리소스 100개에 46,037바이트, 5,000개에 2,312,038바이트였어요(하나에 약 460바이트). 볼 점은 API를 하나도 안 불러도 시간이 리소스 수보다 빠르게 늘었다는 것이에요. 이 실험에서는 -refresh=false를 붙여도 빨라지지 않았어요. 이 옵션이 줄여 주는 몫은 API 읽기 시간뿐이라는 뜻이에요.

API 시간은 계산으로 어림할 수 있어요. 인스턴스마다 읽기 호출이 한 번이고 호출 하나에 평균 L초가 걸린다면, refresh 시간은 대략 ⌈N ÷ parallelism⌉ × L이에요. 의존 관계 때문에 기다리는 시간은 빼고 본 하한값이에요.

리소스 수별 refresh 시간 어림값 (API 호출 1번 200ms 가정)
parallelism 10parallelism 30
0255075100200개 · parallelism 10 4초200개 · parallelism 30 1.4초200개1000개 · parallelism 10 20초1000개 · parallelism 30 6.8초1000개3000개 · parallelism 10 60초3000개 · parallelism 30 20초3000개5000개 · parallelism 10 100초5000개 · parallelism 30 33.4초5000개

200ms는 설명용 가정이에요. 호출이 느린 리소스나 속도 제한 재시도가 섞이면 더 길어져요.

단위: 초 · 자료: ⌈N ÷ parallelism⌉ × 0.2초로 직접 계산한 예시
표로 보기
구분parallelism 10parallelism 30
200개41.4
1000개206.8
3000개6020
5000개10033.4

parallelism을 세 배로 올리면 같은 순간 API 호출도 세 배가 돼 속도 제한에 걸리기 쉬워요. 한 상태에 넣는 리소스 수를 줄이는 쪽이 근본적인 해결이에요.

저장된 plan이 낡을 확률 — 상태를 나눌 신호

저장된 plan을 적용할 때 테라폼은 plan을 만들 때의 상태와 지금 상태를 비교해요. backend_local.go 소스를 보면 lineage가 다르면 "Saved plan does not match the given state", serial이 다르면 "Saved plan is stale" 오류로 멈춰요. 테라폼 1.9.8에서도 plan 파일을 만든 뒤 -target으로 리소스 하나를 적용해 serial을 올리자 그 plan 파일은 "Saved plan is stale"로 거부됐어요.

그래서 한 상태 파일에 여러 팀의 변경이 몰리면 리뷰를 기다리는 사이 다른 apply가 끼어들어 plan이 계속 낡아요. 같은 상태에 apply가 하루 a번 무작위로 일어나고 리뷰에 t시간이 걸린다면, 그 사이 한 번 이상 apply가 끼어들 확률은 1 − e^(−a × t ÷ 24)예요.

리뷰 대기 시간별 저장된 plan이 낡을 확률 (예시 계산)
하루 apply 5번하루 apply 20번하루 apply 50번
025507510015분 · 하루 apply 5번 5.1%15분 · 하루 apply 20번 18.8%15분 · 하루 apply 50번 40.6%15분1시간 · 하루 apply 5번 18.8%1시간 · 하루 apply 20번 56.5%1시간 · 하루 apply 50번 87.5%1시간4시간 · 하루 apply 5번 56.5%4시간 · 하루 apply 20번 96.4%4시간 · 하루 apply 50번 100%4시간하루 · 하루 apply 5번 99.3%하루 · 하루 apply 20번 100%하루 · 하루 apply 50번 100%하루

apply가 하루 고르게 무작위로 일어난다고 가정했어요. 실제로는 업무 시간에 몰려 더 높아요.

단위: % · 자료: 1 − e^(−하루 apply 횟수 × 대기 시간 ÷ 24)로 직접 계산
표로 보기
구분하루 apply 5번하루 apply 20번하루 apply 50번
15분5.118.840.6
1시간18.856.587.5
4시간56.596.4100
하루99.3100100

하루 apply가 20번인 상태에서 리뷰가 1시간 걸리면 절반 넘게(56.5%) 다시 plan해야 해요. 낡은 plan이 자주 나온다면 그 상태를 팀·환경·수명 주기 기준으로 나누라는 신호로 읽어요.

상태 파일을 나누는 기준

상태를 쪼개는 기준은 "같이 바뀌는가, 같은 사람이 책임지는가"예요. 자주 나오는 경우를 표로 정리했어요.

묶을까 나눌까판단이유
운영·스테이징 환경나눔스테이징 실수가 운영 대응표를 건드리면 안 돼요
네트워크(VPC)와 애플리케이션 서버나눔네트워크는 거의 안 바뀌고, 앱은 하루에도 여러 번 바뀌어요
한 서비스의 로드밸런서·서버·DNS 레코드묶음함께 바뀌고 같은 팀이 리뷰해요
여러 팀이 공유하는 IAM 정책별도 상태 + 소유 팀 지정apply가 몰리는 곳이라 낡은 plan과 잠금 대기가 잦아요

나눈 상태끼리는 출력값으로 잇는데, terraform_remote_state로 읽으면 상대 상태 전체를 읽을 권한이 필요해 민감한 값까지 보일 수 있어요. Sensitive Data in State 문서도 상태를 원격 백엔드에 두고 접근을 통제하라고 권해요.

S3 백엔드 상태 잠금 설정

S3 백엔드 문서는 잠금이 선택 기능이고 use_lockfile = true(기본 false)로 켠다고 적어요. DynamoDB 잠금은 deprecated이고 이후 마이너 버전에서 제거될 예정이에요. 변경 기록 기준으로 S3 자체 잠금은 1.10에 들어왔고, 1.11에서 정식 기능이 되며 DynamoDB 관련 인수가 deprecated로 바뀌었어요.

항목설정확인할 것
잠금use_lockfile = true잠금 파일 <key>.tflock에 s3:GetObject·s3:PutObject·s3:DeleteObject 권한
옮기는 중use_lockfile과 dynamodb_table 함께문서는 이전을 위해 둘을 동시에 둘 수 있다고 해요. 모든 실행 환경이 1.11 이상이 된 뒤 DynamoDB를 빼요
버전 관리버킷 Versioning 켜기문서가 실수로 지웠을 때 복구할 수 있게 강하게 권해요

잠금이 걸려 있는데 그 작업이 이미 죽었다면 terraform force-unlock LOCK_ID로 풀어요. force-unlock 문서대로 인프라는 그대로 두고 잠금만 지워요. 실제로 apply 중인 잠금을 풀면 두 작업이 서로의 결과를 덮어쓰니, 작업자와 시작 시각을 보고 정말 끝났는지 먼저 확인해요.

운영에서 자주 나는 문제와 확인 순서

매번 같은 변경이 뜸, Saved plan is stale, 상태 잠금 오류, 이름 변경 뒤 destroy, 대량 삭제·생성 계획, 콘솔에서 만든 리소스 누락 여섯 가지 증상별 먼저 볼 것과 조치 표
증상으로 줄을 찾고 가운데 칸부터 확인해요

1. 아무것도 안 바꿨는데 plan마다 같은 ~ 변경이 떠요. 바뀐다는 속성 값을 앞뒤로 비교해요. JSON 정책의 키 순서, 대소문자처럼 표기만 다르면 프로바이더가 API 표기를 그대로 상태에 넣는 경우가 많아요. 설정을 API 표기에 맞추거나(정책은 jsonencode) 프로바이더를 올려요. 마지막 수단인 ignore_changes는 진짜 드리프트도 가린다는 점을 주석으로 남겨요.

2. Saved plan is stale로 apply가 실패해요. 리뷰 사이 같은 상태에 다른 apply가 있었다는 뜻이라 다시 plan하는 수밖에 없어요. 잦으면 상태를 나눠요.

3. Error acquiring the state lock이 나요. 오류에 찍힌 잠금 ID, 작업자, 시작 시각을 봐요. 다른 CI 작업이 돌고 있으면 -lock-timeout으로 기다리게 하고, 강제 종료된 작업의 잠금이 남았을 때만 force-unlock을 써요.

4. 리소스 이름만 바꿨는데 destroy와 create가 계획돼요. 출력에 "is not in configuration"이 붙었으면 주소가 바뀐 거예요. moved 블록으로 옛 주소와 새 주소를 이어 주고, plan 요약이 0 to destroy인지 확인한 뒤 적용해요. count를 for_each로 바꾸면 인스턴스 키가 [0]에서 ["이름"]으로 바뀌니 인스턴스마다 moved를 적어요.

5. 갑자기 거의 모든 리소스가 삭제·생성으로 계획돼요. 코드보다 실행 환경을 먼저 의심해요. 다른 계정의 자격 증명, 다른 리전, 다른 워크스페이스를 가리키면 refresh가 객체를 "없음"으로 읽어 이렇게 나올 수 있어요. 이때 -refresh-only를 적용하면 상태에서 전부 지워져 버리니 절대 승인하지 말고, 계정·리전·워크스페이스를 확인한 뒤 다시 plan해요.

6. 콘솔에서 만든 리소스가 plan에 안 나와요. refresh는 상태에 있는 인스턴스만 읽어서 terraform state list에 없는 주소는 안 보여요. import 블록을 쓰면 가져오기를 plan에서 미리 보고 리뷰할 수 있어요.

반대로 리소스는 남기고 관리에서만 빼려면 1.7부터 생긴 removed 블록에 lifecycle { destroy = false }를 적어요. state rm과 달리 코드로 남아 리뷰를 거쳐요.

도입 전에 따질 것

테라폼을 팀 단위로 굴리기 전에 정해 둘 것들이에요.

자주 묻는 질문

-refresh=false를 쓰면 plan이 얼마나 빨라지나요?

줄어드는 것은 API 읽기 시간뿐이라, API가 들지 않는 리소스로 잰 실험에서는 차이가 없었어요. 외부 변경을 못 보니 일상 기본값으로 두지 말고, 느리면 상태 분리부터 검토해요.

-minimal-refresh는 -refresh=false보다 안전한가요?

바뀔 리소스는 refresh한 뒤 다시 계획하니 그 리소스에 대해서는 더 정확해요. 하지만 코드 변경이 없는 리소스의 외부 변경은 여전히 무시해요. 테라폼 1.17.0 이상에서만 쓸 수 있고, 전체 refresh를 도는 주기적 점검과 함께 써야 해요.

plan -out 없이 apply만 해도 되지 않나요?

혼자 쓰는 작은 환경이면 apply가 보여 주는 계획을 보고 승인해도 돼요. 다만 apply는 처음부터 다시 계산하니 리뷰 때 본 plan과 다를 수 있어요. 리뷰와 적용이 다른 사람·다른 단계로 나뉜 CI라면 plan 파일을 넘기는 방식이 안전해요.

DynamoDB 잠금을 쓰는 기존 설정은 바로 바꿔야 하나요?

당장 멈추지는 않지만 S3 백엔드 문서가 deprecated로 표시하고 이후 제거를 예고했어요. use_lockfile = true를 추가해 둘을 함께 쓰다가, 모든 실행 환경이 1.11 이상이 된 뒤 dynamodb_table을 빼는 순서가 무난해요.

정리

참고한 자료

#terraform plan 옵션#테라폼 refresh#Saved plan is stale#테라폼 상태 분리#S3 use_lockfile#드리프트 점검#detailed-exitcode#테라폼 상태 잠금
← 이전 글Structured Outputs vs JSON 모드 vs 재시도, LLM JSON 비용 비교다음 글 →인증서 폐기 확인, CRL·OCSP·CRLite 차이와 openssl 점검법

더 둘러보기

💻 개발자 다른 도구

🔥 많이 찾는 도구

🔮 재미로 한 번 더

📝 읽어 볼 만한 글

글 전체 보기 →