PCG.dev
Blog
TerraformAWSInfrastructure

[Terraform] 운영서버 구축을 위한 가이드

공용 global 계층과 서비스 계층을 분리하고, 도메인부터 S3·CloudFront·Shared ALB·Private EC2까지 운영 인프라를 구축한 과정을 정리합니다.

·12분 읽기

현재 IT지원위원회는 학생들을 위한 다양한 서비스를 운영 중이다. 인프라 자원을 효율적으로 관리하기 위해 공용 자원을 다루는 global 계층개별 비즈니스 로직을 다루는 서비스 계층으로 폴더를 분리하여 Terraform 코드를 구성했다.

도메인 관리부터 프론트엔드/백엔드 배포까지의 전체 구축 과정을 단계별로 정리한다.


전체 아키텍처 개요

인프라는 공통 자원을 담당하는 global(Hub)과 서비스별 project/*로 분리된 구조다.

terraform/
├── module/            # 여러 계층/서비스가 공통으로 참조하는 재사용 모듈 (route53, acm 등)
└── project/
    ├── global/        # 모든 서비스가 공유하는 공용 자원 (VPC, Route53, ACM, Shared ALB, IAM)
    ├── ssuport/
    │   └── prod/      # 이 글에서 다루는 부분
    ├── homepage/
    ├── club-homepage/
    └── authenticator/

global이 네트워크(VPC, Subnet), 루트 도메인(Route53), 통합 로드밸런서(Shared ALB), 공통 보안 그룹·IAM 등 모든 서비스가 공유하는 뼈대를 만들어두면, 각 서비스(ssuport, homepage, club-homepage, authenticator 등)는 terraform_remote_state로 그 값(VPC ID, ALB DNS, 인증서 ARN 등)만 참조해서 자기 리소스(EC2, S3, Target Group)만 그 위에 얹는다. 신규 서비스를 추가할 때 네트워크나 ALB를 매번 새로 만들지 않아도 되는 이유다.

이 구조를 택한 배경은 크게 비용(서비스마다 ALB를 띄우면 트래픽이 거의 없는 서비스도 고정비가 나감)과 관리 포인트 단일화 두 가지다.

트래픽 흐름

프론트엔드 (정적 웹사이트)

User → Route53 → CloudFront → S3

React/Vue 등 프론트엔드는 서버(EC2)를 띄우지 않고 빌드 결과물을 S3에만 저장한다. S3 버킷은 퍼블릭 차단(OAC)되어 있고 CloudFront를 통해서만 접근 가능하다.

백엔드 (API)

User → Route53 → CloudFront (API용) → Shared ALB → Target Group → Private EC2

이 글의 Step 4~5에서 다루는 기본 구성에는 없지만, 실제 운영 구조에서는 API 트래픽도 CloudFront를 한 번 거친다. 이유는 두 가지다.

  • 악성 트래픽을 엣지 단에서 1차 차단
  • 운영 환경의 /swagger-ui/* 접근을 CloudFront Function으로 차단

Shared ALB 라우팅: global 계층에는 prod-alb라는 이름의 ALB가 딱 1개 존재하고, 모든 서비스의 백엔드 트래픽이 여기로 모인다. ALB 리스너는 들어오는 Host Header나 URL 경로를 보고 알맞은 타깃 그룹으로 트래픽을 분배한다 — ssuport 관련 요청은 ssuport 타깃 그룹으로, homepage 관련 요청은 homepage 타깃 그룹으로 가는 식이다. Step 4에서 만든 aws_lb_target_group이 바로 이 라우팅의 대상 중 하나다.

EC2 접근 제어

  • 모든 EC2는 프라이빗 서브넷에 위치하고 퍼블릭 IP가 없다. 외부에서 EC2로 직접 접근하는 것은 물리적으로 불가능하고, 오직 Shared ALB를 통해서만 접근할 수 있다.
  • EC2 인바운드 보안 그룹은 ALB의 보안 그룹에서 오는 트래픽만 허용하도록 좁혀져 있다.

보안 및 인증

  • 중앙집중식 IAM (global/iam.tf): EC2가 가져야 할 권한(S3 접근, ECR 이미지 다운로드, SSM 접속 등)을 global에서 Instance Profile 하나로 통합 관리한다. Step 4의 iam_instance_profile = data.terraform_remote_state.itsupport_global.outputs.prod_ec2_role_profile_name이 바로 이 공용 Profile을 참조하는 부분이다.
  • Presigned URL 업로드: 이미지·PDF를 업로드할 때 백엔드 서버를 거치면 부하가 커지기 때문에, S3에 직접 업로드하는 Presigned URL 방식을 쓰도록 각 서비스의 Assets 버킷에 CORS 정책을 세팅해뒀다. 최근 추가된 club-homepage도 동일한 방식을 쓴다.
  • ACM: CloudFront용(us-east-1)과 ALB용(ap-northeast-2) 인증서를 분리 발급해 리전 요구사항을 맞췄다.

아키텍처 다이어그램

global 계층과 서비스 계층으로 분리한 전체 인프라 아키텍처global 계층과 서비스 계층으로 분리한 전체 인프라 아키텍처


이 글의 범위

Step 1~5는 위 전체 그림 중 ssuport 서비스 하나를 배포하는 기본 흐름을 다룬다. API 앞단의 CloudFront, 중앙집중식 IAM 상세 정책, Presigned URL 설정 등은 범위 밖이라 코드는 생략했다. 프로덕션에 그대로 적용하려면 위 요소들을 추가로 고려해야 한다.

Step 1. Global 계층 세팅: 공용 도메인 및 인증서 중앙화

모든 서비스의 시작은 도메인과 네트워크다. global 계층에서는 모든 서비스가 공통으로 사용할 네트워크 기반과 호스팅 영역을 구성한다.

Route53 호스팅 존 생성

전용 호스팅 존(ssuport.kr)을 생성하여 향후 모든 서브도메인을 파생시킬 준비를 마친다.

global/route53.tf
module "route53_ssuport_prod" {
  source      = "../../modules/route53"
  domain_name = local.ssuport_prod_domain_name
  common_tags = {
    Environment = "global",
    Project     = "itsupport-ssuport",
    Owner       = "itsupport-devops",
    Name        = "SSUPORT_PROD_DOMAIN"
  }
}

ACM 인증서 발급

  • us-east-1 (버지니아 북부): 프론트엔드 CloudFront에 연결할 *.ssuport.kr 와일드카드 인증서를 발급받는다. CloudFront 글로벌 엣지는 구조상 반드시 버지니아 북부 리전의 인증서만 허용한다.
  • ap-northeast-2 (서울): 백엔드 ALB에 부착할 인증서를 발급받는다.
global/acm.tf
# CloudFront용 버지니아 북부 인증서
resource "aws_acm_certificate" "ssuport_prod_certificate" {
  provider          = aws.us_east_1
  domain_name       = local.ssuport_prod_domain_name
  validation_method = "DNS"
  lifecycle {
    create_before_destroy = true
  }
  subject_alternative_names = ["*.${local.ssuport_prod_domain_name}"]
  tags = local.common_tags
}
 
# ALB용 서울 리전 인증서
resource "aws_acm_certificate" "ssuport_prod_certificate_seoul" {
  provider          = aws.ap_northeast_2
  domain_name       = local.ssuport_prod_domain_name
  validation_method = "DNS"
  lifecycle {
    create_before_destroy = true
  }
  subject_alternative_names = ["*.${local.ssuport_prod_domain_name}"]
  tags = merge(local.common_tags, { Name = "ssuport-prod-cert-seoul" })
}

validation_method = "DNS" 옵션은 ACM이 도메인 소유권을 확인하는 방식을 DNS로 선택하는 것이다.


Step 2. 원격 상태 참조 (ssuport/prod/data.tf)

이제 서비스 계층의 인프라를 올릴 차례다. 여기서 핵심은 VPC를 새로 파는 것이 아니라, terraform_remote_state를 통해 global 계층이 만들어낸 공용 네트워크 상태 값을 그대로 읽어온다는 점이다.

ssuport/prod/data.tf
data "terraform_remote_state" "itsupport_global" {
  backend = "s3"
  config = {
    bucket = "terraform-state-xxxxxxxx"
    key    = "global/terraform.tfstate"
    region = "ap-northeast-2"
  }
}
 
# 글로벌 공용 ALB도 참조
data "aws_lb" "itsupport_prod_alb" {
  name = "shared-prod-alb"
}

Step 3. 프론트엔드 배포: S3 + CloudFront

생성한 버킷을 오리진으로 하는 CloudFront 배포를 만들고, 앞서 발급한 us-east-1 인증서를 부착한다. 이때 **OAC(Origin Access Control)**를 설정하여 외부에서 S3 URL로 직접 접근하는 것을 원천 차단하고, 오직 CloudFront를 통해서만 웹사이트가 서빙되도록 보안을 강화한다.

S3와 CloudFront를 이용한 프론트엔드 배포 구성S3와 CloudFront를 이용한 프론트엔드 배포 구성

ssuport/prod/s3.tf
# S3 버킷 및 OAC 정책 설정 예시
resource "aws_s3_bucket" "ssuport_prod_static" {
  bucket = local.prod_static_bucket_name
  # ... (태그 및 기타 설정 생략) ...
}
 
# CloudFront OAC만 S3 읽기 권한 허용
resource "aws_s3_bucket_policy" "ssuport_prod_policy" {
  bucket = aws_s3_bucket.ssuport_prod_static.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Sid       = "AllowCloudFrontServicePrincipalReadOnly"
      Effect    = "Allow"
      Principal = { Service = "cloudfront.amazonaws.com" }
      Action    = "s3:GetObject"
      Resource  = "${aws_s3_bucket.ssuport_prod_static.arn}/*"
      Condition = {
        StringEquals = {
          "AWS:SourceArn" = aws_cloudfront_distribution.ssuport_apply_prod_distribution.arn
        }
      }
    }]
  })
}

Step 4. 백엔드 구성: Private EC2와 Public ALB의 활용

ssuport/prod/ec2.tf
resource "aws_instance" "ssuport_prod_application_instance" {
  ami           = "ami-xxxxxxxxxxxxxxxxx" # 실제 AMI ID 마스킹
  instance_type = "t4g.small"
 
  # 프라이빗 서브넷 배치 (원격 상태 참조)
  subnet_id = data.terraform_remote_state.itsupport_global.outputs.itsupport_production_subnet_6_id
 
  associate_public_ip_address = false
  iam_instance_profile        = data.terraform_remote_state.itsupport_global.outputs.prod_ec2_role_profile_name
  # ...
}

Shared ALB (비용 최적화): 각 서비스마다 ALB를 새로 생성하지 않는다. Step 2에서 data로 불러온 글로벌 공용 ALB를 재사용한다.

ssuport/prod/alb.tf
# 1. 타깃 그룹 생성 및 헬스체크 설정
resource "aws_lb_target_group" "ssuport_prod_tg" {
  name     = "ssuport-prod-tg"
  port     = 8080
  protocol = "HTTP"
  vpc_id   = data.terraform_remote_state.itsupport_global.outputs.itsupport_prod_vpc_id
 
  health_check {
    path                = "/actuator/health"
    healthy_threshold   = 3
    unhealthy_threshold = 2
    timeout             = 5
    interval            = 15
    matcher             = "200"
  }
 
  tags = merge(local.common_tags, {
    Name = "ssuport-prod-tg"
  })
}
 
# 2. 생성한 타깃 그룹에 EC2 인스턴스 연결
resource "aws_lb_target_group_attachment" "ssuport_prod_attachment" {
  target_group_arn = aws_lb_target_group.ssuport_prod_tg.arn
  target_id        = aws_instance.ssuport_prod_application_instance.id
  port             = 8080
}

생성한 프라이빗 EC2를 타깃 그룹으로 묶어 ALB에 연결하고, /actuator/health 경로로 헬스체크를 설정한다.


Step 5. 최종 연결: Route53 레코드 매핑

모든 리소스와 규칙 세팅이 끝났으니, 사용자가 도메인을 입력했을 때 찾아갈 이정표를 세운다. Route53 설정 파일에서 프론트엔드와 백엔드 서브도메인 각각에 대해, 미리 만들어둔 CloudFront 배포 / ALB 주소로 A Record(Alias)를 매핑한다.

ssuport/prod/route53.tf
resource "aws_route53_record" "ssuport_apply" {
  zone_id = data.terraform_remote_state.itsupport_global.outputs.ssuport_prod_zone_id
  name    = local.domain_names["apply"]
  type    = "A"
 
  alias {
    name                   = aws_cloudfront_distribution.ssuport_apply_prod_distribution.domain_name
    zone_id                = aws_cloudfront_distribution.ssuport_apply_prod_distribution.hosted_zone_id
    evaluate_target_health = false
  }
}

일반 A Record는 도메인을 고정된 IP 주소 하나에 직접 매핑하는 방식이다. 하지만 CloudFront나 ALB의 엔드포인트는 AWS 내부적으로 IP가 유동적으로 바뀔 수 있기 때문에, 고정 IP를 직접 명시하는 대신 Alias를 사용해 AWS 리소스 자체(CloudFront 배포, ALB 등)를 가리키도록 구성한다. 이렇게 하면 IP가 바뀌어도 별도 갱신 없이 항상 올바른 대상으로 라우팅된다.


이로써 사용자 요청이 두 갈래로 안전하게 흐르는 무중단 인프라 구축이 완료된다.

  • 프론트엔드: 사용자 → CloudFront → S3(OAC로 보호됨)
  • 백엔드: 사용자 → ALB(Shared) → Private EC2