Skip to content

Create Week08 Mission 1, 2, 3#65

Open
SJ01-max wants to merge 1 commit into
mainfrom
SJ01-max/week08
Open

Create Week08 Mission 1, 2, 3#65
SJ01-max wants to merge 1 commit into
mainfrom
SJ01-max/week08

Conversation

@SJ01-max
Copy link
Copy Markdown
Contributor

@SJ01-max SJ01-max commented May 17, 2026

📝 미션 번호

8주차 Misson 1

📋 구현 사항

  • useDebounce<T>(value, delay) 커스텀 훅 직접 구현
    • setTimeout / clearTimeout으로 타이머 관리
    • 언마운트 및 의존성 변경 시 cleanup 처리
  • 검색 입력에 useDebounce 적용 (delay: 500ms)
    • debouncedQueryuseInfiniteQuery queryKey 및 enabled 조건 설정
    • 빈 문자열/공백 입력 시 API 요청 차단
  • useInfiniteQuery 연계
    • queryKey: ["search", debouncedQuery]로 지연된 값 기준 캐싱
    • getNextPageParam으로 cursor 기반 무한스크롤 구현
    • staleTime: 1분 / gcTime: 5분으로 불필요한 재요청 방지

📎 스크린샷

image

📝 미션 번호

8주차 Misson 2

📋 구현 사항

  • useThrottle(fn, interval) 커스텀 훅 직접 구현
    • useRefshouldWait 플래그 관리
    • interval 동안 최대 1번만 실행되도록 제어
    • 언마운트 시 cleanup 처리
  • 무한스크롤 fetchNextPageuseThrottle 적용 (interval: 1000ms)
    • IntersectionObserver에서 throttledFetchNextPage 직접 호출
    • 스크롤 시 1초에 1번만 다음 페이지 요청

📎 스크린샷

image

📝 미션 번호

8주차 Misson 3

📋 구현 사항

  • useSidebar 커스텀 훅 구현
    • isOpen 상태 및 open() / close() / toggle() 함수 제공
    • ESC 키로 닫기: keydown 이벤트 리스너 등록/해제
    • 사이드바 열릴 때 overflow: hidden으로 배경 스크롤 방지
  • Sidebar 컴포넌트 구현
    • Tailwind translate-x + transition-transform duration-300으로 슬라이드 애니메이션
    • 오버레이 클릭 시 닫기 처리
  • 햄버거 메뉴 버튼에 useSidebar 연결

📎 스크린샷

image

✅ 체크리스트

  • Merge 하려는 브랜치가 올바르게 설정되어 있나요?
  • 로컬에서 실행했을 때 에러가 발생하지 않나요?
  • 불필요한 주석이 제거되었나요?
  • 코드 스타일이 일관적인가요?

🤔 질문 사항

Summary by CodeRabbit

돌려돌려LP판 - 초기 프로젝트 설정

  • 새로운 기능

    • 사용자 인증 시스템 (회원가입, 로그인, 탈퇴)
    • LP 목록 조회 및 상세 페이지
    • LP 검색 및 정렬 기능
    • LP 좋아요 및 댓글 시스템
    • 사용자 프로필 관리
    • 반응형 레이아웃 및 네비게이션
  • 문서

    • 개발 환경 설정 완료 (Vite, React, TypeScript)

@SJ01-max SJ01-max requested a review from wantkdd May 17, 2026 07:09
@SJ01-max SJ01-max self-assigned this May 17, 2026
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 17, 2026

📝 Walkthrough

Walkthrough

3개의 거의 동일한 Vite + React 프로젝트(Mission1, 2, 3)를 추가. 각각 Axios API 클라이언트(auth 토큰 인터셉터), React Context 인증, React Query 데이터 조회, TypeScript, Tailwind를 포함. LP 게시판(목록/상세/댓글/검색)과 사용자 관리(로그인/가입/프로필) 기능 구현.

Changes

3개 Vite React 풀 스택 프로젝트 (Mission1, Mission2, Mission3)

Layer / File(s) Summary
프로젝트 메타 & 빌드 설정
package.json, tsconfig.json/app.json/node.json, vite.config.ts, index.html, .gitignore
3개 Mission 프로젝트의 Vite + React + TypeScript + Tailwind 개발 환경 설정. 스크립트(dev/build/lint/preview), 의존성(React 19, React Router 7, React Query 5, Axios, Tailwind), TypeScript 컴파일 옵션 정의.
API 클라이언트 & 데이터 타입
src/api/client.ts, src/types/lp.ts
Axios 기반 API 클라이언트. 로컬스토리지 토큰 자동 Authorization 헤더 주입. 인증(회원가입/로그인/로그아웃/탈퇴), LP CRUD, 댓글 CRUD, 파일 업로드 래퍼 함수. LP/댓글/사용자 데이터 타입 정의.
인증 & 라우팅 구조
src/contexts/AuthContext.tsx, src/App.tsx
React Context 기반 인증 상태(loading|authenticated|unauthenticated) 및 사용자 관리. 초기 토큰 검증, apiGetMe() 호출, 멀티탭 동기화(storage 이벤트). 라우팅(RequireAuth 보호 경로), 스크롤 복원 비활성화(manual).
인증 페이지
src/pages/LoginPage.tsx, src/pages/SignupPage.tsx, src/pages/MyPage.tsx
LoginPage: 이메일/비밀번호 입력 + useMutation 기반 apiSignin 호출 및 리다이렉트. SignupPage: apiSignup 후 자동 로그인 플로우. MyPage: 프로필 조회/편집 모드 전환, 아바타 업로드, apiUpdateMe 호출, 낙관적 업데이트 & 실패 롤백.
콘텐츠 페이지
src/pages/LpListPage.tsx, src/pages/LpDetailPage.tsx, src/pages/LpCreatePage.tsx
LpListPage: useInfiniteQuery 무한 스크롤, IntersectionObserver + useThrottle 페이지 로딩. LpDetailPage: 상세 조회, 작성자 권한 기반 편집/삭제, 좋아요 토글(낙관적 업데이트), 댓글 통합. LpCreatePage: 폼 입력 & 생성 후 리다이렉트.
레이아웃 & 모달 컴포넌트
src/components/Layout.tsx, src/components/Sidebar.tsx, src/components/LoginModal.tsx, src/components/LpCreateModal.tsx, src/components/SearchModal.tsx
Layout: 고정 헤더, 반응형 사이드바(768px 기준), 플로팅 버튼, 탈퇴 모달. Sidebar: 네비게이션 링크 & 탈퇴 버튼. 각 모달: 로그인 안내, LP 생성/수정(파일 업로드), 검색(localStorage 최근 검색어).
댓글 & 검색 UI
src/components/CommentSection.tsx, 추가 모달 및 컴포넌트
CommentSection: useQuery 목록 조회, 작성자 권한 메뉴, useMutation 생성/수정/삭제, 상대 시간 라벨. SearchModal: useDebounce + useInfiniteQuery 검색, 최근 검색어 localStorage 관리. SkeletonDetail 로딩 상태.
커스텀 훅 & 유틸
src/hooks/useDebounce.ts, src/hooks/useLocalStorage.ts, src/hooks/useThrottle.ts, src/hooks/useSidebar.ts, src/index.css, src/main.tsx, src/vite-env.d.ts
useDebounce: 지연 값 업데이트. useLocalStorage: JSON 직렬화 & localStorage 동기화. useThrottle: 간격 실행 제한. useSidebar: 상태 관리 & Escape 키. index.css: Tailwind 기본 설정 & 전역 스타일. main.tsx: React 부트스트랩. vite-env.d.ts: Vite 타입 참조.

예상 코드 리뷰 노력

🎯 4 (Complex) | ⏱️ ~60 분

관련 가능성 있는 PR

제안 검토자

  • wantkdd

Poem

🐰 세 번을 복붙한 React의 마법,
Axios 요청 맞춰 춤을 춘다네,
Query는 구르고 모달은 흔들고,
localStorage 토큰 주머니 손잡고,
댓글 싸이다 검색하며 나아가 🎉

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch SJ01-max/week08

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 18

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

🟡 Minor comments (15)
Week08/SJ01-max/Mission3/src/hooks/useSidebar.ts-21-25 (1)

21-25: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

기존 body overflow 값을 보존해서 복원해 주세요.

현재 구현은 사이드바가 닫히거나 언마운트될 때 document.body.style.overflow를 빈 문자열로 고정 복원합니다. 다른 컴포넌트가 기존에 설정한 overflow 값을 덮어쓸 수 있습니다.

