Skip to content
Published on

Pulumi IaC 실전 가이드: TypeScript로 구축하는 클라우드 인프라 자동화

Authors
  • Name
    Twitter
Pulumi IaC

들어가며

Infrastructure as Code(IaC)는 이제 선택이 아닌 필수입니다. 2025-2026년 현재, 선도적인 조직들은 인프라를 단순히 프로비저닝하는 수준을 넘어 소프트웨어처럼 다루고 있습니다. 테스트, 버전 관리, 코드 리뷰, CI/CD 파이프라인까지 소프트웨어 엔지니어링의 모든 관행을 인프라에 적용하는 것이죠.

그러나 기존 IaC 도구들, 특히 Terraform의 HCL(HashiCorp Configuration Language)은 범용 프로그래밍 언어가 아닙니다. 복잡한 조건 분기, 반복 로직, 타입 안전성, 단위 테스트 등을 구현하려면 HCL의 한계에 부딪히게 됩니다. Pulumi는 이 문제를 TypeScript, Python, Go, C#, Java 등 범용 프로그래밍 언어로 인프라를 정의할 수 있게 해 해결합니다.

이 글에서는 TypeScript를 중심으로 Pulumi의 핵심 개념부터 프로덕션 운영까지 실전에서 필요한 모든 것을 다룹니다. Terraform과의 비교, AWS 인프라 구축, 스택 관리, Automation API, 테스트 전략, CI/CD 통합, 트러블슈팅까지 코드 예제와 함께 살펴보겠습니다.

Pulumi 핵심 개념

Pulumi를 사용하기 전에 반드시 이해해야 할 핵심 개념들을 정리합니다.

Project (프로젝트)

프로젝트는 Pulumi 프로그램이 담긴 디렉토리입니다. Pulumi.yaml 파일이 프로젝트의 루트를 정의하며, 프로젝트 이름과 사용할 런타임(nodejs, python, go 등)을 지정합니다.

Stack (스택)

스택은 프로젝트의 독립적인 인스턴스입니다. 동일한 프로그램을 dev, staging, production 같은 다른 환경에 배포할 때 각각을 스택으로 관리합니다. 각 스택은 고유한 설정 값과 상태를 가집니다.

Resource (리소스)

리소스는 클라우드 인프라의 기본 단위입니다. S3 버킷, EC2 인스턴스, VPC 등이 모두 리소스입니다. Pulumi에서는 TypeScript 클래스의 인스턴스로 표현됩니다.

State (상태)

Pulumi는 배포된 리소스의 현재 상태를 추적합니다. 상태는 Pulumi Cloud(기본), AWS S3, Azure Blob Storage, Google Cloud Storage, 로컬 파일시스템 등에 저장할 수 있습니다.

Provider (프로바이더)

프로바이더는 특정 클라우드 서비스와 통신하는 플러그인입니다. AWS, GCP, Azure, Kubernetes 등 150개 이상의 프로바이더가 존재합니다.

Output과 Input

Pulumi 리소스의 속성은 Output<T> 타입으로 반환됩니다. 이는 리소스가 실제로 생성될 때까지 값을 알 수 없는 비동기적 특성을 표현합니다. 다른 리소스에 이 값을 전달할 때는 Input<T> 타입으로 받습니다.

import * as aws from '@pulumi/aws'

// S3 버킷 생성
const bucket = new aws.s3.Bucket('my-bucket', {
  website: {
    indexDocument: 'index.html',
  },
})

// bucket.id는 Output<string> 타입
// 다른 리소스의 Input으로 직접 전달 가능
const bucketPolicy = new aws.s3.BucketPolicy('my-bucket-policy', {
  bucket: bucket.id, // Output<string> -> Input<string> 자동 변환
  policy: bucket.arn.apply((arn) =>
    JSON.stringify({
      Version: '2012-10-17',
      Statement: [
        {
          Effect: 'Allow',
          Principal: '*',
          Action: 's3:GetObject',
          Resource: `${arn}/*`,
        },
      ],
    })
  ),
})

// Output 값을 export하면 스택 출력으로 표시
export const bucketName = bucket.id
export const websiteUrl = bucket.websiteEndpoint

IaC 도구 비교: Pulumi vs Terraform vs CDK

세 가지 주요 IaC 도구를 다양한 관점에서 비교합니다. 팀의 기술 스택과 요구사항에 맞는 도구를 선택하는 데 참고하시기 바랍니다.

항목PulumiTerraformAWS CDK
언어TypeScript, Python, Go, C#, Java, YAMLHCL (DSL)TypeScript, Python, Java, C#, Go
멀티 클라우드AWS, GCP, Azure, K8s 등 150+ 프로바이더수천 개 공식/커뮤니티 프로바이더AWS 전용
상태 관리Pulumi Cloud, S3, GCS, Azure Blob, 로컬Terraform Cloud, S3, GCS, 로컬 등CloudFormation에 위임
테스트표준 테스트 프레임워크 (Jest, Mocha)terraform test (HCL 기반)표준 테스트 프레임워크 (Jest 등)
타입 안전성TypeScript 정적 타입 체킹제한적 (HCL 변수 타입)TypeScript 정적 타입 체킹
IDE 지원VS Code IntelliSense, 자동 완성HCL 플러그인 필요VS Code IntelliSense, 자동 완성
학습 곡선프로그래밍 경험 있으면 낮음HCL 학습 필요AWS + 프로그래밍 지식 필요
상태 잠금내장 (Pulumi Cloud), S3 DynamoDBS3 + DynamoDB, Cloud 기본 제공CloudFormation 자체 관리
Drift 감지pulumi refreshterraform planCloudFormation drift detection
비밀 관리내장 암호화, ESC 지원Vault 연동 필요Secrets Manager/SSM 연동
Automation API프로그래밍 방식 실행 지원제한적 (CLI wrapper)제한적
커뮤니티/에코시스템성장 중 (GitHub 22k+ 스타)매우 성숙 (GitHub 43k+ 스타)AWS 생태계 내
라이선스Apache 2.0 (오픈소스)BSL (Business Source License)Apache 2.0 (오픈소스)

