티스토리 뷰

사이드 프로젝트/🩺 바디토리 : 건강기록 및 병원추천 웹서비스

개발환경에서 소셜로그인 과정 오류 수정하기

2023. 4. 24. 16:56

📖 목차

-  문제점

-  관련 코드 살펴보기

-  의심되는 부분

-  하나씩 해결해나가보자 (오류는 해결, 원인은..?)

-  Next.js에서 getServerSideProps 란?

-  우리는 SSR을 사용하고 있던게 아니었나?

-  마무리

 

 

 

문제점

소셜 회원가입 시 바디토리 자체 아이디/비밀번호 입력 단계는 생략되고 바로 이름, 생년월일 등의 정보를 입력하는 단계로 넘어가야 정삭적인 동작이다.

 

그런데 이게 배포환경에서는 정상적으로 동작하는데 개발환경에서는 아이디/비밀번호 단계가 생략되지않고 진행되고 그대로 가입을 완료하고 DB를 확인해보면 User의 type이 naver나 kakao가 아니라 origin(일반회원가입 유형)으로 저장된다.

네이버로 가입했으나 orgin으로 가입했다고 저장됨

 

 

소셜로그인 정보 제공 동의 후 콜백URL인 /auth/login/loading에서 500 에러 메세지가 찍힌다.

(배포 환경에서는 에러 메세지 안찍히고 동작도 정상)

 

네이버 로그인 후 /auth/login/loading 에서 찍히는 에러 메세지

 

카카오 로그인도 마찬가지

카카오 로그인 후 /auth/login/loading 에서 찍히는 에러 메세지

 

 

네이버 로그인 페이지에서 값이 제대로 넘어오지 않는 건가 해서 콘솔에 찍어봤는데 제공받는 정보값은 잘 들어오고 type도 지정한대로 naver로 들어오는 것을 볼 수 있다.

 

 

 

 

 

 

관련 코드 살펴보기

일단 소셜 회원가입이 진행되는 과정을 따라가며 코드를 살펴보자.

 

아래는 네이버 회원가입 버튼 컴포넌트(components/layout/buttons/NaverBtn.tsx)의 코드이다.

이 컴포넌트는 로그인 페이지에서는 "네이버로 로그인" 버튼으로, 회원가입 페이지에서는 "네이버로 회원가입"버튼으로 사용된다.

// components/layout/buttons/NaverBtn.tsx

const NaverLoginBtn = ({ mutate, kind }: SocialBtnProps) => {
  const naverRef = useRef<any>(null);
  const router = useRouter();
  const [comment, _] = useState(kind === "login" ? "로그인" : "바로시작");
  const handleNaverLogin = () => {
    naverRef?.current!.children[0].click();
  };

  const loadedNaverSdk = useCallback(() => {
    const naver = (window as any).naver;
    let naverLogin: any;
    const login = () => {
      naverLogin = new naver.LoginWithNaverId({
        clientId: process.env.NEXT_PUBLIC_NAVER_CLIENT_ID, // ClientID
        callbackUrl: "http://localhost:3000/auth/login/loading", // Callback URL
        // ...
      });
      naverLogin.init();
    };
    const getToken = () => {
      const hash = router.asPath.split("#")[1];
      if (hash) {
        const token = hash.split("=")[1].split("&")[0]; 
        localStorage.setItem("naverToken", token);
        naverLogin.getLoginStatus((status: boolean) => {
          if (status) {
            const { email, mobile, name, birthyear, gender, id: accountId } = naverLogin.user;
            mutate({
              accountId,
              type: "naver",
              email,
              phone: mobile,
              name,
              birth: birthyear,
              gender: gender === "M" ? "male" : "female",
            });
          }
        });
      }
    };
    login();
    getToken();
  }, [mutate, router.asPath]);

  // ...
  
  return (
    <div>
      <SocialButton onClick={handleNaverLogin} social="naver" sm={kind === "login"}>
        네이버로 {comment}
      </SocialButton>
      <button ref={naverRef} id="naverIdLogin" style={{ display: "none" }} />
    </div>
  );
};

 

