IaC IAC 01 손으로 만든 서버는 무엇을 잃나 — 명령형 스크립트와 선언형 코드
IaC · 1/5

IAC 01 손으로 만든 서버는 무엇을 잃나 — 명령형 스크립트와 선언형 코드

gabury1고친 사람 github-actions[bot]

실측은 전부 라즈베리파이 5 · Terraform v1.16.4 · hashicorp/aws 6.66.0 · aws-cli 2.35.15 에서 돌렸습니다. 진짜 AWS 대신 AWS API 와 똑같이 응답하는 모의 서버 moto 5.2.3.dev0 를 파이 안에 띄워 두고 거기에 요청을 보냈습니다(LocalStack 은 인증 토큰을 요구해서 moto 를 썼습니다). 출력은 실제로 찍힌 것을 그대로 붙였습니다. 길어서 자른 곳은 「…」로 표시했습니다.

1. 아무도 다시 만들 수 없는 서버

EC2 두 대로 도는 작은 서비스가 있다고 합시다. EC2(Elastic Compute Cloud)는 AWS(Amazon Web Services)가 빌려주는 가상 서버입니다. 3년 전에 누군가 AWS 웹 콘솔에서 인스턴스(가상 서버 한 대)를 띄웠습니다. 그 뒤로 여러 사람이 손을 댔습니다. 급한 장애 때 보안 그룹에 포트를 하나 열었습니다. 로그가 넘쳐서 디스크를 늘렸습니다. 누군가 SSH(Secure Shell, 원격 접속 도구)로 들어가 패키지를 몇 개 깔았습니다. 서버는 오늘도 잘 돕니다.

그러다 이런 요청이 옵니다. 「같은 걸 하나 더 만들어 봐. 스테이징으로 쓰게.」

이때 알게 됩니다. 이 서버가 정확히 어떤 모양인지 적힌 곳이 없습니다. 콘솔을 한 칸씩 열어 보며 베껴야 합니다. 3년 동안 누가 무엇을 왜 바꿨는지는 콘솔에도 안 나옵니다. 연 포트 가운데 지금도 필요한 것이 어느 것인지 아무도 확신하지 못합니다.

이렇게 손으로 고쳐 가며 키우다가 다시 만들 수 없게 된 서버를 스노우플레이크 서버(snowflake server)라 부릅니다. 눈송이는 하나하나 모양이 다르고 똑같은 것을 다시 만들 수 없다는 데서 온 말입니다. 서버가 망가지면 문제입니다. 멀쩡해도 「하나 더」가 안 되니 역시 문제입니다.

잃은 것은 서버가 아니라 서버가 어떤 모양이어야 하는지에 대한 기록입니다. 이 기록을 사람 머릿속이나 콘솔 화면이 아니라 파일로 남기고, 그 파일로 인프라를 만드는 방식이 코드형 인프라(Infrastructure as Code, IaC)입니다. 여기서 인프라는 서버·네트워크 규칙·저장소처럼 애플리케이션이 올라앉는 바탕을 말합니다.

「파일로 적는다」에도 두 갈래가 있습니다. 이 편은 두 갈래로 같은 것을 만들어 보고, 각각을 두 번 돌려 봅니다.

해 본 것 결과
손으로 하던 순서를 옮긴 셸 스크립트를 두 번 에러 세 줄, 그런데 종료 코드 0
「있으면 찾아 쓰기」로 고친 스크립트를 두 번 인스턴스가 1 → 2 → 3 대
테라폼(인프라를 코드로 적으면 그대로 만들어 주는 도구)으로 두 번 두 번째는 바꾼 것 0
같은 코드로 스테이징 한 벌 명령 2개, 코드 수정 0줄, 약 24.5초

표에 없는 대가도 하나 있습니다. 만드는 시간은 스크립트가 다섯 배 빨랐습니다. 그 까닭은 7절에서 봅니다.

2. 첫 번째 방법 — 손으로 하던 순서를 스크립트로

만들 것 세 가지

이 편의 실습 대상은 세 자원입니다. 시리즈 끝에 코드로 옮길 작은 서비스(EC2 두 대·보안 그룹·S3 버킷 등)를 줄인 모양입니다. EC2 가 처음이면 AWS 01-1 서버를 통째로 빌린다 — EC2·Lightsail·Beanstalk·CloudShell을 먼저 보셔도 됩니다.

