[Flutter] 앱의 Lazy Update 전략: 서버 부하 50% 줄이기

FlutterPyo·2026년 1월 28일

들어가며

중고거래 앱을 운영하면서 한 가지 고민이 생겼습니다.

"조회수가 1 올라갈 때마다 API를 2번 호출하는 게 맞나?"

현재 구조를 보면:

사용자가 상품 상세 화면 진입
    ↓
조회수 증가 API 호출 (1회)
    ↓
상품 정보 재조회 API 호출 (2회) ← 이게 필요한가?
    ↓
UI 업데이트

사용자가 100명이면 200번, 1000명이면 2000번의 API 호출이 발생합니다.

조회수가 "123"에서 "124"로 바뀌는 걸 사용자가 즉시 확인해야 할까요? 대부분의 사용자는 그 차이를 인지하지 못합니다.

이런 "굳이 즉시 반영하지 않아도 되는" 업데이트를 최적화하는 전략이 Lazy Update입니다.


즉시 업데이트 vs Lazy Update

용어 정리

먼저 혼동하기 쉬운 개념들을 정리합니다.

용어설명초점
즉시 업데이트 (Real-time)액션 후 서버 + UI 모두 즉시 반영실시간성
Lazy Update (지연)서버는 즉시, UI는 나중에 반영효율성
낙관적 업데이트 (Optimistic)UI 먼저 반영, 서버는 백그라운드반응성
비관적 업데이트 (Pessimistic)서버 응답 후 UI 반영안전성

Lazy Update는 낙관적/비관적과 다른 축입니다.

  • 낙관적/비관적: "서버 응답을 기다릴 것인가?"
  • 즉시/Lazy: "UI를 얼마나 자주 동기화할 것인가?"

왜 "Lazy"인가?

프로그래밍에서 "Lazy"는 "필요할 때까지 미루다"라는 의미입니다.

  • Lazy Loading: 보이는 것만 로드
  • Lazy Evaluation: 필요할 때만 계산
  • Lazy Update: 필요할 때만 UI 동기화

Lazy Update의 핵심:

"서버에는 즉시 반영하되, 클라이언트 UI는 사용자가 새로고침할 때 반영하자"

데이터 일관성은 유지하면서, 불필요한 API 호출을 줄이는 전략입니다.


현재 구조의 문제점

즉시 업데이트 흐름

현재 앱의 조회수 증가, 찜 토글, 채팅 카운트 업데이트 흐름입니다.

┌─────────────────────────────────────────────────────────────┐
│  사용자 액션 (예: 상품 상세 화면 진입)                         │
└─────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────┐
│  1. incrementViewCountUseCase(productId)                    │
│     → POST /api/products/{id}/view                          │
│     → 서버에서 조회수 +1                                      │
└─────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────┐
│  2. getProductDetailUseCase(productId)  ← 추가 API 호출!     │
│     → GET /api/products/{id}                                │
│     → 업데이트된 상품 정보 전체 조회                           │
└─────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────┐
│  3. updatedProductProvider에 이벤트 발행                     │
│     → 홈 화면, 검색 결과, 내 상품 목록 등 모든 화면에 전파      │
│     → 각 화면에서 해당 상품 찾아서 UI 업데이트                  │
└─────────────────────────────────────────────────────────────┘

문제점:

  1. API 2회 호출: 액션 1번에 API 2번
  2. 이벤트 전파 오버헤드: 모든 목록 화면이 반응
  3. 불필요한 동기화: 조회수 1 증가를 위해 전체 상품 정보 재조회

API 호출 현황

기능API 호출 횟수설명
조회수 증가2회조회수 증가 + 상품 정보 재조회
찜 토글2회토글 + 상품 정보 재조회
채팅방 생성2회생성 + 상품 정보 재조회

상품 상세 화면 진입 + 찜 + 채팅 시작 = 6회 API 호출


Lazy Update 적용 기준

