서비스 레이어에서 유닛 오브 워크 패턴을 사용하여 데이터 무결성 보장
James Reed
Infrastructure Engineer · Leapcell

소개: 안정적인 데이터 작업 조정
오늘날의 복잡한 엔터프라이즈 애플리케이션에서 데이터 일관성 및 무결성을 유지하는 것은 매우 중요합니다. 소프트웨어 시스템이 발전함에 따라 서비스 레이어는 종종 복잡한 비즈니스 로직을 처리하며, 다양한 데이터 저장소 또는 집합에 걸쳐 여러 데이터 수정을 포함합니다. 이러한 시나리오에서 일반적인 함정은 원자성(일련의 작업은 모두 성공하거나 모두 실패해야 한다는 원칙)을 보장하지 못한다는 것입니다. 두 계좌 간에 자금을 이체하는 은행 애플리케이션을 상상해 보세요. 한 건의 출금이 성공했지만 입금이 실패하면 시스템은 불일치 상태가 됩니다. 이러한 과제는 이러한 원자 작업 및 트랜잭션을 관리하여 비즈니스 규칙이 항상 준수되도록 보장하는 강력한 메커니즘의 중요성을 강조합니다. 이 문서는 서비스 레이어 내에서 이러한 문제를 해결하는 강력한 접근 방식인 유닛 오브 워크 패턴을 자세히 살펴보고, 더 안정적이고 유지 관리하기 쉬운 백엔드 시스템을 위한 길을 열어줍니다.
유닛 오브 워크 패턴 이해
유닛 오브 워크 패턴의 구체적인 내용에 들어가기 전에 기본 개념을 명확하게 이해해 보겠습니다.
핵심 용어
- 원자성(Atomicity): 데이터베이스 트랜잭션의 맥락에서 원자성은 트랜잭션이 단일, 분할 불가능한 단위로 취급된다는 것을 의미합니다. 그 안의 모든 작업은 성공적으로 완료되거나, 아무것도 완료되지 않습니다. 부분적인 완료는 없습니다.
- 트랜잭션(Transaction): 단일 논리적 작업 단위로 수행되는 일련의 작업입니다. 트랜잭션은 일반적으로 ACID 속성(원자성, 일관성, 격리성, 지속성)을 가집니다.
- 영속성 무관(Persistence Ignorance): 비즈니스 개체가 저장하거나 로드되는 방식을 알지 못해야 한다는 아이디어입니다. 이는 도메인 모델을 영속성 메커니즘과 분리합니다.
- 리포지토리 패턴(Repository Pattern): 일반적인 데이터 액세스 작업에 대한 인터페이스를 제공하는 도메인과 데이터 매핑 계층 간의 추상화 계층입니다. 데이터 저장 및 검색의 세부 정보를 숨깁니다.
- 변경 추적(Change Tracking): 유닛 오브 워크 내의 개체에 대한 수정 사항을 시스템이 모니터링하는 메커니즘입니다. 이를 통해 UoW는 어떤 변경 사항을 영속화해야 하는지 알 수 있습니다.
유닛 오브 워크 원칙
유닛 오브 워크 패턴은 종종 리포지토리 패턴과 함께 사용되며, 본질적으로 특정 비즈니스 트랜잭션의 일부인 모든 작업에 대해 단일 세션을 만듭니다. 각 리포지토리 작업(예: Update(), Add(), Delete())이 즉시 데이터베이스와 상호 작용하는 대신, 이러한 작업은 유닛 오브 워크에 등록됩니다. 그런 다음 유닛 오브 워크는 조정자 역할을 하여 수명 주기 동안 개체에 발생한 모든 변경 사항을 추적합니다. 유닛 오브 워크의 Commit() 메서드가 호출될 때만 이 보류 중인 모든 변경 사항이 데이터베이스로 플러시되며, 이는 일반적으로 단일 ACID 트랜잭션 내에서 이루어집니다. 이 커밋 내의 어떤 작업이라도 실패하면 전체 트랜잭션이 롤백되어 데이터 일관성을 유지합니다.
작동 방식: 자세히 살펴보기
- 인스턴스화: 비즈니스 작업(예: 서비스 메서드 시작)이 시작될 때 유닛 오브 워크 인스턴스가 생성됩니다.
- 변경 사항 등록: 서비스 메서드가 다양한 리포지토리를 사용하여 데이터 수정(새 엔터티 추가, 기존 엔터티 업데이트, 다른 엔터티 삭제)을 수행함에 따라, 이러한 리포지토리 메서드는 변경 사항을 즉시 영속화하지 않습니다. 대신, 이러한 변경 사항을 유닛 오브 워크에 등록합니다. 유닛 오브 워크는 "더티"(수정됨), "새로움"(추가됨), "제거됨"(삭제됨) 개체의 내부 컬렉션을 유지 관리합니다.
- 커밋: 서비스 메서드 내의 모든 비즈니스 로직 실행이 완료되고 오류가 발생하지 않으면 유닛 오브 워크의
Commit()메서드가 호출됩니다. 그러면 등록된 모든 변경 사항이 데이터베이스에 실제로 영속화됩니다. 이 전체 프로세스는 단일 데이터베이스 트랜잭션으로 래핑됩니다. - 롤백:
Commit()이 호출되기 전이나Commit()프로세스 자체 중에 어느 시점에서든 오류가 발생하면 유닛 오브 워크는Rollback()을 시작할 수 있습니다. 이렇게 하면 부분적인 변경 사항이 저장되지 않고 데이터베이스가 원래 상태로 유지됩니다.
실제 구현 예 (C# 및 Entity Framework Core)
인기 있는 ORM인 C# 및 Entity Framework Core를 사용하여 유닛 오브 워크 패턴의 구현을 보여 드리겠습니다.
먼저 IUnitOfWork 인터페이스를 정의합니다.
// Interfaces/IUnitOfWork.cs public interface IUnitOfWork : IDisposable { // 도메인 또는 일반 리포지토리에 특화된 리포지토리 노출 // 단순화를 위해 더미 예제 리포지토리를 노출하겠습니다. IRepository<Product> Products { get; } IRepository<Order> Orders { get; } Task<int> CompleteAsync(); // 모든 변경 사항을 커밋합니다. void Rollback(); // 모든 변경 사항을 롤백합니다 (종종 Complete를 호출하지 않음으로써 암묵적으로 처리됨). } // Interfaces/IRepository.cs (일반 리포지토리 예제) public interface IRepository<TEntity> where TEntity : class { Task AddAsync(TEntity entity); Task<TEntity> GetByIdAsync(int id); void Update(TEntity entity); void Remove(TEntity entity); // ... 기타 일반적인 CRUD 메서드 }
다음으로 UnitOfWork 클래스를 구현합니다. 일반적으로 DbContext를 래핑합니다.
// Implementations/UnitOfWork.cs public class UnitOfWork : IUnitOfWork { private readonly ApplicationDbContext _context; private IRepository<Product> _products; private IRepository<Order> _orders; public UnitOfWork(ApplicationDbContext context) { _context = context ?? throw new ArgumentNullException(nameof(context)); } public IRepository<Product> Products => _products ??= new Repository<Product>(_context); public IRepository<Order> Orders => _orders ??= new Repository<Order>(_context); // 마법이 일어나는 곳 - 모든 변경 사항이 하나의 트랜잭션으로 저장됩니다. public async Task<int> CompleteAsync() { return await _context.SaveChangesAsync(); } public void Rollback() { // EF Core에서 SaveChangesAsync()를 호출하지 않고 컨텍스트를 폐기하면 // 변경 사항이 암묵적으로 롤백됩니다. 폐기 전에 보류 중인 변경 사항을 명시적으로 롤백하려면 // 추적된 엔터티를 분리해야 할 수 있습니다. 이 예제에서는 단순화를 위해 EF Core 기본 동작에 의존하겠습니다. foreach (var entry in _context.ChangeTracker.Entries()) { switch (entry.State) { case EntityState.Added: entry.State = EntityState.Detached; break; case EntityState.Modified: case EntityState.Deleted: entry.Reload(); // 데이터베이스에서 원래 상태를 다시 로드합니다. break; } } } public void Dispose() { _context.Dispose(); } }
그리고 간단한 (하지만 실제로는 더 복잡한) 일반 Repository 구현입니다.
// Implementations/Repository.cs public class Repository<TEntity> : IRepository<TEntity> where TEntity : class { protected readonly ApplicationDbContext _context; public Repository(ApplicationDbContext context) { _context = context; } public async Task AddAsync(TEntity entity) { await _context.Set<TEntity>().AddAsync(entity); } public async Task<TEntity> GetByIdAsync(int id) { return await _context.Set<TEntity>().FindAsync(id); } public void Update(TEntity entity) { _context.Set<TEntity>().Update(entity); } public void Remove(TEntity entity) { _context.Set<TEntity>().Remove(entity); } }
마지막으로 서비스 레이어에서 사용하는 방법입니다.
// Services/OrderService.cs public class OrderService { private readonly IUnitOfWork _unitOfWork; public OrderService(IUnitOfWork unitOfWork) { _unitOfWork = unitOfWork; } public async Task PlaceOrderAsync(int productId, int quantity, decimal totalAmount) { // 논리적 트랜잭션 시작 try { // 1. 제품을 가져와 가용성을 확인하고 가격을 가져옵니다. var product = await _unitOfWork.Products.GetByIdAsync(productId); if (product == null || product.Stock < quantity) { throw new InvalidOperationException("Product not available or insufficient stock."); } // 2. 새 주문 생성 var order = new Order { ProductId = productId, Quantity = quantity, TotalAmount = totalAmount, OrderDate = DateTime.UtcNow }; await _unitOfWork.Orders.AddAsync(order); // 3. 제품 재고 업데이트 product.Stock -= quantity; _unitOfWork.Products.Update(product); // 모든 변경 사항이 이제 UnitOfWork에 의해 추적됩니다. // CompleteAsync()를 호출하면 모두 단일 데이터베이스 트랜잭션으로 저장됩니다. await _unitOfWork.CompleteAsync(); Console.WriteLine($"Order {order.Id} placed successfully. Product stock updated."); } catch (Exception ex) { // 암묵적 롤백(CompleteAsync()를 호출하지 않음) 또는 // 명시적 롤백 로직이 이를 처리합니다. Console.WriteLine($"Error placing order: {ex.Message}. Transaction rolled back."); _unitOfWork.Rollback(); // 보류 중인 변경 사항을 명시적으로 롤백합니다. throw; // 오류를 전파하기 위해 다시 던집니다. } } }
이 예제에서는 Order에 대한 AddAsync와 Product에 대한 Update가 모두 UnitOfWork가 래핑하는 ApplicationDbContext 인스턴스에 등록됩니다. _unitOfWork.CompleteAsync()가 호출될 때만 이러한 변경 사항이 단일 트랜잭션으로 데이터베이스에 커밋됩니다. 오류가 발생하면(예: 제품을 찾을 수 없음, 재고 부족) CompleteAsync가 호출되지 않으므로 변경 사항이 영속화되지 않아 원자성이 유지됩니다.
적용 시나리오
유닛 오브 워크 패턴은 특히 다음 시나리오에서 유익합니다.
- 복잡한 비즈니스 작업: 단일 비즈니스 작업의 일부로 여러 도메인 엔터티가 수정됩니다.
- 교차 리포지토리 트랜잭션: 작업에 서로 다른 리포지토리의 데이터가 포함되며, 원자적으로 커밋되어야 합니다.
- 성능 최적화: 여러 데이터베이스 작업을 단일 커밋으로 일괄 처리하면 데이터베이스 왕복 횟수가 줄어들어 성능이 향상됩니다.
- 일관성 보장: 작업이 실패하더라도 데이터베이스가 항상 일관된 상태로 유지되도록 보장합니다.
- 테스트 용이성: 영속성에서 서비스 로직을 분리하면 서비스를 독립적으로 테스트하기 쉬워지며,
IUnitOfWork를 모의(mock)할 수 있습니다.
결론: 강력한 서비스 레이어의 초석
유닛 오브 워크 패턴은 강력하고 유지 관리 가능하며 데이터 일관성이 뛰어난 서비스 레이어를 구축하기 위한 기본 설계 원칙입니다. 일련의 작업을 캡슐화하고 단일 트랜잭션 경계 내에서 영속성을 조정함으로써 원자 작업 및 트랜잭션 관리 문제를 우아하게 해결합니다. 이 패턴을 채택하면 애플리케이션의 데이터 무결성이 보장되어 중요한 비즈니스 프로세스를 위한 안정적인 백본을 제공하게 됩니다. 궁극적으로 유닛 오브 워크 패턴은 서비스 작업이 전체적으로 완벽하게 성공하거나 일관성이 전혀 남지 않은 채 우아하게 실패하는 것을 보장하는 데 도움이 되는 동반자입니다.