diff --git a/apps/web/package.json b/apps/web/package.json
index 7e8bbe44..0ae2f811 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -43,6 +43,8 @@
"@tailwindcss/postcss": "^4",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2",
+ "@types/google-one-tap": "^1.2.7",
+ "@types/google.accounts": "^0.0.18",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
diff --git a/apps/web/src/_pages/auth/use-cases/social-login-url.ts b/apps/web/src/_pages/auth/use-cases/social-login-url.ts
deleted file mode 100644
index 5f36b714..00000000
--- a/apps/web/src/_pages/auth/use-cases/social-login-url.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import { API_BASE_URL, TEAM_ID } from "@/shared/config/env";
-
-/**
- * 구글 소셜 로그인 시작 URL 반환
- * 브라우저가 이 URL로 이동하면 백엔드가 구글 로그인 페이지로 302 리다이렉트
- * 구글 인증 완료 후 /oauth/callback?accessToken=...&refreshToken=... 으로 복귀
- */
-export function getGoogleLoginUrl(): string {
- return `${API_BASE_URL}/${TEAM_ID}/auth/google`;
-}
-
-/**
- * 카카오 소셜 로그인 시작 URL 반환
- * 브라우저가 이 URL로 이동하면 백엔드가 카카오 로그인 페이지로 302 리다이렉트
- * 카카오 인증 완료 후 /oauth/callback?accessToken=...&refreshToken=... 으로 복귀
- */
-export function getKakaoLoginUrl(): string {
- return `${API_BASE_URL}/${TEAM_ID}/auth/kakao`;
-}
diff --git a/apps/web/src/_pages/auth/use-cases/social-login.ts b/apps/web/src/_pages/auth/use-cases/social-login.ts
new file mode 100644
index 00000000..8135a873
--- /dev/null
+++ b/apps/web/src/_pages/auth/use-cases/social-login.ts
@@ -0,0 +1,33 @@
+import { API_BASE_URL, TEAM_ID } from "@/shared/config/env";
+
+/**
+ * 구글 소셜 로그인
+ * Google Identity Services SDK로 받은 access_token을 백엔드로 전달
+ * 백엔드가 토큰 검증 후 자체 JWT(accessToken, refreshToken) 발급
+ */
+export async function loginWithGoogle(accessToken: string) {
+ const res = await fetch(`${API_BASE_URL}/${TEAM_ID}/oauth/google`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token: accessToken }),
+ });
+
+ if (!res.ok) throw new Error("구글 로그인 실패");
+ return res.json() as Promise<{ accessToken: string; refreshToken: string }>;
+}
+
+/**
+ * 카카오 소셜 로그인
+ * Next.js Route Handler에서 카카오 access_token으로 교환 후 백엔드로 전달
+ * 백엔드가 토큰 검증 후 자체 JWT(accessToken, refreshToken) 발급
+ */
+export async function loginWithKakao(accessToken: string) {
+ const res = await fetch(`${API_BASE_URL}/${TEAM_ID}/oauth/kakao`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token: accessToken }),
+ });
+
+ if (!res.ok) throw new Error("카카오 로그인 실패");
+ return res.json() as Promise<{ accessToken: string; refreshToken: string }>;
+}
diff --git a/apps/web/src/app/api/oauth/kakao/route.ts b/apps/web/src/app/api/oauth/kakao/route.ts
new file mode 100644
index 00000000..f90f05c1
--- /dev/null
+++ b/apps/web/src/app/api/oauth/kakao/route.ts
@@ -0,0 +1,36 @@
+import { type NextRequest, NextResponse } from "next/server";
+
+// 카카오 인가 코드 -> access_token 교환
+// client_secret이 필요하기 때문에 브라우저가 아닌 서버(Route Handler)에서 처리
+export async function GET(request: NextRequest) {
+ const { searchParams } = new URL(request.url);
+ const code = searchParams.get("code");
+
+ if (!code) {
+ return NextResponse.json({ message: "인가 코드가 없습니다." }, { 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.NEXT_PUBLIC_KAKAO_CLIENT_ID!,
+ client_secret: process.env.KAKAO_CLIENT_SECRET!,
+ code,
+ redirect_uri: process.env.NEXT_PUBLIC_KAKAO_REDIRECT_URI!,
+ }),
+ });
+
+ if (!tokenRes.ok) {
+ return NextResponse.json({ message: "카카오 토큰 교환 실패" }, { status: 400 });
+ }
+
+ const { access_token } = await tokenRes.json();
+
+ if (!access_token) {
+ return NextResponse.json({ message: "카카오 토큰 응답 오류" }, { status: 400 });
+ }
+
+ return NextResponse.json({ accessToken: access_token });
+}
diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx
index 58757a69..53a3d36c 100644
--- a/apps/web/src/app/layout.tsx
+++ b/apps/web/src/app/layout.tsx
@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import "./globals.css";
+import Script from "next/script";
import { AmplitudeInit } from "@/amplitude";
export const metadata: Metadata = {
@@ -23,6 +24,7 @@ export default function RootLayout({
/>
+
{children}
diff --git a/apps/web/src/app/oauth/callback/page.tsx b/apps/web/src/app/oauth/callback/kakao/page.tsx
similarity index 56%
rename from apps/web/src/app/oauth/callback/page.tsx
rename to apps/web/src/app/oauth/callback/kakao/page.tsx
index ed79b226..ed0759bb 100644
--- a/apps/web/src/app/oauth/callback/page.tsx
+++ b/apps/web/src/app/oauth/callback/kakao/page.tsx
@@ -3,46 +3,52 @@
import { LoadingIndicator } from "@moum-zip/ui/components";
import { useRouter, useSearchParams } from "next/navigation";
import { Suspense, useEffect, useRef } from "react";
+import { loginWithKakao } from "@/_pages/auth/use-cases/social-login";
import { ROUTES } from "@/shared/config/routes";
-function OAuthCallbackContent() {
+function KakaoCallbackContent() {
const router = useRouter();
const searchParams = useSearchParams();
const handledRef = useRef(false);
useEffect(() => {
- // 토큰 처리 중복 실행 방지
+ // 중복 실행 방지
if (handledRef.current) return;
handledRef.current = true;
- const accessToken = searchParams.get("accessToken");
- const refreshToken = searchParams.get("refreshToken");
- // 백엔드 OAuth 실패 시 ?error=... 형태로 넘어오는 케이스 대비
+ const code = searchParams.get("code");
const error = searchParams.get("error");
- // 토큰이 없거나 에러가 있으면 로그인 페이지로 이동
- if (error || !accessToken || !refreshToken) {
+ // 카카오 로그인 실패 시 ?error=... 형태로 넘어오는 케이스 대비
+ if (error || !code) {
router.replace(ROUTES.login);
return;
}
const handleCallback = async () => {
try {
- // 토큰을 URL에서 즉시 제거 (히스토리/Referer 헤더로 토큰 노출 방지)
- window.history.replaceState({}, "", window.location.pathname);
+ // 카카오 인가 코드 -> access_token 교환 (Route Handler에서 처리)
+ const tokenRes = await fetch(`/api/oauth/kakao?code=${code}`);
+ if (!tokenRes.ok) {
+ router.replace(ROUTES.login);
+ return;
+ }
+ const { accessToken: kakaoAccessToken } = await tokenRes.json();
+
+ // 카카오 access_token -> 백엔드 JWT 발급
+ const { accessToken, refreshToken } = await loginWithKakao(kakaoAccessToken);
- // httpOnly 쿠키는 서버에서만 set 가능하므로 Route Handler에 위임
+ // httpOnly 쿠키에 저장
const res = await fetch("/api/auth/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ accessToken, refreshToken }),
credentials: "include",
- referrerPolicy: "no-referrer", // Referer 헤더로 토큰 노출 방지
+ referrerPolicy: "no-referrer",
});
if (res.ok) {
- // router.replace는 서버 컴포넌트를 재실행하지 않아 네비게이션 상태가 바뀌지 않음
- // 풀 리로드로 쿠키 반영
+ // 쿠키 반영을 위해 홈으로 새 요청 (클라이언트 이동 X)
window.location.replace(ROUTES.home);
} else {
router.replace(ROUTES.login);
@@ -58,10 +64,10 @@ function OAuthCallbackContent() {
return ;
}
-export default function OAuthCallbackPage() {
+export default function KakaoCallbackPage() {
return (
}>
-
+
);
}
diff --git a/apps/web/src/features/auth/ui/login-form.tsx b/apps/web/src/features/auth/ui/login-form.tsx
index 6155edad..62b990fa 100644
--- a/apps/web/src/features/auth/ui/login-form.tsx
+++ b/apps/web/src/features/auth/ui/login-form.tsx
@@ -1,11 +1,11 @@
"use client";
-import { Button, InputField, SocialButton } from "@moum-zip/ui/components";
+import { Button, InputField, SocialButton, toast } from "@moum-zip/ui/components";
import Link from "next/link";
import { startTransition, useActionState } from "react";
import { useForm } from "react-hook-form";
import { loginAction } from "@/_pages/auth/actions";
-import { getGoogleLoginUrl, getKakaoLoginUrl } from "@/_pages/auth/use-cases/social-login-url";
+import { loginWithGoogle } from "@/_pages/auth/use-cases/social-login";
import { ROUTES } from "@/shared/config/routes";
import { PasswordInput } from "./password-input";
@@ -98,14 +98,33 @@ export const LoginForm = () => {
provider="google"
className="w-full md:w-[222px]"
onClick={() => {
- window.location.href = getGoogleLoginUrl();
+ const client = window.google.accounts.oauth2.initTokenClient({
+ client_id: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID!,
+ scope: "email profile",
+ callback: async (response) => {
+ try {
+ const { accessToken, refreshToken } = await loginWithGoogle(response.access_token);
+ const res = await fetch("/api/auth/token", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ accessToken, refreshToken }),
+ });
+ if (!res.ok) throw new Error("토큰 저장 실패");
+ window.location.replace(ROUTES.home);
+ } catch {
+ toast({ message: "로그인 중 오류가 발생했어요. 다시 시도해주세요.", size: "small" });
+ window.location.replace(ROUTES.login);
+ }
+ },
+ });
+ client.requestAccessToken();
}}
/>
{
- window.location.href = getKakaoLoginUrl();
+ window.location.href = `https://kauth.kakao.com/oauth/authorize?client_id=${process.env.NEXT_PUBLIC_KAKAO_CLIENT_ID}&redirect_uri=${encodeURIComponent(process.env.NEXT_PUBLIC_KAKAO_REDIRECT_URI!)}&response_type=code`;
}}
/>
diff --git a/apps/web/src/features/auth/ui/signup-form.tsx b/apps/web/src/features/auth/ui/signup-form.tsx
index b9e778ac..65fd47e2 100644
--- a/apps/web/src/features/auth/ui/signup-form.tsx
+++ b/apps/web/src/features/auth/ui/signup-form.tsx
@@ -1,10 +1,10 @@
"use client";
-import { Button, InputField, SocialButton } from "@moum-zip/ui/components";
+import { Button, InputField, SocialButton, toast } from "@moum-zip/ui/components";
import Link from "next/link";
import { startTransition, useActionState } from "react";
import { useForm } from "react-hook-form";
import { signupAction } from "@/_pages/auth/actions";
-import { getGoogleLoginUrl, getKakaoLoginUrl } from "@/_pages/auth/use-cases/social-login-url";
+import { loginWithGoogle } from "@/_pages/auth/use-cases/social-login";
import { ROUTES } from "@/shared/config/routes";
import { PasswordInput } from "./password-input";
@@ -127,14 +127,33 @@ export const SignupForm = () => {
provider="google"
className="w-full md:w-[222px]"
onClick={() => {
- window.location.href = getGoogleLoginUrl();
+ const client = window.google.accounts.oauth2.initTokenClient({
+ client_id: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID!,
+ scope: "email profile",
+ callback: async (response) => {
+ try {
+ const { accessToken, refreshToken } = await loginWithGoogle(response.access_token);
+ const res = await fetch("/api/auth/token", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ accessToken, refreshToken }),
+ });
+ if (!res.ok) throw new Error("토큰 저장 실패");
+ window.location.replace(ROUTES.home);
+ } catch {
+ toast({ message: "회원가입 중 오류가 발생했어요. 다시 시도해주세요.", size: "small" });
+ window.location.replace(ROUTES.signup);
+ }
+ },
+ });
+ client.requestAccessToken();
}}
/>
{
- window.location.href = getKakaoLoginUrl();
+ window.location.href = `https://kauth.kakao.com/oauth/authorize?client_id=${process.env.NEXT_PUBLIC_KAKAO_CLIENT_ID}&redirect_uri=${encodeURIComponent(process.env.NEXT_PUBLIC_KAKAO_REDIRECT_URI!)}&response_type=code`;
}}
/>
diff --git a/apps/web/src/global.d.ts b/apps/web/src/global.d.ts
new file mode 100644
index 00000000..b52dd817
--- /dev/null
+++ b/apps/web/src/global.d.ts
@@ -0,0 +1,14 @@
+declare namespace google.accounts.oauth2 {
+ interface TokenResponse {
+ error?: string;
+ error_description?: string;
+ }
+}
+
+declare global {
+ interface Window {
+ google: typeof google;
+ }
+}
+
+export {};
\ No newline at end of file
diff --git a/apps/web/src/shared/config/env.ts b/apps/web/src/shared/config/env.ts
index feb684a2..7c2f315f 100644
--- a/apps/web/src/shared/config/env.ts
+++ b/apps/web/src/shared/config/env.ts
@@ -1,3 +1,6 @@
-export const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? "https://together-dallaem-api.vercel.app";
+export const API_BASE_URL = (process.env.NEXT_PUBLIC_API_URL ?? "https://together-dallaem-api.vercel.app").replace(
+ /\/+$/,
+ "",
+);
export const TEAM_ID = process.env.NEXT_PUBLIC_TEAM_ID ?? "moum-zip-dev";
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 2b1f4d5b..95accfa4 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -111,6 +111,12 @@ importers:
'@testing-library/react':
specifier: ^16.3.2
version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@types/google-one-tap':
+ specifier: ^1.2.7
+ version: 1.2.7
+ '@types/google.accounts':
+ specifier: ^0.0.18
+ version: 0.0.18
'@types/node':
specifier: ^20
version: 20.19.37
@@ -3615,6 +3621,12 @@ packages:
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
+ '@types/google-one-tap@1.2.7':
+ resolution: {integrity: sha512-u+1J+ldj/ZvvBBP7a5zC+sWy5LaHVfcTcOW/aaTKYoWUlsWi6OCBYYN4dsPlYRmA08y9jag8MtcT5rVgYdPf1g==}
+
+ '@types/google.accounts@0.0.18':
+ resolution: {integrity: sha512-yHaPznll97ZnMJlPABHyeiIlLn3u6gQaUjA5k/O9lrrpgFB9VT10CKPLuKM0qTHMl50uXpW5sIcG+utm8jMOHw==}
+
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
@@ -10209,6 +10221,10 @@ snapshots:
'@types/estree@1.0.8': {}
+ '@types/google-one-tap@1.2.7': {}
+
+ '@types/google.accounts@0.0.18': {}
+
'@types/json-schema@7.0.15': {}
'@types/linkify-it@5.0.0': {}
diff --git a/tsconfig.json b/tsconfig.json
index ad632a35..1c97561a 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -14,7 +14,8 @@
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
- "baseUrl": "."
+ "baseUrl": ".",
+ "types": ["google.accounts"]
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]