티스토리 뷰
Terraform 4편 - Terraform 모듈을 함수처럼 설계하기 — 입력 검증, 출력 경계, 그리고 지워지면 안 되는 것
ebson 2026. 6. 12. 09:49들어가며
S3 버킷을 하나 제대로 만들려면 생각보다 손이 많이 갑니다. 암호화를 켜고, 버전 관리를 켜고, 퍼블릭 액세스를 네 가지 옵션으로 모두 막고, 라이프사이클 규칙을 걸고, HTTPS가 아닌 요청을 거부하는 정책까지 붙여야 합니다. 문제는 이런 버킷이 프로젝트 안에 하나가 아니라는 점입니다. 원시 데이터용, 정적 자산용, 로그용으로 여러 개를 만들다 보면, 어느 버킷에는 버전 관리를 빠뜨리고 어느 버킷에는 퍼블릭 차단 옵션 하나를 깜빡합니다. 같은 설정을 다섯 번 복사해 붙이는 방식은 다섯 번째에서 반드시 어긋납니다.
이 글은 그 반복을 모듈로 묶기로 한 결정과, 모듈을 어떤 기준으로 설계했는지를 정리하는 글입니다. 앞 글에서 Terraform을 "무엇이 있어야 하는지를 적는 선언형 도구"로 봤다면, 이번에는 그 선언을 재사용 가능한 단위로 끌어올리는 이야기입니다. 명령형 코드를 오래 다룬 개발자에게 모듈은 낯선 개념이 아닙니다. 함수와 거의 같은 자리에 있기 때문입니다. 그래서 이 글은 모듈을 함수에 빗대 풀어 보되, 함수에는 없는 인프라 특유의 장치 한두 가지에서 그 비유가 어디까지 통하고 어디서 깨지는지도 함께 짚어 보려 합니다.
기술적 사실은 HashiCorp 공식 문서에서 확인되는 범위 안에서 적고, 코드 예시는 이 저장소의 실제 모듈을 그대로 인용합니다. 새 예제를 지어내기보다, 돌아가고 있는 코드를 근거로 두는 편이 정직하다고 생각합니다.
모듈은 함수다 — 입력, 본문, 출력의 세 갈래
Terraform 모듈의 표준 구조는 간단합니다. 공식 문서가 권하는 최소 구성은 main.tf, variables.tf, outputs.tf 세 파일과 README.md입니다. variables.tf는 모듈이 받는 입력을 선언하고, main.tf는 실제로 자원을 만드는 본문이며, outputs.tf는 모듈이 바깥으로 내보내는 값을 선언합니다. 공식 문서는 변수와 출력에 한두 문장짜리 설명을 붙이라고 분명히 권합니다. 그 설명이 문서 자동 생성의 재료가 되기 때문입니다.
이 세 갈래를 함수에 그대로 겹쳐 볼 수 있습니다. variables.tf는 함수의 파라미터 목록이고, outputs.tf는 반환 타입이며, main.tf는 구현입니다. 함수를 쓸 때 우리는 그 안이 어떻게 짜였는지 몰라도 시그니처만 보고 호출합니다. 모듈도 마찬가지로, 호출하는 쪽은 어떤 입력을 주면 어떤 출력이 나오는지만 알면 됩니다. 본문에서 어떤 자원을 어떤 순서로 만드는지는 모듈 안으로 숨겨집니다. 이 숨김이 잘 되어 있을수록 모듈은 함수처럼 깔끔하게 재사용됩니다.
함수와 가까운 또 하나의 성질은 멱등성입니다. 같은 입력을 주면 같은 결과가 나와야 합니다. 그러려면 모듈 본문이 바깥 상태에 몰래 기대지 않아야 하는데, 이 점이 다음 절의 주제입니다.
환경 독립 — 모듈 본문에 계정 ID를 박지 않는다
좋은 함수는 전역 변수를 함부로 읽지 않습니다. 인자로 받은 값만으로 동작해야, 어디서 호출하든 같은 결과를 냅니다. 모듈에도 같은 규칙이 있습니다. 이 저장소의 설계 기준은 modules/ 아래의 모듈이 환경과 계정에 독립이어야 한다는 것입니다. 모듈 본문에 특정 계정 ID나 특정 환경의 ARN을 하드코딩하지 않는다는 뜻입니다.
이유는 재사용 그 자체입니다. dev 계정의 ID가 모듈 본문에 한 줄이라도 박히는 순간, 그 모듈은 beta나 prod로 옮겨 심을 수 없는 반쪽짜리가 됩니다. 함수 안에서 전역 변수를 읽어 버린 것과 똑같은 상황입니다. 그래서 환경마다 달라지는 값은 전부 입력 변수로 빼고, 모듈은 받은 값으로만 동작하게 둡니다. 실제로 이 저장소의 iam-role-workload 모듈은 Role 이름을 본문에서 직접 짓지 않고, project와 environment를 입력으로 받아 {project}-{environment}-task-{workload_name} 형태로 조립합니다. 환경이라는 변수가 본문 바깥에서 주입되기 때문에, 같은 모듈이 dev에서도 prod에서도 각자의 이름으로 Role을 만듭니다.
이 저장소의 자원 이름 규칙도 같은 맥락에 있습니다. techai-{env}-{component}-{purpose} 형태로, 환경 이름이 식별자 안에 들어갑니다. 이름 하나에도 환경이 변수로 박혀 있어야, 같은 설계를 여러 환경에 펼쳤을 때 자원들이 서로 충돌하지 않고 구분됩니다.
입력 설계와 validation — 잘못된 입력을 자원 만들기 전에 끊는다
입력 변수를 설계할 때 이 저장소가 둔 규칙은 두 갈래입니다. 반드시 받아야 하는 값(required)은 기본값을 두지 않아서, 호출하는 쪽이 빠뜨리면 곧장 드러나게 합니다. 선택값(optional)은 기본값을 주되, 그 기본값이 무엇인지 분명히 둡니다. s3-bucket 모듈에서 bucket_name은 기본값이 없는 필수 입력이고, versioning_enabled는 기본값 true를 둔 선택 입력입니다. 버킷 이름은 사람이 정해야 하지만 버전 관리는 켜는 쪽이 안전한 기본이라는 판단이 변수 정의에 그대로 담겨 있습니다.
여기에 더해, 타입만으로는 막을 수 없는 입력에는 validation 블록을 답니다. 공식 문서가 설명하는 custom validation rule은 참/거짓을 판정하는 condition과, 거짓일 때 보여 줄 error_message 두 부분으로 이뤄집니다. 이 저장소의 s3-bucket 모듈은 버킷 이름에 다음과 같은 검증을 둡니다.
variable "bucket_name" {
description = "S3 버킷 이름. 전역 유일."
type = string
validation {
condition = can(regex("^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$", var.bucket_name))
error_message = "S3 버킷 이름 규칙 위반."
}
}
type = string만으로는 "문자열이다"까지밖에 못 막습니다. 그런데 S3 버킷 이름에는 길이와 허용 문자 규칙이 따로 있습니다. 그 규칙을 정규식으로 적고 can()으로 감싸, 형식에 맞지 않는 이름이 들어오면 검증에서 걸리게 했습니다. 화이트리스트 방식의 검증도 있습니다. Object Lock 모드는 contains(["GOVERNANCE", "COMPLIANCE"], var.object_lock_mode)로, 정해진 두 값 외에는 받지 않습니다.
Java 개발자라면 이 장치를 생성자 인자 검증에 빗댈 수 있습니다. 다만 차이가 하나 있습니다. 보통 우리는 객체를 만드는 시점에 인자를 검사하는데, Terraform의 validation은 그 검사를 호출 시점이 아니라 변수의 계약으로 못 박아 둡니다. 잘못된 입력이면 실제로 자원을 만들기 전에 오류로 멈춥니다. 공식 문서가 검증이 정확히 plan 단계인지 apply 단계인지를 이 페이지에서 못 박지는 않아 단계 이름까지 단정하지는 않겠지만, 적어도 잘못된 값으로 실제 인프라가 만들어지기 전에 걸러진다는 점은 분명합니다. 인자 검증을 코드 곳곳에 흩뿌리는 대신 입력 정의 한곳에 모아 둔 셈이라, 같은 모듈을 쓰는 모든 호출이 같은 검증을 자동으로 거칩니다.
출력 설계 — 다음 계층이 실제로 쓰는 값만 내보낸다
출력은 입력의 거울입니다. 함수의 반환 타입을 정할 때 "이 함수를 부른 쪽이 무엇을 필요로 하는가"를 묻듯, 모듈의 출력도 다음 계층이 실제로 쓰는 값만 추립니다. 이 저장소의 기준은 ID, ARN, Endpoint처럼 다른 모듈이나 환경 조립 계층이 참조해야 하는 값만 노출하는 것입니다. 모듈 안에서 만든 모든 자원의 속성을 다 내보내면, 그건 함수가 내부 변수를 전부 반환하는 것과 같아서 경계가 흐려집니다.
s3-bucket 모듈의 출력을 보면 의도가 드러납니다. bucket_regional_domain_name은 "CloudFront Origin에 사용"이라는 설명과 함께 나가고, bucket_hosted_zone_id는 "Route 53 alias 레코드에 사용"이라는 설명을 답니다. 단순히 값을 내보내는 게 아니라, 그 값을 누가 어디에 쓰는지가 출력 정의에 적혀 있습니다. 다음 계층이 이 버킷을 CloudFront의 오리진으로 연결하거나 DNS 레코드로 가리킬 때 필요한 값을, 그 용도와 함께 건네주는 것입니다.
민감한 값에는 한 가지 장치가 더 붙습니다. 공식 기준에 따라, 평문으로 노출되면 안 되는 값은 sensitive = true로 표시합니다. 이렇게 하면 plan이나 출력에서 그 값이 그대로 찍히지 않습니다. 데이터베이스의 마스터 자격 증명 관련 ARN처럼 로그에 남으면 곤란한 값을 다룰 때 쓰는 표시입니다. 반환 타입에 "이건 화면에 찍지 말 것"이라는 꼬리표를 붙이는 셈입니다.
목적 단위 모듈 — Role 하나만 책임지게 둔다
모듈을 어디까지 쪼갤지는 늘 헷갈리는 문제입니다. 이 저장소가 둔 기준은 모듈을 목적 단위로 만들고, 한 번만 쓰는 코드는 모듈로 빼지 않는다는 것입니다. 미리 일반화하지 않고, 정말 반복되는 것만 모듈로 올린다는 단순함의 원칙과 맞물립니다.
iam-role-workload 모듈이 이 감각을 잘 보여 줍니다. 이 모듈은 워크로드 Role을 딱 하나 만듭니다. 여러 개의 Role이 필요하면, 모듈이 내부에서 여러 개를 찍어 내는 게 아니라 호출하는 쪽이 모듈을 여러 번 부릅니다. 모듈 안에서는 managed_policy_arns나 inline_policies처럼 한 Role에 붙는 정책 묶음에만 for_each를 쓰고, "여러 Role을 한꺼번에"라는 식의 반복은 모듈 경계 밖으로 밀어냅니다. 이렇게 하면 모듈이 책임지는 범위가 또렷해집니다. 이 모듈은 "신뢰 서비스 하나와 정책 묶음을 받아 Role 하나를 표준대로 만든다"가 전부입니다.
경계를 이렇게 좁게 잡으면 입력 검증도 깔끔해집니다. 이 모듈은 environment가 dev, beta, prod 중 하나인지, trust_service가 .amazonaws.com으로 끝나는 AWS 서비스 형식인지를 각각 validation으로 확인합니다. 한 Role만 책임지기 때문에 검증할 입력도 그 Role에 필요한 것으로 한정됩니다. 만약 한 모듈이 여러 종류의 Role을 한꺼번에 다루려 했다면, 입력과 검증이 금세 복잡해졌을 것입니다. 모듈을 작게 유지하는 일은 곧 그 모듈의 계약을 단순하게 유지하는 일이기도 합니다.
라이프사이클 가드 — 지워지면 안 되는 것
여기서부터는 함수 비유가 닿지 않는 자리입니다. 인프라에는 한 번 지우면 되돌리기 어렵거나 비용이 큰 자원이 있습니다. 데이터베이스, 암호화 키, 상태를 보관하는 버킷이 그렇습니다. 코드 한 줄을 잘못 고쳐 이런 자원이 destroy 대상에 들어가면, 함수에서 잘못된 값을 반환하는 것과는 차원이 다른 사고가 됩니다.
Terraform은 이를 막는 장치를 라이프사이클 메타 인자로 둡니다. 공식 문서에 따르면 prevent_destroy를 켜면 Terraform은 그 자원을 파괴하게 되는 plan을 거부하고 오류를 냅니다. 그래서 이 저장소는 데이터베이스, KMS 키, 상태 버킷 같은 불가역·고비용 자원에 prevent_destroy = true를 답니다. 실수로 destroy가 섞인 plan이 apply로 넘어가기 전에 멈추게 하는 안전장치입니다.
다만 공식 문서는 이 장치의 한계도 분명히 적어 둡니다. prevent_destroy는 정상적인 apply 과정에서의 우발적 파괴를 막을 뿐, 설정 자체를 지워 버리면 막지 못합니다. 자원 블록을 코드에서 통째로 들어내면 이 가드도 함께 사라집니다. 그러니 이 장치를 "걸어 두면 절대 안 지워진다"로 오해하지 않는 편이 안전합니다. 우발적 사고를 줄이는 가드일 뿐, 모든 삭제를 봉인하는 자물쇠는 아닙니다. 그래서 이 가드는 정말 불가역·고비용인 자원에만 한정해 거는 것이 이 저장소의 기준입니다. 모든 자원에 무턱대고 걸면, 정작 정당하게 교체해야 할 때 가드가 발목을 잡습니다.
라이프사이클에는 ignore_changes라는 인자도 있습니다. 특정 속성의 변경을 Terraform이 무시하게 만드는 장치인데, 공식 문서는 이것이 update를 계획할 때 해당 속성을 무시한다고 설명합니다. 외부 프로세스가 바꾸는 값을 Terraform이 매번 되돌리지 않게 할 때 유용하지만, 이 저장소는 이걸 남용하지 않기로 했습니다. 무시 대상으로 묶어 버리면 실제 인프라와 코드 사이의 차이, 즉 드리프트가 보이지 않게 가려지기 때문입니다. 불가역 속성을 바꿔야 한다면 ignore_changes로 덮는 대신 변경을 승인 절차로 다루는 편을 택했습니다. 차이를 숨기는 것과 차이를 관리하는 것은 다른 일입니다.
마무리 — 좋은 모듈은 호출부가 짧다
이 글에서 정리한 모듈 설계를 한 줄로 줄이면, "모듈을 환경에 독립적인 함수처럼 만들고, 입력은 검증으로 계약을 못 박고, 출력은 다음 계층이 쓰는 값만 내보내고, 지워지면 안 되는 것에는 가드를 건다"가 됩니다. 입력·본문·출력의 세 갈래와 라이프사이클 가드는 모두 그 모듈을 여러 환경에 안전하게 재사용하기 위한 장치입니다.
개인적으로 이 설계를 정리하면서 가장 또렷해진 기준은 "좋은 모듈은 호출부가 짧다"는 것이었습니다. 모듈이 환경 독립을 잘 지키고 입력 검증을 계약으로 못 박아 두면, 그 모듈을 부르는 쪽은 필요한 값 몇 개만 넘기면 됩니다. 검증도 기본값도 이름 규칙도 모듈 안에 들어 있으니, 호출부는 "무엇을 만들지"만 말하고 "어떻게 만들지"는 묻지 않습니다. 함수를 잘 설계했을 때 호출 코드가 읽기 쉬워지는 경험과 똑같았습니다.
또 하나 남는 인상은, 인프라 코드에는 함수에 없는 한 겹이 더 있다는 점이었습니다. prevent_destroy 같은 가드는 일반적인 프로그래밍에는 대응물이 마땅치 않습니다. 코드가 만들어 내는 것이 메모리 위의 값이 아니라 실제로 데이터를 담고 있는 자원이기 때문에, "되돌릴 수 없음"이라는 무게를 코드 차원에서 다뤄야 합니다. 그 무게를 의식하는 일이 인프라 코드를 짤 때 가장 다른 점이라고 느꼈습니다. 이 시리즈의 다음 글에서는 이렇게 만든 모듈들을 dev, beta, prod 세 환경에 변수만 바꿔 가며 조립하는 이야기로 이어 가려 합니다. 비슷한 자리에서 모듈의 경계를 어디에 그을지 고민하는 분들에게 이 글이 작은 참고가 되기를 바라며 마무리합니다.
참고한 공식 문서
- Modules — Overview — https://developer.hashicorp.com/terraform/language/modules
- Standard Module Structure — https://developer.hashicorp.com/terraform/language/modules/develop/structure
- Input Variables (Custom Validation Rules) — https://developer.hashicorp.com/terraform/language/values/variables
- The lifecycle Meta-Argument — https://developer.hashicorp.com/terraform/language/meta-arguments/lifecycle
- Terraform Recommended Practices — https://developer.hashicorp.com/terraform/cloud-docs/recommended-practices
'TECH AND AI > DEVOPS' 카테고리의 다른 글
| Terraform 6편 - 팀에서 Terraform을 안전하게 굴리기 — 게이트·권한·시크릿으로 사고를 막는 법 (1) | 2026.06.12 |
|---|---|
| Terraform 5편 - 환경 조립 — 변수 한두 개로 dev/beta/prod를 가른다 (0) | 2026.06.12 |
| Terraform 3편 - HCL은 작은 언어입니다 — 블록 다섯 종과 반복 세 가지로 모듈을 읽기 (0) | 2026.06.12 |
| Terraform 2편 - State — Terraform이 현실을 기억하는 법 (0) | 2026.06.12 |
| Terraform 1편 - 셸 스크립트에서 Terraform으로 — 인프라를 '어떻게'가 아니라 '무엇'으로 적는 사고 전환 (0) | 2026.06.12 |
- Total
- Today
- Yesterday
- 백엔드 성능 튜닝
- 동시성처리
- 캐시 장애
- Enum 기반 싱글톤
- Java Performance
- Double-Checked Locking
- Spring Batch
- 캐시와 인덱스
- 캐시 성능 비교
- Initialization-on-Demand Holder Idiom
- InterruptedException
- Cache Penetration
- DB 트랜잭션
- Cache Avalanche
- Eager Initialization
- 트랜잭션 관리
- 스레드 생명주기
- Hot Key 문제
- 트래픽 처리
- mybatis
- Redis vs DB
- spring batch 5
- Cache Aside
- Redis 캐시 전략
- TTL 설계
- 백엔드 성능 설계
- 백엔드 성능
- DB 인덱스 성능
- Redis 성능 개선
- 백엔드 아키텍처
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | |
| 7 | 8 | 9 | 10 | 11 | 12 | 13 |
| 14 | 15 | 16 | 17 | 18 | 19 | 20 |
| 21 | 22 | 23 | 24 | 25 | 26 | 27 |
| 28 | 29 | 30 |
