Search

Go Fiber를 이용한 MVC 기반 웹서버 만들기 (+boilerplate)

주제
Backend
날짜
2024/02/18

Golang Web Frameworks

Golang은 다른 언어에 비해 절대적인 입지를 가진 프레임워크가 없기 때문에 여러 프레임워크 중에서 선택해야한다.
Java - Spring Node.js - Express, NestJS Python - Django, FastAPI
다만 절대적인 입지를 가진 프레임워크가 없을 뿐이지 Golang에서 자주 쓰이는 대표적인 프레임워크는 총 5개정도 있다.

gin-gonic/gin

Golang의 가장 오래된 웹 프레임워크이자 압도적으로 많은 Github Star를 보유중인 프레임워크.
다만, 한국에선 다른 프레임워크들에 비해 무겁다는 이유로 잘 사용되지 않는 것 같다.
해당 글에서 다룰 웹 프레임워크이다.
다른 웹 프레임워크보다 압도적인 성능을 보여준다.
위 그래프를 보면 다른 프레임워크에 비해 가장 늦게(2020년) 출시되었지만 압도적인 성장을 보여주고 있다.
기존에는 다른 프레임워크에 비해 눈에 띄지 않던 프레임워크였으나, 최근 v2로 업데이트되면서 Github Star가 fiber 를 넘어섰습니다.
beego는 built-in ORM이며 GORM을 사용하지 않는다.
ORM말고도 다양한 편리한 기능들을 많이 제공해주고 있다.
bee api [project_name] 하면 초기 세팅 까지 있는 구조도 다 잡준다.
bee generate scaffold [scaffold_name] 하면 Controller, Service, Model 등등 정의 된 이름에 따라 모두 자동 생성해준다.
다른 프레임워크들에 비해 복잡하지 않고 단순하다.
성능은 gin과 비슷하다.
서로가 자기가 더 빠르다고 벤치마킹 성능을 공개했다.
Benchmark

Go Library

프로젝트 구조를 만들기 위해 사용된 라이브러리를 소개한다

Uber/Zap

Zap이란?

zap
uber-go
Blazing fast, structured, leveled logging in Go.
Go에서 실시간(hot path)으로 로깅을 할 때 reflection 기반 직렬화 및 문자열 포맷팅에 많은 비용이 발생한다.
이 작업은 CPU를 많이 사용하며 많은 small allocation을 발생시켜 메모리에 부담을 준다.
즉, encoding/jsonfmt.Fprintf를 사용하여 많은 interface{}를 로깅하는 것은 애플리케이션을 느리게 만든다.
Zap은 reflection을 사용하지 않고, 메모리 할당이 없는 JSON encoder를 포함하고 있으며, 최대한 직렬화 오버헤드와 메모리 할당을 피하기 위해 노력한다.

성능

Package
Time
Time % to zap
Objects Allocated
zap
656 ns/op
+0%
5 allocs/op
zap (sugared)
935 ns/op
+43%
10 allocs/op
zerolog
380 ns/op
-42%
1 allocs/op
go-kit
2249 ns/op
+243%
57 allocs/op
slog (LogAttrs)
2479 ns/op
+278%
40 allocs/op
slog
2481 ns/op
+278%
42 allocs/op
apex/log
9591 ns/op
+1362%
63 allocs/op
log15
11393 ns/op
+1637%
75 allocs/op
logrus
11654 ns/op
+1677%
79 allocs/op

프로젝트에 적용하기

