grep

Engineering

뱅크샐러드 Go 코딩 컨벤션

뱅크샐러드

2023년 9월 6일

원문에서 보기 ↗

안녕하세요, 뱅크샐러드 코어 백엔드 팀의 정겨울입니다.

뱅크샐러드는 백엔드 서비스에 다양한 언어를 사용하고 있습니다. 특히 지난 4년간은 Go와 gRPC를 활발히 사용하며 많은 코어 서비스를 만들었고, 이 과정에서 다양한 실수와 시행착오를 겪으며 코딩 컨벤션을 정해나갈 수 있었습니다.

Go는 언어 자체의 키워드가 적어 누구나 쉽게 시작하기 좋고, 여러 제약사항 덕분에 오용하기 어려운 언어입니다. 기존엔 없었다가 2022년 1.18 버전부터 추가되어 사용할 수 있게 된 Generic은 남용되지 않도록 언제 Generic을 사용하는게 좋은지 가이드 해주는 공식 블로그 글이 존재하며, 포매팅도 정해진 스타일이 있습니다.

그럼에도 언제나 모호한 부분은 존재하기에 조직에선 비용을 줄이기 위해 공통된 규칙을 합의하고 이런 규칙을 코드에 반영하기 위해 golangci-lint같은 린트 도구를 사용합니다. 한 두 사람이 몇 안되는 레포의 코드 일관성을 유지할 순 있지만 시간이 흐르고, 조직의 규모가 변하며, 구성원이 바뀔수록 코드 일관성 그 이상으로 여러 코드 베이스의 개념적 일관성을 유지하기 위한 노력이 필요합니다. 이 글에선 주로 코드 일관성 측면에서 뱅크샐러드가 어떤 Go 코딩 컨벤션을 따르며 권장하는지 얘기해보고자 합니다.

위키피디아 상 코딩 컨벤션 = 코딩 스타일 + 프랙티스 + 원칙으로 서술되어 있어 여기선 모든걸 총칭하는 용어로 ‘코딩 컨벤션’을 사용합니다.

읽어두면 좋은 자료

이미 Golang에 관한 컨벤션 가이드와 best practices는 많이 존재하기에 앞으로 얘기할 내용에는 아래 자료와 중복되는 내용도 있습니다. 이 블로그 글에 전부 담지 못했지만 어떤 코드가 Go다운지, 관용적인 Go 코드인지 궁금하다면 한 번씩 읽어보길 권합니다. 뱅크샐러드 내부의 Go 코드와 코드 리뷰 또한 아래 자료를 근간으로 합니다.

각 자료의 순서는 중요도와 무관합니다.

코딩 프랙티스

이 단락에선 더 나은 코드를 위해 권장하는 코딩 프랙티스를 다룹니다. 각 항목은 간결한 설명을 위해 bullet point와 다른 어체로 작성되었습니다.

Don’t panic

Panic을 낼 수 있는 함수는 must prefix 붙이기

// 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 safe goroutine

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한 결과 모으기

Error stacking

Error handling

Error logging

// ❌
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
}

No named return

func nextInt(b []byte, pos int) (int, int)
// vs
func nextInt(b []byte, pos int) (value, nextPos int)

HTTP client 설정

HTTP connection 재사용

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 설정

// ❌
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

// ❌
if results == nil {
	// ...
}

// ✅
if len(results) == 0 {
	// ...
}

Bool map과 struct{} map

// ✅
m := make(map[string]bool{})
if m["key"]{
 // ...
}

// ✅
v := make(map[string]struct{}{})
if _, ok := v["key"]; ok{
    // ...
}

Map 조회 시 ok 체크

Avoid map loop

// 간결한 예시를 위해 위에서 언급한 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 사용

// ❌
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 녕

문자열 길이

// ❌
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() 사용

Early return 애용

time.Duration 사용

타임존

// 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()

테이블 기반 테스트

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

Deterministic test

Avoid reflect

Functional options

// ❌
_ = NewAESCipher(key, nil, nil, nil, nil)

// ✅
_ = NewAESCipher(key, WithGCM(nonce))
_ = NewAESCipher(key, WithEncoding(euckr))

코딩 스타일

코딩 스타일에 정해진 정답은 없으며 Go 생태계에서 널리 받아들여진 내용도 있겠지만 주로 뱅크샐러드에서 일관성을 위해 권장하는 규칙을 다룹니다. 각 항목은 그대로 따르기보단 코드의 일관성을 위해 각 조직과 개인의 기호에 맞게 정해야 합니다.

일반적인 네이밍 규칙은 Go 네이밍 룰 문서를 참고하면 좋습니다.

함수의 인자 순서

테스트 함수 네이밍

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 순서

import (
	"standard/lib"

	"github.com/thrid-party/lib"

	"github.com/banksalad/lib"
)

단수는 get 복수는 list

모호한 단어 피하기

const 네이밍

패키지 네이밍

프로젝트 구조

.
├── 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 서버 개발 원칙인 누구나 알아볼 수 있는 코드를 어떻게 실천하는지 등의 내용도 다루고 싶었으나 글이 너무 길어져 글을 줄이게 됐습니다.

위 주제들도 따로 소개해드릴 순간이 있길 기대하며 여기까지 긴 글 읽어주셔서 감사합니다.