티스토리 뷰

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

Overview | Guide | Decisions Best practices

 

 

참고: 이 문서는 Google의 Go 스타일에 대한 일련의 문서 중 일부입니다. 이 문서는 규범적이지도 않고 표준적이지도 않으며, 핵심 스타일 가이드에 대한 보조 문서입니다. 자세한 내용은 개요를 참조하세요.

 

소개

이 파일은 Go 스타일 가이드를 최선으로 적용하는 방법에 대한 지침을 문서화합니다. 이 지침은 일반적으로 자주 발생하는 상황에 대해 작성되었으며, 모든 경우에 적용되지 않을 수 있습니다. 가능한 경우, 여러 대체 접근 방식을 논의하고 언제 이를 적용할지에 대한 고려사항을 설명합니다.

전체 스타일 가이드 문서는 개요를 참조하세요.

 

명명 규칙

함수 및 메서드 이름

반복 피하기

함수나 메서드 이름을 선택할 때는 해당 이름이 읽히는 컨텍스트를 고려해야 합니다. 호출 위치에서 과도한 반복을 피하기 위한 다음의 권장 사항을 참고하세요:

  • 함수와 메서드 이름에서는 다음 사항을 생략하는 것이 일반적입니다:
    • 입력 및 출력의 타입(충돌이 없는 경우)
    • 메서드 수신자의 타입
    • 입력 또는 출력이 포인터인지 여부
  • 함수의 경우, 패키지 이름을 반복하지 마세요.
      // 좋은 예:
      package yamlconfig
    
      func Parse(input string) (*Config, error)
  • // 나쁜 예: package yamlconfig func ParseYAMLConfig(input string) (*Config, error)
  • 메서드의 경우, 메서드 수신자의 이름을 반복하지 마세요.
      // 좋은 예:
      func (c *Config) WriteTo(w io.Writer) (int64, error)
  • // 나쁜 예: func (c *Config) WriteConfigTo(w io.Writer) (int64, error)
  • 전달된 매개변수 이름을 반복하지 마세요.
      // 좋은 예:
      func Override(dest, source *Config) error
  • // 나쁜 예: func OverrideFirstWithSecond(dest, source *Config) error
  • 반환 값의 이름과 타입을 반복하지 마세요.
      // 좋은 예:
      func Transform(input *Config) *jsonconfig.Config
  • // 나쁜 예: func TransformYAMLToJSON(input *Config) *jsonconfig.Config

유사한 이름을 가진 함수들을 구분할 필요가 있는 경우, 추가 정보를 포함하는 것이 허용됩니다.

// 좋은 예:
func (c *Config) WriteTextTo(w io.Writer) (int64, error)
func (c *Config) WriteBinaryTo(w io.Writer) (int64, error)

 

이름 관례

함수 및 메서드 이름을 선택할 때의 일반적인 관례는 다음과 같습니다:

  • 반환 값을 가진 함수는 명사형 이름을 사용합니다.이와 관련하여 함수와 메서드 이름에서 Get 접두사를 피하는 것이 좋습니다.
  • // 나쁜 예: func (c *Config) GetJobName(key string) (value string, ok bool)
  • // 좋은 예: func (c *Config) JobName(key string) (value string, ok bool)
  • 어떤 작업을 수행하는 함수는 동사형 이름을 사용합니다.
  • // 좋은 예: func (c *Config) WriteDetail(w io.Writer) (int64, error)
  • 동일한 함수가 포함된 타입에 따라 달라지는 경우, 타입 이름을 함수 이름 끝에 추가합니다.명확한 "기본" 버전이 있는 경우, 그 버전에서는 타입을 이름에서 생략할 수 있습니다.
  • // 좋은 예: func (c *Config) Marshal() ([]byte, error) func (c *Config) MarshalText() (string, error)
  • // 좋은 예: func ParseInt(input string) (int, error) func ParseInt64(input string) (int64, error) func AppendInt(buf []byte, value int) []byte func AppendInt64(buf []byte, value int64) []byte

 

테스트 대체 패키지 및 타입

테스트 헬퍼 및 특히 [테스트 대체물](test doubles)을 제공하는 패키지와 타입의 명명 규칙을 적용하는 여러 방식이 있습니다. 테스트 대체물은 스텁, 페이크, 목 또는 스파이일 수 있습니다.

이 예제는 주로 스텁을 사용하며, 코드에서 페이크 또는 다른 종류의 테스트 대체물을 사용하는 경우 이름을 해당 내용에 맞게 업데이트하세요.

아래와 같은 프로덕션 코드를 제공하는 집중된 패키지가 있다고 가정합니다:

package creditcard

import (
    "errors"
    "path/to/money"
)

// ErrDeclined는 발행자가 청구를 거부했음을 나타냅니다.
var ErrDeclined = errors.New("creditcard: declined")

// Card는 발행자, 만료일, 한도 등의 신용카드 정보를 포함합니다.
type Card struct {
    // 생략
}

// Service는 외부 결제 처리업체에 대해 청구, 승인, 환불, 구독과 같은 작업을 수행할 수 있게 해줍니다.
type Service struct {
    // 생략
}

func (s *Service) Charge(c *Card, amount money.Money) error { /* 생략 */ }

 

테스트 헬퍼 패키지 생성

다른 패키지에 대한 테스트 대체물을 포함하는 패키지를 만들고자 한다고 가정해보겠습니다. 위의 package creditcard를 예로 사용합니다.

한 가지 접근 방식은 프로덕션용 패키지를 기반으로 한 새로운 Go 패키지를 테스트용으로 도입하는 것입니다. 안전한 선택은 원래 패키지 이름에 test를 추가하는 것입니다("creditcard" + "test"):

// 좋은 예:
package creditcardtest

별도로 명시되지 않는 한, 아래 섹션의 모든 예제는 package creditcardtest에 속합니다.

 

간단한 경우

Service에 대한 테스트 대체물을 추가하려고 합니다. Card는 프로토콜 버퍼 메시지와 유사한 단순 데이터 타입이므로 테스트에서 특별한 처리가 필요하지 않아 대체물이 필요하지 않습니다. 특정 타입(예: Service)에 대한 테스트 대체물만 예상되는 경우, 대체물의 이름을 간결하게 지정할 수 있습니다:

// 좋은 예:
import (
    "path/to/creditcard"
    "path/to/money"
)

// Stub은 creditcard.Service를 스텁하고, 자체 동작을 제공하지 않습니다.
type Stub struct{}

func (Stub) Charge(*creditcard.Card, money.Money) error { return nil }

이것은 StubService 또는 매우 나쁜 예인 StubCreditCardService 같은 이름 선택보다 엄격하게 선호됩니다. 기본 패키지 이름과 그 도메인 타입이 creditcardtest.Stub이 무엇인지 암시해 주기 때문입니다.

마지막으로 패키지가 Bazel로 빌드되는 경우, 패키지의 새로운 go_library 규칙에 testonly로 표시하는 것을 잊지 마세요:

# 좋은 예:
go_library(
    name = "creditcardtest",
    srcs = ["creditcardtest.go"],
    deps = [
        ":creditcard",
        ":money",
    ],
    testonly = True,
)

위의 접근 방식은 관례적이며 다른 엔지니어들이 합리적으로 잘 이해할 수 있습니다.

참고 자료:

 

여러 테스트 대체물 동작

하나의 스텁으로 충분하지 않을 때 (예를 들어 항상 실패하는 스텁이 추가로 필요할 때), 스텁을 구현하는 동작에 따라 이름을 지정하는 것이 좋습니다. 여기서 StubAlwaysCharges로 이름을 변경하고, AlwaysDeclines라는 새로운 스텁을 추가합니다:

// 좋은 예:
// AlwaysCharges는 creditcard.Service를 스텁하고 성공을 시뮬레이션합니다.
type AlwaysCharges struct{}

func (AlwaysCharges) Charge(*creditcard.Card, money.Money) error { return nil }

// AlwaysDeclines는 creditcard.Service를 스텁하고 결제를 거부하는 것을 시뮬레이션합니다.
type AlwaysDeclines struct{}

func (AlwaysDeclines) Charge(*creditcard.Card, money.Money) error {
    return creditcard.ErrDeclined
}

 

여러 타입에 대한 여러 대체물

이제 package creditcardServiceStoredValue 같은 여러 타입이 있고, 이를 위한 대체물이 필요하다고 가정해 보겠습니다:

package creditcard

type Service struct {
    // 생략
}

type Card struct {
    // 생략
}

// StoredValue는 고객의 크레딧 잔액을 관리합니다.
// 반품 상품이 신용 발행인이 아닌 고객의 로컬 계정에 적립되는 경우 적용됩니다.
// 이러한 이유로 별도의 서비스로 구현됩니다.
type StoredValue struct {
    // 생략
}

func (s *StoredValue) Credit(c *Card, amount money.Money) error { /* 생략 */ }

이 경우 더 명확한 테스트 대체물 이름을 사용하는 것이 합리적입니다:

// 좋은 예:
type StubService struct{}

func (StubService) Charge(*creditcard.Card, money.Money) error { return nil }

type StubStoredValue struct{}

func (StubStoredValue) Credit(*creditcard.Card, money.Money) error { return nil }

 

테스트에서의 로컬 변수

테스트에서 대체물을 참조하는 변수를 사용할 때는 상황에 따라 프로덕션 타입과 명확히 구분할 수 있는 이름을 선택하세요. 예를 들어 테스트하려는 프로덕션 코드가 다음과 같다고 가정합니다:

package payment

import (
    "path/to/creditcard"
    "path/to/money"
)

type CreditCard interface {
    Charge(*creditcard.Card, money.Money) error
}

type Processor struct {
    CC CreditCard
}

var ErrBadInstrument = errors.New("payment: instrument is invalid or expired")

func (p *Processor) Process(c *creditcard.Card, amount money.Money) error {
    if c.Expired() {
        return ErrBadInstrument
    }
    return p.CC.Charge(c, amount)
}

테스트에서는 CreditCard의 "spy"라는 대체물이 프로덕션 타입과 나란히 사용되므로, 이름에 접두사를 붙이면 가독성이 향상될 수 있습니다.

// 좋은 예:
package payment

import "path/to/creditcardtest"

func TestProcessor(t *testing.T) {
    var spyCC creditcardtest.Spy
    proc := &Processor{CC: spyCC}

    // 선언 생략: card 및 amount
    if err := proc.Process(card, amount); err != nil {
        t.Errorf("proc.Process(card, amount) = %v, 예상 nil", err)
    }

    charges := []creditcardtest.Charge{
        {Card: card, Amount: amount},
    }

    if got, want := spyCC.Charges, charges; !cmp.Equal(got, want) {
        t.Errorf("spyCC.Charges = %v, 예상 %v", got, want)
    }
}

접두사를 붙이지 않은 경우보다 더 명확합니다.

// 나쁜 예:
package payment

import "path/to/creditcardtest"

func TestProcessor(t *testing.T) {
    var cc creditcardtest.Spy

    proc := &Processor{CC: cc}

    // 선언 생략: card 및 amount
    if err := proc.Process(card, amount); err != nil {
        t.Errorf("proc.Process(card, amount) = %v, 예상 nil", err)
    }

    charges := []creditcardtest.Charge{
        {Card: card, Amount: amount},
    }

    if got, want := cc.Charges, charges; !cmp.Equal(got, want) {
        t.Errorf("cc.Charges = %v, 예상 %v", got, want)
    }
}

 

Shadowing

참고: 이 설명에서는 비공식 용어인 stompingshadowing을 사용합니다. 이는 Go 언어 사양의 공식 개념이 아닙니다.

많은 프로그래밍 언어와 마찬가지로 Go에는 값이 변경될 수 있는 변수(mutable variables)가 있습니다. 변수를 할당하면 해당 변수의 값이 변경됩니다.

// 좋은 예:
func abs(i int) int {
    if i < 0 {
        i *= -1
    }
    return i
}

:= 연산자를 사용하는 짧은 변수 선언을 사용할 때, 경우에 따라 새 변수가 생성되지 않습니다. 이를 stomping이라 부를 수 있으며, 원래 값이 더 이상 필요하지 않은 경우에 사용해도 괜찮습니다.

// 좋은 예:
// innerHandler는 일부 요청 처리 핸들러를 위한 헬퍼로, 자체적으로 다른 백엔드에 요청을 보냅니다.
func (s *Server) innerHandler(ctx context.Context, req *pb.MyRequest) *pb.MyResponse {
    // 이 부분의 요청 처리를 위해 무조건적으로 제한 시간을 설정합니다.
    ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
    defer cancel()
    ctxlog.Info(ctx, "inner 요청에서 제한 시간을 설정했습니다")

    // 여기서부터는 원래 context에 접근할 수 없습니다.
    // 이 코드를 작성할 때, 코드가 확장되더라도 원래 context(무제한일 수 있음)를 사용하는
    // 연산은 합리적으로 존재하지 않도록 하는 것이 좋은 스타일입니다.

    // ...
}

그러나 새 범위에서 짧은 변수 선언을 사용할 때 주의하세요. 이는 새 변수를 도입하여 원래 변수를 shadowing합니다. 코드 블록이 끝난 후에는 원래 변수로 다시 참조하게 됩니다. 다음은 조건에 따라 제한 시간을 줄이려는 시도의 잘못된 예입니다:

// 나쁜 예:
func (s *Server) innerHandler(ctx context.Context, req *pb.MyRequest) *pb.MyResponse {
    // 조건부로 제한 시간을 설정하려고 시도합니다.
    if *shortenDeadlines {
        ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
        defer cancel()
        ctxlog.Info(ctx, "inner 요청에서 제한 시간을 설정했습니다")
    }

    // 버그: 여기서 "ctx"는 호출자가 제공한 context를 의미하게 됩니다.
    // 위의 잘못된 코드는 ctx와 cancel이 if 문 내에서 사용되었기 때문에 컴파일이 되었습니다.

    // ...
}

올바른 코드 버전은 다음과 같습니다:

// 좋은 예:
func (s *Server) innerHandler(ctx context.Context, req *pb.MyRequest) *pb.MyResponse {
    if *shortenDeadlines {
        var cancel func()
        // = 할당을 사용한 점에 주목하세요. :=이 아닙니다.
        ctx, cancel = context.WithTimeout(ctx, 3*time.Second)
        defer cancel()
        ctxlog.Info(ctx, "inner 요청에서 제한 시간을 설정했습니다")
    }
    // ...
}

stomping의 경우, 새로운 변수가 없기 때문에 할당되는 타입은 원래 변수의 타입과 일치해야 합니다. shadowing의 경우 완전히 새로운 변수가 도입되므로 다른 타입을 가질 수 있습니다. 의도적인 shadowing은 유용할 수 있지만, 가독성을 높이기 위해 새 이름을 사용하는 것이 좋습니다.

표준 패키지 이름과 동일한 이름을 광범위한 범위에서 사용하는 것은 권장되지 않습니다. 이렇게 하면 해당 패키지의 함수 및 값을 사용할 수 없게 됩니다. 반대로, 패키지 이름을 선택할 때는 import 이름 변경을 필요로 하거나 클라이언트 쪽에서 유용한 변수 이름을 덮어쓰게 만드는 이름을 피하세요.

