Go 표준 및 스타일 가이드라인

이 문서는 Go 언어를 사용하는 GitLab 프로젝트를 위한 다양한 가이드라인과 모범 사례를 설명합니다.

GitLab은 Ruby on Rails 위에 구축되었지만, 적절한 경우에 Go를 사용하는 프로젝트도 있습니다. Go는 매우 강력한 언어로, 많은 장점을 가지고 있으며, IO(디스크/네트워크 접근), HTTP 요청, 병렬 처리 등이 많은 프로젝트에 가장 적합합니다. GitLab에서는 Ruby on Rails와 Go를 모두 사용하므로, 두 가지 중 어떤 것이 작업에 가장 적합한지를 신중하게 평가해야 합니다.

이 페이지는 다양한 경험을 바탕으로 우리의 Go 가이드라인을 정의하고 조직하는 것을 목표로 합니다. 여러 프로젝트가 서로 다른 표준으로 시작되었으며, 여전히 특정 사항이 존재할 수 있습니다. 이들은 해당하는 README.md 또는 PROCESS.md 파일에 설명되어 있습니다.

프로젝트 구조

Go 애플리케이션 프로젝트의 기본 레이아웃에 따르면, 공식적인 Go 프로젝트 레이아웃은 없습니다. 그러나 Ben Johnson의 표준 패키지 레이아웃에는 몇 가지 좋은 제안이 있습니다.

다음은 영감을 위한 GitLab Go 기반 프로젝트 목록입니다:

Go 언어 버전

Go 업그레이드 문서에서는 개요를 제공하여 GitLab이 Go 이진 파일 지원을 관리하고 제공하는 방식을 설명합니다.

GitLab 구성 요소가 최신 버전의 Go를 요구하는 경우, 업그레이드 프로세스를 따라 고객, 팀 또는 구성 요소에 부정적인 영향을 미치지 않도록 합니다.

때때로 개별 프로젝트는 다양한 Go 버전으로 빌드를 관리해야 할 수도 있습니다.

종속성 관리

Go는 종속성 관리를 위한 소스 기반 전략을 사용합니다. 종속성은 해당 소스 저장소에서 소스 형태로 다운로드됩니다. 이는 종속성의 소스 저장소와 별도의 패키지 저장소에서 아티팩트 형태로 다운로드하는 보다 일반적인 아티팩트 기반 전략과 다릅니다.

Go는 1.11 이전에 버전 관리에 대한 일급 지원이 없었습니다. 해당 버전에서는 Go 모듈과 의미적 버전 관리의 사용을 도입했습니다. Go 1.12에서는 클라이언트와 소스 버전 제어 시스템 간의 중개 역할을 할 수 있는 모듈 프록시와 종속성 다운로드의 무결성을 확인할 수 있는 체크섬 데이터베이스가 도입되었습니다.

자세한 사항은 Go의 종속성 관리를 참조하세요.

코드 리뷰

우리는 Go 코드 리뷰 댓글의 일반 원칙을 따릅니다.

리뷰어와 유지 관리자는 다음 사항에 주의해야 합니다:

  • defer 함수: 필요할 때 존재하는지, err 확인 후에 있는지 확인합니다.
  • 종속성을 매개변수로 주입합니다.
  • JSON으로 마샬링할 때 빈 구조체를 사용합니다 ( [] 대신 null 을 생성합니다).

보안

보안은 GitLab의 최우선 사항입니다. 코드 리뷰 중에 코드에 대한 가능한 보안 침해를 주의해야 합니다:

  • 텍스트/템플릿 사용 시 XSS
  • Gorilla를 사용한 CSRF 보호
  • 알려진 취약점이 없는 Go 버전 사용
  • 비밀 토큰 유출 방지
  • SQL 삽입

프로젝트에서 SAST의존성 스캔을 실행하는 것을 기억하세요(또는 적어도 gosec 분석기를 실행하세요), 그리고 우리의 보안 요구 사항을 따르세요.

웹 서버는 Secure와 같은 미들웨어의 장점을 활용할 수 있습니다.

리뷰어 찾기

우리의 많은 프로젝트는 일정한 유지 관리자가 필요할 정도로 크지 않습니다. 그래서 우리는 GitLab에서 Go 리뷰어의 공유 풀을 운영하고 있습니다. 리뷰어를 찾으려면, 핸드북의 엔지니어링 프로젝트 페이지에서 “GitLab” 프로젝트의 “Go” 섹션을 사용하세요.

이 목록에 본인을 추가하려면, team.yml 파일의 프로필에 다음을 추가하고, 매니저에게 검토 및 병합을 요청하세요.

projects:
  gitlab: reviewer go

코드 스타일 및 형식

  • 전역 변수를 피하세요. 패키지가 여러 번 포함될 경우 부작용을 일으킬 수 있습니다.
  • 커밋하기 전에 goimports를 사용하세요. goimportsGofmt를 사용하여 Go 소스 코드를 자동으로 포맷하는 도구입니다. 누락된 것을 추가하고 사용되지 않는 것을 제거하는 것 외에도 임포트 라인을 포맷합니다.

    대부분의 편집기/IDE에서는 파일을 저장하기 전/후에 명령을 실행할 수 있는 기능이 있으므로 goimports가 저장할 때 모든 파일에 적용되도록 설정할 수 있습니다.

  • 개인 메서드는 소스 파일의 첫 번째 호출 메서드 아래에 위치시키세요.

자동 린팅

경고: registry.gitlab.com/gitlab-org/gitlab-build-images:golangci-lint-alpine의 사용은 16.10 버전부터 사용 중단되었습니다.

상위 버전의 golangci-lint를 사용하세요. 기본적으로 활성화/비활성화된 린터 목록을 참고하세요.

Go 프로젝트는 아래와 같은 GitLab CI/CD 작업을 포함해야 합니다:

variables:
  GOLANGCI_LINT_VERSION: 'v1.56.2'
lint:
  image: golangci/golangci-lint:$GOLANGCI_LINT_VERSION
  stage: test
  script:
    # gl-code-quality-report.json에 코드 커버리지 보고서를 작성하고,
    # 형식: path/to/file:line description으로 stdout에 린팅 문제를 출력합니다.
    # 린팅 문제가 감지되면 작업이 실패하도록 하려면 `--issues-exit-code 0`을 제거하거나 
    # 0이 아닌 값으로 설정하세요.
    - golangci-lint run --issues-exit-code 0 --print-issued-lines=false --out-format code-climate:gl-code-quality-report.json,line-number
  artifacts:
    reports:
      codequality: gl-code-quality-report.json
    paths:
      - gl-code-quality-report.json

프로젝트의 루트 디렉터리에 .golangci.yml을 포함하면 golangci-lint의 구성이 가능합니다. golangci-lint의 모든 옵션은 이 예제에 나와 있습니다.

재귀 포함이 가능해지면, 이렇게 분석기와 같은 작업 템플릿을 공유할 수 있습니다.

Go GitLab 린터 플러그인은 gitlab-org/language-tools/go/linters 네임스페이스에서 유지 관리됩니다.

도움말 텍스트 스타일 가이드

귀하의 Go 프로젝트가 사용자에게 도움말 텍스트를 제공하는 경우, gitaly 프로젝트에서 제공하는 조언을 따르는 것을 고려하세요.

종속성

종속성은 최소한으로 유지해야 합니다. 새로운 종속성을 도입할 경우, 승인 가이드라인에 따라 병합 요청에서 이에 대한 논의가 필요합니다.

종속성 스캐닝은 모든 프로젝트에서 활성화되어야 하며, 새로운 종속성의 보안 상태와 라이센스 호환성을 보장합니다.

모듈

Go 1.11 이상에서는 Go Modules라는 이름 아래 표준 종속성 시스템이 제공됩니다. 이는 재현 가능한 빌드를 위해 종속성을 정의하고 잠글 수 있는 방법을 제공합니다. 가능한 한 자주 사용해야 합니다.

Go Modules를 사용할 때는 vendor/ 디렉토리가 없어야 합니다. 대신, Go는 프로젝트를 빌드하는 데 필요한 종속성을 자동으로 다운로드합니다. 이는 Ruby 프로젝트에서 Bundler로 처리하는 방식과 일치하며, 병합 요청을 더 쉽게 검토할 수 있습니다.

