lucide-react
React 프로젝트에서 아이콘을 사용하는 방법은 여러 가지가 있다. 이미지 파일(png, svg)을 <img> 태그로 넣거나, 아이콘 폰트(Font Awesome 등)를 로드하거나, SVG를 인라인으로 직접 넣거나. 각각 문제가 있다.
이미지 파일은 네트워크 요청이 아이콘 수만큼 발생하고, 색상이나 크기를 CSS로 자유롭게 제어하기 어렵다. 아이콘 폰트는 사용하지 않는 아이콘까지 전부 로드해야 해서 번들 크기가 커진다. SVG 인라인은 유연하지만 JSX에 복잡한 <svg> 마크업이 반복되면 가독성이 떨어진다.
이런 문제를 해결하는 접근이 SVG 아이콘을 React 컴포넌트로 래핑하는 아이콘 라이브러리다. lucide-react는 이 접근법을 채택한 대표적인 라이브러리로, 1000개 이상의 아이콘을 개별 React 컴포넌트로 제공하면서 트리쉐이킹을 완벽하게 지원한다.
Feather Icons에서 Lucide로
lucide-react의 역사를 이해하려면 Feather Icons부터 알아야 한다. Feather Icons는 깔끔하고 미니멀한 디자인으로 인기를 끌었던 오픈소스 아이콘 세트다. 24×24 그리드 기반, stroke 2px, rounded line cap이라는 일관된 디자인 규칙을 갖고 있었다.
문제는 프로젝트 관리였다. 300개 이상의 이슈가 방치되고, 100개 이상의 PR이 머지되지 않은 채 쌓여갔다. 사실상 프로젝트가 abandoned 상태가 된 것이다. 커뮤니티 기여자들이 시간을 투자해서 새 아이콘을 만들어도 반영될 가능성이 없었다.
Lucide는 이런 상황에서 커뮤니티가 주도해서 만든 Feather Icons의 포크다. 기존 아이콘을 모두 유지하면서(일부는 이름 변경) 500개 이상의 새 아이콘을 추가했고, 현재 1000개 이상의 아이콘을 보유하고 있다. Feather의 디자인 철학—미니멀하고 일관된 스트로크 기반 디자인—을 그대로 계승하면서 적극적으로 유지보수되고 있다.
설치와 기본 사용법
pnpm add lucide-react
아이콘은 Named Export로 가져온다. 각 아이콘 이름은 PascalCase다.
import { Camera, Heart, Search } from 'lucide-react';
function App() {
return (
<div>
<Camera />
<Heart />
<Search />
</div>
);
}
각 아이콘은 인라인 <svg> 요소를 렌더링하는 React 컴포넌트다. 별도의 네트워크 요청 없이 DOM에 직접 SVG가 삽입되므로, 아이콘 폰트처럼 외부 리소스 로딩을 기다릴 필요가 없다.
Props로 커스터마이징하기
lucide-react 아이콘 컴포넌트는 네 가지 주요 props를 받는다.
<Camera
size={48} // 아이콘 크기 (기본값: 24)
color="red" // 색상 (기본값: currentColor)
strokeWidth={1.5} // 선 굵기 (기본값: 2)
absoluteStrokeWidth // 크기에 관계없이 선 굵기 고정
/>
size
width와 height를 동시에 설정한다. 기본값은 24(px). 숫자 또는 문자열("2rem")을 넘길 수 있다.
color
SVG의 stroke 색상을 결정한다. 기본값이 currentColor이므로, 부모 요소의 CSS color 속성을 자동으로 상속한다. 이 덕분에 별도로 color를 지정하지 않아도 텍스트 색상과 아이콘 색상이 자연스럽게 맞는다.
// 부모의 text-color를 따라감
<p className="text-blue-500">
<Search /> 검색
</p>
// 직접 지정
<Heart color="#ff6b6b" />
strokeWidth
선의 굵기를 조절한다. 기본값은 2. 값을 낮추면 더 가는 선으로, 높이면 더 굵은 선으로 렌더링된다.
absoluteStrokeWidth
이 prop이 핵심적으로 중요한 이유가 있다. SVG에서 stroke-width는 viewBox 기준이다. 아이콘의 size를 키우면 선 굵기도 비례해서 두꺼워진다. size={48}로 두 배 키우면 선도 두 배 두꺼워지는 것이다.
absoluteStrokeWidth를 사용하면 아이콘 크기와 무관하게 선 굵기가 일정하게 유지된다. 내부적으로 strokeWidth 값을 size에 반비례하도록 자동 계산해주는 방식이다.
// size를 키워도 선 굵기는 동일
<Camera size={16} absoluteStrokeWidth />
<Camera size={24} absoluteStrokeWidth />
<Camera size={48} absoluteStrokeWidth />
큰 아이콘과 작은 아이콘을 혼용할 때 시각적 일관성을 유지하는 데 유용하다.
SVG 프레젠테이션 속성
lucide-react 컴포넌트는 모든 SVG 프레젠테이션 속성도 props로 받는다. fill, opacity, className 등을 직접 전달할 수 있다.
<Heart
fill="red"
stroke="red"
className="animate-pulse"
/>
Tailwind CSS와도 자연스럽게 조합된다.
<Search className="w-6 h-6 text-gray-400 hover:text-gray-600 transition-colors" />
트리쉐이킹: 왜 번들이 작은가
lucide-react의 가장 중요한 설계 결정은 각 아이콘을 독립적인 ES Module로 내보내는 구조다. 이것이 react-icons 같은 라이브러리와의 가장 큰 차이점이다.
트리쉐이킹이란
트리쉐이킹은 번들러(webpack, Vite/Rollup 등)가 실제로 사용되지 않는 코드를 최종 번들에서 제거하는 최적화 기법이다. ES Module의 정적 import/export 구문을 분석해서, 어떤 export가 실제로 import되어 사용되는지 파악한 후 나머지를 제거한다.
lucide-react의 모듈 구조
lucide-react/
├── dist/
│ ├── esm/
│ │ ├── icons/
│ │ │ ├── camera.js // Camera 아이콘만
│ │ │ ├── heart.js // Heart 아이콘만
│ │ │ ├── search.js // Search 아이콘만
│ │ │ └── ... (1000+ 파일)
│ │ └── lucide-react.js // barrel export
│ └── cjs/
│ └── ...
각 아이콘이 개별 파일로 존재하기 때문에, import { Camera } from 'lucide-react'를 하면 번들러가 camera.js만 포함하고 나머지 999개 이상의 아이콘 파일은 완전히 제거한다.
react-icons와의 비교
react-icons는 여러 아이콘 세트(Font Awesome, Material Design 등)를 하나의 패키지로 묶어놓은 메타 라이브러리다. 편리하지만 구조적 차이가 있다.
// react-icons - 패키지별로 import
import { FaCamera } from 'react-icons/fa';
react-icons도 트리쉐이킹을 지원하지만, 각 아이콘 세트(fa, md 등) 단위로 묶여 있어서 세트 내 모든 아이콘이 로드될 수 있다. 실제로 Next.js 같은 환경에서 번들 크기가 예상보다 커지는 경우가 보고되곤 한다.
lucide-react는 단일 아이콘 세트에 집중하면서 모듈 구조를 처음부터 트리쉐이킹에 최적화했다. 아이콘 하나당 약 200400바이트(gzip 기준) 수준으로, 10개를 써도 34KB 이내로 유지된다.
Heroicons와의 비교
Heroicons(@heroicons/react)는 Tailwind CSS 팀이 만든 아이콘 라이브러리로, 역시 개별 컴포넌트 export 구조라 트리쉐이킹이 잘 된다. 다만 아이콘 수가 약 300개로 Lucide(1000개+)보다 적다. Heroicons는 outline과 solid 두 가지 스타일을 제공하고, Lucide는 stroke 기반 단일 스타일에 집중한다.
동적 아이콘 렌더링
CMS나 데이터베이스에서 아이콘 이름을 문자열로 받아서 렌더링해야 하는 경우가 있다. lucide-react는 DynamicIcon 컴포넌트를 제공한다.
import { DynamicIcon } from 'lucide-react/dynamic';
function IconFromDB({ iconName }: { iconName: string }) {
return <DynamicIcon name={iconName} size={24} />;
}
// 사용
<IconFromDB iconName="camera" />
<IconFromDB iconName="heart" />
주의: DynamicIcon은 내부적으로 모든 아이콘을 import하는 구조다. 트리쉐이킹이 작동하지 않으므로 번들 크기가 크게 증가한다. 정적으로 어떤 아이콘을 쓸지 알 수 있는 경우에는 반드시 Named Import를 사용해야 한다.
동적 렌더링이 정말 필요한 경우, 사용할 아이콘 목록을 미리 정의해서 Map으로 관리하는 방식이 더 낫다.
import { Camera, Heart, Search, Settings, User } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
const iconMap: Record<string, LucideIcon> = {
camera: Camera,
heart: Heart,
search: Search,
settings: Settings,
user: User,
};
function DynamicIconSafe({ name, ...props }: { name: string } & React.ComponentProps<LucideIcon>) {
const Icon = iconMap[name];
if (!Icon) return null;
return <Icon {...props} />;
}
이 방식이면 Map에 등록된 아이콘만 번들에 포함된다.
Lucide Lab: 실험적 아이콘
공식 라이브러리에 없는 아이콘이 필요할 때 Lucide Lab을 사용할 수 있다. 커뮤니티에서 제안되었지만 아직 공식 채택되지 않은 아이콘들이 모여 있다.
import { Icon } from 'lucide-react';
import { coconut } from '@lucide/lab';
function App() {
return <Icon iconNode={coconut} size={24} />;
}
Icon 컴포넌트에 iconNode prop으로 아이콘 데이터를 전달하는 방식이다. 이 iconNode는 SVG path 정보를 담은 배열로, Lucide의 내부 아이콘 표현 형식이다. 커스텀 아이콘을 직접 만들 때도 같은 형식을 사용할 수 있다.
접근성(Accessibility)
lucide-react 아이콘은 기본적으로 aria-hidden="true"가 적용된다. 아이콘이 순수하게 장식적인 용도일 때는 이게 올바른 동작이다. 스크린 리더가 "카메라 SVG 이미지"같은 불필요한 정보를 읽지 않는다.
아이콘이 의미를 전달해야 하는 경우(예: 텍스트 없이 아이콘만 있는 버튼)에는 aria-label을 추가한다.
// 장식적 아이콘 - 옆에 텍스트가 있으므로 aria-hidden 유지
<button>
<Search /> 검색
</button>
// 의미 전달 아이콘 - 텍스트 없으므로 aria-label 필요
<button aria-label="검색">
<Search />
</button>
// 또는 아이콘 자체에 라벨
<button>
<Search aria-label="검색" />
</button>
아이콘 래퍼 패턴
프로젝트에서 아이콘 사이즈, 색상 등의 기본값을 통일하고 싶을 때 래퍼 컴포넌트를 만들면 편하다.
import type { LucideIcon, LucideProps } from 'lucide-react';
interface IconProps extends LucideProps {
icon: LucideIcon;
variant?: 'default' | 'muted' | 'danger';
}
const variantStyles = {
default: 'text-foreground',
muted: 'text-muted-foreground',
danger: 'text-destructive',
};
function Icon({ icon: LucideIcon, variant = 'default', className, ...props }: IconProps) {
return (
<LucideIcon
className={`${variantStyles[variant]} ${className ?? ''}`}
size={20}
strokeWidth={1.75}
{...props}
/>
);
}
import { Trash2, Settings } from 'lucide-react';
<Icon icon={Settings} />
<Icon icon={Trash2} variant="danger" />
이 패턴을 사용하면 프로젝트 전체에서 아이콘 스타일을 일관되게 유지할 수 있고, 나중에 아이콘 라이브러리를 교체해야 할 때도 래퍼만 수정하면 된다.
다른 아이콘 라이브러리와 언제 어떤 걸 쓸까
| 라이브러리 | 아이콘 수 | 스타일 | 트리쉐이킹 | 특징 |
|---|---|---|---|---|
| lucide-react | 1000+ | Stroke | ✅ 완벽 | Feather 계승, 미니멀 |
| @heroicons/react | ~300 | Outline/Solid | ✅ 완벽 | Tailwind 팀 제작 |
| react-icons | 40000+ | 다양 | ⚠️ 주의 필요 | 메타 라이브러리, 여러 세트 |
| @phosphor-icons/react | 7000+ | 6가지 weight | ✅ | 풍부한 변형 |
- 미니멀한 UI, 일관된 스트로크 디자인이 목표라면 lucide-react가 적합하다.
- Tailwind 생태계에 올인한 프로젝트라면 Heroicons도 좋은 선택이다.
- 여러 디자인 시스템의 아이콘을 섞어 써야 하면 react-icons가 편하다(번들 크기 관리에 주의).
- 다양한 두께/스타일 변형이 필요하면 Phosphor Icons를 고려한다.
정리
lucide-react가 해결하는 핵심 문제는 명확하다: 많은 수의 아이콘을 제공하면서도 실제로 사용하는 것만 번들에 포함시키는 것. ES Module 기반의 개별 파일 export 구조가 이를 가능하게 하고, currentColor 기본값과 SVG props 전달 덕분에 CSS와 자연스럽게 통합된다.
Feather Icons의 디자인 철학을 계승하면서 커뮤니티 주도로 활발하게 확장되고 있다는 점, 그리고 TypeScript 지원이 완벽하다는 점도 실무에서 선택하는 주요 이유다.