junyeokk
Blog
React Ecosystem·2025. 11. 15

SVGR

프로젝트에서 아이콘을 사용할 때, SVG 파일을 어떻게 관리하느냐는 생각보다 골치 아픈 문제다. <img src="icon.svg" />로 넣으면 간단하지만, 색상을 동적으로 바꾸거나 크기를 텍스트에 맞추거나 hover 시 스타일을 변경하는 건 불가능하다. 인라인 SVG로 직접 JSX에 넣으면 되긴 하는데, SVG 코드가 수십 줄씩 되는 걸 컴포넌트 안에 그대로 붙여넣는 건 끔찍하다.

SVGR은 이 문제를 해결한다. SVG 파일을 React 컴포넌트로 변환해주는 도구다. .svg 파일을 import하면 자동으로 React 컴포넌트가 되어, props로 색상·크기를 제어하고, 트리쉐이킹도 가능하다.


SVG를 다루는 기존 방식들

SVG를 React에서 사용하는 방법은 여러 가지가 있다. 각각 장단점이 뚜렷하다.

1. img 태그로 불러오기

jsx
<img src="/icons/arrow.svg" alt="arrow" width={24} height={24} />

가장 간단하지만 제약이 크다. SVG 내부에 접근할 수 없기 때문에 fill, stroke 같은 속성을 동적으로 바꿀 수 없다. CSS로 색상을 바꾸고 싶어도 불가능하다. 별도의 HTTP 요청이 발생하고, 브라우저 캐시에 의존해야 한다.

2. 인라인 SVG

jsx
const ArrowIcon = () => (
  <svg width="24" height="24" viewBox="0 0 24 24" fill="none">
    <path d="M5 12h14M12 5l7 7-7 7" stroke="currentColor" strokeWidth={2} />
  </svg>
);

완전한 제어가 가능하다. currentColor로 부모의 텍스트 색상을 상속받을 수도 있고, props로 어떤 속성이든 바꿀 수 있다. 문제는 SVG가 복잡해지면 코드가 장황해진다는 것이다. 디자이너가 Figma에서 내보낸 SVG를 매번 수동으로 JSX로 변환하는 건 비효율적이다. HTML 속성명을 JSX 규칙에 맞게 바꿔야 하는 것도 번거롭다(stroke-widthstrokeWidth, fill-rulefillRule 등).

3. SVG 스프라이트

jsx
<svg>
  <use href="/sprites.svg#arrow" />
</svg>

여러 아이콘을 하나의 파일로 합쳐서 HTTP 요청을 줄이는 전통적인 방법이다. 하지만 스프라이트 파일을 수동으로 관리해야 하고, 개별 아이콘에 대한 세밀한 제어가 어렵다. 현대 번들러의 트리쉐이킹과도 잘 맞지 않는다.

SVGR의 접근

SVGR은 2번(인라인 SVG)의 장점을 가져가면서, 1번의 편의성을 합친 것이다. .svg 파일을 그대로 유지하면서 빌드 타임에 자동으로 React 컴포넌트로 변환한다.


SVGR의 내부 동작 파이프라인

SVGR이 SVG를 React 컴포넌트로 바꾸는 과정은 단순한 문자열 치환이 아니다. 여러 단계의 AST(Abstract Syntax Tree) 변환을 거친다.

text
SVG 파일

[1] SVGO로 최적화

[2] HTML → HAST (HTML AST) 파싱

[3] HAST → Babel AST (JSX AST) 변환

[4] Babel AST 변환 (속성명 변경, 값 치환 등)

[5] JSX를 React 컴포넌트로 래핑

[6] Babel AST → 코드 생성

[7] Prettier로 포매팅

React 컴포넌트 (.jsx / .tsx)

1단계: SVGO 최적화

SVGO(SVG Optimizer)가 먼저 실행된다. 디자인 도구에서 내보낸 SVG에는 에디터 메타데이터, 불필요한 그룹(<g>), 주석, 빈 <defs> 등 쓸모없는 코드가 많다. SVGO가 이걸 제거하고, 경로를 단순화하고, 속성을 정리한다.

예를 들어 Figma에서 내보낸 SVG에 이런 게 포함되어 있다면:

xml
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
  <title>Rectangle 5</title>
  <desc>Created with Sketch.</desc>
  <defs></defs>
  <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
    <g id="19-Separator" fill="#063855">
      <rect id="Rectangle-5" x="25" y="36" width="48" height="1"></rect>
    </g>
  </g>
</svg>

SVGO를 거치면 이렇게 줄어든다:

xml
<svg xmlns="http://www.w3.org/2000/svg">
  <path d="M0 0h48v1H0z" fill="#063855" fill-rule="evenodd"/>
</svg>

XML 선언, <title>, <desc>, 빈 <defs>, 불필요한 <g> 래핑이 모두 제거되고, <rect>가 동등한 <path>로 최적화되었다.

SVGR은 기본적으로 prefixIds 플러그인도 활성화한다. 이건 SVG 내부의 idclass 값에 접두사를 붙여서, 여러 SVG 컴포넌트가 같은 페이지에 렌더링될 때 ID 충돌을 방지한다. 예를 들어 두 SVG 모두 id="gradient1"을 가지고 있으면 하나가 다른 하나의 그라디언트를 가져가는 버그가 생길 수 있는데, prefixIds가 이걸 막는다.

2~4단계: HTML → HAST → Babel AST 변환

SVG는 HTML의 일부이므로 먼저 HAST(HTML Abstract Syntax Tree)로 파싱한다. 그 다음 이걸 Babel AST(JSX가 이해할 수 있는 형태)로 변환하면서 HTML 속성을 JSX 속성으로 바꾼다:

HTML 속성JSX 속성
stroke-widthstrokeWidth
fill-rulefillRule
clip-pathclipPath
xmlns:xlinkxmlnsXlink
classclassName

이 과정에서 옵션에 따라 추가 변환도 수행한다. replaceAttrValues로 하드코딩된 색상을 currentColor로 바꾸거나, dimensions: false로 고정 width/height를 제거하거나, svgProps로 추가 속성을 삽입하는 등의 작업이 여기서 일어난다.

5~7단계: 컴포넌트 래핑과 코드 생성

변환된 JSX를 React 컴포넌트 함수로 감싼다. props를 받아서 SVG 태그에 {...props}로 전달하는 형태가 기본이다:

jsx
const SvgArrow = (props) => (
  <svg width="1em" height="1em" viewBox="0 0 24 24" {...props}>
    <path d="M5 12h14M12 5l7 7-7 7" stroke="currentColor" strokeWidth={2} />
  </svg>
);
export default SvgArrow;

마지막으로 Prettier가 코드를 포매팅해서 깔끔한 출력을 만든다.


Vite에서 사용하기: vite-plugin-svgr

Vite 프로젝트에서 SVGR을 쓰려면 vite-plugin-svgr을 설치한다.

bash
npm install vite-plugin-svgr --save-dev
typescript
// vite.config.ts
import svgr from "vite-plugin-svgr";

export default defineConfig({
  plugins: [svgr()],
});

이제 SVG를 React 컴포넌트로 import할 수 있다.

v3 이전 방식 (Named Export)

typescript
import { ReactComponent as ArrowIcon } from "./arrow.svg";

CRA(Create React App)에서 쓰던 익숙한 패턴이다. v3까지는 이 방식이 기본이었다.

v4 이후 방식 (쿼리 파라미터)

typescript
// React 컴포넌트로 import
import ArrowIcon from "./arrow.svg?react";

// URL로 import (기본 Vite 동작)
import arrowUrl from "./arrow.svg";

v4부터는 ?react 접미사를 붙여서 명시적으로 컴포넌트 변환을 요청한다. 접미사가 없으면 Vite의 기본 동작대로 URL 문자열이 반환된다. 이 방식이 더 명확하고, 같은 SVG를 URL로도 컴포넌트로도 사용할 수 있어서 유연하다.

TypeScript 타입 선언

TypeScript 프로젝트에서는 .svg?react import의 타입을 인식시켜야 한다. vite-plugin-svgr이 제공하는 타입 파일을 tsconfig에 추가한다:

json
// tsconfig.json
{
  "compilerOptions": {
    "types": ["vite-plugin-svgr/client"]
  }
}

또는 직접 선언 파일을 작성할 수도 있다:

typescript
// src/vite-env.d.ts
declare module "*.svg?react" {
  import type { FunctionComponent, SVGProps } from "react";
  const ReactComponent: FunctionComponent<SVGProps<SVGSVGElement>>;
  export default ReactComponent;
}

주요 옵션들

SVGR에는 다양한 옵션이 있다. vite-plugin-svgr에서는 svgrOptions로 전달한다.

typescript
// vite.config.ts
import svgr from "vite-plugin-svgr";

export default defineConfig({
  plugins: [
    svgr({
      svgrOptions: {
        icon: true,
        typescript: true,
        ref: true,
        memo: true,
        replaceAttrValues: { "#000": "currentColor" },
        svgProps: { role: "img" },
      },
    }),
  ],
});

icon

