[SpringBoot]파일 다운로드의 두가지 방법

유알·2022년 12월 1일
1

[Spring]

목록 보기
1/17
post-thumbnail

이번시간에는 SpringBoot에서 파일을 다운로드하게 하는 컨트롤러 작성법에 대해 알아보겠다.

기본 전재

  1. 파일의 정보를 담은 데이터 베이스를 가지고 있고, 그 정보에 나온 로컬 디렉토리에 파일이 저장되어있다.

  2. 클라이언트에서 a태그로 GET 요청을 보내고, 파라미터에 idx와 boardIdx를 담아서 준다
  <div class="file_list">
    <a th:each="list : ${board.fileList}" th:href="@{/board/downloadBoardFile.do(idx=${list.idx}, boardIdx=${list.boardIdx})}" th:text="|${list.originalFileName} (${list.fileSize} kb)|"></a>
  </div>

타임리프를 이용해서 작성했다.

<div class="file_list">
    <a href="/board/downloadBoardFile.do?idx=4&amp;boardIdx=14">hype 투명_대지 1.png (259 kb)</a>
    <a href="/board/downloadBoardFile.do?idx=5&amp;boardIdx=14">hype 투명_대지 1_대지 1.png (239 kb)</a>
    <a href="/board/downloadBoardFile.do?idx=6&amp;boardIdx=14">hype 투명_대지 1_대지 1_대지 1.png (205 kb)</a>
</div

렌더링 된 모습

첫번째 방법

코드

@GetMapping("/board/downloadBoardFile.do")
public ResponseEntity<Resource> downloadBoardFile(@RequestParam("idx") int idx,@RequestParam("boardIdx")int boardIdx){
    log.debug("dd");
    //Body
    BoardFileDto boardFileDto = boardService.selectBoardFileInfo(idx,boardIdx);
    UrlResource resource;
    try{
        resource = new UrlResource("file:"+ boardFileDto.getStoredFilePath());
    }catch (MalformedURLException e){
        log.error("the given File path is not valid");
        e.getStackTrace();
        throw new RuntimeException("the given URL path is not valid");
    }
    //Header
    String originalFileName = boardFileDto.getOriginalFileName();
    String encodedOriginalFileName = UriUtils.encode(originalFileName, StandardCharsets.UTF_8);

    String contentDisposition = "attachment; filename=\"" + encodedOriginalFileName + "\"";

    return ResponseEntity
            .ok()
            .header(HttpHeaders.CONTENT_DISPOSITION,contentDisposition)
            .body(resource);
}

한줄씩 설명하겠다.