언제 Pulumi를 선택해야 할까?

  • 개발자 중심 팀: TypeScript/Python 등 이미 사용 중인 언어로 인프라를 관리하고 싶을 때
  • 복잡한 로직이 필요할 때: 조건부 리소스 생성, 동적 설정, 복잡한 변환이 빈번할 때
  • 테스트 주도 인프라: Jest, Mocha 등 기존 테스트 프레임워크로 인프라를 테스트하고 싶을 때
  • Automation API 활용: 플랫폼 엔지니어링에서 인프라 프로비저닝을 API로 노출해야 할 때
  • 비밀 관리 내장 필요: 별도 도구 없이 비밀을 안전하게 관리하고 싶을 때

언제 Terraform을 유지해야 할까?

  • 대규모 멀티 클라우드: 수천 개의 프로바이더 에코시스템이 필요할 때
  • 운영 중심 팀: 개발보다 운영에 초점을 맞춘 SRE/인프라 팀일 때
  • 기존 Terraform 코드베이스: 이미 대규모 HCL 코드가 존재하고 마이그레이션 비용이 높을 때
  • tf2pulumi: Terraform에서 Pulumi로 마이그레이션하는 도구도 제공되지만, 대규모 전환은 신중히 판단해야 합니다

환경 설정과 프로젝트 초기화

Pulumi CLI 설치

# macOS
brew install pulumi/tap/pulumi

# Linux (curl)
curl -fsSL https://get.pulumi.com | sh

# Windows (Chocolatey)
choco install pulumi

# 설치 확인
pulumi version
# v3.x.x

# Node.js 확인 (TypeScript 사용 시 필수)
node --version
# v20.x.x 이상 권장

# Pulumi 로그인 (Pulumi Cloud 사용)
pulumi login

# 또는 S3 백엔드 사용
pulumi login s3://my-pulumi-state-bucket

# 로컬 파일시스템 사용
pulumi login --local

새 프로젝트 생성

# 새 디렉토리 생성
mkdir my-infra && cd my-infra

# AWS TypeScript 템플릿으로 프로젝트 초기화
pulumi new aws-typescript

# 대화형 프롬프트에서 설정
# project name: my-infra
# project description: Production infrastructure
# stack name: dev
# aws:region: ap-northeast-2

프로젝트 초기화 후 생성되는 파일 구조를 살펴봅시다.

my-infra/
├── Pulumi.yaml          # 프로젝트 메타데이터
├── Pulumi.dev.yaml      # dev 스택 설정
├── index.ts             # 메인 프로그램
├── package.json         # npm 의존성
└── tsconfig.json        # TypeScript 설정

Pulumi.yaml 파일의 내용은 다음과 같습니다.

name: my-infra
runtime:
  name: nodejs
  options:
    typescript: true
description: Production infrastructure
config:
  pulumi:tags:
    value:
      pulumi:template: aws-typescript

Pulumi.dev.yaml 스택 설정 파일의 예시입니다.

config:
  aws:region: ap-northeast-2
  my-infra:environment: dev
  my-infra:dbPassword:
    secure: AAABADEFaBCDeFgHiJkLmNoPqRsTuVwXyZ== # 암호화된 시크릿

AWS 인프라 구축 실전

실제 프로덕션에서 사용할 수 있는 AWS 인프라를 TypeScript로 구축해 봅시다.

VPC와 네트워크 구성

import * as pulumi from '@pulumi/pulumi'
import * as aws from '@pulumi/aws'
import * as awsx from '@pulumi/awsx'

const config = new pulumi.Config()
const environment = config.require('environment')

// awsx를 활용한 VPC 생성 (고수준 추상화)
const vpc = new awsx.ec2.Vpc(`${environment}-vpc`, {
  cidrBlock: '10.0.0.0/16',
  numberOfAvailabilityZones: 3,
  subnetStrategy: awsx.ec2.SubnetAllocationStrategy.Auto,
  enableDnsHostnames: true,
  enableDnsSupport: true,
  subnetSpecs: [
    {
      type: awsx.ec2.SubnetType.Public,
      name: 'public',
      cidrMask: 24,
    },
    {
      type: awsx.ec2.SubnetType.Private,
      name: 'private',
      cidrMask: 24,
    },
    {
      type: awsx.ec2.SubnetType.Isolated,
      name: 'isolated',
      cidrMask: 24,
    },
  ],
  tags: {
    Environment: environment,
    ManagedBy: 'pulumi',
  },
})

// 보안 그룹 생성
const albSecurityGroup = new aws.ec2.SecurityGroup(`${environment}-alb-sg`, {
  vpcId: vpc.vpcId,
  description: 'Security group for ALB',
  ingress: [
    {
      protocol: 'tcp',
      fromPort: 80,
      toPort: 80,
      cidrBlocks: ['0.0.0.0/0'],
      description: 'HTTP',
    },
    {
      protocol: 'tcp',
      fromPort: 443,
      toPort: 443,
      cidrBlocks: ['0.0.0.0/0'],
      description: 'HTTPS',
    },
  ],
  egress: [
    {
      protocol: '-1',
      fromPort: 0,
      toPort: 0,
      cidrBlocks: ['0.0.0.0/0'],
      description: 'Allow all outbound',
    },
  ],
  tags: {
    Name: `${environment}-alb-sg`,
    Environment: environment,
  },
})

