IaC IAC 02 plan 이 말해 주는 것 — 제자리 변경·교체·상태 파일
IaC · 2/5

IAC 02 plan 이 말해 주는 것 — 제자리 변경·교체·상태 파일

gabury1고친 사람 github-actions[bot]

실측은 전부 라즈베리파이 5 · Terraform v1.16.4 · hashicorp/aws 6.66.0 에서 돌렸습니다. AWS(Amazon Web Services) 대신 moto 서버 5.2.3.dev0(AWS API 를 흉내 내는 로컬 서버)에 붙였습니다. 출력에 나오는 인스턴스 ID·IP·버킷 이름은 전부 moto 가 만든 가짜 값입니다. 길어서 줄을 잘라 낸 곳은 「…」로 표시했습니다.

서버 한 대를 적은 코드에서 값 하나씩만 바꿔 가며 「이렇게 바꾸면 어떻게 할 건지」를 물었습니다. 네 번 물었더니 두 번은 「서버를 지우고 새로 만들겠다」고 답했습니다. 나머지 두 번은 「지금 서버를 그대로 고치겠다」고 답했습니다. 그대로 고치겠다는 쪽 하나를 실제로 적용했습니다. 서버 ID 는 그대로였습니다. 공인 IP 는 바뀌어 있었습니다.

이 편이 보는 것은 세 가지입니다. 테라폼(클라우드 자원을 코드대로 만들어 주는 도구)이 「어떻게 할 건지」를 알려 주는 기호 넷이 각각 언제 나오는지, 테라폼이 「내가 만든 것」을 기억하는 파일에 무엇이 들었는지, 그 파일을 지우면 무슨 일이 나는지입니다.

1. 먼저 낱말 몇 개

테라폼(Terraform)은 클라우드 자원(서버·방화벽 규칙·저장소)을 코드로 적어 두면 그대로 만들어 주는 도구입니다. 콘솔에서 버튼을 누르는 대신 파일에 「이런 서버 한 대가 있어야 한다」고 적습니다. 이렇게 인프라를 코드로 관리하는 방식을 코드형 인프라(IaC)라 부릅니다.

테라폼 코드는 HCL(HashiCorp Configuration Language)이라는 설정 언어로 씁니다. HCL 은 블록 단위로 읽힙니다. 블록은 종류 "이름" { ... } 꼴의 덩어리 하나입니다. 이 편에 나오는 블록은 넷입니다.

블록 하는 일
resource 테라폼이 만들고 관리할 자원 하나. 이것을 리소스라 부릅니다
data 만들지 않고 이미 있는 것을 조회만 합니다
variable 바꿔 끼울 값. var.이름 으로 꺼내 씁니다
provider 어느 클라우드의 API 를 부를지 정합니다

프로바이더(provider)는 테라폼의 명령을 클라우드 API 호출로 바꿔 주는 플러그인입니다. 테라폼 본체는 AWS 를 모릅니다. hashicorp/aws 프로바이더가 「인스턴스 하나 만들어라」를 AWS API 호출로 바꿉니다.

테라폼을 쓰는 명령은 둘이 핵심입니다. terraform plan 은 코드와 지금 모습을 견줘서 「무엇을 어떻게 바꿀지」 계획만 보여 줍니다. 아무것도 바꾸지 않습니다. terraform apply 는 그 계획을 실제로 실행합니다.

실험에 쓴 코드

EC2(AWS 가 빌려주는 가상 서버, AWS 01-1 서버를 통째로 빌린다 — EC2·Lightsail·Beanstalk·CloudShell) 한 대, 보안 그룹 하나, S3 버킷 하나입니다. 보안 그룹은 서버 앞에 두는 방화벽 규칙 묶음입니다. Amazon S3 버킷은 파일을 담는 저장 공간입니다. 속성 하나씩만 바꿔 보려고 바꿀 값을 전부 변수로 뺐습니다. 아래는 여러 줄로 적은 블록 일부를 짧게 줄여 옮긴 것입니다.

hcl
variable "ami"           { default = "ami-0f47531f8c49bd1c6" }
variable "instance_type" { default = "t4g.small" }
variable "az"            { default = "us-east-1a" }
variable "role_tag"      { default = "app" }

data "aws_subnet" "picked" {
  availability_zone = var.az
  default_for_az    = true
}

