Go 제네릭 CRUD 리포지토리 패턴으로 깔끔한 Go 데이터 레이어
가독성 좋은 제약, 리플렉션 없음, 명확한 코드로 List/Get/Create/Update/Delete 로직을 재사용하는 실용적인 Go 제네릭 CRUD 리포지토리 패턴을 알아보세요.

Go에서 CRUD 리포지토리가 지저분해지는 이유
CRUD 리포지토리는 처음엔 단순합니다. GetUser를 쓰고, ListUsers를 쓰고, 그다음 Orders, Invoices에 대해 똑같은 일을 반복합니다. 엔터티가 몇 개 쌓이면 데이터 계층은 거의 복사본 같은 코드들의 산더미가 되고, 작은 차이가 눈에 띄지 않습니다.
반복되는 부분은 대부분 SQL 자체가 아닙니다. 쿼리 실행, 행 스캔, “없음” 처리, 데이터베이스 에러 매핑, 페이징 기본값 적용, 입력을 올바른 타입으로 변환하는 흐름이 반복됩니다.
자주 보이는 문제지점은 익숙합니다: 중복된 Scan 코드, 반복되는 context.Context와 트랜잭션 패턴, 보일러플레이트한 LIMIT/OFFSET 처리(때로는 전체 카운트 포함), "0 행이면 not found" 체크, 그리고 복사-붙여넣기된 INSERT ... RETURNING id 변형들.
반복이 견딜 수 없게 되면 많은 팀이 리플렉션을 찾게 됩니다. "한 번만 작성"하면 되는 CRUD를 약속하죠: 어떤 구조체든 런타임에 컬럼으로 채우는 방식입니다. 하지만 비용은 나중에 드러납니다. 리플렉션 중심 코드는 읽기 어렵고 IDE 지원이 약해지며 실패가 컴파일 타임에서 런타임으로 옮겨갑니다. 필드 이름을 바꾸거나 nullable 컬럼을 추가하는 작은 변화가 테스트나 프로덕션에서만 드러나는 놀라움이 됩니다.
타입 안전한 재사용은 반복되는 흐름을 공유하면서도 Go의 일상적 장점(명확한 시그니처, 컴파일러가 확인하는 타입, 실제로 도움이 되는 자동완성)을 포기하지 않는 것입니다. 제네릭을 사용하면 Get[T], List[T] 같은 연산을 재사용하면서도 T로 행을 스캔하는 방법처럼 추측할 수 없는 부분은 각 엔터티가 제공하게 할 수 있습니다.
이 패턴은 의도적으로 데이터 접근 계층만 다룹니다. SQL과 매핑을 일관되고 단조롭게 유지합니다. 도메인을 모델링하거나 비즈니스 규칙을 강제하거나 서비스 수준 로직을 대체하려는 게 아닙니다.
설계 목표(그리고 이 패턴이 해결하려 하지 않는 것들)
좋은 리포지토리 패턴은 일상적인 데이터베이스 접근을 예측 가능하게 만듭니다. 리포지토리를 읽으면 어떤 일을 하고, 어떤 SQL을 실행하며, 어떤 에러를 반환할 수 있는지 빠르게 알아야 합니다.
목표는 단순합니다:
- 끝에서 끝까지 타입 안전성(IDs, 엔터티, 결과가
any가 아님) - 의도를 설명하는 제약(과도한 타입 마술 없이)
- 중요한 동작을 숨기지 않으면서 보일러플레이트 감소
- List/Get/Create/Update/Delete 전반에 걸친 일관된 동작
비목표도 중요합니다. 이건 ORM이 아닙니다. 필드 매핑을 추측하거나 테이블을 자동으로 조인하거나 쿼리를 조용히 바꾸면 안 됩니다. "마법 매핑"은 리플렉션, 태그, 엣지 케이스로 다시 밀어넣습니다.
일반적인 SQL 워크플로를 가정하세요: 명시적 SQL(또는 얇은 쿼리 빌더), 명확한 트랜잭션 경계, 그리고 추론 가능한 에러. 실패하면 에러가 "not found", "conflict/constraint violation", "DB unavailable"처럼 의미 있는 신호를 줘야 합니다. "repository error" 같은 모호한 에러는 피하세요.
핵심 결정은 무엇을 제네릭으로 만들고 무엇을 엔터티별로 둘지입니다.
- 제네릭: 흐름(쿼리 실행, 스캔, 타입화된 값 반환, 공통 에러 번역)
- 엔터티별: 의미(테이블 이름, 선택한 컬럼, 조인, SQL 문자열)
모든 엔터티를 하나의 범용 필터 시스템에 억지로 끼워 넣으려 하면 코드가 더 읽기 어려워져서, 두 개의 명확한 쿼리를 쓰는 것보다 나빠집니다.
엔터티와 ID 제약 선택하기
대부분 CRUD 코드는 모든 테이블이 기본적인 동작을 공유하지만 각 엔터티는 고유한 필드를 가집니다. 제네릭의 요령은 작은 형태를 공유하고 나머지는 자유롭게 두는 것입니다.
리포지토리가 엔터티에 대해 진짜로 알아야 하는 것이 무엇인지부터 결정하세요. 많은 팀에선 유일하게 보편적인 부분이 ID입니다. 타임스탬프는 유용할 수 있지만 보편적이지 않으며 모든 타입에 강제하면 모델이 부자연스러워집니다.
견딜 수 있는 ID 타입을 선택하세요
ID 타입은 데이터베이스에서 행을 식별하는 방식과 일치해야 합니다. 어떤 프로젝트는 int64를 쓰고, 다른 곳은 UUID 문자열을 씁니다. 서비스 전반에서 하나의 방식만 쓴다면 고정하면 시그니처가 짧아집니다. 여러 서비스에서 하나의 접근법을 원하면 ID를 제네릭으로 만드세요.
ID에 대한 좋은 기본 제약은 comparable입니다. ID는 비교하고, 맵 키로 쓰며, 여기저기 전달하기 때문입니다.
type ID interface {
comparable
}
type Entity[IDT ID] interface {
GetID() IDT
SetID(IDT)
}
엔터티 제약은 최소로 유지하세요
구조체 임베딩이나 ~struct{...} 같은 타입셋 트릭으로 필드를 요구하지 마세요. 강력해 보이지만 도메인 타입을 리포지토리 패턴에 결합합니다.
대신 공유 CRUD 흐름에서 필요한 것만 요구하세요:
- ID를 가져오고 설정할 수 있어야 함(그래야 Create가 ID를 반환하고 Update/Delete가 이를 대상으로 삼을 수 있음)
나중에 soft delete나 optimistic locking 같은 기능을 추가하려면 작은 옵트인 인터페이스(예: GetVersion/SetVersion)를 추가하고 필요한 곳에서만 사용하세요. 작은 인터페이스는 시간이 지나도 잘 유지됩니다.
읽기 쉬운 제네릭 리포지토리 인터페이스
리포지토리 인터페이스는 앱이 필요로 하는 것을 설명해야지, 데이터베이스가 하는 일을 그대로 노출하면 안 됩니다. 인터페이스가 SQL처럼 느껴지면 세부 사항이 여기저기 누설됩니다.
메서드 집합은 작고 예측 가능하게 유지하세요. context.Context를 맨 앞에 두고, 그다음 주요 입력(ID 또는 데이터), 그다음 선택적 설정을 구조체로 묶어서 받습니다.
type Repository[T any, ID comparable, CreateIn any, UpdateIn any, ListQ any] interface {
Get(ctx context.Context, id ID) (T, error)
List(ctx context.Context, q ListQ) ([]T, error)
Create(ctx context.Context, in CreateIn) (T, error)
Update(ctx context.Context, id ID, in UpdateIn) (T, error)
Delete(ctx context.Context, id ID) error
}
List에 범용 필터 타입을 강요하지 마세요. 필터는 엔터티마다 가장 다릅니다. 실용적인 접근은 엔터티별 쿼리 타입과 임베드 가능한 작은 공용 페이징 형태를 사용하는 것입니다.
type Page struct {
Limit int
Offset int
}
에러 처리는 리포지토리가 자주 시끄러워지는 부분입니다. 호출자가 분기할 수 있는 에러를 미리 결정하세요. 간단한 집합이면 충분합니다:
ErrNotFound— ID가 존재하지 않을 때ErrConflict— 유니크 제약 위반이나 버전 충돌일 때ErrValidation— 입력이 유효하지 않을 때(리포지토리가 검증을 한다면)
나머지는 저수준 에러(DB/네트워크)를 래핑해서 전달하세요. 이 규약이 있으면 서비스 코드는 저장소가 지금 PostgreSQL인지 다른 무엇인지 신경 쓰지 않고 not found나 conflict를 처리할 수 있습니다.
리플렉션 없이 흐름만 재사용하는 방법
리플렉션은 보통 "어떤 구조체든 채우겠다"고 할 때 슬며시 들어옵니다. 그건 오류를 런타임까지 숨기고 규칙을 불분명하게 만듭니다.
더 깔끔한 접근은 반복되는 지루한 부분만 재사용하는 것입니다: 쿼리 실행, 행 루프, 영향받은 행 수 확인, 에러 일관된 래핑. 구조체 ↔ DB 매핑은 명시적으로 유지하세요.
책임을 분리하세요: SQL, 매핑, 공유 흐름
실용적 분리는 다음과 같습니다:
- 엔터티별: SQL 문자열과 파라미터 순서
- 엔터티별: 행을 구체적 구조체로 스캔하는 작은 매핑 함수
- 제네릭: 쿼리를 실행하고 매퍼를 호출하는 공유 흐름
이렇게 하면 제네릭은 반복을 줄이되 DB가 하는 일을 숨기지 않습니다.
다음은 *sql.DB와 *sql.Tx를 둘 중 하나로 전달할 수 있게 해주는 작은 추상입니다:
type DBTX interface {
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
}
제네릭이 해야 할 것(그리고 하지 말아야 할 것)
제네릭 계층이 구조체를 "이해"하려고 시도하면 안 됩니다. 대신 당신이 제공하는 명시적 함수들을 받아들이세요. 예:
- 입력을 쿼리 인수로 바꾸는 바인더
- 컬럼을 엔터티로 읽는 스캐너
예를 들어 Customer 리포지토리는 SQL을 상수로 저장(selectByID, insert, update)하고 scanCustomer(rows)를 한 번 구현합니다. 제네릭 List는 루프, 컨텍스트, 에러 래핑을 처리하고 scanCustomer가 타입 안전하고 명확한 매핑을 유지합니다.
컬럼을 추가하면 SQL과 스캐너를 업데이트하세요. 컴파일러가 깨진 부분을 찾아주는 데 도움을 줍니다.
단계별: 패턴 구현하기
목표는 List/Get/Create/Update/Delete에 대해 재사용 가능한 흐름을 하나 만들되 각 리포지토리가 SQL과 행 매핑에 대해 정직하게 책임을 지게 하는 것입니다.
1) 핵심 타입 정의
최소한의 제약으로 시작하세요. 코드베이스에 맞는 ID 타입과 예측 가능하게 유지되는 리포지토리 인터페이스를 선택합니다.
type ID interface{ ~int64 | ~string }
type Repo[E any, K ID] interface {
Get(ctx context.Context, id K) (E, error)
List(ctx context.Context, limit, offset int) ([]E, error)
Create(ctx context.Context, e *E) error
Update(ctx context.Context, e *E) error
Delete(ctx context.Context, id K) error
}
2) DB와 트랜잭션을 위한 실행자 추가
제네릭 코드를 *sql.DB나 *sql.Tx에 직접 묶지 마세요. 호출하는 메서드에 맞춘 작은 실행자 인터페이스에 의존하세요(QueryContext, ExecContext, QueryRowContext). 그러면 서비스는 DB나 트랜잭션을 전달해도 리포지토리 코드를 바꿀 필요가 없습니다.
3) 공유 흐름을 가진 제네릭 베이스 만들기
baseRepo[E,K]를 만들어 실행자와 몇 개의 함수 필드를 저장하세요. 베이스는 지루한 부분을 처리합니다: 쿼리 호출, "not found" 매핑, 영향받은 행 수 확인, 일관된 에러 반환.
4) 엔터티별 조각 구현하기
각 엔터티 리포지토리는 제네릭으로 처리할 수 없는 것을 제공합니다:
- list/get/create/update/delete용 SQL
- 행을
E로 변환하는scan(row)함수 - 쿼리 인수를 반환하는
bind(...)함수
5) 구체 리포지토리 연결하고 서비스에서 사용하기
NewCustomerRepo(exec Executor) *CustomerRepo를 만들어 baseRepo를 임베드하거나 래핑하세요. 서비스 레이어는 Repo[E,K] 인터페이스에 의존하고 트랜잭션을 시작할 시점을 결정합니다. 리포지토리는 주어진 실행자만 사용하면 됩니다.
놀라움 없는 List/Get/Create/Update/Delete 처리
제네릭 리포지토리는 모든 메서드가 어디서나 같은 방식으로 동작할 때만 도움이 됩니다. 대부분의 문제는 작은 일관성 부족에서 옵니다: 어떤 리포지토리는 created_at으로 정렬하고 다른 리포지토리는 id로 정렬합니다; 어떤 것은 누락 시 nil, nil을 반환하고 다른 것은 에러를 반환합니다.
List: 페이징과 정렬을 흔들리지 않게
하나의 페이징 스타일을 정하고 일관되게 적용하세요. Offset 페이징(limit/offset)은 단순하고 관리자 화면에서 잘 작동합니다. 엔드리스 스크롤에는 커서 페이징이 더 낫지만 안정적인 정렬 키가 필요합니다.
무엇을 선택하든 정렬은 명시적이고 안정적이어야 합니다. 유니크 컬럼(보통 기본키)으로 정렬하면 새로운 행이 생겨도 페이지 사이에서 항목이 흔들리지 않습니다.
Get: 명확한 “not found” 신호
Get(ctx, id)는 타입화된 엔터티와 명확한 누락 신호(보통 ErrNotFound)를 반환해야 합니다. 제로값 엔터티와 nil 에러를 반환하면 호출자는 "없음"과 "빈 필드"를 구분할 수 없습니다.
타입은 데이터용, 에러는 상태용이라는 습관을 들이세요.
메서드를 구현하기 전에 몇 가지 결정을 내리고 일관되게 유지하세요:
Create: ID와 타임스탬프가 없는 입력 타입을 받는가, 아니면 전체 엔터티를 받는가? 많은 팀은 서버 소유 필드를 막기 위해Create(ctx, in CreateX)를 선호합니다.Update: 전체 교체인가 패치인가? 패치라면 제로 값이 모호한 일반 구조체를 쓰지 마세요. 포인터, nullable 타입, 명시적 필드 마스크를 사용하세요.Delete: 하드 딜리트인가 소프트 딜리트인가? 소프트 딜리트면Get이 기본적으로 삭제된 행을 숨기는지 결정하세요.
쓰기 메서드가 무엇을 반환할지도 결정하세요. 놀라움이 적은 옵션은 DB 기본값을 반영한 업데이트된 엔터티를 반환하거나 변경이 없을 때 ErrNotFound를 반환하는 것입니다.
제네릭과 엔터티별 부분에 대한 테스트 전략
이 접근법이 가치가 있으려면 신뢰하기 쉬워야 합니다. 코드와 같은 선에서 테스트를 분리하세요: 공유 헬퍼는 한 번 테스트하고 각 엔터티의 SQL과 스캐닝은 별도로 테스트합니다.
페이징 검증, 허용된 컬럼으로 정렬 키 매핑, WHERE 조각 빌드와 같은 작은 순수 함수는 빠른 단위 테스트로 커버하세요.
리스트 쿼리는 테이블 기반 테스트가 잘 맞습니다. 빈 필터, 알 수 없는 정렬 키, limit 0, 최대 초과, 음수 오프셋, "다음 페이지" 경계(한 행 더 가져오는 경우) 같은 엣지 케이스를 다루세요.
엔터티별 테스트는 실제 엔터티 특유의 것들에 집중하세요: 실행될 것으로 기대하는 SQL과 행이 엔터티 타입으로 스캔되는 방식. SQL 모크나 가벼운 테스트 DB를 사용해 널, 선택적 컬럼, 타입 변환을 제대로 처리하는지 확인하세요.
트랜잭션을 지원하면 작은 페이크 실행자를 만들어 커밋/롤백 동작을 테스트하세요:
- Begin은 tx 범위의 실행자를 반환
- 오류 시 rollback이 정확히 한 번 호출
- 성공 시 commit이 정확히 한 번 호출
- commit 실패 시 에러는 그대로 반환
또한 각 리포지토리가 반드시 통과해야 하는 "계약 테스트"(create 후 get이 동일 데이터 반환, update가 의도한 필드 변경, delete 후 get이 not found 반환, list가 같은 입력에서 안정적 정렬 반환)를 추가할 수 있습니다.
흔한 실수와 함정
제네릭은 모든 것을 하나로 묶고 싶은 욕구를 부릅니다. 데이터 접근에는 작은 차이들이 많고 그 차이들이 중요합니다.
자주 보이는 함정은:
- 모든 메서드가 조인, 검색, 권한, 소프트 삭제, 캐시 같은 거대한 옵션 백을 받도록 지나치게 일반화하는 것. 그러면 또 다른 ORM을 만든 셈입니다.
- 너무 영리한 제약. 독자가 타입셋을 해독해야만 엔터티가 뭘 구현해야 하는지 알 수 있다면 추상화의 비용이 이득보다 큽니다.
- 입력 타입을 DB 모델로 취급하는 것. Create와 Update가 행에서 스캔하는 동일한 구조체를 받으면 DB 세부사항이 핸들러와 테스트로 누설되고 스키마 변경이 앱 전체에 파급됩니다.
List의 조용한 동작: 불안정한 정렬, 일관되지 않은 기본값, 엔터티별로 다른 페이징 규칙.- 호출자가 문자열을 파싱하게 만드는 not-found 처리 대신
errors.Is를 사용하지 못하게 하는 설계.
구체적 예: ListCustomers가 ORDER BY를 설정하지 않아 매번 다른 순서로 고객을 반환한다면 페이징이 요청 사이에 레코드를 중복하거나 누락시킵니다. 정렬을 명시하세요(기본키로 정렬하는 것만으로도 큰 차이가 납니다) 그리고 기본 동작을 일관되게 유지하세요.
도입 전 빠른 체크리스트
제네릭 리포지토리를 모든 패키지에 도입하기 전에, 반복을 제거하면서도 중요한 DB 동작을 숨기지 않는지 확인하세요.
일관성부터 시작하세요. 한 리포지토리는 context.Context를 받고 다른 리포지토리는 받지 않거나, 한 곳은 (T, error)를 반환하고 다른 곳은 (*T, error)를 반환하면 서비스, 테스트, 목에 문제가 생깁니다.
각 엔터티에 SQL을 위한 명확한 위치가 하나씩 남아 있는지 확인하세요. 제네릭은 흐름(스캔, 검증, 에러 매핑)을 재사용해야지 쿼리 문자열을 여기저기로 흩어놓아서는 안 됩니다.
문제를 방지하는 빠른 점검 목록:
- List/Get/Create/Update/Delete에 대한 한 가지 시그니처 규약
- 모든 리포지토리가 사용하는 일관된 not-found 규칙
- 문서화되고 테스트된 안정적인 리스트 정렬
*sql.DB와*sql.Tx에서 같은 코드를 실행할 수 있는 깔끔한 방법(실행자 인터페이스로)- 제네릭 코드와 엔터티 규칙(검증 및 비즈니스 체크는 제네릭 레이어 밖)에 대한 명확한 경계
내부 도구를 AppMaster에서 빠르게 빌드하고 나중에 생성된 Go 코드를 내 레포지토리로 옮겨 확장하거나 내보낼 계획이라면, 이런 체크들이 데이터 계층을 예측 가능하고 테스트하기 쉽게 유지하는 데 도움이 됩니다.
현실적인 예: Customer 리포지토리 만들기
타입 안전성을 유지하면서 교묘해지지 않는 작은 Customer 리포지토리 구조입니다.
저장 모델부터 시작하세요. ID를 강하게 타입화하면 다른 ID와 섞이는 걸 막아줍니다:
type CustomerID int64
type Customer struct {
ID CustomerID
Name string
Status string // "active", "blocked", "trial"...
}
이제 "API가 받는 것"과 "저장하는 것"을 분리하세요. Create와 Update가 달라야 하는 지점입니다.
type CreateCustomerInput struct {
Name string
Status string
}
type UpdateCustomerInput struct {
Name *string
Status *string
}
제네릭 베이스는 공유 흐름(쿼리 실행, 스캔, 에러 매핑)을 처리하고 Customer 리포지토리는 Customer 전용 SQL과 매핑을 소유합니다. 서비스 레이어에서 보는 인터페이스는 깔끔합니다:
type CustomerRepo interface {
Create(ctx context.Context, in CreateCustomerInput) (Customer, error)
Update(ctx context.Context, id CustomerID, in UpdateCustomerInput) (Customer, error)
Get(ctx context.Context, id CustomerID) (Customer, error)
Delete(ctx context.Context, id CustomerID) error
List(ctx context.Context, q CustomerListQuery) ([]Customer, int, error)
}
List에는 필터와 페이징을 일급 요청 객체로 다루세요. 호출부가 읽기 쉬워지고 제한을 잊기 어려워집니다.
type CustomerListQuery struct {
Status *string // filter
Search *string // name contains
Limit int
Offset int
}
이 패턴은 잘 확장됩니다: 다음 엔터티에 대해 구조를 복사하고 입력을 저장 모델과 분리하며 스캐닝을 명시적으로 유지하면 변경이 분명하고 컴파일러 친화적입니다.
자주 묻는 질문
제네릭은 흐름(쿼리 실행, 행 반복, not-found 처리, 페이징 기본값, 에러 매핑)을 재사용하도록 사용하세요. 대신 SQL과 행 매핑은 엔터티별로 명시적으로 유지합니다. 이렇게 하면 런타임의 "마법" 없이 반복을 줄일 수 있습니다.
리플렉션은 매핑 규칙을 숨기고 실패를 런타임으로 옮깁니다. 컴파일러 검사가 사라지고 IDE 지원이 약해지며, 작은 스키마 변경이 테스트나 프로덕션에서만 드러나는 문제를 만듭니다. 제네릭과 명시적 스캐너 함수를 쓰면 타입 안전성을 유지하면서 반복되는 부분만 공유할 수 있습니다.
기본적으로 comparable 같은 제약이 좋습니다. ID는 비교되고, 맵 키로 쓰이며, 여기저기 전달되기 때문에 비교 가능한 타입이면 충분합니다. 만약 시스템이 int64와 UUID 문자열처럼 여러 ID 스타일을 쓰면 ID를 제네릭으로 만들면 한 가지 방식으로 강제하지 않아도 됩니다.
최소한으로 유지하세요. 보통 공유 CRUD 흐름에 필요한 것, 예를 들어 GetID()와 SetID() 정도면 충분합니다. 임베딩이나 복잡한 타입셋으로 공통 필드를 강제하면 도메인 타입이 리포지토리 패턴에 결합되어 리팩터링이 힘들어집니다.
작게 필요한 메서드들만 포함한 실행자 인터페이스(DBTX)를 두세요. 예: QueryContext, QueryRowContext, ExecContext 등 리포지토리에서 사용하는 메서드만 포함시키면 *sql.DB든 *sql.Tx든 그대로 전달해서 사용할 수 있습니다.
Get(ctx, id)는 명확한 누락 신호를 줘야 합니다. 제로값과 nil 에러를 반환하면 호출자가 레코드가 없는지 필드가 비어있는지 구분할 수 없습니다. ErrNotFound 같은 공통 시그널을 에러로 반환하면 서비스 코드에서 errors.Is로 안전하게 분기할 수 있습니다.
입력과 저장 모델을 분리하세요. 보통 Create(ctx, CreateInput)와 Update(ctx, id, UpdateInput)을 선호합니다. 이렇게 하면 호출자가 서버가 관리하는 ID나 타임스탬프를 임의로 설정하지 못합니다. 패치 업데이트는 포인터(또는 nullable 타입)를 사용해 "설정 안 함"과 "제로 값 설정"을 구분하세요.
항상 명시적이고 안정적인 ORDER BY를 설정하세요. 보통 기본키 같은 유니크한 컬럼으로 정렬하면 새 레코드가 생겨도 페이지 사이에서 항목이 이동하지 않습니다. 정렬이 명시되지 않으면 페이징 시 중복 또는 누락이 발생하기 쉽습니다.
서비스가 분기할 수 있는 소수의 에러만 공개하세요. 예: ErrNotFound, ErrConflict 정도. 나머지는 저수준 DB 에러를 컨텍스트와 함께 래핑해서 반환하면 됩니다. 호출자가 문자열을 파싱하게 하지 말고 errors.Is로 판별할 수 있게 만드세요.
공유 헬퍼(페이징 정규화, not-found 매핑, 영향을 받은 행 수 검사 등)는 한 번만 테스트하고, 각 엔터티의 SQL과 스캐닝은 별도로 테스트하세요. 또한 각 리포지토리가 지켜야 할 계약 테스트(예: create 후 get이 동일한 데이터 반환, update가 기대한 필드 변경, delete 후 get이 ErrNotFound 반환, list 정렬 안정성)를 추가하면 안전합니다.


