웹 개발을 처음 배울 때 요청과 응답은 보통 “클라이언트가 서버에 요청을 보내면 서버가 결과를 응답한다”라고 설명한다.
const response = await fetch("/api/tasks");
const tasks = await response.json();
클라이언트가 작업 목록을 요청하고 서버가 JSON 데이터를 반환한다. 웹이 어떤 방향으로 통신하는지 이해하기에는 충분한 설명이다.
하지만 실제 서비스에서는 요청을 보냈고 응답을 받았다는 사실만으로 기능이 완성되지 않는다.
200을 반환했다면 업무도 성공한 것인가?요청과 응답은 단순히 데이터를 왕복시키는 봉투가 아니다. 서로 다른 시스템이 한 번의 상호작용을 같은 의미로 이해하도록 만드는 메시지다.
실제 서비스에서 요청은 사용자가 원하는 변화와 그 판단에 필요한 맥락을 전달하는 메시지이며, 응답은 서버가 판단한 결과와 클라이언트의 다음 행동을 전달하는 메시지다.
협업 Todo 서비스에서 Chris가 작업 하나를 완료하려고 한다.
데이터 변경만 생각하면 다음과 같은 요청을 만들 수 있다.
await fetch("/api/tasks/task_42", {
method: "PATCH",
body: JSON.stringify({
status: "completed",
}),
});
이 요청은 status 값을 바꾸고 싶다는 사실은 보여준다. 그러나 사용자가 왜 그 값을 바꾸는지, 서버가 어떤 규칙을 적용해야 하는지는 드러내지 않는다.
작업 완료는 단순한 문자열 변경이 아닐 수 있다.
사용자의 의도를 직접 표현하면 서버가 적용할 규칙도 분명해진다.
await fetch("/api/tasks/task_42/complete", {
method: "POST",
});
이 요청은 “상태 컬럼을 바꿔 달라”가 아니라 “이 작업을 완료해 달라”는 의미를 전달한다.
서버는 현재 작업 상태와 요청자의 권한을 확인한 뒤 완료가 가능한지 판단할 수 있다.
좋은 요청은 서버의 저장 구조를 원격으로 조작하지 않는다. 사용자가 서비스에서 수행하려는 행동을 표현한다.
새 작업을 만드는 요청을 생각해 보자.
await fetch("/api/tasks", {
method: "POST",
body: JSON.stringify({
title: "Prepare sprint demo",
status: "open",
createdBy: "user_chris",
createdAt: new Date().toISOString(),
}),
});
모든 값을 클라이언트가 보내면 편해 보인다. 하지만 status, createdBy, createdAt은 서버가 결정하거나 신뢰할 수 있는 정보에서 가져와야 한다.
클라이언트가 보내야 하는 것은 사용자의 의도를 판단하는 데 필요한 입력이다.
type CreateTaskRequest = {
title: string;
assigneeId?: string;
dueAt?: string;
};
await fetch("/api/tasks", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
title: "Prepare sprint demo",
dueAt: "2026-09-18T07:00:00.000Z",
} satisfies CreateTaskRequest),
});
작성자는 인증된 세션에서 확인할 수 있다. 생성 시각은 서버 시각을 사용하고, 초기 상태는 서비스 규칙으로 결정할 수 있다.
요청에서 필드를 줄이는 목적은 전송량을 조금 아끼는 데 있지 않다.
누가 어떤 값을 결정할 책임이 있는지 분명하게 만드는 데 있다.
| 값 | 결정할 책임 |
|---|---|
| 작업 제목 | 사용자 |
| 담당자 후보 | 사용자 요청 + 서버 권한 확인 |
| 작성자 | 인증된 세션 |
| 생성 시각 | 서버 |
| 초기 상태 | 서비스 규칙 |
| 작업 ID | 서버 또는 데이터 저장 계층 |
요청에는 사용자가 선택할 수 있는 값만 담고, 서버가 소유해야 하는 사실은 서버에서 만든다.
HTTP 요청은 하나의 문자열이나 JSON 객체가 아니다.
await fetch("/api/projects/project_7/tasks", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": crypto.randomUUID(),
},
body: JSON.stringify({
title: "Prepare sprint demo",
}),
});
각 요소는 서로 다른 의미를 전달한다.
| 요소 | 답하는 질문 |
|---|---|
| URL | 어떤 리소스나 기능을 대상으로 하는가? |
| 메서드 | 어떤 종류의 행동을 요청하는가? |
| 헤더 | 요청을 어떻게 해석하고 처리해야 하는가? |
| 본문 | 행동을 수행하는 데 어떤 입력이 필요한가? |
| 인증 정보 | 누가 요청했는가? |
모든 값을 본문에 넣을 수도 있다.
{
"action": "create",
"resource": "task",
"projectId": "project_7",
"contentType": "json",
"userId": "user_chris"
}
그러나 이렇게 하면 전송 규약, 대상, 사용자 신원과 업무 입력의 경계가 흐려진다.
요청의 각 부분을 구분하면 서버는 공통 처리와 업무 처리를 나눌 수 있다. 인증 미들웨어는 신원을 확인하고, 요청 파서는 형식을 해석하며, 서비스 로직은 실제 작업 생성 규칙을 판단한다.
요청 형식은 단순한 문법이 아니다. 정보를 어느 책임에 배치할지 결정한 결과다.
프론트엔드에 타입을 정의했다고 생각해 보자.
type CreateTaskRequest = {
title: string;
dueAt?: string;
};
이 타입은 개발자가 올바른 요청을 작성하도록 돕는다. 그러나 실제 네트워크 요청을 보장하지는 않는다.
사용자는 개발자 도구나 별도의 프로그램으로 요청을 직접 만들 수 있다. 오래된 앱이 이전 형식의 요청을 보낼 수도 있고, 네트워크 중간의 데이터가 손상될 수도 있다.
서버는 도착한 값을 다시 검증해야 한다.
const createTaskSchema = z.object({
title: z.string().trim().min(1).max(120),
dueAt: z.string().datetime().optional(),
});
const parsed = createTaskSchema.safeParse(
await request.json()
);
if (!parsed.success) {
return Response.json(
{
ok: false,
code: "INVALID_TASK_INPUT",
fields: parsed.error.flatten().fieldErrors,
},
{ status: 400 }
);
}
클라이언트 검증은 빠른 피드백을 제공한다. 서버 검증은 시스템의 규칙과 데이터를 보호한다.
두 검증은 중복이 아니라 목적이 다르다.
요청은 우리 화면에서 출발했더라도 서버의 신뢰 경계를 통과하기 전까지 외부 입력이다.
Chris가 작업 생성 버튼을 눌렀지만 응답을 받기 전에 네트워크가 끊겼다고 생각해 보자.
클라이언트는 작업이 생성되지 않았다고 판단하고 같은 요청을 다시 보낼 수 있다. 그러나 첫 번째 요청이 이미 서버에서 처리되었다면 작업이 두 개 생긴다.
await createTask({
title: "Prepare sprint demo",
});
await createTask({
title: "Prepare sprint demo",
});
요청의 재전송은 예외적인 상황이 아니다. 모바일 네트워크, 프록시, 타임아웃과 사용자의 연속 클릭 때문에 언제든 발생할 수 있다.
중복 수행이 위험한 기능은 요청을 식별할 수 있어야 한다.
const idempotencyKey = crypto.randomUUID();
await fetch("/api/tasks", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify({
title: "Prepare sprint demo",
}),
});
서버는 같은 키로 이미 처리한 요청이 있는지 확인한다.
const previous =
await requestRepository.findByKey(
currentUser.id,
idempotencyKey
);
if (previous) {
return Response.json(previous.body, {
status: previous.status,
});
}
같은 요청을 여러 번 받아도 효과가 한 번만 발생하도록 만드는 성질을 멱등성이라고 한다.
모든 요청에 멱등성 키가 필요한 것은 아니다. 목록 조회처럼 반복해도 상태가 바뀌지 않는 요청은 보통 자연스럽게 다시 보낼 수 있다.
결제, 주문, 작업 생성처럼 중복 실행이 실제 문제를 만드는 기능은 재전송을 정상 흐름으로 보고 설계해야 한다.
클라이언트가 응답을 받지 못했다는 사실은 서버가 요청을 처리하지 않았다는 뜻이 아니다.
작업 생성 후 데이터베이스 엔티티를 그대로 반환할 수 있다.
return Response.json(taskRow);
그러나 클라이언트가 필요한 것은 내부 행 전체가 아니다.
요청이 성공했는지, 어떤 작업이 만들어졌는지, 화면을 어떤 상태로 갱신해야 하는지가 필요하다.
type CreateTaskResponse = {
ok: true;
task: {
id: string;
title: string;
status: "open";
assignee: {
id: string;
displayName: string;
} | null;
dueAt: string | null;
createdAt: string;
};
};
응답 모델은 저장 구조가 아니라 소비자의 다음 행동을 기준으로 만든다.
클라이언트는 반환된 작업을 목록에 추가하거나 상세 화면으로 이동할 수 있다. 서버가 정규화한 제목, 확정한 초기 상태와 생성한 ID도 즉시 반영할 수 있다.
응답에 데이터가 많을수록 친절한 것은 아니다. 내부 필드가 불필요하게 노출되고, 클라이언트가 우연히 그 구조에 의존할 수 있다.
좋은 응답은 처리 결과를 이해하고 다음 상태로 이동하는 데 필요한 사실을 전달한다.
서버에서 JSON을 받았다고 해서 요청한 행동이 성공한 것은 아니다.
const response = await fetch(
"/api/tasks/task_42/complete",
{ method: "POST" }
);
네트워크 연결은 성공했지만 서버가 작업 완료를 거절할 수 있다.
HTTP 상태와 응답 본문은 서로 보완해야 한다.
return Response.json(
{
ok: false,
code: "TASK_REQUIRES_APPROVAL",
message: "A reviewer must approve this task first.",
},
{ status: 409 }
);
HTTP 상태는 응답의 큰 범주를 전달한다. 업무 오류 코드는 서비스 안에서 무엇이 실패했는지 구체화한다.
if (response.status === 401) {
redirectToLogin();
}
const result = await response.json();
if (
!result.ok &&
result.code === "TASK_REQUIRES_APPROVAL"
) {
showApprovalRequiredMessage();
}
200, 400, 500만으로 모든 의미를 표현하려 하면 클라이언트가 메시지 문자열을 해석하게 된다.
반대로 모든 업무 상태를 HTTP 코드만으로 표현하려 하면 도메인의 구체적인 차이를 담기 어렵다.
전송 규약의 상태와 서비스의 결과를 함께 사용해야 한다.
다음 응답은 서버에서 문제가 발생했다는 사실만 알려준다.
{
"error": "Something went wrong"
}
클라이언트는 입력을 고쳐야 하는지, 로그인해야 하는지, 최신 데이터를 다시 불러와야 하는지 알 수 없다.
실패를 클라이언트의 대응 방식에 따라 구분할 수 있다.
type CompleteTaskResult =
| {
ok: true;
task: TaskSummary;
}
| {
ok: false;
code: "UNAUTHENTICATED";
}
| {
ok: false;
code: "FORBIDDEN";
}
| {
ok: false;
code: "TASK_NOT_FOUND";
}
| {
ok: false;
code: "TASK_ALREADY_CHANGED";
currentTask: TaskSummary;
}
| {
ok: false;
code: "TEMPORARILY_UNAVAILABLE";
retryAfterSeconds: number;
};
| 오류 코드 | 클라이언트의 다음 행동 |
|---|---|
UNAUTHENTICATED | 로그인 화면으로 이동한다 |
FORBIDDEN | 권한이 없음을 안내한다 |
TASK_NOT_FOUND | 목록에서 제거하거나 다시 조회한다 |
TASK_ALREADY_CHANGED | 최신 상태를 보여주고 사용자에게 확인받는다 |
TEMPORARILY_UNAVAILABLE | 안내된 시간 이후 재시도한다 |
내부 데이터베이스 오류나 스택 트레이스를 그대로 보내서는 안 된다. 클라이언트가 해결할 수 없고 시스템 구조나 민감한 정보만 노출할 수 있다.
실패 응답의 목적은 서버의 사정을 모두 설명하는 것이 아니다. 소비자가 안전한 다음 행동을 선택하도록 돕는 것이다.
Chris와 Victoria가 같은 작업을 동시에 수정한다고 생각해 보자.
Chris의 화면에는 작업 상태가 open으로 보인다. 그러나 요청이 서버에 도착하기 직전에 Victoria가 작업을 보관했을 수 있다.
await fetch("/api/tasks/task_42/complete", {
method: "POST",
});
서버는 클라이언트 화면에 남아 있는 과거 상태가 아니라 현재 저장된 상태를 기준으로 판단해야 한다.
const task = await taskRepository.findById(taskId);
if (task.status === "archived") {
return {
ok: false,
code: "TASK_ALREADY_CHANGED",
currentTask: toTaskSummary(task),
};
}
응답에는 서버가 최종적으로 확인하거나 변경한 사실을 담는다.
return {
ok: true,
task: {
id: task.id,
status: task.status,
completedAt: task.completedAt?.toISOString(),
version: task.version,
},
};
클라이언트는 자신이 보낸 값을 그대로 화면에 믿고 표시하기보다 서버가 확정한 결과와 동기화할 수 있다.
요청은 사용자의 희망을 전달한다. 응답은 서버가 현재 상태와 규칙을 기준으로 확정한 사실을 전달한다.
작업 목록을 CSV로 내보내는 기능은 데이터가 많으면 몇 분이 걸릴 수 있다.
HTTP 연결을 계속 유지하며 기다리게 하면 타임아웃과 재시도 문제가 커진다.
서버는 요청을 접수한 뒤 처리 상태를 반환할 수 있다.
return Response.json(
{
ok: true,
job: {
id: "export_91",
status: "queued",
statusUrl: "/api/exports/export_91",
},
},
{ status: 202 }
);
클라이언트는 작업 상태를 조회한다.
const result = await fetch(
"/api/exports/export_91"
).then((response) => response.json());
if (result.job.status === "completed") {
showDownloadButton(result.job.downloadUrl);
}
이때 최초 응답은 내보내기가 끝났다는 뜻이 아니다. 서버가 요청을 받아 비동기 작업을 시작했다는 뜻이다.
응답은 항상 최종 데이터를 담아야 하는 것이 아니다. 현재까지 확정된 처리 상태와 이후 결과를 확인하는 방법을 정확히 알려주면 된다.
클라이언트가 10초 안에 응답을 받지 못해 타임아웃을 발생시켰다고 생각해 보자.
try {
await createTask(input);
} catch (error) {
showError("Task creation failed");
}
이 문구는 사실과 다를 수 있다.
클라이언트가 응답을 받지 못한 것뿐이며 서버에서는 작업이 생성되었을 수 있다.
따라서 네트워크 오류를 업무 실패로 즉시 단정하면 안 된다.
showWarning(
"We could not confirm the result. Checking the latest task list."
);
await refetchTasks();
중복 생성이 위험하다면 같은 멱등성 키로 재시도하거나 요청 상태를 조회해야 한다.
요청과 응답 사이에는 다음 세 상태가 존재할 수 있다.
세 번째 상태를 첫 번째 상태와 같게 다루면 중복 실행이나 잘못된 오류 안내가 발생한다.
분산된 시스템에서 “응답을 받지 못했다”는 것은 “실패했다”가 아니라 “결과를 아직 모른다”일 수 있다.
사용자가 오류를 신고했는데 로그에는 수많은 작업 요청이 섞여 있을 수 있다.
요청마다 추적 ID를 부여하면 한 번의 흐름을 연결해서 볼 수 있다.
const requestId =
request.headers.get("X-Request-Id") ??
crypto.randomUUID();
logger.info({
requestId,
route: "/api/tasks",
userId: currentUser.id,
});
응답에도 같은 식별자를 담을 수 있다.
return Response.json(result, {
headers: {
"X-Request-Id": requestId,
},
});
추적 ID는 오류 내용을 사용자에게 전부 공개하지 않으면서도 운영자가 관련 로그를 찾게 돕는다.
다만 추적을 위해 요청 본문 전체를 무조건 기록해서는 안 된다. 비밀번호, 토큰, 개인정보와 같은 값은 제외하거나 마스킹해야 한다.
관측 가능성도 요청과 응답 설계의 일부다. 문제가 생긴 상호작용을 안전하게 찾을 수 있어야 운영 중 약속을 확인할 수 있다.
작업 완료 요청의 전체 흐름은 다음과 같다.
flowchart TD
A[사용자 행동] --> B[요청 생성]
B --> C[인증과 입력 검증]
C --> D[서비스 규칙 판단]
D --> E[상태 변경]
E --> F[결과 응답]
F --> G[화면과 로컬 상태 갱신]
각 단계는 다른 책임을 가진다.
| 단계 | 핵심 책임 |
|---|---|
| 사용자 행동 | 무엇을 하려는지 결정한다 |
| 요청 생성 | 의도와 필요한 입력을 전송 가능한 형태로 만든다 |
| 인증과 검증 | 신원과 외부 입력을 확인한다 |
| 서비스 규칙 판단 | 현재 상태에서 행동이 가능한지 결정한다 |
| 상태 변경 | 확정된 결과를 저장한다 |
| 결과 응답 | 성공, 실패와 현재 사실을 전달한다 |
| 클라이언트 갱신 | 응답을 근거로 다음 화면과 행동을 선택한다 |
프론트엔드가 업무 성공을 미리 결정해서도 안 되고, API 핸들러가 화면의 다음 동작까지 결정해서도 안 된다.
요청과 응답은 각 책임을 하나로 섞는 구조가 아니다. 서로 다른 책임이 같은 상호작용의 의미를 이어받도록 만드는 경계다.
{
"action": "complete",
"taskId": "task_42",
"userId": "user_chris",
"completedAt": "2026-09-14T01:00:00.000Z"
}
대상, 신원, 사용자 입력과 서버가 결정할 사실의 경계가 사라진다.
각 값의 책임에 따라 URL, 인증 정보, 본문과 서버 로직으로 나눈다.
const input = body as CreateTaskRequest;
타입 단언은 실제 값을 검사하지 않는다.
서버의 신뢰 경계에서 런타임 스키마로 형식과 허용 범위를 검증한다.
catch {
await createTask(input);
}
첫 요청이 이미 처리되었다면 중복 데이터가 생길 수 있다.
멱등성 키를 재사용하거나 최신 상태를 조회해 결과를 확인한다.
return new Response(null, { status: 200 });
클라이언트는 서버가 확정한 ID, 상태와 시각을 알 수 없어 별도 조회가 필요해질 수 있다.
다음 화면을 정확히 갱신하는 데 필요한 결과를 반환한다.
500으로 반환한다return Response.json(
{ error: "Failed" },
{ status: 500 }
);
잘못된 입력, 권한 부족, 상태 충돌과 서버 장애를 구분할 수 없다.
전송 상태와 안정적인 업무 오류 코드를 사용해 소비자의 대응 방식을 구분한다.
return Response.json(taskEntity);
내부 컬럼과 관계가 외부 의존성이 되고 민감한 값이 노출될 수 있다.
처리 결과와 다음 행동에 필요한 공개 응답 모델로 변환한다.
요청은 클라이언트가 서버에 데이터를 보내는 것이고, 응답은 서버가 결과를 돌려주는 것이다.
const response = await fetch("/api/tasks");
const tasks = await response.json();
이 정의는 웹 통신의 방향을 이해하기에 충분하다.
그러나 실제 서비스에서 요청과 응답을 설계할 때는 다음을 함께 결정해야 한다.
좋은 요청은 데이터베이스 변경 명령을 그대로 보내지 않는다. 사용자가 원하는 행동과 서버의 판단에 필요한 입력을 전달한다.
좋은 응답은 내부 데이터의 복사본을 무조건 반환하지 않는다. 서버가 현재 상태와 규칙을 기준으로 판단한 결과를 알려주고, 클라이언트가 다음 흐름을 안전하게 선택하게 한다.
요청을 보냈지만 응답을 받지 못할 수도 있고, 전송은 성공했지만 업무 규칙 때문에 행동은 거절될 수도 있다. 처리가 접수되었지만 아직 끝나지 않았을 수도 있다.
따라서 요청과 응답을 설계한다는 것은 JSON의 모양만 정하는 일이 아니다.
결국 다음 질문에 답하는 일이다.
사용자가 원하는 행동을 서버가 오해 없이 판단하고, 그 결과를 클라이언트가 다음 행동으로 이어갈 수 있게 하려면 무엇을 주고받아야 하는가?
요청과 응답은 데이터를 주고받는 형식이 아니다.
서로 다른 시스템이 하나의 상호작용을 같은 의미로 완성하기 위한 대화다.