Skip to content
ioob.dev
Go back

Terraform 3편 — HCL 문법

· 7분 읽기
Terraform 시리즈 (3/15)
  1. Terraform 1편 — Terraform이란
  2. Terraform 2편 — 설치와 첫 배포
  3. Terraform 3편 — HCL 문법
  4. Terraform 4편 — 변수와 출력
  5. Terraform 5편 — 프로바이더
  6. Terraform 6편 — 리소스와 의존성
  7. Terraform 7편 — 데이터 소스와 Import
  8. Terraform 8편 — State 관리
  9. Terraform 9편 — 모듈
  10. Terraform 10편 — 반복과 조건
  11. Terraform 11편 — 워크스페이스와 환경 분리
  12. Terraform 12편 — Kubernetes와 Helm 프로바이더
  13. Terraform 13편 — CI/CD 통합
  14. Terraform 14편 — 테스트와 정책
  15. Terraform 15편 — 실전 패턴과 함정
Table of contents

Table of contents

언어를 안다는 것

2편에서 이미 .tf 파일을 썼다. resource, provider, data 같은 단어가 나왔고, 그럴듯하게 동작했다. 그런데 몇 가지 더 쓰다 보면 질문이 쌓인다. 왜 따옴표를 썼다 안 썼다 하는가? 문자열 안에 변수를 넣는 법은? 리스트를 순회하며 리소스를 여러 개 만들 수는 없나?

이런 궁금증에 답하려면 HCL 자체를 한 번 정리할 필요가 있다. 이번 편은 Terraform을 쓰는 데 필요한 HCL 문법을 지도처럼 깐다. 모든 표현식을 외울 필요는 없지만, “이런 도구가 있다”는 걸 알아두면 실무에서 검색만으로 충분히 해결할 수 있다.

HCL이 뭔가

HCL(HashiCorp Configuration Language)은 HashiCorp가 만든 설정용 DSL이다. Terraform, Vault, Consul, Nomad 등 HashiCorp 도구들이 공통으로 쓴다. JSON과 호환되지만, 주석과 표현식을 지원해서 사람이 읽기 더 쉽다.

HCL의 기본 단위는 두 가지뿐이다. 블록(Block)인자(Argument).

# 블록 하나
resource "aws_instance" "web" {
  # 인자들
  ami           = "ami-xxx"
  instance_type = "t3.micro"

  # 중첩 블록
  tags = {
    Name = "web"
  }
}

이게 전부다. 파일 전체가 블록과 인자의 조합이고, 인자 값으로 표현식이 들어간다. 표현식이 HCL을 “설정 언어 이상”으로 만들어준다.

블록 구조

블록은 이런 모양이다.

<TYPE> "<LABEL1>" "<LABEL2>" {
  <인자> = <>
  <중첩 블록> { ... }
}

TYPE은 resource, variable, output, provider, module, data, terraform, locals 등이 있다. 블록 타입에 따라 레이블 개수가 다르다.

# 레이블 0개 — terraform, locals
terraform {
  required_version = ">= 1.5.0"
}

locals {
  environment = "dev"
}

# 레이블 1개 — provider, variable, output, module
provider "aws" { region = "ap-northeast-2" }

variable "instance_count" {
  type    = number
  default = 2
}

# 레이블 2개 — resource, data
resource "aws_instance" "web" { ... }
data "aws_ami" "latest"     { ... }

resource와 data는 “타입 + 이름” 두 레이블을 받는다. 타입은 프로바이더가 정한 이름이고(aws_instance, aws_s3_bucket), 이름은 내가 붙이는 논리적 식별자다. 이 이름은 같은 모듈 안에서 유일해야 하고, 다른 리소스에서 참조할 때 쓴다.

주석

세 가지 주석 스타일이 있다. 어떤 걸 써도 된다.

# 한 줄 주석 (권장)
// 한 줄 주석
/* 여러 줄
   주석 */

실무에선 #이 제일 흔하다. 다른 언어와의 일관성을 위해 //를 쓰기도 한다.

기본 타입

인자에 들어갈 수 있는 기본 타입은 단순하다.

# 문자열
name = "web-server"

# 숫자
count = 3
price = 1.5

# 불리언
enabled = true

# null — 값이 없음을 명시
custom_value = null

타입이 헷갈리면 명시적으로 적을 수 있다(variable 블록에서 주로 사용). 변수와 타입 시스템은 다음 편에서 따로 다룬다.

컬렉션 — list, map, set

컬렉션 타입은 세 가지가 있다.

# 리스트 (순서 있음, 중복 허용)
availability_zones = ["ap-northeast-2a", "ap-northeast-2b", "ap-northeast-2c"]

# 맵 (키-값)
tags = {
  Name        = "web"
  Environment = "prod"
}

# 셋 (순서 없음, 중복 불가) — 리터럴은 없고 함수로 만든다
unique_ports = toset([80, 443, 80])   # {80, 443}

접근 방식도 다른 언어와 비슷하다.

first_az = var.availability_zones[0]       # 리스트 인덱스
name     = var.tags["Name"]                # 맵 키
# 또는
name     = var.tags.Name                   # 점 표기법 (키가 식별자 규칙을 따를 때만)

리스트와 맵은 자주 쓰이니 손에 익혀두는 편이 좋다.

문자열 보간

HCL에서 가장 자주 쓰는 기능 중 하나다. ${...} 안에 표현식을 넣으면 그 값이 문자열에 삽입된다.

variable "environment" {
  default = "dev"
}

resource "aws_s3_bucket" "logs" {
  bucket = "myapp-logs-${var.environment}"   # myapp-logs-dev
}

표현식에는 변수, 리소스 속성, 함수 호출 등 어떤 것도 들어갈 수 있다.

tags = {
  Name       = "web-${count.index}"          # web-0, web-1, ...
  CreatedAt  = "${formatdate("YYYY-MM-DD", timestamp())}"
  BucketName = "${lower(replace(var.project, "_", "-"))}"
}

단, 인자 값 전체가 하나의 표현식이면 보간 없이 바로 쓰는 게 관용이다. 예전 버전에선 "${var.x}"로 썼지만 지금은 지양한다.

# 예전 스타일
region = "${var.region}"

# 지금 스타일 (권장)
region = var.region

문자열 안에 값을 끼워 넣을 때만 ${}를 쓰면 된다.

heredoc — 여러 줄 문자열

긴 문자열을 쓸 때 이스케이프 지옥에 빠지지 않게 해주는 문법이다. IAM 정책, 사용자 데이터 스크립트 같은 멀티라인 데이터에 쓴다.

resource "aws_iam_policy" "read_logs" {
  name   = "read-logs"
  policy = <<-EOT
    {
      "Version": "2012-10-17",
      "Statement": [
        {
          "Effect": "Allow",
          "Action": ["s3:GetObject"],
          "Resource": "arn:aws:s3:::${var.bucket_name}/*"
        }
      ]
    }
  EOT
}

<<-EOT로 시작해서 EOT로 닫는다. 표지(EOT)는 원하는 대로 바꿀 수 있다(EOF, END, POLICY…). <<- 형태를 쓰면 각 줄 앞의 공통 들여쓰기가 제거되어 가독성이 좋아진다(<<만 쓰면 들여쓰기까지 문자열에 포함된다).

JSON 정책을 이렇게 넣어도 되지만, jsonencode() 함수가 더 안전하다.

resource "aws_iam_policy" "read_logs" {
  name = "read-logs"
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect   = "Allow"
      Action   = ["s3:GetObject"]
      Resource = "arn:aws:s3:::${var.bucket_name}/*"
    }]
  })
}

JSON 문법 실수(쉼표 빠뜨림 등)를 컴파일 타임에 잡아주니 이 방식을 권장한다.

