0. 들어가며
처음에 아이콘 시스템을 설계할 때 “화면에 아이콘이 잘 나타나면 되지 않을까?”라고 생각할 수 있다.
하지만 실제 서비스에서 아이콘은 생각보다 더 많은 요구사항을 만족해야 한다.
- 색상, 크기, 상태를 자유롭게 제어할 수 있어야 한다.
- 다크 모드나 테마에 따라 스타일이 바꿀 수 있어야 한다.
- hover, active, disabled 상태를 반영할 수 있어야 한다.
- 번들 크기는 최대한 작게 유지할 수 있어야 한다.
문제는 이 요구사항들이 서로 잘 충돌한다는 점이다.
스타일 제어를 잘 하려면 SVG를 코드처럼 다뤄야 하고,
반대로 번들 크기를 줄이려면 정적인 리소스처럼 다루는 게 유리하다.
즉, 아이콘 시스템을 설계할 때의 핵심 질문은 단순히 ‘아이콘을 어떻게 렌더링할 것인가?’가 아니다.
실제로 이 질문에 더 가깝다.
스타일 제어를 유지하면서도, 번들 최적화를 어떻게 가져갈 것인가?
이번 시간에는 이 문제를 해결하기 위해 시도했던 두 가지 방식을 정리해보려고 한다.
- 첫 번째 시도: 런타임에서 SVG 문자열을 DOM으로 변환해서 직접 제어하는 방식
- 두 번째 시도: 빌드 타임에서 SVG를 코드로 변환하고, 정적 import로 사용하는 방식
1. 아이콘은 보통 어떻게 사용할까?
아이콘을 사용하는 방식은 크게 세 가지가 있다.
<img />background-image- SVG Component
각 방식은 장단점은 분명하다.
1-1. 가장 단순한 방식: <img />
가장 직관적인 방식이다.
<img src="/icons/home.svg"/>
이 방식은 HTML만으로 아이콘 출력이 가능하고 브라우저 캐싱도 활용할 수 있다.
하지만 한계도 명확하다. SVG 내부의 fill, stroke를 직접 제어하기 어려워
상태에 따라 스타일을 바꾸기 어렵다.
즉, 이 방식은 보여주기에는 좋지만 스타일을 제어하기 어려운 방식이다.
1-2. CSS 기반 방식: background-image
다음은 CSS에 아이콘을 넣는 방식이다.
.icon {
background-image: url("/icons/home.svg");
}
이 방식은 레이아웃에 큰 영향을 주지 않고 아이콘을 배치할 수 있다.
특히 버튼이나 input 같은 UI에서 텍스트와 아이콘을 분리해 다루기 편하다.
하지만 이 방식도 SVG 내부 노드를 직접 컨트롤할 수 없고, 세밀한 스타일링이 어렵다.
1-3. SPA에서 자주 쓰는 방식: SVG 컴포넌트
상태와 스타일 제어가 중요한 순간, SVG를 컴포넌트처럼 다루게 된다.
<Icon name="home" size="24" color="red" />
이 방식은 색상, 크기, 상태를 컴포넌트 props로 제어할 수 있어 매우 유연하다.
다만 잘못 설계하면 아이콘 개수만큼 JavaScript 번들 부담이 커진다.
예를 들어, 아이콘을 모두 컴포넌트로 분리하고 (HomeIcon.vue, UserIcon.jsx 등)
각 아이콘을 props 기반으로 제어하도록 설계하면, 사용성은 좋아진다.
<HomeIcon color="red" />
<UserIcon size="24" />
하지만 이 구조는 “사용하기 쉬운 만큼, 그대로 번들에 포함되는 구조”다.
즉,
- 아이콘 개수 증가 → 컴포넌트 파일 증가
- 컴포넌트 증가 → 번들 크기 증가
결국, 제어를 위해 구조를 확장할수록 번들 비용도 함께 증가하는 구조가 된다.
그럼 아이콘을 어떻게 관리해야 번들 비용도 아끼면서 스타일 커스텀을 자유롭게 할 수 있을까?
2. 첫 번째 시도: SVG를 문자열로 로드해 직접 제어하자
첫 번째 시도의 핵심은 단순하다. “SVG를 “이미지”가 아니라 “DOM”으로 다룬다!”
즉, SVG를 파일로 렌더링하는 것이 아니라
런타임에서 DOM으로 변환하고, 내부 노드를 직접 제어하는 방식이다. (원본 코드)
2-1. 왜 이런 접근을 했을까?
SVG는 XML 기반 구조다.
즉, 문자열로 가져온 뒤 DOM으로 파싱하면 내부 노드에 직접 접근할 수 있다.
// SVG 내부 노드에 접근하는 코드 예시
const parser = new DOMParser()
const doc = parser.parseFromString(svgText,"image/svg+xml")
const svgElement = doc.querySelector("svg")
이렇게 변환하면 SVG는 더 이상 단순한 파일이 아니라, 내부 구조를 직접 조작할 수 있는 DOM 객체가 된다.
그래서 fill, stroke 제어, 특정 노드 선택, 상태 기반 스타일 변경과 같은 세밀한 제어를 할 수 있다.
2-2. 런타임 처리 흐름 보기
외부에서 사용하는 API는 매우 단순하게 구성했다.
<Icon name="home"/>
하지만 내부에서는 다음과 같은 과정이 일어난다.

