Go 표준 및 스타일 가이드

이 문서는 GitLab 프로젝트가 Go 언어를 사용하는 경우의 여러 지침과 모범 사례에 대해 설명합니다.

GitLab은 루비 온 Rails 위에 구축되었지만, 필요한 경우 프로젝트에서 Go를 사용하기도 합니다. Go는 매우 강력한 언어로, 다양한 장점을 가지고 있으며, 디스크/네트워크 액세스, HTTP 요청, 병렬 처리 등이 많이 필요한 프로젝트에 가장 적합합니다. GitLab에서는 루비 온 Rails와 Go를 모두 사용하기 때문에 두 언어 중 어느 것이 작업에 가장 적합한지를 신중하게 평가해야 합니다.

이 페이지는 다양한 경험을 기반으로 Go 가이드라인을 정의하고 구성하는 것을 목표로 합니다. 다양한 프로젝트가 서로 다른 표준으로 시작되었고 여전히 특정 사항을 가질 수 있습니다. 이러한 내용은 각각의 README.md 또는 PROCESS.md 파일에 설명되어 있습니다.

Go 언어 버전

Go 업그레이드 문서 개요에서 GitLab이 Go 이진 지원을 어떻게 관리하고 제공하는지 개요를 제공합니다.

GitLab 컴포넌트가 더 높은 버전의 Go를 필요로 하는 경우, 업그레이드 프로세스를 따라야 합니다. 이는 고객, 팀 또는 컴포넌트에 불이익을 주지 않도록 보장합니다.

가끔은 개별 프로젝트도 여러 버전의 Go를 지원하도록 관리해야 합니다.

의존성 관리

Go는 의존성 관리를 위해 소스 기반 전략을 사용합니다. 의존성은 소스 리포지터리에서 소스 코드로 다운로드됩니다. 이는 보다 일반적인 아티팩트 기반 전략과는 다릅니다. 일반적인 전략에서는 의존성이 소스 리포지터리와는 별도인 패키지 리포지터리에서 아티팩트로 다운로드됩니다.

1.11 이전에는 Go가 버전 관리를 위한 1급 지원을 제공하지 않았습니다. 이 버전에서 Go 모듈과 의미 있는 버전 사용이 소개되었습니다. 1.12에서는 모듈 프록시와 체크섬 데이터베이스가 도입되었는데, 모듈 프록시는 클라이언트와 소스 버전 관리 시스템 간의 중간 역할을 할 수 있고, 체크섬 데이터베이스는 의존성 다운로드의 무결성을 검증하는 데 사용될 수 있습니다.

더 자세한 내용은 Go의 의존성 관리를 참조하십시오.

코드 리뷰

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

리뷰어와 유지보수자는 다음 사항에 유의해야 합니다.

  • defer 함수: 필요할 때 존재 여부를 확인하고, err 확인 이후에 실행되도록 합니다.
  • 의존성을 매개변수로 주입합니다.
  • JSON으로 marshaling할 때 void 구조체를 사용합니다 ([] 대신에 null을 생성함).

보안

보안은 GitLab에서의 최우선 순위입니다. 코드 리뷰에서는 다음과 같은 가능한 보안 위반에 주의해야 합니다.

  • text/template 사용 시 XSS
  • Gorilla를 사용한 CSRF 보호
  • 알려진 취약점이 없는 Go 버전 사용
  • 비밀 토큰 누출 방지
  • SQL 인젝션

프로젝트에서 SAST의존성 스캐닝을 실행하고, 적어도 gosec analyzer를 실행하고, 보안 요구 사항을 준수해야 합니다.

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

리뷰어 찾기

우리의 많은 프로젝트는 전문 유지보수자가 없을 만큼 충분히 작습니다. 그래서 GitLab에는 여러 Go 리뷰어들의 공유 풀이 있습니다. 리뷰어를 찾으려면 핸드북 내 “GitLab” 프로젝트의 “Go” 섹션을 사용하십시오.

이 디렉터리에 자신을 추가하려면, 다음을 당신의 team.yml 파일의 프로필에 추가하고 당신의 매니저에게 검토하고 머지하도록 요청하십시오.

projects:
  gitlab: reviewer go

코드 스타일과 포맷

  • 전역 변수는 피하십시오. 심지어 패키지 내에서도 그렇게 하면 패키지를 여러 번 포함하는 경우 부작용이 발생합니다.
  • 커밋하기 전에 goimports를 사용하십시오. goimports는 임포트 행의 포맷을 바꾸고 불필요한 것들을 제거하며 누락된 것을 추가하는 등의 작업을 수행하는 도구입니다.

    대부분의 편집기/IDE는 파일을 저장하기 전/후에 명령을 실행할 수 있도록 해주는데, goimports를 실행하도록 설정하여 모든 파일에 적용할 수 있습니다.

  • 소스 파일에서 첫 번째 호출자 메서드 아래에 비공개 메서드를 배치하십시오.

