Frontend
TanStack Query로 사내 업무 앱의 비동기 상태 다루기
신*진··수정됨 2026.09.03
TanStack Query로 사내 업무 앱의 비동기 상태 다루기
시작하며
"확정" 버튼을 누른 뒤 바로 PDF 다운로드 버튼을 보여주면 될까요? 메일 발송 버튼을 누른 뒤에는 성공 토스트만 띄우고 끝내도 될까요? 저희 팀은 사내 업무 관리 시스템의 견적 기능을 만들면서 "아니오"라는 답을 얻었습니다. 견적은 단순히 저장하고 조회하는 데이터가 아니라, 초안 작성, 품목 수정, 금액 계산, 확정, PDF 생성, 메일 발송, 새 버전 재작성으로 이어지는 긴 업무 흐름이었습니다.
처음에는 API를 호출해서 데이터를 가져오고, 버튼을 누르면 다시 저장하는 정도로 생각했습니다. 하지만 실제로 구현해보니 문제는 화면 상태가 아니라 서버 상태였습니다. 확정 요청은 한 번만 처리되어야 했고, PDF는 서버에서 비동기로 생성됐고, 메일 발송은 큐에 들어간 뒤 나중에 성공하거나 실패했습니다.
이 글은 그 과정에서 저희가 TanStack Query로 정리한 세 가지 장치, 즉 서버 상태와 UI 상태 분리, 변경 후 재조회 전략, 그리고 비동기 작업 폴링을 정리한 기록입니다.
TanStack Query를 쓰게 된 이유
초기 화면에서는 useEffect로 데이터를 가져오고 useState에 담는 방식도 충분해 보였습니다.
const [quotation, setQuotation] = useState(null)
useEffect(() => {
getQuotation(id).then(setQuotation)
}, [id])하지만 견적 상세 화면에서는 이 방식이 금방 한계에 부딪혔습니다. 견적을 확정하면 문서 상태, 견적번호, 확정 시각, PDF 생성 상태가 함께 바뀝니다. 품목을 수정하면 견적의 version이 올라갑니다. 메일을 발송하면 발송 이력뿐 아니라 견적 상태도 SENT로 바뀔 수 있습니다.
어느 값 하나만 로컬 상태로 바꿔서는 화면 전체를 신뢰하기 어려웠습니다. 그래서 이 프로젝트에서는 기준을 하나 정했습니다.
금액과 업무 상태의 정본은 서버다. 프론트는 서버 응답을 기준으로 화면을 복원한다.
이 기준을 세우고 나니 TanStack Query의 역할도 분명해졌습니다. 견적 상세, PDF 생성 상태, 메일 발송 이력처럼 서버에서 온 데이터는 Query로 관리하고, 모달 열림 여부나 입력 중인 메일 제목처럼 화면 안에서만 의미 있는 값은 useState로 관리했습니다.
문제 1: 서버 상태와 UI 상태가 섞이기 시작했다
견적 상세 화면에는 여러 종류의 상태가 함께 존재합니다. 사용자가 보고 있는 견적 데이터도 있고, 메일 발송 모달이 열렸는지 여부도 있고, 발송 이력의 펼침 상태도 있습니다. 이걸 모두 useState로 관리하면 어떤 값이 서버에서 온 값이고 어떤 값이 화면에서만 쓰이는 값인지 점점 흐려집니다.
그래서 서버에서 온 견적 상세 데이터는 useQuery로 가져왔습니다.
const quoteQuery = useQuery({
queryKey: ["quotations", quotationId],
queryFn: () => getQuotation(quotationId),
})여기서 중요한 건 queryKey였습니다.
["quotations", quotationId]이 키는 "특정 견적 상세 데이터"를 식별합니다. 같은 견적을 보는 컴포넌트는 같은 캐시를 공유할 수 있고, 견적이 바뀌었을 때 이 키를 기준으로 다시 조회할 수 있습니다.
반대로 모달이 열려 있는지, 메일 제목에 어떤 값을 입력했는지, 발송 이력 한 줄이 펼쳐졌는지는 서버 상태가 아닙니다. 이런 값은 그대로 useState에 남겼습니다.
이렇게 나누고 나니 화면 코드가 훨씬 읽기 쉬워졌습니다. 서버 데이터는 Query Cache에서 오고, 화면 안에서만 필요한 값은 컴포넌트 상태에 남습니다.

