본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 17. 23:48

iOS Safari가 내 SVG 공유 카드를 먹어치웠다. 3주 뒤에 발견한 버그와 해결 방법

요약

iOS Safari에서 SVG의 `foreignObject`가 캔버스 렌더링 시 조용히 사라지는 버그를 발견하고, 이를 해결하기 위해 네이티브 SVG `<text>`와 `<tspan>` 요소를 사용하는 방식으로 개선한 사례를 다룹니다.

핵심 포인트

  • iOS Safari는 SVG 캔버스 렌더링 시 `foreignObject`를 무시하는 경향이 있음
  • 에러 메시지 없이 콘텐츠가 사라지므로 자동화된 모니터링이 중요함
  • 해결책으로 의존성 없는 순수 JS와 네이티브 SVG 텍스트 요소 사용
  • SVG 텍스트의 자동 줄 바꿈 문제를 해결하기 위한 헬퍼 함수 구현

프랑스 임대 규정 준수를 위한 저의 SaaS인 BailleurVérif에는 공유 카드 (share card) 기능이 있습니다. 세입자가 규정 준수 퀴즈를 실행하면, 임대료가 법적 상한선을 초과하는지 여부에 대한 판결을 받게 됩니다. 만약 초과한다면, 연간 얼마를 초과하며 프랑스 법에 따라 얼마를 환급받을 수 있는지를 알려줍니다.

공유 카드는 해당 판결 내용을 헤드라인과 서브라인이 포함된 다운로드 가능한 PNG 파일로 변환하여, 집주인에게 보내거나 세입자 포럼에 게시할 수 있도록 설계되었습니다.

서브라인 (subline)에는 핵심적인 유인책이 담겨 있습니다. 예를 들어 "+6,000 €/년 환급 가능 (최대 3년 소급 적용)"과 같은 내용입니다. 이 내용이 없다면 카드는 단순한 선언에 불과하지만, 이 내용이 있으면 행동 유도 (call to action) 수단이 됩니다.

데스크톱 Chrome에서는 완벽하게 렌더링됩니다. Android Chrome에서도 완벽합니다. 하지만 iPhone을 사용하는 프랑스 세입자라는 타겟 페르소나의 브라우저인 iOS Safari에서는 서브라인이 조용히 사라져 있었습니다. 무려 3주 동안이나 말이죠.

저의 자율 에이전트 (autonomous agent)가 6월 17일 05:44Z, 588번째 실행에서 이를 포착했습니다. 어떤 일이 있었는지 설명하겠습니다.

근본 원인: foreignObject와 Safari의 숨겨진 문제

공유 카드는 118줄의 바닐라 JS (vanilla JS)로 작성되었습니다. SVG 문자열을 생성하고, canvas.drawImage()를 통해 이를 캔버스 (canvas)에 렌더링한 다음, PNG 데이터 URL (data URL)로 내보내는 방식입니다.

기존 구현 방식은 SVG 내부에 스타일링된 HTML로서 서브라인을 삽입하기 위해 foreignObject를 사용했습니다:

<!-- 수정 전 — iOS Safari에서 조용히 작동하지 않음 -->
<foreignObject x="60" y="280" width="1080" height="200">
  <div xmlns="http://www.w3.org/1999/xhtml"
...

Chrome과 Firefox는 SVG를 캔버스에 그릴 때 foreignObject 콘텐츠를 직렬화 (serialize) 합니다. 하지만 iOS Safari는 그렇지 않습니다. Safari는 foreignObject 블록을 조용히 제거한 채 SVG를 렌더링합니다. 에러도, 경고도, 시각적 표시도 없습니다. 그저 서브라인이 있어야 할 자리가 빈 공간으로 남을 뿐입니다.

이 문제는 WebKit의 버그 트래커 (bug tracker)에 문서화되어 있으며 Stack Overflow에서도 활발히 논의되는 내용입니다. 하지만 데스크톱이나 Android 기기에서 테스트할 때는 놓치기 쉽습니다.

해결 방법: 네이티브 텍스트 (Native text)와 tspan

해결 방법: foreignObject를 완전히 제거합니다. 대신 SVG의 네이티브 <text><tspan> 요소를 사용합니다. 유일한 복잡한 점은 줄 바꿈 (word wrapping)입니다. SVG 텍스트는 자동으로 줄을 바꾸지 않습니다.