회원가입 유형 선택 페이지(/auth/register/choice)에서 네이버 회원가입 버튼을 누르면 아래와 같이 네이버 로그인 페이지로 이동 후 동의를 완료하면 콜백URL로 설정한 페이지(/auth/login/loading)로 이동한다.

 

 

/auth/login/loading 페이지에서는 네이버 로그인 mutation을 진행하는데 /api/auth/login로 post 요청을 해서 success 시 반환값 data에 isNew라는 속성이 있으면 회원가입 페이지(/auth/register)로 이동하는데 이 때 data값을 쿼리에 실어서 함께 보내준다. 

 

이 isNew라는 속성은 네이버로 로그인을 하는 것인지, 회원가입을 하는 것인지를 판단하기 위한 값이다.

/api/auth/login API 요청을 살펴보면 loginBySocial()에서 전달받은 유저 정보 중 type과 email로 DB를 조회해보고 바디토리 회원이 아니라면 isNew: true를 유저 정보에 추가하여 응답을 보낸다. 반면, DB를 조회했는데 이미 회원이라면 로그인이 진행된다.

// pages/auth/login/loading.tsx

const Loading: NextPage = () => {
  const queryClient = useQueryClient();
  const router = useRouter();
  const { postApi } = customApi("/api/auth/login");
  const { mutate } = useMutation([USER_LOGIN], postApi, {
    onError(error: any) {},
    onSuccess(data) {
      console.log(data);
      if (data.isNew) {
        return router.push(
          {
            pathname: "/auth/register",
            query: data,
          },
          "/auth/register",
        );
      } else {
        queryClient.refetchQueries([USE_USER]);
        return router.push("/");
      }
    },
  });
  return (
    // ...
      <ButtonBox>
        <NaverLoginBtn mutate={mutate} kind={"login"} />
      </ButtonBox>
    </AnalysisWrapper>
  );
};
// pages/api/auth/login.ts

