Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/apis/recruit.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ import instance from './instance';
*/
export const submitRecruit = (data) => instance.post('/api/v1/visitor/applicantMembers', data);

/**
* 신입 부원 모집 공고 조회
* @returns {Promise}
*/
export const getMainRecruitment = () => instance.get('/api/v1/visitor/main/recruitment');

/**
* (관리자) 지원자 목록 조회
* @returns {Promise}
Expand Down
133 changes: 111 additions & 22 deletions src/components/recruit/RecruitCompleteCard.jsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,81 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';

import { getMainRecruitment } from '@/apis/recruit';
import { ROUTES } from '@/constants/routes';

function RecruitCompleteCard() {
const COMPLETE_MESSAGE = '회장의 연락을 통해 면접 일정이 조정될 예정이오니, 잠시 기다려 주세요.';

const EMPTY_RECRUITMENT_NOTICE = '모집 공고 정보를 불러오지 못했습니다.';

const formatKoreanDate = (dateText, shouldIncludeYear = true) => {
if (typeof dateText !== 'string') return '';

const [year, month, day] = dateText.split('-');
if (!year || !month || !day) return dateText;

const formattedMonth = Number(month);
const formattedDay = Number(day);
if (Number.isNaN(formattedMonth) || Number.isNaN(formattedDay)) return dateText;

const monthDayText = `${formattedMonth}월 ${formattedDay}일`;

return shouldIncludeYear ? `${year}년 ${monthDayText}` : monthDayText;
};

const createDateRangeText = (startDate, endDate) => {
if (typeof startDate !== 'string' && typeof endDate !== 'string') return '';
if (typeof startDate !== 'string') return formatKoreanDate(endDate);
if (typeof endDate !== 'string' || startDate === endDate) return formatKoreanDate(startDate);

const startYear = startDate.split('-')[0];
const endYear = endDate.split('-')[0];
const shouldShowEndYear = startYear !== endYear;

return `${formatKoreanDate(startDate)} ~ ${formatKoreanDate(endDate, shouldShowEndYear)}`;
};
Comment on lines +11 to +36

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

formatKoreanDatecreateDateRangeText 함수에서 인자로 전달받는 dateText, startDate, endDate가 문자열 타입이 아니거나 예상치 못한 형식일 경우, split 메서드 호출 시 TypeError가 발생하거나 잘못된 날짜 텍스트(NaN년 NaN월 NaN일)가 생성될 수 있습니다.

방어적 프로그래밍(Defensive Programming) 관점에서 인자의 타입을 검사하고, 변환된 월/일 값이 유효한 숫자인지(Number.isNaN) 확인하는 예외 처리를 추가하여 애플리케이션의 안정성을 높이는 것을 권장합니다.

const formatKoreanDate = (dateText, shouldIncludeYear = true) => {
  if (typeof dateText !== 'string') return '';

  const [year, month, day] = dateText.split('-');
  if (!year || !month || !day) return dateText;

  const formattedMonth = Number(month);
  const formattedDay = Number(day);
  if (Number.isNaN(formattedMonth) || Number.isNaN(formattedDay)) return dateText;

  const monthDayText = formattedMonth + '월 ' + formattedDay + '일';

  return shouldIncludeYear ? year + '년 ' + monthDayText : monthDayText;
};

const createDateRangeText = (startDate, endDate) => {
  if (typeof startDate !== 'string' && typeof endDate !== 'string') return '';
  if (typeof startDate !== 'string') return formatKoreanDate(endDate);
  if (typeof endDate !== 'string' || startDate === endDate) return formatKoreanDate(startDate);

  const startYear = startDate.split('-')[0];
  const endYear = endDate.split('-')[0];
  const shouldShowEndYear = startYear !== endYear;

  return formatKoreanDate(startDate) + ' ~ ' + formatKoreanDate(endDate, shouldShowEndYear);
};


const RecruitCompleteCard = () => {
const [recruitment, setRecruitment] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [hasRecruitmentError, setHasRecruitmentError] = useState(false);

useEffect(() => {
let isMounted = true;

const fetchMainRecruitment = async () => {
try {
const response = await getMainRecruitment();
if (!isMounted) return;

setRecruitment(response.data?.data ?? null);
setHasRecruitmentError(false);
} catch (error) {
if (!isMounted) return;

console.error('[RecruitCompleteCard] 모집 공고 조회 실패', error);
setHasRecruitmentError(true);
} finally {
if (isMounted) {
setIsLoading(false);
}
}
};

fetchMainRecruitment();

return () => {
isMounted = false;
};
}, []);
Comment on lines +43 to +70

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

useEffect 내에서 비동기로 모집 공고를 조회할 때, API 요청이 완료되기 전에 사용자가 다른 페이지로 이동하여 컴포넌트가 언마운트되면, 이미 존재하지 않는 컴포넌트의 상태(setRecruitment, setHasRecruitmentError, setIsLoading)를 업데이트하려고 시도하게 됩니다.

이는 메모리 누수를 유발할 수 있으므로, 컴포넌트의 마운트 여부를 추적하는 로컬 변수(isMounted)를 활용하여 언마운트된 후에는 상태 업데이트를 건너뛰도록 개선하는 것이 안전합니다.

  useEffect(() => {
    let isMounted = true;
    const fetchMainRecruitment = async () => {
      try {
        const response = await getMainRecruitment();
        if (!isMounted) return;
        setRecruitment(response.data?.data ?? null);
        setHasRecruitmentError(false);
      } catch (error) {
        if (!isMounted) return;
        console.error('[RecruitCompleteCard] 모집 공고 조회 실패', error);
        setHasRecruitmentError(true);
      } finally {
        if (isMounted) {
          setIsLoading(false);
        }
      }
    };

    fetchMainRecruitment();
    return () => {
      isMounted = false;
    };
  }, []);


const interviewPeriod = createDateRangeText(
recruitment?.interviewStart,
recruitment?.interviewEnd
);
const notificationDate = formatKoreanDate(recruitment?.notificationDate);
const contactText = [recruitment?.bossName, recruitment?.contactNumber].filter(Boolean).join(' ');

return (
<div className="mx-auto flex min-h-[620px] w-full max-w-[1253px] items-center justify-center rounded-[28px] bg-white px-5 py-12 shadow-recruit-complete sm:min-h-[700px] sm:rounded-[38px] sm:px-8 lg:min-h-[802px] lg:rounded-[50px]">
<div className="w-full max-w-[1040px] text-center text-ink">
Expand All @@ -12,26 +85,42 @@ function RecruitCompleteCard() {
</h2>

<div className="mx-auto mt-9 w-full max-w-[650px] space-y-5 text-left text-[clamp(16px,4.8vw,27px)] font-light leading-[1.45] sm:mt-12 sm:space-y-6 lg:mt-[54px] lg:w-fit lg:max-w-full lg:space-y-[29px] lg:leading-[1.35]">
<p className="lg:whitespace-nowrap">
<span className="font-normal text-brand">면접 기간: </span>
2026년 4월 6일 ~ 4월 7일
</p>
<p className="lg:whitespace-nowrap">
<span className="font-normal text-brand">합격 통보: </span>
2026년 4월 7일 예정{' '}
<span className="text-[clamp(13px,3.4vw,24px)] text-ink-sub">
(해당 일정은 변동될 수 있습니다.)
</span>
</p>
<p className="lg:whitespace-nowrap">
<span className="font-normal text-brand">면접 장소: </span>
3-511호 ONE 동아리방
</p>

<p className="pt-8 lg:whitespace-nowrap lg:pt-[42px]">
회장의 연락을 통해 면접 일정이 조정될 예정이오니, 잠시 기다려 주세요.
</p>
<p className="lg:whitespace-nowrap">문의: 회장 최예은 010-1111-1111</p>
{isLoading && <p className="text-center">모집 공고 정보를 불러오는 중입니다.</p>}

{!isLoading && hasRecruitmentError && (
<p className="text-center text-error" role="alert">
{EMPTY_RECRUITMENT_NOTICE}
</p>
)}

{!isLoading && !hasRecruitmentError && recruitment && (
<>
{interviewPeriod && (
<p className="lg:whitespace-nowrap">
<span className="font-normal text-brand">면접 기간: </span>
{interviewPeriod}
</p>
)}
{notificationDate && (
<p className="lg:whitespace-nowrap">
<span className="font-normal text-brand">합격 통보: </span>
{notificationDate} 예정{' '}
<span className="text-[clamp(13px,3.4vw,24px)] text-ink-sub">
(해당 일정은 변동될 수 있습니다.)
</span>
</p>
)}
{recruitment.roomLocation && (
<p className="lg:whitespace-nowrap">
<span className="font-normal text-brand">면접 장소: </span>
{recruitment.roomLocation}
</p>
)}
</>
)}

<p className="pt-8 lg:whitespace-nowrap lg:pt-[42px]">{COMPLETE_MESSAGE}</p>
{contactText && <p className="lg:whitespace-nowrap">문의: 회장 {contactText}</p>}
</div>

<Link
Expand All @@ -43,6 +132,6 @@ function RecruitCompleteCard() {
</div>
</div>
);
}
};

export default RecruitCompleteCard;
22 changes: 18 additions & 4 deletions src/hooks/useRecruitForm.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ const FIELD_VALUE_FORMATTERS = {

const DEFAULT_FIELD_VALUE_FORMATTER = (value) => value;

const DEFAULT_SUBMIT_ERROR_MESSAGE = '모집 신청 제출에 실패했습니다. 잠시 후 다시 시도해 주세요.';

const getRecruitSubmitErrorMessage = (error) => {
return error.response?.data?.message ?? DEFAULT_SUBMIT_ERROR_MESSAGE;
};

export function useRecruitForm() {
const navigate = useNavigate();
const isMountedRef = useRef(true);
Expand All @@ -29,6 +35,8 @@ export function useRecruitForm() {
const [isSubmitting, setIsSubmitting] = useState(false);

useEffect(() => {
isMountedRef.current = true;

return () => {
isMountedRef.current = false;
};
Expand Down Expand Up @@ -73,18 +81,24 @@ export function useRecruitForm() {
setIsSubmitting(true);
setSubmitError('');
try {
await submitRecruit({ ...form, grade: Number(form.grade) });
const response = await submitRecruit({ ...form, grade: Number(form.grade) });
if (!isMountedRef.current) return;

if (response.data?.success === false) {
const errorMessage = response.data?.message ?? DEFAULT_SUBMIT_ERROR_MESSAGE;
setIsConfirmOpen(false);
setSubmitError(errorMessage);
return;
}
Comment on lines +87 to +92

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

커스텀 훅 내부에서 브라우저의 동기식 alert 창을 직접 호출하는 것은 UI 렌더링 흐름을 일시 중단시키며, 훅의 역할(상태 및 비즈니스 로직 관리)과 UI 표현(에러 메시지 표시 방식)의 관심사 분리 원칙에 어긋납니다.

이미 이 훅은 submitError 상태를 반환하고 있으므로, alert 호출을 제거하고 이 훅을 사용하는 컴포넌트 단에서 submitError 상태를 감지하여 인라인 메시지나 커스텀 모달/토스트 UI를 통해 에러를 표현하도록 책임을 분리하는 것을 권장합니다.

      if (response.data?.success === false) {
        const errorMessage = response.data?.message ?? DEFAULT_SUBMIT_ERROR_MESSAGE;
        setIsConfirmOpen(false);
        setSubmitError(errorMessage);
        return;
      }


setIsConfirmOpen(false);
navigate(ROUTES.RECRUIT_COMPLETE);
} catch (error) {
if (!isMountedRef.current) return;

setIsConfirmOpen(false);
setSubmitError(
error.response?.data?.message || '모집 신청 제출에 실패했습니다. 잠시 후 다시 시도해 주세요.'
);
const errorMessage = getRecruitSubmitErrorMessage(error);
setSubmitError(errorMessage);
} finally {
if (isMountedRef.current) {
setIsSubmitting(false);
Expand Down