티스토리 뷰

공식 문서 : https://google.github.io/styleguide/go

Overview | Guide | Decisions Best practices

 

 

참고: 이 문서는 Google의 Go Style 시리즈 문서의 일부입니다. 이 문서는 규범적이지만 표준적이 아니며, 핵심 스타일 가이드의 하위 문서입니다. 자세한 내용은 개요를 참조하세요.

 

개요

이 문서는 Go 가독성 멘토들이 제공하는 조언에 대한 표준 지침, 설명, 예시를 제공하고 통일성을 유지하기 위한 스타일 결정을 포함합니다.

이 문서는 포괄적이지 않으며 시간이 지나면서 추가될 예정입니다. 핵심 스타일 가이드와 이 문서의 내용이 상충될 경우 스타일 가이드가 우선하며, 이에 따라 이 문서도 업데이트되어야 합니다.

Go 스타일 문서의 전체 목록은 개요에서 확인하세요.

다음 섹션은 스타일 결정사항에서 가이드의 다른 부분으로 이동되었습니다:

  • Guide -> MixedCaps
  • Guide -> Formatting
  • Guide -> Line Length

 

네이밍 (Naming)

네이밍에 대한 일반적인 지침은 핵심 스타일 가이드의 네이밍 섹션을 참조하세요. 다음 섹션에서는 네이밍의 특정 영역에 대한 추가 설명을 제공합니다.

 

언더스코어 (Underscores)

Go의 이름에는 일반적으로 언더스코어를 포함하지 않습니다. 다음 세 가지 경우에 예외가 있습니다:

  1. 생성된 코드에 의해서만 가져오는 패키지 이름은 언더스코어를 포함할 수 있습니다. 여러 단어로 이루어진 패키지 이름을 선택하는 방법에 대한 자세한 내용은 패키지 이름을 참조하세요.
  2. *_test.go 파일 내의 Test, Benchmark, Example 함수 이름에는 언더스코어를 포함할 수 있습니다.
  3. 운영 체제나 cgo와 상호작용하는 저수준 라이브러리는 식별자를 재사용할 수 있습니다. syscall이 그 예입니다. 대부분의 코드베이스에서는 매우 드문 경우입니다.

 

패키지 이름 (Package names)

Go 패키지 이름은 짧고 소문자로만 구성되어야 합니다. 여러 단어로 이루어진 패키지 이름은 모두 소문자로 연속되게 작성합니다. 예를 들어, 패키지 tabwritertabWriter, TabWriter, 또는 tab_writer로 작성되지 않습니다.

자주 사용되는 로컬 변수 이름에 의해 가려질 가능성이 있는 패키지 이름을 피하세요. 예를 들어, count보다 usercount가 더 좋은 패키지 이름입니다. count는 흔히 사용되는 변수 이름이기 때문입니다.

Go 패키지 이름에는 언더스코어가 없어야 합니다. 이름에 언더스코어가 포함된 패키지를 가져와야 하는 경우(주로 생성된 코드나 서드 파티 코드), Go 코드에서 사용하기에 적절한 이름으로 가져올 때 이름을 변경해야 합니다.

예외로, 생성된 코드에 의해 가져오는 패키지 이름은 언더스코어를 포함할 수 있습니다. 구체적인 예는 다음과 같습니다:

  • 외부 테스트 패키지에 대해 _test 접미사를 사용하는 경우 (예: 통합 테스트)
  • 패키지 수준 문서 예제에 대해 _test 접미사를 사용하는 경우

foopb와 같이 가져오는 패키지를 로컬 이름으로 변경하는 경우(import foopb "path/to/foo_go_proto"), 로컬 이름은 위의 규칙을 따라야 하며, 로컬 이름이 파일에서 패키지의 기호를 참조하는 방식을 결정합니다. 특히 동일하거나 인접한 패키지에서 여러 파일에 걸쳐 이름을 변경하는 경우 일관성을 유지하기 위해 가능한 한 동일한 로컬 이름을 사용하는 것이 좋습니다.

패키지 이름에 대한 추가 정보는 Go 블로그의 패키지 이름 관련 글을 참조하세요.

 

리시버 이름 (Receiver names)

리시버 변수 이름은 다음과 같아야 합니다:

  • 짧게(보통 한두 글자) 작성합니다.
  • 타입 자체에 대한 약어를 사용합니다.
  • 해당 타입의 모든 리시버에 일관되게 적용합니다.
긴 이름 더 나은 이름
func (tray Tray) func (t Tray)
func (info *ResearchInfo) func (ri *ResearchInfo)
func (this *ReportWriter) func (w *ReportWriter)
func (self *Scanner) func (s *Scanner)

 

상수 이름 (Constant names)

상수 이름은 다른 모든 이름과 마찬가지로 MixedCaps를 사용해야 합니다. (내보내는 상수는 대문자로 시작하고, 내보내지 않는 상수는 소문자로 시작합니다.) 이는 다른 언어의 관례와 다를 때도 동일하게 적용됩니다. 상수 이름은 값의 파생 형태가 아니라 그 값이 나타내는 의미를 설명해야 합니다.

// 좋은 예:
const MaxPacketSize = 512

const (
    ExecuteBit = 1 << iota
    WriteBit
    ReadBit
)

MixedCaps가 아닌 상수 이름이나 K 접두어를 사용한 상수 이름을 사용하지 마세요.

// 나쁜 예:
const MAX_PACKET_SIZE = 512
const kMaxBufferSize = 1024
const KMaxUsersPergroup = 500

상수는 값이 아닌 역할에 따라 이름을 지어야 합니다. 상수가 값 외에 별다른 역할이 없다면, 상수로 정의할 필요가 없습니다.

// 나쁜 예:
const Twelve = 12

const (
    UserNameColumn = "username"
    GroupColumn    = "group"
)

 

약어 (Initialisms)

이름에 사용되는 약어 또는 두문자어(예: URL, NATO)는 동일한 대소문자로 표기해야 합니다. URLURL 또는 url(예: urlPony, URLPony)로 나타낼 수 있으며, Url로 표기하지 않습니다. 일반적으로 식별자(예: ID, DB)는 영어에서 사용하는 대소문자 규칙에 따라 표기해야 합니다.

  • 여러 두문자어가 포함된 이름에서는 (예: XMLAPIXMLAPI를 포함함) 각 두문자어 내의 문자는 동일한 대소문자 규칙을 따르지만, 이름의 각 두문자어가 동일한 대소문자를 가질 필요는 없습니다.
  • 소문자가 포함된 두문자어가 포함된 이름(예: DDoS, iOS, gRPC)은 일반적인 표기법을 따르되, 내보내기를 위해 첫 글자를 변경할 필요가 있다면 전체 두문자어의 대소문자가 동일하게 유지되어야 합니다(예: ddos, IOS, GRPC).
영어 표기 적용 범위 올바른 표기 잘못된 표기
XML API 내보내기 XMLAPI XmlApi, XMLApi, XmlAPI, XMLapi
XML API 내보내지 않음 xmlAPI xmlapi, xmlApi
iOS 내보내기 IOS Ios, IoS
iOS 내보내지 않음 iOS ios
gRPC 내보내기 GRPC Grpc
gRPC 내보내지 않음 gRPC grpc
DDoS 내보내기 DDoS DDOS, Ddos
DDoS 내보내지 않음 ddos dDoS, dDOS
ID 내보내기 ID Id
ID 내보내지 않음 id iD
DB 내보내기 DB Db
DB 내보내지 않음 db dB
Txn 내보내기 Txn TXN

 

게터 함수 (Getters)

 

함수 및 메서드 이름에는 Get 또는 get 접두어를 사용하지 않아야 하며, 대신 개념에 맞는 명사를 바로 사용하세요. 예를 들어 GetCounts 대신 Counts를 사용하는 것이 좋습니다. 단, 기본 개념이 "get"을 사용해야 하는 경우(예: HTTP GET)에는 Get을 사용할 수 있습니다.

복잡한 계산을 수행하거나 원격 호출을 실행하는 함수의 경우 Get 대신 Compute 또는 Fetch와 같은 단어를 사용하여 함수 호출에 시간이 소요될 수 있거나 블로킹 또는 실패할 수 있음을 독자에게 명확히 알리는 것이 좋습니다.

 

변수 이름 (Variable names)

 

일반적인 원칙은 변수 이름의 길이가 변수의 스코프 크기에 비례하고, 해당 스코프 내에서 사용되는 빈도에 반비례해야 한다는 것입니다. 파일 스코프에 있는 변수는 여러 단어로 작성할 수 있지만, 단일 블록 내에 있는 변수는 한두 글자로 줄여서 불필요한 정보가 줄어들게 하는 것이 좋습니다.

대략적인 기준은 다음과 같습니다. 이러한 숫자 지침은 엄격한 규칙이 아니며, 명확성간결성에 따라 상황에 맞게 판단해야 합니다.

  • 작은 스코프: 한두 개의 작은 작업이 수행되는 1-7줄 정도의 코드 영역.
  • 중간 스코프: 몇 개의 작은 작업 또는 하나의 큰 작업이 있는 8-15줄 정도의 코드 영역.
  • 큰 스코프: 한두 개의 큰 작업이 있는 15-25줄 정도의 코드 영역.
  • 매우 큰 스코프: 한 페이지 이상(약 25줄 이상)에 걸쳐있는 코드 영역.

작은 스코프에서 충분히 명확한 이름(예: c라는 카운터)이 더 큰 스코프에서는 부족하여, 코드 내에서 그 목적을 다시 상기시키기 위해 더 구체적인 이름이 필요할 수 있습니다. 많은 변수 또는 유사한 개념을 나타내는 변수가 있는 스코프에서는 변수 이름을 더 길게 설정해야 할 수도 있습니다.

변수 이름을 간결하게 유지하는 데 있어 개념의 구체성도 도움이 됩니다. 예를 들어, 사용되는 데이터베이스가 하나뿐이라면, 큰 스코프에서도 db와 같은 짧은 변수명이 명확할 수 있습니다. 이 경우 database라는 한 단어를 사용하는 것이 스코프 크기에 적절할 수 있지만, 대체 해석이 거의 없는 db라는 약어도 충분히 명확합니다.

로컬 변수 이름은 값의 출처가 아니라 현재 문맥에서 무엇을 포함하고 어떻게 사용되는지를 반영해야 합니다. 예를 들어, 최적의 로컬 변수 이름은 종종 구조체나 프로토콜 버퍼 필드 이름과 동일하지 않을 수 있습니다.

일반적으로:

  • countoptions와 같은 단어 하나의 이름은 좋은 시작점입니다.
  • 비슷한 이름을 구별하기 위해 추가 단어를 추가할 수 있습니다. 예를 들어 userCountprojectCount와 같이.
  • 글자를 줄이기 위해 단순히 문자를 생략하지 마세요. 예를 들어, SandboxSbx보다 선호되며, 특히 내보내는 이름에서는 더욱 그렇습니다.
  • 대부분의 변수 이름에서 타입과 타입 같은 단어를 생략하세요.
    • 숫자의 경우 userCountnumUsersusersInt보다 더 좋은 이름입니다.
    • 슬라이스의 경우 userSlice보다 users가 더 좋은 이름입니다.
    • 두 가지 버전의 값이 있는 경우, 예를 들어 문자열 형태로 저장된 ageString과 파싱된 age를 사용하는 것처럼, 타입 같은 구분자를 포함해도 괜찮습니다.
  • 주변 컨텍스트에서 명확한 단어는 생략하세요. 예를 들어, UserCount 메서드의 구현에서 userCount라는 로컬 변수는 불필요하게 중복될 수 있으며, count, users, 또는 c와 같이 간결한 이름이 더 읽기 쉽습니다.

 

한 글자 변수 이름 (Single-letter variable names)

한 글자 변수 이름은 반복을 최소화하는 데 유용할 수 있지만, 코드가 불필요하게 불명확해질 수 있습니다. 전체 단어가 명확하고 한 글자 변수보다 더 길게 사용할 경우 반복적일 때만 한 글자 변수를 사용하세요.

일반적으로:

  • 메서드 리시버 변수는 한두 글자 이름이 권장됩니다.
  • 자주 사용되는 유형에 대해 익숙한 변수 이름을 사용하는 것이 유용할 수 있습니다.
    • rio.Reader 또는 *http.Request
    • wio.Writer 또는 http.ResponseWriter
  • 루프의 인덱스와 좌표(예: i, x, y 등)를 나타내는 경우, 정수 루프 변수로 한 글자 식별자를 사용할 수 있습니다.
  • 스코프가 짧을 때는 축약어도 루프 식별자로 사용할 수 있습니다. 예를 들어, for _, n := range nodes { ... }.

 

반복 (Repetition)

Go 소스 코드에서는 불필요한 반복을 피해야 합니다. 일반적인 반복 요소 중 하나는 불필요한 단어가 포함되거나 맥락이나 타입을 반복하는 이름입니다. 또한 동일하거나 유사한 코드 조각이 가까운 위치에 여러 번 나타나는 경우도 불필요하게 반복적일 수 있습니다.

반복적인 네이밍은 다양한 형태로 나타날 수 있습니다.

 

패키지 이름과 내보내는 심볼 이름

내보내는 심볼을 네이밍할 때 패키지 이름이 항상 외부에 표시되므로, 두 이름 사이의 중복 정보를 줄이거나 제거해야 합니다. 패키지에서 하나의 타입만 내보내고 그 타입이 패키지 자체의 이름을 따르는 경우, 생성자가 필요하다면 New라는 이름을 사용하는 것이 표준입니다.

예시: 반복적인 이름 -> 더 나은 이름

  • widget.NewWidget -> widget.New
  • widget.NewWidgetWithName -> widget.NewWithName
  • db.LoadFromDatabase -> db.Load
  • goatteleportutil.CountGoatsTeleported -> gtutil.CountGoatsTeleported 또는 goatteleport.Count
  • myteampb.MyTeamMethodRequest -> mtpb.MyTeamMethodRequest 또는 myteampb.MethodRequest

 

변수 이름과 타입

컴파일러는 항상 변수의 타입을 알고 있으며, 대부분의 경우 변수의 사용 방식에 따라 독자가 타입을 쉽게 파악할 수 있습니다. 변수의 값이 동일 스코프에서 두 번 이상 나타나는 경우에만 타입을 명시할 필요가 있습니다.

반복적인 이름 더 나은 이름
var numUsers int var users int
var nameString string var name string
var primaryProject *Project var primary *Project

값이 여러 형태로 나타나는 경우에는 rawparsed와 같은 추가 단어를 사용하거나 기본 표현 방식을 통해 명확히 할 수 있습니다:

// 좋은 예:
limitStr := r.FormValue("limit")
limit, err := strconv.Atoi(limitStr)
// 좋은 예:
limitRaw := r.FormValue("limit")
limit, err := strconv.Atoi(limitRaw)

 

외부 컨텍스트와 로컬 이름

주변 컨텍스트에서 정보를 포함하는 이름은 종종 불필요한 노이즈를 추가합니다. 패키지 이름, 메서드 이름, 타입 이름, 함수 이름, 임포트 경로, 그리고 파일 이름조차도 모든 이름에 자동으로 컨텍스트를 제공할 수 있습니다.