@GetMapping("/board/downloadBoardFile.do")
public ResponseEntity<Resource> downloadBoardFile
		(@RequestParam("idx") int idx,@RequestParam("boardIdx")int boardIdx){

우선 GetMapping으로 호출을 받는다.
그리고 @RequestParam을 이용해서 파라미터를 변수로 받는다.

@RequestParam의 괄호 안에 위와 같이 키를 써주면 클라이언트에서 전달해준 키를 직접 지정할 수 있다.

괄호 안을 지정하지 않는다면, 매개변수의 이름이 곧 키가 된다.(즉 위의 경우에는 키랑 매개변수명하고 같으므로 생략 가능하다.)

리턴타입의 ResponseEntity에 대해서 알아보자

ResponseEntity 설명

org.springframework.http.ResponseEntity

public class ResponseEntity<T> extends HttpEntity<T> {

이렇게 생겼고 위의 주석을 보면 이렇게 쓰여있다.

Extension of HttpEntity
that adds an HttpStatus status code.
Used in RestTemplate as well as in @Controller methods.
In RestTemplate, this class is returned by getForEntity() and exchange():

말그대로 HttpEntity의 자손이고, HttpEntity에 HttpStatus를 추가한 객체라는 것이다.

이해된다

그리고 RestTemplate와 @Controller에서 쓰인다고 한다.
(우리가 맨날 쓰는 그 Controller 어노테이션에서 쓰이다니)

그렇다면 그의 조상이 핵심같으니 한번 더 타고 올라가보자

org.springframework.http.HttpEntity

public class HttpEntity<T> {

조상이 없다. Object의 상속을 받는 클래스다.

주석을 보면 또 자세히 나와있다. (API를 봐도 잘 나와있다 링크)

Represents an HTTP request or response entity, consisting of headers and body.

헤더와 바디로 구성된 HTTP request나 response entity를 나타낸다고

Often used in combination with the org.springframework.web.client.RestTemplate, like so:
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.TEXT_PLAIN);
HttpEntity entity = new HttpEntity<>("Hello World", headers);
URI location = template.postForLocation("https://example.com", entity);

위와 같은 객체들과 같이쓰인다고 한다. 보면 작명이 매우 직관적이다. 찾아보진 않았지만

HttpHeaders - Http헤더인가보다. 자세히 보고 싶으면 이도 찾아보면 될듯

아래 보니까 setContentType 메서드 안에서 MediaType.상수로 지정할 수 도 있나보다.

이제 그러면 이 객체의 생성자를 살펴보자

여러가지 생성자 중 가장 핵심은 아래의 생성자다.

나머지는 값이 없거나 한개만 있거나 이런걸 적절히 수정해 이 생성자로 연결해준다.

public HttpEntity(@Nullable T body, @Nullable MultiValueMap<String, String> headers) {
   this.body = body;
   this.headers = HttpHeaders.readOnlyHttpHeaders(headers != null ? headers : new HttpHeaders());
}

인자는 body와 headers를 받는다. body의 경우는 안의 타입으로 받는다.

대충 이해간다. 아 그냥 크게 body와 header을 받아서 http request를 구성해주는 객체이구나??

하위 메서드 들은 api를 읽어보는게 나을 것이다. 하지만 핵심은 그거다.

그렇다면 우리가 궁금한 이 객체는

public class ResponseEntity<T> extends HttpEntity<T> {

간단해진다. Header하고 Body하고 +Http Status로 구성된 객체이구나? 👌

다시 본론

자 그러면 다시 본론으로 돌아와서 우리의 다운로드 컨트롤러를 계속 보자

@GetMapping("/board/downloadBoardFile.do")
public ResponseEntity<Resource> downloadBoardFile
		(@RequestParam("idx") int idx,@RequestParam("boardIdx")int boardIdx){

리턴 타입에서 ResponseEntity를 말하고 있다.

즉 Header은 아직 지정해 주어야 하지만 Http Response의 Body로 Resource라는 객체를 담아서 전달한다는 것이다.

자 그러면 다음 부분을 보자

//Body
BoardFileDto boardFileDto = boardService.selectBoardFileInfo(idx,boardIdx);
UrlResource resource;
try{
  resource = new UrlResource("file:"+ boardFileDto.getStoredFilePath());
}catch (MalformedURLException e){
  log.error("the given File path is not valid");
  e.getStackTrace();
  throw new RuntimeException("the given URL path is not valid");
}

DB에서 파일 정보를 불러와서 Dto에 받아주고

Resource 인터페이스의 구현체중 하나인 UrlResource 사용한다.

UrlResource를 통해 리소스를 가져온다.

UrlResource의 (String path)의 값에 "http:"나 "file:"의 prefix를 붙여서 path를 지정해줄 수 있다.

여기에서는 "file:" 뒤에 상대경로를 지정해서 UrlResource 객체를 만들어줬다.

MalformedURLException이 발생할 수 있다고 해서 에러 처리를 해주었다.

  //Header
  String originalFileName = boardFileDto.getOriginalFileName();
  String encodedOriginalFileName = UriUtils.encode(originalFileName, StandardCharsets.UTF_8);

  String contentDisposition = "attachment; filename=\"" + encodedOriginalFileName + "\"";

  return ResponseEntity
          .ok()
          .header(HttpHeaders.CONTENT_DISPOSITION,contentDisposition)
          .body(resource);
}

말했다 싶이 ResponceEntity에는 Header, Body, Status 가 필요하다.

여기서는 헤더를 만들어서 넣어 줫다.

파일 이름을 UTF-8로 인코딩 해주고,

header에 넣을 문구를 만들어준다. 여기서는

"attachment; filename=\"" + encodedOriginalFileName + "\"" 이런 식으로 만들어줬다.

ResponseEntity에

.ok()로 status를 지정해주었다.

.header()로 헤더를 지정하면서 HttpHeaders.CONTENT_DISPOSITION, contentDisposition 로 속성을 지정해주었다.

.body()에는 방금 만들어주었던 UrlResource를 넣어주었다.

그리고 리턴해주었다.

결과적으로 웹에서 링크를 클릭하면 이미지가 다운로드 받아진다.

두번째 방법

그리고 두번째 방법이다.
이번에는 내가 읽은 책에서 쓴 방법을 써보겠다.

코드

@GetMapping("/board/downloadBoardFile.do")
public void downloadBoardFile(@RequestParam("idx") int idx, @RequestParam("boardIdx")int boardIdx, HttpServletResponse response){
    try {
        BoardFileDto boardFile = boardService.selectBoardFileInfo(idx, boardIdx);
        if (ObjectUtils.isEmpty(boardFile) == false){
            String fileName = boardFile.getOriginalFileName();
            byte[] files = FileUtils.readFileToByteArray(new File(boardFile.getStoredFilePath()));

            response.setContentType("application/octet-stream");
            response.setContentLength(files.length);
            response.setHeader("Content-Disposition","attachment; fileName=\""+ URLEncoder.encode(fileName,StandardCharsets.UTF_8)+"\";");
            response.setHeader("Content-Transfer-Encoding","binary");

            response.getOutputStream().write(files);
            response.getOutputStream().flush();
            response.getOutputStream().close();

        }
    } catch (IOException e){
        log.error(e.getMessage());
        e.getStackTrace();
    }
}

여기서는 리턴 타입이 void 이다.

그러면 어떻게 전송하는지 보자.

전체적인 과정은 유사하다. 파일 이름을 UTF-8 로 인코딩 하는 과정이나 헤더를 지정하는 과정들은 비슷하다

여기서는 바로 HttpServletResponse를 받는다.

그 객체를 가지고 파일을 전송하는데,

일단 MIME Type중에 application/octect-stream을 이용해서 전송한다.

이는 일단 8비트로 된 데이터라는 뜻으로 따로 처리될 수 있는 파일 형식이 아닐 때 기본 값이다.

브라우저는 이런 octect-stream 형식이 도착할때 주의를 기울인다.

byte[] files = FileUtils.readFileToByteArray(new File(boardFile.getStoredFilePath()));

우선 FileUtils.readFileToByteArray를 이용하여 byte[]를 만든다.

이때 readFileToByteArray의 인자로 new File(상대경로) 를 넣는다.

response.setContentType("application/octet-stream");
response.setContentLength(files.length);
response.setHeader("Content-Disposition","attachment; fileName=\""+ URLEncoder.encode(fileName,StandardCharsets.UTF_8)+"\";");
response.setHeader("Content-Transfer-Encoding","binary");

response.getOutputStream().write(files);
response.getOutputStream().flush();
response.getOutputStream().close();

우선 response의 ContentType, ContentLength를 설정해주고,

아까와 똑같이 Content-Disposition을 설정해준다.

그리고 Content-Transfer-Encoding을 binary로 설정해준다.

그리고 전송부분은 response에서 OutputStream을 받아서 write -> flush -> close 과정을 통해 전송한다.

응답

첫번째 방법

Content-Disposition: attachment; filename="IMG_1745.JPG"
Accept-Ranges: bytes
Content-Type: text/html
Content-Length: 2601567
Date: Thu, 24 Nov 2022 12:22:07 GMT
Keep-Alive: timeout=60
Connection: keep-alive

두번째 방법

HTTP/1.1 200
Content-Disposition: attachment; fileName="IMG_1745.JPG";
Content-Transfer-Encoding: binary
Content-Type: application/octet-stream
Content-Length: 2601567
Date: Thu, 24 Nov 2022 12:20:32 GMT
Keep-Alive: timeout=60
Connection: keep-alive

profile
더 좋은 구조를 고민하는 개발자 입니다

0개의 댓글