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-only | terraform refresh: 확인 없이 바로 상태를 덮어써요 |
| 드리프트만 주기적으로 점검 | plan -refresh-only -detailed-exitcode | apply까지 자동으로 이어 붙이지 않아요 |
| 고장 난 객체 하나를 다시 만들기 | -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였어요.
CI에 넣을 옵션을 정리하면 이래요.
| 옵션 | 넣을 값 | 이유 |
|---|---|---|
-input=false | 항상 | 변수 입력을 기다리며 작업이 멈추지 않게 해요 |
-out | tfplan 같은 파일 | 리뷰한 계획만 적용하려면 필요해요. 없으면 apply가 처음부터 다시 계산해요 |
-detailed-exitcode | plan 단계에만 | 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에서 세 번 돌린 가운데 값이에요.
같은 실험에서 상태 파일 크기는 리소스 100개에 46,037바이트, 5,000개에 2,312,038바이트였어요(하나에 약 460바이트). 볼 점은 API를 하나도 안 불러도 시간이 리소스 수보다 빠르게 늘었다는 것이에요. 이 실험에서는 -refresh=false를 붙여도 빨라지지 않았어요. 이 옵션이 줄여 주는 몫은 API 읽기 시간뿐이라는 뜻이에요.
API 시간은 계산으로 어림할 수 있어요. 인스턴스마다 읽기 호출이 한 번이고 호출 하나에 평균 L초가 걸린다면, refresh 시간은 대략 ⌈N ÷ parallelism⌉ × L이에요. 의존 관계 때문에 기다리는 시간은 빼고 본 하한값이에요.
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)예요.
하루 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 중인 잠금을 풀면 두 작업이 서로의 결과를 덮어쓰니, 작업자와 시작 시각을 보고 정말 끝났는지 먼저 확인해요.
운영에서 자주 나는 문제와 확인 순서
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과 달리 코드로 남아 리뷰를 거쳐요.
도입 전에 따질 것
테라폼을 팀 단위로 굴리기 전에 정해 둘 것들이에요.
- 드리프트 점검 주기: PR마다 도는 plan만으로는 코드를 안 건드린 상태의 드리프트를 못 봐요. 하루 한 번
plan -refresh-only -detailed-exitcode를 돌려 종료 코드 2면 알림을 보내게 해요. 스케줄 크론식은 크론 표현식 해석기로 다음 실행 시각을 보고, 러너가 UTC인지 크론 표현식 읽는 법의 타임존 항목으로 따져요. - 누가 apply 권한을 갖나: plan은 클라우드 리소스 읽기 권한(과 잠금용 쓰기 권한)이면 돌지만, apply는 리소스를 바꾸는 권한이 필요해요. 두 작업의 자격 증명을 나누면 리뷰를 건너뛴 적용을 막기 쉬워요.
- 버전 고정: 테라폼 CLI와 프로바이더 버전을 고정하고, 잠금 파일(
.terraform.lock.hcl)을 저장소에 커밋해요. 실행 환경마다 프로바이더 버전이 다르면 같은 코드에서 다른 plan이 나올 수 있어요. - 복구와 크기 상한: 버킷 버전 관리로 상태를 되돌리는 절차를 한 번 연습하고, 한 상태의 리소스 수·하루 apply 횟수 기준을 정해 쪼갤 시점을 놓치지 않아요.
자주 묻는 질문
-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을 빼는 순서가 무난해요.
정리
- 평소엔 기본 plan +
-out, 외부 변경 수용은-refresh-only, 강제 재생성은-replace예요. - CI에는
-input=false,-detailed-exitcode,-lock-timeout을 넣고-lock=false는 빼요. plan 파일은 평문 민감 정보가 든 산출물로 다뤄요. - API 없이도 리소스 500개 0.61초, 5,000개 23.91초로 plan 시간이 빠르게 늘었어요. 느리면 상태 분리가 먼저예요.
- 저장된 plan이 자주 낡거나 잠금 대기가 길면 상태를 팀·환경·변경 빈도로 나눠요. 대량 destroy가 보이면 apply를 멈추고 계정·워크스페이스부터 확인해요.