자원 무엇인가 이름
보안 그룹 인스턴스 앞에 서는 방화벽 규칙 묶음. 여기서는 22번 포트(SSH)만 엽니다 demo-sg-prod
EC2 인스턴스 AWS 에서 빌리는 가상 서버 한 대. 이름은 태그(자원에 붙이는 키-값 이름표)로 답니다 태그 Name=demo-app-prod
S3 버킷 S3(Simple Storage Service)는 파일을 넣어 두는 저장소입니다. 로그를 모을 곳입니다 demo-logs-prod

명령형 — 무엇을 할지 순서대로

콘솔에서 하던 일을 순서 그대로 명령으로 옮기면 이렇게 됩니다. 보안 그룹을 만들고, 규칙을 넣고, 인스턴스를 띄우고, 버킷을 만듭니다. 이처럼 무엇을 할지를 순서대로 적는 방식을 명령형(imperative)이라 합니다. 요리책의 「양파를 썬다 → 볶는다 → 물을 붓는다」와 같은 꼴입니다.

명령은 aws CLI(Command Line Interface, AWS 를 부르는 명령줄 도구)로 보냅니다. aws CLI 는 AWS API(프로그램이 AWS 에 일을 시키는 창구)를 대신 불러 줍니다.

터미널
#!/bin/bash
set -u
ENV=${1:-prod}
AMI=ami-0f47531f8c49bd1c6

SG_ID=$(aws ec2 create-security-group \
  --group-name demo-sg-$ENV \
  --description "demo app sg" \
  --query GroupId --output text)
echo "SG_ID=$SG_ID"
aws ec2 authorize-security-group-ingress \
  --group-id "$SG_ID" \
  --protocol tcp --port 22 --cidr 0.0.0.0/0 \
  >/dev/null && echo "ingress 22 ok"
IID=$(aws ec2 run-instances \
  --image-id $AMI --instance-type t4g.small \
  --security-group-ids "$SG_ID" \
  --tag-specifications \
    "ResourceType=instance,Tags=[{Key=Name,Value=demo-app-$ENV}]" \
  --query 'Instances[0].InstanceId' --output text)
echo "INSTANCE=$IID"
aws s3api create-bucket --bucket demo-logs-$ENV \
  >/dev/null && echo "bucket demo-logs-$ENV ok"
  • set -u — 정의 안 된 변수를 쓰면 멈추게 하는 옵션입니다. 명령 하나가 실패하면 거기서 멈추게 하는 set -e 는 켜지 않았습니다. 셸 스크립트에서 흔히 보는 모양입니다
  • ${1:-prod} — 첫 인자가 없으면 prod 를 씁니다. 나중에 ./create.sh stga 처럼 환경 이름을 넘깁니다
  • $(...) — 안쪽 명령의 출력을 문자열로 받아 변수에 넣습니다
  • --query GroupId --output text — aws CLI 응답(JSON 형식)에서 GroupId 값 하나만 뽑아 글자로 내놓게 합니다
  • --cidr 0.0.0.0/0 — 어디서 오는 요청이든 모두 허용한다는 뜻입니다
  • ami-... — AMI(Amazon Machine Image)는 인스턴스를 띄울 때 쓰는 디스크 원본입니다. 여기서는 moto 가 싣고 있는 arm64 이미지를 씁니다

한 번 돌리면 잘 됩니다. 궁금한 것은 두 번째입니다. 중간에 끊겨서든 동료가 모르고서든, 스크립트는 언젠가 반드시 두 번 돌게 됩니다.

두 번 돌리면

count.sh 는 demo- 로 시작하는 보안 그룹·running 인스턴스·버킷 수를 세는 스크립트입니다. 아래 명령은 전부 moto 를 가리키는 가짜 자격 증명(AWS_ACCESS_KEY_ID=test 등)과 AWS_ENDPOINT_URL=http://127.0.0.1:4566 을 환경 변수로 깔고 돌렸습니다. 출력의 === 1회차 같은 머리줄은 회차를 가르려고 명령 사이에 넣은 echo 가 찍은 것입니다. 명령 블록에서는 그 echo 를 생략했습니다.

실측 — 명령형 스크립트를 두 번 (2026-09-30, 라즈베리파이 5, aws-cli 2.35.15 + moto)

터미널
./create.sh; echo "exit=$?"; ./count.sh
./create.sh; echo "exit=$?"; ./count.sh
=== 1회차
SG_ID=sg-2f674c3d50a15f16c
ingress 22 ok
INSTANCE=i-8b2111a4c1ca140f6
bucket demo-logs-prod ok
exit=0
SG(demo-sg-*):   1
인스턴스(running, Name=demo-app-*): 1
버킷(demo-logs-*): 1
=== 2회차

