import ( "코딩", "행복", "즐거움" )

테스트까지 작성하고 이해하는 레이어드 아키텍처 본문

소프트웨어 아키텍처

테스트까지 작성하고 이해하는 레이어드 아키텍처

더코드마니아 2023. 3. 13. 18:26

원문: https://zenn.dev/7oh/articles/6338a8ccd470c7

 

【Go】テストまで書いて理解するレイヤードアーキテクチャ

ご覧いただきありがとうございます。 個人でWEB・モバイル開発を行っているnanaoと申します。 ロービジョンのためスクリーンリーダーを使用してコードや記事を書いています。 スキル: Flutte

zenn.dev

 

Go 언어 + 레이어드 아키텍처로 테스트 코드가 포함된 REST API를 만들면서 많은 것을 배웠기 때문에 공유합니다.
이 글이 누군가에게 도움이 되었으면 좋겠습니다.
이번 샘플 코드의 최종 형태는 제 GitHub에 있습니다.

https://github.com/7oh2020/go-rest-api-example

 

GitHub - 7oh2020/go-rest-api-example: A example simple rest api

A example simple rest api. Contribute to 7oh2020/go-rest-api-example development by creating an account on GitHub.

github.com

 

**레이어드 아키텍처란?
레이어드 아키텍처는 시스템의 기능을 업무별 그룹(레이어)으로 나누어 인접한 레이어와만 상호 작용할 수 있도록 하는 구조입니다.
레이어 간의 의존 관계는 상위에서 하위로 한 방향으로 통일되어 있는 것이 특징입니다.
레이어 간에는 인터페이스로 느슨하게 결합되어 있기 때문에 의존성을 낮게 유지할 수 있다는 장점이 있습니다.

레이어의 종류는 일반적으로 다음과 같은 것들이 있습니다.

1 프레젠테이션 레이어: GUI나 CUI와 같은 사용자 인터페이스
2 애플리케이션 계층: 애플리케이션 로직
3 도메인 레이어: 도메인 로직
4 인프라 계층: 데이터 액세스, 네트워크 액세스 등 세부적인 구현


의존관계는 위에서 아래로 한 방향이며, 예를 들어 애플리케이션 계층은 도메인 계층에 의존한다.
애초에 왜 굳이 레이어로 나누어야 하는가? 라고 묻는다면, 다음과 같은 장점이 있기 때문입니다.

책임별로 그룹화되어 있기 때문에 추가 수정 시 어디를 수정해야 하는지 알기 쉽다.
종속관계가 일방향이므로 추가 수정 시 영향 범위를 쉽게 파악할 수 있습니다.
인터페이스로 추상화되어 있어 컴포넌트 재사용이 용이하다.
하위 레이어를 모킹할 수 있어 책임별 단위 테스트가 용이하다.

 

예를 들어, 레이어별로 나누지 않고 만들면 결합도가 높아져 애플리케이션을 위한 로직과 도메인 로직, 데이터베이스를 위한 로직이 뒤섞여 추가 수정 시 어디를 수정해야 하는지 알 수 없게 됩니다.

책임별 레이어로 나누면 코드의 가시성이 좋아지고, 로직이 늘어날 때에도 코드를 단순하게 유지할 수 있습니다.

다음 글에서는 실제 코드와 함께 간단한 사용자 관리 API를 만들어 보겠습니다.

 

**엔티티 생성하기
엔티티는 ID를 가진 가변적인 데이터 모델입니다.
각 데이터는 ID로 식별됩니다. 예를 들어, 동명이인 사용자가 있어도 ID가 다르면 다른 사용자로 취급할 수 있습니다.

이번에는 필드 선언만 했지만, 엔티티에 메소드를 추가하고 거기에 비즈니스 로직을 작성해도 좋을 것 같습니다.
또한, 구조체에 구조체를 삽입하여 복잡한 데이터 모델을 표현할 수도 있습니다.

 

 

package entity

import "time"

