콘텐츠로 이동

Backend Conventions (Go)

Umbra 의 Go 코드 작성 규칙. 도메인 무관 언어/스타일 컨벤션을 한 곳에 모은다. 아키텍처 패턴 (Hexagonal, Outbox) 은 별도 가이드 참조.

Why conventions matter

Go 는 언어 자체가 의견 강하지만 프로젝트마다 팀 스타일이 있다. Umbra 는 결제/복구 같은 신뢰성 크리티컬 영역을 다루므로 실수 방지 + 리뷰 속도 를 위해 일관된 패턴을 강제한다.

이 문서의 규칙은 Pablo 의 개인 Go 컨벤션 (luxtra-agent 의 luxtra-go 플러그인) 과 일치하며, CI 에서 golangci-lint 로 일부 자동 검사된다.

Tooling

  • Formattergofumpt (gofmt 의 엄격 버전)
  • Lintergolangci-lint (설정은 .golangci.yml)
  • Importsgoimports -local github.com/luxtradev/Umbra (로컬 import 분리)
  • Test runnergo test
  • Coveragego test -cover
  • Generatorgo generate ./... (sqlc, mock 등)

Pre-commit hook 또는 CI 에서 자동 검증.

File organization

Package layout

  • 패키지명 = 디렉토리명 (Go 표준)
  • 소문자 단어, 가능한 한 짧게: billing, recovery, identity
  • 복수형 지양: members 대신 member (테이블명은 복수 허용)

File naming

  • 소문자, underscore 권장 (Go 관행은 snake_case 아니지만 underscore 허용): service_impl.go, toss_client.go
  • 테스트는 _test.go
  • 파일당 주로 하나의 주요 타입 또는 함수 그룹

Internal vs shared

  • 프로세스 전용: apps/{bot,api,worker}/internal/
  • 도메인 공유: engine/{domain}/
  • 인프라 공유: platform/

internal/ 은 Go 키워드라 외부 import 자동 차단됨. 경계 강제의 무기.

Function rules

35-line limit

함수는 최대 35줄 (공백/주석 포함). 넘으면 분리.

이유:

  • 한 화면에 전체 흐름이 보임
  • 한 함수가 한 가지만 해야 함
  • 예외 처리가 40% 를 차지하면 로직이 모호

예외: DI wiring, test setup 은 60줄까지 허용 (사실상 선언 집합).

Return early

// ❌ Nested
func Do(x int) error {
    if x > 0 {
        // ... 20 lines of logic
        return nil
    }
    return ErrInvalid
}

// ✓ Early return
func Do(x int) error {
    if x <= 0 {
        return ErrInvalid
    }
    // ... 20 lines of logic
    return nil
}

Single responsibility

한 함수 = 한 의도. 혼합된 경우 <verb><noun> 로 이름 분리:

// ❌ 여러 일
func ProcessSubscription(id) error { ... }

// ✓ 명확
func ChargeSubscription(id) error { ... }
func CancelSubscription(id) error { ... }

Parameter limit

  • 파라미터 5개 이하 권장
  • 초과 시 struct 로 묶기:
// ❌
func Create(userID, guildID, planID, billingKeyID uuid.UUID, amount int, orderName string) error

// ✓
type CreateInput struct {
    UserID, GuildID, PlanID, BillingKeyID uuid.UUID
    Amount int
    OrderName string
}
func Create(input CreateInput) error

첫 파라미터는 거의 항상 context.Context, 이는 파라미터 카운트에서 제외.

Naming

Types

  • PascalCase: Subscription, BillingKey
  • Interface 는 -er suffix 또는 서비스 역할: Repository, Publisher, Service

Variables

  • camelCase
  • Receiver 는 1~2자 약어: func (s *Service) ..., func (r *Repo) ...
  • 전역 변수 금지 (상수, var error 는 예외)

Constants

// Grouped
const (
    StatusActive    = "active"
    StatusSuspended = "suspended"
)

// Private prefix
const defaultRetryAttempts = 3

Errors

Err prefix + PascalCase:

var (
    ErrSubscriptionNotFound = errors.New("subscription not found")
    ErrPlanLimitExceeded    = errors.New("plan limit exceeded")
)

Sentinel error + wrapping:

if err != nil {
    return fmt.Errorf("charge subscription: %w", err)
}

