설계와 프로젝트 셋업
이 챕터에서 다루는 것
파트 1부터 12까지 만든 조각들을 하나의 서비스로 합친다. 주제는 백그라운드 워커가
붙은 태스크 관리 API이고, 이름은 taskd다.
이 챕터에서는 요구사항을 정하고, 계층 경계를 결정하고, 그 결정을 실행 가능한 테스트로 바꾼다. 코드는 아직 거의 쓰지 않는다.
무엇을 만드는가
기능 요구사항은 짧다.
- 사용자는 로그인해서 액세스 토큰과 리프레시 토큰을 받는다.
- 사용자는 자기 할 일을 만들고, 읽고, 고치고, 지운다. 남의 것은 보이지 않는다.
- 할 일이 생성되거나 완료되면 알림이 나간다. 알림은 요청 처리와 분리된 백그라운드 작업이고, 실패하면 재시도한다.
- 관리자는 잡 큐 상태를 볼 수 있다.
운영 요구사항이 더 길고, 사실 이쪽이 이 파트의 본론이다.
- 알림은 유실되면 안 된다. 할 일은 저장됐는데 알림만 사라지는 상태가 없어야 한다.
- 잡 하나가 잘못돼도 프로세스가 죽지 않는다.
- 인스턴스를 둘 이상 띄워도 같은 잡이 두 번 실행되지 않는다.
- 배포할 때 502가 나지 않는다.
- 운영 중에 재시작 없이 로그 레벨을 올릴 수 있다.
- 요청이 몰리면 429로 막고 언제 다시 오라고 알려 준다.
- 프로파일과 헬스체크는 서비스 포트가 아닌 곳에 노출된다.
이 목록은 임의로 고른 것이 아니다. 파트 12가 연습문제와 TODO로 남긴 숙제들이고, 이 파트에서 전부 갚는다.
계층을 어떻게 나눌 것인가
계층 이야기를 할 때 흔히 나오는 그림이 "클린 아키텍처"의 네 겹 동심원이다. 엔티티, 유스케이스, 인터페이스 어댑터, 프레임워크. 이 서비스에는 그것을 쓰지 않는다. 왜 안 쓰는지를 먼저 말해 두는 편이 낫다.
네 겹 구조의 값은 "엔티티가 유스케이스를 모르고, 유스케이스가 어댑터를 모른다"는 데서 나온다. 그 값을 얻으려면 대가를 치러야 한다. 계층을 넘을 때마다 DTO를 하나씩 만들고, 그 사이를 옮기는 매퍼 함수를 짝수 개씩 쓴다. 할 일 하나에 필드가 여섯 개인 서비스에서 그 매퍼는 순수한 비용이다. 필드를 하나 추가하면 네 군데를 고쳐야 하고, 네 군데 다 고쳤는지 확인해 주는 것은 컴파일러뿐인데, 그 확인은 한 겹짜리 구조에서도 똑같이 컴파일러가 해 준다.
그래서 이 서비스는 세 겹이다.
| 계층 | 패키지 | 아는 것 | 모르는 것 |
|---|---|---|---|
| 도메인 | task | 할 일이 무엇인지, 어떤 규칙이 있는지 | HTTP, SQL, GORM, Gin |
| 유스케이스 | task.Service | 트랜잭션 경계, 무엇과 무엇이 원자적이어야 하는지 | HTTP, SQL |
| 경계 | httpapi, gormstore, worker | Gin, GORM, 잡 큐 | 서로 |
도메인과 유스케이스가 같은 패키지에 있다. 나눠도 되지만, 나누면 task.Task를
usecase.Task로 옮기는 매퍼가 생긴다. 이 크기에서는 얻는 것보다 잃는 것이 많다.
서비스가 커져서 유스케이스가 스무 개를 넘어가면 그때 나눈다. 미리 나눠 두는 것은
YAGNI다.
대신 절대 양보하지 않는 규칙이 셋 있다.
1. 도메인은 저장 기술도 전송 프로토콜도 모른다. GORM을 sqlc로 바꾸는 일이 도메인 변경이 되면 안 된다. 파트 11에서 같은 인터페이스에 메모리 구현과 GORM 구현을 둘 다 붙여 봤기 때문에, 이 규칙이 지켜지고 있는지는 "테스트가 DB 없이 도는가"로 확인할 수 있다.
2. 인터페이스는 쓰는 쪽에 선언한다. task.Repository가 task 패키지에 있고
gormstore가 그것을 구현한다. 반대가 아니다. 이것이 의존 방향을 뒤집는다.
3. 에러 정책은 아무것도 의존하지 않는 바닥이다. apperr는 도메인도 HTTP도
import하지 않는다. 모두가 apperr를 쓰기 때문이다.
디렉터리 구조
examples/13-capstone/
├── go.mod
├── Dockerfile
├── ci.yml
├── cmd/
│ └── taskd/
│ └── main.go ← 배선만 한다. 로직 없음
└── internal/
├── apperr/ ← 에러 종류. 아무것도 의존하지 않는다
├── applog/ ← slog 조립, 요청 ID, 로그 레벨 엔드포인트
├── buildinfo/ ← -ldflags로 박은 버전 정보
├── config/ ← 설정 로딩과 검증
├── health/ ← 라이브니스, 레디니스, 배수
├── safego/ ← 고루틴 패닉 경계
├── task/ ← 도메인 + 유스케이스 + 인터페이스
├── memstore/ ← 메모리 구현
├── gormstore/ ← GORM 구현 + 마이그레이션
│ └── migrations/*.sql
├── storetest/ ← 두 구현이 통과해야 하는 적합성 테스트
├── auth/ ← JWT 발급·검증, 계정
├── httpapi/ ← Gin 핸들러, 미들웨어, 요청 제한
├── worker/ ← 워커 풀, 재시도, 백오프
└── arch/ ← 계층 규칙 테스트 (프로덕션 코드 없음)
cmd/와 internal/로 나눈 것은 파트 6에서 다룬 그대로다. internal/ 아래는
이 모듈 밖에서 import할 수 없으므로, 공개 API를 유지할 의무가 없다. 마음대로
리팩터링할 수 있다는 뜻이고, 그것이 이 배치의 실질적인 값이다.
패키지가 열네 개다. 많아 보이지만 각각이 한 가지만 한다. 반대로 internal/service
하나에 전부 넣으면 계층 규칙을 강제할 방법이 사라진다 — 같은 패키지 안에서는
무엇이든 부를 수 있다. 패키지 경계가 곧 강제 가능한 유일한 경계다.
기술 선택
| 무엇 | 선택 | 버전 | 왜 |
|---|---|---|---|
| 웹 | Gin | v1.12.0 | 파트 10에서 다뤘고, 미들웨어 체인과 바인딩 검증이 이 서비스에 필요한 만큼 있다 |
| ORM | GORM | v1.31.2 | 파트 11에서 다뤘다. 제네릭 API를 쓴다 |
| DB | SQLite (modernc, 순수 Go) | v1.56.0 | CGO 없이 정적 링크된다. 예제를 실행하는 데 DB 서버가 필요 없다 |
| 마이그레이션 | golang-migrate | v4.19.1 | 버전과 dirty 플래그를 기록한다. AutoMigrate는 그것을 못 한다 |
| JWT | golang-jwt/jwt/v5 | v5.3.1 | 파트 10-7에서 다뤘다 |
| 로깅 | log/slog | 표준 | 파트 12-2 |
| 비밀번호 | golang.org/x/crypto/bcrypt | v0.48.0 | 느린 것이 목적인 해시 |
:::warning SQLite 드라이버 이름 충돌 — 실제로 부딪힌 문제
파트 10~12는 GORM의 SQLite 방언으로 github.com/glebarez/sqlite를 썼다. 캡스톤에서는
gorm.io/driver/sqlite로 바꿨다. 취향 문제가 아니라 링크가 안 되기 때문이다.
glebarez/sqlite는glebarez/go-sqlite를 끌어오고, 그것이sql.Register("sqlite", …)을 부른다.- golang-migrate의 SQLite 드라이버는
modernc.org/sqlite를 빈 import하고, 그것도sql.Register("sqlite", …)을 부른다.
둘 다 한 바이너리에 들어가면 두 번째 init에서 이렇게 죽는다.
panic: sql: Register called twice for driver sqlite
database/sql의 드라이버 이름은 프로세스 전역이라 피할 방법이 없다. 선택지는 둘이었다.
- golang-migrate를 포기하고 마이그레이터를 직접 만든다.
- GORM 방언을
gorm.io/driver/sqlite로 바꾸고, 드라이버는modernc.org/sqlite로 통일한다.
2번을 골랐다. gorm.io/driver/sqlite는 mattn/go-sqlite3(CGO)를 의존성으로 갖지만,
Dialector{Conn: db}처럼 이미 열린 커넥션을 넘기면 그 드라이버를 쓰지 않는다.
그리고 CGO_ENABLED=0으로 빌드하면 C 코드는 하나도 컴파일되지 않는다.
CGO_ENABLED=0 go list -f '{{.GoFiles}}' github.com/mattn/go-sqlite3으로 확인하면
남는 것은 [convert.go doc.go sqlite3_func_crypt.go static_mock.go] 넷뿐이고,
static_mock.go는 //go:build !cgo가 붙은 스텁이다. 그 스텁이 하는 일은 이것뿐이다.
var errorMsg = errors.New("Binary was compiled with 'CGO_ENABLED=0', ...")
func init() {
sql.Register("sqlite3", &SQLiteDriver{})
}
즉 패키지는 바이너리에 들어가고 드라이버도 등록된다. 다만 이름이 sqlite3라
modernc의 sqlite와 부딪히지 않고, 실수로 그 이름을 쓰면 Open이 위 에러를
돌려주며 즉시 실패한다. 조용히 잘못 동작하지 않는다는 뜻이라 이 조합은 안전하다.
실제로 이 서비스의 컨테이너 이미지는 정적 링크된 바이너리 하나이고 CGO가 꺼져 있다
(13-5에서 확인한다).
써 놓고 보면 깔끔하지 않다. 안 쓰는 CGO 의존성이 go.mod에 남는다. 실제 프로젝트에서
드물지 않게 만나는 종류의 문제이고, "둘 다 되는 조합이 없으니 하나를 바꾼다"가
정답인 경우가 많다. 왜 바꿨는지를 기록해 두지 않으면 몇 달 뒤에 누군가 되돌린다.
:::
module example.com/capstone
go 1.26.5
require (
github.com/gin-gonic/gin v1.12.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/golang-migrate/migrate/v4 v4.19.1
github.com/google/uuid v1.6.0
golang.org/x/crypto v0.48.0
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.2
modernc.org/sqlite v1.56.0
)
modernc.org/sqlite가 직접 의존인 것은 드라이버를 빈 import로 등록하기 때문이다.
golang-migrate가 어차피 같은 것을 import하지만, 남의 사정에 기대면 그쪽이 바뀔 때
조용히 깨진다.
경계를 테스트로 못 박는다
여기까지가 설계다. 문제는 설계가 문서에만 있으면 반드시 무너진다는 것이다. 급할 때 import 한 줄 추가하는 것이 언제나 가장 쉬운 해결책이고, 리뷰에서 그것을 매번 잡아내는 사람은 없다.
그래서 규칙을 테스트로 쓴다.
// rule은 "이 패키지는 저것을 import하면 안 된다"는 규칙 하나다.
type rule struct {
pkg string
// forbidden은 금지된 import 접두사다. 직접 import뿐 아니라
// 전이 의존까지 검사한다 — 한 단계 건너 끌어오면 규칙이 무의미하다.
forbidden []string
why string
}
why가 구조체에 있는 것이 의도다. 실패 메시지에 이유가 함께 나오지 않으면,
반년 뒤에 이 테스트를 처음 깬 사람은 규칙을 이해하는 대신 규칙을 지운다.
var rules = []rule{
{
pkg: module + "/internal/task",
forbidden: []string{
"github.com/gin-gonic/gin",
"gorm.io/",
"database/sql",
"net/http",
},
why: "도메인은 저장 기술도 전송 프로토콜도 몰라야 한다. " +
"알게 되면 GORM을 sqlc로 바꾸는 일이 도메인 변경이 된다",
},
전이 의존까지 보는 것이 중요하다. 직접 import만 검사하면 task가 helper를
import하고 helper가 Gin을 import하는 것으로 규칙이 우회된다.
// transitiveDeps는 패키지의 전이 의존을 전부 모은다.
//
// go/build로 import 목록을 읽는다. 모듈 모드에서 build.Import는 내부적으로
// go list를 부르므로 빠르지는 않다 — 이 테스트 하나가 몇 초를 쓴다.
// 그 대신 의존성이 없고, 결과를 파싱할 필요가 없다.
// 더 빠르게 하려면 go list -deps를 한 번만 부르고 출력을 읽으면 된다.
func transitiveDeps(t *testing.T, root string) []string {
금지 목록과 별개로, 도메인에 대해서는 화이트리스트도 둔다.
// TestDomainHasNoInternalDepsButApperr는 도메인 패키지가 무엇에
// 의존하는지를 통째로 못 박는다.
//
// 금지 목록은 "이것만 아니면 된다"이지만, 이 테스트는 "이것만 된다"다.
// 새 의존이 하나라도 생기면 실패하므로, 추가할 때 한 번은 생각하게 된다.
func TestDomainHasNoInternalDepsButApperr(t *testing.T) {
deps := transitiveDeps(t, module+"/internal/task")
var internal []string
for _, d := range deps {
if strings.HasPrefix(d, module+"/") {
internal = append(internal, d)
}
}
want := []string{module + "/internal/apperr"}
if !slices.Equal(internal, want) {
t.Errorf("task의 내부 의존 = %v, %v를 기대", internal, want)
}
}
그 테스트가 처음 잡은 것
이 테스트를 처음 돌렸을 때 통과하지 않았다.
--- FAIL: TestLayerBoundaries/internal/task (0.72s)
arch_test.go:85: example.com/capstone/internal/task가 net/http를 의존한다 (net/http에 걸림)
이유: 도메인은 저장 기술도 전송 프로토콜도 몰라야 한다. 알게 되면 GORM을 sqlc로 바꾸는 일이 도메인 변경이 된다
arch_test.go:85: example.com/capstone/internal/task가 net/http/httptrace를 의존한다 (net/http에 걸림)
arch_test.go:85: example.com/capstone/internal/task가 net/http/internal를 의존한다 (net/http에 걸림)
arch_test.go:85: example.com/capstone/internal/task가 net/http/internal/ascii를 의존한다 (net/http에 걸림)
arch_test.go:85: example.com/capstone/internal/task가 net/http/internal/httpcommon를 의존한다 (net/http에 걸림)
범인은 **12-1에서 그대로 복사해 온 apperr**였다. 그 패키지에는 HTTPStatus가
들어 있고, 그것 때문에 net/http를 import한다. 도메인이 apperr를 쓰니까
도메인이 net/http 전체에 딸려 들어간 것이다.
12-1에서는 문제가 아니었다. 그 예제에는 도메인 패키지와 웹 패키지가 하나씩뿐이었고 둘 다 어차피 HTTP를 알았다. 계층이 늘어나자 결함이 드러났다.
고치는 방법은 간단하다. 종류를 정하는 일과 종류를 상태 코드로 옮기는 일은 다른 일이고, 뒤쪽만 HTTP를 알면 된다.
// httpStatus는 에러 종류를 상태 코드로 옮긴다.
//
// 이 함수만이 HTTP를 안다. 12-1에서는 이것이 apperr 안에 있었는데,
// 그러면 apperr가 net/http를 import하고 apperr를 쓰는 도메인 패키지까지
// 전송 계층에 묶인다. 같은 서비스를 gRPC로도 노출한다면 이 함수 옆에
// 매핑을 하나 더 두면 된다 — 도메인은 아무것도 몰라도 된다.
func httpStatus(err error) int {
switch apperr.KindOf(err) {
case apperr.KindInvalid:
return http.StatusBadRequest
그리고 규칙에 한 줄을 더해, 같은 일이 다시 일어나지 않게 한다.
{
pkg: module + "/internal/apperr",
forbidden: []string{
module + "/internal/",
"github.com/",
"gorm.io/",
// 12-1의 apperr는 HTTPStatus 때문에 net/http를 import했다.
// 그 한 줄이 도메인 전체를 전송 계층에 묶는다.
"net/http",
},
why: "에러 정책은 모두가 의존하는 바닥이다. 여기가 무언가를 " +
"의존하면 그 무언가가 온 서비스에 딸려간다",
},
:::tip 이 테스트의 진짜 값
"도메인이 net/http를 import한다"가 당장 무엇을 망가뜨리지는 않는다. 컴파일도 되고
테스트도 통과한다. 문제는 아무도 모르는 사이에 경계가 사라진다는 것이다.
반년 뒤 "도메인만 뽑아서 다른 서비스에서 쓰자"는 이야기가 나왔을 때 비로소
발견되고, 그때는 되돌리는 비용이 백 배다.
go vet도 린터도 이런 것을 잡지 않는다. 프로젝트마다 규칙이 다르기 때문이다.
그래서 직접 쓴다. 오십 줄이면 된다.
:::
개발 환경
파트 1에서 세운 것 그대로면 충분하다. 확인할 것만 적는다.
cd examples/13-capstone
go version # go1.26.5 이상
go mod download
go vet ./...
go test ./...
go test ./...는 DB 서버 없이 돈다. SQLite가 순수 Go이고, 테스트는 t.TempDir()에
파일을 만들기 때문이다. 이 조건을 유지하는 것이 중요하다 — 테스트를 돌리는 데
docker compose up이 필요해지는 순간, 사람들은 테스트를 안 돌린다.
서비스를 직접 띄워 보려면 이렇게 한다.
go run ./cmd/taskd -addr 127.0.0.1:8080 -admin-addr 127.0.0.1:6060
기본값만으로 뜬다. 개발용 계정이 자동으로 들어가고, 경고 로그로 알려 준다.
level=WARN msg="demo accounts seeded" hint="alice@example.com / password123, admin@example.com / password123"
prod 환경에서는 이 코드가 아예 실행되지 않는다. 13-3에서 그 조건을 본다.
흔한 실수
구조를 먼저 정하고 요구사항을 나중에 맞춘다. "클린 아키텍처로 간다"를 먼저 정하면 필요 없는 계층이 생기고, 그 계층을 정당화하려고 코드가 늘어난다. 요구사항이 계층을 정하지, 그 반대가 아니다.
계층 규칙을 README에 적는다. 아무도 안 읽고, 읽어도 안 지킨다. 테스트로 쓴다.
internal/을 안 쓴다. 그러면 모든 패키지가 공개 API가 되고, 리팩터링할 때마다
"누가 이걸 쓰고 있을지도 모른다"를 걱정하게 된다.
패키지 하나에 다 넣는다. 편하지만 경계를 강제할 방법이 사라진다.
의존성 버전을 두 곳에 적는다. go.mod와 CI 설정에 각각 Go 버전을 적으면
반드시 어긋난다. go-version-file을 쓴다(12-8).
드라이버 충돌 같은 문제를 "일단 되니까" 넘어간다. 왜 이 조합을 골랐는지 적어 두지 않으면, 다음 사람이 좋은 뜻으로 되돌려 놓고 링크 에러를 만난다.
정리
- 요구사항이 계층을 정한다. 이 크기에서 네 겹 동심원은 매퍼 비용만 남는다. 세 겹으로 가되 의존 방향 규칙은 양보하지 않는다.
- 인터페이스는 쓰는 쪽에 선언한다.
task.Repository가 도메인에 있고gormstore가 구현한다. apperr는 아무것도 의존하지 않는 바닥이다. 12-1의HTTPStatus는 그 규칙을 어겼고,httpapi로 옮겼다.- 계층 경계는 테스트로 못 박는다. 오십 줄짜리 테스트가 복사해 온 코드의 설계 결함을 즉시 잡아냈다.
- 드라이버 이름은 프로세스 전역이다. 같은 이름을 등록하는 라이브러리 둘은 한 바이너리에 못 들어간다. 조합을 바꾼 이유는 기록해 둔다.
연습문제
-
arch_test.go에 규칙을 하나 더 추가해 보자. "internal/config는internal/task를 import하면 안 된다." 지금 통과하는가? 통과한다면, 왜 이 규칙이 그래도 있어야 하는지 한 문장으로 적어 보자. 그리고 반대 방향 —task가config를 의존하는 것 — 은 왜 더 나쁜지 생각해 보자. -
transitiveDeps는 패키지마다go list를 부른다.go list -deps <pkg>한 번으로 같은 결과를 얻도록 바꿔 보자.os/exec로 실행하고 줄 단위로 읽으면 된다. 몇 초가 몇 밀리초가 된다. 대신 잃는 것이 하나 있는데, 무엇인가? (힌트: 테스트가 어떤 환경 변수에 영향을 받게 되는가?) -
이 서비스를 gRPC로도 노출한다고 하자. 어떤 패키지를 새로 만들고, 어떤 패키지를 고치고, 어떤 패키지는 손대지 않아도 되는가? 손대지 않아도 되는 목록이 길수록 계층 분리가 값을 한 것이다.
httpStatus에 해당하는 것이 gRPC 쪽에서는 무엇이 되는지도 찾아보자.