Query Facade
Query Facade
관리자 주문 목록을 만든다고 해보자. 화면에는 주문 번호와 고객 이름, 주문 금액, 주문 상태만 나오는데, 코드는 주문 객체를 가져온 다음 고객 객체를 찾고, 주문 항목을 돌면서 금액을 계산하고, 마지막에야 화면에 보낼 응답을 만든다. 주문을 취소할 때 필요한 규칙까지 목록 조회가 지나가고 있으니, 화면에 열 하나를 붙이려다가 주문 도메인 전체를 건드리는 일이 생긴다.
Query Facade는 이런 조회를 ListOrdersAsync나 GetDashboardAsync 같은 진입점으로 모으고, 화면이나 API에 필요한 결과를 만들어 반환하는 방식이다. 호출자는 고객 이름을 어느 테이블에서 찾는지, 금액을 미리 저장해두었는지, SQL과 ORM 중 무엇을 쓰는지까지 알 필요 없이, 조회 조건을 넘기고 약속된 모양의 데이터를 받으면 된다.
여기서 query는 질문이나 조회, facade는 복잡한 내부 앞에 두는 단순한 인터페이스라는 뜻이다. 읽기 전용 조회가 화면마다 흩어지기 시작하면, 그 조회의 조건과 결과를 한곳으로 모으자는 설계다. 관리자 화면, 대시보드, 리포트, 검색 결과, 목록 API가 대표적인 적용처다.
개인적인 메모:관리자 주문 목록 하나 만들려다가 주문 도메인 전체를 리팩터링하게 되는 순간을 막아주는 패턴이다. Query Facade는 "나 화면 하나 고치려고 했을 뿐인데 어쩌다 이렇게 된걸까"를 막아주는 방패다.
화면에 필요한 데이터와 업무 객체
주문을 취소할 때는 이미 배송이 시작됐는지, 환불할 결제가 있는지, 취소할 권한이 있는지를 확인해야 한다. 주문 객체가 그 규칙을 지키도록 만들어져 있다면 취소 처리에서 그 객체를 거치는 이유가 분명하다.
주문 목록이라면 주문 20개의 이름과 금액을 보여주려고 취소 규칙을 실행할 필요는 없고, 고객의 전체 주소나 주문 항목의 모든 속성도 필요하지 않으니, 읽을 수 있는 주문의 범위와 화면에 나갈 필드를 정한 다음 그에 맞는 데이터를 가져온다. 물론 여기서 생략하는 것은 취소 처리에 필요한 행위이지, 조회 권한이나 금액이 무엇을 뜻하는지까지 생략하는 것은 아니다.
주문 취소
API -> 취소 처리 -> 주문의 업무 규칙 -> 변경 저장
관리자 주문 목록
API -> OrderQueries.ListAsync -> 조회와 결과 구성 -> 목록 DTODTO는 Data Transfer Object의 약자로, 이 예에서는 호출자에게 전달할 데이터의 모양이다. OrderListRow에 주문 번호와 고객 이름이 들어 있다고 해서 그 객체가 주문을 취소하거나 고객 이름을 바꿀 책임까지 갖지는 않는다.
데이터베이스에서 필요한 필드만 골라 결과 모양으로 만드는 일을 projection이라고 한다. EF Core에서는 Select, SQL에서는 SELECT의 열 목록으로 나타나며, Microsoft의 조회 구현 예제도 쓰기 쪽 도메인 모델과 별개로 화면에 맞는 ViewModel을 구성한다. 여러 엔티티의 데이터를 조합한 결과가 화면의 읽기 모델이 될 수 있다. 1
핵심 구조
구성 요소 | 하는 일 | 주문 목록의 예 |
|---|---|---|
HTTP API나 화면 어댑터 | 요청 형식을 해석하고 응답을 전달한다 | 쿼리 문자열을 필터로 바꾸고 JSON을 반환한다 |
인증과 권한 처리 | 호출자와 허용 범위를 확인한다 | 관리자가 어느 회사의 주문을 볼 수 있는지 정한다 |
Query Facade | 조회 조건을 적용하고 결과를 구성한다 | 회사 범위, 상태 필터, 정렬, 페이지 크기를 적용한다 |
조회 수단 | 저장소에서 데이터를 읽는다 | EF Core, SQL, Dapper, 외부 조회 API |
DTO나 읽기 모델 | 호출자가 받는 결과의 계약이다 |
|
- 구성 요소
HTTP API나 화면 어댑터
- 하는 일
요청 형식을 해석하고 응답을 전달한다
- 주문 목록의 예
쿼리 문자열을 필터로 바꾸고 JSON을 반환한다
- 구성 요소
인증과 권한 처리
- 하는 일
호출자와 허용 범위를 확인한다
- 주문 목록의 예
관리자가 어느 회사의 주문을 볼 수 있는지 정한다
- 구성 요소
Query Facade
- 하는 일
조회 조건을 적용하고 결과를 구성한다
- 주문 목록의 예
회사 범위, 상태 필터, 정렬, 페이지 크기를 적용한다
- 구성 요소
조회 수단
- 하는 일
저장소에서 데이터를 읽는다
- 주문 목록의 예
EF Core, SQL, Dapper, 외부 조회 API
- 구성 요소
DTO나 읽기 모델
- 하는 일
호출자가 받는 결과의 계약이다
- 주문 목록의 예
OrderListRow,OrderPage
인증과 권한 처리는 프로젝트에 따라 미들웨어나 application service에서 먼저 수행할 수도 있다. 어느 위치를 택하든 Query Facade가 허용 범위를 잃어버리지 않고 실제 조회에 적용해야 하고, 요청 URL의 tenantId를 그대로 믿고 회사 범위를 정하면 안 된다. 여기서 tenant는 같은 서비스를 쓰지만 데이터는 나누어 관리하는 회사나 조직이고, 이 예제의 TenantId는 그 회사 번호다.
개인적인 메모:미들웨어란 요청이 실제 처리 로직에 도달하기 전에 공통으로 거치는 중간 단계다 보통은 검사를 많이 넣는다. 검문소 정도라고 생각하면 편하다
Facade라는 이름 때문에 거대한 클래스를 만들 필요는 없다. 화면의 조회가 하나라면 함수 하나로 시작해도 되고, 주문 목록과 주문 상세가 함께 바뀐다면 OrderQueries로 묶을 수 있다. 반대로 회원 통계, 결제 정산, 재고 리포트까지 ApplicationQueryFacade 하나에 밀어 넣으면 각각의 조회가 다시 서로를 끌고 다닌다.
Facade와 CQS 그리고 CQRS
GoF의 『Design Patterns』에서 다루는 Facade는 서브시스템을 사용하기 위한 인터페이스를 단순하게 만드는 구조 패턴이고, 조회라는 용도에만 한정되지 않는다. Query Facade는 그 발상을 조회 진입점에 적용한 이름으로 이해하면 된다. OrderQueries, QueryService, ReadService 같은 이름을 쓰는 코드도 있으므로 클래스 이름보다 실제 책임을 보는 편이 낫다. 2
개인적인 메모: "클래스 이름보다 실제 책임을 보라" 또는 "실제 코드가 동작하는 것을 보라"는 말은 맞는데, 그 실제 책임의 경계는 결국 팀장이 정한다. 그래서 이름이 OrderQueries든 QueryService든, 팀장이 "이건 조회"라고 하면 조회다.
하지만 "조회 진입점"이 무엇인가, 애초에 무엇이 조회이고 무엇이 조회가 아닌지 나누는 기준이 필요하다.
그렇게 등장한 것이 CQS는 Command Query Separation이다. 상태를 바꾸는 메서드와 결과를 돌려주는 조회를 구분하자는 원칙으로, Fowler는 이 용어를 Meyer의 『Object-Oriented Software Construction』에서 온 것으로 설명한다.3
CQRS는 Command Query Responsibility Segregation이며, 읽기와 갱신에 서로 다른 모델을 사용할 수 있다는 설계다. Fowler의 2011년 설명에도 두 모델이 같은 DB를 쓸 수 있다고 나오므로, CQRS를 이야기한다고 DB 두 개와 이벤트 버스가 따라와야 하는 것은 아니다.4
개인적인 메모: 물론 요즘 유행은 나누는 쪽이지만, 반드시 유행을 따를 필요는 없다. 사실 나는 "꼭 두 개로 나누고 이벤트 버스까지 붙여야 한다"는 주장을 SaaS·BaaS 벤더들이 만든 것이라고 생각한다. 결국 프로그래머에게 팔아야 하니까. 프로그래머로서 성장한다는 건 실리콘밸리에 유행하는 기술을 전부 써보고, "아, 이건 나한테 돈을 청구하려고 권하는 거구나"라고 깨닫는 과정이기도 하다. 물론 그러면서도 나는 다음 유행에 또 써본다.
Query Facade는 그중 조회 진입점과 결과 구성을 맡는다. 쓰기 모델과 읽기 모델을 분리한 CQRS의 조회 쪽에 둘 수도 있고, 평범한 CRUD 애플리케이션에서 복잡한 목록 하나만 따로 빼는 데 쓸 수도 있다. 클래스 하나를 분리했다는 사실만으로 시스템 전체를 CQRS라고 부를 이유는 없다.
같은 애플리케이션과 같은 DB에서 시작
CancelOrderHandler -> 업무 객체와 갱신 -> 같은 DB
OrderQueries -> 목록 DTO 조회 -> 같은 DB
나중에 조회 부하나 결과 구조가 요구할 때
CancelOrderHandler -> 원본 DB
변경 전파 -> 검색 인덱스나 별도 읽기 저장소
OrderQueries -> 선택한 읽기 저장소아래쪽 구조로 넘어가면 변경 전파와 읽기 지연을 관리해야 한다. 위쪽처럼 같은 원본 DB에서 바로 읽는 구조에는 별도 읽기 저장소의 동기화 문제가 없으며, 이 둘을 처음부터 같은 위험으로 설명하면 도입 판단이 흐려진다.
언제 쓰는가
Query Facade가 필요해지는 건 화면과 업무 객체 사이에 틈이 생길 때다. 주문 목록에 고객 이름을 붙이거나, 대시보드에서 주문 수와 매출을 같이 보여주거나, 검색 결과에 여러 출처를 섞어야 할 때 사용한다.
상황 | 분리할 이유 |
|---|---|
목록이나 리포트가 여러 테이블을 조합한다 | join과 DTO 구성을 호출자마다 반복하지 않는다 |
업무 객체를 전부 읽고 일부 필드만 버린다 | 필요한 열과 행을 저장소에서 먼저 고른다 |
같은 조회가 HTTP API와 내보내기 기능에 쓰인다 | 필터와 허용 범위를 공통으로 적용할 수 있다 |
화면 요구가 주문 처리 규칙과 다른 속도로 바뀐다 | 조회 결과의 변경을 업무 행위와 분리한다 |
SQL 계획을 별도로 관리해야 할 정도로 조회가 복잡하다 | 그 조회의 성능과 테스트를 담당할 위치가 생긴다 |
- 상황
목록이나 리포트가 여러 테이블을 조합한다
- 분리할 이유
join과 DTO 구성을 호출자마다 반복하지 않는다
- 상황
업무 객체를 전부 읽고 일부 필드만 버린다
- 분리할 이유
필요한 열과 행을 저장소에서 먼저 고른다
- 상황
같은 조회가 HTTP API와 내보내기 기능에 쓰인다
- 분리할 이유
필터와 허용 범위를 공통으로 적용할 수 있다
- 상황
화면 요구가 주문 처리 규칙과 다른 속도로 바뀐다
- 분리할 이유
조회 결과의 변경을 업무 행위와 분리한다
- 상황
SQL 계획을 별도로 관리해야 할 정도로 조회가 복잡하다
- 분리할 이유
그 조회의 성능과 테스트를 담당할 위치가 생긴다
단순히 FindById 한 번으로 필요한 데이터가 끝나고 중복도 없다면 기존 코드에 두어도 된다. 외부 호출자가 안정된 조회 계약을 원하거나, 조회에 자기만의 규칙이 붙기 시작하면 분리할 이유가 커진다.
개인적인 메모: 현재 이 문서는 단일 RDBMS와 EF Core 환경을 기준으로 적었다. 그럴 수밖에 없는 것이, 내가 실제로 만드는 시스템의 대부분이 이 범위 안에 있기 때문이다.
규모가 커지거나 데이터 소스가 분리된 환경에서는 이야기가 달라진다. 검색은 Elasticsearch에 있고, 결제 정보는 PG사 API에 있으며, 다른 업무 데이터는 별도 서비스에 있을 수 있다. 그러면 단순한 Query Facade를 넘어 BFF, API Gateway, service composition 같은 상위 레이어의 조합 문제가 된다.
캐시 무효화나 복잡한 읽기 부하를 위해 Read Replica를 두는 경우도 있지만, 이 부분도 여기서는 다루지 않는다. 이유는 단순하다. 내가 실제로 운영해본 적이 없기 때문이다.
물론 실제로 운영해본 적 없다고 해서 예제 실행을 못해보는 것은 아니지만, 안해본 것을 잘난척하기에는 밑천이 부족하다.
C#으로 주문 목록 구현하기
예제는 한 회사의 주문 전체를 볼 수 있는 관리자를 대상으로 한다. OrderReadScope는 인증과 권한 확인을 마친 서버 코드가 만들고, OrderListFilter는 사용자가 고를 수 있는 목록 조건이다. 회사 번호를 필터에 넣지 않은 것은 사용자가 조회 범위를 선택하게 하지 않기 위해서다.
저장소 접근에는 EF Core를 쓰지만 Query Facade에 특정 ORM이 필수인 것은 아니다. 이건 내가 예전에 만들어 본 코드이기때문에 단순 C#을 쓴 것뿐이다. 나는 C# 프로그래머니까. ORM은 객체와 DB 테이블 사이의 접근을 돕는 도구이며, 아래 코드는 C# 12 이상의 문법으로 조회 모델과 Facade를 함께 정의했다. 작은 저장 모델에는 주문 취소 같은 업무 메서드를 넣지 않았다.
실행 환경은 언어 버전에 따라 봐야한다.
예제의 Enum.IsDefined(status)가 사용하는 제네릭 오버로드는 .NET 7 소스에도 존재하지만, C# 12로 작성했다는 사실만으로 런타임과 EF Core의 호환성이 정해지지는 않음을 이해해야 한다. 이 문서의 실행 검증에는 .NET 10과 EF Core SQLite 10.0.12를 사용했으며, EF Core 10은 .NET 10을 요구한다. 다른 버전을 선택한다면 런타임, EF Core와 DB provider가 함께 지원하는 조합인지 확인한다.
아래 블록에는 주요 타입과 조회 구현이 들어 있지만, 실행 진입점, DB 연결 설정과 인증·권한 처리까지 포함한 독립 실행 프로젝트는 아니라고 생각해야한다. 실제 애플리케이션에서는 그 설정과 요청 어댑터를 연결해야 한다.
using Microsoft.EntityFrameworkCore;
public enum OrderStatus
{
Pending,
Paid,
Cancelled
}
// 로그인한 주체를 확인한 서버 코드가 만든다.
// 사용자가 보낸 JSON을 이 타입으로 바로 역직렬화하지 않는다.
// 이 record 자체가 권한을 검증하거나 생성자를 보호하는 것은 아니다.
public sealed record OrderReadScope(long TenantId, bool CanReadOrders);
// 목록은 생성 시각 내림차순, 같은 시각이면 주문 번호 내림차순이다.
// 시각은 UTC Unix epoch 이후의 밀리초를 저장하고 그대로 커서에 넣는다.
public sealed record OrderCursor(long CreatedAtUnixMs, long Id);
public sealed record OrderListFilter(
OrderStatus? Status = null,
int PageSize = 20,
OrderCursor? After = null);
public sealed record OrderListRow(
long Id,
string CustomerName,
long TotalWon,
OrderStatus Status,
long CreatedAtUnixMs);
public sealed record OrderPage(
IReadOnlyList<OrderListRow> Items,
OrderCursor? NextCursor);
public sealed class OrderQueries(ReadDbContext db)
{
public async Task<OrderPage> ListAsync(
OrderReadScope scope,
OrderListFilter filter,
CancellationToken ct)
{
// 권한 없음과 목록이 비어 있음을 구별한다.
if (!scope.CanReadOrders || scope.TenantId <= 0)
throw new UnauthorizedAccessException("Order read denied.");
// 상한을 넘긴 요청을 조용히 잘라내지 않고 잘못된 입력으로 알린다.
if (filter.PageSize is < 1 or > 100)
throw new ArgumentOutOfRangeException(nameof(filter), filter.PageSize, "Page size must be between 1 and 100.");
if (filter.Status is { } status && !Enum.IsDefined(status))
throw new ArgumentException("Unknown order status.", nameof(filter));
if (filter.After is { } cursor &&
(cursor.CreatedAtUnixMs < 0 || cursor.Id <= 0))
throw new ArgumentException("Invalid order cursor.", nameof(filter));
ct.ThrowIfCancellationRequested();
// 허용 범위와 삭제 정책을 DB 쿼리에 넣는다.
// 먼저 전체 데이터를 가져온 뒤 메모리에서 tenant를 거르지 않는다.
var orders = db.Orders.AsNoTracking()
.Where(o => o.TenantId == scope.TenantId && !o.IsDeleted);
if (filter.Status is { } selectedStatus)
orders = orders.Where(o => o.Status == selectedStatus);
if (filter.After is { } after)
{
// DESC 정렬에서 다음 페이지는 더 오래된 시각이나 더 작은 ID다.
// 같은 밀리초에 생성된 주문도 ID로 순서를 확정한다.
orders = orders.Where(o =>
o.CreatedAtUnixMs < after.CreatedAtUnixMs ||
(o.CreatedAtUnixMs == after.CreatedAtUnixMs && o.Id < after.Id));
}
// 고객 번호가 회사별로 중복되어도 다른 회사의 고객과 연결되지 않는다.
// 이 예에서는 삭제된 고객의 주문도 관리자 목록에서 제외한다.
var visibleRows =
from order in orders
join customer in db.Customers.AsNoTracking()
on new { order.TenantId, Id = order.CustomerId }
equals new { customer.TenantId, customer.Id }
where !customer.IsDeleted
select new { Order = order, CustomerName = customer.Name };
var rows = await visibleRows
.OrderByDescending(row => row.Order.CreatedAtUnixMs)
.ThenByDescending(row => row.Order.Id)
.Take(filter.PageSize + 1)
.Select(row => new OrderListRow(
row.Order.Id,
row.CustomerName,
row.Order.TotalWon,
row.Order.Status,
row.Order.CreatedAtUnixMs))
.ToListAsync(ct);
// 한 행을 더 읽어 다음 페이지의 존재만 확인한다.
// 별도 COUNT 쿼리를 실행하지 않는다.
var hasMore = rows.Count > filter.PageSize;
if (hasMore)
rows.RemoveAt(rows.Count - 1);
OrderCursor? next = hasMore
? new OrderCursor(rows[^1].CreatedAtUnixMs, rows[^1].Id)
: null;
return new OrderPage(rows.AsReadOnly(), next);
}
}
public sealed class Order
{
public long TenantId { get; set; }
public long Id { get; set; }
public long CustomerId { get; set; }
public long TotalWon { get; set; }
public OrderStatus Status { get; set; }
public long CreatedAtUnixMs { get; set; }
public bool IsDeleted { get; set; }
}
public sealed class Customer
{
public long TenantId { get; set; }
public long Id { get; set; }
public string Name { get; set; } = "";
public bool IsDeleted { get; set; }
}
public sealed class ReadDbContext(DbContextOptions<ReadDbContext> options)
: DbContext(options)
{
public DbSet<Order> Orders => Set<Order>();
public DbSet<Customer> Customers => Set<Customer>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Customer>()
.HasKey(c => new { c.TenantId, c.Id });
modelBuilder.Entity<Order>().HasKey(o => new { o.TenantId, o.Id });
modelBuilder.Entity<Order>()
.HasOne<Customer>()
.WithMany()
.HasForeignKey(o => new { o.TenantId, Id = o.CustomerId });
modelBuilder.Entity<Order>()
.HasIndex(o => new { o.TenantId, o.CreatedAtUnixMs, o.Id });
modelBuilder.Entity<Order>().Property(o => o.Status)
.HasConversion<string>();
}
}정렬과 개수 제한은 원래 주문 열에 적용하고, 마지막 Select에서 DTO를 만든다. DTO를 먼저 만든 뒤 그 객체의 속성에 다시 조건을 붙이면 provider가 해당 표현을 SQL로 번역할 수 있는지에 영향을 받으니, 목록의 정렬 기준은 DB 열로 드러내고 실제로 생성되는 SQL도 확인한다.
After는 목록에서 마지막으로 본 행 다음이라는 뜻이다. 시각이 더 뒤라는 뜻으로 읽으면 조건 방향을 반대로 쓰기 쉬운데, 이 목록은 최신 주문부터 내려오므로 다음 페이지에서는 더 작은 시각을 찾는다.
예제의 TotalWon은 주문 생성 때 확정해서 저장한 원 단위 금액이다. 오늘의 상품 가격에 주문 수량을 곱해 다시 계산한 값이 아니며, 환불액을 뺀 순매출이나 실제 결제 완료액도 아니다. 화면에 필요한 금액의 의미가 바뀌면 필드 이름과 계산 근거부터 바꿔야 한다.
삭제된 고객의 주문을 제외하는 것은 이 예제의 정책이며 정산이나 감사용 조회라면 삭제된 고객의 주문을 남기고 이름을 익명화하거나, 주문 당시 저장한 고객 표시명을 사용할 수도 있다. 같은 INNER JOIN을 그대로 복사하면 과거 주문이 화면에서 사라지므로 화면별 보존 정책을 확인해야 한다.
참고: OrderListRow의 long은 C# 내부 계약이다. JavaScript가 소비하는 JSON API에서 주문 번호가 안전한 정수 범위를 넘을 수 있다면 문자열 ID로 직렬화하는 등 별도 wire 계약을 두고, 커서도 그 정밀도를 잃지 않게 인코딩한다.
조회 범위를 만드는 코드도 권한 경계
OrderReadScope는 이미 확인한 권한을 조회에 전달하는 값이지, 그 권한을 증명하는 장치가 아니다. 생성자가 공개되어 있으므로 같은 프로세스의 다른 코드도 new OrderReadScope(999, true)를 만들 수 있고, ListAsync는 그 회사가 실제로 호출자의 소속인지 다시 알아내지 않는다. 그러니 요청의 JSON이나 쿼리 문자열에서 TenantId와 CanReadOrders를 받아 이 객체를 만들면 안 되는 것을 알 수 있다.
HTTP 요청을 받는 어댑터는 인증된 주체를 서버의 소속·권한 정책에 연결해서 조회 범위를 얻고, 다른 진입점도 같은 정책을 거치도록 구성한다.
참고: ASP.NET Core에서는 정책과 IAuthorizationService를 통해 이 판단을 모을 수 있다. 다만 로그인 여부만 확인하는 정책과 특정 회사의 주문을 읽을 권한은 같지 않으므로, 해당 회사에 대한 허용 범위까지 확인해야 한다. 생성 경로를 internal이나 제한된 팩터리로 좁히면 실수는 줄일 수 있지만, 같은 프로세스에서 임의의 코드를 실행할 수 있는 상대를 타입 하나로 차단했다고 볼 수는 없다.5
AsNoTracking의 역할
EF Core는 엔티티를 추적해서 변경 여부를 확인할 수 있다. 읽기 전용 조회에는 그 추적이 필요 없을 때가 있어 AsNoTracking을 쓰지만, 이것만 붙였다고 DB 쓰기가 금지되는 것은 아니다. 같은 DbContext에서 다른 코드가 SaveChanges를 호출하거나 SQL로 갱신하면 여전히 쓸 수 있다. EF Core 추적과 비추적 조회
즉, 조회한 엔티티를 수정해 저장할 필요가 없는 읽기 전용 조회라는 뜻이다.
이 예제처럼 최종 결과에 엔티티를 포함하지 않고 값만 DTO로 투영하면 EF Core가 결과의 엔티티를 추적할 대상 자체가 없다. 그래서 여기서 AsNoTracking은 "읽기 전용이다"는 의도를 드러내는 표시에 가깝다. 좋은 코드의 기본은 결국 읽기 쉬운거니까.
쓰기 경계를 강하게 나눠야 한다면 조회용 DB 계정에 필요한 SELECT 권한만 주는 방법도 있다. 클래스 이름이나 ORM 옵션으로 의도를 표현하는 것과, DB가 실제로 거부하는 범위는 따로 확인한다.
SQL로 읽기
SQL이 복잡해지면 Facade 안에서 직접 작성하거나 조회 어댑터로 분리할 수 있다. 호출자는 여전히 ListAsync와 OrderPage만 알고, 내부에서는 DTO를 만드는 데 필요한 조회를 수행한다.
아래는 앞의 목록과 같은 조건을 표현한 PostgreSQL 쿼리다. 이 SQL은 소문자 테이블과 열을 사용하는 스키마를 가정한다.
커서의 두 값을 검증
C#의 OrderCursor는 시각과 ID를 함께 담지만, HTTP 입력이나 SQL 파라미터로 풀어놓으면 한쪽만 들어오는 요청도 생길 수 있다. 두 값이 없으면 첫 페이지, 두 값이 있으면 다음 페이지로 해석하고, 한쪽만 있으면 DB 조회 전에 입력 오류로 거부한다. 외부 입력을 각각의 nullable 값으로 받는 어댑터라면 다음처럼 커서를 만들 수 있다.
public static class OrderCursorInput
{
public static OrderCursor? Parse(long? createdAtUnixMs, long? id)
{
if (createdAtUnixMs is null && id is null)
return null;
// 둘 중 하나만 빠졌다면 첫 페이지로 되돌리거나 빈 목록을 보내지 않는다.
if (createdAtUnixMs is not { } time || id is not { } orderId)
throw new ArgumentException("Both cursor fields are required.");
if (time < 0 || orderId <= 0)
throw new ArgumentException("Invalid order cursor.");
return new OrderCursor(time, orderId);
}
}숫자로 해석할 수 없는 문자열은 이 함수에 도달하기 전 요청 파싱 단계에서 거부하고, 여기서 발생한 ArgumentException도 API의 입력 오류 응답으로 연결한다. 실패를 잡아서 커서를 null로 바꾸면 잘못된 요청이 첫 페이지 조회로 둔갑한다.
-- $1: 서버에서 확인한 tenant_id
-- $2: 상태 문자열 또는 NULL
-- $3: 커서의 created_at_unix_ms 또는 NULL
-- $4: 커서의 id 또는 NULL
-- $5: 검증된 page_size + 1
-- 실행 경계에서 $3과 $4를 함께 검증한다. 부분 NULL은 입력 오류다.
-- 아래 방어 조건만으로는 잘못된 입력에 대한 오류가 발생하지 않는다.
SELECT
o.id,
c.name AS customer_name,
o.total_won,
o.status,
o.created_at_unix_ms
FROM orders AS o
JOIN customers AS c
ON c.tenant_id = o.tenant_id
AND c.id = o.customer_id
WHERE o.tenant_id = $1
AND o.is_deleted = FALSE
AND c.is_deleted = FALSE
AND ($2::text IS NULL OR o.status = $2::text)
AND (
($3::bigint IS NULL AND $4::bigint IS NULL)
OR (
$3::bigint IS NOT NULL
AND $4::bigint IS NOT NULL
AND (o.created_at_unix_ms, o.id)
< ($3::bigint, $4::bigint)
)
)
ORDER BY o.created_at_unix_ms DESC, o.id DESC
LIMIT $5;(시각, ID) < (커서 시각, 커서 ID)는 시각부터 비교하고, 시각이 같으면 ID를 비교한다. 두 열이 NOT NULL인 이 예제에서 내림차순의 다음 페이지를 찾는 조건이며, 서로 다른 방향으로 정렬하는 조회에 그대로 적용해서는 안 된다.6
왜 두 값의 유무까지 확인할까. 이전처럼 $3 IS NULL만으로 커서 없는 경우를 판단하면 (NULL, 34)는 ID를 무시하고 첫 페이지를 읽으며, (1000, NULL)은 1000ms보다 오래된 행만 읽어 같은 시각의 주문 33을 빠뜨릴 수 있기때문이다. 두 값이 함께 들어온다는 전제에서는 기존 비교도 맞지만, 그 전제를 검사하지 않은 채 외부 입력을 넘기면 결과가 틀어진다.(사실 이정도까지는 안해도 되긴한다)
위 SQL은 부분 NULL을 받았을 때 행을 반환하지 않도록 방어하지만, 그것만으로 입력 오류와 정상적인 빈 목록을 구별하지는 못한다. 따라서 앞의 입력 검증을 실행 경계에 연결하고, SQL 조건을 검증의 대체물로 사용하지 않는다.
값은 provider의 파라미터로 전달하고 SQL 문자열에 이어 붙이지 않는다. 상태와 커서가 없는 경우에는 해당 조건을 고정된 SQL 조각으로 생략하는 구현도 가능하지만, 사용자 문자열을 SQL로 삽입하는 것과는 다르다.
인덱스는 조회하는 조건과 순서에 맞춰 검토한다. 다음은 이 목록을 위한 후보이며, customers에는 (tenant_id, id)의 유일성도 필요하다.
-- 삭제되지 않은 주문의 회사별 최신 목록을 위한 후보 인덱스다.
CREATE INDEX ix_orders_tenant_created_id_visible
ON orders (tenant_id, created_at_unix_ms DESC, id DESC)
WHERE is_deleted = FALSE;앞의 EF Core HasIndex는 삭제된 주문까지 포함하는 일반 복합 인덱스이고, 이 SQL은 삭제되지 않은 주문만 담는 PostgreSQL의 부분 인덱스다. 같은 인덱스를 두 문법으로 적은 것이 아니라, 기본 설정과 DB별 최적화 후보를 나누어 보여준 것이다. EF Core에서도 지원되는 provider에 HasFilter와 IsDescending을 지정할 수 있지만, 필터의 SQL과 열 이름은 실제 매핑에 맞춰야 한다. EF Core 인덱스 설정
또한 PostgreSQL의 B-tree는 역방향으로도 읽을 수 있다. 이 조회처럼 tenant_id를 하나로 고정하고 나머지 두 열을 모두 내림차순으로 읽는 경우에는 일반 오름차순 인덱스도 정렬을 지원할 수 있으므로, DESC가 빠졌다는 이유만으로 성능 결함이라고 판단하지 않는다. 부분 인덱스의 포함 범위와 정렬 방향은 서로 다른 특성이며, 실제로 어느 인덱스를 쓰는지는 실행 계획으로 확인한다. PostgreSQL 인덱스와 정렬
인덱스를 만들었다고 모든 호출이 빨라지는 것은 아니다. 특정 상태 필터의 선택도, 회사별 주문 수, join할 고객 수에 따라 계획이 달라질 수 있고, OR ... IS NULL로 선택 조건을 합친 쿼리도 실제 계획을 봐야 한다. 실행 계획에서 읽은 행 수, 정렬, 버퍼 사용을 확인하고 필요한 경우 상태 필터가 있는 조회와 없는 조회의 SQL을 구분한다. PostgreSQL EXPLAIN
-- 실제로 실행하므로 운영에서는 부하를 고려해서 수행한다.
-- 아래 값은 실행 계획 확인을 위한 예시 값이다.
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, total_won, created_at_unix_ms
FROM orders
WHERE tenant_id = 7 AND is_deleted = FALSE
ORDER BY created_at_unix_ms DESC, id DESC
LIMIT 21;이 EXPLAIN은 주문 테이블의 필터와 정렬만 확인하는 축소 예제다. Facade 전체의 성능을 확인할 때는 고객 join과 실제 상태·커서 조건까지 포함한 쿼리를 함께 측정한다.
목록을 먼저 불러오면 늦어지는 이유
Query Facade를 만들고도 내부에서 전체 객체를 읽으면 읽기 비용은 그대로다. DB에서 모든 주문을 가져와 ToListAsync를 호출한 다음, 메모리에서 페이지를 자르거나 DTO로 변환하면 그 앞에서 이미 전송과 할당이 끝나 있다.
// 피해야 할 형태의 예시다.
// 조건에 맞는 모든 행의 엔티티 속성을 먼저 가져온다.
var allOrders = await db.Orders
.Where(o => o.TenantId == scope.TenantId)
.ToListAsync(ct);
// 이미 읽은 뒤에 잘라내므로 DB가 읽고 보낸 데이터는 줄지 않는다.
var page = allOrders
.OrderByDescending(o => o.CreatedAtUnixMs)
.ThenByDescending(o => o.Id)
.Take(20)
.ToList();필요한 열을 고르는 Select, 행을 제한하는 Where, 순서를 정하는 OrderBy, 상한을 두는 Take를 저장소가 실행할 쿼리에 넣는다. EF Core의 효율적인 조회 안내도 projection과 결과 개수 제한을 함께 다룰 수 있다. EF Core 효율적인 조회
이때 DTO를 만든다는 이유로 필요한 업무 의미까지 빼면 안 된다. 취소된 주문을 매출 합계에 넣을지, 개인정보를 어느 필드까지 표시할지, 시간대를 어디서 변환할지는 조회의 계약으로 남는다.
N+1 조회와 join으로 늘어나는 행
주문 목록 20개를 가져온 뒤 각 주문의 고객을 따로 읽으면, 목록 조회 한 번에 고객 조회 20번이 붙는다. 이런 형태를 N+1이라고 부른다.
아마 AI 시대에 가장 많이 보는 문제이고, 또 프로그래머로써 성장통이다. 매 프로그램을 만들때마다 매번 N+1 조회를 만들고 수정하는 게 일상이다.
N+1의 뜻은 첫 조회의 결과 개수에 비례해서 후속 조회 횟수가 증가한다는 뜻이다.
// 설명용 안티패턴이다. 순차 호출 때문에 왕복도 반복된다.
foreach (var order in orders)
{
var customer = await customerRepository.FindAsync(order.CustomerId, ct);
// 고객 이름을 각 주문에 붙인다.
}앞의 주문 목록처럼 고객이 주문마다 하나라면 join으로 필요한 이름을 같이 가져올 수 있다. 여러 외부 조회 API를 조합한다면 일괄 조회를 지원하는지도 확인한다. Facade 메서드가 하나라고 DB나 네트워크 호출까지 한 번이 되는 것은 아니다.
그렇다고 테이블을 전부 join하면 또 다른 문제가 생긴다. 한 주문에 주문 항목 두 개와 결제 기록 세 개가 있으면, 두 관계를 동시에 펼친 결과는 여섯 행이 된다. 그 상태에서 항목 금액을 더하면 같은 항목이 결제 기록 수만큼 반복된다. PostgreSQL의 join 설명에서도 각 입력 행이 조건에 맞는 상대 행과 결합되어 결과를 만든다는 점을 확인할 수 있다. PostgreSQL 테이블 표현과 join
주문 A
항목: 키보드, 마우스
결제 기록: 승인, 부분 환불, 추가 환불
항목과 결제 기록을 그대로 함께 join
키보드 x 승인
키보드 x 부분 환불
키보드 x 추가 환불
마우스 x 승인
마우스 x 부분 환불
마우스 x 추가 환불
결과는 6행이며 항목 금액도 각각 3번 나타난다.주문별 합계를 먼저 만든 뒤 join하거나, 목록 페이지를 정한 다음 그 주문 ID들에 대해서만 항목과 결제를 각각 일괄 조회하는 식으로 풀 수 있다. SUM(DISTINCT amount)를 넣어 얼버무리면 서로 다른 항목의 금액이 같은 경우까지 합쳐버린다.
-- 항목 집계에서 주문당 한 행을 만든 후 주문에 연결한다.
-- 같은 금액의 항목도 서로 다른 항목이면 각각 합산된다.
WITH item_totals AS (
SELECT tenant_id, order_id, SUM(amount_won) AS item_total_won
FROM order_items
WHERE tenant_id = $1
GROUP BY tenant_id, order_id
)
SELECT
o.id,
COALESCE(it.item_total_won, 0) AS item_total_won
FROM orders AS o
LEFT JOIN item_totals AS it
ON it.tenant_id = o.tenant_id AND it.order_id = o.id
WHERE o.tenant_id = $1 AND o.is_deleted = FALSE;이 쿼리는 항목이 없는 주문도 0으로 보여주는 집계 구조 예시이며, 페이지 제한과 고객 삭제 조건은 넣지 않았다. 실서비스 목록에 붙일 때는 페이지에 포함된 주문으로 집계 대상을 좁힐 수 있고, 표시할 주문 금액이 이 항목 합계와 같은 의미인지도 먼저 확인한다.
행이 여러 개로 펼쳐진 상태에서 LIMIT 20을 적용하면 주문 20개가 아니라 펼쳐진 행 20개를 얻는다. 목록의 한 항목이 주문이라면 페이지 경계를 주문 단위로 정한 뒤 하위 데이터를 붙여야 한다.
페이지네이션과 커서
정렬에는 동률을 가르는 값이 필요하다
생성 시각만으로 정렬하면 같은 밀리초에 만들어진 주문의 상대 순서는 정해지지 않는다. 그 상태로 페이지를 나누면 주문이 반복되거나 빠지는 일을 설명하기 어려워지므로, 앞의 예제에서는 주문 ID까지 붙여 허용된 결과 집합 안에서 순서를 유일하게 만든다. EF Core의 pagination 문서도 완전히 유일한 정렬을 요구한다.7
정렬: created_at_unix_ms DESC, id DESC
1000ms, 주문 35
1000ms, 주문 34 <- 첫 페이지 마지막 주문
1000ms, 주문 33 <- 다음 페이지에 남아 있어야 한다
900ms, 주문 40
다음 페이지 조건
시각 < 1000ms
OR (시각 = 1000ms AND id < 34)커서에 시각만 저장하면 주문 33을 건너뛰기 쉽다. 원본 정렬값과 동률을 가르는 ID를 함께 넣고, 정밀도와 정렬 방향을 그대로 보존한다.
Offset과 keyset은 화면 동작에 맞춰 고른다
offset 방식은 앞의 결과 몇 개를 건너뛰고 그 뒤를 가져오는 것으로, OFFSET 200000 LIMIT 20이면 앞의 20만 행을 건너뛴 다음 20행을 읽는다. 임의의 페이지 번호로 이동하기 편하지만 깊은 페이지에서는 앞의 행들을 건너뛰는 작업이 늘어날 수 있고, 최신 목록에서 다음 페이지로 내려가는 화면이라면 마지막 정렬값 다음을 찾는 keyset 방식이 맞는 경우가 많다. 앞의 C# 예제가 바로 그 방식이며, 커서는 다음 조회를 시작할 위치를 담는 값이다. keyset도 그 조건에 맞는 인덱스가 있어야 효율적으로 읽을 수 있다.
앞의 예제는 PageSize + 1개를 읽어 다음 페이지의 존재를 판단한다. 전체 주문 개수를 항상 계산하지 않아도 되는 화면에 적합하고, 관리자가 정확한 총 페이지 수를 필요로 한다면 count 조회가 별도로 필요할 수 있다.
안정된 순서와 고정된 스냅샷은 다르다
커서를 쓴다고 여러 페이지가 같은 시점의 데이터가 되는 것은 아니다. 페이지 사이에 주문이 삭제되면 다음 결과는 짧아질 수 있고, 상태 필터가 있는데 주문 상태가 바뀌면 그 주문이 집합에 들어오거나 빠질 수 있다. 정렬에 쓰는 생성 시각까지 바뀐다면 이미 읽은 주문이 다시 나타나거나 아직 읽지 않은 주문이 건너뛰어질 수 있다.
일반적인 최신 목록은 이런 변화를 받아들이고, 감사 보고서나 일괄 내보내기는 기준 시점이나 버전을 고정한 읽기 저장소, 적절한 스냅샷 조회를 따로 설계한다. 첫 페이지의 마지막 시각을 붙잡는 것만으로 모든 수정과 삭제에 대한 스냅샷이 생기지는 않는다.
커서는 권한을 대신하지 않는다
다른 회사에서 얻은 커서를 넣더라도 현재 요청의 tenant 조건을 다시 적용해야 한다. 커서가 결과를 잘라내는 기준일 수는 있지만, 그 커서를 소유한 사람이 해당 주문에 접근할 수 있다는 증거는 아니다.
API에서 커서를 불투명한 토큰으로 만들면 Base64로 인코딩하는 것만으로 위변조를 막을 수는 없다. 위변조 방지가 필요한 계약이라면 서명이나 서버 저장 토큰을 사용하고, 정렬·필터·회사 범위가 바뀐 커서를 거부하는 규칙도 둘 수 있다. 서버는 어느 방식에서도 현재 요청의 권한을 다시 확인한다.
대시보드는 여러 값을 모은다
대시보드는 목록보다 집계가 많다. 오늘의 주문 수, 완료된 결제 금액, 최근 주문을 한 응답으로 묶을 수 있지만, 한 응답 안에 들어 있다는 이유로 숫자들이 같은 시점의 상태를 나타내는 것은 아니다.
GetDashboardAsync
-> 오늘 주문 수 조회
-> 결제 완료액 조회
-> 최근 주문 조회
-> DashboardDto 구성PostgreSQL의 기본 Read Committed에서는 같은 트랜잭션 안의 연속된 두 SELECT도 서로 다른 스냅샷을 볼 수 있어, 주문 수를 센 뒤 새 주문이 commit되면 최근 주문 목록에는 그 새 주문이 포함될 수 있다. 같은 DB의 값들을 동일한 스냅샷에서 읽어야 한다면 한 SQL로 집계하거나 그 요구에 맞는 읽기 트랜잭션을 택한다. PostgreSQL Repeatable Read는 BEGIN 같은 제어문을 제외한 첫 조회·데이터 조작 문장 시점에 스냅샷을 잡고, 이후 조회에도 그 스냅샷을 사용한다. 8
서로 다른 DB나 외부 API를 읽는 대시보드에는 DB 트랜잭션 하나로 공통 시점이 생기지 않는다. 필요하다면 각 값의 집계 기준 시점이나 원본 버전을 응답에 포함하고, 어떤 값끼리 맞아야 하는지를 정한다.
같은 DbContext를 병렬로 사용하지 않는다
집계를 빨리 끝내려고 같은 EF Core DbContext에 세 조회를 걸고 Task.WhenAll을 호출하면 안 된다. EF Core는 한 context에서 여러 병렬 작업을 지원하지 않는다. DbContext의 병렬 작업 제한
서로 독립적인 context로 실행하면 병렬 호출은 가능하지만, DB 연결 수와 순간 부하가 늘고 공통 스냅샷을 요구하던 계약도 다시 봐야 한다. 같은 DB 집계라면 조건부 집계를 한 SQL로 합칠 수 있는지 먼저 확인해볼 만하다.
Repository와 Service Layer의 차이
이름이 겹치는 경우가 많으므로 실제로 어떤 결과를 약속하는지 보면 된다.
개념 | 주된 관점 | 주문 예제에서 맡는 일 |
|---|---|---|
Facade | 내부 사용법을 단순한 인터페이스로 감싼다 | 여러 조회 수단을 호출자가 직접 조합하지 않게 한다 |
Query Facade | 읽기 유스케이스의 조건과 결과를 제공한다 | 주문 목록 DTO와 페이지 계약을 반환한다 |
Repository | 도메인 객체를 컬렉션처럼 다룬다 | 주문 객체를 찾아 업무 행위를 수행할 수 있게 한다 |
Service Layer | 애플리케이션의 작업 경계를 제공한다 | 취소 요청의 권한, 업무 호출, 저장을 조율한다 |
CQRS | 갱신과 조회의 모델을 구분한다 | 취소에 필요한 모델과 목록에 필요한 모델을 다르게 둔다 |
- 개념
Facade
- 주된 관점
내부 사용법을 단순한 인터페이스로 감싼다
- 주문 예제에서 맡는 일
여러 조회 수단을 호출자가 직접 조합하지 않게 한다
- 개념
Query Facade
- 주된 관점
읽기 유스케이스의 조건과 결과를 제공한다
- 주문 예제에서 맡는 일
주문 목록 DTO와 페이지 계약을 반환한다
- 개념
Repository
- 주된 관점
도메인 객체를 컬렉션처럼 다룬다
- 주문 예제에서 맡는 일
주문 객체를 찾아 업무 행위를 수행할 수 있게 한다
- 개념
Service Layer
- 주된 관점
애플리케이션의 작업 경계를 제공한다
- 주문 예제에서 맡는 일
취소 요청의 권한, 업무 호출, 저장을 조율한다
- 개념
CQRS
- 주된 관점
갱신과 조회의 모델을 구분한다
- 주문 예제에서 맡는 일
취소에 필요한 모델과 목록에 필요한 모델을 다르게 둔다
Fowler의 Repository 정의는 도메인 객체에 접근하는 컬렉션 같은 인터페이스에 초점을 두고, Service Layer는 애플리케이션의 작업과 상호작용을 조율하는 경계에 초점을 둔다. 실제 프로젝트에서는 Query Facade가 Service Layer의 조회 부분이 될 수도 있고, read repository라는 이름으로 DTO 조회를 제공할 수도 있다. Repository, Service Layer
구분한다고 계층을 무조건 하나씩 만들 필요는 없다. Controller -> QueryService -> QueryFacade -> ReadRepository -> DbContext를 만들었는데 중간 클래스가 모두 같은 인자를 전달하고 결과를 그대로 돌려준다면, 각각이 따로 담당할 결정이 있는지 확인한다.
IQueryable을 밖으로 반환할 때
Facade가 IQueryable<Order>를 반환하면 호출자가 나중에 조건을 붙이고 실행하게 된다. 그 호출자는 ORM의 번역 능력, DB 연결 수명, 실행 시점과 결과 모양까지 알아야 하므로 여기서 설명한 목록 계약은 느슨해진다.
// 호출자가 이 쿼리를 언제, 어떤 모양으로 실행할지 결정한다.
public IQueryable<Order> GetOrders() => db.Orders;공유된 조회 조합기가 목적이라면 의도적으로 이런 계약을 둘 수도 있지만, 화면용 Facade라면 실행을 끝낸 DTO와 페이지 정보를 반환하는 편이 책임이 분명하다. 스트리밍이 필요한 내보내기는 IAsyncEnumerable<Row>로 계약을 둘 수 있으며, 그때는 열거 중 연결이 살아 있어야 하는 기간과 중도 취소·실패를 별도로 정한다.
읽기 전용에서도 지켜야 할 보안
목록은 ID 하나를 조회하는 API보다 한 번에 많은 정보를 내보낼 수 있다. 읽기 전용이라는 설명은 데이터 유출을 막아주는 조건이 아니다.
서버가 확인한 회사 범위를 모든 조회에 적용하고, join에도 회사 번호를 포함한다. 앞의 예제처럼 고객 번호 5가 회사마다 존재할 수 있는데 customer_id만으로 연결하면 다른 회사의 이름이 섞일 수 있다. 주문 목록에 추가한 count, 합계, CSV 내보내기도 같은 범위를 써야 하며, 목록을 잘 막아놓고 전체 개수만 모든 회사 기준으로 세는 실수도 테스트한다.
개인별 열람 제한이 있는 서비스라면 TenantId만으로 충분하지 않다. 담당자, 팀, 문서 ACL 등 실제 허용 범위를 더 적용해야 한다. 예제의 CanReadOrders는 그 회사 주문 전체를 볼 수 있는 관리자만 대상으로 한 단순화다.
DTO에는 화면에 필요한 개인정보만 넣고, DB 엔티티를 그대로 직렬화하지 않는다. 이메일이나 결제 식별자가 관리자 화면에 필요하더라도 다른 목록과 공용 DTO로 묶어 자동으로 퍼지게 하지 않는다. OWASP의 권한 지침은 기본 거부, 요청별 권한 검사와 최소 권한을 강조한다. OWASP 권한 검사
SQL 입력값은 파라미터로 전달한다. 열 이름과 정렬 방향은 일반 값 파라미터로 바인딩할 수 없는 경우가 있으므로 허용된 값에서 서버가 고른다. OWASP SQL 삽입 방지
// SQL을 직접 만드는 경우의 정렬 선택 예시다.
// 외부 sort 문자열을 그대로 ORDER BY 뒤에 붙이지 않는다.
string orderBy = sort switch
{
"newest" => "o.created_at_unix_ms DESC, o.id DESC",
"oldest" => "o.created_at_unix_ms ASC, o.id ASC",
_ => throw new ArgumentException("Unsupported sort.")
};정렬을 늘렸다면 커서 조건도 그 정렬과 함께 바꿔야 한다. 위 switch만 붙여 oldest를 허용하고 앞의 내림차순 커서 비교를 유지하면 페이지가 틀어진다.
읽기용 DB 계정도 가능한 범위에서 권한을 줄이고, 조회 개수와 쿼리 실행 시간을 제한한다. 실행 시간 제한을 DB 클라이언트에서는 command timeout이라고 부른다. 요청의 CancellationToken을 전달하더라도 provider가 취소를 처리하는 시점이 다를 수 있으므로, 토큰 전달만으로 즉시 작업 종료가 보장된다고 쓰지 않는다.
실패를 빈 화면으로 바꾸지 않는다
필터에 맞는 주문이 하나도 없는 것과 DB를 읽지 못한 것은 다른 결과다. DB 장애를 잡아서 빈 배열을 반환하면 관리자는 오늘 주문이 없다고 판단할 수 있다. 재고 조회가 실패했는데 stock = 0으로 대체하면 사용자는 상품이 모두 팔렸다고 생각할 수 있고, 자동화된 시스템은 품절 처리나 재발주까지 실행할 수 있다.
재고가 0인 것과 재고를 읽지 못한 것은 전혀 다른 상태인 것이다.
전자는 매출이 잘나왔다는 것이고, 후자는 뭔가 상황이 잘못돌아가고 있는 것이다.
개인적인 메모: 나는 아직 실제 장애에서 이 실수를 겪어본 적은 없다. 다만 오류를 정상값으로 덮어쓰지 말라는 원칙은 여러 시스템 설계 문헌에서 반복해서 나온다.
상황 | 호출자가 구별해야 하는 결과 |
|---|---|
허용된 목록 조회가 성공했지만 행이 없다 | 정상적인 빈 목록 |
페이지 크기나 커서가 잘못됐다 | 입력 오류 |
조회 범위가 없거나 권한이 없다 | 접근 거부 |
DB 접속이나 쿼리 실행이 실패했다 | 저장소 오류 |
시간 예산이 끝났다 | 시간 초과 |
사용자가 요청을 취소했다 | 취소 |
일부 외부 조회만 실패했다 | 계약이 허용한 부분 결과 또는 전체 실패 |
- 상황
허용된 목록 조회가 성공했지만 행이 없다
- 호출자가 구별해야 하는 결과
정상적인 빈 목록
- 상황
페이지 크기나 커서가 잘못됐다
- 호출자가 구별해야 하는 결과
입력 오류
- 상황
조회 범위가 없거나 권한이 없다
- 호출자가 구별해야 하는 결과
접근 거부
- 상황
DB 접속이나 쿼리 실행이 실패했다
- 호출자가 구별해야 하는 결과
저장소 오류
- 상황
시간 예산이 끝났다
- 호출자가 구별해야 하는 결과
시간 초과
- 상황
사용자가 요청을 취소했다
- 호출자가 구별해야 하는 결과
취소
- 상황
일부 외부 조회만 실패했다
- 호출자가 구별해야 하는 결과
계약이 허용한 부분 결과 또는 전체 실패
예제는 .NET의 예외와 취소 계약을 사용한다. 프로젝트가 Result나 typed diagnostic을 쓰고 있다면 그 경계에서 기존 오류 계약으로 변환하면 되고, Query Facade를 추가한다는 이유로 별도의 오류 체계를 새로 만들 필요는 없다.
부분 결과를 허용하는 화면이라면 실패한 항목의 상태를 함께 반환한다. 재고 조회가 실패했는데 stock = 0을 넣어 성공으로 보내는 계약은 피한다.
문제가 있을때:
{
"orders": { "state": "ready", "count": 128 },
"stock": {
"state": "unavailable",
"code": "STOCK_TIMEOUT"
}
}정상 일때:
{
"orders": { "state": "ready", "count": 128 },
"stock": {
"state": "ready",
"quantity": 0
}
}여기에는 내부 SQL이나 연결 문자열을 넣지 않는데, 각 항목의 상태를 클라이언트가 표시할 수 있도록 API 계약에 정의하고, 전체가 실패해야 하는 화면이라면 성공 응답으로 감싸지 않는다.
Cache Aside와 함께 쓸 때
Query Facade는 조회의 조건과 응답을 맡고, Cache-Aside는 그 결과를 재사용하는 방식을 맡는다. 같은 주문 목록을 여러 번 읽는 경우 Facade 바깥의 decorator나 조회 내부의 정해진 위치에서 캐시를 적용할 수 있지만, 캐시가 읽기 경계를 다시 정의하게 두지는 않는다.
API
-> 현재 요청의 권한과 조회 범위 확인
-> 캐시된 OrderPage 조회
hit -> 허용 범위에 맞는 결과 반환
miss -> OrderQueries.ListAsync -> 결과 저장 -> 반환목록 캐시 키에는 결과를 바꾸는 회사 범위, 추가 권한 범위, 필터, 정렬과 커서, 페이지 크기, 응답 버전을 반영한다. 범위와 조건을 정규화한 식별값으로 묶을 수도 있으며, 다음은 각 부분을 정규화하고 안전하게 인코딩한다는 전제의 개념 예시다.
order-list:v1:tenant=7:scope=admin-all:status=Paid:
sort=newest:cursor=1000-34:size=20권한이 다른 사용자가 같은 key를 읽어도 되는지는 결과 범위가 동일한지로 결정한다. scope=admin-all만 붙이면 세밀한 권한이 생기는 것은 아니며, 호출 시점의 권한 검사는 cache hit에서도 수행한다.
목록 캐시는 상세 캐시보다 무효화할 대상이 늘어나기 쉽다. 주문 하나의 상태가 바뀌면 Paid 목록, Pending 목록, 전체 목록과 대시보드 합계까지 영향을 받을 수 있다. 어떤 목록이 영향을 받는지 추적하거나 세대 번호를 바꾸거나, 계약이 허용한 짧은 TTL을 두는 식으로 관리하고, 항상 최신값이 필요한 정산 판단에는 이 캐시를 그대로 쓰지 않는다. 자세한 채움과 무효화 흐름은 Cache-Aside에서 다룬다.
읽기 복제본과 별도 읽기 모델
원본 DB의 변경을 복사해서 읽기에 사용하는 DB를 읽기 복제본, replica라고 한다. 읽기 부하 때문에 replica나 검색 인덱스로 옮겨도 호출자의 메서드 모양은 유지할 수 있지만, 막 주문 상태를 바꾼 관리자가 목록을 새로고침했는데 이전 상태가 보일 수 있으므로 읽기의 최신성도 계약에 포함해야 한다.
취소 요청 -> 원본 DB commit
|
| 변경 전파가 아직 끝나지 않음
v
목록 조회 -> replica나 읽기 인덱스 -> 이전 상태쓰기 직후 확인은 원본을 읽도록 정하거나, 필요한 버전까지 읽기 모델이 따라왔는지 확인하거나, 지연을 표시하는 방법이 있다. 이 선택은 화면과 업무 요구에 따라 다르고 Query Facade라는 이름이 자동으로 보장해주지는 않는다.
별도 읽기 모델을 갱신하는 작업은 조회 요청 밖에서 관리한다. 읽기 모델이 없다고 조회 메서드가 주문을 생성하거나 업무 상태를 수정해 복구하는 형태는 경계를 흐린다. 재구성이 필요한 읽기 모델에는 별도의 생성·재생·검증 절차를 둔다.
조회에서 업무 변경을 섞지 않는다
조회에 변경을 섞으면 체크가 어려워진다. 조회는 크롤러도, 재시도도, 새로고침도 부른다. 그중 어디서 업무 변경이 실행됐는지 나중에 알아낼 방법이 없다. 많은 안티패턴은 결국 하나의 경계 안에 서로 다른 일을 집어넣으면서 시작한다. 프로그래머에게 중요한 능력 중 하나는 멘탈 모델 안에서 서로 독립적으로 이유를 설명할 수 있는 최소한의 책임 단위를 구분하는 것이다.
GetOrderDetailAsync 안에서 미결제 주문을 만료 처리하거나, 재고를 예약하거나, 쿠폰을 사용 처리하면 페이지 새로고침이 업무 행위를 일으킨다. 크롤러, 재시도, 관리자 탭의 반복 조회도 그 행위를 실행하게 될 수 있다.
조회 요청 -> 화면에 필요한 데이터 읽기
만료 처리 -> 정해진 명령이나 배치 작업 -> 업무 규칙 확인 -> 상태 갱신조회 과정의 로그, 메트릭, 결과 캐시 저장 같은 인프라 작업과 주문 상태 변경을 한 부류로 묶지는 않는다. 어떤 부수효과가 있어도 된다는 포괄적인 허용 대신, 조회가 업무 상태를 바꾸지 않는다는 경계를 두고 필요한 인프라 효과를 따로 설명한다. 순수 함수가 아니어도 읽기 유스케이스로 설계할 수 있다.
표시용 계산도 공유할 기준이 있다. 상품 이름을 합치거나 금액 표시 형식을 바꾸는 일은 조회에서 할 수 있지만, 환불 가능 금액 같은 업무 규칙을 목록 SQL에 다시 작성하면 쓰기 쪽 규칙과 어긋날 수 있다. 같은 규칙의 소유자를 정해 재사용하거나, 그 규칙에 따라 이미 확정된 결과를 읽는다.
성능을 확인하는 방법
Facade로 옮겼다는 사실로 인해서 조회 속도가 드라마틱하게 빨라지지 않는다. 물론 대부분은 빨라지는 경우가 많지만, 기본적으로 이전에는 N+1이던 것을 join이나 일괄 조회로 줄였는지, 전체 객체 대신 필요한 열만 읽는지, 깊은 페이지가 같은 양의 일을 반복하는지에 따라 다르다.
성능 확인하는 값들은 다음과 같다.
확인할 값 | 알 수 있는 문제 |
|---|---|
유스케이스별 p50과 p95 응답 시간 | 일부 회사나 조건에서만 느린 조회 |
요청당 DB와 외부 API 호출 수 | N+1 또는 반복 일괄 호출 |
읽은 행 수와 반환한 행 수 | 과도한 스캔이나 메모리 필터링 |
결과 바이트 수와 할당량 | 불필요한 필드와 대형 응답 |
connection pool 대기와 timeout | 병렬 호출이나 긴 쿼리의 누적 |
캐시 hit과 읽기 모델 지연 | 빠르지만 낡은 결과 또는 잦은 원본 조회 |
- 확인할 값
유스케이스별 p50과 p95 응답 시간
- 알 수 있는 문제
일부 회사나 조건에서만 느린 조회
- 확인할 값
요청당 DB와 외부 API 호출 수
- 알 수 있는 문제
N+1 또는 반복 일괄 호출
- 확인할 값
읽은 행 수와 반환한 행 수
- 알 수 있는 문제
과도한 스캔이나 메모리 필터링
- 확인할 값
결과 바이트 수와 할당량
- 알 수 있는 문제
불필요한 필드와 대형 응답
- 확인할 값
connection pool 대기와 timeout
- 알 수 있는 문제
병렬 호출이나 긴 쿼리의 누적
- 확인할 값
캐시 hit과 읽기 모델 지연
- 알 수 있는 문제
빠르지만 낡은 결과 또는 잦은 원본 조회
p50과 p95는 각각 요청의 50%, 95%가 그 시간 이하에 끝났다는 뜻이다. 평균만 보면 드러나지 않는 느린 요청을 함께 보는 데 쓰며, 읽은 행 수 같은 값은 DB 계획이나 실제 추적에서 얻어야 한다. Facade가 반환한 행 수를 DB가 읽은 행 수처럼 기록하지 않는다.
추적에는 Orders.List처럼 종류를 구분할 이름을 붙이고, 필터 종류나 페이지 크기처럼 필요한 속성을 기록한다. 원문 검색어, 고객 이름, 연결 문자열을 로그에 남기지 않으며, 사용자 ID나 커서 전체를 메트릭 label에 넣어 종류가 끝없이 늘어나게 하지 않는다.
개인적인 메모: 이 문서는 간단하게 조금 쓰려고 했다가 정신차리고 보니 일주일째 작성했다. ListOrdersAsync 하나 쓰려고 들어왔는데 권한, 추적, 성능, 로깅까지 다 나왔다. 이게 Query Facade의 함정이다. 조회 하나가 조회 하나로 안 끝난다. 프로그래밍의 가장 어려운 가정은 이것이다. 똑똑한 프로그래머는 필요한 것만 넣고, 우둔한 프로그래머는 모든 것을 넣는다. 내가 우둔한 프로그래머인 이유다.
테스트할 경계
모의 repository가 원하는 DTO를 돌려주는 테스트만으로는 join이나 정렬 방향을 검증할 수 없다. SQL을 실행하는 실제 provider에서 데이터 몇 개를 넣고, 결과 계약과 경계 조건을 확인한다. EF Core InMemory provider는 관계형 DB의 SQL 번역이나 제약조건을 재현하는 검증 수단으로 세지 않는 것이 중요하다.
테스트 | 확인할 결과 |
|---|---|
필터에 맞는 주문이 없다 | 빈 목록이며 장애로 처리하지 않는다 |
같은 생성 시각의 주문이 여러 개다 | ID 순서까지 맞고 다음 페이지에서 누락되지 않는다 |
페이지 크기를 넘는 주문이 있다 | 크기를 지키고 마지막 반환 행으로 커서를 만든다 |
정확히 페이지 크기만큼만 있다 | 다음 커서는 없다 |
다른 회사에 같은 고객 번호가 있다 | 이름이 섞이지 않고 그 회사 주문도 반환하지 않는다 |
주문이나 고객이 삭제됐다 | 해당 화면의 삭제 정책에 맞게 처리한다 |
권한 플래그가 꺼져 있거나 회사 번호가 0 이하다 | DB 조회 전에 거부한다 |
알 수 없는 상태나 잘못된 커서를 보낸다 | 입력 오류이며 조용히 기본값으로 바꾸지 않는다 |
커서의 시각이나 ID 중 하나만 보낸다 | DB 실행 전에 입력 오류로 거부한다 |
요청에서 회사 번호나 권한 값을 조작한다 | 서버의 권한 정책이 범위를 결정하고 요청 값을 믿지 않는다 |
DB 실행이 실패한다 | 빈 목록으로 성공하지 않는다 |
요청이 취소됐다 | 취소를 보존하고 정상 결과로 바꾸지 않는다 |
목록용 DTO를 조회한다 | 업무 객체를 수정하거나 저장하지 않는다 |
- 테스트
필터에 맞는 주문이 없다
- 확인할 결과
빈 목록이며 장애로 처리하지 않는다
- 테스트
같은 생성 시각의 주문이 여러 개다
- 확인할 결과
ID 순서까지 맞고 다음 페이지에서 누락되지 않는다
- 테스트
페이지 크기를 넘는 주문이 있다
- 확인할 결과
크기를 지키고 마지막 반환 행으로 커서를 만든다
- 테스트
정확히 페이지 크기만큼만 있다
- 확인할 결과
다음 커서는 없다
- 테스트
다른 회사에 같은 고객 번호가 있다
- 확인할 결과
이름이 섞이지 않고 그 회사 주문도 반환하지 않는다
- 테스트
주문이나 고객이 삭제됐다
- 확인할 결과
해당 화면의 삭제 정책에 맞게 처리한다
- 테스트
권한 플래그가 꺼져 있거나 회사 번호가 0 이하다
- 확인할 결과
DB 조회 전에 거부한다
- 테스트
알 수 없는 상태나 잘못된 커서를 보낸다
- 확인할 결과
입력 오류이며 조용히 기본값으로 바꾸지 않는다
- 테스트
커서의 시각이나 ID 중 하나만 보낸다
- 확인할 결과
DB 실행 전에 입력 오류로 거부한다
- 테스트
요청에서 회사 번호나 권한 값을 조작한다
- 확인할 결과
서버의 권한 정책이 범위를 결정하고 요청 값을 믿지 않는다
- 테스트
DB 실행이 실패한다
- 확인할 결과
빈 목록으로 성공하지 않는다
- 테스트
요청이 취소됐다
- 확인할 결과
취소를 보존하고 정상 결과로 바꾸지 않는다
- 테스트
목록용 DTO를 조회한다
- 확인할 결과
업무 객체를 수정하거나 저장하지 않는다
페이지 테스트에는 한 번의 성공 예시만 넣지 말고 여러 페이지를 끝까지 읽어 ID 집합과 순서를 비교하는 경우를 포함한다. 상태 필터와 커서의 조합도 검사하고, 조회 수가 반환 행 수에 비례해서 늘지 않는지 확인한다.
SQLite를 사용하면 관계형 join과 일부 쿼리 번역을 빠르게 검증할 수 있지만 PostgreSQL이나 SQL Server와 계획, 타입, 격리 동작이 같지는 않다. 운영 provider의 SQL 번역과 실제 부하는 별도 통합 테스트에서 확인해야한다.
도입하지 않아도 되는 경우
데이터 한 개를 읽어 그대로 반환하는 코드에, 별도 조회 정책도 안정된 외부 계약도 없는데 Facade와 인터페이스를 추가하면 읽어야 할 파일만 늘 수 있다. 이미 application service가 조회의 범위와 결과를 잘 책임지고 있다면 그 위치를 유지해도 된다.
반대로 원본 테이블이나 이벤트 기록을 읽을 권한이 없는 다른 서비스의 DB를 직접 join하려는 설계라면, 메서드 이름을 Query Facade로 바꾸기 전에 데이터 소유 경계를 해결해야 한다. 승인된 조회 API나 명시적인 읽기 projection을 통해 필요한 데이터를 가져온다.
같은 DB에서 작은 조회를 분리하는 것, 읽기 복제본을 쓰는 것, 별도 읽기 저장소를 운영하는 것은 서로 다른 규모의 결정이다. 현재의 조회 문제를 해결할 수 있는 범위에서 시작하면 된다.
요약
Query Facade는 화면이나 API가 요구하는 조회를 모아, 허용된 범위에서 필요한 결과를 만들어 돌려준다. 관리자 목록이라면 회사와 권한 조건, 표시할 필드, 정렬과 페이지 경계가 그 계약에 들어가고, 내부 구현은 EF Core나 SQL처럼 조회에 맞는 수단을 고를 수 있다.
주문을 취소하는 모델과 목록을 보여주는 결과가 꼭 같아야 할 필요는 없고, 조회 하나를 분리하는 데 DB 두 개나 이벤트 버스도 필요하지 않다. 대신 조회가 업무 상태를 바꾸지 않는지, 데이터를 너무 많이 읽고 있지 않은지, 다른 회사의 데이터가 섞이지 않는지와 실패가 정상 빈 결과로 보이지 않는지를 확인한다.
참고문헌
Gamma, Helm, Johnson, Vlissides. Design Patterns: Elements of Reusable Object-Oriented Software. Facade를 포함한 구조 패턴의 배경.
Martin Fowler. Command Query Separation, CQRS. 조회와 변경의 구분, 읽기 모델과 갱신 모델.
Martin Fowler. Repository, Service Layer. 도메인 객체 접근과 애플리케이션 작업 경계.
Microsoft. Implement reads and queries in a CQRS microservice. 도메인 모델과 독립된 조회와 화면용 DTO 구성.
Microsoft. Efficient querying, Pagination, Tracking and no-tracking queries, DbContext configuration, Indexes. projection, 결과 상한, 정렬, 추적과 context 수명, 인덱스 설정.
Microsoft. Policy-based authorization, What's New in EF Core 10. 서버의 권한 판단과 EF Core 실행 환경.
PostgreSQL. Table expressions, Row Constructor Comparison, Indexes and ORDER BY, Transaction isolation, Using EXPLAIN. join, NULL과 커서 비교, 인덱스 정렬, 스냅샷과 실행 계획.
OWASP. Authorization Cheat Sheet, SQL Injection Prevention Cheat Sheet. 요청별 권한 검사, 파라미터와 정렬 허용 목록.
각주
- Microsoft의 CQRS 조회 구현https://learn.microsoft.com/en-us/dotnet/architecture/microservices/microservice-ddd-cqrs-patterns/cqrs-microservice-reads ↩
- [GoF 『Design Patterns』 ](https://www.informit.com/store/design-patterns-elements-of-reusable-object-oriented-9780201633610) ↩
- Command Query Separation ↩
- CQRS ↩
- ASP.NET Core 정책 기반 권한 검사 ↩
- PostgreSQL 행 생성자 비교 ↩
- EF Core 페이지네이션 ↩
- PostgreSQL 트랜잭션 격리 ↩