Terraform 입문: HCL과 모듈, AWS·Azure·GCP 프로바이더, State 백엔드, Workspace 환경 분리

이 글의 핵심

콘솔에서 클릭으로 만든 인프라는 다시 만들기 어렵고 누가 무엇을 바꿨는지도 남지 않습니다. Terraform의 선언적 모델과 의존성 그래프가 이 문제를 어떻게 푸는지 짚고, State 파일을 팀이 공유할 때 잠금이 필요한 이유, plan 결과가 예상과 다를 때 확인할 순서, 비밀값을 다루는 보안 실무까지 정리합니다.

Terraform은 HashiCorp가 만든 Infrastructure as Code(IaC) 도구입니다. 콘솔에서 클릭으로 만든 인프라는 다시 만들기 어렵고 누가 무엇을 바꿨는지도 남지 않습니다. Terraform은 원하는 최종 상태를 코드로 적어 두고, 현재 상태(State)와의 차이를 계산한 뒤 그 차이만 적용합니다.

라이선스도 짚어 둘 필요가 있습니다. Terraform은 1.6 버전(2023년)부터 MPL 2.0에서 BSL(Business Source License)로 바뀌어, 엄밀한 의미의 오픈소스가 아닙니다. 이에 대응해 Linux Foundation 산하에서 MPL 시절 코드를 이어받은 포크인 OpenTofu가 나왔습니다. 이 글의 HCL 문법과 개념은 둘 모두에 대부분 그대로 적용되지만, 최신 기능은 버전마다 차이가 있을 수 있습니다.

예제는 Terraform CLI가 설치되어 있고 대상 클라우드 계정과 권한이 있다는 전제로 작성했습니다. 교육용이므로 실제로 적용하기 전에는 비용, 쿼터, 조직 보안 정책을 확인해야 합니다.


Terraform과 Infrastructure as Code

선언적 모델과 멱등성

Terraform은 선언적(declarative)입니다. 어떤 순서로 명령을 실행할지가 아니라 어떤 인프라가 존재해야 하는지를 기술합니다. 같은 구성을 다시 적용하면, 리소스가 이미 목표 상태인 경우 아무것도 바꾸지 않고 달라진 부분만 조정하도록 설계되어 있습니다(멱등성).

이 모델 덕분에 코드를 Git에 두면 같은 환경을 다시 만들 수 있고, 인프라 변경을 Pull Request로 리뷰할 수 있으며, terraform plan으로 적용 전에 영향 범위를 확인할 수 있습니다.

핵심 구성 요소

개념설명
ProviderAWS, Azure, GCP 등 각 서비스의 API를 호출하는 플러그인
Resource생성·관리할 인프라 객체(예: VPC, VM, 버킷)
Data source생성하지 않고 기존 리소스 정보를 읽기만 할 때 사용
StateTerraform이 관리하는 리소스와 실제 ID, 속성의 기록
Module입력 변수와 출력으로 캡슐화한 재사용 가능한 .tf 묶음

Terraform은 리소스 간 참조(예: aws_subnet이 aws_vpc.main.id를 참조)를 분석해 의존성 그래프를 만들고, 서로 의존하지 않는 작업은 병렬로 수행합니다(기본 동시 작업 수 10, -parallelism으로 조정). 참조로 드러나지 않는 의존이 있다면 depends_on으로 명시합니다.

기본 워크플로

  1. .tf 파일에 resource 등을 정의합니다.
  2. terraform init으로 프로바이더 플러그인과 모듈을 내려받고 백엔드를 초기화합니다.
  3. terraform plan으로 변경 예정 내용을 확인합니다.
  4. terraform apply로 실제 인프라에 반영합니다.
  5. 학습이나 검증 환경이라면 terraform destroy로 정리합니다.

운영 환경에서는 plan 결과를 저장해 리뷰하고, 승인된 plan만 적용하는 CI/CD 파이프라인과 결합하는 것이 일반적입니다(뒤에서 다룹니다).


HCL 문법과 모듈

terraform 블록과 required_providers

