티스토리 뷰
개요
Golang 으로 백엔드 서비스를 만든다면 프로젝트 구조, 의존성 주입, 로깅, 데이터베이스 연결과 같이 기본적으로 챙겨야 할 것들이 많다. 서비스를 만들때에 시작점이 될 수 있는, 기본 예제가 담겨있는 서버를 구현해보려 한다. 완성된 백엔드 서버 결과물을 바로 보여주는 것이 아니라 만들어가는 과정을 하나씩 정리하겠다.
전체적인 그림을 다 그려놓고 작성하는 글이 아닌 만큼 크고 작은 오류들은 양해를 바라며, 그 첫 번째로 클린 아키텍처로 구현된 기본적인 API 서버를 만들어본다.
링크
- GitHub 브랜치: https://github.com/nicewook/gocore/tree/1_clean-architecture-basic
- 블로그 링크: Go 백엔드 1: 클린 아키텍처 기본
클린 아키텍처
클린 아키텍처는 비즈니스 로직을 외부 환경(DB, 프레임워크 등)과 분리하여 유지보수성과 확장성을 높이는 설계 방식이다. 핵심 계층은 도메인(엔티티), 유스케이스(비즈니스 로직), 인터페이스 어댑터(핸들러, 리포지토리), 프레임워크 & 드라이버(DB, 웹서버) 로 나뉜다.
핸들러 → 유스케이스 → 리포지토리 순으로 의존성을 주입하며, 안쪽 계층이 바깥쪽 계층에 의존하지 않는다(의존성 규칙). 장점은 테스트 용이성, 유지보수성, 기술 독립성이지만, 초기 개발 비용 증가, 복잡성, 성능 오버헤드가 단점이다. 큰 프로젝트에서 효과적이지만, 작은 프로젝트에는 오버엔지니어링이 될 수 있다.
코드들은 패키지명, import 등의 군더더기를 가능한 제외하고 비슷한 층위의 함수들은 그중 일부만을 보여주겠다. 전체 코드는 GitHub 에서 볼 수 있다.
프로젝트 구조
├── cmd
│ └── gocore
│ └── main.go
└── internal
├── domain
│ └── errors.go
│ └── user.go
├── handler
│ └── userHandler.go
├── repository
│ └── userRepository.go
└── usecase
└── userUsecase.go
- cmd 아래에 하나 이상의 앱을 구현한다. 즉, 앱마다 별도의 디렉토리를 만들면 된다. 우리가 구현하는 것은 gocore 라는 앱이다.
- internal 디렉토리 아래에는 domain, handler, usecase, repository 디렉토리가 있다. 각각은 다음과 같은 역할을 한다.
- domain:
- (User와 같은) 모델을 정의하고, usecase, repository 인터페이스를 정의해둔다.
- 공통으로 사용하는 에러도 정의해둔다.
- handler:
- API 요청을 받아 query, body 등에서 필요한 정보를 추출하고 검증한 다음 usecase에 전달하고,
- usecase에서 돌아온 답변을 회신한다.
- usecase: 핵심 비즈니스 로직을 담는다.
- repository: 가능한 비즈니스 로직은 모르는 채로 usecase가 시키는 일만하는 단순 노동자차럼 구현한다.
- domain:
gocore/main.go
- echo 프레임워크를 사용하였다.
- repository → usecase → handler 로 의존성을 주입해두었다.
- User를 추가하고, 그중 하나를 불러오는 요청에 대한 핸들러 라우팅을 정의해두었다.
// cmd/gocore/main.go
func main() {
e := echo.New()
// 의존성 주입
userRepo := repository.NewUserRepository()
userUseCase := usecase.NewUserUseCase(userRepo)
userHandler := handler.NewUserHandler(userUseCase)
// 라우팅
e.POST("/users", userHandler.CreateUser)
e.GET("/users/:id", userHandler.GetUser)
// 서버 실행
log.Println("Server started at :8080")
if err := e.Start(":8080"); err != nil {
log.Fatal("Shutting down the server due to:", err)
}
}
domain
- User 구조체가 정의되어 있고, UserRepository, UserUseCase 인터페이스에 대한 정의가 되어있다.
- user.go 하나만 있지만 product, order 등이 생겨나면 domain 디렉토리에 파일들이 하나씩 추가될 것이다.
// internal/domain/user.go
package domain
type User struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
type UserRepository interface {
Save(user User) error
FindByID(id int) (User, error)
}
type UserUseCase interface {
CreateUser(user User) error
GetUser(id int) (User, error)
}
- 에러를 미리 정의해 둔다.
// internal/domain/errors.go
package domain
import "errors"
var (
ErrNotFound = errors.New("not found")
ErrAlreadyExists = errors.New("already exists")
ErrInvalidInput = errors.New("invalid input")
)
handler
- 생성자인 NewUserHandler가 인터페이스인 domain.UserUseCase를 파라미터로 받는것을 주목하자.
- CreateUser 메서드는 POST /users 를 처리하는 핸들러이다.
- c.Bind 를 이용해 요청사항을 추출한 다음, usecase로 전달하고 그 결과를 회신한다.
- 요청사항을 검증하는 것도 handler의 책임으로 한다.
- GetUser 메서드는 GET /users/:id 를 처리하는 핸들러이다.
- path parameter를 추출한 다음, usecase로 전달하고 그 결과를 회신한다.
- ErrResponse 함수는 헬퍼 함수이다.
- usecase 작업에서 에러가 발생했을 때는 errors.Is 를 이용하여 에러별로 처리하고, 기본적으로는 내부에러(Internal Server Error, 500)로 간주한다.
// internal/handler/userHandler.go
type UserHandler struct {
userUseCase domain.UserUseCase
}
func NewUserHandler(userUseCase domain.UserUseCase) *UserHandler {
return &UserHandler{userUseCase: userUseCase}
}
func ErrResponse(err error) map[string]string {
return map[string]string{
"error": err.Error(),
}
}
func (h *UserHandler) CreateUser(c echo.Context) error {
var user domain.User
if err := c.Bind(&user); err != nil {
return c.JSON(http.StatusBadRequest, ErrResponse(domain.ErrInvalidInput))
}
if user.Name == "" || user.Email == "" {
return c.JSON(http.StatusBadRequest, ErrResponse(domain.ErrInvalidInput))
}
err := h.userUseCase.CreateUser(user)
if err == nil {
return c.JSON(http.StatusCreated, user)
}
switch {
case errors.Is(domain.ErrInvalidInput, err):
return c.JSON(http.StatusBadRequest, ErrResponse(err))
case errors.Is(domain.ErrAlreadyExists, err):
return c.JSON(http.StatusConflict, ErrResponse(err))
default:
return c.JSON(http.StatusInternalServerError, ErrResponse(domain.ErrInternal))
}
}
func (h *UserHandler) GetUser(c echo.Context) error {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
return c.JSON(http.StatusBadRequest, ErrResponse(domain.ErrInvalidInput))
}
user, err := h.userUseCase.GetUser(id)
if err == nil {
return c.JSON(http.StatusOK, user)
}
switch {
case errors.Is(err, domain.ErrNotFound):
return c.JSON(http.StatusNotFound, ErrResponse(err))
default:
return c.JSON(http.StatusInternalServerError, ErrResponse(domain.ErrInternal))
}
}
usecase
- 생성자인 NewUserUseCase가 인터페이스인 domain.UserRepository를 파라미터로 받아서 구현체인 userUseCase를 생성한 다음, 인터페이스인 domain.UserUseCase를 리턴하고 있다.
- usecase 계층은 서비스의 핵심이랄 수 있는 비즈니스 로직을 담게 되는 계층이다. handler 계층에게서 요청을 받아 repository 계층에 명령을 한다
- 다만 여기서는 코드가 단순하긴 하다.
- CreateUser 메서드에서 user.Name, user.Email을 검증하는 부분은 handler 계층에서 검증하도록 리팩터링 할 필요가 있겠다.
// internal/usecase/userUseCase.go
type userUseCase struct {
userRepo domain.UserRepository
}
func NewUserUseCase(userRepo domain.UserRepository) domain.UserUseCase {
return &userUseCase{userRepo: userRepo}
}
func (uc *userUseCase) CreateUser(user domain.User) error {
if user.Name == "" || user.Email == "" {
return domain.ErrInvalidInput
}
return uc.userRepo.Save(user)
}
func (uc *userUseCase) GetUser(id int) (domain.User, error) {
return uc.userRepo.FindByID(id)
}
repository
- 여기서는 실제 데이터베이스가 아니라 map으로 간단한 메모리 저장소를 만들었다.
- 생성자인 NewUserRepository 는 구현체인 userRepository를 생성한 다음 인터페이스인 domain.UserRepository 를 리턴한다.
// internal/repository/userRepository.go
type userRepository struct {
users map[int]domain.User // 간단한 메모리 저장소
}
func NewUserRepository() domain.UserRepository {
return &userRepository{
users: make(map[int]domain.User),
}
}
func (r *userRepository) Save(user domain.User) error {
if _, exists := r.users[user.ID]; exists {
return domain.ErrAlreadyExists
}
r.users[user.ID] = user
return nil
}
func (r *userRepository) FindByID(id int) (domain.User, error) {
user, exists := r.users[id]
if !exists {
return domain.User{}, domain.ErrNotFound
}
return user, nil
}
마무리
손보고 싶은 부분은 끝이 없지만 여기서 마무리하려 한다. 데이터베이스 연결, 설정 관리, 의존성 주입 등등 다음 주제들이 떠오르는데 하나씩 글을 추가하겠다.
'golang' 카테고리의 다른 글
Go, XORM, Soft delete, Unscoped (0) | 2024.02.14 |
---|---|
Golang: http.Client의 Timeout (0) | 2023.12.20 |
Go: 함수가 리턴해도 함수 속 고루틴은 종료되지 않는다 (0) | 2023.10.10 |
Golang: timezone 설정하기 (0) | 2023.06.11 |
Golang: 개발중에만 log 출력이 되어야 한다면 (0) | 2023.06.10 |
![잡학툰 뱃지](https://tistory1.daumcdn.net/tistory/2908812/skin/images/badge.png)
- Total
- Today
- Yesterday
- websocket
- 인텔리제이
- 독서
- agile
- Gin
- 잡학툰
- 노션
- 엉클 밥
- clean agile
- notion
- OpenAI
- 2023
- 티스토리챌린지
- 클린 아키텍처
- intellij
- github
- Bug
- 독서후기
- API
- 2024년
- 오블완
- ChatGPT
- solid
- golang
- bun
- go
- folklore
- 클린 애자일
- 영화
- strange
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 |