방구석에 놔둔 개발 노트

1년차 웹 / 앱 프론트엔드 엔지니어의 좌충우돌 얼렁뚱땅 앞뒤짱구 생존기

2023-04-02: 개인 프로젝트 도전기 (11주차)

목표 정산

우선, 이번 주에 진행하고자 했던 사항은 아래와 같다. 최후의 목표인 런칭은 다음주로 미뤄졌다.

  • 1차 QA 진행 후 발생한 이슈 수정
  • 로그인 관련 부족한 기능 개선
  • 2차 QA 테스트 진행 후 발생한 이슈 수정
  • Vercel 등을 통해 배포 진행 및 배포 후 테스트 진행 (희망)
  • 솔루션 릴리즈 (희망)

생각보다 1차 QA 자체에서 수정해야할 사항들이 많았다. 이런저런 수정을 진행하다보니까 1주일이 순식간에 사라져버렸다, 세상에.

그러나, QA 과정에서 내가 생각치 못했던 예외사항들을 발견할 수 있기도 했고, 이렇게 발견한 사항들이 다음 프로젝트 때의 참고할 지식이 될 것이기에 열심히 수정 작업을 진행했다. 이번 주는 그 수정 작업과 관련한 이야기들을 좀 풀어볼까 한다.

React-Query의 useQuery에는 다양한 옵션이 있다.

1차 QA 테스트를 진행하면서 발견했던 이슈 중에는 아래와 같은 이슈가 있었다.

관리자 매장 관리 페이지에서 내용을 작성 중에 다른 탭 등을 갔다가 돌아올 시, 작성된 내용이 아닌 기존 설정값으로 돌아오는 문제

무슨 소리냐 함은, 매장 관리 페이지에서 내용을 작성하거나 수정하고 있던 중에 잠시 딴짓 등을 통해서 해당 페이지를 놔둔 상태로 다른 작업을 진행하고 있다고 치자.

그러다 이제 다시 수정해야지라고 생각하고 놔뒀던 페이지로 다시 돌아오니 도중에 수정했던 값들이 전부 초기화되어 초기값으로 돌아간다는 문제이다.

창을 새로고침했거나, 닫다가 다시 연 상태라면 문제가 없겠지만 여기서 생각해야할 점은 ‘창을 켜둔 상태에서 다른 창이나 프로그램을 건드렸다가 돌아왔다’라는 상태이다.

즉, 창이 꺼지지 않은 상태라는 점이다.

이 문제를 해결하기 위해 어떤 점이 있을까 고민하던 중에 이전에 useMutation 덕분에 fetchtype 옵션을 알게 됐으니 useQuery에도 분명 이처럼 옵션들이 있을 거라 생각하고 공식 문서를 확인해봤다.

useQuery | TanStack Query Docs

공식 문서 내용 중에 현재의 내 상황을 해결할 수 있는 옵션으로 refetchOnWindowFocus가 있었다. (옵션명이 되게 직관적이라 쉽게 발견할 수 있었다. 이래서 변수명이나 함수명 등을 잘 써야하나보다.)

refetchOnWindowFocus: boolean | "always" | ((query: Query) => boolean | "always") 초기값은true로 설정되어 있다. - true값으로 설정되어 있다면, 포커스된 창에 데이터가 오래됐다면 쿼리를 다시 갱신해준다. - false 값을 설정되어 있다면, 데이터가 오래됐을 시에도 포커스된 창에 쿼리를 갱신해주지 않는다. - always 값을 설정되어 있다면, 데이터가 오래된 것과 상관없이 포커스된 창에 쿼리를 다시 갱신해준다. - 함수를 값으로 설정했다면, 함수 안의 조건에 따라 값을 계산하고 포커스된 창에 쿼리를 갱신할 지 여부를 판단합니다.

여기에 있는 옵션 값을 활용하면, 창에서 잠시 벗어났다가 돌아와도 수정하던 내용이 변경되지 않으리라 생각하고 아래와 같이 옵션을 설정했다.

