Go 표준 및 스타일 가이드

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

GitLab은 Ruby on Rails을 기반으로 만들어졌지만, 필요에 따라 Go를 사용하기도 합니다. Go는 많은 장점을 가진 매우 강력한 언어로, 디스크/네트워크 액세스(IO), HTTP 요청, 병렬 처리 등이 많은 프로젝트에 가장 적합합니다. GitLab에서는 Ruby on Rails와 Go 둘 다 사용하고 있기 때문에 두 언어 중 어떤 것이 작업에 가장 적합한지 신중히 평가해야 합니다.

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

Go 언어 버전

Go 업그레이드 문서는 GitLab이 Go 바이너리 지원을 관리하고 배포하는 방법에 대한 개요를 제공합니다.

GitLab 구성 요소가 더 높은 버전의 Go를 필요로 하는 경우, 업그레이드 과정을 따라 현상이 없도록 해야 합니다. 이로 인해 고객, 팀 또는 구성 요소에 부정적인 영향을 미치지 않도록 합니다.

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

의존성 관리

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

Go는 1.11 버전 이전에는 버전 관리를 위한 일급 지원을 제공하지 않았습니다. 이 버전에서 Go 모듈과 semantic versioning을 도입했습니다. Go 1.12에서는 모듈 프록시와 체크섬 데이터베이스를 도입했는데, 모듈 프록시는 클라이언트와 소스 버전 관리 시스템 간의 중간 단계로 사용될 수 있으며, 체크섬 데이터베이스는 의존성 다운로드의 무결성을 확인하는 데 사용할 수 있습니다.

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

코드 리뷰

우리는 Go 코드 리뷰 코멘트의 일반 원칙을 준수합니다.

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

  • defer 함수: 필요한 경우에 존재 여부를 확인하고 err 확인 후에 사용합니다.
  • 매개변수로 의존성을 주입합니다.
  • JSON 직렬화 시 null 대신 []를 생성하는 대신 null을 생성하도록 void 구조체를 사용합니다.

보안

보안은 GitLab에서 가장 중요한 사항입니다. 코드 리뷰 중에는 코드에서 발생할 수 있는 가능한 보안 위협에 유의해야 합니다:

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

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

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

리뷰어 찾기

우리의 많은 프로젝트는 전문 유지보수자를 두지 않을 정도로 작을 수 있습니다. 이것이 바로 GitLab에서 공유하는 Go 리뷰어 풀이 있는 이유입니다. 리뷰어를 찾으려면 핸드북의 “Engineering Projects” 페이지의 “GitLab” 프로젝트에서 “Go” 섹션을 참조하십시오.

이 목록에 자신을 추가하려면 다음을 당신의 team.yml 파일 프로필에 추가하고 당신의 매니저에게 검토하고 병합하도록 요청하십시오.

projects:
  gitlab: reviewer go

코드 스타일과 형식

  • 전역 변수를 피합니다. 패키지에서도 피합니다. 전역 변수를 사용하면 패키지가 여러 번 포함될 경우 부작용이 발생합니다.
  • 커밋 전에 goimports를 사용합니다. goimports 는 import 라인을 포매팅하고 누락된 import 라인을 추가하고 참조되지 않는 라인을 제거하는 방식으로 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에 작성하고 린팅 이슈를 stdout에 출력합니다: path/to/file:line description
    # `--issues-exit-code 0`을 제거하거나 linting 이슈가 감지되면 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의 모든 옵션은 이 예제에 나열되어 있습니다.

한 번 recursive includes가 사용 가능해지면 이렇게 분석기와 같은 작업 템플릿을 공유할 수 있습니다.

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

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

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

종속성

최소한의 종속성을 유지해아 합니다. 새로운 종속성을 도입하는 경우에는 승인 가이드라인에 따라 병합 요청에서 논의되어야 합니다. 모든 프로젝트에 의존성 스캔을 활성화하여 새로운 종속성의 보안 상태와 라이선스 호환성을 보증해야 합니다.

모듈

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

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

일부 경우, 다른 프로젝트의 CI 실행을 위한 종속성으로 Go 프로젝트를 빌드하는 경우 vendor/ 디렉토리를 제거하면 코드를 반복적으로 다운로드해야 하므로 속도 제한이나 네트워크 장애로 인한 일시적 문제가 발생할 수 있습니다. 이러한 경우에는 다운로드한 코드를 캐시하는 것이 좋습니다.

Go 1.11.4 이전의 Go 버전에서는 모듈 체크섬 오류가 발생할 수 있으므로 이러한 오류를 피하려면 적어도 이 버전을 사용해야 합니다.

ORM

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

마이그레이션

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

테스트

테스트 프레임워크

테스트를 위한 특정 라이브러리나 프레임워크를 사용해서는 안됩니다. 표준 라이브러리가 이미 시작할 수 있는 모든 기능을 제공합니다. 더 정교한 테스트 도구가 필요한 경우 아래의 외부 종속성을 고려할 가치가 있습니다.

Subtests

가능한 경우 Subtests를 사용하여 코드 가독성과 테스트 출력을 향상시키세요.

