종합 실습 — 리포지토리 계층 설계
이 챕터에서 다루는 것
9-8의 연습문제 3과
10-8의 연습문제 4가 남겨 둔 숙제를
여기서 갚는다. 그때 api.Config는 구체 타입인 *TaskStore를 직접 받았고,
"파트 11이 그 자리에 진짜 DB 구현을 넣는다"고 적어 두었다.
만드는 것은 네 가지다.
- 소비자 쪽에 선언한 리포지토리 인터페이스
- GORM 구현과 메모리 구현
- 둘이 똑같이 행동하는지 검사하는 적합성 테스트
- 서비스 계층의 트랜잭션 경계 — 그리고 그 경계를 벗어날 수 없게 하는 시그니처
문제 — 인터페이스를 어디에 두는가
*TaskStore를 인터페이스로 바꾸는 것 자체는 5분이면 된다. 문제는 그 인터페이스가
어디에 있어야 하고 메서드가 무엇이어야 하는가다.
흔한 실패가 둘 있다.
저장소 패키지에 인터페이스를 둔다. gormtask.Repository를 만들고 핸들러가
그것을 import한다. 그러면 핸들러가 여전히 GORM 패키지에 의존한다. 이름만
인터페이스지 아무것도 분리되지 않았다. 파트 6에서 다룬 "인터페이스는 쓰는 쪽에
둔다"가 이것이다.
저장소 구현을 그대로 인터페이스로 옮긴다. GORM 구현에 있는 메서드를 전부 나열하면
인터페이스가 저장소의 모양을 하게 된다. Preload, Unscoped, WithTx 같은 것이
인터페이스에 새어 나오고, 메모리 구현이 그것을 흉내 낼 수 없어진다.
인터페이스는 쓰는 쪽이 필요한 것만 담아야 한다.
도메인 패키지가 인터페이스를 선언한다
// Package task는 할 일 도메인이다.
//
// 이 패키지는 GORM도 database/sql도 import하지 않는다. 저장 기술을 모르는
// 것이 요점이다. 리포지토리 인터페이스가 여기 선언되어 있는 이유도 같다.
// 인터페이스는 구현하는 쪽이 아니라 쓰는 쪽에 둔다.
package task
// Repository는 할 일 저장소다.
//
// 메서드마다 ownerID를 앞에 받는다. 소유자 범위를 저장소 경계에서
// 강제하기 위해서다. 위 계층이 깜빡해도 남의 데이터가 나오지 않는다.
//
// 트랜잭션 관련 메서드는 일부러 없다. 어디부터 어디까지가 한 트랜잭션인지는
// 저장소가 아니라 유스케이스를 아는 쪽이 정해야 한다.
type Repository interface {
// Create는 새 할 일을 저장하고 ID가 채워진 값을 돌려준다.
Create(ctx context.Context, t Task) (Task, error)
// Get은 소유자의 할 일 하나를 찾는다. 없으면 ErrNotFound다.
Get(ctx context.Context, ownerID string, id int64) (Task, error)
// List는 소유자의 할 일을 ID 오름차순으로 돌려준다.
List(ctx context.Context, ownerID string, opt ListOptions) ([]Task, error)
// Save는 이미 있는 할 일을 통째로 덮어쓴다.
// t.OwnerID가 저장된 값과 다르면 ErrNotFound다.
Save(ctx context.Context, t Task) error
// Delete는 소유자의 할 일을 지운다. 없으면 ErrNotFound다.
Delete(ctx context.Context, ownerID string, id int64) error
}
10-8의 Get(ownerID, id) 형태를 그대로 이어받았다. 그때는 "핸들러가 소유자 확인을
빼먹을 수 없게" 하려는 것이었는데, SQL로 내려오면 이 시그니처가 WHERE owner_id = ?
그 자체가 된다. 정책이 타입에서 SQL까지 한 줄로 이어진다.
Update(ownerID, id, mutate)는 인터페이스에서 뺐다. 그것은 읽고-바꾸고-쓰기라
저장소의 일이 아니라 유스케이스의 일이기 때문이다. 대신 Get과 Save로 쪼갰다.
트랜잭션 경계 — 타입으로 강제하기
Get 다음에 Save를 부르는 사이에 다른 요청이 끼어들 수 있다. 11-2에서 본 갱신
분실이다. 그러니 둘을 한 트랜잭션으로 묶어야 하는데, 묶는 범위를 아는 것은
저장소가 아니라 서비스다.
// TxManager는 트랜잭션 경계를 여는 문이다.
//
// fn이 nil을 돌려주면 커밋, 에러를 돌려주면 롤백이다. fn에 넘어오는
// Repository는 그 트랜잭션에 묶여 있다. 바깥의 Repository를 쓰면
// 트랜잭션 밖에서 실행되므로, 시그니처가 그 실수를 어렵게 만들도록 짰다.
type TxManager interface {
WithinTx(ctx context.Context, fn func(ctx context.Context, r Repository) error) error
}
// Store는 서비스가 필요로 하는 전부다.
type Store interface {
Repository
TxManager
}
이 시그니처가 11-5의 "콜백 안에서 바깥 db를 쓰면 안 된다"는 규칙을 구조로
바꾼 것이다. 콜백은 Repository를 인자로 받는다. 그 안에서 쓸 저장소가 이미
손에 쥐어져 있으므로, 바깥 것을 가져다 쓸 이유가 없다. 완전히 막지는 못하지만
(클로저가 바깥 변수를 볼 수는 있다) 올바른 길이 가장 짧은 길이 된다.
*gorm.DB나 *sql.Tx가 인터페이스에 등장하지 않는다는 점도 중요하다. 도메인
패키지는 여전히 저장 기술을 모른다.
서비스가 경계를 긋는다
// Update는 읽고-바꾸고-쓰기를 한 트랜잭션 안에서 한다.
//
// 10장의 메모리 저장소에서는 이 세 단계를 뮤텍스 하나로 묶었다.
// DB에서는 뮤텍스가 통하지 않는다. 프로세스가 두 개면 서로의 뮤텍스를
// 볼 수 없기 때문이다. 그 자리를 트랜잭션이 대신한다.
func (s *Service) Update(ctx context.Context, ownerID string, id int64, mutate func(*Task)) (Task, error) {
var out Task
err := s.store.WithinTx(ctx, func(ctx context.Context, r Repository) error {
current, err := r.Get(ctx, ownerID, id)
if err != nil {
return err
}
next := current
mutate(&next)
// 호출자가 무엇을 하든 바꿀 수 없는 것들을 되돌린다.
next.ID = current.ID
next.OwnerID = current.OwnerID
next.CreatedAt = current.CreatedAt
next.UpdatedAt = s.now()
if err := next.Validate(); err != nil {
return err
}
if err := r.Save(ctx, next); err != nil {
return err
}
out = next
return nil
})
if err != nil {
return Task{}, err
}
return out, nil
}
10-8의 Update(ownerID, id, mutate func(*Task))와 시그니처가 같다. 호출부는
바뀌지 않았고, 안쪽에서 뮤텍스가 트랜잭션으로 바뀌었을 뿐이다.
여러 행을 함께 바꿔야 하는 유스케이스도 같은 방식으로 쓴다.
// CompleteAll은 소유자의 할 일을 모두 완료로 바꾼다.
//
// 여러 행을 건드리므로 "전부 되거나 전부 안 되거나"여야 한다.
// 트랜잭션 경계를 서비스에 둔 덕분에, 저장소에 CompleteAll 같은 메서드를
// 새로 만들지 않고도 원자성을 얻는다.
func (s *Service) CompleteAll(ctx context.Context, ownerID string) (int, error) {
이것이 경계를 서비스에 둔 대가로 얻는 것이다. 새 유스케이스가 생길 때마다 저장소 인터페이스에 메서드를 추가할 필요가 없다.
시각은 Go에서 만든다
// Clock은 시각 공급자다.
//
// 타임스탬프를 DB의 CURRENT_TIMESTAMP에 맡길 수도 있지만, 여기서는
// Go 쪽에서 만든다. 그래야 저장소 구현이 둘이어도 같은 값이 나오고,
// 테스트와 데모 출력이 결정적이다. 대신 여러 프로세스가 쓰는 시스템에서는
// 서버 간 시계 차이를 감수해야 한다. 감사 로그처럼 순서가 곧 진실인
// 데이터라면 DB 시각을 쓰는 편이 맞다.
type Clock func() time.Time
의식적인 선택이다. 파트 8~10에서 써 온 시계 주입을 유지하려면 시각이 Go에서
나와야 한다. 메모리 구현이 CURRENT_TIMESTAMP를 흉내 낼 방법도 없다.
대가는 실제로 있다. 서버가 여러 대면 각자의 시계가 조금씩 다르고, updated_at으로
정렬한 결과가 실제 순서와 어긋날 수 있다. 그것이 문제가 되는 도메인이라면
DB 시각을 쓰고, 그 대신 결정적 테스트를 포기하거나 타임스탬프를 마스킹해야 한다.
어느 쪽을 골랐는지 코드에 적어 두는 것이 중요하다.
두 구현
GORM 구현 — 도메인 타입과 행 타입을 분리한다
// row는 tasks 테이블 한 행이다.
//
// 소프트 삭제를 쓰지 않는다. 할 일을 지우면 정말 지운다. gorm.Model을
// 습관적으로 임베드하면 원치 않는 deleted_at 조건이 모든 쿼리에 붙는다.
type row struct {
ID int64 `gorm:"primaryKey;autoIncrement"`
OwnerID string `gorm:"size:64;not null;index:idx_tasks_owner_status,priority:1"`
Title string `gorm:"size:200;not null"`
Status string `gorm:"size:16;not null;index:idx_tasks_owner_status,priority:2"`
CreatedAt time.Time
UpdatedAt time.Time
}
task.Task에 gorm 태그를 붙이지 않았다. 붙이면 도메인 패키지가 ORM을 알게 되고,
컬럼 이름을 바꾸는 것이 도메인 변경이 된다. 변환 함수 두 개(toRow, toTask)가
그 값을 한다. 작은 프로젝트에서는 과해 보일 수 있지만, 저장소를 둘 이상 두는
순간부터는 필수가 된다.
인덱스 태그도 눈여겨볼 만하다. idx_tasks_owner_status는 (owner_id, status)
복합 인덱스다. 모든 조회에 WHERE owner_id = ?가 붙으므로 첫 컬럼이 owner_id여야 한다.
// Get은 소유자 조건을 함께 걸어 조회한다.
//
// WHERE에 owner_id가 들어 있으므로, 남의 행은 애초에 결과에 오지 않고
// gorm.ErrRecordNotFound가 된다. 404 정책이 SQL 한 줄로 표현된다.
func (s *Store) Get(ctx context.Context, ownerID string, id int64) (task.Task, error) {
r, err := gorm.G[row](s.db).
Where("owner_id = ? AND id = ?", ownerID, id).
First(ctx)
if errors.Is(err, gorm.ErrRecordNotFound) {
return task.Task{}, task.ErrNotFound
}
if err != nil {
return task.Task{}, fmt.Errorf("gormtask: 조회: %w", err)
}
return toTask(r), nil
}
10-8이 "403 대신 404"를 코드 주석으로 지키던 정책이, 여기서는 그냥 WHERE 절이다.
"권한 없음" 분기를 쓸 자리 자체가 없다.
Save는 11-3의 제로값 함정을 피해야 한다.
// Save는 행을 통째로 덮어쓴다.
//
// Updates에 구조체를 넘기면 제로값이 무시된다(11-3). 컬럼을 map으로
// 명시하면 그 함정을 피할 수 있고, 어떤 컬럼이 나가는지도 눈에 보인다.
func (s *Store) Save(ctx context.Context, t task.Task) error {
r := toRow(t)
n, err := gorm.G[row](s.db).
Where("owner_id = ? AND id = ?", r.OwnerID, r.ID).
Set(
assign("title", r.Title),
assign("status", r.Status),
assign("updated_at", r.UpdatedAt),
).
Update(ctx)
if err != nil {
return fmt.Errorf("gormtask: 저장: %w", err)
}
if n == 0 {
return task.ErrNotFound
}
return nil
}
rowsAffected가 0이면 ErrNotFound다. 행이 없었거나 남의 것이었거나 — 어느
쪽이든 호출자에게는 같은 대답이다.
트랜잭션 구현은 세 줄이다.
// WithinTx는 GORM 트랜잭션을 연다.
//
// 콜백에는 tx에 묶인 새 Store를 넘긴다. 바깥의 s를 쓰면 그 쿼리는
// 트랜잭션 밖에서 실행된다. 인터페이스가 Repository를 인자로 주는 이유다.
func (s *Store) WithinTx(ctx context.Context, fn func(context.Context, task.Repository) error) error {
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return fn(ctx, &Store{db: tx})
})
}
&Store{db: tx}가 핵심이다. 같은 타입인데 안에 든 것이 *gorm.DB 대신 트랜잭션이다.
GORM이 두 타입을 같게 다루기 때문에 가능하다.
메모리 구현 — 롤백까지 흉내 낸다
테스트 대역이라고 대충 만들면 안 된다. 적합성 테스트를 GORM 구현과 똑같이 통과해야 하므로, 소유자 범위도 롤백 의미도 맞춰야 한다.
// WithinTx는 트랜잭션을 흉내 낸다.
//
// 락을 잡아 원자성을, 스냅숏 복원으로 롤백을 얻는다. 맵 하나를 통째로
// 복사하므로 데이터가 크면 쓸 수 없는 방식이지만, 테스트 대역으로는 충분하다.
// 중첩 호출은 교착에 빠지므로 지원하지 않는다 — 서비스 계층이 트랜잭션을
// 중첩하지 않는다는 규칙을 이 구현이 강제하는 셈이다.
func (s *Store) WithinTx(ctx context.Context, fn func(context.Context, task.Repository) error) error {
s.mu.Lock()
defer s.mu.Unlock()
snapshot := maps.Clone(s.tasks)
snapshotNext := s.nextID
if err := fn(ctx, lockedView{s}); err != nil {
s.tasks = snapshot
s.nextID = snapshotNext
return err
}
return nil
}
콜백에 s 자신을 넘기면 각 메서드가 다시 s.mu.Lock()을 시도해 교착에 빠진다.
Go의 sync.Mutex는 재진입이 안 되기 때문이다. 락을 잡지 않는 별도 타입을 넘겨
그 실수를 원천 차단한다.
// lockedView는 이미 락을 쥔 상태에서 쓰는 Repository다.
//
// 트랜잭션 콜백에 Store 자신을 넘기면 각 메서드가 다시 락을 잡아 교착에
// 빠진다. 락을 잡지 않는 별도 타입을 넘겨서 그 실수를 원천 차단한다.
type lockedView struct{ s *Store }
정렬도 맞춰야 한다. 맵 순회 순서는 무작위인데 SQL은 ORDER BY id다.
func (s *Store) listLocked(ownerID string, opt task.ListOptions) []task.Task {
out := make([]task.Task, 0, len(s.tasks))
// 맵 순회 순서는 무작위다. ID로 정렬해야 SQL의 ORDER BY id와 같아진다.
for _, t := range slices.SortedFunc(maps.Values(s.tasks), func(a, b task.Task) int {
return int(a.ID - b.ID)
}) {
적합성 테스트
인터페이스는 메서드 이름과 시그니처만 강제한다. "남의 것을 조회하면 ErrNotFound",
"롤백하면 아무것도 안 바뀜" 같은 의미는 컴파일러가 검사하지 않는다.
10-6의 TestThreeImplementationsAgree가 세 프레임워크에 했던 일을, 여기서는
두 저장소에 한다.
// Factory는 빈 저장소를 하나 만든다. 테스트마다 새로 부른다.
type Factory func(t *testing.T) task.Store
// Clock은 스위트가 쓰는 고정 시계다.
var Clock = time.Date(2026, 3, 1, 9, 0, 0, 0, time.UTC)
// Run은 스위트 전체를 돌린다.
func Run(t *testing.T, f Factory) {
t.Helper()
t.Run("CreateAssignsID", func(t *testing.T) { testCreateAssignsID(t, f) })
t.Run("GetOwnScoped", func(t *testing.T) { testGetOwnScoped(t, f) })
t.Run("OtherOwnerLooksMissing", func(t *testing.T) { testOtherOwnerLooksMissing(t, f) })
t.Run("ListIsOrderedAndFiltered", func(t *testing.T) { testListIsOrderedAndFiltered(t, f) })
t.Run("ListLimit", func(t *testing.T) { testListLimit(t, f) })
t.Run("UpdateKeepsInvariants", func(t *testing.T) { testUpdateKeepsInvariants(t, f) })
t.Run("UpdateMissingIsNotFound", func(t *testing.T) { testUpdateMissingIsNotFound(t, f) })
t.Run("DeleteThenGetIsNotFound", func(t *testing.T) { testDeleteThenGetIsNotFound(t, f) })
t.Run("DeleteOtherOwnerIsNotFound", func(t *testing.T) { testDeleteOtherOwnerIsNotFound(t, f) })
t.Run("CompleteAllIsAtomic", func(t *testing.T) { testCompleteAllIsAtomic(t, f) })
t.Run("RollbackLeavesNothingBehind", func(t *testing.T) { testRollbackLeavesNothingBehind(t, f) })
t.Run("ValidationRejectsBadStatus", func(t *testing.T) { testValidationRejectsBadStatus(t, f) })
}
각 구현은 한 줄로 스위트를 돌린다.
// TestConformance는 메모리 구현이 계약을 지키는지 검사한다.
// gormtask_test.go에 같은 한 줄이 있고, 둘은 같은 스위트를 돈다.
func TestConformance(t *testing.T) {
repotest.Run(t, func(*testing.T) task.Store {
return memtask.New()
})
}
가장 중요한 테스트가 롤백이다. 두 구현의 트랜잭션 의미가 실제로 같은지는 이것으로만 알 수 있다.
// testRollbackLeavesNothingBehind는 두 구현의 롤백 의미가 같은지 본다.
// 이 테스트가 메모리 구현과 GORM 구현을 진짜로 맞물리게 하는 부분이다.
func testRollbackLeavesNothingBehind(t *testing.T, f Factory) {
svc, _ := newService(t, f)
seed(t, svc, "u1", "하나", "둘", "셋")
// 두 건을 고친 뒤 세 번째에서 실패시킨다.
err := svc.PrefixAll(t.Context(), "u1", "[급함] ", 2)
if err == nil {
t.Fatal("실패를 기대했다")
}
after, err := svc.List(t.Context(), "u1", task.ListOptions{})
if err != nil {
t.Fatalf("List: %v", err)
}
for _, x := range after {
if x.Title != "하나" && x.Title != "둘" && x.Title != "셋" {
t.Errorf("롤백되지 않은 값이 남았다: %q", x.Title)
}
}
:::tip 적합성 테스트가 실제로 잡아 준 것 이 스위트를 처음 돌렸을 때 두 구현이 똑같은 방식으로 실패했다. 원인은 구현이 아니라 테스트의 접두사 검사가 한글에서 틀린 것이었지만, 두 구현이 나란히 같은 결과를 낸다는 것 자체가 신호였다. 한쪽만 실패했다면 구현 차이를 의심했을 것이다.
적합성 테스트의 가치는 "통과했다"가 아니라 **"두 구현이 갈라지는 순간을 알려 준다"**는 데 있다. :::
HTTP 계층은 저장소를 모른다
10-8의 핸들러가 *api.TaskStore를 받던 자리에 이제 *task.Service가 들어간다.
// Package httpapi는 10-8의 API를 리포지토리 계층 위로 옮긴 것이다.
//
// 핸들러는 *task.Service만 안다. 저장소가 메모리인지 GORM인지 모른다.
// 그래서 10장에서 *api.TaskStore를 직접 받던 자리에 인터페이스가 들어가고,
// 프로덕션은 GORM 구현을, 테스트는 메모리 구현을 꽂는다.
//
// 인증은 10-7에서 다뤘으므로 여기서는 다시 만들지 않는다. 주체(Principal)를
// 컨텍스트에서 꺼내는 부분만 남기고, JWT 미들웨어는 그대로 앞에 붙는다고 본다.
package httpapi
404 정책도 그대로 이어진다.
// writeServiceErr는 도메인 에러를 상태 코드로 옮긴다.
//
// 남의 자원에 403이 아니라 404를 주는 정책이 여기서 완성된다.
// 저장소가 소유자 조건을 WHERE에 넣어 ErrNotFound를 만들었기 때문에,
// 이 함수에는 "권한 없음" 분기 자체가 없다.
func writeServiceErr(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, task.ErrNotFound):
writeErr(w, http.StatusNotFound, "할 일을 찾을 수 없다")
case errors.Is(err, task.ErrInvalidStatus):
writeErr(w, http.StatusBadRequest, err.Error())
default:
// 검증 실패는 400, 나머지는 500이다. 검증 에러에 센티널을 두면
// 이 분기가 더 깔끔해진다 — 12장의 숙제로 남긴다.
writeErr(w, http.StatusBadRequest, err.Error())
}
}
그리고 HTTP 테스트를 두 저장소로 각각 돌린다. 핸들러 코드는 한 줄도 다르지 않다.
func TestAPIBehavesTheSameOnBothStores(t *testing.T) {
for _, tc := range stores() {
t.Run(tc.name, func(t *testing.T) {
svc := task.NewService(tc.make(t), func() time.Time { return clock })
h := httpapi.Handler(svc)
실행
go run ./07-practice
== 메모리 구현 ==
생성 id=1 장보기 (todo)
생성 id=2 청소 (todo)
생성 id=3 운동 (todo)
u2가 1번 조회: task: 할 일 없음 (ErrNotFound=true)
수정 id=2 status=doing owner=u1
PrefixAll 중간 실패: task: 2번째에서 일부러 실패
id=1 장보기
id=2 청소
id=3 운동
CompleteAll: 3건
GET /tasks?status=done&limit=2 → 200
{"items":[{"id":1,"title":"장보기","status":"done","created_at":"2026-03-01T09:00:00Z","updated_at":"2026-03-01T09:00:00Z"},{"id":2,"title":"청소","status":"done","created_at":"2026-03-01T09:00:00Z","updated_at":"2026-03-01T09:00:00Z"}]}
== GORM 구현 ==
생성 id=1 장보기 (todo)
생성 id=2 청소 (todo)
생성 id=3 운동 (todo)
u2가 1번 조회: task: 할 일 없음 (ErrNotFound=true)
수정 id=2 status=doing owner=u1
PrefixAll 중간 실패: task: 2번째에서 일부러 실패
id=1 장보기
id=2 청소
id=3 운동
CompleteAll: 3건
GET /tasks?status=done&limit=2 → 200
{"items":[{"id":1,"title":"장보기","status":"done","created_at":"2026-03-01T09:00:00Z","updated_at":"2026-03-01T09:00:00Z"},{"id":2,"title":"청소","status":"done","created_at":"2026-03-01T09:00:00Z","updated_at":"2026-03-01T09:00:00Z"}]}
두 구현의 출력이 같은가: true
한 글자도 다르지 않다. 시계를 주입했고, 정렬을 맞췄고, 에러를 도메인 타입으로 번역했기 때문이다. 세 가지 중 하나라도 빠지면 이 출력은 갈라진다.
인터페이스로 감싸면 무엇이 좋은가
정직하게 말하면, 저장 기술을 바꾸기 위해서가 아니다. GORM에서 sqlc로 갈아타는 일은 실제로는 잘 일어나지 않는다. 진짜 이득은 셋이다.
테스트가 빨라진다. 메모리 구현으로 도는 서비스 테스트는 DB 준비가 필요 없다. 이 예제는 SQLite라 GORM 쪽도 빠르지만, PostgreSQL이었다면 차이가 크다.
의존 방향이 뒤집힌다. 도메인이 저장소를 모른다. GORM 버전을 올려도, 훅 버그를 만나도, 컬럼 이름을 바꿔도 도메인 패키지는 컴파일된다.
계약이 문서가 된다. repotest를 읽으면 저장소가 무엇을 약속하는지 알 수 있다.
새 구현을 추가하는 사람은 그 스위트를 통과시키면 된다.
:::warning 과하게 하지 않기 테이블이 세 개인 프로젝트에 이 구조를 다 얹으면 코드가 두 배가 된다. 인터페이스, 두 구현, 변환 함수, 적합성 테스트는 전부 유지보수 대상이다.
시작은 구체 타입으로 하고, 두 번째 구현이 실제로 필요해질 때 추출해도 늦지 않다. Go에서 인터페이스는 나중에 뽑아내기 쉽다 — 구현체가 인터페이스를 명시적으로 선언하지 않기 때문이다. 10-8이 구체 타입으로 시작한 것이 잘못이 아니었던 이유다. :::
흔한 실수
인터페이스를 저장소 패키지에 둔다. 아무것도 분리되지 않는다.
저장소 메서드를 그대로 인터페이스로 옮긴다. 쓰는 쪽이 필요한 것만 담는다.
트랜잭션을 저장소 인터페이스에 넣는다. BeginTx() (Repository, error) 같은
모양은 커밋/롤백 책임이 애매해진다. 콜백 형태가 낫다.
메모리 구현을 대충 만든다. 테스트가 거짓말을 하기 시작한다. 적합성 테스트가 없으면 그 사실을 알 수 없다.
메모리 구현에서 정렬을 안 맞춘다. 맵 순회는 무작위다. SQL 쪽만 ORDER BY가 있으면
테스트가 간헐적으로 깨진다.
도메인 구조체에 gorm 태그를 붙인다. 도메인이 ORM에 묶인다.
정리
- 인터페이스는 쓰는 쪽에 둔다. 도메인 패키지가 선언하고 저장소 패키지가 구현한다.
- 소유자 ID를 시그니처에 넣으면 그것이 그대로
WHERE owner_id = ?가 되고, 404 정책이 SQL 한 줄로 표현된다. - 트랜잭션 경계는 서비스 계층에 있다. 저장소는 어디까지가 한 단위인지 모른다.
WithinTx(ctx, func(ctx, Repository) error)는 "바깥 db를 쓰지 마라"를 구조로 바꾼 것이다.- 적합성 테스트가 인터페이스의 의미를 검사한다. 컴파일러가 못 하는 일이다.
- 도메인 타입과 행 타입을 분리한다. 변환 함수 두 개의 값은 저장소가 둘 이상일 때 드러난다.
- 시각을 Go에서 만들지 DB에서 만들지는 선택이고, 그 선택을 적어 둔다.
- 이 구조는 공짜가 아니다. 두 번째 구현이 필요해질 때 뽑아내도 늦지 않다.
연습문제
-
task.Repository에Count(ctx, ownerID) (int64, error)를 추가해 보자. 두 구현과 적합성 테스트에 각각 무엇을 더해야 하는가? 그리고 이 메서드를 추가하기 전에 물어야 할 질문이 하나 있다 — 누가 이것을 쓰는가? 쓰는 사람이 아직 없다면 추가하지 않는 것이 맞다(YAGNI). -
낙관적 잠금을 넣어 보자.
Task에Version int를 추가하고,Save가WHERE ... AND version = ?로 갱신하며 버전을 올린다. 충돌했을 때 어떤 에러를 돌려주어야 하는가? 그 에러는 HTTP 몇 번인가? 메모리 구현에서도 같은 의미를 재현할 수 있는가? 적합성 테스트로 두 구현이 같은지 확인해 보자. -
PlaceOrder처럼 커밋 후에 이벤트를 발행해야 하는 유스케이스를 추가해 보자.WithinTx의 콜백 안에서 발행하면 안 되는 이유는 11-5에서 봤다. 그렇다면 서비스는 어떻게 생겨야 하는가? 커밋은 성공했는데 발행이 실패하면 어떻게 되는가? (힌트: 이 문제의 표준적인 답은 "발행할 이벤트도 같은 트랜잭션에서 DB에 쓰고, 별도 워커가 그것을 읽어 발행한다"이다. 이름이 있는 패턴이니 찾아보자.)