북마크 매니저, 링크 디렉터리, 또는 다른 웹사이트를 표시하는 대시보드를 만들어 보신 적 있을 겁니다. 목록을 깔끔하게 보이도록 각 사이트의 favicon을 표시하고 싶지만, 모든 URL에 대해 무작정 /favicon.ico를 요청하는 방법은 안정적이지 않습니다. 많은 사이트가 SVG 아이콘을 사용하거나, 사용자 지정 경로를 쓰거나, 기본 루트 파일 대신 조건부 <link> 태그에 의존하기 때문입니다.
이 튜토리얼에서는 HTML에서 <link rel="icon"> 선언을 파싱하고, 기존 /favicon.ico 엔드포인트로 폴백(fallback)한 뒤, 응답을 검증하여 바이너리를 저장하는 PHP get favicon from URL 워크플로를 단계별로 살펴봅니다.
사전 준비 사항
PHP 7.4 이상과 다음 두 가지 확장이 활성화되어 있어야 합니다.
curl— 원격 페이지를 가져오고 리디렉션을 처리하기 위해 필요dom— HTML을 파싱하고<link>태그를 추출하기 위해 필요
두 확장은 공식 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.';
?>
자주 발생하는 문제점
- HTML 오류 페이지가 아이콘으로 위장하는 경우. 서버 설정이 잘못되면 favicon이 없을 때 HTML 본문과 함께 200 상태를 반환할 수 있습니다. 항상 content-type 헤더를 확인하고
image/으로 시작하지 않는 응답은 버려야 합니다. - CDN 요청 제한. 서버에서 타사 CDN을 반복적으로 호출하면 IP가 제한되거나 차단될 수 있습니다. 성공한 다운로드는 디스크에 캐시하고, 정중한 요청 간격(호스트당 몇 초 정도면 보통 안전)을 유지하세요.
- SSL 검증 비활성화. 디버깅 중에
CURLOPT_SSL_VERIFYPEER를false로 설정하고 싶어질 수 있지만, 사용자를 MITM 공격에 노출시킵니다. 운영 환경에서는 검증 옵션을 그대로 켜두세요. - 인코딩 엣지 케이스. HTML에 charset meta 태그가 없을 때 DOMDocument는 ISO-8859-1로 폴백하기 때문에, UTF-8 사이트에서는 href 값을 왜곡할 수 있습니다.
<meta charset='UTF-8'>블록을 앞에 붙이거나HTML5DomDocument처럼 UTF-8을 인식하는 파서로 전환하세요. - Same-origin 정책은 서버 측에는 적용되지 않습니다. PHP는 CORS 검사 없이도 모든 공개 favicon을 가져올 수 있습니다. 이 규칙은 프런트엔드 JavaScript에만 적용됩니다.
전용 도구로 전환할 시점
수천 개 정도의 URL이라면 직접 작성한 PHP 함수로도 충분합니다. 하지만 ICO, PNG, SVG, Apple Touch 아이콘 같은 멀티 포맷 출력, 다크 모드 인식, maskable manifest 아이콘, 혹은 특이한 <link> 구조를 가진 사이트까지 안정적으로 대응해야 할 때가 되면, cURL 코드는 유지보수 부담이 됩니다. Mzu favicondl 같은 도구는 모든 브라우저와 디바이스에 걸친 파싱, 폴백, 정규화, 패키징을 처리해 주므로 보일러플레이트를 걷어내고 제품 개발에 집중할 수 있습니다.
이 수동 방식을 인기 있는 공개 favicon API 엔드포인트와 비교해 보면, 어떤 트레이드오프가 자신의 스택에 더 맞는지 판단할 수 있습니다. 수동 페칭은 완전한 제어권을 주는 반면, API는 외부 종속성을 감수하는 대신 시간을 절약해 줍니다.