[Node.js] Express 카카오 로그인 구현해 보기

CCONAC·2023년 7월 28일
12
post-thumbnail

🚪 시작하기 전에...

사실 카카오 로그인은 이전에도 구현해 본 경험이 있다. 정말 아무것도 모르고, 뭘 하고 싶은지도 모를 적에 뭐라도 구현해 보고 싶어서 선택했던 기능이다. 문제는 정말 아무것도 모를 적이라 이해도 하지 않고 남의 코드를 그대로 베껴서 성공하고 이해도 안 하고 말았었다. 그러니까 구현해 보긴 했지만 정말 해 보기만 하고 한 것도 아니었다.

그러다 동기 한 명과 옛날에 했던 엉망진창인 프로젝트 하나를 리팩토링 해 보기로 했고, Express 프레임워크를 사용해 다시 서버를 만져 보게 됐다!

기존 프로젝트에서는 JavaScript만 사용했지만 백엔드와 프론트 둘 다 TypeScript를 사용하게 됐고, DB는 MongoDB에서 Firebase로 변경해 주었다. 별 이유는 없고 새로운 경험을 해 보고 싶었다.

그리고 그 결과.

아똥꼬빠질것같다... 개어렵다...

타입스크립트도 공부를 하나도 하지 않고 시작한 거라 자바스크립트보다 훨씬 까다롭고 타입 관련 에러도 너무 많이 뜬다. (당연한 거지만) 파이어베이스도 초기 설정부터 굉장히 험난했다. 그리고 무엇보다 가장 힘들었던 건 요청을 주고받는 거였는데, 이 과정에서 너무 많은 에러를 만났고... 에러보다 더 공포인 점은 에러가 한 줄도 뜨지 않는데 생각처럼 안 되는 경우였다.

이런 경우는 로그를 찍어 보면 undefined로 받아오는 경우가 대부분이더라. 이것도 아니라면 네트워크 탭에 정말 다 답이 나와 있었다. 왠지 모르겠지만 네트워크 탭은 약간 공포스러웠는데 이제 적어도 피하지는 않게 될 것 같다. 아직 어색하고 서먹서먹하긴 하다.

무튼, 서론이 너무 길었다. 이제 진짜 시작!


⚒ 개발 환경

Frontend: Vite + TypeScript + React
Backend: Express + TypeScript + Node.js + Firebase

⚒ 구현 과정

⚒ KAKAO DEVELOPER

개발에 들어가기 전 Kakao developer에서 해야 할 몇 가지 필수 선행 작업이 있다.

1. 애플리케이션 추가

내가 가진 애플리케이션이 없다면 애플리케이션 추가하기 옆의 플러스 버튼을 눌러 만들고자 하는 애플리케이션의 이름으로 앱을 생성해 주면 된다.

2. 앱 키 발급

애플리케이션을 생성하고 보면 이렇게 내 앱의 다양한 키를 발급할 수 있다. 나는 여기서 REST API 키를 사용해 구현했다. 사실 전에 REST API 키를 사용해 봤으니 JavaSciprt 키로 함 해 봐? 했었으나 이전에 했던 것은 남의 코드로만 이루어진 의미가 없던 구현이었어서 새로 시작하는 기분으로 다시 사용해 보았다.

3. 사이트 도메인 설정

내 애플리케이션 > 앱 설정 > 플랫폼으로 들어가면 위와 같이 사이트 도메인을 설정할 수 있는데, 현재 로컬에서 작업하고 있으므로 localhost:4000으로 설정해 주었다.

많은 포스트에서 강조하고 있듯이 이 부분에서 굉장히 중요한 사실이 있는데......

반드시 프론트 측의 URL을 입력해 줘야 한다.

사실 반드시가 맞는지는 모르겠다. 그런데 분명한 건 Kakao API 호출 시 사용하는 사이트 도메인을 입력해야 하기 때문에 나는 반드시라고 작성했다. 만일 서버에서 html 파일을 별도로 만들어 서버 로컬에서만 작업하는 경우를 예외라고 치면 98% 정도는 맞을 것 같다. 권장하지 않지만 백엔드와 프론트엔드가 같은 포트를 사용하고 있을 경우에는 그 포트 작성해 주면 된다.

4. Redirect URI 설정

마지막으로 내 애플리케이션 > 제품 설정 > 카카오 로그인에서 Redirect URI를 설정해 주면 선행 작업은 모두 끝이다!

그래서 얘는 뭐 하는 애인데......

Redirect URI는 사용자가 동의 항목에 동의하고 로그인을 요청하면 인가 코드를 넘겨 받는 역할이다!! 인가 코드가 있어야 토큰 발급을 받을 수 있고, 토큰이 있어야 우리가 원하는 사용자 정보를 얻어 회원가입을 시키든지, 해당하는 정보로 작업을 하든지 할 수 있다. 이걸 알고 작업하는 것과 모르고 코드만 따라 치는 건 하늘과 땅 차이드라.

⚒ 개발 시작

정말 공식 문서에 순서와 해야 하는 작업이 명료하게 잘 그려져 있어 가지고 왔다.

1 ~ 4

LoginPage/Auth.ts

const REST_API_KEY: any = import.meta.env.VITE_REST_API_KEY;
const REDIRECT_URI: any = import.meta.env.VITE_REDIRECT_URI;

export const KAKAO_AUTH_URL: string = `https://kauth.kakao.com/oauth/authorize?client_id=${REST_API_KEY}&redirect_uri=${REDIRECT_URI}&response_type=code&scope=profile_nickname,account_email`;

민감한 정보인 REST_API_KEYREDIRECT_URI는 .env 파일에 넣어 주었다. 근데 VITE는 .env 파일을 사용하는 방법이 React랑 달라서 REACT_APP_KEY_NAME 이런 식으로 작성하다가 undefined가 떠서 꽤 애를 먹었다.... 👊

https://kauth.kakao.com/oauth/authorize?response_type=code&client_id=${REST_API_KEY}&redirect_uri=${REDIRECT_URI}

나는 email 동의 항목을 수집해야 하기 때문에 별도로 scope 쿼리 파라미터를 작성했지만, 이는 필수 항목이 아니기 때문에 카카오 공식 문서에 나와 있는 위와 같은 요청 URL만 작성해 주면 된다.

쿼리 파라미터에 대한 정보는 공식 문서가 짱이다. 정말 친절하게 잘 나와 있다. 이러니까 공식 문서 처돌이 같은데 이해하고 보니까 정말 친절하더라. 이해하기 전에는 구현된 블로그 포스트만 봤지 공식 문서는 쳐다도 안 봤다.

LoginPage/LoginPage.tsx

import { KAKAO_AUTH_URL } from "./Auth";

const LoginPage = () => {
  return (
    <div className={styles.login_page}>
      <a href={KAKAO_AUTH_URL}>
          <div className={styles.kakao_login_button}>
            <img src="/src/Assets/kakaologin.png"/>
          </div>
      </a>
    </div>
  );
};

해당 작업은 프론트엔드에서 진행해 줬다. 인가 코드를 요청하고 발급받는 단계인데, 우리 웹 사이트의 카카오 로그인 버튼을 누르면 KAKAO_AUTH_URL로 연결되면서 인가 코드를 받기 위한 요청을 보낸다. 그리고 사용자의 인증 및 동의를 받으면 그때 인가 코드를 발급 받을 수 있는 것이다!

이렇게 네트워크 탭에 인가 코드 받기 요청이 HTTP 302 리다이렉트되었다면, redirect_uri에 인가 코드가 GET 요청으로 전달된 거다!!!! 이얏호.

5. 토큰 요청 & 발급

프로젝트 트리

