Android 개발/트러블슈팅

지하철에서 요청이 죽던 자리, 지수 백오프로 안드로이드 네트워크 재시도 다시 짠 일지

stackD 2026. 9. 27. 18:00
반응형

 

얼마 전에 2호선 강변역 지나던 3초, 결제 스피너가 안 멈춰서 홧김에 버튼을 다섯 번 연타했거든요. 지상으로 나온 순간, 똑같은 주문 5건이 나란히 결제돼 있더라고요.

 

약한 신호에서 첫 실패를 그냥 에러 화면으로 던지던 자리를, 안드로이드 네트워크 재시도를 지수 백오프와 지터로 다시 짜서 서버도 제 배터리도 덜 다치게 만든 기록입니다. 재시도해도 되는 요청을 어떻게 가르는지, OkHttp 인터셉터(Interceptor)·Ktor 실제 코드는 어떻게 잡는지, 상한은 어디에 두는지까지 순서대로 풀어볼게요.

 

'한 번 실패=에러'로 던지자 이탈이 튀던 순간

처음엔 단순했어요. 첫 타임아웃이 나면 곧장 "네트워크 오류" 화면을 띄웠습니다. 근데 지하철 진입 구간에서 이탈이 눈에 띄게 늘더라고요. 정밀 측정은 아니고 정성적인 관찰이었지만, 패턴은 확실했습니다.

 

지하철·터널·엘리베이터는 완전한 오프라인이 아니에요. 신호가 1~2초 붙었다 끊겼다를 반복하는 간헐적 연결(flaky connection) 구간입니다. 여기서 실패한 그 찰나에 곧바로 다시 쏘면, 하필 신호가 없는 창에 또 던지는 셈이라 계속 실패하죠.

 

그래서 질문을 바꿔야 했어요. "언제 다시 시도하냐"보다 "지금 이 무선 링크가 살아 있냐"가 먼저였습니다. 범인은 서버가 아니라 끊겼다 붙는 신호였던 거예요.

 

재시도해도 되는 요청 vs 절대 안 되는 요청

백오프를 짜기 전에, 재시도 자체를 걸러야 합니다. AWS Well-Architected 재시도 가이드(REL05-BP03)도 이 순서를 강조해요.

 

재시도해도 되는 건 일시적(transient) 실패입니다. 429(요청 과다), 503(일시 과부하), 연결 타임아웃, 연결 끊김 같은 것들이죠. 반대로 400·401·403·404, 유효성 검증 실패 같은 건 다시 쏴도 결과가 똑같습니다. 이런 걸 재시도하면 서버 부하에 배터리·데이터 요금까지 태우는 헛수고예요.

 

더 무서운 함정은 POST·PATCH 같은 비멱등 요청입니다. GET·PUT·DELETE는 여러 번 보내도 결과가 같아서 안전한데, POST는 타임아웃 뒤 재시도하면 제 강변역 사건처럼 중복 결제·중복 주문이 나거든요.

 

해법은 멱등성 키(Idempotency Key) 예요. 요청마다 UUID를 헤더에 실어 보내고, 서버가 그 키를 Redis 같은 저장소에 기억합니다. 같은 키가 또 오면 저장해둔 응답을 그대로 돌려주죠. 스트라이프(Stripe)는 첫 요청의 상태 코드와 응답 본문을 성공·실패 상관없이 저장했다가 재요청 때 그대로 반환하고, 키 수명은 공식 문서 기준 24시간이에요. 다만 이건 서버 저장소가 필요해서 프론트 혼자로는 못 끝내고 백엔드와 같이 짜야 합니다.

 

 

지수 백오프와 지터로 재시도 폭풍 막기

이제 대기 시간을 계산할 차례예요. 지수 백오프는 재시도할수록 간격을 벌리는 방식입니다. delay = base * 2^attempt 로 1초→2초→4초→8초 이렇게 늘리고, cap 으로 상한을 걸어요. 서버에 숨 쉴 시간을 주는 겁니다.

 

근데 여기서 초보들이 자주 놓치는 지점이 있어요. 지터(jitter) 없이 고정된 간격만 쓰면, 앱 재실행이나 백그라운드 복귀 순간에 수많은 클라이언트가 똑같은 타이밍에 재시도를 쏩니다. 이게 바로 썬더링 허드(thundering herd), 재시도 폭풍이에요. 용량이 겨우 줄어든 순간 부하가 다시 폭증하는 악순환이 생기죠.

 

그래서 대기 시간에 무작위성을 섞습니다. 마크 브루커(Marc Brooker)가 AWS에서 정리한 지터가 대표적으로 세 종류예요.

 

  1. Full Jitter: random(0, min(cap, base * 2^attempt)). 전 구간을 랜덤으로 흩뜨려서 분산이 가장 좋습니다. 가끔 대기가 너무 짧아지는 게 흠이에요.
  2. Equal Jitter: 절반은 고정, 절반은 랜덤. 최소 대기의 50%는 보장돼서 예측이 쉽습니다.
  3. Decorrelated Jitter: min(cap, random(base, sleep * 3)). 시도 횟수 대신 직전 대기값을 쓰는 방식인데, AWS·넷플릭스 테스트에서 스파이크가 가장 적었다고 합니다.

 

개인적으로는 대부분의 앱이라면 Full Jitter가 낫다고 봅니다. Decorrelated가 강력해 보이지만, 톰 라이트(Thom Wright)의 반론처럼 상한에 금방 붙어버려서 기대만큼 분산이 안 될 때가 있고, 직전 대기값을 계속 들고 다녀야 해서 구현도 번거로워요. 단순하고 충분한 쪽을 고르는 게 실무에선 이득이라고 보시면 되겠습니다.

 

 

