테라폼 실전 강좌 #9 환경 분리: 워크스페이스의 한계, 디렉터리 구조, moved 리팩터링

5 분 소요

시리즈 내내 미뤄 온 청구서가 쌓여 있습니다. NAT 개수(#2), skip_final_snapshot과 prevent_destroy(#5) 같은 값들이 “실습에서는 이렇게, 운영에서는 반대로"인 채 하드코딩되어 있습니다. 운영 환경을 진짜로 만들려면 같은 구조에 다른 값을 넣는 방법이 필요합니다. 이번 편은 실전 시리즈에서 가장 테라폼다운 편입니다. 새 리소스는 거의 없고, 지금까지 만든 것을 재생성 없이 재구성하는 리팩터링이 전부입니다.

워크스페이스를 먼저 검토하고 접습니다 #

테라폼에는 terraform workspace라는 내장 기능이 있습니다. 같은 코드에서 state만 여러 벌 갖는 방식으로, workspace new prod를 만들면 같은 디렉터리에서 dev와 prod의 state가 분리됩니다. 간편해 보이지만 환경 분리 용도로는 한계가 뚜렷해서 실무 채택률이 낮습니다.

  • 환경 차이의 표현이 비틀립니다: 코드가 한 벌이니 환경별 차이를 전부 terraform.workspace == "prod" ? ... : ... 조건식으로 적게 되고, 코드가 조건식 밭이 됩니다.
  • 격리가 약합니다: 같은 백엔드를 공유하고, 지금 어느 워크스페이스에 있는지는 CLI 상태에 달려 있습니다. dev인 줄 알고 prod에 apply 하는 사고의 구조적 원인이 됩니다.
  • 권한을 나눌 수 없습니다: state가 한 버킷 경로 아래 있어 “dev는 누구나, prod는 CI만” 같은 접근 분리가 안 됩니다.

워크스페이스는 같은 구성의 임시 복제(PR 미리 보기 환경 등)에는 유용하지만, 성격이 다른 dev와 prod의 분리에는 다음 방식이 표준입니다.

목표 구조: modules와 envs #

프로젝트 구조
myapp-infra/
├── modules/
│   ├── network/    # VPC, 서브넷, 라우팅, NAT
│   ├── compute/    # ECS 클러스터, 서비스, ALB
│   └── data/       # RDS, S3, 시크릿
└── envs/
    ├── dev/
    │   ├── backend.tf    # key = "myapp/dev/terraform.tfstate"
    │   ├── main.tf       # 모듈 호출 (dev 값)
    │   └── ...
    └── prod/
        ├── backend.tf    # key = "myapp/prod/terraform.tfstate"
        ├── main.tf       # 모듈 호출 (prod 값)
        └── ...

구조가 곧 답입니다. 리소스 정의는 modules에 한 벌만 있고, 환경 디렉터리는 모듈에 값을 넣는 얇은 껍데기입니다. state는 환경마다 다른 key라 완전히 격리되고(#1에서 key를 myapp/dev/로 잡아 둔 것이 여기서 회수됩니다), prod 디렉터리에서의 apply만 prod를 건드리므로 워크스페이스의 사고 모드가 사라집니다. 모듈을 나누는 기준은 기초 #8의 “수명을 같이하는 단위"를 따랐습니다.

환경 차이는 모듈의 variable로 #

#5에서 하드코딩했던 값들이 모듈의 입력으로 승격됩니다.

환경별 모듈 호출
# modules/data/variables.tf
variable "deletion_protection" {
  type        = bool
  description = "true면 prevent_destroy 대상 + 최종 스냅샷 생성"
}

# envs/dev/main.tf
module "data" {
  source              = "../../modules/network"  # 등 생략
  deletion_protection = false
  db_instance_class   = "db.t4g.micro"
  nat_gateway_per_az  = false
}

# envs/prod/main.tf
module "data" {
  source              = "../../modules/data"
  deletion_protection = true
  db_instance_class   = "db.t4g.small"
  nat_gateway_per_az  = true
}

한 가지 테라폼의 제약을 만나게 됩니다. lifecycle의 prevent_destroy에는 변수를 쓸 수 없어서(plan 전에 확정되어야 하는 메타 인수), deletion_protection 변수는 RDS의 deletion_protection 인수(AWS 쪽 삭제 보호)와 skip_final_snapshot에 연결하고, prevent_destroy는 모듈 안에 true로 고정하는 절충이 일반적입니다. 완벽하지 않은 지점을 절충하는 것까지가 실무입니다.

moved 블록: 재생성 없는 주소 이동 #

리팩터링의 기술적 핵심입니다. 리소스가 모듈 안으로 들어가면 주소가 aws_vpc.main에서 module.network.aws_vpc.main으로 바뀝니다. 기초 #5에서 본 대로, 테라폼은 이것을 “옛 주소 삭제 + 새 주소 생성"으로 읽습니다. 아무 조치 없이 apply 하면 운영 중인 VPC와 RDS를 전부 지웠다 다시 만드는 plan이 나옵니다. 답이 moved 블록입니다.

envs/dev/moved.tf
# envs/dev/moved.tf
moved {
  from = aws_vpc.main
  to   = module.network.aws_vpc.main
}

moved {
  from = aws_db_instance.main
  to   = module.data.aws_db_instance.main
}
# ... 리소스마다 하나씩 ...

moved 블록이 있으면 plan이 두 주소를 같은 리소스로 인식해 state 주소만 옮기고 인프라는 건드리지 않습니다. 리팩터링 후 plan의 목표는 명확합니다. 0 to add, 0 to change, 0 to destroy에 moved 항목만 나열되는 것입니다. 이 “변경 없음 리팩터링"이 확인되면 apply 하고, 한동안 유지한 뒤 moved 블록은 지워도 됩니다. 리소스가 많아 수작업이 부담이면 terraform state mv를 스크립트로 돌리는 방법도 있지만, 리뷰에 남는 moved 쪽이 팀 작업에는 맞습니다.

prod를 세웁니다 #

리팩터링이 끝난 dev가 “변경 없음"으로 안정되면, prod는 처음부터 모듈 호출로 시작합니다. envs/prod에서 init과 apply를 실행하면 같은 구조가 prod 값(NAT 2개, 삭제 보호, 큰 인스턴스)으로 새로 만들어집니다. dev와 prod가 같은 모듈을 쓰므로 구조의 어긋남이 원천적으로 없고, 이후의 인프라 변경은 모듈을 고쳐 dev에 먼저 적용하고 검증 후 prod에 적용하는 흐름이 됩니다. 그 흐름을 사람 손이 아니라 CI가 돌리게 만드는 것이 운영 시리즈의 주제입니다.

정리 #

이번 글에서 다룬 내용입니다.

  • 워크스페이스는 조건식 남발, 약한 격리, 권한 분리 불가 때문에 dev·prod 분리에는 부적합합니다. 같은 구성의 임시 복제에만 씁니다
  • 표준 구조는 modules(정의 한 벌) + envs(환경별 얇은 껍데기)입니다. 환경별 백엔드 key로 state가 완전히 격리됩니다
  • 환경 차이는 모듈 variable로 표현합니다. prevent_destroy처럼 변수를 못 받는 메타 인수는 절충이 필요합니다
  • 모듈 리팩터링의 핵심은 moved 블록입니다. 목표는 add·change·destroy 모두 0인 “변경 없음 리팩터링"입니다
  • prod는 모듈 호출로 새로 세웁니다. dev에서 검증하고 prod에 적용하는 흐름의 기반이 완성됐습니다

다음 글(#10 도메인·HTTPS·완성)은 실전 시리즈의 마지막 편입니다. Route 53과 ACM 인증서로 HTTPS를 켜고, CloudFront로 정적 자산을 서빙하며 전체 아키텍처를 완성하겠습니다.

X