diff --git a/CLAUDE.md b/CLAUDE.md index 6e4c1ba..e93e851 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -95,6 +95,30 @@ export interface ComponentProps { ... } export function Component({ ... }: ComponentProps) { ... } ``` +### Memoization (useMemo / useCallback) +**원칙: 측정 먼저, 최적화 나중.** 추측으로 추가하지 않는다. + +**사용하는 경우:** +- 계산 비용 ≥1ms (`console.time()`으로 측정 확인) +- `memo()`로 감싼 자식에 객체/함수를 props로 전달할 때 (세트로 사용) +- 다른 Hook의 deps 배열에 객체/배열/함수가 들어갈 때 (참조 안정화) +- 커스텀 훅에서 함수를 반환할 때 (소비자 최적화 여지 보장) +- Context Provider의 value prop + +**사용하지 않는 경우:** +- `memo()` 없는 자식에게 전달되는 props (효과 없음) +- 단순 계산 (`.map()`, `.filter()`, 문자열 접합, 날짜 포맷 등) +- "혹시 모르니까" 예방적 추가 (코드 복잡성 + 메모리 비용만 증가) +- deps가 매 렌더마다 바뀌는 경우 (캐시 히트 0%) +- useEffect deps 문제를 우회하기 위해 (근본 원인을 수정할 것) + +> React Compiler 1.0 (2025.10 stable)이 수동 메모이제이션을 자동화한다. +> 지금 최소화하면 향후 마이그레이션에도 유리. +> +> 근거: [React 공식 useMemo](https://react.dev/reference/react/useMemo) | +> [Kent C. Dodds](https://kentcdodds.com/blog/usememo-and-usecallback) | +> [Josh Comeau](https://www.joshwcomeau.com/react/usememo-and-usecallback/) + ## 구현 워크플로우 (IMPORTANT) ### 자동 TDD 적용 diff --git a/VERSION b/VERSION index 781dcb0..65087b4 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.3 +1.1.4 diff --git a/e2e/pages/banner-detail.page.ts b/e2e/pages/banner-detail.page.ts index db07585..41c1a58 100644 --- a/e2e/pages/banner-detail.page.ts +++ b/e2e/pages/banner-detail.page.ts @@ -24,16 +24,16 @@ export class BannerDetailPage extends BasePage { readonly loadingState = () => this.page.getByText('로딩 중...'); - readonly nameInput = () => this.page.getByPlaceholder('배너명을 입력하세요'); + readonly nameInput = () => this.page.getByPlaceholder('설명을 입력하세요'); readonly bannerTypeSelect = () => this.page.locator('button[role="combobox"]').filter({ hasText: /^\s*(설문조사|큐레이션|광고|제휴|기타|배너 타입 선택)\s*$/ }); readonly isActiveSwitch = () => this.page.locator('button#isActive'); - readonly descriptionAInput = () => this.page.getByPlaceholder('첫 번째 줄 설명'); + readonly descriptionAInput = () => this.page.getByPlaceholder('제목 첫번째줄을 입력하세요'); - readonly descriptionBInput = () => this.page.getByPlaceholder('두 번째 줄 설명'); + readonly descriptionBInput = () => this.page.getByPlaceholder('제목 두번째줄을 입력하세요'); readonly textPositionSelect = () => this.page.locator('button[role="combobox"]').filter({ hasText: /좌측 상단|좌측 하단|우측 상단|우측 하단|중앙|텍스트 위치 선택/ }); diff --git a/src/components/common/ColorPickerInput.tsx b/src/components/common/ColorPickerInput.tsx new file mode 100644 index 0000000..b7d75c7 --- /dev/null +++ b/src/components/common/ColorPickerInput.tsx @@ -0,0 +1,98 @@ +import { useRef } from 'react'; +import { Pipette } from 'lucide-react'; + +import { cn } from '@/lib/utils'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; + +const PRESET_COLORS = [ + 'ffffff', '000000', 'f5f5f5', '333333', 'a3a3a3', '737373', + 'ef4444', 'f97316', 'eab308', '22c55e', '3b82f6', '8b5cf6', + 'ec4899', '78716c', '0ea5e9', '14b8a6', 'f43f5e', 'd97706', +] as const; + +interface ColorPickerInputProps { + value: string; + onChange: (hex: string) => void; + error?: string; + id?: string; +} + +export function ColorPickerInput({ value, onChange, error, id }: ColorPickerInputProps) { + const nativeInputRef = useRef(null); + + const handleNativeChange = (e: React.ChangeEvent) => { + onChange(e.target.value.replace('#', '')); + }; + + const handleTextChange = (e: React.ChangeEvent) => { + const raw = e.target.value.replace(/[^0-9a-fA-F]/g, '').slice(0, 6); + onChange(raw); + }; + + return ( +
+
+ + + + + + +
+ {PRESET_COLORS.map((color) => ( +
+
+ + +
+
+
+
+ ); +} diff --git a/src/components/common/TagSelector.tsx b/src/components/common/TagSelector.tsx index 1ea1a1d..07e4cfe 100644 --- a/src/components/common/TagSelector.tsx +++ b/src/components/common/TagSelector.tsx @@ -125,7 +125,7 @@ export function TagSelector({ selectedTags, availableTags, onTagsChange }: TagSe />
{filteredUnselectedTags.length > 0 ? ( -
+
{filteredUnselectedTags.map((tag) => ( 기본 정보 - - - - - + + + + + @@ -60,34 +69,22 @@ export function BannerTextSettingsCard({ form }: BannerTextSettingsCardProps) {
- -
-
- -
+ + form.setValue('descriptionFontColor', hex)} + error={form.formState.errors.descriptionFontColor?.message} + /> - -
-
- -
+ + form.setValue('nameFontColor', hex)} + error={form.formState.errors.nameFontColor?.message} + />
diff --git a/src/pages/tasting-tags/TastingTagList.tsx b/src/pages/tasting-tags/TastingTagList.tsx index 8124000..b397a5a 100644 --- a/src/pages/tasting-tags/TastingTagList.tsx +++ b/src/pages/tasting-tags/TastingTagList.tsx @@ -34,7 +34,7 @@ export function TastingTagListPage() { // API 조회 const { data, isLoading } = useTastingTagList({ keyword: searchKeyword || undefined, - size: 100, // 태그는 보통 많지 않으므로 한 번에 조회 + size: 500, // 태그 전체를 한 번에 조회 }); // 삭제 mutation diff --git a/src/pages/whisky/WhiskyDetail.tsx b/src/pages/whisky/WhiskyDetail.tsx index 9d1cd91..ee4414d 100644 --- a/src/pages/whisky/WhiskyDetail.tsx +++ b/src/pages/whisky/WhiskyDetail.tsx @@ -50,8 +50,8 @@ export function WhiskyDetailPage() { rootPath: S3UploadPath.ALCOHOL, }); - // 기존 태그 목록 조회 - const { data: tagListData } = useTastingTagList({ size: 100 }); + // 기존 태그 목록 조회 (전체 태그를 한 번에 가져옴) + const { data: tagListData } = useTastingTagList({ size: 500 }); const availableTags = tagListData?.items.map((t) => t.korName) ?? []; // 로컬 상태 @@ -171,6 +171,7 @@ export function WhiskyDetailPage() { diff --git a/src/pages/whisky/components/WhiskyTastingTagCard.tsx b/src/pages/whisky/components/WhiskyTastingTagCard.tsx index 4f7e9ec..b30b6c3 100644 --- a/src/pages/whisky/components/WhiskyTastingTagCard.tsx +++ b/src/pages/whisky/components/WhiskyTastingTagCard.tsx @@ -8,31 +8,46 @@ import { TagSelector } from '@/components/common/TagSelector'; import type { AlcoholTastingTag } from '@/types/api'; +/** 태그 목록 아이템 (ID 매핑용) */ +interface TagListItem { + id: number; + korName: string; + engName: string; +} + /** * WhiskyTastingTagCard 컴포넌트의 props * @param tastingTags - 현재 선택된 테이스팅 태그 목록 - * @param availableTags - 선택 가능한 태그 목록 + * @param availableTags - 선택 가능한 태그 이름 목록 + * @param tagListItems - 전체 태그 목록 (ID 매핑용) * @param onTagsChange - 태그 변경 콜백 */ export interface WhiskyTastingTagCardProps { tastingTags: AlcoholTastingTag[]; availableTags?: string[]; + tagListItems?: TagListItem[]; onTagsChange: (tags: AlcoholTastingTag[]) => void; disabled?: boolean; } +/** 이름으로 태그 객체를 찾아 반환. 선택 목록 → 전체 목록 → 폴백 순서 */ +function resolveTag(name: string, selected: AlcoholTastingTag[], all: TagListItem[]): AlcoholTastingTag { + const fromSelected = selected.find((t) => t.korName === name); + if (fromSelected) return fromSelected; + const fromAll = all.find((t) => t.korName === name); + if (fromAll) return { id: fromAll.id, korName: fromAll.korName, engName: fromAll.engName }; + return { id: 0, korName: name, engName: name }; +} + export function WhiskyTastingTagCard({ tastingTags, availableTags = [], + tagListItems = [], onTagsChange, disabled = false, }: WhiskyTastingTagCardProps) { const handleTagsChange = (tags: string[]) => { - const newTags = tags.map((name) => { - const existing = tastingTags.find((t) => t.korName === name); - return existing ?? { id: 0, korName: name, engName: name }; - }); - onTagsChange(newTags); + onTagsChange(tags.map((name) => resolveTag(name, tastingTags, tagListItems))); }; return ( diff --git a/src/pages/whisky/useWhiskyDetailForm.ts b/src/pages/whisky/useWhiskyDetailForm.ts index bf0bac6..2ab7ad0 100644 --- a/src/pages/whisky/useWhiskyDetailForm.ts +++ b/src/pages/whisky/useWhiskyDetailForm.ts @@ -132,7 +132,7 @@ export function useWhiskyDetailForm(id: string | undefined): UseWhiskyDetailForm const onSubmit = ( data: WhiskyFormValues, - { relatedKeywords }: { tastingTags: AlcoholTastingTag[]; relatedKeywords: string[]; imagePreviewUrl: string | null } + { tastingTags, relatedKeywords }: { tastingTags: AlcoholTastingTag[]; relatedKeywords: string[]; imagePreviewUrl: string | null } ) => { // TODO: API에 relatedKeywords 필드 추가 시 요청 데이터에 포함 console.log('Related Keywords:', relatedKeywords); @@ -145,6 +145,9 @@ export function useWhiskyDetailForm(id: string | undefined): UseWhiskyDetailForm // 증류소 미선택(0) 시 '-' 항목의 ID로 대체 const distilleryId = data.distilleryId || (distilleryData?.items.find((d) => d.korName === '-')?.id ?? data.distilleryId); + // 테이스팅 태그 ID 목록 추출 + const tastingTagIds = tastingTags.map((tag) => tag.id); + if (isNewMode) { const createData: AlcoholCreateRequest = { korName: data.korName, @@ -161,6 +164,7 @@ export function useWhiskyDetailForm(id: string | undefined): UseWhiskyDetailForm imageUrl: data.imageUrl, description, volume: data.volume, + tastingTagIds, }; createMutation.mutate(createData); } else if (alcoholId) { @@ -179,6 +183,7 @@ export function useWhiskyDetailForm(id: string | undefined): UseWhiskyDetailForm imageUrl: data.imageUrl, description, volume: data.volume, + tastingTagIds, }; updateMutation.mutate({ alcoholId, data: updateData }); } diff --git a/src/types/api/alcohol.api.ts b/src/types/api/alcohol.api.ts index 488cedb..693404a 100644 --- a/src/types/api/alcohol.api.ts +++ b/src/types/api/alcohol.api.ts @@ -235,6 +235,8 @@ export interface AlcoholApiTypes { description: string; /** 용량 (예: "700ml") */ volume: string; + /** 연결할 테이스팅 태그 ID 목록 */ + tastingTagIds: number[]; }; /** 응답 데이터 */ response: { @@ -294,6 +296,8 @@ export interface AlcoholApiTypes { description: string; /** 용량 (예: "700ml") */ volume: string; + /** 연결할 테이스팅 태그 ID 목록 */ + tastingTagIds: number[]; }; /** 응답 데이터 */ response: {