AWS 콘솔 없이도 동일한 인프라를 반복 재현하는 법
개념부터 실전 패턴, 실수 방지까지
왜 Terraform을 써야 하는가
어느 스타트업의 월요일 아침, 팀장님이 이렇게 메시지를 보냈다고 상상해 보세요.
— 스테이징은 6개월 전 누군가가 AWS 콘솔에서 클릭클릭 만든 환경. 문서는 없음.
VPC, Subnet, Security Group, RDS, EC2, ALB, IAM Role … 하나씩 콘솔 열어서 비교하고 다시 클릭으로 만드는 것, 가능하긴 합니다. 그런데 그 과정에서 누락이나 실수가 생기면? 다음에 또 같은 요청이 들어오면?
콘솔 관리 vs IaC — 무엇이 다른가
| 구분 | 콘솔 관리 | IaC (Terraform) |
|---|---|---|
| 핵심 질문 | 지금 무엇을 만들 것인가? | 어떤 상태를 유지할 것인가? |
| 변경 이력 | ❌ 남지 않음 | ✅ Git에 기록됨 |
| 환경 복제 | 수작업 반복 | 동일 코드로 반복 가능 |
| 코드 리뷰 | ❌ 불가 | ✅ PR 리뷰 가능 |
| 실수 회복 | 어려움 | 코드 되돌리기로 복구 |
| 환경 동기화 | dev/prod 설정이 서서히 어긋남 | 같은 코드로 동일 보장 |
좋은 IaC의 5가지 조건
| 조건 | 왜 필요한가 | 어떻게 |
|---|---|---|
| 선언적 | "어떤 상태가 되어야 하는지"만 표현 | resource 블록 사용 |
| 재현 가능 | 같은 코드 = 같은 결과 | Terraform 자체 동작 |
| 검토 가능 | 변경 전 영향 파악 | terraform plan |
| 협업 가능 | 팀원과 동시 작업 | Remote Backend + Locking |
| 안전 | 사고 시 복구 가능 | Git 이력 + 백업 |
핵심 개념 4가지
Provider — Terraform과 AWS를 연결하는 다리
Terraform 자체는 AWS EC2가 뭔지 모릅니다. Provider가 각 클라우드 서비스의 리소스 타입과 API 호출 방법을 알고 있습니다.
# Provider 설정 — 항상 버전을 고정하세요
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0" # 5.x 범위 내 최신 버전 사용
}
}
}
provider "aws" {
region = "ap-northeast-2" # 서울 리전
}main.tf
version = "~> 5.0"은 5.x 버전만 허용한다는 뜻입니다. 버전을 고정하지 않으면 팀원마다 다른 버전으로 실행해서 예기치 않은 동작이 발생할 수 있습니다.Resource — 관리할 대상 선언
# 형식: resource "리소스_타입" "로컬_이름" { ... }
resource "aws_s3_bucket" "logs" {
bucket = "my-app-logs-2026"
tags = {
Environment = "prod"
ManagedBy = "terraform"
}
}main.tf
aws_s3_bucket은 리소스 타입, logs는 이 코드 내에서 참조할 때 쓰는 이름입니다. 실제 AWS에는 bucket 값인 my-app-logs-2026로 만들어집니다.
State — Terraform의 기억
State는 Terraform이 "내가 어떤 리소스를 만들었는가"를 기록한 파일입니다. terraform.tfstate라는 JSON 파일로 저장됩니다.
// terraform.tfstate (자동 생성 — 직접 수정 금지!)
{
"version": 4,
"resources": [
{
"type": "aws_s3_bucket",
"name": "logs",
"instances": [
{
"attributes": {
"bucket": "my-app-logs-2026",
"arn": "arn:aws:s3:::my-app-logs-2026"
}
}
]
}
]
}terraform.tfstate
terraform.tfstate를 절대 Git에 커밋하지 마세요. DB 비밀번호, IAM Secret Key 등 민감 정보가 평문(plain text)으로 담겨 있습니다.Plan / Apply — 변경의 양면
plan은 변경 예고편, apply는 실제 반영입니다. 실무에서 plan 결과를 정확히 읽는 것이 apply보다 더 중요합니다.
+ aws_instance.web # ✅ 신규 생성 → 비용 발생
~ aws_s3_bucket.logs # 🔄 속성 수정 (in-place)
- aws_eip.legacy # ❌ 삭제 → 데이터 손실 위험!
-/+ aws_db_instance.main # 💥 삭제 후 재생성 (다운타임!)plan 기호 해석
-/+는 기존 리소스를 삭제 후 새로 만든다는 뜻입니다. RDS의 db_name 변경이 대표적인 예시 — DB가 삭제되면 데이터도 함께 사라집니다. 반드시 스냅샷 백업 후 진행하세요.기본 명령어와 동작 흐름
| 명령어 | 역할 | 언제 실행? |
|---|---|---|
terraform init |
작업 디렉터리 초기화, Provider 다운로드 | 프로젝트 시작 시, Provider 변경 시 |
terraform fmt |
코드 포맷 자동 정리 | 커밋 전 |
terraform validate |
문법 검증 | 커밋 전, CI에서 자동 실행 |
terraform plan |
변경 계획 미리 보기 | apply 전 항상 |
terraform apply |
실제 인프라 반영 | plan 검토 후 |
terraform destroy |
관리 중인 리소스 전체 삭제 | 테스트 환경 정리 시 |
terraform state list |
state에 등록된 리소스 목록 | state 확인 시 |
terraform state mv |
리소스 이름 변경 시 state 동기화 | 리소스 식별자 변경 시 |
terraform import |
기존 리소스를 state로 가져옴 | 콘솔 리소스를 IaC로 전환 시 |
terraform output |
output 값 조회 | apply 후 값 확인 시 |
전체 동작 흐름
실습: Random Provider로 흐름 체험하기
AWS 계정 없이도 Terraform 명령어 흐름을 연습할 수 있습니다.
terraform {
required_providers {
random = {
source = "hashicorp/random"
version = "~> 3.0"
}
}
}
resource "random_id" "server" {
byte_length = 8
}
output "server_id" {
value = random_id.server.hex
}main.tf
# 순서대로 실행
$ mkdir terraform-practice && cd terraform-practice
$ terraform init
$ terraform plan
$ terraform apply
$ cat terraform.tfstate # state 파일 확인
$ terraform destroyterminal
State와 Remote Backend
State가 꼬이면 벌어지는 일
| 상황 | 결과 |
|---|---|
| state 파일 분실 | Terraform이 기존 리소스를 "없는 것"으로 인식 → 중복 생성 |
| state와 실제 인프라 불일치 | plan 결과가 예상과 전혀 다르게 나옴 |
| 두 명이 동시에 apply | state 파일 손상 (race condition) |
| state를 Git에 커밋 | DB 비밀번호 등이 GitHub에 평문 노출 |
Remote Backend — 팀 협업의 필수 조건
혼자 쓸 때는 로컬 state 파일로도 되지만, 팀이 함께 작업한다면 Remote Backend는 필수입니다. 가장 흔한 조합은 AWS S3 + DynamoDB입니다.
terraform {
backend "s3" {
bucket = "my-terraform-state"
key = "prod/terraform.tfstate"
region = "ap-northeast-2"
encrypt = true # S3 저장 시 암호화
dynamodb_table = "terraform-state-lock" # 동시 실행 방지 (Locking)
}
}backend.tf
State Locking — 동시 실행 방지
[10:00] 개발자 A: terraform apply 시작
→ DynamoDB에 lock 획득
[10:01] 개발자 B: terraform apply 시도
→ Error: Error acquiring the state lock
→ 대기 또는 중단
[10:05] 개발자 A: apply 완료 → lock 해제
[10:06] 개발자 B: 재시도 가능State Locking 흐름
Drift — 코드와 실제 인프라가 어긋날 때
plan 때 의도치 않은 변경이 나타납니다.# 시나리오
1. Terraform으로 EC2 생성 (코드: t3.medium)
2. 콘솔에서 t3.large로 수동 변경 ← Drift 발생!
3. 다음 terraform plan 결과:
~ aws_instance.web
~ instance_type: "t3.large" → "t3.medium"
4. 모르고 apply 하면 t3.medium으로 되돌려짐Drift 예시
원칙: Terraform으로 만든 리소스는 콘솔에서 수정하지 않는다.
긴급 상황으로 콘솔 수정이 불가피했다면, 반드시 코드에도 동일하게 반영
추가: terraform import
기존에 콘솔로 만든 리소스를 Terraform 관리로 가져오고 싶을 때 씁니다.
# 콘솔에서 만든 S3 버킷을 state로 가져오기
$ terraform import aws_s3_bucket.logs my-app-logs-2026
# 가져온 뒤에는 코드를 state에 맞게 작성해야 합니다
$ terraform plan # "No changes"가 나올 때까지 코드를 맞춰가세요terminal
import 블록을 코드 안에 선언할 수 있습니다. 1.6부터는 terraform plan이 자동으로 import 코드를 생성해 주는 기능이 추가됐습니다.변수 · 출력 · 모듈
variable — 환경별 값 분리
# variables.tf
variable "environment" {
type = string
description = "배포 환경 (dev, staging, prod)"
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "environment는 dev, staging, prod 중 하나여야 합니다."
}
}
variable "db_instance_class" {
type = string
default = "db.t3.micro"
}
variable "db_password" {
type = string
sensitive = true # plan/apply 출력에서 마스킹
}variables.tf
validation 블록으로 변수 값을 검증할 수 있습니다. 잘못된 환경 이름이 들어오면 apply 전에 에러를 발생시켜 사고를 예방합니다.tfvars — 환경별 값 파일
# dev.tfvars
environment = "dev"
db_instance_class = "db.t3.micro"
# prod.tfvars
environment = "prod"
db_instance_class = "db.r6g.large"*.tfvars
$ terraform apply -var-file="dev.tfvars"
$ terraform apply -var-file="prod.tfvars"terminal
*.tfvars 파일에 비밀번호 같은 값이 들어갈 수 있으므로, 반드시 .gitignore에 추가하세요.output — 생성된 리소스 정보 노출
output "db_endpoint" {
value = aws_db_instance.main.endpoint
description = "애플리케이션에서 사용할 DB 엔드포인트"
}
output "db_password" {
value = aws_db_instance.main.password
sensitive = true # 화면 출력 시 "sensitive" 표시
}outputs.tf
sensitive = true로 설정해도 state 파일에는 평문으로 저장됩니다. state 파일 자체를 암호화(S3 encrypt)하고 접근 권한을 제한하는 것이 중요합니다.
module — 재사용 단위
project/
├── main.tf
├── variables.tf
└── modules/
└── web-server/
├── main.tf # 실제 리소스 정의
├── variables.tf # 입력 변수
└── outputs.tf # 출력 값디렉터리 구조
# project/main.tf
module "web_dev" {
source = "./modules/web-server"
environment = "dev"
instance_type = "t3.micro"
}
module "web_prod" {
source = "./modules/web-server"
environment = "prod"
instance_type = "t3.large"
}main.tf
locals — 중간 계산 값 정의
반복 사용되는 표현식을 locals로 정의하면 코드가 훨씬 깔끔해집니다.
locals {
common_tags = {
Environment = var.environment
ManagedBy = "terraform"
Project = "my-app"
}
name_prefix = "${var.environment}-myapp"
}
resource "aws_instance" "web" {
ami = "ami-0abc12345"
instance_type = "t3.medium"
tags = local.common_tags # 재사용
}locals 활용
for_each / count — 여러 리소스를 한 번에
# count: 숫자로 반복
resource "aws_instance" "web" {
count = 3
ami = "ami-0abc12345"
instance_type = "t3.micro"
tags = { Name = "web-${count.index}" }
}
# for_each: 맵/셋으로 반복 (더 권장됨)
resource "aws_s3_bucket" "buckets" {
for_each = toset(["logs", "assets", "backups"])
bucket = "my-app-${each.key}"
}for_each vs count
count는 중간 항목을 삭제하면 인덱스가 바뀌어 이후 리소스가 줄줄이 재생성됩니다. for_each는 키 기반이라 다른 항목에 영향을 주지 않습니다.절대로 코드에 넣으면 안 되는 것들
가장 흔한 실수 — Provider에 키를 직접 입력
# ❌ 절대 이렇게 하면 안 됨
provider "aws" {
region = "ap-northeast-2"
access_key = "AKIAIOSFODNN7EXAMPLE" # 💀
secret_key = "wJalrXUtnFEMI/K7MDENGbPxRfiCYEXAMPLEKEY" # 💀
}절대 금지
GitHub public 저장소에 push하면 자동 스캐너가 수초 내에 키를 감지합니다. AWS 계정 탈취로 수백만 원의 요금 청구가 발생한 실제 사례가 많습니다.
안전한 자격 증명 방법
| 방식 | 설명 | 추천 상황 |
|---|---|---|
| 환경 변수 | AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY |
로컬 개발 |
| 자격 증명 파일 | ~/.aws/credentials (aws configure) |
로컬 개발 |
| IAM Role | EC2/EKS에 Role 부여 | 서버에서 실행 시 |
| AWS SSO | 임시 자격 증명 사용 | 기업 환경 |
| OIDC (GitHub Actions) | 키 없이 임시 토큰 발급 | CI/CD 파이프라인 |
# ✅ 올바른 방법 — Provider에 키를 적지 않음
provider "aws" {
region = "ap-northeast-2"
# 자격 증명은 환경 변수나 ~/.aws/credentials에서 자동으로 읽힘
}main.tf
민감 값 주입 방법
# 방법 1: 환경 변수로 주입 (로컬 작업 시)
$ export TF_VAR_db_password="MySecurePassword!"
$ terraform apply
# 방법 2: AWS Secrets Manager에서 동적으로 읽기 (운영 환경 권장)
data "aws_secretsmanager_secret_version" "db_pass" {
secret_id = "prod/myapp/db_password"
}
resource "aws_db_instance" "main" {
username = "admin"
password = data.aws_secretsmanager_secret_version.db_pass.secret_string
}민감 값 주입
.gitignore 필수 항목
# .gitignore
.terraform/
*.tfstate
*.tfstate.*
*.tfvars
*.tfvars.json
crash.log
override.tf
override.tf.json
.terraform.lock.hcl # 팀에 따라 커밋 여부 결정.gitignore
변경 요청 6단계 워크플로우
"이번에만 임시로 콘솔에서 열어주세요"도 Terraform 코드로 처리해야 합니다. 임시라도 콘솔 변경은 Drift를 만듭니다.
prod에 바로 적용하지 않습니다. dev에서 먼저 검증하세요.
Add / Change / Destroy 수치가 의도와 일치하는지, Force Replacement가 있는지 확인합니다.
resource 이름 변경, 블록 삭제 등으로 의도치 않은 destroy가 발생할 수 있습니다. 이름 변경이 필요하면 terraform state mv를 먼저 실행하세요.
plan 결과를 PR에 붙여넣고, 보안 / 가독성 / 영향 범위를 함께 확인합니다.
apply 직후 콘솔 또는 CLI로 리소스가 정상 생성됐는지, 서비스가 정상 응답하는지, 다시 plan했을 때 "No changes"가 뜨는지 확인합니다.
# plan 결과를 파일로 저장해서 동일한 내용을 apply
$ terraform plan -out=tfplan
$ terraform apply tfplan # 검토한 그대로만 반영됨terminal
Plan 결과 직접 분석해보기
$ terraform plan
Terraform will perform the following actions:
# aws_security_group.web will be updated in-place
~ resource "aws_security_group" "web" {
~ ingress = [
- {
- cidr_blocks = ["10.0.0.0/16"] # 내부 IP만 허용
- from_port = 22
- protocol = "tcp"
- to_port = 22
},
+ {
+ cidr_blocks = ["0.0.0.0/0"] # 전체 인터넷 허용!
+ from_port = 22
+ protocol = "tcp"
+ to_port = 22
},
]
}
# aws_db_instance.main must be replaced
-/+ resource "aws_db_instance" "main" {
~ db_name = "appdb" → "app_db" # forces replacement
~ id = "db-abc123" → (known after apply)
}
Plan: 1 to add, 1 to change, 1 to destroy.plan 결과
📋 분석 결과
| 질문 | 답변 |
|---|---|
| 변경되는 리소스는? | security group(수정) + db_instance(삭제 후 재생성) = 총 2개 |
| SG 변경 내용 | SSH 허용 IP가 내부 VPC(10.0.0.0/16)에서 전체 인터넷(0.0.0.0/0)으로 변경 |
| SG 변경이 안전한가? | 위험! SSH 포트를 전 세계에 여는 건 심각한 보안 취약점 |
| RDS 처리 | db_name 변경은 삭제 후 재생성을 유발 (Force Replacement) |
| 데이터 영향 | 데이터 손실! 기존 DB 데이터가 함께 삭제됨 |
| 안전한 진행 방법 | ① RDS 스냅샷 백업 먼저 ② SG 변경 의도 재확인 ③ db_name 변경 방법 재검토 (마이그레이션 방안 필요) |
추가로 알아야 할 개념들
data source — 기존 리소스 참조
Terraform이 만들지 않은 기존 리소스(다른 팀이 만든 VPC 등)를 코드에서 참조할 때 씁니다.
# 기존에 존재하는 VPC를 참조 (만드는 게 아님)
data "aws_vpc" "existing" {
filter {
name = "tag:Name"
values = ["prod-vpc"]
}
}
resource "aws_subnet" "new" {
vpc_id = data.aws_vpc.existing.id # 참조!
cidr_block = "10.0.100.0/24"
}data source
depends_on — 명시적 의존성
Terraform은 리소스 간 참조를 분석해서 자동으로 순서를 결정합니다. 하지만 참조 관계가 없는데도 순서가 필요할 때는 depends_on을 명시합니다.
resource "aws_iam_role_policy_attachment" "attach" {
role = aws_iam_role.example.name
policy_arn = aws_iam_policy.example.arn
depends_on = [aws_iam_role.example] # 명시적 의존성
}depends_on
lifecycle — 리소스 생명주기 제어
resource "aws_instance" "web" {
ami = "ami-0abc12345"
instance_type = "t3.medium"
lifecycle {
create_before_destroy = true # 삭제 전 새 것을 먼저 만듦 (무중단)
prevent_destroy = true # 실수로 destroy 막기 (prod DB에 유용)
ignore_changes = [ami] # AMI 변경은 Terraform이 무시
}
}lifecycle 블록
prevent_destroy = true를 반드시 붙이세요. 실수로 terraform destroy를 실행해도 에러가 나면서 막아줍니다.dynamic 블록 — 반복 속성
variable "ingress_rules" {
default = [
{ port = 80, cidr = "0.0.0.0/0" },
{ port = 443, cidr = "0.0.0.0/0" },
]
}
resource "aws_security_group" "web" {
name = "web-sg"
dynamic "ingress" {
for_each = var.ingress_rules
content {
from_port = ingress.value.port
to_port = ingress.value.port
protocol = "tcp"
cidr_blocks = [ingress.value.cidr]
}
}
}dynamic 블록
terraform.lock.hcl — Provider 버전 고정
terraform init 후 생성되는 파일입니다. Provider의 정확한 버전과 체크섬을 기록해서 팀원 모두가 동일한 버전을 사용하도록 보장합니다. 반드시 Git에 커밋하세요.
moved 블록 — 리소스 이름 변경 시
# Terraform 1.1+ — state mv 없이 코드로 이름 변경
moved {
from = aws_instance.old_name
to = aws_instance.new_name
}moved 블록
이전에는 terraform state mv CLI를 써야 했지만, moved 블록으로 코드에서 선언적으로 처리할 수 있습니다.
workspaces — 하나의 코드로 여러 환경
$ terraform workspace new dev
$ terraform workspace new prod
$ terraform workspace select prod
$ terraform apply # prod workspace에 반영workspaces
실무 도구 생태계
| 역할 | 도구 | 특징 |
|---|---|---|
| 코드 작성 | Terraform / OpenTofu | OpenTofu는 완전 오픈소스 포크 (Linux Foundation) |
| 코드 검사 | tflint | 문법 오류, 잘못된 인스턴스 타입 탐지 |
| 보안 검사 | tfsec / Checkov | 공개 S3, 평문 시크릿 등 보안 취약점 탐지 |
| 비용 예측 | Infracost | plan 결과로 월 예상 비용 계산 |
| 실행 자동화 | Atlantis | 오픈소스, GitHub/GitLab PR에서 plan/apply 자동화 |
| 실행 자동화 | Terraform Cloud (HCP) | HashiCorp SaaS, 팀 플랜 이상에서 유용 |
| 모듈 반복 제거 | Terragrunt | backend 설정, variable 주입 반복 감소 |
| State 저장 | S3 + DynamoDB | AWS 환경에서 가장 흔한 조합 |
실무 자동화 파이프라인
개발자가 로컬에서 작업 후 GitHub/GitLab에 PR을 올립니다.
tflint, tfsec 검사도 함께 실행합니다.
Atlantis 또는 GitHub Actions이 자동으로 붙입니다.
개발자가 로컬에서 직접 apply를 실행하지 않습니다.
정리 & 체크리스트
핵심 키워드 한 눈에
실무 적용 체크리스트
- ☐
terraform.tfstate를 Git에 커밋하지 않는다 - ☐ Secret을 코드에 직접 적지 않는다
- ☐ 변경 전 항상
terraform plan을 확인한다 - ☐ Destroy 항목이 의도된 것인지 점검한다
- ☐ Remote Backend를 사용한다 (협업 시 필수)
- ☐ 콘솔에서 수동 변경을 하지 않는다 (Drift 방지)
- ☐ 동료 리뷰 후 apply한다
- ☐ Provider 버전을 고정하고
.terraform.lock.hcl을 커밋한다 - ☐ 중요 리소스에
prevent_destroy = true를 붙인다 - ☐ 모든 리소스에
ManagedBy = "terraform"태그를 붙인다
다음 학습 방향
| 영역 | 학습 주제 |
|---|---|
| Terraform 심화 | dynamic 블록, for_each, count, locals, moved 블록 |
| State 운영 | terraform import, state mv, state rm, state pull/push |
| 모듈 설계 | Terraform Registry 공개 모듈 분석, 모듈 버전 관리 |
| 보안 강화 | AWS Secrets Manager 연동, IRSA, OIDC (키 없는 CI/CD) |
| 자동화 | Atlantis, GitHub Actions, Terragrunt |
| 분석 도구 | tflint, tfsec, Checkov, Infracost |
| 멀티 클라우드 | GCP, Azure Provider |
| Kubernetes 연동 | Helm Provider, Kubernetes Provider |
이 세 가지를 지키면 인프라 관리의 90%는 해결됩니다.
'MSA' 카테고리의 다른 글
| 로깅(Observability) 완전 정리장애가 나면 어디부터 봐야 할까? (0) | 2026.05.15 |
|---|---|
| OpenFeign 공식 문서 ,선언적 HTTP 클라이언트의 모든 것 (0) | 2026.05.15 |
| API 게이트웨이 (0) | 2026.04.15 |
| 서킷브레이커 (1) | 2026.04.14 |
| 로드밸런싱 (0) | 2026.04.14 |
