Conversation
Walkthrough클라이언트 북마크 페이지와 관련 UI 컴포넌트, BookmarkAPI, 인증 유틸(AuthUtils) 및 타입 정의를 추가하고 헤더의 북마크 링크를 Changes
Sequence Diagram(s)sequenceDiagram
autonumber
actor User
participant Page as BookmarksPage
participant Components as UI Components
participant API as BookmarkAPI
participant Auth as AuthUtils
Note over Page: 페이지 진입
User->>Page: /bookmarks 요청
Page->>Auth: hasToken/getToken
Page->>API: getBookmarks()
API-->>Page: Bookmark[]
Note over Components: 목록 표시 & 상호작용
User->>Components: 검색/뷰 전환/아이템 클릭
alt 북마크 추가/수정
User->>Components: Add/Edit 클릭
Components->>Components: BookmarkModal 열기
User->>Components: 제출
Components->>Auth: getAuthHeaders
Components->>API: createBookmark/updateBookmark
API-->>Components: Bookmark
Components->>Page: 목록 갱신
end
alt 삭제
User->>Components: 삭제 클릭
Components->>API: deleteBookmark
API-->>Components: ok
Components->>Page: 목록에서 제거
end
alt 클릭(시간 확인)
User->>Components: 아이템 클릭
Components->>Components: ConfirmModal
User->>Components: 확인
Components->>API: clickBookmark
API-->>Components: ok
Components->>User: /result?url=... 새 탭 오픈
end
alt 비로그인 시 보호 동작
User->>Components: 보호된 작업 시도
Components->>Components: LoginModal/SignupModal 표시
User->>Components: 로그인/회원가입 완료
Components->>Auth: setToken
Components->>API: getBookmarks()
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Poem
Pre-merge checks and finishing touches❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✨ Finishing touches
🧪 Generate unit tests
📜 Recent review detailsConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro 📒 Files selected for processing (2)
🚧 Files skipped from review as they are similar to previous changes (2)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (18)
src/components/bookmarks/BookmarkItem.tsx (2)
20-27: favicon URL 생성 로직 개선 필요현재 모든 도메인에서
/favicon.ico경로를 사용한다고 가정하고 있지만, 실제로는 많은 사이트들이 다른 경로나 형식의 favicon을 사용합니다. 또한bookmark.favicon필드가 있음에도 불구하고 사용되지 않고 있습니다.const getFaviconUrl = (url: string) => { try { const urlObj = new URL(url); - return `${urlObj.origin}/favicon.ico`; + // bookmark.favicon이 있으면 우선 사용 + if (bookmark.favicon) { + // 절대 URL인지 확인 + try { + new URL(bookmark.favicon); + return bookmark.favicon; + } catch { + // 상대 경로인 경우 origin과 결합 + return `${urlObj.origin}${bookmark.favicon.startsWith('/') ? '' : '/'}${bookmark.favicon}`; + } + } + // 기본값으로 /favicon.ico 사용 + return `${urlObj.origin}/favicon.ico`; } catch { return null; } };
47-51: 이미지 에러 핸들링에서 DOM 직접 조작 지양
onError핸들러에서 DOM을 직접 조작하는 대신 React state를 사용하는 것이 더 안전하고 React 패러다임에 맞습니다.+const [faviconError, setFaviconError] = useState(false); {bookmark.favicon && faviconUrl ? ( <img src={faviconUrl} alt={bookmark.custom_name} className="w-8 h-8 rounded" - onError={(e) => { - e.currentTarget.style.display = 'none'; - const nextElement = e.currentTarget.nextElementSibling as HTMLElement; - if (nextElement) nextElement.style.display = 'block'; - }} + onError={() => setFaviconError(true)} + style={{ display: faviconError ? 'none' : 'block' }} /> ) : null} -<span style={{ display: bookmark.favicon && faviconUrl ? 'none' : 'block' }}> +<span style={{ display: !bookmark.favicon || !faviconUrl || faviconError ? 'block' : 'none' }}> </span>Also applies to: 109-113
src/types/bookmark.ts (1)
12-28: 중복된 타입 정의 통합 고려
BookmarkCreateRequest,BookmarkUpdateRequest,BookmarkFormData세 인터페이스가 동일한 필드를 가지고 있습니다. 타입 별칭이나 상속을 통해 중복을 줄일 수 있습니다.-export interface BookmarkCreateRequest { - custom_name: string; - custom_url: string; - favicon?: string; // 선택적 필드로 유지 (백엔드에서 사용) -} - -export interface BookmarkUpdateRequest { - custom_name: string; - custom_url: string; - favicon?: string; -} - -export interface BookmarkFormData { - custom_name: string; - custom_url: string; - favicon?: string; -} +export interface BookmarkFormData { + custom_name: string; + custom_url: string; + favicon?: string; +} + +export type BookmarkCreateRequest = BookmarkFormData; +export type BookmarkUpdateRequest = BookmarkFormData;src/components/bookmarks/BookmarkForm.tsx (1)
54-61: URL 검증 로직 개선 필요현재 URL 검증이 너무 엄격할 수 있습니다. 프로토콜 없는 URL(예:
example.com)도 허용하고 자동으로https://를 추가하는 것이 사용자 경험에 더 좋을 수 있습니다.const isValidUrl = (url: string): boolean => { try { + // 프로토콜이 없으면 https:// 추가 + if (!url.match(/^https?:\/\//)) { + url = `https://${url}`; + } new URL(url); return true; } catch { return false; } }; +// handleChange 함수도 수정 +const handleChange = (field: keyof BookmarkFormData, value: string) => { + // URL 필드인 경우 프로토콜 자동 추가 + if (field === 'custom_url' && value && !value.match(/^https?:\/\//)) { + value = `https://${value}`; + } + setFormData((prev: BookmarkFormData) => ({ ...prev, [field]: value })); + // ... +};src/components/bookmarks/BookmarkModal.tsx (2)
95-99:any타입 사용 지양TypeScript의 타입 안정성을 위해
any대신 명확한 타입을 사용해야 합니다.- const submitData: any = { + const submitData: BookmarkFormData = { custom_name: formData.custom_name.trim(), custom_url: formData.custom_url.trim(), // favicon 필드는 전송하지 않음 (백엔드에서 자동 처리) };
80-87: BookmarkForm 컴포넌트의 URL 검증 로직과 중복
isValidUrl함수가BookmarkForm컴포넌트에도 동일하게 존재합니다. 공통 유틸리티 함수로 추출하는 것이 좋습니다.+// src/utils/validation.ts 파일 생성 +export const isValidUrl = (url: string): boolean => { + try { + new URL(url); + return true; + } catch { + return false; + } +};그리고 두 컴포넌트에서 import하여 사용:
+import { isValidUrl } from '@/utils/validation'; -const isValidUrl = (url: string): boolean => { - try { - new URL(url); - return true; - } catch { - return false; - } -};src/components/bookmarks/BookmarkList.tsx (2)
77-91: 시간 확인 시 에러 처리 개선 필요
window.open이 팝업 차단기에 의해 차단될 수 있습니다. 이 경우를 처리하고 사용자에게 알려야 합니다.const executeCheckTime = async () => { if (!selectedBookmark) return; try { await BookmarkAPI.clickBookmark(selectedBookmark.id); // 시간 확인 결과를 새 창에서 열기 - window.open(`/result?url=${encodeURIComponent(selectedBookmark.custom_url)}`, '_blank'); + const newWindow = window.open(`/result?url=${encodeURIComponent(selectedBookmark.custom_url)}`, '_blank'); + if (!newWindow) { + alert('팝업이 차단되었습니다. 팝업 차단을 해제해 주세요.'); + } setConfirmModalOpen(false); setSelectedBookmark(null); } catch (err) { alert(err instanceof Error ? err.message : '시간 확인에 실패했습니다.'); setConfirmModalOpen(false); setSelectedBookmark(null); } };
175-186: 북마크가 없을 때 빈 상태 UI 추가 권장북마크 목록이 비어있을 때 사용자에게 더 나은 경험을 제공하기 위해 빈 상태 UI를 추가하는 것이 좋습니다.
{/* 북마크 목록 */} +{bookmarks.length === 0 ? ( + <div className="text-center py-12"> + <svg className="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"> + <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" /> + </svg> + <h3 className="mt-2 text-sm font-medium text-gray-900">북마크가 없습니다</h3> + <p className="mt-1 text-sm text-gray-500">새로운 북마크를 추가해 보세요.</p> + <div className="mt-6"> + <button + onClick={handleAdd} + className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700" + > + 북마크 추가 + </button> + </div> + </div> +) : ( <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> {bookmarks.map((bookmark) => ( <BookmarkItem key={bookmark.id} bookmark={bookmark} onEdit={handleEdit} onDelete={handleDelete} onCheckTime={handleCheckTime} viewMode="grid" /> ))} </div> +)}src/libs/auth.ts (1)
17-22: 토큰 저장/삭제 일관성 필요 — AuthUtils에 저장 로직 통합 권장발견: refreshToken·userName은 src/app/page.tsx(라인 110–113) 및 src/app/bookmarks/page.tsx(라인 364–367)에서 localStorage에 직접 저장되는 반면, src/libs/auth.ts의 AuthUtils.setToken은 accessToken만 저장(setToken: src/libs/auth.ts:11–13). removeToken은 accessToken·refreshToken·userName을 모두 제거(src/libs/auth.ts:17–21).
권장: 저장/삭제 로직을 중앙화(예: AuthUtils.setToken(token, refreshToken?, userName?))하거나 removeToken과 setToken이 동일한 키를 다루도록 맞출 것.
src/app/bookmarks/page.tsx (5)
103-108: 비로그인 상태에서 시간 확인 시 로그인 유도 필요현재는 비로그인도 확인 모달을 띄운 후 API 호출에서 401이 발생합니다.
handleAdd와 동일하게 클릭 시 로그인 모달을 유도하는 편이 UX와 오류율 측면에서 낫습니다.const handleCheckTime = (bookmark: Bookmark) => { - setSelectedBookmark(bookmark); - setConfirmOpen(true); + if (!isAuthed) { + setLoginOpen(true); + return; + } + setSelectedBookmark(bookmark); + setConfirmOpen(true); };
91-101: 브라우저 confirm 대신 공용 ConfirmModal 사용으로 UX 일관화삭제 확인은
window.confirm대신 이미 존재하는ConfirmModal을 재사용하세요. 접근성/디자인 일관성 확보됩니다.간단 대안: 삭제용
ConfirmModal상태(열림/대상 id) 추가 후, 확인 시BookmarkAPI.deleteBookmark실행.
171-189: 내부 라우팅은 Link 사용 및 빈#제거
/bookmarks는<a>대신next/link를 사용해 전체 리로드를 피하세요.href="#"는 불필요한 최상단 이동을 유발합니다.- <a href="/bookmarks" className="text-black text-sm font-semibold no-underline">북마크</a> + <Link href="/bookmarks" className="text-black text-sm font-semibold no-underline">북마크</Link> - <a href="#" className="text-gray-600 text-sm font-medium hover:text-black transition-colors no-underline">실시간 랭킹</a> + <button className="text-gray-600 text-sm font-medium hover:text-black transition-colors">실시간 랭킹</button>
151-161: 토큰 삭제 로직 단일화직접
localStorage조작 대신AuthUtils.removeToken()을 사용해 중복/누락 위험을 줄이세요.- localStorage.removeItem('accessToken'); - localStorage.removeItem('refreshToken'); - localStorage.removeItem('userName'); + AuthUtils.removeToken();
349-377: 인증 API 호출 분리(중복 제거 및 응답 스키마 흡수)로그인/회원가입
fetch로직을 페이지에 직접 두면 응답 스키마 변경에 취약합니다.src/libs/api/auth.ts등으로 이동해 일관 처리(에러 파싱, 토큰 저장)하세요.Also applies to: 389-413
src/libs/api/bookmarks.ts (4)
178-198: 클릭 API도 빈/비-JSON 응답 대비 필요리다이렉트/204/텍스트 응답일 수 있습니다. JSON 전제 제거가 안전합니다.
- const result = await response.json(); - - // 클릭은 검색 결과를 반환하므로 성공 여부만 확인 - if (!result.success) { - throw new Error('북마크 클릭 처리에 실패했습니다.'); - } + const ct = response.headers.get('content-type') || ''; + if (ct.includes('application/json')) { + const result = await response.json(); + if (result && result.success === false) { + throw new Error('북마크 클릭 처리에 실패했습니다.'); + } + }
12-16: GET 요청에 불필요한 Content-Type 헤더 제거
getAuthHeaders()가 모든 요청에Content-Type: application/json을 추가합니다. GET에는 불필요하며 일부 서버에서 엄격히 검사합니다. 선택적으로 포함하도록 변경을 권장합니다.
42-56: 광범위한 console 로그는 제거 또는 디버그 플래그로 제한PII/운영 로그 과다 노출 우려.
process.env.NODE_ENV !== 'production'가드로 제한하거나 로거로 대체하세요.Also applies to: 79-86, 90-106, 128-133
21-23: 에러 메시지에 상태코드 포함(디버깅 용이성 향상)고정 문구 대신 상태코드를 포함해 원인 파악을 빠르게 하세요.
- throw new Error('북마크 목록을 가져오는데 실패했습니다.'); + throw new Error(`북마크 목록을 가져오는데 실패했습니다. (status=${response.status})`);
📜 Review details
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (9)
src/app/bookmarks/page.tsx(1 hunks)src/components/bookmarks/BookmarkForm.tsx(1 hunks)src/components/bookmarks/BookmarkItem.tsx(1 hunks)src/components/bookmarks/BookmarkList.tsx(1 hunks)src/components/bookmarks/BookmarkModal.tsx(1 hunks)src/components/ui/Header.tsx(1 hunks)src/libs/api/bookmarks.ts(1 hunks)src/libs/auth.ts(1 hunks)src/types/bookmark.ts(1 hunks)
🧰 Additional context used
🧬 Code graph analysis (6)
src/components/bookmarks/BookmarkList.tsx (5)
src/types/bookmark.ts (2)
Bookmark(1-10)BookmarkFormData(24-28)src/libs/api/bookmarks.ts (1)
BookmarkAPI(10-199)src/components/bookmarks/BookmarkItem.tsx (1)
BookmarkItem(13-153)src/components/bookmarks/BookmarkModal.tsx (1)
BookmarkModal(15-214)src/components/ui/ConfirmModal.tsx (1)
ConfirmModal(15-105)
src/app/bookmarks/page.tsx (7)
src/types/bookmark.ts (2)
Bookmark(1-10)BookmarkFormData(24-28)src/libs/api/bookmarks.ts (1)
BookmarkAPI(10-199)src/components/bookmarks/BookmarkItem.tsx (1)
BookmarkItem(13-153)src/components/bookmarks/BookmarkModal.tsx (1)
BookmarkModal(15-214)src/components/auth/LoginModal.tsx (1)
LoginModal(15-174)src/components/auth/SignupModal.tsx (1)
SignupModal(16-252)src/components/ui/ConfirmModal.tsx (1)
ConfirmModal(15-105)
src/components/bookmarks/BookmarkModal.tsx (1)
src/types/bookmark.ts (2)
Bookmark(1-10)BookmarkFormData(24-28)
src/components/bookmarks/BookmarkItem.tsx (1)
src/types/bookmark.ts (1)
Bookmark(1-10)
src/libs/api/bookmarks.ts (2)
src/types/bookmark.ts (3)
Bookmark(1-10)BookmarkCreateRequest(12-16)BookmarkUpdateRequest(18-22)src/libs/auth.ts (1)
AuthUtils(3-37)
src/components/bookmarks/BookmarkForm.tsx (1)
src/types/bookmark.ts (2)
Bookmark(1-10)BookmarkFormData(24-28)
🔇 Additional comments (1)
src/components/ui/Header.tsx (1)
61-66: LGTM! 북마크 페이지로의 라우팅이 올바르게 설정되었습니다.기존의 placeholder 링크(
#)가 실제 북마크 페이지 경로(/bookmarks)로 적절히 변경되었습니다.
| const executeCheckTime = async () => { | ||
| if (!selectedBookmark) return; | ||
|
|
||
| try { | ||
| await BookmarkAPI.clickBookmark(selectedBookmark.id); | ||
| // 시간 확인 결과를 새 창에서 열기 | ||
| window.open(`/result?url=${encodeURIComponent(selectedBookmark.custom_url)}`, '_blank'); | ||
| setConfirmOpen(false); | ||
| setSelectedBookmark(null); | ||
| } catch (err) { |
There was a problem hiding this comment.
팝업 차단 회피: 새 탭을 동기적으로 먼저 열고 URL 전환하세요.
사용자 제스처(클릭) 이후 네트워크 대기(await) 뒤에 window.open을 호출하면 브라우저 팝업 차단에 걸릴 수 있습니다. 동기적으로 빈 탭을 먼저 열고, 요청 성공 시 그 탭의 location을 갱신하세요.
- try {
- await BookmarkAPI.clickBookmark(selectedBookmark.id);
- // 시간 확인 결과를 새 창에서 열기
- window.open(`/result?url=${encodeURIComponent(selectedBookmark.custom_url)}`, '_blank');
- setConfirmOpen(false);
- setSelectedBookmark(null);
- } catch (err) {
+ const newTab = window.open('about:blank', '_blank', 'noopener,noreferrer');
+ try {
+ await BookmarkAPI.clickBookmark(selectedBookmark.id);
+ const targetUrl = `/result?url=${encodeURIComponent(selectedBookmark.custom_url)}`;
+ if (newTab) newTab.location.href = targetUrl;
+ else window.open(targetUrl, '_blank', 'noopener,noreferrer');
+ setConfirmOpen(false);
+ setSelectedBookmark(null);
+ } catch (err) {
alert(err instanceof Error ? err.message : '시간 확인에 실패했습니다.');
setConfirmOpen(false);
setSelectedBookmark(null);
- }
+ if (newTab) newTab.close();
+ }Committable suggestion skipped: line range outside the PR's diff.
🤖 Prompt for AI Agents
In src/app/bookmarks/page.tsx around lines 110 to 119, calling window.open after
awaiting BookmarkAPI.clickBookmark can trigger browser popup blockers; open a
new blank tab synchronously on the user gesture before the await, save the
window reference, then after the API call succeeds set that window's location to
the encoded result URL (and on failure close the opened tab or navigate to an
error page); ensure you still call setConfirmOpen(false) and
setSelectedBookmark(null) after success and handle errors by closing the tab and
reporting the failure.
| const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE; | ||
|
|
There was a problem hiding this comment.
환경변수 미설정 방어 로직 추가
NEXT_PUBLIC_API_BASE가 비어있으면 런타임에 URL 파싱 오류가 납니다. 각 메서드 진입 시 조기 검증하세요.
-const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE;
+const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE;
+function ensureBaseUrl(): string {
+ if (!API_BASE_URL) {
+ throw new Error('환경변수 NEXT_PUBLIC_API_BASE 가 설정되지 않았습니다.');
+ }
+ return API_BASE_URL;
+}그리고 각 fetch 호출의 템플릿 리터럴 앞에서 const base = ensureBaseUrl(); 사용.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE; | |
| const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE; | |
| function ensureBaseUrl(): string { | |
| if (!API_BASE_URL) { | |
| throw new Error('환경변수 NEXT_PUBLIC_API_BASE 가 설정되지 않았습니다.'); | |
| } | |
| return API_BASE_URL; | |
| } |
🤖 Prompt for AI Agents
In src/libs/api/bookmarks.ts around lines 8-9, the module currently reads
NEXT_PUBLIC_API_BASE directly which leads to runtime URL parsing errors if the
env var is empty; add a small helper ensureBaseUrl() that validates and
normalizes process.env.NEXT_PUBLIC_API_BASE (throwing a clear error if
missing/invalid) and then, inside each exported API method, call const base =
ensureBaseUrl(); at the start and use that base variable instead of directly
interpolating the template literal URL so all fetch calls use the validated
base.
| static async deleteBookmark(id: number): Promise<void> { | ||
| const response = await fetch(`${API_BASE_URL}/api/bookmarks/${id}`, { | ||
| method: 'DELETE', | ||
| headers: AuthUtils.getAuthHeaders(), | ||
| }); | ||
|
|
||
| if (response.status === 401) { | ||
| throw new Error('로그인이 필요합니다. 다시 로그인해주세요.'); | ||
| } | ||
|
|
||
| if (!response.ok) { | ||
| throw new Error('북마크 삭제에 실패했습니다.'); | ||
| } | ||
|
|
||
| const result = await response.json(); | ||
|
|
||
| // 삭제는 { success: true, data: [...] } 형태로 반환하지만 성공이면 OK | ||
| if (!result.success) { | ||
| throw new Error('북마크 삭제에 실패했습니다.'); | ||
| } | ||
| } |
There was a problem hiding this comment.
DELETE 204/빈 응답 처리 누락(런타임 예외 가능)
성공 시 204 No Content를 반환하는 백엔드는 흔합니다. 현재는 무조건 response.json()을 호출해 예외가 발생할 수 있습니다. 본문 유무를 확인하세요.
static async deleteBookmark(id: number): Promise<void> {
- const response = await fetch(`${API_BASE_URL}/api/bookmarks/${id}`, {
+ const base = ensureBaseUrl();
+ const response = await fetch(`${base}/api/bookmarks/${id}`, {
method: 'DELETE',
headers: AuthUtils.getAuthHeaders(),
});
@@
- const result = await response.json();
-
- // 삭제는 { success: true, data: [...] } 형태로 반환하지만 성공이면 OK
- if (!result.success) {
- throw new Error('북마크 삭제에 실패했습니다.');
- }
+ // 204 또는 빈 본문 처리
+ const contentType = response.headers.get('content-type') || '';
+ const hasBody = contentType.includes('application/json');
+ if (!hasBody) return;
+ const result = await response.json();
+ if (result && result.success === false) {
+ throw new Error('북마크 삭제에 실패했습니다.');
+ }
}Committable suggestion skipped: line range outside the PR's diff.
🤖 Prompt for AI Agents
In src/libs/api/bookmarks.ts around lines 156 to 176, the code always calls
response.json() which will throw on a 204 No Content; update the success
handling to first accept HTTP 204 (or response.ok with no body) as success and
only call response.json() when a body is present (e.g., status !== 204 and
headers/content-type indicates JSON or content-length > 0). If status is 204 or
response.ok with no body, return immediately; otherwise parse JSON and validate
result.success as before, and keep the existing 401 and non-ok checks.
📌 작업 내용
📸 스크린샷
📝 기타
Summary by CodeRabbit
신기능
내비게이션