API와 서버 상태

3줄 요약

  • 서버 상태는 프론트가 소유한 값이 아니라 서버에서 가져와 잠시 cache하고 다시 동기화해야 하는 값이다.
  • loading, error, empty, success 상태를 명시하면 화면이 API 실패와 지연에도 견고해진다.
  • TanStack Query는 cache, staleTime, retry, invalidation, optimistic update 같은 서버 상태 정책을 컴포넌트마다 직접 구현하지 않게 해주는 도구다.

핵심 정리

  • 클라이언트 상태는 modal open, input 값, 선택된 tab처럼 브라우저 안에서 소유하는 값이다.
  • 서버 상태는 사용자 정보, 게시글 목록, 프로젝트 상세처럼 서버가 source of truth인 값이며, 프론트의 복사본은 언제든 오래될 수 있다.
  • API 연동은 성공 응답만 처리하는 일이 아니라 loading, empty, error, retry, cancel, refresh를 함께 모델링하는 일이다.
  • mutation 후에는 어떤 query를 invalidate할지 정해야 화면이 최신 상태로 돌아온다.
  • API 계약은 success schema뿐 아니라 error schema, pagination metadata, nullable, enum, 날짜 기준을 포함해야 한다.

백엔드 개발자가 헷갈리는 지점

  • API 응답을 useState에 넣으면 서버 상태 관리가 끝난다고 생각하기 쉽다.
    • 판단 기준은 cache, stale, refetch, mutation 후 동기화가 필요한지다.
    • 반복되면 TanStack Query를 검토한다.
  • loading과 error만 있으면 충분하다고 보기 쉽다.
    • 판단 기준은 empty와 permission denied, validation error처럼 사용자가 해야 할 행동이 다른 상태를 구분하는 것이다.
    • 상태가 다르면 UI도 달라야 한다.
  • optimistic update를 항상 좋은 UX라고 생각하기 쉽다.
    • 판단 기준은 실패 시 rollback이 쉬운지, 실패 비용이 큰지다.

