Backend

웹 프론트엔드 기초와 백엔드 협업 관점

React, Router, Zustand, Fetch/Axios, 비동기 처리, 폼 상태 학습 내용을 API 계약과 백엔드 협업 흐름으로 연결해 정리합니다.

웹 프론트엔드 기초와 백엔드 협업 관점

백엔드 API를 만들 때 화면을 모르면 응답 구조가 어색해지기 쉽다. 목록 화면은 페이지네이션과 필터가 필요하고, 상세 화면은 관계 데이터를 어디까지 포함할지 결정해야 하며, 생성/수정 화면은 검증 실패를 사용자가 이해할 수 있는 단위로 돌려줘야 한다. 프론트엔드 기초를 보는 이유는 화면을 깊게 구현하기 위해서만이 아니라, API가 실제 사용자 흐름에서 어떻게 소비되는지 이해하기 위해서다.

HTML, CSS, JavaScript, React, Router, Zustand, Fetch/Axios를 따로 보면 입문 문법처럼 보인다. 하지만 백엔드 협업 관점에서는 모두 “클라이언트가 어떤 상태를 만들고, 어떤 요청을 보내며, 어떤 실패를 사용자에게 보여주는가”라는 하나의 문제로 연결된다.

HTML과 CSS는 API 데이터가 놓이는 경계다

HTML은 화면의 의미 구조를 만든다. 제목, 문단, 이미지, 링크, 폼, 테이블 같은 요소가 어떤 의미를 가지는지 정리한다. 백엔드 입장에서 이 구조를 이해하면 API 응답이 화면의 어떤 영역에 쓰이는지 더 잘 보인다.

예를 들어 목록 화면은 단순 배열만 있으면 되는 것처럼 보이지만, 실제 UI에는 제목, 전체 개수, 페이지 정보, 빈 목록 메시지, 액션 버튼, 로딩 상태가 함께 필요하다.

<section>
  <h1>Members</h1>
  <p>Total: 42</p>

  <table>
    <thead>
      <tr>
        <th>ID</th>
        <th>Name</th>
        <th>Status</th>
      </tr>
    </thead>
    <tbody>
      <!-- API data is rendered here -->
    </tbody>
  </table>
</section>

폼 화면에서는 필드별 에러가 중요하다. 백엔드가 단순히 Bad Request만 돌려주면 프론트엔드는 어떤 입력을 고쳐야 하는지 알 수 없다.

<form>
  <label for="email">Email</label>
  <input id="email" name="email" type="email" />
  <p class="field-error">Email format is invalid.</p>
</form>

그래서 검증 실패 응답은 필드 단위로 내려주는 편이 협업하기 좋다.

{
  "code": "VALIDATION_FAILED",
  "message": "Request validation failed.",
  "fields": {
    "email": "Email format is invalid.",
    "password": "Password must be at least 12 characters."
  }
}

CSS는 시각 표현만의 문제가 아니다. 긴 이름, 비어 있는 설명, 깨진 이미지, 모바일 화면, 다크모드, 에러 메시지 길이처럼 데이터가 화면에 들어왔을 때 생기는 문제를 드러낸다. 예를 들어 백엔드가 name 길이를 제한하지 않으면 카드나 테이블이 쉽게 깨진다.

