Backend Conventions (Go)¶
Umbra 의 Go 코드 작성 규칙. 도메인 무관 언어/스타일 컨벤션을 한 곳에 모은다. 아키텍처 패턴 (Hexagonal, Outbox) 은 별도 가이드 참조.
Why conventions matter¶
Go 는 언어 자체가 의견 강하지만 프로젝트마다 팀 스타일이 있다. Umbra 는 결제/복구 같은 신뢰성 크리티컬 영역을 다루므로 실수 방지 + 리뷰 속도 를 위해 일관된 패턴을 강제한다.
이 문서의 규칙은 Pablo 의 개인 Go 컨벤션 (luxtra-agent 의 luxtra-go 플러그인) 과 일치하며, CI 에서 golangci-lint 로 일부 자동 검사된다.
Tooling¶
- Formatter —
gofumpt(gofmt 의 엄격 버전) - Linter —
golangci-lint(설정은.golangci.yml) - Imports —
goimports -local github.com/luxtradev/Umbra(로컬 import 분리) - Test runner —
go test - Coverage —
go test -cover - Generator —
go 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 는
-ersuffix 또는 서비스 역할: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:
%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 로 추가:
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.Sprintfin logging - ❌ god interfaces
- ❌ raw SQL in handlers (sqlc 우회)
See also¶
project-structure.mddocumentation-conventions.mdhexagonal-pattern.mddatabase-conventions.mdapi-conventions.mdtemporal-workflow.mdasynq-jobs.mdtesting-strategy.md../adr/0001-language-go.md