북마크 매니저, 링크 디렉터리, 또는 다른 웹사이트를 표시하는 대시보드를 만들어 보신 적 있을 겁니다. 목록을 깔끔하게 보이도록 각 사이트의 favicon을 표시하고 싶지만, 모든 URL에 대해 무작정 /favicon.ico를 요청하는 방법은 안정적이지 않습니다. 많은 사이트가 SVG 아이콘을 사용하거나, 사용자 지정 경로를 쓰거나, 기본 루트 파일 대신 조건부 <link> 태그에 의존하기 때문입니다.

이 튜토리얼에서는 HTML에서 <link rel="icon"> 선언을 파싱하고, 기존 /favicon.ico 엔드포인트로 폴백(fallback)한 뒤, 응답을 검증하여 바이너리를 저장하는 PHP get favicon from URL 워크플로를 단계별로 살펴봅니다.

사전 준비 사항

PHP 7.4 이상과 다음 두 가지 확장이 활성화되어 있어야 합니다.

두 확장은 공식 PHP Docker 이미지와 대부분의 Linux 배포판에 기본 포함되어 있습니다. 코딩을 시작하기 전에 php -m 명령으로 두 확장이 목록에 표시되는지 확인해 주세요.

1단계: 페이지 HTML 가져오기

기본 cURL 요청부터 시작합니다. User-Agent 헤더는 생각보다 중요합니다. 일부 사이트는 브라우저가 아닌 클라이언트에 대해서는 단순화된 HTML을 반환하며, 이 경우 favicon <link> 태그가 아예 누락될 수 있습니다.

<?php
function fetchHtml(string $url): string|false {
    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_FOLLOWLOCATION => true,
        CURLOPT_MAXREDIRS      => 5,
        CURLOPT_TIMEOUT        => 10,
        CURLOPT_USERAGENT      => 'Mozilla/5.0 (compatible; FaviconFetcher/1.0)',
        CURLOPT_SSL_VERIFYPEER => true,
    ]);
    $html = curl_exec($ch);
    curl_close($ch);
    return $html;
}
?>

리디렉션과 타임아웃에는 항상 상한을 두세요. 30초짜리 cURL 요청이 제어 없이 이어지면 PHP-FPM 워커 풀이 금방 가득 차게 됩니다.

2단계: rel="icon" 태그를 위해 HTML 파싱하기

HTML 명세는 favicon을 선언하는 방법으로 rel="icon"을 정의하고 있습니다(WHATWG 링크 관계 참조 참고). 브라우저는 일치하는 <link> 중 어느 것이든 선택할 수 있으므로, 우리는 모든 항목을 스캔하고 사용 가능한 href를 가진 첫 번째 항목을 우선 사용합니다.

<?php
function findIconUrl(string $baseUrl, string $html): ?string {
    libxml_use_internal_errors(true);
    $dom = new DOMDocument();
    $dom->loadHTML($html);
    libxml_clear_errors();

    $xpath = new DOMXPath($dom);
    $links = $xpath->query('//link[@rel="icon" or @rel="shortcut icon"]');

    foreach ($links as $link) {
        $href = $link->getAttribute('href');
        if ($href === '') continue;
        return resolveUrl($baseUrl, $href);
    }
    return null;
}
?>

여기서 shortcut icon 항목도 함께 포함했습니다. 이는 구 Microsoft IE의 잔재이지만, 의외로 많은 레거시 사이트가 아직도 이 방식으로 favicon을 선언하고 있습니다.

Step 3: 상대 URL 해석하기

Favicon의 href는 /static/icon.png나 icons/favicon.svg처럼 상대 경로인 경우가 많습니다. 다운로드 전에 parse_url과 간단한 helper 함수를 함께 사용해 절대 URL로 변환합니다.

<?php
function resolveUrl(string $base, string $ref): string {
    if (parse_url($ref, PHP_URL_SCHEME) !== null) {
        return $ref; // already absolute
    }
    $parts = parse_url($base);
    $origin = ($parts['scheme'] ?? 'https') . '://' . ($parts['host'] ?? '');
    if (str_starts_with($ref, '/')) {
        return $origin . $ref;
    }
    $path = $parts['path'] ?? '/';
    $dir = substr($path, 0, strrpos($path, '/') + 1);
    return $origin . $dir . $ref;
}
?>