export const vpcId = vpc.vpcId
export const publicSubnetIds = vpc.publicSubnetIds
export const privateSubnetIds = vpc.privateSubnetIds

ECS Fargate 서비스 배포

import * as aws from '@pulumi/aws'
import * as awsx from '@pulumi/awsx'
import * as pulumi from '@pulumi/pulumi'

const config = new pulumi.Config()
const environment = config.require('environment')
const containerPort = config.getNumber('containerPort') || 3000
const cpu = config.getNumber('cpu') || 256
const memory = config.getNumber('memory') || 512
const desiredCount = config.getNumber('desiredCount') || 2

// ECR 리포지토리 생성
const repo = new awsx.ecr.Repository(`${environment}-app-repo`, {
  forceDelete: environment !== 'production',
  lifecyclePolicy: {
    rules: [
      {
        description: 'Keep last 10 images',
        maximumNumberOfImages: 10,
        tagStatus: 'any',
      },
    ],
  },
})

// Docker 이미지 빌드 및 푸시
const image = new awsx.ecr.Image(`${environment}-app-image`, {
  repositoryUrl: repo.url,
  context: '../app',
  platform: 'linux/amd64',
})

// ECS 클러스터 생성
const cluster = new aws.ecs.Cluster(`${environment}-cluster`, {
  settings: [
    {
      name: 'containerInsights',
      value: 'enabled',
    },
  ],
  tags: {
    Environment: environment,
    ManagedBy: 'pulumi',
  },
})

// ALB + ECS Fargate 서비스 (awsx 고수준 컴포넌트)
const service = new awsx.ecs.FargateService(`${environment}-service`, {
  cluster: cluster.arn,
  desiredCount: desiredCount,
  networkConfiguration: {
    subnets: vpc.privateSubnetIds,
    securityGroups: [albSecurityGroup.id],
    assignPublicIp: false,
  },
  taskDefinitionArgs: {
    container: {
      name: 'app',
      image: image.imageUri,
      cpu: cpu,
      memory: memory,
      essential: true,
      portMappings: [
        {
          containerPort: containerPort,
          targetGroup: loadBalancer.defaultTargetGroup,
        },
      ],
      environment: [
        { name: 'NODE_ENV', value: environment },
        { name: 'PORT', value: String(containerPort) },
      ],
      logConfiguration: {
        logDriver: 'awslogs',
        options: {
          'awslogs-group': `/ecs/${environment}-app`,
          'awslogs-region': aws.config.region!,
          'awslogs-stream-prefix': 'ecs',
        },
      },
    },
  },
  tags: {
    Environment: environment,
    ManagedBy: 'pulumi',
  },
})

export const serviceUrl = pulumi.interpolate`http://${loadBalancer.loadBalancer.dnsName}`

RDS 데이터베이스 생성

import * as aws from '@pulumi/aws'
import * as pulumi from '@pulumi/pulumi'
import * as random from '@pulumi/random'

const config = new pulumi.Config()
const environment = config.require('environment')
const dbName = config.require('dbName')

// 랜덤 비밀번호 생성
const dbPassword = new random.RandomPassword(`${environment}-db-password`, {
  length: 32,
  special: true,
  overrideSpecial: '!#$%&*()-_=+[]{}<>:?',
})

// Secrets Manager에 비밀번호 저장
const dbSecret = new aws.secretsmanager.Secret(`${environment}-db-secret`, {
  name: `${environment}/database/master-password`,
  tags: { Environment: environment },
})

const dbSecretVersion = new aws.secretsmanager.SecretVersion(`${environment}-db-secret-version`, {
  secretId: dbSecret.id,
  secretString: pulumi
    .all([dbPassword.result])
    .apply(([password]) => JSON.stringify({ username: 'admin', password })),
})

// DB 서브넷 그룹
const dbSubnetGroup = new aws.rds.SubnetGroup(`${environment}-db-subnet`, {
  subnetIds: vpc.isolatedSubnetIds,
  tags: { Environment: environment },
})

// DB 보안 그룹
const dbSecurityGroup = new aws.ec2.SecurityGroup(`${environment}-db-sg`, {
  vpcId: vpc.vpcId,
  description: 'Security group for RDS',
  ingress: [
    {
      protocol: 'tcp',
      fromPort: 5432,
      toPort: 5432,
      securityGroups: [albSecurityGroup.id],
      description: 'PostgreSQL from ECS',
    },
  ],
  tags: { Name: `${environment}-db-sg`, Environment: environment },
})

// RDS 인스턴스 생성
const db = new aws.rds.Instance(`${environment}-postgres`, {
  engine: 'postgres',
  engineVersion: '16.4',
  instanceClass: environment === 'production' ? 'db.r6g.large' : 'db.t4g.micro',
  allocatedStorage: 20,
  maxAllocatedStorage: environment === 'production' ? 100 : 50,
  dbName: dbName,
  username: 'admin',
  password: dbPassword.result,
  dbSubnetGroupName: dbSubnetGroup.name,
  vpcSecurityGroupIds: [dbSecurityGroup.id],
  multiAz: environment === 'production',
  backupRetentionPeriod: environment === 'production' ? 14 : 1,
  deletionProtection: environment === 'production',
  skipFinalSnapshot: environment !== 'production',
  finalSnapshotIdentifier:
    environment === 'production' ? `${environment}-final-snapshot` : undefined,
  storageEncrypted: true,
  performanceInsightsEnabled: environment === 'production',
  tags: {
    Environment: environment,
    ManagedBy: 'pulumi',
  },
})