모든 업데이트를 Lazy로 바꾸면 안 됩니다. 기준이 필요합니다.

즉시 업데이트 유지 (중요한 액션)

기능이유
상품 삭제삭제된 상품이 목록에 남아있으면 혼란
상품 수정수정 내용을 바로 확인해야 함
상품 상태 변경"예약중", "판매완료"는 중요한 정보
찜 상태 토글하트 아이콘은 즉시 반영해야 함

공통점: 사용자가 액션 결과를 즉시 확인해야 하는 경우

Lazy Update 적용 (통계성 데이터)

기능이유
조회수 증가123 → 124 차이를 사용자가 인지 못함
찜 카운트하트는 즉시 반영, 숫자는 나중에
채팅 카운트채팅방은 생성됨, 숫자는 나중에

공통점: 통계성 데이터로, 즉시 정확하지 않아도 사용자 경험에 영향 없음

결정 기준 체크리스트

Lazy Update 적용 여부를 결정할 때:

□ 사용자가 액션 결과를 즉시 확인해야 하는가?
  → Yes: 즉시 업데이트
  → No: Lazy Update 후보

□ 데이터가 틀리면 사용자가 혼란스러운가?
  → Yes: 즉시 업데이트
  → No: Lazy Update 후보

□ 해당 데이터가 비즈니스 로직에 영향을 주는가?
  → Yes: 즉시 업데이트
  → No: Lazy Update 후보

□ 빈번하게 발생하는 액션인가?
  → Yes: Lazy Update 적용 효과 큼
  → No: 즉시 업데이트 유지해도 무방

구현 예시 1: 조회수 증가

가장 빈번하게 발생하는 조회수 증가에 Lazy Update를 적용합니다.

변경 전 (즉시 업데이트)

Future<bool> incrementViewCount(int productId) async {
  final result = await incrementViewCountUseCase(productId);

  return result.fold(
    (failure) {
      debugPrint('조회수 증가 실패: ${failure.message}');
      return false;
    },
    (incremented) {
      if (incremented) {
        final currentState = state;
        if (currentState is ProductDetailLoaded) {
          // ❌ 문제: 조회수 1 증가를 위해 전체 상품 정보 재조회
          final productResult = getProductDetailUseCase(productId);

          productResult.then((result) {
            result.fold(
              (failure) {
                debugPrint('상품 정보를 불러오는데 실패했습니다');
              },
              (updatedProduct) {
                final updatedState = state;
                if (updatedState is ProductDetailLoaded) {
                  // UI 업데이트
                  state = updatedState.copyWith(product: updatedProduct);

                  // ❌ 문제: 모든 목록 화면에 이벤트 전파
                  ref.read(updatedProductProvider.notifier).state = updatedProduct;
                  Future.microtask(() {
                    ref.read(updatedProductProvider.notifier).state = null;
                  });
                }
              },
            );
          });
        }
      }
      return incremented;
    },
  );
}

문제점 분석:

  1. incrementViewCountUseCase → API 1회
  2. getProductDetailUseCase → API 1회 (추가)
  3. updatedProductProvider 이벤트 → 모든 목록 화면 리렌더링

조회수가 1 증가하는데 이 모든 과정이 필요할까요?

변경 후 (Lazy Update)

Future<bool> incrementViewCount(int productId) async {
  final result = await incrementViewCountUseCase(productId);

  return result.fold(
    (failure) {
      debugPrint('조회수 증가 실패: ${failure.message}');
      return false;
    },
    (incremented) {
      // ✅ Lazy Update: 서버에만 반영
      // 클라이언트 UI는 사용자가 새로고침할 때 자연스럽게 반영됨
      //
      // 제거된 것:
      // - 상품 정보 재조회 API 호출
      // - updatedProductProvider 이벤트 발행
      // - 모든 목록 화면 리렌더링
      return incremented;
    },
  );
}

