Published on

애증의 먼지쌓인 사이드 프로젝트 완성시키기

Authors
  • avatar
    Name
    Younggyoung Lee
    Twitter

사이드

예전에 꽤나 호기롭게 시작했던 Resume.io 클론 프로젝트가 있었다. 이 정도면 아주 멋있는 프로젝트가 나오겠구만 하하 😈 했지만 겉으로 간단해보이는 만큼 기능 구현은 쉽지 않았다. 주변인들에게 도움도 받으면서 여러 기능을 완성했지만, PDF export 기능에서 벽에 부딪히고 말았다. 결국, 나의 레쥬메 빌더 프로젝트는 깃헙에 방치되어 먼지만 쌓여갔다.

FSecEffVgAAC_e5

하지만 애정을 쏟은만큼 이 프로젝트만은 꼭 완성시키고 싶다는 마음이 들었고 블로그를 통해 이 여정을 함께 나눠보고 싶었다. 아마 마음처럼 구현되지 않는 기능에 막혀서 포기해본 프로젝트들이 다들 있을 것이라고 생각한다. (저만 그런거 아니잖아요. 그렇다고 해줘요..🙄) 내 글이 누군가에겐 위로가 되길 바라면서 하나씩 시작해보자!

🎁 프로젝트 소개

프로젝트의 계기는 간단했다. 외국인 친구가 Resume.io를 사용해서 이력서를 정말 깔끔하고 이쁘게 만드는 걸 보고 나도 헉! 저거다! 싶어서 사용해볼려고 했다. 그런데 이게 웬 걸! 한국어 지원을 안해서 한국어로 입력 자체가 아예 불가능했다. 😭 당황스럽기도 하고 아쉬웠다. 아름다운 UX/UI에 반해, '내가 직접 만들어보자!'라는 생각이 번뜩 들었다. 그래서 클론 프로젝트를 시작했다.

🌲 디렉터리 구조

지금까지 빌드한 앱의 디렉토리 구조는 다음과 같다.

.
├── dist // 빌드된 파일 디렉토리
├── public // 정적 파일 디렉토리
└── src/ // 소스 코드 메인 디렉토리
    ├── components/ // UI 컴포넌트 디렉토리
    │   │
    │   ├── EditorView // 에디터 뷰 컴포넌트 디렉토리
    │   │
    │   ├── hooks // 전역 커스텀 훅 디렉토리
    │   │
    │   ├── PagesView/ // 페이지 뷰 컴포넌트 디렉토리
    │   │   ├── content // 페이지 뷰 관련 컨텐츠 컴포넌트 디렉토리
    │   │   ├── footer // 페이지 뷰 관련 푸터 컴포넌트 디렉토리
    │   │   ├── header // 페이지 뷰 관련 헤더 컴포넌트 디렉토리
    │   │   └── hooks // 페이지 뷰 관련 커스텀 훅 디렉토리
    │   │
    │   └── UI/ // 전역 UI 컴포넌트 디렉토리
    │       ├── Buttons // 버튼 컴포넌트 디렉토리
    │       ├── DatePicker // 데이트 픽커 컴포넌트 디렉토리
    │       ├── Inputs // 인풋 컴포넌트 디렉토리
    │       └── RichTextEditor // 리치 텍스트 에디터(Draft.js) 컴포넌트 디렉토리
    └── state/ // 애플리케이션 상태 관리 디렉토리
        ├── action-creators // 액션 생성 함수 디렉토리
        ├── action-types // 액션 타입 디렉토리
        ├── actions // 액션 객체 디렉토리
        └── reducers // 리듀서 디렉토리

Resume Builder 깃허브 링크

Resume Builder 데모

🫥 현재의 주요 이슈들

  • 에디터에서 List 기능이 제대로 작동하지 않는다. (해결 난이도: 하)
  • 프로그래스 바가 뷰포트에 따라 다르게 보인다. (해결 난이도: 하)
  • 페이지 이동 혹은 블록이 접혔다가 펼쳤을 때, 데이터가 사라지고 저장되지 않는 문제가 있다 (해결 난이도: 중)
  • PDF 다운로드 기능이 구현되지 않았다. (해결 난이도: 상)

옵션 기능들

  • 다양한 디자인의 템플릿 선택기능
  • 링크를 통한 레쥬메 공유기능
  • DOCX 다운로드 기능
  • TXT 다운로드 기능

🔧 에디터에서 List 기능이 제대로 작동하지 않는다

Screenshot 2023-12-12 at 17 58 10

스크린샷에서 보이는 리스트 기능들(UL, OL)이 작동하지 않는다. 'Draft.js'라는 에디터 라이브러리를 사용했는데, 이 라이브러리는 좀 오래된 편이라 걱정이 있었다. 하지만 구현 과정에서는 별다른 문제가 없었다. 인라인 스타일링은 모두 잘 작동하고 있다. 그래서 리스트 기능이 인라인 기능이 아닌 것 같다는 생각이 들었다. 이 의문을 해결하기 위해 문서를 살펴보니, 'ordered-list-item'과 'unordered-list-item'이 모두 인라인이 아닌 콘텐트 블록으로 분류되어 있다는 것을 알게 되었다.1

결론: ULOL은 인라인이 아닌 콘텐트 블록이다.

아래와 같이 인라인 스타일링, 블록 스타일링을 따로 담당하게 함수를 나눠줬다.

RichTextEditor.tsx
...
const onInlineStyleClick = (style: string) => {
    const newState = RichUtils.toggleInlineStyle(editorState, style);
    setEditorState(newState);
    props.updateData(convertToRaw(newState.getCurrentContent()));
  };

const onBlockStyleClick = (blockType: string) => {
    const newState = RichUtils.toggleBlockType(editorState, blockType);
    setEditorState(newState);
    props.updateData(convertToRaw(newState.getCurrentContent()));
  };
...

리스트

음 매우 잘된다! 👍

🫧 리팩토링을 해보자!

🧼 가독성을 위해 인라인 svg 태그 컴포넌트로 변환하기

인라인 svg 태그를 복사 + 붙여넣기로 바로 사용했는데 가독성이 매우 떨어졌다. svg 파일들을 모두 다운받고 img태그를 사용해서 src속성에 path를 추가하는 방법도 있지만, RichTextEditor 파일 내에서만 사용하는 일회성 아이콘들이라 고민이 많았다. 그래서 아이콘들을 컴포넌트화하되, 파일 내에서 분리하기로 했다. 2

RichTextEditor.tsx
	...
  		<button onClick={() => toggleInlineStyle('BOLD')}>
          <svg
            width="24"
            height="24"
            viewBox="0 0 24 24"
            version="1.1"
            xmlns="http://www.w3.org/2000/svg"
          >
            <path d="M8,17 L8,7 L11.7707948,7 C13.0770244,7 14.0677723,7.23236947 14.7430684,7.69711538 C15.4183644,8.1618613 15.7560074,8.84294423 15.7560074,9.74038462 C15.7560074,10.2303138 15.6204573,10.6618571 15.349353,11.0350275 C15.0782488,11.4081978 14.7011731,11.6817757 14.2181146,11.8557692 C14.7701814,11.983975 15.205174,12.242672 15.5231054,12.6318681 C15.8410367,13.0210642 16,13.4972499 16,14.0604396 C16,15.0219828 15.6697507,15.7499975 15.0092421,16.2445055 C14.3487336,16.7390135 13.4072766,16.9908424 12.1848429,17 L8,17 Z M10.2181146,12.6456044 L10.2181146,15.3447802 L12.1182994,15.3447802 C12.6407913,15.3447802 13.0486738,15.2291678 13.3419593,14.9979396 C13.6352448,14.7667113 13.7818854,14.4473464 13.7818854,14.0398352 C13.7818854,13.1240797 13.2717241,12.6593407 12.2513863,12.6456044 L10.2181146,12.6456044 Z M10.2181146,11.1895604 L11.8595194,11.1895604 C12.9784406,11.1712453 13.5378928,10.7568722 13.5378928,9.94642857 C13.5378928,9.4931296 13.3961813,9.1668966 13.1127542,8.96771978 C12.829327,8.76854296 12.3820117,8.66895604 11.7707948,8.66895604 L10.2181146,8.66895604 L10.2181146,11.1895604 Z"></path>
          </svg>
        </button>
	...
{/* 계속 코드 반복 */}

하지만 파일 내에서 컴포넌트로 분리하는 대신 RichTextEditor 함수 밖으로 빼서 아이콘 컴포넌트들의 불필요한 리렌더링을 막았다.

RichTextEditor.tsx
	...
		const BoldIcon = () => (
		<svg
			width="24"
			height="24"
			viewBox="0 0 24 24"
			version="1.1"
			xmlns="http://www.w3.org/2000/svg"
		>
			<path d="M8,17 L8,7 L11.7707948,7 C13.0770244,7 14.0677723,7.23236947 14.7430684,7.69711538 C15.4183644,8.1618613 15.7560074,8.84294423 15.7560074,9.74038462 C15.7560074,10.2303138 15.6204573,10.6618571 15.349353,11.0350275 C15.0782488,11.4081978 14.7011731,11.6817757 14.2181146,11.8557692 C14.7701814,11.983975 15.205174,12.242672 15.5231054,12.6318681 C15.8410367,13.0210642 16,13.4972499 16,14.0604396 C16,15.0219828 15.6697507,15.7499975 15.0092421,16.2445055 C14.3487336,16.7390135 13.4072766,16.9908424 12.1848429,17 L8,17 Z M10.2181146,12.6456044 L10.2181146,15.3447802 L12.1182994,15.3447802 C12.6407913,15.3447802 13.0486738,15.2291678 13.3419593,14.9979396 C13.6352448,14.7667113 13.7818854,14.4473464 13.7818854,14.0398352 C13.7818854,13.1240797 13.2717241,12.6593407 12.2513863,12.6456044 L10.2181146,12.6456044 Z M10.2181146,11.1895604 L11.8595194,11.1895604 C12.9784406,11.1712453 13.5378928,10.7568722 13.5378928,9.94642857 C13.5378928,9.4931296 13.3961813,9.1668966 13.1127542,8.96771978 C12.829327,8.76854296 12.3820117,8.66895604 11.7707948,8.66895604 L10.2181146,8.66895604 L10.2181146,11.1895604 Z"></path>
		</svg>
		);

{/* 계속 코드 반복 */}

function RichTextEditor(props: Props) {
  return (
        <button onClick={() => toggleInlineStyle('BOLD')}>
          <BoldIcon />
        </button>
  )
}

🧼 콘텐트 타입 함수 변수명 변경하기

RichTextEditor.tsx
...
const onInlineStyleClick = (style: string) => { ... };

const onBlockStyleClick = (blockType: string) => { ... };
...

이전 함수명은 on<타입>StyleClick 으로 주었다. 하지만 Draft.js 공식 문서에서도 블록 컨텐트에 관해서는 스타일링이라는 표현은 사용하지 않고 있다. 오히려 타입이라는 단어를 많이 쓰기에 함수명을 아래와 같이 바꾸기로 결심했다.

RichTextEditor.tsx
...
const toggleInlineStyle = (style: string) => { ... };

const toggleBlockType = (blockType: string) => { ... };
...

일단 토글을 하면 상태가 변한다는 것을 참고하여 toggle로 함수명을 시작했다. 인라인 텍스트에는 스타일이란 표현을 블록 컨텐트에는 타입이란 표현을 사용했다.

🧼 에디터 상태변경 함수 로직 분리하기

RichTextEditor.tsx
...
const onInlineStyleClick = (style: string) => {
    const newState = RichUtils.toggleInlineStyle(editorState, style);
    setEditorState(newState);
    props.updateData(convertToRaw(newState.getCurrentContent()));
  };

const onBlockStyleClick = (blockType: string) => {
    const newState = RichUtils.toggleBlockType(editorState, blockType);
    setEditorState(newState);
    props.updateData(convertToRaw(newState.getCurrentContent()));
  };
...

