[FIFAPulse] 개발기록 - 모달창을 이용한 로그인 페이지 구현

조민호·2023년 4월 26일
0
post-custom-banner

로그인 페이지 구현하기

이 웹은 구글 로그인을 사용하며 , 최초 로그인 시 실제 피파온라인에서 사용하고 있는
닉네임을 입력받아서 로그인한 구글 계정과 연동해서 이용하는 방식이다

그리고 이후에 같은 구글ID로 로그인을 하면 기존에 연동했던 피파온라인 계정으로 즉시 이용할 수 있는 것이다


그래서 로그인 페이지에서 실제로 진행해야 하는 작업들을 순서대로 나열하면 아래와 같다


  1. 게스트 모드 or 로그인을 진행할지 선택한다

  2. 로그인을 진행하게 되면 그 즉시 DB에 접근해서 기존 로그인 정보가 있는지 확인을 한다

    • 기존 로그인 정보가 없을 경우

      1. 모든 기존 동작을 멈추고 모달창을 띄워서 피파온라인에서 사용하고 있는

        닉네임을 입력하게끔 한다

      2. 입력 받은 닉네임을 바탕으로 피파온라인 api를 호출한다

        입력한 닉네임이 피파온라인에 존재하지 않거나 , 중복된 계정에 대한 유효성 검사를 진행한다

      3. 호출해서 받은 정보들을 조합해서 아래의 정보를 DB에 저장한다

        • 피파온라인닉네임
        • 넥슨계정accessID
        • 게임레벨
        • 이 웹에서 로그인한 구글uid
      4. 위의 과정들이 마치게 되면 자동으로 모달창은 닫힌다

        모달 창이 띄워져 있는 경우, 모든 기존 동작들은 정지되어 아무것도 진행할 수 없어야 한다

    • 기존 로그인 정보가 있을 경우

      1. 실제 피파온라인 계정의 업데이트 사항이 존재할 수 있으므로 (ex 레벨)

        여기서도 피파온라인 api를 사용해서 피파온라인 로그인 정보를 가져온다

      2. api로 받은 정보들을 바탕으로 DB에 있는 기존 정보들을 업데이트 한다

      3. 다시 기존 페이지로 돌아와서 기존의 로그인 화면에다가

        연동된 피파온라인 닉네임을 넣은 상태로 변경해서 보여준다

        로그인 전
        게스트 모드 , 로그인(Google) 하기
        
        로그인 후
        게스트 모드 or (00구단주)님 안녕하세요!



이러한 과정을 진행해야 하는데 상상 이상으로 엄청나게 까다로웠다

수많은 비동기처들을 내가 원하는 대로 순서대로 진행하고

동시에 전역 context로 사용하고 있는 모달창까지 useEffect로 동시에 관리를 해야 했다

그래서 거의 하루 종일 이것만 붙잡고 수많은 오류사항과 에러 , 그리고 비동기의 처리 과정에 대해
(특히 async/await) 다시 제대로 공부하게 되는 계기가 되었던 것 같다


결국 구현을 완료했다

아래는 완성된 코드이고 주석을 통해 설명을 적어놓았다

import React, { useEffect, useState } from 'react';
import { onAuthStateChanged, GoogleAuthProvider, signInWithPopup } from 'firebase/auth';
import { collection, addDoc, getDocs, updateDoc, doc } from 'firebase/firestore';
import { useNavigate } from 'react-router-dom';
import { authService, dbService } from '../../../firebase';
import AskNickNameModal from '../../Components/AskNickNameModal';
import { useLoginAPI } from '../../Context/Firebase/LoginContext';
import { useModalAPI } from '../../Context/Modal/ModalContext';
import { useUserObjAPI } from '../../Context/UserObj/UserObjContext';
import FIFAData from '../../Services/FifaData';

