From 41d41df64d2d0f9e238c0c37b216b8340d6af9b9 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Mon, 19 Jan 2026 14:06:19 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20Award=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EC=9C=A0=ED=8B=B8=20=ED=95=A8=EC=88=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - formatSemesterLabel: Award의 year와 semester를 포맷팅하여 문자열 반환 - getAwardKey: Award 객체와 인덱스로 고유 키 생성 - ClubIntroContent에서 사용하던 함수를 재사용 가능하도록 분리 --- frontend/src/utils/awardHelpers.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 frontend/src/utils/awardHelpers.ts diff --git a/frontend/src/utils/awardHelpers.ts b/frontend/src/utils/awardHelpers.ts new file mode 100644 index 00000000..849f64ef --- /dev/null +++ b/frontend/src/utils/awardHelpers.ts @@ -0,0 +1,13 @@ +import { Award, SemesterTerm } from '@/types/club'; + +export const formatSemesterLabel = (award: Award): string | null => { + if (award?.year && award?.semester) { + const semesterLabel = + award.semester === SemesterTerm.FIRST ? '1학기' : '2학기'; + return `${award.year} ${semesterLabel}`; + } + return null; +}; + +export const getAwardKey = (award: Award, index: number): string => + `${award.year}-${award.semester}-${index}`; From 03151987111835dd5dc568b4bbc97a364b6c5b0e Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Mon, 19 Jan 2026 14:06:28 +0900 Subject: [PATCH 2/4] =?UTF-8?q?test:=20awardHelpers=20=EC=9C=A0=ED=8B=B8?= =?UTF-8?q?=20=ED=95=A8=EC=88=98=20=EB=8B=A8=EC=9C=84=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - formatSemesterLabel 테스트 8개 (정상 케이스, null/undefined 처리) - getAwardKey 테스트 5개 (고유성 검증, 엣지 케이스) - Given 데이터 공통화로 테스트 가독성 향상 - 모든 테스트 통과 (13/13) --- frontend/src/utils/awardHelpers.test.ts | 99 +++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 frontend/src/utils/awardHelpers.test.ts diff --git a/frontend/src/utils/awardHelpers.test.ts b/frontend/src/utils/awardHelpers.test.ts new file mode 100644 index 00000000..a62aa2f2 --- /dev/null +++ b/frontend/src/utils/awardHelpers.test.ts @@ -0,0 +1,99 @@ +import { Award, SemesterTerm, SemesterTermType } from '@/types/club'; +import { formatSemesterLabel, getAwardKey } from './awardHelpers'; + +describe('awardHelpers', () => { + const createAward = ( + year: number, + semester: SemesterTermType, + achievements: string[] = [], + ): Award => ({ + year, + semester, + achievements, + }); + + const validAward2024First = createAward(2024, SemesterTerm.FIRST); + const validAward2024Second = createAward(2024, SemesterTerm.SECOND); + const validAward2023First = createAward(2023, SemesterTerm.FIRST); + const validAward2025Second = createAward(2025, SemesterTerm.SECOND); + + describe('formatSemesterLabel', () => { + it('1학기를 올바른 형식으로 반환해야 한다', () => { + expect(formatSemesterLabel(validAward2024First)).toBe('2024 1학기'); + }); + + it('2학기를 올바른 형식으로 반환해야 한다', () => { + expect(formatSemesterLabel(validAward2024Second)).toBe('2024 2학기'); + }); + + it('year가 없으면 null을 반환해야 한다', () => { + const award: Partial = { + semester: SemesterTerm.FIRST, + achievements: [], + }; + + expect(formatSemesterLabel(award as Award)).toBeNull(); + }); + + it('semester가 없으면 null을 반환해야 한다', () => { + const award: Partial = { + year: 2024, + achievements: [], + }; + + expect(formatSemesterLabel(award as Award)).toBeNull(); + }); + + it('year와 semester가 모두 없으면 null을 반환해야 한다', () => { + const award: Partial = { + achievements: [], + }; + + expect(formatSemesterLabel(award as Award)).toBeNull(); + }); + + it('award가 null이면 null을 반환해야 한다', () => { + expect(formatSemesterLabel(null as unknown as Award)).toBeNull(); + }); + + it('award가 undefined이면 null을 반환해야 한다', () => { + expect(formatSemesterLabel(undefined as unknown as Award)).toBeNull(); + }); + + it('다양한 연도를 올바르게 처리해야 한다', () => { + expect(formatSemesterLabel(validAward2023First)).toBe('2023 1학기'); + expect(formatSemesterLabel(validAward2025Second)).toBe('2025 2학기'); + }); + }); + + describe('getAwardKey', () => { + it('year, semester, index를 조합한 고유 키를 생성해야 한다', () => { + expect(getAwardKey(validAward2024First, 0)).toBe('2024-FIRST-0'); + }); + + it('인덱스가 다르면 다른 키를 생성해야 한다', () => { + const key1 = getAwardKey(validAward2024First, 0); + const key2 = getAwardKey(validAward2024First, 1); + + expect(key1).not.toBe(key2); + expect(key1).toBe('2024-FIRST-0'); + expect(key2).toBe('2024-FIRST-1'); + }); + + it('학기가 다르면 다른 키를 생성해야 한다', () => { + expect(getAwardKey(validAward2024First, 0)).toBe('2024-FIRST-0'); + expect(getAwardKey(validAward2024Second, 0)).toBe('2024-SECOND-0'); + }); + + it('연도가 다르면 다른 키를 생성해야 한다', () => { + const award2024 = createAward(2024, SemesterTerm.FIRST); + + expect(getAwardKey(validAward2023First, 0)).toBe('2023-FIRST-0'); + expect(getAwardKey(award2024, 0)).toBe('2024-FIRST-0'); + }); + + it('큰 인덱스 숫자를 올바르게 처리해야 한다', () => { + expect(getAwardKey(validAward2024First, 999)).toBe('2024-FIRST-999'); + }); + }); +}); From c486f8d006ce23f6c7df9acf5220114dacae9ae2 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Mon, 19 Jan 2026 14:06:47 +0900 Subject: [PATCH 3/4] =?UTF-8?q?refactor:=20ClubIntroContent=20=EC=84=B1?= =?UTF-8?q?=EB=8A=A5=20=EC=B5=9C=EC=A0=81=ED=99=94=20=EB=B0=8F=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FAQ 토글 상태를 Array에서 Set으로 변경 (O(n) → O(1) 조회) - handleToggleFaq를 useCallback으로 메모이제이션 - validAwards를 useMemo로 계산하여 불필요한 재계산 방지 - 유효하지 않은 award(year/semester 누락) 렌더링 방지 - formatSemesterLabel, getAwardKey 함수를 utils로 분리 및 import --- .../ClubIntroContent/ClubIntroContent.tsx | 65 ++++++++++--------- 1 file changed, 36 insertions(+), 29 deletions(-) diff --git a/frontend/src/pages/ClubDetailPage/components/ClubIntroContent/ClubIntroContent.tsx b/frontend/src/pages/ClubDetailPage/components/ClubIntroContent/ClubIntroContent.tsx index 0da782fa..3c807d68 100644 --- a/frontend/src/pages/ClubDetailPage/components/ClubIntroContent/ClubIntroContent.tsx +++ b/frontend/src/pages/ClubDetailPage/components/ClubIntroContent/ClubIntroContent.tsx @@ -1,18 +1,10 @@ -import { useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { USER_EVENT } from '@/constants/eventName'; import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack'; -import { Award, FAQ, IdealCandidate, SemesterTerm } from '@/types/club'; +import { Award, FAQ, IdealCandidate } from '@/types/club'; +import { formatSemesterLabel, getAwardKey } from '@/utils/awardHelpers'; import * as Styled from './ClubIntroContent.styles'; -const formatSemesterLabel = (award: Award): string => { - const semesterLabel = - award.semester === SemesterTerm.FIRST ? '1학기' : '2학기'; - return `${award.year} ${semesterLabel}`; -}; - -const getAwardKey = (award: Award, index: number): string => - `${award.year}-${award.semester}-${index}`; - interface ClubIntroContentProps { activityDescription?: string; awards?: Award[]; @@ -30,21 +22,37 @@ const ClubIntroContent = ({ }: ClubIntroContentProps) => { const trackEvent = useMixpanelTrack(); - const [openFaqIndexes, setOpenFaqIndexes] = useState([]); + const [openFaqIndexes, setOpenFaqIndexes] = useState>(new Set()); + + const validAwards = useMemo( + () => awards?.filter((award) => formatSemesterLabel(award) !== null) || [], + [awards], + ); + + const handleToggleFaq = useCallback( + (index: number) => { + setOpenFaqIndexes((prev) => { + const newSet = new Set(prev); + const isOpening = !newSet.has(index); - const handleToggleFaq = (index: number) => { - const isOpening = !openFaqIndexes.includes(index); - setOpenFaqIndexes((prev) => - prev.includes(index) ? prev.filter((i) => i !== index) : [...prev, index], - ); + if (isOpening) { + newSet.add(index); + } else { + newSet.delete(index); + } - if (faqs && faqs[index]) { - trackEvent(USER_EVENT.FAQ_TOGGLE_CLICKED, { - question: faqs[index].question, - action: isOpening ? 'open' : 'close', + if (faqs?.[index]) { + trackEvent(USER_EVENT.FAQ_TOGGLE_CLICKED, { + question: faqs[index].question, + action: isOpening ? 'open' : 'close', + }); + } + + return newSet; }); - } - }; + }, + [faqs, trackEvent], + ); return ( @@ -57,17 +65,16 @@ const ClubIntroContent = ({ )} - {awards && awards.length > 0 && ( + {validAwards.length > 0 && ( 동아리 성과 - {awards.map((award, index) => { + {validAwards.map((award, index) => { + const semesterLabel = formatSemesterLabel(award)!; const awardKey = getAwardKey(award, index); return ( - - {formatSemesterLabel(award)} - + {semesterLabel} {award.achievements.map((item, idx) => ( @@ -102,7 +109,7 @@ const ClubIntroContent = ({ FAQ {faqs.map((faq, index) => { - const isOpen = openFaqIndexes.includes(index); + const isOpen = openFaqIndexes.has(index); return ( handleToggleFaq(index)}> From 197a2552329363dbc31fc0ca91940fa2694e3f69 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Mon, 19 Jan 2026 14:34:56 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20setState=20updater=20=ED=95=A8?= =?UTF-8?q?=EC=88=98=EC=97=90=EC=84=9C=20=EB=B6=80=EC=9E=91=EC=9A=A9=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=ED=95=98=EC=97=AC=20React=20=EA=B7=9C?= =?UTF-8?q?=EC=B9=99=20=EC=A4=80=EC=88=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - trackEvent 호출을 setOpenFaqIndexes updater 함수 외부로 이동 - React 공식 문서의 updater 순수성 요구사항 준수 - StrictMode에서 trackEvent 중복 호출 방지 - openFaqIndexes를 useCallback 의존성 배열에 추가 --- .../ClubIntroContent/ClubIntroContent.tsx | 28 ++++++++----------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/frontend/src/pages/ClubDetailPage/components/ClubIntroContent/ClubIntroContent.tsx b/frontend/src/pages/ClubDetailPage/components/ClubIntroContent/ClubIntroContent.tsx index 3c807d68..190ea61a 100644 --- a/frontend/src/pages/ClubDetailPage/components/ClubIntroContent/ClubIntroContent.tsx +++ b/frontend/src/pages/ClubDetailPage/components/ClubIntroContent/ClubIntroContent.tsx @@ -31,27 +31,23 @@ const ClubIntroContent = ({ const handleToggleFaq = useCallback( (index: number) => { + const isOpening = !openFaqIndexes.has(index); + setOpenFaqIndexes((prev) => { const newSet = new Set(prev); - const isOpening = !newSet.has(index); - - if (isOpening) { - newSet.add(index); - } else { - newSet.delete(index); - } - - if (faqs?.[index]) { - trackEvent(USER_EVENT.FAQ_TOGGLE_CLICKED, { - question: faqs[index].question, - action: isOpening ? 'open' : 'close', - }); - } - + if (isOpening) newSet.add(index); + else newSet.delete(index); return newSet; }); + + if (faqs?.[index]) { + trackEvent(USER_EVENT.FAQ_TOGGLE_CLICKED, { + question: faqs[index].question, + action: isOpening ? 'open' : 'close', + }); + } }, - [faqs, trackEvent], + [faqs, trackEvent, openFaqIndexes], ); return (