Repository: 도메인 로직과 데이터 접근 기술을 분리하는 컬렉션 추상화
Repository는 도메인 객체의 저장·조회를 메모리 컬렉션처럼 다룰 수 있게 해 주는 추상화 계층이다. 도메인 로직이 SQL이나 ORM 같은 영속성 기술에 직접 의존하지 않도록 경계를 만들어 준다. 인터페이스와 구현체를 분리하면 테스트 용이성과 교체 가능성이 높아지지만, 과도하게 적용하면 불필요한 계층이 된다.
By 송민성
1. 개념
리포지토리(Repository)는 도메인 객체의 집합을 메모리 상의 컬렉션처럼 다룰 수 있게 해 주는 추상화다. 에릭 에반스의 『도메인 주도 설계(Domain-Driven Design)』와 마틴 파울러의 『엔터프라이즈 애플리케이션 아키텍처 패턴』에서 정리된 개념이다.
호출하는 쪽은 add, get, find 같은 컬렉션 연산만 사용한다. 데이터가 PostgreSQL에 있는지, 파일에 있는지, 메모리에 있는지는 리포지토리 내부가 책임진다.
DAO(Data Access Object)와 자주 혼동된다. 둘 다 데이터 접근을 감추지만 관점이 다르다.
| 구분 | DAO | Repository |
|---|---|---|
| 관점 | 테이블·저장소 중심 | 도메인 객체(애그리거트) 중심 |
| 단위 | 테이블 한 개에 가까운 CRUD | 애그리거트 루트 단위 |
| 인터페이스 위치 | 주로 데이터 계층 | 주로 도메인 계층 |
2. 왜 사용하는가
- 관심사 분리(Separation of Concerns): 비즈니스 규칙에 SQL 문자열이나 ORM 세부 사항이 섞이지 않는다.
- 테스트 용이성: 인메모리 구현체로 교체하면 DB 없이 서비스 로직을 단위 테스트할 수 있다.
- 교체 가능성: 저장소 기술을 바꿔도 도메인과 서비스 코드의 변경 범위가 줄어든다.
- 질의 로직의 중앙화: "최근 7일간 주문한 활성 사용자" 같은 조회 조건이 한 곳에 모인다.
3. 동작 원리
핵심은 의존성 역전 원칙(Dependency Inversion Principle) 이다.
- 도메인(또는 애플리케이션) 계층이 리포지토리 인터페이스를 정의한다.
- 인프라 계층이 그 인터페이스를 구현한다(SQL, ORM 등).
- 서비스는 인터페이스에만 의존하고, 구현체는 외부에서 주입(Dependency Injection)받는다.
Service ──depends on──▶ OrderRepository (interface)
▲
│ implements
PostgresOrderRepository / InMemoryOrderRepository리포지토리는 보통 애그리거트 루트(Aggregate Root) 단위로 만든다. 예를 들어 Order와 그 하위 OrderLine은 하나의 애그리거트이므로 OrderRepository 하나로 함께 저장·조회한다. OrderLine용 리포지토리는 따로 두지 않는다.
트랜잭션 경계는 보통 리포지토리가 아니라 작업 단위(Unit of Work) 또는 서비스 계층이 관리한다.
4. 코드 예제
인터페이스, 인메모리 구현, 서비스를 Python으로 작성한 예다. 표준 라이브러리만 사용하므로 그대로 실행된다.
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Optional
@dataclass
class OrderLine:
sku: str
quantity: int
unit_price: int
@dataclass
class Order:
id: int
customer_id: int
lines: list[OrderLine] = field(default_factory=list)
status: str = "PENDING"
def total(self) -> int:
return sum(l.quantity * l.unit_price for l in self.lines)
def confirm(self) -> None:
if not self.lines:
raise ValueError("빈 주문은 확정할 수 없습니다")
self.status = "CONFIRMED"
class OrderRepository(ABC):
@abstractmethod
def add(self, order: Order) -> None: ...
@abstractmethod
def get(self, order_id: int) -> Optional[Order]: ...
@abstractmethod
def find_by_customer(self, customer_id: int) -> list[Order]: ...
class InMemoryOrderRepository(OrderRepository):
def __init__(self) -> None:
self._store: dict[int, Order] = {}
def add(self, order: Order) -> None:
self._store[order.id] = order
def get(self, order_id: int) -> Optional[Order]:
return self._store.get(order_id)
def find_by_customer(self, customer_id: int) -> list[Order]:
return [o for o in self._store.values() if o.customer_id == customer_id]
class OrderService:
def __init__(self, repo: OrderRepository) -> None:
self._repo = repo
def confirm_order(self, order_id: int) -> Order:
order = self._repo.get(order_id)
if order is None:
raise LookupError(f"주문 {order_id}을(를) 찾을 수 없습니다")
order.confirm()
self._repo.add(order) # 변경 사항 저장
return order
if __name__ == "__main__":
repo = InMemoryOrderRepository()
repo.add(Order(id=1, customer_id=10, lines=[OrderLine("A-1", 2, 5000)]))
service = OrderService(repo)
confirmed = service.confirm_order(1)
print(confirmed.status, confirmed.total()) # CONFIRMED 10000같은 인터페이스를 PostgreSQL로 구현할 때 find_by_customer가 실행하는 쿼리는 다음과 같다.
SELECT o.id, o.customer_id, o.status,
l.sku, l.quantity, l.unit_price
FROM orders o
LEFT JOIN order_lines l ON l.order_id = o.id
WHERE o.customer_id = $1
ORDER BY o.id;서비스 코드는 이 쿼리의 존재를 모른다. 구현체만 InMemoryOrderRepository에서 Postgres 구현체로 바뀔 뿐이다.
5. 시간 복잡도 또는 성능 특성
리포지토리 패턴 자체에는 고유한 시간 복잡도가 없다. 비용은 구현체가 사용하는 저장소에 따라 결정된다.
- 위 인메모리 구현에서
get은 해시 테이블 조회로 평균 O(1)이다. find_by_customer는 전체 순회이므로 O(n)이다. 고객별 인덱스 딕셔너리를 두면 개선할 수 있다.- SQL 구현에서는
customer_id컬럼 인덱스 유무가 조회 성능을 좌우한다. 일반적인 B-tree 인덱스를 쓰면 탐색은 O(log n) 수준이다. - 추상화 계층 자체의 호출 오버헤드는 DB 왕복(network round trip) 비용에 비하면 보통 무시할 수 있다.
6. 실무 사용 사례
- Spring Data JPA:
JpaRepository<T, ID>인터페이스만 선언하면 구현체가 런타임에 생성된다. - Django:
Manager와QuerySet이 리포지토리와 비슷한 역할을 하지만, ORM과 도메인 모델이 결합되어 있어 엄밀한 의미의 리포지토리는 아니다. - TypeORM, Prisma 기반 NestJS 서비스: ORM 클라이언트를 직접 쓰지 않고 리포지토리 클래스로 감싸 서비스에 주입한다.
- 테스트 전략: 서비스 단위 테스트에는 인메모리 리포지토리를, 통합 테스트에는 실제 DB 구현체를 사용한다.
7. 주의할 점
- 누수 있는 추상화(Leaky Abstraction): 리포지토리가 ORM 엔티티나
QueryBuilder를 그대로 반환하면 호출 측이 저장 기술에 묶인다. - N+1 쿼리: 컬렉션처럼 보인다는 이유로 루프 안에서
get을 반복하면 쿼리가 N번 나간다. 필요한 경우 일괄 조회 메서드를 제공한다. - 범용 리포지토리의 남용: 모든 엔티티에 CRUD만 있는 제네릭 리포지토리를 만들면 도메인 의도가 드러나지 않는다.
find_overdue_invoices()처럼 의도가 드러나는 메서드를 선호한다. - 애그리거트 경계 위반: 하위 엔티티마다 리포지토리를 만들면 불변식(invariant)을 지키기 어려워진다.
- 과도한 계층화: 단순 CRUD 앱에서는 ORM 위에 리포지토리를 한 겹 더 얹는 것이 코드만 늘릴 수 있다. 복잡도에 비례해 도입한다.
- 복잡한 조회와 리포팅: 여러 애그리거트를 가로지르는 조회는 리포지토리 대신 별도의 조회 전용 모델(CQRS의 Query 측)로 분리하는 편이 낫다.
8. 핵심 정리
- 리포지토리는 애그리거트 루트를 메모리 컬렉션처럼 저장·조회하게 하는 추상화다.
- 인터페이스는 도메인 쪽에 두고 구현체는 인프라 쪽에 두는 의존성 역전이 핵심이다.
- 인메모리 구현체로 DB 없이 서비스 로직을 테스트할 수 있다.
- 성능은 패턴이 아니라 하부 저장소와 인덱스 설계가 결정한다.
- 저장 기술의 세부 사항이 인터페이스 밖으로 새지 않게 하고, 단순한 앱에서는 도입 비용을 따져 본다.