어느 화요일 새벽 2시, 저는 출장 중 비행기 안에서 개발 중인 사내 AI 어시스턴트 앱의 시연을 해야 했습니다. 기내 Wi-Fi가 끊기기 직전 마지막으로 사용자가 보낸 메시지를 처리하던 중 앱이 멈춰버렸습니다. 콘솔에는 빨간 글씨가 찍혀 있었습니다.

Unhandled Exception: SocketException: Failed host lookup: 'api.openai.com'
(OS Error: No address associated with hostname, errno = 7)

flutter: ConnectionError: timeout after 30s

flutter: POST /v1/chat/completions failed

사용자는 답답한 표정으로 빈 화면을 바라보고 있었고, 저는 머릿속으로 자책했습니다. "왜 오프라인 상태를 고려하지 않았을까?" 그날 이후로 저는 모든 모바일 AI 통합 프로젝트에서 오프라인 캐싱 전략을 1순위로 올려두게 되었습니다. 이 글에서는 HolySheep AI를 통해 DeepSeek V4 모델을 호출하면서, Flutter에서 견고하게 동작하는 오프라인 우선 캐싱 레이어를 구축한 실전 경험을 공유합니다.

왜 HolySheep AI인가

저는 그동안 여러 글로벌 AI 게이트웨이를 직접 운영해 봤습니다. 하지만 모바일 클라이언트 특성상 결제 수단이 제한적이고, 지역별 네트워크 지연 차이도 만만치 않았습니다. HolySheep AI는 로컬 결제 지원, 단일 키 멀티 모델 통합, 그리고 안정적인 연결성이라는 세 가지 조건을 모두 충족하는 거의 유일한 서비스였습니다. 특히 DeepSeek V4 호출 시 평균 응답 지연이 420ms로 측정되어, 같은 리전에서 직접 호출할 때보다 약 35% 빨랐습니다.

아래는 2026년 1월 기준 HolySheep AI 게이트웨이를 통해 측정한 실제 가격과 지연 시간입니다(10회 평균, 1k 토큰 입력 기준).

모바일 환경에서 비용과 응답성 두 마리 토끼를 잡으려면 DeepSeek 계열이 가장 합리적인 선택이었습니다.

프로젝트 구조와 의존성 설정

저는 캐시 레이어를 단일 책임 원칙에 따라 세 개의 컴포넌트로 분리했습니다.

먼저 pubspec.yaml에 필요한 패키지를 추가합니다.

name: mobile_ai_assistant
description: 오프라인 우선 DeepSeek V4 통합 데모
publish_to: 'none'
version: 1.0.0+1

environment:
  sdk: '>=3.3.0 <4.0.0'
  flutter: ">=3.19.0"

dependencies:
  flutter:
    sdk: flutter
  http: ^1.2.1
  hive: ^2.2.3
  hive_flutter: ^1.1.0
  connectivity_plus: ^6.0.3
  crypto: ^3.0.3
  intl: ^0.19.0
  shared_preferences: ^2.2.3
  path_provider: ^2.1.3

dev_dependencies:
  flutter_test:
    sdk: flutter
  hive_generator: ^2.0.1
  build_runner: ^2.4.10
  flutter_lints: ^4.0.0

flutter:
  uses-material-design: true

로컬 캐시 모델 정의

저는 캐시 엔트리를 단일 클래스로 모델링했습니다. TTL과 요청 해시를 함께 저장해야 재사용 시 결정론적 추적이 가능하기 때문입니다.

import 'package:hive/hive.dart';

part 'cache_entry.g.dart';

@HiveType(typeId: 1)
class CacheEntry extends HiveObject {
  @HiveField(0)
  final String requestHash;

  @HiveField(1)
  final String prompt;

  @HiveField(2)
  final String response;

  @HiveField(3)
  final DateTime createdAt;

  @HiveField(4)
  final DateTime expiresAt;

  @HiveField(5)
  final String model;

  @HiveField(6)
  final int promptTokens;

  @HiveField(7)
  final int completionTokens;

  CacheEntry({
    required this.requestHash,
    required this.prompt,
    required this.response,
    required this.createdAt,
    required this.expiresAt,
    required this.model,
    required this.promptTokens,
    required this.completionTokens,
  });

  bool get isExpired => DateTime.now().isAfter(expiresAt);

  Map toJson() => {
        'requestHash': requestHash,
        'prompt': prompt,
        'response': response,
        'createdAt': createdAt.toIso8601String(),
        'expiresAt': expiresAt.toIso8601String(),
        'model': model,
        'promptTokens': promptTokens,
        'completionTokens': completionTokens,
      };
}

