iOS Safari에서만 SVG 아이콘이 사라졌던 디버깅 기록
상품 카드 리스트에서 Pick 아이콘과 별점 아이콘을 SVG로 쓰고 있었습니다. 그런데 iOS에서만 아주 이상한 현상이 보고됐습니다. 첫 번째 카드의 아이콘은 멀쩡히 보이는데, 두 번째 카드부터는 똑같은 아이콘이 통째로 사라지는 겁니다.
원인을 추적한 끝에 clipPath를 제거해 해결했습니다. 그 과정을 정리해보겠습니다.
첫 번째 카드만 살아남았다
상품 리스트 페이지에서 벌어진 일은 이랬습니다.
페이지에 처음 들어갔을 때는 모든 아이콘이 보였습니다. 다른 페이지로 이동했다가 돌아오면 첫 번째 카드를 제외한 나머지 카드의 SVG 아이콘이 사라졌습니다.
DevTools를 아무리 봐도 차이가 없었다
Chrome DevTools로 DOM과 스타일을 하나씩 뜯어봤습니다.
<svg> 요소는 DOM에 남아 있었고, width, height, display, opacity, fill도 모두 정상이었습니다. 첫 번째 카드와 나머지 카드의 CSS에도 차이가 없었습니다. 그런데 화면에는 아이콘이 보이지 않았습니다.
재현 환경
- iOS Safari (15+)
- iOS WebView
- Desktop Chrome (재현 안 됨)
- Desktop Safari (재현 안 됨)
- Android Chrome (재현 안 됨)
iOS에서만, 그것도 뒤로 가기 이후에만 터진다는 뜻이었습니다.
같은 SVG를 반복해서 렌더링하고 있었다
문제가 됐던 StarIcon의 원본 SVG 코드입니다.
<svg width="12" height="11" viewBox="0 0 12 11" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_3200_4527)">
<path d="..." fill="#ECEEEF"/>
</g>
<defs>
<clipPath id="clip0_3200_4527">
<rect width="11" height="11" fill="white" transform="translate(0.183594)"/>
</clipPath>
</defs>
</svg>
범인은 id 하나였다
원인은 생각보다 간단했습니다. SVG의 구성 자체에 문제가 있었죠.
바로 <clipPath id="clip0_3200_4527">였습니다.
결과부터 얘기하면, 이 SVG 컴포넌트를 카드마다 반복해서 렌더링하면 DOM 안에 동일한 id="clip0_3200_4527"를 가진 <clipPath>가 여러 번 등장하게 됩니다. 그게 아이콘이 사라진 원인이었습니다.
SVG의 id도 key처럼 유일해야 한다
React에서 배열을 렌더링할 때 우리는 항상 key를 지정해주죠. 이 key는 유니크해야 하고, 그렇지 않으면 UI상 문제가 발생합니다.
SVG의 id는 문서 전체에서 유일해야 합니다. React의 key가 형제 목록 안에서 식별자를 구분하는 것과는 범위가 다릅니다. key를 바꿔도 SVG 안에 하드코딩된 id는 바뀌지 않습니다.
하지만 문제가 된 컴포넌트에서는 SVG 파일을 여러 번 inline으로 쓰는 경우가 많고, 이때 자연스럽게 id가 중복된 상태가 돼버립니다.
// 이렇게 사용하면
{products.map(product => (
<ProductCard key={product.id}>
<StarIcon /> {/* 각각 동일한 id를 가진 clipPath 포함 */}
</ProductCard>
))}
// DOM에는 이렇게 됩니다
<svg>
<defs><clipPath id="clip0_3200_4527">...</clipPath></defs>
...
</svg>
<svg>
<defs><clipPath id="clip0_3200_4527">...</clipPath></defs> <!-- 중복! -->
...
</svg>
<svg>
<defs><clipPath id="clip0_3200_4527">...</clipPath></defs> <!-- 중복! -->
...
</svg>
다른 브라우저는 넘어가 줬을 뿐이다
대부분의 브라우저는 이 상황을 어느 정도 관대하게 처리해서, 시각적으로 문제가 잘 드러나지 않습니다.
인라인 SVG의 ID 참조는 다른 브라우저에서도 문서 범위의 중복을 피해야 합니다. 이 사례에서 확인한 것은 iOS의 뒤로 가기 이후 문제가 드러났고, 불필요한 clipPath를 제거하자 사라졌다는 사실입니다. 이 결과만으로 WebKit 내부의 특정 BFCache 버그까지 확정할 수는 없습니다.
뒤로 가기에서는 BFCache가 페이지를 복원한다
BFCache는 브라우저가 페이지를 메모리에 저장해두고, 뒤로 가기와 앞으로 가기 시 빠르게 복원하는 기능입니다.
iOS Safari는 이 기능을 적극적으로 쓰는데, 페이지를 완전히 새로 렌더링하는 게 아니라 스냅샷을 복원합니다.
복원 시점의 참조 문제를 가설로 세웠다
다음은 당시 증상을 설명하기 위해 세운 가설입니다. BFCache 사용 여부와 엔진 내부의 참조 해석은 별도의 재현과 관찰이 필요합니다.
1. 첫 진입 시
각 카드의 SVG 컴포넌트가 렌더링됨
→ 각 <svg> 안의 <defs>, <clipPath>가 DOM에 붙음
→ clip-path="url(#clip0_3200_4527)"가 적절한 <clipPath>를 찾아감
→ 정상 렌더링됨
2. 다른 페이지로 이동
현재 페이지가 BFCache에 스냅샷으로 저장됨
→ DOM 상태가 메모리에 보존됨
3. 뒤로 가기로 돌아올 때
BFCache에 있던 DOM이 복원됨
→ WebKit이 inline SVG의 <defs>와 id 참조를 완벽하게 복원하지 못함
→ 동일한 id를 가진 <clipPath>가 여러 개 있을 때
- 어떤 <clipPath>에 매칭할지 애매해짐
- 첫 번째 인스턴스는 정상 작동
- 나머지는 참조가 끊기거나 잘못된 clipPath를 참조
→ 결과적으로 렌더링이 화면 밖으로 잘려나감
이 흐름을 의심했지만, 확실히 관찰한 것은 뒤로 가기와 반복된 ID가 있는 상황의 렌더링 실패였습니다. 해결책이 효과가 있었다는 사실과 내부 원인을 증명했다는 사실은 구분해야 합니다.
뒤로 가기라는 재현 조건을 더 잘게 나눠본다
브라우저의 뒤로 가기에는 BFCache 복원뿐 아니라 문서를 다시 요청하는 경우도 있습니다. SPA 안에서 라우터가 화면만 바꾸는 경우와도 구분해야 합니다. 버튼을 누른 동작만 같다고 같은 생명주기를 거쳤다고 볼 수는 없습니다.
다음 코드는 별도 테스트 페이지의 문서 진입 시점에 설치할 수 있는 관찰 코드입니다. pageshow.persisted가 참이면 BFCache에서 복원된 것입니다. 거짓이면 같은 현상이 생겨도 BFCache만 원인으로 좁히면 안 됩니다.
window.addEventListener("pageshow", (event) => {
console.log("BFCache 복원:", event.persisted);
});
const counts = new Map();
for (const node of document.querySelectorAll("[id]")) {
counts.set(node.id, (counts.get(node.id) ?? 0) + 1);
}
console.table([...counts].filter(([, count]) => count > 1));
재현 페이지에는 같은 SVG 두 개만 두고 시작할 수 있습니다. 중복 ID인 경우, 인스턴스별 ID를 쓰는 경우, clipPath를 없앤 경우를 각각 비교합니다. 첫 진입과 문서 간 뒤로 가기, SPA 내 복귀를 나눠 보면 수정이 어떤 조건에서 효과가 있는지 드러납니다. 이 최소 재현 없이 페이지 전체에서 증상이 사라진 것만 보면 다른 코드 변경의 효과와 섞일 수 있습니다.
시도했던 해결책들
1차 시도: SVGR + SVGO 설정
처음엔 GPT의 도움을 받아 빌드 타임에 id 충돌을 줄여보기로 했습니다. SVGR 설정을 이렇게 조정했습니다.
// next.config.js
webpack(config, { isServer }) {
config.module.rules.push({
test: /\.svg$/i,
issuer: { and: [/\.(js|ts)x?$/] },
use: [
{
loader: "@svgr/webpack",
options: {
svgo: true,
svgoConfig: {
plugins: [
"preset-default",
"prefixIds", // id에 prefix를 붙여 충돌 방지
],
},
},
},
],
});
if (!isServer) {
config.resolve.fallback = {
fs: false,
dns: false,
net: false,
tls: false,
http2: false,
dgram: false,
};
}
return config;
}
이제 해결됐다고 생각했습니다. 그런데 iOS에서 뒤로 가기를 하자 아이콘이 다시 사라졌습니다.
파일 단위 prefix를 붙여도 같은 컴포넌트를 여러 번 렌더링하면 같은 prefix가 반복됩니다. 파일 사이의 충돌을 줄이는 설정만으로 인스턴스 사이의 중복까지 없어지지는 않았습니다.
쓰지 않는 clipPath를 제거하자 아이콘이 돌아왔다
설정을 바꿔도 해결되지 않아 SVG 구조를 처음부터 다시 살펴봤습니다.
viewBox="0 0 12 11"
clipPath: <rect width="11" height="11" transform="translate(0.183594)"/>
path: 대부분 viewBox 내부에 위치
그러다 이상한 걸 발견했습니다. 이 아이콘에서 clipPath는 사실상 아무 역할도 하지 않고 있었습니다.
path 좌표가 이미 viewBox 내부에 있었고, 일부 음수 좌표가 있지만 화면 밖이라 제거해도 보이는 변화가 없었습니다. clipPath를 없애도 디자인은 그대로였습니다.
그래서 단순하게, 문제가 되는 clipPath를 아예 삭제했습니다.
AS-IS
<svg width="12" height="11" viewBox="0 0 12 11" fill="none">
<g clip-path="url(#clip0_3200_4527)">
<path d="..." fill="#ECEEEF"/>
</g>
<defs>
<clipPath id="clip0_3200_4527">
<rect width="11" height="11" fill="white" transform="translate(0.183594)"/>
</clipPath>
</defs>
</svg>
TO-BE
<svg width="12" height="11" viewBox="0 0 12 11" fill="none">
<path
d="..."
fill="#ECEEEF"
/>
</svg>
적용 후 모든 환경에서 문제가 완전히 해결됐습니다.
| 환경 | 첫 진입 | 뒤로 가기 후 |
|---|---|---|
| iOS Safari | 정상 | 정상 |
| iOS WebView | 정상 | 정상 |
| Desktop Chrome | 정상 | 정상 |
| Desktop Safari | 정상 | 정상 |
| Android Chrome | 정상 | 정상 |
clipPath를 지울 수 없다면
이번 케이스처럼 clipPath를 아예 제거할 수 있는 경우도 있지만, 아이콘 디자인상 clipPath나 filter, mask가 반드시 필요한 경우도 있습니다. 그럴 때 id 중복 문제를 다루는 방법들입니다.
1. 컴포넌트마다 고유 id 생성하기
가장 명확한 방법은 컴포넌트가 마운트될 때 고유한 id를 만들어 쓰는 것입니다.
import { useId } from 'react'; // React 18+
function StarIcon() {
const id = useId();
const clipId = `clip-${id}`;
return (
<svg width="12" height="11" viewBox="0 0 12 11" fill="none">
<g clipPath={`url(#${clipId})`}>
<path d="..." fill="#ECEEEF" />
</g>
<defs>
<clipPath id={clipId}>
<rect width="11" height="11" fill="white" transform="translate(0.183594)" />
</clipPath>
</defs>
</svg>
);
}
React 18에 추가된 useId는 서버와 클라이언트 양쪽에서 안정적으로 고유한 id를 생성합니다. 이전에는 Math.random()이나 Date.now() 기반 id를 쓰기도 했는데, 이는 SSR hydration 오류를 유발할 수 있어서 useId가 훨씬 안전한 선택입니다.
2. SVG sprite 방식으로 분리하기
페이지 어딘가에 SVG 정의를 한 번만 넣고, 다른 곳에서는 <use href="#id" />로 참조하는 방식입니다.
<!-- 페이지 최상단에 한 번만 정의 -->
<svg style="display:none">
<defs>
<clipPath id="star-clip">
<rect width="11" height="11" transform="translate(0.183594)" />
</clipPath>
</defs>
<symbol id="star-icon" viewBox="0 0 12 11">
<g clip-path="url(#star-clip)">
<path d="..." />
</g>
</symbol>
</svg>
<!-- 사용 시 -->
<svg width="12" height="11">
<use href="#star-icon" />
</svg>
id가 문서 전체에 단 하나만 존재하니까 중복 문제 자체가 발생하지 않습니다. 다만 Next.js 같은 SSR 환경에서는 SVG 스프라이트를 어디에 주입할지 관리가 필요합니다.
3. 빌드 타임 id prefix (SVGO)
빌드 과정에서 SVGO의 prefixIds 플러그인이 각 SVG 파일의 id에 고유 prefix를 붙이도록 설정할 수 있습니다. 서로 다른 SVG 파일 사이의 이름 충돌에는 도움이 됩니다. 그러나 같은 파일에서 만든 컴포넌트를 반복하면 ID도 반복되므로, 인스턴스마다 유일한 값이 필요할 때는 useId 등을 함께 사용해야 합니다. prefixIds 실패를 BFCache 버그의 증거로 해석해서는 안 됩니다.
뒤로 가기에서만 드러나는 문제가 있었다
처음엔 "iOS에서만 아이콘이 사라진다"는 증상만 보고 원인을 전혀 짐작하지 못했습니다. DevTools로 DOM과 스타일을 아무리 봐도 차이가 없었으니까요.
이 경험에서 재사용할 수 있는 확실한 기준은 반복하는 인라인 SVG의 ID 범위를 확인하고, 불필요한 참조 구조를 줄이는 것입니다. BFCache가 관여했는지는 pageshow 관찰과 최소 재현으로 따로 확인할 과제로 남겨두는 편이 정확합니다.
Figma에서 export한 SVG를 React 컴포넌트로 만들어 리스트에서 반복 사용한다면 id 중복이 생길 수 있다는 점, 기억해두면 언젠가 도움이 될지도 모르겠습니다. 혹시 iOS Safari에서 뒤로 가기 후 이상한 렌더링 문제를 만난다면, BFCache와 SVG id를 함께 의심해보시길 권합니다.