Skip to content
Published on

Terraform IaC 완전 가이드 2025: AWS/GCP 인프라를 코드로 관리하는 모든 것

Authors

들어가며

인프라를 수동으로 관리하는 시대는 끝났습니다. 클라우드 리소스가 수십, 수백 개로 늘어나면 콘솔 클릭으로는 재현성, 감사 추적, 협업이 불가능합니다. Terraform은 HashiCorp가 만든 오픈소스 IaC(Infrastructure as Code) 도구로, AWS, GCP, Azure를 비롯한 3,000개 이상의 프로바이더를 지원합니다.

이 가이드에서는 Terraform의 기초부터 실전까지 모든 것을 다룹니다. HCL 문법, State 관리, 모듈 설계, AWS/GCP 실전 인프라, Terragrunt, CI/CD 자동화, 보안 스캐닝, 비용 추정까지 — 프로덕션 레벨의 IaC를 구축하는 데 필요한 모든 지식을 체계적으로 정리합니다.


1. 왜 IaC가 중요한가

1.1 수동 관리 vs IaC

관점수동 (콘솔)IaC (Terraform)
재현성불가능코드로 100% 재현
감사 추적제한적Git 히스토리
협업불가능PR 리뷰
환경 복제수 시간수 분
롤백수동 복구terraform apply
문서화별도 관리코드가 문서

1.2 Terraform vs 다른 IaC 도구

도구언어클라우드상태 관리학습 곡선
TerraformHCL멀티 클라우드State 파일중간
CloudFormationJSON/YAMLAWS 전용스택낮음
PulumiPython/TS/Go멀티 클라우드State 파일높음
AWS CDKPython/TS/GoAWS 전용CloudFormation높음
OpenTofuHCL멀티 클라우드State 파일중간

Terraform을 선택하는 이유:

  • 멀티 클라우드 지원 (AWS + GCP + Azure)
  • 가장 큰 커뮤니티와 모듈 레지스트리
  • 선언적 구문으로 의도 명확
  • Plan -> Apply 워크플로로 안전한 변경

2. Terraform 기초

2.1 HCL 기본 문법

# 프로바이더 설정
terraform {
  required_version = ">= 1.7.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.40"
    }
  }

  backend "s3" {
    bucket         = "my-terraform-state"
    key            = "prod/terraform.tfstate"
    region         = "ap-northeast-2"
    dynamodb_table = "terraform-locks"
    encrypt        = true
  }
}

provider "aws" {
  region = var.aws_region

  default_tags {
    tags = {
      Environment = var.environment
      ManagedBy   = "terraform"
      Project     = var.project_name
    }
  }
}

2.2 변수 (Variables)

# variables.tf
variable "aws_region" {
  description = "AWS region to deploy resources"
  type        = string
  default     = "ap-northeast-2"
}

variable "environment" {
  description = "Environment name (dev, staging, prod)"
  type        = string

  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "Environment must be dev, staging, or prod."
  }
}

variable "instance_config" {
  description = "EC2 instance configuration"
  type = object({
    instance_type     = string
    volume_size       = number
    enable_monitoring = bool
  })
  default = {
    instance_type     = "t3.medium"
    volume_size       = 50
    enable_monitoring = true
  }
}

variable "allowed_cidrs" {
  description = "List of CIDR blocks allowed for ingress"
  type        = list(string)
  default     = ["10.0.0.0/8"]
}

variable "tags" {
  description = "Additional tags"
  type        = map(string)
  default     = {}
}

2.3 리소스 (Resources)

# main.tf
resource "aws_vpc" "main" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_support   = true
  enable_dns_hostnames = true

  tags = {
    Name = "main-vpc-${var.environment}"
  }
}

resource "aws_subnet" "public" {
  count = length(var.availability_zones)

  vpc_id            = aws_vpc.main.id
  cidr_block        = cidrsubnet(aws_vpc.main.cidr_block, 8, count.index)
  availability_zone = var.availability_zones[count.index]

  map_public_ip_on_launch = true

  tags = {
    Name = "public-subnet-${count.index + 1}"
    Type = "public"
  }
}

2.4 데이터 소스 (Data Sources)

# 최신 Amazon Linux 2023 AMI 조회
data "aws_ami" "amazon_linux" {
  most_recent = true
  owners      = ["amazon"]

  filter {
    name   = "name"
    values = ["al2023-ami-*-x86_64"]
  }

  filter {
    name   = "virtualization-type"
    values = ["hvm"]
  }
}

# 현재 AWS 계정 정보
data "aws_caller_identity" "current" {}

# 현재 리전
data "aws_region" "current" {}

2.5 출력값 (Outputs)

