리포지토리 패턴
집합체 저장소를 컬렉션처럼 보이는 인터페이스 뒤에 두어 업무 코드가 저장 기술을 모르게 하는 패턴.
· 마지막 업데이트: 2026-10-25 · 23 min read
개요
주문 취소 기능을 만든다고 하자. CancelOrder 메서드가 SELECT ... FROM orders WHERE id = @id로 행을 읽고, 열 값을 주문 객체로 옮기고, 이미 취소된 주문인지 확인한 뒤 UPDATE orders SET status = ...를 실행한다. 취소 규칙 하나를 시험하려 해도 DB가 있어야 하고, 고객의 주문을 찾는 SQL은 취소, 환불, 알림 코드에 따로 복사된다. 저장 방식을 바꾸면 업무 규칙이 들어 있는 메서드를 모두 열어야 한다.
리포지토리(Repository)는 이 저장과 조회를 객체 컬렉션처럼 보이는 인터페이스 뒤로 옮긴다. 업무 코드는 Add, GetById, FindByCustomer 같은 메서드로 주문을 넣고 꺼낼 뿐이고, 그 뒤가 SQL인지 파일인지 메모리의 사전인지는 모른다. 인터페이스는 업무 코드 쪽에, 구현은 저장 기술을 아는 바깥 계층에 두므로, 업무 코드가 저장 기술에 의존하는 대신 저장 구현이 업무 쪽 인터페이스에 의존한다.
리포지토리는 보통 집합체(aggregate) 단위로 만든다. 집합체는 주문과 주문 항목처럼 함께 일관성을 지켜야 하는 객체 묶음이고, 바깥 객체가 참조할 수 있는 대표 객체를 루트(root)라고 한다.1 이 예제에서는 Order가 루트이며 취소 규칙을 직접 지킨다. 그래서 주문 항목 테이블마다 리포지토리를 두지 않고, IOrderRepository 하나로 주문을 통째로 읽고 쓴다.
구조
OrderService는 IOrderRepository에 대한 참조만 가진다. InMemoryOrderRepository가 그 인터페이스를 구현하고 Order 객체를 사전에 보관하므로, 서비스에 다른 구현을 넣어도 서비스 코드는 바뀌지 않는다.
역사
Repository는 Martin Fowler의 Patterns of Enterprise Application Architecture(2002)에 실린 패턴이다. Fowler 사이트의 책 소개는 저자를 Fowler와 Dave Rice, Matthew Foemmel, Edward Hieatt, Robert Mee, Randy Stafford로 적는다.2 같은 사이트의 패턴 카탈로그는 Repository 항목의 저자를 Edward Hieatt와 Rob Mee로 표기하고, 이 패턴을 컬렉션 모양의 인터페이스로 도메인 객체에 접근하게 하여 도메인 계층과 데이터 매핑 계층(객체와 DB 테이블 사이에서 값을 옮기는 코드) 사이를 중재하는 것으로 요약한다. 이 항목은 클라이언트가 조회 조건을 선언적으로 만들어 리포지토리에 넘긴다고 설명하고, 도메인과 데이터 매핑 계층 사이의 의존을 한 방향으로 정리하는 것도 목적으로 든다.3
Eric Evans는 2003년 8월에 나온(판권 연도 2004) Domain-Driven Design에서 Repository를 집합체와 묶어 설명했다.4 Evans가 이 책의 패턴 요약을 다듬어 낸 DDD Reference의 Repositories 항목은, 전역 접근이 필요한 집합체 종류마다 그 루트 타입 객체 전체를 담은 메모리 컬렉션처럼 보이는 서비스를 두라고 한다. 이 서비스는 객체를 추가하고 제거하는 메서드와 도메인 전문가에게 의미 있는 조건으로 객체를 고르는 메서드를 제공하고, 리포지토리는 직접 접근이 실제로 필요한 집합체 루트에만 두라고 한다.5
Microsoft의 .NET 마이크로서비스 아키텍처 가이드는 집합체 루트마다 리포지토리를 하나씩 두고 데이터베이스 테이블마다 만들지는 말라고 권한다. 같은 문서는 EF Core의 DbContext가 Repository와 Unit of Work 패턴을 함께 구현한다고 적고,6 DbContext API 문서도 이 클래스를 두 패턴의 조합으로 설명한다.7
왜 사용하는가?
리포지토리를 두면 업무 규칙과 저장 기술이 나뉜다. 서비스는 컬렉션 모양의 저장 약속만 알기 때문에, 취소 규칙을 시험할 때는 메모리 구현을 넣고 운영에서는 DB 구현을 넣을 수 있다. 데이터 접근 코드가 한곳에 모이므로 같은 조회 SQL이 서비스마다 복사되지도 않는다. EF Core 문서도 운영 DB 없이 테스트하기로 했다면 리포지토리 계층을 두고, 테스트에서는 그 계층을 정해 둔 값을 돌려주는 스텁(stub)이나 호출을 기록하는 목(mock)으로 바꾸는 방법을 권한다.8
같은 문서는 이 방식의 비용도 적는다. 테스트할 LINQ 쿼리를 모두 리포지토리 메서드로 감싸야 하므로 계층이 하나 늘고, 그 계층을 구현하고 유지하는 데 상당한 비용이 든다. 리포지토리가 감싼 쿼리 자체는 여전히 실제 DB에서 시험해야 하며, 대역(test double)으로 통과한 테스트는 운영 DB의 대소문자 비교 같은 동작까지 보장하지 않는다.9
EF Core를 쓴다면 DbContext가 이미 리포지토리와 작업 단위 역할을 한다. Microsoft 가이드의 예제 리포지토리도 변경 추적(읽어 온 객체에서 바뀐 값을 기억해 두었다가 저장할 때 반영하는 일)을 다시 만들지 않고 DbContext에 맡긴다. 이 가이드는 리포지토리를 DDD 설계에 꼭 필요한 패턴으로 보지 않으며, 가이드에 직접 피드백을 준 Jimmy Bogard가 리포지토리는 저장 메커니즘의 중요한 세부를 가린다는 이유로 선호하지 않는다고 한 의견도 함께 싣는다.10
모든 엔터티에 같은 메서드를 붙이는 범용 리포지토리(IRepository<T>)는 엔터티마다 쉽게 하나씩 만들 수 있어서 테이블마다 리포지토리가 생기는 쪽으로 흐르기 쉽고, 이는 앞의 집합체 단위 원칙과 어긋난다. 범용 리포지토리가 IQueryable<T>를 돌려주면 호출자가 임의의 LINQ 식을 이어 붙일 수 있고, 그 식을 SQL로 번역할 수 있는지는 ORM에 달려 있다. EF Core 문서는 이렇게 하면 쿼리 번역에 여전히 EF Core가 관여하므로 리포지토리를 둔 목적이 사라진다고 설명한다.11 Mark Seemann은 IQueryable<T>를 돌려주는 API 전반을 비판하며, 예로 든 IQueryable<T> Query<T>() 같은 인터페이스가 어떤 쿼리든 쓸 수 있다는 거짓 보장을 하고, 구현을 바꿀 수 있다는 착각을 만든다고 했다.12 Microsoft 가이드의 IRepository<T> where T : IAggregateRoot는 이와 다르다. 이 제약은 집합체 루트에만 리포지토리를 만들게 하는 표시이고, 실제 메서드는 IOrderRepository처럼 집합체마다 정한다.13
리포지토리는 집합체를 읽어 바꾸고 다시 저장하는 쪽을 위한 것이다. 관리자 목록처럼 여러 테이블을 묶어 화면 모양으로 읽는 조회까지 집합체를 거치게 하면, 쓰지 않을 규칙과 필드를 함께 끌고 온다. Microsoft 가이드도 갱신은 리포지토리와 집합체 루트가 맡되 조회는 다른 경로로 해도 된다고 적는다.14 이런 읽기 전용 조회는 Query Facade에서 다룬다. 여러 변경을 한 트랜잭션으로 묶어 저장하는 일도 리포지토리의 범위 밖이며 작업 단위 패턴이 맡는다.
예제
주문을 넣고, 서비스로 취소한 뒤, 고객별로 다시 찾는다. 서비스는 IOrderRepository만 받는다.
using System;
using System.Collections.Generic;
using System.Linq;
var orders = new InMemoryOrderRepository();
orders.Add(new Order("o-1", "c-1", 120));
orders.Add(new Order("o-2", "c-1", 80));
orders.Add(new Order("o-3", "c-2", 50));
var service = new OrderService(orders);
Console.WriteLine("cancel o-1: " + service.Cancel("o-1"));
Console.WriteLine("cancel o-9: " + service.Cancel("o-9"));
foreach (var order in orders.FindByCustomer("c-1"))
Console.WriteLine($"{order.Id} {order.Status} {order.Total}");
enum OrderStatus { Open, Cancelled }
// 집합체 루트. 취소 규칙은 주문이 직접 지킨다.
sealed class Order(string id, string customerId, int total)
{
public string Id { get; } = id;
public string CustomerId { get; } = customerId;
public int Total { get; } = total;
public OrderStatus Status { get; private set; } = OrderStatus.Open;
public bool Cancel()
{
if (Status == OrderStatus.Cancelled) return false;
Status = OrderStatus.Cancelled;
return true;
}
}
// 서비스가 아는 것은 이 컬렉션 모양의 약속뿐이다.
interface IOrderRepository
{
void Add(Order order);
Order? GetById(string id);
IReadOnlyList<Order> FindByCustomer(string customerId);
}
// 저장 방식을 아는 쪽. 여기서는 사전 하나가 저장소다.
sealed class InMemoryOrderRepository : IOrderRepository
{
private readonly Dictionary<string, Order> orders = new();
// 같은 ID를 다시 넣으면 Dictionary.Add가 예외를 던진다. 다음 예제는 이를 Duplicate 값으로 돌려준다.
public void Add(Order order) => orders.Add(order.Id, order);
public Order? GetById(string id) => orders.GetValueOrDefault(id);
public IReadOnlyList<Order> FindByCustomer(string customerId) =>
orders.Values.Where(o => o.CustomerId == customerId).OrderBy(o => o.Id, StringComparer.Ordinal).ToList();
}
// 주문을 어디에 어떻게 저장하는지 모르는 서비스.
sealed class OrderService(IOrderRepository orders)
{
public string Cancel(string orderId)
{
var order = orders.GetById(orderId);
if (order is null) return "not found";
return order.Cancel() ? "cancelled" : "already cancelled";
}
}cancel o-1: cancelled
cancel o-9: not found
o-1 Cancelled 120
o-2 Open 80메모리 구현의 GetById는 보관 중인 객체 자체를 돌려주므로 Cancel()의 변경이 저장 호출 없이 바로 보인다. DB 구현이라면 변경을 언제 저장할지 따로 정해야 하며, 다음 예제는 그 저장을 Save로 드러낸다.
범위 넓힌 예제
이번에는 저장소 안에 객체 대신 값의 사본을 두고, 읽을 때마다 새 Order를 만든다. 두 요청이 DB에서 같은 행을 따로 읽는 상황과 비슷하다. 없는 주문이나 중복 추가 같은 실패는 예외 대신 결과 타입으로 돌려준다. Then은 앞 단계가 성공했을 때만 다음 단계를 부른다.
같은 인터페이스를 두 가지로 구현한다. Version은 저장할 때마다 1씩 늘어나는 번호이고, 주문을 읽은 뒤 다른 요청이 저장했는지 알아내는 데 쓴다. 추가나 저장에 성공하면 저장소가 그 객체의 Version도 새 번호로 맞추므로, o-2처럼 같은 객체를 이어서 다시 저장할 수 있다. LastWriteWinsOrderRepository는 이 번호를 비교하지 않고 마지막 저장을 받아들이며, VersionCheckedOrderRepository는 읽을 때의 버전과 저장된 버전이 다르면 VersionConflict로 거절한다. 요청 B가 o-1을 먼저 읽고, 요청 A가 취소해 저장한 뒤, B가 취소 전 사본에 상품을 더해 저장한다.
using System;
using System.Collections.Generic;
using System.Linq;
IOrderRepository[] stores = [new LastWriteWinsOrderRepository(), new VersionCheckedOrderRepository()];
foreach (var repo in stores)
{
Console.WriteLine("== " + repo.GetType().Name);
var service = new OrderService(repo);
Console.WriteLine("add o-1: " + repo.Add(new Order("o-1", "c-1", 120)));
// 추가나 저장에 성공하면 같은 객체의 Version도 새 번호가 되어 다시 저장할 수 있다.
var o2 = new Order("o-2", "c-1", 80);
Console.WriteLine("add o-2: " + repo.Add(o2));
Console.WriteLine("o-2 add item: " + o2.AddItem(5).Then(repo.Save));
Console.WriteLine("o-2 add item again: " + o2.AddItem(5).Then(repo.Save));
Console.WriteLine("add o-1 again: " + repo.Add(new Order("o-1", "c-9", 10)));
Console.WriteLine("cancel o-9: " + service.Cancel("o-9"));
// 요청 B가 먼저 o-1을 읽어 둔다.
var requestB = repo.GetById("o-1");
// 요청 A가 같은 주문을 읽고 취소해 저장한다.
Console.WriteLine("A cancel o-1: " + service.Cancel("o-1"));
// 요청 B는 취소 전에 읽은 복사본에 상품을 더해 저장한다.
Console.WriteLine("B add item: " + requestB.Then(o => o.AddItem(30)).Then(repo.Save));
Console.WriteLine("o-1 now: " + repo.GetById("o-1"));
Console.WriteLine("c-1 open orders: " + service.OpenCount("c-1"));
}
enum OrderStatus { Open, Cancelled }
enum ErrorCode { NotFound, Duplicate, AlreadyCancelled, VersionConflict }
sealed record Error(ErrorCode Code, string Detail);
// 실패를 예외 대신 값으로 돌려준다. 생성자가 private이라 Ok와 Fail로만 만들 수 있고,
// default(Result<T>)는 null이 되어 성공 값으로 읽히지 않는다.
sealed class Result<T>
{
private readonly T? value;
private readonly Error? error;
private Result(T? value, Error? error) { this.value = value; this.error = error; }
public static Result<T> Ok(T value) => new(value, null);
public static Result<T> Fail(ErrorCode code, string detail) => new(default, new Error(code, detail));
public Result<U> Then<U>(Func<T, Result<U>> next) =>
error is null ? next(value!) : new Result<U>(default, error);
public override string ToString() => error is null ? $"ok {value}" : $"{error.Code} ({error.Detail})";
}
// 집합체 루트. Version은 이 객체를 마지막으로 읽거나 저장했을 때의 저장 버전이다.
sealed class Order(string id, string customerId, int total,
OrderStatus status = OrderStatus.Open, int version = 0)
{
public string Id { get; } = id;
public string CustomerId { get; } = customerId;
public int Total { get; private set; } = total;
public OrderStatus Status { get; private set; } = status;
public int Version { get; private set; } = version;
// 저장소가 추가나 저장에 성공한 뒤에만 부른다.
public void MarkStored(int version) => Version = version;
public Result<Order> Cancel()
{
if (Status == OrderStatus.Cancelled) return Result<Order>.Fail(ErrorCode.AlreadyCancelled, Id);
Status = OrderStatus.Cancelled;
return Result<Order>.Ok(this);
}
public Result<Order> AddItem(int price)
{
if (Status == OrderStatus.Cancelled) return Result<Order>.Fail(ErrorCode.AlreadyCancelled, Id);
Total += price;
return Result<Order>.Ok(this);
}
public override string ToString() => $"{Id} {Status} total={Total} v{Version}";
}
interface IOrderRepository
{
Result<int> Add(Order order);
Result<Order> GetById(string id);
IReadOnlyList<Order> FindByCustomer(string customerId);
Result<int> Save(Order order);
}
// 저장소 안에는 객체가 아니라 값의 사본을 둔다. 읽을 때마다 새 Order를 만든다.
sealed record StoredOrder(string Id, string CustomerId, int Total, OrderStatus Status, int Version)
{
public static StoredOrder From(Order o, int version) => new(o.Id, o.CustomerId, o.Total, o.Status, version);
public Order ToOrder() => new(Id, CustomerId, Total, Status, Version);
}
// 첫 번째 구현: 버전을 비교하지 않고 마지막 저장이 이긴다.
sealed class LastWriteWinsOrderRepository : IOrderRepository
{
private readonly Dictionary<string, StoredOrder> rows = new();
public Result<int> Add(Order order)
{
if (!rows.TryAdd(order.Id, StoredOrder.From(order, 1))) return Result<int>.Fail(ErrorCode.Duplicate, order.Id);
order.MarkStored(1);
return Result<int>.Ok(1);
}
public Result<Order> GetById(string id) =>
rows.TryGetValue(id, out var row) ? Result<Order>.Ok(row.ToOrder()) : Result<Order>.Fail(ErrorCode.NotFound, id);
public IReadOnlyList<Order> FindByCustomer(string customerId) =>
rows.Values.Where(r => r.CustomerId == customerId).OrderBy(r => r.Id, StringComparer.Ordinal).Select(r => r.ToOrder()).ToList();
public Result<int> Save(Order order)
{
if (!rows.TryGetValue(order.Id, out var current)) return Result<int>.Fail(ErrorCode.NotFound, order.Id);
var next = current.Version + 1;
rows[order.Id] = StoredOrder.From(order, next);
order.MarkStored(next);
return Result<int>.Ok(next);
}
}
// 두 번째 구현: 읽어 간 버전과 지금 버전이 다르면 저장하지 않는다.
sealed class VersionCheckedOrderRepository : IOrderRepository
{
private readonly Dictionary<string, StoredOrder> rows = new();
public Result<int> Add(Order order)
{
if (!rows.TryAdd(order.Id, StoredOrder.From(order, 1))) return Result<int>.Fail(ErrorCode.Duplicate, order.Id);
order.MarkStored(1);
return Result<int>.Ok(1);
}
public Result<Order> GetById(string id) =>
rows.TryGetValue(id, out var row) ? Result<Order>.Ok(row.ToOrder()) : Result<Order>.Fail(ErrorCode.NotFound, id);
public IReadOnlyList<Order> FindByCustomer(string customerId) =>
rows.Values.Where(r => r.CustomerId == customerId).OrderBy(r => r.Id, StringComparer.Ordinal).Select(r => r.ToOrder()).ToList();
public Result<int> Save(Order order)
{
if (!rows.TryGetValue(order.Id, out var current)) return Result<int>.Fail(ErrorCode.NotFound, order.Id);
if (current.Version != order.Version)
return Result<int>.Fail(ErrorCode.VersionConflict, $"{order.Id} read v{order.Version}, stored v{current.Version}");
var next = current.Version + 1;
rows[order.Id] = StoredOrder.From(order, next);
order.MarkStored(next);
return Result<int>.Ok(next);
}
}
// 어느 저장소 구현이 들어오든 같은 코드로 동작한다.
sealed class OrderService(IOrderRepository orders)
{
public Result<int> Cancel(string orderId) =>
orders.GetById(orderId).Then(o => o.Cancel()).Then(orders.Save);
public int OpenCount(string customerId) =>
orders.FindByCustomer(customerId).Count(o => o.Status == OrderStatus.Open);
}== LastWriteWinsOrderRepository
add o-1: ok 1
add o-2: ok 1
o-2 add item: ok 2
o-2 add item again: ok 3
add o-1 again: Duplicate (o-1)
cancel o-9: NotFound (o-9)
A cancel o-1: ok 2
B add item: ok 3
o-1 now: ok o-1 Open total=150 v3
c-1 open orders: 2
== VersionCheckedOrderRepository
add o-1: ok 1
add o-2: ok 1
o-2 add item: ok 2
o-2 add item again: ok 3
add o-1 again: Duplicate (o-1)
cancel o-9: NotFound (o-9)
A cancel o-1: ok 2
B add item: VersionConflict (o-1 read v1, stored v2)
o-1 now: ok o-1 Cancelled total=120 v2
c-1 open orders: 1첫 번째 구현에서는 B의 저장이 A의 취소를 덮어써서 o-1이 다시 Open이 되었다. 두 번째 구현은 같은 서비스 코드에서 이 충돌을 VersionConflict로 돌려주고, 다시 읽어 재시도할지 사용자에게 알릴지는 호출자가 정한다. EF Core는 같은 검사를 동시성 토큰으로 하며, WHERE에 원래 버전 조건을 붙인 UPDATE가 0행을 바꾸면 DbUpdateConcurrencyException을 던진다.15 SaveChanges를 부르는 쪽(리포지토리의 Save든 작업 단위든)이 이 예외를 잡아 VersionConflict 같은 값으로 바꾸면 서비스 코드는 이 예제와 같은 모양을 유지한다. Fowler 카탈로그에서는 동시에 진행된 업무 트랜잭션 사이의 충돌을 감지해 되돌리는 방식을 David Rice가 쓴 Optimistic Offline Lock 항목이 다룬다.16
충돌로 거절된 객체의 Version은 그대로 남으므로, 다시 시도하려면 주문을 새로 읽어야 한다. 이 예제는 버전 비교와 쓰기가 원자적이지 않아서 단일 스레드에서만 맞고, 여러 스레드가 동시에 Save를 부르면 둘 다 검사를 통과할 수 있다. 실제 DB에서는 비교와 갱신을 조건부 UPDATE 한 문장으로 처리한다. 트랜잭션, 여러 집합체를 묶는 저장, 조회 조건 객체는 구현하지 않았다.
TypeScript 예제
같은 시나리오를 TypeScript로 옮겼다. Result는 ok 필드로 구분하는 유니언 타입이고, andThen이 C#의 Then과 같은 일을 한다.
type OrderStatus = "Open" | "Cancelled";
type ErrorCode = "NotFound" | "Duplicate" | "AlreadyCancelled" | "VersionConflict";
type AppError = { code: ErrorCode; detail: string };
// 실패를 예외 대신 값으로 돌려준다.
type Result<T> = { ok: true; value: T } | { ok: false; error: AppError };
const ok = <T>(value: T): Result<T> => ({ ok: true, value });
const fail = <T>(code: ErrorCode, detail: string): Result<T> => ({ ok: false, error: { code, detail } });
const andThen = <A, B>(r: Result<A>, next: (a: A) => Result<B>): Result<B> => (r.ok ? next(r.value) : r);
const show = <T>(r: Result<T>): string => (r.ok ? `ok ${String(r.value)}` : `${r.error.code} (${r.error.detail})`);
// 집합체 루트. version은 이 객체를 마지막으로 읽거나 저장했을 때의 저장 버전이다.
class Order {
#total: number;
#status: OrderStatus;
#version: number;
constructor(readonly id: string, readonly customerId: string, total: number,
status: OrderStatus = "Open", version = 0) {
this.#total = total;
this.#status = status;
this.#version = version;
}
get version(): number { return this.#version; }
// 저장소가 추가나 저장에 성공한 뒤에만 부른다.
markStored(version: number): void { this.#version = version; }
get total(): number { return this.#total; }
get status(): OrderStatus { return this.#status; }
cancel(): Result<Order> {
if (this.#status === "Cancelled") return fail("AlreadyCancelled", this.id);
this.#status = "Cancelled";
return ok(this);
}
addItem(price: number): Result<Order> {
if (this.#status === "Cancelled") return fail("AlreadyCancelled", this.id);
this.#total += price;
return ok(this);
}
toString(): string { return `${this.id} ${this.#status} total=${this.#total} v${this.#version}`; }
}
interface OrderRepository {
add(order: Order): Result<number>;
getById(id: string): Result<Order>;
findByCustomer(customerId: string): Order[];
save(order: Order): Result<number>;
}
// 저장소 안에는 객체가 아니라 값의 사본을 둔다. 읽을 때마다 새 Order를 만든다.
type StoredOrder = { id: string; customerId: string; total: number; status: OrderStatus; version: number };
const toRow = (o: Order, version: number): StoredOrder =>
({ id: o.id, customerId: o.customerId, total: o.total, status: o.status, version });
const toOrder = (r: StoredOrder): Order => new Order(r.id, r.customerId, r.total, r.status, r.version);
// 첫 번째 구현: 버전을 비교하지 않고 마지막 저장이 이긴다.
class LastWriteWinsOrderRepository implements OrderRepository {
readonly #rows = new Map<string, StoredOrder>();
add(order: Order): Result<number> {
if (this.#rows.has(order.id)) return fail("Duplicate", order.id);
this.#rows.set(order.id, toRow(order, 1));
order.markStored(1);
return ok(1);
}
getById(id: string): Result<Order> {
const row = this.#rows.get(id);
return row === undefined ? fail("NotFound", id) : ok(toOrder(row));
}
findByCustomer(customerId: string): Order[] {
return [...this.#rows.values()].filter((r) => r.customerId === customerId)
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)).map(toOrder);
}
save(order: Order): Result<number> {
const current = this.#rows.get(order.id);
if (current === undefined) return fail("NotFound", order.id);
const next = current.version + 1;
this.#rows.set(order.id, toRow(order, next));
order.markStored(next);
return ok(next);
}
}
// 두 번째 구현: 읽어 간 버전과 지금 버전이 다르면 저장하지 않는다.
class VersionCheckedOrderRepository implements OrderRepository {
readonly #rows = new Map<string, StoredOrder>();
add(order: Order): Result<number> {
if (this.#rows.has(order.id)) return fail("Duplicate", order.id);
this.#rows.set(order.id, toRow(order, 1));
order.markStored(1);
return ok(1);
}
getById(id: string): Result<Order> {
const row = this.#rows.get(id);
return row === undefined ? fail("NotFound", id) : ok(toOrder(row));
}
findByCustomer(customerId: string): Order[] {
return [...this.#rows.values()].filter((r) => r.customerId === customerId)
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)).map(toOrder);
}
save(order: Order): Result<number> {
const current = this.#rows.get(order.id);
if (current === undefined) return fail("NotFound", order.id);
if (current.version !== order.version) {
return fail("VersionConflict", `${order.id} read v${order.version}, stored v${current.version}`);
}
const next = current.version + 1;
this.#rows.set(order.id, toRow(order, next));
order.markStored(next);
return ok(next);
}
}
// 어느 저장소 구현이 들어오든 같은 코드로 동작한다.
class OrderService {
constructor(private readonly orders: OrderRepository) {}
cancel(orderId: string): Result<number> {
const cancelled = andThen(this.orders.getById(orderId), (o) => o.cancel());
return andThen(cancelled, (o) => this.orders.save(o));
}
openCount(customerId: string): number {
return this.orders.findByCustomer(customerId).filter((o) => o.status === "Open").length;
}
}
const stores: OrderRepository[] = [new LastWriteWinsOrderRepository(), new VersionCheckedOrderRepository()];
for (const repo of stores) {
console.log("== " + repo.constructor.name);
const service = new OrderService(repo);
console.log("add o-1: " + show(repo.add(new Order("o-1", "c-1", 120))));
// 추가나 저장에 성공하면 같은 객체의 version도 새 번호가 되어 다시 저장할 수 있다.
const o2 = new Order("o-2", "c-1", 80);
console.log("add o-2: " + show(repo.add(o2)));
console.log("o-2 add item: " + show(andThen(o2.addItem(5), (o) => repo.save(o))));
console.log("o-2 add item again: " + show(andThen(o2.addItem(5), (o) => repo.save(o))));
console.log("add o-1 again: " + show(repo.add(new Order("o-1", "c-9", 10))));
console.log("cancel o-9: " + show(service.cancel("o-9")));
// 요청 B가 먼저 o-1을 읽어 둔다.
const requestB = repo.getById("o-1");
// 요청 A가 같은 주문을 읽고 취소해 저장한다.
console.log("A cancel o-1: " + show(service.cancel("o-1")));
// 요청 B는 취소 전에 읽은 복사본에 상품을 더해 저장한다.
const added = andThen(requestB, (o) => o.addItem(30));
console.log("B add item: " + show(andThen(added, (o) => repo.save(o))));
console.log("o-1 now: " + show(repo.getById("o-1")));
console.log("c-1 open orders: " + service.openCount("c-1"));
}== LastWriteWinsOrderRepository
add o-1: ok 1
add o-2: ok 1
o-2 add item: ok 2
o-2 add item again: ok 3
add o-1 again: Duplicate (o-1)
cancel o-9: NotFound (o-9)
A cancel o-1: ok 2
B add item: ok 3
o-1 now: ok o-1 Open total=150 v3
c-1 open orders: 2
== VersionCheckedOrderRepository
add o-1: ok 1
add o-2: ok 1
o-2 add item: ok 2
o-2 add item again: ok 3
add o-1 again: Duplicate (o-1)
cancel o-9: NotFound (o-9)
A cancel o-1: ok 2
B add item: VersionConflict (o-1 read v1, stored v2)
o-1 now: ok o-1 Cancelled total=120 v2
c-1 open orders: 1출력은 C# 예제와 같다. 여기서는 동기 코드라 비교와 쓰기 사이에 다른 요청이 끼어들지 않지만, 저장이 비동기 I/O가 되면 그 사이에 다른 요청이 실행될 수 있으므로 같은 원자성 문제가 생긴다.
같이 보기
C# 예제는 각각 독립된 .NET 10 콘솔 프로그램이다.
TypeScript 예제는 ES2022 이상으로 컴파일한 JavaScript를 Node.js에서 실행한다.
각주
- Eric Evans. Domain-Driven Design Reference (2015). Domain Language PDF. 16쪽 Aggregates. ↩
- Martin Fowler. Patterns of Enterprise Application Architecture (2002). 저자 사이트 책 소개. ↩
- Edward Hieatt, Rob Mee. Repository. Martin Fowler, Catalog of Patterns of Enterprise Application Architecture. ↩
- Eric Evans. Domain-Driven Design: Tackling Complexity in the Heart of Software. Addison-Wesley, 2003 (© 2004). 출판사 서지. ↩
- Eric Evans. Domain-Driven Design Reference (2015). Domain Language PDF. 감사의 말(v쪽), 17쪽 Repositories. ↩
- Microsoft Learn. Designing the infrastructure persistence layer. .NET Microservices: Architecture for Containerized .NET Applications. 도입부, Define one repository per aggregate 절. ↩
- Microsoft Learn. DbContext Class. Definition. ↩
- Microsoft Learn. Choosing a testing strategy. EF Core. Repository pattern 절, Summary. ↩
- Microsoft Learn. Choosing a testing strategy. 도입부, Repository pattern 절. ↩
- Microsoft Learn. Designing the infrastructure persistence layer. The Repository pattern makes it easier to test your application logic 절, Repositories shouldn't be mandatory 절. ↩
- Microsoft Learn. Testing without your Production Database System. Repository pattern 절. ↩
- Mark Seemann. IQueryable is Tight Coupling (2012). ↩
- Microsoft Learn. Designing the infrastructure persistence layer. Enforce one aggregate root per repository 절. ↩
- Microsoft Learn. Designing the infrastructure persistence layer. Define one repository per aggregate 절. ↩
- Microsoft Learn. Handling Concurrency Conflicts. EF Core. Optimistic concurrency 절. ↩
- David Rice. Optimistic Offline Lock. Martin Fowler, Catalog of Patterns of Enterprise Application Architecture. ↩