const ChooseModeAndLogin = () => {
  const [init, setInit] = useState(false);

  // 모달창에서 입력한 닉네임과 연동된 계정 정보가, DB에 존재하는지에 대한 플래그
  // 새로고침 됐을 때 기본적으로 true값이어야 모달창이 안 뜸
  // (최초 상태로는 안 뜨는게 맞는것이다)
  const [isNickNameExist, setIsNickNameExist] = useState(true);
  const { isLoggedIn, setIsLoggedIn } = useLoginAPI()!; // context로 관리하는 로그인이 되어 있는지 알려주는 상태
  const { isModalOpen, openModal } = useModalAPI()!; // context로 관리하는 현재 모달이 열렸는지 알려주는 상태
  const { userObj, setUserObj } = useUserObjAPI()!; // context로 관리하는 현재 로그인 중인 유저의 정보

  console.log(authService.currentUser);
  const navigate = useNavigate();

  useEffect(() => {
    onAuthStateChanged(authService, (user) => {
      if (user) {
        // 로그인 됐을 때

        console.log('logged in');
        setIsLoggedIn(true);

        // DB에 입력한 닉네임으로 저장된 정보가 있는지 확인

        let existOnDB = false;
        let documentIDForUpdate: string;
        let existUserDBInfo: any;

        const getDataAndUpdateInfo = async () => {
          const dbInfo = await getDocs(collection(dbService, 'userInfo'));
          dbInfo.forEach((i) => {
            if (i.data().googleUID === user.uid) {
              existOnDB = true; // 존재한다면 true
              documentIDForUpdate = i.id; // 해당 firestore의 documentId를 가져옴
              existUserDBInfo = i.data();
            }
          });

          if (existOnDB && authService.currentUser) {
            // 이미 존재하더라도 , 레벨 정보 같은게 바뀔 수도 있으므로 업데이트
            const fifa = new FIFAData();
            const result = await fifa.getUserId(existUserDBInfo.nickname);

            const updateResult = doc(dbService, 'userInfo', `${documentIDForUpdate}`);
            await updateDoc(updateResult, {
              // googleUID와 nickname은 굳이 업데이트 x
              FIFAOnlineAccessId: result.accessId,
              level: result.level,
            });

            // 유저 객체 업데이트
            setUserObj({
              googleUID: user.uid,
              FIFAOnlineAccessId: result.accessId,
              level: result.level as unknown as number,
              nickname: result.nickname,
            });

            // 기존에 존재했으므로 true
            setIsNickNameExist(true);
          }
          if (!existOnDB && authService.currentUser) {
            // 없다면 모달창 띄워서 닉네임 입력받아야 함
            setIsNickNameExist(false);
          }
        };

        getDataAndUpdateInfo();
      }
      if (!user) {
        // 로그아웃 됐을 때

        console.log('logged out');
        setIsNickNameExist(true); // 모달 창에서 뒤로가기 선택하고 재 로그인시
        // 모달창 띄우는 useEffect를 실행하기 위해서 의존성 배열을 변경해야 하므로
        // 의도적으로 isNickNameExist를 초기값으로 세팅
        // (로그아웃 됐을 때true -> 재로그인시 false로 변경돼서 useEffect 실행)

        setIsLoggedIn(false);
        setUserObj(null);
      }
      setInit(true);
    });
  }, []);

  // isNickNameExist의 여부에 따라 모달창을 띄움
  useEffect(() => {
    isNickNameExist ? '' : openModal(<AskNickNameModal />);
  }, [isNickNameExist]);

  // 로그인하기 버튼 시 작동하는 이벤트
  const onSocialClick = async (e: React.MouseEvent<HTMLButtonElement>) => {
    const { name, value } = e.currentTarget;

    let provider;

    if (name === 'google') {
      provider = new GoogleAuthProvider();
    }

    const data = await signInWithPopup(authService, provider as GoogleAuthProvider);
  };

  return (
    <div>
      <h1>모드를 선택하세요</h1>
      {init ? ( // 화면이 띄워지고 로그인 정보가 불러지기 전 후에 대한 조건부 렌더링
        isLoggedIn ? ( // 로그인이 됐을때의 조건부 렌더링
          isModalOpen ? ( // 로그인이 되고 만약 닉네임을 입력받아야 해서 모달창이 띄워진 것에 대한 조건부 렌더링
            <>닉네임을 입력 할 때까지 기다리는 중...</>
          ) : (
            <>
              <button type="button" onClick={() => navigate('/guest')}>
                게스트 모드
              </button>
              <button type="button" onClick={() => navigate('/main-select')}>
                {userObj?.nickname} 님 안녕하세요!
              </button>
            </>
          )
        ) : (
          <>
            <button type="button" onClick={() => navigate('/guest')}>
              게스트 모드
            </button>

            {/* 추후 로그인 경로가 다양해지면 로그인 하기 버튼 전체를 컴포넌트로 분리 <LogIn /> */}
            <button type="button" name="google" onClick={onSocialClick}>
              로그인 하기(Google)
            </button>
          </>
        )
      ) : (
        'Loading...'
      )}
    </div>
  );
};

