React Session 로그인 구현(w. 회원가입, JSON Server, react-query): (2) 클라이언트

김 주현·2023년 12월 4일

React Session 로그인

목록 보기
2/2
post-thumbnail

이전 포스팅에 이어서 이번엔 클라이언트 쪽의 코드를 작성해보자.


클라이언트

클라이언트 코드도 역시 중요한 부분만 체크하며 샥샥 넘어가보자고!

폴더 구조

폴더 구조는 늘 하던대로 나누었다.

  • /API : API 호출 로직을 모아둠
  • /Hooks : 필요한 훅들은 여기에~
  • /Pages : 경로에 따른 페이지는 여기
  • /Store : 전역적으로 필요한 값들은 여기~
  • /Types : 프로젝트에서 필요한 타입은 여기

App 코드

App.tsx

/** Package Import */
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { RouterProvider, createBrowserRouter } from 'react-router-dom';

/** Page */
import LandingPage from '@Pages/Landing';
import LoginPage, { loader as loginLoader } from '@Pages/Login';
import MainPage, { loader as mainLoader } from '@Pages/Main';
import RegisterPage from '@Pages/Register';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5,
      retry: 0,
    },
  },
});

const router = createBrowserRouter([
  {
    path: '/',
    element: <LandingPage />,
  },
  {
    path: '/login',
    element: <LoginPage />,
    loader: loginLoader(queryClient),
  },
  {
    path: '/register',
    element: <RegisterPage />,
  },
  {
    path: '/main',
    element: <MainPage />,
    loader: mainLoader(queryClient),
  },
]);

const App = () => {
  return (
    <QueryClientProvider client={queryClient}>
      <RouterProvider router={router} />
    </QueryClientProvider>
  );
};

Query Client 설정

Query Client 생성

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5,
      retry: 0,
    },
  },
});

React Query를 사용하기 위해서는 client를 생성해서 넘겨줘야 하는데, 이때 기본값을 지정해줄 수 있다.

  • staleTime: 쿼리 상태가 stale로 넘어가기까지의 시간
  • retry: 실패시 다시 시도할 횟수(기본값 3)

staleTime에 대한 설명은 이 포스트를 참고!

나는 staleTime을 5분으로 주었고, retry를 0으로 지정해주었다. 참고로 staleTime을 지정해주지 않으면 invalidateQueries를 호출해도 자동으로 리패칭되지 않으니 알아두자.

Routing

라우팅 설정

const router = createBrowserRouter([
  {
    path: '/',
    element: <LandingPage />,
  },
  {
    path: '/login',
    element: <LoginPage />,
    loader: loginLoader(queryClient),
  },
  {
    path: '/register',
    element: <RegisterPage />,
  },
  {
    path: '/main',
    element: <MainPage />,
    loader: mainLoader(queryClient),
  },
]);

React Router Dom이 v6으로 넘어오면서 라우팅을 주는 방식이 조금 달라졌다. 물론 Backward Compatibility를 지원하고 있어서 이전의 방식들도 혼용해서 사용 가능하다.

createBrowserRouter()를 사용해서 각 경로에 대한 컴포넌트를 설정해줄 수 있다. 저렇게 1depth의 경로 설정 뿐만 아니라 chidlren 속성을 이용하면 중첩 라우팅도 가능하고, path, element, loader 이외에도 다양한 속성을 지원하니까 공식문서를 참고해보자!

loader

여기에서 알아봐야할 건 이 loader라는 녀석인데, 이건 이번 v6으로 넘어오면서 생긴 기능이다. 이 녀석은 페이지를 로드할 때, 렌더가 되기 전에 데이터를 불러올 수 있게 하는 속성이다.

일단 먼저 알아두면 좋을 게, 요새 FE의 추세 중 하나가 더 빠른 데이터 패칭이다. Next.js라든지 Remix같은 녀석들이 가지는 이점 중에 하나가 API 요청시 서버 데이터에 바로 접근해버린다는 것이다.

이렇게 동작하게 되면 클라이언트에서 요청하는 것보다 더 빠른 호출이 되므로 데이터를 빠르게 받아볼 수 있게 된다.

물론 이건 SSR을 구현했을 때의 이야기지만, 여튼, 저 요청을 보다 빨리 당기는 인사이트를 주목해서 구현한 게 loader이다. 우린 Client Side의 React를 다루고 있으니 서버까지 당길 순 없다. 그러면 어디까지 당길 수 있는 걸까?

SPA의 모든 시작은 경로를 분리시켜주는 데에서 시작한다. 즉, Router가 경로에 따라서 Element를 뿌려주는 것부터 시작한다는 말. 자, 그러면 생각해보자. Element는 많은 자식 컴포넌트를 가지고 있을 테고, 만약 깊게 있는 컴포넌트에서 데이터 패칭을 요청한다고 해보자. 그러면 경로 변경 -> Element 뿌려줌 -> 데이터 패칭이 있는 컴포넌트까지 렌더링이 필요 -> 데이터 패칭이 이루어 지는 것이 지금까지의 순서였다.

그런데 이걸~ 제임 처음 접근하는 Router 단에서 미리 패칭할 수 있게 하면 어떨까? 굳이 깊게 있는 컴포넌트까지 렌더링하지 않아도 가져올 수 있게 말이다. 이런 생각에서 나온 게 바로 loader이다.

React Router Dom의 loader 예제(공홈)

createBrowserRouter([
  {
    path: "/teams/:teamId",
    loader: ({ params }) => {
      return fakeGetTeam(params.teamId);
    },
  },
]);

이 loader는 해당 경로의 컴포넌트가 렌더되기 전에 미리 데이터를 불러오게 만들어서 컴포넌트는 받아온 데이터를 뿌려주기만 하면 된다. 이렇게 하면~ 이런 게 되는 것이다.

이렇게 컴포넌트 마다 요청하는 것들을~

이렇게!

캡쳐 출처는 여기이다. Remix에 대한 설명을 하는 거지만 좋은 인사이트를 가질 수 있어서 한번 주욱 보는 것도 추천~

이와 마찬가지로 action이라는 속성도 있는데, 이것 역시 서버에 데이터를 전송할 때 먼저 전송을 해주는 속성이다.

action 예제 (공홈)

<Route
  path="/song/:songId/edit"
  element={<EditSong />}
  action={async ({ params, request }) => {
    let formData = await request.formData();
    return fakeUpdateSong(params.songId, formData);
  }}
  loader={({ params }) => {
    return fakeGetSong(params.songId);
  }}
/>

formData를 받아와서 서버에 요청을 하고 있는 것을 볼 수 있다.

이렇듯 React Router Dom은 loader와 action이라는 기능을 이번에 도입해서 보다 빠른 데이터 패칭을 구현했다. 그런데 이거, 어디에서 많이 본 느낌이다. 그렇다. React Query의 useQuery와 useMutation와 비슷한걸!

React Router + React Query

데이터를 불러온다는 점에서 loader와 useQuery가 비슷하고, 데이터 조작 요청을 한다는 점에서 action과 useMutation이 비슷하다. 그러면 이제 React Query를 사용하지 않고 이거 사용하면 되는 건가!?

..라는 생각을 했었고, 많은 사람들도 이런 생각을 했던 것 같다. 그래서 React Query의 Maintainer인 Tkdodo는 이런 포스팅을 작성했고, 이 포스팅에서 언급된 Ryan Florence가 말한 주된 포인트는 다음과 같았다.

React Router는 언제(when)에 대한 것이고, React Query는 무엇(what)에 대한 것이다.

중요하니까 모든 강조 효과!

데이터 패칭을 한다는 점은 비슷하지만, 보다 본질적인 건 React Router는 '라우팅'을 하는 것이고, React Query는 '캐싱'을 하는 것이다. 그러므로, React Router는 페이지를 로드하기 전에 데이터를 패칭할 기회는 주는 것이고, React Query는 실제로 데이터를 패칭하고 캐싱하는 것. 그래서 내가 이해한 역할은 다음과 같다.

  • loader : 데이터를 불러오는 역할이 아니라, 데이터를 불러올 기회를 만들어 주는 것.
  • action : Mutation을 처리하는 역할이 아니라, Mutation을 처리할 기회를 만들어 주는 것.

그래서 다음과 같이 활용하는 걸 추천하고 있다. loader에서는 해당 쿼리의 캐시가 존재하면 캐시를 반환하고, 없으면 요청을 하는 것.

loader

export const loader = (queryClient) => async ({ params }) => {
  const query = contactDetailQuery(params.contactId)
  // ⬇️ return data or fetch it
  return (
    queryClient.getQueryData(query.queryKey) ??
    (await queryClient.fetchQuery(query))
  )
}

그리고 데이터가 필요한 부분에선 이렇게!

데이터 불러오기

export default function Contact() {
  const params = useParams()
  // ⬇️ useQuery as per usual
  const { data: contact } = useQuery(contactDetailQuery(params.contactId))
  // render some jsx
}

아주 좋은 방식인 것 같다. 자세한 건 아까 남긴 포스트 링크를 참고해보시길~

아무튼 나도 이 방식을 활용해서 loader를 설정해준 것. 어째 삼천포로 빠진 모양

LandingPage

LandingPage에서는 별거 없다. 그냥 회원가입 / 로그인 / 메인페이지로 이동하는 버튼만 놔두었다. 생각해보니 왜 만들었는지 의문(...)

Pages/LandingPage.tsx

const LandingPage = () => {
  const navigate = useNavigate();

  return (
    <div>
      <p>어서오세요, 회원가입이든 로그인이든 해보쇼</p>
      <button onClick={() => navigate('/register')}>회원가입</button>
      <button onClick={() => navigate('/login')}>로그인</button>
      <button onClick={() => navigate('/main')}>일단 메인 페이지 가기</button>
    </div>
  );
};

RegisterPage

회원가입 페이지는 이렇게 생겼다. 최소한의 스타일링만 했다. 최소한의 예의

Pages/RegisterPage.tsx

const RegisterPage = () => {
  return (
    <div>
      <h2>회원가입</h2>
      <RegisterForm />
    </div>
  );
};

크게 제목와 나머지 부분으로 나누었다. 최상위 컴포넌트는 항상 깔끔하게!

RegisterForm

이메일, 비밀번호, 닉네임을 받아서 서버에 제출하는 컴포넌트

Pages/RegisterPage.tsx - RegisterForm

