Flutter 앱에 Claude API 연동하기 — 채팅 UI부터 스트리밍 응답까지
Flutter 앱에 AI 채팅 기능을 넣고 싶어서 Claude API를 연동해 봤다. 처음에는 "그냥 http로 API 호출하면 되겠지" 하고 가볍게 시작했는데, 막상 해보니 API 키를 어디에 둘 것인가라는 보안 문제와 스트리밍 응답 처리라는 두 개의 산이 있었다. 이 글에서는 실제로 구현한 순서 그대로, 아키텍처 설계부터 기본 요청, 채팅 UI, SSE 스트리밍, 에러 처리까지 정리한다.
1. 아키텍처 — 앱이 API 키를 직접 들면 안 되는 이유
코드를 한 줄 쓰기 전에 이것부터 짚고 가야 한다. 가장 흔한 실수가 Anthropic API 키를 Dart 코드에 상수로 박아 넣고 앱에서 직접 API를 호출하는 구조다. 이 구조는 절대 배포하면 안 된다. 이유는 명확하다.
- APK/AAB는 누구나 다운로드해서 디컴파일할 수 있다. 문자열 상수로 들어간 API 키는
strings명령 한 번이면 그대로 노출된다. --dart-define이나.env파일로 주입해도 마찬가지다. 빌드 결과물 안에 키가 들어가는 순간 "숨긴 것"이지 "보호한 것"이 아니다.- 키가 유출되면 내 계정으로 과금이 발생한다. 종량제 API 특성상 피해 상한이 없다.
그래서 정석은 프록시 서버 경유다. 앱은 내 서버에만 요청을 보내고, API 키는 서버 환경변수에만 존재한다. 서버는 Cloud Run이나 Cloud Functions 같은 서버리스로 띄우면 요청이 없을 때 비용이 거의 들지 않는다. 프록시 서버에는 최소한 다음을 넣는다.
- 앱 사용자 인증 확인 (Firebase Auth 토큰 검증 등)
- 사용량 제한 (사용자당 하루 N회 같은 상한)
- Claude API 호출 후 응답 중계
이 글의 코드 예제는 개발 단계에서 구조를 이해하기 위한 것이고, 실제 배포 시에는 요청 URL만 내 프록시 서버 주소로 바꾸면 되도록 짜 두는 것이 좋다.
2. 기본 요청/응답 — http 패키지로 첫 호출
Flutter의 표준 HTTP 클라이언트인 http 패키지를 쓴다. pubspec.yaml에 추가한다.
dependencies:
http: ^1.2.0
Claude Messages API의 요청 형식은 단순하다. 모델명, 최대 토큰 수, 그리고 messages 배열이 핵심이다. 프록시 서버가 중계한다고 가정한 기본 호출 코드는 다음과 같다.
import 'dart:convert';
import 'package:http/http.dart' as http;
Future<String> askClaude(List<Map<String, String>> messages) async {
// 실제 배포 시엔 내 프록시 서버 주소. 키는 서버에만 있다.
final uri = Uri.parse('https://my-proxy.example.com/v1/chat');
final response = await http.post(
uri,
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer $userIdToken', // 앱 사용자 인증 토큰
},
body: jsonEncode({
'model': 'claude-sonnet-4-5',
'max_tokens': 1024,
'messages': messages,
}),
);
if (response.statusCode != 200) {
throw Exception('API 오류: ${response.statusCode}');
}
final data = jsonDecode(utf8.decode(response.bodyBytes));
return data['content'][0]['text'] as String;
}
몇 가지 짚을 점이 있다. 응답 본문을 response.body로 바로 읽으면 한글이 깨질 수 있어서 utf8.decode(response.bodyBytes)를 거친다. 모델명은 시점에 따라 바뀌므로 공식 문서의 모델 목록에서 최신 이름을 확인하고 쓴다. 서버 쪽에서는 이 요청을 받아 x-api-key와 anthropic-version 헤더를 붙여 Anthropic API로 전달하면 된다.
3. 채팅 UI 구성 — 메시지 리스트와 입력창
UI는 전형적인 채팅 화면이다. 메시지 모델 하나, 리스트 하나, 입력창 하나면 뼈대가 나온다.
class ChatMessage {
final String role; // 'user' 또는 'assistant'
String text;
ChatMessage(this.role, this.text);
}
// State 안에서
final List<ChatMessage> _messages = [];
final _controller = TextEditingController();
Widget build(BuildContext context) {
return Column(
children: [
Expanded(
child: ListView.builder(
reverse: true, // 최신 메시지가 아래 오도록
itemCount: _messages.length,
itemBuilder: (context, i) {
final m = _messages[_messages.length - 1 - i];
return Align(
alignment: m.role == 'user'
? Alignment.centerRight
: Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.all(6),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: m.role == 'user'
? Colors.blue.shade100
: Colors.grey.shade200,
borderRadius: BorderRadius.circular(12),
),
child: Text(m.text),
),
);
},
),
),
Row(
children: [
Expanded(child: TextField(controller: _controller)),
IconButton(icon: const Icon(Icons.send), onPressed: _send),
],
),
],
);
}
전송 버튼을 누르면 사용자 메시지를 리스트에 넣고, 비어 있는 assistant 메시지를 하나 추가한 뒤 API를 부른다. 이렇게 해 두면 스트리밍 단계에서 그 빈 메시지에 텍스트를 이어 붙이기만 하면 된다. 대화 맥락을 유지하려면 _messages 전체를 role/content 형태로 변환해서 매 요청에 함께 보낸다는 점도 잊지 말자. Claude API는 상태를 저장하지 않으므로 이전 대화는 매번 다시 보내야 한다.
4. 스트리밍 응답 받기 — SSE 처리
일반 요청은 응답이 다 만들어질 때까지 수 초를 기다려야 한다. 채팅 UX에서는 이 침묵이 치명적이라, 요청에 "stream": true를 넣고 SSE(Server-Sent Events)로 조각을 받아 실시간으로 그리는 것이 표준이다.
이벤트는 message_start → content_block_start → content_block_delta(반복) → content_block_stop → message_stop 순서로 온다. 우리가 UI에 그릴 것은 content_block_delta 안의 delta.text다. http 패키지에서는 client.send()로 StreamedResponse를 받아 줄 단위로 파싱한다.
Future<void> askClaudeStream(
List<Map<String, String>> messages,
void Function(String chunk) onChunk,
) async {
final client = http.Client();
try {
final request = http.Request(
'POST', Uri.parse('https://my-proxy.example.com/v1/chat'))
..headers['Content-Type'] = 'application/json'
..body = jsonEncode({
'model': 'claude-sonnet-4-5',
'max_tokens': 1024,
'stream': true,
'messages': messages,
});
final response = await client.send(request);
await response.stream
.transform(utf8.decoder)
.transform(const LineSplitter())
.forEach((line) {
if (!line.startsWith('data: ')) return;
final payload = line.substring(6);
final event = jsonDecode(payload) as Map<String, dynamic>;
if (event['type'] == 'content_block_delta') {
final text = event['delta']?['text'];
if (text != null) onChunk(text as String);
}
});
} finally {
client.close();
}
}
호출부에서는 onChunk가 올 때마다 마지막 assistant 메시지에 텍스트를 붙이고 setState를 부르면 타자기처럼 답변이 흘러나온다. 주의할 점 하나 — 네트워크 사정에 따라 한 chunk에 이벤트 줄이 반 토막으로 잘려 들어올 수 있다. 위 코드처럼 LineSplitter를 거치면 대부분 해결되지만, 프로덕션에서는 버퍼를 두고 완전한 줄만 파싱하는 방어 코드를 넣거나 SSE 전용 패키지를 쓰는 편이 안전하다.
5. 에러와 재시도 처리
API 연동에서 완성도를 가르는 것은 정상 경로가 아니라 실패 경로다. Claude API에서 실제로 만나게 되는 에러는 대략 네 종류다.
핵심 원칙은 일시적 오류(429, 529)만 재시도하고, 4xx 오류는 재시도하지 않는다는 것이다. 429를 즉시 재시도하면 오히려 한도 초과가 길어진다. 지수 백오프를 넣은 재시도 래퍼는 이렇게 짰다.
Future<T> withRetry<T>(Future<T> Function() task) async {
const maxAttempts = 3;
for (var attempt = 1; ; attempt++) {
try {
return await task();
} on ApiException catch (e) {
final retryable = e.statusCode == 429 || e.statusCode == 529;
if (!retryable || attempt >= maxAttempts) rethrow;
// 1초 → 2초 → 4초 지수 백오프
await Future.delayed(Duration(seconds: 1 << (attempt - 1)));
}
}
}
스트리밍 중간에 연결이 끊기는 경우도 있다. 이때는 지금까지 받은 텍스트를 버리지 말고 화면에 남긴 채 "응답이 중단되었습니다. 다시 시도" 버튼을 보여주는 쪽이 UX가 좋았다.
6. 정리 — 보안 구조 다시 한번
마지막으로 다시 강조한다. 이 글의 모든 코드에서 Anthropic API 키는 단 한 번도 앱에 등장하지 않는다. 키는 프록시 서버의 환경변수에만 있고, 앱은 자기 사용자 인증 토큰만 들고 다닌다. 개발 중에 편하다고 키를 앱에 넣고 테스트하다가 그대로 스토어에 올라가는 사고가 실제로 흔하다. 처음부터 프록시 구조로 짜 두면 배포 직전에 급하게 구조를 뜯을 일이 없다.
| 단계 | 할 일 | 키 위치 |
|---|---|---|
| 로컬 개발 | 구조 잡기, UI 개발 | 로컬 서버 환경변수 |
| 테스트 배포 | 프록시 서버 연결 | 서버 시크릿 관리자 |
| 프로덕션 | 인증·사용량 제한 활성화 | 서버 시크릿 관리자 |
여기까지 하면 Flutter 앱에서 Claude와 대화하는 채팅 기능의 뼈대가 완성된다. 다음 단계로는 프롬프트 캐싱으로 반복 시스템 프롬프트 비용을 줄이거나, 대화 기록을 로컬 DB에 저장하는 작업을 붙이면 된다.
※ 이 글의 API 사양, 모델명, 패키지 버전은 2026년 기준이며 변경될 수 있습니다.
댓글
댓글 쓰기