테스트에서 더 나은 출력

테스트에서 예상 및 실제 값 비교 시, 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) // 예상 값이 먼저 오고, 그 다음에 실제 값이 온다는 점에 유의하세요 ("차이" 의미론)
    })
}

테이블 기반 테스트

동일한 함수에 대한 여러 입력/출력 항목이 있는 경우 테이블 기반 테스트를 사용하는 것이 일반적으로 좋은 실천법입니다. 테이블 기반 테스트를 작성할 때 다음과 같은 지침을 따를 수 있습니다. 이러한 지침은 대부분의 경우 Go 표준 라이브러리 소스 코드에서 추출되었습니다. 합리적인 경우 이러한 지침을 따르지 않아도 상관 없음을 염두에 두세요.

테스트 케이스 정의

각 테이블 항목은 입력 및 예상 결과뿐만 아니라 테스트 출력을 쉽게 읽을 수 있도록 테스트 이름과 같은 추가 정보를 가진 완전한 테스트 케이스입니다.

  • 테스트 내부에 익명 구조체의 슬라이스를 정의하고 있습니다(링크 참조).
  • 테스트 외부에 익명 구조체의 슬라이스를 정의하고 있습니다(링크 참조).
  • 코드 재사용을 위해 명명된 구조체를 사용하고 있습니다(링크 참조).
  • map[string]struct{}을 사용하고 있습니다(링크 참조).

테스트 케이스 내용

  • 이상적으로는 각 테스트 케이스마다 고유 식별자 필드가 있어야 하며 서브테스트의 이름 짓기에 사용합니다. Go 표준 라이브러리에서는 일반적으로 name string 필드를 사용합니다.
  • 어설션에 사용되는 경우에는 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를 사용하여 확인할 수 있습니다.

네이밍

  • 심각한 오류를 사용할 때 항상 ErrXxx와 같이 이름 짓어야 합니다.
  • 새로운 오류 유형을 작성할 때 항상 XxxError와 같이 이름 짓어야 합니다.

오류 유형 확인

  • 오류 동등성을 확인하려면 ==를 사용하지 말고, 대신에 (Go 버전이 1.13 이상인 경우) errors.Is를 사용하세요.
  • 오류가 특정 유형인지 확인하려면 타입 단언을 사용하지 말고, 대신에 (Go 버전이 1.13 이상인 경우) errors.As를 사용하세요.

오류 처리 작업을 위한 참고 자료

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("Failed to do something").
  • 구조화된 로깅을 사용하기 때문에 WithFieldWithFields를 사용하여 해당 코드 경로의 컨텍스트에서 필드를 기록할 수 있습니다. 예: logrus.WithField("file", "/app/go").Info("Opening dir"). 여러 키를 기록해야 하는 경우 항상 WithFields를 여러 번 호출하는 대신 한 번 호출하세요.

컨텍스트

데몬은 장기 실행되는 애플리케이션들이므로 취소를 관리하고 불필요한 리소스 소비를 피해야 합니다(DDoS 취약점을 초래할 수 있음). Go 컨텍스트를 사용하여 블록될 수 있는 함수에 적용하고 첫 번째 매개변수로 전달해야 합니다.

Dockerfile

모든 프로젝트는 저장소의 루트에 Dockerfile을 가져야 하며, 프로젝트를 빌드하고 실행해야 합니다. Go 프로그램은 정적 이진 파일이기 때문에 외부 종속성이 없어야 하며, 최종 이미지에 있는 셸은 쓸모가 없어야 합니다. Multistage builds를 권장합니다:

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

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

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

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

코드 스타일 및 형식

커밋 전에 goimports -local gitlab.com/gitlab-org를 사용하세요. goimports는 import 라인을 포맷팅하고 누락된 라인을 추가하거나 참조되지 않는 라인을 제거하는 것 외에도 Go 소스 코드를 자동으로 포맷하는 도구입니다. -local gitlab.com/gitlab-org 옵션을 사용하면 goimports가 로컬 참조 패키지를 외부 패키지와 별도로 그룹화합니다. 자세한 내용은 Go 위키의 코드 리뷰 코멘트 페이지의 import 섹션를 참조하세요. 대부분의 편집기/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 고란치-린트 룰을 추천합니다.

분석기 테스트

일반적인 보안 분석기에는 SAST/DAST 스캐너 보고서를 GitLab Security 보고서로 변환하는 convert 함수가 있습니다. convert 함수의 테스트를 작성할 때에는 분석기 리포지토리의 루트에 testdata 디렉터리를 사용하여 테스트 픽스처를 활용해야 합니다. testdata 디렉터리에는 두 개의 하위 디렉터리가 있어야 합니다: expectreports. reports 디렉터리에는 테스트 설정 중 convert 함수로 전달되는 샘플 SAST/DAST 스캐너 보고서가 포함되어야 합니다. expect 디렉터리에는 convert가 반환하는 예상된 GitLab Security 보고서가 포함되어야 합니다. 예시를 위해 Secret Detection을 참조하세요.

스캐너 보고서가 작고(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,
              },
        }

개발 문서로 돌아가기