Context API와 Jotai: 인증 상태 관리를 어떻게 더 효율적으로 만들까?

xxziiko·2024년 9월 29일
0

[React]

목록 보기
3/5
post-thumbnail

최근에 기술의 깊은 이해를 위해 스터디 목적의 사이드 프로젝트를 개발하기 시작했습니다. 이 프로젝트는 React의 내부 동작 원리와 상태 관리, 그리고 사용된 라이브러리에 대한 깊은 이해를 목표로 하고 있습니다. 그 중, 로그인/회원가입 기능 구현 과정에서 어떻게 상태관리를 최적화 할 수 있을지에 대한 생각을 하게 되어 관련 내용을 기록하고자 합니다.

상태 관리의 중요성

상태관리는 다음과 같은 관점에서 중요하게 생각됩니다.

  1. UI와 데이터 일관성 유지

    상태관리는 사용자 인터페이스(UI)와 데이터를 일관성 있게 유지하고 업데이트 하는 것에 중요한 역할을 합니다. 다양한 사용자 입력, 이벤트, 서버에서 가져온 데이터등 여러가지 이유로 자주 업데이트되는 상태를 일관성 있게 추적하고 반영해야 하며, 그 결과로 사용자가 보는 UI는 항상 최신 데이터와 데이터의 무결성이 유지되어야 합니다.

  2. 복잡한 애플리케이션 구조 관리

    컴포넌트 간의 복잡한 데이터 흐름과 의존성을 예측 가능하게하고 체계적으로 관리하기 위해서는 상태관리가 필요합니다. 이를 통해 데이터 흐름을 이해하여 버그 가능성을 줄일 수 있습니다.

  3. 성능 최적화와 유지보수

    잘 관리된 상태는 불필요한 렌더링을 줄여 최적화하기 용이합니다. 뿐만 아니라 적절한 상태관리는 코드의 구조를 명확하게 할 수 있습니다. 이는 개발자 간의 협업 시에 코드의 이해도를 높이고 유지보수를 용이하게 합니다.

  4. 사용자 경험 향상

    효율적인 상태 관리는 실시간으로 변화하는 UI와 사용자 인터렉션에 즉각적으로 대응이 가능해 사용자 경험을 향상시키는 것에 기여합니다.



Context API와 Tanstack Query로 인증 상태 관리

Context API로 인증 상태 관리했던 초기 방식

import { AuthContext } from '@/entities/auth';
import { supabase } from '@/shared';
import { useSuspenseQuery } from '@tanstack/react-query';
import { useState } from 'react';

const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
  children,
}) => {
  const { data: session } = useSuspenseQuery({
    queryKey: ['Auth'],
    queryFn: () => supabase.auth.getSession(),
  });
  const [userName, setUserName] = useState<string | null>(null);

  return (
    <AuthContext.Provider
      value={{
        session: session.data.session,
        userId: session.data.session?.user.id,
        userName,
        setUserName,
      }}
    >
      {children}
    </AuthContext.Provider>
  );
};

export default AuthProvider;

처음에는 인증에 대한 상태관리 규모가 크지 않다고 생각해 Context API로 관리하기 충분하다고 생각했습니다. 그래서 Context API와 Tanstack-Query를 함께 사용하여 인증 상태와 사용자 정보를 전역에서 관리하려고 했습니다. 하지만 동기화 문제상태 관리의 중복으로 인해 애플리케이션에서 상태 불일치가 발생하는 상황을 확인했습니다. 특히 query에서 세션이나 사용자 이름을 비동기적으로 가져오고 갱신할 때, Context API가 그 변화를 제대로 반영하지 못하는 문제를 확인했습니다.




// useLoginMutation.tsx

import { signInUser, useAuth } from "@/entities/auth";
import { type UserInfo } from "@/shared";
import { useMutation } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import type { UseFormSetError } from "react-hook-form";

const useLoginMutation = (setError: UseFormSetError<UserInfo>) => {
  const navigate = useNavigate({ from: "/login" });
  const { userName } = useAuth();

  const { mutate, isError } = useMutation({
    mutationFn: signInUser,
    onSuccess: () => {
      if (userName) {
        navigate({ to: "/chat" });
      }
    },
    onError: () => {
      setError("password", {
        type: "manual",
        message: "비밀번호가 틀렸습니다",
      });
    },
  });

  return { mutate, isError };
};

export default useLoginMutation;
// LoginPage.tsx

import { useAuth } from "@/entities/auth";
import { LoginForm } from "@/features/loginForm";
import { ProfileForm } from "@/features/profileForm";
import { PageLayout } from "@/shared";
import { Header } from "@/widgets";
import { memo, useEffect, useState } from "react";

