Next.JS에서 카카오 로그인 구현하기

코딩하는 남자·2022년 7월 5일
22
post-thumbnail

모처럼 방학을 맞이한 기념으로 그동안 해보고 싶었던 토이 프로젝트를 시작했다.
소셜 로그인은 일단 카카오로만 구현할 생각이었고 Next.JS의 기본적인 설정(데이터베이스 연결, Scss 설정 등) 후에 바로 카카오 로그인 구현을 시작했다. 처음엔 3일이면 충분히 해낼 줄 알았지만 거의 일주일이란 시간이 걸려버렸다. ㅜㅜ

Next.js에서 카카오 로그인을 구현하기까지의 모든 과정과 코드를 공유한다.
(카카오 홈페이지에서 사이트 등록을 하는 등의 작업은 다른 포스트에서 많이 다루기 때문에 패스했다.)

인증 순서

그림 출처(https://data-jj.tistory.com/53)

구현 과정은 위와 거의 동일하다. 다만 내 사이트에선 토큰 대신 세션을 사용해 유저를 인증한다. (6번만 다르고 카카오 인증 과정은 동일하다)

0. JS SDK 파일 다운로드

Kakao Developer -> 시작하기 / Javascript
먼저 카카오에서 제공하는 자바스크립트 SDK 파일을 다운로드한다. (Script 태그 사용)
그리고 앱 시작과 동시에 Kakao.init() 함수로 초기화한다.

// _app.tsx

import type { AppProps } from 'next/app';
import Layout from '../components/layouts/layout';
import Script from 'next/script';

declare global { // Kakao 함수를 전역에서 사용할 수 있도록 선언
  interface Window {
    Kakao: any;
  }
}
function App({ Component, pageProps }: AppProps) {
  
  function kakaoInit() { // 페이지가 로드되면 실행
    window.Kakao.init(process.env.NEXT_PUBLIC_KAKAO_JS_KEY);
    console.log(window.Kakao.isInitialized());
  }

  return (
    <Layout>
      <Component {...pageProps} />
      <Script
        src='https://developers.kakao.com/sdk/js/kakao.js'
        onLoad={kakaoInit} 
      ></Script>
    </Layout>
  );
}

export default App;

이제 다른 파일에서도 카카오에서 제공하는 함수를 사용할 수 있다. (백엔드는 따로 설정해야 한다)


1. 로그인 요청

유저가 카카오 로그인 버튼을 누르면 카카오 로그인 창을 띄워야 한다.
이때는 두가지 방법이 있는데

  1. 리다이렉트 방식

    위의 그림에 나와있는 방식과 동일하며 Kakao.Auth.authorize() 함수를 사용한다. 별도의 팝업창 없이 같은 창 내에서 요청을 주고 받는다.
  1. 팝업 방식

    Kakao.Auth.login() 함수를 사용한다. 별도의 팝업창을 열어서 로그인 로직을 구현하는 방식이다.

나는 리다이렉트 방식을 사용했다. 클라이언트에서 인가코드만 받아서 나머지는 백엔드에서 처리하는 리다이렉트 방식과 달리 클라이언트에서 직접 토큰을 받아오는 팝업 방식이 보안적으로 더 위험해 보였기 때문이다.

함수를 만들기 전에 먼저 카카오 Developer 홈페이지에서 Redirect URI를 등록해야 한다. 유저가 입력한 아이디/비번을 카카오 서버에서 확인하면 등록된 URL로 인가코드를 전송해준다. 코드는 URL 쿼리 형식으로 전달 받기 때문에 해당 로직을 처리할 프론트엔드 페이지를 하나 만들어야 한다.

나는 http://localhost:3000/kakao 페이지를 Redirect URI로 등록했는데 http://localhost:3000/kakao?code=nowiw9f92rjf 형식으로 인가코드를 전달받았다.

// pages/login.tsx

const Login = () => {
  
  // 등록한 redirectUri를 매개변수로 넣어준다.
  function kakaoLogin() {
    window.Kakao.Auth.authorize({
      redirectUri: 'http://localhost:3000/kakao', 
    });
  }
  
  return (
    <div className={styles.container}>
      <KakaoBtn title='카카오 로그인' onClickBtn={kakaoLogin} />
    </div>
  );
};

export default Login;

2. 백엔드에 인가코드 보내기 && 리다이렉트 처리하기

이제 등록된 redirectUri에서 인가코드를 받은 뒤 백엔드에 보내야 한다. (유저에 대한 정보는 모두 백엔드에서 처리한다) 그리고 백엔드에서 받은 응답(성공/에러)에 따라서 각각 처리해준다. 위의 그림에서 3번, 8번에 해당한다.

페이지가 호출될 때 한번만 요청을 보내므로 useEffect() 함수를 사용했다. 또 무한루프를 방지하기 위해 loginHandler() 함수를useCallback() 함수로 감싸서 재렌더링을 방지했다.

// pages/kakao.tsx

import { NextPage } from 'next';
import { useRouter } from 'next/router';
import { useCallback, useEffect } from 'react';


interface ResponseType {
  ok: boolean;
  error?: any;
}

const Kakao: NextPage = () => {
  const router = useRouter();
  const { code: authCode, error: kakaoServerError } = router.query;

  const loginHandler = useCallback(
    async (code: string | string[]) => {
      
      // 백엔드에 전송
      const response: ResponseType = await fetch('/api/users/kakao-login', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          authCode: code,
        }),
      }).then((res) => res.json());
      
      if (response.ok) { // 성공하면 홈으로 리다이렉트
        router.push('/');
      } else { // 실패하면 에러 페이지로 리다이렉트
        router.push('/notifications/authentication-failed');
      }
    },
    [router]
  );

  useEffect(() => {
    if (authCode) {
      loginHandler(authCode);
      
      // 인가코드를 제대로 못 받았을 경우에 에러 페이지를 띄운다.
    } else if (kakaoServerError) { 
      router.push('/notifications/authentication-failed');
    }
  }, [loginHandler, authCode, kakaoServerError, router]);

  return (
          <h2>로그인 중입니다..</h2>
  );
};

