Skip to content
Merged
58 changes: 58 additions & 0 deletions src/api/oauth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// src/api/oauth.ts
import { apiFetch } from '@/config/client';
import { OAuthAuthResponse } from '@/types/oauth';

type OAuthProvider = 'kakao';

type OAuthSignInParams = {
provider: OAuthProvider;
redirectUri: string;
token: string;
};

type OAuthSignUpParams = {
provider: OAuthProvider;
redirectUri: string;
token: string;
nickname: string;
};

/**
* OAuth 로그인
* POST /oauth/sign-in/{provider}
*/
export function oauthSignIn({
provider,
redirectUri,
token,
}: OAuthSignInParams) {
return apiFetch<OAuthAuthResponse>(`/oauth/sign-in/${provider}`, {
method: 'POST',
body: {
redirectUri,
token,
},
skipAuthRefresh: true,
});
}

/**
* OAuth 회원가입
* POST /oauth/sign-up/{provider}
*/
export function oauthSignUp({
provider,
redirectUri,
token,
nickname,
}: OAuthSignUpParams) {
return apiFetch<OAuthAuthResponse>(`/oauth/sign-up/${provider}`, {
method: 'POST',
body: {
redirectUri,
token,
nickname,
},
skipAuthRefresh: true,
});
}
20 changes: 19 additions & 1 deletion src/app/(auth)/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { login } from '@/api/auth';
import kakaoLogo from '@/assets/icons/auth/ic-kakao.svg';
import Button from '@/components/Button';
import { TextInput, PasswordInput } from '@/components/Input';
import { KAKAO_REST_API_KEY, KAKAO_REDIRECT_URI } from '@/config/oauth';
import { useGuestOnly } from '@/hooks/useGuestOnly';
import { validateEmail, validatePassword } from '@/util/validations';

Expand All @@ -19,6 +20,18 @@ export default function LoginPage() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');

const handleKakaoLogin = () => {
const url =
'https://kauth.kakao.com/oauth/authorize' +
`?client_id=${KAKAO_REST_API_KEY}` +
`&redirect_uri=${encodeURIComponent(KAKAO_REDIRECT_URI)}` +
'&response_type=code' +
'&state=login' +
'&prompt=login';

window.location.href = url;
};
Comment on lines +23 to +33
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

카카오 로그인 URL을 생성할 때 문자열을 직접 더하는 방식보다 URLSearchParams 객체를 사용하면 가독성과 유지보수성이 향상됩니다. 파라미터를 객체 형태로 관리할 수 있어 실수를 줄일 수 있습니다.

Suggested change
const handleKakaoLogin = () => {
const url =
'https://kauth.kakao.com/oauth/authorize' +
`?client_id=${KAKAO_REST_API_KEY}` +
`&redirect_uri=${encodeURIComponent(KAKAO_REDIRECT_URI)}` +
'&response_type=code' +
'&state=login';
window.location.href = url;
};
const handleKakaoLogin = () => {
const params = new URLSearchParams({
client_id: KAKAO_REST_API_KEY,
redirect_uri: KAKAO_REDIRECT_URI,
response_type: 'code',
state: 'login',
});
const url = `https://kauth.kakao.com/oauth/authorize?${params.toString()}`;
window.location.href = url;
};


