Skip to content
Published on

IaC 패턴 & 베스트 프랙티스 2025: Terraform 모듈, Pulumi, Crossplane, 상태 관리

Authors

목차

1. IaC 랜드스케이프 2025: 도구 비교와 선택 기준

Infrastructure as Code(IaC)는 인프라를 코드로 정의하고 버전 관리하는 핵심 DevOps 실천법이다. 2025년 현재 IaC 생태계는 다양한 도구가 공존하며, 각각 고유한 강점을 가지고 있다.

1.1 주요 IaC 도구 비교

도구언어상태 관리클라우드 지원특징
Terraform/OpenTofuHCL원격 상태 파일멀티 클라우드가장 큰 생태계, 프로바이더 풍부
PulumiTS/Python/Go/C#Pulumi Cloud/자체 백엔드멀티 클라우드범용 프로그래밍 언어 사용
CDKTFTS/Python/Go/C#Terraform 백엔드멀티 클라우드CDK 구문 + Terraform 프로바이더
AWS CDKTS/Python/Go/C#CloudFormationAWS 전용AWS 네이티브, L2/L3 컨스트럭트
CrossplaneYAML(K8s CRD)K8s etcd멀티 클라우드K8s 네이티브, GitOps 친화
AnsibleYAML상태 없음(절차적)멀티 클라우드구성 관리 + 프로비저닝

1.2 선언적 vs 명령적 IaC

선언적(Declarative) IaC:
┌─────────────────────────────────────────────┐
│  "나는 EC2 인스턴스 3대를 원한다"              │
│  → 도구가 현재 상태와 비교하여 필요한 변경 계산  │
│  → Terraform, Pulumi, CloudFormation         │
└─────────────────────────────────────────────┘

명령적(Imperative) IaC:
┌─────────────────────────────────────────────┐
│  "EC2 인스턴스를 생성하라, 보안그룹을 연결하라" │
│  → 순서대로 명령을 실행                       │
│  → Ansible, Shell Scripts                    │
└─────────────────────────────────────────────┘

1.3 OpenTofu vs Terraform

2023년 HashiCorp의 라이센스 변경(BSL) 이후 OpenTofu가 Linux Foundation 프로젝트로 탄생했다.

# OpenTofu와 Terraform은 동일한 HCL 문법 사용
# opentofu init / terraform init 모두 동일하게 동작

terraform {
  required_version = ">= 1.6.0"

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

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

2. Terraform 모듈 설계 패턴

Terraform 모듈은 재사용 가능한 인프라 코드의 핵심 단위다. 올바른 모듈 설계는 조직 전체의 인프라 일관성과 생산성을 결정한다.

2.1 플랫 모듈 vs 중첩 모듈

플랫 모듈 구조 (권장):
modules/
├── vpc/
│   ├── main.tf
│   ├── variables.tf
│   ├── outputs.tf
│   └── versions.tf
├── ecs-cluster/
│   ├── main.tf
│   ├── variables.tf
│   ├── outputs.tf
│   └── versions.tf
└── rds/
    ├── main.tf
    ├── variables.tf
    ├── outputs.tf
    └── versions.tf

중첩 모듈 구조 (복잡성 증가):
modules/
└── platform/
    ├── main.tf          # vpc, ecs, rds 모듈 호출
    ├── modules/
    │   ├── vpc/
    │   ├── ecs-cluster/
    │   └── rds/
    └── outputs.tf

2.2 컴포지션 패턴

모듈 컴포지션은 작은 모듈을 조합하여 더 큰 인프라를 구성하는 패턴이다.

# environments/prod/main.tf
# 컴포지션 패턴: 작은 모듈을 조합

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

  name       = "prod-vpc"
  cidr_block = "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  # 프로덕션은 AZ별 NAT
}

module "ecs_cluster" {
  source = "../../modules/ecs-cluster"

  name       = "prod-cluster"
  vpc_id     = module.vpc.vpc_id
  subnet_ids = module.vpc.private_subnet_ids

  capacity_providers = ["FARGATE", "FARGATE_SPOT"]
}

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

  name               = "prod-db"
  engine             = "aurora-postgresql"
  engine_version     = "15.4"
  instance_class     = "db.r6g.xlarge"
  vpc_id             = module.vpc.vpc_id
  subnet_ids         = module.vpc.database_subnet_ids
  security_group_ids = [module.ecs_cluster.security_group_id]
}

2.3 팩토리 패턴

동일한 모듈을 여러 번 인스턴스화하는 패턴이다.

# 서비스 팩토리 패턴
variable "services" {
  type = map(object({
    cpu    = number
    memory = number
    port   = number
    count  = number
    health_check_path = string
  }))
  default = {
    "api-gateway" = {
      cpu    = 512
      memory = 1024
      port   = 8080
      count  = 3
      health_check_path = "/health"
    }
    "user-service" = {
      cpu    = 256
      memory = 512
      port   = 8081
      count  = 2
      health_check_path = "/actuator/health"
    }
    "order-service" = {
      cpu    = 512
      memory = 1024
      port   = 8082
      count  = 2
      health_check_path = "/health"
    }
  }
}

module "ecs_services" {
  source   = "../../modules/ecs-service"
  for_each = var.services

  name              = each.key
  cluster_id        = module.ecs_cluster.cluster_id
  cpu               = each.value.cpu
  memory            = each.value.memory
  container_port    = each.value.port
  desired_count     = each.value.count
  health_check_path = each.value.health_check_path
  subnet_ids        = module.vpc.private_subnet_ids
}

2.4 래퍼 모듈 패턴

커뮤니티 모듈을 감싸서 조직 표준을 강제하는 패턴이다.

# modules/org-s3-bucket/main.tf
# 조직 표준을 강제하는 래퍼 모듈

module "s3_bucket" {
  source  = "terraform-aws-modules/s3-bucket/aws"
  version = "~> 4.0"

  bucket = var.bucket_name

  # 조직 표준: 항상 암호화
  server_side_encryption_configuration = {
    rule = {
      apply_server_side_encryption_by_default = {
        sse_algorithm     = "aws:kms"
        kms_master_key_id = var.kms_key_id
      }
    }
  }

  # 조직 표준: 퍼블릭 접근 차단
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true

  # 조직 표준: 버전 관리 활성화
  versioning = {
    enabled = true
  }

  # 조직 표준: 태그
  tags = merge(var.tags, {
    ManagedBy   = "terraform"
    Team        = var.team
    Environment = var.environment
    CostCenter  = var.cost_center
  })
}

3. Terraform 베스트 프랙티스

3.1 원격 상태 관리

# 상태 관리 인프라 부트스트랩
# bootstrap/main.tf

resource "aws_s3_bucket" "terraform_state" {
  bucket = "myorg-terraform-state-${data.aws_caller_identity.current.account_id}"

  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"
  }

  tags = {
    Name      = "Terraform State Lock Table"
    ManagedBy = "terraform"
  }
}

3.2 워크스페이스 vs 디렉토리 전략

디렉토리 전략 (권장):
infrastructure/
├── modules/           # 재사용 모듈
│   ├── vpc/
│   ├── ecs/
│   └── rds/
├── environments/
│   ├── dev/
│   │   ├── main.tf
│   │   ├── variables.tf
│   │   ├── terraform.tfvars
│   │   └── backend.tf    # dev 전용 상태 파일
│   ├── staging/
│   │   ├── main.tf
│   │   ├── variables.tf
│   │   ├── terraform.tfvars
│   │   └── backend.tf
│   └── prod/
│       ├── main.tf
│       ├── variables.tf
│       ├── terraform.tfvars
│       └── backend.tf
└── global/            # 공유 리소스 (IAM, Route53)
    ├── iam/
    └── dns/

워크스페이스 전략 (주의 필요):
- 동일 코드, 다른 상태 → 환경 간 차이가 커지면 관리 어려움
- terraform workspace select dev
- terraform workspace select prod
- 상태 파일이 같은 백엔드에 저장 → 권한 분리 어려움

3.3 프로바이더 버전 관리

# versions.tf
terraform {
  required_version = ">= 1.6.0, < 2.0.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.30"  # 5.30.x 허용, 6.0 불가
    }
    kubernetes = {
      source  = "hashicorp/kubernetes"
      version = ">= 2.24, < 3.0"
    }
    helm = {
      source  = "hashicorp/helm"
      version = "~> 2.12"
    }
  }
}

# .terraform.lock.hcl 파일은 반드시 Git에 커밋
# terraform init -upgrade 로 프로바이더 업데이트

3.4 변수 검증과 타입 안전성

variable "environment" {
  type        = string
  description = "배포 환경"

  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "environment는 dev, staging, prod 중 하나여야 합니다."
  }
}

variable "instance_type" {
  type        = string
  description = "EC2 인스턴스 타입"

  validation {
    condition     = can(regex("^(t3|m6i|c6i|r6i)\\.", var.instance_type))
    error_message = "허용된 인스턴스 패밀리: t3, m6i, c6i, r6i"
  }
}

variable "cidr_block" {
  type        = string
  description = "VPC CIDR 블록"

  validation {
    condition     = can(cidrhost(var.cidr_block, 0))
    error_message = "유효한 CIDR 블록을 입력하세요."
  }
}

# 복합 타입 변수
variable "scaling_config" {
  type = object({
    min_size     = number
    max_size     = number
    desired_size = number
  })

  validation {
    condition     = var.scaling_config.min_size <= var.scaling_config.desired_size
    error_message = "min_size는 desired_size보다 작거나 같아야 합니다."
  }

  validation {
    condition     = var.scaling_config.desired_size <= var.scaling_config.max_size
    error_message = "desired_size는 max_size보다 작거나 같아야 합니다."
  }
}

4. Pulumi 딥 다이브: 프로그래밍 언어로 IaC

Pulumi는 TypeScript, Python, Go, C# 등 범용 프로그래밍 언어로 인프라를 정의한다. 조건문, 반복문, 추상화 등 언어의 모든 기능을 활용할 수 있다.

4.1 Pulumi TypeScript 기본 구조

// index.ts
import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";

const config = new pulumi.Config();
const environment = config.require("environment");
const vpcCidr = config.get("vpcCidr") || "10.0.0.0/16";

// VPC 생성
const vpc = new aws.ec2.Vpc("main-vpc", {
  cidrBlock: vpcCidr,
  enableDnsHostnames: true,
  enableDnsSupport: true,
  tags: {
    Name: `${environment}-vpc`,
    Environment: environment,
    ManagedBy: "pulumi",
  },
});

// 퍼블릭 서브넷 (프로그래밍 언어의 반복문 활용)
const azs = ["ap-northeast-2a", "ap-northeast-2b", "ap-northeast-2c"];
const publicSubnets = azs.map((az, index) => {
  return new aws.ec2.Subnet(`public-subnet-${index}`, {
    vpcId: vpc.id,
    cidrBlock: `10.0.${index + 1}.0/24`,
    availabilityZone: az,
    mapPublicIpOnLaunch: true,
    tags: {
      Name: `${environment}-public-${az}`,
      Type: "public",
    },
  });
});

// 출력값
export const vpcId = vpc.id;
export const publicSubnetIds = publicSubnets.map(s => s.id);

4.2 Pulumi 컴포넌트 리소스

// components/ecs-service.ts
import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";

interface EcsServiceArgs {
  clusterArn: pulumi.Input<string>;
  vpcId: pulumi.Input<string>;
  subnetIds: pulumi.Input<string>[];
  containerImage: string;
  cpu: number;
  memory: number;
  port: number;
  desiredCount: number;
  healthCheckPath: string;
}

export class EcsService extends pulumi.ComponentResource {
  public readonly serviceUrl: pulumi.Output<string>;
  public readonly taskDefinitionArn: pulumi.Output<string>;

  constructor(
    name: string,
    args: EcsServiceArgs,
    opts?: pulumi.ComponentResourceOptions
  ) {
    super("custom:app:EcsService", name, {}, opts);

    const logGroup = new aws.cloudwatch.LogGroup(`${name}-logs`, {
      retentionInDays: 30,
      tags: { Service: name },
    }, { parent: this });

    const taskRole = new aws.iam.Role(`${name}-task-role`, {
      assumeRolePolicy: JSON.stringify({
        Version: "2012-10-17",
        Statement: [{
          Action: "sts:AssumeRole",
          Effect: "Allow",
          Principal: { Service: "ecs-tasks.amazonaws.com" },
        }],
      }),
    }, { parent: this });

    const taskDefinition = new aws.ecs.TaskDefinition(`${name}-task`, {
      family: name,
      cpu: args.cpu.toString(),
      memory: args.memory.toString(),
      networkMode: "awsvpc",
      requiresCompatibilities: ["FARGATE"],
      executionRoleArn: taskRole.arn,
      taskRoleArn: taskRole.arn,
      containerDefinitions: JSON.stringify([{
        name: name,
        image: args.containerImage,
        cpu: args.cpu,
        memory: args.memory,
        portMappings: [{ containerPort: args.port }],
        logConfiguration: {
          logDriver: "awslogs",
          options: {
            "awslogs-group": logGroup.name,
            "awslogs-region": "ap-northeast-2",
            "awslogs-stream-prefix": "ecs",
          },
        },
      }]),
    }, { parent: this });

    this.taskDefinitionArn = taskDefinition.arn;

    this.registerOutputs({
      taskDefinitionArn: this.taskDefinitionArn,
    });
  }
}

4.3 Pulumi 스택 레퍼런스

// 스택 간 데이터 공유
// infrastructure/index.ts 에서 VPC 정보 export
export const vpcId = vpc.id;
export const privateSubnetIds = privateSubnets.map(s => s.id);

// application/index.ts 에서 참조
const infraStack = new pulumi.StackReference("org/infrastructure/prod");
const vpcId = infraStack.getOutput("vpcId");
const subnetIds = infraStack.getOutput("privateSubnetIds");

4.4 Pulumi Policy as Code (CrossGuard)

// policy-pack/index.ts
import { PolicyPack, validateResourceOfType } from "@pulumi/policy";
import * as aws from "@pulumi/aws";

