띵동이란?
명지대학교 동아리 통합 플랫폼으로,
학생들이 파편화된 동아리 정보와 비효율적인 동아리 업무를
일원화하여 제공하는 서비스입니다.
기존에는 동아리 지원 시, 해당 동아리가 올린 외부 폼지(구글 폼 등)로 이동해야 했어요.
💡 개선된 방식 → 띵동 자체 폼지를 제작!
이제 지원하기 버튼을 누르면 띵동 내부 폼지로 이동합니다.
동아리 지원서 특성상, 모든 지원자가 공통으로 입력해야 하는 항목이 있어요.
모집 분야가 여러 개인 동아리는 시트를 추가할 수 있어요.
예를 들어,
밴드부 → 보컬, 기타, 드럼 등 각 파트별 추가 질문 가능
사용자가 보컬 선택 → 공통 질문 + 보컬 질문 응답
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>
);
}
formData를 섹션별로 객체화하여 관리했는데,
일단 배포가 되었지만.... 코드의 심각성을 느끼고
리팩토링에 들어갔습니다.
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 ?? [],
});
isDisabled와 isEditableRegardlessOfPeriod를 활용하여 사용자의 입력 가능 여부를 상태로 관리하므로,
폼 입력 필드나 버튼을 제어할 때 중복 로직 없이 깔끔하게 처리할 수 있음.
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';
기존에는 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>
);
}
아직도 수정할 부분이 많이 남았지만 일차적으로 구조적인 수정이 끝났습니다
계속해서 리팩토링이 필요해보입니다
더 개선하고 싶은 부분은..
상태관리 최적화 -> formState를 useState로 관리하지만, 여러 필드 업데이트를 처리하면서 setState를 연속 호출하는 경우가 많음. 폼지에서 가장 필요한 부분이 아닐까 싶습니다
formFields 업데이트 최적화
현재 formFields는 배열이라 추가/삭제 시마다 setFormState를 새로 호출하는 방식인데,불변성 유지 코드를 더 간결하게 수정하고 싶습니다
불필요한 리렌더링 줄이기 : 현재 불필요한 리렌더링이 많이 일어나고 있어서 줄이고자 합니다
이번 리팩토링을 진행하면서 단순히 기능을 구현하는 것보다 코드의 구조를 정리하고 유지보수성을 높이는 것이 얼마나 중요한지 다시금 깨닫게 되었습니다.
처음에는 하나씩 기능을 추가하는 방식으로 개발했지만, 점점 코드가 복잡해지면서 유지보수하기 어려워졌고, 새로운 요구사항이 생길 때마다 예상보다 더 많은 수정이 필요하게 되었습니다.
이 과정에서 개발 초기 설계의 중요성을 체감했고, 코드가 확장될 때도 유연하게 대응할 수 있도록 구조를 잡는 것이 필수적이라는 점을 배웠습니다.
특히,
리팩토링을 통해 관심사를 분리하고, 불필요한 상태 관리를 줄이고, 코드의 가독성을 높이며, 유지보수가 쉬운 구조로 개선할 수 있었지만, 여전히 더 다듬을 부분이 남아 있다고 생각합니다.
"기능 구현"을 넘어서 "설계와 유지보수"까지 고려하는 개발자가 되어야겠다는 목표를 다시 한번 되새기게 된 리팩토링이었습니다. .. 다음 글은 최적화로 찾아뵙겠습니다!