티스토리 뷰

들어가며

콘솔에서 VPC를 하나 만들어 본 사람이라면, 그걸 dev, beta, prod에 똑같이 세 번 만들어야 하는 순간을 한 번쯤 겪습니다. 처음에는 어렵지 않아 보입니다. 그런데 막상 세 번을 손으로 반복하고 나면, 어느 환경에는 빠진 태그가 있고 어느 환경에는 서브넷 CIDR이 한 칸 어긋나 있고 보안 그룹 규칙 하나가 다르게 들어가 있습니다. 누구의 잘못도 아닙니다. 사람이 같은 작업을 세 번 반복하면 미세한 차이가 생기는 것이 자연스럽습니다. 문제는 그 미세한 차이가 나중에 "dev에서는 되는데 prod에서는 안 되는" 디버깅으로 돌아온다는 점입니다.

이 글은 그 반복을 셸 스크립트가 아니라 Terraform으로 옮기기로 한 결정을 정리하는 글입니다. 정확히는 도구 자체의 기능 소개보다, 명령형 코드에 익숙한 개발자가 Terraform 앞에서 처음 부딪히는 벽이 문법이 아니라 사고의 방향이라는 점을 짚어 보려 합니다. Java로 서비스를 만들던 사람이 Terraform 파일을 처음 열면, 위에서 아래로 실행되는 스크립트로 읽으려는 습관이 먼저 작동합니다. 그런데 Terraform은 그렇게 동작하지 않습니다. 이 차이를 먼저 넘지 못하면, 뒤에 나오는 state나 모듈 같은 개념이 전부 겉돕니다. 그래서 이 시리즈의 출발점을 여기에 두었습니다.


기술적 사실은 가능한 한 HashiCorp 공식 문서에서 확인되는 범위 안에서만 적겠습니다. 정답을 제시하기보다는, 이 프로젝트가 왜 이 결정에 다다랐고 그 과정에서 무엇을 확인했는지를 공유하는 정도로 읽어 주시면 좋겠습니다.


명령형과 선언형 — 사고의 방향이 뒤집힌다

셸 스크립트로 인프라를 만든다는 것은 "이 명령을 이 순서로 실행하라"를 한 줄씩 적는 일입니다. VPC를 만드는 aws ec2 create-vpc를 적고, 그 출력에서 VPC ID를 뽑아 변수에 담고, 그 ID를 다음 서브넷 생성 명령에 넘기고, 다시 그 결과를 라우팅 테이블에 연결합니다. 순서가 곧 코드입니다. 한 줄이라도 순서가 어긋나면 앞 명령의 출력이 없어서 뒤 명령이 깨집니다. 이미 만들어진 자원 위에서 스크립트를 다시 돌리면 "이미 존재한다"는 오류가 쏟아지기도 합니다. 그래서 스크립트는 보통 한 번 쓰고 버리거나, "이미 있으면 건너뛰기" 같은 분기를 사람이 직접 손으로 채워 넣게 됩니다.

Terraform은 이 방향을 뒤집습니다. 코드에 적는 것은 명령의 순서가 아니라 "이 리소스들이 존재해야 한다"는 상태입니다. VPC가 있어야 하고, 그 안에 서브넷이 있어야 하고, 서브넷이 그 VPC를 가리켜야 한다는 사실만 적습니다. 그러면 무엇을 먼저 만들고 무엇을 나중에 만들지, 지금 있는 것과 비교해 무엇을 새로 만들고 무엇을 바꾸고 무엇을 지울지는 도구가 계산합니다. HashiCorp 공식 문서는 이 방식을 두고, 사용자가 "어떻게 만들지"가 아니라 "무엇을 원하는지"를 선언하면 시스템이 그에 맞는 버전·구성을 알아서 고르는 선언형 모델이라고 설명합니다.

Java를 오래 다룬 개발자에게 이 감각이 완전히 낯설지는 않습니다. SQL을 떠올리면 됩니다. SELECT를 쓸 때 우리는 "이 테이블을 먼저 스캔하고 그다음 조인하고…"라는 실행 순서를 적지 않습니다. "이런 결과가 필요하다"만 적고, 실제로 어떤 순서로 인덱스를 타고 조인을 풀지는 옵티마이저가 정합니다. for 루프로 한 행씩 비교하던 사고에서 원하는 결과 집합을 선언하는 사고로 넘어가던 그 순간이, Terraform을 처음 잡을 때 다시 한번 찾아옵니다. 인프라의 SQL을 배우는 셈입니다.

