Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .claude/settings.local.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"permissions": {
"allow": [
"Bash(pnpm build *)",
"Bash(npm install *)",
"Bash(npx tsc *)",
"Bash(echo \"EXIT: $?\")",
"Bash(npm run *)",
"Read(//tmp/**)",
"Bash(curl -s -o /dev/null -w \"%{http_code}\" http://localhost:5173/)",
"Bash(kill %1)",
"Bash(wait)",
"Bash(curl -s \"https://api.themoviedb.org/3/search/movie?api_key=ca324ae23606391798bdf45057a9bf4f&query=inception&language=ko-KR&include_adult=false\")"
]
}
}
Empty file added .claudeignore
Empty file.
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# 이 파일을 복사해 .env 로 만들고 본인 키를 채우세요. (.env 는 git에 커밋되지 않습니다)
VITE_TMDB_API_KEY=your_tmdb_api_key_here
12 changes: 12 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# 모든 텍스트 파일의 줄바꿈을 저장소·작업트리 모두 LF로 통일한다.
# (Windows의 core.autocrlf 설정과 무관하게 CRLF 변환 경고가 더 이상 뜨지 않음)
* text=auto eol=lf

# 바이너리로 다뤄 변환하지 않을 파일들
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.woff binary
*.woff2 binary
34 changes: 34 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# dependencies
node_modules
.pnp
.pnp.js

# build output
dist
dist-ssr
*.local
*.tsbuildinfo

# env files (절대 커밋 금지 - TMDB API 키 보호)
.env
.env.*
!.env.example

# logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*

# editor / os
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
90 changes: 90 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Location

All source code lives under `UMC-10th-mission-FE/`. Run all commands from that directory.

## Commands

```bash
# Install dependencies (pnpm)
pnpm install

# Start dev server
pnpm dev

# Type-check and build
pnpm build

# Lint
pnpm lint
```

No test suite is configured. Use `pnpm build` to catch type errors.

## Environment

Create `UMC-10th-mission-FE/.env.local` with:

```
VITE_API_BASE_URL=http://localhost:8080
```

## Architecture

**Stack:** React 19 + TypeScript + Vite, TailwindCSS v4, TanStack Query v5, React Router v7, Axios, Zod + React Hook Form, Styled Components.

**Entry point:** `src/main.tsx` → `src/App.tsx`

### Routing (`src/App.tsx`)

React Router v7 with a nested layout structure:

```
AppRoot (QueryClientProvider + AuthProvider)
└── HomeLayout (nav + sidebar + floating button)
├── / (HomePage)
├── /login, /signup, /v1/auth/google/callback
├── /lps (LPListPage)
├── /lps/:lpid (LPDetailPage)
└── PrivateLayout (redirects to /login if no accessToken)
├── /mypage
└── /write
```

### Auth flow

- `AuthContext` (`src/context/AuthContext.tsx`) holds `accessToken` in React state, initialized from `localStorage`.
- Tokens stored under keys in `src/constants/key.ts` (`accessToken`, `refreshToken`).
- `src/apis/axios.ts` — single `axiosInstance` with two interceptors: attaches Bearer token on every request, auto-refreshes on 401 (deduped with a shared `refreshPromise`), clears storage and redirects to `/login` on refresh failure.
- `PrivateLayout` reads `accessToken` from context and redirects unauthenticated users.

### API layer (`src/apis/`)

- `axios.ts` — configured `axiosInstance` (base URL from `VITE_API_BASE_URL`)
- `auth.ts` — sign-in, sign-up, logout, Google OAuth, my-info endpoints
- `lp.ts` — LP list (cursor-based), LP detail, LP comments

### Data fetching hooks (`src/hooks/`)

All hooks wrap TanStack Query:
- `useGetLpList` — `useInfiniteQuery` with cursor pagination; `queryKey: ["lps", sort]`
- `useGetLPDetail` — `useQuery` for a single LP
- `useGetLpComments` — comments for an LP
- `useGetMyInfo` — accepts `accessToken | null`; skips the query when null

### Infinite scroll pattern

`LPListPage` combines `useInfiniteQuery` + `react-intersection-observer`: a sentinel `<div ref={ref}>` at the bottom triggers `fetchNextPage()` via `useEffect` when `inView && hasNextPage && !isFetchingNextPage`. Initial load shows a grid of `<LpCardSkeleton>` components; subsequent pages append more skeletons below the list while fetching.

