Skip to content

ADR-07: 리포지토리 패턴 데이터 접근 추상화

🇺🇸 English Version

날짜작성자리포지토리
2024-12-18@KubrickCodeworker

컨텍스트

문제

UseCase 레이어에 직접 데이터베이스 쿼리가 산재되면 여러 문제 발생:

강한 결합:

  • UseCase가 PostgreSQL 특정 코드(pgx, pgtype)에 직접 의존
  • 데이터베이스 벤더 변경 시 비즈니스 로직 수정 필요
  • SQL 쿼리가 비즈니스 워크플로우 오케스트레이션과 혼재

테스트 어려움:

  • 단위 테스트에 데이터베이스 연결 또는 복잡한 모킹 필요
  • 비즈니스 로직을 격리하여 테스트 불가
  • 간단한 규칙 검증에도 통합 테스트 필요

코드 구성:

  • 데이터 접근과 비즈니스 로직 사이 명확한 경계 없음
  • 쿼리 최적화 관심사가 UseCase로 누출
  • 여러 usecase에 쿼리 패턴 중복

목표

  1. 추상화: 데이터베이스 구현 세부사항을 UseCase로부터 숨김
  2. 테스트 용이성: 간단한 mock 구현으로 단위 테스트 가능
  3. 유지보수성: 데이터 접근 로직을 전용 레이어에 중앙화
  4. 도메인 정렬: 데이터 작업을 도메인 용어로 표현

결정

Domain 레이어에 정의된 도메인 중심 인터페이스로 Repository 패턴 채택.

인터페이스 설계

go
// domain/analysis/repository.go
type Repository interface {
    CreateAnalysisRecord(ctx context.Context, params CreateAnalysisRecordParams) (UUID, error)
    RecordFailure(ctx context.Context, analysisID UUID, errMessage string) error
    SaveAnalysisInventory(ctx context.Context, params SaveAnalysisInventoryParams) error
}

주요 특성

항목결정
인터페이스 위치Domain 레이어 (domain/analysis/repository.go)
구현체 위치Adapter 레이어 (adapter/repository/postgres/)
트랜잭션 범위메서드 단위 (각 메서드가 원자적)
파라미터 스타일검증 기능을 가진 Value Object
에러 처리도메인 에러 + 래핑된 인프라 에러

검토한 대안

옵션 A: Repository 패턴 (선택됨)

설명:

Domain 레이어에 인터페이스 정의, Adapter 레이어에 구현. 각 메서드는 완전하고 원자적인 작업을 표현.

장점:

  • 도메인 로직과 영속성 간 명확한 분리
  • UseCase는 추상화에만 의존
  • 단위 테스트를 위한 모킹 용이
  • 비즈니스 로직에 영향 없이 구현 변경 가능

단점:

  • 추가적인 추상화 레이어
  • 메서드가 많아지는 "repository 비대화" 위험
  • 세분화된 작업과 큰 단위 작업 사이 균형 필요

옵션 B: 쿼리 오브젝트 패턴

설명:

특정 쿼리를 캡슐화하는 쿼리 객체를 생성하여 범용 실행기에 전달.

장점:

  • 매우 유연한 쿼리 구성
  • 재사용 가능한 쿼리 조각

단점:

  • 더 복잡한 API 표면
  • 쿼리 객체가 영속성 세부사항을 노출할 수 있음
  • 데이터 흐름 이해 어려움

옵션 C: Active Record 패턴

설명:

도메인 객체가 자체 영속성 메서드를 포함.

장점:

  • CRUD 작업에 간단하고 직관적
  • 작은 도메인에서 적은 코드

단점:

  • 도메인 객체가 영속성 로직으로 무거워짐
  • 단일 책임 원칙 위반
  • 도메인과 인프라 간 강한 결합
  • 도메인 로직을 격리하여 테스트하기 어려움

구현 원칙

Domain 레이어의 인터페이스 정의

인터페이스는 구현되는 곳이 아닌 사용되는 곳에 정의:

domain/
  analysis/
    repository.go      ← 인터페이스 정의 (Repository)
    autorefresh.go     ← 확장 인터페이스 (AutoRefreshRepository)

adapter/
  repository/
    postgres/
      analysis.go      ← PostgreSQL 구현체

근거:

  • Domain 레이어가 인프라 의존성 없이 유지
  • 의존성 역전: 상위 레벨 모듈이 계약 정의
  • 구현 세부사항이 Adapter 레이어에 격리

