μŠ€ν”„λ§ ν”„λ ˆμž„μ›Œν¬ 기반의 μ›Ή μ• ν”Œλ¦¬μΌ€μ΄μ…˜μ—μ„œ 파일 λ‹€μš΄λ‘œλ“œ 예제λ₯Ό 찾아보면 λŒ€λΆ€λΆ„μ€ μ„œλΈ”λ¦Ώ μŠ€νƒμ˜ HttpServletResponse의 OutputStream 에 파일의 λ‚΄μš©μ„ μ“°λŠ” λ°©μ‹μœΌλ‘œ μ„€λͺ…ν•˜λŠ” κ²½μš°κ°€ λ§Žλ‹€. κ·ΈλŸ¬λ‚˜, μŠ€ν”„λ§ ν”„λ ˆμž„μ›Œν¬μ—μ„œλŠ” λ°”μ΄νŠΈ μ²˜λ¦¬μ— λŒ€ν•œ 좔상화가 λ˜μ–΄μžˆκΈ° λ•Œλ¬Έμ— 더 쉽고 κ°„κ²°ν•œ 파일 λ‹€μš΄λ‘œλ“œ 예제 μ½”λ“œλ₯Ό μž‘μ„±ν•  수 μžˆλ‹€. 이리저리 찾아보며 ν™œμš©ν•  수 μžˆλŠ” ν΄λž˜μŠ€λ“€μ„ 톡해 μ•„λž˜μ™€ 같이 μ½”λ“œλ₯Ό μž‘μ„±ν•΄λ³΄μ•˜λ‹€.

