Next.js에서 SVG 아이콘 제대로 다루기 — CDN 제거부터 next/image 함정까지

DAM·2026년 4월 14일

[Project]

목록 보기
3/10
post-thumbnail

아이콘 36개를 어디서 불러오느냐로 Lighthouse 점수가 87점에서 75점까지 떨어졌습니다. 원인은 loading="lazy" 한 줄이었습니다.

포트폴리오 사이트의 기술 스택 섹션을 개발하면서 아이콘 처리 방식이 성능에 이렇게까지 영향을 미칠 거라고는 생각하지 못했습니다.

처음엔 단순히 타입 안전성을 높이는 리팩토링이었는데,
결국 외부 CDN 의존을 완전히 제거하는 방향으로 이어졌습니다.

이 글은 그 과정을 단계별로 정리한 기록입니다.


1단계 — Type-safe 구조로: SkillId union type 도입

문제

처음 코드는 스킬 이름(string)을 key로 아이콘 URL을 매핑하는 구조였습니다.

// Skills.tsx
const ICON_URL: Record<string, string> = {
    "HTML": `${DI}/html5/html5-original.svg`,
    "React": `${DI}/react/react-original.svg`,
    // ...
};

이 구조는 오타가 발생해도 TypeScript가 잡아주지 못했고, 존재하지 않는 키에 접근해도 컴파일 에러가 없었습니다.

해결

SkillId union type을 정의하고 Record<SkillId, string>으로 타입을 좁혔습니다.

// types/index.ts
export type SkillId =
    | "html"
    | "css"
    | "javascript"
    | "typescript"
    | "react"
    | "nextjs"
    // ...

export interface Skill {
    id: SkillId;
    name: string;
    color: string;
}
// Skills.tsx
const ICON_URL: Record<SkillId, string> = {
    html: `${DI}/html5/html5-original.svg`,
    react: `${DI}/react/react-original.svg`,
    // ...
};

효과: 오타 시 즉시 컴파일 에러. 존재하지 않는 id 접근 차단.


2단계 — 데이터 구조 개선: 아이콘을 데이터 안으로 이동

문제

스킬 데이터(portfolio.ts)와 아이콘 URL(Skills.tsx)이 분리되어 있어서, 스킬을 추가할 때 두 파일을 동시에 수정해야 했습니다.

portfolio.ts  → 스킬 데이터 (name, color)
Skills.tsx    → 아이콘 매핑 (ICON_URL)

유지보수할 때 둘 중 하나를 빠뜨리면 아이콘이 깨지는 문제가 있었습니다.

해결: 데이터 colocation 구조

Skill 인터페이스에 icon 필드를 추가하고, 아이콘 URL을 데이터 안에 포함시켰습니다.

// types/index.ts
export interface Skill {
    id: SkillId;
    name: string;
    color: string;
    icon?: string; // 추가
}
// portfolio.ts
export const skillGroups: SkillGroup[] = [
    {
        category: "Language",
        skills: [
            { id: "html", name: "HTML", color: "#e34c26", icon: `${DI}/html5/html5-original.svg` },
            { id: "react", name: "React", color: "#61dafb", icon: `${DI}/react/react-original.svg` },
        ],
    },
];
// Skills.tsx — ICON_URL 완전 제거
{skill.icon ? (
    <img src={skill.icon} alt={skill.name} width={14} height={14} />
) : (
    <span style={{ backgroundColor: skill.color }} />
)}

효과: 스킬을 추가할 때 portfolio.ts 한 곳만 수정하면 됩니다.
데이터와 아이콘 정보가 같은 곳에 있으니(data colocation) 누락이 생길 수 없습니다.

ICON_URL을 제거하고 <img> 태그를 직접 쓰게 되면서,
이미지 최적화에 대해 평소 알고 있던 내용을 적용해보고 싶었습니다.

"이미지 태그에는 loading="lazy" 거는 게 성능에 좋다고 했으니까 추가해두자."

<img
    src={skill.icon}
    alt={skill.name}
    width={14}
    height={14}
    loading="lazy" // 추가
/>

이미지 성능 최적화를 공부하다 보면 loading="lazy"가 자주 등장합니다.
스크롤 아래 이미지를 뷰포트에 가까워질 때까지 로드를 미뤄서 초기 로딩을 빠르게 만드는 기법인데, 문제는 이걸 14×14px짜리 아이콘에도 그대로 적용했다는 점입니다.

