Terraform state 정리, 아직도 terraform state mv 로컬에서 치고 있는지? 우리 팀도 얼마 전까지는 그랬다. 리소스 하나 옮길 때마다 누군가 로컬에서 state 조작을 하고, PR에는 코드만 올라가고, "state는 내가 미리 옮겨놨어요" 슬랙 메시지가 흘러가고. 그러다 한 명이 실수하면 다음 사람이 apply 돌릴 때 리소스가 destroy/create 로 뜬다. 새벽에.
이 문제를 해결하려고 나온 게 declarative state 관리 블록 세 개다. moved (1.1+), import (1.5+), removed (1.7+). 이름만 들으면 뻔한데, 실제로 팀 컨벤션으로 못박아 놓으면 state 관련 사고가 눈에 띄게 줄어든다. 최근 Terraform 1.11 대까지 오면서 -generate-config-out 같은 옵션이 안정화돼서 import 블록도 이제 실전에서 쓸 만하다. 이 글은 그 세 블록을 실제로 어떻게 굴리는지에 대한 이야기다.
왜 imperative가 문제였나
terraform state mv aws_instance.foo aws_instance.bar 이 명령이 나쁜 건 아니다. 다만 세 가지가 문제다.
하나, 코드에 흔적이 안 남는다. 왜 옮겼는지, 언제 옮겼는지 git blame으로 추적이 안 된다. 나중에 다른 팀원이 "이 리소스 왜 이 이름이지?" 하면서 물어보면 다시 조사해야 한다.
둘, workspace가 여러 개면 각각 돌려야 한다. dev/stage/prod 세 개 있으면 세 번 쳐야 하고, 실수로 하나 빼먹으면 그 환경에서 다음 apply 때 폭탄이 터진다.
셋, CI/CD에 안 붙는다. PR 리뷰 프로세스를 우회하는 셈이라, 리뷰어 입장에서 state 변경이 어떻게 일어날지 예측이 안 된다.
moved/import/removed 블록은 이 세 가지를 정확히 뒤집는다. 코드에 남고, plan에서 검증되고, workspace 전체에 적용된다.
moved 블록, 리소스 이름 바꿀 때
가장 흔한 케이스부터. 예를 들어 aws_security_group.web 이라는 애를 aws_security_group.frontend_web 으로 리네이밍하고 싶다고 하자.
# 옛날 방식이면 이랬을 것:
# terraform state mv aws_security_group.web aws_security_group.frontend_web
# 이제는 그냥 코드에 이렇게 쓴다
resource "aws_security_group" "frontend_web" {
name = "frontend-web-sg"
# ...
}
moved {
from = aws_security_group.web
to = aws_security_group.frontend_web
}
terraform plan을 돌리면 이런 라인이 뜬다.
# aws_security_group.web has moved to aws_security_group.frontend_web
No changes. Your infrastructure matches the configuration.
여기서 핵심은 "No changes"다. destroy/create가 아니고, in-place update도 아니다. state 안에서 이름만 바꾼다.
모듈을 나누거나 합칠 때도 똑같이 쓴다. 이게 실무에서 더 자주 나온다.
# 예: 기존에 인라인으로 있던 리소스들을 모듈로 뺄 때
module "vpc" {
source = "./modules/vpc"
# ...
}
moved {
from = aws_vpc.main
to = module.vpc.aws_vpc.this
}
moved {
from = aws_subnet.public[0]
to = module.vpc.aws_subnet.public[0]
}
한 가지 주의점. moved 블록은 리팩터링이 끝난 뒤에도 한동안 남겨둬야 한다. 최소한 모든 환경에서 apply가 한 바퀴 돌 때까지는. 안 그러면 old state가 남아있는 workspace에서 리소스가 지워지고 새로 만들어진다. 우리 팀 컨벤션은 "moved 블록은 최소 2주 유지 후 정리"다.
import 블록, 이미 있는 리소스를 코드로 편입할 때
레거시 정리하다 보면 콘솔에서 손으로 만든 리소스가 튀어나온다. 예전에는 terraform import 커맨드를 쳤는데, 이게 상당히 위험했다. state에 바로 들어가고, HCL은 사람이 직접 써야 하고, 잘못 쓰면 다음 plan에서 리소스가 파괴됐다.
import 블록은 이걸 declarative하게 바꾼다.
import {
to = aws_s3_bucket.legacy_logs
id = "our-legacy-logs-bucket-2019"
}
resource "aws_s3_bucket" "legacy_logs" {
bucket = "our-legacy-logs-bucket-2019"
# 나머지 설정...
}
plan을 돌리면 이렇게 뜬다.
Terraform will perform the following actions:
# aws_s3_bucket.legacy_logs will be imported
resource "aws_s3_bucket" "legacy_logs" {
...
}
Plan: 1 to import, 0 to add, 0 to change, 0 to destroy.
apply 전에 정확히 뭐가 들어올지 볼 수 있다. 이게 imperative import 대비 가장 큰 이점이다.
HCL을 자동 생성하는 트릭도 있다.
리소스 설정을 손으로 다 쓰기 귀찮으면:
terraform plan -generate-config-out=generated.tf
이러면 import 대상 리소스의 HCL을 뽑아준다. 완벽하진 않고 사후 정리는 필요하다. provider가 기본값을 다 뽑아버려서 노이즈가 많고, 민감한 값은 그대로 코드에 박히니까. 우리 팀에서는 이걸 초안으로만 쓰고, 리뷰 단계에서 필요 없는 attribute를 걷어낸다.
for_each와 함께 쓰는 벌크 import도 된다.
레거시 리소스 20개쯤 한 번에 import할 때 유용하다.
locals {
legacy_buckets = {
"logs" = "old-logs-bucket"
"backup" = "old-backup-bucket"
"media" = "old-media-bucket"
}
}
import {
for_each = local.legacy_buckets
to = aws_s3_bucket.legacy[each.key]
id = each.value
}
resource "aws_s3_bucket" "legacy" {
for_each = local.legacy_buckets
bucket = each.value
}
removed 블록, state에서 빼되 리소스는 살려둘 때
이게 세 개 중 가장 마이너하지만, 실제로 필요한 순간이 있다.
시나리오. 어떤 리소스를 Terraform 관리에서 빼야 한다. 예를 들어 별도 팀으로 소유권을 이관하거나, 다른 IaC 도구로 옮기거나, 아니면 그냥 "이건 이제 Terraform이 건드리지 마" 하는 경우. 그런데 리소스 자체는 살려둬야 한다.
옛날엔 terraform state rm 을 쳤는데, 역시 imperative의 문제가 그대로 있었다.
# 코드에서 resource 블록 삭제하고
# 대신 removed 블록 추가
removed {
from = aws_iam_role.legacy_admin
lifecycle {
destroy = false
}
}
lifecycle.destroy = false가 핵심이다. 이걸 안 걸면 진짜로 리소스를 파괴한다. 이건 사실상 그냥 resource 블록 지우는 것과 같다.
destroy = false로 두면 plan에서 이렇게 뜬다.
# aws_iam_role.legacy_admin will no longer be managed by Terraform,
# but will not be destroyed
이 문구가 정확히 우리가 원하는 그거다.
그래서 팀에 어떻게 정착시켰나
이 블록들이 좋다는 건 이제 대부분 알지만, 팀에 실제로 정착시키는 건 다른 문제다. 우리 팀에서 잘 굴러가고 있는 룰 몇 가지.
terraform state 커맨드는 CI에서 아예 못 쓰게 막았다. .pre-commit-hooks.yaml에 grep 훅 하나 걸어놓고 PR에 terraform state mv나 terraform import 커맨드가 들어간 스크립트가 있으면 실패시킨다. 처음엔 좀 빡빡한 것 같았는데, 결과적으로 사고가 줄었다.
moved/import/removed 블록은 별도 파일에 모아둔다. state_migrations.tf 같은 이름으로. 그러면 리뷰어가 state 변경만 따로 볼 수 있고, 오래된 블록을 정리할 때도 편하다.
Import 블록은 반드시 두 단계 PR로 처리한다. 1단계는 -generate-config-out으로 뽑은 HCL과 import 블록만 넣은 PR. 2단계는 필요 없는 attribute 정리와 우리 컨벤션에 맞는 리팩터링. 한 번에 하면 리뷰가 지옥이 된다.
그리고 오래된 moved/import 블록은 분기별로 청소한다. 남겨두면 파일이 지저분해지고, 신입이 봤을 때 뭔가 하고 있는 게 아닌가 오해할 수도 있다. Removed는 특성상 계속 남아있어야 하니까 예외.
마무리
Terraform state 관련 사고는 대부분 imperative 커맨드에서 나왔다. Declarative 블록 세 개를 팀에 정착시키면 이 카테고리의 사고가 확 줄어든다. 특히 여러 환경/여러 workspace를 굴리는 팀일수록 효과가 크다.
아직 안 쓰고 있다면 다음 리팩터링 때 moved 하나부터 시작해보길. terraform state mv 없이도 리네이밍이 되는 걸 한 번 경험하면, 굳이 옛날 방식으로 안 돌아가게 된다.
참고
- Terraform Import: Commands, Examples & Import Blocks (2026)
- Terraform moved Block: Syntax, Examples, and When to Use It
- How to Use the removed Block to Forget Resources in Terraform
태그: Terraform, IaC, state관리, DevOps, 리팩터링
'IT > IaC' 카테고리의 다른 글
| OpenTofu vs Terraform, 2026년 지금 뭘 쓸까 (0) | 2026.08.24 |
|---|---|
| Terraform state lock 삽질 노트 — DynamoDB 걷어내고 use_lockfile로 옮긴 이야기 (1) | 2026.08.12 |
| Terraform vs OpenTofu vs Pulumi, 2026년에 우리는 뭘 골랐나 (0) | 2026.08.09 |
| Terraform에서 OpenTofu로 이관하며 삽질한 3주 (0) | 2026.08.05 |
| DynamoDB 락 테이블을 지우던 새벽에 대하여 (0) | 2026.07.31 |