방구석에 놔둔 개발 노트

1년차 웹 / 앱 프론트엔드 엔지니어의 좌충우돌 얼렁뚱땅 앞뒤짱구 생존기

2024-03-12: ‘준비물 챙겼어?’ 이야기 (4)

💭 프로젝트에 대한 고민

프로젝트의 3분의 1이 구현 완료 됐다.

구현하면서 너무 많은 것들이 바뀌었는데, 문서화를 거치면서 코드 컨벤션 맞추고 하는 변경 사항은 저번에 작성을 했었으니 이번엔 작업하면서 알게 된 사항이나 헤메던 것들을 기록하고자 한다.

현 상황까지 와서 자체 QA를 가볍게 돌려보니 전반적으로 고쳐야 할 것들이 잔뜩 나왔다.

살려줘.. 이걸 다 고쳐야 한다고?

게다가 Vite를 통해서 React CLI를 이용했지만, RESTful하게 맞추게 됐던 폴더 구조의 변화도 그렇고 Panda CSS가 Window에서 진행할 때 서버 컴포넌트에서 값을 제대로 반영해주지 못하는 이슈가 있었는데, 얼마 전 너굴의 숲 관련으로 빠르게 테스트해 본 결과 잘 반영되는 것을 봐서 사실 요즘의 고민은 React CLI을 계속 유지하는 게 좋을지라는 생각을 깊게 하고 있다.

폴더 구조 등 초기 생각을 다 엎어버린 사항들이 요 근래에 많이 있었고, 또 SEO 등을 생각하면 메타 데이터 반영 등을 위해서 Next.js로 가는 게 좋지 않을까라는 생각이 반복되었다.

아마 바꾸게 된다면 위의 이유를 토대로 바꾸게 될텐데, 누군가 고민에 대해서 답을 줬으면 좋겠다, 잉잉..

아무튼 늦었지만 이번 글도 스타~트~!

❓ Vite에서 svg 파일을 읽어오게 하려면?

CRA로 프로젝트를 진행했을 때에는 크게 불편함을 느끼지 못했는데, Vite를 이용해 생성한 React CLI 환경에서 svg 파일을 받아오려고 하니 다음과 같은 에러 사항으로 인해 파일을 읽어오지 못하는 이슈가 있었다.

'".svg"' 모듈에 내보낸 멤버 'ReactComponent'이(가) 없습니다. 대신 '".svg"에서 ReactComponent 가져오기'를 사용하시겠습니까?

해당 사항을 해결하기 위해서는 어떻게 해야하나 싶었는데, 구글링을 해보니 아니나 다를까 많은 선생님들의 시행착오가 보이지 않는가. 허허.

추적을 거듭한 결과, 아래의 글이 현 상황에서 가장 적합한 글이라 보면서 적용해봤다.

[React] vite + ts에서 vite-plugin-svgr로 svg 사용 설정

요약하자면, 아래와 같은 단계로 진행한다.

  1. 플러그인 패키지를 설치하자.
npm install --save-dev vite-plugin-svgr
  1. Vite가 참고할 환경을 정의하는 vite-env.d.ts 파일에 다음과 같은 내용을 추가하자.
/// <reference types="vite-plugin-svgr/client" />

  1. 그 다음, Vite 설정 파일인 vite.config.ts 파일에 플러그인을 추가해주자.
import svgr from 'vite-plugin-svgr'

export default defineConfig({
  plugins: [react(), vitePluginSvgr()], <- 패키지 import하고 넣어줄 것.
});

  1. 여기까지면 좋겠지만, 아직 더 할 게 남아있다. 다음은 타입스크립트 설정 파일에 들어가서 아래 내용을 추가해주자.
{
  "compilerOptions": {
        ...
    "types": ["vite-plugin-svgr/client"], <- 추가해주자
  },
  "include": ["svg.d.ts"], <- 추가해주자
}

  1. 마지막으로 svg 파일을 모듈로 불러와 사용할 수 있도록 모듈 정의를 해줘야한다. vite-env.d.ts 파일과 동일한 경로에 svg.d.ts 파일을 생성하고 다음 내용을 넣어 모듈을 정의해주자.
declare module '*.svg' {
  const content: React.FC<React.SVGProps<SVGElement>>
  export default content
}

여기까지 하면 이제 SVG 파일을 불러오면 Profit!

다만, 이 방식으로 할 경우 파일 경로 끝에 ?react를 반드시 붙여줘야 한다.

어미에 ?react붙여줘야 하는 이유에 대해서 좀 궁금했는데, 설명된 부분은 없어서 궁금하니까 나중에 issue로 질문해봐야겠다.🤔

이와는 별개로, Svg 파일을 public으로 받아오려고 하니 Vite 측에서 에러 메시지를 띄웠다. Vite에서는 svg 파일은 public이 아니라 src 경로에서 asserts 폴더 파서 거기서 관리하라고 안내했는데, images들은 모두 public에 관리하려고 했지만 역시 안 되나 보다..😂

결국 아래와 같이 svg만을 관리하기 위해 폴더구조를 분리했다.. 흑.

🔨 TypeScript의 type은 어떤 식으로 타입을 상속/확장할 수 있을까?

현재의 프로젝트는 타입의 선언으로서 interface가 아니라 type을 사용하고 있다.

interface도 좋지만, 재선언을 통한 상속/확장이 가능하다는 점에서 잘못 쓰면 혼란이 생길 거 같아서, type으로 모든 것을 관리하려고 한다. (inheritance가 상속이라는 것을 알고 있지만, 여기서는 확장이라는 단어로 통일해서 말하겠다.)

그런데, 현재까지 프로젝트를 진행하다 보니까 타입을 정의하는 데에 있어서 몇 가지 요소만 다를 뿐 같은 내용이 계속해서 들어가는 타입들이 있어서 이걸 줄일 수 있는 방법이 없을까 고민을 했다.

결과적으로 공통 요소를 하나의 타입으로 두고 이걸 확장해서 쓰는 게 좋겠다 싶어서, type에 대해서 확장할 수 있는 방법을 찾아봤더니 아래와 같은 글을 발견했다.

Documentation - Advanced Types

type의 확장은 & 연산자를 통해 이용할 수 있다!

그래서 바로 아래와 같이 적용해봤다.

export type TravelCaseType = "domestic" | "foreign";

export type TravelInfoType = {
  travelType: TravelCaseType;
  title: string;
  departureAt: string;
  travelPeriod: string;
  destination: string;
};

export type TravelListType = TravelInfoType & {
  id: string;
};

맨 아래의 TravelListTypeTravelInfoType을 상속받고 거기에 id를 추가하는 식으로 확장한 type이다.

이런 식으로 타입의 확장을 & 연산자를 통해 적용할 수 있으니 마음이 정말 편하다ㅠㅠ.

참고로 비슷한 방식으로 쓰이는 interfaceclass는 어떤 식으로 확장을 할까? 우선 두 가지의 공통점은 extends를 통해 타입을 상속/확장할 수 있다.

  • interface
interface ButtonInterface {
  readonly _type:string;
  onInit?():void;
  onClick():void;
}

// ButtonInterface를 확장하는 ToggleButtonInterface
interface ToggleButtonInterface extends ButtonInterface {
  toggle():void;
  onToggled?():void;
}
  • class
class Animal {
  move(distanceInMeters: number = 0) {
    console.log(`Animal moved ${distanceInMeters}m.`);
  }
}
 
class Dog extends Animal {
  bark() {
    console.log("Woof! Woof!");
  }
}

interface는 여기서 추가적으로 다른 방식으로 확장이 또 가능한데, 바로 재선언을 통해 확장할 수 있다는 것이다. 개인적으로는 이 점 때문에 프로젝트를 진행할 때 interface를 사용하지 않도록 정의했다.

interface Ezreal = {
  health:number
};

interface Ezreal = { 
  damage:number
};

아울러, interface는 다른 타입인 class를 상속 받는 것도 가능하다.

class Person {
  constructor(public name: string, public age: number) {}
}

interface Developer extends Person {
  skills: string[];
}

