본문으로 건너뛰기
Previous
Next
Terraform 완벽 가이드 — 엔진·그래프·프로토콜·State·프로덕션

Terraform 완벽 가이드 — 엔진·그래프·프로토콜·State·프로덕션

Terraform 완벽 가이드 — 엔진·그래프·프로토콜·State·프로덕션

이 글의 핵심

Terraform은 선언적 IaC 도구이며, 내부적으로는 구성과 State를 바탕으로 의존성 그래프를 만들고 프로바이더 플러그인(gRPC)을 통해 API와 통신합니다. 이 글은 HCL·모듈·멀티클라우드 운영에 더해, 그래프 구성·프로토콜·State 잠금·플랜/적용 파이프라인·프로덕션 패턴까지 엔진 관점에서 깊이 있게 다룹니다.

솔직히 말하면 HCL이 얼마나 예쁘고, 모듈이 몇 층이냐, for_each를 얼마나 우아하게 쓰냐는 부수적인 문제다. 팀이 커질수록, 프로덕션에서 살아남는 건 결국 상태 파일 관리 한 가지다. terraform.tfstate가 누구 것인지(로컬인지, S3·GCS·Terraform Cloud인지), 락이 누가 잡는지, 그걸 실수로 덮어쓰거나 분실하지 않는지가 전부다. 그래서 나는 처음부터 “상태 파일 관리가 전부”라고 잡고 설계하는 편이다. 나머지는 그 뒤에 온다.

Terraform이 하는 일을 한 문장에 줄이면, “코드에 적어 둔 최종 모습과, State에 기록된 지금을 비교해서 차이를 메우는 도구”다. 콘솔로 클릭해서 만든 인프라는 감사도 애매하고, 재현은 더 애매하다. Git에 HCL이 있어도 State가 꼬이면 plan은 랜덤에 가깝다.

그렇다고 선언적이고 멱등하다는 말이 마법은 아니다. terraform plan 뒤에는 구성 파싱, 의존성 그래프(참조로 암시적 간선, depends_on으로 명시적 간선, 순환이 있으면 그냥 터짐), 프로바이더 플러그인이 gRPC로 PlanResourceChange 같은 걸 돌리는 일이 겹쳐 있다. 코어가 AWS API를 직접 때리는 게 아니라, 프로바이더 바이너리가 API 규칙에 맞게 diff를 반환하는 구조다. 그러니 required_providers.terraform.lock.hcl을 CI에서 못 박아 두는 이유가 있다. 동일 HCL이어도 프로바이더 버전 올리면 replace가 쏟아질 수 있다.

버전을 이렇게 잡는 식이면 팀이 기대하는 동작이 맞아진다.

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

variable·output·locals는 반복을 줄이고, 모듈은 디렉터리 단위로 경계를 만든다. 루트가 module "network"로 부르고, 소스는 로컬이든 Git이든 태그/커밋으로 고정하는 게 맞다. AWS·Azure·GCP는 HCL 문법은 같아도 리전, IAM, 이름 규칙이 전부 달라서, “멀티 클라우드 추상화”에 목숨 걸기보다는 팀 용어집이랑 프로바이더 문서를 같이 읽는 게 낫다.


IaC로 이미 떡 치고 있는 인프라를 건드릴 때 이야기가 재밌다. 나는 흔히 이런 마이그레이션을 본다. 1) 먼저 문서로 “뭐가 어디 ID로 붙어 있는지”를 정리한다(여기서 이미 반은 간다). 2) .tf목표 모양을 대충이 아니라, 실제와 맞게 짠다. 3) terraform import로 콘솔에 있던 걸 State에 한 줄씩 넣는다. 이때 “import 됐다!”가 끝이 아니라, plan0에 가깝게 나올 때까지 속성·태그·의존성을 맞추는 게 본게임이다. 4) 그다음 백엔드 이사다. 처음엔 노트북 terraform.tfstate만 있던 걸, 팀이 같이 쓰려면 원격으로 보내야 한다. backend "s3" + DynamoDB 락 같은 패턴이 흔한 이유가 여기다. S3는 객체 저장, DynamoDB는 동시 apply 막는 락—둘 다 없으면 누군가 apply 두 번 돌다가 state 깨질 확률이 올라간다. GCS·Azure Blob도 “저장 + 잠금”이 어떻게 붙는지는 백엔드 문서를 보면 된다. Terraform Cloud는 SaaS가 락·RBAC까지 챙겨 주니까, 조직이 크면 옵션이 된다. 백엔드 옮길 땐 terraform init -migrate-state 쓰는데, 이건 사고 나면 state 날릴 수 있어서 백업(S3 버전, terraform state pull 결과) 먼저, 운영 시간·승인 문서 남기고 가는 쪽이 정신 건강에 좋다.

