Table of contents
- 왜 인프라에도 테스트가 필요한가
- 가장 먼저 — fmt와 validate
- 정적 보안 분석 — Checkov와 tfsec
- 정책 검증 — OPA
- 통합 테스트 — Terratest
- terraform test — 내장 테스트 프레임워크
- pre-commit 훅 — 로컬에서 먼저 잡기
- tflint — 린터
- 전체 파이프라인 구성
- 도구 선택 가이드
왜 인프라에도 테스트가 필요한가
애플리케이션 코드에는 당연히 테스트를 쓴다. 그런데 인프라 코드에는 왜 잘 안 쓸까. “배포해보면 알지”라는 감각이 남아 있어서다. 하지만 Terraform 코드도 버그가 있고, 보안 허점이 있고, 정책 위반이 있다.
- 보안 그룹이 0.0.0.0/0으로 SSH를 연다
- S3 버킷이 퍼블릭 읽기로 설정된다
- IAM 정책이
*:*를 허용한다 - 태그가 누락된다
이런 문제들이 apply되기 전에 잡혀야 한다. 한 번 배포된 뒤 발견하면 이미 늦다.
flowchart LR
Code["HCL 코드"] --> L1["단계 1:\nfmt / validate"]
L1 --> L2["단계 2:\n정적 보안 분석\n(Checkov, tfsec)"]
L2 --> L3["단계 3:\n정책 검증\n(OPA, Sentinel)"]
L3 --> L4["단계 4:\n통합 테스트\n(Terratest)"]
L4 --> Apply["apply 단계"]
비용과 속도가 다른 단계들이다. 왼쪽으로 갈수록 빠르고 싸지만 잡을 수 있는 범위가 좁고, 오른쪽으로 갈수록 느리고 비싸지만 실제 동작을 검증한다.
가장 먼저 — fmt와 validate
Terraform이 기본으로 제공하는 가장 빠른 검사다. 로컬에서도, CI에서도 항상 실행하는 게 좋다.
terraform fmt — 코드 스타일 정렬
# 전체 파일 자동 정렬
terraform fmt -recursive
# CI에서 "정렬 안 된 파일이 있는지만" 확인
terraform fmt -check -recursive
팀 전체가 같은 스타일을 쓰게 만드는 도구다. 들여쓰기, 공백, 속성 정렬을 자동으로 맞춰준다. -check는 정렬이 안 된 파일이 있으면 0이 아닌 exit code를 반환해서 CI를 실패시킨다.
terraform validate — 문법 검증
terraform validate
HCL 문법이 맞는지, 참조하는 변수나 리소스가 존재하는지, 타입이 맞는지 등을 검사한다. terraform init 이후에 실행해야 프로바이더 스키마를 알고 검증할 수 있다.
Success! The configuration is valid.
Error: Reference to undeclared input variable
on main.tf line 5, in resource "aws_instance" "app":
5: instance_type = var.size
이 정도는 다 잡아주진 않는다. 오타나 논리 오류는 놓친다. 하지만 가장 빠르고 쉬운 첫 관문이다.
정적 보안 분석 — Checkov와 tfsec
HCL을 파싱해서 “이 코드가 보안 규칙을 위반하지 않는지” 검사하는 도구들이다. apply하지 않고도, state 없이도 검사할 수 있어서 개발 중 계속 돌리기 좋다.
Checkov
Bridgecrew(Palo Alto)에서 만든 도구다. Terraform뿐 아니라 CloudFormation, Kubernetes, Dockerfile까지 다양한 포맷을 지원한다.
# 설치
pip install checkov
# 실행
checkov -d .
checkov -d envs/prod --framework terraform
결과는 이런 식으로 나온다.
Check: CKV_AWS_24: "Ensure no security groups allow ingress from 0.0.0.0:0 to port 22"
FAILED for resource: aws_security_group.web
File: /envs/prod/main.tf:45-60
45 | resource "aws_security_group" "web" {
...
55 | ingress {
56 | from_port = 22
57 | to_port = 22
58 | protocol = "tcp"
59 | cidr_blocks = ["0.0.0.0/0"]
60 | }
Guide: https://docs.bridgecrew.io/docs/networking_1
어떤 규칙을 위반했는지, 어느 파일 몇 번째 줄인지, 가이드 링크까지 알려준다.
특정 규칙을 무시하고 싶을 때는 파일에 주석을 남긴다.
# checkov:skip=CKV_AWS_24:내부 네트워크라 허용
resource "aws_security_group" "internal" {
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["10.0.0.0/8"]
}
}
이유를 반드시 주석으로 남기는 게 원칙이다. skip 남발은 “아무것도 검사하지 않는 것”과 같다.
tfsec
Aqua Security의 도구다. Terraform 전용이라 좀 더 가볍다.
# 설치 (macOS)
brew install tfsec
# 실행
tfsec .
Result #1 CRITICAL Security group rule allows ingress from public internet.
──────────────────────────────────────────
envs/prod/main.tf:55
──────────────────────────────────────────
53 ingress {
54 from_port = 22
55 → cidr_blocks = ["0.0.0.0/0"]
56 protocol = "tcp"
57 to_port = 22
──────────────────────────────────────────
ID AVD-AWS-0107
Impact Your port is exposed to the whole internet
Resolution Set a more restrictive cidr range
Checkov와 tfsec은 상당 부분 중복된다. 둘 다 쓰는 팀도 있고, 한쪽만 쓰는 팀도 있다. 시작은 하나로 가볍게 해보고, 필요하면 추가하는 방식이 낫다. 개인적으로는 tfsec이 Terraform 특화된 만큼 출력이 더 깔끔하게 느껴진다.
정책 검증 — OPA
Checkov와 tfsec은 미리 정의된 규칙을 검사한다. 조직 고유의 정책(예: “모든 리소스에 Owner 태그가 있어야 한다”)은 자체 정책으로 표현해야 한다. 이럴 때 OPA(Open Policy Agent)를 쓴다.
OPA는 Rego라는 언어로 정책을 쓰는 범용 정책 엔진이다. Kubernetes, Envoy, Terraform 등 어디에나 쓸 수 있다. Terraform에는 conftest라는 래퍼를 통해 주로 쓴다.
먼저 terraform plan의 JSON을 얻는다.
terraform plan -out=tfplan
terraform show -json tfplan > plan.json
정책을 Rego로 쓴다.
# policies/tags.rego
package main
required_tags := {"Owner", "Environment", "Service"}
deny[msg] {
resource := input.resource_changes[_]
resource.type == "aws_instance"
tag := required_tags[_]
not resource.change.after.tags[tag]
msg := sprintf("인스턴스 %v에 필수 태그 %v가 누락됨", [resource.address, tag])
}
이 정책은 “모든 EC2 인스턴스에 Owner, Environment, Service 태그가 있어야 한다”는 의미다. deny에 해당하면 정책 위반이다.
# 검사
conftest test --policy policies/ plan.json
FAIL - plan.json - 인스턴스 aws_instance.app에 필수 태그 Environment가 누락됨
좀 더 복잡한 예시도 있다. “프로덕션 환경에서는 특정 인스턴스 타입만 허용”이라든가.
package main
allowed_prod_types := {"m5.large", "m5.xlarge", "r5.large"}
deny[msg] {
resource := input.resource_changes[_]
resource.type == "aws_instance"
resource.change.after.tags.Environment == "prod"
instance_type := resource.change.after.instance_type
not allowed_prod_types[instance_type]
msg := sprintf(
"프로덕션에서 인스턴스 타입 %v는 허용되지 않음. 허용: %v",
[instance_type, allowed_prod_types]
)
}
조직 정책을 코드로 명문화하면, 누구든 위반 사항을 사전에 발견할 수 있다. “프로덕션 규칙 뭐였지?” 같은 질문이 없어진다.
통합 테스트 — Terratest
가장 무거운 테스트다. 실제 클라우드 리소스를 만들어보고, 만든 게 의도대로 동작하는지 검증한다. Go로 작성한다.
// test/vpc_test.go
package test
import (
"fmt"
"testing"
"github.com/gruntwork-io/terratest/modules/terraform"
"github.com/stretchr/testify/assert"
)
func TestVpcModule(t *testing.T) {
t.Parallel()
terraformOptions := &terraform.Options{
TerraformDir: "../modules/vpc",
Vars: map[string]interface{}{
"cidr_block": "10.99.0.0/16",
"environment": fmt.Sprintf("test-%d", time.Now().Unix()),
},
}
// 테스트 종료 시 반드시 정리
defer terraform.Destroy(t, terraformOptions)
// apply 실행
terraform.InitAndApply(t, terraformOptions)
// 출력값 검증
vpcId := terraform.Output(t, terraformOptions, "vpc_id")
assert.Regexp(t, "^vpc-", vpcId)
subnets := terraform.OutputList(t, terraformOptions, "public_subnet_ids")
assert.Equal(t, 2, len(subnets))
}
이 테스트는 실제로 VPC와 서브넷을 만들고, 출력값이 예상대로 나오는지 확인하고, 마지막에 깨끗이 삭제한다.
cd test
go test -v -timeout 30m
장단점이 극단적이다.
장점
- 실제 동작을 검증 — 정적 분석이 놓치는 것까지 잡아냄
- 복잡한 모듈의 회귀 방지에 탁월
단점
- 테스트 한 번에 몇 분 ~ 수십 분
- 실제 클라우드 비용 발생
- 테스트 실패 시 정리 안 된 리소스가 남을 위험
그래서 Terratest는 자주 쓰는 핵심 모듈에만 적용한다. 공식 오픈소스 모듈이 Terratest를 얼마나 갖추고 있는지 보면 기준이 잡힌다. 전체 코드에 적용하는 건 비용 대비 효과가 낮다.
terraform test — 내장 테스트 프레임워크
Terraform 1.6부터는 자체 테스트 프레임워크가 생겼다. Go 없이 HCL만으로 테스트를 쓸 수 있다.
# tests/vpc.tftest.hcl
run "valid_cidr" {
command = plan
variables {
cidr_block = "10.99.0.0/16"
environment = "test"
}
assert {
condition = aws_vpc.this.cidr_block == "10.99.0.0/16"
error_message = "VPC CIDR이 입력값과 일치해야 함"
}
}
run "creates_two_subnets_by_default" {
command = plan
variables {
cidr_block = "10.99.0.0/16"
environment = "test"
}
assert {
condition = length(aws_subnet.public) == 2
error_message = "기본 AZ 2개로 서브넷 2개가 생성되어야 함"
}
}
실행은 이렇게 한다.
terraform test
command = plan이면 apply 없이 plan만 실행해서 검증한다. 실제 리소스를 만들지 않으니 빠르고 공짜다. command = apply로 바꾸면 실제로 생성해서 테스트한다(비용 발생).
이 내장 프레임워크는 간단한 검증에 이상적이다. “입력에 따라 출력이 의도대로 바뀌는가”, “조건부 로직이 맞게 동작하는가” 같은 것들. 여전히 복잡한 통합 테스트는 Terratest가 유리하지만, 일상적인 모듈 테스트는 내장 프레임워크로도 충분하다.
pre-commit 훅 — 로컬에서 먼저 잡기
CI에서 테스트하기 전에, 로컬에서 커밋 시점에 기본 검사를 돌리면 훨씬 빠른 피드백을 받을 수 있다. pre-commit 도구를 쓴다.
# .pre-commit-config.yaml
repos:
- repo: https://github.com/antonbabenko/pre-commit-terraform
rev: v1.88.4
hooks:
- id: terraform_fmt
- id: terraform_validate
- id: terraform_tflint
args:
- --args=--only=terraform_required_version
- --args=--only=terraform_required_providers
- id: terraform_tfsec
- id: terraform_docs
args:
- --args=--output-file README.md
설치와 활성화.
# 설치 (macOS)
brew install pre-commit
# 훅 활성화
pre-commit install
# 수동 실행 (모든 파일)
pre-commit run --all-files
이제 git commit을 칠 때마다 fmt, validate, tflint, tfsec이 자동으로 돈다. 문제가 있으면 커밋이 실패한다. CI까지 가기 전에 개발자 노트북에서 문제를 잡는다.
flowchart LR
Edit["코드 수정"] --> Commit["git commit"]
Commit --> Hook["pre-commit 훅"]
Hook --> Check{"검사 통과?"}
Check -->|"실패"| Fix["수정 후 다시 커밋"]
Check -->|"성공"| Push["git push"]
Push --> CI["CI 파이프라인\n(더 무거운 테스트)"]
tflint — 린터
tflint는 Terraform 전용 린터다. 공식 검사보다 훨씬 다양한 규칙을 제공한다.
brew install tflint
tflint --init
tflint
.tflint.hcl 설정으로 AWS 프로바이더용 룰셋을 켤 수 있다.
plugin "aws" {
enabled = true
version = "0.30.0"
source = "github.com/terraform-linters/tflint-ruleset-aws"
}
rule "terraform_unused_declarations" {
enabled = true
}
rule "terraform_deprecated_interpolation" {
enabled = true
}
tflint는 잘못된 인스턴스 타입, 없는 AMI ID, 미사용 변수 같은 것까지 잡아준다.
전체 파이프라인 구성
지금까지 다룬 도구들을 CI에 통합한 예시다.
# .github/workflows/terraform-ci.yml
name: Terraform CI
on:
pull_request:
paths: ['**/*.tf', '**/*.tftest.hcl']
jobs:
checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.8.0
- name: Format
run: terraform fmt -check -recursive
- name: Init (모든 envs/*)
run: |
for dir in envs/*; do
(cd "$dir" && terraform init -backend=false)
done
- name: Validate
run: |
for dir in envs/*; do
(cd "$dir" && terraform validate)
done
- name: tflint
uses: terraform-linters/setup-tflint@v4
- run: |
tflint --init
tflint --recursive
- name: tfsec
uses: aquasecurity/tfsec-action@v1.0.3
- name: Checkov
uses: bridgecrewio/checkov-action@master
with:
directory: .
framework: terraform
soft_fail: false
- name: OPA Policy Check
run: |
for dir in envs/*; do
(cd "$dir" && terraform plan -out=tfplan && terraform show -json tfplan > plan.json)
conftest test --policy policies/ "$dir/plan.json"
done
- name: terraform test
run: |
for mod in modules/*; do
if [ -d "$mod/tests" ]; then
(cd "$mod" && terraform test)
fi
done
단계별로 비용이 다르다. fmt와 validate가 제일 빠르고(초 단위), 정적 분석이 다음(수십 초), OPA가 다음(분 단위, plan 포함), 실제 terratest가 가장 무거움(수십 분). 파이프라인 순서를 이렇게 배치해야 빠르게 실패하면서 전체 시간을 아낀다.
도구 선택 가이드
지금까지 나온 도구가 많다. 무엇을 써야 할까.
| 목적 | 필수 | 권장 | 선택 |
|---|---|---|---|
| 문법/스타일 | terraform fmt, validate | tflint | |
| 보안 정적 분석 | tfsec 또는 Checkov | 둘 다 | |
| 조직 정책 | OPA/conftest | Sentinel (Terraform Cloud) | |
| 단위 테스트 | terraform test | ||
| 통합 테스트 | Terratest (핵심 모듈만) | ||
| 로컬 통합 | pre-commit |
모든 걸 한 번에 도입할 필요는 없다. 단계적으로 쌓아간다.
- 먼저
fmt,validate,pre-commit설정 tfsec또는Checkov하나 선택해 CI에 추가- 자주 바뀌는 핵심 모듈에
terraform test추가 - 조직 정책이 명확해지면 OPA 도입
- 진짜 핵심 모듈에만 Terratest
인프라에도 품질 기준이 있다. “돌아가면 됐지”는 한 번은 통할지 몰라도, 규모가 커지면 버티지 못한다. 자동 테스트와 정책 검증이 인프라 코드에 대한 신뢰의 기반이다.
다음 편에서는 시리즈의 마무리로, 실전 패턴과 함정들을 정리한다. 디렉토리 구조, 태깅 전략, 흔한 사고와 대규모 마이그레이션까지 한 번에 훑는다.


Loading comments...