파란하늘의 지식창고
반응형

Spring에서 mp4 동영상 같이 용량이 큰 파일을 내려보내 주려면 어떻게 해야 할까?

파일 읽어 들이고 내보내기

요청에 대해 응답으로 데이터를 내보내야 한다.

자바에서는 데이터를 InputStream으로 가져와서 OutputStream으로 내보낸다.

최상위인 InputStream과 OutputStream 추상 클래스를 extends 한 여러 class를 java.io package에서 제공하는데 대략 다음과 같다.

InputStream OutputStream
BufferedInputStream BufferedOutputStream
ByteArrayInputStream ByteArrayOutputStream
DataInputStream DataOutputStream
FileInputStream FileOutputStream
FilterInputStream FilterOutputStream
ObjectInputStream ObjectOutputStream
PipedInputStream PipedOutputStream

java.io package는 위와 같은 Input, Output Stream을 제공하는데 자세한 내용을 알지 못해도 대략적으로 어디선가 읽고 어디론가 내보낸다는 것으로 이해하면 필요할 때 필요한 구현 클래스를 선택하여 사용할 수 있다.

위 목록의 외에도 여러 확장 클래스를 여러 package에서 제공하고 있다.

(이외에도 character를 기반으로 한 Reader와 Writer가 있다.)

Spring MVC에서 응답으로 파일 내보내기

동영상 파일을 읽어서 outputStream으로 내보내는 응답을 위해 Spring은 StreamingResponseBody를 제공한다.

@FunctionalInterface
public interface StreamingResponseBody {

	void writeTo(OutputStream outputStream) throws IOException;

}

StreamingResponseBody를 만들어 반환하면 되는데 대략 다음과 같다.

@RestController
public class TestController {

    @GetMapping(path = "/video")
    public ResponseEntity<StreamingResponseBody> video() {
        File file = new File("C:\\Users\\bluesky\\Downloads\\ForBiggerBlazes.mp4");
        if (!file.isFile()) {
            return ResponseEntity.notFound().build();
        }

        StreamingResponseBody streamingResponseBody = new StreamingResponseBody() {
            @Override
            public void writeTo(OutputStream outputStream) throws IOException {
                try {
                    final InputStream inputStream = new FileInputStream(file);

                    byte[] bytes = new byte[1024];
                    int length;
                    while ((length = inputStream.read(bytes)) >= 0) {
                        outputStream.write(bytes, 0, length);
                    }
                    inputStream.close();
                    outputStream.flush();

                } catch (final Exception e) {
                    log.error("Exception while reading and streaming data {} ", e);
                }
            }
        };

        final HttpHeaders responseHeaders = new HttpHeaders();
        responseHeaders.add("Content-Type", "video/mp4");
        responseHeaders.add("Content-Length", Long.toString(file.length()));

        return ResponseEntity.ok().headers(responseHeaders).body(streamingResponseBody);
    }

}

 

파일을 읽어 FileInputStream을 만든 후 StreamingResponseBody에서 OutputStream을 처리하는 구현을 만든 후 응답하는 예제이다.

lambda expression으로 StreamingResponseBody를 만든다면 다음과 같다.

@RestController
public class TestController {

    @GetMapping(path = "/video")
    public ResponseEntity<StreamingResponseBody> video() {
        File file = new File("C:\\Users\\bluesky\\Downloads\\ForBiggerBlazes.mp4");
        if (!file.isFile()) {
            return ResponseEntity.notFound().build();
        }

        StreamingResponseBody streamingResponseBody = outputStream -> {
            try {
                final InputStream inputStream = new FileInputStream(file);

                byte[] bytes = new byte[1024];
                int length;
                while ((length = inputStream.read(bytes)) >= 0) {
                    outputStream.write(bytes, 0, length);
                }
                inputStream.close();
                outputStream.flush();
            } catch (final Exception e) {
                log.error("Exception while reading and streaming data {} ", e);
            }
        };

        final HttpHeaders responseHeaders = new HttpHeaders();
        responseHeaders.add("Content-Type", "video/mp4");
        responseHeaders.add("Content-Length", Long.toString(file.length()));

        return ResponseEntity.ok().headers(responseHeaders).body(streamingResponseBody);
    }

}