Fiber 프레임워크에 보다 쉽게 Zap 로깅을 적용하기 위해 Fiber 프레임워크에서 공식으로 지원하는 라이브러리를 사용했다.
Zap은 다양한 종류의 Log Level을 지원한다.
const ( // DebugLevel logs are typically voluminous, and are usually disabled in // production. DebugLevel = zapcore.DebugLevel // InfoLevel is the default logging priority. InfoLevel = zapcore.InfoLevel // WarnLevel logs are more important than Info, but don't need individual // human review. WarnLevel = zapcore.WarnLevel // ErrorLevel logs are high-priority. If an application is running smoothly, // it shouldn't generate any error-level logs. ErrorLevel = zapcore.ErrorLevel // DPanicLevel logs are particularly important errors. In development the // logger panics after writing the message. DPanicLevel = zapcore.DPanicLevel // PanicLevel logs a message, then panics. PanicLevel = zapcore.PanicLevel // FatalLevel logs a message, then calls os.Exit(1). FatalLevel = zapcore.FatalLevel )
Go
복사
ENV로 전달할 때 string 형식으로 debug, DEBUG, info, INFO 등과 같이 전달해줄 수 있다.
Log Level Unmarshal
Log Level을 다르게함으로써 아래와 같이 환경별로 로깅 방식도 바꿀 수 있다.
if level == zap.DebugLevel { encoderConfig = zap.NewDevelopmentEncoderConfig() encoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder encoding = "console" } else { encoderConfig = zap.NewProductionEncoderConfig() encoding = "json" }
Go
복사
개발을 할 땐 가시성이 좋고 편한 Console Logging을 하고, 배포 후에는 JSON Logging을 함으로써 Kibana에서 Log를 쉽게 확인할 수 있게 한다.

Uber/Fx

Fx란?

Fx is a dependency injection system for Go.
DI (Dependency Injection)에 대한 내용은 의존성 주입과 IoC에서 확인할 수 있다.
Fx는 Uber에서 개발한 reusable하고 composable한 모듈로 어플리케이셔을 쉽게 구축할 수 있는 프레임워크이다.
Fx는 Uber의 또 다른 DI 패키지인 Dig를 기반으로 하는데 쉽게 말해서 Dig를 자동화해서 쉽게 사용할 수 있게 한 것이 Fx이다.
경험상 Dig는 InversifyJS와 비슷하고 Fx는 NestJS의 module과 비슷하다고 생각하면 된다.
Uber에서는 Dig 자체를 사용하기보단 Fx라는 Uber의 DI기반 프레임워크를 통해 사용하기를 권고한다.
Fx는 Reflect 기반의 Injector이다.
Fx는 DI를 사용하여 함수 호출을 수동으로 연결할 필요 없이 전역을 제거한다. 종속성 주입에 대한 다른 접근 방식과 달리 Fx는 일반 Go 함수와 함께 작동한다.
struct 태그를 사용하거나 특별한 type을 포함할 필요가 없으므로 Fx는 대부분의 Go package와 자동으로 잘 작동한다.

다른 DI 도구들과 비교 (Compile-time DI vs Runtime DI)

DI 방식
Compile-time DI
Google/Wire
Runtime DI (Go의 Reflection을 이용해 구현)
Facebook/Inject
Archive 되어 약 10년간 신규 업데이트가 없다.
Uber/Fx
Compile-time DI
Runtime DI
정의
의존성이 컴파일 시간에 해결되며, 코드에서 직접 구현된다.
의존성이 애플리케이션 실행 시간에 해결되며, 보통 외부 설정이나 프레임워크를 통해 이루어진다.
장점
• 빠른 실행 속도: 런타임에 의존성을 해결할 필요가 없어 실행 속도가 빠르다. • 안정성: 컴파일 시점에 의존성 오류를 발견할 수 있다. • 간단한 설정: 외부 도구나 프레임워크에 의존하지 않고 코드 내에서 관리되므로 설정이 상대적으로 간단하다.
• 유연성: 실행 시간에 의존성을 변경할 수 있어 다양한 환경이나 상황에 쉽게 적응할 수 있다. • 구성의 용이성: 외부 설정을 통해 의존성을 관리할 수 있어, 코드 수정 없이도 구성 변경이 가능하다. • 툴 지원: 많은 현대 프레임워크와 툴이 런타임 DI를 지원하여, 개발과정을 용이하게 한다.
단점
• 유연성 부족: 실행 시간에 구성을 변경하기 어렵거나 불가능할 수 있다. • 오류 발견 지연: 특정 의존성 관련 오류를 실행 시점까지 발견하지 못할 수 있다. • 초기 학습 곡선: DI 구현 방식이나 패턴을 이해하고 적용하는데 시간이 필요할 수 있다.
• 성능 오버헤드: 런타임에 의존성을 해결하는 과정에서 성능 저하가 발생할 수 있다. • 복잡한 설정: 외부 설정 파일이나 어노테이션 등을 관리해야 하므로, 구성이 복잡해질 수 있다. • 컴파일 시간 오류 감지 불가: 의존성 관련 오류를 실행 시점 전에 발견하기 어려울 수 있다. |
적합한 사용 사례
• 성능이 중요한 애플리케이션 • 변경사항이 적고, 구성이 비교적 고정적인 애플리케이션 • 간단한 의존성 구조를 가진 애플리케이션
• 개발 환경과 운영 환경 등 다양한 환경에서 유연하게 구성을 변경해야 하는 애플리케이션 • 복잡한 의존성 구조와 다양한 구성 옵션이 필요한 대규모 애플리케이션 • 빠르게 변화하는 프로젝트에서 유연한 개발이 필요한 경우 |

Plain DI vs Google/Wire vs Uber/Fx

각 DI 도구의 동작 방식을 한번 가볍게 봐보자.
Facebook/Inject은 아카이빙되어 비교에서 제외했다.
먼저 아래는 어떤 DI 도구도 사용하지 않았을 때의 DI 방식이다.
Plain DI
/* * Type 정의 */ type Message string type Greeter struct { Message Message } type Event struct { Greeter Greeter } /* * Constructor 정의 */ func NewMessage() Message { return Message("Hi there!") } func NewGreeter(m Message) Greeter { return Greeter{Message: m} } func NewEvent(g Greeter) Event { return Event{Greeter: g} } /* * Method 정의 */ func (g Greeter) Greet() Message { return g.Message } func (e Event) Start() { msg := e.Greeter.Greet() fmt.Println(msg) } /* * DI */ func main() { message := NewMessage() greeter := NewGreeter(message) event := NewEvent(greeter) event.Start() }
Go
복사
위 코드를 예시로 각 도구들의 방식으로 DI를 해보겠다.
Google/Wire
func main() { e := InitializeEvent() e.Start() }
Go
복사
// wire.go func InitializeEvent() Event { wire.Build(NewEvent, NewGreeter, NewMessage) return Event{} }
Go
복사
이 상태에서 터미널에 wire 라고 명령어를 치게 되면 wire_gen.go가 생긴다.
// wire_gen.go func InitializeEvent() Event { message := NewMessage() greeter := NewGreeter(message) event := NewEvent(greeter) return event }
Go
복사
이런식으로 Google/Wire의 경우 귀찮은 DI 코드들을 대신 만들어주는 정적인 Compile-time DI이다.
위의 DI 도구를 사용하지 않은 코드의 func main() 함수와 비슷한 DI 코드가 생긴것을 확인할 수 있다.
Uber/Fx
먼저 fx.Provide() 또는 fx.Invoke()를 통해 각 객체를 생성해줘야한다.
var MessageModule = fx.Module( "message", fx.Provide(NewMessage), ) var GreeterModule = fx.Module( "greeter", fx.Provide(NewGreeter), ) var EventModule = fx.Module( "event", fx.Provide(NewEvent), )
Go
복사
그리고 어플리케이션의 시작점이 되는 func main() 함수에 해당 Module들을 넣어 어플리케이션이 시작될 때 각 객체들이 생성되게 한다.
func main() { fx.New( src.MessageModule, src.GreeterModule, src.EventModule, fx.Invoke(Start), ).Run() } func Start(e src.Event) { msg := e.Greeter.Greet() fmt.Println(msg) }
Go
복사
이렇게 하면 의존성 주입이 자동으로 된다.
Uber/Fx는 이렇게 간단하게 의존성을 주입한다.
Uber/Fx는 각 Constructor에서 필요로 하는 객체(Type)과 해당 객체(Type)을 반환하는 Constructor를 보고 의존성을 주입해준다.
예시로, 아래 Constructor에서 NewMessage()Message 객체를 반환한다. 그리고 NewGreeter(m Message)Message 객체를 필요로 하고 있다. 그렇기에 NewMessage()에서 생긴 Message 객체를 NewGretter(m Messagefe)에 의존성을 주입해준다.
func NewMessage() Message <-- { return Message("Hi there!") } func NewGreeter(m Message <--) Greeter { return Greeter{Message: m} }
Go
복사
참고로 Uber/Fx의 Injection 순서에 대해 빠르게 알아보자
func func1() { fmt.Println("test1") } func func2() { fmt.Println("test2") } func func3() { fmt.Println("test3") } func func4() { fmt.Println("test4") } func func5() { fmt.Println("test5") } func func6() { fmt.Println("test6") } func main() { fx.New( fx.Invoke(func1), fx.Module("Module A", fx.Invoke(func2), fx.Invoke(func3), ), fx.Module("Module B", fx.Invoke(func4), fx.Invoke(func5), ), fx.Invoke(func6), ).Run() } // 실행 결과 // "test2" -> "test3" -> "test4" -> "test5" -> "test1" -> "test6"
Go
복사
실행 순서
1.
자신의 모듈에 등록된 Invoke 함수를 순차적으로 실행된다.
2.
자신의 하위 모듈에 등록된 Invoke 함수를 순차적으로 실행된다.
주의
1.
InvokeModule은 Slice(배열)로 관리되므로 순서가 중요하다.
2.
Invoke 함수는 등록된 순서대로 등록되며, Module 또한 등록된 순서대로 적용
결과
func2func3func4func5func1func6
결론
의존성을 자동으로 주입해주고 불필요한 코드 작성을 줄일 수 있다는 점에서 Uber/Fx에 더 마음이 갔다.
결정적으로 Logger를 Uber의 Zap을 사용하고 있기에 DI 도구도 Uber의 Fx로 선택했다.

Air

Live reload for Go apps
Golang은 컴파일 언어이다. 그렇기에 항상 컴파일 후 실행을 해야하는 번거러움이 있다.
그래서 컴파일 언어는 보통 Hot Reload를 지원하는 라이브러리가 있다.
Spring의 경우 spring-boot-devtools를 주로 사용한다.
보통 Golang 기반으로 서버 애플리케이션을 개발 할 때 코드 변경사항을 적용하기위해 IDE에서 직접 재시작 버튼을 누루거나, CLI를 통해서 재시작 하는 경우가 많다.
이런 불편함을 해결하기 위해 air 패키지를 사용했다.
air 패키지는 터미널에 air 명령어를 입력한 디렉토리를 기준으로 디렉토리안에 있는 .go 파일을 감시(watch)한다.
아래와 같이 watching을 하는 곳이 로깅된다.
air가 watching하는 곳에 있는 .go 파일이 수정될 경우 자동으로 reload된다.
Reload될 때마다 실행할 명령어를 .air.toml 파일을 통해 지정할 수 있는데, swagger도 함께 업데이트될 수 있도록 swag init; go build -o ./.air/main . 로 설정했다.
.air.toml
root = "." testdata_dir = "testdata" tmp_dir = ".air" [build] args_bin = [] bin = "./.air/main" cmd = "swag init; go build -o ./.air/main ." delay = 0 exclude_dir = ["assets", "docs", ".air", "vendor", "testdata"] exclude_file = [] exclude_regex = ["_test.go"] exclude_unchanged = false follow_symlink = false full_bin = "" include_dir = [] include_ext = ["go", "tpl", "tmpl", "html"] include_file = [] kill_delay = "0s" log = "build-errors.log" poll = false poll_interval = 0 rerun = false rerun_delay = 500 send_interrupt = false stop_on_error = false [color] app = "" build = "yellow" main = "magenta" runner = "green" watcher = "cyan" [log] main_only = false time = false [misc] clean_on_exit = false [screen] clear_on_rebuild = false keep_scroll = true
TOML
복사
해당 설정 파일에서 주로 설정할 것들이다.
root : 해당 설정파일에 있는 경로들의 root가 될 경로
tmp_dir : 명령어 실행 결과(Go 빌드 파일)가 저장될 경로
bin : 바이너리 실행 커맨드
cmd : 감시 중인 파일이 수정이 일어날 때마다 실행할 명령어
include_ext : watch 대상이 될 파일 확장자
exclude_dir : watch 대상에서 제외할 파일 디렉토리

Validator

Package validator implements value validations for structs and individual fields based on tags.
HTTP 통신 시, 필수 parameter나 특정 조건에 대한 validation에 필요성을 느껴 도입했다.
기존에는 controller에서 if문을 통해 하나하나 검증을 했다면, 이제는 해당 패키지를 통해 validate tag로 validation을 할 수 있다.
type PaymentListParam struct { Pagination AccountID int TransactionID string Status int `validate:"required,numeric,oneof=0 1 2"` StartDate string `validate:"omitempty,rfc3339"` EndDate string `validate:"omitempty,rfc3339"` }
Go
복사
기본적으로 제공해주는 tag이외에 custom tag를 통해 custom validator를 만들 수 있는데, 이 또한 매우 편리하다.
예시로 해당 프로젝트에서는 API 통신 시 RFC3339 형식으로 통신을 하기로 그라운드룰을 정했기에 RFC3339 형식인지 검증하는 custom validator를 만들었다.
var ( ValidationMapper = map[string]func(level validator.FieldLevel) bool{ "rfc3339": rfc3339, } ) func rfc3339(fl validator.FieldLevel) bool { _, err := time.Parse(time.RFC3339, fl.Field().String()) return err == nil }
Go
복사
이와 같이 필요한 validator를 만들고 parameter 검증에 사용할 수 있다.
검증은 Struct() 함수를 통해 이루어지지만, Multi Error를 위해 한번 더 인터페이스화했다.
func (v *Validator) Struct(data interface{}) error { var result error errs := v.Validate.Struct(data) if errs != nil { for _, err := range errs.(validator.ValidationErrors) { result = multierror.Append(result, err) } } return errors.WithStack(result) }
Go
복사

Go Fiber Boilerplate

아래 프로젝트는 위에서 말한 각종 패키지와 MVC 패턴의 디렉토리 구조가 적용된 Boilerplate이다.
자세한 것은 직접 코드를 보면 이해가 더 빠를 것으로 생각된다.

Router

기존에 Go Fiber로 개발하면서 불편했던 점은 handler를 router에 등록하는 것이였다.
handler와 router가 분리되어있어서 코드 관리와 가독성에 있어서 불편했다.
해당 부분을 Fx 패키지를 이용해 Spring이나 NestJS과 같이 Controller에서 동적으로 매핑될 수 있게 개발했다.

Before

// route.go v1 := app.Group("/v1") account := v1.Group("/account") account.Get("/qna", handler.GetQnaList) account.Get("/qna/category", handler.GetQnaCategory) account.Get("/qna/answer/:qnaId", handler.GetQnaAnswer) account.Put("/qna/answer/:qnaId", handler.UpdateQnaAnswer) ...
Go
복사

After

// qna_controller.go func (ctrl qnaController) Table() []app.Mapping { return []app.Mapping{ {Method: fiber.MethodGet, Path: "/qna", Handler: ctrl.GetQnaList}, {Method: fiber.MethodGet, Path: "/qna/category", Handler: ctrl.GetQnaCategory}, {Method: fiber.MethodGet, Path: "/qna/answer/:qnaIdx", Handler: ctrl.GetQnaAnswer}, {Method: fiber.MethodPut, Path: "/qna/answer/:qnaId", Handler: ctrl.UpdateQnaAnswer} } }
Go
복사

Parameter & Validate

Parameter를 받아오고 이를 Validation을 하는 코드를 매번 작성하는것에 불편함을 느껴 이를 공통 함수화했다.
이렇게 하면 기존에 controller에서 하나하나 받던 parameter들을 한번에 받아올 수 있다.

Before

// controller.go page, _ := strconv.Atoi(ctx.Query("page", "1")) limit, _ := strconv.Atoi(ctx.Query("limit", "50")) orderBy := ctx.Query("order_by", "RegDate DESC") category, _ := strconv.Atoi(ctx.Query("category")) status, _ := strconv.Atoi(ctx.Query("status")) searchType1 := ctx.Query("search_type1") searchParam1 := ctx.Query("search_param1") searchType2 := ctx.Query("search_type2") searchParam2 := ctx.Query("search_param2") param := dto.QnaListQueryParam{ Pagination: dto.Pagination{ Page: page, Limit: limit, OrderBy: orderBy, Offset: utils.GetOffset(page, limit), }, Category: category, Status: status, SearchType1: searchType1, SearchParam1: searchParam1, SearchType2: searchType2, SearchParam2: searchParam2, } err := validator.Validate(param) if err != nil { return err }
Go
복사

After

// controller.go var req dto.GetCoinListReq if err := ctrl.core.Parameter.GetRequest(c, &req); err != nil { return errors.WithStack(err) } var param dto.GetCoinListParam if err := ctrl.core.Parameter.ValidateParams(c, req, &param); err != nil { return errors.WithStack(err) }
Go
복사
아래 공통 코드를 통해 매번 하나하나 받아올 필요가 없어졌다.
// parameter.go func (gp getParameter) GetRequest(ctx *fiber.Ctx, param interface{}) error { if err := ctx.ParamsParser(param); err != nil { return exception.WithData(errcode.InvalidParameter, err, fiber.Map{"error": err.Error()}) } if err := ctx.ReqHeaderParser(param); err != nil { return exception.WithData(errcode.InvalidParameter, err, fiber.Map{"error": err.Error()}) } if err := ctx.QueryParser(param); err != nil { return exception.WithData(errcode.InvalidParameter, err, fiber.Map{"error": err.Error()}) } if ctx.Request().Body() != nil { err := ctx.BodyParser(param) if err != nil { return exception.WithData(errcode.InvalidParameter, err, fiber.Map{"error": err.Error()}) } } return nil } func (gp getParameter) ValidateParams(ctx *fiber.Ctx, req interface{}, param param) error { err := param.GenerateParam(ctx, req) if err != nil { return errors.WithStack(err) } err = gp.helper.Validator.Struct(param) return errors.WithStack(err) }
Go
복사
Parameter를 받아올 때 Query, Body, Path, Header의 구분은 tag를 이용해 이루어진다.
해당 tag는 fiber에서 제공하는 기능이다.
Query
`query:"key"`
Body
`form:"key"`, `json:"key"`
Path
`params:"key"`
Header
`reqHeader:"key"`
bodyTag를 보면 form 인걸 볼 수 있는데 내부 코드를 보면 body가 json 형식인 경우(content-type: application/json) json tag를 줘도 된다.
아래는 실제로 parameter를 받아올 때 작성한 DTO 구조체이다.
type GetCoinListReq struct { AccountId int `params:"accountId"` Type int `query:"type"` StoreType string `query:"store_type"` }
Go
복사

디렉토리 구조

📂root ├── 📂app │ ├── 📂core // 모든 도메인에서 사용되는 공통적인 core 코드들 │ │ ├── 📂helper // gorm, zap, sentry, kafka... etc │ │ │ ├── 📂database // DB 설정 및 연결 │ │ │ ├── 📂sentry // sentry 설정 및 연결 │ │ │ └── 📂logger // logger 설정 및 생성 │ │ ├── 📂util // 단순 유틸성 코드 (date formatting 등) │ │ ├── 📂consts // 공통 enum │ │ ├── 📂base // 각 도메인에서 공통적으로 쓰이는 유틸성 로직들 │ │ ├── 📂exception // 에러 처리 │ │ │ ├── 📂errcode // error code │ │ │ ├── 📃error_handler.go // global error handler │ │ │ ├── 📃error.go // error struct & function │ │ │ ├── 📃recover.go // error recover │ │ │ └── 📃error_message.go // error message │ │ ├── 📂model // DB Table과 매칭되는 entity(model)들 │ │ └── 📂repository // 공통 query들에 대한 ORM 코드들 │ ├── 📂domain // business domains │ │ └── 📂temp │ │ ├── 📂dto // request, response struct │ │ ├── 📂controller // parameter validation │ │ │ └── 📃temp_controller.go │ │ ├── 📂service // business logic │ │ ├── 📂repository // domain 종속적인 query들 │ │ └── 📃module.go // fx DI modules │ ├── 📃app.go // server initialize │ └── 📃router.go // router, url-controller mapping ├── 📂config // Database, Kafka... configuration ├── 📂docs // swagger 등 문서 ├── 📂vendor // private library (git에 올라가면 안되는 것들, auth key 등) │ ├── 📃main.go // server start point ├── 📃go.mod ├── 📃go.sum ├── 📃.golangci.yml // golang lint └── 📃.air.toml // air setting
TOML
복사