| 단계 | 동작 |
|---|---|
| SVG Raw 로드 | SVG를 문자열(raw)로 로드한다 |
| DOMParser 파싱 | 문자열을 실제 DOM으로 변환한다 |
| 노드 탐색 및 주입 | [fill], [stroke] 노드를 탐색해 class를 주입한다 |
| 스타일 삽입 & 렌더링 | <style>을 삽입해 스코프 스타일을 적용한다 |
즉, <Icon /> 하나를 렌더링하기 위해
- SVG를 다시 파싱하고
- 내부 구조를 탐색하고
- 스타일을 주입하는 과정이 진행된다.
그리고 이 흐름은 크게 두 단계로 나뉜다.
- 스타일을 적용할 대상 노드를 찾는 단계
- 해당 노드에 스타일을 실제로 적용하는 단계
이제 이 두 과정을 각각 살펴보자.
2-3. 스타일을 적용할 노드를 어떻게 찾을까?
스타일을 적용하려면, 먼저 대상이 되는 노드([fill], [stroke])를 찾아야 한다.
런타임에 파싱된 SVG DOM에서 해당 속성을 가진 노드를 탐색한다.
이 노드들은 실제 색상이 적용되는 지점이다.
이후 class를 주입해, 클래스만으로 스타일을 제어할 수 있는 구조를 만든다.
const paths = svgElement.querySelectorAll("[stroke], [fill]")
for (constpathofpaths) {
// ...속성 확인 로직...
if (hasStroke) path.classList.add("svg-stroke")
if (props.isActive) path.classList.add("active")
}
2-4. 스타일은 어떻게 적용했을까?
이제 class로 제어 지점을 만들었으니, 실제 스타일을 적용해보자.
이 단계에서는 SVG 내부에 <style> 태그를 삽입해, 해당 SVG에만 스타일이 적용되도록 스코프를 격리한다.

위 이미지는 SVG 내부에 <style>이 삽입된 모습이다.
이 방식 덕분에 props 값에 따라 색상, 상태(active 등)를 유연하게 변경할 수 있다.
하지만 이 구조에는 명확한 문제가 있다. 모든 과정이 런타임에 수행된다는 점이다!
3. 그런데 왜 이 방식이 최선이 아니었을까?
첫 번째 시도는 스타일 제어 측면에서는 이점이 있었다.
SVG 내부 노드를 직접 다룰 수 있었고, 상태에 따라 유연하게 스타일을 바꿀 수도 있었다.
그런데 치명적인 문제가 하나 있었다.
이 방식은 구조적으로 트리쉐이킹이 불가능했다.
이건 구현이 조금 부족한 수준의 문제가 아니다. 구조 자체가 번들러 친화적이지 않은 방식이었다.
3-1. 문제의 본질: 아이콘 선택이 런타임에 결정된다
코드를 보면 아이콘의 이름을 문자열로 받아서 동적으로 로드한다.
const loader = resolveSvgLoader(name)
// name 변수는 런타임에 결정됨
여기서 핵심은 name 값이 빌드 타임이 아니라 런타임에 정해진다는 점이다.
번들러 입장에서는 어떤 아이콘이 실제로 쓰일지 알 수 없다.
"home"이 들어올지, 혹은 다른 어떤 값이 들어올지 빌드 시점에서는 판단할 수 없기 때문이다.
그 결과 번들러는 모든 아이콘을 포함할 수 밖에 없다.
즉, 사용한 아이콘이 하나뿐이어도 전체 아이콘 세트가 번들에 남게 된다.
3-2. 왜 모든 아이콘이 번들에 포함될까?
트리쉐이킹은 '사용하지 않는 코드를 제거하는 최적화'다. 그런데 이 최적화가 동작하려면 전제가 하나 있다.
바로 의존성이 빌드 타임에 정적으로 분석 가능해야 한다.
하지만 첫 번째 방식은 정반대였다.
- 참조 방식: 문자열(
name) - 선택 시점: 런타임
- 번들러 분석: 불가능
즉, 캐시를 추가하거나, 탐색 로직을 조금 더 빠르게 만든다고 해서 해결될 문제가 아니었다.
문제는 SVG를 어떻게 파싱하느냐가 아니라, 아이콘을 언제 결정하느냐에 있었다.
문제점
첫 번째 시도는 의존성이 런타임에 결정된다.
그래서 번들러가 사용 여부를 정적으로 분석할 수 없다.
즉, 스타일 제어에는 강하지만 번들 최적화에는 구조적으로 불리하다.
4. 문제의 본질: Runtime이 아니라 Build Time에서 결정해야 한다
첫 번째 시도의 한계를 다시 요약하면 다음과 같다.
"home" → 런타임에 찾음 → 번들러는 모름그렇다면 해결 방향은 자연스럽게 정해진다.
아이콘을 런타임에 찾지 말고, 빌드 타임에 미리 확정해야 한다
즉, 문자열 기반 탐색 구조를 버리고, 정적으로 분석 가능한 구조로 바꿔야 한다.
4-1. 핵심 구조 변경

문자열 기반 참조를 버리고 아이콘을 모듈로 만들어, 정적 import 구조로 전환해야 한다.
결국 이 변화는 단순히 “렌더링 구현 방식”을 바꾸는 문제가 아니었다. 의존성을 언제 확정할 것인가의 문제였다.
5. 두 번째 시도: SVG를 빌드 타임에 코드로 변환하자
이제 방향은 명확하다. SVG 파일 자체를 사용하지 않고, 빌드 스크립트를 통해 컴포넌트 코드로 변환해야 한다.
즉, 아이콘을 런타임에 찾는 것이 아니라, 빌드 타임에 미리 생성해 두고 앱에서는 정적으로 가져다 쓰는 구조로 바꾸는 것이다. (원본 코드)
전체 흐름은 다음과 같다.

각 단계는 다음과 같은 역할을 가진다.
| 단계 | 역할 |
|---|---|
| SVG 파일 수집 (directory scan) | assets 디렉토리를 순회하며 SVG 파일을 수집한다 |
| AST 변환 (svg-parser) | SVG를 DOM이 아닌 분석 가능한 구조(AST)로 변환한다 |
| 메타데이터 생성 | viewBox, path 등 렌더링에 필요한 정보를 추출한다 |
| 컴포넌트 생성 | 메타데이터를 기반으로 Vue/React 컴포넌트 코드를 생성한다 (샘플코드에서는 Vue만 생성) |
Export 등록 (index.ts) |
생성된 컴포넌트를 정적으로 import할 수 있도록 export에 등록한다 |
이 과정을 거치면 SVG가 빌드타임에 코드로 변환된다. 그리고 이 동작은 generate-icons.ts에서 시작한다.
6. generate-icons.ts 기준으로 전체 과정을 따라가보자
이제부터는 두 번째 시도의 구현 흐름을 generate-icons.ts를 기준으로 차근차근 따라가보자.
6-1. 1단계: SVG 파일 목록 읽기
먼저 변환 대상이 되는 SVG 파일을 수집한다.
const svgFiles = fs.readdirSync(ICON_ASSET_PATH);
6-2. 2단계: 문자열을 AST로 변환하기
빌드 타임 환경은 Node.js다. 즉, 브라우저의 DOM이 없다.
그래서 첫 번째 방식처럼 DOMParser를 사용할 수 없다.
이때 필요한 것이 svg-parser 같은 도구다. SVG 문자열을 DOM이 아니라 AST로 변환한다.
| 구분 | DOMParser | AST (svg-parser) |
|---|---|---|
| 실행 시점 | 런타임 | 빌드 타임 |
| 환경 | 브라우저 | Node.js |
| 결과 | DOM 객체 | 순수 데이터 구조 |
즉, 두 번째 방식의 목표는 렌더링 가능한 DOM을 만드는 것이 아니라, 코드 생성이 가능한 구조 데이터를 만드는 것이다.
이 단계에서 실제로 수행하는 작업은 다음과 같다.
/**
* SVG 문자열을 AST로 변환한 뒤, 루트 <svg> 노드를 추출한다.
*/
export function extractSvgTree(raw: string): SvgAstNode | undefined {
const parsed = parse(raw) as { children: SvgAstNode[] };
return parsed.children.find((node) => node.tagName === "svg");
}
이 함수의 역할은 단순하다.
- SVG 문자열을 AST로 변환하고
- 그 중에서
<svg>루트 노드만 추출한다
이렇게 얻은 AST는 이후 단계에서
- path 추출
- viewBox 분석
- 메타데이터 생성
과 같은 작업의 입력 데이터로 사용된다.
6-3. 3단계: 구조를 평탄화하고 메타데이터를 만든다
SVG는 보통 중첩된 트리 구조를 가진다.
* svg
* ├─ g
* │ └─ path
* └─ path이 구조를 그대로 렌더링 코드에 사용하면, 재귀 처리와 분기 로직이 복잡해진다.
그래서 이 단계에서는 구조를 한 번 평탄화한다. 중첩 구조를 “렌더링 가능한 단순 리스트”로 변환한다.
이 과정에서 만들어지는 결과가 바로 메타데이터다.
export const HomeMeta = {
viewBox: "0 0 24 24",
nodes: [
{ tag: "path", attrs: { d: "...", stroke: "#000" } }
]
}
이 메타데이터는
- SVG 구조를 데이터로 표현한 결과이며
- 이후 렌더링 단계에서 그대로 사용되는 입력값이다
즉, 이 시점부터 SVG는 “파일”이 아니라 렌더링 가능한 구조 데이터가 된다.
이 구조를 만들기 위해 사용하는 핵심 함수는 다음과 같다.
export function flattenSvg(svg: SvgAstNode) {
const nodes: Array<{ tag: string; attrs: SvgAttrs }> = [];
const groups = new Set<string>();
collectNodes(svg.children ?? [], nodes, groups);
return {
nodes,
groups: [...groups],
};
}
이 함수의 역할은 명확하다.
- 중첩된 SVG 트리를 순회하고
- 렌더링에 필요한 노드만 추출한 뒤
- 평탄한 구조(
nodes)로 변환한다
결과적으로, 이후 단계에서 복잡한 트리를 다루지 않고 단순한 리스트 기반으로 아이콘을 렌더링할 수 있다.
6-4. 4단계: 아이콘 컴포넌트는 어떻게 생성될까?
앞에서 메타데이터를 만들었다면, 이제 이 데이터를 실제 SVG로 렌더링하는 컴포넌트를 만들어야 한다.
이때 핵심이 되는 함수가 바로 createIconComponent다.
이 함수는 메타데이터를 기반으로 VNode를 생성하고, props에 따라 최종 SVG를 렌더링한다.
6-4-1. createIconComponent 내부는 어떻게 동작할까?
createIconComponent의 핵심 로직만 가져와봤다.
export function createIconComponent(meta: IconMeta) {
return defineComponent({
setup(props) {
const renderNodes = createNodeRenderer(meta);
const renderSvg = createSvgRenderer(meta);
return () => {
const ctx = getRenderContext(props);
const nodes = renderNodes(ctx);
return renderSvg(props, nodes);
};
},
});
}
이 구조에서 중요한 점은 두 가지다. 첫째, SVG를 다시 파싱하지 않는다는 점이다.
첫 번째 방식은 다음 흐름이었다.
SVG 문자열 → DOMParser → DOM → 조작반면 두 번째 방식은 이미 빌드 타임에 만들어 둔 메타데이터를 바탕으로, 렌더링 시점에는 VNode만 생성한다.
즉, 런타임 파싱 비용이 사라진다.
meta → VNode 생성 → 렌더링둘째, 렌더링 방식이 명령형에서 선언형으로 바뀐다는 점이다.
첫 번째 방식은 DOM을 직접 탐색하고 속성을 하나씩 변경했는데, 어떻게 바꿀지 코드로 일일이 명령했다.
반면 두 번째 방식은 최종적으로 어떤 형태의 SVG가 되어야 하는지만 정의한다.
즉, “이 상태라면 이런 결과가 나와야 한다”를 선언하면, 실제 DOM 업데이트는 프레임워크가 알아서 처리한다.
6-4-2. props는 어떻게 반영될까?
메타데이터에는 기본 SVG 속성이 들어 있다.
하지만 실제 사용 시에는 fill, stroke, size 같은 props로 값을 덮어써야 한다.
이를 위해 attrs 레벨에서 merge를 수행한다.
function resolveNodeAttrs(attrs,props) {
return {
...attrs,
fill:props.fill??attrs.fill,
stroke:props.stroke??attrs.stroke,
}
}
이 구조의 장점은 명확하다.
- 원본 SVG 속성은 기본값으로 유지되고
- 필요한 경우 props로 override할 수 있다
즉, 첫 번째 방식처럼 DOM을 파싱한 뒤 class를 주입하고 스타일을 덮어쓰는 것이 아니라,
렌더링 시점에 최종 속성을 직접 결정하는 구조가 된다.
6-4-3. 스타일 적용 방식은 어떻게 달라졌을까?
첫 번째 방식에서는 <style> 태그를 SVG 내부에 주입하고, class 기반으로 스타일을 제어했다.
반면 두 번째 방식에서는 렌더링 시점에 attrs로 직접 값을 넣는다.
h("path", {
d:"...",
fill:props.fill,
})
두 방식을 비교하면 다음과 같다.
| 방식 | 스타일 적용 |
|---|---|
| 첫 번째 방식 | DOM 조작 + style 주입 |
| 두 번째 방식 | props 기반 attrs |
즉, 첫 번째 방식은 스타일을 나중에 덮는 구조였다면, 두 번째 방식은 렌더링 시점에 최종 스타일을 결정하는 구조다.
6-4-4. 성능 관점에서는 어떤 차이가 있을까?
이 구조 변화는 트리쉐이킹만의 문제가 아니다. 런타임 비용에도 차이를 만든다.
첫 번째 방식에서는 매 렌더마다 다음 작업이 수행될 수 있다.
- DOMParser 실행
- querySelector 탐색
- class 주입
- style 태그 생성
반면 두 번째 방식은 이미 준비된 메타데이터를 바탕으로 VNode를 생성하고, 이후는 프레임워크의 Virtual DOM diff에 맡긴다.
6-5. 5단계: 아이콘 컴포넌트를 export한다
메타데이터를 기반으로 실제 Vue 컴포넌트 코드를 생성한 뒤, 각각을 독립된 파일로 저장한다.
그리고 index.ts에서 각각을 export한다.
export { HomeIcon } from "./HomeIcon";
export { UserIcon } from "./UserIcon";이 단계가 중요한데, 각 아이콘이 독립된 모듈이 되어야지만
앱에서 필요한 것만 정적으로 import할 수 있기 때문이다.
6-6. 전체 흐름을 다시 정리해보자
지금까지의 빌드 타임 구조를 한 번에 정리하면 다음과 같다.
SVG 파일
↓
문자열로 읽기
↓
AST 변환
↓
구조 정리 및 메타데이터 생성
↓
아이콘 컴포넌트 생성
↓
export 등록
↓
정적 import 사용이제 앱에서는 필요한 아이콘만 명시적으로 가져다 쓰게 된다.
import { HomeIcon } from"@v-simple/icon/common";
즉, 아이콘은 더 이상 런타임에서 “찾는 대상”이 아니라, 빌드 타임에 생성된 “정적 모듈”이 된다.
7. 그래서 실제로 무엇이 달라졌을까?
이제 두 방식을 실제 결과 기준으로 비교해보자.
아이콘 100개 중 실제로는 1개만 사용하는 극단적인 상황을 만들어보았다.
첫 번째 방식 (런타임)
사용한 아이콘이 하나뿐이어도, 전체 아이콘 세트가 번들에 포함된다. (193KB)
- 1개만 사용해도 100개 전체가 포함됨

