sayu.day

Enterprise Go 설계: 프로젝트 구조부터 HTTP 서버, Context, 트랜잭션까지

Go로 엔터프라이즈급 서비스를 만들 때 가장 먼저 정해야 할 것은 화려한 폴더 구조가 아니라 책임의 경계입니다. 어디까지가 조립 로직이고 어디부터가 비즈니스 로직인지, 요청의 생명주기를 어떻게 전파할지, 트랜잭션 경계를 코드 어디에 둘지, 그리고 팀이 같은 명령으로 빌드하고 테스트하는지가 서비스의 뼈대를 결정합니다. 이 글은 프로젝트 구조 설계에서 시작해 HTTP 서버의 미들웨어·종료 전략, Context를 통한 생명주기 관리, 데이터베이스 트랜잭션 경계, 마지막으로 Makefile을 통한 워크플로우 표준화까지 하나의 흐름으로 정리합니다.

프로젝트 구조: Hollow Main과 internal/app

핵심 원칙은 세 가지입니다. main은 최대한 얇게 유지하고, 의존성 조립은 internal/app에 집중하며, 공개 API가 아니라면 기본값은 internal로 둡니다.

project/
├── cmd/
│   └── api/
│       └── main.go
├── internal/
│   ├── app/
│   ├── api/
│   ├── service/
│   └── data/
├── pkg/            # 외부 공개가 필요할 때만
└── Makefile

main에서 의존성 조립까지 다 처리하면 테스트 가능한 진입점이 사라집니다. 이를 막는 방법이 Hollow Main 패턴입니다.

package main

func main() {
	if err := app.Run(); err != nil {
		os.Exit(1)
	}
}

실제 조립은 internal/app가 담당합니다. 설정 로딩, 의존성 조립, 프로세스 라이프사이클(시작/종료)이 이곳의 책임입니다.

func Run() error {
	cfg := LoadConfig()
	db := data.NewDB(cfg)
	svc := service.New(db)
	server := api.NewHTTPServer(svc)
	return server.Run()
}

구조는 처음부터 완성형일 필요가 없습니다. 초기 PoC 단계에서는 단일 main.go로 시작하고, 코드가 커지면 cmd + internal로 분리하고, 바이너리가 늘어나면 cmd/{api,worker,admin}으로 확장하는 편이 유지보수에 유리합니다. 자주 하는 실수는 pkg를 너무 일찍 열어 API 호환성 부담을 만드는 것, 도메인/인프라 경계를 파일 경로로만 나누고 실제 책임은 섞어두는 것, 그리고 main에서 설정·DI·서버 실행·시그널 처리까지 모두 처리해버리는 것입니다.

HTTP 서버: 미들웨어 순서와 Graceful Shutdown

서버는 "응답만 되는 코드"가 아니라 장애 시에도 예측 가능하게 동작해야 합니다. 핵심은 미들웨어 순서, 에러 계약, graceful shutdown 세 가지입니다.

미들웨어 순서

권장 순서는 Recover → RequestID → Logger → CORS → Auth → Handler입니다. 이 순서가 깨지면 추적 불가능한 로그, 패닉 누락, 인증 실패 누락 로그가 발생합니다. Recover가 가장 바깥에 있어야 패닉이 전체 서버를 죽이지 않고, RequestID가 Logger보다 앞서야 모든 로그에 추적 ID가 남습니다.

도메인 에러의 HTTP 매핑

핸들러에서 원천 에러를 직접 분기하지 말고 도메인 코드로 매핑합니다.

switch err.Code {
case domain.CodeNotFound:
	status = 404
case domain.CodeValidation:
	status = 400
default:
	status = 500
}

규칙이 있으면 에러 포맷과 모니터링 지표가 안정됩니다.

Graceful Shutdown

종료 시에는 새 요청 수락을 중지하고, 진행 중인 요청에는 제한 시간을 부여하며, 커넥션과 워커를 정리한 뒤 종료해야 합니다.

ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()

멀티 컴포넌트 구성이라면 run.Group 패턴으로 종료 순서를 한곳에서 관리하는 것이 안전합니다. 운영 체크포인트로는 readiness/liveness 분리, request timeout 기본값 명시, panic 발생 시 표준 에러 응답 보장, access log에 request id 필수 포함이 있습니다. 견고한 서버의 핵심은 프레임워크 선택이 아니라 이런 운영 규칙을 팀 표준으로 고정하는 것입니다.

Context로 요청 생명주기 관리

Context는 요청 범위 값(request id, trace id), timeout/deadline, cancel 신호라는 세 가지를 전달합니다. 핵심은 모든 하위 호출로 동일한 생명주기를 전파하는 것입니다.

기본 규칙은 다음과 같습니다.

  • 함수의 첫 번째 인자는 context.Context로 둡니다.
  • 구조체 필드에 Context를 저장하지 않습니다.
  • context.Background()는 진입점에서만 사용합니다.

타임아웃 설계

상위 timeout이 하위를 감싸야 합니다. 예를 들어 HTTP 전체는 30초, UseCase는 10초, DB는 3초, 외부 API는 5초처럼 계층별로 줄여가며 설정합니다.

ctx, cancel := context.WithTimeout(parent, 3*time.Second)
defer cancel()

취소 전파

클라이언트가 연결을 끊었는데 하위 DB나 API 호출이 계속 돌면 리소스가 낭비됩니다. 모든 저장소·외부 호출은 전달받은 ctx를 그대로 사용해야 합니다.

값 전달 시 주의점

WithValue는 설정 저장소가 아닙니다. request id, trace id, auth subject 정도는 허용되지만 DB 핸들, 대형 객체, 옵션 묶음을 담는 것은 피해야 합니다. 자주 하는 실수는 타임아웃 없는 outbound HTTP 호출, 새 goroutine에서 parent context를 누락하는 것, 라이브러리 내부에서 임의로 Background()를 생성하는 것입니다. Context는 편의 기능이 아니라 운영 안정성 장치이며, 전파 누락이 없는 구조를 만들면 timeout·취소·추적이 동시에 정리됩니다.

트랜잭션 경계: WithTx 패턴

Spring의 @Transactional처럼 선언형으로 처리하던 트랜잭션을 Go에서는 명시적으로 다뤄야 합니다. 핵심은 트랜잭션 경계가 코드에서 눈에 보이게 만드는 것입니다.

func (u *TransferUseCase) Transfer(ctx context.Context, from, to string, amount int64) error {
	return dbtx.WithTx(ctx, u.db, func(ctx context.Context) error {
		if err := u.accountRepo.Withdraw(ctx, from, amount); err != nil {
			return err
		}
		if err := u.accountRepo.Deposit(ctx, to, amount); err != nil {
			return err
		}
		return u.logRepo.Save(ctx, from, to, amount)
	})
}

성공 시 커밋, 에러 시 롤백을 한곳에서 보장합니다. Repository는 트랜잭션 생성 책임을 갖지 않고, 호출자가 준 ctx에서 트랜잭션 핸들을 얻어 실행합니다.

func (r *Repo) Save(ctx context.Context, m *Model) error {
	db := dbtx.FromContextOrDB(ctx, r.db)
	return db.Create(m).Error
}

커넥션 풀 설정은 정답값이 없고 지표 기반으로 맞춰야 하지만, 출발점으로는 MaxOpenConns를 DB 서버 여유와 동시성에 맞춰 제한하고, MaxIdleConnsMaxOpenConns의 30~50% 수준으로, ConnMaxLifetime은 LB나 방화벽의 timeout보다 짧게 설정하는 것이 안전합니다. 자주 하는 실수는 UseCase 밖에서 트랜잭션 경계를 흩뿌리는 것, Repository가 내부에서 임의로 트랜잭션을 시작하는 것, 긴 트랜잭션 안에 외부 API 호출까지 포함시키는 것입니다. Go DB 연동의 핵심은 ORM 선택이 아니라 트랜잭션 경계 통제이며, WithTx 패턴으로 경계를 고정하면 롤백 일관성과 테스트 용이성이 함께 올라갑니다.

Makefile로 실행 인터페이스 표준화

명령이 늘어날수록 팀마다 실행 방식이 달라지고 CI와 로컬 결과가 어긋나기 쉽습니다. Makefile은 이를 단일 인터페이스로 맞추는 장치입니다. 최소한 make build, make test, make lint, make generate, make clean을 갖춰 개발자와 CI가 같은 명령을 쓰도록 강제합니다.

GO := go

.PHONY: build test lint generate clean

build:
	$(GO) build ./...

test:
	$(GO) test -race -cover ./...

lint:
	golangci-lint run ./...

generate:
	$(GO) generate ./...

clean:
	rm -rf bin

모노레포에서는 서비스별 타겟을 분리(build-api, build-worker)하고, 변경된 서비스만 빌드하는 타겟을 따로 두며, help 타겟으로 실행 가능한 명령을 문서화하는 것이 도움이 됩니다. 자주 하는 실수는 환경별 분기를 Makefile에 과도하게 넣어 복잡도를 키우는 것, 로컬에서만 되는 명령을 넣어 CI와 어긋나게 만드는 것, 모든 타겟을 .PHONY로 선언하지 않아 캐시가 오작동하는 것입니다. Makefile의 본질은 자동화가 아니라 표준화이며, 팀이 같은 명령을 쓰게 만드는 것만으로 온보딩과 배포 안정성이 크게 좋아집니다.

정리

Hollow Main으로 진입점을 얇게 유지하고, 미들웨어 순서와 graceful shutdown으로 서버 동작을 예측 가능하게 만들고, Context로 요청 생명주기를 빠짐없이 전파하고, WithTx로 트랜잭션 경계를 코드에 드러내고, Makefile로 팀의 실행 방식을 통일하는 것. 이 다섯 가지가 맞물릴 때 비로소 규모가 커져도 무너지지 않는 Go 서비스의 뼈대가 완성됩니다.