useEffect(() => {
const savedEmail = sessionStorage.getItem('signupEmail');

Expand Down Expand Up @@ -126,7 +139,12 @@ export default function LoginPage() {
<div className="h-px flex-1 bg-gray-100" />
</div>

<Button type="button" size="lg" variant="secondary" className="w-full">
<Button
type="button"
size="lg"
variant="secondary"
className="w-full"
onClick={handleKakaoLogin}>
<Image src={kakaoLogo} alt="카카오로고" width={24} height={24} />
카카오 로그인
</Button>
Expand Down
31 changes: 26 additions & 5 deletions src/app/(auth)/signup/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { signup } from '@/api/users';
import kakaoLogo from '@/assets/icons/auth/ic-kakao.svg';
import Button from '@/components/Button';
import { TextInput, PasswordInput } from '@/components/Input';
import { useToast } from '@/components/toast/useToast';
import { KAKAO_REST_API_KEY, KAKAO_REDIRECT_URI } from '@/config/oauth';
import { useGuestOnly } from '@/hooks/useGuestOnly';
import {
validateEmail,
Expand All @@ -35,6 +37,19 @@ export default function SignupPage() {
});

const router = useRouter();
const toast = useToast();

const handleKakaoSignup = () => {
const url =
'https://kauth.kakao.com/oauth/authorize' +
`?client_id=${KAKAO_REST_API_KEY}` +
`&redirect_uri=${encodeURIComponent(KAKAO_REDIRECT_URI)}` +
'&response_type=code' +
'&state=signup' +
'&prompt=login';

window.location.href = url;
};
Comment on lines +42 to +52
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

카카오 회원가입 URL을 생성할 때 문자열을 직접 더하는 방식보다 URLSearchParams 객체를 사용하면 가독성과 유지보수성이 향상됩니다.

Suggested change
const handleKakaoSignup = () => {
const url =
'https://kauth.kakao.com/oauth/authorize' +
`?client_id=${KAKAO_REST_API_KEY}` +
`&redirect_uri=${encodeURIComponent(KAKAO_REDIRECT_URI)}` +
'&response_type=code' +
'&state=signup';
window.location.href = url;
};
const handleKakaoSignup = () => {
const params = new URLSearchParams({
client_id: KAKAO_REST_API_KEY,
redirect_uri: KAKAO_REDIRECT_URI,
response_type: 'code',
state: 'signup',
});
const url = `https://kauth.kakao.com/oauth/authorize?${params.toString()}`;
window.location.href = url;
};


const handleChange = (key: keyof typeof form) => (value: string) => {
setForm((prev) => ({
Expand Down Expand Up @@ -71,36 +86,37 @@ export default function SignupPage() {
password: form.password,
nickname: form.nickname,
});
//TODO toast 팝업으로 교체
alert('회원가입이 완료되었습니다!');
toast.success('회원가입이 완료되었습니다!');
sessionStorage.setItem('signupEmail', form.email);

router.push('/login');
} catch (error) {
//TODO 에러처리
if (error instanceof Error) {
if (error.message.includes('이메일')) {
setErrors((prev) => ({
...prev,
email: error.message,
}));
toast.error(error.message);
} else if (error.message.includes('닉네임')) {
setErrors((prev) => ({
...prev,
nickname: error.message,
}));
toast.error(error.message);
} else {
setErrors((prev) => ({
...prev,
password: error.message,
}));
toast.error(error.message);
}
Comment on lines 94 to 112
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

에러 메시지 문자열(error.message.includes('이메일'))에 의존하여 분기하는 로직은 불안정합니다. API의 에러 메시지가 변경되면 로직이 깨질 수 있습니다. GENERAL_RULES에 따라, API 응답에 에러 코드나 에러 필드명을 포함하여 명시적으로 처리하는 것이 훨씬 안정적이고 유지보수하기 좋습니다. 예를 들어, 백엔드에서 { "code": "DUPLICATE_EMAIL", "field": "email", "message": "..." }와 같은 형식으로 응답을 내려주면 클라이언트에서 더 견고하게 처리할 수 있습니다.

References
  1. For robust error handling, use API error codes or dedicated field names for branching logic instead of relying on parsing error message strings.

} else {
// 예상치 못한 에러
setErrors((prev) => ({
...prev,
password: '알 수 없는 오류가 발생했습니다.',
}));
Comment on lines 115 to 118
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

예상치 못한 에러가 발생했을 때, 에러 메시지를 password 필드에 할당하고 있습니다. 이 에러는 비밀번호와 관련이 없을 수 있으므로, 특정 필드에 할당하는 것은 사용자에게 혼란을 줄 수 있습니다. GENERAL_RULES에 따라, 이와 같은 일반적인 에러는 토스트 메시지로만 알리고 특정 입력 필드와 연결하지 않는 것이 좋습니다. setErrors 호출을 제거하는 것을 제안합니다.

References
  1. Handle generic, non-field-specific API errors with a global mechanism like a toast notification, rather than assigning the error to an arbitrary input field.

toast.error('알 수 없는 오류가 발생했습니다.');
}
Comment on lines 93 to 120
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

API 에러 메시지 문자열('이메일', '닉네임')을 확인하여 분기하는 방식은 불안정합니다. 백엔드에서 에러 메시지가 변경되면 이 로직이 깨질 수 있습니다.
더 안정적인 에러 처리를 위해 백엔드에서 에러 코드나 에러 필드명을 포함한 구조화된 에러 객체를 반환하도록 협의하고, 클라이언트에서는 이를 기반으로 분기하는 것이 좋습니다.

예시:

{
  "code": "DUPLICATE_EMAIL",
  "message": "이미 사용 중인 이메일입니다.",
  "field": "email"
}
References
  1. For robust error handling, use API error codes or dedicated field names for branching logic instead of relying on parsing error message strings.

}
};
Expand Down Expand Up @@ -199,7 +215,12 @@ export default function SignupPage() {
<div className="h-px flex-1 bg-gray-100" />
</div>

<Button type="button" size="lg" variant="secondary" className="w-full">
<Button
type="button"
size="lg"
variant="secondary"
className="w-full"
onClick={handleKakaoSignup}>
<Image src={kakaoLogo} alt="카카오로고" width={24} height={24} />
카카오 회원가입
</Button>
Expand Down
14 changes: 8 additions & 6 deletions src/app/(common)/(mypage)/mypage/hooks/useMyPageForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { createUpdatePayload, isUnauthorizedError } from './useMypageFormUtils';
import { validateForm } from './useMyPageFormValidators';
import { useGetMyInfo, useUpdateMyInfo } from './useUser';

import { useToast } from '@/components/toast/useToast';
import { getApiErrorMessage } from '@/util/error';

/**
Expand All @@ -16,6 +17,7 @@ import { getApiErrorMessage } from '@/util/error';
*/
export function useMyPageForm() {
const router = useRouter();
const toast = useToast();

// React Query: 사용자 정보 조회
const {
Expand Down Expand Up @@ -59,8 +61,8 @@ export function useMyPageForm() {
if (fetchError) {
console.error('사용자 정보 로딩 실패:', fetchError);
if (isUnauthorizedError(fetchError)) {
alert('로그인이 필요합니다.');
router.push('/signin');
toast.warning('로그인이 필요합니다.');
router.push('/login');
}
}
}, [fetchError, router]);
Expand Down Expand Up @@ -102,7 +104,7 @@ export function useMyPageForm() {
try {
const payload = createUpdatePayload(formData);
await updateProfile(payload);
alert('저장되었습니다.');
toast.success('저장되었습니다!');

// 비밀번호 필드 초기화
setFormData((prev) => ({
Expand All @@ -113,12 +115,12 @@ export function useMyPageForm() {
} catch (error: unknown) {
console.error('저장 실패:', error);
if (isUnauthorizedError(error)) {
alert('로그인이 필요합니다.');
router.push('/signin');
toast.warning('로그인이 필요합니다.');
router.push('/login');
return;
}
const errorMessage = getApiErrorMessage(error, '저장에 실패했습니다.');
alert(errorMessage);
toast.error(errorMessage);
}
};

Expand Down
7 changes: 7 additions & 0 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ export default function RootLayout({
}) {
return (
<html lang="ko" className={notoSansKR.variable}>
<head>
{/* kakao logout SDK */}
<Script
src="https://developers.kakao.com/sdk/js/kakao.min.js"
strategy="beforeInteractive"
/>
</head>
<body className={notoSansKR.className}>
<QueryProvider>
<ModalProvider>
Expand Down
94 changes: 94 additions & 0 deletions src/app/oauth/kakao/KakaoCallbackContent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
'use client';

import Image from 'next/image';
import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useRef } from 'react';

import { oauthSignIn, oauthSignUp } from '@/api/oauth';
import kakaoLogo from '@/assets/icons/auth/ic-kakao.svg';
import { useToast } from '@/components/toast/useToast';
import { KAKAO_REDIRECT_URI } from '@/config/oauth';

// 랜덤 5자리 문자열 생성 함수
function generateRandomNickname(length = 5) {
const chars =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}

export default function KakaoCallbackContent() {
const router = useRouter();
const searchParams = useSearchParams();
const hasRequested = useRef(false);
const toast = useToast();

useEffect(() => {
const code = searchParams.get('code');
const state = searchParams.get('state');

if (!code || !state) {
router.replace('/login');
return;
}

if (hasRequested.current) return;
hasRequested.current = true;

window.history.replaceState({}, '', '/oauth/kakao');

const processOAuth = async () => {
try {
let res;
if (state === 'signup') {
res = await oauthSignUp({
provider: 'kakao',
nickname: generateRandomNickname(),
redirectUri: KAKAO_REDIRECT_URI,
token: code,
});
} else {
res = await oauthSignIn({
provider: 'kakao',
redirectUri: KAKAO_REDIRECT_URI,
token: code,
});
}

localStorage.setItem('accessToken', res.accessToken);
localStorage.setItem('refreshToken', res.refreshToken);
Comment on lines +61 to +62
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-critical critical

localStorage에 토큰을 저장하는 것은 XSS(Cross-Site Scripting) 공격에 취약합니다. 보안 강화를 위해 리프레시 토큰은 httpOnly 쿠키에, 액세스 토큰은 메모리에 저장하는 것이 좋습니다. 이는 저장소에 직접 접근하는 스크립팅 공격으로부터 토큰을 보호합니다.

References
  1. Store refresh tokens in httpOnly cookies and access tokens in memory to mitigate XSS risks, instead of using localStorage.


router.replace('/');
} catch (e) {
console.error('카카오 인증 실패', e);
if (e instanceof Error) {
if (state === 'signup' && e.message.includes('이미')) {
toast.warning('이미 가입된 계정입니다. 로그인을 진행해 주세요.');
router.replace('/login');
} else if (state === 'login' && e.message.includes('않은')) {
toast.warning(
'가입되지 않은 계정입니다. 회원가입을 진행해 주세요.'
);
router.replace('/signup');
} else {
toast.error('인증에 실패했습니다. 다시 시도해주세요.');
router.replace(state === 'signup' ? '/signup' : '/login');
}
Comment on lines +67 to +79
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

회원가입/로그인 실패 시 에러 메시지 문자열(e.message.includes('이미'))을 확인하여 분기하고 있습니다. 이 방식은 API의 에러 메시지가 변경될 경우 로직이 깨질 위험이 있습니다. GENERAL_RULES에서 권장하는 것처럼, 백엔드 API에서 명시적인 에러 코드(예: ALREADY_REGISTERED_USER, USER_NOT_FOUND)를 반환하도록 하여, 클라이언트에서는 이 코드를 기반으로 분기하는 것이 더 안정적입니다.

References
  1. For robust error handling, use API error codes or dedicated field names for branching logic instead of relying on parsing error message strings.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/gemini
console.log('=== OAuth 에러 디버깅 ===');
console.log('에러 객체:', e);
console.log('에러 타입:', e?.constructor?.name);
console.log('에러 메시지:', e instanceof Error ? e.message : '');
console.log('상태 코드:', (e as any)?.statusCode);
console.log('상세 정보:', (e as any)?.details);

이렇게 테스트 해봤을때 아래처럼 떠서 글자 기준으로 분기했어 확인해줘

page.tsx:55_ 카카오 인증 실패 ApiError: 이미 등록된 사용자입니다. at apiFetch (client.ts:151:11) at async KakaoCallbackPage.useEffect.processOAuth (page.tsx:36:17)
page.tsx:58 === OAuth 에러 디버깅 ===
page.tsx:59 에러 객체: ApiError: 이미 등록된 사용자입니다. at apiFetch (client.ts:151:11) at async KakaoCallbackPage.useEffect.processOAuth (page.tsx:36:17)
page.tsx:60 에러 타입: ApiError
page.tsx:61 에러 메시지: 이미 등록된 사용자입니다.
page.tsx:62 상태 코드: undefined
page.tsx:63 상세 정보: undefined

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

eunji0124님, 디버깅 결과를 공유해주셔서 감사합니다. ApiError 객체에 statusCodedetails와 같은 명시적인 에러 코드가 포함되어 있지 않아 현재로서는 에러 메시지 문자열을 통해 분기하는 방식이 불가피하다는 점을 이해했습니다.

하지만 여전히 이 방식은 백엔드에서 에러 메시지 내용이 변경될 경우 클라이언트 로직이 오작동할 수 있는 위험을 내포하고 있습니다. 예를 들어, '이미 등록된 사용자입니다.'라는 메시지가 '이미 가입된 회원입니다.' 등으로 변경되면 현재 includes('이미') 로직은 더 이상 유효하지 않게 됩니다.

장기적인 관점에서 더 안정적이고 유지보수하기 쉬운 에러 처리를 위해서는 백엔드 API에서 ALREADY_REGISTERED_USER와 같은 명확한 에러 코드를 응답으로 제공하도록 개선하는 것을 강력히 권장합니다. 이를 통해 클라이언트에서는 문자열 비교 대신 에러 코드를 기반으로 정확하게 에러 상황을 판단하고 적절한 사용자 경험을 제공할 수 있습니다.

현재 상황에서는 이 로직이 최선일 수 있으나, 백엔드 팀과 협의하여 에러 응답 구조를 개선하는 방안을 고려해보시는 것이 좋겠습니다.

}
}
};

processOAuth();
}, [router, searchParams]);

return (
<div className="flex min-h-screen flex-col items-center justify-center">
<Image src={kakaoLogo} alt="카카오로고" width={100} height={100} />
<p className="title-lg mt-5">카카오 인증 처리 중...</p>
<p className="title-sm mt-3">잠시만 기다려주세요</p>
</div>
);
}
22 changes: 22 additions & 0 deletions src/app/oauth/kakao/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
'use client';

import Image from 'next/image';
import { Suspense } from 'react';

import KakaoCallbackContent from './KakaoCallbackContent';

import kakaoLogo from '@/assets/icons/auth/ic-kakao.svg';

export default function KakaoCallbackPage() {
return (
<Suspense
fallback={
<div className="flex min-h-screen flex-col items-center justify-center">
<Image src={kakaoLogo} alt="카카오로고" width={100} height={100} />
<p className="title-lg mt-5">카카오 인증 처리 중...</p>
</div>
}>
<KakaoCallbackContent />
</Suspense>
);
}
5 changes: 5 additions & 0 deletions src/components/Header/HeaderAuth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ export default function HeaderAuth() {

const handleLogout = () => {
logout();
if (typeof window !== 'undefined' && window.Kakao?.Auth) {
window.Kakao.Auth.logout(() => {
console.log('카카오 로그아웃 완료');
});
}
router.push('/login');
};

Expand Down
5 changes: 5 additions & 0 deletions src/components/SideMenu/SideMenuNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ export default function SideMenuNav({ onClose }: { onClose?: () => void }) {
const router = useRouter();
const handleLogout = () => {
logout();
if (typeof window !== 'undefined' && window.Kakao?.Auth) {
window.Kakao.Auth.logout(() => {
console.log('카카오 세션 로그아웃 완료');
});
}
router.push('/login');
};
return (
Expand Down
6 changes: 6 additions & 0 deletions src/config/oauth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// src/config/oauth.ts
export const KAKAO_REST_API_KEY = process.env.NEXT_PUBLIC_KAKAO_REST_API_KEY!;

export const KAKAO_REDIRECT_URI = process.env.NEXT_PUBLIC_KAKAO_REDIRECT_URI!;

//redirectUri는 카카오 디벨로퍼 + 백엔드에 등록한 값과 동일해야 함
Loading