new PolicyPack("aws-security", {
  policies: [
    {
      name: "s3-no-public-read",
      description: "S3 버킷은 퍼블릭 읽기를 허용하면 안 됩니다",
      enforcementLevel: "mandatory",
      validateResource: validateResourceOfType(aws.s3.BucketAclV2, (acl, args, reportViolation) => {
        if (acl.acl === "public-read" || acl.acl === "public-read-write") {
          reportViolation("S3 버킷에 퍼블릭 ACL이 설정되었습니다.");
        }
      }),
    },
    {
      name: "ec2-require-tags",
      description: "EC2 인스턴스는 필수 태그를 가져야 합니다",
      enforcementLevel: "mandatory",
      validateResource: validateResourceOfType(aws.ec2.Instance, (instance, args, reportViolation) => {
        const requiredTags = ["Name", "Environment", "Team", "CostCenter"];
        const tags = instance.tags || {};
        for (const tag of requiredTags) {
          if (!(tag in tags)) {
            reportViolation(`필수 태그 '${tag}'가 누락되었습니다.`);
          }
        }
      }),
    },
  ],
});

5. Crossplane: Kubernetes 네이티브 IaC

Crossplane은 Kubernetes CRD(Custom Resource Definition)를 사용하여 클라우드 리소스를 관리한다. kubectl로 AWS, GCP, Azure 리소스를 생성하고 관리할 수 있다.

5.1 Crossplane 아키텍처

Crossplane 아키텍처:
┌─────────────────────────────────────────────────┐
│  K8s Cluster                                     │
│  ┌───────────────────────────────────────────┐   │
│  │  Crossplane Core                          │   │
│  │  ┌─────────┐  ┌──────────┐  ┌─────────┐  │   │
│  │  │Composite│  │Composition│  │  XRD    │  │   │
│  │  │Resource │  │          │  │         │  │   │
│  │  └────┬────┘  └────┬─────┘  └────┬────┘  │   │
│  │       └─────────────┼─────────────┘       │   │
│  └───────────────────┬─┤─────────────────────┘   │
│                      │ │                          │
│  ┌───────────────────┴─┴─────────────────────┐   │
│  │  Providers                                │   │
│  │  ┌──────────┐  ┌──────────┐  ┌─────────┐  │   │
│  │  │AWS Prov. │  │GCP Prov. │  │Azure P. │  │   │
│  │  └────┬─────┘  └────┬─────┘  └────┬────┘  │   │
│  └───────┼──────────────┼─────────────┼──────┘   │
└──────────┼──────────────┼─────────────┼──────────┘
           ▼              ▼             ▼
       AWS Cloud      GCP Cloud    Azure Cloud

5.2 XRD (Composite Resource Definition) 정의

# xrd.yaml - 플랫폼 팀이 정의하는 API 스키마
apiVersion: apiextensions.crossplane.io/v1
kind: CompositeResourceDefinition
metadata:
  name: xdatabases.platform.example.com
spec:
  group: platform.example.com
  names:
    kind: XDatabase
    plural: xdatabases
  claimNames:
    kind: Database
    plural: databases
  versions:
    - name: v1alpha1
      served: true
      referenceable: true
      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
              properties:
                engine:
                  type: string
                  enum: ["postgresql", "mysql"]
                  description: "데이터베이스 엔진"
                size:
                  type: string
                  enum: ["small", "medium", "large"]
                  description: "인스턴스 크기"
                region:
                  type: string
                  default: "ap-northeast-2"
              required:
                - engine
                - size

5.3 Composition 정의

# composition.yaml - 실제 리소스 매핑
apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
  name: xdatabases.aws.platform.example.com
  labels:
    provider: aws
spec:
  compositeTypeRef:
    apiVersion: platform.example.com/v1alpha1
    kind: XDatabase
  resources:
    - name: rds-instance
      base:
        apiVersion: rds.aws.upbound.io/v1beta1
        kind: Instance
        spec:
          forProvider:
            engine: postgresql
            engineVersion: "15"
            instanceClass: db.t3.medium
            allocatedStorage: 20
            publiclyAccessible: false
            skipFinalSnapshot: true
      patches:
        - type: FromCompositeFieldPath
          fromFieldPath: "spec.engine"
          toFieldPath: "spec.forProvider.engine"
        - type: FromCompositeFieldPath
          fromFieldPath: "spec.size"
          toFieldPath: "spec.forProvider.instanceClass"
          transforms:
            - type: map
              map:
                small: db.t3.medium
                medium: db.r6g.large
                large: db.r6g.xlarge
        - type: FromCompositeFieldPath
          fromFieldPath: "spec.region"
          toFieldPath: "spec.forProvider.region"
    - name: subnet-group
      base:
        apiVersion: rds.aws.upbound.io/v1beta1
        kind: SubnetGroup
        spec:
          forProvider:
            description: "Crossplane managed subnet group"

