테라폼 실전 강좌 #9 환경 분리: 워크스페이스의 한계, 디렉터리 구조, moved 리팩터링
시리즈 내내 미뤄 온 청구서가 쌓여 있습니다. 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
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로 정적 자산을 서빙하며 전체 아키텍처를 완성하겠습니다.