Step 4: /favicon.ico로 fallback 처리

HTML에 <link rel="icon"> 태그가 전혀 없더라도, 모든 브라우저는 자동으로 /favicon.ico를 요청합니다. 레거시 사이트에서 빈 아이콘이 반환되지 않도록 이 엔드포인트를 fallback으로 호출합니다.

<?php
function fallbackIconUrl(string $pageUrl): string {
    $parts = parse_url($pageUrl);
    return ($parts['scheme'] ?? 'https') . '://' . ($parts['host'] ?? '') . '/favicon.ico';
}
?>

Step 5: 바이너리 다운로드 및 검증

이제 아이콘을 다운로드하고, 실제로 이미지가 반환되었는지 검증한 뒤 디스크에 저장합니다. HTML 응답을 재사용하지 말고 반드시 cURL로 다시 가져와야 합니다. favicon URL은 서로 다른 도메인이나 캐싱 규칙이 다른 CDN에 위치할 수 있기 때문입니다.

<?php
function downloadFavicon(string $iconUrl, string $savePath): bool {
    $ch = curl_init($iconUrl);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_FOLLOWLOCATION => true,
        CURLOPT_MAXREDIRS      => 5,
        CURLOPT_TIMEOUT        => 10,
        CURLOPT_USERAGENT      => 'Mozilla/5.0 (compatible; FaviconFetcher/1.0)',
        CURLOPT_SSL_VERIFYPEER => true,
    ]);
    $data = curl_exec($ch);
    $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    $type = curl_getinfo($ch, CURLINFO_CONTENT_TYPE) ?? '';
    curl_close($ch);

    if ($data === false || $code !== 200 || $data === '') {
        return false;
    }
    // Reject obvious non-image responses (HTML error pages, JSON, etc.)
    if (stripos($type, 'image/') !== 0 && stripos($type, 'application/octet-stream') !== 0) {
        return false;
    }
    return file_put_contents($savePath, $data) !== false;
}
?>

PHP URL 파비콘 가져오기 파이프라인 구성하기

전체 흐름은 다음과 같습니다. HTML을 가져오고, link 태그를 파싱한 뒤, 값이 없으면 루트 아이콘으로 대체한 다음 저장합니다.

<?php
function getFaviconFromUrl(string $pageUrl, string $savePath): bool {
    $html = fetchHtml($pageUrl);
    if ($html === false) return false;

    $iconUrl = findIconUrl($pageUrl, $html) ?? fallbackIconUrl($pageUrl);
    return downloadFavicon($iconUrl, $savePath);
}
?>

호출 예시는 다음과 같습니다.

<?php
$ok = getFaviconFromUrl('https://example.com', __DIR__ . '/example.ico');
echo $ok ? 'Saved.' : 'Failed.';
?>

자주 발생하는 문제점

전용 도구로 전환할 시점

수천 개 정도의 URL이라면 직접 작성한 PHP 함수로도 충분합니다. 하지만 ICO, PNG, SVG, Apple Touch 아이콘 같은 멀티 포맷 출력, 다크 모드 인식, maskable manifest 아이콘, 혹은 특이한 <link> 구조를 가진 사이트까지 안정적으로 대응해야 할 때가 되면, cURL 코드는 유지보수 부담이 됩니다. Mzu favicondl 같은 도구는 모든 브라우저와 디바이스에 걸친 파싱, 폴백, 정규화, 패키징을 처리해 주므로 보일러플레이트를 걷어내고 제품 개발에 집중할 수 있습니다.

이 수동 방식을 인기 있는 공개 favicon API 엔드포인트와 비교해 보면, 어떤 트레이드오프가 자신의 스택에 더 맞는지 판단할 수 있습니다. 수동 페칭은 완전한 제어권을 주는 반면, API는 외부 종속성을 감수하는 대신 시간을 절약해 줍니다.