const currentStoreOption = useQuery({
  queryKey: ["currentStoreOption"],
  queryFn: getStoreSettingData,
  refetchOnWindowFocus: false,
  onSuccess(data) {
    if (data !== undefined) {
      setStoreData(data.data);
    }
  },
});

위와 같이 옵션으로 설정 후, 내용을 적용했더니 원하는 대로 이제는 창에서 벗어났다 돌아와도 기존의 내용 그대로 유지된다!

여담으로 아까 말하다시피 useQuery에는 이러한 옵션 외에도 다양한 옵션들이 존재한다. 아래와 같은 옵션들도 존재한다.

useQuery도 useQuery지만, useMutation도 다양한 옵션이 존재하며 이러한 옵션들은 공식 문서에 잘 정리되어 있다. 이러한 옵션들이 어떠한 역할을 가지는지 이해를 할 수 있다면, 앞으로 useQuery를 이용해서 데이터를 관리하는 데에 좀 더 수월한 일들을 해낼 수 있지 않을까!

순진하게 믿었죠. 링크만 누르면 이메일 인증이 될 거라는 걸.

이번에 1차 QA 테스트 이후, 문제점을 수정하면서 한 가지 당황했던 일이 있었는데 파이어베이스 인증에서 지원해주는 기능 중에는 이메일 링크를 통해서 계정 인증을 진행하는 부분이 있었다.

처음에는 이메일을 보내주고, 받은 이메일의 링크를 진입하면 자동으로 이메일이 인증될 거라는 믿음이 있었다.

그런데 로그인 페이지에서 해당 계정이 이메일 인증이 진행되지 않으면 이메일 인증을 하라고 메일을 다시 보내주도록 기능을 구현하고 테스트 했더니… 위처럼 생각한대로 이메일 링크를 열어 인증이 될 거라는 믿음이 산산조각이 났다.

그렇다, 인증이 되지 않았다.

사실 내가 바보였던 게 아닐까

이 문제를 타파하기 위해 받은 이메일 링크를 통해 어떻게하면 인증 절차를 구현할 수 있을지 구글링을 통해 검색해봤는데, 생각 외로 ‘이메일을 보내주는 것’까지는 다들 정리를 했는데 ‘이메일 인증’까지 적용하는 사항에 대해서는 언급되지 않았다.

당혹스러움에 어떻게 하면 좋을까 고민 끝에 최후의 보루, 마지노선으로 항상 쓰이는 Chat GPT에 관련 방법이 있을지 물어본 결과, applyActionCode라는 기능을 알려줬다.

Auth | JavaScript SDK  |  Firebase JavaScript API reference

인자값으로 firebase의 Auth 모듈과 이메일 링크로 받아오는 oobCode 값을 받아와서 인증을 진행하는 것인데.. SDK API 참조 문서에는 있으면서, 공식 가이드 문서에서는 이 기능이 누락되었다. 이 자식들이

Firebase에서 사용자 관리하기

아무튼 이 모듈을 알게 됐으니까 이제 써먹기만 하면 되겠다 싶어 아래와 같이 코드르 추가했다.

if (searchParams.get("mode") === "verifyEmail") {
  if (actionCode !== null) {
    applyActionCode(firebaseAuth, actionCode);
  }
}

이제 링크를 통해 들어오는 사람은 해당 링크를 통해 정상적으로 이메일 인증이 진행될 것이리라.

마지막으로 인증이 되었는지를 확인하기 위해 로그인을 시도해본 결과, 정상적으로 로그인이 진행된 것을 확인했다.

다른 이메일을 입력했어도 회원 탈퇴가 정상적으로 진행된 건에 대하여

회원 탈퇴를 테스트하던 중에 하나 크리티컬한 이슈가 한가지 있었는데 우선 아래의 코드를 보자.

const withdrawalAccount = async (userData: AdminData) => {
  const credential = EmailAuthProvider.credential(
    currentUser!.email!,
    userData.password!
  );

  const withdrawalState = reauthenticateWithCredential(
    currentUser!,
    credential
  )
    .then(() =>
      deleteUser(currentUser!).then((data) => {
        return "delete-success";
      })
    )
    .catch((error) => error.message);
  return withdrawalState;
};

Firebase에서는 회원 탈퇴를 진행하기 전에, 유저에게 재인증을 하고 해당 유저가 맞다면 탈퇴를 진행시키도록 유도하고 있다.

위의 내용은 회원 탈퇴를 진행할 때의 코드인데, credential이라는 변수의 내용을 보면 예상이 되겠지만 저 내용은 유저의 재인증을 위해 증명을 발급하기 위한 절차이다.

그런데, 저렇게 코드를 작성하고 나니 한 가지 문제점이 발견됐는데 내가 입력한 이메일이 다르다고 해도, 비밀번호만 옳게 입력했다면 정상적으로 탈퇴가 진행된다는 점이다!

해당 문제는 어찌됐든, 입력한 이메일을 통해서 회원 탈퇴가 진행되어야 한다는 점이다. 그러나, 그 전에 현재 로그인된 계정의 이메일과 입력한 이메일이 동일해야 하는 점도 간과해서는 안된다.

따라서 해당 절차를 진행하기 전에, 유효성 검사를 적용하기 위해 submit 함수에 아래와 같이 내용을 추가했다.

if (userData.email !== currentUser?.email) {
  setLoadingState(false);
  return !toastMsg.isActive("error-emailIncorrect")
    ? toastMsg({
        title: "계정 불일치",
        id: "error-emailIncorrect",
        description:
          "현재 로그인하신 계정의 이메일과 입력하신 이메일 아이디가 동일하지 않습니다. 이메일을 정확하게 입력했는지 확인해주세요.",
        status: "error",
        duration: 5000,
        isClosable: true,
      })
    : null;
}

간단한 예외사항이라고 할지 몰라도, 계정이 실수로 날아갈 수도 있는 크리티컬한 문제였지만 이제서라도 이걸 파악해서 참 다행이다.

그리고 한편으로는 계정 관리에는 정말 다양한 예외사항이 존재하구나 싶었고, 얼마나 그걸 잘 막아내고 보완하는가가 정말 중요한 포인트이구나.. 싶었다.

새로고침으로부터 계정 로그인 상태를 유지하기 위한 토큰 확인 및 갱신

예전에 사이드 프로젝트를 진행할 당시에도 겪었던 부분이긴 한데, 새로고침을 할 때마다 계정 로그인 정보를 받아오지 못해서 정상적으로 페이지의 기능들을 이용하지 못했던 이슈가 있었다.

그 당시에는 페이지를 넘어갈 때마다 axiosintercepter를 통해서 토큰 데이터를 재확인하거나, 혹은 localStorage를 통해 데이터가 있는지 확인하는 방식으로 진행했었는데 이번엔 전부 Firebase를 통해서 진행하다보니 axios를 쓸 일도 없어서, 어떻게하면 로그인 데이터를 받아올 수 있을지 고민이 됐다.

우선, 계정의 영속성에 대해서 어떻게 해결할 수 있을까? 를 찾기 위해 구글링을 통해서 검색을 해보니 다음과 같은 글을 발견하게 됐다.

인증 상태 지속성  |  Firebase

내용을 확인 + 현재 코드에서 이것저것 둘러본 결과 몇 가지 특징을 발견하게 됐는데,

  • Firebase Auth를 통해 계정을 로그인할 경우, 해당 계정은 특정한 조건을 건드리지 않는 이상 기본적으로 웹의 localstorage에 토큰을 저장한다.
  • 지속성을 유지해주기 위한 저장소로 localStoragesessionStorage를 지원해준다.
  • 브라우저가 종료되는 게 아닌, 현재의 세션이나 탭에서만 상태가 유지되며 사용자가 인증된 탭이나 창이 닫힐 때, 로그인 정보를 폐기하고 싶다면 sessionStorage를 사용하는 것이 좋다.

브라우저가 켜진 상태에서 로그인을 계속 유지시킬 생각은 없기에, 지속성을 유지하기 위해 정보를 sessionStorage에 저장하는 식으로 진행해보기로 했다.

