Conversation
-상태에 따른 Navbar UI 변경 -zustand persist 기반 구현 -response body . request body type 구체화
-나의정보 , 회운정보 , 비밀번호 변경 , 큐레이션 정보 , 찜한 집 , 최근 본 집 , DHome 리포트 컴포넌트 구현
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 Walkthrough개요이 PR은 대규모 기능 추가 및 리팩토링으로, 새로운 Navbar 컴포넌트, 확장된 Curation 컴포넌트, List 및 ListDetail과 관련된 다양한 섹션 컴포넌트들, 지도 관련 필터바 및 드롭다운, 마이페이지 전체 구성, 인증 시스템 개선, 상태 관리(Zustand), 및 디렉토리 구조 재정렬을 포함합니다. Walkthrough이 PR은 전체 애플리케이션 구조를 재정렬하여 부동산 매물 조회 시스템을 확장합니다. 새로운 지도 기반 필터링, 상세 매물 목록 뷰, 사용자 계정 관리 페이지를 추가하고, 인증 플로우를 개선하며, 컴포넌트 계층 구조를 정리하고 원격 API 설정을 업데이트합니다. Changes
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 15
Note
Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
package.json (1)
14-28:⚠️ Potential issue | 🟡 Minor
@radix-ui/react-slider@1.3.6React 19 런타임 호환성 확인 필요
@radix-ui/react-slider@1.3.6은 peerDependencies에서 React 19를 명시적으로 지원하나, React 19 환경에서@radix-ui/react-primitive버전 불일치로 인한 런타임 크래시가 보고되었습니다.node_modules또는package-lock.json에서@radix-ui/react-primitive이2.1.3으로 제대로 해결되는지 확인하세요. 해결되지 않으면 lockfile을 재생성하세요.
react-kakao-maps-sdk@1.2.0은 React 16.8+ 지원으로 React 19와 호환성 문제가 없습니다.app/onboarding/page.tsx (1)
39-43:⚠️ Potential issue | 🔴 Critical렌더링 중
setTimeout호출은 버그를 유발합니다.
setTimeout이 컴포넌트 렌더 함수 본문에서 직접 호출되고 있습니다. 이는 다음과 같은 문제를 야기합니다:
- 매 렌더링마다 새로운 타이머가 생성됨
- 컴포넌트 언마운트 시 정리(cleanup)가 되지 않아 메모리 누수 발생
- 예측 불가능한 상태 업데이트
useEffect로 감싸고 cleanup 함수를 추가해야 합니다.🐛 권장 수정 사항
+import React, { useState, useEffect } from "react"; -import React, { useState } from "react";- if (currentStep === 3) { - setTimeout(() => { - setIsLoading(false); - }, 5000); - } + useEffect(() => { + if (currentStep === 3) { + const timer = setTimeout(() => { + setIsLoading(false); + }, 5000); + return () => clearTimeout(timer); + } + }, [currentStep]);
🤖 Fix all issues with AI agents
In @.gitignore:
- Line 42: Remove orval.config.ts from .gitignore so the config file remains
tracked (it is required to regenerate API clients), and instead add the
generated output paths to .gitignore: ignore the specific generated client file
shared/api/generated/dingle.ts and the generated models directory
shared/api/generated/model (or a pattern such as shared/api/generated/**) so
generated artifacts are not committed while keeping orval.config.ts under
version control.
In `@app/components/layout/Navbar.tsx`:
- Around line 3-20: Replace the useEffect/setMounted hydration guard in Navbar
with a useSyncExternalStore-based client check: remove the useEffect and mounted
state, and instead call useSyncExternalStore(subscribe, getSnapshot,
getServerSnapshot) where getSnapshot returns typeof window !== "undefined"
(client true) and getServerSnapshot returns false, and subscribe is a
no-op/unsubscribe function; update code references from mounted to the new
isClient value and keep the rest of Navbar (user, clearUser, router, etc.)
unchanged.
In `@app/components/list/list_section/detail/ContactModal.tsx`:
- Line 19: The ContactModal component uses non-existent Tailwind classes (e.g.,
"z-150", "animate-in", "fade-in", "zoom-in", "max-w-100", "h-18.5", "pt-7.5") in
the className on ContactModal.tsx; fix it by either installing and enabling the
tailwindcss-animate plugin and adding it to the plugins array (so animate-*
classes are available) and/or defining the custom z, spacing and size tokens in
your tailwind.config.ts under theme.extend (or in the `@theme` block in
app/globals.css) so "z-150", "max-w-100", "h-18.5", "pt-7.5" resolve, and
alternatively replace "z-150" with a built-in z-index like "z-50" if you prefer
not to add custom z-values; update ContactModal.tsx className accordingly after
making these config/plugin changes.
In `@app/components/mypage/HomeReport.tsx`:
- Around line 98-123: Replace the non-focusable div checkbox in HomeReport.tsx
with an accessible control: use a semantic input[type="checkbox"] or a button
with role="checkbox" (referencing toggleSelection, report.id and isSelected) so
it is keyboard-focusable and operable; ensure it exposes
aria-checked={isSelected}, proper tabIndex, and calls toggleSelection(report.id)
on click and onKeyDown (Space/Enter) if you choose a button, and preserve the
existing className logic (bg-main-400/border-main-400 vs
bg-white/border-gray-300) so visuals remain the same.
In `@app/components/mypage/HouseCard.tsx`:
- Line 47: The Tailwind classes like top-17, w-30 and h-30 used in the HouseCard
component's class string are not part of the default spacing scale and aren't
defined in globals.css, so they won't generate CSS; either replace those tokens
in the HouseCard JSX (look for the class string containing "absolute top-17
left-5 w-5 h-5 ...") with valid Tailwind spacing classes (e.g., top-16/top-20,
w-8/w-32, h-8/h-32) that match the intended layout, or add explicit spacing keys
to your Tailwind config under theme.extend.spacing (e.g., "17": "4.25rem", "30":
"7.5rem") and rebuild Tailwind so top-17/w-30/h-30 generate styles—pick one
approach and update the class string or tailwind.config.js accordingly.
In `@app/components/mypage/MyPageSidebar.tsx`:
- Around line 45-57: The menu items rendered in MyPageSidebar.tsx using
section.items.map are only clickable via mouse; make them keyboard-accessible by
either replacing the <li> with a <button> (preferred) or by adding
role="button", tabIndex={0}, and an onKeyDown handler that calls
onMenuChange(item) when Enter or Space is pressed; also ensure the currentMenu
state is reflected via aria-current or aria-pressed for screen readers and keep
the existing cn class logic intact so styling (currentMenu === item) remains
unchanged.
In `@app/components/signup/SignupForm.tsx`:
- Around line 44-48: The debug console.log in the SignupForm that prints
watchedValues (from watch() inside the useEffect) can leak PII; remove the
unconditional console.log or guard it so it only runs in non-production (e.g.,
check process.env.NODE_ENV !== 'production' or a DEV flag) and avoid logging raw
form values (or explicitly redact sensitive fields like email, phone, password)
inside the useEffect that references watchedValues.
In `@app/components/ui/Portal.tsx`:
- Around line 19-21: The Portal component returns createPortal(children,
document.querySelector(selector) as HTMLElement) which can throw if
document.querySelector(selector) is null; update the mounting path in the Portal
(check the result of document.querySelector(selector) into a variable like
mountNode) and only call createPortal when mountNode is non-null (or
create/fallback to a container) to avoid the unsafe cast; ensure you still
respect the mounted flag and return null when no mount node exists.
In `@app/components/ui/PriorityToggle.tsx`:
- Around line 77-79: In the PriorityToggle component update the image path
string to fix the typo: replace the incorrect "ativate" segment with "activate"
in the src template used for the <Image> (the expression using isSelected ?
"ativate" : "deactivate" should become isSelected ? "activate" : "deactivate"),
so that icons load correctly for each p.id and p.label.
In `@app/login/page.tsx`:
- Line 24: The stayLoggedIn checkbox is currently only a label click target and
its state (stayLoggedIn) is never passed to the server; either implement a
proper accessible <input type="checkbox"> bound to state and propagate
stayLoggedIn to the server action, or remove the UI. To implement: update the
form in page.tsx to include a real checkbox input (controlled by stayLoggedIn
via setStayLoggedIn) with an associated <label htmlFor="stayLoggedIn"> to ensure
keyboard/screenreader access, include stayLoggedIn in the form submission
payload so loginAction receives it, and modify the server-side
loginAction/session creation logic to use the received stayLoggedIn to set token
maxAge dynamically (instead of the hardcoded 1 day) or apply conditional session
expiry. Reference symbols: stayLoggedIn, setStayLoggedIn, loginAction, and any
session/token maxAge handling in the server action.
In `@app/map/layout.tsx`:
- Around line 5-20: The layout function currently ignores the required Next.js
App Router children prop, so update the exported layout component (layout) to
accept and render a children prop (e.g., function signature (props: { children:
React.ReactNode }) or ({ children })) and place {children} where page content
should be rendered (for example inside the list/map container or replacing <List
/> if appropriate); alternatively, if you intend this to be a regular component
rather than an app layout, rename/remove the layout export and move this JSX
into a normal component used by page.tsx (e.g., MapShell) and ensure page.tsx
imports and renders FilterBar, List, KakaoMap and the page content accordingly.
In `@app/store/userStore.ts`:
- Around line 41-42: Add the persist skipHydration flag and manual rehydrate
call to avoid SSR hydration mismatches: update the persist config where
createJSONStorage is used to include skipHydration: true (so the persisted
localStorage state is not applied during server render), and in the client-only
component lifecycle call useUserStore.persist.rehydrate() inside a useEffect to
restore state on the client; reference the persist config that contains
createJSONStorage(...) and the useUserStore.persist.rehydrate() API when making
these changes.
In `@app/types/login.ts`:
- Around line 3-6: The Zod v4 change requires passing error details as an object
to .min()/.max(), so update loginSchema (export const loginSchema) to replace
z.string().min(1, "아이디를 입력해주세요.") and z.string().min(1, "비밀번호를 입력해주세요.") with
the v4 form z.string().min(1, { message: "..." }) and make the same conversion
for all .min()/.max() usages in app/types/signup.ts; ensure you keep the same
numeric bounds and messages but wrap the message in { message: "..." } for each
affected schema call.
In `@orval.config.ts`:
- Line 6: 현재 orval.config.ts의 target이 원격
URL("https://d-home.o-r.kr/v3/api-docs")에 하드코딩되어 있어 빌드 재현성과 가용성 리스크가 있으니, target
값을 환경변수 기반 또는 로컬 파일로 대체하도록 변경하세요: 변경할 위치는 orval.config.ts의 target 프로퍼티이며 값은
process.env.ORVAL_OPENAPI_URL ?? "./openapi.json" 형태로 바꿔서 환경변수로 주입 가능하게 하고, 프로젝트
루트에 openapi.json을 커밋해 두세요(또는 둘다 지원). 추가로 CI 설정에 yarn api:gen(또는 사용하는 스크립트) 단계를
추가해 빌드 시 자동으로 orval 코드를 생성하도록 구성하세요.
In `@shared/api/auth.ts`:
- Around line 34-40: The login function is setting a manual "Content-Type":
"multipart/form-data" header which prevents Axios from adding the required
boundary for FormData; update the login implementation (the login function that
calls customInstance) to remove the manual headers when data is a FormData
instance so Axios can auto-generate the Content-Type/boundary (i.e., stop
passing the headers object or only pass headers when not sending FormData).
🟡 Minor comments (15)
app/components/list/ListItem.tsx-59-62 (1)
59-62:⚠️ Potential issue | 🟡 Minor좋아요 버튼 핸들러가 비어 있습니다.
좋아요 버튼의 클릭 핸들러가 구현되지 않았습니다. 추후 구현 예정이라면 TODO 주석을 더 명확하게 작성하거나, 기능이 준비될 때까지 버튼을 비활성화하는 것을 고려해 보세요.
app/components/mypage/MemberInfo.tsx-80-85 (1)
80-85:⚠️ Potential issue | 🟡 Minor저장 버튼에 기능이 구현되지 않았습니다.
저장 버튼이 있지만
onClick핸들러가 없어 클릭해도 아무 동작을 하지 않습니다. 사용자 경험 측면에서 혼란을 줄 수 있습니다.이 기능 구현을 위한 코드를 생성해 드릴까요, 아니면 추후 구현을 위한 이슈를 생성할까요?
app/components/list/list_section/detail/SchoolDetail.tsx-145-149 (1)
145-149:⚠️ Potential issue | 🟡 Minor링크 스타일이 적용되었으나 클릭 불가
isLink: true일 때 파란색 밑줄 스타일이 적용되지만, 실제로는<span>요소라 클릭할 수 없습니다. 사용자가 클릭을 시도할 수 있어 혼란을 줄 수 있습니다.🔧 권장 수정안
- <span - className={`flex-1 font-bold text-black ${row.isLink ? "text-blue-500 underline" : ""}`} - > - {row.value} - </span> + {row.isLink ? ( + <a + href={row.value} + target="_blank" + rel="noopener noreferrer" + className="flex-1 font-bold text-blue-500 underline hover:opacity-70" + > + {row.value} + </a> + ) : ( + <span className="flex-1 font-bold text-black"> + {row.value} + </span> + )}app/components/list/list_section/SchoolItem.tsx-18-18 (1)
18-18:⚠️ Potential issue | 🟡 Minor미사용 변수로 인한 파이프라인 실패
timeprop이 정의되었지만 사용되지 않아 ESLint 경고가 발생하고 있습니다. 이 변수를 사용하거나 제거해야 합니다.🔧 수정 방안
방안 1: time을 사용하지 않는 경우 제거
const SchoolItem = ({ name, type, distance, - time, onClick, }: SchoolItemProps) => {인터페이스에서도 제거:
interface SchoolItemProps { name: string; type: string; distance: string; - time?: string; onClick?: () => void; }방안 2: time을 실제로 사용
<div className="flex items-center gap-1 ml-1 pt-0.5"> <Image src="/icons/feature/list_detail/school/mappin.svg" alt="Marker" width={14} height={14} /> <span className="text-[18px] font-bold text-[`#2EA98C`]"> {distance} </span> + {time && ( + <span className="text-[14px] text-[`#9D9D9D`] ml-2"> + ({time}) + </span> + )} </div>app/components/mypage/UserInfo.tsx-32-32 (1)
32-32:⚠️ Potential issue | 🟡 Minor
preferences미사용으로 ESLint 경고 발생Line 32의
preferences가 렌더링에 쓰이지 않아 CI 경고가 납니다. 배열을 활용해 렌더링하도록 정리하거나 변수를 제거해 주세요.🔧 배열 기반 렌더링 예시
- const preferences = ["오피스텔", "강남구", "관악구", "송파구"]; + const preferences = ["오피스텔", "강남구", "관악구", "송파구"]; + const [preferredType, ...preferredAreas] = preferences; ... - <span className="px-3 py-1.5 bg-[`#DEFAF2`] border border-main-400 text-main-400 rounded-md text-[14px] font-medium"> - 오피스텔 - </span> + <span className="px-3 py-1.5 bg-[`#DEFAF2`] border border-main-400 text-main-400 rounded-md text-[14px] font-medium"> + {preferredType} + </span> ... - <span className="px-3 py-1.5 bg-[`#DEFAF2`] border border-main-400 text-main-400 rounded-full text-[14px] font-medium"> - 강남구 - </span> - <span className="px-3 py-1.5 bg-[`#DEFAF2`] border border-main-400 text-main-400 rounded-full text-[14px] font-medium"> - 관악구 - </span> - <span className="px-3 py-1.5 bg-[`#DEFAF2`] border border-main-400 text-main-400 rounded-full text-[14px] font-medium"> - 송파구 - </span> + {preferredAreas.map((area) => ( + <span + key={area} + className="px-3 py-1.5 bg-[`#DEFAF2`] border border-main-400 text-main-400 rounded-full text-[14px] font-medium" + > + {area} + </span> + ))}Also applies to: 136-155
app/components/list/list_section/SchoolSection.tsx-95-100 (1)
95-100:⚠️ Potential issue | 🟡 Minor잘못된 클래스 토큰(
bg-) 제거 필요유효하지 않은 클래스 문자열이 남아 있어 스타일 빌드에 잡음이 됩니다. 제거해 주세요.
🧹 정리 예시
- <div className="flex items-center px-3 py-2 bg-[`#F8FAFB`] rounded-lg mb-6 bg-"> + <div className="flex items-center px-3 py-2 bg-[`#F8FAFB`] rounded-lg mb-6">app/components/mypage/LikedHouses.tsx-74-76 (1)
74-76:⚠️ Potential issue | 🟡 Minor기본 선택값 하드코딩은 실사용 시 오동작 가능성이 있습니다.
초기 상태가["1","2"]라 비교 버튼이 항상 활성화됩니다. 실제 데이터 기반으로 초기화하거나 빈 배열로 시작하는 편이 안전합니다.🔧 개선 예시
- const [selectedIds, setSelectedIds] = useState<string[]>(["1", "2"]); + const [selectedIds, setSelectedIds] = useState<string[]>([]);app/components/list/list_section/SummarySection.tsx-32-103 (1)
32-103:⚠️ Potential issue | 🟡 Minor아이콘 버튼/이미지의 접근성 라벨을 보완해주세요.
하트 버튼에
aria-label이 없고, 5개의 이미지 컴포넌트가 모두alt="Area"로 설정되어 있습니다. 스크린 리더 사용자가 각 요소의 의미를 정확히 인식할 수 있도록 다음과 같이 수정해 주세요:🔧 개선 예시
- <button className="text-[`#30CEA1`] mt-2"> + <button className="text-[`#30CEA1`] mt-2" aria-label="관심 매물 토글"> <svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"> <path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" /> </svg> </button> - alt="Area" + alt="면적" - alt="Area" + alt="방/욕실" - alt="Area" + alt="층수" - alt="Area" + alt="방향" - alt="Area" + alt="큐레이션"app/components/list/list_section/CurationSection.tsx-101-133 (1)
101-133:⚠️ Potential issue | 🟡 Minor툴팁 트리거를 키보드/스크린리더에서도 접근 가능하게 해주세요.
현재 모든 카테고리 마커(안전, 환경, 접근성, 편의, 소음)가
div+ hover 이벤트로만 작동하여 포커스가 불가능합니다. 각 마커를button+onFocus/onBlur+aria-describedby(카테고리별 유니크 id) +role="tooltip"으로 전환해 주세요. 키보드 탭/스페이스로 모든 마커의 툴팁이 열리는지 확인 부탁드립니다.🔧 개선 예시 (1개 마커 기준)
- <div - className="absolute -top-13 left-1/2 -translate-x-1/2 flex flex-col items-center cursor-help group" - onMouseEnter={() => setHoveredCategory("안전")} - onMouseLeave={() => setHoveredCategory(null)} - > + <button + type="button" + className="absolute -top-13 left-1/2 -translate-x-1/2 flex flex-col items-center cursor-help group bg-transparent border-0 p-0" + onMouseEnter={() => setHoveredCategory("안전")} + onMouseLeave={() => setHoveredCategory(null)} + onFocus={() => setHoveredCategory("안전")} + onBlur={() => setHoveredCategory(null)} + aria-describedby="tooltip-safe" + > ... - {hoveredCategory === "안전" && ( - <div className="absolute mb-2 top-5 -left-1 bg-[`#E8FBF6`] border border-[`#30CEA1`] rounded-md px-3 py-2 shadow-lg z-50 w-45"> + {hoveredCategory === "안전" && ( + <div id="tooltip-safe" role="tooltip" className="absolute mb-2 top-5 -left-1 bg-[`#E8FBF6`] border border-[`#30CEA1`] rounded-md px-3 py-2 shadow-lg z-50 w-45"> ... - </div> + </button>app/components/list/ListDetail.tsx-54-70 (1)
54-70:⚠️ Potential issue | 🟡 Minor
<main>중첩은 HTML5 표준 위반입니다.
HTML Living Standard에 따르면 문서 내<main>요소는 1개만 허용되며,<main>요소 내에 또 다른<main>을 중첩할 수 없습니다. 외부 컨테이너를div또는section으로 변경해 주세요.🔧 개선 예시
- <main className="relative flex flex-col w-full h-full bg-white overflow-hidden"> + <div className="relative flex flex-col w-full h-full bg-white overflow-hidden"> {/* Top Header (Fixed) */} <header className="sticky top-0 z-50 flex items-center justify-between w-full h-16 py-5 px-5 bg-white border-b border-[`#E5E5E5`] flex-none"> <h1 className="flex-1 text-[20px] font-bold text-[`#000000`] truncate"> 약수하이츠 11동 </h1> <button onClick={onClose} className="flex-1 flex justify-end text-[`#000000`] hover:opacity-70 transition-opacity" > <X className="w-6 h-6" /> </button> </header> {/* Main Scrollable Content */} <main className="flex-1 overflow-y-auto no-scrollbar scroll-smooth"> <SummarySection /> - </main> + </div>app/components/mypage/CurationInfo.tsx-5-5 (1)
5-5:⚠️ Potential issue | 🟡 Minor사용되지 않는
MapPinimport를 제거하세요.ESLint 경고에서 확인된 바와 같이
MapPin이 import되었지만 사용되지 않습니다.🔧 수정 제안
-import { ChevronDown, ChevronUp, MapPin } from "lucide-react"; +import { ChevronDown, ChevronUp } from "lucide-react";app/components/curation/Curation.tsx-1-1 (1)
1-1:⚠️ Potential issue | 🟡 Minor사용되지 않는
useEffectimport를 제거하세요.ESLint 경고에서 확인된 바와 같이
useEffect가 import되었지만 사용되지 않습니다.🔧 수정 제안
-import React, { useEffect } from "react"; +import React from "react";app/map/page.tsx-1-6 (1)
1-6:⚠️ Potential issue | 🟡 Minor사용되지 않는 import를 제거하세요.
React와FilterBar모두 import되었지만 사용되지 않습니다. Next.js App Router에서는 React를 명시적으로 import할 필요가 없습니다.또한 페이지가 빈
<div>만 반환하는데,layout.tsx의 문제와 연관되어 있습니다. layout이 children을 렌더링하지 않으므로 이 페이지 내용은 화면에 표시되지 않습니다.🔧 수정 제안
-import React from "react"; -import FilterBar from "../components/map/FilterBar"; - -const page = () => { - return <div></div>; +export default function MapPage() { + return null; -}; - -export default page; +}app/components/map/FilterBar.tsx-135-139 (1)
135-139:⚠️ Potential issue | 🟡 Minor검색 입력에 접근성 라벨이 필요합니다.
시각적 텍스트만으로는 스크린리더가 입력 목적을 알 수 없습니다.
aria-label추가를 권장합니다.🔧 제안 수정안
<input type="text" placeholder="지역, 단지, 지하철역 등을 입력하세요" + aria-label="검색어 입력" className="w-[366px] h-12 pl-5 pr-12 bg-[`#F8FAFB`] border border-[`#E4E4E4`] rounded-lg text-[16px] text-[`#707070`] placeholder:text-[`#C4C4C4`] focus:outline-none focus:border-[`#30CEA1`] transition-colors" />app/components/map/FilterBar.tsx-68-91 (1)
68-91:⚠️ Potential issue | 🟡 Minor가격 라벨의 ‘최대’ 기준이 월세/매매가와 불일치합니다.
현재
format은max >= 100000일 때만 “최대”로 표시되어, 월세(최대 500만)는 “최대”가 표시되지 않고, 매매가(최대 100억)는 10억(100000)에서도 “최대”로 표시되는 문제가 있습니다. 각 범위의 상한값을 기준으로 판단하도록 분리하는 게 정확합니다.🔧 제안 수정안
- const format = (range: [number, number], unit: string) => { + const format = ( + range: [number, number], + unit: string, + maxLimit: number, + ) => { const min = range[0]; const max = range[1]; const formattedMin = min >= 10000 ? `${(min / 10000).toFixed(0)}억` : `${min}만`; const formattedMax = - max >= 100000 + max >= maxLimit ? "최대" : max >= 10000 ? `${(max / 10000).toFixed(0)}억` : `${max}만`; return `${unit}${formattedMin}~${formattedMax}`; }; const priceParts: string[] = []; if (selectedTypes.includes("전세") || selectedTypes.includes("월세")) { - priceParts.push(`보${format(depositRange, "")}`); + priceParts.push(`보${format(depositRange, "", 100000)}`); } if (selectedTypes.includes("월세")) { - priceParts.push(`월${format(monthlyRentRange, "")}`); + priceParts.push(`월${format(monthlyRentRange, "", 500)}`); } if (selectedTypes.includes("매매")) { - priceParts.push(`매${format(salePriceRange, "")}`); + priceParts.push(`매${format(salePriceRange, "", 1000000)}`); }
🧹 Nitpick comments (24)
app/signup/page.tsx (1)
10-10: 스크롤바 숨김 UX 영향만 한 번 체크해주세요.Line 10처럼
no-scrollbar를 적용하면 스크롤 가능 영역의 발견성이 떨어질 수 있습니다. 필요 시 시각적 힌트(그라디언트/섀도)나 스크롤바 유지 옵션을 함께 고려해 주세요.next.config.ts (1)
4-12: localhost 허용은 dev 한정으로 두는 편이 안전합니다.Line 4-12의 설정이 프로덕션에 포함될 필요가 없다면
NODE_ENV기반으로 제한해 두는 걸 권장합니다.✅ 제안 변경안 (dev 한정)
import type { NextConfig } from "next"; -const nextConfig: NextConfig = { - images: { - remotePatterns: [ - { - protocol: "http", - hostname: "localhost", - port: "3845", - }, - ], - }, - /* config options here */ -}; +const isDev = process.env.NODE_ENV === "development"; + +const nextConfig: NextConfig = { + images: isDev + ? { + remotePatterns: [ + { + protocol: "http", + hostname: "localhost", + port: "3845", + }, + ], + } + : undefined, + /* config options here */ +};app/components/list/list_section/AgencySection.tsx (1)
24-71: 중개사 정보는 props로 주입되도록 분리하는 것을 권장합니다.
현재 하드코딩된 값은 실제 데이터 연동 시 재사용/테스트가 어려워집니다.app/components/list/list_section/FacilitiesSection.tsx (1)
6-34: 정적 데이터는 컴포넌트 밖으로 분리하고 key는 label 사용 권장.
렌더마다 배열이 재생성되고idxkey는 향후 정렬 변경 시 UI 깨짐을 유발할 수 있습니다.♻️ 제안 수정
-const FacilitiesSection = () => { - const options = [ +const options = [ { label: "냉장고", icon: "/icons/option/refrigerator.svg" }, ... - ]; - - const buildingFacilities = [ + ]; + +const buildingFacilities = [ { label: "헬스장", icon: "/icons/option/gym.svg" }, ... - ]; - + ]; + +const FacilitiesSection = () => { return ( <section className="px-5 py-8 bg-white" id="facilities"> ... - {options.map((item, idx) => ( - <div key={idx} className="flex flex-col items-center gap-2"> + {options.map((item) => ( + <div key={item.label} className="flex flex-col items-center gap-2"> ... - {buildingFacilities.map((item, idx) => ( - <div key={idx} className="flex flex-col items-center gap-2"> + {buildingFacilities.map((item) => ( + <div key={item.label} className="flex flex-col items-center gap-2">Also applies to: 46-73
app/components/map/KakaoMap.tsx (1)
7-9: Kakao API 키 미설정 시 더 명확한 오류 메시지 표시 고려현재 코드는 useKakaoLoader 훅의 error 상태(13-18줄)를 통해 이미 로더 실패를 처리하고 있습니다. 그러나 API 키가 미설정될 경우 "지도를 불러올 수 없습니다"라는 일반적인 오류 메시지만 표시되어 디버깅이 어렵습니다.
API 키 미설정을 명시적으로 감지하여 "Kakao 지도 API 키가 설정되지 않았습니다"와 같은 구체적인 메시지를 표시하면 개발 단계에서 원인 파악이 더 용이해집니다.
🛡️ 제안 수정
+ const appKey = process.env.NEXT_PUBLIC_KAKAO_MAP_API_KEY; const [loading, error] = useKakaoLoader({ - appkey: process.env.NEXT_PUBLIC_KAKAO_MAP_API_KEY || "", // 발급받은 JavaScript 키 + appkey: appKey ?? "", // 발급받은 JavaScript 키 }); + + if (!appKey) { + return ( + <div className="w-full h-full flex items-center justify-center bg-gray-100 text-red-500"> + Kakao 지도 API 키가 설정되지 않았습니다. + </div> + ); + }app/components/list/list_section/ReviewSection.tsx (1)
16-22:fill사용 시sizesprop이 필요합니다.Next.js 공식 문서에 따르면,
fill을 사용할 때sizes를 지정하지 않으면 브라우저가100vw로 가정하여 불필요하게 큰 이미지를 다운로드할 수 있습니다. 제안된sizes="100vw"는 브라우저 기본값과 동일하므로 개선 효과가 제한적입니다.높이가 고정된(
h-56) 상황이므로 두 가지 방안을 검토하세요:
width/heightprop 사용: 실제 크기를 알 수 있다면fill대신 명시적 크기 지정fill유지 시: 컨테이너의 실제 너비 레이아웃에 맞는sizes지정 (예:sizes="100vw"또는 반응형 레이아웃에 맞는 값)app/components/mypage/MemberInfo.tsx (2)
9-24: 하드코딩된 사용자 데이터 확인 필요
username을 제외한id,phone,userStore에서 해당 필드들을 가져오거나, 의도적인 목업 데이터라면 주석으로 명시하는 것이 좋습니다.
47-75: 비제어 컴포넌트(defaultValue) 사용 시 주의사항
defaultValue를 사용하면 React가 입력값을 추적하지 않아 폼 제출 시 변경된 값을 가져오기 어렵습니다. 저장 기능 구현 시useState와value/onChange를 사용하는 제어 컴포넌트 패턴이나,useRef로 값을 읽는 방식을 고려하세요.app/components/list/ListItem.tsx (2)
100-117: 태그 스타일링 로직을 별도 유틸리티로 분리하세요.중첩된 삼항 연산자가 가독성을 저하시킵니다. 태그별 스타일을 객체 매핑으로 분리하면 유지보수가 용이해집니다.
♻️ 권장 리팩토링
+const TAG_STYLES: Record<string, string> = { + 소음: "border-[`#FBBA78`] text-[`#FBBA78`] bg-[`#FFFCF6`]", + 접근성: "border-[`#7CB7CD`] text-[`#7CB7CD`] bg-[`#F7FCFE`]", + 안전: "border-[`#F48787`] text-[`#F48787`] bg-[`#FFF7F7`]", + 편의: "border-[`#AB9FD5`] text-[`#AB9FD5`] bg-[`#FAF9FD`]", + default: "border-[`#82AA82`] text-[`#82AA82`] bg-[`#F8FCF8`]", +}; + +const getTagStyle = (tag: string) => TAG_STYLES[tag] || TAG_STYLES.default;{property.tags.map((tag, idx) => ( <span key={idx} - className={`px-3 py-1 text-[12px] rounded-full font-bold border ${ - tag === "소음" - ? "border-[`#FBBA78`] text-[`#FBBA78`] bg-[`#FFFCF6`]" - : tag === "접근성" - ? "border-[`#7CB7CD`] text-[`#7CB7CD`] bg-[`#F7FCFE`]" - : tag === "안전" - ? "border-[`#F48787`] text-[`#F48787`] bg-[`#FFF7F7`]" - : tag === "편의" - ? "border-[`#AB9FD5`] text-[`#AB9FD5`] bg-[`#FAF9FD`]" - : "border-[`#82AA82`] text-[`#82AA82`] bg-[`#F8FCF8`]" - }`} + className={`px-3 py-1 text-[12px] rounded-full font-bold border ${getTagStyle(tag)}`} >
100-101: 배열 인덱스를key로 사용하는 것은 권장되지 않습니다.태그 순서가 변경되거나 동적으로 추가/삭제되는 경우, 인덱스 기반 key는 불필요한 리렌더링이나 상태 불일치를 유발할 수 있습니다. 태그 문자열 자체를 key로 사용하세요 (중복이 없다면).
♻️ 권장 수정
- {property.tags.map((tag, idx) => ( - <span - key={idx} + {property.tags.map((tag) => ( + <span + key={tag}app/components/list/list_section/detail/ContactModal.tsx (1)
17-21: 키보드 접근성 개선을 고려하세요.현재 모달은 Escape 키로 닫기, 포커스 트랩(focus trap) 기능이 없습니다. 접근성(a11y) 향상을 위해 다음 기능 추가를 권장합니다:
- Escape 키로 모달 닫기
- 모달 열릴 때 첫 번째 포커스 가능 요소로 포커스 이동
role="dialog"및aria-modal="true"속성 추가♻️ Escape 키 핸들러 예시
+import { useEffect } from "react"; + const ContactModal = ({ isOpen, onClose }: ContactModalProps) => { + useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + if (isOpen) { + document.addEventListener("keydown", handleEscape); + return () => document.removeEventListener("keydown", handleEscape); + } + }, [isOpen, onClose]); + if (!isOpen) return null;<div className="fixed inset-0 z-150 flex items-center justify-center px-5 bg-black/50" onClick={onClose} + role="dialog" + aria-modal="true" >app/components/list/list_section/DetailInfoSection.tsx (2)
23-31: 비표준 Tailwind aspect ratio 클래스 사용
aspect-401/300은 Tailwind CSS의 기본 클래스가 아닙니다.tailwind.config에 커스텀 설정이 없다면 이 클래스는 동작하지 않습니다.♻️ 권장 수정안
- <div className="relative w-full aspect-401/300 bg-white border border-[`#E5E5E5`] rounded-xl mb-8 overflow-hidden flex items-center justify-center p-4"> + <div className="relative w-full aspect-[401/300] bg-white border border-[`#E5E5E5`] rounded-xl mb-8 overflow-hidden flex items-center justify-center p-4">또는 Tailwind의 기본 aspect ratio 클래스(
aspect-video,aspect-square등)를 사용하세요.
47-51: subValue 로직 명확성 개선 필요
subValue가"/"로 설정되어 있지만, 실제로는 사용되지 않고 단순히 "면적" 버튼 표시 여부를 결정합니다. boolean 플래그(showAreaButton)를 사용하는 것이 더 명확합니다.♻️ 권장 수정안
{ label: "단지명", value: "약수하이츠" }, { label: "주소", value: "서울시 중구 다산로10길 30 101동 1102호" }, { label: "매물 형태", value: "아파트" }, - { label: "전용/공용면적", value: "110.33m²/142.65m²", subValue: "/" }, + { label: "전용/공용면적", value: "110.33m²/142.65m²", showAreaButton: true },- {detail.subValue && ( + {detail.showAreaButton && (app/components/list/list_section/SchoolItem.tsx (1)
22-22: 선택적 onClick에 대한 커서 스타일 고려
onClick이undefined일 때도cursor-pointer클래스가 적용됩니다. 클릭 핸들러가 없을 때는 포인터 커서를 제거하는 것이 UX에 더 좋습니다.♻️ 권장 수정안
- <div className="group cursor-pointer relative" onClick={onClick}> + <div className={`group relative ${onClick ? 'cursor-pointer' : ''}`} onClick={onClick}>app/components/list/list_section/detail/SchoolDetail.tsx (1)
207-218: 인라인 SVG 대신 lucide-react 아이콘 사용 권장이미
lucide-react를 import하고 있으므로, 인라인 SVG 대신ChevronDown아이콘을 사용하면 일관성을 유지할 수 있습니다.♻️ 권장 수정안
import 추가:
-import { X, ChevronLeft, Building2, Users } from "lucide-react"; +import { X, ChevronLeft, ChevronDown, Building2, Users } from "lucide-react";SVG 교체:
<button className="w-full h-14 border border-[`#E5E5E5`] rounded-xl flex items-center justify-center gap-2 text-[16px] font-bold text-black hover:bg-gray-50 transition-colors"> 배정 단지 더보기 - <svg - width="20" - height="20" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - strokeWidth="2" - strokeLinecap="round" - strokeLinejoin="round" - > - <path d="m6 9 6 6 6-6" /> - </svg> + <ChevronDown className="w-5 h-5" /> </button>app/components/mypage/RecentlyViewedHouses.tsx (2)
73-76: 디버그용 console.log 및 주석 처리된 코드
console.log는 프로덕션 코드에서 제거해야 합니다. 주석 처리된 네비게이션 코드가 있다면 TODO 주석을 추가하거나 구현하는 것이 좋습니다.♻️ 권장 수정안
const handleHouseClick = (id: string) => { - console.log("Navigating to house detail:", id); - // window.location.href = `/property/${id}`; + // TODO: 매물 상세 페이지로 네비게이션 구현 + // router.push(`/property/${id}`); };
103-136: 페이지네이션 UI가 비기능적페이지네이션 버튼이 렌더링되지만 실제로 동작하지 않습니다. 현재 페이지 상태 관리나 클릭 핸들러가 없습니다. 추후 구현 예정이라면 TODO 주석을 추가하는 것이 좋습니다.
페이지네이션 기능 구현을 도와드릴까요? 또는 이 작업을 추적할 이슈를 생성할까요?
app/components/ui/RangeSlider.tsx (1)
29-29: 매직 스트링 사용 개선 필요
title !== "매매가"체크는 매직 스트링에 의존하고 있어 유지보수가 어렵습니다. 단위 표시 로직을 더 명확하게 제어할 수 있는 방법을 고려하세요.♻️ 권장 수정안
unitSuffixprop을 추가하여 단위를 명시적으로 전달:interface RangeSliderProps { title: string; range: [number, number]; setRange: (value: [number, number]) => void; max: number; formatValue: (val: number) => string; showUnit?: boolean; + unitSuffix?: string; } export default function RangeSlider({ title, range, setRange, max, formatValue, showUnit = true, + unitSuffix = "", }: RangeSliderProps) {<span className="text-[16px] font-bold text-[`#30CEA1`]"> {formatValue(range[0])} - {showUnit && title !== "매매가" ? "원" : ""} ~ {formatValue(range[1])} + {showUnit ? unitSuffix : ""} ~ {formatValue(range[1])}{showUnit ? unitSuffix : ""} </span>app/components/map/dropdown/SpaceDropdown.tsx (1)
6-9: 타입 일관성 개선 권장
setRange타입이React.Dispatch<React.SetStateAction<[number, number]>>로 정의되어 있지만,RangeSlider는(value: [number, number]) => void를 기대합니다. 기능적으로는 호환되지만, 타입을 일치시키면 코드 일관성이 향상됩니다.♻️ 권장 수정안
interface SpaceDropdownProps { range: [number, number]; - setRange: React.Dispatch<React.SetStateAction<[number, number]>>; + setRange: (value: [number, number]) => void; }app/components/mypage/CurationInfo.tsx (1)
250-263: 저장 버튼이 콘솔에만 로깅하고 있습니다.현재
console.log만 실행되며 실제 API 연동이 없습니다. 퍼블리싱 단계로 의도된 것이라면 TODO 주석을 추가하는 것이 좋겠습니다.💡 제안
<button onClick={() => { + // TODO: API 연동 필요 console.log("Saving Curation Data:", { selectedPriorities, selectedRegions, selectedHousingType, }); }}app/components/mypage/ChangePassword.tsx (1)
35-54: 폼 입력 필드에 상태 관리와 접근성 속성이 누락되었습니다.
- 입력 필드가 제어되지 않는 컴포넌트(uncontrolled)입니다. 실제 기능 구현 시
useState로 값을 관리해야 합니다.<label>과<input>연결을 위한id/htmlFor속성이 없어 접근성이 떨어집니다.name속성이 없어 폼 제출 시 데이터 식별이 불가능합니다.♻️ 접근성 개선 제안
{[ - { label: "현재 비밀번호", placeholder: "" }, - { label: "새 비밀번호", placeholder: "" }, - { label: "새 비밀번호 확인", placeholder: "" }, + { label: "현재 비밀번호", name: "currentPassword" }, + { label: "새 비밀번호", name: "newPassword" }, + { label: "새 비밀번호 확인", name: "confirmPassword" }, ].map((item) => ( - <div key={item.label} className="flex gap-18 items-center w-full"> + <div key={item.name} className="flex gap-18 items-center w-full"> <div className="w-40 flex items-start shrink-0"> - <span className="text-[20px] font-bold text-black leading-normal"> + <label + htmlFor={item.name} + className="text-[20px] font-bold text-black leading-normal" + > {item.label} - </span> + </label> </div> <input + id={item.name} + name={item.name} type="password" className="w-100 h-12 border border-gray-300 rounded-md px-4 outline-none focus:border-main-400 transition-colors" /> </div> ))}app/components/ui/Checkbox.tsx (1)
11-39: 접근성 개선이 필요합니다: 키보드 탐색 및 스크린 리더 지원 부족.현재 구현은
<div>에onClick을 사용하고 있어 다음 문제가 있습니다:
- 키보드로 포커스 및 토글 불가능 (Tab/Space/Enter)
- 스크린 리더가 체크박스로 인식하지 못함
숨겨진 native
<input type="checkbox">를 사용하거나, 최소한role,tabIndex,onKeyDown속성을 추가하는 것이 좋습니다.♿ 접근성 개선 제안 (hidden native input 사용)
export default function Checkbox({ label, checked, onChange }: CheckboxProps) { return ( <label className="flex items-center gap-2.5 cursor-pointer select-none group"> + <input + type="checkbox" + checked={checked} + onChange={onChange} + className="sr-only" + /> <div - onClick={onChange} className={`w-5 h-5 rounded border flex items-center justify-center transition-colors ${ checked ? "bg-[`#30CEA1`] border-[`#30CEA1`]" : "border-[`#D9D9D9`] bg-white group-hover:border-[`#30CEA1`]" }`} + aria-hidden="true" >app/components/mypage/MyPageSidebar.tsx (1)
6-13:MyPageMenu타입 중복 정의는 드리프트 위험이 큽니다.공통 타입을 한 곳에서 export하고 여기서는 import로 재사용하는 편이 안전합니다.
♻️ 제안 변경
-import { cn } from "@/app/lib/utils"; - -export type MyPageMenu = - | "나의정보" - | "회원정보" - | "비밀번호 변경" - | "큐레이션 정보" - | "찜한 집" - | "최근 본 집" - | "D.HOME 리포트"; +import { cn } from "@/app/lib/utils"; +import type { MyPageMenu } from "@/app/mypage/MyPageContext";app/mypage/MyPageContext.tsx (1)
5-12: MyPageMenu 타입 중복 정의는 드리프트 위험이 큽니다.
app/components/mypage/MyPageSidebar.tsx에도 동일한 유니온 타입이 별도로 정의되어 있어 메뉴 라벨 변경 시 불일치가 발생할 수 있습니다. 공용 타입 파일로 분리해 단일 소스로 관리하는 편이 안전합니다.
| # typescript | ||
| *.tsbuildinfo | ||
| next-env.d.ts | ||
| orval.config.ts No newline at end of file |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# orval.config.ts가 이미 Git에 추적되는지 확인
echo "=== Git tracking status for orval.config.ts ==="
git ls-files -- orval.config.ts
# 생성물 경로가 이미 ignore되고 있는지 확인
echo ""
echo "=== Checking .gitignore for shared/api/generated ==="
rg -n "shared/api/generated" .gitignore
# .gitignore 전체 내용 확인 (라인 40-45 근처)
echo ""
echo "=== .gitignore context around line 42 ==="
cat -n .gitignore | sed -n '35,50p'Repository: D-ingle/frontend
Length of output: 352
🏁 Script executed:
#!/bin/bash
# orval.config.ts 파일 존재 여부 및 내용 확인
echo "=== Checking orval.config.ts existence and content ==="
if [ -f "orval.config.ts" ]; then
echo "File exists. First 30 lines:"
head -30 orval.config.ts
else
echo "orval.config.ts not found"
fi
# 생성된 API 파일이 있는지 확인
echo ""
echo "=== Looking for generated API files ==="
find . -type f -name "*generated*" -o -name "*api*" | grep -E "(src|shared|lib)" | head -20
# 전체 .gitignore 내용 확인
echo ""
echo "=== Full .gitignore content ==="
cat .gitignoreRepository: D-ingle/frontend
Length of output: 2384
orval.config.ts를 .gitignore에 추가하면 안 됩니다. 대신 생성된 파일들을 ignore하세요.
orval.config.ts는 API 클라이언트 재생성에 필요한 설정 파일이므로, Git에서 추적되어야 하고 모든 개발자/CI가 접근 가능해야 합니다. 현재 코드에서 확인된 생성 경로는 shared/api/generated/dingle.ts와 shared/api/generated/model이므로, 다음과 같이 변경하세요:
-orval.config.ts
+/shared/api/generated/📝 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.
| orval.config.ts | |
| /shared/api/generated/ |
🤖 Prompt for AI Agents
In @.gitignore at line 42, Remove orval.config.ts from .gitignore so the config
file remains tracked (it is required to regenerate API clients), and instead add
the generated output paths to .gitignore: ignore the specific generated client
file shared/api/generated/dingle.ts and the generated models directory
shared/api/generated/model (or a pattern such as shared/api/generated/**) so
generated artifacts are not committed while keeping orval.config.ts under
version control.
| import React from "react"; | ||
| import Image from "next/image"; | ||
| import Link from "next/link"; | ||
| import { cn } from "@/app/lib/utils"; | ||
| import { useEffect, useState } from "react"; | ||
| import { useUserStore } from "@/app/store/userStore"; | ||
| import { logoutAction } from "@/app/login/actions"; | ||
| import { useRouter } from "next/navigation"; | ||
|
|
||
| export default function Navbar() { | ||
| const router = useRouter(); | ||
| const { user, clearUser } = useUserStore(); | ||
| const [mounted, setMounted] = useState(false); | ||
|
|
||
| // hydration 에러 방지 (client-side의 localStorage 데이터가 서버 렌더링 결과와 다를 수 있음) | ||
| useEffect(() => { | ||
| setMounted(true); | ||
| }, []); |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
hydration guard useSyncExternalStore React best practice
💡 Result:
A “hydration guard” (e.g., const [mounted,setMounted]=useState(false); useEffect(()=>setMounted(true),[]) and rendering null until mounted) avoids hydration mismatches by skipping SSR output—but it also throws away SSR benefits (content/SEO/TTFB) and is usually a last resort.
Best practice: if the mismatch is caused by reading client-only state (media query, localStorage, time, window size, etc.), prefer useSyncExternalStore with getServerSnapshot so the server render and the hydration render use the same snapshot, and then React can switch to the real client snapshot after hydration. This is exactly what the third argument is for. [1][2]
Recommended pattern (replace “hydration guard” with SSR-safe snapshots)
import { useSyncExternalStore } from "react";
function subscribeNoop() {
return () => {};
}
function useClientOnlyValue<T>(getClient: () => T, getServer: () => T) {
return useSyncExternalStore(subscribeNoop, getClient, getServer);
}
// Example: prefers-reduced-motion
function usePrefersReducedMotion() {
return useClientOnlyValue(
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false // must match what the server rendered during hydration
);
}Using a “no-op store” subscription is a known/accepted approach when you’re not truly subscribing to an external store, but want the SSR/hydration snapshot split. [2]
Practical rules of thumb
- Always provide
getServerSnapshotin SSR; omitting it can cause SSR/hydration problems (and is a common pitfall). [1][3] getSnapshotmust be pure + cached/stable: if nothing changed, return the exact same value (especially for objects/arrays) or React will warn/re-render excessively. [1]- Keep
subscribe/getSnapshotstable (define outside the component or memoize) to avoid unnecessary re-subscribes. [4]
If you share what your current “hydration guard” is protecting (e.g., theme from localStorage, media queries, date/time), I can sketch the exact getServerSnapshot/getSnapshot pair for it.
Sources: React docs on useSyncExternalStore and snapshot caching/SSR semantics [1], TkDodo on avoiding hydration mismatches with getServerSnapshot (including no-op subscription) [2], React issue discussing SSR usage pitfalls [3], Epic React best-practices around stable functions and snapshots [4].
References: [1] (react.dev) [2] (tkdodo.eu) [3] (github.com) [4] (epicreact.dev)
Citations:
- 1: https://react.dev/reference/react/useSyncExternalStore?utm_source=openai
- 2: https://tkdodo.eu/blog/avoiding-hydration-mismatches-with-use-sync-external-store?utm_source=openai
- 3: Bug: Using useSyncExternalStore with SSR and no getServerSnapshot results in error react/react#28431
- 4: https://www.epicreact.dev/use-sync-external-store-demystified-for-practical-react-development-w5ac0?utm_source=openai
🌐 Web query:
react-hooks/set-state-in-effect lint error useEffect setState hydration
💡 Result:
The react-hooks/set-state-in-effect lint error is triggered when you call a useState setter synchronously inside useEffect (e.g. useEffect(() => setX(...), [...])). React’s hook lint docs flag this because it forces an extra render pass and is often “derived state” that can be computed during render instead. [1]
Fix patterns (pick the one that matches your case)
1) If the state is derived from props/state: don’t store it—derive it
function Component({ selectedId, items }) {
const selected = items.find(i => i.id === selectedId);
return <div>{selected?.name}</div>;
}This is exactly what the rule wants. [1]
2) If you were “initializing” state in an effect: use a lazy useState initializer
Works when the value is available on first client render (and you can safely guard window):
const [theme, setTheme] = useState(() => {
if (typeof window === "undefined") return "light"; // SSR fallback
return localStorage.getItem("theme") ?? "light";
});This often eliminates the effect entirely (and the lint error). [1]
3) If it’s a hydration / SSR case (client-only data like localStorage) and you must update after mount
You generally have three sane options:
A. Render a consistent SSR fallback, then update after mount (accept the extra render)
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) return null; // or skeleton that matches SSR
return <ClientOnlyStuff />;If the lint rule is blocking you, you may need to disable it locally for this line/component (because you are intentionally doing client-only initialization).
B. Use a subscription-style API for external state (preferred when the value can change and you want correctness)
React tooling ecosystems commonly recommend moving “external store” reads away from “setState in effect”; some lint rule sets explicitly push toward this model. [2]
C. If you’re measuring layout (DOM size), use useLayoutEffect instead
React’s lint docs treat layout measurement as a valid case. [1]
Why this relates to hydration
On SSR, the server can’t read browser-only sources (like localStorage). If your first client render uses different values than the server HTML, you can get hydration mismatches—so you either:
- render a stable fallback until mount, or
- accept a post-mount update (often via an effect), or
- model it as an external store/subscription.
Sources: React lint rule docs for set-state-in-effect [1]; ESLint React rule description of “no direct setState in useEffect” [2].
🌐 Web query:
Next.js hydration guard pattern 2025 useSyncExternalStore
💡 Result:
A solid “hydration guard” in Next.js (App Router) is to use useSyncExternalStore with a getServerSnapshot so React forces a stable value during SSR + the hydration pass, then immediately switches to the client value after hydration—avoiding mismatches without the “isMounted + useEffect” flicker pattern.
Pattern: useIsHydrating() (or useIsServerRender())
'use client'
import * as React from 'react'
function useIsHydrating() {
return React.useSyncExternalStore(
() => () => {}, // no-op subscription
() => false, // after hydration: "not hydrating"
() => true // SSR + hydration pass: "hydrating"
)
}
export function HydrationGuard({ fallback, children }: {
fallback: React.ReactNode
children: React.ReactNode
}) {
const isHydrating = useIsHydrating()
return isHydrating ? fallback : children
}Why it works
- React uses the 3rd arg (
getServerSnapshot) during server rendering and during hydration, and it must match what the server rendered. This is specifically what prevents hydration mismatches. [1] - This behavior was introduced to handle concurrent / selective hydration where the external store could change mid-hydration; React will read the “server snapshot” during hydration, then re-render with the client snapshot helping correctness. [2]
- You can use a no-op store because you only need the server-vs-client split; you’re not actually subscribing to updates. [3]
- This is widely described as a “concurrent hydration” trick: render one thing for SSR/hydration, then immediately render the real client thing. [4]
When to use it in Next.js
Use this guard around UI that depends on client-only state (e.g., localStorage, theme, media queries, extensions-injected DOM, Date.now(), random IDs) where you want SSR output to be deterministic and avoid hydration errors.
Sources: React docs on useSyncExternalStore/getServerSnapshot [1], React 18 working group explanation of hydration snapshot guarantees [2], TkDodo’s hydration-mismatch guard approach [3], Jacob Groß’s concurrent hydration example/pattern [4].
hydration guard 구현이 린트 에러로 CI를 깨뜨립니다
useEffect에서 즉시 setMounted를 호출해 react-hooks/set-state-in-effect 에러가 발생합니다. useSyncExternalStore 기반의 클라이언트 여부 체크로 바꾸면 린트를 통과하면서 추가 렌더링 없이 동일한 목적을 달성할 수 있습니다.
🛠️ 대체 구현
-import React from "react";
+import React, { useSyncExternalStore } from "react";
...
-import { useEffect, useState } from "react";
...
- const [mounted, setMounted] = useState(false);
-
- // hydration 에러 방지 (client-side의 localStorage 데이터가 서버 렌더링 결과와 다를 수 있음)
- useEffect(() => {
- setMounted(true);
- }, []);
+ // hydration 에러 방지 (client-side의 localStorage 데이터가 서버 렌더링 결과와 다를 수 있음)
+ const mounted = useSyncExternalStore(
+ () => () => {},
+ () => true,
+ () => false,
+ );이 패턴은 React 공식 권장 사항으로, 서버 렌더링과 하이드레이션 단계에서는 false를 반환하고 클라이언트 렌더링 후에는 true를 반환하여 하이드레이션 불일치를 방지합니다.
📝 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.
| import React from "react"; | |
| import Image from "next/image"; | |
| import Link from "next/link"; | |
| import { cn } from "@/app/lib/utils"; | |
| import { useEffect, useState } from "react"; | |
| import { useUserStore } from "@/app/store/userStore"; | |
| import { logoutAction } from "@/app/login/actions"; | |
| import { useRouter } from "next/navigation"; | |
| export default function Navbar() { | |
| const router = useRouter(); | |
| const { user, clearUser } = useUserStore(); | |
| const [mounted, setMounted] = useState(false); | |
| // hydration 에러 방지 (client-side의 localStorage 데이터가 서버 렌더링 결과와 다를 수 있음) | |
| useEffect(() => { | |
| setMounted(true); | |
| }, []); | |
| import React, { useSyncExternalStore } from "react"; | |
| import Image from "next/image"; | |
| import Link from "next/link"; | |
| import { cn } from "@/app/lib/utils"; | |
| import { useUserStore } from "@/app/store/userStore"; | |
| import { logoutAction } from "@/app/login/actions"; | |
| import { useRouter } from "next/navigation"; | |
| export default function Navbar() { | |
| const router = useRouter(); | |
| const { user, clearUser } = useUserStore(); | |
| // hydration 에러 방지 (client-side의 localStorage 데이터가 서버 렌더링 결과와 다를 수 있음) | |
| const mounted = useSyncExternalStore( | |
| () => () => {}, | |
| () => true, | |
| () => false, | |
| ); |
🧰 Tools
🪛 ESLint
[error] 19-19: Error: Calling setState synchronously within an effect can trigger cascading renders
Effects are intended to synchronize state between React and external systems such as manually updating the DOM, state management libraries, or other platform APIs. In general, the body of an effect should do one or both of the following:
- Update external systems with the latest state from React.
- Subscribe for updates from some external system, calling setState in a callback function when external state changes.
Calling setState synchronously within an effect body causes cascading renders that can hurt performance, and is not recommended. (https://react.dev/learn/you-might-not-need-an-effect).
/home/jailuser/git/app/components/layout/Navbar.tsx:19:5
17 | // hydration 에러 방지 (client-side의 localStorage 데이터가 서버 렌더링 결과와 다를 수 있음)
18 | useEffect(() => {
19 | setMounted(true);
| ^^^^^^^^^^ Avoid calling setState() directly within an effect
20 | }, []);
21 |
22 | const handleLogout = async () => {
(react-hooks/set-state-in-effect)
🪛 GitHub Actions: Frontend CI
[error] 19-19: ESLint: Calling setState synchronously within an effect can trigger cascading renders. Avoid calling setMounted(true) directly within the effect.
🤖 Prompt for AI Agents
In `@app/components/layout/Navbar.tsx` around lines 3 - 20, Replace the
useEffect/setMounted hydration guard in Navbar with a useSyncExternalStore-based
client check: remove the useEffect and mounted state, and instead call
useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot) where
getSnapshot returns typeof window !== "undefined" (client true) and
getServerSnapshot returns false, and subscribe is a no-op/unsubscribe function;
update code references from mounted to the new isClient value and keep the rest
of Navbar (user, clearUser, router, etc.) unchanged.
| return ( | ||
| <Portal> | ||
| <div | ||
| className="fixed inset-0 z-150 flex items-center justify-center px-5 bg-black/50" |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# Find tailwind config files and check structure
find . -name "tailwind.config*" -type f | head -20Repository: D-ingle/frontend
Length of output: 42
🏁 Script executed:
# Check package.json for dependencies
cat package.json | grep -A 5 -B 5 "tailwindcss\|animate"Repository: D-ingle/frontend
Length of output: 487
🏁 Script executed:
# Search for where these custom classes might be defined
rg "z-150|animate-in|fade-in|zoom-in" --type ts --type tsx --type js --type css -A 2 -B 2 | head -50Repository: D-ingle/frontend
Length of output: 87
🏁 Script executed:
# Look at the ContactModal.tsx file to understand context
cat -n app/components/list/list_section/detail/ContactModal.tsx | sed -n '15,30p'Repository: D-ingle/frontend
Length of output: 799
🏁 Script executed:
# Find tailwind config files
fd "tailwind.config" --type fRepository: D-ingle/frontend
Length of output: 42
🏁 Script executed:
# If found, read the config
cat -n tailwind.config.ts 2>/dev/null || cat -n tailwind.config.js 2>/dev/null || echo "Config not found in root"Repository: D-ingle/frontend
Length of output: 84
🏁 Script executed:
# Also check for theme extensions or global CSS that might define these
fd "globals.css|tailwind.css" --type fRepository: D-ingle/frontend
Length of output: 75
🏁 Script executed:
# Read the globals.css file
cat -n app/globals.cssRepository: D-ingle/frontend
Length of output: 1114
🏁 Script executed:
# Search more broadly for tailwind config files anywhere in the repo
find . -name "*tailwind*" -type f 2>/dev/null | grep -v node_modulesRepository: D-ingle/frontend
Length of output: 42
🏁 Script executed:
# Check if there's a postcss or other config that might define these
find . -maxdepth 2 -name "*.config.*" -type f 2>/dev/null | grep -v node_modulesRepository: D-ingle/frontend
Length of output: 135
🏁 Script executed:
# Check postcss config for Tailwind plugins
cat -n postcss.config.mjsRepository: D-ingle/frontend
Length of output: 202
🏁 Script executed:
# Check next.config for Tailwind settings
cat -n next.config.tsRepository: D-ingle/frontend
Length of output: 446
🏁 Script executed:
# Search for all custom class definitions in the codebase
rg "@layer|@apply" app/ --type css --type ts -B 2 -A 2 | head -100Repository: D-ingle/frontend
Length of output: 217
🏁 Script executed:
# Check if there are any CSS files with custom class definitions
find app -name "*.css" -type f 2>/dev/nullRepository: D-ingle/frontend
Length of output: 75
🏁 Script executed:
# Search for theme customizations that might define these classes
rg "@theme|--" app/globals.css -A 20Repository: D-ingle/frontend
Length of output: 768
🏁 Script executed:
# Check if there's a tailwind config in package.json or tsconfig
cat -n tsconfig.json 2>/dev/null | head -30Repository: D-ingle/frontend
Length of output: 883
🏁 Script executed:
# Look for any other CSS files that might have these definitions
find . -path ./node_modules -prune -o -name "*.css" -type f -print 2>/dev/null | xargs grep -l "z-150\|animate-in\|max-w-100\|h-18.5" 2>/dev/nullRepository: D-ingle/frontend
Length of output: 44
🏁 Script executed:
# Check Tailwind v4 documentation - search for references to these classes in comments or code
rg "z-\[150\]|z-150" app/ --type ts -B 2 -A 2Repository: D-ingle/frontend
Length of output: 492
Tailwind 커스텀 클래스 미정의로 인한 스타일 미적용
z-150, animate-in, fade-in, zoom-in, max-w-100, h-18.5, pt-7.5 등의 클래스가 프로젝트에 정의되어 있지 않습니다.
tailwindcss-animate플러그인이 설치되지 않았으며,@theme블록에도 이들 클래스가 정의되어 있지 않습니다- Tailwind v4의 기본 z-값 범위는 z-0~z-50이므로
z-150은 기본 설정에 없습니다 max-w-100,h-18.5등의 커스텀 사이즈값도 정의되지 않았습니다
tailwindcss-animate 플러그인을 설치하고, 커스텀 값들을 위한 tailwind.config.ts 파일을 생성하여 extend.theme에 정의하거나 app/globals.css의 @theme 블록에 추가하세요.
🤖 Prompt for AI Agents
In `@app/components/list/list_section/detail/ContactModal.tsx` at line 19, The
ContactModal component uses non-existent Tailwind classes (e.g., "z-150",
"animate-in", "fade-in", "zoom-in", "max-w-100", "h-18.5", "pt-7.5") in the
className on ContactModal.tsx; fix it by either installing and enabling the
tailwindcss-animate plugin and adding it to the plugins array (so animate-*
classes are available) and/or defining the custom z, spacing and size tokens in
your tailwind.config.ts under theme.extend (or in the `@theme` block in
app/globals.css) so "z-150", "max-w-100", "h-18.5", "pt-7.5" resolve, and
alternatively replace "z-150" with a built-in z-index like "z-50" if you prefer
not to add custom z-values; update ContactModal.tsx className accordingly after
making these config/plugin changes.
| <div | ||
| onClick={() => toggleSelection(report.id)} | ||
| className={cn( | ||
| "w-5 h-5 flex items-center justify-center rounded-sm border cursor-pointer transition-colors shrink-0", | ||
| isSelected | ||
| ? "bg-main-400 border-main-400" | ||
| : "bg-white border-gray-300", | ||
| )} | ||
| > | ||
| {isSelected && ( | ||
| <svg | ||
| width="12" | ||
| height="9" | ||
| viewBox="0 0 12 9" | ||
| fill="none" | ||
| xmlns="http://www.w3.org/2000/svg" | ||
| > | ||
| <path | ||
| d="M1 4L4.5 7.5L11 1" | ||
| stroke="white" | ||
| strokeWidth="2" | ||
| strokeLinecap="round" | ||
| /> | ||
| </svg> | ||
| )} | ||
| </div> |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# First, let's check the file structure and locate the file
find . -name "HomeReport.tsx" -type fRepository: D-ingle/frontend
Length of output: 98
🏁 Script executed:
# Read the specific file to understand the context around lines 98-123
wc -l app/components/mypage/HomeReport.tsxRepository: D-ingle/frontend
Length of output: 100
🏁 Script executed:
# Read a larger section around lines 98-123 to understand the full context
sed -n '80,140p' app/components/mypage/HomeReport.tsxRepository: D-ingle/frontend
Length of output: 2621
🏁 Script executed:
# Look for the toggleSelection function to understand how it's implemented
rg "toggleSelection" app/components/mypage/HomeReport.tsx -B 3 -A 3Repository: D-ingle/frontend
Length of output: 706
🏁 Script executed:
# Check for any onKeyDown or keyboard event handling on this div or similar elements
rg "onKeyDown|onKeyPress|aria-" app/components/mypage/HomeReport.tsx -B 2 -A 2Repository: D-ingle/frontend
Length of output: 42
🏁 Script executed:
# Check if there are any keyboard event handlers at the component level or parent
sed -n '1,80p' app/components/mypage/HomeReport.tsxRepository: D-ingle/frontend
Length of output: 2465
🏁 Script executed:
# Check if there's a custom checkbox component defined elsewhere that might be used
rg "checkbox" app/components/mypage/ -lRepository: D-ingle/frontend
Length of output: 133
🏁 Script executed:
# Verify the complete implementation of the checkbox section
sed -n '98,130p' app/components/mypage/HomeReport.tsxRepository: D-ingle/frontend
Length of output: 1458
커스텀 체크박스가 키보드로 접근 불가능합니다.
div 요소는 onClick만 지원하므로 Tab 키로 포커스되지 않고 Space/Enter로 선택할 수 없습니다. 키보드 사용자와 스크린 리더 사용자가 일괄 다운로드 기능을 사용할 수 없으므로, button(role="checkbox") 또는 input[type=checkbox]로 변경해 주세요.
🔧 개선 예시
- <div
- onClick={() => toggleSelection(report.id)}
- className={cn(
- "w-5 h-5 flex items-center justify-center rounded-sm border cursor-pointer transition-colors shrink-0",
- isSelected
- ? "bg-main-400 border-main-400"
- : "bg-white border-gray-300",
- )}
- >
+ <button
+ type="button"
+ role="checkbox"
+ aria-checked={isSelected}
+ aria-label={`${report.title} 선택`}
+ onClick={() => toggleSelection(report.id)}
+ className={cn(
+ "w-5 h-5 flex items-center justify-center rounded-sm border cursor-pointer transition-colors shrink-0",
+ isSelected
+ ? "bg-main-400 border-main-400"
+ : "bg-white border-gray-300",
+ )}
+ >
...
- </div>
+ </button>📝 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.
| <div | |
| onClick={() => toggleSelection(report.id)} | |
| className={cn( | |
| "w-5 h-5 flex items-center justify-center rounded-sm border cursor-pointer transition-colors shrink-0", | |
| isSelected | |
| ? "bg-main-400 border-main-400" | |
| : "bg-white border-gray-300", | |
| )} | |
| > | |
| {isSelected && ( | |
| <svg | |
| width="12" | |
| height="9" | |
| viewBox="0 0 12 9" | |
| fill="none" | |
| xmlns="http://www.w3.org/2000/svg" | |
| > | |
| <path | |
| d="M1 4L4.5 7.5L11 1" | |
| stroke="white" | |
| strokeWidth="2" | |
| strokeLinecap="round" | |
| /> | |
| </svg> | |
| )} | |
| </div> | |
| <button | |
| type="button" | |
| role="checkbox" | |
| aria-checked={isSelected} | |
| aria-label={`${report.title} 선택`} | |
| onClick={() => toggleSelection(report.id)} | |
| className={cn( | |
| "w-5 h-5 flex items-center justify-center rounded-sm border cursor-pointer transition-colors shrink-0", | |
| isSelected | |
| ? "bg-main-400 border-main-400" | |
| : "bg-white border-gray-300", | |
| )} | |
| > | |
| {isSelected && ( | |
| <svg | |
| width="12" | |
| height="9" | |
| viewBox="0 0 12 9" | |
| fill="none" | |
| xmlns="http://www.w3.org/2000/svg" | |
| > | |
| <path | |
| d="M1 4L4.5 7.5L11 1" | |
| stroke="white" | |
| strokeWidth="2" | |
| strokeLinecap="round" | |
| /> | |
| </svg> | |
| )} | |
| </button> |
🤖 Prompt for AI Agents
In `@app/components/mypage/HomeReport.tsx` around lines 98 - 123, Replace the
non-focusable div checkbox in HomeReport.tsx with an accessible control: use a
semantic input[type="checkbox"] or a button with role="checkbox" (referencing
toggleSelection, report.id and isSelected) so it is keyboard-focusable and
operable; ensure it exposes aria-checked={isSelected}, proper tabIndex, and
calls toggleSelection(report.id) on click and onKeyDown (Space/Enter) if you
choose a button, and preserve the existing className logic
(bg-main-400/border-main-400 vs bg-white/border-gray-300) so visuals remain the
same.
| {variant === "checkbox" && ( | ||
| <div | ||
| className={cn( | ||
| "absolute top-17 left-5 w-5 h-5 flex items-center justify-center rounded-sm border transition-colors", |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Tailwind 설정 파일 찾기
fd -type f \( -name 'tailwind.config.*' -o -name 'tailwind.js' \)
# 프로젝트 구조 확인
git ls-files | head -20Repository: D-ingle/frontend
Length of output: 1052
🏁 Script executed:
#!/bin/bash
# top-17이 코드베이스에서 사용되는 곳 확인
rg "top-17" --type ts --type tsx --type js --type cssRepository: D-ingle/frontend
Length of output: 87
🏁 Script executed:
#!/bin/bash
# HouseCard.tsx 파일 확인
cat -n app/components/mypage/HouseCard.tsxRepository: D-ingle/frontend
Length of output: 3705
🏁 Script executed:
#!/bin/bash
# 루트 디렉토리에서 Tailwind 설정 파일 찾기
find . -maxdepth 2 -name 'tailwind.config.*' -o -name 'postcss.config.*' | head -20Repository: D-ingle/frontend
Length of output: 80
🏁 Script executed:
#!/bin/bash
# top-17 클래스가 코드베이스에서 사용되는 모든 곳 확인
rg "top-17"Repository: D-ingle/frontend
Length of output: 210
🏁 Script executed:
#!/bin/bash
# 프로젝트 루트의 주요 설정 파일 목록
ls -la | grep -E 'tailwind|postcss|next\.config'Repository: D-ingle/frontend
Length of output: 199
🏁 Script executed:
#!/bin/bash
# globals.css 또는 기타 CSS 파일에서 커스텀 spacing 정의 확인
find . -name '*.css' -type f | head -10 | xargs grep -l "top-17\|--spacing\|extend" 2>/dev/null || echo "No CSS files with custom spacing found"Repository: D-ingle/frontend
Length of output: 141
🏁 Script executed:
#!/bin/bash
# postcss.config.mjs 파일 내용 확인
cat -n postcss.config.mjsRepository: D-ingle/frontend
Length of output: 202
🏁 Script executed:
#!/bin/bash
# 전체 repository에서 tailwind 관련 설정 찾기
rg "tailwind|extend.*theme" --type-list | head -20 || rg "tailwind|extend.*theme" | head -20Repository: D-ingle/frontend
Length of output: 449
🏁 Script executed:
#!/bin/bash
# package.json에서 tailwind 버전 확인
cat package.json | grep -A5 -B5 "tailwindcss"Repository: D-ingle/frontend
Length of output: 487
🏁 Script executed:
#!/bin/bash
# 프로젝트 전체에서 tailwind.config 파일 찾기 (hidden 포함)
find . -name '*tailwind*' -type f 2>/dev/nullRepository: D-ingle/frontend
Length of output: 42
🏁 Script executed:
#!/bin/bash
# CSS 파일 찾기 (globals, app, styles 등)
find . -name 'globals.css' -o -name 'app.css' -o -name 'styles.css' 2>/dev/nullRepository: D-ingle/frontend
Length of output: 77
🏁 Script executed:
#!/bin/bash
# layout.tsx에서 CSS import 확인
find . -name 'layout.tsx' -exec cat {} \; 2>/dev/null | head -20Repository: D-ingle/frontend
Length of output: 469
🏁 Script executed:
#!/bin/bash
# Tailwind v4의 기본 spacing 값 확인 및 top-17, w-30, h-30 클래스가 생성되는지 검증
# 프로젝트에서 tailwind 관련 모든 config 찾기
rg "@layer|extend:" --max-count 20Repository: D-ingle/frontend
Length of output: 94
🏁 Script executed:
#!/bin/bash
# globals.css 전체 내용 확인
cat -n app/globals.cssRepository: D-ingle/frontend
Length of output: 1114
비표준 Tailwind spacing 클래스 사용 - 커스텀 설정 없음
top-17, w-30, h-30 같은 클래스들은 Tailwind CSS v4의 기본 spacing scale에 없으며, 프로젝트의 globals.css에도 정의되어 있지 않습니다. 이 클래스들은 CSS를 생성하지 않아 체크박스 위치와 이미지 크기가 제대로 적용되지 않을 것입니다. Tailwind 설정에서 이 값들을 명시적으로 확장하거나 인라인 스타일을 사용해야 합니다.
🤖 Prompt for AI Agents
In `@app/components/mypage/HouseCard.tsx` at line 47, The Tailwind classes like
top-17, w-30 and h-30 used in the HouseCard component's class string are not
part of the default spacing scale and aren't defined in globals.css, so they
won't generate CSS; either replace those tokens in the HouseCard JSX (look for
the class string containing "absolute top-17 left-5 w-5 h-5 ...") with valid
Tailwind spacing classes (e.g., top-16/top-20, w-8/w-32, h-8/h-32) that match
the intended layout, or add explicit spacing keys to your Tailwind config under
theme.extend.spacing (e.g., "17": "4.25rem", "30": "7.5rem") and rebuild
Tailwind so top-17/w-30/h-30 generate styles—pick one approach and update the
class string or tailwind.config.js accordingly.
| const layout = () => { | ||
| return ( | ||
| <div className="flex flex-col h-screen overflow-hidden"> | ||
| <FilterBar /> | ||
| <div className="flex-1 flex overflow-hidden"> | ||
| <div className="w-100 h-full z-10"> | ||
| <List /> | ||
| </div> | ||
| {/* Map area */} | ||
| <div className="flex-1 relative"> | ||
| <KakaoMap /> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| ); | ||
| }; |
There was a problem hiding this comment.
Next.js App Router의 layout 규칙을 따르지 않습니다.
Next.js App Router에서 layout.tsx는 반드시 children prop을 받아서 렌더링해야 합니다. 현재 구현은 children을 무시하고 있어 page.tsx의 내용이 렌더링되지 않습니다.
만약 현재 구조가 의도된 것이라면, 이 파일을 layout이 아닌 일반 컴포넌트로 분리하고 page.tsx에서 사용하는 것이 올바른 패턴입니다.
🔧 수정 제안 (layout 패턴 유지 시)
+import { ReactNode } from "react";
import FilterBar from "../components/map/FilterBar";
import List from "../components/list/List";
import KakaoMap from "../components/map/KakaoMap";
-const layout = () => {
+export default function MapLayout({ children }: { children: ReactNode }) {
return (
<div className="flex flex-col h-screen overflow-hidden">
<FilterBar />
<div className="flex-1 flex overflow-hidden">
<div className="w-100 h-full z-10">
<List />
</div>
{/* Map area */}
<div className="flex-1 relative">
<KakaoMap />
+ {children}
</div>
</div>
</div>
);
-};
-
-export default layout;
+}🤖 Prompt for AI Agents
In `@app/map/layout.tsx` around lines 5 - 20, The layout function currently
ignores the required Next.js App Router children prop, so update the exported
layout component (layout) to accept and render a children prop (e.g., function
signature (props: { children: React.ReactNode }) or ({ children })) and place
{children} where page content should be rendered (for example inside the
list/map container or replacing <List /> if appropriate); alternatively, if you
intend this to be a regular component rather than an app layout, rename/remove
the layout export and move this JSX into a normal component used by page.tsx
(e.g., MapShell) and ensure page.tsx imports and renders FilterBar, List,
KakaoMap and the page content accordingly.
| name: "dhome-user-storage", // localStorage에 저장될 키 이름 | ||
| storage: createJSONStorage(() => localStorage), // 저장소로 localStorage 사용 |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
fd -type f -name "userStore.ts" | head -5Repository: D-ingle/frontend
Length of output: 230
🌐 Web query:
zustand persist middleware skipHydration Next.js SSR hydration mismatch
💡 Result:
When you use persist with localStorage in Next.js SSR, the server always renders with the default initial state (no access to localStorage), but the client can immediately rehydrate from storage and render different UI during hydration—causing a hydration mismatch (server HTML ≠ first client render). (zustand.docs.pmnd.rs)
Fix: skipHydration: true + manually rehydrate() on the client
Zustand persist has skipHydration. If true, Zustand won’t auto-rehydrate on initialization; you manually trigger it on the client (e.g., in a useEffect). This is explicitly recommended for SSR apps. (zustand.docs.pmnd.rs)
Store
import { create } from "zustand";
import { persist } from "zustand/middleware";
type State = { theme: "light" | "dark"; setTheme: (t: State["theme"]) => void };
export const useAppStore = create<State>()(
persist(
(set) => ({
theme: "light",
setTheme: (theme) => set({ theme }),
}),
{
name: "app",
skipHydration: true, // important for SSR
}
)
);Client-only hydrator (App Router: put in app/layout.tsx)
"use client";
import { useEffect } from "react";
import { useAppStore } from "./store";
export function StoreHydrator() {
useEffect(() => {
useAppStore.persist.rehydrate();
}, []);
return null;
}Then render it once near the root:
// app/layout.tsx
import { StoreHydrator } from "./StoreHydrator";
export default function RootLayout({ children }) {
return (
<html>
<body>
<StoreHydrator />
{children}
</body>
</html>
);
}This pattern is commonly used in Next.js + Zustand to avoid SSR hydration mismatches by ensuring the first client render matches the server, and only then applying persisted state. (zustand.docs.pmnd.rs)
Citations:
- 1: https://zustand.docs.pmnd.rs/middlewares/persist?utm_source=openai
- 2: https://zustand.docs.pmnd.rs/middlewares/persist?utm_source=openai
- 3: https://zustand.docs.pmnd.rs/middlewares/persist?utm_source=openai
🏁 Script executed:
cat -n app/store/userStore.tsRepository: D-ingle/frontend
Length of output: 1389
SSR 하이드레이션 불일치 문제를 해결하기 위해 skipHydration 옵션 적용 필요
Next.js의 SSR 환경에서 localStorage를 사용하는 현재 코드는 서버에서는 기본 상태로 렌더링되고 클라이언트에서는 localStorage에서 자동으로 상태를 복원하게 되어 하이드레이션 불일치가 발생할 수 있습니다.
Zustand의 공식 권장 방식에 따라 다음과 같이 수정하세요:
- persist 옵션에
skipHydration: true추가 - 클라이언트 컴포넌트에서 useEffect를 통해
useUserStore.persist.rehydrate()수동 호출
이를 통해 서버와 클라이언트의 초기 렌더링을 일치시키고, 클라이언트에서만 localStorage 상태를 적용할 수 있습니다.
🤖 Prompt for AI Agents
In `@app/store/userStore.ts` around lines 41 - 42, Add the persist skipHydration
flag and manual rehydrate call to avoid SSR hydration mismatches: update the
persist config where createJSONStorage is used to include skipHydration: true
(so the persisted localStorage state is not applied during server render), and
in the client-only component lifecycle call useUserStore.persist.rehydrate()
inside a useEffect to restore state on the client; reference the persist config
that contains createJSONStorage(...) and the useUserStore.persist.rehydrate()
API when making these changes.
| export const loginSchema = z.object({ | ||
| userId: z.string().min(1, "아이디를 입력해주세요."), | ||
| password: z.string().min(1, "비밀번호를 입력해주세요."), | ||
| }); |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
Zod v4 string min() method second argument error message format
💡 Result:
In Zod v4, z.string().min()’s 2nd argument is an options object whose key is error (not message):
import * as z from "zod/v4";
z.string().min(5, { error: "Must be at least 5 characters" });error can be either a string or an error map function:
z.string().min(5, {
error: (iss) => iss.input === undefined ? "Required" : "Too short",
});Zod v4 unified the old v3 message + errorMap into a single error option, so { message: "..." } will cause a type/error mismatch in v4 typings. [1][2]
Sources:
[1] Zod “Customizing errors” (v4 error param, includes .min(..., { error: ... })) (zod.dev)
[2] Zod v3→v4 codemod notes (message → error for .min) (hypermod.io)
Citations:
- 1: https://zod.dev/error-customization?utm_source=openai
- 2: https://www.hypermod.io/explore/zod-v4?utm_source=openai
🏁 Script executed:
# Check Zod version in package.json
cd app 2>/dev/null || cd .
find . -name "package.json" -type f | head -5 | xargs grep -l "zod" | head -1 | xargs cat | grep -A2 -B2 '"zod"'Repository: D-ingle/frontend
Length of output: 42
🏁 Script executed:
# Also check for package-lock.json or yarn.lock to see exact version
find . -name "package-lock.json" -o -name "yarn.lock" | head -1 | xargs grep -A5 '"zod"' | head -20Repository: D-ingle/frontend
Length of output: 42
🏁 Script executed:
# Search for all .min() usages in the codebase to see pattern consistency
rg "\.min\(" --type ts --type js -A 1 | head -50Repository: D-ingle/frontend
Length of output: 1128
🏁 Script executed:
# More direct check for Zod version in package.json
find . -name "package.json" -type f -exec cat {} \; | grep -E '"zod"' | head -5Repository: D-ingle/frontend
Length of output: 249
🏁 Script executed:
# Check if there's a lock file showing zod version
find . -type f \( -name "package.json" -o -name "package-lock.json" -o -name "yarn.lock" -o -name "pnpm-lock.yaml" \) | while read f; do echo "=== $f ==="; head -100 "$f" | grep -i zod || echo "no zod found in first 100 lines"; doneRepository: D-ingle/frontend
Length of output: 50372
🏁 Script executed:
# Check the exact file structure to find package.json
ls -la | head -20
find . -maxdepth 3 -name "package.json" -type fRepository: D-ingle/frontend
Length of output: 12228
Zod v4 API 호환성 문제: .min() 에러 메시지 형식 변경 필요
Zod v4에서는 .min() 메서드의 두 번째 인자 시그니처가 변경되었습니다. 현재 코드는 문자열을 직접 전달하고 있으나, v4에서는 반드시 { error: "..." } 객체 형식으로 전달해야 합니다.
수정 방법
export const loginSchema = z.object({
- userId: z.string().min(1, "아이디를 입력해주세요."),
- password: z.string().min(1, "비밀번호를 입력해주세요."),
+ userId: z.string().min(1, { error: "아이디를 입력해주세요." }),
+ password: z.string().min(1, { error: "비밀번호를 입력해주세요." }),
});app/types/signup.ts의 .min(), .max() 호출도 동일한 형식으로 수정이 필요합니다.
📝 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.
| export const loginSchema = z.object({ | |
| userId: z.string().min(1, "아이디를 입력해주세요."), | |
| password: z.string().min(1, "비밀번호를 입력해주세요."), | |
| }); | |
| export const loginSchema = z.object({ | |
| userId: z.string().min(1, { error: "아이디를 입력해주세요." }), | |
| password: z.string().min(1, { error: "비밀번호를 입력해주세요." }), | |
| }); |
🤖 Prompt for AI Agents
In `@app/types/login.ts` around lines 3 - 6, The Zod v4 change requires passing
error details as an object to .min()/.max(), so update loginSchema (export const
loginSchema) to replace z.string().min(1, "아이디를 입력해주세요.") and z.string().min(1,
"비밀번호를 입력해주세요.") with the v4 form z.string().min(1, { message: "..." }) and make
the same conversion for all .min()/.max() usages in app/types/signup.ts; ensure
you keep the same numeric bounds and messages but wrap the message in { message:
"..." } for each affected schema call.
| dingle: { | ||
| input: { | ||
| target: "./openapi.json", // TODO: 백엔드 Swagger URL이나 파일 경로로 수정 필요 | ||
| target: "https://d-home.o-r.kr/v3/api-docs", |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# 파일 구조 및 orval.config.ts 확인
git ls-files | head -20Repository: D-ingle/frontend
Length of output: 864
🏁 Script executed:
# orval.config.ts 내용 확인
find . -name "orval.config.ts" -type fRepository: D-ingle/frontend
Length of output: 77
🏁 Script executed:
# 로컬 OpenAPI 스펙 파일 존재 여부 확인
find . -name "*.json" -o -name "*.yaml" -o -name "*.yml" | grep -i "openapi\|api\|spec" | head -20Repository: D-ingle/frontend
Length of output: 757
🏁 Script executed:
# orval.config.ts 파일 내용 확인
cat -n ./orval.config.tsRepository: D-ingle/frontend
Length of output: 675
🏁 Script executed:
# 로컬 OpenAPI 스펙 파일 검색 (node_modules 제외, 더 정확한 패턴)
find . -type f \( -name "*.json" -o -name "*.yaml" -o -name "*.yml" \) ! -path "*/node_modules/*" ! -path "*/.next/*" ! -path "*/dist/*" | head -30Repository: D-ingle/frontend
Length of output: 130
🏁 Script executed:
# API 관련 폴더 구조 확인
find . -maxdepth 3 -type d ! -path "*/node_modules/*" ! -path "*/.next/*" ! -path "*/dist/*" -name "*api*" -o -name "*spec*" -o -name "*openapi*" 2>/dev/null | head -20Repository: D-ingle/frontend
Length of output: 189
🏁 Script executed:
# shared/api 폴더 내용 확인
find ./shared/api -type f 2>/dev/null | head -20Repository: D-ingle/frontend
Length of output: 1149
🏁 Script executed:
# CI 워크플로우에서 orval 관련 설정 확인
cat -n ./.github/workflows/ci.ymlRepository: D-ingle/frontend
Length of output: 945
🏁 Script executed:
# package.json에서 orval 관련 스크립트 확인
grep -A 2 -B 2 "orval" ./package.jsonRepository: D-ingle/frontend
Length of output: 281
원격 OpenAPI URL 의존으로 인한 빌드 재현성/가용성 리스크
Line 6의 원격 스펙(https://d-home.o-r.kr/v3/api-docs)은 네트워크 장애나 원격 스펙 변경 시 빌드를 깨뜨릴 수 있습니다. 현재 CI에서 orval 생성 단계가 자동화되지 않아 개발자가 로컬에서 수동 실행해야 하므로 더욱 리스크합니다.
권장사항:
- OpenAPI 스펙을 로컬 파일로 커밋 (예:
./openapi.json) - CI 워크플로우에
yarn api:gen단계 추가 - 또는 환경변수로 URL 주입:
target: process.env.ORVAL_OPENAPI_URL ?? "./openapi.json"
🤖 Prompt for AI Agents
In `@orval.config.ts` at line 6, 현재 orval.config.ts의 target이 원격
URL("https://d-home.o-r.kr/v3/api-docs")에 하드코딩되어 있어 빌드 재현성과 가용성 리스크가 있으니, target
값을 환경변수 기반 또는 로컬 파일로 대체하도록 변경하세요: 변경할 위치는 orval.config.ts의 target 프로퍼티이며 값은
process.env.ORVAL_OPENAPI_URL ?? "./openapi.json" 형태로 바꿔서 환경변수로 주입 가능하게 하고, 프로젝트
루트에 openapi.json을 커밋해 두세요(또는 둘다 지원). 추가로 CI 설정에 yarn api:gen(또는 사용하는 스크립트) 단계를
추가해 빌드 시 자동으로 orval 코드를 생성하도록 구성하세요.
| export const login = (data: FormData) => { | ||
| return customInstance<LoginResponse>({ | ||
| url: "/login", | ||
| method: "POST", | ||
| headers: { "Content-Type": "multipart/form-data" }, | ||
| data, | ||
| }); |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
Axios FormData Content-Type boundary automatic handling documentation
💡 Result:
-
If you pass a real
FormDatainstance to Axios, do not manually setContent-Type. The runtime (browser) or Axios/Node integration will generatemultipart/form-data; boundary=...automatically; manually settingContent-Type: multipart/form-datacan prevent the boundary from being added and break uploads. [1], [2]const form = new FormData(); form.append('file', fileInput.files[0]); await axios.post('/upload', form); // ✅ boundary handled automatically
-
If you want Axios to build
FormDatafor you from a plain object, then you do setContent-Type: multipart/form-data(or usepostForm/putForm/patchForm). Axios will serialize toFormData. [1]await axios.post('/upload', { file: fileInput.files[0] }, { headers: { 'Content-Type': 'multipart/form-data' } }); // or: axios.postForm('/upload', { file: fileInput.files[0] })
-
Node.js note: if you use a Node
form-dataimplementation, you typically send its generated headers (which include the boundary), e.g.headers: form.getHeaders(). [2]
Sources: Axios “Multipart Bodies” docs [1]; discussion/bug report showing missing boundary when Content-Type is set manually with FormData [2].
🏁 Script executed:
# Search for FormData usage in codebase
rg "FormData" -t ts -t tsx -A 3 -B 1Repository: D-ingle/frontend
Length of output: 87
🏁 Script executed:
# Find customInstance implementation to understand the context
fd "customInstance" -t ts -t tsxRepository: D-ingle/frontend
Length of output: 229
🏁 Script executed:
# Search for axios configuration and other FormData usages
rg "multipart/form-data" -t ts -t tsx -B 2 -A 2Repository: D-ingle/frontend
Length of output: 87
🏁 Script executed:
# Search for FormData usage in TypeScript files
rg "FormData" --type js --type ts -A 3 -B 1 2>/dev/null || rg "FormData" -A 3 -B 1Repository: D-ingle/frontend
Length of output: 1821
🏁 Script executed:
# Search for multipart/form-data references
rg "multipart/form-data" -A 2 -B 2Repository: D-ingle/frontend
Length of output: 265
🏁 Script executed:
# Find and inspect the customInstance implementation
rg "customInstance" -A 10 -B 2Repository: D-ingle/frontend
Length of output: 50372
🏁 Script executed:
# Look at the auth.ts file to see full context
fd "auth.ts" | head -5Repository: D-ingle/frontend
Length of output: 78
FormData 요청 시 수동으로 Content-Type 헤더를 지정하면 boundary가 누락되어 요청이 실패합니다.
Axios에 FormData 인스턴스를 전달할 때 Content-Type 헤더를 수동으로 지정하면 boundary가 자동 생성되지 않습니다. 이로 인해 서버가 요청을 거부할 수 있습니다. 헤더를 제거하고 Axios에 자동 처리를 맡겨야 합니다.
수정 방법
export const login = (data: FormData) => {
return customInstance<LoginResponse>({
url: "/login",
method: "POST",
- headers: { "Content-Type": "multipart/form-data" },
data,
});
};📝 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.
| export const login = (data: FormData) => { | |
| return customInstance<LoginResponse>({ | |
| url: "/login", | |
| method: "POST", | |
| headers: { "Content-Type": "multipart/form-data" }, | |
| data, | |
| }); | |
| export const login = (data: FormData) => { | |
| return customInstance<LoginResponse>({ | |
| url: "/login", | |
| method: "POST", | |
| data, | |
| }); | |
| }; |
🤖 Prompt for AI Agents
In `@shared/api/auth.ts` around lines 34 - 40, The login function is setting a
manual "Content-Type": "multipart/form-data" header which prevents Axios from
adding the required boundary for FormData; update the login implementation (the
login function that calls customInstance) to remove the manual headers when data
is a FormData instance so Axios can auto-generate the Content-Type/boundary
(i.e., stop passing the headers object or only pass headers when not sending
FormData).
작업 내용
참고 사항
Summary by CodeRabbit
릴리스 노트
새로운 기능
사용성 개선