export const dbEndpoint = db.endpoint
export const dbSecretArn = dbSecret.arn

위 코드에서 주목할 점은 TypeScript의 조건부 표현식을 활용해 환경별로 다른 설정을 자연스럽게 적용하고 있다는 것입니다. HCL에서는 count, for_each, ternary 등의 제한된 문법을 써야 하지만, Pulumi에서는 일반 프로그래밍 로직을 그대로 사용할 수 있습니다.

스택 관리와 환경 분리

스택 생성과 전환

# 새 스택 생성
pulumi stack init staging
pulumi stack init production

# 스택 목록 확인
pulumi stack ls
# NAME        LAST UPDATE  RESOURCE COUNT  URL
# dev*        2 minutes    15              https://app.pulumi.com/...
# staging     n/a          n/a             https://app.pulumi.com/...
# production  n/a          n/a             https://app.pulumi.com/...

# 스택 전환
pulumi stack select staging

# 스택별 설정
pulumi config set environment staging
pulumi config set aws:region ap-northeast-2
pulumi config set desiredCount 2
pulumi config set --secret dbPassword 'super-secret-password'

# 모든 설정 확인
pulumi config
# KEY              VALUE
# aws:region       ap-northeast-2
# dbPassword       [secret]
# desiredCount     2
# environment      staging

스택 참조 (Cross-Stack Reference)

대규모 프로젝트에서는 인프라를 여러 프로젝트로 분리하고, 스택 참조를 통해 연결합니다.

import * as pulumi from '@pulumi/pulumi'

// 네트워크 스택의 출력 참조
const networkStack = new pulumi.StackReference('organization/network-infra/production')

// 다른 스택의 출력값 가져오기
const vpcId = networkStack.getOutput('vpcId')
const privateSubnetIds = networkStack.getOutput('privateSubnetIds')

// 이 값들을 현재 스택에서 사용
const service = new aws.ecs.Service('my-service', {
  networkConfiguration: {
    subnets: privateSubnetIds.apply((ids) => ids as string[]),
    // ...
  },
})

Self-Managed Backend (S3)

Pulumi Cloud 대신 S3를 백엔드로 사용하려면 다음과 같이 설정합니다.

# S3 버킷 생성 (AWS CLI)
aws s3 mb s3://my-company-pulumi-state --region ap-northeast-2

# 버킷 버전 관리 활성화 (상태 파일 보호)
aws s3api put-bucket-versioning \
  --bucket my-company-pulumi-state \
  --versioning-configuration Status=Enabled

# 서버측 암호화 활성화
aws s3api put-bucket-encryption \
  --bucket my-company-pulumi-state \
  --server-side-encryption-configuration '{
    "Rules": [{"ApplyServerSideEncryptionByDefault": {"SSEAlgorithm": "aws:kms"}}]
  }'

# Pulumi 백엔드를 S3로 변경
pulumi login s3://my-company-pulumi-state

# KMS 키로 시크릿 암호화 설정
pulumi stack init production \
  --secrets-provider="awskms://alias/pulumi-secrets?region=ap-northeast-2"

S3 백엔드를 사용할 때 상태 잠금(State Locking)은 기본적으로 활성화되어 있어, 동시에 여러 프로세스가 상태를 수정하는 것을 방지합니다. 기존 DIY 백엔드를 프로젝트 범위 스택(project-scoped stacks)으로 업그레이드하려면 pulumi state upgrade 명령을 사용할 수 있습니다.

Pulumi ESC (Environments, Secrets, and Configuration)

Pulumi ESC는 환경별 시크릿과 설정을 중앙에서 관리하는 기능입니다.

# Pulumi ESC 환경 정의 예시 (my-org/production.yaml)
imports:
  - my-org/base-config # 기본 설정 상속

values:
  aws:
    login:
      fn::open::aws-login:
        oidc:
          roleArn: arn:aws:iam::123456789012:role/pulumi-esc-role
          sessionName: pulumi-esc-session

  environmentVariables:
    AWS_ACCESS_KEY_ID: ${aws.login.accessKeyId}
    AWS_SECRET_ACCESS_KEY: ${aws.login.secretAccessKey}
    AWS_SESSION_TOKEN: ${aws.login.sessionToken}

  pulumiConfig:
    aws:region: ap-northeast-2
    environment: production
    dbInstanceClass: db.r6g.large

  secrets:
    fn::open::aws-secrets:
      region: ap-northeast-2
      login: ${aws.login}
      get:
        db-password:
          secretId: production/database/master-password

ESC를 사용하면 AWS OIDC, Azure OIDC, Google Cloud OIDC, HashiCorp Vault, AWS Secrets Manager 등에서 동적으로 시크릿을 가져올 수 있습니다.

Automation API 활용

Pulumi Automation API는 Pulumi의 가장 강력한 차별점 중 하나입니다. CLI 없이 프로그래밍 방식으로 Pulumi 작업을 실행할 수 있어, 플랫폼 엔지니어링이나 셀프서비스 인프라 포털 구축에 이상적입니다.

인라인 프로그램 예제

import { InlineProgramArgs, LocalWorkspace } from '@pulumi/pulumi/automation'
import * as aws from '@pulumi/aws'

// 인라인으로 Pulumi 프로그램 정의
const pulumiProgram = async () => {
  const bucket = new aws.s3.Bucket('auto-bucket', {
    website: {
      indexDocument: 'index.html',
    },
  })

  return {
    bucketName: bucket.id,
    websiteUrl: bucket.websiteEndpoint,
  }
}

async function deployInfrastructure(stackName: string, region: string) {
  const args: InlineProgramArgs = {
    stackName,
    projectName: 'auto-deploy',
    program: pulumiProgram,
  }

  // 스택 생성 또는 선택
  const stack = await LocalWorkspace.createOrSelectStack(args)

  // 스택 설정
  await stack.setConfig('aws:region', { value: region })

  console.log('Running pulumi preview...')
  const previewResult = await stack.preview({ onOutput: console.log })
  console.log(`Preview: ${previewResult.changeSummary}`)

  console.log('Running pulumi up...')
  const upResult = await stack.up({ onOutput: console.log })
  console.log(`Update summary: ${JSON.stringify(upResult.summary)}`)
  console.log(`Outputs: ${JSON.stringify(upResult.outputs)}`)

  return upResult.outputs
}

async function destroyInfrastructure(stackName: string) {
  const args: InlineProgramArgs = {
    stackName,
    projectName: 'auto-deploy',
    program: pulumiProgram,
  }

  const stack = await LocalWorkspace.createOrSelectStack(args)

  console.log('Running pulumi destroy...')
  await stack.destroy({ onOutput: console.log })

  console.log('Removing stack...')
  await stack.workspace.removeStack(stackName)
}

// 사용 예시
;(async () => {
  try {
    const outputs = await deployInfrastructure('dev', 'ap-northeast-2')
    console.log(`Website URL: ${outputs.websiteUrl.value}`)
  } catch (err) {
    console.error(`Error: ${err}`)
    process.exit(1)
  }
})()

HTTP API로 인프라 노출하기

Automation API를 Express.js와 결합하면 REST API로 인프라 프로비저닝을 노출할 수 있습니다.

import express from 'express'
import { InlineProgramArgs, LocalWorkspace } from '@pulumi/pulumi/automation'
import * as aws from '@pulumi/aws'

const app = express()
app.use(express.json())

// POST /api/environments - 새 환경 생성
app.post('/api/environments', async (req, res) => {
  const { name, region, instanceType } = req.body

  try {
    const program = async () => {
      const vpc = new aws.ec2.Vpc(`${name}-vpc`, {
        cidrBlock: '10.0.0.0/16',
        tags: { Name: `${name}-vpc` },
      })

      const subnet = new aws.ec2.Subnet(`${name}-subnet`, {
        vpcId: vpc.id,
        cidrBlock: '10.0.1.0/24',
        tags: { Name: `${name}-subnet` },
      })

      return { vpcId: vpc.id, subnetId: subnet.id }
    }

    const stack = await LocalWorkspace.createOrSelectStack({
      stackName: name,
      projectName: 'self-service-infra',
      program,
    })

    await stack.setConfig('aws:region', { value: region || 'ap-northeast-2' })
    const result = await stack.up({ onOutput: console.log })

    res.json({
      status: 'deployed',
      outputs: result.outputs,
      summary: result.summary,
    })
  } catch (error: any) {
    res.status(500).json({ error: error.message })
  }
})

// DELETE /api/environments/:name - 환경 삭제
app.delete('/api/environments/:name', async (req, res) => {
  const { name } = req.params

  try {
    const stack = await LocalWorkspace.selectStack({
      stackName: name,
      projectName: 'self-service-infra',
      program: async () => ({}),
    })

    await stack.destroy({ onOutput: console.log })
    await stack.workspace.removeStack(name)

    res.json({ status: 'destroyed' })
  } catch (error: any) {
    res.status(500).json({ error: error.message })
  }
})

// GET /api/environments/:name - 환경 상태 조회
app.get('/api/environments/:name', async (req, res) => {
  const { name } = req.params

  try {
    const stack = await LocalWorkspace.selectStack({
      stackName: name,
      projectName: 'self-service-infra',
      program: async () => ({}),
    })

    const outputs = await stack.outputs()
    const info = await stack.info()

    res.json({ stack: name, outputs, lastUpdate: info })
  } catch (error: any) {
    res.status(404).json({ error: `Stack ${name} not found` })
  }
})

app.listen(3000, () => console.log('Infra API running on :3000'))

이 패턴은 내부 개발자 플랫폼(IDP) 구축 시 매우 유용합니다. 개발자가 직접 인프라를 프로비저닝할 수 있는 셀프서비스 포털을 만들 수 있습니다.

테스트 전략

Pulumi의 큰 장점 중 하나는 표준 테스트 프레임워크로 인프라 코드를 테스트할 수 있다는 것입니다. 크게 단위 테스트(Unit Test)와 통합 테스트(Integration Test)로 나뉩니다.

단위 테스트 (Jest)

단위 테스트에서는 Pulumi 엔진을 모킹하여 실제 클라우드 리소스 없이 인프라 로직을 검증합니다.

// __tests__/infra.test.ts
import * as pulumi from '@pulumi/pulumi'