### Types (`src/types/`)

- `CommonRes<T>` — wrapper `{ status, message, data: T }` for all API responses
- `lp.ts` — `Lp`, `GetLpsResponse`
- `auth.ts` — auth DTOs

### Styling

Tailwind v4 utility classes throughout. Color palette: `#0f1014` backgrounds, `#FF1493` accent/primary, `#1a1a1a` / `#333` surfaces. `LpCardSkeleton` uses `animate-pulse` for loading states.
86 changes: 86 additions & 0 deletions MOVIE_SEARCH_README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# 🎥 TMDB 영화 검색 + 렌더링 최적화 미션

Vite + React + TypeScript 로 만든 TMDB 영화 검색 사이트입니다.
`memo` / `useCallback` / `useMemo` 로 불필요한 리렌더를 제거하는 과정을 콘솔 로그로 확인할 수 있습니다.

## 실행 방법

```bash
# 1. 의존성 설치
npm install

# 2. 환경변수 설정 (루트 .env)
# .env.example 을 복사해 .env 로 만들고 본인 TMDB 키를 넣으세요.
# VITE_TMDB_API_KEY=발급받은_키
# ※ .env 는 .gitignore 에 포함되어 커밋되지 않습니다.

# 3. 개발 서버 실행
npm run dev # http://localhost:5173

# (선택) 타입체크 + 프로덕션 빌드
npm run build
```

## 폴더 구조 (이번 미션에서 만든 파일)

```
.
├─ .env # VITE_TMDB_API_KEY (git 미추적)
├─ .env.example # 키 자리표시자 템플릿
├─ .gitignore # .env 포함
├─ index.html
├─ package.json
├─ tsconfig.json
├─ vite.config.ts
└─ src/
├─ main.tsx
├─ App.tsx # MovieSearch 렌더
├─ index.css
├─ vite-env.d.ts # VITE_TMDB_API_KEY 타입 선언
├─ apis/
│ └─ tmdb.ts # search/movie 호출 (키는 import.meta.env 로만 사용)
├─ types/
│ └─ movie.ts # Movie / 응답 / Language 타입
└─ components/
├─ MovieSearch.tsx # 부모: 검색 폼 + 상태 + 최적화 훅
└─ MovieCard.tsx # 자식: memo 적용 카드
```

## 단계별 구현 내용

### 1단계 — 기본 검색 기능 (`MovieSearch.tsx`)
- 상단 검색 영역을 `<form>` 으로 감싸 **엔터만으로도 검색** (`onSubmit` + `e.preventDefault()`).
- 영화 제목 `text input` — `placeholder="영화 제목을 입력하세요"`, 값은 `title` state.
- 성인 콘텐츠 `checkbox` — `includeAdult` boolean state → API `include_adult` 파라미터.
- 언어 `select` — 한국어(`ko-KR`)·영어(`en-US`)·일본어(`ja-JP`), `language` state → API `language` 파라미터.
- TMDB `search/movie` 호출 후 포스터·제목·평점·개요를 리스트로 렌더.
- **로딩 상태** 표시, **빈 검색어**(`trim()` 후 빈 값)는 호출하지 않음.

### 2단계 — 리렌더 추적 로그
- 부모(`MovieSearch`)와 자식(`MovieCard`)에 각각 `console.log` 삽입.
- 검색어를 타이핑하면 `title` state가 바뀌어 부모가 리렌더되고,
최적화 전에는 **목록이 그대로인데도 모든 카드가 다시 렌더**되는 걸 콘솔에서 볼 수 있음.

### 3단계 — 최적화 적용
- **`memo`** (`MovieCard.tsx`): 카드 컴포넌트를 메모이제이션 → `movie`, `onSelect` props가 같으면 리렌더 건너뜀.
- **`useCallback`** (`MovieSearch.tsx`의 `handleSelect`): memo된 카드에 넘기는 핸들러의 **참조를 고정**.
인라인 함수로 넘기면 매 렌더마다 새 함수가 되어 memo가 무력화되므로 필수.
- **`useMemo`** (`sortedMovies`): 평점순 정렬을 `movies`가 바뀔 때만 재계산.
- 반대로 **`handleSubmit`** 은 form에서만 쓰고 자식에게 넘기지 않으므로 `useCallback`으로 감싸지 않음(효과 없음).

## 최적화 전 / 후 콘솔 로그 차이

검색 결과가 떠 있는 상태에서 **검색창에 글자 한 개를 타이핑**할 때:

| 구분 | 부모 로그 | 카드 로그 |
| --- | --- | --- |
| **최적화 전** (memo·useCallback 없음) | `👪 [MovieSearch] render` 1회 | `🎬 [MovieCard] render` **카드 수만큼** (예: 20회) ❌ |
| **최적화 후** (memo + useCallback) | `👪 [MovieSearch] render` 1회 | `🎬 [MovieCard] render` **0회** ✅ |

- 부모는 `title` state가 바뀌므로 어느 경우든 리렌더된다(정상).
- 핵심은 **목록이 바뀌지 않았는데 자식 카드가 다시 그려지지 않는 것**.
`movie` prop은 `useMemo`로 안정적인 배열에서 오고, `onSelect`는 `useCallback`으로 참조가 고정되어
`memo`의 얕은 비교를 통과 → 카드 렌더가 생략된다.
- 새로 **검색을 실행**하면 `movies`가 바뀌어 `useMemo`가 재계산되고 카드들이 정상적으로 렌더된다.

> 참고: `main.tsx`가 `StrictMode`라 개발 모드에서는 초기 렌더 로그가 의도적으로 2번씩 찍힐 수 있습니다(프로덕션 빌드에는 영향 없음). 타이핑 시 카드 로그가 사라지는지로 최적화 효과를 확인하세요.
135 changes: 135 additions & 0 deletions OPTIMIZATION_NOTES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
# 🚀 성능 최적화 정리 (React.memo / useCallback / useMemo)

> React DevTools **Profiler 탭**으로 리렌더를 관찰하고, 불필요한 상위/하위 리렌더를
> `memo` · `useCallback` · `useMemo`로 제거한 과정을 정리한 문서입니다.

## 0. Profiler로 무엇을 보나

1. Chrome 웹스토어에서 **React Developer Tools** 설치 → 개발 모드(`npm run dev`)로 앱 실행.
2. DevTools → **⚛️ Profiler** 탭 → 좌상단 **● Record** 클릭.
3. 영화 검색 / 필터링(검색어 타이핑, 언어 변경 등)을 수행 → **Stop**.
4. **Flamegraph / Ranked** 차트에서 *회색(렌더 안 됨)* vs *색칠(리렌더됨)* 을 확인.
- 설정 ⚙️ → **"Highlight updates when components render"** 를 켜면 화면에서도 리렌더된 컴포넌트에 테두리가 깜빡인다.

관찰 포인트: **목록 데이터가 안 바뀌었는데도 영화 카드들이 리렌더되는가?**

---

## 1. TMDB 영화 검색 — 최적화 전 / 후

### 문제 (최적화 전)

검색창에 글자 하나를 타이핑 → 부모 `MovieSearch`의 `title` state 변경 → 부모 리렌더.
이때 아래 두 가지 때문에 **목록이 그대로인데도 모든 `MovieCard`가 같이 리렌더**된다.

- `MovieCard`가 일반 컴포넌트라 부모가 렌더되면 무조건 자식도 렌더.
- 카드에 넘기는 `onSelect`가 **인라인 화살표 함수**라 매 렌더마다 새 참조.
- 정렬된 배열을 **렌더마다 새로 `sort()`** → 새 배열 참조.

### 해결 (최적화 후)

| 훅 | 위치 | 역할 |
| --- | --- | --- |
| `memo` | `MovieCard.tsx` | `movie`·`onSelect` props가 얕은 비교로 같으면 리렌더 건너뜀 |
| `useCallback` | `MovieSearch.tsx` `handleSelect`, `handleCloseModal` (deps `[]`) | memo된 카드에 넘기는 핸들러 참조 고정 → memo 유지 |
| `useMemo` | `MovieSearch.tsx` `sortedMovies` (deps `[movies]`) | 평점순 정렬을 `movies` 변경 시에만 재계산 + 안정적 배열 참조 |

> `handleSubmit`은 form에서만 쓰고 자식에게 안 넘기므로 일부러 `useCallback`을 쓰지 않았다(효과 없음).

### 콘솔 로그로 본 차이 (Profiler의 대용 증거)

검색 결과 20개가 떠 있는 상태에서 **검색창에 글자 1개 입력** 시:

| 구분 | `👪 MovieSearch` | `🎬 MovieCard` |
| --- | --- | --- |
| 최적화 전 | 1회 | **20회** ❌ |
| 최적화 후 | 1회 | **0회** ✅ |

- 부모는 입력 state가 바뀌므로 어느 쪽이든 1회 렌더(정상).
- 핵심은 **목록 미변경 시 카드 리렌더가 0회**가 된 것. Profiler Flamegraph에서도 카드들이 회색(렌더 안 됨)으로 표시된다.
- 새로 **검색을 실행**하면 `movies`가 바뀌어 `useMemo` 재계산 + 카드 정상 렌더 → 의도대로 동작.

**결론:** 현재 TMDB 앱은 상위 전체가 불필요하게 리렌더되는 구간이 없다(이미 최적화 완료 상태).

---

## 2. LP 사이트 성능 개선 포인트 (3개 이상)

> 현재 LP 프로젝트(`UMC-10th-mission-FE`)는 새 미션을 위해 working tree에서 제거된 상태라,
> 아래는 git 기록의 실제 코드(`LPListPage.tsx`, `CommentSection.tsx`)를 기준으로 한 개선안이다.
> 프로젝트를 복구(`git restore`)하면 그대로 적용 가능.

### ① LP 카드를 별도 컴포넌트로 분리 + `React.memo`

**문제:** `LPListPage`에서 카드를 인라인 `<div>`로 `.map()` 렌더 중. 정렬 토글(`setSort`)이나
다음 페이지 로드(`isFetchingNextPage`) 등으로 페이지가 리렌더되면 **기존 카드 전부가 다시 렌더**된다.

```tsx
// components/LpCard.tsx (신규)
import { memo } from "react";
import type { Lp } from "../types/lp";

interface LpCardProps {
lp: Lp;
onClick: (id: number) => void; // 인라인 navigate 대신 id만 받는 안정적 핸들러
}

function LpCardBase({ lp, onClick }: LpCardProps) {
return (
<div onClick={() => onClick(lp.id)} className="...">
{/* 기존 카드 마크업 */}
</div>
);
}
export default memo(LpCardBase); // props 같으면 리렌더 skip
```

### ② 카드 클릭 핸들러를 `useCallback`으로 고정

**문제:** 기존엔 카드마다 `onClick={() => navigate(`/lps/${lp.id}`)}` 인라인 함수 → memo를 무력화.

```tsx
// LPListPage.tsx
const handleCardClick = useCallback(
(id: number) => navigate(`/lps/${id}`),
[navigate]
);
// ...
{lpList.map((lp) => <LpCard key={lp.id} lp={lp} onClick={handleCardClick} />)}
```

→ ①+② 조합으로, 정렬 토글/추가 로딩 시 **새로 들어온 카드만 렌더**되고 기존 카드는 skip.

### ③ `flatMap` 결과를 `useMemo`로 메모이제이션

**문제:** `const lpList = data?.pages.flatMap(...) || []` 가 **렌더마다 새 배열** 생성 →
하위에 넘기면 참조가 매번 달라져 memo가 깨진다.

```tsx
const lpList = useMemo(
() => data?.pages.flatMap((page) => page.data.data) ?? [],
[data]
);
```

### ④ (보너스) CommentSection 댓글 항목 분리 + memo

**문제:** `CommentSection`은 `commentText`(입력창), `openMenuId`, `editingId` 등 state가 많아
**댓글을 한 글자 타이핑할 때마다 댓글 리스트 전체가 리렌더**된다.

```tsx
// 댓글 한 개를 CommentItem으로 분리하고 memo로 감싼다.
const CommentItem = memo(function CommentItem({ comment, myId, onEdit, onDelete }) { ... });
// 부모에서 onEdit/onDelete는 useCallback으로 고정.
```

→ 입력창 타이핑 시 입력 영역만 리렌더되고, 기존 댓글들은 Profiler에서 회색(skip)으로 유지.

---

## 적용한 최적화 요약 (한 줄)

- **TMDB**: `MovieCard`(memo) + `handleSelect/handleCloseModal`(useCallback) + `sortedMovies`(useMemo)
→ 타이핑 시 카드 리렌더 20회 → 0회.
- **LP**: 카드/댓글 컴포넌트 분리 후 `memo`, 클릭·수정·삭제 핸들러 `useCallback`, 목록 배열 `useMemo`
→ 정렬 토글·추가 로딩·댓글 입력 시 기존 항목 리렌더 제거.
13 changes: 13 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TMDB 영화 검색</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
Loading