이 글을 시작하기 전 말씀드리고 싶은 것이 있습니다. 저조차도 아직 이 내용이 제대로 이해가 되지 않습니다. 그렇기 때문에 용기 내어 글을 올려봅니다. 왜냐하면 이 글을 작성하면서 이 글을 작성했다는 제 자신이 부끄럽지 않기 위해 더 공부할 예정입니다.
그러니 이상한 내용이 있다면 말씀해주세요.ㅎㅎ
오늘은 단일 서버나 단순 VPC 구성을 넘어, 실제 운영(Production) 환경에서 바로 적용할 수 있는 운영 가능한(Production-Ready) 고가용성 인프라 구축 가이드를 작성해 보려고 합니다.
테라폼(Terraform)을 활용해 Multi-AZ(다중 가용 영역) 기반의 3-Tier 웹 아키텍처를 완벽하게 자동화 구축하는 실습을 정리했습니다.
1. V1 아키텍처 개요 및 특징
우리가 구축할 V1 아키텍처는 단일 서버 구조의 한계를 극복하고,
고가용성(HA), 확장성(Scalability), 불변 인프라(Immutable Infrastructure), 그리고 강력한 보안 체계를 제공합니다.
주요 특징
- 고가용성 (High Availability)
- 단일 데이터센터(AZ) 장애에 대비해 리전 내 2개 이상의 가용 영역(us-east-2a, us-east-2c)에 서버와 DB를 분산 배치합니다.
- ALB (Application Load Balancer)가 헬스 체크를 통해 장애가 발생하거나 부하가 높은 EC2를 자동으로 감지하여 정상 인스턴스로 트래픽을 우회합니다.
- 자동 확장성 (Auto Scaling)
- 트래픽 변화(예: CPU 사용량 50% 초과)에 따라 EC2 인스턴스의 개수가 자동으로 최소 2대에서 최대 4대까지 증감합니다.
- Kubernetes의 ReplicaSet/HPA 개념과 유사하게 동작합니다.
- 불변 인프라 (Immutable Infrastructure)
- EC2 내부 코드를 수동으로 수정하지 않고, Launch Template과 UserData 템플릿을 기반으로 서버를 새로 구동합니다.
- 애플리케이션 업데이트 시 Instance Refresh (Rolling 배포) 전략을 통해 무중단으로 신규 사양/코드가 반영된 EC2로 순차 교체합니다.
- 보안 강화 (AWS SSM Session Manager)
- .pem 키 파일 보관에 따른 유출 위험 및 SSH 22번 포트 오픈을 전면 배제합니다.
- AWS IAM Role 기반의 SSM Session Manager를 적용하여 Private Subnet에 위치한 EC2에도 키 파일 없이 안전하게 접속합니다.
- 데이터베이스 자동 관리 (RDS Multi-AZ)
- AWS RDS MySQL Multi-AZ를 활용해 Primary-Standby 복제 구조를 가져갑니다.
- 비밀번호는 코드에 직접 노출하지 않고 AWS Secrets Manager와 자동 연동합니다.
2. 전체 아키텍처 구성도
1) 트래픽 이동 경로 (Request Flow)
Internet (외부 망)
│
Public ALB (퍼블릭 서브넷 / HTTP 80)
│
WEB Auto Scaling Group (App Private Subnet / 최소 2개~최대 4개)
├─ WEB EC2 (AZ-a)
└─ WEB EC2 (AZ-c)
│
Internal ALB (App Private Subnet / HTTP 8000 / Web -> WAS 전달)
│
WAS Auto Scaling Group (App Private Subnet / 최소 2개~최대 4개)
├─ WAS EC2 (AZ-a)
└─ WAS EC2 (AZ-c)
│
RDS MySQL Multi-AZ (DB Private Subnet / Port 3306)
├─ Primary DB (AZ-a)
└─ Standby DB (AZ-c)
2) 네트워크 서브넷 분리 구조
2개의 가용 영역(AZ-a, AZ-c)에 각 3개의 서브넷 계층(Public, App Private, DB Private)을 만들어 격리합니다.

us-east-2a us-east-2c
───────────────────────────────────────────────────────────────
Public-Subnet-A Public-Subnet-C
├─ Public ALB ├─ Public ALB
└─ NAT Gateway-A └─ NAT Gateway-C
App-Private-Subnet-A App-Private-Subnet-C
├─ WEB ASG ├─ WEB ASG
├─ Internal ALB ├─ Internal ALB
└─ WAS ASG └─ WAS ASG
DB-Private-Subnet-A DB-Private-Subnet-C
└─ RDS Primary └─ RDS Standby
3. 프로젝트 파일 구조
모듈화 및 관리의 편의성을 위해 프로젝트 파일 구성을 기능별로 분리하여 관리합니다.
tf-step5/
├── provider.tf # Provider 설정, 리전 및 공통 태그(default_tags) 정의
├── variables.tf # 환경, 리전, 스펙 등 입력 변수 모음
├── locals.tf # 상수, 서브넷 CIDR, 공통 태그 등 로컬 변수 정의
├── iam.tf # EC2 SSM 접속용 IAM Role 및 Instance Profile 정의
├── data.tf # 최신 Amazon Linux 2023 AMI 조회
├── vpc.tf # VPC, Subnet, IGW, NAT GW, Route Table 연동
├── sg.tf # 계층별(ALB, Web, Int-ALB, WAS, DB) 보안 그룹 설정
├── alb.tf # Public / Internal ALB, Target Group, Listener 구성
├── launch_template.tf # Web, WAS EC2 생성을 위한 런치 템플릿
├── asg.tf # Web, WAS Auto Scaling Group 및 Target Tracking 정책
├── rds.tf # RDS MySQL Multi-AZ 및 DB Subnet Group 설정
├── userdata-web.sh.tftpl # Web EC2 인스턴스 초기화 쉘 스크립트 템플릿
├── userdata-was.sh.tftpl # WAS EC2 인스턴스 초기화 쉘 스크립트 템플릿
└── outputs.tf # ALB URL, RDS Endpoint 등 최종 결과값 출력
4. 핵심 테라폼 코드 상세 설명
1) 로컬 변수 및 Provider 설정 (locals.tf & provider.tf)
공통 태그와 가용 영역별 CIDR 대역을 깔끔하게 정의하여 반복 코드를 최소화합니다.
# provider.tf
terraform {
required_version = ">=1.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~>6.0"
}
}
}
provider "aws" {
region = var.region
default_tags {
tags = local.common_tags
}
}
# locals.tf
locals {
project = "DE-AI-07-IaC-3tier-V1"
common_tags = {
Project = local.project
Environment = var.environment
ManageBy = "Terraform"
}
azs = {
a = "us-east-2a"
c = "us-east-2c"
}
public_subnets = {
a = "10.0.1.0/24"
c = "10.0.2.0/24"
}
app_subnets = {
a = "10.0.11.0/24"
c = "10.0.12.0/24"
}
db_subnets = {
a = "10.0.21.0/24"
c = "10.0.22.0/24"
}
}
2) 키 파일 없는 안전한 접속: IAM & SSM 설정 (iam.tf)
.pem 파일 없이 AWS Systems Manager(SSM)를 사용하도록 EC2에 권한을 부여합니다.
# iam.tf
data "aws_iam_policy_document" "ec2_assume_role" {
statement {
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["ec2.amazonaws.com"]
}
}
}
resource "aws_iam_role" "ec2_ssm" {
name = "${local.project}-EC2-SSM-Role"
assume_role_policy = data.aws_iam_policy_document.ec2_assume_role.json
}
# AWS 관리형 정책 연결 (AmazonSSMManagedInstanceCore)
resource "aws_iam_role_policy_attachment" "ssm_core" {
role = aws_iam_role.ec2_ssm.name
policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}
resource "aws_iam_instance_profile" "ec2_ssm" {
name = "${local.project}-EC2-SSM-Profile"
role = aws_iam_role.ec2_ssm.name
}
3) 계층별 보안 그룹 Chaining 설정 (sg.tf)
IP 대역 오픈을 최소화하고, 바로 앞 계층의 보안 그룹에서 유입되는 트래픽만 허용하도록 체이닝 구조를 형성합니다.
# sg.tf (일부 예시)
# 1. Public ALB: 외부 인터넷 80포트 허용
resource "aws_security_group" "public_alb" {
name = "${local.project}-PUBLIC-ALB-SG"
vpc_id = aws_vpc.main.id
ingress {
from_port = 80
to_port = 80
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"]
}
}
# 2. WEB Tier: Public ALB로부터 오는 80포트만 허용
resource "aws_security_group" "web" {
name = "${local.project}-WEB-SG"
vpc_id = aws_vpc.main.id
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
security_groups = [aws_security_group.public_alb.id]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
# 3. WAS Tier: Internal ALB로부터 오는 8000포트만 허용
resource "aws_security_group" "was" {
name = "${local.project}-WAS-SG"
vpc_id = aws_vpc.main.id
ingress {
from_port = 8000
to_port = 8000
protocol = "tcp"
security_groups = [aws_security_group.internal_alb.id]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
4) Public & Internal ALB 구성 (alb.tf)
외부 사용자의 입구를 담당하는 Public ALB와 Web-WAS 간 내부 로드밸런싱을 담당하는 Internal ALB를 선언합니다.
# alb.tf
# Public ALB
resource "aws_lb" "public" {
name = "${local.project}-public-alb"
internal = false
load_balancer_type = "application"
security_groups = [aws_security_group.public_alb.id]
subnets = [for subnet in aws_subnet.public : subnet.id]
}
resource "aws_lb_target_group" "web" {
name = "${local.project}-web-tg"
port = 80
protocol = "HTTP"
target_type = "instance"
vpc_id = aws_vpc.main.id
health_check {
enabled = true
path = "/health"
protocol = "HTTP"
matcher = "200"
interval = 30
timeout = 5
healthy_threshold = 2
unhealthy_threshold = 2
}
}
resource "aws_lb_listener" "public_http" {
load_balancer_arn = aws_lb.public.arn
port = 80
protocol = "HTTP"
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.web.arn
}
}
5) Launch Template 및 Auto Scaling Group (asg.tf)
CPU 사용량 목표 추적 정책(Target Tracking) 및 롤링 배포(Instance Refresh) 설정을 적용합니다.
# asg.tf
resource "aws_autoscaling_group" "web" {
name = "${local.project}-WEB-ASG"
min_size = 2
desired_capacity = var.web_desired_capacity
max_size = 4
vpc_zone_identifier = [for subnet in aws_subnet.app : subnet.id]
target_group_arns = [aws_lb_target_group.web.arn]
health_check_type = "ELB"
health_check_grace_period = 180
launch_template {
id = aws_launch_template.web.id
version = "$Latest"
}
instance_refresh {
strategy = "Rolling"
preferences {
min_healthy_percentage = 50
}
}
}
# CPU 50% 기준 자동 스케일링 정책
resource "aws_autoscaling_policy" "web_cpu" {
name = "${local.project}-WEB-CPU-50"
autoscaling_group_name = aws_autoscaling_group.web.name
policy_type = "TargetTrackingScaling"
target_tracking_configuration {
predefined_metric_specification {
predefined_metric_type = "ASGAverageCPUUtilization"
}
target_value = 50
}
}
6) RDS MySQL Multi-AZ 구성 (rds.tf)
데이터 원본 및 Standby 인스턴스를 자동으로 구성합니다.
# rds.tf
resource "aws_db_instance" "mysql" {
identifier = "de-ai-07-mysql-v1"
engine = "mysql"
instance_class = var.db_instance_class
allocated_storage = 20
max_allocated_storage = 100
db_name = var.db_name
username = var.db_username
manage_master_user_password = true # Secrets Manager가 비밀번호 자동 관리
multi_az = true # Multi-AZ 구성
publicly_accessible = false
db_subnet_group_name = aws_db_subnet_group.main.name
vpc_security_group_ids = [aws_security_group.rds.id]
}
7) 입력 변수 정의 (variables.tf)
인프라 스펙과 가용량을 변수 화하여 관리합니다.
variable "region" {
description = "AWS 리전"
type = string
default = "us-east-2"
}
variable "environment" {
description = "구동 환경"
type = string
default = "dev"
}
variable "instance_type" {
description = "WEB/WAS EC2 인스턴스 유형"
type = string
default = "t3.micro"
}
variable "web_desired_capacity" {
description = "WEB ASG 기본 인스턴스 수"
type = number
default = 2
}
variable "was_desired_capacity" {
description = "WAS ASG 기본 인스턴스 수"
type = number
default = 2
}
variable "db_instance_class" {
description = "DB 인스턴스 클래스"
type = string
default = "db.t3.micro"
}
variable "db_name" {
description = "초기 생성 DB 이름"
type = string
default = "appdb"
}
variable "db_username" {
description = "RDS 관리자 이름"
type = string
default = "adminuser"
}
8) for_each 기반의 멀티 AZ 네트워크 구성 (vpc.tf)
각 가용 영역별 서브넷과 NAT 게이트웨이를 for_each 문법으로 유연하게 동적 생성합니다.
# VPC 및 인터넷 게이트웨이
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
enable_dns_support = true
tags = { Name = "${local.project}-VPC" }
}
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = { Name = "${local.project}-IGW" }
}
# Public 서브넷 (AZ-a, AZ-c)
resource "aws_subnet" "public" {
for_each = local.public_subnets
vpc_id = aws_vpc.main.id
cidr_block = each.value
availability_zone = local.azs[each.key]
map_public_ip_on_launch = true
tags = {
Name = "${local.project}-PUBLIC-${upper(each.key)}"
Tier = "PUBLIC"
}
}
# Private App 서브넷 (WEB/WAS용)
resource "aws_subnet" "app" {
for_each = local.app_subnets
vpc_id = aws_vpc.main.id
cidr_block = each.value
availability_zone = local.azs[each.key]
map_public_ip_on_launch = false
tags = {
Name = "${local.project}-APP-${upper(each.key)}"
Tier = "APPLICATION"
}
}
# Private DB 서브넷 (RDS용)
resource "aws_subnet" "db" {
for_each = local.db_subnets
vpc_id = aws_vpc.main.id
cidr_block = each.value
availability_zone = local.azs[each.key]
map_public_ip_on_launch = false
tags = {
Name = "${local.project}-DB-${upper(each.key)}"
Tier = "DATABASE"
}
}
# 가용 영역별 NAT Gateway 생성
resource "aws_eip" "nat" {
for_each = local.azs
domain = "vpc"
tags = { Name = "${local.project}-NAT-EIP-${upper(each.key)}" }
}
resource "aws_nat_gateway" "nat" {
for_each = local.azs
allocation_id = aws_eip.nat[each.key].id
subnet_id = aws_subnet.public[each.key].id
tags = { Name = "${local.project}-NAT-GW-${upper(each.key)}" }
depends_on = [aws_internet_gateway.main]
}
# App Private Route Table (NAT GW 바인딩)
resource "aws_route_table" "app" {
for_each = local.azs
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_nat_gateway.nat[each.key].id
}
tags = { Name = "${local.project}-APP-RT" }
}
resource "aws_route_table_association" "app" {
for_each = aws_subnet.app
subnet_id = each.value.id
route_table_id = aws_route_table.app[each.key].id
}
9) WEB Tier 초기화 스크립트 (userdata-web.sh.tftpl)
Internal ALB의 DNS 주소를 테라폼 템플릿 변수(${internal_alb_dns})로 받아서 Nginx 리버스 프록시 설정을 주입합니다.
#!/bin/bash
set -euxo pipefail
# 패키지 업데이트 및 Nginx 설치
dnf update -y
dnf install -y nginx
# SSM Agent 및 Nginx 활성화
systemctl enable --now amazon-ssm-agent || true
# Nginx 설정을 작성하여 Internal ALB로 요청 동적 프록시 (proxy_pass)
cat > /etc/nginx/nginx.conf <<'NGINX'
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log notice;
pid /run/nginx.pid;
events {
worker_connections 1024;
}
http {
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
keepalive_timeout 65;
server {
listen 80;
server_name _;
location = /health {
access_log off;
default_type application/json;
return 200 '{"status":"ok","tier":"web"}';
}
location / {
# 테라폼에서 주입된 Internal ALB DNS 주소로 포워딩
proxy_pass http://${internal_alb_dns}:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
}
NGINX
nginx -t
systemctl enable --now nginx
timedatectl set-timezone Asia/Seoul
10) WAS Tier 초기화 스크립트 (userdata-was.sh.tftpl)
Docker를 설치하고 FastAPI 앱 코드와 Dockerfile을 생성하여 컨테이너 기반으로 백엔드를 띄웁니다.
#!/bin/bash
set -euxo pipefail
dnf update -y
dnf install -y docker
systemctl enable --now amazon-ssm-agent || true
systemctl enable --now docker
mkdir -p /opt/app
# FastAPI 애플리케이션 생성
cat > /opt/app/app.py <<'PYTHON'
import os
import socket
from fastapi import FastAPI
app = FastAPI()
@app.get("/health")
def health():
return {"status": "ok", "tier": "was"}
@app.get("/")
def root():
return {
"message": "3-Tier v2 response",
"hostname": socket.gethostname(),
"db_host": os.getenv("DB_HOST"),
"db_name": os.getenv("DB_NAME"),
}
PYTHON
cat > /opt/app/requirements.txt <<'REQ'
fastapi==0.116.1
uvicorn[standard]==0.35.0
REQ
# Dockerfile 생성
cat > /opt/app/Dockerfile <<'DOCKER'
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
EXPOSE 8000
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
DOCKER
# 이미지 빌드 및 도커 컨테이너 구동
cd /opt/app
docker build -t de-ai-07-was:v2 .
docker run -d \
--name was-app \
--restart always \
-p 8000:8000 \
-e DB_HOST="${db_host}" \
-e DB_PORT="${db_port}" \
-e DB_NAME="${db_name}" \
-e DB_USER="${db_user}" \
de-ai-07-was:v2
timedatectl set-timezone Asia/Seoul
이번 V1 인프라 구축을 통해 단일 장애점(SPOF)이 없는 운영 가능 수준의 3-Tier 아키텍처를 완성할 수 있었습니다.
- 고가용성: ALB + Multi-AZ 구조로 데이터센터 레벨 장애 방어
- 유연한 확장: Auto Scaling Group 기반의 자동 인스턴스 증감
- 컨테이너화: UserData를 활용해 WAS 계층을 Docker 컨테이너 기반 환경으로 표준화
- 강력한 보안: SSM Agent 적용으로 .pem 키 및 SSH 포트 노출 차단
이 구조는 추후 컨테이너 오케스트레이션인 EKS(Kubernetes) 및 GitOps/CI-CD 파이프라인으로 자연스럽게 전환하거나 확장하기에도 매우 수월한 유연성을 제공합니다. 대규모 운영 환경을 준비 중이신 분들께 도움이 되었기를 바랍니다!
+ 제가 이해가 안돼서 분석글을 작성할 것입니다. ㅎㅎ