API는 서버에서 데이터를 가져오는 주소가 아니다

vx_developer·5일 전

개발하다가

목록 보기
25/30
post-thumbnail

프로그래밍을 처음 배울 때 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는 저장 구조보다 서비스가 허용하는 행동을 설명한다.


주소가 같아도 계약이 불분명하면 같은 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의 입력은 필드 목록이 아니라 허용된 의도의 범위다

작업 수정 API가 데이터 전체를 받는다고 생각해 보자.

type UpdateTaskRequest = {
  id: string;
  title: string;
  status: string;
  projectId: string;
  createdBy: string;
  createdAt: string;
};

클라이언트가 createdBycreatedAt까지 보낼 수 있으면 서버가 소유해야 할 값도 변경 대상으로 보인다.

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";
  };
};

이 응답은 작업 목록뿐 아니라 다음 페이지를 가져올 수 있는지와 어떤 필터가 적용되었는지도 전달한다.

클라이언트는 배열 길이를 근거로 서버 정책을 추측하지 않아도 된다.

그렇다고 가능한 모든 메타데이터를 응답에 넣어야 하는 것은 아니다.

소비자가 현재 기능을 정확히 구현하는 데 필요한 맥락만 안정적인 계약으로 제공해야 한다.


API는 서버 내부 구조를 감추고 안정적인 경계를 제공한다

처음에는 작업과 담당자를 하나의 데이터베이스에서 조회할 수 있다.

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 응답 모델을 별도로 두면 내부 저장 방식과 외부 약속을 독립적으로 발전시킬 수 있다.


이미 공개한 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 문서는 현재의 약속을 설명하고, 계약 테스트는 코드가 그 약속을 계속 지키는지 확인한다.


API 경계는 시스템 사이의 책임을 나눈다

Todo 생성 요청이 처리되는 흐름은 다음과 같이 볼 수 있다.

flowchart LR
    A[웹·모바일 클라이언트] --> B[API 계약]
    B --> C[인증과 입력 검증]
    C --> D[Todo 도메인 규칙]
    D --> E[데이터 저장]

클라이언트는 공개된 계약만 사용한다.

API 경계는 신원과 입력을 확인한다. 도메인 로직은 작업 생성과 완료 규칙을 판단한다. 저장 계층은 확정된 사실을 보존한다.

각 책임을 구분하면 API 핸들러가 모든 일을 직접 수행하는 구조를 피할 수 있다.

책임적합한 위치
요청 형식 파싱API 계층
인증된 사용자 확인인증 미들웨어 또는 API 계층
외부 입력 검증API 경계의 스키마 검증
작업 완료 가능 여부도메인 또는 서비스 로직
데이터 저장과 조회리포지토리
외부 응답 형태 변환API 응답 매퍼
공개 계약 설명API 문서와 스키마

API는 이 책임들을 한곳에 모으는 계층이 아니다.

외부 요청과 내부 기능이 만나는 경계를 정의하고 각 책임을 올바른 위치로 연결하는 계층이다.


API를 설계하기 전에 물어봐야 할 질문

기능과 의도가 공개되어 있는가

  1. 이 API는 소비자에게 어떤 기능을 제공하는가?
  2. 요청이 데이터 필드 변경이 아니라 사용자의 의도를 표현하는가?
  3. 하나의 요청이 하나의 일관된 업무 행동을 나타내는가?
  4. 서버의 테이블과 컬럼 구조가 불필요하게 노출되지는 않는가?
  5. 같은 기능을 웹, 모바일과 외부 연동이 동일하게 이해할 수 있는가?

입력과 출력 계약이 명확한가

  1. 필수 필드와 선택 필드가 구분되어 있는가?
  2. 날짜, ID, 상태와 금액의 형식과 의미가 설명되어 있는가?
  3. 클라이언트 타입뿐 아니라 서버 런타임 검증도 존재하는가?
  4. 응답에 기능 구현에 필요한 데이터와 맥락이 포함되어 있는가?
  5. 내부 데이터 모델을 응답으로 그대로 반환하고 있지는 않은가?

권한과 서비스 규칙이 경계에서 지켜지는가

  1. 현재 사용자의 신원을 신뢰할 수 있는 세션이나 토큰에서 가져오는가?
  2. 로그인 여부와 행동 권한을 별도로 확인하는가?
  3. UI에서 버튼을 숨기는 것에만 의존하고 있지는 않은가?
  4. 최신 서버 상태를 기준으로 업무 규칙을 다시 확인하는가?
  5. 외부 입력을 검증 전까지 신뢰하지 않는가?

실패가 다음 행동을 안내하는가

  1. 소비자가 실패 원인을 안정적인 코드로 구분할 수 있는가?
  2. 입력 수정, 로그인, 권한 부족과 일시적 실패가 구분되는가?
  3. 내부 예외나 민감한 정보가 응답에 노출되지 않는가?
  4. 재시도할 수 있는 실패인지 명확한가?
  5. 오류 계약도 성공 계약처럼 문서화되고 테스트되는가?

