
프로그래밍을 처음 배울 때 API는 보통 “서버에서 데이터를 가져오는 주소”라고 배운다.
const response = await fetch("/api/tasks");
const tasks = await response.json();
이 설명은 프론트엔드가 URL로 요청을 보내고 서버가 JSON 데이터를 반환한다는 기본 흐름을 이해하기에 충분하다.
하지만 실제 서비스를 개발하면 주소와 데이터 형식만으로는 API를 설계할 수 없다.
URL은 API에 접근하는 한 가지 수단일 뿐이다. API의 핵심은 서로 다른 코드가 같은 기능과 규칙을 동일하게 이해하도록 만드는 데 있다.
실제 서비스에서 API는 데이터를 가져오는 주소가 아니라, 시스템이 제공하는 기능과 규칙을 외부에 공개하는 계약이다.
크리스가 팀 Todo 서비스에서 자신의 작업 목록을 조회한다고 생각해 보자.
const response = await fetch("/api/tasks");
const tasks = await response.json();
이 코드는 작업 데이터를 가져온다. 그러나 Todo 서비스가 제공해야 하는 것은 조회 기능만이 아니다.
API를 데이터 주소의 모음으로만 생각하면 서버의 데이터베이스 구조가 그대로 외부에 노출되기 쉽다.
await fetch("/api/update-task-row", {
method: "POST",
body: JSON.stringify({
table: "tasks",
id: "task_42",
column: "status",
value: "done",
}),
});
이 요청은 클라이언트가 테이블과 컬럼을 직접 변경하게 한다. 작업을 완료할 수 있는 조건이나 누가 변경할 수 있는지는 표현되지 않는다.
사용자의 의도를 기능으로 공개하는 편이 낫다.
await fetch("/api/tasks/task_42/complete", {
method: "POST",
});
이 요청은 “status 컬럼을 done으로 바꿔라”가 아니라 “이 작업을 완료하라”는 의도를 전달한다.
서버는 현재 상태, 사용자 권한과 완료 규칙을 확인한 뒤 필요한 데이터를 변경할 수 있다.
좋은 API는 저장 구조보다 서비스가 허용하는 행동을 설명한다.
다음 함수는 작업을 생성한다.
async function createTask(input: any) {
return fetch("/api/tasks", {
method: "POST",
body: JSON.stringify(input),
});
}
호출할 주소는 알 수 있지만 어떤 값을 보내야 하는지 알 수 없다.
제목이 필수인지, 담당자는 ID로 보내는지, 날짜는 어느 시간대를 사용하는지, 성공하면 무엇을 받는지도 코드에 드러나지 않는다.
입력과 출력을 명시하면 API의 경계가 선명해진다.
type CreateTaskRequest = {
title: string;
assigneeId?: string;
dueAt?: string;
};
type CreateTaskResponse = {
task: {
id: string;
title: string;
status: "open";
assignee: {
id: string;
displayName: string;
} | null;
dueAt: string | null;
createdAt: string;
};
};
이 타입은 클라이언트와 서버가 합의해야 할 최소한의 구조를 보여준다.
title은 필수이고 담당자와 마감일은 선택 사항이다. 생성된 작업은 서버가 부여한 ID와 생성 시각을 포함하며 처음 상태는 open이다.
async function createTask(
input: CreateTaskRequest
): Promise<CreateTaskResponse> {
const response = await fetch("/api/tasks", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(input),
});
return response.json();
}
이 함수의 호출자는 요청과 응답의 형태를 타입으로 확인할 수 있다.
그러나 TypeScript 타입은 실행 중 들어오는 값을 막지 못한다. 외부 입력은 검증 전까지 신뢰할 수 없다.
const parsed = createTaskSchema.safeParse(
await request.json()
);
if (!parsed.success) {
return {
ok: false,
code: "INVALID_TASK_INPUT",
details:
parsed.error.flatten().fieldErrors,
};
}
서버는 실제 요청을 스키마로 다시 검증한다.
정적 타입은 개발 중 계약을 이해하게 돕고, 런타임 검증은 신뢰 경계를 통과하는 실제 값을 보호한다.
작업 수정 API가 데이터 전체를 받는다고 생각해 보자.
type UpdateTaskRequest = {
id: string;
title: string;
status: string;
projectId: string;
createdBy: string;
createdAt: string;
};
클라이언트가 createdBy와 createdAt까지 보낼 수 있으면 서버가 소유해야 할 값도 변경 대상으로 보인다.
status에 어떤 문자열이 허용되는지도 알 수 없다.
사용자가 수행할 수 있는 행동에 필요한 값만 받는 편이 낫다.
type RenameTaskRequest = {
title: string;
};
type AssignTaskRequest = {
assigneeId: string | null;
};
type ChangeTaskDueDateRequest = {
dueAt: string | null;
};
이 계약들은 서로 다른 의도를 분리한다.
제목 변경, 담당자 지정과 마감일 변경은 검증 규칙과 필요한 권한이 다를 수 있다.
입력을 작게 만든다는 것은 필드를 무조건 하나씩 나눈다는 뜻이 아니다. 함께 변경되어야 하는 하나의 업무 행동을 기준으로 경계를 정한다는 뜻이다.
API 요청의 형태는 클라이언트가 서버 데이터를 얼마나 많이 아는지가 아니라, 어떤 행동을 요청할 권한이 있는지를 표현해야 한다.
작업 완료를 단순한 상태 변경으로 구현할 수 있다.
async function completeTask(taskId: string) {
return taskRepository.update(taskId, {
status: "completed",
});
}
하지만 실제 Todo 서비스에는 다음과 같은 규칙이 있을 수 있다.
API 계층은 요청을 내부의 서비스 기능으로 전달하고, 도메인 로직이 규칙을 적용하게 해야 한다.
async function completeTask(
taskId: string,
currentUserId: string
) {
const task = await taskRepository.findById(
taskId
);
if (!task) {
return {
ok: false,
code: "TASK_NOT_FOUND",
};
}
if (!task.canBeCompletedBy(currentUserId)) {
return {
ok: false,
code: "TASK_CANNOT_BE_COMPLETED",
};
}
const completedTask = task.complete({
completedBy: currentUserId,
completedAt: new Date(),
});
await taskRepository.save(completedTask);
return {
ok: true,
task: completedTask,
};
}
이 코드는 API가 공개한 “작업 완료” 기능을 서비스 규칙과 연결한다.
클라이언트는 내부 컬럼을 직접 조작하지 않고 허용된 행동을 요청한다. 서버는 현재 데이터와 사용자를 기준으로 실행 가능 여부를 판단한다.
API 문서에 요청과 응답 JSON만 적어서는 충분하지 않다.
기능을 사용할 수 있는 조건과 실행 후 달라지는 사실도 계약의 일부다.
클라이언트가 요청 본문으로 사용자 ID를 보낸다고 생각해 보자.
await fetch("/api/tasks/task_42/complete", {
method: "POST",
body: JSON.stringify({
userId: "user_chris",
}),
});
요청자가 원하는 사용자 ID를 직접 보낼 수 있다면 다른 사람인 것처럼 행동할 수 있다.
사용자 신원은 신뢰할 수 있는 세션이나 토큰에서 확인해야 한다.
const currentUser =
await requireUser(request);
const result = await completeTask(
params.taskId,
currentUser.id
);
이 코드는 인증된 세션에서 현재 사용자를 가져온다.
요청 본문의 주장보다 서버가 검증한 신원을 Source of Truth로 사용한다.
그다음에는 권한을 확인해야 한다.
const membership =
await projectRepository.findMembership({
projectId: task.projectId,
userId: currentUser.id,
});
if (!membership?.canCompleteTasks) {
return {
ok: false,
code: "FORBIDDEN",
};
}
로그인은 사용자가 누구인지 확인한다. 권한은 그 사용자가 현재 프로젝트의 작업을 완료할 수 있는지 결정한다.
API는 화면의 버튼이 숨겨져 있다는 사실을 보안 규칙으로 믿어서는 안 된다.
모든 요청은 UI를 거치지 않고도 만들어질 수 있으므로 서버의 기능 경계에서 권한을 확인해야 한다.
모든 오류를 같은 응답으로 반환할 수 있다.
return {
error: "Something went wrong",
};
이 메시지만으로는 클라이언트가 로그인 화면으로 이동해야 하는지, 입력을 수정해야 하는지, 작업 목록을 새로고침해야 하는지 판단할 수 없다.
클라이언트가 처리 방법을 구분할 수 있는 안정적인 오류 코드를 제공하는 편이 낫다.
type CompleteTaskError =
| {
ok: false;
code: "UNAUTHENTICATED";
}
| {
ok: false;
code: "FORBIDDEN";
}
| {
ok: false;
code: "TASK_NOT_FOUND";
}
| {
ok: false;
code: "TASK_ALREADY_ARCHIVED";
};
UNAUTHENTICATED이면 로그인이 필요하다.
FORBIDDEN이면 사용자는 로그인했지만 해당 행동의 권한이 없다.
TASK_NOT_FOUND이면 삭제되었거나 접근할 수 없는 작업일 수 있다.
TASK_ALREADY_ARCHIVED이면 목록을 갱신하고 현재 상태를 보여줄 수 있다.
API의 오류 계약은 내부 예외 이름을 그대로 외부에 노출하기 위한 구조가 아니다.
소비자가 안전하게 다음 흐름을 선택할 수 있도록 실패를 분류하는 구조다.
작업 목록을 배열 하나로 반환할 수 있다.
const tasks = await fetchTasks();
return tasks;
작은 목록에서는 충분해 보이지만 데이터가 늘어나면 현재 페이지, 다음 조회 가능 여부와 정렬 기준이 필요해진다.
type TaskListResponse = {
items: TaskSummary[];
pageInfo: {
nextCursor: string | null;
hasNextPage: boolean;
};
appliedFilter: {
status:
| "open"
| "completed"
| "all";
};
};
이 응답은 작업 목록뿐 아니라 다음 페이지를 가져올 수 있는지와 어떤 필터가 적용되었는지도 전달한다.
클라이언트는 배열 길이를 근거로 서버 정책을 추측하지 않아도 된다.
그렇다고 가능한 모든 메타데이터를 응답에 넣어야 하는 것은 아니다.
소비자가 현재 기능을 정확히 구현하는 데 필요한 맥락만 안정적인 계약으로 제공해야 한다.
처음에는 작업과 담당자를 하나의 데이터베이스에서 조회할 수 있다.
const task =
await database.task.findUnique({
where: { id: taskId },
include: { assignee: true },
});
나중에는 사용자 정보가 별도의 서비스로 이동할 수 있다.
const task =
await taskRepository.findById(taskId);
const assignee = task.assigneeId
? await memberService.getSummary(
task.assigneeId
)
: null;
서버 구현은 달라졌지만 외부 응답 계약을 유지할 수 있다.
return {
task: {
id: task.id,
title: task.title,
assignee,
status: task.status,
},
};
클라이언트는 데이터가 한 테이블에서 왔는지 여러 서비스에서 조합되었는지 알 필요가 없다.
API는 내부 변경을 숨기고 소비자에게 필요한 안정적인 모델을 제공한다.
데이터베이스 엔티티를 그대로 반환하면 내부 컬럼 이름 변경, 관계 분리와 민감 필드 추가가 곧 외부 계약 변경이 된다.
API 응답 모델을 별도로 두면 내부 저장 방식과 외부 약속을 독립적으로 발전시킬 수 있다.
작업 응답에서 assigneeName을 제거하고 assignee 객체로 바꾼다고 생각해 보자.
// 기존 응답
{
"assigneeName": "Chris"
}
// 변경된 응답
{
"assignee": {
"displayName": "Chris"
}
}
새 구조가 더 명확하더라도 기존 모바일 앱이나 외부 연동은 즉시 수정되지 않을 수 있다.
서버가 배포되는 순간 모든 소비자가 함께 배포된다고 가정할 수 없다.
호환 가능한 변경은 기존 필드를 유지한 채 새 필드를 추가하는 방식으로 진행할 수 있다.
return {
assigneeName:
assignee?.displayName ?? null,
assignee: assignee
? {
id: assignee.id,
displayName:
assignee.displayName,
}
: null,
};
기존 소비자는 assigneeName을 계속 사용하고 새로운 소비자는 assignee를 사용할 수 있다.
이전 필드는 사용 중단 예정임을 문서화하고 실제 사용량을 확인한 뒤 제거해야 한다.
호환성을 유지하기 어려운 변경이라면 새로운 버전이나 별도의 기능 경계를 제공할 수 있다.
중요한 것은 버전 번호 자체가 아니라 소비자가 이동할 시간과 방법을 제공하는 일이다.
API를 변경한다는 것은 서버 코드 한 곳을 수정하는 일이 아니다.
그 계약에 의존하는 다른 팀, 앱과 자동화의 변경 비용을 함께 다루는 일이다.
타입만 공유하면 같은 저장소 안의 TypeScript 클라이언트에는 도움이 된다.
그러나 모바일 앱, 외부 파트너와 다른 언어로 작성된 서비스는 그 타입을 직접 사용할 수 없다.
API 문서는 다음 내용을 설명해야 한다.
문서만 작성하고 실제 응답과 달라지면 계약으로 사용할 수 없다.
자동화된 테스트로 주요 약속을 확인할 수 있다.
it("returns the created task contract", async () => {
const response = await request(app)
.post("/api/tasks")
.send({
title: "Prepare sprint demo",
})
.expect(201);
expect(response.body).toMatchObject({
task: {
id: expect.any(String),
title: "Prepare sprint demo",
status: "open",
createdAt: expect.any(String),
},
});
});
이 테스트는 구현 함수의 내부 동작보다 소비자가 실제로 받는 응답을 확인한다.
리팩터링 이후에도 공개된 구조와 의미가 유지되는지 검증할 수 있다.
API 문서는 현재의 약속을 설명하고, 계약 테스트는 코드가 그 약속을 계속 지키는지 확인한다.
Todo 생성 요청이 처리되는 흐름은 다음과 같이 볼 수 있다.
flowchart LR
A[웹·모바일 클라이언트] --> B[API 계약]
B --> C[인증과 입력 검증]
C --> D[Todo 도메인 규칙]
D --> E[데이터 저장]
클라이언트는 공개된 계약만 사용한다.
API 경계는 신원과 입력을 확인한다. 도메인 로직은 작업 생성과 완료 규칙을 판단한다. 저장 계층은 확정된 사실을 보존한다.
각 책임을 구분하면 API 핸들러가 모든 일을 직접 수행하는 구조를 피할 수 있다.
| 책임 | 적합한 위치 |
|---|---|
| 요청 형식 파싱 | API 계층 |
| 인증된 사용자 확인 | 인증 미들웨어 또는 API 계층 |
| 외부 입력 검증 | API 경계의 스키마 검증 |
| 작업 완료 가능 여부 | 도메인 또는 서비스 로직 |
| 데이터 저장과 조회 | 리포지토리 |
| 외부 응답 형태 변환 | API 응답 매퍼 |
| 공개 계약 설명 | API 문서와 스키마 |
API는 이 책임들을 한곳에 모으는 계층이 아니다.
외부 요청과 내부 기능이 만나는 경계를 정의하고 각 책임을 올바른 위치로 연결하는 계층이다.
return database.task.findMany();
내부 필드와 관계가 공개 계약이 되고, 이후 저장 구조를 변경하기 어려워진다.
민감하거나 소비자에게 필요하지 않은 필드가 노출될 위험도 있다.
소비자가 필요한 응답 모델로 명시적으로 변환한다.
any로 요청을 받고 타입이 있다고 생각한다async function createTask(input: any) {
// ...
}
어떤 값이 허용되는지 알 수 없고 실행 중 잘못된 값도 차단하지 못한다.
요청 타입으로 개발 중 계약을 표현하고, 서버 스키마로 실제 입력을 검증한다.
if (currentUser) {
return taskRepository.delete(taskId);
}
로그인 여부만 확인하면 다른 프로젝트의 작업도 삭제할 수 있다.
현재 사용자가 해당 작업에 수행할 수 있는 행동을 리소스 단위로 확인한다.
return {
error: "Request failed",
};
클라이언트가 수정, 로그인, 새로고침과 재시도 중 어떤 행동을 해야 하는지 판단할 수 없다.
소비자의 처리 방법이 달라지는 실패를 안정적인 코드로 구분한다.
delete response.assigneeName;
업데이트되지 않은 모바일 앱과 외부 연동이 즉시 실패할 수 있다.
호환 가능한 변경을 우선하고, 중단이 필요한 변경에는 이동 기간과 명확한 대체 계약을 제공한다.
{
"title": "Prepare sprint demo"
}
값의 제약, 필요한 권한, 실패 방식과 실행 결과를 알 수 없다.
API 문서는 데이터 모양뿐 아니라 기능의 조건과 의미를 설명해야 한다.
API는 클라이언트가 서버에 요청을 보내고 데이터를 받게 해주는 접점이다.
const tasks = await fetch("/api/tasks")
.then((response) => response.json());
이 정의는 API 사용의 기본 흐름을 이해하기에 충분하다.
하지만 실제 서비스에서 API를 설계할 때는 다음을 함께 결정해야 한다.
좋은 API는 데이터베이스를 원격으로 조작하게 하지 않는다.
서비스가 허용하는 기능을 도메인의 언어로 공개한다. 외부 입력을 검증하고, 신원과 권한을 확인하며, 성공과 실패를 소비자가 이해할 수 있는 형태로 전달한다.
서버 내부의 테이블, 라이브러리와 서비스 구조가 달라져도 공개된 계약은 가능한 한 안정적으로 유지한다.
변경이 필요하면 그 계약에 의존하는 소비자가 이동할 시간과 방법까지 설계한다.
결국 API를 설계한다는 것은 다음 질문에 답하는 일이다.
이 시스템은 외부에 어떤 기능과 규칙을 약속하며, 그 약속을 변경 속에서도 어떻게 지킬 것인가?
API는 서버에서 데이터를 가져오는 주소가 아니다.
서로 다른 시스템에 기능과 규칙, 실패 방식과 변경 약속을 공개하는 인터페이스다.