OkHttp 인터셉터와 Ktor 재시도 실제 구현

말로만 하면 감이 안 오니 코드로 볼게요. 먼저 OkHttp 리트라이 인터셉터입니다. 응답 코드를 분류해서 일시적 실패면 계산한 만큼 쉬고 다시 chain.proceed() 를 호출하는 구조예요.

 

class RetryInterceptor(
    private val maxRetries: Int = 3,
    private val baseMs: Long = 1000,
    private val capMs: Long = 8000,
) : Interceptor {

    override fun intercept(chain: Interceptor.Chain): Response {
        var attempt = 0
        while (true) {
            try {
                val response = chain.proceed(chain.request())
                // 클라이언트 에러(4xx)는 재시도 무의미 → 그대로 반환
                if (response.isSuccessful || response.code < 500 && response.code != 429) {
                    return response
                }
                if (attempt >= maxRetries) return response
                response.close() // 다음 시도 전 응답 바디 반드시 닫기
            } catch (e: IOException) {
                // 타임아웃·연결 끊김 등 네트워크 예외만 재시도
                if (attempt >= maxRetries) throw e
            }
            // Full Jitter: 0 ~ min(cap, base * 2^attempt) 사이 랜덤 대기
            val ceil = minOf(capMs, baseMs * (1L shl attempt))
            // 메인 스레드에서 직접 호출 금지 (여기선 OkHttp 디스패처 스레드라 안전)
            Thread.sleep(Random.nextLong(ceil + 1))
            attempt++
        }
    }
}

 

멱등성 키가 필요한 요청이면, 재시도 루프 바깥에서 요청 한 번당 UUID를 한 번만 만들어 Idempotency-Key 헤더로 넣어야 해요. 루프 안에서 매번 새로 만들면 중복 방지가 안 되니 주의하셔야 됩니다.

 

Ktor 재시도는 훨씬 선언적입니다. HttpRequestRetry 플러그인이 상한·지수 대기·지터를 다 옵션으로 받아요.

 

val client = HttpClient(CIO) {
    install(HttpRequestRetry) {
        retryOnServerErrors(maxRetries = 3) // 5xx 재시도
        retryOnException(maxRetries = 3, retryOnTimeout = true)
        exponentialDelay(base = 2.0, maxDelayMs = 8000) // 지터 기본 포함
        // 멱등키는 여기서 만들지 않음: modifyRequest 는 재시도마다 호출되므로
        // 매번 새 UUID 가 생성되면 서버 측 중복 방지가 무력화됨
    }
}

// 멱등키는 요청 생성 시점에 '한 번만' 부여하고, 재시도 전 과정에서 동일 값 유지
client.post("/orders") {
    header("Idempotency-Key", generateUuid())
    setBody(payload)
}

 

Ktor에서도 원리는 OkHttp와 같습니다. 멱등키는 install(HttpRequestRetry) 안이 아니라 요청을 호출하는 쪽에서 한 번만 발급해야, 같은 요청이 몇 번을 재시도하든 서버가 같은 키로 중복을 걸러낼 수 있어요.

 

여기서 핵심 하나만 덧붙일게요. 지수 백오프만으로는 지하철 구간에 부족합니다. 대기하는 동안 신호가 돌아왔는데도 정해진 8초를 멍하니 기다리면 손해잖아요. 안드로이드라면 ConnectivityManager.NetworkCallback 으로 연결 복귀 이벤트를 받아서, 대기 중에 신호가 붙으면 남은 백오프를 건너뛰고 바로 재개하는 게 좋습니다. 이 연결 상태 리스너 결합은 대부분의 재시도 글이 빠뜨리는 지점이더라고요.

 

 

최대 재시도 횟수와 총 타임아웃 상한 잡기

상한 없는 재시도는 모바일에선 죄악에 가깝습니다. 서버 부하만 있는 백엔드와 달리, 폰은 배터리랑 데이터 요금이라는 비용 축이 하나 더 붙거든요. 조용히 무한 재시도를 돌리면 사용자 폰이 지하철에서 뜨끈해집니다.

 

그래서 저는 이중 상한을 둡니다. 재시도 횟수는 35회, 그리고 누적 소요 시간은 1520초 안쪽. 둘 중 하나라도 넘으면 멈춰요. 참고로 저 같은 경우엔 결제처럼 민감한 요청은 3회로 짧게 끊고, 조회성 요청만 5회까지 여유를 둡니다.

 

그리고 이게 늘 최선도 아니에요. 상한을 잘못 잡으면 실패 피드백이 늦어져서 오히려 사용자가 답답해합니다. 이럴 땐 조용한 무한 재시도보다 빠르게 실패를 알리고 "다시 시도" 버튼을 명시적으로 보여주는 게 나을 때도 많죠.

 

사실 백오프는 근본 해결이 아니라 증상 완화예요. 지하철 같은 간헐 연결의 진짜 답은 오프라인 우선 설계입니다. 요청을 로컬 큐에 쌓아뒀다가 "연결되면 자동 전송"으로 동기화하는 쪽이 사용자 경험엔 훨씬 매끄럽습니다.

 

강변역에서 배운 건 하나였어요. 범인은 서버가 아니라 '모두가 같은 순간에 다시 시도한다'였습니다. 에러를 먼저 가르고, 멱등성 키로 중복을 막고, 지수 백오프에 지터로 시도를 흩뜨리고, 연결 이벤트로 재개하고, 상한으로 끊는 것. 이 다섯 순서가 for문 세 번을 조용히 대체하면, 다음번 터널에서 스피너를 보더라도 결제가 다섯 건 쌓이는 일은 없습니다.

 

 

반응형
LIST