
마이 컴퍼니는 회사 소개, 제공하는 서비스, 뉴스 구독, 소통을 위한 게시판, 댓글, 연락망을 통해 사용자와 하나로 연결되는 웹사이트이다!
팀 프로젝트와 개인 프로젝트를 연달아 진행하며 머리를 산뜻하게 정화시키면서 공부는 게을리하지 않으려고 생각하다 시작했다.
쌀 한톨의 가벼움을 가장한 쌀포대 학습...이랄까?
주제는 지구와 환경 earth & environment 🌍🌳
자연을 사랑하고 보호하는 회사 컨셉이다.
처음 회사 홈페이지를 만들고 싶다고 생각한 것은 다양한 회사들의 홈페이지를 방문하면서였다. 그래서 내 홈페이지는 뛰어난 기술이 들어간 것도 아니고, 스크롤에 따른 애니메이션이 나오는 것도 아니다.
그렇기에 초기 페이지를 설계하고 디자인과 색상을 정하고, 레이아웃을 만들면서 머릿속의 초심은 아래와 같았다.
그리하여 처음에 간단하게 페이지 기능만 있는 회사 사이트를 만들었는데...
선배🔊 "개인 프로젝트라고 내놓기에는 아직 부족해, 업그레이드가 필요해 보여!"
나💬 "그죠? 저도 다양한 상태 관리나 데이터 통신을 공부하고,
이번 기회에 타입스크립트로 리팩토링도 생각 중이에요!"
선배🔊 "좋아! 근데 꼭 필요한 기능인지 생각해 보고,
기술적으로 성장할 수 있는 부분을 디벨롭하면 도움이 될 거야!"
나💬 "넵, 감사합니다~ㅎㅎ"
사실 내 눈에도 부족한 부분이 보였기에, 커피챗을 통해 멘토 선배에게 피드백을 받으면서 미리 생각해둔 리팩토링을 바로 진행했다.