export default ChooseModeAndLogin;





사실 코드 자체는 워낙 사람마다 작성하는 방식이 다르므로 , 코드 자체보단 이번 페이지를 구현하면서 내가 가장 골머리를 앓았었던 문제 3개에 대해서 언급하려고 한다


비동기 , 렌더링 패턴

아래의 코드는 실제 내가 구현한 코드 일부이다 (필요없는 주석 및 import문 제거)

앞서 말했듯이 여러개의 비동기 처리 , 리렌더링 순서 , useEffect의 가 돌아가는 순서와 구조를

파악해서 코드를 짜야 했다

여기서 로그인 하기(Google)버튼을 클릭해서 onSocialClick가 실행되는 순간

여러개의 비동기와 상태 변경으로 인한 리렌더링 , useEffect가 어떠한 순서로

진행되는지 주석으로 기록해 보았다

import ...

const ChooseModeAndLogin = () => {
  const [init, setInit] = useState(false);
  const [isNickNameExist, setIsNickNameExist] = useState(true);
  const { isLoggedIn, setIsLoggedIn } = useLoginAPI()!;
  const { isModalOpen, openModal } = useModalAPI()!;
  const { userObj, setUserObj } = useUserObjAPI()!;

  console.log(authService.currentUser);
  const navigate = useNavigate();

  useEffect(() => {
		**// (0) >> 버튼 클릭 전에 최초 화면 렌더링이 될 때** 

    onAuthStateChanged(authService, (user) => {
		**// (2)** 

      if (user) {
				**// (3)**
        console.log('logged in');
        setIsLoggedIn(true);
        let existOnDB: boolean = false;
        let documentIDForUpdate: string;
        let existUserDBInfo: any;

        const getDataAndUpdateInfo = async () => {
					**// (5)
					// getDataAndUpdateInfo()는 비동기 함수이므로 여기까지 진행하고
					// 나머지 기존 로직을 먼저 진행한다**
          const dbInfo = await getDocs(collection(dbService, 'userInfo'));

					**// 모든 기존 로직이 끝났으므로 마저 남은 비동기처리 진행**
					**// (9)** 
          dbInfo.forEach((i) => {
            if (i.data().googleUID == user.uid) {
              existOnDB = true;
              documentIDForUpdate = i.id;
              existUserDBInfo = i.data();
            }
          });

		      ...

        };

				**// (4) 함수 실행**
        getDataAndUpdateInfo();

      }
      if (!user) {
        console.log('logged out');
				setIsNickNameExist(true);
        setIsLoggedIn(false);
        setUserObj(null);
      }
			**// (6) 5번까지 진행하고 실행되는 나머지 로직** 
      setInit(true);
    });
  }, []);

  useEffect(() => {
		**// (0) >> 버튼 클릭 전, 최초 화면 렌더링이 될 때** 
		**// (8) >> 실제 코드는 의존성 배열에 isNickNameExist상태가 존재하면 실행이 안 되지만
		// 전체 리렌더링 구조를 알아야 하니까 의존성 배열이 없다는 가정하에 진행**

		
		**// 6번에서 상태를 업데이트 했으므로 리렌더링이 우선적으로 된다
	  // 그리고 나머지 비동기 작업을 진행하게 된다**
    isNickNameExist ? '' : openModal(<AskNickNameModal />);
  }, //[isNickNameExist]
	);

  const onSocialClick = async (e: React.MouseEvent<HTMLButtonElement>) => {
    const { name, value } = e.currentTarget;

    let provider;

    if (name === 'google') {
      provider = new GoogleAuthProvider();
    }
			
		**// (1) 
		// signInWithPopup이 되는 순간, onAuthStateChanged가 실행된다**
    const data = await signInWithPopup(authService, provider as GoogleAuthProvider);
	  **// 나머지 부분은 await이므로 여기서 기다려야 한다**

		**// (7)** 
		**// await 이후의 로직들이 진행됨 비동기 처리 로직이 됨**
  };

  return (
    <div>
      <h1>모드를 선택하세요</h1>
      {init ? ( 
        isLoggedIn ? (
          isModalOpen ? (
            <>닉네임을 입력 할 때까지 기다리는 중...</>
          ) : (
            <>
              <button onClick={() => navigate('/guest')}>게스트 모드</button>
              <button onClick={() => navigate('/main-select')}>{userObj?.nickname} 님 안녕하세요!</button>
            </>
          )
        ) : (
          <>
            <button onClick={() => navigate('/guest')}>게스트 모드</button>
            <button name="google" onClick={onSocialClick}>
              로그인 하기(Google)
            </button>
          </>
        )
      ) : (
        'Loading...'
      )}
    </div>
  );
};

