2026.03.31
프로덕션 Sentry 대시보드에 처음 보는 에러가 올라왔다.
TypeError: Attempting to change value of a readonly property.
at defineProperty ([native code])
at ? (app:///10e8bc2c-fca1-4aa8-9c1d-87e872816d2a/ready:1:4715)
at global code (app:///10e8bc2c-fca1-4aa8-9c1d-87e872816d2a/ready:1:4819)
프로젝트는 Next.js 16 (App Router) + React Compiler + Turbopack 스택이고, 에러가 발생한 페이지는 결제 직전의 /ready 페이지였다. "readonly property를 변경하려 했다"는 메시지, Object.defineProperty에서의 실패, 그리고 global code 시점 실행. 뭔가 심각해 보였다.
스택 트레이스에서 defineProperty ([native code])가 보이니까, React Compiler가 memoization 캐시를 설정할 때 Object.defineProperty를 사용하는 게 아닌가 의심했다. reactCompiler: true로 활성화된 상태였고, Safari에서만 발생하는 것 같았으니 "React Compiler + Safari = 충돌"이라는 가설이 그럴듯했다.
결과: 배제. React Compiler의 출력물을 분석해보니, 컴파일러는 Object.defineProperty를 전혀 사용하지 않는다. 출력은 import { c as _c } from "react/compiler-runtime", const $ = _c(N), 그리고 if/else 캐시 체크가 전부다. 모듈 레벨 변환도 import문 추가 외에는 없다.
그러면 defineProperty를 호출하는 건 번들러인 Turbopack이 아닌가? Turbopack의 __turbopack_esm__ 함수는 실제로 Object.defineProperty로 모듈 exports를 정의하고, configurable: true를 생략하면 기본값이 false가 되어 재정의 시 에러가 발생할 수 있다.
Next.js GitHub에서도 Turbopack + React Compiler 조합의 불안정성이 보고된 이슈들(#78163, #78924)이 있었고, Safari 특유의 Turbopack 문제(#71923)도 있었다. "Turbopack이 Safari에서 모듈 exports를 재정의하다 실패"라는 가설을 세웠고, WKWebView의 app:/// 스킴이 모듈 재평가를 유발한다고 추론했다.
결과: 방향은 맞았지만 원인 주체가 틀렸다. app:/// 스킴의 해석이 잘못되었다.
"use no memo" 적용React Compiler가 가장 유력한 원인이라고 판단해서, ready 페이지 관련 3개 컴포넌트에 "use no memo" 디렉티브를 적용했다.
templates/ready-template.tsxcomponents/ready/ready-payment.tsxcomponents/common/paypal-button.tsx세 컴포넌트 모두 단순한 구조라 성능 영향은 미미했지만, 결과적으로 이 조치는 이 에러에 대해서는 불필요했다.
분석 문서를 정리하고 나서, Sentry 태그를 다시 꼼꼼히 봤다.
browser=Facebook 554.0.0
browser.name=Facebook
device=iPhone 13 Pro
os=iOS 26.4
mechanism=auto.browser.global_handlers.onerror
handled=no
url=https://readmypillars.com/.../ready
browser=Facebook 554.0.0. 이건 Safari가 아니라 Facebook 앱 내장 브라우저(Facebook In-App Browser)다.
그리고 결정적인 단서: 이 에러를 겪은 유저가 결제까지 성공적으로 완료했다.
DB에 status가 done으로 찍혀 있었다. /ready → /loading → /my-result 플로우가 정상 동작한 것이다.
모든 퍼즐이 맞춰졌다.
Facebook In-App Browser(FIAB)는 페이지를 로드할 때 자체 JavaScript를 주입한다. 이 주입된 스크립트가 global code 시점에 Object.defineProperty를 호출하다가, Safari(iOS의 WKWebView)의 strict mode에서 readonly property 위반으로 TypeError가 발생한 것이다. 이 에러는 FIAB의 스크립트에서 발생한 것이지 내 앱 코드와는 무관하고, window.onerror를 통해 Sentry에 캡처된 것뿐이다.
시간순으로 정리하면:
global code 실행 시점에 Object.defineProperty 호출 → Safari strict mode에서 TypeErrorwindow.onerror가 이 에러를 캡처beforeSend 콜백을 통해 에러를 서버로 전송이 에러에서 app:///UUID/ready 스킴은 WKWebView/Cordova 하이브리드 앱이 아니라, FIAB가 주입한 스크립트의 내부 소스 URL이었다.
instrumentation-client.ts의 beforeSend에 서드파티 주입 스크립트 에러 필터링을 추가했다.
function isThirdPartyInjectedScriptError(event: Sentry.ErrorEvent): boolean {
const frames = event.exception?.values?.[0]?.stacktrace?.frames;
if (!frames?.length) return false;
const hasAppScheme = frames.some(
(frame) => frame.filename?.startsWith("app:///")
);
const message = event.exception?.values?.[0]?.value ?? "";
const isReadonlyError = message.includes("readonly property");
return hasAppScheme && isReadonlyError;
}
두 조건(app:/// 스킴 + readonly property 메시지)을 AND로 결합해서 자체 앱 코드의 에러가 실수로 필터링되지 않도록 했다. browser.name 대신 app:/// 스킴으로 필터링한 이유는, Facebook 외에 Instagram, LINE 등 다른 In-App Browser에서도 동일 패턴이 발생할 수 있기 때문이다.
"use no memo" 제거: 추가 사례가 쌓여서 Facebook FIAB 전용 에러임이 확정되면 제거 예정스택 트레이스에 매몰되어 코드 레벨 분석부터 시작했는데, Sentry 태그의 browser=Facebook 554.0.0이 처음부터 답을 가지고 있었다. 에러의 "무엇(what)"보다 "어디서(where)"를 먼저 확인하는 습관이 필요하다.
에러가 발생했는데 유저가 정상적으로 플로우를 완료했다면, 그건 앱 코드의 에러가 아닐 가능성이 높다. DB에서 해당 유저의 상태를 확인하는 것만으로 디버깅 방향이 크게 좁혀졌다.
app:/// 스킴은 여러 컨텍스트에서 나타난다app:///를 보고 Cordova/WKWebView 하이브리드 앱으로 바로 연결지었는데, Facebook In-App Browser 같은 서드파티 앱의 주입 스크립트에서도 이 스킴이 사용된다. URL 스킴만으로 환경을 단정짓지 말고 다른 태그와 교차 검증해야 한다.
In-App Browser, 광고 SDK, 브라우저 확장 프로그램 등이 주입하는 스크립트에서 발생하는 에러가 window.onerror를 통해 Sentry에 잡히는 건 드문 일이 아니다. beforeSend에서 app:///, chrome-extension:// 등 서드파티 스킴의 에러를 필터링하는 건 Sentry 노이즈 관리의 기본이다.
React Compiler 가설 → Turbopack 가설 → FIAB 가설로 점점 좁혀졌는데, 처음부터 "이 에러가 앱 동작을 차단했는가?"를 확인했다면 훨씬 빨리 방향을 잡았을 것이다. 에러의 기술적 원인을 파기 전에, 비즈니스 임팩트부터 확인하는 게 효율적이다.