# outputs.tf
output "vpc_id" {
  description = "VPC ID"
  value       = aws_vpc.main.id
}

output "public_subnet_ids" {
  description = "Public subnet IDs"
  value       = aws_subnet.public[*].id
}

output "account_id" {
  description = "AWS Account ID"
  value       = data.aws_caller_identity.current.account_id
  sensitive   = false
}

output "db_password" {
  description = "Database password"
  value       = random_password.db.result
  sensitive   = true
}

2.6 Locals

locals {
  name_prefix = "${var.project_name}-${var.environment}"

  common_tags = merge(var.tags, {
    Environment = var.environment
    ManagedBy   = "terraform"
    Project     = var.project_name
  })

  env_config = {
    dev = {
      instance_type = "t3.small"
      min_size      = 1
      max_size      = 2
    }
    staging = {
      instance_type = "t3.medium"
      min_size      = 2
      max_size      = 4
    }
    prod = {
      instance_type = "t3.large"
      min_size      = 3
      max_size      = 10
    }
  }

  config = local.env_config[var.environment]
}

3. State 관리

3.1 State란?

Terraform State는 실제 인프라와 코드 간의 매핑 정보를 저장합니다. terraform.tfstate 파일에 모든 리소스의 현재 상태가 JSON으로 기록됩니다.

State가 없으면:

  • Terraform이 어떤 리소스를 관리하는지 알 수 없음
  • Plan에서 모든 리소스를 새로 생성하려고 함
  • 리소스 간 의존성 추적 불가

3.2 원격 State (S3 + DynamoDB)

# backend.tf
terraform {
  backend "s3" {
    bucket         = "mycompany-terraform-state"
    key            = "environments/prod/terraform.tfstate"
    region         = "ap-northeast-2"
    dynamodb_table = "terraform-locks"
    encrypt        = true
  }
}

S3 버킷과 DynamoDB 테이블 생성 (부트스트랩):

# bootstrap/main.tf
resource "aws_s3_bucket" "terraform_state" {
  bucket = "mycompany-terraform-state"

  lifecycle {
    prevent_destroy = true
  }
}

resource "aws_s3_bucket_versioning" "terraform_state" {
  bucket = aws_s3_bucket.terraform_state.id
  versioning_configuration {
    status = "Enabled"
  }
}

resource "aws_s3_bucket_server_side_encryption_configuration" "terraform_state" {
  bucket = aws_s3_bucket.terraform_state.id

  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm = "aws:kms"
    }
  }
}

resource "aws_dynamodb_table" "terraform_locks" {
  name         = "terraform-locks"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "LockID"

  attribute {
    name = "LockID"
    type = "S"
  }
}

3.3 State 명령어

# State 목록 확인
terraform state list

# 특정 리소스 상세 정보
terraform state show aws_vpc.main

# 리소스 이름 변경 (코드 리팩터링 시)
terraform state mv aws_instance.old aws_instance.new

# State에서 리소스 제거 (실제 인프라는 유지)
terraform state rm aws_instance.legacy

# State 가져오기
terraform state pull > backup.tfstate

4. 모듈 (Modules)

4.1 모듈 생성

modules/
  vpc/
    main.tf
    variables.tf
    outputs.tf
  ec2/
    main.tf
    variables.tf
    outputs.tf
  rds/
    main.tf
    variables.tf
    outputs.tf
# modules/vpc/main.tf
resource "aws_vpc" "this" {
  cidr_block           = var.cidr_block
  enable_dns_support   = true
  enable_dns_hostnames = true

  tags = merge(var.tags, {
    Name = "${var.name_prefix}-vpc"
  })
}

resource "aws_subnet" "public" {
  count = length(var.public_subnet_cidrs)

  vpc_id                  = aws_vpc.this.id
  cidr_block              = var.public_subnet_cidrs[count.index]
  availability_zone       = var.availability_zones[count.index]
  map_public_ip_on_launch = true

  tags = merge(var.tags, {
    Name = "${var.name_prefix}-public-${count.index + 1}"
    Type = "public"
  })
}

resource "aws_subnet" "private" {
  count = length(var.private_subnet_cidrs)

  vpc_id            = aws_vpc.this.id
  cidr_block        = var.private_subnet_cidrs[count.index]
  availability_zone = var.availability_zones[count.index]

  tags = merge(var.tags, {
    Name = "${var.name_prefix}-private-${count.index + 1}"
    Type = "private"
  })
}

resource "aws_internet_gateway" "this" {
  vpc_id = aws_vpc.this.id

  tags = merge(var.tags, {
    Name = "${var.name_prefix}-igw"
  })
}

