Skip to content
ioob.dev
Go back

Terraform 11편 — 워크스페이스와 환경 분리

· 8분 읽기
Terraform 시리즈 (11/15)
  1. Terraform 1편 — Terraform이란
  2. Terraform 2편 — 설치와 첫 배포
  3. Terraform 3편 — HCL 문법
  4. Terraform 4편 — 변수와 출력
  5. Terraform 5편 — 프로바이더
  6. Terraform 6편 — 리소스와 의존성
  7. Terraform 7편 — 데이터 소스와 Import
  8. Terraform 8편 — State 관리
  9. Terraform 9편 — 모듈
  10. Terraform 10편 — 반복과 조건
  11. Terraform 11편 — 워크스페이스와 환경 분리
  12. Terraform 12편 — Kubernetes와 Helm 프로바이더
  13. Terraform 13편 — CI/CD 통합
  14. Terraform 14편 — 테스트와 정책
  15. Terraform 15편 — 실전 패턴과 함정
Table of contents

Table of contents

환경 분리는 왜 어려운가

“dev에 배포한 인프라를 그대로 prod에도 만들어주세요.” 말은 간단하지만 실제로는 까다롭다. prod는 크기가 다르고, 백업 정책도 다르고, 접근 권한도 다르다. 완전히 같은 것을 두 번 만드는 게 아니라, 같은 패턴을 조금씩 다른 파라미터로 재현하는 문제다.

환경이 분리된다는 건 state도 완전히 분리된다는 뜻이다. dev의 실수가 prod에 영향을 주면 안 된다. state가 같은 곳에 있으면 한쪽 작업이 다른 쪽을 망가뜨릴 수 있다.

flowchart TB
    subgraph "환경 분리의 요구사항"
        direction TB
        Sep1["state 완전 분리"]
        Sep2["변수/설정 차이 허용"]
        Sep3["접근 권한 분리"]
        Sep4["승인 프로세스 차등화"]
        Sep5["코드 중복 최소화"]
    end

Terraform이 제공하는 해결책은 두 가지다. 워크스페이스디렉토리 기반 분리. 둘을 보완해주는 도구로 Terragrunt가 있다. 이 셋을 차례로 살펴본다.

terraform workspace

Terraform은 기본적으로 default라는 워크스페이스에서 동작한다. 워크스페이스를 전환하면 state 파일이 분리된다.

# 현재 워크스페이스 확인
terraform workspace show
# default

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

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

# 리스트
terraform workspace list
#   default
# * dev
#   prod

원격 백엔드(S3 등)를 쓰는 경우, state 파일 경로가 워크스페이스별로 자동 분리된다. 예를 들어 백엔드 설정이 이렇다면:

terraform {
  backend "s3" {
    bucket = "my-tfstate"
    key    = "app/terraform.tfstate"
    region = "ap-northeast-2"
  }
}

실제 S3 경로는 이렇게 생긴다.

s3://my-tfstate/
├── app/terraform.tfstate                    # default
└── env:/
    ├── dev/app/terraform.tfstate            # dev 워크스페이스
    └── prod/app/terraform.tfstate           # prod 워크스페이스

코드에서는 terraform.workspace 변수로 현재 워크스페이스 이름을 참조할 수 있다.

locals {
  environment = terraform.workspace

  instance_count = {
    dev     = 1
    staging = 2
    prod    = 5
  }[terraform.workspace]
}

resource "aws_instance" "app" {
  count         = local.instance_count
  instance_type = local.environment == "prod" ? "m5.large" : "t3.micro"

  tags = {
    Environment = local.environment
  }
}

한 번 보면 깔끔해 보인다. 코드는 한 벌, 워크스페이스만 바꾸면 여러 환경이 나오니까. 그런데 실무에서는 이게 함정이 되기도 한다.

워크스페이스의 한계

