Vite + TS + React + Redux + tanstack-query + styled-components 셋팅 & 사용하기 (삽질의 여정)

Sheryl Yun·2023년 4월 1일
3

최근에 Vite를 쓰고 있는데 뭔가 스택을 추가할 때 초기 설정이 CRA와 다른 점을 제외하곤 너무 만족스럽게 잘 쓰고 있어서 계속 연습해보고 있다. 수없이 업데이트되는 브라우저 화면을 확인하기, 터미널을 새로 켜기에 이제 더 이상 CRA는 쓸 수 없게 되었다.. 모든 게 대충 10초 내외로 해결됨

이번 조합에서의 의외의 복병은 Vite뿐만 아니라 Typescript였다. React + TS + SCSS 조합은 너무 많이 써서 간단할 줄 알았지만 그건 CRA였을 때 얘기였고 특히 styled-components를 너무 오랜만에 써서 셋팅이 쉽지 않았다 ㅠㅠ 이후 다른 스택들을 추가할 때도 눈물겨운 삽질(?)을 안겨다주었다. 여정을 기록해본다.

Vite + styled-components 셋팅 중 GlobalStyles 에러 해결

에러 메시지를 따로 캡쳐해두지 못했는데 JSX~ 어쩌구 해서 처음에는 태그 관련 에러인 줄 알았다. 여차저차 헤메다가 아래와 같이 셋팅해서 해결

// styles/GlobalStyle.tsx
import { createGlobalStyle } from 'styled-components';

const GlobalStyles = createGlobalStyle({
  body: { boxSizing: 'border-box' },
});

export default GlobalStyles;
// App.tsx
import styled from 'styled-components';
import GlobalStyles from 'styles/GlobalStyle';

function App() {
  return (
    <Wrap>
      <GlobalStyles /> /* 여기서 계속 에러 발생 */
    </Wrap>
  );
}

export default App;

const Wrap = styled.div`
  width: 100%;
  height: 100vh;
`;

정확히 왜 해결되었는지는 모르고 그냥 절대 경로와 같이 셋팅하던 중에 어떻게 했더니 해결되었다.

다음은 예측되는 에러 해결 원인이다.

예측되는 해결 원인

  • package.json에서 scripts 위쪽에서 name와 version만 남기고 삭제 (총 2줄 지움)
  • tsconfig.json에서 "useDefineForClassFields": true 라인 삭제

정확한 이유는 잘 모르겠다. 하지만 해결되었을 때 소화제를 먹은 듯한 시원함

// tsconfig.json
{
  "compilerOptions": {
    "baseUrl": "src",
    "target": "ESNext",
    "lib": ["DOM", "DOM.Iterable", "ESNext"],
    "allowJs": false,
    "skipLibCheck": true,
    "esModuleInterop": false,
    "allowSyntheticDefaultImports": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "module": "ESNext",
    "moduleResolution": "Node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "jsx": "react-jsx"
  },
  "include": ["./src"]
}

Vite + TS 절대경로 설정하기

  • package.json에 baseUrl: 'src'을 설정한다.
  • vite-tsconfig-paths 설치 후 vite.config.ts를 다음과 같이 설정한다.
import tsconfigPaths from 'vite-tsconfig-paths';
...
plugins: [tsconfigPaths()],

vite-tsconfig-pathstsconfigPaths는 Vite + TS에서 절대경로 설정과 관련된 모듈이다.

Vite 저장 시 자동 화면 업데이트

Vite는 CRA와 달리 git도 설치 안 되어 있고(git init 해줘야 remote origin 추가 가능), 저장 시 브라우저 화면이 자동 새로고침 되는 기능도 탑재되어 있지 않았다. 그래서 다음처럼 추가 모듈 설치가 필요.

@vitejs/plugin-react-refresh 설치 후에 vite.config.ts에 다음을 추가한다.

import reactRefresh from '@vitejs/plugin-react-refresh';
...
plugins: [..., reactRefresh()],

위의 두 모듈을 설치하고 나면 vite.config.ts의 최종 모습은 다음과 같이 된다.

