Terraform: 인프라스트럭처 as Code
왜 Terraform을 공부하게 됐나
AWS 콘솔에서 수동으로 EC2, RDS, VPC를 설정했는데, 같은 환경을 다시 만들려니 뭘 설정했는지 기억이 안 났습니다. 실수로 삭제하면 복구도 힘들었습니다.
처음에는 "그냥 다시 클릭하면 되지 않나?" 싶었습니다. 그런데 팀 프로젝트에서 스테이징 환경을 프로덕션과 동일하게 맞춰달라는 요청이 왔을 때, 정말 막막했습니다. 어떤 보안 그룹 규칙을 어떻게 걸었는지, VPC의 서브넷 CIDR 블록이 어떻게 나뉘어졌는지, 전혀 재현이 안 됐습니다. 메모해둔 것도 없고, 스크린샷도 없었습니다. 콘솔에서 기억에 의존해서 클릭하다 보니 두 환경이 조금씩 달라졌고, 그게 버그의 원인이 됐습니다.
"Terraform을 써보라"는 조언을 들었고, 적용하자 인프라를 코드로 관리하고, 버전 관리하고, 자동으로 생성/삭제할 수 있었습니다!
처음엔 뭐가 이해가 안 갔나
가장 혼란스러웠던 부분은 "코드로 인프라를 만든다는 게 뭔가?"였습니다. 클릭하는 게 아니라 코드를 쓴다고?
백엔드 코드나 프론트엔드 코드는 익숙했습니다. 함수를 호출하면 결과가 나오는 방식은 이해했습니다. 그런데 HCL이라는 낯선 언어로 resource "aws_instance" "web" { ... } 이런 식으로 쓰는 게 뭘 하는 건지 처음엔 감이 안 잡혔습니다. 이게 실행되면 실제 서버가 뜨는 건가? 어떻게 뜨는 건가? 그 과정이 전혀 보이지 않으니 블랙박스처럼 느껴졌습니다.
또 다른 혼란은 "State 파일이 뭔가?"였습니다. 왜 필요한 걸까?
terraform apply를 처음 실행했을 때 terraform.tfstate라는 파일이 생겼습니다. 열어보니 JSON이었는데, 내가 만든 EC2 인스턴스의 ID, IP, 설정 값들이 빼곡히 담겨 있었습니다. "이게 왜 있는 거지?" 싶었는데, 나중에 이 파일이 없으면 Terraform이 현재 클라우드 상태를 전혀 모른다는 걸 알았습니다. State 파일을 실수로 지웠다가 terraform plan이 모든 리소스를 새로 만들려 해서 패닉했던 경험이 있습니다.
어떤 포인트에서 이해가 됐나
이해하는 데 결정적이었던 비유는 "건축 설계도"였습니다.
수동 설정 = 직접 짓기:
- 기억에 의존
- 재현 어려움
- 실수 많음
Terraform = 설계도:
- 코드로 명시
- 언제든 재현 가능
- Git으로 버전 관리
이 비유로 이해했습니다. Terraform은 인프라를 코드로 정의하여 자동화하고 관리하는 도구라는 것을.
Git을 쓸 때 "코드를 버전 관리한다"는 개념이 처음엔 낯설었지만, 쓰다 보니 당연하게 됐습니다. Terraform이 바로 그 역할을 인프라에 대해 하는 것이었습니다. git diff로 코드 변경을 보듯, terraform plan으로 인프라 변경을 미리 볼 수 있고, git commit으로 변경을 기록하듯 .tf 파일을 커밋해두면 언제든 그 시점의 인프라를 재현할 수 있다는 게 와닿았습니다.
State가 핵심이다
처음에 State 파일의 존재가 불편하게 느껴졌습니다. "왜 이런 파일을 따로 관리해야 하지?" 싶었습니다. 그런데 Terraform의 작동 방식을 이해하고 나면 State가 왜 필수인지 납득이 됩니다.
Terraform은 선언적(Declarative) 도구입니다. "이런 인프라를 원합니다"라고 .tf 파일에 적어두면, Terraform이 현재 상태와 원하는 상태를 비교해서 그 차이만 반영합니다. 이 비교를 하려면 "현재 상태"를 어딘가에 기록해둬야 합니다. 그게 State 파일입니다.
예를 들어, EC2 인스턴스를 만들었다고 가정합니다. AWS에서 이 인스턴스에 부여한 ID는 i-0abc123def456789입니다. 이 정보가 State에 없으면 Terraform은 "이 인스턴스가 이미 존재하는지" 알 방법이 없습니다. 다음에 terraform apply를 실행하면 새 인스턴스를 또 만들려 합니다.
State 파일에서 중요하게 정리해본 규칙들입니다.
절대 State를 Git에 올리지 말 것. State 파일에는 DB 비밀번호, API 키 같은 민감 정보가 평문으로 들어갈 수 있습니다. .gitignore에 *.tfstate, *.tfstate.backup을 꼭 추가해야 합니다.
팀에서는 반드시 원격 State를 써야 한다. 로컬에 State 파일이 있으면 나만 apply를 할 수 있습니다. 팀원 A가 apply하는 동안 팀원 B도 apply하면 State가 꼬입니다. S3 + DynamoDB 조합으로 원격 저장 + 락(Lock)을 걸면 이 문제가 해결됩니다.
기본 사용법
AWS EC2 생성
# main.tf
provider "aws" {
region = "us-east-1"
}
resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t2.micro"
tags = {
Name = "WebServer"
}
}
실행:
# 초기화
terraform init
# 계획 확인
terraform plan
# 적용
terraform apply
# 삭제
terraform destroy
변수 사용
# variables.tf
variable "instance_type" {
description = "EC2 instance type"
type = string
default = "t2.micro"
}
variable "environment" {
description = "Environment name"
type = string
}
# main.tf
resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = var.instance_type
tags = {
Name = "WebServer"
Environment = var.environment
}
}
사용:
terraform apply -var="environment=production"
출력
# outputs.tf
output "instance_ip" {
description = "Public IP of instance"
value = aws_instance.web.public_ip
}
output "instance_id" {
value = aws_instance.web.id
}
실제 예시
VPC + EC2 + RDS
# VPC
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
tags = {
Name = "main-vpc"
}
}
# Subnet
resource "aws_subnet" "public" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.1.0/24"
tags = {
Name = "public-subnet"
}
}
# EC2
resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t2.micro"
subnet_id = aws_subnet.public.id
tags = {
Name = "web-server"
}
}
# RDS
resource "aws_db_instance" "postgres" {
identifier = "mydb"
engine = "postgres"
engine_version = "13.7"
instance_class = "db.t3.micro"
allocated_storage = 20
username = "admin"
password = var.db_password
skip_final_snapshot = true
}
Kubernetes 클러스터
# EKS Cluster
resource "aws_eks_cluster" "main" {
name = "my-cluster"
role_arn = aws_iam_role.cluster.arn
vpc_config {
subnet_ids = aws_subnet.private[*].id
}
}
# Node Group
resource "aws_eks_node_group" "main" {
cluster_name = aws_eks_cluster.main.name
node_group_name = "main-nodes"
node_role_arn = aws_iam_role.node.arn
subnet_ids = aws_subnet.private[*].id
scaling_config {
desired_size = 2
max_size = 5
min_size = 1
}
instance_types = ["t3.medium"]
}
모듈
모듈을 처음 썼을 때 "이게 함수랑 같은 개념이구나"라는 생각이 들었습니다. 코드에서 중복되는 로직을 함수로 뽑듯, 반복되는 인프라 구성을 모듈로 뽑을 수 있습니다. 개발 환경과 프로덕션 환경에 각각 웹 서버가 필요하다면, 서버 정의를 두 번 복붙하는 게 아니라 하나의 모듈을 두 번 호출하면 됩니다.
# modules/web-server/main.tf
variable "instance_type" {
type = string
}
variable "name" {
type = string
}
resource "aws_instance" "this" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = var.instance_type
tags = {
Name = var.name
}
}
output "public_ip" {
value = aws_instance.this.public_ip
}
# 사용
module "web_server" {
source = "./modules/web-server"
instance_type = "t2.micro"
name = "production-web"
}
output "web_ip" {
value = module.web_server.public_ip
}
모듈을 잘 만들어두면 새 프로젝트에서 재사용할 수 있고, 팀 내 인프라 패턴을 표준화할 수 있습니다. 실제로 회사에서 공통 모듈 레포를 따로 두고 여러 프로젝트에서 참조하는 방식을 쓰는 경우가 많습니다.
State 관리
로컬 State (기본)
# terraform.tfstate 파일에 저장
terraform apply
원격 State (S3)
terraform {
backend "s3" {
bucket = "my-terraform-state"
key = "prod/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-locks" # State 잠금
encrypt = true
}
}
인프라 드리프트와 import
terraform plan을 실행했더니 아무것도 바꾸지 않았는데 "변경이 감지됐다"는 출력이 나온 적이 있습니다. 알고 보니 팀원이 급하게 콘솔에서 직접 보안 그룹 규칙을 수정한 것이었습니다. 코드와 실제 인프라가 달라진 상태, 이걸 "드리프트(Drift)"라고 부릅니다.
드리프트가 생기면 terraform plan이 코드 기준으로 되돌리려 합니다. 팀원이 추가한 규칙을 Terraform이 지우려 할 수 있습니다. 이 상황에서 정리해본 대응 방식은 두 가지입니다.
첫째, 콘솔에서 한 변경이 필요했던 거라면 그 내용을 코드에 반영하고 apply합니다. 코드가 항상 진실의 원천(source of truth)이어야 합니다.
둘째, 이미 콘솔에서 만들어진 리소스를 Terraform으로 가져오고 싶다면 terraform import를 씁니다. Terraform에 "이 리소스가 이미 존재하고, 이 코드 블록과 연결해"라고 알려주는 명령어입니다.
# 기존 EC2 인스턴스를 State에 등록
terraform import aws_instance.web i-0abc123def456789
import 후에는 terraform plan으로 코드와 실제 상태가 일치하는지 반드시 확인해야 합니다.
팁
1. 환경별 분리
terraform/
├── dev/
│ ├── main.tf
│ └── terraform.tfvars
├── staging/
│ ├── main.tf
│ └── terraform.tfvars
└── prod/
├── main.tf
└── terraform.tfvars
2. 민감 정보 관리
# terraform.tfvars (Git에 추가하지 않음)
db_password = "secret123"
# .gitignore
*.tfvars
terraform.tfstate
3. 계획 저장
# 계획 저장
terraform plan -out=tfplan
# 저장된 계획 적용
terraform apply tfplan
4. CI/CD와 연동
GitHub Actions나 GitLab CI에서 PR이 열릴 때 terraform plan을 자동 실행하고 그 결과를 PR 코멘트로 달아주면, 팀원들이 코드 리뷰하면서 인프라 변경도 함께 검토할 수 있습니다. terraform apply는 main 브랜치 머지 후에만 실행하도록 제한해두면 실수를 많이 줄일 수 있습니다.
자주 만나는 실수들
Terraform을 쓰면서 직접 겪거나 팀에서 겪은 실수들을 정리해본다.
State 파일을 Git에 올린 경우. .gitignore에 추가하는 걸 빠뜨리면 State 파일이 커밋됩니다. DB 비밀번호가 평문으로 이력에 남습니다. 이미 올라갔다면 git filter-branch 또는 git filter-repo로 이력에서 제거해야 하는데, 이 과정이 꽤 번거롭습니다. 처음부터 .gitignore에 넣는 습관이 중요합니다.
terraform destroy를 프로덕션에서 실수로 실행한 경우. 실수로 프로덕션 환경에서 destroy를 실행하면 되돌리기 어렵습니다. CI/CD에서 프로덕션 apply는 수동 승인(manual approval) 단계를 넣어두는 게 좋습니다. GitHub Actions의 environment 보호 규칙이나 Atlantis의 apply lock 기능이 이럴 때 유용합니다.
의존성 순서를 잘못 이해한 경우. EC2 인스턴스가 속한 서브넷을 먼저 지우려 할 때 에러가 납니다. Terraform이 의존성을 알아서 처리해주지만, 명시적 의존성이 빠지면 순서가 꼬일 수 있습니다. depends_on을 활용해서 명시적으로 의존성을 선언하면 이런 문제를 피할 수 있습니다.
프로바이더 버전을 고정하지 않은 경우. terraform init을 실행할 때마다 최신 프로바이더를 내려받으면, 어느 날 갑자기 API 변경으로 기존 코드가 깨질 수 있습니다. required_providers 블록에서 버전을 ~> 5.0 형식으로 고정해두면 이 위험을 줄일 수 있습니다. 처음엔 귀찮게 느껴졌는데, 버전 충돌로 시간을 날린 뒤로 이 습관이 몸에 밴 것이 이해됩니다.
마무리
Terraform을 처음 접했을 때는 "그냥 콘솔 클릭이 더 빠른 거 아닌가?" 싶었습니다. 그런데 프로젝트 규모가 조금만 커져도 수동 관리의 한계가 바로 드러납니다. 환경이 두 개만 돼도 동일하게 맞추기가 힘들고, 팀원이 두 명만 넘어도 누가 뭘 바꿨는지 추적이 안 됩니다.
Terraform을 쓰면서 인프라가 코드베이스의 일부가 됐습니다. PR 리뷰하듯 인프라 변경을 리뷰하고, git log 보듯 인프라 이력을 봅니다. "서버 설정을 언제 바꿨더라?"가 git log로 해결됩니다.
작게 시작해도 됩니다. EC2 인스턴스 하나짜리 main.tf부터 시작해서, terraform apply의 경험이 쌓이면 자연스럽게 모듈화와 원격 State로 넘어가게 됩니다.