다른 프로젝트에 대한 CI 실행의 종속성으로 작동하는 Go 프로젝트를 빌드하는 경우와 같은 몇 가지 특수 사례에서는 vendor/ 디렉토리를 제거하는 것이 코드가 반복적으로 다운로드되도록 만들며, 이는 속도 제한이나 네트워크 오류로 인해 간헐적인 문제를 일으킬 수 있습니다. 이러한 경우에는 다운로드된 코드를 캐시하는 것을 고려해야 합니다.

Go v1.11.4 이전 버전에서는 모듈 체크섬 오류가 있었으므로, checksum mismatch 오류를 피하기 위해서는 최소한 이 버전을 사용해야 합니다.

ORM

GitLab에서는 객체-관계 매핑 라이브러리(ORM)를 사용하지 않습니다(단, ActiveRecord는 Ruby on Rails에서 사용됨). 프로젝트는 ORM을 피하기 위해 서비스를 구조화할 수 있습니다. pgx는 PostgreSQL 데이터베이스와 상호작용하기에 충분합니다.

마이그레이션

호스팅된 데이터베이스를 관리하는 드문 경우에는 ActiveRecord가 제공하는 마이그레이션 시스템을 사용해야 합니다. postgres 컨테이너에서 사용하도록 설계된 간단한 라이브러리인 Journey를 장기 실행 포드로 배포할 수 있습니다. 새 버전은 새 포드를 배포하고 데이터를 자동으로 마이그레이션합니다.

테스트

테스트 프레임워크

우리는 테스트를 위한 특정 라이브러리나 프레임워크를 사용해서는 안 되며, 표준 라이브러리가 이미 시작하는 데 필요한 모든 것을 제공합니다. 더 정교한 테스트 도구가 필요할 경우, 특정 라이브러리나 프레임워크를 사용할 것을 결정할 때 다음의 외부 종속성을 고려할 수 있습니다:

서브 테스트

서브 테스트를 가능한 한 많이 사용하여 코드 가독성과 테스트 출력을 개선하세요.

테스트에서 더 나은 출력

테스트에서 예상 값과 실제 값을 비교할 때는 testify/require.Equal, testify/require.EqualError, testify/require.EqualValues 등을 사용하여 구조체, 오류, 대량의 텍스트 또는 JSON 문서를 비교할 때 가독성을 향상시켜야 합니다:

type TestData struct {
    // ...
}

func FuncUnderTest() TestData {
    // ...
}

func Test(t *testing.T) {
    t.Run("FuncUnderTest", func(t *testing.T) {
        want := TestData{}
        got := FuncUnderTest()

        require.Equal(t, want, got) // 예상 값이 먼저 오고 그 다음 실제 값이 옵니다 ("diff" 의미론)
    })
}

테이블 기반 테스트

테이블 기반 테스트 사용은 동일한 함수에 대한 여러 입력/출력 항목이 있을 때 일반적으로 좋은 관행입니다. 아래는 테이블 기반 테스트를 작성할 때 따를 수 있는 몇 가지 가이드라인입니다. 이러한 가이드라인은 주로 Go 표준 라이브러리 소스 코드에서 추출되었습니다. 이러한 가이드라인을 따르지 않는 것도 괜찮다는 것을 염두에 두세요.

테스트 케이스 정의하기

각 테이블 항목은 입력 및 예상 결과와 함께 완전한 테스트 케이스이며, 때로는 테스트 출력을 쉽게 읽을 수 있도록 테스트 이름과 같은 추가 정보가 포함됩니다.

테스트 케이스의 내용

  • 이상적으로, 각 테스트 케이스는 하위 테스트를 명명하는 데 사용할 수 있는 고유 식별자가 있는 필드를 가져야 합니다. Go 표준 라이브러리에서는 일반적으로 name string 필드입니다.
  • 테스트 케이스에서 주장을 위해 사용되는 것을 지정할 때 want/expect/actual을 사용하세요.

변수 이름

  • 각 테이블 기반 테스트 맵/구조체 슬라이스는 tests라는 이름을 가질 수 있습니다.
  • tests를 반복할 때 익명 구조체는 tt 또는 tc로 참조할 수 있습니다.
  • 테스트의 설명은 name/testName/tn으로 참조할 수 있습니다.

벤치마크

많은 IO나 복잡한 작업을 처리하는 프로그램은 항상 벤치마크를 포함해야 하며, 이를 통해 성능 일관성을 보장할 수 있습니다.

오류 처리

컨텍스트 추가하기

오류를 반환하기 전에 컨텍스트를 추가하는 것이 도움이 될 수 있습니다. 단순히 오류를 반환하는 대신, 이를 통해 개발자는 프로그램이 오류 상태에 들어갔을 때 무엇을 하려고 했는지 이해할 수 있어 디버깅을 훨씬 쉽게 할 수 있습니다.

예를 들어:

// 오류를 감싸기
return nil, fmt.Errorf("get cache %s: %w", f.Name, err)

// 단순히 컨텍스트 추가하기
return nil, fmt.Errorf("saving cache %s: %v", f.Name, err)

컨텍스트를 추가할 때 염두에 두어야 할 몇 가지 사항:

  • 호출자에게 기본 오류를 노출할지 여부를 결정하세요. 노출하고 싶다면 %w를 사용하고, 원하지 않는다면 %v를 사용할 수 있습니다.
  • failed, error, didn't와 같은 단어를 사용하지 마세요. 이미 오류가 있으므로 사용자는 무언가 실패했다는 것을 알고 있으며, 이로 인해 failed xx failed xx failed xx와 같은 문자열이 생성될 수 있습니다. 대신 무엇이 실패했는지 설명하세요.
  • 오류 문자열은 대문자로 시작하지 않거나 구두점 또는 개행으로 끝나지 않아야 합니다. 이를 위해 golint를 사용하여 확인할 수 있습니다.

이름 짓기

  • 센티널 오류를 사용할 때 항상 ErrXxx와 같은 형식으로 이름을 지정해야 합니다.
  • 새로운 오류 유형을 생성할 때는 항상 XxxError와 같은 형식으로 이름을 지정해야 합니다.

오류 유형 검사

  • 오류의 동등성을 확인할 때 ==를 사용하지 마세요. 대신에 errors.Is를 사용하세요 (Go 버전 >= 1.13용).
  • 오류가 특정 유형인지 확인할 때 타입 주장을 사용하지 마세요, 대신에 errors.As를 사용하세요 (Go 버전 >= 1.13용).

오류 작업을 위한 참고자료

CLI

모든 Go 프로그램은 명령줄에서 실행됩니다.

cli는 명령줄 앱을 생성하기 위한 편리한 패키지입니다. 그것은 프로젝트가 데몬이든 간단한 CLI 도구이든 관계없이 사용해야 합니다. 플래그는 환경 변수에 직접 매핑할 수 있으며, 이는 프로그램과의 모든 가능한 명령줄 상호작용을 문서화하고 중앙 집중화합니다. os.GetEnv를 사용하지 마세요, 이는 변수들을 코드 깊숙이 숨깁니다.

라이브러리

LabKit

LabKit은 Go 서비스용 공통 라이브러리를 보관하는 곳입니다. LabKit 사용 예제는 workhorsegitaly를 참조하세요. LabKit은 세 가지 관련 기능을 제공합니다:

이것은 Workhorse, Gitaly 및 아마도 다른 Go 서버에서 일관된 기본 구현에 대한 얇은 추상을 제공합니다. 예를 들어, gitlab.com/gitlab-org/labkit/tracing의 경우 애플리케이션 코드 변경 없이 Opentracing을 사용하다가 Zipkin이나 Go kit 자체의 추적 래퍼로 전환할 수 있으며, 여전히 같은 일관된 구성 메커니즘(즉, GITLAB_TRACING 환경 변수)을 유지합니다.

구조화된 (JSON) 로깅

