이 글은 Jon Calhoun의 Using the Service Object Pattern in Go를 번역한 글입니다. 오역, 오타 등 고칠내용이 있다면 댓글부탁드립니다. 🙏

이것은 실험적 내용입니다.
이 글에 나오는 대부분의 코드와 아이디어는 제가 실험한 내용입니다. 그렇다고 아이디어와 글의 내용이 가치가 없다는 것은 아닙니다. 하지만 당신이 맹목적으로 이 패턴을 따라해서도 안 됩니다. 이 패턴만의 장단점이 존재하고 상황에 따라 고려돼야 합니다. 즉, 이 패턴은 저에게 매우 잘 사용되고 있으며, 이 글에서 살펴볼 내용인 데이터 파싱로직을 애플리케이션 로직에서 분리하는 것은 다양한 포맷(HTML, JSON API)을 지원하는 웹 애플리케이션을 만드는데 중요한 단계입니다.

우리는 모두 다음과 같은 핸들러 함수를 Go로 작성한 웹 애플리케이션에서 본 적이 있을 것입니다.

type WidgetHandler struct {
    DB *sql.DB
    // Renders the HTML page w/ a form to create a widget
    CreateTemplate *template.Template
    // Renders a list of widgets in an HTML page
    ShowTemplate *template.Template
}

func (handler *WidgetHandler) Create(w http.ResponseWriter, r *http.Request) {
    // Most HTML based web apps will use cookies for sessions
    cookie, err := r.Cookie("remember_token")
    if err != nil {
        http.Redirect(w, r, "/login", http.StatusFound)
        return
    }
    // Hash the value since we store remember token hashes in our db
    rememberHash := hash(cookie.Value)
    // Then look up the user in the database by hashed their remember token
    var user User
    row := handler.DB.QueryRow(`SELECT id, email FROM users WHERE remember_hash=$1`, rememberHash)
    err = row.Scan(&user.ID, &user.Email)
    if err != nil {
        http.Redirect(w, r, "/login", http.StatusFound)
        return
    }

    // From here on we can assume we have a user and move on to processing
    // the request
    var widget Widget
    widget.UserID = user.ID
    err = r.ParseForm()
    // TODO: handle the error
    widget.Name = r.FormValue("name")
    // postgres specific SQL
    const insertWidgetSql = `
INSERT INTO widgets (user_id, name) 
VALUES ($1, $2) 
RETURNING id`
    err = handler.DB.QueryRow(insertWidgetSql, widget.UserID, widget.Name).Scan(&widget.ID)
    if err != nil {
        // Render the error to the user and the create page
        w.Header().Set("Content-Type", "text/html")
        handler.CreateTemplate.Execute(w, map[string]interface{}{
            "Error":  "Failed to create the widget...",
            "Widget": widget,
        })
        return
    }
  // Redirect the user to the widget
  http.Redirect(w, r, fmt.Sprintf("/widgets/%d", widget.ID), http.StatusFound)
}

정확한 세부사항들은 아마 다를 것입니다. 예를 들어 애플리케이션이 다른 데이터베이스를 사용할 수도 있고, 직접 SQL을 작성하는 대신 UserServicewidgetSevice 인터페이스를 만들 수도 있으며, echo와 같은 프레임워크를 사용할 수도 있습니다. 하지만 대략적인 코드의 내용은 비슷 할 것입니다. 핸들러의 처음 몇 줄은 데이터를 파싱하는데 사용될 것이고, 그리고나서 실제로 애플리케이션에서 원하는 작업을 한 후 마침내 결과나 에러를 렌더링 할 것입니다.

Handlers are a data parsing and rendering layer

위 코드로 다시 돌아가 보면, 얼마나 많은 코드가 그저 파싱과 렌더링만을 한다는 것에 놀랄 것입니다. 쿠키를 찾아오는 섹션은 그저 토큰을 얻어오거나 에러가 있는 사용자를 리다이렉트 하는데 사용됩니다. 우리는 토큰으로 데이터베이스를 탐색하지만, 곧이어 에러 핸들링 로직과 렌더링 로직이 따라옵니다. 그리고 폼을 파싱하고, 위젯의 이름을 알아온 후 위젯을 생성하면서 발생된 에러를 렌더링합니다. 마침내 우리는 새로운 위젯을 만든 유저를 리다이렉트 시킬 수 있습니다. 하지만, 곰곰히 생각해보면 리다이렉트도 근본적으론 그저 렌더링 로직일 뿐입니다.

대체로 우리 코드의 60%는 그저 데이터를 파싱하고, 결과나 에러를 렌더링합니다.

데이터를 파싱한다는 것이 본질적으로 나쁜것은 아니지만, 데이터를 파싱하기에 앞서 제가 불안한 이유는 요구사항이 불분명하다는 것입니다. 생각해보세요. 제가 만약 아래와 같은 함수 정의를 당신에게 건내주고 테스트를 요청한다면, 저 함수가 어떤 데이터를 요구하는지 알 수 있을까요?