loading="lazy"가 효과적인 상황은 따로 있습니다.

  • 뷰포트 아래에 있는 크고 무거운 이미지
  • 초기 로딩 시 굳이 가져올 필요 없는 이미지

Skills 섹션의 아이콘은 달랐습니다. 크기가 14x14px라 용량 자체가 작고, Lighthouse가 모바일 에뮬레이션으로 측정할 때 측정이 끝나기 전에 로드가 완료되지 않아 미완성 상태로 점수에 반영됩니다. 결과는 87점 → 75점, 12점 하락이었습니다.

loading="lazy"를 제거하자 점수는 82점으로 회복됐지만, 초기 87점으로 돌아오지 않았습니다. lazy가 문제가 아니라 외부 CDN 요청 자체가 병목이었다는 걸 파악하게 됐습니다.


3단계 — 성능 개선: 외부 CDN → 로컬 SVG

문제

아이콘 URL이 두 개의 외부 CDN을 사용하고 있었습니다.

const DI = "https://cdn.jsdelivr.net/gh/devicons/devicon@latest/icons";
const SI = "https://cdn.simpleicons.org";

총 33개의 외부 이미지 요청이 발생하고 있었고, Lighthouse 성능 점수가 82점에서 더 오르지 않았습니다.

원인 분석

외부 CDN 요청의 문제는 단순히 파일을 가져오는 게 아닙니다. 각 도메인마다 아래 과정이 필요합니다.

cdn.jsdelivr.net  : DNS 조회 → TCP 연결 → TLS 핸드셰이크 → 요청
cdn.simpleicons.org: DNS 조회 → TCP 연결 → TLS 핸드셰이크 → 요청

preconnect로 사전 연결을 맺어도, 33개 요청 자체가 네트워크 비용이었습니다. 특히 Lighthouse가 모바일 에뮬레이션으로 측정할 때 외부 네트워크 지연이 점수에 직접 반영됩니다.

해결 — 아이콘 로컬 저장

모든 SVG 아이콘을 /public/icons/에 다운로드했습니다.

# 터미널에서 일괄 다운로드
DI="https://cdn.jsdelivr.net/gh/devicons/devicon@latest/icons"
SI="https://cdn.simpleicons.org"
OUT="./public/icons"

curl -s -o "$OUT/html.svg"       "${DI}/html5/html5-original.svg"
curl -s -o "$OUT/react.svg"      "${DI}/react/react-original.svg"
curl -s -o "$OUT/mobx.svg"       "${SI}/mobx/ff7a00"
# ... (36개 전체)

이후 portfolio.ts의 icon 경로를 로컬로 전환했습니다.

// Before
{ id: "html", name: "HTML", color: "#e34c26", icon: `${DI}/html5/html5-original.svg` }

// After
{ id: "html", name: "HTML", color: "#e34c26", icon: "/icons/html.svg" }

CDN 상수와 preconnect 힌트도 모두 제거했습니다.

// 제거
const DI = "https://cdn.jsdelivr.net/gh/devicons/devicon@latest/icons";
const SI = "https://cdn.simpleicons.org";
// layout.tsx에서 제거
<link rel="preconnect" href="https://cdn.jsdelivr.net" />
<link rel="preconnect" href="https://cdn.simpleicons.org" />

결과

단계별 변화를 정리하면 아래와 같습니다.

단계변경 내용Lighthouse
초기외부 CDN 아이콘, loose 타입87
lazy 추가 (실수)loading="lazy" 추가 → 측정 시점에 미로드75
부분 수정preconnect + fetchPriority82
로컬 전환SVG 로컬 저장, CDN 제거87+

배운 점

loading="lazy"는 크기와 위치를 먼저 따져야 한다

14×14px 아이콘처럼 용량이 작고 페이지 중간에 위치한 이미지에는 오히려 역효과가 납니다. 뷰포트 아래 멀리 있는 크고 무거운 이미지에만 사용해야 합니다.

외부 CDN은 파일 수와 배포 환경을 고려해야 한다

아이콘 36개처럼 파일 수가 적고 Vercel처럼 자체 엣지 서빙이 되는 환경에서는 외부 CDN보다 로컬 파일이 더 안정적입니다.

next/image는 SVG에 최적화가 적용되지 않는다

SVG는 WebP 변환이 불가능하고 보안상 기본 차단됩니다. 14px 아이콘에는 width/height를 명시한 <img>가 더 적합합니다.


🔍 번외 — next/image vs <img>: SVG 아이콘에는 무엇이 맞을까?

