Domain-Oriented Data Fetching

작성일:2026.08.19|수정일:2026.08.26|조회수:4

Domain-Oriented Data Fetching

객체지향이라는 개념을 접하게 된 것은 개발을 시작하고 얼마 지나지 않았을 때였다. 데이터와 그 데이터를 다루는 메서드가 하나의 객체 안에서 긴밀하게 연결되어 동작한다는 것과, 이러한 객체들이 유기적으로 협력하며 하나의 시스템을 구성한다는 개념이 내게는 어떠한 계시처럼 느껴졌다. 그 이후로 나는 이 개념의 아름다움에 깊이 매료되어, 프론트엔드 개발 전반에서 이를 어떻게든 구현해보려 애썼다. 이 블로그에는 존재하지 않지만 과거 티스토리 블로그에서 작성했었던 포스트들 ─ 어댑터 패턴을 활용한 프론트엔드 주도 개발, RAD 아키텍처 등 ─ 은 모두 백엔드에서 데이터를 가져오는 과정에 어떻게 객체지향적 사고를 녹여낼 수 있을지 고민한 흔적이었다.

이제 프론트엔드를 시작한 지 만으로 3년이 다 되어간다. 프로젝트를 진행할 때마다 고민은 매번 새로웠고, 그 끝에는 늘 객체지향이 있었다. 프론트엔드라는 환경과 그 안에서 발생하는 문제에 맞는 객체지향은 어떤 모습이어야 할까. 지난 3년간의 고민과 시행착오를 바탕으로, 이제는 이 질문에 대한 나름의 답을 정리해보려 한다.

외부 API의 계약을 가져오기

swagger.ts

아마도 가장 오랫동안 골치를 앓았던 부분은 백엔드의 스키마를 얼마나 자연스럽게 프론트엔드의 코드로 가져올 수 있느냐는 문제였다. 프론트엔드에서 API를 호출하려면 결국 백엔드가 어떤 데이터를 받고 어떤 데이터를 반환하는지 알아야 한다. 문제는 이 정보를 프론트엔드에서 다시 타입으로 정의하는 순간, 이미 백엔드에 존재하는 스키마를 한 번 더 작성하게 된다는 점이었다.

이렇게 수동으로 타입을 작성하다 보면 예기치 못한 문제가 생길 수 있다. 가령 사소한 오탈자 하나가 런타임에 프로젝트 전체를 터뜨릴 수도 있고, 백엔드의 스키마가 변경되었음에도 프론트엔드의 타입에는 그 변경이 반영되지 않은 채 오랫동안 방치될 수도 있다. 컴파일 타임에는 아무런 문제가 없는 것처럼 보이지만, 실제 서버가 반환하는 데이터와 우리가 작성한 타입 사이에는 어떠한 강제적인 연결도 존재하지 않기 때문이다. 결국 개발자는 API 문서와 실제 응답, 그리고 프론트엔드에 정의된 타입이 서로 일치하는지를 끊임없이 확인하고 동기화해야 했다.

src/entities/order/model/order.ts
TS
// 백엔드가 정의한 상태값export enum OrderResponseStatus {  PAYMENT_COMPLETED = "PAYMENT_COMPLETED",  SHIPPING = "SHIPPING",  DELIVERED = "DELIVERED",}// 프론트엔드에서 작성한 타입interface OrderResponse {  id: string;  order_number: string;  product_name: string;  status: OrderResponseStatus;  ordered_at: string;  total_price: number;}// 백엔드에서 정의한 스키마class OrderResponse {  @IsString()  @IsNotEmpty()  id: string;  @IsString()  @IsNotEmpty()  order_number: string;  @IsString()  @IsNotEmpty()  product_name: string;  @IsEnum(OrderResponseStatus)  status: OrderResponseStatus;  @IsString()  @IsNotEmpty()  ordered_at: string;  @IsNumber()  @IsNotEmpty()  total_price: number;}

이 문제를 해결하기 위해 가장 먼저 떠올린 것은 백엔드가 이미 가지고 있는 스키마를 프론트엔드에서 그대로 타입으로 사용하는 것이었다. 대부분의 백엔드는 Swagger를 통해 OpenAPI 스키마를 제공하고 있었고, 그 안에는 각 API의 경로와 HTTP 메서드부터 요청에 필요한 파라미터와 바디, 응답의 형태까지 이미 정의되어 있었다. 그렇다면 프론트엔드에서 동일한 타입을 다시 작성할 것이 아니라, 이 스키마로부터 TypeScript 타입을 직접 만들어 사용하면 되지 않을까. 그렇게 찾게 된 것이 openapi-typescript였다.

