English | 한국어
Go로 백엔드 서버 만들 때마다 "이번엔 구조를 좀 더 예쁘게 짜봐야지" 하고 시작해서, 결국 프로젝트 중반에 갈아엎기를 반복하다, 드디어 정착한 구조.
더 이상 리팩토링 지옥에 빠지지 않길 바라며, 그리고 미래의 내가 "그때 어떻게 했더라? 할 때 참고하려고 만든 레포.
레이어드 아키텍처 기반의 Go 백엔드 템플릿입니다.
이 템플릿은 레이어드 아키텍처 패턴을 따릅니다:
Route → Middleware → Handler → Service → Repository
go-backend-template/
├── cmd/
│ └── server/ # 애플리케이션 진입점
│ ├── main.go
│ └── config.go
├── internal/
│ ├── app/
│ │ └── server/ # HTTP 서버 구현
│ │ ├── server.go
│ │ ├── handler/ # HTTP 핸들러 (컨트롤러)
│ │ │ ├── base.go # 공통 에러 처리
│ │ │ ├── context.go # 컨텍스트 헬퍼
│ │ │ └── user/ # User 도메인 핸들러
│ │ │ ├── handler.go
│ │ │ └── dto.go # 요청/응답 DTO
│ │ ├── middleware/ # HTTP 미들웨어
│ │ │ └── auth/
│ │ │ └── auth.go
│ │ ├── routes/ # 라우트 정의
│ │ │ ├── routes.go
│ │ │ └── user.go
│ │ └── service/ # 비즈니스 로직 레이어
│ │ └── user/
│ │ ├── service.go
│ │ ├── input.go # 서비스 입력 타입
│ │ └── dependencies.go # 의존성 인터페이스
│ └── pkg/ # 공유 내부 패키지
│ ├── auth/ # 인증 유틸리티
│ │ ├── jwt.go
│ │ └── password.go
│ ├── domain/ # 도메인 에러
│ │ └── errors.go
│ ├── entity/ # 도메인 엔티티
│ │ └── user.go
│ └── repository/ # 데이터 접근 레이어
│ ├── errors.go # 공통 Repository 에러
│ └── postgres/
│ ├── repository.go
│ ├── schema.go # 테이블 스키마
│ └── user.go
├── build/
│ └── Dockerfile
├── deployments/
│ ├── docker-compose.yml
│ └── .env.example
├── go.mod
├── go.sum
├── Makefile
└── README.md
- HTTP 요청 파싱 (경로 파라미터, 쿼리, 바디)
- 요청 형식 유효성 검증
- 요청 DTO를 서비스 입력으로 변환
- 서비스 메서드 호출
- 서비스 출력을 응답 DTO로 변환
- 에러 처리 및 HTTP 응답 전송
- 비즈니스 로직 구현
- 레포지토리 호출 조율
- 도메인 에러 반환
- HTTP 관련 코드 없음
- 데이터베이스 접근
- SQL 쿼리
- 데이터베이스 에러 반환
- 도메인 엔티티
- HTTP 상태 매핑이 포함된 도메인 에러
Handler의 DTO와 Service의 Input을 분리. Handler는 HTTP 요청/응답에 집중하고, Service는 비즈니스 로직에 집중.
// handler/user/dto.go - HTTP 요청용
type CreateUserRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=8"`
}
// service/user/input.go - 비즈니스 로직용
type CreateUserInput struct {
Email string
Username string
Password string
}도메인 에러에 HTTP 상태 코드 매핑을 포함. Handler에서 일관된 에러 처리 가능.
// domain/errors.go
type DomainError interface {
error
HTTPStatus() int
}
type UserNotFoundError struct {
Id int
}
func (e UserNotFoundError) Error() string {
return fmt.Sprintf("user not found with id: %d", e.Id)
}
func (e UserNotFoundError) HTTPStatus() int {
return http.StatusNotFound
}Service는 구체적인 구현이 아닌 인터페이스에 의존. 테스트 시 모킹 용이.
// service/user/dependencies.go
type IUserRepository interface {
GetUserById(id int) (*entity.User, error)
InsertUser(user *entity.User) (int, error)
// ...
}
type IPasswordHasher interface {
Hash(password string) (string, error)
Compare(hashedPassword, password string) error
}공통 에러 처리 로직을 BaseHandler에 구현. 도메인별 Handler에서 임베딩하여 재사용.
// handler/base.go
type BaseHandler struct{}
func (b *BaseHandler) HandleDomainError(c *gin.Context, err error) {
if domainErr, ok := err.(domain.DomainError); ok {
c.AbortWithStatusJSON(domainErr.HTTPStatus(), gin.H{"message": domainErr.Error()})
return
}
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"message": err.Error()})
}
// handler/user/handler.go
type UserHandler struct {
handler.BaseHandler // 임베딩
userService *user.Service
}- Go 1.21+
- PostgreSQL 16+
- Docker & Docker Compose (선택)
# 바이너리 빌드
make build
# Docker 이미지 빌드
make docker-build# 모든 테스트 실행
make test
# 커버리지와 함께 테스트 실행
make test-coverage
# 특정 패키지 테스트 실행
go test -v ./internal/app/server/service/user/...
go test -v ./internal/app/server/handler/user/...
go test -v ./internal/pkg/auth/...
go test -v ./internal/pkg/repository/postgres/...
# 레이스 디텍션과 함께 테스트 실행
go test -race ./...- 레포지토리 인터페이스 모킹
- 패스워드 해셔 모킹
- 비즈니스 로직 격리 테스트
- 도메인 에러 반환 검증
type MockUserRepository struct {
mock.Mock
}
func (m *MockUserRepository) GetUserById(id int) (*entity.User, error) {
args := m.Called(id)
return args.Get(0).(*entity.User), args.Error(1)
}httptest를 사용한 HTTP 테스트- 서비스 레이어 모킹
- 요청 파싱 및 유효성 검증 테스트
- 응답 포맷팅 테스트
router := gin.New()
router.POST("/users", handler.CreateUser)
req := httptest.NewRequest(http.MethodPost, "/users", body)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusCreated, w.Code)- 쿼리 빌딩 단위 테스트
- 테스트 데이터베이스를 사용한 통합 테스트 (기본적으로 스킵)
- 설정 유효성 검증 테스트
- JWT 토큰 생성 및 검증
- 패스워드 해싱 및 비교
- 엣지 케이스 (만료된 토큰, 잘못된 패스워드)
internal/pkg/entity/에 엔티티 생성internal/pkg/domain/errors.go에 도메인 에러 추가internal/pkg/repository/postgres/에 레포지토리 메서드 생성internal/app/server/service/<domain>/에 서비스 생성dependencies.go- 레포지토리 인터페이스input.go- 서비스 입력 타입service.go- 비즈니스 로직
internal/app/server/handler/<domain>/에 핸들러 생성dto.go- 요청/응답 DTOhandler.go- HTTP 핸들러
internal/app/server/routes/<domain>.go에 라우트 추가server.go에서 연결
프로젝트가 커질 때 참고할 수 있는 가이드입니다.
도메인별로 완전히 분리하는 구조 고려:
internal/
├── user/ # user 도메인 전체
│ ├── handler/
│ ├── service/
│ ├── repository/
│ └── entity/
├── order/ # order 도메인 전체
│ └── ...
도메인이 많아지면 에러도 파일별로 분리:
internal/pkg/domain/
├── errors.go # 공통 에러 (InternalServerError, ValidationError 등)
├── user_errors.go # User 도메인 에러
├── order_errors.go # Order 도메인 에러
└── product_errors.go # Product 도메인 에러
또는 도메인별 완전 분리 시:
internal/user/domain/errors.go
internal/order/domain/errors.go
순환 의존성이 발생했다면, 먼저 설계를 재검토할 것.
순환 의존성은 책임 분리가 잘못되었거나, 상위 레이어에서 조율해야 할 로직이 서비스에 들어간 경우가 많음. 인터페이스 분리는 기술적 해결책일 뿐, 근본적인 해결이 아닐 수 있음.
설계 재검토 예시:
// Before: UserService가 삭제 전 주문 확인을 직접 함
type UserService struct {
orderService *OrderService // 순환 발생
}
// After: 상위 레이어(Handler)에서 조율
func (h *Handler) DeleteUser(userId int) error {
if hasOrders, _ := h.orderService.HasActiveOrders(userId); hasOrders {
return errors.New("active orders exist")
}
return h.userService.Delete(userId)
}차선책: 인터페이스로 분리
설계 변경이 어려운 경우, 각 서비스가 필요한 기능만 인터페이스로 정의:
type IUserGetter interface {
GetUserById(id int) (*entity.User, error)
}
type IOrderChecker interface {
HasActiveOrders(userId int) (bool, error)
}
type UserService struct {
orderChecker IOrderChecker // 인터페이스에 의존
}
type OrderService struct {
userGetter IUserGetter // 인터페이스에 의존
}여러 Repository를 하나의 트랜잭션으로 묶어야 할 때 고려할 수 있는 Unit of Work 패턴:
type UnitOfWork interface {
Begin() error
Commit() error
Rollback() error
Users() IUserRepository
Orders() IOrderRepository
}현재 템플릿은 raw SQL을 사용. 쿼리가 복잡해지면 다음 도구들을 고려:
| 도구 | 특징 |
|---|---|
| sqlx | database/sql 확장. 구조체 매핑, Named Query 지원 |
| sqlc | SQL → Go 코드 생성. 타입 안전, 컴파일 타임 검증 |
| squirrel | SQL 쿼리 빌더. Fluent API, 동적 쿼리 생성에 강함 |
| goqu | 쿼리 빌더. 다양한 DB 방언 지원, 활발한 유지보수 |
| GORM | 풀 ORM. 마이그레이션, 관계 매핑, 훅 지원 |
| ent | Facebook 개발. 스키마 기반 코드 생성, 그래프 순회 |
| Bun | 경량 ORM. PostgreSQL 기능 잘 지원 |
선택 기준:
- 단순 CRUD 위주 → sqlx
- 동적 쿼리 생성 → squirrel, goqu
- 타입 안전성 중시 → sqlc
- 복잡한 관계/마이그레이션 필요 → GORM, ent
| 상황 | 고려할 패턴/도구 |
|---|---|
| 조회 성능 이슈 | 캐싱 레이어 (Redis) |
| 비동기 처리 필요 | 이벤트 기반 아키텍처 |
| API 하위 호환성 | API 버저닝 (/api/v1/, /api/v2/) |
| 서비스 규모 폭발 | 마이크로서비스 분리 검토 |
| 환경별 설정 관리 | 환경별 config 파일 |
| 분산 추적 필요 | OpenTelemetry |