// 사용자 엔티티
type User struct {
	ID        uint      
	Name      string    
	CreatedAt time.Time 
	UpdatedAt time.Time 
}

func NewUser(id uint, name string, createdAt time.Time, updatedAt time.Time) *User {
	return &User{
		ID:        id,
		Name:      name,
		CreatedAt: createdAt,
		UpdatedAt: updatedAt,
	}
}

**리포지토리 생성
리포지토리는 엔티티를 파일이나 데이터베이스에 저장(영속화)하는 역할을 한다.

먼저 리포지토리의 인터페이스를 정의하고, 이를 구현하는 struct를 생성한다.
왜 인터페이스를 사용하는가 하면, 상위 계층은 저장소의 인터페이스만 참조하기 때문에 저장소 내부에서 어떤 DB를 사용하고 있는지, 어떤 SQL이나 ORM을 사용하고 있는지 등을 알 필요가 없기 때문이다.

예를 들어, 이번에는 SQL을 직접 작성하고 있지만, gorm이나 sqlboiler 등의 ORM 등으로 대체해도 전혀 문제가 없다.
즉, 인터페이스를 사용함으로써 레이어의 책임과 지식을 캡슐화할 수 있다는 뜻이네요.

리포지토리의 인터페이스는 다음과 같습니다.
데이터베이스에 저장할 때는 엔티티를 받고, 데이터베이스에서 가져올 때는 엔티티로 반환합니다.

 

package repository

import "go-rest-api-example/app/infrastructure/mysql/entity"

type IUserRepository interface {
	FindByID(id uint) (*entity.User, error)
	Create(user *entity.User) error
	Update(user *entity.User) error
	Delete(id uint) error
}

아래는 리포지토리 구현입니다.

 

package mysql

import (
	"database/sql"
	"go-rest-api-example/app/domain/repository"
	"go-rest-api-example/app/infrastructure/mysql/entity"
)

type UserRepository struct {
	*sql.DB
}

// データベースのコネクションを外側から渡せるようにすることでテストが容易になります。
//
// また、関数の戻り値をインタフェース型にして構造体をreturnすると型チェックが行われます。
// 構造体がインタフェースを満たしていない場合はコンパイルエラーになるのですぐに気付けて便利です。
func NewUserRepository(db *sql.DB) repository.IUserRepository {
	return &UserRepository{db}
}

func (r *UserRepository) FindByID(id uint) (*entity.User, error) {
	stmt, err := r.Prepare(`SELECT id, name, created_at, updated_at FROM users WHERE id = ?`)
	if err != nil {
		return nil, err
	}
	defer stmt.Close()

	user := &entity.User{}
	err = stmt.QueryRow(id).Scan(&user.ID, &user.Name, &user.CreatedAt, &user.UpdatedAt)
	if err != nil {
		return nil, err
	}

	return user, nil
}

func (r *UserRepository) Create(user *entity.User) error {
	stmt, err := r.Prepare(`INSERT INTO users(name, created_at) VALUES (?, ?)`)
	if err != nil {
		return err
	}
	defer stmt.Close()

	if _, err := stmt.Exec(user.Name, user.CreatedAt); err != nil {
		return err
	}
	return nil
}

func (r *UserRepository) Update(user *entity.User) error {
	stmt, err := r.Prepare(`UPDATE users SET name = ?, updated_at = ? WHERE id = ?`)
	if err != nil {
		return err
	}
	defer stmt.Close()

	if _, err := stmt.Exec(user.Name, user.UpdatedAt, user.ID); err != nil {
		return err
	}

	return nil
}

func (r *UserRepository) Delete(id uint) error {
	stmt, err := r.Prepare(`DELETE FROM users WHERE id = ?`)
	if err != nil {
		return err
	}
	defer stmt.Close()

	if _, err := stmt.Exec(id); err != nil {
		return err
	}

	return nil

}

**저장소 테스트
리포지토리 구현이 어느 정도 완료되면, 리포지토리에서 발행되는 SQL이 타당한지, 전달되는 값에 누락이 없는지, COMMIT이나 ROLLBACK 등이 예상한 순서대로 호출되는지 등을 테스트한다.
sqlmock을 사용하면 데이터베이스를 구동하지 않고도 SQL이나 입출력 데이터를 테스트할 수 있어 편리하다.

!
레이어 단위의 테스트에서는 데이터베이스 연결이나 컨텍스트 등의 의존 객체를 상위에서 전달할 수 있도록 하면 테스트의 전제조건을 맞추기 쉬워져 편리하다.
특히 현재 시간, 난수 등 호출할 때마다 값이 바뀌는 것은 상위에서 전달할 수 있도록 하는 것이 테스트에 도움이 된다(후술).

 

package mysql

import (
	"go-rest-api-example/app/common"
	"go-rest-api-example/app/infrastructure/mysql/entity"
	"testing"

	"github.com/stretchr/testify/assert"

	"github.com/DATA-DOG/go-sqlmock"
)


func TestUserRepository_Create(tt *testing.T) {
	tt.Run(
		"정상 시스템: 오류 없음",
		func(t *testing.T) {
			user := &entity.User{
				Name:      "Alice",
				CreatedAt: common.CurrentTime(),
			}

			// 모의 연결 만들기
			db, mock, err := sqlmock.New()
			assert.NoError(t, err)
			defer db.Close()

			// SQl, 인수, 반환값이 의도한 대로 되기를 기대합니다.
			mock.ExpectPrepare(`INSERT INTO users`).
				ExpectExec().
				WithArgs(user.Name, user.CreatedAt).
				WillReturnResult(sqlmock.NewResult(1, 1))

				// 테스트 대상 리포지토리 생성
			r := NewUserRepository(db)


// 오류가 발생하지 않기를 바란다
			assert.NoError(t, r.Create(user))

			// 위에서 지정한 대로 모의가 호출되기를 기대합니다.
			assert.NoError(t, mock.ExpectationsWereMet())
		})
}

・・・

sqlmock은 어디까지나 SQl과 입출력 데이터가 적절한지, 예상한 순서대로 호출되는지 테스트하는 것이므로, 실제 데이터베이스를 구동해야 하는 테스트(예를 들어, 기본키나 NOT NULL 제약 조건 확인 등)는 별도의 테스트가 필요합니다.

 

** 서비스 생성
위에서 만든 엔티티와 리포지토리의 메소드를 조합하여 비즈니스 로직을 만드는 것이 서비스의 역할입니다.
시스템 중에서도 특히 변경이 많고, 업무와 밀접한 관련이 있는 중요한 레이어입니다.

또한, 서비스는 엔티티를 다루지만 상위 레이어에 대해서는 DTO(Data Transfer Object)로 변환하여 전달합니다.
DTO는 데이터를 전송하기 위한 구조체입니다.

이는 애플리케이션 계층으로 도메인 지식이 유출되는 것을 방지하기 위함입니다.
만약 DTO를 사용하지 않는다면 도메인 로직이 애플리케이션과 서비스에 흩어져 영향력이 커질 수 있습니다.

DTO는 애플리케이션 계층에 특화되어 있으며, 유효성 검사 및 요청/응답과의 바인딩에도 사용됩니다.
또한, 엔티티에서 변환할 때 애플리케이션 계층에 노출하지 않으려는 필드를 제한하거나 정형화할 수 있습니다.

package dto

import "time"

// ID 전용 사용자 데이터
type UserIDModel struct {
	ID uint `query:"id" form:"id" validate:"required"`
}

// 사용자 데이터
type UserModel struct {
	ID        uint      `json:"id" form:"id" validate:"required"`
	Name      string    `json:"name" form:"name" validate:"required,max=32"`
	CreatedAt time.Time `json:"created_at"`
	UpdatedAt time.Time `json:"updated_at"`
}

func NewUserModel(id uint, name string, createdAt time.Time, updatedAt time.Time) *UserModel {
	return &UserModel{
		ID:        id,
		Name:      name,
		CreatedAt: createdAt,
		UpdatedAt: updatedAt,
	}
}

