
이 글은 TACO에서 프론트엔드 개발자로 새 프로젝트를 진행하며, 팀 프로젝트에서 사용할 API Client SDK 구현과 배포 경험을 정리한 글이다. 정확한 포지션은 프론트엔드 개발자이지만 평소 기본적인 백엔드 지식과 DRF로 간단한 백엔드 토이프로젝트 경험도 있어서 백엔드 코드를 시간 날 때 어떻게 작성하나 구경하곤 한다. 아무튼 전에 써본 API Client SDK가 프론트 개발에 큰 편리함을 제공해줬어서, 이번 프로젝트에서 내가 의견을 주도적으로 의견을 내보았다.
가장 큰 이유는 우리가 TypeScript를 채택했다는 것에서 시작되었다. TypeScript를 사용해 코드를 작성하는 만큼 any 같은 최상위 타입 사용을 하지 않고, 정확한 타이핑을 통해 타입 안정성을 가져가는 것을 목표로 했다. 그렇지만 지속되는 회의와 개발 프로세스를 거치며 특정 엔드포인드는 사라지기도, 다른 엔드포인트와 결합하기도, 새로 생기기도 했다. 이에 각 응답 DTO, 요청 파라미터도 바뀌었다. 프론트앤드 개발자로 인턴을 했을 때 팀에서 이런 SDK를 사용했을 때 이런 문제에 있어서 하나하나 문서를 찾아보지 않아도 쉽게 확인할 수 있다는 점이 너무 매력적이었다.