변경이 기존 소비자를 보호하는가

  1. 필드 제거와 의미 변경이 기존 앱을 깨뜨리지 않는가?
  2. 새 필드를 추가하는 방식으로 호환성을 유지할 수 있는가?
  3. 사용 중단 예정 기능의 이동 방법과 기간이 제공되는가?
  4. 어떤 소비자가 기존 계약을 사용하는지 확인할 수 있는가?
  5. 서버 내부 변경이 외부 계약으로 새어 나오지 않는가?

흔한 실수는 API를 원격 데이터베이스처럼 만든다

데이터베이스 엔티티를 그대로 반환한다

return database.task.findMany();

내부 필드와 관계가 공개 계약이 되고, 이후 저장 구조를 변경하기 어려워진다.

민감하거나 소비자에게 필요하지 않은 필드가 노출될 위험도 있다.

소비자가 필요한 응답 모델로 명시적으로 변환한다.


any로 요청을 받고 타입이 있다고 생각한다

async function createTask(input: any) {
  // ...
}

어떤 값이 허용되는지 알 수 없고 실행 중 잘못된 값도 차단하지 못한다.

요청 타입으로 개발 중 계약을 표현하고, 서버 스키마로 실제 입력을 검증한다.


인증된 사용자가 모든 리소스에 접근할 수 있게 한다

if (currentUser) {
  return taskRepository.delete(taskId);
}

로그인 여부만 확인하면 다른 프로젝트의 작업도 삭제할 수 있다.

현재 사용자가 해당 작업에 수행할 수 있는 행동을 리소스 단위로 확인한다.


모든 실패를 같은 메시지로 반환한다

return {
  error: "Request failed",
};

클라이언트가 수정, 로그인, 새로고침과 재시도 중 어떤 행동을 해야 하는지 판단할 수 없다.

소비자의 처리 방법이 달라지는 실패를 안정적인 코드로 구분한다.


서버를 배포하면 모든 클라이언트가 함께 바뀐다고 가정한다

delete response.assigneeName;

업데이트되지 않은 모바일 앱과 외부 연동이 즉시 실패할 수 있다.

호환 가능한 변경을 우선하고, 중단이 필요한 변경에는 이동 기간과 명확한 대체 계약을 제공한다.


문서를 예제 JSON 하나로 끝낸다

{
  "title": "Prepare sprint demo"
}

값의 제약, 필요한 권한, 실패 방식과 실행 결과를 알 수 없다.

API 문서는 데이터 모양뿐 아니라 기능의 조건과 의미를 설명해야 한다.


API의 핵심은 시스템 사이의 약속에 있다

API는 클라이언트가 서버에 요청을 보내고 데이터를 받게 해주는 접점이다.

const tasks = await fetch("/api/tasks")
  .then((response) => response.json());

이 정의는 API 사용의 기본 흐름을 이해하기에 충분하다.

하지만 실제 서비스에서 API를 설계할 때는 다음을 함께 결정해야 한다.

  • 소비자에게 어떤 기능과 행동을 공개하는가?
  • 요청과 응답의 구조와 의미는 무엇인가?
  • 어떤 입력을 허용하며 어디에서 검증하는가?
  • 인증된 사용자가 어떤 리소스에 어떤 행동을 할 수 있는가?
  • 서비스 규칙을 어떤 경계에서 적용하는가?
  • 소비자가 실패 이유를 어떻게 구분하고 복구하는가?
  • 내부 구현을 감추면서 안정적인 외부 모델을 어떻게 제공하는가?
  • 기존 소비자를 깨뜨리지 않고 계약을 어떻게 발전시키는가?
  • 문서와 테스트로 약속이 유지되는지 어떻게 확인하는가?

좋은 API는 데이터베이스를 원격으로 조작하게 하지 않는다.

서비스가 허용하는 기능을 도메인의 언어로 공개한다. 외부 입력을 검증하고, 신원과 권한을 확인하며, 성공과 실패를 소비자가 이해할 수 있는 형태로 전달한다.

서버 내부의 테이블, 라이브러리와 서비스 구조가 달라져도 공개된 계약은 가능한 한 안정적으로 유지한다.

변경이 필요하면 그 계약에 의존하는 소비자가 이동할 시간과 방법까지 설계한다.

결국 API를 설계한다는 것은 다음 질문에 답하는 일이다.

이 시스템은 외부에 어떤 기능과 규칙을 약속하며, 그 약속을 변경 속에서도 어떻게 지킬 것인가?

API는 서버에서 데이터를 가져오는 주소가 아니다.

서로 다른 시스템에 기능과 규칙, 실패 방식과 변경 약속을 공개하는 인터페이스다.

profile
Vision eXperience Developer

0개의 댓글