코드를 검토하며 든 생각은, 역시 인간은 같은 실수를 반복하네...?
중복 코드 발생! 리팩토링하면서 작은 것부터 하나씩 개선해나갔다.
기본만 갖춘 회사 홈페이지라도 상태나 데이터 관리를 다른 방식으로 활용해 보고 싶었기에 Recoil, React-Query를 도입했다.
React-Query를 도입하기 전, Axios를 사용해 데이터를 처리했고, 상태 관리 작업(데이터 가져오기, 로딩 상태, 에러 처리 등)을 리코일을 통해 관리했지만, React-Query를 도입하면서 확실히 Recoil을 통해 작성했던 코드가 이전하니, 코드의 양도 줄어들고 이점을 확실하게 느꼈다.
하지만, 이점만 있는 것은 아니었다.
React-Query 도입으로 Recoil의 필요성이 불분명해진 것도 있다. 그래도 공부를 하고, 프로젝트에 활용해보았기에 후회는 없다.
그 다음, 이제 타입스크립트로 리팩토링하기 위해 jsx -> tsx 변환하는 순간, 새빨간 에러 파티가 시작되었다. 하지만, 에러 따위 무섭지 않지. 나는 할 수 있다.
Why? 일단 해보고 보는 거지! 머뭇거리면 시간만 간다고! 1분 1초가 얼마나 아까운데 😭😱
에러 구문 영어를 차분하게 읽으면 답이 나온다ㅎㅎ 안 나오면 동료 찬스, 구글 찬스, 제미나이 찬스~
그리고 개인 프로젝트임에도 불구하고, 깃허브 이슈를 사용해 개발할 기능 등 프로젝트의 전반적인 진행 상황을 파악해서 우선순위를 정하거나 작업을 분배했다. 작업 내용, 결정 사항 내용 등 이슈에 기록되어 있어 나중에 참고할 수 있고, 프로젝트의 문서화와 작은 지식이라도 공유가 가능하니까!
| 메인 홈 & 이메일 구독 | 회사 소개 | 서비스 | 공지 게시판 & 업로드 |
|---|---|---|---|
| 게시글 상세 | 게시글 댓글 | 게시글 삭제 | 지도 연락망 |
|---|---|---|---|
| 메인 홈 | 소개 |
|---|---|
| 서비스 | 지도 & 연락망 |
|---|---|
| 게시글 업로드 | 게시글 삭제 & 댓글 |
|---|---|
일단 코드 스플리팅을 하기 전에 WHY? 왜 필요할까? 라는 생각을 먼저했다.
리액트와 같이 SPA로 개발된 프로젝트를 빌드하면 자바스크립트 파일로 번들링되는데, 하나의 파일로 번들링된 결과물인 배포 사이트에 들어가면 처음 진입 시 모든 페이지에 대한 정보를 불러오게 되고, 이는 초기 로딩을 느리게 만들어 사용자 경험에 안좋다!
이같이 초기 로딩이 느려지는 것이 CSR의 문제점 중 하나며, 이를 해결하기 위해 SSR 같은 방법도 있다. 하지만 성능 향상과 SEO만을 위해 SSR을 사용하기 보다는 리액트에서도 다양한 성능 향상 방법을 통해 문제점을 개선해보고자 리팩토링에서 코드 스플리팅을 하게 되었다.
우선, 코드 분할을 사용하면 초기 번들만 로드하고 필요에 따라 추가 번들을 로드하기 때문에 초기 로딩 시간이 단축된다. 또한 코드를 작은 청크로 분할해 전체 번들 크기를 줄일 수 있다.
이는 다운로드 시간을 단축할 수 있고, 대역폭 소비를 줄일 수 있다. 그 다음, 사용되는 컴포넌트만 로드되기에 브라우저가 효과적으로 캐싱할 수 있다.
라우트 기반 코드 스플리팅을 통해 React.lazy 함수는 동적으로 컴포넌트를 로드하고, Suspense는 로드되는 동안 보여줄 풀백 UI를 정의한다.
Suspense 컴포넌트를 사용하는 이유는 코드 분할을 하면 초기 로딩 시간은 단축되지만, 사용자가 다른 페이지로 이동할 때 로딩 지연이 발생할 수 있기에 이 같은 로딩 지표를 사용한다.
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import Loading from '../components/Loading/Loading';
const Home = lazy(() => import('../pages/HomePage/Home'));
const About = lazy(() => import('../pages/AboutPage/About'));
const Service = lazy(() => import('../pages/ServicePage/Service'));
const Contact = lazy(() => import('../pages/ContactPage/Contact'));
const Notice = lazy(() => import('../pages/NoticePage/Notice'));
const UploadPost = lazy(() => import('../pages/NoticePage/UploadPost'));
const Error404 = lazy(() => import('../pages/ErrorPage/Error404'));
const PostDetail = lazy(() => import('../pages/NoticePage/PostDetail'));
const Router = () => {
return (
<BrowserRouter>
<Suspense fallback={<Loading />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/service" element={<Service />} />
<Route path="/contact" element={<Contact />} />
<Route path="/notice" element={<Notice />} />
<Route path="/notice/upload" element={<UploadPost />} />
<Route path="/notice/:id" element={<PostDetail />} />
<Route path="*" element={<Error404 />} />
<Route path="/error" element={<Error404 />} />
</Routes>
</Suspense>
</BrowserRouter>
);
};
export default Router;
코드 스플리팅을 통해 성능이 향상되었는지 확인하기 위해서는 WebPack 번들 분석 도구나, Netlify 배포 로그로 확인 가능하다.
전후 이미지를 보면 애플리케이션의 자바스크립트 파일이 여러 개의 청크로 분리되어, 각 청크는 애플리케이션의 특정 부분에 대응하고, 독립적으로 로드될 수 있으며 메인 번들 크기가 35% 감소한 것을 알 수 있다.
또한 개발자 도구 네트워크 탭의 리소스도 감소해 로딩 속도 향상에 기여하였다.
원래는 Axios 만을 사용해 데이터를 처리했기에 상태 관리 작업을 리코일을 통해 처리했고, 데이터를 가져오는 과정, 로딩 상태와 에러를 처리하는 과정을 모두 구현했지만, React-Query를 도입해 함께 사용하면서 useQuery와 useMutation 훅을 사용해서 데이터를 가져오고 수정할 수 있으며, 관리하는 과정이 간단해졌다.
Axios 를 사용하면 데이터를 캐싱하고, 재사용하는 기능을 별도로 구현해야 하며, 새로운 요청을 보낼 때마다 데이터를 다시 가져와야 하므로 성능 면에서 좋지 않을 수 있지만, React-Query를 함께 사용하면 자체적으로 데이터를 캐싱하고 관리해 필요할 때마다 캐시된 데이터를 사용하기에 성능을 향상시키고 서버에 불필요한 요청을 줄여준다.
또한 데이터가 자동으로 갱신되기 때문에 최신 데이터를 유지할 수 있는 이점도 있다!
아래 변경된 api 파일을 살펴보면, 이전에는 각각의 API 호출마다 비동기 함수를 export하여 호출하는 방식이었지만, 리액트 쿼리를 사용하면서 함수 내에 직접적으로 API 호출이 이루어지지 않고 쿼리 훅에 연결된다.
이로써 API 호출과 관련된 로직을 캡슐화하고 추상화할 수 있고, 이는 컴포넌트 내부에서 데이터 로직을 감춤으로써 코드의 가독성을 향상시키고 유지보수를 쉽게 만든다. 또한 데이터 로딩 상태, 데이터 캐싱, 에러 처리 등을 쉽게 관리할 수 있다.

아래 변경된 CommentList 컴포넌트를 보면, useGetComment 훅을 사용하여 해당 포스트의 댓글 데이터를 비동기적으로 가져온다. 이 훅은 리액트 쿼리를 사용해 데이터를 관리하며, 데이터 로딩 상태 및 에러 상태를 자동으로 처리한다.

navigateTo 유틸리티 함수를 만들어 라우터의 경로를 변경한다.
첫번째 매개변수 navigate (경로 이동 함수)와 두번째 매개변수 path (이동하는 경로)를 통해 navigate(path)를 호출하여 해당 경로로 이동한다.
// navigateTo 유틸리티 함수 정의
export const navigateTo = (navigate, path) => {
navigate(path);
};
↓ ↓ ↓
// 모달에서 함수 사용
import React from 'react';
import BaseModal from './BaseModal';
import { useNavigate } from 'react-router-dom';
import { navigateTo, scrollToTop } from './../../utils/utils';
const UploadModal = ({ onClose }) => {
const navigate = useNavigate();
const handleCloseAndNavigate = () => {
onClose();
navigateTo(navigate, '/notice');
scrollToTop();
};
return (
<BaseModal
onClose={handleCloseAndNavigate}
title="Success! ☺️"
message="Your post has been uploaded."
/>
);
};
export default UploadModal;
scrollToTop 유틸리티 함수를 통해 웹 페이지의 스크롤 위치를 페이지의 맨 위로 이동시킨다. 그리고 window.scrollTo(0, 0)를 호출하여 브라우저 창의 스크롤 위치를 가로축(x)과 세로축(y)에서 각각 0으로 설정하여 페이지의 맨 위로 스크롤한다.
scrollToTop 함수를 사용하는 이유는 웹 페이지에서 새로운 내용을 탐색하거나 새로운 페이지로 이동할 때 기본적으로 브라우저는 이전 페이지의 스크롤 위치를 유지하기 때문이다!
따라서 꼭 필요한 페이지 전환시에 사용자 경험을 향상시키며, 웹 사이트를 편안하게 사용할 수 있도록 하자!
// scrollToTop 유틸리티 함수 정의
export const scrollToTop = () => {
window.scrollTo(0, 0);
};
↓ ↓ ↓
// 모달에서 함수 사용 (위 코드와 동일)
import React from 'react';
import BaseModal from './BaseModal';
import { useNavigate } from 'react-router-dom';
import { navigateTo, scrollToTop } from './../../utils/utils';
const UploadModal = ({ onClose }) => {
const navigate = useNavigate();
const handleCloseAndNavigate = () => {
onClose();
navigateTo(navigate, '/notice');
scrollToTop();
};
return (
<BaseModal
onClose={handleCloseAndNavigate}
title="Success! ☺️"
message="Your post has been uploaded."
/>
);
};
export default UploadModal;
레이아웃, 로딩, 에러, 모달 등 여러 컴포넌트에서 공통적으로 사용되는 부분을 추출해 합성 컴포넌트를 만들어 재사용함으로써 애플리케이션의 확장성과 개발 생산성을 향상시킨다.
합성 컴포넌트를 통해 코드 중복을 줄이고, 유지보수성을 높이며 새로운 컴포넌트를 만들 때 기존의 컴포넌트를 조합해 빠르게 개발할 수 있다.
// 메인 레이아웃 합성 컴포넌트
const MainLayout = ({ icon, iconTxt, title, desc }) => {
return (
<Main>
<Article>
<H2>
<img src={icon} alt={iconTxt} />
<strong>{title}</strong>
<span>{desc}</span>
</H2>
</Article>
</Main>
);
};
↓ ↓ ↓
// 다른 컴포넌트에서 재사용
<MainLayout icon={notice} iconTxt="공지 아이콘" title="Notice" desc="공지 게시판" />
// 모달 합성 컴포넌트
const BaseModal = ({ onClose, title, message }) => {
return (
<S.ModalBg>
<S.Modal>
<CloseButton onClose={onClose} />
<S.ContentBox>
<h2>{title}</h2>
<p>{message}</p>
</S.ContentBox>
</S.Modal>
</S.ModalBg>
);
};
↓ ↓ ↓
// 다른 컴포넌트에서 기능을 추가해 재사용
const UploadModal = ({ onClose }) => {
const navigate = useNavigate();
const handleCloseAndNavigate = () => {
onClose();
navigateTo(navigate, '/notice');
scrollToTop();
};
return (
<BaseModal
onClose={handleCloseAndNavigate}
title="Success! ☺️"
message="Your post has been uploaded."
/>
);
};
useModal 커스텀 훅을 이용해 관련 컴포넌트에 import해서 변경하니, 모달을 렌더링하는 몇 개의 컴포넌트에서 문제가 발생했다.
예를 들면, PostDetail 컴포넌트의 삭제 버튼은 DeleteModal 렌더링되어야 하고, CommentForm 컴포넌트는 등록 버튼 CommentModal이 렌더링되어야 하는데, 전부 DeleteModal이 나타나고 있다.
또한 이로 인해 스타일까지 중복되면서 에러가 발생해 버렸다...
PostDetail 컴포넌트에서 CommentForm 컴포넌트가 렌더링되는 상황인데, CommentForm 컴포넌트에서 사용된 모달 상태와 PostDetail 컴포넌트에서 사용된 모달 상태가 동일한 상태를 공유하고 있기 때문이다.
따라서 CommentForm 컴포넌트는 CommentModal을 트리거하기 위해 사용되어야 하지만, 현재는 DeleteModal을 트리고 하고 있다.
그렇기에 문제의 원인은 모달 상태를 공유하고 있는 것이 아니라, 모달이 열리고 닫히는 역할을 하는 useModal 훅에서 문제가 발생하고 있는 것이다.
// useModal.jsx
import React from 'react';
import { useRecoilState } from 'recoil';
import { isModalOpenState } from './../recoil/atoms';
const useModal = () => {
const [isModalOpen, setIsModalOpen] = useRecoilState(isModalOpenState);
const openModal = () => {
setIsModalOpen(true);
};
const closeModal = () => {
setIsModalOpen(false);
};
return { isModalOpen, openModal, closeModal };
};
export default useModal;
현재 useModal 훅은 useRecoilState(isModalOpenState) 리코일을 통해 전역으로 상태를 관리하고 있기 때문에, 모든 컴포넌트에서 이 상태를 공유하고 있다. 따라서 PostDetail과 CommentForm 컴포넌트에서는 동일한 모달 상태를 사용하고 있는 것이다.
이것이 CommentModal이 열리지 않는 이유이기에 useModal 커스텀 훅을 useState 훅을 사용해 로컬 상태를 관리하도록 변경한다면, 전역으로 상태가 공유되는 문제가 해결될 것이다.
그럼 각 컴포넌트에서는 자체적으로 모달 상태를 관리할 수 있게 된다.
// useModal.jsx
import { useState } from 'react';
const useModal = () => {
const [isModalOpen, setIsModalOpen] = useState(false);
const openModal = () => {
setIsModalOpen(true);
};
const closeModal = () => {
setIsModalOpen(false);
};
return { isModalOpen, openModal, closeModal };
};
export default useModal;
타입스크립트 리팩토링을 하려는 이유는 자바스크립트와의 완벽한 호환이 가능하기 때문이다. 또한 타입스크립트가 정적 타입 언어이기 때문에 코드에서 발생할 수 잇는 버그를 컴파일 타임에 잡아낼 수 있고, 이를 통해 코드의 안정성을 높이고, 쉽게 코드를 알아보고 유지보수할 수 있다.
타입스크립트 도입 시 지켜야하는 규칙들을 나열해 보자!
1. 기존 설치 패키지 충돌 확인 후, 타입스크립트 지원 패키지로 재설치
2. js, jsx -> ts, tsx 확장자 변경
3. Any 타입 대신, 지정할 수 있는 곳에 타입 정의
4. as 키워드 대신, 타입 명시적으로 정의
5. 가능한 곳은 타입을 명시적으로 정의하지 않고, 타입 추론 활용
5. 단계별 도입으로 작은 컴포넌트부터 변화해 점진적으로 확장
리액트 프로젝트에 타입스크립트로 리팩토링하려면 아래와 같은 명령어로 패키지를 설치해야 한다. 나는 npm으로 이전 패키지를 전부 설치했으니, types 패키지도 npm으로 설치했다.
npm i --save typescript @types/node @types/react @types/react-dom @types/jest
yarn add typescript @types/node @types/react @types/react-dom @types/jest
그리고 tsconfig 파일도 추가해주어야 한다. 이 파일은 타입스크립트 컴파일러에게 프로젝트를 컴파일하는 방법에 대한 정보를 제공한다.
아래 명렁어를 치면 tsconfig.json 파일이 생성되며, 불필요한 옵션은 지우고 초기 세팅을 하면 된다.
ts --init
{
"compilerOptions": {
"target": "es6", // JavaScript 코드 버전 설정
"lib": ["dom", "dom.iterable", "esnext"], // 컴파일러가 사용할 라이브러리 목록
"allowJs": true, // TypeScript 컴파일러가 JavaScript 파일도 컴파일할지 지정
"outDir": "dist", // 컴파일된 JavaScript 파일을 저장할 디렉토리 지정
"skipLibCheck": true, // 라이브러리 파일의 타입 체크를 스킵 여부
"esModuleInterop": true, // ES 모듈 및 CommonJS 모듈 간 상호 운용성 활성화 옵션
"allowSyntheticDefaultImports": true, // default import 사용
"strict": true, // 모든 엄격한 타입 검사 옵션 활성화
"forceConsistentCasingInFileNames": true, // 파일 이름의 일관된 케이스를 강제하는 옵션
"noFallthroughCasesInSwitch": true, // switch 문에서 case 절 뒤에 break 없이 다음 case로 넘어가는 것 방지
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": false,
"jsx": "react-jsx" // JSX 문법을 사용하는 파일의 파일 확장자를 지정
},
"include": ["src/**/*.ts", "src/**/*.tsx"], // 컴파일 대상 파일 지정 패턴
"exclude": ["node_modules", "dist"] // 컴파일 대상에서 제외할 파일 지정 패턴
}
자! 이제 무시무시한 상황이 기다리고 있다. 내가 선택한 방법은 페이지별로 나눠진 기능별 컴포넌트들을 하나씩 바꿀 생각이다. 한 번에 바꾸면 빨간 에러창의 스크롤을 끊임없이 내릴 수 있으니 주의하자!
변환 후 아래 명렁어를 실행하면 타입스크립트 컴파일러 워치 모드가 실행된다.
워치 모드는 타입스크립트 컴파일러가 프로젝트의 파일을 지속적으로 모니터링해서 파일이 변경될 때마다 자동으로 컴파일을 수행해서 편하다.
tsc --w
예상대로 스타일드 컴포넌트와 이미지 import 부분에서 에러가 생겼다. 스타일드 컴포넌트는 기존에 설치되어 있던 패키지를 타입스크립트가 지원하는 패키지로 재설치했다.

npm i -D @types/styled-components
타입스크립트와 react-script 버전의 충돌로 인해 설치를 실패했다.
수소문 해보니, 타입스크립트를 하위 버전으로 설치하면 가뿐하게 해결된다ㅎㅎ

스타일드 컴포넌트, 타입스크립트 버전 등 재설치가 전부 끝나면, 이미지 import하는 부분 에러를 수정해보자!
현재 타입스크립트가 이미지 파일을 인식하지 못하고, 해당 형식의 선언을 찾을 수 없다는 오류가 발생했다.

why? 타입스크립트가 이미지 파일을 일반적인 자바스크립트 모듈로 인식할 수 없기 때문이다. 따라서 이미지 관련 타입 선언 파일을 추가해서 타입스크립트에게 이미지 파일의 타입에 대해 알려줘야 한다.
declare module 구문을 사용해 이미지 확장자별 타입 정의를 담은 별도의 .d.ts 파일을 만들어 타입스크립트에서 사용할 수 있도록 정의한다.
// image.d.ts
declare module '*.jpg';
declare module '*.png';
declare module '*.jpeg';
declare module '*.svg';
declare module '*.webp';
declare module '*.gif';
근데 리팩토링처럼 프로젝트 후에 타입스크립트를 도입하는 것과 처음부터 리액트 타입스크립트 프로젝트를 생성하는 것은 일반적으로 프로젝트 구성과 설정에 차이가 있다.
후자의 상황은 이미지 파일을 모듈로 인식할 수 있는 설정이 기본적으로 포함되어 있으며, 이미지 파일에 대한 타입 정의가 포함된 패키지가 함께 설치된다.
이에 반해, 전자의 상황은 이러한 설정이나 패키지를 추가로 설치해야 한다.
d.ts 파일은 주로 외부 라이브러리나 모듈 타입을 정의할 때 사용한다.
타입 선언만을 포함하고, 구현 코드는 포함하지 않는다.
ts 파일은 일반적으로 타입스크립트 코드를 작성하는데 사용된다.
타입 선언 뿐만 아니라, 자바스크립트 코드도 포함할 수 있다.
타입스크립트로 변환했을 때 이벤트 핸들링에서도 e 타입을 명시해주지 않으면 문제가 발생한다.
onClick, onSubmit, onChange 등 이벤트는 각자 다른 타입을 가지고 있고, 같은 onChange 함수여도 핸들링 요소가 input이나 textarea에 따라 달라지기도 한다.

이럴 땐, 해당 이벤트 위에 커서를 올려보면 VSCode가 타입을 유추해 준다!


이제 타입을 명시해주자! 그리고 void로 onSubmit, handleInputChange 함수가 값을 반환하지 않는다고 나타내자!
const onSubmit = (e: React.FormEvent<HTMLFormElement>): void => {
e.preventDefault();
console.log('댓글 데이터:', inputComment);
openModal();
setInputComment('');
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
setInputComment(e.target.value);
};