// 나쁜 예:
// "ads/targeting/revenue/reporting" 패키지 안에
type AdsTargetingRevenueReport struct{}

func (p *Project) ProjectName() string
// 좋은 예:
// "ads/targeting/revenue/reporting" 패키지 안에
type Report struct{}

func (p *Project) Name() string
// 나쁜 예:
// "sqldb" 패키지 안에
type DBConnection struct{}
// 좋은 예:
// "sqldb" 패키지 안에
type Connection struct{}
// 나쁜 예:
// "ads/targeting" 패키지 안에
func Process(in *pb.FooProto) *Report {
    adsTargetingID := in.GetAdsTargetingID()
}
// 좋은 예:
// "ads/targeting" 패키지 안에
func Process(in *pb.FooProto) *Report {
    id := in.GetAdsTargetingID()
}

반복은 일반적으로 심볼 사용자의 관점에서 평가되어야 하며, 개별적으로 평가하지 않아야 합니다. 예를 들어, 다음 코드는 특정 상황에서는 괜찮을 수 있지만, 컨텍스트에서는 중복으로 간주될 수 있습니다.

// 나쁜 예:
func (db *DB) UserCount() (userCount int, err error) {
    var userCountInt64 int64
    if dbLoadError := db.LoadFromDatabase("count(distinct users)", &userCountInt64); dbLoadError != nil {
        return 0, fmt.Errorf("failed to load user count: %s", dbLoadError)
    }
    userCount = int(userCountInt64)
    return userCount, nil
}

컨텍스트나 사용에서 명확한 이름 정보를 생략하여 코드의 가독성을 높일 수 있습니다:

// 좋은 예:
func (db *DB) UserCount() (int, error) {
    var count int64
    if err := db.Load("count(distinct users)", &count); err != nil {
        return 0, fmt.Errorf("failed to load user count: %s", err)
    }
    return int(count), nil
}

 

주석 (Commentary)

주석에 대한 규칙(주석할 내용, 스타일, 실행 가능한 예제 제공 방법 등)은 공용 API 문서를 읽는 경험을 지원하기 위해 마련되었습니다. 더 자세한 내용은 Effective Go를 참조하세요.

베스트 프랙티스 문서의 문서화 규칙 섹션에서 이를 더 깊이 다룹니다.

베스트 프랙티스: doc preview를 개발 중이거나 코드 리뷰 시 사용하여 문서와 실행 가능한 예제가 유용하게 작성되었는지, 기대한 대로 표시되는지 확인하세요.

팁: Godoc은 특별한 형식을 거의 사용하지 않습니다. 목록과 코드 스니펫은 줄바꿈을 피하기 위해 들여쓰기하는 것이 좋습니다. 들여쓰기를 제외하고는 꾸밈을 피하는 것이 좋습니다.

 

주석 줄 길이 (Comment line length)

주석이 좁은 화면에서도 읽기 쉽게 작성되어야 합니다.

주석이 너무 길어지면 여러 개의 단일 행 주석으로 나누는 것이 좋습니다. 가능하면 주석이 80열 너비의 터미널에서도 잘 읽히도록 하는 것이 좋지만, 엄격한 제한은 없습니다. Go에는 주석의 줄 길이에 대한 고정된 제한이 없으며, 표준 라이브러리는 예를 들어 구두점에 따라 주석을 나누어 개별 줄이 60-70자에 가깝게 남는 경우도 종종 있습니다.

길이가 80자를 초과하는 주석이 있는 기존 코드가 많습니다. 이 지침은 가독성 리뷰에서 이러한 코드를 변경하는 근거로 사용해서는 안 되지만(일관성 참조), 팀이 리팩토링의 일환으로 주석을 기회가 될 때마다 이 지침에 맞춰 업데이트하는 것은 권장됩니다. 이 지침의 주요 목표는 Go 가독성 멘토가 주석 작성에 대해 일관된 추천을 제공하도록 하는 것입니다.

주석에 대한 자세한 내용은 The Go Blog의 포스트를 참조하세요.

# 좋은 예:
// 이것은 주석 단락입니다. 개별 줄의 길이는 Godoc에서는 중요하지 않습니다.
// 하지만 줄바꿈 방식은 좁은 화면에서 읽기 쉽게 만듭니다.
//
// 긴 URL은 너무 걱정하지 마세요:
// https://supercalifragilisticexpialidocious.example.com:8080/Animalia/Chordata/Mammalia/Rodentia/Geomyoidea/Geomyidae/
//
// 마찬가지로, 너무 많은 줄바꿈으로 불편해지는 정보가 있다면
// 판단에 따라 긴 줄을 포함하는 것이 더 도움이 되면 그렇게 하세요.

작은 화면에서 반복적으로 줄이 줄바꿈되는 주석은 독서 경험에 좋지 않으므로 피하세요.

# 나쁜 예:
// 이것은 주석 단락입니다. 개별 줄의 길이는 Godoc에서 중요하지 않습니다.
// 하지만 줄바꿈 방식이 좁은 화면이나 코드 리뷰에서 들쑥날쑥한 줄을
// 만들어내서, 특히 반복적으로 줄바꿈되는 주석 블록에서는 보기 불편할 수 있습니다.
//
// 긴 URL은 너무 걱정하지 마세요:
// https://supercalifragilisticexpialidocious.example.com:8080/Animalia/Chordata/Mammalia/Rodentia/Geomyoidea/Geomyidae/

 

문서 주석 (Doc comments)

모든 최상위 내보내는 이름에는 문서 주석이 있어야 하며, 의미나 동작이 명확하지 않은 내보내지 않는 타입이나 함수 선언에도 주석을 추가해야 합니다. 이러한 주석은 완전한 문장이어야 하며, 설명하는 객체의 이름으로 시작해야 합니다. 읽기 쉽게 하기 위해 이름 앞에 "a", "an", "the"와 같은 관사를 붙일 수 있습니다.