변경 효과:

  • API 호출: 2회 → 1회 (50% 감소)
  • 이벤트 전파: 있음 → 없음
  • 목록 화면 리렌더링: 발생 → 없음

사용자 경험 변화

항목변경 전변경 후
서버 데이터즉시 반영즉시 반영 (동일)
상세 화면 조회수즉시 업데이트이전 값 유지
목록 화면 조회수즉시 업데이트새로고침 시 반영

실제로 사용자가 느끼는 차이:

거의 없습니다. 조회수가 "123"인 상태에서 화면을 봤다가, 새로고침하면 "124"가 됩니다. 사용자는 그 차이를 인지하지 못합니다.


구현 예시 2: 찜 토글

찜 기능은 두 가지 데이터가 있습니다:

  1. 찜 상태 (isFavorite): 내가 찜했는지 여부 → 즉시 반영 필요
  2. 찜 카운트 (favoriteCount): 총 찜 개수 → Lazy Update 가능

변경 전 (모두 즉시 업데이트)

Future<void> toggleFavorite(int productId) async {
  final currentState = state;
  if (currentState is! ProductDetailLoaded) return;

  // 낙관적 업데이트: 찜 상태 즉시 반영
  final previousIsFavorite = currentState.isFavorite;
  state = currentState.copyWith(isFavorite: !previousIsFavorite);

  final result = await toggleFavoriteUseCase(productId);

  await result.fold(
    (failure) {
      // 실패 시 롤백
      state = currentState.copyWith(isFavorite: previousIsFavorite);
    },
    (isFavorite) async {
      // ❌ 문제: 찜 카운트 동기화를 위해 전체 상품 정보 재조회
      final productResult = await getProductDetailUseCase(productId);

      productResult.fold(
        (failure) {
          debugPrint('상품 정보를 불러오는데 실패했습니다');
        },
        (updatedProduct) {
          state = currentState.copyWith(
            product: updatedProduct,
            isFavorite: isFavorite,
          );

          // ❌ 문제: 모든 목록 화면에 이벤트 전파
          ref.read(updatedProductProvider.notifier).state = updatedProduct;
          Future.microtask(() {
            ref.read(updatedProductProvider.notifier).state = null;
          });
        },
      );
    },
  );
}

변경 후 (찜 상태만 즉시, 카운트는 Lazy)

Future<void> toggleFavorite(int productId) async {
  final currentState = state;
  if (currentState is! ProductDetailLoaded) return;

  // 낙관적 업데이트: 찜 상태 즉시 반영 (유지)
  final previousIsFavorite = currentState.isFavorite;
  state = currentState.copyWith(isFavorite: !previousIsFavorite);

  final result = await toggleFavoriteUseCase(productId);

  await result.fold(
    (failure) {
      // 실패 시 롤백 (유지)
      state = currentState.copyWith(isFavorite: previousIsFavorite);
    },
    (isFavorite) async {
      // ✅ Lazy Update: 찜 상태만 즉시 반영
      // 찜 카운트(favoriteCount)는 새로고침 시 자연스럽게 반영됨
      state = currentState.copyWith(isFavorite: isFavorite);

      // 제거된 것:
      // - 상품 정보 재조회 API 호출
      // - updatedProductProvider 이벤트 발행
    },
  );
}

핵심 포인트:

  • 찜 상태(하트 아이콘): 낙관적 업데이트로 즉시 반영 (사용자 피드백 중요)
  • 찜 카운트(숫자): Lazy Update로 새로고침 시 반영 (정확한 숫자는 덜 중요)

사용자 관점 시나리오

1. 사용자가 찜 버튼 클릭
   - 하트: ♡ → ♥ (즉시 변경) ✅
   - 카운트: "찜 23" (이전 값 유지)

2. 서버에서 처리
   - 찜 추가 성공
   - 실제 카운트: 24

3. 사용자가 화면 새로고침 (또는 다시 진입)
   - 하트: ♥ (유지)
   - 카운트: "찜 24" (서버 값 반영) ✅

사용자는 하트가 바로 바뀌는 것만 확인하고, 숫자가 23인지 24인지는 크게 신경 쓰지 않습니다.


구현 예시 3: 채팅 카운트

채팅방 생성 시 chatCount를 업데이트하는 로직입니다.

변경 전 (즉시 업데이트)

void _updateProductAfterChatRoomCreated(int productId) {
  // ❌ 문제: 채팅 카운트 동기화를 위해 전체 상품 정보 재조회
  getProductDetailUseCase(productId).then((result) {
    result.fold(
      (failure) {
        debugPrint('채팅방 생성 후 상품 정보 조회 실패');
      },
      (updatedProduct) {
        // 상품 업데이트 이벤트 발행
        ref.read(updatedProductProvider.notifier).state = updatedProduct;
        Future.microtask(() {
          ref.read(updatedProductProvider.notifier).state = null;
        });
      },
    );
  });
}

// 채팅방 생성 메서드에서 호출
Future<void> createOrGetChatRoomAndEnter(...) async {
  // ... 채팅방 생성 로직 ...

  // 새 채팅방이 생성된 경우 상품 정보 업데이트
  if (response.isNewChatRoom ?? false) {
    _updateProductAfterChatRoomCreated(productId);  // ❌ 추가 API 호출
  }
}

변경 후 (Lazy Update)

// ✅ _updateProductAfterChatRoomCreated 메서드 제거

Future<void> createOrGetChatRoomAndEnter(...) async {
  // ... 채팅방 생성 로직 ...

  // ✅ Lazy Update: 채팅 카운트는 새로고침 시 반영
  // 채팅방은 정상적으로 생성됨
  // 상품의 chatCount는 사용자가 상품 목록을 새로고침할 때 반영

  // 제거된 것:
  // - _updateProductAfterChatRoomCreated() 호출
  // - 상품 정보 재조회 API 호출
  // - updatedProductProvider 이벤트 발행
}

변경 효과:

채팅방 생성 흐름이 단순해집니다:

변경 전:
채팅방 생성 → 상품 정보 재조회 → 이벤트 발행 → 목록 업데이트

변경 후:
채팅방 생성 → 끝

변경 전후 비교

API 호출 횟수

시나리오변경 전변경 후감소율
상품 상세 진입2회1회50%
찜 토글2회1회50%
채팅방 생성2회1회50%
합계6회3회50%

서버 부하 예측

가정: 일일 활성 사용자 10,000명, 평균 상품 조회 5회, 찜 1회, 채팅 0.5회

변경 전:
- 상품 조회: 10,000 × 5 × 2 = 100,000 API 호출
- 찜 토글: 10,000 × 1 × 2 = 20,000 API 호출
- 채팅 생성: 10,000 × 0.5 × 2 = 10,000 API 호출
- 총: 130,000 API 호출/일

변경 후:
- 상품 조회: 10,000 × 5 × 1 = 50,000 API 호출
- 찜 토글: 10,000 × 1 × 1 = 10,000 API 호출
- 채팅 생성: 10,000 × 0.5 × 1 = 5,000 API 호출
- 총: 65,000 API 호출/일

절감: 65,000 API 호출/일 (50%)

인프라 비용 영향

항목영향
서버 CPUAPI 처리량 50% 감소 → CPU 부하 감소
데이터베이스쿼리 수 50% 감소 → DB 부하 감소
네트워크트래픽 감소 → 대역폭 비용 절감
응답 시간서버 부하 감소 → 전체적인 응답 속도 개선

Lazy Update 적용 시점

지금 당장 필요한가?

Lazy Update는 최적화 전략입니다. 조기 최적화는 오히려 복잡도만 높입니다.

즉시 적용이 필요한 경우:

  • 동시 접속자 1,000명 이상
  • 상품 상세 조회가 초당 100회 이상
  • 서버 응답 시간이 느려지고 있음
  • 인프라 비용이 부담스러움

현재 상태 유지해도 되는 경우:

  • 동시 접속자 수백 명 이하
  • 서버 응답 시간이 정상 (200ms 이하)
  • 비용 부담이 없음

단계적 적용 권장

1단계: 조회수 증가에 Lazy Update 적용
       → 가장 빈번하고, 사용자 경험 영향 최소

2단계: 모니터링 및 효과 측정
       → API 호출 감소 확인
       → 사용자 불만 여부 확인

3단계: 찜 카운트, 채팅 카운트에 적용
       → 효과가 검증되면 확장

추가 최적화 아이디어

Lazy Update 외에도 고려할 수 있는 최적화:

1. 배치 처리 (Batch Update)

조회수를 실시간으로 증가시키지 않고, 일정 시간마다 배치로 처리합니다.

// 예: 클라이언트에서 조회한 상품 ID를 모아두었다가
// 10초마다 한 번에 서버로 전송
class ViewCountBatcher {
  final List<int> _pendingProductIds = [];
  Timer? _timer;

  void addView(int productId) {
    _pendingProductIds.add(productId);
    _startTimerIfNeeded();
  }

  void _startTimerIfNeeded() {
    _timer ??= Timer(Duration(seconds: 10), _flush);
  }

  Future<void> _flush() async {
    if (_pendingProductIds.isEmpty) return;

    final ids = List<int>.from(_pendingProductIds);
    _pendingProductIds.clear();
    _timer = null;

    // 한 번의 API 호출로 여러 상품의 조회수 증가
    await batchIncrementViewCountUseCase(ids);
  }
}

2. 캐싱 전략

자주 조회되는 상품 정보를 클라이언트에 캐싱합니다.

// 예: 5분간 캐시 유지
final productCache = StateProvider.family<Product?, int>((ref, productId) {
  // 5분 후 자동 만료
  ref.cacheFor(Duration(minutes: 5));
  return null;
});

3. Debouncing

같은 상품을 연속으로 조회할 때 API 호출을 줄입니다.

// 예: 같은 상품을 1초 내에 다시 조회하면 무시
final _recentViews = <int, DateTime>{};

Future<void> incrementViewCountDebounced(int productId) async {
  final lastView = _recentViews[productId];
  if (lastView != null &&
      DateTime.now().difference(lastView) < Duration(seconds: 1)) {
    return; // 최근에 조회함, 무시
  }

  _recentViews[productId] = DateTime.now();
  await incrementViewCount(productId);
}

마무리

Lazy Update는 "모든 것을 즉시 반영할 필요는 없다"는 실용적인 관점입니다.

핵심 정리

  1. 서버 데이터는 항상 즉시 반영 - 데이터 일관성 유지
  2. 클라이언트 UI는 선별적으로 지연 - 중요하지 않은 데이터만
  3. 사용자 경험 영향 최소화 - 통계성 데이터 위주로 적용
  4. 효과 측정 필수 - 적용 전후 모니터링

언제 적용할까?

  • 서버 부하가 증가하고 있을 때
  • 인프라 비용을 줄여야 할 때
  • 앱 규모가 커지고 있을 때

조기 최적화보다는, 실제로 문제가 발생하거나 예상될 때 적용하는 것을 권장합니다.


⚠️ 참고

이 글은 Riverpod + StateNotifier + Clean Architecture 환경에서 작성되었습니다.
다른 상태관리를 사용하더라도 패턴 자체는 동일하게 적용할 수 있습니다.

핵심은:

  • 중요한 데이터: 즉시 업데이트
  • 통계성 데이터: Lazy Update (새로고침 시 반영)

관련 용어

용어설명
Lazy Update필요할 때만 업데이트
Deferred Update업데이트를 나중으로 미룸
On-Demand Update요청 시에만 업데이트
Eventual Consistency최종적으로 일관되게 수렴

참고 자료

0개의 댓글