resource "aws_nat_gateway" "this" {
  count = var.enable_nat_gateway ? length(var.public_subnet_cidrs) : 0

  allocation_id = aws_eip.nat[count.index].id
  subnet_id     = aws_subnet.public[count.index].id

  tags = merge(var.tags, {
    Name = "${var.name_prefix}-nat-${count.index + 1}"
  })
}

resource "aws_eip" "nat" {
  count  = var.enable_nat_gateway ? length(var.public_subnet_cidrs) : 0
  domain = "vpc"

  tags = merge(var.tags, {
    Name = "${var.name_prefix}-eip-${count.index + 1}"
  })
}
# modules/vpc/variables.tf
variable "name_prefix" {
  description = "Name prefix for resources"
  type        = string
}

variable "cidr_block" {
  description = "VPC CIDR block"
  type        = string
  default     = "10.0.0.0/16"
}

variable "public_subnet_cidrs" {
  description = "Public subnet CIDR blocks"
  type        = list(string)
}

variable "private_subnet_cidrs" {
  description = "Private subnet CIDR blocks"
  type        = list(string)
}

variable "availability_zones" {
  description = "Availability zones"
  type        = list(string)
}

variable "enable_nat_gateway" {
  description = "Enable NAT Gateway"
  type        = bool
  default     = true
}

variable "tags" {
  description = "Additional tags"
  type        = map(string)
  default     = {}
}
# modules/vpc/outputs.tf
output "vpc_id" {
  value = aws_vpc.this.id
}

output "public_subnet_ids" {
  value = aws_subnet.public[*].id
}

output "private_subnet_ids" {
  value = aws_subnet.private[*].id
}

4.2 모듈 사용

# environments/prod/main.tf
module "vpc" {
  source = "../../modules/vpc"

  name_prefix = "prod"
  cidr_block  = "10.0.0.0/16"

  public_subnet_cidrs  = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
  private_subnet_cidrs = ["10.0.11.0/24", "10.0.12.0/24", "10.0.13.0/24"]
  availability_zones   = ["ap-northeast-2a", "ap-northeast-2b", "ap-northeast-2c"]

  enable_nat_gateway = true

  tags = local.common_tags
}

module "rds" {
  source = "../../modules/rds"

  name_prefix       = "prod"
  vpc_id            = module.vpc.vpc_id
  subnet_ids        = module.vpc.private_subnet_ids
  instance_class    = "db.r6g.large"
  engine_version    = "15.5"
  allocated_storage = 100

  tags = local.common_tags
}

4.3 Terraform Registry 모듈 사용

# 공식 AWS VPC 모듈 사용
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "5.5.0"

  name = "my-vpc"
  cidr = "10.0.0.0/16"

  azs             = ["ap-northeast-2a", "ap-northeast-2b", "ap-northeast-2c"]
  private_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
  public_subnets  = ["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"]

  enable_nat_gateway = true
  single_nat_gateway = false

  tags = local.common_tags
}

5. AWS 실전 인프라

5.1 VPC + EC2 + ALB + RDS

# EC2 인스턴스 + Auto Scaling Group
resource "aws_launch_template" "app" {
  name_prefix   = "${local.name_prefix}-app-"
  image_id      = data.aws_ami.amazon_linux.id
  instance_type = local.config.instance_type

  vpc_security_group_ids = [aws_security_group.app.id]

  user_data = base64encode(templatefile("userdata.sh", {
    environment = var.environment
    db_host     = aws_db_instance.main.endpoint
  }))

  iam_instance_profile {
    name = aws_iam_instance_profile.app.name
  }

  block_device_mappings {
    device_name = "/dev/xvda"
    ebs {
      volume_size = var.instance_config.volume_size
      volume_type = "gp3"
      encrypted   = true
    }
  }

  tag_specifications {
    resource_type = "instance"
    tags = merge(local.common_tags, {
      Name = "${local.name_prefix}-app"
    })
  }
}

resource "aws_autoscaling_group" "app" {
  name                = "${local.name_prefix}-app-asg"
  desired_capacity    = local.config.min_size
  min_size            = local.config.min_size
  max_size            = local.config.max_size
  vpc_zone_identifier = module.vpc.private_subnet_ids
  target_group_arns   = [aws_lb_target_group.app.arn]

  launch_template {
    id      = aws_launch_template.app.id
    version = "$Latest"
  }

  health_check_type         = "ELB"
  health_check_grace_period = 300

  tag {
    key                 = "Name"
    value               = "${local.name_prefix}-app"
    propagate_at_launch = true
  }
}

5.2 ALB 설정

resource "aws_lb" "app" {
  name               = "${local.name_prefix}-alb"
  internal           = false
  load_balancer_type = "application"
  security_groups    = [aws_security_group.alb.id]
  subnets            = module.vpc.public_subnet_ids

  enable_deletion_protection = var.environment == "prod"

  tags = local.common_tags
}

