Skip to content

Conversation

@seongwon030
Copy link
Member

@seongwon030 seongwon030 commented Jan 19, 2026

#️⃣연관된 이슈

ex) #이슈번호, #이슈번호

📝작업 내용

이번 PR에서 작업한 내용을 간략히 설명해주세요(이미지/동영상 첨부 가능)

중점적으로 리뷰받고 싶은 부분(선택)

리뷰어가 특별히 봐주었으면 하는 부분이 있다면 작성해주세요

ex) 메서드 XXX의 이름을 더 잘 짓고 싶은데 혹시 좋은 명칭이 있을까요?

논의하고 싶은 부분(선택)

논의하고 싶은 부분이 있다면 작성해주세요.

🫡 참고사항

Summary by CodeRabbit

  • 새로운 기능

    • 지원자 상태 실시간 업데이트 기능 추가
  • 개선사항

    • 수상 정보의 학기별 관리 및 표시 방식 개선
    • 관리자 페이지 성능 최적화
    • 클럽 정보 페이지 안정성 향상

✏️ Tip: You can customize this high-level summary in your review settings.

seongwon030 and others added 23 commits January 17, 2026 14:21
- 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 실시간 동기화 구현
@seongwon030 seongwon030 self-assigned this Jan 19, 2026
@seongwon030 seongwon030 added 💻 FE Frontend 📈 release 릴리즈 배포 labels Jan 19, 2026
@vercel
Copy link

vercel bot commented Jan 19, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Review Updated (UTC)
moadong Ready Ready Preview, Comment Jan 19, 2026 8:31am

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Jan 19, 2026

Warning

.coderabbit.yaml has a parsing error

The CodeRabbit configuration file in this repository has a parsing error and default settings were used instead. Please fix the error(s) in the configuration file. You can initialize chat with CodeRabbit to get help with the configuration file.

💥 Parsing errors (1)
Validation error: Invalid regex pattern for base branch. Received: "**" at "reviews.auto_review.base_branches[0]"
⚙️ Configuration instructions
  • Please see the configuration documentation for more information.
  • You can also validate your configuration using the online YAML validator.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

요약

Walkthrough

이 PR은 SSE 기반 지원자 상태 실시간 업데이트 기능을 구현하며, 어워드 데이터 구조를 문자열 기반에서 열거형 기반으로 리팩토링하고, 여러 컴포넌트에서 공유 타입으로 마이그레이션합니다.

Changes

그룹 / 파일 변경 요약
의존성 및 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())
Loading

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 에러 이벤트 타입을 확인하세요.

EventSourceonerror 이벤트는 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를 의존성에서 제거하세요.

openFaqIndexesuseCallback 의존성 배열에 포함되어 있어, 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: 공유 유틸리티 함수를 재사용하세요.

formatSemesterLabelgetAwardKey 함수가 @/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);
   // ...

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Contributor

@lepitaaar lepitaaar left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@seongwon030 seongwon030 merged commit 9ad129f into main Jan 19, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

💻 FE Frontend 📈 release 릴리즈 배포

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants