
Terraform 입문: 인프라를 코드로 관리하기
AWS 콘솔 클릭질로 만든 서버가 왜 문제인지, Terraform으로 인프라를 코드로 선언하면 무엇이 달라지는지 실전 예제와 함께 정리했다.

AWS 콘솔 클릭질로 만든 서버가 왜 문제인지, Terraform으로 인프라를 코드로 선언하면 무엇이 달라지는지 실전 예제와 함께 정리했다.
새벽엔 낭비하고 점심엔 터지는 서버 문제 해결기. '택시 배차'와 '피자 배달' 비유로 알아보는 오토 스케일링과 서버리스의 차이, 그리고 Spot Instance를 활용한 비용 절감 꿀팁.

서버를 끄지 않고 배포하는 법. 롤링, 카나리, 블루-그린의 차이점과 실제 구축 전략. DB 마이그레이션의 난제(팽창-수축 패턴)와 AWS CodeDeploy 활용법까지 심층 분석합니다.

내 서버가 해킹당하지 않는 이유. 포트와 IP를 검사하는 '패킷 필터링'부터 AWS Security Group까지, 방화벽의 진화 과정.

왜 넷플릭스는 멀쩡한 서버를 랜덤하게 꺼버릴까요? 시스템의 약점을 찾기 위해 고의로 장애를 주입하는 카오스 엔지니어링의 철학과 실천 방법(GameDay)을 소개합니다.

팀에 합류한 지 일주일 됐다. 운영 서버에 문제가 생겼고, 나는 똑같은 환경을 새로 만들어야 했다. AWS 콘솔을 열었다. EC2 설정? 누가 어떤 보안 그룹을 왜 붙였는지 알 수가 없다. RDS 파라미터 그룹? 누군가 6개월 전에 뭔가 건드렸는데 메모가 없다. VPC CIDR 블록? 왜 이걸 썼는지 모른다.
이게 콘솔 기반 인프라 관리의 현실이다. 누군가 손으로 만들었고, 그 과정이 어디에도 기록되지 않았다. 재현 불가능하고, 감사(audit)도 불가능하고, 팀원이 바뀌면 지식이 사라진다.
Terraform을 처음 봤을 때 "코드로 서버를 만든다"는 게 추상적으로 느껴졌다. 근데 쓰다 보니 이게 단순한 자동화가 아니라 인프라에 버전 관리를 적용하는 것임을 깨달았다.
| 문제 | 설명 |
|---|---|
| 재현 불가 | 프로덕션과 동일한 환경을 다시 만들 수 없음 |
| 드리프트 | 콘솔에서 몰래 변경한 것들이 쌓임 |
| 협업 불가 | "내가 했는데 뭘 했더라..." |
| 롤백 불가 | 이전 상태로 되돌리기 어려움 |
| 감사 불가 | 언제 누가 무엇을 바꿨는지 모름 |
코드 = 인프라 설명서 + 자동화 스크립트 + 버전 히스토리
IaC 도구가 Terraform만 있는 건 아니다.
| Terraform | Pulumi | CloudFormation | |
|---|---|---|---|
| 언어 | HCL (자체 DSL) | Python/TypeScript/Go 등 | YAML/JSON |
| 멀티 클라우드 | 완벽 지원 | 완벽 지원 | AWS 전용 |
| 학습 곡선 | 중간 | 낮음 (기존 언어 사용) | 높음 |
| 커뮤니티 | 매우 큼 | 성장 중 | AWS 생태계 |
| State 관리 | 자체 state 파일 | 자체 state | AWS 관리 |
| 가격 | OSS 무료 / Cloud 유료 | OSS 무료 / Cloud 유료 | 무료 |
이 글은 Terraform 기준으로 간다.
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.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.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 {
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"
})
}
프로바이더는 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
}
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"
}
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"
}
}
]
}
]
}
로컬 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와 실제 인프라가 다르게 된다. 이를 드리프트(drift)라고 한다.
# 드리프트 감지
terraform plan -refresh-only
# 드리프트를 state에 반영 (코드는 건드리지 않음)
terraform apply -refresh-only
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
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.
+ 생성, ~ 수정, - 삭제. -/+는 삭제 후 재생성 (다운타임 발생 가능!)이므로 특히 주의.
모듈은 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
}
실제 웹 애플리케이션에 필요한 기본 인프라를 처음부터 끝까지 만들어보자.
infrastructure/
main.tf
variables.tf
outputs.tf
network.tf
compute.tf
database.tf
terraform.tfvars.example
backend.tf
# 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"
}
# 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" })
}
# 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
# .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.hcl (staging/)
terraform {
source = "../../modules/app"
}
inputs = {
environment = "staging"
instance_type = "t3.micro"
db_instance_class = "db.t3.micro"
}
Terraform을 쓰기 시작하면 AWS 콘솔이 불편해진다. "이걸 코드로 선언하면 되는데..." 하는 생각이 자꾸 든다.
핵심은 간단하다. 인프라도 코드다. 코드처럼 리뷰하고, 테스트하고, 버전을 관리한다. "이 서버가 어떻게 만들어졌는지 아는 사람?" 같은 질문은 이제 git log로 해결된다.