강력한 Go 웹 서비스를 위한 TDD 채택
Lukas Schneider
DevOps Engineer · Leapcell

소개
빠르게 진화하는 웹 개발 환경에서 강력하고 유지보수 가능하며 확장 가능한 애플리케이션을 구축하는 것은 매우 중요합니다. Go는 성능, 동시성 및 단순성 덕분에 백엔드 서비스의 강력한 무기로 부상했지만, 고품질 Go 코드를 작성하는 과정에서 여전히 어려움이 있을 수 있습니다. 이러한 문제를 크게 해결하고 초창기부터 품질 문화를 조성하는 방법론 중 하나는 테스트 주도 개발(TDD)입니다. TDD는 단순한 테스트 그 이상입니다. 디자인에 영향을 미치고, 명확성을 개선하며, 궁극적으로 더 안정적인 소프트웨어로 이어지는 개발 패러다임입니다. 이 글에서는 Go 웹 애플리케이션을 개발할 때 TDD의 실질적인 적용 방법을 안내하고, 이 접근 방식이 개발 프로세스와 코드 품질을 어떻게 향상시킬 수 있는지 보여줍니다.
테스트 주도 개발의 핵심 원칙
실제 예제를 살펴보기 전에 TDD와 관련된 핵심 개념을 명확하게 이해해 봅시다.
테스트 주도 개발(TDD): 매우 짧은 개발 주기를 반복하는 것에 의존하는 소프트웨어 개발 프로세스:
- 빨강(Red): 새로운 기능에 대해 실패하는 테스트를 작성합니다. 이 테스트는 기능이 아직 존재하지 않기 때문에 실패해야 합니다.
- 녹색(Green): 실패하는 테스트를 통과하게 만드는 데 필요한 만큼의 프로덕션 코드를 작성합니다. 그 이상도 그 이하도 아닙니다.
- 리팩터(Refactor): 모든 테스트가 계속 통과하는지 확인하면서 코드의 디자인을 개선합니다. 이 단계는 깨끗하고 이해하기 쉬운 코드베이스를 유지하는 데 중요합니다.
단위 테스트(Unit Test): 소프트웨어의 개별 단위 또는 구성 요소를 테스트하는 소프트웨어 테스트 방법입니다. Go에서는 일반적으로 외부 종속성으로부터 격리된 단일 패키지 내의 함수 또는 메서드입니다.
통합 테스트(Integration Test): 개별 단위가 결합되어 그룹으로 테스트되는 소프트웨어 테스트 유형입니다. 웹 애플리케이션의 맥락에서 이는 종종 핸들러가 서비스 계층 또는 데이터베이스와 상호 작용하는 것과 같은 구성 요소 간의 상호 작용을 테스트하는 것을 포함합니다.
모킹(Mocking): 실제 종속성(데이터베이스, 외부 API 또는 기타 서비스)의 동작을 흉내 내는 시뮬레이션된 객체를 만드는 행위입니다. 모크는 테스트 중인 단위를 격리하고 종속성 동작을 제어하여 테스트를 더 빠르고 안정적으로 만드는 데 단위 테스트에 사용됩니다.
Go 웹 애플리케이션에서의 실질적인 TDD
TDD를 사용하여 간단한 사용자 관리 API를 구축하는 예제를 살펴보겠습니다. 새 사용자를 생성하는 핸들러 함수에 중점을 둘 것입니다.
1단계: API 및 데이터베이스 인터페이스 정의
먼저 사용자 저장소에 대한 인터페이스를 정의하여 테스트에서 데이터베이스를 쉽게 모킹할 수 있도록 합니다.
// user_store.go package user import ( "context" "errors" ) var ErrUserAlreadyExists = errors.New("user already exists") type User struct { ID string Username string Email string // ... other fields } // UserStore defines operations for user persistence. type UserStore interface { CreateUser(ctx context.Context, user User) error // ... other user operations }
2단계: 빨강 - 핸들러에 대한 실패하는 테스트 작성
HTTP 핸들러를 테스트하기 위해 Go의 내장 testing
패키지와 net/http/httptest
를 사용합니다.
우리의 목표는 JSON 페이로드를 수락하고, 사용자를 생성하고, 성공적인 응답을 반환하는 핸들러를 만드는 것입니다. 성공적으로 사용자를 생성하는 가장 간단한 실패 테스트부터 시작하겠습니다.
// handler_test.go package user_test import ( "bytes" "context" "encoding/json" "io/ioutil" "net/http" "net/http/httptest" "testing" "yourproject/user" // Assuming your package is user ) // MockUserStore is a mock implementation of UserStore for testing. type MockUserStore struct { CreateUserFunc func(ctx context.Context, u user.User) error } func (m *MockUserStore) CreateUser(ctx context.Context, u user.User) error { return m.CreateUserFunc(ctx, u) } func TestCreateUserHandler_Success(t *testing.T) { // Arrange: Prepare test data and mock dependencies mockUserStore := &MockUserStore{ CreateUserFunc: func(ctx context.Context, u user.User) error { // Simulate successful creation return nil }, } handler := user.NewUserHandler(mockUserStore) testUser := struct { // Anonymous struct for request payload Username string `json:"username"` Email string `json:"email"` }{ Username: "testuser", Email: "test@example.com", } body, _ := json.Marshal(testUser) req := httptest.NewRequest(http.MethodPost, "/users", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() // Act: Execute the handler handler.ServeHTTP(rec, req) // Assert: Check the results if rec.Code != http.StatusCreated { t.Errorf("Expected status %d, got %d", http.StatusCreated, rec.Code) } responseBody, _ := ioutil.ReadAll(rec.Body) expectedResponse := `{"message":"User created successfully"}` // Or return the user object if string(responseBody) != expectedResponse { // Note: For real applications, parse JSON and compare struct for robustness t.Errorf("Expected response body %s, got %s", expectedResponse, string(responseBody)) } }
지금 go test ./...
를 실행하면 user.NewUserHandler
와 실제 핸들러 로직이 아직 존재하지 않기 때문에 이 테스트가 실패합니다. 이것이 "빨강" 단계입니다.
3단계: 녹색 - 테스트를 통과시키는 프로덕션 코드 작성
이제 TestCreateUserHandler_Success
테스트를 통과시키는 데 필요한 만큼의 코드를 작성합니다.
// handler.go package user import ( "context" "encoding/json" "net/http" ) // UserHandler handles user-related HTTP requests. type UserHandler struct { store UserStore } // NewUserHandler creates a new UserHandler. func NewUserHandler(store UserStore) *UserHandler { return &UserHandler{store: store} } // CreateUserRequest represents the request payload for creating a user. type CreateUserRequest struct { Username string `json:"username"` Email string `json:"email"` } // CreateUserHandler is the HTTP handler for creating a new user. func (h *UserHandler) CreateUserHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } var req CreateUserRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request payload", http.StatusBadRequest) return } newUser := User{ Username: req.Username, Email: req.Email, // In a real app, generate ID, hash password etc. } if err := h.store.CreateUser(r.Context(), newUser); err != nil { if err == ErrUserAlreadyExists { http.Error(w, err.Error(), http.StatusConflict) // 409 Conflict return } http.Error(w, "Failed to create user", http.StatusInternalServerError) return } w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(map[string]string{"message": "User created successfully"}) } // ServeHTTP implements http.Handler interface for the main handler. func (h *UserHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // For simplicity, we'll map all POST /users to CreateUserHandler. // In a complete router, you'd dispatch based on path. if r.URL.Path == "/users" && r.Method == http.MethodPost { h.CreateUserHandler(w, r) return } http.NotFound(w, r) }
go test ./...
를 다시 실행합니다. 테스트는 이제 통과해야 합니다. 이것이 "녹색" 단계입니다.
4단계: 리팩터 - 코드 개선
현재 코드는 상당히 간단하지만 이미 개선할 수 있는 몇 가지 영역을 볼 수 있습니다. 예를 들어 ServeHTTP
메서드는 수동으로 라우팅합니다. 더 강력한 해결책은 gorilla/mux
또는 chi
와 같은 전용 라우터를 사용할 것입니다. 지금은 기존 사용자를 처리하기 위한 또 다른 테스트를 추가하겠습니다.
스토어에서 ErrUserAlreadyExists
오류를 올바르게 처리하는지 확인하기 위해 또 다른 테스트 사례를 추가해 보겠습니다.
// handler_test.go (add to existing file) func TestCreateUserHandler_UserAlreadyExists(t *testing.T) { // Arrange mockUserStore := &MockUserStore{ CreateUserFunc: func(ctx context.Context, u user.User) error { return user.ErrUserAlreadyExists // Simulate user already existing }, } handler := user.NewUserHandler(mockUserStore) testUser := struct { Username string `json:"username"` Email string `json:"email"` }{ Username: "existinguser", Email: "existing@example.com", } body, _ := json.Marshal(testUser) req := httptest.NewRequest(http.MethodPost, "/users", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() // Act handler.ServeHTTP(rec, req) // Assert if rec.Code != http.StatusConflict { t.Errorf("Expected status %d, got %d", http.StatusConflict, rec.Code) } responseBody, _ := ioutil.ReadAll(rec.Body) expectedResponse := `User already exists` // Simplified for example, actual JSON error might be better if !bytes.Contains(responseBody, []byte(expectedResponse)) { t.Errorf("Expected response body to contain '%s', got '%s'", expectedResponse, string(responseBody)) } }
이 테스트는 "녹색" 단계에서 ErrUserAlreadyExists
처리를 구현했기 때문에 이미 통과해야 합니다. 이는 TDD가 변경 사항이 기존 기능을 손상시키지 않는다는 확신을 갖도록 어떻게 도움이 되는지 보여줍니다.
리팩터링 예시: 응답 메시지를 하드코딩하는 대신 JSON 응답 및 오류를 보내는 도우미를 도입해 보겠습니다.
// utils.go (new file) package user import ( "encoding/json" "net/http" ) // JSONResponse sends a JSON response with the given status code. func JSONResponse(w http.ResponseWriter, statusCode int, data interface{}) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(statusCode) if data != nil { json.NewEncoder(w).Encode(data) } } // JSONError sends a JSON error response. func JSONError(w http.ResponseWriter, message string, statusCode int) { JSONResponse(w, statusCode, map[string]string{"error": message}) }
이제 이러한 도우미를 사용하도록 handler.go
를 리팩터링합니다.
// handler.go (refactored parts) // ... func (h *UserHandler) CreateUserHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { JSONError(w, "Method not allowed", http.StatusMethodNotAllowed) return } var req CreateUserRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { JSONError(w, "Invalid request payload", http.StatusBadRequest) return } newUser := User{ Username: req.Username, Email: req.Email, } if err := h.store.CreateUser(r.Context(), newUser); err != nil { if err == ErrUserAlreadyExists { JSONError(w, err.Error(), http.StatusConflict) return } JSONError(w, "Failed to create user", http.StatusInternalServerError) return } JSONResponse(w, http.StatusCreated, map[string]string{"message": "User created successfully"}) }
리팩터링 후 모든 테스트를 다시 실행(go test ./...
)하여 변경 사항이 회귀를 일으키지 않았는지 확인합니다. 이 지속적인 테스트 주기는 TDD의 초석입니다.
애플리케이션 시나리오
TDD는 Go 웹 애플리케이션의 다양한 계층에서 매우 효과적입니다.
- 핸들러/컨트롤러: 설명한 대로 TDD는 API 계약을 정의하고 핸들러가 요청을 올바르게 처리하고, 서비스와 상호 작용하고, 적절한 HTTP 응답을 반환하는지 확인하는 데 도움이 됩니다.
- 서비스 계층/비즈니스 로직: 서비스 메서드에 대한 테스트를 먼저 작성하면 비즈니스 핵심 규칙이 올바르게 구현되고 하위 수준의 문제로부터 격리되었는지 확인할 수 있습니다. 데이터 저장소 인터페이스를 모킹하면 로직을 독립적으로 테스트할 수 있습니다.
- 리포지토리/데이터 액세스 계층: 여기서 테스트는 데이터베이스 상호 작용(예: SQL 쿼리, ORM 호출)이 올바르고 데이터가 예상대로 지속되고 검색되는지 확인할 수 있습니다. 이를 위해 테스트 데이터베이스 또는 인메모리 데이터베이스를 사용하여 더 빠른 테스트를 수행할 수 있습니다.
- 미들웨어: 사용자 정의 미들웨어의 경우 TDD는 요청을 올바르게 가로채고, 컨텍스트를 수정하거나, 인증/권한 부여 로직을 처리하는지 확인하는 데 도움이 될 수 있습니다.
Go 웹 개발에서 TDD의 이점
- 향상된 디자인: 테스트를 먼저 작성하면 코드 API에 대해 생각하게 되어 더 모듈화되고, 테스트 가능하며, 느슨하게 결합된 디자인으로 이어집니다.
- 더 높은 품질의 코드: TDD의 지속적인 피드백 루프는 더 적은 버그를 발생시키고, 발생하는 버그는 더 빨리 발견됨을 의미합니다.
- 살아있는 문서: 테스트는 코드의 동작 방식에 대한 최신 문서 역할을 합니다.
- 증가된 자신감: 포괄적인 테스트 스위트는 리팩터링, 새 기능 추가 또는 버그 수정 시 기존 기능을 손상시키지 않았다는 확신을 제공합니다.
- 쉬운 유지보수: 잘 테스트된 코드는 이해하고, 디버그하고, 수정하기가 더 쉽습니다.
결론
테스트 주도 개발은 Go 웹 애플리케이션 개발을 크게 향상시키는 강력한 방법론입니다. 빨강-녹색-리팩터 주기를 일관되게 적용함으로써 개발자는 보다 강력하고 유지보수 가능하며 잘 설계된 서비스를 구축할 수 있습니다. TDD를 채택하면 테스트를 사후 계획이 아닌 개발 프로세스의 필수 부분으로 전환하여 더 높은 품질의 소프트웨어와 더 큰 개발자 자신감을 얻을 수 있습니다. TDD는 단순한 테스트 작성 그 이상이며, 처음부터 더 나은 소프트웨어를 설계하는 것입니다.