최근에 기술의 깊은 이해를 위해 스터디 목적의 사이드 프로젝트를 개발하기 시작했습니다. 이 프로젝트는 React의 내부 동작 원리와 상태 관리, 그리고 사용된 라이브러리에 대한 깊은 이해를 목표로 하고 있습니다. 그 중, 로그인/회원가입 기능 구현 과정에서 어떻게 상태관리를 최적화 할 수 있을지에 대한 생각을 하게 되어 관련 내용을 기록하고자 합니다.
상태관리는 다음과 같은 관점에서 중요하게 생각됩니다.
UI와 데이터 일관성 유지
상태관리는 사용자 인터페이스(UI)와 데이터를 일관성 있게 유지하고 업데이트 하는 것에 중요한 역할을 합니다. 다양한 사용자 입력, 이벤트, 서버에서 가져온 데이터등 여러가지 이유로 자주 업데이트되는 상태를 일관성 있게 추적하고 반영해야 하며, 그 결과로 사용자가 보는 UI는 항상 최신 데이터와 데이터의 무결성이 유지되어야 합니다.
복잡한 애플리케이션 구조 관리
컴포넌트 간의 복잡한 데이터 흐름과 의존성을 예측 가능하게하고 체계적으로 관리하기 위해서는 상태관리가 필요합니다. 이를 통해 데이터 흐름을 이해하여 버그 가능성을 줄일 수 있습니다.
성능 최적화와 유지보수
잘 관리된 상태는 불필요한 렌더링을 줄여 최적화하기 용이합니다. 뿐만 아니라 적절한 상태관리는 코드의 구조를 명확하게 할 수 있습니다. 이는 개발자 간의 협업 시에 코드의 이해도를 높이고 유지보수를 용이하게 합니다.
사용자 경험 향상
효율적인 상태 관리는 실시간으로 변화하는 UI와 사용자 인터렉션에 즉각적으로 대응이 가능해 사용자 경험을 향상시키는 것에 기여합니다.
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);
제가 발견한 문제는 다음과 같습니다.
session
정보만 변경되었을 때는 session에 의존하는 컴포넌트만 리렌더링되면 충분하지만, Context API의 특성상 userName
이나 userId
를 구독하는 컴포넌트도 불필요하게 리렌더링해당 버그 뿐만 아니라 추후 채팅 기능의 도입을 고려해 session
정보만 변경되었을 때 해당 정보에 의존하는 컴포넌트만 리렌더링되도록 제어하기를 원했기 때문에 다른 상태 관리 도구를 도입할 필요성을 느꼈습니다. 사용자 인증뿐만 아니라 채팅 애플리케이션에는 실시간 메시지와 채팅방 정보와 같은 복잡한 상태관리도 필요한데 Context API로 상태를 관리하기에는 확장성에 한계가 있을 거라고 생각했습니다.
// ../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
를 발견했고, 비동기 데이터 페칭 최적화와 해당 데이터의 상태관리를 단일 흐름으로 처리할 수 있어 해당 기술을 사용했습니다.atomWithSuspenseQuery
를 사용하여 로딩 상태와 오류 처리를 선언적으로 처리할 수 있었습니다.queryKey
와 enabled
를 사용해 쿼리 실행을 userId
에 의존하도록 하여, 불필요한 네트워크 요청을 방지합니다. userId
가 없으면 쿼리가 실행되지 않도록 처리되어 성능 최적화가 가능했습니다.useSuspenseQuery
는 enabled
속성이 없어서 불편했는데, 해당 이점을 가져갈 수 있어서 개인적으로 좋게 느껴졌습니다.atom
은 다른 atom
의 값을 의존하여 파생 상태(derived state)를 정의할 수 있는데, 이 점이 상태 유지를 일관적으로 하기 용이하다고 느껴졌습니다. useSuspanseQuery
에서 enabled
옵션이 없을까 ?혹시 기술적인 이유가 있을까 궁금해졌습니다.
Suspense 의 철학
useSuspenseQuery
는 React의 Suspense 패턴을 기반으로 동작하는데, Suspense의 본질은 데이터가 준비되기 전까지 컴포넌트 렌더링을 지연시키는 데 있습니다. 이 때문에enabled
같은 옵션을 명시적으로 추가하지 않아도 Suspense를 사용하는 시점에서 이미 데이터가 필요하다는 전제가 깔려 있습니다.
Suspense의 철학은 “필요한 데이터가 없으면 기다리기”이기 때문에,enabled
와 같은 옵션을 넣어 "데이터 요청을 할지 말지"를 결정하는 것이 Suspense의 기본 목적과 맞지 않을 수 있습니다.
그럼에도 불구하고 atomWithSuspenseQuery
에는 해당 속성이 존재하는 이유가 뭘까?
atomWithSuspenseQuery
의 enabled
속성은 이런 상황에서 기존의 Suspense
와는 달리 더 유연하게 데이터 페칭을 제어할 수 있게 된다. 비동기 데이터가 반드시 즉시 필요하지 않거나, 조건이 충족되기 전에는 쿼리가 실행되지 않도록 설정할 수 있는 추가적인 유연성을 제공하는 것이다.userId
가 없으면 해당 쿼리를 요청할 필요가 없기 때문에 enabled: !!get(userIdAtom)
을 사용하여 조건을 명시적으로 설정하는 것이 가능해진다. 이 기능은 특히 조건부 데이터 요청이 중요한 대규모 애플리케이션이나 특정 시점에서만 데이터를 가져와야 하는 경우에 유용할 수 있다.결국, enabled
속성은 Suspense의 기본적인 철학을 유지하면서도 실용적인 데이터 페칭 최적화를 가능하게 하기 위한 추가적인 제어 도구라고 할 수 있습니다. 이 속성 덕분에 조건부로 쿼리를 활성화하거나 비활성화할 수 있어, 데이터 요청 흐름을 더욱 세밀하게 관리할 수 있어 성능에 더 유리할 것입니다.
atom
단위로 상태를 관리하면서, session
이나 userName
이 변경될 때 필요한 컴포넌트만 리렌더링됩니다. 이에 따라 기존 Context API를 사용했을 때보다 성능 최적화가 이루어졌습니다.atom
을 정의하여 필요한 상태만 구독하여 상태 관리가 더 세밀하고 유연해졌습니다.atomWithSuspenseQuery
를 사용하여 쿼리와 상태 관리를 일관된 흐름으로 통합함으로써 API요청과 상태 업데이트를 더 쉽게 관리가 가능했습니다. 점진적으로 jotai-tanstack-query로 전환할 예정입니다.atomWithSuspenseQuery
의 enabled
속성을 사용하여 조건에 맞춰서 쿼리를 실행함으로써 불필요한 API 요청을 방지하여 성능을 최적화했습니다. jotai-tanstack-query으로 쿼리 실행 조건을 더 세밀하게 제어할 수 있게 되었습니다.Context API에서 Jotai로 상태관리를 변경하는 과정에서 Context API의 상태 관리와 렌더링에 대해 이해하는 계기가 되었고, 이 부분에 대해 공부한 것은 여기를 눌러보세요. 뿐만 아니라, Jotai를 적용하면서 장점들을 직접 경험해 볼 수 있었고 왜 사람들이 많이 쓰는 라이브러리인지 체감하게 된 경험이었습니다. Jotai는 비동기 상태 관리가 필요한 상황에서도 간단한 API로 상태를 관리할 수 있었고, 비동기 데이터를 atom에 직접 저장하고 Suspense와 쉽게 통합할 수 있어 비동기 로직을 처리하기 직관적이었으나 아직 Jotai 사용에 대한 전반적인 적응이 필요한 것 같습니다.