Go의 net/http를 사용하여 모듈식이며 테스트 가능한 웹 애플리케이션 구축
Takashi Yamamoto
Infrastructure Engineer · Leapcell

소개
빠르게 발전하는 소프트웨어 개발 환경에서 확장 가능하고 유지 관리 가능한 웹 애플리케이션을 구축하는 것이 무엇보다 중요합니다. Go는 강력한 동시성 기본 요소와 간단하면서도 강력한 표준 라이브러리를 통해 이를 위한 훌륭한 기반을 제공합니다.
종종 개발자들은 Go의 net/http
패키지가 제공하는 고유한 기능을 간과하고 복잡한 프레임워크로 바로 뛰어듭니다. 프레임워크는 편리함을 제공하지만, 진정한 숙달은 종종 내부 메커니즘을 이해하는 데서 비롯됩니다.
이 글에서는 net/http
를 활용하여 효율적이고 성능이 뛰어날 뿐만 아니라 디자인이 모듈화되고 쉽게 테스트할 수 있는 웹 애플리케이션을 구축하는 방법을 시연하여 장기적인 프로젝트 성공과 쉬운 협업을 위한 길을 열어갈 것입니다.
견고한 HTTP 애플리케이션을 위한 핵심 개념
구현에 들어가기 전에 Go에서 훌륭한 HTTP 애플리케이션을 구축하는 데 필수적인 주요 용어에 대한 공통된 이해를 구축해 봅시다.
- 핸들러 (Handler):
net/http
에서Handler
는 단일 메서드ServeHTTP(w http.ResponseWriter, r *http.Request)
를 가진 인터페이스입니다. 이 메서드는 들어오는 HTTP 요청(r
)을 처리하고 HTTP 응답(w
)을 보내는 역할을 합니다.func(w http.ResponseWriter, r *http.Request)
시그니처와 일치하는 함수는http.HandlerFunc
를 사용하여 쉽게http.Handler
인스턴스로 변환할 수 있습니다. - 미들웨어 (Middleware): 미들웨어 함수는 다른 핸들러를 감싸는 함수입니다. 요청과 응답을 가로채서 로깅, 인증, 오류 처리 또는 메인 핸들러가 실행되기 전후에 요청/응답 헤더를 수정하는 등의 작업을 수행할 수 있습니다. 이는 코드 재사용 및 관심사 분리를 촉진합니다.
- 라우팅 (Routing): 라우팅은 들어오는 HTTP 요청(URL 경로, HTTP 메서드 등에 기반)을 특정 핸들러에 매핑하는 프로세스입니다.
net/http
는 기본 라우팅(http.HandleFunc
및http.ServeMux
)을 제공하지만, 더 복잡한 애플리케이션에서는 사용자 정의 또는 타사 라우터를 사용하여 경로를 효율적으로 관리하는 경우가 많습니다. - 의존성 주입 (Dependency Injection, DI): DI는 컴포넌트가 작동하는 데 필요한 의존성(객체 또는 함수)이 컴포넌트 자체에서 생성되는 것이 아니라 컴포넌트에 제공되는 디자인 패턴입니다. 이는 테스트 가능성과 유연성을 크게 향상시키며, 테스트 중에 서로 다른 '모의' 의존성을 주입할 수 있습니다.
- 테스트 가능성 (Testability): 소프트웨어를 테스트할 수 있는 용이성. 테스트 가능성이 높은 애플리케이션은 종종 느슨한 결합, 명확한 인터페이스 및 작고 집중된 코드 단위를 활용하는데, 이는 모듈식 디자인이 목표로 하는 정확히 그것입니다.
모듈식이며 테스트 가능한 웹 애플리케이션 구축
우리의 목표는 사용자 관련 작업(예: ID로 사용자 가져오기)을 구조화되고 유지 관리 가능하며 테스트 가능한 방식으로 처리하는 웹 애플리케이션을 만드는 것입니다. 이를 위해 코드를 별도의 모듈로 구성하고 Go의 인터페이스 시스템을 활용할 것입니다.
1. 애플리케이션 구조 정의
Go 웹 애플리케이션을 위한 일반적이고 효과적인 구조는 handlers
, services
(또는 usecases
), repositories
(또는 stores
)와 같은 패키지로 관심사를 분리하는 것을 포함합니다.
.
├── cmd/app/main.go # 애플리케이션 진입점
├── internal/
│ ├── handlers/ # HTTP 요청 핸들러
│ │ └── user_handler.go
│ ├── models/ # 데이터 구조
│ │ └── user.go
│ ├── services/ # 비즈니스 로직
│ │ └── user_service.go
│ └── repositories/ # 데이터 액세스 계층
│ └── user_repo.go
└── go.mod
└── go.sum
2. 모델: 데이터 구조 정의
먼저 internal/models/user.go
에서 User
모델을 정의해 봅시다.
package models import "fmt" type User struct { ID string `json:"id"` Name string `json:"name"` Email string `json:"email"` } func (u User) String() string { return fmt.Sprintf("ID: %s, Name: %s, Email: %s", u.ID, u.Name, u.Email) }
3. 리포지토리: 데이터 액세스 계층 (인터페이스 포함)
리포지토리 패턴은 데이터 저장 메커니즘을 추상화합니다. 인터페이스를 정의함으로써 구현을 쉽게 전환할 수 있습니다(예: 테스트용 인메모리, 프로덕션용 PostgreSQL).
internal/repositories/user_repo.go
:
package repositories import ( "context" "errors" "fmt" "yourproject/internal/models" ) // ErrUserNotFound는 사용자를 찾을 수 없을 때 반환됩니다. var ErrUserNotFound = errors.New("user not found") // UserRepository는 사용자 데이터 작업에 대한 인터페이스를 정의합니다. type UserRepository interface { GetUserByID(ctx context.Context, id string) (*models.User, error) // CreateUser, UpdateUser, DeleteUser와 같은 다른 메서드 추가 } // InMemoryUserRepository는 UserRepository의 간단한 인메모리 구현입니다. type InMemoryUserRepository struct { users map[string]*models.User } // NewInMemoryUserRepository는 새 InMemoryUserRepository를 생성합니다. func NewInMemoryUserRepository() *InMemoryUserRepository { return &InMemoryUserRepository{ users: map[string]*models.User{ "1": {ID: "1", Name: "Alice", Email: "alice@example.com"}, "2": {ID: "2", Name: "Bob", Email: "bob@example.com"}, }, } } // GetUserByID는 메모리에서 ID로 사용자를 검색합니다. func (r *InMemoryUserRepository) GetUserByID(ctx context.Context, id string) (*models.User, error) { // 데이터베이스 호출 지연 시뮬레이션 // time.Sleep(10 * time.Millisecond) if user, ok := r.users[id]; ok { return user, nil } return nil, fmt.Errorf("%w: %s", ErrUserNotFound, id) }
4. 서비스: 비즈니스 로직 계층 (인터페이스 포함)
서비스 계층은 애플리케이션의 핵심 비즈니스 로직을 포함합니다. 리포지토리 간의 상호 작용을 조정하고 특정 사용 사례를 처리합니다. 다시 한 번, 인터페이스는 테스트 가능성을 향상시킵니다.
internal/services/user_service.go
:
package services import ( "context" "yourproject/internal/models" "yourproject/internal/repositories" ) // UserService는 사용자 관련 비즈니스 작업에 대한 인터페이스를 정의합니다. type UserService interface { GetUserByID(ctx context.Context, id string) (*models.User, error) } // UserServiceImpl은 UserService의 구현입니다. type UserServiceImpl struct { userRepo repositories.UserRepository } // NewUserService는 새 UserService를 생성합니다. func NewUserService(repo repositories.UserRepository) *UserServiceImpl { return &UserServiceImpl{userRepo: repo} } // GetUserByID는 ID로 사용자를 가져오고 필요한 비즈니스 로직을 수행합니다. func (s *UserServiceImpl) GetUserByID(ctx context.Context, id string) (*models.User, error) { // 여기서 유효성 검사, 권한 부여 확인 등을 추가할 수 있습니다. user, err := s.userRepo.GetUserByID(ctx, id) if err != nil { // 디버깅을 위해 오류 로깅 // log.Printf("Error getting user by ID %s: %v", id, err) return nil, err // 오류를 위로 전달 } return user, nil }
5. 핸들러: HTTP 요청 처리
핸들러는 HTTP 요청의 진입점입니다. 비즈니스 로직을 위해 서비스 계층에 위임합니다. json.Marshal
및 json.NewDecoder
의 사용에 주목하십시오.
internal/handlers/user_handler.go
:
package handlers import ( "encoding/json" "log" "net/http" "yourproject/internal/services" ) // UserHandler는 사용자와 관련된 HTTP 요청을 처리합니다. type UserHandler struct { userService services.UserService } // NewUserHandler는 새 UserHandler를 생성합니다. func NewUserHandler(svc services.UserService) *UserHandler { return &UserHandler{userService: svc} } // GetUserByID는 ID로 사용자를 가져오는 것을 처리합니다. // 이 함수는 /users/{id}와 같은 URL 경로를 예상합니다. func (h *UserHandler) GetUserByID(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } // 기본 파싱: 실제 앱에서는 경로 매개변수를 추출하는 라우터를 사용합니다. // 단순화를 위해 마지막 세그먼트를 수동으로 파싱합니다. id := r.URL.Path[len("/users/"):] if id == "" { http.Error(w, "User ID is required", http.StatusBadRequest) return } user, err := h.userService.GetUserByID(r.Context(), id) if err != nil { if err == services.ErrUserNotFound { // 특정 오류를 확인합니다. http.Error(w, err.Error(), http.StatusNotFound) return } log.Printf("Error getting user: %v", err) // 내부 오류 로깅 http.Error(w, "Internal server error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(user); err != nil { log.Printf("Error encoding response: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) } }
6. 애플리케이션 진입점 및 와이어링
cmd/app/main.go
의 main
함수는 구성 요소를 와이어링하는 역할을 합니다.
package main import ( "log" "net/http" "time" "yourproject/internal/handlers" "yourproject/internal/repositories" "yourproject/internal/services" ) func main() { // 리포지토리 초기화 userRepo := repositories.NewInMemoryUserRepository() // 리포지토리를 사용하여 서비스 초기화 userService := services.NewUserService(userRepo) // 서비스를 사용하여 핸들러 초기화 userHandler := handlers.NewUserHandler(userService) // 라우팅을 위한 새 ServeMux 생성 mux := http.NewServeMux() // 라우트 등록 // 참고: 고급 라우팅의 경우 Chi 또는 Gorilla Mux와 같은 타사 라우터를 고려하십시오. // 데모를 위해 간단한 접두사 일치를 사용합니다. mux.Handle("/users/", http.HandlerFunc(userHandler.GetUserByID)) // 미들웨어 적용 (선택 사항이지만 크로스 커팅 관심사를 위해 강력히 권장됨) wrappedMux := loggingMiddleware(mux) // 간단한 로깅 미들웨어 추가 // HTTP 서버 구성 server := &http.Server{ Addr: ":8080", Handler: wrappedMux, ReadTimeout: 5 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 120 * time.Second, } log.Printf("Server starting on %s", server.Addr) if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalf("Server failed to start: %v", err) } } // loggingMiddleware는 들어오는 요청을 로깅합니다. func loggingMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() next.ServeHTTP(w, r) log.Printf("[%s] %s %s %s", r.Method, r.RequestURI, time.Since(start), r.RemoteAddr) }) }
이제 go run cmd/app/main.go
로 실행하고 http://localhost:8080/users/1
또는 http://localhost:8080/users/3
으로 GET
요청을 보냅니다.
7. 테스트 가능성
이 모듈식 디자인의 아름다움은 테스트에서 빛을 발합니다. 인터페이스와 의존성 주입 덕분에 의존성을 쉽게 모의할 수 있습니다.
internal/services/user_service_test.go
:
package services_test import ( "context" "errors" "testing" "yourproject/internal/models" "yourproject/internal/repositories" "yourproject/internal/services" // 테스트 대상 패키지 가져오기 ) // MockUserRepository는 repositories.UserRepository의 모의 구현입니다. type MockUserRepository struct { GetUserByIDFunc func(ctx context.Context, id string) (*models.User, error) } // GetUserByID는 모의 함수를 호출합니다. func (m *MockUserRepository) GetUserByID(ctx context.Context, id string) (*models.User, error) { if m.GetUserByIDFunc != nil { return m.GetUserByIDFunc(ctx, id) } return nil, errors.New("GetUserByID not implemented") // 누락된 모의에 대한 기본 패닉 } func TestUserService_GetUserByID(t *testing.T) { // 테스트 케이스 1: 사용자 찾음 t.Run("user found", func(t *testing.T) { expectedUser := &models.User{ID: "123", Name: "Test User", Email: "test@example.com"} mockRepo := &MockUserRepository{ GetUserByIDFunc: func(ctx context.Context, id string) (*models.User, error) { if id == "123" { return expectedUser, nil } return nil, repositories.ErrUserNotFound }, } svc := services.NewUserService(mockRepo) user, err := svc.GetUserByID(context.Background(), "123") if err != nil { t.Errorf("Expected no error, got %v", err) } if user == nil || user.ID != "123" { t.Errorf("Expected user ID 123, got %v", user) } }) // 테스트 케이스 2: 사용자 찾지 못함 t.Run("user not found", func(t *testing.T) { mockRepo := &MockUserRepository{ GetUserByIDFunc: func(ctx context.Context, id string) (*models.User, error) { return nil, repositories.ErrUserNotFound }, } svc := services.NewUserService(mockRepo) _, err := svc.GetUserByID(context.Background(), "unknown") if err == nil { t.Error("Expected an error, got nil") } if !errors.Is(err, repositories.ErrUserNotFound) { t.Errorf("Expected ErrUserNotFound, got %v", err) } }) // 테스트 케이스 3: 리포지토리 오류 t.Run("repository error", func(t *testing.T) { internalErr := errors.New("database connection failed") mockRepo := &MockUserRepository{ GetUserByIDFunc: func(ctx context.Context, id string) (*models.User, error) { return nil, internalErr }, } svc := services.NewUserService(mockRepo) _, err := svc.GetUserByID(context.Background(), "123") if err == nil { t.Error("Expected an error, got nil") } if !errors.Is(err, internalErr) { t.Errorf("Expected internal error, got %v", err) } }) }
핸들러 직접 테스트는 net/http/httptest
를 사용하여 수행할 수 있습니다.
internal/handlers/user_handler_test.go
:
package handlers_test import ( "context" "encoding/json" "errors" "net/http" "net/http/httptest" "testing" "yourproject/internal/handlers" "yourproject/internal/models" "yourproject/internal/repositories" // 비교를 위해 리포지토리 오류 가져오기 "yourproject/internal/services" // 비교를 위해 서비스 오류 가져오기 ) // MockUserService는 services.UserService의 모의 구현입니다. type MockUserService struct { GetUserByIDFunc func(ctx context.Context, id string) (*models.User, error) } // GetUserByID는 모의 함수를 호출합니다. func (m *MockUserService) GetUserByID(ctx context.Context, id string) (*models.User, error) { if m.GetUserByIDFunc != nil { return m.GetUserByIDFunc(ctx, id) } return nil, errors.New("GetUserByID not implemented") // 기본값 } func TestUserHandler_GetUserByID(t *testing.T) { // 테스트 케이스 1: 사용자 찾음 t.Run("user found", func(t *testing.T) { expectedUser := &models.User{ID: "1", Name: "Alice", Email: "alice@example.com"} mockService := &MockUserService{ GetUserByIDFunc: func(ctx context.Context, id string) (*models.User, error) { if id == "1" { return expectedUser, nil } return nil, services.ErrUserNotFound // 리포지토리를 통해 내보내진 서비스 오류 사용 }, } handler := handlers.NewUserHandler(mockService) req := httptest.NewRequest(http.MethodGet, "/users/1", nil) rec := httptest.NewRecorder() handler.GetUserByID(rec, req) if rec.Code != http.StatusOK { t.Errorf("Expected status %d, got %d", http.StatusOK, rec.Code) } var actualUser models.User if err := json.NewDecoder(rec.Body).Decode(&actualUser); err != nil { t.Fatalf("Failed to decode response: %v", err) } if actualUser.ID != expectedUser.ID || actualUser.Name != expectedUser.Name { t.Errorf("Expected user %+v, got %+v", expectedUser, actualUser) } }) // 테스트 케이스 2: 사용자 찾지 못함 t.Run("user not found", func(t *testing.T) { mockService := &MockUserService{ GetUserByIDFunc: func(ctx context.Context, id string) (*models.User, error) { return nil, repositories.ErrUserNotFound // 기본 오류 자체를 세로로 전달합니다. }, } handler := handlers.NewUserHandler(mockService) req := httptest.NewRequest(http.MethodGet, "/users/99", nil) rec := httptest.NewRecorder() handler.GetUserByID(rec, req) if rec.Code != http.StatusNotFound { t.Errorf("Expected status %d, got %d", http.StatusNotFound, rec.Code) } if rec.Body.String() != "user not found: 99\n" && rec.Body.String() != "user not found\n" { // 오류가 전달되는 방식에 따라 다름 t.Errorf("Expected 'user not found', got '%s'", rec.Body.String()) } }) // 테스트 케이스 3: 잘못된 메서드 t.Run("invalid method", func(t *testing.T) { mockService := &MockUserService{} // 실제 서비스 로직은 필요 없습니다. handler := handlers.NewUserHandler(mockService) req := httptest.NewRequest(http.MethodPost, "/users/1", nil) rec := httptest.NewRecorder() handler.GetUserByID(rec, req) if rec.Code != http.StatusMethodNotAllowed { t.Errorf("Expected status %d, got %d for POST request", http.StatusMethodNotAllowed, rec.Code) } }) }
이러한 테스트는 실제 데이터베이스나 외부 서비스 없이도 진정한 의존성으로 동일한 인터페이스를 구현하는 모의 객체를 만들어 구성 요소를 격리하는 방법을 얼마나 쉽게 테스트할 수 있는지 보여줍니다. 이를 통해 비즈니스 로직과 HTTP 처리를 독립적으로 테스트할 수 있습니다.
애플리케이션 시나리오
이 모듈식 접근 방식은 다음과 같은 경우에 이상적입니다.
- RESTful API: 리소스 관리를 위한 명확하게 구조화된 엔드포인트.
- 마이크로서비스: 각 서비스는 독립적인 모듈식 애플리케이션이 될 수 있습니다.
- 확장되는 코드베이스: 기존 코드를 방해하지 않고 새 패키지에 새 기능을 추가하여 유지 관리성을 향상시킬 수 있습니다.
- 협업 개발: 명확한 경계 덕분에 다른 팀이 최소한의 병합 충돌로 다른 서비스 또는 계층에서 작업할 수 있습니다.
결론
Go의 net/http
를 사용하여 모듈식이며 테스트 가능한 웹 애플리케이션을 구축하는 것은 단순히 달성 가능한 것이 아니라 견고하고 유지 관리 가능하며 검증 가능한 시스템을 산출하는 강력한 접근 방식입니다. 인터페이스, 의존성 주입 및 계층화된 아키텍처를 채택함으로써 개발자는 이해, 확장 및 가장 중요하게는 테스트하기 쉬운 애플리케이션을 만들 수 있습니다. Go의 디자인 철학에서 직접 파생된 이러한 고유한 단순성과 명확성은 애플리케이션을 지속적인 성공으로 이끌 것입니다.