배경
네이버 지도 안에 마커를 보여주는 기능을 구현했다. 기본 마커로는 장소 종류를 구분할 수 없어서, 프레임 이미지 위에 카테고리 아이콘을 얹는 형태로 직접 만들었다.
네이버 지도의 Marker는 icon.content에 HTML 문자열을 받을 수 있어서, 두 이미지를 이렇게 조립해 넘겼다.
const markerHtml = `
<div style="position:relative;width:48px;height:56px;">
<img src="${frameSrc}" alt="" aria-hidden="true" style=".." />
<img src="${iconSrc}" alt="" aria-hidden="true" style=".." />
</div>
`
<NaverMap
latlng={..}
options={..}
markerOptions={{
title: place.title,
icon: { content: markerHtml },
}}
className="map"
/>
마커에 하나의 이미지만 렌더링하는 것이 아니라 프레임 이미지 안에 아이콘 이미지를 띄우는 방식이다. 그리고 프레임과 아이콘 모두 Figma에서 내보낸 .svg를 그대로 쓰고 있었다.
문제
그런데, 이 마커 이미지가 뭉개져 보이는 것이다.
얼핏 보면 멀쩡한 아이콘처럼 보이지만, 확대해서 살펴보면 이미지가 뭉개진 것을 확인할 수 있다.
상황 파악
개발할 때는 크롬에서만 확인했는데, 크롬에서는 이미지가 뭉개지지 않았다. 그래서 어떤 상황에서 위 문제가 발생하는지 범위를 좁히고자 했다.
| 환경 | 결과 |
|---|---|
| PC Chrome | 정상 |
| AOS 앱 웹뷰 | 정상 |
| iOS 앱 웹뷰 | 재현 |
| PC Safari | 재현 |
| 다른 아이콘 · 마커를 쓰는 다른 화면 | 모두 재현 |
iOS 기기만의 문제도, 특정 에셋 하나가 잘못된 것도 아니었다. WebKit과 Blink의 렌더링 차이에서 오는 문제라는 데까지 좁혀졌다.
여기서 변인이 하나 더 남아 있었는데, 당시에는 우선 지나쳤다. 같은 마커 HTML을 지도 밖 일반 DOM에도 하나 띄워보는 것. 나중에 시도해보니 지도 안이든 밖이든 결과가 같았다. 지도의 합성 레이어와는 무관한 문제였다는 뜻이다. 이 시도를 먼저 했더라면 원인에 조금 더 빨리 도달할 수 있지 않았을까 생각한다.
첫 시도
여기까지 파악한 내용과 작성한 코드를 AI에게 전달하며, 구체적인 원인과 해결 방법을 물었다. AI가 지목해준 원인은 다음과 같았다.
⚠️ AI가 지목한 원인 - 나중에 반증됨
WebKit은 GPU 합성 레이어의 내용을 레이어가 만들어질 때의 배율로 한 번 래스터라이즈해 텍스처로 들고 있는다. 이후 transform으로 확대돼도 벡터를 다시 그리지 않고 텍스처를 그대로 늘리기 때문에, 레티나 디스플레이(2~3x)에서 계단 현상이 나타난다. 지도 오버레이는 패닝과 줌 때문에 CSS transform이 걸린 채로 움직이고, 마커는 그 위에 얹힌다. 지도 위 마커라는 조건이 갖춰져야 재현되는 문제다.
함께 제시된 해결 방법은 두 가지였다.
- 아이콘을 SVG 인라인으로 삽입 -
<img src>대신 markerHtml 안에<svg>태그를 직접 넣으면 WebKit이 렌더 트리에서 벡터로 그린다 - PNG를 쓴다면 2~3x 에셋을 준비하고 CSS로 축소
먼저 1번을 시도했다. 그런데 아이콘을 인라인으로 바꿔도 증상은 그대로였다. 당시에는 원인을 더 파고들지 않고 2번으로 넘어갔다. 아이콘을 PNG로 바꿔 올리니 뭉개짐이 사라졌고, 래스터 포맷(PNG)이면 해결된다는 것까지 확인된 셈이었다.
나중에 돌아보니, 1번이 실패한 것 자체가 가장 중요한 단서였던 것이다. 로드 방식을 바꿨는데도 증상이 그대로였다면 원인은 로드 방식이 아니라는 뜻이다. 그때 이 신호를 따라갔어야 했다.
원인: SVG가 아니라 SVG 안에 있던 것
“SVG를 <img>로 넣으면 1x로 래스터라이즈된다”가 사실이라면 페이지의 다른 SVG 아이콘도 전부 깨져야 했다. 그렇지 않았다. 그래서 가설을 검증하기로 하고, 마커 렌더링 방식 여러 가지를 같은 지도 위에 나란히 띄워 WebKit·Chromium × DPR 1/2/3으로 캡처해 비교했다.
1. 순수 <path> SVG는 **<img>**로도 선명했다.
필터·마스크가 없는 SVG를 <img>로 넣으면 WebKit DPR 3에서도 벡터 그대로 선명하게 그려졌다. 지도 위에 올려도 마찬가지였다. 그렇다면 “SVG를 <img>로 로드해서”도, “GPU 합성 레이어 위라서”도 원인이 아니었다.
2. 뭉개지는 건 **<filter>**와 **<mask>**였다
WebKit은 <img>로 로드한 SVG 안에서 중간 버퍼가 필요한 요소 — 필터, 마스크, 클립 — 의 결과를 CSS 픽셀 기준 1x 버퍼에 그린 뒤 확대한다. 순수 path는 DPR대로 그리지만, 이 버퍼를 거친 부분만 1x가 되는 것이다.
프레임 SVG를 열어보니 Figma의 drop-shadow 이펙트가 <filter>로 들어 있던 것이다.
<svg width="48" height="56" viewBox="0 0 48 56">
<g filter="url(#filter0_d_332_66)">
<path d="M34 2C39.523 2 44 6.48 …" fill="black"/>
</g>
<path d="M34 2C39.523 2 44 6.48 …" fill="white"/>
<defs>
<filter id="filter0_d_332_66" …>
<feOffset dy="3"/>
<feGaussianBlur stdDeviation="2.5"/>
…
</filter>
</defs>
</svg>
이 필터가 모든 마커의 프레임 외곽을 뭉개고 있었다. 필터만 걷어내고 그림자를 CSS로 옮기자 프레임은 완전히 선명해졌다.
3. 아이콘 54개 중 48개는 벡터가 아니었다
더 당황스러운 건 아이콘이었다. 파일을 열어보니 대부분이 이런 구조였다.
<svg viewBox="0 0 100 100">
<rect width="100" height="100" fill="url(#pattern0)"/>
<defs>
<pattern id="pattern0" …><use href="#image0"/></pattern>
<image id="image0" width="174" height="174"
href="data:image/png;base64,iVBORw0KGgo…"/>
</defs>
</svg>
174×174 PNG를 base64로 감싼 파일이다. 확장자만 .svg일 뿐 안에 벡터가 없다. Figma에서 레이어가 이미지 fill이거나 래스터 이펙트가 섞여 있으면 이렇게 내보내진다.
진짜 <path>로 그려진 아이콘은 병원·공원·음식점 3종(active/inactive 6개)뿐이었고, 이 6개는 또 **<mask>·<clipPath>**를 쓰고 있어서 프레임과 같은 이유로 뭉개졌다.
💡 정리하면
뭉개짐의 원인은 두 가지가 겹친 것이었다. ① 프레임의
<filter>와 벡터 아이콘의<mask>가 WebKit에서 1x로 래스터되는 것, ② 나머지 아이콘은 애초에 174px 비트맵이라 벡터의 이점이 없었던 것. 둘 다 “SVG여서”가 아니라 Figma가 내보낸 SVG의 내용물 문제였다.그리고 1번이 실패한 이유도 여기서 설명된다. 인라인으로 바꾼 건 아이콘이었는데 그 아이콘 대부분이 애초에 벡터가 아니었고, 프레임의 필터는 그대로 남아 있었다. 방법이 틀린 게 아니라 이 에셋에 적용될 여지가 없었던 것이다.
해결 방법 검토
원인이 정확해지자 선택지가 달라졌다. 실험에서 시도한 것들을 함께 적는다.
| 방법 | 결과 | 비고 |
|---|---|---|
| Figma에서 순수 path로 재export + 프레임 그림자를 CSS로 (채택) | 해결 | 모든 DPR에서 벡터. 원인을 제거하는 유일한 방법 |
| 고해상도 WebP로 전환 | 해결 | 확실하고 단순. 벡터 확장성 포기 |
아이콘을 인라인 <svg>로 삽입 | 부분적 | 진짜 벡터 6개에만 유효. 래스터 포장 48개는 <img>와 동일 |
2배 크기로 두고 scale(0.5) | DPR 의존 | DPR 2까지만. DPR 3에서 다시 뭉개짐 |
CSS background-image로 SVG 로드 | 효과 없음 | <img>와 결과 동일 |
translateZ(0) / will-change | 효과 없음 | 합성 레이어만 하나 더 생김 |
WebP 전환은 확실한 해결책이었고 실제로 잠깐 그렇게 진행을 하려했다. 48개가 이미 비트맵이었으니 잃을 벡터가 없었다. 그런데 이 방향은 원인을 우회하는 것이지 근본적인 원인을 해결하는 방법은 아니라 생각했다. 다음에 디자이너가 새 아이콘을 SVG로 넘기면 같은 문제가 그대로 재발할 것이라 생각했다.
그래서 에셋을 제대로 받는 쪽으로 결정했다.
최종 결정: 순수 path SVG로 재export
아이콘 54개 전부 벡터 레이어 기준으로 다시 export를 했다. 그리고 만약에 이미지 fill이 있으면 벡터로 교체했다. export 하기 전에 flatten(⌘E), outline stroke, 마스크/불리언 그룹 해제를 하여 <mask>·<clipPath>가 남지 않게 했다.
마지막으로, 프레임 2개는 drop-shadow 이펙트를 빼고 export. 그림자는 코드에서 얹었다.
파일 검사
전달받은 파일에 문제가 되는 요소가 있는지 확인하기 위해 스크립트를 작성해 검사하는 파이프라인을 추가했다.
# filter / mask / clipPath / pattern / base64 image 가 남아 있는 파일 찾기
grep -lE '<(filter|mask|clipPath|pattern|image)\b' \
public/static/townwalk/place/**/*.svg
# 아무것도 출력되지 않으면 통과
이 검사를 통과한 파일은 <svg>와 <path>만 남는다.
프레임 그림자를 CSS로
Figma 필터 값(dy=3, stdDeviation=2.5, 30% 남색)을 CSS drop-shadow로 옮겼다. 렌더링 코드는 이 한 줄 외에 바뀐 게 없다.
const markerHtml = `
<div style="position:relative;width:48px;height:56px;">
<img src="${frameSrc}" alt="" aria-hidden="true"
style="display:block;width:100%;height:100%;
filter:drop-shadow(0 3px 5px rgba(48, 61, 81, 0.3));" />
<img src="${iconSrc}" alt="" aria-hidden="true" style=".." />
</div>
`
CSS 필터는 WebKit에서 요소의 합성 단계에서 DPR대로 처리되므로, SVG 안의 <feGaussianBlur>와 달리 1x 버퍼 문제가 생기지 않는다. 실험에서도 지도 위·DPR 3에서 프레임 외곽이 선명하게 유지됐다.
결론
해결 과정이 위와 같이 순탄치만은 않았다. 처음 받은 원인을 그대로 믿고 래스터 전환으로 갔다면 문제는 사라졌겠지만 근본적인 원인은 해결하지 못한채 그대로 남았을 것이다.
왜 처음부터 빠르게 해결하지 못했을까 고민해보고, 다음과 같은 결론을 내렸다.
- 차분하게 임하지 못했다.
‘빨리 문제를 해결해야해’라는 생각에 문제 해결에 급하게 임했다. 그래서 해결 방법들을 하나씩 차분하게 시도 및 소거를 하지 못해서 시간이 조금 더 딜레이가 되었던 거 같다. 소거법이 한 칸 남기고 멈춘 것도 그래서였다.
- 파악한 맥락 전체를 AI에게 전달하지 못했다.
크롬에서는 발생하지 않고 사파리에서만 발생한다는 점, 아이콘에 SVG를 사용하고 있다는 점, 네이버 지도의 아이콘으로 HTML 형태로 전달하고 있다는 점 등 맥락을 처음부터 묶어서 전달했다면 시행착오를 줄일 수 있었을 것이다. 다만 이번 경우엔 맥락을 다 줬어도 결과가 크게 달랐을 것 같지는 않다. 돌아온 원인이 애초에 재현되지 않는 설명이었기 때문이다.
- 받은 원인을 검증하지 않고 해결로 건너뛰었다.
“SVG를 <img>로 로드하면 1x가 된다”는 설명은 지도 밖에 <img> 하나 놓아보면 5분 만에 반증된다. AI가 준 원인은 결론이 아니라 가설이고, 가설은 최소 재현으로 먼저 깨보는 게 순서였다. 그리고 파일을 열어봤어야 했다. .svg라는 확장자만 보고 벡터라고 믿은 것이 이 문제의 절반이었다.
🔍 같은 문제를 겪는다면
Figma에서 내보낸 SVG를
<img>로 쓸 계획이라면, 쓰기 전에<defs>부터 열어보자.filter·mask·clipPath·base64image중 하나라도 있으면 WebKit에서 뭉개진다. 문제는 SVG가 아니라 그 안에 무엇이 들어 있느냐다.
다음에는 더 빠르고 명확하게 원인과 해결 방법을 짚는 개발자가 되기를 바라며, 나와 같은 이슈를 겪은 개발자들에게 이 글을 추천한다.