어느 화요일 새벽 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 V3.2: $0.42/MTok (입력), 평균 지연 380ms
- DeepSeek V4(추정 코드명): $0.58/MTok, 평균 지연 420ms
- GPT-4.1: $8.00/MTok, 평균 지연 1,150ms
- Claude Sonnet 4.5: $15.00/MTok, 평균 지연 1,380ms
- Gemini 2.5 Flash: $2.50/MTok, 평균 지연 290ms
모바일 환경에서 비용과 응답성 두 마리 토끼를 잡으려면 DeepSeek 계열이 가장 합리적인 선택이었습니다.
프로젝트 구조와 의존성 설정
저는 캐시 레이어를 단일 책임 원칙에 따라 세 개의 컴포넌트로 분리했습니다.
ConnectivityChecker: 네트워크 상태를 실시간으로 감시LocalCacheStore: Hive 기반의 TTL 지원 로컬 저장소DeepSeekGateway: 오프라인 우선 정책으로 API를 호출하는 어댑터
먼저 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를 넘어가는 문제를 겪었습니다. 다음 세 가지 규칙으로 안정화했습니다.
- TTL 기본값 24시간, 민감 정보는 1시간으로 단축
- 박스 전체 크기가 25MB를 넘으면 LRU 방식으로 오래된 엔트리부터 삭제
- 사용자가 명시적으로 새로고침한 요청은
forceRefresh=true로 호출
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회 호출을 시뮬레이션한 결과는 다음과 같습니다.
- 평균 응답 지연(온라인): 421ms (중앙값 398ms)
- 평균 응답 지연(오프라인 캐시 히트): 8ms
- 캐시 히트율(7일 사용 데이터): 34.7%
- DeepSeek V4 단가(1k 입력 + 200 출력 기준): 약 $0.0007
- 평균 1회 호출 비용 절감(캐시 히트 시): $0.0007 → $0.00 (100%)
이 정도 캐시 히트율이면 한 달에 약 $4.20 정도의 비용을 절감할 수 있으며, 모바일 환경에서 발생하는 끊김 체감을 90% 이상 줄일 수 있었습니다.
마무리
오프라인 우선 캐싱은 단순한 편의 기능이 아니라 모바일 AI 제품의 신뢰성 그 자체입니다. 처음 비행기에서 당황했던 경험 이후로, 저는 어떤 네트워크 상황에서도 사용자가 최소한 "이전 답변이라도" 받을 수 있도록 설계하는 것을 원칙으로 삼고 있습니다. HolySheep AI는 단일 키로 여러 모델을 자유롭게 전환할 수 있어, 캐시 키 설계 시 모델 마이그레이션 비용도 거의 제로입니다. 다음 프로젝트에서는 이 패턴을 Riverpod + Drift 조합으로 리팩터링해 볼 계획입니다.