티스토리 뷰

golang

Go 백엔드 1: 클린 아키텍처 기본

주먹불끈 2025. 2. 6. 11:34

 

개요

Golang 으로 백엔드 서비스를 만든다면 프로젝트 구조, 의존성 주입, 로깅, 데이터베이스 연결과 같이 기본적으로 챙겨야 할 것들이 많다. 서비스를 만들때에 시작점이 될 수 있는, 기본 예제가 담겨있는 서버를 구현해보려 한다. 완성된 백엔드 서버 결과물을 바로 보여주는 것이 아니라 만들어가는 과정을 하나씩 정리하겠다.

전체적인 그림을 다 그려놓고 작성하는 글이 아닌 만큼 크고 작은 오류들은 양해를 바라며, 그 첫 번째로 클린 아키텍처로 구현된 기본적인 API 서버를 만들어본다.

링크

클린 아키텍처

클린 아키텍처는 비즈니스 로직을 외부 환경(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가 시키는 일만하는 단순 노동자차럼 구현한다.

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
}

마무리

손보고 싶은 부분은 끝이 없지만 여기서 마무리하려 한다. 데이터베이스 연결, 설정 관리, 의존성 주입 등등 다음 주제들이 떠오르는데 하나씩 글을 추가하겠다.

반응형
반응형
잡학툰 뱃지
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
«   2025/02   »
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
글 보관함