도메인과 저장소 계층
이 챕터에서 다루는 것
도메인 타입과 저장소 계층을 만든다. 파트 11-7의 task/memtask/gormtask 스택을
가져오되 세 가지를 바꾼다.
- 센티널 에러를
apperr로 만든다. 도메인이 HTTP를 모른 채로 "이건 404"를 말한다. - 잡 큐를
task.Tx에 넣는다. 할 일 저장과 잡 등록이 한 트랜잭션이어야 하기 때문이다. - 스키마를
AutoMigrate가 아니라 golang-migrate가 만든다.
센티널 에러를 종류로 만든다
파트 11에서는 이렇게 썼다.
var ErrNotFound = errors.New("task: 할 일 없음")
이 값에는 정보가 하나 부족하다. "이것이 몇 번 상태 코드인가" 를 아는 곳이
어디에도 없다. HTTP 계층에서 errors.Is(err, task.ErrNotFound)로 분기하면 되지만,
도메인 에러가 열 개로 늘어나면 HTTP 계층에 열 줄짜리 스위치가 생기고,
새 에러를 추가할 때마다 그 스위치를 고치는 것을 잊는다. 잊으면 500이 나간다.
12-1의 apperr.E가 그 정보를 담는다. 그리고 apperr.E는 포인터를 돌려주므로,
패키지 수준 변수 하나로 두면 그대로 센티널이 된다.
// 도메인 센티널 에러.
//
// 파트 11에서는 errors.New로 만들었다. 여기서는 apperr.E로 만든다.
// 값은 여전히 하나뿐이라 errors.Is가 동일성으로 맞히고, 동시에
// apperr.KindOf가 종류를 읽어 HTTP 상태 코드까지 자동으로 정해진다.
// 도메인이 "이건 404다"라고 말하면서도 HTTP는 모르는 상태가 유지된다.
var (
// ErrNotFound는 없는 ID를 가리켰을 때다.
//
// 남의 할 일에 접근했을 때도 같은 에러를 쓴다. 403을 주면 "그 ID는
// 존재한다"는 사실이 새어 나간다. SQL에서 이 정책은 WHERE 절에
// owner_id 조건을 함께 넣는 것으로 자연스럽게 구현된다.
ErrNotFound = apperr.E(apperr.KindNotFound, "task", "할 일을 찾을 수 없다", nil)
// ErrInvalidStatus는 알 수 없는 상태를 받았을 때다.
ErrInvalidStatus = apperr.E(apperr.KindInvalid, "task", "알 수 없는 상태다", nil)
// ErrEmptyTitle은 제목이 비었을 때다.
ErrEmptyTitle = apperr.E(apperr.KindInvalid, "task", "제목이 비어 있다", nil)
// ErrNoOwner는 소유자가 없을 때다. 인증 미들웨어를 통과했다면
// 일어날 수 없으므로, 이것이 보이면 배선 버그다.
ErrNoOwner = apperr.E(apperr.KindInternal, "task", "", nil)
)
세 가지가 한꺼번에 성립한다.
errors.Is(err, task.ErrNotFound)가 동작한다. 값이 하나뿐이라 포인터 동일성으로 맞는다.apperr.KindOf(err)가KindNotFound를 돌려준다. HTTP 계층은 종류만 보면 된다.apperr.Public(err)가 "할 일을 찾을 수 없다"를 돌려준다. 사용자에게 그대로 나가도 되는 문장이다.
ErrNoOwner의 Msg가 비어 있는 것도 의도다. 공개 메시지가 없으면
apperr.Public이 종류별 기본 문구("일시적인 오류가 발생했다")를 쓴다.
내부 버그를 사용자에게 설명하지 않는다.
:::warning Wrap을 통과해도 종류가 유지되어야 한다
apperr.Wrap은 Op만 얹고 종류는 원래 것을 물려받는다. 그래서
apperr.Wrap("httpapi.getTask", task.ErrNotFound)는 여전히 404다.
계층을 지날 때마다 종류가 바뀌면 정책이 흐트러진다. 종류를 바꿔야 한다면
그것은 감싸기가 아니라 번역이고, apperr.E로 새로 만들어야 한다.
:::
잡 큐를 트랜잭션 안으로
할 일을 만들면 알림 잡이 하나 생긴다. 이 둘이 원자적이어야 한다.
// 이렇게 하면 안 된다
created, _ := repo.Create(ctx, t)
queue.Publish(ctx, event) // ← 여기서 프로세스가 죽으면 알림이 사라진다
12-8의 아웃박스가 답이다. 같은 DB의 같은 트랜잭션에 쓴다. 그런데 파트 11의
TxManager는 콜백에 Repository만 넘겼다. 잡을 넣으려면 그것으로 부족하다.
인터페이스를 하나 추가하고 트랜잭션 뷰를 합성 타입으로 바꾼다.
// Tx는 트랜잭션 안에서 쓸 수 있는 것 전부다.
//
// Repository에 JobQueue를 더한 것이 요점이다. 할 일을 저장하는 것과
// 그에 딸린 잡을 큐에 넣는 것이 한 트랜잭션이어야 하기 때문이다.
// 12-8의 아웃박스가 *sql.Tx를 첫 인자로 요구했던 것과 같은 발상이고,
// 여기서는 타입 자체가 그 제약을 표현한다.
type Tx interface {
Repository
JobQueue
}
넣는 쪽과 꺼내는 쪽을 다른 인터페이스로 나눈 것도 의도다.
// JobQueue는 잡을 넣는 쪽이다.
//
// 넣는 것과 꺼내는 것을 다른 인터페이스로 나눴다. HTTP 핸들러는 넣기만
// 하고 워커는 꺼내기만 하므로, 한 인터페이스로 묶으면 양쪽 다 쓰지 않는
// 메서드를 보게 된다.
type JobQueue interface {
// Enqueue는 잡을 대기 상태로 넣는다. ID가 채워진 값을 돌려준다.
Enqueue(ctx context.Context, j Job) (Job, error)
}
서비스 계층에서 트랜잭션 경계가 이렇게 보인다.
// Create는 새 할 일을 만들고 알림 잡을 같은 트랜잭션에 넣는다.
//
// 잡을 커밋 뒤에 넣으면 그 사이에 프로세스가 죽었을 때 알림이 사라진다.
// 커밋 전에 외부 큐로 보내면 롤백됐는데 알림만 나간다. 같은 DB의 같은
// 트랜잭션에 넣는 것이 이 문제를 통째로 없앤다 — 12-8의 아웃박스다.
func (s *Service) Create(ctx context.Context, ownerID, title string) (Task, error) {
var out Task
err := s.store.WithinTx(ctx, func(ctx context.Context, tx Tx) error {
created, err := tx.Create(ctx, t)
if err != nil {
return err
}
if _, err := enqueueEvent(ctx, tx, now, TopicTaskCreated, created); err != nil {
return err
}
out = created
return nil
})
Update는 조금 더 흥미롭다. 상태가 done으로 바뀌는 순간에만 완료 알림을 넣어야
하는데, 그 판단에 "바뀌기 전 값"이 필요하다.
if current.Status != StatusDone && next.Status == StatusDone {
if _, err := enqueueEvent(ctx, tx, next.UpdatedAt, TopicTaskCompleted, next); err != nil {
return err
}
}
읽기와 쓰기가 한 트랜잭션 안에 있어야 이 조건이 의미를 갖는다. 두 요청이 동시에
done으로 바꾸려 하면, 트랜잭션이 없을 때는 둘 다 "이전 상태가 done이 아니다"를
보고 알림을 두 번 보낸다.
페이로드는 도메인 타입이 아니다
잡 페이로드에 task.Task를 그대로 직렬화하지 않는다.
// Event는 잡 페이로드에 담기는 내용이다.
//
// 도메인 구조체(Task)를 그대로 직렬화하지 않는다. 페이로드는 DB에 저장되어
// 며칠 뒤에 소비될 수 있고, 그동안 Task 구조체는 바뀔 수 있다. 저장되는
// 형식은 도메인과 별도로 관리해야 필드 하나 지우는 것이 옛 잡을 깨뜨리지 않는다.
type Event struct {
TaskID int64 `json:"task_id"`
OwnerID string `json:"owner_id"`
Title string `json:"title"`
Status string `json:"status"`
}
같은 이유가 HTTP 응답에도 적용된다(13-3). 도메인 구조체가 직렬화되는 곳이 늘어날수록 필드 하나 고치기가 어려워진다.
잡 테이블의 설계
Job에는 아웃박스에 없던 필드가 셋 있다.
// Job은 백그라운드로 처리할 작업 하나다.
//
// 12-8의 아웃박스 테이블을 확장한 것이다. 아웃박스에는 발행 여부만
// 있었는데, 여기에는 재시도가 있으므로 세 필드가 더 필요하다.
//
// - Attempts — 몇 번 시도했는가. 집어 갈 때 올린다.
// - AvailableAt — 언제부터 집어도 되는가. 백오프가 여기 표현된다.
// - ClaimedBy — 누가 집었는가. 중복 실행을 조사할 때 유일한 단서다.
type Job struct {
ID int64
Topic string
Payload string
Status JobStatus
Attempts int
AvailableAt time.Time
ClaimedBy string
LastError string
CreatedAt time.Time
UpdatedAt time.Time
}
워커 쪽 연산은 JobStore에 따로 모은다. Claim의 주석이 이 챕터에서 가장 중요한
설계 결정을 담고 있다.
// Claim은 실행 가능한 잡을 최대 limit개 집어 온다.
//
// 여러 워커 프로세스가 동시에 불러도 같은 잡이 두 곳에 가면 안 된다.
// 12-8 연습문제 2가 남긴 숙제이고, 구현은 조건부 UPDATE로 한다.
//
// 집어 간 잡은 status=running이 되고 available_at이 now+visibility로
// 밀린다. 워커가 죽어 Complete도 Fail도 못 부르면, 그 시각이 지난 뒤
// 다른 워커가 다시 집는다. 대가는 최소 한 번 실행이다.
Claim(ctx context.Context, workerID string, now time.Time, visibility time.Duration, limit int) ([]Job, error)
마이그레이션 — golang-migrate
파트 11에서는 AutoMigrate를 썼고, 11-5에서 "실제 서비스라면 golang-migrate"라고
말만 하고 넘어갔다. 여기서 갚는다.
AutoMigrate가 못 하는 것이 셋 있다.
- 컬럼을 지우거나 이름을 바꾸지 못한다. 필드를 지우면 컬럼은 그냥 남는다.
- "지금 스키마가 몇 번인가"를 기록하지 않는다. 롤백할 수도 없다.
- 실패한 마이그레이션을 표시하지 않는다. 절반만 적용된 상태로 서비스가 뜬다.
golang-migrate는 schema_migrations 테이블에 버전과 dirty 플래그를 남긴다.
CREATE TABLE jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
topic TEXT NOT NULL,
payload TEXT NOT NULL,
status TEXT NOT NULL,
attempts INTEGER NOT NULL DEFAULT 0,
-- 시각을 유닉스 나노초 정수로 둔다. SQLite는 타입이 동적이라
-- 문자열 시각도 들어가지만, 그러면 부등호 비교가 사전순 비교가 되어
-- 포맷이 조금만 달라져도 조용히 틀린 결과를 낸다.
available_at INTEGER NOT NULL,
claimed_by TEXT NOT NULL DEFAULT '',
last_error TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
-- 워커의 Claim 쿼리가 그대로 타는 인덱스다.
CREATE INDEX idx_jobs_claim ON jobs (status, available_at, id);
:::danger SQLite에 시각을 문자열로 넣지 않는다
available_at이 INTEGER인 것은 취향이 아니다. 워커의 Claim은
available_at <= ? 부등호 비교를 하는데, SQLite는 타입이 동적이라 문자열도
그대로 들어가고 문자열끼리의 부등호는 사전순 비교가 된다.
time.RFC3339Nano는 뒤쪽 0을 지운다. 그래서
"2026-08-13T00:00:00.1Z"와 "2026-08-13T00:00:00.05Z"의 사전순은 시간순과 반대다.
두 번째가 더 이른 시각인데 사전순으로는 뒤에 온다.
이 버그는 대부분의 경우 동작하고 가끔 잘못된 결과를 낸다. 가장 나쁜 종류다. 나노초 정수로 두면 그런 함정이 없다. :::
마이그레이션은 바이너리에 박는다.
// migrationFS는 마이그레이션 SQL을 바이너리에 박는다.
//
// 파일을 옆에 두고 경로로 읽으면 컨테이너 이미지에 SQL을 같이 넣어야
// 하고, 넣는 것을 잊으면 배포된 뒤에야 안다. go:embed는 그 실수를
// 컴파일 시점으로 옮긴다 — 파일이 없으면 빌드가 안 된다.
//
//go:embed migrations/*.sql
var migrationFS embed.FS
// ErrNoChange는 "이미 최신"이라는 뜻이지 실패가 아니다.
// 이것을 에러로 다루면 두 번째 기동부터 프로세스가 죽는다.
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
return 0, fmt.Errorf("gormstore: 마이그레이션: %w", err)
}
v, dirty, err := m.Version()
if err != nil {
return 0, fmt.Errorf("gormstore: 버전 확인: %w", err)
}
if dirty {
return v, fmt.Errorf("gormstore: 스키마가 dirty다 (버전 %d). 손으로 고쳐야 한다", v)
}
return v, nil
dirty일 때 기동을 막는 것이 중요하다. 절반만 적용된 스키마 위에서 서비스가
도는 것보다, 안 뜨고 사람을 부르는 편이 낫다.
기동 시 로그에 버전이 찍힌다.
time=2026-08-13T13:35:02.319+09:00 level=INFO msg="schema ready" version=2
:::note 마이그레이션을 기동에 둘 것인가 인스턴스가 하나면 기동에 두는 것이 편하다. 여럿이면 동시에 뜨며 같은 마이그레이션을 돌리려 하고, golang-migrate가 잠금으로 막긴 하지만 배포가 느려진다.
규모가 커지면 배포 파이프라인의 별도 단계로 뺀다 — 쿠버네티스라면 initContainer나 Job이다. 그때는 애플리케이션이 마이그레이션을 돌리지 않고 확인만 하도록 바꾼다. "스키마 버전이 기대값보다 낮으면 기동 실패"가 그 확인이다. :::
커넥션 하나를 마이그레이터와 GORM이 나눠 쓴다
// Open은 *sql.DB를 열고, 마이그레이션을 돌리고, 그 위에 GORM을 얹는다.
//
// 커넥션을 하나만 만드는 것이 요점이다. golang-migrate에 DSN을 주면
// 자기 커넥션을 따로 열고, 그 커넥션은 풀 설정도 PRAGMA도 다르다.
// database/sql 인스턴스 하나를 마이그레이터와 GORM이 나눠 쓰면
// "마이그레이션은 성공했는데 앱은 다른 파일을 보고 있다" 같은 일이 없다.
//
// 돌려주는 *sql.DB는 호출자가 닫는다. GORM은 이 풀을 빌려 쓸 뿐이다.
func Open(driver, dsn string, pool Pool, gl logger.Interface) (*gorm.DB, *sql.DB, uint, error) {
db, err := gorm.Open(gormsqlite.Dialector{Conn: sqlDB}, &gorm.Config{
Logger: gl,
// 기본 트랜잭션을 끈다. GORM은 Create/Update 하나하나를
// 트랜잭션으로 감싸는데, 쓰기마다 BEGIN/COMMIT이 붙는 비용이 있고
// 이 서비스는 필요한 곳에서 WithinTx로 명시적으로 감싼다.
SkipDefaultTransaction: true,
})
Dialector{Conn: sqlDB}가 13-1에서 말한 드라이버 충돌 회피의 핵심이기도 하다.
GORM은 자기 드라이버를 열지 않고 넘겨받은 풀만 쓴다.
중복 실행을 막는 조건부 UPDATE
12-8 연습문제 2의 답이다. 워커가 둘이면 같은 잡을 둘 다 집을 수 있고, 그러면 알림이 두 번 나간다.
// Claim은 실행 가능한 잡을 집어 온다.
//
// 12-8 연습문제 2의 답이다. 두 워커가 같은 잡을 집으면 알림이 두 번 나간다.
// 막는 방법은 두 가지다.
//
// - PostgreSQL: SELECT ... FOR UPDATE SKIP LOCKED. 후보 행에 락을 걸고
// 이미 잠긴 행은 건너뛴다. 한 번의 왕복으로 끝나 가장 깔끔하다.
// - 어디서나: 후보를 읽고, 조건부 UPDATE로 "내가 집는다"를 시도한다.
// UPDATE ... WHERE id=? AND status=? AND available_at<=? 의
// RowsAffected가 1이면 내가 이긴 것이고 0이면 남이 먼저 집은 것이다.
// 비교와 교체가 한 문장 안에서 원자적으로 일어나는 것이 요점이다.
//
// SQLite에는 SKIP LOCKED가 없으므로 뒤쪽을 쓴다. 다만 SQLite는 쓰기가
// 애초에 한 번에 하나뿐이라 경쟁 자체가 드물다 — 그래서 "SQLite에서는
// 문제가 덜 심각하다". 덜 심각한 것이지 없는 것은 아니고, 조건부 UPDATE가
// 없으면 두 워커가 같은 행을 읽고 순서대로 UPDATE해서 둘 다 성공한다.
claimed := make([]task.Job, 0, len(candidates))
for _, c := range candidates {
n, err := gorm.G[jobRow](s.db).
// 읽었을 때의 상태와 available_at을 조건에 그대로 넣는다.
// 그사이 남이 집었다면 둘 중 하나는 달라져 있고 UPDATE는 0행이다.
Where("id = ? AND status = ? AND available_at = ?", c.ID, c.Status, c.AvailableAt).
Set(
assign("status", string(task.JobRunning)),
assign("attempts", gorm.Expr("attempts + 1")),
assign("claimed_by", workerID),
assign("available_at", deadline),
assign("updated_at", nowNS),
).
Update(ctx)
if err != nil {
return claimed, fmt.Errorf("gormstore: 잡 클레임: %w", err)
}
if n == 0 {
// 남이 먼저 집었다. 정상이므로 그냥 넘어간다.
continue
}
attempts를 집을 때 올리는 것도 결정이다. 처리 후에 올리면, 워커가 잡을 집고
프로세스째 죽었을 때 시도 횟수가 늘지 않아 같은 잡이 영원히 재시도된다.
집을 때 올리면 죽어도 횟수가 소모되고, 결국 dead로 간다.
적합성 테스트 — 같은 테스트를 둘 다에게
파트 11의 repotest와 같은 발상이다. 구현이 둘이면 "둘이 정말 같게 동작하는가"가
진짜 질문이 되고, 답하는 방법은 같은 테스트를 둘 다에게 돌리는 것뿐이다.
// Factory는 빈 저장소를 하나 만든다. 테스트마다 새로 부른다.
type Factory func(t *testing.T) (task.Store, task.JobStore)
// Run은 적합성 테스트 전부를 돌린다.
func Run(t *testing.T, newStore Factory) {
t.Helper()
t.Run("소유자범위", func(t *testing.T) { testOwnerScope(t, newStore) })
t.Run("목록정렬과필터", func(t *testing.T) { testList(t, newStore) })
t.Run("트랜잭션롤백", func(t *testing.T) { testRollback(t, newStore) })
t.Run("잡클레임은배타적", func(t *testing.T) { testClaimExclusive(t, newStore) })
t.Run("백오프이전에는안집힌다", func(t *testing.T) { testClaimRespectsBackoff(t, newStore) })
t.Run("가시성만료후회수", func(t *testing.T) { testVisibilityReclaim(t, newStore) })
t.Run("상태집계", func(t *testing.T) { testCountByStatus(t, newStore) })
}
가장 중요한 것이 배타적 클레임 테스트다. 이것이 없으면 조건부 UPDATE가 실제로 동작하는지 아무도 모른다.
// testClaimExclusive는 12-8 연습문제 2의 답을 검증한다.
//
// 워커 열 개가 동시에 Claim을 부른다. 잡은 하나뿐이므로 정확히 하나만
// 가져가야 한다. 둘이 가져가면 알림이 두 번 나간다.
func testClaimExclusive(t *testing.T, newStore Factory) {
var wg sync.WaitGroup
for range workers {
wg.Go(func() {
got, err := js.Claim(ctx, "w", base, time.Minute, 10)
mu.Lock()
defer mu.Unlock()
total += len(got)
if err != nil {
errs = append(errs, err)
}
})
}
wg.Wait()
if err := errors.Join(errs...); err != nil {
t.Fatalf("Claim: %v", err)
}
if total != 1 {
t.Errorf("동시 Claim 총 %d건, 1건을 기대 (중복 실행이다)", total)
}
두 구현이 이것을 서로 다른 방법으로 만족한다. 메모리 구현은 뮤텍스로, GORM 구현은 조건부 UPDATE로. 인터페이스가 약속하는 것은 결과이지 방법이 아니다.
// Claim은 실행 가능한 잡을 집어 온다.
//
// 뮤텍스가 배타성을 보장한다. GORM 구현에서는 이 자리를 조건부 UPDATE가
// 대신한다 — 프로세스가 둘이면 뮤텍스는 서로를 보지 못하기 때문이다.
두 테스트 파일은 각각 세 줄이다.
func TestConformance(t *testing.T) {
t.Parallel()
storetest.Run(t, func(*testing.T) (task.Store, task.JobStore) {
s := memstore.New()
return s, s
})
}
// newStore는 테스트마다 새 파일 DB를 연다.
//
// ":memory:"를 쓰면 커넥션마다 다른 DB가 열린다. 풀이 커넥션을 여러 개
// 만드는 순간 "방금 넣은 행이 없다"가 되고, 원인을 찾는 데 반나절이 간다.
// t.TempDir()의 파일이면 그 함정이 없고 테스트가 끝나면 자동으로 지워진다.
func newStore(t *testing.T) (task.Store, task.JobStore) {
마이그레이션이 실제로 돌았는지도 여기서 확인한다.
if version != 2 {
t.Fatalf("마이그레이션 버전 = %d, 2를 기대", version)
}
돌려 보면 이렇다.
$ go test -count=1 ./internal/memstore/ ./internal/gormstore/
ok example.com/capstone/internal/memstore 1.083s
ok example.com/capstone/internal/gormstore 0.449s
:::tip 적합성 테스트가 실제로 잡는 것 대개 메모리 구현 쪽의 거짓말이다. 롤백이 진짜로 되는지, 소유자 범위가 지켜지는지, 클레임이 배타적인지. 메모리 구현은 "대충 맞게" 짜기 쉽고, 그 대충이 서비스 계층 테스트를 통과시켜 버린다. 그러면 운영에서만 깨진다.
이 파트를 쓰면서 실제로 잡힌 것 하나: 백오프 테스트를 처음 쓸 때 Retry 뒤의
Attempts가 2일 것으로 기대했는데 1이 나왔다. Attempts가 집을 때 오르는데
테스트가 집지 않고 바로 Retry를 불렀기 때문이다. 테스트가 틀렸던 것이지만,
그 덕분에 "언제 세는가"라는 결정이 명시적으로 문서에 남았다.
:::
흔한 실수
도메인 에러를 문자열로 구별한다. if strings.Contains(err.Error(), "not found").
메시지를 고치는 순간 조용히 깨진다.
AutoMigrate를 운영에 쓴다. 버전도 롤백도 없다. 컬럼을 지우지도 못한다.
마이그레이션에 down을 안 쓴다. 되돌려야 하는 순간에 없다는 것을 알게 된다.
CI에서 짝을 검사한다(13-5).
SQLite에 시각을 문자열로 넣고 부등호로 비교한다. 대부분 동작하고 가끔 틀린다.
AvailableAt을 미래로 두고 테스트한다. 워커가 잡을 영원히 집지 않는다.
이 파트를 쓰면서 실제로 밟았다 — 테스트 고정 시각을 오늘 오전 9시(UTC)로 뒀는데
실행 시각이 UTC 04시라, 모든 잡이 "아직 실행할 때가 아닌" 상태였다.
메모리 구현의 트랜잭션을 흉내만 낸다. 적합성 테스트로 강제한다.
:memory: DSN을 커넥션 풀과 함께 쓴다. 커넥션마다 다른 DB가 열린다.
attempts를 처리 후에 올린다. 프로세스가 죽으면 횟수가 소모되지 않아
같은 잡이 영원히 재시도된다.
정리
- 센티널 에러를
apperr.E로 만들면errors.Is와 종류 기반 상태 코드 결정이 동시에 성립한다. 도메인은 여전히 HTTP를 모른다. Tx = Repository + JobQueue. 타입이 "할 일 저장과 잡 등록은 한 트랜잭션"을 표현한다.- 페이로드 타입은 도메인 타입과 별개로 관리한다. 저장된 잡은 코드보다 오래 산다.
- 스키마는 golang-migrate가 만든다. 버전과
dirty를 기록하고,dirty면 기동을 막는다. go:embed로 SQL을 바이너리에 박는다. 배포에서 빠뜨릴 수 없게 된다.*sql.DB하나를 마이그레이터와 GORM이 나눠 쓴다.- 조건부 UPDATE의
RowsAffected가 클레임의 승자를 정한다. PostgreSQL이라면FOR UPDATE SKIP LOCKED가 더 깔끔하다. - 적합성 테스트를 두 구현에 똑같이 돌린다. 인터페이스가 약속하는 것은 결과다.
연습문제
-
storetest에 테스트를 하나 더 추가해 보자. "Retry로 되돌린 잡의ClaimedBy가 비워지는가." 두 구현 다 통과하는가? 통과한다면,ClaimedBy를 비우지 않는 편이 나은 경우를 하나 생각해 보자. (힌트: 같은 워커가 반복해서 실패시키고 있다면?) -
jobs테이블에dead상태로 오래 남은 행을 지우는 마이그레이션과 정리 작업을 설계해 보자. 마이그레이션000003을 추가하고,down도 함께 쓴다. 정리 작업을 워커의 토픽 하나로 만드는 것과 별도 cron으로 두는 것 중 어느 쪽이 나은가? -
task.Repository의 메서드는 전부ownerID를 첫 인자로 받는다. 이것을context에 넣어 나르면 인자가 줄어든다. 왜 그렇게 하지 않았는지 생각해 보자. (힌트: 컴파일러가 무엇을 확인해 주는가? 워커는 소유자가 누구인가?)