Onboarding & 토큰 이슈 해결기

YuminPark·2025년 8월 14일

veco

목록 보기
1/5
post-thumbnail

시작에 앞서, 이 부분을 정말 열심히 고민했고 해결했을 때 진짜!!! 짜릿했다. 백호 개발을 하며 여러 에러 해결과 리팩토링을 진행했지만 그 중에서도 이 이슈가 가장 기억에 남는다.
3차 과제에 트러블슈팅 항목이 있는 걸 보자마자, 무조건 이 이슈를 꼼꼼히 정리해야겠다고 생각했다! 토나오는토큰이슈모음.zip


쿠키 저장을 어떻게 해야해?!🍪

이번 프로젝트에서는 구글(카카오도 마찬가지) 로그인을 성공할 경우 백엔드 측에서 refreshToken이라는 이름의 쿠키로 리프레시토큰을 발급해주셨다.
따라서 프론트 측에서는 리프레시토큰을 헤더에 실어서, 다른 API 함수를 호출하여 따로 accessToken을 발급받아야 했다.
나는 워크북+구글링 정도로만 쿠키를 접하였고, 이 당시까지에는 코드에

// 기본 axios 인스턴스
export const axiosInstance = axios.create({
  baseURL: import.meta.env.VITE_SERVER_API_URL,
  withCredentials: true,
});

와 같이 쿠키를 헤더에 실을 수 있게 처리만 해주면 문제 없을거라고 생각했다.
그런데 생각보다 간단한 문제가 아니었다..........
프론트에서는 개발을 로컬 환경, 즉 http://localhost:5173 에서 진행했는데, 백엔드 측에서 Secure 속성이 있어 프론트는 https://localhost:5173 으로 전환해야 했다.
그런데 여기서 또!! 이슈가 생긴다.. 바로 백엔드와 도메인이 일치해야 쿠키가 저장된다는 것이었다... 🥲 Cross-site 제약이 있기 때문이었는데, 프론트 팀원 언니의 도움으로, https://web.vecoservice.shop 로 도메인을 변경하게 되었다.
덕분에 쿠키가 제대로 헤더에 실리고, accessToken 요청 함수를 호출할 수 있게 되었다.
다시 생각해도 정말 아찔


백엔드에서의 분기처리가 과연 좋을 것인지...

우선적으로 내가 고려해야하는 사용자 케이스는 크게 3가지가 존재했다.

1. 다른 사람이 만든 워크스페이스에 초대받은 사용자(워크스페이스 만든 이력 없음)
2. 신규 사용자 (워크스페이스 만든 이력 없음)
3. 기존 사용자 (워크스페이스 만든 이력 있음)

또한 백엔드 측에서 리다이렉트를

- 워크스페이스와 연결되어 있지 않은 회원의 경우, 쿼리스트링으로 `flow` 라는 값을 받아 분기 처리 (미지정시 기본값 create)
    - flow = create인 경우 https://veco-eight.vercel.app/onboarding/workspace
    - flow = join인 경우 https://veco-eight.vercel.app/onboarding/input-pw
- 아닐 경우 https://veco-eight.vercel.app/workspace로 리다이렉트

이와 같이 처리해주셨기에, 나는 다음과 같은 문제점을 마주했다. 🥲
앞으로 아래 3개의 논제를 골칫거리라고 언급하겠다...

① 위에 언급한 모든 사용자가 각기 다른 경로를 통해, 정상적으로 워크스페이스에 진입할 수 있도록 해야 하는데, 초대 받은 사람의 경우는 어떻게 진입하게 해야하는가?
② accessToken을 전달받아 로컬 스토리지에 저장해야 하는데, 이 로직은 어느 페이지에서 구현해야 하는가?
③ 자칫 잘못하면 여러 페이지에서 accessToken을 중복으로 관리하게 될 것 같은데 이 문제는 어떻게 해결해야 하는가?


N번의 시도..... 대안으로 무엇이 좋을지?

사실 나는 이 정도로 딥하고 고려해야하는 경우가 많은 로직을 짜본 적이 없었다. 어떤 로직부터 짜야될지 모르겠어서 솔직히 진짜 막막했다. 정말 괜히 하겠다고 했나... 싶었다 🥹 그런데 대부분의 API 요청의 경우 헤더에

Authorization: Bearer {accessToken} 형식의 jwt 토큰

이 필요하기 때문에 내 작업이 지체되면 팀원들에게도 일정 지연을 줄 것 같아 정신을 빠짝 차렸다..
따라서 우선적으로, 백호 서비스를 이용하려는 신규 사용자(즉 2번 케이스에 해당)만 러프하게 생각해보고 다른 경우를 나눠서 처리하고자 했다.

<첫 번째 시도>

신규 사용자의 경우 벡엔드 측의 리다이렉트를 통해 /onboarding/workspace 경로로 이동할 것이기 때문에, 나는 accessToken을 해당 경로에 해당하는, OnboardingCreateWorkspace.tsx에서 처음으로 accessToken을 받아 저장하도록 코드를 작성했다. 그 로직에 해당하는 코드는 다음과 같았다.

  useEffect(() => {
    const fetchAccessToken = async () => {
      const accesstoken = await postReIssueAccessToken();
      const { setItem } = useLocalStorage(LOCAL_STORAGE_KEY.accessToken);
      setItem(accesstoken);
    };
    fetchAccessToken();
  }, []);

실제로 시도해보니 accessToken이 제대로 로컬스토리지에 저장되고, accessToken이 필요한 다른 API 함수 요청도 똑바로 가는 것을 Network 탭을 통해 확인하였다.
easy하게 해결되는 듯 하였으나..... 여기서 또 문제점이 발생했다.
당시 accessToken의 유효시간이 1시간이었는데, 만료되고 나면 수기로 /onboarding/workspace 주소를 입력창에 쳐서 accessToken을 받아와야한다는 점이었다.
즉 팀원들이 ProtectedRoutes 산하에 있는 접근 제한된 페이지들 속에서 작업을 하다가 accessToken이 만료되면 일일이 /onboarding/workspace 로 들어가서 토큰을 받아와야 했다.... 아놔;;
난 이 상황에서 accessToken을 요청하고 로컬스토리지에 저장하는 로직을 분리해야겠다고 생각했다. 사실 저 경로는, 신규 사용자만이 최초 가입 시 딱 1번만 지나가는 페이지라서 더욱 수기로 입력해서 들어가면 안된다고 생각했다. 따라서 백엔드측에 SOS를 요청했다.

<두 번째 시도>

내가 두 번째로 생각한 로직은 다음과 같았다.

  • 구글 로그인 버튼을 누르면 지금처럼 백엔드 측에서 세 가지 경로로 분기 처리를 해주시는 게 아니라, 프론트 측에서 분기 처리를 한다.
  • 단순히 하얀 배경에 '로딩중입니다.' 정도의 문구가 들어간 페이지를 만들고, 백엔드는 그 페이지로 리다이렉트 시킨 후 그 페이지 코드에서 accessToken을 요청해 로컬스토리지에 저장한다.

꽤 그럴듯했으나, 여기서 새로운 고민사항이 생겼다.
이 방식으로 할 경우, 프론트 측에서 세 가지 경로로 분기처리를 하게 되는데 어떤 방식으로 해야하나? 사용자가 초대 받은 사용자인지, 신규 사용자인지, 기존 사용자인지 구분을 어떻게 할 수 있을까? 특히 초대 받았는지 아닌지를 어떻게 구별할 것인가?

벡엔드 측과 함께 시도해본 로직과 그 결과는 다음과 같았다.

  • 단순 하얀 페이지에 해당하는 페이지로 리다이렉트를 시켜주시고, 응답으로 함께 flow값을 넘겨주시면 어떨지? 그렇다면 프론트엔드에서 flow값이 [create, join, 둘 다 아님] 으로 사용자를 구분할 수 있을 것 같다!

    • 그럴듯해 보이지만 백엔드 측에서 구현이 불가한 방법이었다. 리다이렉트를 시키면서 동시에 response body를 보낼 수 없다고 하셨다.
  • refreshToken 이름으로 쿠키를 발급해주실 때 flow값을 함께 쿠키에 실어서 응답을 주시면 어떨지?

    • 이 방법은 프론트엔드 측에서 구현이 불가능하다. 쿠키의 경우 httpOnly 이고, 내가 따로 그 쿠키를 파싱하거나 값을 읽을 수 없었다.

이 때 정말로 멘붕이 왔었다...................(┬┬﹏┬┬)

<세 번째 시도>

새로운 고민사항에 대한 여러가지 차선책을 마련하고자 했다.
그 당시 벡엔드 팀원분과 함께 떠올렸던 플랜 B,C는 다음과 같다.

  • 플랜B (FE->BE 제안)

    • 로딩 페이지 (위에서 언급한 단순 하얀배경의 페이지)에서 accessToken을 요청하여 로컬스토리지에 저장을 하고, 그 accessToken을 헤더에 넣어서, 따로 flow 값을 받을 수 있는 API 함수를 요청하면 어떨까?
    • 즉 벡엔드 측에서 flow 값을 response로 내려주는 API를 하나 더 개발해주실 수 있는지..
  • 플랜C (BE->FE 제안)

    • 구글 or 카카오 버튼을 클릭하여 로그인 요청시 함수 뒤에 { } 와 같이 값을 넣어서, 이 사람이 초대받은 사용자인지 아닌지 구분해서 요청 보내주실 수 있는지?

    • 그 당시 구글 로그인 주소로 이동시키는 함수는 아래와 같았다. /authorization/google 뒤에 초대받은 사용자인지 아닌지 구분을 하는 방법을 제안해주셨다.

      export const redirectToGoogleLogin = () => {
      const baseURL = import.meta.env.VITE_SERVER_API_URL;
      if (!baseURL) {
        console.error('서버 주소가 정의되지 않았습니다.');
        alert('서버 연결에 문제가 있어 Google 로그인을 진행할 수 없습니다.');
        return;
      }
      
      try {
        window.location.href = `${baseURL}/oauth2/authorization/google`;
      } catch (error) {
        console.error('Google 로그인 리다이렉트 실패:', error);
        alert('Google 로그인 중 문제가 발생했습니다.');
      }
      };

이렇게 여러 차선책을 떠올리던 중, 내부 로직페이지를 하나 더 만들어 우선적으로 초대받은 사용자인지 아닌지를 먼저 가려내는 방식을 하는 게 좋겠다고 제안해주셨다. 이 방식이 실패한다면, 차선 플랜으로 진행하기로 하였다!


결국은 로직 갈아엎기...... 결과는?!

마지막으로 시도해보기로 한 로직은 다음과 같았다. 말로 하다가 너무 헷갈려서 그림을 그리려고 했는데 당시 내가 펜도 없고 아이패드도 없어서 이 그림은 PM님이 직접 그려주셨다 ㅋㅋㅋㅋㅋㅠㅠ최고....

근데 나같은 감자는.. 머리로는 이해가 되지만 위 플로우를 보면서도 어디서부터 짜야할 지 조금 어려워서, 우선적으로 초대 받은 사용자를 고려하지 않고 내부 로직 페이지2 부터 구현하였다.

내부 로직 페이지2️⃣ (TokenLoading.tsx) 경로- /onboarding/loading

import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { postReIssueAccessToken } from '../../apis/auth';
import { getWorkspaceProfile } from '../../apis/setting/useGetWorkspaceProfile';
import { LOCAL_STORAGE_KEY } from '../../constants/key';
import { useLocalStorage } from '../../hooks/useLocalStorage';

const TokenLoading = () => {
  const { setItem } = useLocalStorage(LOCAL_STORAGE_KEY.accessToken);
  const { getItem } = useLocalStorage(LOCAL_STORAGE_KEY.isInvite);
  const navigate = useNavigate();

  useEffect(() => {
    const init = async () => {
      try {
        // accessToken 발급
        const accessToken = await postReIssueAccessToken();
        setItem(accessToken);
        try {
          await getWorkspaceProfile(); // 워크스페이스 조회
          // 200 응답
          navigate('/workspace/complete');
        } catch (err: any) {
          // 404 응답 -> isInvite 따라 분기 처리
          if (err.response?.status === 404) {
            const isInvite = getItem();
            if (isInvite === 'true') {
              navigate('/onboarding/input-pw');
            } else {
              navigate('/onboarding/workspace');
            }
          } else {
            console.log('[TokenLoading] ❌ 워크스페이스 조회 실패:', err);
          }
        }
      } catch (err) {
        navigate('/onboarding'); // fallback
      }
    };

    init();
  }, [setItem, navigate]);

  return <h3 className="font-title-sub-r text-gray-600">로딩중입니다.</h3>;
};

export default TokenLoading;

(완성 버전이라 isInvite 조건 분기도 있다..!!)
사진 속 플로우에 따라 그대로 코드를 옮기면 되었다. 그리고 accessToken 요청해 저장하는 로직을 /onboarding/workspace에서, /onboarding/loading 으로 따로 분리하였다는 점!
모든 사용자가 구글 or 카카오 버튼을 클릭해 이 페이지를 무조건 지나기 때문에 여기저기 accessToken을 중복으로 관리 하지 않아도 되므로 골칫거리 ②③번을 해결할 수 있었다!! 🤩🤩

그 다음으로는 골칫거리 1번을 해결하기 위해 또 다른 내부로직 페이지를 제작하였다.

내부 로직 페이지1️⃣ (InviteLoading.tsx) 경로- /:workspaceName/invite

초대 받은 사용자는 다음과 같은 정보를 갖고 있다. (예시)

팀원 URL : https://veco-eight.vercel.app/yu-min/invite?token=e73b89f6fb8a
암호 : 3a2pmv

내부 로직 페이지 1에 해당하는 코드는 다음과 같다.

import { useEffect } from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { useLocalStorage } from '../../hooks/useLocalStorage';
import { LOCAL_STORAGE_KEY } from '../../constants/key';

const InviteLoading = () => {
  const { workspaceName } = useParams(); // URL 파라미터 추출
  const [searchParams] = useSearchParams(); // URL query parameter
  const token = searchParams.get('token'); // token 값 추출

  const navigate = useNavigate();
  const { setItem: setWorkspaceName } = useLocalStorage(LOCAL_STORAGE_KEY.workspaceName);
  const { setItem: setIsInvite } = useLocalStorage(LOCAL_STORAGE_KEY.isInvite);
  const { setItem: setInviteToken } = useLocalStorage(LOCAL_STORAGE_KEY.token);

  useEffect(() => {
    if (workspaceName) {
      setWorkspaceName(workspaceName);
      setIsInvite('true');
      if (token) {
        setInviteToken(token);
      }
      setTimeout(() => {
        navigate('/onboarding');
      }, 50);
    }
  }, [workspaceName, token, navigate]);

  return (
    <div className="min-w-max min-h-screen flex flex-col items-center justify-center">
      <h3 className="font-title-sub-r text-gray-600">초대 확인중입니다.</h3>
    </div>
  );
};

export default InviteLoading;

사용자가 팀원 URL을 입력하면 먼저 내부 로직 페이지1 로 보내고, 그 페이지 내에서 팀원 URL에 있는 workspaceName, 초대 여부 isInvite를 저장한다.그 후 로그인 화면 /onboarding으로 이동시킨다.
이후 사용자가 구글 or 카카오 버튼을 클릭하면 accessToken을 요청 및 저장하고 워크스페이스 정보 조회 404 응답을 통해 이 사람은 워크스페이스를 만든 적이 없구나! 하고 알게 되는데, 로컬스토리지에 isInvite라는 값이 저장되어 있기 때문에, 이 사용자가 초대 받아서 들어왔구나! 를 알 수 있게 된다. 따라서 바로 암호 입력 페이지 /onboarding/input-pw 로 보낼 수 있다!
결론적으로 골칫거리 ①번도 해결할 수 있게 된다. 🤩🤩

