API 응답 결과에 대한 캐싱 전략

pitseleh·2025년 5월 27일
post-thumbnail

백엔드 담당자가 Open Weather API를 활용하여 구현한 API가 상당히 특이했다. 시간대별로 응답 결과가 달랐는데, 0~3시에 호출할 때 아래와 같이 가장 많은 시간대별 날씨 정보를 얻을 수 있었고, 21~23시에 조회 시 빈 배열을 반환했다. 메인페이지가 외부 API에 강하게 의존하고 있다는 문제도 있었기 때문에 캐싱이 필수적이었다.

// 0~3시에 호출
{
    "today_weather": [
        {
            "time": "2025-05-20 03:00",
            "score": 75.5,
            "체감온도": "17.5℃ (보통)",
            "풍속": "4.0m/s (보통)",
            "습도": "89% (나쁨)",
            "구름": "100% (많음)",
            "미세먼지": "25.88μg/m³ (좋음)",
            "초미세먼지": "21.19μg/m³ (보통)"
        },
        {
            "time": "2025-05-20 06:00",
            "score": 76.0,
            "체감온도": "17.4℃ (보통)",
            "풍속": "3.3m/s (보통)",
            "습도": "93% (나쁨)",
            "구름": "100% (많음)",
            "미세먼지": "25.88μg/m³ (좋음)",
            "초미세먼지": "21.19μg/m³ (보통)"
        },
        {
            "time": "2025-05-20 09:00",
            "score": 76.6,
            "체감온도": "19.3℃ (보통)",
            "풍속": "2.0m/s (좋음)",
            "습도": "93% (나쁨)",
            "구름": "100% (많음)",
            "미세먼지": "25.88μg/m³ (좋음)",
            "초미세먼지": "21.19μg/m³ (보통)"
        },
        {
            "time": "2025-05-20 12:00",
            "score": 77.6,
            "체감온도": "21.2℃ (보통)",
            "풍속": "3.5m/s (보통)",
            "습도": "86% (나쁨)",
            "구름": "100% (많음)",
            "미세먼지": "25.88μg/m³ (좋음)",
            "초미세먼지": "21.19μg/m³ (보통)"
        },
        {
            "time": "2025-05-20 15:00",
            "score": 73.0,
            "체감온도": "24.9℃ (높음)",
            "풍속": "4.0m/s (보통)",
            "습도": "64% (나쁨)",
            "구름": "79% (많음)",
            "미세먼지": "25.88μg/m³ (좋음)",
            "초미세먼지": "21.19μg/m³ (보통)"
        },
        {
            "time": "2025-05-20 18:00",
            "score": 75.2,
            "체감온도": "22.8℃ (높음)",
            "풍속": "3.3m/s (보통)",
            "습도": "71% (나쁨)",
            "구름": "3% (적음)",
            "미세먼지": "25.88μg/m³ (좋음)",
            "초미세먼지": "21.19μg/m³ (보통)"
        },
        {
            "time": "2025-05-20 21:00",
            "score": 79.3,
            "체감온도": "20.0℃ (보통)",
            "풍속": "1.6m/s (좋음)",
            "습도": "84% (나쁨)",
            "구름": "5% (적음)",
            "미세먼지": "25.88μg/m³ (좋음)",
            "초미세먼지": "21.19μg/m³ (보통)"
        }
    ]
}

// 21~23시에 호출
{
    "today_weather": [ ]
}

1. 설계

  • 일별 캐싱
    • 날짜별로 데이터 분리 저장
    • 날짜가 바뀔 때마다 새로운 날씨 데이터 확보
  • 시간대별 대응
    • API 특성에 맞는 호출 시점 최적화
    • 골든타임(0-3시) 집중 활용, 데드타임(21-23시) 차단
  • 오프라인 대응
    • 네트워크 오류 시 캐시 데이터 활용
  • 자동 갱신
    • 자정 시점에 자동 데이터 업데이트

2. 구현 방법

1) 캐시 키 설정

static const String CACHE_KEY_DATA = 'weather_data';     // 날씨 데이터
static const String CACHE_KEY_DATE = 'weather_date';     // 캐시 날짜

2) 메인 캐싱 로직

static Future<List<WeatherData>> fetchWeatherData(String? token, {bool forceRefresh = false}) async {
  if (token == null) return [];

  final now = DateTime.now();
  final today = DateFormat('yyyy-MM-dd').format(now);
  final currentHour = now.hour;
  final prefs = await SharedPreferences.getInstance();
  
  // API 비가용 시간대 판단 (21-23시)
  final isApiUnavailableTime = currentHour >= 21 && currentHour <= 23;
  
  // 캐시 우선 전략
  if (!forceRefresh) {
    final cachedDataStr = prefs.getString(CACHE_KEY_DATA);
    final cachedDateStr = prefs.getString(CACHE_KEY_DATE);
    
    if (cachedDataStr != null && cachedDateStr == today) {
      debugPrint('오늘 날짜의 캐시된 데이터 사용');
      try {
        return _parseCachedData(cachedDataStr);
      } catch (e) {
        debugPrint('캐시된 데이터 파싱 오류: $e');
      }
    }
  }
  
  // API 비가용 시간대 특별 처리
  if (isApiUnavailableTime) {
    debugPrint('API 비가용 시간대 (21시~23시): 캐시된 데이터 사용');
    return _getCachedDataOrEmpty(prefs, today);
  }
  
  // 실제 API 호출
  return await _callWeatherAPI(token, prefs, today);
}
  • 캐시 우선 : 강제 새로고침이 아닌 경우 캐시 데이터 우선 사용
  • 시간대 고려 : 21-23시에는 API 호출을 차단하고 캐시만 사용
  • 날짜 검증 : 오늘 날짜의 캐시만 유효한 것으로 처리