const loginAccount = async (userData: AdminData) => {
    // 위의 이미지의 항목은 firebase v8 기준으로 나와있다.
  // v9는 모듈러 형태이기 때문에, 아래와 같이 인자값을 auth 모듈과 함께 어디다 저장할 것인지를
  // 설정하는 다른 모듈을 가져와야한다.
  // 여기서는 세션으로 저장할 것이므로 browserSessionPersistence를 사용하기로 했다.
  const loginState = setPersistence(firebaseAuth, browserSessionPersistence)
    .then(() =>
      signInWithEmailAndPassword(
        firebaseAuth,
        userData.email,
        userData.password!
      )
    )
    .then((cred) => {
      if (cred.user.emailVerified === false) {
        sendEmailVerification(cred.user);
        return "send-email";
      } else {
        onAuthStateChanged(firebaseAuth, () => {});
        return "login-success";
      }
    })
    .catch((error) => error.message);

  return loginState;
};

이런 식으로 로그인 후의 세팅을 설정하면, 이제 관리자 도구의 어플리케이션 항목에서 sessionStorage 항목에 관련 내용을 확인할 수 있다.

sessionStorage에 토큰 값이 있다면, 현재 로그인이 된 상태인지를 확인하는 건 간단하다. 토큰이 있다면 페이지를 유지하고, 없다면 에러메시지를 띄우고 로그인 페이지로 돌아가게 하면 되리라. 관리자 페이지에서 이런 로그인 상태를 확인할 필요가 있으므로, 이를 이용할 수 있는 유틸리티 함수를 하나 다음과 같이 만들고 사용하기로 했다.

export const loginStateCheck = () => {
  const sessionKey = `firebase:authUser:${firebaseConfig.apiKey}:[DEFAULT]`;
  const loginState = sessionStorage.getItem(sessionKey);

  if (loginState === null) {
    return false;
  } else {
    return JSON.parse(loginState).uid;
  }
};

위와 같이 현재 로그인된 상태가 맞다면 현재 로그인된 계정의 uid 값을 반환하도록 했고, 아니면 false를 받도록 하여 로그인 페이지로 돌아가게 했다.

아래는 이 방법을 활용한 예시 중 하나이다.

if (loginStateCheck() === false) {
  if (toastMsg.isActive("error-notLogin")) {
    toastMsg({
      title: "로그인 상태가 아님",
      id: "error-notLogin",
      description:
        "로그인 상태가 아닙니다. 로그인 페이지에서 정상적으로 로그인해주세요.",
      status: "error",
      duration: 5000,
      isClosable: true,
    });
  }
  navigate("/adminlogin");
}

로그인한 상태라면, sessionStorage를 통해서 uid을 받아올 수 있기 때문에 getAuth()로 인증 정보를 받아오는 게 먹통이 되는 상태라도 이 uid를 통해서 데이터 정보를 가져오는 게 가능했다.

아래는 그런 사항을 참고해서 uid를 받아와 데이터를 불러오는 코드이다.

const getStoreOption = async () => {
  const storeDataState = await getDocs(collection(db, "adminList")).then(
    (data) => {
      let adminData: any;
      data.forEach((doc) => {
        if (doc.data().uid === loginStateCheck()) {
          return (adminData = doc.data());
        }
      });
      return adminData;
    }
  );
  return storeDataState;
};

이런 식으로 담아있는 uid 값을 통해 데이터를 받아오게 해서 필요한 데이터를 받아오게 설정했다.

Firebase에서 토큰을 갱신시켜주는 묘한(?) 방법? getIdToken

위처럼 로그인을 확인하는 코드를 제작하긴 했지만, 경우에 따라서는 이 로그인된 상태가 오랜 기간 유지되어야 할 필요가 있을 때도 있다.

예를 들자면, 관리자가 오랜 시간 대기 리스트 페이지를 열어두고 있다가 새로고침을 했다고 치자. 그때 세션이 만료되었다면 다시 로그인을 해야하는 번거로움이 발생하게 된다.