import { defineConfig } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths';
import reactRefresh from '@vitejs/plugin-react-refresh';

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [tsconfigPaths(), reactRefresh()],
});

createBrowserRouter로 라우터 설정

원티드 3월 챌린지 실습 때 배웠던 코드를 써먹어보았다. 객체지향형(?) 코드라는데 이 프로젝트에는 페이지 수가 너무 적어서 멋있어 보이기 실패

import styled from 'styled-components';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import { Home } from 'pages/Home';
import { Error404 } from 'pages/Error404';
import GlobalStyles from 'styles/GlobalStyle';

function App() {
  const router = createBrowserRouter([
    {
      path: '/',
      element: <Home />,
      /* 여기에 admin이나 auth라는 커스텀 속성을 추가해서 어떤 상황에 어떤 페이지만 렌더링할 것인지를 정할 수 있다 */
    },
    {
      path: '/error',
      element: <Error404 />,
    },
  ]);

  return (
    <Wrapper>
      <GlobalStyles />
      <RouterProvider router={router} />
    </Wrapper>
  );
}

기존에 쓰던 path와 element만 있던 형태와는 달리, createBrowserRouter로 만든 router 객체를 RouterProvider에 prop으로 전달했다.

Link 컴포넌트 안에 img 태그 넣기

오랜만에 Link 컴포넌트를 썼더니 안에 img를 넣는지 겉에 div로 감싸는지 헷갈려서 찾아보다가 Link와 a(anchor) 태그를 둘 다 소개한 매우 모범적인 자료를 발견했다.

참고 자료: How to Use an Image as a Link in React.js

import { Link } from 'react-router-dom';

export default function App() {
  return (
    <div>
      {/* 👇️ react router link */}
      <Link to="/about">
        <img
          src="https://bobbyhadz.com/images/blog/react-usestate-dynamic-key/thumbnail.webp"
          alt="example"
        />
      </Link>

      <br />
      <br />

      {/* 👇️ Anchor link */}
      <a href="https://google.com" target="_blank" rel="noreferrer">
        <img
          src="https://bobbyhadz.com/images/blog/react-prevent-page-refresh-on-form-submit/thumbnail.webp"
          alt="example"
        />
      </a>
    </div>
  );
}

그냥 둘 다 안에 img 태그를 넣어주면 된다. div나 button은 음.. 겉에 감싸나? ㅎㅎ

ThemeProvider와 theme.ts 셋팅

theme.ts는 항상 쓸 때마다 뭘 넣으면 좋을 고민된다. Figma가 있으면 Component 탭을 참고해서 설정하는데.. 이번에는 theme.ts을 다음과 같이 썼다.

// styles/theme.ts
import { DefaultTheme } from 'styled-components';

const colors = {
  primary: '#7879F1',
  secondary: '#0D9991',
  secondaryLight: '#9ED6D3',
  warn: 'rgba(255, 0, 0, 0.65)',
  black: '#000000',
  white: '#ffffff',
};

/* font-size, margin, width 등 여러 군데에 썼다. */
const sizes = {
  size16: '16px',
  size18: '18px',
  size20: '20px',
  size24: '24px',
  size28: '28px',
  size32: '32px',
  size40: '40px',
  size48: '48px',
  size60: '60px',
  size200: '200px',
  full: '100%',
};

const fontWeight = {
  regular: 400,
  bold: 700,
};

const flex = {
  base: `display: flex`,
  row: `
    display: flex;
    justify-content: center;
    align-items: center;
  `,
  column: `
    display: flex;
    flex-direction: column;
    justify-content: center;
    align-items: center;
  `,
  spaceBetween: `
    display: flex;
    justify-content: space-between;
    align-items: center;
  `,
};

export const cursors = {
  pointer: 'cursor: pointer',
};

export type ColorsType = typeof colors;
export type FontSizeType = typeof fonts;
export type FontWeightType = typeof fontWeight;
export type SizeType = typeof sizes;
export type FlexType = typeof flex;
export type CursorType = typeof cursors;

