뱅크샐러드 Go 코딩 컨벤션

뱅크샐러드 Go 코딩 컨벤션

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

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

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

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

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



읽어두면 좋은 자료

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

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



코딩 프랙티스

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



Don’t panic

  • 프로덕션 런타임(서버가 올바르게 시작되어 요청을 처리할 수 있는 상태)에선 절대 panic을 발생시키지 않음
  • 프로세스를 종료시키는 fatal도 마찬가지로 사용하지 않음
  • panic은 다른 언어의 try, catch 문법처럼 예외처리를 위한 것이 아님
  • 대신 모든 fatal, panic은 main.go같은 서버 애플리케이션 초기화 시점에만 빠른 실패를 위해 사용함
  • 의도치 않은 panic에 대비해 서버 인터셉터 혹은 미들웨어로 recovery 체인을 추가하는게 권장됨 (e.g. echo의 미들웨어, grpc 인터셉터)
  • 같이 보면 좋을 문서


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.StackTrace interface를 만족하는지 assertion하는 식으로 구현함
  • 같이 보면 좋을 문서


Error handling



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 재사용

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

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


문자열 길이

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



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 비교에 주의해야함


Avoid reflect

  • json/encode처럼 이미 내부적으로 reflect를 쓰고있고 그래야만 하는 경우도 있음
  • 하지만 서버 핸들러에서 reflect를 사용한다면 보통 무언가 잘못되어가고 있다는 신호
  • reflect로 할 수 있다고 reflect를 사용해야하는 건 아님
  • 제너레이터나 테스트 코드에선 유용할 수 있음


Functional options

// ❌
_ = 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) {}


파일 내 선언 순서



import 순서

  • 세 그룹으로만 나눔
import (
	"standard/lib"

	"github.com/thrid-party/lib"

	"github.com/banksalad/lib"
)


단수는 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 참고


패키지 네이밍



프로젝트 구조

  • 꼭 필요한 패키지 폴더만 최소한으로 유지하려 함
.
├── 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 서버 개발 원칙인 누구나 알아볼 수 있는 코드를 어떻게 실천하는지 등의 내용도 다루고 싶었으나 글이 너무 길어져 글을 줄이게 됐습니다.

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






보다 빠르게 뱅크샐러드에 도달하는 방법 🚀

지원하기
Share This:

Featured Posts

post preview
post preview
post preview
post preview
post preview

Related Posts