프론트엔드와 백엔드는 여러 인터페이스 레이어를 통해 연결된다.
프론트엔드에서 API를 다루다 보면 타입을 어떻게 관리해야 할지 애매한 순간이 많다.
백엔드에서 내려주는 응답 타입을 그대로 써야 하는지, 프론트엔드에서 사용하기 편한 타입을 따로 만들어야 하는지, 에러 메시지는 서버에서 내려주는 문구를 그대로 보여줘도 되는지 같은 문제들이 계속 생긴다.
처음에는 단순하게 생각할 수 있다.
백엔드에서 데이터를 내려주면 프론트엔드에서 적당히 타입을 만들고, 화면에 필요한 형태로 바꿔서 사용하면 된다. 작은 프로젝트에서는 이 방식도 크게 문제 없어 보인다. 그런데 API가 늘어나고, 화면이 늘어나고, 에러 케이스가 늘어나면 금방 관리가 어려워진다.
결국 프론트엔드의 API 인터페이스 관리는 백엔드 명세를 얼마나 잘 받아오느냐에서 시작한다고 생각한다.
이번 글에서는 프론트엔드 관점에서 백엔드 인터페이스를 어떻게 받아와야 하는지 정리해보려고 한다. 다음 글에서는 반대로 백엔드에서 좋은 인터페이스를 어떻게 설계해야 하는지 다뤄볼 예정이다.
프론트엔드는 백엔드 인터페이스에 강하게 의존한다.
프론트엔드는 백엔드가 내려주는 요청/응답 스키마, 에러 코드, 상태 코드, 페이지네이션 구조, 이벤트 이름을 기준으로 화면과 상태를 구성한다. 따라서 프론트엔드에서 백엔드 규약과 별개로 개별 패턴을 만들기 시작하면 유지보수가 어려워진다.
인터페이스를 너무 복잡하게 생각할 필요는 없다. 기본적으로 인터페이스는 데이터를 송신하고 수신할 때 정의하는 규약이라고 볼 수 있다. 백엔드에서 API, SSE, Socket 같은 트래픽을 통해 데이터를 전달하는 것도 인터페이스이고, 프론트엔드에서 데이터를 UI로 출력해 사용자에게 전달하는 것도 인터페이스이다.
이 관점에서 보면 백엔드와 프론트엔드는 서로 다른 형태의 인터페이스를 최종 산출한다고 볼 수 있다. 백엔드는 API, SSE, Socket 같은 트래픽 기반 인터페이스를 산출하고, 프론트엔드는 최종적으로 사용자가 마주하는 UI 인터페이스를 산출한다.
즉, 백엔드의 핵심 산출물이 안정적인 API 타입과 트래픽 계약이라면, 프론트엔드의 핵심 산출물은 그 데이터를 바탕으로 만들어지는 UI 출력이다. 이 둘은 형태는 다르지만 같은 철학을 가진다. 결국 인터페이스는 한쪽이 만든 데이터를 다른 쪽이 예측 가능한 방식으로 해석하게 만드는 규약이다.
예를 들어 사용자 조회 API는 다음과 같은 응답을 내려준다고 해보자.
{
"id": 1,
"name": "choi",
"status": "active"
}
어떤 화면은 이 응답을 그대로 사용하고, 어떤 화면은 자체 타입으로 바꾸고, 또 다른 화면은 일부 필드만 골라서 새로운 객체를 만든다면 처음에는 편할 수 있다. 하지만 API 응답이 바뀌는 순간 어떤 화면이 어떤 타입에 의존하고 있는지 추적해야 한다.
그래서 프론트엔드가 가장 먼저 지켜야 하는 원칙은 백엔드 인터페이스를 임의로 재해석하지 않는 것이다.
프론트엔드는 백엔드가 정의한 강한 규약을 전달받아서 사용하는 방식으로 관리해야 한다.
이런 패턴을 적용할 때 좋은 방법은 가능하면 OpenAPI 같은 API 스키마 문서를 기준으로 인터페이스를 생성하는 것이다.
프론트엔드에서 API 타입, 요청 함수, 응답 타입, 에러 타입을 손으로 맞추기 시작하면 언젠가 백엔드 규약과 어긋난다. 반대로 OpenAPI 명세를 기준으로 타입과 API client를 생성하고, 프론트엔드에서 임의로 재해석하지 않으면 API 인터페이스 관리는 훨씬 편해진다.
백엔드 OpenAPI 명세
→ 타입 생성
→ API client 생성
→ 프론트엔드에서 사용
이 흐름이 만들어지면 프론트엔드는 백엔드 API가 어떤 요청을 받고, 어떤 응답을 내려주고, 어떤 에러 코드를 반환할 수 있는지 명세를 기준으로 확인할 수 있다.
물론 이 방식은 백엔드 명세가 정확해야 의미가 있다. OpenAPI 문서가 실제 구현과 다르면 프론트엔드는 잘못된 규약을 강하게 따르게 된다. 따라서 백엔드에서는 OpenAPI 문서를 단순한 Swagger 문서가 아니라 실제 인터페이스 계약으로 관리해야 한다.
프론트엔드 입장에서는 이 계약을 기준으로 삼고, 필요한 변환이 있다면 정해진 계층에서 일관되게 처리하는 것이 좋다.
다만 BFF(Backend For Frontend)처럼 화면 요구사항을 위해 프론트엔드에 더 맞는 API 계층을 따로 두는 경우도 있다. 이 경우에도 핵심은 같다. 프론트엔드가 임의로 해석하는 것이 아니라, BFF가 제공하는 인터페이스를 명세로 관리해야 한다.
프론트에서 API 타입을 정의할 때 주의해야 할 점이 있다.
백엔드에서 가져온 타입을 UI를 위해 무조건 다시 정의하는 방식은 이상한 구조가 될 수 있다. OpenAPI를 통해 이미 UserResponse 타입을 생성했는데, 화면마다 비슷한 User 타입을 다시 만들기 시작하면 타입이 두 벌로 관리된다.
그러면 백엔드 스키마가 바뀌었을 때 어떤 타입을 고쳐야 하는지 흐려진다.
따라서 API 타입과 UI 타입은 역할이 다를 때만 분리해야 한다. 그리고 프론트엔드 타입 정의 패턴에서는 보통 API 타입, UI 타입, Props 타입을 구분해서 생각하는 것이 좋다.
예를 들어 다음 타입은 백엔드 인터페이스를 그대로 표현한다.
type UserResponse = {
id: number;
name: string;
createdAt: string;
};
이 타입을 화면에서 그대로 사용해도 충분하다면 굳이 새로운 타입을 만들 필요는 없다.
하지만 화면에서 날짜를 표시 문자열로 바꾸거나, 여러 필드를 조합해서 displayName을 만들거나, 선택 상태를 위해 isSelected 같은 값을 추가해야 한다면 UI 타입을 따로 둘 수 있다.
type UserViewModel = {
id: number;
displayName: string;
createdDateText: string;
isSelected: boolean;
};
중요한 것은 개발자가 이 두 타입의 역할을 반드시 구분해서 알고 있어야 한다는 점이다.
API 타입은 백엔드 규약을 따르는 타입이고, UI 타입은 화면 요구사항을 표현하는 타입이다. Props 타입은 컴포넌트 경계의 입력 계약이다. 이 구분 없이 타입을 다시 만들면 인터페이스를 관리하는 것이 아니라, 인터페이스를 복제해서 관리하는 문제가 생긴다.
백엔드가 API 타입을 통해 트래픽 인터페이스를 안정적으로 제공하듯, 프론트엔드는 그 타입을 바탕으로 UI 인터페이스를 안정적으로 출력해야 한다. API 타입과 UI 타입은 같은 타입은 아니지만, 둘 다 인터페이스를 안정적으로 유지하기 위한 도구라는 점에서는 같은 철학을 가진다.
파일 위치도 이 기준에 맞춰 생각할 수 있다.
이 방식은 타입의 사용 범위가 좁을수록 효과적이다. 특정 컴포넌트에서만 쓰는 UserCardProps를 전역 types 파일에 두면 오히려 추적이 어려워진다. 반대로 여러 페이지에서 공유되는 UI 타입이라면 해당 feature나 domain 단위의 model 파일로 올리는 편이 낫다.
인터페이스 변경 전략도 함께 생각해야 한다. 인터페이스는 한 번 정의해도 언젠가 바뀐다. 필드를 바로 삭제하기보다는 deprecated 상태를 거치고, 새 필드를 추가한 뒤 프론트엔드 반영이 끝난 후 제거하는 흐름이 필요하다.
백엔드와 프론트엔드의 네이밍 컨벤션이 항상 같지는 않다.
예를 들어 백엔드에서는 Python, DB, FastAPI 스타일 때문에 snake_case를 사용하는 경우가 많고, 프론트엔드에서는 JavaScript/TypeScript 스타일상 camelCase를 사용하는 경우가 많다.
{
"user_id": 1,
"created_at": "2026-05-16T10:00:00Z"
}
프론트엔드에서는 보통 다음 형태가 더 자연스럽다.
type User = {
userId: number;
createdAt: string;
};
이 변환 자체는 문제가 아니다. 문제는 이 변환을 화면마다 제각각 처리하는 것이다.
네이밍 변환은 API client, adapter, schema parser 같은 한 지점에서 일관되게 처리해야 한다. 그래야 백엔드 스키마가 바뀌었을 때 수정 범위가 명확해진다.
핵심은 네이밍을 어디에서 변환하든, 변환 규칙이 명세와 함께 관리되어야 한다는 점이다.
프론트엔드에서 에러 처리를 할 때 가장 위험한 방식은 백엔드가 내려주는 message를 그대로 기준으로 삼는 것이다.
에러 메시지는 사람이 읽는 값이고, 에러 코드는 프로그램이 판단하는 값이다. 프론트엔드는 화면 상태, 입력 폼, 토스트, 번역 문구를 결정해야 하므로 message보다 code를 기준으로 처리하는 편이 좋다.
예를 들어 백엔드는 다음처럼 안정적인 에러 코드를 내려준다.
{
"error": {
"code": "USER_NOT_FOUND",
"message": "User not found.",
"status": 404
}
}
프론트엔드는 이 코드를 도메인별 enum으로 관리하고, 필요한 경우 보통 사용하는 번역 도구인 i18n에 매핑한다.
export enum UserErrorCode {
USER_NOT_FOUND = "USER_NOT_FOUND",
}
export enum AuthErrorCode {
INVALID_PASSWORD = "INVALID_PASSWORD",
EMAIL_ALREADY_EXISTS = "EMAIL_ALREADY_EXISTS",
}
type ApiErrorCode = UserErrorCode | AuthErrorCode;
const errorI18nKeyMap: Record<ApiErrorCode, string> = {
[UserErrorCode.USER_NOT_FOUND]: "error.user.notFound",
[AuthErrorCode.INVALID_PASSWORD]: "error.auth.invalidPassword",
[AuthErrorCode.EMAIL_ALREADY_EXISTS]: "error.auth.emailAlreadyExists",
};
const message = t(errorI18nKeyMap[UserErrorCode.USER_NOT_FOUND]);
이 패턴의 장점은 역할이 분리된다는 점이다. 백엔드는 에러 코드라는 안정적인 스키마를 제공하고, 프론트엔드는 해당 코드를 기준으로 화면 정책과 번역 문구를 결정한다. 즉, 에러 코드 enum은 백엔드 에러 스키마와 프론트엔드 i18n 리소스를 연결하는 중간 계층이 된다.
500대 서버 에러는 프론트엔드에서 구체적인 원인을 모두 관리할 필요가 적다. 서버 내부 장애, DB 장애, 외부 API 장애는 클라이언트가 해결할 수 있는 영역이 아니기 때문이다. 이 경우에는 공통 장애 메시지나 재시도 버튼 정도로 처리할 수 있다.
하지만 400, 401, 403, 404, 409, 422 같은 400대 에러는 사용자의 입력, 권한, 요청 상태와 직접 연결된다. 따라서 프론트엔드에서 반드시 구체적으로 고려해야 한다.
다만 그 기준은 프론트엔드가 임의로 만든 패턴이 아니라, 백엔드에서 내려주는 에러 코드여야 한다.
API는 요청과 응답이 명확하기 때문에 SSE나 Socket보다 단순해 보인다. 하지만 API도 상태 조건에 따라 프론트엔드와 백엔드가 강하게 결합되는 경우가 많다.
예를 들어 로그인 횟수 제한을 생각해볼 수 있다.
{
"error": {
"code": "LOGIN_ATTEMPT_LIMIT_EXCEEDED",
"message": "Login attempt limit exceeded.",
"status": 429,
"details": {
"retryAfterSeconds": 300
}
}
}
이 경우 프론트엔드는 단순히 에러 메시지를 보여주는 것으로 끝나지 않는다. 로그인 버튼을 비활성화할지, 남은 시간을 표시할지, 비밀번호 재설정 링크를 보여줄지, 일정 시간이 지나면 다시 요청할 수 있게 할지 결정해야 한다.
이런 케이스는 백엔드와 프론트엔드가 매우 강하게 결합된다.
결합도가 높아지는 이유는 일반적인 API 응답에 부가적인 필드가 추가되고, 프론트엔드가 그 조건을 기준으로 화면을 설계해야 하기 때문이다. 기본적으로 인터페이스는 데이터를 주고받기 위한 규약이고, 프론트엔드는 해당 데이터를 UI에 출력하는 역할을 한다. 그런데 출력하기 전에 조건 처리와 분기 처리가 들어가기 시작하면 복잡도와 결합도가 함께 증가한다.
즉, 단순히 데이터를 보여주는 것이 아니라 이 상태라면 버튼을 막는다, 이 조건이라면 안내 문구를 바꾼다, 이 값이 있으면 재시도 시간을 보여준다 같은 정책이 프론트엔드에 들어온다. 이런 순간부터 프론트엔드는 백엔드의 도메인 정책을 더 깊게 알게 된다. 따라서 개발자는 이런 결합이 필요한 기능인지, 어떤 필드를 인터페이스로 노출해야 하는지, 해당 필드가 화면에서 어떻게 분기되는지 반드시 고려해야 한다.
비슷한 예시는 많다.
로그인 횟수 제한
결제 진행 상태
주문 취소 가능 여부
쿠폰 적용 가능 조건
상품 재고 또는 예약 가능 상태
사용자 권한 또는 플랜 제한
약관 동의 필요 상태
이메일 인증 또는 휴대폰 인증 필요 상태
이런 기능들은 백엔드의 정책과 프론트엔드의 UI가 강하게 연결된다. 결제 상태가 PENDING, PAID, FAILED, CANCELLED 중 무엇인지에 따라 버튼, 안내 문구, 재시도 가능 여부가 달라진다. 주문 취소 가능 여부도 단순히 화면에서 판단할 수 없다. 백엔드가 현재 주문 상태와 정책을 기준으로 취소 가능 여부를 내려줘야 한다.
즉, 이런 기능은 결합도를 낮추는 것만이 정답이 아니다.
유지보수 관점에서는 강한 결합이 불리할 수 있지만, 서비스 정책을 정확히 구현하기 위해 반드시 필요한 결합도 있다. 결국 이것은 개발자가 인정하고 관리해야 하는 트레이드오프에 가깝다.
다만 이런 결합은 단순한 기술적 결합이라기보다, 서비스 정책을 프론트엔드와 공유하기 위한 도메인 결합에 가깝다. 프론트엔드가 백엔드의 구현 세부사항에 의존하는 것은 위험하지만, 서비스 상태와 정책 자체를 인터페이스로 전달받는 것은 반드시 필요한 경우가 많다.
중요한 것은 이 결합을 숨기지 않고 인터페이스 규약으로 명시하는 것이다.
따라서 상태 조건 기반 API는 에러 코드뿐 아니라 details 스키마까지 함께 정의해야 한다.
LOGIN_ATTEMPT_LIMIT_EXCEEDED
- retryAfterSeconds
- maxAttempts
- lockedUntil
이렇게 정의해야 프론트엔드가 백엔드 정책을 추측하지 않고, 명시된 인터페이스를 기준으로 화면 상태를 관리할 수 있다.
또한 이런 결합도 높은 패턴은 도메인별로 관리되어야 한다. 개발자는 어떤 기능이 기존 패턴에 부가적인 속성이나 필드를 추가하는지 알고 있어야 한다. 예를 들어 결제 도메인에서는 paymentStatus, retryable, failedReason 같은 값이 중요할 수 있고, 인증 도메인에서는 retryAfterSeconds, remainingAttempts, lockedUntil 같은 값이 중요할 수 있다.
이런 필드들은 한 번 프론트엔드 인터페이스로 노출되면 쉽게 바꾸기 어렵다. 따라서 초기 설계 단계에서 도메인별 상태 패턴을 최대한 구체적으로 정의하고, 이후에는 가능하면 변경되지 않도록 관리해야 한다. 필요한 확장은 가능해야 하지만, 기존 필드의 의미가 바뀌거나 화면마다 다른 방식으로 해석되는 상황은 피해야 한다.
지금까지는 HTTP API를 중심으로 인터페이스를 설명했다.
HTTP API는 요청과 응답이 비교적 명확하다. request, response, error 스키마를 정의하고, OpenAPI 같은 명세를 기준으로 관리할 수 있다. 따라서 REST API나 HTTP 기반 API 정의 방법은 사실상 디펙토로 자리잡았다.
하지만 SSE, Socket, WebSocket 같은 이벤트 기반 트래픽은 이야기가 조금 다르다.
이런 방식들은 API처럼 명확한 디팩토가 강하게 정해져 있지 않다. REST API가 OpenAPI를 중심으로 계약을 관리하듯, 이벤트 기반 인터페이스에서는 AsyncAPI 같은 명세 도구도 존재한다. 다만 일반적인 HTTP API와 비교하면 팀마다 이벤트 이름, payload 구조, 재연결 처리, 실패 처리 방식이 훨씬 다르게 설계된다. 그래서 복잡도가 쉽게 증가한다.
API는 보통 한 번 요청하고 한 번 응답받으면 흐름이 끝난다. 반면 SSE나 Socket은 연결이 유지된 상태에서 여러 이벤트가 순서대로 오간다. 이벤트가 중복으로 들어올 수도 있고, 연결이 끊겼다가 다시 붙을 수도 있고, 마지막으로 받은 이벤트 이후부터 복구해야 할 수도 있다.
따라서 이벤트 기반 트래픽은 API보다 관리하기가 훨씬 까다롭다.
관리하기 위해서는 단순히 payload 타입만 정의하면 안 된다. 먼저 각 이벤트의 payload schema를 정의하고, 그 다음에 이벤트 흐름을 함께 정의해야 한다.
payload schema
event flow
reconnect strategy
failure event
completion event
예를 들어 작업 진행 상태를 SSE로 전달한다면 다음처럼 이벤트 흐름을 먼저 잡아야 한다.
job.started
→ job.progress.updated
→ job.completed
job.started
→ job.progress.updated
→ job.failed
그리고 각 이벤트는 일정한 envelope와 payload schema를 가져야 한다.
{
"event": "job.progress.updated",
"version": 1,
"id": "evt-123",
"occurredAt": "2026-05-16T10:00:00Z",
"payload": {
"jobId": "job-1",
"progress": 40,
"status": "running"
}
}
즉, API 인터페이스가 요청/응답/에러 스키마 중심이라면, 이벤트 기반 인터페이스는 payload schema와 event flow 중심으로 설계해야 한다.
그리고 이런 이벤트 기반 트래픽은 남용하면 안 된다고 생각한다. 단순 조회나 일반적인 CRUD 요청까지 Socket이나 SSE로 처리하면 오히려 상태 관리와 디버깅이 어려워진다. 실시간성이 필요하거나, 서버가 지속적으로 상태를 밀어줘야 하는 경우에만 사용하는 것이 좋다.
프론트엔드 API 인터페이스 관리는 결국 백엔드 인터페이스를 잘 받아오는 문제에 가깝다.
프론트엔드에서 개별적으로 API 타입과 에러 처리 패턴을 만들기 시작하면 당장은 빠를 수 있다. 하지만 API가 늘어나고, 예외 케이스가 늘어나고, 화면이 늘어나면 결국 백엔드 규약과 프론트엔드 구현이 어긋나기 쉽다.
이번 글은 프론트엔드가 백엔드 인터페이스를 어떻게 받아와야 하는지에 초점을 맞췄다.
다음 글에서는 반대로 백엔드에서 좋은 인터페이스를 어떻게 설계해야 하는지 다뤄보려고 한다.