Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ yarn-debug.log*
yarn-error.log*

# local env files
.env
.env.local
.env.development.local
.env.test.local
Expand Down
58 changes: 58 additions & 0 deletions apps/web/app/HydrationBoundaryPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import {
dehydrate,
HydrationBoundary,
QueryClient,
} from '@tanstack/react-query'
import { ReactNode } from 'react'

/**
* React Query 쿼리 구성 타입
*
* @property queryKey - React Query에서 사용할 쿼리 키
* @property queryFn - 데이터를 가져오는 비동기 함수
*/
type QueryConfig = {
queryKey: string[]
queryFn: () => Promise<unknown>
}

/**
* 서버 컴포넌트에서 React Query 쿼리를 미리 요청(prefetch)한 뒤,
* dehydrate 상태를 클라이언트에 전달하기 위한 컴포넌트.
*
* @example
* ```tsx
* <HydrationBoundaryPage
* queries={[
* { queryKey: ['user'], queryFn: fetchUser },
* { queryKey: ['posts'], queryFn: fetchPosts },
* ]}
* >
* <MyPage />
* </HydrationBoundaryPage>
* ```
*
* @param queries - 사전 요청할 쿼리들의 배열
* @param children - HydrationBoundary로 감쌀 React 노드
*/
export const HydrationBoundaryPage = async ({
queries,
children,
}: {
queries: QueryConfig[]
children: ReactNode
}) => {
const queryClient = new QueryClient()

await Promise.all(
queries.map(({ queryKey, queryFn }) =>
queryClient.prefetchQuery({ queryKey, queryFn }),
),
)

return (
<HydrationBoundary state={dehydrate(queryClient)}>
{children}
</HydrationBoundary>
)
}
3 changes: 3 additions & 0 deletions apps/web/app/_constants/path.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const API_PATH = {
CATEGORY: '/categories',
}
8 changes: 8 additions & 0 deletions apps/web/app/_lib/axiosInstance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import axios from 'axios'

const axiosInstance = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL,
withCredentials: true,
})
Comment on lines +3 to +6
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

개발 환경에서 MSW 핸들러 매칭 실패 가능성: baseURL 미설정/상대경로 요청 이슈

현재 baseURL이 환경변수에 전적으로 의존합니다. dev에서 NEXT_PUBLIC_API_URL이 비어 있으면:

  • 브라우저: 상대경로('/…')로 호출되어, 절대 URL을 기준으로 작성된 MSW 핸들러와 매칭되지 않을 수 있음.
  • 서버(SSR): axios가 상대경로를 처리하지 못해 실패할 수 있음.

해결 방향(택1):

  • 서버에서 env 미설정 시 즉시 에러로 fail-fast 처리.
  • 브라우저에서는 origin으로 폴백해 항상 절대 URL을 사용.
  • 핸들러를 절대 URL이 아닌 상대 경로로 정의(별도 파일 변경 필요).

아래 예시는 서버에서 env 미설정 시 에러를 던지고, 브라우저에서는 origin으로 폴백하며 trailing slash를 정규화합니다. 타임아웃도 기본 추가했습니다.

적용 제안 diff:

-const axiosInstance = axios.create({
-  baseURL: process.env.NEXT_PUBLIC_API_URL,
-  withCredentials: true,
-})
+const baseURL =
+  process.env.NEXT_PUBLIC_API_URL?.replace(/\/$/, '') ??
+  (typeof window !== 'undefined' ? window.location.origin : undefined)
+
+if (typeof window === 'undefined' && !baseURL) {
+  // SSR에서 상대경로 요청을 방지하기 위해 명시적으로 실패
+  throw new Error('NEXT_PUBLIC_API_URL is required on the server for axiosInstance.')
+}
+
+const axiosInstance = axios.create({
+  baseURL,
+  withCredentials: true,
+  timeout: 10000,
+})
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const axiosInstance = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL,
withCredentials: true,
})
const baseURL =
process.env.NEXT_PUBLIC_API_URL?.replace(/\/$/, '') ??
(typeof window !== 'undefined' ? window.location.origin : undefined)
if (typeof window === 'undefined' && !baseURL) {
// SSR에서 상대경로 요청을 방지하기 위해 명시적으로 실패
throw new Error('NEXT_PUBLIC_API_URL is required on the server for axiosInstance.')
}
const axiosInstance = axios.create({
baseURL,
withCredentials: true,
timeout: 10000,
})
🤖 Prompt for AI Agents
In apps/web/app/_lib/axiosInstance.ts around lines 3 to 6, the axios instance
currently relies solely on NEXT_PUBLIC_API_URL which can be empty in dev causing
MSW handler mismatches or SSR failures; update the file to (1) if running on
server (typeof window === 'undefined') throw an error immediately when
NEXT_PUBLIC_API_URL is not set (fail-fast), (2) when running in browser,
fallback to window.location.origin when NEXT_PUBLIC_API_URL is empty and ensure
the baseURL has no duplicate or missing trailing slash (normalize trailing
slash), and (3) set a sensible default timeout on the axios.create config so
requests won’t hang; implement these checks before calling axios.create and use
the computed absolute baseURL for the instance.


export default axiosInstance
22 changes: 22 additions & 0 deletions apps/web/app/_mocks/MSWProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
'use client'

import { useEffect, useState } from 'react'
import { initBrowserMSW } from '@/_mocks/initMSW'

export function MSWProvider({ children }: { children: React.ReactNode }) {
const isDev = process.env.NODE_ENV === 'development'
const [mswReady, setMSWReady] = useState(!isDev)

useEffect(() => {
if (!isDev || mswReady) return
const init = async () => {
await initBrowserMSW()
setMSWReady(true)
}
init()
}, [isDev, mswReady])

if (!mswReady) return null

return <>{children}</>
}
17 changes: 17 additions & 0 deletions apps/web/app/_mocks/data/category.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export const category = [
{ id: 1, name: '치킨', iconKey: 'chicken' },
{ id: 2, name: '햄버거', iconKey: 'burger' },
{ id: 3, name: '한식', iconKey: 'korean' },
{ id: 4, name: '분식', iconKey: 'bunsik' },
{ id: 5, name: '중식', iconKey: 'chinese' },
{ id: 6, name: '양식', iconKey: 'western' },
{ id: 7, name: '샐러드', iconKey: 'salad' },
{ id: 8, name: '일식', iconKey: 'japanese' },
{ id: 9, name: '카페', iconKey: 'cafe' },
{ id: 10, name: '피자', iconKey: 'pizza' },
{ id: 11, name: '멕시칸', iconKey: 'mexican' },
{ id: 12, name: '아시안', iconKey: 'asian' },
{ id: 13, name: '찜·탕', iconKey: 'soup' },
{ id: 14, name: '고기·구이', iconKey: 'meat' },
{ id: 15, name: '도시락', iconKey: 'lunchbox' },
]
15 changes: 15 additions & 0 deletions apps/web/app/_mocks/handlers/categoryHandlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { http, HttpResponse } from 'msw'
import { category } from '../data/category'
import { API_PATH } from '@/_constants/path'

const BASE_URL = process.env.NEXT_PUBLIC_API_URL || ''

const addBaseUrl = (path: string) => {
return `${BASE_URL}${path}`
}

export const CategoryHandlers = [
http.get(addBaseUrl(API_PATH.CATEGORY), () => {
return HttpResponse.json(category)
}),
]
3 changes: 3 additions & 0 deletions apps/web/app/_mocks/handlers/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { CategoryHandlers } from '@/_mocks/handlers/categoryHandlers'

export const handlers = [...CategoryHandlers]
21 changes: 21 additions & 0 deletions apps/web/app/_mocks/initMSW.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
export const initServerMSW = async () => {
// 서버에서만 실행되는 MSW 초기화
if (typeof window === 'undefined' && process.env.NODE_ENV === 'development') {
import('./server').then(({ server }) => {
server.listen({
onUnhandledRequest: 'warn',
})
console.log('✅ MSW 서버 시작됨')
})
}
}

export const initBrowserMSW = async () => {
if (typeof window !== 'undefined' && process.env.NODE_ENV === 'development') {
const { worker } = await import('./worker')
await worker.start({
onUnhandledRequest: 'bypass',
})
console.log('✅ MSW 브라우저 시작됨')
}
}
4 changes: 4 additions & 0 deletions apps/web/app/_mocks/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { setupServer } from 'msw/node'
import { handlers } from './handlers'

export const server = setupServer(...handlers)
4 changes: 4 additions & 0 deletions apps/web/app/_mocks/worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { setupWorker } from 'msw/browser'
import { handlers } from './handlers'

export const worker = setupWorker(...handlers)
24 changes: 15 additions & 9 deletions apps/web/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import './globals.css'
import type { Metadata } from 'next'
import QueryProvider from './QueryClientProvider'
import localFont from 'next/font/local'
import { initServerMSW } from '@/_mocks/initMSW'
import { MSWProvider } from '@/_mocks/MSWProvider'
import { Column } from '@repo/ui/components/Layout/Column'

export const metadata: Metadata = {
Expand All @@ -16,21 +18,25 @@ const pretendard = localFont({
weight: '45 920',
})

export default function RootLayout({
export default async function RootLayout({
children,
}: {
children: React.ReactNode
}) {
await initServerMSW()

return (
<html lang='ko'>
<html lang='ko' suppressHydrationWarning={true}>
<body className={pretendard.className}>
<QueryProvider>
<div className={'flex h-svh justify-center bg-gray-50'}>
<Column className={'relative w-[450px] max-w-[450px] bg-white'}>
{children}
</Column>
</div>
</QueryProvider>
<MSWProvider>
<QueryProvider>
<div className={'flex h-svh justify-center bg-gray-50'}>
<Column className={'relative w-[450px] max-w-[450px] bg-white'}>
{children}
</Column>
</div>
</QueryProvider>
</MSWProvider>
</body>
</html>
)
Expand Down
7 changes: 6 additions & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
"version": "1.0.0",
"type": "module",
"private": true,
"msw": {
"workerDirectory": "./public"
},
"scripts": {
"dev": "next dev --port 3000 --turbopack",
"build": "next build",
Expand All @@ -17,6 +20,7 @@
"@suspensive/react": "^3.3.2",
"@tanstack/react-query": "^5.84.1",
"@tanstack/react-query-devtools": "^5.84.1",
"axios": "^1.11.0",
"motion": "^12.23.12",
"next": "^15.4.2",
"react": "^19.1.0",
Expand All @@ -40,10 +44,11 @@
"autoprefixer": "^10.4.20",
"eslint": "^9.32.0",
"jsdom": "^26.1.0",
"msw": "^2.10.5",
"postcss": "^8.5.3",
"tailwindcss": "^4.1.5",
"typescript": "5.8.2",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.2.4"
}
}
}
17 changes: 0 additions & 17 deletions apps/web/public/circles.svg

This file was deleted.

Loading