3) 자동 갱신 시스템

// 날짜 변경 감지 및 자동 갱신
static Future<bool> checkAndUpdateIfNeeded(String? token) async {
  if (token == null) return false;
  
  final prefs = await SharedPreferences.getInstance();
  final cachedDateStr = prefs.getString(CACHE_KEY_DATE);
  final now = DateTime.now();
  final today = DateFormat('yyyy-MM-dd').format(now);
  
  // 날짜 변경 감지
  if (cachedDateStr == null || cachedDateStr != today) {
    debugPrint('날짜가 변경되어 데이터 업데이트: $cachedDateStr -> $today');
    
    final currentHour = now.hour;
    if (currentHour >= 21) {
      // 21시 이후에는 날짜만 업데이트
      await prefs.setString(CACHE_KEY_DATE, today);
      return false;
    }
    
    // API 호출 및 캐싱
    await fetchWeatherData(token, forceRefresh: true);
    return true;
  }
  
  return false;
}
  • 날짜 감지
    • 캐시된 날짜와 현재 날짜 비교로 일자 변경 자동 감지
    • 첫 실행 시 캐시가 없는 경우도 자동으로 감지하여 초기 데이터 로드
  • 시간대를 고려한 업데이트
    • 밤 9시 이후 날짜가 바뀐 경우: 날짜 메타데이터만 업데이트
    • 일반 시간대 날짜 변경: 즉시 새로운 데이터 요청

4) 골든타임 최적화

// 자정 직후 최적 데이터 수집
static Future<void> fetchMidnightData(String? token) async {
  if (token == null) return;
  
  final now = DateTime.now();
  final hour = now.hour;
  
  // 자정 직후 시간인지 확인 (0시~2시)
  if (hour >= 0 && hour < 3) {
    debugPrint('자정 직후 시간에 데이터 가져오기 시도');
    await fetchWeatherData(token, forceRefresh: true);
  }
}

0-3시에 응답 데이터가 가장 많기 때문에 해당 시간대에 데이터를 선제적으로 확보해야 한다.

3. UI에서 활용

1) 앱 생명주기 관리

class _FirstStatusInfoState extends State<FirstStatusInfo> with WidgetsBindingObserver {
  Timer? _midnightTimer;

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
    _initializeData();
    _setupMidnightTimer();  // 자정 타이머 설정
  }

  // 앱이 포그라운드로 복귀 시 날짜 확인
  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    if (state == AppLifecycleState.resumed) {
      _checkDateAndRefresh();
    }
  }
  
  // 자정 타이머 설정
  void _setupMidnightTimer() {
    final now = DateTime.now();
    final tomorrow = DateTime(now.year, now.month, now.day + 1);
    final duration = tomorrow.difference(now);
    
    _midnightTimer = Timer(duration, () {
      debugPrint('자정이 되어 데이터 새로고침');
      _refreshData(forceRefresh: true);
      _setupMidnightTimer();  // 다음 자정 타이머 설정
    });
  }
}
  • WidgetsBindingObserver를 통해 앱의 포그라운드/백그라운드 전환 감지
  • 백그라운드에서 포그라운드로 복귀 시 자동으로 데이터가 당일의 데이터인지 확인
  • 따라서 사용자가 오랫동안 앱을 사용하지 않다가 돌아와도 최신 데이터를 보장할 수 있음

2) 초기화

Future<void> _initializeData() async {
  final token = Provider.of<AppState>(context, listen: false).accessToken;
  final now = DateTime.now();
  final hour = now.hour;
  final isPostMidnight = hour >= 0 && hour < 3;
  
  setState(() {
    _weatherDataFuture = Future(() async {
      try {
        if (isPostMidnight) {
          // 자정 직후
          final prefs = await SharedPreferences.getInstance();
          final lastMidnightRefresh = prefs.getString('last_midnight_refresh');
          final today = DateFormat('yyyy-MM-dd').format(now);
          
          if (lastMidnightRefresh != today) {
            debugPrint('자정 직후 첫 방문: 강제 새로고침');
            await prefs.setString('last_midnight_refresh', today);
            return await WeatherService.fetchWeatherData(token, forceRefresh: true);
          } else {
            debugPrint('자정 직후 재방문: 이미 갱신했으므로 캐시 사용');
            return await WeatherService.fetchWeatherData(token, forceRefresh: false);
          }
        } else {
          // 일반 시간대 처리
          final dateChanged = await WeatherService.checkAndUpdateIfNeeded(token);
          if (dateChanged) {
            return await WeatherService.fetchWeatherData(token, forceRefresh: true);
          } else {
            return await WeatherService.fetchWeatherData(token, forceRefresh: false);
          }
        }
      } catch (e) {
        return <WeatherData>[];
      }
    });
  });
}
  • 자정 직후 여러 번 앱을 켜도 불필요한 API 호출 방지

Flutter는 처음이기도 하고 API 응답 방식이 특이해서 어떻게 해야 할지 고민되었는데 다행히 완성은 했다...시간에 쫓겨서 정신 없이 개발했지만...

0개의 댓글