[Go 2주 완성 #07] Day 12~13: 의존성 관리와 테스팅 - CMake보다 쉬운 세상

이 글의 핵심

C++ 프로젝트에서 빌드 설정과 Google Test 연결에 쓰던 시간이 Go에서는 거의 사라집니다. 이 글은 시리즈 일곱 번째 편으로 모듈 시스템이 버전을 고정하는 방식과 자주 쓰는 go 명령, 입력과 기대값을 표로 나열하는 테스트 패턴을 설명하고 HTTP 핸들러 테스트와 커버리지 개선 실습으로 마무리합니다.

시리즈 안내

📚 Go 2주 완성 시리즈 #07 | 전체 목차 보기

이 글은 C++ 개발자를 위한 2주 완성 Go 언어 커리큘럼의 Day 12~13 내용입니다.

이전: #06 고루틴·채널 ← | → 다음: #08 REST API 프로젝트 단위 테스트는 모든 언어에서 중요합니다. Python에서 pytest·CI, Node.js의 Jest, C++의 Google Test, Rust의 cargo test는 각각의 생태계에서 표준에 가깝습니다. 테스트를 파이프라인에 붙이려면 Node.js GitHub Actions CI/CD와 C++ GitHub Actions 멀티 OS 빌드를 함께 보세요. 의존성·빌드 도구 관점에서는 go mod가 언어에 내장된 반면, C++는 CMake와 Conan·vcpkg를 조합하는 경우가 많고, npm·node_modules·pip·uv·Poetry·Rust Cargo와 비교하면 “선언·락·재현 빌드” 패턴이 한눈에 정리됩니다. C++ 빌드 시스템 완전 비교도 함께 읽으면 좋습니다.


💡 초보자를 위한 한 줄: Go는 go mod init으로 프로젝트 시작, go get 패키지명으로 라이브러리 추가, go test로 테스트 실행. CMake·vcpkg 필요 없습니다. 테스트 파일은 _test.go 접미사로 만들고, 함수명은 TestXxx(t *testing.T)로 시작합니다. 벤치마크는 BenchmarkXxx(b *testing.B).

들어가며: “CMake 설정 파일은 어디 있죠?”

C++에서 외부 라이브러리를 추가하려면:

# CMakeLists.txt 수정
find_package(Boost REQUIRED)
find_package(OpenSSL REQUIRED)
target_link_libraries(myapp Boost::boost OpenSSL::SSL)

# vcpkg나 Conan으로 의존성 관리
vcpkg install boost openssl

Go는 한 줄입니다:

go get github.com/gin-gonic/gin

끝입니다. 테스트도 마찬가지입니다. Google Test를 설치하고 설정할 필요 없이, go test만 실행하면 됩니다.

이 글에서 배울 내용:

  • Go Modules로 의존성 관리
  • go get으로 라이브러리 추가
  • go test로 유닛 테스트 작성
  • 테이블 주도 테스트와 벤치마크

C++ 개발자 관점: C++ 백그라운드에서 Go로 전환하며 겪은 차이점과 함정을 중심으로 설명합니다. 포인터, 동시성, 메모리 관리 등 핵심 개념을 비교하며 정리했습니다.

Go Modules: 의존성 관리

C++ vs Go: 프로젝트 초기화

# C++: CMakeLists.txt
cmake_minimum_required(VERSION 3.15)
project(MyProject)
set(CMAKE_CXX_STANDARD 17)
# 외부 라이브러리 찾기
find_package(Boost REQUIRED)
find_package(OpenSSL REQUIRED)
# vcpkg 설정
# ...
add_executable(myapp main.cpp)
target_link_libraries(myapp Boost::boost OpenSSL::SSL)
# Go: 모듈 초기화 (한 줄)
go mod init myproject
# 생성된 go.mod
# module myproject
# 
# go 1.21

의존성 추가

# C++: vcpkg 또는 Conan
vcpkg install boost openssl
# 또는
conan install . --build=missing
# CMakeLists.txt 수정 필요
# Go: go get (한 줄)
go get github.com/gin-gonic/gin@latest
go get github.com/stretchr/[email protected]
# go.mod에 자동 추가됨

go.mod 파일 구조

// go.mod
module myproject
go 1.21
require (
    github.com/gin-gonic/gin v1.9.1
    github.com/stretchr/testify v1.8.4
)
// 간접 의존성 (자동 관리)
require (
    github.com/gin-contrib/sse v0.1.0 // indirect
    github.com/go-playground/validator/v10 v10.14.0 // indirect
    // ...
)

go.mod의 require가 두 블록으로 나뉜 것은 go mod tidy가 Go 1.17부터 직접 의존성과 간접 의존성(// indirect)을 따로 적기 때문입니다. 간접 의존성까지 go.mod에 기록하는 이유는 빌드할 때 필요한 모든 모듈의 버전을 go.mod 하나만 보고 결정할 수 있게 하기 위해서입니다. Go는 npm처럼 “조건을 만족하는 가장 최신 버전”을 고르지 않고, 의존성들이 요구하는 버전 중 가장 높은 최소 버전을 고르는 MVS(Minimal Version Selection) 방식을 씁니다. 그래서 누가 새 버전을 배포해도 내 go.mod를 바꾸기 전에는 빌드 결과가 달라지지 않고, 별도의 잠금 파일이 필요 없습니다. go.sum은 버전 선택이 아니라 내려받은 파일이 변조되지 않았는지 확인하는 체크섬 목록이므로, 두 파일 모두 저장소에 커밋해야 합니다.

go 1.21 줄도 단순한 메모가 아닙니다. 이 모듈이 요구하는 최소 Go 버전이자 언어 기능의 기준이라서, 예를 들어 1.22의 반복문 변수 의미 변경이나 새 ServeMux 패턴 문법은 이 값이 1.22 이상일 때 적용됩니다. Go 1.21부터는 이 값보다 오래된 툴체인으로 빌드하면 필요한 툴체인을 자동으로 내려받거나 오류를 냅니다.

주요 go 명령어

# 모듈 관리
go mod init myproject      # 모듈 초기화
go mod tidy                # 불필요한 의존성 제거
go mod download            # 의존성 다운로드
go mod verify              # 의존성 무결성 검증
# 의존성 추가/업데이트
go get package@latest      # 최신 버전
go get [email protected]      # 특정 버전
go get -u                  # 모든 의존성 업데이트
# 빌드
go build                   # 현재 디렉토리
go build ./...             # 모든 하위 패키지
go build -o myapp          # 출력 파일명 지정
# 실행
go run main.go             # 컴파일 + 실행

두 가지는 다른 언어 경험 때문에 헷갈리기 쉽습니다. 첫째, go get은 이제 현재 모듈의 의존성을 바꾸는 명령일 뿐이고, golangci-lint 같은 도구 바이너리를 설치하는 용도로는 go install 경로@버전을 써야 합니다. 예전 블로그 글처럼 go get으로 도구를 설치하려 하면 go get 대신 go install을 쓰라는 안내가 나옵니다. 둘째, go get -u는 직접 의존성뿐 아니라 간접 의존성까지 한꺼번에 최신 마이너 버전으로 올립니다. 편하지만 한 번에 바뀌는 범위가 커서, 저는 운영 중인 서비스에서는 go get 패키지@버전으로 필요한 것만 올리고 go mod tidy와 테스트를 돌린 뒤 커밋하는 방식을 씁니다.


외부 라이브러리 사용

실전 예시: HTTP 서버 (Gin 프레임워크)

# 의존성 추가
go get github.com/gin-gonic/gin
// main.go
package main
import (
    "github.com/gin-gonic/gin"
    "net/http"
)
func main() {
    r := gin.Default()
    
    r.GET("/ping", func(c *gin.Context) {
        c.JSON(http.StatusOK, gin.H{
            "message": "pong",
        })
    })
    
    r.Run(":8080")
}
# 실행
go run main.go
# 빌드
go build -o server
./server

유닛 테스트 작성

C++ vs Go: 테스트 프레임워크

// C++: Google Test 설치 및 설정 필요
#include <gtest/gtest.h>
int Add(int a, int b) {
    return a + b;
}
TEST(MathTest, AddPositive) {
    EXPECT_EQ(Add(2, 3), 5);
}
TEST(MathTest, AddNegative) {
    EXPECT_EQ(Add(-2, -3), -5);
}
int main(int argc, char **argv) {
    ::testing::InitGoogleTest(&argc, argv);
    return RUN_ALL_TESTS();
}
// CMakeLists.txt에 Google Test 설정 추가 필요
// Go: 내장 testing 패키지 (설치 불필요)
// math.go
package math
func Add(a, b int) int {
    return a + b
}
// math_test.go
package math
import "testing"
func TestAddPositive(t *testing.T) {
    result := Add(2, 3)
    if result != 5 {
        t.Errorf("Add(2, 3) = %d; want 5", result)
    }
}
func TestAddNegative(t *testing.T) {
    result := Add(-2, -3)
    if result != -5 {
        t.Errorf("Add(-2, -3) = %d; want -5", result)
    }
}
# 테스트 실행
go test                    # 현재 패키지
go test ./...              # 모든 하위 패키지
go test -v                 # 상세 출력
go test -run TestAddPositive  # 특정 테스트만

Go 테스트는 규칙이 단순한 대신 이름 규칙을 어기면 조용히 무시됩니다. 파일 이름이 _test.go로 끝나야 하고, 함수 이름은 Test 다음에 대문자(또는 숫자, 밑줄)로 시작해야 하며, 인자는 *testing.T 하나여야 합니다. Testadd나 testAdd처럼 쓰면 에러 없이 테스트 대상에서 빠지고 go test는 ok만 출력하므로, 새 테스트를 추가했는데 통과가 너무 빠르다 싶으면 go test -v로 실제로 실행됐는지 확인하는 습관이 좋습니다. 또 t.Errorf는 실패를 기록하고 계속 진행하고, t.Fatalf는 그 테스트 함수를 즉시 끝냅니다. 뒤따르는 검사가 앞의 결과에 의존한다면(예: 에러가 nil이 아니면 결과값이 무의미한 경우) Fatalf를 써야 nil 포인터 패닉 같은 2차 오류를 피할 수 있습니다.

참고로 이 글의 예제는 패키지 이름을 math로 두었는데, 실제 프로젝트에서는 표준 라이브러리 math와 이름이 겹쳐 같은 파일에서 둘을 함께 import하려면 별칭이 필요해집니다. 실습용이 아니라면 mathutil, calc처럼 겹치지 않는 이름을 권합니다.

테스트 헬퍼 함수

// Go: 테스트 헬퍼
package math
import "testing"
func assertEqual(t *testing.T, got, want int) {
    t.Helper()  // 에러 발생 시 호출자 라인 표시
    if got != want {
        t.Errorf("got %d; want %d", got, want)
    }
}
func TestAdd(t *testing.T) {
    assertEqual(t, Add(2, 3), 5)
    assertEqual(t, Add(-2, -3), -5)
    assertEqual(t, Add(0, 0), 0)
}

t.Helper()는 작은 줄이지만 효과가 큽니다. 이 호출이 없으면 실패 메시지의 파일·줄 번호가 헬퍼 함수 내부의 t.Errorf 줄을 가리켜서, 어느 검사가 실패했는지 알려면 로그를 한참 읽어야 합니다. t.Helper()를 호출하면 그 함수는 스택에서 건너뛰고 헬퍼를 호출한 테스트의 줄이 표시됩니다.


테이블 주도 테스트

테이블 주도 테스트 패턴

// Go: 테이블 주도 테스트 (권장 패턴)
package math
import "testing"
func TestAdd(t *testing.T) {
    tests := []struct {
        name     string
        a, b     int
        expected int
    }{
        {"positive numbers", 2, 3, 5},
        {"negative numbers", -2, -3, -5},
        {"zero", 0, 0, 0},
        {"mixed", -5, 10, 5},
        {"large numbers", 1000000, 2000000, 3000000},
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            result := Add(tt.a, tt.b)
            if result != tt.expected {
                t.Errorf("Add(%d, %d) = %d; want %d", 
                    tt.a, tt.b, result, tt.expected)
            }
        })
    }
}