const LoginPage = () => {
  const [step, setStep] = useState<"로그인" | "프로필설정">("로그인");
  const { userName, session } = useAuth();

  const title =
    step === "로그인"
      ? "이메일로\n간편하게 가입해요"
      : "반가워요\n사용하실 이름을 작성해주세요";

  useEffect(() => {
    if (!userName && session) setStep("프로필설정");
  }, [userName, session]);

  return (
    <PageLayout>
      <Header isBackIconVisible title={title} />
      {step === "로그인" && <LoginForm />}

      {step === "프로필설정" && <ProfileForm />}
    </PageLayout>
  );
};

export default memo(LoginPage);

제가 발견한 문제는 다음과 같습니다.

  • Context API와 Tanstack Query가 각각 상태를 별도로 관리하다 보니, 두 상태가 원하는 대로 동기화되지 않는 문제 발생. 구체적으로, 비동기적으로 세션 데이터를 가져오는 과정에서 Tanstack Query가 데이터를 업데이트했지만, Context API는 이를 즉시 반영하지 못해 잘못된 데이터가 화면에 렌더링되는 상황이 발생
  • Tanstack-Query가 데이터를 자동으로 관리하고 있음에도 불구하고 Context API로 수동으로 반영해야 하는 구조로 인해 코드 복잡도가 증가
  • Context API는 상태가 변경될 때 이를 구독하는 모든 컴포넌트를 리렌더링하므로 추후 채팅과 같이 상태관리가 복잡해질 때 성능 최적화에 어려움이 있을 거라고 판단 예를 들어, session 정보만 변경되었을 때는 session에 의존하는 컴포넌트만 리렌더링되면 충분하지만, Context API의 특성상 userName이나 userId를 구독하는 컴포넌트도 불필요하게 리렌더링

해당 버그 뿐만 아니라 추후 채팅 기능의 도입을 고려해 session 정보만 변경되었을 때 해당 정보에 의존하는 컴포넌트만 리렌더링되도록 제어하기를 원했기 때문에 다른 상태 관리 도구를 도입할 필요성을 느꼈습니다. 사용자 인증뿐만 아니라 채팅 애플리케이션에는 실시간 메시지와 채팅방 정보와 같은 복잡한 상태관리도 필요한데 Context API로 상태를 관리하기에는 확장성에 한계가 있을 거라고 생각했습니다.



Jotai 선택한 이유

  • 라이브러리 크기가 매우 작음(3kb)
  • jotai-tanstack-query 확장 라이브러리를 제공해 tanstack-query와의 통합이 쉽다. 더 효율적이고 간편하게 비동기 상태 관리와 데이터 캐싱 가능
  • 불필요했던 리렌더링 방지 상태가 업데이트되면 Context를 구독하는 모든 컴포넌트가 리렌더링되지만 Jotai는 상태 단위로 의존하는 컴포넌트만 리렌더링
  • atom 단위로 상태 관리
  • 비동기 상태 관리의 편리함 Jotai는 비동기 상태 관리가 필요한 상황에서도 간단한 API로 상태를 관리할 수 있으며 비동기 데이터를 atom에 직접 저장하고 Suspense와 쉽게 통합할 수 있어 비동기 로직을 처리하기 직관적



Jotai로 Auth 상태관리하기

// ../auth/atoms.tsx

import { handleError, supabase } from '@/shared';
import { Session } from '@supabase/supabase-js';
import { atom } from 'jotai';
import { atomWithSuspenseQuery } from 'jotai-tanstack-query';
import { fetchUserName } from './apis';

export const userNameQueryAtom = atomWithSuspenseQuery((get) => ({
  queryKey: ['userName', get(userIdAtom)],
  queryFn: async () => {
    const userId = get(userIdAtom);
    if (!userId) return null;

    return fetchUserName(userId);
  },
  enabled: !!get(userIdAtom),
}));

export const sessionAtom = atom<Session | null>(null);
export const userIdAtom = atom<string | undefined>(
  (get) => get(sessionAtom)?.user.id,
);
  • Jotai 공식문서를 살펴 보던 중, Tanstack Query가 결합한 jotai-tanstack-query 를 발견했고, 비동기 데이터 페칭 최적화와 해당 데이터의 상태관리를 단일 흐름으로 처리할 수 있어 해당 기술을 사용했습니다.
  • atomWithSuspenseQuery를 사용하여 로딩 상태와 오류 처리를 선언적으로 처리할 수 있었습니다.
  • queryKeyenabled를 사용해 쿼리 실행을 userId에 의존하도록 하여, 불필요한 네트워크 요청을 방지합니다. userId가 없으면 쿼리가 실행되지 않도록 처리되어 성능 최적화가 가능했습니다.
  • Tanstack Query에서의 useSuspenseQueryenabled 속성이 없어서 불편했는데, 해당 이점을 가져갈 수 있어서 개인적으로 좋게 느껴졌습니다.
  • jotai의 atom은 다른 atom의 값을 의존하여 파생 상태(derived state)를 정의할 수 있는데, 이 점이 상태 유지를 일관적으로 하기 용이하다고 느껴졌습니다.



🧐 왜 Tanstack Query 의 useSuspanseQuery에서 enabled 옵션이 없을까 ?

혹시 기술적인 이유가 있을까 궁금해졌습니다.

Suspense 의 철학
useSuspenseQuery는 React의 Suspense 패턴을 기반으로 동작하는데, Suspense의 본질은 데이터가 준비되기 전까지 컴포넌트 렌더링을 지연시키는 데 있습니다. 이 때문에 enabled 같은 옵션을 명시적으로 추가하지 않아도 Suspense를 사용하는 시점에서 이미 데이터가 필요하다는 전제가 깔려 있습니다.
Suspense의 철학은 “필요한 데이터가 없으면 기다리기”이기 때문에, enabled와 같은 옵션을 넣어 "데이터 요청을 할지 말지"를 결정하는 것이 Suspense의 기본 목적과 맞지 않을 수 있습니다.


그럼에도 불구하고 atomWithSuspenseQuery에는 해당 속성이 존재하는 이유가 뭘까?

  • Suspense는 데이터를 기다리기 위한 메커니즘이지만, 모든 시점에서 무조건 데이터를 요청해야 하는 것은 아니며 특정 조건이 충족되지 않으면 데이터 요청을 피하고 싶을 때가 있다. 사용자의 인증 상태에 따라 데이터를 가져오거나, 특정 페이지로 이동했을 때만 데이터를 페칭하는 상황을 예로 들 수 있다.
  • atomWithSuspenseQueryenabled 속성은 이런 상황에서 기존의 Suspense와는 달리 더 유연하게 데이터 페칭을 제어할 수 있게 된다. 비동기 데이터가 반드시 즉시 필요하지 않거나, 조건이 충족되기 전에는 쿼리가 실행되지 않도록 설정할 수 있는 추가적인 유연성을 제공하는 것이다.
  • 따라서, userId가 없으면 해당 쿼리를 요청할 필요가 없기 때문에 enabled: !!get(userIdAtom)을 사용하여 조건을 명시적으로 설정하는 것이 가능해진다. 이 기능은 특히 조건부 데이터 요청이 중요한 대규모 애플리케이션이나 특정 시점에서만 데이터를 가져와야 하는 경우에 유용할 수 있다.

결국, enabled 속성은 Suspense의 기본적인 철학을 유지하면서도 실용적인 데이터 페칭 최적화를 가능하게 하기 위한 추가적인 제어 도구라고 할 수 있습니다. 이 속성 덕분에 조건부로 쿼리를 활성화하거나 비활성화할 수 있어, 데이터 요청 흐름을 더욱 세밀하게 관리할 수 있어 성능에 더 유리할 것입니다.



개선 후 이점은?

  • atom 단위로 상태를 관리하면서, session이나 userName이 변경될 때 필요한 컴포넌트만 리렌더링됩니다. 이에 따라 기존 Context API를 사용했을 때보다 성능 최적화가 이루어졌습니다.
  • 여러 atom을 정의하여 필요한 상태만 구독하여 상태 관리가 더 세밀하고 유연해졌습니다.
  • 기존에는 개별적으로 Tanstack Query를 사용하여 비동기 데이터를 관리하고, Context API를 통해 전역 상태를 관리했었는데, atomWithSuspenseQuery를 사용하여 쿼리와 상태 관리를 일관된 흐름으로 통합함으로써 API요청과 상태 업데이트를 더 쉽게 관리가 가능했습니다. 점진적으로 jotai-tanstack-query로 전환할 예정입니다.
  • atomWithSuspenseQueryenabled 속성을 사용하여 조건에 맞춰서 쿼리를 실행함으로써 불필요한 API 요청을 방지하여 성능을 최적화했습니다. jotai-tanstack-query으로 쿼리 실행 조건을 더 세밀하게 제어할 수 있게 되었습니다.




정리

Context API에서 Jotai로 상태관리를 변경하는 과정에서 Context API의 상태 관리와 렌더링에 대해 이해하는 계기가 되었고, 이 부분에 대해 공부한 것은 여기를 눌러보세요. 뿐만 아니라, Jotai를 적용하면서 장점들을 직접 경험해 볼 수 있었고 왜 사람들이 많이 쓰는 라이브러리인지 체감하게 된 경험이었습니다. Jotai는 비동기 상태 관리가 필요한 상황에서도 간단한 API로 상태를 관리할 수 있었고, 비동기 데이터를 atom에 직접 저장하고 Suspense와 쉽게 통합할 수 있어 비동기 로직을 처리하기 직관적이었으나 아직 Jotai 사용에 대한 전반적인 적응이 필요한 것 같습니다.

profile
코딩하는 감자 🥔

0개의 댓글