resource "aws_lb_target_group" "app" {
  name     = "${local.name_prefix}-app-tg"
  port     = 8080
  protocol = "HTTP"
  vpc_id   = module.vpc.vpc_id

  health_check {
    path                = "/actuator/health"
    port                = "traffic-port"
    healthy_threshold   = 3
    unhealthy_threshold = 3
    timeout             = 5
    interval            = 30
    matcher             = "200"
  }

  deregistration_delay = 30

  tags = local.common_tags
}

resource "aws_lb_listener" "https" {
  load_balancer_arn = aws_lb.app.arn
  port              = 443
  protocol          = "HTTPS"
  ssl_policy        = "ELBSecurityPolicy-TLS13-1-2-2021-06"
  certificate_arn   = aws_acm_certificate.main.arn

  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.app.arn
  }
}

5.3 RDS (PostgreSQL)

resource "aws_db_instance" "main" {
  identifier = "${local.name_prefix}-db"

  engine         = "postgres"
  engine_version = "15.5"
  instance_class = "db.r6g.large"

  allocated_storage     = 100
  max_allocated_storage = 500
  storage_type          = "gp3"
  storage_encrypted     = true

  db_name  = "myapp"
  username = "admin"
  password = random_password.db.result

  multi_az               = var.environment == "prod"
  db_subnet_group_name   = aws_db_subnet_group.main.name
  vpc_security_group_ids = [aws_security_group.db.id]

  backup_retention_period = 7
  backup_window           = "03:00-04:00"
  maintenance_window      = "Mon:04:00-Mon:05:00"

  skip_final_snapshot       = var.environment != "prod"
  final_snapshot_identifier = "${local.name_prefix}-db-final"

  performance_insights_enabled = true

  tags = local.common_tags
}

resource "random_password" "db" {
  length  = 32
  special = false
}

5.4 S3 + IAM

resource "aws_s3_bucket" "assets" {
  bucket = "${local.name_prefix}-assets"
  tags   = local.common_tags
}

resource "aws_s3_bucket_versioning" "assets" {
  bucket = aws_s3_bucket.assets.id
  versioning_configuration {
    status = "Enabled"
  }
}

resource "aws_s3_bucket_public_access_block" "assets" {
  bucket = aws_s3_bucket.assets.id

  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

# IAM Role for EC2
resource "aws_iam_role" "app" {
  name = "${local.name_prefix}-app-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRole"
        Effect = "Allow"
        Principal = {
          Service = "ec2.amazonaws.com"
        }
      }
    ]
  })

  tags = local.common_tags
}

resource "aws_iam_role_policy" "app_s3" {
  name = "${local.name_prefix}-app-s3-policy"
  role = aws_iam_role.app.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = [
          "s3:GetObject",
          "s3:PutObject",
          "s3:ListBucket"
        ]
        Resource = [
          aws_s3_bucket.assets.arn,
          "${aws_s3_bucket.assets.arn}/*"
        ]
      }
    ]
  })
}

6. GCP 실전 인프라

6.1 VPC + GKE

provider "google" {
  project = var.gcp_project_id
  region  = var.gcp_region
}

resource "google_compute_network" "main" {
  name                    = "${local.name_prefix}-vpc"
  auto_create_subnetworks = false
}

resource "google_compute_subnetwork" "main" {
  name          = "${local.name_prefix}-subnet"
  ip_cidr_range = "10.0.0.0/20"
  region        = var.gcp_region
  network       = google_compute_network.main.id

  secondary_ip_range {
    range_name    = "pods"
    ip_cidr_range = "10.1.0.0/16"
  }

  secondary_ip_range {
    range_name    = "services"
    ip_cidr_range = "10.2.0.0/20"
  }
}

resource "google_container_cluster" "primary" {
  name     = "${local.name_prefix}-gke"
  location = var.gcp_region

  remove_default_node_pool = true
  initial_node_count       = 1

  network    = google_compute_network.main.name
  subnetwork = google_compute_subnetwork.main.name

  ip_allocation_policy {
    cluster_secondary_range_name  = "pods"
    services_secondary_range_name = "services"
  }

  workload_identity_config {
    workload_pool = "${var.gcp_project_id}.svc.id.goog"
  }

  release_channel {
    channel = "REGULAR"
  }
}