수정 예시
 useEffect(() => {
-  document.body.style.overflow = isOpen ? 'hidden' : ''
+  const prevOverflow = document.body.style.overflow
+  document.body.style.overflow = isOpen ? 'hidden' : prevOverflow
   return () => {
-    document.body.style.overflow = ''
+    document.body.style.overflow = prevOverflow
   }
 }, [isOpen])
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Week08/SJ01-max/Mission3/src/hooks/useSidebar.ts` around lines 21 - 25, In
the useEffect inside the useSidebar hook, preserve and restore the existing
document.body.style.overflow instead of always resetting to '' — capture the
current value (e.g., const previousOverflow = document.body.style.overflow)
before you set overflow based on isOpen, set overflow to 'hidden' when isOpen is
true, and on cleanup (and when closing) restore document.body.style.overflow =
previousOverflow so other components' overflow settings are not lost; modify the
useEffect that references isOpen/document.body.style.overflow accordingly.
Week08/SJ01-max/Mission3/src/pages/LoginPage.tsx-11-11 (1)

11-11: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

타입 단언이 불완전합니다.

Location 타입이 임포트 없이 사용되었습니다. 명시적으로 { pathname: string } 형태의 타입을 정의하거나, 올바른 타입을 임포트해야 합니다.

🔧 타입 수정 제안
- const from = (location.state as { from?: Location })?.from?.pathname ?? '/'
+ const from = (location.state as { from?: { pathname: string } })?.from?.pathname ?? '/'
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Week08/SJ01-max/Mission3/src/pages/LoginPage.tsx` at line 11, The cast for
location.state is using an undefined Location type; update the type assertion in
LoginPage by either importing the correct Location type (e.g., import { Location
} from 'history') and using (location.state as { from?: Location }) or replace
it with an explicit shape such as (location.state as { from?: { pathname: string
} }) so that the expression const from = (location.state as { from?: ...
})?.from?.pathname ?? '/' is properly typed; adjust imports or the inline type
accordingly in LoginPage.tsx (refer to the const from declaration).
Week08/SJ01-max/Mission3/src/pages/LpCreatePage.tsx-25-26 (1)

25-26: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

에러 로깅을 추가하여 디버깅을 개선하세요.

catch 블록에서 에러 객체를 사용하지 않고 있습니다. 개발 중 문제 해결을 위해 에러를 콘솔에 로깅하는 것이 좋습니다.

🐛 에러 로깅 추가 제안
-    } catch {
+    } catch (err) {
+      console.error('LP 생성 실패:', err)
       setError('LP 생성에 실패했습니다.')
     } finally {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Week08/SJ01-max/Mission3/src/pages/LpCreatePage.tsx` around lines 25 - 26,
The catch block in LpCreatePage.tsx currently swallows errors; update the catch
to accept the error (e.g., catch (error)) and log it for debugging, e.g., call
console.error('LP 생성 실패:', error) (while keeping setError('LP 생성에 실패했습니다.')) so
the error object is preserved and visible; locate the catch surrounding the
async LP creation logic and the setError call to implement this change.
Week08/SJ01-max/Mission1/src/pages/MyPage.tsx-32-35 (1)

32-35: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

소개(bio) 필드를 빈 값으로 초기화할 수 없습니다.

bio.trim()이 참일 때만 payload에 포함되므로, 사용자가 기존 소개를 삭제하고 빈 값으로 저장하려고 해도 API 요청에 bio 필드가 포함되지 않아 변경되지 않습니다.

🐛 빈 문자열도 전송하도록 수정
      const payload: { name?: string; bio?: string; avatar?: string | null } = {}
      if (name.trim()) payload.name = name.trim()
-     if (bio.trim()) payload.bio = bio.trim()
+     payload.bio = bio.trim()
      payload.avatar = avatar

또는 사용자가 명시적으로 삭제했는지 확인하려면:

      const payload: { name?: string; bio?: string; avatar?: string | null } = {}
      if (name.trim()) payload.name = name.trim()
-     if (bio.trim()) payload.bio = bio.trim()
+     // bio는 항상 포함 (빈 문자열 또는 내용)
+     payload.bio = bio.trim() || ''
      payload.avatar = avatar
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Week08/SJ01-max/Mission1/src/pages/MyPage.tsx` around lines 32 - 35, The
payload construction currently only includes bio when bio.trim() is truthy so
deleting an existing bio doesn't send an empty string; change the payload logic
in the save handler (the block that defines payload, name, bio, avatar) to
always set payload.bio = bio.trim() (or otherwise explicitly include bio when
the user cleared the field) instead of conditionally adding it, so the API
receives an empty string to clear the bio; keep the existing conditional
behaviour for name if desired and still set payload.avatar = avatar as before.
Week08/SJ01-max/Mission3/src/pages/LpListPage.tsx-182-188 (1)

182-188: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

테스트용 더미 텍스트는 본 화면에서 제거하는 편이 좋습니다.

Line 182-188의 placeholder 콘텐츠는 실제 목록 UX를 방해하고 스크롤 트리거 동작을 오해하게 만들 수 있습니다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Week08/SJ01-max/Mission3/src/pages/LpListPage.tsx` around lines 182 - 188,
Remove the temporary placeholder scroll content in the LpListPage component:
delete the JSX block that renders the "스크롤 테스트용 텍스트입니다." paragraph and the
Array.from({ length: 30 }).map(...) loop (the dummy <div className="mt-8
space-y-4 text-gray-600 text-sm"> block) so the real list UX and scroll-trigger
behavior are not obscured; if you need to keep it for local testing, wrap it
behind a DEV flag or conditional (e.g., process.env.NODE_ENV === 'development'
or a isDev prop) instead of rendering it unconditionally.
Week08/SJ01-max/Mission2/src/pages/LpCreatePage.tsx-13-23 (1)

13-23: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

공백-only 입력이 그대로 등록될 수 있습니다.

required만으로는 " " 같은 값이 통과됩니다. Line 13 이후 제출 전에 trim() 검증을 추가해 빈 제목/내용 등록을 막아주세요.

수정 예시
   const handleSubmit = async (e: FormEvent) => {
     e.preventDefault()
     setError('')
+    const normalizedTitle = title.trim()
+    const normalizedContent = content.trim()
+    if (!normalizedTitle || !normalizedContent) {
+      setError('제목과 내용을 입력해주세요.')
+      return
+    }
     setLoading(true)
     try {
       const { data } = await apiClient.post('/lps', {
-        title,
-        content,
+        title: normalizedTitle,
+        content: normalizedContent,
         thumbnail: thumbnail || null,
         published: true,
       })

Also applies to: 41-64

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Week08/SJ01-max/Mission2/src/pages/LpCreatePage.tsx` around lines 13 - 23,
The form allows inputs that are only whitespace to be submitted; update the
submit validation in the handleSubmit async function to trim title and content
and reject empty strings before calling apiClient.post: obtain trimmedTitle =
title.trim() and trimmedContent = content.trim(), if either is empty
setError('제목/내용을 입력하세요') (or similar), setLoading(false) and return early; apply
the same trim-and-empty check to the other submit handler referenced around
lines 41-64 so neither handler sends whitespace-only title/content to the
server.
Week08/SJ01-max/Mission3/src/pages/MyPage.tsx-60-65 (1)

60-65: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

아바타 미리보기 Object URL 정리가 필요합니다.

Line 64에서 생성한 Object URL을 해제하지 않아 파일을 여러 번 바꾸면 메모리가 누적될 수 있습니다.

수정 예시
+import { useEffect, useRef, useState } from 'react'
@@
   const [avatarPreview, setAvatarPreview] = useState<string | null>(user?.avatar ?? null)
+  const previewUrlRef = useRef<string | null>(null)
@@
   const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
     const f = e.target.files?.[0]
     if (!f) return
     setAvatarFile(f)
-    setAvatarPreview(URL.createObjectURL(f))
+    if (previewUrlRef.current) URL.revokeObjectURL(previewUrlRef.current)
+    const nextUrl = URL.createObjectURL(f)
+    previewUrlRef.current = nextUrl
+    setAvatarPreview(nextUrl)
   }
+
+  useEffect(() => {
+    return () => {
+      if (previewUrlRef.current) URL.revokeObjectURL(previewUrlRef.current)
+    }
+  }, [])
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Week08/SJ01-max/Mission3/src/pages/MyPage.tsx` around lines 60 - 65,
handleAvatarChange currently creates an object URL with URL.createObjectURL(f)
and assigns it to avatarPreview without revoking prior URLs; update
handleAvatarChange to revoke the previous preview URL (call
URL.revokeObjectURL(prevPreview) if prevPreview exists) before creating a new
one, and ensure you add a cleanup (useEffect return) that revokes the final
avatarPreview when the component unmounts so no object URLs leak; reference
handleAvatarChange, setAvatarPreview, avatarPreview, and
URL.createObjectURL/URL.revokeObjectURL when making the changes.
Week08/SJ01-max/Mission2/src/components/LpCreateModal.tsx-1-1 (1)

1-1: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

파일 미리보기 Object URL 정리가 없어 메모리 누수가 발생할 수 있습니다.

Line 66의 URL.createObjectURL 결과를 해제하지 않아, 파일을 여러 번 변경하면 브라우저 메모리가 누적됩니다.

🧹 제안 수정안
-import { useRef, useState } from 'react'
+import { useEffect, useRef, useState } from 'react'
@@
   const queryClient = useQueryClient()
   const fileInputRef = useRef<HTMLInputElement>(null)
+  const objectUrlRef = useRef<string | null>(null)
@@
   const [tags, setTags] = useState<string[]>(editLp?.tags.map((t) => t.name) ?? [])
+
+  useEffect(() => {
+    return () => {
+      if (objectUrlRef.current) URL.revokeObjectURL(objectUrlRef.current)
+    }
+  }, [])
@@
   const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
     const f = e.target.files?.[0]
     if (!f) return
+    if (objectUrlRef.current) URL.revokeObjectURL(objectUrlRef.current)
+    const objectUrl = URL.createObjectURL(f)
+    objectUrlRef.current = objectUrl
     setFile(f)
-    setPreview(URL.createObjectURL(f))
+    setPreview(objectUrl)
   }

Also applies to: 34-37, 62-67

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Week08/SJ01-max/Mission2/src/components/LpCreateModal.tsx` at line 1, The
file preview created by URL.createObjectURL is never revoked, causing memory
leaks; update the file-selection and cleanup logic (e.g., in the handler that
calls URL.createObjectURL — likely handleFileChange or wherever setPreview is
used — and the component-level cleanup) to revoke previous object URLs using
URL.revokeObjectURL before replacing them and also revoke any remaining object
URL in a useEffect cleanup on unmount; use a ref (e.g., prevObjectUrlRef) to
track the current object URL so you can call
URL.revokeObjectURL(prevObjectUrlRef.current) before setting a new preview and
clear the ref after revocation.
Week08/SJ01-max/Mission3/src/components/LpCreateModal.tsx-62-67 (1)