export default ChooseModeAndLogin;

각 실행 순서에 대한 설명들은 간략히 주석으로 적어 놓았다

그리고 추가로 , 알아야 할 것들이 있는데


  1. 사실 , onClick으로 등록한 onSocialClick() 또한 비동기 함수이다

    그러므로 이게 진행될 동안 다른 기존 로직이 진행되어야 하는게 맞다

    그렇지만 기존에 실행되고 있던게 이벤트로 트리거된 onSocialClick()

    뿐이라 나머지를 진행할 게 없는 것이다.

    그래서 바로 await 의 비동기 로직을 곧바로 수행하게 된다

    • (1)에서 (2)로 넘어갈 때 onSocialClick() 외부의 기존에 실행중인 다른 로직이 먼저 진행돼야 하는데 그게 없으므로 바로 (2) 부분이 실행되는 것이다
    • 같은 예시로 , (5)번까지 진행이 되고 getDataAndUpdateInfo() 외부의 기존에 실행중인 로직이 있으므로 (6)번이 먼저 진행되는 것을 볼 수 있다

    이것 때문에 맨 처음에 리액트를 접하게 될 때, 비동기 함수는 그 함수가 끝날때까지 나머지 모든 로직들이 다 멈추고 기다리는 것이구나 라고 착각했다


  1. (1)에서 진행된 await signInWithPopup 로직은 (위에서 설명한 것처럼) 바로 실행 돼서

    (6)까지 진행하고 (7)로 돌아온다

    그러면 이제 더이상 진행될 나머지 기존 로직이 없으므로 비동기 로직인

    (9)번이 진행돼야 하는데 이 때 (6)에서 상태 업데이트를 진행했었다

    이럴 땐 리렌더링이 먼저 되고나서( 8번이 실행 되고 ) (9)가 진행되게 된다

    즉 , 나머지 테스크 큐에 있는 비동기 로직보다 리렌더링이 우선 되는 것이다




로그아웃 시 로그인 정보가 남아있는 버그


로그인이 되는 순간 DB조회 , 이에 따른 모달 호출 및 API호출 같은 로직들이 진행 된다

그러다 보니, 비동기 처리 진행 순서가 꼬여서

로그아웃을 해도 context로 사용하고 있는 로그인 유저 정보 객체에 값이 남아있는 남아있는 심각한 에러가 발생했다


firebase에서 제공하는 onAuthStateChanged는 로그인 정보가 바뀔 때마다 실행되며

useEffect로 onAuthStateChanged에 로그인 정보가 바뀔 때마다 진행되는 로직을

콜백으로 지정해 준다

그러므로 로그아웃이 됐을 때

  • 사전에 로그인이 된 상태로 onAuthStateChanged를 한 번 진행하고 (이게 원인이 됨)
  • 그 다음 비로소 로그아웃이 된 상태로 onAuthStateChanged가 진행된다

즉 , 로그아웃을 했는데도 (로그인이 된 상태로 한번 진행되기 때문에) 로그인이 된 상태에서만
진행하는 유저 정보context를 수정하는 로직이 비동기로 남아 있다가 마지막에 진행이 되기 때문에 발생하는 문제였다


