새로운 Remix 프로젝트를 막 시작했다면, 아마 눈에 띄는 빈자리를 발견했을 것입니다. public 폴더 안에 <link> 태그를 작성할 index.html 파일이 없다는 사실 말이죠. Remix 파비콘 설정을 제대로 하려면 문서의 <head>를 다루는 방식에 대한 사고의 전환이 필요합니다. 기존의 React SPA에서는 루트 디렉토리에 .ico 파일 하나 던져두면 끝이었습니다. 하지만 Remix는 문서 메타데이터를 관리하기 위해 라우트 기반의 export를 적극 활용하는 다른 방식을 취합니다.
GitHub의 브라우저 탭을 살펴보세요. 단순한 정적 이미지가 아닙니다. 시스템 테마에 따라 파비콘이 변경되고, 읽지 않은 알림이 있을 때는 파란색 점이 동적으로 표시됩니다. Remix에서 이 정도 수준의 디테일을 구현하려면 프레임워크의 '마법'에 의존해서는 안 됩니다. 에셋을 명시적으로 정의해야 합니다.
Remix의 철학: 암시적 동작보다 명시적 선언
app 디렉토리에서 favicon.ico나 icon.svg 파일을 자동으로 스캔하는 Next.js App Router와 달리, Remix는 명시적인 선언을 선호합니다. 저는 개인적으로 이 방식을 강력히 지지합니다. 마법 같은 파일 라우팅은 잘 작동할 때는 좋지만, 아이콘 캐싱에 문제가 생기는 순간 프레임워크 소스 코드를 뒤져야 하는 상황이 발생하기 때문입니다.
Remix에서는 LinksFunction export를 사용합니다. 이 함수는 Remix가 HTML <link> 태그로 직접 매핑하는 객체 배열을 반환합니다. 파비콘은 애플리케이션의 모든 페이지에 표시되어야 하므로, 이를 배치할 유일한 논리적 위치는 app/root.tsx 파일입니다.
1단계: 에셋 스택 준비하기
코드를 작성하기 전에 실제 이미지 파일이 필요합니다. 2026년에는 15가지나 되는 다양한 크기의 아이콘이 필요하지 않습니다. public 디렉토리에 딱 3개의 특정 파일만 있으면 됩니다.
- /favicon.ico: 레거시 브라우저 및 엄격한 기업 환경을 위한 48x48 폴백(fallback).
- /icon.svg: 확장 가능한 최신 벡터 아이콘.
- /apple-touch-icon.png: iOS 홈 화면 및 Safari 북마크 전용 180x180 PNG.
고해상도 로고만 가지고 있다면 Mzu favicondl을 사용해 보세요. 이 정확한 최신 스택을 생성하고 SVG 파일을 무겁게 만드는 불필요한 메타데이터를 제거해 줍니다.
2단계: root.tsx의 Links Export
app/root.tsx 파일을 엽니다. 기본 Remix 템플릿을 사용했다면 스타일시트를 export하는 links 함수가 이미 있을 것입니다. 이 배열을 확장하여 파비콘 스택을 포함시켜 보겠습니다.
import type { LinksFunction } from '@remix-run/node';
export const links: LinksFunction = () => [
// 레거시 폴백
{ rel: 'icon', href: '/favicon.ico', sizes: '48x48' },
// 최신 확장 가능 아이콘
{ rel: 'icon', href: '/icon.svg', type: 'image/svg+xml' },
// Apple 기기 지원
{ rel: 'apple-touch-icon', href: '/apple-touch-icon.png' }
];Remix는 이 객체들을 가져와 문서의 <head> 내에 렌더링되는 <Links /> 컴포넌트에 주입합니다. 여기서 순서가 중요합니다. 브라우저는 위에서 아래로 읽기 때문에, 최신 브라우저는 SVG를 지원할 경우 올바르게 SVG를 우선시하여 로드합니다.
3단계: 다크 모드 처리
로고가 검은색인 경우, 사용자가 브라우저를 다크 모드로 전환하면 로고가 완전히 사라집니다. Remix의 links 배열은 모든 유효한 HTML 링크 속성을 허용하므로, media 속성을 사용하여 사용자의 시스템 설정에 따라 아이콘을 전환할 수 있습니다.
export const links: LinksFunction = () => [
{ rel: 'icon', href: '/favicon.ico', sizes: '48x48' },
{
rel: 'icon',
href: '/icon-light.svg',
type: 'image/svg+xml',
media: '(prefers-color-scheme: light)'
},
{
rel: 'icon',
href: '/icon-dark.svg',
type: 'image/svg+xml',
media: '(prefers-color-scheme: dark)'
}
];이 방식은 단일 SVG 파일 내에 인라인 CSS를 작성하는 것보다 훨씬 깔끔하며, 브라우저가 실제로 필요한 에셋만 요청하도록 보장합니다.
고급: Loader 데이터를 활용한 동적 파비콘
개발자들이 자주 겪는 함정이 있습니다. links export를 사용하여 읽지 않은 알림 배지를 표시하려고 시도하는 것입니다. 무엇이 문제일까요? LinksFunction은 useLoaderData()에 접근할 수 없습니다. 컴포넌트가 렌더링되기 전에 평가되기 때문입니다.
사용자 상태(예: 읽지 않은 메시지)에 따라 동적인 파비콘이 필요한 경우, 해당 특정 태그에 대해서는 links export를 우회하고 루트 컴포넌트의 <head> 내에 표준 HTML 요소를 수동으로 렌더링해야 합니다.
export default function App() {
const data = useLoaderData<typeof loader>();
const faviconUrl = data.hasUnread ? '/icon-unread.svg' : '/icon.svg';
return (
<html lang='en'>
<head>
<Meta />
<Links />
{/* 동적 파비콘 수동 렌더링 */}
<link rel='icon' href={faviconUrl} type='image/svg+xml' />
</head>
<body>
<Outlet />
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}이 접근 방식은 두 가지 장점을 모두 제공합니다. 정적 에셋(Apple Touch Icon 등)은 깔끔한 links export에 유지하면서, 동적 상태는 React 트리에서 직접 처리할 수 있습니다. 표준적인 React 파비콘 설정에서 넘어온 경우, 이 패턴이 매우 친숙하게 느껴질 것입니다.
최종 테스트
배포 후 브라우저는 캐시된 아이콘을 업데이트하는 데 매우 고집스럽게 굴 수 있습니다. 새로고침을 해도 여전히 이전 Remix 로고가 보인다면 로컬 캐시 문제일 가능성이 높습니다. 코드 디버깅에 시간을 낭비하지 말고 먼저 파비콘 캐시 지우기 방법을 확인하세요.
Remix는 문서의 head를 명시적으로 관리하도록 강제하지만, 이는 궁극적으로 버그를 줄이고 성능을 향상시킵니다. SVG + ICO 폴백 패턴을 고수하고, 정적 에셋에 links export를 활용하면 모든 기기에서 완벽하게 작동하는 전문적인 브라우저 탭을 구현할 수 있습니다.