resource "google_container_node_pool" "primary" {
  name       = "${local.name_prefix}-node-pool"
  location   = var.gcp_region
  cluster    = google_container_cluster.primary.name
  node_count = 3

  node_config {
    machine_type = "e2-standard-4"
    disk_size_gb = 100
    disk_type    = "pd-ssd"

    oauth_scopes = [
      "https://www.googleapis.com/auth/cloud-platform"
    ]

    labels = {
      environment = var.environment
    }
  }

  autoscaling {
    min_node_count = 3
    max_node_count = 10
  }
}

6.2 Cloud SQL + Cloud Run

resource "google_sql_database_instance" "main" {
  name             = "${local.name_prefix}-db"
  database_version = "POSTGRES_15"
  region           = var.gcp_region

  settings {
    tier = "db-custom-4-16384"

    ip_configuration {
      ipv4_enabled    = false
      private_network = google_compute_network.main.id
    }

    backup_configuration {
      enabled                        = true
      point_in_time_recovery_enabled = true
      start_time                     = "03:00"
    }

    availability_type = var.environment == "prod" ? "REGIONAL" : "ZONAL"
  }

  deletion_protection = var.environment == "prod"
}

resource "google_cloud_run_v2_service" "app" {
  name     = "${local.name_prefix}-app"
  location = var.gcp_region

  template {
    containers {
      image = "gcr.io/${var.gcp_project_id}/myapp:latest"

      ports {
        container_port = 8080
      }

      resources {
        limits = {
          cpu    = "2"
          memory = "1Gi"
        }
      }

      env {
        name  = "SPRING_PROFILES_ACTIVE"
        value = var.environment
      }

      startup_probe {
        http_get {
          path = "/actuator/health"
        }
        initial_delay_seconds = 10
        period_seconds        = 3
      }
    }

    scaling {
      min_instance_count = var.environment == "prod" ? 2 : 0
      max_instance_count = 10
    }
  }
}

7. 고급 패턴

7.1 Workspaces

# 워크스페이스 생성
terraform workspace new dev
terraform workspace new staging
terraform workspace new prod

# 워크스페이스 전환
terraform workspace select prod

# 현재 워크스페이스 확인
terraform workspace show
# 워크스페이스 기반 환경 분리
locals {
  env_config = {
    dev = {
      instance_type = "t3.small"
      db_instance   = "db.t3.medium"
    }
    staging = {
      instance_type = "t3.medium"
      db_instance   = "db.r6g.large"
    }
    prod = {
      instance_type = "t3.large"
      db_instance   = "db.r6g.xlarge"
    }
  }

  config = local.env_config[terraform.workspace]
}

7.2 Dynamic Blocks

resource "aws_security_group" "app" {
  name        = "${local.name_prefix}-app-sg"
  description = "Application security group"
  vpc_id      = module.vpc.vpc_id

  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_blocks
      description = ingress.value.description
    }
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = local.common_tags
}

variable "ingress_rules" {
  type = list(object({
    port        = number
    cidr_blocks = list(string)
    description = string
  }))
  default = [
    {
      port        = 8080
      cidr_blocks = ["10.0.0.0/8"]
      description = "Application port"
    },
    {
      port        = 443
      cidr_blocks = ["0.0.0.0/0"]
      description = "HTTPS"
    }
  ]
}

7.3 for_each vs count

# count -- 인덱스 기반 (삭제 시 재생성 문제)
resource "aws_subnet" "example" {
  count      = 3
  cidr_block = "10.0.${count.index}.0/24"
  vpc_id     = aws_vpc.main.id
}

# for_each -- 키 기반 (안전한 삭제/추가)
resource "aws_subnet" "example" {
  for_each = {
    "public-a"  = { cidr = "10.0.1.0/24", az = "ap-northeast-2a" }
    "public-b"  = { cidr = "10.0.2.0/24", az = "ap-northeast-2b" }
    "private-a" = { cidr = "10.0.11.0/24", az = "ap-northeast-2a" }
  }

  cidr_block        = each.value.cidr
  availability_zone = each.value.az
  vpc_id            = aws_vpc.main.id

  tags = {
    Name = "${local.name_prefix}-${each.key}"
  }
}

7.4 Lifecycle 규칙

resource "aws_instance" "app" {
  ami           = data.aws_ami.amazon_linux.id
  instance_type = local.config.instance_type

  lifecycle {
    create_before_destroy = true
    prevent_destroy       = false
    ignore_changes        = [ami]
  }
}

# moved 블록 (리소스 이름 변경 시)
moved {
  from = aws_instance.old_name
  to   = aws_instance.new_name
}

8. Import & 마이그레이션

8.1 terraform import 명령어

# 기존 리소스 임포트
terraform import aws_vpc.main vpc-0123456789abcdef0
terraform import aws_instance.web i-0123456789abcdef0
terraform import 'aws_subnet.public[0]' subnet-0123456789abcdef0