├─ backend
│  ├─ src
│  │  ├─ app.ts
│  │  ├─ Config
│  │  │  ├─ firebaseConfig.ts
│  │  │  └─ sessionConfig.ts
│  │  ├─ Firebase
│  │  │  └─ user.ts
│  │  └─ Router
│  │     └─ kakaoRoutes.ts
│  └─
└─ .env

app.ts 내의 코드를 최소화하고 싶어서 이렇게 파일 분리를 진행했다.

[src/Router/kakaoRoutes.ts] access_token 발급

import { Request, Response, Router } from "express";
import { setUsers } from "../Firebase/user";
import dotenv from "dotenv";
import axios from "axios";
import qs from "qs";

export const kakaoRouter = Router();

dotenv.config();

const kakao = {
  CLIENT_ID: process.env.KAKAO_ID,
  REDIRECT_URI: process.env.REDIRECT_URI,
};

kakaoRouter.get("/oauth/callback/kakao", async (req: Request, res: Response) => {
  /* access token 발급 */
  let token: any;
  try {
    token = await axios({
      method: "POST",
      url: "https://kauth.kakao.com/oauth/token",
      headers: {
        "content-type": "application/x-www-form-urlencoded",
      },
      data: qs.stringify({
        grant_type: "authorization_code",
        client_id: kakao.CLIENT_ID,
        redirectUri: kakao.REDIRECT_URI,
        code: req.query.code as string,
      }),
    });
  } catch (e: any) {
    res.json(e.data);
    return;
  }

토큰을 받아오기 위해 필요한 REST_API_KEYREDIRECT_URI는 프론트엔드와 마찬가지로 민감한 정보이므로 .env 파일에 넣어 줬다.

const kakao = {
  CLIENT_ID: process.env.KAKAO_ID,
  REDIRECT_URI: process.env.REDIRECT_URI,
};

토큰을 요청하고 발급 받는 코드이다. /oauth/callback/kakao는 우리가 이전에 설정했던 Redirect URI인데, 인가 코드 요청이 성공적으로 수행되었다면 이 Redirect URI에 그 인가 코드가 아주 잘 담겨 있을 거다. 우린 이걸로 토큰 요청을 하면 된다!

kakaoRouter.get("/oauth/callback/kakao", async (req: Request, res: Response) => {
  /* access token 발급 */
  let token: any;
  try {
    token = await axios({
      method: "POST",
      url: "https://kauth.kakao.com/oauth/token",
      headers: {
        "content-type": "application/x-www-form-urlencoded",
      },
      data: qs.stringify({
        grant_type: "authorization_code",
        client_id: kakao.CLIENT_ID,
        redirectUri: kakao.REDIRECT_URI,
        code: req.query.code as string,
      }),
    });
  } catch (e: any) {
    res.json(e.data);
    return;
  }

필수 파라미터인 headers와 grant_type, client_id, redirect_uri, code를 쿼리 스트링으로 변환하고 `https://kauth.kakao.com/oauth/token`으로 POST 요청을 하면, 요청 성공 시 응답에 토큰이 날라오는 것을 확인할 수 있다.

이때 정말 많은 에러를 만났는데 그 중 하나가 KOE320이다.

포스트별로 구현이 죄다 비슷한 것처럼 느껴지지만 백엔드에서 회원가입과 로그인에 어느 정도 관여를 하는지에 따라 구현 방법이 다르다는 걸 조금만 둘러봐도 알 수 있다.

처음에는 이해보다 무작정 따라 하려고만 하다 보니 프론트와 백엔드에서 동시에 인가 요청을 날리게 됐고, 결국 토큰이 연속적으로 호출되면서 하나는 200 OK, 서버에서는 404 ERROR가 떠서 거의 이틀을 넘게 골골 앓았다. 이 과정에서 챗 GPT랑도 뒤지게 싸웠다.

아니멍청아, 죄송합니다. 아니그게아니라, 죄송합니다. 아니그니까이해햇다매??????, 죄송합니다. 아됏다걍혼자하고말지, 다음에 제 도움이 필요하시다면 언제든 말씀해 주세요! 이런미친

이러다 정말 일주일 꼬박 태워도 안 될 것 같아서 하루를 이해하는 데 쏟아 챗 GPT의 도움 없이 문제를 해결할 수 있었다. 인간이 승리했다!

6. 토큰으로 사용자 정보 가져오기

[src/Router/kakaoRoutes.ts] 사용자 정보 가져오기

let user: any;
  try {
    user = await axios({
      method: "GET",
      url: "https://kapi.kakao.com/v2/user/me",
      headers: {
        Authorization: `Bearer ${token.data.access_token}`,
      },
    });
  } catch (e: any) {
    res.json(e.data);
    return;
  }

공식 문서에 명시된 대로 액세스 토큰 방식을 사용해 방금 발급 받은 토큰을 헤더에 담아 GET 요청을 하면, 드디어 사용자 정보를 응답으로 받아낼 수 있다. console.log(user.data)로 출력한 결과는 아래와 같다.

7. 데이터베이스와 세션에 사용자 정보 저장

src/Firebase/user.ts

import { doc, increment, setDoc, getDoc, updateDoc, collection } from "firebase/firestore";
import { database } from "../Config/firebaseConfig";

export const setUsers = async (userData: any) => {
  const userInfo = {
    _id: userData.id,
    email: userData.kakao_account.email,
    name: userData.kakao_account.profile.nickname,
  };

  const userRef = doc(collection(database, "slowmailbox", "user", "users"), `${userInfo._id}`);
  const userDoc = await getDoc(userRef);

  if (!userDoc.exists()) {
    await updateDoc(doc(database, "slowmailbox", "counter"), {
      totalUser: increment(1),
    });

    await setDoc(userRef, userInfo);
  }
};

데이터베이스는 파이어베이스를 사용 중이라 위와 같은 코드를 작성했다. 이게 주가 되는 내용은 아니니 대충 설명하자면 userRef 경로를 탐색해 유저 아이디 이름을 가지고 있는 컬렉션이 이미 존재하면 건너뛰고, 존재하지 않는다면 counter 문서의 totalUser 필드를 1 증가시킨 뒤 새로운 유저 정보가 DB에 삽입되도록 했다.

src/Config/sessionConfig.ts

import session from "express-session";

declare module "express-session" {
  interface SessionData {
    userData: { _id: number; name: string };
  }
}

export const sessionConfig = session({
  secret: "ras",
  resave: true,
  saveUninitialized: false,
});

그리고 session에 유저 정보를 저장해 주기 위해 sessionConfig.ts 파일을 만들어 session과 관련된 내용들을 작성했다. 그리고 여기서 또 하나의 난관에 봉착했었는데, Typescript는 Javascript와 다르게 타입이 정의되어 있지 않으면 에러를 발생시키는지라...... req.session.userDatauserData 부분에서 계속해서 에러가 발생했다. 🐒

에러의 에러의 에러의 연속이구나...

이 에러 때문에 하루 내내 구글링을 하다 이런 아주 좋은 포스트를 발견했고, 해결해 냈다! 정말 무한한 감사를 드립니다!!! 🤩

declare module "express-session" {
  interface SessionData {
    userData: { _id: number; name: string };
  }
}

그래서 이건 또 뭔데?

이 모듈이 포함되어 있기만 하면 Typescript 컴파일러가 자동으로 타입을 인지할 수 있게 된다는 것이다. 타입스크립트의 세계는 정말 어렵다.

src/Router/KakakoRoutes.ts

await setUsers(user.data);

req.session.userData = { _id: user.data.id, name: user.data.kakao_account.profile.nickname };

res.redirect("http://localhost:4000/main");

모든 작업을 다 마친 뒤 KakaoRoutes.ts로 돌아와서 DB에 유저 정보를 저장하는 함수도 불러와 주고, session에 유저 정보도 저장해 준 뒤 리다이렉트를 해 주면!

DB에 아주 잘 저장된 모습을 확인할 수 있다!!!! 🐸🐸🐸

디자인은 민망하지만 로그인 페이지에서 메인 페이지로도 아주 잘 넘어간다. 아 뿌듯해!!

전체 코드

src/Router/KakaoRoutes.ts

import { Request, Response, Router } from "express";
import { setUsers } from "../Firebase/user";
import dotenv from "dotenv";
import axios from "axios";
import qs from "qs";

export const kakaoRouter = Router();

dotenv.config();

const kakao = {
  CLIENT_ID: process.env.KAKAO_ID,
  REDIRECT_URI: process.env.REDIRECT_URI,
};

/* login 이후 나타나는 callback page */
kakaoRouter.get("/oauth/callback/kakao", async (req: Request, res: Response) => {
  /* access token 발급 */
  let token: any;
  try {
    token = await axios({
      method: "POST",
      url: "https://kauth.kakao.com/oauth/token",
      headers: {
        "content-type": "application/x-www-form-urlencoded",
      },
      data: qs.stringify({
        grant_type: "authorization_code",
        client_id: kakao.CLIENT_ID,
        redirectUri: kakao.REDIRECT_URI,
        code: req.query.code as string,
      }),
    });
  } catch (e: any) {
    res.json(e.data);
    return;
  }

  /* access token 발급받은 뒤 사용자 정보 가져옴 */
  let user: any;
  try {
    user = await axios({
      method: "GET",
      url: "https://kapi.kakao.com/v2/user/me",
      headers: {
        Authorization: `Bearer ${token.data.access_token}`,
      },
    });
  } catch (e: any) {
    res.json(e.data);
    return;
  }

  /* 가지고 온 사용자 정보 DB & session 저장 */
  await setUsers(user.data);

  req.session.userData = { _id: user.data.id, name: user.data.kakao_account.profile.nickname };

  res.redirect("http://localhost:4000/main");
});

src/app.ts

import express from "express";
import { sessionConfig } from "./Config/sessionConfig";
import { kakaoRouter } from "./Router/kakaoRoutes";

const app = express();
const PORT = process.env.PORT;

/* kakao login */
app.use(sessionConfig);
app.use(kakaoRouter);

app.listen(PORT, () => {
  console.log(`Server listening on port ${PORT}`);
});

📄 회고

지금이야 성공한 지 며칠이 지났으니 이렇게 정말 덤덤하게 쓰고 있지만, 하루 4-6시간을 쏟아 가며 4일을 고군분투해서 이뤄낸 거라 성공했을 당시 나는 이랬다.

진짜 성공하자마자 스프링(프레임워크 아님)마냥 펄쩍 뛰어올라서 같이 프로젝트를 진행하는 친구한테 이런 생난리를 쳤다. 지금 보니 웬 짐승이 따로 없다.

이건 친구한테 말해 준 성공 후기다. 거짓말 안 치고 3일 동안은 저 상태였다. 점점 눈이 동태 눈깔이 되고 혼이 나가서 코드를 쳐다도 보기 싫었었다. 물론 싫어도 악바리 근성으로 적어도 하루 5시간은 꾸준히 잡았다. 근데 딱 나흘차에 성공하자마자 뇌에 도파민이 돌면서 코딩이 너무 재미있어지더라.... 코딩은 진짜 너무 무서운 듯.

그리고 본문에서도 말했지만 이해를 시도조차 하지 않을 적에는 공식 문서가 굉장히 부실하다고 느꼈는데, 지금 보니까 이렇게 명료하고 모든 게 친절하게 떠먹여 주듯이 잘 나와 있을 수가 없다. 정말 감사하다...... 기록은 거의 공식 문서를 보고 했다.

암튼 재미있었다!


📄 REFERENCE

profile
기록의 습관화 📝

0개의 댓글