기록

이미지 생성 폴링과 개별 완료 상태

TL;DR

  • 이미지 생성 API는 요청 직후 이미지를 돌려주지 않고 생성 이력 ID를 돌려줬다.
  • React Query refetchInterval은 함수로 둘 수 있고, false를 반환하면 interval이 제거된다.
  • 한 요청에서 여러 이미지가 만들어질 때 요청 완료와 개별 이미지 준비는 다른 상태다.
  • 카드 skeleton은 history.status가 아니라 resourceSet[].image를 기준으로 풀어야 한다.

이미지 생성 화면은 POST 요청 후 바로 결과 이미지를 받지 않았다. 응답은 생성 이력 한 건이었다.

interface History {
  id: number;
  status: "READY" | "PROGRES" | "COMPLETE" | "FAILURE";
  resourceSet: { id: number; image?: string }[];
}

사용자는 /generate/[historyId]로 이동하고, 결과 화면은 이력 ID를 기준으로 폴링한다. URL에 기다림의 기준을 둔 덕분에 새로고침해도 같은 이력을 다시 조회할 수 있다. 문제는 “언제 끝났는가”의 기준이었다.

한 번의 refetch

초기 구현은 생성 mutation이 성공한 뒤 이력 목록을 한 번 다시 가져오는 구조였다.

const { data } = useQuery(
  ["resource", variation.id],
  () => fetchResource(variation.id),
  { enabled: false },
);

여기에는 두 문제가 있었다.

문제 영향
enabled: false 쿼리가 실행되지 않음
variation.id 혼동 이력 ID와 결과 항목 ID가 섞임

생성 요청은 비동기 작업을 시작할 뿐이다. mutation 성공은 “요청 접수”이지 “이미지 준비”가 아니다. 결과 화면은 한 번 더 조회하는 구조가 아니라 상태가 변할 때까지 반복 조회하는 구조여야 한다.

함수형 refetchInterval

React Query v4의 refetchInterval은 숫자뿐 아니라 함수를 받을 수 있다.

const { data: history } = useQuery(
  ["history", id],
  () => fetchHistory(id),
  {
    refetchInterval: (data) =>
      data?.status === "COMPLETE" ? false : 2000,
    refetchOnWindowFocus: false,
  },
);

함수는 쿼리 결과가 바뀔 때마다 다시 계산된다. 2000을 반환하면 2초마다 다시 가져오고, false를 반환하면 interval이 제거된다. 이 구조에서 history.status는 요청 단위 상태다.

READY
  -> 작업 대기
PROGRES
  -> 생성 중
COMPLETE
  -> 요청 완료
FAILURE
  -> 요청 실패

폴링 종료 조건은 terminal state 전체를 봐야 한다.

function isTerminalStatus(status?: History["status"]) {
  return status === "COMPLETE" || status === "FAILURE";
}

refetchInterval: (data) => isTerminalStatus(data?.status) ? false : 2000;

COMPLETE만 보면 실패 이력에서 폴링이 끝나지 않는다.

요청 완료와 카드 완료

batch count가 3이면 한 요청에서 결과 카드가 세 장 생긴다. 여기서 요청 완료 상태를 카드 skeleton에 그대로 쓰면 세 카드가 같은 순간에 풀린다.

const isLoaded = history.status === "COMPLETE";

return history.resourceSet.map((resource) => (
  <Skeleton isLoaded={isLoaded}>
    <Image src={resource.image} />
  </Skeleton>
));

요청이 완료되기 전에도 일부 결과 이미지가 먼저 준비될 수 있다. 응답 스키마도 이 가능성을 보여준다. resourceSet[].image가 optional이기 때문이다. 카드가 기다려야 하는 것은 전체 이력의 완료가 아니라 자기 이미지다.

return history.resourceSet.map((resource) => {
  const isLoaded = !!resource.image;

return (
    <Skeleton isLoaded={isLoaded}>
      {resource.image && <Image src={resource.image} />}
    </Skeleton>
  );
});

이렇게 하면 먼저 준비된 이미지는 먼저 열린다. 마지막 이미지가 끝날 때까지 전체 화면이 함께 막히지 않는다.

상태의 층위

이 화면에는 완료 상태가 두 층 있었다.

상태 단위 쓰는 곳
history.status 생성 요청 하나 폴링 유지, 실패 처리, 재생성 버튼 잠금
resourceSet[].image 결과 이미지 하나 카드 skeleton, 이미지 src

두 상태를 하나의 boolean으로 합치면 UI가 가장 느린 결과에 묶인다. 요청 상태는 작업 전체의 생명주기를 다루고, 항목 상태는 화면에 무엇을 보여줄지 결정한다.

v5 마이그레이션 주의

React Query v5에서는 useQuery 호출 형태와 refetchInterval 콜백 인자가 바뀐다. v4처럼 data를 직접 받는다고 가정하면 조건식이 깨질 수 있다. v5에서는 옵션 객체 형태로 쓰고, 콜백에서 query state를 통해 data를 읽는 방식으로 맞춰야 한다.

useQuery({
  queryKey: ["history", id],
  queryFn: () => fetchHistory(id),
  refetchInterval: (query) => {
    const history = query.state.data;
    return isTerminalStatus(history?.status) ? false : 2000;
  },
});

라이브러리 버전이 바뀌면 폴링 중단 조건부터 확인해야 한다. 타입 에러 없이도 interval이 계속 살아 있을 수 있다.

정리

비동기 생성 화면에서 “완료”는 하나가 아닐 수 있다.

요청이 끝났는가
결과 항목이 준비됐는가
사용자가 다음 행동을 할 수 있는가

이미지 생성 화면에서는 요청 단위 상태와 카드 단위 상태를 분리해야 했다. 폴링은 history.status로 멈추고, skeleton은 resourceSet[].image로 풀어야 한다. 응답 스키마에서 optional 필드는 UI가 기다릴 수 있는 지점을 알려준다. 그 필드가 여러 개라면 완료 상태도 여러 개다.