Skip to content
ioob.dev
Go back

Terraform 8편 — State 관리

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

state가 왜 중요할까

Terraform을 처음 만지면 terraform.tfstate라는 파일이 생긴 걸 보게 된다. 실수로 지우거나 커밋해버리면 다음부터는 같은 리소스를 또 만들려고 덤벼든다. 이미 만들어진 AWS 인스턴스가 있는데도 말이다.

왜 이런 일이 벌어지는가. Terraform은 “내가 지금까지 뭘 만들었는지”를 state에 저장한다. HCL 코드는 “원하는 최종 모습”이고, state는 “현재 실제로 만들어진 것”의 스냅샷이다. Terraform은 이 둘을 비교해서 차이만큼만 API를 호출한다. state가 없으면 비교 대상이 없어진다.

flowchart LR
    HCL["HCL 코드\n(원하는 상태)"]
    State["state 파일\n(실제 만들어진 것)"]
    Cloud["클라우드\n(진짜 현재 상태)"]

    HCL -->|plan 시 비교| Diff["차이 계산"]
    State -->|plan 시 비교| Diff
    Diff -->|apply| Cloud
    Cloud -.refresh.-> State

그래서 state 관리는 Terraform 운영의 절반이라고 해도 과언이 아니다. 혼자 쓸 때는 로컬 파일로 충분하지만, 팀이 되는 순간부터는 이야기가 달라진다.

로컬 state의 한계

기본적으로 terraform apply를 실행하면 현재 디렉토리에 terraform.tfstate 파일이 생긴다. 이게 바로 로컬 state다.

my-infra/
├── main.tf
├── terraform.tfstate       # 현재 state
└── terraform.tfstate.backup # 직전 state 백업

혼자서 작은 프로젝트를 할 때는 이걸로 괜찮다. 하지만 팀이 둘 이상이 되는 순간부터 문제가 터진다.

로컬 state는 “혼자 쓰는 프로토타입”이거나 “튜토리얼용” 정도로만 쓰는 게 맞다. 팀으로 넘어가는 순간 원격 백엔드로 옮겨야 한다.

원격 백엔드 선택

Terraform은 여러 원격 백엔드를 지원한다. 각자 쓰는 클라우드에 맞춰 고르면 된다.

백엔드잠금 방식주로 쓰는 환경
s3 + DynamoDBDynamoDB 테이블AWS
gcs내장 객체 잠금GCP
azurermBlob leaseAzure
remote (Terraform Cloud)내장멀티 클라우드, 팀 협업
http구현에 따라GitLab, 자체 구축

가장 흔히 쓰이는 조합은 AWS의 s3 + DynamoDB다. S3에 state 파일을 저장하고, DynamoDB 테이블에 잠금(lock)을 거는 식이다.

sequenceDiagram
    participant A as 개발자 A
    participant B as 개발자 B
    participant DDB as DynamoDB\n(Lock)
    participant S3 as S3\n(State)

    A->>DDB: 잠금 획득 시도
    DDB-->>A: OK (Lock ID 발급)
    A->>S3: state 읽기
    B->>DDB: 잠금 획득 시도
    DDB-->>B: 이미 잠김 (대기)
    A->>S3: apply 후 state 쓰기
    A->>DDB: 잠금 해제
    DDB-->>B: OK (이제 사용 가능)
    B->>S3: state 읽기

한쪽이 작업 중일 때 다른 쪽은 자동으로 대기한다. 실수로 덮어쓰는 사고를 막는 안전장치다.

S3 + DynamoDB 백엔드 구성

AWS 환경이라면 이 조합이 사실상 표준이다. 순서대로 구성해보자.

먼저 state를 담을 S3 버킷과 잠금용 DynamoDB 테이블을 미리 만들어야 한다. 이 리소스들은 Terraform으로 관리하지 않고 수동으로 만드는 게 일반적이다. “state를 담을 공간”을 Terraform으로 만들면 닭과 달걀 문제가 생긴다.

# S3 버킷 생성
aws s3api create-bucket \
  --bucket my-company-tfstate \
  --region ap-northeast-2 \
  --create-bucket-configuration LocationConstraint=ap-northeast-2

# 버저닝 활성화 — state를 실수로 덮어써도 복구 가능
aws s3api put-bucket-versioning \
  --bucket my-company-tfstate \
  --versioning-configuration Status=Enabled

# 암호화 설정
aws s3api put-bucket-encryption \
  --bucket my-company-tfstate \
  --server-side-encryption-configuration '{
    "Rules": [{
      "ApplyServerSideEncryptionByDefault": {
        "SSEAlgorithm": "AES256"
      }
    }]
  }'

