본문으로 건너뛰기

재고 관리

상품을 삭제하면 관련 주문 이력도 함께 사라질까요? 물리적으로 삭제하면 참조 무결성이 깨지고, 과거 데이터를 복구할 수 없습니다. 데이터를 보존하면서 삭제된 것처럼 동작하게 만들 수는 없을까요?

이 장에서는 재고(Inventory) 도메인을 통해 ISoftDeletable 패턴과 Cursor 기반 페이지네이션을 구현합니다. 논리 삭제/복원 메커니즘과 ActiveProductSpec을 사용한 필터링을 보여주며, Cursor 페이지네이션으로 대규모 데이터 처리 패턴을 학습합니다.


이 장을 완료하면 다음을 할 수 있습니다:

  1. ISoftDeletable 인터페이스로 논리 삭제를 구현할 수 있습니다
  2. Delete() / Restore() 메서드로 삭제/복원 상태를 관리할 수 있습니다
  3. ActiveProductSpec으로 삭제된 항목을 필터링할 수 있습니다
  4. Cursor 기반 페이지네이션 (SearchByCursor)을 구현할 수 있습니다
  5. Stream 비동기 열거로 대량 데이터를 처리할 수 있습니다

물리적 DELETE 대신 DeletedAt을 설정하면 데이터를 보존하면서 삭제 상태를 표현할 수 있습니다. 복원도 간단합니다.

public sealed class Product : AggregateRoot<ProductId>, ISoftDeletable
{
public Option<DateTime> DeletedAt { get; private set; }
// ISoftDeletable.IsDeleted는 DeletedAt.IsSome으로 자동 계산
public Fin<Unit> Delete() { DeletedAt = DateTime.UtcNow; ... }
public Fin<Unit> Restore() { DeletedAt = None; ... }
}

ActiveProductSpec으로 삭제되지 않은 상품만 조회하면, 애플리케이션 코드에서는 삭제 여부를 의식하지 않아도 됩니다.

대규모 데이터를 페이지 단위로 조회할 때, Cursor 방식은 페이지 깊이와 무관하게 일정한 성능을 보장합니다. 첫 페이지는 커서 없이 시작하고, 이후 NextCursor를 전달하여 다음 페이지를 가져옵니다.

// 1페이지: 커서 없이 시작
var page1 = await query.SearchByCursor(spec,
new CursorPageRequest(pageSize: 20),
SortExpression.By("Name")).Run().RunAsync();
// 2페이지: NextCursor를 after에 전달
if (page1.HasMore)
{
var page2 = await query.SearchByCursor(spec,
new CursorPageRequest(after: page1.NextCursor, pageSize: 20),
SortExpression.By("Name")).Run().RunAsync();
}

각 파일이 소프트 삭제와 페이지네이션에서 어떤 역할을 하는지 확인하세요.

파일역할
ProductId.csUlid 기반 상품 식별자
Product.cs상품 Aggregate Root (ISoftDeletable)
ProductDto.csQuery 측 DTO
ActiveProductSpec.cs비삭제 상품 Specification
IProductRepository.csRepository 인터페이스
InMemoryProductRepository.csInMemory Repository 구현
InMemoryProductQuery.csInMemory Query Adapter (Cursor 포함)

이 예제에서 사용된 소프트 삭제와 페이지네이션 패턴을 정리하면 다음과 같습니다.

개념구현
Soft DeleteISoftDeletableDeletedAt, IsDeleted
삭제/복원Delete() / Restore()Fin<Unit>
활성 필터ActiveProductSpec (IsDeleted == false)
Cursor 페이지네이션SearchByCursor(spec, cursor, sort)
비동기 스트림Stream(spec, sort)IAsyncEnumerable<T>

Q1: Soft Delete와 Hard Delete 중 어떤 것을 선택해야 하나요?

섹션 제목: “Q1: Soft Delete와 Hard Delete 중 어떤 것을 선택해야 하나요?”

A: 비즈니스 요구에 따라 다릅니다. 감사 추적이 필요하거나 실수로 삭제한 데이터를 복구해야 하면 Soft Delete, 개인정보 보호법(GDPR 등)에 의해 완전 삭제가 필요하면 Hard Delete를 사용합니다.

Q2: Cursor 페이지네이션이 Offset보다 좋은 이유는?

섹션 제목: “Q2: Cursor 페이지네이션이 Offset보다 좋은 이유는?”

A: Offset은 SKIP N으로 N개를 건너뛰므로 deep page에서 O(N) 비용이 발생합니다. Cursor는 WHERE 조건으로 시작 지점을 직접 지정하므로 페이지 깊이와 무관하게 O(1) 성능입니다.

Q3: ActiveProductSpec을 Repository가 아닌 Query에서 사용하는 이유는?

섹션 제목: “Q3: ActiveProductSpec을 Repository가 아닌 Query에서 사용하는 이유는?”

A: Repository는 Aggregate 단위 CRUD에 집중하고, 필터링/검색은 Query 측의 역할입니다. CQRS에서 Command와 Query의 관심사를 분리하는 핵심 원칙입니다.


재고 관리와 소프트 삭제를 구현했습니다. 마지막으로, 같은 데이터를 Offset, Cursor, Stream 세 가지 방식으로 조회해야 한다면 어떤 것을 선택해야 할까요? 다음 장에서 세 가지 페이지네이션을 비교합니다.

4장: 카탈로그 검색