Feat: 학습용 데모 및 로또/React Query 기능 추가

package.json
- 개발/실행 스크립트 및 의존성(react, next, react-query 등) 추가/업데이트

package-lock.json
- 종속성 락 파일 갱신

AGENTS.md
- 프로젝트 가이드, 코딩 컨벤션 및 AI 멘토 프롬프트 문서 추가

db.json
- json-server용 샘플 유저 데이터 추가

next.config.ts
- 불필요한 설정 제거

.tsconfig.json
- 경로/JSX 설정 및 형식 정리

.src/app/layout.tsx
- QueryProvider 적용으로 React Query 전역 Provider 추가

src/app/page.tsx
- 새 홈 페이지 컴포넌트 추가

src/app/lotto/page.tsx
- 로또 기능 라우트 추가

src/app/api/lotto/route.ts
- 외부 로또 API를 프록시하는 Next.js API 라우트 추가 (CORS 우회, 디버그 옵션 포함)

src/app/react-query/*, src/app/zustand/*
- React Query 및 Zustand 예제 페이지(목록/상세/생성/로딩) 추가

src/app/globals.css
- Tailwind 기반 전역 스타일 및 테마 변수 확장 (다크 모드 포함)

src/providers/QueryProvider.tsx
- React Query Provider 및 Devtools 추가

src/lib/getQueryClient.ts
- 서버/클라이언트용 QueryClient 생성 유틸 추가

src/lib/utils.ts
- 클래스 병합 유틸 추가 (cn)

src/lib/useDialogDragResize.ts
- Radix Dialog용 드래그/리사이즈 훅 추가

src/components/ui/*.tsx
- shadcn 스타일 UI 컴포넌트들(버튼, 카드, 배지, dialog, table, pagination, skeleton 등) 추가

src/features/react-query-demo/**
- React Query 데모: API 래퍼, 훅(useUsers, useUser, useCreateUser), 컴포넌트(UserList, UserDetail, UserDashboard, UserCreateForm), 타입, 스토어 추가
- 서버 프리패치/하이드레이션 패턴 포함

src/features/lotto/**
- 로또 기능: API 래퍼, payload 파서(다양한 포맷 정규화), hooks, store(Zustand), 컴포넌트(LottoDashboard, LottoTable, RecommendationsDialog), 추천 알고리즘 및 유틸(번호 생성, mock 데이터) 추가

src/features/zustand/**
- Zustand 예제 스토어 및 컴포넌트 추가

.src/components.json
- shadcn 구성 파일 추가

.idea/*
- IDE 설정(inspection, vcs mapping) 추가

여러 파일(주로 새로 추가된 라이브러리 관련 파일)
- 프로젝트 초기 설정과 데모 실행을 위한 구성 및 의존성 추가
This commit is contained in:
2026-01-16 14:43:52 +09:00
parent 87146bf3a9
commit e4f8c3cd25
58 changed files with 6149 additions and 578 deletions

View File

@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
</profile>
</component>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

131
AGENTS.md Normal file
View File

@@ -0,0 +1,131 @@
# 📔 Repository & Teaching Mentor Guidelines
이 파일은 프로젝트의 구조, 코딩 규칙 및 AI 어시스턴트의 **'교육용 멘토'** 역할을 정의합니다.
---
## 🏗️ Project Structure & Module Organization
- `src/app`: Next.js App Router 페이지, 레이아웃, 글로벌 스타일 및 API 라우트 (`src/app/api/...`)
- `src/features`: 기능 중심 모듈화 (예: `lotto`, `react-query-demo`). 각 기능 내부에 `components`, `hooks`, `api` 등을 응집도 있게 관리합니다.
- `src/components/ui`: shadcn/ui 기반의 공통 UI 컴포넌트.
- `src/lib`: 프로젝트 전반에서 공유되는 유틸리티 및 설정 (`utils.ts` 등).
- `public`: 이미지, 폰트 등 정적 자산.
## 💻 Development Commands
- `npm run dev`: 로컬 개발 서버 시작 (Next.js)
- `npm run dev:turbo`: Turbopack 모드로 더 빠른 개발 서버 시작
- `npm run build`: 프로덕션 빌드 생성
- `npm run start`: 프로덕션 빌드 실행
- `npm run server`: `json-server` 실행 (Port: 3002, `db.json` 참조)
## 🎨 Coding Style & Conventions
- **Language**: TypeScript + React
- **Indentation**: 2-space (Prettier/ESLint 설정 기반)
- **Naming**:
- Components: `PascalCase` (예: `LottoTable.tsx`)
- Hooks: `camelCase` (예: `useLottoDraws.ts`)
- Utils/API: `camelCase`
- **Styling**: Tailwind CSS + `cn(...)` 유틸리티 활용. UI 원형(Primitives)은 `src/components/ui`를 우선 사용합니다.
## 🔒 Security & API
- **CORS 방지**: 외부 API 호출 시 직접 브라우저에서 호출하기보다 Next.js API Routes(`src/app/api`)를 프록시로 활용하여 보안과 CORS 문제를 해결합니다.
- **Environment**: 민감한 정보는 `.env`로 관리하며, 클라이언트 환경변수는 `NEXT_PUBLIC_` 접두사를 사용합니다.
---
# 🧑‍🏫 AI Teaching Mentor Rule Prompt
**"단순히 코드만 짜는 로봇이 아닌, 성장을 돕는 친절한 사수가 되어주세요."**
### 1. 역할 및 기본 페르소나
- 당신은 10년 차 이상의 능숙한 시니어 풀스택 개발자이자 실력이 뛰어난 **교육 멘토**입니다.
- 답변 시 항상 **친절하고 격려하는 어조**를 유지하며, 모든 설명과 주석은 **한국어(한글)**로 작성합니다.
- 사용자가 코드의 구현 결과뿐만 아니라 **"왜(Why)"**와 **"어떻게(Flow)"**를 이해하도록 돕는 것이 당신의 존재 이유입니다.
### 2. 코드 생성 시 필수 3요소 (AAA)
#### A. 상세한 해설 (Detailed Explanation)
코드를 제안하기 전(또는 후)에 다음 내용을 반드시 명시합니다.
1. **무엇인가?**: 구현하려는 기능의 핵심 요약.
2. **왜 이렇게 했는가? (Rationale)**: 사용된 기술(React Query, Zustand 등)의 선택 이유와 이점.
3. **데이터의 흐름 (Data Flow)**: 컴포넌트 간, 혹은 클라이언트-서버 간 데이터가 어떻게 이동하는지 상세 설명.
#### B. 교과서 같은 주석 (Educational Comments)
작성하는 모든 코드에는 다음 수준의 주석을 포함합니다.
1. **JSDoc**: 함수, 인터페이스 상단에 역할, 매개변수, 반환값 명시.
2. **논리적 배경**: "단순히 무엇을 한다"가 아니라 "이 조건문이 왜 필요한지"를 주석으로 표현.
3. **기술 스택 포인트**:
- **React Query**: `queryKey` 설계 이유, 캐싱 전략.
- **Zustand**: 상태 원자성(Atomicity) 및 스토어 구조의 장점.
4. **의존성 배열 설명**:
- `useEffect`/`useMemo`/`useCallback` 등 훅의 의존성 배열이 **"어떤 값이 바뀌면 다시 실행되는지"**를 한 줄로 설명합니다.
- 예: "page/totalPages/setPage 중 하나라도 바뀌면 useEffect가 다시 실행됩니다."
- 예시 코드:
```tsx
// page/totalPages/setPage 중 하나라도 바뀌면 이 effect가 다시 실행됩니다.
useEffect(() => {
if (page > totalPages) {
setPage(totalPages);
}
}, [page, totalPages, setPage]);
```
- useMemo 예시:
```tsx
// items/page/pageSize 중 하나라도 바뀌면 계산 결과가 다시 만들어집니다.
const pagedItems = useMemo(() => {
const startIndex = (page - 1) * pageSize;
return items.slice(startIndex, startIndex + pageSize);
}, [items, page, pageSize]);
```
- useCallback 예시:
```tsx
// totalPages/setPage가 바뀌면 새 콜백을 다시 만듭니다.
const handlePageChange = useCallback(
(nextPage: number) => {
const clamped = Math.min(Math.max(nextPage, 1), totalPages);
setPage(clamped);
},
[totalPages, setPage]
);
```
- React Query `queryKey` 예시:
```tsx
// userId가 바뀌면 React Query가 다른 캐시 키로 인식해 재요청합니다.
const userQuery = useQuery({
queryKey: ["users", userId],
queryFn: () => getUser(userId),
});
```
- React Query `invalidateQueries` 예시:
```tsx
// 유저 생성 후 목록 캐시를 무효화해 최신 목록을 다시 가져오게 합니다.
const queryClient = useQueryClient();
await createUser(payload);
queryClient.invalidateQueries({ queryKey: ["users", "list"] });
```
#### C. 다음 단계 가이드 (Next Step)
- 현재 구현 완료 후, 학습자가 도전해볼 만한 심화 작업이나 관련 기술 개념을 하나 추천합니다.
### 3. 답변 템플릿
```markdown
### 📝 오늘 배울 내용: [기능 이름]
[기능에 대한 전반적인 설명과 학습 목표]
#### 💡 기술적 배경과 의도
[이 기술을 선택한 이유와 프로젝트 구조상 이점 설명]
#### 🔍 주요 코드 흐름
1. [흐름 1]
2. [흐름 2]
#### 💻 구현된 소스 코드 (상세 주석 포함)
[주석이 가득한 코드 블록]
#### 🚀 더 나아가기
[연관 학습 키워드 또는 다음 과제]
```
### 4. 주의 사항 (Constraints)
- 주석 없는 코드는 절대 제공하지 않습니다.
- 전문 용어는 가급적 풀어 쓰거나, 필요시 (괄호)를 통해 보충 설명을 덧붙입니다.
- 사용자가 질문하지 않은 부분이라도, 실수하기 쉬운 포인트가 있다면 미리 조언해주세요.

22
components.json Normal file
View File

@@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

610
db.json Normal file
View File

@@ -0,0 +1,610 @@
{
"users": [
{
"id": "1",
"name": "김철수",
"username": "chulsoo_kim",
"email": "chulsoo@example.com"
},
{
"id": "2",
"name": "이영희",
"username": "younghee_lee",
"email": "younghee@example.com"
},
{
"id": "3",
"name": "박지성",
"username": "jisung_park",
"email": "jisung@example.com"
},
{
"id": "4",
"name": "최유진",
"username": "yujin_choi",
"email": "yujin@example.com"
},
{
"id": "5",
"name": "정범석",
"username": "bumseok_jung",
"email": "bumseok@example.com"
},
{
"id": "6",
"name": "강민경",
"username": "minkyung_kang",
"email": "minkyung@example.com"
},
{
"id": "7",
"name": "조세호",
"username": "seho_cho",
"email": "seho@example.com"
},
{
"id": "8",
"name": "윤하늘",
"username": "haneul_yoon",
"email": "haneul@example.com"
},
{
"id": "9",
"name": "임재범",
"username": "jaebeom_lim",
"email": "jaebeom@example.com"
},
{
"id": "10",
"name": "한미소",
"username": "miso_han",
"email": "miso@example.com"
},
{
"id": "11",
"name": "신동엽",
"username": "dongyup_shin",
"email": "dongyup@example.com"
},
{
"id": "12",
"name": "안정환",
"username": "junghwan_ahn",
"email": "junghwan@example.com"
},
{
"id": "13",
"name": "손흥민",
"username": "hm_son",
"email": "son@example.com"
},
{
"id": "14",
"name": "황희찬",
"username": "hc_hwang",
"email": "hwang@example.com"
},
{
"id": "15",
"name": "김연아",
"username": "yuna_kim",
"email": "yuna@example.com"
},
{
"id": "16",
"name": "박보검",
"username": "bogum_park",
"email": "bogum@example.com"
},
{
"id": "17",
"name": "아이유",
"username": "iu_lee",
"email": "iu@example.com"
},
{
"id": "18",
"name": "유재석",
"username": "js_yoo",
"email": "yoo@example.com"
},
{
"id": "19",
"name": "강호동",
"username": "hd_kang",
"email": "kang@example.com"
},
{
"id": "20",
"name": "백종원",
"username": "jw_baek",
"email": "baek@example.com"
},
{
"id": "21",
"name": "심형탁",
"username": "ht_shim",
"email": "shim@example.com"
},
{
"id": "22",
"name": "노홍철",
"username": "hc_no",
"email": "no@example.com"
},
{
"id": "23",
"name": "정형돈",
"username": "hd_jung",
"email": "jung_hd@example.com"
},
{
"id": "24",
"name": "하동훈",
"username": "haha",
"email": "haha@example.com"
},
{
"id": "25",
"name": "박명수",
"username": "ms_park",
"email": "giant_park@example.com"
},
{
"id": "26",
"name": "김종국",
"username": "jk_kim",
"email": "kkook@example.com"
},
{
"id": "27",
"name": "송지효",
"username": "jh_song",
"email": "jihyo@example.com"
},
{
"id": "28",
"name": "이광수",
"username": "ks_lee",
"email": "giraffe@example.com"
},
{
"id": "29",
"name": "지석진",
"username": "sj_jee",
"email": "nose@example.com"
},
{
"id": "30",
"name": "양세찬",
"username": "sc_yang",
"email": "yang@example.com"
},
{
"id": "31",
"name": "공명",
"username": "myung_gong",
"email": "gong@example.com"
},
{
"id": "32",
"name": "차은우",
"username": "ew_cha",
"email": "cha@example.com"
},
{
"id": "33",
"name": "한고은",
"username": "ge_han",
"email": "han_ge@example.com"
},
{
"id": "34",
"name": "서강준",
"username": "gj_seo",
"email": "seo@example.com"
},
{
"id": "35",
"name": "이정재",
"username": "jj_lee",
"email": "lee_jj@example.com"
},
{
"id": "36",
"name": "정우성",
"username": "ws_jung",
"email": "jung_ws@example.com"
},
{
"id": "37",
"name": "황정민",
"username": "jm_hwang",
"email": "hwang_jm@example.com"
},
{
"id": "38",
"name": "이병헌",
"username": "bh_lee",
"email": "lee_bh@example.com"
},
{
"id": "39",
"name": "조승우",
"username": "sw_cho",
"email": "cho_sw@example.com"
},
{
"id": "40",
"name": "공효진",
"username": "hj_kong",
"email": "kong@example.com"
},
{
"id": "41",
"name": "김혜수",
"username": "hs_kim",
"email": "kim_hs@example.com"
},
{
"id": "42",
"name": "손예진",
"username": "yj_son",
"email": "son_yj@example.com"
},
{
"id": "43",
"name": "현빈",
"username": "hb_kim",
"email": "kim_hb@example.com"
},
{
"id": "44",
"name": "김다미",
"username": "dm_kim",
"email": "kim_dm@example.com"
},
{
"id": "45",
"name": "최우식",
"username": "ws_choi",
"email": "choi_ws@example.com"
},
{
"id": "46",
"name": "박서준",
"username": "sj_park",
"email": "park_sj@example.com"
},
{
"id": "47",
"name": "김태리",
"username": "tr_kim",
"email": "kim_tr@example.com"
},
{
"id": "48",
"name": "안효섭",
"username": "hs_ahn",
"email": "ahn_hs@example.com"
},
{
"id": "49",
"name": "김세정",
"username": "sj_kim",
"email": "kim_sj_god@example.com"
},
{
"id": "50",
"name": "로몬",
"username": "lomon",
"email": "lomon@example.com"
},
{
"id": "51",
"name": "장나라",
"username": "nr_jang",
"email": "jang_nr@example.com"
},
{
"id": "52",
"name": "이상윤",
"username": "sy_lee",
"email": "lee_sy@example.com"
},
{
"id": "53",
"name": "송중기",
"username": "jk_song",
"email": "song_jk@example.com"
},
{
"id": "54",
"name": "김태희",
"username": "th_kim",
"email": "kim_th@example.com"
},
{
"id": "55",
"name": "비",
"username": "rain_jung",
"email": "rain@example.com"
},
{
"id": "56",
"name": "전지현",
"username": "jh_jun",
"email": "jun_jh@example.com"
},
{
"id": "57",
"name": "강동원",
"username": "dw_kang",
"email": "kang_dw@example.com"
},
{
"id": "58",
"name": "원빈",
"username": "wb_kim",
"email": "kim_wb@example.com"
},
{
"id": "59",
"name": "고수",
"username": "gs_go",
"email": "go_gs@example.com"
},
{
"id": "60",
"name": "신민아",
"username": "ma_shin",
"email": "shin_ma@example.com"
},
{
"id": "61",
"name": "김우빈",
"username": "wb_kim_real",
"email": "kim_wb_real@example.com"
},
{
"id": "62",
"name": "이종석",
"username": "js_lee",
"email": "lee_js@example.com"
},
{
"id": "63",
"name": "수지",
"username": "suzy_bae",
"email": "suzy@example.com"
},
{
"id": "64",
"name": "남주혁",
"username": "jh_nam",
"email": "nam_jh@example.com"
},
{
"id": "65",
"name": "한주희",
"username": "jh_han",
"email": "han_jh@example.com"
},
{
"id": "66",
"name": "김수현",
"username": "sh_kim",
"email": "kim_sh@example.com"
},
{
"id": "67",
"name": "전도연",
"username": "dy_jeon",
"email": "jeon_dy@example.com"
},
{
"id": "68",
"name": "설경구",
"username": "gg_sul",
"email": "sul_gg@example.com"
},
{
"id": "69",
"name": "하정우",
"username": "jw_ha",
"email": "ha_jw@example.com"
},
{
"id": "70",
"name": "이정은",
"username": "je_lee",
"email": "lee_je@example.com"
},
{
"id": "71",
"name": "조우진",
"username": "wj_cho",
"email": "cho_wj@example.com"
},
{
"id": "72",
"name": "허성태",
"username": "st_heo",
"email": "heo_st@example.com"
},
{
"id": "73",
"name": "이무생",
"username": "ms_lee",
"email": "lee_ms@example.com"
},
{
"id": "74",
"name": "박해준",
"username": "hj_park",
"email": "park_hj@example.com"
},
{
"id": "75",
"name": "김희애",
"username": "ha_kim",
"email": "kim_ha@example.com"
},
{
"id": "76",
"name": "한소희",
"username": "sh_han",
"email": "han_sh@example.com"
},
{
"id": "77",
"name": "안보현",
"username": "bh_ahn",
"email": "ahn_bh@example.com"
},
{
"id": "78",
"name": "박소담",
"username": "sd_park",
"email": "park_sd@example.com"
},
{
"id": "79",
"name": "정해인",
"username": "hi_jung",
"email": "jung_hi@example.com"
},
{
"id": "80",
"name": "지수",
"username": "js_blackpink",
"email": "jisu@example.com"
},
{
"id": "81",
"name": "제니",
"username": "jn_blackpink",
"email": "jenni@example.com"
},
{
"id": "82",
"name": "로제",
"username": "rs_blackpink",
"email": "rose@example.com"
},
{
"id": "83",
"name": "리사",
"username": "ls_blackpink",
"email": "lisa@example.com"
},
{
"id": "84",
"name": "카리나",
"username": "karina_aespa",
"email": "karina@example.com"
},
{
"id": "85",
"name": "윈터",
"username": "winter_aespa",
"email": "winter@example.com"
},
{
"id": "86",
"name": "지젤",
"username": "giselle_aespa",
"email": "giselle@example.com"
},
{
"id": "87",
"name": "닝닝",
"username": "ning_aespa",
"email": "ning@example.com"
},
{
"id": "88",
"name": "장원영",
"username": "wy_ive",
"email": "wonyoung@example.com"
},
{
"id": "89",
"name": "안유진",
"username": "yj_ive",
"email": "yujin@example.com"
},
{
"id": "90",
"name": "레이",
"username": "rei_ive",
"email": "rei@example.com"
},
{
"id": "91",
"name": "가을",
"username": "gaeul_ive",
"email": "gaeul@example.com"
},
{
"id": "92",
"name": "리즈",
"username": "liz_ive",
"email": "liz@example.com"
},
{
"id": "93",
"name": "이서",
"username": "leeseo_ive",
"email": "leeseo@example.com"
},
{
"id": "94",
"name": "민지",
"username": "mj_newjeans",
"email": "minji@example.com"
},
{
"id": "95",
"name": "하니",
"username": "hn_newjeans",
"email": "hanni@example.com"
},
{
"id": "96",
"name": "다니엘",
"username": "dn_newjeans",
"email": "danielle@example.com"
},
{
"id": "97",
"name": "해린",
"username": "hr_newjeans",
"email": "haerin@example.com"
},
{
"id": "98",
"name": "혜인",
"username": "hi_newjeans",
"email": "hyein@example.com"
},
{
"id": "99",
"name": "박진영",
"username": "jyp",
"email": "jyp@example.com"
},
{
"id": "100",
"name": "방시혁",
"username": "hitman_bang",
"email": "bang@example.com"
},
{
"id": "eb12",
"name": "이지훈",
"email": "d1d2r3@naver.com",
"username": "d1d2r3"
}
]
}

View File

@@ -2,7 +2,6 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
reactCompiler: true,
};
export default nextConfig;

1994
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,24 +4,46 @@
"private": true,
"scripts": {
"dev": "next dev",
"dev:turbo": "next dev --turbo",
"build": "next build",
"start": "next start",
"lint": "eslint"
"lint": "eslint",
"server": "json-server --watch db.json --port 3002"
},
"dependencies": {
"next": "16.1.1",
"react": "19.2.3",
"react-dom": "19.2.3"
"@hookform/resolvers": "^5.2.2",
"@mescius/spread-sheets": "^19.0.1",
"@mescius/spread-sheets-react": "^19.0.1",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@tanstack/react-query": "^5.90.16",
"@tanstack/react-query-devtools": "^5.91.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.562.0",
"next": "15.5.9",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-hook-form": "^7.69.0",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7",
"zod": "^4.2.1",
"zustand": "^5.0.9"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/react": "18.3.27",
"@types/react-dom": "18.3.7",
"babel-plugin-react-compiler": "1.0.0",
"eslint": "^9",
"eslint-config-next": "16.1.1",
"eslint-config-next": "15.5.9",
"json-server": "^1.0.0-beta.3",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5"
}
}

View File

@@ -0,0 +1,95 @@
import { NextResponse } from "next/server";
import {
parseLottoPayload,
safeParseJson,
} from "@/features/lotto/api/lottoParser";
const BASE_URL = "https://www.dhlottery.co.kr/lt645/selectPstLt645Info.do";
const UPSTREAM_HEADERS = {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
Accept: "application/json,text/html;q=0.9,*/*;q=0.8",
Referer: "https://www.dhlottery.co.kr/",
};
function buildUpstreamUrl(searchParams: URLSearchParams): string | null {
// 클라이언트 쿼리를 외부 로또 API 규격으로 변환합니다.
const mode = searchParams.get("mode") ?? "all";
if (mode === "all") {
return `${BASE_URL}?srchLtEpsd=all`;
}
if (mode === "single") {
const round = searchParams.get("round");
if (!round) {
return null;
}
return `${BASE_URL}?srchStrLtEpsd=${round}`;
}
if (mode === "range") {
const start = searchParams.get("start");
const end = searchParams.get("end");
if (!start || !end) {
return null;
}
return `${BASE_URL}?srchStrLtEpsd=${start}&srchEndLtEpsd=${end}`;
}
return null;
}
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const upstreamUrl = buildUpstreamUrl(searchParams);
if (!upstreamUrl) {
return NextResponse.json(
{ error: "Invalid query parameters." },
{ status: 400 }
);
}
// 브라우저 CORS 제약을 피하기 위해 서버 라우트가 대신 호출합니다.
const response = await fetch(upstreamUrl, {
cache: "no-store",
headers: UPSTREAM_HEADERS,
});
if (!response.ok) {
return NextResponse.json(
{ error: "Upstream API error." },
{ status: response.status }
);
}
const text = await response.text();
const isDebug = searchParams.get("debug") === "1";
if (isDebug) {
return NextResponse.json({
upstreamUrl,
contentType: response.headers.get("content-type"),
raw: text,
});
}
const payload = safeParseJson(text);
if (!payload) {
return NextResponse.json(
{ error: "Unexpected response format." },
{ status: 502 }
);
}
// Normalize the upstream payload into LottoDraw items.
const items = parseLottoPayload(payload);
return NextResponse.json({
items,
totalCount: items.length,
source: "dhlottery",
});
}

View File

@@ -1,26 +1,149 @@
@import "tailwindcss";
@custom-variant dark (&:is(.dark *));
@plugin "tailwindcss-animate";
:root {
--background: #ffffff;
--foreground: #171717;
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: #f8fafc;
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
--card: #0f172a;
--card-foreground: #e2e8f0;
--popover: #0f172a;
--popover-foreground: #e2e8f0;
--primary: #e2e8f0;
--primary-foreground: #0f172a;
--secondary: #1e293b;
--secondary-foreground: #e2e8f0;
--muted: #1e293b;
--muted-foreground: #94a3b8;
--accent: #1e293b;
--accent-foreground: #e2e8f0;
--destructive: #f87171;
--destructive-foreground: #0f172a;
--border: #1e293b;
--input: #1e293b;
--ring: #e2e8f0;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -1,7 +1,9 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import QueryProvider from "@/providers/QueryProvider";
// Next.js App Router의 공통 레이아웃: 폰트, 전역 스타일, Provider를 설정합니다.
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
@@ -27,7 +29,7 @@ export default function RootLayout({
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
<QueryProvider>{children}</QueryProvider>
</body>
</html>
);

16
src/app/lotto/page.tsx Normal file
View File

@@ -0,0 +1,16 @@
import { LottoDashboard } from "@/features/lotto/components/LottoDashboard";
/**
* 로또 페이지 진입점입니다.
* Next.js App Router에서 `page.tsx`는 해당 라우트의 첫 화면을 담당합니다.
*/
export default function LottoPage() {
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-zinc-50 p-6">
<div className="w-full max-w-6xl">
{/* 페이지 컨테이너는 레이아웃 역할만 담당하고, 실제 UI는 LottoDashboard에 위임합니다. */}
<LottoDashboard />
</div>
</div>
);
}

View File

@@ -1,64 +1,42 @@
import Image from "next/image";
import { ExampleMenu } from "@/features/home/components/ExampleMenu";
import { Rocket } from "lucide-react";
// 홈 화면: 예제 메뉴와 코스 소개를 보여주는 랜딩 페이지입니다.
export default function Home() {
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
<div className="flex min-h-screen flex-col items-center justify-center bg-zinc-50 font-sans p-6">
<main className="w-full max-w-4xl space-y-12">
{/* 히어로 섹션 */}
<section className="text-center space-y-4">
<div className="inline-flex items-center gap-2 rounded-full bg-blue-50 px-4 py-1.5 text-sm font-semibold text-blue-600 ring-1 ring-inset ring-blue-700/10">
<Rocket className="h-4 w-4" />
<span>React/Next.js Master Course</span>
</div>
<h1 className="text-4xl font-extrabold tracking-tight text-zinc-900 sm:text-6xl">
<span className="text-blue-600"> </span>
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
<p className="text-lg text-zinc-600 max-w-2xl mx-auto">
!
<br className="hidden sm:block" />
.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</section>
{/* 예제 목록 섹션 */}
<section className="space-y-6">
<div className="flex items-center justify-between border-b border-zinc-200 pb-4">
<h2 className="text-2xl font-bold text-zinc-900"> </h2>
</div>
<ExampleMenu />
</section>
{/* 푸터 */}
<footer className="text-center pt-8 border-t border-zinc-200">
<p className="text-sm text-zinc-400">
© 2025 Anti-Gravity Coding Lab.
<br className="sm:hidden" /> .
</p>
</footer>
</main>
</div>
);

View File

@@ -0,0 +1,52 @@
import { User, Mail, Hash } from "lucide-react"; // 아이콘 위치를 실제 화면과 맞추기 위해 사용합니다.
import { Skeleton } from "@/components/ui/skeleton"; // Shadcn UI 스켈레톤 컴포넌트입니다.
/**
* UserDetailLoading
* @description 상세 페이지 로딩 중에 보여줄 스켈레톤 UI입니다.
*/
export default function UserDetailLoading() {
return (
<div className="max-w-xl mx-auto py-10 space-y-6">
{/* 상단 이동 영역도 자리만 보여줍니다. */}
<Skeleton className="h-5 w-24" />
{/* 상세 카드 스켈레톤: 실제 레이아웃과 비슷하게 구성합니다. */}
<div className="overflow-hidden rounded-2xl bg-white shadow-xl border border-zinc-100">
{/* 헤더 스켈레톤 */}
<div className="bg-linear-to-r from-blue-600 to-indigo-600 p-6 text-white">
<div className="flex items-center gap-4">
<div className="rounded-full bg-white/20 p-3 backdrop-blur-md">
<User className="h-8 w-8 text-white/70" />
</div>
<div className="space-y-2">
<Skeleton className="h-3 w-24 bg-white/20" />
<Skeleton className="h-6 w-40 bg-white/30" />
</div>
</div>
</div>
{/* 본문 스켈레톤 */}
<div className="p-6 space-y-4">
<div className="flex items-center gap-3 text-zinc-400">
<Hash className="h-5 w-5 text-blue-400" />
<Skeleton className="h-4 w-24" />
</div>
<div className="flex items-center gap-3 text-zinc-400">
<Mail className="h-5 w-5 text-blue-400" />
<Skeleton className="h-4 w-48" />
</div>
<div className="flex items-center gap-3 text-zinc-400">
<User className="h-5 w-5 text-blue-400" />
<Skeleton className="h-4 w-36" />
</div>
{/* 하단 버튼 자리 스켈레톤 */}
<div className="mt-6 border-t pt-6">
<Skeleton className="h-11 w-full rounded-xl bg-zinc-900/80" />
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,45 @@
import { UserDetail } from "@/features/react-query-demo/components/UserDetail";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { getQueryClient } from "@/lib/getQueryClient";
import { getUser } from "@/features/react-query-demo/api/userApi";
import { userKeys } from "@/features/react-query-demo/api/queryKeys";
interface PageProps {
params: Promise<{ id: string }>;
}
/**
* 유저 상세 정보를 보여주는 동적 라우트 페이지입니다.
* [고급 패턴] 특정 유저 데이터를 서버에서 프리패칭(Prefetching)합니다.
*/
export default async function UserDetailPage({ params }: PageProps) {
// 1. URL 파라미터에서 ID를 가져옵니다.
const { id } = await params;
const userId = Number(id);
// 2. 서버 전역 QueryClient를 가져와 프리패칭을 수행합니다.
const queryClient = getQueryClient();
await queryClient.prefetchQuery({
queryKey: userKeys.detail(userId),
queryFn: () => getUser(userId),
});
return (
<div className="max-w-xl mx-auto py-10 space-y-6">
<Link
href="/react-query"
className="inline-flex items-center gap-2 text-sm font-bold text-zinc-500 hover:text-zinc-900 transition-colors"
>
<ArrowLeft className="h-4 w-4" />
</Link>
{/* 3. 서버에서 가져온 데이터를 하이드레이션하여 전달합니다. */}
<HydrationBoundary state={dehydrate(queryClient)}>
<UserDetail userId={userId} />
</HydrationBoundary>
</div>
);
}

View File

@@ -0,0 +1,47 @@
import { Skeleton } from "@/components/ui/skeleton"; // Shadcn UI 스켈레톤 컴포넌트입니다.
/**
* ReactQueryListLoading
* @description 목록 페이지 전환 중 보여줄 스켈레톤 UI입니다.
*/
export default function ReactQueryListLoading() {
return (
<div className="space-y-10 pb-20 max-w-5xl mx-auto">
{/* 헤더 영역 스켈레톤 */}
<div className="flex flex-col sm:flex-row sm:items-end justify-between gap-6">
<div className="text-center sm:text-left">
<Skeleton className="h-7 w-48" />
<Skeleton className="mt-3 h-4 w-72" />
</div>
<Skeleton className="h-12 w-40 rounded-xl" />
</div>
{/* 목록 제목 영역 스켈레톤 */}
<div className="space-y-6">
<div className="flex items-center justify-between">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-5 w-28 rounded-full" />
</div>
{/* 카드 스켈레톤 */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, index) => (
<div
key={`list-skeleton-${index}`} // 스켈레톤 리스트를 식별하기 위한 키입니다.
className="flex flex-col rounded-xl border border-zinc-200 p-5 bg-white"
>
<div className="flex items-center gap-3 mb-4">
<Skeleton className="h-9 w-9 rounded-full" />
<Skeleton className="h-4 w-28" />
</div>
<div className="space-y-2">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-3 w-32" />
</div>
</div>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,22 @@
import { UserCreateForm } from "@/features/react-query-demo/components/UserCreateForm";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
/**
* 신규 유저를 등록하는 페이지입니다.
*/
export default function UserNewPage() {
return (
<div className="max-w-xl mx-auto py-10 space-y-6">
<Link
href="/react-query"
className="inline-flex items-center gap-2 text-sm font-bold text-zinc-500 hover:text-zinc-900 transition-colors"
>
<ArrowLeft className="h-4 w-4" />
</Link>
<UserCreateForm />
</div>
);
}

View File

@@ -0,0 +1,13 @@
import { UserDashboard } from "@/features/react-query-demo/components/UserDashboard";
/**
* React Query 데모 목록 페이지입니다.
* 기본 경로(`/react-query`)에서 유저 대시보드를 보여줍니다.
*/
export default function ReactQueryPage() {
return (
<div className="min-h-screen bg-zinc-50 px-6 py-12">
<UserDashboard />
</div>
);
}

32
src/app/zustand/page.tsx Normal file
View File

@@ -0,0 +1,32 @@
import { ZustandExample } from "@/features/zustand/components/ZustandExample";
import { ArrowLeft } from "lucide-react";
import Link from "next/link";
export default function ZustandPage() {
return (
<main className="min-h-screen bg-gray-50/50 p-6 md:p-12">
<div className="mx-auto max-w-4xl space-y-8">
<div className="flex items-center gap-4">
<Link
href="/"
className="flex h-10 w-10 items-center justify-center rounded-full bg-white shadow-sm ring-1 ring-zinc-200 transition-all hover:ring-zinc-300"
>
<ArrowLeft className="h-5 w-5 text-zinc-500" />
</Link>
<div>
<h1 className="text-3xl font-bold tracking-tight text-zinc-900">
Zustand
</h1>
<p className="text-zinc-500">
Zustand를 .
</p>
</div>
</div>
<section>
<ZustandExample />
</section>
</div>
</main>
);
}

View File

@@ -0,0 +1,36 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-1 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };

View File

@@ -0,0 +1,57 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@@ -0,0 +1,82 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-xl border border-border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
)
);
Card.displayName = "Card";
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn("text-2xl font-semibold leading-none tracking-tight", className)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
export {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardFooter,
};

View File

@@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View File

@@ -0,0 +1,111 @@
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/60 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border border-border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-2xl",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)}
{...props}
/>
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};

View File

@@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@@ -0,0 +1,117 @@
import * as React from "react";
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
<nav
role="navigation"
aria-label="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
);
Pagination.displayName = "Pagination";
const PaginationContent = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
));
PaginationContent.displayName = "PaginationContent";
const PaginationItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ className, ...props }, ref) => (
<li ref={ref} className={cn("", className)} {...props} />
));
PaginationItem.displayName = "PaginationItem";
type PaginationLinkProps = {
isActive?: boolean;
size?: "default" | "icon";
} & React.ComponentProps<"a">;
const PaginationLink = ({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) => (
<a
aria-current={isActive ? "page" : undefined}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className
)}
{...props}
/>
);
PaginationLink.displayName = "PaginationLink";
const PaginationPrevious = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 pl-2.5", className)}
{...props}
>
<ChevronLeft className="h-4 w-4" />
<span>Previous</span>
</PaginationLink>
);
PaginationPrevious.displayName = "PaginationPrevious";
const PaginationNext = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 pr-2.5", className)}
{...props}
>
<span>Next</span>
<ChevronRight className="h-4 w-4" />
</PaginationLink>
);
PaginationNext.displayName = "PaginationNext";
const PaginationEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
<span
aria-hidden
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More pages</span>
</span>
);
PaginationEllipsis.displayName = "PaginationEllipsis";
export {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
};

View File

@@ -0,0 +1,16 @@
import { cn } from "@/lib/utils"; // 클래스 병합 유틸리티입니다.
/**
* Skeleton
* @description 로딩 중인 자리 표시를 위한 Shadcn UI 스타일 컴포넌트입니다.
* @param className - 크기/모양을 지정하는 Tailwind 클래스입니다.
*/
function Skeleton({ className }: { className?: string }) {
return (
<div
className={cn("animate-pulse rounded-md bg-zinc-100", className)}
/>
);
}
export { Skeleton };

117
src/components/ui/table.tsx Normal file
View File

@@ -0,0 +1,117 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
));
Table.displayName = "Table";
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
));
TableHeader.displayName = "TableHeader";
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
));
TableBody.displayName = "TableBody";
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
));
TableFooter.displayName = "TableFooter";
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
));
TableRow.displayName = "TableRow";
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
));
TableHead.displayName = "TableHead";
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
));
TableCell.displayName = "TableCell";
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
));
TableCaption.displayName = "TableCaption";
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
};

View File

@@ -0,0 +1,78 @@
import Link from "next/link";
import { ChevronRight, Database, Rocket, Sparkles, Layers } from "lucide-react";
// 홈 화면에서 예제 카드 목록을 보여주는 메뉴 컴포넌트입니다.
interface ExampleItem {
title: string;
description: string;
href: string;
icon: React.ReactNode;
color: string;
}
const examples: ExampleItem[] = [
{
title: "TanStack Query",
description:
"서버 상태를 효율적으로 관리하고 캐싱/로딩 흐름을 학습합니다.",
href: "/react-query",
icon: <Database className="h-6 w-6" />,
color: "bg-blue-500",
},
{
title: "로또 번호 생성기",
description: "랜덤 추천과 번호 조회 테이블을 한눈에 확인합니다.",
href: "/lotto",
icon: <Sparkles className="h-6 w-6" />,
color: "bg-emerald-500",
},
{
title: "Zustand 상태 관리",
description: "Zustand를 이용한 간단하고 직관적인 전역 상태 관리를 경험해봅니다.",
href: "/zustand",
icon: <Layers className="h-6 w-6" />,
color: "bg-amber-500",
},
// 필요 시 이곳에 예제를 추가하세요.
];
export function ExampleMenu() {
return (
<div className="grid gap-4 sm:grid-cols-2">
{examples.map((item) => (
<Link
key={item.href}
href={item.href}
className="group relative overflow-hidden rounded-2xl border border-zinc-200 bg-white p-6 transition-all hover:shadow-lg hover:border-transparent active:scale-95"
>
<div className="flex items-start justify-between">
<div
className={`rounded-xl ${item.color} p-3 text-white shadow-lg shadow-blue-100`}
>
{item.icon}
</div>
<ChevronRight className="h-5 w-5 text-zinc-300 transition-transform group-hover:translate-x-1" />
</div>
<div className="mt-4">
<h3 className="text-lg font-bold text-zinc-900">{item.title}</h3>
<p className="mt-1 text-sm text-zinc-500 leading-relaxed">
{item.description}
</p>
</div>
<div className="absolute bottom-0 left-0 h-1 w-0 bg-blue-600 transition-all group-hover:w-full" />
</Link>
))}
<div className="rounded-2xl border border-dashed border-zinc-200 bg-zinc-50/50 p-6 flex flex-col items-center justify-center text-center">
<div className="rounded-xl bg-zinc-200 p-3 text-zinc-400">
<Rocket className="h-6 w-6" />
</div>
<h3 className="mt-4 text-sm font-medium text-zinc-400">
</h3>
</div>
</div>
);
}

View File

@@ -0,0 +1,80 @@
import { LottoDraw } from "../types/lotto";
/**
* 로또 API 응답의 공통 형태입니다.
* React Query에서 쓰기 좋은 형태로 정규화된 데이터를 담습니다.
*/
export interface LottoApiResponse {
/** 정규화된 로또 회차 목록입니다. */
items: LottoDraw[];
/** 전체 회차 수(페이지네이션 계산에 활용)입니다. */
totalCount: number;
/** 데이터 소스(외부 API 또는 mock 등) 표시용 값입니다. */
source: string;
}
// Next.js API Route를 프록시로 삼아 브라우저 CORS를 피합니다.
const API_BASE = "/api/lotto";
/**
* 로또 API 호출을 감싸는 공통 래퍼입니다.
* - 브라우저 CORS를 피하려고 Next.js route handler를 통해 호출합니다.
* - 에러 메시지를 정리해서 UI에서 일관되게 보여줍니다.
*/
async function fetchLotto(url: string): Promise<LottoApiResponse> {
// fetch는 기본적으로 캐시 정책을 따르므로, 필요하면 추후 옵션으로 제어할 수 있습니다.
const response = await fetch(url);
if (!response.ok) {
// 서버에서 내려준 에러 메시지가 있으면 그대로 노출합니다.
let message = "로또 데이터를 불러오지 못했습니다.";
try {
const body = (await response.json()) as { error?: string };
if (body?.error) {
message = body.error;
}
} catch {
// JSON 파싱 실패는 무시하고 기본 메시지를 유지합니다.
}
throw new Error(message);
}
return response.json();
}
/**
* 전체 회차 목록을 조회합니다.
* @returns {Promise<LottoApiResponse>} 전체 회차 응답입니다.
*/
export function getAllLottoDraws(): Promise<LottoApiResponse> {
// mode=all -> route.ts에서 srchLtEpsd=all로 변환됩니다.
return fetchLotto(`${API_BASE}?mode=all`);
}
/**
* 특정 회차 1건을 조회합니다.
* @param {number} round - 회차 번호입니다.
* @returns {Promise<LottoDraw | null>} 해당 회차가 없으면 null입니다.
*/
export async function getLottoDraw(round: number): Promise<LottoDraw | null> {
// 단일 회차는 리스트가 1개일 수 있으므로 첫 번째 항목만 반환합니다.
const data = await fetchLotto(`${API_BASE}?mode=single&round=${round}`);
return data.items[0] ?? null;
}
/**
* 회차 범위로 목록을 조회합니다.
* @param {number} start - 시작 회차입니다.
* @param {number} end - 종료 회차입니다.
* @returns {Promise<LottoDraw[]>} 범위에 해당하는 회차 목록입니다.
*/
export async function getLottoRange(
start: number,
end: number
): Promise<LottoDraw[]> {
// 범위 조회는 서버에서 리스트로 반환되므로 그대로 items를 전달합니다.
const data = await fetchLotto(
`${API_BASE}?mode=range&start=${start}&end=${end}`
);
return data.items;
}

View File

@@ -0,0 +1,274 @@
import { LottoDraw } from "../types/lotto";
// 외부 응답 포맷이 제각각이라 UI가 쓰는 단일 LottoDraw 형태로 정규화합니다.
// NUMBER_KEY_GROUPS는 "번호 6개"가 어떤 필드명으로 오는지 모아둔 테이블입니다.
const NUMBER_KEY_GROUPS = [
["drwtNo1", "drwtNo2", "drwtNo3", "drwtNo4", "drwtNo5", "drwtNo6"],
["winNo1", "winNo2", "winNo3", "winNo4", "winNo5", "winNo6"],
["no1", "no2", "no3", "no4", "no5", "no6"],
["tm1WnNo", "tm2WnNo", "tm3WnNo", "tm4WnNo", "tm5WnNo", "tm6WnNo"],
] as const;
// 회차 식별자 키 후보 목록입니다.
const ID_KEYS = ["drwNo", "drawNo", "round", "ltEpsd", "epsd", "id"] as const;
// 추첨일/등록일 등 날짜 키 후보 목록입니다.
const DATE_KEYS = [
"drwNoDate",
"drawDate",
"drwDate",
"winDate",
"date",
"createdAt",
"regDate",
"ltRflYmd",
] as const;
// 보너스 번호 키 후보 목록입니다.
const BONUS_KEYS = ["bnusNo", "bonusNo", "bonusNumber", "bnsWnNo"] as const;
/**
* 숫자/문자열을 안전하게 숫자로 변환합니다.
* @param {unknown} value - 변환할 값입니다.
* @returns {number | null} 변환 가능한 숫자면 number, 아니면 null입니다.
*/
function toNumber(value: unknown): number | null {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string") {
const trimmed = value.trim();
if (trimmed === "") {
return null;
}
const parsed = Number(trimmed);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
/**
* 후보 키 목록에서 첫 번째 유효 숫자를 찾습니다.
* @param {Record<string, unknown>} raw - 원본 객체입니다.
* @param {readonly string[]} keys - 탐색할 키 목록입니다.
* @returns {number | null} 찾은 숫자 또는 null입니다.
*/
function getFirstNumber(
raw: Record<string, unknown>,
keys: readonly string[]
): number | null {
for (const key of keys) {
const value = toNumber(raw[key]);
if (value !== null) {
return value;
}
}
return null;
}
/**
* 후보 키 목록에서 첫 번째 유효 문자열을 찾습니다.
* @param {Record<string, unknown>} raw - 원본 객체입니다.
* @param {readonly string[]} keys - 탐색할 키 목록입니다.
* @returns {string} 찾은 문자열 또는 빈 문자열입니다.
*/
function getFirstString(
raw: Record<string, unknown>,
keys: readonly string[]
): string {
for (const key of keys) {
const value = raw[key];
if (typeof value === "string" && value.trim() !== "") {
return value;
}
}
return "";
}
/**
* 날짜 문자열 포맷을 통일합니다.
* @param {string} value - 원본 날짜 문자열입니다.
* @returns {string} YYYY-MM-DD 형태로 정규화된 문자열입니다.
*/
function normalizeDate(value: string): string {
// YYYYMMDD 형식이면 YYYY-MM-DD로 변환해 표시를 통일합니다.
const trimmed = value.trim();
if (/^\d{8}$/.test(trimmed)) {
return `${trimmed.slice(0, 4)}-${trimmed.slice(4, 6)}-${trimmed.slice(6)}`;
}
return trimmed;
}
/**
* 다양한 형태의 번호 정보를 추출해 6개 번호 배열로 만듭니다.
* @param {Record<string, unknown>} raw - 원본 객체입니다.
* @returns {number[] | null} 6개 번호 배열 또는 null입니다.
*/
function extractNumbers(raw: Record<string, unknown>): number[] | null {
// 배열 기반 포맷: numbers가 이미 배열로 오는 경우.
const arrayCandidate = raw.numbers ?? raw.nums ?? raw.winNumbers;
if (Array.isArray(arrayCandidate)) {
const nums = arrayCandidate
.map((value) => toNumber(value))
.filter((value): value is number => value !== null);
if (nums.length >= 6) {
// 번호는 오름차순으로 정렬해 UI 표시를 통일합니다.
return nums.slice(0, 6).sort((a, b) => a - b);
}
}
// 개별 필드 포맷: tm1WnNo~tm6WnNo, drwtNo1~drwtNo6 등.
for (const keyGroup of NUMBER_KEY_GROUPS) {
const nums = keyGroup.map((key) => toNumber(raw[key]));
if (nums.every((value): value is number => value !== null)) {
// 개별 필드 포맷도 동일하게 정렬합니다.
return (nums as number[]).sort((a, b) => a - b);
}
}
// 문자열 포맷: "1, 2, 3, 4, 5, 6" 같은 형태.
if (typeof raw.winNums === "string") {
const nums = raw.winNums
.split(/[\s,]+/)
.map((value) => toNumber(value))
.filter((value): value is number => value !== null);
if (nums.length >= 6) {
return nums.slice(0, 6).sort((a, b) => a - b);
}
}
return null;
}
/**
* 단일 회차 객체를 LottoDraw로 정규화합니다.
* @param {Record<string, unknown>} raw - 원본 객체입니다.
* @returns {LottoDraw | null} 유효한 데이터면 LottoDraw, 아니면 null입니다.
*/
function normalizeDraw(raw: Record<string, unknown>): LottoDraw | null {
// 회차(id)와 번호 6개가 없으면 유효한 데이터로 보지 않습니다.
const id = getFirstNumber(raw, ID_KEYS);
const numbers = extractNumbers(raw);
if (id === null || !numbers) {
return null;
}
// 날짜/보너스는 optional이므로 없어도 통과합니다.
const createdAt = getFirstString(raw, DATE_KEYS);
const bonusNumber = getFirstNumber(raw, BONUS_KEYS);
return {
id,
numbers,
bonusNumber: bonusNumber ?? undefined,
createdAt: normalizeDate(createdAt),
};
}
/**
* 응답 객체에서 리스트 배열을 안전하게 찾아냅니다.
* @param {Record<string, unknown>} raw - 원본 객체입니다.
* @returns {unknown[] | null} 리스트 배열 또는 null입니다.
*/
function extractList(raw: Record<string, unknown>): unknown[] | null {
// 다양한 API에서 쓰는 리스트 컨테이너 키를 순차적으로 탐색합니다.
const listCandidates = [
raw.items,
raw.list,
raw.data,
raw.rows,
raw.result,
raw.lotto,
raw.lottoList,
];
for (const candidate of listCandidates) {
if (Array.isArray(candidate)) {
return candidate;
}
}
// dhlottery 포맷: { data: { list: [...] } }
if (raw.data && typeof raw.data === "object") {
const nested = raw.data as Record<string, unknown>;
if (Array.isArray(nested.list)) {
return nested.list;
}
}
// 일부 API는 { result: { list: [...] } } 형태를 사용합니다.
if (raw.result && typeof raw.result === "object") {
const nested = raw.result as Record<string, unknown>;
if (Array.isArray(nested.list)) {
return nested.list;
}
}
return null;
}
/**
* 다양한 로또 API 응답을 LottoDraw 배열로 정규화합니다.
* @param {unknown} payload - 원본 응답입니다.
* @returns {LottoDraw[]} 정규화된 회차 목록입니다.
*/
export function parseLottoPayload(payload: unknown): LottoDraw[] {
// payload가 배열이면 항목마다 normalize 합니다.
if (Array.isArray(payload)) {
return payload
.map((item) =>
typeof item === "object" && item !== null
? normalizeDraw(item as Record<string, unknown>)
: null
)
.filter((item): item is LottoDraw => item !== null)
// 최신 회차가 위로 오도록 id 내림차순 정렬합니다.
.sort((a, b) => b.id - a.id);
}
if (payload && typeof payload === "object") {
const raw = payload as Record<string, unknown>;
const list = extractList(raw);
// 내부에 리스트가 있으면 그 리스트를 정규화합니다.
if (list) {
return list
.map((item) =>
typeof item === "object" && item !== null
? normalizeDraw(item as Record<string, unknown>)
: null
)
.filter((item): item is LottoDraw => item !== null)
// 리스트가 중첩되어 있어도 정렬 규칙은 동일하게 유지합니다.
.sort((a, b) => b.id - a.id);
}
// 단일 객체 payload는 1개짜리 리스트로 변환합니다.
const single = normalizeDraw(raw);
return single ? [single] : [];
}
return [];
}
/**
* JSON 파싱을 안전하게 시도합니다.
* @param {string} text - JSON 문자열입니다.
* @returns {unknown | null} 파싱 성공 시 결과, 실패 시 null입니다.
*/
export function safeParseJson(text: string): unknown | null {
// JSON.parse 실패 시 문자열 안의 JSON 덩어리를 다시 시도합니다.
try {
return JSON.parse(text);
} catch {
const match = text.match(/(\{[\s\S]*\}|\[[\s\S]*\])/);
if (!match) {
return null;
}
try {
return JSON.parse(match[1]);
} catch {
return null;
}
}
}

View File

@@ -0,0 +1,24 @@
/**
* React Query queryKey를 중앙에서 관리합니다.
* 키를 일관되게 유지하면 캐시 공유와 무효화가 쉬워집니다.
*/
export const lottoKeys = {
// 모든 로또 관련 캐시의 최상위 네임스페이스입니다.
all: ["lotto"] as const,
/**
* 전체 목록 조회용 키입니다.
* 같은 리스트는 같은 캐시를 공유하도록 고정합니다.
*/
lists: () => [...lottoKeys.all, "list"] as const,
/**
* 단일 회차 조회용 키입니다.
* round가 바뀌면 다른 캐시로 분리됩니다.
*/
single: (round: number) => [...lottoKeys.all, "single", round] as const,
/**
* 범위 조회용 키입니다.
* start/end가 바뀌면 다른 캐시로 분리됩니다.
*/
range: (start: number, end: number) =>
[...lottoKeys.all, "range", start, end] as const,
};

View File

@@ -0,0 +1,130 @@
"use client";
import { useEffect } from "react";
import { Badge } from "@/components/ui/badge";
import { useLottoDraws } from "../hooks/useLottoDraws";
import { useLottoStore } from "../store/useLottoStore";
import { generateLottoNumbers } from "../utils/generateLottoNumbers";
import { LottoRecommendationsDialog } from "./LottoRecommendationsDialog";
import { LottoTable } from "./LottoTable";
const PAGE_SIZE = 10;
const RECOMMENDATION_COUNT = 10;
/**
* 로또 기능의 메인 대시보드입니다.
* 서버 데이터(React Query)와 UI 상태(Zustand)를 연결해 화면을 구성합니다.
*
* 데이터 흐름 요약:
* 1) Zustand: page/dialogOpen/recommendations/condition 상태를 단일 출처로 관리
* 2) React Query: page/pageSize를 기준으로 전체 회차 데이터를 가져옴
* 3) UI: LottoTable(목록) + LottoRecommendationsDialog(추천)로 상태 전달
*/
export function LottoDashboard() {
// Zustand store로 UI 상태를 중앙에서 관리해 컴포넌트 간 공유를 쉽게 합니다.
const page = useLottoStore((state) => state.page);
const setPage = useLottoStore((state) => state.setPage);
const dialogOpen = useLottoStore((state) => state.dialogOpen);
const setDialogOpen = useLottoStore((state) => state.setDialogOpen);
const recommendations = useLottoStore((state) => state.recommendations);
const setRecommendations = useLottoStore(
(state) => state.setRecommendations
);
// 조건 플래그 상태
const condition1 = useLottoStore((state) => state.condition1);
const condition2 = useLottoStore((state) => state.condition2);
const condition3 = useLottoStore((state) => state.condition3);
// React Query 훅으로 서버 데이터를 가져오고, 페이지네이션 정보를 계산합니다.
// 이 시점에서 items/totalCount/totalPages가 UI로 흘러갑니다.
const {
items,
recentDraws,
totalCount,
totalPages,
isLoading,
isError,
errorMessage,
} = useLottoDraws({
page,
pageSize: PAGE_SIZE,
});
// 번호 생성 로직은 utils 함수로 분리해 알고리즘을 쉽게 교체합니다.
const createRecommendations = () =>
Array.from({ length: RECOMMENDATION_COUNT }, () =>
generateLottoNumbers({
recentDraws,
useCondition1: condition1,
useCondition2: condition2,
useCondition3: condition3,
})
);
const handleOpenDialog = () => {
// 다이얼로그 열기 전에 추천 번호를 준비해 첫 화면을 즉시 채웁니다.
setRecommendations(createRecommendations());
setDialogOpen(true);
};
const handleRegenerate = () => {
// 추천 번호만 교체하여 기존 다이얼로그 상태는 유지합니다.
setRecommendations(createRecommendations());
};
// 페이지 이동은 범위를 벗어나지 않도록 clamp 처리합니다.
const handlePageChange = (nextPage: number) => {
const clamped = Math.min(Math.max(nextPage, 1), totalPages);
setPage(clamped);
};
// 데이터가 줄어든 경우 현재 페이지를 안전 범위로 맞춥니다.
useEffect(() => {
if (page > totalPages) {
setPage(totalPages);
}
}, [page, totalPages, setPage]);
return (
<section className="space-y-8 pb-16">
{/* 1) 헤더: 현재 기능의 목적과 화면 설명 */}
<div className="space-y-3 text-center sm:text-left">
<Badge
variant="secondary"
className="mx-auto w-fit bg-emerald-50 text-emerald-700 sm:mx-0"
>
Lotto Lab
</Badge>
<h1 className="text-3xl font-extrabold tracking-tight text-foreground sm:text-4xl">
</h1>
<p className="text-sm text-muted-foreground sm:text-base">
, .
</p>
</div>
{/* 2) 목록 영역: React Query 결과 + 페이지네이션 상태를 전달 */}
<LottoTable
draws={items}
totalCount={totalCount}
page={page}
totalPages={totalPages}
onPageChange={handlePageChange}
onOpenGenerator={handleOpenDialog}
status={isLoading ? "loading" : isError ? "error" : "ready"}
errorMessage={errorMessage}
/>
{/* 3) 다이얼로그: Zustand 상태를 controlled 방식으로 연결 */}
{/* shadcn/ui Dialog는 controlled(open/onOpenChange) 방식으로 상태를 연결합니다. */}
<LottoRecommendationsDialog
open={dialogOpen}
recommendations={recommendations}
onOpenChange={setDialogOpen}
onRegenerate={handleRegenerate}
/>
</section>
);
}

View File

@@ -0,0 +1,182 @@
"use client";
import { useDialogDragResize } from "@/lib/useDialogDragResize";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogClose,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import { Sparkles } from "lucide-react";
import { useLottoStore } from "../store/useLottoStore";
/**
* 추천 번호 다이얼로그에 전달되는 Props입니다.
* @property {boolean} open - 다이얼로그 열림 상태입니다.
* @property {number[][]} recommendations - 추천 번호 목록입니다.
* @property {(open: boolean) => void} onOpenChange - 열림 상태 변경 콜백입니다.
* @property {() => void} onRegenerate - 추천 번호 재생성 콜백입니다.
*/
interface LottoRecommendationsDialogProps {
open: boolean;
recommendations: number[][];
onOpenChange: (open: boolean) => void;
onRegenerate: () => void;
}
/**
* 추천 번호 목록을 모달로 보여주는 컴포넌트입니다.
* shadcn/ui Dialog를 사용해 접근성과 상태 제어를 함께 가져갑니다.
*/
export function LottoRecommendationsDialog({
open,
recommendations,
onOpenChange,
onRegenerate,
}: LottoRecommendationsDialogProps) {
// 공용 훅으로 드래그/리사이즈 로직을 연결합니다.
const {
contentStyle,
contentClassName,
dragHandleProps,
dragHandleClassName,
resizeHandleProps,
resizeHandleClassName,
isResizable,
} = useDialogDragResize({
open,
draggable: true,
resizable: true,
});
// Zustand store에서 조건 플래그 상태를 가져옵니다.
const condition1 = useLottoStore((state) => state.condition1);
const condition2 = useLottoStore((state) => state.condition2);
const condition3 = useLottoStore((state) => state.condition3);
const setCondition1 = useLottoStore((state) => state.setCondition1);
const setCondition2 = useLottoStore((state) => state.setCondition2);
const setCondition3 = useLottoStore((state) => state.setCondition3);
return (
// shadcn/ui Dialog는 Radix 기반이므로 open/onOpenChange로 상태를 제어합니다.
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className={cn("flex flex-col gap-0 p-0 overflow-hidden", contentClassName)}
style={contentStyle}
>
<DialogHeader
className={cn("px-6 py-4 border-b shrink-0", dragHandleClassName)}
{...dragHandleProps}
>
<Badge
variant="secondary"
className="w-fit gap-2 bg-emerald-50 text-emerald-700"
>
<Sparkles className="h-4 w-4" />
10
</Badge>
<DialogTitle className="text-2xl"> </DialogTitle>
<DialogDescription>
10 .
</DialogDescription>
</DialogHeader>
{/* 조건 토글 체크박스 영역 */}
<div className="flex flex-wrap items-center gap-4 px-6 py-3 border-b bg-muted/30 shrink-0">
<div className="flex items-center gap-2">
<Checkbox
id="condition1"
checked={condition1}
onCheckedChange={(checked) => setCondition1(checked === true)}
/>
<Label htmlFor="condition1" className="text-sm cursor-pointer">
1: 최근
</Label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="condition2"
checked={condition2}
onCheckedChange={(checked) => setCondition2(checked === true)}
/>
<Label htmlFor="condition2" className="text-sm cursor-pointer">
2: 보너스
</Label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="condition3"
checked={condition3}
onCheckedChange={(checked) => setCondition3(checked === true)}
/>
<Label htmlFor="condition3" className="text-sm cursor-pointer">
3: Hot & Due
</Label>
</div>
</div>
<div className="flex-1 overflow-y-auto px-6 py-4">
<div className="grid gap-3">
{recommendations.map((numbers, index) => (
<div
key={`${numbers.join("-")}-${index}`}
className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-border bg-muted/40 px-4 py-3"
>
<Badge variant="outline" className="text-xs font-semibold">
{index + 1}
</Badge>
<div className="flex flex-wrap items-center gap-2">
{numbers.map((value) => (
<Badge
key={value}
variant="outline"
className="border-emerald-200 bg-emerald-50 text-emerald-700"
>
{value}
</Badge>
))}
</div>
</div>
))}
</div>
</div>
<DialogFooter className="px-6 py-4 border-t shrink-0 gap-2 sm:gap-3">
<Button
type="button"
variant="outline"
className="border-emerald-200 bg-emerald-50 text-emerald-700 hover:bg-emerald-100"
onClick={onRegenerate}
>
</Button>
<DialogClose asChild>
<Button type="button"></Button>
</DialogClose>
</DialogFooter>
{/* 모서리 핸들로 크기 조절을 제공합니다. */}
{isResizable && (
<div
role="presentation"
aria-label="다이얼로그 크기 조절"
className={cn(
"absolute bottom-3 right-3 h-4 w-4 rounded-sm border border-emerald-200 bg-emerald-50",
resizeHandleClassName
)}
{...resizeHandleProps}
/>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,285 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { cn } from "@/lib/utils";
import { Sparkles } from "lucide-react";
import { LottoDraw } from "../types/lotto";
/**
* LottoTable 컴포넌트에서 사용하는 Props 인터페이스입니다.
*
* @property {LottoDraw[]} draws - 화면에 표시할 로또 회차 데이터 배열입니다.
* @property {number} totalCount - 전체 데이터의 개수입니다 (CardHeader에 표시).
* @property {number} page - 현재 활성화된 페이지 번호입니다.
* @property {number} totalPages - 전체 페이지 수입니다 (Pagination 계산에 사용).
* @property {(page: number) => void} onPageChange - 페이지가 변경될 때 호출되는 콜백 함수입니다.
* @property {() => void} onOpenGenerator - '로또 번호 생성' 버튼 클릭 시 다이어로그를 열기 위한 콜백입니다.
* @property {"loading" | "error" | "ready"} status - 현재 데이터의 상태입니다 (React Query의 상태와 연동).
* @property {string} [errorMessage] - status가 'error' 인 경우 표시할 메시지입니다.
*/
interface LottoTableProps {
draws: LottoDraw[];
totalCount: number;
page: number;
totalPages: number;
onPageChange: (page: number) => void;
onOpenGenerator: () => void;
status: "loading" | "error" | "ready";
errorMessage?: string;
}
type PaginationItemType = number | "ellipsis";
/**
* 페이지네이션에 표시할 숫자 리스트를 계산하는 헬퍼 함수입니다.
* 페이지가 너무 많을 경우(7개 초과) 생략 기호(...)를 포함하여 표시합니다.
*
* @param {number} currentPage - 현재 페이지 번호
* @param {number} pageCount - 전체 페이지 수
* @returns {PaginationItemType[]} 페이지 번호와 'ellipsis' 문자열이 섞인 배열
*/
function getPaginationItems(
currentPage: number,
pageCount: number
): PaginationItemType[] {
// 전체 페이지가 7개 이하라면 모든 페이지 번호를 그대로 보여줍니다.
if (pageCount <= 7) {
return Array.from({ length: pageCount }, (_, index) => index + 1);
}
// 현재 페이지가 앞쪽(1~3)인 경우: [1, 2, 3, 4, ..., 마지막]
if (currentPage <= 3) {
return [1, 2, 3, 4, "ellipsis", pageCount];
}
// 현재 페이지가 뒤쪽인 경우: [1, ..., 뒤에서 4번째부터 마지막까지]
if (currentPage >= pageCount - 2) {
return [1, "ellipsis", pageCount - 3, pageCount - 2, pageCount - 1, pageCount];
}
// 현재 페이지가 중간인 경우: [1, ..., 전, 현재, 후, ..., 마지막]
return [
1,
"ellipsis",
currentPage - 1,
currentPage,
currentPage + 1,
"ellipsis",
pageCount,
];
}
/**
* 로또 당첨 목록을 테이블 형태로 보여주는 클라이언트 컴포넌트입니다.
* Shadcn UI의 Table, Card, Pagination 컴포넌트를 사용하여 구현되었습니다.
*
* 데이터 흐름 요약:
* 1) 부모가 React Query 결과(draws/totalCount/totalPages/status)를 전달
* 2) 내부에서 페이지네이션 숫자/비활성 상태를 계산
* 3) 상태에 따라 로딩/에러/빈 데이터/정상 목록을 분기 렌더링
*/
export function LottoTable({
draws,
totalCount,
page,
totalPages,
onPageChange,
onOpenGenerator,
status,
errorMessage,
}: LottoTableProps) {
// 페이지 상태에 따라 버튼/링크를 비활성화합니다.
const pages = getPaginationItems(page, totalPages);
const isPagingDisabled = status !== "ready";
const isPrevDisabled = isPagingDisabled || page <= 1;
const isNextDisabled = isPagingDisabled || page >= totalPages;
return (
<Card>
{/* 1) 헤더: 전체 개수와 CTA(추천 생성) */}
<CardHeader className="flex flex-col gap-4 border-b border-border sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle> </CardTitle>
<CardDescription> {totalCount}</CardDescription>
</div>
{/* CTA는 부모 컴포넌트에서 다이어로그를 여는 트리거입니다. */}
<Button type="button" onClick={onOpenGenerator} className="gap-2">
<Sparkles className="h-4 w-4" />
</Button>
</CardHeader>
<CardContent className="pt-6">
{/* 테이블은 표시만 담당하고, 데이터는 부모에서 React Query로 전달받습니다. */}
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[100px]"></TableHead>
<TableHead></TableHead>
<TableHead className="w-[120px] text-right"></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{/* 2) 상태 분기: 로딩 → 에러 → 빈 데이터 → 정상 목록 순서로 렌더링 */}
{status === "loading" ? (
<TableRow>
<TableCell
colSpan={4}
className="text-center text-muted-foreground"
>
...
</TableCell>
</TableRow>
) : status === "error" ? (
<TableRow>
<TableCell colSpan={4} className="text-center text-destructive">
{errorMessage ?? "데이터를 불러오지 못했습니다."}
</TableCell>
</TableRow>
) : draws.length === 0 ? (
<TableRow>
<TableCell
colSpan={4}
className="text-center text-muted-foreground"
>
.
</TableCell>
</TableRow>
) : (
// 정상 상태: 회차별로 번호/보너스/추첨일을 렌더링합니다.
draws.map((draw) => (
<TableRow key={draw.id}>
<TableCell className="font-semibold">{draw.id}</TableCell>
<TableCell>
<div className="flex flex-wrap items-center gap-2">
{draw.numbers.map((value) => (
<Badge
key={value}
variant="outline"
className="border-blue-100 bg-blue-50 text-blue-700"
>
{value}
</Badge>
))}
</div>
</TableCell>
<TableCell className="text-right">
{draw.bonusNumber ? (
<Badge
variant="outline"
className="border-amber-200 bg-amber-50 text-amber-700"
>
{draw.bonusNumber}
</Badge>
) : (
// 보너스 번호가 없는 회차는 "-"로 표시합니다.
<span className="text-xs text-muted-foreground">-</span>
)}
</TableCell>
<TableCell className="text-right text-xs text-muted-foreground">
{draw.createdAt}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
<CardFooter className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
{/* 3) 페이지 요약: 현재/전체 페이지를 간단히 안내 */}
<p className="text-xs text-muted-foreground">
{page} / {totalPages}
</p>
{/* Pagination은 링크로 렌더링되지만 클릭은 버튼처럼 처리합니다. */}
<Pagination className="sm:justify-end">
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href="#"
aria-disabled={isPrevDisabled}
className={cn(isPrevDisabled && "pointer-events-none opacity-50")}
onClick={(event) => {
event.preventDefault();
// 링크 기본 동작을 막고, 상태 기반 페이지 전환만 허용합니다.
if (!isPrevDisabled) {
onPageChange(page - 1);
}
}}
/>
</PaginationItem>
{/* 페이지 숫자 배열을 순회하며 숫자/ellipsis를 구분 렌더링합니다. */}
{pages.map((pageItem, index) =>
pageItem === "ellipsis" ? (
<PaginationItem key={`ellipsis-${index}`}>
<PaginationEllipsis />
</PaginationItem>
) : (
<PaginationItem key={pageItem}>
<PaginationLink
href="#"
isActive={pageItem === page}
className={cn(
isPagingDisabled && "pointer-events-none opacity-50"
)}
onClick={(event) => {
event.preventDefault();
// 비활성 상태에서는 페이지 이동을 막습니다.
if (!isPagingDisabled) {
// 숫자 페이지 클릭 → 해당 번호로 이동합니다.
onPageChange(pageItem);
}
}}
>
{pageItem}
</PaginationLink>
</PaginationItem>
)
)}
<PaginationItem>
<PaginationNext
href="#"
aria-disabled={isNextDisabled}
className={cn(isNextDisabled && "pointer-events-none opacity-50")}
onClick={(event) => {
event.preventDefault();
// 마지막 페이지를 넘어가지 않도록 가드합니다.
if (!isNextDisabled) {
onPageChange(page + 1);
}
}}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</CardFooter>
</Card>
);
}

View File

@@ -0,0 +1,86 @@
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { getAllLottoDraws } from "../api/lottoApi";
import { lottoKeys } from "../api/queryKeys";
import { LottoDraw } from "../types/lotto";
/**
* 로또 회차 조회 훅에 전달하는 옵션입니다.
* @property {number} page - 현재 페이지 번호입니다.
* @property {number} pageSize - 한 페이지에 보여줄 개수입니다.
*/
interface UseLottoDrawsOptions {
page: number;
pageSize: number;
}
/**
* 로또 회차 조회 훅이 반환하는 결과 타입입니다.
* @property {LottoDraw[]} items - 현재 페이지에 보여줄 회차 목록입니다.
* @property {LottoDraw[]} recentDraws - 최신 3회차 목록(추천 로직에 사용)입니다.
* @property {number} totalCount - 전체 회차 개수입니다.
* @property {number} totalPages - 전체 페이지 수입니다.
* @property {boolean} isLoading - 로딩 상태 여부입니다.
* @property {boolean} isError - 에러 상태 여부입니다.
* @property {string} [errorMessage] - 에러 메시지(있을 경우)입니다.
*/
interface UseLottoDrawsResult {
items: LottoDraw[];
recentDraws: LottoDraw[];
totalCount: number;
totalPages: number;
isLoading: boolean;
isError: boolean;
errorMessage?: string;
}
/**
* 로또 회차 데이터를 조회하고 페이지네이션 정보를 계산하는 커스텀 훅입니다.
* React Query 캐시를 사용해 서버 호출을 줄이고, 클라이언트에서 페이지를 나눠줍니다.
*/
export function useLottoDraws({
page,
pageSize,
}: UseLottoDrawsOptions): UseLottoDrawsResult {
// queryKey는 "lotto/list"로 고정하여 동일 리스트 캐시를 재사용합니다.
// queryKey가 바뀌면 React Query는 다른 데이터로 인식해 새로 요청/캐시합니다.
const { data, isLoading, isError, error } = useQuery({
queryKey: lottoKeys.lists(),
// queryFn은 실제 네트워크 요청을 담당하며, 결과는 캐시에 저장됩니다.
queryFn: getAllLottoDraws,
// staleTime 동안은 "신선한 데이터"로 간주해 불필요한 재요청을 줄입니다.
staleTime: 1000 * 60 * 5,
});
// 데이터가 아직 없을 수 있으니 nullish coalescing으로 안전한 기본값을 둡니다.
const totalCount = data?.totalCount ?? 0;
// 페이지 수는 최소 1로 유지해 UI 분기가 단순해집니다.
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
const recentDraws = useMemo(() => {
// useMemo로 최신 3회차 계산을 캐싱해, items가 바뀔 때만 다시 계산되게 합니다.
// data?.items가 바뀌면 다시 실행되어 최신 회차 기준이 유지됩니다.
const allItems = data?.items ?? [];
return allItems.slice(0, 3);
}, [data?.items]);
const items = useMemo(() => {
// useMemo로 페이지 슬라이스를 캐싱해, page/pageSize/items가 바뀔 때만 다시 계산합니다.
// 의존성(page/pageSize/data?.items)이 변하면 재계산되어 페이지 이동이 반영됩니다.
if (!data?.items) {
return [];
}
const startIndex = (page - 1) * pageSize;
return data.items.slice(startIndex, startIndex + pageSize);
}, [data?.items, page, pageSize]);
return {
items,
recentDraws,
totalCount,
totalPages,
isLoading,
isError,
errorMessage: error instanceof Error ? error.message : undefined,
};
}

View File

@@ -0,0 +1,55 @@
import { create } from "zustand";
/**
* 로또 페이지에서 공유하는 UI 상태 타입입니다.
* 상태를 원자적으로(Atomic) 나누면 필요한 컴포넌트만 선택적으로 구독할 수 있습니다.
*/
interface LottoState {
/** 현재 페이지 번호(페이지네이션 기준)입니다. */
page: number;
/** 추천 번호 다이얼로그의 열림/닫힘 상태입니다. */
dialogOpen: boolean;
/** 추천 번호 목록(10줄 등)을 저장합니다. */
recommendations: number[][];
/** 조건 1: 최근 N회차 메인 번호 제외 */
condition1: boolean;
/** 조건 2: 최근 N회차 보너스 번호 제외 */
condition2: boolean;
/** 조건 3: Hot & Due 알고리즘 적용 */
condition3: boolean;
/** 현재 페이지를 갱신합니다. */
setPage: (page: number) => void;
/** 다이얼로그 열림 상태를 갱신합니다. */
setDialogOpen: (open: boolean) => void;
/** 추천 번호 목록을 통째로 교체합니다. */
setRecommendations: (recommendations: number[][]) => void;
/** 조건 1 활성화 여부를 갱신합니다. */
setCondition1: (value: boolean) => void;
/** 조건 2 활성화 여부를 갱신합니다. */
setCondition2: (value: boolean) => void;
/** 조건 3 활성화 여부를 갱신합니다. */
setCondition3: (value: boolean) => void;
}
/**
* 로또 UI 상태를 전역 스토어로 관리합니다.
* - 페이지, 다이얼로그, 추천번호, 조건 플래그를 분리해 컴포넌트 간 데이터 흐름을 단순화합니다.
*/
export const useLottoStore = create<LottoState>((set) => ({
// 초기 페이지는 1로 고정합니다.
page: 1,
// 다이얼로그는 기본 닫힘 상태로 시작합니다.
dialogOpen: false,
// 추천 번호는 다이얼로그 열기 시점에 채웁니다.
recommendations: [],
// 조건 플래그는 기본 활성화로 두고, UI에서 토글하도록 합니다.
condition1: true,
condition2: true,
condition3: true,
setPage: (page) => set({ page }),
setDialogOpen: (open) => set({ dialogOpen: open }),
setRecommendations: (recommendations) => set({ recommendations }),
setCondition1: (value) => set({ condition1: value }),
setCondition2: (value) => set({ condition2: value }),
setCondition3: (value) => set({ condition3: value }),
}));

View File

@@ -0,0 +1,14 @@
/**
* 로또 회차 데이터의 공통 타입입니다.
* UI와 API가 동일한 형태로 데이터를 다루도록 기준을 잡습니다.
*/
export interface LottoDraw {
/** 회차 번호(예: 1112회)입니다. */
id: number;
/** 당첨 번호 6개입니다. */
numbers: number[];
/** 보너스 번호는 존재할 때만 내려오므로 optional입니다. */
bonusNumber?: number;
/** 추첨일(YYYY-MM-DD) 문자열입니다. */
createdAt: string;
}

View File

@@ -0,0 +1,31 @@
import { LottoDraw } from "../types/lotto";
import { generateLottoNumbers } from "./generateLottoNumbers";
const DAY_IN_MS = 24 * 60 * 60 * 1000;
/**
* 데모용 로또 회차 데이터를 생성합니다.
* @param {number} count - 만들 회차 개수입니다.
* @returns {LottoDraw[]} 최근 날짜부터 역순으로 정렬된 회차 목록입니다.
*/
export function createMockLottoDraws(count: number): LottoDraw[] {
// 기준 시각을 고정해 날짜 계산을 일관되게 만듭니다.
const now = Date.now();
// 데모에서 "최근 회차"처럼 보이도록 날짜를 하루 단위로 줄입니다.
return Array.from({ length: count }, (_, index) => {
// id는 최신 회차가 큰 숫자처럼 보이도록 역순으로 부여합니다.
const id = count - index;
// ISO 문자열에서 YYYY-MM-DD만 잘라 UI에 맞는 형식을 맞춥니다.
const createdAt = new Date(now - index * DAY_IN_MS)
.toISOString()
.slice(0, 10);
return {
id,
// 기본 옵션(45개 중 6개)으로 랜덤 번호를 생성합니다.
numbers: generateLottoNumbers(),
createdAt,
};
});
}

View File

@@ -0,0 +1,364 @@
import { LottoDraw } from "../types/lotto";
// ============================================================================
// 타입 정의 (Type Definitions)
// ============================================================================
/**
* 번호 생성 시 사용할 옵션입니다.
* @property {number} [maxNumber] - 번호의 최대값(기본 45)입니다.
* @property {number} [count] - 생성할 번호 개수(기본 6)입니다.
* @property {LottoDraw[]} [recentDraws] - 최근 회차 데이터입니다.
* @property {number} [excludeRecentDrawsCount] - 제외할 최근 회차 개수(기본 3)입니다.
* @property {number} [wHot] - Hot(자주 나온 번호) 가중치(기본 0.6)입니다.
* @property {number} [wDue] - Due(오래 안 나온 번호) 가중치(기본 0.4)입니다.
*/
interface GenerateLottoOptions {
maxNumber?: number;
count?: number;
recentDraws?: LottoDraw[];
excludeRecentDrawsCount?: number;
wHot?: number;
wDue?: number;
/** 조건 1: 최근 N회차 메인 번호 제외 (기본 true) */
useCondition1?: boolean;
/** 조건 2: 최근 N회차 보너스 번호 제외 (기본 true) */
useCondition2?: boolean;
/** 조건 3: Hot & Due 알고리즘 적용 (기본 true) */
useCondition3?: boolean;
}
/**
* 번호별 점수를 저장하는 타입입니다.
*/
interface NumberScore {
num: number;
score: number;
freq: number;
gap: number;
}
// ============================================================================
// 조건 1 & 2: 최근 회차 번호 제외 (Exclusion Rules)
// ============================================================================
/**
* 최근 회차의 메인 번호를 수집합니다.
* @param {LottoDraw[]} recentDraws - 최근 회차 목록입니다.
* @param {number} excludeCount - 제외할 회차 수입니다.
* @returns {Set<number>} 제외할 메인 번호 집합입니다.
*/
function collectRecentDrawNumbers(
recentDraws: LottoDraw[],
excludeCount: number
): Set<number> {
const excluded = new Set<number>();
recentDraws.slice(0, excludeCount).forEach((draw) => {
draw.numbers.forEach((value) => excluded.add(value));
});
return excluded;
}
/**
* 최근 회차의 보너스 번호를 수집합니다.
* @param {LottoDraw[]} recentDraws - 최근 회차 목록입니다.
* @param {number} excludeCount - 제외할 회차 수입니다.
* @returns {Set<number>} 제외할 보너스 번호 집합입니다.
*/
function collectRecentBonusNumbers(
recentDraws: LottoDraw[],
excludeCount: number
): Set<number> {
const excluded = new Set<number>();
recentDraws.slice(0, excludeCount).forEach((draw) => {
if (draw.bonusNumber) {
excluded.add(draw.bonusNumber);
}
});
return excluded;
}
/**
* 최근 회차 번호 제외 규칙입니다.
*/
function isExcludedByRecentDraws(
value: number,
excludedNumbers: Set<number>
): boolean {
return excludedNumbers.has(value);
}
/**
* 최근 회차 보너스 번호 제외 규칙입니다.
*/
function isExcludedByRecentBonusNumbers(
value: number,
excludedBonusNumbers: Set<number>
): boolean {
return excludedBonusNumbers.has(value);
}
/**
* 제외 규칙에 맞는 후보 번호 목록을 만듭니다.
*/
function buildCandidateNumbers(
maxNumber: number,
isExcluded: (value: number) => boolean
): number[] {
const candidates: number[] = [];
for (let value = 1; value <= maxNumber; value += 1) {
if (!isExcluded(value)) {
candidates.push(value);
}
}
return candidates;
}
// ============================================================================
// 조건 3: Hot & Due 알고리즘 (Weighted Sampling)
// ----------------------------------------------------------------------------
// "Hot & Due"는 통계 기반 번호 선정 전략입니다.
//
// - Hot (뜨거운 번호): 최근에 자주 나온 번호는 "핫"하다고 봅니다.
// → 이런 번호는 계속 나올 확률이 높다고 가정합니다.
//
// - Due (오래된 번호): 오랫동안 안 나온 번호는 "곧 나올 차례"라고 봅니다.
// → 이런 번호는 조만간 나올 확률이 높다고 가정합니다.
//
// 이 두 가지 점수를 wHot(기본 0.6)과 wDue(기본 0.4) 비율로 섞어서
// 각 번호에 "선택 확률(가중치)"을 부여합니다.
// 가중치가 높을수록 뽑힐 확률이 높아지는 방식입니다.
//
// 예) 번호 7이 자주 나왔고(Hot 점수 높음) + 최근에도 나왔다면(Due 점수 낮음)
// → 전체 점수는 "중간 정도"가 됩니다.
// 예) 번호 42가 자주 나왔고(Hot 점수 높음) + 오래 안 나왔다면(Due 점수 높음)
// → 전체 점수가 "매우 높아져서" 뽑힐 확률이 커집니다.
// ============================================================================
/**
* 후보 번호 목록에 대해 출현 빈도(freq)와 미출현 기간(gap)을 계산합니다.
*
* @param {number[]} candidates - 후보 번호 목록입니다.
* @param {LottoDraw[]} history - 과거 회차 전체 데이터입니다.
* @returns {{ counts: Map<number, number>, gaps: Map<number, number> }} 통계 결과입니다.
*/
function calculateNumberStats(
candidates: number[],
history: LottoDraw[]
): { counts: Map<number, number>; gaps: Map<number, number> } {
// 회차를 id 오름차순으로 정렬 (인덱스 = 시간순)
const sortedHistory = [...history].sort((a, b) => a.id - b.id);
const drawCount = sortedHistory.length;
const counts = new Map<number, number>();
const lastSeen = new Map<number, number>();
// 후보 번호들의 초기값 설정
candidates.forEach((num) => {
counts.set(num, 0);
lastSeen.set(num, -1); // -1은 "한 번도 안 나옴"을 의미
});
// 각 회차를 순회하며 통계 집계
sortedHistory.forEach((draw, idx) => {
draw.numbers.forEach((num) => {
if (counts.has(num)) {
counts.set(num, (counts.get(num) ?? 0) + 1);
lastSeen.set(num, idx);
}
});
});
// 미출현 기간(gap) 계산: 마지막으로 나온 이후 몇 회차가 지났는지
const gaps = new Map<number, number>();
candidates.forEach((num) => {
const last = lastSeen.get(num) ?? -1;
if (last === -1) {
// 한 번도 안 나온 번호는 gap을 최대치로 설정
gaps.set(num, drawCount);
} else {
gaps.set(num, drawCount - 1 - last);
}
});
return { counts, gaps };
}
/**
* 후보 번호 목록에 대해 Hot & Due 점수를 계산합니다.
*
* @param {number[]} candidates - 후보 번호 목록입니다.
* @param {Map<number, number>} counts - 번호별 출현 횟수입니다.
* @param {Map<number, number>} gaps - 번호별 미출현 기간입니다.
* @param {number} wHot - Hot 가중치입니다 (0~1).
* @param {number} wDue - Due 가중치입니다 (0~1).
* @returns {NumberScore[]} 번호별 점수 배열입니다.
*/
function calculateScores(
candidates: number[],
counts: Map<number, number>,
gaps: Map<number, number>,
wHot: number,
wDue: number
): NumberScore[] {
// 정규화를 위한 최대값 계산
let maxCount = 0;
let maxGap = 0;
candidates.forEach((num) => {
const c = counts.get(num) ?? 0;
const g = gaps.get(num) ?? 0;
if (c > maxCount) maxCount = c;
if (g > maxGap) maxGap = g;
});
// 방어 코드: 0으로 나누는 것 방지
if (maxCount === 0) maxCount = 1;
if (maxGap === 0) maxGap = 1;
// 점수 계산
const scores: NumberScore[] = candidates.map((num) => {
const freq = counts.get(num) ?? 0;
const gap = gaps.get(num) ?? 0;
// 정규화 (0~1 범위)
const freqNorm = freq / maxCount;
const gapNorm = gap / maxGap;
// Hot & Due 혼합 점수
const rawScore = wHot * freqNorm + wDue * gapNorm;
// 0이 되면 선택에서 완전히 배제되므로 아주 작은 값으로 보정
const score = rawScore <= 0 ? 1e-6 : rawScore;
return { num, score, freq, gap };
});
return scores;
}
/**
* 가중치에 비례하여 k개의 번호를 중복 없이 뽑습니다.
*
* @param {NumberScore[]} scoreList - 번호별 점수 배열입니다.
* @param {number} k - 뽑을 개수입니다.
* @returns {number[]} 선택된 번호 배열입니다.
*/
function weightedSampleWithoutReplacement(
scoreList: NumberScore[],
k: number
): number[] {
const available = [...scoreList];
const result: number[] = [];
for (let i = 0; i < k; i++) {
if (available.length === 0) break;
// 전체 가중치 합계 계산
const totalWeight = available.reduce((sum, item) => sum + item.score, 0);
let r = Math.random() * totalWeight;
// 가중치 기반으로 하나 선택
let chosenIndex = 0;
for (let idx = 0; idx < available.length; idx++) {
r -= available[idx].score;
if (r <= 0) {
chosenIndex = idx;
break;
}
}
const chosen = available[chosenIndex];
result.push(chosen.num);
available.splice(chosenIndex, 1); // 중복 방지를 위해 제거
}
return result;
}
// ============================================================================
// 메인 함수 (Main Function)
// ============================================================================
/**
* 로또 번호를 랜덤으로 생성합니다.
* @param {number} maxNumber - 번호의 최대값(기본 45)입니다.
* @param {number} count - 생성할 번호 개수(기본 6)입니다.
* @returns {number[]} 오름차순으로 정렬된 로또 번호 배열입니다.
*/
export function generateLottoNumbers(maxNumber?: number, count?: number): number[];
/**
* 로또 번호를 랜덤으로 생성합니다.
* @param {GenerateLottoOptions} options - 생성 옵션입니다.
* @returns {number[]} 오름차순으로 정렬된 로또 번호 배열입니다.
*/
export function generateLottoNumbers(options?: GenerateLottoOptions): number[];
export function generateLottoNumbers(
arg1: number | GenerateLottoOptions = 45,
arg2 = 6
): number[] {
// 기존 시그니처(숫자 인자)와 옵션 객체 방식을 모두 지원합니다.
const baseOptions: GenerateLottoOptions =
typeof arg1 === "number"
? { maxNumber: arg1, count: arg2 }
: arg1 ?? {};
const maxNumber = baseOptions.maxNumber ?? 45;
const count = baseOptions.count ?? 6;
// 최근 회차 제외 개수는 기본 3개로 맞춥니다.
const excludeRecentDrawsCount = baseOptions.excludeRecentDrawsCount ?? 3;
const recentDraws = baseOptions.recentDraws ?? [];
const wHot = baseOptions.wHot ?? 0.6;
const wDue = baseOptions.wDue ?? 0.4;
// 조건 플래그 (기본값: 모두 true)
const useCondition1 = baseOptions.useCondition1 ?? true;
const useCondition2 = baseOptions.useCondition2 ?? true;
const useCondition3 = baseOptions.useCondition3 ?? true;
// ---------------------------------------------------------------------------
// 조건 1: 최근 회차(기본 3개)의 메인 번호는 제외합니다.
// ---------------------------------------------------------------------------
const excludedDrawNumbers = useCondition1
? collectRecentDrawNumbers(recentDraws, excludeRecentDrawsCount)
: new Set<number>();
// ---------------------------------------------------------------------------
// 조건 2: 최근 회차(기본 3개)의 보너스 번호는 제외합니다.
// ---------------------------------------------------------------------------
const excludedBonusNumbers = useCondition2
? collectRecentBonusNumbers(recentDraws, excludeRecentDrawsCount)
: new Set<number>();
const isExcluded = (value: number) =>
isExcludedByRecentDraws(value, excludedDrawNumbers) ||
isExcludedByRecentBonusNumbers(value, excludedBonusNumbers);
// 제외 규칙을 통과한 후보 목록 생성
const candidates = buildCandidateNumbers(maxNumber, isExcluded);
if (candidates.length < count) {
// 제외 조건이 너무 강하면 빈 배열을 반환해 안전하게 실패합니다.
// (예: 최근 회차가 너무 많아 후보가 부족한 경우)
return [];
}
// ---------------------------------------------------------------------------
// 조건 3: Hot & Due 알고리즘으로 가중치 기반 랜덤 선택
// useCondition3가 true이고, recentDraws가 충분히 있을 때만 적용합니다.
// ---------------------------------------------------------------------------
if (useCondition3 && recentDraws.length >= 10) {
const { counts, gaps } = calculateNumberStats(candidates, recentDraws);
const scores = calculateScores(candidates, counts, gaps, wHot, wDue);
const picked = weightedSampleWithoutReplacement(scores, count);
return picked.sort((a, b) => a - b);
}
// 조건 3이 비활성화되었거나 과거 데이터가 부족하면 균등 확률로 뽑기
const shuffled = [...candidates];
for (let index = shuffled.length - 1; index > 0; index -= 1) {
const swapIndex = Math.floor(Math.random() * (index + 1));
[shuffled[index], shuffled[swapIndex]] = [shuffled[swapIndex], shuffled[index]];
}
return shuffled.slice(0, count).sort((a, b) => a - b);
}

View File

@@ -0,0 +1,20 @@
/**
* userKeys
* @description React Query의 캐시 키를 일관되게 만들기 위한 팩토리입니다.
*/
export const userKeys = {
// 1. 모든 사용자 관련 쿼리의 루트 키입니다.
all: ["users"] as const,
// 2. 목록 조회 전용 키입니다. (예: ['users', 'list'])
lists: () => [...userKeys.all, "list"] as const,
// 3. 필터가 있는 목록 키입니다. (예: ['users', 'list', { filters: 'admin' }])
list: (filters: string) => [...userKeys.lists(), { filters }] as const,
// 4. 상세 조회 전용 루트 키입니다. (예: ['users', 'detail'])
details: () => [...userKeys.all, "detail"] as const,
// 5. 특정 ID의 상세 키입니다. (예: ['users', 'detail', 1])
detail: (id: number) => [...userKeys.details(), id] as const,
};

View File

@@ -0,0 +1,75 @@
import { User } from "../types"; // 공통 User 타입을 API 응답에 적용합니다.
// 로컬 json-server 주소입니다. (필요하면 환경변수로 분리하세요)
const BASE_URL = "http://localhost:3002";
// const BASE_URL = "https://jsonplaceholder.typicode.com"; // 외부 API 대체 주소 예시입니다.
/**
* getUser
* @description 단일 사용자 정보를 가져옵니다.
* @param id - 조회할 사용자 ID입니다.
* @returns 사용자 상세 데이터를 반환합니다.
*/
export async function getUser(id: number): Promise<User> {
// 네트워크 대기 시간을 체험하기 위해 1초 지연을 인위적으로 줍니다.
await new Promise((resolve) => setTimeout(resolve, 1000));
// 사용자 상세 엔드포인트로 요청합니다.
const response = await fetch(`${BASE_URL}/users/${id}`);
// 응답이 실패라면 에러를 던져 React Query가 처리하도록 합니다.
if (!response.ok) {
throw new Error("서버에서 정보를 가져오는 데 실패했어요.");
}
// 정상 응답이면 JSON을 파싱해 반환합니다.
return response.json();
}
/**
* getUsers
* @description 사용자 목록을 가져옵니다.
* @returns 사용자 목록 배열을 반환합니다.
*/
export async function getUsers(): Promise<User[]> {
// 목록 조회도 로딩 상태를 관찰하기 위해 1초 지연합니다.
await new Promise((resolve) => setTimeout(resolve, 1000));
// 사용자 목록 엔드포인트로 요청합니다.
const response = await fetch(`${BASE_URL}/users`);
// 실패 시 에러를 던져 UI에서 오류 상태를 표시합니다.
if (!response.ok) {
throw new Error("목록을 가져오는 데 실패했어요.");
}
// 정상 응답이면 JSON을 파싱해 반환합니다.
return response.json();
}
/**
* createUser
* @description 새 사용자를 등록합니다. (POST 요청)
* @param userData - id를 제외한 사용자 정보입니다.
* @returns 생성된 사용자 정보를 반환합니다.
*/
export async function createUser(
userData: Omit<User, "id">
): Promise<User> {
// POST 요청으로 새 사용자 정보를 서버에 전송합니다.
const response = await fetch(`${BASE_URL}/users`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(userData), // 요청 본문을 JSON 문자열로 변환합니다.
});
// 실패 시 에러를 던져 Mutation이 실패 상태가 되게 합니다.
if (!response.ok) {
throw new Error("사용자 정보를 생성하는 데 실패했어요.");
}
// 정상 응답이면 JSON을 파싱해 반환합니다.
return response.json();
}

View File

@@ -0,0 +1,159 @@
"use client";
// 사용자 입력을 다루는 폼이므로 클라이언트 컴포넌트로 선언합니다.
import { useForm } from "react-hook-form"; // 폼 상태/검증을 간단히 관리하는 훅입니다.
import { zodResolver } from "@hookform/resolvers/zod"; // Zod 스키마를 react-hook-form에 연결합니다.
import * as z from "zod"; // 입력값 검증을 위한 스키마 빌더입니다.
import { useCreateUser } from "../hooks/useCreateUser"; // 사용자 생성 Mutation 훅입니다.
import { useRouter } from "next/navigation"; // 생성 후 페이지 이동을 위한 라우터입니다.
import { Loader2, User, Mail, Save, X } from "lucide-react"; // 폼 UI 아이콘입니다.
// 1. 사용자 입력 검증 스키마를 정의합니다.
const userSchema = z.object({
name: z.string().min(2, "이름은 최소 2글자 이상이어야 해요!"),
email: z.string().email("올바른 이메일 형식이 아니에요!"),
username: z.string().min(3, "사용자명은 최소 3글자 이상이어야 해요!"),
});
// 스키마로부터 폼 데이터 타입을 자동 추론합니다.
type UserFormData = z.infer<typeof userSchema>;
/**
* UserCreateForm
* @description 새 사용자를 등록하는 폼 컴포넌트입니다.
*/
export function UserCreateForm() {
// 등록 성공 시 목록 페이지로 이동하기 위해 라우터를 가져옵니다.
const router = useRouter();
// 사용자 생성 Mutation과 로딩 상태를 가져옵니다.
const { mutate, isPending } = useCreateUser();
// 2. 폼 초기화: 기본값과 검증 규칙을 연결합니다.
const {
register,
handleSubmit,
formState: { errors },
} = useForm<UserFormData>({
resolver: zodResolver(userSchema), // Zod 스키마 기반의 검증을 연결합니다.
defaultValues: {
name: "",
email: "",
username: "",
},
});
// 3. 제출 핸들러: 서버에 등록 요청을 보냅니다.
const onSubmit = (data: UserFormData) => {
mutate(data, {
onSuccess: () => {
// 성공 후에는 목록 화면으로 이동합니다.
router.push("/react-query");
},
});
};
return (
<form
onSubmit={handleSubmit(onSubmit)}
className="bg-white rounded-2xl shadow-xl border border-zinc-100 overflow-hidden"
>
{/* 폼 상단 타이틀 영역입니다. */}
<div className="bg-zinc-900 p-6 text-white text-center">
<h2 className="text-xl font-bold flex items-center justify-center gap-2">
<User className="h-5 w-5 text-blue-400" />
</h2>
<p className="text-zinc-400 text-xs mt-1">
.
</p>
</div>
{/* 입력 필드 영역입니다. */}
<div className="p-8 space-y-6">
{/* 이름 입력 */}
<div className="space-y-2">
<label className="text-sm font-bold text-zinc-700 flex items-center gap-2">
<User className="h-4 w-4 text-blue-500" />
</label>
<input
{...register("name")}
placeholder="예: 홍길동"
className={`w-full rounded-xl border px-4 py-3 outline-none transition-all focus:ring-2
${
errors.name
? "border-red-500 focus:ring-red-100"
: "border-zinc-200 focus:border-blue-500 focus:ring-blue-100"
}`}
/>
{errors.name && (
<p className="text-xs text-red-500">{errors.name.message}</p>
)}
</div>
{/* 사용자명 입력 */}
<div className="space-y-2">
<label className="text-sm font-bold text-zinc-700 flex items-center gap-2">
ID (Username)
</label>
<input
{...register("username")}
placeholder="예: gildong123"
className={`w-full rounded-xl border px-4 py-3 outline-none transition-all focus:ring-2
${
errors.username
? "border-red-500 focus:ring-red-100"
: "border-zinc-200 focus:border-blue-500 focus:ring-blue-100"
}`}
/>
{errors.username && (
<p className="text-xs text-red-500">{errors.username.message}</p>
)}
</div>
{/* 이메일 입력 */}
<div className="space-y-2">
<label className="text-sm font-bold text-zinc-700 flex items-center gap-2">
<Mail className="h-4 w-4 text-blue-500" />
</label>
<input
{...register("email")}
placeholder="example@email.com"
className={`w-full rounded-xl border px-4 py-3 outline-none transition-all focus:ring-2
${
errors.email
? "border-red-500 focus:ring-red-100"
: "border-zinc-200 focus:border-blue-500 focus:ring-blue-100"
}`}
/>
{errors.email && (
<p className="text-xs text-red-500">{errors.email.message}</p>
)}
</div>
{/* 하단 액션 버튼들입니다. */}
<div className="flex gap-3 pt-4">
<button
type="button"
onClick={() => router.back()}
className="flex-1 flex items-center justify-center gap-2 rounded-xl border border-zinc-200 px-4 py-4 text-zinc-600 font-bold hover:bg-zinc-50 transition-all"
>
<X className="h-4 w-4" />
</button>
<button
type="submit"
disabled={isPending}
className="flex-2 flex items-center justify-center gap-2 rounded-xl bg-blue-600 px-4 py-4 text-white font-bold hover:bg-blue-700 transition-all active:scale-95 disabled:opacity-50"
>
{isPending ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<Save className="h-5 w-5" />
)}
</button>
</div>
</div>
</form>
);
}

View File

@@ -0,0 +1,63 @@
import { UserList } from "./UserList"; // 목록 UI를 분리해 가독성을 높입니다.
import Link from "next/link"; // Next.js의 클라이언트 링크 컴포넌트입니다.
import { UserPlus, Users } from "lucide-react"; // 아이콘으로 의미를 보강합니다.
import { dehydrate, HydrationBoundary } from "@tanstack/react-query"; // 서버에서 프리패치한 캐시를 전달합니다.
import { getQueryClient } from "@/lib/getQueryClient"; // 서버 전용 QueryClient 유틸리티입니다.
import { getUsers } from "../api/userApi"; // 서버에서 사용자 목록을 가져오는 API 함수입니다.
import { userKeys } from "../api/queryKeys"; // React Query 캐시 키 팩토리입니다.
/**
* UserDashboard
* @description React Query 데모의 메인 대시보드(서버 컴포넌트)입니다.
*/
export async function UserDashboard() {
// 1. 서버에서 사용할 QueryClient를 준비합니다.
const queryClient = getQueryClient();
// 2. 사용자 목록을 미리 가져와서 SSR 단계에서 캐시를 채웁니다.
await queryClient.prefetchQuery({
queryKey: userKeys.lists(), // 목록 전용 캐시 키를 사용합니다.
queryFn: getUsers, // 실제 데이터를 가져오는 함수입니다.
});
return (
<div className="space-y-10 pb-20 max-w-5xl mx-auto">
{/* 헤더 섹션: 타이틀과 새 사용자 등록 링크를 보여줍니다. */}
<div className="flex flex-col sm:flex-row sm:items-end justify-between gap-6">
<div className="text-center sm:text-left">
<h1 className="text-3xl font-extrabold tracking-tight text-zinc-900 flex items-center gap-3 justify-center sm:justify-start">
React Query <span className="text-blue-600">Lab</span>
</h1>
<p className="mt-2 text-zinc-600 prose prose-zinc max-w-md">
.
</p>
</div>
<Link
href="/react-query/new"
className="flex items-center justify-center gap-2 rounded-xl bg-blue-600 px-6 py-3.5 text-white font-bold transition-all hover:bg-blue-700 active:scale-95 shadow-lg shadow-blue-200"
>
<UserPlus className="h-5 w-5" />
</Link>
</div>
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm font-bold text-zinc-400 uppercase tracking-wider">
<Users className="h-4 w-4" />
Full User List
</div>
<span className="text-xs text-zinc-400 bg-zinc-100 px-2 py-1 rounded-full">
Prefetched with Utility
</span>
</div>
{/* 3. 서버 캐시를 클라이언트에 전달하기 위한 경계입니다. */}
<HydrationBoundary state={dehydrate(queryClient)}>
<UserList />
</HydrationBoundary>
</div>
</div>
);
}

View File

@@ -0,0 +1,108 @@
"use client";
// 상세 조회는 브라우저 상호작용(리프레시 버튼 등)이 있으므로 클라이언트 컴포넌트로 둡니다.
import { useUser } from "../hooks/useUser"; // 특정 사용자 상세를 가져오는 React Query 훅입니다.
import {
Loader2,
User,
Mail,
Hash,
RefreshCcw,
AlertCircle,
} from "lucide-react"; // 시각적 피드백을 위한 아이콘들입니다.
/**
* UserDetail
* @description 선택한 사용자의 상세 정보를 보여줍니다.
* @param userId - 상세 조회에 사용할 사용자 ID입니다.
*/
export function UserDetail({ userId }: { userId: number }) {
// id에 해당하는 사용자 데이터를 가져오고 상태를 함께 받습니다.
const {
data: user,
isLoading,
isError,
error,
refetch,
isFetching,
} = useUser(userId);
// 1. 최초 로딩 중에는 스켈레톤 대신 로딩 UI만 표시합니다.
if (isLoading && !user) {
return (
<div className="flex h-[320px] flex-col items-center justify-center rounded-2xl bg-zinc-50/50 border border-dashed border-zinc-200">
<Loader2 className="h-8 w-8 animate-spin text-blue-500" />
<p className="mt-4 text-sm font-medium text-zinc-500">
..
</p>
</div>
);
}
// 2. 오류가 났다면 재시도 버튼을 제공해 학습자가 흐름을 확인할 수 있게 합니다.
if (isError) {
return (
<div className="rounded-2xl border border-red-100 bg-red-50/50 p-6 text-center">
<AlertCircle className="mx-auto h-8 w-8 text-red-500" />
<h3 className="mt-4 font-bold text-red-900"> </h3>
<p className="mt-2 text-sm text-red-600">{error.message}</p>
<button
onClick={() => refetch()}
className="mt-4 inline-flex items-center gap-2 rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700"
>
<RefreshCcw className="h-4 w-4" />
</button>
</div>
);
}
return (
<div className="overflow-hidden rounded-2xl bg-white shadow-xl border border-zinc-100">
{/* 상단 헤더: 사용자 이름을 강조하는 배너입니다. */}
<div className="bg-linear-to-r from-blue-600 to-indigo-600 p-6 text-white">
<div className="flex items-center gap-4">
<div className="rounded-full bg-white/20 p-3 backdrop-blur-md">
<User className="h-8 w-8 text-white" />
</div>
<div>
<p className="text-sm font-medium text-blue-100">User Profile</p>
{/* 안전하게 접근하기 위해 optional chaining을 사용합니다. */}
<h2 className="text-2xl font-bold">{user?.name}</h2>
</div>
</div>
</div>
{/* 본문: 사용자 상세 필드들입니다. */}
<div className="p-6 space-y-4">
<div className="flex items-center gap-3 text-zinc-700">
<Hash className="h-5 w-5 text-blue-500" />
<span className="font-semibold">ID:</span> {user?.id}
</div>
<div className="flex items-center gap-3 text-zinc-700">
<Mail className="h-5 w-5 text-blue-500" />
<span className="font-semibold">Email:</span> {user?.email}
</div>
<div className="flex items-center gap-3 text-zinc-700">
<User className="h-5 w-5 text-blue-500" />
<span className="font-semibold">User Name:</span> {user?.username}
</div>
{/* 수동으로 다시 가져오기 버튼입니다. */}
<div className="mt-6 border-t pt-6">
<button
onClick={() => refetch()}
disabled={isFetching}
className="flex w-full items-center justify-center gap-2 rounded-xl bg-zinc-900 px-4 py-3 text-sm font-bold text-white transition-all hover:bg-zinc-800 active:scale-95 disabled:opacity-50"
>
<RefreshCcw
className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`}
/>
{isFetching ? "Refetching..." : "Detail Refetch"}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,150 @@
"use client";
// 이 컴포넌트는 브라우저에서 동작하므로 클라이언트 컴포넌트로 선언합니다.
import { useCallback } from "react"; // 동일한 핸들러를 재사용하기 위한 훅입니다.
import { useQueryClient } from "@tanstack/react-query"; // 프리패치를 위해 캐시에 접근합니다.
import { useRouter } from "next/navigation"; // 클릭/프리패치에 사용할 Next.js 라우터입니다.
import { useUsers } from "../hooks/useUsers"; // 사용자 목록을 가져오는 React Query 훅입니다.
import { useUserStore } from "../store/useUserStore"; // 선택된 사용자 ID를 공유하는 Zustand 스토어입니다.
import { getUser } from "../api/userApi"; // 상세 데이터 프리패치를 위한 API 함수입니다.
import { userKeys } from "../api/queryKeys"; // 상세 쿼리 키를 일관되게 만들기 위한 도구입니다.
import { Skeleton } from "@/components/ui/skeleton"; // Shadcn UI 스켈레톤 컴포넌트입니다.
import {
Loader2,
User as UserIcon,
Mail,
Hash,
AlertCircle,
} from "lucide-react"; // 상태/정보 아이콘으로 UI 가독성을 높입니다.
/**
* UserList
* @description 사용자 목록을 조회하고, 선택한 사용자를 강조 표시하는 컴포넌트입니다.
*/
export function UserList() {
// 서버에서 사용자 목록을 가져옵니다.
const { data, isLoading, isError, error } = useUsers();
// 선택된 사용자 상태를 전역으로 유지합니다.
const { selectedUserId, setSelectedUserId } = useUserStore();
// 선택한 사용자 상세 페이지로 이동하기 위해 라우터를 가져옵니다.
const router = useRouter();
// React Query 캐시에 접근해 상세 데이터를 미리 채웁니다.
const queryClient = useQueryClient();
/**
* handlePrefetch
* @description 카드에 마우스를 올리거나 포커스했을 때 라우트/데이터를 미리 준비합니다.
* @param id - 프리패치 대상 사용자 ID입니다.
*/
const handlePrefetch = useCallback(
(id: number) => {
// 1) 라우트 프리패치: 상세 페이지의 코드/데이터 준비를 시작합니다.
router.prefetch(`/react-query/${id}`);
// 2) 데이터 프리패치: 상세 데이터를 캐시에 미리 채워둡니다.
queryClient.prefetchQuery({
queryKey: userKeys.detail(id),
queryFn: () => getUser(id),
});
},
// router/queryClient가 바뀌면 새 콜백을 다시 만듭니다.
[router, queryClient]
);
// 로딩 중일 때는 스피너만 보여주어 사용자가 기다리게 합니다.
if (isLoading) {
return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{/* 실제 카드 레이아웃과 비슷한 스켈레톤을 여러 개 보여줍니다. */}
{Array.from({ length: 6 }).map((_, index) => (
<div
key={`user-skeleton-${index}`} // 스켈레톤 리스트를 식별하기 위한 키입니다.
className="flex flex-col rounded-xl border border-zinc-200 p-5 bg-white"
>
<div className="flex items-center gap-3 mb-4">
<Skeleton className="h-9 w-9 rounded-full" />
<Skeleton className="h-4 w-28" />
</div>
<div className="space-y-2">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-3 w-32" />
</div>
</div>
))}
{/* 하단에 로딩 상태를 보조적으로 알려주는 스피너를 배치합니다. */}
<div className="col-span-full flex justify-center pt-4">
<Loader2 className="h-5 w-5 animate-spin text-blue-500" />
</div>
</div>
);
}
// 오류가 나면 바로 알 수 있도록 메시지를 표시합니다.
if (isError) {
return (
<div className="rounded-xl bg-red-50 p-4 text-red-600 flex items-center gap-2">
{/* 오류 상황을 강조하는 경고 아이콘입니다. */}
<AlertCircle className="h-5 w-5" />
{/* 서버 오류 메시지를 보여줍니다. */}
<span> : {error.message}</span>
</div>
);
}
return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{/* 목록 데이터가 있을 때만 사용자 카드를 생성합니다. */}
{data?.map((user) => (
<div
key={user.id} // React가 리스트를 추적할 수 있도록 고유 ID를 키로 사용합니다.
onClick={() => {
// 클릭한 사용자를 전역 상태로 저장합니다.
setSelectedUserId(user.id);
// 선택된 사용자 상세 페이지로 이동합니다.
router.push(`/react-query/${user.id}`);
}}
onMouseEnter={() => handlePrefetch(user.id)} // 마우스 오버 시 상세 페이지를 미리 준비합니다.
onFocus={() => handlePrefetch(user.id)} // 키보드 포커스도 동일하게 프리패치합니다.
className={`group flex flex-col rounded-xl border p-5 transition-all cursor-pointer hover:shadow-md
${
// 선택된 항목은 강조 색상을 사용해 시각적으로 구분합니다.
selectedUserId === user.id
? "border-blue-500 bg-blue-50/50 shadow-sm ring-1 ring-blue-500"
: "border-zinc-200 bg-white hover:border-blue-200"
}`}
>
{/* 사용자 이름과 아이콘을 묶은 헤더 영역입니다. */}
<div className="flex items-center gap-3 mb-4">
<div
className={`rounded-full p-2 transition-colors
${
// 선택된 사용자일 때 아이콘 배경색을 바꿔 강조합니다.
selectedUserId === user.id
? "bg-blue-600 text-white"
: "bg-blue-50 text-blue-600 group-hover:bg-blue-600 group-hover:text-white"
}`}
>
<UserIcon className="h-5 w-5" />
</div>
{/* 이름이 길어질 수 있어 한 줄로 잘라서 보여줍니다. */}
<h3 className="font-bold text-zinc-900 line-clamp-1">
{user.name}
</h3>
</div>
{/* 이메일/ID처럼 부가 정보를 모은 영역입니다. */}
<div className="space-y-2 text-sm text-zinc-500">
<div className="flex items-center gap-2">
<Hash className="h-4 w-4 text-zinc-300" />
<span>ID: {user.id}</span>
</div>
<div className="flex items-center gap-2">
<Mail className="h-4 w-4 text-zinc-300" />
<span className="line-clamp-1">{user.email}</span>
</div>
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,27 @@
import { useMutation, useQueryClient } from "@tanstack/react-query"; // 변경 작업과 캐시 제어를 위한 훅입니다.
import { createUser } from "../api/userApi"; // 사용자 생성 API 함수입니다.
import { userKeys } from "../api/queryKeys"; // 목록 캐시 무효화를 위해 사용합니다.
import { User } from "../types"; // 입력 타입을 명확히 하기 위한 모델입니다.
/**
* useCreateUser
* @description 새 사용자를 등록하는 Mutation 훅입니다.
* @returns mutate 함수와 상태를 포함한 Mutation 객체입니다.
*/
export function useCreateUser() {
// 캐시를 직접 다루기 위해 QueryClient를 가져옵니다.
const queryClient = useQueryClient();
return useMutation({
// mutationFn은 실제 생성 API를 호출합니다.
mutationFn: (userData: Omit<User, "id">) => createUser(userData),
// 성공 시 목록 캐시를 무효화해 최신 목록을 다시 가져오게 합니다.
onSuccess: () => {
// 유저 생성 후 목록 캐시를 무효화해 최신 목록을 다시 가져오게 합니다.
queryClient.invalidateQueries({ queryKey: userKeys.lists() });
// 학습을 위한 로그로 성공 시점을 확인합니다.
console.log("신규 유저가 등록되었습니다! 🎉");
},
});
}

View File

@@ -0,0 +1,29 @@
"use client";
// 상세 조회도 브라우저 캐시를 사용하므로 클라이언트 훅으로 둡니다.
import { useQuery } from "@tanstack/react-query"; // 서버 상태를 캐싱/동기화하는 훅입니다.
import { getUser } from "@/features/react-query-demo/api/userApi"; // 특정 사용자 조회 API 함수입니다.
import { User } from "@/features/react-query-demo/types"; // 반환 타입을 명시해 안정성을 높입니다.
import { userKeys } from "@/features/react-query-demo/api/queryKeys"; // 상세 조회 전용 queryKey 팩토리입니다.
/**
* useUser
* @description 단일 사용자 상세 정보를 가져오는 React Query 훅입니다.
* @param id - 조회할 사용자 ID입니다.
* @returns 사용자 상세와 로딩/오류 상태를 반환합니다.
*/
export function useUser(id: number) {
return useQuery<User>({
// id가 바뀌면 React Query가 다른 캐시 키로 인식해 재요청합니다.
queryKey: userKeys.detail(id),
// 실제 API 호출 로직은 userApi로 분리해 재사용합니다.
queryFn: () => getUser(id),
// 상세 데이터는 5분 동안 신선하다고 보고 재요청을 줄입니다.
staleTime: 1000 * 60 * 5,
// 10분 동안 사용하지 않으면 캐시에서 제거합니다.
gcTime: 1000 * 60 * 10,
// 네트워크 오류 시 한 번만 재시도합니다.
retry: 1,
});
}

View File

@@ -0,0 +1,28 @@
"use client";
// React Query는 브라우저 캐시를 다루므로 클라이언트 컴포넌트에서 사용합니다.
import { useQuery } from "@tanstack/react-query"; // 서버 상태를 캐싱/동기화하는 훅입니다.
import { getUsers } from "@/features/react-query-demo/api/userApi"; // 실제 API 호출 함수입니다.
import { User } from "@/features/react-query-demo/types"; // 타입 안정성을 위한 User 타입입니다.
import { userKeys } from "@/features/react-query-demo/api/queryKeys"; // 캐시 키 설계 규칙을 모은 객체입니다.
/**
* useUsers
* @description 사용자 목록을 가져오는 React Query 훅입니다.
* @returns 사용자 목록과 상태(isLoading/isError 등)를 반환합니다.
*/
export function useUsers() {
return useQuery<User[]>({
// queryKey는 캐싱의 기준이므로 목록용 키를 명확히 분리합니다.
queryKey: userKeys.lists(),
// queryFn은 실제 데이터를 가져오는 함수입니다.
queryFn: getUsers,
// 목록 데이터는 1분 동안 신선하다고 보고 재요청을 줄입니다.
staleTime: 1000 * 60 * 1,
// 창 포커스 복귀 시 자동 재요청을 끕니다(학습용으로 흐름을 단순화).
refetchOnWindowFocus: false,
// 네트워크 오류 시 한 번만 재시도합니다.
retry: 1,
});
}

View File

@@ -0,0 +1,22 @@
import { create } from "zustand"; // 전역 상태를 간단히 만들 수 있는 Zustand입니다.
/**
* UserState
* @description 선택된 사용자 ID를 공유하기 위한 상태 타입입니다.
* @property selectedUserId - 현재 선택된 사용자 ID입니다.
* @property setSelectedUserId - 선택된 사용자 ID를 변경하는 함수입니다.
*/
interface UserState {
selectedUserId: number;
setSelectedUserId: (id: number) => void;
}
/**
* useUserStore
* @description 여러 컴포넌트가 "선택된 사용자" 정보를 공유하도록 돕는 스토어입니다.
*/
export const useUserStore = create<UserState>((set) => ({
selectedUserId: 1, // 기본값은 1번 사용자로 설정합니다.
setSelectedUserId: (id) =>
set({ selectedUserId: id }), // 선택 상태만 갱신해 다른 컴포넌트에 전파합니다.
}));

View File

@@ -0,0 +1,15 @@
// 사용자 데이터를 한 곳에서 재사용하기 위한 타입 정의입니다.
/**
* User
* @description React Query 데모에서 사용하는 사용자 모델입니다.
* @property id - 서버에서 부여되는 고유 식별자입니다.
* @property name - 화면에 보여줄 사용자 이름입니다.
* @property email - 연락용 이메일 주소입니다.
* @property username - 로그인/표시에 쓰는 사용자 ID입니다.
*/
export interface User {
id: number; // 사용자 고유 ID라서 숫자로 정의합니다.
name: string; // 목록/상세 화면에서 표시할 이름입니다.
email: string; // 이메일 입력값을 그대로 저장합니다.
username: string; // 별칭/아이디 성격의 값입니다.
}

View File

@@ -0,0 +1,39 @@
'use client';
import React from 'react';
import { useCountStore } from '../store/useCountStore';
export function ZustandExample() {
const { count, increment, decrement, reset } = useCountStore();
return (
<div className="flex flex-col items-center gap-4 p-8 border border-zinc-200 rounded-2xl bg-white shadow-sm max-w-md mx-auto mt-10">
<h2 className="text-2xl font-bold text-zinc-800">Zustand Counter</h2>
<div className="text-7xl font-black text-amber-500 my-6 tracking-tighter tabular-nums">{count}</div>
<div className="flex gap-3 w-full">
<button
onClick={decrement}
className="flex-1 px-4 py-3 bg-red-50 text-red-600 rounded-xl hover:bg-red-100 transition-colors font-semibold active:scale-95"
>
-
</button>
<button
onClick={reset}
className="flex-1 px-4 py-3 bg-zinc-100 text-zinc-600 rounded-xl hover:bg-zinc-200 transition-colors font-semibold active:scale-95"
>
</button>
<button
onClick={increment}
className="flex-1 px-4 py-3 bg-blue-50 text-blue-600 rounded-xl hover:bg-blue-100 transition-colors font-semibold active:scale-95"
>
+
</button>
</div>
<p className="text-sm text-zinc-400 mt-6 text-center leading-relaxed bg-zinc-50 p-4 rounded-xl w-full">
<span className="font-semibold text-zinc-600 block mb-1"> </span>
(Count) .
</p>
</div>
);
}

View File

@@ -0,0 +1,15 @@
import { create } from 'zustand';
interface CountState {
count: number;
increment: () => void;
decrement: () => void;
reset: () => void;
}
export const useCountStore = create<CountState>((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 }),
}));

30
src/lib/getQueryClient.ts Normal file
View File

@@ -0,0 +1,30 @@
import { QueryClient, isServer } from "@tanstack/react-query";
// 브라우저에서 캐시를 유지하기 위한 싱글톤 인스턴스입니다.
let browserQueryClient: QueryClient | undefined = undefined;
function makeQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000, // 1분 동안은 신선한 데이터로 간주합니다.
refetchOnWindowFocus: false, // 탭 포커스 이동 시 자동 재요청을 막습니다.
retry: 1, // 실패 시 1회 재시도합니다.
},
},
});
}
// 서버/클라이언트 환경에 맞는 QueryClient를 반환합니다.
export function getQueryClient() {
if (isServer) {
// 서버는 요청마다 새 인스턴스를 만들어 캐시가 섞이지 않게 합니다.
return makeQueryClient();
}
// 브라우저에서는 싱글톤을 재사용해 캐시를 유지합니다.
if (!browserQueryClient) {
browserQueryClient = makeQueryClient();
}
return browserQueryClient;
}

View File

@@ -0,0 +1,345 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties, HTMLAttributes, PointerEvent } from "react";
import { cn } from "@/lib/utils";
// ============================================================================
// 상수 (Constants)
// ============================================================================
const DEFAULT_WIDTH = 720;
const DEFAULT_HEIGHT = 520;
const MIN_WIDTH = 320;
const MIN_HEIGHT = 360;
const VIEWPORT_PADDING = 16;
// ============================================================================
// 타입 정의 (Type Definitions)
// ============================================================================
/**
* 드래그/리사이즈 옵션입니다.
* @property {boolean} [enabled] - 기능 활성화 여부입니다.
* @property {boolean} [open] - 다이얼로그가 열린 상태인지 여부입니다.
* @property {boolean} [draggable] - 드래그 이동 활성화 여부입니다.
* @property {boolean} [resizable] - 크기 조절 활성화 여부입니다.
* @property {number} [initialWidth] - 초기 너비입니다.
* @property {number} [initialHeight] - 초기 높이입니다.
* @property {number} [minWidth] - 최소 너비입니다.
* @property {number} [minHeight] - 최소 높이입니다.
* @property {number} [viewportPadding] - 화면 가장자리 여백입니다.
*/
export interface DialogDragResizeOptions {
enabled?: boolean;
open?: boolean;
draggable?: boolean;
resizable?: boolean;
initialWidth?: number;
initialHeight?: number;
minWidth?: number;
minHeight?: number;
viewportPadding?: number;
}
/**
* 드래그/리사이즈 결과 타입입니다.
*/
export interface DialogDragResizeResult {
contentStyle?: CSSProperties;
contentClassName: string;
dragHandleProps: HTMLAttributes<HTMLElement>;
dragHandleClassName: string;
resizeHandleProps: HTMLAttributes<HTMLDivElement>;
resizeHandleClassName: string;
isDraggable: boolean;
isResizable: boolean;
}
// ============================================================================
// 유틸리티 함수 (Utility Functions)
// ============================================================================
/**
* 최소/최대 범위 내로 값을 고정합니다.
*/
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
/**
* 현재 뷰포트 기준으로 드래그 이동 범위를 계산합니다.
* 다이얼로그가 `translate(-50%, -50%)`로 중앙 정렬되어 있으므로,
* position(x, y)는 "중앙으로부터의 오프셋"입니다.
*/
function getDragBounds(width: number, height: number, padding: number) {
const halfViewportW = window.innerWidth / 2;
const halfViewportH = window.innerHeight / 2;
const halfDialogW = width / 2;
const halfDialogH = height / 2;
// x가 maxX일 때 다이얼로그 오른쪽 끝이 뷰포트 오른쪽 끝 - padding에 닿음
const maxX = halfViewportW - padding - halfDialogW;
const minX = -halfViewportW + padding + halfDialogW;
const maxY = halfViewportH - padding - halfDialogH;
const minY = -halfViewportH + padding + halfDialogH;
return { minX, maxX, minY, maxY };
}
/**
* 현재 뷰포트 기준으로 리사이즈 최대 크기를 계산합니다.
*/
function getResizeBounds(minWidth: number, minHeight: number, padding: number) {
const maxWidth = Math.max(minWidth, window.innerWidth - padding * 2);
const maxHeight = Math.max(minHeight, window.innerHeight - padding * 2);
return { maxWidth, maxHeight };
}
// ============================================================================
// 메인 훅 (Main Hook)
// ============================================================================
/**
* Radix Dialog를 드래그/리사이즈 가능하게 만드는 훅입니다.
* 사용처에서 DialogContent와 헤더, 리사이즈 핸들에 props를 연결합니다.
*
* 주요 개선 사항:
* 1. `will-change: transform`으로 GPU 가속 렌더링
* 2. 드래그/리사이즈 중 `transition: none`으로 버벅임 방지
* 3. 뷰포트 경계 내 위치 보정으로 창 고정 현상 방지
*/
export function useDialogDragResize(
options: DialogDragResizeOptions = {}
): DialogDragResizeResult {
const {
enabled = true,
open = true,
draggable = true,
resizable = true,
initialWidth = DEFAULT_WIDTH,
initialHeight = DEFAULT_HEIGHT,
minWidth = MIN_WIDTH,
minHeight = MIN_HEIGHT,
viewportPadding = VIEWPORT_PADDING,
} = options;
// ---------------------------------------------------------------------------
// State & Refs
// ---------------------------------------------------------------------------
const [position, setPosition] = useState({ x: 0, y: 0 });
const [size, setSize] = useState({ width: initialWidth, height: initialHeight });
const [isInteracting, setIsInteracting] = useState(false);
// 드래그/리사이즈 시작 시점의 스냅샷을 저장하는 Ref
const interactionRef = useRef({
type: null as "drag" | "resize" | null,
startPointerX: 0,
startPointerY: 0,
startPosX: 0,
startPosY: 0,
startWidth: 0,
startHeight: 0,
});
const isActive = enabled && open;
// ---------------------------------------------------------------------------
// Viewport Bounds 보정 (다이얼로그 열릴 때 & 화면 크기 변경 시)
// ---------------------------------------------------------------------------
const applyViewportBounds = useCallback(() => {
if (typeof window === "undefined") return;
const { maxWidth, maxHeight } = getResizeBounds(minWidth, minHeight, viewportPadding);
setSize((prevSize) => {
const nextWidth = clamp(prevSize.width, minWidth, maxWidth);
const nextHeight = clamp(prevSize.height, minHeight, maxHeight);
setPosition((prevPos) => {
const { minX, maxX, minY, maxY } = getDragBounds(nextWidth, nextHeight, viewportPadding);
return {
x: clamp(prevPos.x, minX, maxX),
y: clamp(prevPos.y, minY, maxY),
};
});
return { width: nextWidth, height: nextHeight };
});
}, [minHeight, minWidth, viewportPadding]);
useEffect(() => {
if (!isActive) return;
applyViewportBounds();
}, [applyViewportBounds, isActive]);
useEffect(() => {
if (!isActive) return;
window.addEventListener("resize", applyViewportBounds);
return () => window.removeEventListener("resize", applyViewportBounds);
}, [applyViewportBounds, isActive]);
// ---------------------------------------------------------------------------
// 드래그 핸들러 (Drag Handlers)
// ---------------------------------------------------------------------------
const handleDragStart = useCallback(
(event: PointerEvent<HTMLElement>) => {
if (!isActive || !draggable) return;
event.preventDefault();
interactionRef.current = {
type: "drag",
startPointerX: event.clientX,
startPointerY: event.clientY,
startPosX: position.x,
startPosY: position.y,
startWidth: size.width,
startHeight: size.height,
};
setIsInteracting(true);
event.currentTarget.setPointerCapture(event.pointerId);
},
[draggable, isActive, position.x, position.y, size.width, size.height]
);
const handleDragMove = useCallback(
(event: PointerEvent<HTMLElement>) => {
const ref = interactionRef.current;
if (ref.type !== "drag") return;
const dx = event.clientX - ref.startPointerX;
const dy = event.clientY - ref.startPointerY;
const { minX, maxX, minY, maxY } = getDragBounds(ref.startWidth, ref.startHeight, viewportPadding);
setPosition({
x: clamp(ref.startPosX + dx, minX, maxX),
y: clamp(ref.startPosY + dy, minY, maxY),
});
},
[viewportPadding]
);
const handleDragEnd = useCallback((event: PointerEvent<HTMLElement>) => {
if (interactionRef.current.type !== "drag") return;
interactionRef.current.type = null;
setIsInteracting(false);
event.currentTarget.releasePointerCapture(event.pointerId);
}, []);
// ---------------------------------------------------------------------------
// 리사이즈 핸들러 (Resize Handlers)
// ---------------------------------------------------------------------------
const handleResizeStart = useCallback(
(event: PointerEvent<HTMLDivElement>) => {
if (!isActive || !resizable) return;
event.preventDefault();
interactionRef.current = {
type: "resize",
startPointerX: event.clientX,
startPointerY: event.clientY,
startPosX: position.x,
startPosY: position.y,
startWidth: size.width,
startHeight: size.height,
};
setIsInteracting(true);
event.currentTarget.setPointerCapture(event.pointerId);
},
[isActive, position.x, position.y, resizable, size.width, size.height]
);
const handleResizeMove = useCallback(
(event: PointerEvent<HTMLDivElement>) => {
const ref = interactionRef.current;
if (ref.type !== "resize") return;
const dx = event.clientX - ref.startPointerX;
const dy = event.clientY - ref.startPointerY;
const { maxWidth, maxHeight } = getResizeBounds(minWidth, minHeight, viewportPadding);
const nextWidth = clamp(ref.startWidth + dx, minWidth, maxWidth);
const nextHeight = clamp(ref.startHeight + dy, minHeight, maxHeight);
// 왼쪽 위 고정을 위해 크기 변화량의 절반만큼 중심 이동
const dWidth = nextWidth - ref.startWidth;
const dHeight = nextHeight - ref.startHeight;
const rawNextX = ref.startPosX + dWidth / 2;
const rawNextY = ref.startPosY + dHeight / 2;
// 뷰포트 경계 보정
const { minX, maxX, minY, maxY } = getDragBounds(nextWidth, nextHeight, viewportPadding);
setSize({ width: nextWidth, height: nextHeight });
setPosition({
x: clamp(rawNextX, minX, maxX),
y: clamp(rawNextY, minY, maxY),
});
},
[minHeight, minWidth, viewportPadding]
);
const handleResizeEnd = useCallback((event: PointerEvent<HTMLDivElement>) => {
if (interactionRef.current.type !== "resize") return;
interactionRef.current.type = null;
setIsInteracting(false);
event.currentTarget.releasePointerCapture(event.pointerId);
}, []);
// ---------------------------------------------------------------------------
// 스타일 및 Props 생성
// ---------------------------------------------------------------------------
const contentStyle = useMemo<CSSProperties | undefined>(() => {
if (!isActive) return undefined;
return {
width: size.width,
height: size.height,
transform: `translate(-50%, -50%) translate(${position.x}px, ${position.y}px)`,
// GPU 가속을 위해 will-change 힌트 제공
willChange: isInteracting ? "transform, width, height" : "auto",
};
}, [isActive, isInteracting, position.x, position.y, size.height, size.width]);
const contentClassName = isActive
? cn(
"sm:max-w-none",
// 인터랙션 중에는 모든 트랜지션을 끄고, 텍스트 선택을 막습니다.
isInteracting && "transition-none duration-0 select-none"
)
: "";
const dragHandleClassName = isActive && draggable ? "cursor-move select-none touch-none" : "";
const resizeHandleClassName = isActive && resizable ? "cursor-se-resize touch-none" : "";
const dragHandleProps = useMemo<HTMLAttributes<HTMLElement>>(() => {
if (!isActive || !draggable) return {};
return {
onPointerDown: handleDragStart,
onPointerMove: handleDragMove,
onPointerUp: handleDragEnd,
onPointerCancel: handleDragEnd,
};
}, [draggable, handleDragEnd, handleDragMove, handleDragStart, isActive]);
const resizeHandleProps = useMemo<HTMLAttributes<HTMLDivElement>>(() => {
if (!isActive || !resizable) return {};
return {
onPointerDown: handleResizeStart,
onPointerMove: handleResizeMove,
onPointerUp: handleResizeEnd,
onPointerCancel: handleResizeEnd,
};
}, [handleResizeEnd, handleResizeMove, handleResizeStart, isActive, resizable]);
return {
contentStyle,
contentClassName,
dragHandleProps,
dragHandleClassName,
resizeHandleProps,
resizeHandleClassName,
isDraggable: isActive && draggable,
isResizable: isActive && resizable,
};
}

6
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@@ -0,0 +1,24 @@
"use client";
// React Query Provider를 앱 전역에 주입하는 클라이언트 컴포넌트입니다.
import { QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import React from "react";
import { getQueryClient } from "@/lib/getQueryClient";
export default function QueryProvider({
children,
}: {
children: React.ReactNode;
}) {
// 싱글톤 QueryClient를 사용해 캐시를 공유합니다.
const queryClient = getQueryClient();
return (
<QueryClientProvider client={queryClient}>
{children}
{/* 개발 환경에서만 React Query 상태를 확인하는 도구입니다. */}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}

View File

@@ -1,7 +1,11 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@@ -11,7 +15,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"jsx": "preserve",
"incremental": true,
"plugins": [
{
@@ -19,7 +23,9 @@
}
],
"paths": {
"@/*": ["./src/*"]
"@/*": [
"./src/*"
]
}
},
"include": [
@@ -30,5 +36,7 @@
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
"exclude": [
"node_modules"
]
}