Skip to content

fix(core): 재발급 클라이언트에 전용 Dispatcher 를 줘 콜드 스타트의 동시 401 교착을 없앤다 (#2160) - #2162

Merged
OnlyReadOneBook merged 2 commits into
developfrom
fix/2160-reissue-dispatcher-deadlock
Sep 26, 2026
Merged

OnlyReadOneBook merged 2 commits into
developfrom
fix/2160-reissue-dispatcher-deadlock

Conversation

@OnlyReadOneBook

@OnlyReadOneBook OnlyReadOneBook commented Sep 23, 2026 •

Copy link
Copy Markdown
Collaborator

먼저 볼 파일: NetworkModule.kt(재발급 클라이언트의 전용 Dispatcher 와 KDoc), ConcurrentUnauthorizedReissueTest.kt(운영 조립 그대로의 동시 401 재현), NetworkModuleTest.kt(풀 공유·디스패처 분리 단언)입니다.

📌𝘐𝘴𝘴𝘶𝘦𝘴

Closes #2160

📎𝘞𝘰𝘳𝘬 𝘋𝘦𝘴𝘤𝘳𝘪𝘱𝘵𝘪𝘰𝘯

액세스 토큰이 만료된 채 콜드 스타트하면 인증 요청 5건이 응답도 실패도 없이 멈추고 /auth/reissue 는 서버로 나가지 않았습니다. 재발급 클라이언트가 base 의 Dispatcher 를 공유해서입니다. 같은 호스트의 401 5건이 호스트당 실행 칸 5개를 쥔 채 TokenReissuer 락에서 재발급을 기다리고, 재발급 호출은 칸이 비기를 기다리며 대기열에 머뭅니다. 대기열의 호출에는 callTimeout 도 걸리지 않습니다. 원인 분석과 스레드 덤프는 #2160 에 있습니다.

  • provideRefreshOkHttpClient 에 .dispatcher(Dispatcher()) 한 줄을 더했습니다. 커넥션 풀은 계속 base 와 공유합니다.
  • base 클라이언트 KDoc 의 "파생은 Dispatcher 를 공유한다" 에 이 예외를 적고, 재발급 클라이언트에 왜 따로 두는지 KDoc 으로 남겼습니다. 옛 줄 주석 두 줄(토큰 미부착 이유)은 같은 KDoc 첫 문단으로 옮겼습니다.
  • NetworkModuleTest 에 재발급 클라이언트가 풀은 공유하고 디스패처는 따로 쓰는지 단언을 더했습니다.
  • 재현 테스트 ConcurrentUnauthorizedReissueTest 를 싣습니다. NetworkModule·ServiceModule 조립과 TokenReissuer·TokenAuthenticator·AuthInterceptor 를 그대로 쓰고 기본 주소만 로컬 소켓 서버로 바꿉니다. 이 PR 에서 바뀐 곳은 정리 단계뿐으로, 따로 생긴 재발급 디스패처도 닫습니다.

이 방식을 고른 이유

  • 호스트당 상한(maxRequestsPerHost)을 올리면 교착이 나는 동시 요청 수만 뒤로 밀립니다.
  • 재발급을 동기 execute() 로 바꾸면 디스패처 상한을 안 받지만 TokenApiService.reissue 의 suspend 계약과 AuthRepository.rotateToken 이 같이 바뀝니다. 전용 디스패처는 같은 효과를 NetworkModule 한 곳에서 냅니다.
  • base 의 실행기를 넘겨 받는 Dispatcher(baseClient.dispatcher.executorService) 로 칸 계산만 가를 수도 있습니다. 그러면 두 클라이언트가 스레드를 나눠 쓰는데, 서로 막지 않는다는 성질이 실행기가 무제한 캐시 풀이라는 전제에 기댑니다. 새 Dispatcher() 는 그 전제가 필요 없습니다. 재발급은 락 안에서 한 번에 한 건이라 스레드가 더 드는 것은 재발급 뒤 60초 동안 남는 유휴 스레드 하나입니다.
  • fix(core): 재발급 실패가 single-flight 밖에서 각자 처리됨 — 400 오분류 시 무효 refresh 가 세션에 남는다 #1126·feat(core-network): expiresIn 활용 — 액세스 토큰 만료 전 선제 reissue #408 이 만든 단일 락 구조는 그대로 둡니다. 락이 기다리는 호출만 다른 실행 칸으로 뺐습니다.

🧪 CI Test Plan

{
  "androidTest": {
    "mode": "none",
    "reason": "core/network 의 OkHttp 클라이언트 조립(NetworkModule)과 JVM 테스트만 바뀐다. 화면·내비게이션·매니페스트·계측 테스트 파일 변경이 없고, 재발급 교착은 로컬 소켓 서버를 쓰는 JVM 재현 테스트(ConcurrentUnauthorizedReissueTest)와 에뮬레이터 콜드 스타트로 확인했다.",
    "tests": []
  }
}

✅ 검증

로컬 (JVM)

항목 결과
수정 전(c49167fba) 재현 테스트 4건 통과(0.43초), 5건 실패(5.03초): 요청 5건 중 0건만 5초 안에 끝남. 재발급 서버 도달 0회, 디스패처 실행 5건·대기 1건, maxRequestsPerHost=5
수정 뒤 :core:network:testDebugUnitTest --rerun-tasks 96건 중 95건 통과, 1건 건너뜀(Docker 가 있어야 도는 기존 ApiWireContractSmokeTest 한 케이스), 실패 0. 재현 테스트 4건 0.40초·5건 0.005초
변이: .dispatcher(Dispatcher()) 한 줄만 빼고 di 패키지 테스트 11건 중 2건 실패: 재현 테스트 5건 케이스, NetworkModuleTest 의 디스패처 분리 단언
:core:network:ktlintCheck --rerun-tasks 통과
:konsist:test --rerun-tasks 54건 통과
:app:compileDebugUnitTestKotlin · :app:compileDebugAndroidTestKotlin · :app:assembleDebug 통과

에뮬레이터 콜드 스타트

2026-09-23 20:44~20:52 KST, QA 에뮬레이터(Android 16, 1080x2400), dev 서버, QA 계정 세션입니다. 첫 두 회차의 access 는 19:43 에 발급돼 20:43 에 만료된 실제 토큰이고 refresh 는 유효했습니다. 회차마다 앱 정지, logcat 비움, 콜드 스타트, 45초 관찰(callTimeout 30초가 지난 뒤) 순서로 돌렸고, OkHttp 로그는 요청·응답 첫 줄만 셌습니다. 토큰 값은 보지 않고 DataStore 의 access·refresh 가 바뀌었는지와 만료 시각만 확인했습니다.

빌드 결과
수정 전 (.dispatcher(Dispatcher()) 한 줄만 뺀 빌드), 만료 access 인증 요청 5건(users/me, users/receivers, daily-questions/today, users/push-tokens, mind-record)이 45초 동안 응답 0건. /auth/reissue 요청 0회. 인증이 없는 app/version 만 200. 스레드 덤프에서 요청 스레드 5개 중 1개가 TokenReissuer.kt:91 의 runBlocking 에서 락을 쥐고, 4개가 TokenReissuer.kt:82 에서 그 락을 기다립니다. 홈은 캐시된 이름만 그리고 수신인 상태·오늘의 질문 자리는 45초 뒤에도 로딩 모양 그대로입니다. DataStore 토큰은 바뀌지 않았습니다.
수정 뒤 (이 PR), 만료 access /auth/reissue 1회(26ms). 그 뒤 users/me·users/receivers·users/push-tokens 200, daily-questions/today·mind-record 는 앱이 취소(Canceled). 홈은 "정보를 불러오지 못했습니다." 실패 화면. DataStore 의 access·refresh 가 둘 다 새 값으로 바뀌었습니다(refresh 만료 09-30 20:45).
수정 뒤, 유효 access (대조군) 재발급 없이 6건 모두 200 을 받고도 같은 실패 화면. mind-record 는 앱이 취소.
수정 뒤 + #2120 의 ReceiverListDto·UserMapper 변경을 커밋 없이 얹은 빌드, 유효 access 6건 200, 홈이 다 그려집니다(수신인 지정 완료, 오늘의 질문).
같은 빌드, 서버가 거절하는 access /auth/reissue 1회(25ms) 뒤 인증 요청 5건 모두 200, 홈이 다 그려집니다. DataStore 의 access·refresh 가 둘 다 새 값으로 바뀌었습니다(refresh 만료 09-30 20:51).
  • 수정 뒤 빌드의 실패 화면은 재발급과 무관한 fix(setting): 수신자 목록이 200 응답에 수신자가 있는데도 빈 화면으로 그려진다 #2105 입니다. GET /users/receivers 응답에 authCode 가 없고 relation 이 null 인 행이 있어 develop 의 ReceiverListDto(두 필드 필수) 디코드가 실패합니다. GetHomeSummaryUseCase 는 수신자 조회를 필수로 묶어 두어, 그 실패가 같은 coroutineScope 의 나머지 조회를 취소하고 홈 전체를 실패로 그립니다. 유효 access 대조군에서도 똑같이 나고 fix(setting): 수신자 목록 응답 계약을 맞춰 행 소실을 막는다 (#2105) #2120 의 변경을 얹으면 사라집니다. 이 PR 에서는 고치지 않습니다.
  • 마지막 회차의 access 는 만료 토큰이 아닙니다. 실제 만료 토큰은 수정 뒤 첫 회차의 재발급으로 교체돼 다시 쓸 수 없어서, 저장된 access 의 payload 시각만 과거로 바꿔 서명이 맞지 않는 토큰을 넣었습니다. TokenAuthenticator 는 401 의 오류 코드를 읽지 않으므로 만료 토큰과 같은 경로입니다.
  • 401 줄은 로그에 없습니다. 로깅 인터셉터가 애플리케이션 인터셉터라 인증 재시도 뒤의 최종 응답만 찍기 때문입니다. 콜드 스타트 직후에는 선제 갱신 기한이 비어 있어(메모리 전용), 재발급이 일어났다는 것 자체가 401 경로를 탔다는 뜻입니다.

📷𝘚𝘤𝘳𝘦𝘦𝘯𝘴𝘩𝘰𝘵

수정 전, 만료 access 로 콜드 스타트한 지 45초 뒤 수정 뒤, 만료 access 로 콜드 스타트한 지 45초 뒤(#2105 실패 화면) 수정 뒤 + #2120 변경, 서버가 거절하는 access 로 콜드 스타트한 지 45초 뒤
수정 전: 홈이 로딩 모양 그대로 멈춤 수정 뒤: 재발급은 됐고 #2105 디코드 실패로 홈 실패 화면 수정 뒤와 #2120: 재발급 1회 뒤 홈 전체가 그려짐

💬𝘛𝘰 𝘙𝘦𝘷𝘪𝘦𝘸𝘦𝘳𝘴

재발급 클라이언트가 base 의 Dispatcher 를 공유해, 같은 호스트의 401 5건이
호스트당 실행 칸 5개를 쥔 채 재발급 락에서 기다리는 동안 재발급 호출은
대기열에서 시작하지 못했다. 대기열 호출에는 callTimeout 도 걸리지 않아
콜드 스타트의 인증 요청이 응답도 실패도 없이 멈췄다.

재발급 클라이언트에만 새 Dispatcher 를 주고 커넥션 풀은 계속 공유한다.
NetworkModule KDoc 에 이 예외를 적고, NetworkModuleTest 에 풀 공유·
디스패처 분리 단언을 더했다. 재현 테스트의 정리 단계는 두 디스패처를
모두 닫는다.
@OnlyReadOneBook
OnlyReadOneBook added this pull request to the merge queue Sep 26, 2026
Merged via the queue into develop with commit 62533ee Sep 26, 2026
40 checks passed
@OnlyReadOneBook
OnlyReadOneBook deleted the fix/2160-reissue-dispatcher-deadlock branch September 26, 2026 12:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(core): 액세스 토큰이 만료된 채 콜드 스타트하면 인증 요청이 재발급을 기다리며 영영 멈춘다

2 participants