그러므로 조건문에 추가적인 플래그(authService.currentUser)를 넣어줘서 로그아웃이 됐을때는 해당 로직을 막아준다

const getDataAndUpdateInfo = async () => {
          const dbInfo = await getDocs(collection(dbService, 'userInfo'));
          dbInfo.forEach((i) => {
            if (i.data().googleUID == user.uid) {
              flag = true; // 존재한다면 true
              documentIDForUpdate = i.id;
              existUserDBInfo = i.data();
            }
          });

          if (flag && authService.currentUser) { **// authService.currentUser사용 (로그아웃되면 falsy이므로 진행이 안 됨)**
            const fifa = new FIFAData();
            const result = await fifa.getUserId(existUserDBInfo.nickname);

            const updateResult = doc(dbService, 'userInfo', `${documentIDForUpdate}`);
            await updateDoc(updateResult, {
              accessId: result.accessId,
              level: result.level,
            });

            setUserObj({
              googleUID: user.uid,
              accessId: result.accessId,
              level: result.level as unknown as number,
              nickname: result.nickname,
            });
            setIsNickNameExist(true);
          }
          if (!flag && authService.currentUser) { **// authService.currentUser사용 (로그아웃되면 falsy이므로 진행이 안 됨)**
            setUserObj({
              googleUID: user.uid,
              accessId: '',
              level: 0,
              nickname: '',
            });
            setIsNickNameExist(false);
          }
        };

authService.currentUser는 firebase에서 현재 로그인 된 유저 정보를 보여준다

이걸 && 연산자로 추가해주면 로그아웃이 됐을 때 falsy가 되므로.

사전으로 진행되는 로그인 상태인onAuthStateChanged 로직을 막을 수 있는 것이다

사실 authService.currentUser는 onAuthStateChanged 의 콜백 인자로 사용되는 user 변수와 동일하다
그렇지만 , user는 onAuthStateChanged 안에서 작동하는 것이므로로그아웃이 됐을 때 , 사전으로 진행되는 로그인 된 상태인 onAuthStateChanged 에서는 동일하게 로그인이 된 상태가 되므로 위의 로직에서는 효력이 없게 된다
그러므로 외부에서 독자적으로 정보를 알려주는 authService.currentUser를 사용한다




모달창 이용시 나머지 로직 정지

당연히 닉네임을 입력받는 모달창이 띄워지게 되면 기존 페이지의 기능들은 전부

막혀야 한다

그 말은 , 모달창이 띄워졌을 때 기존 페이지의 모든 동작을 멈춰야 하는 것이다


이걸 어떻게 구현해야 할지 고민을 하다가

사실 모달창마저도 비동기로 따로 관리를 해야 하나 싶긴했지만

생각보다 엄청 간단한 방법이 있었다


JSX부분에서 삼항 연산자를 통해 조건부 렌더링을 해주면 된다

즉 , 모달창이 띄워져 있는지에 대한 상태값은 이미 전역 context로 사용하고 있으므로

이걸 바탕으로 모달창이 띄워져 있다면 기존 페이지를

아무 기능이 없는 페이지로 바꾸는 것이다

return (
    <div>
      <h1>모드를 선택하세요</h1>
      {init ? ( // 화면이 띄워지고 로그인 정보가 불러지기 전 후에 대한 조건부 렌더링
        isLoggedIn ? ( // 로그인이 됐을때의 조건부 렌더링
          isModalOpen ? ( // 로그인이 되고 만약 닉네임을 입력받아야 해서 모달창이 띄워진 것에 대한 조건부 렌더링
            <>닉네임을 입력 할 때까지 기다리는 중...</>
          ) : (
            <>
              <button type="button" onClick={() => navigate('/guest')}>
                게스트 모드
              </button>
              <button type="button" onClick={() => navigate('/main-select')}>
                {userObj?.nickname} 님 안녕하세요!
              </button>
            </>
          )
        ) : (
          <>
            <button type="button" onClick={() => navigate('/guest')}>
              게스트 모드
            </button>
            <button type="button" name="google" onClick={onSocialClick}>
              로그인 하기(Google)
            </button>
          </>
        )
      ) : (
        'Loading...'
      )}
    </div>
  );
post-custom-banner

0개의 댓글