aws: [ERROR]: An error occurred (InvalidGroup.Duplicate) when calling the CreateSecurityGroup operation: The security group 'demo-sg-prod' already exists
SG_ID=

aws: [ERROR]: An error occurred (InvalidGroup.NotFound) when calling the AuthorizeSecurityGroupIngress operation: The security group '' does not exist

aws: [ERROR]: An error occurred (InvalidGroup.NotFound) when calling the RunInstances operation: The security group '' does not exist
INSTANCE=
bucket demo-logs-prod ok
exit=0
SG(demo-sg-*):   1
인스턴스(running, Name=demo-app-*): 1
버킷(demo-logs-*): 1

2회차의 첫 명령이 실패했습니다. 보안 그룹 이름은 같은 VPC(Virtual Private Cloud, AWS 안에 갈라 둔 내 사설 네트워크) 안에서 겹칠 수 없어서입니다. 실패한 명령은 아무것도 출력하지 않았으니 SG_ID 는 빈 문자열이 됐습니다.

그 빈 값이 뒤로 번졌습니다. 규칙 추가도, 인스턴스 생성도 「'' 라는 보안 그룹은 없다」며 넘어졌습니다.

종료 코드는 exit=0, 성공입니다. 셸 스크립트의 종료 코드는 마지막 명령의 것입니다. 마지막 줄인 버킷 생성이 성공했습니다. 이 스크립트를 자동으로 돌리는 쪽은 에러 세 줄을 보지 못하고 「성공」만 받습니다.

버킷이 두 번째에도 「ok」인 것은 us-east-1 리전(AWS 데이터센터 지역)에서 내 버킷을 같은 이름으로 다시 만들면 성공 응답이 오기 때문입니다.

결과적으로 자원 수는 1·1·1 그대로입니다. 중복은 안 생겼습니다. 다만 그것은 스크립트가 잘 짜여서가 아니라 보안 그룹 이름 규칙에 우연히 걸려서입니다.

3. 에러를 없애면 이번엔 서버가 늘어난다

흔한 수리 — 있으면 찾아 쓰고 없으면 만든다

2절을 본 사람이 먼저 하는 수리는 이것입니다. 보안 그룹을 이름으로 먼저 찾고, 없을 때만 만듭니다. 이 편에서는 이것을 고친 스크립트(create-v2.sh)라 부릅니다. 바뀐 곳은 보안 그룹 부분뿐입니다.

터미널
SG_ID=$(aws ec2 describe-security-groups \
  --filters Name=group-name,Values=demo-sg-$ENV \
  --query 'SecurityGroups[0].GroupId' --output text)
if [ "$SG_ID" = "None" ]; then
  SG_ID=$(aws ec2 create-security-group ...)
  aws ec2 authorize-security-group-ingress ...
fi
# 인스턴스·버킷 부분은 그대로
  • describe-security-groups --filters — 조건에 맞는 보안 그룹을 조회합니다. 없으면 --query 결과가 None 이라는 글자로 나옵니다

고친 스크립트를 2절 직후(보안 그룹 1·인스턴스 1·버킷 1 이 있는 상태)에서 두 번 돌렸습니다. 마지막 describe-instances 는 이름표가 demo-app-prod 이고 running 인 인스턴스의 ID 와 이름을 뽑는 조회입니다. --query 안의 식은 JSON 에서 값을 골라내는 aws CLI 문법(JMESPath)이라 읽지 않고 넘어가도 됩니다. 출력 머리줄의 「땜질판」은 고친 스크립트를 가리킵니다.

실측 — 고친 스크립트를 두 번 (2026-09-30, 라즈베리파이 5, aws-cli 2.35.15 + moto)

터미널
./create-v2.sh; ./count.sh
./create-v2.sh; ./count.sh
aws ec2 describe-instances \
  --filters Name=tag:Name,Values=demo-app-prod \
            Name=instance-state-name,Values=running \
  --query 'Reservations[].Instances[]
    .[InstanceId,Tags[?Key==`Name`]|[0].Value]' \
  --output text
=== 땜질판 1회차
SG_ID=sg-2f674c3d50a15f16c
INSTANCE=i-a8eaddfdd0382ca34
bucket demo-logs-prod ok
SG(demo-sg-*):   1
인스턴스(running, Name=demo-app-*): 2
버킷(demo-logs-*): 1
=== 땜질판 2회차
SG_ID=sg-2f674c3d50a15f16c
INSTANCE=i-3b7ad05134519b4d6
bucket demo-logs-prod ok
SG(demo-sg-*):   1
인스턴스(running, Name=demo-app-*): 3
버킷(demo-logs-*): 1
i-8b2111a4c1ca140f6	demo-app-prod
i-a8eaddfdd0382ca34	demo-app-prod
i-3b7ad05134519b4d6	demo-app-prod

에러는 사라졌습니다. 보안 그룹은 계속 같은 sg-2f674c3d50a15f16c 를 재사용했습니다. 대신 인스턴스는 돌릴 때마다 하나씩 늘어 1 → 2 → 3 대가 됐습니다. 마지막 조회에는 demo-app-prod 라는 이름표를 단 서로 다른 인스턴스 셋이 동시에 running 입니다.

인스턴스에는 이름이 겹치면 안 된다는 규칙이 없습니다. Name 은 그냥 태그라서 run-instances 는 몇 번이든 성공합니다. 보안 그룹에서 에러를 막아 주던 규칙이 인스턴스에는 없었던 것입니다. 실제 AWS 라면 요금도 세 대분이 나갑니다.

스크립트에 빠진 성질 — 멱등성

같은 일을 여러 번 해도 한 번 한 것과 결과가 같은 성질을 멱등성(idempotency)이라 합니다. 엘리베이터 호출 버튼이 그렇습니다. 한 번 누르든 열 번 누르든 엘리베이터는 한 대 옵니다. 여기서 결과에는 남은 자원뿐 아니라 실행이 성공했는지도 들어갑니다.

두 스크립트는 둘 다 멱등이 아니었습니다. 원래 스크립트는 자원 수가 그대로였지만 두 번째에 에러를 냈습니다. 그 자원 수도 설계가 아니라 이름 규칙이 우연히 지켜 준 것입니다. 고친 스크립트는 두 번째에 서버를 하나 더 만들었습니다.

고친 스크립트를 멱등으로 만들려면 인스턴스·버킷·규칙마다 「이미 있나」를 검사하는 코드를 따로 짜야 합니다. 그 검사 기준(태그로 찾을지, 이름으로 찾을지, 규칙 내용까지 비교할지)도 사람이 자원마다 정해야 합니다.

명령형 스크립트의 한계가 여기서 보입니다. 스크립트는 할 일을 적었을 뿐 있어야 할 모양을 적지 않았습니다. 그래서 지금 무엇이 있는지와 상관없이 적힌 일을 또 합니다.

4. 두 번째 방법 — 무엇이 있어야 하는지를 적는다

선언형이란

명령형의 반대편이 선언형(declarative)입니다. 순서를 적는 대신 무엇이 있어야 하는지, 즉 최종 모양만 적습니다. 그 모양에 이르는 순서는 도구가 정합니다. 이렇게 적은 설정을 선언적 설정이라 합니다.

택시에 비유하면 명령형은 「두 번째 골목에서 좌회전, 300미터 직진」이고 선언형은 「시청역이요」입니다. 목적지만 말하면 길은 기사가 고릅니다. 그리고 이미 시청역 앞에 서 있다면 기사는 아무 데도 가지 않습니다. 선언형이 멱등성을 따로 짜지 않고 얻는 이유가 이 마지막 문장에 있습니다.

테라폼과 HCL

테라폼(Terraform)은 HashiCorp 가 만든 코드형 인프라 도구입니다. 공식 문서의 설명은 이렇습니다. "Terraform configuration files are declarative, meaning that they describe the end state of your infrastructure."(테라폼 설정 파일은 선언형이다. 즉 인프라의 최종 상태를 기술한다.)

설정 파일은 .tf 확장자를 쓰고 HCL(HashiCorp Configuration Language)이라는 언어로 적습니다. HCL 의 기본 단위는 블록입니다. 블록은 세 부분으로 됩니다.

부분 무엇 예
타입 이 블록이 무엇인지 resource
레이블 타입 뒤 따옴표 값들. 개수는 타입마다 정해져 있습니다 "aws_instance" "app"
본문 { } 안. 이름 = 값 꼴의 인수와 안쪽 블록 ami = "..."

본문 안에서 이름 = { ... } 처럼 = 가 붙으면 값(키-값 묶음)이고, 이름 { ... } 처럼 = 가 없으면 안쪽 블록입니다.

프로바이더 — AWS 와 말하는 플러그인

테라폼 본체는 AWS 를 모릅니다. AWS API 를 부르는 일은 프로바이더(provider)가 합니다. 공식 문서는 프로바이더를 "plugins that offer a collection of resource types"(자원 종류 묶음을 제공하는 플러그인)라고 정의합니다. AWS 용, 구글 클라우드용, 깃허브용 프로바이더가 따로 있습니다.

어떤 프로바이더를 쓸지는 terraform 블록에 적습니다. terraform init 이 이 블록을 읽고 프로바이더를 내려받습니다.

hcl
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
}
  • source — 프로바이더를 받아 올 주소입니다. hashicorp/aws 는 공식 레지스트리의 AWS 프로바이더입니다
  • ~> 6.0 — 6.x 가운데 최신을 받되 7.0 으로는 넘어가지 않습니다. 이번에는 6.66.0 이 받아졌습니다

프로바이더에 넘길 설정은 provider "aws" 블록에 적습니다. 이 실습에서는 가짜 키 test 와 moto 주소를 넣어 진짜 AWS 로 요청이 나가지 않게 했습니다.

hcl
provider "aws" {
  region     = "us-east-1"
  access_key = "test"
  secret_key = "test"
  # … 자격 증명 확인을 건너뛰는 옵션 몇 줄 생략
  endpoints {
    ec2 = "http://127.0.0.1:4566"
    s3  = "http://127.0.0.1:4566"
    # … 나머지 서비스 주소 생략
  }
}

리소스 — 있어야 할 것 하나

리소스(resource)는 테라폼으로 만들고 관리할 인프라 객체 하나입니다. 2절의 세 자원을 리소스 블록 셋으로 적으면 이렇습니다.

hcl
variable "env" {
  type    = string
  default = "prod"
}