FileController.java
import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.Resource; import org.springframework.http.ContentDisposition; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; import java.io.IOException; import java.nio.charset.StandardCharsets; @RestController public class FileController { @GetMapping("/files/sample.csv") public ResponseEntity<byte[]> download() throws IOException { Resource resource = new ClassPathResource("sample/file.csv"); byte[] bytes = resource.getContentAsByteArray(); // NOTE: Use FileCopyUtils.copyToByteArray ContentDisposition contentDisposition = ContentDisposition.attachment() .filename("ν•œκΈ€νŒŒμΌλͺ….csv", StandardCharsets.UTF_8) .build(); return ResponseEntity.ok() .header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition.toString()) .contentType(MediaType.APPLICATION_OCTET_STREAM) .body(bytes); } }
src/main/resources/templates/index.html
<!DOCTYPE html> <html> <body> <a href="/files/sample.csv">sample.csv</a> </body> </html>

Resource 와 ResponseEntity

ResponseEntity<Resource>도 κ°€λŠ₯ν•˜μ§€λ§Œ 더 λͺ…ν™•ν•œ ν‘œν˜„μ„ μœ„ν•΄μ„œ byte[]λ₯Ό λͺ…μ‹œν•˜μ˜€λ‹€. ResponseEntityλ₯Ό μ“΄ μ΄μœ λŠ” Content-Disposition 헀더λ₯Ό 톡해 파일λͺ…을 μ§€μ •ν•˜κ³  일반적인 λ°”μ΄λ„ˆλ¦¬ 응닡을 μ˜λ―Έν•˜λ„λ‘ application/octet-stream 을 μ„€μ •ν•˜κΈ° μœ„ν•΄μ„œμ΄λ‹€. HttpServletResponseλ₯Ό ν•Έλ“€λŸ¬ ν•¨μˆ˜μ— νŒŒλΌλ―Έν„°λ‘œ λ°›μ•„μ„œ μ‚¬μš©ν•  μˆ˜λ„ μžˆμ§€λ§Œ ꡳ이 ν•„μš”ν•˜μ§€ μ•ŠμŒμ„ 보여쀀닀.

FileCopyUtils.copyToByteArray

μŠ€ν”„λ§ ν”„λ ˆμž„μ›Œν¬ 6 λΆ€ν„°λŠ” μ˜€λž˜μ „λΆ€ν„° μ œκ³΅ν•˜λ˜ FileCopyUtils.copyToByteArray λ₯Ό μ‚¬μš©ν•˜μ—¬ Resourceλ₯Ό λ°”μ΄νŠΈ λ°°μ—΄λ‘œ λ°”κΎΈλŠ” ν•¨μˆ˜λ₯Ό μ œκ³΅ν•œλ‹€. λ§Œμ•½, μŠ€ν”„λ§ ν”„λ ˆμž„μ›Œν¬ 5 μ΄ν•˜μ˜ 버전이라면 Resource의 InputStream을 κ°€μ Έμ™€μ„œ FileCopyUtils.copyToByteArrayλ₯Ό 직접 μ΄μš©ν•˜λ©΄ λœλ‹€.

ContentDisposition

일뢀 μ˜ˆμ œμ—μ„œλŠ” Content-Disposition 헀더λ₯Ό μ§€μ •ν•˜κΈ° μœ„ν•΄μ„œ λ¬Έμžμ—΄μ„ μž…λ ₯ν•˜λŠ” 것을 λ³Ό 수 μžˆλ‹€. 잘λͺ»λœ 것은 μ•„λ‹ˆμ§€λ§Œ μ‚¬λžŒμ΄ μž…λ ₯ν•˜λŠ”λ° μ‹€μˆ˜λ₯Ό ν•  수 있기 λ•Œλ¬Έμ— μŠ€ν”„λ§ ν”„λ ˆμž„μ›Œν¬μ— ν¬ν•¨λœ ContentDisposition 클래슀λ₯Ό μ΄μš©ν•΄μ„œ μ‹€μˆ˜λ₯Ό 방지할 수 μžˆλ‹€. 심지어 ν•œκΈ€λ‘œ 된 파일λͺ…을 μ§€μ •ν•˜κΈ° μœ„ν•΄μ„œλŠ” URLEncoderλ₯Ό μ‚¬μš©ν•΄μ•Όν•˜λŠ”λ° ContentDisposition 클래슀 λ‚΄λΆ€μ μœΌλ‘œ RFC 6266 와 RFC 2047 에 따라 URL 인코딩을 μˆ˜ν–‰ν•˜λ―€λ‘œ 이에 λŒ€ν•œ 과정도 μƒλž΅ν•  수 μžˆλ‹€.


μ–΄λ–€κ°€μš”? μ—¬λŸ¬λΆ„μ΄ μž‘μ„±ν•œ μ½”λ“œλ³΄λ‹€ κ°„κ²°ν•΄μ‘Œλ‚˜μš”? Downloading a file from spring controllers μ—μ„œ 더 λ§Žμ€ 예제 μ½”λ“œλ₯Ό 확인할 수 μžˆμŠ΅λ‹ˆλ‹€.


λŒ€μš©λŸ‰ 파일 λ‹€μš΄λ‘œλ“œ

일반적인 파일 λ‹€μš΄λ‘œλ“œλŠ” μœ„μ™€ 같이 λ°”μ΄νŠΈ 배열을 μ‘λ‹΅ν•˜μ—¬ μ²˜λ¦¬ν•  수 μžˆμ§€λ§Œ μš©λŸ‰μ΄ 큰 νŒŒμΌμ„ λ‹€μš΄λ‘œλ“œν•΄μ•Όν•˜λŠ” 경우라면 μ• ν”Œλ¦¬μΌ€μ΄μ…˜ λ©”λͺ¨λ¦¬μ— 뢀담이 μžˆμ„ 수 μžˆλ‹€. 이 경우 StreamingResponseBodyλ₯Ό ν™œμš©ν•˜μ—¬ μ•„λž˜μ™€ 같이 νŒŒμΌμ„ μŠ€νŠΈλ¦¬λ°ν•  수 μžˆλ‹€.

@RestController
public class FileController {
    @GetMapping("/files/sample.mp4")
    public ResponseEntity<StreamingResponseBody> download() {
        Resource resource = new ClassPathResource("sample/sample.mp4");
        StreamingResponseBody responseBody = output ->
                StreamUtils.copy(resource.getInputStream(), new BufferedOutputStream(output)); // NOTE: 8192 bytes.
        ContentDisposition contentDisposition = ContentDisposition.attachment()
                .filename("λ™μ˜μƒ.mp4", StandardCharsets.UTF_8)
                .build();
        return ResponseEntity.ok()
                .header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition.toString())
                .contentType(MediaType.APPLICATION_OCTET_STREAM)
                .body(responseBody);
    }
}

λ™μ˜μƒ 슀트리밍

κ°„λ‹¨ν•œ λ™μ˜μƒ μŠ€νŠΈλ¦¬λ°μ„ μ œκ³΅ν•˜κ³  싢은 경우 ResourceRegion 을 μ‚¬μš©ν•˜μ—¬ λ™μ˜μƒ ν”Œλ ˆμ΄μ–΄μ—μ„œ 전체 νŒŒμΌμ„ λ‹€μš΄λ‘œλ“œ 받지 μ•Šμ•„λ„ μ›ν•˜λŠ” μœ„μΉ˜λΆ€ν„° λ‹€μš΄λ‘œλ“œ 받을 수 μžˆλ„λ‘ μ²˜λ¦¬ν•  수 μžˆλ‹€. ν•˜μ§€λ§Œ, λŒ€λΆ€λΆ„μ˜ 슀트리밍 μ‚¬μ΄νŠΈμ˜ 경우 λ™μ˜μƒ νŒŒμΌμ„ 잘게 μͺΌκ°œν•΄λ‘κ³  CDN으둜 μ²˜λ¦¬ν•˜λŠ” 것 κ°™λ‹€.

@RestController
public class FileController {
    @GetMapping("/sample.mp4")
    public ResponseEntity<List<ResourceRegion>> streamingVideo(@RequestHeader HttpHeaders headers) throws MalformedURLException {
        Resource resource = new UrlResource("https://sample-videos.com/video321/mp4/720/big_buck_bunny_720p_30mb.mp4");
        List<ResourceRegion> resourceRegions = HttpRange.toResourceRegions(headers.getRange(), resource);
        return ResponseEntity.status(HttpStatus.PARTIAL_CONTENT)
                .header(HttpHeaders.ACCEPT_RANGES, "bytes")
                .contentType(MediaType.parseMediaType("video/mp4"))
                .body(resourceRegions);
    }

    @GetMapping("/files/sample.stream")
    public ResponseEntity<Resource> streamingVideo() throws MalformedURLException {
        Resource resource = new UrlResource("https://sample-videos.com/video321/mp4/720/big_buck_bunny_720p_30mb.mp4");
        return ResponseEntity.ok().body(resource);
    }
}

λ™μ˜μƒ μŠ€νŠΈλ¦¬λ°μ— λŒ€ν•΄μ„œλŠ” AbstractMessageConverterMethodProcessor에 κ΅¬ν˜„λ˜μ–΄ μžˆμ–΄μ„œ InputStreamResourceκ°€ μ•„λ‹ˆλΌλ©΄ HttpRangeλ₯Ό 직접 μ‚¬μš©ν•˜μ§€ μ•Šμ•„λ„ μ•Œμ•„μ„œ μ²˜λ¦¬λœλ‹€κ³  ν•œλ‹€.