3. 카카오 서버에서 유저 정보 받아오기

프론트엔드에서 받아온 인가코드로 카카오 서버에서 유저 정보를 받아온다.

  1. 먼저 인가코드를 사용해서 토큰을 받아온 뒤
  2. 받아온 토큰으로 유저 정보를 받는다.

토큰을 받아올 때는 REST_API_KEY와 REDIRECT_URI를 URL에 넣어야 한다. 공식문서

// pages/api/users/kakao-login.ts

import { NextApiRequest, NextApiResponse } from 'next';

interface TokenResponse {
  token_type: string;
  access_token: string;
  refresh_token: string;
  id_token: string;
  expires_in: number;
  refresh_token_expires_in: string;
  scope: string;
}

interface UserInfo {
  id: number;
  connected_at: string;
  properties: {
    nickname: string;
    profile_image?: string; // 640x640
    thumbnail_image?: string; // 110x110
  };
}

async function getTokenFromKakao(authCode: string) {
  const tokenUrl = `https://kauth.kakao.com/oauth/token?grant_type=authorization_code&client_id=${process.env.KAKAO_RESTAPI_KEY}&redirect_uri=${process.env.NEXT_PUBLIC_KAKAO_REDIRECT_URI}&code=${authCode}`;
  const response: TokenResponse = await fetch(tokenUrl, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
  }).then((res) => res.json());
  return response;
}