62-67: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

프리뷰 blob URL 해제 누락으로 메모리 누수 위험이 있습니다.

Line 66에서 생성한 object URL을 정리하지 않아 이미지 교체가 반복되면 누수가 누적됩니다. useEffect를 추가하여 preview 상태가 변경될 때마다 이전 blob URL을 URL.revokeObjectURL()로 해제하거나, handleFileChange에서 새로운 URL 생성 전 이전 URL을 정리하는 것이 필요합니다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Week08/SJ01-max/Mission3/src/components/LpCreateModal.tsx` around lines 62 -
67, The preview blob URL created in handleFileChange is not revoked, causing
memory leaks; modify the component to revoke the previous URL whenever preview
changes and on unmount. Specifically, either: (1) in handleFileChange, call
URL.revokeObjectURL(preview) before creating a new URL and then
setPreview(URL.createObjectURL(f)), or (2) add a useEffect that watches preview
and on cleanup calls URL.revokeObjectURL(oldPreview) and also returns a cleanup
to revoke the current preview on unmount; reference the handleFileChange
function and the preview state/setPreview when implementing this fix.
Week08/SJ01-max/Mission1/src/pages/LpDetailPage.tsx-124-129 (1)

124-129: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

썸네일 프리뷰 blob URL을 정리해야 합니다.

Line 128에서 생성하는 blob URL을 해제하지 않아 썸네일을 반복 변경할 때마다 메모리 누수가 발생합니다. 새로운 파일을 선택하기 전에 기존 blob URL을 URL.revokeObjectURL()로 정리하고, 편집 취소 시에도 마찬가지로 정리해야 합니다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Week08/SJ01-max/Mission1/src/pages/LpDetailPage.tsx` around lines 124 - 129,
The preview blob URL created in handleFileChange (which calls
setEditPreview(URL.createObjectURL(f))) must be revoked to avoid memory leaks:
before creating a new blob URL, revoke the previous one stored in editPreview
via URL.revokeObjectURL(editPreview); likewise revoke the current editPreview
when clearing the edit (e.g., in your cancel/clear handler that calls
setEditFile(null) or setEditPreview(null)) and also add a useEffect cleanup that
revokes editPreview on component unmount; update the handlers and add the
cleanup to ensure every created blob URL is revoked.
Week08/SJ01-max/Mission2/src/pages/MyPage.tsx-60-65 (1)

60-65: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Blob URL 해제가 없어 프리뷰 교체 시 메모리 누수가 발생합니다.

Line 64에서 URL.createObjectURL(f)로 생성한 object URL을 해제하지 않으면, 사용자가 파일을 여러 번 변경할 때마다 blob URL이 메모리에 누적되고, 컴포넌트 언마운트 시에도 정리되지 않습니다.

수정 예시
-import { useRef, useState } from 'react'
+import { useEffect, useRef, useState } from 'react'
 ...
 export default function MyPage() {
   const { user, updateUser } = useAuth()
 
   const [isEditing, setIsEditing] = useState(false)
   const [name, setName] = useState(user?.name ?? '')
   const [bio, setBio] = useState(user?.bio ?? '')
   const [avatarFile, setAvatarFile] = useState<File | null>(null)
   const [avatarPreview, setAvatarPreview] = useState<string | null>(user?.avatar ?? null)
   const fileInputRef = useRef<HTMLInputElement>(null)
+  const objectUrlRef = useRef<string | null>(null)
 
   ...
 
   const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
     const f = e.target.files?.[0]
     if (!f) return
     setAvatarFile(f)
-    setAvatarPreview(URL.createObjectURL(f))
+    if (objectUrlRef.current) URL.revokeObjectURL(objectUrlRef.current)
+    const nextUrl = URL.createObjectURL(f)
+    objectUrlRef.current = nextUrl
+    setAvatarPreview(nextUrl)
   }
+
+  useEffect(() => {
+    return () => {
+      if (objectUrlRef.current) URL.revokeObjectURL(objectUrlRef.current)
+    }
+  }, [])
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Week08/SJ01-max/Mission2/src/pages/MyPage.tsx` around lines 60 - 65, The
avatar preview uses URL.createObjectURL in handleAvatarChange (setting state via
setAvatarPreview) but never revokes previous blob URLs; update
handleAvatarChange to revoke the prior preview URL (if any) via
URL.revokeObjectURL before creating a new one, and add a cleanup (e.g., in a
useEffect cleanup or componentWillUnmount equivalent) to revoke the current
avatarPreview when the component unmounts to avoid memory leaks.
Week08/SJ01-max/Mission1/src/components/LpCreateModal.tsx-62-67 (1)

62-67: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

URL.createObjectURL로 생성된 객체 URL 메모리 누수.

LpDetailPage와 동일한 문제입니다. 이전 프리뷰 URL을 해제하지 않으면 메모리 누수가 발생합니다.

🔧 수정 방법
 const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
   const f = e.target.files?.[0]
   if (!f) return
   setFile(f)
+  if (preview) URL.revokeObjectURL(preview)
   setPreview(URL.createObjectURL(f))
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Week08/SJ01-max/Mission1/src/components/LpCreateModal.tsx` around lines 62 -
67, handleFileChange creates object URLs with URL.createObjectURL but never
revokes previous ones, causing a memory leak; update handleFileChange to store
the previous preview value and call URL.revokeObjectURL(prev) before calling
setPreview(URL.createObjectURL(f)), and add a cleanup useEffect that revokes the
current preview on unmount (referencing handleFileChange and the preview
state/setPreview) so any remaining object URL is released when the component
unmounts.
Week08/SJ01-max/Mission3/src/pages/LpDetailPage.tsx-124-129 (1)

124-129: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

URL.createObjectURL로 생성된 객체 URL을 해제하지 않아 메모리 누수가 발생합니다.

파일 선택 시마다 새 객체 URL이 생성되지만 이전 URL이 해제되지 않습니다. 컴포넌트 언마운트 또는 새 파일 선택 시 URL.revokeObjectURL을 호출해야 합니다.

🔧 메모리 누수 수정
 const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
   const f = e.target.files?.[0]
   if (!f) return
   setEditFile(f)
+  if (editPreview) URL.revokeObjectURL(editPreview)
   setEditPreview(URL.createObjectURL(f))
 }

또한 컴포넌트 언마운트 시 정리를 위해 useEffect를 추가하세요:

useEffect(() => {
  return () => {
    if (editPreview) URL.revokeObjectURL(editPreview)
  }
}, [editPreview])
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Week08/SJ01-max/Mission3/src/pages/LpDetailPage.tsx` around lines 124 - 129,
The handleFileChange handler creates object URLs with URL.createObjectURL but
never revokes them, causing leaks; update handleFileChange to revoke any
existing preview URL before calling setEditPreview (i.e., if editPreview exists
call URL.revokeObjectURL(editPreview) then setEditPreview(newUrl)), and add a
useEffect cleanup that revokes editPreview on unmount (useEffect(() => () => {
if (editPreview) URL.revokeObjectURL(editPreview) }, [editPreview])); reference
handleFileChange, setEditFile, setEditPreview, and the new useEffect cleanup to
implement this.
Week08/SJ01-max/Mission1/src/components/LpCreateModal.tsx-133-134 (1)

133-134: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Enter 키로 태그 추가 시 기본 폼 동작을 방지해야 합니다.

Enter 키를 눌렀을 때 폼이 의도치 않게 제출될 수 있습니다. preventDefault()를 호출하세요.

🔧 수정
-onKeyDown={(e) => e.key === 'Enter' && handleAddTag()}
+onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleAddTag() } }}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Week08/SJ01-max/Mission1/src/components/LpCreateModal.tsx` around lines 133 -
134, The input's onKeyDown handler should prevent the default form-submit
behavior when Enter is pressed: in the onKeyDown callback for the tag input
(where it currently does e.key === 'Enter' && handleAddTag()), first call
e.preventDefault() when e.key === 'Enter' and then invoke handleAddTag(); update
the handler in LpCreateModal.tsx to use the event parameter to call
preventDefault() before calling handleAddTag.
Week08/SJ01-max/Mission3/src/components/SearchModal.tsx-63-72 (1)

63-72: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

IntersectionObserver가 isFetchingNextPage를 확인하지 않아 중복 요청이 발생할 수 있습니다.

스크롤 시 이미 다음 페이지를 가져오는 중인데도 fetchNextPage가 다시 호출될 수 있습니다. 쓰로틀링이 적용된 Mission2와 달리 이 컴포넌트는 보호 장치가 없습니다.

🔧 중복 요청 방지
 useEffect(() => {
   const el = bottomRef.current
   if (!el) return
   const observer = new IntersectionObserver(
-    (entries) => { if (entries[0].isIntersecting && hasNextPage) fetchNextPage() },
+    (entries) => { 
+      if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) fetchNextPage() 
+    },
     { threshold: 0.1 },
   )
   observer.observe(el)
   return () => observer.disconnect()
-}, [hasNextPage, fetchNextPage])
+}, [hasNextPage, fetchNextPage, isFetchingNextPage])
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Week08/SJ01-max/Mission3/src/components/SearchModal.tsx` around lines 63 -
72, The IntersectionObserver callback should guard against duplicate loads by
checking isFetchingNextPage before calling fetchNextPage: update the observer
callback to only call fetchNextPage when entries[0].isIntersecting &&
hasNextPage && !isFetchingNextPage, and add isFetchingNextPage to the useEffect
dependency array so the closure sees current state; locate this change around
bottomRef, hasNextPage, fetchNextPage and isFetchingNextPage usage inside the
useEffect/IntersectionObserver block and adjust dependencies accordingly.
🧹 Nitpick comments (11)
Week08/SJ01-max/Mission3/src/pages/SignupPage.tsx (1)

16-30: ⚡ Quick win

일관성을 위해 useMutation 사용을 권장합니다.

LoginPageuseMutation을 사용하지만 이 컴포넌트는 수동으로 상태를 관리합니다. 동일한 패턴을 적용하면 코드베이스 전반의 일관성이 향상됩니다.

♻️ useMutation 패턴으로 리팩토링 제안
+ import { useMutation } from '`@tanstack/react-query`'
  import { useState, type FormEvent } from 'react'
  import { Link, useNavigate } from 'react-router-dom'
  import { useAuth } from '../contexts/AuthContext'
  import { apiSignup, apiSignin } from '../api/client'
  
  export default function SignupPage() {
    const { login } = useAuth()
    const navigate = useNavigate()
  
    const [name, setName] = useState('')
    const [email, setEmail] = useState('')
    const [password, setPassword] = useState('')
-   const [error, setError] = useState('')
-   const [loading, setLoading] = useState(false)
  
+   const { mutate, isPending, error } = useMutation({
+     mutationFn: async () => {
+       await apiSignup({ name, email, password })
+       return await apiSignin({ email, password })
+     },
+     onSuccess: async (tokens) => {
+       await login(tokens)
+       navigate('/', { replace: true })
+     },
+   })
  
    const handleSubmit = async (e: FormEvent) => {
      e.preventDefault()
-     setError('')
-     setLoading(true)
-     try {
-       await apiSignup({ name, email, password })
-       const tokens = await apiSignin({ email, password })
-       await login(tokens)
-       navigate('/', { replace: true })
-     } catch {
-       setError('회원가입에 실패했습니다. 이미 사용 중인 이메일일 수 있습니다.')
-     } finally {
-       setLoading(false)
-     }
+     mutate()
    }

그리고 JSX에서:

-         {error && <p className="text-center text-sm text-red-400">{error}</p>}
+         {error && <p className="text-center text-sm text-red-400">회원가입에 실패했습니다. 이미 사용 중인 이메일일 수 있습니다.</p>}
          <button
            type="submit"
-           disabled={loading}
+           disabled={isPending}
            className="rounded-lg bg-pink-500 py-3 text-sm font-semibold text-white hover:bg-pink-400 disabled:opacity-50"
          >
-           {loading ? '처리 중...' : '회원가입'}
+           {isPending ? '처리 중...' : '회원가입'}
          </button>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Week08/SJ01-max/Mission3/src/pages/SignupPage.tsx` around lines 16 - 30,
Replace the manual handleSubmit state management with React Query's useMutation:
create a signup mutation (calling apiSignup) and a signin mutation (calling
apiSignin) or chain them inside the signup mutation's onSuccess; in signup's
onSuccess call the signin mutation or call login(tokens) after receiving tokens,
then call navigate('/', { replace: true }); remove manual setLoading and
setError usage in handleSubmit and instead derive loading from
mutation.isLoading and set error via mutation.onError (or by reading
mutation.error) so the component uses useMutation for apiSignup/apiSignin and
the existing functions login and navigate.
Week08/SJ01-max/Mission1/src/pages/LpCreatePage.tsx (1)

13-30: ⚡ Quick win

일관성을 위해 useMutation 사용을 권장합니다.

다른 페이지들(예: LoginPage)은 @tanstack/react-queryuseMutation을 사용하지만, 이 컴포넌트는 수동으로 loadingerror 상태를 관리합니다. 일관된 패턴을 유지하면 코드 유지보수성이 향상됩니다.

