Android에서 “다운로드가 멈췄다”는 말은 서로 다른 세 단계를 가리킬 수 있습니다. 브라우저가 파일을 끝까지 받지 못한 것, APK 파일은 다 받았지만 설치 단계에서 거절된 것, Play 스토어가 앱 설치를 대기 중인 것은 원인과 확인 위치가 다릅니다. 우선 알림에 보이는 문구보다 멈춘 바이트 수와 최종 HTTP 응답을 남기면 문제 층을 빠르게 나눌 수 있습니다.
이 글에서는 실제 Android 기기 화면을 재현했다고 주장하지 않습니다. 대신 2026년 9월 21일 이 컴퓨터에 격리 HTTP 서버를 만들고 정상 재개, Range 무시, redirect, 응답 중간 절단을 같은 1MiB 파일로 실행했습니다. 그 결과를 바탕으로 사용자와 앱·서버 개발자가 각각 어디까지 확인할지 정리합니다.
먼저 멈춘 단계를 네 가지로 나누기
| 관찰한 증상 | 지금 확인할 층 | 남겨야 할 증거 |
|---|---|---|
| 누른 직후 0B이거나 로그인 화면으로 돌아감 | 링크 만료, 인증 쿠키, redirect, 접근 권한 | 최종 URL과 HTTP 상태 |
| 항상 비슷한 용량에서 멈추거나 처음부터 다시 받음 | 연결 중단, 저장 공간, Range 재개 지원 | 수신 바이트, 200/206, Content-Range |
| 파일 크기는 채워졌지만 열기·설치 실패 | 파일 손상, APK 서명·호환성, 설치 출처 정책 | 예상 크기, SHA-256, 설치 오류 문구 |
| Play 스토어에서 ‘대기 중’만 표시 | Google Play 앱·계정·네트워크의 별도 흐름 | 다른 앱도 같은지, 스토어 오류 문구 |
예를 들어 APK가 100%까지 받아진 뒤 “앱이 설치되지 않았습니다”가 뜬다면 다운로드 재개 설정부터 바꿀 이유가 없습니다. 반대로 파일 크기가 계속 달라진다면 서명 문제를 조사하기 전에 전송 완료 여부부터 확인합니다.
격리 서버에서 실패 네 가지를 직접 재현했다
테스트는 Windows 10.0.19045 x64, Node.js v24.19.0에서 수행했습니다. 외부 서버나 이동통신망 변수를 제거하기 위해 127.0.0.1에만 HTTP 서버를 열었습니다. 1,048,576 byte의 결정적 바이트 배열을 만들고 원본 SHA-256 042d84d3772b8cc9080a8f388c428c11d88f8566e5a38fda322fc8ea33492cfc를 기준으로 완성 파일을 비교했습니다.
| 테스트 경로 | 서버 동작 | 클라이언트 판정 |
|---|---|---|
/file |
Range를 해석해 206과 Content-Range 반환 | 부분 두 개를 결합한 SHA-256 일치 |
/ignore-range |
Range가 있어도 전체 파일을 200으로 반환 | 기존 조각에 추가하면 길이·SHA-256 불일치 |
/redirect |
302 뒤 실제 파일 경로로 이동 | 최종 URL·200·SHA-256 확인 |
/truncated |
1MiB를 선언하고 256KiB만 보낸 뒤 연결 종료 | 응답 중단 오류 감지 |

