정현닷넷 | | 이력서 | 플레이리스트


프로젝트 구조와 관례

Go 프로젝트가 커지면 디렉토리를 어떻게 나눌지 고민이 시작된다. Go는 커뮤니티가 수렴한 관례가 있어서 팀마다 다른 구조를 쓰는 상황이 적다. 코드 포매팅과 린팅도 마찬가지다. 논쟁 대신 도구가 결정한다.

표준 디렉토리 레이아웃

02편에서 간단한 프로젝트 구조를 봤다. 프로젝트 규모가 커지면 다음 관례적 레이아웃으로 발전한다:

myapp/
  cmd/
    server/
      main.go       // 서버 바이너리 진입점
    cli/
      main.go       // CLI 도구 진입점
  internal/
    auth/
      token.go
    user/
      repository.go
      service.go
  pkg/
    validate/
      email.go
  go.mod
  go.sum

각 디렉토리의 역할:

  • cmd/ — 실행 가능한 바이너리의 진입점. 각 하위 디렉토리가 하나의 main 패키지다. 비즈니스 로직은 여기에 두지 않는다. 의존성 조립과 실행만 담당한다.
  • internal/ — 프로젝트 내부에서만 사용하는 패키지. 외부 모듈에서 import할 수 없다.
  • pkg/ — 외부에서 import해도 되는 라이브러리 코드. 다른 프로젝트에서 재사용할 수 있다.

src/index.tscmd/server/main.go에, src/lib/pkg/에, src/modules/internal/에 대응한다고 생각하면 된다. 이 레이아웃이 강제는 아니지만, 대부분의 오픈소스 프로젝트가 따르고 있어 코드를 처음 보는 사람도 구조를 바로 파악할 수 있다.

cmd/ — 여러 바이너리 관리

하나의 모듈에서 여러 실행 파일을 만들 수 있다:

// cmd/server/main.go
package main

import (
    "fmt"
    "myapp/internal/user"
)

func main() {
    svc := user.NewService()
    fmt.Println("starting server with", svc)
}
// cmd/cli/main.go
package main

import (
    "fmt"
    "myapp/internal/user"
)

func main() {
    svc := user.NewService()
    fmt.Println("running cli with", svc)
}

빌드는 경로를 지정한다:

go build -o server ./cmd/server
go build -o cli ./cmd/cli

package.jsonbin 필드와 달리, 각 cmd/ 하위 디렉토리가 독립적인 바이너리를 생성한다.

pkg/ 사용 여부

pkg/ 디렉토리는 논란이 있다. Go 팀 공식 권장이 아니며, "외부에 공개할 코드"라는 의도를 표현하는 관례일 뿐이다. 실제로 프로젝트가 라이브러리가 아닌 애플리케이션이라면 pkg/가 불필요할 수 있다. internal/만으로 충분한 경우가 많다.

소규모 프로젝트에서는 cmd/, internal/, pkg/를 모두 만들 필요가 없다. 파일 몇 개로 구성된 프로젝트에 이 구조를 적용하면 오히려 과도한 추상화다. Go 공식 가이드도 "필요해지면 나눠라"라고 말한다.

internal 패키지 — 접근 제한 메커니즘

11편에서 internal 패키지를 간략히 다뤘다. 여기서 더 자세히 살펴본다.

internal 디렉토리 아래의 패키지는 internal의 부모 디렉토리를 루트로 하는 트리 안에서만 import할 수 있다. 컴파일러가 강제한다:

myapp/
  internal/
    auth/
      token.go      // package auth
  handler/
    user.go          // import "myapp/internal/auth" -- OK
  main.go            // import "myapp/internal/auth" -- OK

같은 모듈 안에서는 자유롭게 import한다. 하지만 다른 모듈에서 import "myapp/internal/auth"를 시도하면:

use of internal package myapp/internal/auth not allowed

컴파일 에러다. 이 제한은 internal이라는 디렉토리 이름만으로 활성화된다. 별도의 설정이 필요 없다.

internal은 중첩할 수도 있다:

myapp/
  service/
    internal/
      cache/        // service/ 하위에서만 import 가능
    handler.go      // import "myapp/service/internal/cache" -- OK
  main.go           // import "myapp/service/internal/cache" -- 에러

service/internal/cacheservice/ 패키지와 그 하위에서만 접근 가능하다. 프로젝트 루트의 main.go에서도 접근할 수 없다.

package.jsonexports 필드나 ESLint 규칙으로 비슷한 제한을 흉내 낼 수 있지만, Go의 internal은 컴파일러 수준에서 강제된다는 점이 근본적으로 다르다.

naming convention — 간결한 이름

Go는 이름을 짧게 짓는 문화가 있다. utils/, helpers/, common/ 같은 이름은 권장되지 않는다.

패키지 이름

// 나쁜 예
package string_utils
package httpHelpers
package common

// 좋은 예
package strings
package http
package auth

규칙:

  • 소문자, 한 단어. 밑줄이나 camelCase를 쓰지 않는다.
  • 패키지가 하는 일을 설명하는 명사를 쓴다.
  • util, helper, common, misc 같은 포괄적 이름을 피한다. 무엇이든 들어갈 수 있는 이름은 결국 모든 것이 들어간다.

utils/string.ts, utils/date.ts, utils/validation.ts처럼 성격이 다른 코드가 한 곳에 쌓이는 대신, 처음부터 strings, time, validate 등 구체적인 패키지로 나눈다.

함수와 변수 이름

패키지명이 컨텍스트를 제공하므로 함수명에서 패키지명을 반복하지 않는다:

// 나쁜 예
package user

func UserCreate() {}  // user.UserCreate -- user가 반복된다
func GetUser() {}     // user.GetUser -- Get 접두사 불필요

// 좋은 예
package user

func Create() {}      // user.Create
func ByID(id int) {}  // user.ByID

import { createUser, getUserById } 같은 긴 이름 대신, 패키지명이 접두사 역할을 하므로 함수명이 짧아진다. http.Get, json.Marshal, fmt.Println 등 표준 라이브러리가 이 패턴을 따른다.

gofmt와 goimports — 포매팅은 논쟁 대상이 아니다

Prettier를 설정할 때 탭이냐 스페이스냐, 세미콜론 유무, 줄 길이, trailing comma 등을 논의한다. Go에서 이런 논의는 없다.

gofmt는 Go에 내장된 코드 포매터다. 설정 옵션이 없다. 모든 Go 코드가 동일한 스타일로 포매팅된다:

gofmt -w .

goimportsgofmt의 상위 호환이다. 포매팅에 더해 import 구문을 자동 정리한다. 사용하지 않는 import를 제거하고, 필요한 import를 추가한다:

go install golang.org/x/tools/cmd/goimports@latest
goimports -w .

VS Code의 Go extension은 저장 시 자동으로 goimports를 실행한다. 대부분의 Go 개발자가 이 설정을 쓰므로, 포매팅은 의식하지 않아도 되는 영역이다.

도구포매팅import 정리설정
gofmtOX없음
goimportsOO없음
PrettierOX다수

"gofmt의 스타일이 마음에 들지 않더라도, gofmt의 스타일이 모두의 스타일이다"는 Go 커뮤니티의 격언이다.

go vet — 내장 정적 분석

go vet은 Go 툴체인에 내장된 정적 분석 도구다. 컴파일러가 잡지 않는 의심스러운 코드를 검출한다:

package main

import "fmt"

func main() {
    fmt.Printf("%d", "hello") // 포맷 인자 불일치
}
go vet ./...
# ./main.go:6:2: fmt.Printf format %d has arg "hello" of wrong type string

go vet이 잡는 대표적인 문제:

  • Printf 포맷 문자열과 인자 타입 불일치
  • 도달할 수 없는 코드
  • 구조체 복사 시 lock 복사
  • 잘못된 struct tag 문법
  • 테스트 함수명 규칙 위반

go vetgo test를 실행할 때 자동으로 함께 실행된다. 별도로 실행하지 않아도 테스트를 돌리면 vet 검사가 수행된다.

ESLint의 기본 규칙과 비슷하지만, 별도 설치 없이 Go에 포함되어 있다.

staticcheck — 사실상 표준 린터

staticcheck은 Go 커뮤니티에서 가장 널리 사용되는 서드파티 정적 분석 도구다. go vet보다 더 많은 규칙을 제공한다:

go install honnef.co/go/tools/cmd/staticcheck@latest
staticcheck ./...

검출 예시:

func process(items []string) {
    for i, _ := range items { // SA4006: _ 불필요
        fmt.Println(i)
    }

    if err := doSomething(); err != nil {
        return
    }
    // S1023: 함수 마지막의 불필요한 return
    return
}

staticcheck의 규칙은 카테고리로 분류된다:

  • SA — 정적 분석. 버그 가능성이 높은 코드.
  • S — 단순화. 더 간결하게 쓸 수 있는 코드.
  • ST — 스타일. 관례에 맞지 않는 코드.
  • QF — 빠른 수정. 자동 수정 가능한 항목.

golangci-lint — 린터 통합 도구

여러 린터를 하나씩 실행하는 것은 번거롭다. golangci-lint는 수십 개의 린터를 통합 실행하는 도구다. ESLint가 플러그인들을 묶어 실행하는 것과 같은 위치다:

go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
golangci-lint run

기본 설정으로도 go vet, staticcheck, errcheck 등 핵심 린터가 활성화된다. .golangci.yml 파일로 설정을 조정한다:

linters:
  enable:
    - govet
    - staticcheck
    - errcheck
    - gosimple
    - ineffassign
    - unused
    - gocritic

linters-settings:
  errcheck:
    check-type-assertions: true

issues:
  exclude-use-default: false

.eslintrc와 같은 역할이지만, parser, plugin, extends, rules를 조합하는 복잡함이 없다. 린터 목록에서 활성화할 항목을 고르면 된다.

Node.jsGo역할
ESLintgolangci-lint린터 통합 실행
ESLint 기본 규칙go vet내장 정적 분석
eslint-plugin-*staticcheck 등추가 린터
Prettiergofmt코드 포매팅
Prettier + ESLintgoimports + golangci-lint포매팅 + 린팅

ESLint + Prettier 충돌 같은 문제가 없다 -- gofmt와 린터가 처음부터 독립적이다.

CI에 린팅 통합

CI에서 린팅을 실행하여 코드 품질을 자동으로 검증할 수 있다. GitHub Actions 예시:

name: lint
on: [push, pull_request]

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: "1.24"
      - uses: golangci/golangci-lint-action@v6
        with:
          version: latest

golangci-lint는 공식 GitHub Action을 제공하므로 설정이 간단하다.

디렉토리 구조, 코드 스타일, 포매팅 도구 모두 커뮤니티가 수렴한 답이 있다. 프로젝트마다 컨벤션을 새로 정하고 문서화하는 비용이 사라지고, 새로운 Go 프로젝트에 합류했을 때 구조를 파악하는 데 걸리는 시간이 짧다.


데이터베이스Makefile과 빌드