스프링에서 파일 다운로드 처리 대하여 알아보겠습니다.
@Controller
public class DownloadController {
private static final String SAMPLE_FILE_NAME = "스프링.png"; // (1)
@Value("classpath:static/spring.png") // (2)
private Resource resource;
@GetMapping("/download/img")
public ResponseEntity<Resource> downloadImg() {
return ResponseEntity.ok()
.contentType(MediaType.IMAGE_PNG) // (3)
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.inline() // (4)
.filename(SAMPLE_FILE_NAME, StandardCharsets.UTF_8)
.build()
.toString())
.body(resource);
}
@GetMapping("/download/file")
public ResponseEntity<Resource> downloadFile() {
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_OCTET_STREAM) // (5)
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment() // (6)
.filename(SAMPLE_FILE_NAME, StandardCharsets.UTF_8)
.build()
.toString())
.body(resource);
}
}
@WebMvcTest(DownloadController.class)
@Slf4j
class DownloadControllerTest {
@Autowired
private MockMvc mockMvc;
@Test
void downloadImg() throws Exception {
// when
MvcResult mvcResult = mockMvc.perform(get("/download/img"))
.andExpect(status().isOk())
.andReturn();
// then
MockHttpServletResponse response = mvcResult.getResponse();
int contentLength = response.getContentLength();
String contentType = response.getContentType();
String contentDisposition = response.getHeader(HttpHeaders.CONTENT_DISPOSITION);
assertAll(
() -> assertThat(contentLength).isEqualTo(9183),
() -> assertThat(contentType).isEqualTo(MediaType.IMAGE_PNG_VALUE),
() -> assertThat(contentDisposition).contains("inline", "UTF-8")
);
// inline; filename*=UTF-8''%EC%8A%A4%ED%94%84%EB%A7%81.png
log.info("contentDisposition : {}", contentDisposition);
}
@Test
void downloadFile() throws Exception {
// when
MvcResult mvcResult = mockMvc.perform(get("/download/file"))
.andExpect(status().isOk())
.andReturn();
// then
MockHttpServletResponse response = mvcResult.getResponse();
int contentLength = response.getContentLength();
String contentType = response.getContentType();
String contentDisposition = response.getHeader(HttpHeaders.CONTENT_DISPOSITION);
assertAll(
() -> assertThat(contentLength).isEqualTo(9183),
() -> assertThat(contentType).isEqualTo(MediaType.APPLICATION_OCTET_STREAM_VALUE),
() -> assertThat(contentDisposition).contains("attachment", "UTF-8")
);
// attachment; filename*=UTF-8''%EC%8A%A4%ED%94%84%EB%A7%81.png
log.info("contentDisposition : {}", contentDisposition);
}
}
컨트롤러에서 ResponseEntity 를 리턴 할 경우 HttpEntityMethodProcessor
handleReturnValue 에서 처리하며
AbstractMessageConverterMethodProcessor
writeWithMessageConverters 에서 메세지 컨버터를 선택하여 처리합니다.
이때 선택되는 메세지 컨버터는 ResourceHttpMessageConverter
이며,
부모 클래스 AbstractHttpMessageConverter
의 실제 write 하는 메서드에서 211 Line addDefaultHeaders 를 확인해보면
259 Line 에서 실제 Content-Length 가 세팅되어 있지 않을 경우 자동으로 추가해줍니다.
따라서 컨트롤러에서 따로 해당 헤더를 세팅해주지 않아도 됩니다.
기존 여러 샘플을 보면 브라우저에서 파일 다운로드시에 한글 깨지는것을 방지 하기위해 하단과 같이
User-Agent 로 분기 처리하여 브라우저별 filename Encoding 하는것을 자주 볼 수 있습니다.
public String getBrowser(HttpServletRequest request) {
String userAgent = request.getHeader("User-Agent");
if (userAgent.contains("MSIE") || userAgent.contains("Trident") || userAgent.contains("Edge")) {
return "MSIE";
} else if (userAgent.contains("Chrome")) {
return "Chrome";
} else if (userAgent.contains("Opera")) {
return "Opera";
} else if (userAgent.contains("Safari")) {
return "Safari";
} else if (userAgent.contains("Firefox")) {
return "Firefox";
} else {
return "";
}
}
public String encodeFileName(String browser, String filename) {
if ("MSIE".equals(browser)) {
return URLEncoder.encode(filename, StandardCharsets.UTF_8).replaceAll("\\+", "%20");
}
if ("Firefox".equals(browser)) {
return // ...
}
if ("Chrome".equals(browser)) {
return // ...
}
...
}
상단의 방법 보단 RFC6266 과 RFC5987 스펙을 다양한 브라우저에서 지원함에
따라 해당 명세를 이용하여 Content-Disposition 을 설정 할 경우 한번에 처리 가능 합니다.
http://test.greenbytes.de/tech/tc2231 에서 확인 가능하며
IE 의 경우 IE9 부터 지원 가능합니다.
Test | Results |
---|---|
FF22 | pass |
MSIE8 | unsupported |
MSIE9 | pass |
Opera | pass |
Saf6 | pass |
Konq | pass |
Chr25 | pass |
해당 처리를 하기 위해 Spring
에서 손쉽게 사용 할 수 있는 클래스도 지원 합니다.
ContentDisposition
에 filename 에 charset 을 지정 할 경우 자동으로 해당 스펙으로 헤더를 인코딩 합니다.
@Slf4j
class ContentDispositionTest {
@ParameterizedTest
@CsvSource({
"스프링.png, inline; filename*=UTF-8''%EC%8A%A4%ED%94%84%EB%A7%81.png",
"스프링1234.png, inline; filename*=UTF-8''%EC%8A%A4%ED%94%84%EB%A7%811234.png",
"스프링-!@#$%.png, inline; filename*=UTF-8''%EC%8A%A4%ED%94%84%EB%A7%81-!%40#$%25.png"
})
void buildContentDisposition(String filename, String expected) {
// when
ContentDisposition contentDisposition = ContentDisposition.inline()
.filename(filename, StandardCharsets.UTF_8)
.build();
// then
assertThat(contentDisposition.toString()).isEqualTo(expected);
}
}
Content-Disposition: inline; filename*=UTF-8''%EC%8A%A4%ED%94%84%EB%A7%81.png
RFC2231 의 4. Parameter Value Character Set and Language Information 참조
파라미터 값에 문자셋이나 언어를 지정해야 할 경우 아스터리스크(*)를 붙이고 '
를 구분자로 하여
문자셋, 언어, 값 순으로 설정하면 됩니다.
그리고 값은 URL-encoded 로 인코딩합니다.
마지막으로 문자셋이나 언어는 비워 둘 수있지만 반드시 '
를 표시해야 합니다.
블로그에 사용된 코드는 Github 에서 확인 하실 수 있습니다.
덕분에 도움이 많이 됐네요 !
내용 잘 봤습니다.