// Pulumi 런타임 모킹 - 반드시 import 전에 설정
pulumi.runtime.setMocks({
  newResource: (args: pulumi.runtime.MockResourceArgs): { id: string; state: any } => {
    // 리소스 타입별 모킹 응답
    switch (args.type) {
      case 'aws:s3/bucket:Bucket':
        return {
          id: `${args.name}-id`,
          state: {
            ...args.inputs,
            arn: `arn:aws:s3:::${args.name}`,
            bucket: args.name,
            websiteEndpoint: `${args.name}.s3-website.ap-northeast-2.amazonaws.com`,
          },
        }
      case 'aws:ec2/securityGroup:SecurityGroup':
        return {
          id: `sg-${args.name}`,
          state: {
            ...args.inputs,
            arn: `arn:aws:ec2:ap-northeast-2:123456789012:security-group/sg-${args.name}`,
          },
        }
      default:
        return {
          id: `${args.name}-id`,
          state: args.inputs,
        }
    }
  },
  call: (args: pulumi.runtime.MockCallArgs) => {
    return args.inputs
  },
})

// 테스트할 인프라 코드 import (모킹 설정 후에 import)
import { bucket, securityGroup } from '../index'

describe('Infrastructure Tests', () => {
  test('S3 버킷에 웹사이트 설정이 있어야 한다', async () => {
    const websiteConfig = await new Promise((resolve) =>
      bucket.website.apply((website) => resolve(website))
    )
    expect(websiteConfig).toBeDefined()
  })

  test('S3 버킷 이름에 환경 접두사가 포함되어야 한다', async () => {
    const bucketName = await new Promise((resolve) => bucket.id.apply((name) => resolve(name)))
    expect(bucketName).toContain('dev')
  })

  test('보안 그룹이 포트 443을 허용해야 한다', async () => {
    const ingress = await new Promise((resolve) =>
      securityGroup.ingress.apply((rules) => resolve(rules))
    )
    const httpsRule = (ingress as any[]).find((r: any) => r.fromPort === 443)
    expect(httpsRule).toBeDefined()
    expect(httpsRule.protocol).toBe('tcp')
  })

  test('보안 그룹이 SSH(22) 포트를 0.0.0.0/0에 열지 않아야 한다', async () => {
    const ingress = await new Promise((resolve) =>
      securityGroup.ingress.apply((rules) => resolve(rules))
    )
    const sshRule = (ingress as any[]).find(
      (r: any) => r.fromPort === 22 && r.cidrBlocks?.includes('0.0.0.0/0')
    )
    expect(sshRule).toBeUndefined()
  })
})

Jest 설정 파일도 함께 작성합니다.

// jest.config.ts
import type { Config } from 'jest'

const config: Config = {
  preset: 'ts-jest',
  testEnvironment: 'node',
  testMatch: ['**/__tests__/**/*.test.ts'],
  moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'],
  transform: {
    '^.+\\.tsx?$': 'ts-jest',
  },
  // Pulumi Output 타임아웃 방지
  testTimeout: 30000,
}

export default config

Policy as Code (CrossGuard)

Pulumi CrossGuard를 사용하면 정책을 코드로 정의하여 인프라 배포 시 자동으로 검증할 수 있습니다.

// policy-pack/index.ts
import * as policy from '@pulumi/policy'

new policy.PolicyPack('security-policies', {
  policies: [
    {
      name: 's3-no-public-read',
      description: 'S3 버킷에 퍼블릭 읽기 권한이 없어야 합니다',
      enforcementLevel: 'mandatory',
      validateResource: policy.validateResourceOfType(
        'aws:s3/bucket:Bucket',
        (bucket, args, reportViolation) => {
          if (bucket.acl === 'public-read' || bucket.acl === 'public-read-write') {
            reportViolation('S3 버킷에 퍼블릭 ACL이 설정되어 있습니다.')
          }
        }
      ),
    },
    {
      name: 'rds-encryption-required',
      description: 'RDS 인스턴스는 반드시 암호화되어야 합니다',
      enforcementLevel: 'mandatory',
      validateResource: policy.validateResourceOfType(
        'aws:rds/instance:Instance',
        (instance, args, reportViolation) => {
          if (!instance.storageEncrypted) {
            reportViolation('RDS 인스턴스에 스토리지 암호화가 활성화되어 있지 않습니다.')
          }
        }
      ),
    },
    {
      name: 'ec2-no-public-ip',
      description: 'EC2 인스턴스에 퍼블릭 IP가 할당되지 않아야 합니다',
      enforcementLevel: 'advisory',
      validateResource: policy.validateResourceOfType(
        'aws:ec2/instance:Instance',
        (instance, args, reportViolation) => {
          if (instance.associatePublicIpAddress) {
            reportViolation('EC2 인스턴스에 퍼블릭 IP가 할당되어 있습니다.')
          }
        }
      ),
    },
  ],
})

CI/CD 파이프라인 통합

GitHub Actions 워크플로우

Pulumi의 공식 GitHub Actions를 사용하면 PR 기반 인프라 변경 워크플로우를 구성할 수 있습니다.

# .github/workflows/pulumi-preview.yml
name: Pulumi Preview

on:
  pull_request:
    branches: [main]
    paths:
      - 'infra/**'

env:
  PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }}
  AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
  AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
  AWS_REGION: ap-northeast-2

jobs:
  preview:
    name: Pulumi Preview
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: infra

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
          cache-dependency-path: infra/package-lock.json

      # 플러그인 캐시로 CI 속도 향상
      - uses: actions/cache@v4
        with:
          path: |
            ~/.pulumi/plugins
            ~/.pulumi/policies
          key: ${{ runner.os }}-pulumi-${{ hashFiles('infra/package-lock.json') }}
          restore-keys: |
            ${{ runner.os }}-pulumi-

      - run: npm ci

      # 단위 테스트 실행
      - name: Run Unit Tests
        run: npm test

      # Pulumi Preview
      - uses: pulumi/actions@v6
        with:
          command: preview
          stack-name: organization/my-infra/staging
          work-dir: infra
          comment-on-pr: true
          comment-on-summary: true