// 나쁜 예:
func LongFunction() {
    url := "https://example.com/"
    // 이 코드 아래에서 net/url을 사용할 수 없습니다.
}

 

유틸 패키지

Go 패키지에는 package 선언에서 지정된 이름이 있으며, 이는 import 경로와는 별개입니다. 패키지 이름은 경로보다 가독성에 더 큰 영향을 줍니다.

Go 패키지 이름은 패키지가 제공하는 기능과 관련되어야 합니다. 패키지를 util, helper, common과 같이 단순하게 명명하는 것은 좋지 않은 선택입니다 (단, 이름의 일부로는 사용할 수 있습니다). 설명이 부족한 이름은 코드 읽기를 어렵게 만들고, 너무 광범위하게 사용하면 불필요한 import 충돌을 일으킬 가능성이 큽니다.

대신, 호출 위치에서의 가독성을 고려하세요.

// 좋은 예:
db := spannertest.NewDatabaseFromFile(...)

_, err := f.Seek(0, io.SeekStart)

b := elliptic.Marshal(curve, x, y)

import 목록을 보지 않아도 대략적으로 각 코드가 무엇을 하는지 알 수 있습니다 (cloud.google.com/go/spanner/spannertest, io, crypto/elliptic). 초점이 적은 이름을 사용하면 다음과 같이 보일 수 있습니다:

// 나쁜 예:
db := test.NewDatabaseFromFile(...)

_, err := f.Seek(0, common.SeekStart)

b := helper.Marshal(curve, x, y)

 

패키지 크기

Go 패키지가 얼마나 커야 하는지, 관련된 타입을 같은 패키지에 둘지 다른 패키지로 나눌지 고민된다면, Go 블로그의 패키지 이름에 대한 글을 참고해 보세요. 이 글은 단순히 이름에 관한 내용만이 아니라, 유용한 힌트와 여러 참고 자료 및 강연을 소개합니다.

다음은 몇 가지 추가 고려 사항과 노트입니다.

사용자는 패키지의 godoc을 한 페이지로 확인할 수 있으며, 패키지에서 제공하는 타입의 모든 메서드는 해당 타입별로 그룹화됩니다. godoc은 생성자도 반환하는 타입과 함께 그룹화합니다. 만약 클라이언트 코드가 서로 다른 타입의 두 값을 상호작용하는 데 필요할 가능성이 있다면, 사용자 입장에서 이들을 같은 패키지에 두는 것이 편리할 수 있습니다.

패키지 내의 코드는 패키지의 비공개 식별자에 접근할 수 있습니다. 구현이 밀접하게 연결된 몇 가지 관련 타입이 있는 경우, 이를 동일한 패키지에 두면 공용 API에 해당 세부 정보를 노출하지 않고도 이러한 결합을 달성할 수 있습니다. 이러한 결합을 테스트하는 좋은 방법은 두 개의 패키지를 사용하는 가상의 사용자를 상상해 보는 것입니다. 만약 사용자가 두 패키지를 동시에 import해야 의미 있게 사용할 수 있다면, 이들을 결합하는 것이 일반적으로 옳은 방법입니다. 표준 라이브러리는 일반적으로 이러한 범위 및 계층 구조를 잘 보여줍니다.

그렇다고 프로젝트 전체를 하나의 패키지에 넣는 것은 너무 크기 때문에 권장되지 않습니다. 개념적으로 구분되는 요소는 자체 패키지를 부여하면 사용하기가 더 쉬워질 수 있습니다. 클라이언트가 알게 되는 패키지의 짧은 이름과 내보내진 타입 이름이 결합되어 의미 있는 식별자를 형성합니다: 예를 들어 bytes.Buffer, ring.New가 그렇습니다. 더 많은 예는 블로그 글에서 확인할 수 있습니다.

Go 스타일은 파일 크기에 대해 유연합니다. 메인테이너가 호출자에게 영향을 주지 않고 패키지 내에서 코드를 다른 파일로 옮길 수 있기 때문입니다. 그러나 일반적인 가이드라인으로는: 수천 줄이 넘는 파일을 단일 파일로 유지하거나 너무 작은 파일을 여러 개로 나누는 것은 좋은 아이디어가 아닙니다. 일부 언어처럼 "하나의 타입, 하나의 파일"이라는 규칙은 없습니다. 대체적으로 파일은 메인테이너가 원하는 내용을 찾기 쉽게 집중되어 있어야 하며, 작은 크기로 유지하여 찾기 쉽게 만들어야 합니다. 표준 라이브러리는 종종 큰 패키지를 여러 소스 파일로 분할하여 관련된 코드를 파일별로 그룹화합니다. 패키지 bytes의 소스 코드가 좋은 예입니다. 긴 패키지 설명이 필요한 패키지에서는 설명을 doc.go라는 파일에 넣어 패키지 선언과 함께 사용할 수 있지만 필수는 아닙니다.

Google 코드베이스 및 Bazel을 사용하는 프로젝트 내에서 Go 코드의 디렉터리 레이아웃은 오픈 소스 Go 프로젝트와 다릅니다. 한 디렉터리에서 여러 go_library 타겟을 가질 수 있습니다. 프로젝트를 오픈 소스로 전환할 계획이 있다면, 각 패키지에 자체 디렉터리를 제공하는 것이 좋습니다.

추가 자료:

 

Imports

Proto와 스텁

Proto 라이브러리 import는 언어 간 특성 때문에 표준 Go import와 다르게 처리됩니다. 프로토 import에 대한 명명 규칙은 패키지를 생성한 규칙을 기반으로 합니다:

  • 일반적으로 go_proto_library 규칙에는 pb 접미사를 사용합니다.
  • 일반적으로 go_grpc_library 규칙에는 grpc 접미사를 사용합니다.

일반적으로 1~2글자의 짧은 접두사를 사용합니다:

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

만약 패키지가 단일 proto만 사용하는 경우나 proto와 긴밀히 연결된 경우에는 접두사를 생략할 수 있습니다:

import (
    pb "path/to/package/foo_service_go_proto"
    grpc "path/to/package/foo_service_go_grpc"
)

Proto 내의 기호가 일반적이거나 설명이 부족한 경우, 또는 약어로 패키지 이름을 줄이면 혼동을 초래할 때는 간단한 단어로 접두사를 사용하는 것이 좋습니다:

// 좋은 예:
import (
    mapspb "path/to/package/maps_go_proto"
)

이 경우 코드가 지도와 관련이 명확하지 않은 경우 mpb.Address보다 mapspb.Address가 더 명확할 수 있습니다.

 

Import 순서

Imports는 일반적으로 다음과 같은 두 개 이상의 그룹으로 정렬됩니다:

  1. 표준 라이브러리 import (예: "fmt")
  2. 프로젝트 import (예: "/path/to/somelib")
  3. (선택사항) Protobuf import (예: fpb "path/to/foo_go_proto")
  4. (선택사항) 사이드 이펙트 import (예: _ "path/to/package")

파일에 위의 선택적 범주 중 하나가 없는 경우 관련 import는 프로젝트 import 그룹에 포함됩니다.

명확하고 이해하기 쉬운 모든 import 그룹화 방식은 일반적으로 괜찮습니다. 예를 들어, gRPC import를 protobuf import와 별도로 그룹화하도록 팀에서 선택할 수 있습니다.

참고: 두 개의 필수 그룹(표준 라이브러리용 한 그룹과 모든 다른 import용 한 그룹)만 유지하는 코드의 경우, goimports 도구는 이 가이드에 일관된 출력을 생성합니다.

그러나 goimports는 필수 그룹 외의 그룹에 대한 정보가 없으므로 선택적 그룹은 도구에 의해 무효화될 가능성이 있습니다. 선택적 그룹이 사용될 때 작성자와 리뷰어 모두 주의를 기울여 그룹이 일관되게 유지되도록 해야 합니다.

어떤 접근 방식이든 괜찮지만, import 섹션을 불일치하거나 부분적으로만 그룹화된 상태로 두지 마세요.

 

에러 처리

Go에서는 에러는 값입니다. 즉, 에러는 코드에서 생성되고 코드에서 소비됩니다. 에러는 다음과 같은 용도로 사용될 수 있습니다:

  • 사람이 보기 위한 진단 정보로 변환
  • 메인테이너가 사용
  • 최종 사용자가 해석할 수 있도록 제공

에러 메시지는 로그 메시지, 에러 덤프, UI에 표시되는 등 다양한 인터페이스에서 나타납니다.

에러를 처리하는 코드(생성 또는 소비)는 신중하게 작성해야 합니다. 에러 반환 값을 무시하거나 무작정 전파하고 싶을 수 있지만, 현재 호출 프레임의 함수가 에러를 가장 효과적으로 처리할 위치인지 항상 고려할 필요가 있습니다. 에러 처리는 광범위한 주제이며 일괄적인 조언을 주기 어렵습니다. 판단을 사용하되 다음과 같은 사항을 염두에 두세요:

  • 에러 값을 생성할 때, 구조화할 필요가 있는지 결정하세요.
  • 에러를 처리할 때, 호출자 및/또는 피호출자가 알지 못할 수 있는 정보를 추가하는 것을 고려하세요.
  • 에러 로깅에 대한 지침도 참조하세요.

에러를 무시하는 것은 일반적으로 적절하지 않지만, 관련 작업을 조정할 때와 같이 종종 첫 번째 에러만 유용할 경우가 있습니다. 패키지 errgroup은 실패하거나 그룹으로 취소할 수 있는 작업 그룹에 편리한 추상화를 제공합니다.

추가 자료:

 

에러 구조화

호출자가 에러를 검사해야 할 경우(예: 다른 에러 조건을 구분해야 할 때), 에러 값을 구조화하여 호출자가 문자열 매칭 대신 프로그래밍 방식으로 구분할 수 있도록 하세요. 이 조언은 프로덕션 코드뿐만 아니라 다른 에러 조건을 신경 쓰는 테스트에도 적용됩니다.

가장 간단한 구조화된 에러는 매개 변수가 없는 전역 값입니다.

type Animal string

var (
    // ErrDuplicate는 이미 본 동물일 때 발생합니다.
    ErrDuplicate = errors.New("duplicate")

    // ErrMarsupial은 호주 외의 유대류에 대한 알레르기 때문에 발생합니다. 죄송합니다.
    ErrMarsupial = errors.New("marsupials are not supported")
)

func process(animal Animal) error {
    switch {
    case seen[animal]:
        return ErrDuplicate
    case marsupial(animal):
        return ErrMarsupial
    }
    seen[animal] = true
    // ...
    return nil
}

호출자는 함수에서 반환된 에러 값을 알려진 에러 값 중 하나와 비교할 수 있습니다:

// 좋은 예:
func handlePet(...) {
    switch err := process(an); err {
    case ErrDuplicate:
        return fmt.Errorf("feed %q: %v", an, err)
    case ErrMarsupial:
        // 다른 친구로 대체 시도
        alternate := an.BackupAnimal()
        return handlePet(..., alternate, ...)
    }
}

위 예제는 센티널 값(sentinel values)을 사용하며, 에러는 예상 값과 동일(== 연산자 의미)해야 합니다. 이는 많은 경우에 충분히 적합합니다. process가 래핑된 에러를 반환하는 경우(아래에서 설명), errors.Is를 사용할 수 있습니다.

// 좋은 예:
func handlePet(...) {
    switch err := process(an); {
    case errors.Is(err, ErrDuplicate):
        return fmt.Errorf("feed %q: %v", an, err)
    case errors.Is(err, ErrMarsupial):
        // ...
    }
}

