티스토리 뷰

golang

Go 백엔드 11: 유효성 검사

주먹불끈 2025. 4. 9. 21:27

개요

첫 포스팅이었던 클린 아키텍처의 세 계층에 대해 간단한 복습을 한 다음, handler 계층의 중요한 역할인 요청의 파라미터들에 대한 유효성 검사(validation)을 다루어 본다.

링크

목차

  • 클린 아키텍처의 세 계층
  • 유효성 검사
  • gocore 에의 적용
  • 마치며

클린 아키텍처의 세 계층

첫 포스팅에서 클린 아키텍처를 핸들러, 유스케이스, 리포지토리 세 계층으로 나누어 설명하고 간단한 장단점을 이야기 한 후 구현해보았었다. 이번에는 세 계층의 역할에 대해 간략히 적어보려 한다.

식당의 경우(예시)

홀에서 서빙을 하는 웨이터가 있고, 주방에는 주방장과 주방 보조가 있다고 생각해보자(각각은 핸들러, 유스케이스, 그리고 리포지토리에 대한 비유이다).

웨이터

웨이터는 주문을 받아 주방에 전하고, 요리가 나오면 서빙을 한다.

  • 손님의 주문을 받아, 그 주문내용을 잘 정리하여 주방에 전달한다.
  • 주문에 문제가 없는지에 대한 검토도 한다. 예를 들어
    • 식당 메뉴에 짜장면은 없습니다. 
    • 스파게티 100인분을 시키신게 맞으신가요?
  • 주방에서 요리가 다 만들어지면 손님에게 가져다 준다. 요리가 지연되거나 만들 수 없다면 그것 역시 손님에게 알려준다.

주방장

주방장은 식당의 핵심이다. 식당이라는 비즈니스는 요리를 만들어 제공하는게 핵심인 것이다.

  • 요리를 만드는 전체 과정을 제어한다.
  • 필요한 경우 주방 보조에게 특정한 일을 맡긴다.
    • 채소를 다듬어서 여기에 담아둬.
    • 소스가 떨어졌어. 얼른 나가서 사와
    • 냄비가 끓고 나면 2분만 더 뜸을 들인 뒤에 불을 꺼

주방 보조

주방보조는 시키는 것을 할 뿐이다. 요리를 어떻게 만드는 지는 모른다. 요리를 만드는 전체 맥락은 모른채(비즈니스 로직은 모른 채) 주방장이 시키는 일을 할 뿐이다.

서로의 역할은 불가침 영역이다

서로에 대한 의존성을 최소화하는 것이 좋다는 말이다.

웨이터가 가끔씩 몇몇 요리를 하고, 주방 보조가 직접 서빙을 하고, 주방장이 주문을 받거나 주방 보조가 할 재료 다듬는 일을 하기도 하면 어떻게 될까?

이들이 손발을 맞추고 함께 일하는 동안은 문제가 없다. 누군가가 로또가 되어 식당을 그만둘 때에 문제가 된다. 웨이터, 주방장, 주방보조는 구하기 어렵지 않지만, 요리할 수 있는 웨이터, 서빙도 하는 주방 보조, 주문도 받아야 하는 주방장은 구하기 힘들어진다.

클린 아키텍처의 경우

핸들러

웨이터이다. 클라이언트와의 소통을 담당한다.

클라이언트의 요청을 받아 요청의 유효성을 검사한 다음 적절한 유스케이스에 전달한다. 유스케이스가 회신을 하면 (필요한 경우 클라이언트가 바라는 형태로 다듬어서) 회신을 한다.

유스케이스

주방장이다.

핵심 비즈니스 로직을 담고 있는 부분이다. 핸들러가 정리해준 요청을 받아서 리포지토리 등의 도움을 받아 처리한 다음에 결과를 회신한다.

리포지토리

주방 보조이다. 리포지토리는 비즈니스 로직을 몰라야 한다. 데이터베이스에서 무언가를 가져오라면 가져오고, 저장하라면 저장을 할 뿐인 것이다.

유효성 검사

이러한 클린 아키텍처의 계층 중에서 핸들러에서 해야할 중요한 역할이 유효성 검사이다. go-playground/validator  패키지를 이용해 요청의 파라미터들을 검증해보자.