// +8L: 단어 경계 줄 바꿈 헬퍼 (word-boundary wrap helper)
function wrapAt(s, maxChars = 46) {
  const words = s.split(" ");
...

순수 코드 12줄, 의존성 없음, 프로덕션 환경에서 foreignObject 사용 안 함. 세 가지 판정 유형(verdict types)에 대해 스모크 테스트(Smoke-tested)를 완료했습니다:

severity=danger (Paris, 28.5m²) → "Encadrement Paris • Soit 6 000 €/an" / "récupérables (rétroactif 3 ans max)" ✓
severity=warn  (Saint-Denis)    → 단일 행, 줄 바꿈 필요 없음 ✓
severity=ok    (Lille, 14.2m²)  → 회수 가능 금액 하단 행 없음 ✓

d066a28로 커밋되었습니다. 프로덕션에서 foreignObject가 전혀 사용되지 않음을 확인했습니다.

에이전트가 이를 발견한 방법

에이전트는 이 버그를 특별히 찾고 있었던 것이 아닙니다. 에이전트는 72시간 관찰 주기(observation cycle)의 중간 단계에 있었습니다. 이는 마찰 감소 수정 사항(퀴즈 질문 3번과 4번의 도움말 텍스트)이 전환율(conversion)을 개선하고 있는지 추적하는 과정이었습니다.

퍼널(funnel)은 퀴즈 진입부터 판정까지의 모든 단계를 추적합니다:

home_visit → q1 → q2 → q3 → q4 → q5 → verdict_displayed
→ [verdict_dwell_ms, email_gate_reached, share_card_post_verdict_clicked]

579번째 실행(6월 16일, 10:06Z)에서, 후보자 #14가 Android Chrome을 통해 /encadrement-loyer-paris-2026.html로 유입되었습니다. 이 사용자는 질문 2번에서 2분 13초 동안 머물렀으며(의도적인 읽기), 이후 전체 퍼널을 완료했습니다. 이는 마찰 감소 수정(post-friction-fix) 이후의 첫 번째 데이터 포인트였습니다.

해당 세션으로 인해 공유 카드 경로에 대한 코드 리뷰가 촉발되었습니다: 사용자 에이전트(user-agent) 클래스별로 share_card_post_verdict_clicked 이벤트를 교차 참조했습니다. 에이전트는 모바일 Safari 세션 중 다운로드를 트리거한 경우가 단 한 건도 없다는 점을 발견했습니다. 적격 방문의 약 40%가 모바일이라는 점을 고려할 때, 의심스러울 정도로 깨끗한 '0'이라는 수치였습니다.

share-card.js의 52행: foreignObject. 즉시 식별되었습니다.

이것은 총 591회 실행 중 588회째 실행입니다. 에이전트는 105일 전 첫 번째 실행 이후 2시간마다 실행되어 왔습니다. 이 에이전트는 개발자보다 똑똑해서가 아니라, 어떤 개발자도 수동으로 검사하지 않을 시간 흐름에 따른 패턴을 확인함으로써 조용한 버그(silent bugs)를 찾아냅니다.

보너스 발견 사항: 35일간의 LLM 트래픽 데이터

Run-591 (오늘, 11:46Z)은 전략적 비평가(strategic critic) 서브 에이전트의 요청을 수행했습니다: 실제 LLM 크롤러(crawler) 트래픽을 라이브 llms.txt 파일과 대조하여 감사(audit)하는 작업입니다.

에이전트는 visits.jsonl (전체 생애 방문 횟수 484회)에서 GPTBot, Claude-Web, PerplexityBot, YouBot, Diffbot, Bytespider 등을 포함한 10개의 명시적인 LLM 유저 에이전트(user-agent) 문자열을 grep으로 검색했습니다.

결과: 35일간의 라이브 트래픽 중 히트(hits) 0건.

llms.txt 파일은 존재하며, HTTP 200 응답을 제공하고 9,712 바이트를 반환합니다. 하지만 어떤 LLM 크롤러도 이 파일에 접근하지 않았습니다.

로그에서 발견된 유일한 LLM 소스 신호는 utm_source=chatgpt.com 리퍼럴(referrals)이었습니다. 35일 동안 3개의 서로 다른 IP 해시를 통해 5건의 이벤트가 발생했습니다. 빈도는 약 10일에 1회 방문 수준입니다. 이것은 크롤링이 아니라, 사용자가 명시적으로 검색하고 클릭하여 들어오는 ChatGPT의 웹 브라우징(web browsing) 기능입니다.

한 가지 수정 사항이 도출되었습니다: robots.txtllms.txt가 참조되어 있지 않았습니다. 오직 sitemap.xml만 있었습니다. 수정 사항 — 다음과 같이 주석 블록을 추가합니다:

# LLM discovery
# llms.txt: https://bailleurverif.fr/llms.txt
# llms-full.txt: https://bailleurverif.fr/llms-full.txt
...

LLM 크롤러가 실제로 발견(discovery)을 위해 robots.txt의 주석을 읽는지 여부는 아직 실증적으로 해결되지 않은 문제입니다. 재검색(Re-grep)은 2026-07-17로 예정되어 있습니다.

현재 프로젝트 상태

솔직한 수치입니다:

지표 (Metric)값 (Value)
총 방문 횟수 (35일)484
...

교훈 (Lessons Learned)

  • foreignObject + canvas.drawImage() = iOS Safari에서 조용히 작동이 중단됨. 네이티브 <text> + <tspan>과 간단한 줄 바꿈 (word-wrap) 헬퍼를 사용하는 것이 해결책입니다. 의존성 없이 단 12줄이면 충분합니다.
  • 실전에서의 LLM SEO: 잘 구성된 llms.txt를 운영한 35일 동안 명시적인 LLM 크롤러 접속은 0건이었습니다. 유일한 실제 신호는 월 약 3회 정도의 ChatGPT 웹 브라우징뿐이었습니다. 아직 LLM을 기반으로 고객 유입 전략을 세우지는 마세요.
  • 자율 에이전트 (Autonomous agents)는 시간을 두고 측정하기 때문에 조용한 버그를 잡아냅니다. iOS Safari 버그는 스테이징 (staging) 환경에서는 보이지 않았습니다. 에이전트는 세그먼트 분석에서 수치가 0인 것을 발견함으로써 이를 찾아냈습니다. 이는 개발자가 매일 수동으로 수행하기 어려운 종류의 체크입니다.

🔗 Code MIT github.com/Creariax5/bailleurverif · Site bailleurverif.fr · Wikidata Q139857638

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0