# .github/workflows/pulumi-deploy.yml
name: Pulumi Deploy

on:
  push:
    branches: [main]
    paths:
      - 'infra/**'

env:
  PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }}
  AWS_REGION: ap-northeast-2

jobs:
  deploy-staging:
    name: Deploy to Staging
    runs-on: ubuntu-latest
    environment: staging
    defaults:
      run:
        working-directory: infra

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
          cache-dependency-path: infra/package-lock.json

      - uses: actions/cache@v4
        with:
          path: |
            ~/.pulumi/plugins
            ~/.pulumi/policies
          key: ${{ runner.os }}-pulumi-${{ hashFiles('infra/package-lock.json') }}

      - run: npm ci

      - uses: pulumi/actions@v6
        with:
          command: up
          stack-name: organization/my-infra/staging
          work-dir: infra

  deploy-production:
    name: Deploy to Production
    needs: deploy-staging
    runs-on: ubuntu-latest
    environment: production # GitHub Environment (수동 승인 가능)

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
          cache-dependency-path: infra/package-lock.json

      - uses: actions/cache@v4
        with:
          path: |
            ~/.pulumi/plugins
            ~/.pulumi/policies
          key: ${{ runner.os }}-pulumi-${{ hashFiles('infra/package-lock.json') }}

      - run: npm ci
        working-directory: infra

      - uses: pulumi/actions@v6
        with:
          command: up
          stack-name: organization/my-infra/production
          work-dir: infra

GitHub App을 설치하면 PR에 리소스 변경 요약이 자동으로 코멘트로 달립니다. 이를 통해 코드 리뷰어가 인프라 변경의 영향을 쉽게 파악할 수 있습니다.

트러블슈팅

1. Update Conflict (상태 충돌)

증상: error: the stack is currently locked by 1 lock(s) 또는 conflict: another update is in progress

원인: 다른 사용자가 동일 스택을 업데이트 중이거나, 이전 업데이트가 비정상 종료된 경우 발생합니다.

해결:

# 현재 업데이트 취소 (다른 사용자가 작업 중이 아닌 경우에만)
pulumi cancel

# 상태 확인
pulumi stack --show-urns

2. Interrupted Update (중단된 업데이트)

증상: error: update interrupted 또는 리소스가 pending 상태로 남아있는 경우

해결:

# 상태를 클라우드 프로바이더와 동기화
pulumi refresh --yes

# pending 작업이 있으면 자동으로 정리됨
# 이후 정상적으로 배포 재시도
pulumi up

3. Resource Already Exists (리소스 이미 존재)

증상: error: resource already exists - Pulumi 외부에서 생성한 리소스와 충돌

해결:

# 기존 리소스를 Pulumi 상태로 가져오기 (import)
pulumi import aws:s3/bucket:Bucket my-bucket my-existing-bucket-name

# 또는 코드에서 import 옵션 사용
const existingBucket = new aws.s3.Bucket(
  'my-bucket',
  {
    bucket: 'my-existing-bucket-name',
  },
  {
    import: 'my-existing-bucket-name', // 기존 리소스 import
  }
)

4. Output 값 접근 문제

증상: Calling [toString] on an [Output<T>] 경고 또는 [object Object]가 출력됨

원인: Output&lt;T&gt; 값을 직접 문자열로 사용하려 할 때 발생

해결:

// 잘못된 사용
const url = `http://${bucket.websiteEndpoint}` // [object Object] 출력

// 올바른 사용 1: apply 메서드
const url = bucket.websiteEndpoint.apply((ep) => `http://${ep}`)

// 올바른 사용 2: pulumi.interpolate 태그드 템플릿
const url = pulumi.interpolate`http://${bucket.websiteEndpoint}`

// 올바른 사용 3: pulumi.all로 여러 Output 결합
const combined = pulumi.all([bucket.id, bucket.arn]).apply(([id, arn]) => {
  return { id, arn }
})

5. Provider 버전 충돌

증상: failed to load plugin 또는 version mismatch 에러

해결:

# 플러그인 캐시 정리
rm -rf ~/.pulumi/plugins

# 의존성 재설치
npm install

# 특정 프로바이더 버전 고정
npm install @pulumi/aws@6.x.x --save-exact

# Pulumi 플러그인 설치
pulumi plugin install resource aws v6.x.x

6. 상태 파일 손상

증상: checkpoint file is not valid 또는 예상치 못한 리소스 상태

해결:

# 상태 파일 내보내기 (백업)
pulumi stack export > state-backup.json

# 상태 파일 검증 및 편집
# 문제 리소스를 수동으로 제거하거나 수정 가능

# 수정된 상태 파일 가져오기
pulumi stack import < state-fixed.json

# 또는 특정 리소스를 상태에서 제거
pulumi state delete 'urn:pulumi:dev::my-infra::aws:s3/bucket:Bucket::my-bucket'

프로덕션 체크리스트

프로덕션 환경에 Pulumi를 도입하기 전에 다음 항목을 확인하세요.

상태 관리

  • 상태 백엔드 선택 완료 (Pulumi Cloud 또는 S3/GCS)
  • S3 사용 시 버킷 버전 관리 활성화
  • S3 사용 시 서버측 암호화(SSE-KMS) 설정
  • S3 사용 시 버킷 정책으로 접근 제한
  • 상태 잠금(State Locking) 동작 확인

