3차례의 QA와 5회의 유저 테스트(UT)를 진행하며, 자잘한 버그를 해결하고 테스트 유저로부터 피드백을 수집했습니다.
테스트 과정 중 "글 수정 기능이 있으면 좋겠다"는 피드백을 받아 수정 기능을 구현하기로 결정했습니다. 하지만 새로운 풀이 작성 페이지와 풀이 수정 페이지를 같은 MissionSubmitPage로 재사용하고자 했기에, 이를 어떻게 설계할지에 대해 고민이 들었습니다. 그래서 일단 흐름도를 그려보기로 했습니다.

글 작성 페이지를 (1) 새로운 글을 작성하기 위해 방문하는 경우와, (2) 수정하기 위해 방문하는 경우를 나누기 위해 url에 solutionId 파라미터를 추가했습니다. 페이지 로드 시 파라미터를 확인하여, 데이터가 존재하면 서버에서 기존 데이터를 가져와 수정 작업으로 간주합니다. 반대로 파라미터가 없을 경우, 새로운 글을 작성하기 위한 작업으로 처리하도록 설계했습니다.
데이터의 안정성을 확보하기 위해 URL 파라미터의 유무로 isEditMode를 확인하는 동시에, 서버에서 받아온 데이터의 작성자 ID를 검증하는 조건을 추가했습니다. 이미 해당 작성 페이지는 PrivateRouter로 보호되어 로그인된 사용자만 접근 가능하지만, 다른 사용자가 solutionId를 임의로 입력해 접근할 가능성을 대비하기 위함입니다.
1차 구현
// 글을 수정하는 경우 input의 초기값을 해당 값으로 변경
useEffect(() => {
if (isEditMode && member?.id === userInfo?.id) {
if (inputTitle)
handleSolutionTitle({
target: { value: inputTitle },
} as React.ChangeEvent<HTMLInputElement>);
if (inputDescription)
handleDescription({
target: { value: inputDescription },
} as React.ChangeEvent<HTMLTextAreaElement>);
if (inputUrl)
handleUrl({ target: { value: inputUrl } } as React.ChangeEvent<HTMLInputElement>);
}
}, [isEditMode, inputTitle, inputDescription, inputUrl, member?.id, userInfo?.id]);
폼 제출 로직도 isEditMode 값에 따라 분기처리를 해주었습니다.
const solutionId = Number(searchParams.get('solutionId')) ?? null;
const isEditMode = !!solutionId;
const handleFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (isEditMode && solutionId) {
solutionPatchMutation({
solutionId,
title: solutionTitle,
description,
url,
});
} else {
handleSubmitSolution(e);
}
};
isEditMode를 통해 solutionId param의 유무를 체크하고, isEditMode가 true이면서 solutionId param이 있다면 글을 수정하는 Mutation을 실행합니다. 반대로 하나라도 통과되지 않으면 글을 제출하는 핸들러를 실행합니다.
1차 구현의 전체 코드
import * as S from './MissionSubmitPage.styled';
import SubmitBanner from '@/components/MissionSubmit/SubmitBanner';
import PRLink from '@/components/MissionSubmit/PRLink';
import OneWord from '@/components/MissionSubmit/OneWord';
import SubmitButton from '@/components/MissionSubmit/SubmitButton';
import SubmitSuccessPopUp from '@/components/PopUp/SubmitSuccessPopUp';
import { useParams, useSearchParams } from 'react-router-dom';
import useMission from '@/hooks/useMission';
import { ERROR_MESSAGE } from '@/constants/messages';
import useSubmitSolution from '@/hooks/useSubmitSolution';
import LoadingSpinner from '@/components/common/LoadingSpinner/LoadingSpinner';
import MissionTitle from '@/components/MissionSubmit/MissionTitle';
import useUserInfo from '@/hooks/useUserInfo';
import { useEffect } from 'react';
import { useUpdateSolution } from '@/hooks/useUpdateSolution';
import useSolution from '@/hooks/useSolution';
export default function MissionSubmitPage() {
const { id } = useParams();
const missionId = Number(id) || 0;
const [searchParams] = useSearchParams();
const solutionId = Number(searchParams.get('solutionId')) ?? null;
const { data: mission } = useMission(missionId);
const { data: solution } = useSolution(solutionId);
const { data: userInfo } = useUserInfo();
const { solutionPatchMutation } = useUpdateSolution(solutionId);
const missionName = new URL(mission.url).pathname.split('/').pop() ?? '';
const {
solutionTitle,
url,
description,
handleDescription,
handleMarkDownDescription,
handleUrl,
handleSubmitSolution,
handleSolutionTitle,
isPending,
isModalOpen,
isUrlError,
isDescriptionError,
isSolutionTitleError,
isSubmitSolutionError,
isValidSolutionTitle,
} = useSubmitSolution({ missionId, missionName });
const { title: inputTitle, url: inputUrl, description: inputDescription, member } = solution;
const isEditMode = !!solutionId;
// 글을 수정하는 경우 input의 초기값을 해당 값으로 변경
useEffect(() => {
if (isEditMode && member?.id === userInfo?.id) {
if (inputTitle)
handleSolutionTitle({
target: { value: inputTitle },
} as React.ChangeEvent<HTMLInputElement>);
if (inputDescription)
handleDescription({
target: { value: inputDescription },
} as React.ChangeEvent<HTMLTextAreaElement>);
if (inputUrl)
handleUrl({ target: { value: inputUrl } } as React.ChangeEvent<HTMLInputElement>);
}
}, [isEditMode, inputTitle, inputDescription, inputUrl, member?.id, userInfo?.id]);
const handleFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (isEditMode && solutionId) {
solutionPatchMutation({
solutionId,
title: solutionTitle,
description,
url,
});
} else {
handleSubmitSolution(e);
}
};
return (
<S.Container>
{isPending && <LoadingSpinner />}
<div style={{ maxWidth: '1000px', margin: '0 auto' }}>
<SubmitBanner mission={mission} />
<form onSubmit={handleFormSubmit}>
<MissionTitle
value={solutionTitle}
onChange={handleSolutionTitle}
danger={isSolutionTitleError || !isValidSolutionTitle}
/>
<PRLink
value={url}
onChange={handleUrl}
missionId={missionId}
danger={isUrlError || isSubmitSolutionError}
/>
<OneWord
danger={isDescriptionError}
dangerMessage={ERROR_MESSAGE.no_content}
value={description ?? ''}
onChange={handleMarkDownDescription}
/>
<SubmitButton />
</form>
</div>
<SubmitSuccessPopUp isModalOpen={isModalOpen} thumbnail={mission.thumbnail} />
</S.Container>
);
}
모든 로직을 한 파일에 작성하다 보니 코드가 길어지고 가독성이 떨어졌습니다. 역할을 명확히 분리하고 재사용성을 높이기 위해 커스텀 훅으로 분리하기로 결정했습니다.
2차 구현
로직은 다음과 같습니다.
- 풀이 작성 및 수정 : MissionSubmitPage -> useSubmitSolutionHandlers -> 초깃값 설정, 폼 제출
- 디스커션 작성 및 수정 : DiscussionSubmitPage -> useSubmitDiscussionHandlers -> 초깃값 설정, 폼 제출
useSubmitSolutionHandlers와 useSubmitDiscussionHandlers라는 중간 레이어를 추가하여 두 페이지 간 공통된 로직을 분리했습니다.
또한, 초깃값 설정과 폼 제출 로직이 두 곳에서 반복적으로 사용되어 이를 각각 useFormSubmission과 useInitializeInputs라는 커스텀 훅으로 추상화했습니다.
하지만 useFormSubmission에서 patchMutation을 실행하려면 특정 인자를 전달해야 했는데, Mission과 Discussion의 Mutation 핸들러에서 요구하는 인자가 서로 달랐습니다.
Mission의 경우 solutionId, title, description, url을 전달해야 했지만
export interface SolutionPatchMutationProps {
solutionId: number;
title: string;
description: string;
url: string;
}
Discussion의 경우 discussionId, title, content, missionId, hashTagIds를 전달해야 했습니다.
export interface DiscussionPatchMutationProps {
discussionId: number;
title: string;
content: string;
missionId?: number;
hashTagIds: number[];
}
이러한 차이를 해결하기 위해 확장성을 고려한 설계를 적용했습니다. id 값을 필수로 받고, 나머지 props는 제네릭을 활용해 외부에서 주입받도록 구현했습니다.
// useFormSubmission.ts의 타입
interface FormSubmissionParams<T> {
isEditMode: boolean;
id: number;
handleSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
patchMutation: (props: T) => void;
props: T;
}
// 아래와 같이 props를 전달합니다.
const handleEditSubmit = {
patchMutation({
...props,
id,
});
}
// 외부에서 주입하는 경우 아래와 같이 사용 가능합니다.
// 폼 제출 로직
const handleFormSubmit = useFormSubmission<SolutionPatchMutationProps>({
isEditMode,
id: solutionId,
handleSubmit,
patchMutation: solutionPatchMutation,
props: {
solutionId,
title: solutionTitle,
description,
url,
},
});
useCallback 메모이제이션
제출 페이지는 form 관련 컴포넌트와 Tag 관련 컴포넌트를 포함하여 최소 3개 이상의 하위 컴포넌트로 구성되어 있었습니다. 이 경우, 함수의 참조값 변경으로 인해 자식 컴포넌트의 리렌더링이 발생할 가능성이 있었습니다. 이를 방지하기 위해 함수 메모이제이션을 적용했습니다.
따라서 handleEditSubmit 함수에 useCallback을 사용하여 참조값이 변경되지 않도록 설정했습니다.
const handleEditSubmit = useCallback(() => {
patchMutation({
...props,
id,
});
}, [patchMutation, props, id]);
useInitializeInputs 훅은 페이지 렌더링 시 초깃값을 설정하는 역할을 합니다. 내부적으로 useEffect를 사용하여 초깃값을 불러오는데, 이때 useEffect에서 호출하는 setInitialInputValues 핸들러를 메모이제이션했습니다.
setInitialInputValues 핸들러를 메모이제이션하지 않으면, useEffect의 의존성 배열이 변경되어 의도치 않은 재실행이 발생할 수 있습니다. 이를 방지하기 위해 useCallback을 사용해 다음과 같이 구현했습니다.
const setInitialInputValues = useCallback(() => {
// ... 관련 로직
}, [... 필요한 의존성 배열]);
useEffect(() => {
setInitialInputValues();
}, [setInitialInputValues]);
useCallback을 통해 핸들러의 참조값을 고정함으로써 useEffect의 의존성 배열을 안정적으로 유지하고 불필요한 업데이트를 방지할 수 있게 되었습니다.
전체 코드
// MissionSubmitPage.tsx
import * as S from './MissionSubmitPage.styled';
import SubmitBanner from '@/components/MissionSubmit/SubmitBanner';
import PRLink from '@/components/MissionSubmit/PRLink';
import OneWord from '@/components/MissionSubmit/OneWord';
import SubmitButton from '@/components/MissionSubmit/SubmitButton';
import SubmitSuccessPopUp from '@/components/PopUp/SubmitSuccessPopUp';
import { ERROR_MESSAGE } from '@/constants/messages';
import LoadingSpinner from '@/components/common/LoadingSpinner/LoadingSpinner';
import MissionTitle from '@/components/MissionSubmit/MissionTitle';
import { useSubmitSolutionHandlers } from '@/hooks/useSubmitSolutionHandlers';
export default function MissionSubmitPage() {
const {
mission,
missionId,
isPending,
isModalOpen,
solutionTitle,
handleSolutionTitle,
url,
handleUrl,
description,
handleMarkDownDescription,
isSolutionTitleError,
isValidSolutionTitle,
isUrlError,
isSubmitSolutionError,
isDescriptionError,
handleFormSubmit,
} = useSubmitSolutionHandlers();
return (
<S.Container>
{isPending && <LoadingSpinner />}
<S.Wrapper>
<SubmitBanner mission={mission} />
<form onSubmit={handleFormSubmit}>
<MissionTitle
value={solutionTitle}
onChange={handleSolutionTitle}
danger={isSolutionTitleError || !isValidSolutionTitle}
/>
<PRLink
value={url}
onChange={handleUrl}
missionId={missionId}
danger={isUrlError || isSubmitSolutionError}
/>
<OneWord
danger={isDescriptionError}
dangerMessage={ERROR_MESSAGE.no_content}
value={description ?? ''}
onChange={handleMarkDownDescription}
/>
<SubmitButton />
</form>
</S.Wrapper>
<SubmitSuccessPopUp isModalOpen={isModalOpen} thumbnail={mission.thumbnail} />
</S.Container>
);
}
// useSubmitSolutionHandlers.ts
import { useParams, useSearchParams } from 'react-router-dom';
import useMission from '@/hooks/useMission';
import useSubmitSolution from '@/hooks/useSubmitSolution';
import useSolution from '@/hooks/useSolution';
import useUserInfo from '@/hooks/useUserInfo';
import { type SolutionPatchMutationProps, useUpdateSolution } from '@/hooks/useUpdateSolution';
import { useInitializeInputs } from './useInitializeInputs';
import { useFormSubmission } from './useFormSubmission';
export const useSubmitSolutionHandlers = () => {
const { id } = useParams();
const missionId = Number(id) || 0;
const [searchParams] = useSearchParams();
const solutionId = Number(searchParams.get('solutionId')) ?? null;
const { data: mission } = useMission(missionId);
const { data: solution } = useSolution(solutionId);
const { data: userInfo } = useUserInfo();
const { solutionPatchMutation } = useUpdateSolution(solutionId);
const missionName = new URL(mission.url).pathname.split('/').pop() ?? '';
const {
solutionTitle,
url,
description,
handleDescription,
handleMarkDownDescription,
handleUrl,
handleSubmitSolution: handleSubmit,
handleSolutionTitle,
isPending,
isModalOpen,
isUrlError,
isDescriptionError,
isSolutionTitleError,
isSubmitSolutionError,
isValidSolutionTitle,
} = useSubmitSolution({ missionId, missionName });
const { title: inputTitle, url: inputUrl, description: inputDescription, member } = solution;
const isEditMode = !!solutionId;
// 초기값 설정 로직
useInitializeInputs({
isEditMode,
userInfo,
member,
inputTitle,
inputDescription,
inputUrl,
handleTitle: handleSolutionTitle,
handleDescription,
handleUrl,
});
// 폼 제출 로직
const handleFormSubmit = useFormSubmission<SolutionPatchMutationProps>({
isEditMode,
id: solutionId,
handleSubmit,
patchMutation: solutionPatchMutation,
props: {
solutionId,
title: solutionTitle,
description,
url,
},
});
return {
mission,
missionId,
isPending,
isModalOpen,
solutionTitle,
handleSolutionTitle,
url,
handleUrl,
description,
handleMarkDownDescription,
isSolutionTitleError,
isValidSolutionTitle,
isUrlError,
isSubmitSolutionError,
isDescriptionError,
handleFormSubmit,
};
};
// useSubmitDiscussionHandlers.ts
import { useState } from 'react';
import { useInitializeInputs } from './useInitializeInputs';
import { useFormSubmission } from './useFormSubmission';
import useDiscussion from '@/hooks/useDiscussion';
import useUserInfo from '@/hooks/useUserInfo';
import useHashTags from '@/hooks/useHashTags';
import useMissions from '@/hooks/useMissions';
import {
type DiscussionPatchMutationProps,
useUpdateDiscussion,
} from '@/hooks/useUpdateDiscussion';
import { useSubmitDiscussion } from '@/hooks/useSubmitDiscussion';
import type { HashTag } from '@/types';
import { useSearchParams } from 'react-router-dom';
export const useSubmitDiscussionHandlers = () => {
const [searchParams] = useSearchParams();
const discussionId = Number(searchParams.get('discussionId')) ?? null;
const { data: userInfo } = useUserInfo();
const { data: allHashTags } = useHashTags();
const { missions } = useMissions();
const [selectedHashTags, setSelectedHashTags] = useState<HashTag[]>([]);
const [selectedMission, setSelectedMission] = useState<{ id: number; title: string } | null>(
null,
);
const { data: discussion } = useDiscussion(discussionId);
const { discussionPatchMutation } = useUpdateDiscussion(discussionId || 0);
const {
description,
discussionTitle,
isDiscussionTitleError,
isValidDiscussionTitle,
handleDiscussionTitle,
handleMarkDownDescription,
isDescriptionError,
handleDescription,
handleSubmitDiscussion,
} = useSubmitDiscussion({
hashTagIds: selectedHashTags.map((tag) => tag.id),
missionId: selectedMission?.id,
});
const { title: inputTitle, content: inputContent, member } = discussion || {};
// 초기값 설정 로직
useInitializeInputs({
isEditMode: !!discussionId,
userInfo,
member,
inputTitle,
inputDescription: inputContent,
inputUrl: '',
handleTitle: handleDiscussionTitle,
handleDescription,
handleUrl: () => {},
});
// 폼 제출 로직
const handleFormSubmit = useFormSubmission<DiscussionPatchMutationProps>({
isEditMode: !!discussionId,
id: discussionId || 0,
handleSubmit: handleSubmitDiscussion,
patchMutation: (props: DiscussionPatchMutationProps) => discussionPatchMutation(props),
props: {
discussionId: discussionId || 0,
content: description,
hashTagIds: selectedHashTags.map((tag) => tag.id),
missionId: selectedMission?.id,
title: discussionTitle,
},
});
return {
missions,
allHashTags,
selectedHashTags,
setSelectedHashTags,
selectedMission,
setSelectedMission,
discussionTitle,
description,
handleDiscussionTitle,
handleMarkDownDescription,
isDiscussionTitleError,
isValidDiscussionTitle,
isDescriptionError,
handleFormSubmit,
};
};
// useFormSubmission.ts
import { useCallback } from 'react';
interface FormSubmissionParams<T> {
isEditMode: boolean;
id: number;
handleSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
patchMutation: (props: T) => void;
props: T;
}
export const useFormSubmission = <T>({
isEditMode,
id,
handleSubmit,
patchMutation,
props,
}: FormSubmissionParams<T>) => {
const handleEditSubmit = useCallback(() => {
patchMutation({
...props,
id,
});
}, [patchMutation, props, id]);
const handleFormSubmit = useCallback(
(e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (isEditMode && id) {
handleEditSubmit();
} else {
handleSubmit(e);
}
},
[isEditMode, id, handleEditSubmit, handleSubmit],
);
return handleFormSubmit;
};
// useInitializeInputs.ts
import type { Member } from '@/types/solution';
import type { UserInfo } from '@/types/user';
import { useCallback, useEffect } from 'react';
interface InitializeInputsParams {
isEditMode: boolean;
userInfo: UserInfo | undefined;
member: Member;
inputTitle: string;
inputDescription: string;
inputUrl: string;
handleTitle: (e: React.ChangeEvent<HTMLInputElement>) => void;
handleDescription: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
handleUrl: (e: React.ChangeEvent<HTMLInputElement>) => void;
}
export const useInitializeInputs = ({
isEditMode,
userInfo,
member,
inputTitle,
inputDescription,
inputUrl,
handleTitle,
handleDescription,
handleUrl,
}: InitializeInputsParams) => {
const setInitialInputValues = useCallback(() => {
if (!isEditMode || member?.id !== userInfo?.id) return;
if (inputTitle)
handleTitle({ target: { value: inputTitle } } as React.ChangeEvent<HTMLInputElement>);
if (inputDescription)
handleDescription({
target: { value: inputDescription },
} as React.ChangeEvent<HTMLTextAreaElement>);
if (inputUrl) handleUrl({ target: { value: inputUrl } } as React.ChangeEvent<HTMLInputElement>);
}, [isEditMode, userInfo?.id, member?.id, inputTitle, inputDescription, inputUrl]);
useEffect(() => {
setInitialInputValues();
}, [setInitialInputValues]);
};
'우아한테크코스' 카테고리의 다른 글
[우아한테크코스] 길고 긴 10개월이 지나고... 드디어 수료식!!✨ (0) | 2024.12.21 |
---|---|
개발 서버와 운영 서버의 환경변수만 다른데 운영 서버 배포만 안되는 이유?! (AWS S3, CDN, CodePipeline, CodeBuild) (0) | 2024.11.17 |
Intersection Observer와 display를 활용한 랜딩 페이지 및 헤더 구현기 (0) | 2024.11.16 |
프로젝트 성능 개선기 (번들 사이즈 최적화를 통한 웹 성능 최적화) (1) | 2024.10.31 |
사용자 피드백 반영하기 (수정 기능, 반응형 랜딩+헤더 구현) (0) | 2024.10.31 |