Value Object 파라미터

원시 타입 파라미터 대신 검증된 Value Object 사용:

go
type CreateAnalysisRecordParams struct {
    AnalysisID *UUID    // 선택: 제공된 ID 사용 또는 새로 생성
    Branch     string
    CommitSHA  string
    Owner      string
    Repo       string
}

func (p CreateAnalysisRecordParams) Validate() error {
    if p.Owner == "" {
        return fmt.Errorf("%w: owner is required", ErrInvalidInput)
    }
    // ... 검증 로직
}

장점:

  • 자기 문서화되는 메서드 시그니처
  • 검증 로직이 데이터와 함께 위치
  • API를 깨지 않고 쉽게 확장
  • 필수 필드와 선택 필드의 명확한 구분

메서드 단위 트랜잭션 범위

각 Repository 메서드는 완전하고 원자적인 작업:

go
func (r *AnalysisRepository) CreateAnalysisRecord(ctx context.Context, params ...) (UUID, error) {
    tx, err := r.pool.Begin(ctx)
    if err != nil {
        return NilUUID, fmt.Errorf("begin transaction: %w", err)
    }
    defer tx.Rollback(ctx)  // 안전: 커밋되면 no-op

    // ... 트랜잭션 내 작업

    if err := tx.Commit(ctx); err != nil {
        return NilUUID, fmt.Errorf("commit transaction: %w", err)
    }
    return result, nil
}

근거:

  • 단순한 멘탈 모델: 각 메서드는 완전히 성공하거나 실패
  • 메서드 경계를 넘는 트랜잭션 누출 없음
  • UseCase가 트랜잭션 라이프사이클 관리 불필요
  • Context 취소 자동 처리

에러 메시지 Truncation

긴 에러 메시지는 저장 전 잘라냄:

go
const maxErrorMessageLength = 1000

func truncateErrorMessage(msg string) string {
    if len(msg) <= maxErrorMessageLength {
        return msg
    }
    return msg[:maxErrorMessageLength-15] + "... (truncated)"
}

근거:

  • 데이터베이스 컬럼에 크기 제한 있음
  • 과대 데이터로 인한 쿼리 실패 방지
  • 에러 메시지의 유용한 부분 보존
  • 잘림 발생 표시

외부 Analysis ID 지원

Repository는 선택적으로 외부 제공 ID 지원:

go
type CreateAnalysisRecordParams struct {
    AnalysisID *UUID  // nil이면 새 UUID 생성; 제공되면 사용
    // ...
}

// 구현
analysisID := analysis.NewUUID()
if params.AnalysisID != nil {
    analysisID = *params.AnalysisID
}

사용 사례:

  • Web 서비스가 알려진 ID로 Analysis 레코드 생성
  • Worker가 작업 페이로드에서 이 ID 수신
  • Worker가 결과 저장 시 동일 ID 사용
  • 시스템 간 상관관계 추적 가능

결과

긍정적

테스트 용이성:

go
// 단위 테스트를 위한 쉬운 모킹
type MockRepository struct {
    CreateAnalysisRecordFn func(...) (UUID, error)
}

func (m *MockRepository) CreateAnalysisRecord(...) (UUID, error) {
    return m.CreateAnalysisRecordFn(...)
}

유연성:

  • PostgreSQL을 다른 데이터베이스로 교체 가능
  • 구현 변경이 UseCase 테스트에 영향 없음
  • 캐싱 레이어를 투명하게 추가 가능

유지보수성:

  • 모든 SQL 쿼리가 한 위치에
  • 쿼리 최적화가 Adapter 레이어에 격리
  • 명확한 책임 경계

부정적

추상화 오버헤드:

  • 추가적인 인터페이스와 구현 파일
  • params와 DB 구조체 사이 일부 중복
  • 도메인과 영속성 모델 간 매핑 필요

메서드 증가:

  • 새로운 데이터 접근 패턴에 새 메서드 필요
  • Repository가 "신 객체(god object)"가 될 위험
  • 특화된 repository로 분리 필요할 수 있음

트랜잭션 제한:

  • 메서드 간 트랜잭션은 인터페이스에서 지원 안 함
  • 복잡한 워크플로우는 Adapter 레이어에서 조율 필요

참조

Open-source test coverage insights