trpc 데이터 깜박임 문제 해결하기: 문제 인식부터 해결까지

Yuji MinYuji Min
7 min read

1. 서론

화면에서 데이터가 잠깐 사라졌다가 다시 나타나는 ‘깜빡임’은 프론트엔드 개발자라면 누구나 한 번쯤 마주했을 문제이다. 서버 데이터를 다시 패칭할 때 기존 값이 잠시 사라지며 발생하는 이 '깜빡임' 현상은 사용자 경험에 큰 영향을 준다. 이번 글에서는 내가 실제 프로젝트에서 마주한 이 문제 상황을 중심으로, 원인을 분석하고 다양한 해결 방안을 비교하며 어떤 선택을 내렸는지를 기록하고자 한다.

2. 문제 상황 인지 및 맥락 설명

이번 프로젝트에서 나는 QA Verification 단계에서 이 깜빡임 현상을 해결하는 이슈를 담당하게 되었다. Verification 단계에서는 이미 QA 담당자가 Pass 처리한 테스트에 영향을 주지 않도록, 기존 기능의 안정성을 유지하면서 코드 수정 범위를 최소화하여 문제를 해결하는 것이 가장 중요한 요구사항이었다. 즉, 불필요한 리팩토링보다는 현상을 정확히 파악하고, 제한된 환경에서 실현 가능한 해결책을 찾아야 했다.

문제는 다음과 같은 흐름에서 발생했다. 이번 프로젝트에서 작업한 페이지는 사용자 입력값을 서버로 보내 시뮬레이션한 결과를 보여주는 페이지였다. 실제 프로젝트 설명이 어려운 관계로, 이해를 돕기 위해 '삶는 시간에 따른 달걀의 익힘 정도 시뮬레이션'이라는 비유를 사용해 설명하겠다. 이 때, 사용자가 조정하는 입력값은 ‘삶는 시간’이었고, 결과값은 ‘달걀의 익은 정도’로 표현된다. 단, 서버에는 이미 기존에 세팅된 삶는 시간과 그 시간동안 삶은 달걀의 익은 정도도 함께 저장되어 있어 클라이언트가 삶는 시간을 0으로(초기값) 하여 데이터를 요청하면 {time: 12, eggStatus: 30}과 같은 형태로 기존에 서버에 저장되어 있던 삶는 시간과 달걀의 익은 정도를 반환한다. 즉, 페이지 첫 접근 시 기본적으로 서버에 저장되어 있던 위 데이터가 렌더링되는 것이다.

종합하여 말하자면 클라이언트에서는 자신이 시뮬레이션 해보고 싶은 시간을 조정하여 modifiedTime을 서버에 전송하며, 서버에서는 { time: 기존에 서버에 세팅된 삶는 시간, eggStatus: 클라이언트에서 전송한 삶는 시간을 기준으로 시뮬레이션한 결과인 달걀의 익은 정도 }를 응답해주는 형태인 것이다.

// pages/boilSimulation/index.page.tsx
// ...
function BoilSimulationPage() {
  const [modifiedTime, setModifiedTime] = useState(0);
  const {
    data, // {time, eggStatus}
    error,
    isLoading,
  } = useBoilSimulationResult(modifiedTime);

  return (
    <div>
      <div>Current Time: {data?.time}</div>
      <div>
        <button onClick={() => setModifiedTime(prev => prev + 1)}>+</button>
        <div>Simulation Time: {modifiedTime}</div>
        <button onClick={() => setModifiedTime(prev => prev - 1)}>-</button>
      </div>
      <div>
        {isLoading ? '🌀Loading Spinner' : data?.eggStatus}
      </div>
    </div>
  );
}


// queries/boilSimulation.ts
// 클라이언트 코드 내부에서 사용하는, trpc의 useQuery 훅이 반환하는 쿼리 결과 객체를 그대로 리턴하는 커스텀 훅
export function useBoilSimulationResult(args: Parameters<typeof trpc.boilSimulation.results.useQuery>[0]) {
  return trpc.boilSimulation.results.useQuery(args);
}