icon: true로 설정하면 SVG의 widthheight1em으로 바꾼다. 이렇게 하면 아이콘 크기가 부모 요소의 font-size를 따르게 되어, 텍스트 옆에 배치했을 때 자연스럽게 크기가 맞는다.

jsx
// icon: false (기본값)
<svg width="24" height="24" viewBox="0 0 24 24">

// icon: true
<svg width="1em" height="1em" viewBox="0 0 24 24">

특정 크기를 지정하고 싶으면 icon: "2em" 또는 icon: 32 같이 값을 넣을 수도 있다.

typescript

typescript: true.tsx 파일을 생성하고, 컴포넌트에 SVGProps<SVGSVGElement> 타입이 자동으로 붙는다. 타입 안전성이 보장되어 잘못된 SVG 속성을 전달하면 컴파일 타임에 잡힌다.

ref

ref: true로 설정하면 React.forwardRef로 감싸서 부모 컴포넌트에서 SVG DOM 요소에 직접 접근할 수 있게 한다. 애니메이션이나 크기 측정이 필요할 때 유용하다.

jsx
// ref: true일 때 생성되는 코드
const SvgArrow = React.forwardRef((props, ref) => (
  <svg ref={ref} {...props}>...</svg>
));

memo

memo: trueReact.memo로 래핑한다. 동일한 props가 전달되면 리렌더링을 건너뛴다. 아이콘 컴포넌트는 대부분 순수 컴포넌트이므로 memo의 효과가 크다.

replaceAttrValues

SVG에 하드코딩된 색상 값을 동적으로 교체한다. 가장 흔한 사용 사례는 고정 색상을 currentColor로 바꾸는 것이다.

typescript
replaceAttrValues: {
  "#000000": "currentColor",
  "#000": "currentColor",
}

이렇게 하면 아이콘이 CSS color 속성을 상속받아, 별도의 prop 없이도 텍스트 색상과 일치하게 된다:

jsx
// 변환 전
<path fill="#000000" d="..." />

// 변환 후
<path fill="currentColor" d="..." />

동적 prop으로 매핑할 수도 있다:

typescript
replaceAttrValues: {
  "#000": "{props.color}",
}

dimensions

dimensions: false로 설정하면 루트 <svg> 태그에서 widthheight를 제거한다. viewBox만 남기면 SVG가 부모 컨테이너의 크기에 맞춰 유연하게 스케일된다. CSS로 크기를 제어하고 싶을 때 유용하다.

expandProps

expandProps: "end"(기본값)이면 {...props}가 SVG의 마지막에 위치해서, 사용할 때 전달한 props가 기본값을 덮어쓴다. "start"로 바꾸면 기본 속성이 우선한다. false면 props 전파를 아예 하지 않는다.

svgProps

모든 생성 컴포넌트에 공통 속성을 추가한다. 접근성을 위해 role="img"를 넣거나, focusable="false"로 키보드 포커스를 방지하는 등에 쓴다.

typescript
svgProps: {
  role: "img",
  focusable: "false",
}

titleProp / descProp

titleProp: true면 컴포넌트에 title prop을 전달해서 접근성 제목을 설정할 수 있게 된다. 스크린 리더가 아이콘의 의미를 읽어줄 수 있다.

jsx
<ArrowIcon title="다음으로 이동" />

// 렌더링 결과
<svg role="img">
  <title>다음으로 이동</title>
  <path d="..." />
</svg>

커스텀 템플릿

기본 컴포넌트 구조가 마음에 들지 않으면 템플릿을 직접 작성할 수 있다. 예를 들어 모든 아이콘에 size prop을 추가하고 싶다면:

javascript
// svgr-template.js
const template = (variables, { tpl }) => {
  return tpl`
    ${variables.imports};
    
    const ${variables.componentName} = ({ size = 24, ...props }) => (
      ${variables.jsx}
    );

    ${variables.exports};
  `;
};

module.exports = template;
typescript
// vite.config.ts
svgr({
  svgrOptions: {
    template: require("./svgr-template"),
  },
})

템플릿 내부에서 사용 가능한 변수들:

  • variables.imports: React import 문
  • variables.interfaces: TypeScript 인터페이스 (typescript: true일 때)
  • variables.componentName: 컴포넌트 이름 (파일명에서 파생)
  • variables.jsx: 변환된 SVG JSX
  • variables.exports: export 문

트리쉐이킹과 번들 크기

SVGR의 가장 큰 실질적 이점 중 하나는 트리쉐이킹이 제대로 작동한다는 것이다.

SVG 스프라이트 방식은 모든 아이콘이 하나의 파일에 합쳐져 있어서, 하나만 써도 전체를 로드해야 한다. SVGR은 각 SVG가 독립적인 ES 모듈이므로, 실제로 import한 아이콘만 번들에 포함된다.

typescript
// arrow.svg만 번들에 포함됨
import ArrowIcon from "./icons/arrow.svg?react";

// check.svg는 import하지 않았으므로 번들에서 제외됨

이건 아이콘이 수백 개인 대규모 프로젝트에서 특히 중요하다. 사용하지 않는 아이콘이 번들 크기를 부풀리지 않는다.


SVGO 설정 커스터마이징

SVGO의 기본 설정이 모든 상황에 맞지는 않는다. 때로는 특정 최적화를 끄거나 추가 플러그인을 활성화해야 한다.

typescript
svgr({
  svgrOptions: {
    svgoConfig: {
      plugins: [
        {
          name: "preset-default",
          params: {
            overrides: {
              // viewBox 제거 방지 (반응형에 필수)
              removeViewBox: false,
              // 숨겨진 요소 유지
              removeHiddenElems: false,
            },
          },
        },
        // ID에 접두사 추가 (충돌 방지)
        "prefixIds",
      ],
    },
  },
})

특히 removeViewBox: false는 거의 항상 설정해야 한다. viewBox가 없으면 SVG가 반응형으로 동작하지 않기 때문이다. SVGO의 기본 프리셋에서 removeViewBox가 활성화되어 있는 경우가 있어서, 명시적으로 비활성화하는 게 안전하다.


실전 패턴: 아이콘 시스템 구축

프로젝트에서 아이콘을 체계적으로 관리하려면 약간의 구조화가 필요하다.

래퍼 컴포넌트 패턴

SVGR로 생성된 아이콘을 직접 쓰기보다, 공통 래퍼 컴포넌트를 만들어서 일관된 인터페이스를 제공하는 게 좋다:

tsx
// components/Icon.tsx
import type { SVGProps, FunctionComponent } from "react";

interface IconProps extends SVGProps<SVGSVGElement> {
  icon: FunctionComponent<SVGProps<SVGSVGElement>>;
  size?: number | string;
}

const Icon = ({ icon: SvgIcon, size = 24, ...props }: IconProps) => (
  <SvgIcon width={size} height={size} {...props} />
);

export default Icon;
tsx
// 사용
import ArrowIcon from "./icons/arrow.svg?react";
import CheckIcon from "./icons/check.svg?react";

<Icon icon={ArrowIcon} size={16} className="text-gray-500" />
<Icon icon={CheckIcon} size={20} className="text-green-600" />

barrel export 패턴

아이콘이 많아지면 barrel export로 import 경로를 깔끔하게 유지한다:

typescript
// icons/index.ts
export { default as ArrowIcon } from "./arrow.svg?react";
export { default as CheckIcon } from "./check.svg?react";
export { default as CloseIcon } from "./close.svg?react";
export { default as SearchIcon } from "./search.svg?react";
typescript
// 사용하는 쪽
import { ArrowIcon, CheckIcon } from "@/icons";

단, barrel export는 트리쉐이킹이 제대로 동작하는 번들러(Vite/webpack 5)에서만 쓰는 게 좋다. 그렇지 않으면 하나를 import해도 전체가 번들에 포함될 수 있다.


다른 접근법과 비교

방식동적 스타일링트리쉐이킹설정 난이도번들 크기
img 태그-없음별도 HTTP 요청
인라인 SVG없음코드에 포함
SVG 스프라이트중간전체 스프라이트
SVGR낮음사용분만
아이콘 폰트중간전체 폰트

SVGR의 단점도 있다. 빌드 타임에 변환이 일어나므로 SVG가 매우 많으면 빌드가 느려질 수 있고, 런타임에 동적으로 SVG를 로드하는 경우(사용자 업로드 아이콘 등)에는 적합하지 않다. 이런 경우에는 dangerouslySetInnerHTML이나 별도의 SVG 로더를 써야 한다.


정리

SVGR은 "SVG를 React에서 편하게 쓰고 싶다"는 단순한 욕구에서 출발했지만, 내부적으로는 SVGO 최적화 → AST 변환 → 컴포넌트 래핑이라는 정교한 파이프라인을 거친다. vite-plugin-svgr(v4+)에서는 ?react 접미사로 명시적으로 컴포넌트 변환을 요청하는 방식이 표준이 되었다. icon, ref, memo, replaceAttrValues 등의 옵션을 조합하면 프로젝트에 맞는 아이콘 시스템을 빌드 설정 한 곳에서 제어할 수 있다.


관련 문서