이 글에서 다루는 것
이미지가 새로고침할 때마다 "어떤 땐 보이고 어떤 땐 안 보이는" 간헐 버그를 잡다가 배운 세 가지를 정리한다. 특정 제품 이야기는 없고, 어디서나 통하는 일반 개념(React 훅 수명, 브라우저 이미지 변환, 서비스워커 캐시)만 다룬다.
핵심 깨달음은 하나였다. 이 버그는 서버·네트워크가 아니라 클라이언트의 렌더 타이밍 문제였다. 파일은 멀쩡한데 화면이 깨졌다.
오늘 다룬 것
| 작업 | 무엇을 하고 싶었나 | 한 일 | 결과 |
|---|---|---|---|
| objectURL 레이스 | 간헐적 이미지 깨짐을 없애고 싶었다 | blob→objectURL을 없애고 서버 URL을 직접 렌더 | 레이스 원천 제거 + 캐시 가능 |
| WebP 폴백 | 이미지를 가볍게 하되 실패해도 안 깨지게 | webp 우선·onError 폴백 + 테스트 hang 방지 게이트 | 점진적 향상, 테스트 안전 |
| 서비스워커 셸 | 오프라인 문서 폴백을 안전하게 | 셸 폴백을 navigate 요청에만 한정 | 이미지·API 요청 오염 제거 |
1. 서버가 주는 이미지는 objectURL로 감싸지 마라
어떤 상황이었나. 이미지가 새로고침마다 들쭉날쭉 깨졌다. 콘솔에 네트워크 에러는 없었다. 처음엔 저장소 서명 URL 만료를 의심했지만, 이미지는 서버 프록시 경로로 내려받고 있어 만료 문제가 아니었다.
핵심 개념. 원인은 objectURL 수명 레이스였다. objectURL은 브라우저 메모리에 든 이미지 데이터(blob)를 가리키는 임시 주소다. URL.createObjectURL로 만들고 URL.revokeObjectURL로 버린다. 그런데 이 생성을 useMemo(계산 캐시)에, 해제를 useEffect(정리 훅)에 나눠 넣으면, React가 렌더를 두 번 돌리는 개발 모드(StrictMode)나 커밋을 버리는 동시성 렌더에서 "해제 → 그 주소를 다시 <img>에 세팅" 순서가 어긋난다. <img>가 이미 버린 blob 주소를 물어 빈 이미지가 뜬다. useMemo는 생명주기를 관리하는 도구가 아니라 그저 계산 캐시이기 때문에 여기에 부작용을 넣으면 정리 시점이 보장되지 않는다.
한 일. 최소 패치(생성·해제를 한 effect로 묶기) 대신 objectURL 자체를 없애고 서버 URL을 직접 <img src>로 렌더했다. 서버가 이미 안정적인 URL을 주는데 blob으로 감쌀 이유가 없었고, 덤으로 브라우저·CDN·서비스워커 캐시까지 받게 됐다. prop이 바뀔 때 상태 리셋은 effect가 아니라 렌더 중 이전-prop 비교(React 공식 escape hatch)로 처리했다.
교훈. 서버가 URL로 주는 이미지는 objectURL blob으로 감싸지 말고 직접 렌더한다 — 레이스가 원천 사라진다.
2. 브라우저 전용 lib는 테스트를 매달리게 할 수 있다
어떤 상황이었나. 이미지를 가볍게 하려 WebP(같은 화질을 더 작게 담는 포맷)를 도입했다. 업로드 시 원본과 WebP를 둘 다 저장하고, 뷰어는 WebP를 우선 쓰되 실패하면 원본으로 폴백하려 했다. 그런데 WebP 변환 라이브러리는 브라우저의 canvas에 의존하는데, 테스트 환경(jsdom)엔 canvas가 없어 변환이 이미지 로드를 기다리다 영원히 멈출 위험이 있었다.
핵심 개념. 두 가지를 배웠다. 첫째, 브라우저 전용 lib를 테스트가 도는 코드경로에 넣을 땐 동기 capability 게이트가 필요하다. 무거운 작업을 시작하기 전에 canvas.getContext('2d') != null 같은 걸 즉시(비동기 대기 없이) 확인해, 없으면 바로 null로 포기시켜 jsdom이 매달리지 않게 한다. 둘째, 로드 실패 폴백은 <picture><source type="webp">가 아니라 <img onError> 스테이지 머신이어야 한다. <picture>는 브라우저가 WebP를 지원하는지만 보고 소스를 고르지, 그 파일이 404여도 폴백을 하지 않는다. 실제 로드 실패까지 폴백하려면 onError로 webp → 원본 → null 순서를 직접 몰아야 한다.
한 일. 변환 래퍼가 실행 전에 canvas 지원을 동기 확인해 jsdom/서버를 즉시 null로 걸러내게 했고(테스트 hang 없음), 뷰어 폴백을 onError 스테이지 머신으로 구현했다.
교훈. 브라우저 전용 API를 테스트 경로에 넣을 땐 동기 capability 게이트로 즉시 no-op 시켜 hang을 막고, 로드 실패 폴백은 <picture>가 아니라 onError로.
3. 서비스워커 셸 폴백은 문서 요청에만
어떤 상황이었나. 프로덕션 서비스워커에 별개 버그가 있었다. 모든 동일 출처 GET을 가로채 실패 시 앱 껍데기 HTML("/")로 폴백하게 짜여 있어, 사진·API 요청이 실패하면 HTML을 반환해 <img>가 깨지고 그 HTML이 캐시에 무한 적재됐다.
핵심 개념. 서비스워커는 페이지와 네트워크 사이에서 요청을 가로채는 백그라운드 스크립트다. 오프라인 "셸 폴백"(네트워크 실패 시 화면 골격 HTML 반환)은 유용하지만, request.mode === 'navigate' 인 문서 이동 요청에만 걸어야 한다. request.mode는 그 요청이 페이지 이동인지 이미지 같은 하위 리소스인지 구분하는 표식이다. 이걸 안 보고 모든 요청에 셸을 걸면 이미지 실패에 HTML을 돌려줘 렌더가 깨진다.
한 일. 셸 폴백을 navigate 요청으로 한정하고, 이미지·API·정적 자산은 각자 전략(이미지 캐시, API 네트워크)으로 분기했다. 기존 오염분은 캐시 버전을 올려 purge했다.
교훈. 서비스워커 셸 폴백은 navigation 문서 요청 한정 — 이미지·API에 HTML을 반환하지 않는다.
→ Service Worker 셸 폴백은 navigation 요청만