메뉴

문서정보

목차

개요

Go 언어를 이용해서 백앤드 애플리케이션을 개발한다. 이 애플리케이션은 유저정보를 저장하고, 조회하는 2개의 REST API를 제공한다. 이 애플리케이션을 구조적으로 견고하고 확장가능한 형태로 만들어보려 한다.

제안하는 구조

아이디어는 Robert C. Martin의 The Clean Architecture에서 가져왔다. 이 문서에서는 클린 아키텍처의 제약 조건을 아래와 같이 설정하고 있다.
  1. 프레임워크와 무관하다. 아키텍처는 특정 소프트웨어 라이브러리에 의존하지 않는다. 특정 시스템에 제한된 제약 조건을 두지 않고, 다양한 프레임워크를 도구로 사용 할 수 있어야 한다.
  2. 비즈니스 규칙은 UI, 데이터베이스, 웹 서버 또는 기타 외부 요소 없이 테스트 할 수 있어야 한다.
  3. UI와 무관하다. UI는 시스템의 나머지 부분을 변경하지 않고도 쉽게 변경할 수 있다. 예를 들어 비즈니스 규칙을 변경하지 않고도 웹 UI를 콘솔 UI로 교체할 수 있어야 한다.
  4. 데이터베이스에 독립적. Oracle을 SQL Server, MySQL Server, MongoDB, BigTable, CouchDB 등의 다른 것으로 교체할 수 있어야 한다. 비즈니스 로직이 데이터베이스에 바인딩되면 안될 것이다.
  5. 비즈니스 로직은 외부 세계와 무관하게 작동할 수 있어야 한다.
이러한 요구사항은 결과적으로 애플리케이션을 여러 계층으로 나누게 한다. 각 계층은 다른 계층과 독립적이어야 하는데, 이는 독립적으로 구현하고 독립적으로 테스트가 가능해야 함을 의미한다. Martin의 아키텍처는 4개의 레이어로 구성된다.

 4레이어

  1. Entities
  2. Use Cases
  3. Controllers
  4. Devices
각 동심원은 소프트웨어 영역을 나타낸다. 원의 바깥쪽에는 사용자가 자리잡는다.

이 아키텍처의 중요 규칙은 종속성 규칙이다. 이 규칙은 코드 종속성이 안쪽만을 가리키도록 한다. 예를 들어 Use Cases는 어떤 Controller가 사용될지, 이 Controller가 어떤 일을 할지 알 필요도 없고 알아서도 안된다. Use Case는 Entities 만 알고 있으면 된다.

우리는 아래와 같이 4개의 레이어를 가지도록 구조화 했다.
  1. Model
  2. Repository
  3. Use Case
  4. Delivery
 GoLang 애플리케이션 구조

Model

모델은 엔티티와 동일하며 모든 레이어에서 사용한다. 여기에는 Struct와 메서드를 저장한다. 모델의 예로는 서비스 사용자, 쇼핑몰 상품, 블로그 컨텐츠 등이다. 블로그 컨텐츠는 아래와 같은 모델을 가질 것이다.
type Article struct {
	ID        int64     `json:"id"`
	Title     string    `json:"title"`
	Content   string    `json:"content"`
	UpdatedAt time.Time `json:"updated_at"`
	CreatedAt time.Time `json:"created_at"`
}
모델은 비즈니스 규칙을 담는 객체로 애플리케이션의 가장 밑바닥에 위치하며, 거의 변하지 않는다. 애플리케이션 기능이 변경된다고 해서, 혹은 디바이스가 추가된다고 해서(모바일 애플리케이션이 추가 된다거나) 변하지 않는다. 기능이 변경될 때마다 엔티티가 변하면 설계에 문제가 있는 건 아닌지 검토를 해봐야 한다.

Repository

많은 언어들이 자원(Resource)에 대한 리포지토리를 만든다. 이 리포지토리는 데이터를 관리한다. 즉 자원을 저장하고 검색하는 역할을 한다. 우리가 다룰 시나리오에서는 "사용자"가 객체가 된다.