이렇게 세 가지 타입의 확장 방법에 대해서 알아봤다. 추후에 interfaceclass를 쓸 일이 있다면 위의 사항을 참조해서 유연하게 사용해봐야지!

🙅 step 절차 구현은 useState가 아니라 쿼리스트링으로!

이전에 여행 정보 생성 페이지에서 작업하고 있던 중에 담(@herb1401) 님 께서 내 코드를 보더니 깐족거리는 말투로(다시 생각해도 킹받네…💢) 다음과 같이 말했다.

에엥~~? state로 단계를 변경한다고요? 왜요?

나로서는 이런 식으로 하는 것 밖에 몰랐기 때문에 현재까지 다음과 같은 방식으로 진행했다.

  • 상위 페이지 컴포넌트에서 useState를 선언하고, 이를 하위 컴포넌트에서 setState를 props drilling하는 방식으로 단계가 변경하도록 처리
  • 하위 컴포넌트가 여러 개라면 atom 등을 이용해서 전역으로 단계를 관리

담 님이 이 이야기를 듣고 이마를 치더니, 그렇게 하지 말고 쿼리 스트링으로 단계를 처리하면 되지 않냐고 물었다.

확실히 생각해보니까 쿼리스트링을 이용하면 useState 훅을 쓸 필요 없이 렌더링을 최소화할 수도 있을 뿐더러, 무엇보다 url에 변경이 생겨 히스토리가 남으니 페이지 이전/다음 처리 같은 것도 문제되지가 않는다..

좋은 아이디어는 바로 채택이니까 바로 아래와 같이 컴포넌트 단계를 넘기는 걸 구현해봤다. 우선 상위 컴포넌트인 아래의 ListsCreate 코드를 보자.

export default function ListsCreate() {
  const [searchParams] = useSearchParams();
  const currentStep = Number(searchParams.get("step")) - 1;
  const componentLists = [
    <SelectTravelSection />,
    <TravelInfoForm />,
    <ConfirmTravelInfoSection />,
    <SelectUseTemplateSection />,
  ];

  return (
    <StCreateListPage.Section>
      <Stepper />
      {componentLists[currentStep]}
    </StCreateListPage.Section>
  );
}

return 안의 Stepper 컴포넌트 아래를 보면 변수 componentsLists의 배열 속에 있는 컴포넌트들을 currentStep의 값에 맞게 배치되고 있는 것을 볼 수 있다.

여기서 currentStepuseSearchParams 훅을 이용해 받아오는 쿼리 스트링 중에 step이라는 값을 받아와 적용하고 있다.

그렇다면, 쿼리 스트링으로 step이라는 값을 어떻게 적용할 수 있는 걸까? 아래는 특정 컴포넌트에서 다음 스텝으로 넘기려는 함수의 내용이다.

export default function useConfirmTravelInfoSection() {
  const [searchParams, setSearchParams] = useSearchParams();

  const moveToNextStep = () => {
    searchParams.set("step", "4");
    return setSearchParams(searchParams);
  };

  return { moveToNextStep };
}

이런 식으로 useSearchParams 훅에서 searchParams를 이용해서 .set() 메소드를 통해 배치할 쿼리 스트링을 적용해준 뒤, setSearchParams를 통해 searchParams을 적용해주면 쿼리 스트링이 url에 적용된다.

참고로 특정 쿼리 스트링을 가져오고 싶다면 위에서 쓰던 .get()을, 모든 쿼리스트링 값을 가져오고 싶다면 .getAll()을, 특정 쿼리스트링을 삭제하고 싶다면 .delete() 메소드를 이용해 적용하면 된다.

📦 Firestore에서 doc와 collection을 이용해 하위 컬렉션을 만드는 방법

유저가 여행 정보를 등록할 때 여행의 기본 정보만을 담은 카드와 아이템 리스트를 담은 리스트 두 개의 데이터가 생성되도록 하고 싶었다.

