Table of contents
- 리소스가 모든 것의 중심이다
- resource 블록의 구조
- 리소스 참조와 암묵적 의존성
- 명시적 의존성 — depends_on
- count — 리소스를 숫자만큼 복제
- for_each — 맵이나 셋을 순회
- count vs for_each 한눈에
- lifecycle — 생성/변경/삭제 미세 제어
- 타임아웃 — 기다려줄 시간
- 전체 그림 — 리소스 하나에 들어가는 것들
- 자주 하는 실수
- 다음으로
리소스가 모든 것의 중심이다
Terraform 코드의 대부분은 결국 resource 블록이다. 변수와 프로바이더는 리소스를 잘 다루기 위한 조연이고, 이번 편의 주제인 리소스 자체는 주인공이다. 그래서 이 편의 분량이 조금 길 수 있지만, 여기까지 소화하면 실무 Terraform 코드를 혼자서 읽고 쓸 수 있게 된다.
다루는 주제는 세 덩어리다.
resource블록의 구조와 참조 방식- 의존성 — Terraform이 실행 순서를 어떻게 정하는지
- 라이프사이클 — “재생성”, “삭제 방지”, “변경 무시” 같은 미세 제어
resource 블록의 구조
가장 단순한 형태부터 보자.
resource "<TYPE>" "<NAME>" {
# 인자들
}
TYPE은 프로바이더가 정한 리소스 타입이다. aws_instance, aws_s3_bucket, kubernetes_deployment처럼 접두사로 어느 프로바이더인지 드러나게 네이밍한다. NAME은 내가 붙이는 논리적 이름이다. 한 모듈 안에서 유일해야 하고, 이 이름은 다른 리소스에서 참조할 때 쓰인다.
실제 예시.
resource "aws_s3_bucket" "logs" {
bucket = "myapp-logs-prod"
tags = {
Environment = "prod"
ManagedBy = "terraform"
}
}
resource "aws_s3_bucket_versioning" "logs" {
bucket = aws_s3_bucket.logs.id
versioning_configuration {
status = "Enabled"
}
}
같은 NAME(logs)이지만 TYPE이 다르니 충돌하지 않는다. 다른 리소스에서 참조할 때는 <TYPE>.<NAME>.<ATTRIBUTE> 형태를 쓴다. aws_s3_bucket.logs.id가 그 예다.
리소스 속성의 세 가지
각 리소스는 세 종류의 속성을 가진다. 이 구분을 알면 문서를 읽을 때 덜 헷갈린다.
- 인자(Arguments): 사용자가 정하는 값. 예:
bucket,tags - 속성(Attributes): 생성 후 AWS가 정해주는 값. 예:
id,arn - 메타 인자(Meta-arguments): 모든 리소스에 공통으로 쓰이는 특수 인자. 예:
count,for_each,depends_on,lifecycle,provider
인자는 “내가 넣는다”, 속성은 “결과로 받는다”, 메타 인자는 “Terraform이 해석한다”로 기억하면 된다.
리소스 참조와 암묵적 의존성
Terraform의 강력한 특징 하나는 리소스 간 의존성을 자동으로 파악한다는 점이다. 의존성을 직접 적지 않아도, 리소스 A의 인자에 리소스 B의 속성을 참조하면 Terraform은 “A는 B 다음에 만들어야 한다”고 추론한다. 이걸 암묵적 의존성(implicit dependency)이라고 한다.
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
}
resource "aws_subnet" "public" {
vpc_id = aws_vpc.main.id # VPC를 참조
cidr_block = "10.0.1.0/24"
}
resource "aws_security_group" "web" {
vpc_id = aws_vpc.main.id # VPC를 참조
}
resource "aws_instance" "web" {
ami = data.aws_ami.amazon_linux.id
instance_type = "t3.micro"
subnet_id = aws_subnet.public.id # 서브넷 참조
vpc_security_group_ids = [aws_security_group.web.id] # SG 참조
}
이 코드만으로 Terraform은 의존성 그래프를 이렇게 그린다.
flowchart TB
VPC[aws_vpc.main] --> SUBNET[aws_subnet.public]
VPC --> SG[aws_security_group.web]
SUBNET --> EC2[aws_instance.web]
SG --> EC2
DATA[data.aws_ami.amazon_linux] --> EC2
실행 순서는 그래프의 위상 정렬대로다. VPC가 먼저 생기고, 서브넷과 보안 그룹이 병렬로 생성되고, 마지막에 EC2가 생긴다. 사용자는 순서를 지시하지 않았는데, Terraform이 알아서 풀어낸다. 이게 선언형의 가장 큰 이점이다.
그래프는 terraform graph 명령으로 시각화할 수도 있다.
terraform graph | dot -Tpng > graph.png
대규모 프로젝트에서 리소스 관계가 얽히면 이 명령이 디버깅에 큰 도움이 된다.
명시적 의존성 — depends_on
대부분의 경우 암묵적 의존성으로 충분하다. 하지만 “직접 참조는 없는데 순서가 중요한” 경우가 있다. 대표적으로 IAM 정책이 먼저 연결되어야 EC2가 S3에 접근할 수 있는 상황이다.
resource "aws_iam_role" "app" {
name = "app-role"
# ...
}
resource "aws_iam_role_policy_attachment" "s3_read" {
role = aws_iam_role.app.name
policy_arn = "arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess"
}
resource "aws_instance" "app" {
ami = data.aws_ami.amazon_linux.id
instance_type = "t3.micro"
iam_instance_profile = aws_iam_instance_profile.app.name
# EC2는 IAM Role에 대한 참조만 있음
# 하지만 실제로는 정책이 먼저 붙어야 동작한다
depends_on = [
aws_iam_role_policy_attachment.s3_read
]
}
depends_on은 “이 리소스는 저것들 다음에 만들어라”고 명시적으로 지시한다. 값은 리소스 참조 리스트다(속성 참조가 아니라 리소스 자체를 가리킨다).
다만 depends_on은 꼭 필요할 때만 쓴다. 남발하면 그래프가 복잡해지고 plan 속도가 느려진다. 가능하면 암묵적 의존성으로 풀고, 정말 안 되는 경우에만 depends_on을 쓴다.
count — 리소스를 숫자만큼 복제
같은 리소스를 여러 개 만들고 싶을 때 제일 먼저 떠오르는 게 count다.
resource "aws_instance" "web" {
count = 3
ami = data.aws_ami.amazon_linux.id
instance_type = "t3.micro"
tags = {
Name = "web-${count.index}" # web-0, web-1, web-2
}
}
count.index로 현재 반복 번호를 참조한다. 생성되는 리소스들은 aws_instance.web[0], aws_instance.web[1]처럼 인덱스로 접근한다.
# 특정 인스턴스 참조
output "first_instance_ip" {
value = aws_instance.web[0].public_ip
}
# 전체를 리스트로 (splat)
output "all_ips" {
value = aws_instance.web[*].public_ip
}
count는 간단해서 쓰기 편한데, 약점이 하나 있다. 중간 요소를 지우면 뒤의 인덱스가 재정렬된다. 예를 들어 count = 3으로 만든 뒤, 중간에 있는 web[1]을 없애려고 코드를 조정하면, Terraform은 web[1]을 지우는 게 아니라 web[1]의 설정을 바꾸고 web[2]를 지운다. 사용자 입장에선 예기치 않은 변경이 일어난다.
이런 문제 때문에 “순서가 중요하지 않은 여러 리소스”에는 for_each가 더 어울린다.
for_each — 맵이나 셋을 순회
for_each는 count보다 표현력이 풍부하다. 맵이나 셋을 받아서 키별로 리소스를 만든다.
resource "aws_s3_bucket" "services" {
for_each = toset(["logs", "artifacts", "backups"])
bucket = "myapp-${each.key}-prod"
tags = {
Purpose = each.key
}
}
each.key는 현재 순회 중인 키, each.value는 값이다(셋이면 key == value). 생성되는 리소스는 키로 접근한다.
output "artifacts_bucket_arn" {
value = aws_s3_bucket.services["artifacts"].arn
}
맵을 쓰면 키별로 다른 설정을 줄 수 있다.
variable "services" {
default = {
logs = {
versioning = true
lifecycle = "log"
}
artifacts = {
versioning = true
lifecycle = "archive"
}
backups = {
versioning = false
lifecycle = "glacier"
}
}
}
resource "aws_s3_bucket" "services" {
for_each = var.services
bucket = "myapp-${each.key}-prod"
tags = {
Purpose = each.key
Lifecycle = each.value.lifecycle
}
}
resource "aws_s3_bucket_versioning" "services" {
for_each = { for k, v in var.services : k => v if v.versioning }
bucket = aws_s3_bucket.services[each.key].id
versioning_configuration {
status = "Enabled"
}
}
키가 변하지 않는 한, 순서가 바뀌어도 Terraform이 맞는 리소스를 맞는 자리에서 관리한다. “리소스 여러 개를 만들 때 기본은 for_each, 단순 반복만 할 땐 count”가 실무 원칙이다.
count vs for_each 한눈에
flowchart LR
subgraph COUNT[count = 3]
C0["인스턴스 0"]
C1["인스턴스 1"]
C2["인스턴스 2"]
end
subgraph FOREACH[for_each = toset...]
F1["인스턴스 logs"]
F2["인스턴스 artifacts"]
F3["인스턴스 backups"]
end
NUM[숫자 인덱스<br/>순서 중요] --> COUNT
KEY[문자열 키<br/>의미 있는 식별] --> FOREACH
일반적인 선택 기준은 이렇다.
- 여러 개가 정확히 같고, 순서나 키가 무의미 →
count - 각각에 의미 있는 이름이 있거나, 개별로 관리해야 함 →
for_each
팀에서 가이드라인을 정하고 일관되게 쓰는 편이 좋다. 같은 리소스를 어떤 건 count, 어떤 건 for_each로 쓰면 나중에 혼란스럽다.
lifecycle — 생성/변경/삭제 미세 제어
Terraform은 기본적으로 “지금 상태를 바라는 상태로 만든다”만 생각한다. 하지만 실무에선 “특정 리소스는 먼저 만든 뒤 지우게 해달라”, “이 리소스는 실수로 지워지면 안 된다”처럼 세밀한 요구가 있다. lifecycle 메타 블록이 이런 제어를 담당한다.
resource "aws_instance" "web" {
# ...
lifecycle {
create_before_destroy = true
prevent_destroy = false
ignore_changes = [tags["LastDeployed"]]
}
}
네 가지 플래그가 핵심이다.
create_before_destroy
기본 동작은 “교체가 필요하면 기존 리소스를 지우고 새로 만든다”다. 이러면 사이에 다운타임이 생긴다. create_before_destroy = true는 순서를 뒤집는다. 새 리소스를 먼저 만들고, 다른 리소스가 새 것을 참조하도록 바꾼 뒤, 옛 것을 지운다.
resource "aws_launch_template" "web" {
name_prefix = "web-" # 이름 겹침 방지용 prefix
image_id = data.aws_ami.amazon_linux.id
instance_type = "t3.micro"
lifecycle {
create_before_destroy = true
}
}
특히 Launch Template처럼 “이름이 unique해야 하고, 교체 시 무중단이 중요한” 리소스에 유용하다. 주의할 점은 이름이 겹치면 실패한다는 것이다. 그래서 name_prefix를 써서 Terraform이 자동 생성한 접미사를 붙이게 한다.
sequenceDiagram
participant TF as Terraform
participant OLD as 기존 리소스
participant NEW as 새 리소스
Note over TF: 기본 (destroy before create)
TF->>OLD: 삭제
TF->>NEW: 생성
Note over NEW: 삭제와 생성 사이 공백 발생
Note over TF: create_before_destroy = true
TF->>NEW: 먼저 생성
TF->>OLD: 참조 교체 후 삭제
Note over NEW: 공백 없이 전환
prevent_destroy
“절대로 삭제되면 안 되는 리소스”에 거는 안전장치다. RDS의 운영 DB, S3 버킷(데이터가 중요), Route53 호스팅 존 등에 써둔다.
resource "aws_db_instance" "main" {
# ...
lifecycle {
prevent_destroy = true
}
}
이 플래그가 켜진 리소스를 Terraform이 삭제하려 하면 plan 단계에서 에러로 막힌다. 정말 지워야 하면 이 줄을 지우고 다시 apply해야 한다. 실수로 terraform destroy 돌렸을 때 지워지지 않게 막아주는 마지막 방어선이다.
ignore_changes
“이 속성은 Terraform이 관리하지 않는 걸로 쳐달라”고 선언하는 플래그다. 외부 시스템(오토스케일링, CI 배포)이 값을 바꾸고 있어서 Terraform이 매번 “변경 감지”로 잡는 경우에 쓴다.
resource "aws_autoscaling_group" "web" {
name = "web-asg"
desired_capacity = 3
max_size = 10
min_size = 2
# ...
lifecycle {
# 오토스케일링이 desired_capacity를 수시로 바꾸니 무시
ignore_changes = [desired_capacity]
}
}
resource "aws_instance" "web" {
# ...
tags = {
Name = "web"
LastDeploy = "2026-04-01" # CI가 배포 때마다 바꿈
}
lifecycle {
# LastDeploy 태그만 무시
ignore_changes = [tags["LastDeploy"]]
}
}
all을 쓰면 “이 리소스의 모든 속성 변경을 무시”하게 된다. 신중하게 써야 한다. 너무 넓게 걸면 Terraform이 상태를 관리한다는 보장이 약해진다.
replace_triggered_by
“다른 리소스가 바뀌면 이 리소스를 교체해줘”라는 의도를 표현한다(Terraform 1.2+).
resource "aws_instance" "web" {
# ...
lifecycle {
replace_triggered_by = [
aws_launch_template.web.latest_version
]
}
}
Launch Template의 새 버전이 나올 때마다 인스턴스를 자동으로 재생성하는 식이다. 이 기능이 없던 시절엔 “tainted” 개념으로 비슷한 걸 했지만, 지금은 이 선언이 훨씬 명확하다.
타임아웃 — 기다려줄 시간
기본 타임아웃이 부족한 경우가 있다. 큰 RDS는 생성에 30분 넘게 걸리기도 한다. 리소스별로 설정할 수 있다.
resource "aws_db_instance" "main" {
# ...
timeouts {
create = "60m"
update = "40m"
delete = "40m"
}
}
지원하는 액션은 리소스마다 다르다. 공식 문서의 각 리소스 페이지에 “Timeouts” 섹션이 있으니 확인해서 쓰면 된다.
전체 그림 — 리소스 하나에 들어가는 것들
지금까지 본 요소들을 하나의 리소스에 몰아넣으면 이런 모양이 된다.
resource "aws_instance" "web" {
# 기본 인자
ami = data.aws_ami.amazon_linux.id
instance_type = local.instance_type
subnet_id = aws_subnet.public.id
# 중첩 블록
root_block_device {
volume_size = 20
volume_type = "gp3"
encrypted = true
}
tags = merge(local.common_tags, {
Name = "web-${count.index}"
Role = "web"
})
# 메타 인자들
count = local.replicas
provider = aws.us_east_1 # 특정 프로바이더 alias 선택
depends_on = [aws_iam_role_policy_attachment.s3_read]
lifecycle {
create_before_destroy = true
ignore_changes = [tags["LastDeployed"]]
}
timeouts {
create = "10m"
}
}
거대해 보이지만 구성 원리는 단순하다. 기본 인자와 중첩 블록은 리소스의 내용이고, 메타 인자는 Terraform이 이 리소스를 어떻게 다룰지에 대한 지시다.
자주 하는 실수
depends_on을 리소스의 속성에 쓴다.depends_on은 리소스 블록의 메타 인자 자리에만 둔다. 일반 인자 안에서는 허용되지 않는다count와for_each를 같은 리소스에 둘 다 쓴다. 한 리소스는 둘 중 하나만 가능하다for_each에 리스트를 그대로 넣는다. 맵이나 셋이어야 한다. 리스트라면toset()으로 감싸거나, for 표현식으로 맵을 만든다prevent_destroy를 걸고 코드를 지우려 한다. 이 플래그를 먼저 풀지 않으면 “prevent destroy” 에러로 막힌다ignore_changes를all로 남용한다. 중요한 변경도 감지 못 하게 되어 상태 관리의 의미가 약해진다
다음으로
리소스까지 다뤘으니 Terraform 언어의 핵심은 거의 다 본 셈이다. 마지막 편에서는 기존 인프라와의 접점을 본다. data 블록으로 이미 존재하는 AWS 리소스(기존 VPC, AMI, 계정 정보 등)를 조회하는 방법, 그리고 콘솔로 만들어진 레거시 리소스를 terraform import로 Terraform 관리 하에 가져오는 과정을 다룬다.


Loading comments...