Firebase의 토큰 만료 시간은 짧지 않은 1시간이다. 그 1시간 동안 대기 관리를 계속 체크할 수 있겠지만은, 화면을 껐다켰다 하면서 이용할 경우 1시간이 지나는 순간 로그인이 풀려서 다시 로그인을 해야하는 번거로움이 발생할 수 있다.

따라서 사용하는 상태에 따라서 로그인 상태를 갱신, 즉 토큰을 갱신해줘야할 상황이 생긴다는 건데 어떻게 토큰을 갱신할 수 있을까?

보통 토큰 정보를 갱신한다는 내용과 관련하여 이전에 조사했을 때 알게 된 방법은 access token을 서버에 전송해 인증 정보를 체크하고 만료됐을 시, 새로운 access token을 발급하는 식으로 진행되는 것으로 알고 있었다. (잘못됐다면 누군가가 알려주시길..! refresh token을 사용하지 않는 가정으로 작성하였다.)

여기서도 그 규칙이 성립되는지는 모르겠지만, firebase에서는 getIdToken이라는 모듈을 통해서 토큰을 가져와서 새롭게 갱신해주는 방식을 사용해주고 있었다.

이렇게 사용자를 식별하기 위한 유저 정보가 담긴 token을 getIdToken을 통해 가져온 뒤, 옵션에 적힌대로 ‘force refresh’. 즉, 강제로 토큰 정보를 갱신시켜주려고 한다.

그러나 구현하려는 웹사이트에서는 1시간 이상 사용하지 않을 때에는 대기열이 없는 상태가 오래 지속되는 거니까 사용성이 떨어지므로, 보안을 위해서라도 로그아웃 해주는 게 맞다고 생각이 됐고 그렇지 않을 경우에는 어찌됐든 대기열이 계속 생기는 거니까 계속해서 로그인을 유지해줘야 하리라.

그래야 이용의 불편함을 덜 수 있을 거라고 판단했고 이런 점을 고려해 아래와 같이 코드를 작성했다.

export const tokenExpirationCheck = async () => {
  const firebaseAuth = getAuth();
    // 현재 유저의 토큰 정보를 받아오도록 함.
  const expirationCheck = await firebaseAuth.currentUser
    ?.getIdTokenResult()
    .then((token) => {
            // 토큰 정보에 있는 만료 시간 값을 가져옴
      const expirationTime: number = new Date(token.expirationTime).getTime();
            // 만료 시간과 비교할 현재 시간 값을 가져옴.
      const nowTime: number = new Date().getTime();

            // 현재 시간이 만료 시간을 이겼을 경우, 즉 만료 시간이 넘어갔을 경우
      if (nowTime >= expirationTime) {
        const sessionKey = `firebase:authUser:${firebaseConfig.apiKey}:[DEFAULT]`;
        sessionStorage.removeItem(sessionKey);
                // 로그인된 정보를 삭제하도록 한다.
        return true;
      } else {
        firebaseAuth.currentUser?.getIdToken(true);
                // 토큰 값을 가져와서 토큰이 강제로 갱신되도록 한다.
        return false;
      }
    });
  if (expirationCheck === undefined) {
        // 토큰 값이 없을 경우, false로 반환하도록 한다.
    return false;
  }
  return expirationCheck;
};

위와 같이 토큰에 있는 만료 시간을 체크하고 넘겼나 안넘겼나에 따라 값을 다르게 하여 토큰을 갱신할 거인지, 아니면 토큰을 지워서 새로 로그인하게 만들 것인지를 판단하게 하도록 유도했다.

이렇게 함으로서 계속 사용 중인 상태일 때는 로그인이 풀리지 않아, 안정적으로 대기열 관리 페이지를 유지할 수 있지만 아예 사용되지 않은 채 방지될 때는 보안 상의 문제를 고려해 로그인 상태를 ㅎ제하고, 세션을 지운 뒤 다시 로그인하라고 유도했다.

위와 같은 작업들 외에도 이런 저런 작업들을 진행했지만, 가장 큰 문제점이나 기억에 남던 요소들은 이런 사항들이었던 것 같다.

여기까지의 작업사항들까지 포함하여 1차 QA에서 발견한 문제점이나 보완점들을 다 해결했고 2차 QA를 진행했다.

2차 QA는 1차보다 좀 더 세부적으로 들어가자는 접근 + UI까지 고려해서 보자는 생각으로 진행했었는데 1차보다 다양한 문제점들을 발견하게 됐고 아직도 미숙한 점이 많구나 실감도 되고..

그러나, 런칭 전에 이런 문제점들을 파악하고 깨달았으니 다행이다 싶었다. 이런 문제점이 만약 런칭 후에 발생했다면…. 진짜 상상만 해도 무섭다..(으악)

여기까지가 이번주에 진행했던 작업들이다. 프로젝트를 구현하기 급급하느라 놓쳤던 문제점들을 깨닫고 자기 반성을 하기도 했지만, 덕분에 앞으로 프로젝트를 진행할 때에 어떤 점들을 신경써야할지 깨닫기도 한 좋은 기회였다.

2차 QA의 결과도 문제점이나 보완점이 많긴 하지만, 그래도 이렇게 발견하고 고쳐나가는 경험이 분명 향후의 밑거름이 되어줄 것이라는 걸 믿고 있다.

성공적(?)으로 마친 3월 2차 모여봐요 너굴의 숲

3월 2차 너굴의 숲은 3월의 마지막인 3월 31일에 진행했다. 이번엔 한 분을 제외하고 7명이서 진행할 뻔 했으나, 3월 1차 때 불참 의사를 밝히시던 분이 2차 때 참가 희망을 밝혀주셨고 그 외에도 개인적으로 모셔올까 싶었던 분이 있어서 총 9명으로 모임이 진행되었다.

많은 분들이 와주시고 최대한 자유롭게 떠드는 분위기를 마련하려고 했으나, 이게 참 쉽지가 않다. 결국은 진행병이 도져서 혼자 또 지자랑 대회가 되어버려서 좀 그렇긴 했는데 그래도 모임의 막바지에는 다들 오기 잘했다고 말씀도 해주시고, 모임 전에 비하면 편한 분위기가 형성되어서 마음의 짐을 덜었던 것 같다.

기회가 된다면 계속 이런 모임을 주도하고 싶은데, 그때는 내가 화제를 주도하기 보다는 다른 분들이 화제를 끌고 와서 자유롭게 이야기를 나눌 수 있는 화기애애한 장을 만들어보고 싶다.

이렇게 3월 너굴의 숲은 마감이 됐다. 4월에도 진행이 될지는 모르겠지만, 그 때도 많은 분들이 와주셔서 즐거운 모임이 되기를.. 개인적으로 바라고 있다.

다음주에 할 일들

프로젝트 끝바지가 보이는 가운데에 일단은 2차 QA까지 진행은 완료됐다. 이제 QA 내용을 토대로 또 문제 사항을 수정하면 최종적으로 목표로 하던 Vercel을 통해 내용을 배포하고, 매장에 실제로 배치해봄으로서 런칭이라기 보다는, 프리 릴리즈를 해볼 수 있을 것 같다.

그런 의미에서 다음 주의 목표는 다음과 같다.

  • 2차 QA 테스트 진행 후 발생한 이슈 수정
  • Vercel 등을 통해 배포 진행 후 프리 릴리즈, 테스트 진행
  • 솔루션 릴리즈

11주차, 물론 집중도 안 될 때도 있었고, 가끔은 때려치우고 싶을 때도 있었지만 그래도 정말 잘 버티고 열심히 한 덕분에 여기까지 올 수 있었다고 생각한다.

라스트 스퍼트는 아직 남아있다. 이렇게 기운이 있을 때, 최선을 다해 프로젝트가 좋은 결과를 얻을 수 있도록 분주히 노력하자.

참고 자료

JWT에서 Refresh Token은 왜 필요한가?

Auth | JavaScript SDK  |  Firebase JavaScript API reference

Firebase에서 사용자 관리하기

useQuery | TanStack Query Docs