두 번째 방식 (빌드 타임)
사용한 아이콘만 번들에 포함된다. (58KB)
- 실제 사용한 1개만 포함됨

차이
첫 번째 방식은 사용 여부와 관계없이 전체가 포함된다.
그래서 아이콘 수가 늘어날수록 불필요한 비용이 계속 누적된다.
반면 두 번째 방식은 사용한 만큼만 포함된다.
즉, 규모가 커질수록 두 방식의 차이는 더 크게 벌어진다.
8. 마치며
이번 글에서는 아이콘 시스템을 설계하며 시도한 두 가지 방식을 살펴보았다.
첫 번째 방식은 아이콘을 문자열 name으로 찾고, 그 결정이 런타임에 이루어진다.
이 구조에서는 번들러가 어떤 아이콘이 사용되는지 알 수 없기 때문에, 모든 아이콘이 번들에 포함된다.
반면 두 번째 방식은 SVG를 빌드 타임에 코드로 변환하고, import로 명시적으로 사용한다.
그 결과 번들러가 의존성을 추적할 수 있고, 실제 사용한 아이콘만 남길 수 있다.
문제는 SVG가 아니라, 결정 시점이었다.
런타임 결정 → 번들러 개입 불가
빌드 타임 결정 → 번들러 최적화 가능아이콘은 화면에서 사소한 요소일 수 있다.
하지만 시스템이 커질수록 함께 증가하고,
결국 관리와 번들 사이즈 문제로 이어진다.
결국 더 나은 시스템은, 언제 결정할 것인가를 고민하는 것에서 시작된다.
'개발 기술 > 개발 이야기' 카테고리의 다른 글
| 내 서비스의 호환성을 지키는 방법? Browserslist, Babel, Polyfill, target 도구 사용하기 (0) | 2026.07.18 |
|---|---|
| 컴포넌트 프리뷰는 어떻게 실행될까? iframe으로 만들어본 프리뷰 런타임 (0) | 2026.05.05 |
| [TS × 클린 아키텍처] 2편 — 타입스크립트 한계와 Mapper: AST로 타입 검증하기 (0) | 2025.10.12 |
| [TS × 클린 아키텍처] 1편 — 타입스크립트 한계와 Mapper: 스키마로 런타임 검증하기 (0) | 2025.09.28 |
| Tailwind 없이, PostCSS+PurgeCSS로 유틸리티 클래스 구축하기 (0) | 2025.05.26 |
댓글