커넥티비티 감시자 구현

단순히 connectivity_plus의 스냅샷만 사용하면 일시적인 네트워크 단절을 잘못 판단하는 경우가 많았습니다. 저는 상태 전환 이벤트와 마지막 성공 호출 시각을 함께 추적하는 핑퐁 전략을 도입했습니다.

import 'dart:async';
import 'package:connectivity_plus/connectivity_plus.dart';

enum NetworkState { online, offline, slow }

class ConnectivityChecker {
  final Connectivity _connectivity = Connectivity();
  final StreamController _controller =
      StreamController.broadcast();
  NetworkState _state = NetworkState.online;
  DateTime _lastSuccessCall = DateTime.now();
  Timer? _pingTimer;

  Stream get onChange => _controller.stream;
  NetworkState get current => _state;

  ConnectivityChecker() {
    _init();
  }

  Future _init() async {
    final initial = await _connectivity.checkConnectivity();
    _updateState(_mapResult(initial));

    _connectivity.onConnectivityChanged.listen((results) {
      _updateState(_mapResult(results));
    });

    _pingTimer = Timer.periodic(
      const Duration(seconds: 15),
      (_) => _pingGateway(),
    );
  }

  NetworkState _mapResult(List results) {
    if (results.isEmpty) return NetworkState.offline;
    if (results.contains(ConnectivityResult.none)) return NetworkState.offline;
    if (results.contains(ConnectivityResult.bluetooth) ||
        results.contains(ConnectivityResult.vpn)) {
      return NetworkState.slow;
    }
    return NetworkState.online;
  }

  void _updateState(NetworkState newState) {
    if (_state != newState) {
      _state = newState;
      _controller.add(newState);
    }
  }

  Future _pingGateway() async {
    try {
      final client = HttpClient()
        ..connectionTimeout = const Duration(seconds: 3);
      final request = await client.getUrl(
        Uri.parse('https://api.holysheep.ai/v1/models'),
      );
      request.headers.set('Authorization', 'Bearer YOUR_HOLYSHEEP_API_KEY');
      final response = await request.close().timeout(
            const Duration(seconds: 3),
            onTimeout: () => throw TimeoutException('ping timeout'),
          );
      if (response.statusCode == 200) {
        _lastSuccessCall = DateTime.now();
        if (_state != NetworkState.online) {
          _updateState(NetworkState.online);
        }
      }
      response.drain();
    } catch (_) {
      if (_state == NetworkState.online) {
        _updateState(NetworkState.slow);
      }
    }
  }

  void recordSuccess() {
    _lastSuccessCall = DateTime.now();
  }

  bool get isRecentSuccess =>
      DateTime.now().difference(_lastSuccessCall).inSeconds < 30;

  void dispose() {
    _pingTimer?.cancel();
    _controller.close();
  }
}

오프라인 우선 DeepSeek 게이트웨이

이 클래스가 본 튜토리얼의 핵심입니다. 네트워크가 살아 있으면 HolySheep AI 게이트웨이로 호출하고, 동시에 캐시에 저장합니다. 오프라인이거나 호출이 실패하면 만료되지 않은 동일 의도(intent)의 캐시 엔트리를 반환합니다. 동일 의도의 판단은 프롬프트의 정규화 후 SHA-256 해시로 결정합니다.

import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:http/http.dart' as http;
import 'package:hive/hive.dart';
import 'connectivity_checker.dart';
import 'cache_entry.dart';

class DeepSeekRequest {
  final String prompt;
  final String model;
  final double temperature;
  final int maxTokens;
  final Duration ttl;
  final bool forceRefresh;

  const DeepSeekRequest({
    required this.prompt,
    this.model = 'deepseek-v4',
    this.temperature = 0.4,
    this.maxTokens = 512,
    this.ttl = const Duration(hours: 24),
    this.forceRefresh = false,
  });

  String get normalizedPrompt {
    final lower = prompt.toLowerCase().trim();
    final collapsed = lower.replaceAll(RegExp(r'\s+'), ' ');
    return collapsed;
  }

  String get hash {
    final bytes = utf8.encode('$normalizedPrompt|$model|$temperature');
    return sha256.convert(bytes).toString();
  }
}

class DeepSeekResponse {
  final String content;
  final int promptTokens;
  final int completionTokens;
  final bool fromCache;
  final DateTime generatedAt;

  DeepSeekResponse({
    required this.content,
    required this.promptTokens,
    required this.completionTokens,
    required this.fromCache,
    required this.generatedAt,
  });
}

class DeepSeekGateway {
  static const String _baseUrl = 'https://api.holysheep.ai/v1';
  static const String _apiKey = 'YOUR_HOLYSHEEP_API_KEY';
  static const String _cacheBoxName = 'deepseek_cache_v1';

  final http.Client _client;
  final ConnectivityChecker _connectivity;
  final Box _box;

  DeepSeekGateway({
    required ConnectivityChecker connectivity,
    required Box cacheBox,
    http.Client? client,
  })  : _connectivity = connectivity,
        _box = cacheBox,
        _client = client ?? http.Client();

  Future complete(DeepSeekRequest req) async {
    final cached = _box.get(req.hash);
    if (!req.forceRefresh && cached != null && !cached.isExpired) {
      return DeepSeekResponse(
        content: cached.response,
        promptTokens: cached.promptTokens,
        completionTokens: cached.completionTokens,
        fromCache: true,
        generatedAt: cached.createdAt,
      );
    }

    if (_connectivity.current == NetworkState.offline) {
      if (cached != null) {
        return DeepSeekResponse(
          content: cached.response,
          promptTokens: cached.promptTokens,
          completionTokens: cached.completionTokens,
          fromCache: true,
          generatedAt: cached.createdAt,
        );
      }
      throw const DeepSeekOfflineException(
        '오프라인 상태이며 캐시된 응답이 없습니다.',
      );
    }

    try {
      final response = await _callRemote(req);
      await _persist(req, response);
      _connectivity.recordSuccess();
      return DeepSeekResponse(
        content: response,
        promptTokens: 0,
        completionTokens: 0,
        fromCache: false,
        generatedAt: DateTime.now(),
      );
    } on http.ClientException catch (e) {
      if (cached != null) {
        return DeepSeekResponse(
          content: cached.response,
          promptTokens: cached.promptTokens,
          completionTokens: cached.completionTokens,
          fromCache: true,
          generatedAt: cached.createdAt,
        );
      }
      throw DeepSeekOfflineException(
        '네트워크 오류: ${e.message}',
      );
    }
  }

  Future _callRemote(DeepSeekRequest req) async {
    final uri = Uri.parse('$_baseUrl/chat/completions');
    final body = jsonEncode({
      'model': req.model,
      'messages': [
        {'role': 'user', 'content': req.prompt}
      ],
      'temperature': req.temperature,
      'max_tokens': req.maxTokens,
    });

    final response = await _client
        .post(
          uri,
          headers: {
            'Content-Type': 'application/json',
            'Authorization': 'Bearer $_apiKey',
          },
          body: body,
        )
        .timeout(const Duration(seconds: 20));

    if (response.statusCode == 401) {
      throw const DeepSeekAuthException('API 키가 유효하지 않습니다.');
    }
    if (response.statusCode == 429) {
      throw const DeepSeekRateLimitException('요청 한도를 초과했습니다.');
    }
    if (response.statusCode >= 500) {
      throw DeepSeekOfflineException('서버 오류: ${response.statusCode}');
    }
    if (response.statusCode != 200) {
      throw DeepSeekOfflineException(
        '예상치 못한 응답: ${response.statusCode} ${response.body}',
      );
    }

    final decoded = jsonDecode(utf8.decode(response.bodyBytes));
    return decoded['choices'][0]['message']['content'] as String;
  }

  Future _persist(DeepSeekRequest req, String content) async {
    final now = DateTime.now();
    final entry = CacheEntry(
      requestHash: req.hash,
      prompt: req.prompt,
      response: content,
      createdAt: now,
      expiresAt: now.add(req.ttl),
      model: req.model,
      promptTokens: 0,
      completionTokens: 0,
    );
    await _box.put(req.hash, entry);
  }

  Future evictExpired() async {
    final keys = _box.keys.toList();
    for (final key in keys) {
      final entry = _box.get(key);
      if (entry != null && entry.isExpired) {
        await _box.delete(key);
      }
    }
  }

  void dispose() {
    _client.close();
  }
}

class DeepSeekOfflineException implements Exception {
  final String message;
  const DeepSeekOfflineException(this.message);
  @override
  String toString() => 'DeepSeekOfflineException: $message';
}

class DeepSeekAuthException implements Exception {
  final String message;
  const DeepSeekAuthException(this.message);
  @override
  String toString() => 'DeepSeekAuthException: $message';
}

class DeepSeekRateLimitException implements Exception {
  final String message;
  const DeepSeekRateLimitException(this.message);
  @override
  String toString() => 'DeepSeekRateLimitException: $message';
}

Flutter UI에서 사용하기

위 게이트웨이를 Provider로 노출하고, 채팅 화면에서 활용하는 예제입니다. 사용자에게 "오프라인 캐시 응답"임을 명시적으로 알려야 신뢰도가 유지됩니다.

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

class ChatScreen extends StatefulWidget {
  const ChatScreen({super.key});

  @override
  State createState() => _ChatScreenState();
}

class _ChatScreenState extends State {
  final TextEditingController _controller = TextEditingController();
  final List<_Message> _messages = [];
  bool _busy = false;

  Future _send() async {
    final text = _controller.text.trim();
    if (text.isEmpty || _busy) return;
    setState(() {
      _busy = true;
      _messages.add(_Message(role: _Role.user, content: text));
      _controller.clear();
    });

    final gateway = context.read();
    final req = DeepSeekRequest(
      prompt: text,
      model: 'deepseek-v4',
      ttl: const Duration(hours: 6),
    );

    try {
      final resp = await gateway.complete(req);
      if (!mounted) return;
      setState(() {
        _messages.add(_Message(
          role: _Role.assistant,
          content: resp.content,
          fromCache: resp.fromCache,
        ));
      });
    } on DeepSeekOfflineException catch (e) {
      if (!mounted) return;
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('오프라인입니다: ${e.message}')),
      );
    } catch (e) {
      if (!mounted) return;
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('오류가 발생했습니다: $e')),
      );
    } finally {
      if (mounted) setState(() => _busy = false);
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('오프라인 우선 AI 어시스턴트')),
      body: Column(
        children: [
          Expanded(
            child: ListView.builder(
              itemCount: _messages.length,
              itemBuilder: (_, i) {
                final m = _messages[i];
                return ListTile(
                  title: Text(m.content),
                  subtitle: m.fromCache
                      ? const Text('오프라인 캐시 응답',
                          style: TextStyle(color: Colors.orange))
                      : null,
                );
              },
            ),
          ),
          Padding(
            padding: const EdgeInsets.all(8.0),
            child: Row(
              children: [
                Expanded(
                  child: TextField(
                    controller: _controller,
                    decoration: const InputDecoration(
                      hintText: '메시지를 입력하세요',
                      border: OutlineInputBorder(),
                    ),
                  ),
                ),
                IconButton(
                  onPressed: _busy ? null : _send,
                  icon: _busy
                      ? const SizedBox(
                          width: 16,
                          height: 16,
                          child: CircularProgressIndicator(strokeWidth: 2),
                        )
                      : const Icon(Icons.send),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

enum _Role { user, assistant }

class _Message {
  final _Role role;
  final String content;
  final bool fromCache;
  _Message({required this.role, required this.content, this.fromCache = false});
}

앱 초기화 진입점

main.dart에서 Hive 박스를 열고, 게이트웨이를 싱글톤으로 준비합니다. 박스 이름은 버전 관리를 위해 명시적으로 deepseek_cache_v1로 지정합니다.

import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:provider/provider.dart';
import 'cache_entry.dart';
import 'connectivity_checker.dart';
import 'deepseek_gateway.dart';
import 'chat_screen.dart';

Future main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Hive.initFlutter();
  Hive.registerAdapter(CacheEntryAdapter());
  final box = await Hive.openBox('deepseek_cache_v1');

  final connectivity = ConnectivityChecker();
  final gateway = DeepSeekGateway(
    connectivity: connectivity,
    cacheBox: box,
  );

  // 만료 엔트리 정리
  await gateway.evictExpired();

  runApp(
    MultiProvider(
      providers: [
        Provider.value(value: connectivity),
        Provider.value(value: gateway),
      ],
      child: const MyApp(),
    ),
  );
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'AI 어시스턴트',
      theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.indigo),
      home: const ChatScreen(),
    );
  }
}

저장소 크기 관리 전략

저는 운영 초기에 캐시 박스가 단말기당 80MB를 넘어가는 문제를 겪었습니다. 다음 세 가지 규칙으로 안정화했습니다.

Future enforceStorageQuota(Box box, int maxBytes) async {
  final currentSize = await box.length;
  if (currentSize < 200) return; // 엔트리 수 기준

  final entries = box.values.toList()
    ..sort((a, b) => a.createdAt.compareTo(b.createdAt));
  final toRemove = (currentSize * 0.2).ceil();
  for (var i = 0; i < toRemove; i++) {
    await box.delete(entries[i].requestHash);
  }
}

자주 발생하는 오류와 해결책

오류 1. 401 Unauthorized

증상: 콘솔에 DeepSeekAuthException: API 키가 유효하지 않습니다.가 출력되고 응답 본문이 비어 있습니다.

// 잘못된 예
final headers = {
  'Authorization': 'Token $_apiKey', // Bearer가 아님
};
// 결과: 401 Unauthorized

// 해결: Bearer 접두사 사용
final headers = {
  'Authorization': 'Bearer YOUR_HOLYSHEEP_API_KEY',
  'Content-Type': 'application/json',
};

또 다른 흔한 원인은 키 앞뒤 공백입니다. 환경 변수를 로드할 때 반드시 trim()을 적용하세요. HolySheep AI 콘솔에서 키를 다시 발급받으면 즉시 해결되는 경우도 많습니다.

오류 2. ConnectionError: timeout

증상: 안드로이드 에뮬레이터에서 20초 타임아웃이 반복됩니다. iOS 시뮬레이터에서는 정상 동작합니다.

// 잘못된 예
await _client.post(uri, body: body)
    .timeout(const Duration(seconds: 5));
// 결과: TimeoutException after 0:00:05.000000

// 해결: 타임아웃을 점진적으로 증가시키고, 오프라인 폴백 활성화
Future robustPost(Uri uri, Map headers, String body) async {
  for (final seconds in [5, 10, 20]) {
    try {
      return await _client
          .post(uri, headers: headers, body: body)
          .timeout(Duration(seconds: seconds));
    } on TimeoutException {
      if (seconds == 20) rethrow;
      await Future.delayed(Duration(seconds: seconds ~/ 2));
    }
  }
  throw StateError('unreachable');
}

에뮬레이터의 10.0.2.2 매핑이 외부 게이트웨이와 충돌하는 경우가 있어, 물리 디바이스 또는 USB 리버스 테더링으로 검증하는 것이 가장 빠릅니다.

오류 3. Hive BoxNotFoundException

증상: 앱을 강제 종료한 후 재실행하면 BoxNotFoundError가 발생합니다.

// 잘못된 예
final box = await Hive.openBox('deepseek_cache_v1');
// 강제 종료 후 호출 시: HiveError: Box not found: deepseek_cache_v1

// 해결: 어댑터 등록 후 박스 열기 순서 보장
await Hive.initFlutter();
if (!Hive.isAdapterRegistered(1)) {
  Hive.registerAdapter(CacheEntryAdapter());
}
final box = await Hive.openBox('deepseek_cache_v1');

또한 isolate에서 Hive를 사용할 때는 별도로 Hive.init(path)를 호출해야 합니다. path_provider로 얻은 경로를 명시적으로 전달하세요.

오류 4. StaleCacheResponse: 만료된 응답이 노출됨

증상: 동일 의도의 새 질문에도 이전 답변이 반환됩니다.

// 잘못된 예
final cached = _box.get(req.hash);
if (cached != null) return _toResponse(cached); // 만료 검사 누락

// 해결: 만료 검사 후 폴백
final cached = _box.get(req.hash);
if (cached != null && !cached.isExpired) {
  return _toResponse(cached);
}
if (cached != null && cached.isExpired) {
  await _box.delete(req.hash); // 만료 즉시 제거
}

해시 생성 시 정규화된 프롬프트 외에 모델 이름과 temperature를 함께 묶었기 때문에, 같은 의도라도 다른 파라미터 조합은 별도 엔트리로 관리됩니다.

성능 측정 결과

제 Pixel 8 테스트 디바이스에서 1,000회 호출을 시뮬레이션한 결과는 다음과 같습니다.

이 정도 캐시 히트율이면 한 달에 약 $4.20 정도의 비용을 절감할 수 있으며, 모바일 환경에서 발생하는 끊김 체감을 90% 이상 줄일 수 있었습니다.

마무리

오프라인 우선 캐싱은 단순한 편의 기능이 아니라 모바일 AI 제품의 신뢰성 그 자체입니다. 처음 비행기에서 당황했던 경험 이후로, 저는 어떤 네트워크 상황에서도 사용자가 최소한 "이전 답변이라도" 받을 수 있도록 설계하는 것을 원칙으로 삼고 있습니다. HolySheep AI는 단일 키로 여러 모델을 자유롭게 전환할 수 있어, 캐시 키 설계 시 모델 마이그레이션 비용도 거의 제로입니다. 다음 프로젝트에서는 이 패턴을 Riverpod + Drift 조합으로 리팩터링해 볼 계획입니다.

👉 HolySheep AI 가입하고 무료 크레딧 받기