8.2 Import Block (Terraform 1.5+)

# import.tf -- 선언적 임포트
import {
  to = aws_vpc.main
  id = "vpc-0123456789abcdef0"
}

import {
  to = aws_instance.web
  id = "i-0123456789abcdef0"
}
# 코드 자동 생성
terraform plan -generate-config-out=generated.tf

# 생성된 코드 검토 후 적용
terraform apply

8.3 기존 인프라 마이그레이션 전략

단계별 접근:

  1. 인벤토리 작성 — 현재 리소스 목록화
  2. 코드 작성 — 리소스별 HCL 코드 작성
  3. Import — 기존 리소스를 State에 가져오기
  4. Plan 검증terraform plan으로 변경사항 없음 확인
  5. 점진적 관리 — 새 리소스부터 Terraform으로 생성

9. Terragrunt

9.1 DRY 환경 관리

infrastructure/
  terragrunt.hcl
  environments/
    dev/
      terragrunt.hcl
      vpc/
        terragrunt.hcl
      rds/
        terragrunt.hcl
    prod/
      terragrunt.hcl
      vpc/
        terragrunt.hcl
      rds/
        terragrunt.hcl
  modules/
    vpc/
    rds/
# infrastructure/terragrunt.hcl (루트)
remote_state {
  backend = "s3"
  generate = {
    path      = "backend.tf"
    if_exists = "overwrite"
  }
  config = {
    bucket         = "mycompany-terraform-state"
    key            = "${path_relative_to_include()}/terraform.tfstate"
    region         = "ap-northeast-2"
    dynamodb_table = "terraform-locks"
    encrypt        = true
  }
}

generate "provider" {
  path      = "provider.tf"
  if_exists = "overwrite"
  contents  = <<EOF
provider "aws" {
  region = "ap-northeast-2"
}
EOF
}
# infrastructure/environments/prod/vpc/terragrunt.hcl
include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "../../../modules/vpc"
}

inputs = {
  name_prefix          = "prod"
  cidr_block           = "10.0.0.0/16"
  public_subnet_cidrs  = ["10.0.1.0/24", "10.0.2.0/24"]
  private_subnet_cidrs = ["10.0.11.0/24", "10.0.12.0/24"]
  availability_zones   = ["ap-northeast-2a", "ap-northeast-2b"]
  enable_nat_gateway   = true
}

9.2 의존성 관리

# infrastructure/environments/prod/rds/terragrunt.hcl
include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "../../../modules/rds"
}

dependency "vpc" {
  config_path = "../vpc"
}

inputs = {
  name_prefix = "prod"
  vpc_id      = dependency.vpc.outputs.vpc_id
  subnet_ids  = dependency.vpc.outputs.private_subnet_ids
}

10. CI/CD 자동화

10.1 GitHub Actions + Terraform

# .github/workflows/terraform.yml
name: Terraform CI/CD

on:
  pull_request:
    paths:
      - 'infrastructure/**'
  push:
    branches:
      - main
    paths:
      - 'infrastructure/**'

permissions:
  id-token: write
  contents: read
  pull-requests: write

jobs:
  plan:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/terraform-ci
          aws-region: ap-northeast-2

      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: 1.7.0

      - name: Terraform Init
        working-directory: infrastructure/environments/prod
        run: terraform init

      - name: Terraform Plan
        working-directory: infrastructure/environments/prod
        run: terraform plan -no-color -out=tfplan

  apply:
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4

      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/terraform-ci
          aws-region: ap-northeast-2

      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: 1.7.0

      - name: Terraform Init
        working-directory: infrastructure/environments/prod
        run: terraform init

      - name: Terraform Apply
        working-directory: infrastructure/environments/prod
        run: terraform apply -auto-approve

10.2 Atlantis

# atlantis.yaml
version: 3
projects:
  - name: prod-infra
    dir: infrastructure/environments/prod
    workspace: default
    terraform_version: v1.7.0
    autoplan:
      when_modified:
        - "*.tf"
        - "../../modules/**/*.tf"
      enabled: true
    apply_requirements:
      - approved
      - mergeable

11. 비용 관리 (Infracost)

11.1 Infracost 통합

# Infracost 실행
infracost breakdown --path=infrastructure/environments/prod

# JSON 출력
infracost breakdown \
  --path=infrastructure/environments/prod \
  --format=json \
  --out-file=infracost.json

# PR에 비용 코멘트
infracost comment github \
  --path=infracost.json \
  --repo=myorg/myrepo \
  --pull-request=42 \
  --github-token=GH_TOKEN_VALUE

11.2 태깅 전략

# 비용 추적을 위한 필수 태그
locals {
  required_tags = {
    Environment = var.environment
    Project     = var.project_name
    Team        = var.team_name
    CostCenter  = var.cost_center
    ManagedBy   = "terraform"
  }
}

12. 보안 (tfsec, Checkov)

12.1 tfsec 정적 분석

# tfsec 실행
tfsec ./infrastructure/

# 특정 규칙 무시
tfsec ./infrastructure/ --exclude-downloaded-modules
# tfsec 규칙 무시 (정당한 사유가 있을 때만)
resource "aws_s3_bucket" "logs" {
  bucket = "my-access-logs"

  #tfsec:ignore:aws-s3-enable-versioning -- 로그 버킷은 버저닝 불필요
}

12.2 Checkov

# Checkov 실행
checkov -d ./infrastructure/ --framework terraform

# CI/CD에서 실행
checkov -d ./infrastructure/ \
  --output junitxml \
  --output-file-path ./results

12.3 Secrets 관리 (Vault)

# HashiCorp Vault에서 시크릿 조회
data "vault_generic_secret" "db" {
  path = "secret/data/myapp/database"
}

resource "aws_db_instance" "main" {
  username = data.vault_generic_secret.db.data["username"]
  password = data.vault_generic_secret.db.data["password"]
}

13. Terraform 자격증 가이드

HashiCorp Certified: Terraform Associate (003)

시험 개요:

  • 시간: 60분
  • 문항: 57문항 (객관식 + 빈칸 채우기)
  • 합격 기준: 70%
  • 유효 기간: 2년
  • 비용: 약 70 USD

핵심 도메인:

  1. IaC 개념 이해 (15%) — IaC 장점, Terraform 워크플로
  2. Terraform 기초 (20%) — 프로바이더, 리소스, 변수, 출력
  3. State 관리 (15%) — 원격 State, 잠금, 명령어
  4. 모듈 (15%) — 모듈 생성, 사용, 레지스트리
  5. Terraform CLI (15%) — init, plan, apply, destroy, import
  6. 워크플로 (10%) — 팀 워크플로, Terraform Cloud
  7. 구현 및 유지보수 (10%) — 디버깅, 업그레이드

학습 리소스:

  • HashiCorp Learn 공식 튜토리얼
  • Terraform Up and Running (O'Reilly)
  • 공식 시험 리뷰 가이드

14. 인터뷰 질문 15선

기본 (1-5)

Q1. Terraform의 핵심 워크플로를 설명하세요.

Write(코드 작성) -> Plan(변경사항 미리보기) -> Apply(적용)의 3단계입니다. terraform plan은 실제 변경 없이 무엇이 바뀔지 보여주고, terraform apply가 실제 인프라를 변경합니다.

Q2. Terraform State의 역할과 중요성은?

State는 실제 인프라와 코드 간의 매핑을 저장합니다. State가 없으면 Terraform은 어떤 리소스를 관리하는지 알 수 없습니다. 원격 백엔드(S3+DynamoDB)를 사용하여 팀 공유와 잠금을 구현해야 합니다.

Q3. Provider와 Resource의 차이는?

Provider는 클라우드/서비스와의 API 연결을 담당하는 플러그인(AWS, GCP 등)이고, Resource는 Provider를 통해 관리하는 실제 인프라 객체(VPC, EC2 등)입니다.

Q4. terraform plan과 terraform apply의 차이는?

plan은 변경사항을 미리보기로 보여주며 실제 인프라를 변경하지 않습니다. apply는 실제로 인프라를 변경합니다. apply는 내부적으로 plan을 먼저 실행합니다.

Q5. 변수의 우선순위(Variable Precedence)를 설명하세요.

환경변수 TF_VAR_xxx, terraform.tfvars, terraform.tfvars.json, auto.tfvars, -var 또는 -var-file 명령줄 옵션 순으로 우선순위가 높아집니다.

심화 (6-10)

Q6. count와 for_each의 차이와 언제 어떤 것을 사용해야 하나요?

count는 인덱스 기반으로 중간 요소 삭제 시 뒤의 모든 리소스가 재생성됩니다. for_each는 키 기반으로 특정 요소만 안전하게 삭제/추가 가능합니다. 일반적으로 for_each를 권장합니다.

Q7. State Locking이 필요한 이유는?

두 명이 동시에 terraform apply를 실행하면 State가 충돌하여 인프라가 손상될 수 있습니다. DynamoDB 잠금 테이블로 동시 실행을 방지합니다.

Q8. 모듈(Module)의 장점과 설계 원칙은?

재사용성, DRY 원칙, 캡슐화가 장점입니다. 단일 책임 원칙으로 설계하고, 변수로 설정을 외부화하며, 출력값으로 다른 모듈과 연결합니다.

Q9. terraform import와 Import Block의 차이는?

terraform import는 명령줄에서 한 번에 하나의 리소스만 가져오며, 코드를 자동 생성하지 않습니다. Import Block(1.5+)은 선언적으로 여러 리소스를 한 번에 가져올 수 있고, -generate-config-out 옵션으로 코드 자동 생성이 가능합니다.

Q10. Terraform에서 시크릿을 안전하게 관리하는 방법은?

State 파일에 민감 정보가 저장되므로 원격 백엔드 암호화 필수, sensitive = true 변수 사용, HashiCorp Vault나 AWS Secrets Manager 연동, .tfvars 파일은 .gitignore에 추가, 환경변수로 주입합니다.

실전 (11-15)

Q11. Drift Detection이란 무엇이고 어떻게 처리하나요?

Drift는 실제 인프라가 Terraform 코드와 다른 상태입니다. terraform plan으로 감지하고, terraform apply로 코드 상태로 되돌리거나 코드를 수정합니다. 정기적인 plan 실행으로 드리프트를 모니터링해야 합니다.

Q12. Terragrunt를 사용하는 이유는?

Terraform의 DRY 원칙 부족을 보완합니다. 환경별 설정을 상속 구조로 관리하고, 원격 State 설정을 자동화하며, 모듈 간 의존성을 관리합니다.

Q13. Terraform CI/CD 파이프라인의 모범 사례는?

PR에서 plan 실행 후 결과를 코멘트로 게시, main 브랜치 merge 시 apply 실행, OIDC로 AWS 인증(시크릿 키 불필요), terraform fmt + tfsec으로 코드 품질 검증, 환경별 승인 프로세스(GitHub Environments) 적용합니다.

Q14. Terraform과 CloudFormation을 함께 사용해야 하는 경우는?

AWS 전용이고 CloudFormation StackSets, Service Catalog 등 AWS 네이티브 기능이 필요한 경우 CFN을, 멀티 클라우드나 SaaS 리소스 관리가 필요한 경우 Terraform을 사용합니다.

Q15. Infracost로 비용 관리하는 방법은?

PR에서 인프라 변경의 비용 영향을 자동으로 추정하여 코멘트로 보여줍니다. 월간 비용 증가/감소를 시각화하고, 팀이 비용을 인지한 상태에서 인프라 변경을 승인합니다.


15. 퀴즈

Q1. Terraform State의 원격 백엔드로 S3를 사용할 때 State Locking에 필요한 AWS 서비스는?

DynamoDB입니다. S3는 State 파일 저장, DynamoDB는 잠금(Lock) 테이블로 동시 실행을 방지합니다. dynamodb_table 속성으로 설정합니다.

Q2. for_each가 count보다 권장되는 이유는?

count는 인덱스 기반이라 중간 요소를 제거하면 그 뒤의 모든 리소스가 재생성됩니다. for_each는 키 기반이라 특정 요소만 안전하게 추가/삭제할 수 있습니다.

Q3. terraform plan -generate-config-out 옵션의 용도는?

Import Block으로 선언한 리소스의 HCL 코드를 자동 생성합니다. 기존 인프라를 Terraform으로 마이그레이션할 때 코드를 처음부터 작성할 필요 없이 자동 생성된 코드를 기반으로 정리하면 됩니다.

Q4. Terragrunt의 dependency 블록의 역할은?

모듈 간의 실행 순서와 데이터 전달을 관리합니다. 예를 들어 RDS 모듈이 VPC 모듈의 output(subnet_ids)에 의존하면, Terragrunt가 VPC를 먼저 적용하고 그 output을 RDS 모듈에 전달합니다.

Q5. tfsec와 Checkov의 차이점은?

tfsec는 Terraform 전용 보안 스캐너로 HCL 코드의 보안 취약점을 검사합니다. Checkov는 Terraform뿐 아니라 CloudFormation, Kubernetes, Docker 등 다양한 IaC 도구를 지원하는 범용 정책 검사 도구입니다.


참고 자료

  1. Terraform 공식 문서
  2. Terraform Registry
  3. Terraform AWS Provider
  4. Terraform GCP Provider
  5. Terragrunt 공식 문서
  6. Infracost 문서
  7. tfsec 가이드
  8. Checkov 문서
  9. HashiCorp Learn Terraform
  10. Terraform Up and Running (O'Reilly)
  11. AWS VPC Terraform Module
  12. Terraform Best Practices
  13. Atlantis 문서
  14. OpenTofu
  15. HashiCorp Vault Terraform Provider
  16. Terraform Associate 시험 가이드
  17. AWS IAM Terraform 모범 사례
  18. GKE Terraform 모듈