From 8d7b6f9b5c84ca5c6604ac972d1d508a8e9488e9 Mon Sep 17 00:00:00 2001 From: jin2304 Date: Sun, 19 Apr 2026 22:05:31 +0900 Subject: [PATCH 01/16] =?UTF-8?q?feat:=20=EB=AF=B8=EB=B6=84=EB=A5=98=20?= =?UTF-8?q?=EC=8B=9C=EC=8A=A4=ED=85=9C=20=ED=8F=B4=EB=8D=94=20=EB=8F=84?= =?UTF-8?q?=EC=9E=85=20=EB=B0=8F=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=AC=B4?= =?UTF-8?q?=EA=B2=B0=EC=84=B1=20=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - init_postgres.sql: folder_type 컬럼 추가 및 시스템 폴더 유니크 제약 설정 - MemberFolder.java: folderType 필드 및 markAsUnorganized 메서드 추가 - MemberFolderServiceImpl.java: 미분류 폴더 자동 생성 및 보장 로직 구현 - CustomOAuth2MemberService.java: 소셜 가입 시 기본 폴더 생성 연동 - FolderErrorCode.java: 시스템 폴더 관련 에러 코드 추가 - MemberFolderResponses.java: DTO에 folderType 필드 추가 - FolderCard.tsx: 시스템 폴더 제어 제한 및 UI 반영 --- .../src/components/my-links/FolderCard.tsx | 23 ++++++- frontend/src/lib/types/folder.ts | 3 + .../controller/dto/MemberFolderResponses.java | 2 + .../folder/dao/MemberFolderJpaDao.java | 8 +++ .../SearchWeb/folder/domain/FolderType.java | 9 +++ .../SearchWeb/folder/domain/MemberFolder.java | 21 ++++++ .../folder/error/FolderErrorCode.java | 4 +- .../folder/service/MemberFolderService.java | 7 +- .../service/MemberFolderServiceImpl.java | 65 ++++++++++++++++++- .../service/CustomOAuth2MemberService.java | 15 ++++- src/main/resources/db/init_postgres.sql | 9 ++- 11 files changed, 157 insertions(+), 9 deletions(-) create mode 100644 src/main/java/com/web/SearchWeb/folder/domain/FolderType.java diff --git a/frontend/src/components/my-links/FolderCard.tsx b/frontend/src/components/my-links/FolderCard.tsx index f7b78a0..881b40c 100644 --- a/frontend/src/components/my-links/FolderCard.tsx +++ b/frontend/src/components/my-links/FolderCard.tsx @@ -4,6 +4,7 @@ import React, { useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import type { FolderResponse } from '@/lib/types/folder'; import { useFolderStore } from '@/lib/store/folderStore'; +import { useLinkStore } from '@/lib/store/linkStore'; import { FolderEditModal, FolderDeleteModal, FolderMoveModal } from './FolderManagementModals'; interface FolderCardProps { @@ -15,8 +16,12 @@ const MENU_OFFSET_Y = 4; // 버튼과 메뉴 사이의 상하 간격 export function FolderCard({ folder, color }: FolderCardProps) { const setSelectedFolderId = useFolderStore((s) => s.setSelectedFolderId); + const searchQuery = useFolderStore((s) => s.searchQuery); + const setLinkSearchQuery = useLinkStore((s) => s.setSearchQuery); const [showMenu, setShowMenu] = useState(false); // 드롭다운 메뉴 표시 여부 const [modalType, setModalType] = useState<'edit' | 'delete' | 'move' | null>(null); // 현재 열린 모달 타입 + // 시스템 폴더(미분류)는 Move/Delete 불가 (Rename 만 허용) + const isSystemFolder = folder.folderType === 'UNORGANIZED'; // DOM 참조 (메뉴 외부 클릭 감지 및 위치 계산용) const menuRef = useRef(null); @@ -71,7 +76,13 @@ export function FolderCard({ folder, color }: FolderCardProps) { // 카드 클릭 시 폴더 선택 처리 (메뉴가 열려있지 않을 때만) const handleCardClick = () => { if (!showMenu) { + // 선택된 폴더 ID 변경 -> 우측 패널 데이터 갱신 트리거 setSelectedFolderId(folder.memberFolderId); + + // 기존 폴더 검색어가 있다면 링크 검색어로 전달 (경험 최적화) + if (searchQuery) { + setLinkSearchQuery(searchQuery); + } } }; @@ -84,6 +95,10 @@ export function FolderCard({ folder, color }: FolderCardProps) { // 메뉴 항목 클릭 시 모달 열기 및 메뉴 닫기 const handleMenuItemClick = (type: 'edit' | 'delete' | 'move', e: React.MouseEvent) => { e.stopPropagation(); + // 시스템 폴더는 Move/Delete 를 허용하지 않음 + if (isSystemFolder && (type === 'move' || type === 'delete')) { + return; + } setModalType(type); setShowMenu(false); }; @@ -103,7 +118,7 @@ export function FolderCard({ folder, color }: FolderCardProps) { className={`bg-white dark:bg-card-dark rounded-lg p-2.5 border border-gray-200/70 dark:border-white/5 shadow-sm hover:shadow-md hover:border-purple-300 dark:hover:border-white/10 transition-all duration-300 group cursor-pointer h-[90px] flex flex-col justify-between focus:ring-1 focus:ring-purple-300 dark:focus:ring-purple-500 outline-none hover:bg-purple-50/30 dark:hover:bg-white/[0.03] relative overflow-visible ${color || ''}`} >
-
+
folder_open
@@ -148,7 +163,8 @@ export function FolderCard({ folder, color }: FolderCardProps) {
diff --git a/frontend/src/lib/api/bookmarkApi.ts b/frontend/src/lib/api/bookmarkApi.ts index 437ff44..f4ac6e3 100644 --- a/frontend/src/lib/api/bookmarkApi.ts +++ b/frontend/src/lib/api/bookmarkApi.ts @@ -2,6 +2,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { fetchClient } from './fetchClient'; import type { BookmarkResponse, + BookmarkSearchResponse, BookmarkSearchParams, CreateBookmarkRequest, UpdateBookmarkRequest, @@ -11,7 +12,7 @@ import type { * 북마크(링크) 목록 조회 (GET /api/bookmarks) * @param params 검색 필터 (폴더 ID, 정렬, 검색어 등) */ -async function fetchBookmarks(params: BookmarkSearchParams): Promise { +async function fetchBookmarks(params: BookmarkSearchParams): Promise { const searchParams = new URLSearchParams(); if (params.folderId != null) searchParams.set('folderId', String(params.folderId)); if (params.sort) searchParams.set('sort', params.sort); @@ -19,7 +20,7 @@ async function fetchBookmarks(params: BookmarkSearchParams): Promise(`/api/bookmarks${qs ? `?${qs}` : ''}`); + return fetchClient(`/api/bookmarks${qs ? `?${qs}` : ''}`); } /** @@ -71,10 +72,11 @@ async function analyzeUrl(url: string): Promise { /** * [조회 Hook] 북마크 목록을 가져오고 캐싱합니다. */ -export function useBookmarks(params: BookmarkSearchParams) { +export function useBookmarks(params: BookmarkSearchParams, options?: { enabled?: boolean }) { return useQuery({ queryKey: ['bookmarks', params], // 검색 조건이 바뀌면 새로운 쿼리로 취급 queryFn: () => fetchBookmarks(params), + enabled: options?.enabled, }); } diff --git a/frontend/src/lib/store/folderStore.ts b/frontend/src/lib/store/folderStore.ts index 6fb6494..d859f1f 100644 --- a/frontend/src/lib/store/folderStore.ts +++ b/frontend/src/lib/store/folderStore.ts @@ -2,11 +2,22 @@ import { create } from 'zustand'; interface FolderStore { selectedFolderId: number | null; + searchScope: 'folders' | 'all'; + searchQuery: string; + setSelectedFolderId: (id: number | null) => void; + setSearchScope: (scope: 'folders' | 'all') => void; + setSearchQuery: (query: string) => void; + clearSearch: () => void; } export const useFolderStore = create((set) => ({ - selectedFolderId: null, + selectedFolderId: null, // 현재 선택된 폴더 (우측 패널에 표시됨) + searchScope: 'all', // 검색 범위 ('folders': 폴더명만, 'all': 북마크 포함) + searchQuery: '', // 폴더 목록 필터링용 검색어 setSelectedFolderId: (id) => set({ selectedFolderId: id }), + setSearchScope: (scope) => set({ searchScope: scope }), + setSearchQuery: (query) => set({ searchQuery: query }), + clearSearch: () => set({ searchQuery: '', searchScope: 'all' }), })); diff --git a/frontend/src/lib/store/linkStore.ts b/frontend/src/lib/store/linkStore.ts index e4e5466..93448aa 100644 --- a/frontend/src/lib/store/linkStore.ts +++ b/frontend/src/lib/store/linkStore.ts @@ -23,6 +23,7 @@ export const useLinkStore = create((set) => ({ sortType: 'latest', }, + // 검색어 상태 업데이트 (다른 컴포넌트 자동 반영) setSearchQuery: (query) => set((state) => ({ filters: { ...state.filters, searchQuery: query } })), diff --git a/frontend/src/lib/types/bookmark.ts b/frontend/src/lib/types/bookmark.ts index 6ff1a7a..b50ba4d 100644 --- a/frontend/src/lib/types/bookmark.ts +++ b/frontend/src/lib/types/bookmark.ts @@ -26,6 +26,12 @@ export interface BookmarkResponse { updatedAt: string | null; } +// 검색 결과 응답 타입 +export interface BookmarkSearchResponse { + bookmarks: BookmarkResponse[]; + matchingFolderIds: number[]; +} + // POST /api/bookmarks 요청 바디 export interface CreateBookmarkRequest { memberFolderId?: number | null; From cddfad45d441f8898563a0a83e8211eef63c1af5 Mon Sep 17 00:00:00 2001 From: jin2304 Date: Sun, 19 Apr 2026 22:40:54 +0900 Subject: [PATCH 04/16] =?UTF-8?q?feat(frontend):=20=ED=8F=B4=EB=8D=94=20?= =?UTF-8?q?=EC=95=84=ED=82=A4=ED=85=8D=EC=B2=98=20=EC=A0=95=EA=B5=90?= =?UTF-8?q?=ED=99=94=20=EB=B0=8F=20=EC=9D=B8=ED=84=B0=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=20=EA=B8=80=EB=A1=9C=EB=B2=8C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - page.tsx: 인터페이스 글로벌화 수행: My-Links 페이지 내 모든 한국어 레이블을 영문(Recently Added 등)으로 변환하여 시각적 전문성 및 서비스 확장성 확보. (Saved today 관련 로직 제외) - page.tsx: 데이터 정렬 및 시각적 계층화 최적화: 미분류(Unorganized) 시스템 폴더를 목록 최상단에 고정 배치하고, 다른 폴더들을 최신순/이름순으로 정렬하는 직관적인 레이아웃 로직 구현. - 04. unorganized_folder_introduction_plan.md: 시스템 폴더 아키텍처 설계 문서화: 미분류 폴더 도입 배경, DB 유니크 제약 사항 및 시스템 자동 생성 로직에 대한 상세 기술 명세 수립. --- ...4. unorganized_folder_introduction_plan.md | 349 ++++++++++++++++++ frontend/src/app/my-links/page.tsx | 200 ++++++++-- 2 files changed, 516 insertions(+), 33 deletions(-) create mode 100644 docs/Plan/04. unorganized_folder_introduction_plan.md 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/my-links/page.tsx b/frontend/src/app/my-links/page.tsx index 8e6db92..c454dc1 100644 --- a/frontend/src/app/my-links/page.tsx +++ b/frontend/src/app/my-links/page.tsx @@ -4,23 +4,41 @@ 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 { useBookmarks } from '@/lib/api/bookmarkApi'; import { useFolderStore } from '@/lib/store/folderStore'; +import { useLinkStore } from '@/lib/store/linkStore'; import { useAuthStore } from '@/lib/store/authStore'; import { SortDropdown, SortOption } from '@/components/ui/SortDropdown'; -import { useState } from 'react'; - +import { useState, useEffect } 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']; + 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 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 memberId = useAuthStore((s) => s.member?.memberId); // 로그인된 사용자 ID + const isAuthInitializing = useAuthStore((s) => s.isInitializing); // 인증 세션 복구 중 여부 + const { data: folders, isLoading: isFoldersLoading, error } = useFolders(memberId); // 전체 폴더 목록 조회 + + // 인증 또는 폴더 로딩 중 const isLoading = isAuthInitializing || isFoldersLoading; - const pinnedFolders = folders?.slice(0, 5) ?? []; + // 시스템 폴더(미분류)는 Pinned 섹션에 섞지 않는다 + const pinnedFolders = (folders ?? []).filter((f) => f.folderType !== 'UNORGANIZED').slice(0, 5); + const unorganizedFolder = folders?.find((f) => f.folderType === 'UNORGANIZED'); + + const [pendingQuery, setPendingQuery] = useState(''); // 입력 중인 검색어 + + // 검색어 디바운스 처리 (이슈 M1 해결) + useEffect(() => { + const handler = setTimeout(() => { + setSearchQuery(pendingQuery); + }, 250); + + return () => clearTimeout(handler); + }, [pendingQuery, setSearchQuery]); const [folderSort, setFolderSort] = useState('recently'); const folderSortOptions: SortOption[] = [ @@ -29,6 +47,44 @@ export default function MyLinksPage() { { id: 'count', label: 'Link Count', icon: 'list_alt' } ]; + // 상단 고정 폴더 배지 클릭 처리 + const handleFolderBadgeClick = (folderId: number) => { + setSelectedFolderId(folderId); // 선택된 폴더 변경 + useLinkStore.getState().setSearchQuery(searchQuery); // 현재 폴더 검색어를 링크 검색어로 동기화 + }; + + // 검색 필터링 로직 + const isSearching = searchQuery.trim().length > 0; + + // 북마크 데이터 조회 조건 결정: + // 1. 전체 범위 검색 중이거나 + // 2. 오늘 저장 필터가 활성화된 경우 서버에서 북마크 정보를 가져옴 + const shouldFetchBookmarks = (searchScope === 'all' && isSearching); + const { data: allBookmarks } = useBookmarks( + shouldFetchBookmarks ? (isSearching && searchScope === 'all' ? { query: searchQuery } : {}) : {}, + { enabled: shouldFetchBookmarks } + ); + + // 북마크 데이터로부터 필터링에 필요한 ID 집합(Set) 추출 (O(N)) + const matchingFolderIdsSet = new Set(allBookmarks?.matchingFolderIds ?? []); + + // 폴더 필터링: 폴더명 매칭 또는 백엔드에서 판별된 매칭 폴더 확인 (O(M)) + let searchResultFolders = isSearching + ? (folders ?? []).filter((folder) => { + const queryLower = searchQuery.toLowerCase(); + // 1. 폴더명 자체에 검색어가 포함된 경우 + if (folder.folderName.toLowerCase().includes(queryLower)) return true; + // 2. 검색 범위가 '전체'인 경우, 백엔드가 알려준 '매칭 북마크 포함 폴더'인지 확인 + return searchScope === 'all' && matchingFolderIdsSet.has(folder.memberFolderId); + }) + : (folders ?? []); + + const searchResultFolderCount = searchResultFolders.length; + + // 필터 또는 검색 활성화 시 결과 표시 + // 대시보드 대신 검색 결과 화면을 보여줄지 여부 (검색 중이거나 필터 활성화 시) + const isShowingFiltered = isSearching; + return (
{/* Main Content Area */} @@ -36,43 +92,116 @@ export default function MyLinksPage() { {/* Top Search & Filter Section */}
- -
+ +
{/* auto_awesome */}
- setPendingQuery(e.target.value)} />
- -
-
- - + +
+ + - - + */} + + + {isShowingFiltered && ( + + {`${searchResultFolderCount} results`} + + )}
+ {/* 검색어 있음 또는 필터 활성화: 폴더 카드 그리드로 검색 결과 표시 */} + {isShowingFiltered && ( +
+
+

+ Search Results: {searchResultFolderCount} folders found +

+
+
+ {searchResultFolders.length === 0 ? ( +
+
+ search_off + No results found +
+
+ ) : ( + searchResultFolders.map((folder) => ( + + )) + )} +
+
+ )} + + {/* 검색어 없음 & 필터 미활성화: 기존 대시보드 */} + {!isShowingFiltered && ( + <> {/* Pinned Folders Top Section - Strictly 5 Columns Desktop */} {pinnedFolders.length > 0 && (
@@ -83,11 +212,11 @@ export default function MyLinksPage() { key={folder.memberFolderId} role="button" tabIndex={0} - onClick={() => setSelectedFolderId(folder.memberFolderId)} + onClick={() => handleFolderBadgeClick(folder.memberFolderId)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); - setSelectedFolderId(folder.memberFolderId); + handleFolderBadgeClick(folder.memberFolderId); } }} className={`${PINNED_COLORS[idx % PINNED_COLORS.length]} text-white rounded-lg p-2.5 flex flex-col justify-between h-20 shadow-sm relative overflow-hidden group hover:scale-[1.01] transition-all duration-300 cursor-pointer focus:ring-1 focus:ring-offset-2 focus:ring-purple-300 outline-none hover:shadow-md`} @@ -114,15 +243,15 @@ export default function MyLinksPage() {

My Folder

- +
- - -
- + {/* ── 로딩 상태 ── */} {isLoading && (
progress_activity - 폴더를 불러오는 중... + Loading folders...
)} @@ -158,7 +287,7 @@ export default function MyLinksPage() {
error - 폴더 목록을 불러오지 못했습니다: {error.message} + Failed to load folders: {error.message}
)} @@ -168,13 +297,16 @@ export default function MyLinksPage() {
folder_off - 아직 생성된 폴더가 없습니다 + No folders created yet
)} {/* ── 폴더 카드 목록 (백엔드 데이터 반복 렌더링) ── */} {(folders ? [...folders] : []).sort((a, b) => { + // 시스템(미분류) 폴더는 항상 최상단 고정 + if (a.folderType === 'UNORGANIZED' && b.folderType !== 'UNORGANIZED') return -1; + if (b.folderType === 'UNORGANIZED' && a.folderType !== 'UNORGANIZED') return 1; if (folderSort === 'recently') return b.memberFolderId - a.memberFolderId; if (folderSort === 'a-z') return a.folderName.localeCompare(b.folderName); return 0; @@ -183,6 +315,8 @@ export default function MyLinksPage() { ))}
+ + )}
From c3343fa9c9a983c50b8a754ff2606859b0ae31be Mon Sep 17 00:00:00 2001 From: jin2304 Date: Mon, 20 Apr 2026 20:24:00 +0900 Subject: [PATCH 05/16] =?UTF-8?q?'=EC=98=A4=EB=8A=98=20=EC=A0=80=EC=9E=A5?= =?UTF-8?q?=ED=95=9C=20=EB=A7=81=ED=81=AC'=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=8B=A0=EA=B7=9C=20=EB=8F=84=EC=9E=85=20=EB=B0=8F=20UX=20?= =?UTF-8?q?=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - dateUtils: 공통 유틸리티 생성: 날짜 비교 로직의 중복을 제거하고 코드 재사용성을 높이기 위해 'isCreatedToday' 헬퍼 함수를 별도 유틸리티 파일로 분리 및 외부 모듈화. - RightPanel: 링크 필터링 기능 신규 도입: 'savedTodayFilter' 상태에 따른 실시간 클라이언트 사이드 필터링 로직을 신규 적용하여, 오늘 저장된 링크만 선별적으로 확인할 수 있는 기능 구현. - MyLinksPage: 전역 필터링 UX 최적화: 'Saved today' 필터 활성화 시 기존 폴더 선택 상태를 초기화(null 처리)함으로써 사용자에게 오늘 저장된 모든 링크를 전역적으로 조망할 수 있는 직관적인 뷰 모드 제공. - MyLinksPage: 코드 클린업: 로컬에 중복 정의되었던 날짜 확인 함수를 제거하고 'dateUtils' 공통 모듈을 참조하도록 리팩토링 수행. - globals: 시각적 피드백 일관성 확보: 모든 'button' 태그 및 'role=button' 요소에 'cursor-pointer' 스타일을 전역 적용하여 클릭 가능한 요소에 대한 사용자 인터랙션 인지 기능 강화. --- frontend/src/app/globals.css | 3 + frontend/src/app/my-links/page.tsx | 394 +++++++++--------- .../src/components/my-links/FolderCard.tsx | 8 +- .../src/components/my-links/RightPanel.tsx | 10 +- frontend/src/lib/dateUtils.ts | 13 + frontend/src/lib/store/folderStore.ts | 8 +- 6 files changed, 237 insertions(+), 199 deletions(-) create mode 100644 frontend/src/lib/dateUtils.ts 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 c454dc1..956835c 100644 --- a/frontend/src/app/my-links/page.tsx +++ b/frontend/src/app/my-links/page.tsx @@ -4,41 +4,32 @@ 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 { useBookmarks } from '@/lib/api/bookmarkApi'; import { useFolderStore } from '@/lib/store/folderStore'; -import { useLinkStore } from '@/lib/store/linkStore'; import { useAuthStore } from '@/lib/store/authStore'; import { SortDropdown, SortOption } from '@/components/ui/SortDropdown'; -import { useState, useEffect } 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 { useState } from 'react'; +import { isCreatedToday } from '@/lib/dateUtils'; +const PINNED_COLORS = ['bg-blue-600/90', 'bg-indigo-500/90', 'bg-teal-500/90', 'bg-amber-500/90', 'bg-emerald-600/90']; export default function MyLinksPage() { + const { toggleRightPanel } = useUIStore(); const setSelectedFolderId = useFolderStore((s) => s.setSelectedFolderId); 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 memberId = useAuthStore((s) => s.member?.memberId); // 로그인된 사용자 ID const isAuthInitializing = useAuthStore((s) => s.isInitializing); // 인증 세션 복구 중 여부 const { data: folders, isLoading: isFoldersLoading, error } = useFolders(memberId); // 전체 폴더 목록 조회 // 인증 또는 폴더 로딩 중 const isLoading = isAuthInitializing || isFoldersLoading; - // 시스템 폴더(미분류)는 Pinned 섹션에 섞지 않는다 - const pinnedFolders = (folders ?? []).filter((f) => f.folderType !== 'UNORGANIZED').slice(0, 5); - const unorganizedFolder = folders?.find((f) => f.folderType === 'UNORGANIZED'); - - const [pendingQuery, setPendingQuery] = useState(''); // 입력 중인 검색어 - - // 검색어 디바운스 처리 (이슈 M1 해결) - useEffect(() => { - const handler = setTimeout(() => { - setSearchQuery(pendingQuery); - }, 250); - - return () => clearTimeout(handler); - }, [pendingQuery, setSearchQuery]); + const pinnedFolders = folders?.slice(0, 5) ?? []; const [folderSort, setFolderSort] = useState('recently'); const folderSortOptions: SortOption[] = [ @@ -59,7 +50,7 @@ export default function MyLinksPage() { // 북마크 데이터 조회 조건 결정: // 1. 전체 범위 검색 중이거나 // 2. 오늘 저장 필터가 활성화된 경우 서버에서 북마크 정보를 가져옴 - const shouldFetchBookmarks = (searchScope === 'all' && isSearching); + const shouldFetchBookmarks = (searchScope === 'all' && isSearching) || savedTodayFilter; const { data: allBookmarks } = useBookmarks( shouldFetchBookmarks ? (isSearching && searchScope === 'all' ? { query: searchQuery } : {}) : {}, { enabled: shouldFetchBookmarks } @@ -67,6 +58,13 @@ export default function MyLinksPage() { // 북마크 데이터로부터 필터링에 필요한 ID 집합(Set) 추출 (O(N)) const matchingFolderIdsSet = new Set(allBookmarks?.matchingFolderIds ?? []); + const todayFolderIdsSet = new Set( + savedTodayFilter && allBookmarks?.bookmarks + ? allBookmarks.bookmarks + .filter((b) => isCreatedToday(b.createdAt)) + .map((b) => b.memberFolderId) + : [] + ); // 폴더 필터링: 폴더명 매칭 또는 백엔드에서 판별된 매칭 폴더 확인 (O(M)) let searchResultFolders = isSearching @@ -79,11 +77,18 @@ export default function MyLinksPage() { }) : (folders ?? []); + // '오늘 저장한 링크' 필터링 최적화 (O(M)) + if (savedTodayFilter) { + searchResultFolders = searchResultFolders.filter((folder) => + todayFolderIdsSet.has(folder.memberFolderId) + ); + } + const searchResultFolderCount = searchResultFolders.length; // 필터 또는 검색 활성화 시 결과 표시 // 대시보드 대신 검색 결과 화면을 보여줄지 여부 (검색 중이거나 필터 활성화 시) - const isShowingFiltered = isSearching; + const isShowingFiltered = isSearching || savedTodayFilter; return (
@@ -92,106 +97,118 @@ export default function MyLinksPage() { {/* Top Search & Filter Section */}
- -
+ +
- {/* auto_awesome */} + search
- setPendingQuery(e.target.value)} + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} /> - + + {/* Search Scope Toggle (Folders / All) */} +
+ + +
+ + {searchQuery && ( + + )}
- - - -
-
- - {/* Search Scope Buttons */} - + + - -
- - - {/* - - */} - - - {isShowingFiltered && ( - - {`${searchResultFolderCount} results`} - - )}
{/* 검색어 있음 또는 필터 활성화: 폴더 카드 그리드로 검색 결과 표시 */} {isShowingFiltered && (
-
-

- Search Results: {searchResultFolderCount} folders found +
+

+ search + {savedTodayFilter ? 'Saved Today' : 'Search Results'}: {searchResultFolderCount} folders found

+
- {searchResultFolders.length === 0 ? ( -
-
- search_off - No results found -
+ {searchResultFolderCount === 0 ? ( +
+ search_off + No results match your search
) : ( - searchResultFolders.map((folder) => ( + searchResultFolders.sort((a, b) => { + if (a.folderType === 'UNORGANIZED' && b.folderType !== 'UNORGANIZED') return -1; + if (b.folderType === 'UNORGANIZED' && a.folderType !== 'UNORGANIZED') return 1; + return 0; + }).map((folder) => ( )) )} @@ -202,122 +219,113 @@ export default function MyLinksPage() { {/* 검색어 없음 & 필터 미활성화: 기존 대시보드 */} {!isShowingFiltered && ( <> - {/* Pinned Folders Top Section - Strictly 5 Columns Desktop */} - {pinnedFolders.length > 0 && ( -
-

Pinned

-
- {pinnedFolders.map((folder, idx) => ( -
handleFolderBadgeClick(folder.memberFolderId)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - handleFolderBadgeClick(folder.memberFolderId); - } - }} - className={`${PINNED_COLORS[idx % PINNED_COLORS.length]} text-white rounded-lg p-2.5 flex flex-col justify-between h-20 shadow-sm relative overflow-hidden group hover:scale-[1.01] transition-all duration-300 cursor-pointer focus:ring-1 focus:ring-offset-2 focus:ring-purple-300 outline-none hover:shadow-md`} - > -
-
- folder + {/* Pinned Folders Top Section - Strictly 5 Columns Desktop */} + {pinnedFolders.length > 0 && ( +
+

Pinned

+
+ {pinnedFolders.map((folder, idx) => ( +
handleFolderBadgeClick(folder.memberFolderId)} + className={`${PINNED_COLORS[idx % PINNED_COLORS.length]} text-white rounded-lg p-2.5 flex flex-col justify-between h-20 shadow-sm relative overflow-hidden group hover:scale-[1.01] transition-all duration-300 cursor-pointer focus:ring-1 focus:ring-offset-2 focus:ring-purple-300 outline-none hover:shadow-md`} + > +
+
+ folder +
+ +
+
+

{folder.folderName}

+
+
- -
-
-

{folder.folderName}

-
-
+ ))}
- ))} -
-
- )} - -
-
-

My Folder

-
- -
- - - -
-
- -
- - {/* ── 로딩 상태 ── */} - {isLoading && ( -
-
- progress_activity - Loading folders...
-
- )} + )} - {/* ── 에러 상태 ── */} - {error && ( -
-
- error - Failed to load folders: {error.message} +
+
+

My Folder

-
- )} - - {/* ── 빈 상태 (로딩이 끝난 후 데이터가 없는 경우) ── */} - {!isLoading && folders && folders.length === 0 && ( -
-
- folder_off - No folders created yet + +
+ + +
- )} - {/* ── 폴더 카드 목록 (백엔드 데이터 반복 렌더링) ── */} - {(folders ? [...folders] : []).sort((a, b) => { - // 시스템(미분류) 폴더는 항상 최상단 고정 - if (a.folderType === 'UNORGANIZED' && b.folderType !== 'UNORGANIZED') return -1; - if (b.folderType === 'UNORGANIZED' && a.folderType !== 'UNORGANIZED') return 1; - if (folderSort === 'recently') return b.memberFolderId - a.memberFolderId; - if (folderSort === 'a-z') return a.folderName.localeCompare(b.folderName); - return 0; - }).map((folder) => ( - - ))} +
+ {/* ── 로딩 상태 ── */} + {isLoading && ( +
+
+ progress_activity + Loading folders... +
+
+ )} -
+ {/* ── 에러 상태 ── */} + {error && ( +
+
+ error + Failed to load folders: {error.message} +
+
+ )} + + {/* ── 빈 상태 (로딩이 끝난 후 데이터가 없는 경우) ── */} + {!isLoading && folders && folders.length === 0 && ( +
+
+ folder_off + No folders created yet +
+
+ )} + + {/* ── 폴더 카드 목록 (백엔드 데이터 반복 렌더링) ── */} + {(folders ? [...folders] : []).sort((a, b) => { + // 시스템(미분류) 폴더는 항상 최상단 고정 + if (a.folderType === 'UNORGANIZED' && b.folderType !== 'UNORGANIZED') return -1; + if (b.folderType === 'UNORGANIZED' && a.folderType !== 'UNORGANIZED') return 1; + if (folderSort === 'recently') return b.memberFolderId - a.memberFolderId; + if (folderSort === 'a-z') return a.folderName.localeCompare(b.folderName); + return 0; + }).map((folder) => ( + + ))} +
)} -
{/* Right Sidebar Panel */} diff --git a/frontend/src/components/my-links/FolderCard.tsx b/frontend/src/components/my-links/FolderCard.tsx index 881b40c..b759d26 100644 --- a/frontend/src/components/my-links/FolderCard.tsx +++ b/frontend/src/components/my-links/FolderCard.tsx @@ -5,6 +5,7 @@ import { createPortal } from 'react-dom'; import type { FolderResponse } from '@/lib/types/folder'; import { useFolderStore } from '@/lib/store/folderStore'; import { useLinkStore } from '@/lib/store/linkStore'; +import { useUIStore } from '@/lib/store/uiStore'; import { FolderEditModal, FolderDeleteModal, FolderMoveModal } from './FolderManagementModals'; interface FolderCardProps { @@ -76,13 +77,16 @@ export function FolderCard({ folder, color }: FolderCardProps) { // 카드 클릭 시 폴더 선택 처리 (메뉴가 열려있지 않을 때만) const handleCardClick = () => { if (!showMenu) { - // 선택된 폴더 ID 변경 -> 우측 패널 데이터 갱신 트리거 + // 1. 선택된 폴더 ID 변경 -> 우측 패널 데이터 갱신 트리거 setSelectedFolderId(folder.memberFolderId); - // 기존 폴더 검색어가 있다면 링크 검색어로 전달 (경험 최적화) + // 2. 기존 폴더 검색어가 있다면 링크 검색어로 전이 (사용자 요청: 클릭 시 링크 필터링되도록) if (searchQuery) { setLinkSearchQuery(searchQuery); } + + // 3. 우측 패널이 닫혀있다면 자동으로 열어줌 + useUIStore.getState().toggleRightPanel(true); } }; diff --git a/frontend/src/components/my-links/RightPanel.tsx b/frontend/src/components/my-links/RightPanel.tsx index 3359676..f9d7b5e 100644 --- a/frontend/src/components/my-links/RightPanel.tsx +++ b/frontend/src/components/my-links/RightPanel.tsx @@ -8,6 +8,7 @@ import { useAuthStore } from '@/lib/store/authStore'; import { useLinkStore } from '@/lib/store/linkStore'; import { SortDropdown, SortOption } from '@/components/ui/SortDropdown'; import type { BookmarkResponse } from '@/lib/types/bookmark'; +import { isCreatedToday } from '@/lib/dateUtils'; /** * 날짜 문자열을 받아 현재 시간 기준 상대적인 시간(예: Just now, 5m ago)으로 변환합니다. @@ -498,6 +499,7 @@ export function RightPanel() { const memberId = useAuthStore((s) => s.member?.memberId); const linkSearchQuery = useLinkStore((s) => s.filters.searchQuery); // 현재 검색어 상태 구독 const setLinkSearchQuery = useLinkStore((s) => s.setSearchQuery); // 검색어 변경 함수 + const savedTodayFilter = useFolderStore((s) => s.savedTodayFilter); // 오늘 저장 필터 상태 // 검색어 디바운스 처리를 위한 로컬 상태 const [pendingSearch, setPendingSearch] = useState(linkSearchQuery); @@ -577,12 +579,16 @@ export function RightPanel() { }; }, [isTagDropdownOpen, isMoreMenuOpen]); - // Client-side tag filtering logic | 태그 필터링 처리 (클라이언트 사이드 필터링) + // Client-side filtering logic (Tags & Saved Today) const allLinks = bookmarks ?? []; - const filteredLinks = selectedTags.length > 0 + let filteredLinks = selectedTags.length > 0 ? allLinks.filter(link => link.tags?.some(tag => selectedTags.includes(tag))) : allLinks; + if (savedTodayFilter) { + filteredLinks = filteredLinks.filter(link => isCreatedToday(link.createdAt)); + } + /** 태그 선택/해제 */ const toggleTag = (tag: string) => { setSelectedTags(prev => diff --git a/frontend/src/lib/dateUtils.ts b/frontend/src/lib/dateUtils.ts new file mode 100644 index 0000000..532cd64 --- /dev/null +++ b/frontend/src/lib/dateUtils.ts @@ -0,0 +1,13 @@ +/** + * 날짜 문자열이 '오늘'인지 확인하는 헬퍼 함수 + */ +export function isCreatedToday(createdAtStr: string): boolean { + if (!createdAtStr) return false; + const createdDate = new Date(createdAtStr); + const today = new Date(); + return ( + createdDate.getFullYear() === today.getFullYear() && + createdDate.getMonth() === today.getMonth() && + createdDate.getDate() === today.getDate() + ); +} diff --git a/frontend/src/lib/store/folderStore.ts b/frontend/src/lib/store/folderStore.ts index d859f1f..e875fd4 100644 --- a/frontend/src/lib/store/folderStore.ts +++ b/frontend/src/lib/store/folderStore.ts @@ -4,20 +4,24 @@ interface FolderStore { selectedFolderId: number | null; searchScope: 'folders' | 'all'; searchQuery: string; + savedTodayFilter: boolean; setSelectedFolderId: (id: number | null) => void; setSearchScope: (scope: 'folders' | 'all') => void; setSearchQuery: (query: string) => void; + toggleSavedTodayFilter: () => void; clearSearch: () => void; } export const useFolderStore = create((set) => ({ selectedFolderId: null, // 현재 선택된 폴더 (우측 패널에 표시됨) - searchScope: 'all', // 검색 범위 ('folders': 폴더명만, 'all': 북마크 포함) + searchScope: 'folders', // 검색 범위 ('folders': 폴더명만, 'all': 북마크 포함) searchQuery: '', // 폴더 목록 필터링용 검색어 + savedTodayFilter: false, // 오늘 저장한 링크만 표시 여부 setSelectedFolderId: (id) => set({ selectedFolderId: id }), setSearchScope: (scope) => set({ searchScope: scope }), setSearchQuery: (query) => set({ searchQuery: query }), - clearSearch: () => set({ searchQuery: '', searchScope: 'all' }), + toggleSavedTodayFilter: () => set((state) => ({ savedTodayFilter: !state.savedTodayFilter })), + clearSearch: () => set({ searchQuery: '', searchScope: 'all', savedTodayFilter: false }), })); From b228a81e42e55802874705a1cc0ca33427c5f0f1 Mon Sep 17 00:00:00 2001 From: jin2304 Date: Sat, 25 Apr 2026 15:38:30 +0900 Subject: [PATCH 06/16] =?UTF-8?q?feat(backend):=20=EB=AF=B8=EC=97=B4?= =?UTF-8?q?=EB=9E=8C=20=ED=95=84=ED=84=B0=20=EA=B8=B0=EB=8A=A5=20=EB=B0=8F?= =?UTF-8?q?=20=EB=B6=81=EB=A7=88=ED=81=AC=20=EC=9D=BD=EC=9D=8C=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EA=B4=80=EB=A6=AC=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit init_postgres.sql: 데이터 구조 확장: 북마크별 조회수(view_count)와 마지막 읽은 시간(last_viewed_at) 컬럼을 추가하여 읽음 상태 추적 기반 마련. BookmarkApiController: API 기능 확장: 링크 읽음 처리 엔드포인트 추가 및 검색 API에 '미열람 항목만 보기' 필터링 파라미터 연동. BookmarkServiceImpl: 비즈니스 로직 구현: 링크 클릭 시 읽음 상태 업데이트 처리 및 조건에 따른 미열람 데이터 필터링 로직 적용. bookmark-mapper.xml: 쿼리 최적화: 조회수 기반 필터링 처리를 위한 동적 SQL 및 조회수 업데이트(Update) 쿼리 정의. --- .../controller/BookmarkApiController.java | 13 ++++++++++++ .../controller/dto/BookmarkRequests.java | 2 ++ .../SearchWeb/bookmark/dao/BookmarkDao.java | 3 +++ .../bookmark/dao/MybatisBookmarkDao.java | 9 ++++++++ .../SearchWeb/bookmark/domain/Bookmark.java | 3 +++ .../bookmark/service/BookmarkService.java | 3 +++ .../bookmark/service/BookmarkServiceImpl.java | 13 ++++++++++++ .../command/BookmarkSearchCommand.java | 1 + src/main/resources/db/init_postgres.sql | 7 +++++++ src/main/resources/mapper/bookmark-mapper.xml | 21 +++++++++++++++++++ 10 files changed, 75 insertions(+) diff --git a/src/main/java/com/web/SearchWeb/bookmark/controller/BookmarkApiController.java b/src/main/java/com/web/SearchWeb/bookmark/controller/BookmarkApiController.java index f82dde8..833261c 100644 --- a/src/main/java/com/web/SearchWeb/bookmark/controller/BookmarkApiController.java +++ b/src/main/java/com/web/SearchWeb/bookmark/controller/BookmarkApiController.java @@ -115,6 +115,19 @@ public ResponseEntity> deleteBookmark( } + /** + * 북마크 조회 기록 + */ + @PatchMapping("/{bookmarkId}/read") + public ResponseEntity> recordView( + @CurrentMemberId Long memberId, + @PathVariable Long bookmarkId) { + + bookmarkService.recordView(memberId, bookmarkId); + return ResponseEntity.ok(ApiResponse.success(null)); + } + + /** * 북마크 확인 */ diff --git a/src/main/java/com/web/SearchWeb/bookmark/controller/dto/BookmarkRequests.java b/src/main/java/com/web/SearchWeb/bookmark/controller/dto/BookmarkRequests.java index 56e248c..ad02a33 100644 --- a/src/main/java/com/web/SearchWeb/bookmark/controller/dto/BookmarkRequests.java +++ b/src/main/java/com/web/SearchWeb/bookmark/controller/dto/BookmarkRequests.java @@ -38,6 +38,7 @@ public static class SearchDto { public String sort = "Newest"; // 기본값 설정 public String query; public Long categoryId; + public Boolean unreadOnly; public BookmarkSearchCommand toCommand(Long memberId) { return BookmarkSearchCommand.builder() @@ -46,6 +47,7 @@ public BookmarkSearchCommand toCommand(Long memberId) { .sort(this.sort) .query(this.query) .categoryId(this.categoryId) + .unreadOnly(this.unreadOnly) .build(); } } diff --git a/src/main/java/com/web/SearchWeb/bookmark/dao/BookmarkDao.java b/src/main/java/com/web/SearchWeb/bookmark/dao/BookmarkDao.java index ab42dfe..e75ea8a 100644 --- a/src/main/java/com/web/SearchWeb/bookmark/dao/BookmarkDao.java +++ b/src/main/java/com/web/SearchWeb/bookmark/dao/BookmarkDao.java @@ -26,6 +26,9 @@ public interface BookmarkDao { //북마크 삭제 int deleteBookmark(Long memberId, Long bookmarkId); + //북마크 조회수 증가 (읽음 처리) + int incrementViewCount(Long bookmarkId, Long memberId); + //북마크 태그 연결 삭제 int deleteBookmarkTags(Long bookmarkId, Long memberId); diff --git a/src/main/java/com/web/SearchWeb/bookmark/dao/MybatisBookmarkDao.java b/src/main/java/com/web/SearchWeb/bookmark/dao/MybatisBookmarkDao.java index b246e7c..da97722 100644 --- a/src/main/java/com/web/SearchWeb/bookmark/dao/MybatisBookmarkDao.java +++ b/src/main/java/com/web/SearchWeb/bookmark/dao/MybatisBookmarkDao.java @@ -81,6 +81,15 @@ public int deleteBookmarkTags(Long bookmarkId, Long memberId) { } + /** + * 북마크 조회수 증가 (읽음 처리) + */ + @Override + public int incrementViewCount(Long bookmarkId, Long memberId) { + return mapper.incrementViewCount(bookmarkId, memberId); + } + + /** * 북마크 삭제 (Link ID 기반 - soft delete) */ diff --git a/src/main/java/com/web/SearchWeb/bookmark/domain/Bookmark.java b/src/main/java/com/web/SearchWeb/bookmark/domain/Bookmark.java index 0dd050b..d36f143 100644 --- a/src/main/java/com/web/SearchWeb/bookmark/domain/Bookmark.java +++ b/src/main/java/com/web/SearchWeb/bookmark/domain/Bookmark.java @@ -1,6 +1,7 @@ package com.web.SearchWeb.bookmark.domain; import java.math.BigDecimal; +import java.time.OffsetDateTime; import java.util.List; import com.web.SearchWeb.common.domain.BaseEntity; @@ -33,6 +34,8 @@ public class Bookmark extends BaseEntity { private Long primaryCategoryId; // 주 카테고리 ID (선택, AI 분류 또는 사용자 지정) private String categorySource; // 카테고리 출처 ('system', 'member', default 'system') private BigDecimal categoryScore; // 카테고리 정확도 점수 (0.0 ~ 1.0) + private Integer viewCount; // 조회 수 (클릭 1회당 +1, 0이면 unread) + private OffsetDateTime lastViewedAt; // 마지막 조회 시각 (NULL이면 아직 안 읽음) // Link 객체 (Association) private Link link; // Link 테이블과 조인된 객체 diff --git a/src/main/java/com/web/SearchWeb/bookmark/service/BookmarkService.java b/src/main/java/com/web/SearchWeb/bookmark/service/BookmarkService.java index 45837c4..9872bd1 100644 --- a/src/main/java/com/web/SearchWeb/bookmark/service/BookmarkService.java +++ b/src/main/java/com/web/SearchWeb/bookmark/service/BookmarkService.java @@ -28,6 +28,9 @@ Long updateBookmark(Long memberId, Long bookmarkId, Long memberFolderId, String //북마크 삭제 Long deleteBookmark(Long memberId, Long bookmarkId); + //북마크 조회 기록 + void recordView(Long memberId, Long bookmarkId); + // 링크 조회 또는 생성 (URL 정규화) Link getOrCreateLink(String url, Long createdByMemberId); diff --git a/src/main/java/com/web/SearchWeb/bookmark/service/BookmarkServiceImpl.java b/src/main/java/com/web/SearchWeb/bookmark/service/BookmarkServiceImpl.java index 40ab4bf..d91f2d1 100644 --- a/src/main/java/com/web/SearchWeb/bookmark/service/BookmarkServiceImpl.java +++ b/src/main/java/com/web/SearchWeb/bookmark/service/BookmarkServiceImpl.java @@ -159,6 +159,19 @@ public Long updateBookmark(Long memberId, Long bookmarkId, Long memberFolderId, } + /** + * 북마크 조회 기록 (view_count 증가, last_viewed_at 업데이트) + */ + @Override + @Transactional + public void recordView(Long memberId, Long bookmarkId) { + int affected = bookmarkDao.incrementViewCount(bookmarkId, memberId); + if (affected == 0) { + throw BookmarkException.of(BookmarkErrorCode.BOOKMARK_NOT_FOUND); + } + } + + /** * 북마크 삭제 (soft delete) */ diff --git a/src/main/java/com/web/SearchWeb/bookmark/service/command/BookmarkSearchCommand.java b/src/main/java/com/web/SearchWeb/bookmark/service/command/BookmarkSearchCommand.java index 8ac2ce3..c2b8dee 100644 --- a/src/main/java/com/web/SearchWeb/bookmark/service/command/BookmarkSearchCommand.java +++ b/src/main/java/com/web/SearchWeb/bookmark/service/command/BookmarkSearchCommand.java @@ -18,4 +18,5 @@ public class BookmarkSearchCommand { private String sort; // 정렬 기준 (Newest, Oldest) private String query; // 검색어 (display_title 검색) private Long categoryId; // primary_category_id로 필터 (태그 대체) + private Boolean unreadOnly; // true이면 view_count = 0 인 북마크만 조회 } \ No newline at end of file diff --git a/src/main/resources/db/init_postgres.sql b/src/main/resources/db/init_postgres.sql index 6b0ee5b..335176a 100644 --- a/src/main/resources/db/init_postgres.sql +++ b/src/main/resources/db/init_postgres.sql @@ -398,6 +398,8 @@ CREATE TABLE IF NOT EXISTS "member_saved_link" ( "primary_category_id" int, "category_source" varchar(10) DEFAULT 'system' NOT NULL, "category_score" numeric(5,4), + "view_count" integer DEFAULT 0 NOT NULL, + "last_viewed_at" timestamptz, "created_at" timestamptz DEFAULT now() NOT NULL, "updated_at" timestamptz DEFAULT now() NOT NULL, "deleted_at" timestamptz, @@ -407,11 +409,16 @@ CREATE TABLE IF NOT EXISTS "member_saved_link" ( CONSTRAINT pk_member_saved_link PRIMARY KEY ("member_saved_link_id"), CONSTRAINT ck_member_saved_link_category_source CHECK (category_source in ('system','member')), CONSTRAINT ck_member_saved_link_category_score CHECK (category_score is null or (category_score between 0 and 1)), + CONSTRAINT ck_member_saved_link_view_count_nonneg CHECK (view_count >= 0), CONSTRAINT fk_member_saved_link_link_id FOREIGN KEY ("link_id") REFERENCES "link"("link_id"), CONSTRAINT fk_member_saved_link_link_enrichment_id FOREIGN KEY ("link_enrichment_id") REFERENCES "link_enrichment"("link_enrichment_id"), CONSTRAINT fk_member_saved_link_member_folder_id FOREIGN KEY ("member_folder_id") REFERENCES "member_folder"("member_folder_id") ); +-- (idempotent) 기존 DB에 대해 view_count / last_viewed_at 컬럼 추가 +ALTER TABLE "member_saved_link" ADD COLUMN IF NOT EXISTS "view_count" integer DEFAULT 0 NOT NULL; +ALTER TABLE "member_saved_link" ADD COLUMN IF NOT EXISTS "last_viewed_at" timestamptz; + CREATE TABLE IF NOT EXISTS "link_enrichment_feedback" ( "link_enrichment_feedback_id" int GENERATED ALWAYS AS IDENTITY NOT NULL, "link_enrichment_id" int NOT NULL, diff --git a/src/main/resources/mapper/bookmark-mapper.xml b/src/main/resources/mapper/bookmark-mapper.xml index 548c3e4..388a006 100644 --- a/src/main/resources/mapper/bookmark-mapper.xml +++ b/src/main/resources/mapper/bookmark-mapper.xml @@ -17,6 +17,8 @@ + + @@ -107,6 +109,9 @@ AND LOWER(msl.display_title) LIKE '%' || LOWER(#{query}) || '%' + + + AND msl.view_count = 0 ORDER BY @@ -140,9 +145,25 @@ AND LOWER(msl.display_title) LIKE '%' || LOWER(#{query}) || '%' + + AND msl.view_count = 0 + + + + UPDATE member_saved_link + SET view_count = view_count + 1, + last_viewed_at = now(), + updated_at = now(), + updated_by_member_id = #{memberId} + WHERE member_saved_link_id = #{bookmarkId} + AND created_by_member_id = #{memberId} + AND deleted_at IS NULL + + + @@ -134,21 +141,41 @@ FROM member_saved_link msl JOIN link l ON msl.link_id = l.link_id - WHERE msl.created_by_member_id = #{memberId} - AND msl.deleted_at IS NULL - - AND msl.member_folder_id = #{folderId} - - - AND msl.primary_category_id = #{categoryId} - - - AND LOWER(msl.display_title) LIKE '%' || LOWER(#{query}) || '%' - - - AND msl.view_count = 0 - + + + + + + + + + AND msl.created_by_member_id = #{memberId} + AND msl.deleted_at IS NULL + + AND msl.member_folder_id = #{folderId} + + + AND msl.primary_category_id = #{categoryId} + + + AND LOWER(msl.display_title) LIKE '%' || LOWER(#{query}) || '%' ESCAPE '\' + + + AND msl.view_count = 0 + + + AND msl.created_at >= (date_trunc('day', now() AT TIME ZONE 'Asia/Seoul') AT TIME ZONE 'Asia/Seoul') + AND msl.created_at < ((date_trunc('day', now() AT TIME ZONE 'Asia/Seoul') + INTERVAL '1 day') AT TIME ZONE 'Asia/Seoul') + + + From d811abe19a90196d8b2d879780832e0fedb6cc3f Mon Sep 17 00:00:00 2001 From: jin2304 Date: Sun, 26 Apr 2026 18:29:12 +0900 Subject: [PATCH 13/16] =?UTF-8?q?refactor(bookmark):=20Request=20DTO=20pub?= =?UTF-8?q?lic=20=ED=95=84=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BookmarkRequests.java: CreateDto public 필드를 private 필드와 @Getter, @NoArgsConstructor, @AllArgsConstructor 기반 구조로 변경하여 요청 DTO 직접 필드 접근 제거. BookmarkRequests.java: UpdateDto public 필드를 private 필드와 getter-only 구조로 변경하여 setter 노출 범위 축소. BookmarkRequests.java: SearchDto는 @ModelAttribute query parameter 바인딩 안정성을 위해 @Getter/@Setter 유지 및 요청 DTO별 setter 노출 범위 분리. BookmarkApiController.java: 북마크 생성 요청 처리에서 request 필드 직접 접근을 getter 호출로 변경하여 DTO 캡슐화 규칙 준수. --- .../controller/BookmarkApiController.java | 12 ++++----- .../controller/dto/BookmarkRequests.java | 27 +++++++++---------- 2 files changed, 19 insertions(+), 20 deletions(-) diff --git a/src/main/java/com/web/SearchWeb/bookmark/controller/BookmarkApiController.java b/src/main/java/com/web/SearchWeb/bookmark/controller/BookmarkApiController.java index 45f4b9d..a5593c0 100644 --- a/src/main/java/com/web/SearchWeb/bookmark/controller/BookmarkApiController.java +++ b/src/main/java/com/web/SearchWeb/bookmark/controller/BookmarkApiController.java @@ -40,12 +40,12 @@ public ResponseEntity> insertBookmark( Long bookmarkId = bookmarkService.insertBookmark( memberId, - request.url, - request.memberFolderId, - request.displayTitle, - request.note, - request.primaryCategoryId, - request.tags + request.getUrl(), + request.getMemberFolderId(), + request.getDisplayTitle(), + request.getNote(), + request.getPrimaryCategoryId(), + request.getTags() ); return ResponseEntity diff --git a/src/main/java/com/web/SearchWeb/bookmark/controller/dto/BookmarkRequests.java b/src/main/java/com/web/SearchWeb/bookmark/controller/dto/BookmarkRequests.java index 7a82f9e..aec5813 100644 --- a/src/main/java/com/web/SearchWeb/bookmark/controller/dto/BookmarkRequests.java +++ b/src/main/java/com/web/SearchWeb/bookmark/controller/dto/BookmarkRequests.java @@ -8,23 +8,22 @@ import lombok.Setter; /** - * Bookmark Controller 전용 Request DTO - * - Controller에서만 사용하며, Service 계층에는 개별 파라미터로 전달 + * Bookmark controller request DTOs. */ public class BookmarkRequests { - /** - * 북마크 생성 요청 Dto - */ - public static class CreateDto { - public Long bookmarkId; // 북마크 ID (PK, Insert 시 생성된 키 저장용) - public Long memberFolderId; // 폴더 ID (null이면 기본 폴더) - public String displayTitle; // 표시 제목 - public String url; // 저장할 URL - public String note; // 메모 - public Long primaryCategoryId; // 카테고리 ID - public Long createdByMemberId; // 저장한 회원 - public String tags; // 태그 문자열 (공백/콤마 구분) + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class CreateDto { + private Long bookmarkId; + private Long memberFolderId; + private String displayTitle; + private String url; + private String note; + private Long primaryCategoryId; + private Long createdByMemberId; + private String tags; } @Getter From c8a0f3b61c85d89303ec4422e058efd33c647e47 Mon Sep 17 00:00:00 2001 From: jin2304 Date: Sun, 26 Apr 2026 18:31:43 +0900 Subject: [PATCH 14/16] =?UTF-8?q?fix(frontend):=20=EC=9D=BD=EC=9D=8C=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EC=8B=9C=20=ED=95=84=EC=9A=94=ED=95=9C=20?= =?UTF-8?q?=EB=B6=81=EB=A7=88=ED=81=AC=20=EC=BA=90=EC=8B=9C=EB=A7=8C=20?= =?UTF-8?q?=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bookmarkApi.ts: useRecordBookmarkView 성공 시 전체 ['bookmarks'] 쿼리 무조건 invalidate 제거 및 캐시 내 대상 bookmark만 로컬 갱신. bookmarkApi.ts: 일반 북마크 목록 캐시는 viewCount + 1, lastViewedAt 현재 시간 갱신으로 서버의 누적 조회수 정책과 프론트 캐시 의미 일치. bookmarkApi.ts: unreadOnly 쿼리는 읽은 bookmark를 캐시에서 제거하고 totalCount를 감소시킨 뒤 unread 쿼리만 좁게 invalidate하여 불필요한 전체 재조회 방지. bookmarkApi.ts: 일반 응답과 useInfiniteQuery 응답을 모두 처리하는 cache data 타입 가드 추가로 기존 목록 조회와 무한 스크롤 조회의 읽음 상태 갱신 지원. --- frontend/src/lib/api/bookmarkApi.ts | 76 +++++++++++++++++++++++++++-- 1 file changed, 73 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/api/bookmarkApi.ts b/frontend/src/lib/api/bookmarkApi.ts index 9d62d40..bb86590 100644 --- a/frontend/src/lib/api/bookmarkApi.ts +++ b/frontend/src/lib/api/bookmarkApi.ts @@ -70,6 +70,68 @@ async function recordBookmarkView(bookmarkId: number): Promise { }); } +function queryKeyHasUnreadOnly(queryKey: readonly unknown[]): boolean { + return queryKey.some((part) => { + if (!part || typeof part !== 'object' || Array.isArray(part)) return false; + return (part as { unreadOnly?: unknown }).unreadOnly === true; + }); +} + +function updateReadStateInResponse( + data: BookmarkSearchResponse, + bookmarkId: number, + removeFromUnreadQuery: boolean, +): BookmarkSearchResponse { + let changed = false; + let removed = false; + const now = new Date().toISOString(); + const bookmarks = data.bookmarks.flatMap((bookmark) => { + if (bookmark.bookmarkId !== bookmarkId) return [bookmark]; + changed = true; + if (removeFromUnreadQuery) { + removed = true; + return []; + } + return [{ + ...bookmark, + viewCount: (bookmark.viewCount ?? 0) + 1, + lastViewedAt: now, + }]; + }); + + if (!changed) return data; + return { + ...data, + bookmarks, + totalCount: removed ? Math.max(data.totalCount - 1, 0) : data.totalCount, + }; +} + +function isInfiniteBookmarkData(data: unknown): data is InfiniteData { + return Boolean(data && typeof data === 'object' && Array.isArray((data as { pages?: unknown }).pages)); +} + +function isBookmarkSearchResponse(data: unknown): data is BookmarkSearchResponse { + return Boolean(data && typeof data === 'object' && Array.isArray((data as { bookmarks?: unknown }).bookmarks)); +} + +function updateReadStateInCacheData( + data: unknown, + bookmarkId: number, + removeFromUnreadQuery: boolean, +): unknown { + if (isInfiniteBookmarkData(data)) { + return { + ...data, + pages: data.pages.map((page) => updateReadStateInResponse(page, bookmarkId, removeFromUnreadQuery)), + }; + } + if (isBookmarkSearchResponse(data)) { + return updateReadStateInResponse(data, bookmarkId, removeFromUnreadQuery); + } + return data; +} + /** * URL 분석 (GET /api/bookmarks/analyze) * @param url 분석할 URL @@ -155,14 +217,22 @@ export function useDeleteBookmark() { /** * [조회 기록 Hook] 북마크 열람 시 view_count 증가 및 읽음 처리. - * 성공 시 'bookmarks' 캐시를 무효화하여 Unread 필터 목록 등이 자동 갱신됩니다. */ export function useRecordBookmarkView() { const queryClient = useQueryClient(); return useMutation({ mutationFn: recordBookmarkView, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['bookmarks'] }); + onSuccess: (_, bookmarkId) => { + const bookmarkQueries = queryClient.getQueryCache().findAll({ queryKey: ['bookmarks'] }); + bookmarkQueries.forEach((query) => { + const removeFromUnreadQuery = queryKeyHasUnreadOnly(query.queryKey); + queryClient.setQueryData(query.queryKey, (oldData) => + updateReadStateInCacheData(oldData, bookmarkId, removeFromUnreadQuery) + ); + }); + queryClient.invalidateQueries({ + predicate: (query) => query.queryKey[0] === 'bookmarks' && queryKeyHasUnreadOnly(query.queryKey), + }); }, }); } From f8e35456939e4b8528af4709f8802433000c1327 Mon Sep 17 00:00:00 2001 From: jin2304 Date: Mon, 27 Apr 2026 20:30:53 +0900 Subject: [PATCH 15/16] =?UTF-8?q?feat(backend):=20=EB=B6=81=EB=A7=88?= =?UTF-8?q?=ED=81=AC=20=EA=B2=80=EC=83=89=20=ED=95=84=ED=84=B0=20=EA=B3=A0?= =?UTF-8?q?=EB=8F=84=ED=99=94=20=EB=B0=8F=20=EC=98=88=EC=99=B8=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BookmarkSearchCommand: 검색 기능 확장: unreadOnly(미열람), savedTodayOnly(당일 저장) 필터 속성 추가 및 조회 성능 최적화를 위한 필드 타입 조정. bookmark-mapper.xml: 쿼리 동적 처리: MyBatis의 태그를 사용하여 unreadOnly 및 savedTodayOnly 조건부 검색 로직 구현 및 ID 기반 단건 조회(findById) 쿼리 추가. BookmarkServiceImpl: 예외 처리 정밀화: 북마크 조회/수정/삭제 실패 시 validateBookmarkOwner 메서드를 통해 '존재하지 않음(404)'과 '권한 없음(403)'을 명확히 구분하여 응답 정확도 확보. BookmarkServiceImpl: 서비스 연동 강화: 북마크 생성 시 폴더 미지정 상태일 경우 MemberFolderService를 통해 사용자의 '미분류' 폴더를 자동 조회하거나 생성하는 로직 추가. BookmarkApiController: API 인터페이스 최적화: recordView(읽음 처리) 호출 시 증가된 조회수와 마지막 열람 시간이 반영된 최신 북마크 객체를 반환하도록 수정. BookmarkErrorCode: 에러 코드 추가: 접근 권한 위반 시 사용될 ACCESS_DENIED(403) 코드 정의. --- .../controller/BookmarkApiController.java | 6 +-- .../SearchWeb/bookmark/dao/BookmarkDao.java | 3 ++ .../bookmark/dao/MybatisBookmarkDao.java | 8 ++++ .../bookmark/error/BookmarkErrorCode.java | 3 +- .../bookmark/service/BookmarkService.java | 2 +- .../bookmark/service/BookmarkServiceImpl.java | 36 +++++++++++++--- .../command/BookmarkSearchCommand.java | 9 ++-- .../folder/dao/MemberFolderJpaDao.java | 4 +- .../SearchWeb/folder/domain/MemberFolder.java | 1 + .../service/MemberFolderServiceImpl.java | 41 ++++++++++++------- src/main/resources/mapper/bookmark-mapper.xml | 16 +++++--- 11 files changed, 95 insertions(+), 34 deletions(-) diff --git a/src/main/java/com/web/SearchWeb/bookmark/controller/BookmarkApiController.java b/src/main/java/com/web/SearchWeb/bookmark/controller/BookmarkApiController.java index a5593c0..1fcef4c 100644 --- a/src/main/java/com/web/SearchWeb/bookmark/controller/BookmarkApiController.java +++ b/src/main/java/com/web/SearchWeb/bookmark/controller/BookmarkApiController.java @@ -119,12 +119,12 @@ public ResponseEntity> deleteBookmark( * 북마크 조회 기록 */ @PatchMapping("/{bookmarkId}/read") - public ResponseEntity> recordView( + public ResponseEntity> recordView( @CurrentMemberId Long memberId, @PathVariable Long bookmarkId) { - bookmarkService.recordView(memberId, bookmarkId); - return ResponseEntity.ok(ApiResponse.success(null)); + Bookmark updatedBookmark = bookmarkService.recordView(memberId, bookmarkId); + return ResponseEntity.ok(ApiResponse.success(updatedBookmark)); } diff --git a/src/main/java/com/web/SearchWeb/bookmark/dao/BookmarkDao.java b/src/main/java/com/web/SearchWeb/bookmark/dao/BookmarkDao.java index c23aa17..7dd161d 100644 --- a/src/main/java/com/web/SearchWeb/bookmark/dao/BookmarkDao.java +++ b/src/main/java/com/web/SearchWeb/bookmark/dao/BookmarkDao.java @@ -14,6 +14,9 @@ public interface BookmarkDao { //북마크 단일 조회 Bookmark selectBookmark(@Param("memberId") Long memberId, @Param("bookmarkId") Long bookmarkId); + + //북마크 기본 조회 (ID 기반, 권한 체크용) + Bookmark findById(@Param("bookmarkId") Long bookmarkId); //북마크 목록 조회 List selectBookmarkList(BookmarkSearchCommand searchCommand); diff --git a/src/main/java/com/web/SearchWeb/bookmark/dao/MybatisBookmarkDao.java b/src/main/java/com/web/SearchWeb/bookmark/dao/MybatisBookmarkDao.java index 93d08a2..31ac004 100644 --- a/src/main/java/com/web/SearchWeb/bookmark/dao/MybatisBookmarkDao.java +++ b/src/main/java/com/web/SearchWeb/bookmark/dao/MybatisBookmarkDao.java @@ -38,6 +38,14 @@ public Bookmark selectBookmark(Long memberId, Long bookmarkId) { return mapper.selectBookmark(memberId, bookmarkId); } + /** + * 북마크 기본 조회 (ID 기반, 권한 체크용) + */ + @Override + public Bookmark findById(Long bookmarkId) { + return mapper.findById(bookmarkId); + } + @Override public List selectBookmarkList(BookmarkSearchCommand searchCommand) { diff --git a/src/main/java/com/web/SearchWeb/bookmark/error/BookmarkErrorCode.java b/src/main/java/com/web/SearchWeb/bookmark/error/BookmarkErrorCode.java index 360ea47..3be2591 100644 --- a/src/main/java/com/web/SearchWeb/bookmark/error/BookmarkErrorCode.java +++ b/src/main/java/com/web/SearchWeb/bookmark/error/BookmarkErrorCode.java @@ -9,7 +9,8 @@ @RequiredArgsConstructor public enum BookmarkErrorCode implements ErrorCode { DUPLICATE_BOOKMARK(HttpStatus.CONFLICT, "B001", "이미 존재하는 북마크입니다."), - BOOKMARK_NOT_FOUND(HttpStatus.NOT_FOUND, "B002", "북마크를 찾을 수 없습니다."); + BOOKMARK_NOT_FOUND(HttpStatus.NOT_FOUND, "B002", "북마크를 찾을 수 없습니다."), + ACCESS_DENIED(HttpStatus.FORBIDDEN, "B003", "해당 북마크에 대한 권한이 없습니다."); private final HttpStatus status; private final String code; diff --git a/src/main/java/com/web/SearchWeb/bookmark/service/BookmarkService.java b/src/main/java/com/web/SearchWeb/bookmark/service/BookmarkService.java index 9872bd1..4e9c6df 100644 --- a/src/main/java/com/web/SearchWeb/bookmark/service/BookmarkService.java +++ b/src/main/java/com/web/SearchWeb/bookmark/service/BookmarkService.java @@ -29,7 +29,7 @@ Long updateBookmark(Long memberId, Long bookmarkId, Long memberFolderId, String Long deleteBookmark(Long memberId, Long bookmarkId); //북마크 조회 기록 - void recordView(Long memberId, Long bookmarkId); + Bookmark recordView(Long memberId, Long bookmarkId); // 링크 조회 또는 생성 (URL 정규화) Link getOrCreateLink(String url, Long createdByMemberId); diff --git a/src/main/java/com/web/SearchWeb/bookmark/service/BookmarkServiceImpl.java b/src/main/java/com/web/SearchWeb/bookmark/service/BookmarkServiceImpl.java index 32c8001..5113ad9 100644 --- a/src/main/java/com/web/SearchWeb/bookmark/service/BookmarkServiceImpl.java +++ b/src/main/java/com/web/SearchWeb/bookmark/service/BookmarkServiceImpl.java @@ -18,6 +18,7 @@ import com.web.SearchWeb.config.exception.CommonErrorCode; import com.web.SearchWeb.bookmark.dto.MemberTagResultDto; +import com.web.SearchWeb.folder.service.MemberFolderService; import com.web.SearchWeb.linkanalysis.service.LinkMetadataExtractor; import java.net.URI; import java.util.ArrayList; @@ -33,11 +34,13 @@ public class BookmarkServiceImpl implements BookmarkService { private final BookmarkDao bookmarkDao; private final LinkMetadataExtractor linkMetadataExtractor; + private final MemberFolderService memberFolderService; @Autowired - public BookmarkServiceImpl(BookmarkDao bookmarkDao, LinkMetadataExtractor linkMetadataExtractor) { + public BookmarkServiceImpl(BookmarkDao bookmarkDao, LinkMetadataExtractor linkMetadataExtractor, MemberFolderService memberFolderService) { this.bookmarkDao = bookmarkDao; this.linkMetadataExtractor = linkMetadataExtractor; + this.memberFolderService = memberFolderService; } @@ -51,10 +54,9 @@ public Long insertBookmark(Long memberId, String url, Long memberFolderId, Strin // 링크 조회 또는 생성 Link link = getOrCreateLink(url, memberId); - // TODO: 링크 분석 및 폴더 서비스 완성 후 제거 - 임시 기본 폴더 ID 설정 + // 폴더 미지정 시 사용자의 미분류(UNORGANIZED) 폴더 ID를 조회하거나 생성 if (memberFolderId == null) { - memberFolderId = 1L; // 임시 하드코딩 값 - log.warn("memberFolderId가 null이어서 임시 기본값(1)을 사용합니다. 링크 분석 및 폴더 서비스 연동 후 제거 필요."); + memberFolderId = memberFolderService.getOrCreateUnorganizedFolderId(memberId); } // Entity 생성 @@ -94,6 +96,9 @@ public Long insertBookmark(Long memberId, String url, Long memberFolderId, Strin public Bookmark selectBookmark(Long memberId, Long bookmarkId) { Bookmark bookmark = bookmarkDao.selectBookmark(memberId, bookmarkId); if (bookmark == null) { + // 상세 조회를 통한 원인 파악 (404 vs 403) + validateBookmarkOwner(bookmarkId, memberId); + // 위에서 예외가 발생하지 않았다면 (그럴 리 없지만) 기본 NotFound 처리 throw BookmarkException.of(BookmarkErrorCode.BOOKMARK_NOT_FOUND); } return bookmark; @@ -139,6 +144,8 @@ public Long updateBookmark(Long memberId, Long bookmarkId, Long memberFolderId, int result = bookmarkDao.updateBookmark(bookmark); if (result == 0) { + // 수정 실패 시 원인 파악 (404 vs 403) + validateBookmarkOwner(bookmarkId, memberId); throw BookmarkException.of(BookmarkErrorCode.BOOKMARK_NOT_FOUND); } @@ -166,11 +173,14 @@ public Long updateBookmark(Long memberId, Long bookmarkId, Long memberFolderId, */ @Override @Transactional - public void recordView(Long memberId, Long bookmarkId) { + public Bookmark recordView(Long memberId, Long bookmarkId) { int affected = bookmarkDao.incrementViewCount(bookmarkId, memberId); if (affected == 0) { + // 업데이트 실패 시 원인 파악 (404 vs 403) + validateBookmarkOwner(bookmarkId, memberId); throw BookmarkException.of(BookmarkErrorCode.BOOKMARK_NOT_FOUND); } + return bookmarkDao.selectBookmark(memberId, bookmarkId); } @@ -184,6 +194,8 @@ public Long deleteBookmark(Long memberId, Long bookmarkId) { int result = bookmarkDao.deleteBookmark(memberId, bookmarkId); if (result == 0) { + // 삭제 실패 시 원인 파악 (404 vs 403) + validateBookmarkOwner(bookmarkId, memberId); throw BookmarkException.of(BookmarkErrorCode.BOOKMARK_NOT_FOUND); } @@ -196,6 +208,20 @@ public Long deleteBookmark(Long memberId, Long bookmarkId) { + /** + * 북마크 소유권 및 존재 여부 검증 (에러 구분용) + */ + private void validateBookmarkOwner(Long bookmarkId, Long memberId) { + Bookmark bookmark = bookmarkDao.findById(bookmarkId); + if (bookmark == null || bookmark.getDeletedAt() != null) { + throw BookmarkException.of(BookmarkErrorCode.BOOKMARK_NOT_FOUND); + } + if (!bookmark.getCreatedByMemberId().equals(memberId)) { + throw BookmarkException.of(BookmarkErrorCode.ACCESS_DENIED); + } + } + + // ========== Helper Methods ========== /** * 링크 조회 또는 생성 (URL 정규화) diff --git a/src/main/java/com/web/SearchWeb/bookmark/service/command/BookmarkSearchCommand.java b/src/main/java/com/web/SearchWeb/bookmark/service/command/BookmarkSearchCommand.java index e552835..a53748d 100644 --- a/src/main/java/com/web/SearchWeb/bookmark/service/command/BookmarkSearchCommand.java +++ b/src/main/java/com/web/SearchWeb/bookmark/service/command/BookmarkSearchCommand.java @@ -1,5 +1,6 @@ package com.web.SearchWeb.bookmark.service.command; +import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -9,8 +10,8 @@ * Bookmark 검색 요청 Command */ @Getter -@NoArgsConstructor -@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor(access = AccessLevel.PRIVATE) @Builder public class BookmarkSearchCommand { private Long memberId; // created_by_member_id로 필터 @@ -21,7 +22,7 @@ public class BookmarkSearchCommand { private Boolean unreadOnly; // true이면 view_count = 0 인 북마크만 조회 private Boolean savedTodayOnly; // true이면 오늘 생성된 북마크만 조회 @Builder.Default - private Integer limit = 100; // list query limit + private Integer limit = 100; // 최대 조회 개수 (Limit) @Builder.Default - private Integer offset = 0; // list query offset + private Integer offset = 0; // 조회 시작 위치 (Offset) } \ No newline at end of file diff --git a/src/main/java/com/web/SearchWeb/folder/dao/MemberFolderJpaDao.java b/src/main/java/com/web/SearchWeb/folder/dao/MemberFolderJpaDao.java index dc3310c..2a88282 100644 --- a/src/main/java/com/web/SearchWeb/folder/dao/MemberFolderJpaDao.java +++ b/src/main/java/com/web/SearchWeb/folder/dao/MemberFolderJpaDao.java @@ -23,9 +23,11 @@ public interface MemberFolderJpaDao extends JpaRepository { boolean existsByOwnerMemberIdAndParentFolderIdIsNullAndFolderName(Long ownerMemberId, String normalizedFolderName); + boolean existsByOwnerMemberIdAndFolderType(Long ownerMemberId, FolderType folderType); + // 시스템 폴더(UNORGANIZED) 조회 — 한 사용자당 최대 1개 (partial unique index 로 보장) Optional findFirstByOwnerMemberIdAndFolderType(Long ownerMemberId, FolderType folderType); // 루트에 같은 이름 폴더가 이미 있는지 조회 (흡수(absorb) 처리에 사용) - Optional findFirstByOwnerMemberIdAndParentFolderIdIsNullAndFolderName(Long ownerMemberId, String folderName); + Optional findFirstByOwnerMemberIdAndParentFolderIdIsNullAndFolderName(Long ownerMemberId, String normalizedFolderName); } diff --git a/src/main/java/com/web/SearchWeb/folder/domain/MemberFolder.java b/src/main/java/com/web/SearchWeb/folder/domain/MemberFolder.java index 01043d1..0604400 100644 --- a/src/main/java/com/web/SearchWeb/folder/domain/MemberFolder.java +++ b/src/main/java/com/web/SearchWeb/folder/domain/MemberFolder.java @@ -48,6 +48,7 @@ public boolean isUnorganized() { /** 기존 폴더를 시스템 폴더(UNORGANIZED)로 마킹. 이름 충돌 흡수(absorb) 경로에서 사용. */ public void markAsUnorganized() { + if (isUnorganized()) return; this.folderType = FolderType.UNORGANIZED; } diff --git a/src/main/java/com/web/SearchWeb/folder/service/MemberFolderServiceImpl.java b/src/main/java/com/web/SearchWeb/folder/service/MemberFolderServiceImpl.java index 083c1fd..bae1262 100644 --- a/src/main/java/com/web/SearchWeb/folder/service/MemberFolderServiceImpl.java +++ b/src/main/java/com/web/SearchWeb/folder/service/MemberFolderServiceImpl.java @@ -11,7 +11,10 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; @Slf4j @@ -23,6 +26,12 @@ public class MemberFolderServiceImpl implements MemberFolderService { private final MemberFolderJpaDao memberFolderJpaRepository; private final BookmarkDao bookmarkDao; + private MemberFolderService self; + + @Autowired + public void setSelf(@Lazy MemberFolderService self) { + this.self = self; + } @Override @Transactional @@ -104,11 +113,13 @@ public MemberFolder get(Long memberId, Long memberFolderId) { } @Override - @Transactional + @Transactional(readOnly = true) public List listRootFolders(Long memberId, Long ownerMemberId) { validateOwner(memberId, ownerMemberId); // 기존 회원 대상 lazy 백필: 조회 호출 시 미분류 폴더가 없으면 이 시점에 생성 - getOrCreateUnorganizedFolderId(ownerMemberId); + if (!memberFolderJpaRepository.existsByOwnerMemberIdAndFolderType(ownerMemberId, FolderType.UNORGANIZED)) { + self.getOrCreateUnorganizedFolderId(ownerMemberId); + } return memberFolderJpaRepository.findAllByOwnerMemberIdAndParentFolderIdIsNull(ownerMemberId); } @@ -221,7 +232,7 @@ public void delete(Long memberId, Long memberFolderId) { } @Override - @Transactional + @Transactional(propagation = Propagation.REQUIRES_NEW) public Long getOrCreateUnorganizedFolderId(Long memberId) { // 1. 이미 시스템 폴더가 존재하면 해당 ID 반환 (idempotent) Optional existing = memberFolderJpaRepository @@ -230,25 +241,27 @@ public Long getOrCreateUnorganizedFolderId(Long memberId) { return existing.get().getMemberFolderId(); } - // 2. 이름("미분류") 이 루트에 이미 있으면 그 커스텀 폴더를 시스템 폴더로 전환 - Optional sameName = memberFolderJpaRepository - .findFirstByOwnerMemberIdAndParentFolderIdIsNullAndFolderName(memberId, UNORGANIZED_FOLDER_NAME); - if (sameName.isPresent()) { - MemberFolder existingFolder = sameName.get(); - existingFolder.markAsUnorganized(); - return memberFolderJpaRepository.save(existingFolder).getMemberFolderId(); - } - - // 3. 신규 생성 — 동시 호출은 partial unique index 로 DB 에서 차단, catch 후 재조회 + // 2 & 3. 동시 호출은 partial unique index 로 DB 에서 차단됨 -> catch 후 재조회 try { + // 2. 이름("미분류") 이 루트에 이미 있으면 그 커스텀 폴더를 시스템 폴더로 전환 + Optional sameName = memberFolderJpaRepository + .findFirstByOwnerMemberIdAndParentFolderIdIsNullAndFolderName(memberId, UNORGANIZED_FOLDER_NAME); + if (sameName.isPresent()) { + MemberFolder existingFolder = sameName.get(); + existingFolder.markAsUnorganized(); + return memberFolderJpaRepository.save(existingFolder).getMemberFolderId(); + } + + // 3. 신규 생성 MemberFolder newFolder = MemberFolder.builder() .ownerMemberId(memberId) .folderName(UNORGANIZED_FOLDER_NAME) .folderType(FolderType.UNORGANIZED) .build(); return memberFolderJpaRepository.save(newFolder).getMemberFolderId(); + } catch (DataIntegrityViolationException e) { - // 동시 요청으로 인한 중복 생성 방지: 제약 조건 위반 시 이미 생성된 폴더를 재조회하여 반환 + // 동시 요청으로 인한 중복 생성/전환 방지: 제약 조건 위반 시 이미 생성된 폴더를 재조회하여 반환 return memberFolderJpaRepository .findFirstByOwnerMemberIdAndFolderType(memberId, FolderType.UNORGANIZED) .orElseThrow(() -> new FolderException(FolderErrorCode.DEFAULT_FOLDER_NAME_CONFLICT)) diff --git a/src/main/resources/mapper/bookmark-mapper.xml b/src/main/resources/mapper/bookmark-mapper.xml index bc02e71..5fa1092 100644 --- a/src/main/resources/mapper/bookmark-mapper.xml +++ b/src/main/resources/mapper/bookmark-mapper.xml @@ -85,6 +85,14 @@ + + + +