중고거래 앱을 운영하면서 한 가지 고민이 생겼습니다.
"조회수가 1 올라갈 때마다 API를 2번 호출하는 게 맞나?"
현재 구조를 보면:
사용자가 상품 상세 화면 진입
↓
조회수 증가 API 호출 (1회)
↓
상품 정보 재조회 API 호출 (2회) ← 이게 필요한가?
↓
UI 업데이트
사용자가 100명이면 200번, 1000명이면 2000번의 API 호출이 발생합니다.
조회수가 "123"에서 "124"로 바뀌는 걸 사용자가 즉시 확인해야 할까요? 대부분의 사용자는 그 차이를 인지하지 못합니다.
이런 "굳이 즉시 반영하지 않아도 되는" 업데이트를 최적화하는 전략이 Lazy Update입니다.
먼저 혼동하기 쉬운 개념들을 정리합니다.
| 용어 | 설명 | 초점 |
|---|---|---|
| 즉시 업데이트 (Real-time) | 액션 후 서버 + UI 모두 즉시 반영 | 실시간성 |
| Lazy Update (지연) | 서버는 즉시, UI는 나중에 반영 | 효율성 |
| 낙관적 업데이트 (Optimistic) | UI 먼저 반영, 서버는 백그라운드 | 반응성 |
| 비관적 업데이트 (Pessimistic) | 서버 응답 후 UI 반영 | 안전성 |
Lazy Update는 낙관적/비관적과 다른 축입니다.
프로그래밍에서 "Lazy"는 "필요할 때까지 미루다"라는 의미입니다.
Lazy Update의 핵심:
"서버에는 즉시 반영하되, 클라이언트 UI는 사용자가 새로고침할 때 반영하자"
데이터 일관성은 유지하면서, 불필요한 API 호출을 줄이는 전략입니다.
현재 앱의 조회수 증가, 찜 토글, 채팅 카운트 업데이트 흐름입니다.
┌─────────────────────────────────────────────────────────────┐
│ 사용자 액션 (예: 상품 상세 화면 진입) │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 1. incrementViewCountUseCase(productId) │
│ → POST /api/products/{id}/view │
│ → 서버에서 조회수 +1 │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 2. getProductDetailUseCase(productId) ← 추가 API 호출! │
│ → GET /api/products/{id} │
│ → 업데이트된 상품 정보 전체 조회 │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 3. updatedProductProvider에 이벤트 발행 │
│ → 홈 화면, 검색 결과, 내 상품 목록 등 모든 화면에 전파 │
│ → 각 화면에서 해당 상품 찾아서 UI 업데이트 │
└─────────────────────────────────────────────────────────────┘
문제점:
| 기능 | API 호출 횟수 | 설명 |
|---|---|---|
| 조회수 증가 | 2회 | 조회수 증가 + 상품 정보 재조회 |
| 찜 토글 | 2회 | 토글 + 상품 정보 재조회 |
| 채팅방 생성 | 2회 | 생성 + 상품 정보 재조회 |
상품 상세 화면 진입 + 찜 + 채팅 시작 = 6회 API 호출
모든 업데이트를 Lazy로 바꾸면 안 됩니다. 기준이 필요합니다.
| 기능 | 이유 |
|---|---|
| 상품 삭제 | 삭제된 상품이 목록에 남아있으면 혼란 |
| 상품 수정 | 수정 내용을 바로 확인해야 함 |
| 상품 상태 변경 | "예약중", "판매완료"는 중요한 정보 |
| 찜 상태 토글 | 하트 아이콘은 즉시 반영해야 함 |
공통점: 사용자가 액션 결과를 즉시 확인해야 하는 경우
| 기능 | 이유 |
|---|---|
| 조회수 증가 | 123 → 124 차이를 사용자가 인지 못함 |
| 찜 카운트 | 하트는 즉시 반영, 숫자는 나중에 |
| 채팅 카운트 | 채팅방은 생성됨, 숫자는 나중에 |
공통점: 통계성 데이터로, 즉시 정확하지 않아도 사용자 경험에 영향 없음
Lazy Update 적용 여부를 결정할 때:
□ 사용자가 액션 결과를 즉시 확인해야 하는가?
→ Yes: 즉시 업데이트
→ No: Lazy Update 후보
□ 데이터가 틀리면 사용자가 혼란스러운가?
→ Yes: 즉시 업데이트
→ No: Lazy Update 후보
□ 해당 데이터가 비즈니스 로직에 영향을 주는가?
→ Yes: 즉시 업데이트
→ No: Lazy Update 후보
□ 빈번하게 발생하는 액션인가?
→ Yes: Lazy Update 적용 효과 큼
→ No: 즉시 업데이트 유지해도 무방
가장 빈번하게 발생하는 조회수 증가에 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;
},
);
}
문제점 분석:
incrementViewCountUseCase → API 1회getProductDetailUseCase → API 1회 (추가)updatedProductProvider 이벤트 → 모든 목록 화면 리렌더링조회수가 1 증가하는데 이 모든 과정이 필요할까요?
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;
},
);
}
변경 효과:
| 항목 | 변경 전 | 변경 후 |
|---|---|---|
| 서버 데이터 | 즉시 반영 | 즉시 반영 (동일) |
| 상세 화면 조회수 | 즉시 업데이트 | 이전 값 유지 |
| 목록 화면 조회수 | 즉시 업데이트 | 새로고침 시 반영 |
실제로 사용자가 느끼는 차이:
거의 없습니다. 조회수가 "123"인 상태에서 화면을 봤다가, 새로고침하면 "124"가 됩니다. 사용자는 그 차이를 인지하지 못합니다.
찜 기능은 두 가지 데이터가 있습니다:
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;
});
},
);
},
);
}
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 이벤트 발행
},
);
}
핵심 포인트:
1. 사용자가 찜 버튼 클릭
- 하트: ♡ → ♥ (즉시 변경) ✅
- 카운트: "찜 23" (이전 값 유지)
2. 서버에서 처리
- 찜 추가 성공
- 실제 카운트: 24
3. 사용자가 화면 새로고침 (또는 다시 진입)
- 하트: ♥ (유지)
- 카운트: "찜 24" (서버 값 반영) ✅
사용자는 하트가 바로 바뀌는 것만 확인하고, 숫자가 23인지 24인지는 크게 신경 쓰지 않습니다.
채팅방 생성 시 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 호출
}
}
// ✅ _updateProductAfterChatRoomCreated 메서드 제거
Future<void> createOrGetChatRoomAndEnter(...) async {
// ... 채팅방 생성 로직 ...
// ✅ Lazy Update: 채팅 카운트는 새로고침 시 반영
// 채팅방은 정상적으로 생성됨
// 상품의 chatCount는 사용자가 상품 목록을 새로고침할 때 반영
// 제거된 것:
// - _updateProductAfterChatRoomCreated() 호출
// - 상품 정보 재조회 API 호출
// - updatedProductProvider 이벤트 발행
}
변경 효과:
채팅방 생성 흐름이 단순해집니다:
변경 전:
채팅방 생성 → 상품 정보 재조회 → 이벤트 발행 → 목록 업데이트
변경 후:
채팅방 생성 → 끝
| 시나리오 | 변경 전 | 변경 후 | 감소율 |
|---|---|---|---|
| 상품 상세 진입 | 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%)
| 항목 | 영향 |
|---|---|
| 서버 CPU | API 처리량 50% 감소 → CPU 부하 감소 |
| 데이터베이스 | 쿼리 수 50% 감소 → DB 부하 감소 |
| 네트워크 | 트래픽 감소 → 대역폭 비용 절감 |
| 응답 시간 | 서버 부하 감소 → 전체적인 응답 속도 개선 |
Lazy Update는 최적화 전략입니다. 조기 최적화는 오히려 복잡도만 높입니다.
즉시 적용이 필요한 경우:
현재 상태 유지해도 되는 경우:
1단계: 조회수 증가에 Lazy Update 적용
→ 가장 빈번하고, 사용자 경험 영향 최소
2단계: 모니터링 및 효과 측정
→ API 호출 감소 확인
→ 사용자 불만 여부 확인
3단계: 찜 카운트, 채팅 카운트에 적용
→ 효과가 검증되면 확장
Lazy 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);
}
}
자주 조회되는 상품 정보를 클라이언트에 캐싱합니다.
// 예: 5분간 캐시 유지
final productCache = StateProvider.family<Product?, int>((ref, productId) {
// 5분 후 자동 만료
ref.cacheFor(Duration(minutes: 5));
return null;
});
같은 상품을 연속으로 조회할 때 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는 "모든 것을 즉시 반영할 필요는 없다"는 실용적인 관점입니다.
조기 최적화보다는, 실제로 문제가 발생하거나 예상될 때 적용하는 것을 권장합니다.
⚠️ 참고
이 글은 Riverpod + StateNotifier + Clean Architecture 환경에서 작성되었습니다.
다른 상태관리를 사용하더라도 패턴 자체는 동일하게 적용할 수 있습니다.핵심은:
- 중요한 데이터: 즉시 업데이트
- 통계성 데이터: Lazy Update (새로고침 시 반영)
| 용어 | 설명 |
|---|---|
| Lazy Update | 필요할 때만 업데이트 |
| Deferred Update | 업데이트를 나중으로 미룸 |
| On-Demand Update | 요청 시에만 업데이트 |
| Eventual Consistency | 최종적으로 일관되게 수렴 |