5.4 Claim 기반 프로비저닝

# claim.yaml - 개발자가 요청하는 리소스
apiVersion: platform.example.com/v1alpha1
kind: Database
metadata:
  name: orders-db
  namespace: orders-team
spec:
  engine: postgresql
  size: medium
  region: ap-northeast-2
# 개발자의 사용 경험
kubectl apply -f claim.yaml
kubectl get databases -n orders-team
# NAME        ENGINE       SIZE    READY   AGE
# orders-db   postgresql   medium  True    5m

6. 상태 관리 심화

6.1 상태 파일 백엔드 비교

S3 + DynamoDB (AWS):
┌─────────────┐     ┌──────────────┐
│ S3 Bucket   │     │ DynamoDB     │
│ (상태 저장)  │     │ (잠금 관리)   │
│ 버전 관리 ON │     │ LockID 해시  │
│ 암호화 ON    │     │ 키 테이블    │
└─────────────┘     └──────────────┘

GCS (GCP):
┌─────────────────────────────┐
│ GCS Bucket                  │
│ (상태 저장 + 내장 잠금)       │
│ 버전 관리 ON, 암호화 ON      │
└─────────────────────────────┘

Terraform Cloud / Spacelift:
┌─────────────────────────────┐
│ 관리형 상태 저장             │
│ 내장 잠금, 상태 히스토리      │
│ 접근 제어, 감사 로그         │
└─────────────────────────────┘

6.2 상태 잠금과 동시성

# 상태 잠금 강제 해제 (주의: 다른 작업이 실행 중이 아닌지 확인)
terraform force-unlock LOCK_ID

# 상태 파일 조회
terraform state list
terraform state show aws_instance.web

# 상태에서 리소스 제거 (삭제 없이)
terraform state rm aws_instance.legacy

# 기존 리소스를 상태로 가져오기
terraform import aws_instance.web i-1234567890abcdef0

6.3 상태 마이그레이션

# moved 블록으로 리소스 이동 (Terraform 1.1+)
moved {
  from = aws_instance.web
  to   = module.compute.aws_instance.web
}

moved {
  from = aws_security_group.web_sg
  to   = module.networking.aws_security_group.web_sg
}

# import 블록으로 기존 리소스 가져오기 (Terraform 1.5+)
import {
  to = aws_instance.legacy_server
  id = "i-0abc123def456789"
}

import {
  to = aws_s3_bucket.existing_bucket
  id = "my-existing-bucket-name"
}

7. IaC 테스트 전략

7.1 Terratest

// test/vpc_test.go
package test

import (
    "testing"
    "github.com/gruntwork-io/terratest/modules/terraform"
    "github.com/stretchr/testify/assert"
)

func TestVpcModule(t *testing.T) {
    t.Parallel()

    terraformOptions := &terraform.Options{
        TerraformDir: "../modules/vpc",
        Vars: map[string]interface{}{
            "name":       "test-vpc",
            "cidr_block": "10.0.0.0/16",
            "azs":        []string{"us-east-1a", "us-east-1b"},
        },
        NoColor: true,
    }

    defer terraform.Destroy(t, terraformOptions)
    terraform.InitAndApply(t, terraformOptions)

    vpcId := terraform.Output(t, terraformOptions, "vpc_id")
    assert.NotEmpty(t, vpcId)

    privateSubnetIds := terraform.OutputList(t, terraformOptions, "private_subnet_ids")
    assert.Equal(t, 2, len(privateSubnetIds))
}

7.2 Checkov 정적 분석

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

# 특정 체크 건너뛰기
checkov -d . --skip-check CKV_AWS_18,CKV_AWS_21

# JSON 리포트 생성
checkov -d . -o json > checkov-report.json

# 커스텀 정책 예시
# custom_policy.yaml
metadata:
  id: "CUSTOM_001"
  name: "Ensure S3 bucket has lifecycle policy"
  category: "general"
definition:
  cond_type: "attribute"
  resource_types:
    - "aws_s3_bucket"
  attribute: "lifecycle_rule"
  operator: "exists"

7.3 OPA Conftest

# policy/terraform.rego
package terraform

deny[msg] {
  resource := input.resource_changes[_]
  resource.type == "aws_instance"
  not resource.change.after.tags.Environment
  msg := sprintf("EC2 인스턴스 '%s'에 Environment 태그가 없습니다", [resource.address])
}

deny[msg] {
  resource := input.resource_changes[_]
  resource.type == "aws_s3_bucket"
  resource.change.after.acl == "public-read"
  msg := sprintf("S3 버킷 '%s'이 퍼블릭 읽기로 설정되었습니다", [resource.address])
}

