본문으로 건너뛰기

퍼즈 테스트 — Go 1.18+ 내장 퍼징

Go 1.18부터 퍼즈 테스트(Fuzz Testing)가 표준 라이브러리에 내장되었습니다. 예상치 못한 입력으로 버그와 패닉을 자동으로 찾아냅니다.


퍼즈 테스트란?

퍼즈 테스트(Fuzzing)는 프로그램에 무작위하고 예상치 못한 입력 을 자동으로 생성하여 크래시, 패닉, 무한 루프, 메모리 오류 등을 찾는 기법입니다.

언제 유용한가?

  • 파서, 직렬화/역직렬화 코드
  • 암호화 관련 함수
  • 사용자 입력을 처리하는 코드
  • 복잡한 알고리즘의 경계 조건

기본 퍼즈 테스트 작성

// reverse.go
package main

import "unicode/utf8"

// UTF-8 문자열을 역순으로 반환
func Reverse(s string) string {
b := []byte(s)
for i, j := 0, len(b)-1; i < len(b)/2; i, j = i+1, j-1 {
b[i], b[j] = b[j], b[i]
}
return string(b)
}

// 올바른 구현 (rune 단위 처리)
func ReverseRune(s string) string {
r := []rune(s)
for i, j := 0, len(r)-1; i < len(r)/2; i, j = i+1, j-1 {
r[i], r[j] = r[j], r[i]
}
return string(r)
}
// reverse_test.go
package main

import (
"testing"
"unicode/utf8"
)

// 일반 단위 테스트 (시드 코퍼스로도 활용)
func TestReverse(t *testing.T) {
tests := []struct {
input string
want string
}{
{"", ""},
{"abc", "cba"},
{"Hello, 世界", "界世 ,olleH"}, // 이 케이스는 Reverse에서 실패!
}
for _, tt := range tests {
if got := ReverseRune(tt.input); got != tt.want {
t.Errorf("ReverseRune(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}

// 퍼즈 테스트 — testing.F 사용
func FuzzReverse(f *testing.F) {
// 시드 코퍼스: 초기 입력 예제들
f.Add("Hello, World")
f.Add("")
f.Add("a")
f.Add("abc")

// 퍼즈 함수: Go가 자동으로 변형된 입력을 생성
f.Fuzz(func(t *testing.T, s string) {
// 속성 기반 테스트: 역순 두 번 = 원본이어야 함
reversed := ReverseRune(s)
doubleReversed := ReverseRune(reversed)

if s != doubleReversed {
t.Errorf("이중 역순 실패: 원본=%q, 이중역순=%q", s, doubleReversed)
}

// UTF-8 유효성 검증
if !utf8.ValidString(reversed) {
t.Errorf("역순 결과가 유효한 UTF-8이 아님: %q", reversed)
}
})
}

퍼즈 테스트 실행

# 시드 코퍼스만으로 일반 테스트처럼 실행 (빠름)
go test -run=FuzzReverse

# 퍼징 모드로 실행 (무한히 새로운 입력 생성)
go test -fuzz=FuzzReverse

# 30초간 퍼징
go test -fuzz=FuzzReverse -fuzztime=30s

# 병렬 워커 수 지정
go test -fuzz=FuzzReverse -parallel=4

코퍼스 파일 관리

퍼징이 버그를 발견하면 자동으로 코퍼스 파일에 저장됩니다.

testdata/
└── fuzz/
└── FuzzReverse/
├── 48f3a8c9b2e1d5f7 ← 버그를 발견한 입력값 저장
└── a1b2c3d4e5f6a7b8
# 발견된 버그 재현
go test -run=FuzzReverse/48f3a8c9b2e1d5f7

# 코퍼스 파일 내용 확인
cat testdata/fuzz/FuzzReverse/48f3a8c9b2e1d5f7

코퍼스 파일은 Git에 커밋 해야 합니다. CI에서 go test를 실행하면 시드 코퍼스로 회귀 테스트가 됩니다.


실전 퍼즈 테스트 예시

JSON 파서 퍼징

func FuzzJSONParse(f *testing.F) {
// 시드: 유효한 JSON 예제
f.Add(`{"name":"김고랭","age":30}`)
f.Add(`[]`)
f.Add(`null`)
f.Add(`"hello"`)

f.Fuzz(func(t *testing.T, data string) {
// 패닉이 발생하지 않는지 확인
var v interface{}
err := json.Unmarshal([]byte(data), &v)

if err == nil {
// 파싱 성공 시 재직렬화해도 유효해야 함
out, err2 := json.Marshal(v)
if err2 != nil {
t.Errorf("파싱은 성공했지만 직렬화 실패: %v", err2)
}
// 재파싱도 성공해야 함
var v2 interface{}
if err3 := json.Unmarshal(out, &v2); err3 != nil {
t.Errorf("재파싱 실패: %v", err3)
}
}
// err != nil 케이스는 정상 — JSON 파서가 오류를 올바르게 반환한 것
})
}

URL 파서 퍼징

func FuzzURLParse(f *testing.F) {
f.Add("https://example.com/path?query=1#fragment")
f.Add("http://localhost:8080")
f.Add("")
f.Add("/relative/path")

f.Fuzz(func(t *testing.T, rawURL string) {
u, err := url.Parse(rawURL)
if err != nil {
return // 파싱 실패는 정상
}

// 불변식: String()으로 재구성한 URL을 다시 파싱하면 동일해야 함
reconstructed := u.String()
u2, err := url.Parse(reconstructed)
if err != nil {
t.Errorf("재구성된 URL 파싱 실패: %q → %q, err: %v",
rawURL, reconstructed, err)
return
}

if u.Host != u2.Host || u.Path != u2.Path {
t.Errorf("URL 왕복 불일치: 원본=%v, 재구성=%v", u, u2)
}
})
}

커스텀 파서 퍼징

// 자체 제작 설정 파서 퍼징
func FuzzConfigParse(f *testing.F) {
f.Add("key=value\n")
f.Add("# comment\nkey=value\n")
f.Add("")

f.Fuzz(func(t *testing.T, input string) {
// 패닉이 없어야 함
cfg, err := ParseConfig(input)
if err != nil {
return // 파싱 에러는 허용
}

// 속성: 직렬화 후 재파싱하면 동일해야 함
serialized := cfg.String()
cfg2, err := ParseConfig(serialized)
if err != nil {
t.Errorf("직렬화된 설정 파싱 실패: input=%q, serialized=%q",
input, serialized)
return
}

if !cfg.Equal(cfg2) {
t.Errorf("왕복 불일치: %v != %v", cfg, cfg2)
}
})
}

여러 입력 타입 퍼징

// 복수 매개변수 퍼징
func FuzzAdd(f *testing.F) {
f.Add(int64(0), int64(0))
f.Add(int64(1), int64(-1))
f.Add(math.MaxInt64, int64(1))

f.Fuzz(func(t *testing.T, a, b int64) {
// 오버플로 없이 덧셈해야 함
result := safeAdd(a, b)

// 교환 법칙
if safeAdd(b, a) != result {
t.Errorf("교환 법칙 위반: safeAdd(%d, %d) != safeAdd(%d, %d)",
a, b, b, a)
}
})
}

// 지원되는 타입:
// string, []byte, bool, byte, rune,
// float32, float64, int, int8, int16, int32, int64,
// uint, uint8, uint16, uint32, uint64

퍼즈 테스트와 단위 테스트 통합

func FuzzHTTPHandler(f *testing.F) {
// 유효한 시드 요청
f.Add("GET", "/users/1", "")
f.Add("POST", "/users", `{"name":"test"}`)
f.Add("DELETE", "/users/999", "")

f.Fuzz(func(t *testing.T, method, path, body string) {
req := httptest.NewRequest(method, path, strings.NewReader(body))
w := httptest.NewRecorder()

// 패닉 없이 응답해야 함
handler(w, req)

resp := w.Result()

// 최소한 유효한 HTTP 상태 코드여야 함
if resp.StatusCode < 100 || resp.StatusCode > 599 {
t.Errorf("잘못된 HTTP 상태 코드: %d", resp.StatusCode)
}
})
}

CI 통합 전략

# .github/workflows/fuzz.yml
name: Fuzz Tests

on:
schedule:
- cron: '0 2 * * *' # 매일 새벽 2시

jobs:
fuzz:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.22'

# 시드 코퍼스만으로 단위 테스트처럼 실행 (PR 시)
- name: Run fuzz tests (seed corpus)
run: go test -run=FuzzXxx ./...

# 장시간 퍼징 (야간 스케줄)
- name: Run fuzzing (5 minutes)
run: go test -fuzz=FuzzXxx -fuzztime=5m ./...

핵심 정리

항목내용
함수 접두사FuzzXxx(f *testing.F)
시드 추가f.Add(value1, value2, ...)
퍼즈 함수f.Fuzz(func(t *testing.T, args...))
단위 테스트 실행go test -run=FuzzXxx
퍼징 모드go test -fuzz=FuzzXxx
코퍼스 경로testdata/fuzz/FuzzXxx/

속성 기반 테스트 원칙:

  • 이중 역전(왕복) 불변식: reverse(reverse(x)) == x
  • 직렬화/역직렬화 왕복: parse(serialize(x)) == x
  • 교환 법칙: f(a, b) == f(b, a)
  • 경계 조건: 빈 입력, 최댓값, 특수 문자