클라이언트 앱 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 | 필드별 최신 값 채택 | 복잡한 문서 |
네트워크 상태 감지
// 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 비교
| 특성 | WebSocket | SSE (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 변경 |
| Header | Accept: application/vnd.api+json;v=1 | URL 깔끔 | 디버깅 어려움 |
| 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 < 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 Worker | fetch 사용 (XMLHttpRequest 불가) |
| CORS | host_permissions에 API 도메인 등록 필요 |
| 백그라운드 제한 | Service Worker 30초 제한 (Manifest V3) |
| 쿠키 | chrome.cookies API 또는 credentials: include |
Tauri
| 항목 | 주의사항 |
|---|---|
| IPC 통신 | 프론트엔드 ↔ Rust 간 invoke() / emit() |
| HTTP 프록시 | Rust 측에서 API 호출 가능 (CORS 우회) |
| Capabilities | http: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 연결 (선택)
- 자동 재연결 로직
- 충돌 해결 전략 정의