ICO 파일을 헥스 에디터로 열어라. 첫 6바이트:
00 00 01 00 03 00
이것이 전체 파일 헤더다. 00 00은 Reserved 필드 — 항상 0이다. 01 00은 Type 필드 — 1은 이 파일이 아이콘 파일임을 뜻한다(커서 파일은 2를 사용한다). 03 00은 Count 필드 — 리틀 엔디언으로 3을 의미하며, 이 ICO에 세 개의 독립적인 이미지가 포함되어 있음을 나타낸다. 세 개의 독립 이미지 파일을 담는 컨테이너를 기술하는 데 6바이트면 충분하다. 나머지 파일은 해당 이미지들에 대한 메타데이터와 그 뒤를 잇는 이미지 데이터 자체로 구성된다.
ICO는 압축 알고리즘도, 색 공간도 아니다. 그저 컨테이너일 뿐인데, 모든 Windows 머신과 주요 브라우저에서 여전히 매일 쓰이는 가장 작고 원시적인 컨테이너 포맷이다.
ICO의 기원
마이크로소프트는 1985년 Windows 1.0과 함께 ICO를 도입했다. 1985년의 PC 생태계에는 표준화된 이미지 파일이라는 개념 자체가 존재하지 않았다. JPEG도, PNG도, GIF도 없었다. 비트맵 데이터는 순수 픽셀 배열이었고, 각 프로그램이 자체 저장 방식을 처리했다. 마이크로소프트는 프로그램, 폴더, 시스템 UI 요소용 아이콘을 배포할 방법이 필요했다. 요구사항은 단순했다:
- OS가 필요로 하는 크기가 몇 개든 하나의 파일로 관리
- 런타임에 빠른 조회 — OS는 이미지를 디코딩하지 않고도 크기를 알아야 함
- 256KB RAM을 가진 시스템에서도 작은 메모리 점유
해결책은 디렉터리 구조였다. 파일은 남아 있는 이미지 개수를 알리는 헤더로 시작한다. 그 뒤에는 항목 배열이 이어지며, 각 항목은 하나의 이미지 폭, 높이, 비트 심도, 파일 내 오프셋을 기술한다. OS는 디렉터리를 읽고, 현재 디스플레이 컨텍스트에 맞는 항목을 선택한 뒤, 해당 오프셋으로 시크하여 비트맵을 렌더링한다.
이 설계는 ZIP보다 2년, GIF보다 2년, JPEG보다 7년 앞섰다. ICO는 파일시스템이 주목받기 전에 등장한 미니어처 파일시스템이었다.
ICO가 존재하는 이유
아이콘은 하나의 이미지가 아니라 여러 크기를 담은 묶음이다. Windows는 파일 목록에서는 16 x 16, 데스크톱에서는 32 x 32, Explorer 상세 창에서는 48 x 48, "초대형" 보기에서는 256 x 256으로 동일한 아이콘을 렌더링한다. 200% 스케일의 고DPI 디스플레이는 64 x 64 소스로부터 32 x 32 아이콘을 렌더링해야 한다. OS는 상황에 맞는 크기를 순식간에 골라야 한다.
아이콘이 개별 PNG 파일로 저장된다면, OS는 여러 파일을 열고 각각을 디코딩한 뒤 결과를 캐싱해야 한다. ICO를 사용하면 OS는 하나의 파일을 열고 16바이트 디렉터리 항목을 읽은 뒤 비트맵 데이터로 직접 점프한다. 디렉터리는 이 포맷을 자기 기술적(self-describing)으로 만든다. 메타데이터용 디코더가 필요 없다.
파비콘도 마찬가지다. 브라우저가 /favicon.ico를 요청하면, 탭용 16 x 16, 북마크 바용 32 x 32, iOS 홈 화면 단축키용 180 x 180 등 필요할 수 있는 모든 크기를 담은 하나의 파일을 받는다. 브라우저는 이미지 헤더를 파싱하지 않고도 적절한 항목을 선택한다.
파일 헤더
ICO 헤더는 두 부분으로 구성된다: ICONDIR과 ICONDIRENTRY 배열이다.
ICONDIR (6바이트)
Bytes 0-1: Reserved (must be 0)
Bytes 2-3: Type (1 = icon, 2 = cursor)
Bytes 4-5: Count (number of images, little-endian uint16)
모든 ICO 파일은 00 00 01 00으로 시작한다. 다음 두 바이트가 뒤따르는 이미지 개수를 알려준다.
ICONDIRENTRY (각 16바이트)
각 이미지는 하나의 항목을 받는다:
Bytes 0: Width (in pixels; 0 means 256)
Bytes 1: Height (in pixels; 0 means 256)
Bytes 2: ColorCount (0 if more than 256 colors)
Bytes 3: Reserved (always 0)
Bytes 4-5: Planes (1 for icons)
Bytes 6-7: BitCount (bits per pixel: 1, 4, 8, 24, or 32)
Bytes 8-11: BytesInRes (size of image data in bytes, uint32)
Bytes 12-15: ImageOffset (byte offset to image data from file start, uint32)
Width와 Height 필드는 각각 1바이트씩이다. 명시적으로 저장 가능한 최댓값은 255다. 이미지가 256 x 256 픽셀일 때는 필드가 0x00을 저장하며, 디코더는 이를 256으로 해석한다. 이 특이점은 1985년 스펙 수립 이후 변경되지 않았다.
다음은 세 개의 이미지 — 16 x 16 BMP, 32 x 32 BMP, 256 x 256 PNG —를 담은 ICO의 디렉터리 모습이다:
Offset 0x00: 00 00 01 00 03 00 -- ICONDIR: 3 images
Offset 0x06: 10 10 00 00 01 00 18 00 2C 01 00 00 16 00 00 00 -- 16x16, 24bpp, BMP, 300 bytes at 0x16
Offset 0x16: 20 20 00 00 01 00 18 00 A8 02 00 00 42 01 00 00 -- 32x32, 24bpp, BMP, 680 bytes at 0x142
Offset 0x26: 00 00 00 00 01 00 20 00 B4 1C 00 00 EA 03 00 00 -- 256x256, 32bpp, PNG, 7348 bytes at 0x3EA
OS는 디렉터리를 읽고 필요에 맞는 항목을 찾은 뒤 ImageOffset으로 바로 이동한다. 별도의 파싱이나 추측 없이 메모리에 직접 접근하는 방식이다.
PNG, WebP, JPEG이 ICO를 대체하지 못한 이유
모든 현대적 기준에서 PNG는 ICO의 내부 비트맵 인코딩보다 우수한 포맷이다. PNG는 더 나은 압축률, 진정한 알파 투명도, 널리 보급된 툴체인을 갖추고 있다. WebP는 더 작다. JPEG는 사진 처리에 특화되어 있다. 그럼에도 ICO는 남아있다. 세 가지 이유가 있다:
1. 단일 파일 다중 해상도. PNG는 하나의 이미지를 저장한다. ICO는 임의의 개수를 저장한다. 웹사이트가 PNG 압축 파일 묶음을 제공할 수는 있지만, 어떤 브라우저도 파비콘을 위해 그 안에서 적절한 이미지를 고를 방법을 모른다. ICO의 디렉터리 구조가 이를 6바이트로 해결한다.
2. 시스템 API 결합. Windows의 LoadIcon, ExtractIcon, SHGetFileInfo는 모두 ICO 데이터를 기대한다. Win32 API에는 PNG 아이콘 컨테이너에 대응하는 것이 없다. 이를 바꾸면 1985년 이후 컴파일된 모든 Windows 애플리케이션이 깨진다. 마이크로소프트는 하위 호환성을 포기하지 않는다.
3. 파비콘 표준. HTML의 <link rel="icon"> 태그는 PNG, SVG, ICO를 모두 허용하지만, /favicon.ico에 대한 암묵적 기본 요청은 이들 옵션보다 앞선다. Internet Explorer 5(1999년) 이후 모든 브라우저는 기본적으로 /favicon.ico를 요청한다. 파비콘 링크를 명시적으로 선언하지 않은 사이트는 해당 경로에 ICO가 필요하며, 그렇지 않으면 브라우저는 404를 받는다.
PNG가 ICO를 대체하지 못한 이유는 ICO가 이미지 품질과 경쟁한 적이 없기 때문이다. ICO는 컨테이너 의미론과 경쟁했고, 30년의 운영체제 지원을 가진 동일한 파일 내 디렉터리 모델을 제공하는 다른 포맷은 존재하지 않는다.
컨테이너 내부
ICO 파일의 각 항목은 독립적인 이미지를 가리킨다. 이미지 데이터는 두 가지 포맷 중 하나일 수 있다:
BMP 인코딩 (레거시)
Windows Vista 이전에는 모든 ICO 이미지가 비압축 또는 RLE 압축 BMP 비트맵이었다. 저장된 데이터는 DIB(Device Independent Bitmap) — 즉 BITMAPFILEHEADER가 없는 BMP다. BITMAPINFOHEADER로 바로 시작한다:
Bytes 0-3: Header size (40 for BITMAPINFOHEADER)
Bytes 4-7: Width (uint32)
Bytes 8-11: Height (uint32, doubled for XOR + AND masks)
Bytes 12-13: Planes (1)
Bytes 14-15: BitCount (1, 4, 8, 24, or 32)
Bytes 16-19: Compression (0 for uncompressed, 1 or 2 for RLE)
Bytes 20-23: Image size (0 if uncompressed)
Bytes 24-27: X pixels per meter
Bytes 28-31: Y pixels per meter
Bytes 32-35: Colors used
Bytes 36-39: Important colors
DIB 헤더의 Height 필드는 실제 아이콘 높이의 두 배다. 32 x 32 아이콘은 Height 필드에 64를 저장한다. 상위 절반은 XOR 마스크(실제 컬러 이미지)이고, 하위 절반은 AND 마스크(1비트 투명도 비트맵)이다. 알파 채널을 가진 32비트 아이콘의 경우 AND 마스크는 일반적으로 무시된다.
알파가 없는 24비트 이하 BMP 인코딩 아이콘의 경우 AND 마스크가 유일한 투명도 메커니즘이다. AND 마스크의 1은 투명을, 0은 불투명을 의미한다. 이것이 32비트 컬러가 표준화되기 전 Windows가 투명도를 구현한 방식이다.
PNG 인코딩 (Windows Vista+)
Windows Vista부터 ICO 파일은 PNG 인코딩 이미지를 저장할 수 있다. ICONDIRENTRY에 명시된 오프셋의 이미지 데이터는 순수한 PNG 파일 — 자체 89 50 4E 47 시그니처와 완전한 PNG 청크 구조를 포함한다.
256 x 256 아이콘에 이것이 바로 원하는 포맷이다. 256 x 256 32비트 BMP는 비압축 시 약 262KB다. 동일한 이미지를 PNG로 저장하면 일반적으로 20-60KB다. 현대 아이콘 툴은 256 x 256에는 PNG 인코딩 항목을, 더 작은 크기에는 BMP 인코딩 항목을 생성해 호환성과 파일 크기의 최적 균형을 제공한다.
청크 비교
| 인코딩 | 도입 시기 | 압축 방식 | 알파 채널 지원 | 최적 용도 |
|---|---|---|---|---|
| BMP | Windows 1.0 (1985) | 없음 또는 RLE | 1비트 AND 마스크 | 16 x 16 ~ 48 x 48 |
| PNG | Windows Vista (2006) | DEFLATE | 8비트 알파 | 64 x 64 ~ 256 x 256 |
ICO 파일 감지 및 검사
.ico 확장자를 믿지 마라. 첫 6바이트를 읽고 디렉터리를 파싱하라.
TypeScript
interface IcoEntry {
width: number
height: number
bitCount: number
bytesInRes: number
imageOffset: number
}
interface IcoInfo {
valid: boolean
type: "icon" | "cursor" | "unknown"
count: number
entries: IcoEntry[]
}
async function inspectIco(file: File): Promise<IcoInfo> {
const buffer = await file.slice(0, 1024).arrayBuffer()
const bytes = new Uint8Array(buffer)
if (bytes.length < 6)
return { valid: false, type: "unknown", count: 0, entries: [] }
const reserved = bytes[0] | (bytes[1] << 8)
const type = bytes[2] | (bytes[3] << 8)
const count = bytes[4] | (bytes[5] << 8)
if (reserved !== 0 || (type !== 1 && type !== 2)) {
return { valid: false, type: "unknown", count: 0, entries: [] }
}
const entries: IcoEntry[] = []
const dirSize = 6 + count * 16
if (bytes.length < dirSize) {
return { valid: false, type: "unknown", count: 0, entries: [] }
}
for (let i = 0; i < count; i++) {
const off = 6 + i * 16
entries.push({
width: bytes[off] === 0 ? 256 : bytes[off],
height: bytes[off + 1] === 0 ? 256 : bytes[off + 1],
bitCount: bytes[off + 6] | (bytes[off + 7] << 8),
bytesInRes:
bytes[off + 8] |
(bytes[off + 9] << 8) |
(bytes[off + 10] << 16) |
(bytes[off + 11] << 24),
imageOffset:
bytes[off + 12] |
(bytes[off + 13] << 8) |
(bytes[off + 14] << 16) |
(bytes[off + 15] << 24),
})
}
return {
valid: true,
type: type === 1 ? "icon" : "cursor",
count,
entries,
}
}
Python
import struct
from typing import TypedDict
class IcoEntry(TypedDict):
width: int
height: int
bit_count: int
bytes_in_res: int
image_offset: int
class IcoInfo(TypedDict):
valid: bool
type: str
count: int
entries: list[IcoEntry]
def inspect_ico(path: str) -> IcoInfo:
with open(path, "rb") as f:
header = f.read(1024)
if len(header) < 6:
return {"valid": False, "type": "unknown", "count": 0, "entries": []}
reserved, type_, count = struct.unpack("<HHH", header[:6])
if reserved != 0 or type_ not in (1, 2):
return {"valid": False, "type": "unknown", "count": 0, "entries": []}
dir_size = 6 + count * 16
if len(header) < dir_size:
return {"valid": False, "type": "unknown", "count": 0, "entries": []}
entries: list[IcoEntry] = []
for i in range(count):
off = 6 + i * 16
w, h, colors, _, planes, bit_count = struct.unpack("<BBBBHH", header[off:off+8])
bytes_in_res, image_offset = struct.unpack("<II", header[off+8:off+16])
entries.append({
"width": 256 if w == 0 else w,
"height": 256 if h == 0 else h,
"bit_count": bit_count,
"bytes_in_res": bytes_in_res,
"image_offset": image_offset,
})
return {
"valid": True,
"type": "icon" if type_ == 1 else "cursor",
"count": count,
"entries": entries,
}
Go
package main
import (
"encoding/binary"
"fmt"
"os"
)
type IcoEntry struct {
Width int
Height int
BitCount int
BytesInRes uint32
ImageOffset uint32
}
type IcoInfo struct {
Valid bool
Type string
Count int
Entries []IcoEntry
}
func inspectIco(path string) (IcoInfo, error) {
f, err := os.Open(path)
if err != nil {
return IcoInfo{}, err
}
defer f.Close()
buf := make([]byte, 1024)
n, _ := f.Read(buf)
buf = buf[:n]
if n < 6 {
return IcoInfo{Valid: false, Type: "unknown"}, nil
}
reserved := binary.LittleEndian.Uint16(buf[0:2])
typ := binary.LittleEndian.Uint16(buf[2:4])
count := binary.LittleEndian.Uint16(buf[4:6])
if reserved != 0 || (typ != 1 && typ != 2) {
return IcoInfo{Valid: false, Type: "unknown"}, nil
}
dirSize := 6 + int(count)*16
if n < dirSize {
return IcoInfo{Valid: false, Type: "unknown"}, nil
}
entries := make([]IcoEntry, 0, count)
for i := 0; i < int(count); i++ {
off := 6 + i*16
w := buf[off]
h := buf[off+1]
bitCount := binary.LittleEndian.Uint16(buf[off+6 : off+8])
bytesInRes := binary.LittleEndian.Uint32(buf[off+8 : off+12])
imageOffset := binary.LittleEndian.Uint32(buf[off+12 : off+16])
if w == 0 { w = 256 }
if h == 0 { h = 256 }
entries = append(entries, IcoEntry{
Width: int(w),
Height: int(h),
BitCount: int(bitCount),
BytesInRes: bytesInRes,
ImageOffset: imageOffset,
})
}
typStr := "icon"
if typ == 2 {
typStr = "cursor"
}
return IcoInfo{Valid: true, Type: typStr, Count: int(count), Entries: entries}, nil
}
PHP
function inspectIco(string $path): array {
$header = file_get_contents($path, false, null, 0, 1024);
if (strlen($header) < 6) {
return ["valid" => false, "type" => "unknown", "count" => 0, "entries" => []];
}
$reserved = unpack("v", substr($header, 0, 2))[1];
$type = unpack("v", substr($header, 2, 2))[1];
$count = unpack("v", substr($header, 4, 2))[1];
if ($reserved !== 0 || ($type !== 1 && $type !== 2)) {
return ["valid" => false, "type" => "unknown", "count" => 0, "entries" => []];
}
$dirSize = 6 + $count * 16;
if (strlen($header) < $dirSize) {
return ["valid" => false, "type" => "unknown", "count" => 0, "entries" => []];
}
$entries = [];
for ($i = 0; $i < $count; $i++) {
$off = 6 + $i * 16;
$w = ord($header[$off]);
$h = ord($header[$off + 1]);
$bitCount = unpack("v", substr($header, $off + 6, 2))[1];
$bytesInRes = unpack("V", substr($header, $off + 8, 4))[1];
$imageOffset = unpack("V", substr($header, $off + 12, 4))[1];
$entries[] = [
"width" => $w === 0 ? 256 : $w,
"height" => $h === 0 ? 256 : $h,
"bit_count" => $bitCount,
"bytes_in_res" => $bytesInRes,
"image_offset" => $imageOffset,
];
}
return [
"valid" => true,
"type" => $type === 1 ? "icon" : "cursor",
"count" => $count,
"entries" => $entries,
];
}
ImageMagick CLI
magick identify -verbose favicon.ico | grep -E "(Print size|Resolution|Colorspace|Type)"
모든 내부 이미지 나열:
magick identify favicon.ico
특정 크기 추출:
magick favicon.ico[2] extracted-256x256.png
또는 더 간단한 방법:
file favicon.ico
# favicon.ico: MS Windows icon resource - 5 icons, 16x16, 32 bits/pixel, 32x32, 32 bits/pixel
모범 사례 및 사용 사례
ICO는 범용 이미지 포맷이 아니다. 특정 컨텍스트에 맞는 배포 아티팩트다. 컨텍스트가 요구하는 곳에서만 사용하고, 그 외에는 PNG를 사용하라.
파비콘
현대 웹사이트의 경우 두 포맷을 모두 제공하라:
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
브라우저는 지원하는 첫 번째 포맷을 선택한다. SVG를 인식하는 브라우저는 벡터 아이콘을 받는다. 레거시 브라우저와 북마크 유틸리티는 ICO로 폴백한다. ICO 파일은 다음을 포함해야 한다:
| 크기 | 인코딩 | 용도 |
|---|---|---|
| 16 x 16 | BMP | 브라우저 탭, 레거시 IE |
| 32 x 32 | BMP | 작업 표시줄, 북마크 바 |
| 48 x 48 | BMP | Windows 바로가기 |
| 180 x 180 | PNG | iOS 홈 화면 아이콘 |
| 256 x 256 | PNG | Windows Explorer 초대형 보기 |
이 다섯 항목을 담은 파비콘 ICO는 일반적으로 30-50KB다. 256 x 256 PNG가 없으면 10KB 이하로 줄어든다.
Windows 애플리케이션 아이콘
Windows 애플리케이션 실행 파일은 ICO 리소스를 내장한다. OS는 런타임에 내장 디렉터리로부터 적절한 크기를 로드한다. Windows 10 및 11을 대상으로 하는 데스크톱 애플리케이션의 경우 다음을 포함하라:
- 16 x 16, 24 x 24, 32 x 32, 48 x 48 (BMP, 레거시 및 표준 DPI용)
- 64 x 64, 128 x 128, 256 x 256 (PNG, 고DPI 디스플레이용)
Windows는 정확한 크기가 없을 경우 자동으로 축소하지만, 축소는 원본 작은 크기를 렌더링하는 것보다 시각적으로 열등하다. 생략한 각 크기는 시각적 품질을 희생시킨다.
ICO를 사용하지 말아야 할 경우
- 웹 콘텐츠 이미지: WebP, JPEG, PNG를 사용하라. ICO는 압축 이점도 없고 인라인 이미지에 대한 브라우저 네이티브 렌더링 이점도 없다.
- 사진: ICO는 고색상 연속톤 이미지용으로 설계되지 않았다. 파일 크기가 폭발한다.
- 크로스플랫폼 에셋: macOS는
.icns를 사용한다. ICO가 아니다. Linux는 PNG나 SVG를 사용한다. ICO는 Windows 네이티브 포맷으로, 웹에서 우연히도 동작한다.
ICO의 미래
ICO가 곧 사라질 것이란 예측은 대부분 빗나갈 것이다. 이유는 그 기원과 동일하다: 하위 호환성.
SVG 파비콘은 기술적으로 우수하다. 어떤 해상도로든 스케일되고, 어떤 래스터 포맷보다 압축률이 좋으며, 애니메이션과 상호작용성을 지원한다. Chrome, Firefox, Safari는 모두 SVG 파비콘을 지원한다. 그럼에도 ICO는 수백만 대의 기기, 엔터프라이즈 시스템, 레거시 브라우저가 여전히 기본적으로 /favicon.ico를 요청하기 때문에 남아있다. ICO 파일이 없는 사이트는 서버 로그에 404를 생성하고 오래된 소프트웨어에서는 깨진 아이콘을 보여준다.
Windows 11은 여전히 애플리케이션 아이콘, 폴더 아이콘, 시스템 UI에 ICO를 사용한다. Win32 API는 여전히 ICO 데이터를 기대한다. 마이크로소프트는 이 포맷을 대체할 의지를 보이지 않았다 — 30년간의 애플리케이션 호환성을 깰 비즈니스적 이유가 없다.
달라지는 건 ICO 파일을 만드는 방식뿐이다. 현대 툴체인 — IconKitchen, Figma 플러그인, 온라인 생성기 —은 자동으로 PNG 인코딩 고해상도 항목을 포함한 ICO 파일을 출력한다. 컨테이너는 그대로다. 담기는 내용물만 나아지는 것이다.
ICO를 특별히 좋아하는 사람은 없다. 하지만 어디서나 작동하고, 아무것도 깨뜨리지 않으며, 지원 비용도 들지 않는다. 바로 그 이유로 2035년에도 여전히 남아 있을 것이다.
기존 이미지로부터 ICO 파일을 생성해야 한다면, JPG to ICO가 JPG, PNG, WebP 소스를 브라우저에서 직접 다중 해상도 ICO 파일로 변환한다 — 업로드 없이, 서버 처리 없이. 표준 크기 매트릭스를 생성하고 256 x 256에는 자동으로 PNG 인코딩을, 더 작은 크기에는 BMP 인코딩을 선택해 호환성과 파일 크기의 최적 균형을 제공한다.



