모바일 네트워크 전환 시 HTTP 요청이 중복되는 이유와 해결 방법


들어가며

모바일 웹 개발을 하다 보면 서버 로그에서 이상한 현상을 발견할 때가 있습니다. 사용자가 한 번만 버튼을 클릭했는데 동일한 API 요청이 두 번, 세 번 기록되어 있는 것입니다. 특히 사용자가 지하철이나 엘리베이터에서 3G↔LTE↔Wi-Fi 간 전환을 겪을 때 이런 현상이 자주 발생합니다.

이는 버그가 아닙니다. 브라우저, OS, 라이브러리의 설계된 동작입니다. 오늘은 이 현상의 정확한 원인과 해결 방법을 공식 문서와 소스코드를 통해 알아보겠습니다.

모바일 네트워크 전환 시 HTTP 요청 중복 예시


🔍 현상 분석: 왜 요청이 중복될까?

문제 상황

// 사용자가 한 번만 클릭
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  // 중복!

원인 요약

모바일 환경에서는 다층적 재시도 메커니즘이 작동합니다:

  1. 브라우저 레벨: Chrome, Safari의 자동 재시도
  2. OS 레벨: Android OkHttp, iOS NSURLSession의 연결 복구
  3. 라이브러리 레벨: axios-retry 등의 명시적 재시도
  4. 네트워크 인프라: 프록시, 로드밸런서의 재시도

📚 공식 근거 자료

1. HTTP 표준 (RFC 9110)

§ 9.2.2 Retrying Requests에서 명시:

“사용자 에이전트는 응답을 받기 전에 이전 시도가 중단되고 요청 메서드가 멱등으로 알려진 경우 요청을 자동으로 재시도할 수 있습니다(MAY)

2. 브라우저 구현

Chrome/Chromium

  • 소스코드: net/url_request/url_request.ccRetryIfAllowed() 함수
  • 동작: ERR_NETWORK_CHANGED 또는 데이터 수신 전 연결 종료 시 멱등 메서드 재시작
// Chromium 소스코드 (simplified)
bool URLRequest::RetryIfAllowed() {
  if (error == ERR_NETWORK_CHANGED && IsIdempotentMethod(method)) {
    return true; // 재시도 허용
  }
  return false;
}

Safari/WebKit

  • Apple Technical Q&A QA1941:

    “기본 인터페이스가 변경되면 NSURLSession이 멱등 HTTP 메서드에 대해 새 경로에서 작업을 자동으로 재발행

3. 모바일 OS 레벨

Android - OkHttp

// OkHttp RetryAndFollowUpInterceptor.java (simplified)
private Request followUpRequest(Response userResponse) {
  if (connectionFailed && canRetry(request)) {
    return request; // 동일 요청 재시도
  }
  return null;
}

공식 문서: “연결이 실패하면 다른 경로로 한 번 자동 재시도

iOS - NSURLSession

WWDC20 Session 10111:

“URLSession 작업은 임시 네트워크 손실이나 인터페이스 전환 후 자동으로 재개


🚨 실제 사례

Stripe 사고 보고서 (2021-04-15)

“모바일 Safari가 셀룰러에서 Wi-Fi로 전환POST /payment_intents 호출을 재시도하여 이중 결제 발생. Idempotency-Key로 완화”

GitHub Issue 사례

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 클라이언트 오류 ❌ 재시도 안함

💡 해결 방법

1. 서버 측: Idempotency Key 구현

// 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);
});

2. 클라이언트 측: 요청 추적

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);
  }
};

3. axios-retry 설정 최적화

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
    });
  }
});

4. UI 레벨 중복 방지

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>
  );
};

🔬 디버깅 도구

1. 요청 로깅 미들웨어

// 요청 추적을 위한 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);
  }
);

2. 네트워크 상태 모니터링

// 네트워크 상태 변화 감지
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();
});

🎯 베스트 프랙티스

1. 멱등성 설계 원칙

// ✅ 좋은 예: 멱등한 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  // 낙관적 잠금
}

2. 타임아웃 전략

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);
};

3. 사용자 경험 개선

// 네트워크 상태에 따른 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 요청 중복은 예상되는 정상 동작입니다. 이를 해결하기 위해서는:

  1. 서버 측: Idempotency Key와 중복 감지 로직 구현
  2. 클라이언트 측: 요청 추적과 안전한 재시도 설정
  3. 모니터링: 중복 요청 패턴 분석과 사용자 경험 최적화

이러한 접근을 통해 안정적이고 사용자 친화적인 모바일 웹 경험을 제공할 수 있습니다.


이 글이 도움이 되셨다면 댓글로 경험을 공유해 주세요! 🚀



Written by@[namu]
모바일, 스마트폰, 금융, 재테크, 생활 정보 등