실행 결과:

$ go test -v
=== RUN   TestAdd
=== RUN   TestAdd/positive_numbers
=== RUN   TestAdd/negative_numbers
=== RUN   TestAdd/zero
=== RUN   TestAdd/mixed
=== RUN   TestAdd/large_numbers
--- PASS: TestAdd (0.00s)
    --- PASS: TestAdd/positive_numbers (0.00s)
    --- PASS: TestAdd/negative_numbers (0.00s)
    --- PASS: TestAdd/zero (0.00s)
    --- PASS: TestAdd/mixed (0.00s)
    --- PASS: TestAdd/large_numbers (0.00s)
PASS

테이블 주도 테스트가 Go에서 표준처럼 쓰이는 이유는, 케이스를 추가하는 비용이 구조체 한 줄로 줄어들기 때문입니다. 경계값(0, 음수, 최댓값) 케이스를 빠뜨렸다는 리뷰를 받아도 테스트 함수를 새로 만들 필요 없이 표에 한 줄을 더하면 됩니다. 각 케이스를 t.Run으로 감싸면 실패한 케이스의 이름이 출력되고, 한 케이스가 실패해도 나머지는 계속 실행됩니다.

서브테스트에 t.Parallel()을 붙여 병렬로 돌릴 때는 Go 1.21 이하에서 유명한 함정이 있었습니다. 반복문 변수 tt가 모든 반복에서 공유되어, 병렬 서브테스트들이 모두 마지막 케이스만 검사하는 문제입니다. 테스트는 전부 통과하는데 실제로는 한 케이스만 검사된 셈이라 발견하기 어려웠고, 그래서 예전 코드에는 tt := tt 줄이 관용구처럼 들어 있습니다. Go 1.22부터는 반복마다 변수가 새로 만들어져 이 줄이 필요 없지만, go.mod의 go 버전이 1.22 이상일 때만 새 의미가 적용됩니다.