export const theme: DefaultTheme = {
  colors,
  fontWeight,
  sizes,
  flex,
  cursors,
};

타입스크립트여서 아래의 코드가 추가되었다.

import { DefaultTheme } from 'styled-components';

...
export type ColorsType = typeof colors;
export type FontSizeType = typeof fonts;
export type FontWeightType = typeof fontWeight;
export type SizeType = typeof sizes;
export type FlexType = typeof flex;
export type CursorType = typeof cursors;

export const theme: DefaultTheme = {
  colors,
  fonts,
  fontWeight,
  sizes,
  flex,
  cursors,
};

이후 ~.d.ts 파일에 타입을 등록해준다. (.d.ts 앞에 오는 파일 이름은 자유)

// styles/styled.d.ts

import 'styled-components';
import {
  ColorsType,
  CursorType,
  FlexType,
  FontWeightType,
  SizeType,
} from './theme';

declare module 'styled-components' {
  export interface DefaultTheme {
    colors: ColorsType;
    fontWeight: FontWeightType;
    sizes: SizeType;
    flex: FlexType;
    cursors: CursorType;
  }
}

모듈로 전부 export 해줘야 함
이런 거 모아놓은(자동으로 해주는) TS용 프레임워크 없나😂

이후 반복되는 코드들을 theme에 있는 코드로 교체해주었다. 원래 미리 설정해두고 갖다 쓰는 게 맞는 순서인데 이번에는 뭐가 들어갈지 몰라서 이렇게 했다.


위의 빨간 코드를 아래 녹색 코드로 대체한 것이다. props.theme~으로 사용해도 되는데 비구조화 할당으로 theme을 미리 꺼내면 코드 길이가 조금 더 줄어든다.

svg 사용하기 (feat. public)

요즘 센드버드 깃허브에 빠져있다. 최고 개발자들은 이렇게 코드를 짜는구나 하면서 감탄하면서 둘러보고 있다. assets 폴더 안에 svg 파일을 정갈하게 정리해놓은 거 보고 오오 했는데 최근에 봤던 한 아티클에서는 이미지를 src 안에서 관리하면 컴파일 시 따로 모듈로 취급된다고 들어서 (원래 코드에는 정답이란 게 없으니) 아티클에서 권장하는 대로 public 폴더 안에 svg 파일을 넣었다. 모듈화나 컴파일 관점은 잘 모르겠지만 img 태그에서 불러올 때 경로가 간편한 점은 좋은 것 같다.

사용하기!

export const Logo = () => {
  return (
    <LinkWrap to='/'>
      <LogoImage src='icon-logo.svg' alt='logo' />
    </LinkWrap>
  );
};

Vite에서 json-server 쓰기

신기하게 Swagger UI가 남아 있었던 예전의 채용 과제.. 근데 서버는 꺼두셨는지 fetch 메서드로 API 호출이 되지는 않았다. 하지만 Swagger의 Execute를 실행하면 성공했을 때의 응답 데이터가 뜨니까 ㅎㅎ mock 데이터로 가져다썼는데 (이래도 되는 것일까) 이 과정에서 사용한 것이 json-server이다.

셋팅 방식은 CRA랑 비슷했다. json-server와 함께 @types/json-server를 설치하고 서버와 클라이언트 동시 시작을 위해 concurrently 모듈을 설치한다. (셋 다 devDependency로 설치)

concurrently는 package.json의 scripts에 명령어를 추가하면 서버와 클라이언트를 자동으로 한 번에 켜주는, json-server와 함께 쓰면 매우 편한 아이다(!)

// package.json
...
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
     /* 아래 2줄 추가 */
    "server": "json-server --watch mock/scores.json --port 4000",
    "json": "concurrently \"yarn dev\" \"yarn server\"",
    "build": "tsc && vite build",
    "preview": "vite preview",
    "storybook": "start-storybook -p 6006",
    "build-storybook": "build-storybook"
  },
  ...