에러 문자열을 기반으로 에러를 구분하려고 하지 마세요. (Go Tip #13: 체크를 위한 에러 설계를 참조하세요.)

// 나쁜 예:
func handlePet(...) {
    err := process(an)
    if regexp.MatchString(`duplicate`, err.Error()) {...}
    if regexp.MatchString(`marsupial`, err.Error()) {...}
}

호출자가 프로그래밍 방식으로 필요로 하는 추가 정보가 에러에 있는 경우, 이상적으로는 구조화된 형태로 제공되어야 합니다. 예를 들어, os.PathError 타입은 실패한 작업의 경로명을 struct 필드에 배치하여 호출자가 쉽게 접근할 수 있도록 문서화되어 있습니다.

다른 에러 구조는 상황에 따라 적절하게 사용할 수 있습니다. 예를 들어, 프로젝트 구조체가 에러 코드와 세부 정보 문자열을 포함할 수 있습니다. status 패키지는 일반적인 캡슐화 방식을 제공하며, 이 방식을 선택할 수 있지만(의무 사항은 아님) 사용하려면 표준 상태 코드를 사용하세요. Go Tip #89: Canonical 상태 코드를 에러로 사용할 시점을 참조하여 상태 코드 사용이 적절한지 판단하세요.

 

에러에 정보 추가하기

에러를 반환하는 함수는 에러 값을 유용하게 만드는 것을 목표로 해야 합니다. 종종 함수는 호출 체인의 중간에 위치하며, 다른 함수(아마도 다른 패키지에서 호출된 함수)로부터 발생한 에러를 단순히 전달하는 역할을 합니다. 이 경우 에러에 추가 정보를 주석으로 추가할 수 있지만, 프로그래머는 중복되거나 관련 없는 세부 정보를 추가하지 않고 에러에 충분한 정보가 있는지 확인해야 합니다. 확신이 서지 않는다면, 개발 중에 에러 조건을 유발해 보는 것이 좋습니다. 이는 에러 관찰자(사람이든 코드이든)가 최종적으로 어떤 결과를 얻을지 평가하는 좋은 방법입니다.

표준화된 규칙과 좋은 문서화가 도움이 됩니다. 예를 들어, 표준 패키지 os는 에러에 경로 정보를 포함한다고 광고합니다. 이렇게 하면 호출자가 에러를 받았을 때 이미 실패한 함수에 제공한 정보를 중복해서 주석으로 추가할 필요가 없으므로 유용한 스타일입니다.

// 좋은 예:
if err := os.Open("settings.txt"); err != nil {
    return err
}

// 출력 예:
//
// open settings.txt: no such file or directory

에러의 의미에 대해 추가로 전달할 사항이 있는 경우, 적절히 추가할 수 있습니다. 다만, 호출 체인에서 어느 레벨이 이 의미를 가장 잘 이해할 수 있는지 고려하세요.

// 좋은 예:
if err := os.Open("settings.txt"); err != nil {
    // 에러의 의미를 전달합니다. 현재 함수가 실패할 수 있는 여러 파일 작업을 수행할 수
    // 있으므로, 이러한 주석은 호출자에게 무엇이 잘못되었는지 명확히 하는 데 도움이 될 수 있습니다.
    return fmt.Errorf("launch codes unavailable: %v", err)
}

// 출력 예:
//
// launch codes unavailable: open settings.txt: no such file or directory

반면에 중복 정보가 포함된 경우:

// 나쁜 예:
if err := os.Open("settings.txt"); err != nil {
    return fmt.Errorf("could not open settings.txt: %w", err)
}

// 출력 예:
//
// could not open settings.txt: open settings.txt: no such file or directory

전달된 에러에 정보를 추가할 때는, 에러를 래핑하거나 새로운 에러로 표시할 수 있습니다. fmt.Errorf에서 %w를 사용해 에러를 래핑하면 호출자가 원래 에러의 데이터를 액세스할 수 있습니다. 이는 유용할 수 있지만, 경우에 따라 이러한 세부 정보가 호출자에게 혼란을 주거나 중요하지 않을 수 있습니다. 에러 래핑에 대한 자세한 내용은 에러 래핑에 관한 블로그 글을 참조하세요. 에러 래핑은 또한 패키지의 API 표면을 확장하며, 패키지 구현 세부 사항이 변경될 경우 문제를 일으킬 수 있습니다.

문서화하고(테스트로 검증해야 함) 노출하는 기본 에러가 명확하지 않다면 %w 사용을 피하는 것이 좋습니다. 호출자가 errors.Unwrap, errors.Is 등을 호출할 것으로 예상되지 않는 경우 %w를 사용하지 않아도 됩니다.

이 개념은 *status.Status와 같은 구조화된 에러에도 적용됩니다(표준 상태 코드 참조). 예를 들어, 서버가 백엔드에 잘못된 요청을 보내고 InvalidArgument 코드를 수신한 경우, 클라이언트가 잘못한 것이 아니라면 이 코드는 클라이언트에 전파되지 말아야 합니다. 대신, 클라이언트에게 Internal 표준 코드를 반환해야 합니다.

에러에 주석을 추가하면 자동화된 로깅 시스템이 에러의 상태 페이로드를 보존하는 데 도움이 됩니다. 예를 들어, 내부 함수에서 에러를 주석으로 추가하는 것은 적절한 방식입니다.

// 좋은 예:
func (s *Server) internalFunction(ctx context.Context) error {
    // ...
    if err != nil {
        return fmt.Errorf("couldn't find remote file: %w", err)
    }
}

시스템 경계에 있는 코드(RPC, IPC, 저장소와 유사)는 표준 에러 스페이스를 사용하여 에러를 보고해야 합니다. 여기의 코드는 도메인별 에러를 처리하고 표준적으로 표현할 책임이 있습니다. 예를 들어:

// 나쁜 예:
func (*FortuneTeller) SuggestFortune(context.Context, *pb.SuggestionRequest) (*pb.SuggestionResponse, error) {
    // ...
    if err != nil {
        return nil, fmt.Errorf("couldn't find remote file: %w", err)
    }
}
// 좋은 예:
import (
    "google.golang.org/grpc/codes"
    "google.golang.org/grpc/status"
)
func (*FortuneTeller) SuggestFortune(context.Context, *pb.SuggestionRequest) (*pb.SuggestionResponse, error) {
    // ...
    if err != nil {
        // 에러를 호출자가 언래핑하도록 하려면 %w와 함께 fmt.Errorf를 사용할 수 있습니다.
        return nil, status.Errorf(codes.Internal, "couldn't find fortune database", status.ErrInternal)
    }
}

추가 자료:

 

에러에서 %w의 위치

에러 문자열 끝에 %w를 배치하는 것을 선호합니다.

에러는 %w 형식을 통해 래핑하거나 Unwrap() error를 구현하는 구조화된 에러(예: fs.PathError)에 넣어 래핑할 수 있습니다.

래핑된 에러는 에러 체인을 형성하며, 각 새로운 래핑 레이어는 에러 체인의 앞부분에 새 항목을 추가합니다. Unwrap() error 메서드를 사용하여 에러 체인을 순차적으로 탐색할 수 있습니다. 예를 들어:

err1 := fmt.Errorf("err1")
err2 := fmt.Errorf("err2: %w", err1)
err3 := fmt.Errorf("err3: %w", err2)

이렇게 하면 다음과 같은 에러 체인이 형성됩니다.

flowchart LR
  err3 == err3 wraps err2 ==> err2;
  err2 == err2 wraps err1 ==> err1;

%w의 위치와 상관없이 반환된 에러는 항상 에러 체인의 앞부분을 나타내며, %w는 다음 자식입니다. 마찬가지로 Unwrap() error는 항상 가장 최근 에러부터 오래된 에러 순으로 에러 체인을 탐색합니다.

%w의 위치는 에러 체인이 최신에서 오래된 순으로 출력될지, 오래된 순에서 최신 순으로 출력될지 또는 둘 다 아닌지에 영향을 줍니다:

// 좋은 예:
err1 := fmt.Errorf("err1")
err2 := fmt.Errorf("err2: %w", err1)
err3 := fmt.Errorf("err3: %w", err2)
fmt.Println(err3) // err3: err2: err1
// err3은 최신에서 오래된 순으로 구성된 에러 체인이며, 출력도 최신에서 오래된 순입니다.
// 나쁜 예:
err1 := fmt.Errorf("err1")
err2 := fmt.Errorf("%w: err2", err1)
err3 := fmt.Errorf("%w: err3", err2)
fmt.Println(err3) // err1: err2: err3
// err3은 최신에서 오래된 순으로 구성된 에러 체인이지만, 출력은 오래된 순에서 최신 순입니다.
// 나쁜 예:
err1 := fmt.Errorf("err1")
err2 := fmt.Errorf("err2-1 %w err2-2", err1)
err3 := fmt.Errorf("err3-1 %w err3-2", err2)
fmt.Println(err3) // err3-1 err2-1 err1 err2-2 err3-2
// err3은 최신에서 오래된 순으로 구성된 에러 체인이지만, 출력은 최신에서 오래된 순 또는 오래된 순에서 최신 순이 아닙니다.

따라서 에러 텍스트가 에러 체인 구조를 반영하도록 %w를 끝에 [...]: %w 형태로 배치하는 것을 권장합니다.

 

에러 로깅

함수는 때때로 에러를 호출자에게 전달하지 않고 외부 시스템에 알려야 할 필요가 있습니다. 로깅은 이 경우의 명백한 선택이지만, 에러를 어떻게, 무엇을 기록할지 신중하게 생각해야 합니다.

  • 좋은 테스트 실패 메시지와 마찬가지로, 로그 메시지는 문제가 무엇인지 명확하게 표현하고 진단을 돕기 위해 관련 정보를 포함해야 합니다.
  • 중복을 피하세요. 에러를 반환하는 경우, 에러를 스스로 기록하기보다는 호출자에게 이를 처리하도록 맡기는 것이 좋습니다. 호출자는 에러를 로깅하거나 rate.Sometimes를 사용해 로깅 빈도를 제한하거나, 복구를 시도하거나, 프로그램을 중단할 수도 있습니다. 이렇게 호출자에게 제어권을 부여하면 불필요한 로그 스팸을 피하는 데 도움이 됩니다.
  • 이 접근 방식의 단점은 모든 로깅이 호출자의 코드 라인에 의해 작성된다는 점입니다.
  • PII(개인 식별 정보)에 유의하세요. 많은 로그 저장소는 민감한 사용자 정보를 저장하기에 적합하지 않습니다.
  • log.Error는 신중하게 사용하세요. ERROR 수준의 로깅은 버퍼를 플러시하며, 낮은 수준의 로깅보다 비용이 더 많이 듭니다. 이것은 코드에 심각한 성능 영향을 줄 수 있습니다. 에러와 경고 수준 사이에서 결정할 때, 에러 수준의 메시지는 "경고보다 더 심각하다"기보다는 조치를 취할 수 있어야 한다는 모범 사례를 고려하세요.
  • Google 내부에서는 로그 파일에 기록하고 누군가가 이를 인지하기를 기대하기보다는, 더 효과적인 경고를 설정할 수 있는 모니터링 시스템이 있습니다. 이는 표준 라이브러리의 패키지 expvar와 유사하지만 동일하지는 않습니다.

 

사용자 정의 상세 수준

자세한 로깅(log.V)을 활용하세요. 자세한 로깅은 개발 및 추적에 유용할 수 있습니다. 상세 수준에 대한 규칙을 정하는 것도 좋습니다. 예를 들어:

  • V(1)에서는 약간의 추가 정보를 기록
  • V(2)에서는 더 많은 정보를 추적
  • V(3)에서는 큰 내부 상태를 덤프

자세한 로깅의 비용을 최소화하려면 log.V가 꺼져 있을 때에도 실수로 비용이 많이 드는 함수를 호출하지 않도록 주의하세요. log.V는 두 가지 API를 제공합니다. 더 편리한 API는 이런 실수를 초래할 위험이 있으므로, 의심스러울 때는 약간 더 장황한 스타일을 사용하는 것이 좋습니다.

// 좋은 예:
for _, sql := range queries {
  log.V(1).Infof("Handling %v", sql)
  if log.V(2) {
    log.Infof("Handling %v", sql.Explain())
  }
  sql.Run(...)
}
// 나쁜 예:
// sql.Explain이 이 로그가 출력되지 않을 때에도 호출됩니다.
log.V(2).Infof("Handling %v", sql.Explain())

 

프로그램 초기화

프로그램 초기화 에러(예: 잘못된 플래그 및 구성)는 main으로 전달하여, 수정 방법을 설명하는 에러와 함께 log.Exit을 호출해야 합니다. 이러한 경우 log.Fatal은 일반적으로 사용되지 않는 것이 좋습니다. 검사 지점을 가리키는 스택 추적보다는 사람이 생성한 실행 가능한 메시지가 더 유용할 가능성이 높기 때문입니다.

 

프로그램 검사 및 패닉

패닉을 사용하지 않는 결정에 따라, 표준 에러 처리는 에러 반환 값을 중심으로 구조화되어야 합니다. 라이브러리는 프로그램을 중단하기보다는 호출자에게 에러를 반환하는 것을 선호해야 하며, 특히 일시적인 에러의 경우 그렇습니다.

일관성 검사를 수행하고 위반 시 프로그램을 종료해야 하는 경우가 가끔 있습니다. 일반적으로, 이는 불변 조건 실패가 내부 상태가 복구 불가능하게 되었음을 의미할 때에만 수행됩니다. Google 코드베이스에서 이를 수행하는 가장 신뢰할 수 있는 방법은 log.Fatal을 호출하는 것입니다. 이러한 경우 panic을 사용하는 것은 신뢰할 수 없습니다. 지연된 함수가 교착 상태에 빠지거나 내부 또는 외부 상태를 더 손상시킬 가능성이 있기 때문입니다.

마찬가지로, 패닉을 피하려고 복구하는 것은 유혹적일 수 있지만, 그렇게 하면 손상된 상태가 전파될 수 있습니다. 패닉에서 멀어질수록 프로그램의 상태에 대해 알 수 있는 것이 적어지며, 잠금 또는 다른 리소스를 보유할 수 있습니다. 그 결과, 문제 진단을 더 어렵게 만드는 다른 예상치 못한 실패 모드가 발생할 수 있습니다. 코드에서 예상치 못한 패닉을 처리하려고 하지 말고 모니터링 도구를 사용하여 예기치 않은 실패를 감지하고 관련 버그를 우선적으로 해결하세요.

참고: 표준 net/http 서버는 이 조언을 위반하며 요청 핸들러의 패닉을 복구합니다. 숙련된 Go 엔지니어들은 이것이 역사적인 실수였다고 생각합니다. 다른 언어의 애플리케이션 서버에서 서버 로그를 샘플링하면, 처리되지 않은 대형 스택 추적이 자주 남겨지는 것을 확인할 수 있습니다. 서버에서 이 함정에 빠지지 않도록 주의하세요.

 

패닉을 언제 사용할지

표준 라이브러리는 API 오용 시 패닉을 발생시킵니다. 예를 들어, reflect는 값이 잘못 해석된 것처럼 액세스될 경우 여러 상황에서 패닉을 발생시킵니다. 이는 슬라이스의 범위를 벗어난 요소에 접근할 때 발생하는 언어 핵심 버그와 유사합니다. 코드 리뷰와 테스트는 이러한 버그를 발견해야 하며, 프로덕션 코드에서는 이러한 버그가 발생하지 않아야 합니다. 이 패닉은 라이브러리에 의존하지 않는 불변 조건 검사 역할을 하며, 표준 라이브러리는 Google 코드베이스에서 사용하는 레벨형 log 패키지에 접근할 수 없기 때문에 이러한 방식으로 동작합니다.

패닉이 유용할 수 있는 또 다른 경우는 패키지의 내부 구현 세부사항으로서, 항상 호출 체인에서 복구되는 경우입니다. 파서와 같은 깊게 중첩되고 긴밀하게 결합된 내부 함수 그룹은 에러 반환을 추가할 경우 복잡성만 늘어나는 상황에서 이러한 설계가 유용할 수 있습니다. 이 설계의 핵심은 이러한 패닉이 절대 패키지 경계를 넘어 전파되지 않으며 패키지의 API에 포함되지 않는다는 점입니다. 이를 위해 최상위에서 지연된 복구를 사용해 전파되는 패닉을 공용 API 표면에서 반환된 에러로 변환합니다.

패닉은 컴파일러가 도달할 수 없는 코드임을 인식하지 못할 때, 예를 들어 log.Fatal과 같이 반환되지 않는 함수를 사용할 때에도 사용됩니다:

// 좋은 예:
func answer(i int) string {
    switch i {
    case 42:
        return "yup"
    case 54:
        return "base 13, huh"
    default:
        log.Fatalf("Sorry, %d is not the answer.", i)
        panic("unreachable")
    }
}

플래그가 파싱되기 전에 log 함수를 호출하지 마세요. 패키지 초기화 함수(init 또는 "must" 함수)에서 프로그램을 중단해야 하는 경우, fatal 로그 호출 대신 패닉을 사용하는 것이 허용됩니다.

 

문서화

규칙

이 섹션은 결정 문서의 설명 섹션을 보완합니다.

익숙한 스타일로 문서화된 Go 코드는 읽기 쉽고 오용될 가능성이 낮습니다. 실행 가능한 예제는 Godoc 및 코드 검색에 표시되며, 코드 사용법을 설명하는 훌륭한 방법입니다.

 

매개변수와 설정

모든 매개변수를 문서화할 필요는 없습니다. 이는 다음에 적용됩니다:

  • 함수 및 메서드 매개변수
  • 구조체 필드
  • 옵션 API

에러가 발생하기 쉬운 필드와 매개변수, 그리고 이해하기 어려운 항목에 대해서는 주의 깊게 설명하여 의미를 명확히 해야 합니다.

다음 예제에서 강조된 설명은 독자에게 거의 유용한 정보를 제공하지 않습니다:

// 나쁜 예:
// Sprintf는 형식 지정자에 따라 포맷을 수행하고 결과 문자열을 반환합니다.
//
// format은 형식이고, data는 삽입할 데이터입니다.
func Sprintf(format string, data ...any) string

그러나 아래 예제에서는 이전과 유사한 코드 상황을 설명하지만, 독자에게 비일반적이거나 실질적으로 유용한 정보를 제공합니다:

// 좋은 예:
// Sprintf는 형식 지정자에 따라 포맷을 수행하고 결과 문자열을 반환합니다.
//
// 제공된 데이터는 형식 문자열을 삽입하는 데 사용됩니다. 데이터가 예상 형식 동사와 일치하지 않거나
// 데이터 양이 형식 사양을 충족하지 못할 경우, 함수는 위의 "포맷 오류" 섹션에서 설명한 대로
// 출력 문자열에 포맷 오류 경고를 인라인으로 삽입합니다.
func Sprintf(format string, data ...any) string

문서화 시 대상을 고려하고 적절한 깊이로 설명하세요. 메인테이너, 팀의 신규 멤버, 외부 사용자, 그리고 6개월 후의 자신은 처음 문서화를 작성할 때 염두에 두었던 것과 약간 다른 정보를 고마워할 수 있습니다.

추가 자료:

 

컨텍스트

컨텍스트 인수가 제공된 함수를 중단하는 것은 암시됩니다. 함수가 에러를 반환할 수 있는 경우, 관례적으로 ctx.Err()를 반환합니다.

이 사실을 재진술할 필요는 없습니다:

// 나쁜 예:
// Run은 작업자의 실행 루프를 실행합니다.
//
// 메서드는 컨텍스트가 취소될 때까지 작업을 처리하며, 이에 따라 에러를 반환합니다.
func (Worker) Run(ctx context.Context) error

이는 암시되므로, 다음과 같은 코드가 더 좋습니다:

// 좋은 예:
// Run은 작업자의 실행 루프를 실행합니다.
func (Worker) Run(ctx context.Context) error

컨텍스트의 동작이 다르거나 비직관적일 때는 다음과 같은 경우 이를 명시적으로 문서화해야 합니다.

  • 컨텍스트가 취소되었을 때 ctx.Err()이 아닌 다른 에러를 반환하는 경우:
  • // 좋은 예: // Run은 작업자의 실행 루프를 실행합니다. // // 컨텍스트가 취소되면, Run은 nil 에러를 반환합니다. func (Worker) Run(ctx context.Context) error
  • 함수가 중단되거나 수명을 영향을 미칠 수 있는 다른 메커니즘이 있는 경우:
  • // 좋은 예: // Run은 작업자의 실행 루프를 실행합니다. // // Run은 컨텍스트가 취소되거나 Stop이 호출될 때까지 작업을 처리합니다. // 컨텍스트 취소는 내부적으로 비동기로 처리되므로, 모든 작업이 중단되기 전에 실행이 반환될 수 있습니다. // Stop 메서드는 동기식으로 실행 루프의 모든 작업이 완료될 때까지 대기합니다. // 우아한 종료를 위해 Stop을 사용하세요. func (Worker) Run(ctx context.Context) error func (Worker) Stop()
  • 함수가 컨텍스트 수명, 계층, 또는 첨부된 값에 대한 특별한 기대를 가지고 있는 경우:주의: 컨텍스트에 대한 특별한 요구 사항(예: 제한 시간이 없는 컨텍스트)을 호출자에게 요구하는 API 설계는 피하세요. 위는 피할 수 없을 때 이를 문서화하는 방법의 예일 뿐이며, 해당 패턴을 권장하는 것은 아닙니다.
  • // 좋은 예: // NewReceiver는 지정된 큐로 전송된 메시지 수신을 시작합니다. // 컨텍스트는 제한 시간을 갖지 않아야 합니다. func NewReceiver(ctx context.Context) *Receiver // Principal은 호출한 주체의 사람이 읽을 수 있는 이름을 반환합니다. // 컨텍스트에는 security.NewContext에서 첨부된 값이 있어야 합니다. func Principal(ctx context.Context) (name string, ok bool)

 

동시성

Go 사용자들은 개념적으로 읽기 전용 작업이 동시 사용에 안전하며 추가적인 동기화가 필요하지 않다고 가정합니다.

이러한 동시성 관련 설명은 Godoc에서 안전하게 제거할 수 있습니다:

// Len은 버퍼의 읽지 않은 부분의 바이트 수를 반환합니다;
// b.Len() == len(b.Bytes())입니다.
//
// 여러 고루틴에서 동시에 호출해도 안전합니다.
func (*Buffer) Len() int

반면, 변경 작업은 동시 사용에 안전하다고 가정되지 않으며, 사용자에게 동기화를 고려하도록 요구합니다.

마찬가지로, 아래의 동시성 관련 설명도 안전하게 제거할 수 있습니다:

// Grow는 버퍼의 용량을 증가시킵니다.
//
// 여러 고루틴에서 동시에 호출하면 안전하지 않습니다.
func (*Buffer) Grow(n int)

다음과 같은 경우에는 문서화를 강력히 권장합니다:

  • 작업이 읽기 전용인지 변경 작업인지 명확하지 않은 경우:왜? LRU 캐시에서 키를 조회할 때 캐시 적중이 내부적으로 변경될 수 있습니다. 모든 독자가 이를 명확히 이해하지 못할 수 있습니다.
  • // 좋은 예: package lrucache // Lookup은 캐시에서 키와 연관된 데이터를 반환합니다. // // 이 작업은 동시 사용에 안전하지 않습니다. func (*Cache) Lookup(key string) (data []byte, ok bool)
  • API에서 동기화를 제공하는 경우:왜? Stubby는 동기화를 제공합니다.
  • 참고: API가 타입이며 전체 동기화를 제공하는 경우, 일반적으로 타입 정의에서만 동시성 의미를 문서화합니다.
  • // 좋은 예: package fortune_go_proto // NewFortuneTellerClient는 FortuneTeller 서비스에 대한 *rpc.Client를 반환합니다. // 여러 고루틴에서 동시에 안전하게 사용할 수 있습니다. func NewFortuneTellerClient(cc *rpc.ClientConn) *FortuneTellerClient
  • API가 사용자 구현 타입 또는 인터페이스를 소비하며, 인터페이스의 소비자가 특정 동시성 요구사항을 가진 경우:왜? 여러 고루틴에서 안전하게 사용할 수 있는 API인지는 계약의 일부입니다.
  • // 좋은 예: package health // Watcher는 엔티티(보통 백엔드 서비스)의 상태를 보고합니다. // // Watcher 메서드는 여러 고루틴에서 동시에 안전하게 사용할 수 있습니다. type Watcher interface { // Watch는 Watcher의 상태가 변경될 때 전달된 채널에 true를 전송합니다. Watch(changed chan<- bool) (unwatch func()) // Health는 감시되는 엔티티가 건강한 경우 nil을 반환하고, 건강하지 않은 경우 원인을 설명하는 // non-nil 에러를 반환합니다. Health() error }

 

정리(Cleanup)

API에 명시적인 정리 요구사항이 있으면 문서화하세요. 그렇지 않으면 호출자가 API를 올바르게 사용하지 않아 리소스 누수나 기타 가능한 버그가 발생할 수 있습니다.

호출자가 정리해야 할 사항을 명시하세요:

// 좋은 예:
// NewTicker는 채널을 포함하는 새로운 Ticker를 반환하며, 이 채널은 매 틱마다 현재 시간을 전송합니다.
//
// 완료되면 Ticker와 연결된 리소스를 해제하려면 Stop을 호출하세요.
func NewTicker(d Duration) *Ticker

func (*Ticker) Stop()

리소스 정리 방법이 명확하지 않을 경우, 이를 설명하세요:

// 좋은 예:
// Get은 지정된 URL로 GET 요청을 보냅니다.
//
// err가 nil일 때, resp는 항상 non-nil resp.Body를 포함합니다.
// 읽기가 끝나면 호출자가 resp.Body를 닫아야 합니다.
//
//    resp, err := http.Get("http://example.com/")
//    if err != nil {
//        // 에러 처리
//    }
//    defer resp.Body.Close()
//    body, err := io.ReadAll(resp.Body)
func (c *Client) Get(url string) (resp *Response, err error)

추가 자료:

 

에러

함수가 호출자에게 반환하는 중요한 에러 센티널 값이나 에러 타입을 문서화하여 호출자가 코드에서 처리할 수 있는 조건 유형을 예측할 수 있도록 하세요.

// 좋은 예:
package os

// Read는 파일에서 최대 len(b) 바이트를 읽어 b에 저장합니다. 읽은 바이트 수와 발생한
// 에러를 반환합니다.
//
// 파일 끝에 도달하면, Read는 0, io.EOF를 반환합니다.
func (*File) Read(b []byte) (n int, err error)

함수가 특정 에러 타입을 반환할 때, 에러가 포인터 리시버인지 아닌지 올바르게 명시하세요:

// 좋은 예:
package os

type PathError struct {
    Op   string
    Path string
    Err  error
}

// Chdir는 현재 작업 디렉토리를 지정된 디렉토리로 변경합니다.
//
// 에러가 발생하면, *PathError 타입의 에러일 것입니다.
func Chdir(dir string) error {

반환된 값이 포인터 리시버인지 문서화하면 호출자가 errors.Is, errors.As, package cmp를 사용하여 에러를 올바르게 비교할 수 있습니다. 비포인터 값은 포인터 값과 동등하지 않기 때문입니다.

참고: Chdir 예제에서는 반환 타입이 *PathError 대신 error로 작성되었습니다. 이는 nil 인터페이스 값의 작동 방식에 기인합니다.

패키지의 대부분의 에러에 적용되는 동작인 경우, 전체 에러 규칙을 패키지의 문서에 문서화하세요:

// 좋은 예:
// os 패키지는 운영 체제 기능에 대한 플랫폼 독립적인 인터페이스를 제공합니다.
//
// 에러 내에 추가 정보가 포함되어 있는 경우가 많습니다. 예를 들어, 파일 이름을 사용하는 호출이 실패할 때,
// Open 또는 Stat과 같은 경우, 에러는 실패한 파일 이름을 포함하며 *PathError 타입일 것입니다.
// 추가 정보를 위해 분해할 수 있습니다.
package os

이러한 접근 방식을 신중하게 적용하면 에러에 추가 정보를 간단히 제공할 수 있으며, 호출자가 중복 주석을 추가하지 않도록 도울 수 있습니다.

추가 자료:

 

문서 미리보기

Go는 문서 서버를 제공합니다. 코드 리뷰 프로세스 전후로 코드가 생성하는 문서를 미리 보는 것이 좋습니다. 이를 통해 godoc 포맷이 올바르게 렌더링되는지 확인할 수 있습니다.

Godoc 포맷팅

Godoc문서 포맷팅을 위한 몇 가지 특정 문법을 제공합니다.

  • 단락을 분리하려면 빈 줄이 필요합니다:
  • // 좋은 예: // LoadConfig는 지정된 파일에서 설정을 읽어옵니다. // // 설정 파일 형식에 대한 자세한 내용은 some/shortlink를 참조하세요.
  • 테스트 파일에는 실행 가능한 예제를 포함할 수 있으며, 이는 godoc에서 해당 문서에 첨부되어 나타납니다:
  • // 좋은 예: func ExampleConfig_WriteTo() { cfg := &Config{ Name: "example", } if err := cfg.WriteTo(os.Stdout); err != nil { log.Exitf("Failed to write config: %s", err) } // Output: // { // "name": "example" // } }
  • 줄을 두 칸 들여쓰면 원본 그대로 형식이 유지됩니다:그러나, 주석 대신 실행 가능한 예제로 코드를 포함하는 것이 더 적절할 수 있습니다.
      // 좋은 예:
      // LoadConfig는 지정된 파일에서 설정을 읽어옵니다.
      //
      // LoadConfig는 다음 키를 특별한 방식으로 처리합니다:
      //   "import"는 이 구성이 지정된 파일에서 상속되도록 만듭니다.
      //   "env"가 존재하면 시스템 환경이 채워집니다.
  • 원본 형식 유지 기능은 목록이나 테이블과 같이 godoc에 기본적으로 없는 형식에도 사용할 수 있습니다:
  • // 좋은 예: // Update는 함수를 원자 트랜잭션으로 실행합니다. // // 주로 익명 TransactionFunc과 함께 사용됩니다: // // if err := db.Update(func(state *State) { state.Foo = bar }); err != nil { // // ... // }
  • 대문자로 시작하고 쉼표와 괄호 외에는 구두점을 포함하지 않으며, 다른 단락으로 이어지는 단일 행은 제목으로 포맷됩니다:
  • // 좋은 예: // 다음 줄은 제목으로 포맷됩니다. // // 제목 사용 // // 제목에는 자동 생성된 앵커 태그가 있어 링크를 쉽게 걸 수 있습니다.

 

시그널 부스트

코드 라인이 평범해 보이지만 실제로는 그렇지 않은 경우가 있습니다. err == nil 체크가 그 예 중 하나입니다 (err != nil이 훨씬 더 일반적이기 때문에). 다음 두 조건문은 구별하기 어렵습니다:

// 좋은 예:
if err := doSomething(); err != nil {
    // ...
}
// 나쁜 예:
if err := doSomething(); err == nil {
    // ...
}

대신 조건문의 신호를 "부스트"하기 위해 주석을 추가할 수 있습니다:

// 좋은 예:
if err := doSomething(); err == nil { // 에러가 없는 경우
    // ...
}

주석이 조건문의 차이를 강조합니다.

 

변수 선언

초기화

일관성을 위해 새로운 변수를 비제로 값으로 초기화할 때 var보다 :=를 선호하세요.

// 좋은 예:
i := 42
// 나쁜 예:
var i = 42

 

제로 값으로 변수 선언

다음 선언들은 제로 값을 사용합니다:

// 좋은 예:
var (
    coords Point
    magic  [4]byte
    primes []int
)

나중에 사용할 빈 값을 나타내는 경우 제로 값을 사용하여 값을 선언하세요. 명시적 초기화를 포함한 복합 리터럴 사용은 번거로울 수 있습니다:

// 나쁜 예:
var (
    coords = Point{X: 0, Y: 0}
    magic  = [4]byte{0, 0, 0, 0}
    primes = []int(nil)
)

제로 값 선언의 일반적인 사용 사례는 변수의 출력을 언마샬링할 때입니다:

// 좋은 예:
var coords Point
if err := json.Unmarshal(data, &coords); err != nil {

포인터 타입 변수에 제로 값을 사용하는 것이 필요할 경우 다음과 같은 형식을 사용하는 것도 괜찮습니다:

// 좋은 예:
msg := new(pb.Bar) // 또는 "&pb.Bar{}"
if err := proto.Unmarshal(data, msg); err != nil {

구조체에 복사되면 안 되는 필드가 필요한 경우, 제로 값 초기화를 활용하기 위해 값 타입으로 만들 수 있습니다. 이는 포함하는 타입을 포인터로 전달해야 하며 값으로 전달할 수 없음을 의미합니다. 타입의 메서드는 포인터 리시버를 사용해야 합니다.

// 좋은 예:
type Counter struct {
    // 이 필드는 "*sync.Mutex"일 필요는 없습니다.
    // 그러나 사용자 간에 Counter 객체가 아니라 *Counter 객체를 전달해야 합니다.
    mu   sync.Mutex
    data map[string]int64
}

// 복사를 방지하기 위해 포인터 리시버를 사용해야 합니다.
func (c *Counter) IncrementBy(name string, n int64)

구조체와 배열과 같은 복합 자료형의 로컬 변수에 대해 값 타입을 사용하는 것은 허용됩니다. 그러나 복합 자료형이 함수에 의해 반환되거나 모든 접근이 주소를 가져야 하는 경우, 처음부터 포인터 타입으로 변수를 선언하는 것이 좋습니다. 마찬가지로, 프로토버프는 포인터 타입으로 선언하는 것이 좋습니다.

// 좋은 예:
func NewCounter(name string) *Counter {
    c := new(Counter) // "&Counter{}"도 괜찮습니다.
    registerCounter(name, c)
    return c
}

var msg = new(pb.Bar) // 또는 "&pb.Bar{}".

이는 *pb.Somethingproto.Message를 만족시키지만, pb.Something은 그렇지 않기 때문입니다.

// 나쁜 예:
func NewCounter(name string) *Counter {
    var c Counter
    registerCounter(name, &c)
    return &c
}

var msg = pb.Bar{}

중요: 맵 타입은 수정하기 전에 명시적으로 초기화해야 합니다. 그러나 제로 값 맵을 읽는 것은 문제가 없습니다.

맵 및 슬라이스 타입의 경우, 코드가 특히 성능에 민감하고 크기를 미리 알고 있다면 크기 힌트 섹션을 참조하세요.

 

복합 리터럴

다음은 복합 리터럴 선언 예시입니다:

// 좋은 예:
var (
    coords   = Point{X: x, Y: y}
    magic    = [4]byte{'I', 'W', 'A', 'D'}
    primes   = []int{2, 3, 5, 7, 11}
    captains = map[string]string{"Kirk": "James Tiberius", "Picard": "Jean-Luc"}
)

초기 요소나 멤버를 알고 있을 때는 복합 리터럴을 사용하여 값을 선언하세요.

반대로, 멤버가 없는 빈 값을 선언할 때는 제로 값 초기화를 사용하는 것이 더 간결합니다.

제로 값의 포인터가 필요한 경우, 빈 복합 리터럴 또는 new 키워드를 사용할 수 있습니다. 두 방법 모두 괜찮지만, new 키워드는 비제로 값이 필요할 경우 복합 리터럴이 작동하지 않는다는 점을 독자에게 상기시킬 수 있습니다:

// 좋은 예:
var (
  buf = new(bytes.Buffer) // 비어 있지 않은 Buffer는 생성자로 초기화합니다.
  msg = new(pb.Message)    // 비어 있지 않은 프로토 메시지는 빌더나 필드를 하나씩 설정하여 초기화합니다.
)

 

크기 힌트

용량을 미리 할당하기 위해 크기 힌트를 활용한 선언 예시입니다:

// 좋은 예:
var (
    // 대상 파일 시스템을 위한 권장 버퍼 크기: st_blksize.
    buf = make([]byte, 131072)
    // 각 실행에서 보통 8~10개 요소를 처리함(16개는 안전한 가정).
    q = make([]Node, 0, 16)
    // 각 샤드는 shardSize(보통 32000개 이상) 요소를 처리함.
    seen = make(map[string]bool, shardSize)
)

크기 힌트와 미리 할당은 코드와 통합을 경험적으로 분석할 때 성능과 자원 효율성을 높이는 데 중요합니다.

대부분의 코드는 크기 힌트나 미리 할당이 필요하지 않으며, 런타임이 슬라이스나 맵을 필요에 따라 확장하도록 허용할 수 있습니다. 최종 크기가 알려진 경우(예: 맵과 슬라이스 간 변환 시) 미리 할당이 가능하지만, 읽기 난이도에 큰 영향을 미치지 않으며 소규모 경우에는 그다지 유용하지 않을 수 있습니다.

경고: 필요한 것보다 더 많은 메모리를 미리 할당하면 메모리를 낭비하거나 성능을 저하시킬 수 있습니다. 의심스러울 때는 GoTip #3: Go 코드 벤치마킹을 참고하고 기본적으로 제로 초기화 또는 복합 리터럴 선언을 사용하세요.

 

채널 방향

가능한 경우 채널 방향을 명시하세요.

// 좋은 예:
// sum은 모든 값의 합계를 계산합니다. 채널이 닫힐 때까지 채널에서 읽습니다.
func sum(values <-chan int) int {
    // ...
}

명시하지 않은 경우 발생할 수 있는 프로그래밍 오류를 방지할 수 있습니다:

// 나쁜 예:
func sum(values chan int) (out int) {
    for v := range values {
        out += v
    }
    // 이 코드가 실행되려면 values가 이미 닫혀 있어야 하며, 두 번째로 닫으면 패닉이 발생합니다.
    close(values)
}

방향이 명시되면 컴파일러가 이러한 단순한 오류를 감지할 수 있으며, 소유권을 명확하게 전달하는 데 도움이 됩니다.

자세한 내용은 Bryan Mills의 발표 "Rethinking Classical Concurrency Patterns"을 참조하세요:
슬라이드 비디오.

 

함수 인수 목록

함수 시그니처가 너무 길어지지 않도록 하세요. 함수에 매개변수가 추가될수록 개별 매개변수의 역할이 모호해지고, 같은 타입의 인접한 매개변수를 혼동할 가능성이 커집니다. 많은 인수를 가진 함수는 호출 위치에서 읽기 어렵고 기억하기 어렵습니다.

API 설계 시 시그니처가 복잡해진다면 높은 구성 가능성을 가진 함수를 여러 개의 더 단순한 함수로 나누는 것을 고려하세요. 필요하다면 비공개 구현을 공유할 수 있습니다.

많은 입력이 필요한 함수의 경우 일부 인수에 대해 옵션 구조체를 도입하거나 가변 옵션 방식을 사용하는 것을 고려하세요. 어떤 전략을 선택할지의 주된 고려사항은 예상 사용 사례 전반에서 함수 호출이 어떻게 보이는지입니다.

아래의 권장 사항은 주로 더 높은 기준을 적용받는 공개 API에 적용됩니다. 이러한 기법이 필요하지 않을 수도 있으니, 판단력을 발휘해 명확성최소 메커니즘의 원칙을 균형 있게 맞추세요.

추가 자료:
Go Tip #24: 사례별 구체적 구성 사용

 

옵션 구조체

옵션 구조체는 함수나 메서드의 일부 또는 모든 인수를 수집하는 구조체 타입이며, 함수나 메서드의 마지막 인수로 전달됩니다. (구조체가 공개된 함수에서 사용되는 경우에만 구조체를 공개합니다.)

옵션 구조체 사용의 장점은 다음과 같습니다:

  • 구조체 리터럴은 각 인수에 대한 필드와 값을 포함하므로 자체 문서화되며 교환하기 어렵습니다.
  • 관련 없는 필드나 기본 필드는 생략할 수 있습니다.
  • 호출자가 옵션 구조체를 공유하고 이를 조작하는 헬퍼를 작성할 수 있습니다.
  • 구조체는 함수 인수보다 필드별 문서화가 더 깔끔합니다.
  • 옵션 구조체는 호출 위치에 영향을 주지 않고 시간이 지남에 따라 확장될 수 있습니다.

다음은 개선이 필요한 함수의 예시입니다:

// 나쁜 예:
func EnableReplication(ctx context.Context, config *replicator.Config, primaryRegions, readonlyRegions []string, replicateExisting, overwritePolicies bool, replicationInterval time.Duration, copyWorkers int, healthWatcher health.Watcher) {
    // ...
}

위의 함수는 옵션 구조체를 사용하여 다음과 같이 다시 작성할 수 있습니다:

// 좋은 예:
type ReplicationOptions struct {
    Config              *replicator.Config
    PrimaryRegions      []string
    ReadonlyRegions     []string
    ReplicateExisting   bool
    OverwritePolicies   bool
    ReplicationInterval time.Duration
    CopyWorkers         int
    HealthWatcher       health.Watcher
}

func EnableReplication(ctx context.Context, opts ReplicationOptions) {
    // ...
}

다른 패키지에서 함수는 다음과 같이 호출할 수 있습니다:

// 좋은 예:
func foo(ctx context.Context) {
    // 복잡한 호출:
    storage.EnableReplication(ctx, storage.ReplicationOptions{
        Config:              config,
        PrimaryRegions:      []string{"us-east1", "us-central2", "us-west3"},
        ReadonlyRegions:     []string{"us-east5", "us-central6"},
        OverwritePolicies:   true,
        ReplicationInterval: 1 * time.Hour,
        CopyWorkers:         100,
        HealthWatcher:       watcher,
    })

    // 단순한 호출:
    storage.EnableReplication(ctx, storage.ReplicationOptions{
        Config:         config,
        PrimaryRegions: []string{"us-east1", "us-central2", "us-west3"},
    })
}

참고: Contexts는 옵션 구조체에 포함되지 않습니다.

다음과 같은 경우 옵션 구조체가 자주 선호됩니다:

  • 모든 호출자가 하나 이상의 옵션을 지정해야 하는 경우
  • 많은 호출자가 여러 옵션을 제공해야 하는 경우
  • 옵션이 사용자가 호출할 여러 함수 간에 공유되는 경우

 

가변 옵션 (Variadic Options)

가변 옵션을 사용하면 함수에서 가변 인수(...) 매개변수로 클로저를 전달할 수 있는 익스포트된 함수를 작성할 수 있습니다. 이러한 함수는 옵션 값들을 매개변수로 받고, 반환된 클로저는 가변 레퍼런스(보통 구조체 타입의 포인터)를 받아 입력을 바탕으로 업데이트됩니다.

가변 옵션의 장점:

  • 설정이 필요하지 않은 경우, 호출 위치에서 옵션이 공간을 차지하지 않습니다.
  • 옵션은 값이기 때문에 호출자가 옵션을 공유하고, 헬퍼 함수를 작성하고, 옵션을 축적할 수 있습니다.
  • 옵션 함수는 여러 매개변수를 받을 수 있습니다(예: cartesian.Translate(dx, dy int) TransformOption).
  • 옵션 함수는 고도크(godoc)에서 옵션을 그룹화할 수 있는 명명된 타입을 반환할 수 있습니다.
  • 패키지에서 서드 파티 패키지가 옵션을 정의하거나 정의하지 않도록 허용할 수 있습니다.

참고: 가변 옵션을 사용하면 코드 양이 상당히 늘어나므로, 장점이 오버헤드를 초과하는 경우에만 사용하세요.

다음은 개선할 수 있는 함수의 예시입니다:

// 나쁜 예:
func EnableReplication(ctx context.Context, config *placer.Config, primaryCells, readonlyCells []string, replicateExisting, overwritePolicies bool, replicationInterval time.Duration, copyWorkers int, healthWatcher health.Watcher) {
  ...
}

위의 예시는 다음과 같이 가변 옵션으로 다시 작성할 수 있습니다:

// 좋은 예:
type replicationOptions struct {
    readonlyCells       []string
    replicateExisting   bool
    overwritePolicies   bool
    replicationInterval time.Duration
    copyWorkers         int
    healthWatcher       health.Watcher
}

// ReplicationOption은 EnableReplication을 설정합니다.
type ReplicationOption func(*replicationOptions)

// ReadonlyCells는 추가로 읽기 전용 복제를 포함할 셀들을 추가합니다.
// 이 옵션을 여러 번 전달하면 읽기 전용 셀이 추가됩니다.
//
// 기본값: 없음
func ReadonlyCells(cells ...string) ReplicationOption {
    return func(opts *replicationOptions) {
        opts.readonlyCells = append(opts.readonlyCells, cells...)
    }
}

// ReplicateExisting은 기본 셀에 이미 존재하는 파일을 복제할지 여부를 제어합니다.
// 그렇지 않으면 새로 추가된 파일만 복제 대상으로 지정됩니다.
//
// 이 옵션을 다시 전달하면 이전 값이 덮어씁니다.
//
// 기본값: false
func ReplicateExisting(enabled bool) ReplicationOption {
    return func(opts *replicationOptions) {
        opts.replicateExisting = enabled
    }
}

// ... 다른 옵션 ...

// DefaultReplicationOptions는 EnableReplication에 전달된 옵션을 적용하기 전의 기본값을 제어합니다.
var DefaultReplicationOptions = []ReplicationOption{
    OverwritePolicies(true),
    ReplicationInterval(12 * time.Hour),
    CopyWorkers(10),
}

func EnableReplication(ctx context.Context, config *placer.Config, primaryCells []string, opts ...ReplicationOption) {
    var options replicationOptions
    for _, opt := range DefaultReplicationOptions {
        opt(&options)
    }
    for _, opt := range opts {
        opt(&options)
    }
}

다른 패키지에서 함수는 다음과 같이 호출할 수 있습니다:

// 좋은 예:
func foo(ctx context.Context) {
    // 복잡한 호출:
    storage.EnableReplication(ctx, config, []string{"po", "is", "ea"},
        storage.ReadonlyCells("ix", "gg"),
        storage.OverwritePolicies(true),
        storage.ReplicationInterval(1*time.Hour),
        storage.CopyWorkers(100),
        storage.HealthWatcher(watcher),
    )

    // 단순한 호출:
    storage.EnableReplication(ctx, config, []string{"po", "is", "ea"})
}

이 옵션을 선호할 시나리오:

  • 대부분의 호출자가 옵션을 지정할 필요가 없을 때
  • 대부분의 옵션이 자주 사용되지 않을 때
  • 옵션이 다수일 때
  • 옵션에 인수가 필요할 때
  • 옵션이 실패하거나 잘못 설정될 가능성이 있을 때
  • 옵션에 많은 문서화가 필요할 때
  • 사용자 또는 다른 패키지가 사용자 정의 옵션을 제공할 수 있을 때

이 스타일의 옵션은 존재로 값을 신호하기보다는 매개변수를 받아야 합니다. 예를 들어, 바이너리 설정은 불리언을 받아야 합니다(rpc.FailFast(enable bool)rpc.EnableFailFast()보다 선호됩니다). 옵션은 일반적으로 순서대로 처리되어야 하며, 마지막 인수가 우선권을 가집니다.

추가 자료:

 

복잡한 명령줄 인터페이스

일부 프로그램은 하위 명령을 포함하는 풍부한 명령줄 인터페이스를 제공합니다. 예를 들어, kubectl create, kubectl run과 같은 하위 명령들이 kubectl 프로그램에서 제공됩니다. 이를 위한 일반적인 라이브러리로는 다음이 있습니다.

별다른 선호가 없다면 subcommands를 추천합니다.

 

테스트

Test 함수에 테스트를 맡기세요

Go는 "테스트 헬퍼"와 "어설션 헬퍼"를 구분합니다.

  • 테스트 헬퍼는 설정(setup)이나 정리(cleanup) 작업을 수행하는 함수입니다. 테스트 헬퍼에서 발생하는 모든 실패는 테스트 중인 코드가 아닌 환경의 문제로 인해 발생한 실패로 간주됩니다. 예를 들어, 테스트용 데이터베이스가 사용 가능한 포트가 없어서 시작되지 않는 경우가 이에 해당합니다. 이러한 함수에서는 t.Helper를 호출하여 테스트 헬퍼로 표시하는 것이 적절할 수 있습니다. 자세한 내용은 테스트 헬퍼의 오류 처리를 참고하세요.
  • 어설션 헬퍼는 시스템의 정확성을 확인하고 기대에 부합하지 않으면 테스트를 실패하게 하는 함수입니다. 어설션 헬퍼는 Go에서 관용적인 방식이 아닙니다.

테스트의 목적은 테스트 중인 코드의 통과/실패 상태를 보고하는 것입니다. 테스트 실패를 표시하기에 이상적인 위치는 Test 함수 내부입니다. 이를 통해 실패 메시지와 테스트 로직을 명확히 할 수 있습니다.

테스트 코드가 커지면 일부 기능을 별도의 함수로 분리해야 할 필요가 생길 수 있습니다. 표준 소프트웨어 엔지니어링 규칙은 테스트 코드도 코드이기 때문에 여전히 적용됩니다. 이 기능이 테스트 프레임워크와 상호작용하지 않는 경우 일반적인 규칙을 모두 적용할 수 있습니다. 그러나 공통 코드가 프레임워크와 상호작용할 경우, 유용하지 않은 실패 메시지와 유지보수하기 어려운 테스트를 초래할 수 있는 일반적인 함정을 피하기 위해 주의해야 합니다.

여러 개의 개별 테스트 케이스가 동일한 검증 로직을 필요로 하는 경우, 어설션 헬퍼나 복잡한 검증 함수를 사용하는 대신 테스트를 다음과 같은 방식으로 구성하세요.

  • Test 함수 내에서 로직(검증과 실패 모두)을 인라인으로 작성하세요. 단순한 경우에는 반복이 있더라도 이 방법이 가장 효과적입니다.
  • 입력이 유사하다면, 루프 내에서 로직을 인라인으로 유지하면서 테이블 기반 테스트로 통합하는 방법을 고려하세요. 이를 통해 반복을 줄이면서 검증과 실패를 Test 내에 유지할 수 있습니다.
  • 여러 호출자가 동일한 검증 함수를 필요로 하지만 테이블 테스트가 적합하지 않은 경우(일반적으로 입력이 충분히 단순하지 않거나 검증이 일련의 작업의 일부로 필요한 경우), 검증 함수가 testing.T 매개변수를 사용해 테스트를 실패하게 하는 대신 값을 반환하도록(보통 error) 구성하세요. Test 내에서 실패 여부를 결정하고 유용한 테스트 실패 메시지를 제공합니다. 또한 공통적인 설정 코드를 분리하기 위해 테스트 헬퍼를 생성할 수도 있습니다.

마지막 항목에서 설명한 설계는 독립성을 유지합니다. 예를 들어, 패키지 cmp는 테스트를 실패하게 하는 것이 아니라 값을 비교하고 차이를 나타내도록 설계되었습니다. 따라서 비교가 이루어진 상황을 알 필요가 없으며, 호출자가 이를 제공할 수 있습니다. 공통 테스트 코드가 데이터 유형에 대한 cmp.Transformer를 제공한다면, 이는 가장 간단한 설계가 될 수 있습니다. 다른 검증에 대해서는 error 값을 반환하는 것을 고려하세요.

// 좋은 예:
// polygonCmp는 s2 기하 객체를 일부 부동소수점 오차 내에서 동일하게 간주하는 cmp.Option을 반환합니다.
func polygonCmp() cmp.Option {
    return cmp.Options{
        cmp.Transformer("polygon", func(p *s2.Polygon) []*s2.Loop { return p.Loops() }),
        cmp.Transformer("loop", func(l *s2.Loop) []s2.Point { return l.Vertices() }),
        cmpopts.EquateApprox(0.00000001, 0),
        cmpopts.EquateEmpty(),
    }
}

func TestFenceposts(t *testing.T) {
    // 이 예는 어떤 Place 객체 주위에 울타리를 그리는 Fenceposts라는 가상의 함수에 대한 테스트입니다.
    // 결과는 s2 기하 객체라는 점을 제외하면 세부사항은 중요하지 않습니다.
    got := Fencepost(tomsDiner, 1*meter)
    if diff := cmp.Diff(want, got, polygonCmp()); diff != "" {
        t.Errorf("Fencepost(tomsDiner, 1m) 예상과 다른 차이 발생 (-원함+얻음):\n%v", diff)
    }
}

func FuzzFencepost(f *testing.F) {
    // 동일한 테스트에 대한 퍼즈 테스트 (https://go.dev/doc/fuzz)

    f.Add(tomsDiner, 1*meter)
    f.Add(school, 3*meter)

    f.Fuzz(func(t *testing.T, geo Place, padding Length) {
        got := Fencepost(geo, padding)
        // 간단한 참조 구현: 프로덕션에서 사용되지는 않지만,
        // 이해하기 쉬워 랜덤 테스트에서 유용하게 활용할 수 있습니다.
        reference := slowFencepost(geo, padding)

        // 퍼즈 테스트에서는 입력과 출력이 클 수 있으므로 차이를 출력하지 않고 cmp.Equal만 사용합니다.
        if !cmp.Equal(got, reference, polygonCmp()) {
            t.Errorf("Fencepost 잘못된 위치 반환")
        }
    })
}

polygonCmp 함수는 호출 방법에 구애받지 않으며, 구체적인 입력 유형을 받지 않고 두 객체가 일치하지 않을 경우 수행할 작업도 강제하지 않습니다. 따라서 더 많은 호출자가 이를 활용할 수 있습니다.

참고: 테스트 헬퍼와 일반 라이브러리 코드 간에는 유사점이 있습니다. 라이브러리 내의 코드는 보통 패닉을 일으키지 않아야 하며, 테스트에서 호출된 코드도 진행할 필요가 없을 때를 제외하고는 테스트를 중단해서는 안 됩니다.

 

확장 가능한 검증 API 설계

스타일 가이드의 대부분의 테스트 조언은 자체 코드에 대한 테스트에 관한 것입니다. 이 섹션은 다른 사람들이 자신의 코드가 라이브러리의 요구 사항을 충족하는지 확인하기 위한 테스트 기능을 제공하는 방법에 관한 것입니다.

 

수락 테스트

이러한 테스트는 수락 테스트로 불립니다. 이러한 테스트의 전제는 테스트를 사용하는 사람이 테스트 내부에서 발생하는 모든 세부 사항을 알 필요 없이 입력을 테스트 기능에 제공해 작업을 수행하도록 한다는 것입니다. 이를 제어 역전의 한 형태로 생각할 수 있습니다.

일반적인 Go 테스트에서 테스트 함수는 프로그램 흐름을 제어하며, [어설트 없음] 및 [테스트 함수] 가이드라인은 이를 유지하도록 권장합니다. 이 섹션에서는 Go 스타일에 맞는 방식으로 이러한 테스트를 지원하는 방법을 설명합니다.

방법을 설명하기 전에, 다음의 io/fs에서 가져온 예제를 살펴보세요:

type FS interface {
    Open(name string) (File, error)
}

fs.FS의 잘 알려진 구현이 존재하지만, Go 개발자는 이를 작성해야 할 것으로 기대될 수 있습니다. 사용자 구현 fs.FS가 올바른지 검증하는 데 도움이 되도록 testing/fstestfstest.TestFS라는 일반 라이브러리가 제공됩니다. 이 API는 io/fs 계약의 가장 기본적인 부분을 충족하는지 확인하기 위해 구현을 블랙박스로 취급합니다.

 

테스트

수락 테스트 작성하기

이제 수락 테스트가 무엇이며 왜 사용하는지 이해했으니, 체스 게임을 시뮬레이션하는 package chess에 대한 수락 테스트를 작성해 보겠습니다. chess의 사용자는 chess.Player 인터페이스를 구현해야 합니다. 우리는 이러한 구현이 규칙에 맞는 이동을 수행하는지 검증할 것입니다. 단, 이동의 수준이나 전략의 우수성은 고려하지 않습니다.

  1. 검증 기능을 위한 새로운 패키지를 생성하고, 패키지 이름에 test를 추가하여 이름을 관례적으로 지정합니다. 예를 들어, chesstest와 같이 지정합니다.
  2. 테스트 대상 구현을 인수로 받아 실행하는 검증 함수를 작성합니다.이 테스트는 어떤 불변 조건이 깨졌는지와 그 방식을 기록해야 합니다. 실패를 보고하는 방식으로 다음 두 가지 방식을 선택할 수 있습니다.
    • 빠른 실패: 불변 조건을 위반할 때 즉시 오류를 반환합니다.
      for color, army := range b.Armies {
          // 킹은 절대 체스판을 떠나지 않아야 합니다. 왜냐하면 게임은 체크메이트로 끝나기 때문입니다.
          if army.King == nil {
              return &MissingPieceError{Color: color, Piece: chess.King}
          }
      }
    • 이 방식은 간단하며, 수락 테스트가 빠르게 실행될 것으로 예상된다면 적합합니다. 간단한 오류 [sentinel] 및 [사용자 정의 타입]을 쉽게 사용할 수 있으며, 이는 수락 테스트 자체를 테스트하기에도 용이합니다.
    • 모든 실패 집계: 모든 실패를 수집하고 이를 모두 보고합니다.실패를 집계하는 방법은 개별 실패를 검사할 수 있는 기능을 사용자 또는 자신에게 제공할지에 따라 결정됩니다. 아래는 오류를 집계하는 사용자 정의 오류 타입을 사용하는 예입니다.
    • var badMoves []error move := p.Move() if putsOwnKingIntoCheck(b, move) { badMoves = append(badMoves, PutsSelfIntoCheckError{Move: move}) } if len(badMoves) > 0 { return SimulationError{BadMoves: badMoves} } return nil
    • 이 방식은 계속 진행 가이드와 유사한 느낌을 주며, 수락 테스트가 느리게 실행될 것으로 예상되는 경우 선호될 수 있습니다.
  3. // ExercisePlayer는 체스판에서 한 턴 동안 Player 구현을 테스트합니다. // 체스판은 합리성과 정확성을 위해 부분적으로 검사됩니다. // // 플레이어가 제공된 체스판에서 올바른 이동을 수행하면 nil 오류를 반환합니다. // 그렇지 않으면 ExercisePlayer는 플레이어가 검증에 실패한 방식과 원인을 나타내기 위해 // 이 패키지의 오류 중 하나를 반환합니다. func ExercisePlayer(b *chess.Board, p chess.Player) error

수락 테스트는 시스템이 테스트 중인 불변 조건이 깨지지 않는 한 t.Fatal을 호출하지 않도록 계속 진행 가이드를 준수해야 합니다.

예를 들어, t.Fatal은 일반적으로 설정 실패와 같은 예외적인 경우에 사용해야 합니다.

func ExerciseGame(t *testing.T, cfg *Config, p chess.Player) error {
    t.Helper()

    if cfg.Simulation == Modem {
        conn, err := modempool.Allocate()
        if err != nil {
            t.Fatalf("상대방을 위한 모뎀을 프로비저닝할 수 없습니다: %v", err)
        }
        t.Cleanup(func() { modempool.Return(conn) })
    }
    // 수락 테스트 실행 (전체 게임)
}

이 기법은 간결하고 표준화된 검증을 작성하는 데 도움이 됩니다. 그러나 이를 어설션에 대한 가이드를 우회하기 위해 사용하려고 시도해서는 안 됩니다.

최종 결과물은 사용자에게 다음과 유사한 형태로 제공되어야 합니다.

// 좋은 예:
package deepblue_test

import (
    "chesstest"
    "deepblue"
)

func TestAcceptance(t *testing.T) {
    player := deepblue.New()
    err := chesstest.ExerciseGame(t, chesstest.SimpleGame, player)
    if err != nil {
        t.Errorf("Deep Blue 플레이어가 수락 테스트에 실패했습니다: %v", err)
    }
}

 

실제 전송 수단 사용하기

컴포넌트 통합을 테스트할 때, 특히 HTTP나 RPC가 컴포넌트 간 전송 수단으로 사용되는 경우, 백엔드 테스트 버전에 연결할 때 실제 전송 수단을 사용하는 것이 좋습니다.

예를 들어, 테스트하려는 코드(종종 "테스트 대상 시스템" 또는 SUT라고 함)가 long running operations API를 구현한 백엔드와 상호작용한다고 가정해 보겠습니다. SUT를 테스트하려면 OperationsServer테스트 더블(예: 목, 스텁, 가짜)과 연결된 실제 OperationsClient를 사용하세요.

클라이언트 동작을 올바르게 모방하는 것은 복잡하므로 직접 클라이언트를 구현하기보다는 실제 클라이언트를 사용하는 것이 좋습니다. 프로덕션 클라이언트를 테스트 전용 서버와 함께 사용하면 테스트가 실제 코드의 가능한 많은 부분을 사용할 수 있도록 보장할 수 있습니다.

팁: 가능하다면, 테스트 중인 서비스 작성자가 제공하는 테스트 라이브러리를 사용하세요.

 

t.Error vs. t.Fatal

결정사항에서 논의했듯이, 테스트는 일반적으로 처음 발생한 문제에서 중단하지 않아야 합니다.

그러나 일부 상황에서는 테스트를 진행하지 않아야 합니다. 일부 테스트 설정이 실패할 때, 특히 테스트 설정 헬퍼에서 테스트를 계속 진행할 수 없는 경우 t.Fatal을 호출하는 것이 적절합니다. 테이블 기반 테스트에서는 테스트 루프 전에 전체 테스트 함수를 설정하는 실패에 대해 t.Fatal을 사용하는 것이 적절합니다. 단일 테이블 항목에 영향을 미쳐 해당 항목에서 진행할 수 없는 경우, 다음과 같은 방식으로 실패를 보고하세요.

  • t.Run 서브 테스트를 사용하지 않는 경우 t.Error를 사용한 다음 continue 문을 추가하여 다음 테이블 항목으로 넘어갑니다.
  • 서브 테스트를 사용하는 경우(즉, t.Run 내에서 호출 중일 때), t.Fatal을 사용하여 현재 서브 테스트를 종료하고 다음 서브 테스트로 진행할 수 있도록 합니다.

경고: t.Fatal 및 유사한 함수를 호출하는 것이 항상 안전하지는 않습니다. 자세한 내용은 여기에서 확인하세요.

 

테스트 헬퍼에서의 오류 처리

참고: 이 섹션에서는 Go에서 "테스트 헬퍼"라는 용어를 사용한 방식에 대해 설명합니다. 이는 공통적인 어설션 도구가 아니라 테스트 설정 및 정리를 수행하는 함수입니다. 자세한 내용은 테스트 함수 섹션을 참고하세요.

테스트 헬퍼에서 수행하는 작업이 실패하는 경우가 종종 있습니다. 예를 들어, 파일로 디렉토리를 설정하는 작업은 I/O 작업으로, 실패할 가능성이 있습니다. 테스트 헬퍼가 실패하면 이는 일반적으로 설정 전제 조건이 실패했음을 나타내므로, 테스트를 계속할 수 없는 경우가 많습니다. 이때는 헬퍼 함수에서 Fatal 계열 함수를 호출하는 것이 좋습니다.

// 좋은 예:
func mustAddGameAssets(t *testing.T, dir string) {
    t.Helper()
    if err := os.WriteFile(path.Join(dir, "pak0.pak"), pak0, 0644); err != nil {
        t.Fatalf("설정 실패: pak0 에셋을 쓸 수 없습니다: %v", err)
    }
    if err := os.WriteFile(path.Join(dir, "pak1.pak"), pak1, 0644); err != nil {
        t.Fatalf("설정 실패: pak1 에셋을 쓸 수 없습니다: %v", err)
    }
}

이 방식은 헬퍼가 오류를 테스트 본문으로 반환할 때보다 호출 측을 깔끔하게 유지할 수 있습니다.

// 나쁜 예:
func addGameAssets(t *testing.T, dir string) error {
    t.Helper()
    if err := os.WriteFile(path.Join(d, "pak0.pak"), pak0, 0644); err != nil {
        return err
    }
    if err := os.WriteFile(path.Join(d, "pak1.pak"), pak1, 0644); err != nil {
        return err
    }
    return nil
}

경고: t.Fatal과 같은 함수를 호출하는 것이 항상 안전하지는 않습니다. 자세한 내용은 여기를 참고하세요.

오류 메시지에는 발생한 일을 설명하는 내용을 포함하는 것이 좋습니다. 특히 헬퍼에서 오류가 발생하는 단계가 많아질수록, 오류가 발생한 위치와 원인을 알 수 있도록 하는 것이 중요합니다.

팁: Go 1.14에서는 테스트가 완료될 때 실행되는 정리 함수를 등록할 수 있는 t.Cleanup 함수를 도입했습니다. 이 함수는 테스트 헬퍼와 함께 사용할 수 있습니다. 테스트 헬퍼 단순화에 대한 안내는 GoTip #4: 테스트 정리를 참고하세요.

다음은 가상의 paint_test.go 파일에서 (*testing.T).Helper가 Go 테스트에서 실패 보고에 어떻게 영향을 미치는지 보여줍니다.

package paint_test

import (
    "fmt"
    "testing"
)

func paint(color string) error {
    return fmt.Errorf("오늘은 %q 색상의 페인트가 없습니다", color)
}

func badSetup(t *testing.T) {
    // 이 함수는 t.Helper를 호출해야 하지만 호출하지 않았습니다.
    if err := paint("taupe"); err != nil {
        t.Fatalf("테스트 중인 집을 페인트할 수 없습니다: %v", err) // line 15
    }
}

func mustGoodSetup(t *testing.T) {
    t.Helper()
    if err := paint("lilac"); err != nil {
        t.Fatalf("테스트 중인 집을 페인트할 수 없습니다: %v", err)
    }
}

func TestBad(t *testing.T) {
    badSetup(t)
    // ...
}

func TestGood(t *testing.T) {
    mustGoodSetup(t) // line 32
    // ...
}

다음은 실행 시 나타나는 출력 예시입니다. 강조된 텍스트와 차이점을 주목하세요.

=== RUN   TestBad
    paint_test.go:15: 테스트 중인 집을 페인트할 수 없습니다: 오늘은 "taupe" 색상의 페인트가 없습니다
--- FAIL: TestBad (0.00s)
=== RUN   TestGood
    paint_test.go:32: 테스트 중인 집을 페인트할 수 없습니다: 오늘은 "lilac" 색상의 페인트가 없습니다
--- FAIL: TestGood (0.00s)
FAIL

paint_test.go:15에서의 오류는 badSetup 함수에서 실패한 설정 함수의 줄을 참조합니다.

t.Fatalf("테스트 중인 집을 페인트할 수 없습니다: %v", err)

반면 paint_test.go:32TestGood에서 실패한 테스트의 줄을 참조합니다.

goodSetup(t)

(*testing.T).Helper를 적절히 사용하면 다음과 같은 경우 실패 위치를 훨씬 더 잘 나타낼 수 있습니다.

  • 헬퍼 함수가 커질 때
  • 헬퍼 함수가 다른 헬퍼를 호출할 때
  • 테스트 함수에서 헬퍼 사용이 많아질 때

팁: 헬퍼가 (*testing.T).Error 또는 (*testing.T).Fatal을 호출할 경우, 어떤 오류가 발생했는지와 그 이유를 파악할 수 있도록 포맷 문자열에 컨텍스트를 추가하세요.

팁: 헬퍼가 테스트 실패를 유발할 가능성이 전혀 없다면 t.Helper를 호출할 필요가 없습니다. 이 경우 함수 매개변수 목록에서 t를 제거하여 서명을 간소화하세요.

 

별도의 고루틴에서 t.Fatal을 호출하지 마세요

패키지 테스트 문서에 따르면, t.FailNow, t.Fatal 등을 테스트 함수(또는 서브테스트)를 실행하는 고루틴 외부에서 호출하는 것은 잘못된 사용입니다. 테스트에서 새로운 고루틴을 시작할 경우, 그 고루틴 내부에서 이러한 함수를 호출해서는 안 됩니다.

테스트 헬퍼는 일반적으로 새로운 고루틴에서 실패를 신호하지 않으므로 t.Fatal을 사용하는 것이 허용됩니다. 의심스러울 경우 t.Error를 호출하고 대신 반환하세요.

// 좋은 예:
func TestRevEngine(t *testing.T) {
    engine, err := Start()
    if err != nil {
        t.Fatalf("엔진 시작 실패: %v", err)
    }

    num := 11
    var wg sync.WaitGroup
    wg.Add(num)
    for i := 0; i < num; i++ {
        go func() {
            defer wg.Done()
            if err := engine.Vroom(); err != nil {
                // 여기서는 t.Fatalf를 사용할 수 없습니다.
                t.Errorf("엔진에 더 이상 vroom이 남아 있지 않습니다: %v", err)
                return
            }
            if rpm := engine.Tachometer(); rpm > 1e6 {
                t.Errorf("상상할 수 없는 엔진 속도: %d", rpm)
            }
        }()
    }
    wg.Wait()

    if seen := engine.NumVrooms(); seen != num {
        t.Errorf("engine.NumVrooms() = %d, 원함 %d", seen, num)
    }
}

테스트나 서브테스트에 t.Parallel을 추가한다고 해서 t.Fatal을 호출하는 것이 위험하지는 않습니다.

모든 testing API 호출이 테스트 함수 내부에 있을 경우, go 키워드가 명확히 보이므로 잘못된 사용을 쉽게 찾을 수 있습니다. testing.T 인수를 전달하는 것은 이러한 사용 추적을 더 어렵게 만듭니다. 일반적으로 이러한 인수를 전달하는 이유는 테스트 헬퍼를 도입하기 위한 것이며, 이는 테스트 중인 시스템에 의존해서는 안 됩니다. 따라서 테스트 헬퍼가 치명적인 테스트 실패를 등록하는 경우, 테스트의 고루틴에서 수행할 수 있고 수행해야 합니다.

 

구조체 리터럴에서 필드 이름 사용하기

테이블 기반 테스트에서는 테스트 케이스 구조체 리터럴을 초기화할 때 필드 이름을 지정하는 것이 좋습니다. 이는 테스트 케이스가 많은 수직 공간(예: 20-30줄 이상)을 차지할 때, 동일한 타입의 인접 필드가 있을 때, 또는 기본값을 가진 필드를 생략하고자 할 때 유용합니다. 예를 들어:

// 좋은 예:
func TestStrJoin(t *testing.T) {
    tests := []struct {
        slice     []string
        separator string
        skipEmpty bool
        want      string
    }{
        {
            slice:     []string{"a", "b", ""},
            separator: ",",
            want:      "a,b,",
        },
        {
            slice:     []string{"a", "b", ""},
            separator: ",",
            skipEmpty: true,
            want:      "a,b",
        },
        // ...
    }
    // ...
}

 

특정 테스트에 국한하여 설정 코드 작성하기

가능한 경우, 리소스와 종속성을 특정 테스트 케이스에 최대한 근접하게 설정하세요. 예를 들어, 다음과 같은 설정 함수를 가정합니다:

// mustLoadDataset는 테스트를 위한 데이터 세트를 로드합니다.
//
// 이 예제는 매우 단순하며 쉽게 읽을 수 있습니다. 실제 설정은 더 복잡하고, 오류가 발생하기 쉽거나 느릴 수 있습니다.
func mustLoadDataset(t *testing.T) []byte {
    t.Helper()
    data, err := os.ReadFile("path/to/your/project/testdata/dataset")

    if err != nil {
        t.Fatalf("데이터 세트를 로드할 수 없습니다: %v", err)
    }
    return data
}

mustLoadDataset는 필요한 테스트 함수에서 명시적으로 호출하세요:

// 좋은 예:
func TestParseData(t *testing.T) {
    data := mustLoadDataset(t)
    parsed, err := ParseData(data)
    if err != nil {
        t.Fatalf("데이터를 파싱하는 동안 예기치 않은 오류 발생: %v", err)
    }
    want := &DataTable{ /* ... */ }
    if got := parsed; !cmp.Equal(got, want) {
        t.Errorf("ParseData(data) = %v, want %v", got, want)
    }
}

func TestListContents(t *testing.T) {
    data := mustLoadDataset(t)
    contents, err := ListContents(data)
    if err != nil {
        t.Fatalf("콘텐츠를 나열하는 동안 예기치 않은 오류 발생: %v", err)
    }
    want := []string{ /* ... */ }
    if got := contents; !cmp.Equal(got, want) {
        t.Errorf("ListContents(data) = %v, want %v", got, want)
    }
}

func TestRegression682831(t *testing.T) {
    if got, want := guessOS("zpc79.example.com"), "grhat"; got != want {
        t.Errorf(`guessOS("zpc79.example.com") = %q, want %q`, got, want)
    }
}

테스트 함수 TestRegression682831은 데이터 세트를 사용하지 않으므로, 실패 가능성이 있고 느릴 수 있는 mustLoadDataset를 호출하지 않습니다:

// 나쁜 예:
var dataset []byte

func TestParseData(t *testing.T) {
    // 위에 문서화된 것처럼 mustLoadDataset를 직접 호출하지 않고 작성.
}

func TestListContents(t *testing.T) {
    // 위에 문서화된 것처럼 mustLoadDataset를 직접 호출하지 않고 작성.
}

func TestRegression682831(t *testing.T) {
    if got, want := guessOS("zpc79.example.com"), "grhat"; got != want {
        t.Errorf(`guessOS("zpc79.example.com") = %q, want %q`, got, want)
    }
}

func init() {
    dataset = mustLoadDataset()
}

사용자가 다른 테스트 함수들과 독립적으로 함수를 실행하려 할 때, 이러한 비효율적인 초기화로 인해 성능 저하를 겪지 않도록 해야 합니다.

# 불필요한 초기화가 실행될 이유가 없습니다.
$ go test -run TestRegression682831

 

커스텀 TestMain 진입점을 사용하는 경우

패키지 내 모든 테스트가 공통 설정을 필요로 하고, 그 설정에 해제가 필요하다면, 커스텀 TestMain 진입점을 사용할 수 있습니다. 이는 테스트 케이스가 요구하는 리소스를 설정하는 데 비용이 많이 드는 경우에 발생할 수 있으며, 비용을 상쇄해야 하는 경우에 적합합니다. 일반적으로는 관련 없는 테스트를 테스트 모음에서 이미 분리해 두었을 것입니다. 이는 보통 기능 테스트에만 사용됩니다.

커스텀 TestMain 사용은 최우선 선택지가 되어서는 안 됩니다. 올바른 사용을 위해 신중하게 관리해야 하기 때문입니다. 먼저 공통 테스트 설정의 상쇄 또는 일반적인 테스트 헬퍼가 필요한 요구를 충족할 수 있는지 고려해 보세요.

// 좋은 예:
var db *sql.DB

func TestInsert(t *testing.T) { /* 생략 */ }

func TestSelect(t *testing.T) { /* 생략 */ }

func TestUpdate(t *testing.T) { /* 생략 */ }

func TestDelete(t *testing.T) { /* 생략 */ }

// runMain은 테스트 종속성을 설정하고 결국 테스트를 실행합니다.
// 이는 설정 단계를 명확하게 구분하여 해제 단계를 지연할 수 있도록 하기 위해 별도의 함수로 정의됩니다.
func runMain(ctx context.Context, m *testing.M) (code int, err error) {
    ctx, cancel := context.WithCancel(ctx)
    defer cancel()

    d, err := setupDatabase(ctx)
    if err != nil {
        return 0, err
    }
    defer d.Close() // 데이터베이스 명확히 정리.
    db = d          // db는 패키지 수준 변수로 정의됨.

    // m.Run()은 사용자 정의된 테스트 함수를 실행합니다.
    // m.Run()이 완료된 후에는 설정된 모든 defer 문이 실행됩니다.
    return m.Run(), nil
}

func TestMain(m *testing.M) {
    code, err := runMain(context.Background(), m)
    if err != nil {
        // 실패 메시지는 STDERR에 기록되어야 하며, log.Fatal은 이를 사용합니다.
        log.Fatal(err)
    }
    // 주의: os.Exit가 프로세스를 종료하므로 여기서 defer 문은 실행되지 않습니다.
    os.Exit(code)
}

이상적으로는 테스트 케이스가 자체 호출과 다른 테스트 케이스 간의 상호작용에서 격리되어야 합니다.

최소한, 각 테스트 케이스가 외부 데이터베이스와 같은 전역 상태를 수정한 경우 이를 재설정하도록 해야 합니다.

 

공통 테스트 설정의 상쇄

모든 다음 조건을 만족할 경우, sync.Once 사용이 적절할 수 있습니다:

  • 설정 비용이 많이 듭니다.
  • 일부 테스트에만 적용됩니다.
  • 해제가 필요하지 않습니다.
// 좋은 예:
var dataset struct {
    once sync.Once
    data []byte
    err  error
}

func mustLoadDataset(t *testing.T) []byte {
    t.Helper()
    dataset.once.Do(func() {
        data, err := os.ReadFile("path/to/your/project/testdata/dataset")
        // dataset은 패키지 수준 변수로 정의됨.
        dataset.data = data
        dataset.err = err
    })
    if err := dataset.err; err != nil {
        t.Fatalf("데이터 세트를 로드할 수 없습니다: %v", err)
    }
    return dataset.data
}

mustLoadDataset이 여러 테스트 함수에서 사용될 때, 이 비용은 상쇄됩니다.

// 좋은 예:
func TestParseData(t *testing.T) {
    data := mustLoadDataset(t)

    // 위에 문서화된 것처럼.
}

func TestListContents(t *testing.T) {
    data := mustLoadDataset(t)

    // 위에 문서화된 것처럼.
}

func TestRegression682831(t *testing.T) {
    if got, want := guessOS("zpc79.example.com"), "grhat"; got != want {
        t.Errorf(`guessOS("zpc79.example.com") = %q, want %q`, got, want)
    }
}

공통 해제가 어려운 이유는 정리 루틴을 등록할 통일된 장소가 없기 때문입니다. 설정 함수(loadDataset 등)가 컨텍스트에 의존할 경우, sync.Once는 문제가 될 수 있습니다. 이는 두 개의 경쟁 호출이 설정 함수를 호출할 때, 첫 번째 호출이 완료될 때까지 기다려야 하는 상황이 발생할 수 있기 때문이며, 이 대기 시간을 컨텍스트의 취소를 쉽게 존중하도록 만들기 어렵습니다.

 

문자열 연결

Go에서 문자열을 연결하는 방법에는 여러 가지가 있습니다. 예시로는 다음과 같습니다:

  • + 연산자
  • fmt.Sprintf
  • strings.Builder
  • text/template
  • safehtml/template

하나의 규칙으로 모든 경우를 아우를 수는 없지만, 각 방법의 선호 상황에 대한 지침은 다음과 같습니다.

 

간단한 경우에는 +를 사용하세요

몇 개의 문자열을 연결할 때는 +를 사용하는 것이 좋습니다. 이 방법은 구문이 가장 간단하고, 추가적인 임포트가 필요하지 않습니다.

// 좋은 예:
key := "projectid: " + p

 

포맷이 필요할 때는 fmt.Sprintf를 사용하세요

포맷이 필요한 복잡한 문자열을 만들 때는 fmt.Sprintf를 사용하는 것이 좋습니다. 많은 + 연산자를 사용하면 결과가 모호해질 수 있습니다.

// 좋은 예:
str := fmt.Sprintf("%s [%s:%d]-> %s", src, qos, mtu, dst)
// 나쁜 예:
bad := src.String() + " [" + qos.String() + ":" + strconv.Itoa(mtu) + "]-> " + dst.String()

최적의 사용법: 문자열 생성 작업의 결과가 io.Writer로 출력될 경우, fmt.Sprintf를 사용해 임시 문자열을 만들어 전달하지 말고 fmt.Fprintf를 사용하여 직접 Writer로 출력하세요.

포맷이 더 복잡한 경우에는 text/template 또는 safehtml/template를 사용하는 것이 좋습니다.

 

조각별로 문자열을 생성할 때는 strings.Builder를 사용하세요

조각별로 문자열을 생성할 때는 strings.Builder를 사용하는 것이 좋습니다. strings.Builder는 상쇄된 선형 시간(Linear time)으로 작동하지만, + 연산자나 fmt.Sprintf는 순차적으로 호출될 때 더 큰 문자열을 생성하는 데 이차 시간(Quadratic time)이 소요됩니다.

// 좋은 예:
b := new(strings.Builder)
for i, d := range digitsOfPi {
    fmt.Fprintf(b, "the %d digit of pi is: %d\n", i, d)
}
str := b.String()

참고: 더 많은 논의는 GoTip #29: 효율적인 문자열 생성을 참고하세요.

 

상수 문자열

상수 문자열 리터럴을 만들 때는 여러 줄 문자열이 필요한 경우 백틱(``)을 사용하는 것이 좋습니다.

// 좋은 예:
usage := `사용법:

custom_tool [args]`
// 나쁜 예:
usage := "" +
  "사용법:\n" +
  "\n" +
  "custom_tool [args]"

 

전역 상태

라이브러리는 클라이언트에게 전역 상태에 의존하는 API 사용을 강요해서는 안 됩니다. 모든 클라이언트의 동작을 제어하는 API 일부로 패키지 수준의 변수 또는 전역 상태를 노출하지 않도록 권장합니다. 이 섹션에서는 "전역"과 "패키지 수준 상태"를 동일한 의미로 사용합니다.

대신, 기능이 상태를 유지해야 하는 경우 클라이언트가 인스턴스 값을 생성하고 사용할 수 있도록 해야 합니다.

중요: 이 지침은 모든 개발자에게 적용되지만, 특히 라이브러리, 통합 및 서비스를 다른 팀에 제공하는 인프라 제공자에게 가장 중요합니다.

// 좋은 예:
// sidecar 패키지는 애플리케이션에 기능을 제공하는 하위 프로세스를 관리합니다.
package sidecar

type Registry struct { plugins map[string]*Plugin }

func New() *Registry { return &Registry{plugins: make(map[string]*Plugin)} }

func (r *Registry) Register(name string, p *Plugin) error { ... }

사용자는 필요한 데이터를 인스턴스화(*sidecar.Registry)한 후 명시적 종속성으로 전달할 수 있습니다:

// 좋은 예:
package main

func main() {
  sidecars := sidecar.New()
  if err := sidecars.Register("Cloud Logger", cloudlogger.New()); err != nil {
    log.Exitf("클라우드 로거를 설정할 수 없습니다: %v", err)
  }
  cfg := &myapp.Config{Sidecars: sidecars}
  myapp.Run(context.Background(), cfg)
}

기존 코드를 의존성 전달을 지원하도록 마이그레이션하는 방법에는 여러 가지가 있습니다. 주요 방법은 생성자, 함수, 메서드 또는 호출 체인의 구조체 필드에 의존성을 전달하는 것입니다.

참조 자료:

명시적 의존성 전달을 지원하지 않는 API는 클라이언트 수가 증가함에 따라 취약해질 수 있습니다:

// 나쁜 예:
package sidecar

var registry = make(map[string]*Plugin)

func Register(name string, p *Plugin) error { /* registry에 플러그인 등록 */ }

클라우드 로깅을 위해 sidecar에 의존하는 코드를 테스트할 때의 상황을 생각해 보세요.

// 나쁜 예:
package app

import (
  "cloudlogger"
  "sidecar"
  "testing"
)

func TestEndToEnd(t *testing.T) {
  // 테스트 대상 시스템(SUT)은 이미 등록된 프로덕션 클라우드 로거를 sidecar에 의존합니다.
  ... // SUT 실행 및 불변 조건 확인
}

func TestRegression_NetworkUnavailability(t *testing.T) {
  // 네트워크 파티션으로 인해 클라우드 로거가 작동하지 않는 문제를 해결하기 위해
  // 로거가 없는 상태를 시뮬레이션하는 테스트 더블을 사용하여 SUT를 테스트하는 회귀 테스트를 추가했습니다.
  sidecar.Register("cloudlogger", cloudloggertest.UnavailableLogger)
  ... // SUT 실행 및 불변 조건 확인
}

func TestRegression_InvalidUser(t *testing.T) {
  // SUT는 이미 등록된 프로덕션 클라우드 로거를 sidecar에 의존합니다.
  //
  // 실수로 이전 테스트에서 cloudloggertest.UnavailableLogger가 여전히 등록되어 있습니다.
  ... // SUT 실행 및 불변 조건 확인
}

 

Go 테스트는 기본적으로 순차적으로 실행되기 때문에 위의 테스트는 다음 순서로 실행됩니다:

  1. TestEndToEnd
  2. TestRegression_NetworkUnavailability (cloudlogger의 기본값을 덮어씁니다)
  3. TestRegression_InvalidUser (기본값이 등록된 cloudlogger를 필요로 합니다)

이로 인해 테스트가 실행 순서에 의존하게 되어 테스트 필터로 실행하는 경우 오류가 발생할 수 있고, 테스트를 병렬로 실행하거나 샤딩하기 어렵게 됩니다.

전역 상태를 사용하면 API 사용자와 API 클라이언트 모두에게 어려운 문제를 초래할 수 있습니다:

  • 클라이언트가 동일한 프로세스 공간에서 별도이고 독립적인 Plugin 세트를 사용할 필요가 있을 때 어떻게 될까요? (예를 들어 여러 서버를 지원하기 위해)
  • 클라이언트가 테스트에서 대체 구현(예: [테스트 더블])으로 등록된 Plugin을 교체하려고 할 때 어떻게 될까요?
  • 클라이언트의 테스트가 Plugin 인스턴스 간, 또는 등록된 모든 플러그인 간의 독립성을 요구할 때 어떻게 될까요?
  • 여러 클라이언트가 동일한 이름으로 Plugin을 등록하는 경우 어떤 것이 선택될까요? (선택된다면)
    • 오류는 어떻게 처리해야 할까요? 코드가 패닉을 일으키거나 log.Fatal을 호출하는 것이 항상 API가 호출되는 모든 상황에 적합할까요? 클라이언트가 문제가 발생하지 않도록 확인할 방법이 있나요?
  • 프로그램의 시작 또는 실행 중 특정 단계에서만 Register가 호출될 수 있거나, 호출되면 안 될 때가 있을까요?
    • Register가 잘못된 시점에 호출되면 어떻게 될까요? 클라이언트가 플래그가 파싱되기 전 또는 main 이후에 func init에서 Register를 호출할 수 있습니다. 호출 시점은 오류 처리에 영향을 미칩니다. API 작성자가 프로그램 초기화 동안에만 API가 호출된다고 가정하고 오류 처리를 프로그램을 중단하는 방식으로 설계한다면, 이는 일반적인 라이브러리 함수로서 적합하지 않습니다.
  • 클라이언트와 설계자의 동시성 요구 사항이 일치하지 않으면 어떻게 될까요?

참조 자료:

전역 상태는 Google 코드베이스의 유지 관리성에 악영향을 미치며, 전역 상태는 철저한 검토가 필요합니다.

전역 상태는 여러 가지 형태로 나타납니다, 일부는 안전 여부를 확인할 수 있는 간단한 테스트를 사용할 수 있습니다.

 

주요 패키지 상태 API의 형태

문제가 되는 몇 가지 일반적인 API 형태는 다음과 같습니다:

  • 최상위 수준 변수(내보낸 변수든 아니든).이러한 변수가 안전한지 확인하려면 간단한 테스트를 참고하세요.
  • // 나쁜 예: package logger // Sinks는 이 패키지의 로깅 API의 기본 출력 소스를 관리합니다. 이 // 변수는 패키지 초기화 시 설정되고 이후에는 변경되지 않아야 합니다. var Sinks []Sink
  • 서비스 로케이터 패턴.
    첫 번째 예제를 참조하세요. 서비스 로케이터 패턴 자체는 문제가 되지 않지만 로케이터가 전역으로 정의된 경우 문제가 발생합니다.
  • 콜백 및 유사한 동작을 위한 레지스트리.
  • // 나쁜 예: package health var unhealthyFuncs []func func OnUnhealthy(f func()) { unhealthyFuncs = append(unhealthyFuncs, f) }
  • 백엔드, 스토리지, 데이터 액세스 계층 및 기타 시스템 리소스와 같은 것에 대한 Thick-Client 싱글톤. 이는 종종 서비스 신뢰성과 관련된 추가 문제를 초래합니다.
  • // 나쁜 예: package useradmin var client pb.UserAdminServiceClientInterface func Client() *pb.UserAdminServiceClient { if client == nil { client = ... // 클라이언트 설정 } return client }

참고: Google 코드베이스의 많은 레거시 API는 이러한 지침을 따르지 않습니다. 사실 일부 Go 표준 라이브러리도 전역 값을 통해 구성을 허용합니다. 그러나 레거시 API가 이 지침을 따르지 않는다고 해서 이러한 패턴을 선례로 사용해서는 안 됩니다.

오늘날 올바른 API 설계에 투자하는 것이 나중에 다시 설계하는 비용보다 더 나은 선택입니다.

 

간단한 테스트

위에서 언급한 패턴을 사용하는 API는 다음의 경우에 안전하지 않습니다:

  • 여러 함수가 전역 상태를 통해 상호작용하며, 이는 프로그램 내에서 독립적이어야 하는 함수 간의 상호작용을 일으킵니다(예: 완전히 다른 디렉토리에서 다른 작성자가 작성한 코드).
  • 독립적인 테스트 케이스가 전역 상태를 통해 서로 상호작용합니다.
  • API 사용자가 전역 상태를 테스트 목적으로 교체하고자 할 때 특히 테스트 더블(stub, fake, spy, mock 등)과 같은 대체 상태로 교체하려는 경우.
  • 전역 상태와 상호작용할 때 특정 순서를 고려해야 할 때(예: func init 호출 순서, 플래그 파싱 여부 등).

위 조건을 피한다면, 제한된 상황에서 이러한 API가 안전할 수 있는 몇 가지 경우는 다음과 같습니다:

  • 전역 상태가 논리적으로 불변일 때 (예제).
  • 패키지의 관찰 가능한 동작이 상태가 없을 때. 예를 들어, 공개 함수가 캐시로 전역 변수를 사용할 수 있지만, 호출자가 캐시 히트(hit)와 미스(miss)를 구별할 수 없는 한 이 함수는 상태가 없는 것입니다.
  • 전역 상태가 프로그램 외부로 확산되지 않을 때(예: 사이드카 프로세스 또는 공유 파일 시스템의 파일).
  • 예측 가능한 동작에 대한 기대가 없는 경우 (예제).

참고: 사이드카 프로세스는 엄밀히 말해 프로세스 로컬일 필요는 없습니다. 하나 이상의 애플리케이션 프로세스와 공유될 수 있으며, 실제로 그렇게 사용하는 경우가 많습니다. 또한 이러한 사이드카는 종종 외부 분산 시스템과 상호작용합니다.

이 경우에도 본문의 고려 사항에 더해, 사이드카 프로세스 코드에 동일한 상태 비저장, 멱등성, 로컬 규칙이 적용되어야 합니다.

안전한 상황의 예로는 package imageimage.RegisterFormat 함수가 있습니다. PNG 형식을 처리하는 디코더와 같은 일반적인 디코더에 대해 위의 간단한 테스트를 적용해 보겠습니다:

  • package image의 API(예: image.Decode)를 사용하는 여러 호출이 서로 간섭할 수 없으며, 테스트에서도 동일합니다. 예외는 image.RegisterFormat이지만, 아래의 요인들로 인해 그 영향은 완화됩니다.
  • 사용자가 디코더를 테스트 더블로 교체하고자 할 가능성은 거의 없지만, 디코더가 운영 체제 리소스와 상태를 가지고 상호작용할 경우 더블로 교체하려는 가능성은 높아집니다.
  • 등록 충돌은 상상할 수 있지만 실제로는 드물게 발생합니다.
  • 디코더는 상태가 없고, 멱등적이며, 순수한 함수입니다.

 

기본 인스턴스 제공

권장되지는 않지만, 사용자 편의를 극대화하기 위해 패키지 수준 상태를 사용하는 간단한 API를 제공하는 것은 허용됩니다.

이러한 경우 다음 지침과 함께 간단한 테스트를 따르세요:

  1. 패키지는 클라이언트가 패키지 타입의 독립된 인스턴스를 생성할 수 있는 기능을 제공해야 합니다(위에서 설명한 대로).
  2. 전역 상태를 사용하는 공개 API는 이전 API에 대한 얇은 프록시여야 합니다. 예로는 http.Handle(*http.ServeMux).Handle을 내부적으로 호출하는 방식이 있으며, 이는 패키지 변수 http.DefaultServeMux에 적용됩니다.
  3. 이 패키지 수준 API는 바이너리 빌드 대상에서만 사용해야 하며, 라이브러리에서는 사용되지 않아야 합니다. 다른 패키지가 가져올 수 있는 인프라 라이브러리는 가져온 패키지의 패키지 수준 상태에 의존해서는 안 됩니다.
     // 좋은 예:
     package cloudlogger
    
     func New() *Logger { ... }
    
     func Register(r *sidecar.Registry, l *Logger) {
       r.Register("Cloud Logging", l)
     }
  4. 예를 들어, API를 사용하는 다른 팀과 공유할 사이드카를 구현하는 인프라 제공자는 이를 지원하는 API를 제공해야 합니다:
  5. 이 패키지 수준 API는 그 불변성을 문서화하고, 프로그램 실행 중 호출 가능한 단계나 동시 사용 가능 여부 등의 특성을 명확히 해야 합니다. 또한, 테스트를 쉽게 하기 위해 전역 상태를 기본값으로 재설정하는 API를 제공해야 합니다.

참조 자료: