본문으로 건너뛰기

클라이언트 앱 API 통합 가이드

작성일: 2026-04-06 Last Updated: 2026-04-06 대상: Browser Extension, Tauri Desktop App, Flutter Mobile App


1. REST API 클라이언트 패턴

플랫폼별 권장 HTTP 클라이언트

플랫폼라이브러리특징
Browser Extfetch (내장) 또는 ky경량, Manifest V3 호환
Tauri (프론트엔드)ky 또는 ofetch경량, 타입 안전
Tauri (Rust)reqwestRust 표준 HTTP 클라이언트
Flutterdio인터셉터, 취소, 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성공정상 처리
400Bad Request입력값 검증 에러 표시
401Unauthorized토큰 갱신 시도 -> 실패 시 로그인 화면
403Forbidden권한 없음 안내
404Not Found"리소스를 찾을 수 없습니다"
409Conflict충돌 해결 UI 또는 재시도 안내
422Validation Error필드별 유효성 에러 표시
429Too Many RequestsRate limit 대기 후 재시도
500Server Error"일시적 오류" + 재시도 버튼
502-504Gateway 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 TimeoutO즉시 재시도
429 Too Many RequestsORetry-After 헤더 기반 대기
500-504 서버 에러O지수 백오프
400 Bad RequestX사용자 입력 수정 필요
401 Unauthorized토큰 갱신토큰 갱신 후 1회 재시도
403 ForbiddenX권한 문제
404 Not FoundX리소스 없음
422 ValidationX사용자 입력 수정 필요

지수 백오프 구현

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필드별 최신 값 채택복잡한 문서

네트워크 상태 감지

// TypeScript (Browser Ext, Tauri)
function createNetworkMonitor(callbacks: {
onOnline: () => void;
onOffline: () => void;
}) {
window.addEventListener('online', callbacks.onOnline);
window.addEventListener('offline', callbacks.onOffline);

// 초기 상태
if (navigator.onLine) {
callbacks.onOnline();
} else {
callbacks.onOffline();
}

// 실제 연결 확인 (navigator.onLine은 신뢰도 낮음)
async function checkRealConnection(): Promise<boolean> {
try {
await fetch('/api/health', {
method: 'HEAD',
cache: 'no-store',
});
return true;
} catch {
return false;
}
}

return { checkRealConnection };
}
// Flutter
import 'package:connectivity_plus/connectivity_plus.dart';

class NetworkMonitor {
final Connectivity _connectivity = Connectivity();

Stream<bool> get onConnectivityChanged {
return _connectivity.onConnectivityChanged.asyncMap((results) async {
if (results.contains(ConnectivityResult.none)) return false;
// 실제 연결 확인
try {
final response = await dio.head('/api/health');
return response.statusCode == 200;
} catch (_) {
return false;
}
});
}
}

6. 실시간 통신

WebSocket vs SSE 비교

특성WebSocketSSE (Server-Sent Events)
방향양방향서버 -> 클라이언트 (단방향)
프로토콜ws:// / wss://HTTP/HTTPS
재연결수동 구현브라우저 자동 재연결
바이너리지원텍스트만
프록시 호환가끔 문제호환성 좋음
적합 사례채팅, 게임, 실시간 협업알림, 피드 업데이트, 진행률

권장

용도프로토콜이유
알림SSE단방향으로 충분, 재연결 자동
실시간 데이터 업데이트SSE단방향, HTTP 호환
채팅/메시징WebSocket양방향 필요
실시간 협업 편집WebSocket양방향 + 저지연

WebSocket 클라이언트 패턴

// shared/api/websocket.ts
class WsClient {
private ws: WebSocket | null = null;
private reconnectAttempts = 0;
private maxReconnectAttempts = 10;
private listeners = new Map<string, Set<(data: unknown) => void>>();

connect(url: string, token: string): void {
this.ws = new WebSocket(`${url}?token=${token}`);

this.ws.onopen = () => {
this.reconnectAttempts = 0;
this.emit('connected', null);
};

this.ws.onmessage = (event) => {
const message = JSON.parse(event.data);
this.emit(message.event, message.data);
};

this.ws.onclose = (event) => {
if (!event.wasClean) {
this.reconnect(url, token);
}
};

this.ws.onerror = () => {
// onclose가 이어서 호출됨
};
}

private reconnect(url: string, token: string): void {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
this.emit('reconnect_failed', null);
return;
}

const delay = getRetryDelay(this.reconnectAttempts);
this.reconnectAttempts++;

setTimeout(() => this.connect(url, token), delay);
}

on(event: string, callback: (data: unknown) => void): () => void {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
}
this.listeners.get(event)!.add(callback);

// unsubscribe 함수 반환
return () => this.listeners.get(event)?.delete(callback);
}

send(event: string, data: unknown): void {
this.ws?.send(JSON.stringify({ event, data }));
}

disconnect(): void {
this.ws?.close(1000, 'Client disconnect');
this.ws = null;
}

private emit(event: string, data: unknown): void {
this.listeners.get(event)?.forEach(cb => cb(data));
}
}

Laravel Echo 연동 (Broadcasting)

// Laravel Echo + Pusher/Soketi 연동
import Echo from 'laravel-echo';
import Pusher from 'pusher-js';

const echo = new Echo({
broadcaster: 'pusher',
key: import.meta.env.VITE_PUSHER_KEY,
wsHost: import.meta.env.VITE_WS_HOST,
wsPort: import.meta.env.VITE_WS_PORT,
forceTLS: true,
disableStats: true,
authorizer: (channel: { name: string }) => ({
authorize: (socketId: string, callback: Function) => {
api.post('/broadcasting/auth', {
socket_id: socketId,
channel_name: channel.name,
}).then(response => callback(null, response))
.catch(error => callback(error, null));
},
}),
});

// 사용
echo.private(`tenant.${tenantId}`)
.listen('ItemCreated', (event) => {
// 실시간 업데이트 처리
});

7. API 버전 관리

버전 관리 전략

전략형태장점단점
URL Path (권장)/api/v1/users명확, 라우팅 쉬움URL 변경
HeaderAccept: application/vnd.api+json;v=1URL 깔끔디버깅 어려움
Query/api/users?version=1간편캐시 키 복잡

클라이언트 측 버전 관리

// shared/api/config.ts
const API_VERSION = 'v1';
const API_BASE_URL = `${import.meta.env.VITE_API_BASE_URL}/api/${API_VERSION}`;

// 버전 협상 (서버가 지원 버전 알려줌)
interface ApiVersionInfo {
current: string; // "v1"
minimum: string; // "v1" (최소 지원 버전)
deprecated: string[]; // ["v0.9"]
sunset_date?: string; // "2027-01-01"
}

강제 업데이트 패턴

// 서버 응답 헤더 확인
api.interceptors.response.use((response) => {
const minVersion = response.headers.get('X-Min-Client-Version');

if (minVersion && isVersionLessThan(APP_VERSION, minVersion)) {
// 강제 업데이트 필요
showForceUpdateDialog({
currentVersion: APP_VERSION,
requiredVersion: minVersion,
updateUrl: getUpdateUrl(),
});
}

return response;
});

8. Rate Limiting 대응

서버 응답 헤더 (Laravel)

X-RateLimit-Limit: 60           # 분당 최대 요청
X-RateLimit-Remaining: 45 # 남은 요청 수
Retry-After: 30 # 429 시 대기 시간 (초)

클라이언트 대응 전략

상황대응
Remaining > 10정상 요청
Remaining &lt; 10요청 간격 늘리기 (쓰로틀링)
Remaining == 0요청 큐잉 + 타이머
429 응답Retry-After 헤더만큼 대기 후 재시도

Rate Limiter 구현

// shared/api/rate-limiter.ts
class RateLimiter {
private remaining: number = Infinity;
private resetTime: number = 0;
private queue: Array<{
execute: () => Promise<unknown>;
resolve: (value: unknown) => void;
reject: (error: Error) => void;
}> = [];

updateFromHeaders(headers: Headers): void {
const remaining = headers.get('X-RateLimit-Remaining');
const retryAfter = headers.get('Retry-After');

if (remaining !== null) {
this.remaining = parseInt(remaining, 10);
}
if (retryAfter !== null) {
this.resetTime = Date.now() + parseInt(retryAfter, 10) * 1000;
}
}

async throttle<T>(request: () => Promise<T>): Promise<T> {
if (this.remaining <= 0 && Date.now() < this.resetTime) {
const waitTime = this.resetTime - Date.now();
await new Promise(resolve => setTimeout(resolve, waitTime));
}

const result = await request();
this.remaining--;
return result;
}
}

요청 최적화 패턴

패턴설명구현
Debounce연속 입력 시 마지막만 요청검색 입력 (300ms)
Throttle일정 간격으로 제한스크롤 이벤트 (100ms)
Batch여러 요청을 하나로 묶기GraphQL 배칭, 대량 업데이트
Dedup동일 요청 중복 제거같은 API 동시 호출 방지
// 요청 중복 제거
const pendingRequests = new Map<string, Promise<unknown>>();

async function dedupedFetch<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
if (pendingRequests.has(key)) {
return pendingRequests.get(key) as Promise<T>;
}

const promise = fetcher().finally(() => pendingRequests.delete(key));
pendingRequests.set(key, promise);
return promise;
}

9. 플랫폼별 API 통신 특이사항

Browser Extension

항목주의사항
Service Workerfetch 사용 (XMLHttpRequest 불가)
CORShost_permissions에 API 도메인 등록 필요
백그라운드 제한Service Worker 30초 제한 (Manifest V3)
쿠키chrome.cookies API 또는 credentials: include

Tauri

항목주의사항
IPC 통신프론트엔드 ↔ Rust 간 invoke() / emit()
HTTP 프록시Rust 측에서 API 호출 가능 (CORS 우회)
Capabilitieshttp:default에 허용 URL 명시
파일 업로드Rust 측 reqwest로 스트리밍 업로드 권장

Flutter

항목주의사항
백그라운드 통신workmanager 패키지 (Android), BackgroundTask (iOS)
대용량 다운로드flutter_downloader 패키지
인증서 고정SecurityContext 또는 ssl_pinning_plugin
플랫폼 채널네이티브 HTTP 필요 시 MethodChannel 사용

10. 구현 체크리스트

기본 설정

  • API 클라이언트 싱글턴 생성
  • Base URL 환경 변수화
  • 인증 인터셉터 (Bearer 토큰)
  • 타임존 헤더 인터셉터
  • 에러 변환 인터셉터
  • 요청/응답 로깅 (개발 환경)

에러 처리

  • HTTP 상태코드별 에러 매핑
  • 422 Validation 에러 필드별 표시
  • 401 자동 토큰 갱신 + 재시도
  • 네트워크 에러 사용자 안내
  • 전역 에러 핸들러 (Toast/Snackbar)

성능 최적화

  • 캐싱 전략 적용 (SWR)
  • 요청 중복 제거 (dedup)
  • 페이지네이션 구현
  • 이미지/파일 로딩 최적화
  • Rate Limiting 대응

오프라인/실시간

  • 네트워크 상태 모니터링
  • 오프라인 큐 (선택)
  • WebSocket/SSE 연결 (선택)
  • 자동 재연결 로직
  • 충돌 해결 전략 정의