func (handler *WidgetHandler) Create(w http.ResponseWriter, r *http.Request)

당신은 아마 WidgetHandler라는 타입과 Create라는 함수 이름을 통해 위젯을 만들기 위해 사용된다고 추론할 수 있고, 따라서 우리는 위젯에 대한 정보가 필요하다는 것도 알 수 있을 것입니다. 하지만 어떤 포맷의 데이터가 필요한지 알 수 있을까요? 사용자가 쿠키 기반 세션을 통해 로그인해야 한다는 것을 알 수 있을까요?

더 나쁜 소식은, WidgetHandler의 어떤 필드가 이 일을 위해서 인스턴스화 돼야하는지 추론할 수 없다는 것입니다. 만약 우리가 코드를 살펴본다면 DB 필드를 사용하고 에러를 렌더링하기 위해 CreateTemplate을 세팅해줘야 한다는 것을 명확하게 알 수 있지만, 그러기 위해선 모든 코드를 살펴보아야 합니다.

이 예시에서 우리가 사용하는 필드들이 명확하지만, WidgetHandler가 생성, 갱신, 발행과 같이 더 많은 동작을 수행한다고 상상해보세요. 그 경우 WidgetHandler 타입은 더 많은 필드를 갖게 될 것이고, 이 핸들러를 테스팅하기 위해 모든 필드를 세팅할 필요는 없을 것입니다.

들어오는 HTTP 요청에 대한 모호한 정의를 갖고, 데이터를 파싱하기위한 코드를 작성하지 않고서는 HTTP 서버를 만들 수 있는 실용적인 방법이 없기 때문에 핸들러 함수는 모호해야 합니다. 심지어 재사용가능한 미들웨어를 만들고 요청 컨텍스트를 사용하여 파싱한 데이터를 저장할지라도, 우리는 여전히 그러한 미들웨어를 작성하고 테스트해야 하고, 이는 핸들러 함수의 불분명한 데이터 요구로 인한 문제를 해결할 수 없습니다. 그렇다면 우리는 이 문제를 어떻게 해결해야할까요?

The servcie object pattern

핸들러 내부에서 데이터를 파싱해야한다는 사실과 싸우는 대신, 저는 그것들을 포용하고 핸들러를 더 엄격하게 데이터 파싱/렌더링 레이어로 만들어 더 잘 동작하도록 하는 방법을 찾았습니다. 즉, 핸들러 내부에서 데이터 파싱 또는 렌더링과 관련이 없는 로직을 피하고, 그 대신 Ruby의 Service Object 패턴과 유사한 패턴을 사용하는 것입니다.

실제로, 저는 가능한 한 데이터 렌더링을 핸들러에서 빼내려는 시도도 해봤습니다. Creating V in MVC에서 더 자세한 내용을 확인하실 수 있습니다.

이 패턴의 동작은 매우 단순합니다. 위젯 생성과 같은 작업을 하기 위해 핸들러 내부에 로직을 작성하는 대신, 그 코드를 밖으로 빼내어 명확한 데이터 요구를 갖게하고 테스트하기 쉽도록 만듭니다. 예를들어, 위젯 생성 예시에서 저는 이렇게 만들었을 것입니다.

func CreateWidget(db *sql.DB, userID int, name string) error {
  var widget Widget
  widget.Name = name
  widget.UserID = userID
    const insertWidgetSql = `
INSERT INTO widgets (user_id, name) 
VALUES ($1, $2) 
RETURNING id`
    err = db.QueryRow(insertWidgetSql, widget.UserID, widget.Name).Scan(&widget.ID)
    if err != nil {
    return err
  }
  return nil
}

이제 위젯을 만들기 위해 데이터베이스 커넥션, 위젯 ID, 위젯 이름이 필요하다는 것이 더 명확해졌습니다.

이 글의 상세한 요구사항을 모두 따라할 필요는 없습니다. 예를 들어, 저는 종종 상세한 userIDname을 인자로 받는 대신 UserWidget을 인자로 받는 함수를 만들곤 합니다. 선택은 당신에게 달렸습니다.

A more interesting example

이러한 특정 예시들은 다소 지루할 수 있으므로, 조금 더 흥미로운 예시들을 살펴보겠습니다. 사용자가 저희 애플리케이션에 가입을 했을 때 데이터베이스에 사용자를 추가하고, 환영 메일을 보내고, 메일링 리스트 도구에 그들을 추가하는 작업이 필요하다고 가정해봅시다. 전통적인 방식의 핸들러는 아마 이렇게 생겼을 것입니다.

func (handler *UserHandler) Signup(w http.ResponseWriter, r *http.Reqeust) {
  // 1. parse user data
  r.ParseForm()
  email = r.FormValue("email")
  password = r.FormValue("password")

  // 2. hash the pw and create the user, handling any errors
  hashedPw, err := handler.Hasher.Bcrypt(password)
  if err != nil {
    // ... handle this
  }
  var userID int
  err := handler.DB.QueryRow("INSERT INTO users .... RETURNING id", email, hashedPw).Scan(&userID)
  if err != nil {
    handler.SignupForm.Execute(...)
    return
  }

  // 3. Add the user to our mailing list
  err = handler.MailingService.Subscribe(email)
  if err != nil {
    // handle the error somehow
  }

  // 4. Send them a welcome email
  err = handler.Emailer.WelcomeUser(email)
  if err != nil {
    // handle the error
  }


  // 5. Finally redirect the user to their dashboard
  http.Redirect(...)
}

보시다시피, 꽤 많은 에러 핸들링 로직이 있고 각각의 if 블록 안에서 우리는 에러 페이지를 렌더링하고, 사용자를 로그인 페이지로 돌려보내는 등과 같은 작업을 필요로 할 것입니다. 또한, MailingService, SignupForm, Emailer, Hasher와 같이 핸들러의 몇몇 필드를 사용하게 되었고, 이 중 테스트를 목적으로 하는 것은 없습니다.

더 나쁜 소식은 핸들러의 개별적인 필드들을 테스팅하는 것이 다소 성가시다는 것입니다. 만약 엔드포인트가 호출됐을 때 데이터베이스에 사용자가 생성되는지 검증하고 싶다면, 우리는 여전히 다른 모든 필드들의 스텁을 만들어야 한다는 것입니다.

이와 같은 경우, 코드를 명확한 요구사항을 갖고있고 독립적인 테스트가 가능한 몇 개의 서비스 객체로 나누면 매우 유용합니다.

type UserCreator struct {
  DB *sql.DB
  Hasher
  Emailer
  MailingService
}

func (uc *UserCreator) Run(email, password string) (*User, error) {
  pwHash, err := uc.Hasher.BCrypt(password)
  if err != nil {
    return nil, err
  }
  user := User{
    Email: email,
  }
  row := uc.DB.QueryRow("INSERT INTO users .... RETURNING id", email, hashedPw)
  err = row.Scan(&user.ID)
  if err != nil {
    return nil, err
  }
  err = uc.MailingService.Subscribe(email)
  if err != nil {
    // log the error
  }
  err = uc.Emailer.WelcomeUser(email)
  if err != nil {
    // log the error
  }
  return &user, nil
}

이제 우리는 사용자 생성 코드를 쉽게 테스트할 수 있고, 의존성은 명확하며 HTTP 요청에 손댈 필요가 없습니다. 이것은 통상적인 Go 코드입니다.

우리는 또한 핸들러 코드의 단순화라는 부가적인 이점도 갖게 됩니다. 이제는 로깅만 하면 되는 심각하지 않은 오류들을 처리하는데 시간을 들이지 않아도 되고, 그저 데이터를 파싱하는데 집중할 수 있습니다.

type UserHandler struct {
  signup func(email, password string) (*User, error)
}

func (handler *UserHandler) Signup(w http.ResponseWriter, r *http.Reqeust) {
  // 1. parse user data
  r.ParseForm()
  email = r.FormValue("email")
  password = r.FormValue("password")
  user, err := handler.signup(email, password)
  if err != nil {
    // render an error
  }
  http.Redirect(...)
}

위 코드를 인스턴스화 하기 위해 우리는 이렇게 작성할 것입니다.

uc := &UserCreator{...}
uh := &UserHandler{signup: uc.Run}

그러고 나서 우리는 라우터에서 uh의 메서드를 HandlerFuncs로 마음껏 사용할 수 있을 것입니다.

More, but clearer code

이 방법은 분명히 더 많은 코드를 필요로합니다. 우리는 이제 UseCreator 타입을 세팅하고, 그것의 Run 함수를 UserHandlersignup 필드에 세팅해야하지만, 이러한 작업을 통해 우리는 함수들이 분명하게 분리된 역할을 갖게하고, 테스트를 훨씬 쉽게 만들어줍니다. 우리는 더 이상 핸들러를 테스트하기 위해 데이터베이스 커넥션을 필요로하지 않고, 다음과 같은 코드를 통해 테스트할 수 있습니다.

uh := &UserHandler{
  signup: func(email, password) (*User, error) {
    return &User{
      ID: 123,
      Email: email,
    }, nil
  }
}

마찬가지로, UserCreator 함수를 테스트할 때도 우리는 httptest 패키지를 사용할 필요가 없습니다. 야호! 🙌

마지막으로 우리가 후속 포스트에서 볼 수 있듯이(아직 작업 중입니다.), 이는 입출력 형식에 구애받지 않는 애플리케이션 작성의 문을 열어줍니다.
즉, 기존 웹 애플리케이션을 사용하면서 적은 노력으로 JSON API 지원을 추가할 수 있습니다.