Desired State
.tf 파일에 선언한 원하는 인프라 상태. VPC CIDR, S3 버킷 이름, IAM Role 정책처럼 앞으로 유지하고 싶은 값을 적는다.
코드 리뷰에서 '인프라가 어떻게 되어야 하는가'를 검토할 때.분류: Layer 5 - 플랫폼 엔지니어링 & 자동화 | 선수지식: VPC/Subnet/SG, IAM
Terraform은 인프라를 HCL(HashiCorp Configuration Language) 코드로 선언하고, 코드의 원하는 상태와 실제 클라우드 상태를 비교해 안전한 변경 계획을 만든 뒤 적용하는 Infrastructure as Code(IaC) 도구이다.
여기서 IaC는 Infrastructure as Code, 즉 서버·네트워크·DB·권한 같은 인프라를 문서가 아니라 실행 가능한 코드로 관리한다는 뜻이다. 핵심은 “콘솔 클릭을 스크립트로 자동화한다”보다 한 단계 더 깊다. Terraform은 리소스 사이의 의존 관계를 그래프로 만들고, 실제 리소스와 코드 리소스의 매핑을 state에 저장해 다음 변경도 같은 기준으로 계산한다.
AWS 콘솔에서 VPC, Subnet, Security Group, RDS, S3를 직접 만들면 처음에는 빠르다. 문제는 두 번째 변경부터 시작된다. 누가 어떤 값을 바꿨는지 Git diff로 볼 수 없고, staging과 prod가 왜 다른지 설명하기 어렵고, 장애 대응 중 급하게 바꾼 설정이 코드에 남지 않는다.
AWS CLI 스크립트도 일부 문제를 줄인다. 그러나 스크립트는 보통 “무엇을 실행할지”를 적을 뿐, 이미 존재하는 리소스가 코드의 어느 블록과 대응되는지, 삭제와 교체 순서가 안전한지, 다른 사람이 동시에 변경 중인지까지 안정적으로 관리하지 못한다. 작은 dev 환경에서는 aws ec2 create-vpc 몇 줄로 충분해 보이지만, VPC -> Subnet -> Security Group -> EKS -> Add-on처럼 의존성이 얽히면 순서와 소유권이 사람의 기억으로 흩어진다.
Terraform은 이 한계를 세 가지로 푼다.
즉 Terraform의 본질은 인프라 변경을 “재현 가능한 그래프 계산 문제”로 바꾸는 데 있다.
수동 콘솔 작업의 가장 큰 실패 모드는 재현 불가능성이다. 예를 들어 운영자가 콘솔에서 prod-api-sg에 8080 포트를 임시로 열었다고 하자. 장애는 끝났지만 그 변경이 코드나 PR에 남지 않으면 다음 배포자가 현재 보안 그룹이 의도된 상태인지 임시 예외인지 알 수 없다. 새 계정에 같은 환경을 만들 때도 “콘솔에서 그때 뭘 눌렀는지”를 다시 추적해야 한다.
스크립트 방식은 재현성 일부를 회복하지만, 멱등성과 상태 추적이 약하다. 같은 create 명령을 두 번 실행하면 이미 존재한다는 에러가 나거나 중복 리소스를 만들 수 있다. 반대로 update 명령은 실제 값이 이미 다른 시스템에 의해 바뀐 경우 어떤 차이를 의도적으로 받아들일지 판단하지 못한다.
Terraform은 선언형 모델을 쓴다. 사용자는 “VPC가 이 CIDR을 가져야 한다”, “S3 버킷 이름은 이 규칙으로 정한다”, “RDS는 prod에서 삭제되면 안 된다”를 적는다. Terraform은 현재 상태를 읽고, 필요한 생성·수정·삭제만 계산한다. 이 계산 결과가 plan이고, 팀은 plan을 코드 리뷰처럼 검토한 뒤 apply한다.
IaC(Infrastructure as Code)는 인프라의 원하는 상태를 코드로 관리하는 방식이다. 문서에 “prod DB는 Multi-AZ여야 한다”고 적는 대신, 실제 배포 도구가 읽을 수 있는 코드로 그 조건을 표현한다.
HCL(HashiCorp Configuration Language)은 Terraform이 주로 쓰는 설정 언어다. JSON보다 사람이 읽기 쉽고, “어떤 순서로 실행하라”보다 “어떤 리소스가 어떤 속성을 가져야 한다”에 집중한다.
resource는 Terraform이 직접 생성·수정·삭제하는 관리 대상이다. 예를 들어 aws_vpc.main은 Terraform 주소이고, 실제 AWS의 vpc-0a1b2c3d 같은 ID와 state에서 연결된다.
resource "aws_vpc" "main" { cidr_block = "10.0.0.0/16"
tags = { Name = "platform-main" Environment = "prod" }}
resource "aws_s3_bucket" "logs" { bucket = "myteam-prod-logs-123456789012-ap-northeast-2"}위 코드에서 aws_vpc와 aws_s3_bucket은 리소스 타입이다. "main"과 "logs"는 코드 안에서 쓰는 이름이다. 둘을 합친 aws_vpc.main, aws_s3_bucket.logs가 Terraform 주소가 된다.
provider는 Terraform Core와 외부 API 사이의 어댑터다. AWS provider는 AWS API를 알고, GitHub provider는 GitHub API를 알고, Kubernetes provider는 Kubernetes API를 안다. Terraform Core는 state, plan, graph 계산을 담당하고, provider는 실제 플랫폼별 create/read/update/delete 호출을 담당한다.
terraform { required_providers { aws = { source = "hashicorp/aws" version = "~> 5.0" } }}
provider "aws" { region = "ap-northeast-2"}terraform init은 provider 바이너리를 다운로드하고 .terraform.lock.hcl에 선택된 provider 버전을 기록한다. 이 lock 파일을 Git에 커밋해야 팀원이 같은 provider 버전으로 plan을 계산할 수 있다.
처음 Terraform 코드를 읽을 때는 variable, locals, output의 경계를 먼저 잡는다.
variable "environment" { type = string description = "배포 환경: dev, staging, prod" default = "dev"}
locals { name_prefix = "${var.environment}-api" common_tags = { Environment = var.environment ManagedBy = "terraform" }}
resource "aws_s3_bucket" "logs" { bucket = "${local.name_prefix}-logs-123456789012" tags = local.common_tags}
output "logs_bucket_name" { value = aws_s3_bucket.logs.bucket description = "로그 버킷 이름"}variable은 외부 입력이다. .tfvars, CI 변수, -var 인자로 주입된다. locals는 파일 내부에서 반복을 줄이는 계산 값이다. output은 apply 후 외부에 알려줄 값이며, 다른 모듈이나 CI 로그에서 참조할 수 있다.
data source는 Terraform이 만들지 않는 기존 리소스를 읽는 블록이다. resource가 “새 부품을 주문한다”라면, data는 “이미 창고에 있는 부품의 ID를 조회한다”에 가깝다.
data "aws_caller_identity" "current" {}data "aws_region" "current" {}
resource "aws_s3_bucket" "logs" { bucket = "myteam-prod-logs-${data.aws_caller_identity.current.account_id}-${data.aws_region.current.name}"}이 예시는 S3 버킷 이름에 AWS 계정 ID와 리전을 넣는다. S3 버킷 이름은 전 세계에서 유일해야 하므로 my-app-logs처럼 흔한 이름은 실패할 수 있다. 계정 ID와 리전을 붙이면 충돌 가능성을 크게 줄이고, 어떤 계정의 어떤 리전용 버킷인지 이름만 봐도 알 수 있다.
Terraform은 세 가지 축을 함께 이해해야 한다.
.tf 파일에 선언한 원하는 인프라 상태. VPC CIDR, S3 버킷 이름, IAM Role 정책처럼 앞으로 유지하고 싶은 값을 적는다.
코드 리뷰에서 '인프라가 어떻게 되어야 하는가'를 검토할 때.Terraform 주소와 실제 리소스 ID의 매핑. 코드의 aws_s3_bucket.logs가 실제 어떤 버킷인지 기억한다.
기존 리소스를 수정할지 새로 만들지 판단해야 할 때.리소스 의존성을 나타내는 DAG. VPC가 있어야 Subnet을 만들 수 있고, Subnet이 있어야 EKS Node Group을 붙일 수 있다.
생성·수정·삭제 순서를 안전하게 계산해야 할 때.Desired State, State, 실제 API 응답을 비교해 나온 변경 제안서. apply 전에 사람이 검토해야 한다.
생성·수정·삭제가 의도한 범위인지 확인할 때.이 구조는 Kubernetes나 GitOps의 reconciliation과 닮았지만 완전히 같지는 않다. Kubernetes controller는 루프를 돌며 계속 실제 상태를 원하는 상태에 맞춘다. Terraform은 보통 사람이 plan과 apply를 실행할 때만 비교하고 수렴한다. 따라서 Terraform은 “상시 자동 복구 도구”가 아니라 “리뷰 가능한 인프라 변경 계산기”로 이해하는 편이 안전하다.
flowchart TD
Code[".tf: 원하는 상태"] --> Plan["terraform plan"]
State["state: 코드 주소와 실제 리소스 매핑"] --> Plan
Cloud["Cloud API: 실제 현재 상태"] --> Plan
Plan --> Review{"사람/CI 리뷰"}
Review -->|승인| Apply["terraform apply"]
Review -->|거부| Edit["코드 수정"]
Edit --> Plan
Apply --> Cloud
Apply --> State terraform init은 작업 디렉터리를 초기화한다. provider를 다운로드하고, backend 설정이 있으면 state를 어디에 저장할지 준비한다. 이 단계는 인프라를 만들지 않는다.
초보자가 자주 오해하는 지점은 init이 “배포”가 아니라는 점이다. init은 Terraform이 plan을 계산할 수 있게 도구와 저장소를 맞추는 단계다.
terraform plan은 실제 변경 전에 결과를 보여준다. plan은 “읽기만 한다”고 단순화할 수 있지만, 내부적으로는 provider API를 호출해 현재 상태를 refresh한다. 대규모 인프라에서는 이 refresh가 느릴 수 있고 API rate limit의 영향을 받을 수 있다.
Terraform will perform the following actions:
# aws_s3_bucket.logs will be created + resource "aws_s3_bucket" "logs" { + bucket = "myteam-prod-logs-123456789012-ap-northeast-2" + id = (known after apply) }
# aws_vpc.main will be updated in-place ~ resource "aws_vpc" "main" { id = "vpc-0a1b2c3d" ~ tags = { ~ "Name" = "old-vpc" -> "platform-main" } }
Plan: 1 to add, 1 to change, 0 to destroy.읽는 순서는 아래가 좋다.
add/change/destroy 숫자를 본다.- destroy 또는 -/+ replace가 있는지 먼저 찾는다.known after apply는 지금은 provider가 알 수 없고 생성 후 알 수 있는 값임을 이해한다.+는 생성, ~는 수정, -는 삭제다. -/+는 삭제 후 재생성(replace)을 뜻한다. replace는 이름만 바꾸는 작은 변경처럼 보여도 실제로는 downtime이나 데이터 손실을 만들 수 있다.
terraform apply는 plan을 실제 API 호출로 실행한다. 프로덕션에서는 즉석 apply보다 plan 파일을 저장하고 승인 후 같은 파일을 적용하는 흐름이 안전하다.
terraform plan -out=tfplanterraform apply tfplan이 방식은 “내가 리뷰한 plan”과 “실제로 적용한 변경”을 일치시키는 데 도움이 된다. plan 이후 누군가 state를 바꾸면 Plan is stale 같은 신호가 나며 다시 plan해야 한다.
terraform destroy는 state에 들어 있는 리소스를 삭제 대상으로 만든다. 개발 실습에서는 편하지만, 팀 환경에서는 가장 위험한 명령 중 하나다. destroy를 안전하게 다루려면 환경을 디렉터리와 backend로 분리하고, 중요한 리소스에는 prevent_destroy를 걸고, CI에서 별도 승인 단계를 둔다.
plan은 길게 나오기 때문에 처음부터 모든 줄을 같은 밀도로 읽으면 놓치는 것이 많다. 아래처럼 한 리소스는 태그 수정이고, 다른 리소스는 교체라면 위험도는 완전히 다르다.
# aws_instance.web must be replaced-/+ resource "aws_instance" "web" { ~ ami = "ami-aaa111" -> "ami-bbb222" # forces replacement instance_type = "t3.micro" }
# aws_s3_bucket.logs will be updated in-place ~ resource "aws_s3_bucket" "logs" { ~ tags = { ~ "Owner" = "unknown" -> "platform" } }
Plan: 1 to add, 1 to change, 1 to destroy.이 plan에서 핵심은 마지막 줄의 1 to destroy다. S3 태그 수정은 in-place 변경이지만, EC2 AMI 변경은 삭제 후 재생성이다. EC2가 오토스케일링 그룹 뒤의 일회성 인스턴스라면 괜찮을 수 있지만, 고정 IP나 로컬 디스크에 의존하는 서버라면 장애가 된다.
리뷰 질문은 네 가지로 충분하다.
처음 Terraform을 배울 때는 Plan: 3 to add, 0 to change, 0 to destroy 같은 숫자보다 destroy와 replace를 먼저 찾는 습관이 더 중요하다. 생성은 실패해도 대개 되돌리기 쉽지만, 삭제와 교체는 데이터·트래픽·권한 손실로 이어질 수 있다.
State는 단순 백업 파일이 아니다. 코드의 Terraform 주소와 실제 리소스 ID를 연결하는 매핑 테이블이다.
{ "version": 4, "resources": [ { "type": "aws_s3_bucket", "name": "logs", "instances": [ { "attributes": { "bucket": "myteam-prod-logs-123456789012-ap-northeast-2", "id": "myteam-prod-logs-123456789012-ap-northeast-2", "region": "ap-northeast-2" } } ] } ]}이 매핑이 없으면 Terraform은 aws_s3_bucket.logs가 이미 존재하는 어떤 버킷을 가리키는지 알 수 없다. 그래서 기존 버킷을 새로 만들려 하거나, 코드에서 삭제된 리소스를 실제로 삭제해야 한다고 판단할 수 있다.
State가 맡는 역할은 네 가지다.
Terraform 주소와 실제 클라우드 리소스 ID를 연결한다. 수정과 신규 생성을 구분하는 기준이다.
aws_s3_bucket.logs가 실제 어떤 버킷인지 알아야 할 때.대규모 인프라에서 매번 모든 속성을 API로 조회하지 않도록 이전 속성을 보관한다.
리소스 수가 많아 refresh 시간이 길어질 때.코드에서 사라진 리소스도 안전한 삭제 순서를 계산할 수 있게 관계 정보를 남긴다.
Subnet을 지우기 전에 연결된 리소스를 먼저 정리해야 할 때.민감 값이 들어갈 수 있으므로 Git 커밋 금지, 암호화, 접근 제어가 필요하다.
DB 비밀번호나 토큰이 output 또는 provider 응답에 포함될 수 있을 때.혼자 실습할 때는 terraform.tfstate가 로컬에 생겨도 된다. 팀 작업에서는 Remote Backend가 필요하다. Remote Backend는 state를 S3, Terraform Cloud 같은 공유 저장소에 두고 팀원이 같은 기준으로 plan하도록 만든다.
terraform { backend "s3" { bucket = "myteam-terraform-state" key = "prod/network/terraform.tfstate" region = "ap-northeast-2" encrypt = true use_lockfile = true }}핵심은 bucket보다 key다. key는 state 파일의 경로이므로 환경과 도메인이 분리되어야 한다. 예를 들어 dev/network/terraform.tfstate와 prod/network/terraform.tfstate를 나누면 dev 변경이 prod state를 건드리지 않는다.
일부 기존 구성은 S3와 DynamoDB lock 테이블을 함께 쓴다. 다른 구성은 S3 backend의 lockfile 방식을 쓴다. 어떤 방식을 쓰든 학습의 핵심은 같다. state는 공유 저장소에 두고, 동시에 두 apply가 state를 덮어쓰지 못하게 lock을 건다.
두 사람이 동시에 apply하면 둘 다 같은 이전 state를 기준으로 변경을 계산할 수 있다. A가 Subnet을 만들고 state를 갱신하는 동안 B가 오래된 state로 Security Group을 바꾸면, 마지막에 저장된 state가 실제 리소스와 어긋날 수 있다. Lock은 이 상황을 막는다.
Error: Error acquiring the state lock
Lock Info: ID: a1b2c3d4 Operation: OperationTypeApply Who: deploy-bot이 에러는 보통 나쁜 소식이 아니라 보호 장치가 작동했다는 신호다. 먼저 누가 apply 중인지 확인한다. 정말 아무도 실행 중이 아니고 이전 작업이 끊긴 것이 확실할 때만 force-unlock을 고려한다. 실행 중인 apply가 있는데 강제로 lock을 풀면 state가 꼬일 수 있다.
Terraform에서 sensitive = true는 CLI 출력 마스킹을 도와줄 뿐, state에 값을 절대 저장하지 않는다는 뜻이 아니다. provider가 비밀번호나 토큰을 state 속성에 남길 수 있다. 따라서 state는 Git에 커밋하지 않고, remote backend 암호화와 접근 제어를 적용해야 한다.
실패 신호는 명확하다. 누군가 PR에 terraform.tfstate, *.tfplan, crash.log 같은 파일을 올렸다면 즉시 멈추고 민감 정보 노출 여부를 확인해야 한다. .gitignore에 *.tfstate*를 추가하는 것은 최소 방어선이고, 실제 보안은 backend 권한과 암호화에서 결정된다.
작은 예제에서는 state가 13개 리소스만 담는다. 팀 인프라에서는 한 환경의 state가 수십수백 리소스를 담을 수 있다. 리소스가 200개이고 각 리소스 refresh에 평균 200ms가 걸린다고 단순 계산하면, 순차 조회만으로 40초가 필요하다. 실제 Terraform은 병렬 처리와 provider 최적화를 쓰지만, API rate limit과 느린 리소스 때문에 plan 시간이 길어질 수 있다.
그래서 state를 하나로 너무 크게 묶으면 두 가지 문제가 생긴다.
반대로 state를 너무 잘게 쪼개면 output 연결과 실행 순서 관리가 어려워진다. 예를 들어 VPC, EKS, RDS, 앱 인프라를 모두 별도 state로 나누면 각 state의 output을 어떻게 전달할지 정해야 한다. terraform_remote_state, Terragrunt dependency, CI 변수 중 하나를 쓰게 되는데, 이 연결도 운영 복잡도다.
첫 분리 기준은 변경 주기와 소유권이다.
| State 후보 | 분리하기 좋은 신호 | 같이 두면 생기는 문제 |
|---|---|---|
| 네트워크(VPC/Subnet/Route) | 변경이 드물고 여러 서비스가 공유 | 앱 변경마다 네트워크 plan까지 커짐 |
| 데이터(RDS/S3) | 삭제 위험이 크고 승인 흐름이 다름 | 앱 배포 실수가 데이터 리소스 변경으로 번짐 |
| 클러스터(EKS) | 업그레이드 주기와 권한이 별도 | 앱 리소스 변경과 클러스터 변경이 섞임 |
| 서비스별 리소스(SQS/IAM/ECR) | 서비스 팀이 자주 바꿈 | 플랫폼 팀 lock과 충돌 |
State 분리는 성능 최적화이기 전에 책임 경계 설계다.
Terraform은 HCL 파일의 작성 순서대로 리소스를 만들지 않는다. 리소스 참조를 읽어 DAG(Directed Acyclic Graph, 방향성 비순환 그래프)를 만든다.
resource "aws_vpc" "main" { cidr_block = "10.0.0.0/16"}
resource "aws_subnet" "private_a" { vpc_id = aws_vpc.main.id cidr_block = "10.0.1.0/24" availability_zone = "ap-northeast-2a"}
resource "aws_security_group" "api" { name = "api-sg" vpc_id = aws_vpc.main.id}aws_subnet.private_a와 aws_security_group.api는 둘 다 aws_vpc.main.id를 참조한다. Terraform은 VPC를 먼저 만들고, 그 뒤에 Subnet과 Security Group을 병렬로 처리할 수 있다고 판단한다. 기본 병렬 처리 수가 크지 않아도, 리소스가 수백 개로 늘면 이 그래프 계산이 실행 시간과 안전성에 큰 영향을 준다.
flowchart LR VPC["aws_vpc.main"] --> Subnet["aws_subnet.private_a"] VPC --> SG["aws_security_group.api"] Subnet --> EKS["aws_eks_node_group.default"] SG --> EKS
대부분의 의존성은 참조로 생긴다. 이것을 implicit dependency라고 부른다. vpc_id = aws_vpc.main.id가 대표 예다.
depends_on은 Terraform이 코드 참조만으로 알 수 없는 순서를 명시할 때 쓴다. 예를 들어 IAM policy attachment가 끝난 뒤에만 어떤 리소스를 만들고 싶지만 직접 속성 참조가 없을 수 있다. 그러나 depends_on을 남발하면 그래프 병렬성이 줄고, 진짜 의존성이 코드에서 보이지 않게 된다.
첫 기준은 단순하다. 값이 필요해서 참조할 수 있으면 참조로 의존성을 만들고, 순서만 필요하고 값 참조가 불가능할 때만 depends_on을 쓴다.
DAG에는 cycle이 있으면 안 된다. A가 B를 먼저 필요로 하고 B가 다시 A를 먼저 필요로 하면 시작점이 없다.
# 피해야 하는 예: 두 Security Group이 서로를 inline rule에서 참조한다.resource "aws_security_group" "api" { ingress { security_groups = [aws_security_group.db.id] }}
resource "aws_security_group" "db" { ingress { security_groups = [aws_security_group.api.id] }}이 구조는 Error: Cycle로 막힐 수 있다. 해결은 그룹 자체와 규칙을 분리하는 것이다.
resource "aws_security_group" "api" {}resource "aws_security_group" "db" {}
resource "aws_security_group_rule" "api_to_db" { type = "ingress" security_group_id = aws_security_group.db.id source_security_group_id = aws_security_group.api.id from_port = 5432 to_port = 5432 protocol = "tcp"}핵심은 “먼저 빈 SG를 만들고, 그 다음 rule 리소스로 연결한다”이다. Terraform이 cycle을 거부할 때는 명령어로 우회하려 하지 말고 리소스 모델을 cycle 없는 형태로 바꿔야 한다.
Module은 여러 resource를 묶은 재사용 패키지다. VPC, RDS, EKS처럼 팀이 반복해서 만드는 인프라 패턴을 모듈로 만들면, 새 환경을 만들 때 같은 구조와 태그, 보안 기본값을 재사용할 수 있다.
terraform/├── modules/│ ├── vpc/│ │ ├── main.tf│ │ ├── variables.tf│ │ └── outputs.tf│ └── rds/│ ├── main.tf│ ├── variables.tf│ └── outputs.tf└── environments/ ├── dev/ │ ├── main.tf │ └── backend.tf └── prod/ ├── main.tf └── backend.tf모듈의 학습 모델은 함수와 비슷하다. variables.tf는 인자, main.tf는 구현, outputs.tf는 반환값이다. 그러나 함수와 달리 모듈은 실제 외부 리소스를 만들고 state에 기록한다. 그래서 모듈 버전 변경은 라이브러리 업그레이드이면서 인프라 변경이다.
module "vpc" { source = "../../modules/vpc"
name = "prod-main" cidr = "10.0.0.0/16" azs = ["ap-northeast-2a", "ap-northeast-2c"]}
module "rds" { source = "../../modules/rds"
name = "prod-api" subnet_ids = module.vpc.private_subnet_ids vpc_id = module.vpc.vpc_id}module.rds가 module.vpc의 output을 참조하면 Terraform은 모듈 사이에도 의존성을 만든다. 이때 output 이름이 모듈 API가 된다. 팀 모듈을 설계할 때는 “무엇을 input으로 열고, 무엇을 output으로 노출할지”가 표준화 품질을 결정한다.
모듈은 반복을 줄이지만 추상화 비용도 만든다. 처음 한 번만 쓰는 리소스까지 모듈로 감싸면 실제 속성이 어디 있는지 찾기 어렵다. 반대로 세 팀이 모두 VPC를 만들고 태그, Flow Logs, NAT Gateway 선택을 매번 다르게 한다면 모듈화할 가치가 크다.
| 상황 | 판단 |
|---|---|
| 같은 패턴이 3번 이상 반복된다 | 모듈 후보 |
| 보안 기본값을 강제하고 싶다 | 모듈 후보 |
| 환경마다 리소스 구조가 크게 다르다 | 모듈 입력을 늘리기보다 디렉터리 분리 검토 |
| 한 번만 만들고 자주 바뀌지 않는다 | 루트 모듈에 직접 두는 편이 단순 |
| 모듈 옵션이 30개를 넘어 읽기 어렵다 | 모듈을 더 작은 책임으로 나눌 신호 |
나쁜 모듈은 모든 provider 속성을 input으로 열어 둔다.
module "rds" { source = "../../modules/rds"
allocated_storage = 100 max_allocated_storage = 1000 backup_retention_period = 7 deletion_protection = true publicly_accessible = false multi_az = true performance_insights = true monitoring_interval = 60 skip_final_snapshot = false apply_immediately = false}이 모듈은 겉보기에는 유연하지만, 사용자는 RDS 전문가가 되어야 한다. 팀 표준을 제공한다기보다 provider 문서를 한 번 더 감싼 형태가 된다.
좋은 모듈은 위험한 선택지를 기본값으로 닫고, 제품 팀이 실제로 결정해야 하는 값만 열어 둔다.
module "rds" { source = "../../modules/rds"
service_name = "payment" environment = "prod" size_class = "standard"}이 경우 prod이면 deletion_protection = true, multi_az = true, 백업 보존 기간 7일 이상 같은 규칙을 모듈 내부에서 강제할 수 있다. 모듈의 목적은 타이핑을 줄이는 것이 아니라, 좋은 기본값과 위험한 선택지의 경계를 팀 표준으로 만드는 것이다.
Terraform에서 환경을 나누는 대표 방식은 workspace와 디렉터리 분리다.
Workspace는 같은 루트 모듈을 여러 state로 실행하는 기능이다. dev, staging, prod workspace를 만들면 코드 파일은 같고 state만 나뉜다.
locals { env_config = { dev = { instance_type = "t3.small", min_size = 1 } prod = { instance_type = "t3.large", min_size = 3 } }
config = local.env_config[terraform.workspace]}이 방식은 환경 차이가 작은 경우에만 적합하다. prod만 별도 AWS 계정, 별도 IAM Role, Multi-AZ RDS, 수동 승인 게이트를 쓴다면 workspace는 위험해진다. terraform workspace select prod를 잘못 선택한 상태에서 destroy를 실행하는 사고도 가능하다.
디렉터리 분리는 환경마다 루트 모듈과 backend를 명시적으로 나누는 방식이다.
environments/├── dev/│ ├── backend.tf # key = "dev/api/terraform.tfstate"│ ├── main.tf│ └── terraform.tfvars└── prod/ ├── backend.tf # key = "prod/api/terraform.tfstate" ├── main.tf └── terraform.tfvars| 기준 | Workspace가 맞는 경우 | 디렉터리 분리가 맞는 경우 |
|---|---|---|
| 환경 차이 | 크기와 태그 정도만 다름 | 리소스 구조, 계정, 권한이 다름 |
| 팀 규모 | 소수 인원이 같은 코드 관리 | 여러 팀이 환경별 책임을 나눔 |
| 승인 흐름 | 거의 동일 | prod만 별도 승인/감사 필요 |
| 사고 격리 | workspace 선택 실수에 취약 | 경로와 backend가 명시적 |
| 장기 운영 | 간단한 반복 배포 | 프로덕션 표준에 더 적합 |
첫 학습 기준은 이렇게 잡으면 된다. “같은 인프라를 크기만 다르게 여러 번 만든다”면 workspace를 고려한다. “prod는 다른 계정·권한·구조를 가진다”면 디렉터리 분리를 기본값으로 둔다.
Drift는 .tf 코드와 실제 클라우드 리소스 상태가 달라진 상태다. 누군가 콘솔에서 보안 그룹 포트를 열거나, AWS 서비스가 자동으로 태그를 붙이거나, 긴급 대응 중 수동으로 인스턴스 크기를 바꾸면 drift가 생긴다.
$ terraform plan -refresh-only
# aws_security_group.api has changed ~ resource "aws_security_group" "api" { ~ ingress = [ + { + from_port = 8080 + to_port = 8080 + protocol = "tcp" }, ] }-refresh-only는 실제 인프라를 코드에 맞추는 것이 아니라, state를 실제 상태에 맞게 갱신하는 계획을 보여준다. 이 결과를 보고 “수동 변경을 코드로 받아들일지”, “수동 변경을 되돌릴지”를 결정한다.
드리프트 대응의 순서는 아래가 좋다.
.tf 코드에 반영하고 plan한다.Drift를 발견했다고 항상 즉시 apply해서 되돌리는 것은 아니다. 수동 변경이 장애 대응을 위해 필요한 임시 완화였을 수 있기 때문이다.
| 상황 | 처리 |
|---|---|
| 긴급 대응으로 포트를 임시 오픈했고 아직 트래픽 우회 중 | 바로 되돌리지 말고 owner와 만료 시각을 확인 |
| AWS가 자동 태그를 붙였고 운영상 의미가 없다 | 좁은 ignore_changes 또는 태그 정책 조정 검토 |
| 콘솔에서 DB deletion protection이 꺼졌다 | 즉시 위험 변경으로 보고 코드/콘솔 상태를 복구 |
| 다른 IaC 도구가 같은 리소스를 바꾸고 있다 | 소유자를 하나로 정하고 한쪽 관리를 중단 |
| drift가 plan마다 반복된다 | Terraform 모델이 현실의 controller와 충돌하는지 확인 |
Drift 대응의 목표는 “plan을 깨끗하게 만드는 것”이 아니다. 코드와 실제 운영 의도가 다시 일치하도록 소유권과 변경 경로를 정리하는 것이다.
처음부터 모든 인프라가 Terraform으로 만들어지는 경우는 드물다. 이미 콘솔에서 만든 S3 버킷, VPC, IAM Role을 Terraform으로 가져와야 할 때가 있다. 이때 필요한 개념이 import다.
Import는 실제 리소스를 새로 만들지 않는다. 기존 리소스 ID를 Terraform 주소에 연결해 state에 등록한다.
import { to = aws_s3_bucket.legacy_logs id = "my-existing-logs-bucket"}
resource "aws_s3_bucket" "legacy_logs" { bucket = "my-existing-logs-bucket"}이 방식은 import 자체도 코드 리뷰 대상이 된다. plan에서 “1 to import, 0 to add, 0 to change, 0 to destroy”를 확인한 뒤 apply할 수 있다.
Plan: 1 to import, 0 to add, 0 to change, 0 to destroy.
# aws_s3_bucket.legacy_logs will be imported resource "aws_s3_bucket" "legacy_logs" { bucket = "my-existing-logs-bucket" }이 출력이 중요한 이유는 Terraform이 새 버킷을 만들려는 것이 아니라, 기존 버킷을 aws_s3_bucket.legacy_logs 주소에 연결하려 한다는 점을 리뷰에서 확인할 수 있기 때문이다.
둘 다 기존 리소스를 다루지만 목적이 다르다.
| 개념 | 하는 일 | 이후 소유권 |
|---|---|---|
data source | 기존 리소스 정보를 읽는다 | Terraform이 소유하지 않음 |
import | 기존 리소스를 state에 등록한다 | Terraform이 소유함 |
terraform state rm | state에서 연결을 끊는다 | 실제 리소스는 남음 |
예를 들어 회사 공통 VPC를 네트워크 팀이 관리하고, 애플리케이션 팀은 그 VPC ID만 필요하다면 data source가 맞다. 반대로 우리 팀이 앞으로 그 S3 버킷 정책과 lifecycle을 Terraform으로 관리할 것이라면 import가 맞다.
ResourceAlreadyExists는 보통 “AWS에 리소스가 있는데 state에는 없다”는 뜻이다. 해결은 무작정 이름을 바꾸는 것이 아니다. 그 리소스를 Terraform이 앞으로 소유해야 하는지 먼저 결정해야 한다.
이 판단 없이 import하면 소유권이 꼬인다. 하나의 리소스는 가능한 한 하나의 컨트롤 플레인만 관리해야 한다.
Terraform 코드 리팩터링 중 리소스 이름을 바꾸고 싶을 때가 있다.
# 기존resource "aws_s3_bucket" "logs" { bucket = "myteam-prod-logs-123456789012"}
# 변경 후resource "aws_s3_bucket" "access_logs" { bucket = "myteam-prod-logs-123456789012"}이렇게 이름만 바꿔도 Terraform 주소는 aws_s3_bucket.logs에서 aws_s3_bucket.access_logs로 달라진다. state 매핑을 알려주지 않으면 Terraform은 예전 리소스를 삭제하고 새 리소스를 만들려는 plan을 낼 수 있다.
이때는 moved block으로 주소 변경을 선언한다.
moved { from = aws_s3_bucket.logs to = aws_s3_bucket.access_logs}moved block은 실제 인프라 변경이 아니라 Terraform 주소 변경을 기록한다. 리팩터링 PR에서 plan에 destroy/create가 사라지고 move로 표시되는지 확인하는 것이 핵심이다. 이름 정리 같은 코드 개선이 실제 리소스 삭제로 번지지 않게 하는 안전장치다.
Terraform은 plan이 계산한 대로 리소스를 만들고 바꾸고 지운다. 그런데 모든 변경을 그대로 허용하면 위험한 리소스가 있다. lifecycle은 이런 리소스의 변경 규칙을 코드에 붙이는 장치다.
resource "aws_db_instance" "prod" { identifier = "prod-api-db" engine = "postgres" instance_class = "db.t3.medium" multi_az = var.multi_az
lifecycle { prevent_destroy = true
ignore_changes = [ tags["LastModifiedBy"], ]
precondition { condition = var.environment != "prod" || var.multi_az == true error_message = "prod RDS는 Multi-AZ여야 합니다." } }}prevent_destroy는 RDS, S3처럼 데이터가 있는 리소스의 실수 삭제를 막는다. ignore_changes는 다른 시스템이 수정하는 속성을 Terraform이 계속 되돌리는 충돌을 줄인다. precondition은 “prod에서는 Multi-AZ여야 한다” 같은 팀 규칙을 plan 단계에서 검증한다.
ignore_changes는 편리하지만 남용하면 drift를 숨긴다. 예를 들어 보안 그룹 ingress 전체를 ignore하면 누군가 0.0.0.0/0으로 DB 포트를 열어도 Terraform plan이 조용할 수 있다.
좋은 기준은 좁게 무시하는 것이다. 외부 시스템이 자동으로 바꾸는 태그 하나, 오토스케일러가 조정하는 replica 수처럼 변경 주체가 명확한 속성만 ignore한다. 보안·네트워크·삭제 정책처럼 사고 범위가 큰 속성은 ignore 대상이 아니다.
prevent_destroy가 있다고 destroy가 완전히 안전해지는 것은 아니다. 이 설정은 해당 리소스의 삭제를 막지만, 다른 리소스는 삭제될 수 있다. 또한 리소스를 코드에서 완전히 제거하면 Terraform은 해당 리소스 설정 자체를 더 이상 읽지 못하므로 삭제 방어가 기대와 다르게 동작할 수 있다.
프로덕션에서 더 중요한 방어는 여러 겹이다.
prevent_destroy와 백업 정책을 함께 둔다.create_before_destroy는 다운타임을 줄이지만 공짜가 아니다. 새 리소스를 먼저 만들기 때문에 한동안 용량과 비용이 두 배가 될 수 있다.
예를 들어 Launch Template 교체로 Auto Scaling Group이 기존 10대와 새 10대를 동시에 유지해야 한다면, 배포 중에는 최대 20대의 인스턴스 비용과 quota가 필요하다. ALB Target Group이나 DNS 전환이 준비되지 않은 상태에서 이 옵션만 켜면, 새 리소스는 만들어졌지만 트래픽 전환은 실패할 수 있다.
따라서 create_before_destroy를 볼 때는 세 가지를 함께 확인한다.
이 조건이 없으면 create_before_destroy는 안전장치가 아니라 실패 시점을 뒤로 미루는 설정이 된다.
다음 코드는 VPC, Subnet, Security Group, EC2 인스턴스의 최소 관계를 보여준다. 실제 프로덕션 예제가 아니라 의존성 읽기용 예제다.
resource "aws_vpc" "lab" { cidr_block = "10.0.0.0/16"}
resource "aws_subnet" "web" { vpc_id = aws_vpc.lab.id cidr_block = "10.0.1.0/24"}
resource "aws_security_group" "web" { vpc_id = aws_vpc.lab.id
ingress { from_port = 80 to_port = 80 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] }}
resource "aws_instance" "web" { ami = var.ami_id instance_type = "t3.micro" subnet_id = aws_subnet.web.id vpc_security_group_ids = [aws_security_group.web.id]}이 코드에서 Terraform이 계산하는 순서는 다음과 같다.
만약 cidr_block을 10.0.0.0/16에서 10.1.0.0/16으로 바꾸면 plan은 단순 수정이 아니라 replace를 제안할 수 있다. VPC CIDR은 많은 하위 리소스에 영향을 주므로 “한 줄 변경”처럼 보여도 파급이 크다. 이때 plan에서 -/+를 먼저 찾아야 한다.
초보자는 자주 이렇게 쓴다.
resource "aws_s3_bucket" "logs" { bucket = "my-app-logs"}이 코드는 읽기 쉽지만 실패하기 쉽다. S3 버킷 이름은 계정 안에서만 유일한 것이 아니라 글로벌 namespace에서 유일해야 한다. 이미 다른 계정이 my-app-logs를 쓰고 있으면 내 계정에서는 만들 수 없다.
더 안전한 이름은 의미와 고유성을 함께 담는다.
data "aws_caller_identity" "current" {}data "aws_region" "current" {}
locals { bucket_prefix = "myteam-${var.environment}-logs"}
resource "aws_s3_bucket" "logs" { bucket = "${local.bucket_prefix}-${data.aws_caller_identity.current.account_id}-${data.aws_region.current.name}"}수치 감각도 중요하다. S3 버킷 이름은 길이 제한이 있으므로, 팀 이름·환경·서비스명·계정 ID·리전을 모두 붙이면 63자 제한에 가까워질 수 있다. 이름 규칙을 팀 표준으로 만들 때는 myteam-prod-payment-api-access-logs-123456789012-ap-northeast-2처럼 실제 길이를 세어 보고, 너무 길면 prefix를 줄이는 편이 낫다.
Terraform은 인프라 프로비저닝에 강하지만 모든 자동화의 답은 아니다. 경계를 알아야 같은 리소스를 여러 도구가 동시에 관리하는 사고를 피할 수 있다.
| 도구/개념 | Terraform과의 관계 | 판단 기준 |
|---|---|---|
| CloudFormation | AWS 전용 IaC | AWS만 쓰고 조직 표준이 CloudFormation이면 충분할 수 있음 |
| AWS CDK | 프로그래밍 언어로 AWS 인프라 정의 | TypeScript/Python 추상화가 팀에 더 자연스러울 때 검토 |
| Ansible | 서버 내부 설정·패키지 구성에 강함 | VM 안의 패키지 설치와 파일 배포는 Ansible 영역인 경우가 많음 |
| Kubernetes YAML | 클러스터 안 앱 상태 선언 | Deployment/Service는 GitOps 도구가 더 자연스러울 수 있음 |
| Argo CD/Flux | GitOps reconciliation 도구 | Terraform은 클러스터와 기반 인프라, GitOps는 앱 오브젝트로 나누는 경우가 많음 |
| Crossplane | Kubernetes CRD로 클라우드 리소스 관리 | 이미 Crossplane이 소유한 리소스를 Terraform도 소유하면 충돌 위험 |
| OpenTofu | Terraform 생태계에서 갈라진 IaC 도구 | 라이선스와 조직 정책 때문에 검토할 수 있는 대안 |
| Terragrunt | Terraform 래퍼 | 많은 환경·리전에서 backend/provider 반복이 과도할 때 검토 |
첫 원칙은 하나다. 하나의 실제 리소스에는 가능한 한 하나의 소유자를 둔다. EKS 클러스터 자체는 Terraform, 클러스터 안 Deployment는 Argo CD, DB 스키마는 migration 도구처럼 계층을 나누면 drift와 충돌이 줄어든다.
Terragrunt는 Terraform을 대체하는 언어가 아니라 래퍼다. 여러 환경에서 backend, provider, 공통 input을 반복 작성할 때 중복을 줄인다. 그러나 작은 팀의 첫 Terraform 학습에는 오히려 추상화가 늘 수 있다.
Terragrunt를 고려할 신호는 아래와 같다.
environments/dev, staging, prod, dr에 같은 backend/provider 블록이 반복된다.ap-northeast-2, us-east-1 구조가 반복된다.반대로 루트 모듈이 2~3개뿐이고 팀원이 Terraform 자체를 배우는 중이라면, 먼저 순수 Terraform 구조를 익히는 편이 좋다.
OpenTofu는 Terraform 생태계의 라이선스 변화 이후 등장한 오픈소스 계열 도구다. 기본 HCL 문법과 많은 provider 사용 방식은 Terraform과 비슷하게 읽을 수 있다. 이 문서의 첫 회독에서는 “Terraform과 유사한 IaC 대안이며, 조직 정책이나 라이선스 요구 때문에 검토할 수 있다” 정도면 충분하다.
중요한 것은 도구 이름보다 state, provider, plan, apply, graph라는 모델이다. 이 모델을 이해하면 Terraform과 OpenTofu의 차이는 나중에 조직 정책과 기능 요구를 기준으로 비교할 수 있다.
긴 트러블슈팅 명령어를 외우기보다, 어떤 증상이 어떤 개념의 실패인지 연결하는 것이 먼저다.
| 증상 | 연결되는 개념 | 왜 생기는가 | 첫 확인 |
|---|---|---|---|
Error acquiring the state lock | Remote Backend, State Lock | 다른 apply가 실행 중이거나 이전 작업의 lock이 남음 | 누가 apply 중인지 확인하고 CI 실행 상태를 본다 |
Plan is stale | Plan artifact, State 변경 | plan 이후 state가 바뀌어 승인한 계획이 낡음 | 새 plan을 만들고 이전 plan을 폐기한다 |
ResourceAlreadyExists | State 매핑, Import | 실제 리소스는 있는데 state에 연결이 없음 | Terraform이 앞으로 소유할 리소스인지 결정한다 |
Unsupported argument | Provider 버전, schema | 코드가 provider가 모르는 인자를 사용함 | .terraform.lock.hcl과 provider upgrade guide를 확인한다 |
Error: Cycle | DAG, dependency | 리소스가 서로를 먼저 필요로 함 | 참조 방향을 끊고 rule 같은 별도 리소스로 분리한다 |
plan에 -/+ replace가 보임 | ForceNew 속성, 교체 | provider가 in-place 변경을 지원하지 않음 | downtime과 데이터 손실 가능성을 리뷰한다 |
| plan마다 같은 속성이 원복됨 | Drift, ownership 충돌 | 다른 시스템이 같은 값을 바꿈 | 소유자를 하나로 정하거나 좁은 ignore_changes를 검토한다 |
terraform destroy 범위가 예상보다 큼 | Workspace/Backend 분리 실패 | 잘못된 state 또는 환경을 선택함 | 현재 디렉터리, backend key, workspace를 먼저 확인한다 |
| state 파일이 PR에 올라옴 | State 보안 | 민감 값이 Git에 노출될 수 있음 | 즉시 제거하고 secret rotation 필요성을 검토한다 |
State lock 에러가 나면 가장 먼저 “이 lock이 나를 막아서 불편하다”가 아니라 “동시 apply를 막아 사고를 줄이고 있다”고 해석한다. force-unlock은 복구 도구이지 일반 해결책이 아니다.
좋은 질문은 세 가지다.
이 세 질문이 확인되기 전에는 lock을 풀지 않는다.
Unsupported argument는 코드가 틀렸다는 뜻일 수도 있고, provider 버전이 오래됐거나 너무 새롭다는 뜻일 수도 있다. 예를 들어 어떤 AWS provider 버전에서는 S3 ACL 속성이 별도 리소스로 분리되어 기존 예제가 그대로 동작하지 않을 수 있다.
첫 확인은 .terraform.lock.hcl이다. 팀원 한 명만 terraform init -upgrade를 실행해 provider를 바꿨다면, 같은 코드라도 사람마다 plan 결과가 달라질 수 있다. provider 업그레이드는 애플리케이션 의존성 업그레이드처럼 PR로 리뷰해야 한다.
Terraform provider에는 Kubernetes 리소스를 관리하는 기능도 있다. 하지만 HPA, VPA, Argo CD, Flux 같은 controller가 같은 Deployment를 관리하면 충돌이 생길 수 있다. 예를 들어 VPA가 CPU request를 조정했는데 Terraform 코드에는 예전 값이 있으면, 다음 apply가 VPA의 변경을 되돌릴 수 있다.
이때 선택지는 세 가지다.
ignore_changes한다.운영에서 가장 중요한 것은 “누가 최종 소유자인가”를 명확히 하는 것이다.
plan -> 승인 -> apply 흐름을 만들고 인프라 변경을 감사 가능하게 한다.플랫폼 엔지니어링 관점에서 Terraform은 “인프라 요청을 사람에게 티켓으로 보내는 방식”을 “검토 가능한 코드 변경”으로 바꾸는 기본 도구다. 새 스테이징 환경을 만들어 달라는 요청이 반복된다면, 먼저 수동 절차를 문서화하는 것이 아니라 Terraform 모듈과 환경 디렉터리로 재현 가능한 경로를 만든다.
Product Engineering 관점에서도 의미가 있다. 애플리케이션 개발자가 직접 VPC 라우팅을 깊게 몰라도, 팀 표준 모듈을 통해 필요한 S3 버킷, SQS 큐, IAM Role을 요청할 수 있다. 좋은 Terraform 모듈은 개발자에게 모든 클라우드 세부사항을 노출하지 않으면서도 중요한 선택지는 input으로 열어 둔다.
좋은 리뷰 질문은 아래처럼 구체적이어야 한다.
| 개념 A | 개념 B | 차이 |
|---|---|---|
plan | apply | plan은 변경 제안서, apply는 실제 API 호출 |
resource | data | resource는 Terraform이 소유, data는 읽기만 함 |
import | state rm | import는 state에 연결, state rm은 연결 해제 |
| Local State | Remote State | Local은 개인 파일, Remote는 팀 공유 기준 |
| State Lock | IAM Lock | State Lock은 동시 apply 방지, IAM은 API 권한 제어 |
| Workspace | 디렉터리 분리 | Workspace는 같은 코드 다른 state, 디렉터리는 코드와 backend를 명시적으로 분리 |
| Module | Root Module | Module은 재사용 단위, Root Module은 현재 실행 디렉터리 |
| Terraform | Ansible | Terraform은 인프라 프로비저닝, Ansible은 서버 구성 관리에 강함 |
| Drift | Provider bug | Drift는 실제 상태와 코드의 차이, provider bug는 도구 schema/API 동작 문제 |
| OpenTofu | Terraform | 유사한 IaC 모델을 공유하지만 라이선스와 기능 로드맵은 별도로 검토 |
Terraform은 강력하지만 도입 비용이 있다. 아래 질문에 “예”가 많을수록 Terraform 가치가 커진다.
| 질문 | 예라면 |
|---|---|
| 같은 인프라를 dev/staging/prod에 반복해서 만드는가? | 모듈과 환경 분리 가치가 큼 |
| 인프라 변경을 PR로 리뷰해야 하는가? | plan 기반 승인 흐름이 유용 |
| 수동 콘솔 변경이 자주 drift를 만드는가? | IaC 소유권 규칙이 필요 |
| 여러 사람이 같은 인프라를 바꾸는가? | Remote Backend와 Lock이 필요 |
| IAM, 네트워크, DB 같은 위험 리소스가 많은가? | 코드 리뷰와 lifecycle 방어가 중요 |
| 일회성 PoC이고 곧 버릴 환경인가? | 콘솔/CDK/스크립트가 더 단순할 수 있음 |
초기 팀이라면 모든 것을 한 번에 Terraform으로 옮기지 않아도 된다. 네트워크 기반, 공유 IAM Role, 데이터 리소스처럼 사고 범위가 크고 반복되는 것부터 코드화하는 편이 현실적이다.
이 부록은 본문 이해에 필수는 아니다. 손으로 한 번 관찰하고 싶을 때만 실행한다.
terraform initterraform plan관찰할 것은 명령 성공 자체가 아니라 plan의 기호다.
+ create 새 리소스 생성~ update 기존 리소스 수정- destroy 기존 리소스 삭제-/+ replace 삭제 후 재생성첫 실습에서는 -와 -/+가 나오면 apply하지 않고 왜 삭제 또는 교체가 필요한지부터 읽는다.
terraform state list출력은 Terraform 주소 목록이다.
aws_vpc.mainaws_subnet.private_aaws_security_group.api이 목록은 “Terraform이 현재 소유한다고 믿는 리소스”다. AWS 콘솔에 있는데 여기에 없다면 Terraform은 그 리소스를 관리하지 않는다. 여기에 있는데 AWS에는 없다면 state와 실제 리소스가 어긋난 것이다.
terraform graphterraform graph는 DOT 형식의 그래프를 출력한다. 실제 이미지를 만들려면 Graphviz가 필요하지만, 첫 학습에서는 출력에 리소스 주소와 화살표가 있다는 정도만 봐도 된다. cycle 에러가 났을 때 “순서 문제가 아니라 모델 문제”임을 이해하는 데 도움이 된다.
비용이 걱정된다면 실제 AWS 리소스를 만들기보다 LocalStack 같은 로컬 AWS 호환 환경이나 별도 샌드박스 계정에서 작은 예제만 본다. 목표는 설치 절차를 익히는 것이 아니라 “VPC -> Subnet -> Security Group” 참조가 graph에 어떻게 나타나고, apply 뒤 state list에 어떤 Terraform 주소가 남는지 확인하는 것이다.
관찰 순서는 짧게 잡는다.
terraform plan에서 생성 순서와 참조 관계를 읽는다.terraform apply 뒤 terraform state list로 소유권 목록을 확인한다.destroy가 몇 개인지 확인한다.이 정도만 봐도 Terraform이 단순 명령 실행기가 아니라 state와 DAG로 변경을 계산하는 도구라는 감각을 얻을 수 있다.
TF_LOG=DEBUG terraform planTF_LOG는 provider API 호출과 내부 로그를 많이 출력한다. 초보 단계에서 먼저 켜면 정보량이 너무 많다. plan diff, state, provider 버전을 먼저 확인한 뒤에도 원인이 보이지 않을 때만 사용한다. 로그에는 민감 값이 섞일 수 있으므로 공유 전에 반드시 확인한다.
퀴즈
힌트: State는 백업 파일이 아니라 주소와 실제 리소스의 연결표다.
State가 Terraform 주소와 실제 리소스 ID의 매핑을 들고 있기 때문이다. 매핑이 없으면 코드의 aws_s3_bucket.logs가 이미 존재하는 어떤 버킷을 가리키는지 알 수 없다.
퀴즈
힌트: 같은 코드 다른 state인지, 코드와 backend 자체를 분리하는지 비교한다.
대체로 디렉터리 분리가 더 안전하다. prod는 별도 계정, 권한, backend, 승인 흐름을 갖는 경우가 많고, 디렉터리 분리는 이 차이를 경로와 state로 명시하기 때문이다.
OpenTofu, Terraform Cloud, Terragrunt, Terraform Module Registry, .tfvars, terraform_remote_state, provider alias, depends_on, moved block, import block, drift detection, state lock, prevent_destroy, ignore_changes, policy as code, GitOps, Crossplane
prevent_destroy, ignore_changes, condition 사용법수동 콘솔 작업 -> 재현 불가, 변경 추적 어려움, 환경 차이 누적 -> IaC 필요
Terraform 모델 -> HCL Desired State -> Provider가 실제 API 읽기 -> State가 코드 주소와 실제 리소스 매핑 -> DAG가 순서 계산 -> Plan이 변경 제안 -> Apply가 API 호출
운영에서 만나는 현실 -> Drift: 실제 상태가 코드와 달라짐 -> Import: 기존 리소스를 Terraform 소유로 편입 -> Lifecycle: 삭제/교체/외부 변경을 제어 -> Remote Backend + Lock: 팀 동시 작업 방지
경계 -> Terraform: 기반 인프라 프로비저닝 -> GitOps: Kubernetes 앱 오브젝트 지속 reconciliation -> Ansible: 서버 내부 구성 관리 -> OpenTofu/Terragrunt: 조직 정책과 규모에 따른 선택지ignore_changes가 사고 범위가 큰 속성을 숨기고 있지 않은가?prevent_destroy와 백업 정책이 함께 있는가?.terraform.lock.hcl로 고정되어 있다.plan -> 승인 -> apply 흐름을 사용한다.prevent_destroy와 백업 정책이 있다.최종 수정: 2026-04-01