서브테스트 활용

// Go: 서브테스트로 구조화
func TestMath(t *testing.T) {
    t.Run("Add", func(t *testing.T) {
        if Add(2, 3) != 5 {
            t.Error("Add failed")
        }
    })
    
    t.Run("Subtract", func(t *testing.T) {
        if Subtract(5, 3) != 2 {
            t.Error("Subtract failed")
        }
    })
    
    t.Run("Multiply", func(t *testing.T) {
        if Multiply(2, 3) != 6 {
            t.Error("Multiply failed")
        }
    })
}

벤치마크와 커버리지

벤치마크

// Go: 벤치마크 (함수명 BenchmarkXxx)
package math
import "testing"
func BenchmarkAdd(b *testing.B) {
    for i := 0; i < b.N; i++ {
        Add(2, 3)
    }
}
func BenchmarkAddLarge(b *testing.B) {
    for i := 0; i < b.N; i++ {
        Add(1000000, 2000000)
    }
}
# 벤치마크 실행
$ go test -bench=.
BenchmarkAdd-8          1000000000    0.25 ns/op
BenchmarkAddLarge-8     1000000000    0.26 ns/op
PASS
# 메모리 할당 측정
$ go test -bench=. -benchmem
BenchmarkAdd-8          1000000000    0.25 ns/op    0 B/op    0 allocs/op

출력은 “반복 횟수(b.N)와 1회당 시간”입니다. b.N은 직접 정하지 않고, 테스트 프레임워크가 측정 시간이 충분히 길어질 때까지(기본 1초) 값을 늘려 가며 함수를 다시 호출합니다. 그래서 벤치마크 안에서 매번 해야 하는 준비 작업이 아니라면 루프 밖에서 하고, 준비 시간이 측정에 섞이지 않게 b.ResetTimer()를 호출합니다.

위의 0.25 ns/op는 사실 경고 신호입니다. 덧셈 하나는 CPU 사이클 한두 개 수준이고, 결과를 아무도 쓰지 않기 때문에 컴파일러가 Add 호출을 인라인한 뒤 통째로 제거했을 가능성이 높습니다. 이런 벤치마크는 “아무것도 하지 않는 루프”의 속도를 잰 것입니다. 결과를 패키지 수준 변수에 대입해 제거를 막거나, Go 1.24 이상에서는 for b.Loop() { Add(2, 3) } 형태를 쓰면 프레임워크가 이 최적화를 막아 줍니다. 벤치마크 결과는 실행할 때마다 흔들리므로, 변경 전후를 비교할 때는 -count=10으로 여러 번 돌린 뒤 benchstat으로 통계적으로 비교하는 것이 정석입니다.

커버리지

