Modules — Boundaries Before Reuse
한국어 원문으로 표시합니다.
한 줄 요약
모듈은 코드를 짧게 만드는 장치가 아니라 변경이 번져 나가는 반경을 제한하는 장치다.
왜 이게 필요했나
같은 스택을 dev·stage·prod 에 만든다고 하자. 처음에는 디렉터리를 세 벌 복사하는 것이 가장 빠르다. 실제로 빠르다. 문제는 6개월 뒤다. prod 에 급하게 넣은 설정 하나가 dev 에는 없고, stage 에는 반쯤 들어가 있다. 세 디렉터리는 이제 서로 다른 생물이 되어 있고, "prod 에서만 나는 문제"의 원인을 찾으려면 세 파일을 나란히 놓고 diff 를 떠야 한다.
여기서 진짜 비용은 중복된 줄 수가 아니다. 변경을 세 번 해야 한다는 사실, 그리고 세 번 중 한 번을 빠뜨려도 아무도 모른다는 사실이다. 모듈은 이 문제를 "구현은 한 벌, 입력만 세 벌"로 뒤집는다. 그러면 변경은 한 곳에서 일어나고, 환경 사이의 차이는 코드가 아니라 값으로 드러난다.
어떻게 동작하나
모듈은 그냥 .tf 파일이 들어 있는 디렉터리다. 특별한 문법은 없고 관례가 있을 뿐이다.
| 파일 | 역할 |
|---|---|
variables.tf |
이 모듈이 받는 입력. 모듈의 공개 API |
main.tf |
구현. 밖에서는 보이지 않아야 하는 부분 |
outputs.tf |
밖으로 내보내는 값. 모듈의 반환값 |
세 파일로 나누는 이유는 취향이 아니라 읽는 순서 때문이다. 남의 모듈을 처음 볼 때 사람이 알고 싶은 것은 "무엇을 넣으면 무엇이 나오나"이고, 구현은 그다음이다.
기억할 성질이 네 가지 있다.
첫째, 모듈은 캡슐이다. 모듈 안의 리소스는 밖에서 직접 참조할 수 없다. module.primary.local_file.box.filename 같은 주소는 존재하지 않는다. 값을 밖으로 꺼내려면 반드시 output 을 통과해야 한다. 귀찮아 보이지만, 이 제약 덕분에 모듈 안의 리소스 이름을 바꿔도 사용하는 쪽이 깨지지 않는다.
둘째, 주소가 바뀐다. 모듈 안 리소스의 상태 주소에는 module.<호출이름>. 접두어가 붙는다. 중첩하면 module.bundle.module.left.local_file.box 처럼 계속 쌓인다. 리팩터링이 곧 주소 변경이라는 사실이 3편의 상태 수술과 연결된다.
셋째, 프로바이더는 루트가 주입한다. 자식 모듈 안에 provider 블록을 두면 그 모듈을 쓰는 쪽이 프로바이더 설정을 바꿀 수 없고, 더 나쁜 것은 그 모듈 호출을 코드에서 지울 수 없게 된다는 점이다. 리소스를 파괴하려면 프로바이더 설정이 필요한데 그 설정이 사라진 모듈 안에 있기 때문이다. 그래서 모듈에는 required_providers 만 두고 실제 설정은 루트에 둔다.
넷째, 같은 소스를 여러 번 호출한다. 인스턴스를 늘리고 싶다고 모듈 디렉터리를 복사하면 처음의 복붙 문제로 돌아간다. module "primary" 와 module "secondary" 처럼 같은 source 를 이름만 달리해 호출하는 것이 정답이다.
현장에서 만나는 모습
첫째, 나누는 기준은 "함께 바뀌는가"다. 리소스 종류별로 나누면(모두 vpc 모듈, 모두 iam 모듈) 겉보기에는 깔끔하지만 실제 변경은 늘 여러 모듈에 걸쳐 일어난다. 함께 태어나고 함께 죽는 것들을 한 모듈에 묶어야 한다. 상태 파일을 나누는 기준도 같다 — 폭발 반경과 apply 시간이 기준이지 폴더 미학이 아니다.
둘째, 검증은 모듈 경계에서 하는 것이 가장 싸다. 잘못된 값이 모듈 안으로 들어가면 오류는 저 아래 리소스에서 난다. variable 의 validation 블록으로 입구에서 막으면 오류 메시지에 "어느 모듈의 어느 변수가 왜 거부됐는지"가 그대로 찍힌다. 디버깅 시간이 몇 배 차이 난다.
셋째, 모듈은 버전을 갖는다. 로컬 상대 경로는 학습과 단일 저장소에서는 편하지만, 여러 팀이 쓰는 모듈이라면 레지스트리와 버전 고정이 필요하다. 그러지 않으면 남의 커밋 하나가 우리 prod 계획을 바꾼다.
모듈을 언제 만들고, 어디까지 감출 것인가
모듈은 재사용을 위해 만든다고 배우지만, 실제로는 두 번째 사용처가 생겼을 때 만드는 것이 맞다. 한 곳에서만 쓰는 모듈은 파일을 하나 더 열게 만들 뿐이다.
이르게 만든 모듈은 반드시 잘못 추상화된다. 첫 사용처의 요구만 알고 만들었기 때문이다. 두 번째 사용처가 생기면 변수를 하나 늘리고, 세 번째에서 또 늘리다가 변수가 마흔 개인 모듈이 된다. 그 시점의 모듈은 감추는 것이 없으므로 존재 이유가 없다.
좋은 모듈은 결정을 담는다. 자원을 묶는 것이 아니라 "우리 조직에서 이런 종류의 것은 이렇게 만든다" 는 판단을 담아야 한다. 암호화는 켜고, 로그는 어디로 보내고, 태그는 이렇게 붙이고, 백업은 며칠 보관한다는 결정을 안에 넣고 밖으로는 몇 개만 노출한다.
변수는 적을수록 좋고, 통과만 시키는 변수는 나쁘다. 안의 자원 속성을 그대로 바깥으로 내보내는 변수가 많다면 그 모듈은 얇은 껍데기다. 그럴 바에는 모듈을 없애고 자원을 직접 쓰는 편이 읽기 쉽다.
버전을 고정하고 올린다. 레지스트리나 git 태그로 버전을 잡고, 사용처는 그
버전을 명시한다. ref=main 으로 두면 모듈을 고치는 순간 아무도 계획하지 않은
변경이 모든 사용처에 퍼진다.
module "db" {
source = "git::https://git.example.com/tf-modules.git//rds?ref=v2.3.0"
...
}
중첩은 두 단계까지. 모듈 안의 모듈 안의 모듈이 되면 어느 변수가 어디까지 전달되는지 아무도 추적하지 못한다. 값을 넘기는 코드가 늘어나기만 하고, 계획을 읽을 때 자원 주소가 길어져 무엇이 바뀌는지 보이지 않는다.
출력은 계약이다. 사용처가 그 출력에 의존하므로, 이름을 바꾸면 모든 사용처가 깨진다. 필요한 것만 내보내고, 이름은 안의 구현이 아니라 바깥에서의 의미로 짓는다.
다음 실습에서 할 것
filebox 라는 작은 자식 모듈을 입력·구현·출력 세 파일로 만들고, 같은 소스를 primary·secondary 두 이름으로 호출한다. 모듈 안 리소스의 상태 주소가 어떻게 달라지는지 직접 확인하고, 모듈 안에서 다시 모듈을 부르는 중첩 구조를 만든다. 마지막에는 변수 검증으로 잘못된 값을 모듈 경계에서 거부하고, 세 모듈의 출력을 하나의 맵으로 모은다.