OkHttp에 재시도 인터셉터를 달아 뒀는데 네트워크 실패가 전혀 줄지 않는다면, 재시도 조건이 실제 예외를 못 잡고 있을 가능성이 큽니다. 연결이 응답 도중 끊기면 OkHttp는 SocketException이 아니라 평범한 IOException에 unexpected end of stream이라는 문구를 실어 던지고, 읽기 타임아웃의 문구는 timeout이 아니라 Read timed out입니다. 제 앱은 두 구멍에 차례로 빠져 있었고, 실사용자 실패 9건의 재시도 기록이 9건 전부 "한 번에 포기"였습니다. 재시도 코드는 멀쩡히 붙어 있었는데 한 번도 돈 적이 없었던 것입니다.
공공데이터 API를 쓰는 앱이었습니다. 정부 서버가 가끔 연결을 끊어서, 몇 년 전에 재시도를 붙여 둔 코드가 있었습니다.
fun isRetryable(e: IOException): Boolean =
e is SSLException ||
e is SocketException ||
e.message?.contains("Connection reset") == true ||
e.message?.contains("timeout") == true
읽어 보면 그럴듯합니다. SSL 오류, 소켓 오류, 연결 리셋, 타임아웃. 흔한 네트워크 실패는 다 들어 있는 것처럼 보입니다. 파일 맨 위 주석에도 "서버의 Connection reset에 대비한 재시도"라고 적혀 있었습니다.
첫 번째 구멍 — unexpected end of stream
이 람다를 별도 클래스로 꺼내서 테스트를 붙였더니 첫 실행에서 바로 빨간불이 났습니다. 테스트는 MockWebServer로 서버가 연결을 진짜로 끊게 만든 것이었습니다.
server.enqueue(MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AT_START))
server.enqueue(MockResponse().setBody("ok"))
client.newCall(request).execute().use { assertEquals(200, it.code) }
첫 요청은 끊기고 두 번째는 성공하니, 재시도가 돌면 통과해야 합니다. 그런데 첫 요청에서 예외가 그대로 밖으로 나왔습니다. 찍힌 예외는 이랬습니다.
java.io.IOException: unexpected end of stream on http://127.0.0.1:2114/...
SocketException이 아닙니다. 그냥 IOException이고, 문구에 "Connection reset"도 "timeout"도 없습니다. 네 조건 어디에도 걸리지 않으니 재시도 없이 즉시 포기했습니다. "서버가 연결을 끊는 상황"에 대비한 코드가 정작 그 상황의 상당 부분을 놓치고 있었습니다.
isRetryable(SocketException("Connection reset"))처럼 예외를 직접 만들어 넣는 테스트는 내가 짐작한 예외를 내가 분류하는지만 봅니다. OkHttp가 실제로 무엇을 던지는지는 소켓을 진짜로 끊어 봐야 나옵니다.
같이 터진 것 — android.util.Log는 JVM 테스트에서 예외를 던진다
처음 돌린 테스트 네 개가 전부 다른 이유로 깨지기도 했습니다. 재시도 경로의 catch 블록 안에 Log.w(...)가 있었는데, 로컬 유닛테스트에서 android.util.Log는 호출만 해도 RuntimeException: Method w in android.util.Log not mocked를 던집니다. 재시도 경로가 통째로 폭발한 것입니다.
unitTests.isReturnDefaultValues = true를 켜면 사라지긴 합니다. 저는 켜지 않았습니다. 그 옵션은 안드로이드 API 전부를 조용한 기본값으로 바꿔서, 언젠가 다른 곳의 진짜 실패까지 초록불로 덮기 때문입니다. 대신 로그를 생성자 파라미터로 뺐습니다.
internal class RetryInterceptor(
private val maxAttempts: Int = 3,
private val sleep: (Long) -> Unit = { Thread.sleep(it) },
private val logWarn: (String) -> Unit = { Log.w(TAG, it) }, // 테스트에서는 {} 를 넘긴다
) : Interceptor
두 번째 구멍 — "timeout"과 "timed out"은 다른 문자열이다
unexpected end of stream을 조건에 추가해서 배포했습니다. 닷새 뒤, 실사용자 기기 세 종류에서 첫 실행 데이터 로딩 실패가 6건 올라왔습니다. 실패 사유는 timeout으로 정확히 분류돼 있었습니다. 그런데 같은 시간대의 재시도 결말 기록을 보니 이상했습니다.
| 재시도 결말 | 건수 | 뜻 |
|---|---|---|
| recovered | 0 | 재시도해서 살아났다 |
| exhausted | 0 | 끝까지 재시도했는데 실패 |
| skipped | 9 | 재시도할 실패가 아니라 한 번에 포기 |
실패 9건, 포기 9건. 모든 실패에서 재시도가 0회였습니다. 원문을 열어 보니 이렇게 찍혀 있었습니다.
retry_skipped detail=1/3 timeout Read timed out
조건은 contains("timeout")인데 메시지는 Read timed out입니다. "timeout"과 "timed out"은 다른 문자열이라 걸리지 않습니다. 타입 조건에도 안 걸렸습니다. SocketTimeoutException은 이름과 달리 SocketException의 자식이 아니라 InterruptedIOException의 자식입니다.
java.io.IOException
├─ java.net.SocketException ← 조건이 본 것
│ └─ ConnectException ...
└─ java.io.InterruptedIOException
└─ java.net.SocketTimeoutException ← 실제로 온 것
더 허무했던 건, 같은 앱에서 실패 사유를 분류하는 코드는 진작 is SocketTimeoutException으로 정확히 분류하고 있었다는 점입니다. 그래서 보고서에는 reason=timeout이 제대로 찍혔습니다. 분류는 타입으로, 재시도 판정만 문자열로 한 불일치가 원인이었습니다.
이 옛 테스트가 결함을 덮고 있었다
타임아웃 테스트가 없었던 게 아닙니다. 있었고, 초록불이었습니다.
assertTrue(isRetryable(IOException("read timeout"))) // ← 손으로 만든 예외
첫 번째 구멍에서 "손으로 만든 예외로 테스트하면 안 된다"를 배우고도, 바로 그 파일에 그 함정이 한 줄 남아 있었습니다. SocketPolicy.NO_RESPONSE로 서버가 응답을 영영 안 주게 만들어 진짜 읽기 타임아웃을 내는 테스트로 바꿨습니다.
server.enqueue(MockResponse().setSocketPolicy(SocketPolicy.NO_RESPONSE))
server.enqueue(MockResponse().setBody("ok"))
// client 는 readTimeout 을 짧게(예: 200ms) 잡아 둔다
해결 — 판정을 문자열이 아니라 타입으로
fun isRetryable(e: IOException): Boolean =
e is SocketTimeoutException || // "Read timed out" / "timeout" 둘 다
e is SSLException ||
e is SocketException ||
e.message?.contains("unexpected end of stream", ignoreCase = true) == true
unexpected end of stream은 전용 예외 타입이 없어서 문구로 남길 수밖에 없습니다. 나머지는 전부 타입입니다. 실제로 실기기에서 타임아웃을 재현해 보니 같은 SocketTimeoutException인데 메시지가 Read timed out이 아니라 timeout으로 나왔습니다. 어느 계층이 먼저 터지느냐로 문구가 갈립니다(Okio의 타임아웃이냐, 소켓의 SO_TIMEOUT이냐). 문자열 판정은 기기와 경로에 따라 되고 안 되고가 갈린다는 뜻입니다.
나중에 만든 앱에서는 아예 종류로 거르지 않고 IOException이면 다 재시도하고, HTTP 4xx만 바로 던지게 했습니다. 거르는 조건이 없으면 조건이 좁아서 새는 일도 없습니다. 다만 비행기 모드처럼
UnknownHostException이 나는 상황에서도 기다리게 되니, 시도 횟수를 작게 잡아야 합니다.
함께 막아야 하는 두 가지
① 취소는 실패가 아니다. 코루틴이 취소되면 Retrofit이 Call.cancel()을 부르고, OkHttp는 IOException("Canceled")를 던집니다. 실패처럼 생겨서 옵니다. 그대로 두면 사용자가 화면을 벗어난 횟수만큼 실패 통계가 쌓입니다. 문구가 아니라 call의 상태로 가릅니다.
} catch (e: IOException) {
if (chain.call().isCanceled()) throw e // 재시도도, 실패 기록도 하지 않는다
...
② 타임아웃은 시도 상한을 따로 낮춘다. 끊긴 연결은 즉시 예외가 오지만, 타임아웃은 한 번마다 readTimeout을 꼬박 태웁니다. 20초 타임아웃에 3회면 최악 60초인데, 제 경우 그 실패가 나는 자리가 하필 첫 실행 스플래시였습니다. 타임아웃만 2회로 줄였고, 실기기에서 43초에 끝나는 것을 확인했습니다.
private fun attemptLimit(e: IOException) =
if (e is SocketTimeoutException) minOf(maxAttempts, 2) else maxAttempts
재시도가 실제로 도는지 밖에서 보는 법
이번 일의 진짜 교훈은 여기 있습니다. 재시도는 잘 돌아도 조용하고, 안 돌아도 조용합니다. 실패 건수만 보면 "네트워크가 원래 불안정하구나"로 읽힙니다. 그래서 재시도의 결말을 세 갈래로 나눠 기록합니다.
| 결말 | 이게 많으면 볼 곳 |
|---|---|
recovered | 재시도가 일을 하고 있다는 유일한 증거. 0이면 의심할 것 |
exhausted | 서버가 정말 죽었는지, 상한이 너무 낮은지 |
skipped | 재시도 조건이 너무 좁은지 — 제 경우가 이것 |
첫 시도 성공은 기록하지 않습니다. 재시도가 있었을 때만 남겨야 "살려낸 요청"이 잡음에 묻히지 않습니다. 실패 건수와 skipped 건수가 똑같이 움직이면, 그게 재시도가 한 번도 안 돌고 있다는 지문입니다.
정리
- 연결이 응답 도중 끊기면 OkHttp는 평범한
IOException("unexpected end of stream on …")을 던집니다.SocketException조건에 안 걸립니다. - 읽기 타임아웃의 문구는
Read timed out이라contains("timeout")에 안 걸립니다.SocketTimeoutException은SocketException의 자식도 아닙니다. - 판정은 타입으로 합니다. 같은 예외라도 계층에 따라 문구가 달라집니다.
- 테스트는
MockWebServer의SocketPolicy로 진짜 소켓을 끊어서 합니다. 손으로 만든 예외는 아무것도 검증하지 않습니다. - 수정을 잠깐 되돌려서 새 테스트가 빨간불이 되는지 확인하세요. 초록불이 "검증됐다"인지 "조건에 못 닿았다"인지는 그렇게만 갈립니다.
- 재시도 결말(
recovered·exhausted·skipped)을 기록하세요. 안 도는 재시도는 로그 없이는 안 보입니다.
자주 묻는 것
OkHttp의 retryOnConnectionFailure로는 안 되나요?
기본값이 true이고 일부 연결 실패는 알아서 다시 시도합니다. 하지만 대상이 좁습니다. 이미 요청을 보낸 뒤 응답 도중 끊긴 경우나 읽기 타임아웃은 다시 보내지 않는 경우가 많습니다. 서버 쪽에서 중복 처리돼도 문제없는 GET 요청이라면 인터셉터로 직접 재시도하는 편이 확실합니다.
POST도 이렇게 재시도해도 되나요?
조심해야 합니다. unexpected end of stream은 서버가 요청을 이미 처리한 뒤 응답만 못 보낸 경우에도 납니다. 결제·등록 같은 요청을 다시 보내면 두 번 처리될 수 있습니다. 저는 조회용 GET에만 이 인터셉터를 붙였습니다.
unexpected end of stream이 자꾸 나는 원인 자체는 뭔가요?
대개 서버나 중간 장비(로드밸런서·프록시)가 연결을 먼저 닫은 것입니다. 오래 놀던 keep-alive 연결을 재사용하려는 순간 서버는 이미 닫아 둔 경우가 흔합니다. 서버를 고칠 수 없는 공공 API라면 클라이언트에서 재시도로 받아내는 것이 현실적인 답입니다.