resource "aws_security_group" "app" {
  name        = "demo-sg-${var.env}"
  description = "demo app sg"

  ingress {
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

resource "aws_instance" "app" {
  ami           = "ami-0f47531f8c49bd1c6"
  instance_type = "t4g.small"
  # ↓ 보안 그룹의 ID 를 가리킨다
  vpc_security_group_ids = [aws_security_group.app.id]
  tags = {
    Name = "demo-app-${var.env}"
  }
}

resource "aws_s3_bucket" "logs" {
  bucket = "demo-logs-${var.env}"
}
  • resource "aws_instance" "app" — 첫 레이블 aws_instance 는 프로바이더가 제공하는 자원 종류, 둘째 app 은 이 코드 안에서 부르는 이름입니다. 둘을 이어 aws_instance.app 으로 가리킵니다
  • variable "env" — 바깥에서 바꿔 넣을 수 있는 입력값입니다. 안 넘기면 prod 입니다. -var env=staging 처럼 실행할 때 바꿉니다
  • "demo-sg-${var.env}" — 문자열 안에 변수 값을 끼워 넣습니다. env 가 prod 면 demo-sg-prod 입니다
  • ingress { ... } — 블록 안의 블록입니다. 들어오는 트래픽 규칙 하나를 적습니다. cidr_blocks = ["0.0.0.0/0"] 은 어디서 오든 모두 허용한다는 뜻입니다
  • aws_security_group.app.id — 위 보안 그룹이 만들어진 뒤에 생기는 ID 를 가리킵니다

스크립트와 비교하면 순서가 빠졌습니다. 「보안 그룹 먼저, 인스턴스 다음」이라는 말이 어디에도 없습니다. 대신 인스턴스가 aws_security_group.app.id 를 가리킵니다. 테라폼은 이런 참조를 보고 보안 그룹을 먼저 만들어야 한다고 알아냅니다. 서로 참조가 없는 버킷은 보안 그룹과 동시에 만들어도 됩니다. 곧 실측 출력에서 그 순서가 그대로 보입니다.

5. apply 를 두 번 돌리면

plan 과 apply, 그리고 상태 파일

테라폼으로 인프라를 바꾸는 명령은 둘입니다. 여기서는 맛만 보고 자세한 것은 IAC 02 편에서 다룹니다.

명령 하는 일
terraform plan 코드와 지금 인프라를 비교해 무엇을 만들고·바꾸고·지울지 계획을 보여 줍니다. 아무것도 바꾸지 않습니다
terraform apply 계획을 세워 보여 주고, 승인하면 그대로 실행합니다. -auto-approve 는 승인 질문을 건너뜁니다

「지금 인프라」를 알려면 테라폼이 자기가 무엇을 만들었는지 기억해야 합니다. 그 기억을 적는 파일이 상태 파일(state file, 기본 이름 terraform.tfstate)입니다. 코드의 aws_instance.app 이 AWS 의 어느 인스턴스 ID 인지가 여기 적힙니다. 상태 파일을 지우면 어떻게 되는지는 IAC 02 편의 주제입니다.

flowchart TD
    A["코드 읽기<br/>있어야 할 모양"] --> B["상태 파일 + 실제 자원 조회<br/>지금 있는 모양"]
    B --> C{"차이가 있나"}
    C -->|있다| D["차이만큼만 만들기·바꾸기·지우기"]
    C -->|없다| E["No changes"]
    D --> F["상태 파일 갱신"]

1회차 — 3개 추가

빈 moto 에서 시작했습니다.

실측 — terraform apply 1회차 (2026-09-30, 라즈베리파이 5, Terraform 1.16.4 + moto)

터미널
terraform init -no-color
terraform apply -auto-approve -no-color
…(중략)
      + name                   = "demo-sg-prod"
      …
      + vpc_id                 = (known after apply)
    }

Plan: 3 to add, 0 to change, 0 to destroy.
aws_security_group.app: Creating...
aws_s3_bucket.logs: Creating...
aws_s3_bucket.logs: Creation complete after 1s [id=demo-logs-prod]
aws_security_group.app: Creation complete after 1s [id=sg-b760458a0c2fce527]
aws_instance.app: Creating...
aws_instance.app: Still creating... [00m10s elapsed]
aws_instance.app: Creation complete after 10s [id=i-39bc6b058f924d98f]

Apply complete! Resources: 3 added, 0 changed, 0 destroyed.

위쪽은 계획입니다. 자른 계획 본문에는 자원 셋이 만들 속성과 함께 차례로 나열됩니다. + 는 새로 만든다는 표시이고 (known after apply) 는 만들어 봐야 알 수 있는 값(AWS 가 정해 주는 ID 같은 것)입니다. Plan: 3 to add 가 계획의 요약입니다. 이 편에는 + 만 나옵니다. 바꾸기(~)와 지우기(-)는 IAC 02 편에서 봅니다.

아래쪽 순서를 보면 4절에서 말한 것이 그대로 나옵니다. 보안 그룹과 버킷이 동시에 Creating... 에 들어갔습니다. 인스턴스는 보안 그룹이 끝난 뒤에야 시작했습니다. 순서를 적지 않았는데 참조를 보고 순서가 정해졌습니다.

2회차 — 변경 0

코드도 moto 도 그대로 두고 한 번 더 돌렸습니다.

실측 — terraform apply 2회차 (2026-09-30, 라즈베리파이 5, Terraform 1.16.4 + moto)

터미널
time terraform apply -auto-approve -no-color; ./count.sh
=== apply 2회차
…
aws_instance.app: Refreshing state... [id=i-39bc6b058f924d98f]

No changes. Your infrastructure matches the configuration.

Terraform has compared your real infrastructure against your configuration
and found no differences, so no changes are needed.

Apply complete! Resources: 0 added, 0 changed, 0 destroyed.

real	0m17.248s
SG(demo-sg-*):   1
인스턴스(running, Name=demo-app-*): 1
버킷(demo-logs-*): 1

결과는 0 added, 0 changed, 0 destroyed 입니다. 자원 수는 1·1·1 그대로입니다. 두 번째 apply 가 한 일은 지금 상태를 다시 읽고(Refreshing state), 코드와 비교하고, 차이가 없다고 말한 것뿐입니다. 택시가 이미 시청역 앞에 서 있었던 것입니다.

그 비교도 공짜는 아닙니다. 아무것도 만들지 않았는데 파이에서 17.248초가 걸렸습니다. 이 17.248초는 7절에서 다시 씁니다.

6. 「하나 더」 — 스테이징 한 벌

이제 1절의 요청으로 돌아갑니다. 같은 걸 하나 더, 스테이징으로 쓸 한 벌을 만듭니다.

운영 자원과 스테이징 자원을 한 상태 파일에 섞으면 곤란합니다. 스테이징 코드를 적용하는 순간 테라폼이 운영 인스턴스를 「코드와 다르다」며 바꾸려 들기 때문입니다. 그래서 워크스페이스(workspace)를 하나 새로 팠습니다. 워크스페이스는 같은 설정 코드에 상태 파일을 여러 벌 달아 두는 기능입니다. 처음에는 default 하나가 있습니다.

코드는 한 글자도 고치지 않았습니다. 변수 env 만 staging 으로 넘겼습니다. 시간은 세 번 쟀고, 회차 사이마다 스테이징을 지웠다(destroy) 다시 만들었습니다. 아래 출력은 1회차만 옮겼습니다.

실측 — 같은 코드로 스테이징 한 벌 (2026-09-30, 라즈베리파이 5, Terraform 1.16.4 + moto)

터미널
terraform workspace new staging -no-color
terraform apply -auto-approve -no-color -var env=staging
Created and switched to workspace "staging"!

You're now on a new, empty workspace. Workspaces isolate their state,
so if you run "terraform plan" Terraform will not see any existing state
for this configuration.
…
aws_s3_bucket.logs: Creation complete after 0s [id=demo-logs-staging]
aws_security_group.app: Creation complete after 0s [id=sg-c3f4eec3316166de1]
aws_instance.app: Creation complete after 10s [id=i-68f424308b3694325]
Apply complete! Resources: 3 added, 0 changed, 0 destroyed.

적용 뒤 count.sh 로 세면 보안 그룹·인스턴스·버킷이 두 개씩입니다. 운영 prod 한 벌에 스테이징 한 벌이 더해진 것입니다.

스테이징 한 벌에 든 것 값
명령 2개
코드 수정 0줄
시간(3회) 24.5 · 24.7 · 24.4초

1절의 스노우플레이크 서버에서 「하나 더」가 안 됐던 것은 모양을 적어 둔 곳이 없어서였습니다. 여기서는 그 모양이 main.tf 에 있습니다. 환경 이름만 바꿔 한 번 더 읽히면 됩니다.

1절이 잃었다고 한 「누가 무엇을 왜 바꿨나」는 .tf 파일을 git 에 두면 커밋 기록이 알려 줍니다. 이미 손으로 만든 자원을 코드로 가져오는 길은 IAC 03 「이미 있는 것을 코드로 가져오기 — import」에서 다룹니다.

워크스페이스로 환경을 가르는 데는 한계가 있습니다. 공식 문서는 자격 증명과 접근 권한을 따로 둬야 하는 환경을 가르는 데는 워크스페이스가 알맞지 않다고 적습니다. 이 편에서는 그 대안을 재지 않았습니다.

7. 테라폼이 다섯 배 느린 까닭

같은 한 벌을 명령형 스크립트로 만드는 시간도 쟀습니다. 이름이 겹치지 않게 환경 이름을 stga·stgb·stgc 로 바꿔 가며 돌렸습니다.

실측 — 명령형 스크립트로 한 벌 만드는 시간 (2026-09-30, 라즈베리파이 5, aws-cli 2.35.15 + moto)

터미널
for n in a b c; do
  s=$(date +%s.%N)
  ./create.sh stg$n >/dev/null 2>&1
  e=$(date +%s.%N)
  echo "명령형 stg$n wall=$(echo "$e - $s" | bc)s"
done
명령형 stga wall=4.570263817s
명령형 stgb wall=4.622518373s
명령형 stgc wall=4.796573141s

스크립트는 약 4.7초, 테라폼은 약 24.5초입니다. 만드는 속도만 보면 스크립트가 다섯 배 빠릅니다.

두 숫자는 같은 일을 잰 것이 아닙니다. 테라폼 출력의 aws_instance.app: Creation complete after 10s 를 보면 인스턴스 하나에 10초를 썼습니다. aws_instance 리소스는 생성 요청을 보낸 뒤 10초를 기다렸다가 상태를 확인하기 시작합니다. 그리고 인스턴스가 running 이 되어야 완료를 알립니다.

이 동작은 레지스트리 문서에는 없고 프로바이더 소스에 있습니다. internal/service/ec2/wait.go 의 waitInstanceCreated 가 Target: running, Delay: 10 * time.Second 로 기다립니다. moto 는 인스턴스를 사실상 즉시 running 으로 만듭니다. 그러니 이 10초는 moto 가 느려서 생긴 대기가 아니라 프로바이더에 고정된 첫 확인 지연입니다. 인스턴스가 즉시 떠도 10초는 걸리는 최소 대기입니다.

스크립트의 run-instances 는 「띄우라는 요청을 받았다」는 응답만 받고 바로 다음 줄로 넘어갑니다. 인스턴스가 실제로 떴는지는 확인하지 않습니다. 뒤에 SSH 접속 같은 일을 이어 붙이려면 aws ec2 wait instance-running 한 줄을 더 넣으면 됩니다. 다만 그런 대기를 자원마다 사람이 챙겨야 합니다.

대기 10초를 빼도 차이는 남습니다. 24.5초에서 10초를 빼면 14.5초이고, 스크립트 약 4.7초의 세 배쯤입니다. 테라폼 본체와 프로바이더를 띄우고 계획을 세우는 일은 스크립트에 없는 몫입니다. 어디에 몇 초가 들었는지는 나눠 재지 않았습니다.

5절의 17.248초는 이 14.5초와 다른 일을 잰 것입니다. 5절 2회차는 이미 있는 자원 셋을 새로 읽고 코드와 비교했습니다. 6절 스테이징은 빈 워크스페이스라 새로 읽을 기존 자원이 없었습니다. 그래서 한쪽 숫자로 다른 쪽을 설명하지 않았습니다.

이 시간 숫자에는 한계가 둘 있습니다. 첫째, moto 는 인스턴스를 즉시 running 으로 만듭니다. 진짜 AWS 에서는 테라폼의 대기가 10초보다 길어질 수 있습니다(재지 않았습니다). 스크립트도 뒤이을 일이 있다면 그만큼 기다려야 합니다. 그러니 이 숫자는 「파이 + moto」 안에서의 상대 비교로만 읽어야 합니다. 둘째, 파이는 다른 서비스가 같이 도는 기계라 절대 시간은 흔들릴 수 있습니다. 세 번 잰 값의 범위는 테라폼 0.31초, 스크립트 0.23초였습니다.

정리하면 테라폼이 얻은 것은 속도가 아닙니다. 두 번 돌려도 같은 결과가 나오는 것, 끝났다고 말할 때 정말 떠 있는 것, 그리고 모양이 파일에 남는 것입니다. 그 대가로 매번 상태를 읽고 비교하는 시간을 씁니다. 아무것도 만들지 않은 5절의 apply 에 든 17.248초가 그 대가를 그대로 보여 줍니다.

한 장 요약

콘솔로 고쳐 가며 키운 서버는 멀쩡히 돌아도 다시 만들 수 없습니다. 잃은 것은 서버가 어떤 모양이어야 하는지의 기록입니다. 그 기록을 파일로 남겨 인프라를 만드는 방식이 코드형 인프라입니다(1절).

손으로 하던 순서를 옮긴 명령형 스크립트는 두 번째에 에러 세 줄을 내고도 종료 코드 0으로 끝났습니다. 에러를 피하게 고친 스크립트는 돌릴 때마다 인스턴스를 한 대씩 늘렸습니다. 스크립트는 할 일만 적고 있어야 할 모양은 적지 않아 멱등하지 않습니다(2·3절).

테라폼은 HCL 로 있어야 할 모양만 적습니다. apply 는 코드와 상태 파일·실제 자원을 비교해 차이만 실행하므로 두 번째 apply 는 변경 0이었습니다. 같은 코드에 변수만 바꿔 스테이징 한 벌이 명령 2개·코드 수정 0줄·약 24.5초에 나왔습니다(4~6절).

만드는 시간은 스크립트 약 4.7초 대 테라폼 약 24.5초였습니다. 테라폼 쪽에는 인스턴스가 running 이 되는지 확인하는 고정 대기 10초와 상태를 읽고 비교하는 몫이 들어 있습니다. 모의 서버(moto) 위의 상대 비교라 진짜 AWS 의 시간은 재지 않았습니다(7절).

관련 항목

코드형 인프라를 이루는 개념

코드형 인프라 · 선언적 설정 · 멱등성 · 상태 파일 · 구성 드리프트 · 불변 인프라

코드형 인프라가 막으려는 것

스노우플레이크 서버 · 구성 드리프트 · 수동 변경 · 환경 불일치

테라폼을 이루는 부품

테라폼 · HCL · 프로바이더 · 리소스 · 테라폼 변수 · 테라폼 워크스페이스 · terraform plan · terraform apply · terraform init

테라폼 옆의 도구

CloudFormation · 앤서블 · OpenTofu · Pulumi · AWS CDK

이 편에서 코드로 옮긴 AWS 자원

Amazon EC2 · 보안 그룹 · Amazon S3 · 머신 이미지 · VPC

명령형 방식에 쓴 도구

AWS CLI · 셸 스크립트 · 종료 코드 · set -e · JMESPath

실습에 쓴 모의 환경

moto · LocalStack · 라즈베리파이

IaC 시리즈의 다른 편

IAC 02 plan 이 말해 주는 것 — 제자리 변경·교체·상태 파일 · IAC 03 이미 있는 것을 코드로 가져오기 — import · IAC 04 누가 콘솔에서 손대면 — 드리프트와 테라폼이 못 보는 것 · IAC 05 둘이 같이 apply 하면 — 원격 상태와 잠금