개인 프로젝트 기준

  • 목록/상세/현재 사용자 정보는 query로 둔다.
  • 생성/수정/삭제는 mutation으로 두고 성공 시 관련 query를 invalidate한다.
  • 검색어, filter, page는 query key와 URL query param에 함께 반영한다.
  • 관련 판단은 [[01. 백엔드 개발자를 위한 프론트엔드 실전 판단#서버-상태를-usestate에-넣는-것과-tanstack-query를-쓰는-것은-뭐가-다를까|서버 상태를 useState에 넣는 것과 TanStack Query를 쓰는 것은 뭐가 다를까?]]를 본다.

확인 질문

  • 서버 상태와 클라이언트 상태의 차이는 무엇인가?
    • source of truth가 서버인지 브라우저 UI인지의 차이다.
  • mutation 성공 후 왜 invalidation이 필요한가?
    • 기존 cache가 서버 최신 상태와 달라질 수 있기 때문이다.
  • empty state는 왜 error state와 다른가?
    • 요청은 성공했지만 표시할 데이터가 없는 상태이므로 사용자 안내와 행동이 다르다.

01. API 연동과 서버 상태 상세

이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.

  • 서버 상태와 클라이언트 상태를 어떻게 구분하는가?
  • TanStack Query를 쓰면 어떤 반복 코드를 줄일 수 있는가?
  • API 계약에서 프론트가 백엔드와 꼭 맞춰야 하는 지점은 무엇인가?

개요

프론트엔드의 API 연동은 fetch 한 번으로 끝나지 않는다. 화면은 요청 중, 성공, 빈 결과, 실패, 재시도, 권한 없음, validation 실패 같은 다양한 상태를 표현해야 한다. 백엔드 개발자가 프론트를 이해하려면 “API가 200을 준다”보다 “그 응답으로 어떤 화면 상태를 안정적으로 만들 수 있는가”를 함께 봐야 한다.

원리

서버 상태는 서버가 소유하고 프론트가 임시로 빌려 쓰는 값이다. 이 값은 오래될 수 있고, 다른 사용자의 작업이나 다른 탭의 작업으로 바뀔 수 있다. TanStack Query는 query key를 기준으로 cache를 관리하고, staleTime, retry, background refetch, invalidation 같은 정책을 제공한다.

코드로 보는 핵심 예시

import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { z } from "zod";
 
const projectSchema = z.object({
  id: z.number(),
  title: z.string(),
  status: z.enum(["active", "archived"]),
});
 
const projectListSchema = z.array(projectSchema);
type Project = z.infer<typeof projectSchema>;
 
async function fetchProjects({ status }: { status: Project["status"] }) {
  const response = await fetch(`/api/projects?status=${encodeURIComponent(status)}`);
  if (!response.ok) throw new Error("프로젝트를 불러오지 못했습니다.");
  const body: unknown = await response.json();
  return projectListSchema.parse(body);
}
 
async function archiveProject(id: number) {
  const response = await fetch(`/api/projects/${id}/archive`, {
    method: "POST",
  });
  if (!response.ok) throw new Error("프로젝트 보관에 실패했습니다.");
}
 
function ProjectListPage() {
  const queryClient = useQueryClient();
 
  const projectsQuery = useQuery({
    queryKey: ["projects", { status: "active" }],
    queryFn: () => fetchProjects({ status: "active" }),
    staleTime: 30_000,
  });
 
  const archiveMutation = useMutation({
    mutationFn: archiveProject,
    onSuccess: () => {
      // ["projects"] prefix로 active/archived 목록을 함께 stale 처리한다.
      // 특정 목록만 갱신하려면 ["projects", { status: "active" }]까지 좁힌다.
      queryClient.invalidateQueries({ queryKey: ["projects"] });
    },
  });
 
  if (projectsQuery.isLoading) return <ProjectListSkeleton />;
  if (projectsQuery.isError) return <ErrorView message="프로젝트를 불러오지 못했습니다." />;
 
  const projects = projectsQuery.data ?? [];
 
  if (projects.length === 0) return <EmptyState message="프로젝트가 없습니다." />;
 
  return (
    <ul>
      {projects.map((project) => (
        <li key={project.id}>
          {project.title}
          <button
            disabled={archiveMutation.isPending}
            onClick={() => archiveMutation.mutate(project.id)}
          >
            보관
          </button>
        </li>
      ))}
    </ul>
  );
}

백엔드와 맞춰야 할 지점

  • 목록 API에는 pagination metadata와 sort 안정성이 필요하다.
  • 실패 응답에는 사용자 행동을 결정할 수 있는 error code가 필요하다.
  • nullable과 enum은 타입으로 고정해야 한다.
  • 검색은 debounce, 최소 검색어 길이, 서버 부하 정책을 함께 정한다.
  • optimistic update가 필요한 기능은 실패 시 복구 가능한 API 설계가 필요하다.
  • mutation 성공 후 목록만 갱신할지, 상세와 현재 사용자 summary까지 갱신할지 query key 기준으로 합의한다.

개인 프로젝트 기준

  • query key는 화면 상태와 API parameter를 반영한다.
  • staleTime은 데이터가 얼마나 자주 바뀌는지 기준으로 정한다.
  • mutation 후 목록, 상세, 현재 사용자 등 어떤 query를 갱신할지 명시한다.
  • API 응답 DTO를 view model로 변환하는 함수를 둔다.
  • 데이터가 자주 변하지 않는 설정값과 자주 변하는 알림/재고를 같은 stale 정책으로 묶지 않는다.

실전 팁

  • 검색어, page, filter는 URL query param과 query key를 함께 맞춘다.
  • optimistic update는 좋아요, 북마크처럼 rollback이 쉬운 기능부터 적용한다.
  • mutation 후 ["projects"]처럼 넓게 invalidate하면 관련 목록을 한 번에 최신화할 수 있고, 특정 filter 화면만 갱신해야 하면 더 구체적인 query key로 좁힌다.
  • 오류 메시지는 서버 내부 메시지를 그대로 보여주지 말고 사용자 행동 중심으로 바꾼다.
  • 관련 함정은 nullable API 응답 타입을 어떻게 표현해야 할까?union type과 discriminated union은 왜 실전에서 유용할까?를 본다.

위험 신호!

  • 같은 서버 데이터를 여러 useState에 복사한다.
  • mutation 성공 후 stale한 목록이 그대로 남는다.
  • API 실패를 모두 같은 toast로만 처리한다.
  • 페이지네이션 metadata가 없어 UI가 다음 페이지 존재 여부를 추측한다.

확인 질문

확인 질문

  • TanStack Query의 query key는 무엇을 표현해야 하는가?
    • 서버 데이터의 종류와 그 데이터를 결정하는 parameter를 표현해야 한다.
  • staleTime은 무엇을 뜻하는가?
    • 데이터를 fresh로 간주하는 시간이다.
  • API 계약에서 error schema가 중요한 이유는 무엇인가?
    • 프론트가 field error, global error, logout, retry 같은 UI 행동을 결정해야 하기 때문이다.

참고 문서