// 좋은 예:
// A Request represents a request to run a command.
type Request struct { ...

// Encode writes the JSON encoding of req to w.
func Encode(w io.Writer, req *Request) { ...

문서 주석은 Godoc에 나타나며, IDE에서도 표시되기 때문에 패키지를 사용하는 사람들을 위해 작성되어야 합니다.

문서 주석은 해당 주석이 위치한 다음의 심볼이나 구조체 필드 그룹에 적용됩니다.

// 좋은 예:
// Options configure the group management service.
type Options struct {
    // 일반 설정:
    Name  string
    Group *FooGroup

    // 종속성:
    DB *sql.DB

    // 사용자 정의:
    LargeGroupThreshold int // 선택 사항; 기본값: 10
    MinimumMembers      int // 선택 사항; 기본값: 2
}

베스트 프랙티스: 내보내지 않는 코드에 문서 주석이 있는 경우, 내보내는 코드와 마찬가지로 주석을 작성하세요(즉, 주석을 내보내지 않는 이름으로 시작). 이렇게 하면 나중에 이름을 내보내기로 변경할 때 주석과 코드 모두에서 해당 이름만 교체하여 쉽게 적용할 수 있습니다.

 

주석 문장 (Comment sentences)

완전한 문장인 주석은 표준 영어 문장처럼 대문자와 구두점을 사용하여 작성해야 합니다. 예외적으로, 문장이 소문자로 시작하는 식별자 이름으로 시작해도 문맥상 명확한 경우 허용됩니다. 이러한 경우는 문단의 시작에서 사용하는 것이 좋습니다.

문장 조각인 주석은 구두점이나 대문자 사용이 필수가 아닙니다.

문서화 주석은 항상 완전한 문장으로 작성되어야 하므로, 대문자와 구두점을 포함해야 합니다. 단순한 인라인 주석(특히 구조체 필드의 경우)은 필드 이름이 주제임을 가정하는 간단한 구절로 작성될 수 있습니다.

// 좋은 예:
// A Server handles serving quotes from the collected works of Shakespeare.
type Server struct {
    // BaseDir points to the base directory under which Shakespeare's works are stored.
    //
    // 디렉터리 구조는 다음과 같이 기대됩니다:
    //   {BaseDir}/manifest.json
    //   {BaseDir}/{name}/{name}-part{number}.txt
    BaseDir string

    WelcomeMessage  string // 사용자가 로그인할 때 표시되는 메시지
    ProtocolVersion string // 수신 요청과 비교되는 프로토콜 버전
    PageLength      int    // 페이지당 줄 수 (선택 사항; 기본값: 20)
}

 

예제 (Examples)

패키지는 사용 방법을 명확하게 문서화해야 합니다. 가능하면 실행 가능한 예제를 제공하세요. 예제는 Godoc에 표시되며, 테스트 파일에 작성되고, 프로덕션 소스 파일에는 포함되지 않습니다. 예시(Godoc, 소스)를 참고하세요.

실행 가능한 예제를 제공하기 어렵다면 코드 주석에 예제 코드를 추가할 수 있습니다. 다른 코드나 명령줄 스니펫과 마찬가지로, 표준 형식 규칙을 따라야 합니다.

 

이름이 있는 결과 매개변수 (Named result parameters)

매개변수에 이름을 지정할 때는 Godoc에서 함수 서명이 어떻게 나타나는지 고려하세요. 함수 이름과 결과 매개변수의 타입만으로도 충분히 명확한 경우가 많습니다.

// 좋은 예:
func (n *Node) Parent1() *Node
func (n *Node) Parent2() (*Node, error)

동일한 타입의 두 개 이상의 매개변수를 반환하는 경우에는 이름을 추가하는 것이 유용할 수 있습니다.

// 좋은 예:
func (n *Node) Children() (left, right *Node, err error)

특정 결과 매개변수에 대해 호출자가 조치를 취해야 하는 경우, 이름을 지정하면 그 동작이 무엇인지 암시할 수 있습니다.

// 좋은 예:
// WithTimeout 함수는 현재 시간으로부터 d의 기간 내에 취소되는 컨텍스트를 반환합니다.
//
// 호출자는 반환된 cancel 함수를 호출하여 리소스 누수를 방지해야 합니다.
func WithTimeout(parent Context, d time.Duration) (ctx Context, cancel func())

위 코드에서 cancel 함수는 호출자가 반드시 취소 작업을 수행해야 하는 구체적인 행동을 나타냅니다. 반면 결과 매개변수가 단순히 (Context, func())로만 표시되었다면 "cancel 함수"가 의미하는 바가 불명확할 수 있습니다.

불필요한 반복을 초래하는 이름이 있는 결과 매개변수는 사용하지 마세요.

// 나쁜 예:
func (n *Node) Parent1() (node *Node)
func (n *Node) Parent2() (node *Node, err error)

함수 내에서 변수를 선언하는 것을 피하기 위해 결과 매개변수에 이름을 지정하지 마세요. 이는 구현에서 약간의 간결함을 얻기 위해 API의 복잡성을 증가시키는 결과를 초래합니다.

Naked returns은 작은 함수에서만 허용됩니다. 함수가 중간 크기 이상이 되면 반환할 값을 명시적으로 작성하세요. 동일하게, 결과 매개변수에 이름을 지정하여 Naked return을 사용하지 마세요. 명확성은 함수의 몇 줄을 절약하는 것보다 항상 더 중요합니다.

반환값을 지연된 클로저에서 변경해야 하는 경우 결과 매개변수에 이름을 지정하는 것은 항상 허용됩니다.

팁: 함수 서명에서 이름보다 타입이 더 명확할 때가 많습니다. GoTip #38: Functions as Named Types는 이를 잘 보여줍니다.

위의 WithTimeout 함수에서는 실제 코드에서 결과 매개변수 목록에 func() 대신 CancelFunc을 사용하여 문서화가 간편합니다.

 

패키지 주석 (Package comments)

패키지 주석은 패키지 선언 바로 위에 빈 줄 없이 위치해야 합니다. 예시:

// 좋은 예:
// Package math는 기본 상수와 수학 함수를 제공합니다.
//
// 이 패키지는 아키텍처 간의 비트 동일 결과를 보장하지 않습니다.
package math

패키지 주석은 패키지당 하나만 있어야 합니다. 패키지가 여러 파일로 구성된 경우, 그 중 정확히 하나의 파일에만 패키지 주석이 있어야 합니다.

main 패키지의 주석은 약간 다르며, BUILD 파일의 go_binary 규칙 이름이 패키지 이름 대신 사용됩니다.

// 좋은 예:
// seed_generator 명령은 JSON 연구 구성 세트에서 Finch 시드 파일을 생성하는 유틸리티입니다.
package main

다른 주석 스타일도 괜찮지만, 바이너리 이름이 정확히 BUILD 파일에 작성된 그대로여야 합니다. 바이너리 이름이 첫 단어인 경우, 명령줄 호출의 철자와 정확히 일치하지 않더라도 대문자로 시작해야 합니다.

// 좋은 예:
// Binary seed_generator ...
// Command seed_generator ...
// Program seed_generator ...
// The seed_generator command ...
// The seed_generator program ...
// Seed_generator ...

팁:

  • 예제 명령줄 호출과 API 사용은 유용한 문서화가 될 수 있습니다. Godoc 형식을 위해 코드가 포함된 주석 줄을 들여쓰기 하세요.
  • 주석이 매우 길거나 주요 파일이 없는 경우, 주석과 패키지 선언만 포함한 doc.go 파일에 주석을 작성해도 괜찮습니다.
  • 여러 줄 주석을 단일 주석 대신 사용할 수 있습니다. 특히 샘플 명령줄이나 템플릿 예제와 같이 소스 파일에서 복사하여 붙여넣을 수 있는 섹션이 있을 경우 유용합니다.
  • // 좋은 예: /* seed_generator 명령은 JSON 연구 구성 세트에서 Finch 시드 파일을 생성하는 유틸리티입니다. seed_generator *.json | base64 > finch-seed.base64 */ package template
  • 유지보수자를 위한 파일 전체에 적용되는 주석은 보통 import 선언 뒤에 위치하며, Godoc에 표시되지 않고 패키지 주석 규칙에 적용되지 않습니다.

 

임포트 (Imports)

임포트 이름 변경 (Import renaming)

임포트는 다른 임포트와 이름이 충돌하는 경우에만 이름이 변경되어야 합니다. (좋은 패키지 이름을 사용하는 경우 이름 변경이 필요하지 않습니다.) 이름 충돌이 발생하면 가장 로컬적이거나 프로젝트에 특화된 임포트를 이름 변경하세요. 패키지에 대한 로컬 이름(별칭)은 패키지 네이밍 지침을 따라야 하며, 언더스코어나 대문자 사용을 금지합니다.

생성된 프로토콜 버퍼 패키지는 이름에서 언더스코어를 제거하여 이름을 변경해야 하며, 별칭에 pb 접미사를 추가해야 합니다. 자세한 내용은 프로토 및 스텁 베스트 프랙티스를 참조하세요.

// 좋은 예:
import (
    fspb "path/to/package/foo_service_go_proto"
)

식별에 유용하지 않은 정보만 포함된 패키지 이름(e.g., package v1)을 가진 임포트는 이전 경로 구성 요소를 포함하도록 이름을 변경해야 합니다. 변경된 이름은 동일한 패키지를 가져오는 다른 로컬 파일과 일관되어야 하며 버전 번호를 포함할 수 있습니다.

참고: 패키지 이름을 좋은 패키지 이름과 일치하도록 변경하는 것이 바람직하지만, vendored 디렉터리의 패키지에는 종종 불가능합니다.

// 좋은 예:
import (
    core "github.com/kubernetes/api/core/v1"
    meta "github.com/kubernetes/apimachinery/pkg/apis/meta/v1beta1"
)

사용하려는 로컬 변수 이름(e.g., url, ssh)과 충돌하는 패키지를 임포트해야 하고 이름을 변경하려는 경우 pkg 접미사(e.g., urlpkg)를 사용하는 것이 좋습니다. 로컬 변수가 패키지를 가릴 수 있으므로 해당 변수의 스코프 내에서 패키지를 여전히 사용해야 하는 경우에만 이름 변경이 필요합니다.

 

임포트 그룹화 (Import grouping)

임포트는 다음 두 그룹으로 정리해야 합니다:

  • 표준 라이브러리 패키지
  • 기타 (프로젝트 및 vendored 패키지)
// 좋은 예:
package main

import (
    "fmt"
    "hash/adler32"
    "os"

    "github.com/dsnet/compress/flate"
    "golang.org/x/text/encoding"
    "google.golang.org/protobuf/proto"
    foopb "myproj/foo/proto/proto"
    _ "myproj/rpc/protocols/dial"
    _ "myproj/security/auth/authhooks"
)

필요하다면 프로젝트 패키지를 여러 그룹으로 나누어 의미 있는 그룹을 유지할 수 있습니다. 다음은 일반적인 이유입니다:

  • 이름이 변경된 임포트
  • 부작용을 위해 가져오는 패키지

예시:

// 좋은 예:
package main

import (
    "fmt"
    "hash/adler32"
    "os"

    "github.com/dsnet/compress/flate"
    "golang.org/x/text/encoding"
    "google.golang.org/protobuf/proto"

    foopb "myproj/foo/proto/proto"

    _ "myproj/rpc/protocols/dial"
    _ "myproj/security/auth/authhooks"
)

참고: 필수적인 구분(표준 라이브러리와 Google 임포트 간의 구분) 이상으로 임포트 그룹을 나누는 것은 goimports 도구에서 지원되지 않습니다. 추가 임포트 하위 그룹을 유지하려면 작성자와 리뷰어가 일관성을 유지하기 위해 주의를 기울여야 합니다.

AppEngine 앱인 Google 프로그램은 AppEngine 임포트를 위한 별도의 그룹이 있어야 합니다.

Gofmt는 각 그룹을 임포트 경로별로 정렬하지만, 임포트를 그룹으로 자동 분리하지는 않습니다. goimports는 Gofmt와 임포트 관리를 결합하여 위의 결정에 따라 임포트를 그룹으로 분리합니다. goimports에 임포트 정리를 완전히 맡겨도 괜찮지만, 파일을 수정할 때 임포트 목록의 내부 일관성을 유지해야 합니다.

 

"빈" 임포트 (import _)

부작용만을 위해 임포트되는 패키지(import _ "package" 문법)는 main 패키지에서만 또는 필요한 경우 테스트에서만 임포트할 수 있습니다.

일반적인 예로는 다음과 같은 패키지가 있습니다:

라이브러리 패키지에서 빈 임포트를 피하세요. 라이브러리가 간접적으로 이러한 패키지에 의존하더라도, 빈 임포트는 main 패키지로 제한하여 의존성을 관리하고, 테스트가 충돌 없이 다른 임포트를 사용하거나 불필요한 빌드 비용을 줄일 수 있습니다.

다음 경우에만 이 규칙의 예외가 허용됩니다:

  • nogo 정적 검사기에서 불허된 임포트 검사를 우회하기 위해 빈 임포트를 사용할 수 있습니다.
  • //go:embed 컴파일러 지시어가 사용된 파일에서 embed 패키지를 빈 임포트로 사용할 수 있습니다.

팁: 프로덕션에서 부작용 임포트를 간접적으로 의존하는 라이브러리 패키지를 작성할 경우, 의도된 사용 방법을 문서화하세요.

 

"닷" 임포트 (import .)

import . 구문은 다른 패키지에서 내보내는 식별자를 현재 패키지에서 자격 없이 사용할 수 있게 해줍니다. 더 자세한 내용은 언어 사양을 참조하세요.

이 기능은 Google 코드베이스에서 사용하지 마세요. 이로 인해 기능의 출처를 파악하기 어려워집니다.

// 나쁜 예:
package foo_test

import (
    "bar/testutil" // "foo" 패키지를 포함
    . "foo"
)

var myThing = Bar() // Bar는 foo 패키지에서 정의되었으며, 자격이 필요하지 않습니다.
// 좋은 예:
package foo_test

import (
    "bar/testutil" // "foo" 패키지를 포함
    "foo"
)

var myThing = foo.Bar()

 

오류 (Errors)

오류 반환 (Returning errors)

함수가 실패할 수 있음을 나타내기 위해 error를 사용하세요. 관례적으로 error는 마지막 결과 매개변수로 지정됩니다.

// 좋은 예:
func Good() error { /* ... */ }

성공적으로 수행되었음을 나타내기 위해 nil 오류를 반환하는 것이 관용적입니다. 함수가 오류를 반환하는 경우, 명시적으로 문서화되지 않는 한 오류가 아닌 반환 값은 미지의 상태로 간주해야 합니다. 일반적으로 오류가 아닌 반환 값은 해당 유형의 기본값이지만 이를 가정해서는 안 됩니다.

// 좋은 예:
func GoodLookup() (*Result, error) {
    // ...
    if err != nil {
        return nil, err
    }
    return res, nil
}

오류를 반환하는 내보내는 함수는 error 타입을 사용해 반환해야 합니다. 구체적인 오류 타입은 미묘한 버그의 위험이 있습니다. 예를 들어, 구체적인 nil 포인터가 인터페이스로 래핑되면 nil이 아닌 값이 될 수 있습니다. 자세한 내용은 Go FAQ 항목을 참조하세요.

// 나쁜 예:
func Bad() *os.PathError { /*...*/ }

팁: context.Context 매개변수를 사용하는 함수는 일반적으로 error를 반환하여 함수 실행 중 컨텍스트가 취소되었는지 호출자가 판단할 수 있게 해야 합니다.

 

오류 문자열 (Error strings)

오류 문자열은 대문자로 시작하지 않아야 하며(내보내는 이름, 고유 명사, 두문자어로 시작하는 경우 제외), 구두점으로 끝나지 않아야 합니다. 이는 오류 문자열이 일반적으로 다른 문맥 내에서 사용자에게 출력되기 전에 포함되기 때문입니다.

// 나쁜 예:
err := fmt.Errorf("Something bad happened.")
// 좋은 예:
err := fmt.Errorf("something bad happened")

반면, 전체 표시 메시지(로그, 테스트 실패, API 응답 또는 기타 UI)는 상황에 따라 다르지만, 일반적으로 대문자로 시작해야 합니다.

// 좋은 예:
log.Infof("Operation aborted: %v", err)
log.Errorf("Operation aborted: %v", err)
t.Errorf("Op(%q) failed unexpectedly; err=%v", args, err)

 

오류 처리 (Handle errors)

오류가 발생하는 코드에서는 오류를 처리할 방법을 신중하게 선택해야 합니다. _ 변수를 사용하여 오류를 버리는 것은 일반적으로 적절하지 않습니다. 함수가 오류를 반환하는 경우 다음 중 하나를 수행해야 합니다:

  • 오류를 즉시 처리하고 해결합니다.
  • 오류를 호출자에게 반환합니다.
  • 예외적인 상황에서만 log.Fatal 또는 (절대 필요한 경우) panic을 호출합니다.

참고: log.Fatalf는 표준 라이브러리 로그가 아닙니다. #logging을 참조하세요.

오류를 무시하거나 버리는 것이 적절한 드문 경우(예: (*bytes.Buffer).Write는 실패하지 않는다고 문서화된 경우), 해당 작업이 안전한 이유를 설명하는 주석이 있어야 합니다.

// 좋은 예:
var b *bytes.Buffer

n, _ := b.Write(p) // 항상 nil 오류를 반환

오류 처리에 대한 추가 논의와 예제는 Effective Go베스트 프랙티스를 참조하세요.

 

인밴드 오류 (In-band errors)

C 언어와 유사한 언어에서는 함수가 -1, null 또는 빈 문자열과 같은 값을 반환하여 오류 또는 누락된 결과를 신호하는 것이 일반적입니다. 이를 인밴드 오류 처리라고 합니다.

// 나쁜 예:
// Lookup은 key의 값이나 key에 대한 매핑이 없는 경우 -1을 반환합니다.
func Lookup(key string) int

인밴드 오류 값을 확인하지 않으면 잘못된 함수에 오류가 할당되어 버그가 발생할 수 있습니다.

// 나쁜 예:
// 아래 줄은 입력 값에 대해 Parse가 실패했다는 오류를 반환하지만, 실패 원인은 missingKey에 대한 매핑이 없는 것입니다.
return Parse(Lookup(missingKey))

Go의 여러 반환값 지원 기능을 사용하면 더 나은 해결책을 제공할 수 있습니다(Effective Go: 다중 반환값 참조). 인밴드 오류 값을 확인할 필요 없이, 함수는 반환 값이 유효한지 여부를 나타내는 추가 값을 반환해야 합니다. 이 값은 오류 또는 boolean일 수 있으며, 설명이 필요하지 않은 경우 최종 반환 값이 되어야 합니다.

// 좋은 예:
// Lookup은 key의 값을 반환하거나 key에 대한 매핑이 없는 경우 ok=false를 반환합니다.
func Lookup(key string) (value string, ok bool)

이 API는 Lookup(key)에 2개의 반환값이 있기 때문에 Parse(Lookup(key))와 같은 잘못된 사용을 방지합니다.

이와 같은 방식으로 오류를 반환하면 더욱 견고하고 명시적인 오류 처리를 유도할 수 있습니다.

// 좋은 예:
value, ok := Lookup(key)
if !ok {
    return fmt.Errorf("no value for %q", key)
}
return Parse(value)

strings 패키지의 함수와 같은 일부 표준 라이브러리 함수는 인밴드 오류 값을 반환합니다. 이는 문자열 조작 코드를 크게 단순화하지만, 프로그래머에게 더 많은 주의가 필요합니다. 일반적으로, Google 코드베이스의 Go 코드는 오류에 대해 추가 값을 반환해야 합니다.

 

오류 흐름 들여쓰기 (Indent error flow)

오류를 처리한 후 코드의 나머지 부분을 진행하세요. 이를 통해 코드의 가독성이 개선되어, 독자가 정상 경로를 빠르게 찾을 수 있습니다. 이 논리는 조건을 검사한 후 종료 조건(e.g., return, panic, log.Fatal)으로 끝나는 모든 블록에 적용됩니다.

종료 조건이 충족되지 않은 경우 실행되는 코드는 if 블록 이후에 나타나야 하며, else 절 안에 들여쓰지 않아야 합니다.

// 좋은 예:
if err != nil {
    // 오류 처리
    return // 또는 continue 등
}
// 정상 코드
// 나쁜 예:
if err != nil {
    // 오류 처리
} else {
    // 들여쓰기 때문에 비정상적으로 보이는 정상 코드
}

팁: 변수를 몇 줄 이상 사용해야 하는 경우 if-with-initializer 스타일을 사용하지 않는 것이 좋습니다. 이러한 경우 선언을 블록 밖으로 이동하고 표준 if 문을 사용하는 것이 더 좋습니다.

// 좋은 예:
x, err := f()
if err != nil {
  // 오류 처리
  return
}
// 여러 줄에 걸쳐 x를 사용하는 코드
// 나쁜 예:
if x, err := f(); err != nil {
  // 오류 처리
  return
} else {
  // 여러 줄에 걸쳐 x를 사용하는 코드
}

자세한 내용은 Go Tip #1: 시야TotT: 중첩 감소로 코드 복잡도 줄이기를 참조하세요.

 

언어 (Language)

리터럴 형식 (Literal formatting)

Go에는 강력한 복합 리터럴 구문이 있어, 단일 표현식에서 깊이 중첩된 복잡한 값을 표현할 수 있습니다. 가능한 경우, 리터럴 구문을 사용해 필드별로 값을 설정하는 대신 전체 값을 표현하세요. gofmt는 리터럴에 대한 포맷팅을 잘 처리하지만, 리터럴을 읽기 쉽고 유지 관리하기 쉽게 하기 위한 추가 규칙도 있습니다.

 

필드 이름 (Field names)

외부 패키지에서 정의된 타입에 대해 구조체 리터럴을 사용할 때는 필드 이름을 지정해야 합니다.

  • 다른 패키지의 타입에 대해서는 필드 이름을 포함하세요.구조체에서 필드의 위치와 전체 필드 집합(필드 이름이 생략된 경우 이를 올바르게 이해하기 위한 요소)은 일반적으로 구조체의 공용 API 일부로 간주되지 않으므로, 불필요한 결합을 피하기 위해 필드 이름을 지정해야 합니다.
  • // 나쁜 예: r := csv.Reader{',', '#', 4, false, false, false, false}
  • // 좋은 예: // https://pkg.go.dev/encoding/csv#Reader r := csv.Reader{ Comma: ',', Comment: '#', FieldsPerRecord: 4, }
  • 패키지 로컬 타입의 경우, 필드 이름은 선택 사항입니다.코드의 가독성을 높일 수 있다면 필드 이름을 사용하는 것이 좋으며, 많은 필드를 가진 구조체는 거의 항상 필드 이름을 사용해 초기화하는 것이 일반적입니다.
  • // 좋은 예: okay := StructWithLotsOfFields{ field1: 1, field2: "two", field3: 3.14, field4: true, }
  • // 좋은 예: okay := Type{42} also := internalType{4, 2}

 

일치하는 중괄호 (Matching braces)

중괄호 쌍의 닫는 부분은 항상 여는 중괄호와 동일한 들여쓰기 수준에 있어야 합니다. 단일 행 리터럴은 이 규칙을 자동으로 충족합니다. 리터럴이 여러 줄로 분할될 때도 이러한 일관성을 유지하여 함수나 if 문과 같은 Go의 일반 구문과 일치하도록 중괄호 매칭을 유지합니다.

여기서 가장 흔한 실수는 다중 행 구조체 리터럴에서 닫는 중괄호를 값과 같은 줄에 놓는 것입니다. 이러한 경우, 해당 줄은 쉼표로 끝내고 닫는 중괄호는 다음 줄에 두어야 합니다.

// 좋은 예:
good := []*Type{{Key: "value"}}
// 좋은 예:
good := []*Type{
    {Key: "multi"},
    {Key: "line"},
}
// 나쁜 예:
bad := []*Type{
    {Key: "multi"},
    {Key: "line"}}
// 나쁜 예:
bad := []*Type{
    {
        Key: "value"},
}

 

중괄호 간격 제거 (Cuddled braces)

슬라이스 및 배열 리터럴에서 중괄호 사이의 간격을 제거("cuddling")하는 것은 다음 두 조건이 모두 충족될 때만 허용됩니다.

  • 들여쓰기가 일치해야 합니다.
  • 내부 값이 리터럴 또는 프로토 빌더여야 합니다(변수나 다른 표현식이 아님).
// 좋은 예:
good := []*Type{
    { // 간격 없음
        Field: "value",
    },
    {
        Field: "value",
    },
}
// 좋은 예:
good := []*Type{{ // 올바르게 간격 없음
    Field: "value",
}, {
    Field: "value",
}}
// 좋은 예:
good := []*Type{
    first, // 간격을 줄 수 없음
    {Field: "second"},
}
// 좋은 예:
okay := []*pb.Type{pb.Type_builder{
    Field: "first", // 프로토 빌더는 수직 공간 절약을 위해 간격을 줄 수 있음
}.Build(), pb.Type_builder{
    Field: "second",
}.Build()}
// 나쁜 예:
bad := []*Type{
    first,
    {
        Field: "second",
    }}

 

반복되는 타입 이름 (Repeated type names)

슬라이스 및 맵 리터럴에서 반복되는 타입 이름은 생략할 수 있습니다. 이는 코드의 잡음을 줄이는 데 도움이 될 수 있습니다. 프로젝트에서 자주 사용되지 않는 복잡한 타입을 다룰 때, 반복되는 타입 이름이 도움이 될 수 있습니다.

// 좋은 예:
good := []*Type{
    {A: 42},
    {A: 43},
}
// 나쁜 예:
repetitive := []*Type{
    &Type{A: 42},
    &Type{A: 43},
}
// 좋은 예:
good := map[Type1]*Type2{
    {A: 1}: {B: 2},
    {A: 3}: {B: 4},
}
// 나쁜 예:
repetitive := map[Type1]*Type2{
    Type1{A: 1}: &Type2{B: 2},
    Type1{A: 3}: &Type2{B: 4},
}

팁: 구조체 리터럴에서 반복적인 타입 이름을 제거하려면 gofmt -s를 실행할 수 있습니다.

 

기본값 필드 (Zero-value fields)

기본값을 가지는 필드는 구조체 리터럴에서 명확성이 손상되지 않는 경우 생략할 수 있습니다.

잘 설계된 API는 가독성을 높이기 위해 기본값 생성을 자주 사용합니다. 예를 들어, 아래 구조체에서 세 개의 기본값 필드를 생략하면 지정된 유일한 옵션에 주목할 수 있습니다.

// 나쁜 예:
import (
  "github.com/golang/leveldb"
  "github.com/golang/leveldb/db"
)

ldb := leveldb.Open("/my/table", &db.Options{
    BlockSize: 1<<16,
    ErrorIfDBExists: true,

    // 모든 필드는 기본값을 가집니다.
    BlockRestartInterval: 0,
    Comparer: nil,
    Compression: nil,
    FileSystem: nil,
    FilterPolicy: nil,
    MaxOpenFiles: 0,
    WriteBufferSize: 0,
    VerifyChecksums: false,
})
// 좋은 예:
import (
  "github.com/golang/leveldb"
  "github.com/golang/leveldb/db"
)

ldb := leveldb.Open("/my/table", &db.Options{
    BlockSize: 1<<16,
    ErrorIfDBExists: true,
})

테이블 기반 테스트 내에서 구조체는 명시적인 필드 이름을 사용할 때 특히 유용하며, 테스트 구조체가 복잡할 경우 더욱 그렇습니다. 이는 테스트와 관련 없는 기본값 필드를 완전히 생략할 수 있게 하여, 성공 사례에서는 오류 관련 필드를 생략하는 것이 좋습니다. 테스트 케이스에서 zero나 nil 입력을 확인해야 하는 경우, 필드 이름을 명시해야 합니다.

 

간결한 예시

tests := []struct {
    input      string
    wantPieces []string
    wantErr    error
}{
    {
        input:      "1.2.3.4",
        wantPieces: []string{"1", "2", "3", "4"},
    },
    {
        input:   "hostname",
        wantErr: ErrBadHostname,
    },
}

 

명시적인 예시

tests := []struct {
    input    string
    wantIPv4 bool
    wantIPv6 bool
    wantErr  bool
}{
    {
        input:    "1.2.3.4",
        wantIPv4: true,
        wantIPv6: false,
    },
    {
        input:    "1:2::3:4",
        wantIPv4: false,
        wantIPv6: true,
    },
    {
        input:    "hostname",
        wantIPv4: false,
        wantIPv6: false,
        wantErr:  true,
    },
}

 

Nil 슬라이스 (Nil slices)

대부분의 경우, nil 슬라이스와 빈 슬라이스는 기능적으로 차이가 없습니다. lencap 같은 내장 함수는 nil 슬라이스에서도 예상대로 작동합니다.

// 좋은 예:
import "fmt"

var s []int         // nil 슬라이스

fmt.Println(s)      // []
fmt.Println(len(s)) // 0
fmt.Println(cap(s)) // 0
for range s {...}   // 아무 작업도 수행하지 않음

s = append(s, 42)
fmt.Println(s)      // [42]

로컬 변수로 빈 슬라이스를 선언할 때(특히 반환 값의 소스가 될 수 있는 경우), 호출자가 버그를 발생시킬 위험을 줄이기 위해 nil 초기화를 사용하는 것이 좋습니다.

// 좋은 예:
var t []string
// 나쁜 예:
t := []string{}

API를 설계할 때 nil과 빈 슬라이스 간의 구별을 강요하지 마세요.

// 좋은 예:
// Ping 함수는 대상 호스트에 핑을 보내고 성공적으로 응답한 호스트를 반환합니다.
func Ping(hosts []string) ([]string, error) { ... }
// 나쁜 예:
// Ping 함수는 대상 호스트에 핑을 보내고 성공적으로 응답한 호스트 목록을 반환합니다.
// 입력이 빈 경우에는 빈 목록을 반환하며, 시스템 오류가 발생한 경우에는 nil을 반환합니다.
func Ping(hosts []string) []string { ... }

인터페이스를 설계할 때, nil 슬라이스와 길이가 0인 비 nil 슬라이스를 구분하지 않도록 하세요. 이는 == nil 대신 len을 사용하여 비어 있는지 확인함으로써 실수를 방지할 수 있습니다.

이 구현은 nil과 길이가 0인 슬라이스를 "비어 있음"으로 받아들입니다:

// 좋은 예:
// describeInts 함수는 s가 비어 있지 않으면 prefix와 함께 s를 설명합니다.
func describeInts(prefix string, s []int) {
    if len(s) == 0 {
        return
    }
    fmt.Println(prefix, s)
}

API의 일부로 nil과 빈 슬라이스의 구분을 의존하는 대신 다음과 같은 접근을 피하세요:

// 나쁜 예:
func maybeInts() []int { /* ... */ }

// describeInts 함수는 s가 비어 있지 않으면 prefix와 함께 s를 설명합니다. s를 생략하려면 nil을 전달하세요.
func describeInts(prefix string, s []int) {
  // 이 함수의 동작은 maybeInts()가 '비어 있음'을 nil 또는 []int{}로 반환하는지에 따라 달라집니다.
  if s == nil {
    return
  }
  fmt.Println(prefix, s)
}

describeInts("Here are some ints:", maybeInts())

자세한 내용은 인밴드 오류를 참조하세요.

 

들여쓰기 혼란 (Indentation confusion)

줄 바꿈으로 인해 나머지 코드가 들여쓰기된 블록과 같은 위치에 맞춰지는 경우 이를 피하세요. 피할 수 없다면, 줄을 구분하는 빈 칸을 추가하여 블록 내 코드와 줄 바꿈된 코드의 구분을 명확히 하세요.

// 나쁜 예:
if longCondition1 && longCondition2 &&
    // 조건 3과 4는 if 문 안의 코드와 같은 들여쓰기를 가집니다.
    longCondition3 && longCondition4 {
    log.Info("all conditions met")
}

자세한 규칙과 예시는 다음 섹션을 참고하세요:

 

함수 형식 (Function formatting)

함수 또는 메서드 선언의 서명은 들여쓰기 혼란을 방지하기 위해 단일 줄에 유지해야 합니다.

Go 소스 파일에서 함수 인수 목록은 가장 긴 줄 중 하나가 될 수 있습니다. 그러나 함수 인수 목록은 들여쓰기 변화 전에 위치하기 때문에 줄을 나누면 후속 줄이 함수 본문의 일부로 잘못 보일 수 있습니다.

// 나쁜 예:
func (r *SomeType) SomeLongFunctionName(foo1, foo2, foo3 string,
    foo4, foo5, foo6 int) {
    foo7 := bar(foo1)
    // ...
}

함수 호출이 너무 길어질 경우, 로컬 변수를 도입하여 줄을 줄일 수 있습니다.

// 좋은 예:
local := helper(some, parameters, here)
good := foo.Call(list, of, parameters, local)

마찬가지로 함수 및 메서드 호출은 단순히 줄 길이를 기준으로 나누어서는 안 됩니다.

// 좋은 예:
good := foo.Call(long, list, of, parameters, all, on, one, line)
// 나쁜 예:
bad := foo.Call(long, list, of, parameters,
    with, arbitrary, line, breaks)

함수 인수에 인라인 주석을 추가하는 것은 가능한 한 피하세요. 대신 옵션 구조체를 사용하거나 함수 문서에 자세한 설명을 추가하세요.

// 좋은 예:
good := server.New(ctx, server.Options{Port: 42})
// 나쁜 예:
bad := server.New(
    ctx,
    42, // Port
)

API를 변경할 수 없거나 호출이 특이한 경우(너무 길든 아니든), 이해를 돕기 위해 줄 바꿈을 추가할 수 있습니다.

// 좋은 예:
canvas.RenderCube(cube,
    x0, y0, z0,
    x0, y0, z1,
    x0, y1, z0,
    x0, y1, z1,
    x1, y0, z0,
    x1, y0, z1,
    x1, y1, z0,
    x1, y1, z1,
)

위 예시에서 줄은 특정 열 경계에서 나누지 않고 좌표 삼중항을 기준으로 그룹화됩니다.

함수 내에서 긴 문자열 리터럴은 줄 길이 때문에 나누지 않아야 합니다. 이런 문자열이 포함된 함수에서는 문자열 형식 뒤에 줄 바꿈을 추가하고, 인수를 다음 줄이나 이후 줄에 제공합니다. 줄 바꿈 위치는 줄 길이보다는 의미 있는 입력 그룹을 기준으로 결정하는 것이 좋습니다.

// 좋은 예:
log.Warningf("Database key (%q, %d, %q) incompatible in transaction started by (%q, %d, %q)",
    currentCustomer, currentOffset, currentKey,
    txCustomer, txOffset, txKey)
// 나쁜 예:
log.Warningf("Database key (%q, %d, %q) incompatible in"+
    " transaction started by (%q, %d, %q)",
    currentCustomer, currentOffset, currentKey, txCustomer,
    txOffset, txKey)

 

조건문과 반복문 (Conditionals and loops)

if 문은 줄 바꿈 없이 작성하세요. 여러 줄로 작성된 if 절은 들여쓰기 혼란을 유발할 수 있습니다.

// 나쁜 예:
// 두 번째 if 문은 if 블록 내의 코드와 정렬되어 들여쓰기 혼란을 일으킵니다.
if db.CurrentStatusIs(db.InTransaction) &&
    db.ValuesEqual(db.TransactionKey(), row.Key()) {
    return db.Errorf(db.TransactionError, "query failed: row (%v): key does not match transaction key", row)
}

단락(short-circuit) 동작이 필요하지 않은 경우, 불리언 피연산자를 별도로 추출할 수 있습니다.

// 좋은 예:
inTransaction := db.CurrentStatusIs(db.InTransaction)
keysMatch := db.ValuesEqual(db.TransactionKey(), row.Key())
if inTransaction && keysMatch {
    return db.Error(db.TransactionError, "query failed: row (%v): key does not match transaction key", row)
}

특히 조건문이 반복적이라면, 다른 로컬 변수를 추출하여 코드를 간결하게 만들 수 있습니다.

// 좋은 예:
uid := user.GetUniqueUserID()
if db.UserIsAdmin(uid) || db.UserHasPermission(uid, perms.ViewServerConfig) || db.UserHasPermission(uid, perms.CreateGroup) {
    // ...
}
// 나쁜 예:
if db.UserIsAdmin(user.GetUniqueUserID()) || db.UserHasPermission(user.GetUniqueUserID(), perms.ViewServerConfig) || db.UserHasPermission(user.GetUniqueUserID(), perms.CreateGroup) {
    // ...
}

if 문에 클로저나 여러 줄로 된 구조체 리터럴이 포함된 경우, 중괄호를 일치시켜 들여쓰기 혼란을 피하세요.

// 좋은 예:
if err := db.RunInTransaction(func(tx *db.TX) error {
    return tx.Execute(userUpdate, x, y, z)
}); err != nil {
    return fmt.Errorf("user update failed: %s", err)
}
// 좋은 예:
if _, err := client.Update(ctx, &upb.UserUpdateRequest{
    ID:   userID,
    User: user,
}); err != nil {
    return fmt.Errorf("user update failed: %s", err)
}

마찬가지로 for 문에 인위적인 줄 바꿈을 삽입하지 마세요. 우아하게 리팩터링할 방법이 없다면, 줄이 길어지더라도 그대로 두세요.

// 좋은 예:
for i, max := 0, collection.Size(); i < max && !collection.HasPendingWriters(); i++ {
    // ...
}

자주, 아래와 같이 리팩터링이 가능합니다:

// 좋은 예:
for i, max := 0, collection.Size(); i < max; i++ {
    if collection.HasPendingWriters() {
        break
    }
    // ...
}

switchcase 문도 한 줄로 유지하는 것이 좋습니다.

// 좋은 예:
switch good := db.TransactionStatus(); good {
case db.TransactionStarting, db.TransactionActive, db.TransactionWaiting:
    // ...
case db.TransactionCommitted, db.NoTransaction:
    // ...
default:
    // ...
}
// 나쁜 예:
switch bad := db.TransactionStatus(); bad {
case db.TransactionStarting,
    db.TransactionActive,
    db.TransactionWaiting:
    // ...
case db.TransactionCommitted,
    db.NoTransaction:
    // ...
default:
    // ...
}

줄이 너무 길어지면, 들여쓰기를 통해 모든 case를 구분하고 빈 줄로 구분하여 들여쓰기 혼란을 피하세요.

// 좋은 예:
switch db.TransactionStatus() {
case
    db.TransactionStarting,
    db.TransactionActive,
    db.TransactionWaiting,
    db.TransactionCommitted:

    // ...
case db.NoTransaction:
    // ...
default:
    // ...
}

변수를 상수와 비교하는 조건문에서, 변수 값을 등호 연산자의 왼쪽에 위치시키세요:

// 좋은 예:
if result == "foo" {
  // ...
}

상수가 먼저 오는 표현 (일명 "요다 스타일 조건문")은 피하세요:

// 나쁜 예:
if "foo" == result {
  // ...
}

 

복사 (Copying)

예기치 않은 별칭 및 유사한 버그를 피하기 위해 다른 패키지에서 구조체를 복사할 때 주의가 필요합니다. 예를 들어, sync.Mutex와 같은 동기화 객체는 복사해서는 안 됩니다.

bytes.Buffer 타입은 []byte 슬라이스와 작은 문자열을 위한 바이트 배열을 포함합니다. Buffer를 복사할 경우, 복사된 슬라이스가 원본 배열과 같은 메모리 참조를 가질 수 있어 이후 메서드 호출이 예기치 않은 영향을 미칠 수 있습니다.

일반적으로, 메서드가 포인터 타입 *T와 연결되어 있다면 해당 타입 T의 값을 복사하지 마세요.

// 나쁜 예:
b1 := bytes.Buffer{}
b2 := b1

값을 전달하는 리시버를 사용하는 메서드 호출은 복사를 숨길 수 있습니다. API를 작성할 때, 필드가 복사되지 않아야 하는 구조체가 포함된 경우 일반적으로 포인터 타입을 받고 반환하는 것이 좋습니다.

다음은 올바른 예시입니다:

// 좋은 예:
type Record struct {
  buf bytes.Buffer
  // 생략된 다른 필드
}

func New() *Record {...}

func (r *Record) Process(...) {...}

func Consumer(r *Record) {...}

다음과 같은 예시는 대개 잘못된 사용입니다:

// 나쁜 예:
type Record struct {
  buf bytes.Buffer
  // 생략된 다른 필드
}

func (r Record) Process(...) {...} // r.buf를 복사합니다.

func Consumer(r Record) {...} // r.buf를 복사합니다.

이 지침은 sync.Mutex의 복사에도 적용됩니다.

 

패닉 금지 (Don't panic)

정상적인 오류 처리를 위해 panic을 사용하지 마세요. 대신 error와 다중 반환값을 사용하세요. 자세한 내용은 Effective Go: 오류 섹션을 참고하세요.

package main과 초기화 코드에서 프로그램을 종료해야 하는 오류(예: 잘못된 구성)가 발생한 경우 log.Exit를 사용하세요. 이 경우 스택 트레이스가 필요하지 않을 수 있습니다. 참고로, log.Exit는 [os.Exit]를 호출하므로, 이 경우 연기된 함수는 실행되지 않습니다.

불가능한 조건을 나타내는 오류, 즉 코드 리뷰나 테스트에서 항상 잡혀야 하는 버그인 경우, 함수를 통해 오류를 반환하거나 log.Fatal을 호출할 수 있습니다.

panic을 사용할 수 있는 경우에 대한 자세한 내용은 panic을 사용할 때를 참조하세요.

 

Must 함수 (Must functions)

프로그램 실패 시 종료하는 설정 헬퍼 함수는 MustXYZ 또는 mustXYZ라는 명명 규칙을 따릅니다. 일반적으로 이는 프로그램 시작 초기에만 호출해야 하며, 사용자 입력과 같은 상황에서는 일반적인 Go 오류 처리가 선호됩니다.

이 패턴은 패키지 초기화 시에만 호출되는 전역 변수 초기화에 자주 사용됩니다. 예를 들어 template.Mustregexp.MustCompile 함수가 있습니다.

// 좋은 예:
func MustParse(version string) *Version {
    v, err := Parse(version)
    if err != nil {
        panic(fmt.Sprintf("MustParse(%q) = _, %v", version, err))
    }
    return v
}

// 패키지 레벨 "상수"입니다. `Parse`를 사용하려면 `init`에서 값을 설정해야 합니다.
var DefaultVersion = MustParse("1.2.3")

테스트 헬퍼에서 이러한 패턴을 사용하여 t.Fatal을 통해 오류 발생 시 현재 테스트만 중지할 수 있습니다. 이러한 헬퍼 함수는 테스트 값을 생성할 때 유용합니다. 예를 들어, 테이블 기반 테스트에서 오류를 반환하는 함수를 구조체 필드에 직접 할당할 수 없습니다.

// 좋은 예:
func mustMarshalAny(t *testing.T, m proto.Message) *anypb.Any {
  t.Helper()
  any, err := anypb.New(m)
  if err != nil {
    t.Fatalf("mustMarshalAny(t, m) = %v; want %v", err, nil)
  }
  return any
}

func TestCreateObject(t *testing.T) {
  tests := []struct{
    desc string
    data *anypb.Any
  }{
    {
      desc: "my test case",
      data: mustMarshalAny(t, mypb.Object{}),
    },
    // ...
  }
  // ...
}

이 두 가지 경우 모두, 이러한 패턴을 사용하면 헬퍼 함수를 "값" 컨텍스트에서 호출할 수 있는 장점이 있습니다. 이러한 헬퍼는 오류를 잡아내기 어려운 위치나 오류가 확인되어야 하는 컨텍스트(예: 여러 요청 핸들러)에서는 호출하지 말아야 합니다. 상수 입력에 대해서는 Must 인자가 올바르게 형성되었는지 테스트에서 쉽게 확인할 수 있으며, 비상수 입력의 경우 오류가 적절하게 처리되거나 전달되는지 검증할 수 있습니다.

Must 함수가 테스트에서 사용될 때는 일반적으로 테스트 헬퍼로 표시하고, 오류 발생 시 t.Fatal을 호출해야 합니다. (자세한 사항은 테스트 헬퍼의 오류 처리를 참조하세요.)

일반적인 오류 처리가 가능한 경우에는 Must 함수를 사용하지 마세요 (일부 리팩토링 포함):

// 나쁜 예:
func Version(o *servicepb.Object) (*version.Version, error) {
    // Must 함수를 사용하지 말고 오류를 반환하세요.
    v := version.MustParse(o.GetVersionString())
    return dealiasVersion(v)
}

 

고루틴 생명 주기 (Goroutine lifetimes)

고루틴을 생성할 때, 이들이 언제 종료되거나 종료될지 여부를 명확히 하세요.

고루틴은 채널의 송수신 대기 중에 차단되면 누수가 발생할 수 있습니다. 가비지 수집기는 다른 고루틴이 채널에 대한 참조를 가지고 있지 않더라도 채널에서 차단된 고루틴을 종료하지 않습니다.

고루틴이 누수되지 않더라도, 더 이상 필요하지 않을 때 계속 실행 중인 고루틴이 존재하면 다른 미묘하고 진단하기 어려운 문제가 발생할 수 있습니다. 예를 들어, 닫힌 채널에 데이터를 전송하면 패닉이 발생합니다.

// 나쁜 예:
ch := make(chan int)
ch <- 42
close(ch)
ch <- 13 // 패닉 발생

결과가 더 이상 필요하지 않을 때 사용 중인 입력을 수정하면 데이터 경합이 발생할 수 있습니다. 고루틴을 임의로 오랫동안 실행하면 메모리 사용량이 예측 불가능해질 수 있습니다.

동시성 코드는 고루틴의 생명 주기가 명확히 드러나도록 작성해야 합니다. 일반적으로 동기화 관련 코드를 함수 범위 내에 제한하고, 로직을 동기 함수로 분리하는 것이 좋습니다. 동시성이 여전히 명확하지 않은 경우, 고루틴이 종료되는 시점과 이유를 문서화하는 것이 중요합니다.

컨텍스트 사용에 대한 모범 사례를 따르면 고루틴의 생명 주기가 더욱 명확해집니다. 이 경우 context.Context를 사용하는 것이 일반적입니다.

// 좋은 예:
func (w *Worker) Run(ctx context.Context) error {
    var wg sync.WaitGroup
    // ...
    for item := range w.q {
        // process 함수는 컨텍스트가 취소될 때까지 종료됩니다.
        wg.Add(1)
        go func() {
            defer wg.Done()
            process(ctx, item)
        }()
    }
    // ...
    wg.Wait()  // 생성된 고루틴이 이 함수의 생명 주기를 벗어나지 않도록 합니다.
}

위 코드의 다른 변형은 chan struct{}와 같은 신호 채널이나 동기화 변수, 조건 변수 등을 사용할 수 있습니다. 중요한 것은 고루틴의 종료 시점이 후속 유지보수 담당자에게 명확히 드러나는 것입니다.

다음 예시는 생성된 고루틴의 종료 시점을 신경 쓰지 않는 예입니다.

// 나쁜 예:
func (w *Worker) Run() {
    // ...
    for item := range w.q {
        // process는 종료할 때까지 대기하지 않으며, 상태 전환이나 프로그램 종료를
        // 깨끗하게 처리하지 않을 수 있습니다.
        go process(item)
    }
    // ...
}

이 코드에는 다음과 같은 여러 문제가 잠재되어 있습니다:

  • 실제 운영 환경에서 정의되지 않은 동작을 할 가능성이 높으며, 운영 체제가 리소스를 해제하더라도 프로그램이 정상적으로 종료되지 않을 수 있습니다.
  • 코드의 생명 주기가 불확실하여 의미 있는 테스트를 수행하기 어렵습니다.
  • 앞서 설명한 것처럼 리소스 누수가 발생할 수 있습니다.

추가 참조:

 

인터페이스 (Interfaces)

Go 인터페이스는 일반적으로 인터페이스 타입의 값을 구현하는 패키지가 아니라 소비하는 패키지에 포함되어야 합니다. 구현하는 패키지는 구체적인 타입(보통 포인터 또는 구조체 타입)을 반환하는 것이 좋습니다. 이렇게 하면 구현에 새 메서드를 추가해도 대규모 리팩토링이 필요하지 않습니다. 자세한 내용은 GoTip #49: 인터페이스를 받아들이고 구체 타입을 반환하기를 참조하세요.

API가 사용하는 인터페이스의 테스트 더블([test double][double types]) 구현을 API에서 내보내지 마세요. 대신, 실제 구현공용 API를 사용하여 API를 테스트할 수 있도록 설계하세요. GoTip #42: 테스트용 스텁 작성을 참조하세요. 실제 구현을 사용하는 것이 불가능한 경우에도 전체 메서드를 포함하는 인터페이스를 도입하지 않고, 소비자가 필요한 메서드만 포함하는 인터페이스를 생성할 수 있습니다. 이에 대한 예시는 GoTip #78: 최소 필요 인터페이스를 참조하세요.

Stubby RPC 클라이언트를 사용하는 패키지를 테스트하려면 실제 클라이언트 연결을 사용하세요. 실제 서버를 테스트에서 실행할 수 없는 경우, Google의 내부에서는 내부 rpctest 패키지를 사용하여 로컬 테스트 더블에 실제 클라이언트 연결을 얻는 방식으로 테스트합니다.

사용되지 않는 인터페이스는 정의하지 마세요 (참고: TotT: 코드 건강: YAGNI 냄새 제거). 실질적인 사용 예시가 없는 경우 인터페이스가 필요할지 여부와 포함해야 할 메서드를 파악하기 어렵습니다.

인터페이스 타입 매개변수는 패키지 사용자들이 다양한 타입을 전달할 필요가 없을 경우 사용하지 마세요.

패키지 사용자가 필요하지 않은 인터페이스는 내보내지 마세요.

참고: 인터페이스에 대한 심층적인 문서를 작성하고 여기에 연결할 예정입니다.

// 좋은 예:
package consumer // consumer.go

type Thinger interface { Thing() bool }

func Foo(t Thinger) string { ... }
// 좋은 예:
package consumer // consumer_test.go

type fakeThinger struct{ ... }
func (t fakeThinger) Thing() bool { ... }
...
if Foo(fakeThinger{...}) == "x" { ... }
// 나쁜 예:
package producer

type Thinger interface { Thing() bool }

type defaultThinger struct{ ... }
func (t defaultThinger) Thing() bool { ... }

func NewThinger() Thinger { return defaultThinger{ ... } }
// 좋은 예:
package producer

type Thinger struct{ ... }
func (t Thinger) Thing() bool { ... }

func NewThinger() Thinger { return Thinger{ ... } }

 

제네릭 (Generics)

제네릭(공식적으로는 "타입 매개변수")은 비즈니스 요구 사항을 충족하는 경우에 사용할 수 있습니다. 많은 응용 프로그램에서 기존의 언어 기능(슬라이스, 맵, 인터페이스 등)을 사용하는 일반적인 접근 방식이 추가적인 복잡성 없이도 잘 작동하므로, 제네릭의 조기 사용에 주의해야 합니다. 이에 대한 자세한 논의는 최소 메커니즘을 참조하세요.

제네릭을 사용하는 내보내는 API를 도입할 때는 적절한 문서화를 반드시 제공해야 합니다. 실행 가능한 예제를 포함하는 것이 권장됩니다.

멤버 요소의 타입에 신경 쓰지 않는 알고리즘이나 데이터 구조를 구현한다는 이유만으로 제네릭을 사용하지 마세요. 실제로 하나의 타입만 인스턴스화하는 경우, 제네릭을 전혀 사용하지 않고 해당 타입에서 코드가 작동하도록 시작하세요. 나중에 다형성을 추가하는 것이 불필요한 추상화를 제거하는 것보다 간단합니다.

도메인 특화 언어(DSL)를 만들기 위해 제네릭을 사용하지 마세요. 특히, 독자에게 큰 부담을 줄 수 있는 오류 처리 프레임워크의 도입을 자제하세요. 대신, 확립된 오류 처리 관행을 따르세요. 테스트를 위해서는 덜 유용한 테스트 실패를 초래하는 어설션 라이브러리나 프레임워크의 도입에 특히 주의하세요.

일반적으로:

  • 코드를 작성하고, 타입을 설계하지 마세요. Robert Griesemer와 Ian Lance Taylor의 GopherCon 발표에서 인용.
  • 유용한 통합 인터페이스를 공유하는 여러 타입이 있는 경우, 해당 인터페이스를 사용하여 솔루션을 모델링하는 것을 고려하세요. 제네릭이 필요하지 않을 수 있습니다.
  • 그렇지 않으면, any 타입과 과도한 타입 스위칭에 의존하는 대신 제네릭을 고려하세요.

또한 참조하세요:

 

값 전달 (Pass values)

몇 바이트를 절약하기 위해 포인터를 함수 인수로 전달하지 마세요. 함수가 인수 x를 전체적으로 *x로만 읽는 경우, 해당 인수는 포인터일 필요가 없습니다. 일반적인 사례로는 문자열의 포인터(*string)나 인터페이스 값의 포인터(*io.Reader)를 전달하는 것이 있습니다. 두 경우 모두 값 자체는 고정된 크기이며 직접 전달할 수 있습니다.

이 조언은 큰 구조체나 크기가 증가할 수 있는 작은 구조체에는 적용되지 않습니다. 특히, 프로토콜 버퍼 메시지는 일반적으로 값보다는 포인터로 처리해야 합니다. 포인터 타입은 proto.Message 인터페이스를 만족하며(proto.Marshal, protocmp.Transform 등에서 사용), 프로토콜 버퍼 메시지는 상당히 클 수 있으며 시간이 지남에 따라 더 커질 수 있습니다.

 

리시버 타입 (Receiver type)

메서드 리시버는 마치 일반 함수 매개변수처럼 값 또는 포인터로 전달할 수 있습니다. 두 선택지 중 어떤 것을 사용할지는 해당 메서드가 포함될 메서드 집합을 기준으로 결정됩니다.

정확성이 속도나 단순성보다 우선합니다. 특정 상황에서는 포인터를 반드시 사용해야 하며, 그 외의 경우에는 큰 타입이나 코드가 확장될 가능성이 있을 때 포인터를 선택하고, 간단한 기본 데이터 구조의 경우에는 값을 사용하는 것이 좋습니다.

아래는 각 경우에 대한 자세한 설명입니다:

  • 리시버가 슬라이스일 경우, 슬라이스의 크기를 변경하거나 재할당하지 않는다면 포인터 대신 값을 사용합니다.
  • // 좋은 예: type Buffer []byte func (b Buffer) Len() int { return len(b) }
  • 메서드가 리시버를 수정해야 하는 경우, 리시버는 반드시 포인터여야 합니다.
  • // 좋은 예: type Counter int func (c *Counter) Inc() { *c++ } // https://pkg.go.dev/container/heap 참고 type Queue []Item func (q *Queue) Push(x Item) { *q = append([]Item{x}, *q...) }
  • 리시버가 복사하기에 안전하지 않은 필드를 포함하는 구조체일 경우, 포인터 리시버를 사용합니다. 일반적인 예로 sync.Mutex와 같은 동기화 타입이 있습니다.팁: 타입의 Godoc을 확인하여 안전하게 복사할 수 있는지 여부를 파악하세요.
  • // 좋은 예: type Counter struct { mu sync.Mutex total int } func (c *Counter) Inc() { c.mu.Lock() defer c.mu.Unlock() c.total++ }
  • 리시버가 "큰" 구조체나 배열일 경우, 포인터 리시버가 더 효율적일 수 있습니다. 구조체를 전달하는 것은 모든 필드를 메서드에 매개변수로 전달하는 것과 동일하므로, 값으로 전달하기에 크기가 너무 크다면 포인터를 사용하는 것이 좋습니다.
  • 메서드가 다른 함수와 동시에 호출되거나 다른 함수가 리시버를 수정할 경우, 그 수정이 메서드에 영향을 미치지 않도록 하려면 값을 사용하고, 영향을 주어야 한다면 포인터를 사용하세요.
  • 리시버가 포인터를 포함하는 구조체나 배열이고, 포인터가 수정될 가능성이 있는 경우, 가변성을 명확히 하기 위해 포인터 리시버를 사용하는 것이 좋습니다.
  • // 좋은 예: type Counter struct { m *Metric } func (c *Counter) Inc() { c.m.Add(1) }
  • 수정할 필요가 없는 내장 타입인 경우에는 값을 사용합니다.
  • // 좋은 예: type User string func (u User) String() { return string(u) }
  • 리시버가 맵, 함수, 또는 채널일 경우 포인터 대신 값을 사용합니다.
  • // 좋은 예: // https://pkg.go.dev/net/http#Header 참고 type Header map[string][]string func (h Header) Add(key, value string) { /* 생략 */ }
  • 리시버가 작은 배열이나 구조체이고, 가변 필드나 포인터가 없을 경우, 보통 값 리시버를 사용하는 것이 적절합니다.
  • // 좋은 예: // https://pkg.go.dev/time#Time 참고 type Time struct { /* 생략 */ } func (t Time) Add(d Duration) Time { /* 생략 */ }
  • 확신이 서지 않는다면 포인터 리시버를 사용하세요.

일반적으로, 한 타입의 메서드는 모두 포인터 메서드 또는 모두 값 메서드로 통일하는 것이 좋습니다.

참고: 값이나 포인터로 전달하는 것이 성능에 미치는 영향에 대한 잘못된 정보가 많이 있습니다. 컴파일러는 스택에서 값을 복사하거나 포인터로 전달할 수 있으며, 이러한 고려 사항은 대부분의 경우 코드의 가독성과 정확성보다 중요하지 않습니다. 성능이 중요한 경우에는 결정하기 전에 현실적인 벤치마크를 통해 두 접근 방식을 프로파일링하는 것이 중요합니다.

 

switchbreak

switch 문에서 break 문을 타겟 레이블 없이 사용하지 마세요. Go에서는 switch 문이 자동으로 break 되므로 break는 불필요합니다. C나 Java와 달리 Go의 switch 문은 자동으로 각 case 블록을 끝낸 후 빠져나가며, C 스타일의 동작을 원할 경우 fallthrough 문을 사용해야 합니다. 빈 case 블록의 목적을 명확히 하고 싶다면, break 대신 주석을 추가하세요.

// 좋은 예:
switch x {
case "A", "B":
    buf.WriteString(x)
case "C":
    // 이 case는 switch문 외부에서 처리됨
default:
    return fmt.Errorf("알 수 없는 값: %q", x)
}
// 나쁜 예:
switch x {
case "A", "B":
    buf.WriteString(x)
    break // 이 break는 불필요합니다
case "C":
    break // 이 break도 불필요합니다
default:
    return fmt.Errorf("알 수 없는 값: %q", x)
}

참고: for 루프 내에 있는 switch 문에서 break는 루프가 아니라 switch 문만 종료합니다.

for {
  switch x {
  case "A":
     break // switch문만 종료, 루프는 계속
  }
}

루프를 종료하려면 for 문에 라벨을 사용하세요:

loop:
  for {
    switch x {
    case "A":
       break loop // 루프 종료
    }
  }

 

동기 함수 (Synchronous functions)

동기 함수는 직접 결과를 반환하며 콜백이나 채널 연산을 완료한 후에 반환합니다. 동기 함수는 비동기 함수보다 선호됩니다.

동기 함수는 고루틴을 호출 내부에 한정시키므로, 고루틴의 생애 주기를 파악하고 리소스 누수나 데이터 경합을 피하는 데 도움이 됩니다. 동기 함수는 테스트하기도 더 쉬워서, 호출자는 입력을 전달하고 출력만 확인하면 됩니다. 동기 함수가 아닌 경우에는 폴링 또는 동기화가 필요할 수 있습니다.

필요한 경우, 호출자가 별도의 고루틴에서 함수를 호출하여 병렬 처리를 추가할 수 있습니다. 그러나 필요 없는 병렬 처리를 호출 측에서 제거하는 것은 매우 어렵거나 불가능할 수 있습니다.

참고:

 

타입 별칭 (Type aliases)

새로운 타입을 정의할 때는 타입 정의 (type T1 T2)를 사용하고, 기존 타입에 대한 참조만 필요한 경우에는 타입 별칭 (type T1 = T2)을 사용합니다. 타입 별칭은 주로 패키지의 소스 코드 위치를 변경할 때 도움을 주기 위해 사용됩니다. 필요하지 않은 경우에는 타입 별칭을 사용하지 마세요.

 

%q 사용하기

Go의 서식 함수(fmt.Printf 등)는 문자열을 큰따옴표로 묶어 출력하는 %q 서식 문자를 제공합니다.

// 좋은 예:
fmt.Printf("값 %q는 영어 텍스트처럼 보입니다", someText)

수동으로 큰따옴표로 묶는 대신 %q를 사용하세요:

// 나쁜 예:
fmt.Printf("값 \"%s\"는 영어 텍스트처럼 보입니다", someText)
// 문자열을 작은따옴표로 묶는 것도 피하세요:
fmt.Printf("값 '%s'는 영어 텍스트처럼 보입니다", someText)

%q 사용은 입력 값이 비어 있거나 제어 문자를 포함할 가능성이 있는 경우 사람이 읽기 쉽게 만들기 위해 권장됩니다. 빈 문자열은 감지하기 어려울 수 있지만 ""는 확실히 눈에 띕니다.

 

any 사용하기

Go 1.18에서는 any 타입이 별칭으로 interface{}와 동일하게 도입되었습니다. anyinterface{}와 쉽게 교체 가능하며, 새로운 코드에서는 any를 사용하는 것이 좋습니다.

 

공통 라이브러리

Flags

Google의 Go 코드베이스에서는 표준 flag 패키지의 내부 변형 버전을 사용합니다. 인터페이스는 유사하지만 내부 Google 시스템과 호환됩니다. Go 바이너리의 Flag 이름은 단어를 구분할 때 언더스코어를 사용하는 것이 좋으며, flag 값이 저장된 변수는 표준 Go 명명 스타일(mixed caps)을 따라야 합니다. 즉, flag 이름은 snake case로 작성하고, 변수 이름은 해당 이름을 camel case로 작성합니다.

// 좋은 예:
var (
    pollInterval = flag.Duration("poll_interval", time.Minute, "폴링에 사용할 간격입니다.")
)
// 나쁜 예:
var (
    poll_interval = flag.Int("pollIntervalSeconds", 60, "초 단위의 폴링 간격입니다.")
)

Flags는 package main 또는 이에 해당하는 곳에서만 정의해야 합니다.

일반 목적의 패키지는 Go API를 통해 구성되어야 하며, 라이브러리를 가져올 때 부작용으로 새 flag를 내보내지 않도록 해야 합니다. 라이브러리 가져오기 시 flag가 추가되는 일이 없도록 명시적인 함수 인자 또는 구조체 필드 할당을 사용하세요. 매우 드문 경우로 이 규칙을 어겨야 할 때는 flag 이름에 해당 패키지를 명확하게 나타내야 합니다.

Flag가 전역 변수라면 import 섹션 뒤에 var 그룹에 따로 배치하세요.

서브커맨드를 포함한 복잡한 CLI 생성에 대한 모범 사례가 추가로 논의되어 있습니다.

참고 링크:

 

로깅

Google 코드베이스의 Go 프로그램은 표준 log 패키지의 변형 버전을 사용합니다. 인터페이스는 유사하지만 기능이 더 강력하며 내부 Google 시스템과 잘 호환됩니다. 오픈 소스 버전은 패키지 glog로 제공되며, 오픈 소스 Google 프로젝트에서는 이를 사용할 수 있습니다. 이 가이드에서는 이를 log로 지칭합니다.

참고: 비정상적인 프로그램 종료를 위해 이 라이브러리는 log.Fatal을 사용하여 스택 트레이스를 출력하며 종료하고, log.Exit은 스택 트레이스 없이 종료합니다. 표준 라이브러리와 달리 log.Panic 함수는 제공되지 않습니다.

팁: log.Info(v)log.Infof("%v", v)와 동일하며, 다른 로그 레벨에서도 동일합니다. 포맷팅이 필요 없는 경우 포맷팅 없는 버전을 사용하는 것이 좋습니다.

참고 링크:

 

Contexts

context.Context 타입의 값은 API와 프로세스 경계를 넘어 보안 자격 증명, 추적 정보, 데드라인, 취소 신호 등을 전달합니다. Go는 Google 코드베이스의 C++ 및 Java가 스레드 로컬 스토리지를 사용하는 것과 달리, 전체 함수 호출 체인을 통해 컨텍스트를 명시적으로 전달합니다.

context.Context를 함수나 메서드에 전달할 때는 항상 첫 번째 매개변수로 배치합니다.

func F(ctx context.Context /* 다른 인자들 */) {}

예외 사항:

  • HTTP 핸들러의 경우 req.Context()를 통해 컨텍스트를 가져옵니다.
  • 스트리밍 RPC 메서드는 스트림에서 컨텍스트를 가져옵니다. gRPC 스트리밍을 사용하는 코드는 grpc.ServerStream을 구현하는 생성된 서버 타입의 Context() 메서드로부터 컨텍스트에 접근합니다. 자세한 내용은 gRPC 생성 코드 문서를 참조하세요.
  • 진입점 함수에서는 context.Background()를 사용합니다.
    • 바이너리 타겟에서는 main
    • 일반 목적 코드 및 라이브러리에서는 init
    • 테스트에서는 TestXXX, BenchmarkXXX, FuzzXXX

참고: 호출 체인의 중간 코드에서 context.Background()를 사용하여 자체 기본 컨텍스트를 생성해야 할 경우는 거의 없습니다. 호출자의 컨텍스트를 우선 사용하세요.
서버 라이브러리들은 요청당 새로운 컨텍스트 객체를 생성하여 클라이언트 호출자로부터 네트워크 경계를 넘어 전달된 정보를 핸들러에 전달합니다. 이러한 컨텍스트의 수명은 요청에 한정됩니다. 요청이 끝나면 컨텍스트가 취소됩니다.

라이브러리 코드에서 context.Background()로 컨텍스트를 생성하지 마세요. 진입점 함수가 아닌 곳에서 context.Background()가 필요하다면 Google Go 스타일 메일링 리스트와 상의하세요.

컨텍스트를 함수의 첫 번째 인자로 두는 규칙은 테스트 헬퍼에도 적용됩니다.

// 좋은 예:
func readTestFile(ctx context.Context, t *testing.T, path string) string {}

컨텍스트 멤버를 구조체 타입에 추가하지 말고, 필요할 때마다 메서드에 컨텍스트 인자를 추가하세요. 예외는 표준 라이브러리나 외부 라이브러리 인터페이스와 시그니처를 맞춰야 하는 경우뿐이며, 이러한 사례는 드물고 구현 전에 검토가 필요합니다.

Google 코드베이스에서 상위 컨텍스트가 취소된 후에도 백그라운드 작업을 계속 실행해야 하는 코드에는 내부 패키지를 사용하여 분리할 수 있습니다. 오픈 소스 대안을 위한 논의는 issue #40221을 참조하세요.

컨텍스트는 불변이므로, 동일한 데드라인, 취소 신호, 자격 증명, 부모 추적 등을 공유하는 여러 호출에 동일한 컨텍스트를 전달해도 괜찮습니다.

추가 자료:

 

사용자 정의 컨텍스트

함수 시그니처에서 context.Context 외의 사용자 정의 컨텍스트 타입이나 인터페이스를 사용하지 마세요. 예외는 없습니다.

모든 팀이 고유한 컨텍스트를 정의하면 패키지 간 함수 호출마다 변환 과정을 거쳐야 하므로 오류가 발생하기 쉽고, 자동화된 리팩터링을 복잡하게 만듭니다.

전달할 데이터가 있다면 인자, 수신자, 전역 변수, 또는 컨텍스트 값에 넣으세요. 사용자 정의 컨텍스트 타입은 허용되지 않으며, 이는 Go 팀이 Go 프로그램의 생산성을 높이기 위해 작업하는 기능을 저해할 수 있기 때문입니다.

 

crypto/rand

패키지 math/rand를 사용하여 키를 생성하지 마세요. 시드가 설정되지 않은 경우 생성기가 완전히 예측 가능하며, time.Nanoseconds()로 시드를 설정하더라도 엔트로피는 몇 비트에 불과합니다. 대신 crypto/rand의 Reader를 사용하고, 텍스트가 필요한 경우 16진수 또는 base64로 출력하세요.

// 좋은 예:
import (
    "crypto/rand"
    "fmt"
)

func Key() string {
    buf := make([]byte, 16)
    if _, err := rand.Read(buf); err != nil {
        log.Fatalf("Out of randomness, should never happen: %v", err)
    }
    return fmt.Sprintf("%x", buf)
    // 또는 hex.EncodeToString(buf)
    // 또는 base64.StdEncoding.EncodeToString(buf)
}

참고: log.Fatalf는 표준 라이브러리 log가 아닙니다. 자세한 내용은 #logging을 참조하세요.

 

유용한 테스트 실패

테스트 소스를 읽지 않고도 테스트 실패를 진단할 수 있어야 합니다. 테스트는 다음 정보를 포함하는 도움이 되는 메시지로 실패해야 합니다.

  • 무엇이 실패의 원인이 되었는지
  • 실패를 유발한 입력
  • 실제 결과
  • 예상한 결과

이를 달성하기 위한 특정 규칙은 아래에 설명되어 있습니다.

 

Assertion 라이브러리

테스트를 위해 "Assertion 라이브러리"를 생성하지 마세요.

Assertion 라이브러리는 테스트 내에서 검증과 실패 메시지 생성을 결합하려고 시도하는 도구입니다. 테스트 헬퍼와 Assertion 라이브러리의 구별에 대해 더 알아보세요.

// 나쁜 예:
var obj BlogPost

assert.IsNotNil(t, "obj", obj)
assert.StringEq(t, "obj.Type", obj.Type, "blogPost")
assert.IntEq(t, "obj.Comments", obj.Comments, 2)
assert.StringNotEq(t, "obj.Body", obj.Body, "")

Assertion 라이브러리는 assertt.Fatalfpanic을 호출할 경우 테스트를 일찍 중지시키거나, 테스트가 올바른 점에 대한 정보를 누락할 수 있습니다.

// 나쁜 예:
package assert

func IsNotNil(t *testing.T, name string, val any) {
    if val == nil {
        t.Fatalf("Data %s = nil, want not nil", name)
    }
}

func StringEq(t *testing.T, name, got, want string) {
    if got != want {
        t.Fatalf("Data %s = %q, want %q", name, got, want)
    }
}

복잡한 Assertion 함수는 유용한 실패 메시지를 제공하지 못하는 경우가 많습니다. 대신 표준 라이브러리인 cmp와 [fmt]를 사용하여 비교 및 동등성 검사를 수행하세요.

// 좋은 예:
var got BlogPost

want := BlogPost{
    Comments: 2,
    Body:     "Hello, world!",
}

if !cmp.Equal(got, want) {
    t.Errorf("Blog post = %v, want = %v", got, want)
}

테스트의 실패 메시지에서 유용한 정보를 제공하도록 값을 반환하거나 오류를 반환하는 도메인별 비교 헬퍼를 사용하는 것을 선호합니다.

// 좋은 예:
func postLength(p BlogPost) int { return len(p.Body) }

func TestBlogPost_VeritableRant(t *testing.T) {
    post := BlogPost{Body: "I am Gunnery Sergeant Hartman, your senior drill instructor."}

    if got, want := postLength(post), 60; got != want {
        t.Errorf("Length of post = %v, want %v", got, want)
    }
}

 

함수 식별하기

대부분의 테스트에서 실패 메시지에는 실패한 함수의 이름을 포함해야 합니다. 실패 메시지는 YourFunc(%v) = %v, want %v 형식으로 나타내는 것이 좋습니다.

 

Identify the input

대부분의 테스트에서, 실패 메시지에 함수의 입력 값이 짧다면 포함해야 합니다. 입력의 관련 속성이 명확하지 않거나(예: 입력이 크거나 불투명한 경우), 테스트 사례에 대한 설명을 테스트 이름에 포함하고, 오류 메시지의 일부로 이 설명을 출력해야 합니다.

 

Got before want

테스트 출력에는 함수가 반환한 실제 값(got)을 예상 값(want)보다 먼저 포함해야 합니다. 테스트 출력의 표준 형식은 YourFunc(%v) = %v, want %v입니다. "actual" 및 "expected" 대신 각각 "got"과 "want"를 사용하세요.

Diff 비교의 경우 방향성이 덜 명확하므로, 실패 메시지에 해석을 돕는 키를 포함해야 합니다. 기존 코드에서는 순서에 대해 일관성이 없을 수 있으므로, 사용하는 diff 순서도 명확히 명시하는 것이 좋습니다.

 

Full structure comparisons

함수가 여러 필드를 포함한 구조체를 반환할 경우, 개별 필드별 비교 대신 기대하는 데이터를 직접 생성하고 [깊은 비교](deep comparison)를 수행하세요.

참고: 데이터에 테스트 의도를 모호하게 하는 불필요한 필드가 포함된 경우 이 규칙을 적용하지 않습니다.

만약 근사치 등으로 비교가 필요한 경우, cmp.Diff 또는 cmp.Equal과 같은 [cmpopts] 옵션을 사용하여 조건을 조정할 수 있습니다.

 

Compare stable results

의존하는 패키지의 안정성에 영향을 받을 수 있는 결과를 비교하는 것을 피하세요. 포맷된 문자열이나 직렬화된 바이트를 반환하는 함수의 경우, 문자열 또는 바이트 자체가 아닌 의미적으로 일관된 정보와 비교하는 것이 더 안전합니다.

예를 들어, [json.Marshal]을 통한 JSON 직렬화는 변경될 수 있으므로, 문자열 동등성 검사 대신 JSON 문자열의 내용이 예상 데이터 구조와 의미적으로 일치하는지 검사하는 방식이 더 견고한 테스트가 됩니다.

 

Keep going

가능한 한 테스트가 실패 후에도 계속 진행되어, 한 번의 실행에서 모든 실패 지점을 출력하게 해야 합니다. 이를 통해 개발자는 모든 오류를 확인하고 수정할 수 있습니다.

여러 속성을 비교할 때는 t.Error를 사용하고, 예상치 못한 오류 조건을 보고할 때만 t.Fatal을 사용하세요. Table-driven 테스트의 경우에는 서브 테스트와 t.Fatal을 사용하는 것이 유용할 수 있습니다.

Best Practice: t.Fatal의 사용 시점에 대한 자세한 논의는 best practices를 참조하세요.

 

Equality comparison and diffs

== 연산자는 언어 정의 비교를 사용하여 동등성을 평가합니다. 스칼라 값(숫자, 불리언 등)은 값 자체로 비교되지만, 특정 구조체와 인터페이스만이 이 방식으로 비교될 수 있습니다. 포인터는 포인터가 가리키는 값의 동등성 여부가 아닌, 동일한 변수를 가리키는지 여부에 따라 비교됩니다.

더 복잡한 데이터 구조를 비교할 때는 cmp 패키지를 사용하세요. ==으로 적절히 비교되지 않는 슬라이스와 같은 데이터 구조의 경우, cmp.Equal을 통해 동등성을 비교하고, cmp.Diff를 사용하여 사람 읽기 좋은 형태의 diff를 생성할 수 있습니다.

// Good:
want := &Doc{
    Type:     "blogPost",
    Comments: 2,
    Body:     "This is the post body.",
    Authors:  []string{"isaac", "albert", "emmy"},
}
if !cmp.Equal(got, want) {
    t.Errorf("AddPost() = %+v, want %+v", got, want)
}

cmp는 범용 비교 라이브러리로, 특정 유형을 기본적으로 비교할 수 없을 수 있습니다. 예를 들어, 프로토콜 버퍼 메시지를 비교하려면 protocmp.Transform 옵션이 필요합니다.

// Good:
if diff := cmp.Diff(want, got, protocmp.Transform()); diff != "" {
    t.Errorf("Foo() returned unexpected difference in protobuf messages (-want +got):\n%s", diff)
}

cmp 패키지는 Go 팀이 유지 관리하며, 시간이 지나도 안정적인 동등성 결과를 제공할 수 있습니다. 사용자 구성 가능하여 대부분의 비교 요구에 적합합니다.

기존 코드에서는 다음과 같은 오래된 라이브러리를 사용할 수 있으며, 일관성을 위해 이를 계속 사용할 수 있습니다:

  • pretty는 미적 차이를 강조하여 보고서를 생성합니다. 하지만, nil 슬라이스와 빈 슬라이스 차이를 감지하지 못하며, 중첩된 맵을 기준으로 구조체와 비교할 수 있습니다. 의존성의 세부 구현 변화에 민감하여 프로토콜 버퍼 메시지에는 적합하지 않습니다.

새 코드에는 cmp를 선호하며, 실용적인 경우 기존 코드도 cmp로 업데이트하는 것이 좋습니다.

오래된 코드에서 reflect.DeepEqual을 사용하는 경우, 이 함수는 비공개 필드와 구현 세부 사항에 민감하므로 사용을 피하고 cmp 또는 pretty와 같은 라이브러리로 변경해야 합니다.

참고: cmp 패키지는 테스트 용도로 설계되었으며, 잘못된 비교가 수행될 경우 경고 메시지로 panic을 일으킬 수 있습니다. 이 때문에, cmp는 프로덕션 코드보다는 테스트 코드에서 사용하는 것이 적합합니다.

 

Level of Detail

테스트의 실패 메시지로 흔히 사용하는 형식은 YourFunc(%v) = %v, want %v이며, 이는 대부분의 Go 테스트에 적합합니다. 그러나 특정 상황에서는 더 상세하거나 덜 상세한 메시지가 필요할 수 있습니다:

  • 복잡한 상호 작용을 수행하는 테스트는 그 상호 작용을 설명해야 합니다. 예를 들어, 같은 YourFunc를 여러 번 호출했다면 어떤 호출이 실패했는지를 명시하세요. 또한 시스템의 추가 상태 정보가 중요하다면, 실패 메시지(또는 최소한 로그)에 포함하십시오.
  • 복잡한 구조체에 상당한 기본 설정이 필요한 경우 메시지에서 중요한 부분만 설명할 수 있지만, 데이터를 지나치게 생략하지는 마세요.
  • 설정 실패는 동일한 수준의 세부 정보가 필요하지 않습니다. 테스트 데이터베이스 설정에 실패한 경우, 실패 메시지로 t.Fatalf("Setup: Failed to set up test database: %s", err)와 같은 간단한 형식이면 충분할 수 있습니다.

팁: 실패 모드를 개발 중에 자주 트리거하여 유지보수자가 이 실패 메시지로 문제를 효과적으로 해결할 수 있는지 확인해 보세요.

아래는 테스트 입력과 출력을 보다 명확하게 재현하는 몇 가지 방법입니다:

  • 문자열 데이터 출력 시, %q 사용은 값을 강조하고 오류 값을 쉽게 찾아볼 수 있게 합니다.
  • (작은) 구조체 출력 시 %+v%v보다 유용할 수 있습니다.
  • 더 큰 값의 검증이 실패할 경우, diff를 출력하는 것이 실패를 이해하는 데 도움이 될 수 있습니다.

 

Diff 출력하기

함수가 큰 출력을 반환할 경우, 테스트가 실패했을 때 실패 메시지에서 차이점을 찾기 어려울 수 있습니다. 반환 값과 기대 값을 모두 출력하는 대신 diff를 생성하세요.

값에 대한 diff를 계산할 때, cmp.Diff를 사용하는 것이 권장되며, 특히 새 테스트와 코드에 적합합니다. 기타 diff 도구도 사용 가능합니다. 각 함수의 장단점에 대한 자세한 내용은 동등성 비교의 유형을 참조하세요.

diff 패키지를 사용하여 여러 줄 문자열이나 문자열 목록을 비교할 수 있으며, 이를 다른 유형의 diff를 만드는 기본 요소로 사용할 수 있습니다.

diff 방향을 설명하는 텍스트를 실패 메시지에 추가하는 것이 좋습니다.

  • cmp, pretty, diff 패키지를 사용하는 경우 (want, got)을 함수에 전달했다면 diff (-want +got)과 같은 메시지를 사용하세요. format 문자열의 -+가 실제 diff 라인의 시작에 표시되는 -+와 일치하기 때문입니다. (got, want)을 전달했다면 (-got +want)를 사용합니다.
  • messagediff 패키지 사용 시 출력 형식이 다르므로 (want, got)을 전달한 경우 diff (want -> got)과 같은 메시지를 사용하여 화살표 방향을 맞출 수 있습니다.

diff는 여러 줄에 걸쳐 나타나므로, diff 출력 전에 새 줄을 추가하세요.

 

테스트 오류 의미론

단위 테스트가 문자열 비교나 기본 cmp를 사용하여 특정 입력에 대해 특정 종류의 오류가 반환되는지를 확인할 때, 이러한 오류 메시지가 미래에 변경되면 테스트가 불안정해질 수 있습니다. 이는 단위 테스트가 변경 감지기로 변질될 가능성이 있기 때문에 (TotT: Change-Detector Tests Considered Harmful 참조), 문자열 비교를 사용하여 함수가 반환하는 오류의 유형을 확인하지 마세요. 그러나 테스트하는 패키지에서 오는 오류 메시지가 특정 속성을 만족하는지 확인하는 데 문자열 비교를 사용하는 것은 허용됩니다. 예를 들어, 오류 메시지에 매개변수 이름이 포함되어야 한다는 조건을 확인하는 경우입니다.

Go의 오류 값은 일반적으로 사람이 읽을 수 있는 정보와 의미론적 제어 흐름에 사용되는 정보로 구성됩니다. 테스트는 사람이 디버깅을 위해 사용하는 출력 정보보다는 신뢰할 수 있는 의미 정보만을 테스트하는 것이 좋습니다. 향후 변경될 수 있는 디스플레이 정보는 테스트 대상에서 제외하는 것이 좋습니다. 의미론적인 오류 구성에 대한 자세한 가이드는 에러 처리 모범 사례를 참조하세요. 제어할 수 없는 외부 종속성에서 충분한 의미 정보를 제공하지 않는 오류가 발생하는 경우, 오류 메시지 구문 분석에 의존하기보다는 API 개선을 위해 소유자에게 버그 신고를 고려해보세요.

단위 테스트에서는 주로 오류 발생 여부만 확인하는 경우가 많습니다. 이 경우, 오류가 발생할 것으로 예상될 때 오류가 nil이 아닌지만 확인하면 충분합니다. 오류가 다른 특정 오류와 의미상 일치하는지를 테스트하고 싶다면, errors.Is 또는 cmpopts.EquateErrors와 함께 cmp를 사용하는 것을 고려하세요.

참고: 만약 테스트가 cmpopts.EquateErrors를 사용하지만, 모든 wantErr 값이 nil 또는 cmpopts.AnyError인 경우에는 cmp 사용이 불필요한 메커니즘입니다. want 필드를 bool로 만들어 코드 단순화가 가능합니다. 그 후에는 != 비교를 통해 간단하게 확인할 수 있습니다.

// 좋은 예:
err := f(test.input)
gotErr := err != nil
if gotErr != test.wantErr {
    t.Errorf("f(%q) = %v, 원하는 오류 존재 여부 = %v", test.input, err, test.wantErr)
}

자세한 내용은 GoTip #13: Checking을 위한 오류 설계를 참조하세요.

 

테스트 구조

Subtests

표준 Go 테스트 라이브러리는 subtests 정의를 지원합니다. 이를 통해 설정 및 정리 작업의 유연성, 병렬성 제어, 테스트 필터링을 할 수 있습니다. Subtest는 유용할 수 있으며 (특히 테이블 기반 테스트에 유용), 사용이 필수는 아닙니다. 자세한 내용은 Go 블로그의 Subtest에 관한 글을 참조하세요.

Subtest는 다른 케이스의 성공이나 초기 상태에 의존하지 않도록 작성해야 합니다. Subtest는 go test -run 플래그 또는 Bazel test filter 표현식을 사용하여 개별적으로 실행할 수 있어야 하기 때문입니다.

 

Subtest 이름

Subtest의 이름을 설정할 때는 테스트 출력에서 읽기 쉽고, 테스트 필터링을 사용하는 사용자가 커맨드 라인에서 유용하게 사용할 수 있도록 해야 합니다. t.Run을 사용하여 subtest를 만들 때, 첫 번째 인수는 테스트에 대한 설명적인 이름으로 사용됩니다. 테스트 결과가 로그를 읽는 사람들에게 가독성이 있도록, 이스케이프 처리 후에도 유용하고 읽기 쉬운 subtest 이름을 선택하세요. Subtest 이름은 산문적 설명보다는 함수 식별자에 가깝다고 생각하세요. 테스트 러너는 공백을 언더스코어로 대체하고, 출력할 수 없는 문자는 이스케이프 처리합니다. 만약 테스트 데이터에 더 긴 설명이 필요하다면, 별도의 필드에 설명을 넣는 것도 고려할 수 있습니다 (예: t.Log를 사용하여 출력하거나 실패 메시지와 함께 출력).

Subtest는 Go test runner나 Bazel [test filter]를 사용하여 개별적으로 실행할 수 있으므로, 입력하기 쉬운 설명적인 이름을 선택하는 것이 좋습니다.

주의: 슬래시 문자는 subtest 이름에서 특히 비우호적입니다. 이는 테스트 필터에서 특별한 의미를 갖기 때문입니다.

# 나쁜 예:
# TestTime과 t.Run("America/New_York", ...)을 가정
bazel test :mytest --test_filter="Time/New_York"    # 아무것도 실행되지 않음!
bazel test :mytest --test_filter="Time//New_York"   # 맞지만 어색함.

함수의 입력을 식별하기 위해, 테스트 실패 메시지에 이를 포함하세요. 그러면 테스트 러너에 의해 이스케이프 처리되지 않습니다.

// 좋은 예:
func TestTranslate(t *testing.T) {
    data := []struct {
        name, desc, srcLang, dstLang, srcText, wantDstText string
    }{
        {
            name:        "hu=en_bug-1234",
            desc:        "버그 1234 이후 회귀 테스트. 담당: cleese",
            srcLang:     "hu",
            srcText:     "cigarettát és egy öngyújtót kérek",
            dstLang:     "en",
            wantDstText: "cigarettes and a lighter please",
        }, // ...
    }
    for _, d := range data {
        t.Run(d.name, func(t *testing.T) {
            got := Translate(d.srcLang, d.dstLang, d.srcText)
            if got != d.wantDstText {
                t.Errorf("%s\nTranslate(%q, %q, %q) = %q, 예상 %q",
                    d.desc, d.srcLang, d.dstLang, d.srcText, got, d.wantDstText)
            }
        })
    }
}

다음은 피해야 할 예시들입니다:

// 나쁜 예:
// 너무 장황함.
t.Run("check that there is no mention of scratched records or hovercrafts", ...)
// 슬래시가 커맨드 라인에서 문제를 일으킴.
t.Run("AM/PM confusion", ...)

자세한 내용은 Go Tip #117: Subtest Names를 참조하세요.

 

테이블 기반 테스트

유사한 테스트 논리를 통해 다양한 테스트 케이스를 테스트할 수 있을 때는 테이블 기반 테스트를 사용하세요.

  • 함수의 실제 출력이 예상 출력과 동일한지 테스트할 때. 예를 들어, fmt.Sprintf에 대한 여러 테스트 또는 아래 최소 스니펫을 참고하세요.
  • 함수의 출력이 항상 동일한 불변 조건 세트를 충족하는지 테스트할 때. 예를 들어, net.Dial에 대한 테스트를 참고하세요.

다음은 테이블 기반 테스트의 최소 구조입니다. 필요에 따라 다른 이름을 사용하거나 subtest, 설정 및 정리 함수와 같은 추가 기능을 사용할 수 있습니다. 항상 유용한 테스트 실패를 염두에 두세요.

// 좋은 예:
func TestCompare(t *testing.T) {
    compareTests := []struct {
        a, b string
        want int
    }{
        {"", "", 0},
        {"a", "", 1},
        {"", "a", -1},
        {"abc", "abc", 0},
        {"ab", "abc", -1},
        {"abc", "ab", 1},
        {"x", "ab", 1},
        {"ab", "x", -1},
        {"x", "a", 1},
        {"b", "x", -1},
        // runtime·memeq의 청크 구현 테스트
        {"abcdefgh", "abcdefgh", 0},
        {"abcdefghi", "abcdefghi", 0},
        {"abcdefghi", "abcdefghj", -1},
    }

    for _, test := range compareTests {
        got := Compare(test.a, test.b)
        if got != test.want {
            t.Errorf("Compare(%q, %q) = %v, 예상 %v", test.a, test.b, got, test.want)
        }
    }
}

참고: 위 예시의 실패 메시지는 함수 식별입력 식별 지침을 충족합니다. 행을 숫자로 식별할 필요는 없습니다.

일부 테스트 케이스에서 다른 테스트 케이스와 다른 논리를 사용해야 하는 경우, GoTip #50: Disjoint Table Tests에 설명된 대로 여러 테스트 함수를 작성하는 것이 더 적합합니다. 테스트 코드의 논리가 복잡해져서 테이블의 각 항목이 입력에 대한 각 출력을 확인하기 위해 각기 다른 조건 논리를 가지게 된다면, 이해하기 어려워질 수 있습니다. 동일한 설정이 있는 테스트 케이스가 다른 논리를 가지고 있다면 단일 테스트 함수 내의 subtest 순서가 유용할 수 있습니다.

테이블 기반 테스트와 여러 테스트 함수를 결합할 수 있습니다. 예를 들어, 함수의 출력이 예상 출력과 정확히 일치하는지 테스트하고, 잘못된 입력에 대해 함수가 nil이 아닌 오류를 반환하는지 확인할 때는 두 개의 별도 테이블 기반 테스트 함수로 나누는 것이 최선입니다: 정상 출력용과 오류 출력용 각각을 위한 테스트 함수입니다.

 

데이터 기반 테스트 케이스

테이블 테스트의 각 행이 조건에 따라 테스트 케이스 내부의 동작을 결정할 때, 복잡해질 수 있습니다. 테스트 케이스 사이에 중복이 발생하더라도 읽기 쉬움을 위해 필요할 수 있습니다.

// 좋은 예:
type decodeCase struct {
    name   string
    input  string
    output string
    err    error
}

func TestDecode(t *testing.T) {
    // setupCodex는 실제 Codex를 생성하므로 느림.
    codex := setupCodex(t)

    var tests []decodeCase // 행 생략

    for _, test := range tests {
        t.Run(test.name, func(t *testing.T) {
            output, err := Decode(test.input, codex)
            if got, want := output, test.output; got != want {
                t.Errorf("Decode(%q) = %v, 예상 %v", test.input, got, want)
            }
            if got, want := err, test.err; !cmp.Equal(got, want) {
                t.Errorf("Decode(%q) 오류 %q, 예상 %q", test.input, got, want)
            }
        })
    }
}

func TestDecodeWithFake(t *testing.T) {
    // fakeCodex는 실제 Codex의 빠른 대체물.
    codex := newFakeCodex()

    var tests []decodeCase // 행 생략

    for _, test := range tests {
        t.Run(test.name, func(t *testing.T) {
            output, err := Decode(test.input, codex)
            if got, want := output, test.output; got != want {
                t.Errorf("Decode(%q) = %v, 예상 %v", test.input, got, want)
            }
            if got, want := err, test.err; !cmp.Equal(got, want) {
                t.Errorf("Decode(%q) 오류 %q, 예상 %q", test.input, got, want)
            }
        })
    }
}

아래의 반례를 보면, 테스트 설정에서 각 테스트 케이스마다 어떤 유형의 Codex가 사용되는지 구분하기가 어렵습니다. (TotT: Data Driven Traps!의 조언을 위반함)

// 나쁜 예:
type decodeCase struct {
  name   string
  input  string
  codex  testCodex
  output string
  err    error
}

type testCodex int

const (
  fake testCodex = iota
  prod
)

func TestDecode(t *testing.T) {
  var tests []decodeCase // 행 생략

  for _, test := range tests {
    t.Run(test.name, func(t *testing.T) {
      var codex Codex
      switch test.codex {
      case fake:
        codex = newFakeCodex()
      case prod:
        codex = setupCodex(t)
      default:
        t.Fatalf("알 수 없는 codex 유형: %v", codex)
      }
      output, err := Decode(test.input, codex)
      if got, want := output, test.output; got != want {
        t.Errorf("Decode(%q) = %q, 예상 %q", test.input, got, want)
      }
      if got, want := err, test.err; !cmp.Equal(got, want) {
        t.Errorf("Decode(%q) 오류 %q, 예상 %q", test.input, got, want)
      }
    })
  }
}

 

행 식별

테스트 테이블의 인덱스를 사용하여 테스트 이름을 대신하거나 입력을 출력하지 마세요. 어떤 테스트 케이스가 실패했는지 확인하기 위해 테스트 테이블의 항목을 세는 것은 불편합니다.

// 나쁜 예:
tests := []struct {
    input, want string
}{
    {"hello", "HELLO"},
    {"wORld", "WORLD"},
}
for i, d := range tests {
    if strings.ToUpper(d.input) != d.want {
        t.Errorf("케이스 #%d에서 실패", i)
    }
}

테스트 구조체에 테스트 설명을 추가하고 실패 메시지와 함께 출력하세요. subtest를 사용할 때, subtest 이름이 행을 식별하는 데 효과적이어야 합니다.

중요: t.Run이 출력 및 실행을 스코핑하더라도, 항상 입력을 식별해야 합니다. 테이블 테스트 행의 이름은 subtest 이름 지침을 따라야 합니다.

 

테스트 헬퍼

테스트 헬퍼는 설정 또는 정리 작업을 수행하는 함수입니다. 테스트 헬퍼에서 발생하는 모든 실패는 환경의 문제(테스트 대상 코드의 문제가 아님)로 간주됩니다. 예를 들어, 이 컴퓨터에 사용할 수 있는 포트가 부족하여 테스트 데이터베이스를 시작할 수 없는 경우가 해당됩니다.

*testing.T를 전달하는 경우, t.Helper를 호출하여 테스트 헬퍼에서 발생한 실패가 헬퍼를 호출한 라인에 속하도록 해야 합니다. 이 매개변수는 context 매개변수 다음, 나머지 매개변수들 앞에 위치해야 합니다.

// 좋은 예:
func TestSomeFunction(t *testing.T) {
    golden := readFile(t, "testdata/golden-result.txt")
    // ... golden을 사용한 테스트 ...
}

// readFile은 데이터 파일의 내용을 반환합니다.
// 테스트를 시작한 동일한 고루틴에서만 호출해야 합니다.
func readFile(t *testing.T, filename string) string {
    t.Helper()
    contents, err := runfiles.ReadFile(filename)
    if err != nil {
        t.Fatal(err)
    }
    return string(contents)
}

테스트 실패와 그 실패를 유발한 조건 간의 연결을 모호하게 만드는 경우 이 패턴을 사용하지 마세요. 특히, assert 라이브러리에 관한 지침이 여전히 적용되며, t.Helper는 이러한 라이브러리를 구현하는 데 사용되어서는 안 됩니다.

팁: 테스트 헬퍼와 assert 헬퍼의 차이에 대한 자세한 내용은 모범 사례를 참조하세요.

위에서 *testing.T에 대한 내용을 다루고 있지만, 대부분의 지침은 벤치마크 및 퍼즈 헬퍼에도 동일하게 적용됩니다.

 

테스트 패키지

동일 패키지 내 테스트

테스트는 테스트 대상 코드와 동일한 패키지에서 정의할 수 있습니다.

동일한 패키지에서 테스트를 작성하려면:

  • 테스트를 foo_test.go 파일에 위치시킵니다.
  • 테스트 파일에서 package foo를 사용합니다.
  • 테스트할 패키지를 명시적으로 import하지 마세요.
# 좋은 예:
go_library(
    name = "foo",
    srcs = ["foo.go"],
    deps = [
        ...
    ],
)

go_test(
    name = "foo_test",
    size = "small",
    srcs = ["foo_test.go"],
    library = ":foo",
    deps = [
        ...
    ],
)

동일 패키지의 테스트는 해당 패키지의 비공개 식별자에도 접근할 수 있습니다. 이를 통해 더 좋은 테스트 커버리지를 달성하고, 더 간결한 테스트 작성이 가능합니다. 단, 이 경우 테스트에 선언된 예제들은 사용자가 자신의 코드에서 필요한 패키지 이름을 포함하지 않을 수 있음을 주의하세요.

 

다른 패키지에서 테스트

테스트를 테스트 대상 코드와 동일한 패키지에서 정의하는 것이 적합하지 않거나 불가능할 때가 있습니다. 이런 경우 패키지 이름에 _test 접미사를 사용합니다. 이는 패키지 이름 규칙에서 "언더스코어 금지" 규칙의 예외입니다. 예를 들어:

  • 통합 테스트가 특정 라이브러리에 명확히 속하지 않을 경우
  • // 좋은 예: package gmailintegration_test import "testing"
  • 동일한 패키지에 테스트를 정의하면 순환 종속성이 발생하는 경우
  • // 좋은 예: package fireworks_test import ( "fireworks" "fireworkstestutil" // fireworkstestutil도 fireworks를 import )

 

testing 패키지 사용하기

Go 표준 라이브러리에는 testing 패키지가 있습니다. 이는 Google 코드베이스에서 Go 코드에 허용된 유일한 테스트 프레임워크입니다. 특히 assertion 라이브러리와 서드파티 테스트 프레임워크는 허용되지 않습니다.

testing 패키지는 좋은 테스트를 작성하기 위한 최소한의 필수 기능을 제공합니다:

  • 최상위 테스트
  • 벤치마크
  • 실행 가능한 예제
  • Subtest
  • 로깅
  • 일반적인 실패와 치명적인 실패

이들은 구성 리터럴if 초기화 구문과 같은 코어 언어 기능과 조화롭게 작동하도록 설계되어 명확하고 읽기 쉬우며 유지 관리하기 쉬운 테스트를 작성할 수 있습니다.

 

비결정 사항

스타일 가이드가 모든 사안에 대해 긍정적인 처방을 나열할 수는 없으며, 의견을 제시하지 않는 사안에 대해서도 모든 항목을 나열할 수 없습니다. 그러나 다음은 가독성 커뮤니티에서 이전에 논의되었으나 합의에 도달하지 못한 몇 가지 사항입니다.

  • 지역 변수의 기본값 초기화. var i inti := 0은 동일합니다. 자세한 내용은 초기화 모범 사례를 참조하세요.
  • 빈 구성 리터럴 vs new 또는 make. &File{}new(File)는 동일합니다. map[string]bool{}make(map[string]bool)도 마찬가지입니다. 구성 선언 모범 사례를 참조하세요.
  • cmp.Diff 호출에서 got, want 인수 순서. 로컬 일관성을 유지하고, 오류 메시지에 설명을 포함하세요.
  • 비포맷된 문자열에 errors.New vs fmt.Errorf 사용. errors.New("foo")fmt.Errorf("foo")는 상호 교환하여 사용할 수 있습니다.

특별한 상황에서 다시 등장하면 가독성 멘토가 선택적인 의견을 줄 수 있지만, 일반적으로 작성자는 주어진 상황에서 선호하는 스타일을 자유롭게 선택할 수 있습니다.

스타일 가이드에 포함되지 않은 항목에 대해 추가 논의가 필요할 경우, 작성자는 특정 리뷰에서 또는 내부 메시지 보드를 통해 질문할 수 있습니다.