리팩토링 전에는 onInlineStyleClick과 onBlockStyleClick 함수가 에디터 상태 변경, 새 상태 설정, 데이터 업데이트 등 여러 작업을 수행했다. props.updateData를 호출하여 변경된 데이터를 업데이트하는 로직을 분리하여 따로 함수로 만들었다.

RichTextEditor.tsx
 const toggleInlineStyle = (style: string) => {
    setEditorState(RichUtils.toggleInlineStyle(editorState, style));
  };

  const toggleBlockType = (blockType: string) => {
    setEditorState(RichUtils.toggleBlockType(editorState, blockType));
  };

const handleEditorChange = (newEditorState: EditorState) => {
    setEditorState(newEditorState);
    props.updateData(convertToRaw(newEditorState.getCurrentContent()));
  };

리팩토링 후에는 toggleInlineStyle과 toggleBlockType 함수가 각각 인라인 스타일과 블록 타입 변경만을 담당하게 되었고, handleEditorChange 함수가 에디터 상태 변경과 데이터 업데이트를 처리한다. 코드가 더 간결하고 명확해졌으며, 각 함수의 역할이 분명해졌다.

🧼 접근성을 위한 aria-label 추가하기

RichTextEditor.tsx
       <button onClick={() => toggleInlineStyle('BOLD')} aria-label="bold">
          <BoldIcon />
        </button>
        <button onClick={() => toggleInlineStyle('ITALIC')} aria-label="italic">
          <ItalicIcon />
        </button>

aria-label은 태그가 가진 이름표 같은 것이다. 브라우저가 스크린 리더 사용자에게 전달해야 할 내용을 상황에 따라 적어야 한다. 3 마침 button 태그가 있으니 접근성을 위해 적어주기로 했다.

🔧 프로그래스 바가 뷰포트에 따라 다르게 보인다

프로그레스바

특정 뷰포트 이하에서 프로그레스바가 프리뷰가 있을때와 같은 사이즈를 유지했다. 오른쪽 두 사진처럼 프리뷰가 안보일 때도 프로그레스바가 절반만 보였다. 사실 스크롤바의 상태 값을 조정하다가 실수를 한 줄 알았다. 이전에는 이런 문제가 발생하지 않았던 것 같았다. (착각이었지만 🥹) EditorView.tsx 파일에서 상태 값을 건드리며 뻘짓을 오래했다. 하지만 특정 뷰포트 이하에서 오른쪽 프리뷰가 사라진다는 걸 보면서 CSS 문제일거란 의심이 들었다. 그래서 프리뷰가 사라지게 만드는 코드를 확인하고 CSS만 조정하면 해결될 것 같았다. 예상대로 문제는 테일윈드의 xl: 브레이크포인트 설정으로 인해 프리뷰가 사라지고 있었고, 상태값과는 무관하게 CSS로 쉽게 해결할 수 있었다.

EditorView.tsx
<section
      // NOTE: FLOATING HEADER
      // FIXME: 에디터가 풀 스크린일 때(프리뷰가 사라질 때), 플로팅 헤더가 없어지지 않는다.
       className={`${
          shouldFixHeader
          ? 'fixed top-0 w-[50%] px-[48px] left-0 pt-[20px] bg-white z-10'
          : ''
         }`}
        >
           <ProgressBar />
 </section>

이후 xl 브레이크 포인트랑 translate를 사용해서 아래와 같이 코드를 변경했고 정상적으로 작동했다.

EditorView.tsx
<section
    className={`${
        shouldFixHeader
        ? `fixed top-0 xl:w-[50%] xl:left-0 w-[760px] xl:translate-x-[0%] xl:px-[48px]  left-[50%] -translate-x-[50%] pt-[20px] bg-white z-10`
         : ''
     }`}
    >
    <ProgressBar />
</section>

🔧 페이지 이동 혹은 블록이 접혔다가 펼쳤을 때, 데이터가 사라지고 저장되지 않는 문제가 있다

🔧 PDF 다운로드 기능이 구현되지 않았다

Footnotes

  1. https://draftjs.org/docs/api-reference-content-block

  2. https://blog.logrocket.com/how-to-use-svgs-react/#using-svg-component

  3. https://velog.io/@a_in/WAI-ARIA-role-aria-label