swimminginthecode DIVE!

Dazz6/ABOUT

모각 프로젝트 - #2-5. 로그인/로그아웃

dazz6 2025. 8. 5. 18:00

모각 프로젝트에서는 카카오 소셜 로그인과 네이버 소셜 로그인을 지원한다.

 

 

관리자 및 테스트 로그인을 제외하고는 소셜 로그인으로만 회원가입 및 로그인이 가능하다.

이전 프로젝트에서 구현했던 로그인과 가장 다른 점은 next 의 서버사이드에서 처리했다는 것!

 

"use client";

import SubTitle from "@/app/components/shared/sub-title";
import kakaoLogin from "./kakao_login.png";
import naverLogin from "./naver_login.png";
import Link from "next/link";
import { useEffect } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { getClientUser } from "@/utils/client/user.client.util";

export default function LoginPage() {
  const router = useRouter();
  const searchParams = useSearchParams();
  const nextPath = searchParams.get("next") || "/";

  useEffect(() => {
    if (getClientUser()) {
      router.push("/");
    }
  }, []);

  const handleKakaoLogin = () => {
    window.location.href = `/api/internal/auth/kakao/login?next=${encodeURIComponent(
      nextPath
    )}`;
  };

  const handleNaverLogin = () => {
    window.location.href = `/api/internal/auth/naver/login?next=${encodeURIComponent(
      nextPath
    )}`;
  };

  return (
    <div>
      <SubTitle contents="로그인" />
      <span className="flex justify-center items-center mt-4 text-center">
        소셜 로그인을 통해 간편하게 로그인 / 회원가입 해 보세요!
      </span>
      <div className="flex flex-col items-center m-10 justify-center gap-y-5">
        <img
          className="w-60 cursor-pointer"
          src={kakaoLogin.src}
          alt="카카오 로그인"
          onClick={handleKakaoLogin}
        />
        <img
          className="w-60 cursor-pointer"
          src={naverLogin.src}
          alt="네이버 로그인"
          onClick={handleNaverLogin}
        />
      </div>
      <hr className="w-80 mx-auto dark:border-border-dark border-borders" />
      <div className="flex text-sm text-borders dark:text-border-dark justify-center items-center mt-4 text-center">
        관리자 페이지는
        <Link
          className="mx-2 underline italic text-secondary dark:text-secondary-dark"
          href="/login/admin"
        >
          여기
        </Link>
        를 클릭해 주세요.
      </div>
    </div>
  );
}

 

기존 react+typescript 환경에서 로그인 후 로그인 이전 페이지로 돌아가는 기능 구현을 위해 쿠키를 사용했었는데,

SSR 이 섞인 Next 환경에서는 쿠키가 아닌 url 쿼리스트링을 사용하여 구현했다.

 

import { NextResponse } from "next/server";

export async function GET(req: Request) {
  const { searchParams } = new URL(req.url);
  const next = searchParams.get("next") || "/";

  const clientId = process.env.KAKAO_REST_API_KEY!;
  const redirectUri = process.env.KAKAO_REDIRECT_URI!;

  const kakaoUrl = `https://kauth.kakao.com/oauth/authorize?client_id=${clientId}&redirect_uri=${redirectUri}&response_type=code&state=${encodeURIComponent(
    next
  )}`;

  return NextResponse.redirect(kakaoUrl);
}
import { NextRequest, NextResponse } from "next/server";

export async function GET(req: NextRequest) {
  try {
    const url = new URL(req.url);
    const code = url.searchParams.get("code");
    const state = url.searchParams.get("state");
    const next = state || "/";

    if (!code) {
      return NextResponse.json({ error: "No code provided" }, { status: 400 });
    }

    const tokenRes = await fetch("https://kauth.kakao.com/oauth/token", {
      method: "POST",
      headers: {
        "Content-Type": "application/x-www-form-urlencoded",
      },
      body: new URLSearchParams({
        grant_type: "authorization_code",
        client_id: process.env.KAKAO_REST_API_KEY!,
        redirect_uri: process.env.KAKAO_REDIRECT_URI!,
        code,
        client_secret: process.env.KAKAO_CLIENT_SECRET!,
      }),
    });

    const tokenData = await tokenRes.json();
    if (!tokenData.access_token) {
      return NextResponse.json(
        { error: "Token fetch failed", tokenData },
        { status: 500 }
      );
    }

    const accessToken = tokenData.access_token;

    const userRes = await fetch("https://kapi.kakao.com/v2/user/me", {
      headers: {
        Authorization: `Bearer ${accessToken}`,
      },
    });

    const userData = await userRes.json();

    const userInfo = {
      provider: "kakao",
      providerId: userData.id,
    };

    const res = await fetch(
      `${process.env.BACKEND_API_URL}/auth/social-login`,
      {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify(userInfo),
      }
    );

    const { token, memberId } = await res.json();

    const redirectUrl = `${
      process.env.FRONTEND_API_URL
    }/login/callback?memberId=${memberId}&token=${token}&next=${encodeURIComponent(
      next
    )}`;

    const response = NextResponse.redirect(redirectUrl);
    response.cookies.set("jwt", token, { path: "/", httpOnly: false });
    response.cookies.set("provider", "kakao");

    return response;
  } catch (err) {
    console.error("카카오 로그인 처리 오류:", err);
    return NextResponse.json(
      { error: "Internal server error" },
      { status: 500 }
    );
  }
}

 

이렇게 /api (서버 사이드) 경로에서 외부 통신을 마친 다음, 지정한 callback 페이지로 redirect 한다.

 

"use client";

import { useEffect } from "react";
import { useSearchParams, useRouter } from "next/navigation";

import Loading from "@/app/loading";
import { useAuthStore } from "@/store/authStore";

export default function AuthCallbackPage() {
  const params = useSearchParams();
  const router = useRouter();
  const { login } = useAuthStore();

  useEffect(() => {
    const memberId = params.get("memberId");
    const token = params.get("token");
    const next = params.get("next") || "/";

    if (memberId) {
      localStorage.setItem("memberId", memberId);
    }

    if (token) {
      localStorage.setItem("jwt", token);
      login(token);
    }

    window.dispatchEvent(new Event("member:changed"));
    router.replace(next);
  }, [params, router, login]);

  return <Loading />;
}

 

그러면 해당 callback 페이지에서 localStorage 에 memberId 와 jwt 를 저장하고,

쿼리스트링으로 계속 가지고 다닌 url 로 replace 한다.

 

위 예시는 카카오 로그인이며, 네이버 로그인도 크게 다르지 않으므로 줄이겠다.