번외로 새롭게 알게된 게 있는데, 팀원 URL을 보면 도메인이 https://veco-eight.vercel.app 이기 때문에 현재 http://localhost:5173 에서 작업 중인데 테스트를 어떻게 해보지...? 싶었는데,

/* 초대 받은 사용자를 위한 리다이렉트 페이지 */
  {
    path: '/:workspaceName/invite',
    element: <InviteLoading />,
  },

PublicRoutes 파일에 경로를 위와 같이 연결해두면 로컬 환경에서도 정상적으로 초대 받은 사용자의 플로우도 체험해볼 수 있었다!!즉 http://localhost:5173/워크스페이스이름/invite?token=초대토큰값 이라고 주소창에 입력해도 잘 들어가지는!( •̀ ω •́ )✧

Onboarding 흐름 정리 ✍🏻

관련 PR 링크 : https://github.com/vecosystem/VECO_Frontend/pull/143

내부 로직 페이지 3️⃣ (WorkspaceComplete.tsx) 경로- /workspace/complete

  • 원래는 /workspace로 일괄적으로 보내도록 처리했었는데, 그렇게 하니 경로에 teamId가 없는 workspace가 되어서 작업에 이상이 생겼다.
  • 따라서 /workspace/complete 경로로 보내고, 이 페이지 속에서 teamId를 포함한 경로로 각 사용자가 분기할 수 있게 처리를 했다. (이 부분은 팀원 언니가 해주셨다!!)
  • 따라서 각 사용자는 본인이 만들었거나 or 참여한 워크스페이스의 teamId에 맞게 공간에 입장할 수 있게 된다.

느낀 점

  • 성공하고 보니, 로직이 매우 복잡하고 고려해야 하는 상황이 많아서 플로우 먼저 짜고 코딩을 했다면 어땠을까 싶은 생각이 든다. 사실 생각보다 코딩하는 시간이 아주 길진 않았고 고민하고 갈아엎는 시간이 훨씬!!!오래 걸렸던 것 같다.

  • 또한 백엔드측에서 리다이렉트 & 분기처리를 해주시는 방식이 프론트 측에서 편하기야 하다만, 지금 같은 상황에서는 프론트가 하는 것이 훨씬 낫다. 사실 나는 구현에 큰 자신은 없었지만 갈아 엎자는 제안을 했었는데... (말해놓고 두려웠다) 잘한 선택인 것 같다. 어쩔 때는 갈아 엎을 수 있는 용기도 필요하다.

  • 또한 쿠키 방식을 도입하신다고 하셨을 때 미리 좀 찾아볼 걸 그랬다!!!!! 글 초반에 말했듯이 정말 코드에 withCredentials = true만 설정하면 될거라고 생각했다....도메인 이슈까지 겹칠 줄은 몰랐다. 앞으로는 처음 시도해보는 방식이 있다면 내가 미리 준비해야하는 것은 무엇인지 유의사항 등을 미리 서치해둬야겠다.

  • 프&벡 소통이 너무나 중요하다고 느꼈다. 함께 도와주신 백엔드 팀원분께 무한 감사를 드린다.... 진짜 내가 바보같은 질문 엄청 많이 하고 새벽까지 붙잡았던 것 같은데 엄청 도와주셔서 너무 감사했다. 천사이심에 틀림없다... 👼🏻🪽

  • 과정은 힘들었지만 그래도 성공했을 때 나보다 더 기뻐해주는 사람들과 함께 일할 수 있어서 좋았다. 감자를 믿어주신 천사분들...🥔🥔

profile
기억하고 싶은 것을 기록합니다

0개의 댓글