로깅
얼마집 프론트엔드는 Datadog(이벤트/RUM)과 Sentry(에러 트래킹) 두 가지 모니터링 도구를 사용합니다.
핵심 원칙
앱에서는
@datadog/browser-rum,@datadog/browser-logs,@sentry/nextjs패키지를 직접 호출하지 말고, 반드시logger패키지를 통해 로깅하세요.
logger 패키지는 두 도구의 초기화·사용자 컨텍스트 설정·호출 방식을 통일된 인터페이스로 추상화합니다.
직접 호출 시 초기화 순서 문제, 사용자 컨텍스트 누락, 서버/클라이언트 환경 혼용 등의 문제가 발생할 수 있습니다.
초기화
Sentry — initSentry
- 클라이언트: 앱 루트의
instrumentation-client.ts에서 호출합니다. (Next.js 15.3+/Sentry v9+ 권장 — 구sentry.client.config.ts대체) - 서버/엣지:
instrumentation.ts의register()에서 런타임 가드 + 동적 import 로 호출합니다. (구sentry.server.config.ts/sentry.edge.config.ts대체 — server/edge 설정이 동일하므로 한 곳에서 처리)
// instrumentation-client.ts (클라이언트)
import {initSentry} from '@howmuchhome-web/logger/sentry';
import * as Sentry from '@sentry/nextjs';
initSentry();
// App Router 클라이언트 내비게이션 instrumentation
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;// instrumentation.ts (서버/엣지)
export async function register() {
if (
process.env.ENV === 'production' &&
(process.env.NEXT_RUNTIME === 'nodejs' ||
process.env.NEXT_RUNTIME === 'edge')
) {
const {initSentry} = await import('@howmuchhome-web/logger/sentry');
initSentry();
}
}initSentry는 NEXT_PUBLIC_ENV === 'production'일 때만 실제로 초기화합니다.
initSentry는 브라우저 전용 패키지를 포함하지 않도록 서브패스(/sentry)로 분리되어 있습니다. 메인 패키지(@howmuchhome-web/logger)가 아닌 서브패스에서 import해야 합니다.
Datadog RUM + 사용자 컨텍스트 — LoggerProvider
앱 루트에 LoggerProvider를 마운트합니다. 내부적으로 다음을 모두 처리합니다.
- Datadog RUM 초기화 (
datadogRum.init) - Datadog RUM 사용자 컨텍스트 설정 (
datadogRum.setUser) - Sentry 사용자 컨텍스트 설정 (
Sentry.setUser) - OpenTelemetry span 속성 기록 (서버)
// app/layout.tsx (Server Component)
import {LoggerProvider} from '@howmuchhome-web/logger';
export default async function RootLayout({children}) {
const user = await getCurrentUser();
return (
<html>
<body>
<LoggerProvider user={user}>{children}</LoggerProvider>
</body>
</html>
);
}user가 null이면 anonymous-{timestamp}-{random} 형식의 익명 ID가 자동으로 생성됩니다.
서비스 이름을 변경해야 하는 경우 service prop을 전달합니다 (기본값: 'howmuchhome-web').
<LoggerProvider user={user} service="howmuchhome-web-admin">
{children}
</LoggerProvider>로그 기록 — Logger
일반 로그 (Datadog)
클라이언트 사이드에서 커스텀 로그를 Datadog Browser Logs로 전송합니다.
import Logger from '@howmuchhome-web/logger';
// 기본 로그 (type 기본값: 'info')
Logger.log('사용자가 부동산 상세 페이지를 조회했습니다.');
// 로그 레벨 및 컨텍스트 지정
Logger.log('검색 결과가 없습니다.', {
type: 'warn',
context: {keyword: '강남구', resultCount: 0},
});type 값 | 설명 |
|---|---|
'ok' | 정상 완료 |
'info' | 일반 정보 (기본값) |
'warn' | 경고 |
'error' | 에러 |
'debug' | 디버그 |
사용자 행동 추적 (Datadog RUM)
Datadog RUM에 커스텀 액션을 기록합니다.
// 버튼 클릭, 폼 제출 등 사용자 인터랙션 추적
Logger.addAction('부동산_찜하기', {propertyId: '123', source: 'detail_page'});
Logger.addAction('검색_실행', {keyword: '강남구', filters: {minPrice: 5000}});뷰 전환 추적 (Datadog RUM)
SPA에서 URL 변경과 무관하게 논리적 뷰 전환을 추적할 때 사용합니다.
Logger.startView('부동산_상세');
Logger.startView('청약_신청_퍼널');에러 캡처 (Sentry)
에러를 Sentry로 전송합니다. 서버/클라이언트 양쪽에서 모두 사용할 수 있습니다.
import Logger from '@howmuchhome-web/logger';
import {ErrorLevel} from '@howmuchhome-web/error';
try {
await submitApplication(data);
} catch (error) {
Logger.captureError(error as Error, {
transactionName: '청약_신청',
level: ErrorLevel.ERROR,
tags: {feature: 'application', step: 'submit'},
extra: {applicationId: data.id},
});
}level 옵션 (ErrorLevel)
| 값 | 설명 |
|---|---|
ErrorLevel.FATAL | 치명적 오류 |
ErrorLevel.ERROR | 일반 오류 (기본값) |
ErrorLevel.WARNING | 경고 수준 |
ErrorLevel.INFO | 정보성 이벤트 |
ErrorLevel.IGNORE | Sentry 전송 생략 |
ErrorLevel.IGNORE를 지정하면 Sentry에 전송되지 않습니다. 예상 가능한 에러(사용자 취소 등)에 활용하세요.
fingerprint 옵션
동일한 에러를 하나의 Sentry 이슈로 그룹핑할 때 사용합니다.
Logger.captureError(error, {
fingerprint: ['payment-timeout'],
});API 에러 처리와의 관계
API 요청 에러는 @howmuchhome-web/api 패키지의 fetcher가 자동으로 Sentry에 전송합니다.
@SilentErrorCodeList 데코레이터로 특정 에러 코드를 전송에서 제외할 수 있습니다.
별도로 Logger.captureError()를 호출할 필요가 없습니다.
직접 Logger.captureError()를 사용하는 경우:
- API 이외의 비즈니스 로직 에러
- 외부 SDK 에러
- 클라이언트 사이드에서만 발생하는 예외
API 에러 처리 — 예상 실패는 Datadog 집계 / 미처리는 Sentry error
원칙: 복구·처리된 예상 실패는 Datadog(집계), 처리가 누락됐거나 치명적인 에러는 Sentry error.
처리된(예상된) API 실패는 Datadog RUM custom action(api_error) 으로 집계해 어떤 실패가 얼마나 자주 발생하는지(errorCode 기준)를 분석하고, 처리 누락은 Sentry error로 잡습니다. web·dashboard·native 세 표면 모두 동일하게 적용됩니다. (IGNORE 레벨 에러 — 401/사용자 취소 등 — 은 항상 자동 스킵됩니다.)
Sentry로는 미처리/치명적 에러만 남겨 이슈 노이즈·쿼터를 줄입니다.
분류 필드 (api_*)
ApiServerError는 아래 필드를 자동으로 답니다. 이 필드는 Datadog RUM action attribute(예상 실패 집계)와 Sentry event.tags(미처리 error, sentryBeforeSend가 병합) 양쪽에 동일하게 실립니다.
| 필드 | 값 | 예 |
|---|---|---|
api_status | HTTP 상태코드 | 404, 422 |
api_method | HTTP 메서드 | GET, POST |
api_path | 정규화된 경로(id → {id}) | /vote/{id}/ballot |
api_error_code | 서버 errorCode | VOTE_ALREADY_SUBMITTED |
- Datadog: RUM Explorer에서
@action.name:api_error @api_error_code:*facet으로 집계하거나 RUM-based metric으로 대시보드/monitor를 만든다. - Sentry(미처리 error):
api_error_code:VOTE_ALREADY_SUBMITTED태그로 필터. 이슈 그룹핑은fingerprint = [path, method, status, errorCode]로 분리됨.
GET (query)
ErrorBoundary가 자동으로 처리합니다 — 폴백을 띄워 서브트리를 복구하면 Sentry warning, 최상위 크래시 바운더리(errorLevel={ErrorLevel.ERROR})는 error. 별도 조치가 필요 없습니다.
뮤테이션 (useAPIMutation)
표준: 에러 UX(toast/alert)는 API 정의의 @MutationOptions onError 에 두고, 호출부는 await mutateAsync() + try/catch 로 성공 후처리를 게이팅한다.
onError(데코레이터/호출부) 또는 errorLevel 이 있으면 = “처리됨” 신호 → useAPIMutation 이 내부에서 reportHandledError 를 호출한다.
errorLevel미지정: 예상 API 에러(api_error_code) → Datadog RUM action(api_error) 집계(Sentry X). 이때 그 에러는 silent 로 마킹되어,await mutateAsync를 잡지 않아도 전역unhandledrejection의 Sentry 이중이 뜨지 않는다(→ 호출부 빈catch가 안전).errorLevel지정: 그 레벨로 Sentry 에스컬레이션(“이건 실제 에러다”). 이 경로는 마킹하지 않아 Sentry 로 간다.- 비-API(예상 밖) 처리 에러 → Sentry
warning(가시성 유지). - 어느 신호도 없으면 개입하지 않는다 → 처리 누락
mutateAsyncuncaught 는 전역unhandledrejection이 Sentryerror로 잡는다.
에러 UX 를 api 정의(@MutationOptions onError) 에 두면 그 엔드포인트의 전 호출부 공통 기본 동작이 되고(중복 제거), 동시에 “처리됨” 신호가 되어 Datadog 집계에 편입된다. 알림은 @howmuchhome-web/imperative-ui 의 toast/alert 로 띄운다(ui 무의존, 컨텍스트 밖에서도 호출 가능). 자세한 건 ui 문서의 “명령형 toast / alert”.
// (1) api 정의: 기본 에러 UX + "처리됨" 신호(→ Datadog 집계)
import {toast} from '@howmuchhome-web/imperative-ui';
@MutationOptions(client => ({
onSuccess: () => {
client.invalidateQueries({queryKey: getQueryKey(QuizAPI.getQuizList)});
},
onError: () => toast.error('퀴즈 제출에 실패했어요'),
}))
@POST(props => `v1/quiz/${props.quizId}/solve`)
static async solveQuiz(props: {quizId: string; answer: string}) {
/* ... */
}// (2) 호출부: await + try/catch 로 성공 후처리 게이팅. 에러는 데코레이터 onError 가 처리하므로 catch 는 삼킨다.
const quizSolveMutation = useAPIMutation(QuizAPI.solveQuiz);
const handleAnswer = async (quizId: string, answer: string) => {
try {
await quizSolveMutation.mutateAsync({quizId, answer});
} catch {
return; // 에러는 데코레이터 onError(toast + Datadog 집계)가 처리 → 성공 후처리 중단
}
openResultSheet(); // 성공 후처리
};호출부에서 문구를 다르게 하거나 Sentry 로 올리려면 그 useAPIMutation 의 onError/errorLevel 로 override 한다.
errorCode 별 구체 처리 — 호출부 config onError override
화면이 errorCode 마다 다른 안내(특정 alert/문구)나 컴포넌트 동작(상태 리셋·언마운트 가드·네비게이션)을 해야 하면, 그 분기를 호출부 useAPIMutation(API.x, {onError}) 에 둔다. config onError 는 데코레이터의 generic 기본 toast 를 override 하므로(이중 없음), errorCode별 구체 안내만 뜬다. 호출부 클로저라 alert 컨트롤러·setState·ref·router 등 컴포넌트 스코프에 모두 접근할 수 있다.
const alreadyAlert = useAlert();
// errorCode 별 구체 처리는 호출부 onError 로(데코레이터 기본 toast override).
const submitMutation = useAPIMutation(VoteAPI.voteSubmission, {
onError: error => {
if (
ApiServerError.isApiServerError(error) &&
error.isErrorOfType('VOTE_ALREADY_SUBMITTED')
) {
alreadyAlert.open(); // 화면별 alert 컨트롤러
} else {
toast.error('제출에 실패했어요'); // 그 외 일반 안내
}
setIsSubmitting(false); // 에러 시 컴포넌트 상태 정리
},
});
const handleSubmit = async () => {
try {
await submitMutation.mutateAsync(vars);
} catch {
return; // 에러는 onError 가 처리 → 성공 후처리만 중단
}
goNext(); // 성공 후처리
};왜 데코레이터가 아니라 호출부인가 — 데코레이터는 공유 packages/api 에 있어 (1) 컴포넌트 컨텍스트(alert 컨트롤러·state·언마운트 가드)에 접근할 수 없고, (2) 화면별 UX 문구를 데이터 레이어로 끌어들이게 된다. 그래서 역할을 나눈다: generic 기본 toast 는 데코레이터(엔드포인트 안전망), errorCode별 구체 UX 는 호출부 onError. 호출부에 onError 가 있으면 데코레이터 기본은 자동으로 덮이므로 이중이 나지 않는다.
정책:
- 비-GET API 호출은 컴포넌트/훅에서
useAPIMutation으로 한다. - 에러 UX 는
@MutationOptionsonError 에(표준, 처리됨 신호). 호출부는 통일성을 위해mutateAsync+try/catch(성공 후처리가 없으면 빈catch라도 자연스럽다). - errorCode별 구체 안내·컴포넌트 동작(특정 alert·상태 리셋·언마운트 가드 등)이 필요하면 호출부 config
onError로 처리한다(데코레이터 generic 기본 toast 를 자동 override → 이중 없음). 위 errorCode 별 구체 처리 참조. onError/errorLevel없이try/catch만으로 처리하는 곳은 런타임에 처리 여부가 안 보여 집계되지 않는다(동작엔 문제 없음). onError 로 옮기면 집계에 편입 — 점진 이관 대상.- (참고) 미처리 뮤테이션을 호출 방식과 무관하게 Sentry
error로 직접 보고하는 안전망 자리가useAPIMutation에 있으나, 기존 try/catch-only 사이트의 일괄 스파이크를 막기 위해 현재 비활성(주석) 이다. web try/catch → onError 이관(또는 lint) 후 활성화한다.
서버액션 (server action)
서버액션은 서버에서 ApiServerError를 잡아 serializeForServerAction()으로 직렬화해 던집니다. 클라이언트 catch에서 reportServerActionError 로 복원 + 동일 기록(예상 API 에러 → Datadog, 예상 밖 → Sentry error)을 합니다.
import {reportServerActionError} from '@howmuchhome-web/api';
try {
await someServerAction(args);
} catch (e) {
const err = reportServerActionError(e); // 예상 API 에러 → Datadog, 예상 밖 → Sentry error
if (err?.statusCode === 401) toast.show({message: '비밀번호가 맞지 않아요'});
}Native (Expo)
native 앱(apps/native-app)도 위와 동일한 @howmuchhome-web/logger(Logger) API 를 씁니다. 애플리케이션 코드는 web·native 구분 없이 Logger.log / addAction / startView / captureError 만 호출하며, Datadog·Sentry SDK 를 직접 부르지 않는 원칙도 그대로입니다.
차이는 그 아래 구현뿐입니다. 공유 packages/logger/index.ts 는 web 전제로 @datadog/browser-logs·@datadog/browser-rum·@sentry/nextjs 를 직접 import 하는데, 이 셋은 RN 에서 못 씁니다. 공유 패키지를 수정하지 않고, apps/native-app/metro.config.js 의 커스텀 resolver 가 native 번들에서만 Datadog 브라우저 SDK 를 mobile 어댑터 shim(@datadog/mobile-react-native 기반)으로, @sentry/nextjs 를 @sentry/react-native 로 갈아끼웁니다. 초기화·RUM/에러 필터·소스맵 등 native 세부는 별도로 다룹니다.
자세한 내용은 native 로깅·관측을 참고하세요.
사례 — 간편인증(eziok) 계측
간편인증은 외부 인증앱(카카오·네이버·토스…)을 다녀와야 끝나는 플로우라 실패가 조용합니다. 앱 프로세스가 OS 에 종료되거나 사용자가 인증앱에서 이탈하면 우리 서버에는 아무 요청도 남지 않습니다. 실측(2026-08-21 프로덕션)으로 인증 시작 33건 대비 결과 도달 18건이었고, 서버측 검증 실패는 0건이었습니다 — 즉 “어디서 끊겼는지”는 서버 로그만으로 알 수 없습니다.
그래서 이 플로우는 세 계층에 나눠 계측합니다. 계층을 잇는 상관키는 경로마다 다릅니다 — 표준창 경로는 clientTxId(BFF 발급, URL state 로 왕복)를, API 경로(siv_api_* 이벤트)는 transactionId(서버 거래 ID)를 상관키로 씁니다.
| 계층 | 어디에 | 이벤트 |
|---|---|---|
| web (RUM/Logs) | apps/app/src/features/eziok/logging.ts | siv_view_opened · siv_auth_start · siv_auth_start_failed · siv_auth_failed · siv_result_received · siv_returned_without_result · siv_save_log_ok · siv_save_log_failed · siv_low_power_guide_shown · siv_api_start_ok · siv_api_start_failed · siv_api_fallback_to_std · siv_api_app_link_opened · siv_api_terminal · siv_api_resumed_waiting |
| BFF 라우트 (Vercel 런타임 로그) | apps/app/lib/eziok/log.ts | request_issued · request_failed · key_not_configured · pending_recorded · pending_record_failed · prefill_applied · prefill_skipped · result_ok · result_tx_mismatch · result_issue_date_expired · result_verify_failed · result_no_key_token · result_no_hub_token · result_exception |
| native 셸 (RUM/Logs) | apps/native-app/src/app/native/assignment-simple-identity-verification.tsx, src/components/SimpleIdentityVerificationResumer.tsx | siv_native_screen_opened · siv_native_backgrounded · siv_native_external_app_open · siv_native_returned_from_external_app · siv_native_bounced_to_start · siv_native_webview_process_gone · siv_native_hardware_back · siv_resumed_after_process_kill |
BFF 로그에는 개인정보를 넣지 않습니다. 인증 명의(이름·생년월일·휴대폰)·encCi·hubToken·keyToken 은 실명과 자격증명이고, Vercel 런타임 로그는 접근 통제가 이 값들에 맞춰져 있지 않습니다. 표준창 자동 채우기(prefill_applied)도 적용 여부와 필드 수만 남기고 평문은 라우트 핸들러 스코프를 벗어나지 않습니다. clientTxId 만 예외로 남기는데, 10분 TTL 단회용 거래 식별자이고 이미 URL state 로 왕복하므로 계층을 잇는 상관키로 쓸 수 있습니다.
표준창 되돌림(C 버킷) 기전 판별 (2026-09-15, HMH-9302)
인증앱에서 살아 돌아왔는데 결과가 없는 케이스(C 버킷)는 web 계측만으로는 기전을 알 수 없습니다. siv_returned_without_result 는 “결과 없이 다시 열렸다”는 사실만 알고, 직전 페이지가 cross-origin 표준창이라 어디서 어떻게 돌아왔는지 모릅니다. 그래서 native 셸이 WebView 를 지켜봅니다. 벤더 스펙상 표준창 결과는 브라우저를 거쳐서만 도달하고 허브가 결과를 5분만 보관하므로, 여기서 잡히는 케이스는 같은 거래로는 회수 불가입니다 — 이 계측의 목적은 회수가 아니라 원인 분해입니다.
| 이벤트 / 컨텍스트 | 뜻 | 읽는 법 |
|---|---|---|
siv_native_webview_process_gone | WebView 렌더러(Android)/콘텐츠 프로세스(iOS)가 죽음. 표준창 거래 상태(userToken)는 이때 소멸 | awayForMs 가 있으면 인증앱 왕복 중 죽은 것. 백그라운드 emit 은 유실될 수 있어 하한 |
siv_native_bounced_to_start | 직전 URL 이 표준창(*.ez-iok.com)이었고, 결과 파라미터 없이 SIV 페이지로 넘어옴 | sinceReturnMs 가 수백 ms 면 표준창 스크립트가 history.back() 한 것, 길면 사용자 행동. prevPath 로 어느 표준창 페이지였는지 |
siv_native_hardware_back | Android 뒤로가기(소비하지 않음, 관측만) | lastUrlKind:eziok 이면 표준창 위에서 이탈 |
siv_native_returned_from_external_app 의 lastUrlKind · lastUrlPath · webviewProcessGone | 복귀 순간 WebView 위치와 왕복 중 프로세스 종료 여부 | lastUrlKind:app 이면 돌아오기 전에 이미 우리 페이지로 되돌려진 것 |
siv_resumed_after_process_kill 의 jsUptimeMs | JS 번들이 살아 있던 시간 | 수 초 이하면 콜드스타트(프로세스 킬), 길면 Activity 재생성 등 warm 재마운트 |
URL 은 호스트·경로만 남기고 쿼리는 버립니다 — 표준창 쿼리에는 거래 토큰이, SIV 페이지 쿼리에는 복호화된 실명(eziokResult)이 실립니다. other(임의 사이트)는 경로도 남기지 않습니다.
자동 채우기 효과 추적 (2026-09-10 전면 적용)
표준창 자동 채우기(Auto fill-in)를 2026-09-10 16:2x KST 에 프로덕션 전면 적용했습니다(ADR-0008). 목적은 미도달의 가장 큰 구간 — 인증앱에 도달하기도 전에 이탈하는 버킷 — 을 줄이는 것입니다.
⚠️ 지표 정의가 두 번 바뀌었습니다 (2026-09-13 정정)
처음 적은 정의에 결함이 있어 바로잡았습니다. 아래 정정본을 쓰세요. 옛 정의로 낸 숫자(1차 지표 71.5%)는 유입 구성이 바뀌면 실제 변화 없이 움직입니다.
| 무엇이 틀렸나 | 왜 |
|---|---|
1차 지표에 deviceBrowser 조건이 없었다 | siv_native_external_app_open 은 native 셸 전용이다. 문자 링크를 인앱 브라우저로 연 사용자는 분모에만 들어가 비율을 끌어내린다 |
| 사용자 단위 비율을 다른 길이의 창끼리 비교했다 | 카디널리티는 창이 길수록 재시도 사용자가 병합돼 비율이 올라간다. 같은 길이로만 비교할 수 있다 |
실제로 2026-09-11 문자 발송 때 이 결함이 드러났습니다 — 완료 사용자가 인증앱 도달 사용자보다 많아지는 모순이 나왔습니다.
동결 기준선 (적용 전)
창: 2026-09-01 ~ 09-07 (7일). 09-08 은 제외합니다. 그날 자동 채우기 1차 시도가 8분간 인증을 전면 중단시켜(시도 급증 + 결과 도달 0) 기준선이 아래로 편향됩니다. 09-10 도 적용 당일이라 제외합니다.
시도는 native(deviceBrowser:MWV)만 셉니다.
| 날짜 | 시도(MWV) | ..._external_app_open | 전환율 |
|---|---|---|---|
| 09-01 | 27 | 18 | 66.7% |
| 09-02 | 30 | 21 | 70.0% |
| 09-03 | 21 | 20 | 95.2% |
| 09-04 | 360 | 271 | 75.3% |
| 09-05 | 116 | 86 | 74.1% |
| 09-06 | 135 | 95 | 70.4% |
| 09-07 | 246 | 167 | 67.9% |
| 합계 | 932 | 678 | 72.7% |
2차 지표(종단 완료율)는 native·web 양쪽 다 유효하므로 전체로 셉니다 — 948 시도 중 siv_save_log_ok
507 = 53.5%.
지표 정의
분자·분모를 이벤트명과 필터까지 포함해 고정합니다.
| 지표 | 정의 | 기준선 |
|---|---|---|
| 1차 (인증앱 도달률) | siv_native_external_app_open / (siv_auth_start @context.deviceBrowser:MWV) | 72.7% |
| 2차 (종단 완료율, 시도 단위) | siv_save_log_ok / siv_auth_start | 53.5% |
| 2차 (종단 완료율, 사용자 단위) | siv_save_log_ok / siv_auth_start 의 @usr.id 카디널리티 | 창 길이를 맞춰서 비교 (2일 창 09-06~07 = 91.6%) |
| 프리필 적용률 | prefill_applied / request_issued (Vercel 로그) | — |
| 통신사 도달률 | prefill_applied 중 fieldCount=4 비율 | — |
1차 지표가 이 작업의 대상입니다. 자동 채우기는 표준창 입력 구간만 건드리므로, 종단 완료율은 왕복 실패(프로세스 종료·결과 유실)에도 영향받아 신호가 희석됩니다.
판독 시점과 함정
-
최소 7일 뒤에 판독합니다. 기준선 안에서도 일별 전환율이 66.7~95.2% 로 흔들립니다. 48시간으로는 노이즈와 신호를 구분할 수 없습니다
-
⚠️ 대량 발송 코호트를 분리하세요. 동의서 패키지 문자 발송이 하루에 수백~천 명을 몰고 오며, 그 유입은 인앱 브라우저라 native 지표에 잡히지 않고 사용자 구성도 평시와 다릅니다.
@view.url:*<packageId>*로 코호트를 골라내 제외하거나 따로 봅니다실측(2026-09-11
12): 일산 백송마을1235 통합재건축 동의서 패키지 (7건/일에서 263건/일 로 뛰었습니다.1b983cfe-dcef-40d5-b07a-1989727a8311) 하나가 974명·시도 1,803건으로 그 기간 전체 시도의 85% 를 차지했습니다. 같은 기간deviceBrowser:MB(인앱 브라우저) 시도가 평시 1이 정도 규모는 평균을 완전히 덮어씁니다. 발송이 있었는지 모른 채 지표만 보면 “적용 후 전환율 10%p 하락”이라는 잘못된 결론이 나옵니다(실제로 한 번 그렇게 읽었습니다).
-
⚠️
siv_native_returned_from_external_app은..._external_app_open보다 많습니다. 복귀가 진입의 부분집합이 아니므로 차분으로 버킷을 계산하면 안 됩니다 -
2026-09-10 에 웹뷰 정리 배치 A~E 가 함께 배포됐습니다. 지표가 크게 흔들리면 이 변경도 후보로 두세요
조회
# 1차 지표 — native 한정 (이 필터가 빠지면 유입 구성 변화에 오염된다)
@type:action @action.name:siv_auth_start @context.deviceBrowser:MWV → 분모
@type:action @action.name:siv_native_external_app_open → 분자
# 2차 지표 (시도 단위)
@type:action @action.name:(siv_auth_start OR siv_save_log_ok)
→ group by [@action.name], interval 86400000
# 대량 발송 코호트 분리 (packageId 는 SIV 화면 URL 에 들어 있다)
@type:action @action.name:siv_auth_start @view.url:*<packageId>*
# ⚠️ native 이벤트(external_app_open)는 view.url 이 native 화면이라 이 필터로 안 걸린다
# 유입 구성 확인 — MB(인앱 브라우저) 가 튀면 대량 발송이 있었다는 뜻
@type:action @action.name:siv_auth_start → group by @context.deviceBrowser, interval 86400000
# 프리필 적용률·통신사 도달률 (Vercel, 프로덕션 배포 URL 필요)
vercel logs <prod-url> --scope howmuchhome --no-branch --query eziok --limit 500 --json
# prefill_applied 의 fieldCount: 3 = 통신사 없음(은행 인증), 4 = 통신사 포함
# protocol_selected 의 version 이 v2 인지도 함께 확인 (강등되면 프리필이 안 나간다)되돌릴 조건
EZIOK_PROTOCOL_VERSION 을 지우고 재배포하면 v1 으로 돌아갑니다(즉시 필요하면 이전 배포로
vercel promote — 2초). 아래 중 하나면 되돌립니다.
- 종단 완료율이 기준선(53.5%) 대비 유의하게 하락
result_*실패 이벤트나 표준창 오류코드가 새로 등장protocol_downgraded가 지속 발생 (v2 구성이 깨진 것)
핵심 지표는 siv_returned_without_result 입니다. “인증을 시작했는데 결과 파라미터 없이 화면이 다시 열렸다” = 결과 유실이고, 이 사건은 서버에 아무 요청도 남기지 않아 이 이벤트 없이는 관측이 불가능합니다. 표식을 sessionStorage 가 아니라 localStorage 에 두는 이유도 이것입니다 — 프로세스가 죽으면 WebView 가 파괴되어 sessionStorage 는 함께 사라지고, 정작 가장 알고 싶은 케이스를 놓칩니다.
native 쪽에서 배운 것 하나: 처음에는 외부 인증앱 딥링크를 가로채는 시점(onShouldStartLoadWithRequest)에 중단 복구 표식을 남겼는데, 계측을 붙여 보니 그 분기가 프로덕션에서 0건이었습니다(표준창이 핸들러를 우회해 앱을 띄움). 그래서 표식은 AppState 백그라운드 전환에서 남기고, 그 경로가 실제로 도는지 siv_native_backgrounded 로 확인합니다 — 계측이 없었다면 죽은 코드인 줄 몰랐을 사례입니다.
BFF 라우트는 route handler(Node 런타임)라 Logger 를 쓸 수 없습니다 — 브라우저 SDK 기반이고 Logger.isServer 가드로 전부 no-op 이 됩니다. 대신 Vercel 런타임 로그로 나가는 구조화 console 출력을 쓰고, 이렇게 조회합니다.
vercel logs -p howmuchhome-web-app --scope howmuchhome \
--environment production --no-branch --since 10h --limit 500 \
--query "[eziok]" --json⚠️ 이 플로우의 로그에는 인증 명의(이름·생년월일)·encCi·hubToken 을 절대 넣지 않습니다. 표준창 결과의
data는 복호화된 실명입니다. 남길 수 있는 건 거래 식별자(clientTxId)·인증기관(providerId)·상태·소요시간뿐입니다.
패키지 레퍼런스
자세한 API는 @howmuchhome-web/logger를 참고하세요.