Terraform 코어와 프로바이더 버전을 제약해 두면 팀 전체가 같은 동작을 기대할 수 있습니다.

terraform {
  required_version = ">= 1.5.0"

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

~> 5.0은 5.x 범위(5.0 이상 6.0 미만)를 허용한다는 뜻입니다. 프로바이더의 메이저 버전이 바뀌면 리소스 스키마가 달라질 수 있으므로, 메이저 업그레이드는 제약을 직접 바꿔 의도적으로 합니다.

변수·출력·지역 값

입력 변수(variable)로 환경별 차이를 받고, 출력(output)으로 다른 스택이나 모듈을 쓰는 쪽에 값을 넘깁니다. 지역 값(locals)은 같은 모듈 안에서 반복되는 표현을 한곳에 모읍니다.

variable "environment" {
  type        = string
  description = "배포 환경 이름"

  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "environment는 dev, staging, prod 중 하나여야 합니다."
  }
}

locals {
  name_prefix = "app-${var.environment}"
}

output "name_prefix" {
  value       = local.name_prefix
  description = "리소스 이름 접두사"
}

type, description, validation을 적어 두면 잘못된 값이 plan 단계에서 바로 거부되므로, 클라우드 API 오류로 apply 중간에 실패하는 경우를 줄일 수 있습니다.

모듈 설계

모듈은 디렉터리 단위로 묶인 Terraform 구성이며, 루트 모듈이 module 블록으로 하위 모듈을 호출합니다. 같은 네트워크 패턴을 여러 프로젝트에 복사하는 대신 모듈로 공유하고, variables.tf와 outputs.tf로 입력과 출력을 명확히 하며, VPC, 서브넷, 보안 그룹 같은 작은 모듈을 조합해 상위 스택을 만듭니다.

module "network" {
  source = "./modules/network"

  cidr_block = "10.0.0.0/16"
  az_count   = 3
}

Terraform Registry의 모듈은 version 인자로, Git 소스는 ?ref=v1.2.0 같은 태그나 커밋 해시로 고정합니다. 고정하지 않으면 모듈 작성자가 바꾼 내용이 다음 init에서 예고 없이 들어옵니다. 모듈 안에는 계정 ID나 리전 같은 환경 고유 값을 하드코딩하지 말고 입력으로 받아야 다른 환경에서도 그대로 쓸 수 있습니다.


AWS, Azure, GCP 프로바이더

Terraform은 API 호출 방식을 통일해 줄 뿐, 리소스의 의미와 이름 규칙, 권한 모델은 클라우드마다 다릅니다. 각 프로바이더 문서를 함께 읽어야 합니다.

AWS (hashicorp/aws)

리전과 자격 증명 체인(환경 변수, 공유 자격 증명 파일, IAM 역할 등)을 이해해야 합니다. CI에서는 장기 액세스 키 대신 OIDC로 IAM 역할을 맡는(assume) 방식을 권장합니다.

provider "aws" {
  region = "ap-northeast-2"
}

resource "aws_s3_bucket" "logs" {
  bucket = "example-logs-unique-suffix"
}

S3 버킷 이름은 전 세계에서 유일해야 합니다. AWS 프로바이더 4.0부터 버킷의 버전 관리, 암호화, 수명 주기 정책은 aws_s3_bucket_versioning, aws_s3_bucket_server_side_encryption_configuration, aws_s3_bucket_lifecycle_configuration 같은 별도 리소스로 정의합니다. 예전 예제처럼 aws_s3_bucket 안에 versioning {} 블록을 쓰면 경고가 나거나 동작하지 않습니다.

Azure (hashicorp/azurerm)

구독(Subscription), 리소스 그룹, 테넌트 개념이 중요합니다. CI에서는 서비스 프린시펄(가능하면 OIDC 페더레이션)이나 Managed Identity로 비대화형 인증을 구성합니다.

provider "azurerm" {
  features {}
}

resource "azurerm_resource_group" "main" {
  name     = "rg-example"
  location = "Korea Central"
}

azurerm 4.x부터는 provider 블록에 subscription_id를 명시하거나 ARM_SUBSCRIPTION_ID 환경 변수를 설정해야 합니다. Azure는 리소스마다 이름 길이와 허용 문자 규칙이 달라서, 모듈 변수에 validation으로 제약을 두면 배포 실패를 줄일 수 있습니다.

Google Cloud (hashicorp/google)

프로젝트 ID, 리전과 존, 그리고 해당 서비스 API의 활성화가 먼저 필요합니다. API가 꺼져 있으면 리소스 생성이 SERVICE_DISABLED 오류로 실패하므로, google_project_service 리소스로 API 활성화까지 코드에 넣어 두는 경우가 많습니다.

provider "google" {
  project = var.gcp_project_id
  region  = "asia-northeast3"
}

resource "google_storage_bucket" "assets" {
  name                        = "${var.gcp_project_id}-assets-unique"
  location                    = "ASIA-NORTHEAST3"
  uniform_bucket_level_access = true
  force_destroy               = false
}

GCP에서는 IAM을 최소 권한으로 나누고, VPC 서비스 제어나 조직 정책 같은 상위 거버넌스와 충돌하지 않는지 확인해야 합니다.

멀티 클라우드 운영 시 유의점

같은 HCL 문법을 써도 “VPC”나 “로드밸런서”가 의미하는 바는 클라우드마다 다릅니다. 팀 안에서 용어를 맞추고, 네트워크, 아이덴티티, 과금 단위의 차이를 문서로 남겨 두는 것이 협업에 도움이 됩니다.


State 관리와 백엔드

State가 필요한 이유

선언 코드만으로는 aws_vpc.main이 실제 클라우드의 어느 VPC(vpc-0abc...)인지 알 수 없습니다. State는 코드의 리소스 주소와 실제 리소스 ID의 대응, 그리고 마지막으로 알려진 속성 값을 저장합니다. State가 없거나 실제와 어긋나면 plan이 부정확해지고, 이미 있는 리소스를 중복 생성하거나 관리하던 리소스를 잘못 삭제할 위험이 커집니다.

원격 백엔드와 잠금

로컬 terraform.tfstate 파일은 혼자 학습할 때는 편하지만, 팀에서는 원격 백엔드로 옮깁니다. 두 사람이 동시에 apply하면 서로의 State 변경을 덮어쓸 수 있으므로, 백엔드는 적용 중에 State를 잠가야 합니다.

백엔드잠금 방식비고
AWS S3S3 잠금 파일(use_lockfile) 또는 DynamoDB 테이블DynamoDB 방식은 최신 버전에서 deprecated
Azure StorageBlob leaseazurerm 백엔드
GCS백엔드 자체 잠금버킷 IAM과 함께 설계
HCP Terraform(구 Terraform Cloud)서비스가 잠금과 RBAC 제공원격 실행, 정책 검사 포함
terraform {
  backend "s3" {
    bucket       = "my-org-terraform-state"
    key          = "prod/network/terraform.tfstate"
    region       = "ap-northeast-2"
    encrypt      = true
    use_lockfile = true   # S3 네이티브 잠금 (1.10+)
  }
}

S3 백엔드는 Terraform 1.10에서 버킷에 잠금 파일을 만드는 네이티브 잠금을 도입했고(1.11에서 정식 기능), 그와 함께 기존의 dynamodb_table 방식은 deprecated되었습니다. 더 오래된 버전을 쓴다면 use_lockfile 대신 dynamodb_table = "terraform-locks"를 지정합니다. key는 스택마다 다르게 두어 한 State가 너무 커지지 않게 하고, State 버킷에는 버전 관리를 켜서 State가 손상되었을 때 이전 버전으로 되돌릴 수 있게 합니다.

State 분할과 import

하나의 거대한 State는 plan 시간이 길어지고 한 번의 실수로 바뀔 수 있는 범위도 넓어집니다. 팀이나 도메인 단위로 State를 나누고, DNS나 공용 VPC처럼 공유되는 값만 terraform_remote_state 데이터 소스나 SSM Parameter Store 같은 저장소를 통해 느슨하게 연결하는 패턴이 흔합니다.

이미 콘솔로 만든 리소스는 import로 State에 편입할 수 있습니다. 다만 import는 State에 대응 관계만 추가할 뿐이므로, 코드의 속성을 실제 구성과 일치시키지 않으면 다음 plan에서 Terraform이 실제 리소스를 코드에 맞게 바꾸려 합니다. 1.5부터는 import 블록과 terraform plan -generate-config-out=generated.tf로 실제 구성에 맞는 코드 초안을 생성할 수 있어 이 작업이 수월해졌습니다.


Workspace와 환경 분리

Workspace

Workspace는 같은 구성에 대해 State를 여러 개 두는 내장 기능입니다. terraform workspace new staging으로 만들고, 코드에서는 terraform.workspace로 현재 이름을 참조합니다. S3 백엔드라면 기본 workspace 외의 State는 env:/staging/<key> 경로에 자동으로 따로 저장됩니다.

설정이 단순할 때는 빠르게 환경을 나눌 수 있지만, 모든 환경이 같은 백엔드와 같은 자격 증명을 공유한다는 한계가 있습니다. 또 현재 선택된 workspace가 무엇인지 확인하지 않고 apply해 의도하지 않은 환경을 바꾸는 사고가 나기 쉽습니다.

디렉터리와 파이프라인으로 분리

그래서 많은 조직은 env/dev, env/prod처럼 환경마다 루트 모듈 디렉터리를 두고, 각각 다른 백엔드, 다른 변수 파일(terraform.tfvars), 다른 승인 절차를 적용합니다. 공통 구성은 모듈로 빼서 각 환경 디렉터리가 다른 입력으로 호출합니다. 프로덕션은 main 브랜치에 병합된 뒤 CI에서만 apply되도록 제한하는 것이 안전합니다.


보안

최소 권한과 역할 분리

Terraform을 실행하는 CI 서비스 계정에는 해당 스택에 필요한 최소 IAM 권한만 부여합니다. plan 전용 역할(읽기 위주)과 apply 역할을 나누면, PR마다 실행되는 plan이 인프라를 바꿀 수 없게 할 수 있습니다. 개발자 개인 자격 증명으로 프로덕션에 적용하는 관행은 누가 무엇을 바꿨는지 추적하기 어렵게 만듭니다.

시크릿과 State

비밀번호나 API 키를 .tf나 커밋되는 .tfvars에 직접 쓰지 않습니다. 환경 변수(TF_VAR_*), CI 시크릿, 클라우드 시크릿 매니저(AWS Secrets Manager, Azure Key Vault, GCP Secret Manager)를 조합합니다.

주의할 점은 변수를 sensitive = true로 표시해도 plan 출력에서만 가려질 뿐, 리소스 속성으로 쓰인 값은 State에 평문으로 저장된다는 것입니다. 그래서 State 저장소는 암호화하고 접근을 엄격히 제한하며 접근 로그를 남겨야 합니다. Terraform 1.10의 ephemeral 값과 1.11의 write-only 속성을 지원하는 리소스라면 비밀 값을 State에 남기지 않을 수 있고, RDS처럼 클라우드가 비밀번호를 직접 생성해 시크릿 매니저에 보관하는 옵션(manage_master_user_password)을 쓰는 방법도 있습니다.

공급망과 정책

모듈은 공식 레지스트리나 검증된 출처를 우선하고, 버전이나 커밋 해시로 고정합니다. 프로바이더는 .terraform.lock.hcl에 기록된 해시로 검증되므로 이 파일을 커밋합니다. 조직 규모가 커지면 Sentinel(HCP Terraform/Enterprise)이나 OPA(Open Policy Agent) 기반 도구로 “퍼블릭 S3 금지”, “필수 태그” 같은 규칙을 plan 단계에서 자동 검사합니다.


실전 인프라 구축 예제

아래는 교육용으로 줄인 AWS 예시입니다. 퍼블릭 서브넷 두 개에 인터넷 게이트웨이로 가는 라우트를 두고, 그 위에 ALB를 올리며, 프라이빗 서브넷은 애플리케이션용으로 비워 둡니다. 실제 운영에서는 NAT, 헬스 체크, WAF, 백업 정책 등을 추가로 설계해야 합니다.

# 개념 이해용 예시. 실제 배포 전 CIDR·가용 영역·이름을 환경에 맞게 조정하십시오.

data "aws_availability_zones" "available" {
  state = "available"
}

resource "aws_vpc" "main" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_hostnames = true
  tags = {
    Name = "app-vpc"
  }
}

resource "aws_subnet" "public" {
  count                   = 2
  vpc_id                  = aws_vpc.main.id
  cidr_block              = cidrsubnet(aws_vpc.main.cidr_block, 4, count.index)
  availability_zone       = data.aws_availability_zones.available.names[count.index]
  map_public_ip_on_launch = true
  tags = {
    Name = "public-${count.index}"
  }
}

resource "aws_subnet" "private" {
  count             = 2
  vpc_id            = aws_vpc.main.id
  cidr_block        = cidrsubnet(aws_vpc.main.cidr_block, 4, count.index + 8)
  availability_zone = data.aws_availability_zones.available.names[count.index]
  tags = {
    Name = "private-${count.index}"
  }
}

resource "aws_internet_gateway" "igw" {
  vpc_id = aws_vpc.main.id
}

# 퍼블릭 서브넷의 기본 경로를 인터넷 게이트웨이로
resource "aws_route_table" "public" {
  vpc_id = aws_vpc.main.id

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.igw.id
  }
}

resource "aws_route_table_association" "public" {
  count          = 2
  subnet_id      = aws_subnet.public[count.index].id
  route_table_id = aws_route_table.public.id
}