시크릿 관리

  • 시크릿 프로바이더 설정 (Pulumi Cloud, AWS KMS, HashiCorp Vault 등)
  • 민감한 설정값에 --secret 플래그 사용
  • ESC 환경 구성 (대규모 팀인 경우)
  • OIDC 기반 동적 자격 증명 설정

코드 품질

  • 단위 테스트 작성 및 CI에 통합
  • Policy Pack (CrossGuard) 정의
  • 코드 리뷰 프로세스 수립
  • 모듈화 및 컴포넌트 리소스 패턴 적용
  • TypeScript strict 모드 활성화

CI/CD

  • GitHub Actions (또는 다른 CI) 워크플로우 구성
  • PR 시 pulumi preview 자동 실행
  • main 브랜치 머지 시 pulumi up 자동 배포
  • 프로덕션 배포 시 수동 승인(GitHub Environments)
  • 플러그인 캐시 설정으로 CI 속도 최적화

운영

  • 스택 명명 규칙 수립 (organization/project/environment)
  • 리소스 태깅 정책 정의 (Environment, ManagedBy, Owner 등)
  • pulumi refresh 정기 실행으로 드리프트 감지
  • 스택 참조(StackReference) 패턴으로 프로젝트 분리
  • 롤백 절차 문서화

보안

  • IAM 최소 권한 원칙 적용
  • CI/CD에서 OIDC 기반 인증 사용 (장기 자격 증명 지양)
  • 상태 파일 접근 권한 제한
  • 감사 로그 활성화
  • deletionProtection 설정 (중요 리소스)

실패 사례와 복구 절차

사례 1: 프로덕션 RDS 실수 삭제

상황: pulumi destroy를 staging 스택에서 실행하려 했으나, production 스택이 선택된 상태에서 실행하여 RDS가 삭제되었습니다.

복구 절차:

# 1. 즉시 스택 상태 확인
pulumi stack select production
pulumi stack --show-urns

# 2. RDS 최종 스냅샷 확인 (deletionProtection이 없었다면)
aws rds describe-db-snapshots \
  --db-instance-identifier production-postgres \
  --query 'DBSnapshots[*].{ID:DBSnapshotIdentifier,Time:SnapshotCreateTime}' \
  --output table

# 3. 스냅샷에서 복원
aws rds restore-db-instance-from-db-snapshot \
  --db-instance-identifier production-postgres-restored \
  --db-snapshot-identifier production-final-snapshot

# 4. Pulumi 상태에 import
pulumi import aws:rds/instance:Instance production-postgres production-postgres-restored

예방책:

// deletionProtection 항상 활성화
const db = new aws.rds.Instance(
  'production-postgres',
  {
    // ... 설정 ...
    deletionProtection: true, // 실수 삭제 방지
  },
  {
    protect: true, // Pulumi 수준에서도 보호
  }
)

사례 2: 상태 파일과 실제 리소스 불일치 (Drift)

상황: AWS 콘솔에서 보안 그룹 규칙을 수동으로 변경했으나, Pulumi 상태에는 반영되지 않아 다음 배포 시 충돌 발생

복구 절차:

# 1. 드리프트 확인
pulumi refresh --diff

# 출력 예시:
# ~ aws:ec2/securityGroup:SecurityGroup (update)
#   ~ ingress: [
#       + { fromPort: 8080, toPort: 8080, ... }  # 수동으로 추가된 규칙
#     ]

# 2-A. 실제 상태를 코드에 반영 (수동 변경을 코드에 적용)
# 코드를 수정한 후:
pulumi refresh --yes
pulumi up

# 2-B. 코드 상태로 복원 (수동 변경을 되돌림)
pulumi up --yes  # refresh 없이 바로 up하면 코드 기준으로 복원

예방책: AWS 콘솔에서의 수동 변경을 금지하고, 모든 변경은 코드를 통해 진행하도록 팀 규칙을 정합니다. AWS Config Rules나 Pulumi CrossGuard를 통해 드리프트를 자동 감지하는 것도 좋은 방법입니다.

사례 3: CI/CD에서 동시 배포 충돌

상황: 두 PR이 거의 동시에 머지되어 두 개의 pulumi up이 동시에 실행되면서 상태 충돌 발생

복구 절차:

# 1. 충돌 해결 - 먼저 잠금 확인
pulumi cancel  # 대기 중인 업데이트 취소

# 2. refresh로 상태 동기화
pulumi refresh --yes

# 3. 다시 배포
pulumi up --yes

예방책: GitHub Actions의 concurrency 설정으로 동시 배포를 방지합니다.

# .github/workflows/pulumi-deploy.yml
concurrency:
  group: pulumi-${{ github.ref }}
  cancel-in-progress: false # 진행 중인 배포는 취소하지 않음

사례 4: 대규모 리팩토링 시 리소스 재생성 방지

상황: 리소스 이름이나 구조를 변경했을 때, Pulumi가 기존 리소스를 삭제하고 새로 생성(replace)하려고 하는 경우

복구 절차:

# 1. preview로 변경 사항 확인
pulumi preview --diff

# 2. 리소스 이름 변경 시 aliases 사용
// 기존 코드: new aws.s3.Bucket("old-name", {...})
// 변경 후:
const bucket = new aws.s3.Bucket(
  'new-name',
  {
    // ... 설정 ...
  },
  {
    aliases: [{ name: 'old-name' }], // 이전 이름을 별칭으로 지정
  }
)
# 3. 또는 상태에서 직접 리소스 이름 변경
pulumi state rename 'urn:pulumi:dev::project::aws:s3/bucket:Bucket::old-name' 'new-name'

참고자료