안녕하세요, 뱅크샐러드 코어 백엔드 팀의 정겨울입니다.
뱅크샐러드는 백엔드 서비스에 다양한 언어를 사용하고 있습니다. 특히 지난 4년간은 Go와 gRPC를 활발히 사용하며 많은 코어 서비스를 만들었고, 이 과정에서 다양한 실수와 시행착오를 겪으며 코딩 컨벤션을 정해나갈 수 있었습니다.
Go는 언어 자체의 키워드가 적어 누구나 쉽게 시작하기 좋고, 여러 제약사항 덕분에 오용하기 어려운 언어입니다. 기존엔 없었다가 2022년 1.18 버전부터 추가되어 사용할 수 있게 된 Generic은 남용되지 않도록 언제 Generic을 사용하는게 좋은지 가이드 해주는 공식 블로그 글이 존재하며, 포매팅도 정해진 스타일이 있습니다.
그럼에도 언제나 모호한 부분은 존재하기에 조직에선 비용을 줄이기 위해 공통된 규칙을 합의하고 이런 규칙을 코드에 반영하기 위해 golangci-lint같은 린트 도구를 사용합니다. 한 두 사람이 몇 안되는 레포의 코드 일관성을 유지할 순 있지만 시간이 흐르고, 조직의 규모가 변하며, 구성원이 바뀔수록 코드 일관성 그 이상으로 여러 코드 베이스의 개념적 일관성을 유지하기 위한 노력이 필요합니다. 이 글에선 주로 코드 일관성 측면에서 뱅크샐러드가 어떤 Go 코딩 컨벤션을 따르며 권장하는지 얘기해보고자 합니다.
위키피디아 상
코딩 컨벤션 = 코딩 스타일 + 프랙티스 + 원칙
으로 서술되어 있어 여기선 모든걸 총칭하는 용어로 ‘코딩 컨벤션’을 사용합니다.
이미 Golang에 관한 컨벤션 가이드와 best practices는 많이 존재하기에 앞으로 얘기할 내용에는 아래 자료와 중복되는 내용도 있습니다. 이 블로그 글에 전부 담지 못했지만 어떤 코드가 Go다운지, 관용적인 Go 코드인지 궁금하다면 한 번씩 읽어보길 권합니다. 뱅크샐러드 내부의 Go 코드와 코드 리뷰 또한 아래 자료를 근간으로 합니다.
각 자료의 순서는 중요도와 무관합니다.
이 단락에선 더 나은 코드를 위해 권장하는 코딩 프랙티스를 다룹니다. 각 항목은 간결한 설명을 위해 bullet point와 다른 어체로 작성되었습니다.
main.go
같은 서버 애플리케이션 초기화 시점에만 빠른 실패를 위해 사용함regexp.MustCompile()
, netip.MustParseAddr()
)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
}
os.Exit(1)
을 수행함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()
}
for i, id := range ids
이런식으로 고루틴을 시작해 results[i] = result
처럼 slice assign으로 해결해도 됨errors.WithStack(err)
로 해당 에러가 발생한 시점의 스택 트레이스를 덧붙여 줄 수 있음errors.New("...")
은 에러를 생성하며 스택을 담아주므로 굳이 errors.WithStack(errors.New("..."))
처럼 두번 할 필요 없음var errTimout = errors.New("i/o timeout")
처럼 sentinel error로 선언했을 땐 생성시 stack이 첨부되기에 첫 에러 반환지점에서 errors.WithStack(errTimeout)
처럼 감싸줘야함StackTrace() errors.StackTrace
interface를 만족하는지 assertion하는 식으로 구현함errors.Wrap(err, "...")
하거나 errors.WithStack(err)
하고 반환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"
같은 문구를 제외하고 마침표 없는 소문자로만 작성
log.WithError(err).Error("failed to close db")
처럼 에러 로깅함errors.Wrap(err, "user.GetUser")
같은 식으로 서비스와 rpc 명으로 감싸주기도 함// ❌
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")
// ❌
if err := nil {
log.Println("failed to get user id: %d", userID)
return err
}
// ✅
if err := nil {
return err
}
func nextInt(b []byte, pos int) (int, int)
// vs
func nextInt(b []byte, pos int) (value, nextPos int)
MaxIdleConnsPerHost
기본 값이 2이기 때문에 성능을 위해 커스텀하게 설정하길 권장MaxIdleConns
기본 값이 100이기에 주로 100으로 맞춰줌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.
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")
}
}()
// ❌
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
}
len()
을 사용해 판단해야함// ❌
if results == nil {
// ...
}
// ✅
if len(results) == 0 {
// ...
}
map[type]bool
이거나 map[type]struct{}
두 가지 선택// ✅
m := make(map[string]bool{})
if m["key"]{
// ...
}
// ✅
v := make(map[string]struct{}{})
if _, ok := v["key"]; ok{
// ...
}
v := m[k]
보단 v, ok := m[k]
을 권장if !ok { return err }
하느라 함수 반환 시그니쳐가 바뀐다면 굳이 그럴 필요 까진 없음// 간결한 예시를 위해 위에서 언급한 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
}
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.
time.Duration
사용const timeoutInSeconds = 5
보단 const timeout = 5 * time.Second
처럼 사용해야 함time.Duration
사용os.Getenv("TIMEOUT_IN_SECONDS")
로 받되 가능한 한 빨리 time.Duration
으로 변환하길 권장time.Now()
하면 로컬 타임존을 반영한 값이 나옴.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()
if got != expected { t.Errorf("...") }
보단 assert.Equal(t, expected, got)
를 선호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)
})
}
}
time.Now()
, rand.Int()
를 직접 호출하기 보단 nowFunc func() time.Time()
, rander func() int
를 인자로 넘김{"a": "b", "c": 1}
로 마샬링되고 어떨 땐 {"a": "b","c": 1}
로 마샬링됨assert.JSONEq
, cmp.Diff
함수를 사용하거나mustMarshal()
같은 테스트 함수를 만들어 그때그때 marshal을 수행해야함// ❌
_ = 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
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) {}
import (
"standard/lib"
"github.com/thrid-party/lib"
"github.com/banksalad/lib"
)
getXxxs
보단 listXxxs
네이밍을 권장const defaultPageSize = 20
const DEFAULT_PAGE_SIZE = 20
.
├── 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 서버 개발 원칙인 누구나 알아볼 수 있는 코드를 어떻게 실천하는지 등의 내용도 다루고 싶었으나 글이 너무 길어져 글을 줄이게 됐습니다.
위 주제들도 따로 소개해드릴 순간이 있길 기대하며 여기까지 긴 글 읽어주셔서 감사합니다.
보다 빠르게 뱅크샐러드에 도달하는 방법 🚀
지원하기