HashiCorp 공식 문서도 워크스페이스를 “경미하게 다른 환경을 위한 경량 분리”로만 권장한다. 프로덕션 분리에는 부적합하다는 뜻이다. 왜 그럴까.

1) 코드가 하나라 리스크가 크다

dev 워크스페이스에서 잘못된 코드 변경을 했다. 그런데 이 코드는 prod에서도 쓰는 코드다. dev에서 확인하고 prod로 넘어가는 게 아니라, 하나의 코드에 대해 워크스페이스만 다르게 apply하는 구조다. 프로덕션만을 위한 검증 절차를 넣기가 어렵다.

2) 설정 파일이 분리되지 않는다

워크스페이스는 state를 분리할 뿐, 코드는 공유한다. 환경별 설정을 terraform.workspace로 분기해서 쓰는데, 분기가 많아지면 코드가 복잡해지고 가독성이 떨어진다.

resource "aws_db_instance" "db" {
  instance_class = {
    dev     = "db.t3.micro"
    staging = "db.t3.medium"
    prod    = "db.r5.xlarge"
  }[terraform.workspace]

  allocated_storage = {
    dev     = 20
    staging = 50
    prod    = 500
  }[terraform.workspace]

  backup_retention_period = {
    dev     = 1
    staging = 7
    prod    = 30
  }[terraform.workspace]

  # ... 수십 개 속성마다 워크스페이스 분기
}

이 정도만 해도 읽기 싫어진다. 변수 파일로 빼낼 수 있지만, 결국 디렉토리 분리와 별 차이가 없어진다.

3) 백엔드 자체를 분리할 수 없다

워크스페이스는 같은 백엔드 안에서 key 경로만 다르다. dev state를 다른 AWS 계정의 S3 버킷에 두고 싶다면 워크스페이스로는 불가능하다. 환경별로 완전히 분리된 백엔드를 쓰려면 워크스페이스가 아니라 다른 접근이 필요하다.

4) 실수 가능성

현재 워크스페이스가 뭔지 프롬프트에 안 찍힌다. terraform apply를 쳤는데 사실 prod 워크스페이스였다면? 자동화를 안 한 상태에서는 실수가 나기 쉽다.

정리: 워크스페이스는 같은 팀원이 짧게 쓰는 실험용 샌드박스기능별 미리보기 환경 정도에는 적합하다. 장기적인 dev/staging/prod 분리에는 부적합하다.

디렉토리 기반 환경 분리

실무에서 훨씬 많이 쓰이는 방식이다. 환경별로 디렉토리를 완전히 분리한다.

infra/
├── modules/               # 재사용할 모듈들
│   ├── vpc/
│   ├── eks/
│   └── rds/
└── envs/
    ├── dev/
    │   ├── main.tf        # 모듈 조합
    │   ├── variables.tf
    │   ├── terraform.tfvars
    │   └── backend.tf     # dev용 state 백엔드
    ├── staging/
    │   ├── main.tf
    │   ├── variables.tf
    │   ├── terraform.tfvars
    │   └── backend.tf     # staging용 state 백엔드
    └── prod/
        ├── main.tf
        ├── variables.tf
        ├── terraform.tfvars
        └── backend.tf     # prod용 state 백엔드

각 환경은 독립된 디렉토리이고, 독립된 백엔드를 가진다. 모듈은 공유하되, 환경별 설정은 각 디렉토리 안에 둔다.

flowchart TB
    Modules["modules/\n(재사용 단위)"]

    Dev["envs/dev/"]
    Staging["envs/staging/"]
    Prod["envs/prod/"]

    Modules --> Dev
    Modules --> Staging
    Modules --> Prod

    Dev -->|"terraform init/apply\n(dev 디렉토리에서)"| DevState["dev state\n(dev S3 버킷)"]
    Staging -->|"terraform init/apply\n(staging 디렉토리에서)"| StagingState["staging state\n(staging S3 버킷)"]
    Prod -->|"terraform init/apply\n(prod 디렉토리에서)"| ProdState["prod state\n(prod S3 버킷)"]

각 환경의 main.tf는 이런 식으로 모듈을 조합한다.

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

  cidr_block  = "10.0.0.0/16"
  environment = "prod"
  azs         = ["ap-northeast-2a", "ap-northeast-2c", "ap-northeast-2d"]
}

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

  cluster_name = "prod-eks"
  vpc_id       = module.vpc.vpc_id
  subnet_ids   = module.vpc.private_subnet_ids

  node_groups = {
    default = {
      instance_type = "m5.large"
      min_size      = 3
      max_size      = 10
      desired_size  = 5
    }
  }
}
# envs/dev/main.tf
module "vpc" {
  source = "../../modules/vpc"

  cidr_block  = "10.10.0.0/16"  # prod와 다른 CIDR
  environment = "dev"
  azs         = ["ap-northeast-2a", "ap-northeast-2c"]
}

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

  cluster_name = "dev-eks"
  vpc_id       = module.vpc.vpc_id
  subnet_ids   = module.vpc.private_subnet_ids

  node_groups = {
    default = {
      instance_type = "t3.medium"  # 작게
      min_size      = 1
      max_size      = 3
      desired_size  = 1
    }
  }
}

같은 모듈을 쓰지만, 각 환경별로 파라미터가 다르다. 작업은 각 디렉토리에 들어가서 한다.

cd envs/dev
terraform init
terraform apply

# prod로 전환
cd ../prod
terraform init
terraform apply

장점

단점

대부분의 실무 팀이 이 방식을 쓴다. 중복 문제는 모듈화로 어느 정도 완화되지만, 완벽하진 않다. 이 빈틈을 메우는 도구가 Terragrunt다.

Terragrunt 간단 소개

Terragrunt는 Gruntwork에서 만든 Terraform 래퍼다. “DRY(Don’t Repeat Yourself)하게 Terraform을 쓰자”가 핵심 철학이다.

Terragrunt의 핵심 아이디어는 환경별 디렉토리에서 반복되는 것(백엔드 설정, 프로바이더, 공통 변수)을 한 번만 선언하고 상속시키는 것이다.

전형적인 Terragrunt 프로젝트 구조다.

infra/
├── terragrunt.hcl              # 루트 설정 (전역 백엔드, 프로바이더)
├── _envcommon/                 # 환경 공통 컴포넌트 정의
│   ├── vpc.hcl
│   └── eks.hcl
└── envs/
    ├── dev/
    │   ├── env.hcl             # dev 전역 변수
    │   ├── vpc/
    │   │   └── terragrunt.hcl  # _envcommon/vpc.hcl 상속
    │   └── eks/
    │       └── terragrunt.hcl
    └── prod/
        ├── env.hcl
        ├── vpc/
        │   └── terragrunt.hcl
        └── eks/
            └── terragrunt.hcl

루트 terragrunt.hcl에서 백엔드 한 번만 선언

# infra/terragrunt.hcl
remote_state {
  backend = "s3"

  config = {
    bucket         = "my-company-tfstate"
    key            = "${path_relative_to_include()}/terraform.tfstate"
    region         = "ap-northeast-2"
    encrypt        = true
    dynamodb_table = "my-company-tflock"
  }

  generate = {
    path      = "backend.tf"
    if_exists = "overwrite"
  }
}

${path_relative_to_include()}가 핵심이다. 각 하위 디렉토리의 상대 경로가 자동으로 key에 들어간다. envs/dev/vpc 에서 실행하면 key가 자동으로 envs/dev/vpc/terraform.tfstate가 된다. 환경별, 컴포넌트별 state 분리가 공짜로 해결된다.

환경 설정 한 번만

# envs/dev/env.hcl
locals {
  environment = "dev"
  aws_region  = "ap-northeast-2"
  account_id  = "111122223333"
}

실제 컴포넌트 선언

# envs/dev/vpc/terragrunt.hcl
include "root" {
  path = find_in_parent_folders()
}

include "envcommon" {
  path = "${get_terragrunt_dir()}/../../../_envcommon/vpc.hcl"
}

locals {
  env = read_terragrunt_config(find_in_parent_folders("env.hcl")).locals
}

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

inputs = {
  cidr_block  = "10.10.0.0/16"
  environment = local.env.environment
}

프로덕션 VPC의 terragrunt.hcl도 거의 같고, inputs 부분의 CIDR만 다르다.

Terragrunt가 주는 다른 강력한 기능은 의존성 자동 관리다.

# envs/dev/eks/terragrunt.hcl
dependency "vpc" {
  config_path = "../vpc"
}

inputs = {
  vpc_id     = dependency.vpc.outputs.vpc_id
  subnet_ids = dependency.vpc.outputs.private_subnet_ids
}

dependency 블록이 다른 Terragrunt 프로젝트의 출력을 자동으로 가져온다. VPC의 출력을 수동으로 복사하거나 데이터 소스로 가져올 필요가 없다.

run-all 명령

# 모든 하위 프로젝트를 의존성 순서대로 apply
terragrunt run-all apply

# 특정 환경 전체
cd envs/dev
terragrunt run-all apply

Terragrunt가 DAG를 계산해서 VPC → EKS → 앱 순서로 자동 apply한다.

언제 Terragrunt를 도입할까

전략 비교

지금까지의 세 가지 전략을 정리하면 이렇다.

항목workspace디렉토리 분리Terragrunt
state 분리같은 백엔드 내 경로 분리완전 독립 백엔드완전 독립 백엔드
멀티 AWS 계정어려움가능가능, 쉬움
코드 중복적음많음 (환경 수만큼)적음
백엔드 설정한 번환경별 반복한 번
러닝커브낮음낮음중간
의존성 자동화없음없음있음
대규모 적합성낮음중간높음

실전 권장 조합

어느 쪽을 택하든 prod state는 반드시 완전히 분리된 백엔드에 둔다. 워크스페이스로 prod를 분리하는 건 장기적으로 리스크가 크다.

CI/CD와의 조합

환경 분리는 CI/CD와도 맞물린다. 전형적인 파이프라인은 이렇다.

flowchart LR
    PR["PR 생성"] --> Plan["terraform plan\n(모든 환경)"]
    Plan --> Review["리뷰 & 승인"]
    Review --> Merge["main 머지"]
    Merge --> Dev["dev auto-apply"]
    Dev --> Stg["staging auto-apply"]
    Stg --> Manual["prod 수동 승인"]
    Manual --> Prod["prod apply"]

PR 시점에 모든 환경의 plan 결과를 코멘트로 보여주고, 머지되면 dev와 staging은 자동 apply, prod는 수동 승인 단계를 거치는 흐름이다. 디렉토리 기반 분리라면 각 환경 디렉토리에서 개별적으로 Terraform을 돌리면 되고, Terragrunt라면 run-all로 한 번에 처리할 수 있다.

이 파이프라인은 다음 편(CI/CD 통합)에서 자세히 다룬다.


환경 분리는 “코드 중복 최소화”와 “state 독립성”이 정반대 방향으로 당기는 균형점이다. 팀 규모와 환경 복잡도에 맞춰 적절한 지점을 고른다. 확실한 건, prod는 반드시 독립된 state를 가져야 한다는 것이다.

다음 편에서는 Kubernetes와 Helm 프로바이더를 다룬다. Terraform으로 클러스터 내부 리소스까지 관리할지, ArgoCD에 맡길지의 선택 기준도 함께 살펴본다.

12편: Kubernetes와 Helm 프로바이더


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
Terraform 10편 — 반복과 조건
Next Post
Terraform 12편 — Kubernetes와 Helm 프로바이더