클라이언트 앱 API 통합 가이드
작성일: 2026-04-06 Last Updated: 2026-04-06 대상: Browser Extension, Tauri Desktop App, Flutter Mobile App
1. REST API 클라이언트 패턴
플랫폼별 권장 HTTP 클라이언트
| 플랫폼 | 라이브러리 | 특징 |
|---|---|---|
| Browser Ext | fetch (내장) 또는 ky | 경량, Manifest V3 호환 |
| Tauri (프론트엔드) | ky 또는 ofetch | 경량, 타입 안전 |
| Tauri (Rust) | reqwest | Rust 표준 HTTP 클라이언트 |
| Flutter | dio | 인터셉터, 취소, FormData 지원 |
| Flutter (경량) | http | 공식 패키지, 단순 사용 |
API 클라이언트 아키텍처
ApiClient (싱글턴)
|
+-- baseConfig: { baseUrl, timeout, headers }
+-- interceptors: [auth, logging, retry, cache]
+-- request(method, path, options): Promise<Response>
|
+-- 모듈별 서비스
+-- AuthApi: { login, logout, refresh }
+-- UserApi: { getProfile, updateProfile }
+-- TenantApi: { list, switch, create }
TypeScript 구현 (Browser Ext / Tauri)
// shared/api/client.ts
import ky, { type Options, type KyInstance } from 'ky';
class ApiClient {
private client: KyInstance;
constructor(config: ApiConfig) {
this.client = ky.create({
prefixUrl: config.baseUrl,
timeout: config.timeout ?? 30_000,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-Client-Version': config.appVersion,
'X-Client-Platform': config.platform, // 'browser-ext' | 'tauri' | 'flutter'
},
hooks: {
beforeRequest: [
this.authInterceptor,
this.timezoneInterceptor,
],
afterResponse: [
this.refreshTokenInterceptor,
],
beforeError: [
this.errorTransformInterceptor,
],
beforeRetry: [
this.retryInterceptor,
],
},
retry: {
limit: 3,
methods: ['get', 'put', 'delete'],
statusCodes: [408, 429, 500, 502, 503, 504],
backoffLimit: 10_000,
},
});
}
async get<T>(path: string, options?: Options): Promise<T> {
return this.client.get(path, options).json<T>();
}
async post<T>(path: string, data?: unknown, options?: Options): Promise<T> {
return this.client.post(path, { json: data, ...options }).json<T>();
}
async put<T>(path: string, data?: unknown, options?: Options): Promise<T> {
return this.client.put(path, { json: data, ...options }).json<T>();
}
async patch<T>(path: string, data?: unknown, options?: Options): Promise<T> {
return this.client.patch(path, { json: data, ...options }).json<T>();
}
async delete<T>(path: string, options?: Options): Promise<T> {
return this.client.delete(path, options).json<T>();
}
}
export const api = new ApiClient({
baseUrl: import.meta.env.VITE_API_BASE_URL,
appVersion: import.meta.env.VITE_APP_VERSION,
platform: 'tauri',
});
Flutter (Dart) 구현
// core/api/api_client.dart
import 'package:dio/dio.dart';
class ApiClient {
late final Dio _dio;
ApiClient({required ApiConfig config}) {
_dio = Dio(BaseOptions(
baseUrl: config.baseUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 30),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-Client-Version': config.appVersion,
'X-Client-Platform': 'flutter',
},
));
_dio.interceptors.addAll([
AuthInterceptor(authService: config.authService),
TimezoneInterceptor(),
RetryInterceptor(dio: _dio, retries: 3),
LogInterceptor(requestBody: true, responseBody: true),
]);
}
Future<T> get<T>(
String path, {
Map<String, dynamic>? queryParameters,
T Function(dynamic)? fromJson,
}) async {
final response = await _dio.get(path, queryParameters: queryParameters);
return fromJson != null ? fromJson(response.data) : response.data as T;
}
Future<T> post<T>(
String path, {
dynamic data,
T Function(dynamic)? fromJson,
}) async {
final response = await _dio.post(path, data: data);
return fromJson != null ? fromJson(response.data) : response.data as T;
}
// put, patch, delete 동일 패턴...
}
2. 에러 핸들링
HTTP 상태코드 매핑
| 상태코드 | 의미 | 클라이언트 처리 |
|---|---|---|
| 200-299 | 성공 | 정상 처리 |
| 400 | Bad Request | 입력값 검증 에러 표시 |
| 401 | Unauthorized | 토큰 갱신 시도 -> 실패 시 로그인 화면 |
| 403 | Forbidden | 권한 없음 안내 |
| 404 | Not Found | "리소스를 찾을 수 없습니다" |
| 409 | Conflict | 충돌 해결 UI 또는 재시도 안내 |
| 422 | Validation Error | 필드별 유효성 에러 표시 |
| 429 | Too Many Requests | Rate limit 대기 후 재시도 |
| 500 | Server Error | "일시적 오류" + 재시도 버튼 |
| 502-504 | Gateway Error | 자동 재시도 (최대 3회) |
Laravel API 에러 응답 형식
{
"message": "The given data was invalid.",
"errors": {
"email": ["이메일 형식이 올바르지 않습니다."],
"password": ["비밀번호는 8자 이상이어야 합니다."]
}
}
통합 에러 처리 패턴
// shared/api/errors.ts
// 앱 에러 타입 정의
class ApiError extends Error {
constructor(
public status: number,
public code: string,
message: string,
public errors?: Record<string, string[]>, // 필드별 에러
public retryable: boolean = false,
) {
super(message);
this.name = 'ApiError';
}
get isValidationError(): boolean {
return this.status === 422;
}
get isAuthError(): boolean {
return this.status === 401;
}
get isNetworkError(): boolean {
return this.status === 0; // 네트워크 연결 실패
}
}
// 에러 변환 인터셉터
function errorTransformInterceptor(error: HTTPError): ApiError {
const { response } = error;
if (!response) {
return new ApiError(0, 'NETWORK_ERROR', '네트워크 연결을 확인해주세요', undefined, true);
}
const body = response.body;
switch (response.status) {
case 401:
return new ApiError(401, 'UNAUTHORIZED', '인증이 만료되었습니다');
case 403:
return new ApiError(403, 'FORBIDDEN', '접근 권한이 없습니다');
case 404:
return new ApiError(404, 'NOT_FOUND', '요청한 리소스를 찾을 수 없습니다');
case 422:
return new ApiError(422, 'VALIDATION_ERROR', body.message, body.errors);
case 429:
return new ApiError(429, 'RATE_LIMITED', '요청이 너무 많습니다. 잠시 후 다시 시도해주세요', undefined, true);
default:
if (response.status >= 500) {
return new ApiError(response.status, 'SERVER_ERROR', '서버에 일시적인 문제가 발생했습니다', undefined, true);
}
return new ApiError(response.status, 'UNKNOWN_ERROR', body.message || '알 수 없는 오류가 발생했습니다');
}
}
재시도 전략
| 조건 | 재시도 | 방법 |
|---|---|---|
| 네트워크 에러 | O | 지수 백오프 (1s, 2s, 4s) |
| 408 Request Timeout | O | 즉시 재시도 |
| 429 Too Many Requests | O | Retry-After 헤더 기반 대기 |
| 500-504 서버 에러 | O | 지수 백오프 |
| 400 Bad Request | X | 사용자 입력 수정 필요 |
| 401 Unauthorized | 토큰 갱신 | 토큰 갱신 후 1회 재시도 |
| 403 Forbidden | X | 권한 문제 |
| 404 Not Found | X | 리소스 없음 |
| 422 Validation | X | 사용자 입력 수정 필요 |
지수 백오프 구현
function getRetryDelay(attempt: number, baseDelay = 1000, maxDelay = 10000): number {
const delay = Math.min(baseDelay * Math.pow(2, attempt), maxDelay);
// Jitter 추가 (thundering herd 방지)
return delay + Math.random() * 1000;
}
3. 페이지네이션 패턴
Laravel API 페이지네이션 응답
{
"data": [
{ "id": 1, "name": "항목 1" },
{ "id": 2, "name": "항목 2" }
],
"meta": {
"current_page": 1,
"from": 1,
"last_page": 10,
"per_page": 20,
"to": 20,
"total": 195
},
"links": {
"first": "https://api.example.com/items?page=1",
"last": "https://api.example.com/items?page=10",
"prev": null,
"next": "https://api.example.com/items?page=2"
}
}
클라이언트 페이지네이션 유형
| 유형 | 적합 화면 | UX | 구현 복잡도 |
|---|---|---|---|
| 번호 페이지 | 데스크톱 테이블 | 임의 페이지 접근 | 낮음 |
| 무한 스크롤 | 모바일 목록, 피드 | 자연스러운 탐색 | 중간 |
| 커서 기반 | 실시간 피드, 채팅 | 안정적 (데이터 변경 무관) | 높음 |
| Load More 버튼 | 모바일 목록 | 사용자 제어 | 낮음 |
무한 스크롤 구현 패턴
// React (Browser Ext, Tauri)
function useInfiniteList<T>(endpoint: string, perPage = 20) {
const [items, setItems] = useState<T[]>([]);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [loading, setLoading] = useState(false);
const loadMore = useCallback(async () => {
if (loading || !hasMore) return;
setLoading(true);
try {
const response = await api.get<PaginatedResponse<T>>(endpoint, {
searchParams: { page, per_page: perPage },
});
setItems(prev => [...prev, ...response.data]);
setHasMore(response.meta.current_page < response.meta.last_page);
setPage(prev => prev + 1);
} catch (error) {
// 에러 처리
} finally {
setLoading(false);
}
}, [page, loading, hasMore]);
return { items, loading, hasMore, loadMore };
}
커서 기반 페이지네이션
// API 요청
GET /api/messages?cursor=eyJpZCI6MTAwfQ&per_page=20
// 응답
{
"data": [...],
"meta": {
"next_cursor": "eyJpZCI6MTIwfQ",
"prev_cursor": "eyJpZCI6MTAwfQ",
"per_page": 20,
"has_more": true
}
}
4. 캐싱 전략
캐시 계층
요청 -> 메모리 캐시 (가장 빠름)
|
miss
v
로컬 DB 캐시 (IndexedDB / SQLite)
|
miss
v
HTTP 캐시 (Cache-Control)
|
miss
v
서버 API 요청
|
v
응답 -> 각 캐시 레이어에 저장
캐시 전략 매트릭스
| 전략 | 설명 | 적합 데이터 |
|---|---|---|
| Cache-First | 캐시 우선, 없으면 네트워크 | 잘 변하지 않는 데이터 (프로필 이미지) |
| Network-First | 네트워크 우선, 실패 시 캐시 | 자주 변하는 데이터 (알림 목록) |
| Stale-While-Revalidate | 캐시 즉시 반환 + 백그라운드 갱신 | 사용자 프로필, 설정 |
| Network-Only | 항상 네트워크 | 인증, 결제 |
| Cache-Only | 캐시만 사용 | 오프라인 모드 |
Stale-While-Revalidate 구현
// shared/api/cache.ts
interface CacheEntry<T> {
data: T;
timestamp: number;
staleTime: number; // 이 시간 이내면 "신선"
cacheTime: number; // 이 시간 이후 캐시 삭제
}
async function fetchWithSWR<T>(
key: string,
fetcher: () => Promise<T>,
options: {
staleTime?: number; // 기본 5분
cacheTime?: number; // 기본 30분
} = {}
): Promise<T> {
const { staleTime = 5 * 60_000, cacheTime = 30 * 60_000 } = options;
// 1. 캐시 확인
const cached = await getFromCache<T>(key);
if (cached) {
const age = Date.now() - cached.timestamp;
if (age < staleTime) {
// 신선: 캐시 반환
return cached.data;
}
if (age < cacheTime) {
// Stale: 캐시 반환 + 백그라운드 갱신
revalidate(key, fetcher); // 비동기 갱신
return cached.data;
}
}
// 2. 캐시 없거나 만료: 네트워크 요청
const data = await fetcher();
await setCache(key, { data, timestamp: Date.now(), staleTime, cacheTime });
return data;
}
캐시 무효화 패턴
| 이벤트 | 무효화 대상 | 방법 |
|---|---|---|
| 데이터 변경 (POST/PUT/DELETE) | 관련 목록 + 상세 캐시 | 뮤테이션 후 자동 무효화 |
| 사용자 로그아웃 | 모든 캐시 | 전체 삭제 |
| 테넌트 전환 | 모든 데이터 캐시 | 전체 삭제 (설정은 유지) |
| 앱 업데이트 | 스키마 변경된 캐시 | 버전별 캐시 키 |
| 일정 시간 경과 | TTL 만료 캐시 | 자동 만료 |
5. 오프라인 모드
오프라인 아키텍처
[온라인]
API 요청 -> 서버 -> 응답 -> 로컬 DB에 동기화
[오프라인]
요청 -> 로컬 DB 조회 (읽기)
변경 -> 오프라인 큐에 저장 (쓰기)
[온라인 복귀]
큐 -> 순서대로 서버에 전송 -> 충돌 해결 -> 로컬 DB 갱신
오프라인 큐 관리
// shared/api/offline-queue.ts
interface QueuedRequest {
id: string;
method: 'POST' | 'PUT' | 'PATCH' | 'DELETE';
path: string;
data: unknown;
timestamp: number;
retryCount: number;
maxRetries: number;
priority: 'high' | 'normal' | 'low';
}
class OfflineQueue {
private queue: QueuedRequest[] = [];
async enqueue(request: Omit<QueuedRequest, 'id' | 'timestamp' | 'retryCount'>): Promise<void> {
const entry: QueuedRequest = {
...request,
id: crypto.randomUUID(),
timestamp: Date.now(),
retryCount: 0,
};
this.queue.push(entry);
await this.persist(); // 로컬 DB에 저장
}
async processQueue(): Promise<void> {
// 우선순위 + 시간순 정렬
const sorted = this.queue.sort((a, b) => {
if (a.priority !== b.priority) {
const priorityOrder = { high: 0, normal: 1, low: 2 };
return priorityOrder[a.priority] - priorityOrder[b.priority];
}
return a.timestamp - b.timestamp;
});
for (const request of sorted) {
try {
await api[request.method.toLowerCase()](request.path, request.data);
await this.dequeue(request.id);
} catch (error) {
if (error instanceof ApiError && !error.retryable) {
// 재시도 불가능한 에러: 큐에서 제거 + 사용자 알림
await this.dequeue(request.id);
notifyError(request, error);
} else {
request.retryCount++;
if (request.retryCount >= request.maxRetries) {
await this.dequeue(request.id);
notifyError(request, error as Error);
}
}
}
}
}
}
충돌 해결 전략
| 전략 | 설명 | 적합 상황 |
|---|---|---|
| Last Write Wins | 마지막 수정이 우선 | 단일 사용자 데이터 |
| Server Wins | 서버 데이터 우선 | 관리자 데이터 |
| Client Wins | 클라이언트 데이터 우선 | 임시 저장 |
| Manual Merge | 사용자에게 선택 요청 | 협업 데이터 |
| Field-Level Merge | 필드별 최신 값 채택 | 복잡한 문서 |