api 폴더에는 json-server의 포트 번호(4000)를 가진 BASE_URL을 설정해주었다.

// api/const.ts
export const BASE_URL = 'http://localhost:4000';

package.json의 port 번호와 함께 바꾸면 이 port 번호도 변경이 가능하다.

fetch 메서드로 데이터 통신하기

axios 대신 fetch 메서드를 써본 것도 요즘 최적화가 머릿속에 꽉 차 있어서(...) axios는 여러 가지 방면으로 fetch보다 나은 방법이라서 많이 쓰이지만 이번 프로젝트 때는 라이브러리 사용을 최대한 줄이고 싶었다.

form 태그 사용도 그렇고 최대한 브라우저가 기본적으로 갖고 있는 걸 활용하는 게 최적화의 방안이 될 것 같다. 원티드 챌린지에서 여러 번 써봤던 fetch 메서드를 사용해보았다.

import { ScoresProp } from './types';

export const getScores = async (): Promise<ScoresProp[]> => {
  const fetchedScores = await fetch(`${BASE_URL}/scores`);

  if (!fetchedScores.ok) {
    throw new Error('네트워크 응답이 올바르지 않습니다.');
  }

  return fetchedScores.json();
};

export const getScoreById = async (id: number) => {
  const fetchedScore = await fetch(`${BASE_URL}/scores/${id}`);

  if (!fetchedScore.ok) {
    throw new Error('네트워크 응답이 올바르지 않습니다.');
  }

  return fetchedScore.json();
};

형태적으로 axios와는 아주 약간의 차이가 있다. 두 함수를 작성하다보니 형태가 매우 비슷해서 id를 optional로 받아오도록 하는 함수 한 개로도 합쳐봤는데, 나중에 타입스크립트 에러가 날 뿐만 아니라 CRUD + 상세 Read(getById) 관습에도 어긋나는 것 같아서 다시 두 개의 함수로 나눴다. 혹은 use가 앞에 붙는 커스텀 훅으로 모듈화를 할 수 있을 것 같다는 느낌이 왔는데 아직 잘 모르겠다 ^^;

특정 소수점 자리까지 나타내기

렌더링된 숫자 데이터를 소수점 5자리까지 나타내는 요구사항이 있어서 관련 메서드를 찾았다가 괄호 문제로 에러가 나서 console로 디버깅(?)을 했다. 덕분에 Math.Float, Math.round, toFixed 메서드를 복습할 수 있었다.

  • toFixed 사용 시 인자로 몇 번째 소수점까지 나타날 것인지를 지정 (예: toFixed(5))
export const getFloatFixed = (value: number, fixed: number) => {
  return (parseFloat(String(Math.round(value * 100))) / 10).toFixed(fixed);
};

Vite 구동 Port 번호 변경

Vite의 초기 구동 port 번호는 5173이다. vite.config.ts 설정으로 과제의 요구사항인 8080번으로 간단히 바꿨다.

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [tsconfigPaths(), reactRefresh()],
  server: {
    port: 8080,
  },
});

css로 로딩 스피너 만들어서 쓰기

요즘 웹 최적화를 살짝 공부하고 나니(..) 함부로 라이브러리를 설치해서 사용하지 않게 되었다. 어떻게든 기본적으로 제공되는 것(fetch 메서드, form 태그 등등)을 활용하려고 하는 데 그 일환 중 하나로 로딩 스피너도 라이브러리 대신 css로 구현해서 쓰게 되었다. 예전에는 아무 생각 없이 react-spinner 같은 라이브러리를 다운받아 썼는데 이젠 그러고 싶지 않아.. 구글에서 찾은 코드를 재사용이 가능한 컴포넌트로 살짝 수정했다.

import styled from 'styled-components';

interface SpinnerProps {
  size?: string;
  margin?: string;
}

export const Spinner = (props: SpinnerProps) => {
  return <Loader {...props} />;
};