♻️ useMutation 패턴으로 리팩토링 제안
+ import { useMutation } from '`@tanstack/react-query`'
  import { useState, type FormEvent } from 'react'
  import { useNavigate } from 'react-router-dom'
  import { apiClient } from '../api/client'
  
  export default function LpCreatePage() {
    const navigate = useNavigate()
    const [title, setTitle] = useState('')
    const [content, setContent] = useState('')
    const [thumbnail, setThumbnail] = useState('')
-   const [loading, setLoading] = useState(false)
-   const [error, setError] = useState('')
  
+   const { mutate, isPending, error } = useMutation({
+     mutationFn: () => apiClient.post('/lps', {
+       title,
+       content,
+       thumbnail: thumbnail || null,
+       published: true,
+     }),
+     onSuccess: ({ data }) => {
+       navigate(`/lp/${data.data.id}`, { replace: true })
+     },
+   })
  
    const handleSubmit = async (e: FormEvent) => {
      e.preventDefault()
-     setError('')
-     setLoading(true)
-     try {
-       const { data } = await apiClient.post('/lps', {
-         title,
-         content,
-         thumbnail: thumbnail || null,
-         published: true,
-       })
-       navigate(`/lp/${data.data.id}`, { replace: true })
-     } catch {
-       setError('LP 생성에 실패했습니다.')
-     } finally {
-       setLoading(false)
-     }
+     mutate()
    }

그리고 JSX에서:

-       {error && <p className="text-center text-sm text-red-400">{error}</p>}
+       {error && <p className="text-center text-sm text-red-400">LP 생성에 실패했습니다.</p>}
        <button
          type="submit"
-         disabled={loading}
+         disabled={isPending}
          className="rounded-lg bg-pink-500 py-3 text-sm font-semibold text-white hover:bg-pink-400 disabled:opacity-50"
        >
-         {loading ? '생성 중...' : 'LP 만들기'}
+         {isPending ? '생성 중...' : 'LP 만들기'}
        </button>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Week08/SJ01-max/Mission1/src/pages/LpCreatePage.tsx` around lines 13 - 30,
Replace the manual loading/error state in LpCreatePage's handleSubmit with a
react-query useMutation: create a mutation (using useMutation from
`@tanstack/react-query`) that calls apiClient.post('/lps', { title, content,
thumbnail: thumbnail || null, published: true }); move navigation into the
mutation's onSuccess handler (call navigate(`/lp/${data.data.id}`, { replace:
true })), set the error message in onError (call setError('LP 생성에 실패했습니다.') or
use mutation.error), and use mutation.isLoading and mutation.error in the JSX
instead of setLoading/setError; remove the try/catch/finally and the local
loading state management in handleSubmit so handleSubmit simply calls
mutation.mutate with the payload.
Week08/SJ01-max/Mission2/src/contexts/AuthContext.tsx (1)

46-54: ⚡ Quick win

로그인 실패 시 원본 에러 정보가 손실됩니다.

apiGetMe()가 실패했을 때 원본 에러를 무시하고 새로운 일반 에러 메시지를 던지고 있습니다. 이는 디버깅을 어렵게 만들며, 실제 실패 원인(네트워크 오류, 401 인증 실패 등)을 파악할 수 없게 합니다.

♻️ 원본 에러를 포함하도록 개선
  const login = useCallback(async (tokens: AuthTokens) => {
    localStorage.setItem(AUTH_TOKENS_KEY, JSON.stringify(tokens))
    try {
      setUser(await apiGetMe()); setStatus('authenticated')
-   } catch {
+   } catch (error) {
      localStorage.removeItem(AUTH_TOKENS_KEY); setStatus('unauthenticated')
-     throw new Error('사용자 정보를 가져오는 데 실패했습니다.')
+     throw new Error('사용자 정보를 가져오는 데 실패했습니다.', { cause: error })
    }
  }, [])
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Week08/SJ01-max/Mission2/src/contexts/AuthContext.tsx` around lines 46 - 54,
The login function currently swallows the original failure from apiGetMe();
change the catch block to capture the error (catch (err)), still remove
AUTH_TOKENS_KEY and setStatus('unauthenticated'), and then rethrow the original
error or throw a new Error that includes the original error message/stack (e.g.,
include err.message or use error cause) so callers and logs can see the
underlying failure; update the login function, referencing login, apiGetMe,
AUTH_TOKENS_KEY, setUser, and setStatus accordingly.
Week08/SJ01-max/Mission1/src/pages/MyPage.tsx (2)

26-37: ⚡ Quick win

아바타 업로드 실패 시 사용자 피드백이 없습니다.

30번 라인에서 아바타 업로드가 실패해도 에러가 조용히 무시됩니다. 사용자는 새 이미지를 선택했는데 실제로는 업로드되지 않았다는 사실을 알 수 없습니다.

♻️ 업로드 실패 알림 추가
  const { mutate, isPending, error } = useMutation({
    mutationFn: async () => {
      let avatar = user?.avatar ?? null
+     let avatarUploadFailed = false
      if (avatarFile) {
-       try { avatar = await apiUploadFile(avatarFile) } catch { /* 업로드 실패 시 유지 */ }
+       try { 
+         avatar = await apiUploadFile(avatarFile) 
+       } catch {
+         avatarUploadFailed = true
+       }
      }
      const payload: { name?: string; bio?: string; avatar?: string | null } = {}
      if (name.trim()) payload.name = name.trim()
      if (bio.trim()) payload.bio = bio.trim()
      payload.avatar = avatar
-     return apiUpdateMe(payload)
+     const result = await apiUpdateMe(payload)
+     if (avatarUploadFailed) {
+       alert('프로필 정보는 저장되었으나 이미지 업로드에 실패했습니다.')
+     }
+     return result
    },
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Week08/SJ01-max/Mission1/src/pages/MyPage.tsx` around lines 26 - 37, The
avatar upload error is currently swallowed inside the useMutation's mutationFn
(apiUploadFile called with avatarFile), so users get no feedback when upload
fails; update the catch to surface the failure by either setting a
component-level error/notification state (e.g., setUploadError or a toast) or
rethrowing the caught error so useMutation's error becomes populated (mutate /
error can then show a message), and ensure the payload.avatar remains correct
only when upload succeeded; reference mutationFn, apiUploadFile, avatarFile,
payload, apiUpdateMe, mutate and error when making the change.

145-154: 💤 Low value

저장 버튼이 중복되어 있습니다.

이름 입력 필드 내부의 체크 아이콘 버튼(145-154번 라인)과 하단의 "저장" 버튼(180-190번 라인) 모두 mutate()를 호출합니다. 두 버튼이 같은 동작을 수행하여 사용자에게 혼란을 줄 수 있습니다.

체크 아이콘 버튼을 제거하고 하단의 저장/취소 버튼만 남기는 것을 고려해보세요.

Also applies to: 180-190

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Week08/SJ01-max/Mission1/src/pages/MyPage.tsx` around lines 145 - 154, The
inline SVG "check" button that calls mutate() (the button with onClick={() =>
mutate()} containing the <svg> polyline) is duplicating the lower "저장" save
button behavior; remove that inline check-icon button from the name input area
so only the bottom Save/Cancel controls invoke mutate(), or if you prefer keep
it, change its handler to only perform local validation and not call mutate();
update any related disabled/isPending props or aria/title attributes accordingly
to ensure consistent UX.
Week08/SJ01-max/Mission2/src/hooks/useLocalStorage.ts (1)

19-31: ⚡ Quick win

함수 타입 검사는 typeof를 사용하는 것이 더 안전합니다.

21번 라인의 value instanceof Function은 일부 엣지 케이스(다른 실행 컨텍스트의 함수 등)에서 예상대로 동작하지 않을 수 있습니다. typeof value === 'function'을 사용하는 것이 더 안정적입니다.

♻️ typeof로 변경
  const setValue: SetValue<T> = (value) => {
    try {
-     const next = value instanceof Function ? value(state) : value
+     const next = typeof value === 'function' ? value(state) : value
      setState(next as T)
      if (next == null) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Week08/SJ01-max/Mission2/src/hooks/useLocalStorage.ts` around lines 19 - 31,
The conditional in the setValue function uses "value instanceof Function", which
can fail across different execution contexts; update the check inside setValue
(in the useLocalStorage hook) to use "typeof value === 'function'" when deciding
whether to call the updater with state, then proceed to setState and
localStorage as before; keep the rest of setValue's try/catch and null-check
logic unchanged.
Week08/SJ01-max/Mission3/src/contexts/AuthContext.tsx (1)

46-54: ⚡ Quick win

로그인 실패 시 원본 에러 정보가 손실됩니다.

apiGetMe()가 실패했을 때 원본 에러를 무시하고 새로운 일반 에러 메시지를 던지고 있습니다. 이는 디버깅을 어렵게 만들며, 실제 실패 원인(네트워크 오류, 401 인증 실패 등)을 파악할 수 없게 합니다.

♻️ 원본 에러를 포함하도록 개선
  const login = useCallback(async (tokens: AuthTokens) => {
    localStorage.setItem(AUTH_TOKENS_KEY, JSON.stringify(tokens))
    try {
      setUser(await apiGetMe()); setStatus('authenticated')
-   } catch {
+   } catch (error) {
      localStorage.removeItem(AUTH_TOKENS_KEY); setStatus('unauthenticated')
-     throw new Error('사용자 정보를 가져오는 데 실패했습니다.')
+     throw new Error('사용자 정보를 가져오는 데 실패했습니다.', { cause: error })
    }
  }, [])
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Week08/SJ01-max/Mission3/src/contexts/AuthContext.tsx` around lines 46 - 54,
The catch block in the login callback discards the original error from apiGetMe;
update the catch to capture the error (e.g., catch (err) or (error)), include
the original error details when removing tokens and setting status, and rethrow
a wrapped error or augment the thrown Error with the original message or cause
so debugging info is preserved; locate the login function (useCallback async
(tokens: AuthTokens) { ... }) and change the catch accordingly while keeping the
existing localStorage removal, setStatus('unauthenticated'), and thrown error
behavior.
Week08/SJ01-max/Mission3/src/pages/LpDetailPage.tsx (2)

78-93: ⚡ Quick win

업로드 실패 시 사용자에게 피드백이 없습니다.

apiUploadFile 실패 시 조용히 무시되어 사용자는 썸네일이 변경되지 않은 이유를 알 수 없습니다. 최소한 실패 알림을 제공하는 것이 좋습니다.

♻️ 에러 처리 개선 예시
 const updateMutation = useMutation({
   mutationFn: async () => {
     let thumbnail = lp?.thumbnail ?? null
     if (editFile) {
-      try { thumbnail = await apiUploadFile(editFile) } catch { /* 유지 */ }
+      try { 
+        thumbnail = await apiUploadFile(editFile) 
+      } catch (err) { 
+        console.error('썸네일 업로드 실패:', err)
+        // 선택적: toast 알림 등으로 사용자에게 알림
+      }
     }
     return apiUpdateLp(id, { title: editTitle.trim() || lp?.title, thumbnail })
   },
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Week08/SJ01-max/Mission3/src/pages/LpDetailPage.tsx` around lines 78 - 93,
The mutation quietly swallows errors from apiUploadFile so users get no
feedback; update the try/catch inside the mutationFn of updateMutation to catch
the error from apiUploadFile(editFile), surface a user-visible notification
(e.g., call your toast/alert function or set an error state) and handle control
flow (either rethrow to abort apiUpdateLp or continue with the existing
thumbnail) depending on desired behavior; reference the updateMutation
mutationFn, the editFile variable, apiUploadFile, and apiUpdateLp when making
the change so the upload error is logged/notified and the UI reflects the
failure.

47-59: 💤 Low value

스크롤 초기화 로직이 과도하게 복잡합니다.

requestAnimationFrame, setTimeout(0), setTimeout(100)의 조합은 불필요하게 복잡합니다. 대부분의 경우 단일 호출로 충분합니다.

♻️ 간소화된 스크롤 초기화
 useLayoutEffect(() => {
-  const resetScroll = () => {
-    window.scrollTo(0, 0)
-    document.documentElement.scrollTop = 0
-    document.body.scrollTop = 0
-  }
-  resetScroll()
-  requestAnimationFrame(() => {
-    resetScroll()
-    setTimeout(resetScroll, 0)
-    setTimeout(resetScroll, 100)
-  })
+  window.scrollTo({ top: 0, behavior: 'instant' })
 }, [lpid])
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Week08/SJ01-max/Mission3/src/pages/LpDetailPage.tsx` around lines 47 - 59,
The scroll-reset logic inside the useLayoutEffect (function resetScroll
referenced in the effect) is overly complex (uses requestAnimationFrame and two
setTimeouts); simplify by removing requestAnimationFrame and both setTimeout
calls and call a single, reliable scroll reset (e.g., window.scrollTo(0, 0) and
optionally set document.documentElement.scrollTop/document.body.scrollTop) once
when lpid changes — keep the effect dependency on lpid and remove the extra
timing wrappers to make the code deterministic and readable.
Week08/SJ01-max/Mission3/src/components/SearchModal.tsx (1)

80-82: 💤 Low value

불필요한 래퍼 함수입니다.

handleSearch는 단순히 setQuery를 호출하는 것 외에 아무것도 하지 않습니다. 직접 setQuery를 사용하세요.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Week08/SJ01-max/Mission3/src/components/SearchModal.tsx` around lines 80 -
82, The handleSearch wrapper in SearchModal.tsx is unnecessary because it only
calls setQuery; remove the handleSearch function and replace any
references/callbacks that pass handleSearch with setQuery directly (e.g., event
handlers, child prop bindings) so components call setQuery(...) instead of
handleSearch(...), and delete the now-unused handleSearch declaration to avoid
dead code and linter warnings.
Week08/SJ01-max/Mission2/src/api/client.ts (1)

19-22: 💤 Low value

하드코딩된 baseURL을 환경 변수로 변경하는 것을 권장합니다.

프로덕션 배포 시 http://localhost:8000/v1은 동작하지 않습니다. Vite의 환경 변수를 활용하세요.

♻️ 환경 변수 사용 예시
 export const apiClient = axios.create({
-  baseURL: 'http://localhost:8000/v1',
+  baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000/v1',
   withCredentials: true,
 })
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Week08/SJ01-max/Mission2/src/api/client.ts` around lines 19 - 22, 현재
apiClient axios.create에서 baseURL이 하드코딩('http://localhost:8000/v1')되어 있어 프로덕션에서
동작하지 않습니다; apiClient 생성 코드에서 baseURL을 Vite 환경변수(예: import.meta.env.VITE_API_BASE
또는 VITE_API_BASE + '/v1')를 사용하도록 교체하고, 환경변수가 없을 경우를 대비한 안전한 기본값(fallback)을 설정하세요
so that axios.create({ baseURL: apiBase, withCredentials: true }) uses the env
var; update any related usage if needed.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b9d08d3f-19ac-455f-b2cb-4a4e42125d3c

📥 Commits

Reviewing files that changed from the base of the PR and between be5ff0b and b4c9899.

⛔ Files ignored due to path filters (6)
  • Week08/SJ01-max/Mission1/package-lock.json is excluded by !**/package-lock.json
  • Week08/SJ01-max/Mission1/pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
  • Week08/SJ01-max/Mission2/package-lock.json is excluded by !**/package-lock.json
  • Week08/SJ01-max/Mission2/pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
  • Week08/SJ01-max/Mission3/package-lock.json is excluded by !**/package-lock.json
  • Week08/SJ01-max/Mission3/pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (88)
  • Week08/SJ01-max/Mission1/.gitignore
  • Week08/SJ01-max/Mission1/index.html
  • Week08/SJ01-max/Mission1/package.json
  • Week08/SJ01-max/Mission1/src/App.tsx
  • Week08/SJ01-max/Mission1/src/api/client.ts
  • Week08/SJ01-max/Mission1/src/components/CommentSection.tsx
  • Week08/SJ01-max/Mission1/src/components/Layout.tsx
  • Week08/SJ01-max/Mission1/src/components/LoginModal.tsx
  • Week08/SJ01-max/Mission1/src/components/LpCreateModal.tsx
  • Week08/SJ01-max/Mission1/src/components/SearchModal.tsx
  • Week08/SJ01-max/Mission1/src/components/lp/SkeletonDetail.tsx
  • Week08/SJ01-max/Mission1/src/contexts/AuthContext.tsx
  • Week08/SJ01-max/Mission1/src/hooks/useDebounce.ts
  • Week08/SJ01-max/Mission1/src/hooks/useLocalStorage.ts
  • Week08/SJ01-max/Mission1/src/index.css
  • Week08/SJ01-max/Mission1/src/main.tsx
  • Week08/SJ01-max/Mission1/src/pages/LoginPage.tsx
  • Week08/SJ01-max/Mission1/src/pages/LpCreatePage.tsx
  • Week08/SJ01-max/Mission1/src/pages/LpDetailPage.tsx
  • Week08/SJ01-max/Mission1/src/pages/LpListPage.tsx
  • Week08/SJ01-max/Mission1/src/pages/MyPage.tsx
  • Week08/SJ01-max/Mission1/src/pages/SignupPage.tsx
  • Week08/SJ01-max/Mission1/src/types/lp.ts
  • Week08/SJ01-max/Mission1/src/vite-env.d.ts
  • Week08/SJ01-max/Mission1/tsconfig.app.json
  • Week08/SJ01-max/Mission1/tsconfig.json
  • Week08/SJ01-max/Mission1/tsconfig.node.json
  • Week08/SJ01-max/Mission1/vite.config.ts
  • Week08/SJ01-max/Mission2/.gitignore
  • Week08/SJ01-max/Mission2/index.html
  • Week08/SJ01-max/Mission2/package.json
  • Week08/SJ01-max/Mission2/src/App.tsx
  • Week08/SJ01-max/Mission2/src/api/client.ts
  • Week08/SJ01-max/Mission2/src/components/CommentSection.tsx
  • Week08/SJ01-max/Mission2/src/components/Layout.tsx
  • Week08/SJ01-max/Mission2/src/components/LoginModal.tsx
  • Week08/SJ01-max/Mission2/src/components/LpCreateModal.tsx
  • Week08/SJ01-max/Mission2/src/components/SearchModal.tsx
  • Week08/SJ01-max/Mission2/src/components/lp/SkeletonDetail.tsx
  • Week08/SJ01-max/Mission2/src/contexts/AuthContext.tsx
  • Week08/SJ01-max/Mission2/src/hooks/useDebounce.ts
  • Week08/SJ01-max/Mission2/src/hooks/useLocalStorage.ts
  • Week08/SJ01-max/Mission2/src/hooks/useThrottle.ts
  • Week08/SJ01-max/Mission2/src/index.css
  • Week08/SJ01-max/Mission2/src/main.tsx
  • Week08/SJ01-max/Mission2/src/pages/LoginPage.tsx
  • Week08/SJ01-max/Mission2/src/pages/LpCreatePage.tsx
  • Week08/SJ01-max/Mission2/src/pages/LpDetailPage.tsx
  • Week08/SJ01-max/Mission2/src/pages/LpListPage.tsx
  • Week08/SJ01-max/Mission2/src/pages/MyPage.tsx
  • Week08/SJ01-max/Mission2/src/pages/SignupPage.tsx
  • Week08/SJ01-max/Mission2/src/types/lp.ts
  • Week08/SJ01-max/Mission2/src/vite-env.d.ts
  • Week08/SJ01-max/Mission2/tsconfig.app.json
  • Week08/SJ01-max/Mission2/tsconfig.json
  • Week08/SJ01-max/Mission2/tsconfig.node.json
  • Week08/SJ01-max/Mission2/vite.config.ts
  • Week08/SJ01-max/Mission3/.gitignore
  • Week08/SJ01-max/Mission3/index.html
  • Week08/SJ01-max/Mission3/package.json
  • Week08/SJ01-max/Mission3/src/App.tsx
  • Week08/SJ01-max/Mission3/src/api/client.ts
  • Week08/SJ01-max/Mission3/src/components/CommentSection.tsx
  • Week08/SJ01-max/Mission3/src/components/Layout.tsx
  • Week08/SJ01-max/Mission3/src/components/LoginModal.tsx
  • Week08/SJ01-max/Mission3/src/components/LpCreateModal.tsx
  • Week08/SJ01-max/Mission3/src/components/SearchModal.tsx
  • Week08/SJ01-max/Mission3/src/components/Sidebar.tsx
  • Week08/SJ01-max/Mission3/src/components/lp/SkeletonDetail.tsx
  • Week08/SJ01-max/Mission3/src/contexts/AuthContext.tsx
  • Week08/SJ01-max/Mission3/src/hooks/useDebounce.ts
  • Week08/SJ01-max/Mission3/src/hooks/useLocalStorage.ts
  • Week08/SJ01-max/Mission3/src/hooks/useSidebar.ts
  • Week08/SJ01-max/Mission3/src/hooks/useThrottle.ts
  • Week08/SJ01-max/Mission3/src/index.css
  • Week08/SJ01-max/Mission3/src/main.tsx
  • Week08/SJ01-max/Mission3/src/pages/LoginPage.tsx
  • Week08/SJ01-max/Mission3/src/pages/LpCreatePage.tsx
  • Week08/SJ01-max/Mission3/src/pages/LpDetailPage.tsx
  • Week08/SJ01-max/Mission3/src/pages/LpListPage.tsx
  • Week08/SJ01-max/Mission3/src/pages/MyPage.tsx
  • Week08/SJ01-max/Mission3/src/pages/SignupPage.tsx
  • Week08/SJ01-max/Mission3/src/types/lp.ts
  • Week08/SJ01-max/Mission3/src/vite-env.d.ts
  • Week08/SJ01-max/Mission3/tsconfig.app.json
  • Week08/SJ01-max/Mission3/tsconfig.json
  • Week08/SJ01-max/Mission3/tsconfig.node.json
  • Week08/SJ01-max/Mission3/vite.config.ts

Comment on lines +200 to +210
<div
key={lp.id}
onClick={() => {
if (debouncedQuery.trim()) {
saveRecent(debouncedQuery.trim())
setRecentSearches(getRecent())
}
navigate(`/lps/${lp.id}`)
onClose()
}}
className="group relative aspect-square cursor-pointer overflow-hidden rounded-sm bg-gray-900"
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

검색 결과 카드가 키보드로 접근되지 않습니다.

Line 200의 클릭 가능한 div는 키보드 포커스/엔터 실행이 불가해 접근성 흐름이 막힙니다.

수정 예시
-                  <div
+                  <button
+                    type="button"
                     key={lp.id}
                     onClick={() => {
                       if (debouncedQuery.trim()) {
                         saveRecent(debouncedQuery.trim())
                         setRecentSearches(getRecent())
                       }
                       navigate(`/lps/${lp.id}`)
                       onClose()
                     }}
                     className="group relative aspect-square cursor-pointer overflow-hidden rounded-sm bg-gray-900"
                   >
@@
-                  </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.

Suggested change
<div
key={lp.id}
onClick={() => {
if (debouncedQuery.trim()) {
saveRecent(debouncedQuery.trim())
setRecentSearches(getRecent())
}
navigate(`/lps/${lp.id}`)
onClose()
}}
className="group relative aspect-square cursor-pointer overflow-hidden rounded-sm bg-gray-900"
<button
type="button"
key={lp.id}
onClick={() => {
if (debouncedQuery.trim()) {
saveRecent(debouncedQuery.trim())
setRecentSearches(getRecent())
}
navigate(`/lps/${lp.id}`)
onClose()
}}
className="group relative aspect-square cursor-pointer overflow-hidden rounded-sm bg-gray-900"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Week08/SJ01-max/Mission1/src/components/SearchModal.tsx` around lines 200 -
210, The clickable search-result card uses a plain <div> with an onClick but is
not keyboard-accessible; replace or augment that element in SearchModal (the
mapping that renders the lp card with key={lp.id} and onClick) by making it
keyboard focusable and actionable: add tabIndex={0}, role="button" (or use a
semantic <button>/<a>), and implement an onKeyDown handler that calls the same
logic as onClick when Enter or Space is pressed; also include an accessible name
(aria-label or visible text) so screen readers can announce the card.

Comment on lines +19 to +27
const setValue: SetValue<T> = (value) => {
try {
const next = value instanceof Function ? value(state) : value
setState(next as T)
if (next == null) {
localStorage.removeItem(key)
} else {
localStorage.setItem(key, JSON.stringify(next))
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

setValue의 함수형 업데이트가 stale state를 참조합니다.

Line 21에서 value(state)를 사용하면 연속 호출 시 이전 렌더의 state를 읽어 값이 유실될 수 있습니다. setState(prev => ...) 내부에서 next를 계산하도록 바꿔 주세요.

수정 예시
   const setValue: SetValue<T> = (value) => {
     try {
-      const next = value instanceof Function ? value(state) : value
-      setState(next as T)
-      if (next == null) {
-        localStorage.removeItem(key)
-      } else {
-        localStorage.setItem(key, JSON.stringify(next))
-      }
+      setState((prev) => {
+        const next = value instanceof Function ? value(prev) : value
+        if (next == null) {
+          localStorage.removeItem(key)
+          return defaultValue
+        }
+        localStorage.setItem(key, JSON.stringify(next))
+        return next as T
+      })
     } catch (error) {
       console.error(`useLocalStorage setValue error [${key}]:`, error)
     }
   }
📝 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 setValue: SetValue<T> = (value) => {
try {
const next = value instanceof Function ? value(state) : value
setState(next as T)
if (next == null) {
localStorage.removeItem(key)
} else {
localStorage.setItem(key, JSON.stringify(next))
}
const setValue: SetValue<T> = (value) => {
try {
setState((prev) => {
const next = value instanceof Function ? value(prev) : value
if (next == null) {
localStorage.removeItem(key)
return defaultValue
}
localStorage.setItem(key, JSON.stringify(next))
return next as T
})
} catch (error) {
console.error(`useLocalStorage setValue error [${key}]:`, error)
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Week08/SJ01-max/Mission1/src/hooks/useLocalStorage.ts` around lines 19 - 27,
The setValue function currently calls value(state) directly which can read a
stale state; change setValue to use the functional form of setState
(setState(prev => { const next = value instanceof Function ? value(prev) :
value; if (next == null) { localStorage.removeItem(key) } else {
localStorage.setItem(key, JSON.stringify(next)) } return next as T })) so the
next value is computed from the latest prev state and localStorage is updated
inside that updater; update references to value, setState, setValue and
localStorage accordingly.

Comment on lines +81 to +83
if (editFile) {
try { thumbnail = await apiUploadFile(editFile) } catch { /* 유지 */ }
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

썸네일 업로드 실패를 숨기면 수정 성공 상태가 신뢰되지 않습니다.

Line 82에서 업로드 실패를 무시하고 업데이트를 계속해, 사용자가 변경한 썸네일이 반영되지 않아도 수정 성공으로 종료될 수 있습니다.

수정 예시
 if (editFile) {
-  try { thumbnail = await apiUploadFile(editFile) } catch { /* 유지 */ }
+  thumbnail = await apiUploadFile(editFile)
 }
📝 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
if (editFile) {
try { thumbnail = await apiUploadFile(editFile) } catch { /* 유지 */ }
}
if (editFile) {
thumbnail = await apiUploadFile(editFile)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Week08/SJ01-max/Mission1/src/pages/LpDetailPage.tsx` around lines 81 - 83,
The current try/catch around await apiUploadFile(editFile) in LpDetailPage.tsx
(using variables editFile and thumbnail) swallows upload errors; change it so
upload failures are surfaced and stop or flag the overall save: catch the error
from apiUploadFile(editFile), record or show the error (e.g., call
setError/setToast or setUploadFailed), and abort or return from the submit
handler instead of proceeding to update the resource; alternatively, mark the
update as partial and include the upload failure in the response state so users
know the thumbnail wasn't updated.

Comment on lines +42 to +45
<div
onClick={() => navigate(`/lps/${id}`)}
className="group relative aspect-square cursor-pointer overflow-hidden rounded-sm bg-gray-900"
>
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

카드가 키보드로 접근 불가능합니다.

Line 42~45는 클릭 가능한 div라서 키보드 사용자(Enter/Space)로 상세 페이지 이동이 불가능합니다.

♿ 제안 수정안
-    <div
+    <button
+      type="button"
       onClick={() => navigate(`/lps/${id}`)}
       className="group relative aspect-square cursor-pointer overflow-hidden rounded-sm bg-gray-900"
+      aria-label={`${title} 상세 보기`}
     >
@@
-    </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.

Suggested change
<div
onClick={() => navigate(`/lps/${id}`)}
className="group relative aspect-square cursor-pointer overflow-hidden rounded-sm bg-gray-900"
>
<button
type="button"
onClick={() => navigate(`/lps/${id}`)}
className="group relative aspect-square cursor-pointer overflow-hidden rounded-sm bg-gray-900"
aria-label={`${title} 상세 보기`}
>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Week08/SJ01-max/Mission1/src/pages/LpListPage.tsx` around lines 42 - 45, The
clickable div in LpListPage.tsx (the element with onClick={() =>
navigate(`/lps/${id}`)} and className="group relative ...") is not keyboard
accessible; replace the non-semantic div with a focusable element (preferably an
anchor <a> with href={`/lps/${id}`} or a <button>) or add accessibility
handlers: give the div role="button", tabIndex={0}, and implement onKeyDown to
trigger navigate when Enter or Space is pressed, while keeping the existing
onClick; ensure ARIA/semantic change also preserves the existing className and
behavior.

Comment on lines +93 to +99
const observer = new IntersectionObserver(
(entries) => { if (entries[0].isIntersecting && hasNextPage) fetchNextPage() },
{ threshold: 0.1 },
)
observer.observe(el)
return () => observer.disconnect()
}, [hasNextPage, fetchNextPage])
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

무한 스크롤 콜백에서 중복 요청 방지가 필요합니다.

Line 94는 관찰 이벤트가 연속으로 들어올 때 fetchNextPage()를 중복 호출할 수 있습니다. isFetchingNextPage 가드를 추가해 요청 폭주를 막는 게 안전합니다.

🚦 제안 수정안
   useEffect(() => {
@@
     const observer = new IntersectionObserver(
-      (entries) => { if (entries[0].isIntersecting && hasNextPage) fetchNextPage() },
+      (entries) => {
+        if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
+          fetchNextPage()
+        }
+      },
       { threshold: 0.1 },
     )
@@
-  }, [hasNextPage, fetchNextPage])
+  }, [hasNextPage, isFetchingNextPage, fetchNextPage])
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Week08/SJ01-max/Mission1/src/pages/LpListPage.tsx` around lines 93 - 99, The
IntersectionObserver callback may call fetchNextPage() multiple times when
entries fire rapidly; modify the observer callback in the LpListPage useEffect
to guard with isFetchingNextPage (and hasNextPage) so you only call
fetchNextPage() when hasNextPage is true AND isFetchingNextPage is false; locate
the observer creation where IntersectionObserver is constructed and update the
entries[0].isIntersecting branch to check isFetchingNextPage before calling
fetchNextPage(), keeping observer.observe(el) and observer.disconnect() behavior
unchanged.

Comment on lines +46 to +50
mutationFn: async () => {
let thumbnail: string | null = editLp?.thumbnail ?? null
if (file) {
try { thumbnail = await apiUploadFile(file) } catch { /* 업로드 실패 시 유지 */ }
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

파일 업로드 실패를 무시하면 LP 생성/수정 결과가 불완전해집니다.

Line 49에서 업로드 오류를 삼켜 요청을 계속 보내면, 사용자가 이미지를 선택했는데도 썸네일 없이 성공 처리될 수 있습니다.

수정 예시
 if (file) {
-  try { thumbnail = await apiUploadFile(file) } catch { /* 업로드 실패 시 유지 */ }
+  thumbnail = await apiUploadFile(file)
 }
📝 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
mutationFn: async () => {
let thumbnail: string | null = editLp?.thumbnail ?? null
if (file) {
try { thumbnail = await apiUploadFile(file) } catch { /* 업로드 실패 시 유지 */ }
}
mutationFn: async () => {
let thumbnail: string | null = editLp?.thumbnail ?? null
if (file) {
thumbnail = await apiUploadFile(file)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Week08/SJ01-max/Mission3/src/components/LpCreateModal.tsx` around lines 46 -
50, The mutationFn currently swallows apiUploadFile errors causing the mutation
to proceed without the chosen thumbnail; change the upload error handling so
that if file is present and apiUploadFile throws, the mutation aborts and
surfaces the error instead of continuing. In practice, update mutationFn to
await apiUploadFile(file) inside a try/catch where the catch either rethrows the
error (or returns a rejected Promise) so the calling mutation handler fails, and
optionally log the error; ensure the thumbnail variable (declared as let
thumbnail: string | null = editLp?.thumbnail ?? null) is only used when upload
succeeds, and do not silently keep the old or null thumbnail on upload failure.

Comment on lines +51 to +53
if (isEdit) return apiUpdateLp(editLp.id, { title, content, thumbnail, published: true })
return apiCreateLp({ title, content, thumbnail, tags, published: true })
},
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

수정 모드의 태그 변경이 실제 저장 payload에 반영되지 않습니다.

Line 111153에서 태그를 수정할 수 있는데, Line 5153의 수정 요청에는 tags가 포함되지 않아 UI와 저장 결과가 불일치합니다. 수정 모드에서는 태그 UI를 숨기거나, API가 지원하면 payload에 태그를 포함하세요.

Also applies to: 111-153

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Week08/SJ01-max/Mission3/src/components/LpCreateModal.tsx` around lines 51 -
53, The update path omits tags so edits to tags in the UI aren't persisted;
modify the isEdit branch in the submit handler to include tags in the payload
passed to apiUpdateLp (e.g., apiUpdateLp(editLp.id, { title, content, thumbnail,
tags, published: true })), and ensure the submit uses the same tags state used
by the tag UI; alternatively, if the backend doesn't accept tags on update, hide
or disable the tag UI in edit mode (check isEdit, editLp, tags, apiUpdateLp,
apiCreateLp, title, content, thumbnail symbols).

Comment on lines +19 to +27
const setValue: SetValue<T> = (value) => {
try {
const next = value instanceof Function ? value(state) : value
setState(next as T)
if (next == null) {
localStorage.removeItem(key)
} else {
localStorage.setItem(key, JSON.stringify(next))
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

setValue가 stale state를 사용하고 nullT로 강제 캐스팅합니다.

Line [21]에서 updater를 클로저 state로 계산하면 연속 호출 시 값 유실이 생길 수 있고, Line [22]는 null 입력 시 타입 계약([T, ...])을 깨뜨립니다. setState(prev => ...) 내부에서 계산하고 null이면 defaultValue로 되돌려 주세요.

수정 예시
   const setValue: SetValue<T> = (value) => {
-    try {
-      const next = value instanceof Function ? value(state) : value
-      setState(next as T)
-      if (next == null) {
-        localStorage.removeItem(key)
-      } else {
-        localStorage.setItem(key, JSON.stringify(next))
-      }
-    } catch (error) {
-      console.error(`useLocalStorage setValue error [${key}]:`, error)
-    }
+    setState((prev) => {
+      try {
+        const next = value instanceof Function ? value(prev) : value
+        if (next == null) {
+          localStorage.removeItem(key)
+          return defaultValue
+        }
+        localStorage.setItem(key, JSON.stringify(next))
+        return next
+      } catch (error) {
+        console.error(`useLocalStorage setValue error [${key}]:`, error)
+        return prev
+      }
+    })
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Week08/SJ01-max/Mission3/src/hooks/useLocalStorage.ts` around lines 19 - 27,
The updater setValue currently reads the stale closure variable state and
force-casts null to T; change it to use the functional updater form of setState
so the new value is computed from prev (use setState(prev => { const next =
value instanceof Function ? value(prev) : value })), treat next === null by
returning defaultValue (do not cast null to T) and reflect that in storage
logic: removeItem(key) when next is null/undefined, otherwise setItem(key,
JSON.stringify(next)); update use of localStorage (removeItem/setItem)
accordingly so state and storage stay consistent.

Comment on lines +43 to +46
<div
onClick={() => navigate(`/lps/${id}`)}
className="group relative aspect-square cursor-pointer overflow-hidden rounded-sm bg-gray-900"
>
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

클릭 가능한 카드가 키보드 접근 불가입니다.

Line 43의 div + onClick 패턴은 키보드 사용자가 상세 페이지로 이동하기 어렵습니다. button/Link로 바꾸거나 최소 role="button", tabIndex, onKeyDown을 추가해 주세요.

수정 예시 (Link 사용)
+import { Link, useNavigate } from 'react-router-dom'
...
-    <div
-      onClick={() => navigate(`/lps/${id}`)}
-      className="group relative aspect-square cursor-pointer overflow-hidden rounded-sm bg-gray-900"
-    >
+    <Link
+      to={`/lps/${id}`}
+      className="group relative block aspect-square cursor-pointer overflow-hidden rounded-sm bg-gray-900"
+      aria-label={`${title} 상세 보기`}
+    >
...
-    </div>
+    </Link>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Week08/SJ01-max/Mission3/src/pages/LpListPage.tsx` around lines 43 - 46, The
clickable card uses a plain <div> with onClick (the element containing
onClick={() => navigate(`/lps/${id}`)} and className="group relative..."), which
is not keyboard-accessible; replace the <div> with a semantic interactive
element such as a React Router <Link> (pointing to `/lps/${id}`) or a <button>,
or if you must keep a div add role="button", tabIndex={0}, and an onKeyDown
handler that calls navigate(`/lps/${id}`) when Enter or Space is pressed; ensure
the navigate usage and the id identifier are preserved and that focus styles
remain visible for keyboard users.

Comment on lines +34 to +45
if (bio.trim()) payload.bio = bio.trim()
payload.avatar = avatar
return apiUpdateMe(payload)
},
onMutate: async () => {
const prev = user
if (user) {
updateUser({
...user,
name: name.trim() || user.name,
bio: bio.trim() || user.bio,
avatar: avatarPreview ?? user.avatar ?? null,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

소개(bio)를 빈 값으로 초기화할 수 없습니다.

Line 34와 Line 44 로직 때문에 사용자가 소개를 지워도 기존 값이 유지됩니다. “소개 삭제”가 기능적으로 막혀 있습니다.

수정 예시
-      if (bio.trim()) payload.bio = bio.trim()
+      payload.bio = bio.trim() === '' ? null : bio.trim()
@@
-          bio: bio.trim() || user.bio,
+          bio: bio.trim() === '' ? null : bio.trim(),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Week08/SJ01-max/Mission3/src/pages/MyPage.tsx` around lines 34 - 45, The
current onMutate/update flow prevents clearing bio because bio.trim() ||
user.bio preserves the old value and payload.bio is only set when bio.trim() is
non-empty; change both the optimistic update and payload construction to allow
an explicit empty string or null: in the mutation function that builds payload
use payload.bio = bio.trim() === "" ? "" : bio.trim() (or null if you prefer)
instead of conditionally setting it, and in onMutate call updateUser({...user,
name: name.trim() || user.name, bio: bio.trim() === "" ? "" : bio.trim(),
avatar: avatarPreview ?? user.avatar ?? null}) so deleting the bio is reflected
immediately and sent to the API.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant