2025-08-03 00:08
모바일 웹 개발을 하다 보면 서버 로그에서 이상한 현상을 발견할 때가 있습니다. 사용자가 한 번만 버튼을 클릭했는데 동일한 API 요청이 두 번, 세 번 기록되어 있는 것입니다. 특히 사용자가 지하철이나 엘리베이터에서 3G↔LTE↔Wi-Fi 간 전환을 겪을 때 이런 현상이 자주 발생합니다.
이는 버그가 아닙니다. 브라우저, OS, 라이브러리의 설계된 동작입니다. 오늘은 이 현상의 정확한 원인과 해결 방법을 공식 문서와 소스코드를 통해 알아보겠습니다.

// 사용자가 한 번만 클릭
const handleSubmit = async () => {
try {
const response = await axios.post('/api/payment', paymentData);
// 성공 처리
} catch (error) {
// 에러 처리
}
};서버 로그:
[2025-01-03 14:32:15] POST /api/payment - 200 OK
[2025-01-03 14:32:15] POST /api/payment - 200 OK // 중복!모바일 환경에서는 다층적 재시도 메커니즘이 작동합니다:
§ 9.2.2 Retrying Requests에서 명시:
“사용자 에이전트는 응답을 받기 전에 이전 시도가 중단되고 요청 메서드가 멱등으로 알려진 경우 요청을 자동으로 재시도할 수 있습니다(MAY)”
net/url_request/url_request.cc의 RetryIfAllowed() 함수ERR_NETWORK_CHANGED 또는 데이터 수신 전 연결 종료 시 멱등 메서드 재시작// Chromium 소스코드 (simplified)
bool URLRequest::RetryIfAllowed() {
if (error == ERR_NETWORK_CHANGED && IsIdempotentMethod(method)) {
return true; // 재시도 허용
}
return false;
}“기본 인터페이스가 변경되면 NSURLSession이 멱등 HTTP 메서드에 대해 새 경로에서 작업을 자동으로 재발행”
// OkHttp RetryAndFollowUpInterceptor.java (simplified)
private Request followUpRequest(Response userResponse) {
if (connectionFailed && canRetry(request)) {
return request; // 동일 요청 재시도
}
return null;
}공식 문서: “연결이 실패하면 다른 경로로 한 번 자동 재시도”
WWDC20 Session 10111:
“URLSession 작업은 임시 네트워크 손실이나 인터페이스 전환 후 자동으로 재개”
“모바일 Safari가 셀룰러에서 Wi-Fi로 전환 후
POST /payment_intents호출을 재시도하여 이중 결제 발생.Idempotency-Key로 완화”
axios/axios-retry #178:
“Android WebView에서 네트워크가 잠시 끊어질 때 fetch가 두 번 실행됨”
graph TD
A[사용자 요청] --> B[3G 연결]
B --> C[네트워크 전환 감지]
C --> D[연결 중단 90ms]
D --> E[LTE 연결]
E --> F[브라우저 자동 재시도]
F --> G[서버에 중복 요청 도달]| 오류 코드 | 설명 | 재시도 여부 |
|---|---|---|
ERR_NETWORK_CHANGED |
네트워크 인터페이스 변경 | ✅ 자동 재시도 |
ECONNRESET |
TCP 연결 리셋 | ✅ 자동 재시도 |
HTTP 408 |
요청 타임아웃 | ✅ 조건부 재시도 |
HTTP 5xx |
서버 오류 | ✅ 라이브러리별 설정 |
HTTP 4xx |
클라이언트 오류 | ❌ 재시도 안함 |
// Express.js 예시
app.post('/api/payment', async (req, res) => {
const idempotencyKey = req.headers['idempotency-key'] ||
req.headers['x-idempotency-key'];
if (!idempotencyKey) {
return res.status(400).json({
error: 'Idempotency-Key header required'
});
}
// Redis 등에서 키 확인
const existingResult = await redis.get(`payment:${idempotencyKey}`);
if (existingResult) {
return res.json(JSON.parse(existingResult)); // 캐시된 결과 반환
}
// 실제 결제 처리
const result = await processPayment(req.body);
// 결과 캐시 (24시간)
await redis.setex(`payment:${idempotencyKey}`, 86400, JSON.stringify(result));
res.json(result);
});import { v4 as uuidv4 } from 'uuid';
// 글로벌 요청 추적기
const pendingRequests = new Set();
const apiCall = async (url, data) => {
const requestId = uuidv4();
const idempotencyKey = uuidv4();
// 중복 요청 방지
if (pendingRequests.has(JSON.stringify({ url, data }))) {
throw new Error('Request already in progress');
}
const requestKey = JSON.stringify({ url, data });
pendingRequests.add(requestKey);
try {
const response = await axios.post(url, data, {
headers: {
'X-Request-ID': requestId,
'Idempotency-Key': idempotencyKey,
},
timeout: 30000, // 30초 타임아웃
});
return response.data;
} finally {
pendingRequests.delete(requestKey);
}
};import axiosRetry from 'axios-retry';
// 안전한 재시도 설정
axiosRetry(axios, {
retries: 2, // 최대 2회 재시도
retryDelay: axiosRetry.exponentialDelay, // 지수 백오프
retryCondition: (error) => {
// GET, HEAD만 재시도 (멱등 메서드)
const method = error.config?.method?.toUpperCase();
const isSafeMethod = ['GET', 'HEAD', 'OPTIONS'].includes(method);
// 네트워크 오류이면서 안전한 메서드만 재시도
return isSafeMethod && axiosRetry.isNetworkOrIdempotentRequestError(error);
},
onRetry: (retryCount, error, requestConfig) => {
console.warn(`요청 재시도 ${retryCount}회:`, {
url: requestConfig.url,
method: requestConfig.method,
error: error.message
});
}
});import { useState } from 'react';
const PaymentButton = ({ onPayment }) => {
const [isLoading, setIsLoading] = useState(false);
const handleClick = async () => {
if (isLoading) return; // 중복 클릭 방지
setIsLoading(true);
try {
await onPayment();
} finally {
setIsLoading(false);
}
};
return (
<button
onClick={handleClick}
disabled={isLoading}
className={isLoading ? 'opacity-50 cursor-not-allowed' : ''}
>
{isLoading ? '처리 중...' : '결제하기'}
</button>
);
};// 요청 추적을 위한 axios 인터셉터
axios.interceptors.request.use((config) => {
const requestId = config.headers['X-Request-ID'] ||
Math.random().toString(36).substr(2, 9);
config.headers['X-Request-ID'] = requestId;
config.metadata = { startTime: Date.now(), requestId };
console.log(`🚀 [${requestId}] ${config.method?.toUpperCase()} ${config.url}`);
return config;
});
axios.interceptors.response.use(
(response) => {
const { requestId, startTime } = response.config.metadata || {};
const duration = Date.now() - startTime;
console.log(`✅ [${requestId}] ${response.status} (${duration}ms)`);
return response;
},
(error) => {
const { requestId, startTime } = error.config?.metadata || {};
const duration = Date.now() - startTime;
console.error(`❌ [${requestId}] ${error.message} (${duration}ms)`);
return Promise.reject(error);
}
);// 네트워크 상태 변화 감지
const NetworkMonitor = () => {
useEffect(() => {
const handleOnline = () => {
console.log('🌐 네트워크 온라인');
};
const handleOffline = () => {
console.log('📵 네트워크 오프라인');
};
const handleConnectionChange = () => {
if (navigator.connection) {
console.log('📶 연결 타입 변경:', {
effectiveType: navigator.connection.effectiveType,
type: navigator.connection.type,
downlink: navigator.connection.downlink
});
}
};
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
navigator.connection?.addEventListener('change', handleConnectionChange);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
navigator.connection?.removeEventListener('change', handleConnectionChange);
};
}, []);
return null;
};// 중복 요청 탐지 메트릭
const duplicateRequestCounter = new Map();
app.use((req, res, next) => {
const key = `${req.method}:${req.path}:${req.ip}`;
const now = Date.now();
if (duplicateRequestCounter.has(key)) {
const lastRequest = duplicateRequestCounter.get(key);
if (now - lastRequest < 1000) { // 1초 이내 중복
console.warn('🔄 중복 요청 감지:', {
method: req.method,
path: req.path,
ip: req.ip,
timeDiff: now - lastRequest
});
}
}
duplicateRequestCounter.set(key, now);
next();
});// ✅ 좋은 예: 멱등한 API 설계
PUT /api/users/123 {
"name": "김개발",
"email": "kim@dev.com"
}
// ❌ 나쁜 예: 비멱등한 API
POST /api/users/123/increment-score {
"points": 10
}
// ✅ 개선: 절대값으로 설계
PUT /api/users/123/score {
"score": 150,
"version": 5 // 낙관적 잠금
}const timeoutConfig = {
// 빠른 응답이 필요한 API
realtime: { timeout: 5000, retries: 1 },
// 일반적인 API
standard: { timeout: 15000, retries: 2 },
// 무거운 작업 API
heavy: { timeout: 60000, retries: 0 }
};
const apiCall = (endpoint, data, type = 'standard') => {
const config = timeoutConfig[type];
return axios.post(endpoint, data, config);
};// 네트워크 상태에 따른 UX 조정
const useNetworkAwareUX = () => {
const [connectionType, setConnectionType] = useState('4g');
useEffect(() => {
const connection = navigator.connection;
if (connection) {
setConnectionType(connection.effectiveType);
const updateConnection = () => {
setConnectionType(connection.effectiveType);
};
connection.addEventListener('change', updateConnection);
return () => connection.removeEventListener('change', updateConnection);
}
}, []);
// 연결 상태에 따른 설정 조정
const getOptimalConfig = () => {
switch (connectionType) {
case 'slow-2g':
case '2g':
return { timeout: 30000, retries: 3, showProgress: true };
case '3g':
return { timeout: 20000, retries: 2, showProgress: true };
default:
return { timeout: 10000, retries: 1, showProgress: false };
}
};
return getOptimalConfig();
};모바일 네트워크 전환 시 HTTP 요청 중복은 예상되는 정상 동작입니다. 이를 해결하기 위해서는:
이러한 접근을 통해 안정적이고 사용자 친화적인 모바일 웹 경험을 제공할 수 있습니다.
이 글이 도움이 되셨다면 댓글로 경험을 공유해 주세요! 🚀