deny[msg] {
  resource := input.resource_changes[_]
  resource.type == "aws_security_group_rule"
  resource.change.after.cidr_blocks[_] == "0.0.0.0/0"
  resource.change.after.from_port == 22
  msg := "SSH(22) 포트를 0.0.0.0/0에 개방하면 안 됩니다"
}
# Conftest 실행
terraform plan -out=tfplan
terraform show -json tfplan > tfplan.json
conftest test tfplan.json -p policy/

7.4 tfsec 보안 스캐닝

# tfsec 실행
tfsec .

# 결과 예시
# Result: CRITICAL - aws_security_group.web
#   Description: Security group rule allows ingress from 0.0.0.0/0 to port 22
#   Impact: Unrestricted SSH access
#   Resolution: Restrict SSH access to known IP ranges

# tfsec 인라인 무시
resource "aws_security_group_rule" "allow_ssh" {
  #tfsec:ignore:aws-vpc-no-public-ingress-sgr
  type        = "ingress"
  from_port   = 22
  to_port     = 22
  protocol    = "tcp"
  cidr_blocks = ["10.0.0.0/8"]  # 내부 네트워크만 허용
}

8. 모노레포 vs 폴리레포 전략

8.1 모노레포 구조

infrastructure/                    # 모노레포
├── .github/
│   └── workflows/
│       ├── terraform-plan.yml     # PR에서 plan
│       └── terraform-apply.yml    # merge 후 apply
├── modules/                       # 공유 모듈
│   ├── vpc/
│   ├── ecs/
│   ├── rds/
│   └── monitoring/
├── environments/
│   ├── shared/                    # 공유 리소스 (IAM, DNS)
│   │   ├── iam/
│   │   └── route53/
│   ├── dev/
│   │   ├── main.tf
│   │   └── terragrunt.hcl
│   ├── staging/
│   │   ├── main.tf
│   │   └── terragrunt.hcl
│   └── prod/
│       ├── main.tf
│       └── terragrunt.hcl
├── terragrunt.hcl                 # 루트 설정
└── Makefile

8.2 Terragrunt으로 DRY 달성

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

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

inputs = {
  environment = "prod"
  cidr_block  = "10.0.0.0/16"
  azs         = ["ap-northeast-2a", "ap-northeast-2b", "ap-northeast-2c"]

  enable_nat_gateway = true
  single_nat_gateway = false
}

# 루트 terragrunt.hcl
remote_state {
  backend = "s3"
  config = {
    bucket         = "myorg-terraform-state"
    key            = "${path_relative_to_include()}/terraform.tfstate"
    region         = "ap-northeast-2"
    encrypt        = true
    dynamodb_table = "terraform-locks"
  }
}

generate "provider" {
  path      = "provider.tf"
  if_exists = "overwrite_terragrunt"
  contents  = <<EOF
provider "aws" {
  region = "ap-northeast-2"
  default_tags {
    tags = {
      ManagedBy   = "terragrunt"
      Environment = "${basename(get_terragrunt_dir())}"
    }
  }
}
EOF
}

8.3 CI/CD 파이프라인

# .github/workflows/terraform-plan.yml
name: Terraform Plan

on:
  pull_request:
    paths:
      - 'environments/**'
      - 'modules/**'

jobs:
  detect-changes:
    runs-on: ubuntu-latest
    outputs:
      directories: "steps.changes.outputs.directories"
    steps:
      - uses: actions/checkout@v4
      - id: changes
        uses: dorny/paths-filter@v3
        with:
          filters: |
            dev:
              - 'environments/dev/**'
              - 'modules/**'
            staging:
              - 'environments/staging/**'
              - 'modules/**'
            prod:
              - 'environments/prod/**'
              - 'modules/**'

  plan:
    needs: detect-changes
    runs-on: ubuntu-latest
    strategy:
      matrix:
        directory: [dev, staging, prod]
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: "1.7.0"

      - name: Terraform Init
        working-directory: environments/${{ matrix.directory }}
        run: terraform init -no-color

      - name: Terraform Plan
        working-directory: environments/${{ matrix.directory }}
        run: terraform plan -no-color -out=tfplan

      - name: Checkov Scan
        uses: bridgecrewio/checkov-action@v12
        with:
          directory: environments/${{ matrix.directory }}
          framework: terraform

      - name: Infracost
        uses: infracost/actions/setup@v3
        with:
          api-key: "${{ secrets.INFRACOST_API_KEY }}"
      - run: |
          infracost breakdown \
            --path environments/${{ matrix.directory }} \
            --format json \
            --out-file /tmp/infracost.json

9. GitOps for IaC

9.1 Atlantis

# atlantis.yaml
version: 3
automerge: false
parallel_plan: true
parallel_apply: true

