Domain-Oriented Data Fetching in React Query
객체지향이라는 개념을 접하게 된 것은 개발을 시작하고 얼마 지나지 않았을 때였다. 데이터와 그 데이터를 다루는 메서드가 하나의 객체 안에서 긴밀하게 연결되어 동작한다는 것과, 이러한 객체들이 유기적으로 협력하며 하나의 시스템을 구성한다는 개념이 내게는 어떠한 계시처럼 느껴졌다. 그 이후로 나는 이 개념의 아름다움에 깊이 매료되어, 프론트엔드 개발 전반에서 이를 어떻게든 구현해보려 애썼다.
지금은 존재하지 않지만 한때 이 블로그에 작성한 포스트들 ─ 어댑터 패턴을 활용한 프론트엔드 주도 개발, RAD 아키텍처 등 ─ 은 모두 백엔드에서 데이터를 가져오는 방식에 객체지향적 사고를 어떻게 녹여낼 수 있을지 고민한 흔적들이었다. 그러나 지금까지의 고민들은 클래스와 인스턴스를 사용하기는 했어도 본질적으로 객체지향적이라 보기에는 많은 어려움이 있었다.
그리하여 새로운 프로젝트를 시작하기 전 나는 2주 정도 이 문제를 집중적으로 고민하며 객체지향의 본질과 철학을 다시 한 번 깊이 들여다보았다. 단순히 문법적 요소를 넘어서, 객체의 책임 분리와 응집도, 그리고 객체 간의 메시지 전달 방식을 체계적으로 이해하고자 노력했다. 이를 통해 보다 견고하고 유지보수하기 쉬운 설계를 목표로 삼았고, 실제 개발 과정에 적용할 구체적인 원칙과 패턴들을 정립하는 데 집중했다.
이 포스트는 그 고민과 깨달음, 그리고 실제 적용한 원칙과 패턴들을 정리한 기록이다.
API type definitions
API 개발 과정에서 프론트엔드와 백엔드 간 타입 불일치는 자주 발생하는 문제이다. 특히 API 스펙이 변경될 때마다 이를 코드에 반영하는 과정에서 오류가 발생할 수 있다. 이러한 문제를 해결하기 위해 OpenAPI 스펙 파일로부터 TypeScript 타입을 자동으로 생성하는 도구인 openapi-typescript를 활용하는 것이 효과적이다.
openapi-typescript는 서버 API의 경로(path), 요청(request), 응답(response) 구조를 TypeScript 타입으로 자동 변환한다. 덕분에 API 스펙이 바뀌어도 타입이 자동으로 갱신되어, 프론트엔드와 백엔드 간 타입 불일치 문제를 방지할 수 있다. 이는 타입 안정성과 개발 생산성 향상에 큰 도움을 준다.
나는 생성된 타입을 기반으로, 실제 API 호출 시 타입 안전성을 보장하는 유틸리티 타입을 정의해 사용한다. 예를 들어 ApiResponse 타입은 특정 API 엔드포인트와 HTTP 메서드에 대해 서버가 반환하는 응답 타입을 자동으로 추론한다. ApiRequestParams 타입은 각 엔드포인트에 요청할 때 필요한 path, query, body 등 모든 인자를 타입으로 추출한다.
이러한 유틸리티 타입을 활용하면 API 호출 시점에 컴파일 타임 타입 검증이 가능하다. 덕분에 잘못된 요청이나 응답 처리로 인한 오류를 미연에 방지할 수 있다. 결과적으로 openapi-typescript를 활용한 타입 자동화는 코드의 신뢰성과 유지보수성을 크게 높이며, 프론트엔드와 백엔드 간 견고한 협업 환경을 구축하는 데 기여한다.
import { paths } from "./api";
// /api prefix를 자동으로 추가하는 헬퍼 타입
type AddApiPrefix<T extends string> = `/api/${T}`;
// /api prefix가 있는 path만 추출
type ApiPaths = {
[K in keyof paths as K extends `/api/${string}` ? K : never]: paths[K];
};
// /api prefix 없이 사용할 수 있는 path 타입
export type PathWithoutApi<T extends keyof ApiPaths> =
T extends `/api/${infer Rest}` ? Rest : never;
// 개선된 ApiResponse - /api prefix 자동 추가
export type ApiResponse<
T extends PathWithoutApi<keyof ApiPaths>,
M extends keyof ApiPaths[AddApiPrefix<T>],
> =
// 200 응답
ApiPaths[AddApiPrefix<T>][M] extends {
responses: { 200: { content: { "application/json": infer R200 } } };
}
? R200
: // 201 응답
ApiPaths[AddApiPrefix<T>][M] extends {
responses: { 201: { content: { "application/json": infer R201 } } };
}
? R201
: // 204 응답 (204는 보통 content가 없음)
ApiPaths[AddApiPrefix<T>][M] extends {
responses: { 204: { content: { "application/json": infer R204 } } };
}
? R204
: never;
// undefined 또는 never인 필드를 제거
type Clean<T> = {
[K in keyof T as T[K] extends undefined | never ? never : K]: T[K];
};
// parameters에서 path, query 등 실제 값이 있는 필드만 추출
type ExtractParams<P> = P extends object ? Clean<P> : Record<string, never>;
// 특정 엔드포인트의 body, query, path 등 모든 요청 인자를 타입으로 추출
export type ApiRequestParams<
T extends PathWithoutApi<keyof ApiPaths>,
M extends keyof ApiPaths[AddApiPrefix<T>],
> = ExtractParams<
Roll<
ApiPaths[AddApiPrefix<T>][M] extends { parameters: infer P }
? P
: Record<string, never>
> &
(ApiPaths[AddApiPrefix<T>][M] extends {
requestBody: { content: { "application/json": infer B } };
}
? { body: B }
: Record<string, never>)
>;
abstract class BaseHttpClient
HTTP 요청을 처리하는 로직은 대부분의 애플리케이션에서 반복적으로 등장하는 부분이다. 이 프로젝트에서는 이러한 공통 로직을 추상 클래스 BaseHttpClient에 모듈화하여 일관성을 유지하고자 했다. BaseHttpClient는 ky 라이브러리를 기반으로 하여, 요청 파라미터 처리, 경로 파라미터 치환, API 요청 객체 변환 등 HTTP 요청에 필요한 핵심 기능들을 추상화한다. 환경별로 다른 설정이 필요한 경우는 createKyInstance라는 추상 메서드를 통해 하위 클래스에서 유연하게 구현할 수 있도록 설계되었다. 이를 통해 개발자는 각 환경의 특성에 맞춘 HTTP 클라이언트를 손쉽게 구성할 수 있으며, 코드 중복 없이 재사용성과 유지보수성을 동시에 확보할 수 있다.
또한 이 클래스는 API 호출 시 path, query, body, header 등의 파라미터를 일관된 방식으로 처리하기 위해 ApiRequestParams라는 형식을 정의하고, 해당 객체를 ky 옵션으로 변환하는 메서드들을 포함하고 있다. processRequestParams 메서드는 파라미터의 유형을 판단하여 경로 치환 및 옵션 생성을 자동으로 수행하며, 실제 API 요청 전에 필요한 모든 정보를 정제해준다. 덕분에 API 스펙을 기반으로 생성된 타입 정보(ApiRequestParams)를 그대로 전달하면, URL과 옵션이 자동으로 조합되어 HTTP 요청이 수행되도록 구성할 수 있다. 이로 인해 API 호출 로직이 단순해지고, 타입 기반 개발의 이점을 극대화할 수 있다.
// @/api/base/BaseHttpClient.ts
abstract class BaseHttpClient {
protected createKyInstance(environment: Environment) {
switch (environment) {
case "server":
return ky.create({
prefixUrl: process.env.NEXT_PUBLIC_SERVER_API_URL,
});
case "client":
return ky.create({
prefixUrl: process.env.NEXT_PUBLIC_CLIENT_API_URL,
});
default:
throw new Error(`Unknown environment: ${environment}`);
}
}
/**
* 요청 파라미터를 처리하여 최종 경로와 옵션을 반환합니다.
*/
protected processRequestParams(
path: string,
paramsOrOptions?: Record<string, unknown> | Options,
additionalOptions?: Options
): { finalPath: string; finalOptions: Options } {
let finalPath = path;
let finalOptions: Options = {};
if (this.isApiRequestParams(paramsOrOptions)) {
// ApiRequestParams의 각 속성을 ky options로 변환
finalPath = this.processPathParams(
path,
paramsOrOptions.path as Record<string, string>
);
finalOptions = this.buildKyOptions(paramsOrOptions);
// 추가 options 병합
if (additionalOptions) {
finalOptions = { ...finalOptions, ...additionalOptions };
}
} else {
// paramsOrOptions가 Options인 경우
finalOptions = paramsOrOptions || {};
}
return { finalPath, finalOptions };
}
/**
* 객체가 ApiRequestParams인지 확인합니다.
*/
protected isApiRequestParams(obj: unknown): obj is Record<string, unknown> {
return (
obj !== null &&
typeof obj === "object" &&
("body" in obj || "query" in obj || "path" in obj || "header" in obj)
);
}
/**
* path 파라미터를 URL에 치환합니다.
*/
protected processPathParams(
path: string,
pathParams?: Record<string, string>
): string {
if (!pathParams) return path;
let finalPath = path;
Object.entries(pathParams).forEach(([key, value]) => {
finalPath = finalPath.replace(`{${key}}`, value);
});
return finalPath;
}
/**
* ApiRequestParams를 ky Options로 변환합니다.
*/
protected buildKyOptions(params: Record<string, unknown>): Options {
const options: Options = {};
if (params.body) {
options.json = params.body;
}
if (params.query) {
options.searchParams = params.query as Record<string, string>;
}
if (params.header) {
options.headers = params.header as Record<string, string>;
}
return options;
}
}
class Fetcher
Fetcher 클래스는 OpenAPI 스펙을 기반으로 타입 안전한 HTTP 요청을 보장하는 클라이언트 구현체이다. 이 클래스는 앞서 정의한 BaseHttpClient를 상속받아, 서버와 클라이언트 환경에 따라 최적화된 ky 인스턴스를 생성하며, 내부적으로 detectEnvironment() 메서드를 통해 현재 실행 환경을 자동으로 감지한다. 이를 통해 개발자는 환경에 따라 직접 분기 처리할 필요 없이, 동일한 방식으로 API를 호출할 수 있다.
핵심 기능은 createFetcher 메서드 내부에서 동적으로 생성되는 HTTP 메서드 함수들(get, post, put 등)에 있다. 이 함수들은 OpenAPI의 paths 타입과 연동되어 각 엔드포인트에 대한 타입을 자동으로 추론하며, 경로와 파라미터의 유효성 또한 컴파일 타임에 검증된다. 덕분에 런타임 오류 가능성을 사전에 방지할 수 있다. 실제 요청은 ky 인스턴스를 통해 수행되며, 파라미터 처리 및 옵션 구성은 BaseHttpClient에서 정의한 로직을 그대로 재사용하므로 코드의 일관성과 신뢰성이 높게 유지된다. 결과적으로 Fetcher는 OpenAPI 타입 정의와 런타임 네트워크 요청을 자연스럽게 연결해주는 강력한 브릿지 역할을 하며, 타입 중심 개발의 생산성과 안정성을 크게 향상시킨다.
// @/api/base/Fetcher.ts
import {
type UseMutationOptions,
infiniteQueryOptions,
queryOptions,
} from "@tanstack/react-query";
import { BaseHttpClient } from ./BaseHttpClient
export class Fetcher extends BaseHttpClient {
public fetcher = this.createFetcher(this.detectEnvironment());
protected queryOptions = queryOptions;
protected infiniteQueryOptions = infiniteQueryOptions;
protected mutationOptions = <
TData = unknown,
TError = Error,
TVariables = void,
TContext = unknown,
>(
options: UseMutationOptions<TData, TError, TVariables, TContext>
): UseMutationOptions<TData, TError, TVariables, TContext> => options;
private detectEnvironment(): Environment {
// typeof window === 'undefined'는 서버 사이드 렌더링 환경을 의미
if (typeof window === "undefined") {
return "server";
}
// window 객체가 존재하면 클라이언트 환경
return "client";
}
private createFetcher(environment: Environment): FetcherInstance {
const kyInstance = this.createKyInstance(environment);
const fetcher = {} as FetcherInstance;
// HTTP 메서드별 fetcher 함수 생성
(Object.values(HTTPMethod) as HttpMethod[]).forEach((method) => {
fetcher[method] = async <P extends PathWithoutApi<keyof paths>>(
path: P,
paramsOrOptions?: ApiRequestParams<P, typeof method> | Options,
options?: Options
) => {
const { finalPath, finalOptions } = this.processRequestParams(
path as string,
paramsOrOptions,
options
);
const kyMethod = kyInstance[method] as KyMethodSignature;
const response = await kyMethod.call(
kyInstance,
finalPath,
finalOptions
);
return response.json();
};
});
return fetcher;
}
}
class DomainQuery
InterviewQuery 클래스는 React Query 기반의 데이터 요청 로직을 캡슐화하여 인터뷰 관련 API 요청을 담당하는 추상화 계층이다. 내부적으로는 Fetcher 클래스를 상속받아 HTTP 요청 기능을 재사용하며, 각 쿼리에 고유한 키를 부여하기 위해 static get keyFactory를 통해 일관된 쿼리 키 생성 규칙을 제공한다. 이로써 React Query의 캐싱 및 식별 메커니즘을 체계적으로 관리할 수 있다. 또한 @thisBind 데코레이터를 통해 인스턴스 메서드의 this 바인딩 문제를 해결함으로써, 안정적인 함수 참조와 실행이 가능하다.
getInterviewById 메서드는 인터뷰 세션 ID와 메시지 포함 여부를 기반으로 API 요청을 수행하며, 이후 응답을 도메인 객체(Interview)로 감싸는 역할은 외부 어댑터나 훅에서 담당하게 설계되어 있어, 쿼리 로직과 도메인 모델의 책임을 명확히 분리하고 있다. 이 구조는 재사용성과 테스트 용이성을 높이며, 유지보수에 유리한 패턴을 따른다.
// api/interview/Interview.query.ts
@thisBind
export class InterviewQuery extends Fetcher {
constructor() {
super();
}
static get keyFactory() {
const base = ['interview'] as const;
return {
all: () => base,
getInterviewById: (id: string | number) => [...base, id] as const,
};
}
public getInterviewById(sessionId: string, withMessages: boolean = false) {
return this.queryOptions({
queryKey: InterviewQuery.keyFactory.getInterviewById(sessionId),
queryFn: () => this.fetcher.get("interview/{sessionId}", {
query: { withMessages },
path: { sessionId },
},
});
}
}
thisBind Decorator
클래스 기반 코드에서 this 바인딩 문제는 여전히 자주 마주치는 불편함 중 하나이다. 특히 이벤트 핸들러나 콜백으로 메서드를 전달할 때, 원래의 this 컨텍스트가 유지되지 않아 예상치 못한 버그가 발생하곤 한다. 이를 해결하기 위해 만든 thisBind 데코레이터는 클래스 생성 시점에 모든 인스턴스 메서드와 속성 메서드를 자동으로 this에 바인딩해주는 역할을 한다.
프로토타입 메서드는 getter를 통해 항상 현재 인스턴스에 바인딩된 버전을 반환하도록 처리하고, 인스턴스 속성에 정의된 일반 함수들도 this가 바인딩된 새로운 함수로 재정의된다. 화살표 함수는 이미 정의 시점에 this가 고정되어 있기 때문에 별도의 처리가 필요 없다. 이 데코레이터를 사용하면 바인딩을 위한 bind(this) 호출을 일일이 작성할 필요 없이, 모든 메서드가 언제 어디서 호출되더라도 항상 올바른 컨텍스트를 유지하도록 보장할 수 있다.
// @/Shared/decorator/thisBind.ts
/* eslint-disable @typescript-eslint/no-explicit-any */
export function thisBind<T extends { new (...args: any[]): any }>(
constructor: T
) {
return class extends constructor {
constructor(...args: any[]) {
super(...args);
// 프로토타입 메서드 바인딩
const methodNames = Object.getOwnPropertyNames(
constructor.prototype
).filter(
(name) =>
name !== "constructor" &&
typeof constructor.prototype[name] === "function"
);
for (const methodName of methodNames) {
// 중요: 속성 getter를 만들어 항상 바인딩된 함수 반환
Object.defineProperty(this, methodName, {
get: function () {
// 항상 현재 인스턴스에 바인딩된 함수를 반환
return constructor.prototype[methodName].bind(this);
},
configurable: true,
enumerable: true,
});
}
// 인스턴스 속성 처리 (화살표 함수 + 일반 함수 속성)
const propertyNames = Object.getOwnPropertyNames(this);
for (const key of propertyNames) {
const descriptor = Object.getOwnPropertyDescriptor(this, key);
const isMethod = descriptor && typeof descriptor.value === "function";
if (isMethod) {
// 화살표 함수 속성은 이미 this가 바인딩되어 있으므로 추가 작업 불필요
// prototype이 없는 함수는 화살표 함수로 간주
if (!descriptor!.value.prototype) {
continue;
}
// 일반 함수 속성에 this 바인딩
Object.defineProperty(this, key, {
value: descriptor!.value.bind(this),
enumerable: descriptor!.enumerable,
configurable: descriptor!.configurable,
writable: descriptor!.writable,
});
}
}
}
};
}
static get keyFactory
static get keyFactory는 InterviewQuery 클래스의 모든 쿼리 키를 정형화된 방식으로 관리할 수 있게 해주는 정적 게터로, React Query에서 사용할 쿼리 키들을 중앙에서 생성하도록 설계되어 있다. 쿼리 키를 하드코딩하지 않고 하나의 출처에서 만들게 되면, 쿼리 무효화(queryClient.invalidateQueries)나 캐시 참조 시 중복 없이 일관된 키를 사용할 수 있어 유지보수가 훨씬 쉬워진다.
특히 keyFactory는 all, getInterviewById 같은 메서드를 통해 키를 생성하며, 이러한 방식은 쿼리뿐 아니라 mutation이나 무효화 로직 등에서도 재사용할 수 있다.
// mutation에서 invalidateQueries에 사용
const mutation = useMutation(updateInterview, {
onSuccess: (_, variables) => {
queryClient.invalidateQueries(
InterviewQuery.keyFactory.getInterviewById(variables.sessionId)
);
},
});
이처럼 쿼리 키를 keyFactory에서 일관되게 생성하면, 로직 간에 쿼리 키 불일치로 인한 버그를 방지하고, 명시적이고 타입 안전한 코드 구성이 가능해진다.
Domain Interface
도메인 객체를 설계할 때, “구현이 아닌 인터페이스에 의존하라”는 객체지향 설계 원칙을 따르는 것은 매우 중요하다. 이 원칙은 도메인 클래스와 외부 컴포넌트(예: React 컴포넌트, 서비스 레이어)가 모두 특정한 구현체가 아니라, 인터페이스에 의존하도록 만들어준다. 아래 예시에서 DateTimeData이나 InterviewData 같은 인터페이스는 도메인 로직의 핵심 기능만을 추상화하며, 실제 데이터가 어떻게 주어지든지 간에 일관된 방식으로 다룰 수 있는 계약을 정의한다. 덕분에 도메인 모델을 사용하는 클라이언트 입장에서는 구현체의 세부 사항에 얽매이지 않고, 타입 안정성과 예측 가능한 동작을 보장받을 수 있다.
export interface DateTimeData {
readonly date: Date;
toTimezone(timezone?: string): Date;
format(pattern?: string, timezone?: string): string;
}
interface InterviewData<T extends TInterviewEvaluation> {
readonly id: string;
readonly userId: string;
readonly title: string;
readonly position: InterviewPositionKr;
readonly experience: InterviewExperienceKr;
readonly isActive: boolean;
readonly createdAt: DateTimeData;
readonly messages?: components["schemas"]["InterviewMessage"][];
readonly evaluation: InterviewEvaluationData<T>;
}
또한 이 방식은 서버 응답의 구조가 변경되더라도, 도메인 인터페이스와 도메인 모델을 사용하는 컴포넌트에는 영향을 미치지 않도록 해준다. 서버 응답(JSON 등)을 받아 도메인 클래스의 인스턴스로 변환하는 과정에서, 개발자는 어댑터 역할을 수행하는 팩토리나 생성자를 사용하여 서버 데이터의 구조와 도메인 인터페이스 사이를 명확히 구분할 수 있다. 결과적으로 도메인 클래스 내부에서만 응답 형태에 대한 의존성이 생기고, 외부에 노출되는 인터페이스나 호출자는 안정적으로 유지된다. 이 구조는 유지보수성과 테스트 용이성을 모두 향상시키며, 코드의 응집도와 확장성을 크게 높이는 설계 방식이다.
Domain Model
Interview 클래스는 인터페이스로 추상화된 InterviewData를 구현한 도메인 모델로, 인터뷰 세션과 관련된 핵심 정보를 캡슐화한다. 이 클래스는 인터뷰의 식별자, 제목, 직무, 경력, 활성 상태, 생성 일시, 메시지 목록, 평가 결과 등 도메인 로직에서 필요로 하는 모든 데이터를 명확하게 정의하고 있다. 생성자에서는 OpenAPI 기반의 응답 객체를 입력받아, 이를 도메인 친화적인 형태로 가공해 내부 상태를 초기화한다. 예를 들어 position과 experience 필드는 내부 enum으로 캐스팅되며, createdAt은 DateTime이라는 커스텀 시간 객체로 감싸져 시간 관련 로직을 일관되게 다룰 수 있도록 한다.
이처럼 Interview 클래스는 단순히 데이터를 보관하는 그릇이 아니라, 인터뷰의 상태를 스스로 판단하고 외부에 의미 있는 정보를 제공하는 도메인 로직의 중심 역할을 수행한다. 예를 들어 progressStatus 게터는 isActive 플래그와 평가의 존재 여부에 따라 인터뷰의 상태를 IN_PROGRESS, ANALYZING, COMPLETED 중 하나로 계산한다. 그 외에도 isCompleted, isAnalyzing, isInProgress 등의 메서드를 통해 인터뷰 객체가 자신의 상태를 스스로 드러내도록 한다. 이처럼 상태 판단 로직이 외부로 흩어지지 않고 객체 내부에 모여 있다는 점은, 도메인 주도 설계(DDD)의 핵심인 모델의 자율성과 응집도를 잘 구현한 사례라 할 수 있다.
// @/shared/domain/Interview.ts
class Interview<T extends TInterviewEvaluation> extends InterviewDomain<T> {
public readonly id: string;
public readonly userId: string;
public readonly title: string;
public readonly position: InterviewPositionKr;
public readonly experience: InterviewExperienceKr;
public readonly isActive: boolean;
public readonly createdAt: DateTimeDomain;
public readonly messages?: components["schemas"]["InterviewMessage"][];
public readonly evaluation: InterviewEvaluationDomain<T>
constructor(data: {
success: boolean;
session: components["schemas"]["InterviewSession"];
}) {
this.id = data.session.id;
this.userId = data.session.userId;
this.title = data.session.title;
this.position = data.session.position as InterviewPositionKr;
this.experience = data.session.experience as InterviewExperienceKr;
this.isActive = data.session.isActive;
this.createdAt = new DateTime(data.session.createdAt);
this.messages = data.session.messages;
this.evaluation = new InterviewEvaluation({
evaluation: data.session.evaluation,
evaluationType: data.session.evaluationType,
} as T);
}
public get progressStatus(): ProgressStatus {
if (this.isActive) return ProgressStatus.IN_PROGRESS;
if (!this.evaluation.evaluationType) return ProgressStatus.ANALYZING;
return ProgressStatus.COMPLETED;
}
public isCompleted(): boolean {
return this.progressStatus === ProgressStatus.COMPLETED;
}
public isAnalyzing(): boolean {
return this.progressStatus === ProgressStatus.ANALYZING;
}
public isInProgress(): boolean {
return this.progressStatus === ProgressStatus.IN_PROGRESS;
}
}
export class DateTime {
private date: Date;
constructor(dateInput: string | Date | number) {
if (typeof dateInput === "string") {
this.date = new Date(dateInput);
} else if (dateInput instanceof Date) {
this.date = new Date(dateInput.getTime());
} else {
this.date = new Date(dateInput);
}
// 유효하지 않은 날짜인 경우 에러 발생
if (isNaN(this.date.getTime())) {
throw new Error(`Invalid date input: ${dateInput}`);
}
}
/**
* 1. 입력한 나라 시간대로 변형하는 메서드 (기본값: 한국)
* 예: toTimezone() -> 한국시간, toTimezone("America/New_York") -> 뉴욕시간
*/
public toTimezone(timezone: string = "Asia/Seoul"): Date {
// 해당 시간대의 시간으로 변환된 Date 객체 반환
const timeZoneOffset = this.date.toLocaleString("en-US", {
timeZone: timezone,
});
return new Date(timeZoneOffset);
}
/**
* 2. 정해진 규격으로 포매팅하는 메서드 (기본값: YYYY-MM-DD)
* 예: format() -> "2025-07-14", format("YYYY년 MM월 DD일") -> "2025년 07월 14일"
*/
public format(
pattern: string = "YYYY-MM-DD",
timezone: string = "Asia/Seoul"
): string {
// 지정된 시간대로 변환
const targetDate = new Date(
this.date.toLocaleString("en-US", { timeZone: timezone })
);
const year = targetDate.getFullYear();
const month = String(targetDate.getMonth() + 1).padStart(2, "0");
const day = String(targetDate.getDate()).padStart(2, "0");
const hours = String(targetDate.getHours()).padStart(2, "0");
const minutes = String(targetDate.getMinutes()).padStart(2, "0");
const seconds = String(targetDate.getSeconds()).padStart(2, "0");
return pattern
.replace(/YYYY/g, String(year))
.replace(/MM/g, month)
.replace(/DD/g, day)
.replace(/HH/g, hours)
.replace(/mm/g, minutes)
.replace(/ss/g, seconds);
}
}
Interview 클래스는 InterviewData 인터페이스를 구현함으로써 외부와의 결합도를 낮추고, 테스트와 유지보수를 용이하게 만든다. 특히 제네릭 타입 파라미터 T를 통해 다양한 평가 모델을 유연하게 수용할 수 있도록 설계되어 있어, 평가 전략이 확장되더라도 인터뷰 도메인 자체의 구조는 안정적으로 유지된다. 이러한 구조는 도메인 주도 설계(Domain-Driven Design)의 가치에 충실한 방식으로, 인터뷰라는 개념을 하나의 객체로 추상화함으로써 코드의 가독성과 응집도를 높이고, 도메인 로직이 여러 계층에 흩어지는 것을 방지한다. 결과적으로 Interview 클래스는 단순한 데이터 구조를 넘어, 인터뷰라는 도메인을 표현하고 제어하는 자율적인 중심 객체 역할을 수행한다.
in a React Component
React 컴포넌트 내부에서 도메인 모델을 직접 활용하는 방식은 표현(View)과 로직(Domain)의 경계를 명확히 구분해주는 효과적인 구조이다. 일반적인 경우라면 API 응답을 받아 로직 없이 바로 렌더링하거나, 뷰 레이어에서 조건 분기나 데이터 변환을 처리하게 되지만, 도메인 모델을 활용하면 이 책임을 도메인 객체에 위임할 수 있다. 예를 들어 아래 코드에서는 InterviewQuery를 통해 인터뷰 데이터를 요청하고, useQuery 훅을 통해 데이터를 받아온 뒤 Interview 인스턴스로 변환한다. 이 과정에서 data는 단순한 API 응답이 아닌 Interview 도메인 객체로 추론되며, 이 객체를 통해 상태 판별, 날짜 포맷, 평가 정보 접근 등 다양한 도메인 기능을 직접 사용할 수 있게 된다.
특히 interview.createdAt.format()과 같은 호출 방식은 날짜 데이터를 단순 문자열로 가공하는 로직을 도메인 객체 안으로 옮긴 대표적인 예이다. DateTime 클래스는 내부적으로 시간대 변환, 문자열 패턴 포매팅, 유효성 검사 등의 기능을 캡슐화하고 있어, 뷰 단에서는 단지 format("YYYY.MM.DD")처럼 호출만 하면 된다. 이를 통해 컴포넌트 내의 표현 로직이 훨씬 간결해지고, 반복되는 날짜 처리 로직이 제거되며, 코드의 일관성과 유지보수성 또한 크게 향상된다. 도메인 모델을 뷰 레이어에서 직접 활용하는 이러한 접근 방식은, UI 코드의 불필요한 조건문과 가공 로직을 줄이고, 의도를 드러내는 읽기 쉬운 코드를 만드는 데 유리하다.
export default function Page() {
const interviewQuery = new InterviewQuery();
const { data } = useQuery(interviewQuery.getInterviewById(sessionId, withMessages))
const interview = data ? new Interview(data) : undefined
// ~~~~~~~~~ type interview = Interview | undefined
return (
<>
<div>{interview?.progressStatus}</div>
<div>{interview?.createdAt.format()}</div>
</>
)
}
Passing Domain Objects as Props
도메인 모델을 컴포넌트의 props로 직접 전달하는 방식은 UI 구조를 더욱 명확하게 만들고, 로직의 재사용성을 높여준다. 흔히 API 응답 형태 그대로 props로 넘기면, 각 컴포넌트에서 데이터를 해석하거나 가공하는 중복 로직이 발생하게 된다. 반면, 도메인 객체를 통째로 props로 전달하면, 컴포넌트는 그 객체가 제공하는 의미 있는 메서드나 속성에만 의존하면 되기 때문에 불필요한 구조 분해나 가공 없이 의도 중심의 코드를 작성할 수 있다.
function CreatedAtLabel({ createdAt }: { createdAt: DateTimeData }) {
return <span>{createdAt.format("YYYY년 MM월 DD일")}</span>;
}
사용 측에서도 복잡한 변환 없이 도메인 객체를 그대로 넘겨주기만 하면 된다:
<CreatedAtLabel createdAt={interview.createdAt} />
이 구조의 가장 큰 장점은 컴포넌트가 데이터의 구조나 해석에 관여하지 않아도 된다는 점이다. CreatedAtLabel은 createdAt이 단순한 Date인지, 문자열인지, 어떤 형식인지에 대해 알 필요가 없다. 오직 DateTimeData 인터페이스에 정의된 .format() 메서드만 신뢰하면 된다. 결과적으로 props는 단순 데이터가 아니라 도메인 규칙을 내포한 추상화 객체가 되며, 컴포넌트는 표현에만 집중할 수 있는 순수한 뷰 레이어로 유지된다.
이러한 방식은 도메인 주도 설계(DDD)의 원칙을 프론트엔드 컴포넌트 설계에 적용하는 좋은 예시이며, 시간 포매팅, 통화 처리, 상태 판단 등 다양한 도메인 표현 로직에도 동일한 패턴을 적용할 수 있다.
Conclusion: Let the Object Speak for Itself
객체지향 설계의 핵심은 객체가 자신의 상태를 스스로 판단하고, 외부의 요청에 의미 있게 응답하는 구조를 만드는 데 있다. interview.createdAt은 단순한 날짜 데이터처럼 보이지만, 사실은 행동하는 객체이다. format() 메서드를 호출하면 이 객체는 스스로 어떤 시간대에서 어떤 형식으로 표현되어야 하는지를 알고, 그에 맞는 결과를 반환한다. 이러한 방식은 각 컴포넌트에서 중복된 포맷팅 로직을 작성할 필요를 없애고, 표현 로직을 도메인 내부로 통합함으로써 코드의 응집도와 명확성을 동시에 높여준다.
더 나아가, 도메인 모델인 Interview, DateTime과 같은 객체들이 React 컴포넌트 내부에서 직접 활용됨으로써 데이터는 그 자체로 의미 있는 행위의 주체가 된다. 단순히 API 응답을 보여주는 수준을 넘어, 인터뷰라는 개념을 코드로 자연스럽게 표현할 수 있게 되는 것이다. 이는 프론트엔드에서도 도메인 주도 설계(DDD)의 원칙을 실현할 수 있다는 것을 보여주는 사례이며, 결과적으로 코드의 유지보수성과 확장성을 크게 향상시킨다.
이번 프로젝트는 클래스와 인스턴스를 넘어, 객체가 스스로의 책임을 수행하고 서로 협력하는 구조를 프론트엔드 개발에 실질적으로 녹여내기 위한 시도였다. 각 객체가 자신의 역할에 충실하고, 다른 객체들과 명확한 메시지를 주고받으며, 시스템 전체가 유기적으로 작동하는 구조. 나는 이것이 객체지향의 본질이라 믿고 있으며, 앞으로도 이러한 철학을 기반으로 더 나은 설계를 계속 탐구해나가고자 한다.
블로그의 정보
Ayden's journal
Beard Weard Ayden