1. 개요
모바일 웹 기술이 무르익으면서 단독 실행 및 오프라인 동작을 돕는 **PWA(Progressive Web App)**의 형태로 하이브리드 앱을 구축하는 사례가 크게 늘었습니다. DoEatFit (v2.0) 역시 PWA로 빌드하여 모바일 환경에 대응하고 있었습니다. 하지만 iOS(아이폰) 사용자들이 앱을 필드에서 사용하기 시작하자, 운동을 열심히 기록하다가 잠시 화면을 잠근(Lock) 뒤 돌아오면 “서버와의 연결이 원활하지 않습니다”라는 네트워크 에러가 홍수처럼 쏟아지거나 예기치 않게 강제 로그아웃되어 작성 중이던 데이터가 증발하는 치명적인 사용성 장애를 마주하게 되었습니다. 오직 아이폰(iOS Safari PWA) 환경에서만 간헐적이고 고약하게 터지는 미스터리를 웹 표준 및 통신 계층의 복원력 설계로 해결한 과정을 꼼꼼히 정리합니다.
2. 핵심 내용
2-1. 🚨 주요 문제 현상 및 근본 원인 분석
- 현상:
- 포그라운드 복귀 시 화면 초토화: 앱을 백그라운드로 보냈다가 돌아오면 순식간에 네트워크 에러 토스트가 화면 가득 출력되는 현상.
- 예기치 못한 강제 로그아웃: 다른 앱을 다녀왔을 뿐인데 앱이 스스로 세션을 파기하고 초기 로그인 화면으로 세션을 튕겨내는 오동작이 빈번히 재발함.
- 원인:
- iOS WebKit의 혹독한 백그라운드 스레드 서스펜드: 애플은 배터리 소모와 시스템 RAM 리소스를 보전하기 위해 PWA나 사파리 웹 탭이 백그라운드로 내려가는 순간 모든 네트워크 소켓 연결을 차단하고 JavaScript 메인 스레드 실행을 동결(Freeze)시킵니다. 유저가 복귀(Resume)하는 찰나 React Query는 최신 상태 갱신을 위해 API 요청을 난사하지만, iOS 내부의 물리적 네트워크 스택 복구에 0.5초에서 2초가량의 지연 레이턴시가 발생하고 있어 네트워크가 수립되지 않은 상태의 갱신 요청이 전부 타임아웃과 에러를 뿜었던 것입니다.
reissueTokenAction2-hop 구조와 오인 로그아웃: AccessToken이 만료된 시점에 백그라운드에 진입했다가 복귀하면 401 Unauthorized를 만나고 클라이언트는 토큰 재발급(reissueTokenAction)을 수행합니다. DoEatFit은 보안 향상을 위해 Next.js 서버 액션을 거치는 2-hop 구조를 취하고 있어, 이 2-hop(Next.js 서버 ➡ Spring Boot 백엔드) 구간에서 네트워크 타임아웃이 나면 Next.js 서버는{success: false}응답을 정상 JSON으로 뱉게 되는데, 프론트엔드의 레거시 인터셉터 catch 블록이 이를 단순 일시적 네트워크 장애가 아닌 **“토큰이 불법/유출되어 서버에 의해 강제 만료된 진짜 보안 예외”**로 무겁게 오판하여 로컬 세션을 통째로 파기해 버린 것이었습니다.
2-2. 💡 우아한 해결책 및 구현 코드
- 해결 방안: 지연 타이머를 거는 비결정적 우회책(안티패턴)을 기각하고, 실패했을 때 실패 트래픽만 지수적 백오프로 재시도하는
axios-retry및 포그라운드 복귀 직후 5초간 로그아웃을 미뤄두는 **‘로그아웃 유예(Logout Deferral) 아키텍처’**를 구축했습니다. - 코드 명세:
// axios-instance.ts
import axios from 'axios';
import axiosRetry from 'axios-retry';
export const axiosInstance = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL,
timeout: 10000,
});
axiosRetry(axiosInstance, {
retries: 3, // 최대 3회 재시도
retryDelay: axiosRetry.exponentialDelay, // 1초 -> 2초 -> 4초 지수 백오프 + 지터
retryCondition: (error) => {
// 네트워크 단절 또는 타임아웃 에러 시에만 영리하게 작동
return (
axiosRetry.isNetworkError(error) ||
axiosRetry.isRetryableError(error) ||
error.code === 'ECONNABORTED'
);
},
});- 로그아웃 유예 및 복구 윈도우 스키마:
// use-axios-interceptor.ts
const onErrorResponse = async (error: AxiosError<ApiResponse<never>>) => {
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
const isHidden = typeof document !== 'undefined' && document.visibilityState === 'hidden';
if (error.response?.status === 401 && !originalRequest._retry && typeof window !== 'undefined') {
const token = localStorage.getItem('AccessToken');
if (!token) return Promise.reject(error);
originalRequest._retry = true;
isRefreshing = true;
return new Promise((resolve, reject) => {
reissueTokenAction().then((result) => {
resolve(axiosInstance(originalRequest));
}).catch(err => {
// 💡 복귀 직후(5초 이내) 혹은 화면 비가시 상태 시, 일시적 네트워크 단절로 판단해 로그아웃을 유예
if (isHidden || isInRecoveryWindow() || err.message === 'Network Error' || err.code === 'ECONNABORTED') {
reject(new Error('AUTH_RETRY_LATER'));
} else {
// 명시적으로 RT 유효기간이 완전히 끝났음이 서버를 통해 확정되었을 때에만 안전하게 세션 해제
clearClientAuthState();
toast.error("세션이 만료되었습니다. 다시 로그인해주세요.", { id: 'auth-expired' });
reject(new Error('AUTH_EXPIRED'));
}
}).finally(() => {
isRefreshing = false;
});
});
}
handleCommonErrors(error);
return Promise.reject(error);
};2-3. ✅ 결과 검증 및 모던 오픈소스 라이브러리 검토
- 최종 검증:
- 백그라운드 복귀 보장: 일지를 기록하다가 화면을 끄고 10분 이상 운동 세트를 마친 뒤 돌아와 앱을 실행해도 유저 세션이 완벽하게 고정되며 1초 이내에 조용히 최 최신 데이터 동기화에 복구 성공함을 입증했습니다.
- IndexedDB 로컬 캐시 결합:
PersistQueryClientProvider와 초경량 IndexedDB 래퍼인idb-keyval을 도입하여 브라우저 강제 메모리 회수로 PWA 앱이 완전 리부팅되는 척박한 상태에서도 데이터 유실 없이 직전 스크롤 위치를 고스란히 복원했습니다.
- 추천 오픈소스 라이브러리 검토:
- axios-retry:
- 평가: 일시적인 모바일 네트워크 불통 및 드라이버 레이턴시 환경에서, 무작정 요청을 대기시키지 않고 오류 발생 시 지수 백오프 전략으로 즉각 복원력을 불어넣는 최고의 통신 안정화 도구로 추천합니다.
- idb-keyval:
- 평가: localStorage의 5MB 용량 한계를 극복하고 대용량 프론트엔드 비동기 캐시 데이터를 브라우저 내 IndexedDB에 빠르고 비동기적으로 영속화하여, 모바일 디바이스 앱의 리부팅 뷰포트 복구 성능을 향상시키는 핵심 유틸리티입니다.
- axios-retry: