Skip to content
ioob.dev
Go back

Terraform 4편 — 변수와 출력

· 8분 읽기
Terraform 시리즈 (4/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

재사용이 시작되는 자리

지금까지의 코드는 값이 전부 하드코딩이었다. ap-northeast-2, t3.micro, terraform-first-deploy 같은 값들이 리소스 안에 직접 박혀 있었다. 실습으로는 문제없지만, 실무에선 금방 한계가 온다. 같은 구성을 개발/스테이징/운영에 각각 만들고 싶다면? 리전을 바꿔가며 돌리고 싶다면? 전체 코드를 복사해서 값만 바꾸는 건 재앙이다.

이 문제를 푸는 장치가 변수(variable), 출력(output), 로컬(locals) 세 가지다. 이름만 보면 단순하지만 역할은 분명히 다르다.

이번 편은 이 세 가지를 하나씩 풀어본다.

variable — 외부 입력

가장 많이 쓰는 블록이다. variables.tf 파일을 관례적으로 만들고 거기에 모든 변수를 모은다.

# variables.tf
variable "region" {
  description = "리소스를 배포할 AWS 리전"
  type        = string
  default     = "ap-northeast-2"
}

variable "environment" {
  description = "환경 이름 (dev, staging, prod)"
  type        = string
}

각 요소를 살펴보자.

environmentdefault가 없으니 반드시 값을 줘야 한다. 값을 안 주고 apply하면 Terraform이 대화형으로 물어보거나, 비대화형 환경에선 에러로 실패한다.

변수를 참조할 땐 var. 접두사를 붙인다.

provider "aws" {
  region = var.region
}

resource "aws_instance" "web" {
  # ...
  tags = {
    Environment = var.environment
  }
}

타입 제약

HCL은 JavaScript처럼 값을 암묵적으로 넣어도 어느 정도 돌아가지만, 변수엔 타입을 명시하는 게 원칙이다. 타입 제약은 실수를 컴파일 타임에 잡아준다.

기본 타입

variable "name" {
  type = string
}

variable "count" {
  type = number
}

variable "enabled" {
  type = bool
}

컬렉션 타입

# 리스트 (같은 타입만 허용)
variable "availability_zones" {
  type    = list(string)
  default = ["ap-northeast-2a", "ap-northeast-2b"]
}

# 맵 (같은 타입 값만)
variable "instance_types" {
  type = map(string)
  default = {
    dev  = "t3.micro"
    prod = "t3.large"
  }
}

# 셋
variable "allowed_ports" {
  type    = set(number)
  default = [80, 443]
}

구조화 타입

여러 속성을 묶은 구조화 타입도 있다. object와 tuple이다.

# object — 이름 있는 속성을 가진 구조체
variable "web_server" {
  type = object({
    ami           = string
    instance_type = string
    count         = number
    enable_logs   = bool
  })
  default = {
    ami           = "ami-xxx"
    instance_type = "t3.micro"
    count         = 2
    enable_logs   = true
  }
}

# tuple — 순서 있는 여러 타입 모음
variable "location" {
  type    = tuple([string, number, number])
  default = ["Seoul", 37.5665, 126.9780]
}

실무에선 object가 훨씬 자주 쓰인다. 관련된 여러 값을 한 덩어리로 묶고 싶을 때 유용하다. tuple은 거의 안 쓴다.

optional — 선택 속성

object 안의 속성을 선택적으로 만들 수 있다(Terraform 1.3+).

variable "web_server" {
  type = object({
    ami           = string
    instance_type = string
    enable_logs   = optional(bool, false)   # 기본값 false
    backup        = optional(string)        # 기본값 null
  })
}

“대부분의 경우 기본값이면 충분한데, 가끔 덮어쓰고 싶은 속성”에 쓴다. 모듈을 만들 때 사용자 경험이 크게 좋아진다.

any — 타입 추론에 맡김

variable "tags" {
  type    = any
  default = {}
}

타입을 강제하지 않고 싶을 때 any를 쓴다. 유연하지만 위험하기도 하니, 정말 타입을 못 정할 때만 쓰자.

validation — 값 검증

타입만으론 부족한 검증이 있다. 예를 들어 “environment는 dev/staging/prod 중 하나여야 한다” 같은 규칙이다. validation 블록으로 해결한다.

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

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

variable "instance_type" {
  type = string

  validation {
    condition     = can(regex("^t3\\.", var.instance_type))
    error_message = "비용 절감을 위해 t3 계열만 허용한다."
  }
}

condition은 true가 나와야 통과한다. can()은 “에러 없이 실행되면 true”를 반환하는 함수라 regex 같은 검증에 자주 쓴다. 여러 validation 블록을 중첩해서 여러 규칙을 걸 수도 있다.

팀 단위로 규칙을 코드에 박아두면, 실수로 설정을 넣어도 plan 단계에서 잡힌다. 리뷰어가 잡는 것보다 효율적이다.

sensitive — 민감한 값

데이터베이스 비밀번호, API 키 같은 값은 plan/apply 로그에 찍히면 안 된다. sensitive = true로 표시하면 Terraform이 로그에서 값을 (sensitive value)로 가린다.

variable "db_password" {
  description = "데이터베이스 마스터 비밀번호"
  type        = string
  sensitive   = true
}

resource "aws_db_instance" "main" {
  # ...
  password = var.db_password
}

이 변수가 들어간 리소스의 output도 자동으로 민감하게 처리된다. 단, 주의할 게 있다. Sensitive 표시는 로그에서만 가릴 뿐, tfstate 파일엔 평문으로 저장된다. 그래서 State 파일은 암호화된 원격 백엔드(S3 with SSE, Terraform Cloud 등)에 보관해야 한다.

더 나은 방식은 변수에 비밀번호를 직접 넣지 않고, AWS Secrets Manager나 SSM Parameter Store에서 읽어오는 것이다.

data "aws_secretsmanager_secret_version" "db" {
  secret_id = "myapp/db/password"
}

resource "aws_db_instance" "main" {
  password = data.aws_secretsmanager_secret_version.db.secret_string
}

이러면 tfstate엔 시크릿 ARN만 남고, 실제 값은 AWS 쪽에서 관리된다.

값을 주는 여러 방법과 우선순위

변수에 값을 넣는 경로는 여러 가지다. 같은 변수가 여러 경로로 지정되면 우선순위가 적용된다.

flowchart LR
    D[기본값<br/>default] -->|낮은 우선순위| E
    E[환경 변수<br/>TF_VAR_foo] -->|덮어씀| F
    F[terraform.tfvars] --> G
    G[*.auto.tfvars<br/>알파벳순] --> H
    H[-var-file= 옵션] --> I
    I[-var 옵션<br/>최고 우선순위]

우선순위가 낮은 것부터 높은 것 순으로 정리하면 이렇다.

1. default (가장 낮음)

variable 블록의 default 속성. 다른 값이 없을 때 쓰인다.

2. 환경 변수

TF_VAR_<이름> 형태의 환경 변수를 읽어온다.

export TF_VAR_region="us-east-1"
terraform apply

CI 파이프라인에서 민감한 값을 넣을 때 유용하다.

3. terraform.tfvars 또는 terraform.tfvars.json

같은 디렉토리에 이 이름의 파일이 있으면 자동으로 읽는다. 관례적으로 프로젝트별 기본값을 여기에 적는다.

# terraform.tfvars
region      = "ap-northeast-2"
environment = "dev"

4. *.auto.tfvars

terraform.tfvars 외에도 <이름>.auto.tfvars 파일이 있으면 모두 자동으로 읽는다. 알파벳 순으로 읽혀서 뒤에 오는 파일이 앞을 덮어쓴다.

# dev.auto.tfvars
instance_type = "t3.micro"

# prod.auto.tfvars  (이 파일은 보통 prod 배포용 브랜치에만 둔다)
instance_type = "t3.large"

5. -var-file= 옵션

파일을 명시적으로 지정하고 싶을 땐 CLI 옵션을 쓴다.

terraform apply -var-file="environments/prod.tfvars"

환경별 tfvars를 디렉토리 구조로 나눠 관리할 때 이 방식이 편하다.

environments/
  dev.tfvars
  staging.tfvars
  prod.tfvars

6. -var 옵션 (가장 높음)

한 번 실행하면서 일회성으로 값을 덮어쓰고 싶을 때.

terraform apply -var="environment=staging" -var="instance_type=t3.small"

CI에서 태그 값만 일시적으로 바꿀 때 유용하다.

실무 권장 패턴

한 프로젝트에서 환경별로 관리하는 전형적인 구성은 이렇다.

terraform/
  main.tf
  variables.tf
  outputs.tf
  environments/
    dev.tfvars
    staging.tfvars
    prod.tfvars

배포 시엔 환경별 tfvars를 명시적으로 지정한다.

terraform workspace select dev
terraform apply -var-file="environments/dev.tfvars"

민감값(API 키 등)은 tfvars에 넣지 말고 환경 변수나 Secrets Manager로 분리한다.

output — 값 내보내기

output 블록은 apply가 끝난 뒤 “이 값을 밖으로 꺼내놓겠다”는 선언이다. 세 군데에서 쓰인다.

# outputs.tf
output "instance_id" {
  description = "EC2 인스턴스 ID"
  value       = aws_instance.web.id
}

output "instance_public_ip" {
  description = "EC2 퍼블릭 IP"
  value       = aws_instance.web.public_ip
}

output "db_endpoint" {
  description = "RDS 엔드포인트"
  value       = aws_db_instance.main.endpoint
  sensitive   = false
}

output "db_password" {
  description = "DB 비밀번호 (로그에 안 찍힘)"
  value       = var.db_password
  sensitive   = true
}

sensitive = true가 붙은 output은 plan/apply 로그에서 (sensitive value)로 가려진다. 단, terraform output <이름>으로 직접 조회하면 보이긴 한다. 가리기보다 “실수로 로그에 남지 않게” 하는 장치다.

output은 JSON 형태로도 뽑을 수 있다.

terraform output -json > outputs.json

CI 파이프라인에서 “apply 후 생성된 값을 다음 스텝으로 넘겨야 할 때” 이 형태가 요긴하다.

locals — 중간 계산

locals는 모듈 안에서만 쓰는 중간 변수다. 같은 값을 여러 곳에서 쓰고 있을 때, 또는 복잡한 표현식을 한 번만 계산하고 싶을 때 쓴다.

# locals.tf
locals {
  # 공통 태그
  common_tags = {
    Project     = var.project_name
    Environment = var.environment
    ManagedBy   = "terraform"
    CostCenter  = "platform-team"
  }

  # 조건에 따른 계산
  is_prod        = var.environment == "prod"
  instance_type  = local.is_prod ? "t3.large" : "t3.micro"
  backup_enabled = local.is_prod

  # 이름 규칙
  name_prefix = "${var.project_name}-${var.environment}"
}

참조할 땐 local. 접두사를 쓴다(locals. 아님).

resource "aws_instance" "web" {
  ami           = data.aws_ami.amazon_linux.id
  instance_type = local.instance_type

  tags = merge(local.common_tags, {
    Name = "${local.name_prefix}-web"
    Role = "web"
  })
}

variable과 locals의 차이를 헷갈리기 쉬운데, 역할이 분명히 다르다.

둘 다 같은 용도로 쓰지 말자. “파라미터”는 variable, “내부 계산”은 locals다.

셋을 엮은 전형 패턴

변수, 출력, locals가 실전에서 어떻게 엮이는지 하나의 흐름으로 보자. 환경별로 EC2를 띄우는 작은 모듈이라고 치자.

# variables.tf — 외부 입력
variable "environment" {
  type = string
  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "environment는 dev, staging, prod 중 하나여야 한다."
  }
}

variable "project_name" {
  type = string
}

# locals.tf — 내부 파생값
locals {
  is_prod = var.environment == "prod"

  common_tags = {
    Project     = var.project_name
    Environment = var.environment
    ManagedBy   = "terraform"
  }

  instance_type = local.is_prod ? "t3.large" : "t3.micro"
  replicas      = local.is_prod ? 3 : 1
  name_prefix   = "${var.project_name}-${var.environment}"
}

# main.tf — 리소스 정의
resource "aws_instance" "web" {
  count         = local.replicas
  ami           = data.aws_ami.amazon_linux.id
  instance_type = local.instance_type

  tags = merge(local.common_tags, {
    Name = "${local.name_prefix}-web-${count.index}"
  })
}

# outputs.tf — 바깥으로 내보낼 값
output "instance_ids" {
  value = aws_instance.web[*].id
}

output "public_ips" {
  value = aws_instance.web[*].public_ip
}

한 프로젝트의 Terraform 디렉토리는 대체로 이런 모양을 유지한다. variable은 파라미터, locals는 공유 계산, resource는 실제 선언, output은 결과 노출. 역할이 분명하면 코드가 10배로 늘어도 정리된다.

자주 하는 실수

다음으로

변수와 출력을 다루면 Terraform 코드에 숨이 트인다. 다음 편에서는 Terraform이 클라우드 API와 대화하는 창구인 프로바이더를 파고든다. required_providers 블록의 버전 제약을 제대로 거는 법, 여러 리전/계정을 한 프로젝트에서 다루는 법(alias), 공식 프로바이더와 커뮤니티 프로바이더의 차이까지 본다.


5편: 프로바이더


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
Terraform 3편 — HCL 문법
Next Post
Terraform 5편 — 프로바이더