(React) React Query는 훌륭하지만, 인증 관리 매니저는 아닙니다

호두파파·약 16시간 전
3

React

목록 보기
40/40
post-thumbnail

안녕하세요. 정말 오랜만에 글을 쓰는 것 같네요. 마지막 포스팅 이후로 거의 1년 반 만입니다.

그동안 새로운 프로젝트에 참여하면서 다양한 경험을 쌓았는데요. 오늘은 그중에서도 React Query를 사용한 인증 처리에 대해 이야기해보려고 합니다.

최근 프로젝트 코드 리뷰를 진행하면서 흥미로운 패턴을 발견했는데요. React Query의 useQuery 훅을 사용해서 사용자 인증 상태를 관리하는 코드였습니다. 얼핏 보면 괜찮아 보이지만, 자세히 들여다보니 몇 가지 문제가 있더라고요.

이 글에서는 왜 useQuery가 인증 처리에 적합하지 않은지, 그리고 어떻게 개선할 수 있는지 제 경험을 공유해보려고 합니다.

문제의 발단: 이런 코드를 본 적 있으신가요?

export function useAuth() {
  const hasToken = !!tokenStorage.getAccess(); // 동기: 즉시 확인
  
  const { data: user, isLoading } = useGetMyInfo({
    enabled: hasToken, // 비동기: API 호출 대기
  });
  
  return { user, isLoading };
}

코드 리뷰를 진행했던 프로젝트에서는 이렇게 useQuery로 사용자 정보를 가져와서 인증 여부를 확인하고 있었어요. 처음에는 "React Query를 잘 활용하고 있네?"라고 생각했는데, 실제 동작을 살펴보니 문제가 보이기 시작했습니다.

인증과 사용자 정보, 같은 건가요?

이 문제를 이해하려면 먼저 두 가지 개념을 구분해야 합니다.

인증 확인이란?

"사용자가 로그인했는가?"를 확인하는 것입니다. 이건 사실 아주 간단해요. localStorage나 Cookie에 저장된 토큰이 있는지만 확인하면 되거든요. 즉시 확인 가능하고, 서버에 물어볼 필요가 없습니다.

사용자 정보 조회란?

"사용자의 프로필 데이터는 뭐지?"를 확인하는 것입니다. 이건 서버에서 가져와야 하죠. 닉네임, 프로필 이미지, 이메일 같은 정보들이요. 당연히 API 호출이 필요하고 시간이 걸립니다.

프로젝트를 분석해보니 재미있는 사실을 발견했는데요.

인증 확인이 필요한 곳은 3군데였습니다.

  • 보호된 페이지에 접근할 때 (AuthGuardProvider)
  • 헤더에서 로그인/로그아웃 버튼을 보여줄 때
  • 모임 참가 버튼을 활성화할 때

사용자 정보가 필요한 곳은 2군데였습니다.

  • 헤더에 프로필 이미지와 닉네임을 보여줄 때
  • 마이페이지에서 전체 프로필을 보여줄 때

보이시나요? 인증 확인은 필요한데 사용자 정보는 필요 없는 곳이 더 많다는 걸요.

useQuery로 인증을 처리하면 생기는 문제들

1. 불필요한 네트워크 요청

사용자가 /dashboard 페이지에 접근하는 상황을 생각해볼까요?

1. AuthGuardProvider가 마운트됩니다
2. tokenStorage.getAccess()로 토큰 확인 (0ms, 즉시!)
3. useGetMyInfo() API 요청 시작
4. 네트워크 왕복하며 대기... (200-300ms)
5. 서버에서 사용자 정보 받아옴
6. 받은 정보로 인증 여부 확인

토큰이 이미 localStorage에 있는데, 굳이 서버에 물어봐야 할까요?

2. 깜빡이는 화면

실제로 AuthGuardProvider 코드를 보면 이렇게 되어 있었습니다.

export default function AuthGuardProvider({ children }) {
  const { user, isLoading } = useAuth();
  
  useEffect(() => {
    if (isLoading || !isProtectedRoute(pathname)) return;
    
    if (!user) {
      router.replace(`${PATH.SIGNIN}?redirect=${pathname}`);
    }
  }, [pathname, isLoading, user]);
  
  return <>{children}</>;
}

여기서 isLoading이 true인 동안에는 가드 체크를 하지 않습니다. 그 말은 즉, API 응답을 기다리는 200-300ms 동안 사용자는 빈 화면이나 로딩 스피너를 보게 된다는 거죠.

