IAC 01-2 반복과 재사용 — count·for_each·dynamic·module
고친 사람 github-actions[bot]
실측은 전부 라즈베리파이 5 · Terraform v1.16.4 · hashicorp/aws v6.67.0 에서 돌렸습니다. 진짜 AWS(Amazon Web Services) 대신 moto 를 파이 안에 띄워 두고 거기에 요청을 보냈습니다. moto 는 AWS API(Application Programming Interface, 프로그램이 서비스를 부르는 창구)와 같은 형식으로 응답하는 모의 서버입니다. 서버를 실제로 켜거나 트래픽을 막지는 않습니다. 실험 폴더마다 똑같이 든 프로바이더 설정 파일(
versions.tf)은 본문에서 뺐습니다. 무엇을 적는지는 IAC 01-1 테라폼 코드 한 벌 짜기 — 파일 배치·변수·출력·참조 2·3절에 있습니다. 출력은 찍힌 것을 붙이되 정렬용 빈칸은 줄였습니다. 길어서 자른 곳은 「…」로 표시했습니다.
1. 가운데 서버를 뺐더니 옆 서버가 지워졌다
웹 서버 세 대가 떠 있습니다. 서버마다 붙인 Name 태그(AWS 콘솔 목록에 이름으로 보이는 태그)는 demo-web-a · demo-web-b · demo-web-c 입니다. 셋 다 테라폼(인프라를 코드로 적으면 그대로 만들어 주는 도구)으로 띄웠습니다. 코드에는 이름 목록 ["web-a", "web-b", "web-c"] 이 있습니다. web-b 가 필요 없어져서 목록에서 "web-b" 를 지우고 plan 을 돌립니다. plan 은 적용하기 전에 무엇을 만들고·바꾸고·지울지 계획만 보여 주는 명령입니다. 기대하는 답은 「web-b 서버 한 대를 지운다」입니다.
같은 세 대를 두 방식으로 만들어 두고 똑같이 web-b 를 뺐습니다. 하나는 count, 하나는 for_each 입니다. 둘 다 리소스 블록(만들 자원 하나를 적는 코드 묶음) 하나로 같은 종류의 자원을 여러 개 만드는 기능입니다.
count 쪽 계획은 「1개 변경, 1개 삭제」였습니다. 숫자는 얌전합니다. 그런데 내용이 이상합니다. web-b 서버는 지워지지 않고 Name 태그만 demo-web-c 로 바뀝니다. 지워지는 것은 멀쩡히 일하던 진짜 web-c 서버입니다. for_each 쪽 계획은 「1개 삭제」였습니다. 지워지는 것은 web-b 서버입니다.
은행 창구로 비유하면 count 는 서버를 대기 번호로 기억합니다. 0번·1번·2번이 줄을 서 있다가 1번이 빠지면 2번 손님이 한 칸 당겨 1번이 됩니다. 테라폼 눈에는 「1번의 이름이 바뀌었고 2번은 없어졌다」로 보입니다. for_each 는 서버를 이름으로 부릅니다. web-b 가 빠져도 web-c 는 계속 web-c 입니다. 아래 그림의 i-… 는 AWS 가 인스턴스(가상 서버 한 대)마다 붙이는 ID 입니다.
flowchart TD
subgraph A["count — 번호로 기억"]
A0["[0] web-a · i-bfc7…<br/>그대로"]
A1["[1] web-b · i-8faf…<br/>Name 태그만 web-c 로"]
A2["[2] web-c · i-0051…<br/>삭제"]
A0 --- A1 --- A2
end
subgraph B["for_each — 이름으로 기억"]
B0["web-a · i-bcc6…<br/>그대로"]
B1["web-b · i-6196…<br/>삭제"]
B2["web-c · i-41f6…<br/>그대로"]
B0 --- B1 --- B2
end
A ~~~ B
계획의 추가·변경·삭제 수는 count 가 0·1·1, for_each 가 0·0·1 입니다. 숫자만 보고 승인했다면 count 쪽에서는 지우려던 서버가 살아남고 남길 서버가 사라집니다. 2·3절에서 두 코드를 읽으며 왜 갈렸는지 봅니다.
2. count — 번호로 센다
HCL(HashiCorp Configuration Language, 테라폼 설정 언어)의 리소스 블록에 count 를 넣으면 블록 하나로 여러 개를 만듭니다. 리소스 블록·plan·apply 가 처음이면 IAC 01 손으로 만든 서버는 무엇을 잃나 — 명령형 스크립트와 선언형 코드가 먼저입니다. 프로바이더 설정·변수·${}·참조처럼 코드 한 벌을 짜는 문법은 IAC 01-1 테라폼 코드 한 벌 짜기 — 파일 배치·변수·출력·참조에서 풉니다.
variable "names" {
type = list(string)
default = ["web-a", "web-b", "web-c"]
}
resource "aws_instance" "web" {
count = length(var.names)
ami = "ami-04fc404d256fd34a2"
instance_type = "t4g.micro"
tags = {
Name = "demo-${var.names[count.index]}"
}
}
variable "names"— 바깥에서 바꿔 넣을 수 있는 입력값(변수)입니다.list(string)은 문자열 목록 타입입니다. 값을 안 넘기면default를 씁니다aws_instance— EC2(Elastic Compute Cloud) 인스턴스, 곧 AWS 가상 서버입니다ami— AMI(Amazon Machine Image), 곧 서버를 띄울 디스크 원본입니다. moto 에 든 arm64 이미지를 고정값으로 넣었습니다count = length(var.names)— 몇 개 만들지입니다.length()는 목록 길이를 세는 함수라 3이 됩니다count.index— 지금 만드는 것이 몇 번째인지입니다. 공식 문서는 "The distinct index number starting with0corresponding to this instance."(이 인스턴스에 대응하는 0부터 시작하는 고유 번호)라고 적습니다var.names[count.index]— 목록의count.index번째 원소입니다. 0번이면"web-a"입니다tags = { Name = … }— 서버에 다는 태그입니다. 1절에서 말한Name태그가 이 줄입니다"demo-${…}"—${ }안 식의 값을 문자열에 끼워 넣습니다. 0번 서버의 Name 태그는demo-web-a가 됩니다
실측 — count 로 셋 만들고 web-b 빼기 (2026-10-05, 라즈베리파이 5, Terraform 1.16.4 + moto)
terraform apply -auto-approve -no-color
terraform state list
terraform plan -no-color -var 'names=["web-a","web-c"]'
-auto-approve— 「적용할까요?」 확인을 건너뜁니다.-no-color는 출력의 색 코드를 뺍니다terraform state list— 상태 파일에 적힌 자원 주소를 찍습니다. 상태 파일은 「코드의 이 블록이 AWS 의 어느 자원인지」를 테라폼이 적어 두는 파일입니다-var '이름=값'— 변수 값을 명령줄에서 넘깁니다.default대신 이 값이 쓰입니다
apply 출력에서 주소와 ID 만 뽑아 표로 정리했습니다. state list 도 같은 주소 세 줄이었습니다.
주소 인스턴스 ID
aws_instance.web[0] i-bfc7f4feec502773d
aws_instance.web[1] i-8faf4bd05c17016f5
aws_instance.web[2] i-0051b7481a39d3abc
주소 끝의 [0] 이 핵심입니다. count 로 만든 자원은 번호로 상태 파일에 적힙니다. web-b 라는 이름은 주소 어디에도 없습니다. 이 상태에서 web-b 를 뺀 plan 입니다.
# aws_instance.web[1] will be updated in-place
~ resource "aws_instance" "web" {
id = "i-8faf4bd05c17016f5"
~ tags = {
~ "Name" = "demo-web-b" -> "demo-web-c"
}
…
# aws_instance.web[2] will be destroyed
# (because index [2] is out of range for count)
- resource "aws_instance" "web" {
- id = "i-0051b7481a39d3abc" -> null
…
Plan: 0 to add, 1 to change, 1 to destroy.
~ 는 서버를 그대로 둔 채 속성만 고치는 제자리 변경, - 는 삭제입니다. 기호가 언제 나오는지는 IAC 02 plan 이 말해 주는 것 — 제자리 변경·교체·상태 파일에서 다룹니다. 판정 기준은 하나입니다. 테라폼은 상태 파일과 코드를 주소끼리 맞대어 봅니다.
| 주소 | 상태 파일 | 바뀐 코드 |
|---|---|---|
web[1] |
i-8faf…, Name 태그 web-b | names[1] = web-c |
web[2] |
i-0051…, Name 태그 web-c | 없음 (목록 길이 2) |
web[1] 은 양쪽에 다 있고 Name 태그만 다릅니다. 그래서 i-8faf… 의 Name 태그를 고칩니다. web[2] 는 코드에 없습니다(「index [2] is out of range」). 그래서 i-0051…, 진짜 web-c 를 지웁니다. 테라폼이 코드를 틀리게 읽은 것이 아닙니다. 코드에 적힌 것은 「세 번째가 없어졌다」뿐입니다.
이름이 user_data 에도 들어가면
이름이 태그에만 쓰여서 이 정도로 끝났습니다. 태그는 서버를 그대로 둔 채 고칠 수 있습니다. 이번에는 이름을 user_data(서버가 처음 켜질 때 한 번 실행할 스크립트) 인자에도 넣었습니다. 인자는 블록 안의 이름 = 값 한 줄입니다. user_data 는 바뀌어도 기본으로는 서버를 껐다 켜는 제자리 변경입니다. 둘째 줄 user_data_replace_on_change = true 를 켜면 바뀔 때 인스턴스를 지우고 새로 만듭니다. aws 프로바이더(테라폼이 AWS 를 다루게 해 주는 플러그인)의 옵션입니다. 이 옵션을 켠 채 web-b 를 뺐습니다.
user_data = "hostnamectl set-hostname ${var.names[count.index]}"
user_data_replace_on_change = true
실측 — user_data 에 이름이 든 count 에서 web-b 빼기 (2026-10-05, 라즈베리파이 5, Terraform 1.16.4 + moto)
# aws_instance.web[1] must be replaced
-/+ resource "aws_instance" "web" {
…
~ user_data = "…web-b" -> "…web-c" …
…
# aws_instance.web[2] will be destroyed
…
Plan: 1 to add, 0 to change, 2 to destroy.
-/+ 는 지우고 새로 만드는 교체입니다. 잘라 낸 user_data 줄 끝에는 # forces replacement(이 변경이 교체를 부른다)가 붙어 있었습니다. 하나를 뺐는데 서버 둘이 지워지고 하나가 새로 생깁니다. 밀린 번호마다 교체가 붙으니 목록이 길수록, 앞쪽을 뺄수록 지워지는 서버가 늘어납니다. moto 의 인스턴스는 실제로 켜지지 않아서 이 결과는 계획까지입니다.
3. for_each — 이름으로 센다
바뀐 곳은 count 한 줄과 이름을 꺼내는 식 하나입니다.
resource "aws_instance" "web" {
for_each = toset(var.names)
# ami · instance_type 은 2절과 같음
tags = {
Name = "demo-${each.key}"
}
}
for_each— 공식 문서의 정의는 "accepts a map or a set of strings and creates an instance for each item in that map or set"(맵이나 문자열 집합을 받아 원소마다 인스턴스를 하나씩 만든다)입니다. 맵(키-값 묶음)을 주는 법은 4절에서 봅니다toset()— 목록을 집합(순서도 중복도 없는 모음)으로 바꿉니다. 공식 문서는 이 변환이 "discards the ordering of the items in the list and removes any duplicate elements"(순서를 버리고 중복을 지운다)라고 적습니다. 목록은for_each에 바로 못 넣어서 바꿉니다each.key— 지금 원소의 키입니다. 집합이면 원소 자체라"web-a"같은 이름이 들어옵니다
실측 — for_each 로 셋 만들고 web-b 빼기 (2026-10-05, 라즈베리파이 5, Terraform 1.16.4 + moto)
2절과 같은 명령 셋을 돌렸습니다. state list 는 aws_instance.web["web-a"] · ["web-b"] · ["web-c"] 였습니다. 주소 끝이 번호가 아니라 이름입니다. web-b 를 뺀 plan 은 이렇습니다.
# aws_instance.web["web-b"] will be destroyed
# (because key ["web-b"] is not in for_each map)
- resource "aws_instance" "web" {
- id = "i-6196b60f5fa3b4ebf" -> null
…
Plan: 0 to add, 0 to change, 1 to destroy.
주소끼리 맞대면 ["web-a"] 와 ["web-c"] 는 양쪽에 있고 내용도 같습니다. ["web-b"] 만 코드에 없습니다. 그래서 그 하나만 지웁니다.
4. 언제 count, 언제 for_each
공식 문서의 권고입니다. "Use the count argument when you want to create nearly identical instances. Use for_each when some instance arguments must have distinct values that can't be directly derived from an integer index."(거의 똑같은 인스턴스를 만들 때는 count 를, 정수 번호로부터 바로 끌어낼 수 없는 서로 다른 값이 있어야 할 때는 for_each 를 쓴다.) 2·3절 실측이 이 권고의 근거입니다. 세 서버에는 「web-a·web-b·web-c」라는 각자의 이름이 있었습니다. 이름은 0·1·2 에서 끌어낼 수 없는 값입니다. 그것을 count 로 만들자 번호와 이름이 어긋났습니다.
방식 추가 변경 삭제
count (태그만) 0 1 1
count (user_data) 1 0 2
for_each 0 0 1
정리하면 똑같은 것 N개라서 개수만 뜻이 있으면 count 입니다. 하나하나 이름·설정이 다르거나 가운데 원소를 뺄 일이 있으면 for_each 입니다. 위 세 줄이 그 차이입니다.
서버마다 설정이 다르면 — 맵
이름뿐 아니라 인스턴스 타입도 서버마다 다르면 for_each 에 맵을 줍니다. 공식 문서 예시의 꼴을 이 편의 이름으로 옮겼습니다. 이 코드는 돌려 보지 않았습니다.
for_each = tomap({
"web-a" = "t4g.micro"
"web-c" = "t4g.small"
})
instance_type = each.value
tomap({ … })—"키" = 값줄들을 맵으로 만듭니다each.key·each.value— 지금 원소의 키와 값입니다. 첫 서버라면"web-a"와"t4g.micro"입니다. 공식 문서는 집합을 주면each.value가each.key와 같다고 적습니다- 서버 ID 를 이름별로 내려면 output 의 값을
{ for k, v in aws_instance.web : k => v.id }로 적습니다(공식 문서 예).for식은 원소마다키 => 값을 만들어 새 맵으로 엮습니다
for_each 에도 조건이 있습니다. 맵의 키나 집합의 값은 "must be known values"(apply 전에 알 수 있는 값이어야 한다)입니다. 같은 apply 에서 생길 자원의 ID 처럼 만들어 봐야 아는 값은 키로 못 씁니다. 코드에 적힌 이름을 키로 쓰면 걸릴 일이 없습니다. 한 블록에 count 와 for_each 를 같이 쓸 수도 없습니다.
5. dynamic — 블록 안의 블록을 목록으로
방화벽 규칙 묶음인 보안 그룹은 들어오는 트래픽 규칙 하나를 ingress { … } 블록 하나로 적습니다. 규칙이 환경마다 다르다면 이 목록을 변수로 빼고 싶어집니다. count 와 for_each 는 여기에 못 씁니다. 둘은 자원 전체를 여러 개 만듭니다. 지금 여러 개로 만들 것은 보안 그룹 하나 안의 블록입니다. 이것을 하는 것이 dynamic 블록입니다. 공식 문서는 dynamic 이 "acts much like a for expression, but produces nested blocks"(for 식과 비슷하지만 안쪽 블록을 만들어 낸다)라고 적습니다. 규칙은 셋을 넣었습니다. SSH(원격 접속, 22번)는 내부망에서만, HTTP(80번)·HTTPS(443번, 웹)는 어디서든 받습니다.
variable "ingress_rules" {
type = list(object({
port = number
cidr = string
description = string
}))
default = [
{ port = 22, cidr = "10.0.0.0/16", description = "ssh" },
{ port = 80, cidr = "0.0.0.0/0", description = "http" },
{ port = 443, cidr = "0.0.0.0/0", description = "https" },
]
}
resource "aws_security_group" "web" {
name = "demo-web-sg"
description = "demo web sg"
dynamic "ingress" {
for_each = var.ingress_rules
content {
description = ingress.value.description
from_port = ingress.value.port
to_port = ingress.value.port
protocol = "tcp"
cidr_blocks = [ingress.value.cidr]
}
}
}
list(object({ … }))— 원소마다port·cidr·description세 칸을 가진 목록 타입입니다. 칸이 틀린 값은 테라폼이 막습니다cidr—10.0.0.0/16처럼 IP(Internet Protocol) 주소 범위를 적는 표기(CIDR, Classless Inter-Domain Routing)입니다.0.0.0.0/0은 어디서 오든 전부입니다dynamic "ingress"— 블록 이름 뒤 따옴표 낱말을 레이블이라 합니다. 이 레이블"ingress"가 펼칠 블록의 이름입니다.ingress { … }를 원소 수만큼 만듭니다content { … }— 펼쳐질 블록 하나의 본문입니다from_port·to_port— 받을 포트 범위의 시작과 끝입니다. 같으면 한 포트입니다ingress.value— 지금 원소입니다. 반복 변수 이름은 따로 안 정하면 블록 레이블을 따릅니다. dynamic 의 for_each 는 3절과 달리 목록도 그대로 받습니다
값 파일(이름 = 값 만 적는 .tfvars 파일) no-http.tfvars 에는 가운데 http 만 뺀 둘을 넣었습니다. -var-file= 로 이 파일을 넘기면 default 대신 그 값이 쓰입니다.
실측 — dynamic 규칙 목록에서 http 빼기 (2026-10-05, 라즈베리파이 5, Terraform 1.16.4 + moto)
terraform apply -auto-approve -no-color
terraform plan -no-color -var-file=no-http.tfvars
# aws_security_group.web will be updated …
…
- {
…
- description = "http"
- from_port = 80
…
# (2 unchanged elements hidden)
…
Plan: 0 to add, 1 to change, 0 to destroy.
뺀 http 하나만 - 입니다. 나머지 둘은 unchanged 로 숨었습니다. 보안 그룹은 교체가 아니라 제자리 변경입니다. 적용한 뒤 moto 에 규칙 포트를 조회하니 적용 전 22 80 443, 적용 뒤 22 443 이었습니다. moto 는 실제 트래픽을 막지 않아 규칙이 빠진 것까지만 확인했습니다.
2절의 count 와 달리 뒤가 밀리지 않았습니다. aws 프로바이더의 ingress 는 순서 없는 집합입니다. 그래서 테라폼이 위치가 아니라 내용으로 규칙의 짝을 맞춥니다. 같은 까닭으로 첫 apply 의 계획에서도 ingress 는 http(80)·https(443)·ssh(22) 순으로 나와 변수 순서와 달랐습니다. 테라폼이 자기 기준으로 정렬해 보여 준 것입니다.
공식 문서는 dynamic 을 아껴 쓰라고 합니다. "overuse of dynamic blocks can make configuration hard to read and maintain … Always write nested blocks out literally where possible."(남용하면 읽고 유지하기 어려워지니, 가능하면 안쪽 블록은 글자 그대로 적으라는 말입니다.) 규칙을 환경마다 변수로 받아야 할 때 씁니다. 고정된 규칙 두세 개는 그냥 적는 편이 읽기 쉽습니다. 실제 구성이라면 aws 프로바이더 문서는 안쪽 ingress 블록 대신 규칙마다 따로 적는 aws_vpc_security_group_ingress_rule 자원을 권합니다. 여기서는 dynamic 을 보이려고 안쪽 블록을 썼습니다.
6. 모듈 — 웹 서버 한 벌을 dev·prod 에서 두 번
웹 서버 한 대에는 보안 그룹과 인스턴스가 짝으로 붙습니다. 개발용(dev)과 운영용(prod)에 같은 짝이 하나씩 필요합니다. 블록을 복사해 붙이면 고칠 때 두 곳을 고쳐야 합니다. 모듈(module)은 리소스 블록 몇 개를 폴더 하나에 묶은 것입니다. 공식 문서는 모듈을 쓰는 까닭을 "reuse collections of resource definitions"(리소스 정의 묶음을 다시 쓰게 해 준다)라고 적습니다.
모듈은 겉판을 닫은 가전제품과 같습니다. 밖에서 만질 수 있는 것은 버튼과 표시창뿐입니다. 안의 회로에는 손이 안 닿습니다. 테라폼에서 버튼은 입력 variable, 표시창은 출력 output 입니다.
terraform 명령을 돌리는 폴더가 루트 모듈, 거기서 불러 쓰는 모듈이 자식 모듈입니다. 실험 폴더는 이렇게 생겼습니다.
. ← 루트 모듈, 여기서 실행
├── versions.tf ← 프로바이더 설정
├── main.tf ← module 블록 둘
├── outputs.tf
└── modules/
└── web/ ← 자식 모듈
├── versions.tf
├── variables.tf
├── main.tf
└── outputs.tf
루트의 versions.tf 는 IAC 01-1 테라폼 코드 한 벌 짜기 — 파일 배치·변수·출력·참조의 versions.tf·providers.tf 두 파일을 한 파일에 합친 것입니다. required_providers 와 provider "aws" 블록이 다 들어 있습니다.
자식 모듈
# modules/web/versions.tf — required_providers 만
# modules/web/variables.tf
variable "env" {
type = string
}
variable "instance_type" {
type = string
default = "t4g.micro"
}
# ami_id 도 같은 꼴(기본값 = AMI ID)
# modules/web/main.tf
resource "aws_security_group" "this" {
name = "demo-${var.env}-web-sg"
description = "demo web sg (${var.env})"
}
resource "aws_instance" "this" {
ami = var.ami_id
instance_type = var.instance_type
vpc_security_group_ids = [aws_security_group.this.id]
tags = {
Name = "demo-${var.env}-web"
Env = var.env
}
}
# modules/web/outputs.tf
output "instance_id" {
value = aws_instance.this.id
}
# output "private_ip" 도 같은 꼴
versions.tf— 자식 모듈에는terraform { required_providers { … } }블록으로 쓸 프로바이더만 밝힙니다. 공식 문서는 "A module intended to be called by one or more other modules must not contain any provider blocks."(다른 모듈이 불러 쓸 모듈에는 provider 블록을 두면 안 된다)라고 적습니다. 자식 모듈의 자원은 루트의provider "aws"설정을 물려받습니다variable "env"—default가 없어 부르는 쪽이 반드시 넘겨야 하는 입력입니다.default가 있는instance_type은 안 넘겨도 됩니다"this"— 모듈 안에 그 종류가 하나뿐일 때 흔히 붙이는 이름입니다. 문법상 뜻은 없습니다vpc_security_group_ids— 인스턴스에 붙일 보안 그룹 ID 목록입니다.aws_security_group.this.id는 같은 모듈의 보안 그룹 ID 를 가리키는 참조입니다. 꼴은<종류>.<이름>.<속성>입니다. 이 참조 때문에 테라폼은 보안 그룹을 먼저 만들고 인스턴스를 그 뒤에 만듭니다output— 모듈이 밖으로 내놓는 값입니다. 부르는 쪽은 이것만 볼 수 있습니다. 6절 끝 실측에서 확인합니다
루트 모듈 — 두 번 부르기
# main.tf
module "dev" {
source = "./modules/web"
env = "dev"
}
module "prod" {
source = "./modules/web"
env = "prod"
instance_type = "t4g.small"
}
# outputs.tf
output "dev_instance_id" {
value = module.dev.instance_id
}
# output "prod_private_ip" 도 같은 꼴
# (value = module.prod.private_ip)
module "dev"— 모듈을 한 번 부르는 블록입니다.dev는 이 호출의 이름입니다source— 모듈이 있는 곳입니다../로 시작하면 지금 폴더 기준의 로컬 경로입니다.../는 한 단계 위 폴더입니다env = "dev"— 모듈의variable "env"에 값을 넣습니다. prod 는instance_type기본값을t4g.small로 덮어썼습니다module.dev.instance_id— 자식 모듈의 output 을 읽는 참조입니다. 꼴은module.<호출 이름>.<output 이름>입니다
모듈을 더하면 init 부터
실측 — init 과 apply (2026-10-05, 라즈베리파이 5, Terraform 1.16.4 + moto)
terraform init · apply · state list 를 차례로 돌렸습니다.
Initializing modules...
- dev in modules/web
- prod in modules/web
…
Apply complete! Resources: 4 added, …
Outputs:
dev_instance_id = "i-75fc97869522e3321"
prod_private_ip = "10.166.241.177"
module.dev.aws_instance.this
module.dev.aws_security_group.this
module.prod.aws_instance.this
module.prod.aws_security_group.this
terraform init 은 프로바이더를 받아 오는 명령입니다. 모듈 설치도 이 명령이 합니다. 로컬 모듈은 내려받지 않습니다. init 은 「dev 와 prod 는 modules/web 에 있다」는 목록만 적어 둡니다. 그래서 module "staging" 블록을 더하고 init 없이 plan 하면 이렇게 멈춥니다. 잘린 뒷부분은 "terraform init" 을 돌리라는 안내입니다.
Error: Module not installed
…
This module is not yet installed. Run …
모듈 블록 둘이 자원 넷을 만들었습니다. 주소는 module.<호출 이름>.<자원 종류>.<이름> 꼴입니다. 모듈 안에서는 둘 다 aws_instance.this 입니다. 앞에 module.dev · module.prod 가 붙어 겹치지 않습니다. 계획에서 dev 인스턴스는 t4g.micro, prod 는 t4g.small 이었습니다. ID·IP 는 moto 가 지어낸 값입니다.
루트에서는 output 만 보인다
실측 — 루트에서 모듈 안을 들여다보기 (2026-10-05, 라즈베리파이 5, Terraform 1.16.4 + moto)
terraform console 은 식을 넣으면 지금 상태 기준의 값을 보여 주는 명령입니다. 식 넷을 차례로 넣었습니다. > 뒤가 넣은 식, 그 아래가 응답입니다.
> module.dev
{
"instance_id" = "i-75fc97869522e3321"
"private_ip" = "10.117.241.117"
}
> module.dev.instance_id
"i-75fc97869522e3321"
> module.dev.public_ip
…
…does not have an attribute named "public_ip".
> module.dev.aws_instance.this.id
…
…not have an attribute named "aws_instance".
루트에서 module.dev 는 output 두 개짜리 값으로만 보입니다. 인스턴스에 public_ip 가 있어도 output 으로 안 냈으니 못 꺼냅니다. 모듈 안 자원 aws_instance.this 도 직접 못 가리킵니다. 부모 쪽에서는 자식 모듈의 output 만 쓸 수 있습니다. 겉판을 닫은 가전제품과 같은 캡슐화입니다. 밖에서 필요한 값이 생기면 모듈에 output 을 하나 더 적습니다.
7. moved — 이름을 바꿔도 서버를 지킨다
모듈 안 aws_instance.this 를 알아보기 쉬운 aws_instance.server 로 바꾸고 싶어졌습니다. 블록 레이블과 output 의 참조만 고쳤습니다. 서버 설정은 한 글자도 안 바뀌었습니다.
실측 — 모듈 안 자원 이름 바꾸기, moved 없이 (2026-10-05, 라즈베리파이 5, Terraform 1.16.4 + moto)
# module.dev.aws_instance.server will be created
# module.dev.aws_instance.this will be destroyed
# (because aws_instance.this is not in …)
…
Plan: 2 to add, 0 to change, 2 to destroy.
prod 도 같은 두 줄이었습니다. 2절과 같은 판정입니다. 상태 파일의 this 는 코드에 없으니 지웁니다. 코드의 server 는 상태 파일에 없으니 새로 만듭니다. 주소가 바뀌면 테라폼은 기본으로 "destroy the existing resource and create a new resource at the new address"(기존 자원을 지우고 새 주소에 새로 만든다)로 읽습니다.
이사하면 우체국에 주소 이전을 신고합니다. 그러면 옛 주소로 온 편지가 새 주소로 갑니다. moved 블록이 그 신고입니다. 모듈 main.tf 끝에 넣었습니다.
moved {
from = aws_instance.this
to = aws_instance.server
}
from·to— 옛 주소와 새 주소입니다. 테라폼은to로 계획을 세우기 전에 상태 파일에서from주소의 자원을 먼저 찾습니다(공식 문서)
실측 — 같은 이름 바꾸기, moved 와 함께 (2026-10-05, 라즈베리파이 5, Terraform 1.16.4 + moto)
# module.dev.aws_instance.this has moved to …
resource "aws_instance" "server" {
id = "i-75fc97869522e3321"
…
# module.prod.aws_instance.this has moved to …
…
Plan: 0 to add, 0 to change, 0 to destroy.
2 to add, 2 to destroy 가 0, 0, 0 이 됐습니다. dev 인스턴스 ID 는 6절의 i-75fc97869522e3321 그대로입니다. apply 뒤 state list 에는 module.dev.aws_instance.server 가 나왔습니다. 상태 파일의 주소만 옮겨 적은 것입니다. 모듈 안에 둔 moved 한 블록이 dev·prod 두 호출에 다 적용됐습니다.
루트의 호출 이름을 바꿀 때도 같습니다. module "dev" 를 module "development" 로 바꾸고 init 을 먼저 돌렸습니다. moved 없이는 2 to add, 2 to destroy 였습니다. 루트에 from = module.dev, to = module.development 인 moved 블록을 넣자 모듈 안 자원 둘이 같이 옮겨져 0, 0, 0 이 됐습니다.
moved 는 적용이 끝난 뒤에도 지우지 않는 편이 안전합니다. 공식 문서는 "Removing a moved block is a breaking change"(moved 를 지우면 호환이 깨진다)라고 적습니다. 옛 주소의 상태 파일을 가진 다른 환경이 남아 있으면, moved 가 사라진 순간 그쪽에 이동 대신 삭제 계획이 나옵니다.
8. 환경 나누기 — 워크스페이스와 환경별 폴더
6절은 dev 와 prod 를 루트 모듈 하나에서 불렀습니다. 그러면 두 환경의 자원이 상태 파일 하나에 같이 적힙니다. dev 만 고치려 해도 prod 자원까지 같은 plan 에서 새로 읽고 따집니다. 상태 파일을 환경마다 따로 두는 길이 둘 있습니다.
워크스페이스(workspace)는 같은 코드에 상태 파일을 여러 벌 달아 두는 기능입니다. 처음부터 default 하나가 있습니다. 코드에서는 지금 이름을 terraform.workspace 로 읽습니다. 보안 그룹 이름을 "demo-${terraform.workspace}-web-sg" 로 적고 두 워크스페이스에 apply 했습니다.
실측 — 워크스페이스별 상태 파일 (2026-10-05, 라즈베리파이 5, Terraform 1.16.4 + moto)
default 에서 apply 했습니다. terraform workspace new staging 으로 새 워크스페이스를 만들어 다시 apply 한 뒤 .tfstate 파일을 찾았습니다.
./terraform.tfstate
./terraform.tfstate.d/staging/terraform.tfstate
default 의 상태 파일은 늘 쓰던 terraform.tfstate 입니다. 새 워크스페이스는 terraform.tfstate.d/<이름>/ 아래에 따로 생겼습니다. moto 에는 demo-default-web-sg 와 demo-staging-web-sg 가 생겼습니다.
공식 문서는 워크스페이스의 한계를 이렇게 적습니다. "Workspaces are not appropriate for system decomposition or deployments requiring separate credentials and access controls."(시스템을 쪼개거나 자격 증명·접근 권한을 따로 둬야 하는 배포에는 알맞지 않다.) 그럴 때는 루트 모듈, 즉 폴더를 따로 두라고 권합니다. 환경별 폴더는 이런 모양입니다. 폴더마다 루트 모듈이라 프로바이더 설정 versions.tf 도 하나씩 둡니다.
.
├── envs/
│ ├── dev/ ← versions.tf · main.tf
│ └── prod/ ← versions.tf · main.tf
└── modules/
└── web/ ← 6절의 자식 모듈
# envs/dev/main.tf
module "web" {
source = "../../modules/web"
env = "dev"
}
envs/dev/ 에서 두 단계 올라가야 modules/web 이 보여서 "../../modules/web" 입니다. 공식 문서는 로컬 모듈 경로를 ./ 나 ../ 로 시작하라고 적습니다. terraform 은 환경 폴더 안에서 돌립니다. 상태 파일도 폴더마다 하나씩 생깁니다. prod 폴더의 명령은 dev 상태 파일을 열지 않습니다. 이 구성은 재지 않았습니다. dev·prod 처럼 권한까지 갈라야 하는 환경은 이 폴더 방식이 맞습니다. 6절처럼 한 루트에서 두 번 부르는 방식은 연습이나 함께 바뀌어도 되는 작은 구성에 어울립니다. 상태 파일을 공용 저장소에 두는 법은 IAC 05 둘이 같이 apply 하면 — 원격 상태와 잠금에서 다룹니다.
한 장 요약
count 는 자원을 번호로 기억합니다. 서버 셋 가운데 web-b 를 빼자 번호가 당겨져 web-b 서버의 Name 태그가 web-c 로 바뀌고 진짜 web-c 서버가 지워졌습니다(0/1/1). 교체 옵션을 켠
user_data에도 이름이 들어 있으면 둘이 지워졌습니다(1/0/2). for_each 는 이름으로 기억해 web-b 하나만 지웠습니다(0/0/1). 거의 똑같은 것 N개는 count, 하나하나 이름이 다르면 for_each 입니다. 설정까지 다르면 맵을 주고each.value로 꺼냅니다(1~4절). dynamic 은 자원이 아니라 블록 안의 블록을 목록으로 펼칩니다. 가운데 규칙을 빼도ingress가 집합이라 그 규칙 하나만 빠졌습니다(5절).모듈은 리소스 묶음 폴더입니다. 입력은 variable, 출력은 output 입니다. 자식 모듈에는 provider 블록을 두지 않습니다. 루트에서는 output 만 보입니다. 주소는
module.dev.aws_instance.this꼴입니다. module 블록을 더하면 init 을 다시 해야 합니다(6절). 이름을 바꾸면 테라폼은 지우고 새로 만들려 합니다(2/0/2). moved 블록이 상태 파일의 주소만 옮겨 0/0/0 으로 만듭니다(7절). 워크스페이스는 상태 파일을terraform.tfstate.d/<이름>/에 따로 둡니다. 자격 증명까지 가르려면 환경별 폴더에서../../modules/web으로 모듈을 부릅니다(8절).
관련 항목
같은 자원을 여러 개 만드는 방법
count · for_each · dynamic 블록 · toset · tomap · 테라폼 변수
묶음을 다시 쓰는 방법
테라폼 모듈 · 루트 모듈 · 자식 모듈 · 테라폼 출력 · 캡슐화 · 테라폼 레지스트리
이름을 바꿀 때 기대는 것
moved 블록 · 상태 파일 · 리소스 주소 · 리팩터링 · terraform state mv
환경을 나누는 방법
테라폼 워크스페이스 · 원격 상태 · 환경 분리 · 백엔드
테라폼을 이루는 부품과 명령
terraform plan · terraform apply · terraform init · terraform console · 제자리 변경 · 리소스 교체 · 테라폼 · HCL · 프로바이더 · 리소스 · 코드형 인프라 · 멱등성
이 편에서 코드로 만든 AWS 자원
Amazon EC2 · 보안 그룹 · user_data · 머신 이미지 · CIDR · 집합
실습에 쓴 모의 환경
moto · LocalStack · 라즈베리파이
IaC 시리즈의 다른 편
IAC 01 손으로 만든 서버는 무엇을 잃나 — 명령형 스크립트와 선언형 코드 · IAC 01-1 테라폼 코드 한 벌 짜기 — 파일 배치·변수·출력·참조 · IAC 02 plan 이 말해 주는 것 — 제자리 변경·교체·상태 파일 · IAC 03 이미 있는 것을 코드로 가져오기 — import · IAC 04 누가 콘솔에서 손대면 — 드리프트와 테라폼이 못 보는 것 · IAC 05 둘이 같이 apply 하면 — 원격 상태와 잠금