자동 린팅

caution
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에 작성하고, 검출된 린팅 이슈를 파일/라인 설명 형식으로 표준 출력에 인쇄
    # `--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의 모든 옵션은 이 예시에서 나열됩니다.

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

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

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

만약 귀하의 Go 프로젝트가 사용자를 위한 도움말 텍스트를 생성한다면, gitaly 프로젝트의 도움말 텍스트 스타일 가이드에서 제시된 조언을 따르는 것을 고려해보세요.

의존성

의존성은 최소한으로 유지되어야 합니다. 새로운 의존성을 도입할 경우에는 승인 지침에 따라 Merge Request에서 논의되어야 합니다.
새로운 의존성들의 보안 상태와 라이선스 호환성을 보장하기 위해 모든 프로젝트에 의존성 스캔을 활성화해야 합니다.

모듈

Go 1.11 이후 버전에서는 Go 모듈이라는 표준 의존성 시스템이 사용 가능합니다. 이는 재현 가능한 빌드를 위해 의존성을 정의하고 잠그는 방법을 제공합니다. 가능한 경우 항상 사용해아 합니다.

Go 모듈이 사용 중인 경우 vendor/ 디렉터리가 없어야 합니다. 대신, Go는 프로젝트를 빌드하는 데 필요한 의존성을 필요할 때 자동으로 다운로드합니다. 이는 루비 프로젝트의 Bundler로 의존성이 처리되는 방식과 일치하며, Merge Request을 더 쉽게 검토할 수 있도록 합니다.

일부 경우에는 다른 프로젝트의 CI 실행을 위한 의존성으로 Go 프로젝트를 빌드할 때와 같이, vendor/ 디렉터리를 제거하면 코드를 반복적으로 다운로드해야 합니다. 이는 속도 제한 또는 네트워크 장애로 인해 때때로 문제가 발생할 수 있기 때문에, 이러한 상황에서는 캐시된 코드를 다운로드해야 할 것입니다.

Go 1.11.4 이전 버전에서 모듈 체크섬에 대한 버그가 있었으므로, 체크섬 불일치 오류를 피하기 위해 이 버전 이상을 사용하도록 주의해야 합니다.

ORM

우리는 GitLab에서 객체-관계 매핑 라이브러리(ORM)를 사용하지 않습니다(루비 온 Rails의 ActiveRecord 제외). 프로젝트는 ORM 없이 서비스로 구성될 수 있습니다. PostgreSQL 데이터베이스와 상호작용하기 위해서는 pgx가 충분합니다.

마이그레이션

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

테스트

테스트 프레임워크

더 풍부한 테스트 도구가 필요한 경우를 제외하고는, 표준 라이브러리에 이미 모든 시작 정보가 제공되기 때문에, 어떠한 특정 라이브러리나 프레임워크를 사용해서는 안됩니다. 더 진보된 테스트 도구가 필요한 경우를 고려할 때, 다음 외부 의존성들이 고려할 가치가 있습니다.

서브테스트

가능한 경우 서브테스트를 사용하여 코드 가독성과 테스트 출력을 향상시켜야 합니다.

테스트 결과 향상

테스트에서 예상 값과 실제 값을 비교할 때, structs, errors, 큰 텍스트 또는 JSON 파일을 비교할 때 가독성을 향상시키기 위해 testify/require.Equal, testify/require.EqualError, testify/require.EqualValues 등을 사용해야 합니다.

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) // 예상 값이 먼저 오고, 실제 값이 그 다음에 오도록 주의하세요 ("차이" 의미론)
    })
}

테이블 주도 테스트

여러 입력/출력 항목을 가지고 있는 경우 테이블 주도 테스트를 사용하는 것은 좋은 실천 방법입니다. 테이블 주도 테스트를 작성할 때 다음 가이드 라인을 따르는 것이 좋습니다. 이러한 가이드 라인은 대부분의 경우 Go 표준 라이브러리 소스 코드에서 추출되었습니다. 합리적인 경우 이러한 가이드 라인을 따르지 않아도 괜찮습니다.

테스트 케이스 정의

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

  • 테스트 내부에서 익명 구조체의 슬라이스를 정의합니다.
  • 테스트 외부에서 익명 구조체의 슬라이스를 정의합니다.
  • 코드 재사용을 위해 명명된 구조체를 사용합니다.
  • map[string]struct{}를 사용합니다.

테스트 케이스 내용

  • 이상적으로, 각 테스트 케이스는 이름 문자열 필드를 자주 사용하여 서브테스트에 대한 이름을 지정해야 합니다.
  • want/expect/actual을 사용하여 단언에 사용되는 테스트 케이스의 내용을 지정하세요.