openapi-typescript를 사용하면 OpenAPI 스키마로부터 API의 모든 정보를 담고 있는 하나의 TypeScript 파일을 생성할 수 있다. 나는 이 파일을 swagger.ts라는 이름으로 관리했고, 이를 통해 백엔드의 스키마를 프론트엔드에서도 그대로 타입의 근거로 사용할 수 있게 되었다. 백엔드의 스키마가 변경되면 swagger.ts를 다시 생성해 요청과 응답의 정적 타입을 갱신할 수 있다. 다만 런타임 검증을 위한 Zod schema와 도메인 규칙까지 자동으로 따라오는 것은 아니다.

src/shared/apis/swagger.ts
TS
export interface paths {  "/orders/{id}": {    get: {      parameters: {        path: {          id: string;        };      };      responses: {        200: {          content: {            "application/json": components["schemas"]["OrderResponse"];          };        };      };    };    patch: {      parameters: {        path: { id: string };      };      requestBody: {        content: {          "application/json": { status: components["schemas"]["OrderResponse"]["status"] };        };      };      responses: {        200: {          content: {            "application/json": components["schemas"]["OrderResponse"];          };        };      };    };  };}export interface components {  schemas: {    OrderResponse: {      id: string;      order_number: string;      product_name: string;      status: "PAYMENT_COMPLETED" | "SHIPPING" | "DELIVERED";      ordered_at: string;      total_price: number;    };  };}
src/entities/order/model/DTO.ts
TS
import type { paths } from "@/shared/apis/swagger";type OrderDTO = {  req: {    get: {      orderByID: {        params: paths["/orders/{id}"]["get"]["parameters"]["path"];      };    };  };  res: {    get: {      orderByID: paths["/orders/{id}"]["get"]["responses"][200]["content"]["application/json"];    };  };};

ilokesto/fetcher

openapi-typescript를 도입하면서 적어도 백엔드와 프론트엔드가 서로 다른 타입을 관리하는 문제는 상당 부분 해결할 수 있었다. 하지만 실제로 API를 호출하는 코드까지 내려오면 여전히 아쉬움이 남았다. swagger.ts 안에는 이미 /orders/{id}라는 경로와 GET 메서드, 그리고 그 응답 타입의 관계가 모두 정의되어 있었지만, axiosky를 사용할 때는 결국 개발자가 그 타입을 직접 꺼내 요청 코드에 연결해야 했기 때문이다.

src/entities/order/apis/repository.ts
TS
export class OrderRepository {  // ...중략  async getOrderById(    params: OrderDTO["req"]["get"]["orderByID"]["params"],  ): Promise<OrderDTO["res"]["get"]["orderByID"]> {    return ky      .get(`orders/${params.id}`)      .json<OrderDTO["res"]["get"]["orderByID"]>();  }}

프로젝트 초기라면 이 정도로도 문제는 없다. 적어도 파라미터와 응답 타입 자체를 직접 작성할 필요는 없어졌고, 백엔드 스키마가 변경되더라도 swagger.ts 만 바꿔주면 그만이다. 하지만 새로운 API 하나를 추가할 때마다 swagger.tspaths 타입을 따라 들어가 요청과 응답에 필요한 타입을 꺼내고, 이를 다시 OrderDTO와 같은 형태로 정리한 뒤 Repository의 메서드에 연결해야 한다. 타입을 정의하는 일은 사라졌지만, 그 대신 이미 존재하는 타입을 조립하는 일이 생긴 셈이다.

무엇보다 나를 고민하게 만들었던 부분은 'swagger.ts 안에 이미 이 모든 관계가 존재한다'는 점이었다. /orders/{id}라는 경로 아래에는 어떤 HTTP 메서드를 사용할 수 있는지, 어떤 path parameter를 전달해야 하는지, 그리고 어떤 response가 반환되는지가 모두 연결되어 있다. 그렇다면 굳이 개발자가 이 관계를 다시 하나씩 꺼내 연결할 필요가 있을까?

이 생각에서 만든 패키지가 바로 @ilokesto/fetcher다. ky를 기반으로 만들어진 이 패키지는 swagger.ts의 OpenAPI 타입을 주입받아, 요청할 경로와 HTTP 메서드만으로 필요한 파라미터와 요청 본문, 그리고 응답 타입까지 자동으로 추론한다. 이미 swagger.ts 안에 존재하는 관계를 개발자가 다시 타입으로 조립하는 대신, 실제 API를 호출하는 코드가 그 관계를 그대로 따라가도록 만든 것이다.

src/shared/apis/fetcher.ts
TS
import { createFetcher } from "@ilokesto/fetcher";import type { paths } from "./swagger";export const api = createFetcher<paths>({  prefixUrl: "/api",});
src/entities/order/apis/repository.ts
TS
import { api } from "@/shared/apis";export class OrderRepository {  // ...중략  async getOrderById(id: string) {    return api      .get("/orders/{id}", {        params: {          path: { id },        },      })      .json();  }}

이렇게 fetcher를 사용하면서 API를 호출하기 위해 별도의 DTO를 만들고, swagger.ts를 따라가며 요청과 응답 타입을 직접 꺼내던 과정은 대부분 사라졌다. Repository는 어떤 경로로 어떤 요청을 보낼지만 알고 있으면 되었고, 나머지 타입은 swagger.ts에 이미 정의된 관계를 따라 자연스럽게 결정되었다.

하지만 여기까지 구현하고 나니 또 하나의 문제가 눈에 들어왔다. 지금까지 해결한 것은 어디까지나 컴파일 타임의 타입에 관한 문제였다. swagger.ts가 아무리 정확한 타입을 가지고 있고 fetcher가 그 타입을 완벽하게 추론하더라도, 서버에서 실제로 내려오는 데이터가 그 타입과 일치한다는 사실까지 보장해주는 것은 아니다. 개발이 급하게 이뤄지는 동안에는 아무리 주의를 기울인다고 해도 스키마가 자주 바뀌기 마련이고, 그에 따라 OpenAPI 문서의 갱신이 늦어지거나 프론트엔드의 swagger.ts가 실제 서버의 상태를 미처 따라가지 못하는 순간도 생길 수 있다.

fetcher는 OpenAPI에 기록된 경로와 요청, 응답의 관계를 호출 코드까지 가져온다. 하지만 이 코드가 받는 것은 여전히 외부 시스템의 응답이다. 이제 필요한 것은 그 응답을 애플리케이션 안으로 들일 경계다.

Repository: 외부 응답을 OrderData로 바꾸는 경계

swagger.tsfetcher가 해결하는 것은 컴파일 타임의 계약이다. 하지만 서버가 실제로 그 계약을 지킨다는 보장까지 해주지는 않는다. OpenAPI 문서가 늦게 갱신되었거나, 서버 배포가 문서와 어긋난 순간에는 TypeScript가 믿고 있던 값과 런타임 값이 달라질 수 있다.

가령 OpenAPI는 product_name을 문자열로 약속하지만, 실제 응답은 다음처럼 올 수 있다.

JSON
{  "id": "ord_1",  "order_number": "ORD-2026-001",  "product_name": null,  "status": "PAYMENT_COMPLETED",  "ordered_at": "2026-08-20T10:00:00Z",  "total_price": 89000}

그래서 외부 응답이 Repository의 경계를 넘는 순간, 런타임 검증과 애플리케이션 표현으로의 변환을 함께 수행한다.

src/entities/order/model/schema.ts
TS
import { z } from "zod";export const OrderResponseSchema = z  .object({    id: z.string(),    order_number: z.string(),    product_name: z.string(),    status: z.enum(["PAYMENT_COMPLETED", "SHIPPING", "DELIVERED"]),    ordered_at: z.string().datetime({ offset: true }),    total_price: z.number(),  })  .transform(({ order_number, product_name, ordered_at, total_price, ...order }) => ({    ...order,    orderNumber: order_number,    productName: product_name,    orderedAt: ordered_at,    totalPrice: total_price,  }));export type OrderData = z.output<typeof OrderResponseSchema>;

여기서 z.input<typeof OrderResponseSchema>은 서버가 전달하는 원본 응답이고, OrderData는 변환 뒤 애플리케이션이 사용할 값이다. 두 타입을 구분해야 하는 이유는 Zod의 transform이 input과 output을 의도적으로 다르게 만들 수 있기 때문이다.

parse를 한 번 감싼 함수는 fetcher가 추론한 원본 응답 타입이 Zod schema의 input 타입에 할당 가능한지를 컴파일 타임에 확인하게 해준다. 두 계약이 정확히 같은 구조임을 증명하는 것은 아니다. OpenAPI와 Zod를 함께 쓰는 이상, 한쪽만 먼저 바뀌어 둘이 어긋날 수 있다는 비용은 남는다.

src/shared/lib/parse.ts
TS
import { z } from "zod";export function parse<T, S extends z.ZodTypeAny>(  schema: S,  value: T & z.input<S>,): z.output<S> {  return schema.parse(value);}

Repository는 OpenAPI가 추론한 원본 응답을 받고, schema input과 맞는지 확인한 뒤 OrderData만 외부로 반환한다.

src/entities/order/apis/repository.ts
TS
import { api } from "@/shared/apis";import { parse } from "@/shared/lib";import { OrderResponseSchema } from "../model/schema";export class OrderRepository {  async getOrderById(id: string) {    const response = await api      .get("/orders/{id}", {        params: { path: { id } },      })      .json();    return parse(OrderResponseSchema, response);  }  async updateOrder(input: UpdateOrderInput) {    const response = await api      .patch("/orders/{id}", {        params: { path: { id: input.id } },        json: { status: input.status },      })      .json();    return parse(OrderResponseSchema, response);  }}

OpenAPI는 우리가 기대하는 원본 응답의 형태를, Zod는 실제로 들어온 데이터의 형태를 검증한다. 그리고 Zod의 output은 서버의 snake_case 표현을 애플리케이션의 camelCase 표현으로 바꾼다. Repository 바깥의 코드가 order_numbertotal_price를 알 필요가 없는 이유다.

물론 이 구조는 응답 필드를 두 곳에 적는 비용을 감수한다. OpenAPI 타입을 다시 생성해 diff를 확인하는 CI를 두거나, 가능한 경우 OpenAPI에서 런타임 schema까지 생성하는 방법도 있다. 내 경우에는 서버 응답을 검증하는 일과 내부 표현으로 번역하는 일을 같은 경계에 두고 싶어서 Zod schema를 직접 작성했다. 대신 이 중복을 없어진 문제처럼 취급해서는 안 된다.

지금까지 살펴본 과정은 결국 모두 Repository가 맡아야 할 책임에 대한 이야기였다. Repository는 외부 시스템으로 요청을 보내고, 전달받은 데이터를 검증하고, 필요하다면 애플리케이션에서 사용하기 적합한 형태로 변환해 내부로 전달한다. 즉 외부 데이터 소스와 애플리케이션 사이에 하나의 경계를 만드는 객체다.

앞에서 만든 OrderRepository에서 /orders/{id}라는 경로나 HTTP 메서드, 서버가 반환하는 원본 데이터의 형태는 모두 구현 세부사항이다. Repository 바깥에서는 getOrderById(id)라는 메서드와 그 결과로 반환되는 OrderData만 알면 된다.

Repository가 데이터에 접근하는 방법을 감췄다면, 다음 문제는 가져온 데이터를 애플리케이션이 어떻게 같은 것으로 식별할 것인가다.

서버 상태의 정체성: QueryKeyFactory

둘 다 같은 Order를 가져오는 코드지만 query key가 서로 달라지는 순간 TanStack Query에게는 완전히 다른 데이터가 된다. API 호출 자체는 Repository에 모아두었는데, 정작 그 데이터를 식별하는 규칙은 다시 애플리케이션 곳곳에 흩어지는 셈이다.

처음에는 query key 정도는 단순한 배열이니 크게 문제될 것이 없을지도 모른다. 하지만 조회하는 데이터가 많아지고 invalidateQueries, prefetchQuery, setQueryData처럼 동일한 query key를 여러 곳에서 다시 사용하기 시작하면 이야기가 달라진다.

TS
useQuery({  queryKey: ["orders", id],  queryFn: () => orderRepository.getOrderById(id),});queryClient.invalidateQueries({  queryKey: ["orders", id],});queryClient.setQueryData(  ["orders", id],  updatedOrder,);

이제 ["orders", id]라는 배열 자체가 하나의 중요한 계약이 된다. 문자열 하나를 잘못 입력하거나 배열의 순서를 다르게 작성하면 같은 데이터를 가리킨다고 생각했던 코드들이 서로 다른 캐시를 바라보게 된다. 그래서 query key 역시 직접 작성하지 않고 한 곳에서 생성하도록 만들었다.

src/shared/apis/queryKeyFactory/QueryKeyFactory.ts
TS
export class QueryKeyFactory<TKey extends string> {  constructor(private readonly key: TKey) {}  all() {    return [this.key] as const;  }}
src/shared/apis/queryKeyFactory/OrderQueryKeyFactory.ts
TS
import { QueryKeyFactory } from "./QueryKeyFactory";export class OrderQueryKeyFactory extends QueryKeyFactory<"orders"> {  constructor() {    super("orders");  }  detail(id: string) {    return [...this.all(), "detail", id] as const;  }}

이후에는 query key가 필요한 모든 곳에서 동일한 factory를 사용한다.

TS
export const orderQueryKeys = new OrderQueryKeyFactory();useQuery({  queryKey: orderQueryKeys.detail(id),  queryFn: () => orderRepository.getOrderById(id),});queryClient.invalidateQueries({  queryKey: orderQueryKeys.detail(id),});queryClient.setQueryData(  orderQueryKeys.detail(id),  updatedOrder,);

이렇게 하면 query key를 구성하는 규칙이 애플리케이션 곳곳에 흩어지지 않는다. 컴포넌트는 더 이상 "orders"라는 문자열이나 key의 계층 구조를 기억할 필요 없이, 어떤 데이터를 가리키고 싶은지만 표현하면 된다.

여기서 굳이 클래스를 사용한 이유는 각각의 query key를 객체로 만들기 위해서만은 아니다. 대부분의 query key는 특정 도메인을 나타내는 하나의 root에서 시작하고, 그 아래에 detail, list와 같은 key가 계층적으로 확장되는 비슷한 구조를 가진다. 이 공통된 규칙은 QueryKeyFactory가 담당하고, OrderQueryKeyFactory는 Order에서만 필요한 key를 정의하도록 역할을 나눴다.

결국 QueryKeyFactory 역시 앞에서 계속 이야기했던 것과 같은 문제를 해결한다. 문자열과 배열의 조합으로 흩어져 있던 규칙을 하나의 객체 안에 캡슐화하는 것이다. Repository가 외부 데이터에 접근하는 방법을 감춘다면, QueryKeyFactory는 서버 상태를 어떻게 식별할 것인지에 대한 규칙을 감춘다.

Repository가 반환하는 OrderData는 아직 데이터다. 주문이 취소 가능한지, 배송 중인지 같은 질문에 답하는 책임은 아직 어디에도 없다.

Domain Model은 무엇에 답해야 하는가

Domain Interface: 컴포넌트가 요구하는 최소 계약

여기까지 외부에서 들어오는 주문 데이터를 안전하게 애플리케이션 내부로 가져오는 방법을 살펴봤다. OpenAPI와 fetcher는 서버가 약속한 응답을 컴파일 타임에 추적하게 해주고, Zod는 실제 응답을 검증하고 애플리케이션의 표현으로 변환한다. Repository는 이 모든 일을 외부 시스템과 애플리케이션의 경계에서 수행한다.

하지만 검증된 데이터를 가져오는 것과, 그 데이터를 애플리케이션 안에서 어떻게 다룰 것인가는 다른 문제다. 주문에는 주문 번호와 금액 같은 값뿐 아니라 결제 완료인지, 배송 중인지, 취소할 수 있는지처럼 여러 화면이 반복해서 물을 규칙이 있다. 이 규칙을 단순 데이터로 남겨두면, 데이터를 사용하는 컴포넌트가 매번 상태를 해석하게 된다.

TSX
if (order.status === "PAYMENT_COMPLETED") {  return "결제 완료";}if (order.status === "SHIPPING") {  return "배송 중";}return "배송 완료";

처음에는 작은 조건문이다. 그러나 주문 카드, 주문 상세, 취소 버튼, 배송 현황 화면이 각자 같은 조건을 해석하기 시작하면 “이 주문은 취소할 수 있는가?”와 “현재 어떤 상태인가?”라는 도메인 규칙이 UI 곳곳으로 흩어진다.

이때 필요한 것은 서버 응답 전체를 옮긴 거대한 OrderData 타입이 아니다. 도메인 인터페이스는 컴포넌트와 Domain Model이 함께 바라보는 최소한의 계약이다. 컴포넌트는 자신이 필요한 능력만 선언하고, Domain Model은 여러 계약을 구현해 그 능력을 제공한다.

TS
export interface FormattableDate {  format(pattern?: string, timezone?: string): string;}export interface FormattablePrice {  format(currency?: string): string;}export interface OrderStatus {  isPaymentCompleted(): boolean;  isShipping(): boolean;  isDelivered(): boolean;}export interface CancelableOrder {  canCancel(): boolean;}export interface OrderSummary {  readonly orderNumber: string;  readonly productName: string;  readonly orderedAt: FormattableDate;  readonly totalPrice: FormattablePrice;}

OrderStatus는 상태를 표현하고 해석할 수 있는 주문이라는 계약이다. CancelableOrder는 취소 가능 여부를 물을 수 있는 주문이라는 계약이고, OrderSummary는 카드에 표시할 수 있는 주문이라는 계약이다. 이것들은 서버가 어떤 필드 이름으로 응답하는지 설명하지 않는다. 오직 애플리케이션 안에서 객체가 어떤 질문에 답할 수 있어야 하는지를 설명한다.

Order: 상태와 행동을 함께 갖는 객체

Domain Model은 이 계약들을 실제 행동으로 구현한다. 날짜와 금액도 원시값으로 노출하는 대신, 자신을 표현하는 행동을 가진 객체로 만든다.

TS
export class DateTime implements FormattableDate {  constructor(private readonly date: Date) {}  format(pattern = "YYYY-MM-DD", timezone = "Asia/Seoul") {    const value = new Date(      this.date.toLocaleString("en-US", { timeZone: timezone }),    );    return pattern      .replace("YYYY", String(value.getFullYear()))      .replace("MM", String(value.getMonth() + 1).padStart(2, "0"))      .replace("DD", String(value.getDate()).padStart(2, "0"));  }}export class Price implements FormattablePrice {  constructor(private readonly amount: number) {}  format(currency = "KRW") {    return new Intl.NumberFormat("ko-KR", {      style: "currency",      currency,      maximumFractionDigits: 0,    }).format(this.amount);  }}

그리고 Order는 여러 도메인 인터페이스를 구현한다.

TS
export type OrderStatusType = OrderData["status"];export class Order  implements OrderStatus, CancelableOrder, OrderSummary{  readonly orderNumber: string;  readonly productName: string;  readonly orderedAt: FormattableDate;  readonly totalPrice: FormattablePrice;  readonly #status: OrderStatusType;  constructor(data: OrderData) {    this.orderNumber = data.orderNumber;    this.productName = data.productName;    this.orderedAt = new DateTime(new Date(data.orderedAt));    this.totalPrice = new Price(data.totalPrice);    this.#status = data.status;  }  isPaymentCompleted() {    return this.#status === "PAYMENT_COMPLETED";  }  isShipping() {    return this.#status === "SHIPPING";  }  isDelivered() {    return this.#status === "DELIVERED";  }  canCancel() {    return this.isPaymentCompleted();  }}

중요한 것은 클래스를 사용했다는 사실이 아니다. “주문이 취소 가능한가?”와 “배송 중인가?”라는 질문에 답하는 규칙이 Order 안에 존재한다는 점이다. 취소 가능한 상태가 바뀌더라도 모든 컴포넌트의 조건문을 고칠 필요 없이 Order.canCancel()만 바꾸면 된다.

또한 Order의 원본 상태값은 private field로 감쌌다. 컴포넌트가 필요로 하는 것은 상태값 자체가 아니라 isShipping()canCancel()이라는 대답이다. 상태를 UI 문구로 바꾸는 일은 View의 책임으로 남기되, 상태가 무엇을 뜻하고 어떤 행동이 가능한지는 Domain Model이 답한다.

Let the Object Speak for Itself

결국 Domain Model을 도입하며 바꾸고 싶은 것은 데이터를 다루는 방식이다. 데이터를 꺼내 외부에서 판단하는 대신, 객체에게 질문한다.

TS
// 검증·변환된 데이터를 받은 View가 상태를 직접 해석한다.if (orderData.status === "PAYMENT_COMPLETED") {  cancelOrder();}// Domain Model이 자신의 규칙에 답한다.if (order.canCancel()) {  cancelOrder();}
TS
// 원시값을 외부 유틸리티로 보낸다.formatPrice(order.totalPrice);// 가격 객체가 자신을 표현한다.order.totalPrice.format();

PAYMENT_COMPLETED는 데이터다. 반면 “이 주문은 취소 가능한가?”는 주문 도메인에 대한 질문이다. 그 질문에 답하는 방법을 가장 잘 알아야 하는 것은 데이터를 사용하는 컴포넌트가 아니라 Order 자신이다.

내가 만들고 싶은 것은 단순히 클래스를 많이 사용하는 프론트엔드가 아니다. 각 객체가 자신의 상태와 규칙에 책임을 가지고, 다른 객체는 구현 세부사항이 아니라 명확한 계약을 통해 협력하는 구조다.

이제 남은 일은 검증된 OrderDataOrder로 만들고, 이 과정을 컴포넌트가 아니라 애플리케이션의 조합 지점에 두는 것이다.

Service는 객체를 조합한다

queryOptions로 조회 규칙 만들기

QueryKeyFactory를 통해 데이터를 식별하는 규칙을 한곳에 모았다면, 다음으로 남는 것은 그 데이터를 어떻게 조회하고 변경할 것인지에 대한 규칙을 어디에 둘 것인가였다. TanStack Query에서는 이 역할을 각각 queryOptionsmutationOptions가 맡는다.

처음에는 컴포넌트에서 직접 옵션을 작성해도 큰 문제는 없어 보인다. 하지만 같은 조회를 여러 곳에서 사용하기 시작하면 queryKey뿐만 아니라 queryFn, staleTime, enabled, select와 같은 옵션까지 함께 반복될 수 있다. 어느 컴포넌트에서는 staleTime을 5분으로 두고, 다른 곳에서는 아무 설정 없이 같은 데이터를 조회하는 식으로 데이터에 대한 정책이 다시 흩어지기 시작한다.

TS
useQuery({  queryKey: orderQueryKeys.detail(id),  queryFn: () => orderRepository.getOrderById(id),});

그래서 조회에 필요한 설정 자체를 queryOptions로 묶어두었다. 이 시점의 Service는 Repository와 QueryKeyFactory를 조합해 조회 규칙을 제공한다. 하지만 아직 반환값은 평면적인 OrderData다. 다음 절에서 이 조회 규칙에 Domain Model을 연결한다.

select로 OrderData를 Order로 만들기

이제 Domain Model을 실제 데이터 조회 과정에 연결해야 한다. Repository가 반환하는 것은 외부 시스템에서 가져와 검증하고 애플리케이션의 표현으로 변환한 OrderData다. 아직 Order 객체는 아니다.

컴포넌트에서 직접 new Order(data)를 호출하면, 어떤 데이터를 어떤 Domain Model로 만들지에 대한 규칙이 다시 View에 흩어진다. 앞에서 만든 OrderService.getOrderByIdselect를 추가하면 이 변환을 Service에서 담당하게 할 수 있다.

TS
export class OrderService {  constructor(    private readonly orderRepository = new OrderRepository(),    private readonly orderQueryKeys = new OrderQueryKeyFactory(),  ) {}  getOrderById(id: string) {    return queryOptions({      queryKey: this.orderQueryKeys.detail(id),      queryFn: () => this.orderRepository.getOrderById(id),      select: (data) => new Order(data),    });  }}

Repository는 외부 API에 접근하고 Zod로 응답을 검증하는 책임에만 집중한다. Order라는 Domain Model을 알 필요가 없다. 반대로 컴포넌트는 Repository가 어떤 endpoint를 호출했는지, 어떤 schema를 통과했는지 알 필요가 없다.

TXT
Repository검증·변환된 OrderData  Service    ↓ "select"Domain Model: Order Component

이제 useQuerydata는 처음부터 Order로 추론된다.

TSX
const { data: order } = useQuery(  orderService.getOrderById(id),);if (!order) {  return null;}return (  <>    <OrderStatusBadge order={order} />    <CancelOrderButton order={order} />    <OrderedAtLabel orderedAt={order.orderedAt} />  </>);

Service가 하는 일은 Repository 메서드를 한 번 감싸는 것이 아니다. Repository, QueryKeyFactory, Domain Model처럼 서로 다른 책임을 가진 객체를 조합해 애플리케이션에서 실제로 사용할 하나의 유스케이스를 완성한다.

mutationOptions로 변경 이후를 정리하기

데이터를 변경하는 경우도 마찬가지다. mutation 자체만 놓고 보면 Repository의 메서드를 호출하는 것으로 충분하지만, 실제 애플리케이션에서는 변경이 성공한 이후 기존 캐시를 무효화하거나 새로운 값으로 갱신해야 하는 경우가 많다.

JS
const mutation = useMutation({  mutationFn: (input) => orderRepository.updateOrder(input),  onSuccess: (_, input) => {    queryClient.invalidateQueries({      queryKey: orderQueryKeys.detail(input.id),    });  },});

이 코드가 컴포넌트마다 반복되기 시작하면 Order를 수정했을 때 어떤 캐시를 갱신해야 하는지에 대한 규칙 역시 UI 곳곳으로 흩어진다. 그래서 변경에 필요한 규칙도 mutationOptions를 이용해 Service 안으로 가져왔다.

src/entities/order/apis/service.ts
TS
type UpdateOrderInput = {  id: string;  status: OrderStatusType;};export class OrderService {  // constructor와 getOrderById는 앞의 예시와 같다.  updateOrder() {    return mutationOptions({      mutationFn: (input: UpdateOrderInput) =>        this.orderRepository.updateOrder(input),      onSuccess: (_, input, __, { client }) => {        client.invalidateQueries({          queryKey: this.orderQueryKeys.detail(input.id),        });      },    });  }}

컴포넌트에서는 마찬가지로 Service가 제공하는 동작을 그대로 사용한다.

JS
const { mutate: updateOrder } = useMutation(  orderService.updateOrder(),);

in a React Component

필요한 계약만 props로 받기

컴포넌트는 구체적인 Order 클래스 전체에 의존할 필요가 없다. 자신이 요구하는 도메인 인터페이스만 선언하면 된다.

TSX
function OrderStatusBadge({ order }: { order: OrderStatus }) {  if (order.isPaymentCompleted()) return <span>결제 완료</span>;  if (order.isShipping()) return <span>배송 중</span>;  if (order.isDelivered()) return <span>배송 완료</span>;  return <span>상태 확인 필요</span>;}function CancelOrderButton({ order }: { order: CancelableOrder }) {  if (!order.canCancel()) {    return null;  }  return <button>주문 취소</button>;}

날짜와 금액을 보여주는 컴포넌트는 주문조차 알 필요가 없다.

TSX
function OrderedAtLabel({ orderedAt }: { orderedAt: FormattableDate }) {  return <span>{orderedAt.format("YYYY년 MM월 DD일")}</span>;}function OrderPrice({ price }: { price: FormattablePrice }) {  return <strong>{price.format()}</strong>;}

이 구조에서 Order 인스턴스를 props로 전달할 수는 있다. 다만 각 컴포넌트 내부에서 TypeScript가 허용하는 것은 인터페이스가 공개한 계약뿐이다. 이는 런타임에 객체의 다른 public 멤버가 사라진다는 뜻은 아니다. 그래서 외부에 노출하고 싶지 않은 구현 세부사항은 private 또는 #private으로 감추고, 인터페이스는 컴파일 타임의 협력 규칙으로 사용한다.

Passing Domain Objects as Props

주문 카드처럼 여러 능력이 필요할 때만 인터페이스를 조합한다.

TS
export interface OrderCardView  extends OrderSummary, OrderStatus {}
TSX
function OrderCard({ order }: { order: OrderCardView }) {  return (    <article>      <h2>{order.productName}</h2>      <p>주문 번호: {order.orderNumber}</p>      <OrderStatusBadge order={order} />      <OrderedAtLabel orderedAt={order.orderedAt} />      <OrderPrice price={order.totalPrice} />    </article>  );}

OrderCardOrder가 어떤 API에서 왔는지, Zod가 어떤 snake_case 필드를 camelCase로 바꿨는지, 취소 가능 여부가 어떤 내부 상태로 계산되는지 알 필요가 없다. 자신이 받은 계약만 사용한다. props는 단순히 데이터를 운반하는 통로가 아니라, 객체들이 협력하기 위해 필요한 능력을 전달하는 통로가 된다.

마치며

이 글에서 다룬 것은 데이터를 가져오는 문법이 아니라, 각각의 책임을 어디에 둘 것인가에 대한 이야기다. OpenAPI와 fetcher는 외부 API 계약을 추적하고, Zod는 실제 응답을 검증하며 애플리케이션의 표현으로 변환한다. Repository는 외부 시스템과의 경계를 만든다. QueryKeyFactory는 서버 상태를 식별하는 규칙을 캡슐화한다. Domain Model은 자신의 상태와 규칙에 답하고, 도메인 인터페이스는 컴포넌트와 Domain Model이 협력할 수 있는 최소한의 계약을 만든다. Service는 이 객체들을 조합해 하나의 유스케이스를 완성한다.

중요한 것은 클래스를 얼마나 많이 사용했느냐가 아니다. 데이터와 행동을 적절한 객체에 모으고, 구현 세부사항을 경계 안에 감추며, 객체들이 명확한 계약을 통해 협력하도록 만드는 것이다. 지금의 구조 역시 정답이라기보다는 그 질문에 대한 현재의 답에 가깝다. 앞으로 프로젝트를 거치며 이 답은 계속 달라질 것이다.

댓글

댓글을 불러오는 중...