%w 로 wrap 해야 errors.Is / errors.As 동작.

Comments

When to comment

  • Exported identifier — 반드시 docstring (// Subscription represents ...)
  • Non-obvious why — 구현이 명백하지 않은 의도 설명
  • Workaround — 우회 이유 + 복구 조건 명시

When NOT to comment

  • 함수 이름으로 충분히 명백한 경우
  • 코드를 반복 서술만 하는 경우
// ❌ 중복
// Set user ID
s.userID = id

// ✓ 이유 설명
// customerKey 는 Toss 가 식별자로 쓰므로 User 삭제 후에도 재사용 금지.
s.customerKey = "user_" + userID.String()

Doc comment style

// Service handles subscription lifecycle: start, renew, change plan, cancel.
//
// All operations are idempotent where possible. See doc/domain/billing.md
// for state machine and flow documentation.
type Service interface {
    ...
}

패키지 doc 은 doc.go 파일에:

// Package billing implements the Core billing domain for Umbra.
//
// Responsibilities:
//   - Billing key management (Toss integration, AES-256-GCM encryption)
//   - Subscription lifecycle (pending, active, past_due, canceled, suspended)
//   - Payment attempts (retry schedule: 24h / 48h / 72h / auto-cancel)
//
// See docs/domain/billing.md for detailed design.
package billing

Language

코드 주석은 English. 한글 주석 금지 (국제적 가독성 + 개발자 채용 대비).

Exception: 비즈니스 용어가 한글에만 있을 때 (예: 카카오페이 특정 field). 이 경우 괄호 번역 병기.

Error handling

원칙

  • Return error, don't panic — 예상 가능한 에러는 반환. panic 은 프로그래머 오류만.
  • Wrap with context — 호출 지점마다 fmt.Errorf("%s: %w", op, err) 로 위치 정보 추가
  • Sentinel for expected — 호출자가 분기해야 할 에러는 sentinel (ErrXxx)
  • Struct for rich info — 상세 필드 필요하면 custom error struct
type TossError struct {
    Code    string
    Message string
    OrderID string
}

func (e *TossError) Error() string {
    return fmt.Sprintf("toss %s: %s (order=%s)", e.Code, e.Message, e.OrderID)
}

점검 순서

// 1. context cancellation
if err := ctx.Err(); err != nil {
    return err
}

// 2. input validation
if input.Amount <= 0 {
    return ErrInvalidAmount
}

// 3. authorization
if !hasPermission(ctx, input.GuildID) {
    return ErrPermissionDenied
}

// 4. business logic
...

로깅 vs 반환

  • 최상위 handler 에서만 로깅 (도메인 서비스는 에러를 반환만)
  • 중간 계층이 logger.Error(err); return err 반복하면 노이즈
// ✓ In HTTP handler
func (h *Handler) CreateSubscription(c echo.Context) error {
    if err := h.svc.Start(...); err != nil {
        h.logger.Error("start subscription", "error", err, "guild_id", gid)
        return errorToHTTP(err)
    }
    return c.JSON(200, ...)
}

// ✗ In domain service — log + return duplicates
func (s *svc) Start(...) error {
    if err := s.toss.Charge(...); err != nil {
        s.logger.Error("charge failed", err) // 불필요
        return err
    }
}

Context

Rules

  • 첫 파라미터는 항상 context.Context (DI wiring 예외)
  • Context 를 struct 에 저장 금지 (argument 로만 전달)
  • Cancellation 존중 — 장시간 작업은 ctx.Done() 체크

Context values

platform/ctx/ 에 key 정의:

package ctx

type actorKey struct{}

func WithActor(ctx context.Context, actor Actor) context.Context {
    return context.WithValue(ctx, actorKey{}, actor)
}

func ActorFrom(ctx context.Context) (Actor, bool) {
    a, ok := ctx.Value(actorKey{}).(Actor)
    return a, ok
}

직접 string key 사용 금지 (충돌 위험).

사용처

  • actor (누가 호출했는지) — middleware 가 주입
  • request_id — tracing 용
  • tenant_id 유사 — 현재는 없지만 향후 가능

Concurrency

Goroutine

  • 유계성 (bounded): 무한 goroutine 금지
  • 반드시 종료 조건 명시 (context 또는 channel 닫기)
  • goroutine leak 위험이면 wait group + context timeout
// ✓ Context 로 종료
func (w *Worker) Run(ctx context.Context) error {
    for {
        select {
        case <-ctx.Done():
            return ctx.Err()
        case t := <-w.queue:
            w.handle(ctx, t)
        }
    }
}

Channel

  • 방향 명시: chan<- Event, <-chan Event
  • 송신자가 close (수신자 X)
  • Unbounded channel 금지 (메모리 leak)

Locking

  • sync.Mutex / sync.RWMutex 최소 scope
  • Lock 안에서 다른 lock 획득 금지 (deadlock 위험)
  • 가능하면 lock-free (channel, atomic) 선호

Temporal workflow 결정성

Temporal workflow 함수는 순수 해야 한다:

// ❌ workflow 내부
time.Now()  // non-deterministic
rand.Int()  // non-deterministic
http.Get(...)  // IO

// ✓
workflow.Now(ctx)
workflow.SideEffect(ctx, func() any { return rand.Int() })
workflow.ExecuteActivity(ctx, FetchData, ...)

자세한 건 temporal-workflow.md.

Logging

slog (standard library)

Umbra 는 log/slog 사용.

logger.Info("subscription started",
    "subscription_id", sub.ID,
    "guild_id", sub.GuildID,
    "plan", sub.PlanCode,
)

Levels

  • Debug — 개발 중 임시 추적
  • Info — 정상 흐름의 주요 이벤트
  • Warn — 비정상이지만 복구 가능
  • Error — 오류, 조치 필요

금지 항목

  • PII (개인정보): email, phone, full name 금지
  • Secret: API key, billing key plaintext 금지
  • Large payload: 수 KB 초과 metric 기반 샘플링

Structured fields

항상 key-value, 문자열 concat 금지:

// ❌ string interp
logger.Info(fmt.Sprintf("user %s charged %d", user.ID, amount))

// ✓ structured
logger.Info("charge completed", "user_id", user.ID, "amount", amount)

Context 자동 주입

미들웨어가 ctx 에서 request_id, actor 를 자동으로 slog attribute 로 추가:

logger := logger.With("request_id", reqID, "actor", actorID)
ctx = ContextWithLogger(ctx, logger)

Testing

Table-driven tests

func TestAddCyclePeriod(t *testing.T) {
    cases := []struct {
        name string
        start time.Time
        cycle string
        want time.Time
    }{
        {"monthly", now, "monthly", now.AddDate(0, 1, 0)},
        {"yearly", now, "yearly", now.AddDate(1, 0, 0)},
    }
    for _, tc := range cases {
        t.Run(tc.name, func(t *testing.T) {
            got := addCyclePeriod(tc.start, tc.cycle)
            if !got.Equal(tc.want) {
                t.Errorf("got %v, want %v", got, tc.want)
            }
        })
    }
}

Assertions

  • 표준 라이브러리 우선 (if got != want { t.Errorf(...) })
  • 복잡한 비교만 testify/assert 허용 (점진 도입)
  • testify/require 는 fail-fast 필요 시 (예: nil check 후 다음 라인 safe)

Mock

  • engine/{domain}/port/mock/ 에 수동 mock (인터페이스 구현)
  • mock 생성기 (mockery 등) 도입은 Phase 2
  • 도메인 서비스 테스트는 모든 port mock, Toss/Discord 호출 없음
func TestService_StartSubscription(t *testing.T) {
    subs := &mockSubRepo{...}
    toss := &mockTossClient{chargeResult: &ChargeResult{...}}
    svc := NewService(subs, toss, ...)

    sub, err := svc.StartSubscription(ctx, input)
    // ...
}

Integration tests

  • test:integration 별도 target
  • 실제 Docker Postgres + Redis + Temporal
  • 개별 테스트마다 DB transaction rollback 으로 격리

Dependency injection

Constructor pattern

type Service struct {
    subs     SubscriptionRepository
    keys     BillingKeyRepository
    toss     TossClient
    crypto   Crypto
    events   EventPublisher
    clock    Clock
    logger   *slog.Logger
}

func NewService(
    subs SubscriptionRepository,
    keys BillingKeyRepository,
    toss TossClient,
    crypto Crypto,
    events EventPublisher,
    clock Clock,
    logger *slog.Logger,
) *Service {
    return &Service{
        subs: subs, keys: keys, toss: toss,
        crypto: crypto, events: events, clock: clock, logger: logger,
    }
}

Wiring

DI 컨테이너 (uber-fx 등) 도입 검토 중 — MVP 는 apps/{process}/cmd/main.go 에서 명시적 wiring.

Interfaces

Defining at the consumer side

Go 관행: 소비하는 쪽에서 interface 정의. engine/billing/port/ 가 자기가 쓸 repository interface 를 정의하고, engine/billing/adapter/persistence/ 가 그 interface 를 구현.

// engine/billing/port/repository.go — consumer
type SubscriptionRepository interface {
    Insert(ctx, sub) error
    GetByID(ctx, id) (*Subscription, error)
}

// engine/billing/adapter/persistence/sqlc/subscription.go — producer
type sqlcSubscriptionRepo struct{ db *pgxpool.Pool }

func (r *sqlcSubscriptionRepo) Insert(ctx, sub) error { ... }
// 암묵적으로 SubscriptionRepository 구현

Small interfaces

3-5 메서드 이하 권장. 큰 interface 는 role 별로 분리:

// ❌ god interface
type SubscriptionRepo interface {
    Insert, Update, Get, List, Count, Delete, Archive, ...
}

// ✓ split by role
type SubscriptionReader interface { Get, List }
type SubscriptionWriter interface { Insert, Update }

JSON

Struct tags

type Subscription struct {
    ID         uuid.UUID `json:"id"`
    GuildID    uuid.UUID `json:"guild_id"`
    Status     string    `json:"status"`
    CanceledAt *time.Time `json:"canceled_at,omitempty"`
}
  • snake_case JSON keys (OpenAPI 와 일관)
  • omitempty 는 nullable / optional 필드에만
  • 내부 전용 필드는 json:"-"

직접 파싱 금지

encoding/json 표준 사용. 대안 라이브러리 (sonic 등) 는 Phase 2 에서 성능 이슈 시 검토.

Time

Always TZ-aware

// ✓ UTC
now := time.Now().UTC()

// ✓ Or KST (한국 고객 UI 용)
kst, _ := time.LoadLocation("Asia/Seoul")
nowKST := time.Now().In(kst)

PostgreSQL TIMESTAMPTZ 에만 저장, TIMESTAMP (no tz) 금지.

Formatting

  • Log/debug: time.RFC3339
  • DB: native timestamp
  • API output: RFC3339 / ISO8601

Testability

Clock 인터페이스로 주입:

type Clock interface {
    Now() time.Time
    Sleep(d time.Duration)
}

type realClock struct{}
func (realClock) Now() time.Time { return time.Now() }

// 테스트에서는 mock clock 주입

SQL queries

자세한 건 database-conventions.md.

핵심 요약:

  • sqlc generated code 사용 (raw SQL 금지, ORM 금지)
  • 쿼리는 db/queries/{domain}/*.sql 에 작성
  • 트랜잭션은 WithTx 래퍼 사용

HTTP handlers

자세한 건 api-conventions.md. 핵심:

  • Echo v4 + swag annotation
  • DTO 검증은 handler 에서
  • 도메인 서비스 호출은 단순 delegation
  • Error 는 HTTP status 로 매핑 (errorToHTTP)

Do / Don't

Do

  • ✅ 35줄 이하 함수
  • ✅ Early return, flat structure
  • ✅ Error wrapping with %w
  • ✅ Structured logging
  • ✅ Context first parameter
  • ✅ Small interfaces, consumer-defined
  • ✅ Table-driven tests
  • ✅ sqlc 활용

Don't

  • ❌ global mutable state
  • ❌ panic in business code
  • time.Now() in workflows
  • ❌ context in struct field
  • ❌ Korean comments
  • fmt.Sprintf in logging
  • ❌ god interfaces
  • ❌ raw SQL in handlers (sqlc 우회)

See also

  • project-structure.md
  • documentation-conventions.md
  • hexagonal-pattern.md
  • database-conventions.md
  • api-conventions.md
  • temporal-workflow.md
  • asynq-jobs.md
  • testing-strategy.md
  • ../adr/0001-language-go.md