
모바일 앱을 개발하다 보면 로컬 테스트나 QA에서는 보이지 않던 문제가 실제 사용자 환경에서 발생한다.
특히 Flutter 앱은 iOS, Android, OS 버전, 앱 버전, 네트워크 상태, 로그인 세션 상태에 따라 오류 양상이 달라진다.
문제는 이런 오류가 사용자 제보만으로는 추적하기 어렵다는 점이다.
“앱이 튕겼어요”, “버튼을 눌렀는데 안 돼요”라는 제보만으로는 어떤 API가 실패했는지, 어떤 앱 버전에서 발생했는지, 서버 장애인지 사용자 입력 문제인지 판단하기 어렵다.
그래서 ONMU Flutter 앱에 Sentry를 도입하기로 했다.
다만 이번 작업의 목표는 단순히 Sentry SDK를 붙이는 것이 아니었다. 앱에서 발생하는 오류를 분류하고, 사용자에게 보여줄 오류와 운영자가 추적해야 할 오류를 분리하는 것이 핵심이었다.
(참고로 발생했다고 바로 보고되는건 아니고, 약간의 지연시간(분 단위) 는 존재한다.)

이메일로도 옴!

이런식으로 프론트엔드 디버깅에서 중요한 내용까지 태그로 설정하여 파악 가능하다.
Sentry는 앱에서 발생한 오류를 수집하고, 오류가 발생한 환경과 흐름을 추적할 수 있게 해주는 observability 도구다.
Sentry를 통해 다음과 같은 정보를 확인할 수 있다.
| 항목 | 확인 가능한 내용 |
|---|---|
| Platform | iOS, Android, Web 등 오류가 발생한 플랫폼 |
| Environment | local, dev, staging, production |
| App version | 어떤 앱 버전에서 발생했는지 |
| Release | 특정 배포 이후 오류가 증가했는지 |
| Device / OS | 기기 모델, OS 버전 |
| Stack trace | 오류가 발생한 코드 위치 |
| Frequency | 같은 오류가 얼마나 자주 발생하는지 |
| User impact | 몇 명의 사용자에게 영향을 줬는지 |
| Breadcrumb | 오류 직전의 화면 이동, API 요청 등 흐름 |
즉 Sentry는 단순히 “앱이 죽었다”를 알려주는 도구가 아니다.
어떤 배포 이후, 어떤 환경에서, 어떤 기능을 사용하다가 문제가 발생했는지 확인할 수 있게 해준다.
하지만 Sentry를 제대로 사용하려면 먼저 앱 내부의 오류 처리 기준이 정리되어 있어야 한다.
ONMU Flutter 앱은 Spring API와 통신하기 위해 Dio를 사용하고 있다. Dio는 Flutter/Dart에서 많이 쓰이는 HTTP client 라이브러리로, API 요청 실패 시 DioException을 던진다.
예를 들어 다음 상황은 모두 DioException으로 전달될 수 있다.
| 상황 | 실제 의미 |
|---|---|
| 400 | 사용자의 입력값이 잘못됨 |
| 401 | 로그인 세션이 없거나 만료됨 |
| 403 | 권한이 없음 |
| 404 | 리소스가 존재하지 않음 |
| 409 | 이미 처리된 요청 또는 중복 요청 |
| 500 | 서버 내부 오류 |
| 503 | 서버 일시 장애 또는 배포 중 |
| timeout | 네트워크 지연 또는 서버 응답 지연 |
문제는 이 오류들을 그대로 Sentry에 보내면 운영 신호가 흐려진다는 것이다.
예를 들어 사용자가 빈 검색어를 입력한 것, 이미 친구인 사용자를 다시 추가한 것, 로그인 전 /users/me 요청이 401을 반환한 것은 장애가 아니다.
반면 500, 503, 서버 응답 형식 불일치, 알 수 없는 예외는 반드시 확인해야 하는 문제다.
따라서 Sentry를 붙이기 전에 먼저 오류를 ONMU 앱 기준으로 다시 분류해야 했다.
공통 오류 타입으로 OnmuException을 만들고, 오류 종류를 OnmuErrorKind로 분류했다.
| Error Kind | 의미 |
|---|---|
validation | 입력값 오류 |
unauthorized | 인증 필요, 세션 만료 |
forbidden | 권한 없음 |
notFound | 리소스 없음 |
conflict | 중복 요청, 이미 처리된 요청 |
rateLimited | 요청 과다 |
server | 서버 내부 오류 |
unavailable | 서버 일시 사용 불가 |
timeout | 요청 시간 초과 |
network | 네트워크 연결 문제 |
contractMismatch | 서버 응답 형식이 앱 계약과 다름 |
externalProvider | OAuth, 지도, 외부 API 오류 |
permissionDenied | 기기 권한 거절 |
cancelled | 사용자가 취소한 동작 |
backgroundSync | push token, read sync 등 백그라운드 실패 |
unknown | 분류되지 않은 오류 |
OnmuException에는 사용자 메시지와 개발자용 메시지를 분리해서 담았다.
class OnmuException implements Exception {
final OnmuErrorKind kind;
final String userMessage;
final String technicalMessage;
final int? statusCode;
final String? method;
final String? endpoint;
final String feature;
final bool retryable;
final bool reportable;
final Object? cause;
}
여기서 중요한 필드는 retryable과 reportable이다.
retryable은 사용자가 다시 시도할 수 있는 오류인지 나타낸다.
reportable은 Sentry에 보고할 가치가 있는 오류인지 나타낸다.
이렇게 분리하면 같은 API 실패라도 사용자 경험과 운영 보고를 다르게 처리할 수 있다.
ViewModel이 DioException을 직접 다루지 않도록 OnmuApiClient에서 API 오류를 정규화했다.
흐름은 다음과 같다.
DioException
→ OnmuApiException
→ OnmuErrorKind 매핑
→ UI 상태 변환
→ Sentry 보고 여부 판단
예를 들어 503은 다음처럼 해석된다.
HTTP 503
→ kind: unavailable
→ retryable: true
→ reportable: true
→ 사용자에게 재시도 UI 표시
→ Sentry 보고
반대로 친구 추가 중 이미 친구인 경우의 409는 장애로 보지 않는다.
HTTP 409
→ kind: conflict
→ userMessage: 이미 친구예요.
→ retryable: false
→ reportable: false
→ Sentry 보고 제외
이 구조를 통해 ViewModel은 HTTP status code에 직접 의존하지 않고, 앱이 이해할 수 있는 오류 타입만 다루게 된다.
HTTP status와 네트워크 오류는 다음 기준으로 매핑했다.
| HTTP / 상황 | ONMU Error Kind | 사용자 처리 | Sentry 보고 |
|---|---|---|---|
| 400, 422 | validation | form error, snackbar | 보고 안 함 |
| 401 | unauthorized | 로그인 이동, 세션 만료 처리 | 정상 bootstrap에서는 보고 안 함 |
| 403 | forbidden | 권한 없음 안내 | 예상 가능한 권한 오류는 보고 안 함 |
| 404 | notFound | 없음/삭제됨 안내 | 보고 안 함 |
| 409 | conflict | 이미 처리됨, 중복 안내 | 보고 안 함 |
| 429 | rateLimited | 잠시 후 재시도 | 샘플링 |
| 500 | server | 재시도 UI | 보고 |
| 502, 503, 504 | unavailable | 재시도 UI | 보고 |
| timeout | timeout | 네트워크/재시도 안내 | 샘플링 |
| offline/socket | network | 네트워크 안내 | 낮은 샘플링 |
| JSON 필수값 누락 | contractMismatch | fallback 또는 오류 UI | 반드시 보고 |
| 알 수 없는 예외 | unknown | 일반 오류 UI | 보고 |
이 표를 기준으로 “사용자가 해결할 수 있는 오류”와 “운영자가 확인해야 하는 오류”를 분리했다.
Sentry에 보고하는 오류는 다음으로 제한했다.
| 보고 대상 | 이유 |
|---|---|
server | 서버 내부 장애 가능성 |
unavailable | 배포, 인프라 장애 가능성 |
contractMismatch | Flutter와 Spring API 계약 불일치 |
unknown | 분류되지 않은 예외 |
| OAuth 설정 오류 | client id, redirect URI, callback parsing 문제 |
| push token 등록 실패 | 화면을 막지는 않지만 운영 추적 필요 |
| 지도 tile manifest 오류 | 지도 기능 저하 가능성 |
| 반복적인 realtime reconnect 실패 | 채팅/실시간 기능 품질 문제 |
보고하지 않는 오류도 명확히 정했다.
| 제외 대상 | 이유 |
|---|---|
| 400, 422 | 사용자 입력 오류 |
| 정상적인 401 | 로그인 전/세션 없음은 정상 흐름 |
| 403 | 예상 가능한 권한 제한 |
| 404 | 삭제된 리소스 접근 가능성 |
| 409 | 이미 처리된 요청, 중복 요청 |
| 이미지 선택 취소 | 사용자 액션 |
| 권한 거절 | 사용자 선택 |
| 빈 검색어, 폼 미완성 | 클라이언트 검증 대상 |
| optimistic update rollback 자체 | 실패 처리 흐름의 일부 |
이 정책을 통해 Sentry가 사용자 행동에서 발생한 정상적인 실패로 오염되지 않도록 했다.
모든 오류를 같은 비율로 Sentry에 보내지도 않았다.
오류 종류에 따라 중요도와 발생 빈도가 다르기 때문이다.
Sampling rate는 0.0 ~ 1.0 범위로 관리했다.
| 값 | 의미 |
|---|---|
0.0 | 전혀 보고하지 않음 |
0.1 | 약 10%만 보고 |
0.5 | 약 50% 보고 |
1.0 | 전부 보고 |
ONMU에서는 다음과 같은 기준을 잡았다.
| Error Kind | Sampling Rate | 이유 |
|---|---|---|
server | 1.0 | 서버 내부 장애는 반드시 추적해야 함 |
unavailable | 1.0 | 배포/인프라 장애 가능성이 있어 전부 보고 |
contractMismatch | 1.0 | Flutter와 Spring API 계약 불일치는 즉시 확인 필요 |
unknown | 1.0 | 분류되지 않은 오류는 원인 파악 필요 |
rateLimited | 0.3 | 반복 발생 가능성이 있어 일부만 수집 |
timeout | 0.2 | 사용자 네트워크 영향이 커서 샘플링 |
network | 0.1 | 오프라인/불안정 네트워크 노이즈를 줄이기 위함 |
validation | 0.0 | 사용자 입력 오류는 보고하지 않음 |
unauthorized | 0.0 | 정상적인 세션 없음 흐름은 보고하지 않음 |
conflict | 0.0 | 중복 요청/이미 처리됨은 장애가 아님 |
또한 sampling rate 값은 항상 0.0 ~ 1.0 범위로 clamp되도록 했다.
final safeRate = samplingRate.clamp(0.0, 1.0);
설정값이 잘못 들어오더라도 Sentry 보고량이 의도치 않게 폭증하거나 완전히 깨지지 않게 하기 위해서다.
| 입력값 | 실제 적용값 |
|---|---|
-0.5 | 0.0 |
0.2 | 0.2 |
1.7 | 1.0 |
이를 통해 반드시 봐야 하는 오류는 놓치지 않으면서, 모바일 환경에서 흔한 네트워크성 오류가 Sentry issue를 과도하게 만들지 않도록 했다.
Sentry는 오류 분석에 유용하지만, 잘못 사용하면 민감 정보가 외부 도구에 남을 수 있다.
그래서 Sentry tag에 넣을 수 있는 값도 제한했다.
허용한 값은 다음과 같다.
| Tag | 예시 |
|---|---|
feature | auth, group, plan, chat |
kind | server, validation, contractMismatch |
statusCode | 500, 503 |
method | GET, POST |
endpoint_template | /api/v1/groups/{groupId} |
retryable | true, false |
environment | staging, production |
반대로 다음 값은 Sentry에 보내지 않도록 했다.
| 금지 항목 | 이유 |
|---|---|
| JWT, Authorization header | 인증 정보 |
| request body | 사용자 입력 포함 가능 |
| response body | 개인정보 포함 가능 |
| nickname, email | 개인 식별 정보 |
| 채팅 내용 | 사적 대화 |
| 메모, 기록 내용 | 사용자 생성 콘텐츠 |
| 실제 위치 상세값 | 민감 정보 가능성 |
즉 Sentry에는 “어디서 어떤 종류의 오류가 났는지”만 남기고, “사용자가 무엇을 입력했는지”는 남기지 않는 방향으로 설계했다.
Sentry 보고와 사용자 UI 처리는 분리했다.
사용자에게 오류를 어떻게 보여줄지는 OnmuUiError로 변환해서 처리했다.
enum OnmuErrorPresentation {
silent,
snackbar,
inline,
fullScreen,
retryableCard,
}
상황별 UI 처리는 다음과 같이 나눴다.
| 상황 | UI 처리 |
|---|---|
| 초기 화면 로딩 실패 | full screen 또는 retryable card |
| 저장, 삭제, 좋아요 등 mutation 실패 | 기존 화면 유지 + snackbar |
| optimistic update 실패 | rollback + snackbar |
| push token, read sync 실패 | silent |
| contract mismatch | fallback 가능하면 fallback + Sentry 보고 |
| unauthorized | 로그인 흐름으로 전환 |
예를 들어 채팅 메시지 전송 실패는 앱 전체 오류 화면으로 보내지 않는다.
pending message
→ failed message로 변경
→ “메시지를 보내지 못했어요.” 표시
→ reportable 오류면 Sentry 보고
반대로 push token 등록 실패는 사용자 화면에 직접 보여줄 필요가 없다.
push token 등록 실패
→ 화면 영향 없음
→ backgroundSync 오류로 기록
→ reportable이면 Sentry 보고
이렇게 하면 사용자 경험을 유지하면서도 운영 관점에서 필요한 오류는 추적할 수 있다.
이번 작업에서 구성한 구조는 다음과 같다.
| 단계 | 작업 |
|---|---|
| 1 | OnmuErrorKind, OnmuException 정의 |
| 2 | OnmuApiException으로 DioException 정규화 |
| 3 | OnmuContractException으로 JSON contract mismatch 처리 |
| 4 | OnmuReportPolicy로 Sentry 보고 여부 분리 |
| 5 | sampling rate 정책 추가 |
| 6 | OnmuUiError로 ViewModel UI 상태 변환 |
| 7 | OnmuErrorReporter 래퍼 추가 |
| 8 | 추후 Sentry SDK 연결 시 reporter 내부 구현만 교체 가능하게 설계 |
전체 흐름은 다음과 같다.
DioException
↓
OnmuApiException
↓
OnmuReportPolicy
↓
Sampling Rate
↓
OnmuErrorReporter
↓
Sentry
UI 처리는 별도 흐름으로 분리했다.
OnmuException
↓
OnmuUiError
↓
snackbar / fullScreen / retryableCard / silent
오류 정책은 테스트로 고정했다.
| 테스트 | 기대값 |
|---|---|
DioException(statusCode: 400) | validation, reportable=false |
DioException(statusCode: 503) | unavailable, retryable=true, reportable=true |
| receive timeout | timeout, retryable=true |
| 필수 JSON 필드 누락 | contractMismatch, reportable=true |
| 친구 추가 409 | “이미 친구예요.”, reportable=false |
| 채팅 전송 실패 | pending message가 failed로 변경 |
| push token 등록 실패 | 사용자 화면 영향 없음, report policy 적용 |
테스트를 통해 단순히 예외 클래스를 추가하는 데서 끝내지 않고, 실제 ViewModel 상태와 운영 보고 정책이 의도대로 동작하는지 확인했다.
이번 Sentry 도입에서 가장 중요했던 점은 SDK 설치가 아니었다.
핵심은 앱에서 발생하는 오류를 운영 가능한 신호로 바꾸는 것이었다.
기존처럼 DioException을 그대로 다루면 사용자 입력 오류, 인증 만료, 중복 요청, 서버 장애, API contract mismatch가 모두 비슷한 실패처럼 보인다. 그렇게 되면 Sentry를 붙여도 실제로 대응해야 할 문제를 찾기 어렵다.
그래서 ONMU에서는 오류를 먼저 앱 기준의 taxonomy로 분류하고, 사용자에게 보여줄 오류와 Sentry에 보고할 오류를 분리했다. 여기에 sampling rate와 민감 정보 필터링 정책을 더해, 중요한 오류는 놓치지 않으면서도 노이즈와 개인정보 노출 위험을 줄였다.
결과적으로 Sentry는 단순한 crash reporting 도구가 아니라, Flutter 앱과 Spring API 사이의 계약 문제, 서버 장애, 특정 배포 이후의 회귀를 추적하는 운영 도구로 사용할 수 있게 되었다.