특히 카드는 카드만을 불러와서 메인 화면에서도 배치되고 다양하게 쓰일 수 있으므로, 이걸 고려해서 분리한건데, 그러다보니까 컬렉션 두 개를 생성할 필요가 있었고, 이걸 어떻게 넣어야할지 고민이었는데 예전 프로젝트 코드를 찾아보니 구현했던 내용이 있어서 그걸 보고 처리했다.

잘 했다, 내 자신!

const userUid = firebaseAuth.currentUser?.uid;
const currentTime = `${new Date().getTime()}`;

const travelsReference = doc(
  collection(firestore, `travels`, userUid, "docs"),
  currentTime
);
const ListsReference = doc(
  collection(firestore, `lists`, userUid, "docs"),
  currentTime
);
  
// collection(db, 상위 컬렉션, 문서ID, 하위 컬렉션)
// doc(컬렉션, 문서ID)
// => doc(collection(db, 상위 컬렉션, 문서ID, 하위 컬렉션), 문서ID)

collection 모듈을 이용해서 참조할 컬렉션을 잡아놓고, 문서 ID를 추가해서 doc 모듈을 통해 특정 문서를 참조하도록 지정할 수 있다.

이렇게 하면, 추후에 문서를 새로 쓸 때, 상위 컬렉션부터 하위 컬렉션까지 한꺼번에 생성되기 때문에 여러 컬렉션과 문서를 만들 필요가 있다면 이런 식으로 응용하면 되겠다.

✏️ writeBatch를 사용하면 한꺼번에 쓰기 처리가 가능하다!

기존에 프로젝트 했을 때 문서를 여러 개 추가해야하면 setDoc를 여러개 돌리는 식으로 했었는데, 이건 아무리 생각해도 쓰기를 여러번 하는 거기 때문에 솔직히 좋다고 생각되지 않아서 다른 방식이 있을까 검색을 해봤는데 firestore에서 제공하는 기능 중에 writeBatch가 있다는 걸 알게 됐다.

트랜잭션 및 일괄 쓰기  |  Firestore  |  Firebase

상단의 링크를 참고해서 아까 참조하고 있는 문서에 넣을 내용을 한꺼번에 보내기 위해 아래와 같이 코드를 작성했다.

const batch = writeBatch(firestore);

if (!userUid) {
  throw new Error("유저 정보가 없습니다. 데이터를 생성할 수 없습니다.");
}

const travelsReference = doc(
  collection(firestore, `travels`, userUid, "docs"),
  currentTime
);

const ListsReference = doc(
  collection(firestore, `lists`, userUid, "docs"),
  currentTime
);

// 여행 컬렉션에 추가
await batch.set(travelsReference, {
  ...template,
  id: `${userUid}${currentTime}`,
});

// 리스트 컬렉션에 추가
await batch.set(ListsReference, {
  ...travelInfoData,
  id: `${userUid}${currentTime}`,
});

// commit으로 한꺼번에 보내준다.
const commitState = await batch.commit();

set() 메소드를 통해서 참조할 문서에 넣을 내용을 배치할 수 있다. 이후, 모든 작업을 완료하면 commit()을 통해서 한꺼번에 데이터를 배치할 수 있다.

set() 말고도 batch를 통해서 문서 내용을 갱신하는 update()나, 문서 내용을 지우는 delete()도 있으므로, 추후에 쓸 일이 생기면 필요한 사항에 맞게 적재적소로 활용해봐야겠다

🎨 input 인디케이터 색상을 바꿔야 한다면…?

여행 정보 입력 화면에서 여행 일자를 유저가 직접 입력하기보다는 달력을 통해서 값을 넣는 게 좋을 것 같아서, input type에 date를 추가하고 이를 이용해서 값을 받아오려고 했다.

그런데 커스텀해서 색상을 변경하려고 하니 placeholder 색상은 변하는데, 우측에 나오는 달력 아이콘의 색상이 변하지 않고 있는 것이 눈에 보였다.

잘 보면 보인다..

이 상황에서 어떻게 하면 저 달력 아이콘의 색상을 바꿀 수 있을까? 하고 찾아본 결과 아래의 stack overflow 글을 발견했다.

Change color of calendar icon in HTML Date Input

