From 27d0ca7884e95501289480e9cee77abe8133e483 Mon Sep 17 00:00:00 2001 From: eunwoo-levi Date: Mon, 15 Dec 2025 13:49:15 +0900 Subject: [PATCH 01/14] =?UTF-8?q?chore:=20ky=20=EB=9D=BC=EC=9D=B4=EB=B8=8C?= =?UTF-8?q?=EB=9F=AC=EB=A6=AC=20=EC=84=A4=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + yarn.lock | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/package.json b/package.json index 7da69a7..9704d17 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "@vanilla-extract/recipes": "^0.5.7", "@vanilla-extract/sprinkles": "^1.6.5", "@vanilla-extract/vite-plugin": "^5.1.3", + "ky": "^1.14.1", "lucide-react": "^0.555.0", "next": "15.5.7", "react": "^19.2.1", diff --git a/yarn.lock b/yarn.lock index 39e177a..34f1e81 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4627,6 +4627,7 @@ __metadata: eslint-plugin-storybook: "npm:^9.1.16" globals: "npm:^16.5.0" globrex: "npm:^0.1.2" + ky: "npm:^1.14.1" lucide-react: "npm:^0.555.0" next: "npm:15.5.7" prettier: "npm:^3.7.3" @@ -4754,6 +4755,13 @@ __metadata: languageName: node linkType: hard +"ky@npm:^1.14.1": + version: 1.14.1 + resolution: "ky@npm:1.14.1" + checksum: 10c0/21deb9120170ef1f6c3b80b7980fa2202d56bff9a91344b0102ba9f608068064ba74eff29259b83f68002bdcea18e70bfbd7e044e3a2d7df180656fdccf1f4a0 + languageName: node + linkType: hard + "language-subtag-registry@npm:^0.3.20": version: 0.3.23 resolution: "language-subtag-registry@npm:0.3.23" From 86d39ac34d6281d19c5001602eea90245678e858 Mon Sep 17 00:00:00 2001 From: eunwoo-levi Date: Mon, 15 Dec 2025 14:56:10 +0900 Subject: [PATCH 02/14] =?UTF-8?q?feat:=20ky=20create=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=ED=95=98=EC=97=AC=20api=20Client=20=EB=B0=8F=20Server=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/shared/lib/apiClient.ts | 6 ++++++ src/shared/lib/apiServer.ts | 8 ++++++++ 2 files changed, 14 insertions(+) create mode 100644 src/shared/lib/apiClient.ts create mode 100644 src/shared/lib/apiServer.ts diff --git a/src/shared/lib/apiClient.ts b/src/shared/lib/apiClient.ts new file mode 100644 index 0000000..907871d --- /dev/null +++ b/src/shared/lib/apiClient.ts @@ -0,0 +1,6 @@ +import ky from 'ky'; + +export const apiClient = ky.create({ + prefixUrl: '/api', + credentials: 'include', +}); \ No newline at end of file diff --git a/src/shared/lib/apiServer.ts b/src/shared/lib/apiServer.ts new file mode 100644 index 0000000..19b81b4 --- /dev/null +++ b/src/shared/lib/apiServer.ts @@ -0,0 +1,8 @@ +import ky from 'ky'; + +const API_BASE_URL = process.env.API_BASE_URL; + +export const apiServer = ky.create({ + prefixUrl: API_BASE_URL +}) + From 751cc0383f69ce5926616266ceeaa532ab9b37dc Mon Sep 17 00:00:00 2001 From: eunwoo-levi Date: Mon, 15 Dec 2025 14:57:00 +0900 Subject: [PATCH 03/14] =?UTF-8?q?feat:=20login=20Route=20Handler=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/auth/login/route.ts | 71 +++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 app/api/auth/login/route.ts diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts new file mode 100644 index 0000000..1b56ca6 --- /dev/null +++ b/app/api/auth/login/route.ts @@ -0,0 +1,71 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { HTTPError } from 'ky'; +import { apiServer } from '@/shared/lib/apiServer'; + +interface LoginSuccessResponse { + status: string; + message: string; + data: { + accessToken: string; + refreshToken: string; + }; +} + +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + + const result = await apiServer + .post('auth/login', { json: body }) + .json(); + + const { accessToken, refreshToken } = result.data ?? {}; + + if (!accessToken || !refreshToken) { + return NextResponse.json( + { message: '토큰이 응답에 없습니다.' }, + { status: 502 } + ); + } + + const res = NextResponse.json( + { message: result.message ?? '로그인 성공' }, + { status: 200 } + ); + + const isProd = process.env.NODE_ENV === 'production'; + + res.cookies.set('accessToken', accessToken, { + httpOnly: true, + secure: isProd, + sameSite: 'lax', + path: '/', + maxAge: 60 * 15, // TODO: 15분 (백엔드와 동일하게) + }); + + res.cookies.set('refreshToken', refreshToken, { + httpOnly: true, + secure: isProd, + sameSite: 'lax', + path: '/', + maxAge: 60 * 60 * 24 * 14, // TODO: 14일 (백엔드와 동일하게) + }); + + return res; + } catch (error) { + if (error instanceof HTTPError) { + const status = error.response.status; + const errorData = await error.response.json().catch(() => ({} as any)); + + return NextResponse.json( + { message: errorData.message || '로그인 실패' }, + { status } + ); + } + + return NextResponse.json( + { message: 'An unexpected error occurred' }, + { status: 500 } + ); + } +} From bc445d41fa729c2fcb49f481b247df6059568a6a Mon Sep 17 00:00:00 2001 From: eunwoo-levi Date: Mon, 15 Dec 2025 14:57:07 +0900 Subject: [PATCH 04/14] =?UTF-8?q?feat:=20logout=20Route=20Handler=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/auth/logout/route.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 app/api/auth/logout/route.ts diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts new file mode 100644 index 0000000..061ce58 --- /dev/null +++ b/app/api/auth/logout/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from 'next/server'; + +export async function POST() { + const res = NextResponse.json({ message: '로그아웃 성공' }, { status: 200 }); + + res.cookies.delete('accessToken'); + res.cookies.delete('refreshToken'); + + // TODO: 2-phase 임시 토큰 삭제 (일단 삭제 로직에 포함) + res.cookies.delete('signupToken'); + + return res; +} From 707501867018e7666e19e31f23d915b7b3de30b6 Mon Sep 17 00:00:00 2001 From: eunwoo-levi Date: Mon, 15 Dec 2025 14:57:18 +0900 Subject: [PATCH 05/14] =?UTF-8?q?feat:=20accessToken=20=EB=B0=8F=20refresh?= =?UTF-8?q?Token=20=EC=9E=AC=EB=B0=9C=EA=B8=89=20Route=20Handler=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/auth/refresh/route.ts | 81 +++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 app/api/auth/refresh/route.ts diff --git a/app/api/auth/refresh/route.ts b/app/api/auth/refresh/route.ts new file mode 100644 index 0000000..dd79c34 --- /dev/null +++ b/app/api/auth/refresh/route.ts @@ -0,0 +1,81 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { HTTPError } from 'ky'; +import { apiServer } from '@/shared/lib/apiServer'; + +interface RefreshSuccessResponse { + status: string; + message: string; + data: { + accessToken: string; + refreshToken?: string; + }; +} + +export async function POST(req: NextRequest) { + try { + const refreshToken = req.cookies.get('refreshToken')?.value; + + if (!refreshToken) { + return NextResponse.json( + { message: 'refreshToken이 없습니다.' }, + { status: 401 } + ); + } + + const result = await apiServer + .post('auth/refresh', { json: { refreshToken } }) + .json(); + + const newAccessToken = result.data?.accessToken; + const newRefreshToken = result.data?.refreshToken; + + if (!newAccessToken) { + return NextResponse.json( + { message: 'accessToken이 응답에 없습니다.' }, + { status: 502 } + ); + } + + const res = NextResponse.json( + { message: result.message ?? '토큰 재발급 성공' }, + { status: 200 } + ); + + const isProd = process.env.NODE_ENV === 'production'; + + res.cookies.set('accessToken', newAccessToken, { + httpOnly: true, + secure: isProd, + sameSite: 'lax', + path: '/', + maxAge: 60 * 15, // TODO: 백엔드 만료와 맞추기 + }); + + if (newRefreshToken) { + res.cookies.set('refreshToken', newRefreshToken, { + httpOnly: true, + secure: isProd, + sameSite: 'lax', + path: '/', + maxAge: 60 * 60 * 24 * 14, // TODO: 백엔드 만료와 맞추기 + }); + } + + return res; + } catch (error) { + if (error instanceof HTTPError) { + const status = error.response.status; + const errorData = await error.response.json().catch(() => ({} as any)); + + return NextResponse.json( + { message: errorData.message || '토큰 재발급 실패' }, + { status } + ); + } + + return NextResponse.json( + { message: 'An unexpected error occurred' }, + { status: 500 } + ); + } +} From f1cfb98771f7b0c5663f8359e8a0dd9291a220ea Mon Sep 17 00:00:00 2001 From: eunwoo-levi Date: Mon, 15 Dec 2025 14:57:34 +0900 Subject: [PATCH 06/14] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20phase1,=20phase2=20Route=20Handler=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/auth/signup/phase1/route.ts | 62 ++++++++++++++++++++++ app/api/auth/signup/phase2/route.ts | 79 +++++++++++++++++++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 app/api/auth/signup/phase1/route.ts create mode 100644 app/api/auth/signup/phase2/route.ts diff --git a/app/api/auth/signup/phase1/route.ts b/app/api/auth/signup/phase1/route.ts new file mode 100644 index 0000000..f3fb2d9 --- /dev/null +++ b/app/api/auth/signup/phase1/route.ts @@ -0,0 +1,62 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { HTTPError } from 'ky'; +import { apiServer } from '@/shared/lib/apiServer'; + +interface SignupPhase1Response { + status: string; + message: string; + data: { + signupToken: string; + }; +} + +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + + // TODO: 백엔드 엔드포인트명 맞추기 + const result = await apiServer + .post('auth/signup/phase1', { json: body }) + .json(); + + const signupToken = result.data?.signupToken; + + if (!signupToken) { + return NextResponse.json( + { message: 'signupToken이 응답에 없습니다.' }, + { status: 502 } + ); + } + + const res = NextResponse.json( + { message: result.message ?? '회원가입 phase1 완료' }, + { status: 201 } + ); + + const isProd = process.env.NODE_ENV === 'production'; + + // TODO: signupToken 쿠키에 저장 (추후 백엔드 회원가입 로직에 맞춰 수정 필요) + res.cookies.set('signupToken', signupToken, { + httpOnly: true, + secure: isProd, + sameSite: 'lax', + path: '/', + maxAge: 60 * 10, // TODO: 10분 (백엔드와 동일하게 설정할 필요) + }); + + return res; + } catch (error) { + if (error instanceof HTTPError) { + const status = error.response.status; + const errorData = await error.response.json().catch(() => ({} as any)); + return NextResponse.json( + { message: errorData.message || '회원가입 phase1 실패' }, + { status } + ); + } + return NextResponse.json( + { message: 'An unexpected error occurred' }, + { status: 500 } + ); + } +} diff --git a/app/api/auth/signup/phase2/route.ts b/app/api/auth/signup/phase2/route.ts new file mode 100644 index 0000000..e072f4a --- /dev/null +++ b/app/api/auth/signup/phase2/route.ts @@ -0,0 +1,79 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { HTTPError } from 'ky'; +import { apiServer } from '@/shared/lib/apiServer'; + +interface SignupPhase2Response { + status: string; + message: string; + data: { + accessToken: string; + refreshToken: string; + }; +} + +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + const signupToken = req.cookies.get('signupToken')?.value; + + if (!signupToken) { + return NextResponse.json( + { message: 'signupToken이 없습니다. 1단계를 먼저 진행하세요.' }, + { status: 401 } + ); + } + + // TODO: 백엔드가 signupToken을 받는 방식에 맞춰 수정 + const result = await apiServer.post('auth/signup/phase2', { + json: { ...body, signupToken }, + }).json(); + + const { accessToken, refreshToken } = result.data ?? {}; + if (!accessToken || !refreshToken) { + return NextResponse.json( + { message: '토큰이 응답에 없습니다.' }, + { status: 502 } + ); + } + + const res = NextResponse.json( + { message: result.message ?? '회원가입 완료' }, + { status: 201 } + ); + + const isProd = process.env.NODE_ENV === 'production'; + + res.cookies.set('accessToken', accessToken, { + httpOnly: true, + secure: isProd, + sameSite: 'lax', + path: '/', + maxAge: 60 * 15, // TODO: 15분 (백엔드와 동일하게) + }); + + res.cookies.set('refreshToken', refreshToken, { + httpOnly: true, + secure: isProd, + sameSite: 'lax', + path: '/', + maxAge: 60 * 60 * 24 * 14, // TODO: 14일 (백엔드와 동일하게) + }); + + res.cookies.delete('signupToken'); + + return res; + } catch (error) { + if (error instanceof HTTPError) { + const status = error.response.status; + const errorData = await error.response.json().catch(() => ({} as any)); + return NextResponse.json( + { message: errorData.message || '회원가입 2단계 실패' }, + { status } + ); + } + return NextResponse.json( + { message: 'An unexpected error occurred' }, + { status: 500 } + ); + } +} From 440da102a6054639c3ba37f991ef37511868c6a2 Mon Sep 17 00:00:00 2001 From: eunwoo-levi Date: Mon, 15 Dec 2025 14:58:13 +0900 Subject: [PATCH 07/14] =?UTF-8?q?feat:=20auth=20=EB=A1=9C=EC=A7=81=20middl?= =?UTF-8?q?eware=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- middleware.ts | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 middleware.ts diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 0000000..01b3035 --- /dev/null +++ b/middleware.ts @@ -0,0 +1,37 @@ +import type { NextRequest } from 'next/server'; +import { NextResponse } from 'next/server'; + + +// TODO: PUBLIC 및 PROTECTED 경로들 추후 추가 및 수정 필요 +const PUBLIC_ONLY = ['/login', '/signup']; +const PROTECTED_PREFIXES = ['/mypage']; + +export function middleware(request: NextRequest) { + const { pathname } = request.nextUrl; + + const accessToken = request.cookies.get('accessToken')?.value; + + const isAuthed = Boolean(accessToken); + + if (PUBLIC_ONLY.includes(pathname) && isAuthed) { + return NextResponse.redirect(new URL('/', request.url)); + } + + const isProtected = PROTECTED_PREFIXES.some((prefix) => pathname.startsWith(prefix)); + if (isProtected && !isAuthed) { + const url = new URL('/login', request.url); + url.searchParams.set('next', pathname); + return NextResponse.redirect(url); + } + + return NextResponse.next(); +} + +// TODO: 추후 추가 될 예정, matcher는 "미들웨어가 실행될 경로"만 최소로 걸기 +export const config = { + matcher: [ + '/login', + '/signup', + '/mypage/:path*', + ], +}; From e0242f91ca0ef08d402a09eba872a5f932979a0b Mon Sep 17 00:00:00 2001 From: eunwoo-levi Date: Mon, 15 Dec 2025 15:24:34 +0900 Subject: [PATCH 08/14] =?UTF-8?q?feat:=20error.response=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=EA=B5=AC=EC=B2=B4=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/auth/login/route.ts | 2 +- app/api/auth/refresh/route.ts | 2 +- app/api/auth/signup/phase1/route.ts | 2 +- app/api/auth/signup/phase2/route.ts | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts index 1b56ca6..ade5eae 100644 --- a/app/api/auth/login/route.ts +++ b/app/api/auth/login/route.ts @@ -55,7 +55,7 @@ export async function POST(req: NextRequest) { } catch (error) { if (error instanceof HTTPError) { const status = error.response.status; - const errorData = await error.response.json().catch(() => ({} as any)); + const errorData = await error.response.json().catch(() => ({} as Record)); return NextResponse.json( { message: errorData.message || '로그인 실패' }, diff --git a/app/api/auth/refresh/route.ts b/app/api/auth/refresh/route.ts index dd79c34..d0f2a97 100644 --- a/app/api/auth/refresh/route.ts +++ b/app/api/auth/refresh/route.ts @@ -65,7 +65,7 @@ export async function POST(req: NextRequest) { } catch (error) { if (error instanceof HTTPError) { const status = error.response.status; - const errorData = await error.response.json().catch(() => ({} as any)); + const errorData = await error.response.json().catch(() => ({} as Record)); return NextResponse.json( { message: errorData.message || '토큰 재발급 실패' }, diff --git a/app/api/auth/signup/phase1/route.ts b/app/api/auth/signup/phase1/route.ts index f3fb2d9..63c2397 100644 --- a/app/api/auth/signup/phase1/route.ts +++ b/app/api/auth/signup/phase1/route.ts @@ -48,7 +48,7 @@ export async function POST(req: NextRequest) { } catch (error) { if (error instanceof HTTPError) { const status = error.response.status; - const errorData = await error.response.json().catch(() => ({} as any)); + const errorData = await error.response.json().catch(() => ({} as Record)); return NextResponse.json( { message: errorData.message || '회원가입 phase1 실패' }, { status } diff --git a/app/api/auth/signup/phase2/route.ts b/app/api/auth/signup/phase2/route.ts index e072f4a..e003fde 100644 --- a/app/api/auth/signup/phase2/route.ts +++ b/app/api/auth/signup/phase2/route.ts @@ -65,7 +65,7 @@ export async function POST(req: NextRequest) { } catch (error) { if (error instanceof HTTPError) { const status = error.response.status; - const errorData = await error.response.json().catch(() => ({} as any)); + const errorData = await error.response.json().catch(() => ({} as Record)); return NextResponse.json( { message: errorData.message || '회원가입 2단계 실패' }, { status } From 5173d1682d2ba0ffe54a8806d051e8e3ffaeb72a Mon Sep 17 00:00:00 2001 From: eunwoo-levi Date: Mon, 15 Dec 2025 15:24:52 +0900 Subject: [PATCH 09/14] =?UTF-8?q?build:=20formatting=20=EB=AA=85=EB=A0=B9?= =?UTF-8?q?=EC=96=B4=EB=A5=BC=20script=EC=97=90=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/package.json b/package.json index 9704d17..3cb83d7 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,8 @@ "build": "next build", "start": "next start", "lint": "eslint", + "format": "prettier --write .", + "format:check": "prettier --check .", "type-check": "tsc --noEmit", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build", From 5e21b219142e757db42fb5a74a7174d223107c02 Mon Sep 17 00:00:00 2001 From: eunwoo-levi Date: Mon, 15 Dec 2025 17:05:48 +0900 Subject: [PATCH 10/14] =?UTF-8?q?feat:=20=EB=B0=B1=EC=97=94=EB=93=9C=20?= =?UTF-8?q?=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=EC=97=90=20=EB=94=B0=EB=A5=B8=20signup?= =?UTF-8?q?=EC=9D=84=20=ED=95=98=EB=82=98=EB=A1=9C=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/auth/signup/phase1/route.ts | 62 ---------------------- app/api/auth/signup/phase2/route.ts | 79 ----------------------------- app/api/auth/signup/route.ts | 62 ++++++++++++++++++++++ 3 files changed, 62 insertions(+), 141 deletions(-) delete mode 100644 app/api/auth/signup/phase1/route.ts delete mode 100644 app/api/auth/signup/phase2/route.ts create mode 100644 app/api/auth/signup/route.ts diff --git a/app/api/auth/signup/phase1/route.ts b/app/api/auth/signup/phase1/route.ts deleted file mode 100644 index 63c2397..0000000 --- a/app/api/auth/signup/phase1/route.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { HTTPError } from 'ky'; -import { apiServer } from '@/shared/lib/apiServer'; - -interface SignupPhase1Response { - status: string; - message: string; - data: { - signupToken: string; - }; -} - -export async function POST(req: NextRequest) { - try { - const body = await req.json(); - - // TODO: 백엔드 엔드포인트명 맞추기 - const result = await apiServer - .post('auth/signup/phase1', { json: body }) - .json(); - - const signupToken = result.data?.signupToken; - - if (!signupToken) { - return NextResponse.json( - { message: 'signupToken이 응답에 없습니다.' }, - { status: 502 } - ); - } - - const res = NextResponse.json( - { message: result.message ?? '회원가입 phase1 완료' }, - { status: 201 } - ); - - const isProd = process.env.NODE_ENV === 'production'; - - // TODO: signupToken 쿠키에 저장 (추후 백엔드 회원가입 로직에 맞춰 수정 필요) - res.cookies.set('signupToken', signupToken, { - httpOnly: true, - secure: isProd, - sameSite: 'lax', - path: '/', - maxAge: 60 * 10, // TODO: 10분 (백엔드와 동일하게 설정할 필요) - }); - - return res; - } catch (error) { - if (error instanceof HTTPError) { - const status = error.response.status; - const errorData = await error.response.json().catch(() => ({} as Record)); - return NextResponse.json( - { message: errorData.message || '회원가입 phase1 실패' }, - { status } - ); - } - return NextResponse.json( - { message: 'An unexpected error occurred' }, - { status: 500 } - ); - } -} diff --git a/app/api/auth/signup/phase2/route.ts b/app/api/auth/signup/phase2/route.ts deleted file mode 100644 index e003fde..0000000 --- a/app/api/auth/signup/phase2/route.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { HTTPError } from 'ky'; -import { apiServer } from '@/shared/lib/apiServer'; - -interface SignupPhase2Response { - status: string; - message: string; - data: { - accessToken: string; - refreshToken: string; - }; -} - -export async function POST(req: NextRequest) { - try { - const body = await req.json(); - const signupToken = req.cookies.get('signupToken')?.value; - - if (!signupToken) { - return NextResponse.json( - { message: 'signupToken이 없습니다. 1단계를 먼저 진행하세요.' }, - { status: 401 } - ); - } - - // TODO: 백엔드가 signupToken을 받는 방식에 맞춰 수정 - const result = await apiServer.post('auth/signup/phase2', { - json: { ...body, signupToken }, - }).json(); - - const { accessToken, refreshToken } = result.data ?? {}; - if (!accessToken || !refreshToken) { - return NextResponse.json( - { message: '토큰이 응답에 없습니다.' }, - { status: 502 } - ); - } - - const res = NextResponse.json( - { message: result.message ?? '회원가입 완료' }, - { status: 201 } - ); - - const isProd = process.env.NODE_ENV === 'production'; - - res.cookies.set('accessToken', accessToken, { - httpOnly: true, - secure: isProd, - sameSite: 'lax', - path: '/', - maxAge: 60 * 15, // TODO: 15분 (백엔드와 동일하게) - }); - - res.cookies.set('refreshToken', refreshToken, { - httpOnly: true, - secure: isProd, - sameSite: 'lax', - path: '/', - maxAge: 60 * 60 * 24 * 14, // TODO: 14일 (백엔드와 동일하게) - }); - - res.cookies.delete('signupToken'); - - return res; - } catch (error) { - if (error instanceof HTTPError) { - const status = error.response.status; - const errorData = await error.response.json().catch(() => ({} as Record)); - return NextResponse.json( - { message: errorData.message || '회원가입 2단계 실패' }, - { status } - ); - } - return NextResponse.json( - { message: 'An unexpected error occurred' }, - { status: 500 } - ); - } -} diff --git a/app/api/auth/signup/route.ts b/app/api/auth/signup/route.ts new file mode 100644 index 0000000..bd48439 --- /dev/null +++ b/app/api/auth/signup/route.ts @@ -0,0 +1,62 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { HTTPError } from "ky"; + +import { apiServer } from "@/shared/lib/apiServer"; + +interface SignupResponse { + status: string; + message: string; + data: { + accessToken: string; + refreshToken: string; + }; +} + +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + + const result = await apiServer + .post("auth/signup", { + json: body, + }) + .json(); + + const { accessToken, refreshToken } = result.data ?? {}; + if (!accessToken || !refreshToken) { + return NextResponse.json({ message: "토큰이 응답에 없습니다." }, { status: 502 }); + } + + const res = NextResponse.json({ message: result.message ?? "회원가입 완료" }, { status: 201 }); + + const isProd = process.env.NODE_ENV === "production"; + + res.cookies.set("accessToken", accessToken, { + httpOnly: true, + secure: isProd, + sameSite: "lax", + path: "/", + maxAge: 60 * 15, // TODO: 15분 (백엔드와 동일하게) + }); + + res.cookies.set("refreshToken", refreshToken, { + httpOnly: true, + secure: isProd, + sameSite: "lax", + path: "/", + maxAge: 60 * 60 * 24 * 14, // TODO: 14일 (백엔드와 동일하게) + }); + + res.cookies.delete("signupToken"); + + return res; + } catch (error) { + if (error instanceof HTTPError) { + const status = error.response.status; + const errorData = await error.response.json().catch(() => ({}) as Record); + return NextResponse.json({ message: errorData.message || "회원가입 실패" }, { status }); + } + return NextResponse.json({ message: "An unexpected error occurred" }, { status: 500 }); + } +} From d262942c486f12295ff620b685ff13d204e24fa1 Mon Sep 17 00:00:00 2001 From: eunwoo-levi Date: Mon, 15 Dec 2025 17:36:04 +0900 Subject: [PATCH 11/14] =?UTF-8?q?feat:=20BaseResponse=20=EA=B3=B5=ED=86=B5?= =?UTF-8?q?=20API=20=ED=83=80=EC=9E=85=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/auth/login/route.ts | 61 ++++++++++++------------------- app/api/auth/refresh/route.ts | 67 ++++++++++++++--------------------- app/api/auth/signup/route.ts | 13 +++---- src/shared/types/api.ts | 13 +++++++ 4 files changed, 68 insertions(+), 86 deletions(-) create mode 100644 src/shared/types/api.ts diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts index ade5eae..6b2b926 100644 --- a/app/api/auth/login/route.ts +++ b/app/api/auth/login/route.ts @@ -1,53 +1,44 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { HTTPError } from 'ky'; -import { apiServer } from '@/shared/lib/apiServer'; +import { NextRequest, NextResponse } from "next/server"; -interface LoginSuccessResponse { - status: string; - message: string; - data: { - accessToken: string; - refreshToken: string; - }; -} +import { HTTPError } from "ky"; + +import { apiServer } from "@/shared/lib/apiServer"; +import { BaseResponse } from "@/shared/types/api"; + +export type LoginSuccessResponse = BaseResponse<{ + accessToken: string; + refreshToken: string; +}>; export async function POST(req: NextRequest) { try { const body = await req.json(); - const result = await apiServer - .post('auth/login', { json: body }) - .json(); + const result = await apiServer.post("auth/login", { json: body }).json(); const { accessToken, refreshToken } = result.data ?? {}; if (!accessToken || !refreshToken) { - return NextResponse.json( - { message: '토큰이 응답에 없습니다.' }, - { status: 502 } - ); + return NextResponse.json({ message: "토큰이 응답에 없습니다." }, { status: 502 }); } - const res = NextResponse.json( - { message: result.message ?? '로그인 성공' }, - { status: 200 } - ); + const res = NextResponse.json({ message: result.message ?? "로그인 성공" }, { status: 200 }); - const isProd = process.env.NODE_ENV === 'production'; + const isProd = process.env.NODE_ENV === "production"; - res.cookies.set('accessToken', accessToken, { + res.cookies.set("accessToken", accessToken, { httpOnly: true, secure: isProd, - sameSite: 'lax', - path: '/', + sameSite: "lax", + path: "/", maxAge: 60 * 15, // TODO: 15분 (백엔드와 동일하게) }); - res.cookies.set('refreshToken', refreshToken, { + res.cookies.set("refreshToken", refreshToken, { httpOnly: true, secure: isProd, - sameSite: 'lax', - path: '/', + sameSite: "lax", + path: "/", maxAge: 60 * 60 * 24 * 14, // TODO: 14일 (백엔드와 동일하게) }); @@ -55,17 +46,11 @@ export async function POST(req: NextRequest) { } catch (error) { if (error instanceof HTTPError) { const status = error.response.status; - const errorData = await error.response.json().catch(() => ({} as Record)); + const errorData = await error.response.json().catch(() => ({}) as Record); - return NextResponse.json( - { message: errorData.message || '로그인 실패' }, - { status } - ); + return NextResponse.json({ message: errorData.message || "로그인 실패" }, { status }); } - return NextResponse.json( - { message: 'An unexpected error occurred' }, - { status: 500 } - ); + return NextResponse.json({ message: "An unexpected error occurred" }, { status: 500 }); } } diff --git a/app/api/auth/refresh/route.ts b/app/api/auth/refresh/route.ts index d0f2a97..063ae5b 100644 --- a/app/api/auth/refresh/route.ts +++ b/app/api/auth/refresh/route.ts @@ -1,62 +1,55 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { HTTPError } from 'ky'; -import { apiServer } from '@/shared/lib/apiServer'; +import { NextRequest, NextResponse } from "next/server"; -interface RefreshSuccessResponse { - status: string; - message: string; - data: { - accessToken: string; - refreshToken?: string; - }; -} +import { HTTPError } from "ky"; + +import { apiServer } from "@/shared/lib/apiServer"; +import { BaseResponse } from "@/shared/types/api"; + +export type RefreshSuccessResponse = BaseResponse<{ + accessToken: string; + refreshToken?: string; +}>; export async function POST(req: NextRequest) { try { - const refreshToken = req.cookies.get('refreshToken')?.value; + const refreshToken = req.cookies.get("refreshToken")?.value; if (!refreshToken) { - return NextResponse.json( - { message: 'refreshToken이 없습니다.' }, - { status: 401 } - ); + return NextResponse.json({ message: "refreshToken이 없습니다." }, { status: 401 }); } const result = await apiServer - .post('auth/refresh', { json: { refreshToken } }) + .post("auth/refresh", { json: { refreshToken } }) .json(); const newAccessToken = result.data?.accessToken; - const newRefreshToken = result.data?.refreshToken; + const newRefreshToken = result.data?.refreshToken; if (!newAccessToken) { - return NextResponse.json( - { message: 'accessToken이 응답에 없습니다.' }, - { status: 502 } - ); + return NextResponse.json({ message: "accessToken이 응답에 없습니다." }, { status: 502 }); } const res = NextResponse.json( - { message: result.message ?? '토큰 재발급 성공' }, - { status: 200 } + { message: result.message ?? "토큰 재발급 성공" }, + { status: 200 }, ); - const isProd = process.env.NODE_ENV === 'production'; + const isProd = process.env.NODE_ENV === "production"; - res.cookies.set('accessToken', newAccessToken, { + res.cookies.set("accessToken", newAccessToken, { httpOnly: true, secure: isProd, - sameSite: 'lax', - path: '/', + sameSite: "lax", + path: "/", maxAge: 60 * 15, // TODO: 백엔드 만료와 맞추기 }); if (newRefreshToken) { - res.cookies.set('refreshToken', newRefreshToken, { + res.cookies.set("refreshToken", newRefreshToken, { httpOnly: true, secure: isProd, - sameSite: 'lax', - path: '/', + sameSite: "lax", + path: "/", maxAge: 60 * 60 * 24 * 14, // TODO: 백엔드 만료와 맞추기 }); } @@ -65,17 +58,11 @@ export async function POST(req: NextRequest) { } catch (error) { if (error instanceof HTTPError) { const status = error.response.status; - const errorData = await error.response.json().catch(() => ({} as Record)); + const errorData = await error.response.json().catch(() => ({}) as Record); - return NextResponse.json( - { message: errorData.message || '토큰 재발급 실패' }, - { status } - ); + return NextResponse.json({ message: errorData.message || "토큰 재발급 실패" }, { status }); } - return NextResponse.json( - { message: 'An unexpected error occurred' }, - { status: 500 } - ); + return NextResponse.json({ message: "An unexpected error occurred" }, { status: 500 }); } } diff --git a/app/api/auth/signup/route.ts b/app/api/auth/signup/route.ts index bd48439..f13f86d 100644 --- a/app/api/auth/signup/route.ts +++ b/app/api/auth/signup/route.ts @@ -3,15 +3,12 @@ import { NextRequest, NextResponse } from "next/server"; import { HTTPError } from "ky"; import { apiServer } from "@/shared/lib/apiServer"; +import { BaseResponse } from "@/shared/types/api"; -interface SignupResponse { - status: string; - message: string; - data: { - accessToken: string; - refreshToken: string; - }; -} +export type SignupResponse = BaseResponse<{ + accessToken: string; + refreshToken: string; +}>; export async function POST(req: NextRequest) { try { diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts new file mode 100644 index 0000000..98e1a6b --- /dev/null +++ b/src/shared/types/api.ts @@ -0,0 +1,13 @@ +export type SuccessResponse = { + status: "SUCCESS"; + message: string; + data: T; +}; + +export type ErrorResponse = { + status: "ERROR"; + message: string; + data: null; +}; + +export type BaseResponse = SuccessResponse | ErrorResponse; From 377ef1ab0e59732779541a2c644a9d446fbefb24 Mon Sep 17 00:00:00 2001 From: eunwoo-levi Date: Mon, 15 Dec 2025 18:08:03 +0900 Subject: [PATCH 12/14] =?UTF-8?q?refactor:=20accessToken=EA=B3=BC=20refres?= =?UTF-8?q?hToken=EC=9D=84=20=ED=95=9C=20=EA=B3=B3=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=83=81=EC=88=98=EB=A1=9C=20=EA=B4=80=EB=A6=AC=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/auth/constants.ts | 9 +++++++++ app/api/auth/login/route.ts | 6 ++++-- app/api/auth/refresh/route.ts | 6 ++++-- app/api/auth/signup/route.ts | 6 ++++-- 4 files changed, 21 insertions(+), 6 deletions(-) create mode 100644 app/api/auth/constants.ts diff --git a/app/api/auth/constants.ts b/app/api/auth/constants.ts new file mode 100644 index 0000000..064f838 --- /dev/null +++ b/app/api/auth/constants.ts @@ -0,0 +1,9 @@ +/** + * 인증 관련 상수 + */ +// TODO: 백엔드와 동일하게 맞출 것 +/** Access Token 만료 시간 (15분) */ +export const ACCESS_TOKEN_MAX_AGE = 60 * 15; + +/** Refresh Token 만료 시간 (14일) */ +export const REFRESH_TOKEN_MAX_AGE = 60 * 60 * 24 * 14; diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts index 6b2b926..9b8b46f 100644 --- a/app/api/auth/login/route.ts +++ b/app/api/auth/login/route.ts @@ -5,6 +5,8 @@ import { HTTPError } from "ky"; import { apiServer } from "@/shared/lib/apiServer"; import { BaseResponse } from "@/shared/types/api"; +import { ACCESS_TOKEN_MAX_AGE, REFRESH_TOKEN_MAX_AGE } from "../constants"; + export type LoginSuccessResponse = BaseResponse<{ accessToken: string; refreshToken: string; @@ -31,7 +33,7 @@ export async function POST(req: NextRequest) { secure: isProd, sameSite: "lax", path: "/", - maxAge: 60 * 15, // TODO: 15분 (백엔드와 동일하게) + maxAge: ACCESS_TOKEN_MAX_AGE, }); res.cookies.set("refreshToken", refreshToken, { @@ -39,7 +41,7 @@ export async function POST(req: NextRequest) { secure: isProd, sameSite: "lax", path: "/", - maxAge: 60 * 60 * 24 * 14, // TODO: 14일 (백엔드와 동일하게) + maxAge: REFRESH_TOKEN_MAX_AGE, }); return res; diff --git a/app/api/auth/refresh/route.ts b/app/api/auth/refresh/route.ts index 063ae5b..8cd233c 100644 --- a/app/api/auth/refresh/route.ts +++ b/app/api/auth/refresh/route.ts @@ -5,6 +5,8 @@ import { HTTPError } from "ky"; import { apiServer } from "@/shared/lib/apiServer"; import { BaseResponse } from "@/shared/types/api"; +import { ACCESS_TOKEN_MAX_AGE, REFRESH_TOKEN_MAX_AGE } from "../constants"; + export type RefreshSuccessResponse = BaseResponse<{ accessToken: string; refreshToken?: string; @@ -41,7 +43,7 @@ export async function POST(req: NextRequest) { secure: isProd, sameSite: "lax", path: "/", - maxAge: 60 * 15, // TODO: 백엔드 만료와 맞추기 + maxAge: ACCESS_TOKEN_MAX_AGE, }); if (newRefreshToken) { @@ -50,7 +52,7 @@ export async function POST(req: NextRequest) { secure: isProd, sameSite: "lax", path: "/", - maxAge: 60 * 60 * 24 * 14, // TODO: 백엔드 만료와 맞추기 + maxAge: REFRESH_TOKEN_MAX_AGE, }); } diff --git a/app/api/auth/signup/route.ts b/app/api/auth/signup/route.ts index f13f86d..1ad3d3b 100644 --- a/app/api/auth/signup/route.ts +++ b/app/api/auth/signup/route.ts @@ -5,6 +5,8 @@ import { HTTPError } from "ky"; import { apiServer } from "@/shared/lib/apiServer"; import { BaseResponse } from "@/shared/types/api"; +import { ACCESS_TOKEN_MAX_AGE, REFRESH_TOKEN_MAX_AGE } from "../constants"; + export type SignupResponse = BaseResponse<{ accessToken: string; refreshToken: string; @@ -34,7 +36,7 @@ export async function POST(req: NextRequest) { secure: isProd, sameSite: "lax", path: "/", - maxAge: 60 * 15, // TODO: 15분 (백엔드와 동일하게) + maxAge: ACCESS_TOKEN_MAX_AGE, }); res.cookies.set("refreshToken", refreshToken, { @@ -42,7 +44,7 @@ export async function POST(req: NextRequest) { secure: isProd, sameSite: "lax", path: "/", - maxAge: 60 * 60 * 24 * 14, // TODO: 14일 (백엔드와 동일하게) + maxAge: REFRESH_TOKEN_MAX_AGE, }); res.cookies.delete("signupToken"); From a0c9ffbe102b9a7824f79451184cdad728c94480 Mon Sep 17 00:00:00 2001 From: eunwoo-levi Date: Mon, 15 Dec 2025 18:22:33 +0900 Subject: [PATCH 13/14] =?UTF-8?q?feat:=20middle=20macher=20=EC=83=81?= =?UTF-8?q?=EC=88=98=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- middleware.ts | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/middleware.ts b/middleware.ts index 01b3035..cd83dd6 100644 --- a/middleware.ts +++ b/middleware.ts @@ -1,26 +1,30 @@ -import type { NextRequest } from 'next/server'; -import { NextResponse } from 'next/server'; - +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; // TODO: PUBLIC 및 PROTECTED 경로들 추후 추가 및 수정 필요 -const PUBLIC_ONLY = ['/login', '/signup']; -const PROTECTED_PREFIXES = ['/mypage']; +const PUBLIC_ONLY = ["/login", "/signup"]; +const PROTECTED_PREFIXES = ["/mypage"]; + +const MIDDLEWARE_MATCHER = [ + ...PUBLIC_ONLY, + ...PROTECTED_PREFIXES.map((prefix) => `${prefix}/:path*`), +]; export function middleware(request: NextRequest) { const { pathname } = request.nextUrl; - const accessToken = request.cookies.get('accessToken')?.value; + const accessToken = request.cookies.get("accessToken")?.value; - const isAuthed = Boolean(accessToken); + const isAuthed = Boolean(accessToken); if (PUBLIC_ONLY.includes(pathname) && isAuthed) { - return NextResponse.redirect(new URL('/', request.url)); + return NextResponse.redirect(new URL("/", request.url)); } const isProtected = PROTECTED_PREFIXES.some((prefix) => pathname.startsWith(prefix)); if (isProtected && !isAuthed) { - const url = new URL('/login', request.url); - url.searchParams.set('next', pathname); + const url = new URL("/login", request.url); + url.searchParams.set("next", pathname); return NextResponse.redirect(url); } @@ -29,9 +33,5 @@ export function middleware(request: NextRequest) { // TODO: 추후 추가 될 예정, matcher는 "미들웨어가 실행될 경로"만 최소로 걸기 export const config = { - matcher: [ - '/login', - '/signup', - '/mypage/:path*', - ], + matcher: MIDDLEWARE_MATCHER, }; From 7cf50f3673e9618432de232848184bbc4b207424 Mon Sep 17 00:00:00 2001 From: eunwoo-levi Date: Mon, 15 Dec 2025 18:32:55 +0900 Subject: [PATCH 14/14] =?UTF-8?q?fix:=20middleware=20config=20=EC=9D=B8?= =?UTF-8?q?=EB=9D=BC=EC=9D=B8=20=EB=A6=AC=ED=84=B0=EB=9F=B4=EB=A1=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- middleware.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/middleware.ts b/middleware.ts index cd83dd6..78115ae 100644 --- a/middleware.ts +++ b/middleware.ts @@ -5,11 +5,6 @@ import { NextResponse } from "next/server"; const PUBLIC_ONLY = ["/login", "/signup"]; const PROTECTED_PREFIXES = ["/mypage"]; -const MIDDLEWARE_MATCHER = [ - ...PUBLIC_ONLY, - ...PROTECTED_PREFIXES.map((prefix) => `${prefix}/:path*`), -]; - export function middleware(request: NextRequest) { const { pathname } = request.nextUrl; @@ -33,5 +28,5 @@ export function middleware(request: NextRequest) { // TODO: 추후 추가 될 예정, matcher는 "미들웨어가 실행될 경로"만 최소로 걸기 export const config = { - matcher: MIDDLEWARE_MATCHER, + matcher: ["/login", "/signup", "/mypage/:path*"], };