// server/routers/boilSimulation.ts
export const boilSimulationRouter = router({
  results: procedure.input(boilSimulationResultsSchema).query(async ({ input }) => {
    // 시뮬레이션 로직 생략

    return boilSimulationResults; // {time, eggStatus}
  }),
});

이 중 eggStatus는 시뮬레이션 결과값이므로 로딩 스피너를 적용하여 서버 응답 지연에 따른 깜빡임을 방지하고 있었고, 기존 서버에 세팅한 time 값을 나타내는 텍스트 컴포넌트는 서버에서 처음 받아온 이후로 바뀔 일이 없는 값이므로 로딩 처리를 하지 않았다. 따라서 사용자가 + 또는 - 버튼을 클릭하여 modifiedTime를 변경하고 변경된 modifiedTime에 대한 새 simulation 결과가 요청되었을 때, 따로 업데이트 될 일이 없는 time 값도 함께 잠시 사라졌다가 다시 나타나는 깜빡임이 발생했다.

Tanstack Query는 새 데이터 fetch 시 기본적으로 기존 데이터를 제거하고, 이후 데이터를 받아오면서 다시 캐싱한다. 이 과정에서 일시적으로 dataundefined가 되어 깜빡임이 발생한다. 여기서 time 값은 data 객체가 가진 필드의 일부로서 eggStatus와 함께 묶여있는 값이기 때문에 최초에 받아온 이후 변경되지 않아야 함에도 불구하고 data가 다시 페칭되면서 값이 잠시 사라지며 깜빡이는 것이었다.

3. 문제 해결을 위한 다양한 방안 검토

문제의 원인을 정확히 분석한 뒤, 여러 해결 방법을 고민해보았다. 그중 현실적인 제약과 구조적 복잡도를 함께 고려하여 다음 세 가지 대안을 도출하였다.

1) API 분리

  • 아이디어: timeeggStatus를 각각의 tRPC 쿼리로 분리하여 개별적으로 관리하는 방법이다.

  • 장점: 각 필드의 갱신 여부에 따라 독립적으로 페칭할 수 있으므로, 필요 없는 필드까지 함께 깜빡이지 않도록 제어할 수 있다.

  • 제외 이유: Verification 단계에서 API 구조 자체를 변경하는 것은 리스크가 크고, 클라이언트와 서버 양쪽에 큰 코드 수정이 필요하므로 실현 가능성이 낮다.

2) 별도 클라이언트 state 관리

  • 아이디어: 서버에서 받은 time을 클라이언트 state에 복사하여 별도로 관리하는 방식이다.

  • 장점: 클라이언트 state에 있는 값은 서버 fetch와 관계없이 유지되므로 깜빡임 없이 렌더링할 수 있다.

  • 제외 이유: Tanstack Query의 철학은 서버 state의 일관된 관리에 있으므로, 단지 렌더링 목적만으로 local state를 추가하는 것은 라이브러리의 방향성과 어긋난다고 판단하였다. 또한 상태 동기화 문제가 발생할 가능성이 높아, 유지보수 측면에서도 부담이 컸다.

3) keepPreviousData 옵션 활용

  • 아이디어: Tanstack Query의 placeholderData: keepPreviousData 옵션을 사용하여, 새로운 데이터를 받아오기 전까지 이전 데이터를 유지하는 방식이다.

  • 선택 이유: 기존 구조를 유지하면서도 깜빡임 문제를 해결할 수 있는 유일한 방법이었다. 코드 수정 범위도 작고, Tanstack Query의 활용도에 부합하는 방식이어서 채택하기에 적합했다.

  • 적용 결과: 데이터 페칭이 발생했을 때 이전 데이터를 그대로 유지하면서 새로운 데이터를 백그라운드에서 받아오고 새 데이터가 도착하면 바로 이전 데이터를 대체해버리기 때문에 undefined로 초기화되는 과정에 빠지면서 time 텍스트의 깜빡임이 발생하지 않는다.

import { keepPreviousData } from '@tanstack/react-query'

export function useBoilSimulationResult(args: Parameters<typeof trpc.boilSimulation.results.useQuery>[0]) {
  return trpc.boilSimulation.results.useQuery(args, { placeholderData: keepPreviousData }); // keepPreviousData 옵션 추가
}

4. keepPreviousData 적용 시 고려할 점

keepPreviousData를 적용한 뒤, 예상과는 다른 문제가 새로 드러났다. 이전에는 eggStatus가 서버 요청 중일 때 isLoading이 true로 바뀌면서 로딩 스피너가 자연스럽게 나타났지만, 옵션 적용 이후에는 isLoading이 true가 되지 않아 스피너가 보이지 않게 된 것이다.

isLoading vs isPending vs isFetching 비교

  • 세 가지 항목을 비교하려면 query와 관련된 기본 지식 중 statusfetchStatus에 대해 알아야 한다.
구분
status: 데이터를 쿼리가 가지고 있는가?pending: 쿼리가 아직 데이터를 가지지 못한 상태error: 쿼리 요청이 에러로 인해 실패한 상태success: 데이터가 fetch에 성공하여 쿼리에 캐싱됨
fetchStatus: 데이터 페칭 요청(queryFn)이 실행되고 있는가?fetching: 데이터 페칭 요청 진행 중인 상태paused: 쿼리는 데이터 페칭 요청을 하였으나 일시 정지된 상태, 네트워크 커넥션이 돌아오면 재개됨idle: 쿼리가 아무런 동작을 하지 않고 있는 상태
  • isLoading: TkDoDo가 작성한 코멘트와 Tanstack Query 소스코드에 따르면 statusfetchStatus의 조합으로 결정된다.

      const isFetching = newState.fetchStatus === 'fetching'
      const isPending = status === 'pending'
      const isError = status === 'error'
    
      const isLoading = isPending && isFetching
    

    즉, 데이터를 아직 가지고 있지 않은 상태(status === ‘pending’)에 데이터 페칭 요청 진행 중일 때(fetchStatus === ‘fetching’)에만 true로 바뀐다. 즉, keepPreviousData가 활성화된 상황에서는 이전 데이터가 그대로 유지되므로(status ≠ ‘pending’) 더 이상 true로 바뀌지 않는다. 따라서 이를 기준으로 로딩 스피너를 렌더링하면 깜빡임 없이 새로 데이터 페칭이 되는 상황을 표시할 수 없다.

  • isPending: Tanstack Query v5에서 추가된 상태로, 문서상으로 isLoading의 대체 개념으로 보이지만 자세히 들어가면 차이가 존재한다. 위 소스코드에 따르면 isLoadingisPendingisFetching이 둘 다 충족되어야 true이지만, isPending은 쿼리가 아직 데이터를 가지지 못한 상태(status === ‘pending’)이기만 하면 true가 되기 때문이다. 즉, isPendingisLoading을 포함하는, 더 넓은 범위의 개념인 것이다. 특히 isPending은 TypeScript에서도 dataundefined 여부와 직접적으로 연결되므로, !isPending && !isError 분기를 통과하면 data는 안전하게 정의되어 있음을 보장받을 수 있다. 하지만 결과적으로 내가 해결하려는 문제의 관점에서는 쿼리가 이전 데이터를 유지하고 있기 때문에 isLoading과 동일하게 로딩 스피너를 렌더링하는 기준으로 사용하기에 적합하지 않다.

  • isFetching: 데이터 페칭 요청 진행 중인 상태이기만 하면 무조건 true가 된다. 즉, keepPreviousData 옵션을 사용하여 이전 데이터가 유지되는 여부와 관계 없이, 새로운 데이터를 받아오기 위해 백그라운드에서 페칭이 시작되면 무조건 true로 바뀌는 것이다. 따라서 현재 상황에서 로딩 스피너를 제어할 목적으로는 isFetching을 기준으로 삼는 것이 가장 안정적이었다.

위 내용을 표로 정리하면 아래와 같다.

statusfetchStatusisLoadingisFetching
pendingfetchingtruetrue
successfetchingfalsetrue
successidlefalsefalse
pendingidlefalsefalse
errorfetchingfalsetrue
erroridlefalsefalse

이러한 상태값들의 의미를 Tanstack Query 공식 문서와 Repository에 게시된 Discussions 등을 통해 직접 비교해보며 확인했고, 그 결과 기존의 isLoading을 제거하고 isFetching을 기준으로 로딩 UI를 제어하도록 변경하기로 결정하였다. 깜빡임 문제를 해결하기 위한 keepPreviousData 옵션의 핵심은, 이전 값을 유지하는 기능뿐만 아니라 쿼리 상태 관리 방식의 변화까지 이해하는 데 있었다.

5. 결론

이번 경험은 단순히 데이터 깜빡임을 해결한 것을 넘어, 제약 조건 속에서 기술적 선택의 우선순위와 기준을 어떻게 세워야 하는가를 고민하게 만든 사례였다. 특히 TanStack Query의 상태 체계(status, fetchStatus)를 정확히 이해하지 못했다면 놓치기 쉬운 문제였고, 이를 구조 변경 없이 해결하기 위한 keepPreviousData의 활용은 실용적이면서도 합리적인 선택이었다. 여기에 isFetching을 기준으로 로딩 상태를 판단하면서 기존 UI 흐름을 해치지 않고 문제를 해결할 수 있었다.

물론 이상적으로는 처음부터 timeeggStatus를 분리된 API로 설계했더라면 더 나았을 수도 있다. 하지만 현실에서는 항상 이상적인 구조만을 고수할 수는 없다. 상황에 맞는 최선의 선택과 정확한 상태 판단이 더 중요하다고 생각한다. 이처럼 작은 UI 문제 속에서도 기술적인 깊이를 가지고 판단하고, 시스템에 대한 정확한 이해를 바탕으로 해결하는 경험은 개발자로서의 성장을 이끄는 중요한 계기였다. 앞으로도 나는 이렇게 작지만 본질적인 문제에 주목하며, 더욱 정교한 사용자 경험을 설계하는 개발자가 되고자 한다.

0
Subscribe to my newsletter

Read articles from Yuji Min directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Yuji Min
Yuji Min

👩🏻‍💻 𝗖𝗼𝗹𝗹𝗮𝗯𝗼𝗿𝗮𝘁𝗶𝘃𝗲 𝗙𝗿𝗼𝗻𝘁𝗲𝗻𝗱 𝗦𝗼𝗳𝘁𝘄𝗮𝗿𝗲 𝗗𝗲𝘃𝗲𝗹𝗼𝗽𝗲𝗿 with 2+ years of experience building user-centric, maintainable web applications. I specialize in designing reusable components and optimizing workflows to enhance productivity and user satisfaction. 🚀 I thrive in environments where I can leverage 𝗧𝘆𝗽𝗲𝗦𝗰𝗿𝗶𝗽𝘁, 𝗡𝗲𝘅𝘁.𝗷𝘀, and 𝗥𝗲𝗮𝗰𝘁 to create impactful solutions. I am passionate about user-first designs and fostering collaboration to drive innovation. 📍 My experience working in diverse settings has taught me the value of transparency, clear communication, and teamwork. I bring a wide perspective to problem-solving and enjoy contributing to mission-driven projects. 🙋🏻‍♀️ Let’s connect! Reach me at yuji.min.dev@gmail.com. Check out my work on GitHub(https://github.com/nvrtmd) or visit my blog(https://sage-min.hashnode.dev/) for more insights.