Golang Web Frameworks
•
Golang은 다른 언어에 비해 절대적인 입지를 가진 프레임워크가 없기 때문에 여러 프레임워크 중에서 선택해야한다.
Java - Spring
Node.js - Express, NestJS
Python - Django, FastAPI
•
다만 절대적인 입지를 가진 프레임워크가 없을 뿐이지 Golang에서 자주 쓰이는 대표적인 프레임워크는 총 5개정도 있다.
•
Golang의 가장 오래된 웹 프레임워크이자 압도적으로 많은 Github Star를 보유중인 프레임워크.
•
다만, 한국에선 다른 프레임워크들에 비해 무겁다는 이유로 잘 사용되지 않는 것 같다.
•
해당 글에서 다룰 웹 프레임워크이다.
•
다른 웹 프레임워크보다 압도적인 성능을 보여준다.
•
위 그래프를 보면 다른 프레임워크에 비해 가장 늦게(2020년) 출시되었지만 압도적인 성장을 보여주고 있다.
•
기존에는 다른 프레임워크에 비해 눈에 띄지 않던 프레임워크였으나, 최근 v2로 업데이트되면서 Github Star가 fiber 를 넘어섰습니다.
•
beego는 built-in ORM이며 GORM을 사용하지 않는다.
•
다른 프레임워크들에 비해 복잡하지 않고 단순하다.
•
성능은 gin과 비슷하다.
◦
서로가 자기가 더 빠르다고 벤치마킹 성능을 공개했다.
Benchmark
Go Library
프로젝트 구조를 만들기 위해 사용된 라이브러리를 소개한다
Uber/Zap
Zap이란?
Blazing fast, structured, leveled logging in Go.
•
Go에서 실시간(hot path)으로 로깅을 할 때 reflection 기반 직렬화 및 문자열 포맷팅에 많은 비용이 발생한다.
◦
이 작업은 CPU를 많이 사용하며 많은 small allocation을 발생시켜 메모리에 부담을 준다.
◦
즉, encoding/json과 fmt.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.
•
•
Fx는 Uber에서 개발한 reusable하고 composable한 모듈로 어플리케이셔을 쉽게 구축할 수 있는 프레임워크이다.
◦
◦
경험상 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.
Invoke 및 Module은 Slice(배열)로 관리되므로 순서가 중요하다.
2.
Invoke 함수는 등록된 순서대로 등록되며, Module 또한 등록된 순서대로 적용
•
결과
◦
func2 → func3 → func4 → func5 → func1 → func6
결론
•
의존성을 자동으로 주입해주고 불필요한 코드 작성을 줄일 수 있다는 점에서 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 검증에 사용할 수 있다.
•
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, ¶m); 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
복사