Prologue: "이 서버가 어떻게 만들어졌는지 아는 사람?"
팀에 합류한 지 일주일 됐다. 운영 서버에 문제가 생겼고, 나는 똑같은 환경을 새로 만들어야 했다. AWS 콘솔을 열었다. EC2 설정? 누가 어떤 보안 그룹을 왜 붙였는지 알 수가 없다. RDS 파라미터 그룹? 누군가 6개월 전에 뭔가 건드렸는데 메모가 없다. VPC CIDR 블록? 왜 이걸 썼는지 모른다.
이게 콘솔 기반 인프라 관리의 현실이다. 누군가 손으로 만들었고, 그 과정이 어디에도 기록되지 않았다. 재현 불가능하고, 감사(audit)도 불가능하고, 팀원이 바뀌면 지식이 사라진다.
Terraform을 처음 봤을 때 "코드로 서버를 만든다"는 게 추상적으로 느껴졌다. 근데 쓰다 보니 이게 단순한 자동화가 아니라 인프라에 버전 관리를 적용하는 것임을 깨달았다.
1. IaC가 왜 필요한가?
콘솔 클릭의 문제들
| 문제 | 설명 |
|---|---|
| 재현 불가 | 프로덕션과 동일한 환경을 다시 만들 수 없음 |
| 드리프트 | 콘솔에서 몰래 변경한 것들이 쌓임 |
| 협업 불가 | "내가 했는데 뭘 했더라..." |
| 롤백 불가 | 이전 상태로 되돌리기 어려움 |
| 감사 불가 | 언제 누가 무엇을 바꿨는지 모름 |
IaC (Infrastructure as Code)의 장점
코드 = 인프라 설명서 + 자동화 스크립트 + 버전 히스토리
- 재현 가능: 같은 코드 → 같은 인프라
- 버전 관리: Git으로 인프라 변경 이력 추적
- 코드 리뷰: PR로 인프라 변경 검토
- 테스트 가능: staging → production 동일 코드로
- 협업 가능: 팀 전체가 인프라를 이해하고 수정
2. Terraform vs Pulumi vs CloudFormation
IaC 도구가 Terraform만 있는 건 아니다.
비교 테이블
| Terraform | Pulumi | CloudFormation | |
|---|---|---|---|
| 언어 | HCL (자체 DSL) | Python/TypeScript/Go 등 | YAML/JSON |
| 멀티 클라우드 | 완벽 지원 | 완벽 지원 | AWS 전용 |
| 학습 곡선 | 중간 | 낮음 (기존 언어 사용) | 높음 |
| 커뮤니티 | 매우 큼 | 성장 중 | AWS 생태계 |
| State 관리 | 자체 state 파일 | 자체 state | AWS 관리 |
| 가격 | OSS 무료 / Cloud 유료 | OSS 무료 / Cloud 유료 | 무료 |
언제 무엇을?
- Terraform: 멀티 클라우드 or 업계 표준 따르고 싶을 때. 가장 많이 쓰임
- Pulumi: 팀이 TypeScript/Python 잘 쓰고, 로직이 복잡할 때
- CloudFormation: AWS 올인, 서드파티 도구 최소화하고 싶을 때
이 글은 Terraform 기준으로 간다.
3. HCL 기초 문법
Terraform은 **HCL(HashiCorp Configuration Language)**을 쓴다. JSON보다 읽기 쉽고, YAML보다 표현력이 높다.
기본 구조
# main.tf
# 프로바이더 설정
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
required_version = ">= 1.6"
}
provider "aws" {
region = "ap-northeast-2" # 서울 리전
}
# 리소스 정의
resource "aws_instance" "web" {
ami = "ami-0c9c942bd7bf113a2" # Amazon Linux 2023
instance_type = "t3.micro"
tags = {
Name = "web-server"
Environment = "production"
}
}
변수 (Variables)
# variables.tf
variable "environment" {
description = "Deployment environment"
type = string
default = "staging"
validation {
condition = contains(["staging", "production"], var.environment)
error_message = "Environment must be 'staging' or 'production'."
}
}
variable "instance_type" {
description = "EC2 instance type"
type = string
default = "t3.micro"
}
variable "db_password" {
description = "RDS master password"
type = string
sensitive = true # 로그/plan 출력에서 숨김
}
# terraform.tfvars (Git에 올리지 말것!)
environment = "production"
instance_type = "t3.small"
출력 (Outputs)
# outputs.tf
output "web_public_ip" {
description = "Public IP of web server"
value = aws_instance.web.public_ip
}
output "db_endpoint" {
description = "RDS endpoint"
value = aws_db_instance.main.endpoint
sensitive = true
}
로컬 값 (Locals)
locals {
common_tags = {
Project = "codemapo"
Environment = var.environment
ManagedBy = "terraform"
}
name_prefix = "${var.environment}-codemapo"
}
resource "aws_instance" "web" {
# ...
tags = merge(local.common_tags, {
Name = "${local.name_prefix}-web"
})
}
4. 프로바이더와 리소스
프로바이더 (Provider)
프로바이더는 Terraform이 특정 플랫폼과 통신하는 방법을 정의한다. AWS, GCP, Azure, Kubernetes, GitHub, Datadog 등 수백 개의 프로바이더가 있다.
# AWS 프로바이더 (인증 방식)
provider "aws" {
region = "ap-northeast-2"
# 방법 1: 환경변수 (권장)
# AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY
# 방법 2: assume_role (CI/CD에서)
assume_role {
role_arn = "arn:aws:iam::123456789:role/TerraformRole"
}
}
리소스 참조
리소스는 서로 참조할 수 있다. Terraform이 자동으로 의존성을 파악하고 올바른 순서로 생성한다.
resource "aws_security_group" "web" {
name = "web-sg"
vpc_id = aws_vpc.main.id # VPC 생성 후 보안 그룹 생성
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
}
resource "aws_instance" "web" {
ami = "ami-0c9c942bd7bf113a2"
instance_type = var.instance_type
vpc_security_group_ids = [aws_security_group.web.id] # 보안 그룹 참조
subnet_id = aws_subnet.public.id
}
데이터 소스 (Data Sources)
Terraform 외부에서 관리되는 리소스를 참조할 때 쓴다.
# 기존 AMI ID 조회
data "aws_ami" "amazon_linux" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-*-x86_64"]
}
}
resource "aws_instance" "web" {
ami = data.aws_ami.amazon_linux.id # 자동으로 최신 AMI 사용
instance_type = "t3.micro"
}
5. State 관리: Terraform의 핵심
State란?
Terraform은 state 파일 (terraform.tfstate)에 실제 인프라 상태를 기록한다. 이게 있어야 "현재 뭐가 있고, 뭘 변경해야 하는지" 계산할 수 있다.
// terraform.tfstate (예시)
{
"version": 4,
"resources": [
{
"type": "aws_instance",
"name": "web",
"instances": [
{
"attributes": {
"id": "i-0abc123def456",
"public_ip": "54.123.45.67",
"instance_type": "t3.micro"
}
}
]
}
]
}
Remote State: 팀 협업의 필수
로컬 state 파일은 팀 협업 시 충돌 문제가 생긴다. Remote Backend로 해결한다.
# backend.tf
terraform {
backend "s3" {
bucket = "mycompany-terraform-state"
key = "production/terraform.tfstate"
region = "ap-northeast-2"
encrypt = true
# DynamoDB로 State 잠금 (동시 실행 방지)
dynamodb_table = "terraform-state-lock"
}
}
# DynamoDB 테이블 생성 (최초 1회)
aws dynamodb create-table \
--table-name terraform-state-lock \
--attribute-definitions AttributeName=LockID,AttributeType=S \
--key-schema AttributeName=LockID,KeyType=HASH \
--billing-mode PAY_PER_REQUEST
State 드리프트
누군가 콘솔에서 직접 리소스를 변경하면 state와 실제 인프라가 다르게 된다. 이를 **드리프트(drift)**라고 한다.
# 드리프트 감지
terraform plan -refresh-only
# 드리프트를 state에 반영 (코드는 건드리지 않음)
terraform apply -refresh-only
6. Plan/Apply 워크플로우
Terraform의 핵심 워크플로는 init → plan → apply다.
# 1. 초기화 (프로바이더 다운로드, backend 연결)
terraform init
# 2. 포맷 & 검증
terraform fmt
terraform validate
# 3. 변경 사항 미리보기 (실제 변경 없음)
terraform plan
# 4. 적용
terraform apply
# 특정 리소스만 적용
terraform apply -target=aws_instance.web
# 자동 승인 (CI/CD용)
terraform apply -auto-approve
Plan 결과 읽기
Terraform will perform the following actions:
# aws_instance.web will be created
+ resource "aws_instance" "web" {
+ ami = "ami-0c9c942bd7bf113a2"
+ instance_type = "t3.micro"
+ id = (known after apply)
}
# aws_security_group.web will be updated in-place
~ resource "aws_security_group" "web" {
~ ingress = [
+ {
+ from_port = 443
+ to_port = 443
},
]
}
# aws_db_instance.legacy will be destroyed
- resource "aws_db_instance" "legacy" {
- id = "legacy-db-abc123"
}
Plan: 1 to add, 1 to change, 1 to destroy.
+ 생성, ~ 수정, - 삭제. -/+는 삭제 후 재생성 (다운타임 발생 가능!)이므로 특히 주의.
7. 모듈로 재사용
모듈은 Terraform의 함수 같은 개념이다. 반복되는 인프라 패턴을 캡슐화한다.
모듈 구조
modules/
ec2-web/
main.tf
variables.tf
outputs.tf
README.md
# modules/ec2-web/main.tf
resource "aws_security_group" "web" {
name = "${var.name}-sg"
vpc_id = var.vpc_id
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
}
resource "aws_instance" "web" {
ami = var.ami_id
instance_type = var.instance_type
subnet_id = var.subnet_id
vpc_security_group_ids = [aws_security_group.web.id]
user_data = var.user_data
tags = merge(var.tags, { Name = var.name })
}
# modules/ec2-web/variables.tf
variable "name" { type = string }
variable "vpc_id" { type = string }
variable "subnet_id" { type = string }
variable "ami_id" { type = string }
variable "instance_type" { type = string; default = "t3.micro" }
variable "user_data" { type = string; default = "" }
variable "tags" { type = map(string); default = {} }
모듈 사용
# main.tf
module "web_server" {
source = "./modules/ec2-web"
name = "codemapo-web"
vpc_id = aws_vpc.main.id
subnet_id = aws_subnet.public.id
ami_id = data.aws_ami.amazon_linux.id
instance_type = "t3.small"
tags = {
Environment = var.environment
}
}
# Terraform Registry에서 공개 모듈 사용
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.5.0"
name = "main-vpc"
cidr = "10.0.0.0/16"
azs = ["ap-northeast-2a", "ap-northeast-2c"]
private_subnets = ["10.0.1.0/24", "10.0.2.0/24"]
public_subnets = ["10.0.101.0/24", "10.0.102.0/24"]
enable_nat_gateway = true
}
8. 실전: AWS VPC + EC2 + RDS 구성
실제 웹 애플리케이션에 필요한 기본 인프라를 처음부터 끝까지 만들어보자.
디렉토리 구조
infrastructure/
main.tf
variables.tf
outputs.tf
network.tf
compute.tf
database.tf
terraform.tfvars.example
backend.tf
네트워크 (VPC + Subnets)
# network.tf
# VPC
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
enable_dns_support = true
tags = merge(local.common_tags, { Name = "${local.name_prefix}-vpc" })
}
# 인터넷 게이트웨이
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = merge(local.common_tags, { Name = "${local.name_prefix}-igw" })
}
# 퍼블릭 서브넷 (EC2)
resource "aws_subnet" "public" {
count = 2
vpc_id = aws_vpc.main.id
cidr_block = "10.0.${count.index + 1}.0/24"
availability_zone = data.aws_availability_zones.available.names[count.index]
map_public_ip_on_launch = true
tags = merge(local.common_tags, {
Name = "${local.name_prefix}-public-${count.index + 1}"
Tier = "public"
})
}
# 프라이빗 서브넷 (RDS)
resource "aws_subnet" "private" {
count = 2
vpc_id = aws_vpc.main.id
cidr_block = "10.0.${count.index + 11}.0/24"
availability_zone = data.aws_availability_zones.available.names[count.index]
tags = merge(local.common_tags, {
Name = "${local.name_prefix}-private-${count.index + 1}"
Tier = "private"
})
}
# 퍼블릭 라우팅 테이블
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.main.id
}
tags = merge(local.common_tags, { Name = "${local.name_prefix}-public-rt" })
}
resource "aws_route_table_association" "public" {
count = length(aws_subnet.public)
subnet_id = aws_subnet.public[count.index].id
route_table_id = aws_route_table.public.id
}
data "aws_availability_zones" "available" {
state = "available"
}
컴퓨트 (EC2 + ALB)
# compute.tf
# 웹 서버 보안 그룹
resource "aws_security_group" "web" {
name = "${local.name_prefix}-web-sg"
vpc_id = aws_vpc.main.id
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
security_groups = [aws_security_group.alb.id] # ALB에서만
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
# ALB 보안 그룹
resource "aws_security_group" "alb" {
name = "${local.name_prefix}-alb-sg"
vpc_id = aws_vpc.main.id
ingress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
# EC2 인스턴스
resource "aws_instance" "web" {
ami = data.aws_ami.amazon_linux.id
instance_type = var.instance_type
subnet_id = aws_subnet.public[0].id
vpc_security_group_ids = [aws_security_group.web.id]
iam_instance_profile = aws_iam_instance_profile.web.name
user_data = base64encode(<<-EOF
#!/bin/bash
yum update -y
yum install -y nodejs npm
npm install -g pm2
EOF
)
tags = merge(local.common_tags, { Name = "${local.name_prefix}-web" })
}
데이터베이스 (RDS PostgreSQL)
# database.tf
# RDS 보안 그룹
resource "aws_security_group" "rds" {
name = "${local.name_prefix}-rds-sg"
vpc_id = aws_vpc.main.id
ingress {
from_port = 5432
to_port = 5432
protocol = "tcp"
security_groups = [aws_security_group.web.id] # 웹 서버에서만
}
}
# RDS 서브넷 그룹
resource "aws_db_subnet_group" "main" {
name = "${local.name_prefix}-db-subnet"
subnet_ids = aws_subnet.private[*].id # 프라이빗 서브넷
tags = local.common_tags
}
# RDS 파라미터 그룹
resource "aws_db_parameter_group" "postgres" {
family = "postgres16"
name = "${local.name_prefix}-pg16"
parameter {
name = "log_connections"
value = "1"
}
parameter {
name = "log_min_duration_statement"
value = "1000" # 1초 이상 쿼리 로깅
}
}
# RDS 인스턴스
resource "aws_db_instance" "main" {
identifier = "${local.name_prefix}-db"
engine = "postgres"
engine_version = "16.1"
instance_class = var.db_instance_class
allocated_storage = 20
max_allocated_storage = 100 # 자동 확장
storage_type = "gp3"
storage_encrypted = true
db_name = "app_db"
username = "app_user"
password = var.db_password
db_subnet_group_name = aws_db_subnet_group.main.name
vpc_security_group_ids = [aws_security_group.rds.id]
parameter_group_name = aws_db_parameter_group.postgres.name
backup_retention_period = 7
backup_window = "03:00-04:00"
maintenance_window = "sun:04:00-sun:05:00"
deletion_protection = var.environment == "production"
skip_final_snapshot = var.environment != "production"
tags = local.common_tags
}
실행
cd infrastructure/
# 초기화
terraform init
# 변수 파일 준비
cp terraform.tfvars.example terraform.tfvars
# terraform.tfvars 편집
# 검증
terraform validate
terraform plan -out=tfplan
# 적용
terraform apply tfplan
# 결과 확인
terraform output
9. 실전 팁
.gitignore 설정
# .gitignore
*.tfstate
*.tfstate.backup
.terraform/
terraform.tfvars # 시크릿 포함 가능
*.tfplan
.terraform.lock.hcl # 선택적 (팀 합의)
시크릿 관리
# 방법 1: AWS Secrets Manager에서 가져오기
data "aws_secretsmanager_secret_version" "db_password" {
secret_id = "production/db/password"
}
resource "aws_db_instance" "main" {
password = data.aws_secretsmanager_secret_version.db_password.secret_string
}
# 방법 2: 환경변수로 주입
# TF_VAR_db_password=... terraform apply
Terragrunt로 환경 분리
# terragrunt.hcl (staging/)
terraform {
source = "../../modules/app"
}
inputs = {
environment = "staging"
instance_type = "t3.micro"
db_instance_class = "db.t3.micro"
}
Epilogue: 인프라도 코드다
Terraform을 쓰기 시작하면 AWS 콘솔이 불편해진다. "이걸 코드로 선언하면 되는데..." 하는 생각이 자꾸 든다.
핵심은 간단하다. 인프라도 코드다. 코드처럼 리뷰하고, 테스트하고, 버전을 관리한다. "이 서버가 어떻게 만들어졌는지 아는 사람?" 같은 질문은 이제 git log로 해결된다.