# DynamoDB 테이블 생성 (잠금용)
aws dynamodb create-table \
  --table-name my-company-tflock \
  --attribute-definitions AttributeName=LockID,AttributeType=S \
  --key-schema AttributeName=LockID,KeyType=HASH \
  --billing-mode PAY_PER_REQUEST

버저닝은 반드시 켠다. state가 손상되면 이전 버전으로 되돌릴 수 있다. 암호화도 필수다. state에는 DB 비밀번호 같은 민감 정보가 들어갈 수 있다.

이제 Terraform 코드에서 백엔드를 선언한다.

# main.tf
terraform {
  required_version = ">= 1.6.0"

  backend "s3" {
    bucket         = "my-company-tfstate"
    key            = "prod/vpc/terraform.tfstate"
    region         = "ap-northeast-2"
    dynamodb_table = "my-company-tflock"
    encrypt        = true
  }
}

key는 버킷 안에서의 경로다. 환경별, 서비스별로 경로를 나누면 관리가 편하다. prod/vpc/, prod/eks/, dev/vpc/ 같은 식으로.

처음 백엔드를 설정하거나 변경하면 terraform init을 실행해야 한다.

terraform init

# 기존 로컬 state가 있으면 원격으로 옮기겠냐고 물어본다
Initializing the backend...
Do you want to copy existing state to the new backend? (yes/no)

yes라고 답하면 로컬 state가 S3로 올라간다. 이후로는 모든 작업이 S3의 state를 기준으로 동작한다.

GCS와 Azure Blob 백엔드

GCP를 쓴다면 gcs 백엔드가 표준이다. S3와 비슷하지만 DynamoDB 같은 별도 잠금 테이블이 필요 없다. GCS가 객체 단위 잠금을 기본 지원하기 때문이다.

terraform {
  backend "gcs" {
    bucket = "my-company-tfstate"
    prefix = "prod/vpc"
  }
}

Azure는 azurerm 백엔드를 쓴다. Blob Storage의 lease 기능으로 잠금을 처리한다.

terraform {
  backend "azurerm" {
    resource_group_name  = "tfstate-rg"
    storage_account_name = "mycompanytfstate"
    container_name       = "tfstate"
    key                  = "prod/vpc/terraform.tfstate"
  }
}

어떤 백엔드를 쓰든 핵심 원칙은 같다. 버저닝 켜기, 암호화하기, 잠금 활성화하기, 접근 권한 최소화하기.

state 잠금

잠금은 terraform applyterraform plan 같이 state를 읽거나 쓰는 명령을 실행할 때 자동으로 걸린다. 명령이 끝나면 자동으로 풀린다. 개발자가 따로 명령을 내릴 일은 거의 없다.

그런데 가끔 사고가 터진다. apply 중에 Ctrl+C를 눌러서 프로세스를 강제로 죽이거나, 네트워크가 끊기거나, CI 파이프라인이 중단되면 잠금이 풀리지 않고 남는다. 이러면 다음 명령에서 이런 에러가 나온다.

Error: Error acquiring the state lock

Lock Info:
  ID:        abc123-def456
  Path:      my-company-tfstate/prod/vpc/terraform.tfstate
  Operation: OperationTypeApply
  Who:       kai@laptop
  Created:   2026-04-18 10:23:15 +0900 KST

이런 경우 누가 진짜 작업 중인지 먼저 팀에 확인한 후, 작업 중이 아니라면 잠금을 수동으로 푼다.

terraform force-unlock abc123-def456

Lock Info에 찍힌 ID를 써야 한다. force-unlock은 이름 그대로 강제로 푸는 것이라, 누군가 진짜 apply 중이었다면 state가 손상될 수 있다. 반드시 팀과 확인 후에 실행한다.

Drift — 현실과 state의 어긋남

Terraform으로 리소스를 만든 뒤, 누군가 AWS 콘솔에서 직접 설정을 바꿨다고 해보자. 이 경우 state 파일에 기록된 내용과 실제 클라우드의 상태가 어긋난다. 이걸 drift라고 부른다.

drift는 어떻게 감지할까.

terraform plan -refresh-only

-refresh-only 플래그는 실제 리소스 상태를 읽어와 state와 비교하기만 한다. 아무 변경도 적용하지 않는다.

Terraform will perform the following actions:

  # aws_security_group.web will be updated in-place
  ~ resource "aws_security_group" "web" {
      ~ ingress = [
          - {
              cidr_blocks = ["10.0.0.0/16"]
              from_port   = 22
              to_port     = 22
              protocol    = "tcp"
            },
          + {
              cidr_blocks = ["0.0.0.0/0"]  # 누군가 콘솔에서 열어버린 것
              from_port   = 22
              to_port     = 22
              protocol    = "tcp"
            },
        ]
    }

위 예시는 누군가 보안 그룹의 SSH 포트를 전 세계에 열어놓은 상황이다. 심각한 보안 사고로 이어질 수 있다.