const handler = async (req: NextApiRequest, res: NextApiResponse) => {
// ...
async function loginBySocial(req: NextApiRequest, res: NextApiResponse) {
  const { accountId, email, phone, name, birth, gender, type } = req.body;
  const foundUser = await client.user.findFirst({
    where: {
      accountId: String(accountId),
      type,
    },
  });

  if (!foundUser) {
    return res.status(201).json({ isNew: true, type, accountId, email, phone, name, birth, gender });
  }

  req.session.user = {
    id: foundUser.id,
  };
  await req.session.save();
  return res.status(201).json({ type, id: foundUser.id, accountId, email, phone, name, birth, gender });
}

export default withApiSession(withHandler({ methods: ["POST"], handler, isPrivate: false }));

 

 

위와 같이 가입되지 않은 사용자는 회원가입 페이지(/auth/register)로 이동할 때 네이버에서 제공받은 정보(이메일, 이름 등)와 type: naver, isNew:true값을 쿼리에 가지고 넘어가는데 이 값을 회원가입 페이지에서 받아서 사용자가 바디토리 가입 시 작성해야하는 정보 중 네이버에서 받은 값이 있으면 미리 정보를 채워주고, 소셜 로그인의 경우 일반 로그인과 다르게 아이디, 비밀번호를 작성하는 단계를 스킵하게 해준다.

// pages/auth/register/index.tsx

const RegisterPage: NextPage = () => {
  const router = useRouter();
  const [user, setUser] = useState<RegisterForm | undefined>();
  const [page, setPage] = useState(1);
  useEffect(() => {
    if (router.query.isNew) {
      const { accountId, email, phone, name, birth, gender, type } = router.query;
      const fakePassword = Math.floor(10000 + Math.random() * 1000000) + "";
      setUser(prev => ({
        ...prev!,
        accountId: accountId as string,
        password: fakePassword,
        passwordConfirm: fakePassword,
        email: email as string,
        phone: phone as string,
        name: name as string,
        birth: birth as string,
        gender: gender as Gender,
        type: type as UserType,
      }));
      setPage(1);
    } else {
      setUser(prev => ({ ...prev!, type: "origin" }));
    }
  }, []);

  return (
    <>
      {page === 1 && <FirstPage user={user} setUser={setUser} setPage={setPage} />}
      {page === 2 && <SecondPage user={user} setUser={setUser} setPage={setPage} />}
      {page === 3 && <ThirdPage user={user} setUser={setUser} setPage={setPage} />}
      <ToryMotion
        initial={{ opacity: 0 }}
        animate={{ opacity: 1, transition: { duration: 0.6, ease: "easeOut", delay: 0.2 } }}
      >
      </ToryMotion>
    </>
  );
};

 

 

그런데 지금 /auth/login/loading 페이지에서 네이버 로그인 정보를 받아 api요청을 보내고 DB를 조회하여 isNew: true를 추가하여 응답해주는 것까지는 잘 진행되는데 그 다음 에러가 발생하는 것으로 보인다. 근데 또 배포환경에서는 잘 작동하고..

 

 

 

 

 

 

 

의심되는 부분

일단 의심되는 부분을 뽑아보자면

 

1️⃣  에러 메세지의 GET url을 보니 _next/data/development라고 있는 걸 보면 개발환경에서 next가 뭔가 다른 처리가 있는 것 같다. 그리고 구글링해보니 "_next/data/development/....json"은 getServerSideProps와 관련되어있는 것 같은데 아직 Next를 제대로 안다뤄봐서 getServerSideProps가 어떤 역할을 하고 왜 필요한건지부터 알아봐야겠다.

https://github.com/vercel/next.js/discussions/15787

 

 

2️⃣  회원가입 페이지로 넘겨줄 때 router.push를 사용하여 query를 붙여서 넘겨주는데 회원가입 페이지(/auth/register)에서 브라우저의 주소창을 보면 query값은 안보이게 숨김 처리해준다. 

// pages/auth/login/loading.tsx

if (data.isNew) {
    return router.push(
      {
        pathname: "/auth/register",
        query: data,
      },
      "/auth/register",
    );
    } else {
    queryClient.refetchQueries([USE_USER]);
    return router.push("/");
}

 

router.push에서 두번째 인자인 "/auth/register"를 주석처리하고 실행하면, 회원가입 페이지(/auth/register)로 넘어갔을 때 주소창에 쿼리가 보이고 아래와 같은 에러가 발생한다.

 

Error: Error serializing `.seoData` returned from `getServerSideProps` in "/auth/register".

Reason: `undefined` cannot be serialized as JSON. Please use `null` or omit this value.

 

 

 

 

 

하나씩 해결해나가보자

 

getServerSideProps에 대해 알아보기 전에 우선 회원가입 페이지(/auth/register)에서 네이버를 통해 넘어온 정보를 type(회원가입 유형)에 따라 다르게 state에 저장해주는데 제대로 저장이 되는지 콘솔에 찍어보았더니

// pages/auth/register/index.tsx

const [user, setUser] = useState<RegisterForm | undefined>();
const [page, setPage] = useState(1);
useEffect(() => {
  if (router.query.isNew) {
    const { accountId, email, phone, name, birth, gender, type } = router.query;
    const fakePassword = Math.floor(10000 + Math.random() * 1000000) + "";
    setUser(prev => ({
      ...prev!,
      accountId: accountId as string,
      password: fakePassword,
      passwordConfirm: fakePassword,
      email: email as string,
      phone: phone as string,
      name: name as string,
      birth: birth as string,
      gender: gender as Gender,
      type: type as UserType,
    }));
    setPage(1);
  } else {
    setUser(prev => ({ ...prev!, type: "origin" }));
  }
}, []);

// user 정보 확인
useEffect(() => {
  console.log(user);
}, [user]);

 

if(router.query.isNew) 조건에 걸려야하는데 걸리지 않는다.

 

router.query값를 콘솔을 찍어봐도 undefined가 찍힌다.

 

그래서 우선 query값을 주소창에 가시적으로 보이게 해보려고 2️⃣에서 처럼 router.push에서 두번째 인자인 "/auth/register"를 주석처리하고 실행하였다.  그러면서 발생하는 에러 메세지를 살펴보면

 

Error: Error serializing `.seoData` returned from `getServerSideProps` in "/auth/register".

Reason: `undefined` cannot be serialized as JSON. Please use `null` or omit this value.

 

회원가입 페이지(/auth/register)의 getServerSideProprs로부터 리턴되는 .seoData가 undefined라서 JSON으로 직렬화할 수 없다고 한다.

 

.seoData가 무엇인지 몰라서 처음에는 네이버 로그인에서 넘어오는 건가 했는데 vscode내에서 전체 검색으로 찾아보니 프로젝트 내에서 getServerSideProps를 사용해 seo를 위한 meta태그를 설정해주고 있었다.

 

 

역시나 getServerSideProps와 관련되어 있어서 회원가입 페이지 컴포넌트(/auth/register/index.tsx)에서 getServerSideProps부분을 주석처리하고 실행해보았더니 소셜로그인을 통한 회원가입 과정이 정상적으로 작동했다..!

 

 

getServerSideProps가 개발환경과 빌드환경에서 다르게 작동하는건가? Next.js에서 getServerSideProps가 무엇인지 알아보자.

 

 

 

 

 

Next.js에서 getServerSideProps 란?

 

Next.js는 더 좋은 퍼포먼스와 검색엔진최적화(SEO)를 위해 모든 페이지를 사전 렌더링(pre-rendering)한다.

pre-rendering은 html 파일을 미리 만들어 놓는 것이다.

 

pre-rendering의 형태는 정적 생성(SSG)와 서버 사이드 렌더링(SSR)이 있다.

둘의 차이점은 html 파일을 언제 생성하는지에 차이가 있다.

 

 

정적 생성 (SSG)

-  프로젝트가 빌드하는 시점에 html 파일들을 생성한다.

-  모든 요청에 미리 만들어 놓은 html파일들을 재사용한다.

-  정적 생성된 페이지들은 CDN에 캐시된다.

-  퍼포먼스 이유로 Next.js는 정적 생성을 권고한다.

-  getStaticProps / getStaticPaths를 통해 정적 생성을 사용할 수 있다.

 

 

서버 사이드 렌더링 (SSR)

-  매 요청마다 html 파일들 생성한다.

-  항상 최신 상태를 유지할 수 있다.

-  getServerSideProps를 통해 SSR을 사용할 수 있다.

 

 

유저가 요청하기 전에 미리 페이지를 만들어 놓아도 상관없는 블로그 게시물, 도움말 문서, 제품 리스트 페이지 등에는 정적 생성을 사용하는 것이 좋다.

 

반면, 요청이 있을 때마다 컨텐츠가 다시 그려져야 한다면 SSR을 사용하는 것이 좋다. CDN에 캐시되지 않아 정적 생성보다는 느릴 수 있으나 서버를 통해 pre-rendering된 페이지는 항상 최신 상태를 유지할 수 있다.

 

 

 

 

 

우리는 SSR을 사용하고 있던게 아니었나?

 

getServerSideProps에 대해 알아보기 위해 코딩앙마 Next.js 강좌를 보다가 Next.js에서 SSR을 사용하는 방법이 getServerSideProps를 사용하는 것이라는 걸 듣고 그럼 우리는 프로젝트에서 SSR을 meta태그 작성에 대해서만 사용한 것인가? 라는 의문이 들었다.

 

프로젝트를 진행할 당시에는 시간적인 문제로 Next.js에 대해 이해하기 보다 기초적인 사용법만 익혀서 프로젝트를 진행했다보니 나는 Next.js를 사용하는 것 자체가 SSR 방식을 사용하는 것이고, getServerSideProps은 SSR을 사용하면서 옵션같은 것을 설정해줄 수 있는 메서드라고 내 맘대로 생각하고 있었나 보다.

 

 

현재 프로젝트 내 모든 페이지에서 getServerSideProps에 대한 코드는 이것 뿐이다.

export const getServerSideProps = withGetServerSideProps(async (context: GetServerSidePropsContext) => {
  return {
    props: {},
  };
});

withGetServerSideProps는 아래와 같이 각 페이지의 meta태그를 설정하는 함수이다.

// util/client/withGetServerSideProps.tsx

import { GetServerSideProps, GetServerSidePropsContext } from "next";
import { KoreanPosition } from "types/write";
interface MapPathToSeo {
  [key: string]: { title: string | undefined; description: string | undefined };
}
const mapPathToSeo: MapPathToSeo = {
  "/": { title: "홈", description: "바디토리에 오신것을 환영해요! 오늘 나의 몸상태를 꼼꼼히 기록해보세요!" },
  // about
    
  // ...

  "/landing": {
    title: "환영해요",
    description: "바디토리에 오신 것을 환영합니다! 회원가입을 하고 바디토리와 함께 내 건강을 챙겨봐요!",
  },
};

Object.entries(KoreanPosition).forEach(([key, value]) => {
  mapPathToSeo[`/users/records/chart/${key}`] = {
    title: `나의 기록 (${value!})`,
    description: `${value}에 대한 나의 기록을 확인해보세요!`,
  };

  // ...

  mapPathToSeo[`/hospital/chart?position=${key}`] = {
    title: `${value!} 증상보기`,
    description: `${value}에 증상을 확인해보세요!`,
  };
});

const withGetServerSideProps = (getServerSideProps: GetServerSideProps) => {
  return async (context: GetServerSidePropsContext) => {
    const pagePath = context.resolvedUrl;
    return await getServerSideProps(context).then((res: { [key: string]: any }) => {
      return {
        ...res,
        props: {
          ...res.props,
          seoData: mapPathToSeo[pagePath],
        },
      };
    });
  };
};
export default withGetServerSideProps;

 

 

 

 

Next.js에서의 SSR과 SSG에 대해 공식문서와 구글링을 통해 알아본 내용은 따로 정리해보았다.

2023.04.25 - [🍟 사이드 프로젝트/🩺 바디토리 : 건강기록 및 병원추천 웹서비스] - Next.js에서 SSR과 SSG

 

 

 

현재 바디토리 내의 모든 페이지는 getServerSideProps를 사용하여 SSR로 pre-rendering되고 있긴 하지만, getServerSideProps로 반환받는 데이터는 meta태그에 사용되는 title, description 뿐이다. 이외의 모든 Data fetching은 React Query를 통해 클라이언트 측에서 진행되고 있다.

 

리팩토링을 위해  Next.js에 대해 공부하다보니 현재 바디토리 코드는 Next.js를 SSR로도 SSG로도 제대로 활용하고 있지 못하고 있다고 생각된다. Next.js 뿐 아니라 React Query 등 다른 기술스택들에 대해 정말 겉표면만 알고 제대로 사용하지 못했구나라고 느꼈다.

 

 

 

 

마무리

갑자기 내용이 급 마무리로 넘어온 이유는..

현재 이슈 해결과 동시에 블로그 작성을 하고 있는데 이슈 해결을 위해 무언가 하나를 알아갈수록 알아봐야할게 계속 생기고, 궁금증이 끊임없이 생겨나서 이 포스팅이 끝나지가 않을 것 같아서이다..😣

 

우선, 리팩토링하는데 진행이 안되었던 해당 오류 자체는 해결을 하였으니 어느 정도 리팩토링과 공부를 진행한 후에 정리를 하고 포스팅을 이어나가야겠다.

(그런데 결국 /auth/login/loading에서 개발환경에서만 오류가 나는 이유는 무엇이었는지 찾지 못했다.. 이 부분도 조금 더 알아본 뒤 추가로 정리해야겠다.)

 

 

 

 

 

 

참고 자료

🔗 [nextjs.org]  getServerSideProps

🔗 [코딩앙마]  Next js 강좌 #3 서버사이드 렌더링 (Server-side Rendering/SSR/Dynamic Rendering)

 

반응형
프로필사진
개발자 삐롱히

프론트엔드 개발자 삐롱히의 개발 & 공부 기록 블로그