관찰 결과: 206은 결합됐고, 200을 추가하면 손상됐다
첫 요청은 앞의 262,144 byte만 받고, 두 번째 요청은 나머지를 요청했습니다. 서버가 두 요청 모두 206으로 답하면서 범위를 명시했을 때 결합 파일은 정확히 1,048,576 byte였고 원본 SHA-256과 일치했습니다.
GET /file Range: bytes=0-262143
→ 206 Content-Range: bytes 0-262143/1048576
GET /file Range: bytes=262144-
→ 206 Content-Range: bytes 262144-1048575/1048576
결합 크기: 1048576 byte
SHA-256 일치: true
다음에는 같은 이어받기 요청을 Range를 무시하는 경로에 보냈습니다. 응답은 206이 아니라 200이었고 Content-Range도 없었습니다. 이를 기존 262,144 byte 뒤에 그대로 붙이면 1,310,720 byte가 되어 체크섬이 달라졌습니다. 기존 조각을 버리고 200 응답 전체로 교체했을 때만 원본과 일치했습니다.
GET /ignore-range Range: bytes=262144-
→ 200 Content-Range: 없음
잘못 이어 붙인 크기: 1310720 byte
잘못 이어 붙인 SHA-256 일치: false
부분 파일 폐기 후 전체 저장: 1048576 byte
다시 저장한 SHA-256 일치: true
1MiB 선언 후 256KiB만 전송
→ socket hang up 감지: true
따라서 “서버가 Accept-Ranges: bytes를 한 번 보냈다”는 정보만으로 이어 붙이면 안 됩니다. RFC 9110도 서버가 Range를 무시할 수 있다고 설명합니다. 클라이언트는 매번 206 상태와 유효한 Content-Range가 요청한 시작점과 전체 길이에 맞는지 검사해야 합니다. Range 요청에 200이 오면 그 응답은 전체 표현이므로 부분 파일에 추가하지 않고 처음부터 저장합니다.
사용자라면 파일 이름보다 바이트 수부터 기록하기
- 브라우저 다운로드 목록에서 파일 이름, 받은 용량, 총용량, 표시된 오류 문구를 적습니다.
- 같은 링크를 계속 누르기 전에 로그인 세션이 필요한 주소인지 확인합니다. 공유 링크나 서명 URL은 만료되면 HTML 로그인 페이지를 APK 이름으로 저장하기도 합니다.
- 다른 네트워크에서 한 번 비교하되 VPN·광고 차단·보안 앱을 무작정 모두 끄지는 않습니다. 어느 조건을 바꿨는지 한 번에 하나씩 남깁니다.
- 저장 공간에는 표시된 파일 크기보다 여유가 있어야 합니다. 다운로드 뒤 설치·압축 해제 과정은 추가 공간을 쓸 수 있습니다.
- 제공자가 SHA-256을 공개했다면 받은 파일의 값과 비교합니다. 크기만 같아도 내용이 같다는 보장은 없습니다.
브라우저 메뉴 이름과 저장 위치는 제조사·Android 버전에 따라 다릅니다. 이 글에서 특정 기기의 메뉴를 실제로 눌러 검증하지 않았으므로 “설정의 몇 번째 항목”보다 다운로드 목록에서 관찰할 값을 기준으로 제시합니다.
서버 운영자는 최종 GET과 Range 응답을 따로 확인한다
HEAD만으로 판단하면 실제 GET·CDN 경로와 다를 수 있습니다. 테스트 파일이나 권한이 허용된 자신의 URL에서 최종 redirect를 따라간 GET 응답과 Range GET을 각각 저장합니다. 아래 명령의 example.com은 실제 관리 URL로 바꿉니다.
# 전체 응답: redirect를 따라가며 헤더와 파일을 각각 저장
curl -L -D full-headers.txt -o downloaded.apk
https://example.com/app.apk
# 262144 byte부터 재개 요청
curl -L -H "Range: bytes=262144-"
-D range-headers.txt -o resumed-part.bin
https://example.com/app.apk
Range 응답에서 볼 것은 206 여부, Content-Range: bytes 262144-.../전체길이, 실제 본문 길이입니다. CDN이나 reverse proxy가 Range를 제거하거나, 인증 만료로 redirect된 HTML이 200으로 돌아오는 경우도 있으므로 최종 URL과 Content-Type도 함께 남깁니다. ETag나 Last-Modified가 다운로드 도중 바뀌었다면 이전 조각과 새 표현을 합치지 말고 새로 받는 편이 안전합니다.
| Range GET 결과 | 의미 | 안전한 처리 |
|---|---|---|
| 206 + 요청 시작점과 맞는 Content-Range | 부분 표현을 받음 | 길이·표현 식별자 확인 후 결합 |
| 200 + 전체 파일 | 서버가 Range를 무시함 | 기존 부분 파일 폐기 후 교체 |
416 + bytes */전체길이 |
요청 범위가 현재 표현에 맞지 않음 | 로컬 길이와 원격 길이를 다시 비교 |
| 302/303 뒤 HTML 200 | 인증·동의·만료 페이지일 수 있음 | 최종 URL·Content-Type·세션 확인 |
| Content-Length보다 일찍 종료 | 전송이 완성되지 않음 | 완료로 표시하지 말고 재시도·로그 수집 |
앱에서 DownloadManager를 쓴다면 status와 reason을 함께 저장하기
Android DownloadManager는 현재 상태를 COLUMN_STATUS로, 대기·실패 이유를 COLUMN_REASON으로 조회할 수 있습니다. “멈춤”이라는 자체 문구만 보여주지 말고 시스템이 반환한 두 값을 진단 로그에 함께 남깁니다. 공식 API에는 대기, 실행 중, 일시중지, 성공, 실패 상태와 네트워크 대기·재시도 대기 같은 reason이 구분되어 있습니다.
val query = DownloadManager.Query().setFilterById(downloadId)
downloadManager.query(query).use { cursor ->
if (cursor.moveToFirst()) {
val status = cursor.getInt(
cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS)
)
val reason = cursor.getInt(
cursor.getColumnIndexOrThrow(DownloadManager.COLUMN_REASON)
)
val received = cursor.getLong(
cursor.getColumnIndexOrThrow(
DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR
)
)
val total = cursor.getLong(
cursor.getColumnIndexOrThrow(
DownloadManager.COLUMN_TOTAL_SIZE_BYTES
)
)
// 운영 로그에는 URL 토큰을 넣지 말고 status/reason/byte 수만 기록한다.
}
}
앱이 요청을 만들 때 metered network, roaming, Wi-Fi 제한을 걸었다면 사용자가 보는 “대기”가 코드의 의도일 수 있습니다. DownloadManager.Request의 네트워크 제한과 실제 제품 요구가 맞는지 확인하고, 제한 때문에 기다리는 경우 UI에 그 이유를 설명합니다. 토큰·쿠키·서명 URL 전체는 로그에 남기지 않습니다.
APK를 다 받은 뒤 설치가 안 되면 다운로드 문제에서 빠져나오기
파일 크기와 체크섬이 맞고 다운로드가 끝났다면 다음 단계는 설치 진단입니다. 출처를 알 수 없는 APK를 받거나 Play Protect를 끄는 것을 해결책으로 삼지 않습니다. 배포자가 제공한 공식 다운로드 위치와 체크섬인지 먼저 확인하고, 기기가 표시하는 차단·서명·호환성 문구를 그대로 기록합니다.
- 기존 앱 업데이트라면 새 APK가 기존 앱과 같은 signing key로 서명됐는지 확인합니다.
- 지원 Android 버전, ABI, split APK 구성과 기기 조건이 맞는지 확인합니다.
- 네이티브 라이브러리가 있는 앱이 특정 최신 기기에서만 실패한다면 Android 16KB 페이지와 APK·ELF 정렬 점검으로 분리합니다.
- 화면의 설치 오류별 분기는 Android·iPhone 설치 오류 진단표에서 이어서 확인합니다.
Google Play Protect는 설치된 앱과 기기에서 유해 동작을 검사하며, Google은 이를 켜 둔 상태를 권장합니다. 국가·기기·계정 정책에 따라 출처 외 설치 흐름이 달라질 수 있으므로 보안 경고를 우회하기보다 배포 경로와 개발자 확인 상태를 점검합니다.
Play 스토어 ‘대기 중’은 직접 파일 다운로드와 분리한다
Play 스토어에서만 대기 중이고 브라우저 파일은 정상이라면 HTTP Range 테스트를 먼저 할 이유가 없습니다. 다른 앱도 같은지, 네트워크와 저장 공간, Google Play 앱·Play 서비스 상태, 계정의 다운로드 조건을 확인합니다. 상세 순서는 Play 스토어 다운로드 대기 중 점검에 따로 정리했습니다.
APK 링크가 앱으로 열려야 하는데 브라우저로 반복해서 돌아오거나 redirect 과정에서 인증이 풀린다면 전송 자체보다 링크 연결 문제일 수 있습니다. 이 경우 Android App Links 검증 순서에서 도메인 연결과 fallback을 확인합니다.
이번 재현으로 확인하지 못한 범위
- Android 실기기와 제조사별 브라우저, 시스템 DownloadManager UI를 실행하지 않았습니다.
- 실제 CDN, proxy, 로그인 쿠키, 이동통신망의 끊김을 재현하지 않았습니다.
- APK 서명 검증과 설치 성공 여부, Play 스토어 계정 상태를 측정하지 않았습니다.
- 로컬 HTTP라 TLS 인증서·HTTPS redirect 문제는 포함하지 않았습니다.
그러므로 이 글의 수치를 특정 Android 기기의 결과로 일반화하지 않습니다. 대신 응답 상태, 범위, 바이트 수, 체크섬이라는 관찰값으로 전송 완료 여부를 판정하고, 그 뒤에만 설치·스토어 단계로 이동하는 절차를 제공합니다.