Skip to content
2 changes: 1 addition & 1 deletion next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const nextConfig: NextConfig = {
reactStrictMode: true,
// 빌드 환경에서 console.log 제거
compiler: {
removeConsole: process.env.NODE_ENV === 'production',
// removeConsole: process.env.NODE_ENV === 'production',
},
images: {
remotePatterns: [
Expand Down
2 changes: 1 addition & 1 deletion src/app/(app)/error.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export default function SegmentError({ error, reset }: ErrorPageProps) {
}, [error]);

return (
<div className='flex-col-center gap-8 bg-white py-50 text-center text-gray-800'>
<div className='flex-col-center min-h-screen gap-8 bg-white py-50 text-center text-gray-800'>
<h2 className='font-size-20 tablet:font-size-30 flex-center font-bold'>
<TriangleAlert className='mr-8 size-30' />
콘텐츠를 불러오지 못했습니다.
Expand Down
16 changes: 16 additions & 0 deletions src/app/(auth)/kakao/transition/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
'use client';
import dynamic from 'next/dynamic';
import { Suspense } from 'react';

const KakaoTransition = dynamic(
() => import('@/domain/Auth/components/KakaoTransition'),
{ ssr: false }, // 클라이언트 전용 렌더링
);

export default function Page() {
return (
<Suspense fallback={null}>
<KakaoTransition />
</Suspense>
);
}
2 changes: 1 addition & 1 deletion src/app/(auth)/signin/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export default function SignInPage() {
</span>
</div>

<OAuth pageType='signin' />
<OAuth />

<div className='flex justify-center gap-4'>
<span className='text-gray-400'>아직 계정이 없으신가요?</span>
Expand Down
2 changes: 1 addition & 1 deletion src/app/(auth)/signup/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export default function SignUpPage() {
</span>
</div>

<OAuth pageType='signup' />
<OAuth />

<div className='flex justify-center gap-4'>
<span className='text-gray-400'>이미 회원이신가요?</span>
Expand Down
120 changes: 120 additions & 0 deletions src/app/api/auth/kakao/signin/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { NextRequest, NextResponse } from 'next/server';

import {
OAuthResponse,
oauthResponseSchema,
} from '@/domain/Auth/schemas/response';
import setAuthCookies from '@/domain/Auth/utils/setAuthCookies';
import { API_ENDPOINTS } from '@/shared/constants/endpoints';
import { ERROR_CODES, ROUTES } from '@/shared/constants/routes';

/**
* @function handleOauthSignIn
* @description
* 백엔드에 카카오 인가 코드를 전달하여 로그인 시도 후 사용자 정보를 반환합니다.
* 실패 시 에러 객체에 HTTP 상태 코드를 포함하여 던집니다.
*
* @param kakaoAuthCode - 카카오에서 발급받은 인가 코드
* @returns 유효성 검증된 OAuth 로그인 응답 데이터
* @throws 로그인 실패 또는 응답 형식이 올바르지 않을 경우 오류
*/
async function handleOauthSignIn(
kakaoAuthCode: string,
): Promise<OAuthResponse> {
const redirectUri = process.env.NEXT_PUBLIC_KAKAO_SIGNIN_REDIRECT_URI;
if (!redirectUri) {
throw new Error(
'NEXT_PUBLIC_KAKAO_SIGNIN_REDIRECT_URI가 설정되지 않았습니다.',
);
}

const signInRes = await fetch(
`${process.env.API_BASE_URL}${API_ENDPOINTS.OAUTH.SIGNIN_PROVIDER('kakao')}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: kakaoAuthCode,
redirectUri,
}),
},
);

const responseData = await signInRes.json();
if (!signInRes.ok) {
throw Object.assign(
new Error(responseData.message || '로그인에 실패했습니다.'),
{
status: signInRes.status,
},
);
}
return oauthResponseSchema.parse(responseData);
}

/**
* @function GET
* @description
* 카카오 OAuth 로그인 콜백을 처리하는 라우트입니다.
*
* 1. 인가 코드로 백엔드에 로그인 요청
* 2. 성공 시 토큰 쿠키 저장 및 메인 페이지로 이동
* 3. 실패 시 에러 코드에 따라 적절한 리디렉션 처리
*
* ### 에러별 리디렉션 전략:
* - 403 또는 404 → `/kakao/transition?status=need-signup`
* - 그 외 → `/signin?error=...&message=...`
*
* @param request - Next.js 서버 요청 객체
* @returns 리디렉션 응답
*/
export async function GET(request: NextRequest) {
try {
const code = request.nextUrl.searchParams.get('code');

if (!code) {
throw Object.assign(new Error('카카오 인증 코드가 없습니다.'), {
status: 400,
});
}

const responseData = await handleOauthSignIn(code);
const response = NextResponse.redirect(
new URL(ROUTES.ACTIVITIES.ROOT, request.url),
);
setAuthCookies(response, {
accessToken: responseData.accessToken,
refreshToken: responseData.refreshToken,
});
return response;
} catch (error: unknown) {
console.error('[Kakao Signin Error]:', error);

const errorStatus =
error instanceof Error && 'status' in error
? (error as Error & { status: number }).status
: undefined;

if (errorStatus === 404 || errorStatus === 403) {
const redirectToTransition = new URL(
'/kakao/transition',
request.nextUrl.origin,
);
redirectToTransition.searchParams.set('status', 'need-signup');
redirectToTransition.searchParams.set(
'message',
error instanceof Error ? error.message : '회원가입 먼저 해주세요.',
);
return NextResponse.redirect(redirectToTransition);
}

const defaultErrorUrl = new URL(
`${ROUTES.SIGNIN}?error=${ERROR_CODES.OAUTH_KAKAO_FAILED}`,
request.url,
);
if (error instanceof Error) {
defaultErrorUrl.searchParams.append('message', error.message);
}
return NextResponse.redirect(defaultErrorUrl);
}
}
130 changes: 130 additions & 0 deletions src/app/api/auth/kakao/signup/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { NextRequest, NextResponse } from 'next/server';

import {
OAuthResponse,
oauthResponseSchema,
} from '@/domain/Auth/schemas/response';
import setAuthCookies from '@/domain/Auth/utils/setAuthCookies';
import { API_ENDPOINTS } from '@/shared/constants/endpoints';
import { ERROR_CODES, ROUTES } from '@/shared/constants/routes';

/**
* @function handleOauthSignUp
* @description
* 백엔드에 카카오 인가 코드와 랜덤 닉네임을 전달하여 회원가입을 시도합니다.
* 실패 시 HTTP 상태 코드와 함께 오류를 던집니다.
*
* @param kakaoAuthCode - 카카오로부터 받은 인가 코드
* @param nickname - 자동 생성된 임의 닉네임
* @returns 백엔드 응답 (accessToken, refreshToken 등 포함)
* @throws 백엔드 API 실패 또는 응답 스키마 불일치 시 에러 발생
*/
async function handleOauthSignUp(
kakaoAuthCode: string,
nickname: string,
): Promise<OAuthResponse> {
const redirectUri = process.env.NEXT_PUBLIC_KAKAO_SIGNUP_REDIRECT_URI;
if (!redirectUri) {
throw new Error(
'NEXT_PUBLIC_KAKAO_SIGNUP_REDIRECT_URI가 설정되지 않았습니다.',
);
}

const signUpResponse = await fetch(
`${process.env.API_BASE_URL}${API_ENDPOINTS.OAUTH.SIGNUP_PROVIDER('kakao')}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: kakaoAuthCode,
nickname: nickname,
redirectUri,
}),
},
);

const responseData = await signUpResponse.json();
if (!signUpResponse.ok) {
throw Object.assign(
new Error(responseData.message || '회원가입에 실패했습니다.'),
{
status: signUpResponse.status,
},
);
}
return oauthResponseSchema.parse(responseData);
}

/**
* @function GET
* @description
* 카카오 OAuth 회원가입 콜백을 처리하는 라우트입니다.
*
* 1. 인가 코드(code)를 쿼리에서 추출
* 2. 자동 생성된 랜덤 닉네임으로 회원가입 시도
* 3. 성공 시 토큰을 쿠키에 저장하고 `/activities`로 이동
* 4. 실패 시 상태에 따라 리디렉션 분기
*
* ### 리디렉션 분기
* - 409 또는 400: 이미 가입된 사용자 → `/kakao/transition?status=already-exists`
* - 기타 오류: `/signup?error=...&message=...` 으로 리디렉션 (토스트 처리용)
*
* @param request - Next.js GET 요청 객체
* @returns 리디렉션 응답
*/
export async function GET(request: NextRequest) {
try {
const code = request.nextUrl.searchParams.get('code');

if (!code) {
throw Object.assign(new Error('카카오 인증 코드가 없습니다.'), {
status: 400,
});
}

const arbitraryNickname = `K_${crypto.randomUUID().replace(/-/g, '').slice(0, 7)}`;
const responseData = await handleOauthSignUp(code, arbitraryNickname);

const response = NextResponse.redirect(
new URL(ROUTES.ACTIVITIES.ROOT, request.url),
);

setAuthCookies(response, {
accessToken: responseData.accessToken,
refreshToken: responseData.refreshToken,
});

return response;
} catch (error: unknown) {
console.error('[Kakao Signup Error]:', error);

// const errorStatus =
// error instanceof Error && 'status' in error
// ? (error as Error & { status: number }).status
// : undefined;

// if (errorStatus === 409 || errorStatus === 400) {
// const redirectToTransition = new URL(
// '/kakao/transition',
// request.nextUrl.origin,
// );
// redirectToTransition.searchParams.set('status', 'already-exists');
// redirectToTransition.searchParams.set(
// 'message',
// error instanceof Error
// ? error.message
// : '이미 가입된 회원입니다. 로그인해주세요.',
// );
// return NextResponse.redirect(redirectToTransition);
// }

const defaultErrorUrl = new URL(
`${ROUTES.SIGNUP}?error=${ERROR_CODES.OAUTH_KAKAO_FAILED}`,
request.url,
);
if (error instanceof Error) {
defaultErrorUrl.searchParams.append('message', error.message);
}
return NextResponse.redirect(defaultErrorUrl);
}
}
3 changes: 3 additions & 0 deletions src/app/api/auth/signin/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { signinRequestSchema } from '@/domain/Auth/schemas/request';
import handleSignin from '@/domain/Auth/utils/handleSignin';
import { handleApiError } from '@/shared/utils/errors/handleApiError';

export const dynamic = 'force-dynamic';

/**
* @file /api/auth/signin/route.ts
* @description 클라이언트의 로그인 요청을 BFF(Backend for Frontend) 서버에서 처리하고, 성공 시 인증 쿠키를 발급하는 API 라우트 핸들러입니다.
Expand Down Expand Up @@ -37,6 +39,7 @@ import { handleApiError } from '@/shared/utils/errors/handleApiError';
* @see /src/app/(auth)/signin/page.tsx - 이 API를 호출하는 클라이언트 페이지
* @see /src/shared/constants/endpoints.ts - `API_ENDPOINTS` 상수 정의
*/

export async function POST(request: NextRequest) {
try {
const body = await request.json();
Expand Down
2 changes: 1 addition & 1 deletion src/app/api/test/error/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export async function GET(request: NextRequest) {
);
case '409':
return NextResponse.json(
{ message: '이미 사용 중인 이름입니다.' },
{ message: '이미 사용 중인 이메일입니다.' },
{ status: 409 },
);
case '500':
Expand Down
2 changes: 1 addition & 1 deletion src/app/error.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export default function GlobalError({ error, reset }: ErrorPageProps) {
}, [error]);

return (
<div className='flex-col-center gap-8 bg-red-50 p-50 text-center text-red-800'>
<div className='flex-col-center min-h-screen gap-8 bg-red-50 p-50 text-center text-red-800'>
<h1 className='font-size-25 tablet:font-size-30 flex-center font-bold'>
<ErrorIcon className='mr-8 size-30' />
문제가 발생했습니다.
Expand Down
Loading