서비스의 인터페이스와 구현은 다음과 같다.

convertTo() 메서드와 convertFrom() 메서드를 통해 엔티티와 DTO를 상호 변환할 수 있도록 하고 있습니다.
이 변환 함수는 엔티티가 가지고 있어도 좋을 것 같습니다.

package service

import (
	"go-rest-api-example/app/common/dto"
	"go-rest-api-example/app/domain/repository"
	"go-rest-api-example/app/infrastructure/mysql/entity"
)

type IUserService interface {
	FindByID(id uint) (*dto.UserModel, error)
	Create(user *dto.UserModel) error
	Update(user *dto.UserModel) error
	Delete(id uint) error
}

type UserService struct {
	repository.IUserRepository
}

func NewUserService(repo repository.IUserRepository) IUserService {
	return &UserService{repo}
}

func (s *UserService) FindByID(id uint) (*dto.UserModel, error) {
	user, err := s.IUserRepository.FindByID(id)
	if err != nil {
		return nil, err
	}
	return s.convertTo(user), nil
}

func (s *UserService) Create(user *dto.UserModel) error {
	u := s.convertFrom(user)
	return s.IUserRepository.Create(u)
}

func (s *UserService) Update(user *dto.UserModel) error {
	u := s.convertFrom(user)
	return s.IUserRepository.Update(u)
}

func (s *UserService) Delete(id uint) error {
	return s.IUserRepository.Delete(id)
}

// 엔티티에서 DTO로 변환하기
func (s *UserService) convertTo(user *entity.User) *dto.UserModel {
	return dto.NewUserModel(user.ID, user.Name, user.CreatedAt, user.UpdatedAt)
}

// DTO에서 엔티티로 변환하기
func (s *UserService) convertFrom(user *dto.UserModel) *entity.User {
	return entity.NewUser(user.ID, user.Name, user.CreatedAt, user.UpdatedAt)
}

 

** 서비스 테스트
mockery 패키지와 testify 패키지의 모의 기능을 사용하면 명령어 하나로 Go의 인터페이스에서 모의 테스트를 자동으로 생성할 수 있다.
생성된 모의는 외부에서 제어할 수 있어 테스트의 전제조건을 자유롭게 설정할 수 있어 편리하다.

여기서는 서비스 내 도메인 로직 테스트에 집중하고 싶기 때문에 하위 계층인 리포지토리를 모킹한다.

package service

import (
	"go-rest-api-example/app/common"
	"go-rest-api-example/app/common/dto"
	"go-rest-api-example/app/infrastructure/mysql/entity"
	"go-rest-api-example/mocks"
	"testing"

	"github.com/stretchr/testify/assert"
)

func TestUserService_FindByID(tt *testing.T) {
	tt.Run(
		"정상 시스템: 오류 없음",
		func(t *testing.T) {
			user := &entity.User{
				ID:        1,
				Name:      "Alice",
				CreatedAt: common.CurrentTime(),
				UpdatedAt: common.CurrentTime(),
			}

			// 리포지토리 모의 생성
			r := new(mocks.IUserRepository)

			// FindByID(1) 메서드가 호출될 것으로 예상한다. 호출되면 (user, nil)을 반환한다.
			r.On("FindByID", user.ID).Return(user, nil)

			// 테스트 대상 서비스
			s := NewUserService(r)

			// 서비스 메소드 실행
			ret, err := s.FindByID(user.ID)
			// 오류가 없기를 바란다
			assert.NoError(t, err)

			// 각종 필드가 예상과 일치하는지 확인
			assert.Equal(t, ret.ID, user.ID)
			assert.Equal(t, ret.Name, user.Name)
			assert.Equal(t, ret.CreatedAt, user.CreatedAt)
			assert.Equal(t, ret.UpdatedAt, user.UpdatedAt)

			// 위에서 지정한 인자로 메서드가 호출되기를 기대합니다.
			r.AssertExpectations(t)
		})
}

