Admin 폼지 구현하기 (with Next , 리팩토링 진행하기)

ujinsim·2025년 3월 9일
0
post-thumbnail

띵동 프로젝트 – 폼지 제작 기능

띵동이란?

명지대학교 동아리 통합 플랫폼으로,
학생들이 파편화된 동아리 정보비효율적인 동아리 업무
일원화하여 제공하는 서비스입니다.

기존 방식과 개선점

기존에는 동아리 지원 시, 해당 동아리가 올린 외부 폼지(구글 폼 등)로 이동해야 했어요.

💡 개선된 방식 → 띵동 자체 폼지를 제작!
이제 지원하기 버튼을 누르면 띵동 내부 폼지로 이동합니다.


폼지 구성

✅ 기본 질문 (공통)

동아리 지원서 특성상, 모든 지원자가 공통으로 입력해야 하는 항목이 있어요.

  • 이름
  • 학번
  • 학과
  • 전화번호
  • 이메일

✅ 맞춤형 질문 (시트 추가 기능)

모집 분야가 여러 개인 동아리는 시트를 추가할 수 있어요.

예를 들어,
밴드부 → 보컬, 기타, 드럼 등 각 파트별 추가 질문 가능
사용자가 보컬 선택 → 공통 질문 + 보컬 질문 응답

✅ 질문 유형 (5가지)

  • 파일 업로드
  • 체크박스 (다중 선택 가능)
  • 객관식 (단일 선택)
  • 단답형 (300자 이내)
  • 서술형 (1000자 이내)
    각 질문은 옵션 및 필수 여부를 설정할 수 있습니다.

폼지 관리 기능 (Admin)

Admin 페이지에서 지원서의 주요 정보를 관리할 수 있어요.

  • 지원서 제목
  • 모집 기간
  • 지원서 설명
  • 면접 여부 설정

개발 난이도 & 복잡도

처음엔 단순히 폼지만 만든다고 생각했는데…
막상 개발하다 보니 엄청난 복잡도가 있었습니다.

-> 하나의 컴포넌트에서 모든 상태 관리
ManageComponent에서

  • 새 지원서 생성
  • 수정 중인 경우
  • 모집 기간 전
  • 모집 기간 중
    모두 하나의 컴포넌트에서 관리해야 했어요.

초기 코드를 복잡도를 고려하지 않고 작성하면…

  • 유지보수 어려움
  • 예외 처리 부족
    기능 추가 시 구조적으로 수정해야 하는 부분이 많아져서 확장성을 고려한 설계가 정말 중요하다는 걸 느꼈습니다.

이를 고려하지 않고 코드를 짠 결과
제 안타까운 ManageForm컴포넌트를 소개해보겠습니다. . . ☠️

const router = useRouter();
const [{ token }] = useCookies(['token']);
const newFormMutation = useNewForm(token);
const [isEditing, setIsEditing] = useState(false);
const updateFormMutation = useUpdateForm(setIsEditing);

const [title, setTitle] = useState(formData?.title ? formData.title : '');
const [description, setDescription] = useState(
  formData?.description ? formData.description : '',
);

폼 내부 요소들을 모두 각각 관리함

useEffect(() => {
  if (formData) {
    const updatedFormFields = Object.keys(categorizeFormFields(formData)).map(
      (section) => ({
        section,
        questions: categorizeFormFields(formData)[section].map((field) => ({
          question: field.question,
          type: field.type,
          options: field.options || [],
          required: field.required,
          order: field.order,
          section: field.section,
        })),
      }),
    );

    setFormField(updatedFormFields);
  }
}, [formData]);

폼 안에 섹션들을 카테고리화하여 분류함

const handleCreateForm = () => {
  if (!title) {
    toast.error('지원서 제목을 입력하여주세요. ');
  }

  if (!description || description.length > 255) {
    toast.error('지원서 설명은 255자 이내로 작성하여주세요.');
    return;
  }

  const formattedPostData = formatFormData();
  newFormMutation.mutate(formattedPostData);
};

