API 응답 불일치 때문에 iOS와 Android 통계 숫자가 달랐던 이유 (원인과 해결 과정)
앱을 운영하다 보면 같은 데이터인데 플랫폼마다 숫자가 다르게 보이는 일이 생긴다. 이번에 겪은 문제도 그랬다. iOS와 Android의 독서 통계 숫자가 1권 차이로 어긋났는데, 원인을 추적해 보니 앱 버그가 아니라 서버의 API 응답 불일치 문제였다.
이 글에서는 집계 API와 목록 API가 서로 다른 날짜 기준을 쓰면서 생긴 숫자 차이, 새로고침 API에서 필드가 빠져 필터가 동작하지 않았던 문제, 그리고 같은 문제가 다시 생기지 않도록 응답 조립 코드와 인증 로직을 정리한 과정을 순서대로 기록했다. 마지막에는 API 응답 불일치 문제를 막기 위한 체크리스트도 정리해 두었다.

버그를 발견하다
개발하던 앱의 기록 탭에서 이상한 버그를 발견했다. 2026년 기준 ‘읽는 중’ 권수가 iOS는 6권, Android는 7권으로 다르게 표시되고 있었다. 정작 하단 목록을 펼쳐보면 두 플랫폼 모두 7권이 나왔다. 숫자만 다르고 실제 데이터는 같았다는 뜻이다.
처음에는 “앱마다 집계 로직이 다른가?” 싶었지만, 파고들어 보니 원인은 서버였다. 화면마다 ‘연도’의 기준이 서로 다르게 섞여 있었다.
API 응답 불일치 원인: 집계와 목록의 연도 기준이 달랐다
서버의 연도별 상태 집계 API는 ‘읽는 중’ 상태를 이렇게 계산하고 있었다.
status = 'B'
AND created_at LIKE '2026%' -- 서재 등록일 기준
한마디로 2026년에 서재에 ‘등록’한 책만 세고 있었다.
반면 기록 탭의 ‘읽는 중’ 목록은 다른 API에서, 다른 기준으로 조회하고 있었다.
year_date = '2026' -- 읽기 시작일에서 파생된 값
그래서 2025년에 등록했지만 2026년에 읽기 시작한 책은 목록에는 나오는데, 집계에는 잡히지 않는 상황이 벌어졌다. 여기에 더해 Android는 실제 목록 배열의 길이를 화면 숫자로 우선 사용했고, iOS는 서버가 내려주는 집계값을 우선 사용했다. 같은 데이터를 두고 서로 다른 소스를 신뢰한 것이다. 그 결과가 6권 대 7권이었다.
| 플랫폼 | 화면 숫자의 출처 | 표시된 권수 |
|---|---|---|
| iOS | 서버 집계 API 값 (등록일 기준) | 6권 |
| Android | 목록 API 배열 길이 (시작일 기준) | 7권 |
정리하면 두 앱 중 어느 쪽이 틀린 게 아니었다. 서버의 두 API가 같은 질문에 서로 다른 답을 내려주고 있었고, 앱은 각자 다른 API를 믿었을 뿐이다. 이런 API 응답 불일치 문제는 한 플랫폼만 보고 있으면 잘 드러나지 않는다.
상태별 날짜 기준을 다시 정의하다
연도별 기록 화면이 답해야 할 질문은 “서재에 언제 등록했는가”가 아니라 “어떤 독서 이벤트가 언제 일어났는가”다. 그래서 우선 상태별로 기준 날짜를 다음과 같이 정리했다.
| 항목 | 연도 기준 |
|---|---|
| 등록 도서 | 등록한 날 |
| 읽고 싶어요 | 등록한 날 |
| 읽는 중 | 읽기 시작한 날 |
| 다 읽었어요 | 다 읽은 날 |
| 읽다 멈췄어요 | 중단한 날 |
이 기준에 맞춰 서버 집계 쿼리를 수정했다.
-- 읽는 중
status = 'B' AND year_date = :year
-- 읽다 멈췄어요
status = 'D' AND end_date LIKE CONCAT(:year, '%')
완독·중단 건은 날짜 형식이 잘못 들어간 레코드를 걸러내는 검증도 추가했다.
참고로 날짜 컬럼에 LIKE '2026%'처럼 문자열 조건을 거는 방식은 컬럼에 인덱스가 있어도 제대로 활용하지 못할 수 있다. 데이터가 많아지면 BETWEEN이나 >=, < 같은 범위 조건으로 바꾸는 편이 낫다. 인덱스가 실제로 쓰이는지는 SQL 실행 계획(EXPLAIN)으로 확인할 수 있고, 인덱스를 어떻게 잡을지는 MySQL 인덱스 설계 원칙 글에 정리해 두었다.
앱 목록도 같은 기준으로 맞추다
서버 쿼리의 수정만으로 완료가 되었다면 좋았겠지만 문제가 끝나지 않았다. 이번엔 반대로 ‘읽다 멈췄어요’ 카드 숫자와 하단 목록이 어긋나 버렸다. 기존 iOS/AOS 기록 탭은 ‘중단한 책’의 상태 목록을 등록일 기준으로 필터링하고 있었기 때문이다. 이를 중단일 기준으로 바꿔야 했다.
이제 2025년에 등록했지만 2026년에 읽기를 멈춘 책은 이렇게 동작한다.
- 2026년 기록 탭의 ‘읽다 멈췄어요’에 표시된다
- 2025년 서재 탭에는 여전히 남아 있다
- 기록 탭의 집계 숫자와 목록이 일치한다
여기서 놓치기 쉬운 포인트가 있다. 기록 탭과 서재 탭의 ‘연도’가 같은 의미가 되도록 통일하지 않았다는 점이다. 기록 탭은 ‘독서 이벤트가 일어난 연도’를, 서재 탭은 ‘책을 서재에 등록한 연도’를 보여준다. 이름은 비슷해도 사용자에게 답하는 질문이 다르기 때문에, 억지로 기준을 하나로 합치지 않는 편이 맞다고 판단했다.
새로고침하면 서재 필터가 사라지는 이유
추가로 서재 탭에서 신고된 버그도 있었다. 새로고침을 하면 분류 필터가 아무 책도 찾지 못하는 문제였다. 정말 난감하다.
분석 결과 원인은 최초 진입과 당겨서 새로고침이 서로 다른 API를 호출하고 있었기 때문이었다. 두 API는 같은 전체 도서 데이터를 내려주지만 응답 필드 구성이 달랐다.
| 필드 | 최초 진입 API | 새로고침 API |
|---|---|---|
| status | 정규화된 값 | 원본 값 |
| 분류 정보 | 포함 | 누락 |
| 시작일/종료일 | 포함 | 누락 |
| ISBN | 포함 | 누락 |
분류 정보 필드가 새로고침 응답에서 통째로 빠져 있었으니, 새로고침 후 필터가 작동하지 않는 게 당연했다. 새로고침 API 응답에 누락된 필드를 추가하고 상태값 정규화 규칙도 동일하게 맞췄다. 이것도 결국 같은 데이터를 내려주는 두 엔드포인트 사이의 API 응답 불일치 문제였다. 다행히 서버만 수정하면 되는 문제라 앱을 다시 배포하지 않고도 해결할 수 있었다.
API 응답 불일치 줄이기: 중복된 응답 조립 코드를 하나로 모으다
이런 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가 같은 조건을 쓰는지 확인한다. 가능하면 WHERE 조건을 만드는 코드를 한 곳에서 공유한다.
- 날짜 컬럼마다 의미를 문서로 남긴다. 등록일, 시작일, 완독일처럼 어떤 질문에 답하는 날짜인지 적어 두면 집계 기준이 흔들리지 않는다.
- 같은 도메인 데이터는 하나의 매퍼로 응답을 만든다. 엔드포인트마다 JSON을 따로 조립하면 필드 누락은 시간문제다.
- 앱이 화면 숫자를 어디서 가져올지 정한다. 서버 집계값을 쓸지 목록 개수를 셀지, iOS와 Android가 같은 기준을 써야 한다.
- 응답 계약 테스트를 둔다. 같은 도서를 두 API로 조회해 필드 구성이 같은지 비교하는 테스트 하나만 있어도 이번 같은 누락은 배포 전에 잡힌다.
API 응답 규격을 좀 더 명시적으로 관리하고 싶다면 OpenAPI 명세로 응답 스키마를 정의해 두거나, 소비자 주도 계약(Consumer-Driven Contracts) 방식을 참고해 볼 만하다. 처음부터 거창하게 도입하지 않더라도, 주요 API 몇 개의 응답 필드를 고정해 두는 것만으로 효과가 크다.
정리
이번 API 응답 불일치 작업의 핵심은 숫자 하나를 고치는 게 아니라 기준을 정리하는 데 있었다.
- 상태별로 연도를 어떤 날짜 기준으로 계산할지 명확히 정의했다
- 서버 집계와 앱 목록이 같은 기준을 보도록 맞췄다
- 최초 진입과 새로고침 API의 응답 계약을 통일했다
- 중복된 도서 응답 조립 코드를 매퍼로 모았다
- 큰 컨트롤러를 쪼개기 위한 첫걸음으로 인증 경계를 분리했다
다음 단계는 서재 API를 전용 컨트롤러로 옮기는 것이다. 이후 기록/리뷰, 홈/현황, 소셜 인증 순으로 분리하면 거대해진 컨트롤러를 기능 단위로 안정적으로 정리할 수 있을 것 같다.
이번 문제는 앱 간 숫자 차이로 시작했지만, 결국 데이터의 의미와 API 계약을 명시하지 않았을 때 생기는 API 응답 불일치 문제였다. 날짜 필드마다 역할을 정하고, 같은 도메인 데이터를 반환하는 API는 같은 응답 규격을 가져야 한다는 원칙을 다시 확인했다.
첫 댓글을 남겨보세요