projects:
  - name: dev-vpc
    dir: environments/dev
    workspace: default
    terraform_version: v1.7.0
    autoplan:
      when_modified:
        - "*.tf"
        - "../../modules/vpc/**"
      enabled: true
    apply_requirements:
      - approved
      - mergeable

  - name: prod-vpc
    dir: environments/prod
    workspace: default
    terraform_version: v1.7.0
    autoplan:
      when_modified:
        - "*.tf"
        - "../../modules/vpc/**"
      enabled: true
    apply_requirements:
      - approved
      - mergeable
      - undiverged

# Atlantis 서버 설정
# atlantis server \
#   --atlantis-url="https://atlantis.example.com" \
#   --gh-user="atlantis-bot" \
#   --gh-token="ghp_xxx" \
#   --repo-allowlist="github.com/myorg/*"

9.2 Spacelift 설정

# .spacelift/config.yml
version: "1"

stacks:
  prod-infra:
    space: production
    project_root: environments/prod
    terraform_version: "1.7.0"
    autodeploy: false
    administrative: false
    labels:
      - "env:prod"
      - "team:platform"
    policies:
      - name: plan-approval
        type: APPROVAL
        body: |
          package spacelift
          approve {
            count(input.reviews.current.approvals) >= 2
          }
      - name: drift-detection
        type: TRIGGER
        body: |
          package spacelift
          trigger["drift-check"] {
            input.run.type == "DRIFT_DETECTION"
            input.run.drift == true
          }
    drift_detection:
      enabled: true
      schedule:
        - "0 */6 * * *"  # 6시간마다 드리프트 감지
      reconcile: false   # 자동 수정하지 않음

9.3 Env0 설정

# env0.yml
version: 2
deploy:
  steps:
    terraformVersion: "1.7.0"
    init:
      commands:
        - terraform init -no-color
    plan:
      commands:
        - terraform plan -no-color -out=tfplan
        - checkov -d . --framework terraform --output json > checkov.json || true
        - infracost breakdown --path . --format json --out-file infracost.json || true
    apply:
      commands:
        - terraform apply -no-color tfplan

10. 드리프트 감지와 복원

10.1 드리프트 감지 전략

# Terraform 내장 드리프트 감지
terraform plan -detailed-exitcode
# 종료 코드:
# 0 - 변경 없음
# 1 - 오류
# 2 - 변경 있음 (드리프트 감지됨)

# 자동 드리프트 감지 스크립트
#!/bin/bash
set -e

ENVIRONMENTS=("dev" "staging" "prod")

for env in "${ENVIRONMENTS[@]}"; do
  echo "=== Checking drift for $env ==="
  cd "environments/$env"
  terraform init -no-color > /dev/null

  if ! terraform plan -detailed-exitcode -no-color > "/tmp/drift-${env}.txt" 2>&1; then
    EXIT_CODE=$?
    if [ $EXIT_CODE -eq 2 ]; then
      echo "DRIFT DETECTED in $env"
      # Slack 알림 전송
      curl -X POST "$SLACK_WEBHOOK" \
        -H 'Content-Type: application/json' \
        -d "{\"text\": \"Drift detected in ${env} environment\"}"
    fi
  fi
  cd ../..
done

10.2 Infracost 비용 추정

# Infracost 기본 사용
infracost breakdown --path .

# PR에서 비용 차이 표시
infracost diff \
  --path . \
  --compare-to infracost-base.json \
  --format json \
  --out-file infracost-diff.json

# 비용 정책 설정
# infracost.yml
version: 0.1
projects:
  - path: environments/prod
    terraform_var_files:
      - terraform.tfvars
    usage_file: infracost-usage.yml

11. IaC 시크릿 관리

11.1 HashiCorp Vault 연동

# Vault 프로바이더
provider "vault" {
  address = "https://vault.example.com"
}

# Vault에서 시크릿 읽기
data "vault_generic_secret" "db_credentials" {
  path = "secret/data/prod/database"
}

resource "aws_db_instance" "main" {
  engine         = "postgres"
  engine_version = "15"
  instance_class = "db.r6g.large"

  username = data.vault_generic_secret.db_credentials.data["username"]
  password = data.vault_generic_secret.db_credentials.data["password"]
}

11.2 SOPS (Secrets OPerationS)

# SOPS로 암호화
sops --encrypt --age age1xxxxx secrets.yaml > secrets.enc.yaml

# SOPS 설정
# .sops.yaml
creation_rules:
  - path_regex: environments/prod/.*\.enc\.yaml
    age: >-
      age1xxx,age2xxx
    encrypted_regex: "^(password|secret|key|token)$"
  - path_regex: environments/dev/.*\.enc\.yaml
    age: >-
      age3xxx
    encrypted_regex: "^(password|secret|key|token)$"
# Terraform에서 SOPS 사용
data "sops_file" "secrets" {
  source_file = "secrets.enc.yaml"
}

resource "aws_secretsmanager_secret_version" "db_password" {
  secret_id     = aws_secretsmanager_secret.db.id
  secret_string = data.sops_file.secrets.data["db_password"]
}