・・・

** 핸들러 생성하기
핸들러는 웹 프레임워크인 Echo의 루트와 연결되는 모듈로, 애플리케이션 계층에 속합니다.
요청 정보 등이 포함된 컨텍스트를 받아 매개변수의 유효성을 검사하고 응답을 생성하는 등 애플리케이션 로직을 수행하는 역할을 담당한다.
도메인 로직은 내부에 내장된 서비스가 담당합니다.

또한, 핸들러 내에서 생성일자와 갱신일자를 생성하고 있지만, 시간이나 난수 등 호출할 때마다 내용이 바뀌는 변수는 테스트가 매우 어렵습니다.
특히 현재 시간을 가져오는 과정은 기대값을 어떻게 지정해야 할지 모르겠습니다.

그래서 평상시에는 현재 시간을 사용하지만 테스트할 때만 임의의 시간을 컨텍스트를 통해 전달할 수 있도록 조건 분기를 만들어 놓았습니다.

 

package handler

import (
	"go-rest-api-example/app/common"
	"go-rest-api-example/app/common/dto"
	"net/http"
	"time"

	"go-rest-api-example/app/domain/service"

	"github.com/labstack/echo/v4"
)

type IUserHandler interface {
	// ID에서 사용자를 가져옵니다.
	FindByID(c echo.Context) error

	// 사용자를 생성합니다.
	Create(c echo.Context) error

	// 사용자를 업데이트합니다.
	Update(c echo.Context) error

	// 사용자 삭제
	Delete(c echo.Context) error
}

type UserHandler struct {
	service.IUserService
}

func NewUserHandler(srv service.IUserService) IUserHandler {
	return &UserHandler{srv}
}
func (h *UserHandler) FindByID(c echo.Context) error {
	var user dto.UserIDModel
	if err := c.Bind(&user); err != nil {
		return echo.NewHTTPError(400, "failed to bind request")
	}
	if err := c.Validate(user); err != nil {
		return echo.NewHTTPError(400, "failed to validation request")
	}

	ret, err := h.IUserService.FindByID(user.ID)
	if err != nil {
		return echo.NewHTTPError(404, "user is not exists")
	}
	return c.JSON(http.StatusOK, ret)
}

func (h *UserHandler) Create(c echo.Context) error {
	var user dto.UserModel
	if err := c.Bind(&user); err != nil {
		return echo.NewHTTPError(400, "failed to bind request")
	}

	// INSERT 시에는 ID를 사용하지 않기 때문에 ID 검증을 건너뜁니다.
	user.ID = 1

	if err := c.Validate(user); err != nil {
		return echo.NewHTTPError(400, "failed to validation request")
	}
	if now, ok := c.Get("now").(time.Time); ok {
		// 테스트 시 컨텍스트에서 시간 수신
		user.CreatedAt = now
	} else {
		// 평상시에는 현재 시간 가져오기
		user.CreatedAt = common.CurrentTime()
	}

	if err := h.IUserService.Create(&user); err != nil {
		return echo.NewHTTPError(500, "failed to create &user")
	}
	return c.String(http.StatusCreated, `{}`)
}

func (h *UserHandler) Update(c echo.Context) error {
	var user dto.UserModel
	if err := c.Bind(&user); err != nil {
		return echo.NewHTTPError(400, err.Error())
		// return echo.NewHTTPError(400, "failed to bind request")
	}
	if err := c.Validate(user); err != nil {
		return echo.NewHTTPError(400, "failed to validation request")
	}
	if now, ok := c.Get("now").(time.Time); ok {
		// 테스트 시 컨텍스트에서 시간 수신
		user.UpdatedAt = now
	} else {
		// 평상시에는 현재 시간 가져오기
		user.UpdatedAt = common.CurrentTime()
	}

	if err := h.IUserService.Update(&user); err != nil {
		return echo.NewHTTPError(500, "failed to create &user")
	}
	return c.String(http.StatusNoContent, `{}`)
}

