1. 개요

이 사용방법(예제)에서는 파일을 다운로드하는 데 사용할 수 있는 몇 가지 방법을 살펴보겠습니다.

Java IO의 기본 사용법부터 NIO 패키지, AsyncHttpClient 및 Apache Commons IO와 같은 일부 공통 라이브러리에 이르는 예제를 다룹니다.

마지막으로 전체 파일을 읽기 전에 연결이 실패할 경우 다운로드를 재개할 수 있는 방법에 대해 설명합니다.

2. 자바 IO 사용하기

파일을 다운로드하는 데 사용할 수 있는 가장 기본적인 API는 Java IO 입니다. URL  클래스를 사용 하여 다운로드하려는 파일에 대한 연결을 열 수 있습니다.

파일을 효과적으로 읽기 위해  openStream() 메서드를 사용하여 InputStream 을 얻습니다 .

BufferedInputStream in = new BufferedInputStream(new URL(FILE_URL).openStream())

InputStream 에서 읽을 때  성능을 높이려면 BufferedInputStream 으로 래핑하는 것이 좋습니다  .

성능 향상은 버퍼링에서 비롯됩니다. read() 메서드 를 사용하여 한 번에 한 바이트를 읽을 때 각 메서드 호출은 기본 파일 시스템에 대한 시스템 호출을 의미합니다. JVM이 read()  시스템 호출을 호출하면 프로그램 실행 컨텍스트가 사용자 모드에서 커널 모드로 전환했다가 다시 전환됩니다.

이 컨텍스트 전환은 성능 관점에서 비용이 많이 듭니다. 많은 수의 바이트를 읽을 때 관련된 많은 컨텍스트 전환으로 인해 애플리케이션 성능이 저하됩니다.

URL에서 읽은 바이트를 로컬 파일에 쓰기 위해 FileOutputStream  클래스 의 write() 메서드를  사용합니다.

try (BufferedInputStream in = new BufferedInputStream(new URL(FILE_URL).openStream());
  FileOutputStream fileOutputStream = new FileOutputStream(FILE_NAME)) {
    byte dataBuffer[] = new byte[1024];
    int bytesRead;
    while ((bytesRead = in.read(dataBuffer, 0, 1024)) != -1) {
        fileOutputStream.write(dataBuffer, 0, bytesRead);
    }
} catch (IOException e) {
    // handle exception
}

BufferedInputStream사용할 때 read() 메서드는 버퍼 크기에 대해 설정한 만큼의 바이트를 읽습니다. 이 예에서는 한 번에 1024바이트의 블록을 읽음으로써 이미 이 작업을 수행하고 있으므로  BufferedInputStream  이 필요하지 않습니다.

위의 예는 매우 장황하지만 다행히 Java 7부터 IO 작업을 처리하기 위한 도우미 메서드가 포함 된 Files 클래스가 있습니다.

Files.copy() 메서드를 사용 하여 InputStream 에서 모든 바이트를 읽고 로컬 파일에 복사할 수 있습니다.

InputStream in = new URL(FILE_URL).openStream();
Files.copy(in, Paths.get(FILE_NAME), StandardCopyOption.REPLACE_EXISTING);

우리의 코드는 잘 작동하지만 개선할 수 있습니다. 주요 단점은 바이트가 메모리에 버퍼링된다는 사실입니다.

다행히 Java는 버퍼링 없이 채널 간에 직접 바이트를 전송하는 방법이 있는 NIO 패키지를 제공합니다.

다음 섹션에서 자세히 살펴보겠습니다.

3. 니오 사용하기

Java NIO 패키지 는 애플리케이션 메모리에 버퍼링하지 않고 채널 간에 바이트를 전송할 수 있는 가능성을 제공합니다.

URL에서 파일을 읽기 위해 URL  스트림  에서 새 ReadableByteChannel 을 만듭니다 .

ReadableByteChannel readableByteChannel = Channels.newChannel(url.openStream());