12. 퀴즈

다음 퀴즈로 이 글에서 다룬 IaC 패턴에 대한 이해를 점검해 보자.

Q1. Terraform 모듈 설계

질문: Terraform 래퍼 모듈 패턴의 주된 목적은 무엇인가?

정답: 커뮤니티 모듈을 감싸서 조직 표준(암호화, 태깅, 접근 제어)을 강제하는 것이다.

래퍼 모듈은 커뮤니티 모듈을 내부적으로 호출하면서, 조직에서 필수로 요구하는 설정(S3 암호화, 퍼블릭 접근 차단, 태그 정책)을 기본값으로 적용한다. 개발자는 래퍼 모듈만 사용하면 자동으로 보안 및 거버넌스 정책을 준수하게 된다.

Q2. Pulumi vs Terraform

질문: Pulumi가 Terraform 대비 가지는 가장 큰 장점은 무엇인가?

정답: TypeScript, Python, Go 등 범용 프로그래밍 언어를 사용하므로 조건문, 반복문, 추상화 등 언어의 모든 기능을 인프라 코드에 활용할 수 있다.

HCL은 선언적 DSL이므로 복잡한 로직 구현에 한계가 있다. Pulumi는 기존 IDE, 디버거, 테스트 프레임워크, 패키지 매니저를 그대로 사용할 수 있어 개발자 생산성이 높다.

Q3. Crossplane 아키텍처

질문: Crossplane에서 XRD, Composition, Claim의 역할을 각각 설명하시오.

정답:

  • XRD(Composite Resource Definition): 플랫폼 팀이 정의하는 API 스키마. 개발자에게 노출할 필드와 타입을 정의한다.
  • Composition: XRD에 대한 실제 리소스 매핑. 하나의 XRD에 여러 Composition(AWS용, GCP용 등)을 연결할 수 있다.
  • Claim: 개발자가 네임스페이스 수준에서 요청하는 리소스. XRD 스키마에 맞춰 간단한 YAML을 작성하면 Composition이 실제 클라우드 리소스를 생성한다.

Q4. 상태 관리

질문: Terraform 상태 파일에서 moved 블록과 import 블록의 차이점은 무엇인가?

정답:

  • moved 블록: 이미 상태에 있는 리소스의 주소를 변경한다. 모듈 리팩토링 시 리소스를 삭제/재생성하지 않고 이동할 수 있다.
  • import 블록: 상태 파일에 없지만 실제 클라우드에 존재하는 리소스를 Terraform 관리하에 가져온다. Terraform 1.5부터 코드에서 선언적으로 import할 수 있다.

Q5. GitOps for IaC

질문: Atlantis와 Spacelift의 드리프트 감지 방식의 차이를 설명하시오.

정답:

  • Atlantis: PR 기반 워크플로에 초점. 드리프트 감지 기능이 내장되어 있지 않으며, 별도의 cron 스크립트나 CI 파이프라인으로 terraform plan -detailed-exitcode를 실행해야 한다.
  • Spacelift: 내장 드리프트 감지 기능 제공. 스케줄(예: 6시간마다)에 따라 자동으로 plan을 실행하고, 드리프트가 발견되면 알림을 보내거나 자동 복원(reconcile) 옵션을 제공한다. 정책으로 드리프트 대응을 코드화할 수 있다.

13. 참고 자료

  1. HashiCorp Terraform Documentation - https://developer.hashicorp.com/terraform/docs
  2. Pulumi Documentation - https://www.pulumi.com/docs/
  3. Crossplane Documentation - https://docs.crossplane.io/
  4. OpenTofu Documentation - https://opentofu.org/docs/
  5. Terragrunt Documentation - https://terragrunt.gruntwork.io/docs/
  6. Terratest - https://terratest.gruntwork.io/
  7. Checkov by Bridgecrew - https://www.checkov.io/
  8. Infracost Documentation - https://www.infracost.io/docs/
  9. Atlantis Documentation - https://www.runatlantis.io/docs/
  10. Spacelift Documentation - https://docs.spacelift.io/
  11. SOPS (Secrets OPerationS) - https://github.com/getsops/sops
  12. OPA Conftest - https://www.conftest.dev/
  13. tfsec by Aqua Security - https://aquasecurity.github.io/tfsec/

이 글에서는 IaC의 주요 도구(Terraform, Pulumi, Crossplane)와 설계 패턴(컴포지션, 팩토리, 래퍼), 상태 관리, 테스트, GitOps, 드리프트 감지까지 포괄적으로 다루었다. 조직의 규모와 요구사항에 맞는 도구와 패턴을 선택하고, 테스트와 보안 스캐닝을 CI/CD에 통합하는 것이 성공적인 IaC 운영의 핵심이다.