const Loader = styled.div<SpinnerProps>`
  border: 10px solid #f3f3f3;
  border-top: 10px solid #3498db;
  border-radius: 50%;
  width: ${({ size }) => size};
  height: ${({ size }) => size};
  margin: ${({ margin }) => margin};
  animation: spin 1s linear infinite;
  @keyframes spin {
    0% {
      transform: rotate(0deg);
    }
    100% {
      transform: rotate(360deg);
    }
  }
`;

Spinner.defaultProps = {
  size: '80px',
  margin: '24px',
};

사용

const { isLoading } = useFetchData();
...

 {isLoading ? (
        <Spinner size='180px' margin='80px' />
      ) : (
      ...
      }
      

Redux 써보기

리덕스... 그동안은 아티클 보면서 공부만 하고 Recoil Jotai로 미뤄왔는데 토이 프로젝트는! 평가받는 것도 아니고 안 돼서 헤메다가 에러가 해결이 안 되어 브라우저 화면이 안 떠도(?) 상관없기 때문에 과감히 도전해보기로 했다.

소감은 boilerplate가 정말 많고(하나의 액션을 사용하기 위해 쳐야 하는 코드가 몇 개인지) 하나하나가 어떤 역할을 하는지, 무엇을 어디에서 꺼내와야 하는지 제대로 알지 못하면(예: state 심층 구조..) 데이터를 제대로 가져와 쓸 수 없었다.

아직 100% 이해하지 못한 상태로 작성한 거라 완벽하진 않지만 sort(오름차순/내림차순 정렬) 관련 reducer를 만들게 되었다. 여기서 조금 헤맨 것이 tanstack-query에서 받아온 fetched 데이터로 초기값을 지정하고, 사용자가 오름차순 또는 내림차순 버튼을 클릭하면 그에 맞는 데이터로 다시 렌더링하는 것이었다.

결국 stored 상태를 따로 만들었는데 useQuery 로직이 상위에서 작동하고 하위에서 바로 받아쓰는 과정에서 렌더링 시점(?) 차이로 인한 에러가.. ㅠㅠ 아무튼 다음 코드로 성공은 할 수 있었다.

import { ScoresProp } from 'api/types';

export const STORE_DATA = 'SORT/STORE_DATA' as const;
export const SORT_DATA = 'SORT/SORT_DATA' as const;

export const storeData = (data: ScoresProp[]) => ({
  type: STORE_DATA,
  payload: {
    fetchedData: data,
  },
});
export const sortData = (sortBy: string) => ({ type: SORT_DATA, sortBy });

/* ReturnType - 함수의 반환 타입을 가져오는 유틸리티 타입 */
type SortAction = ReturnType<typeof storeData> | ReturnType<typeof sortData>;

type SortState = {
  storedData: ScoresProp[];
  sortedData: ScoresProp[];
};

const initialState: SortState = {
  storedData: [],
  sortedData: [],
};

function sortReducer(state = initialState, action: SortAction) {
  switch (action.type) {
    case STORE_DATA:
      console.log(action.payload.fetchedData);

      return {
        ...state,
        storedData: action.payload.fetchedData,
      };
    case SORT_DATA:
      if (action.sortBy === 'ascend') {
      	/* foxtrot은 탭 이름이자 데이터의 속성 이름이다 */
        const sortedData = state.storedData.sort(
          (prev: ScoresProp, next: ScoresProp) => prev.foxtrot - next.foxtrot,
        );
        return {
          ...state,
          sortedData,
        };
      } else {
        const sortedData = state.storedData.sort(
          (prev: ScoresProp, next: ScoresProp) => next.foxtrot - prev.foxtrot,
        );
        return {
          ...state,
          sortedData,
        };
      }
    default:
      return state;
  }
}

export default sortReducer;

아직 구현 중이어서 나중에 내용을 추가할 예정이다. 협업 프로젝트보다 간단한 과제로 이것저것 실험해보니 재미있다 ^^

profile
영어강사, 프론트엔드 개발자를 거쳐 데이터 분석가를 준비하고 있습니다 ─ 데이터분석 블로그: https://cherylog.tistory.com/

0개의 댓글