diff --git a/src/apis/authApi.ts b/src/apis/authApi.ts new file mode 100644 index 00000000..de68a2c0 --- /dev/null +++ b/src/apis/authApi.ts @@ -0,0 +1,25 @@ +import { clientApiClient } from '@/lib/client/apiClient'; +import { ApiError } from '@/lib/errors/ApiError'; +import { User } from '@/types/user'; + +interface UserInfoResponse { + success: boolean; + data?: User; + message?: string; +} + +export const fetchUserInfo = async (): Promise => { + const response = await clientApiClient('/api/auth/me'); + + if (!response.success || !response.data) { + throw new ApiError(200, response.message || 'Failed to fetch user info', response); + } + + return response.data; +}; + +export const logout = async (): Promise => { + await clientApiClient('/api/auth/logout', { + method: 'POST', + }); +}; diff --git a/src/apis/chatApi.ts b/src/apis/chatApi.ts index f3b1a897..a0451d17 100644 --- a/src/apis/chatApi.ts +++ b/src/apis/chatApi.ts @@ -1,4 +1,5 @@ -import { type SafeFetchOptions, safeFetch } from '@/hooks/util/api/fetch/safeFetch'; +// 백엔드 직접 호출 +import { backendApiClient } from '@/lib/client/backendClient'; import type { ChatListApiResponse, ChatRoom, @@ -7,48 +8,14 @@ import type { DeleteChatApiResponse, } from '@/types/api/chatApi'; -const API_BASE_URL = process.env.NEXT_PUBLIC_BASE_API_URL; -const API_TOKEN = process.env.NEXT_PUBLIC_API_TOKEN; - -if (!API_BASE_URL) { - throw new Error('Missing environment variable: NEXT_PUBLIC_BASE_API_URL'); -} - -if (!API_TOKEN) { - throw new Error('Missing environment variable: NEXT_PUBLIC_API_TOKEN'); -} - -const buildUrl = (...paths: string[]) => `${API_BASE_URL}${paths.join('')}`; - -const authHeaderValue = () => `Bearer ${API_TOKEN}`; - -const withAuth = (init?: SafeFetchOptions): SafeFetchOptions => { - const headers: HeadersInit = { - Authorization: authHeaderValue(), - ...(init?.headers ?? {}), - }; - - return { - timeout: 15_000, - jsonContentTypeCheck: true, - ...init, - headers, - }; -}; - -const CHATS_ENDPOINT = buildUrl('/v1/chats'); - export const fetchChats = async (): Promise => { - const body = await safeFetch( - CHATS_ENDPOINT, - withAuth({ cache: 'no-store' }) - ); + const response = await backendApiClient('/v1/chats'); - if (!body?.data || !body.success) { - throw new Error(body?.message ?? 'Invalid response'); + if (!response.success || !response.data) { + throw new Error(response.message ?? 'Failed to fetch chats'); } - const chats = body.data.chats; + const chats = response.data.chats; if (!Array.isArray(chats)) { throw new Error('Invalid response: chats is not an array'); @@ -58,32 +25,26 @@ export const fetchChats = async (): Promise => { }; export const createChat = async (payload: CreateChatPayload): Promise => { - const body = await safeFetch( - CHATS_ENDPOINT, - withAuth({ - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }) - ); + const response = await backendApiClient('/v1/chats', { + method: 'POST', + body: JSON.stringify(payload), + }); - if (!body?.data || !body.success) { - throw new Error(body?.message ?? 'Invalid response'); + if (!response.success || !response.data) { + throw new Error(response.message ?? 'Failed to create chat'); } - return body.data; + return response.data; }; export const deleteChat = async (id: number): Promise => { - const body = await safeFetch( - `${CHATS_ENDPOINT}/${id}`, - withAuth({ - method: 'DELETE', - }) - ); - if (!body || typeof body.success !== 'boolean' || !body.status || !body.message) { - throw new Error(body?.message ?? 'Invalid response'); + const response = await backendApiClient(`/v1/chats/${id}`, { + method: 'DELETE', + }); + + if (!response.success) { + throw new Error(response.message ?? 'Failed to delete chat'); } - return body; + return response; }; diff --git a/src/apis/report.ts b/src/apis/report.ts index 8faa8ec0..4d864b45 100644 --- a/src/apis/report.ts +++ b/src/apis/report.ts @@ -1,33 +1,15 @@ -import { SafeFetchOptions, safeFetch } from '@/hooks/util/api/fetch/safeFetch'; +import { backendApiClient } from '@/lib/client/backendClient'; import type { ReportApiResponse, ReportRequest } from '@/types/api/report'; -const API_BASE_URL = process.env.NEXT_PUBLIC_BASE_API_URL; -const API_TOKEN = process.env.NEXT_PUBLIC_API_TOKEN; - -const authHeaderValue = () => `Bearer ${API_TOKEN}`; +export const createReport = async (payload: ReportRequest) => { + const res = await backendApiClient('/v1/report', { + method: 'POST', + body: JSON.stringify(payload), + }); -const withAuth = (init?: SafeFetchOptions): SafeFetchOptions => { - const headers: HeadersInit = { - Authorization: authHeaderValue(), - ...(init?.headers ?? {}), - }; + if (!res.success || !res.data) { + throw new Error(res.message ?? 'Failed to create report'); + } - return { - timeout: 15_000, - jsonContentTypeCheck: true, - ...init, - headers, - }; -}; - -export const createReport = async (payload: ReportRequest) => { - return safeFetch( - `${API_BASE_URL}/v1/report`, - withAuth({ - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - cache: 'no-store', - }) - ); + return res.data; }; diff --git a/src/apis/userApi.ts b/src/apis/userApi.ts deleted file mode 100644 index a66543ed..00000000 --- a/src/apis/userApi.ts +++ /dev/null @@ -1,23 +0,0 @@ -// src/apis/userApi.ts -import apiClient from '@/apis/apiClient'; - -// TODO: User types로 통합? -export interface User { - id: number; - name: string; - email: string; -} - -/** 전체 사용자 조회 */ -export const fetchUsers = async (): Promise => { - // JSONPlaceholder는 User[]를 바로 반환합니다. - const { data } = await apiClient.get('/users'); - return data; // ← .users 가 아니라 response.data 자체가 배열 -}; - -/** 사용자 생성 */ -export const createUser = async (payload: Pick): Promise => { - // 서버가 단일 User 객체를 반환한다고 가정 - const response = await apiClient.post('/users', payload); - return response.data; -}; diff --git a/src/app/(route)/home/HomePage.tsx b/src/app/(route)/home/HomePage.tsx index c8c340d2..0406d4de 100644 --- a/src/app/(route)/home/HomePage.tsx +++ b/src/app/(route)/home/HomePage.tsx @@ -39,7 +39,7 @@ export default function Home() {
저장한 링크 속 내용을 바탕으로 - 답변해 드려요. + 답변해 드려요...
diff --git a/src/app/(route)/home/_components/useCreateChatRoom.ts b/src/app/(route)/home/_components/useCreateChatRoom.ts index 22bdea8f..7236a6d0 100644 --- a/src/app/(route)/home/_components/useCreateChatRoom.ts +++ b/src/app/(route)/home/_components/useCreateChatRoom.ts @@ -1,5 +1,5 @@ import { createChat } from '@/apis/chatApi'; -import { FetchError, ParseError, TimeoutError } from '@/hooks/util/api/error/errors'; +import { BackendApiError } from '@/lib/client/backendClient'; import type { ChatRoom } from '@/types/api/chatApi'; import { useQueryClient } from '@tanstack/react-query'; import { useCallback, useState } from 'react'; @@ -41,24 +41,19 @@ export function useCreateChatRoom() { resetForm(); return created; } catch (err) { - switch (true) { - case err instanceof FetchError && err.status === 401: - setError('로그인이 필요합니다.'); - break; - case err instanceof FetchError && err.status === 400: - setError('요청 형식이 올바르지 않습니다.'); - break; - case err instanceof TimeoutError: - setError('요청이 시간 초과되었습니다. 다시 시도해주세요.'); - break; - case err instanceof ParseError: - setError('서버 응답을 처리하는 중 오류가 발생했습니다.'); - break; - case err instanceof FetchError: - setError(err.message || '서버와 통신하는 중 오류가 발생했습니다.'); - break; - default: - setError('알 수 없는 오류가 발생했습니다. 잠시 후 다시 시도해주세요.'); + if (err instanceof BackendApiError) { + switch (err.status) { + case 401: + setError('로그인이 필요합니다.'); + break; + case 400: + setError('요청 형식이 올바르지 않습니다.'); + break; + default: + setError(err.message || '서버와 통신하는 중 오류가 발생했습니다.'); + } + } else { + setError('알 수 없는 오류가 발생했습니다. 잠시 후 다시 시도해주세요.'); } } finally { setCreating(false); diff --git a/src/app/(route)/home/page.tsx b/src/app/(route)/home/page.tsx index 9e2c2e19..59234b06 100644 --- a/src/app/(route)/home/page.tsx +++ b/src/app/(route)/home/page.tsx @@ -1,6 +1,16 @@ +// app/home/page.tsx +import { cookies } from 'next/headers'; + import Home from './HomePage'; -export default function page() { +export const dynamic = 'force-dynamic'; + +export default async function page() { + const cookieStore = await cookies(); + const token = cookieStore.get('accessToken'); + + console.log('🔥 Server side - Token exists:', !!token); + return (
diff --git a/src/app/LandingPage.tsx b/src/app/LandingPage.tsx index 80cb06f3..e2d999df 100644 --- a/src/app/LandingPage.tsx +++ b/src/app/LandingPage.tsx @@ -3,16 +3,21 @@ import ICLandingBackground from '@/components/Icons/svgs/ic_landing_background.svg'; import ICLandingIcLogo from '@/components/Icons/svgs/ic_landing_ic_logo.svg'; import ICLandingTextLogo from '@/components/Icons/svgs/ic_landing_text_logo.svg'; -import { setCookieUtil } from '@/hooks/useCookie'; -import { COOKIES_KEYS } from '@/lib/constants/cookies'; import Image from 'next/image'; import { useRouter, useSearchParams } from 'next/navigation'; -import { useEffect } from 'react'; + +const ERROR_MESSAGES: Record = { + auth_failed: '로그인에 실패했습니다.', + server_error: '서버 오류가 발생했습니다.', + unauthorized: '로그인이 필요합니다.', + session_expired: '세션이 만료되었습니다. 다시 로그인해주세요.', +}; export default function Landing() { const searchParams = useSearchParams(); - const router = useRouter(); const error = searchParams.get('error'); + const router = useRouter(); + const isDev = process.env.NODE_ENV === 'development'; // 개발용 가짜 로그인 TODO: 나중에 지우기(사실 랜딩을 다 갈아야하긴 하지만) @@ -23,41 +28,13 @@ export default function Landing() { console.error('NEXT_PUBLIC_API_TOKEN is missing'); return; } - - // 백엔드 인증 토큰 저장 - setCookieUtil(COOKIES_KEYS.ACCESS_TOKEN, token, { - maxAge: 60 * 60 * 24, // 1일 - path: '/', - }); - - // 개발용 유저 정보 (UI용) - setCookieUtil( - COOKIES_KEYS.USER_INFO, - JSON.stringify({ - id: 'dev', - email: 'dev@test.com', - name: '개발자', - picture: '', - }), - { - maxAge: 60 * 60 * 24, - path: '/', - } - ); - router.push('/home'); }; const handleGoogleLogin = () => { - window.location.href = `${process.env.NEXT_PUBLIC_BASE_API_URL}/oauth2/authorization/google`; // 백엔드 URL로 변경 + window.location.href = '/api/auth/login'; // 백엔드 URL로 변경 }; - useEffect(() => { - if (error) { - console.error('Login error:', error); - } - }, [error]); - return (
@@ -65,7 +42,7 @@ export default function Landing() { -
+
@@ -77,9 +54,7 @@ export default function Landing() { {error && (
- {error === 'auth_failed' && '로그인에 실패했습니다.'} - {error === 'server_error' && '서버 오류가 발생했습니다.'} - {!['auth_failed', 'server_error'].includes(error) && '오류가 발생했습니다.'} + {ERROR_MESSAGES[error] ?? '오류가 발생했습니다.'}
잠시 후 다시 시도해주세요.
@@ -94,12 +69,6 @@ export default function Landing() { 🔧 개발 모드 로그인 )} -
}> + + + ); } diff --git a/src/components/layout/SideNavigation/SideNavigation.tsx b/src/components/layout/SideNavigation/SideNavigation.tsx index 29857ba4..22643f07 100644 --- a/src/components/layout/SideNavigation/SideNavigation.tsx +++ b/src/components/layout/SideNavigation/SideNavigation.tsx @@ -1,15 +1,17 @@ 'use client'; +import { getUserInfoFromCookie } from '@/hooks/useUserInfo'; import { useSideNavStore } from '@/stores/sideNavStore'; import { motion } from 'framer-motion'; +import SideNavigationBottom from './components/Bottom/SideNavigationBottom'; import ChatRoomSection from './components/ChatRoomSection/ChatRoomSection'; import SideNavigationHeader from './components/Header/SideNavigationHeader'; import MenuSection from './components/MenuSection/MenuSection'; -import SideNavigationBottom from './components/SideNavBottom/SideNavBottom'; export default function SideNavigation() { const { isOpen, toggle } = useSideNavStore(); + const userInfo = getUserInfoFromCookie(); return ( <> diff --git a/src/components/layout/SideNavigation/components/Bottom/SideNavigationBottom.tsx b/src/components/layout/SideNavigation/components/Bottom/SideNavigationBottom.tsx index a84d992a..48c26005 100644 --- a/src/components/layout/SideNavigation/components/Bottom/SideNavigationBottom.tsx +++ b/src/components/layout/SideNavigation/components/Bottom/SideNavigationBottom.tsx @@ -1,29 +1,42 @@ import Button from '@/components/basics/Button/Button'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/basics/Popover'; +import { useLogout } from '@/hooks/useLogout'; +import { useUserInfo } from '@/hooks/useUserInfo'; import NavItem from '../NavItem/NavItem'; const SideNavigationBottom = () => { + const { data: user, isLoading } = useUserInfo(); + const { mutate: handleLogout, isPending: isLoggingOut } = useLogout(); + if (isLoading) { + return
로딩 중...
; + } + + if (!user) { + return
; + } + return (
- + {close => (
- ); -}; - -export default SideNavigationBottom; diff --git a/src/hooks/useCookie.ts b/src/hooks/useCookie.ts index e4f870bc..7893224d 100644 --- a/src/hooks/useCookie.ts +++ b/src/hooks/useCookie.ts @@ -7,18 +7,72 @@ interface CookieOptions { days?: number; // 일 단위 secure?: boolean; path?: string; + sameSite?: 'Strict' | 'Lax' | 'None'; } -export function useCookie(key: string) { - const getCookie = useCallback((): string | null => { - if (typeof window === 'undefined') return null; +/** + * 쿠키 가져오기 유틸리티 함수 (클라이언트 전용, 서버에서는 항상 null 반환)) + */ +export const getCookieUtil = (key: string): string | null => { + if (typeof window === 'undefined') return null; + + const cookie = document.cookie.split('; ').find(row => row.startsWith(`${key}=`)); - const cookie = document.cookie.split('; ').find(row => row.startsWith(`${key}=`)); + if (!cookie) return null; + const value = cookie.substring(key.length + 1); - if (!cookie) return null; - const value = cookie.substring(key.length + 1); + return value ? decodeURIComponent(value) : null; +}; + +/** + * 쿠키 설정 유틸리티 함수 + */ +export const setCookieUtil = (key: string, value: string, options: CookieOptions = {}) => { + if (typeof window === 'undefined') return; + + const { maxAge, days = 7, secure: secureOption, path = '/', sameSite = 'Lax' } = options; + const secure = + secureOption ?? (sameSite === 'None' ? true : process.env.NODE_ENV === 'production'); + let cookieString = `${key}=${encodeURIComponent(value)}`; + + // maxAge 또는 days 중 하나만 사용 + if (maxAge !== undefined) { + cookieString += `; max-age=${maxAge}`; + } else { + const expires = new Date(); + expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000); + cookieString += `; expires=${expires.toUTCString()}`; + } + + cookieString += `; path=${path}`; + cookieString += `; SameSite=${sameSite}`; + if (secure) cookieString += '; Secure'; + + document.cookie = cookieString; +}; + +/** + * 쿠키 삭제 유틸리티 함수 + */ +export const deleteCookieUtil = ( + key: string, + path: string = '/', + sameSite: 'Strict' | 'Lax' | 'None' = 'Lax' +) => { + if (typeof window === 'undefined') return; - return value ? decodeURIComponent(value) : null; + const shouldBeSecure = sameSite === 'None' ? true : window.location.protocol === 'https:'; + const secureFlag = shouldBeSecure ? ';Secure' : ''; + + document.cookie = `${key}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=${path};SameSite=${sameSite}${secureFlag}`; +}; + +/** + * 쿠키 관리 훅 + */ +export function useCookie(key: string) { + const getCookie = useCallback((): string | null => { + return getCookieUtil(key); }, [key]); const [cookie, setCookieState] = useState(null); @@ -29,29 +83,7 @@ export function useCookie(key: string) { const setCookie = useCallback( (value: string, options: CookieOptions = {}) => { - const { - maxAge, - days = 7, - secure = process.env.NODE_ENV === 'production', - path = '/', - } = options; - - let cookieString = `${key}=${encodeURIComponent(value)}`; - - // maxAge 또는 days 중 하나만 사용 - if (maxAge !== undefined) { - cookieString += `;max-age=${maxAge}`; - } else { - const expires = new Date(); - expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000); - cookieString += `;expires=${expires.toUTCString()}`; - } - - cookieString += `;path=${path}`; - cookieString += ';SameSite=Lax'; - if (secure) cookieString += ';Secure'; - - document.cookie = cookieString; + setCookieUtil(key, value, options); setCookieState(value); }, [key] @@ -59,9 +91,7 @@ export function useCookie(key: string) { const deleteCookie = useCallback( (path: string = '/') => { - const shouldBeSecure = typeof window !== 'undefined' && window.location.protocol === 'https:'; - const secureFlag = shouldBeSecure ? ';Secure' : ''; - document.cookie = `${key}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=${path};SameSite=Lax${secureFlag}`; + deleteCookieUtil(key, path); setCookieState(null); }, [key] @@ -69,25 +99,3 @@ export function useCookie(key: string) { return { cookie, setCookie, deleteCookie, getCookie }; } - -// 여러 쿠키를 한 번에 관리하는 유틸리티 함수 -export const setCookieUtil = (key: string, value: string, options: CookieOptions = {}) => { - if (typeof window === 'undefined') return; - const { maxAge, days = 7, secure = process.env.NODE_ENV === 'production', path = '/' } = options; - - let cookieString = `${key}=${encodeURIComponent(value)}`; - - if (maxAge !== undefined) { - cookieString += `;max-age=${maxAge}`; - } else { - const expires = new Date(); - expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000); - cookieString += `;expires=${expires.toUTCString()}`; - } - - cookieString += `;path=${path}`; - cookieString += ';SameSite=Lax'; - if (secure) cookieString += ';Secure'; - - document.cookie = cookieString; -}; diff --git a/src/hooks/useLogout.ts b/src/hooks/useLogout.ts new file mode 100644 index 00000000..98236a82 --- /dev/null +++ b/src/hooks/useLogout.ts @@ -0,0 +1,23 @@ +'use client'; + +import { logout } from '@/apis/authApi'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useRouter } from 'next/navigation'; + +export function useLogout() { + const router = useRouter(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: logout, + onSuccess: () => { + queryClient.clear(); + router.push('/'); + }, + onError: () => { + queryClient.clear(); + // 에러가 나도 로그인 페이지로 이동 + router.push('/'); + }, + }); +} diff --git a/src/hooks/useUserInfo.ts b/src/hooks/useUserInfo.ts new file mode 100644 index 00000000..3dd79c4b --- /dev/null +++ b/src/hooks/useUserInfo.ts @@ -0,0 +1,40 @@ +'use client'; + +import { fetchUserInfo } from '@/apis/authApi'; +import { getCookieUtil, setCookieUtil } from '@/hooks/useCookie'; +import { COOKIES_KEYS } from '@/lib/constants/cookies'; +import type { User } from '@/types/user'; +import { useQuery } from '@tanstack/react-query'; +import { useEffect } from 'react'; + +export function useUserInfo() { + const query = useQuery({ + queryKey: ['userInfo'], + queryFn: fetchUserInfo, + staleTime: 1000 * 60 * 5, // 5분 + retry: false, + }); + + // 사용자 정보를 받아오면 쿠키에 저장 + useEffect(() => { + if (query.data) { + setCookieUtil(COOKIES_KEYS.USER_INFO, JSON.stringify(query.data), { maxAge: 86400 }); + } + }, [query.data]); + + return query; +} + +/** + * 쿠키에서 사용자 정보를 즉시 가져오기 + */ +export function getUserInfoFromCookie(): User | null { + const userInfoStr = getCookieUtil(COOKIES_KEYS.USER_INFO); + if (!userInfoStr) return null; + + try { + return JSON.parse(userInfoStr); + } catch { + return null; + } +} diff --git a/src/hooks/useUsers.ts b/src/hooks/useUsers.ts deleted file mode 100644 index fbef23e8..00000000 --- a/src/hooks/useUsers.ts +++ /dev/null @@ -1,20 +0,0 @@ -// src/hooks/useUsers.ts -import { User, createUser, fetchUsers } from '@/apis/userApi'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; - -export function useUsers() { - return useQuery({ - queryKey: ['users'], - queryFn: fetchUsers, - }); -} - -export function useCreateUser() { - const qc = useQueryClient(); - return useMutation>({ - mutationFn: createUser, - onSuccess: () => { - qc.invalidateQueries({ queryKey: ['users'] }); - }, - }); -} diff --git a/src/lib/client/apiClient.ts b/src/lib/client/apiClient.ts new file mode 100644 index 00000000..79ed6cde --- /dev/null +++ b/src/lib/client/apiClient.ts @@ -0,0 +1,28 @@ +import { ApiError } from '../errors/ApiError'; + +/** + * 클라이언트 API 클라이언트 (인증용) + * 클라이언트 사이드 API 클라이언트 + * BFF API Routes 호출용 + */ +export async function clientApiClient(endpoint: string, options: RequestInit = {}): Promise { + const res = await fetch(endpoint, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers, + }, + }); + + if (!res.ok) { + const errorData = await res.json().catch(() => ({})); + throw new ApiError( + res.status, + errorData.error || errorData.message || `Request failed with status ${res.status}`, + errorData + ); + } + + const text = await res.text(); + return text ? JSON.parse(text) : ({} as T); +} diff --git a/src/lib/client/backendClient.ts b/src/lib/client/backendClient.ts new file mode 100644 index 00000000..b2430ee5 --- /dev/null +++ b/src/lib/client/backendClient.ts @@ -0,0 +1,53 @@ +const API_BASE_URL = process.env.NEXT_PUBLIC_BASE_API_URL; // TODO: 환경변수 논의 후 BASE_API_URL로 변경 + +export class BackendApiError extends Error { + constructor( + public status: number, + message: string, + public data?: unknown + ) { + super(message); + this.name = 'BackendApiError'; + } +} + +/** + * 백엔드 직접 호출용 클라이언트 + * 백엔드 API 직접 호출 클라이언트 + * 인증이 필요한 일반 API용 (채팅, 리포트 등) + */ +export async function backendApiClient(endpoint: string, options: RequestInit = {}): Promise { + if (!API_BASE_URL) { + throw new Error('Missing environment variable: NEXT_PUBLIC_BASE_API_URL'); + } + const res = await fetch(`${API_BASE_URL}${endpoint}`, { + ...options, + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + ...options.headers, + }, + }); + + // 401 에러 처리 (토큰 만료 등) + if (res.status === 401) { + if (typeof window !== 'undefined') { + // 클라이언트 사이드에서만 동작 + window.location.href = '/landing'; + } + const errorData = await res.json().catch(() => ({})); + throw new BackendApiError(401, errorData.message || 'Unauthorized', errorData); + } + + if (!res.ok) { + const errorData = await res.json().catch(() => ({})); + throw new BackendApiError( + res.status, + errorData.message || `Request failed with status ${res.status}`, + errorData + ); + } + + const text = await res.text(); + return text ? JSON.parse(text) : ({} as T); +} diff --git a/src/lib/constants/cookies.ts b/src/lib/constants/cookies.ts index 8381d2b8..446c3215 100644 --- a/src/lib/constants/cookies.ts +++ b/src/lib/constants/cookies.ts @@ -1,5 +1,5 @@ export const COOKIES_KEYS = { - ACCESS_TOKEN: 'accessToken', // TODO: 백 쿠키명 확인후 수정 + ACCESS_TOKEN: 'accessToken', REFRESH_TOKEN: 'refreshToken', USER_INFO: 'user_info', } as const; diff --git a/src/lib/errors/ApiError.ts b/src/lib/errors/ApiError.ts new file mode 100644 index 00000000..215e16ff --- /dev/null +++ b/src/lib/errors/ApiError.ts @@ -0,0 +1,11 @@ +// 공통 에러 모듈 +export class ApiError extends Error { + constructor( + public status: number, + message: string, + public data?: unknown + ) { + super(message); + this.name = 'ApiError'; + } +} diff --git a/src/lib/server/apiClient.ts b/src/lib/server/apiClient.ts new file mode 100644 index 00000000..b64d8602 --- /dev/null +++ b/src/lib/server/apiClient.ts @@ -0,0 +1,43 @@ +import { COOKIES_KEYS } from '@/lib/constants/cookies'; +import { cookies } from 'next/headers'; + +import { ApiError } from '../errors/ApiError'; + +const API_BASE_URL = process.env.NEXT_PUBLIC_BASE_API_URL; // TODO: 환경변수 논의 후 BASE_API_URL로 변경 + +if (!API_BASE_URL) { + throw new Error('Missing environment variable: NEXT_PUBLIC_BASE_API_URL'); +} + +/** + * 서버 사이드 API 클라이언트 + * Next.js API Routes에서만 사용 + */ +export async function serverApiClient(endpoint: string, options: RequestInit = {}): Promise { + const cookieStore = await cookies(); // await 추가 + const token = cookieStore.get(COOKIES_KEYS.ACCESS_TOKEN)?.value; + + if (!token) { + throw new ApiError(401, 'No authentication token'); + } + + const response = await fetch(`${API_BASE_URL}${endpoint}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers, + Authorization: `Bearer ${token}`, + }, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new ApiError( + response.status, + errorData.message || `Request failed with status ${response.status}`, + errorData + ); + } + + return response.json(); +} diff --git a/src/types/user.ts b/src/types/user.ts new file mode 100644 index 00000000..da7f0458 --- /dev/null +++ b/src/types/user.ts @@ -0,0 +1,4 @@ +export type User = { + id: string; + name: string; +};