모든 바이너리는 이상적으로 검색 및 로그 필터링에 도움이 되는 구조화된 (JSON) 로깅 시스템을 갖추어야 합니다. LabKit은 Logrus에 대한 추상을 제공합니다. 우리는 JSON 형식으로 구조화된 로깅을 사용합니다, 왜냐하면 우리의 모든 인프라가 이를 가정하고 있기 때문입니다. Logrus를 사용할 때는 내장된 JSON 포맷터를 사용하여 구조화된 로깅을 활성화할 수 있습니다. 이는 우리의 Ruby 애플리케이션에서 사용하는 동일한 로깅 유형을 따릅니다.

Logrus 사용 방법

Logrus 패키지를 사용할 때 따라야 할 몇 가지 가이드라인이 있습니다:

  • 오류를 출력할 때는 WithError를 사용하세요. 예를 들어, logrus.WithError(err).Error("무언가를 수행하지 못했습니다")와 같습니다.

  • 구조적 로깅을 사용하므로, 코드 경로의 맥락에서 필드를 기록할 수 있습니다. 예를 들어, URI를 기록할 때 WithField 또는 WithFields를 사용할 수 있습니다. 예를 들어, logrus.WithField("file", "/app/go").Info("디렉토리 열기"). 여러 키를 기록해야 하는 경우, WithField를 여러 번 호출하기보다는 항상 WithFields를 사용하는 것이 좋습니다.

맥락

데몬은 장기 실행 응용 프로그램이므로, 취소를 관리하고 불필요한 자원 소비를 피하는 메커니즘이 있어야 합니다(이는 DDoS 취약점으로 이어질 수 있습니다). 차단될 수 있는 함수에서는 Go Context를 사용하고 첫 번째 매개변수로 전달해야 합니다.

Dockerfiles

모든 프로젝트는 프로젝트를 빌드하고 실행하기 위해 리포지토리의 루트에 Dockerfile을 가져야 합니다. Go 프로그램은 정적 바이너리이므로 외부 의존성이 필요하지 않아야 하며, 최종 이미지의 셸은 쓸모가 없습니다. 멀티 스테이지 빌드를 권장합니다:

  • 사용자가 올바른 Go 버전과 의존성으로 프로젝트를 빌드할 수 있게 해줍니다.

  • Scratch에서 파생된 작고 독립적인 이미지를 생성합니다.

생성된 Docker 이미지는 포터블 명령어를 생성하기 위해 Entrypoint에 프로그램을 가져야 합니다. 이렇게 하면 누구나 이미지를 실행할 수 있으며, 매개변수 없이 실행하면 도움말 메시지를 표시합니다(만약 cli가 사용되었다면).

보안 팀 표준 및 스타일 가이드라인

다음은 보안 팀에 특정된 몇 가지 스타일 가이드라인입니다.

코드 스타일 및 형식

커밋하기 전에 goimports -local gitlab.com/gitlab-org를 사용하세요. goimports는 수입 라인 형식화 및 누락된 라인을 추가하고 참조되지 않는 라인을 제거하는 것 외에도, Gofmt를 사용하여 Go 소스 코드를 자동으로 형식화하는 도구입니다. -local gitlab.com/gitlab-org 옵션을 사용하면, goimports는 로컬 참조 패키지를 외부 패키지와 별도로 그룹화합니다. Go 위키의 코드 검토 주석 페이지에서 수입(section)에 대한 자세한 내용을 확인하세요. 대부분의 편집기/IDE는 파일 저장 전후에 명령을 실행할 수 있도록 설정할 수 있으며, 저장할 때마다 goimports -local gitlab.com/gitlab-org가 적용되도록 설정할 수 있습니다.

브랜치 이름 지정

GitLab 브랜치 이름 규칙을 준수해야 하며, 브랜치 이름에는 a-z, 0-9 또는 - 문자만 사용하세요. 이 제한은 브랜치 이름에 슬래시 /와 같은 특정 문자가 포함될 경우 go get이 예상대로 작동하지 않기 때문입니다:

$ go get -u gitlab.com/gitlab-org/security-products/analyzers/report/v3@some-user/some-feature

go get: gitlab.com/gitlab-org/security-products/analyzers/report/v3@some-user/some-feature: invalid version: version "some-user/some-feature" invalid: disallowed version string

