1편을 보지 않으셨다면 보고 오시는 것을 추천드립니다!
대용량 파일 다운로드 구현하기 - 1편
안녕하세요 오랜만입니다. 바빠서 간만에 돌아왔습니다. 오늘도 뜬금없이 새로운 주제로 찾아왔습니다. “대용량 파일 다운로드를 구현하려면 어떻게 해야 할까?” 얼마 전 친구가 던진 질문입
interlude.tistory.com
그럼, 이제 코드를 작성해 봅시다!
최소한의 기능으로 구현하기
간단하게 다운로드 함수를 구현해 봤습니다. 한 줄씩 살펴봅시다!
public boolean download(String urlStr, Path path, int byteKB) throws IOException {
HttpURLConnection conn = null;
try {
conn = (HttpURLConnection) new URL(urlStr).openConnection();
conn.setConnectTimeout(5_000);
conn.setReadTimeout(5_000);
if (conn.getResponseCode() != 200) return false;
try (
InputStream in = conn.getInputStream();
OutputStream out = Files.newOutputStream(path, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
) {
byte[] buffer = new byte[byteKB * 1024];
int n;
while ((n = in.read(buffer)) != -1) {
out.write(buffer, 0, n);
}
}
return true;
} finally {
if (conn != null) conn.disconnect();
}
}
public boolean download(String urlStr, Path path, int byteKB) throws IOException
매개변수로 다음 세 가지를 받습니다.
- 다운로드할 리소스의 url
- 파일 저장 경로
- 메모리에 설정할 버퍼 크기
HttpURLConnection conn = null;
try {
conn = (HttpURLConnection) new URL(urlStr).openConnection();
conn.setConnectTimeout(5_000);
conn.setReadTimeout(5_000);
if (conn.getResponseCode() != 200) return false;
...
}
Java에서는 HttpURLConnection을 활용하여 HTTP 통신을 구현할 수 있습니다.
만약 HTTPS 통신에 관련된 설정이 필요하다면 HttpURLConnection으로 형변환하면 됩니다.
HttpsURLConnection → HttpURLConnection → URLConnection 이와 같이 상속 구조가 이루어져 있습니다.
openConnection을 호출하면 내부에서 URL로 http와 https를 구분하여 구현체를 생성하기 때문에 URL이 https로 구성되었다면 구현체는 HttpsURLConnection로 생성됩니다. 그러므로 필요하다면 형변환하여 사용하면 됩니다.
setConnectTimeout은 두 호스트를 연결하는데 걸리는 시간을 제한합니다.
즉, TCP three-way handshake에 걸리는 시간을 제한합니다. 여기서는 5초로 제한했습니다.
handshake를 시작하고 5초동안 연결이 수립되지 않으면 SocketTimeoutException이 발생합니다.
setReadTimeout은 InputStream.read() 호출이 블로킹되는 시간을 제한합니다.
read를 호출했는데 커널 수신 버퍼에 쌓여있는 데이터가 없다면 read는 읽어올 데이터가 생길 때까지 블로킹됩니다.
블로킹된 후 다음 세그먼트가 도착하지 못하고, 블로킹 된 시간이 setReadTimeout으로 설정한 시간을 초과하면 SocketTimeoutException이 발생합니다.
Timeout 시간을 5초로 짧게 설정한 것은 타임아웃을 빠르게 테스트하기 위함입니다. 그러므로 실제 서비스를 구현할 때는 자신의 서비스에 맞게 더 길게 설정하시는 것이 좋습니다.
다운로드 요청이 거절당해 HTTP 상태코드가 200이 아닌 값이 온다면 false를 반환하고 다운로드 함수를 종료합니다.
try (
InputStream in = conn.getInputStream();
OutputStream out = Files.newOutputStream(path, StandardOpenOption.CREATE, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING);
)
HttpURLConnection으로부터 InputStream을 가져올 수 있습니다. InputStream이 앞서 얘기해온 수신 버퍼의 역할을 수행합니다.
엄밀히 말하면 우리가 사용할 InputStream은 수신 버퍼 그 자체는 아닙니다. 다만 소켓 수신 버퍼로부터 데이터를 읽어오는 추상 레이어입니다. 즉, InputStream은 커널 레벨이 아닌 사용자 레벨에서 동작하며 커널 수신 버퍼(소켓 수신 버퍼)로부터 데이터를 가져와 우리에게 전달해주는 역할을 합니다. 그러므로 우리 입장에서는 커널 수신 버퍼의 역할을 하는 것처럼 느껴집니다.
그리고 파일을 저장할 경로인 Path로 열린 OutputStream을 가져옵니다.
byte[] buffer = new byte[byteKB * 1024];
이제 try-with-resources 블럭 내부입니다.
메모리에 유지할 버퍼를 byte 배열 형식으로 선언합니다.
메서드 매개변수 버퍼 크기를 KB단위로 받았기 때문에 1024를 곱해서 배열의 크기를 정해줍니다.
int n;
while ((n = in.read(buffer)) != -1) {
out.write(buffer, 0, n);
}
바이트 배열인 buffer를 read 함수의 매개변수로 넘겨 호출하면 InputStream으로부터 읽은 데이터가 buffer에 담기고 읽어온 데이터(buffer에 담긴 데이터)의 크기를 반환합니다.
만약 read 함수가 -1을 반환한다면, 이는 응답의 HTTP body를 전부 수신했다는 의미이므로
더 이상 받을 내용이 없기 때문에 while문을 종료합니다.
buffer, 0, n을 매개변수로 넘겨 write를 호출합니다. 0은 offset, n은 size로 buffer 배열에서 읽기 시작할 인덱스와 읽어올 크기를 설정합니다. write 함수는 OutputStream으로 buffer 배열의 0번째 인덱스부터 n개의 원소(바이트)를 씁니다.
while문이 끝나면 파일 전부를 다운받아 저장을 완료한 것입니다.
이제 대용량 파일 다운로드의 간단한 구현이 완료되었습니다.
그럼 한 번 사용해볼까요?
Downloader downloader = new Downloader(); // 우리가 만든 download 함수를 갖고 있는 클래스
Path downloadPath = Paths.get("/Users/brian/Downloads/downloadtest1MB.zip");
long start = System.currentTimeMillis();
boolean ok = downloader.download("http://speedtest.tele2.net/100MB.zip", downloadPath, 32);
long end = System.currentTimeMillis();
System.out.println("Result: " + ok);
System.out.println("걸린 시간: " + (end - start) + "ms");
위 URL은 파일 다운로드 테스트를 할 수 있도록 해주는 사이트입니다. (저도 GPT가 찾아줬습니다)
혹은 자신의 구글 드라이브에서 파일의 다운로드 URL을 확인하고 사용해볼 수도 있습니다.
제 노트북의 다운로드 디렉토리를 다운로드 경로로 지정했습니다.
실행해보시면 다운로드가 잘 수행되는 것을 볼 수 있습니다.
다운로드 실패 시 미완성 파일 관리 정책
최소한의 기능으로 다운로드를 구현해 보았습니다. 하지만 아직 부족한 점이 많습니다.
만약 다운로드 중에 인터넷이 끊겨 네트워크 통신이 실패한다면 어떻게 될까요?
미완성 파일이 남겨지게 될 것입니다. 우리는 이 미완성 파일을 정책에 따라 유지할 수도, 삭제할 수도 있습니다.
무엇이 정답일까요?
정답은 없습니다. 우리가 만들고자 하는 것에 맞춰 결정하면 됩니다.
예를 들어보면 다운로드가 이뤄지는 환경적 요인이 있을 것입니다.
데스크탑(및 랩탑) 환경과 모바일 환경을 생각해 봅시다.
데스크탑 환경은 일반적으로 저장장치의 용량이 크고, 사용자들이 파일탐색기를 통해 파일을 검색 및 삭제하는 것에 익숙합니다.
반면 모바일 환경은 비교적 작은 저장장치를 갖고 있으며, 파일 탐색기를 통해 파일을 관리하는 것에 비교적 덜 익숙합니다.
그러므로 다운로드 실패 시 다음과 같은 정책을 생각해볼 수 있습니다.
데스크탑 환경에선 미완성 파일을 유지하고 사용자에게 처분을 맡겨 추후 중단된 지점부터 다시 다운로드할 수 있도록 하고,
모바일 환경에서는 사용자가 따로 관리할 필요가 없도록 미완성 파일을 삭제하고 추후 처음부터 다시 다운로드하도록 할 수 있습니다.
요즘 모바일 기기들은 저장장치 용량이 커져 여유롭기도 합니다. 위 정책은 하나의 예시일뿐입니다. 자신의 생각에 맞춰서 정해봅시다
저는 현재 코드가 미완성 파일을 삭제하지 않는 코드이기 때문에, 삭제하는 정책을 적용해보겠습니다.
그리고 삭제 정책을 적용하기 앞서 "임시 파일"을 사용하고자 합니다.
임시파일
크롬 브라우저에서 파일을 다운로드하면, 다운로드가 완료되기 전까지 .crdownload 확장자를 갖는 파일이 생기는 것을 보신 적이 있으실 겁니다.
crdownload는 크롬 브라우저가 임시 파일을 나타낼 때 사용하는 확장자입니다. 이는 크롬 브라우저가 전용으로 사용하는 확장자이고, 이외에도 .part, .tmp와 같은 확장자들을 임시 파일을 나타내는데 주로 사용합니다.
임시 파일은 왜 사용할까요?
우선 임시 파일을 사용하면 이전 버전의 완성본이 있을 때 이를 보호할 수 있습니다.
예를 들어 팀장인 당신이 팀 프로젝트 보고서를 급박하게 제출해야하는 상황입니다.
당신은 이전에 완성된 보고서를 갖고 있었는데, 다른 팀원이 일부 수정하여 새로운 버전의 보고서를 작성했습니다.
당신은 다른 팀원이 방금 수정하여 올려준 최신 보고서를 공유 저장소(카카오톡, 클라우드 등)에서 다운로드 받으려고 합니다.
이때 임시 파일을 사용하지 않으면 두 파일이 같은 이름이기에 당신이 갖고 있던 보고서가 덮어쓰기 될 것입니다. 그런데 덮어쓰는 과정에서 오류가 발생한다면, 온전한 보고서가 한 개도 남지 않게 됩니다.
그러므로 당신은 촉박한 시간 속에서 이전 버전의 보고서를 제출할 기회조차 사라지게 됩니다.
사실 이것은 파일 이름 정책과도 관련이 있습니다. 꼭 임시파일을 사용하지 않더라도 같은 이름의 파일이 있을 경우 이름 뒤에 (1), (2)를 붙여 덮어쓰지 않고 새로운 파일로 저장할 수 있습니다.
또 임시 파일을 사용하면 이 파일이 정상적으로 완성된 파일이 아닌, 미완성 파일이라는 것을 드러낼 수 있습니다.
예를 들어 당신이 비행기를 타기 전, 비행기에서 볼 영상을 다운로드 받다가 실패했습니다.
이때 다운로드한 파일명이 movie.mp4.part가 아닌 movie.mp4라면 당신은 다운로드가 실패한 것을 모른채 비행기에 탑승할 것입니다.
그리고 비행기에서 영상 시청 도중에 영상이 끊기는 것을 경험할 것입니다.
그러므로 우리는 임시 파일 확장자를 사용하여 다운로드 결과와 파일 상태를 나타낼 수 있습니다.
자, 이제 임시 파일로 다운로드 받고, 다운로드가 완료되면 원래 파일로 이름을 변경하며, 실패 시 임시 파일을 삭제하는 로직을 작성해 봅시다.
public boolean download(String urlStr, Path path, int byteKB) throws IOException {
HttpURLConnection conn = null;
Path part = Paths.get(path + ".part");
try {
conn = (HttpURLConnection) new URL(urlStr).openConnection();
conn.setConnectTimeout(5_000);
conn.setReadTimeout(5_000);
if (conn.getResponseCode() != 200) {
Files.deleteIfExists(part);
return false;
}
try (
InputStream in = conn.getInputStream();
OutputStream out = Files.newOutputStream(part, StandardOpenOption.CREATE);
) {
byte[] buffer = new byte[byteKB * 1024];
int n;
while ((n = in.read(buffer)) != -1) {
out.write(buffer, 0, n);
}
}
Files.move(part, path, StandardCopyOption.REPLACE_EXISTING);
return true;
} catch (IOException e) {
Files.deleteIfExists(part);
} finally {
if (conn != null) conn.disconnect();
}
return false;
}
추가된 로직은 다음과 같습니다. 하나씩 살펴봅시다.
- 임시 파일 경로인 part 생성
- 임시 파일 경로로 OutputStream 열기
- 다운로드 성공시 move함수 호출해서 기존 파일명으로 수정
- catch 블록 추가 - 예외 발생 시 catch문에서 임시 파일 삭제
기존 파일 경로에 .part 임시 파일 확장자를 붙인 새로운 경로를 생성합니다.
Path part = Paths.get(path + ".part");
기존과 다르게 OutputStream을 임시 파일 경로로 엽니다.
OutputStream out = Files.newOutputStream(part, StandardOpenOption.CREATE, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING);
다운로드 성공 시 move 함수를 호출합니다.
Files.move(part, path, StandardCopyOption.REPLACE_EXISTING);
move 함수는 이름을 변경하거나 파일을 이동(copy + delete)합니다. 현재 로직에서는 이름을 변경합니다.
자세한 내용은 공식문서를 읽어봅시다.
다운로드 실패 시 임시 파일을 삭제해줘야 하므로 catch 블록에서 임시 파일을 삭제합니다.
} catch (IOException e) {
Files.deleteIfExists(part);
}
위 코드를 실행해보면 다운로드 중에는 .part 확장자로 된 파일이 보여지다 다운로드가 완료되면 대상 파일명으로 변경되는 것을 볼 수 있습니다.
재시도 정책
이제 우리는 다운로드 실패 시 디바이스에 미완성 파일을 남기지 않고 삭제할 수 있게 됐습니다!
이대로도 괜찮지만, 다른 추가 기능을 떠올려볼 수 있습니다.
우리는 방금 인터넷이 끊겨 다운로드가 실패했을 때 임시 파일을 삭제하는 정책을 추가했습니다.
그런데 만약 파일을 오래걸려서 90%까지 다운로드 받았는데 잠시 인터넷이 끊겼다고 해서, 다운로드가 취소되고 처음부터 다시 받아야 한다면 비효율적이고 짜증나지 않을까요?
그럼 일시적인 다운로드 실패를 복구하기 위해선 어떻게 할 수 있을까요?
제가 떠올린 방법은 재시도 정책과 HTTP Range 헤더를 활용하는 것입니다!
먼저 재시도 정책이란 다운로드가 실패하면 성공할 때까지 설정된 횟수만큼 재시도하는 것입니다.
다운로드에 성공하면 종료, 실패하면 재시도 합니다.
설정된 횟수만큼 재시도 했음에도 성공하지 못했다면 그때 다운로드 실패로 간주합니다.
하지만 이렇게만 한다면 실패할 때마다 매번 새로운 다운로드를 받아야합니다.
그럼 앞서 언급했던 비효율이 해결되지 않습니다. 이때 HTTP Range 헤더를 사용합니다.
HTTP Range란 리소스의 일부분만 요청할 수 있게 해주는 헤더입니다.
대용량 파일 다운로드에 아주 재격입니다.
다만 이는 클라이언트와 서버 양쪽에서 모두 지원해야 사용할 수 있습니다.
HTTP Range는 이런 형태를 갖습니다.
Range: bytes=start-end
예를 들어 아래와 같이 Range 헤더를 설정했다면, 0번 바이트부터 499번 바이트까지 보내달라는 요청입니다.
즉, 처음부터 500바이트를 보내달라는 요청입니다. 배열 인덱스라고 생각하면 됩니다.
Range: bytes=0-499
다음과 같이 설정했다면, 500번 바이트부터 끝까지 보내달라는 요청입니다.
Range: bytes=500-
클라이언트에서 이렇게 요청했을 때 서버에서 Range 헤더를 지원한다면 다음과 같이 응답할 수 있습니다.
HTTP/1.1 206 Partial Content
Content-Range: bytes 0-499/1000
Content-Length: 500
우리는 HTTP 상태 코드를 보고 서버의 Range 헤더 지원 여부를 알 수 있습니다.
요청이 성공했을 때 상태 코드는 Range 헤더를 지원한다면 206, 그렇지 않다면 200이 됩니다.
또한 Content-Range를 통해 요청한 리소스의 전체 크기와 서버에서 응답으로 보내줄 범위를 알 수 있습니다.
위 예시는 1000바이트 크기의 파일 중 0에서 499번 바이트(처음부터 500바이트)를 보내겠다는 의미입니다.
일반적으로 Content-Range는 Range 헤더를 지원하지 않는 경우 응답에 포함되지 않습니다.
다음으로 Content-Length는 리소스의 전체 크기가 아닌 이번 요청에 대한 응답 데이터의 길이입니다.
만약 상태 코드가 200이라면 리소스 전체를 전송할테니 (Content-Range가 없고) Content-Length가 리소스 전체 크기가 될 것입니다.
자, 이제 Range 헤더를 이해했으니 실패 시 재시도 로직을 작성해 봅시다!
public boolean download(String urlStr, Path path, int byteKB, int retry) throws IOException, InterruptedException {
HttpURLConnection conn = null;
Path part = Paths.get(path + ".part");
for (int attempt = 0; attempt < retry; attempt++) {
try {
conn = (HttpURLConnection) new URL(urlStr).openConnection();
conn.setConnectTimeout(5_000);
conn.setReadTimeout(5_000);
long downloadedSize = Files.exists(part) ? Files.size(part) : 0L;
if (downloadedSize > 0) {
conn.setRequestProperty("Range", "bytes=" + downloadedSize + "-");
}
if (conn.getResponseCode() != 200 && conn.getResponseCode() != 206) {
Thread.sleep((long) Math.pow(2, attempt) * 1000);
continue;
}
if (conn.getResponseCode() != 206) {
Files.deleteIfExists(part);
}
try (
InputStream in = conn.getInputStream();
OutputStream out = Files.newOutputStream(part, StandardOpenOption.CREATE, StandardOpenOption.APPEND);
) {
byte[] buffer = new byte[byteKB * 1024];
int n;
while ((n = in.read(buffer)) != -1) {
out.write(buffer, 0, n);
}
}
Files.move(part, path, StandardCopyOption.REPLACE_EXISTING);
return true;
} catch (IOException e) {
Thread.sleep((long) Math.pow(2, attempt) * 1000);
} finally {
if (conn != null) conn.disconnect();
}
}
Files.deleteIfExists(part);
return false;
}
추가된 로직은 다음과 같습니다. 하나씩 살펴봅시다.
- 재시도를 위한 포문
- 임시 파일이 존재하는지 확인하고, 존재하면 임시 파일 크기 확인
- 임시 파일 크기가 0보다 크다면 다운로드된 부분이 존재하므로 Range 헤더를 추가
- HTTP 상태코드가 200, 206이 아닐 경우 일정 시간 대기 후 재시도
- HTTP 상태코드가 206이 아닐 경우 기존에 일부를 다운 받은 임시 파일 삭제
- 예외 발생시 일정 시간 대기 후 재시도
재시도 횟수를 retry 매개변수로 받고 요청 실패시 그만큼 재시도 합니다.
for (int attempt = 0; attempt < retry; attempt++) {
임시 파일이 존재하는지 확인하고, 존재하면 임시 파일의 크기를 확인합니다.
long downloadedSize = Files.exists(part) ? Files.size(part) : 0L;
임시 파일의 크기가 0보다 크다면 이전 시도에서 다운 받은 부분이 존재하므로 Range 헤더를 추가해 나머지 부분을 요청합니다.
if (downloadedSize > 0) {
conn.setRequestProperty("Range", "bytes=" + downloadedSize + "-");
}
HTTP 상태 코드가 200과 206이 아니면 요청이 실패했으므로 일정 시간을 대기하고 재시도합니다. (반복문을 continue합니다)
재시도가 반복될 경우 문제를 해결하는데 시간이 더 필요하다고 판단하여, 재시도 횟수에 따라 2의 지수함수 형태로 대기 시간을 증가시킵니다.
if (conn.getResponseCode() != 200 && conn.getResponseCode() != 206) {
Thread.sleep((long) Math.pow(2, attempt) * 1000);
continue;
}
요청이 성공했는데 HTTP 상태코드가 206이 아닐 경우 (200일 경우), 서버 측에서 Range 헤더를 지원하지 않는 것이기 때문에 이전 시도에서 일부가 다운된 임시 파일을 삭제합니다.
if (conn.getResponseCode() != 206) {
Files.deleteIfExists(part);
}
요청 중에 예외가 발생한 경우에도 앞선 경우와 같이 잠시 대기합니다.
} catch (IOException e) {
Thread.sleep((long) Math.pow(2, attempt) * 1000);
}
이렇게 구현을 하고 다운로드를 실험해보면 재시도 정책이 정상적으로 작동하는 것을 볼 수 있습니다.
저 같은 경우 앞서 최초 구현을 테스트할 때 사용했던 URL로 테스트했고 정상 작동하는 것을 확인했습니다.
저는 다음과 같은 방법으로 테스트해봤습니다.
우선 1차 시도에서 다운로드 중에 네트워크를 꺼버립니다. 그럼 설정한 ReadTimeout(여기선 5초) 후에 예외가 발생하고, 스레드가 잠시 sleep했다가 재시도할 것입니다. 이때 네트워크를 다시 연결하면 재시도에서 남은 부분을 다운로드 받고 전체 다운로드가 완료되는 것을 확인할 수 있습니다.
Range 헤더가 정상 작동하는지 확인하기 위해 각 시도에서 read 함수의 반환값으로 초기화 한 n을 누적해서 각 시도에 다운로드한 크기를 구합니다. 해당 값을 로깅해서 1차 시기와 재시도 시기에 다운로드된 값을 구하고 두 값을 더했을 때 파일의 전체 크기와 같다면 Range 헤더가 정상적으로 작동한 것임을 알 수 있습니다.
예를 들어 100MB 짜리 파일을 다운 받는데 1차 시기에 40MB, 재시도 시기에 60MB를 다운받고 다운이 완료됐다면 Range 헤더가 정상적으로 작동한 것을 알 수 있습니다. 앞서 사용한 speedtest.tele2 사이트와 구글 드라이브 모두 Range 헤더를 지원함을 확인했습니다.
취소 정책
좋다 이제 다운로드 기능이 꽤 근사해졌습니다. 그러나 아직 한 가지 중요한 것이 빠졌습니다.
만약 사용자가 다운로드 버튼을 잘못 눌렀다면? 다운로드 받는데 컴퓨터 리소스나 통신 비용을 지불하고 싶지 않을 것입니다.
그러므로 원할 때 취소할 수 있는 기능이 필요합니다.
취소 정책은 간단하게 플래그 값을 이용해서 구현했습니다.
우리가 작성한 다운로드 함수가 속한 클래스의 필드로 취소 플래그 값을 추가합니다.
플래그 값이 가시성 문제를 일으키지 않도록 volatile로 선언했습니다.
private volatile boolean isCancelled = false;
그리고 플래그 값을 변경할 수 있는 함수도 작성합니다.
이제 다운로더의 사용자는 이 함수를 호출하면 다운로드를 취소할 수 있습니다.
public void cancel() {
isCancelled = true;
}
그럼 다운로드 중에 플래그 값을 확인하고 중단하는 과정이 필요하겠죠?
다음처럼 로직 중간에 플래그 값을 확인하고 만약 true라면 다시 false로 초기화시킨 후 다운로드 함수를 종료합니다.
또한 지금까지 다운로드한 임시 파일을 삭제합니다.
if(isCancelled) {
isCancelled = false;
Files.deleteIfExists(part);
return false;
}
저는 취소 로직을 다운로드 함수에서 세 군데에 적용되었습니다.
- 각 시도가 시작된 직후
- HTTP 헤더를 수신하여 상태 코드를 확인한 직후
- 수신 윈도우로부터 데이터를 읽어올 때마다
public boolean download(String urlStr, Path path, int byteKB, int retry) throws IOException, InterruptedException {
HttpURLConnection conn = null;
Path part = Paths.get(path + ".part");
for (int attempt = 0; attempt < retry; attempt++) {
if(isCancelled) {
isCancelled = false;
Files.deleteIfExists(part);
return false;
}
try {
conn = (HttpURLConnection) new URL(urlStr).openConnection();
conn.setConnectTimeout(5_000);
conn.setReadTimeout(5_000);
long downloadedSize = Files.exists(part) ? Files.size(part) : 0L;
if (downloadedSize > 0) {
conn.setRequestProperty("Range", "bytes=" + downloadedSize + "-");
}
if (conn.getResponseCode() != 200 && conn.getResponseCode() != 206) {
Thread.sleep((long) Math.pow(2, attempt) * 1000);
continue;
}
if (conn.getResponseCode() != 206) {
Files.deleteIfExists(part);
}
if(isCancelled) {
isCancelled = false;
Files.deleteIfExists(part);
return false;
}
try (
InputStream in = conn.getInputStream();
OutputStream out = Files.newOutputStream(part, StandardOpenOption.CREATE, StandardOpenOption.APPEND);
) {
byte[] buffer = new byte[byteKB * 1024];
int n;
while ((n = in.read(buffer)) != -1) {
if(isCancelled) {
isCancelled = false;
Files.deleteIfExists(part);
return false;
}
out.write(buffer, 0, n);
}
}
Files.move(part, path, StandardCopyOption.REPLACE_EXISTING);
return true;
} catch (IOException e) {
Thread.sleep((long) Math.pow(2, attempt) * 1000);
} finally {
if (conn != null) conn.disconnect();
}
}
Files.deleteIfExists(part);
return false;
}
제가 이렇게 세 군데에서 플래그 값을 확인한 이유는 다음과 같습니다.
각 시도가 시작된 직후
재시도를 위한 대기 시간 동안 사용자가 취소를 누른 경우 대기가 끝난 후 플래그 값을 확인할 수 있는 가장 빠른 순간이기 때문입니다.
나머지 두 군데는 각 시도의 네트워크 통신이 시작한 후에 사용자가 취소를 누르게 된 경우입니다.
HTTP 헤더를 수신하여 상태 코드를 확인한 직후
TCP 연결을 수립하고 헤더를 수신하는 것까지 성공했으나, 본격적인 데이터 다운로드 전에 취소하는 경우입니다.
바디가 우리가 다운로드 하고자하는 파일이므로 훨씬 용량이 크기 때문에 바디를 읽어오기 전에 한 번 확인해줍니다.
수신 윈도우로부터 데이터를 읽어올 때마다
바디의 수신을 시작한 후에도, 바디의 용량이 크다면 (파일이 크다면) 전부 수신하는데 시간이 오래걸리므로 수신 윈도우로부터 데이터를 읽어올 때마다 확인합니다. 그러므로 바디를 수신 중에도 얼마든지 취소할 수 있습니다.
위 코드를 테스트해보면 취소가 잘 되는 것을 확인할 수 있습니다.
저는 UI를 구현하지 않았기 때문에 다운로드를 시작하기 전 다른 스레드로 3초 후 cancel 함수를 호출하도록 하고 다운로드 함수를 호출하여 테스트했습니다.
하지만 방금 작성한 취소 로직에는 사실 치명적인 문제가 있습니다.
- 사용자가 하나의 Downloader 인스턴스로 여러번의 download 함수를 호출할 수 있는데, 이때 cancel을 호출하면 각 다운로드를 구분하지 못하고 모두 취소됩니다.
- 사용자가 다운로드 중이 아닐 때 cancel을 호출하면 플래그 값이 true로 변경되고, 이후에 download 함수를 호출했을 때 의도치 않게 다운로드가 취소됩니다.
위 문제들을 해결하기 위해서 간단하게 방법을 떠올려본다면, 다음과 같이 해결해볼 수 있습니다.
현재 진행중인 다운로드들을 구분하기 위해 Downloader 내부에서 각 다운로드의 URL을 Set으로 관리합니다.
download 함수가 호출되었을 때 이미 Set에 해당 URL가 존재한다면 다운로드를 시작하지 않습니다.
private final Set<String> downloadingUrls = ConcurrentHashMap.newKeySet();
private final Set<String> cancelledUrls = ConcurrentHashMap.newKeySet();
또한 취소된 URL을 관리하기 위한 Set을 Downloader 내부에 갖습니다.
cancel 함수 호출 시 URL을 매개변수로 함께 넘겨 취소할 다운로드를 특정하고, 해당 URL이 다운로드 중이라면 해당 Set에 추가합니다.
public void cancel(String url) {
if(!downloadingUrls.contains(url)) return;
cancelledUrls.add(url);
}
다음으로 기존에 플래그 값을 확인하던 조건문을 변경합니다.
if(isCancelled) { // 기존 코드
isCancelled = false;
Files.deleteIfExists(part);
return false;
}
cancelledUrl을 확인하고 현재 다운로드 URL이 존재하면 해당 URL을 cancelledUrls와 downloadingUrls에서 해당 URL 삭제합니다.
if(cancelledUrls.contains(urlStr)) { // 변경된 코드
cancelledUrls.remove(urlStr);
downloadingUrls.remove(urlStr);
Files.deleteIfExists(part);
return false;
}
이렇게 하면 앞서 살펴본 문제들을 해결했으며 취소 기능이 얼추 쓸만하게 구현된 것 같습니다.
다운로드 진행 정도 확인 정책
이제 진짜 마지막입니다.
사용자는 오래 걸리는 다운로드를 하다보면 다운로드가 어느 정도 진행되었는지 궁금할 수 있습니다.
그러므로 파일을 다운로드하는 동안 전체 파일 크기 중 어느 정도 다운로드 했는지를 사용자에게 보여주려고 합니다.
이것을 위해선 우선 파일의 전체 크기를 알아야합니다. 어떻게 확인할까요?
이것은 위에서 한 번 언급했습니다. 바로 Content-Range 혹은 Content-Length를 이용하는 것입니다.
전체 파일 크기만 알면 그 이후 구현은 간단합니다.
private static final String CONTENT_RANGE = "Content-Range";
private long getTotalSize(HttpURLConnection conn) {
String rangeHeader = conn.getHeaderField(CONTENT_RANGE);
if(rangeHeader != null) {
return Long.parseLong(rangeHeader.split("/")[1]);
}
return conn.getContentLengthLong(); // Content-Length 값을 가져오는 함수
}
위 코드가 이해가 안된다면 앞서 재시도 정책에서 Range에 대해 설명했던 부분을 다시 읽어보면 좋습니다.
다운로드 함수의 매개변수로 진행 정도를 알기 위한 progressListener 매개변수가 추가되었습니다.
public boolean download(
String urlStr,
Path path,
int byteKB,
int retry,
BiConsumer<Long, Long> progressListener // 추가됨
) throws IOException, InterruptedException { ... }
그리고 다음 부분에서 progressListener를 사용합니다.
long totalSize = getTotalSize(conn); // 전체 크기 가져오기
try (
InputStream in = conn.getInputStream();
OutputStream out = Files.newOutputStream(part, StandardOpenOption.CREATE, StandardOpenOption.APPEND);
) {
byte[] buffer = new byte[byteKB * 1024];
int n;
while ((n = in.read(buffer)) != -1) {
if(cancelledUrls.contains(urlStr)) {
cancelledUrls.remove(urlStr);
downloadingUrls.remove(urlStr);
Files.deleteIfExists(part);
return false;
}
out.write(buffer, 0, n);
downloadedSize += n; // 다운로드된 크기 업데이트
if(totalSize > 0) {
progressListener.accept(totalSize, downloadedSize); // 진행 정도 알림
}
}
}
앞서 Range 헤더의 사용 여부를 결정할 때 사용한 downloadedSize 변수를 여기서 활용합니다.
데이터를 읽어올 때마다 임시 파일의 크기를 나타내던 downloadedSize 변수에 읽어온 데이터 크기를 더합니다.
서버에서 Content-Length를 제공하지 않는 경우 -1이 반환되므로 totalSize가 0보다 큰 경우에만 진행 정도를 알립니다.
다음과 같이 download 함수를 호출하면 진행률을 체크할 수 있습니다.
boolean ok = downloader.download(
"http://speedtest.tele2.net/100MB.zip", // 다운로드할 파일의 url
downloadPath, // 파일이 저장될 경로
32, // 버퍼 사이즈(KB)
5, // 재시도 횟수
(total, cur) -> System.out.println(cur * 100.0 / total + "% 다운됨") // progressListener
);
완성 코드와 감상평
완성된 코드는 다음과 같습니다. 이 코드를 사용하면 이제 대용량 파일 다운로드는 뚝딱 해낼 수 있을 것 같습니다.
그러나...
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.file.*;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.BiConsumer;
public class Downloader {
private static final String CONTENT_RANGE = "Content-Range";
private final Set<String> downloadingUrls = ConcurrentHashMap.newKeySet();
private final Set<String> cancelledUrls = ConcurrentHashMap.newKeySet();
public boolean download(String urlStr, Path path, int byteKB, int retry, BiConsumer<Long, Long> progressListener) throws IOException, InterruptedException {
if(!downloadingUrls.add(urlStr)) {
return false;
}
HttpURLConnection conn = null;
Path part = Paths.get(path + ".part");
for (int attempt = 0; attempt < retry; attempt++) {
if(cancelledUrls.contains(urlStr)) {
cancelledUrls.remove(urlStr);
downloadingUrls.remove(urlStr);
Files.deleteIfExists(part);
return false;
}
try {
conn = (HttpURLConnection) new URL(urlStr).openConnection();
conn.setConnectTimeout(5_000);
conn.setReadTimeout(5_000);
long downloadedSize = Files.exists(part) ? Files.size(part) : 0L;
if (downloadedSize > 0) {
conn.setRequestProperty("Range", "bytes=" + downloadedSize + "-");
}
if (conn.getResponseCode() != 200 && conn.getResponseCode() != 206) {
Thread.sleep((long) Math.pow(2, attempt) * 1000);
continue;
}
if (conn.getResponseCode() != 206) {
Files.deleteIfExists(part);
}
if(cancelledUrls.contains(urlStr)) {
cancelledUrls.remove(urlStr);
downloadingUrls.remove(urlStr);
Files.deleteIfExists(part);
return false;
}
long totalSize = getTotalSize(conn);
try (
InputStream in = conn.getInputStream();
OutputStream out = Files.newOutputStream(part, StandardOpenOption.CREATE, StandardOpenOption.APPEND);
) {
byte[] buffer = new byte[byteKB * 1024];
int n;
while ((n = in.read(buffer)) != -1) {
if(cancelledUrls.contains(urlStr)) {
cancelledUrls.remove(urlStr);
downloadingUrls.remove(urlStr);
Files.deleteIfExists(part);
return false;
}
out.write(buffer, 0, n);
downloadedSize += n;
if(totalSize > 0) {
progressListener.accept(totalSize, downloadedSize);
}
}
}
Files.move(part, path, StandardCopyOption.REPLACE_EXISTING);
return true;
} catch (IOException e) {
Thread.sleep((long) Math.pow(2, attempt) * 1000);
} finally {
downloadingUrls.remove(urlStr);
if (conn != null) conn.disconnect();
}
}
Files.deleteIfExists(part);
return false;
}
private long getTotalSize(HttpURLConnection conn) {
String rangeHeader = conn.getHeaderField(CONTENT_RANGE);
if(rangeHeader != null) {
return Long.parseLong(rangeHeader.split("/")[1]);
}
return conn.getContentLengthLong();
}
public void cancel(String url) {
if(!downloadingUrls.contains(url)) return;
cancelledUrls.add(url);
}
}
download 함수가 너무 길고, 의미를 파악하기 어려우며, 확장성도 떨어지지 않나요?
변경이 발생할 때마다 수정해야할 것만 같습니다. 이 함수를 각기 다른 목적을 가진 여러 사용자들이 사용할 수 있을까요? 혹은 여러 플랫폼(운영체제)에서 사용될 수 있을까요?
그러므로 다음 글은 지금까지 구현한 내용을 객체지향적으로 리팩토링하는 것을 주제로 작성해보겠습니다.
다음 글에서 만나요!
'학습' 카테고리의 다른 글
대용량 파일 다운로드 구현하기 - 1편 (0) | 2025.05.13 |
---|---|
BitSet을 아시나요? (백준 2458 키 순서) (1) | 2025.04.13 |
Jetpack Compose의 State와 MutableState 2편 (부제: mutableStateOf의 내부 구현은?) (0) | 2025.02.14 |
Jetpack Compose의 State와 MutableState 1편 (부제: RecomposeScope) (0) | 2025.01.26 |
RecyclerView Drag&Drop, Swipe 1편 (3) | 2025.01.16 |