func (h *UserHandler) Delete(c echo.Context) error {
	var user dto.UserIDModel
	if err := c.Bind(&user); err != nil {
		return echo.NewHTTPError(400, "failed to bind request")
	}
	if err := c.Validate(user); err != nil {
		return echo.NewHTTPError(400, "failed to validation request")
	}

	if err := h.IUserService.Delete(user.ID); err != nil {
		return echo.NewHTTPError(404, "user is not exists")
	}
	return c.String(http.StatusNoContent, `{}`)
}

 

 

** 핸들러 테스트
이쪽도 서비스와 마찬가지로 mockery + testify로 인터페이스에서 모의를 자동 생성하고 있습니다.
이번에는 핸들러 내의 애플리케이션 로직에 초점을 맞추고자 하므로 하위 레이어인 서비스를 모킹하고 있습니다.

또한, 위에서도 언급했듯이 여기서도 컨텍스트를 통해 임의의 날짜와 시간을 핸들러에 전달하고 있습니다.

 

package handler

import (
	"fmt"
	"go-rest-api-example/app/common"
	"go-rest-api-example/app/common/dto"
	"go-rest-api-example/app/common/validation"
	"go-rest-api-example/mocks"
	"net/http"
	"net/http/httptest"
	"net/url"
	"strings"
	"testing"

	"github.com/go-playground/validator"
	"github.com/stretchr/testify/assert"

	"github.com/labstack/echo/v4"
)

func TestUserHandler_FindByID(tt *testing.T) {
	tt.Run(
		"정상 시스템: 오류 없음",
		func(t *testing.T) {
			// 현재 시간 확인
			now := common.CurrentTime()

			user := dto.NewUserModel(1, "Alice", now, now)

			// GET 요청 만들기
			req := httptest.NewRequest(http.MethodGet, "/user/detail?id=1", nil)

			// Echo 인스턴스 생성
			e := echo.New()
			e.Validator = &validation.CustomValidator{V: validator.New()}

			//새 컨텍스트 만들기
			rec := httptest.NewRecorder()
			c := e.NewContext(req, rec)

			// 서비스 모의 제작
			s := new(mocks.IUserService)

			// 모의 .FindByID(1) 메서드가 호출될 것으로 예상한다. 호출되면 (user, nil)을 반환한다.
			s.On("FindByID", user.ID).Return(user, nil)

			// 테스트 대상 핸들러
			h := NewUserHandler(s)

			// 오류가 없기를 바란다
			if assert.NoError(t, h.FindByID(c)) {
				// 상태 코드가 200이 될 것으로 예상합니다.
				assert.Equal(t, http.StatusOK, rec.Code)

				// 위에서 지정한 대로 모의가 호출되기를 기대합니다.
				s.AssertExpectations(t)
			}
		})
	tt.Run(
		"준정상계: id에 0을 지정 → 유효성 검사 실패",
		func(t *testing.T) {
			req := httptest.NewRequest(http.MethodGet, "/user/detail?id=0", nil)

			e := echo.New()
			e.Validator = &validation.CustomValidator{V: validator.New()}
			rec := httptest.NewRecorder()
			c := e.NewContext(req, rec)

			s := new(mocks.IUserService)
			h := NewUserHandler(s)

			assert.EqualError(t, h.FindByID(c), `code=400, message=failed to validation request`)
		})
	tt.Run(
		"준정상계: id에 문자열을 지정 → 바인딩 실패",
		func(t *testing.T) {
			req := httptest.NewRequest(http.MethodGet, "/user/detail?id=abc", nil)

			e := echo.New()
			e.Validator = &validation.CustomValidator{V: validator.New()}
			rec := httptest.NewRecorder()
			c := e.NewContext(req, rec)

			s := new(mocks.IUserService)
			h := NewUserHandler(s)

			// 오류 메시지가 예상과 일치하는지 확인
			assert.EqualError(t, h.FindByID(c), `code=400, message=failed to bind request`)
		})
}

