소셜 로그인 토큰 갱신 무한 루프와 인앱 파일 미리보기 개선
목차
네 가지를 하나의 PR에 묶어서 올렸다가 리뷰어한테 욕먹을 뻔한 날이다. 미리보기 모달, 뒤로가기 처리, 커스텀 북마크, 소셜 로그인 토큰 갱신 — 각각은 티켓 하나짜리 소소한 작업이었는데, 막상 합쳐 놓으니 앱 진입점부터 네이티브 매니페스트까지 건드려 있었다.
작업 영역 정리부터:
| 항목 | 핵심 변경 | 영향 범위 |
|---|---|---|
| 미리보기 모달 | 외부 앱 → 인앱 뷰어 | 네이티브 브릿지, 매니페스트 |
| 뒤로가기 | 스택 직접 관리 | 라우터 계층 |
| 커스텀 북마크 | 핀 기능 + 로컬 저장 | 로컬 스토리지 |
| 소셜 로그인 | 무한 갱신 루프 차단 | 인증 인터셉터 |
인앱 파일 미리보기 - 매니페스트 건드리면 주의
원래 구조는 파일을 다운로드하고 OS 기본 뷰어에 넘기는 방식이었다. 짧게 보면 구현이 쉽고 유지보수 부담도 없다. 근데 사용자가 파일 뷰어 앱을 떠났다가 다시 돌아오는 사이에 세션이 끊기는 케이스가 자꾸 보고됐다. 외부 앱 이동이 잦을수록 백그라운드 프로세스 정리에 걸릴 확률이 높아지니까, 구조적으로 피하기 어려운 문제였다.
인앱 뷰어로 바꾸면서 네이티브 브릿지를 새로 팠다. mime 타입별로 핸들러를 분기하고, 임시 캐시 디렉토리와 정리 루틴도 따로 뒀다.
// Android - FileProvider 등록 없이 외부 URI 넘기면 SecurityException
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
tempFile
)
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
여기서 FileProvider 등록을 빠뜨리면 SecurityException으로 앱이 그냥 죽는다. 안드로이드 7.0(Nougat) 이후 file:// URI를 직접 넘기는 게 막혀 있는데, 이걸 놓쳐서 두 시간을 날렸다. 매니페스트에 provider 선언과 res/xml/file_paths.xml 작성이 세트다. 둘 중 하나만 해도 안 된다.
mime별 분기는 대략 이렇게 생겼다:
- PDF → 인앱 PDF 렌더러
- 이미지 → 풀스크린 갤러리 뷰
- 오피스 → WebView 프리뷰 (Google Docs Viewer fallback)
- 기타 → 다운로드 후 공유 시트
오피스 포맷은 완전한 인앱 렌더링이 현실적으로 어렵다. WebView에 Google Docs 뷰어 URL을 태우는 방식이 지금 수준에서 가장 합리적인 타협점이었다. 나중에 네이티브 렌더러를 붙이더라도 인터페이스만 교체하면 되는 구조로 만들어 뒀으니 그게 맞는 방향이라고 생각함.
뒤로가기 스택 - 라이브러리 믿으면 안 되는 케이스
모달이 단일로 떠 있을 때 백버튼 처리는 라이브러리 기본 핸들러가 잘 잡아준다. 문제는 모달 위에 또 모달이 올라가는 경우다. 중첩 깊이가 2단만 넘어도 우선순위가 꼬이기 시작했다.
| 상태 | 백버튼 동작 |
|---|---|
| 모달 1개 | 모달 닫기 |
| 모달 중첩 | 최상단 모달부터 순서대로 닫기 |
| 모달 없음 | 라우터에 위임 |
결국 진입 순서대로 스택을 직접 들고 있다가 pop하는 방식으로 직접 구현했다. 패턴 자체는 단순하다 — 모달이 마운트될 때 스택에 push, 언마운트되거나 닫힐 때 pop, 백버튼 이벤트가 오면 스택 꼭대기 핸들러 먼저 실행.
라이브러리 핸들러를 완전히 버리지는 않았다. 스택이 비어 있을 때는 기존 라우터 동작으로 위임하는 폴백을 남겨뒀다. 커스텀 스택이 라우팅 책임까지 가져가기 시작하면 나중에 유지보수가 훨씬 힘들어진다. 범위를 '모달 레이어 안에서만' 으로 제한한 게 핵심이었다.
소셜 로그인 무한 갱신 루프
이번 작업 중 제일 부끄러운 버그였다.
흐름 자체는 흔한 OAuth 패턴이다. 액세스 토큰 만료 → 401 수신 → 리프레시 토큰으로 갱신 → 원래 요청 재시도. 근데 서버 측 토큰 캐시에 약간의 지연이 있어서, 갱신 직후 재요청이 나갈 때 서버가 아직 이전 토큰 상태를 참조해 다시 401을 내려보내는 케이스가 있었다. 결과적으로 클라이언트는 갱신 → 401 → 갱신 → 401을 무한히 반복했고, 로그가 폭주하면서 인터셉터 전체가 먹통이 됐다.
let refreshAttemptCount = 0;
axiosInstance.interceptors.response.use(
response => response,
async error => {
if (error.response?.status === 401 && refreshAttemptCount === 0) {
refreshAttemptCount++;
try {
await refreshToken();
return axiosInstance(error.config);
} catch {
forceLogout();
} finally {
refreshAttemptCount = 0;
}
}
return Promise.reject(error);
}
);
핵심은 두 가지다.
- 갱신 시도 횟수를 카운터로 제한 - 한 번 갱신 후 또 401이 오면 더 이상 재시도 없이 강제 로그아웃
finally블록에서 카운터 초기화 - 성공이든 실패든 다음 정상 요청에서 카운터가 0으로 돌아와야 함
finally 초기화를 빠뜨리면 첫 번째 갱신 이후 모든 요청이 갱신 없이 곧장 로그아웃 처리된다. 타이밍이 미묘해서 로컬 테스트 환경에서는 재현하기 까다롭다.
원인이 서버 캐시 지연이었다는 건 디버깅 도중에 파악했다. 서버 쪽 문제라고 클라가 무한히 두드리는 건 말이 안 된다. 방어 코드를 진작 짰어야 했는데 빠져 있었던 거고, 이게 서비스에서 먼저 터졌다는 게 부끄럽다.
무한 루프류 버그에는 공통 처리 순서가 있다 — 재시도 카운터 먼저 박고 디버깅 시작. 로그 폭주를 막는 게 먼저고 원인 파악은 그다음이다. 루프가 돌고 있으면 로그가 너무 빠르게 쌓여서 정작 봐야 할 지점을 찾기가 어려워진다.
네 개를 묶지 말았어야 했던 이유
각 작업은 독립적인데 파일이 곳곳에 겹쳐서 diff가 지저분해졌고, 리뷰어 입장에서 맥락 전환이 너무 잦았다. 다음부터 기준은 이렇게 잡을 것:
- 네이티브 매니페스트 건드리는 변경은 무조건 단독 PR
- 인증 흐름 변경도 단독 PR - 롤백이 빠를수록 좋다
- UX 개선 묶음은 같은 화면이나 도메인 안에서만
북마크 서버 동기화는 다음 스프린트로 미뤘다. 로컬 저장만 먼저 붙이고 실제로 쓰는지 보는 게 낫다는 판단이었다. 아무도 안 쓰면 동기화 개발은 그냥 낭비다. 최대 20개 제한도 같은 맥락 — 일단 제한 걸고 불만 들어오면 그때 늘리는 게 오버엔지니어링보다 합리적이다. 사용자가 화내면 그때 늘릴 것.
댓글 0
첫 댓글 달아줘.