변수 이름

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

벤치마크

많은 IO 또는 복잡한 작업을 처리하는 프로그램은 항상 성능 일관성을 보장하기 위해 벤치마크를 포함해야 합니다.

에러 처리

컨텍스트 추가

에러를 반환하기 전에 컨텍스트를 추가하는 것이 도움이 될 수 있습니다. 단순히 에러를 반환하는 것이 아니라 프로그램이 에러 상태에 들어갔을 때 무엇을 시도했는지 이해할 수 있도록 돕습니다. 이렇게 하면 디버깅이 훨씬 쉬워집니다.

예를 들어:

// 에러 래핑
return nil, fmt.Errorf("%s 캐시 가져오기: %w", f.Name, err)

// 컨텍스트만 추가
return nil, fmt.Errorf("%s 캐시 저장: %v", f.Name, err)

컨텍스트를 추가할 때 몇 가지 주의할 사항:

  • 호출자에게 기존 에러를 노출할지 여부를 결정하세요. 그렇다면 %w을 사용하고, 그렇지 않다면 %v을 사용할 수 있습니다.
  • ‘실패’, ‘에러’, ‘하지 않았다’와 같은 단어를 사용하지 마십시오. 이는 이미 에러이기 때문에 사용자는 어떤 것이 실패했는지 이미 알고 있으며, 이로 인해 ‘실패한 xx 실패한 xx 실패한 xx’와 같은 문자열이 생길 수 있습니다. 대신 어떤 부분이 실패했는지 설명하세요.
  • 에러 문자열은 대문자로 시작하거나 마침표나 줄 바꿈으로 끝나지 않아야 합니다. 이를 확인하려면 golint를 사용할 수 있습니다.

네이밍

  • sentinel 에러를 사용할 때는 항상 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 포매터를 사용하여 구조화된 로깅을 활성화할 수 있습니다. 이는 우리가 루비 응용 프로그램에서 사용하는 로깅 유형을 따릅니다.

Logrus 사용 방법

Logrus 패키지를 사용할 때 따라야 할 몇 가지 지침이 있습니다.

  • 에러를 출력할 때 WithError를 사용하세요. 예를 들어, logrus.WithError(err).Error("무언가를 수행하는 데 실패했습니다").
  • 구조화된 로깅을 사용하므로 해당 코드 경로의 문맥에서 필드를 로깅할 수 있으며, 예를 들어 WithField 또는 WithFields를 사용하여 요청의 URI와 같은 필드를 로깅할 수 있습니다. 예를 들어, logrus.WithField("file", "/app/go").Info("디렉터리 열기"). 여러 키를 로깅해야 하는 경우 항상 WithFields를 여러 번 호출하는 대신에 한 번에 호출하세요.

컨텍스트

데몬은 장기 실행 응용 프로그램이기 때문에 차단될 수 있는 함수에서 Go 컨텍스트를 사용하고 첫 번째 매개변수로 전달해야 합니다.

Dockerfiles

모든 프로젝트는 프로젝트를 빌드하고 실행하기 위한 루트 디렉터리에 Dockerfile을 가져야 합니다. Go 프로그램은 정적 이진 파일이기 때문에 외부 의존성이 필요하지 않으며, 최종 이미지의 shell은 사용되지 않습니다. Multistage builds을 권장합니다:

  • 이를 통해 사용자는 올바른 Go 버전 및 의존성으로 프로젝트를 빌드할 수 있습니다.
  • Scratch에서 파생된 작고 독립적인 이미지를 생성합니다.

생성된 Docker 이미지는 휴대용 명령을 만들기 위해 프로그램을 Entrypoint에 가져야 합니다. 이렇게 하면 누구나 이미지를 실행할 수 있고, 매개 변수 없이 실행하면 (cli가 사용된 경우) 도움말 메시지가 표시됩니다.

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

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

코드 스타일과 포맷

커밋하기 전에 goimports -local gitlab.com/gitlab-org를 사용하세요. goimports는 Go 소스 코드를 자동으로 포맷하는 도구로, Gofmt를 사용하여 소스 코드를 포맷팅하고, import 라인을 포맷팅하며 누락된 라인을 추가하고 참조되지 않는 라인을 제거합니다. -local gitlab.com/gitlab-org 옵션을 사용하면 goimports는 로컬 레퍼런스된 패키지를 외부 패키지와 별도로 그룹화합니다. 자세한 내용은 Go 위키의 imports 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 고란시-lint 규칙이 자동으로 확인하도록 권장됩니다.

Analyzer 테스트

전통적인 보안 analyzer에는 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)
  }
}

출력은 전체 구조체를 볼 필요 없이 두 구조체가 다른지를 나타냅니다. 이 차이를 파악하는 것이 중요합니다.

  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,
              },
        }

개발 문서로 돌아가기.