ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Portfolly] 포트폴리오 등록/수정 기능 만들기
    Studying/Proj 과정 2023. 7. 11. 10:45

    들어가면서...

    Portfolly 프로젝트에서 '포트폴리오 등록/수정 페이지'를 담당하게 되었습니다.

     

    기능을 구현 과정을 순서대로 기록합니다. 기능 구현을 다 마치고 코드의 잘못된 부분을 되짚어보며 작성하는 글이기 때문에, 아마 '뭐 저런 코드가 다 있담?'하고 생각할지언정, 코드에 정리되지 못한 부분이 많아 학습에 핵심적인 도움을 주는 포스트는 못될 것 같습니다!!

     

    (혹시나 react-quill 사용법 같은 거 기대하고 들어오실까봐..)

     

    사실 이 블로그 자체가 혼자 주절거리는 것뿐이지만😅

     

     

    사용 언어 및 기술

    • React
    • TypeScript
    • React-Quill

     

     

    포트폴리오 작성 페이지 UI

    포트폴리오 작성/수정 페이지 UI 화면

     

    제가 디자인/구현한 포트폴리오 작성 페이지입니다.

     

    보시다시피 티스토리 블로그와 매우 비슷한 형태입니다. 자주 보이는 UI 형식이 사용자에게도 간편할 것 같아서 골랐습니다. 

     

    첫 번째 사진이 에디터 화면입니다. 우측 하단에 체크 버튼을 클릭하면 2번째 사진처럼 팝업이 나타납니다. 저는 저 팝업을 TitleForm 이라고 명칭했습니다. TitleForm에는 제목/태그/소개글을 작성합니다.

     

    포트폴리오 5개 카테고리(웹/앱/일러스트/사진/비디오) 별 고정 태그가 있어서, 먼저 Seletor로 카테고리를 선택하면 하단에 그에 정해진 태그가 나타납니다. 원하는 태그를 클릭하면 태그가 등록됩니다.

     

     

    React-Quill 사용하기

    저는 React-Quill을 에디터 라이브러리로 채택했습니다.

     

    그 이유는 다운로드 수가 가장 많은데다가, 커스텀이 편리하고, 처음에는 ToastUI를 사용하려고 했는데 react 18버전에 대응하는 버전이 없어서 경고가 떴습니다.

     

    따라서 React-Quill 2.0.0 이상 버전으로 설치했습니다.

     

    일단 QuillEditor.tsx 전체 코드는 다음과 같습니다.

    const QuillEditor = memo(({ isTitleFormOpen }: Quill) => {
      const [imageUrlHandler, imageHandler] = useImageHandler();
      const savedPortfolio = useSelector(portfolio);
      const quillRef = useRef<ReactQuill>();
      const dispatch = useDispatch();
    
      const modules = useMemo(
        () => ({
          toolbar: {
            container: [
              [{ 'header': [1, 2, 3, 4, 5, 6, false] }],
              ["bold", "italic", "underline", "strike"],
              [{ color: [] }, { 'background': [] }],
              ['link'],
              ['image', 'video'],
              ['clean']
            ],
            handlers: {
              imageUrl: () => imageUrlHandler(quillRef.current?.getEditor()),
              image: () => imageHandler(quillRef.current?.getEditor()),
            },
          },
        }), []);
    
      return (
        <QuillWrapper isTitleFormOpen={isTitleFormOpen} >
          <ReactQuill
            ref={(element) => {
              if (element !== null) {
                quillRef.current = element;
                console.log(savedPortfolio.content)
              }
            }}
            value={savedPortfolio.content}
            onChange={(content) => dispatch(setHtmlContent(content))}
            modules={modules}
            theme="snow"
            style={{ height: '100%', marginBottom: '0' }}
            placeholder={'Write Something...'}
          />
        </QuillWrapper>
      )
    })
    
    export default QuillEditor;

     

     

    첫 번째 줄부터 잘못 작성한 부분이 보이는데요, 바로 불필요한 React.memo()를 사용했습니다.

     

    const QuillEditor = memo(({ isTitleFormOpen }: Quill) => {

     

     

    React.memo()란, 동일한 props에 대해 동일한 컴포넌트 결과값을 렌더링하게 되었을 때, 이전 결과값을 저장해두었다가 다시 꺼내와 사용하는 기능입니다.

     

    props가 변하지 않았는데도 자주 리렌더링하게 될 때(부모의 리렌더링 때문에 자식까지 되는 경우) 사용하면 좋은 기능입니다. 물론 이 마저도 해당 컴포넌트를 다시 생성하는 비용이 클 때 해당입니다. 가벼운 로직에 사용하는 건 메모리 낭비입니다.

     

    더 자세한 React.memo()에 대한 정보는 아래 블로그가 가장 간단하고 친절하게 정리되어 있습니다.

     

    [React] 알고 쓰면 피가 되고 살이 되는 React.memo, 그리고 Key와의 관계?

    3월 이맘때쯤, 반복되는 요소들을 출력할 때 React.memo를 사용하면 보다 효율적으로 렌더링할 수 있다는 이야기를 듣고 여러 가지로 테스트를 해본 적이 있습니다. 단순히 React.memo를 사용하는 것

    ssocoit.tistory.com

     

    저의 경우 props로 isTitleFormOpen 대신 htmlContent, setHtmlContent를 받았습니다.

     

    isTitleForm은 TitleForm 컴포넌트를 열었냐, 닫았냐 하는 boolean 값인데 PortfolioEdit.tsx 페이지 컴포넌트에서 관리합니다. htmlContent, setHtmlContent 또한 PortfolioEdit.tsx 페이지 컴포넌트에서 관리하는 '에디터 작성 글' 상태 값입니다.

     

    htmlContent 값이 그대로인데, TitleForm을 많이 열었다가 닫는 바람에 계속 리렌더링이 될까봐 메모이제이션을 했었습니다. 그런데 그 후, TitleForm이 열리면 에디터 작성을 금지시키기 위해 isTitleForm을 props로 받는 형식으로 바꿔놓고 의미 없는 memo를 지우는 걸 깜빡했습니다. 게다가 형식이 바뀐 걸 제외하고도 그 어떤 사용자가 TitleForm을 미친듯이 열고 닫을지도 모를 일이고요. 결론적으로 그냥 지워야 합니다.

     

     

    그 다음은 ReactQuill 툴바 설정에 관한 모듈 객체입니다.

      const modules = useMemo(
        () => ({
          toolbar: {
            container: [
              [{ 'header': [1, 2, 3, 4, 5, 6, false] }],
              ["bold", "italic", "underline", "strike"],
              [{ color: [] }, { 'background': [] }],
              ['link'],
              ['image', 'video'],
              ['clean']
            ],
            handlers: {
              imageUrl: () => imageUrlHandler(quillRef.current?.getEditor()),
              image: () => imageHandler(quillRef.current?.getEditor()),
            },
          },
        }), []);

     

    이 객체는 useMemo()로 감싸주었는데, 이는 useMemo() 첫 번째 인자에 들어가는 콜백 함수 결과 값을 memo해놨다가, 두 번째 인자인 의존성 배열 안 값이 바뀔 때만 콜백 함수를 다시 계산하는 겁니다. 저의 경우 빈 배열이기 때문에 컴포넌트가 처음 마운트 되었을 때 결과 값을 저장하고 그 뒤로 쭉 저장한 결과 값을 사용합니다.

     

    이걸 적용한 이유는 에디터에 글자를 한 글자 한 글자 작성할 때마다 리렌더링되어 객체를 새로 생성해야하기 때문입니다. 사실 저 모듈 객체를 생성하는 데 드는 비용은 그리 크지 않겠지만, 아주 빈번하게 재생성되는 걸 생각하면 그 또한 낭비라고 생각했습니다.

     

    항상 메모이제이션의 기준이 궁금했는데, 아래 게시글을 보니 '메모리 낭비를 고려할 만큼 큰 값도 잘 없다!' 라는 걸 보고 대충 이정도면 메모이제이션 해야겠다, 하고 판단했습니다.

     

     

    Memoization을 사용해야하는 때는 언제인가요? - FrontOverflow

    **함수/값을 생성하는 비용** vs **함수/값을 유지하는 비용** 중에 어떤 걸 기준으로 Memoization 해야할까요?

    www.frontoverflow.com

     

    다음 내용을 계속해서,

     

    툴바에 그렇게 많은 기능을 담진 않았습니다. 포트폴리오 게시물은 주로 글보다는 이미지, 동영상 미디어가 주를 차지할 것이고, 글자 설정을 너무 다양하게 꾸미도록 설정해두면 게시물마다 통일감이 없어서 지저분해보일 것 같았습니다.

     

     

    그래서 기본적으로 위에서부터 글자 크기, 데코레이션, 색상, 링크, 이미지/비디오 업로드, 텍스트 초기화 버튼만 넣었습니다.

     

    그리고 특이한 점은 handlers Key 입니다.

     

    react-quill은 기본적으로 파일에 대해 base64 인코딩을 지원하는데, 이는 파일 길이가 너무 길어져서 DB에 저장할 수 없기 때문에 base64 인코딩 방식을 사용하지 않는 커스텀 이미지 핸들러를 따로 구현해야 합니다.

     

    그에 대한 방법은 아래 다른 게시물로 정리했습니다!

     

    [react-quill] 커스텀 이미지 핸들러 작성하기

    개요 Quill 에디터는 이미지를 첨부하면 base64 인코딩 형식 변환되어 저장됩니다. 이를 서버에 그대로 전송할 경우에는 자체 string이 너무 길어서 서버에 저장되지 않습니다. 따라서 Quill 에디터를

    all-done.tistory.com

     

    이제 최종적인 컴포넌트 결과 값입니다.

     

    return (
        <QuillWrapper isTitleFormOpen={isTitleFormOpen} >
          <ReactQuill
            ref={(element) => {
              if (element !== null) {
                quillRef.current = element;
                console.log(savedPortfolio.content)
              }
            }}
            value={savedPortfolio.content}
            onChange={(content) => dispatch(setHtmlContent(content))}
            modules={modules}
            theme="snow"
            style={{ height: '100%', marginBottom: '0' }}
            placeholder={'Write Something...'}
          />
        </QuillWrapper>
      )

     

     

    <ReactQuill /> 컴포넌트의 매개변수를 뜯어보겠습니다.

     

    가장 먼저 <ReactQuill /> 컴포넌트가 렌더링 됐을 때, quillRef.current 값을 저장해줍니다. quillRef 변수가 렌더링 전에 빈 값으로 생성되어 있다가, <ReactQuill /> 컴포넌트가 렌더링되어 current 값이 생겼을 때 이를 저장하는 겁니다. 저장된 객체는 이미지 핸들러에 사용됩니다.

    ref={(element) => {
      if (element !== null) {
        quillRef.current = element;
      }
    }}

     

     

    그리고 에디터에서 가장 중요한 value와 onChange 이벤트 핸들러입니다.

    value={savedPortfolio.content}
    onChange={(content) => dispatch(setHtmlContent(content))}

     

    위에서 말했듯 저는 에디터에 입력하는 글자 데이터를 전역 상태로 관리합니다.

     

    정확히는 포트폴리오 게시물에 포함되는 모든 내용을 portfolio 라는 전역 상태로 관리합니다. 그 이유는 더 아래에서 자세히 설명하겠습니다.

     

    당장은 이렇게 전역 상태를 useSelector()로 가져와서, value와 onChange에 넣어줍니다.

    import { useDispatch, useSelector } from 'react-redux';
    import { portfolio, setHtmlContent } from '@/store/portfolioSlice';
    
    const savedPortfolio = useSelector(portfolio); // portfolio 전역 상태

     

    결론적으로 에디터에 입력하는 글자가 바뀔 때마다 전역 상태에 dispatch 됩니다.

     

     

     

    Form 데이터 저장하기

    Form 데이터를 관리하는 방법은 한 차례 변경을 거쳤습니다.

     

    처음에는 react-hook-form으로 Form 데이터를 관리하려고 했습니다. 그런데 글 내용을 가지는 <PortfolioEdit /> 컴포넌트와 그 외 데이터를 가지는 <TitleForm/> 컴포넌트가 분리되어 있다보니 register, setValue, isError... 등등 props를 넘겨주는 코드가 너무 못생겨보였습니다.

     

    더하여 카테고리 Selector와 Tag 컴포넌트까지 별도로 분리되어 있으니 코드가 겉보기에 되게 별로였습니다.

     

    그래서 내린 결론은 portfolio 전역 상태를 만들어서 모든 Form 데이터를 저장하자. 입니다.

     

    redux-toolkit으로 전역 상태 관리

     portfolioSlice.tsx 코드는 대략 아래와 같습니다.

     

    const initialState: PortfolioSlice = {
      portfolio: INITIAL_PORTFOLIO,
      pictures: [],
    };
    
    const catagoryMapper: CategoryMapper = {
      웹: 'web',
      앱: 'app',
      '3D/애니메이션': '3danimation',
      그래픽디자인: 'graphicdesign',
      '사진/영상': 'photo',
    };
    
    const matchCategory = (category: string) => {
      return catagoryMapper[category];
    };
    
    const { reducer: portfolioReducer } = createSlice({
      name: 'portfolio',
      initialState,
      reducers: {},
      extraReducers: {
        SET_PORTFOLIO: (state, action) => {
          state.portfolio = { ...state.portfolio, ...action.portfolio };
        },
        SET_TITLE: (state, action) => {
          state.portfolio.title = action.title;
        },
        SET_HTMLCONTENT: (state, action) => {
          state.portfolio.content = action.content;
        },
        SET_CATEGORY: (state, action) => {
          state.portfolio.category = matchCategory(action.category);
        },
        SET_TAG: (state, action) => {
          if (action.tag.isSelected) state.portfolio.tags = [...state.portfolio.tags, action.tag];
          else state.portfolio.tags = state.portfolio.tags.filter((tag: Tag) => tag.name !== action.tag.name);
        },
        SET_EXPLAIN: (state, action) => {
          state.portfolio.explains = action.explain;
        },
        INITIALIZE_TAG: (state) => {
          state.portfolio.tags = [];
        },
        SET_PICTURES: (state, action) => {
          state.pictures = [...state.pictures, action.url];
        },
      },
    });
    
    export const portfolio = (state: RootState) => state.portfolioSlice.portfolio;
    export const pictures = (state: RootState) => state.portfolioSlice.pictures;
    
    export default portfolioReducer;

     

    이 코드에도 잘못된 점이 보이는데요, 바로 이 부분입니다.

     

    SET_PORTFOLIO: (state, action) => {
      state.portfolio = { ...state.portfolio, ...action.portfolio };
    },

     

    Redux-Toolkit은 Redux와 달리 Immer를 내장하고 있어서 불변성을 유지시켜주는데, 뭔가 습관적으로 저렇게 적은 것 같습니다😅...

     

    어찌됐든 저렇게 전역으로 관리했더니 setValue, register 등등 props로 넘겨줄 일이 없어 훨씬 수월하고 깔끔해졌습니다.

     

     

    만약 포트폴리오를 새롭게 작성하는 게 아니라 수정하는 거라면 기존 내용을 불러와야 합니다.

     

    따라서 아래와 같이 useEffect() 훅을 사용해 '포트폴리오 작성'인지, '포트폴리오 수정'인지 판단한 다음 portfolio 전역 상태를 세팅해줍니다.

     

    useEffect(() => {
        const isModified = portfolioId ? true : false;
        if (isModified) {
          getPortfolio(portfolioId)
            .then((res) => {
              console.log(res)
              dispatch(setPortfolio({
                id: portfolioId,
                title: res.data.title,
                content: res.data.content,
                category: res.data.category.name,
                tags: res.data.portfolioTags,
                explains: res.data.explains,
                createdAt: changeDateFormat(res.data.createdAt),
              }));
            })
        }
      }, []);

     

    그런데 이 코드도 별로입니다.

     

    isModified가 true일 때를 제외하곤 아무 로직이 없습니다. 그럴 땐 차라리 다음과 같이 early return을 해주는 게 훨씬 가독성이 좋고 의미가 명확해집니다.

     

    useEffect(() => {
        const isModified = portfolioId ? true : false;
        if (!isModified) return;
        
        getPortfolio(portfolioId)
        .then((res) => {
          console.log(res)
          dispatch(setPortfolio({
            id: portfolioId,
            title: res.data.title,
            content: res.data.content,
            category: res.data.category.name,
            tags: res.data.portfolioTags,
            explains: res.data.explains,
            createdAt: changeDateFormat(res.data.createdAt),
          }));
        })
      }, []);

     

     

    Form 데이터 POST하기

    드디어 포트폴리오 등록입니다. TitleForm은 다음과 같이 생겼습니다.

     

    제목, 카테고리, 태그(중복 선택 가능), 소개글을 입력 받고 우측 하단 체크 버튼을 클릭하면 등록됩니다. 좌측 화살표 버튼을 누르면 TitleForm이 닫힙니다.

     

     

     

    저는 onSubmit 함수랑 각종 유효성 검증 코드들이 TitleForm.tsx에 있으니까 너무 복잡하고,

     

    따지고 보면 TitleForm.tsx 라는 파일의 목적에서 벗어난 코드라고 판단해 useTitleForm.tsx 커스텀 훅을 만들어 Submit에 대한 코드를 분리했습니다.

     

    onSubmit 메서드는 다음과 같습니다.

    const submitPortfolio = async () => {
        const isModified = savedPortfolio.id ? true : false;
        const isValid = checkValidation(savedPortfolio);
        const submissionPortfolio = createSubmissionPortfolio(savedPortfolio);
    
        deleteImageUrls(savedPortfolio.content, savedPictures);
    
        if (!isValid) return;
    
        if (isModified) {
          await modifyPortfolio(submissionPortfolio, savedPortfolio.id as string);
          await navigate(`/portfolios/${savedPortfolio.id}`);
          dispatch(setPortfolio(INITIAL_PORTFOLIO));
          return;
        }
    
        await postPortfolio(submissionPortfolio);
        await navigate(`/main`);
        dispatch(setPortfolio(INITIAL_PORTFOLIO));
        return;
      };

     

    checkValidation 반환 값이 false면 유효성 검증을 통과하지 못했다는 뜻입니다.

     

    통과했으면 POST 요청을 보냅니다.

     

    만약 수정된 포트폴리오라면 해당 포트폴리오 상세보기 페이지로 이동하고,

    새 포트폴리오 등록이면 메인 페이지로 이동합니다.

     

    (지금 생각해보니 둘 다 상세보기 페이지로 이동하면 되지 왜 저렇게 달리 했는지 모르겠습니다)

     

     

    구현하면서 느낀점

    괴팍한 부분들이 건포도처럼 박혀있는 좀 더러운 코드였습니다.

     

    그래도 원래 저것보다 더 괴팍했는데 멘토님께 코드 리뷰를 받고 많이 개선된 거라는 게 신기합니다. (이 게시글에 기록하진 않았지만)  함수 1개당 행동 1개만 하도록 유틸 함수로 엄청 분리 시키고 각 유틸 함수들은 5 Lines Of Code 규칙을 최대한 지키도록 노력했습니다.

     

    부족하게 느껴지는 부분이라면, 유효성 검증을 checkValidation 라는 함수로 지저분하게 만들었다는 점 입니다. 다음에는 Zod 라이브러리가 많이 궁금하던데 그걸로 대체해보면 좋겠습니다.

Designed by Tistory.