# 커버리지 측정
go test -cover
# PASS
# coverage: 85.7% of statements
# 상세 커버리지 리포트
go test -coverprofile=coverage.out
go tool cover -html=coverage.out  # HTML 리포트 생성

커버리지는 “실행된 문장의 비율”이지 “검증된 동작의 비율”이 아닙니다. 에러 반환 경로를 호출만 하고 결과를 검사하지 않아도 커버리지는 올라갑니다. 그래서 숫자 목표보다 HTML 리포트에서 빨간색으로 남은 줄, 특히 에러 처리 분기가 테스트되지 않았는지를 보는 용도가 더 유용합니다. go test -cover는 기본적으로 테스트가 있는 패키지 자신만 측정하므로, 여러 패키지에 걸친 통합 테스트의 커버리지를 보려면 -coverpkg=./...를 지정해야 합니다.

테스트 모킹

// Go: 인터페이스로 모킹
package user
import "testing"
// 인터페이스 정의
type UserStore interface {
    Get(id int) (*User, error)
    Save(u *User) error
}
// 프로덕션 구현
type DBUserStore struct {
    // DB 연결...
}
func (s *DBUserStore) Get(id int) (*User, error) {
    // 실제 DB 조회
    return nil, nil
}
// 테스트용 모의 구현
type MockUserStore struct {
    users map[int]*User
}
func NewMockUserStore() *MockUserStore {
    return &MockUserStore{
        users: make(map[int]*User),
    }
}
func (m *MockUserStore) Get(id int) (*User, error) {
    if user, ok := m.users[id]; ok {
        return user, nil
    }
    return nil, errors.New("not found")
}
func (m *MockUserStore) Save(u *User) error {
    m.users[u.ID] = u
    return nil
}
// 서비스 (인터페이스에 의존)
type UserService struct {
    store UserStore
}
func NewUserService(store UserStore) *UserService {
    return &UserService{store: store}
}
func (s *UserService) GetUser(id int) (*User, error) {
    return s.store.Get(id)
}
// 테스트
func TestUserService_GetUser(t *testing.T) {
    // 모의 스토어 사용
    mockStore := NewMockUserStore()
    mockStore.Save(&User{ID: 1, Name: "Alice"})
    
    service := NewUserService(mockStore)
    
    user, err := service.GetUser(1)
    if err != nil {
        t.Fatal(err)
    }
    
    if user.Name != "Alice" {
        t.Errorf("got %s; want Alice", user.Name)
    }
}

실습 과제

과제 1: 문자열 유틸리티 테스트

// string_utils.go
package utils
import "strings"
func Reverse(s string) string {
    runes := []rune(s)
    for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
        runes[i], runes[j] = runes[j], runes[i]
    }
    return string(runes)
}
func IsPalindrome(s string) bool {
    s = strings.ToLower(s)
    return s == Reverse(s)
}
func WordCount(s string) int {
    return len(strings.Fields(s))
}
// string_utils_test.go
package utils
import "testing"
func TestReverse(t *testing.T) {
    tests := []struct {
        input    string
        expected string
    }{
        {"hello", "olleh"},
        {"Go", "oG"},
        {"", ""},
        {"a", "a"},
        {"안녕하세요", "요세하녕안"},
    }
    
    for _, tt := range tests {
        t.Run(tt.input, func(t *testing.T) {
            result := Reverse(tt.input)
            if result != tt.expected {
                t.Errorf("Reverse(%q) = %q; want %q", 
                    tt.input, result, tt.expected)
            }
        })
    }
}
func TestIsPalindrome(t *testing.T) {
    tests := []struct {
        input    string
        expected bool
    }{
        {"racecar", true},
        {"hello", false},
        {"A man a plan a canal Panama", false},  // 공백 포함
        {"", true},
        {"a", true},
    }
    
    for _, tt := range tests {
        t.Run(tt.input, func(t *testing.T) {
            result := IsPalindrome(tt.input)
            if result != tt.expected {
                t.Errorf("IsPalindrome(%q) = %v; want %v", 
                    tt.input, result, tt.expected)
            }
        })
    }
}
func TestWordCount(t *testing.T) {
    tests := []struct {
        input    string
        expected int
    }{
        {"hello world", 2},
        {"Go is awesome", 3},
        {"", 0},
        {"   spaces   ", 1},
    }
    
    for _, tt := range tests {
        result := WordCount(tt.input)
        if result != tt.expected {
            t.Errorf("WordCount(%q) = %d; want %d", 
                tt.input, result, tt.expected)
        }
    }
}

