
Flutter로 만보기 앱을 운영했다. 단순히 걸음 수만 세는 게 아니라, 걸음 수에 따라 포인트를 적립할 수 있는 앱이다. 사용자가 일정 걸음을 걸으면 "적립 가능" 상태가 되고, 버튼을 탭해서 포인트를 쌓는 방식이다.
운영하던 중에 문의가 계속 들어왔다.
"탭했는데 반응이 없어요"
"포인트가 갑자기 사라졌어요"
첫 번째는 UX 문제고, 두 번째는 데이터 문제다. 이 둘을 어떻게 해결했는지 정리해보려고 한다.
참고로 서버가 내려가서 더 이상은 서비스하지 않는 앱이다. (좋은 말로 피봇팅했다고 하자..)
사용자가 포인트 적립 버튼을 탭하면, 원래는 이런 흐름이었다.
[탭] → [서버 요청] → [1-2초 대기] → [응답] → [UI 업데이트]
네트워크가 느리면 1-2초, 불안정하면 그 이상 걸린다. 사용자 입장에서는 "탭이 안 먹히네?" 하고 느낄 수밖에 없다.
Optimistic UI는 간단하다.
서버 응답을 기다리지 않고, 성공할 거라 믿고 UI를 먼저 업데이트하는 것이다.
// 상태 변수
int _serverPoint = 0; // 서버에서 확인된 포인트
int _accumulatedTapCount = 0; // 탭했지만 아직 서버에 안 보낸 수
int _inFlightPending = 0; // 서버 요청 중인 수
// 낙관적 포인트 계산
int get _optimisticPending =>
_accumulatedTapCount + (_isPointsSyncing ? _inFlightPending : 0);
// 화면에 표시되는 값
int get displayWalletPoints => _serverPoint + _optimisticPending;
[탭] → [UI 즉시 +1] → [버퍼에 누적] → [1.5초 후 서버 전송] → [응답으로 확정]
사용자가 탭하면:
1. _accumulatedTapCount++ - 즉시 증가
2. displayWalletPoints가 바로 올라간다
3. 1.5초 디바운스 후 서버에 배치로 전송
4. 서버 응답이 오면 _serverPoint를 업데이트
탭하자마자 숫자가 올라가니까 "오 반응 빠르네" 하고 느끼게 된다.
서버 요청이 실패하면 낙관적으로 올렸던 값을 되돌려야 한다.
Future<void> _handleSyncFailure(error, previousPoints, failedTapCount) async {
setState(() {
_accumulatedTapCount = 0;
_inFlightPending = 0; // 버퍼 초기화
});
// 사용자에게 재시도 옵션 제공
}
롤백하고 "다시 시도" 스낵바를 띄워준다.
Optimistic UI로 UX는 개선됐다. 근데 또 다른 문의가 들어왔다.
"분명히 10포인트 적립했는데, 새로고침하니까 0이에요"
로그를 확인해보니까, 서버가 accumulatedPoints: 0을 반환하는 경우가 있었다. 근데 같은 응답의 history 배열에는 분명히 적립 기록이 있는 거다.
{
"accumulatedPoints": 0, // ← 버그: 0으로 온다
"history": [
{"id": "abc", "clickCount": 5, "syncFlag": 1},
{"id": "def", "clickCount": 5, "syncFlag": 1}
] // ← 실제로는 10포인트 적립됐다
}
서버 버그였다. 근데 조직 구조상 서버 수정을 바로 요청할 수 없었다. (이런 상황 겪어본 사람은 알겠지...)
원칙을 하나 세웠다: "서버를 신뢰하되, 검증하라"
history 배열에서 실제로 동기화된 포인트를 계산하는 것이다.
int calculateActualSyncedPoints(List<dynamic> history) {
if (history.isEmpty) return 0;
return history.fold<int>(0, (sum, element) {
if (element['syncFlag'] == 1) { // 동기화 완료된 것만
return sum + (element['clickCount'] as int? ?? 0);
}
return sum;
});
}
history에 기록이 있다면 그게 증거다.
PointCorrectionResult correctAccumulatedPoints({
required int currentAccumulatedPoints,
required int? serverAccumulatedPoints,
required int actualSyncedPoints, // history에서 계산한 실제 값
}) {
// 서버 응답이 없으면 기존 값 유지
if (serverAccumulatedPoints == null) {
return PointCorrectionResult(
correctedPoints: currentAccumulatedPoints,
wasCorrected: false,
);
}
// 버그 감지: 서버는 0인데 실제 적립 기록이 있다
if (serverAccumulatedPoints == 0 && actualSyncedPoints > 0) {
return PointCorrectionResult(
correctedPoints: currentAccumulatedPoints + actualSyncedPoints,
wasCorrected: true,
reason: 'Server returned 0 but history shows actual points',
);
}
// 정상: 서버 값 사용
return PointCorrectionResult(
correctedPoints: serverAccumulatedPoints,
wasCorrected: false,
);
}
무조건 로컬 값을 믿으면 또 다른 문제가 생긴다. 클라이언트 버그로 잘못된 값이 영원히 남을 수 있기 때문이다. 그래서 "증거 기반 보정"이 중요했다.
보정 로직의 반환값을 단순히 int로 안 했다.
/// 포인트 보정 결과를 담는 값 객체 (Value Object)
class PointCorrectionResult {
final int correctedPoints; // 최종 포인트
final bool wasCorrected; // 보정이 발생했는지
final String reason; // 왜 이 값이 됐는지
const PointCorrectionResult({
required this.correctedPoints,
required this.wasCorrected,
required this.reason,
});
}
이유는 이렇다:
reason을 보면 된다wasCorrected가 true일 때만 로그를 남기면 서버 버그 발생 빈도를 파악할 수 있다reason만 추가하면 된다단순히 값만 반환하는 게 아니라, "왜 이 값인지"까지 같이 반환하는 게 운영할 때 차이가 크다.

둘은 서로 다른 문제를 해결하는 건데, 같이 써야 완전한 솔루션이 된다.
로직이 복잡해지면서 코드가 스파게티가 될 것 같았다. SOLID 원칙을 따라서 서비스를 분리했다.
PointCalculationService → 포인트 계산, 버그 감지
PointStateManager → 상태 저장/복원 (SharedPreferences)
SyncResponseHandler → 서버 응답 처리, 검증 조율
각 서비스가 하나의 책임만 갖는다. 테스트하기 쉽고, 버그가 나면 어디를 봐야 하는지 명확하다.
서버를 무조건 신뢰하면 서버 버그에 당한다. 무조건 불신하면 클라이언트 버그가 쌓인다. 증거 기반 조건부 검증이 답이었다.
서버 버그를 발견해도 바로 수정이 안 되는 경우가 있다. 클라이언트에서 방어할 수 있어야 한다. 근데 서버가 수정되면 자연스럽게 원래대로 돌아가도록 설계해야 한다.
Optimistic UI로 UX를 해결했다고 끝이 아니다. 데이터가 맞는지는 별도로 검증해야 한다. 두 레이어를 분리해서 생각해야 했다.
완벽한 서버는 없다. 완벽한 네트워크도 없다. 클라이언트는 이 불완전한 환경에서 UX와 데이터 무결성을 둘 다 지켜야 한다.
Optimistic UI는 "일단 믿고 보여주기", 방어적 동기화는 "검증하고 확정하기". 이 둘이 조합돼야 진짜 견고한 앱이 된다.
다음에 비슷한 문제 만나면 더 빨리 해결할 수 있겠지?