resource "aws_security_group" "app" {
  name        = "demo-sg"
  description = "demo app sg"
  ingress {
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

resource "aws_instance" "app" {
  ami                    = var.ami
  instance_type          = var.instance_type
  subnet_id              = data.aws_subnet.picked.id
  vpc_security_group_ids = [aws_security_group.app.id]
  tags = { Name = "demo-app", Role = var.role_tag }
}

resource "aws_s3_bucket" "logs" {
  bucket = "demo-logs"
}
  • ami — AMI(Amazon Machine Image), 서버를 찍어 낼 디스크 원본입니다. 운영체제가 깔린 머신 이미지입니다
  • instance_type — 인스턴스 유형, CPU·메모리 크기를 고르는 값입니다. t4g.small 은 작은 ARM 서버입니다
  • az — 가용 영역(availability zone), 한 리전 안의 데이터센터 묶음 하나입니다
  • data "aws_subnet" — 그 가용 영역의 서브넷을 찾아옵니다. 서브넷은 VPC(Virtual Private Cloud, AWS 안에 만드는 사설 네트워크)를 가용 영역별로 나눈 네트워크 구획입니다. default_for_az = true 는 AWS 가 계정마다 미리 만들어 둔 기본 서브넷을 고르라는 뜻입니다
  • ingress { ... } — 들어오는 연결을 허용하는 규칙입니다. 여기서는 22번 포트, 곧 SSH(Secure Shell, 원격 접속)를 엽니다. cidr_blocks = ["0.0.0.0/0"] 은 모든 주소에서 오는 연결을 받는다는 뜻입니다
  • aws_security_group.app.id — 다른 리소스의 속성을 종류.이름.속성 으로 가리킵니다. 이렇게 가리키면 테라폼은 보안 그룹을 먼저 만듭니다

terraform apply 로 세 자원을 만들어 두고 시작했습니다(Apply complete! Resources: 3 added). 이 첫 apply 의 계획에서는 세 자원 모두 앞에 +(새로 만들기)가 붙습니다.

2. plan 은 적용 전에 받는 계획서다

먼저 상태(state)를 알아야 합니다. 상태는 테라폼이 「내가 만든 것」을 적어 둔 기록입니다. 로컬에서는 terraform.tfstate 라는 JSON(JavaScript Object Notation, 자료를 적는 텍스트 형식) 파일 하나입니다. 이 파일을 상태 파일이라 부릅니다. 5절에서 열어 봅니다.

plan 을 돌리면 테라폼은 세 가지를 합니다. 순서는 terraform plan 명령의 공식 설명을 따랐습니다.

  1. 클라우드에 실제로 있는 자원의 지금 모습을 읽어 와서 상태를 최신으로 맞춥니다
  2. 지금 코드를 이전 상태와 견줘 차이를 찾습니다
  3. 적용하면 클라우드의 자원이 코드와 같아지는 변경 목록을 내놓습니다
flowchart TD
    C["코드 (.tf)<br/>있어야 할 모습"]
    S["상태 파일<br/>내가 만든 것 목록"]
    R["클라우드<br/>지금 실제 모습"]
    S -->|"적힌 것만 조회"| R
    R --> D["상태를 최신으로"]
    C --> P["차이 계산"]
    D --> P
    P --> O["plan 출력<br/>자원마다 기호 하나"]
    O -->|"apply"| X["실제 변경<br/>상태 파일 새로 씀"]

그림에서 볼 것은 클라우드를 조회하는 출발점이 상태 파일이라는 점입니다. 상태 파일에 없는 자원은 테라폼에게 없는 것입니다. 6절에서 이것이 사고로 이어집니다.

apply 도 먼저 plan 을 한 번 더 계산해서 보여 줍니다. 확인을 받은 뒤에 실행합니다.

3. 기호 넷은 언제 나오나

실측 — 속성 하나씩 바꿔 plan (2026-09-30, 라즈베리파이 5, Terraform 1.16.4 + moto)

변수 하나씩만 바꿔 plan 만 돌렸습니다(적용 안 함). 명령 앞에는 가짜 자격증명(AWS_ACCESS_KEY_ID=test 등)을 넣어 진짜 AWS 로 새지 않게 했습니다. 이 편의 모든 명령이 같습니다.

터미널
for v in 'role_tag=web' 'ami=ami-025261407a8d923f3' \
         'instance_type=t4g.medium' 'az=us-east-1b'; do
  terraform plan -no-color -var "$v" 2>&1 \
    | sed -n '/Terraform will perform/,$p'
done
  • -var "이름=값" — 변수의 기본값을 이번 실행에서만 바꿉니다

~ — 태그만 바꿨을 때

Role 태그를 app 에서 web 으로 바꿨습니다.

  # aws_instance.app will be updated in-place
  ~ resource "aws_instance" "app" {
        id                                   = "i-8b1c872f65cacb5ff"
      ~ tags                                 = {
            "Name" = "demo-app"
          ~ "Role" = "app" -> "web"
        }
…
        # (39 unchanged attributes hidden)

        # (4 unchanged blocks hidden)
    }

Plan: 0 to add, 1 to change, 0 to destroy.

~ 는 제자리 변경(update in-place)입니다. 지금 있는 서버를 지우지 않고 속성만 고칩니다. id 줄 앞에 기호가 없는 것을 보십시오. 인스턴스 ID 가 그대로라는 뜻입니다.

기호는 속성 줄 앞에도 붙습니다. 자원 줄의 기호는 자원 전체를, 속성 줄의 기호는 그 속성 하나를 어떻게 할지 알려 줍니다. "app" -> "web" 처럼 화살표 왼쪽은 지금 값이고 오른쪽은 적용 뒤 값입니다. 오른쪽이 null 이면 그 값이 없어진다는 뜻입니다.

-/+ — AMI 를 바꿨을 때

같은 서버에 디스크 원본(AMI)만 다른 것으로 바꿨습니다.

  # aws_instance.app must be replaced
-/+ resource "aws_instance" "app" {
      ~ ami                                  = "ami-0f47531f8c49bd1c6" -> "ami-025261407a8d923f3" # forces replacement
      ~ arn                                  = "arn:aws:ec2:us-east-1::instance/i-8b1c872f65cacb5ff" -> (known after apply)
      ~ associate_public_ip_address          = true -> (known after apply)
      ~ availability_zone                    = "us-east-1a" -> (known after apply)
…
      ~ id                                   = "i-8b1c872f65cacb5ff" -> (known after apply)
…
      ~ private_ip                           = "172.31.0.4" -> (known after apply)
      ~ public_ip                            = "54.214.192.34" -> (known after apply)
…
      - ebs_block_device {
…
          - volume_id             = "vol-76db36c074f6f6f79" -> null
…
        }
…
    }

Plan: 1 to add, 0 to change, 1 to destroy.

-/+ 는 교체(replace)입니다. 지금 서버를 지우고 새 서버를 만듭니다. 요약 줄도 「1 to add, 1 to destroy」로 하나 지우고 하나 만든다고 셉니다.

왜 교체인지는 ami 줄 끝의 주석 # forces replacement 가 알려 줍니다. 「이 속성 때문에 교체가 강제된다」는 뜻입니다. EC2 인스턴스의 AMI 는 만든 뒤에는 멈춰도 바꿀 수 없습니다. EC2 에는 AMI 를 바꾸는 API 가 없습니다. 그래서 프로바이더가 「지우고 새로 만들기」로 계획합니다.

교체가 되면 id·private_ip·public_ip 가 전부 (known after apply) 로 바뀝니다. 적용해 봐야 알 수 있는 값이라는 표시입니다. 새 서버는 ID 도 주소도 다른 서버입니다.

디스크도 따라갑니다. ebs_block_device 는 서버에 붙은 EBS(Elastic Block Store, AWS 가 서버에 붙여 주는 디스크) 디스크입니다. 이 블록 앞의 - 는 지금 디스크(vol-76db36c074f6f6f79)도 함께 지워진다는 계획입니다. 새 서버에는 디스크가 새로 생깁니다. 서버 안에 저장해 둔 파일이 있었다면 같이 사라집니다.

-/+ — 서브넷을 바꿨을 때

가용 영역을 us-east-1b 로 바꾸면 조회해 오는 서브넷이 달라집니다. 이번에도 -/+ 였습니다(Plan: 1 to add, 0 to change, 1 to destroy.). 이번 주석은 subnet_id 줄에 붙었습니다.

      ~ subnet_id                            = "subnet-4f3d4cf8c7c79dd49" -> "subnet-bad4931d5ee9968a8" # forces replacement

서브넷도 인스턴스를 만든 뒤에는 멈춰도 바꿀 수 없습니다. 서브넷은 서버의 첫 네트워크 인터페이스에 묶여 있습니다. 그 첫 인터페이스는 서버에서 떼어 낼 수 없습니다.

- — 코드에서 블록을 지웠을 때

main.tf 에서 S3 버킷 블록만 잘라 내고 plan 을 돌렸습니다.

  # aws_s3_bucket.logs will be destroyed
  # (because aws_s3_bucket.logs is not in configuration)
  - resource "aws_s3_bucket" "logs" {
      - arn                         = "arn:aws:s3:::demo-logs" -> null
      - bucket                      = "demo-logs" -> null
…
      - versioning {
          - enabled    = false -> null
          - mfa_delete = false -> null
        }
    }

Plan: 0 to add, 0 to change, 1 to destroy.

코드에서 지운 블록은 「이제 관리 안 할게」가 아닙니다. 「지워라」로 읽힙니다. 이유 줄 (because aws_s3_bucket.logs is not in configuration) 이 그 판단을 밝힙니다. 코드에는 이 버킷이 없는데 테라폼은 이 버킷을 기억하고 있습니다. 그 기억이 상태 파일입니다.

모아 보면

바꾼 것 기호 plan 요약 줄
없던 자원(처음 apply) + 3 to add
태그 ~ 0 to add, 1 to change
인스턴스 유형 ~ 0 to add, 1 to change
AMI -/+ (# forces replacement) 1 to add, 1 to destroy
서브넷 -/+ (# forces replacement) 1 to add, 1 to destroy
코드에서 블록 삭제 - 1 to destroy

어느 속성이 교체를 부르는지는 프로바이더가 속성마다 정해 둡니다. 이 표는 hashicorp/aws 6.66.0 기준입니다.

4. 제자리 변경인데 IP 가 바뀌었다

표에서 인스턴스 유형은 ~ 입니다. 서버를 지우지 않고 CPU·메모리 크기만 바꾼다는 계획입니다. 서버를 새로 만들지 않으니 아무 일도 없을 것 같습니다. 3절 반복문의 세 번째 plan 출력을 보면 이상한 줄이 둘 있습니다.

  # aws_instance.app will be updated in-place
  ~ resource "aws_instance" "app" {
        id                                   = "i-8b1c872f65cacb5ff"
      ~ instance_type                        = "t4g.small" -> "t4g.medium"
      ~ public_dns                           = "ec2-54-214-192-34.compute-1.amazonaws.com" -> (known after apply)
      ~ public_ip                            = "54.214.192.34" -> (known after apply)
…
        # (37 unchanged attributes hidden)
…

Plan: 0 to add, 1 to change, 0 to destroy.

id 는 그대로입니다. 하지만 public_ip 가 (known after apply) 입니다. 공인 IP(인터넷에서 이 서버에 닿는 주소)가 적용 뒤에나 정해진다고 합니다. 실제로 적용해 봤습니다.

실측 — 인스턴스 유형 변경을 실제로 적용 (2026-09-30, 라즈베리파이 5, Terraform 1.16.4 + moto)

터미널
Q='Reservations[].Instances[]'
Q+='.[InstanceId,InstanceType,PublicIpAddress,State.Name]'
aws ec2 describe-instances \
  --filters Name=tag:Name,Values=demo-app \
  --query "$Q" --output text
terraform apply -auto-approve -no-color \
  -var instance_type=t4g.medium 2>&1 \
  | grep -E 'Modif|Apply complete|Still'
aws ec2 describe-instances ...   # 위와 같음
  • aws ec2 describe-instances --query … — 이름 태그가 demo-app 인 서버의 ID·유형·공인 IP·상태만 뽑습니다
  • -auto-approve — apply 가 묻는 「진행할까요?」에 자동으로 예라고 답합니다
### 적용 전
i-8b1c872f65cacb5ff	t4g.small	54.214.192.34	running
### terraform apply -auto-approve -var instance_type=t4g.medium
aws_instance.app: Modifying... [id=i-8b1c872f65cacb5ff]
aws_instance.app: Still modifying... [id=i-8b1c872f65cacb5ff, 00m10s elapsed]
aws_instance.app: Still modifying... [id=i-8b1c872f65cacb5ff, 00m20s elapsed]
aws_instance.app: Modifications complete after 21s [id=i-8b1c872f65cacb5ff]
Apply complete! Resources: 0 added, 1 changed, 0 destroyed.
### 적용 후
i-8b1c872f65cacb5ff	t4g.medium	54.214.196.184	running

인스턴스 ID 는 i-8b1c872f65cacb5ff 그대로입니다. 유형은 t4g.medium 으로 바뀌었습니다. 여기까지는 약속대로입니다. 그런데 공인 IP 가 54.214.192.34 에서 54.214.196.184 로 바뀌었습니다.

이유는 프로바이더 문서의 instance_type 설명 한 줄에 있습니다. 이 값을 바꾸면 EC2 인스턴스를 멈췄다가 다시 켭니다(stop/start). AMI·서브넷과 달리 인스턴스 유형은 멈추면 바꿀 수 있는 값입니다. 실행 중에만 못 바꿉니다. 그래서 프로바이더는 서버를 지우는 대신 멈추고, 유형을 바꾸고, 다시 켭니다. 같은 서버라서 ID 가 그대로입니다.

적용에 걸린 21초는 서버가 멈춰 있던 시간이 아닙니다. 프로바이더는 멈춤과 켜짐을 기다릴 때 처음에 10초씩 고정으로 쉽니다(aws 6.66.0 소스의 대기 함수). 21초는 대부분 그 두 번의 대기입니다. moto 는 상태를 거의 바로 바꾸므로, 서버가 실제로 몇 초 멈추는지는 이 실험으로 알 수 없습니다. 진짜 AWS 에서는 실제 부팅이 끼므로 더 깁니다.

IP 가 바뀐 것은 EC2 의 규칙 때문입니다. EC2 사용자 가이드에 따르면 EC2 는 인스턴스를 멈출 때 공인 IP 를 반납합니다. 다시 켤 때는 새 공인 IP 를 줍니다. 고정 주소를 원하면 탄력적 IP(Elastic IP)를 따로 붙여야 합니다. 이 실험의 서버에는 그것이 없었습니다. 이번 IP 변경은 moto 가 흉내 낸 것이지만 진짜 AWS 동작과 같습니다.

정리하면 ~ 가 약속하는 것은 「같은 서버다」입니다. 「멈추지 않는다」는 약속하지 않습니다. 이 서버의 IP 를 DNS(Domain Name System, 도메인 이름을 IP 로 바꿔 주는 체계)에 적어 두었다면, 한 번 멈췄다 켜지면서 주소까지 바뀐 셈입니다. plan 은 이것을 public_ip = … -> (known after apply) 한 줄로 미리 말해 주고 있었습니다. 그러니 ~ 기호만 보지 말고 (known after apply) 가 붙은 줄까지 읽어야 합니다.

적용한 뒤 t4g.small 로 되돌렸습니다(Apply complete! Resources: 0 added, 1 changed, 0 destroyed.). 이때 서버가 한 번 더 멈췄다 켜졌습니다.

5. 상태 파일에는 무엇이 들었나

3절에서 테라폼은 코드에 없는 버킷을 「지워라」고 했습니다. 코드에 없는 것을 어떻게 알았을까요. 상태 파일을 jq(JSON 을 골라 찍는 명령줄 도구)로 열었습니다.

실측 — 상태 파일 열어 보기 (2026-09-30, 라즈베리파이 5, Terraform 1.16.4 + moto)

터미널
ls -la terraform.tfstate*
jq '{version, terraform_version, serial, lineage, outputs,
     "resources 개수": (.resources|length)}' terraform.tfstate
jq -r '.resources[]
  | "\(.mode) \(.type).\(.name)  id=\(.instances[0].attributes.id)"' \
  terraform.tfstate
  • .resources[] 는 배열 원소를 하나씩 꺼냅니다. "\(…)" 는 문자열에 값을 끼워 넣는 jq 표기입니다
-rw------- 1 root root 10270 Sep 30 11:10 terraform.tfstate
-rw------- 1 root root 10271 Sep 30 11:10 terraform.tfstate.backup
### 머리
{
…
  "serial": 8,
  "lineage": "7b62f192-6fcc-59cf-e0f8-1061b5c6870a",
…
  "resources 개수": 4
}
### 자원 목록
data aws_subnet.picked  id=subnet-4f3d4cf8c7c79dd49
managed aws_instance.app  id=i-8b1c872f65cacb5ff
managed aws_s3_bucket.logs  id=demo-logs
managed aws_security_group.app  id=sg-aec0090d606f0feb7

자원 목록 네 줄이 이 파일의 뼈대입니다. 왼쪽은 코드 속 이름(aws_instance.app)입니다. 오른쪽은 클라우드가 붙인 식별자(i-8b1c872f65cacb5ff)입니다. 상태 파일은 이 둘을 짝지은 대응표입니다.

테라폼 공식 문서도 이 대응을 상태의 첫째 역할로 듭니다. 예를 들어 이 상태 파일이 있는 채로 apply 를 다시 돌리면, 테라폼은 aws_instance.app 의 짝인 i-8b1c872f65cacb5ff 를 조회합니다. 그 서버가 있으니 새로 만들지 않습니다. 이 짝이 없어지면 어떻게 되는지는 6절에서 봅니다.

data 로 조회한 서브넷도 목록에 있습니다. 만든 것이 아니라 조회한 것이라 mode 가 managed 가 아닌 data 로 적힙니다.

코드에 적은 것보다 훨씬 많다

인스턴스 하나만 더 들여다봤습니다. select(…) 로 인스턴스만 남기고 keys | length 로 속성 이름의 개수를 셉니다.

터미널
$ jq '.resources[] | select(.type=="aws_instance")
  | .instances[0].attributes | keys | length' terraform.tfstate
63

코드에 적은 인스턴스 속성은 ami·instance_type·subnet_id·vpc_security_group_ids·tags 다섯 개입니다. 상태 파일에는 63개가 들었습니다. 3절 plan 의 39 unchanged attributes hidden 과 맞지 않는 것은 plan 이 중첩 블록을 따로 세기 때문입니다(4 unchanged blocks hidden). 그래서 plan 의 숨김 수를 더해 63 을 맞춰 볼 수는 없습니다. jq 로 몇 개만 골라 찍은 발췌가 이것입니다.

…
      "dependencies": [
        "aws_security_group.app",
        "data.aws_subnet.picked"
      ],
      "attributes": {
        "id": "i-8b1c872f65cacb5ff",
        "ami": "ami-0f47531f8c49bd1c6",
        "instance_type": "t4g.small",
…
        "private_ip": "172.31.0.4",
        "public_ip": "54.214.113.249",
…
        "instance_state": "running"
…

private_ip·public_ip 처럼 코드가 정하지 않고 AWS 가 정해 준 값까지 받아 적었습니다. public_ip 가 4절의 두 값과 또 다른 54.214.113.249 인 것은 4절 끝에서 되돌릴 때 서버가 한 번 더 멈췄다 켜졌기 때문입니다.

공식 문서는 이 사본을 속성 캐시라 부릅니다. 2절에서 plan 은 매번 클라우드를 조회한다고 했습니다. 그런데도 사본을 두는 것은 자원이 아주 많으면 전부 조회하기가 너무 느리기 때문입니다. terraform plan -refresh=false 처럼 조회를 건너뛰면 이 사본이 그대로 기준이 됩니다.

dependencies 에는 이 인스턴스가 기대는 것(보안 그룹·서브넷)이 적혀 있습니다. 코드에서 블록을 통째로 지우면 순서를 정할 코드가 없어집니다. 그때 테라폼은 이 기록을 보고 지우는 순서를 정합니다.

맨 위 필드 serial 은 상태 파일을 새로 쓸 때마다 하나씩 오르는 번호입니다. 8 은 이 폴더에서 지금까지 쓴 횟수라서, 이 편에서 보여 준 apply 수와는 맞지 않습니다. lineage 는 상태를 처음 만들 때 붙는 고유 값입니다.

파일 권한은 소유자만 읽고 쓰는 -rw------- 입니다. 권한이 좁은 데는 이유가 있습니다. 상태 파일에는 속성이 평문으로 들어갑니다. 비밀번호 같은 속성이 있는 자원이면 그 값도 그대로 적힙니다.

terraform.tfstate.backup 은 바로 앞 판의 사본입니다.

6. 상태 파일을 지우면

상태 파일이 「내가 만든 것」의 유일한 기억이라면, 지웠을 때 테라폼은 모든 자원을 처음 보는 것으로 여길 것입니다. 정말 그런지 자원 개수로 셌습니다. count.sh 는 aws CLI(command-line interface, AWS 를 명령줄에서 부르는 도구)로 demo-sg 보안 그룹, 실행 중인 demo-app 인스턴스, demo-logs 버킷의 개수를 세는 스크립트입니다. 세 자원이 moto 에 살아 있는 채로 상태 파일만 치웠습니다. 되살릴 수 있게 지우지 않고 옆 폴더로 옮겼습니다.

실측 — 상태 파일을 치우고 plan / apply (2026-09-30, 라즈베리파이 5, Terraform 1.16.4 + moto)

터미널
./count.sh
mv terraform.tfstate terraform.tfstate.backup ../02-stash/
terraform plan -no-color 2>&1 | grep -E '^  # |^Plan:'
terraform apply -auto-approve -no-color
./count.sh

출력의 ### 줄은 구간을 가르려고 찍은 표시입니다. 두 번째 표시의 rm 은 이름일 뿐이고, 실제로는 위 mv 로 옮겼습니다.

### 지우기 전
SG(demo-sg): 1  인스턴스(running, Name=demo-app): 1  버킷(demo-logs): 1
### rm terraform.tfstate*; terraform plan
  # aws_instance.app will be created
  # aws_s3_bucket.logs will be created
  # aws_security_group.app will be created
Plan: 3 to add, 0 to change, 0 to destroy.

plan 은 세 자원을 전부 새로 만들겠다고 합니다. 진짜 자원이 버젓이 있는데도 테라폼은 모릅니다. 2절의 그림대로입니다. plan 이 클라우드를 조회하는 출발점은 상태 파일인데, 거기가 비었습니다. 그러면 apply 는 서버를 두 대로 만들까요.

### terraform apply -auto-approve
Plan: 3 to add, 0 to change, 0 to destroy.
aws_security_group.app: Creating...
aws_s3_bucket.logs: Creating...
Error: creating Security Group (demo-sg): operation error EC2: CreateSecurityGroup, https response error StatusCode: 400, RequestID: …, api error InvalidGroup.Duplicate: The security group 'demo-sg' already exists
  with aws_security_group.app,
  on main.tf line 6, in resource "aws_security_group" "app":
   6: resource "aws_security_group" "app" {
Error: creating S3 Bucket (demo-logs): BucketAlreadyExists
  with aws_s3_bucket.logs,
  on main.tf line 28, in resource "aws_s3_bucket" "logs":
  28: resource "aws_s3_bucket" "logs" {
### apply 뒤
SG(demo-sg): 1  인스턴스(running, Name=demo-app): 1  버킷(demo-logs): 1

중복은 0개입니다. 하지만 테라폼이 기존 자원을 알아보고 막은 것은 아닙니다. 셋이 각각 다른 이유로 멈췄습니다.

  • 보안 그룹은 EC2 API 가 거절했습니다. 한 VPC 안에서는 보안 그룹 이름이 겹치면 안 됩니다. 그래서 InvalidGroup.Duplicate 오류가 났습니다
  • 버킷은 프로바이더가 스스로 멈췄습니다. 아래 소절에서 봅니다
  • 인스턴스는 의존 때문에 만들어지지 않았습니다. 보안 그룹 ID 가 있어야 만들 수 있는데, 보안 그룹이 실패했습니다. 그래서 Creating... 줄조차 없습니다

버킷을 막은 것은 프로바이더였다

moto 의 요청 기록을 보면 이 apply 동안 버킷을 만드는 PUT /demo-logs 는 한 번도 가지 않았습니다. 「있나?」를 묻는 HEAD /demo-logs 만 갔습니다.

172.17.0.1 - - [30/Sep/2026 02:11:55] "HEAD /demo-logs HTTP/1.1" 200 -

프로바이더 소스(aws 6.66.0 bucket.go)에 이 확인이 들어 있습니다. 소스의 주석은 us-east-1 리전이 내 계정의 버킷을 다시 만들면 오류를 주지 않고 ACL(접근 권한 목록)까지 초기화하기 때문이라고 설명합니다. 그래서 프로바이더가 만들기 전에 있는지 확인하고, 있으면 BucketAlreadyExists 로 멈춥니다. 다른 리전이나 남의 계정 버킷에서 진짜 AWS 가 주는 오류와 문구가 같은지는 재지 않았습니다.

이름이 겹쳐도 되는 자원만 있으면

보안 그룹과 버킷은 각자의 규칙 덕분에 우연히 막혔습니다. 그렇다면 이름이 겹쳐도 되는 자원은 어떨까요. EC2 인스턴스의 Name 은 태그일 뿐이라 같은 이름이 몇 대든 생깁니다. 인스턴스 하나만 적은 코드를 따로 만들었습니다.

hcl
resource "aws_instance" "monitoring" {
  ami           = "ami-0f47531f8c49bd1c6"
  instance_type = "t4g.small"
  tags = { Name = "demo-monitoring" }
}

실측 — 상태를 지우고 apply 를 되풀이 (2026-09-30, 라즈베리파이 5, Terraform 1.16.4 + moto)

터미널
terraform apply -auto-approve -no-color; cnt
rm -f terraform.tfstate*; terraform apply -auto-approve -no-color; cnt
rm -f terraform.tfstate*; terraform apply -auto-approve -no-color; cnt
terraform state list -no-color
aws ec2 describe-instances \
  --filters Name=tag:Name,Values=demo-monitoring \
            Name=instance-state-name,Values=running \
  --query 'Reservations[].Instances[].InstanceId' --output text
terraform state show -no-color aws_instance.monitoring \
  | grep -E '^\s+id '
  • cnt — 실행 중인 demo-monitoring 인스턴스 개수를 세는 셸 함수입니다
  • terraform state list · state show — 상태 파일이 아는 리소스 이름과 그 속성을 찍습니다
### apply (상태 있음)
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
인스턴스(running, Name=demo-monitoring): 1
### rm terraform.tfstate*; apply
Plan: 1 to add, 0 to change, 0 to destroy.
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
인스턴스(running, Name=demo-monitoring): 2
### 한 번 더: rm terraform.tfstate*; apply
Plan: 1 to add, 0 to change, 0 to destroy.
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
인스턴스(running, Name=demo-monitoring): 3
### 지금 상태 파일이 아는 것
aws_instance.monitoring
i-eafa819cc7dbe47e9	i-166412564b88a0072	i-0e49b2429315d7e81
    id                                   = "i-0e49b2429315d7e81"

마지막 표시 아래 세 줄은 명령마다 하나씩입니다. 첫 줄은 state list(상태가 아는 리소스 이름)입니다. 둘째 줄은 describe-instances 로 본 실제 실행 중인 서버 셋입니다. 셋째 줄은 state show(상태가 아는 ID)입니다.

상태를 지울 때마다 한 대씩 늘어 3대가 됐습니다. 매번 오류 없이 Apply complete! 였습니다. 테라폼이 아는 것은 그중 마지막 i-0e49b2429315d7e81 하나뿐입니다. 나머지 둘은 코드로도 상태로도 추적되지 않습니다. 이제 terraform destroy(상태에 적힌 자원을 전부 지우는 명령)를 돌려도 그 둘은 남습니다. 요금만 내는 서버가 됩니다.

「같은 코드를 몇 번 돌려도 결과가 같다」는 테라폼의 장점(멱등성)은 코드만으로 생기지 않았습니다. 코드와 상태 파일이 함께 있어야 생깁니다. 상태를 잃은 테라폼은 돌릴 때마다 하나씩 더 만드는 스크립트와 다를 바가 없습니다.

잃은 상태는 이미 있는 자원을 상태에 다시 적어 넣는 import 로 되살립니다. 이 이야기는 이 시리즈의 「IAC 03 이미 있는 것을 코드로 가져오기 — import」에서 합니다. 여러 사람이 쓸 때는 상태 파일을 한곳에 모아 둡니다. 그 이야기는 「IAC 05 둘이 같이 apply 하면 — 원격 상태와 잠금」에서 합니다.

한 장 요약

질문 답
plan 과 apply plan 은 상태 파일에 적힌 자원을 조회해 코드와 견준 계획서입니다. apply 가 그것을 실행하고 상태 파일을 새로 씁니다
기호 + 만들기 · ~ 제자리 변경(태그·인스턴스 유형) · -/+ 교체(AMI·서브넷) · - 지우기
교체를 누가 정하나 프로바이더가 속성마다 정합니다. 원인 속성에 # forces replacement 가 붙습니다
교체와 제자리 변경의 차이 AMI·서브넷은 만든 뒤엔 멈춰도 못 바꿔서 교체입니다. 인스턴스 유형은 멈추면 바꿀 수 있어서 멈췄다 켜는 제자리 변경입니다
인스턴스 유형을 바꾸면 ID 는 그대로지만 멈췄다 켜지면서 공인 IP 가 바뀝니다. (known after apply) 줄이 미리 알려 줍니다
상태 파일 코드 속 이름 ↔ 클라우드 식별자의 대응표. 인스턴스 속성은 코드의 5개가 아니라 63개가 들었습니다
상태를 지우면 plan 은 전부 +. 보안 그룹은 EC2 API 가, 버킷은 프로바이더의 존재 확인이 막았고 인스턴스는 의존 탓에 못 만들어졌습니다. 인스턴스만 있으면 1 → 2 → 3대로 쌓입니다

관련 항목

plan 과 apply 가 기대는 것

테라폼 · 상태 파일 · HCL · 테라폼 프로바이더 · 선언적 설정

교체와 제자리 변경을 가르는 것

forces replacement · create_before_destroy · 불변 인프라 · 머신 이미지 · 탄력적 IP

상태를 잃거나 어긋나면 생기는 것

멱등성 · 구성 드리프트 · 고아 자원 · terraform import · 원격 상태 · 상태 잠금

이 편의 실험 대상

Amazon EC2 · Amazon S3 · 보안 그룹 · 서브넷 · 가용 영역 · moto

같은 일을 하는 다른 도구

코드형 인프라 · CloudFormation · 앤서블 · AWS CDK

IaC 시리즈의 다른 편

IAC 01 손으로 만든 서버는 무엇을 잃나 — 명령형 스크립트와 선언형 코드 · IAC 03 이미 있는 것을 코드로 가져오기 — import · IAC 04 누가 콘솔에서 손대면 — 드리프트와 테라폼이 못 보는 것 · IAC 05 둘이 같이 apply 하면 — 원격 상태와 잠금