작업 단위 패턴
한 업무 처리 동안 생긴 새 객체, 바뀐 객체, 지울 객체를 모아 두었다가 한 번에 커밋해, 일부만 반영되는 쓰기와 잦은 DB 호출을 막는 데이터 접근 패턴.
· 마지막 업데이트: 2026-10-25 · 25 min read
개요
주문을 확정하는 코드가 주문 행을 넣고, 주문 항목을 넣고, 상품 재고를 줄이고, 장바구니 항목을 지운다고 하자. 변경할 때마다 바로 DB에 쓰면 DB를 네 번 호출한다. 세 번째 쓰기인 재고 갱신이 실패하면 앞의 두 행은 이미 들어가 있어서, 재고는 그대로인데 주문만 남는다. 아래 범위 넓힌 예제의 첫 출력이 이 상태다.
작업 단위(Unit of Work)는 한 업무 처리 동안 생긴 변경을 바로 쓰지 않고 목록에 모아 두었다가, 끝에서 한 번에 저장소에 반영하는 객체다. 여기서 한 업무 처리는 "주문 확정"처럼 사용자에게 한 덩어리로 의미가 있는 작업이고, Fowler는 이를 비즈니스 트랜잭션(business transaction)이라고 부른다.
작업 단위가 모으는 변경은 세 종류다. 새로 만든 객체(new)는 INSERT로, 읽은 뒤 값이 바뀐 객체(dirty)는 UPDATE로, 지우기로 한 객체(removed)는 DELETE로 바뀐다. 호출 코드가 이 목록에 변경을 넣는 일을 등록(register), 모은 변경을 저장소에 한 번에 보내는 일을 커밋(commit)이라고 한다. 예제에서는 주문과 주문 항목이 new, 재고가 dirty, 장바구니 항목이 removed다.
커밋한 변경이 전부 적용되거나 전혀 적용되지 않으려면 저장소의 트랜잭션이 필요하다. 트랜잭션은 여러 DB 작업을 하나의 일관성 경계로 묶어, 성공하면 commit하고 실패하면 rollback하는 장치다. 작업 단위는 변경을 트랜잭션 하나에 담을 수 있게 모으고, 원자성 자체는 DB가 제공한다. 변경마다 바로 쓰더라도 전체를 DB 트랜잭션 하나로 감싸면 원자성은 얻지만, 그 트랜잭션을 업무 처리 내내 열어 두어야 한다. 이 글에서는 변경을 모아 커밋할 때 트랜잭션을 여는 방식을 쓴다. 조회부터 일관성을 지켜야 하는 업무라면 트랜잭션을 시작할 시점도 그에 맞춰 정한다.
구조
UnitOfWork는 added, dirty, removed 세 목록에 Row를 보관한다. Plan()은 이 목록을 INSERT, UPDATE, DELETE 순서의 Change 목록으로 바꾸고, Commit()은 그 목록을 Store.Apply()에 한 번 넘긴 뒤 결과를 CommitResult로 돌려준다.
등록하는 동안에는 저장소를 부르지 않고, 저장소 호출은 Commit() 안의 Apply() 한 번뿐이다.
역사
Martin Fowler는 2002년 Patterns of Enterprise Application Architecture에서 Unit of Work를 정리했다. 이 책에는 Dave Rice, Matthew Foemmel, Edward Hieatt, Robert Mee, Randy Stafford가 함께 참여했다.1 책의 온라인 카탈로그는 이 패턴을 객체-관계 행위 패턴(Object-Relational Behavioral Patterns)으로 분류하고, 비즈니스 트랜잭션의 영향을 받는 객체 목록을 유지하면서 변경 내용의 기록과 동시성 문제의 해결을 조정하는 패턴으로 설명한다.2
같은 카탈로그 항목은 객체 모델이 바뀔 때마다 DB를 바꾸면 아주 작은 DB 호출이 많아져 느려지고, 여러 요청에 걸친 비즈니스 트랜잭션 동안 DB 트랜잭션을 계속 열어 두어야 한다는 문제를 든다. 항목에 실린 인터페이스 그림에는 registerNew, registerDirty, registerClean, registerDeleted, commit, rollback 연산이 있다.3 이 문서의 예제는 registerDeleted 대신 RegisterRemoved라는 이름을 쓰고, registerClean과 rollback은 구현하지 않는다.
ORM이 이 역할을 맡는 경우가 많다. Hibernate 사용자 가이드는 Session을 PoEAA의 Unit of Work를 개념적으로 모델링한 단일 스레드, 짧은 수명의 객체로 설명하고, Jakarta Persistence에서는 EntityManager가 Session에 해당한다고 적는다.4 EF Core 문서도 DbContext가 짧은 수명의 unit-of-work를 나타내도록 설계되었다고 설명한다. DbContext는 엔터티를 Added, Modified, Deleted 같은 상태로 추적하고, SaveChanges를 호출하면 각각 INSERT, UPDATE, DELETE를 실행한다.5
왜 사용하는가?
저장소 호출 수가 줄어든다. EF Core는 변경이 생길 때마다 DB에 쓰지 않고, SaveChanges를 호출할 때 모아 둔 변경을 배치로 보낸다. 한 배치에 담는 문장 수는 데이터베이스 공급자에 따라 다르다. SQL Server에서는 기본으로 42개까지 담고, 나머지 문장은 별도 왕복으로 실행한다.6
실패했을 때 일부만 반영된 상태가 남지 않는다. EF Core는 데이터베이스 공급자가 트랜잭션을 지원하면 SaveChanges 한 번의 변경을 하나의 트랜잭션으로 적용하고, 하나라도 실패하면 롤백해 데이터베이스를 바꾸지 않는다.7
문장 실행 순서를 호출 코드의 순서와 떼어 놓는다. Hibernate는 SQL 문장 순서를 엔터티 상태 연산을 호출한 순서가 아니라 ActionQueue가 정한다고 설명한다. ActionQueue 목록에서 고아 삭제(OrphanRemovalAction)는 맨 앞, 엔터티 삽입은 엔터티 갱신보다 앞, 엔터티 삭제(EntityDeleteAction)는 맨 마지막이다. 그래서 같은 가이드의 예처럼 엔터티를 지우고 같은 키로 새 엔터티를 넣으면 INSERT가 DELETE보다 먼저 실행되어 키 충돌이 날 수 있다.8 순서는 ORM마다 다르며, EF Core 변경 추적 문서의 예는 UPDATE, DELETE, INSERT 순서로 실행된다.9 예제의 작업 단위는 외래 키가 가리키는 부모 행(상품, 주문)을 자식 행(주문 항목, 장바구니 항목)보다 먼저 넣도록 테이블 순서를 정해 두었다.
동시성 충돌을 커밋 시점에 검사할 수 있다. 낙관적 동시성(optimistic concurrency)은 데이터를 미리 잠그지 않고, 읽은 뒤 다른 쪽이 값을 바꿨으면 저장할 때 실패하게 하는 방식이다. EF Core는 동시성 토큰(concurrency token)으로 지정한 속성을 UPDATE와 DELETE의 WHERE 절에 넣고, 영향받은 행이 0개면 DbUpdateConcurrencyException을 던진다.10 예제에서는 행마다 둔 Version이 이 토큰 역할을 한다.
작업 단위로 얻는 원자성은 DB 트랜잭션 하나의 범위까지다. 메시지 브로커에 이벤트를 보내거나 원격 API를 부르는 일은 같은 커밋에 들어가지 않으므로, DB 커밋은 성공했는데 메시지 발행은 실패하는 일이 생길 수 있다. Transactional Outbox 패턴은 보낼 메시지를 업무 데이터와 같은 DB 트랜잭션 안의 outbox에 저장하고, 별도 중계 프로세스가 그 메시지를 브로커로 보내는 방식으로 이 문제를 다룬다. 중계 프로세스는 같은 메시지를 여러 번 보낼 수 있어 받는 쪽은 중복을 견뎌야 한다.11
작업 단위가 충돌을 해결해 주지도 않는다. 커밋이 버전 충돌로 거부되면 최신 값을 다시 읽고 변경을 다시 계산할지, 사용자에게 알릴지는 호출 코드가 정한다. EF Core 문서도 사용자에게 알리기, 새 데이터를 다시 조회해 재시도하기, 값 병합하기를 선택지로 든다.12 다시 시도한다면 같은 변경 목록을 그대로 다시 보내지 말고, 최신 값으로 변경을 새로 만들어야 한다.
작업 단위를 오래 살려 두면 추적한 값이 낡아 충돌이 늘고 추적 비용도 쌓인다. EF Core 문서는 DbContext를 만들고, 엔터티를 추적해 바꾸고, SaveChanges를 호출하고, 폐기하는 짧은 수명을 기본으로 설명한다.13 많은 행을 같은 식으로 바꾸는 일괄 갱신이라면, EF Core 문서는 변경 추적을 거치지 않는 ExecuteUpdate가 더 효율적이라고 적는다.14
리포지토리 패턴과는 맡는 일이 다르다. 리포지토리는 업무 객체를 조회하고 저장소에 넣는 창구를 정하고, 작업 단위는 그렇게 다룬 객체의 변경을 언제 한 번에 반영할지 정한다.
예제
주문 확정에 필요한 변경 네 개를 등록하고, 커밋 계획을 출력한 뒤 커밋한다. Store는 메모리 저장소이며 Apply() 한 번을 트랜잭션 하나처럼 다룬다. 변경을 사본에 적용해 보고, 전부 성공했을 때만 사본으로 바꿔 끼운다.
using System;
using System.Collections.Generic;
using System.Linq;
var store = new Store();
store.Seed(new Row("products", "p1", "stock=5", 1));
store.Seed(new Row("cart_items", "c1", "p1 x2", 1));
var uow = new UnitOfWork(store);
// 등록만 하고 저장소에는 아직 쓰지 않는다. 주문 항목을 주문보다 먼저 등록했다.
uow.RegisterNew(new Row("order_lines", "l1", "o1 p1 x2", 0));
uow.RegisterNew(new Row("orders", "o1", "placed", 0));
uow.RegisterDirty(new Row("products", "p1", "stock=3", 1));
uow.RegisterRemoved(new Row("cart_items", "c1", "", 1));
Console.WriteLine("store calls before commit: " + store.Calls);
foreach (var change in uow.Plan()) Console.WriteLine("plan: " + change);
Console.WriteLine("commit: " + uow.Commit());
Console.WriteLine("store calls after commit: " + store.Calls);
foreach (var row in store.Rows()) Console.WriteLine("row: " + row);
enum Kind { Insert, Update, Delete }
sealed record Row(string Table, string Id, string Data, int Version)
{
public override string ToString() => $"{Table}/{Id} v{Version} {Data}";
}
sealed record Change(Kind Kind, Row Row)
{
public override string ToString() => $"{Kind.ToString().ToUpperInvariant()} {Row.Table}/{Row.Id}";
}
abstract record CommitResult;
sealed record Committed(int Changes) : CommitResult;
sealed record Rejected(string Code, string Key, string Detail) : CommitResult;
// 메모리 저장소. Apply 한 번을 트랜잭션 하나처럼 다룬다.
sealed class Store
{
private Dictionary<string, Row> rows = new();
public int Calls { get; private set; }
public void Seed(Row row) => rows[Key(row)] = row;
public IEnumerable<Row> Rows() => rows.Values.OrderBy(Key, StringComparer.Ordinal);
// 사본에 차례로 적용하고, 전부 성공했을 때만 사본으로 바꿔 끼운다.
public CommitResult Apply(IReadOnlyList<Change> changes)
{
Calls++;
var draft = new Dictionary<string, Row>(rows);
foreach (var change in changes)
{
var rejected = ApplyOne(draft, change);
if (rejected is not null) return rejected;
}
rows = draft;
return new Committed(changes.Count);
}
private static Rejected? ApplyOne(Dictionary<string, Row> draft, Change change)
{
var key = Key(change.Row);
var exists = draft.TryGetValue(key, out var current);
if (change.Kind == Kind.Insert)
{
if (exists) return new Rejected("DuplicateKey", key, "row already exists");
draft[key] = change.Row with { Version = 1 };
return null;
}
// 수정과 삭제는 읽었던 버전이 지금도 같을 때만 적용한다.
if (current is null) return new Rejected("VersionConflict", key, $"read v{change.Row.Version}, store missing");
if (current.Version != change.Row.Version)
return new Rejected("VersionConflict", key, $"read v{change.Row.Version}, store v{current.Version}");
if (change.Kind == Kind.Delete) draft.Remove(key);
else draft[key] = change.Row with { Version = current.Version + 1 };
return null;
}
private static string Key(Row row) => row.Table + "/" + row.Id;
}
// 작업 단위: 새 행, 바뀐 행, 지울 행을 모아 두었다가 한 번에 보낸다.
sealed class UnitOfWork(Store store)
{
// 부모 테이블을 앞에 둔다. 넣을 때는 앞에서부터, 지울 때는 뒤에서부터 처리한다.
private static readonly string[] TableOrder = ["products", "orders", "order_lines", "cart_items"];
private readonly List<Row> added = new();
private readonly List<Row> dirty = new();
private readonly List<Row> removed = new();
public void RegisterNew(Row row) => added.Add(row);
public void RegisterDirty(Row row) => dirty.Add(row);
public void RegisterRemoved(Row row) => removed.Add(row);
public IReadOnlyList<Change> Plan() =>
added.OrderBy(Rank).Select(r => new Change(Kind.Insert, r))
.Concat(dirty.OrderBy(Rank).Select(r => new Change(Kind.Update, r)))
.Concat(removed.OrderByDescending(Rank).Select(r => new Change(Kind.Delete, r)))
.ToList();
public CommitResult Commit()
{
// 순서를 정할 수 없는 테이블이 있으면 저장소를 부르지 않고 거부한다.
var unknown = added.Concat(dirty).Concat(removed).FirstOrDefault(r => Rank(r) < 0);
if (unknown is not null)
return new Rejected("UnknownTable", unknown.Table + "/" + unknown.Id, "table has no commit order");
var result = store.Apply(Plan());
if (result is not Committed) return result;
added.Clear();
dirty.Clear();
removed.Clear();
return result;
}
private static int Rank(Row row) => Array.IndexOf(TableOrder, row.Table);
}store calls before commit: 0
plan: INSERT orders/o1
plan: INSERT order_lines/l1
plan: UPDATE products/p1
plan: DELETE cart_items/c1
commit: Committed { Changes = 4 }
store calls after commit: 1
row: order_lines/l1 v1 o1 p1 x2
row: orders/o1 v1 placed
row: products/p1 v2 stock=3주문 항목을 먼저 등록했지만 계획에서는 주문이 먼저 나오고, 저장소 호출은 커밋할 때 한 번뿐이다.
범위 넓힌 예제
상품 p1을 버전 1로 읽은 뒤 다른 요청이 먼저 재고를 바꿔 버전 2가 된 저장소를 두 개 만든다. 첫 번째 저장소에는 변경마다 바로 쓰고, 두 번째 저장소에는 같은 변경을 작업 단위로 한 번에 커밋한다. 마지막으로 커밋 순서가 정해지지 않은 테이블의 행을 등록해 본다. 커밋 결과는 예외가 아니라 Committed나 Rejected 값으로 돌아온다.
using System;
using System.Collections.Generic;
using System.Linq;
var order = new Row("orders", "o1", "placed", 0);
var line = new Row("order_lines", "l1", "o1 p1 x2", 0);
var stock = new Row("products", "p1", "stock=3", 1);
var cart = new Row("cart_items", "c1", "", 1);
Console.WriteLine("== immediate writes");
var direct = NewStoreWithConcurrentChange();
var callsBefore = direct.Calls;
// 변경마다 저장소를 부르고, 실패하면 그 자리에서 멈춘다.
Change[] steps =
[
new(Kind.Insert, order),
new(Kind.Insert, line),
new(Kind.Update, stock),
new(Kind.Delete, cart),
];
foreach (var step in steps)
{
var result = direct.Apply([step]);
Console.WriteLine(step + ": " + result);
if (result is not Committed) break;
}
Console.WriteLine("store calls: " + (direct.Calls - callsBefore));
Dump("after", direct);
Console.WriteLine("== unit of work");
var store = NewStoreWithConcurrentChange();
callsBefore = store.Calls;
Dump("before", store);
var uow = new UnitOfWork(store);
uow.RegisterNew(order);
uow.RegisterNew(line);
uow.RegisterDirty(stock);
uow.RegisterRemoved(cart);
var commit = uow.Commit();
Console.WriteLine("commit: " + Describe(commit));
Console.WriteLine("store calls: " + (store.Calls - callsBefore));
Dump("after", store);
Console.WriteLine("== unknown table");
callsBefore = store.Calls;
var audit = new UnitOfWork(store);
audit.RegisterNew(new Row("audit_log", "a1", "order o1 placed", 0));
Console.WriteLine("commit: " + Describe(audit.Commit()));
Console.WriteLine("store calls: " + (store.Calls - callsBefore));
// 상품 p1을 v1으로 읽은 뒤, 다른 요청이 먼저 재고를 바꿔 v2가 된 저장소를 만든다.
static Store NewStoreWithConcurrentChange()
{
var s = new Store();
s.Seed(new Row("products", "p1", "stock=5", 1));
s.Seed(new Row("cart_items", "c1", "p1 x2", 1));
s.Apply([new Change(Kind.Update, new Row("products", "p1", "stock=4", 1))]);
return s;
}
// 실패는 예외가 아니라 결과 값으로 돌아온다. 다시 읽고 다시 계산할지는 호출자가 정한다.
static string Describe(CommitResult result) => result switch
{
Committed ok => $"committed {ok.Changes} changes",
Rejected no => $"rejected {no.Code} at {no.Key} ({no.Detail})",
_ => "unknown result",
};
static void Dump(string label, Store s)
{
foreach (var row in s.Rows()) Console.WriteLine(label + ": " + row);
}
enum Kind { Insert, Update, Delete }
sealed record Row(string Table, string Id, string Data, int Version)
{
public override string ToString() => $"{Table}/{Id} v{Version} {Data}";
}
sealed record Change(Kind Kind, Row Row)
{
public override string ToString() => $"{Kind.ToString().ToUpperInvariant()} {Row.Table}/{Row.Id}";
}
abstract record CommitResult;
sealed record Committed(int Changes) : CommitResult;
sealed record Rejected(string Code, string Key, string Detail) : CommitResult;
// 메모리 저장소. Apply 한 번을 트랜잭션 하나처럼 다룬다.
sealed class Store
{
private Dictionary<string, Row> rows = new();
public int Calls { get; private set; }
public void Seed(Row row) => rows[Key(row)] = row;
public IEnumerable<Row> Rows() => rows.Values.OrderBy(Key, StringComparer.Ordinal);
// 사본에 차례로 적용하고, 전부 성공했을 때만 사본으로 바꿔 끼운다.
public CommitResult Apply(IReadOnlyList<Change> changes)
{
Calls++;
var draft = new Dictionary<string, Row>(rows);
foreach (var change in changes)
{
var rejected = ApplyOne(draft, change);
if (rejected is not null) return rejected;
}
rows = draft;
return new Committed(changes.Count);
}
private static Rejected? ApplyOne(Dictionary<string, Row> draft, Change change)
{
var key = Key(change.Row);
var exists = draft.TryGetValue(key, out var current);
if (change.Kind == Kind.Insert)
{
if (exists) return new Rejected("DuplicateKey", key, "row already exists");
draft[key] = change.Row with { Version = 1 };
return null;
}
// 수정과 삭제는 읽었던 버전이 지금도 같을 때만 적용한다.
if (current is null) return new Rejected("VersionConflict", key, $"read v{change.Row.Version}, store missing");
if (current.Version != change.Row.Version)
return new Rejected("VersionConflict", key, $"read v{change.Row.Version}, store v{current.Version}");
if (change.Kind == Kind.Delete) draft.Remove(key);
else draft[key] = change.Row with { Version = current.Version + 1 };
return null;
}
private static string Key(Row row) => row.Table + "/" + row.Id;
}
// 작업 단위: 새 행, 바뀐 행, 지울 행을 모아 두었다가 한 번에 보낸다.
sealed class UnitOfWork(Store store)
{
// 부모 테이블을 앞에 둔다. 넣을 때는 앞에서부터, 지울 때는 뒤에서부터 처리한다.
private static readonly string[] TableOrder = ["products", "orders", "order_lines", "cart_items"];
private readonly List<Row> added = new();
private readonly List<Row> dirty = new();
private readonly List<Row> removed = new();
public void RegisterNew(Row row) => added.Add(row);
public void RegisterDirty(Row row) => dirty.Add(row);
public void RegisterRemoved(Row row) => removed.Add(row);
public IReadOnlyList<Change> Plan() =>
added.OrderBy(Rank).Select(r => new Change(Kind.Insert, r))
.Concat(dirty.OrderBy(Rank).Select(r => new Change(Kind.Update, r)))
.Concat(removed.OrderByDescending(Rank).Select(r => new Change(Kind.Delete, r)))
.ToList();
public CommitResult Commit()
{
// 순서를 정할 수 없는 테이블이 있으면 저장소를 부르지 않고 거부한다.
var unknown = added.Concat(dirty).Concat(removed).FirstOrDefault(r => Rank(r) < 0);
if (unknown is not null)
return new Rejected("UnknownTable", unknown.Table + "/" + unknown.Id, "table has no commit order");
var result = store.Apply(Plan());
if (result is not Committed) return result;
added.Clear();
dirty.Clear();
removed.Clear();
return result;
}
private static int Rank(Row row) => Array.IndexOf(TableOrder, row.Table);
}== immediate writes
INSERT orders/o1: Committed { Changes = 1 }
INSERT order_lines/l1: Committed { Changes = 1 }
UPDATE products/p1: Rejected { Code = VersionConflict, Key = products/p1, Detail = read v1, store v2 }
store calls: 3
after: cart_items/c1 v1 p1 x2
after: order_lines/l1 v1 o1 p1 x2
after: orders/o1 v1 placed
after: products/p1 v2 stock=4
== unit of work
before: cart_items/c1 v1 p1 x2
before: products/p1 v2 stock=4
commit: rejected VersionConflict at products/p1 (read v1, store v2)
store calls: 1
after: cart_items/c1 v1 p1 x2
after: products/p1 v2 stock=4
== unknown table
commit: rejected UnknownTable at audit_log/a1 (table has no commit order)
store calls: 0즉시 쓰기는 저장소를 세 번 부르고 주문과 주문 항목만 남긴 채 멈췄다. 작업 단위는 저장소를 한 번 부르고 거부되었으며, 저장소는 커밋 전과 같다. 순서가 없는 테이블의 행은 저장소를 부르기 전에 거부된다.
즉시 쓰기 쪽에는 트랜잭션이 없어서 일부만 남았다. 전체를 DB 트랜잭션 하나로 감싸도 일부만 남지는 않지만, 그 트랜잭션을 업무 처리 내내 열어 두어야 한다. 이 예제의 원자성은 사본을 바꿔 끼우는 메모리 저장소가 흉내 낸 것이고, 실제로는 DB 트랜잭션이 이 일을 맡는다. 거부된 뒤에도 작업 단위는 등록한 변경을 그대로 들고 있으며, 다시 읽고 다시 계산하는 경로는 구현하지 않았다. 같은 행을 두 번 등록하거나 새로 등록한 행을 다시 지우는 경우도 처리하지 않는다. EF Core는 Added 상태의 엔터티를 Remove하면 삽입하지 않고 추적에서 뺀다.15 같은 키의 행을 지우고 다시 넣으면 계획에서 INSERT가 DELETE보다 앞서므로 DuplicateKey로 거부된다. 테이블 순서도 고정 배열로 정했을 뿐 외래 키를 읽어 계산하지 않으며, 이 배열에 없는 테이블의 행이 등록되면 커밋은 저장소를 부르지 않고 UnknownTable로 거부한다.
TypeScript 예제
같은 구조를 TypeScript로 옮겼다. 커밋 결과는 ok 필드로 구분하는 유니언 타입이다. 출력은 커밋 전 상태, 커밋 계획, 거부된 결과, 커밋 후 상태 순서다.
type Kind = "INSERT" | "UPDATE" | "DELETE";
type Row = { table: string; id: string; data: string; version: number };
type Change = { kind: Kind; row: Row };
type CommitResult =
| { ok: true; changes: number }
| { ok: false; code: "DuplicateKey" | "VersionConflict" | "UnknownTable"; key: string; detail: string };
const keyOf = (row: Row): string => `${row.table}/${row.id}`;
const show = (row: Row): string => `${keyOf(row)} v${row.version} ${row.data}`;
// 메모리 저장소. apply 한 번을 트랜잭션 하나처럼 다룬다.
class Store {
#rows = new Map<string, Row>();
calls = 0;
seed(row: Row): void { this.#rows.set(keyOf(row), row); }
rows(): Row[] { return [...this.#rows.values()].sort((a, b) => (keyOf(a) < keyOf(b) ? -1 : 1)); }
// 사본에 차례로 적용하고, 전부 성공했을 때만 사본으로 바꿔 끼운다.
apply(changes: Change[]): CommitResult {
this.calls++;
const draft = new Map(this.#rows);
for (const change of changes) {
const rejected = applyOne(draft, change);
if (rejected !== null) return rejected;
}
this.#rows = draft;
return { ok: true, changes: changes.length };
}
}
function applyOne(draft: Map<string, Row>, change: Change): CommitResult | null {
const key = keyOf(change.row);
const current = draft.get(key);
if (change.kind === "INSERT") {
if (current !== undefined) return { ok: false, code: "DuplicateKey", key, detail: "row already exists" };
draft.set(key, { ...change.row, version: 1 });
return null;
}
// 수정과 삭제는 읽었던 버전이 지금도 같을 때만 적용한다.
if (current === undefined)
return { ok: false, code: "VersionConflict", key, detail: `read v${change.row.version}, store missing` };
if (current.version !== change.row.version)
return { ok: false, code: "VersionConflict", key, detail: `read v${change.row.version}, store v${current.version}` };
if (change.kind === "DELETE") draft.delete(key);
else draft.set(key, { ...change.row, version: current.version + 1 });
return null;
}
// 작업 단위: 새 행, 바뀐 행, 지울 행을 모아 두었다가 한 번에 보낸다.
class UnitOfWork {
// 부모 테이블을 앞에 둔다. 넣을 때는 앞에서부터, 지울 때는 뒤에서부터 처리한다.
static readonly tableOrder = ["products", "orders", "order_lines", "cart_items"];
#added: Row[] = [];
#dirty: Row[] = [];
#removed: Row[] = [];
constructor(private readonly store: Store) {}
registerNew(row: Row): void { this.#added.push(row); }
registerDirty(row: Row): void { this.#dirty.push(row); }
registerRemoved(row: Row): void { this.#removed.push(row); }
plan(): Change[] {
const byRank = (a: Row, b: Row): number => UnitOfWork.rank(a) - UnitOfWork.rank(b);
return [
...[...this.#added].sort(byRank).map((row): Change => ({ kind: "INSERT", row })),
...[...this.#dirty].sort(byRank).map((row): Change => ({ kind: "UPDATE", row })),
...[...this.#removed].sort((a, b) => byRank(b, a)).map((row): Change => ({ kind: "DELETE", row })),
];
}
commit(): CommitResult {
// 순서를 정할 수 없는 테이블이 있으면 저장소를 부르지 않고 거부한다.
const unknown = [...this.#added, ...this.#dirty, ...this.#removed].find((row) => UnitOfWork.rank(row) < 0);
if (unknown !== undefined)
return { ok: false, code: "UnknownTable", key: keyOf(unknown), detail: "table has no commit order" };
const result = this.store.apply(this.plan());
if (!result.ok) return result;
this.#added = [];
this.#dirty = [];
this.#removed = [];
return result;
}
static rank(row: Row): number { return UnitOfWork.tableOrder.indexOf(row.table); }
}
// 상품 p1을 v1으로 읽은 뒤, 다른 요청이 먼저 재고를 바꿔 v2가 된 저장소를 만든다.
const store = new Store();
store.seed({ table: "products", id: "p1", data: "stock=5", version: 1 });
store.seed({ table: "cart_items", id: "c1", data: "p1 x2", version: 1 });
store.apply([{ kind: "UPDATE", row: { table: "products", id: "p1", data: "stock=4", version: 1 } }]);
const callsBefore = store.calls;
for (const row of store.rows()) console.log("before: " + show(row));
const uow = new UnitOfWork(store);
uow.registerNew({ table: "order_lines", id: "l1", data: "o1 p1 x2", version: 0 });
uow.registerNew({ table: "orders", id: "o1", data: "placed", version: 0 });
uow.registerDirty({ table: "products", id: "p1", data: "stock=3", version: 1 });
uow.registerRemoved({ table: "cart_items", id: "c1", data: "", version: 1 });
for (const change of uow.plan()) console.log(`plan: ${change.kind} ${keyOf(change.row)}`);
const result = uow.commit();
// 실패는 예외가 아니라 결과 값으로 돌아온다. 다시 읽고 다시 계산할지는 호출자가 정한다.
console.log(result.ok
? `commit: committed ${result.changes} changes`
: `commit: rejected ${result.code} at ${result.key} (${result.detail})`);
console.log("store calls: " + (store.calls - callsBefore));
for (const row of store.rows()) console.log("after: " + show(row));before: cart_items/c1 v1 p1 x2
before: products/p1 v2 stock=4
plan: INSERT orders/o1
plan: INSERT order_lines/l1
plan: UPDATE products/p1
plan: DELETE cart_items/c1
commit: rejected VersionConflict at products/p1 (read v1, store v2)
store calls: 1
after: cart_items/c1 v1 p1 x2
after: products/p1 v2 stock=4계획에는 변경 네 개가 있었지만, 저장소는 버전 충돌을 발견하고 아무것도 적용하지 않았다. 이 예제도 메모리 저장소를 쓰며, DB 트랜잭션과 재조회, 재시도는 구현하지 않는다.
같이 보기
C# 예제는 각각 독립된 .NET 10 콘솔 프로그램이다.
TypeScript 예제는 ES2022 이상으로 컴파일한 JavaScript를 Node.js에서 실행한다.
각주
- Martin Fowler, with Dave Rice, Matthew Foemmel, Edward Hieatt, Robert Mee, Randy Stafford. Patterns of Enterprise Application Architecture (2002). 저자 책 소개. ↩
- Martin Fowler. Unit of Work. PoEAA 온라인 카탈로그 항목. 분류는 카탈로그 목차의 Object-Relational Behavioral Patterns. ↩
- Martin Fowler. Unit of Work. 본문 2문단과 인터페이스 그림. ↩
- Hibernate ORM 6.6 User Guide. 2. Architecture, 2.1 Overview. Session 항목. ↩
- Microsoft Learn. Change Tracking - EF Core. How to track entities, Entity states 절. ↩
- Microsoft Learn. Efficient Updating - EF Core. Batching 절. ↩
- Microsoft Learn. Transactions - EF Core. Default transaction behavior 절. ↩
- Hibernate ORM 6.6 User Guide. 7.5 Flush operation order. ActionQueue 목록과 Example 448. ↩
- Microsoft Learn. Change Tracking - EF Core. Query then insert, update, and delete 절의 SQL 로그. ↩
- Microsoft Learn. Handling Concurrency Conflicts - EF Core. Optimistic concurrency, Resolving concurrency conflicts 절. ↩
- Chris Richardson. Pattern: Transactional outbox. microservices.io. ↩
- Microsoft Learn. Handling Concurrency Conflicts - EF Core. Resolving concurrency conflicts 절. ↩
- Microsoft Learn. Change Tracking - EF Core. How to track entities 절의 DbContext 수명 목록. ↩
- Microsoft Learn. Efficient Updating - EF Core. Use ExecuteUpdate and ExecuteDelete when relevant 절. ↩
- Microsoft Learn. Basic SaveChanges - EF Core. Deleting Data 절. ↩