서버-클라이언트 상태 동기화로 UI Flickering 제거

Overview

이전 글에서 평점 데이터 분리 문제를 해결했지만, 또 다른 문제가 남아있었다. 바로 새로고침 시 발생하는 UI Flickering이었다. 사용자가 F5를 누르면 스켈레톤 UI가 잠깐 보이다가 실제 평점으로 바뀌는 현상이 발생했다. 이 문제를 React Query의 SSR Hydration으로 어떻게 해결했는지를 기록한다.

문제 상황: Flickering이 발생하는 이유

React Query의 메모리 캐시 특성

React Query는 기본적으로 브라우저 메모리에만 캐시를 저장한다. 이는 다음과 같은 특성을 가진다:

  • 페이지 새로고침(F5) 시 모든 메모리 상태가 초기화됨
  • 브라우저 탭을 닫고 다시 열면 캐시가 사라짐
  • JavaScript 실행 전까지는 캐시 데이터에 접근할 수 없음

이는 React Query가 브라우저 환경에서 동작하는 클라이언트 전용 라이브러리이기 때문이다. 서버에 캐시를 저장하거나 공유하지 않으며, 기본적으로 페이지 이동이나 새로고침 시 캐시는 초기화된다.

새로고침 시 발생하는 문제

🔄 Before: SSR Hydration 없을 때

이 과정에서 사용자는 스켈레톤 UI → 실제 평점으로 바뀌는 UI Flickering을 경험하게 된다.

새로고침 시 스켈레톤 UI가 잠깐 보이다가 실제 평점으로 바뀌는 Flickering 현상

해결책: SSR Hydration

SSR Hydration은 이 문제를 서버와 클라이언트 상태를 동기화함으로써 해결한다:

⚡ After: SSR Hydration 적용 후

SSR Hydration 적용 후 새로고침 시에도 모든 데이터가 즉시 표시되는 매끄러운 렌더링

구현 과정

1. 서버에서 데이터 미리 가져오기

// 평점 데이터 프리패칭
await Promise.allSettled(
  lectures.map(lecture => 
    queryClient.prefetchQuery({
      queryKey: ['lecture-rating', lecture.id, universityName],
      queryFn: () => getLectureRating(lecture.id, universityName)
    })
  )
);
 
const dehydratedState = dehydrate(queryClient);

2. 클라이언트로 캐시 상태 전달

<HydrationBoundary state={dehydratedState}>
  <LectureList lectures={lectures} />
</HydrationBoundary>

작동 원리

Hydration 과정

  1. 서버 렌더링: QueryClient가 서버에서 생성되고 API 호출로 데이터를 미리 가져옴
  2. 상태 직렬화: dehydrate()로 캐시 상태를 JSON으로 직렬화하여 HTML과 함께 전송
  3. 클라이언트 Hydration: 브라우저가 HTML을 즉시 렌더링하고, JavaScript 로드 후 캐시 상태를 복원
  4. 동기화 완료: React Query 훅들이 서버 캐시를 즉시 사용하여 Flickering 방지

또한 이 과정에서 실제 프로덕션 환경에서는 몇 가지 중요한 고려사항이 있다.

참고: React Query는 hydration 후에도 background refetch를 시도할 수 있으므로, 초기 Hydration 상태를 유지하려면 staleTime을 적절히 설정하여 background refetch를 지연시켜야 한다.

보안 고려사항

서버에서 클라이언트로 데이터를 직렬화하여 전송하는 과정에서 보안 이슈가 발생할 수 있다.

데이터 직렬화 보안

dehydrate() 과정에서 서버 데이터가 클라이언트로 직렬화되는데, 이때 민감한 정보가 포함되지 않도록 주의해야 한다. 만약에 민감할 수 있는 토큰의 데이터가 들어있다면?

// 안전하지 않은 예시
const dehydratedState = dehydrate(queryClient); // 모든 캐시 데이터 포함
 
// 안전한 예시  
const dehydratedState = dehydrate(queryClient, {
  shouldDehydrateQuery: (query) => {
    // 민감한 데이터는 제외
    return !query.queryKey.some(k => typeof k === 'string' && k.includes('user-token'));
  }
});

에러 핸들링

서버에서 데이터 prefetch가 실패해도 클라이언트 렌더링이 차단되지 않도록 Promise.allSettled를 사용했다.

// 일부 데이터 로딩 실패 시에도 페이지는 정상 렌더링
await Promise.allSettled(
  lectures.map(lecture => 
    queryClient.prefetchQuery({
      queryKey: ['lecture-rating', lecture.id, universityName],
      queryFn: () => getLectureRating(lecture.id, universityName)
    })
  )
);

결과

사용자 경험 개선

구분Before (Hydration 없음)After (Hydration 적용)
초기 렌더링스켈레톤 UI → 실제 데이터즉시 완전한 데이터 표시
렌더링 속도~200ms (체감 지연)체감상 즉시
사용자 경험Flickering 발생깜빡임 없는 즉시 로딩
데이터 신뢰성클라이언트 요청 의존서버 검증 포함

마무리

React Query의 SSR Hydration은 서버와 클라이언트 간의 상태 동기화를 자동으로 처리해주어, 복잡한 상태 관리 없이도 매끄러운 UX를 구현할 수 있게 해준다.

작은 UI Flickering 현상이었지만, 이를 해결하는 과정에서 React Query의 SSR Hydration에 대해 깊이 이해할 수 있었다. 무엇보다 사용자가 새로고침을 해도 완전히 매끄러운 경험을 제공할 수 있게 되어 큰 만족감을 느꼈다.