ReadableByteChannel 에서 읽은 바이트  는 다운로드할 파일에 해당 하는 FileChannel  로 전송됩니다 .

FileOutputStream fileOutputStream = new FileOutputStream(FILE_NAME);
FileChannel fileChannel = fileOutputStream.getChannel();

ReadableByteChannel 클래스의 transferFrom() 메서드를  사용  하여 주어진 URL에서 FileChannel 로 바이트를 다운로드합니다 .

fileOutputStream.getChannel()
  .transferFrom(readableByteChannel, 0, Long.MAX_VALUE);

transferTo ( )transferFrom() 메서드는 버퍼를 사용하여 스트림에서 단순히 읽는 것보다 더 효율적입니다. 기본 운영 체제에 따라 데이터는 애플리케이션 메모리에 바이트를 복사하지 않고 파일 시스템 캐시에서 파일로 직접 전송할 수 있습니다.

Linux 및 UNIX 시스템에서 이러한 방법 은 커널 모드와 사용자 모드 간의 컨텍스트 전환 수를 줄이는 제로 복사 기술을 사용합니다.

4. 라이브러리 사용

Java 핵심 기능을 사용하여 URL에서 콘텐츠를 다운로드하는 방법을 위의 예에서 보았습니다.

또한 성능 조정이 필요하지 않을 때 기존 라이브러리의 기능을 활용하여 작업을 쉽게 할 수 있습니다.

예를 들어 실제 시나리오에서는 다운로드 코드가 비동기식이어야 합니다.

모든 로직을 Callable 로 래핑 하거나 이를 위해 기존 라이브러리를 사용할 수 있습니다.

4.1. AsyncHttpClient

AsyncHttpClient 는 Netty 프레임워크를 사용하여 비동기 HTTP 요청을 실행하는 데 널리 사용되는 라이브러리입니다. 이를 사용하여 파일 URL에 대한 GET 요청을 실행하고 파일 내용을 가져올 수 있습니다.

먼저 HTTP 클라이언트를 만들어야 합니다.

AsyncHttpClient client = Dsl.asyncHttpClient();

다운로드한 콘텐츠는 FileOutputStream 에 저장됩니다 .

FileOutputStream stream = new FileOutputStream(FILE_NAME);

다음으로 HTTP GET 요청을 만들고  다운로드한 콘텐츠를 처리하기 위해 AsyncCompletionHandler 핸들러를 등록합니다.

client.prepareGet(FILE_URL).execute(new AsyncCompletionHandler<FileOutputStream>() {

    @Override
    public State onBodyPartReceived(HttpResponseBodyPart bodyPart) 
      throws Exception {
        stream.getChannel().write(bodyPart.getBodyByteBuffer());
        return State.CONTINUE;
    }

    @Override
    public FileOutputStream onCompleted(Response response) 
      throws Exception {
        return stream;
    }
})

onBodyPartReceived() 메서드  를 재정의했습니다 . 기본 구현은 수신된 HTTP 청크를 ArrayList 에 누적합니다 . 이로 인해 메모리 사용량이 많거나 큰 파일을 다운로드하려고 할 때 OutOfMemory 예외가 발생할 수 있습니다.

각 HttpResponseBodyPart 를 메모리 에 누적하는 대신  FileChannel 을 사용 하여 바이트를 로컬 파일에 직접 씁니다. getBodyByteBuffer() 메서드를 사용  하여 ByteBuffer 를 통해 본문 부분 콘텐츠에 액세스합니다  .

ByteBuffer 는 메모리가 JVM 힙 외부에 할당된다는 이점이 있으므로 애플리케이션 메모리에 영향을 주지 않습니다.

4.2. 아파치 커먼즈 IO

IO 작업에 많이 사용되는 또 다른 라이브러리는 Apache Commons IO 입니다. Javadoc에서 일반 파일 조작 작업에 사용하는 FileUtils 라는 유틸리티 클래스가 있음을 알 수 있습니다.

URL에서 파일을 다운로드하려면 다음 한 줄짜리를 사용할 수 있습니다.