과제 2: HTTP 핸들러 테스트

// handler.go
package main
import (
    "encoding/json"
    "net/http"
)
type Response struct {
    Message string `json:"message"`
    Status  string `json:"status"`
}
func PingHandler(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodGet {
        http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
        return
    }
    
    resp := Response{
        Message: "pong",
        Status:  "ok",
    }
    
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(resp)
}
// handler_test.go
package main
import (
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "testing"
)
func TestPingHandler(t *testing.T) {
    // 요청 생성
    req := httptest.NewRequest(http.MethodGet, "/ping", nil)
    
    // 응답 기록기
    w := httptest.NewRecorder()
    
    // 핸들러 호출
    PingHandler(w, req)
    
    // 상태 코드 검증
    if w.Code != http.StatusOK {
        t.Errorf("got status %d; want %d", w.Code, http.StatusOK)
    }
    
    // 응답 본문 검증
    var resp Response
    if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
        t.Fatal(err)
    }
    
    if resp.Message != "pong" {
        t.Errorf("got message %s; want pong", resp.Message)
    }
    
    if resp.Status != "ok" {
        t.Errorf("got status %s; want ok", resp.Status)
    }
}
func TestPingHandler_InvalidMethod(t *testing.T) {
    req := httptest.NewRequest(http.MethodPost, "/ping", nil)
    w := httptest.NewRecorder()
    
    PingHandler(w, req)
    
    if w.Code != http.StatusMethodNotAllowed {
        t.Errorf("got status %d; want %d", 
            w.Code, http.StatusMethodNotAllowed)
    }
}

과제 3: 벤치마크 작성

// fibonacci.go
package math
func Fibonacci(n int) int {
    if n <= 1 {
        return n
    }
    return Fibonacci(n-1) + Fibonacci(n-2)
}
func FibonacciMemo(n int) int {
    memo := make(map[int]int)
    return fibMemo(n, memo)
}
func fibMemo(n int, memo map[int]int) int {
    if n <= 1 {
        return n
    }
    
    if v, ok := memo[n]; ok {
        return v
    }
    
    memo[n] = fibMemo(n-1, memo) + fibMemo(n-2, memo)
    return memo[n]
}
// fibonacci_test.go
package math
import "testing"
func BenchmarkFibonacci(b *testing.B) {
    for i := 0; i < b.N; i++ {
        Fibonacci(20)
    }
}
func BenchmarkFibonacciMemo(b *testing.B) {
    for i := 0; i < b.N; i++ {
        FibonacciMemo(20)
    }
}
# 벤치마크 실행
$ go test -bench=.
BenchmarkFibonacci-8            30000     50000 ns/op
BenchmarkFibonacciMemo-8      5000000       300 ns/op
PASS
# 숫자는 출력 형식을 보여 주기 위한 예시이며, 기기와 Go 버전에 따라 달라집니다

직접 실행해 보면 두 구현의 차이는 n이 커질수록 급격히 벌어집니다. 단순 재귀는 호출 횟수가 대략 1.6ⁿ에 비례해 늘고, 메모이제이션 버전은 n에 비례하기 때문입니다. 다만 FibonacciMemo가 호출될 때마다 맵을 새로 만든다면 -benchmem에서 할당 횟수가 보이고, 전역 맵을 재사용한다면 두 번째 반복부터는 캐시 조회만 측정하게 됩니다. 벤치마크가 무엇을 재고 있는지 코드로 확인한 뒤 숫자를 해석하세요.

과제 4: 테스트 커버리지 개선

