Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
39c8b8b
chore: 개발환경에서 https 설정
aken-you Aug 27, 2025
44ba882
refactor: redirection url에서 로딩 ui 제거
aken-you Aug 28, 2025
6c161c5
style: 주석 제거
aken-you Aug 28, 2025
0b9066e
refactor: logout mutation 수정
aken-you Aug 28, 2025
769a12f
feat: ApiError 클래스 생성
aken-you Aug 29, 2025
fad8026
refactor: 에러 메세지 제공
aken-you Aug 29, 2025
ed16614
refactor: useLogoutMutation onSuccess에서 socialImageURL 쿠키 삭제
aken-you Aug 30, 2025
1c2d10d
refactor: access token 재갱신
aken-you Aug 31, 2025
f6f92e3
style: prettier 적용
aken-you Aug 31, 2025
48a56f9
refactor: https 설정 삭제
aken-you Sep 3, 2025
235aaf7
refactor: API_BASE_URL 수정
aken-you Sep 3, 2025
7c0a496
refactor: middleware를 통해 비회원 페이지 접근 제한
aken-you Sep 3, 2025
d1be94f
refactor: access token 재갱신 로직 개선 및 대기열 처리 추가
aken-you Sep 3, 2025
af34ca6
refactor: sign-up 페이지에서 access token 체크 로직 추가
aken-you Sep 3, 2025
9aad95f
feat: access token 재갱신
aken-you Sep 3, 2025
3653afe
refactor: 서버 instance에서 refresh token 재갱신 실패하면 login 페이지로 이동
aken-you Sep 4, 2025
1af891e
feat: 이미 이름을 등록한 유저가 sign-up에 접근할 경우, 메인 페이지로 이동
aken-you Sep 4, 2025
5eacf17
refactor: middleware에서 access token 갱신
aken-you Sep 4, 2025
9d06134
refactor: 회원가입 페이지가 아닌 경우 memberId check
aken-you Sep 4, 2025
1cb8bff
style: 주석 추가
aken-you Sep 4, 2025
5542102
refactor: gitignore에 certificates 삭제
aken-you Sep 4, 2025
9d56392
refactor: verifyAccessToken에서 axios를 fetch로 변경
aken-you Sep 5, 2025
7234d61
Merge pull request #142 from code-zero-to-one/feat/error-handling
aken-you Sep 5, 2025
fe02fc6
fix: MultiDropdown의 onPointerDown에서 기본 동작 방지
aken-you Sep 9, 2025
1046ea5
Merge pull request #146 from code-zero-to-one/fix/select-skill
aken-you Sep 9, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 5 additions & 9 deletions app/(my)/my-page/page.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,13 @@
import { redirect } from 'next/navigation';
import { getUserProfile } from '@/entities/user/api/get-user-profile';
import { getUserProfileInServer } from '@/entities/user/api/get-user-profile.server';
import Profile from '@/features/my-page/ui/profile';
import ProfileInfo from '@/features/my-page/ui/profile-info';
import { getLoginUserId } from '@/shared/lib/get-login-user';
import { getServerCookie } from '@/shared/lib/server-cookie';