validator 등록

echo.Validator 는 인터페이스 타입의 필드이다. Validate 메서드만 구현되어 있으면 된다.

// github.com/labstack/echo
type Validator interface {
	Validate(i interface{}) error
}

이를 위한 헬퍼 패키지인 validatorutil을 만들었다.

CustomValidator 가 Validate 메서드가 구현되어 있는 것을 볼 수 있다. echo.Validator 필드에 넣을 수 있는 것이다.

// ./pkg/validatorutil/validator.go
// Package valiecho provides a validator for Echo framework using go-playground/validator
package validatorutil

import (
	"github.com/go-playground/validator/v10"
)

// CustomValidator implements Echo's Validator interface using go-playground/validator
type CustomValidator struct {
	validator *validator.Validate
}

// NewValidator creates a new instance of CustomValidator
func NewValidator() *CustomValidator {
	return &CustomValidator{validator: validator.New()}
}

// Validate implements the Echo Validator interface
func (cv *CustomValidator) Validate(i interface{}) error {
	return cv.validator.Struct(i)
}

echo.Validator 에 설정을 해주는 코드는 앞에 넣어두었다. 미들웨어는 아니지만 작은 코드이고 초기 설정이기에 큰 무리가 없다고 보았다.

// ./internal/middlewares/middlewares.go
func RegisterMiddlewares(cfg *config.Config, logger *slog.Logger, e *echo.Echo) {

	// ✅ Validator: 요청 바인딩 및 유효성 검사
	e.Validator = validatorutil.NewValidator()
	
	// 후략
}

파라미터의 파싱과 유효성 조건의 설정

echo 프레임워크는 요청의 request body, query, path param, form, header 등에서 파라미터를 간단히 파싱하여 구조체에 담을 수 있다. 구조체의 필드에는 validate 태그에go-playground/validator 가 지원하는 각종 제약을 정의할 수 있다. 예시를 보자.

Request body

request body 의 필드는 json 태그를 사용하면 된다.

validate 태그에는 필수 여부(required), 최소와 최대값, email 형식등의 유효성 검사 조건들이 정의되어 있다.

type UserSignupRequest struct {
    Name     string `json:"name" validate:"required,min=2,max=100"`
    Email    string `json:"email" validate:"required,email"`
    Age      int    `json:"age" validate:"gte=0,lte=130"`
    Password string `json:"password" validate:"required,min=8"`
}

핸들러에서는 c.Bind만 이용하면 요청의 바디에서 값을 추출하여 구조체에 넣어준다. 그리고, c.Validate로 구조체의 유효성을 검증하면 된다.

func SignupHandler(c echo.Context) error {
    var req UserSignupRequest
    if err := c.Bind(&req); err != nil {
        return c.JSON(http.StatusBadRequest, echo.Map{
            "error": "invalid request body",
        })
    }

    if err := c.Validate(&req); err != nil {
        return c.JSON(http.StatusBadRequest, echo.Map{
            "error": "validation failed",
            "detail": err.Error(),
        })
    }

    // 정상 처리 로직
    return c.JSON(http.StatusOK, echo.Map{
        "message": "signup successful",
    })
}

Query

다음과 같이 질의 요청이 들어왔다고 하자. 쿼리 파라미터는 keyword, limit 이다.

GET /search?keyword=echo&limit=10

다음과 같이 질의를 담을 구조체를 정의한다. 태그명을 query로만 해주면 된다. validate 는 마찬가지로 필수 여부, 숫자의 범위 등의 유효성 검사의 기준을 정의해주면 된다. c.Bind를 이용해 쿼리 파라미터를 추출하여 구조체에 넣고 c.Validate로 ``그 구조체를 검증하는 것은 동일하다.

type SearchQuery struct {
    Keyword string `query:"keyword" validate:"required"`
    Limit   int    `query:"limit" validate:"gte=1,lte=100"`
}

func SearchHandler(c echo.Context) error {
    var query SearchQuery
    if err := c.Bind(&query); err != nil {
        return c.JSON(http.StatusBadRequest, echo.Map{"error": "invalid query"})
    }
    
    if err := c.Validate(&req); err != nil {
        return c.JSON(http.StatusBadRequest, echo.Map{
            "error": "validation failed",
            "detail": err.Error(),
        })
    }
    
    return c.JSON(http.StatusOK, query)
}