FileUtils.copyURLToFile(
  new URL(FILE_URL), 
  new File(FILE_NAME), 
  CONNECT_TIMEOUT, 
  READ_TIMEOUT);

성능 관점에서 이 코드는 섹션 2의 코드와 동일합니다.

기본 코드는 루프에서 InputStream 에서 일부 바이트를 읽고 이를 OutputStream 에 쓰는 것과 동일한 개념을 사용합니다 .

한 가지 차이점은 여기에서 URLConnection 클래스가 다운로드가 많은 시간 동안 차단되지 않도록 연결 시간 초과를 제어하는 ​​데 사용된다는 것입니다.

URLConnection connection = source.openConnection();
connection.setConnectTimeout(connectionTimeout);
connection.setReadTimeout(readTimeout);

5. 재개 가능한 다운로드

때때로 인터넷 연결이 실패한다는 점을 고려하면 바이트 0에서 파일을 다시 다운로드하는 대신 다운로드를 재개할 수 있는 것이 유용합니다.

이 기능을 추가하기 위해 이전의 첫 번째 예제를 다시 작성해 보겠습니다.

가장 먼저 알아야 할 것은 HTTP HEAD 메서드를 사용하여 실제로 파일을 다운로드하지 않고도 주어진 URL에서 파일 크기를 읽을 수 있다는 것입니다 .

URL url = new URL(FILE_URL);
HttpURLConnection httpConnection = (HttpURLConnection) url.openConnection();
httpConnection.setRequestMethod("HEAD");
long removeFileSize = httpConnection.getContentLengthLong();

이제 파일의 전체 콘텐츠 크기가 있으므로 파일이 부분적으로 다운로드되었는지 확인할 수 있습니다.

그렇다면 디스크에 기록된 마지막 바이트에서 다운로드를 다시 시작합니다.

long existingFileSize = outputFile.length();
if (existingFileSize < fileLength) {
    httpFileConnection.setRequestProperty(
      "Range", 
      "bytes=" + existingFileSize + "-" + fileLength
    );
}

여기 에서 특정 범위의 파일 바이트를 요청 하도록 URLConnection 을 구성했습니다 . 범위는 마지막으로 다운로드한 바이트에서 시작하여 원격 파일의 크기에 해당하는 바이트에서 끝납니다.

Range 헤더 를 사용하는 또 다른 일반적인 방법 은 다른 바이트 범위를 설정하여 파일을 청크로 다운로드하는 것입니다. 예를 들어 2KB 파일을 다운로드하려면 0 – 1024 및 1024 – 2048 범위를 사용할 수 있습니다.

섹션 2의 코드와 다른 미묘한 차이점은 추가 매개변수가 true 로 설정된 FileOutputStream 이 열립니다 .

OutputStream os = new FileOutputStream(FILE_NAME, true);

이 변경을 수행한 후 나머지 코드는 섹션 2의 코드와 동일합니다.

6. 결론

이 기사에서 Java의 URL에서 파일을 다운로드하는 여러 가지 방법을 보았습니다.

가장 일반적인 구현은 읽기/쓰기 작업을 수행할 때 바이트를 버퍼링하는 것입니다. 이 구현은 전체 파일을 메모리에 로드하지 않기 때문에 대용량 파일에도 안전하게 사용할 수 있습니다.

또한 Java NIO 채널 을 사용하여 제로 카피 다운로드를 구현하는 방법도 보았습니다 . 이것은 바이트를 읽고 쓸 때 수행되는 컨텍스트 전환 수를 최소화하고 직접 버퍼를 사용하여 바이트가 응용 프로그램 메모리에 로드되지 않기 때문에 유용합니다.

또한 파일 다운로드는 일반적으로 HTTP를 통해 수행되기 때문에 AsyncHttpClient 라이브러리를 사용하여 이를 달성하는 방법을 보여주었습니다.

기사의 소스 코드는 GitHub에서 사용할 수 있습니다 .

Generic footer banner