async function getUserFromKakao({ access_token }: TokenResponse) {
  const userInfoUrl = 'https://kapi.kakao.com/v2/user/me';
  const response: UserInfo = await fetch(userInfoUrl, {
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${access_token}`,
    },
  }).then((res) => res.json());
  return response;
}

const handler = async (
  req: NextApiRequest,
  res: NextApiResponse
) => {
  const { authCode } = req.body; // 인가 코드

  // 토큰 받아오기
  const tokenResponse = await getTokenFromKakao(authCode);
  
  // 유저 정보 받아오기
  const userInfo = await getUserFromKakao(tokenResponse);
  const {
    id: kakaoId,
    properties: { nickname, profile_image, thumbnail_image },
  } = userInfo;

4. 데이터베이스에서 유저 정보 확인하고 세션 부여

(데이터베이스는 planetscale, ORM은 prisma를 사용했다)

이제 카카오 로그인은 끝났다. 하지만 카카오의 유저인 사실을 확인했을 뿐 우리 사이트의 유저인지 아닌지는 따로 확인해야 한다. 또 유저의 세션 정보도 업데이트 시켜야 한다.

카카오 서버에서 받아온 유저 정보를 활용해서 데이터베이스를 확인하는 작업이다. 나는 카카오의 고유한 회원 아이디를 이용해서 유저를 인증했다. (회원 아이디는 위의 getUserFromKakao() 함수로 불러올 수 있다)

데이터베이스를 많이 조회할수록 속도가 느려진다. 그래서 해당 유저가 존재하는지 확인(client.user.findUnique())하는 대신 이중 try, catch 문으로 한번에 처리했다.

  1. 유저와 세션이 모두 존재할 경우 해당 유저와 연결된 세션만 업데이트 시킨다.
  2. 유저만 존재할 경우 새로운 세션을 만들어서 유저와 연결한다. (세션이 해킹당했다고 의심될 경우 세션 저장소를 비워야 하기 때문에 세션 없이 유저만 존재할 수도 있다)
  3. 둘 다 존재하지 않을 경우 유저와 세션을 새로 생성한다. (회원가입)

보통의 경우 사이트를 재방문하기 때문에 1번에서 끝난다. (데이터베이스와 한번만 통신한다)

만약 사이트를 처음 방문하는 경우 3번까지 가야 한다. (데이터베이스와 세번 통신한다)

// pages/api/users/kakao-login.ts
// 함수 선언

// 1. 세션만 업데이트하는 함수
async function updateSession(kakaoId: number, newSessionId: string) {
  const session = await client.session.update({
    where: {
      kakaoId,
    },
    data: {
      sessionId: newSessionId,
    },
  });
  return session;
}

// 2. 세션을 생성하고 유저와 연결하는 함수
async function createSessionAndConnectToUser(
  kakaoId: number,
  newSessionId: string
) {
  const user = await client.user.update({
    where: {
      kakaoId,
    },
    data: {
      session: {
        create: { kakaoId, sessionId: newSessionId },
      },
    },
  });
  return user;
}

// 3. 새로운 유저를 생성하는 함수 (회원가입)
async function createUser(
  {
    id: kakaoId,
    properties: { nickname, profile_image, thumbnail_image },
  }: UserInfo,
  newSessionId: string
) {
  const user = await client.user.create({
    data: {
      name: nickname,
      kakaoId,
      loggedFrom: 'Kakao',
      profileImage: profile_image || null,
      session: {
        create: { kakaoId, sessionId: newSessionId },
      },
    },
  });
  return user;
}


// pages/api/users/kakao-login.ts
// 로직 구현

import { createSession } from '../../../lib/secret/createSession';
import client from '../../../lib/server/client';

  let user;
  const newSessionId = createSession(kakaoId); // 새로운 세션 생성

  // 데이터베이스 조회를 최소화하기 위해 이중 try문으로 구현했다.
  try {
    // 1. 세션이 존재하면 업데이트만 해주면 됨
    await updateSession(kakaoId, newSessionId);
  } catch {
    try {
      // 2. 유저는 존재하는데 세션이 없는 경우 유저의 세션 값만 업데이트 해준다.
      user = await createSessionAndConnectToUser(kakaoId, newSessionId);
    } catch {
      // 유저가 존재하지 않으면 새로운 계정을 생성한다.
      user = await createUser(userInfo, newSessionId);
    }
  }

  // 유저에게 세션 부여
  req.session.user = { id: newSessionId };
  await req.session.save();
  return res.json({ ok: true });

백엔드 전체 코드

실제 코드에서 withApiSession(백엔드에서 세션을 사용할 수 있게 해주는 함수), withHandler(백엔드에서의 try,catch문 같은 귀찮은 코드를 사용하는 함수)를 사용했는데 다음 포스트에서 확인하기 바란다.

import { NextApiRequest, NextApiResponse } from 'next';
import { createSession } from '../../../lib/secret/createSession';
import client from '../../../lib/server/client';
import withHandler, { ResponseType } from '../../../lib/server/withHandler';
import { withApiSession } from '../../../lib/server/withSession';

interface TokenResponse {
  token_type: string;
  access_token: string;
  refresh_token: string;
  id_token: string;
  expires_in: number;
  refresh_token_expires_in: string;
  scope: string;
}

interface UserInfo {
  id: number;
  connected_at: string;
  properties: {
    nickname: string;
    profile_image?: string; // 640x640
    thumbnail_image?: string; // 110x110
  };
}

async function getTokenFromKakao(authCode: string) {
  const tokenUrl = `https://kauth.kakao.com/oauth/token?grant_type=authorization_code&client_id=${process.env.KAKAO_RESTAPI_KEY}&redirect_uri=${process.env.NEXT_PUBLIC_KAKAO_REDIRECT_URI}&code=${authCode}`;
  const response: TokenResponse = await fetch(tokenUrl, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
  }).then((res) => res.json());
  return response;
}

async function getUserFromKakao({ access_token }: TokenResponse) {
  const userInfoUrl = 'https://kapi.kakao.com/v2/user/me';
  const response: UserInfo = await fetch(userInfoUrl, {
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${access_token}`,
    },
  }).then((res) => res.json());
  return response;
}

async function updateSession(kakaoId: number, newSessionId: string) {
  const session = await client.session.update({
    where: {
      kakaoId,
    },
    data: {
      sessionId: newSessionId,
    },
  });
  return session;
}

async function createSessionAndConnectToUser(
  kakaoId: number,
  newSessionId: string
) {
  const user = await client.user.update({
    where: {
      kakaoId,
    },
    data: {
      session: {
        create: { kakaoId, sessionId: newSessionId },
      },
    },
  });
  return user;
}
async function createUser(
  {
    id: kakaoId,
    properties: { nickname, profile_image, thumbnail_image },
  }: UserInfo,
  newSessionId: string
) {
  const user = await client.user.create({
    data: {
      name: nickname,
      kakaoId,
      loggedFrom: 'Kakao',
      profileImage: profile_image || null,
      session: {
        create: { kakaoId, sessionId: newSessionId },
      },
    },
  });
  return user;
}

const handler = async (
  req: NextApiRequest,
  res: NextApiResponse<ResponseType>
) => {
  const { authCode } = req.body;

  const tokenResponse = await getTokenFromKakao(authCode);
  const userInfo = await getUserFromKakao(tokenResponse);
  const {
    id: kakaoId,
    properties: { nickname, profile_image, thumbnail_image },
  } = userInfo;

  let user;
  const newSessionId = createSession(kakaoId);

  try {
    await updateSession(kakaoId, newSessionId);
  } catch {
    try {
      user = await createSessionAndConnectToUser(kakaoId, newSessionId);
    } catch {
      user = await createUser(userInfo, newSessionId);
    }
  }

  req.session.user = { id: newSessionId };
  await req.session.save();
  return res.json({ ok: true });
};

export default withApiSession(withHandler({ methods: ['POST'], handler }));
profile
"신은 주사위 놀이를 하지 않는다."

0개의 댓글