const handleUpdateForm = () => {
  if (!id || !formData) {
    toast.error('수정할 폼이 존재하지 않습니다.');
    return;
  }
  if (!title) {
    toast.error('지원서 제목을 입력하여주세요. ');
  }

  if (!description || description.length > 255) {
    toast.error('지원서 설명은 255자 이내로 작성하여주세요.');
    return;
  }

  const formattedPostData = formatFormData();

```tsx
  updateFormMutation.mutate({
    token,
    formId: id,
    formData: formattedPostData,
  });
  setIsEditing(false);
  setIsClosed(true);
};

형식을 컴포넌트 내에서 모두 같이 관리함

  const formatFormData = (): FormData => {
    const formatDate = (date: Date | string | null) => {
      if (!date) return '';
      if (date instanceof Date) return date.toISOString().split('T')[0];
      return new Date(date).toISOString().split('T')[0];
    };

    return {
      title: title.trim(),
      description: description.trim() || null,
      startDate: formatDate(recruitPeriod.startDate),
      endDate: formatDate(recruitPeriod.endDate),
      hasInterview: isChecked ?? false,
      sections: sections,
      formFields: formField.flatMap((section) =>
        section.questions.map(
          (question): FormField => ({
            question: question.question.trim(),
            type: question.type as QuestionType,
            options: question.options || [],
            required: question.required,
            order: question.order,
            section: section.section,
          }),
        ),
      ),
    };
  };

카테고리화해서 관리하기 때문에 이를 다시 해지해줄 format이 필요하게 됨

const isPastStartDate = formData?.startDate
  ? new Date(formData.startDate) < new Date()
  : false;

const [isClosed, setIsClosed] = useState(formData ? true : false);

const [isChecked, setIsChecked] = useState(formData?.hasInterview);

function categorizeFormFields(
  formData: FormData | undefined,
): CategorizedFields {
  const categorizedFields: CategorizedFields = {};

  (formData?.sections || []).forEach((section) => {
    categorizedFields[section] = [];
  });

  (formData?.formFields || []).forEach((field) => {
    if (field.section in categorizedFields) {
      categorizedFields[field.section].push(field);
    }
  });

  return categorizedFields;
}
const [modiformField, setmodiFormField] = useState(
   Object.keys(categorizeFormFields(formData)).map((section) => ({
     section,
     questions: categorizeFormFields(formData)[section].map((field) => ({
       question: field.question,
       type: field.type,
       options: field.options || ['옵션1'],
       required: field.required,
       order: field.order,
       section,
     })),
   })),
 );

 const [focusSection, setFocusSection] = useState('공통');
 const [sections, setSections] = useState(
   formData ? formData.sections : ['공통'],
 );
 const [recruitPeriod, setRecruitPeriod] = useState<DateRangeType>({
   startDate: null,
   endDate: null,
 });

 useEffect(() => {
   if (formData) {
     setRecruitPeriod({
       startDate: formData.startDate ? new Date(formData.startDate) : null,
       endDate: formData.endDate ? new Date(formData.endDate) : null,
     });
   }
 }, [formData]);

 const baseQuestion: FormField[] = [
   {
     question: '',
     type: 'RADIO',
     options: ['옵션1'],
     required: true,
     order: 1,
     section: '공통',
   },
 ];

 const [formField, setFormField] = useState<SectionFormField[]>(
   formData
     ? modiformField
     : [
         {
           section: '공통',
           questions: [
             {
               question: '',
               type: 'RADIO',
               options: ['옵션1'],
               required: true,
               order: 1,
               section: '공통',
             },
           ],
         },
       ],
 );

 const handleDateChange = (newValue: DateRangeType | null) => {
   setRecruitPeriod(newValue ?? { startDate: null, endDate: null });
 };

 const deleteQuestion = (sectionName: string, questionIndex: number) => {
   setFormField((prev) =>
     prev.map((section) =>
       section.section === sectionName
         ? {
             ...section,
             questions: section.questions
               .filter((_, qIndex) => qIndex !== questionIndex)
               .map((question, newIndex) => ({
                 ...question,
                 order: newIndex + 1,
               })),
           }
         : section,
     ),
   );
 };

 const [modalVisible, setModalVisible] = useState(false);
 const [newSectionName, setNewSectionName] = useState('');

 const handleOpenModal = () => {
   setNewSectionName('');
   setModalVisible(true);
 };

 const onClickEditButton = () => {
   setIsEditing(true);
   setIsClosed(false);
 };

 const onClickCancelButton = () => {
   onReset?.();
 };

 const addQuestion = () => {
   setFormField((prev) =>
     prev.map((section) =>
       section.section === focusSection
         ? {
             ...section,
             questions: [
               ...section.questions,
               {
                 question: '',
                 type: 'RADIO',
                 options: ['옵션1'],
                 required: true,
                 order: section.questions.length + 1,
                 section: focusSection,
               },
             ],
           }
         : section,
     ),
   );
 };
  return (
    <div>
      <Head>
        <title>지원서 템플릿 관리</title>
      </Head>

      <div className="flex items-center justify-between gap-2">
        <div
          onClick={() => router.back()}
          className="flex cursor-pointer items-center gap-1 "
        >
          <Image
            src={arrow_left}
            alt="navigation"
            className="mt-7 w-6 md:mt-11 md:w-9"
          />
          <Heading>지원서 생성</Heading>
        </div>
        <FormEditButtons
          formData={formData}
          isEditing={isEditing}
          isClosed={isClosed}
          isPastStartDate={isPastStartDate}
          handleCreateForm={handleCreateForm}
          onClickEditButton={onClickEditButton}
          onClickCancelButton={onClickCancelButton}
          handleUpdateForm={handleUpdateForm}
        />
      </div>

      <div className="flex w-full items-center justify-end gap-2 pt-10 text-lg font-semibold text-gray-500">
        <div className="relative flex h-[20px] w-[20px] cursor-pointer items-center justify-center">
          <Image
            onClick={() => {
              if (!isClosed) {
                setIsChecked(!isChecked);
              }
            }}
            src={isChecked ? square : emptySquare}
            width={isChecked ? 18 : 22}
            height={isChecked ? 18 : 22}
            className={`object-contain ${
              isClosed ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'
            }`}
            alt="checkBox"
          />
        </div>
        우리동아리는 면접을 보지 않아요!
      </div>

      <div className="flex flex-col gap-4">
        <div className="mt-4 flex flex-row flex-wrap gap-3 md:flex-nowrap">
          <BaseInput
            type="text"
            placeholder={'지원서 제목을 입력해주세요'}
            value={title}
            onChange={(
              e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
            ) => setTitle(e.target.value)}
            disabled={isClosed}
          />

          <div className="w-full rounded-lg border pt-1">
            <Datepicker
              value={recruitPeriod}
              useRange={false}
              minDate={new Date(new Date().getFullYear(), 0, 1)}
              maxDate={new Date(new Date().getFullYear(), 11, 31)}
              onChange={handleDateChange}
              placeholder="모집 기간을 설정하세요"
              disabled={isClosed}
            />
          </div>
        </div>
        <TextArea
          placeholder="지원서 설명을 입력해 주세요 (최대 255자 이내)"
          value={description}
          onChange={(
            e: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>,
          ) => setDescription(e.target.value)}
          disabled={isClosed}
        />
      </div>

      <div className="mt-6">
        <Sections
          addSection={handleOpenModal}
          focusSection={focusSection}
          sections={sections}
          setFocusSection={setFocusSection}
          isClosed={isClosed}
          formField={formField}
          setFormField={setFormField}
          setSections={setSections}
          baseQuestion={baseQuestion}
        />
        {focusSection == '공통' && <CommonQuestion disabled={true} />}

        {formField
          .filter((item) => item.section === focusSection)
          .map((section) => (
            <div key={section.section}>
              {section.questions.map((question, qIndex) => (
                <Question
                  key={`${section.section}-${qIndex}`}
                  index={qIndex}
                  questionData={question}
                  deleteQuestion={() => deleteQuestion(section.section, qIndex)}
                  setFormField={setFormField}
                  section={section}
                  isClosed={isClosed}
                />
              ))}
            </div>
          ))}
      </div>
      {!isClosed && (
        <button
          onClick={addQuestion}
          className="fixed bottom-24 right-[calc(10vw)] flex items-center justify-center rounded-full bg-blue-500 p-1 shadow-lg transition-all duration-200 hover:bg-blue-600 md:right-[calc(5vw)] lg:right-[calc(2vw)]"
        >
          <Image src={AddForm} width={40} height={40} alt="질문 추가하기" />
        </button>
      )}
    </div>
  );
}

주요 문제점 정리

1. 과도한 상태(state) 관리

  • isEditing, isClosed, isPastStartDate 등 권한과 관련된 상태들이 마구잡이로 관리됨
  • formData, sections, formFields 등 폼지의 필드들도 각각의 state로 관리됨
    결과적으로 수정이 어려운 코드가 되어버림

2. 불필요한 데이터 변환

formData를 섹션별로 객체화하여 관리했는데,

  • 막상 섹션 추가하는 경우는 거의 없음
  • 게다가 질문도 많아야 10개 이하
    -> 단순 배열로 관리했으면 더 깔끔했을 것임

3. 검증(Validation) 중복

  • Zod를 사용해 Validation을 관리하고 있음에도,
    별도로 유효성 검사를 추가하여 코드가 불필요하게 길어짐

4. 컴포넌트 응집도 최악

  • ManageForm에서 모든 로직을 떠맡음
  • 심지어 "수정 버튼의 상태"까지도 관리함
    단일 책임 원칙(SRP)을 완전히 위반함.

5. 재사용 불가능한 거대한 컴포넌트

  • Sections, Question, FormEditButtons 등 모든 하위 컴포넌트가 과도한 props를 받음
  • isEditing, isClosed, isPastStartDate, handleCreateForm 등 props를 남발하여 유지보수가 힘듦

왜 이렇게 됐을까?

1. "수정"에 대한 설계를 하지 않음

  • 처음에는 지원서 생성만 고려하여 개발
  • 이후 "수정" 기능 추가 시 무리한 상태 추가 → 코드가 엉망이 됨

2. 초기에는 모집 기간이 지난 폼지도 수정 가능했음

  • 이후 "모집 기간이 지난 폼지는 수정 불가" 정책이 추가됨
  • 이 과정에서 상태가 더 복잡해짐

3. 수정 기능 추가 시 기존 코드에 덧붙이는 방식으로 작성

  • "기존 코드 유지"하면서 기능 추가
  • 결과적으로 일관성 없는 상태 관리 + 안좋은 가독성

4. 데이터 구조 설계 실수

  • 폼지의 필드를 섹션별 객체로 나눠 관리함
  • 하지만 필드가 많아야 10개 이하 → 단순 배열로 관리하는 게 나았음

일단 배포가 되었지만.... 코드의 심각성을 느끼고
리팩토링에 들어갔습니다.


리팩토링

1. 상태 관리 일원화 (formState)

  • 기존에는 개별 상태 (title, description 등)를 각각 관리했지만, formState 객체로 통합하여 상태 변경을 일관되게 유지할 수 있음.
  • useState를 통해 모든 데이터를 한 곳에서 관리하면서도, 특정 필드만 업데이트하는 방식으로 개선함.
const [formState, setFormState] = useState<FormState>({
    title: formData?.title ?? '',
    description: formData?.description ?? '',
    hasInterview: formData?.hasInterview ?? false,
    sections: formData?.sections ?? ['공통'],
    startDate: formData?.startDate ?? null,
    endDate: formData?.endDate ?? null,
    formFields: formData?.formFields ?? [],
  });

2. mode와 isDisabled, isEditableRegardlessOfPeriod 활용

  • mode가 수정(edit)과 뷰(view)를 명확하게 구분하도록 해 모드 전환이 직관적으로 변경했어요

isDisabledisEditableRegardlessOfPeriod를 활용하여 사용자의 입력 가능 여부를 상태로 관리하므로,
폼 입력 필드나 버튼을 제어할 때 중복 로직 없이 깔끔하게 처리할 수 있음.


const isPastStartDate = formData?.startDate
    ? new Date(formData.startDate) < new Date()
    : false;

  const [mode, setMode] = useState<'view' | 'edit'>(
    formData == undefined ? 'edit' : 'view',
  );

  const isDisabled = mode === 'view' || isPastStartDate;

  const isEditableRegardlessOfPeriod = mode === 'view';

3. FormEditButtons 컴포넌트로 책임 분리

  • 기존에는 ManageForm에서 폼 저장 및 수정 로직을 직접 관리했지만, 이를 FormEditButtons로 이동하여 관심사 분리 (Separation of Concerns, SoC) 를 적용.

  • FormEditButtons 내부에서 handleCreateForm, handleUpdateForm, onClickEditButton 등 관련 로직을 모두 처리하도록 구조화.

-> 이렇게 분리하면 ManageForm이 폼 데이터를 렌더링하는 역할에 집중할 수 있어 코드가 더 모듈화됨.

import React from 'react';
import { useCookies } from 'react-cookie';
import { useNewForm } from '@/hooks/api/apply/useNewForm';
import { useUpdateForm } from '@/hooks/api/apply/useUpdateForm';
import { useUpdateFormDeadline } from '@/hooks/api/apply/useUpdateFormDeadline';
import { FormState } from '@/types/form';

type ModeType = 'view' | 'edit';

type Props = {
  formData: FormState | undefined;
  mode: ModeType;
  onReset: () => void;
  setMode: React.Dispatch<React.SetStateAction<ModeType>>;
  formState: FormState;
  id: number | undefined;
  isPastStartDate: boolean;
};

export default function FormEditButtons({
  isPastStartDate,
  formData,
  mode,
  onReset,
  setMode,
  formState,
  id,
}: Props) {
  const [{ token }] = useCookies(['token']);
  const newFormMutation = useNewForm(token);
  const updateFormMutation = useUpdateForm(setMode);
  const updateFormDeadlineMutation = useUpdateFormDeadline();

  const onClickEditButton = () => {
    setMode('edit');
  };
  const handleCreateForm = () => {
    newFormMutation.mutate({
      ...formState,
      startDate: formState.startDate || '',
      endDate: formState.endDate || '',
    });
  };
  const onClickCancelButton = () => {
    setMode('view');
    onReset();
  };
  const handleUpdateForm = () => {
    if (id === undefined) {
      return;
    }
    if (isPastStartDate) {
      updateFormDeadlineMutation.mutate({
        token,
        formId: id,
        endDate: formState.endDate || '',
      });
    } else {
      updateFormMutation.mutate({
        token,
        formId: id,
        formData: {
          ...formState,
          startDate: formState.startDate || '',
          endDate: formState.endDate || '',
        },
      });
    }
  };

  return (
    <div className="mt-7 flex items-center justify-between gap-2 text-lg">
      {!formData ? (
        <button
          className="rounded-xl bg-blue-500 px-4 py-2 font-semibold text-white hover:bg-blue-600"
          onClick={handleCreateForm}
        >
          저장하기
        </button>
      ) : (
        <>
          {mode == 'view' ? (
            <button
              onClick={onClickEditButton}
              className="cursor-pointer rounded-xl bg-blue-100 px-4 py-2 font-semibold text-blue-500 hover:bg-blue-200"
            >
              수정하기
            </button>
          ) : (
            <div className="flex flex-row gap-2">
              <button
                onClick={onClickCancelButton}
                className="rounded-xl bg-gray-100 px-3 py-2 font-semibold text-gray-500 hover:bg-gray-200"
              >
                취소
              </button>
              <button
                onClick={handleUpdateForm}
                className="rounded-xl bg-blue-500 px-4 py-2 font-semibold text-white hover:bg-blue-400"
              >
                저장하기
              </button>
            </div>
          )}
        </>
      )}
    </div>
  );
}


아직도 수정할 부분이 많이 남았지만 일차적으로 구조적인 수정이 끝났습니다
계속해서 리팩토링이 필요해보입니다

더 개선하고 싶은 부분은..

  1. 상태관리 최적화 -> formState를 useState로 관리하지만, 여러 필드 업데이트를 처리하면서 setState를 연속 호출하는 경우가 많음. 폼지에서 가장 필요한 부분이 아닐까 싶습니다

  2. formFields 업데이트 최적화
    현재 formFields는 배열이라 추가/삭제 시마다 setFormState를 새로 호출하는 방식인데,불변성 유지 코드를 더 간결하게 수정하고 싶습니다

  3. 불필요한 리렌더링 줄이기 : 현재 불필요한 리렌더링이 많이 일어나고 있어서 줄이고자 합니다


느낀 점

이번 리팩토링을 진행하면서 단순히 기능을 구현하는 것보다 코드의 구조를 정리하고 유지보수성을 높이는 것이 얼마나 중요한지 다시금 깨닫게 되었습니다.
처음에는 하나씩 기능을 추가하는 방식으로 개발했지만, 점점 코드가 복잡해지면서 유지보수하기 어려워졌고, 새로운 요구사항이 생길 때마다 예상보다 더 많은 수정이 필요하게 되었습니다.
이 과정에서 개발 초기 설계의 중요성을 체감했고, 코드가 확장될 때도 유연하게 대응할 수 있도록 구조를 잡는 것이 필수적이라는 점을 배웠습니다.

특히,

  • 상태를 어떻게 관리할 것인지
  • 컴포넌트의 역할을 어떻게 나눌 것인지
  • 어떤 로직을 어디에서 담당하도록 할 것인지
    이러한 고민을 깊이 해보면서 단순히 "동작하는 코드"가 아닌 잘 설계된 코드가 무엇인지 고민하는 계기가 되었습니다.

리팩토링을 통해 관심사를 분리하고, 불필요한 상태 관리를 줄이고, 코드의 가독성을 높이며, 유지보수가 쉬운 구조로 개선할 수 있었지만, 여전히 더 다듬을 부분이 남아 있다고 생각합니다.

"기능 구현"을 넘어서 "설계와 유지보수"까지 고려하는 개발자가 되어야겠다는 목표를 다시 한번 되새기게 된 리팩토링이었습니다. .. 다음 글은 최적화로 찾아뵙겠습니다!

profile
프론트엔드 공부 중.. 💻👩‍🎤

0개의 댓글