diff --git a/docs/Plan/03. my-links_search_feature_plan.md b/docs/Plan/03. my-links_search_feature_plan.md
new file mode 100644
index 0000000..bb6e316
--- /dev/null
+++ b/docs/Plan/03. my-links_search_feature_plan.md
@@ -0,0 +1,453 @@
+# My Links 검색 기능 계획서
+
+> **문서 정보**
+> - **버전:** v0.1.0 (Draft)
+> - **작성일:** 2026-04-14
+> - **관련 화면:** `/my-links`
+> - **관련 문서:** [SearchWeb PRD](./01.%20searchweb_PRD.md), [KPI 측정 설계](./02.%20kpi_measurement_design.md), [Frontend Architecture](../Fontend/01.%20frontend_architecture.md)
+> - **문서 목적:** `/my-links` 검색 기능의 UX 정책, 결과 구조, 데이터 규칙, 구현 범위를 정의한다.
+
+
+
+## 1. 개요
+
+`/my-links` 화면은 현재 좌측 메인 영역과 우측 링크 패널의 역할이 분리되어 있다.
+
+- 좌측 메인 영역: 폴더 탐색, 폴더 생성, 전체 구조 탐색
+- 우측 패널: 선택된 폴더 기준 링크 조회, 정렬, 태그 필터, 링크 편집
+
+검색 기능 역시 이 역할 분리를 유지하면서도, 사용자는 일관된 검색 경험을 느낄 수 있어야 한다.
+따라서 본 계획서는 다음 원칙을 채택한다.
+
+- **검색 경험은 통일한다.**
+- **검색 범위와 결과 해석은 영역별로 분리한다.**
+- **전체검색에서는 폴더와 링크를 모두 보여주되, 결과를 섞지 않고 섹션으로 분리한다.**
+- **링크가 여러 폴더에 저장된 경우, 포함된 폴더 정보를 명시적으로 보여준다.**
+
+
+
+## 2. 문제 정의
+
+현재 `/my-links` 상단에는 `Search folders or links` 형태의 입력창이 존재하지만, 실제 검색 범위와 결과 정책은 정의되어 있지 않다.
+
+이 상태에서 검색을 바로 구현하면 다음 문제가 생길 수 있다.
+
+- 사용자가 지금 찾는 대상이 폴더인지 링크인지 명확하지 않다.
+- 전체검색 시 폴더 결과와 링크 결과가 한 리스트에 섞이면 결과 문맥이 깨진다.
+- 동일 링크가 여러 폴더에 존재할 때 결과를 어떻게 보여줄지 일관된 기준이 없다.
+- 좌측 메인 검색과 우측 패널 검색의 역할이 겹치면 인터랙션이 혼란스러워진다.
+
+
+
+## 3. 목표
+
+### 3.1 사용자 목표
+
+- 사용자가 폴더를 빠르게 찾을 수 있어야 한다.
+- 사용자가 현재 선택한 폴더 안에서 링크를 빠르게 찾을 수 있어야 한다.
+- 사용자가 전체검색을 통해 폴더와 링크를 한 번에 탐색할 수 있어야 한다.
+- 사용자가 검색 결과에서 링크가 어떤 폴더들에 포함되어 있는지 즉시 이해할 수 있어야 한다.
+
+### 3.2 제품 목표
+
+- 검색 대상과 결과 구조를 명확히 분리하여 혼란을 줄인다.
+- 현재 데이터 구조를 크게 깨지 않고 1차 버전을 빠르게 구현한다.
+- 이후 전역 검색 고도화와 이벤트 로깅으로 확장 가능한 기반을 만든다.
+
+
+
+## 4. UX 원칙
+
+### 4.1 검색 경험 통일, 검색 범위 분리
+
+검색창의 시각 스타일, 입력 방식, 아이콘, 인터랙션은 최대한 통일한다.
+다만 각 영역이 담당하는 검색 범위는 다음과 같이 분리한다.
+
+| 영역 | 기본 역할 | 검색 대상 | 기본 범위 |
+| :--- | :--- | :--- | :--- |
+| 좌측 메인 검색 | 탐색 진입점 | 폴더, 링크 | 기본은 폴더 검색 |
+| 우측 패널 검색 | 현재 작업 맥락 유지 | 링크 | 현재 선택된 폴더 내부 링크 |
+
+### 4.2 기본값은 좁고, 확장은 명시적으로
+
+좌측 검색은 기본적으로 `폴더만 검색`으로 시작한다.
+전체검색이 필요한 사용자를 위해 검색 범위 토글을 제공한다.
+
+권장 범위 토글:
+
+- `폴더만`
+- `전체`
+
+기본 선택:
+
+- `폴더만`
+
+선택 이유:
+
+- `/my-links` 좌측 영역의 핵심 역할이 폴더 탐색이기 때문이다.
+- 전체검색을 기본으로 두면 결과 해석 비용이 커진다.
+- 우측 패널의 링크 검색과 역할이 겹치지 않도록 해야 한다.
+
+
+
+## 5. 검색 범위 정책
+
+### 5.1 좌측 메인 검색
+
+좌측 검색창은 두 가지 모드를 가진다.
+
+#### A. 폴더만 검색
+
+- 폴더 이름 기준 검색
+- 결과는 폴더 카드 또는 폴더 리스트로 표시
+- 기존 폴더 탐색 흐름을 해치지 않는 범위에서 동작
+
+#### B. 전체검색
+
+- 검색어와 매칭되는 폴더 결과와 링크 결과를 모두 보여줌
+- 단, 한 리스트에 섞지 않고 별도 섹션으로 분리
+- 상단에 결과 탭 또는 칩으로 `전체 | 폴더 | 링크` 필터 제공 가능
+
+### 5.2 우측 패널 검색
+
+- 검색 대상은 링크만
+- 검색 범위는 현재 `selectedFolderId` 기준으로 조회된 링크 목록
+- 태그 필터, 정렬과 함께 사용 가능해야 함
+- placeholder는 `Search links in this folder` 수준으로 명확화
+
+
+
+## 6. 전체검색 결과 구조
+
+### 6.1 핵심 정책
+
+전체검색에서 검색어가 폴더와 링크 모두에 매칭되면 **둘 다 보여준다.**
+단, 결과를 한 리스트로 섞지 않고 다음과 같이 분리한다.
+
+- `Folders`
+- `Links`
+
+이는 "전체검색을 켰다"는 사용자의 의도가 "어디 있든 관련 있는 것을 다 찾고 싶다"에 가깝기 때문이다.
+
+### 6.2 결과 구성 예시
+
+```text
+[ 검색창: aaa ] [전체]
+
+8 results
+[All] [Folders 5] [Links 6]
+
+Folders
+- aaa 프로젝트 (폴더명 직접 매칭)
+- aaa 참고자료 (폴더명 직접 매칭)
+- Frontend (링크 'aaa 공식문서' 포함)
+- Study (링크 'aaa 공식문서' 포함)
+- Work (링크 'How to use aaa' 포함)
+
+Links
+- aaa 공식문서
+ 포함 폴더: Frontend / Study / Reference
+- How to use aaa
+ 포함 폴더: Work / Archive
+```
+
+**주요 변경:**
+- 링크에 포함된 폴더(Frontend, Study, Work 등)도 **Folders 섹션에 함께 표시**
+- 폴더 클릭 시 우측 패널 링크 검색창에 검색어('aaa')가 자동 입력되어, 해당 폴더의 매칭 링크만 표시
+
+### 6.3 UI 표현 규칙
+
+- 검색창 바로 아래에 총 결과 수를 표시한다.
+- 그 아래에 `All`, `Folders`, `Links` 탭 또는 칩을 둘 수 있다.
+- `Folders` 섹션은 폴더 결과만 렌더링한다.
+- `Links` 섹션은 링크 결과만 렌더링한다.
+- 링크 카드 내부에 `포함 폴더` 메타정보를 표시한다.
+
+### 6.4 결과를 섞지 않는 이유
+
+- 폴더 결과와 링크 결과는 사용자가 기대하는 액션이 다르다.
+- 폴더 결과는 "해당 폴더로 이동"이 주된 액션이다.
+- 링크 결과는 "링크 열기" 또는 "링크가 저장된 위치 확인"이 주된 액션이다.
+- 섞어서 보여주면 정보는 많아지지만 이해 비용이 크게 증가한다.
+
+
+
+## 7. 링크 중복 및 포함 폴더 표시 정책
+
+### 7.1 문제 상황
+
+전체검색에서 동일한 링크가 여러 폴더에 저장될 수 있다. 예를 들어 동일 링크가 `Frontend`, `Study` 두 폴더에 모두 저장된 경우가 있다.
+
+### 7.2 기본 정책
+
+전체검색 결과에서 링크 카드에는 **해당 링크가 포함된 폴더를 모두 보여준다.**
+
+- 링크 결과는 링크 자체의 정보와 함께 렌더링
+- 링크 카드 내부에 `포함 폴더` 목록 표시
+- 폴더 배지 클릭 시 해당 폴더로 이동 가능하도록 설계
+
+### 7.3 폴더 표시 규칙
+
+- 1~3개 폴더는 모두 바로 노출
+- 폴더가 4개 이상이면 `+N more` 패턴 고려
+- hover, 클릭, 확장 패널 등으로 전체 폴더 목록 확인 가능하도록 확장 가능
+
+### 7.4 의미 분리
+
+다음 두 정보는 같은 "폴더"처럼 보이지만 역할이 다르다.
+
+- `Folders` 섹션의 폴더: 검색어에 직접 매칭된 검색 결과
+- 링크 카드의 포함 폴더: 해당 링크가 저장된 위치 정보
+
+따라서 반드시 위치를 분리해서 보여줘야 한다.
+
+
+
+## 8. 데이터 및 API 설계 방향
+
+### 8.1 현재 코드 기준 확인 사항
+
+현재 프론트엔드에서는 북마크 조회 API가 `query` 파라미터를 받을 수 있다.
+
+- `frontend/src/lib/api/bookmarkApi.ts`
+- `frontend/src/lib/types/bookmark.ts`
+
+또한 백엔드 `BookmarkSearchCommand`와 MyBatis 매퍼에서도 `query`를 받고 있다.
+
+- `src/main/java/com/web/SearchWeb/bookmark/service/command/BookmarkSearchCommand.java`
+- `src/main/resources/mapper/bookmark-mapper.xml`
+
+단, 현재 매퍼 기준 링크 검색은 `display_title` 기준으로만 동작한다.
+
+```sql
+AND LOWER(msl.display_title) LIKE '%' || LOWER(#{query}) || '%'
+```
+
+반면 폴더 목록 API는 현재 검색 전용 파라미터가 없다.
+
+- `frontend/src/lib/api/folderApi.ts`
+
+따라서 1차 구현은 다음 전략이 현실적이다.
+
+- 폴더 검색: 이미 로드된 폴더 목록 기반 클라이언트 필터
+- 우측 링크 검색: 기존 `query` 파라미터 활용
+- 전체검색 1차: 폴더는 클라이언트 필터, 링크는 서버 검색 조합
+
+### 8.2 전체검색 결과 응답 방향
+
+향후 전체검색을 서버 중심으로 고도화할 경우 아래 형태의 응답 구조를 권장한다.
+
+```json
+{
+ "query": "aaa",
+ "folders": [
+ {
+ "folderId": 1,
+ "folderName": "aaa 프로젝트"
+ }
+ ],
+ "links": [
+ {
+ "linkKey": "canonical-or-original-url",
+ "title": "aaa 공식문서",
+ "url": "https://example.com/aaa",
+ "matchedBookmarkIds": [10, 25],
+ "includedFolders": [
+ { "folderId": 3, "folderName": "Frontend" },
+ { "folderId": 7, "folderName": "Study" }
+ ]
+ }
+ ]
+}
+```
+
+### 8.3 링크 식별 기준
+
+링크 중복 묶음 또는 관계 계산 시 권장 우선순위는 다음과 같다.
+
+1. `canonicalUrl`
+2. `originalUrl`
+3. 필요 시 `linkId`
+
+단, 1차 구현에서는 저장 단위가 북마크인 만큼 무리한 통합보다는 "링크 카드에 포함 폴더를 보여주는 방향"을 우선 적용한다.
+
+
+
+## 9. 구현 단계 제안
+
+### 9.1 1단계: 빠른 UX 정착
+
+목표:
+
+- 검색 범위를 명확히 나누고 기본 UX를 안정화
+
+범위:
+
+- 좌측 검색창 범위 토글 추가: `폴더만 | 전체`
+- 좌측 `폴더만` 검색: 로컬 폴더 필터
+- 우측 링크 검색: 현재 폴더 기준 `query` 연동
+- placeholder 및 문구 정리
+
+제외:
+
+- 폴더와 링크를 동시에 묶는 서버 통합 검색 API
+- 복잡한 dedupe 로직
+
+### 9.2 2단계: 전체검색 결과 섹션화
+
+목표:
+
+- 전체검색에서 폴더와 링크를 동시에 보여주는 구조 도입
+
+범위:
+
+- 좌측 검색 결과 영역 추가
+- `Folders` / `Links` 섹션 분리
+- 결과 수 표시
+- `전체 | 폴더 | 링크` 보기 필터 추가 가능
+
+### 9.3 3단계: 포함 폴더 정보 고도화
+
+목표:
+
+- 링크 결과의 저장 위치를 명확히 보여줌
+
+범위:
+
+- 링크 카드 내 `포함 폴더` 배지 표시
+- 폴더 배지 클릭 시 해당 폴더 컨텍스트 이동
+- 폴더 수가 많을 때 축약 및 확장 UX 추가
+
+### 9.4 4단계: 검색 품질 개선
+
+목표:
+
+- 검색 정확도와 탐색 속도 개선
+
+범위:
+
+- 링크 검색 범위를 `displayTitle` 외 `note`, `domain`, `tag`, `link title`까지 확장 검토
+- 폴더 검색 API 또는 통합 검색 API 검토
+- 디바운스, 캐싱, 최근 검색어, 키보드 탐색 추가
+
+
+
+## 10. 프론트엔드 구성 제안
+
+### 10.1 좌측 메인 영역
+
+추가 상태 예시:
+
+- `searchScope: 'folders' | 'all'`
+- `searchQuery: string`
+- `searchView: 'all' | 'folders' | 'links'`
+
+렌더링 흐름:
+
+- 검색어 없음: 기존 폴더 대시보드 유지
+- 검색어 있음 + `폴더만`: 폴더 결과만 표시
+- 검색어 있음 + `전체`: `Folders` / `Links` 결과 섹션 표시
+
+### 10.2 우측 패널
+
+추가 상태 예시:
+
+- `linkSearchQuery: string`
+
+렌더링 흐름:
+
+- 현재 `selectedFolderId` 유지
+- 정렬, 태그 필터와 함께 검색 적용
+- 검색 상태에서도 폴더 컨텍스트를 잃지 않음
+
+**폴더 클릭 시 자동 검색 입력 (신규):**
+
+- 좌측 전체검색의 Folders 섹션에서 폴더를 클릭 시:
+ ```typescript
+ handleFolderClick(folderId) {
+ bookmarkStore.setSelectedFolder(folderId);
+ bookmarkStore.setLinkSearchQuery(currentSearchQuery); // 현재 검색어 자동 입력
+ }
+ ```
+- 우측 패널 링크 검색창에는 `linkSearchQuery` 상태가 반영되어 미리 입력된 상태로 표시
+- 사용자는 X 버튼으로 검색어를 삭제하거나, 새로운 검색어를 직접 입력 가능
+- 다른 폴더를 클릭하면 `linkSearchQuery`는 새로운 검색어로 자동 업데이트됨
+
+
+
+## 11. 이벤트 로깅 제안
+
+검색 기능이 실제로 사용되는지 확인하기 위해 아래 이벤트를 추적한다.
+
+| 이벤트 코드 | 설명 | 우선순위 |
+| :--- | :--- | :--- |
+| `SEARCH_EXECUTE` | 검색 실행 | P0 |
+| `SEARCH_SCOPE_CHANGE` | `폴더만` / `전체` 범위 전환 | P0 |
+| `SEARCH_RESULT_CLICK_FOLDER` | 검색 결과에서 폴더 클릭 (좌측 Folders 섹션) | P0 |
+| `SEARCH_RESULT_CLICK_LINK` | 검색 결과에서 링크 클릭 | P0 |
+| `SEARCH_CONTEXT_AUTO_INPUT` | 폴더 클릭 → 우측 패널 검색어 자동 입력 | P0 |
+| `SEARCH_RESULT_CLICK_INCLUDED_FOLDER` | 링크 카드 내부 포함 폴더 클릭 | P1 |
+| `SEARCH_VIEW_FILTER_CHANGE` | `전체 | 폴더 | 링크` 보기 전환 | P1 |
+
+권장 속성 예시:
+
+```json
+{
+ "query": "aaa",
+ "scope": "folders | all | current_folder_links",
+ "folder_result_count": 2,
+ "link_result_count": 6,
+ "selected_folder_id": 15
+}
+```
+
+
+
+## 12. 검증 포인트
+
+### 12.1 UX 검증
+
+- 사용자가 좌측 검색과 우측 검색의 차이를 직관적으로 이해하는가
+- 전체검색 결과에서 폴더와 링크가 섞이지 않아 이해가 쉬운가
+- 링크 카드의 포함 폴더가 정보 과밀 없이 읽히는가
+
+### 12.2 기능 검증
+
+- `폴더만` 검색 시 폴더만 결과에 노출되는가
+- `전체` 검색 시 폴더와 링크가 모두 노출되는가
+- 우측 검색은 현재 선택된 폴더 범위를 벗어나지 않는가
+- 링크가 여러 폴더에 저장된 경우 포함 폴더가 모두 표시되는가
+
+### 12.3 성능 검증
+
+- 검색 입력 시 불필요한 전체 재렌더가 과도하지 않은가
+- 우측 패널 검색 시 API 호출 빈도가 과도하지 않은가
+- 전체검색 결과 수가 많을 때 레이아웃 붕괴 없이 유지되는가
+
+
+
+## 13. 비목표
+
+이번 계획서 범위에서 바로 포함하지 않는 항목은 다음과 같다.
+
+- 완전한 글로벌 검색 모달 별도 도입
+- 고급 랭킹 알고리즘
+- 자연어 검색
+- 팀 폴더/팀 링크까지 포함한 통합 검색
+- 모바일 전용 별도 검색 UX
+
+
+
+## 14. 결론
+
+`/my-links` 검색 기능은 "한 검색창으로 모든 것을 해결"하기보다, **좌측은 탐색, 우측은 현재 작업 컨텍스트 유지**라는 구조를 살려야 한다.
+
+최종 방향은 다음과 같다.
+
+- 좌측 검색 기본값은 `폴더만 검색`
+- 좌측에서 필요 시 `전체검색`으로 확장
+- 전체검색은 폴더와 링크를 모두 보여주되, 반드시 `Folders` / `Links` 섹션으로 분리
+- 링크 결과에는 `포함 폴더`를 명시적으로 표시
+- 우측 패널 검색은 현재 선택된 폴더 안의 링크 검색으로 유지
+
+이 방향은 현재 코드 구조와도 잘 맞고, 1차 구현부터 단계적으로 확장하기에도 적합하다.
diff --git a/docs/Plan/04. unorganized_folder_introduction_plan.md b/docs/Plan/04. unorganized_folder_introduction_plan.md
new file mode 100644
index 0000000..92bdcd4
--- /dev/null
+++ b/docs/Plan/04. unorganized_folder_introduction_plan.md
@@ -0,0 +1,349 @@
+# 미분류(디폴트) 폴더 도입 계획서
+
+> **문서 정보**
+> - **버전:** v0.1.0 (Draft)
+> - **작성일:** 2026-04-18
+> - **관련 화면:** `/my-links`, `SaveLinkDialog`
+> - **관련 문서:** [SearchWeb PRD](./01.%20searchweb_PRD.md), [My Links 검색 기능 계획서](./03.%20my-links_search_feature_plan.md)
+> - **문서 목적:** "미분류" 개념을 **시스템 폴더(`folder_type='UNORGANIZED'`)** 로 정의하고, 이를 도입하기 위한 데이터 모델·서비스·UI 변경 사항을 정의한다.
+
+
+
+## 1. 개요
+
+`/my-links` 화면 상단에는 `Unorganized` 칩이 존재하지만, 현재 정의·동작이 비어 있다 (`frontend/src/app/my-links/page.tsx:179`).
+
+기존 시스템에서 "미분류"를 정의할 수 있는 방법은 두 가지였다.
+
+- ⓐ **태그 부재 기반** — 해시태그가 비어 있는 링크를 미분류로 간주
+- ⓑ **물리적 시스템 폴더 기반** — `folder_type='UNORGANIZED'` 인 시스템 폴더를 자동 생성하고, 분류되지 않은 링크는 이 폴더에 저장
+
+본 계획서는 사전 인터뷰 결과(`deep-interview` round 4 / 최종 ambiguity 14%)에 따라 **ⓑ 디폴트 폴더 방식**을 채택한다.
+이유:
+- 폴더와 태그가 직교 개념(현재 코드 기준)이라 태그 부재 = 미분류 정의는 의미가 약함
+- 사용자가 미분류 칩을 클릭했을 때 동작을 **기존 폴더 클릭과 동일하게** 통일할 수 있음 (`setSelectedFolderId(defaultFolderId)`)
+- AI 분류 실패/사용자 미선택 케이스 둘 다 같은 위치로 모이므로 회수(回收) 동선이 단순해짐
+
+
+
+## 2. 문제 정의
+
+현재 구조에서 디폴트 폴더 도입을 막고 있는 결손은 다음과 같다.
+
+1. **데이터 모델**: `member_folder` 테이블과 `MemberFolder` 엔티티 모두 폴더 타입(`NORMAL`, `UNORGANIZED` 등)을 식별할 수 있는 필드가 없다.
+2. **자동 생성 시점 부재**: 회원가입(소셜) 흐름(`CustomOAuth2MemberService.loadUser` 73~95행)에서 폴더 생성 호출이 없다. 기존 회원에 대한 백필 경로도 없다.
+3. **불변 정책 부재**: `MemberFolderServiceImpl.delete/move/update` 가 시스템 폴더에 대한 가드 없이 일반 폴더와 동일하게 동작한다.
+4. **저장 경로 분기 부재**: `SaveLinkDialog.handleSave` (`frontend/src/components/dialogs/SaveLinkDialog.tsx:261`) 가 `selectedFolderId === null && !pendingNewFolderName` 일 때 `memberFolderId: null` 을 전송하는데, 이를 받는 백엔드는 폴더 NULL 을 허용하지 않는다(현 스키마: `"member_folder_id" int NOT NULL`). → **폴더 미선택 저장 자체가 불가능**.
+5. **UI**: `Unorganized` 칩에 onClick 미연결, `FolderCard` 메뉴(이름변경/이동/삭제)가 시스템 폴더에서도 동일하게 표시됨.
+
+
+
+## 3. 목표
+
+### 3.1 사용자 목표
+
+- 사용자가 폴더를 정하지 않고도 링크를 저장할 수 있다 (퀵 세이브 경로).
+- 사용자가 `Unorganized` 칩을 누르면 미분류 폴더의 링크 목록을 즉시 볼 수 있다.
+- 사용자는 미분류 폴더를 실수로 삭제·이동할 수 없다 (이름 변경은 허용).
+
+### 3.2 제품 목표
+
+- 시스템 폴더를 데이터 모델 차원에서 일급(first-class)으로 표현한다.
+- 신규/기존 사용자 모두에게 정확히 1개의 미분류 폴더를 보장한다 (idempotent).
+- AI 폴더 추천 실패·미선택 같은 후속 시나리오를 같은 디폴트 폴더로 모은다.
+
+### 3.3 비목표 (이번 이터레이션 제외)
+
+- AI 폴더 추천이 실패했을 때 자동으로 디폴트 폴더로 폴백하는 로직 (Future Section)
+- 폴더 이름의 다국어화 / 사용자 커스텀 (이번에는 한국어 `미분류` 고정)
+- 디폴트 폴더 안의 링크에 대한 "이주" 기능 (다른 폴더로 옮기면 자동 비분류 해제 등)
+
+
+
+## 4. 결정 사항 요약 (인터뷰 산출)
+
+| 항목 | 결정 | 비고 |
+| :--- | :--- | :--- |
+| 미분류 정의 | 시스템 폴더(`folder_type='UNORGANIZED'`)에 보관된 링크 | 태그 부재 정의 폐기 |
+| 마이그레이션 전략 | **회원가입 로직 변경 + 기존 회원 lazy 생성** | 옵션 3 (권장안) |
+| 디폴트 폴더 제약 | 삭제 ✗ / 이름 변경 ✓ / 이동 ✗ / 자식 ✓ | 옵션 B (느슨한) |
+| 저장 정책 | **이번 이터레이션에 Quick Save 포함** | 옵션 2 (권장안) |
+| AI 실패 폴백 | 본 이터레이션 제외 | Future Section |
+
+
+
+## 5. 데이터 모델 변경
+
+### 5.1 `member_folder` 테이블
+
+`src/main/resources/db/init_postgres.sql` (96~113행) 의 정의에 컬럼·제약 추가.
+
+```sql
+-- 1. 폴더 타입 컬럼 추가 (기본값 CUSTOM)
+ALTER TABLE "member_folder"
+ ADD COLUMN IF NOT EXISTS "folder_type" varchar(20) NOT NULL DEFAULT 'CUSTOM';
+
+-- 2. 타입 제약 추가 (향후 TRASH, ARCHIVE 등 확장 가능)
+ALTER TABLE "member_folder"
+ DROP CONSTRAINT IF EXISTS ck_member_folder_type,
+ ADD CONSTRAINT ck_member_folder_type CHECK (folder_type IN ('CUSTOM', 'UNORGANIZED'));
+
+-- 3. 한 사용자당 시스템 폴더(UNORGANIZED)는 정확히 1개만 활성 상태로 존재하도록 유니크 제약
+CREATE UNIQUE INDEX IF NOT EXISTS uq_member_folder_owner_unorganized
+ ON "member_folder" ("owner_member_id")
+ WHERE folder_type = 'UNORGANIZED' AND deleted_at IS NULL;
+
+-- 4. member_saved_link 테이블 정합성 정리 및 FK 추가
+-- (주의: FK 추가 전, member_folder_id가 유효하지 않은 데이터가 있는지 점검 필수)
+ALTER TABLE "member_saved_link"
+ ADD CONSTRAINT fk_member_saved_link_member_folder_id
+ FOREIGN KEY ("member_folder_id") REFERENCES "member_folder"("member_folder_id");
+```
+
+`init_postgres.sql` 본체에도 컬럼/제약/인덱스를 반영하여 신규 환경에서 자동 적용되도록 한다.
+특히 `member_saved_link` 정의 시점에 `member_folder_id`에 대한 FK가 누락되어 있으므로 이를 보강한다.
+
+### 5.2 `MemberFolder` 엔티티
+
+`src/main/java/com/web/SearchWeb/folder/domain/MemberFolder.java`:
+
+- `private String folderType;` 필드 추가 (기본값 `"CUSTOM"`).
+- `isUnorganized()` 편의 메서드 추가.
+- `changeParent()` / `delete*` 같이 자기 자신을 변경하는 도메인 메서드들이 시스템 폴더 위반을 방어할 수 있도록, 메서드 레벨에서 `if (isUnorganized()) throw new BusinessException(FolderErrorCode.SYSTEM_FOLDER_IMMUTABLE);` 가드를 추가한다 (이름 변경 `changeInfo` 는 가드 제외).
+
+### 5.3 `FolderErrorCode` 추가
+
+`src/main/java/com/web/SearchWeb/folder/error/FolderErrorCode.java` 에 다음을 추가:
+
+- `SYSTEM_FOLDER_IMMUTABLE` (HTTP 409, "기본 폴더는 삭제하거나 이동할 수 없습니다.")
+- `DEFAULT_FOLDER_NAME_CONFLICT` (HTTP 409, 이미 같은 이름의 폴더가 있어 디폴트 폴더 자동 생성 실패 시)
+
+
+
+## 6. 백엔드 변경 사항
+
+### 6.1 서비스 인터페이스 확장
+
+`src/main/java/com/web/SearchWeb/folder/service/MemberFolderService.java`:
+
+```java
+/** 미분류 폴더 보장. 없으면 생성, 있으면 기존 ID 반환. idempotent. */
+Long getOrCreateUnorganizedFolderId(Long memberId);
+
+/** 미분류 폴더 단건 조회. 없으면 getOrCreate 호출 후 반환. */
+MemberFolder getDefaultFolder(Long memberId);
+```
+
+### 6.2 `MemberFolderServiceImpl` 변경
+
+- **`getOrCreateUnorganizedFolderId(memberId)`** 신규 구현
+ - `findByOwnerAndFolderType(memberId, "UNORGANIZED")` 로 조회 → 있으면 그 ID 반환.
+ - 없으면 `MemberFolder.builder().ownerMemberId(memberId).folderName("미분류").folderType("UNORGANIZED").build()` 로 저장.
+ - 동시 호출(중복 INSERT)은 5.1의 부분 유니크 인덱스가 막고, `DataIntegrityViolationException` catch 후 재조회로 복구.
+- **`delete(memberId, folderId)`** 가드 추가
+ - 조회 후 `if (folder.isUnorganized()) throw FolderErrorCode.SYSTEM_FOLDER_IMMUTABLE` (현재 자식/북마크 가드 앞단).
+- **`move(memberId, folderId, newParentFolderId)`** 가드 추가
+ - 동일하게 시스템 폴더면 차단.
+- **`update(memberId, ...)`** 는 가드 없음 (이름 변경 허용 — 결정 5.B).
+- **`listRootFolders(memberId, ownerMemberId)`** 끝부분에 lazy 보장 추가
+ - `getOrCreateUnorganizedFolderId(ownerMemberId)` 를 1회 호출하여 기존 회원에게도 자동 백필.
+ - 호출은 idempotent 하므로 매 요청마다 안전. (성능 영향은 7.2 참고)
+
+### 6.3 회원가입 흐름 통합
+
+**소셜 회원가입** — `CustomOAuth2MemberService.loadUser` (73~95행)
+
+```java
+if (existMember == null) {
+ // 기존 코드: insertSocialMember(...)
+ memberDao.insertSocialMember(member);
+ existMember = member;
+ // 신규: 디폴트 폴더 생성. 실패해도 가입 자체는 진행시켜야 하므로 try-catch 후 로그.
+ try {
+ memberFolderService.getOrCreateUnorganizedFolderId(existMember.getMemberId());
+ } catch (Exception e) {
+ log.warn("default folder creation failed (will be lazily created on first listRootFolders): {}", e.getMessage());
+ }
+}
+```
+
+`MemberFolderService` 의존성을 생성자 주입으로 추가. 순환 의존성 방지를 위해 `MemberFolderService` 가 `member` 도메인을 역방향으로 참조하지 않는지 점검 (현재 `MemberFolderServiceImpl` 은 `MemberFolderRepository` 만 사용하므로 안전).
+
+**로컬 회원가입** — 별도 `JoinController` 가 코드베이스에 존재하지 않음 (탐색 결과). 향후 로컬 회원가입을 추가할 때 `MemberServiceImpl` 의 신규 가입 메서드에서 동일하게 호출하도록 가이드만 명시.
+
+**기존 회원 백필** — 6.2의 `listRootFolders` lazy 호출이 담당. 별도 마이그레이션 SQL은 작성하지 않는다.
+
+### 6.4 컨트롤러
+
+`MemberFolderController` 자체에는 변경이 없다 (서비스 가드가 도메인 규칙을 책임짐). 단, 응답 DTO `MemberFolderResponses` 에 `isSystem` 필드를 추가하여 프론트에 노출한다.
+
+```java
+public class MemberFolderResponses {
+ public Long memberFolderId;
+ public Long ownerMemberId;
+ public Long parentFolderId;
+ public String folderName;
+ public String description;
+ public String folderType; // ← 추가
+ public static MemberFolderResponses from(MemberFolder f) { ... }
+}
+```
+
+
+
+## 7. 보안 / 인가 / 성능
+
+### 7.1 보안 / 인가
+
+- **소유권 가드**는 기존 `validateOwner(memberId, folder)` 로직 그대로 유지. `is_system` 은 추가 제약일 뿐 소유권 검사를 대체하지 않는다.
+- **`@OwnerCheck` AOP** 가 적용된 메서드들은 이미 다른 사용자의 폴더 접근을 차단하므로, `getOrCreateUnorganizedFolderId` 도 `memberId` 인자만 신뢰한다 (Controller 진입점으로 주입된 값을 사용).
+- **DELETE/MOVE 우회 시도**: 클라이언트가 `folderType` 응답을 무시하고 강제로 `DELETE /api/folders/{id}` 를 호출해도 6.2의 서비스 가드에서 `409` 로 차단한다. 컨트롤러 레벨에서 별도 검증을 두지 않는다 (단일 진실 공급원: 서비스).
+
+### 7.2 성능
+
+- **`listRootFolders` 마다 ensure 호출**의 비용은 인덱스 룩업 1회(부분 유니크 인덱스 적중) → 정상 케이스에서 무시 가능 수준.
+- 부담이 측정되면 후속 단계에서 다음 두 옵션을 검토:
+ - 옵션 A: 회원가입 시점에만 생성하고, 기존 회원에 대해서는 1회성 마이그레이션 SQL 실행.
+ - 옵션 B: 메모리/Redis 캐시에 "이 회원은 이미 디폴트 폴더 보유" 플래그를 두고, 첫 호출 이후 ensure 스킵.
+- 본 이터레이션은 단순성을 우선하여 옵션 A/B 모두 보류.
+
+### 7.3 트랜잭션 / 동시성
+
+- `getOrCreateUnorganizedFolderId` 는 `@Transactional` 로 감싸고, INSERT 실패 시 재조회(read-after-write) 후 반환.
+- 부분 유니크 인덱스(`uq_member_folder_owner_system`)가 동시 회원가입·동시 첫 호출의 경합을 DB 레벨에서 차단.
+
+
+
+## 8. 프론트엔드 변경 사항
+
+### 8.1 타입 확장
+
+`frontend/src/lib/types/folder.ts` 의 `FolderResponse` 에 `folderType: 'CUSTOM' | 'UNORGANIZED'` 추가.
+
+### 8.2 `Unorganized` 칩 클릭 동작
+
+`frontend/src/app/my-links/page.tsx:179` 의 placeholder 버튼:
+
+```tsx
+const defaultFolder = folders?.find(f => f.folderType === 'UNORGANIZED');
+
+
+```
+
+폴더 카드 클릭과 동일한 동작이므로 별도 분기·별도 패널 없음. 우측 패널은 자동으로 미분류 폴더의 북마크를 조회한다 (`useBookmarks({ folderId })`).
+
+### 8.3 폴더 카드 메뉴 가드
+
+`frontend/src/components/my-links/FolderCard.tsx`:
+
+- 시스템 폴더이면 메뉴 자체를 숨길지, "Move/Delete" 항목만 비활성화할지 결정. 결정 5.B(이름 변경 허용)에 따라 **메뉴는 표시하되 Move/Delete 항목만 disabled** 로 처리 (`folderType === 'UNORGANIZED'`).
+- 카드 좌상단 아이콘에 미분류 표식(예: `inventory_2`) 추가하여 시각적으로 구분.
+
+### 8.4 폴더 목록 정렬
+
+`useFolders` 응답을 화면에 표시할 때 디폴트 폴더는 항상 **목록 최상단** 또는 **별도 섹션**에 위치하도록 정렬 (UX 결정은 디자인 단계에서 확정).
+일단 1차에서는 "최상단 고정" 으로 단순 구현.
+
+### 8.5 Quick Save 경로 (결정 5.옵션 2)
+
+`frontend/src/components/dialogs/SaveLinkDialog.tsx`:
+
+- `handleSave` (261행) 에서 `selectedFolderId === null && !pendingNewFolderName` 인 경우, 디폴트 폴더 ID로 저장하도록 보강.
+ ```tsx
+ const targetFolderId = selectedFolderId
+ ?? folders?.find(f => f.folderType === 'UNORGANIZED')?.memberFolderId
+ ?? null;
+ ```
+- "Save to Workspace" 버튼 옆 또는 메인 버튼에 `Quick Save` 라벨/툴팁 추가 (디자인 단에서 라벨 확정).
+- `useBookmarks`/`useCreateBookmark` 등 API 호출은 변경 없음. 백엔드는 `member_folder_id` NOT NULL 제약을 유지하면서 디폴트 폴더 ID를 받는다.
+
+### 8.6 RightPanel 빈 상태
+
+`frontend/src/components/my-links/RightPanel.tsx:329` 의 `'Unordered'` 폴백 라벨 제거 또는 디폴트 폴더에 한해 "AI 분류 대기 중인 링크" 같은 안내 카피로 대체 (선택 사항).
+
+
+
+## 9. 구현 순서
+
+### 9.1 1단계 — DB / 도메인 (백엔드 단독 머지 가능)
+
+1. `init_postgres.sql` 에 컬럼·인덱스 반영 + 마이그레이션 스크립트 작성.
+2. `MemberFolder` 엔티티 + Repository 메서드(`findByOwnerMemberIdAndIsSystemTrue`) 추가.
+3. `FolderErrorCode` 신규 코드 추가.
+
+### 9.2 2단계 — 서비스 레이어
+
+4. `MemberFolderService#getOrCreateUnorganizedFolderId`, `getDefaultFolder` 추가.
+5. `MemberFolderServiceImpl` 의 delete/move 가드 추가.
+6. `listRootFolders` 끝에서 `getOrCreateUnorganizedFolderId` 호출.
+7. **단위 테스트**:
+ - `getOrCreateUnorganizedFolderId` 가 idempotent 한가
+ - 시스템 폴더 delete/move 차단
+ - 시스템 폴더 update(이름 변경) 허용
+ - 동시 호출 시 단일 폴더만 생성됨 (부분 유니크 인덱스 검증)
+
+### 9.3 3단계 — 회원가입 통합
+
+8. `CustomOAuth2MemberService` 에 `MemberFolderService` 의존성 주입 + `getOrCreateUnorganizedFolderId` 호출.
+9. **통합 테스트**: 신규 OAuth 가입 → 폴더 목록 조회 시 미분류 폴더 1건 보장.
+
+### 9.4 4단계 — API 계약
+
+10. `MemberFolderResponses.isSystem` 필드 추가.
+11. 프론트 타입 `FolderResponse.folderType` 추가.
+
+### 9.5 5단계 — 프론트엔드 UI
+
+12. `Unorganized` 칩 onClick 연결 (`page.tsx:179`).
+13. `FolderCard` 메뉴 가드 + 시각적 표식.
+14. 디폴트 폴더 최상단 고정 정렬.
+15. `SaveLinkDialog.handleSave` 의 폴더 미선택 → 디폴트 폴더 fallback.
+
+### 9.6 6단계 — 검증 / QA
+
+16. E2E: 신규 가입 → 링크 저장(폴더 미선택) → My Links 의 Unorganized 칩 클릭 → 방금 저장한 링크 노출 확인.
+17. 기존 회원: 첫 `/my-links` 진입 → 미분류 폴더 자동 생성 확인.
+18. 디폴트 폴더 삭제·이동 시 409 반환 / 토스트 메시지 확인.
+19. 동시 가입(부하 테스트) 시 디폴트 폴더 중복 생성 없음 확인.
+
+
+
+## 10. 위험 및 완화
+
+| 위험 | 영향 | 완화책 |
+| :--- | :--- | :--- |
+| 동일 사용자가 같은 이름("미분류") 폴더를 이미 만들어 둠 | `getOrCreateUnorganizedFolderId` 의 INSERT 가 `uq_member_folder_parent_name` 위반 | catch 후 해당 폴더의 `folder_type`을 `UNORGANIZED`로 업데이트하여 흡수, 또는 시스템 폴더 이름을 `미분류 (기본)` 로 회피. 1차에는 흡수 채택. |
+| 데이터 기존 오염 가능성 | `member_saved_link` 에 존재하지 않는 폴더 ID가 있으면 FK 추가 시 에러 발생 | 마이그레이션 SQL 실행 전 `SELECT ... WHERE member_folder_id NOT IN (SELECT ...)` 로 전수 조사 및 유효한 폴더(UNORGANIZED 등)로 업데이트 필요 |
+| `listRootFolders` 마다 ensure 호출 → 트랜잭션 비용 | RPS 높을 때 약간의 부하 증가 | 7.2 의 옵션 A/B 로 후속 최적화 |
+| 사용자가 "미분류" 라는 이름을 일반 폴더에 쓰고 싶어함 | 명명 충돌 혼란 | 시스템 폴더는 `is_system` 으로만 식별, 일반 폴더 이름은 자유. UI에서 시스템 폴더에 별도 아이콘 표기 |
+| AI 분류 실패 시 별도 대응 부재 (이번 이터레이션) | UX 미세 결손 | Future Section: AI 실패 시 디폴트 폴더로 자동 fallback |
+
+
+
+## 11. Future Section
+
+- **AI 폴더 추천 실패 시 자동 폴백**: `BookmarkService.create` 가 폴더 ID 미결정 상태로 호출되면 디폴트 폴더로 흡수하고 별도 플래그(`category_source='system'` 등)로 회수 가능하게 표시.
+- **다국어화**: `미분류` 라는 폴더명을 지역 설정에 따라 표시 (백엔드 저장 이름은 고정, 프론트에서 i18n 변환).
+- **자동 정리**: 디폴트 폴더 안의 링크 중 일정 시간이 지난 것을 사용자에게 분류 제안하는 inbox-like UX.
+
+
+
+## 12. 결론
+
+본 계획서는 "미분류" 의 정의를 **`is_system=true` 디폴트 폴더 1개** 로 일원화하고, 그 도입을 다음 5단계로 진행한다.
+
+- **데이터 모델**: `folder_type` 컬럼 + 부분 유니크 인덱스 + `member_saved_link` FK 추가
+- **서비스**: `getOrCreateUnorganizedFolderId` (idempotent) + delete/move 가드 (타입 기반 식별)
+- **회원가입 통합**: OAuth 가입 시 즉시 생성, 기존 회원은 `listRootFolders` 에서 lazy 백필
+- **API/타입**: `folderType` 필드 노출
+- **UI**: `Unorganized` 칩 onClick + `FolderCard` 메뉴 가드 + `SaveLinkDialog` Quick Save 폴백 (타입 기반 식별)
+
+각 단계가 독립적으로 머지 가능하며, 1~3단계 머지 후 4~5단계가 그 위에 안전하게 빌드된다.
diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css
index 6ef3513..5f3e631 100644
--- a/frontend/src/app/globals.css
+++ b/frontend/src/app/globals.css
@@ -165,6 +165,9 @@
* {
@apply border-border outline-ring/50;
}
+ button, [role="button"] {
+ @apply cursor-pointer;
+ }
body {
/* bg, text is handled via layout.tsx classes directly */
}
diff --git a/frontend/src/app/my-links/page.tsx b/frontend/src/app/my-links/page.tsx
index 8e6db92..2fa9482 100644
--- a/frontend/src/app/my-links/page.tsx
+++ b/frontend/src/app/my-links/page.tsx
@@ -4,21 +4,54 @@ import { FolderCard } from '@/components/my-links/FolderCard';
import { RightPanel } from '@/components/my-links/RightPanel';
import { useUIStore } from '@/lib/store/uiStore';
import { useFolders } from '@/lib/api/folderApi';
-import { useFolderStore } from '@/lib/store/folderStore';
+import { useFolderStore, type SearchScope } from '@/lib/store/folderStore';
import { useAuthStore } from '@/lib/store/authStore';
import { SortDropdown, SortOption } from '@/components/ui/SortDropdown';
-import { useState } from 'react';
-
-const PINNED_COLORS = ['bg-blue-600/90', 'bg-indigo-500/90', 'bg-teal-500/90', 'bg-amber-500/90', 'bg-emerald-600/90'];
+import { useBookmarks } from '@/lib/api/bookmarkApi';
+import { useLinkStore } from '@/lib/store/linkStore';
+import { FOLDER_TYPE } from '@/lib/types/folder';
+import { useState, useEffect, useMemo } from 'react';
+import { cn } from '@/lib/utils';
+import { compareFolders } from '@/lib/utils/folderUtils';
export default function MyLinksPage() {
const { toggleRightPanel } = useUIStore();
const setSelectedFolderId = useFolderStore((s) => s.setSelectedFolderId);
- const memberId = useAuthStore((s) => s.member?.memberId);
- const isAuthInitializing = useAuthStore((s) => s.isInitializing);
- const { data: folders, isLoading: isFoldersLoading, error } = useFolders(memberId);
-
- // 인증 정보를 복구 중이거나 아직 폴더 목록을 가져오는 중이면 로딩 상태로 간주
+ const selectedFolderId = useFolderStore((s) => s.selectedFolderId); // 현재 선택된 폴더 ID
+ const searchQuery = useFolderStore((s) => s.searchQuery); // 폴더 검색어 상태
+ const setSearchQuery = useFolderStore((s) => s.setSearchQuery); // 폴더 검색어 변경 함수
+ const searchScope = useFolderStore((s) => s.searchScope); // 검색 범위 ('folders' | 'all')
+ const setSearchScope = useFolderStore((s) => s.setSearchScope); // 검색 범위 변경 함수
+ const savedTodayFilter = useFolderStore((s) => s.savedTodayFilter); // '오늘 저장' 필터 상태
+ const toggleSavedTodayFilter = useFolderStore((s) => s.toggleSavedTodayFilter); // 필터 토글 함수
+ const unreadFilter = useFolderStore((s) => s.unreadFilter); // 'Unread' 필터 상태 (view_count = 0)
+ const toggleUnreadFilter = useFolderStore((s) => s.toggleUnreadFilter); // Unread 필터 토글 함수
+ const unorganizedFilter = useFolderStore((s) => s.unorganizedFilter); // 미분류 필터 상태
+ const toggleUnorganizedFilter = useFolderStore((s) => s.toggleUnorganizedFilter); // 미분류 필터 토글 함수
+ const memberId = useAuthStore((s) => s.member?.memberId); // 로그인된 사용자 ID
+ const isAuthInitializing = useAuthStore((s) => s.isInitializing); // 인증 세션 복구 중 여부
+
+ // 검색어 디바운스 처리를 위한 로컬 상태
+ const [localSearchQuery, setLocalSearchQuery] = useState(searchQuery);
+
+ // 외부(스토어)에서 검색어가 직접 변경될 경우(예: 초기화 버튼) 로컬 상태와 동기화
+ useEffect(() => {
+ setLocalSearchQuery(searchQuery);
+ }, [searchQuery]);
+
+ // 디바운스 로직: localSearchQuery가 변경되면 250ms 후에 스토어 업데이트
+ useEffect(() => {
+ const handler = setTimeout(() => {
+ if (localSearchQuery !== searchQuery) {
+ setSearchQuery(localSearchQuery);
+ }
+ }, 250);
+ return () => clearTimeout(handler);
+ }, [localSearchQuery, searchQuery, setSearchQuery]);
+
+ const { data: folders, isLoading: isFoldersLoading, error } = useFolders(memberId); // 전체 폴더 목록 조회
+
+ // 인증 또는 폴더 로딩 중
const isLoading = isAuthInitializing || isFoldersLoading;
const pinnedFolders = folders?.slice(0, 5) ?? [];
@@ -29,6 +62,162 @@ export default function MyLinksPage() {
{ id: 'count', label: 'Link Count', icon: 'list_alt' }
];
+ const scopeOptions: SortOption[] = [
+ { id: 'all', label: 'All', icon: 'filter_alt' },
+ { id: 'folders', label: 'Folders', icon: 'folder' },
+ { id: 'links', label: 'Links', icon: 'link' },
+ ];
+
+ // 상단 고정 폴더 배지 클릭 처리
+ const handleFolderBadgeClick = (folderId: number) => {
+ const currentSelectedId = useFolderStore.getState().selectedFolderId;
+ if (currentSelectedId === folderId) {
+ setSelectedFolderId(null);
+ } else {
+ setSelectedFolderId(folderId); // 선택된 폴더 변경
+
+ // 선택 시에만 수행: 현재 폴더 검색어를 링크 검색어로 동기화
+ if (searchQuery) {
+ useLinkStore.getState().setSearchQuery(searchQuery);
+ }
+
+ // 선택 시에만 수행: 우측 패널 열기
+ toggleRightPanel(true);
+ }
+ };
+
+ // 검색 범위가 '전체' 또는 '링크'로 변경될 때 폴더 선택 해제
+ useEffect(() => {
+ if (searchScope === 'all' || searchScope === 'links') {
+ setSelectedFolderId(null);
+ }
+ }, [searchScope, setSelectedFolderId]);
+
+ // 검색어 동기화: 검색 범위가 '전체' 또는 '링크'일 때만 링크 스토어와 검색어 연동
+ useEffect(() => {
+ if (searchScope === 'all' || searchScope === 'links') {
+ const currentLinkQuery = useLinkStore.getState().filters.searchQuery;
+ if (currentLinkQuery !== searchQuery) {
+ useLinkStore.getState().setSearchQuery(searchQuery);
+ }
+ }
+ }, [searchQuery, searchScope]);
+
+ // 검색 필터링 로직
+ const isSearching = searchQuery.trim().length > 0;
+
+ // 미분류 폴더 ID 찾기
+ const unorganizedFolder = folders?.find(f => f.folderType === FOLDER_TYPE.UNORGANIZED);
+ const unorganizedFolderId = unorganizedFolder?.memberFolderId;
+
+ // 북마크 데이터 조회 조건 결정:
+ // 1. 전체 또는 링크 범위 검색 중이거나
+ // 2. 오늘 저장 필터가 활성화된 경우
+ // 3. Unread 필터가 활성화된 경우
+ // 4. 미분류 필터가 활성화된 경우 서버에서 북마크 정보를 가져옴
+ // 단, 미분류 필터가 활성화되었지만 아직 폴더 정보(unorganizedFolderId)를 불러오지 못한 경우 요청을 보류합니다.
+ const shouldFetchBookmarks =
+ ((searchScope === 'all' || searchScope === 'links') && isSearching) ||
+ savedTodayFilter ||
+ unreadFilter ||
+ (unorganizedFilter && unorganizedFolderId != null);
+
+ const bookmarkParams: Parameters[0] = {};
+ if (isSearching && (searchScope === 'all' || searchScope === 'links')) bookmarkParams.query = searchQuery;
+ if (unreadFilter) bookmarkParams.unreadOnly = true;
+ if (savedTodayFilter) bookmarkParams.savedTodayOnly = true;
+ if (unorganizedFilter && unorganizedFolderId) bookmarkParams.folderId = unorganizedFolderId;
+ const { data: allBookmarks } = useBookmarks(
+ bookmarkParams,
+ { enabled: shouldFetchBookmarks }
+ );
+
+ // 북마크 데이터로부터 필터링에 필요한 ID 집합(Set) 추출 (O(N))
+ // matchingFolderIdsSet은 백엔드에서 정확하게 필터링된 폴더들
+ const matchingFolderIdsSet = new Set(allBookmarks?.matchingFolderIds ?? []);
+
+ // --- Folder Logic (Filtering & Sorting) ---
+
+ // 1. 검색/필터링 결과 폴더 목록 메모이제이션
+ const processedSearchResultFolders = useMemo(() => {
+ if (!folders) return [];
+
+ // 필터링 로직 수행
+ let filtered = isSearching
+ ? folders.filter((folder) => {
+ const queryLower = searchQuery.toLowerCase();
+
+ // 검색 범위에 따른 매칭 확인
+ const matchFolderName = (searchScope === 'all' || searchScope === 'folders')
+ && folder.folderName.toLowerCase().includes(queryLower);
+
+ const matchBookmark = (searchScope === 'all' || searchScope === 'links')
+ && matchingFolderIdsSet.has(folder.memberFolderId);
+
+ return matchFolderName || matchBookmark;
+ })
+ : [...folders];
+
+ // 추가 필터링 (오늘 저장, 안 읽음, 미분류)
+ if (savedTodayFilter) {
+ filtered = filtered.filter((f) => matchingFolderIdsSet.has(f.memberFolderId));
+ }
+ if (unreadFilter) {
+ filtered = filtered.filter((f) => matchingFolderIdsSet.has(f.memberFolderId));
+ }
+ if (unorganizedFilter) {
+ filtered = filtered.filter((f) => f.folderType === FOLDER_TYPE.UNORGANIZED);
+ }
+
+ // 최종 정렬 적용
+ return [...filtered].sort((a, b) => compareFolders(a, b, folderSort));
+ }, [folders, isSearching, searchQuery, searchScope, matchingFolderIdsSet, savedTodayFilter, unreadFilter, unorganizedFilter, folderSort]);
+
+ // 2. 대시보드용 전체 폴더 목록 메모이제이션 (정렬만 적용)
+ const processedAllFolders = useMemo(() => {
+ return (folders ?? []).slice().sort((a, b) => compareFolders(a, b, folderSort));
+ }, [folders, folderSort]);
+
+ const searchResultFolderCount = processedSearchResultFolders.length;
+ const matchedLinksCount = allBookmarks?.bookmarks?.length ?? 0;
+
+ // 헤더 메시지 생성
+ let headerLabel = '';
+ if (unorganizedFilter) {
+ headerLabel = `Unorganized: ${searchResultFolderCount} folders found`;
+ } else if (unreadFilter) {
+ headerLabel = `Unread: ${searchResultFolderCount} folders found`;
+ } else if (savedTodayFilter) {
+ headerLabel = `Saved Today: ${searchResultFolderCount} folders found`;
+ } else if (searchScope === 'all') {
+ headerLabel = `${searchResultFolderCount} folders & ${matchedLinksCount} links found`;
+ } else if (searchScope === 'links') {
+ headerLabel = `${matchedLinksCount} links found in ${searchResultFolderCount} folders`;
+ } else {
+ headerLabel = `${searchResultFolderCount} folders found`;
+ }
+
+ // 필터 또는 검색 활성화 시 결과 표시
+ // 대시보드 대신 검색 결과 화면을 보여줄지 여부 (검색 중이거나 필터 활성화 시)
+ const isShowingFiltered = isSearching || savedTodayFilter || unreadFilter || unorganizedFilter;
+ const emptyFilteredMessage = (() => {
+ if (unreadFilter && savedTodayFilter) return 'No unread links saved today';
+ if (unorganizedFilter && savedTodayFilter) return 'No unorganized links saved today';
+ if (unreadFilter) return 'No unread links';
+ if (savedTodayFilter) return 'No links saved today';
+ if (unorganizedFilter) return 'No unorganized links';
+ if (isSearching) return 'No results match your search';
+ return 'No matching folders';
+ })();
+
+ const PINNED_COLORS = [
+ 'bg-gradient-to-br from-indigo-500 to-purple-600',
+ 'bg-gradient-to-br from-blue-500 to-cyan-500',
+ 'bg-gradient-to-br from-emerald-500 to-teal-600',
+ 'bg-gradient-to-br from-rose-500 to-pink-600',
+ 'bg-gradient-to-br from-amber-500 to-orange-600'
+ ];
+
return (
{/* Main Content Area */}
@@ -37,19 +226,64 @@ export default function MyLinksPage() {
{/* Top Search & Filter Section */}