토큰 확인은 즉시 가능한데 말이에요.

3. React Query의 본래 목적과 맞지 않습니다

React Query의 메인테이너인 TkDodo의 블로그를 보면 이런 말이 나옵니다.

"React Query is designed for asynchronous server state management."

React Query는 서버 상태를 관리하기 위한 도구입니다. 그런데 인증 상태는 어떤가요?

  • 토큰은 이미 클라이언트에 있습니다 (localStorage/Cookie)
  • 동기적으로 즉시 확인 가능합니다
  • 서버에 물어볼 필요가 없습니다

useQuery의 강력한 기능들(자동 리페칭, 캐싱, 리트라이 등)이 이 경우에는 오히려 복잡도만 높이는 거죠.

4. 인증 상태가 여기저기 흩어집니다

useQuery를 사용하면 각 컴포넌트에서 독립적으로 인증을 확인하게 되는데요. 이게 문제가 됩니다.

// Header.tsx
function Header() {
  const { data: user } = useGetMyInfo({ enabled: !!token });
  // ...
}

// AuthGuard.tsx
function AuthGuard() {
  const { data: user } = useGetMyInfo({ enabled: !!token });
  // ...
}

// MyProfileButton.tsx
function MyProfileButton() {
  const { data: user } = useGetMyInfo({ enabled: !!token });
  // ...
}

React Query가 queryKey로 캐싱을 해주긴 하지만, 각 컴포넌트가 마운트될 때마다 "내가 인증 상태를 확인해야겠다"고 생각하게 됩니다. 인증 로직이 컴포넌트 곳곳에 파편화되는 거죠.

이렇게 되면 몇 가지 문제가 생깁니다.

로그아웃 처리가 복잡해집니다

로그아웃을 하면 어떻게 해야 할까요? 모든 컴포넌트의 useQuery를 invalidate해야 합니다.

const logout = () => {
  tokenStorage.clear();
  queryClient.invalidateQueries(['user']);
  // 혹시 빠뜨린 쿼리는 없을까?
}

동기화 이슈가 발생할 수 있습니다

각 컴포넌트가 독립적으로 API를 호출하면, 타이밍에 따라 다른 결과를 받을 수 있습니다. 토큰이 만료되는 순간에 한 컴포넌트는 성공하고 다른 컴포넌트는 실패할 수도 있죠.

"진짜 인증 상태"가 어디 있는지 불명확합니다

인증 상태의 Single Source of Truth가 없어집니다. Header의 user가 진짜일까요? AuthGuard의 user가 진짜일까요? React Query 캐시가 진짜일까요?

// 인증 상태를 확인하려면 어디를 봐야 할까요?
const user1 = queryClient.getQueryData(['user']); // React Query 캐시
const user2 = useGetMyInfo(); // 컴포넌트 A
const user3 = useGetMyInfo(); // 컴포넌트 B
// 진실은 어디에?

이런 상황에서 디버깅을 해본 적 있으신가요? 한 컴포넌트에서는 로그인 상태인데 다른 컴포넌트에서는 로그아웃 상태로 보이는 버그요. 정말 찾기 어렵습니다.

5. React Query 기능을 끄게 만듭니다

실제로 이 문제를 겪은 개발자들은 결국 이런 코드를 작성하게 됩니다.

const { data: user } = useQuery(['user'], getUser, {
  staleTime: Infinity,
  cacheTime: Infinity,
  refetchOnMount: false,
  refetchOnWindowFocus: false,
  refetchOnReconnect: false,
});

React Query의 모든 기능을 꺼버리는 거죠. 그럼 이제 질문이 생깁니다.

"이 모든 기능을 꺼야 한다면, 왜 useQuery를 사용하나요?"

개발자들도 같은 고민을 했습니다

제가 이 문제를 발견하고 리서치를 해보니, 많은 개발자들이 비슷한 고민을 하고 있더라고요.

TanStack Query의 GitHub Discussion에서 누군가 이런 질문을 했습니다.

"useQuery('users', getUser)를 여러 곳에서 사용해도 될까요? 아니면 한 번만 사용하고 다른 곳에서는 queryClient.getQueryData를 써야 할까요?"

그리고 Codemzy라는 개발자는 블로그에서 이렇게 말했습니다.

"사용자 데이터를 다시 요청할 이유가 없어요. 모든 API 호출에서 인증을 확인하니까요. 인증이 만료되면 API 응답에서 에러가 나거든요."

결국 많은 개발자들이 staleTime: Infinity로 설정하고 React Query의 장점을 포기하게 되더라고요.

Stack Overflow에도 비슷한 질문들이 많습니다.

  • "React Query로 인증 상태를 어떻게 관리하나요?"
  • "isLoading 때문에 인증 가드 리다이렉트가 느려요"
  • "React Query가 사용자 데이터를 계속 리페칭해요"

이런 질문들 자체가 도구를 잘못된 용도로 사용하고 있다는 신호라고 생각합니다.

그럼 어떻게 해야 할까요?

커뮤니티에서 권장하는 방법은 관심사를 분리하는 것입니다. Zustand나 Context 같은 클라이언트 상태 관리 도구와 React Query를 함께 사용하는 거죠.

Zustand로 인증 상태 관리하기

import { create } from "zustand";
import { tokenStorage } from "@/lib/utils/token";

interface AuthStore {
  isAuthenticated: boolean;
  login: (token: string) => void;
  logout: () => void;
  validateToken: () => void;
}

export const useAuthStore = create<AuthStore>((set, get) => ({
  // 초기값: 토큰 존재 여부로 즉시 판단
  isAuthenticated: tokenStorage.hasValidAccess(),
  
  login: (token) => {
    tokenStorage.set(token);
    set({ isAuthenticated: true });
  },
  
  logout: () => {
    tokenStorage.clear();
    set({ isAuthenticated: false });
  },
  
  validateToken: () => {
    if (!tokenStorage.hasValidAccess()) {
      get().logout();
    }
  }
}));

이렇게 하면 인증 상태를 즉시 확인할 수 있습니다.

React Query는 사용자 정보가 필요할 때만

export function useUserProfile() {
  const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
  
  return useQuery({
    queryKey: ['user', 'profile'],
    queryFn: getUserProfile,
    enabled: isAuthenticated, // 인증된 경우에만
    staleTime: 5 * 60 * 1000, // 5분
    refetchOnWindowFocus: true, // 이제 이 기능들을 제대로 활용!
  });
}

이제 React Query를 본래 목적대로 사용할 수 있습니다.

AuthGuard는 즉시 동작합니다

export default function AuthGuardProvider({ children }) {
  const pathname = usePathname();
  const router = useRouter();
  const { isAuthenticated, validateToken } = useAuth();
  
  useEffect(() => {
    validateToken(); // 즉시 실행!
    
    if (!isProtectedRoute(pathname)) return;
    
    if (!isAuthenticated) { // isLoading 없음!
      router.replace(`${PATH.SIGNIN}?redirect=${pathname}`);
    }
  }, [pathname, isAuthenticated]);
  
  return <>{children}</>;
}

isLoading을 기다릴 필요가 없습니다. 인증 확인은 즉시 끝나니까요.

성능 개선 효과

실제로 측정해보면 이 정도 차이가 납니다.

기존 방식 (useQuery)

  • 인증 확인: ~250ms (API 응답 대기)
  • 초기 렌더링: isLoading 동안 대기
  • 페이지 전환: 매번 잠재적 API 요청

개선 방식 (Zustand + React Query)

  • 인증 확인: 0ms (즉시!)
  • 초기 렌더링: 대기 없음
  • 페이지 전환: 토큰 확인만

250ms가 별거 아닌 것 같지만, 사용자 경험에서는 체감이 큽니다. 특히 페이지를 여러 번 이동할 때는 더욱 그렇죠.

실제 프로젝트에 적용하기

제가 리뷰했던 프로젝트에 이 방식을 제안했을 때, 팀원들의 반응이 좋았습니다. 마이그레이션도 생각보다 간단했고요.

1단계: Auth Store 만들기

// src/store/authStore.ts
export const useAuthStore = create<AuthStore>((set) => ({
  isAuthenticated: tokenStorage.hasValidAccess(),
  login: (token) => {
    tokenStorage.set(token);
    set({ isAuthenticated: true });
  },
  logout: () => {
    tokenStorage.clear();
    set({ isAuthenticated: false });
  }
}));

2단계: useAuth 훅 수정하기

// src/hooks/auth/useAuth.ts
export function useAuth() {
  const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
  const logout = useAuthStore((s) => s.logout);
  
  return {
    isAuthenticated, // 즉시 사용 가능!
    logout
  };
}

3단계: 컴포넌트 업데이트하기