그리고 전에 인턴을 했을 때 처음으로 백엔드 SDK를 접했었는데, 이때 SDK를 사용했을 때 프론트에서fetch나 axios 사용 없이 직접 API를 호출하는데 이게 정말 기존 방식보다 직관적이며, 코드 에디터 자체에서 응답 DTO나 요청 파라미터를 쉽게 확인할 수 있는 것도 너무 좋았다. (그래서 SDK를 구현하자고 적극적으로 내가 어필하였다) 물론 프론트에서도 유틸 함수로 만들어 사용하면 구현이 가능하지만, status code, error 처리 방식에 대해서 공통적으로 대응할 수 있는 것도 좋은 점이라고 생각을 한다.
// fetch 방식
export const userApi = {
async getUser(params: GetUserParams): Promise<UserDTO> {
const res = await fetch(`/api/users/${params.userId}`);
if (!res.ok) {
const error = await res.json();
throw new ApiError(res.status, error.code, error.message);
}
return res.json();
},
};
// SDK 방식
const user = await api.user.getUser({ userId: "..." });
개발을 하면 SDK(Software Development Kit)라는 용어를 많이 접한다. 그냥 말 그대로 특정 서비스를 개발할 때 필요한 도구와 자료를 모아놓은 패키지이다. API, 문서, 샘플 코드 등 다양한 것들이 포함되어있다. 하나의 도구 상자라고 볼 수 있다. 사실 이 글에서 말하는 SDK는 ‘서비스 API를 타입 안전하게 호출하기 위한 클라이언트 라이브러리(API Client SDK)’에 가깝다.
SDK 자체가 이미 검증된 코드와 이에 맞는 가이드 문서를 포함하고 있어서 안정성과 개발 효율을 향상 시켜줄 수 있다. SDK는 사실 API부분을 넘어 더 다양한 툴을 제공하기 때문에 자세한 내용은 AWS 문서를 참고하면 좋을 것 같다.
sdk/
├─ src/
│ ├─ endpoints/ => 실제 API 엔드포인트를 감싼 SDK 함수
│ │ ├─ health.ts
│ │ └─ me.ts
│ │
│ ├─ types/ => SDK에서 외부에 제공하는 API DTO 타입
│ │ └─ me.ts
│ │
│ ├─ client.ts => SDK 진입점
│ ├─ config.ts => SDK 전역에서 사용하는 설정 값 관리
│ ├─ http-builder.ts => http요청을 통제하는 중앙 처리 (fetch 래핑, headers/credentials/query 관리, 응답 파싱)
│ └─ index.ts => SDK에서 외부에 제공할 것을만 export
│
├─ package.json
├─ tsconfig.json
└─ README.md
우선 RequestBuilder를 따로 분리한 이유는 HTTP 전송 로직 중복을 제거하기 위해서이다. 모든 API에서 공통적으로 필요한 것들을 반복해서 작성하는 것보다는 공통된 로직을 유틸리티 성격의 레이어로 한번 정의하여 SDK에서 API 호출 동작과 타입 시스템을 표준화하여 일관되게 유지할 수 있다.
또한 엔드포인트 코드의 단순화도 있는데 "어떻게 요청을 보내는지"가 아니라 "어떤 API를 호출할 것인가"에 포커스를 두었다. 따라서 http-builder.ts에서는 HTTP 로직의 중앙화를 책임지고 있다고 볼 수 있다.
src/http-builder.ts
export type HttpResponse<T> =
| { isSuccess: true; data: T; statusCode: number }
| {
isSuccess: false;
error: { statusCode: number; message: string; body?: unknown };
};
export class RequestBuilder {
constructor(private opts: BuilderOptions) {}
path(p: string): RequestBuilder {
return new RequestBuilder({ ...this.opts, path: p });
}
async get<T>(): Promise<HttpResponse<T>> {
return this.send<T>("GET");
}
private async send<T>(method: string): Promise<HttpResponse<T>> {
const res = await fetch(/* ... */);
if (!res.ok) {
return {
isSuccess: false,
error: {
statusCode: res.status,
message: res.statusText,
},
};
}
return {
isSuccess: true,
statusCode: res.status,
data: (await res.json()) as T,
};
}
}
다음은 서버 상태를 반환하는 간단한 엔드포인트의 일부분이다.
// src/endpoints/health.ts
import { RequestBuilder, type HttpResponse } from '../http-builder.js';
export interface HealthResponse {
ok: boolean;
}
export class HealthApi {
constructor(private rb: RequestBuilder) {}
// 여기 아래 있는 주석의 내용이 프론트 단에서 해당 API에 대해서 확인할 수 있는 내용
/**
* 서버의 헬스 상태를 확인합니다.
* @returns 헬스 체크 결과
* @example
* const response = await client.health.get();
* console.log(response.data);
* // Output:
* {
* ok: true
* }
*/
get(): Promise<HttpResponse<HealthResponse>> {
return this.rb.path('/healthz').get<HealthResponse>();
}
}
마지막으로 index.ts 부분인데 SDK 내부에는 다양한 구현 세부사항이 존재하지만, 이 파일을 통해 SDK 사용자가 접근할 수 있는 기능과 타입만을 명시적으로 노출할 수 있다. (SDK를 사용하는 입장에서는 굳이 내부 파일 구조를 알 필요가 없다. 또한 이 파일에서 export되지 않은 요소는 SDK 내부 구현으로 사용자에게 직접적으로 제공되지 않아 책임 범위를 명확하게 할 수 있다)
// Barrel exports: 공개 API만 노출
export { createGraphNodeClient, GraphNodeClient } from './client.js';
// Endpoints
export { HealthApi } from './endpoints/health.js';
// Types
export type { MeResponseDto, UserProfileDto } from './types/me.js';
cd sdk_dir
npm init -y # package.json 생성
tsconfig.json에서 NodeNext 대신 ESNext+Bundler를 사용할 수 있는데 이 경우 별도의 tsup 의존성 설치가 필요하다.
# tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "NodeNext", // ESNext
"moduleResolution": "NodeNext", // Bundler
"declaration": true,
"declarationMap": true,
"emitDeclarationOnly": false,
"outDir": "dist",
"strict": true,
"skipLibCheck": true
},
"include": ["src"]
}
# package.json
{
// ...
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.js"
}
},
"files": [
"dist",
"README.md"],
"sideEffects": false,
"scripts": {
"build": "tsc -p tsconfig.json",
"prepublishOnly": "npm run build"
},
"engines": {
"node": ">=18"
},
"license": "MIT",
"publishConfig": {
"access": "public"
},
"devDependencies": {
"@types/node": "^24.10.0",
"typescript": "^5.9.3"
}
Typescript를 사용하면서 프로젝트에서는 tsconfig.json파일을 필요로 한다. 이때 다양한 설정 옵션들이 있는데 여기서 다루는 옵션 2개를 간단하게 설명하면 아래와 같다.
tsconfig.json과 Module Resolution에 관한 다른 분이 쓴 글인데 좋은 것 같습니다.
module에는 여러가지 옵션이 있는데 여기서 다루는 옵션 2개를 간단하게 설명하면 아래와 같다.
export { HealthApi } from './endpoints/health.js';
즉 NodeNext를 사용하면 Node ESM 런타임 규칙에 맞게 모듈 import시 실제 파일 확장자를 명시해야한다. 번들러를 사용하는 경우, 이런 문제를 알아서 해결해주기 때문에 ESNext에서는 다음과 같이 사용할 수 있다.
export { HealthApi } from './endpoints/health';
NodeNext환경에서 위와 같이 사용한다면 런타임에서 모듈 로딩이 실패한다.
moduleResolution에도 여러가지 옵션이 있는데 여기서 다루는 옵션 2개를 간단하게 설명하면 아래와 같다.
import를 어떻게 찾을 것인지 방식을 정하는 것이다.npm에서는 dist를 포함하지만 git에서는 dist, js파일은 ignore해주면 된다.
npm run build
npm login
npm publish
NestJS나 Express.js처럼 백엔드도 TS를 사용한다면, API 요청/응답 DTO 타입을 SDK로 함께 배포하여 프론트에서도 그대로 사용할 수 있다. 이렇게 사용할 경우 프론트에서는 타입을 중복해서 정의할 필요 없으며, 프론트와 백엔드 간의 타입 불일치 가능성을 크게 줄일 수 있다.
그렇지만 프론트에서 화면 상태나 파생 데이터처럼 서버에는 존재하지 않는 데이터를 다루거나, UI 요구사항에 따라 DTO 타입을 그대로 사용하기 어려울 때가 있다. 이러한 경우 프론트 전용 도메인 타입을 별도로 정의하고, 백엔드에서 제공한 API DTO를 도메인 타입으로 변환해 사용하는 방식이 확장성과 유지보수 측면에서 더 적합하다.
// 서버 Type
export interface MeResponseDto {
user_id: string;
profile: UserProfileDto;
created_at: string;
}
// src/domain/me.model.ts
export interface Me {
id: string;
profile: UserProfile; // UserProfile Mapping 생략...
createdAt: Date;
expandType?: boolean; // optional
}
// src/mapper/me.mapper.ts
import { MeResponseDto} from "@taco_tsinghua/graphnode-sdk/types";
import { Me } from "@/src/domain/me.model.ts";
export function toModelMe(dto: MeResponseDTO): Me {
return {
id: dto.user_id,
profile: dto.profile,
createdAt: dto.created_at
? new Date(dto.created_at)
: new Date(Date.now()),
// expandType: false (optional)
};
}
전에 인턴을 했을 때는 백엔드를 NestJS를 사용해서 Nestia를 통해 SDK를 구현했었다. 따라서 Nestia를 통해 배포한 SDK쪽 코드를 봤을 때는 Nestia가 프레임워크 차원에서 자동화를 해준다.
Express.js는 개발자가 직접 설계를 해야하는 단점이 있지만, 대신 구현하는 것에 있어서 더 높은 자유도를 가질 수 있다. 예를 들자면 HttpError파일에서 throw로 예외 처리할 때가 있다. (http-builder부분도 마찬가지이다)
// NestJS에서 Nestia를 사용한 SDK에서의 HttpError 관리 파일
export { HttpError } from "@nestia/fetcher"; // 이거 한 줄로 끝
// express.js에서는 직접 표준 Error 클래스를 정의함
export class HttpError extends Error {
readonly status: number;
readonly body: unknown;
readonly headers: Headers;
constructor(
status: number,
body: unknown,
headers: Headers
) {
super(`HTTP ${status}`);
this.name = "HttpError";
this.status = status;
this.body = body;
this.headers = headers;
}
}