export default async function MyPage() {
const memberId = await getLoginUserId();
const memberIdStr = await getServerCookie('memberId');
const memberId = Number(memberIdStr);

if (!memberId) {
redirect('/login');
}

const userProfile = await getUserProfile(memberId);
const userProfile = await getUserProfileInServer(memberId);

return (
<div className="flex flex-col gap-[26.67px]">
Expand Down
3 changes: 0 additions & 3 deletions app/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,6 @@ export const metadata: Metadata = {
description: '로그인 페이지',
};

// 랜딩페이지에서 로그인 모달이 뜨는 것으로 파악
export default function LoginPage() {
console.log("NEXT_PUBLIC_API_BASE_URL", process.env.NEXT_PUBLIC_API_BASE_URL)

return <Landing isSignupPage={false} />;
}
8 changes: 0 additions & 8 deletions app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import { Metadata } from 'next';
import { redirect } from 'next/navigation';
import StudyCard from '@/features/study/ui/study-card';
import { getLoginUserId } from '@/shared/lib/get-login-user';
import Banner from '@/widgets/home/banner';
import Sidebar from '@/widgets/home/sidebar';

Expand All @@ -11,12 +9,6 @@ export const metadata: Metadata = {
};

export default async function Home() {
const memberId = await getLoginUserId();

if (!memberId) {
redirect('/login');
}

return (
<div className="container mx-auto flex min-h-screen gap-600 py-600">
<div className="flex flex-1 flex-col gap-500">
Expand Down
4 changes: 2 additions & 2 deletions app/redirection/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,13 @@ function RedirectionContent() {
handleRedirection().catch(console.error);
}, [searchParams, router, queryClient]); // 의존성 추가

return <div>처리중...</div>;
return <></>;
}

// useSearchParams() should be wrapped in a suspense boundary
export default function RedirectionPage() {
return (
<Suspense fallback={<div>로딩중...</div>}>
<Suspense fallback={<></>}>
<RedirectionContent />
</Suspense>
);
Expand Down
3 changes: 0 additions & 3 deletions app/sign-up/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,5 @@ import Landing from '@/features/auth/ui/landing';

// 랜딩페이지에서 회원가입 모달이 뜨는 것으로 파악
export default function SignupPage() {
// TODO : URL 에서 토큰 파싱후
console.log('회원가입 페이지');

return <Landing isSignupPage={true} />;
}
114 changes: 114 additions & 0 deletions middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { getServerCookie } from '@/shared/lib/server-cookie';
import { isNumeric } from '@/shared/lib/validation';
import { isApiError } from '@/shared/tanstack-query/api-error';

const verifyAccessToken = async (accessToken: string) => {
try {
// Access token로 memberId만 반환하는 api
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/v1/auth/me`,
{
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
);

if (!response.ok) {
const errorData = await response.json();

if (isApiError(errorData) && errorData.errorCode === 'AUTH001') {
return { state: 'invalid' };
}

return { state: 'unknownError' };
}

const data: { content: number } = await response.json();

return { state: 'valid', memberId: data.content };
} catch (error) {
return { state: 'unknownError' };
}
};

const refreshAccessToken = async () => {
try {
const refreshToken = await getServerCookie('refresh_token');

const response = await fetch(
`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/v1/auth/access-token/refresh`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
cookie: `refresh_token=${refreshToken}`, // 쿠키를 수동으로 전송
},
},
);

const data: { content: { accessToken: string } } = await response.json();

return data.content.accessToken;
} catch (error) {
return null;
}
};

export async function middleware(request: NextRequest) {
const accessToken = request.cookies.get('accessToken')?.value;
const memberId = request.cookies.get('memberId')?.value;

const hasAccessToken = request.cookies.has('accessToken');
const hasMemberId = request.cookies.has('memberId') && isNumeric(memberId);

if (
!hasAccessToken ||
(request.nextUrl.pathname !== '/sign-up' && !hasMemberId) // 회원가입 페이지가 아닌 경우 memberId 체크 (회원가입 하지 않을 경우, memberId는 null)
) {
const loginUrl = new URL('/login', request.url);

return NextResponse.redirect(loginUrl);
}

// access token 갱신 필요 여부 확인
const verifyResponse = await verifyAccessToken(accessToken);

const response = NextResponse.next();

// access token이 유효하지 않을 경우 -> 갱신
if (verifyResponse.state === 'invalid') {
const newAccessToken = await refreshAccessToken();

if (newAccessToken) {
// 갱신 성공
response.cookies.set('accessToken', newAccessToken, {
secure: true,
sameSite: 'strict',
path: '/',
});
} else {
// 갱신 실패
const loginUrl = new URL('/login', request.url);

return NextResponse.redirect(loginUrl);
}
}

// 이미 회원가입 완료 했는데, sign-up 페이지에 진입할 경우 메인 페이지로 리다이렉트
if (request.nextUrl.pathname === '/sign-up' && hasMemberId) {
const mainUrl = new URL('/', request.url);

return NextResponse.redirect(mainUrl);
}

return response;
}

// middleware가 적용될 경로 설정
export const config = {
matcher: ['/', '/my-page', '/my-study', '/my-study-review', '/sign-up'],
};
10 changes: 10 additions & 0 deletions src/entities/user/api/get-user-profile.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { axiosServerInstance } from '@/shared/tanstack-query/axios.server';
import { GetUserProfileResponse } from './types';

export const getUserProfileInServer = async (
memberId: number,
): Promise<GetUserProfileResponse> => {
const res = await axiosServerInstance.get(`/members/${memberId}/profile`);

return res.data.content;
};
2 changes: 0 additions & 2 deletions src/features/auth/api/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import {
// 회원가입 요청 API
export async function signUp(data: any) {
const res = await axiosInstance.post('/members', data);
console.log('signUp res', res);

return res.data;
}
Expand All @@ -30,7 +29,6 @@ export async function uploadProfileImage(
// 멤버 ID 조회 API
export async function getMemberId() {
const res = await axiosInstance.get(`/auth/me`);
console.log('getMemberId res', res);

return res.data;
}
Expand Down
30 changes: 28 additions & 2 deletions src/features/auth/model/use-auth-mutation.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
// 데이터 변경(Mutation) 을 담당하는 커스텀 훅

import { useMutation } from '@tanstack/react-query';
import { sendGTMEvent } from '@next/third-parties/google';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useRouter } from 'next/navigation';
import { logout, signUp, uploadProfileImage } from '@/features/auth/api/auth';
import { hashValue } from '@/shared/lib/hash';
import { deleteCookie, getCookie } from '@/shared/tanstack-query/cookie';
import { SignUpResponse } from './types';

// 회원가입 요청 커스텀 훅
Expand All @@ -27,7 +31,29 @@ export function useUploadProfileImageMutation() {
}

export const useLogoutMutation = () => {
return useMutation<number, unknown, void>({
const queryClient = useQueryClient();
const router = useRouter();

return useMutation({
mutationFn: logout,
onSuccess: () => {
const memberId = getCookie('memberId');

if (memberId)
sendGTMEvent({
event: 'custom_member_logout',
dl_timestamp: new Date().toISOString(),
dl_member_id: hashValue(memberId),
});

deleteCookie('accessToken');
deleteCookie('memberId');
deleteCookie('socialImageURL');

queryClient.clear();

router.push('/login');
router.refresh();
},
});
};
33 changes: 3 additions & 30 deletions src/features/auth/ui/header-user-dropdown.tsx
Original file line number Diff line number Diff line change
@@ -1,48 +1,21 @@
'use client';

import { sendGTMEvent } from '@next/third-parties/google';
import { useQueryClient } from '@tanstack/react-query';
import { useRouter } from 'next/navigation';
import { hashValue } from '@/shared/lib/hash';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/shared/shadcn/ui/dropdown-menu';
import { deleteCookie, getCookie } from '@/shared/tanstack-query/cookie';
import UserAvatar from '@/shared/ui/avatar';
import { logout } from '../api/auth';
import { useLogoutMutation } from '../model/use-auth-mutation';

export default function HeaderUserDropdown({ userImg }: { userImg: string }) {
const queryClient = useQueryClient();
const { mutateAsync: logout } = useLogoutMutation();
const router = useRouter();

const handleLogout = async () => {
try {
// 1. 서버에 로그아웃 요청 (refresh token 삭제)
await logout();

const memberId = getCookie('memberId');
sendGTMEvent({
event: 'custom_member_logout',
dl_timestamp: new Date().toISOString(),
dl_member_id: hashValue(memberId),
});

// 2. 클라이언트의 access token 삭제
deleteCookie('accessToken');
deleteCookie('memberId');

// 3. React Query 캐시 초기화
queryClient.clear();

// 4. 홈으로 리다이렉트
router.push('/');
router.refresh(); // 전체 페이지 리프레시
} catch (error) {
console.error('로그아웃 실패:', error);
}
await logout();
};

return (
Expand Down
6 changes: 1 addition & 5 deletions src/features/auth/ui/login-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,7 @@ export default function LoginModal({
return <></>;
}

const isLocal = origin.includes('localhost') || origin.includes('127.0.0.1'); // 로컬환경 테스트용

const API_BASE_URL = isLocal
? 'https://test-api.zeroone.it.kr'
: process.env.NEXT_PUBLIC_API_BASE_URL;
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL;

const KAKAO_CLIENT_ID = process.env.NEXT_PUBLIC_KAKAO_CLIENT_ID;
const GOOGLE_CLIENT_ID = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID;
Expand Down
4 changes: 2 additions & 2 deletions src/shared/lib/server-cookie.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@ export const getServerCookie = async (
return value ?? undefined;
};

export const setServerCookie = async(
export const setServerCookie = async (
name: string,
value: string,
options: { path?: string } = {},
): Promise<void> => {
const cookieStore = await cookies();
cookieStore.set(name, value, { path: '/', ...options });
};
};
3 changes: 3 additions & 0 deletions src/shared/lib/validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function isNumeric(str: string) {
return /^\d+$/.test(str);
}
40 changes: 40 additions & 0 deletions src/shared/tanstack-query/api-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
class ApiError extends Error {
public name = 'ApiError';
public statusCode: number;
public errorCode: string;
public errorName: string;
public message: string;

constructor({
statusCode,
errorCode,
errorName,
message,
}: {
statusCode: number;
errorCode: string;
errorName: string;
message: string;
}) {
super(message);
this.statusCode = statusCode;
this.errorCode = errorCode;
this.errorName = errorName;
this.message = message;
}
}

// API 에러인지 확인하는 함수
const isApiError = (error: unknown): error is ApiError => {
return (
error instanceof ApiError ||
(typeof error === 'object' &&
error !== null &&
'statusCode' in error &&
'errorCode' in error &&
'errorName' in error &&
'message' in error)
);
};

export { ApiError, isApiError };
Loading