// Before
const { user, isLoading } = useAuth();
if (isLoading) return <Loading />;

// After
const { isAuthenticated } = useAuth();
// isLoading 없음! 즉시 사용 가능

기존 코드를 크게 건드리지 않아도 되고, 점진적으로 마이그레이션할 수 있어서 좋았습니다.

커뮤니티에서도 인정받은 패턴입니다

Medium에서 여러 개발자들이 Zustand와 React Query를 조합한 사례를 공유하고 있습니다.

"Separation of Concerns: React Query는 서버 상태 관리에 탁월합니다. Zustand는 클라이언트 상태 관리에 적합하고요. 이 두 도구를 함께 사용하면 비즈니스 로직과 UI 상태를 서버 데이터와 명확히 분리할 수 있습니다."

Doichev Kostia라는 개발자는 블로그에서 이렇게 말했습니다.

"제가 찾은 이상적인 해결책은 영구 저장소(쿠키)와 앱 내부 저장소를 함께 사용하는 것이었습니다. TypeScript와 React 컴포넌트 모두에서 구독할 수 있도록요. Zustand가 완벽한 선택이었습니다."

실제로 많은 프로덕션 환경에서 이런 패턴을 사용하고 있더라고요.

  1. Zustand: 토큰 저장, 인증 상태 관리
  2. React Query: 사용자 프로필, API 데이터 페칭
  3. Axios Interceptor: 토큰 자동 주입

마치며

제가 이 글을 쓰게 된 이유는, 아마 저뿐만 아니라 많은 프론트엔드 개발자분들이 비슷한 고민을 하고 계실 것 같아서입니다.

React Query는 정말 훌륭한 도구입니다. 하지만 모든 상황에 완벽한 도구는 없죠. 중요한 건 각 도구의 목적을 이해하고 적재적소에 사용하는 것이라고 생각합니다.

이 글의 핵심 요약

🔑 인증(로그인 여부) ≠ 사용자 정보(프로필 데이터)

  • 인증 상태: 클라이언트 상태 → Zustand나 Context로 관리
  • 사용자 정보: 서버 상태 → React Query로 관리
  • 토큰: 이미 로컬에 있음 → 즉시 확인 가능

⚡ 성능과 사용자 경험 개선

  • 인증 확인: API 응답 대기(250ms) → 즉시 확인(0ms)
  • 불필요한 네트워크 요청 제거
  • 페이지 전환 시 화면 깜빡임 없음

🛠️ 권장하는 접근 방식

  1. Zustand로 인증 상태 관리 (isAuthenticated)
  2. React Query는 사용자 정보가 필요할 때만 사용
  3. 각 도구를 본래 목적에 맞게 활용

제 의견이 정답은 아닙니다

이 글에서 제시한 방법은 제가 프로젝트를 경험하면서 찾은 하나의 접근 방식입니다. 절대적인 정답이라고 할 수는 없어요.

프로젝트의 규모, 팀의 상황, 기술 스택에 따라 더 나은 방법이 있을 수 있습니다. 예를 들어:

  • 작은 프로젝트에서는 Context API만으로도 충분할 수 있습니다
  • 이미 Redux를 사용 중이라면 Redux로 인증을 관리하는 게 나을 수도 있죠
  • SSR이 중요한 프로젝트라면 또 다른 고려사항이 있을 겁니다

중요한 건 "왜 이렇게 하는가?"를 이해하는 것이라고 생각합니다. 제 글이 여러분의 프로젝트에서 더 나은 결정을 내리는 데 하나의 참고 자료가 되었으면 좋겠습니다.

마지막으로 한 가지 질문을 던지고 싶습니다.

"토큰이 이미 로컬에 있는데, 서버에 사용자 정보를 요청해야만 인증 여부를 알 수 있나요?"

제 답은 아니오입니다. 인증 상태는 즉시 확인할 수 있고, 사용자 정보는 필요할 때만 가져오면 됩니다. 하지만 여러분의 답은 다를 수 있고, 그것도 괜찮습니다.

이 글이 비슷한 고민을 하고 계신 분들께 도움이 되었으면 좋겠습니다. 궁금하신 점이나 다른 의견이 있으시면 언제든 댓글로 남겨주세요. 여러분의 경험과 생각도 듣고 싶습니다.

감사합니다.

참고 자료

profile
기술적 혁신과 측정 가능한 성과를 추구하는 프론트엔드 개발자 양윤성입니다.

0개의 댓글