
Sentry 커스텀 계측으로 FE 무결성을 증명한 로그인 풀림 트러블슈팅 (일 400건)
Sentry 커스텀 계측으로 FE 무결성을 증명한 로그인 풀림 트러블슈팅 (일 400건)
기간: 2026-03-21 ~ 2026-03-24 규모: 1,200건 이상 (하루 약 400건 지속 발생) 결론: Sentry 커스텀 계측으로 FE 무결성을 데이터로 증명 → 백엔드 코드 버그 확정 및 수정
1. 문제 상황
앱/웹에서 로그인이 간헐적으로 풀리는 현상이 다수 사용자에게 발생했다. 재현 조건이 불명확하고 FE / BE / 앱 어느 쪽이 원인인지 특정하기 어려운 상태였다.
초기 가능성으로 열어둔 원인:
- FE 토큰 갱신 로직 버그
- 동시 다중 401 처리의 race condition
- 앱(WebView) 전용 이슈
- BE refreshToken 관리 정책 문제
2. 접근 방식: 먼저 계측, 그다음 분석
원인이 불명확한 상태에서 코드를 건드리는 대신, Sentry 커스텀 계측을 먼저 설계해 실제 사용자 환경의 데이터를 수집했다.
2-1. captureMessage로 실패 이벤트 생성
강제 로그아웃이 발생하는 logoutAndRedirect 시점에 토큰의 발급/만료 시각, 환경, 실패 이유를 모두 기록했다.
Sentry.captureMessage('token_refresh_failed: 토큰 갱신 실패로 강제 로그아웃', { level: 'warning', extra: { reason, // 실패 분류 (C999, network_error 등) memberNo, isApp: isApp(), isOnline: navigator.onLine, accessExpiry: getTokenExp(accessToken), // JWT decode로 만료 시각 추출 refreshExpiry: getTokenExp(refreshToken), accessIssuedAt: getTokenIat(accessToken), // 발급 시각 → 이전 갱신 이력 추적 refreshIssuedAt: getTokenIat(refreshToken), at: maskToken(accessToken), // 뒤 10자 마스킹 rt: maskToken(refreshToken) } });
설계 의도: accessIssuedAt vs refreshIssuedAt가 다르면 이전에 갱신 성공 이력이 있다는 뜻. 이 필드 하나로 "처음 시도라서 실패했다" 가설을
데이터로 반박할 수 있었다.
2-2. addBreadcrumb로 전체 흐름 추적
토큰 갱신 과정의 모든 분기점에 브레드크럼을 심어, 실패 이벤트에 경위가 자동으로 첨부되도록 설계했다.
| 브레드크럼 | 기록 내용 |
|---|---|
401 감지 | 어떤 API가 401을 받았는지, isTokenRefreshing 상태 |
refresh 시작 - 이 요청이 트리거 | 갱신을 시작시킨 최초 API URL |
갱신 로직 진입 | accessToken 만료 여부 (atExpired) |
refresh 실패 | 서버 응답 코드, 이유, step |
이 구조 덕분에 각 이벤트에서 "어떤 API가 먼저 401을 받았고 → refresh를 시도했고 → 서버가 어떻게 응답했는지"를 한 화면에서 볼 수 있었다.
2-3. Sentry Data Scrubbing 우회
Sentry는 필드명에 token, api 등이 포함되면 값을 [Filtered]로 자동 마스킹한다. 정작 필요한 필드가 전부 가려지는 문제가 있었다.
우회 전략 ①: 동일 값을 다른 이름으로 중복 저장
accessTokenExp: getTokenExp(accessToken), // [Filtered] — 안 보임 accessExpiry: getTokenExp(accessToken), // ✅ 보임
우회 전략 ②: 브레드크럼 message가 필터링되면 data.step으로 대체
Sentry.addBreadcrumb({ category: 'token', message: 'refresh 실패: server_error_401_C999', // category가 'token'이라 필터링될 수 있음 data: { step: 'refresh_실패_server_error_401_C999' // data는 필터링 패턴에 안 걸림 ✅ } });
3. FE 토큰 갱신 로직
계측 설계의 기반이 된 실제 axios interceptor 흐름:
401 수신 (C999 제외)
└─ isTokenRefreshing 이미 true → errorAxiosRetry 폴링 (500ms × 최대 2회)
└─ isTokenRefreshing false
└─ memberAuthToken null → 브레드크럼 기록 (갱신 불가)
└─ memberAuthToken 있음
└─ refreshToken 만료? → 로그아웃 (서버 요청 없음, JWT decode로 판단)
└─ refreshToken 유효 → refreshAuthToken() 진입
└─ accessToken 만료 또는 만료 10분 전
└─ YES → 카시나 refresh API 호출 (access + refresh 신규 발급)
└─ NO → 기존 accessToken 유지
└─ 샵바이 accessToken 재발급 (항상)
└─ isTokenRefreshing = false
└─ errorAxiosRetry → 원래 요청 재시도 → 성공
핵심: 동시에 401이 N개 와도 isTokenRefreshing 플래그로 refresh는 단 1회만 실행된다. 이 동작이 브레드크럼에서 반복적으로 확인됐다.
4. 수집 데이터 분석
4-1. 10건 상세
| 이벤트 | 발급 후 경과 | refreshToken JWT 잔여 | 환경 | 트리거 |
|---|---|---|---|---|
| fc23dc37 | 23시간 | 89일 | iOS 앱 | 샵바이 M0013 |
| 67300a42 | 11일 | 79일 | Android 앱 | 샵바이 M0013 |
| a275188f | 22일 | 68일 | Android 앱 | 샵바이 M0013 |
| d76d5489 ⭐ | 58일 | 32일 | iOS 앱 | 샵바이 M0013 |
| 507d2f3b | 15일 | 75일 | iOS 앱 | 샵바이 M0013 |
| b72fd338 ⭐ | 18시간 | 90일 | iOS 앱 | 카시나 C010 |
| 7ed8f1eb | 87일 | 3일 | 웹 Chrome | 샵바이 M0013 |
| ba254a24 | 78일 | 12일 | 웹 Chrome | 샵바이 M0013 |
| b80f691e | 52일 | 86일 | 웹 Samsung | 샵바이 M0013 |
| 8f64cba4 | 29시간 | 89일 | iOS 앱 | 샵바이 M0013 |
C999 발생 시점에 JWT 만료된 건: 0건 / 10건
4-2. 결정적 증거 — 이벤트 d76d5489
2026-01-22 로그인 → refreshToken 발급
2026-02-24 동일 refreshToken → refresh ✅ 성공 (accessToken 갱신됨)
2026-03-21 동일 refreshToken → refresh ❌ C999
Sentry 데이터:
accessIssuedAt: 2026-02-24→ Feb 24 갱신 성공 확인refreshIssuedAt: 2026-01-22→ 동일 토큰 그대로
Feb 24에 성공했던 토큰이 Mar 21에 실패 → FE 코드가 원인이라면 Feb 24에도 실패했어야 한다. 서버가 그 사이에 무효화한 것이 데이터로 확정됐다.
4-3. FE 무결성 증거 3가지
| # | 이벤트 | 증명하는 것 |
|---|---|---|
| 1 | d76d5489 | Feb 24 성공 이력 → "첫 시도라서 실패" 가설 배제 |
| 2 | b72fd338 | 샵바이 전부 200인데도 카시나 C010 트리거 → FE가 두 토큰 상태를 정확히 구분해 동작 |
| 3 | 271ada53 (정상) | JWT 만료 refreshToken → 서버 요청 없이 자체 로그아웃 → 동일 코드가 정상 케이스에서 의도대로 작동 |
5. 가설별 검증
| 가설 | 검증 방법 | 결과 |
|---|---|---|
| FE race condition | isTokenRefreshing 브레드크럼 — 모든 이벤트에서 단일 refresh 확인 | ❌ 제외 |
| refreshToken 만료 | 10건 전부 JWT exp 기준 유효, 백엔드에 "만료 아니냐" 반박 시 데이터 제시 | ❌ 제외 |
| 장기 미접속 패턴 | 경과 시간 18시간 ~ 87일, 패턴 없음 | ❌ 제외 |
| 앱 전용 이슈 | 웹 Chrome 2건, 웹 Samsung 1건 포함 확인 | ❌ 제외 |
| 50% 갱신 정책 | 45일 초과 4건 설명 가능, 18시간/29시간 케이스 설명 불가 | ⚠️ 부분만 해당 |
| FE deleteOauthToken 비의도 호출 | 호출 시 localStorage도 삭제 → C999 이벤트 자체가 발생 불가 | ❌ 제외 |
| 백엔드 코드 버그 | 백엔드 직접 확인 | ✅ 확정 |
6. 해결
백엔드 코드 버그 확인 및 수정 완료. (2026-03-24)
근본 원인: 서버가 특정 조건에서 유효한 refreshToken을 잘못 무효화하는 로직이 있었음.
7. 회고
이 접근의 핵심
재현이 안 되는 이슈는 코드를 보는 게 아니라 데이터를 만드는 것부터 시작해야 한다.
FE/BE 중 어디가 문제인지 모르는 상황에서 코드만 보고 추측하면 시간만 낭비한다. Sentry에 계측을 먼저 심고, 실제 사용자 환경에서 발생하는 데이터를 수집한 뒤 분석했다.
결과적으로:
- 1,200건의 이벤트를 통해 FE 로직이 매 케이스마다 정상 플로우를 탔음을 증명
- 백엔드가 "만료된 토큰이 아니냐"고 반박했을 때, JWT exp 값과 이벤트 4의 갱신 성공 이력으로 즉시 데이터 반박
- 감정이나 추측 없이 데이터로만 대화해서 빠르게 원인을 좁혔다