drift가 감지되면 두 가지 선택지가 있다.

  1. Terraform 코드를 실제 상태에 맞춘다: 콘솔에서 의도적으로 바꾼 거라면 코드도 그대로 반영
  2. terraform apply로 코드 상태로 되돌린다: 의도하지 않은 변경이면 원래대로 복구

정기적으로(하루 한 번, 혹은 매주) -refresh-only로 drift를 감지하는 파이프라인을 CI에 붙이는 팀도 많다. 콘솔 조작을 원천 차단하지 못한다면 탐지라도 해야 한다.

terraform state 명령어

state를 직접 조작해야 할 때가 있다. 예컨대 리소스 이름을 바꾸거나, state에서 리소스를 제거하거나, 다른 state로 옮겨야 할 때다. 이럴 때 terraform state 서브커맨드를 쓴다.

flowchart TB
    state["terraform state"]
    state --> list["list\n(state 내 리소스 조회)"]
    state --> show["show\n(특정 리소스 상세)"]
    state --> mv["mv\n(리소스 이름 변경/이동)"]
    state --> rm["rm\n(state에서 제거)"]
    state --> pull["pull\n(state JSON 덤프)"]
    state --> push["push\n(state 덮어쓰기)"]

list — state에 뭐가 들었는지 본다

terraform state list
aws_s3_bucket.logs
aws_security_group.web
aws_instance.app[0]
aws_instance.app[1]
module.vpc.aws_vpc.main

어떤 리소스가 관리 중인지 한눈에 파악된다.

show — 특정 리소스의 state 상세를 본다

terraform state show aws_security_group.web
# aws_security_group.web:
resource "aws_security_group" "web" {
    id          = "sg-0abc123def456"
    name        = "web-sg"
    vpc_id      = "vpc-0123456789"
    ingress     = [...]
    ...
}

디버깅할 때 유용하다. 실제 state가 어떻게 되어 있는지 정확히 확인할 수 있다.

mv — 리소스 이름을 바꾸거나 모듈로 옮긴다

코드에서 리소스 이름을 바꾸면 Terraform은 기존 리소스를 삭제하고 새 이름으로 다시 만들려고 한다. 이건 절대 원하는 동작이 아니다. state mv로 state 안에서만 이름을 바꾸면, 실제 클라우드 리소스는 건드리지 않고 Terraform이 “아, 같은 리소스구나”라고 인식하게 할 수 있다.

# 리소스 이름 변경: aws_instance.web → aws_instance.app
terraform state mv aws_instance.web aws_instance.app

# 모듈로 옮기기: aws_vpc.main → module.network.aws_vpc.main
terraform state mv aws_vpc.main module.network.aws_vpc.main

리팩토링할 때 자주 쓰는 기능이다.

rm — state에서만 제거한다 (실제 리소스는 그대로)

실제 클라우드 리소스는 그대로 두면서 Terraform 관리에서만 빼고 싶을 때 쓴다. 수동 관리로 넘기거나, 다른 Terraform 프로젝트로 이관할 때 유용하다.

terraform state rm aws_instance.legacy

이 명령을 쓴 뒤에는 해당 리소스를 Terraform이 더 이상 모른다. 실수로 이 명령을 쓰고 apply를 치면, 해당 리소스를 새로 만들려고 시도할 수 있다. 사용 전에 terraform plan으로 반드시 영향을 확인한다.

import — 이미 있는 리소스를 state로 가져온다

콘솔에서 먼저 만든 리소스를 Terraform 관리로 가져올 때 쓴다.

# 먼저 HCL 코드를 작성한다
resource "aws_instance" "legacy" {
  # (속성은 빈 상태로 두거나 대략 적어둔다)
}

# 실제 리소스 ID를 넘긴다
terraform import aws_instance.legacy i-0abc123def456

import 이후에는 terraform plan으로 코드와 실제 상태의 차이를 확인하고, 코드를 실제에 맞춰 보완하면 된다. Terraform 1.5부터는 import 블록을 HCL에 선언해서 선언적으로 import하는 방식도 생겼다.

import {
  to = aws_instance.legacy
  id = "i-0abc123def456"
}

resource "aws_instance" "legacy" {
  # ...
}

state를 다루는 안전 수칙

마지막으로, state를 망치지 않기 위한 기본 원칙을 정리한다.

state를 잘 다루는 게 Terraform 운영의 절반이다. 팀과 함께 쓴다면 원격 백엔드는 선택이 아니라 필수다.


다음 편에서는 Terraform 모듈을 다룬다. 반복되는 인프라 패턴을 재사용 가능한 단위로 묶는 방법과, 공식 레지스트리를 활용하는 법을 살펴본다.

9편: 모듈


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
Terraform 7편 — 데이터 소스와 Import
Next Post
Terraform 9편 — 모듈