// calculator.go
package calc
import "errors"
func Divide(a, b float64) (float64, error) {
    if b == 0 {
        return 0, errors.New("division by zero")
    }
    return a / b, nil
}
func SafeDivide(a, b float64) float64 {
    result, err := Divide(a, b)
    if err != nil {
        return 0
    }
    return result
}
// calculator_test.go
package calc
import "testing"
func TestDivide(t *testing.T) {
    tests := []struct {
        name      string
        a, b      float64
        expected  float64
        shouldErr bool
    }{
        {"normal", 10, 2, 5, false},
        {"zero dividend", 0, 5, 0, false},
        {"zero divisor", 10, 0, 0, true},
        {"negative", -10, 2, -5, false},
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            result, err := Divide(tt.a, tt.b)
            
            if tt.shouldErr {
                if err == nil {
                    t.Error("expected error, got nil")
                }
            } else {
                if err != nil {
                    t.Errorf("unexpected error: %v", err)
                }
                if result != tt.expected {
                    t.Errorf("got %f; want %f", result, tt.expected)
                }
            }
        })
    }
}
func TestSafeDivide(t *testing.T) {
    tests := []struct {
        name     string
        a, b     float64
        expected float64
    }{
        {"normal", 10, 2, 5},
        {"zero divisor", 10, 0, 0},  // 에러 시 0 반환
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            result := SafeDivide(tt.a, tt.b)
            if result != tt.expected {
                t.Errorf("got %f; want %f", result, tt.expected)
            }
        })
    }
}
# 커버리지 확인
$ go test -cover
PASS
coverage: 100.0% of statements

정리: Day 12~13 학습 체크리스트

완료해야 할 항목

  • go mod init으로 모듈 초기화
  • go get으로 의존성 추가
  • go.mod와 go.sum 파일 이해
  • _test.go 파일에 테스트 작성
  • testing.T로 유닛 테스트
  • 테이블 주도 테스트 패턴 활용
  • testing.B로 벤치마크 작성
  • 커버리지 측정 및 개선
  • 실습 과제 4개 완료

C++에서 Go로 전환 포인트

C++Go비고
CMakeLists.txtgo.mod훨씬 간단
vcpkg/Conango get한 줄로 끝
Google Testtesting 패키지내장
CTestgo test내장
gcov/lcovgo test -cover내장

Go 빌드 시스템의 장점

graph LR
    A[C++ 빌드] --> B[CMake 설정]
    B --> C[의존성 관리]
    C --> D[빌드 실행]
    D --> E[테스트 설정]
    E --> F[테스트 실행]
    
    G[Go 빌드] --> H[go build]
    G --> I[go test]
    
    style A fill:#ffcccc
    style G fill:#ccffcc

Go의 장점: 빌드 시스템(go build), 의존성 관리(go mod), 테스트 프레임워크(go test)가 모두 툴체인에 들어 있어 별도 도구를 조합할 필요가 없습니다. 크로스 컴파일도 환경 변수 두 개로 끝납니다.

# 크로스 컴파일 (C++에서는 복잡)
GOOS=linux GOARCH=amd64 go build    # Linux 64비트
GOOS=windows GOARCH=amd64 go build  # Windows 64비트
GOOS=darwin GOARCH=arm64 go build   # macOS ARM

다음 단계 예고

Day 12~13에서는 의존성 관리와 테스팅을 배웠습니다. 마지막 Day 14에서는 지금까지 배운 모든 것을 통합하여 실전 REST API 서버를 구축합니다!


📚 시리즈 네비게이션

이전 글목차다음 글
← #06 고루틴·채널📑 전체 목차#08 REST API →

Go 2주 완성 시리즈: 커리큘럼 • #01 기본 문법 • #02 자료구조 • #03 객체지향 • #04 인터페이스 • #05 에러 처리 • #06 고루틴·채널 • #07 테스팅 • #08 REST API • #09 context·우아한 종료


Go Modules는 CMake·vcpkg 조합보다 설정할 것이 훨씬 적고, go test는 단위 테스트·벤치마크·커버리지를 외부 프레임워크 없이 제공합니다.

자주 묻는 질문 (FAQ)

Q. 테이블 주도 테스트에서 t.Run 서브테스트를 쓰면 무엇이 좋은가요?

A. 각 케이스에 이름이 붙어서 실패한 케이스가 출력에 바로 드러나고, 한 케이스가 실패해도 나머지 케이스는 계속 실행됩니다. go test -run 'TestAdd/negative'처럼 특정 케이스만 골라 실행할 수 있어 디버깅할 때도 편합니다. 반복문 안에서 t.Errorf만 쓰면 어느 입력에서 실패했는지 메시지에 직접 넣어야 하므로, 케이스가 많아질수록 서브테스트가 관리하기 쉽습니다.


관련 글