조건식 — 삼항 연산자

HCL엔 if문이 없다. 대신 대부분의 언어에 있는 삼항 연산자가 있다.

<조건> ? <참일 때 값> : <거짓일 때 값>

실제 쓰임은 이런 식이다.

variable "environment" {
  default = "dev"
}

resource "aws_instance" "web" {
  ami           = data.aws_ami.amazon_linux.id
  instance_type = var.environment == "prod" ? "t3.large" : "t3.micro"

  tags = {
    Backup = var.environment == "prod" ? "daily" : "none"
  }
}

“운영은 큰 인스턴스, 나머지는 작은 인스턴스”처럼 환경별 분기를 만들 때 자주 쓴다. 여러 조건을 엮고 싶으면 &&, ||, !를 조합한다.

high_availability = var.environment == "prod" && var.region != "ap-northeast-2"

조건식이 너무 복잡해지면 locals로 중간 변수를 두는 게 가독성에 좋다.

locals {
  is_prod    = var.environment == "prod"
  use_large  = local.is_prod || var.force_large
}

for 표현식 — 리스트/맵 변환

HCL의 for 표현식은 Python의 리스트 컴프리헨션과 거의 같다. 컬렉션을 다른 컬렉션으로 변환할 때 쓴다.

variable "users" {
  default = ["alice", "bob", "carol"]
}

locals {
  # 리스트 → 리스트
  upper_users = [for u in var.users : upper(u)]
  # ["ALICE", "BOB", "CAROL"]

  # 리스트 → 맵
  user_map = {for u in var.users : u => "${u}@example.com"}
  # {alice = "alice@example.com", bob = ...}

  # 조건 필터
  short_names = [for u in var.users : u if length(u) < 5]
  # ["alice", "bob"]
}

맵을 순회할 땐 (키, 값)을 함께 받는다.

variable "tags" {
  default = {
    env  = "dev"
    team = "platform"
  }
}

locals {
  formatted = [for k, v in var.tags : "${k}=${v}"]
  # ["env=dev", "team=platform"]
}

리소스를 여러 개 만들 때도 for 표현식과 for_each를 조합하면 깔끔하다. 리소스 반복 생성은 6편에서 다시 다룬다.

splat 연산자 — 속성 한 번에 뽑기

countfor_each로 여러 개 만든 리소스의 속성을 한 번에 뽑고 싶을 때 쓴다. *가 splat 연산자다.

resource "aws_instance" "web" {
  count         = 3
  ami           = data.aws_ami.amazon_linux.id
  instance_type = "t3.micro"
}

# splat으로 모든 인스턴스의 public_ip를 리스트로 뽑기
output "all_public_ips" {
  value = aws_instance.web[*].public_ip
  # ["3.1.1.1", "3.2.2.2", "3.3.3.3"]
}

같은 표현을 for 표현식으로 쓰면 이렇게 된다.

value = [for i in aws_instance.web : i.public_ip]

splat이 더 짧다. 단순히 같은 속성을 뽑을 땐 splat이, 가공이 필요하면 for가 더 맞다.

내장 함수 — 실무에서 자주 쓰는 것들

Terraform에는 100개가 넘는 내장 함수가 있다. 전부 외울 필요는 없고, “이런 게 있다”만 알아두면 된다. 자주 쓰는 몇 가지만 소개한다.

문자열 함수

lower("Hello")                 # "hello"
upper("hello")                 # "HELLO"
title("hello world")           # "Hello World"
trim("  hello  ", " ")         # "hello"
replace("a-b-c", "-", "_")     # "a_b_c"
format("hello, %s!", "world")  # "hello, world!"

format은 printf 스타일이라 기억해두면 편하다.

컬렉션 함수

length(["a", "b", "c"])        # 3
length("hello")                # 5
concat(["a"], ["b"], ["c"])    # ["a", "b", "c"]
merge({a=1}, {b=2})            # {a=1, b=2}
lookup({a=1}, "a", 0)          # 1 (기본값 0)
lookup({a=1}, "b", 0)          # 0
contains(["a","b"], "a")       # true
keys({a=1, b=2})               # ["a", "b"]
values({a=1, b=2})             # [1, 2]

merge는 맵을 합칠 때 자주 쓴다. 기본 태그 + 리소스별 태그를 합성하는 패턴이 대표적이다.

locals {
  common_tags = {
    ManagedBy   = "terraform"
    Environment = var.environment
  }
}

resource "aws_instance" "web" {
  # ...
  tags = merge(local.common_tags, { Role = "web" })
}

인코딩/디코딩

jsonencode({ a = 1 })          # "{\"a\":1}"
jsondecode("{\"a\":1}")        # {a = 1}
yamlencode({ a = 1 })          # "a: 1\n"
base64encode("hello")          # "aGVsbG8="
filebase64("cert.pem")         # 파일 읽어서 base64

사용자 데이터를 Base64로 인코딩해야 하는 EC2 등에 자주 쓰인다.

시간/날짜

timestamp()                            # "2026-04-20T04:30:00Z"
formatdate("YYYY-MM-DD", timestamp())  # "2026-04-20"

주의할 점이 있다. timestamp()는 apply할 때마다 값이 바뀌기 때문에, 리소스 인자에 직접 쓰면 매번 변경으로 감지된다. 태그 같은 곳에 쓸 땐 lifecycle { ignore_changes = [tags["LastUpdated"]] }를 곁들이거나, 아예 쓰지 않는 게 낫다.

파일 읽기

file("init.sh")           # 텍스트 파일 그대로 읽기
templatefile("init.sh.tpl", { name = "web" })  # 템플릿 렌더링

templatefile은 파일 안에 ${name} 같은 보간을 써서 렌더링한다. 사용자 데이터 스크립트를 파일로 분리할 때 유용하다.

# init.sh.tpl
#!/bin/bash
echo "Hello, ${name}!" > /tmp/hello.txt
resource "aws_instance" "web" {
  # ...
  user_data = templatefile("${path.module}/init.sh.tpl", {
    name = "terraform"
  })
}

표현식과 값의 흐름

여기까지 본 문법들이 어디에 어떻게 조합되는지 그림으로 정리하면 이렇다.

flowchart TB
    V[variable<br/>외부 입력] --> EX[표현식]
    L[locals<br/>중간 계산] --> EX
    D[data source<br/>조회 결과] --> EX
    R2[다른 resource<br/>참조] --> EX
    EX -->|문자열 보간<br/>조건식<br/>for<br/>함수| VAL[최종 값]
    VAL --> R[resource 인자]
    VAL --> O[output]

HCL 코드의 대부분은 “입력값을 표현식으로 가공해서 리소스 인자에 넣는” 구조다. 입력의 원천은 변수, locals, 데이터 소스, 다른 리소스의 속성이다. 이 흐름이 익숙해지면 어떤 프로젝트의 Terraform 코드를 봐도 대강 흐름이 보인다.

자주 하는 실수

언어 설명만 따라가면 놓치기 쉬운 실수 몇 가지만 짚는다.

이런 실수는 한 번씩 해봐야 기억에 남는다. 에러 메시지를 꼼꼼히 읽는 습관을 들이는 게 장기적으로 가장 큰 도움이 된다.

다음으로

이 편에서 다룬 문법이 HCL의 기본 골격이다. 다음 편은 이 문법 위에서 “재사용”을 만드는 장치들이다. 변수(variable)로 외부에서 값을 주입하고, 출력(output)으로 값을 꺼내고, 로컬(locals)로 중간 계산을 정리한다. 타입 시스템과 검증, .tfvars 우선순위 같은 실무 주제도 함께 본다.


4편: 변수와 출력


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
Terraform 2편 — 설치와 첫 배포
Next Post
Terraform 4편 — 변수와 출력