대수술이었다. 약 10시간 정도 걸렸다. 이 일을 한 발단은 원티드 프리 온보딩 코스를 들으면서 관심사의 분리를 듣고 시작했다. 할 일 목록 생성 어플을 만들다가 내 프로젝트에 일괄적으로 적용해봐도 괜찮겠다 싶었다. 나의 코드를 열어서 다시 보는 순간 분명 약 1개월 정도밖에 되지 않은 코드인데 썩은 내가 나는 것 같았다. 무슨 의도로 이렇게 난해하게 작성을 했는지 모르겠다. 아마 그때 당시에는 ‘동작’하니까 이렇게 작성했을 것 같다. 만약에 이게 회사였다면 PR 승인도 못받았을 것 같은 느낌적인 느낌. 이번 리펙토링의 큰 줄기는 관심사의 분리다. 그리고 세부적으로 API를 호출하는 함수와 뷰를 최대한 분리하였다. 마침 몇개월 전에 스프린트를 끝내면서 이슈를 하나 남겨놓았었다. 그때도 이 부분이 고민이긴 했나보다.
불분명한 이름 변경하기
아래 코드는 서버에 Get 요청을 위해서 만들어진 코드다. 제네릭 타입을 지정할 때, T라고 그냥 아무 생각 없이 지정했었다. 이런 불분명한 이름은 나중에 다른 사람이 나의 코드를 사용해서 기능을 추가하거나 유지 보수 해야 할 때, 정말 화가날 것 같은 부분이었다. 설명도 없는데 그냥 T라니… 아마 useGet 함수를 사용하기 위해 마우스를 올려다 놓으면 그냥 T라고만 되어있어서 사용하기 어렵다.
- before
1interface IFetchProps<T>
2 extends Omit<
3 UseQueryOptions<T, unknown, T, QueryKey>,
4 "queryKey" | "queryFn"
5 > {
6 url: RequestInfo;
7 queryKey: string | string[];
8}
9
10const useGet = <T,>({ url, queryKey, ...rest }: IFetchProps<T>) => {
11 return useQuery<T>(
12 queryKey,
13 async () => {
14 const response = await fetch(url, getRequest);
15 const { data } = await response.json();
16 return data;
17 },
18 {
19 ...rest
20 }
21 );
22};
그래서 API를 호출하는 훅을 만들 때, 최대한 불분명한 이름을 짓지 않으려고 노력했다. 그 결과가 아래 코드다. 일단 useGetEducation이라는 이름을 보고 대강 교육에 관련된 것을 가져오는 훅이라는 것을 짐작할 수 있다. useQuery에 들어간 제네릭 타입도 EducationGetData라고 이름을 지었다. EducationGet의 Data의 모형을 나타내겠구나라고 짐작할 수 있다. 위의 코드보다 아래 코드가 훨씬 더 명확하다. 목적에 맞는 이름을 잘 짓는 것이 중요한 것 같다.
- after
1import { API } from "@/lib/api";
2import { useQuery } from "react-query";
3import { EducationGetData } from "./interface";
4
5const useGetEducations = () => {
6 const api = new API();
7 return useQuery<EducationGetData[]>(
8 ["educations"],
9 () => api.getData<EducationGetData[]>("/api/education/groups"),
10 { onSuccess: () => {} }
11 );
12};
13
14export default useGetEducations;
하나의 함수, 하나의 일
useDelete는 삭제만 하게 해주세요.
나의 코드의 함수는 여러 일을 하느라 힘들다. 아니 함수는 힘들어보이지 않는데 그걸 보는 내가 힘들다. 대표적인 함수 useDelete를 살펴보고 개선 한 사례를 살펴보자.
1import React, { useEffect, useState } from "react";
2import {
3 QueryKey,
4 useMutation,
5 UseMutationOptions,
6 useQueryClient,
7 UseQueryOptions
8} from "react-query";
9import { deleteRequest } from "@/lib/utils";
10import useGetCSRFToken from "./useGetCSRFToken";
11
12interface IuseDeleteProps<T>
13 extends Omit<UseMutationOptions<T, unknown, void, unknown>, "mutationFn"> {
14 url: RequestInfo;
15 queryKey: string;
16}
17
18const useDelete = <T,>({ url, queryKey, ...rest }: IuseDeleteProps<T>) => {
19 const [isConfirmModal, setIsConfirmModal] = useState(false);
20 const [isDelete, setIsDelete] = useState(false);
21
22 const { csrf, csrfToken } = useGetCSRFToken();
23
24 const deleteHelper = async () => {
25 const response = await fetch(url, deleteRequest(csrfToken));
26 return response.json();
27 };
28
29 useEffect(() => {
30 csrf();
31 }, []);
32
33 const mutation = useMutation<T>(deleteHelper, { ...rest });
34
35 return {
36 isConfirmModal,
37 setIsConfirmModal,
38 isDelete,
39 setIsDelete,
40 ...mutation
41 };
42};
43
44export default useDelete;
이 훅은 delete 요청을 보내 DB에서 무언가를 삭제하기 위해 만든 훅이다. 그런데 보면 용도를 모르겠는 것이 있다.
1const [isConfirmModal, setIsConfirmModal] = useState(false);
2const [isDelete, setIsDelete] = useState(false);
이 코드는 모달을 컨트롤 하기 위해 만든 상태다. 아마 설명이 없다면 이 훅을 보고 당장 사용해야겠다고 생각이 들지 않을 것이다. 누군가 이 코드를 사용해야한다면 내부를 보지 않고는 delete가 일어나는 과정을 유추할 수 가 없다.(열어봐도 유추하기 어렵다.)
나는 이 코드를 하나는 모달을 컨트롤하기 위한 함수, 하나는 서버에 delete 요청을 보내는 함수로 쪼개기로 했다.
- useDeleteNotice : 서버에 공지사항 삭제 요청을 보내는 함수.
1import { API } from "@/lib/api";
2import { useMutation, useQueryClient } from "react-query";
3import { useNavigate } from "react-router";
4
5interface NoticeDeleteVariable {
6 id: string;
7}
8
9interface NoticeDeleteData {
10 data: string;
11}
12
13const useDeleteNotice = () => {
14 const api = new API();
15 const queryClient = useQueryClient();
16
17 return useMutation<NoticeDeleteData, Error, NoticeDeleteVariable>(
18 ({ id }) => api.deleteData(`/api/notice/${id}`),
19 {
20 onSuccess: () => {
21 queryClient.invalidateQueries(["notices"]);
22 }
23 }
24 );
25};
26
27export default useDeleteNotice;
- useModalControl : 클라이언트에서 모달을 열고 닫고, 모달의 확인, 취소 버튼을 컨트롤 할 수 있다.
1import { useState } from "react";
2
3const useModalContorl = () => {
4 const [isConfirm, setIsConfirm] = useState(false);
5 const [isModal, setIsModal] = useState(false);
6
7 return { isConfirm, setIsConfirm, isModal, setIsModal };
8};
9
10export default useModalContorl;
함수를 쪼개면서 여러가지가 변경되었다. 특히 이름이 변경되면서 함수가 어떤 일을 하는지 더 명확해졌다. 이전에는 isDelete, isConfirmModal과 같은 이름을 보면 어떻게 써야하는지 조금 햇갈렸다. 하지만 isModal, isConfirm으로 변경해서 모든 모달에서 두루두루 사용할 수 있을 것 같다.
post와 patch 나누기
usePostOrPatch라는 훅을 만들 때, 정말 근사한 아이디어라고 생각했다. method를 prop으로 받아서 함수 안에서 역할을 나눠주는 것이다. 하지만 나중에 가서 usePostOrPatch라고 이름이 붙어있는 것을 보면 곧바로 어떤 일을 하는지 알 수 없다. props를 살펴봐야지만 ‘지금 post를 하고 있구나, patch를 하고 있구나'라고 유추할 수 있다.
1import React, { useEffect } from "react";
2import { useMutation, useQueryClient } from "react-query";
3import { postOrPatchRequest } from "../utilities/httpMethod";
4import useGetCSRFToken from "./useGetCSRFToken";
5
6interface IusePostProps {
7 url: RequestInfo;
8 queryKey: string | string[];
9 method: "POST" | "PATCH";
10}
11
12const usePostOrPatch = <TData, TError, TVariables>({
13 url,
14 queryKey,
15 method
16}: IusePostProps) => {
17 const { csrf, csrfToken } = useGetCSRFToken();
18 const queryClient = useQueryClient();
19
20 const postHelper = async (postData: TVariables) => {
21 const response = await fetch(
22 url,
23 postOrPatchRequest(postData, csrfToken, method)
24 );
25
26 if (!response.ok) {
27 const error = await response.json();
28 throw new Error(error.message);
29 }
30 return response.json();
31 };
32
33 useEffect(() => {
34 csrf();
35 }, []);
36
37 return useMutation<TData, TError, TVariables, unknown>(postHelper, {
38 onSuccess: () => {
39 queryClient.invalidateQueries(queryKey);
40 }
41 });
42};
43
44export default usePostOrPatch;
하지만 이번 리펙토링에서는 전부 분리하기로 했다. 그냥 하나의 함수에 너무 많은 아이디어를 넣어서 사용 방법을 복잡하게 하는 것보다 쉽게 사용할 수 있게 변경하는 편이 낫다고 생각했기 때문이다. 함수 내부가 비슷하기 때문에 반복 되는 것 같은 느낌이 든다. 하지만 페이지 컴포넌트 안에서 사용될 때, 정말 복잡하다.
- usePatchGroupInfo
1import { API } from "@/lib/api";
2import { useMutation, useQueryClient } from "react-query";
3import {
4 EducationGroupInfoData,
5 EducationGroupInfoVariable
6} from "./interface";
7
8const usePatchGroupInfo = () => {
9 const api = new API();
10 const queryClient = useQueryClient();
11 return useMutation<EducationGroupInfoData, Error, EducationGroupInfoVariable>(
12 ({ id, body }) => api.patchData(`/api/education/groups/${id}`, body),
13 {
14 onSuccess: () => {
15 queryClient.invalidateQueries(["groupInfo"]);
16 }
17 }
18 );
19};
20
21export default usePatchGroupInfo;
- useCreateGroup
1import { API } from "@/lib/api";
2import { useMutation, useQueryClient } from "react-query";
3import { EducatioCreateGroupVariable, EducationGroupData } from "./interface";
4
5const useCreateGroup = () => {
6 const api = new API();
7 const queryClient = useQueryClient();
8 return useMutation<EducationGroupData, Error, EducatioCreateGroupVariable>(
9 ({ id, body }) => api.postData(`/api/education/groups/${id}/group`, body),
10 {
11 onSuccess: () => {
12 queryClient.invalidateQueries(["groupInfo"]);
13 queryClient.invalidateQueries(["groups"]);
14 queryClient.invalidateQueries(["people"]);
15 }
16 }
17 );
18};
19
20export default useCreateGroup;
꼭 분리가 답은 아니다.
분리 해서 편한것도 있지만 같은 역할을 하는 것끼리 통합하는 것도 때론 문제 해결의 방법이 된다. 아래 코드는 fetch option을 생성해서 넣어주는 객체 또는 함수다. 이런 분리가 더 좋지 않았다. fetch를 사용할 때, option을 반복해서 넣어 주어야했다. 그래서 그냥 이 기능을 하나로 합쳐버렸다.
1export const getRequest: RequestInit = {
2 method: "GET",
3 headers: {
4 "Content-type": "application/json"
5 },
6 credentials: "include",
7 mode: "cors"
8};
9
10export const getWeekliesData = async () => {
11 const response = await fetch(`/api/worship`, getRequest);
12 const { data } = await response.json();
13 return data;
14};
15
16export const postOrPatchRequest = (
17 body: any,
18 csrfToken: string,
19 method: "POST" | "PATCH"
20): RequestInit => {
21 const data = JSON.stringify({ ...body });
22 return {
23 method,
24 headers: {
25 "Content-Type": "application/json",
26 "X-CSRF-Token": csrfToken
27 },
28 body: data,
29 credentials: "include",
30 mode: "cors"
31 };
32};
33
34export const postRequestMultipartFormData = (
35 body: any,
36 csrfToken: string
37): RequestInit => {
38 return {
39 method: "POST",
40 headers: {
41 "X-CSRF-Token": csrfToken
42 },
43 body,
44 credentials: "include",
45 mode: "cors"
46 };
47};
48
49export const deleteRequest = (csrfToken: string): RequestInit => ({
50 method: "DELETE",
51 headers: {
52 "Content-type": "application/json",
53 "X-CSRF-Token": csrfToken
54 },
55 credentials: "include",
56 mode: "cors"
57});
API라는 class를 만들어서 get, post, patch, put, delete를 기능별로 만들었다. 그래서 오히려 생성자를 선언하고 method 별로 함수를 불러와 바로 사용할 수 있게 했다. 변경 후에 커스텀 훅 내부가 훨씬 더 알아보기 쉬워졌다. hook의 이름으로 기능을 유추할 수 있지만, 내부를 열었을 때도 변경도 쉽다.
1class API {
2 async getData<TData>(url: string) {
3 const response = await fetch(url, {
4 method: "GET",
5 headers: {
6 "Content-Type": "application/json"
7 },
8 credentials: "include",
9 mode: "cors"
10 });
11
12 const { data } = await response.json();
13
14 return data as TData;
15 }
16
17 async postData<TData, TVariable>(url: string, body: TVariable) {
18 const CSRFToken = await this.getData<string>("/api/csrf-token");
19
20 const response = await fetch(url, {
21 method: "POST",
22 headers: {
23 "Content-Type": "application/json",
24 "X-CSRF-Token": CSRFToken
25 },
26 body: JSON.stringify(body),
27 credentials: "include",
28 mode: "cors"
29 });
30
31 const { data } = await response.json();
32
33 return data as TData;
34 }
35
36 async patchData<TData, TVariable>(url: string, body: TVariable) {
37 const CSRFToken = await this.getData<string>("/api/csrf-token");
38
39 const response = await fetch(url, {
40 method: "PATCH",
41 headers: {
42 "Content-Type": "application/json",
43 "X-CSRF-Token": CSRFToken
44 },
45 body: JSON.stringify(body),
46 credentials: "include",
47 mode: "cors"
48 });
49
50 const { data } = await response.json();
51
52 return data as TData;
53 }
54
55 async putData<TData, TVariable>(url: string, body: TVariable) {
56 const CSRFToken = await this.getData<string>("/api/csrf-token");
57
58 const response = await fetch(url, {
59 method: "PUT",
60 headers: {
61 "Content-Type": "application/json",
62 "X-CSRF-Token": CSRFToken
63 },
64 body: JSON.stringify(body),
65 credentials: "include",
66 mode: "cors"
67 });
68
69 const { data } = await response.json();
70
71 return data as TData;
72 }
73
74 async deleteData<TData>(url: string) {
75 const CSRFToken = await this.getData<string>("/api/csrf-token");
76
77 const response = await fetch(url, {
78 method: "DELETE",
79 headers: {
80 "Content-type": "application/json",
81 "X-CSRF-Token": CSRFToken
82 },
83 credentials: "include",
84 mode: "cors"
85 });
86
87 const { data } = await response.json();
88
89 return data as TData;
90 }
91}
92
93export default API;
1// 다른것을 다 생략하고 보더라도 통합된 함수를 사용하는 것이 훨씬 더 효율적이다.
2
3// 분리
4const useDelete = <T,>({ url, queryKey, ...rest }: IuseDeleteProps<T>) => {
5 const deleteHelper = async () => {
6 const response = await fetch(url, deleteRequest(csrfToken));
7 return response.json();
8 };
9
10 const mutation = useMutation<T>(deleteHelper, { ...rest });
11};
12
13// 통합
14const useDeleteGroup = () => {
15 const api = new API();
16 return useMutation((id: string) =>
17 api.deleteData(`/api/education/group/${id}`)
18 );
19};
최악의 코드
그래서 이번 리펙토링을 통해서 뷰와 비동기 코드를 분리해서 조금은 개선할 수 있었다. 하지만 여전히 부족한 부분이 많이 보인다. 대표적으로 가장 최악이라고 뽑은 코드를 개선 예제로 넣었다. 전체 코드를 보면 정말 토나온다.
-
before
before 전체 코드
1import React, { useEffect, useRef, useState } from "react";2import styled from "styled-components";3import Human from "./Human/Human";4import { Droppable } from "react-beautiful-dnd";5import { useForm } from "react-hook-form";6import { useQueryClient } from "react-query";7import {8 MdArrowDropDown,9 MdDelete,10 MdEdit,11 MdPersonAdd12} from "react-icons/md";13import { ConfirmDeleteModal } from "@/components";1415import {16 People,17 Group as GroupProps18} from "../../../state/educationGroup.atom";1920import { usePost } from "@/lib/hooks";21import { useGet } from "@/lib/hooks";22import { FetchDataProps } from "@/lib/interfaces";2324import { translateEducationTypeNameToKR } from "@/lib/utils";25import { useDebouncedEffect, useDelete } from "@/lib/hooks";26import { useRecoilState } from "recoil";27import { AiOutlineConsoleSql } from "react-icons/ai";2829const Container = styled.div`30 border: 1px solid ${(props) => props.theme.color.gray300};31 border-radius: 1rem;32 padding: 2rem;33 display: flex;34 flex-direction: column;35 margin: 1rem 0;36`;3738const Header = styled.div`39 display: flex;40 justify-content: space-between;41 align-items: center;42 .group-info {43 display: flex;44 span {45 margin-left: 0.5rem;46 }47 }48`;4950const ButtonContainer = styled.div`51 button {52 cursor: pointer;53 background-color: unset;54 border: 0;55 font-size: 2.5rem;56 color: ${(props) => props.theme.color.gray300};57 &:hover {58 color: ${(props) => props.theme.color.primary300};59 }60 }61`;6263const Title = styled.h3`64 font-size: 2.2rem;65 margin-bottom: 1rem;66`;6768const PersonList = styled.div<{ isDraggingOver: boolean }>`69 box-sizing: border-box;70 margin: 2rem 0;71 font-size: 1.8rem;72 transition: all 0.2s ease-in-out;73 padding: ${(props) => props.isDraggingOver && "1rem"};74 border-radius: 0.5rem;75 background-color: ${(props) =>76 props.isDraggingOver77 ? props.theme.color.primary30078 : props.theme.color.background100};79 flex-grow: 1;80 min-height: 100px;81`;8283const AddPersonButton = styled.button`84 cursor: pointer;85 border: 0;86 padding: 1rem;87 border: 1px solid ${(props) => props.theme.color.gray300};88 border-radius: 0.5rem;89 background-color: unset;90 font-size: 1.7rem;91 text-align: left;92 display: flex;93 justify-content: space-between;94 color: ${(props) => props.theme.color.gray500};95 &:hover {96 border: 1px solid ${(props) => props.theme.color.primary300};97 background-color: ${(props) => props.theme.color.primary300};98 color: ${(props) => props.theme.color.fontColorWhite};99 }100`;101102const Form = styled.form`103 position: relative;104 box-sizing: border-box;105 width: 100%;106 input {107 box-sizing: border-box;108 width: 100%;109 padding: 1rem;110 font-size: 1.8rem;111 border: 1px solid ${(props) => props.theme.color.gray300};112 }113 .select-container {114 box-sizing: border-box;115 cursor: pointer;116 display: inline-block;117 position: relative;118 width: 100%;119 overflow: hidden;120 border: 1px solid ${(props) => props.theme.color.gray300};121 padding: 1rem;122 }123 .arrow-drop-down {124 position: absolute;125 z-index: -1;126 top: 50%;127 right: 1rem;128 transform: translateY(-50%);129 }130131 select {132 box-sizing: border-box;133 background-color: unset;134 cursor: pointer;135 font-size: 2rem;136 border: 0;137 outline: none;138 width: 100%;139 -webkit-appearance: none;140 -moz-appearance: none;141 appearance: none;142 margin: 0;143 }144`;145146const SearchingBox = styled.ul`147 position: absolute;148 top: 4.5rem;149 left: 0;150 margin: 0;151 padding: 0;152 z-index: 2;153 width: 100%;154 max-height: 20rem;155 overflow-y: auto;156 border: 1px solid ${(props) => props.theme.color.gray300};157 border-top: 0;158 background-color: ${(props) => props.theme.color.background100};159`;160const SearchingItem = styled.li<{ isSelect?: boolean }>`161 display: grid;162 grid-template-columns: 3fr 0.5fr 0.5fr;163 align-items: center;164 cursor: pointer;165 font-size: 1.8rem;166 padding: 1rem 1rem;167 span {168 font-size: 1.2rem;169 font-weight: bold;170 }171 background-color: ${(props) =>172 props.isSelect && props.theme.color.primary300};173 &:hover {174 background-color: ${(props) => props.theme.color.primary700};175 color: ${(props) => props.theme.color.fontColorWhite};176 }177`;178179interface IGroupProps {180 item: GroupProps;181}182183interface SendPeople {184 _id?: string;185 name?: string;186 type?: "student" | "worker" | "new" | "etc";187}188interface Form {189 name?: string;190 type?: "student" | "worker" | "new" | "etc";191 place?: string;192 groupName?: string;193}194195const Group = ({ item }: IGroupProps) => {196 const searchingListNodes = useRef<HTMLUListElement>(null);197 const [isSearchingBoxError, setSearchingBoxError] = useState(false);198 const [count, setCount] = useState(0);199 const [selectedNodeId, setSelectedNodeId] = useState("");200201 const queryClient = useQueryClient();202 const [searchPersonName, setSearchPersonName] = useState("");203 const {204 register,205 handleSubmit,206 reset,207 formState: { errors },208 setError209 } = useForm<Form>();210211 const [isUpdate, setIsUpdate] = useState(false);212 const [isOpenPeopleInput, setIsOpenPeopleInput] = useState(false);213 const [searchPerson, setSearchPerson] = useState<People[] | null>();214 const { data, refetch } = useGet<People[] | null>({215 url: `/api/education/search?person=${searchPersonName}`,216 queryKey: "search",217 enabled: false,218 onSuccess: (response) => {219 setSearchPerson(response);220 }221 });222223 const { data: people } = useGet<People[]>({224 url: `/api/education/group/${item._id}/people`,225 queryKey: ["people", item._id]226 });227228 const { mutate: addNewPeople } = usePost<229 FetchDataProps<People[]>,230 Error,231 SendPeople232 >({233 url: `/api/education/group/${item._id}/people`,234 queryKey: ["people", item._id],235 method: "POST"236 });237238 const { mutate: updateGroup } = usePost<239 FetchDataProps<GroupProps>,240 Error,241 {242 _id?: string;243 name?: string;244 place?: string;245 type?: "student" | "worker" | "new" | "etc";246 }247 >({248 url: `/api/education/group/update`,249 queryKey: "groups",250 method: "PATCH"251 });252253 const {254 mutate: deleteGroupMutate,255 isConfirmModal,256 setIsConfirmModal,257 isDelete,258 setIsDelete259 } = useDelete({260 url: `/api/education/group/${item._id}`,261 queryKey: "groups",262 onSuccess: () => {263 queryClient.invalidateQueries("groups");264 }265 });266267 const openAddPeopleInput = () => {268 setIsOpenPeopleInput(!isOpenPeopleInput);269 reset({ name: "" });270 setSearchPersonName("");271 setSearchPerson([]);272 };273274 const deleteGroup = () => {275 setIsConfirmModal(true);276 };277278 const onSubmitNewPeopleName = handleSubmit((data) => {279 if (selectedNodeId && searchPerson && searchPerson.length !== 0) {280 const [item] = searchPerson?.filter(281 (value) => value._id === selectedNodeId282 );283 selectItem(item);284 setIsOpenPeopleInput(!isOpenPeopleInput);285 reset({ name: "" });286 setSearchPersonName("");287 setSearchPerson([]);288 return;289 }290291 addNewPeople(292 {293 name: data.name,294 type: item.type295 },296 {297 onSuccess: () => {298 setSearchPerson([]);299 reset({ name: "" });300 },301 onError: (err) => {302 setSearchPerson([]);303 reset({ name: "" });304 setError("name", { message: err.message });305 }306 }307 );308 setIsOpenPeopleInput(!isOpenPeopleInput);309 reset({ name: "" });310 setSearchPersonName("");311 setSearchPerson([]);312 });313314 const onSubmitUpdateGroupName = handleSubmit((data) => {315 updateGroup({316 _id: item._id,317 name: data.groupName,318 type: data.type,319 place: data.place320 });321 reset();322 setIsUpdate(false);323 });324325 const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {326 setSearchPersonName(() => e.target.value);327 };328329 const selectItem = (person: People) => {330 const hasHuman = item.humanIds.some((value) => value === person._id);331 if (hasHuman) {332 setSearchingBoxError(true);333 return;334 }335 addNewPeople({ _id: person._id });336 reset({ name: "" });337 };338339 const handleSearchBoxWithKey = (340 e: React.KeyboardEvent<HTMLInputElement>341 ) => {342 if (e.key === "ArrowUp") {343 setCount(count - 1);344 }345346 if (e.key === "ArrowDown") {347 setCount(count + 1);348 }349350 if (e.key === "Escape") {351 setSearchPerson([]);352 setIsOpenPeopleInput(false);353 }354 };355356 const findNodes = (count: number) => {357 const list = searchingListNodes.current?.childNodes;358 const select = list ? Array.from(list) : [];359 const [li] = select.filter(360 (value, index) => index === count361 ) as HTMLLIElement[];362 const selectId = li && li.dataset.id;363 setSelectedNodeId(() => String(selectId));364 return li;365 };366367 const selectNode = (count: number) => {368 const li = findNodes(count);369 let num = 0 - count;370 if (searchingListNodes && li && num <= 0) {371 searchingListNodes.current?.scrollTo(0, li.offsetTop);372 }373374 if (searchingListNodes && li && num >= 0) {375 searchingListNodes.current?.scrollTo(li.offsetTop, 0);376 }377 };378379 useEffect(() => {380 const length = searchPerson?.length as number;381382 if (count < 0) {383 setCount(length - 1);384 return;385 }386387 if (count >= length) {388 setCount(0);389 return;390 }391392 selectNode(count);393 }, [searchPerson, count, setCount]);394395 useEffect(() => {396 if (isDelete) {397 deleteGroupMutate();398 setIsConfirmModal(false);399 setIsDelete(false);400 }401 }, [isDelete]);402403 useDebouncedEffect(() => refetch(), 300, [searchPersonName]);404405 useEffect(() => {406 const timeout = setTimeout(() => setSearchingBoxError(false), 3000);407 return () => clearTimeout(timeout);408 }, [isSearchingBoxError, setSearchingBoxError]);409 const personName = useRef<HTMLInputElement>(null);410 return (411 <>412 {isConfirmModal && (413 <ConfirmDeleteModal414 setIsModal={setIsDelete}415 setIsConfirm={setIsConfirmModal}416 title="그룹을 삭제하시겠습니까?"417 subtitle="그룹을 삭제하면 복구할 수 없습니다. 참가자는 그대로 남습니다."418 />419 )}420 <Container data-id={item._id}>421 <Header>422 {!isUpdate ? (423 <>424 <div className="group-info">425 <Title>{item.name}</Title>426 <span>{item.place}</span>427 </div>428 <ButtonContainer>429 <button onClick={openAddPeopleInput}>430 <MdPersonAdd />431 </button>432 <button onClick={() => setIsUpdate(true)}>433 <MdEdit />434 </button>435 <button onClick={deleteGroup}>436 <MdDelete />437 </button>438 </ButtonContainer>439 </>440 ) : (441 <>442 <Form onSubmit={onSubmitUpdateGroupName}>443 <input444 autoComplete="off"445 id="groupName"446 defaultValue={item.name}447 placeholder="이름을 적고 엔터!"448 type="text"449 {...register("groupName")}450 />451 <input452 autoComplete="off"453 id="place"454 defaultValue={item.place}455 placeholder="교환할 장소?"456 type="text"457 {...register("place")}458 />459 <span className="select-container">460 <select461 defaultValue={item.type}462 {...register("type")}463 >464 <option value="student">학생</option>465 <option value="worker">직장</option>466 <option value="new">새신자</option>467 <option value="etc">기타</option>468 </select>469 <span className="arrow-drop-down">470 <MdArrowDropDown />471 </span>472 </span>473 <input474 type="submit"475 hidden={true}476 />477 </Form>478 <ButtonContainer>479 <button onClick={onSubmitUpdateGroupName}>480 <MdEdit />481 </button>482 </ButtonContainer>483 </>484 )}485 </Header>486 {isOpenPeopleInput && (487 <>488 <Form onSubmit={onSubmitNewPeopleName}>489 <input490 id="name"491 placeholder="이름을 적고 엔터!"492 type="text"493 value={searchPersonName}494 autoComplete="off"495 {...register("name", {496 required: "이름을 꼭 입력해야합니다.",497 onChange: handleSearch498 })}499 onKeyDown={(e) => handleSearchBoxWithKey(e)}500 />501502 {searchPerson?.length === 0 ? (503 <SearchingBox>504 <SearchingItem>505 <p>검색어 또는 추가할 이름을 입력하세요.</p>506 </SearchingItem>507 </SearchingBox>508 ) : (509 <SearchingBox ref={searchingListNodes}>510 {isSearchingBoxError && (511 <span>이미 참가하고 있습니다.</span>512 )}513 {searchPerson?.map((value) => (514 <SearchingItem515 key={value._id}516 data-id={value._id}517 isSelect={518 selectedNodeId ? value._id === selectedNodeId : false519 }520 onClick={() => selectItem(value)}521 >522 <p>{value.name}</p>523 <span>{value.sex === "male" ? "남자" : "여자"}</span>524 <span>525 {translateEducationTypeNameToKR(value.type)}526 </span>527 </SearchingItem>528 ))}529 </SearchingBox>530 )}531532 <label>{errors.name?.message}</label>533 </Form>534 </>535 )}536 <Droppable droppableId={item._id}>537 {(provided, snapshot) => (538 <PersonList539 ref={provided.innerRef}540 {...provided.droppableProps}541 isDraggingOver={snapshot.isDraggingOver}542 >543 {people?.map((person, index) => (544 <Human545 key={person._id}546 index={index}547 person={person}548 groupId={item._id}549 />550 ))}551 {provided.placeholder}552 </PersonList>553 )}554 </Droppable>555 </Container>556 </>557 );558};559560export default Group;
1const Group = ({ item }: IGroupProps) => {
2 const queryClient = useQueryClient();
3
4 const [isUpdate, setIsUpdate] = useState(false);
5 const [isOpenPeopleInput, setIsOpenPeopleInput] = useState(false);
6 const [searchPerson, setSearchPerson] = useState<People[] | null>();
7 const { data, refetch } = useGet<People[] | null>({
8 url: `/api/education/search?person=${searchPersonName}`,
9 queryKey: "search",
10 enabled: false,
11 onSuccess: (response) => {
12 setSearchPerson(response);
13 }
14 });
15
16 const { data: people } = useGet<People[]>({
17 url: `/api/education/group/${item._id}/people`,
18 queryKey: ["people", item._id]
19 });
20
21 const { mutate: addNewPeople } = usePost<
22 FetchDataProps<People[]>,
23 Error,
24 SendPeople
25 >({
26 url: `/api/education/group/${item._id}/people`,
27 queryKey: ["people", item._id],
28 method: "POST"
29 });
30
31 const { mutate: updateGroup } = usePost<
32 FetchDataProps<GroupProps>,
33 Error,
34 {
35 _id?: string;
36 name?: string;
37 place?: string;
38 type?: "student" | "worker" | "new" | "etc";
39 }
40 >({
41 url: `/api/education/group/update`,
42 queryKey: "groups",
43 method: "PATCH"
44 });
45
46 const {
47 mutate: deleteGroupMutate,
48 isConfirmModal,
49 setIsConfirmModal,
50 isDelete,
51 setIsDelete
52 } = useDelete({
53 url: `/api/education/group/${item._id}`,
54 queryKey: "groups",
55 onSuccess: () => {
56 queryClient.invalidateQueries("groups");
57 }
58 });
59
60 const deleteGroup = () => {
61 setIsConfirmModal(true);
62 };
63
64 const onSubmitNewPeopleName = handleSubmit((data) => {
65 if (selectedNodeId && searchPerson && searchPerson.length !== 0) {
66 const [item] = searchPerson?.filter(
67 (value) => value._id === selectedNodeId
68 );
69 selectItem(item);
70 setIsOpenPeopleInput(!isOpenPeopleInput);
71 reset({ name: "" });
72 setSearchPersonName("");
73 setSearchPerson([]);
74 return;
75 }
76
77 addNewPeople(
78 {
79 name: data.name,
80 type: item.type
81 },
82 {
83 onSuccess: () => {
84 setSearchPerson([]);
85 reset({ name: "" });
86 },
87 onError: (err) => {
88 setSearchPerson([]);
89 reset({ name: "" });
90 setError("name", { message: err.message });
91 }
92 }
93 );
94 setIsOpenPeopleInput(!isOpenPeopleInput);
95 reset({ name: "" });
96 setSearchPersonName("");
97 setSearchPerson([]);
98 });
99
100 const onSubmitUpdateGroupName = handleSubmit((data) => {
101 updateGroup({
102 _id: item._id,
103 name: data.groupName,
104 type: data.type,
105 place: data.place
106 });
107 reset();
108 setIsUpdate(false);
109 });
110
111 const selectItem = (person: People) => {
112 const hasHuman = item.humanIds.some((value) => value === person._id);
113 if (hasHuman) {
114 setSearchingBoxError(true);
115 return;
116 }
117 addNewPeople({ _id: person._id });
118 reset({ name: "" });
119 };
120
121 useEffect(() => {
122 if (isDelete) {
123 deleteGroupMutate();
124 setIsConfirmModal(false);
125 setIsDelete(false);
126 }
127 }, [isDelete]);
128
129 const personName = useRef<HTMLInputElement>(null);
130 return (
131 <>
132 {isConfirmModal && (
133 <ConfirmDeleteModal
134 setIsModal={setIsDelete}
135 setIsConfirm={setIsConfirmModal}
136 title="그룹을 삭제하시겠습니까?"
137 subtitle="그룹을 삭제하면 복구할 수 없습니다. 참가자는 그대로 남습니다."
138 />
139 )}
140 </>
141 );
142};
143
144export default Group;
-
after
after 전체 코드
1import React, { useEffect, useRef, useState } from "react";2import styled from "styled-components";3import Human from "./Human/Human";4import { Droppable } from "react-beautiful-dnd";5import { useForm } from "react-hook-form";6import {7 MdArrowDropDown,8 MdDelete,9 MdEdit,10 MdPersonAdd11} from "react-icons/md";12import { ConfirmDeleteModal } from "@/components";13import { People, Group as GroupProps } from "@/state";1415import { translateEducationTypeNameToKR } from "@/lib/utils";16import { useDebouncedEffect, useModalContorl } from "@/lib/hooks";17import {18 useAddNewPerson,19 useGetPeople,20 useDeleteGroup,21 useEducaionSearchPerson,22 useUpdateGroup23} from "../hooks";2425const Container = styled.div`26 border: 1px solid ${(props) => props.theme.color.gray300};27 border-radius: 1rem;28 padding: 2rem;29 display: flex;30 flex-direction: column;31 margin: 1rem 0;32`;3334const Header = styled.div`35 display: flex;36 justify-content: space-between;37 align-items: center;38 .group-info {39 display: flex;40 span {41 margin-left: 0.5rem;42 }43 }44`;4546const ButtonContainer = styled.div`47 button {48 cursor: pointer;49 background-color: unset;50 border: 0;51 font-size: 2.5rem;52 color: ${(props) => props.theme.color.gray300};53 &:hover {54 color: ${(props) => props.theme.color.primary300};55 }56 }57`;5859const Title = styled.h3`60 font-size: 2.2rem;61 margin-bottom: 1rem;62`;6364const PersonList = styled.div<{ isDraggingOver: boolean }>`65 box-sizing: border-box;66 margin: 2rem 0;67 font-size: 1.8rem;68 transition: all 0.2s ease-in-out;69 padding: ${(props) => props.isDraggingOver && "1rem"};70 border-radius: 0.5rem;71 background-color: ${(props) =>72 props.isDraggingOver73 ? props.theme.color.primary30074 : props.theme.color.background100};75 flex-grow: 1;76 min-height: 100px;77`;7879const AddPersonButton = styled.button`80 cursor: pointer;81 border: 0;82 padding: 1rem;83 border: 1px solid ${(props) => props.theme.color.gray300};84 border-radius: 0.5rem;85 background-color: unset;86 font-size: 1.7rem;87 text-align: left;88 display: flex;89 justify-content: space-between;90 color: ${(props) => props.theme.color.gray500};91 &:hover {92 border: 1px solid ${(props) => props.theme.color.primary300};93 background-color: ${(props) => props.theme.color.primary300};94 color: ${(props) => props.theme.color.fontColorWhite};95 }96`;9798const Form = styled.form`99 position: relative;100 box-sizing: border-box;101 width: 100%;102 input {103 box-sizing: border-box;104 width: 100%;105 padding: 1rem;106 font-size: 1.8rem;107 border: 1px solid ${(props) => props.theme.color.gray300};108 }109 .select-container {110 box-sizing: border-box;111 cursor: pointer;112 display: inline-block;113 position: relative;114 width: 100%;115 overflow: hidden;116 border: 1px solid ${(props) => props.theme.color.gray300};117 padding: 1rem;118 }119 .arrow-drop-down {120 position: absolute;121 z-index: -1;122 top: 50%;123 right: 1rem;124 transform: translateY(-50%);125 }126 select {127 box-sizing: border-box;128 background-color: unset;129 cursor: pointer;130 font-size: 2rem;131 border: 0;132 outline: none;133 width: 100%;134 -webkit-appearance: none;135 -moz-appearance: none;136 appearance: none;137 margin: 0;138 }139`;140141const SearchingBox = styled.ul`142 position: absolute;143 top: 4.5rem;144 left: 0;145 margin: 0;146 padding: 0;147 z-index: 2;148 width: 100%;149 max-height: 20rem;150 overflow-y: auto;151 border: 1px solid ${(props) => props.theme.color.gray300};152 border-top: 0;153 background-color: ${(props) => props.theme.color.background100};154`;155const SearchingItem = styled.li<{ isSelect?: boolean }>`156 display: grid;157 grid-template-columns: 3fr 0.5fr 0.5fr;158 align-items: center;159 cursor: pointer;160 font-size: 1.8rem;161 padding: 1rem 1rem;162 span {163 font-size: 1.2rem;164 font-weight: bold;165 }166 background-color: ${(props) =>167 props.isSelect && props.theme.color.primary300};168 &:hover {169 background-color: ${(props) => props.theme.color.primary700};170 color: ${(props) => props.theme.color.fontColorWhite};171 }172`;173174interface IGroupProps {175 item: GroupProps;176}177178interface Form {179 name?: string;180 type?: "student" | "worker" | "new" | "etc";181 place?: string;182 groupName?: string;183}184185const Group = ({ item }: IGroupProps) => {186 const searchingListNodes = useRef<HTMLUListElement>(null);187 const [isSearchingBoxError, setSearchingBoxError] = useState(false);188 const [count, setCount] = useState(0);189 const [selectedNodeId, setSelectedNodeId] = useState("");190 const [searchPersonName, setSearchPersonName] = useState("");191192 const {193 register,194 handleSubmit,195 reset,196 formState: { errors },197 setError198 } = useForm<Form>();199200 const [isUpdate, setIsUpdate] = useState(false);201 const [isOpenPeopleInput, setIsOpenPeopleInput] = useState(false);202 const [searchPerson, setSearchPerson] = useState<People[] | null>();203204 const { isModal, isConfirm, setIsConfirm, setIsModal } = useModalContorl();205206 const { mutate: addNewPeople } = useAddNewPerson();207 const { mutate: deleteGroupMutate } = useDeleteGroup();208 const { mutate: updateGroup } = useUpdateGroup();209 const { data: people } = useGetPeople(item._id);210 const { refetch } = useEducaionSearchPerson(211 searchPersonName,212 setSearchPerson213 );214215 useDebouncedEffect(() => refetch(), 300, [searchPersonName]);216217 const openAddPeopleInput = () => {218 setIsOpenPeopleInput(!isOpenPeopleInput);219 reset({ name: "" });220 setSearchPersonName("");221 setSearchPerson([]);222 };223224 const deleteGroup = () => {225 setIsModal(true);226 };227228 const onSubmitNewPeopleName = handleSubmit((data) => {229 if (selectedNodeId && searchPerson && searchPerson.length !== 0) {230 const [selectedItem] = searchPerson?.filter(231 (person) => person._id === selectedNodeId232 );233 selectItem(selectedItem);234 } else if (data.name) {235 addNewPeople(236 {237 id: item._id,238 body: {239 name: data.name,240 type: item.type241 }242 },243 {244 onSuccess: () => {245 setSearchPerson([]);246 reset({ name: "" });247 },248 onError: (err) => {249 setSearchPerson([]);250 reset({ name: "" });251 setError("name", { message: err.message });252 }253 }254 );255 }256257 setIsOpenPeopleInput(!isOpenPeopleInput);258 reset({ name: "" });259 setSearchPersonName("");260 setSearchPerson([]);261 });262263 const onSubmitUpdateGroupName = handleSubmit((data) => {264 const id = item._id;265 const body = {266 _id: id,267 name: data.groupName,268 type: data.type,269 place: data.place270 };271 if (id) {272 updateGroup({ body });273 reset();274 setIsUpdate(false);275 }276 });277278 const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {279 setSearchPersonName(() => e.target.value);280 };281282 const selectItem = (person: People) => {283 const hasHuman = item.humanIds.some((value) => value === person._id);284 if (hasHuman) {285 setSearchingBoxError(true);286 return;287 }288 addNewPeople({ id: item._id, body: { _id: person._id } });289 reset({ name: "" });290 };291292 const handleSearchBoxWithKey = (293 e: React.KeyboardEvent<HTMLInputElement>294 ) => {295 if (e.key === "ArrowUp") {296 setCount(count - 1);297 }298299 if (e.key === "ArrowDown") {300 setCount(count + 1);301 }302303 if (e.key === "Escape") {304 setSearchPerson([]);305 setIsOpenPeopleInput(false);306 }307 };308309 const findNodes = (count: number) => {310 const list = searchingListNodes.current?.childNodes;311 const select = list ? Array.from(list) : [];312 const [li] = select.filter(313 (value, index) => index === count314 ) as HTMLLIElement[];315 const selectId = li && li.dataset.id;316 setSelectedNodeId(() => String(selectId));317 return li;318 };319320 const selectNode = (count: number) => {321 const li = findNodes(count);322 let num = 0 - count;323 if (searchingListNodes && li && num <= 0) {324 searchingListNodes.current?.scrollTo(0, li.offsetTop);325 }326327 if (searchingListNodes && li && num >= 0) {328 searchingListNodes.current?.scrollTo(li.offsetTop, 0);329 }330 };331332 useEffect(() => {333 const length = searchPerson?.length as number;334335 if (count < 0) {336 setCount(length - 1);337 return;338 }339340 if (count >= length) {341 setCount(0);342 return;343 }344345 selectNode(count);346 }, [searchPerson, count, setCount]);347348 useEffect(() => {349 const id = item._id;350 if (isConfirm && id) {351 deleteGroupMutate(id);352 setIsConfirm(false);353 setIsModal(false);354 }355 }, [isConfirm]);356357 useEffect(() => {358 const timeout = setTimeout(() => setSearchingBoxError(false), 3000);359 return () => clearTimeout(timeout);360 }, [isSearchingBoxError, setSearchingBoxError]);361362 return (363 <>364 {isModal && (365 <ConfirmDeleteModal366 setIsModal={setIsModal}367 setIsConfirm={setIsConfirm}368 title="그룹을 삭제하시겠습니까?"369 subtitle="그룹을 삭제하면 복구할 수 없습니다. 참가자는 그대로 남습니다."370 />371 )}372 <Container data-id={item._id}>373 <Header>374 {!isUpdate ? (375 <>376 <div className="group-info">377 <Title>{item.name}</Title>378 <span>{item.place}</span>379 </div>380 <ButtonContainer>381 <button onClick={openAddPeopleInput}>382 <MdPersonAdd />383 </button>384 <button onClick={() => setIsUpdate(true)}>385 <MdEdit />386 </button>387 <button onClick={deleteGroup}>388 <MdDelete />389 </button>390 </ButtonContainer>391 </>392 ) : (393 <>394 <Form onSubmit={onSubmitUpdateGroupName}>395 <input396 autoComplete="off"397 id="groupName"398 defaultValue={item.name}399 placeholder="이름을 적고 엔터!"400 type="text"401 {...register("groupName")}402 />403 <input404 autoComplete="off"405 id="place"406 defaultValue={item.place}407 placeholder="교환할 장소?"408 type="text"409 {...register("place")}410 />411 <span className="select-container">412 <select413 defaultValue={item.type}414 {...register("type")}415 >416 <option value="student">학생</option>417 <option value="worker">직장</option>418 <option value="new">새신자</option>419 <option value="etc">기타</option>420 </select>421 <span className="arrow-drop-down">422 <MdArrowDropDown />423 </span>424 </span>425 <input426 type="submit"427 hidden={true}428 />429 </Form>430 <ButtonContainer>431 <button onClick={onSubmitUpdateGroupName}>432 <MdEdit />433 </button>434 </ButtonContainer>435 </>436 )}437 </Header>438 {isOpenPeopleInput && (439 <>440 <Form onSubmit={onSubmitNewPeopleName}>441 <input442 id="name"443 placeholder="이름을 적고 엔터!"444 type="text"445 value={searchPersonName}446 autoComplete="off"447 {...register("name", {448 required: "이름을 꼭 입력해야합니다.",449 onChange: handleSearch450 })}451 onKeyDown={(e) => handleSearchBoxWithKey(e)}452 />453454 {searchPerson?.length === 0 ? (455 <SearchingBox>456 <SearchingItem>457 <p>검색어 또는 추가할 이름을 입력하세요.</p>458 </SearchingItem>459 </SearchingBox>460 ) : (461 <SearchingBox ref={searchingListNodes}>462 {isSearchingBoxError && (463 <span>이미 참가하고 있습니다.</span>464 )}465 {searchPerson?.map((value) => (466 <SearchingItem467 key={value._id}468 data-id={value._id}469 isSelect={470 selectedNodeId ? value._id === selectedNodeId : false471 }472 onClick={() => selectItem(value)}473 >474 <p>{value.name}</p>475 <span>{value.sex === "male" ? "남자" : "여자"}</span>476 <span>477 {translateEducationTypeNameToKR(value.type)}478 </span>479 </SearchingItem>480 ))}481 </SearchingBox>482 )}483484 <label>{errors.name?.message}</label>485 </Form>486 </>487 )}488 <Droppable droppableId={item._id}>489 {(provided, snapshot) => (490 <PersonList491 ref={provided.innerRef}492 {...provided.droppableProps}493 isDraggingOver={snapshot.isDraggingOver}494 >495 {people?.map((person, index) => (496 <Human497 key={person._id}498 index={index}499 person={person}500 groupId={item._id}501 />502 ))}503 {provided.placeholder}504 </PersonList>505 )}506 </Droppable>507 </Container>508 </>509 );510};511512export default Group;
1const Group = ({ item }: IGroupProps) => {
2 const { isModal, isConfirm, setIsConfirm, setIsModal } = useModalContorl();
3
4 const { mutate: addNewPeople } = useAddNewPerson();
5 const { mutate: deleteGroupMutate } = useDeleteGroup();
6 const { mutate: updateGroup } = useUpdateGroup();
7 const { data: people } = useGetPeople(item._id);
8 const { refetch } = useEducaionSearchPerson(
9 searchPersonName,
10 setSearchPerson
11 );
12
13 const deleteGroup = () => {
14 setIsModal(true);
15 };
16
17 const onSubmitNewPeopleName = handleSubmit((data) => {
18 if (selectedNodeId && searchPerson && searchPerson.length !== 0) {
19 const [selectedItem] = searchPerson?.filter(
20 (person) => person._id === selectedNodeId
21 );
22 selectItem(selectedItem);
23 } else if (data.name) {
24 addNewPeople(
25 {
26 id: item._id,
27 body: {
28 name: data.name,
29 type: item.type
30 }
31 },
32 {
33 onSuccess: () => {
34 setSearchPerson([]);
35 reset({ name: "" });
36 },
37 onError: (err) => {
38 setSearchPerson([]);
39 reset({ name: "" });
40 setError("name", { message: err.message });
41 }
42 }
43 );
44 }
45
46 setIsOpenPeopleInput(!isOpenPeopleInput);
47 reset({ name: "" });
48 setSearchPersonName("");
49 setSearchPerson([]);
50 });
51
52 const onSubmitUpdateGroupName = handleSubmit((data) => {
53 const id = item._id;
54 const body = {
55 _id: id,
56 name: data.groupName,
57 type: data.type,
58 place: data.place
59 };
60 if (id) {
61 updateGroup({ body });
62 reset();
63 setIsUpdate(false);
64 }
65 });
66
67 const selectItem = (person: People) => {
68 const hasHuman = item.humanIds.some((value) => value === person._id);
69 if (hasHuman) {
70 setSearchingBoxError(true);
71 return;
72 }
73 addNewPeople({ id: item._id, body: { _id: person._id } });
74 reset({ name: "" });
75 };
76
77 useEffect(() => {
78 const id = item._id;
79 if (isConfirm && id) {
80 deleteGroupMutate(id);
81 setIsConfirm(false);
82 setIsModal(false);
83 }
84 }, [isConfirm]);
85
86 return (
87 <>
88 {isModal && (
89 <ConfirmDeleteModal
90 setIsModal={setIsModal}
91 setIsConfirm={setIsConfirm}
92 title="그룹을 삭제하시겠습니까?"
93 subtitle="그룹을 삭제하면 복구할 수 없습니다. 참가자는 그대로 남습니다."
94 />
95 )}
96 </>
97 );
98};
99
100export default Group;
마무리
관심사의 분리는 그나마 나중에 코드를 돌아보고 과거의 나에게 욕을 덜하게 하는 좋은 도구인 것 같다. 이번에는 목표가 비동기와 뷰를 분리하는 것이었다. 그것을 개선한 것 만으로도 코드를 읽으면서 마음이 편안해진다. 무엇을 분리하고 무엇을 통합해서 반복을 줄여나가고 우아한 코드를 작성할지는 계속 고민하고 연습해야할 것 같다. 원티드 프리온보딩 코스가 그런 의미에서 많은 도움이 됐던 것 같다.
이번에 리펙토링을 하면서 과제가 하나 더 생겼다. 스타일 컴포넌트를 쓰면서 스타일 코드와 뷰가 뒤섞여있어서 수직으로 스크롤을 왔다 갔다 하는게 만만치 않다. 어떻게 분리해야할지 아직 감은 안잡히지만 배웠던 것을 바탕으로 개선을 해봐야겠다.(성능 개선도 해야하는데 ㅜㅠ)
이번 기회에 코드를 돌아볼 수 있어서 다행이었다. 운이 좋았다. 내가 코드를 작성하면서 일단 동작을 하게 하는 것을 중요하게 생각했던 것 같다. 그리고 나중에 고쳐야지라고 생각했을지도 모른다. 팀 프로젝트를 하면서도 ‘일단 동작시키고 나중에 고쳐요'라는 말을 듣고 그때는 그냥 동의를 했었다. 하지만 지금은 아니다. 왜냐하면 팀 프로젝트 때 작성했던 코드를 지금 돌아보지 않기 때문이다. 동작하는 코드는 기본이다. 다른 사람이 나중에 봤을 때, 키보드 샷건 치는 코드는 작성하지 않는 것이 필요하다.
우테코 라이브에서 강사님이 건축 설계와 소프트웨어의 차이를 설명하시면서 소프트웨어는 설계를 철저하게 하지 않으면 미래의 개발 비용이 비약적으로 늘어날 수 있다는 이야기를 했었다. 클린 코드에서는 회사가 망한 사례도 이야기한다. 회사에 들어가서 내가 열심히 코드를 작성하는 것도 일이지만 미래의 개발 부채를 최소한으로 하기 위해 코드를 작성하는 개발자가 되고 싶다.