여기서는 CSS prefix인 ‘-webkit’이 붙여져있는데 크롬이나 사파리 등의 브라우저 엔진에서만 적용되는 사항인 듯 하다.

참고로 CSS prefix로 자주 쓰이는 케이스는 아래와 같다.

  • -webkit-: 크롬, 사파리 등
  • -mos-: 파이어폭스
  • -o-: 오페라

-ms-도 있는데, 이건 IE라서 현재로서는 신경쓰지 않아도 된다고 생각한다.

그는 다른 브라우저를 받기 위한 좋은 툴이었습니다.

아무튼, 위의 내용에 따라 달력 아이콘의 색상을 아래와 같이 적용하자 색이 변경된 것을 확인했다.

const TextInput = styled.input<StInputColorThemeType>`
  ...
  &::-webkit-calendar-picker-indicator {
    **filter: invert(0.79);**
  } 
`;

filterinvert 값을 부여함으로서 색상이 아래와 같이 변경되도록 적용했다.

invert는 색상을 반전시키는 css function인데, 기존 적용된 색상이 있다면 해당 색상을 얼마나 반전시킬 것인지를 비율을 통해 적용할 수 있다.

invert() - CSS: Cascading Style Sheets | MDN

혹시 달력 등 여러가지 아이콘 색을 변경해야 할 일이 생긴다면, 이런 방식으로 변경해보자.

참고로 위의 방법은 크롬 및 사파리에만 적용되는 사항이라 파이어폭스에서 보면…

이쪽도 추후에 대응해보도록 해야겠다..ㅠㅠ

[여담 - 오픈 소스에 처음으로 기여를 시도해봤다.]

input 아이콘을 수정하면서 위의 MDN 문서를 참고해 invert를 적용해봤는데 마침 읽은 문서 내용이 생각보다 많지 않아서, ‘혹시 이거라면 오픈 소스를 기여해볼 수 있지 않을까…?’ 라는 생각에 MDN 문서 번역을 시도해봤다.

[ko] 한국 첫 기여자들을 위한 가이드라인 · Issue #827 · mdn/translated-content

위의 내용과 같이, MDN 문서에 기여하고 싶은 사람들을 위해 가이드라인이 있어서 초반에 조금 헤멨던 거 빼고는 작업 자체는 어렵지 않았다. 도와줘서 고마워유, 라이언(@ryan_kim_dev) 님! 그렇게 레포지토리를 포크 떠서 문서를 가져와 번역한 뒤, 수정하여 커밋까지.. 완료!

그리고 위의 내용을 아래와 같이 현재 MDN 쪽에 PR을 올려서 리뷰 받는 걸 대기하고 있다.

[ko] modified filter-function invert markdown file by DrunkenNeoguri · Pull Request #18555 · mdn/translated-content

이게 뭐라고 이렇게까지 긴장되는 건지ㅋㅋㅋ 처음으로 해보는 오픈소스 기여라서 그런 걸까..👀 문제가 없다면 반영됐으면 좋겠다…!

📌 createPortal을 이용해서 최상단으로 띄울 컴포넌트를 붙여보자

createPortal은 React에서 제공해주는 API인데, 해당 API를 이용해 일부 JSX와 렌더링할 DOM 노드를 전달함으로서 DOM 노드의 물리적 배치를 변경해줄 수 있다.

기존에 Modal을 만들 때에는 해당 Modal을 배치했던 컴포넌트 내에 귀속하는 식으로 만들었는데, 이 createPortal을 이용하면 귀속되지 않고 최상단에 배치하게 된다.

API를 사용하기 위해 최상단에 올릴 컴포넌트가 어떤 게 있을까 살펴보니, Sidebar와 Modal이 딱 그 케이스가 될 것 같아, Portal이라는 컴포넌트를 추가한 후, Sidebar나 Modal 컴포넌트를 받아와서 적용시키도록 작성했다.

import { createPortal } from "react-dom";
import { PortalType } from "../types/portal";