Path, Form, Header

마찬가지로 태그만 잘 명시해주면 echo 에서 알아서 파싱을 해준다.

type UserPathParam struct {
    ID int `param:"id" validate:"required"`
}

type LoginForm struct {
    Username string `form:"username" validate:"required"`
    Password string `form:"password" validate:"required"`
}

type AuthHeader struct {
    AuthToken string `header:"Authorization" validate:"required"`
}

섞어서 사용하기 - Path + Query + Header

이런 식으로 c.Bind 하나로 다양한 파라미터 소스를 구조체에 매핑할 수 있어서 핸들러 코드가 훨씬 깔끔해진다. validate 태그를 함께 쓰면 유효성 검사까지 일관되게 처리한다.

type MixedRequest struct {
    ArticleID int    `param:"id" validate:"required"`
    Lang      string `query:"lang" validate:"omitempty,oneof=ko en jp"`
    UserID    int    `header:"X-User-Id" validate:"required"`
}

func ArticleHandler(c echo.Context) error {
    var req MixedRequest
    if err := c.Bind(&req); err != nil {
        return c.JSON(http.StatusBadRequest, echo.Map{"error": "invalid request"})
    }
    if err := c.Validate(&req); err != nil {
        return c.JSON(http.StatusBadRequest, echo.Map{
            "error": "validation failed",
            "detail": err.Error(),
        })
    }
    return c.JSON(http.StatusOK, req)
}

gocore 에의 적용

  1. request body, query, path param, form, header 등에서 가져오는 법
    1. 실제 예시는 body, path param, query 정도만 하자
  2. 각 핸들러에서 custom validator 넣어주는 법

이제 validator를 실제 사용한 사례를 보자. 일부 코드에만 적용하였음을 미리 알려둔다.

SignUpUser

SignUpUser 메서드는 새로이 가입을 하려는 요청에 대한 처리를 한다.

  1. domain.SignUpRequest 인스턴스를 생성하고 c.Bind 메서드로 요청에서 필요한 파라미터를 파싱한다.
  2. c.Validate 메서드로 유효성 검증을한다.