문제 2: 저장은 됐는데 화면은 예전 상태였다
업무 앱에서 가장 헷갈리는 순간은 요청이 성공했는데 화면 일부가 예전 상태로 남아 있을 때입니다.
견적 기능에서는 이 문제가 특히 위험했습니다. 금액, 권한, 문서 상태, 수정 가능 여부가 모두 서버 정책에 따라 결정되기 때문입니다. 예를 들어 견적을 확정하면 단순히 documentStatus만 CONFIRMED로 바꾸면 되는 게 아니었습니다. 견적번호가 발급되고, 확정 시각이 생기고, PDF 생성 상태가 PROCESSING으로 바뀔 수 있었습니다.
그래서 변경 작업은 useMutation으로 처리하고, 성공한 뒤에는 서버에서 다시 읽었습니다.
const confirmMutation = useMutation({
mutationFn: () =>
confirmQuotation(quotationId, version, newIdempotencyKey()),
onSuccess: async (confirmed) => {
await onQuotationChanged()
toast.success(
confirmed.quotationNumber
? `견적을 확정했습니다 - ${confirmed.quotationNumber}`
: "견적을 확정했습니다"
)
},
})useMutation을 쓰면 요청 중 상태도 자연스럽게 처리할 수 있습니다.
<Button disabled={confirmMutation.isPending}>
{confirmMutation.isPending ? "확정 중..." : "확정"}
</Button>견적 확정처럼 중복 요청이 위험한 작업에서는 이 pending 상태가 중요했습니다. 사용자가 버튼을 여러 번 누르지 못하게 막고, 서버에는 Idempotency-Key를 함께 보내 같은 요청이 중복 처리되지 않게 했습니다.
변경 후 최신 견적을 다시 읽는 함수는 다음처럼 만들었습니다.
const onQuotationChanged = React.useCallback(async () => {
const fresh = await queryClient.fetchQuery({
queryKey: ["quotations", quotationId],
queryFn: () => getQuotation(quotationId),
})
return fresh.version
}, [queryClient, quotationId])여기서 fresh.version을 반환한 이유는 견적 변경 요청에 낙관적 잠금용 version이 필요했기 때문입니다. 품목 수정이나 가격 재적용처럼 금액에 영향을 주는 작업에서는 오래된 version으로 요청하면 서버가 충돌로 거절할 수 있습니다.
그래서 가격 재적용 같은 작업에서는 요청 직전에 최신 견적을 다시 가져왔습니다.
const fresh = await getQuotation(quotationId)
return repriceQuotation(quotationId, {
customerType,
defaultMarginRate,
version: fresh.version,
})모든 요청 전에 무조건 다시 조회하면 네트워크 비용이 커집니다. 하지만 금액에 영향을 주거나 충돌 가능성이 높은 작업에서는 최신 version을 확인하는 쪽이 더 안전했습니다.
문제 3: 요청은 끝났지만 서버 작업은 아직 진행 중이었다
견적을 확정하면 사용자는 "확정이 끝났다"고 생각할 수 있습니다. 하지만 시스템 안에서는 아직 끝난 게 아닙니다. 서버가 PDF 생성 작업을 시작하고, 그 작업이 끝나야 다운로드 버튼을 활성화할 수 있습니다.
상태는 대략 이렇게 변했습니다.
NONE -> PROCESSING -> READY
-> FAILED이 흐름을 직접 setInterval로 만들 수도 있습니다. 하지만 컴포넌트가 사라졌을 때 타이머를 정리해야 하고, 중복 타이머가 생기지 않게 해야 하고, 실패했을 때 멈추는 조건도 따로 관리해야 합니다.
TanStack Query의 refetchInterval을 쓰면 현재 쿼리 데이터에 따라 폴링 여부를 정할 수 있습니다.
const documentQuery = useQuery({
queryKey: ["quotations", quotationId, "documents"],
queryFn: () => getQuotationDocumentStatus(quotationId),
refetchInterval: (query) =>
query.state.data?.documentGenerationStatus === "PROCESSING"
? 4000
: false,
})여기서 핵심은 PDF가 생성 중일 때만 4초마다 다시 조회한다는 점입니다. PROCESSING이면 계속 확인하고, READY가 되면 다운로드 버튼을 활성화한 뒤 폴링을 멈춥니다. FAILED가 되면 폴링을 멈추고 재생성 버튼을 보여줍니다.

이 패턴은 PDF 생성뿐 아니라 메일 발송에도 그대로 적용됐습니다. 사용자가 메일 발송 버튼을 누르면 서버는 발송 요청을 큐에 넣습니다. 실제 성공이나 실패는 발송 이력을 다시 조회해야 알 수 있습니다.
const deliveriesQuery = useQuery({
queryKey: ["quotations", quotationId, "deliveries"],
queryFn: () => getQuotationDeliveries(quotationId),
refetchInterval: (query) =>
query.state.data?.content?.some((delivery) =>
delivery.status === "PENDING" ||
delivery.status === "PROCESSING"
)
? 4000
: false,
})최근 발송 이력이 PENDING 또는 PROCESSING이면 계속 확인하고, SENT나 FAILED가 되면 멈춥니다.
발송 요청이 성공했을 때는 발송 이력만 다시 읽지 않았습니다. 견적 상세도 함께 갱신했습니다.
onSuccess: async () => {
await Promise.all([
deliveriesQuery.refetch(),
onQuotationChanged(),
])
}메일 발송 결과에 따라 견적의 문서 상태가 바뀔 수 있기 때문입니다. 이력만 최신인데 상단 상태 배지는 예전 값으로 남아 있으면 사용자는 어느 쪽을 믿어야 할지 헷갈립니다.
invalidateQueries와 refetch를 구분해서 쓰기
처음에는 변경이 생기면 전부 invalidateQueries만 쓰면 된다고 생각했습니다. 하지만 실제 화면을 만들다 보니 두 가지를 구분하는 편이 더 명확했습니다.
여러 목록이나 관련 화면까지 넓게 최신화해야 할 때는 invalidateQueries를 사용했습니다.
queryClient.invalidateQueries({ queryKey: ["quotations"] })반대로 지금 화면에서 바로 최신 데이터가 필요할 때는 refetch나 fetchQuery를 사용했습니다.
await documentQuery.refetch()
await deliveriesQuery.refetch()예를 들어 PDF 재생성 버튼을 누른 직후에는 문서 상태를 바로 다시 읽어야 합니다. 이때는 단순히 invalidate만 걸어두는 것보다 해당 쿼리를 명시적으로 refetch하는 쪽이 사용자 피드백이 빠릅니다.

TanStack Query가 해결해준 것과 해결하지 않는 것
TanStack Query를 쓰면서 좋았던 점은 서버 상태 관리의 반복 코드가 크게 줄었다는 것입니다. 로딩, 실패, 재시도, 캐시, 폴링, 수동 갱신 같은 흐름을 매번 직접 만들지 않아도 됐습니다.
특히 PDF 생성과 메일 발송처럼 서버 상태가 시간이 지나며 바뀌는 기능에서는 refetchInterval이 큰 도움이 됐습니다. 진행 중일 때만 확인하고, 끝나면 멈추는 구조를 간단하게 만들 수 있었습니다.
하지만 TanStack Query가 모든 설계를 대신해주지는 않습니다.
무엇을 queryKey로 잡을지, 변경 후 어떤 데이터를 다시 읽을지, 어디까지 낙관적으로 갱신할지, 어떤 작업에 Idempotency-Key가 필요한지는 여전히 애플리케이션이 결정해야 합니다.
저희 프로젝트에서는 낙관적 UI를 과하게 쓰지 않았습니다. 견적 금액과 문서 상태는 업무적으로 중요한 값이기 때문에, 프론트에서 먼저 바꿔 보여주기보다 서버 응답을 다시 읽는 쪽을 선택했습니다.
정리하며
이번 견적 기능을 구현하면서 TanStack Query를 단순한 API 호출 도구가 아니라 서버 상태를 다루는 기준점으로 쓰게 됐습니다.
조회는 useQuery로 캐시에 올리고, 변경은 useMutation으로 요청 상태를 관리했습니다. 변경 후에는 서버에서 다시 읽어 화면을 복원했고, PDF 생성이나 메일 발송처럼 시간이 걸리는 작업은 조건부 폴링으로 따라갔습니다.
결과적으로 화면 코드는 "언제 API를 부를지"보다 "현재 서버 상태에서 무엇을 보여줄지"에 집중할 수 있었습니다.
사내 업무 앱에서는 데이터 하나가 단순한 값이 아니라 업무 상태인 경우가 많습니다. 확정됐는지, 발송됐는지, 실패했는지, 다시 시도할 수 있는지에 따라 사용자가 할 수 있는 일이 달라집니다. TanStack Query는 이런 서버 상태의 변화를 화면이 안정적으로 따라가게 해주는 좋은 도구였습니다.
다만 중요한 원칙은 라이브러리보다 앞에 있었습니다.
서버가 정본인 값은 서버에서 다시 읽는다.
중복되면 안 되는 작업은 중복 방지 키를 둔다.
진행 중인 작업은 완료될 때까지만 확인한다.
화면 상태와 서버 상태를 섞지 않는다.
이 네 가지 기준을 세운 뒤에야 TanStack Query의 장점이 제대로 보였습니다.
읽기 도구
약 14분 읽기
이 글이 도움이 되었나요?
Work with GIWorks
프로젝트에 GIWorks의 경험이 필요하신가요?
전시·콘텐츠·엔지니어링 프로젝트의 아이디어와 고민을 들려주세요. 필요한 기술과 실행 방법을 함께 찾겠습니다.
문의하기