iOS와 Android에서 통계 숫자가 다르게 보인 이유 (원인과 해결 과정)
버그를 발견하다
개발하던 앱의 기록 탭에서 이상한 버그를 발견했다. 2026년 기준 ‘읽는 중’ 권수가 iOS는 6권, Android는 7권으로 다르게 표시되고 있었다. 정작 하단 목록을 펼쳐보면 두 플랫폼 모두 7권이 나왔다. 숫자만 다르고 실제 데이터는 같았다는 뜻이다.
처음에는 “앱마다 집계 로직이 다른가?” 싶었지만, 파고들어 보니 원인은 서버였다. 화면마다 ‘연도’의 기준이 서로 다르게 섞여 있었다.
문제는 집계와 목록의 연도 기준이 달랐다
서버의 연도별 상태 집계 API는 ‘읽는 중’ 상태를 이렇게 계산하고 있었다.
status = 'B'
AND created_at LIKE '2026%' -- 서재 등록일 기준
한마디로 2026년에 서재에 ‘등록’한 책만 세고 있었다.
반면 기록 탭의 ‘읽는 중’ 목록은 다른 API에서, 다른 기준으로 조회하고 있었다.
year_date = '2026' -- 읽기 시작일에서 파생된 값
그래서 2025년에 등록했지만 2026년에 읽기 시작한 책은 목록에는 나오는데, 집계에는 잡히지 않는 상황이 벌어졌다. 여기에 더해 Android는 실제 목록 배열의 길이를 화면 숫자로 우선 사용했고, iOS는 서버가 내려주는 집계값을 우선 사용했다. 같은 데이터를 두고 서로 다른 소스를 신뢰한 것이다. 그 결과가 6권 대 7권이었다.
상태별 날짜 기준을 다시 정의하다
연도별 기록 화면이 답해야 할 질문은 “서재에 언제 등록했는가”가 아니라 “어떤 독서 이벤트가 언제 일어났는가”다. 그래서 우선 상태별로 기준 날짜를 다음과 같이 정리했다.
| 항목 | 연도 기준 |
|---|---|
| 등록 도서 | 등록한 날 |
| 읽고 싶어요 | 등록한 날 |
| 읽는 중 | 읽기 시작한 날 |
| 다 읽었어요 | 다 읽은 날 |
| 읽다 멈췄어요 | 중단한 날 |
이 기준에 맞춰 서버 집계 쿼리를 수정했다.
-- 읽는 중
status = 'B' AND year_date = :year
-- 읽다 멈췄어요
status = 'D' AND end_date LIKE CONCAT(:year, '%')
완독·중단 건은 날짜 형식이 잘못 들어간 레코드를 걸러내는 검증도 추가했다.
앱 목록도 같은 기준으로 맞추다
서버 쿼리의 수정만으로 완료가 되었다면 좋았겠지만 문제가 끝나지 않았다. 이번엔 반대로 ‘읽다 멈췄어요’ 카드 숫자와 하단 목록이 어긋나 버렸다. 기존 iOS/AOS 기록 탭은 ‘중단한 책’의 상태 목록을 등록일 기준으로 필터링하고 있었기 때문이다. 이를 중단일 기준으로 바꿔야 했다.
이제 2025년에 등록했지만 2026년에 읽기를 멈춘 책은 이렇게 동작한다.
- 2026년 기록 탭의 ‘읽다 멈췄어요’에 표시된다
- 2025년 서재 탭에는 여전히 남아 있다
- 기록 탭의 집계 숫자와 목록이 일치한다
여기서 놓치기 쉬운 포인트가 있다. 기록 탭과 서재 탭의 ‘연도’가 같은 의미가 되도록 통일하지 않았다는 점이다. 기록 탭은 ‘독서 이벤트가 일어난 연도’를, 서재 탭은 ‘책을 서재에 등록한 연도’를 보여준다. 이름은 비슷해도 사용자에게 답하는 질문이 다르기 때문에, 억지로 기준을 하나로 합치지 않는 편이 맞다고 판단했다.
새로고침하면 서재 필터가 사라지는 이유
추가로 서재 탭에서 신고된 버그도 있었다. 새로고침을 하면 분류 필터가 아무 책도 찾지 못하는 문제였다. 정말 난감하다.
분석 결과 원인은 최초 진입과 당겨서 새로고침이 서로 다른 API를 호출하고 있었기 때문이었다. 두 API는 같은 전체 도서 데이터를 내려주지만 응답 필드 구성이 달랐다.
| 필드 | 최초 진입 API | 새로고침 API |
|---|---|---|
| status | 정규화된 값 | 원본 값 |
| 분류 정보 | 포함 | 누락 |
| 시작일/종료일 | 포함 | 누락 |
| ISBN | 포함 | 누락 |
분류 정보 필드가 새로고침 응답에서 통째로 빠져 있었으니, 새로고침 후 필터가 작동하지 않는 게 당연했다. 새로고침 API 응답에 누락된 필드를 추가하고 상태값 정규화 규칙도 동일하게 맞췄다. 서버만 수정하면 되는 문제라 앱을 다시 배포하지 않고도 해결할 수 있었다.
중복된 응답 조립 코드를 하나로 모으다
이런 문제들의 공통 원인을 파고들면 결국 하나로 수렴한다. 여러 API 엔드포인트가 같은 도서 데이터를 각자 JSON으로 변환하면서, 그때그때 다른 필드를 넣고 있었다는 것이다.
이를 막기 위해 도서 응답을 만드는 로직을 매퍼 하나로 분리했다.
Map<String, Object> toListItem(
BookWebAppDTO book,
String noImageUrl
);
이 매퍼를 상태 초기화, 서재 초기화, 목록 조회 API가 공통으로 사용하도록 바꿨다. 이제 상태값, 분류, 날짜, ISBN, 이미지 기본값 등의 응답 형식이 한 곳에서 관리된다. 앞으로 특정 API에만 필드가 빠지는 실수를 구조적으로 줄일 수 있게 됐다.
인증 로직을 먼저 떼어내다
이번 작업을 하며 알게 된 건, 컨트롤러가 과다하게 늘어났다면 리팩터링 순서도 신경 써야 한다는 점이었다. 서재 관련 API를 별도 컨트롤러로 옮기려 했지만, 인증 로직이 private 메서드로 강하게 얽혀 있어 그대로 옮길 수 없었다.
그래서 라우트를 옮기기 전에 인증·토큰 갱신 로직을 전담 컴포넌트로 분리했다.
기존 컨트롤러의 로그인 유저 조회 로직은 이제 이 컴포넌트에 위임한다.
return requestAuthenticator.authenticate(
request,
(userId, uri) -> blockForceUpdateBeforePrivateApi(request, userId, uri)
);
강제 업데이트 차단 규칙은 일부러 기존 컨트롤러에 그대로 남겨 동작 순서를 바꾸지 않았다. 인증 경계를 먼저 떼어놓았기 때문에, 다음 단계에서는 서재 API를 별도 컨트롤러로 안전하게 옮길 수 있다.
정리
이번 작업의 핵심은 숫자 하나를 고치는 게 아니라 기준을 정리하는 데 있었다.
- 상태별로 연도를 어떤 날짜 기준으로 계산할지 명확히 정의했다
- 서버 집계와 앱 목록이 같은 기준을 보도록 맞췄다
- 최초 진입과 새로고침 API의 응답 계약을 통일했다
- 중복된 도서 응답 조립 코드를 매퍼로 모았다
- 큰 컨트롤러를 쪼개기 위한 첫걸음으로 인증 경계를 분리했다
다음 단계는 서재 API를 전용 컨트롤러로 옮기는 것이다. 이후 기록/리뷰, 홈/현황, 소셜 인증 순으로 분리하면 거대해진 컨트롤러를 기능 단위로 안정적으로 정리할 수 있을 것 같다.
결국 이번 문제는 앱 간 숫자 차이로 시작했지만, 결국 데이터의 의미와 API 계약을 명시하지 않았을 때 생기는 문제였다. 날짜 필드마다 역할을 정하고, 같은 도메인 데이터를 반환하는 API는 같은 응답 규격을 가져야 한다는 원칙을 다시 확인했다.
첫 댓글을 남겨보세요