・・・

 

 

** DI(의존성 주입) 하기
지금까지 따로따로 만들어서 단위 테스트를 했던 저장소, 서비스, 핸들러들의 인스턴스를 생성하여 하나로 합칩니다.
각 레이어는 구체적인 struct가 아닌 인터페이스를 참조하기 때문에 DI(Dependency Injection)를 통해 느슨하게 결합됩니다.

package di

import (
	"database/sql"
	"go-rest-api-example/app/domain/service"
	"go-rest-api-example/app/handler"
	"go-rest-api-example/app/infrastructure/mysql"
)

func InitUser(db *sql.DB) handler.IUserHandler {
	r := mysql.NewUserRepository(db)
	s := service.NewUserService(r)
	return handler.NewUserHandler(s)

}

 

** 핸들러를 WEB 루트에 연결하기
마지막으로 main.go 파일을 작성하여 데이터베이스 연결 생성, 핸들러와 WEB 루트 연결, WEB 서버 구동 처리를 추가한다.

데이터베이스 연결 정보는 docker-compose.yml에서 정의한 환경 변수 MYSQL_DSN에서 가져온다.

!
마지막으로 'parseTime=true'를 추가하지 않으면 MySQL의 날짜 타입과 Go의 Time 타입이 잘 변환되지 않으므로 주의해야 한다.

environment:
  TZ: "Asia/Tokyo"
  MYSQL_DSN: root:pass@tcp(db:3306)/dev?parseTime=true

main.go는 다음과 같습니다.

package main

import (
	"database/sql"
	"go-rest-api-example/app/common/di"
	"go-rest-api-example/app/common/validation"
	"os"

	"github.com/go-playground/validator"
	"github.com/labstack/echo/v4"
	"github.com/labstack/echo/v4/middleware"
)

func main() {
	e := echo.New()
	e.Use(middleware.Logger())
	e.Use(middleware.Recover())
	e.Validator = &validation.CustomValidator{V: validator.New()}

	// MySQL과의 연결을 엽니다.
	// 연결 정보는 환경 변수 MYSQL_DSN에서 가져옵니다.
	db, err := sql.Open("mysql", os.Getenv("MYSQL_DSN"))
	if err != nil {
		panic(err)
	}
	defer db.Close()

	setUserRoutes(e, db)

	// 웹서버를 8080번 포트로 실행합니다.
	e.Logger.Fatal(e.Start(":8080"))
}

// UserHandler의 메소드와 WEB 경로를 연결합니다.
func setUserRoutes(e *echo.Echo, db *sql.DB) {
	user := di.InitUser(db)
	e.GET("/user/detail", user.FindByID)
	e.POST("/user/create", user.Create)
	e.POST("/user/update", user.Update)
	e.POST("/user/delete", user.Delete)
}

** 정리
이번에는 하위 레이어부터 설명했지만, 설계만 잘 되어 있다면 어느 레이어에서 개발해도 문제가 없습니다.
레이어 간 의존도가 낮기 때문에 누군가의 작업을 중단하지 않고 여러 사람이 동시에 개발을 진행할 수 있는 것도 이 아키텍처의 장점이라고 생각합니다.

이번에는 생략했지만, 시스템의 내용이나 규모에 따라 엔티티에 비즈니스 로직의 메소드를 부여하거나, 엔티티를 조합하여 집계를 만들거나 테스트 케이스를 더 늘려서 품질을 높여도 좋을 것 같습니다.
또한, 이번 샘플 코드의 완성본은 제 GitHub에 있습니다.

이상입니다.
여기까지 읽어주셔서 감사합니다!

'소프트웨어 아키텍처' 카테고리의 다른 글

Interpreter 패턴  (0) 2023.03.21
디자인패턴 종류  (0) 2023.03.19
플라이웨이트 패턴  (0) 2023.03.09
퍼사드 패턴(Facade Pattern)  (0) 2023.03.03
데코레이터 패턴(Decorator Pattern)??  (0) 2023.02.27