원격 백엔드 예시는 대략 이런 느낌이다(버킷 이름·키는 네 조직에 맞게).

terraform {
  backend "s3" {
    bucket         = "my-org-terraform-state"
    key            = "prod/network/terraform.tfstate"
    region         = "ap-northeast-2"
    dynamodb_table = "terraform-locks"
    encrypt        = true
  }
}

key스택마다 쪼개는 게 맞다. 한 덩어리 state에 몰빵하면 plan도 느리고, 실패해도 파편이 커진다. terraform_remote_state느슨하게만 엮는 편이 현장에선 잘 먹힌다.

Workspace는 “같은 코드, 다른 state”를 가볍게 쓰는 도구다. env/dev·env/prod완전히 다른 백엔드·권한·승인이면 나는 디렉터리·브랜치·파이프라인으로 쪼개는 쪽이 실수를 줄인다. 그리고 시크릿을 .tf에 박지 말고, CI 시크릿·클라우드 시크릿 매니저 쪽으로 빼는 건 당연이고, state에 민감한 게 남지 않게 설계하는 것도 “상태”의 일부다. 최소 권한 CI 계정, 개인 키로 prod apply 같은 건 감사에서 끔찍하다. Sentinel/OPA로 퍼블릭 S3 막는 건, 규모 커지면 정책도 state 옆에 붙는다고 보면 된다.

교육용으로 VPC + 서브넷 + ALB 골격을 한 번 쳐볼 수는 있다. CIDR, NAT 비용(프라이빗 쪽에 아웃바운드 필요할 때), WAF, 삭제 보호는 운영에서 따로 잡는다.

# 개념용. CIDR·AZ는 환경에 맞게.
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_lb" "app" {
  name               = "app-alb"
  load_balancer_type = "application"
  subnets            = aws_subnet.public[*].id
  security_groups    = [aws_security_group.alb.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"]
  }
}

cidrsubnet + count는 AZ마다 찍는 패턴이고, IGW만으로는 private 쪽 NAT·라우트는 안 해결된다. 앱은 EC2냐 EKS냐에 따라 스택을 어디까지 넣을지 문제다.

plan이 이상할 때: 먼저 drift 의심한다. 콘솔에서 태그만 만져도 plan에 “돌려놓기” 뜰 수 있다. CI가 -refresh=false로 달리면 숨는 경우도 있어서, 가끔은 refresh 넣은 plan을 굴리는 게 맞다. 락 에러는 누가 잡고 있는지(S3+Dynamo면 락 테이블, TFC면 UI) 보고, force-unlock다른 apply 없다는 합의 후에만. 비정상 종료로 락이 남는 건 흔하다. replace가 갑자기 늘면 프로바이더 릴리스 노트 보고, ignore_changes임시로만. 백엔드 마이그레이션이 꼬이면 terraform state pull로 확보한 걸 믿고, init -backend=false읽기 검증하고 다시.

정리하자. Terraform은 “선언적”이라 착한 언어인 것 같지만, 런타임은 그래프 스케줄 + gRPC 프로바이더 + State 저장소 묶음이다. 그중 State 저장소(어디, 누가 쓰기, 락, 백업, 암호화) 를 어떻게 잡느냐가 전부에 가깝다. 그다음이 모듈·HCL·멀티 클라우드·그래프 디버깅이다. 비용·쿼터·보안은 적용 전에 꼭 본다.

같은 맥락으로 Terraform — IaC·멀티클라우드, 실전 가이드(국문), [Practical(영문)](/en/blog/terraform-practical-guide/이 있으면 같이 읽으면 좋다. Terraform, IaC, AWS, DevOps, Infrastructure, gRPC, State로 찾아와도 이 글은 잘 맞는다.