.member-name {
  max-width: 240px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

프론트엔드에서 말줄임을 할 수는 있지만, 도메인 규칙상 최대 길이가 있어야 하는 데이터라면 API 검증도 함께 있어야 한다. 이미지도 마찬가지다. 이미지가 없을 수 있는지, 대체 이미지를 줄지, alt 텍스트를 어떻게 만들지 정해야 한다.

{
  "id": 10,
  "name": "Project Alpha",
  "thumbnailUrl": null,
  "thumbnailAlt": "Project Alpha architecture thumbnail"
}

HTML 속성은 화면 문법이면서 동시에 데이터 계약의 힌트가 된다. required, minlength, maxlength, type="email"은 브라우저 수준의 1차 검증을 제공한다. 하지만 이것이 서버 검증을 대체하지는 않는다. 프론트엔드는 사용자가 빨리 피드백을 받도록 돕고, 백엔드는 신뢰할 수 없는 입력을 최종 검증한다.

<form method="post" action="/api/profile">
  <label for="email">Email</label>
  <input
    id="email"
    name="email"
    type="email"
    required
    maxlength="120"
    autocomplete="email"
  />

  <button type="submit">Save</button>
</form>

이 작은 폼에서도 백엔드와 합의할 지점이 많다. name="email"은 요청 body의 필드명이 되고, maxlength는 DB 컬럼 길이와 연결되며, autocomplete은 브라우저 동작과 개인정보 입력 경험에 영향을 준다. 파일 업로드라면 accept, multiple, enctype도 API 설계와 맞아야 한다.

frontend validation
  - immediate feedback
  - input format hint
  - disabled state and loading state

backend validation
  - trusted validation boundary
  - authorization check
  - persistence constraints
  - audit and error contract

CSS도 정상 케이스만 보고 작성하면 운영에서 쉽게 깨진다. 제목이 길어지는 경우, 다국어 문장이 버튼 안에 들어가는 경우, 이미지 비율이 다른 경우, 표 컬럼이 늘어나는 경우를 확인해야 한다. 이 문제는 디자인만의 문제가 아니라 API 응답 형태와도 연결된다. 화면이 감당할 수 없는 데이터는 결국 사용자가 읽을 수 없는 데이터가 된다.

JavaScript 비동기는 사용자 이벤트를 서버 요청으로 바꾼다

JavaScript는 화면에 동작을 붙인다. DOM 조작을 학습할 때 querySelector, addEventListener, classList 같은 API를 쓰는데, 백엔드 관점에서는 사용자의 행동이 언제 요청으로 바뀌는지 이해하는 데 도움이 된다.

const form = document.querySelector("#member-form");

form.addEventListener("submit", async (event) => {
  event.preventDefault();

  const name = document.querySelector("#name").value;

  const response = await fetch("/api/members", {
    method: "POST",
    headers: {
      "Content-Type": "application/json"
    },
    body: JSON.stringify({ name })
  });

  if (!response.ok) {
    // render field or global error
  }
});

여기서 중요한 지점은 event.preventDefault()다. 기본 폼 제출은 페이지를 새로고침한다. SPA에서는 이를 막고 비동기 요청으로 처리한다. 그러면 백엔드는 redirect가 아니라 JSON 응답, 상태 코드, 에러 형식, 인증 만료 처리를 더 신경 써야 한다.

JavaScript 비동기 처리는 콜백, Promise, async/await로 발전해왔다. 백엔드 API를 호출하는 프론트엔드 코드에서는 async/awaittry/catch/finally가 가장 읽기 쉽다.

async function loadMembers() {
  try {
    const response = await fetch("/api/members");

    if (!response.ok) {
      throw new Error("Failed to load members");
    }

    return await response.json();
  } catch (error) {
    console.error(error);
    throw error;
  } finally {
    console.log("request finished");
  }
}

반복문에서 비동기를 다룰 때도 주의가 필요하다. forEachawait을 기다리지 않는다. 순차 처리라면 for...of가 낫고, 병렬 처리라면 Promise.all이나 Promise.allSettled를 의도적으로 선택해야 한다.

for (const id of memberIds) {
  const member = await fetchMember(id);
  console.log(member);
}

const results = await Promise.allSettled(
  memberIds.map((id) => fetchMember(id))
);

백엔드 입장에서는 이 차이가 트래픽 패턴으로 나타난다. 프론트엔드가 한 번에 50개 요청을 병렬로 보내는지, 페이지 단위로 하나씩 보내는지에 따라 rate limit, pagination, batch endpoint, cache 전략이 달라진다.

Fetch와 Axios 차이는 에러 계약으로 이어진다

Fetch와 Axios는 모두 HTTP 요청을 보내지만 에러 처리 방식이 다르다. Fetch는 네트워크 오류가 아니라면 4xx/5xx도 Promise reject가 되지 않는다. 그래서 response.ok를 직접 확인해야 한다.

const response = await fetch("/api/members");

if (!response.ok) {
  const error = await response.json();
  throw new Error(error.message);
}

const data = await response.json();

Axios는 기본적으로 2xx 범위 밖의 상태 코드를 reject로 처리한다.

try {
  const response = await axios.get("/api/members");
  return response.data;
} catch (error) {
  if (error.response?.status === 401) {
    // redirect to login or refresh token
  }
}

이 차이는 백엔드 에러 설계와 연결된다. 400, 401, 403, 404, 409, 422, 500을 구분해 주면 프론트엔드는 사용자에게 다른 메시지와 행동을 제공할 수 있다.

400 Bad Request        malformed request
401 Unauthorized       missing or expired authentication
403 Forbidden          authenticated but not allowed
404 Not Found          resource does not exist
409 Conflict           state conflict, duplicate resource
422 Unprocessable      validation failed
500 Internal Error     unexpected server failure

타임아웃과 요청 취소도 중요하다. 사용자가 검색어를 빠르게 바꾸면 이전 요청을 취소해야 할 수 있다.

const controller = new AbortController();

fetch(`/api/search?q=${encodeURIComponent(query)}`, {
  signal: controller.signal
});

controller.abort();

백엔드는 취소된 요청, 중복 요청, 짧은 간격의 검색 요청을 견딜 수 있어야 한다. 필요하면 rate limit, debounce, cache, pagination을 함께 설계해야 한다.

React 상태는 API 계약을 압박한다

React의 핵심은 컴포넌트와 상태다. Props는 부모에서 자식으로 내려가는 읽기 전용 데이터이고, useState는 컴포넌트 안에서 변하는 값을 관리한다. 화면이 복잡해질수록 API 응답이 어떤 상태로 렌더링되는지 명확히 봐야 한다.

function MemberList() {
  const [members, setMembers] = useState([]);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState(null);

  useEffect(() => {
    async function loadMembers() {
      setIsLoading(true);
      setError(null);

      try {
        const response = await fetch("/api/members");
        if (!response.ok) {
          throw new Error("Failed to load members");
        }
        setMembers(await response.json());
      } catch (error) {
        setError(error);
      } finally {
        setIsLoading(false);
      }
    }

    loadMembers();
  }, []);

  if (isLoading) return <Loading />;
  if (error) return <ErrorMessage error={error} />;
  if (members.length === 0) return <EmptyState />;

  return <MembersTable members={members} />;
}

이 코드만 봐도 화면에는 최소 세 가지 상태가 있다.

loading
success
error

여기에 빈 목록, 권한 없음, 네트워크 실패, 검증 실패, 부분 성공 같은 상태가 붙는다. 백엔드 API가 이런 상태를 구분하지 못하면 프론트엔드는 모든 오류를 하나의 메시지로 처리하게 된다.

목록 API도 단순 배열보다 페이지 정보를 포함하는 편이 좋다.

{
  "items": [],
  "page": 1,
  "size": 20,
  "total": 0,
  "hasNext": false
}

React의 조건부 렌더링은 이 응답 구조를 UI로 바꾸는 작업이다. if, &&, 삼항 연산자는 문법이지만, 실제로는 API 상태를 화면 상태로 변환하는 도구다.

폼은 API 검증과 가장 가까운 화면이다

폼 입력은 controlled component로 다루는 경우가 많다. valueonChange를 상태에 연결하면 사용자가 입력하는 값과 React 상태가 동기화된다.

function MemberForm() {
  const [email, setEmail] = useState("");
  const [name, setName] = useState("");
  const [fieldErrors, setFieldErrors] = useState({});

  async function handleSubmit(event) {
    event.preventDefault();

    const response = await fetch("/api/members", {
      method: "POST",
      headers: {
        "Content-Type": "application/json"
      },
      body: JSON.stringify({ email, name })
    });

    if (response.status === 422) {
      const error = await response.json();
      setFieldErrors(error.fields ?? {});
      return;
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <input value={email} onChange={(event) => setEmail(event.target.value)} />
      {fieldErrors.email ? <p>{fieldErrors.email}</p> : null}

      <input value={name} onChange={(event) => setName(event.target.value)} />
      {fieldErrors.name ? <p>{fieldErrors.name}</p> : null}

      <button type="submit">Save</button>
    </form>
  );
}

여기서 API가 필드별 에러를 주지 않으면 사용자는 어느 입력이 잘못되었는지 알기 어렵다. 중복 이메일은 409 Conflict로 볼 수도 있고, 입력 형식 오류는 422로 볼 수도 있다. 중요한 것은 프론트엔드와 백엔드가 같은 기준으로 상태 코드를 해석하는 것이다.

생성, 수정, 삭제 요청도 UI 상태와 연결된다. 삭제 후에는 목록에서 항목을 제거할지, 서버에서 다시 조회할지 결정해야 한다. 수정 후에는 낙관적 업데이트를 할지, 응답 데이터를 기준으로 갱신할지 정해야 한다.

async function handleDelete(id) {
  const response = await fetch(`/api/members/${id}`, {
    method: "DELETE"
  });

  if (!response.ok) {
    throw new Error("Failed to delete member");
  }

  setMembers((members) => members.filter((member) => member.id !== id));
}

이런 흐름을 보면 DELETE API가 빈 응답을 줄지, 삭제된 리소스를 돌려줄지, soft delete 상태를 돌려줄지도 협업 포인트가 된다.

Router는 화면 구조와 데이터 선행 로딩을 결정한다

React Router의 Data Mode는 라우팅을 단순 화면 전환이 아니라 데이터 로딩 경계로 다룬다. URL 경로, 레이아웃, 중첩 라우트, loader, redirect가 함께 움직인다.

const router = createBrowserRouter([
  {
    element: <DefaultLayout />,
    children: [
      {
        path: "/members",
        element: <MembersPage />,
        loader: membersLoader
      },
      {
        path: "/members/:memberId",
        element: <MemberDetailPage />,
        loader: memberDetailLoader
      }
    ]
  }
]);

동적 세그먼트는 API 리소스 경로와 닮아 있다.

/members/:memberId
/api/members/:memberId

라우터의 loader에서 인증 상태를 확인하고, 인증되지 않은 사용자를 로그인 페이지로 보낼 수 있다.

export async function membersLoader() {
  const session = await getSession();

  if (!session) {
    throw redirect("/login?redirectTo=/members");
  }

  return fetchMembers();
}

백엔드 입장에서는 이 구조가 401/403 처리와 연결된다. 인증이 없으면 401, 인증은 되었지만 권한이 없으면 403을 반환해야 프론트엔드 라우터와 화면이 올바르게 대응할 수 있다.

SPA를 배포할 때는 직접 URL 접근도 고려해야 한다. /members/1로 새로고침했을 때 서버가 실제 파일을 찾으려 하면 404가 난다. Vercel, Netlify, Firebase 같은 호스팅에서는 모든 경로를 index.html로 돌리는 rewrite 설정이 필요하다.

{
  "rewrites": [
    {
      "source": "/:path*",
      "destination": "/index.html"
    }
  ]
}

Next.js 같은 프레임워크는 라우팅과 서버 렌더링 모델이 다르지만, URL이 데이터 로딩 경계를 만든다는 점은 같다.

Zustand는 클라이언트 상태의 책임을 좁히는 도구다

상태가 가까운 컴포넌트 안에서만 쓰이면 useState로 충분하다. 하지만 여러 화면과 컴포넌트가 같은 상태를 공유하면 props를 계속 내려주는 prop drilling이 생긴다. Zustand 같은 store는 이 문제를 줄여준다.

import { create } from "zustand";

type SessionState = {
  userId: string | null;
  setUserId: (userId: string | null) => void;
};

export const useSessionStore = create<SessionState>((set) => ({
  userId: null,
  setUserId: (userId) => set({ userId })
}));

컴포넌트에서는 필요한 값만 selector로 가져온다. 스토어 전체를 가져오면 불필요한 리렌더링이 늘어날 수 있다.

const userId = useSessionStore((state) => state.userId);
const setUserId = useSessionStore((state) => state.setUserId);

여러 상태를 한 번에 가져올 때는 shallow 비교를 사용해 리렌더링을 줄일 수 있다. persist 미들웨어를 사용하면 localStorage에 상태를 저장할 수 있지만, 인증 토큰처럼 민감한 값을 무심코 저장하면 안 된다. 클라이언트 상태는 편의를 위한 캐시나 UI 상태에 가깝고, 권한의 최종 판단은 서버가 해야 한다.

import { persist } from "zustand/middleware";

export const usePreferencesStore = create(
  persist(
    (set) => ({
      theme: "system",
      setTheme: (theme) => set({ theme })
    }),
    { name: "preferences" }
  )
);

Zustand를 이해하면 백엔드도 “서버가 소유해야 하는 상태”와 “클라이언트가 잠시 들고 있어도 되는 상태”를 더 잘 구분할 수 있다. 장바구니, 임시 필터, 테마, 사이드바 열림 여부는 클라이언트 상태로 충분할 수 있지만, 결제 상태, 권한, 재고, 승인 여부는 서버가 기준이 되어야 한다.

Router와 store를 함께 쓰면 책임이 쉽게 섞인다. URL query parameter에 있어야 할 값이 전역 store에만 있으면 새로고침이나 공유 링크에서 상태가 사라진다. 반대로 화면 내부에서만 쓰는 모달 열림 상태를 URL에 모두 넣으면 라우팅이 불필요하게 복잡해진다.

URL state
  - page
  - keyword
  - selected tab
  - resource id

client UI state
  - modal open
  - temporary form draft
  - sidebar collapsed
  - optimistic loading state

server state
  - user permission
  - payment status
  - inventory
  - approval result

목록 화면을 예로 들면 page, size, keyword, sort는 URL에 남기는 편이 좋다. 그래야 사용자가 링크를 공유하거나 새로고침해도 같은 결과를 볼 수 있다. 하지만 선택된 체크박스나 임시 필터 패널 열림 여부는 클라이언트 상태로 충분할 수 있다.

function buildMembersUrl(params: {
  page: number;
  size: number;
  keyword?: string;
  sort?: "createdAt" | "name";
}) {
  const search = new URLSearchParams({
    page: String(params.page),
    size: String(params.size),
    sort: params.sort ?? "createdAt"
  });

  if (params.keyword) {
    search.set("keyword", params.keyword.trim());
  }

  return `/members?${search.toString()}`;
}

이 함수는 프론트엔드 유틸처럼 보이지만 API 설계와 직접 연결된다. 백엔드는 page의 시작값이 0인지 1인지, size의 최대값이 얼마인지, 정렬 가능한 필드가 무엇인지, 빈 검색어를 어떻게 처리할지 문서화해야 한다. 그렇지 않으면 화면마다 다른 query 규칙이 생긴다.

웹 표준과 접근성은 API 설계에도 영향을 준다

HTML과 CSS를 단순히 화면을 꾸미는 기술로만 보면 놓치는 부분이 있다. 웹 표준, 크로스 브라우징, 접근성은 사용자가 같은 데이터를 다른 환경에서 어떻게 소비하는지와 연결된다. 백엔드 입장에서도 이 관점은 중요하다. API가 반환하는 값이 화면에만 맞춰진 문자열인지, 기계가 이해할 수 있는 상태값인지에 따라 접근성과 국제화, 검색, 자동화 가능성이 달라진다.

예를 들어 주문 상태를 화면에 바로 표시할 한국어 문장으로만 내려주면 프론트엔드는 색상, 아이콘, 필터, 접근성 라벨을 일관되게 처리하기 어렵다.

{
  "statusText": "배송 준비 중입니다"
}

화면 표시용 문구와 시스템 상태값을 분리하면 훨씬 다루기 쉽다.

{
  "status": "PREPARING_SHIPMENT",
  "label": "배송 준비 중",
  "severity": "info"
}

이 구조에서는 화면이 status를 기준으로 조건부 렌더링을 하고, label은 사용자 언어에 맞춰 바꿀 수 있다. 스크린 리더용 텍스트, 색상 대비, 상태 배지, 필터 옵션도 같은 상태값을 기준으로 일관되게 만들 수 있다.

웹 접근성은 프론트엔드만의 책임으로 끝나지 않는다. API가 날짜, 금액, 상태, 오류 코드를 명확하게 제공해야 화면도 의미 있는 HTML을 구성할 수 있다. 날짜를 임의 문자열로 내려주기보다 ISO 형식과 timezone 기준을 정하면 브라우저의 locale formatting을 사용할 수 있다. 금액도 표시 문자열뿐 아니라 통화 코드와 숫자 값을 함께 제공해야 정렬, 필터, 읽기 보조 기술에서 안전하다.

{
  "amount": 12000,
  "currency": "KRW",
  "createdAt": "2025-12-30T09:00:00+09:00"
}

레이아웃은 데이터의 예외를 드러낸다

CSS Flexbox나 Grid를 학습할 때는 justify-content, align-items, gap, flex-wrap 같은 속성을 따로 외우게 된다. 하지만 실제 협업에서는 “API 데이터가 화면을 깨뜨리지 않는가”가 더 중요하다. 이름이 긴 사용자, 필드가 비어 있는 데이터, 이미지가 없는 카드, 권한에 따라 사라지는 버튼, 로딩 중 높이가 바뀌는 목록이 모두 레이아웃 문제로 나타난다.

.member-card {
  display: flex;
  align-items: center;
  gap: 12px;
  min-width: 0;
}

.member-card__name {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

위 CSS는 단순한 스타일처럼 보이지만, API 데이터가 길어질 때 UI가 어떻게 버틸지를 정한다. 백엔드도 이 문제와 무관하지 않다. 목록 API에서 표시 이름, 보조 설명, 아바타 URL, 상태값을 어떤 필드로 줄지 정해야 프론트엔드가 예외를 안정적으로 처리한다.

카드 목록은 특히 데이터 계약의 영향을 많이 받는다.

{
  "items": [
    {
      "id": "usr_01",
      "displayName": "Kim Taeyoung",
      "email": "taeyoung@example.com",
      "avatarUrl": null,
      "role": "ADMIN",
      "lastActiveAt": "2025-12-30T10:15:00+09:00"
    }
  ],
  "page": 1,
  "size": 20,
  "totalCount": 132
}

이런 응답은 화면에서 카드, 테이블, 검색 결과, 빈 상태를 모두 만들 수 있다. 반대로 화면에 보이는 문장만 조합해서 내려주면 재사용성이 떨어진다. 프론트엔드 레이아웃을 이해하면 백엔드 API가 어느 정도의 원자적 데이터를 제공해야 하는지 판단하기 쉬워진다.

JavaScript 함수와 모듈은 API 클라이언트의 경계를 만든다

JavaScript 함수, class, module, import/export는 문법 자체보다 경계를 나누는 방식으로 보는 것이 좋다. API 호출 코드가 컴포넌트 안에 흩어지면 인증 헤더, 에러 처리, timeout, base URL, retry 정책이 화면마다 달라진다. 작은 프로젝트에서는 괜찮아 보여도 기능이 늘면 운영 중 문제를 추적하기 어려워진다.

type ApiError = {
  code: string;
  message: string;
  fieldErrors?: Record<string, string>;
};

async function request<T>(path: string, init?: RequestInit): Promise<T> {
  const response = await fetch(`${import.meta.env.VITE_API_BASE_URL}${path}`, {
    ...init,
    headers: {
      "Content-Type": "application/json",
      ...init?.headers
    }
  });

  if (!response.ok) {
    const error = (await response.json()) as ApiError;
    throw error;
  }

  return response.json() as Promise<T>;
}

이렇게 얇은 API client를 두면 컴포넌트는 “어떻게 요청을 보낼 것인가”보다 “어떤 상태를 보여줄 것인가”에 집중할 수 있다.

export type Member = {
  id: string;
  name: string;
  email: string;
  status: "ACTIVE" | "INACTIVE";
};

export function getMembers() {
  return request<Member[]>("/members");
}

함수의 기본값, 구조 분해 할당, 나머지 매개변수도 실제 API client에서 자주 쓰인다.

type ListMembersParams = {
  page?: number;
  size?: number;
  keyword?: string;
};

export function listMembers({
  page = 1,
  size = 20,
  keyword = ""
}: ListMembersParams = {}) {
  const search = new URLSearchParams({
    page: String(page),
    size: String(size),
    keyword
  });

  return request<Member[]>(`/members?${search.toString()}`);
}

이 구조는 백엔드와도 맞닿아 있다. 프론트엔드가 page, size, keyword를 어떻게 조합하는지 알면 API query parameter의 기본값, 최대 size 제한, 검색어 trim, 정렬 기준, 빈 결과 응답을 더 명확하게 설계할 수 있다.

개발 환경은 협업 속도에 영향을 준다

Vite는 React 프로젝트를 빠르게 시작하기 좋은 빌드 도구다. 개발 서버가 빠르고, TypeScript 템플릿을 쉽게 만들 수 있다.

npm create vite@latest my-app
cd my-app
npm install
npm run dev

프로젝트가 협업 대상으로 커지면 ESLint와 Prettier 설정도 중요해진다. 코드 스타일이 매번 달라지면 리뷰에서 본질이 흐려진다.

{
  "editor.formatOnSave": true,
  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": "explicit"
  }
}

Mock API도 협업 속도를 높인다. JSON Server 같은 도구로 초기 API 형태를 맞춰두면 백엔드 구현이 끝나기 전에도 프론트엔드가 화면 상태를 만들 수 있다.

{
  "members": [
    {
      "id": 1,
      "email": "member@example.com",
      "name": "Kim",
      "status": "ACTIVE"
    }
  ]
}
json-server --watch db.json --port 5000

다만 Mock API는 실제 서버의 인증, 지연, 실패, pagination, validation을 충분히 반영하지 못할 수 있다. 그래서 어느 시점에는 OpenAPI 스펙, contract test, staging API로 맞춰가야 한다.

정리

프론트엔드 기초는 백엔드와 분리된 지식이 아니다. HTML 구조는 API 데이터가 놓이는 자리를 만들고, CSS는 데이터 길이와 예외 상태를 드러내며, JavaScript 비동기는 사용자 이벤트를 서버 요청으로 바꾼다. React 상태는 loading, success, error, empty, validation 같은 API 상태를 화면으로 변환한다. Router는 URL과 데이터 로딩 경계를 만들고, Zustand는 클라이언트 상태의 책임을 좁힌다.

백엔드 협업 관점에서 중요한 질문은 단순하다. 이 화면은 어떤 상태를 가지는가. 어떤 입력 오류를 사용자가 직접 고칠 수 있는가. 인증 실패와 권한 부족은 어떻게 다르게 보이는가. 목록은 페이지 단위로 볼 것인가. 삭제와 수정 후 화면은 서버 응답을 기준으로 갱신할 것인가. 이런 질문에 답할 수 있을 때 API는 문서상으로만 맞는 것이 아니라 실제 화면 흐름 안에서도 자연스럽게 소비된다.

이 글의 무단 복제, 재배포, 자동 수집을 허용하지 않습니다. 인용이 필요한 경우 출처와 원문 링크를 함께 남겨주세요.