이 전환의 진짜 이점은 한 단어로 모입니다. 멱등성입니다. 같은 코드를 두 번, 세 번 apply해도 결과가 같습니다. 처음 한 번은 자원을 만들고, 그다음부터는 "이미 원하는 상태이므로 바꿀 게 없다"가 됩니다. 셸 스크립트에서 사람이 손으로 채우던 "이미 있으면 건너뛰기" 분기를, 선언형 모델에서는 도구가 구조적으로 책임집니다. dev, beta, prod에 같은 코드를 세 번 적용하면 세 환경이 같은 모양으로 수렴한다는 것, 이것이 손으로 세 번 만들 때 생기던 미세한 차이를 없애 줍니다.


plan과 apply — 실행하기 전에 미래를 본다

선언형으로 적은 코드가 실제 인프라로 바뀌는 과정은 두 단계로 나뉩니다. terraform plan이 먼저고 terraform apply가 그다음입니다. 이 둘을 한 덩어리로 뭉뚱그리지 않는 것이 Terraform을 안전하게 쓰는 첫걸음입니다.

plan은 실행 계획을 만들어 보여 주는 명령입니다. 공식 문서의 설명을 따라가면, plan은 먼저 현재의 원격 상태를 읽어 Terraform이 들고 있는 장부가 최신인지 맞추고, 그 상태와 우리가 적은 코드를 비교해 차이를 찾아낸 뒤, 그 차이를 메우기 위해 무엇을 만들고(create) 무엇을 바꾸고(update) 무엇을 지울지(destroy)를 제안합니다. 중요한 것은 공식 문서가 분명히 적어 둔 한 문장입니다. plan 명령만으로는 제안한 변경이 실제로 일어나지 않습니다. 다시 말해 plan은 미래를 미리 보여 줄 뿐, 인프라에 손대지 않습니다.


이 "미리 보기"가 왜 중요한지는 명령형 작업과 비교하면 선명해집니다. 셸 스크립트는 실행하기 전에는 무슨 일이 벌어질지 알기 어렵습니다. 실제로 돌려 봐야 압니다. 반면 Terraform에서는 apply 전에 plan으로 "지금 이 코드를 적용하면 보안 그룹 하나가 새로 생기고, 서브넷 태그 하나가 바뀌고, 쓰지 않는 라우팅 항목 하나가 지워진다"는 목록을 먼저 받아 볼 수 있습니다. 의도하지 않은 destroy가 plan 결과에 섞여 있으면, apply를 누르기 전에 멈출 수 있습니다. 공식 문서도 plan을 두고, 변경을 적용하기 전에 그 변경이 예상과 맞는지 확인하거나 팀과 함께 리뷰하기 위한 명령이라고 안내합니다.


apply는 그 계획을 실제로 실행합니다. 그래서 plan 없는 apply는 컴파일이나 코드 리뷰를 건너뛰고 곧장 운영에 배포하는 일과 비슷합니다. 동작이야 할 수도 있지만, 무엇이 바뀔지 모르는 채로 실행하는 위험을 그대로 떠안습니다. 팀으로 인프라를 다룰 때 plan 결과물을 PR에 붙여 두고 리뷰하는 흐름이 표준처럼 자리 잡은 것도 이 때문입니다. plan이 만들어 내는 변경 목록이 곧 리뷰의 대상이 됩니다.


Provider — 코드를 실제 클라우드 API로 옮기는 플러그인

여기까지 보면 한 가지 의문이 남습니다. HCL로 적은 "이 리소스가 존재해야 한다"는 선언이 어떻게 실제 AWS 자원으로 바뀔까요. 그 사이를 잇는 것이 Provider입니다. AWS Provider는 우리가 적은 aws_vpc, aws_subnet 같은 선언을 실제 AWS API 호출로 옮겨 주는 플러그인입니다. Terraform 코어는 선언과 상태를 비교하는 엔진이고, 그 결과를 특정 클라우드의 실제 호출로 번역하는 일은 Provider가 맡습니다. 이 분리 덕분에 같은 Terraform 코어로 AWS든 다른 클라우드든 다룰 수 있습니다.

그래서 코드 어딘가에는 "나는 어떤 Provider의 어떤 버전을 쓴다"를 분명히 적어 두어야 합니다. 이 프로젝트의 providers.tf는 다음과 같이 선언합니다.

terraform {
  required_version = "~> 1.9.5"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.60"
    }
  }
}


