Table of contents
- 언어를 안다는 것
- HCL이 뭔가
- 블록 구조
- 주석
- 기본 타입
- 컬렉션 — list, map, set
- 문자열 보간
- heredoc — 여러 줄 문자열
- 조건식 — 삼항 연산자
- for 표현식 — 리스트/맵 변환
- splat 연산자 — 속성 한 번에 뽑기
- 내장 함수 — 실무에서 자주 쓰는 것들
- 표현식과 값의 흐름
- 자주 하는 실수
- 다음으로
언어를 안다는 것
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 연산자 — 속성 한 번에 뽑기
count나 for_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 코드를 봐도 대강 흐름이 보인다.
자주 하는 실수
언어 설명만 따라가면 놓치기 쉬운 실수 몇 가지만 짚는다.
${}를 남용한다. 표현식 전체를 감쌀 땐 그냥 표현식으로 쓴다(region = var.region). 문자열 안에 끼워 넣을 때만"${var.region}-suffix"- 리스트와 셋을 혼동한다.
for_each는 맵이나 셋을 요구하기 때문에 리스트를 넣으면 에러다.toset()으로 변환하거나 for 표현식으로 맵을 만든다 count와for_each를 섞어 쓰려 한다. 한 리소스에 둘 중 하나만 된다. 6편에서 용법을 나눠서 본다timestamp()를 리소스에 직접 쓴다. 매 apply마다 값이 바뀌어서 불필요한 변경이 감지된다- heredoc 안에
$가 있을 때 이스케이프를 깜빡한다. Bash 변수$VAR를 그대로 쓰면 Terraform이 보간으로 오해한다.$$VAR로 써야$VAR가 된다
이런 실수는 한 번씩 해봐야 기억에 남는다. 에러 메시지를 꼼꼼히 읽는 습관을 들이는 게 장기적으로 가장 큰 도움이 된다.
다음으로
이 편에서 다룬 문법이 HCL의 기본 골격이다. 다음 편은 이 문법 위에서 “재사용”을 만드는 장치들이다. 변수(variable)로 외부에서 값을 주입하고, 출력(output)으로 값을 꺼내고, 로컬(locals)로 중간 계산을 정리한다. 타입 시스템과 검증, .tfvars 우선순위 같은 실무 주제도 함께 본다.


Loading comments...