브랜치 이름에 슬래시가 포함된 경우, 커밋 SHA를 참조해야 하며 이는 덜 유연합니다. 예를 들어:

$ go get -u gitlab.com/gitlab-org/security-products/analyzers/report/v3@5c9a4279fa1263755718cf069d54ba8051287954

go: downloading gitlab.com/gitlab-org/security-products/analyzers/report/v3 v3.15.3-0.20221012172609-5c9a4279fa12
...

슬라이스 초기화

슬라이스를 초기화할 때, 추가 할당을 피하기 위해 가능한 경우 용량을 제공하세요.

하지 마세요:

var s2 []string
for _, val := range s1 {
    s2 = append(s2, val)
}

하세요:

s2 := make([]string, 0, len(s1))
for _, val := range s1 {
    s2 = append(s2, val)
}

새 슬라이스를 생성할 때 make에 용량을 전달하지 않으면, append는 값을 수용할 수 없을 경우 슬라이스의 백업 배열을 계속적으로 크기를 조정합니다. 용량을 제공하면 할당이 최소화됩니다. prealloc golanci-lint 규칙이 이를 자동으로 확인하는 것이 권장됩니다.

분석기 테스트

일반적인 보안 분석기는 SAST/DAST 스캐너 보고서를 GitLab 보안 보고서로 변환하는 convert 함수를 가지고 있습니다. convert 함수에 대한 테스트를 작성할 때, 분석기 리포지토리의 루트에 testdata 디렉토리를 사용한 테스트 픽스쳐를 사용해야 합니다. testdata 디렉토리는 expectreports라는 두 개의 하위 디렉토리를 포함해야 합니다. reports 디렉토리는 테스트 설정 동안 convert 함수에 전달되는 샘플 SAST/DAST 스캐너 보고서를 포함해야 합니다. expect 디렉토리는 convert가 반환하는 기대하는 GitLab 보안 보고서를 포함해야 합니다. 비밀 탐지에 대한 예제를 참조하세요.

스캐너 보고서가 작고 35라인 미만일 경우, testdata 디렉토리를 사용하기보다는 인라인으로 보고서를 사용해도 괜찮습니다.

테스트 차이

go-cmp 패키지는 테스트에서 대형 구조체를 비교할 때 사용해야 합니다. 이를 통해 두 구조체가 다른 지점에서 특정 차이를 출력할 수 있으며, 테스트 로그에 두 구조체 전체가 인쇄되는 것을 방지할 수 있습니다. 다음은 작은 예시입니다:

package main

import (
  "reflect"
  "testing"

  "github.com/google/go-cmp/cmp"
)

type Foo struct {
  Desc  Bar
  Point Baz
}

type Bar struct {
  A string
  B string
}

type Baz struct {
  X int
  Y int
}

func TestHelloWorld(t *testing.T) {
  want := Foo{
    Desc:  Bar{A: "a", B: "b"},
    Point: Baz{X: 1, Y: 2},
  }

  got := Foo{
    Desc:  Bar{A: "a", B: "b"},
    Point: Baz{X: 2, Y: 2},
  }

  t.Log("reflect comparison:")
  if !reflect.DeepEqual(got, want) {
    t.Errorf("Wrong result. want:\n%v\nGot:\n%v", want, got)
  }

  t.Log("cmp comparison:")
  if diff := cmp.Diff(want, got); diff != "" {
    t.Errorf("Wrong result. (-want +got):\n%s", diff)
  }
}

출력은 대형 구조체를 비교할 때 go-cmp가 얼마나 우수한지를 보여줍니다. 작은 차이로 차이를 쉽게 알아낼 수 있지만, 데이터가 커짐에 따라 금방 복잡해집니다.

  main_test.go:36: reflect comparison:
  main_test.go:38: Wrong result. want:
      {{a b} {1 2}}
      Got:
      {{a b} {2 2}}
  main_test.go:41: cmp comparison:
  main_test.go:43: Wrong result. (-want +got):
        main.Foo{
              Desc: {A: "a", B: "b"},
              Point: main.Baz{
      -               X: 1,
      +               X: 2,
                      Y: 2,
              },
        }

개발 문서로 돌아가기.