왜 순수 Terraform은 멀티환경에서 무너지는가

dev, staging, prod를 나누다 보면 대부분 디렉터리를 통째로 복사한다. 모듈 호출, backend 설정, provider 블록, 변수 파일이 환경마다 중복되고, 태그 규칙 하나를 바꾸려면 세 곳을 고쳐야 한다. Terraform 자체에는 backend 설정에 변수를 넣을 수 없고(bucket = var.x 불가), 여러 상태 파일을 하나의 명령으로 오케스트레이션하는 기능도 없다. 결국 복붙과 수동 apply가 쌓이며 환경 간 드리프트가 발생한다.

Terragrunt가 해결하는 지점

Terragrunt는 Terraform을 감싸는 얇은 래퍼다. 핵심은 세 가지다. backend 설정을 코드로 생성(remote_state), 상위 설정을 자식이 상속(include), 그리고 여러 모듈을 의존성 순서대로 실행(run-all). 환경별로 달라지는 것은 오직 "값"뿐이고, 구조와 규칙은 한 곳에서 정의한다.

디렉터리 레이아웃

모듈 정의(재사용 코드)와 라이브(환경별 인스턴스)를 분리한다.

live/
  terragrunt.hcl            # 루트: backend, provider, 공통 태그
  _envcommon/
    vpc.hcl                 # vpc 모듈 공통 입력
  dev/
    env.hcl                 # region, cidr 등 dev 값
    vpc/terragrunt.hcl
  prod/
    env.hcl
    vpc/terragrunt.hcl
modules/
  vpc/                      # 순수 terraform 모듈

루트 설정: backend와 provider를 한 번만

루트 terragrunt.hcl에서 상태 저장소와 provider를 동적으로 생성한다. path_relative_to_include()로 상태 키가 환경별로 자동 분리된다.

remote_state {
  backend = "s3"
  generate = { path = "backend.tf", if_exists = "overwrite" }
  config = {
    bucket         = "acme-tfstate"
    key            = "${path_relative_to_include()}/terraform.tfstate"
    region         = "ap-northeast-2"
    encrypt        = true
    dynamodb_table = "tf-locks"
  }
}

generate "provider" {
  path      = "provider.tf"
  if_exists = "overwrite"
  contents  = <

환경값과 공통 입력 상속

env.hcl에는 환경 고유 값만 둔다. 자식 모듈은 루트와 공통 입력을 include로 병합한다.

# dev/env.hcl
locals { env = "dev", region = "ap-northeast-2", cidr = "10.10.0.0/16" }

# dev/vpc/terragrunt.hcl
include "root"     { path = find_in_parent_folders() }
include "envcommon"{ path = "${dirname(find_in_parent_folders())}/_envcommon/vpc.hcl" }

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

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

inputs = {
  name = "${local.env.env}-vpc"
  cidr = local.env.cidr
}

prod에서 서브넷 구성을 다르게 하고 싶다면 prod/vpc/terragrunt.hcl의 inputs만 오버라이드한다. 모듈 코드는 손대지 않는다.

실행과 의존성 오케스트레이션

환경 전체를 한 번에 계획/적용한다. 모듈 간 의존은 dependency 블록으로 출력값을 주입한다.

# eks/terragrunt.hcl 에서 vpc 출력 참조
dependency "vpc" {
  config_path = "../vpc"
  mock_outputs = { vpc_id = "vpc-mock", subnet_ids = ["subnet-mock"] }
}
inputs = { vpc_id = dependency.vpc.outputs.vpc_id }
# dev 전체 plan (의존성 순서 자동 정렬)
cd live/dev
terragrunt run-all plan

# 특정 모듈만
cd live/dev/vpc && terragrunt apply

순수 Terraform 방식과의 비교

항목Terraform 단독Terragrunt
backend 설정환경마다 하드코딩루트에서 동적 생성
공통 변수tfvars 복붙include로 상속
모듈 간 의존수동 순서 applydependency + run-all
환경 추가 비용디렉터리 복제env.hcl 한 파일

주의점

mock_outputs는 plan 편의를 위한 것이지 실제 값이 아니다. prod에 잘못된 mock이 apply로 새어 들어가지 않도록 mock_outputs_allowed_terraform_commands = ["plan", "validate"]로 제한하라. run-all apply는 여러 상태를 동시에 바꾸므로 CI에서 반드시 plan 리뷰를 선행하고, 실패 시 부분 적용 상태를 점검해야 한다. 또한 generate로 만든 provider.tf·backend.tf는 .gitignore에 넣어 소스와 생성물을 분리하는 편이 혼선을 줄인다. 마지막으로 Terragrunt와 Terraform(OpenTofu 포함) 버전 조합을 terragrunt.hcl의 제약과 CI 이미지에 고정해 팀 전체가 동일 버전으로 실행하도록 하라.