로컬 SVG로 전환하고 나서 한 가지 의문이 더 생겼습니다.
"Next.js 프로젝트니까 이미지는 next/image로 통일하는 게 맞지 않을까?"
검토해보니 이 경우엔 오히려 잘못된 선택이었습니다.


next/image의 핵심 기능

next/image가 제공하는 주요 최적화는 세 가지입니다.

  • PNG/JPG → WebP/AVIF 자동 변환
  • 뷰포트 기반 사이즈 리사이징
  • Lazy loading 및 CLS(Cumulative Layout Shift) 방지

SVG에는 최적화가 적용되지 않음

SVG는 벡터 포맷이라 WebP 변환이나 사이즈 리사이징이 불가능합니다. next/image를 사용해도 SVG는 원본 파일 그대로 서빙됩니다.

PNG/JPG → next/image → WebP 변환 ✅ 최적화됨
SVG     → next/image → SVG 그대로 ❌ 최적화 없음

next/image는 SVG를 기본적으로 차단

SVG 내부에 <script>가 포함될 수 있어 보안상 이유로, next/image는 SVG를 기본적으로 차단합니다. 제대로 사용하려면 next.config.ts에 별도 설정이 필요합니다.

// next.config.ts
const nextConfig: NextConfig = {
    images: {
        dangerouslyAllowSVG: true, // 명시적으로 허용해야 함
    },
};

이 설정 없이 SVG를 next/image에 넘기면 내부적으로 unoptimized 처리되거나 예상치 못한 동작이 발생할 수 있습니다.

14×14px 아이콘에 next/image는 오버엔지니어링

next/image가 진가를 발휘하는 건 히어로 이미지, 프로젝트 썸네일처럼 크고 무거운 이미지일 때입니다. 14px짜리 아이콘 36개에 적용하면 오히려 불필요한 JS 번들 비용만 추가됩니다.

next/image<img>
WebP 변환SVG는 해당 없음동일
사이즈 최적화14px에서 의미 없음동일
CLS 방지width/height 명시하면 해결width/height 명시하면 해결
JS 번들 비용있음없음
eslint-disable 필요없음필요

CLS 방지는 widthheight를 명시하는 것만으로 충분합니다. next/image 없이도 동일한 효과를 얻을 수 있습니다.

최종 코드

// ✅ 로컬 SVG 아이콘 — 일반 <img> 사용
{skill.icon ? (
    // eslint-disable-next-line @next/next/no-img-element
    <img
        src={skill.icon}
        alt={skill.name}
        width={14}
        height={14}
        style={{ width: 14, height: 14, flexShrink: 0 }}
    />
) : (
    <span
        className="w-2 h-2 rounded-full flex-shrink-0"
        style={{ backgroundColor: skill.color }}
    />
)}

이미지 종류별 권장 방법 정리

이미지 종류권장
프로젝트 썸네일 (JPG/PNG, 큰 이미지)next/image
스킬 아이콘 (SVG, 14px)<img>

next/image는 강력한 도구지만, 모든 이미지에 무조건 적용하는 게 정답은 아닙니다. 이미지의 포맷과 크기, 역할에 따라 적절한 방법을 선택하는 것이 중요합니다.

그래서 이 과정이 의미있었나?

최종 점수는 처음과 같은 87점입니다. 솔직히 말하면 저도 이 질문을 스스로 했습니다.
하지만 CDN을 이용한 87점과 지금의 87점은 다릅니다.

CDN 87점은 외부 네트워크 상태에 따라 달라질 수 있지만, 로컬 SVG 87점은 외부 요인 없이 항상 같은 점수가 나옵니다.
그리고 Lighthouse 점수와 무관하게, SkillId union type과 data colodation은 코드를 더 안전하고 유지보수하기 쉽게 만들었습니다.
스킬을 추가할 때 파일 두 곳을 동시에 수정하지 않아도 되고, 오타가 생기면 즉시 컴파일 에러로 잡힙니다.

점수는 같아도 코드는 달라졌습니다.


마치며

아이콘 36개의 로딩 방식만으로도 Lighthouse 점수가 10점 이상 차이 날 수 있음을 확인했습니다.
성능 최적화는 대규모 아키텍처 개선뿐 아니라, 이처럼 작은 리소스 처리 방식의 선택에서도 크게 좌우된다는 점을 다시 느꼈습니다.

profile
🌐 DOM 위에서 살아남기

0개의 댓글