1. 서론
폰트 파일은 생각보다 용량이 큰 편이다.
특히 한글 문자나 한자를 포함하고 있으면 더더욱 그렇다.
예를 들어, Noto Sans KR을 기준으로는 TTF 포맷의 폰트 파일은 대략 10MB 정도를 차지한다.
만약 이 폰트 파일을 정적 파일로 그대로 서빙한다면 사용자는 웹 사이트에 접속 시 10MB 크기의 폰트 파일을 강제로 다운받게 된다.
내가 그 폰트에 포함된 모든 문자를 다 사용하지 않더라도!!!
이것은 굉장히 비효율적이고, 내가 클라우드 서비스를 사용하고 있고 많은 사용자들이 다녀가고 있는 사이트를 배포하고 있다면 폰트 파일을 서빙하는 데에 꽤 많은 리소스를 소모하고 있을 수 있다.
그래서 구글 폰트는 훨씬 더 효율적인 방식을 사용한다.
하나의 페이지 내에 존재하는 문자들이 포함된 작은 청크들을 필요할 때마다 다운받는 것이다.
이를 다이나믹 서브셋 방식이라고 하고, 본 게시글의 제일 마지막 부분에서 설명하게 될 것이다.
2. 압축
다이나믹 서브셋 방식으로 들어가기 전에 가장 먼저 시도해볼 것은 압축률을 높이는 것이다.
최근에는 Brotli 압축 알고리즘과 새로운 폰트 저장 구조를 적용한 WOFF2 포맷이 등장했다.
이것은 압축률이 높아서 웹 폰트를 서빙할 때 흔히 채택되는 방식이라고 한다.
앞서 서론에서 TTF 포맷의 Noto Sans KR 폰트는 대략 10MB를 차지한다고 했던 것을 기억할 것이다.
이 폰트 파일을 WOFF2 포맷으로 압축하게 되면 대략 3.7MB로 줄어든다.
거의 1/3 정도를 줄인 셈이다.
이로써 우리는 대용량 폰트 파일을 최적화하기 시작했다.
3. 서브셋
다음으로는 서브셋을 제작할 수 있다.
서브셋은 기존의 폰트 파일에서 일부를 추출한 집합이라고 보면 된다.
예를 들어, 내가 Noto Sans KR이라는 폰트가 한글 문자에만 적용되기를 원한다면 그 외 문자에 대한 폰트 정보를 가지고 있을 필요가 없기 때문에 한글 문자 부분만 추출해 서브셋을 만들 수 있다.
근데 폰트를 어떻게 쪼개냐고?
당연히 그런 툴들이 오픈 소스로 많이 존재한다.
나는 font-splitter라는 툴을 사용했다.
https://github.com/VdustR/font-splitter
GitHub - VdustR/font-splitter: Split the big font file into small subsets
Split the big font file into small subsets. Contribute to VdustR/font-splitter development by creating an account on GitHub.
github.com
결과적으로 Noto Sans KR에서 한글에 해당하는 부분만 추출해 서브셋을 만들면 대략 1.0MB 정도 크기가 된다.
우리는 최초의 폰트 파일에서 1/10으로 용량을 줄인 것이다!
4. 다이나믹 서브셋
서론에서 구글의 경우에는 다이나믹 서브셋 방식을 적용한다고 얘기했을 것이다.
이 방식은 하나의 폰트를 아주 작은 유니코드 범위로 세세하게 쪼개고 페이지에 존재하는 문자에 따라서 필요한 서브셋 (일종의 청크)만 다운받아오는 방식이다.
이 다이나믹 서브셋을 생성하는 방법은 간단하게 얘기하면 다음과 같다.
1) 폰트를 자주 사용되는 것들끼리 묶어서 Unicode 범위에 따라 작은 서브셋 단위로 쪼갠다 (Noto Sans KR은 124개로 쪼개져 있다).
2) CSS를 작성한다. 일종의 매핑 테이블이라고 볼 수 있다. U1~Un 범위의 문자는 어떤 서브셋 파일을 참고해야 하는지가 적혀져 있다.
실제로 Noto Sans KR 서브셋 CSS는 구글 폰트에서 링크를 제공하기 때문에 훔쳐볼 수 있다.
@Font-Face 구문을 활용해서 작성해 놓은 것을 알 수 있다.
아래의 툴을 이용하면 구글의 다이나믹 서브셋 CSS를 참고해서 폰트 파일을 쪼개는 것이 가능하다.
https://github.com/black7375/font-range
GitHub - black7375/font-range: font subset with google font's ML result
font subset with google font's ML result. Contribute to black7375/font-range development by creating an account on GitHub.
github.com
그러고 나서 불필요한 서브셋들은 CSS와 서브셋 파일 자체를 제거해버리면 된다.
나 같은 경우 한글 문자만 가지고 있는 다이나믹 서브셋이 필요했기 때문에 구글 폰트의 다이나믹 서브셋 CSS를 가지고 조금 더 수정을 했다.
그 수정은 한글 관련 unicode-range만 남기는 것이었다.
키릴 문자, 그리스 문자, 그리고 한자와 같은 부분을 가르키고 있는 서브셋 정의는 필요가 없기 때문에 CSS에서 제거했다.
아래의 파이썬 스크립트를 사용했고, 이 스크립트의 작성은 Claude code가 조금 도움을 주었다.
#!/usr/bin/env python3
"""
CSS 파일에서 한글 관련 unicode-range만 필터링
"""
import re
# 한글 관련 범위
KOREAN_RANGES = [
(0xAC00, 0xD7A3), # 한글 음절
(0x1100, 0x11FF), # 한글 자모
(0x3130, 0x318F), # 한글 호환 자모
(0xA960, 0xA97F), # 한글 자모 확장-A
(0xD7B0, 0xD7FF), # 한글 자모 확장-B
]
def parse_unicode_range(range_str):
"""unicode-range 문자열에서 유니코드 범위 파싱"""
ranges = []
parts = range_str.split(',')
for part in parts:
part = part.strip()
if '-' in part and part.count('-') == 1:
# 범위 형식: U+XXXX-YYYY
start, end = part.split('-')
start = int(start.replace('U+', '').replace('u+', ''), 16)
end = int(end.replace('U+', '').replace('u+', ''), 16)
ranges.append((start, end))
elif part.startswith('U+') or part.startswith('u+'):
# 단일 값: U+XXXX
val = int(part.replace('U+', '').replace('u+', ''), 16)
ranges.append((val, val))
return ranges
def is_korean_range(unicode_ranges):
"""주어진 unicode-range가 한글 관련 범위와 겹치는지 확인"""
for ur_start, ur_end in unicode_ranges:
for kr_start, kr_end in KOREAN_RANGES:
# 범위가 겹치는지 확인
if not (ur_end < kr_start or ur_start > kr_end):
return True
return False
def filter_css():
"""CSS 파일 필터링"""
with open('NotoSansKR-dynamic.css', 'r', encoding='utf-8') as f:
content = f.read()
# @font-face 블록을 찾기
pattern = r'@font-face\s*\{[^}]+\}'
font_faces = re.findall(pattern, content, re.DOTALL)
filtered_faces = []
total_count = 0
korean_count = 0
for face in font_faces:
total_count += 1
# unicode-range 추출
range_match = re.search(r'unicode-range:\s*([^;]+);', face)
if range_match:
range_str = range_match.group(1).strip()
unicode_ranges = parse_unicode_range(range_str)
if is_korean_range(unicode_ranges):
filtered_faces.append(face)
korean_count += 1
# 결과 저장
result = '\n'.join(filtered_faces)
with open('NotoSansKR-korean-only.css', 'w', encoding='utf-8') as f:
f.write(result)
print(f"총 @font-face: {total_count}개")
print(f"한글 관련: {korean_count}개")
print(f"제거됨: {total_count - korean_count}개")
print(f"\n결과 파일: NotoSansKR-korean-only.css")
if __name__ == "__main__":
filter_css()
다음으로는 CSS만 수정하는 것이 아니라 서브셋 파일들도 필요한 녀석들만 남겼다.
이건 앞서 스크립트로 수정한 CSS에서 남아있는 Font-Face를 참고해서 간단하게 수정할 수 있었다.
최초에 124개의 서브셋이 86개의 서브셋으로 줄어들었다.
4. 결론...?
그러면 다이나믹 서브셋이 얼마나 효과가 있는지 궁금해할 수 있을 것 같다.
그 얼마나 크다고 사용자 체감이라도 있냐? 라고 부정적일 수도 있다.
결과를 먼저 제시하자면, 내가 지금 실무에서 작업하고 있는 FE 프로젝트에서 전체 페이지를 전부 다 탐색했을 때, 다이나믹 서브셋을 적용하면 0.2MB 정도의 폰트 파일만 다운로드 되었다.
최초의 10MB에서 1/50 정도로 줄은 것이다!!!
이렇게 되는 이유는 간단한데, 한글로 만들 수 있는 문자는 11,172개 정도가 있고 이 문자에 대한 정보가 한글 폰트 서브셋에 포함되어 있다.
하지만 이 글자들 중에는 자주 사용되지 않는 글자 뀷, 꽗,,, 등이 포함되어 있기 때문에 사실상 필요없는 데이터까지 받고 있는 것이다.
이때, 다이나믹 서브셋을 적용하게 되면 실질적으로 자주 사용하는 것들만 다운해버리기 때문에 다운받게 되는 폰트 파일의 용량이 확 줄어든다.
용량 문제를 제외하고도 이점이 분명 존재한다.
구글 폰트를 사용하게 되면 구글 CDN을 통해서 폰트 파일이 서빙될 것이고, 뭐 다른 폰트 파일을 쓰더라도 오픈 폰트 CDN을 통해서 폰트 파일이 서빙될 것이다.
이 얘기는 해당 서버에 의존적이라는 것이 된다.
폐쇄망을 사용하거나 안정적인 폰트 파일의 서빙이 목적이라면 폰트 파일을 직접 제공을 해야 한다.
이 과정에서 대용량의 폰트 파일을 한 번에 툭 던져주는 것보다는 다이나믹 서브셋 방식을 적용하는 것이 효과적일 것이다.
따라서, 본 게시글에서와 같이 직접 다이나믹 서브셋 방식을 적용하는 것이 유의미하다고 생각한다.