Engineering
뱅크샐러드 Go 코딩 컨벤션
2023년 9월 6일
원문에서 보기 ↗안녕하세요, 뱅크샐러드 코어 백엔드 팀의 정겨울입니다.
뱅크샐러드는 백엔드 서비스에 다양한 언어를 사용하고 있습니다. 특히 지난 4년간은 Go와 gRPC를 활발히 사용하며 많은 코어 서비스를 만들었고, 이 과정에서 다양한 실수와 시행착오를 겪으며 코딩 컨벤션을 정해나갈 수 있었습니다.
Go는 언어 자체의 키워드가 적어 누구나 쉽게 시작하기 좋고, 여러 제약사항 덕분에 오용하기 어려운 언어입니다. 기존엔 없었다가 2022년 1.18 버전부터 추가되어 사용할 수 있게 된 Generic은 남용되지 않도록 언제 Generic을 사용하는게 좋은지 가이드 해주는 공식 블로그 글이 존재하며, 포매팅도 정해진 스타일이 있습니다.
그럼에도 언제나 모호한 부분은 존재하기에 조직에선 비용을 줄이기 위해 공통된 규칙을 합의하고 이런 규칙을 코드에 반영하기 위해 golangci-lint같은 린트 도구를 사용합니다. 한 두 사람이 몇 안되는 레포의 코드 일관성을 유지할 순 있지만 시간이 흐르고, 조직의 규모가 변하며, 구성원이 바뀔수록 코드 일관성 그 이상으로 여러 코드 베이스의 개념적 일관성을 유지하기 위한 노력이 필요합니다. 이 글에선 주로 코드 일관성 측면에서 뱅크샐러드가 어떤 Go 코딩 컨벤션을 따르며 권장하는지 얘기해보고자 합니다.
위키피디아 상
코딩 컨벤션 = 코딩 스타일 + 프랙티스 + 원칙으로 서술되어 있어 여기선 모든걸 총칭하는 용어로 ‘코딩 컨벤션’을 사용합니다.
읽어두면 좋은 자료
이미 Golang에 관한 컨벤션 가이드와 best practices는 많이 존재하기에 앞으로 얘기할 내용에는 아래 자료와 중복되는 내용도 있습니다. 이 블로그 글에 전부 담지 못했지만 어떤 코드가 Go다운지, 관용적인 Go 코드인지 궁금하다면 한 번씩 읽어보길 권합니다. 뱅크샐러드 내부의 Go 코드와 코드 리뷰 또한 아래 자료를 근간으로 합니다.
각 자료의 순서는 중요도와 무관합니다.
- The Zen of Go
- 각 구절의 상세한 설명: The Zen of Go | Dave Cheney
- 한국어 번역 Go의 철학 | marsettler.com
- Go Proverbs
- Effective Go | go.dev
- CodeReviewComments · golang/go Wiki | GitHub
- Practical Go | Dave Cheney
- Thanos 팀 스타일 가이드 (한국어, 원문)
- Uber 팀 스타일 가이드 (한국어, 원문)
- Ashley McNamara + Brian Ketelsen. Go best practices | YouTube
- Idiomatic Go | Sourcegraph
- Go Style Best Practices | Google
코딩 프랙티스
이 단락에선 더 나은 코드를 위해 권장하는 코딩 프랙티스를 다룹니다. 각 항목은 간결한 설명을 위해 bullet point와 다른 어체로 작성되었습니다.
Don’t panic
- 프로덕션 런타임(서버가 올바르게 시작되어 요청을 처리할 수 있는 상태)에선 절대 panic을 발생시키지 않음
- 프로세스를 종료시키는 fatal도 마찬가지로 사용하지 않음
- panic은 다른 언어의 try, catch 문법처럼 예외처리를 위한 것이 아님
- 대신 모든 fatal, panic은
main.go같은 서버 애플리케이션 초기화 시점에만 빠른 실패를 위해 사용함 - 의도치 않은 panic에 대비해 서버 인터셉터 혹은 미들웨어로 recovery 체인을 추가하는게 권장됨 (e.g. echo의 미들웨어, grpc 인터셉터)
- 같이 보면 좋을 문서
- Effective Go - Panic
- panic과 recover에 관한 자세한 내용은 Recover function in Go (Golang) 참고
Panic을 낼 수 있는 함수는 must prefix 붙이기
- 내부에서 panic이 발생할 수 있다면 must prefix를 붙이는게 관례 (e.g.
regexp.MustCompile(),netip.MustParseAddr()) - must 류 함수는 주로
main.go같은 초기화 시점에서만 사용
// MustAtoi parses the string value as an int value.
// This function panics on error.
func MustAtoi(s string) int {
i, err := strconv.Atoi(s)
if err != nil {
panic(err)
}
return i
}
Panic vs Fatal
- panic은 스택트레이스와 함께 stderr에, fatal은 트레이스 없이 현재 설정된 output에 출력함
- panic은 deferred 함수를 실행하나 fatal은
os.Exit(1)을 수행함 - 다만 이런 차이에도 불구하고 둘 다 런타임에 이 고민을 한다면 뭔가 잘못된 것
- 위에서 언급된 사례로만 사용한다면 스택 출력이 간결한 fatal이 유용할 때도 있고
- 테스트 코드에선 스택트레이스를 위해 panic을 사용하기도 함
Panic safe goroutine
- 그냥 고루틴을 사용하면 fire and forgot으로 동작함
- 고루틴이 종료되길 기다리기 위해 sync.Waitgroup 사용하다
- 에러 처리를 좀 더 편하게 하기 위해 x/sync/errgroup 쓰다가
- errgroup은 하나라도 에러가 발생하면 다른 고루틴에 cancel이 전파 되거나 하나의 에러만 기록돼서
- 멀티 에러 고루틴을 위해 hashicorp/go-multierror 쓰다가
- recovery 미들웨어는 각 핸들러 함수까지만 recover를 보장해주지 그 핸들러 안에서 만든 고루틴의 panic까지는 recover 해주지 않기에
- 핸들러 안에서 panic safe 하려고 아래처럼 grouper를 따로 만들어 사용함
type PanicSafeGroup struct {
mu sync.Mutex
err *multierror.Error
wg sync.WaitGroup
}
func (g *PanicSafeGroup) Go(f func() error) {
g.wg.Add(1)
go func() {
defer g.wg.Done()
defer func() {
var err error
if x := recover(); x != nil {
switch x := x.(type) {
case error:
err = x
default:
err = errors.Errorf("%s", x)
}
}
if err != nil {
g.mu.Lock()
g.err = multierror.Append(g.err, errors.Wrap(err, "recovered"))
g.mu.Unlock()
}
}()
if err := f(); err != nil {
g.mu.Lock()
g.err = multierror.Append(g.err, err)
g.mu.Unlock()
}
}()
}
func (g *PanicSafeGroup) Wait() error {
g.wg.Wait()
g.mu.Lock()
defer g.mu.Unlock()
return g.err.ErrorOrNil()
}
Concurrent safe한 결과 모으기
- 위처럼 여러 고루틴을 기다리고 끝이 아니라 그 안의 결과를 한 곳으로 모아야 할 때가 있음
- 그렇다고 직관적으로 떠오르는 slice append를 사용하면 data race 에러가 발생함
- 이를 mutex를 사용해 해결해도 되고
- 혹은
for i, id := range ids이런식으로 고루틴을 시작해results[i] = result처럼 slice assign으로 해결해도 됨 - sync map을 쓰거나 채널로 모으는 것도 가능
Error stacking
- 에러 스택트레이스를 추가하기 위해 pkg/errors 패키지를 de facto로 사용
errors.WithStack(err)로 해당 에러가 발생한 시점의 스택 트레이스를 덧붙여 줄 수 있음- 이런 stacking은 가장 안쪽에서 한 번만 하는게 원칙
errors.New("...")은 에러를 생성하며 스택을 담아주므로 굳이errors.WithStack(errors.New("..."))처럼 두번 할 필요 없음var errTimout = errors.New("i/o timeout")처럼 sentinel error로 선언했을 땐 생성시 stack이 첨부되기에 첫 에러 반환지점에서errors.WithStack(errTimeout)처럼 감싸줘야함- 로깅 미들웨어에서 에러 로깅 시 스택트레이스도 함께 로깅하기 위해 별도 미들웨어나 옵션 구성이 필요할 수 있음
- 보통 에러가 잡혔을 때
StackTrace() errors.StackTraceinterface를 만족하는지 assertion하는 식으로 구현함
- 보통 에러가 잡혔을 때
- 같이 보면 좋을 문서
Error handling
- sentinel 에러 선언은 이 에러가 다른 곳에서 분기처리되어 특별하게 대응해줘야 하는 에러일 때만 선언함
- 그렇지 않을 땐 그저
errors.Wrap(err, "...")하거나errors.WithStack(err)하고 반환 - 특정 에러(e.g.
sql.ErrNoRows를 판별해야 한다면errors.Cause(err) == sql.ErrNoRows처럼 하거나errors.Is(err, sql.ErrNoRows)처럼 분기errors.Is,errors.As의 차이는 Handle errors in Go (Golang) with errors.Is() and errors.As() 참고
- 에러 네이밍 컨벤션은
"failed to","cannot"같은 문구를 제외하고 마침표 없는 소문자로만 작성- Thanos팀 스타일 가이드 참고
- 에러 네이밍에 포함하지 않을 뿐이지 최상위 에러 로깅 시점엔
log.WithError(err).Error("failed to close db")처럼 에러 로깅함
- 위 원칙 하에 다른 서비스의 rpc 호출하다 에러가 났을 땐 더 쉬운 디버깅을 위해
errors.Wrap(err, "user.GetUser")같은 식으로 서비스와 rpc 명으로 감싸주기도 함 - 같이 보면 좋을 문서
Error logging
- 핸들러 내부에서 발생한 에러는 인터셉터 혹은 미들웨어에 의해 로깅하길 권장
- defer에서 발생한 에러는 무시하지 않고 에러 레벨로 로깅함
- Uber 팀 스타일 가이드 참고
// ❌
defer db.Close()
// ✅
defer func() {
if err := db.Close(); err != nil {
logrus.WithError(err).Error("failed to close db")
}
}()
// ✅
defer logutil.CloseWithLog(db, "close db")
- 에러 로깅은 parent에게 맡기는게 원칙
// ❌
if err := nil {
log.Println("failed to get user id: %d", userID)
return err
}
// ✅
if err := nil {
return err
}
No named return
- 함수 시작 시 기본 값으로 초기화 되는 변수라고 이해하면 좋음
- 같은 유형이 반환될 때 어떤 의미의 값인지 인지하기 쉬워짐
func nextInt(b []byte, pos int) (int, int)
// vs
func nextInt(b []byte, pos int) (value, nextPos int)
- 다만 함수가 길어질수록 언제 어느 값으로 리턴되는지 추적이 어려움
- 짧은 함수에선 보다 간결해보일 수 있지만 괜히 ‘이 땐 named return을 사용할 수 있으려나’ 고민의 여지를 주지 않기 위해 뱅크샐러드에선 일괄적으로 사용하지 않음
- revive lint 룰로도 비활성화해둠
- 같이 보면 좋을 문서
HTTP client 설정
- 외부 서드파티 api나 내부 http 서버를 호출할 땐 해당 서비스의 명세를 따르는 http client를 따로 선언해 사용함
- 이런 http client는 내부 풀에서 keep alive 커넥션을 재사용함
- 다만
MaxIdleConnsPerHost기본 값이 2이기 때문에 성능을 위해 커스텀하게 설정하길 권장 MaxIdleConns기본 값이 100이기에 주로 100으로 맞춰줌- 메모리 사용량과 트레이싱을 참고해 트래픽에 맞게 조정하길 권장함
- 같이 보면 좋을 문서
HTTP connection 재사용
-
커넥션을 재사용하기 위해선 1. response body를 다 읽어야하고 2. 닫아야함
-
Go 1.7 이후 body drain은 내장된걸로 보이나 특정 상황에선 drain이 안될 수도 있다는 이슈가 있어
-
뱅크샐러드에선 http response body는 반드시 drain 해주고 닫고 있음
- 50 Shades of Go: Traps, Gotchas, and Common Mistakes for New Golang Devs 글 기반으로 리다이렉트 실패 시 에러체크보다 먼저 non nil response body를 닫아줘야 하는걸로 알 수 있는데 1.21 기준 틀린 서술임
- net/http 패키지의 Client.Do 주석을 보면 아래처럼 서술되어 있음
On error, any Response can be ignored. A non-nil Response with a non-nil error only occurs when CheckRedirect fails, and even then the returned Response.Body is already closed.
-
아래처럼 코드를 구성해 사용 함. (DOS 공격을 고려해
io.CopyN을 사용해도 좋음)
resp, err := httpClient.Do(req)
if err != nil {
return nil, errors.WithStack(err)
}
defer func() {
if _, err := io.Copy(io.Discard, resp.Body); err != nil {
log.WithError(err).Error("failed to drain http response body")
}
if err := resp.Body.Close(); err != nil {
log.WithError(err).Error("failed to close http response body")
}
}()
Slice 선언 시 len, cap 설정
- slice에 추가될 아이템의 개수를 미리 알고 있다면 len을, 적어도 cap이라도 설정하길 권장함
- map도 마찬가지로 cap을 설정하길 권장함
- 들이는 품에 비해 가독성이 크게 저하되지도 않고 거의 공짜 이득임
- 같이 보면 좋을 문서
// ❌
var ids []string
for _, u := range users {
ids = append(ids, u.id)
}
// ✅
ids := make([]string, len(users))
for i, u := range users {
ids[i] = u.id
}
Nil slice vs Empty slice
- 언제나 nil slice를 사용
- CodeReviewComments - Declaring Empty Slices 참고
- 이와 별개로 slice, map의 emptyness는 항상
len()을 사용해 판단해야함
// ❌
if results == nil {
// ...
}
// ✅
if len(results) == 0 {
// ...
}
Bool map과 struct{} map
- 주로 유니크함을 따질 때나 이미 존재하는지 체크하기 위해 slice를 map으로 만들 때 사용
map[type]bool이거나map[type]struct{}두 가지 선택- 사실 벤치마크에 따라 메모리 사용량과 속도가 누가 더 빠르다가 다르긴한데 매우 큰 용량을 다룰 때 struct가 사소하게 메모리 효율적인 경향을 띰
- bool은 false, true를 인지해야하는 반면 struct{} map은 값에 신경을 쓰지 않을 수 있음
- bool map이 약간 더 간결한 편이라 기호에 따라 취사선택
// ✅
m := make(map[string]bool{})
if m["key"]{
// ...
}
// ✅
v := make(map[string]struct{}{})
if _, ok := v["key"]; ok{
// ...
}
Map 조회 시 ok 체크
v := m[k]보단v, ok := m[k]을 권장- ‘이 값은 반드시 있지’보단 있어야 방어적으로 짜는게 종종 유용함
- 다만 실제로 언제나 있을 수밖에 없는데 굳이
if !ok { return err }하느라 함수 반환 시그니쳐가 바뀐다면 굳이 그럴 필요 까진 없음
Avoid map loop
- Go는 map의 순회 순서를 보장해주지 않음
- map을 순회하며 slice나 기타 반환 값을 만들면 flaky 테스트를 유발하거나 해시 값이 달라지는 버그가 될 수 있음
- append 후 sort를 하거나 아예 map을 순회하지 않길 권장
// 간결한 예시를 위해 위에서 언급한 len, cap 설정을 생략함
// ❌
uniqNames := make(map[string]bool)
for _, n := range allNames {
uniqNames[n] = true
}
names := make([]string, 0)
for n := range uniqNames {
names = append(names, n) // unstable
}
// ✅
names := make([]string, 0)
uniqNames := make(map[string]bool)
for _, n := range allNames {
if uniqNames[n] {
continue
}
uniqNames[n] = true
names = append(names, n) // stable
}
문자열 loop 시 range 사용
- 문자열을 순회할 때
for i := 0; i < len(s); i++처럼 하면 안되고 for i, rune := range s를 사용해야함
// ❌
s := "안녕"
for i := 0; i < len(s); i++ {
fmt.Println(i, s[i], string(s[i]))
}
// 0 236 ì
// 1 149
// 2 136
// 3 235 ë
// 4 133
// 5 149
// ✅
s := "안녕"
for i, r := range s {
fmt.Println(i, r, string(r))
}
// 0 50504 안
// 3 45397 녕
문자열 길이
- 우리가 직관적으로 이해하는 그 문자열의 길이를 알아내려고 할 때는
unicode/utf8패키지의utf8.RuneCountInString를 사용해야 함 - 같이 보면 좋을 문서
// ❌
len("abc") // 3
len("última") // 7
len("世界") // 6
len("안녕") // 6
len("✨🍰✨") // 10
// ✅
utf8.RuneCountInString("abc") // 3
utf8.RuneCountInString("última") // 6
utf8.RuneCountInString("世界") // 2
utf8.RuneCountInString("안녕") // 2
utf8.RuneCountInString("✨🍰✨") // 3
context.TODO() 보다 context.Background() 사용
- 각 주석을 참고
context.TODO()주석 Code should use context.TODO when it’s unclear which Context to use or it is not yet available (because the surrounding function has not yet been extended to accept a Context parameter).context.Background()주석 It is typically used by the main function, initialization, and tests, and as the top-level Context for incoming requests.
Early return 애용
- nested보단 flatten한 구조 선호
- 여러 validation이 있다면 main flow는 해피 케이스가 되도록 구성
- CodeReviewComments - Indent Error Flow 참고
time.Duration 사용
const timeoutInSeconds = 5보단const timeout = 5 * time.Second처럼 사용해야 함- 함수 인자로는 언제나
time.Duration사용 - 환경변수는
os.Getenv("TIMEOUT_IN_SECONDS")로 받되 가능한 한 빨리time.Duration으로 변환하길 권장
타임존
time.Now()하면 로컬 타임존을 반영한 값이 나옴- timezone awareness 값이라 거기에
.UTC()를 붙이거나.In(KST)를 붙여 사용함 - 아래처럼 초기화 단계에서 미리 로딩한 타임존을 활용하길 권장
// MustLoadKST returns KST timezone.
// This function panics on error.
func MustLoadKST() *time.Location {
loc, err := time.LoadLocation("Asia/Seoul")
if err != nil {
panic(err)
}
return loc
}
var kst = MustLoadKST()
테이블 기반 테스트
- 가능한 한 given, when, then 구조의 테이블 기반 테스트를 할 것
- stretchr/testify 라이브러리의 각종 assert, require 패키지를 적극 사용함
if got != expected { t.Errorf("...") }보단assert.Equal(t, expected, got)를 선호- suite 패키지는 사용하지 않음
- 뱅크샐러드에선 테스트의 간결함을 유지하고 누구나 쉽게 작성하기 위해 별도의 cucumber/godog, onsi/ginkgo 라이브러리를 사용하진 않고있음
- 같이 보면 좋을 문서
func TestLastDay(t *testing.T) {
cases := []struct {
name string
t time.Time
expected time.Time
}{
{
name: "마지막 일이 28일",
t: date(2023, 2, 1),
expected: date(2023, 2, 28),
},
{
name: "마지막 일이 29일인 윤년",
t: date(2020, 2, 1),
expected: date(2020, 2, 29),
},
{
name: "주어진 날이 첫 날이 아니어도 잘 찾아줌",
t: date(2023, 1, 21),
expected: date(2023, 1, 31),
},
// ...
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
actual := LastDay(tc.t)
assert.Equal(t, tc.expected, actual)
})
}
}
No monkey patch
- 사이드 이펙트가 있는 코드는 항상 외부에서 주입하길 권장
- 핸들러 내부에서
time.Now(),rand.Int()를 직접 호출하기 보단nowFunc func() time.Time(),rander func() int를 인자로 넘김
Deterministic test
- 위에서 언급한대로 deterministic한 테스트를 위해 map 순회는 가능한 한 피해야 함
- gRPC 서버라면 protojson 라이브러리를 사용할 때 output 비교에 주의해야함
- 어떨 땐
{"a": "b", "c": 1}로 마샬링되고 어떨 땐{"a": "b","c": 1}로 마샬링됨 - Go FAQ | Protocol Buffers Documentation
- encoding: provide canonical output format · Issue #1121 · golang/protobuf
- 한 program 내에서는 같은 output이 나오는게 보장되므로 결과를 비교할 땐
assert.JSONEq,cmp.Diff함수를 사용하거나 - mocking을 위해 값을 지정해줘야할 필요가 있을 땐 직접 json string을 넘기지 않고
mustMarshal()같은 테스트 함수를 만들어 그때그때 marshal을 수행해야함
- 어떨 땐
Avoid reflect
- json/encode처럼 이미 내부적으로 reflect를 쓰고있고 그래야만 하는 경우도 있음
- 하지만 서버 핸들러에서 reflect를 사용한다면 보통 무언가 잘못되어가고 있다는 신호
- reflect로 할 수 있다고 reflect를 사용해야하는 건 아님
- 제너레이터나 테스트 코드에선 유용할 수 있음
Functional options
- Go엔 optional parameter가 없음
- 필수 파라미터를 분리하며 기본 값을 설정하거나 함수 시그니쳐의 하위 호환성 보장을 위해 라이브러리에 권장하는 패턴
- 같이 보면 좋을 문서
// ❌
_ = NewAESCipher(key, nil, nil, nil, nil)
// ✅
_ = NewAESCipher(key, WithGCM(nonce))
_ = NewAESCipher(key, WithEncoding(euckr))
코딩 스타일
코딩 스타일에 정해진 정답은 없으며 Go 생태계에서 널리 받아들여진 내용도 있겠지만 주로 뱅크샐러드에서 일관성을 위해 권장하는 규칙을 다룹니다. 각 항목은 그대로 따르기보단 코드의 일관성을 위해 각 조직과 개인의 기호에 맞게 정해야 합니다.
일반적인 네이밍 규칙은 Go 네이밍 룰 문서를 참고하면 좋습니다.
함수의 인자 순서
ctx context.Context는 항상 맨 앞 The Context should be the first parameter, typically named ctx- 이후 db client, service client를 앞으로 넘기며
- 여러 결과를 들고 있는 slice나 map같은 무거운 인자를 앞에,
userID string이나now time.Time같은 가벼운 인자를 뒤로 넘김
테스트 함수 네이밍
func TestPublicFunc(t *testing.T) {}
func Test_privateFunc(t *testing.T) {}
func TestPublicStruct_PublicFunc(t *testing.T) {}
func TestPublicStruct_privateFunc(t *testing.T) {}
func Test_privateStruct_PublicFunc(t *testing.T) {}
func Test_privateStruct_privateFunc(t *testing.T) {}
파일 내 선언 순서
- interface - type - const - var - new func - public receiver func - private receiver func - public func - private func 순서를 따름
- 우버팀의 Function Grouping and Ordering 스타일을 참고함
- type construct 패턴으로 주로 사용되는 new func는 Go Pointers: Why I Use Interfaces | Medium 글 참고
import 순서
- 세 그룹으로만 나눔
import (
"standard/lib"
"github.com/thrid-party/lib"
"github.com/banksalad/lib"
)
- 이는 goimports 도구 만으로 정렬되지 않기에 gci 혹은 goimports-reviser 도구를 추천함
단수는 get 복수는 list
- 여러 값을 가져오는 함수는
getXxxs보단listXxxs네이밍을 권장 - 가산명사 불가산명사를 엄격하게 구분하지 않고 관용적으로 사용
모호한 단어 피하기
- information, details, summary같은 모호한 단어를 쓰지 않음
- user information 대신 user가, experiment information보단 experiment가 권장됨
- 항상 쓰지 않는건 아니고 information을 사용해 뜻이 더 명확해질 땐 사용
- summary와 details는 어느 필드까지가 요약이고 어느 필드까지가 상세 내용인지 시간에 따라 달라지기 쉽기에 선호되지 않음
const 네이밍
- 언제나 camel case로 작성
const defaultPageSize = 20 - screaming snake case로 작성하지 않음
const DEFAULT_PAGE_SIZE = 20 - Go naming conventions for const 참고
패키지 네이밍
- core, util, helper, common, infra같은 네이밍을 피함
- 대부분의 함수는 parser, timeconv, testutil 등 적절한 패키지 네이밍이 존재
- 따로 분리할 만큼 함수가 많지 않다면 함수 1개짜리 shallow한 패키지를 여럿 만들기 보단 해당 함수를 사용하는 패키지에 같이 두고 나중에 사용 사례가 확장되면 분리하길 권장
- 같이 보면 좋을 문서
프로젝트 구조
- 꼭 필요한 패키지 폴더만 최소한으로 유지하려 함
.
├── client # 다른 서비스 의존성, third party http client
├── cmd # 초기화, 의존성 주입 등을 하는 entrypoint
├── config # 환경 변수, 설정 파일
└── server
├── handler # rpc마다 하나의 핸들러 파일
├── db
│ └── mysql # mysql 기반 자동 생성된 db 모델
└── server.go # 미들웨어, 핸들러 설정
다루지 못한 내용
여기까지 뱅크샐러드가 어떤 코딩 스타일과 프랙티스로 Go를 사용하는지 대략적으로 알아봤습니다.
위에서 언급한 내용 이외에도 Go에서 컨텍스트를 올바르게 사용하는 방법, 제네릭이 유용한 순간, 제네릭 기반의 samber/lo 패키지의 활용, 인터페이스 임베딩, 센티널 오류 핸들링, 컨테이너 환경에서 uber-go/automaxprocs 패키지를 사용한 GOMAXPROCS 설정, 뱅크샐러드가 go-banksalad라는 내부 라이브러리에 어떤 내용을 담고 어떻게 사용하는지, Go 코딩 컨벤션 이상으로 어떤 HTTP, gRPC 프레임워크와 db 라이브러리를 왜 선택했고 어떻게 사용하는지, 뱅크샐러드의 Go 서버 개발 원칙인 누구나 알아볼 수 있는 코드를 어떻게 실천하는지 등의 내용도 다루고 싶었으나 글이 너무 길어져 글을 줄이게 됐습니다.
위 주제들도 따로 소개해드릴 순간이 있길 기대하며 여기까지 긴 글 읽어주셔서 감사합니다.