Conversation
- Award 타입에 year(number), semester(SemesterTermType) 분리
- SemesterTerm 상수 추가 ('FIRST' | 'SECOND')
- AwardEditor 새 타입에 맞게 로직 수정
- ClubIntroContent 로컬 타입 제거, @/types/club에서 import로 통일
- Award 타입에 year(number), semester(SemesterTermType) 분리
- SemesterTerm 상수 추가 ('FIRST' | 'SECOND')
- AwardEditor 새 타입에 맞게 로직 수정
- ClubIntroContent 로컬 타입 제거, @/types/club 통일
- openFaqIndices → openFaqIndexes 리네임
- getAwardKey 함수에 index 파라미터 추가 - 같은 년도-학기에 여러 수상이 있어도 고유한 key 생성 - AwardEditor와 ClubIntroContent 모두 수정 - 백엔드 중복 방지 로직 부재에 대응
…pe-MOA-523 [refactor] year, semester 타입 분리에 따른 리팩토링
- formatSemesterLabel: Award의 year와 semester를 포맷팅하여 문자열 반환 - getAwardKey: Award 객체와 인덱스로 고유 키 생성 - ClubIntroContent에서 사용하던 함수를 재사용 가능하도록 분리
- formatSemesterLabel 테스트 8개 (정상 케이스, null/undefined 처리) - getAwardKey 테스트 5개 (고유성 검증, 엣지 케이스) - Given 데이터 공통화로 테스트 가독성 향상 - 모든 테스트 통과 (13/13)
- FAQ 토글 상태를 Array에서 Set으로 변경 (O(n) → O(1) 조회) - handleToggleFaq를 useCallback으로 메모이제이션 - validAwards를 useMemo로 계산하여 불필요한 재계산 방지 - 유효하지 않은 award(year/semester 누락) 렌더링 방지 - formatSemesterLabel, getAwardKey 함수를 utils로 분리 및 import
- trackEvent 호출을 setOpenFaqIndexes updater 함수 외부로 이동 - React 공식 문서의 updater 순수성 요구사항 준수 - StrictMode에서 trackEvent 중복 호출 방지 - openFaqIndexes를 useCallback 의존성 배열에 추가
…rmance-and-award-utils-MOA-535 [refactor] 동아리 소개 컴포넌트 리팩토링 및 유틸 함수 단위 테스트 추가
…tent-MOA-296 [feat] 지원자 상태 변경 SSE 실시간 동기화 구현
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Warning
|
| 그룹 / 파일 | 변경 요약 |
|---|---|
의존성 및 SSE 기반 인프라 frontend/package.json, frontend/src/apis/clubSSE.ts, frontend/src/types/applicants.ts |
eventsource 의존성 추가; EventSource 기반 인증 SSE 연결 설정 및 applicant-status-changed 이벤트 리스너 구현; ApplicantStatusEvent, ApplicantSSECallbacks 타입 정의 |
AdminClubContext SSE 통합 frontend/src/context/AdminClubContext.tsx |
applicationFormId 상태 및 setter 추가; SSE 생성 및 이벤트 핸들링 구현(handleApplicantStatusChange); 지수 백오프 재시도 로직 및 정리 작업 포함 |
ApplicantsTab SSE 생명주기 frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantsTab.tsx, frontend/src/pages/AdminPage/tabs/ApplicantsTab/ApplicantDetailPage/ApplicantDetailPage.tsx |
setApplicationFormId를 context에서 추출하여 SSE 설정; useEffect로 applicationFormId 관리 및 초기 데이터 로드; ApplicantDetailPage 의존성 배열 확장(status, memo 추가) |
어워드 타입 및 유틸리티 리팩토링 frontend/src/types/club.ts, frontend/src/utils/awardHelpers.ts, frontend/src/utils/awardHelpers.test.ts |
SemesterTerm 열거형 및 SemesterTermType 타입 정의; Award 인터페이스에 year 필드 추가 및 semester를 문자열에서 열거형으로 변경; formatSemesterLabel, getAwardKey 유틸리티 함수 추가 및 테스트 작성 |
AwardEditor 열거형 기반 리팩토링 frontend/src/pages/AdminPage/tabs/ClubIntroEditTab/components/AwardEditor/AwardEditor.tsx |
SemesterTerm 상수 사용으로 마이그레이션; 복합 키(year-semester-index) 기반 상태 관리 및 중복 검사 개선; formatSemesterLabel, getAwardKey 유틸리티 활용; 포커싱 로직 재구현 |
ClubIntroContent 타입 통합 및 성능 최적화 frontend/src/pages/ClubDetailPage/components/ClubIntroContent/ClubIntroContent.tsx |
로컬 타입 선언 제거 및 @/types/club에서 Award, FAQ, IdealCandidate 임포트; useMemo로 어워드 필터링, useCallback으로 FAQ 토글 핸들러 최적화; FAQ 열림 상태를 배열에서 Set으로 변경 |
Sequence Diagram
sequenceDiagram
participant User as 사용자 (관리자)
participant ApplicantsTab as ApplicantsTab<br/>(React Component)
participant AdminClubContext as AdminClubContext<br/>(상태 관리)
participant SSE as SSE 연결<br/>(EventSource)
participant Backend as 백엔드<br/>(/api/club/.../sse)
User->>ApplicantsTab: 지원자 탭 진입
ApplicantsTab->>AdminClubContext: setApplicationFormId(formId) 호출
AdminClubContext->>SSE: createApplicantSSE(formId) 실행
SSE->>Backend: GET /api/club/applicant/{formId}/sse<br/>(Bearer 토큰 포함)
Backend-->>SSE: EventSource 연결 수립
loop 지원자 상태 변경 감지
Backend-->>SSE: applicant-status-changed 이벤트<br/>(ApplicantStatusEvent)
SSE->>AdminClubContext: handleApplicantStatusChange(event)<br/>호출 (onStatusChange)
AdminClubContext->>AdminClubContext: applicantsData 업데이트<br/>(status/memo 반영)
AdminClubContext-->>ApplicantsTab: 상태 변경 알림
ApplicantsTab-->>User: UI 업데이트 표시
end
User->>ApplicantsTab: 지원자 탭 나감
ApplicantsTab->>AdminClubContext: setApplicationFormId(null) 호출
AdminClubContext->>SSE: EventSource 정리<br/>(close())
Estimated code review effort
🎯 4 (Complex) | ⏱️ ~50 minutes
Possibly related PRs
- PR
#1062: SSE 기반 지원자 상태 동기화 구현으로 동일한 createApplicantSSE, 타입, AdminClubContext/ApplicantsTab 통합 변경사항 포함 - PR
#1068: 동일한 awardHelpers 유틸리티(formatSemesterLabel, getAwardKey) 추가 및 ClubIntroContent에서 사용하는 변경사항 포함 - PR
#787: AdminClubContext에서 applicationFormId 및 setApplicationFormId 추가하여 Admin 페이지로 전파하는 동일한 변경사항 포함
Suggested labels
📬 API, ✅ Test, 🔨 Refactor
Suggested reviewers
- lepitaaar
- oesnuj
- suhyun113
🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 inconclusive)
| Check name | Status | Explanation | Resolution |
|---|---|---|---|
| Title check | ❓ Inconclusive | PR 제목 '[release] FE v1.1.18'은 릴리스 버전 번호만 표시할 뿐, 실제 변경 사항의 주요 내용을 구체적으로 설명하지 않습니다. | 제목을 '[release] FE v1.1.18: Award 타입 리팩토링 및 SSE 기능 추가' 등으로 변경하여 주요 변경 사항을 명확히 나타내세요. |
✅ Passed checks (2 passed)
| Check name | Status | Explanation |
|---|---|---|
| Description Check | ✅ Passed | Check skipped - CodeRabbit’s high-level summary is enabled. |
| Docstring Coverage | ✅ Passed | No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. |
✏️ Tip: You can configure your own custom pre-merge checks in the settings.
✨ Finishing touches
- 📝 Generate docstrings
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 @coderabbitai help to get the list of available commands and usage tips.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@frontend/package.json`:
- Line 32: Remove the browser polyfill import and dependency: in
frontend/src/apis/club.ts delete the import statement "import { EventSource }
from 'eventsource'" and switch to the native global EventSource in your
event-handling code; also remove "eventsource" from dependencies in
frontend/package.json (move it to devDependencies if you need it only for
tests/Node), or implement a conditional/Node-only require when running in
test/CI to load the polyfill (keep symbol references: EventSource import in
club.ts and the "eventsource" package entry in package.json).
🧹 Nitpick comments (7)
frontend/src/utils/awardHelpers.ts (1)
3-9: formatSemesterLabel 시그니처를 nullable로 명시하면 더 안전합니다현재 구현은 null/undefined도 처리하지만 타입은
Award로 고정되어 있어 테스트에서 강제 캐스팅이 필요합니다. 시그니처를 넓히면 의도가 명확해지고 사용성이 좋아집니다.♻️ 제안 변경
-export const formatSemesterLabel = (award: Award): string | null => { - if (award?.year && award?.semester) { +export const formatSemesterLabel = ( + award: Award | null | undefined, +): string | null => { + if (award?.year != null && award?.semester) { const semesterLabel = award.semester === SemesterTerm.FIRST ? '1학기' : '2학기'; return `${award.year} ${semesterLabel}`; } return null; };frontend/src/apis/club.ts (2)
88-134: 불필요한 내부 함수 래퍼를 제거하세요.
connect함수가 정의되어 있지만 한 번만 호출되고,eventSource변수도connect()결과를 직접 반환하므로 불필요합니다. 코드를 단순화할 수 있습니다.♻️ 제안하는 리팩토링
export const createApplicantSSE = ( applicationFormId: string, eventHandlers: ApplicantSSECallbacks, ): EventSource | null => { - let eventSource: EventSource | null = null; - - const connect = (): EventSource | null => { - const accessToken = localStorage.getItem('accessToken'); - if (!accessToken) return null; + const accessToken = localStorage.getItem('accessToken'); + if (!accessToken) return null; - const source = new EventSource( - `${API_BASE_URL}/api/club/applicant/${applicationFormId}/sse`, - { - fetch: (input, init) => - fetch(input, { - ...init, - headers: { - ...init?.headers, - Authorization: `Bearer ${accessToken}`, - }, - credentials: 'include', - }), - }, - ); + const source = new EventSource( + `${API_BASE_URL}/api/club/applicant/${applicationFormId}/sse`, + { + fetch: (input, init) => + fetch(input, { + ...init, + headers: { + ...init?.headers, + Authorization: `Bearer ${accessToken}`, + }, + credentials: 'include', + }), + }, + ); - source.addEventListener('applicant-status-changed', (e) => { - try { - const eventData: ApplicantStatusEvent = JSON.parse(e.data); - eventHandlers.onStatusChange(eventData); - } catch (parseError) { - console.error('SSE PARSING ERROR:', parseError); - } - }); + source.addEventListener('applicant-status-changed', (e) => { + try { + const eventData: ApplicantStatusEvent = JSON.parse(e.data); + eventHandlers.onStatusChange(eventData); + } catch (parseError) { + console.error('SSE PARSING ERROR:', parseError); + } + }); - source.onerror = (error) => { - source.close(); - eventHandlers.onError( - new Error(error?.message || 'SSE connection error'), - ); - }; + source.onerror = (error) => { + source.close(); + eventHandlers.onError( + new Error(error?.message || 'SSE connection error'), + ); + }; - return source; - }; - - eventSource = connect(); - return eventSource; + return source; };
122-127: EventSource 에러 이벤트 타입을 확인하세요.
EventSource의onerror이벤트는Event타입으로,message속성이 없습니다.error?.message는 항상undefined가 되어 폴백 메시지가 사용됩니다. 의도된 동작이라면 괜찮지만, 명확성을 위해 단순화하는 것이 좋습니다.♻️ 제안하는 수정
source.onerror = (error) => { source.close(); - eventHandlers.onError( - new Error(error?.message || 'SSE connection error'), - ); + eventHandlers.onError(new Error('SSE connection error')); };frontend/src/context/AdminClubContext.tsx (1)
85-90: 재연결 시 지수 백오프(exponential backoff) 적용을 고려하세요.현재 고정된 2초 지연으로 재연결을 시도합니다. 서버 장애 시 모든 클라이언트가 동시에 재연결을 시도하는 "thundering herd" 문제를 방지하기 위해 지수 백오프 패턴을 고려해 보세요.
♻️ 지수 백오프 예시
const reconnectAttemptsRef = useRef(0); const MAX_RECONNECT_DELAY = 30000; // onError 내부에서: const delay = Math.min(1000 * Math.pow(2, reconnectAttemptsRef.current), MAX_RECONNECT_DELAY); reconnectAttemptsRef.current += 1; reconnectTimeoutRef.current = window.setTimeout(() => { reconnectTimeoutRef.current = null; sseConnect(); }, delay); // 연결 성공 시 reconnectAttemptsRef.current = 0; 으로 리셋frontend/src/pages/ClubDetailPage/components/ClubIntroContent/ClubIntroContent.tsx (1)
32-51:openFaqIndexes를 의존성에서 제거하세요.
openFaqIndexes가useCallback의존성 배열에 포함되어 있어, FAQ를 토글할 때마다 콜백이 재생성됩니다.isOpening계산을setOpenFaqIndexes내부로 이동하면 이 의존성을 제거할 수 있습니다.♻️ 제안하는 리팩토링
const handleToggleFaq = useCallback( (index: number) => { - const isOpening = !openFaqIndexes.has(index); + let isOpening = false; setOpenFaqIndexes((prev) => { + isOpening = !prev.has(index); const newSet = new Set(prev); 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, openFaqIndexes], + [faqs, trackEvent], );frontend/src/pages/AdminPage/tabs/ClubIntroEditTab/components/AwardEditor/AwardEditor.tsx (2)
20-24: 공유 유틸리티 함수를 재사용하세요.
formatSemesterLabel과getAwardKey함수가@/utils/awardHelpers.ts에 이미 정의되어 있습니다. 코드 중복을 피하고 일관성을 유지하기 위해 공유 유틸리티를 import하여 사용하는 것을 권장합니다.♻️ 제안하는 변경
import { CustomDropDown } from '@/components/common/CustomDropDown/CustomDropDown'; import { Award, SemesterTerm, SemesterTermType } from '@/types/club'; +import { formatSemesterLabel, getAwardKey } from '@/utils/awardHelpers'; import * as Styled from './AwardEditor.styles'; // ... const getSemesterSortValue = (award: Award): number => { const semesterValue = award.semester === SemesterTerm.FIRST ? 1 : 2; return award.year * 10 + semesterValue; }; -const formatSemesterLabel = (award: Award): string => { - const semesterLabel = - award.semester === SemesterTerm.FIRST ? '1학기' : '2학기'; - return `${award.year} ${semesterLabel}`; -}; // ... 내부의 getAwardKey 함수도 제거
220-227: 참조 비교에 의존하는 로직이 취약합니다.
originalAward.achievements === award.achievements참조 비교는 현재 동작하지만,sortedAwards가 얕은 복사를 사용하기 때문입니다. 향후 깊은 복사나 데이터 변환이 적용되면 이 비교가 실패할 수 있습니다. 정렬 시 원본 인덱스를 함께 추적하는 것이 더 안전합니다.♻️ 더 안전한 접근 방식
-const sortedAwards = [...awards].sort( - (awardA, awardB) => - getSemesterSortValue(awardB) - getSemesterSortValue(awardA), -); +const sortedAwards = awards + .map((award, index) => ({ award, originalIndex: index })) + .sort( + (a, b) => getSemesterSortValue(b.award) - getSemesterSortValue(a.award), + ); // 렌더링 시: -{sortedAwards.map((award, sortedIndex) => { - const originalIndex = awards.findIndex( - (originalAward, idx) => - originalAward.year === award.year && - originalAward.semester === award.semester && - originalAward.achievements === award.achievements, - ); +{sortedAwards.map(({ award, originalIndex }) => { const awardKey = getAwardKey(award, originalIndex); // ...
[fix] createApplicationSSE import 분리
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@frontend/src/apis/clubSSE.ts`:
- Around line 14-17: The connect function currently returns null when no
accessToken is found, which prevents the consumer's onError-based reconnection
logic from running; update connect (in frontend/src/apis/clubSSE.ts) to treat
missing token as an error by invoking the provided onError callback (the same
handler used for other connection failures) with a descriptive Error (e.g.,
"missing access token") and then return null, ensuring AdminClubContext's
reconnection logic (2s retry) is triggered; make sure to reference the existing
onError parameter/closure used when creating the EventSource so you call that
exact function.
#️⃣연관된 이슈
📝작업 내용
중점적으로 리뷰받고 싶은 부분(선택)
논의하고 싶은 부분(선택)
🫡 참고사항
Summary by CodeRabbit
새로운 기능
개선사항
✏️ Tip: You can customize this high-level summary in your review settings.