들어가기에 앞서
gin-gonic 프레임워크로 공부하던 중 Spring Boot는 MVC패턴으로 코드를 구현하는데 Go에서도 편한 아케텍쳐가 있지 않을까해서 찾아보았고 그중에서 가장 눈에 띄었던 글을 번역한 것입니다. 링크 -> (https://amitshekhar.me/blog/go-backend-clean-architecture)
Clean Architecture를 구현했기 때문에 프로젝트에서 생성된 레이어는 다음과 같습니다.
- 라우터
- 제어 장치
- 유스케이스
- 저장소
- 도메인
다음은 Go 언어로 작성된 백엔드 프로젝트의 전체 아키텍처입니다.
프로젝트에서 Clean-Architecture를 쓰는 이유는 무엇일까?
- 프레임워크 독립적 : 모든 것이 분리되어 있어 필요한 경우 다른패키지로 교체하기가 더 쉽다
ex) 패키지를 변경하거나 필요한 경우 다른 패키지를 추가할 수 있다 ->유지보수에 용이하다
- 높은 테스트 가능성: 테스트 작성이 더 쉽다
- 새로인 기눙 추가가 쉽다
- 필요한 변경 사항에 대한 코드를 쉽게 수정할 수 있다
라우터
요청은 라우터로부터 시작합니다
- Public Router : 모든 Public API는 이 라우터를 거쳐야만 합니다.
- Protected Router : 모든 Private API는 이 라우터를 거쳐야만 합니다
둘의 차이점은 인증(Authentication)을 거치냐가 가장 큰 점입니다
ex) JWT Token 검사
Public API 요청 흐름
Private API 요청 흐름
Aceess Token 유효성 검사를 위한 JWT Authentication(인증) Middleware(미들웨어)
두 Routter 사이에 미들웨어가 추가되어 AccessToken의 유효성을 확인합니다. 따라서 유효하지 않은 AccessToken이 있는 개인 요청은
Protected(보호된) 라우터에 도착하면 안됩니다!
그런 다음 해당 라우터에 배포됩니다. 이해를 위해 아래 코드를 볼 수 있습니다.
package route
import (
"time"
"github.com/amitshekhariitbhu/go-backend-clean-architecture/api/middleware"
"github.com/amitshekhariitbhu/go-backend-clean-architecture/bootstrap"
"github.com/amitshekhariitbhu/go-backend-clean-architecture/mongo"
"github.com/gin-gonic/gin"
)
func Setup(env *bootstrap.Env, timeout time.Duration, db mongo.Database, gin *gin.Engine) {
publicRouter := gin.Group("") //그룹 생성
// 모든 Public API
NewSignupRouter(env, timeout, db, publicRouter) //라우팅 추가
NewLoginRouter(env, timeout, db, publicRouter)
NewRefreshTokenRouter(env, timeout, db, publicRouter)
protectedRouter := gin.Group("") //그룹 생성
//미들웨어에서 AccessToken 유효성 검사
protectedRouter.Use(middleware.JwtAuthMiddleware(env.AccessTokenSecret)) //유효성 검사
//모든 Private API
NewProfileRouter(env, timeout, db, protectedRouter)
NewTaskRouter(env, timeout, db, protectedRouter)
}
그런 다음 Routter는 해당 Controller를 호출합니다.
Controller를 호출하려면 Controller가 usecase에 종속되므로 usecase(MVC패턴에서의 Service라고 생각해도 됨)가 필요합니다.
또한 usecase가 Repository에 따라 다르기 때문에 Repository도 당연히 필요합니다
->( MVC는 Service단마다 Repository가 있으니까)
진행방식:
- Repsotiory가 있고 usecase에 전달합니다
- 그런 다음 usecase가 있고 Controller에 전달합니다
- 이제부터 Controller는 Routter 내부에서 사용할 수 있습니다
여기서 usecase란 보통 비즈니스 로직을 담당하고, 다양한 엔티티랑 상호 작용하는 것을 말합니다
ex) 인터페이스를 통해 구현된 Repository를 사용하여 엔티티와 데이터베이스를 연결합니다.
package route
import (
"time"
"github.com/amitshekhariitbhu/go-backend-clean-architecture/api/controller"
"github.com/amitshekhariitbhu/go-backend-clean-architecture/bootstrap"
"github.com/amitshekhariitbhu/go-backend-clean-architecture/domain"
"github.com/amitshekhariitbhu/go-backend-clean-architecture/mongo"
"github.com/amitshekhariitbhu/go-backend-clean-architecture/repository"
"github.com/amitshekhariitbhu/go-backend-clean-architecture/usecase"
"github.com/gin-gonic/gin"
)
func NewTaskRouter(env *bootstrap.Env, timeout time.Duration, db mongo.Database, group *gin.RouterGroup) {
tr := repository.NewTaskRepository(db, domain.CollectionTask)//task와 관련된 라우팅 정보 설정
//Repository생성
tc := &controller.TaskController{ //Controller패키지에 있는 TaskController 생성
TaskUsecase: usecase.NewTaskUsecase(tr, timeout), //usercase패키지의 NewTaskUsecase메소드 호출
} // 객체에 repository와 timeout값 전달
group.GET("/task", tc.Fetch)
group.POST("/task", tc.Create)//라우팅 생성
} //해당 메소드 순서 Repository 생성 -> TaskUsercase 생성 -> usercase메소드 호출
백엔드에 대한 각 요청은 결국 컨트롤러에 의해 실행됩니다. 주어진 요청을 컨트롤러 및 작업에 매핑하는 경로 목록이 정의됩니다.
Controller
이제 Request는 Controller에 있습니다. 먼저 Request내부에 있는 데이터의 유효성을 검사합니다.
잘못된 것이 있으면 400을 return하고 유효하다면 usecase를 호출하여 작업을 수행합니다.
package controller
import (
"net/http"
"github.com/amitshekhariitbhu/go-backend-clean-architecture/domain"
"github.com/gin-gonic/gin"
"go.mongodb.org/mongo-driver/bson/primitive"
)
type TaskController struct { //Controller 구조체
TaskUsecase domain.TaskUsecase //domain.TaskUsercase는 인터페이스
}
func (tc *TaskController) Create(c *gin.Context) { //Post요청 처리 task 구조체 생성
var task domain.Task
err := c.ShouldBind(&task) //task를 직렬화했을때 구조체에 할당
if err != nil { //에러라면 에러 반환
c.JSON(http.StatusBadRequest, domain.ErrorResponse{Message: err.Error()})
return
}
userID := c.GetString("x-user-id") // 헤더에서user-id를 가져와
task.ID = primitive.NewObjectID() // task 구조체 id필드에 새로운 objectID할당
//*여기서 ObjectId는 MySQL -> Primary Key라고 생각
task.UserID, err = primitive.ObjectIDFromHex(userID)// userId를 Objectid로 반환
// task구조체의 userId를 필드에 할당
if err != nil { //에러가 난다면 에러 반환
c.JSON(http.StatusBadRequest, domain.ErrorResponse{Message: err.Error()})
return
}
err = tc.TaskUsecase.Create(c, &task) //인터페이스에 Create함수호출시 포인터 포인터를 인자로 전달
if err != nil { //에러라면 에러 반환
c.JSON(http.StatusInternalServerError, domain.ErrorResponse{Message: err.Error()})
return
}
c.JSON(http.StatusOK, domain.SuccessResponse{ //OK반환
Message: "Task created successfully",
})
}
func (u *TaskController) Fetch(c *gin.Context) { //헤더에서 가져온 userId를 사용해 함수호출
userID := c.GetString("x-user-id")
tasks, err := u.TaskUsecase.FetchByUserID(c, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, domain.ErrorResponse{Message: err.Error()})
return
}
c.JSON(http.StatusOK, tasks)
}
UseCase
사용 사례 계층은 저장소 계층에 따라 다릅니다.
이 계층은 리포지토리 계층을 사용하여 작업을 수행합니다. 작업을 수행하는 방법은 전적으로 저장소에 달려 있습니다.
package usecase
import (
"context"
"time"
"github.com/amitshekhariitbhu/go-backend-clean-architecture/domain"
)
type taskUsecase struct { //Task와 관련된 usercase를 구현하는 구조체
taskRepository domain.TaskRepository //taskRepository변수
contextTimeout time.Duration //contextTimeout 변수를 가진다
}
func NewTaskUsecase(taskRepository domain.TaskRepository, timeout time.Duration) domain.TaskUsecase {
return &taskUsecase{ //taskRepository, timeout값을 인자로 받아 taskusercase구조체를 생성후 반횐
taskRepository: taskRepository,
contextTimeout: timeout,
}
}
func (tu *taskUsecase) Create(c context.Context, task *domain.Task) error {
ctx, cancel := context.WithTimeout(c, tu.contextTimeout)
defer cancel() //taskUsecase 구조체 메서드로 context, Task인자로 받아 Task를 생성
return tu.taskRepository.Create(ctx, task)
}
func (tu *taskUsecase) FetchByUserID(c context.Context, userID string) ([]domain.Task, error) {
ctx, cancel := context.WithTimeout(c, tu.contextTimeout)
defer cancel() //taskUsecase 구조체 메서드로 context,userid인자로 받아 userid관련된 Task 목록 조회
return tu.taskRepository.FetchByUserID(ctx, userID)
}
Repository
리포지토리는 사용 사례의 종속성입니다. usecase 계층은 리포지토리에 작업을 수행하도록 요청합니다.
리포지토리 계층은 모든 데이터베이스를 자유롭게 선택할 수 있으며 실제로 요구 사항에 따라 다른 독립 서비스를 호출할 수 있습니다.
프로젝트에서 리포지토리 계층은 Usecase 계층에서 요청한 작업을 수행하기 위해 데이터베이스 쿼리를 만듭니다.
package repository
import (
"context"
"github.com/amitshekhariitbhu/go-backend-clean-architecture/domain"
"github.com/amitshekhariitbhu/go-backend-clean-architecture/mongo"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
)
type taskRepository struct {
database mongo.Database
collection string
}
func NewTaskRepository(db mongo.Database, collection string) domain.TaskRepository {
return &taskRepository{
database: db,
collection: collection,
} //생성자라고 생각
}
func (tr *taskRepository) Create(c context.Context, task *domain.Task) error { //Create
collection := tr.database.Collection(tr.collection)
_, err := collection.InsertOne(c, task)
return err
}
func (tr *taskRepository) FetchByUserID(c context.Context, userID string) ([]domain.Task, error) {
collection := tr.database.Collection(tr.collection) //검색
var tasks []domain.Task
idHex, err := primitive.ObjectIDFromHex(userID)
if err != nil {
return tasks, err
}
cursor, err := collection.Find(c, bson.M{"userID": idHex})
if err != nil {
return nil, err
}
err = cursor.All(c, &tasks)
if tasks == nil {
return []domain.Task{}, err
}
return tasks, err
}
도메인 = Domain
도메인 계층에서 다음을 입력합니다.
- 요청 및 응답을 위한 모델.
- 데이터베이스의 엔터티입니다.
- 사용 사례 및 리포지토리에 대한 인터페이스입니다.
package domain
import (
"context"
"go.mongodb.org/mongo-driver/bson/primitive"
)
const (
CollectionTask = "tasks"
)
type Task struct {
ID primitive.ObjectID `bson:"_id" json:"-"`
Title string `bson:"title" form:"title" binding:"required" json:"title"`
UserID primitive.ObjectID `bson:"userID" json:"-"`
}
type TaskRepository interface {
Create(c context.Context, task *Task) error
FetchByUserID(c context.Context, userID string) ([]Task, error)
}
type TaskUsecase interface {
Create(c context.Context, task *Task) error
FetchByUserID(c context.Context, userID string) ([]Task, error)
}
사용 사례 테스트
package usecase_test
import (
"context"
"errors"
"testing"
"time"
"github.com/amitshekhariitbhu/go-backend-clean-architecture/domain"
"github.com/amitshekhariitbhu/go-backend-clean-architecture/domain/mocks"
"github.com/amitshekhariitbhu/go-backend-clean-architecture/usecase"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"go.mongodb.org/mongo-driver/bson/primitive"
)
func TestFetchByUserID(t *testing.T) {
mockTaskRepository := new(mocks.TaskRepository)
userObjectID := primitive.NewObjectID()
userID := userObjectID.Hex()
t.Run("success", func(t *testing.T) {
mockTask := domain.Task{
ID: primitive.NewObjectID(),
Title: "Test Title",
UserID: userObjectID,
}
mockListTask := make([]domain.Task, 0)
mockListTask = append(mockListTask, mockTask)
mockTaskRepository.On("FetchByUserID", mock.Anything, userID).Return(mockListTask, nil).Once()
u := usecase.NewTaskUsecase(mockTaskRepository, time.Second*2)
list, err := u.FetchByUserID(context.Background(), userID)
assert.NoError(t, err)
assert.NotNil(t, list)
assert.Len(t, list, len(mockListTask))
mockTaskRepository.AssertExpectations(t)
})
t.Run("error", func(t *testing.T) {
mockTaskRepository.On("FetchByUserID", mock.Anything, userID).Return(nil, errors.New("Unexpected")).Once()
u := usecase.NewTaskUsecase(mockTaskRepository, time.Second*2)
list, err := u.FetchByUserID(context.Background(), userID)
assert.Error(t, err)
assert.Nil(t, list)
mockTaskRepository.AssertExpectations(t)
})
}
이것이 Go 언어의 백엔드 프로젝트에서 깨끗한 아키텍처를 구현하는 방법입니다.
'Go' 카테고리의 다른 글
[Go] 블록체인 공부가 막막하다면 (0) | 2023.09.13 |
---|---|
[Go] gRPC Basics tutorial 예제 분석 (0) | 2023.07.08 |
[Go] Go의 웹 프레임워크 Gin이란? (0) | 2023.04.29 |
[Go] Go로 로그 패키지 만들기 (1) (1) | 2023.03.07 |
[Go] golang slice안에 특징 (0) | 2023.02.16 |