[Flutter] 왜 내 포인트가 사라졌을까?

보로꼬리·2025년 12월 12일

Flutter

목록 보기
9/9
post-thumbnail

들어가며

Flutter로 만보기 앱을 운영했다. 단순히 걸음 수만 세는 게 아니라, 걸음 수에 따라 포인트를 적립할 수 있는 앱이다. 사용자가 일정 걸음을 걸으면 "적립 가능" 상태가 되고, 버튼을 탭해서 포인트를 쌓는 방식이다.

운영하던 중에 문의가 계속 들어왔다.

"탭했는데 반응이 없어요"
"포인트가 갑자기 사라졌어요"

첫 번째는 UX 문제고, 두 번째는 데이터 문제다. 이 둘을 어떻게 해결했는지 정리해보려고 한다.

참고로 서버가 내려가서 더 이상은 서비스하지 않는 앱이다. (좋은 말로 피봇팅했다고 하자..)


문제 1: 탭했는데 반응이 없다

사용자가 포인트 적립 버튼을 탭하면, 원래는 이런 흐름이었다.

[탭] → [서버 요청] → [1-2초 대기] → [응답] → [UI 업데이트]

네트워크가 느리면 1-2초, 불안정하면 그 이상 걸린다. 사용자 입장에서는 "탭이 안 먹히네?" 하고 느낄 수밖에 없다.


해결 1: Optimistic UI

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;  // 버퍼 초기화
  });
  // 사용자에게 재시도 옵션 제공
}

롤백하고 "다시 시도" 스낵바를 띄워준다.


문제 2: 포인트가 갑자기 사라진다

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포인트 적립됐다
}

서버 버그였다. 근데 조직 구조상 서버 수정을 바로 요청할 수 없었다. (이런 상황 겪어본 사람은 알겠지...)


해결 2: 클라이언트에서 검증하기

원칙을 하나 세웠다: "서버를 신뢰하되, 검증하라"

  • 서버가 정상 값을 주면 → 그대로 사용
  • 서버가 0을 줬는데 "증거"가 있으면 → 로컬에서 보정

증거란?

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,
  );
}

왜 이렇게 했냐면

  1. 서버 우선 원칙 유지 - 서버가 정상이면 서버 값을 쓴다
  2. 조건부 보정 - "증거"가 있을 때만 로컬에서 보정
  3. 자동 복구 - 서버 버그가 수정되면 자연스럽게 서버 값을 사용하게 된다

무조건 로컬 값을 믿으면 또 다른 문제가 생긴다. 클라이언트 버그로 잘못된 값이 영원히 남을 수 있기 때문이다. 그래서 "증거 기반 보정"이 중요했다.

왜 Value Object를 썼나

보정 로직의 반환값을 단순히 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,
  });
}

이유는 이렇다:

  1. 디버깅 - 나중에 "왜 이 값이지?" 할 때 reason을 보면 된다
  2. 로그 추적 - wasCorrectedtrue일 때만 로그를 남기면 서버 버그 발생 빈도를 파악할 수 있다
  3. 확장성 - 보정 유형이 늘어나도 reason만 추가하면 된다

단순히 값만 반환하는 게 아니라, "왜 이 값인지"까지 같이 반환하는 게 운영할 때 차이가 크다.


두 패턴의 조합

  • Optimistic UI → 사용자 경험 확보 (즉각 반응)
  • 서버 검증 → 데이터 무결성 확보 (잘못된 값 방어)

둘은 서로 다른 문제를 해결하는 건데, 같이 써야 완전한 솔루션이 된다.


서비스 분리

로직이 복잡해지면서 코드가 스파게티가 될 것 같았다. SOLID 원칙을 따라서 서비스를 분리했다.

PointCalculationService   → 포인트 계산, 버그 감지
PointStateManager         → 상태 저장/복원 (SharedPreferences)
SyncResponseHandler       → 서버 응답 처리, 검증 조율

각 서비스가 하나의 책임만 갖는다. 테스트하기 쉽고, 버그가 나면 어디를 봐야 하는지 명확하다.


배운 점

1. "신뢰하되 검증하라"

서버를 무조건 신뢰하면 서버 버그에 당한다. 무조건 불신하면 클라이언트 버그가 쌓인다. 증거 기반 조건부 검증이 답이었다.

2. 운영 환경의 현실

서버 버그를 발견해도 바로 수정이 안 되는 경우가 있다. 클라이언트에서 방어할 수 있어야 한다. 근데 서버가 수정되면 자연스럽게 원래대로 돌아가도록 설계해야 한다.

3. UX와 데이터 무결성은 별개 문제

Optimistic UI로 UX를 해결했다고 끝이 아니다. 데이터가 맞는지는 별도로 검증해야 한다. 두 레이어를 분리해서 생각해야 했다.


마치며

완벽한 서버는 없다. 완벽한 네트워크도 없다. 클라이언트는 이 불완전한 환경에서 UX와 데이터 무결성을 둘 다 지켜야 한다.

Optimistic UI는 "일단 믿고 보여주기", 방어적 동기화는 "검증하고 확정하기". 이 둘이 조합돼야 진짜 견고한 앱이 된다.

다음에 비슷한 문제 만나면 더 빨리 해결할 수 있겠지?


Optimistic UI Implementation Guide

profile
1. 나는 무엇을 모르는걸까 2. 사소한 것도 누군가에게는 도움이 된다.

0개의 댓글