이 예제의 경우 Repository는 Article의 저장, 읽기, 검색, 업데이트 등의 작업을 수행한다. Repository는 데이터의 CRUD(쓰기,읽기,업데이트,삭제) 작업만 할 뿐 비즈니스 로직을 가지지 않는다. 즉 데이터베이스의 일반적인 기능만을 담당한다.

Repository 계층은 비즈니스로직과 데이터베이스를 분리하는 역할을 한다. 즉 MySQL, MongoDB, DynamoDB, Postgresql 등 무엇을 사용할지가 여기에서 결정된다. 결정된 데이터베이스에 대한 일반적인 기능들을 제공하기 때문에 비즈니스 영역은 데이터베이스를 신경쓸 필요가 없다.

Use Case

이 영역에서 비즈니스 프로세스를 처리한다. REST API 서비스의 경우, 사용자 요청이 여기에서 처리된다. 이 계층은 사용할 Repository를 결정하고, Repository의 기능을 호출해서 비즈니스 데이터를 제공하고 처리한다. Repository로 부터 읽은 데이터를 계산하는 모든 작업이 여기에서 수행된다.

이 예제에서 Use Case는 사용자가 원하는 Article를 찾아서 제공하거나 새로운 Article를 저장하는 등의 작업을 수행 한다.

Delivery

프리젠테이션을 담당하는 영역으로 데이터가 표시되는 방식을 결정한다. 데이터를 REST API, HTML, gRPC 형태로 프리젠테이션한다. 이 영역은 사용자의 요청을 일선에서 받아내는 역할을 하는데, 데이터가 어떤 형식이든지 Use Case가 사용할 수 있는 형식으로 변환해서 전송한다. 따라서 Use Case는 입/출력 데이터의 형식에 상관없이 데이터만 처리 할 수 있다.

하지만 보통의 웹 애플리케이션들은 REST API, 데이터 입출력은 JSON으로 통일하는 경우가 많다.

레이어간 통신

이들 4개의 레이어는 아래와 같은 구성을 가질 것이다.

 레이어 구조

화살표는 요청의 흐름이다. 화살표는 바깥에서 안쪽으로 향하고 있는데, 이는
  1. 안쪽에 있는 레이어는 바깥쪽의 레이어를 알 필요가 없으며
  2. 바깥쪽의 레이어는 바로 안쪽에 있는 레이어만 알고 있으며 안쪽레이어에서 제공하는 인터페이스로 접근
함을 의미한다.

예를 들어 Repository는 Repository를 사용하는 상위 레이어를 위해서 아래와 같은 인터페이스를 제공 할 것이다.
package repository

import "joinc.co.kr/clean-arch/model"

type ArticleRepository interface {
	GetByID(id int64) (*model.Article, error)
	GetByTitle(title string) (*model.Article, error)
	Update(article *model.Article) (*model.Article, error)
	Delete(id int64) (bool, error)
}
Use Case 레이어는 이 인터페이스를 이용해서 Repository와 커뮤니케이션 한다. 이를 위해서 반드시 구현을 해야 한다.

Use Case 도 인터페이스로 구축 할 것인지 하는 것은 고민이 필요하다. 이론상 Use case도 인터페이스로 만들어 두면, Delivery 레이어와 완전히 분리가 가능하긴 하다. 예를 들어 gPRC를 이용한 모바일 앱, REST API를 이용한 웹 애플리케이션 등 다양한 디바이스로의 대응이 가능하다. 하지만 일반적인 REST API로만 서비스 한다면, 인터페이스를 구축할 필요는 없을 것이다. 일단 직접 구현하고 나중에 필요 할 때, 인터페이스로 쪼개면 된다.

레이어별 테스트

깔끔한 구조라는 것은 독립되어 있다는 것을 의미한데. 독립에는 테스트도 포함된다.

Models 레이어

Struct에 선언된 메서드가 있는 경우에만 테스트한다. 이를 JSON이나 XML로 변환거나, 검증하는 메서드들을 가질 수 있는데, 이럴때 테스트를 작성 할 수 있을 것이다. 다른 레이어와 독립적으로 테스트 할 수 있다.

Repository 레이어

데이터베이스를 다루는 영역이기 때문에, 다른 레이어와의 통합테스트를 수행하는 것이 더 좋다. github.com/DATA-DOG/go-sqlmock 등의 mock를 이용한 테스트도 있다.

Use case 레이어

Use case는 Repository 레이어에 의존한다. Mockery와 같은 mockup 툴을 이용하여 Repository 레이어가 완성되기 전에 Use case를 완성 할 수 있다.

Delivery 레이어

http REST API를 사용하는 경우 golang의 httptest 패키지를 이용해서 테스트를 할 수 있다. 여전히 Use case에 따라서 Mockery를 사용해야 할 수 있다.

연습

실제 코드를 만들어보자. 코드 스트럭처는 아래와 같다. 전체코드는 github에서 다운로드 할 수 있다.
.
├── article
│   ├── delivery
│   │   └── http
│   │       └── article_handler.go
│   ├── repository
│   │   └── mysql
│   │       └── article_repository.go
│   └── usecase
│       └── article_usecase.go
├── domain
│   └── article.go
├── go.mod
├── go.sum
├── handler
│   └── handler.go
├── main.go
└── repository
    ├── mongo.go
    ├── mysql.go
    └── repository.go
Mysql 데이터베이스를 저장소로 이용하는 REST API 프로그램이다. 하지만 시간을 아끼기 위해서 실제 MySQL 연결은 하지 않았다.

프로젝트 초기화

go mod init 명령을 이용해서 프로젝트를 초기화 한다. 초기화 결과 의존성 정보를 담고 있는 go.mod 파일이 만들어진다.

Domain 설정

domain에는 인터페이스가 들어간다.
package domain

type Article struct {
	Title     string `json:"title"`
	Body      string `json:"body"`
	UserName  string `json:"userName"`
	CreatedAt string `json:"createdAt"`
	UpdatedAt string `json:"updatedAt"`
}

type ArticleRepository interface {
	GetByID(id int64) (*Article, error)
	Fetch(offset, limit int) ([]*Article, error)
	Create(article *Article) (*Article, error)
	Update(id int64, article *Article) (*Article, error)
	Delete(id int64) error
}

type ArticleUseCase interface {
	GetByID(id int64) (res *Article, err error)
	Fetch(offset, limit int) (res []*Article, err error)
	Create(article *Article) (*Article, error)
	Update(id int64, article *Article) (*Article, error)
	Delete(id int64) error
}

Repository 구현

레이어의 가장 안쪽 부터 차례대로 구현 할 것이다. 가장 안쪽에는 데이터 모델을 포함한 모델과 인터페이스가 존재한다. 이제 모델을 이용해서 데이터를 읽고/쓰는 Repository를 구현한다.
package mysql

import (
	"fmt"

	model "joinc.co.kr/study/myserver/domain"
)

func NewMySQLRepository() (*MySQLRepository, error) {
	return &MySQLRepository{DBEngine: "mysql"}, nil
}

type MySQLRepository struct {
	DBEngine string
}

func (m *MySQLRepository) GetByID(id int64) (*model.Article, error) {
	fmt.Println(">> ", m.DBEngine)
	return &model.Article{
		Title:    "MySQL 사용법",
		Body:     "MySQL은 쉽게 사용 할 수 있습니다.\n정말로",
		UserName: "yundream",
	}, nil
}

func (m *MySQLRepository) Fetch(offset, limit int) ([]*model.Article, error) {
	fmt.Println(">> ", m.DBEngine)
	return []*model.Article{
		{
			Title:    "MySQL 사용법",
			Body:     "MySQL은 쉽게 사용 할 수 있습니다.\n정말로",
			UserName: "yundream",
		},
		{
			Title:    "GoLang의 미래",
			Body:     "GoLang의 미래는 밝아보입니다....",
			UserName: "yundream",
		},
	}, nil
}

func (m *MySQLRepository) Create(article *model.Article) (*model.Article, error) {
	return article, nil
}

func (m *MySQLRepository) Update(id int64, article *model.Article) (*model.Article, error) {
	return &model.Article{Title: "Update"}, nil
}

func (m *MySQLRepository) Delete(id int64) error {
	fmt.Println("삭제")
	return nil
}
이 문서는 소프트웨어 구조를 잡는 것을 목표로 한다. 데이터베이스까지 구현하면 버거로울 것 같아서, 실제 MySQL 연결을 넣지 않았다. 위 코드는 MySQL을 저장소로 사용하는 Repository 구현체다. Repository 인터페이스의 모든 메서드를 구현한다.

저장소로 MongoDB도 사용해야 한다면, repository/mongo 밑에 mongodb 구현을 만들면 된다.

Use Case 구현

이제 MySQL 저장소로 부터, 데이터를 읽어와서 처리하는 Use Case를 구현 할 것이다. 마찬가지로 실제 비즈니스로직은 포함하지 않는다.
package usecase

import "joinc.co.kr/study/myserver/domain"

type articleUsecase struct {
	articleRepo domain.ArticleRepository
}

func NewArticleUseCase(a domain.ArticleRepository) domain.ArticleUseCase {
	return &articleUsecase{
		articleRepo: a,
	}
}

func (a *articleUsecase) GetByID(id int64) (*domain.Article, error) {
	res, err := a.articleRepo.GetByID(id)
	return res, err
}
func (a *articleUsecase) Fetch(offset, limit int) ([]*domain.Article, error) {
	return a.articleRepo.Fetch(offset, limit)
}

func (a *articleUsecase) Create(article *domain.Article) (*domain.Article, error) {
	return a.articleRepo.Create(article)
}

func (a *articleUsecase) Update(id int64, article *domain.Article) (*domain.Article, error) {
	return a.articleRepo.Update(id, article)
}

func (a *articleUsecase) Delete(id int64) error {
	return a.articleRepo.Delete(id)
}

Repository와는 Article Model로 데이터를 주고 받기 때문에, Repository가 어떤 데이터베이스를 사용하고 있는지에 상관없이 같은 코드를 유지를 할 수 있다.

Delivery

이제 Handler를 개발한다. 이 예제에서는 간단한 gorilla 프레임워크를 이용했다. 역시 일부만 구현했다.
package http

import (
	"encoding/json"
	"fmt"
	"net/http"
	"strconv"

	"github.com/gorilla/mux"
	"joinc.co.kr/study/myserver/domain"
)

type ArticleHandler struct {
	AUseCase domain.ArticleUseCase
}

func NewArticleHandler(router *mux.Router, us domain.ArticleUseCase) {
	handler := ArticleHandler{
		AUseCase: us,
	}
	router.HandleFunc("/article", handler.FetchArticle).Methods("GET")
	router.HandleFunc("/article/{id}", handler.GetArticle).Methods("GET")
}

func (a *ArticleHandler) FetchArticle(w http.ResponseWriter, r *http.Request) {
	articles, _ := a.AUseCase.Fetch(0, 0)
	response, _ := json.Marshal(articles)
	fmt.Fprint(w, string(response))
}

func (a *ArticleHandler) GetArticle(w http.ResponseWriter, r *http.Request) {
	vars := mux.Vars(r)
	id, _ := strconv.ParseInt(vars["id"], 10, 16)
	article, _ := a.AUseCase.GetByID(id)
	response, _ := json.Marshal(article)
	fmt.Fprint(w, string(response))
}

NewArticleHandler는 mux.Router를 설정한다. Handler는 main 함수가 호출하는데, main 함수에서 mux.Router를 이용해서 http Server를 실행하면 된다.

main

이제 main 함수를 만들면 끝이다.
package main

import (
	"log"
	"net/http"
	"os"

	"github.com/gorilla/mux"
	_articleDelivery "joinc.co.kr/study/myserver/article/delivery/http"
	"joinc.co.kr/study/myserver/article/repository/mysql"
	_articleUsecase "joinc.co.kr/study/myserver/article/usecase"
)

func main() {
	router := mux.NewRouter()
	db, err := mysql.NewMySQLRepository()
	if err != nil {
		os.Exit(1)
	}
	au := _articleUsecase.NewArticleUseCase(db)
	_articleDelivery.NewArticleHandler(router, au)
	log.Fatal(http.ListenAndServe(":8080", router))
}
main 함수가 하는 일은 간단하다.

정리

해 볼 것들

참고