const RegisterForm = () => {
  const navigate = useNavigate();

  const { mutate, isPending, isError, error } = useCreateAccount();
  const { isAllFieldConfirmed } = useConfirmFields({ email: false, nickname: false });

  const buttonText = isPending ? '가입중...' : '가입하기';

  const handleSubmit = (e: FormEvent<HTMLFormElement\>) => {
    e.preventDefault();

    const formData = new FormData(e.target as HTMLFormElement);
    const submitData = Object.fromEntries(formData) as UserAuth;

    if (!isAllFieldConfirmed()) {
      return alert('확인을 좀 해보쇼');
    }

    mutate(submitData, {
      onSuccess: () => navigate('/'),
    });
  };

  return (
    <form method="post" onSubmit={handleSubmit}>
      <EmailField />
      <br />
      <PasswordField />
      <br />
      <NicknameField />
      <br />

      {isError && <p style={{ color: 'red' }}>{error.message}</p>}

      <button type="submit" disabled={isPending}>
        {buttonText}
      </button>
    </form>
  );
};

form onSubmit으로 넘어온 formData 재가공

handleSubmit

  const handleSubmit = (e: FormEvent<HTMLFormElement\>) => {
    e.preventDefault();

    const formData = new FormData(e.target as HTMLFormElement);
    const submitData = Object.fromEntries(formData) as UserAuth;

form의 submit 이벤트는 페이지를 새로고침 시키는 기본 동작을 가지고 있다. 이것을 방지하기 위해 preventDefault()를 호출해주었다.

또~ 재밌는 사실은, <form> 태그로 감싸진 입력 element들은, submit이 발생할 때 e.target으로 넘어온다는 점이다! 이걸 이용하면 값을 얻기 위해 Ref를 달고 change에 state 변경하고 해주지 않아도 된다~!

그래서 이렇게 넘어온 formData들을 Key-value를 가진 object로 만드는 로직이 그 다음 두 줄인데, 자세한 설명은 이 포스트를 참고!

useConfirmFields

이 훅은 사용자가 이메일 확인 / 닉네임 확인을 했는지 체크하는 훅이다. 확인 후 성공하면 해당 필드가 true로 변하고, 다시 해당 필드를 수정하면 false로 변한다.

Hooks/useConfirmFields.ts

import { checkFieldsStore } from '@Store/CheckFields';

export const useConfirmFields = (initialFields?: { [k: string]: boolean }) => {
  if (initialFields) {
    checkFieldsStore.initializeFields(initialFields);
  }

  return {
    isConfirmedField: checkFieldsStore.getField,
    isAllFieldConfirmed: checkFieldsStore.isAllChecked,
    confirmField: (fieldName: string) => checkFieldsStore.updateField({ [fieldName]: true }),
    unconfirmField: (fieldName: string) => checkFieldsStore.updateField({ [fieldName]: false }),
  };
};

훅에 initialFields가 설정되면 Store를 초기화 해준다. 그리고 단순히 선언 메서드를 만들어서 반환하는 훅!

Store/checkFields.ts

type CheckField = { [k: string]: boolean };

let checkFields: CheckField = {};
let listeners: (() => void)[] = [];

const emitChange = () => {
  listeners.forEach((listener) => listener());
};

export const checkFieldsStore = {
  subscribe(listener: () => void) {
    listeners = [...listeners, listener];

    return () => {
      listeners = listeners.filter((l) => l !== listener);
    };
  },

  getSnapShot() {
    return checkFields;
  },

  initializeFields(fields: CheckField) {
    checkFields = { ...fields };
    emitChange();
  },

  updateField(field: CheckField) {
    checkFields = { ...checkFields, ...field };
    emitChange();
  },

  getField(fieldName: string) {
    return checkFields[fieldName];
  },

  isAllChecked() {
    return !Object.values(checkFields).includes(false);
  },
};

checkFieldsStore는 이렇게 생겼다. 조그음 특이하게 생겼는데, 왜 이런 구조냐면 useSyncExternalStore를 생각해서 만들었기 때문. 자세한 설명은 공식문서를 참고하길!

왜 useSyncExternalStore을 쓰려고 했냐면, 전역변수 관리하자고 recoil이나 redux를 설치하기엔 품이 크기도 하고, 무엇보다도 이게 UI에 필요한 상태가 아니기 때문이다.

전역 변수로 사용할 때 중요한 점이, 이게 바뀌었을 때 렌더가 다시 되어야 하냐 아니냐가 고려되어야 한다. 즉, UI에 표시가 되는 상태이냐는 것. 보여주는 정보라면 당연히 바뀌었을 때 다시 렌더를 해줘야 한다.

하지만 이 checkFields는~ 제출하는 단계에서 그저 확인을 눌렀냐, 안 눌렀냐의 체크 용도이기 때문에 따로 UI 상태가 없다. 그래서 이렇게 만든 것. 물론 다 구현하고 보니 useSyncExternalStore를 쓸 필요가 없었더랬다 ...

또, 여기에서 포인트가 되는 건 아래의 메서드.

isAllChecked

  isAllChecked() {
    return !Object.values(checkFields).includes(false);
  },

보통 이런 건 메서드가 아니라 flag로 나타낸다. 그러니까 isAllChecked: !Object.values(checkFields).includes(false) 로 작성할 수 있었을 텐데, 함수 형태로 작성한 이유는 바로 지연 평가(lazy evaluation)때문이다.

자바스크립트에는 평가라는 개념이 있는데, 쉬운 말로 코드를 실행해서 결과값으로 만드는 동작을 말한다. 예를 들어 const a = 1 + 1 이라는 코드가 있을 때, 자바스크립트는 이를 실행할 때 1 + 1를 평가해서 const a = 2 라는 코드로 만든다. 즉, a의 값이 선언을 할 때 결정이 되는 것이다.

그런데~ 이 평가를 미루는 방법이 있다. 여러 방법이 있는데, 그 중에서 자주 쓰이는 것이 함수 표현이다. 함수는 호출이 될 때 평가가 된다.

그래서, isAllChecked를 메서드로 만들었다. 이 만약 이걸 flag로 만들었다면 checkFieldsStore의 객체가 생성이 될 때 가지고 있는 checkFields의 값으로 평가를 해버리기 때문이다. checkFields의 값은 나중에 변할 수도 있으므로 호출을 할 때 결정하게 만드는 것이 맞겠다.

mutate

돌아와서~ useCreateAccount라는 훅을 쓰고 있는데, 이건 useMutation을 돌려주는 훅이다. 선언적으로 쓰려고 만든 훅.

useCreateAccount

export const useCreateAccount = () => {
  return useMutation({
    mutationKey: ['createAccount'],
    mutationFn: API.createAccount,
  });
}

그리고 이 훅을 통해서 나온 mutate에서 다음과 같은 callback을 넘겨주고 있다.

    mutate(submitData, {
      onSuccess: () => navigate('/'),
    });

그런데~ 사실 useMutation에도 똑같은 callback 존재한다. 자세한 건 킹식문서ㅋㅋ

만약 다음과 같은 코드가 있다면 A와 B 둘 중 어떤 게 먼저 출력될까?

useMutation의 onSuccess와 mutate의 onSuccess

const { mutate } = useMutation({
  mutationKey: ['createAccount'],
  mutationFn: API.createAccount,
  onSuccess: () => console.log("A")
});

mutate(data, { onSuccess: () => console.log("B") }

정답은 A가 먼저 출력되고, 그 다음 B가 출력! 즉, useMutation에 설정된 콜백이 먼저 실행되고, mutate에 설정된 콜백이 실행된다.

그러면 이런 고민이 생기게 된다. useMutation에 설정해야 할까, mutate에 설정해야 할까?

물론 언제나 정답은 없다만 ... 둘의 차이를 알면 좀 더 적절하게 쓸 수 있을 것. useMutation에 쓰인 onSuccess는 이 쿼리를 호출하는 모든 상황에서 호출이 되고, 반환되는 mutate에 쓰인 onSuccess는 특정한 상황에서 호출이 된다. 이 설명의 기본 전제는 해당 쿼리가 다른 곳에서도 쓰인다는 가정이다.

만약 글을 작성하는 쿼리가 있다고 해보자. 이 쿼리는 A, B페이지에서 모두 쓰인다. 둘 다 성공하면 글 목록을 invalidate하는 동작이 있지만, B페이지에서는 추가적으로 작성한 페이지 목록도 invalidate하는 동작이 있다고 해보자.

그러면 useMutation의 onSuccess에서는 글 목록 invalidate가 들어가겠고, B페이지에서 호출한 mutate의 onSuccess에는 작성한 페이지 목록 invalidate가 들어갈 것이다.

이렇게 같은 동작이라도 상황에 따라 추가적인 콜백이 필요할 때 mutate에 콜백을 따로 추가해주는 것~

나의 경우엔 계정을 생성하는 동작이 서비스에서 한 번밖에 동작하지 않아서 useMutation의 onSuccess에 설정할 수 있었겠지만, 이 동작을 알기 위해선 useCreateAccount를 까봐야 알 수 있다. 그러기 싫어서 한 파일 내에서 어떤 동작이 일어나는지 파악할 수 있게 mutate에 설정해주었다. 또, useCreateAccount는 통신을 호출하는 훅으로 구분짓기도 했고~_~

EmailField

사용자에게서 이메일을 입력받고, 사용 가능한 이메일인지 확인하는 컴포넌트

EmailField

const EmailField = () => {
  const { mutate, isPending, isError, error, isSuccess, reset } = useConfirmEmail();
  const { confirmField, unconfirmField, isConfirmedField } = useConfirmFields();

  const inputRef = useRef<HTMLInputElement\>(null);

  const buttonText = isPending ? '확인중' : '이메일 확인';

  const handleConfirm = (e: MouseEvent<HTMLButtonElement\>) => {
    e.preventDefault();

    mutate(inputRef.current!.value, {
      onSuccess: () => confirmField('email'),
    });
  };

  const handleChange = () => {
    if (isConfirmedField('email') || isError) {
      unconfirmField('email');
      reset();
    }
  };

  const handleKeyDown = (e: KeyboardEvent<HTMLInputElement\>) => {
    if (e.key === 'Enter' && inputRef.current!.value !== '') {
      e.preventDefault();

      mutate(inputRef.current!.value, {
        onError() {
          inputRef.current!.disabled = false;
          inputRef.current!.focus();
        },
      });
    }
  };

  return (
    <div>
      <fieldset disabled={isPending}>
        <label htmlFor="email" style={{ display: 'block' }}>
          <strong>이메일</strong>
        </label>

        <input
          id="email"
          name="email"
          placeholder="example@example.com"
          defaultValue="sangpok@example.com"
          ref={inputRef}
          onChange={handleChange}
          onKeyDown={handleKeyDown}
        />

        <button type="button" onClick={handleConfirm}>
          {buttonText}
        </button>
      </fieldset>

      {isError && <p style={{ color: 'red' }}>{error.message}</p>}
      {isSuccess && <p style={{ color: 'green' }}>사용 가능한~</p>}
    </div>
  );
};

Confirm 상태 제어

지금 버튼을 누르면 mutation이 발생하는데, 이 mutation은 성공하면 Status Code 200을, 실패하면 500을 에러 메시지와 함께 응답해준다.

좀 살펴봐야 할 것은,, 사실 Status Code 500은 try - catch문으로 잡을 수 없다(!) network error가 아니라 fetch error로 다뤄져서, 응답 자체는 제대로 왔다고 판단하는 것. 그러면 지금은 어떻게 isError로 받아줄 수 있는 걸까? 살펴보자굿

Fetch 로직

const handleResponse = async <T\>(res: Response) => {
  if (res.ok) {
    const contentType = res.headers.get('content-type');
    const isJsonType = contentType && contentType.includes('application/json');

    if (isJsonType) {
      return res.json() as Promise<T\>;
    }

    return null;
  }

  throw await (res.json() as Promise<T\>);
};

const Fetcher = {
  POST: <T\>(endpoint: string, formdata: object) =>
    fetch(`${API_URI}${endpoint}`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(formdata),
      credentials: 'include',
    }).then(handleResponse<T\>),
}

export const confirmEmail = (email: string) => Fetcher.POST('/member/check/email', { email });

export const useConfirmEmail = () => {
  return useMutation({
    mutationKey: ['checkEmail'],
    mutationFn: API.confirmEmail,
  });
};

useConfirmEmail에서 필요한 부분만 가져와봤다. 아, 가끔 내 포스트 중에 코드 중간에 이렇게 <T/> 되어있는 부분이 있는데, 이건 벨로그 마크업 버그때문에 임시 방편으로 해놓은 것. 저걸 태그로 인식해버려서 막 이상하게 되더라요(...)

Fetcher의 POST에서 endpoint에 요청을 하고, 해당 fetch promise에서 넘어온 resolve를 handleResponse로 넘겨주고 있다. handleResponse가 핵심!

fetch는 resolve 값으로 Response 객체를 넘겨주는데, 이 Response 객체에 ok라는 속성이 존재한다. 이 속성은 응답은 정상적으로 왔는지 아닌지를 나타내는 속성이다. 이 ok 속성은 Status Code가 200번대이면 true, 아니라면 false를 반환한다.

그래서 ok임이 확인되면, json이 있는 응답인지 확인 후, json이라면 포맷팅 해서 넘겨주고, 아니면 그냥 상태코드만 넘어온 응답이므로 null을 넘겨준다.

ok가 아니라면, throw를 통해 에러를 발생시킨다. 그러면 이걸 받고 있는 React Query는 해당 쿼리의 onError로 에러를 넘겨주는 것이다!

일반화해보면 다음과 같은 형태가 나오는데, 이 형태는 fetch할 땐 아주 기본이 되는 꼴이니까 알아두기! 모던 자바스크립트에서도, MDN에서도 언급된 형태임.

fetch 기본꼴

async function fetchSomething() {
  try {
    const response = await fetch("example");

    if (!response.ok) {
      throw new Error("네트워크 응답이 OK가 아님");
    }

    // TODO: Response 작업
  } catch (error) {
    console.error("취득에 문제가 있었습니다:", error);
  }
}

여튼 그래서~ 이렇게 에러를 처리해서, 성공하면 사용 가능한~ 을 띄우고, 실패하면 오류 메시지를 띄우는 것이다.

이때, 응답을 받은 뒤 다시 입력을 할 때 이 상태들을 없애주고 있는데 이건 useMutation에서 반환하는 reset() 그 역할을 하고 있다.

reset()으로 상태 없애기

  const handleChange = () => {
    if (isConfirmedField('email') || isError) {
      unconfirmField('email');
      reset();
    }
  };

이미 confirm된 상태이거나 쓸 수 없는 상태(isError)라면 unconfirmField()을 호출해주고 reset()을 호출해주고 있다. 이 reset은 쿼리의 상태를 초기화시켜준다. 그럼으로써 isSuccess도, isError로도 분기되지 않는 것~

form의 enter 막기

form 내부의 입력 element에서 enter를 누르게 되면 자동으로 form의 submit으로 넘어가게 된다. 이를 막기 위해선 의외로 form에 설정하는 게 아니라 입력 element로 가야 한다.

handleChange에서 기본 동작 막기

  const handleKeyDown = (e: KeyboardEvent<HTMLInputElement\>) => {
    if (e.key === 'Enter' && inputRef.current!.value !== '') {
      e.preventDefault();

사실 입력상자의 기본 동작에는 form의 submit을 트리거시키는 동작도 포함되어 있다(!) 그래서 이 submit을 막으려면 preventDefault()를 호출해줘야 한다.

확인시에 disabled하기

이건 좀 꿀팁인데, 만약 무언가 제출하는 동작이 오래 걸린다면 그것에 대한 피드백이 있어야겠다. 보통 이렇게 입력 상자가 있으면 제출하고 응답이 오는 동안 disabled해주는 게 안전한 방법이라 이렇게 처리하기도 한다.

isPending에 따른 disabled

<input disabled={isPending} />
<button disabled={isPending} />

그런데 이 방식보다 더 좋은 방식이 있다는 사~실~ (두둥) 그것은 바로 fieldset으로 감싸준 뒤 disabled를 주는 것이다.

fieldset에 disabled

<fieldset disabled={isPending}>
  <input />
  <button />
</fieldset>

그러면 자동으로 fieldset 안에 있는 입력 Element들을 disabled 처리해준다. 와우. 이러면 상태에 따른 스타일링도 편해진다. fieldset의 :disabled pseudo class에 자식들의 disabled 상태를 지정해주면 되기 때문~

PasswordField, NicknameField

요 필드들도 EmailField과 다를 게 없으므로 패스하겠다. 굳이 뭔갈 적자면,, 이렇게 따로 field로 뺀 이유는 해당 필드에만 동작하는 Mutation이 있기 때문이고, 이 Mutation의 상태에 따른 피드백이 존재하기 때문이다. 이게 한 컴포넌트 안에서 관리되면 굉장히 지저분해지기 때문에,, 따로 컴포넌트로 빼준 것.

LoginPage

로그인 페이지는 이렇게 시작한다.

Pages/LoginPage.tsx

const LoginPage = () => {
  const { hasAuth } = useLoaderData() as Awaited<ReturnType<ReturnType<typeof loader\>>>;

  if (hasAuth) {
    return <Navigate to="/main" />;
  }

  return <LoginView />;
};

세션에 따라 화면 분기

여기에서, 일단 로그인 페이지로 들어왔을 때 2가지의 경우를 분기할 수 있다.

(1) 유효한 세션으로 로그인 페이지에 들어왔을 때

이 경우에는 다시 로그인할 필요가 없으므로 페이지를 이동시켜주어야 하는데, 어떤 페이지로 이동할 것이냐가 또 질문으로 이어진다.

사실 곧바로 Login Page로 들어올 일은 없을 것이다. 어느 페이지에서 머물다가 로그인 페이지로 올 경우가 거의 대다수 이므로, 그 경우 로그인이 끝나면 다시 해당페이지로 이동시켜줘야 한다.

이걸 구현하기 위해서는 history의 state에 이전 주소를 담아 navigate 시켜주거나, 아니면 로그인 페이지의 쿼리로 callback url을 담아서 보내주는 것이다. 예를 들면 /login?callback_url=/main 이런 식이다.

어느 방법이든 괜찮지만, 나는 후자의 방법을 더 선호한다. 굳이 state를 만들지 않고 url로 올려줌으로써 더 간편하게 Param을 얻을 수 있기 때문이다.

만약 callback_url 없이 들어왔다면 그냥 /main으로 이동시켜주면 된다. 물론 나는 걍 /main으로 보냄

(2) 유효하지 않은 세션으로 로그인 페이지에 들어왔을 때

이때는 로그인을 할 수 있도록 화면을 띄워주면 된다

그런데~ 생각해보면 이 로직이 비단 로그인 페이지 뿐만 아니라 다른 페이지에도 적용될 수도 있을 것 같다. 만약 로그인이 필요한 경로라면 이 과정을 당연히 거쳐야하지 않겠는가?

그래서 이와 같이 유저 정보가 필요한 경로를 보호된 경로(Protected Route)라고 하고, 이런 경로를 방문할 때는 HOC로 세션을 체크하는 로직을 담은 컴포넌트로 감싸기도 한다. 아래와 같은 식이다.

Protected Route

const ProtectedRoute = () => {
  const { hasAuth } = useAuth()

  if (hasAuth) {
    return <Outlet />
  }

  return (
    <Redirect to=`/login?callback_url=${location.pathname}` />
  )
}

<Route path="/" element={<ProtectedRoute />}>
  <Route path="main" element={<MainPage />} />
</Route>

뭐어.. 이런 식인데, 만약 v6에서 이걸 구현하려면 조금 복잡하게 구현해야 한다.

App.tsx

import {routes} from './routes.ts'

const App = () => {
  const { hasAuth } = useAuth();

  const routing = useRoutes(routes(hasAuth));

  return <>{routing}</>
}

routes.ts

export const routes = (hasAuth: boolean) => [
  {
    path: '/',
    element: hasAuth 
      ? <MainPage />
      : <Navigate to="/login?callback_url=/" />,
    children: [
      { path: 'mypage', element: <MyPage /> },
    ]
  },
  {
    path: '/login',
    element: hasAuth
      ? <Navigate to="/" />
      : <LoginPage />,
  }
}

hasAuth의 값에 따라 routes의 값을 동적으로 바꿔주는 것이다. 조금 복잡하긴 하지만 ,, v6은 이런 방식으로 해야하는 것 같더라. 근데 일단 난 안함(...) 계속 이 말을 하는 것 같은 건 기분입니다.

loader

아무튼~ hasAuth라는 걸 지금 useLoaderData() Hook을 이용해서 얻고 있는데, 이건 React Router에서 제공하는 훅이다. 아까 loader에서 페이지를 렌더하기 전에 데이터를 가져온다고 했었는데, 그 데이터를 반환하는 게 useLoaderData() 이다. 현재 LoginPage의 loader는 아래와 같다.

hasAuth 넘겨주기

export const loader = (queryClient: QueryClient) => async () => {
  try {
    await API.getAuthStatus();
    return { hasAuth: true };
  } catch (error) {
    return { hasAuth: false };
  }
};

캐시할 데이터가 없어서 queryClient를 쓰진 않았다. 여튼, API의 getAuthStatus()를 호출해서 아무 에러가 없으면 유효한 세션값을 가지고 있는 것이고, 오류가 난다면 유효하지 않은 세션을 가지고 있는 것. 그걸 이용해서 값을 나눠줬다.

useLoaderData의 타입

그리고 짚고 넘어가야 할 건,, useLoaderData는 Generic이 아니고 unknown이다. 그래서 타입을 지정해주지 않으면 받아온 데이터에 대한 타입 추론이 되지 않는다. 그래서 아래와 같이 타입 캐스팅을 해줘야 한다.

const { hasAuth } = useLoaderData() as Awaited<ReturnType<ReturnType<typeof loader\>>>;

queryClient를 넘겨준 loader라 ReturnType이 두 번 들어갔다. 이렇게 하면 hasAuth에 대한 타입을 직접 지정해주지 않아도 자동으로 loader에서 반환하는 값으로 캐스팅할 수 있다.

LoginView

Pages/LoginPage - LoginView

const LoginView = () => {
  const naviage = useNavigate();

  const { signIn } = useAuth();

  const [isLogging, setIsLogging] = useState(false);
  const [error, setError] = useState<Error | null>(null);

  const handleSubmit = (e: FormEvent<HTMLFormElement\>) => {
    e.preventDefault();

    const formData = new FormData(e.target as HTMLFormElement);
    const loginData = Object.fromEntries(formData) as LoginData;

    setIsLogging(true);

    signIn(loginData, {
      onSuccess: () => naviage('/main', { replace: true }),
      onError: (error) => {
        setIsLogging(false);
        setError(error);
      },
    });
  };

  return (
    <div>
      <h2>로그인 페이지</h2>

      <form onSubmit={handleSubmit}>
        <fieldset disabled={isLogging}>
          <div>
            <label htmlFor="email">아이디</label>
            <input
              id="email"
              name="email"
              placeholder="example@example.com"
              defaultValue="sangpok@example.com"
            />
          </div>

          <div>
            <label htmlFor="password">비밀번호</label>
            <input
              id="password"
              name="password"
              type="password"
              placeholder="password"
              defaultValue="sangpok"
            />
          </div>

          {error && <p>{error.message}</p>}

          <button type="submit">{isLogging ? '로그인 중...' : '로그인'}</button>
        </fieldset>
      </form>
    </div>
  );
};

useAuth

다른 로그인 예제들 보면 이런 훅으로 로그인, 로그아웃을 관리해주길래 나도 따라해봤다! 어떤 식으로 구현해야 적절할진 모르겠지만, 나름대로... 짜봤다.

Hooks/useAuth.ts

const useAuth = () => {
  const { mutate: mutateLogin } = useLogin();
  const { mutate: mutateLogout } = useLogout();

  const checkSession = async () => {
    try {
      await API.getAuthStatus();
      return true;
    } catch (error) {
      return false;
    }
  };

  const signIn = (
    loginData: LoginData,
    callbakcs?: { onError?: (error: Error) => void; onSuccess?: () => void }
  ) => {
    mutateLogin(loginData, {
      onError: (error) => {
        callbakcs?.onError && callbakcs?.onError(error);
      },
      onSuccess: () => callbakcs?.onSuccess && callbakcs?.onSuccess(),
    });
  };

  const signOut = (callbakcs?: { onError?: (error: Error) => void; onSuccess?: () => void }) => {
    mutateLogout(undefined, {
      onError: (error) => {
        callbakcs?.onError && callbakcs?.onError(error);
      },
      onSuccess: () => callbakcs?.onSuccess && callbakcs?.onSuccess(),
    });
  };

  return { signIn, signOut, checkSession };
};

복잡한 건 없고, signIn()은 로그인하는 Mutation을 호출해주고, signOut()은 로그아웃하는 Mutation을 호출해준다. 재밌는 건 mutateLogout의 보내주는 variable이 undefined 인 것.

POST로 API를 호출하더라도 body에 담을 값이 없을 경우도 있는데, 그럴 경우 undefined를 넘겨주면 type error가 안 난다 굿!

Session 얻기

이제 제일 중요한 부분이 왔다. 사실 이거 적으려고 포스팅한 건데 쓰잘데기 없는 얘길 넘 많이 했다(ㅋㅋ)

로그인하는 흐름을 따라가보자. 로그인을 요구하는 Hook/쿼리는 다음과 같다.

export const useLogin = () => {
  return useMutation({
    mutationKey: ['login'],
    mutationFn: API.login,
  });
};

이 훅이 반환하는 mutate를 실행하는 것으로 시작된다. mutationFn인 login을 살펴보자.

export const login = ({ email, password }: { email: string; password: string }) =>
  Fetcher.POST('/auth/login', { email, password });

email, password을 받아서 Fetcher에게 넘겨주고 있다. 여기까지 온 거면 보통 각 필드에 대한 검증은 끝난 거라(난 따로 안 했지만) 따로 검증과정은 없어도 된다.

그러면, 이 요청을 보낸 직후 Network의 응답을 살펴보자.

Set-Cookie가 정상적으로 담겨서 응답이 왔다! 그러면 브라우저의 쿠키저장소에 쿠키가 잘 담긴다.

담..담겨야 하는데?

사실 요청을 한다고 다 담기는 게 아니다. 이를 이해하기 위해선 CORS와 credentials에 대해서 알아야 하는데, CORS에 대해서는 이전 포스팅에서 설명해두었다!

그래서 나는 사용자 인증 정보가 담긴 쿠키를 '보낼' 때만 credentials이 필요한 거라고 생각했는데, 그게 아니었다. 좀 더 본질적인 건 '쿠키에 사용자 인증 정보'가 있는 것이다.

그러니까 지금과 같이 FE쪽에서 보낼 사용자 정보가 없을 때 credentials 없이 보냈다고 해도, 서버쪽에서 보내오는 응답 헤더에 쿠키가 들어있으니, 이 요청과 응답은 서로 credentials이 필요한 것이다!

따라서 FE에선 다음과 같은 설정이 필요하다.

fetch의 설정에 credentials 설정하기

const Fetcher = {
  POST: <T\>(endpoint: string, formdata: object) =>
    fetch(`${API_URI}${endpoint}`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(formdata),
      credentials: 'include', // <------------------- 요기!
    }).then(handleResponse<T\>),
}

credentials 값으론 다음과 같은 값들이 존재한다.

  • include: 모든 요청에 다 넘겨주기
  • same-origin: 같은 origin에만 넘겨주기
  • omit: 죽어도 못 보내

그래서 include로 설정해주고 다시 확인해보면~

잘 들어온 것을 확인할 수 있다.

Session 보내기

그러면 이제 요청에 같이 쿠키를 담아서 보내보자. 아까 설명했듯이 그저 우린 credentials를 설정해주기만 하면 알아서 서버에 날아간다. /auth/status로 접근해서 확인해보자.

요청에도 잘 들어갔고,

서버 쪽에서도 잘 받았다!

🤔 잠깐, 그러면 저장은?

그렇다. 여기에서 짚고 넘어가야 할 게 있다. credentials 설정만 해주면, 보낼 때도 같이 보내지고, 서버에서 Set-Cookie를 지정해서 넘겨주면 알아서 저장해주고 있다. 그러면 React 쪽에서 따로 세션 아이디를 저장할.. 필요가...?

사실 없다. (두둥!)

심지어 보안상의 이유로 HttpOnly라는 속성을 켜서 응답해주면, JS는 document.cookie를 통해서 쿠키를 얻을 수조차 없다. HttpOnly는 브라우저만 오직 읽고 전송해줄 수 있는 속성이다.

JWT 방식으로도 마찬가지인 것 같다. 서버에서 토큰을 얻는 방법을 쿠키로 얻는 방식으로 바꾸면 된다.

그런데 그러면 내가 은연 중에 봤던 유저 정보를 저장하는 코드들은 어떤 걸 저장하는 거였을까? 유저 정보는 서버에 요청해서 받아오면 될 텐데,,

MainPage

MainPage는 세션이 있는 상태와 없는 상태로 화면이 분기된다. 있으면 다음과 같이 뜨고

없으면 다음과 같이 뜬다.

Pages/MainPage.tsx

const MainPage = () => {
  const navigate = useNavigate();
  const { hasAuth } = useLoaderData() as Awaited<ReturnType<ReturnType<typeof loader\>>>;

  return (
    <div>
      <h2>메인 페이지</h2>

      {!hasAuth && (
        <>
          <p>로그인 정보가 없네! 로그인부터 하쇼</p>
          <button onClick={() => navigate('/login', { replace: true })}>로그인하러 가기</button>
        </>
      )}

      {hasAuth && <LoginedView />}
    </div>
  );
};

아까 LoginPage와 같이 hasAuth에 따라 분기해주고 있고, 이 hasAuth는 loader에서 온다.

loader

MainPage loader

export const loader = (queryClient: QueryClient) => async () => {
  try {
    await API.getAuthStatus();

    queryClient.getQueryData(['user']) ??
      queryClient.fetchQuery({ queryKey: ['user'], queryFn: API.getUser });

    return { hasAuth: true };
  } catch (error) {
    return { hasAuth: false };
  }
};

아까 봤던 코드에 요상한 코드가 있다. 바로,, User의 정보를 불러오는 로직이다. 물음표가 두 개 있는 건 널 병합 연산자(Nullish coalescing operator)라고 해서, 왼쪽의 피연산자가 null 아니면 undefined 이면 오른쪽을 반환하는 연산자이다.

아까 말했듯 loader의 역할은 값을 불러오는 틈을 제공해주는 것. 그래서 user 쿼리키값에 값이 없으면 호출해주는 것이다. 그러면 LoginedView에서 다음과 같이 받는다.

LoginedView

LoginedView

const LoginedView = () => {
  const revalidator = useRevalidator();

  const { data: user, isPending, isError, error, isSuccess } = useGetUser();
  const { signOut } = useAuth();

  const handleClick = () => {
    signOut({ onSuccess: () => revalidator.revalidate() });
  };

  return (
    <>
      <p>반갑읍니다, {user?.nickname || '-'}님ㅋㅋ</p>
      <button onClick={handleClick}>로그아웃하기</button>
    </>
  );
};

평범하게 useQuery를 이용해서 받는다. 캐시가 있으면 캐시로 받고~ 아님 loader에서 패칭을 시도한 것의 값을 받든! 어쨌든 받는다.

useRevalidator

로그아웃을 성공했을 때 무언가를 revalidate하는 걸 확인할 수 있는데, 이 revalidate는 React Router의 것이다. 그러면 loader가 다시 실행되면서 hasAuth의 값을 갱신하게 된다.


후기

여기까지,, Session 방식의 로그인에 대해서 알아봤다. 생각보다 허무했다. 이걸 알아본 이유가 받은 세션 아이디를 어떻게 관리를 해주어야 하는지 고민하기 위해서였는데, 나온 결론이 아무것도 할 필요가 없다는 거라니!

그래도 그 과정에서 많은 걸 배웠다. CORS라든지,, CORS라든지 CORS라든지....

profile
FE개발자 가보자고🥳

0개의 댓글