Spring에선 InputStream을 OutputStream으로 만드는 부분에 대해 util을 제공하고 있어 위 예제의 경우 다음과 같이 좀 더 간단하게 만들 수 있다.

@RestController
public class TestController {

    @GetMapping(path = "/video")
    public ResponseEntity<StreamingResponseBody> video() {
        File file = new File("C:\\Users\\bluesky\\Downloads\\ForBiggerBlazes.mp4");
        if (!file.isFile()) {
            return ResponseEntity.notFound().build();
        }

        StreamingResponseBody streamingResponseBody = outputStream -> FileCopyUtils.copy(new FileInputStream(file), outputStream);

        final HttpHeaders responseHeaders = new HttpHeaders();
        responseHeaders.add("Content-Type", "video/mp4");
        responseHeaders.add("Content-Length", Long.toString(file.length()));

        return ResponseEntity.ok().headers(responseHeaders).body(stream);
    }

}

이제까지 ResponseEntity를 반환한 경우의 위와 같지만 Spring의 응답 처리를 Resource로 한 경우는 다음과 같이 단순해진다.

@GetMapping(path = "/video", produces = "video/mp4")
public Resource video() throws FileNotFoundException {
    return new InputStreamResource(new FileInputStream("C:\\Users\\bluesky\\Downloads\\ForBiggerBlazes.mp4"));
}

이렇게 동영상을 응답하면 브라우저에서 해당 동영상을 보여주지만 특정 플레이 위치로 이동하거나 할 수 없고 용량이 일정 크기를 넘어버리면 브라우저가 알아서 초반부터 데이터를 순차적으로 가져온다.

요청 Accept Header의 Range 사용

미디어나 파일 다운로드 같이 용량이 큰 경우 일부만 다운로드할 수 있도록 accept header에 range를 지정할 수 있다.

이렇게 range로 범위를 지정하면 부분 다운로드가 가능하기 때문에 동영상 플레이의 특정 구간으로 이동하거나 파일 다운로드의 경우 이어받기를 하는 등의 구현이 가능해진다.

https://developer.mozilla.org/ko/docs/Web/HTTP/Headers/Accept-Ranges

예를 들어 요청에 다음과 같은 header가 있다면

Range: Bytes=2-4

2부터 4 byte까지의 데이터를 요청하게 되고 전체 데이터 중 이 구간의 데이터를 응답하도록 처리를 할 수 있다.

만약 아래와 같이 동영상이 아닌 단순 byte array를 반환하는 GetMapping이 있다고 가정하면

@RequestMapping(path = "/byteArrayResource")
public Resource byteArrayResource() {
    return new ByteArrayResource(new byte[] {'a', 'b', 'c', 'd', 'e', 'f'});
}

Accept Header의 Range 범위를 지정하여 요청하면 다음과 같은 응답을 확인할 수 있다.

C:\>curl http://localhost:8084/byteArrayResource -i -H "Range: bytes=2-4"
HTTP/1.1 206
Accept-Ranges: bytes
Content-Range: bytes 2-4/6
Content-Type: application/json
Content-Length: 3
Date: Fri, 25 Feb 2022 00:09:47 GMT

cde

Range 설정 없이 요청했다면 "abcdef"가 반환되지만 Range를 설정하여 2-4 구간을 요청하자 응답으로 Content-Range: byte 2-4/6 header와 함께 응답 값으로 "cde"가 반환된 것을 볼 수 있다.

ResourceRegion 사용

앞에 언급한 ByteArrayResource처럼 resource에 대해 부분 요청을 응답하는 것을 지원하기 위해 Spring은 ResourceRegion을 제공한다.

ResourceRegion에 데이터와 시작 위치, 응답 길이를 지정하여 반환하면 해당 영역만큼만 반환하도록 처리한다.

동영상 요청에 accept header Range를 받아 해당 구간을 응답하는 구현을 한다면 다음과 같이 작성할 수 있다.

@GetMapping(path = "/video")
public ResponseEntity<ResourceRegion> video(@RequestHeader HttpHeaders httpHeaders) throws IOException {
    FileUrlResource resource = new FileUrlResource("C:\\Users\\bluesky\\Downloads\\ForBiggerBlazes.mp4");
    ResourceRegion resourceRegion = resourceRegion(resource, httpHeaders);
    return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT)
            .contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM))
            .body(resourceRegion);
}

private ResourceRegion resourceRegion(Resource video, HttpHeaders httpHeaders) throws IOException {
    final long chunkSize = 1000000L;
    long contentLength = video.contentLength();

    if (httpHeaders.getRange().isEmpty()) {
        return new ResourceRegion(video, 0, Long.min(chunkSize, contentLength));
    }

    HttpRange httpRange = httpHeaders.getRange().stream().findFirst().get();
    long start = httpRange.getRangeStart(contentLength);
    long end = httpRange.getRangeEnd(contentLength);
    long rangeLength = Long.min(chunkSize, end - start + 1);
    return new ResourceRegion(video, start, rangeLength);
}

이 부분 또한 Spring이 Range에 대한 설정을 계산하는 메서드를 제공한다.

위의 코드는 다음과 같이 사용할 수 있다.

@GetMapping(path = "/video")
public ResponseEntity<List<ResourceRegion>> video(@RequestHeader HttpHeaders httpHeaders) throws IOException {
    FileUrlResource resource = new FileUrlResource("C:\\Users\\bluesky\\Downloads\\Sample-Video-File-For-Testing.mp4");

    List<ResourceRegion> resourceRegions = HttpRange.toResourceRegions(httpHeaders.getRange(), resource);

    return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT)
            .contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM))
            .body(resourceRegions);
}

응답 처리를 보면 http status는 206 (PARTIAL_CONTENT)이고 mediatype은 application/octet-stream이다.

Range 요청의 경우 다음과 같이 다중 부분 범위 요청이 가능하다.

Range: byte=0-50, 100-150

때문에 응답을 ResourceRegion 반환이 아닌 List<ResourceRegion>으로 작성하는 게 좋다.

ByteArrayResource 사용

여기까지의 긴 예제를 통해 동일한 요청에 대해 ResponseEntity로 개별 지정, Resource 반환 지정, ResponseEntity에 ResourceRegion을 지정하여 부분 반환처리까지 알아보았다.

동영상을 어떻게 부분 응답하는지를 설명하기 위해 기나긴 예제들을 열거하였는데 설명하던 내용 중 ByteArrayResource로 특정 부분만 응답하는 것에 대해 소개하였었다.

Spring은 부분 반환 요청에 대해 return type이 ByteArrayResource인 경우 알아서 응답 값을 부분 반환해주는 처리를 제공한다.

https://github.com/spring-projects/spring-framework/blob/main/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/AbstractMessageConverterMethodProcessor.java#L187

작성한 controller method의 return type이 InputStreamResource가 아닌 Resource type인 경우 요청의 accept header에 Range가 있으면 알아서 http status를 206으로, resource의 부분 응답처리를 해준다.

따라서 동영상 요청에 대한 처리는 다음과 같이 byteArrayResource 반환으로 처리하면 부분 응답까지 처리가 된다.

@GetMapping(path = "/video", produces = MediaType.APPLICATION_OCTET_STREAM_VALUE)
public Resource video() throws FileNotFoundException, IOException {
    return new ByteArrayResource(FileCopyUtils.copyToByteArray(new FileInputStream("C:\\Users\\bluesky\\Downloads\\ForBiggerBlazes.mp4")));
}

일반적으론 html 페이지에서 video태그를 사용해 동영상을 보여주게 된다.

<html>
<body>
...
<video src="/video" width="1280px" height="720px" controls></video>
...
</body>
</html>

 

반응형
profile

파란하늘의 지식창고

@Bluesky_

내용이 유익했다면 광고 배너를 클릭 해주세요