Go 개발 스택
의견이 반영된 현대적인 Go 개발 환경 설정. 하나의 관심사에 하나의 도구, 중복 없음.
사용 시기
- 처음부터 새 Go 프로젝트를 시작할 때
- 린팅, 포맷팅, 테스트 인프라를 추가할 때
- Go 서비스 또는 라이브러리의 CI/CD를 설정할 때
- Makefile을 대체할 Justfile을 만들 때
- 데이터베이스 마이그레이션 도구를 추가할 때
- 흩어진 gofmt/govet/staticcheck 호출을 통합 설정으로 마이그레이션할 때
상류(upstream)에서 정기적으로 병합하는 포크에는 적합하지 않습니다. Makefile을 교체하고 gofumpt로 트리를 다시 포맷팅하면 모든 병합 시 충돌이 발생하고 실제 변경 사항이 리포맷 diff에 묻힙니다. 상류의 gofmt 및 빌드 도구는 그대로 유지하고, 검사만 추가하세요.
스택
| 도구 | 버전 | 역할 | 대체 대상 |
|---|---|---|---|
| Go | 1.27+ | 언어, 툴체인, go mod, go fix | - |
| golangci-lint | v2.14+ | 메타 린터 (100+ 린터 + 포맷터 + fmt 명령) | gofmt, govet, staticcheck, errcheck 개별 실행 |
| gofumpt | v0.12+ | 엄격한 포맷터 (gofumpt의 상위 집합, 19개의 기본 규칙) | gofmt |
| gotestsum | v1.13+ | 읽기 쉬운 출력, 감시 모드, JUnit XML을 제공하는 테스트 실행기 | 원시 go test |
| just | 1.58+ | 작업 실행기 | Makefile |
| golang-migrate | v4.20+ | DB 마이그레이션 (CLI + 라이브러리 + embed.FS) | 수동 SQL 스크립트 |
| lefthook | v2.1+ | Git 훅 (단일 바이너리, 병렬 실행) | pre-commit (Python) |
버전 하한선은 중요합니다. golangci-lint는 "Go 버전이 컴파일하는 데 사용된 Go 버전보다 낮거나 같은 Go 버전을 지원합니다" - Go 툴체인보다 오래된 핀은 완전히 실패합니다. Go 1.27 지원은 golangci-lint v2.13.0에 도입되었으므로, Go 1.27 프로젝트의 하한선은 v2.13입니다. 이 스킬은 v0.12.0 gofumpt를 번들로 제공하는 첫 번째 릴리스인 v2.14.0을 고정합니다. 최근에 두 개의 하한선이 더 변경되었습니다: gofumpt v0.12.0은 "Go 1.27의 gofmt를 기반으로 하며 Go 1.26 이상이 필요"하고, lefthook의 go install 경로는 이제 Go 1.26+를 요구합니다.
빠른 시작: 새 프로젝트
# 1. 모듈 생성
mkdir myapp && cd myapp
go mod init github.com/yourorg/myapp
# 2. 디렉토리 구조 생성
mkdir -p cmd/myapp internal migrations
# 3. golangci-lint를 모듈 도구가 아닌 바이너리로 설치합니다 (아래 참고 참조)
curl -sSfL https://golangci-lint.run/install.sh | sh -s -- -b $(go env GOPATH)/bin v2.14.0
# 4. 나머지는 go.mod (Go 1.24+ tool 디렉티브)에 추적합니다. 버전을 고정하세요 - @latest는 사용하지 마세요.
# @latest는 모든 CI 실행에서 도구를 다시 컴파일하고 머신 간에 드리프트를 발생시킵니다.
go get -tool mvdan.cc/[email protected]
go get -tool gotest.tools/[email protected]
# golang-migrate는 빌드 태그가 필요하므로 직접 설치합니다
go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/[email protected]
# 5. 설정 파일 생성 (아래 템플릿 참조)
# 6. 실행: just check
golangci-lint를 tools 패턴으로 설치하지 마십시오. 상류에서 명시적으로 다음과 같이 말합니다: "go install/go get, "tools pattern", tool 명령/디렉티브 설치는 작동이 보장되지 않습니다. 바이너리 설치를 권장합니다." 공유 저장소에서 이것이 중요한 이유는 종속성 오염 때문입니다 - "도구의 종속성은 다른 도구나 프로젝트의 종속성을 수정할 수 있습니다." 반드시 go.mod에 있어야 한다면, 자체 -modfile 뒤에 격리하세요 - golangci-lint 참조를 참조하십시오.
go get -tool은 추적하고; go tool은 실행합니다. tool 디렉티브는 go.mod에 종속성을 기록하지만 PATH에는 아무것도 넣지 않습니다. 툴체인을 통해 호출하거나 (go tool gofumpt -l ., go tool gotestsum --format testname) go install tool을 한 번 실행하여 $(go env GOPATH)/bin을 채우십시오. 아래 Justfile은 바이너리만 호출하므로 go install tool 경로 (또는 Homebrew를 통한 시스템 설치 - 그러나 Homebrew는 "golangci-lint의 바이너리를 빌드하는 데 예상치 못한 Go 버전을 사용할 수 있으며" 버전을 고정할 수 없습니다)를 가정합니다. go tool은 현재 디렉토리의 모듈을 기준으로 확인됩니다 - "추가 도구는 현재 모듈의 go.mod에 정의될 수 있습니다" - 따라서 모노레포에서는 레시피가 [working-directory(...)]를 설정하지 않으면 go: no such tool "..." 오류와 함께 실패합니다.
첫 번째 커밋 전에 알아야 할 두 가지 Go 명령 동작:
go mod init는 1.N 툴체인에서go 1.(N-1).0을 쓰고1.N을 쓰지 않습니다 - "1.N.X 버전의 툴체인을 사용하여go mod init을 실행하면go.mod파일에 Go 버전go 1.(N-1).0을 지정합니다." 1.N 언어 기능을 원한다면 디렉티브를 의도적으로 높이십시오.go.mod에toolchain go1.27.1줄을 설정하지만, 이것이 핀이 아니라 하한선임을 알아야 합니다: 기본GOTOOLCHAIN=auto에서go명령은 "<tname>이 기본 Go 툴체인보다 최신인 경우에만" 전환되므로, 더 새로운 로컬go가 우선합니다. 정확한 툴체인을 위해서는GOTOOLCHAIN=go1.27.1(CI 또는go env -w)을 설정하십시오;GODEBUG=toolchaintrace=1 go version은 어떤 것이 선택되었는지 보여줍니다..0이 아닌 현재 패치 수준에서 줄을 유지하십시오: 자체go가 더 오래된 모든 머신에서govulncheck가 표준 라이브러리 권고를 검사하는go를 선택하므로, 오래된 패치는 CI를 자체적으로 적색 경고로 만듭니다 - 아래 Footguns를 참조하십시오.
.golangci.yml
version: "2"
run:
timeout: 5m
build-tags:
- integration # 그렇지 않으면 Justfile의 integration 태그 뒤에 있는 파일은 린트되지 않습니다
linters:
default: standard
enable:
- bodyclose
- copyloopvar
- dupl
- durationcheck
- err113
- errname
- errorlint
- exhaustive
- exptostd
- fatcontext
- goconst
- gocritic
- gosec
- intrange
- misspell
- modernize
- musttag
- nakedret
- nestif
- nilerr
- noctx
- nolintlint
- nonamedreturns
- perfsprint
- prealloc
- revive
- sqlclosecheck
- testifylint
- thelper
- unconvert
- unparam
- usestdlibvars
- usetesting
- wastedassign
- whitespace
- wrapcheck
settings:
govet:
enable:
- shadow
gocritic:
enabled-checks:
- nestingReduce
revive:
enable-all-rules: true
rules:
# enable-all-rules는 `unhandled-error`를 켜는데, 이것은 main 함수에서 `fmt.Println`을 플래그합니다.
# enable-all-rules 하에서 규칙의 `arguments`는 무시됩니다 (규칙이 두 번 등록됨),
# 따라서 허용 목록은 여기서 작동하지 않습니다 - `disabled`만 적용됩니다.
- name: unhandled-error
disabled: true
errcheck:
check-type-assertions: true
exclusions:
generated: strict
presets:
- comments
- std-error-handling
- common-false-positives
rules:
- path: _test\.go
linters:
- errcheck
- dupl
- gosec
- wrapcheck
formatters:
enable:
- gofumpt
- goimports
settings:
gofumpt:
# 규칙을 개별적으로 선택합니다. `extra-rules: true`는 더 이상 사용되지 않으며,
# 또한 gofumpt 자체가 논란의 여지가 있다고 강등시킨 `balance_calls`를 켭니다.
extra:
group-params: true
clothe-returns: true
balance-calls: false
exclusions:
generated: strict
paths:
- vendor/
output:
formats:
text:
path: stdout
print-linter-name: true
colors: true
sort-order:
- linter
- file
show-stats: true
Justfile
set shell := ["bash", "-euo", "pipefail", "-c"]
set dotenv-load
binary := "myapp"
[private]
default:
@just --list --unsorted
# ── 코드 품질 ──────────────────────────────────────────
# 모든 Go 코드 포맷팅
[group('quality')]
fmt:
golangci-lint fmt ./...
# 수정 없이 포맷팅 확인 (CI-안전)
[group('quality')]
fmt-check:
golangci-lint fmt --diff ./...
# 린터 실행
[group('quality')]
lint:
golangci-lint run ./...
# 자동 수정과 함께 린터 실행
[group('quality')]
lint-fix:
golangci-lint run --fix ./...
# 취약점 검사 실행
[group('quality')]
vuln:
govulncheck ./...
# ── 테스트 ───────────────────────────────────────────────
# 경쟁 감지와 함께 모든 테스트 실행
[group('test')]
test *args="./...":
gotestsum --format testname -- -race {{ args }}
# 커버리지와 함께 테스트 실행
[group('test')]
test-cov:
gotestsum --format testname -- -race -coverprofile=coverage.out -covermode=atomic ./...
go tool cover -func=coverage.out
# 브라우저에서 커버리지 보고서 열기
[group('test')]
coverage: test-cov
go tool cover -html=coverage.out
# 통합 테스트 실행
[group('test')]
test-integration:
gotestsum --format testname -- -race -tags=integration ./...
# 개발 중 테스트 감시
[group('test')]
test-watch:
gotestsum --watch --watch-clear --format testname
# 벤치마크 실행
[group('test')]
bench:
go test -bench=. -benchmem ./...
# ── 빌드 ─────────────────────────────────────────────────
# 바이너리 빌드
[group('build')]
build:
go build -o {{ binary }} ./cmd/{{ binary }}
# 최적화된 릴리스 바이너리 빌드
[group('build')]
build-release:
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o {{ binary }} ./cmd/{{ binary }}
# ── 종속성 ──────────────────────────────────────────
# 모듈 정리 및 확인
[group('deps')]
tidy:
go mod tidy
go mod verify
# go.mod/go.sum가 정리되지 않은 경우, 건드리지 않고 실패 (CI-안전)
[group('deps')]
tidy-check:
go mod tidy -diff
# 코드 생성기 실행
[group('deps')]
generate:
go generate ./...
# ── 데이터베이스 ──────────────────────────────────────────────
# 대기 중인 모든 마이그레이션 적용
[group('db')]
migrate-up:
migrate -path migrations -database "$DATABASE_URL" up
# 마지막 마이그레이션 되돌리기
[group('db')]
migrate-down:
migrate -path migrations -database "$DATABASE_URL" down 1
# 새 마이그레이션 생성
[group('db')]
migrate-create name:
migrate create -ext sql -dir migrations -seq {{ name }}
# ── CI ────────────────────────────────────────────────────
# 전체 CI 게이트 (포맷 확인 + 린트 + 테스트)
[group('ci')]
check: fmt-check lint test
@echo "All checks passed"
# 빌드 아티팩트 정리
[group('ci')]
clean:
go clean
rm -f {{ binary }} coverage.out
check는 의도적으로 vuln을 제외합니다: govulncheck는 네트워크를 호출하며, 취약점 데이터베이스 업데이트로 인해 건드린 것이 없는데도 게이트가 빨간색으로 변할 수 있습니다. 대신 자체 CI 작업으로 실행하십시오. golangci-lint run은 활성화된 포맷터도 실행하고 해당 문제를 보고하므로 fmt-check는 해당 검사를 중복합니다 - 읽기 쉬운 diff를 위해 유지하십시오.
Lefthook 구성
Lefthook은 Go 프로젝트에서 pre-commit보다 선호됩니다 - 단일 Go 바이너리이며, 병렬로 훅을 실행하고 Python이 필요 없습니다.
go install github.com/evilmartians/lefthook/[email protected] # Go 1.26+ 필요
lefthook install
# lefthook.yml
assert_lefthook_installed: true # 모든 규칙을 건너뛰는 대신 큰 소리로 실패
pre-commit:
piped: true # 빠른 실패 - 첫 번째 실패 작업에서 중지
commands:
fmt:
glob: "*.go"
run: golangci-lint fmt {staged_files}
stage_fixed: true
lint:
glob: "*.go"
# `golangci-lint run`에 파일 목록을 직접 전달하지 마십시오: 두 디렉토리에 걸친
# 목록은 완전히 거부되고, 다중 파일 패키지의 단일 파일은 "undefined:" 타입체크 오류를 보고합니다.
# 대신 패키지를 린트하십시오.
run: printf '%s\n' {staged_files} | xargs -n1 dirname | sort -u | xargs golangci-lint run --fix
stage_fixed: true
mod-tidy:
glob: "*.{go,mod,sum}"
run: go mod tidy
pre-push:
commands:
test:
run: go test -race ./...
piped: true는 빠른 실패(fail-fast)이지 순서 지정이 아닙니다 - lefthook은 "기본적으로 명령과 스크립트를 순차적으로 실행"하며, piped는 "명령과 스크립트 중 하나가 실패하면 실행을 중지"합니다. parallel: true와 결합할 수 없습니다.
jobs: (lefthook 1.10.0에서 추가됨)는 commands:/scripts: 분할과 함께 더 새로운 기본 요소입니다 - "Jobs는 명령과 스크립트를 모두 지원하는 작업을 정의하는 유연한 방법을 제공합니다. Jobs는 고급 흐름 제어를 위해 그룹화할 수 있습니다." commands:는 더 이상 사용되지 않으며 완전히 문서화된 상태로 유지됩니다; 그룹화, 중첩된 흐름 제어 또는 하나의 훅에서 인라인 명령과 스크립트의 혼합이 필요할 때 jobs:를 사용하십시오.
연결할 가치가 있는 네 가지 추가 사항:
- 위의
assert_lefthook_installed: true는 아래의 휴면 상태 함정에 대한 해독제입니다: "lefthook실행 파일을 $PATH에서 찾을 수 없으면 실패(종료 코드 1)". - CI에서
lefthook validate는 잘못된lefthook.yml이 훅을 자동으로 비활성화하기 전에 포착합니다;lefthook dump는 훅이 작성된 대로 동작하지 않을 때 병합된 유효 구성을 출력합니다. - gitignore된
lefthook-local.yml을 사용하면 개발자가 팀원에게 강요하지 않고 작업을 추가하거나 건너뛸 수 있습니다 - "이것은 팀원에게 강요하지 않고 로컬에서 lefthook을 사용하려는 경우 유용합니다." - 모노레포에서는 각 작업에 해당 모듈 디렉토리를 가리키는
root:를 제공하십시오; 그렇지 않으면go mod tidy와go tool이 저장소 루트에서 실행되어 실패합니다. mod-tidy및 lint 작업에skip: [merge, rebase]를 사용하면 병합 또는 리베이스가 진행되는 동안 이루어진 커밋에서 해당 작업이 제외되므로, 충돌 해결 시 tidy 재작성 또는--fix편집도 함께 스테이징되지 않습니다.
pre-push 테스트 스위트는 CI에서 이미 실행하는 것을 반복합니다. 시간이 몇 분 걸리면 삭제하거나 변경된 패키지로 범위를 좁히십시오 - 그렇지 않으면 개발자는 LEFTHOOK=0으로 푸시하는 법을 배웁니다. 훅에서 실행되는 테스트는 git의 훅 환경(GIT_DIR, GIT_INDEX_FILE, GIT_WORK_TREE)도 상속하므로 t.TempDir()에서 git init 또는 git commit을 실행하는 테스트 도우미는 실제 저장소에서 작동합니다 - cmd.Env에서 해당 변수를 지우십시오.
충돌 없는 git merge는 pre-commit을 실행하지 않습니다: git은 대신 pre-merge-commit을 호출하고, 충돌이 있는 병합을 git commit으로 완료할 때만 pre-commit을 실행합니다. pre-merge-commit: 키 아래에 검사를 미러링하거나, 병합이 린트되지 않은 상태로 들어옵니다.
훅 명령은 호출자의 PATH에서 도구를 확인하므로, 누락된 바이너리는 exit 127과 함께 커밋을 실패시킵니다. go tool <name>(tool 디렉티브)로 실행하면 훅을 빌드만큼 재현 가능하게 만듭니다.
베타이지만 알아두면 좋은 것: ai:는 동일한 파일에 LLM 에이전트 훅을 선언합니다 - "lefthook install 동안 lefthook은 에이전트가 특정 이벤트에서 lefthook run <hook>을 호출할 수 있도록 공급자별 설정 파일을 생성합니다."
DB 마이그레이션
새 마이그레이션 생성
just migrate-create create_users_table
# migrations/20240101000001_create_users_table.up.sql
# migrations/20240101000001_create_users_table.down.sql 생성됨
마이그레이션 적용
DATABASE_URL 환경 변수 설정 (Justfile은 .env 파일을 로드합니다):
export DATABASE_URL="postgres://localhost:5432/mydb?sslmode=disable"
just migrate-up # 모든 대기 마이그레이션 적용
just migrate-down # 마지막 마이그레이션 되돌리기
Go 코드에서 마이그레이션
golang-migrate는 embed.FS를 통해 컴파일된 바이너리에 마이그레이션을 포함하는 것을 지원합니다:
package main
import (
"database/sql"
"embed"
"log"
"github.com/golang-migrate/migrate/v4"
_ "github.com/golang-migrate/migrate/v4/database/postgres"
"github.com/golang-migrate/migrate/v4/source/iofs"
)
//go:embed migrations/*.sql
var migrationsFS embed.FS
func main() {
db, err := sql.Open("postgres", "postgres://localhost:5432/mydb?sslmode=disable")
if err != nil {
log.Fatal(err)
}
source, err := iofs.New(migrationsFS, "migrations")
if err != nil {
log.Fatal(err)
}
m, err := migrate.NewWithInstance("iofs", source, "postgres", db)
if err != nil {
log.Fatal(err)
}
if err := m.Up(); err != nil && err != migrate.ErrNoChange {
log.Fatal(err)
}
}
함정
golangci-lint 버전 고정
golangci-lint를 항상 위의 설치 스크립트를 통해 바이너리로 설치하거나 CI의 특정 릴리스 페이지에서 다운로드하십시오. go install을 사용하면 go 자체 버전에 따라 빌드가 달라질 수 있으며, 실패할 수 있습니다.
go.mod에서 go 지시문
Go 1.21+부터 go.mod의 go 줄은 필수 최소 Go 버전을 선언합니다. go 1.22.0으로 설정하면 패키지가 Go 1.22 기능을 사용할 수 있지만, 툴체인 자체는 더 낮을 수 있습니다. CI와 일치하게 유지하십시오.
lefthook 휴면 상태
lefthook install은 한 번 실행해야 합니다. 그렇지 않으면 아무 일도 일어나지 않습니다. 저장소가 복제되고 개발자가 커밋하면 린트가 실행되지 않습니다. CI에서 lefthook validate를 실행하고 CI가 훅을 신뢰하는 경우 README에 lefthook install 단계를 문서화하십시오.
포맷과 린트의 상호 작용
golangci-lint fmt와 golangci-lint run은 모두 gofumpt를 실행합니다. lint가 실패하기 전에 fmt를 실행하면 두 통과를 모두 얻을 수 있지만 lint-fix는 한 번에 둘 다 처리합니다. Justfile은 명확성을 위해 fmt와 lint를 분리합니다.
커버리지 모드
CI를 위해 -covermode=atomic을 사용하십시오 (race 탐지기 필요). 로컬의 경우 -covermode=count가 더 빠릅니다.
gotestsum 감시 모드
gotestsum --watch는 파일 시스템 이벤트를 사용합니다. Linux에서는 inotify 한도가 감시할 파일 수보다 낮으면 실패할 수 있습니다. fs.inotify.max_user_watches를 늘리십시오.
여러 Go 버전
go.mod에 toolchain 지시문을 추가하면 go 명령이 필요한 경우 자동으로 올바른 Go 버전을 다운로드할 수 있습니다. CI와 개발 환경 간의 불일치를 방지합니다.
go mod tidy -diff # go.mod와 go.sum이 정리되었는지 확인 (CI-안전)
CI 통합
GitHub Actions 예시
name: Go
on:
push:
branches: [main]
pull_request:
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.27"
cache: true
- name: Install golangci-lint
run: curl -sSfL https://golangci-lint.run/install.sh | sh -s -- -b $(go env GOPATH)/bin v2.14.0
- name: Check
run: just check
- name: Vulnerability check
run: govulncheck ./...
VULN
vulnerability:
if: github.event_name == 'schedule'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.27"
- name: govulncheck
run: govulncheck ./...