resource "aws_security_group" "alb" {
  name        = "alb-sg"
  description = "ALB ingress"
  vpc_id      = aws_vpc.main.id

  ingress {
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

resource "aws_lb" "app" {
  name               = "app-alb"
  load_balancer_type = "application"
  subnets            = aws_subnet.public[*].id
  security_groups    = [aws_security_group.alb.id]
}

cidrsubnet(aws_vpc.main.cidr_block, 4, n)은 /16 VPC를 /20 서브넷 16개로 나눈 것 중 n번째를 돌려줍니다. 퍼블릭은 0, 1번, 프라이빗은 8, 9번을 써서 나중에 서브넷을 추가할 여지를 남겼습니다. count로 가용 영역별 반복을 표현했고, ALB는 서로 다른 가용 영역의 서브넷이 최소 두 개 있어야 만들 수 있습니다.

서브넷이 “퍼블릭”인지는 이름이 아니라 라우트 테이블로 정해집니다. 인터넷 게이트웨이를 VPC에 붙이기만 하고 0.0.0.0/0 경로를 연결하지 않으면 ALB가 생성되더라도 외부에서 접근할 수 없습니다. 프라이빗 서브넷의 애플리케이션이 외부로 나가야 한다면(패키지 다운로드, 외부 API 호출 등) NAT 게이트웨이와 별도 라우트 테이블이 필요한데, NAT 게이트웨이는 시간당 요금과 데이터 처리 요금이 붙으므로 가용 영역마다 둘지 하나만 둘지는 비용과 가용성을 비교해 정합니다. 실제로 HTTPS를 받으려면 ACM 인증서와 aws_lb_listener, 대상 그룹도 추가해야 합니다.

애플리케이션 계층은 aws_instance나 ECS, EKS로 올리는 경우가 많습니다. 클러스터, 노드 그룹, IRSA(IAM Roles for Service Accounts)까지 한 스택에 넣을지, 플랫폼 팀과 애플리케이션 팀의 State를 어디서 나눌지는 조직 구조에 따라 달라집니다.

Azure라면 azurerm_virtual_network, azurerm_subnet, azurerm_lb(또는 Application Gateway), GCP라면 google_compute_network, google_compute_subnetwork와 전역 HTTP(S) 로드밸런서를 구성하는 여러 리소스로 비슷한 구조를 만듭니다. 로드밸런서, 헬스 체크, 방화벽 모델이 서로 다르므로, 클라우드 간 차이를 하나의 추상 모듈로 억지로 통일하기보다 클라우드별 모듈을 두고 참조 아키텍처 문서를 맞추는 편이 현실적입니다.


lifecycle·moved, CI 흐름, 그리고 plan이 이상할 때

코드를 고쳤을 뿐인데 plan에 -/+ destroy and then create replacement가 뜨는 순간이 Terraform 운영에서 가장 긴장되는 때입니다. 리소스 이름처럼 제자리 변경이 불가능한 속성을 바꾸면 Terraform은 기본적으로 지우고 나서 새로 만들기 때문에 그 사이에 서비스가 끊길 수 있습니다. lifecycle 블록으로 이 동작을 조정합니다. create_before_destroy는 새 리소스를 먼저 만들고 옛것을 지워 교체 중 공백을 없앱니다(이름이 고유해야 하는 리소스는 name_prefix를 써야 충돌하지 않습니다). prevent_destroy는 RDS나 상태 저장 버킷처럼 지워지면 안 되는 리소스를 삭제하는 plan 자체를 실패시킵니다. ignore_changes는 오토스케일러가 바꾸는 desired_capacity처럼 Terraform 밖에서 관리되는 값을 무시하게 하지만, 드리프트를 숨기는 도구이므로 필요한 속성에만 좁게 씁니다.

resource "aws_db_instance" "main" {
  # ...
  lifecycle {
    prevent_destroy = true
    ignore_changes  = [password]   # 비밀번호는 Secrets Manager에서 회전
  }
}

# 리소스 주소를 바꿀 때 재생성 대신 state만 옮기기 (Terraform 1.1+)
moved {
  from = aws_instance.web
  to   = module.web.aws_instance.this
}

prevent_destroy는 코드에서 리소스 블록 자체를 지우면 함께 사라지므로, 실수로 블록을 삭제하는 경우까지는 막지 못합니다. RDS의 deletion_protection = true처럼 클라우드 쪽 삭제 보호를 함께 켜 두는 것이 좋습니다.

모듈로 코드를 정리하다 리소스 주소가 바뀌면 Terraform은 옛 주소의 리소스를 지우고 새 주소로 다시 만들려고 합니다. 위처럼 moved 블록을 남기면 실제 인프라는 그대로 두고 State의 주소만 옮깁니다.

팀에서는 적용 경로를 CI 하나로 고정하는 편이 안전합니다. terraform fmt -check와 validate를 거친 뒤 plan -out=tfplan으로 만든 계획 파일을 리뷰 아티팩트로 남기고, 승인되면 그 파일을 apply tfplan으로 적용합니다. 승인 뒤에 apply를 새로 돌리면 그 사이 바뀐 상태 때문에 리뷰한 것과 다른 변경이 적용될 수 있습니다. plan 파일에는 변수 값과 State 일부가 들어 있으므로 아티팩트 접근 권한도 State와 같은 수준으로 관리해야 합니다.

plan이 예상과 다르게 나올 때는 먼저 드리프트를 의심합니다. 누군가 콘솔에서 태그 하나만 바꿔도 다음 plan에 변경으로 나타나고, CI가 -refresh=false로 돌고 있으면 반대로 드리프트가 가려집니다. terraform plan -refresh-only로 실제 인프라와 State의 차이만 따로 볼 수 있습니다. Error acquiring the state lock은 대개 중단된 CI 작업이 잠금을 남긴 경우인데, force-unlock은 다른 apply가 실제로 돌고 있지 않은지 확인한 뒤에만 써야 합니다. 프로바이더를 올린 직후 교체(replace)가 대량으로 나타나면 스키마 변경일 가능성이 높으니, CHANGELOG를 보고 스테이징에서 먼저 plan을 검증합니다.


같이 보면 좋은 글