모노레포를 도입하고 공통 컴포넌트와 디자인 토큰까지 패키지로 옮기고
공통 아이콘도 packages/icons라는 패키지를 만들어 여러 앱에서 재사용 하게 하고 싶었다.
import { XIcon, PlusIcon, ChevronRightIcon } from "@repo/icons";
단순히 하나의 패키지에 모아서 import를 해보았는데...
import가 되지 않았다. 앱에서는 잘 쓰던 SVG가 왜 패키지로 옮겼다는 이유만으로 갑자기 사용할 수 없게 된 걸까?
앱에서는 왜 SVG import가 됐을까
기존에는 각 앱의 public/assets 아래에 SVG를 두고 사용하고 있었다.
import { ReactComponent as EditIcon } from "@_public/assets/icons/edit.svg";
이전 프로젝트들에서는 위 코드처럼 SVG 파일을 폴더에 넣으면 바로 React 컴포넌트처럼 사용할 수 있었다.
하지만 패키지의 SVG가 import 되지 않아 알아보니
실제로 import되고 있던 것은 SVG가 아니라 SVG는 XML 형식의 파일일 뿐이고 중간에서 @svgr/webpack이 SVG를 React 컴포넌트로 바꿔주고 있었던 것이었다.
edit.svg → @svgr/webpack → React 컴포넌트 (앱의 빌드 설정)
문제는 SVG를 공통 패키지로 옮겼을 때인데, 패키지 안에는 SVG밖에 없지만 SVG를 React 컴포넌트로 바꾸는 능력은 여전히 소비하는 앱이 가지고 있어서
결국 공통 패키지를 쓰는 모든 앱이 같은 SVG 설정을 가지고 있어야 한다. 패키지는 공통인데 사용하는 방법은 각 앱의 빌드 환경에 묶여있는 셈이다.
공통 Button └─ EditIcon └─ 소비 앱의 webpack 설정 필요
혹시나 하는 마음에 declare module "*.svg"를 추가해도 해결되지 않았는데, 이 설정은 TypeScript가 SVG import를 허용하도록 할 뿐, SVG를 React 컴포넌트로 변환해주지는 않았다. ㅠㅜ
결과적으로 SVG를 React 컴포넌트로 만드는 책임까지 공통 패키지가 가져가게 해야겠다 라는 생각에 미치게 되었다
SVG 변환 책임 옮기기
공통 아이콘 패키지가 소비 앱의 webpack 설정에 의존하지 않게 하려면, SVG를 패키지 안에서 미리 React 컴포넌트로 만들어둘 필요가 있었다. 여기서 사용한 것이 @svgr/cli, SVG 파일을 React 컴포넌트 코드로 변환해 TSX 파일로 만들어주는 도구다.
edit.svg → @svgr/cli → EditIcon.tsx
기존의 @svgr/webpack은 앱을 빌드할 때 SVG를 변환했다면, @svgr/cli는 SVG를 추가하거나 수정할 때 미리 컴포넌트 파일을 만들어둔다. 덕분에 소비 앱은 SVG를 직접 처리할 필요 없이, 이미 만들어진 React 컴포넌트만 import하면 된다.
패키지에서는 해당 방식을 이용하고, @svgr/webpack 은 앱 내부에서만 사용하는 SVG에는 여전히 사용하게 하였다.
| @svgr/webpack | @svgr/cli | |
|---|---|---|
| 변환 시점 | 앱 빌드 시 | SVG 추가·수정 시 |
| 변환 책임 | 소비 앱 | 아이콘 패키지 |
| 소비 앱 설정 | loader 필요 | 별도 설정 없음 |
| 결과물 | 빌드 과정에서 생성 | TSX 파일로 관리 |
SVG가 컴포넌트가 되기까지
패키지는 용도에 따라 두개로 나눴는데, icons에는 버튼, 상태 표시, 파일 타입처럼 UI에서 자주 쓰는 것, symbols에는 빈 화면이나 섹션 헤더처럼 상대적으로 크고 표현적인 아이콘들을 넣어두었다.
packages/icons
packages/symbols
두 패키지 모두 SVG를 React 컴포넌트로 만드는 과정은 같다.
src/svgs/.svg 생성→ @svgr/cli → src/components/.tsx → generate-index.js → index.ts (export)
새로운 SVG를 src/svgs에 추가하고 pnpm svgr 명령을 실행하면 먼저 React 컴포넌트가 만들어진다.
예를 들어 edit.svg를 추가하면 EditIcon.tsx가 생성된다.
export { default as EditIcon } from "./src/components/EditIcon";
여기서 끝내면 위처럼 새로운 아이콘을 추가할 때마다 패키지의 index.ts에도 직접 export를 추가해야 하는데,
아이콘이 많아질수록 이 작업도 반복되기 때문에 generate-index.js가 생성된 컴포넌트들을 읽어 index.ts까지 자동으로 갱신하도록 했다.
결국 SVG 하나를 추가하면 컴포넌트 생성부터 패키지 export 등록까지 한 번에 처리되는 구조가 되었다.
{ "scripts": { "svgr": "svgr --typescript --icon --title-prop -d src/components --no-prettier --no-index src/svgs && node scripts/generate-index.js" } }
결론적으로 패키지에 새로운 아이콘을 추가한 뒤에는 루트에서 한 줄만 실행하면 되게 되었다.
pnpm svgr
여기서 핵심은 명령어를 줄이는 것보다 SVG를 추가하면 컴포넌트 생성과 export 등록까지 항상 같은 과정으로 처리되게 만든 것이라 생각했다.
생성한 아이콘 사용하기
svgr명령어로 자동 생성된 tsx 아이콘 파일은 아래와 같은 형태이다.
import type { SVGProps } from "react"; const CheckIcon = (props: SVGProps<SVGSVGElement>) => ( <svg {...props}> <path d="..." /> </svg> ); export default CheckIcon;
이제 앱에서는 SVG 파일을 직접 가져오는 게 아니라, 만들어진 React 컴포넌트를 import해서 사용하게 되었다.
SVGProps<SVGSVGElement>를 받도록 생성되기 때문에 className, width, height, aria-* 같은 SVG 속성을 그대로 넘길 수 있고, 잘못된 값은 TypeScript가 잡아준다.
import { CheckIcon } from "@repo/icons"; <CheckIcon className="size-4" /> <CheckIcon width={20} height={20} /> <CheckIcon aria-hidden />
아이콘 이름 관리하기
또 하나의 문제는 디자이너에게 전달받는 SVG 파일명은 생각보다 제각각이었다는 것이다.
add.svg
add copy.svg
add_user.svg
alert-triangle.svg
Expand.svg
calendar-check--outline.svg
다행히 SVGR가 파일명을 React 컴포넌트 이름으로 바꾸는 것까지는 처리해주었는데,
add copy.svg → AddCopy.tsx
alert-triangle.svg → AlertTriangle.tsx
문제는 svgr로 생성한 이름을 그대로 앱에 공개했을 때
예를들어 AlertTriangle만 봐서는 일반 컴포넌트인지 아이콘인지 헷갈렸고, symbols 패키지까지 함께 사용하면 이름만으로 둘을 구분하기도 애매했다.
그래서 패키지 밖으로 공개할 때 컴포넌트 뒤에 Icon / Symbols 이름을 붙이는 규칙을 추가했다.
실제 생성된 파일 이름을 바꾼 것이 아닌, 대신 index.ts에서 이름을 바꿔 export하는 방식을 이용했다.
icons → XxxIcon
symbols → XxxSymbol
export { default as AddIcon } from "./src/components/Add"; export { default as AlertTriangleIcon } from "./src/components/AlertTriangle";
이 export를 직접 작성하는 것도 번거롭기 때문에 generate-index.js로 자동화 되도록 하였다.
const componentFiles = fs .readdirSync(COMPONENTS_DIR) .filter((file) => file.endsWith(".tsx")) .map((file) => file.replace(".tsx", "")); const componentExports = componentFiles.map( (name) => `export { default as ${name}Icon } from './src/components/${name}';`, ); fs.writeFileSync(INDEX_FILE, componentExports.join("\n"), "utf8");
위의 스크립트는 현재 src/components에 있는 파일을 읽고 Icon 접미사를 붙인 index.ts를 새로 만든다.
여기서 중요한 건 기존 index.ts에 내용을 계속 추가하지 않고 현재 존재하는 파일을 기준으로 매번 다시 만든다는 점이다.
예를 들어 Delete.tsx를 삭제하면 다음 생성 때 DeleteIcon export도 함께 사라지도록 하여 실제 파일과 패키지에서 공개하는 목록이 어긋나는 일을 막을 수 있었다.
아이콘을 모으는 일에서 패키지를 만드는 일로
처음에는 그저 여러 앱에 흩어져 있던 SVG를 한 폴더에 모아서 사용하고 싶었을 뿐이었다.
그런데 문제가 발생했고..
실제로 문제를 파고들어 보니 SVG 파일의 위치가 핵심이 아닌
기존 구조에서는 SVG를 React 컴포넌트로 만드는 책임이 각 앱에 숨어 있었던 것이었다.
그래서 변환 책임까지 패키지로 옮기고 나서야 앱별 SVG 복사본과 webpack loader 의존성을 함께 없앨 수 있었다.
이렇게 해서 pnpm svgr 한 줄을 실행했을 때 아래와 같은 일들이 한꺼번에 일어나게 되었다.
- 원본 SVG는 한 곳에만 존재하고
- 항상 같은 규칙의 React 컴포넌트가 만들어지고
- 아이콘 이름도 자동으로 정리되고
- 앱은 빌드 설정을 몰라도 되고
- 잘못된 변경은 타입이나 CI에서 발견할 수 있는
이제 매번 SVG를 각각 관리할 필요가 없이 필요한 아이콘을 하나 가져다 쓰면 되게 되었다.
import { XIcon } from "@repo/icons";

현재는 이 방식을 최선으로 생각하였지만, 추후 더 나은 방식을 발견하면 개선해보려고 한다!