export default function Portal(props: PortalType) {
  const { children, container } = props;

  return <>{createPortal(children, container)}</>;
}
export default function Header() {
  const { openState, openSideBar, closeSideBar } = useHeader();
  return (
    <>
      <StHeader.Header>
        <StHeader.Button>
          <CreateListIcon />
        </StHeader.Button>
        {/* <StHeader.Title>제목</StHeader.Title> */}
        <StHeader.Button onClick={() => openSideBar()}>
          <SideBarIcon />
        </StHeader.Button>
      </StHeader.Header>
      {openState && (
        <Portal
          children={<SideBar onClose={() => closeSideBar()} />}
          container={document.body}
        />
      )}
    </>
  );
}
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";

export default function useHeader() {
  const [openState, setOpenState] = useState(false);
  const navigate = useNavigate();

  const moveToCreateList = () => {
    return navigate("/lists/create?step=1");
  };

  const openSideBar = () => {
      // 사이드바가 열리면 스크롤이 작동하지 않도록 최상단 요소의 overflow를 hidden으로 변경
    document.getElementById("root")!.style.overflow = "hidden";
    return setOpenState(true);
  };

  const closeSideBar = () => {
      // 사이드바가 닫히면 다시 스크롤이 움직여지도록 overflow를 scroll으로 변경
    document.getElementById("root")!.style.overflow = "scroll";
    return setOpenState(false);
  };

  return {
    openState,
    openSideBar,
    closeSideBar,
  };
}

이제 저 Portal 컴포넌트를 활용해서 적재적소로 Sidebar나 Modal을 배치하기만 하면 끝!

다만, 모달을 여닫는 스위칭 역할은 useState로 해줘야할 듯 하니, 만들 때 까먹지 말자.

➡️ 다음은 뭘 할까?

아까도 말했지만 프로젝트가 목표하던 내용의 3분의 1이 끝났다.

여기까지 구현한 뒤, 자체 QA를 잠깐 해보니 수정하거나 반영해야 할 것들이 많았다. 우선은 이 수정 사항들을 먼저 반영하는 게 목표일 거 같고, 일단락이 되면 핵심 기능인 준비물 작성 페이지 구현을 진행하고자 한다.

준비물 작성 페이지는 프로젝트의 핵심인 만큼 이와 관련해서 아직도 많은 고민을 하고 있다.

  • 작성/수정 페이지와 목록 페이지를 분리할 것인지?
  • 여행 정보를 수정하고 싶으면 어디다 처리할 것인지?
  • 기존 기획대로 준비물을 여행 일정 기간마다 배치할 것인지, 아니면 단일로 관리할 것인지?
  • 완료 후 업데이트로 고려하고 있던 그룹 이용에 대해서는 어떻게 할 것인지?
  • 템플릿 공유 기능은 어떤 식으로 구현할 것인지?

위의 내용들이 피그마에 어느 정도 일단락 되어야 진행할 수 있을 것 같고.. 그리고 React CLI에서 Next.js로의 전환도 크게 고민하고 있고…. 이거와는 별개로 SNS에 올린 뒤, 다른 분들께서 주신 이력서 피드백을 참고해서 수정도 해야하고…… 취업 준비 때문에 서류도 계속해서 넣어야하는데——!

계획이 없어—!!

나루토 만큼은 아니어도 좋으니까 분신술 써서 나를 분리하고 싶다, 엉엉ㅠㅠ 아무튼 이번 글은 여기까지.

아참, 아래는 현재의 이력서인데 피드백도, 커피챗도 좋으니 관심 있으신 분이 계시다면 언제든지 연락주시면 감사하겠다.

채용 제안은 더욱 더 대.환.영.☆

웹 프론트엔드 개발자 - 신도윤 이력서

Ser deg!

🔖 참고 자료

[React] vite + ts에서 vite-plugin-svgr로 svg 사용 설정

createPortal – React

개발자 단민 | React에서 모달 한번 잘 만들어보자 (feat. createPortal & ref)

Change color of calendar icon in HTML Date Input

Handbook - Classes

PoiemaWeb

Change color of calendar icon in HTML Date Input

useSearchParams v6.22.2