required_providers 블록은 어떤 Provider를 어디서(hashicorp/aws) 어떤 버전 범위(~> 5.60)로 쓸지를 선언하는 자리입니다. required_version은 Terraform 코어 자체의 버전 범위입니다. 둘 다 정확한 한 버전을 못 박는 대신 범위로 적었다는 점이 눈에 띕니다. 이 범위 표기가 다음 절의 주제입니다.


버전 핀과 lock 파일 — 재현성의 기본기

~> 5.60이라는 표기가 정확히 무엇을 허용하는지부터 짚겠습니다. 공식 문서에 따르면 ~> 연산자는 버전의 가장 오른쪽 자리만 올라가도록 허용합니다. ~> 1.0.4는 1.0.5나 1.0.10은 받아들이지만 1.1.0은 막고, ~> 1.0은 1.1이나 1.9까지는 허용하되 2.0은 거부합니다. 이 규칙을 이 프로젝트에 대입하면, ~> 1.9.5는 1.9.6이나 1.9.10 같은 패치 상승은 받아들이지만 1.10.0으로는 넘어가지 않고, ~> 5.60은 5.61이나 5.99 같은 마이너 상승은 받아들이되 6.0으로 메이저가 바뀌는 것은 막습니다. 자잘한 버그 수정은 따라가되, 호환성이 깨질 수 있는 큰 변경은 자동으로 들어오지 못하게 하는 의도입니다.

Java 개발자라면 이 의도가 익숙할 것입니다. Gradle이나 Maven에서 의존성 버전을 박을 때 우리가 하는 고민과 정확히 같습니다. 아무 버전이나 알아서 최신으로 끌어오게 두면, 어제 되던 빌드가 오늘 깨지는 일이 생깁니다. 그래서 버전을 범위로든 고정으로든 명시해 두고, 실제로 어떤 버전이 선택됐는지를 lock 파일에 박아 둡니다. Terraform도 같은 장치를 가지고 있습니다. .terraform.lock.hcl입니다.

공식 문서는 이 lock 파일에 대해 몇 가지를 분명히 합니다. 첫째, lock 파일은 Terraform이 terraform init을 실행할 때마다 자동으로 만들어지거나 갱신됩니다. 둘째, 지금 시점에서 lock 파일이 추적하는 것은 Provider 의존성뿐이고, 원격 모듈의 버전 선택은 기억하지 않습니다. 셋째, lock 파일에는 선택된 정확한 버전과 함께 패키지 검증용 해시가 기록되어, 여러 플랫폼에서 같은 패키지를 받았는지 확인할 수 있게 합니다. 그리고 공식 문서는 이 파일을 버전 관리 저장소에 포함시킬 것을 분명히 권합니다. 외부 의존성의 변경을, 설정 변경을 리뷰하듯 코드 리뷰로 함께 논의하기 위해서입니다.

핵심은 이렇습니다. required_providers의 버전 범위가 "어디까지 허용하는가"를 정한다면, lock 파일은 "그 범위 안에서 실제로 무엇이 선택됐는가"를 기록합니다. 둘이 함께 있어야 비로소, 내 노트북에서 init한 Terraform과 CI 파이프라인에서 init한 Terraform이 같은 Provider 버전을 쓴다는 것을 보장할 수 있습니다. 이 프로젝트가 lock 파일을 반드시 커밋하기로 정한 것은 이 보장을 위해서입니다. Gradle 사용자가 의존성 lock을 커밋하는 동기와 정확히 같은 자리에 있습니다.

이 프로젝트는 한 가지 운영 규칙을 더 두었습니다. Provider 버전을 올리는 변경은 다른 코드 변경과 섞지 않고 별도 PR로 분리하고, dev와 beta에서 먼저 검증한 뒤 prod로 올립니다. 버전 상승은 plan 결과가 크게 흔들릴 수 있는 변경이라, 다른 변경에 묻혀 들어가면 리뷰에서 놓치기 쉽기 때문입니다. plan을 리뷰의 대상으로 삼는다는 원칙을, 버전 관리에도 그대로 적용한 셈입니다.


이 저장소가 Terraform을 고른 이유

도구를 고를 때 후보가 Terraform만 있었던 것은 아닙니다. 설계 문서에는 Terraform, AWS CDK, CloudFormation, Pulumi를 나란히 놓고 비교한 기록이 남아 있습니다. 그 비교의 결론을 끌어낸 가장 큰 무게추는 이 프로젝트의 구체적인 요구였습니다. dev, beta, prod라는 세 환경을, 같은 설계로 재현해야 한다는 요구입니다.

이 요구 위에서 Terraform이 가진 강점은 세 가지 정도로 추려집니다. 먼저 멀티 계정·멀티 환경을 다루는 패턴이 검증되어 있다는 점입니다. Provider alias로 여러 계정·리전을 다루고, 환경별로 상태를 분리하고, 한 환경의 출력을 다른 환경에서 참조하는 방식이 표준처럼 자리 잡혀 있어, 같은 설계를 세 곳에 재현하는 작업과 잘 맞습니다. 다음은 HCL의 진입 장벽이 낮다는 점입니다. 코드가 곧 리소스 명세에 가깝게 읽혀서, 팀의 주력 언어가 Java든 TypeScript든 인프라 코드를 리뷰하는 리듬을 만들기 쉽습니다. 마지막은 커뮤니티 모듈의 성숙도입니다. VPC나 RDS 같은 자주 쓰는 구성에 대해 검증된 공개 모듈이 많아서, 처음부터 다 만들지 않아도 됩니다.

다른 후보가 부족하다는 뜻은 아닙니다. CDK는 TypeScript 같은 익숙한 언어로 강한 추상화를 만들 수 있다는 분명한 장점이 있습니다. 다만 이 프로젝트처럼 "여러 계정에 같은 설계를 반복 재현"하는 요구가 가장 앞에 있을 때는, Terraform의 모듈 체계와 환경 분리 패턴이 더 곧장 맞아떨어졌다는 것이 결론이었습니다. 같은 도구라도 상황에 따라 결론이 달라진다는 이야기는 인프라 결정에서 늘 반복되는데, IaC 도구 선정 역시 예외가 아니었습니다.

이 결정의 결과가 코드에 그대로 드러나 있습니다. dev, beta, prod 세 환경의 .tf 파일은 같은 세트이고, 환경마다 다른 값은 terraform.tfvars로만 갈립니다. 환경의 차이를 "다른 코드"가 아니라 "같은 코드에 다른 변수"로 표현한 것입니다. 손으로 세 번 만들면 미세한 차이가 생기던 자리를, 같은 코드 한 벌로 좁혀 둔 셈입니다. 이것이 글 첫머리에 적은 "dev에서는 되는데 prod에서는 안 되는" 디버깅을 구조적으로 줄이는 방식입니다.


마무리 — 문법보다 먼저 넘어야 하는 건 사고의 방향

이 글에서 정리한 내용을 한 줄로 줄이면, "Terraform을 처음 잡을 때 진짜 벽은 HCL 문법이 아니라, 인프라를 명령의 순서가 아니라 도달해야 할 상태로 적는 사고의 전환"입니다. plan과 apply, Provider, 버전 핀과 lock 파일은 모두 그 선언형 모델을 안전하게 굴리기 위한 장치이고, 이 프로젝트가 Terraform을 고른 이유도 세 환경을 같은 설계로 재현해야 한다는 요구에서 나왔습니다.

개인적으로 이 전환을 정리하면서 남은 인상은, Terraform이 강제하는 절차가 사실은 좋은 개발 습관을 인프라로 옮겨 놓은 것에 가깝다는 점이었습니다. apply 전에 plan으로 변경을 미리 보는 일은 배포 전에 코드 리뷰를 거치는 일과 다르지 않고, Provider 버전을 범위로 박고 lock 파일을 커밋하는 일은 Gradle 의존성을 고정하고 lock을 커밋하던 일과 같은 동기에서 나옵니다. Java로 서비스를 만들며 몸에 익힌 습관들이, 도구만 바뀐 채 거의 그대로 통한다는 사실이 오히려 인프라 코드에 빨리 적응하게 해 주었습니다.

한 가지 더 남는 것은, 선언형으로 적는다는 것이 "순서를 신경 쓰지 않아도 된다"는 편함만은 아니라는 점입니다. 순서를 도구에 맡기는 대신, 우리는 "최종 상태가 무엇이어야 하는가"를 더 또렷하게 알고 있어야 합니다. 무엇이 존재해야 하는지 흐릿하면 선언형은 오히려 더 헷갈립니다. 그래서 이 시리즈의 다음 글에서는, 그 "지금 무엇이 존재하는가"를 Terraform이 어떻게 기억하는지, 즉 state를 다룰 차례입니다. 이 글에서는 plan이 "현재 상태와 코드를 비교한다"고만 적어 두었는데, 그 현재 상태가 어디에 어떻게 기록되는지가 다음 글의 출발점이 됩니다. 비슷한 자리에서 명령형 습관과 씨름하는 분들에게 이 글이 작은 참고가 되기를 바라며 마무리합니다.


참고한 공식 문서