피곤에 쩔은 모습으로 하나의 테스크를 마무리 하는 순간 백엔드 개발자가 다가옵니다.
"죄송하지만 이번에 만든 api 중에 하나의 리스폰스가 변동 될 것 같습니다."
"User로 정의한 스키마 중에 name이 빠질 것 같아요."
순간 머릿속에서 여기저기 대강 흩어져 있는 User 비스무리한 것들이 떠오릅니다.
"API 호출 부터 확인해서 하나 하나 찾아야 하나... 내가 안 빼먹고 깔끔하게 다 찾아서 수정할 수 있을까? 얼마나 오래 걸릴까?"
이런 생각들이 머릿속을 스쳐가면서 백엔드 개발자에게 이야기 합니다. "Pm이랑 이야기 되신거죠?(일정 늘어나는거 이야기 하고 온거죠?)"
Type을 구조적으로 설계하면 이런 상황을 좀 더 쉽게 풀 수 있지 않을까?
이전 글에서는 Type-Driven-Development를 소개하면서 타입을
설계 도구로 활용하는 방법에 대해 이야기했습니다.
이번 글에서는 Type 설계의 첫 단계로, 서버 API Type을 설계하는 방법을 다루고자 합니다.
프론트엔드는 서버로부터 전달받은 데이터를 기반으로 사용자 인터페이스를 구성합니다.
현대적인 웹 애플리케이션은 대부분 클라이언트-서버 아키텍처로 구성되어 있어, 프론트엔드 개발자는 백엔드 API가 정의한 스키마를 그대로 수용해야 하는 경우가 많습니다.
(만일 프론트 혼자서 서비스를 구성한다고 하면 엔티티나 dto로 정의 한 것이 하나의 타입으로써 사용 할 수 있습니다.)
이러한 구조에서 서버 API 스키마를 명확히 정의하는 것은 프론트엔드의 타입 시스템을 설계하는 것과 같습니다.
백엔드에서 정의한 API 응답의 구조, 데이터 타입, 필드 제약 조건 등이 프론트엔드의 타입 정의로 직접 연결되기 때문입니다.
서버의 API 스키마는 프론트엔드 타입 시스템의 기반이 되어, 클라이언트와 서버 간의 데이터 일관성과 타입 안정성을 보장합니다.
프론트엔드에서는 이렇게 정의된 서버 API 스키마를 바탕으로 타입을 정의하고, HTTP 클라이언트를 추상화한 클래스를 활용합니다.
이를 통해 API 호출 시 타입 안정성을 확보할 수 있을 뿐만 아니라, 컴파일 타임에 오류를 사전에 방지할 수 있습니다.
많은 프로젝트에서 서버 통신을 위한 HTTP 클라이언트를 다음과 같이 사용합니다.
// 문제점 1: 타입 안전성 부재
const response = await fetch('/api/users');
const users = await response.json(); // any 타입으로 추론되어 타입 안전성이 없음
// 문제점 2: 타입 단언 남용
const response = await fetch('/api/users');
const users = (await response.json()) as User[]; // 타입 단언 - 실제 데이터가 다르면 런타임 오류 발생
// 문제점 3: 타입 중복 정의
interface User {
id: string;
name: string;
}
// 파일 A에서
const getUsers = async () => {
const response = await fetch('/api/users');
return (await response.json()) as User[]; // User 타입으로 단언
};
// 파일 B에서 - 서로 다른 타입 정의
interface UserData {
// User와 다름
id: string;
name: string;
role: string; // 추가 필드 - User와 불일치 발생
}
const getUserData = async () => {
const response = await fetch('/api/users');
return (await response.json()) as UserData[]; // 동일한 API를 다른 타입으로 단언
};
이제 이런 문제들을 해결하는 Type-Safe한 HTTP 클래스 설계 방법을 알아보겠습니다.
HTTP 메서드에 제네릭을 적용하여 요청 본문과 응답 데이터의 타입을 명확히 정의합니다.
// 📁 packages/@package/core/src/http/Http.ts
post<T = any, D = any>(
url
:
string,
data ? : D, // 요청 데이터의 타입을 D로 명시
config ? : RequestConfig,
):
Promise < HttpResponse < T >> { // 응답 데이터의 타입을 T로 명시
return this.request<T, D>('POST', url, { ...config, data });
}
HTTP 응답을 표준화된 형식으로 정의하여 일관성을 유지합니다.
// 📁 packages/@package/core/src/http/Http.ts
interface HttpResponse<T = any> {
data: T; // 응답 데이터는 제네릭 타입 T로 정의
status: HttpStatusCode; // 상태 코드는 열거형으로 타입 안전성 확보
headers: Headers; // 응답 헤더 정보
}
data
)의 타입이 제네릭으로 명시되어 타입 안전성이 보장됩니다.이제 실제 코드를 통해 타입 안전한 HTTP 클래스가 어떻게 구현되는지 살펴보겠습니다.
// 📁 packages/@package/core/src/http/Http.ts
import { HttpStatusCode } from './HttpStatusCode';
// 요청 설정을 위한 인터페이스
interface RequestConfig<T = any> {
headers?: Record<string, string>; // 요청 헤더
params?: Record<string, string>; // 쿼리 파라미터
data?: T; // 요청 본문 데이터
}
// 응답 형식을 표준화한 인터페이스
interface HttpResponse<T = any> {
data: T; // 응답 데이터
status: HttpStatusCode; // HTTP 상태 코드
headers: Headers; // 응답 헤더
}
export class Http {
private readonly baseURL: string;
private readonly defaultHeaders: Record<string, string>;
constructor(baseURL: string, defaultHeaders: Record<string, string> = {}) {
this.baseURL = baseURL;
this.defaultHeaders = defaultHeaders;
}
// 모든 HTTP 요청의 기본 메서드
private async request<T = any, D = any>(
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
url: string,
config: RequestConfig<D> = {},
): Promise<HttpResponse<T>> {
const fullUrl = new URL(url, this.baseURL);
// 쿼리 파라미터 처리
if (config.params) {
Object.entries(config.params).forEach(([key, value]) =>
fullUrl.searchParams.append(key, value),
);
}
// fetch API 호출
const response = await fetch(fullUrl.toString(), {
method,
headers: {
'Content-Type': 'application/json', // 기본 Content-Type 설정
...this.defaultHeaders, // 기본 헤더 적용
...config.headers, // 요청별 헤더 적용 (우선순위 높음)
},
body: config.data ? JSON.stringify(config.data) : undefined, // 요청 본문 직렬화
});
const data = await response.json();
// 에러 처리
if (!response.ok) {
throw new Error(`HTTP Error: ${response.status} ${response.statusText}`);
}
// 표준화된 응답 형식으로 반환
return {
data,
status: response.status,
headers: response.headers,
};
}
// GET 메서드
get<T = any>(url: string, config?: RequestConfig): Promise<HttpResponse<T>> {
return this.request<T>('GET', url, config);
}
// POST 메서드
post<T = any, D = any>(
url: string,
data?: D,
config?: RequestConfig,
): Promise<HttpResponse<T>> {
return this.request<T, D>('POST', url, { ...config, data });
}
// PUT 메서드
put<T = any, D = any>(
url: string,
data?: D,
config?: RequestConfig,
): Promise<HttpResponse<T>> {
return this.request<T, D>('PUT', url, { ...config, data });
}
// DELETE 메서드
delete<T = any>(
url: string,
config?: RequestConfig,
): Promise<HttpResponse<T>> {
return this.request<T>('DELETE', url, config);
}
}
제네릭 타입 파라미터:
T
: 응답 데이터의 타입D
: 요청 본문의 타입타입 안전한 메서드 체인:
request
메서드를 호출하여 코드 중복을 방지합니다.에러 처리:
// 📁 apps/react/src/server/user/types.ts
import type { User } from '@/shared';
// 요청 타입: User 타입에서 id만 선택
export type UserReq = Pick<User, 'id'>;
// 응답 타입: 전체 User 객체
export type UserRes = User;
타입 정의의 특징:
도메인 모델 재사용:
User
)을 기반으로 API 요청/응답 타입을 정의합니다.Pick
, Omit
등의 유틸리티 타입을 활용하여 필요한 속성만 선택합니다.명확한 네이밍 컨벤션:
UserReq
: 요청 타입UserRes
: 응답 타입// 📁 apps/react/src/server/user/api.ts
import type { UserRes, UserReq } from './types';
import type { Http } from '@package/core';
import { instance } from '@/shared';
// 사용자 관련 API 인터페이스 정의
interface UserServer {
createUser: (user: UserReq) => Promise<UserRes>;
}
// 인터페이스 구현체
class UserServerImpl implements UserServer {
constructor(private api: Http) {} // Http 클라이언트 주입받음
// 사용자 생성 API 호출 메서드
async createUser(user: UserReq): Promise<UserRes> {
const response = await this.api.post<UserRes, UserReq>('/api/user', user);
return response.data; // 응답 데이터만 추출하여 반환
}
}
// 싱글톤 인스턴스 생성 및 내보내기
export const userServer = new UserServerImpl(instance);
UserServer
인터페이스를 통해 API 메서드를 명확히 정의합니다.UserServerImpl
)는 인터페이스를 준수합니다.response.data
를 반환하여 호출자가 HTTP 응답 구조를 알 필요가 없게 합니다.앞서 독자 선택 가이드에서 DI 기반 접근을 선택하셨다면, 지금쯤 "정말 이렇게까지 해야 하나?" 하는 생각이 드실 수 있습니다. 간단한 함수로도 충분히 가능하거든요.
// 간단한 함수형 접근
export const createUser = async (user: UserReq) => {
const response = await httpClient.post('/api/users', user);
return response.data;
};
하지만 실무에서는 다음과 같은 상황들을 반드시 마주하게 됩니다.
😱 환경별 설정 지옥
// 개발 환경에서 테스트하다가 실수로 프로덕션 API 호출
const API_URL = 'https://api.production.com'; // 이거 매번 바꿔야 해?
// DI 사용 시
const userServer = new UserServerImpl(envHttpClient); // 환경별 자동 주입
🧪 테스트 작성의 악몽
// 함수형: 전역 모킹으로 다른 테스트에 영향
jest.mock('../httpClient');
// DI: 깔끔한 격리 테스트
const userServer = new UserServerImpl(mockHttp);
🔄 API 변경 시 대응
// REST → GraphQL 변경 시에도 인터페이스는 동일
const userServer = new GraphQLUserServerImpl(graphqlClient);
이런 상황들은 어떤 프로젝트든 3개월 후에는 반드시 마주합니다. 미리 투자해두면 나중에 큰 고통을 피할 수 있습니다.
타입 안전한 HTTP 클래스 설계의 또 다른 장점은 테스트 용이성입니다.
// 📁 apps/react/src/server/user/user.api.test.ts
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
import type { UserReq, UserRes } from '@/server/user/types';
import { userServer } from '@/server/user/api';
// MSW 서버 설정 - API 요청을 가로채서 모의 응답 제공
const server = setupServer(
http.post<never, UserReq>(
'http://localhost/api/user',
async ({ request }) => {
const user = await request.json(); // 요청 본문 추출
// 모의 응답 생성 - 타입 안전하게 UserRes 형태로 반환
return HttpResponse.json<UserRes>({
id: user.id,
name: 'New User',
email: 'test@test.com',
createdAt: new Date(),
});
},
),
);
// 테스트 환경 설정
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe('UserServerImpl', () => {
it('사용자를 생성할 수 있다', async () => {
// 테스트용 요청 데이터 생성
const userReq: UserReq = { id: '1' };
// API 호출
const response = await userServer.createUser(userReq);
// 응답 검증
expect(response).toEqual({
id: userReq.id,
name: 'New User',
email: 'test@test.com',
createdAt: expect.stringMatching(
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/,
),
});
});
});
테스트의 특징:
MSW를 활용한 API 모킹:
타입 일관성:
UserReq
, UserRes
)을 사용합니다.Type-Safe HTTP 클래스 및 서버 API 설계는 단순히 타입스크립트의 기능을 활용하는 것을 넘어, API 통신의 안정성과 유지보수성을 크게 향상시킵니다. 제네릭,
인터페이스, 타입 유틸리티 등 타입스크립트의 강력한 기능을 활용하면, 컴파일 타임에 많은 잠재적 버그를 잡아낼 수 있습니다.이러한 설계 방식은 초기에 약간의 추가 작업이 필요하지만, 장기적으로는 개발 속도를 높이고 버그를 줄이는 데 큰 도움이 됩니다. 특히 팀 규모가 크거나 프로젝트가 복잡할수록 그
효과는 더욱 두드러집니다.다음 글에서는 이러한 Type-Safe HTTP 클래스 및 서버 API 설계를 기반으로, 서버와 클라이언트 간의 타입 공유 전략에 대해 더 자세히 알아보겠습니다.
오... 마침 최근 제네릭 타입을 공부하면서 어떻게 적용시켜볼까 고민했는데 좋은 사례를 알려주셔서 감사합니다!
단순한 활용법이 아니라 기존에 어떤 문제점을 해결하고, 테스트 용이성까지 고려한 점이 좋았어요!
저도 참고해서 꼭 적용해보도록 하겠습니다!
좋은 글 감사해요~