// ./internal/handler/authHandler.go
func (h *AuthHandler) SignUpUser(c echo.Context) error {
	req := new(domain.SignUpRequest)
	if err := c.Bind(&req); err != nil {
		return c.JSON(http.StatusBadRequest, ErrResponse(domain.ErrInvalidInput))
	}

	if err := c.Validate(req); err != nil {
		return c.JSON(http.StatusBadRequest, ErrResponse(domain.ErrInvalidInput))
	}
// 후략

요청의 파라미터를 담는 domain.SignUpRequest 구조체를 보자. 가입을 원하는 경우이니 다음 조건을 만족해야 한다.

  • required: 이메일과 비밀번호를 필수로 받아야 한다.
  • email: email 형식이 맞는지 확인한다.
  • min=8: 비밀번호는 최소한 8자 이상이어야 한다.
// ./internal/domain/auth.go
type SignUpRequest struct {
	Email    string `json:"email" validate:"required,email"`
	Password string `json:"password" validate:"required,min=8"`
}

이에 대한 테스트 코드는 클라이언트와의 소통에 대한 문서의 역할을 겸해 중요한 역할을 해준다. 테이블 테스트 케이스 부분만을 참고 공유한다. 다양한 실패 케이스들을 반영한 것을 볼 수 있다. LLM에게 가능한 경우를 모두 테스트해달라고 요청하고 생성한 결과를 검토해보길 추천한다.

// ./internal/handler/authHandler_test.go
// TestSignUpRequestValidation 테스트는 SignUpRequest 구조체의 유효성 검사 태그를 확인합니다
func TestSignUpRequestValidation(t *testing.T) {
	tests := []struct {
		name          string
		requestBody   string
		expectedCode  int
		expectedError bool
	}{
		{
			name:          "Valid Request",
			requestBody:   `{"email":"test@example.com","password":"password123"}`,
			expectedCode:  http.StatusCreated,
			expectedError: false,
		},
		{
			name:          "Empty Email",
			requestBody:   `{"email":"","password":"password123"}`,
			expectedCode:  http.StatusBadRequest,
			expectedError: true,
		},
		{
			name:          "Invalid Email Format",
			requestBody:   `{"email":"invalid-email","password":"password123"}`,
			expectedCode:  http.StatusBadRequest,
			expectedError: true,
		},
		{
			name:          "Empty Password",
			requestBody:   `{"email":"test@example.com","password":""}`,
			expectedCode:  http.StatusBadRequest,
			expectedError: true,
		},
		{
			name:          "Password Too Short",
			requestBody:   `{"email":"test@example.com","password":"short"}`,
			expectedCode:  http.StatusBadRequest,
			expectedError: true,
		},
		{
			name:          "Missing Email Field",
			requestBody:   `{"password":"password123"}`,
			expectedCode:  http.StatusBadRequest,
			expectedError: true,
		},
		{
			name:          "Missing Password Field",
			requestBody:   `{"email":"test@example.com"}`,
			expectedCode:  http.StatusBadRequest,
			expectedError: true,
		},
		{
			name:          "Malformed JSON",
			requestBody:   `{"email":"test@example.com","password":}`,
			expectedCode:  http.StatusBadRequest,
			expectedError: true,
		},
	}
	// 후략

커스텀 유효성 검사

때로는 validator의 tag를 넘어서서, 비즈니스 요구사항까지 감안한 유효성 검사를 해야할 경우가 있다. 이 경우에는 요청의 파라미터에 대한 Validate 메서드를 만들어서 해결할 수 있다.

여기서는 이메일에 대하여 hotmail.com 이메일은 쓸 수 없다고 해보자.

  1. SignUpRequest 구조체의 Validate 메서드는 echo.Context를 파라미터로 받는다.
  2. c.Validate 를 이용하여 validate 태그에 대한 유효성 검사부터 해준다.
  3. 그리고 나서, hotmail.com 이메일인지를 검증하는 코드를 추가한다.
// ./internal/domain/auth.go
type SignUpRequest struct {
	Email    string `json:"email" validate:"required,email"`
	Password string `json:"password" validate:"required,min=8"`
}

// Validate는 기본 validator 이후에 실행될 커스텀 유효성 검사를 수행합니다
func (r *SignUpRequest) Validate(c echo.Context) error {
	// 기본 validation 태그 검증 (required, email, min 등)
	if err := c.Validate(r); err != nil {
		return err
	}

	// 추가적인 email 도메인 검증. hotmail.com 도메인은 허용하지 않음
	if strings.HasSuffix(r.Email, "@hotmail.com") {
		return errors.New("restricted email domain")
	}

	return nil
}

핸들러에서의 실제 사용은 간단하다. c.Validate를 바로 사용하는 대신에 SignUpRequest 구조체의 Validate 메서드를 사용하면 된다.

// ./internal/handler/authHandler.go
func (h *AuthHandler) SignUpUser(c echo.Context) error {
	req := new(domain.SignUpRequest)
	if err := c.Bind(&req); err != nil {
		return c.JSON(http.StatusBadRequest, ErrResponse(domain.ErrInvalidInput))
	}

	if err := req.Validate(c); err != nil {
		return c.JSON(http.StatusBadRequest, ErrResponse(domain.ErrInvalidInput))
	}
	// 후략

마치며

유효성 검사는 실제 프로덕션 배포시에 많은 문제들을 잡아내주는 유용한 수단이다. 유효성 검사에 대한 테스트 코드는 요청의 파라미터에 대한 생생한 문서의 역할까지 해준다. LLM을 이용하면 미처 생각하지 못한 다양한 엣지 케이스에 대한 테스트 코드를 쉽게 짤 수 있다.

다음 포스팅에서는 프로덕션에서 중요하고도 유용한 데이터베이스 마이그레이션을 goose 라이브러리를 이용하여 알아보겠다.

반응형
반응형
잡학툰 뱃지
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
«   2025/04   »
1 2 3 4 5
6 7 8 9 10 11 12
13 14 15 16 17 18 19
20 21 22 23 24 25 26
27 28 29 30
글 보관함