티스토리 뷰

요구사항에 보면 ' 해당 첨부의 서버 URI 링크가 아닌, 바이너리 다운로드 형태로 구현되어야 함'  이라는 조건이 있다

URI 직링크는   되나

편한 방법은 uploads 폴더를 웹에 노출시키고 http://서버/uploads/uuid_파일명.png으로 직접 접근하게 하는 것이다. 하지만 이러면 저장 폴더 구조와 실제 파일명이 외부에 노출되고, 주소만 알면 누구나 아무 검증 없이 파일을 가져갈  있다. 나중에 권한 검사 같은  넣을 자리도 없는게 문제다

 

그래서 모든 다운로드가 API를 통과하게 만든다. 클라이언트는 파일 id만 알고, 실제 파일 위치는 서버만 안다.

클라이언트: GET /api/files/1/download  (id만 앎)
    ① 서버가 DB에서 id=1 조회 → 저장명(UUID), 폴더, 원본명 획득
    ② 디스크에서 해당 파일을 읽음
    ③ 응답 본문에 바이너리를 싣고 헤더를 붙여 반환

물품보관소랑 똑같다. 손님은 보관증 번호만 들고 오고, 장부(DB)에서 번호로 선반 위치를 찾아서, 창고(디스크)에서 실물을 꺼내 준다.

다운로드 응답의 생김새

다운로드라고 특별한  아니라 응답 본문에 JSON 대신 파일 바이너리를 실으면 그게 다운로드다. 업로드와 방향만 반대라는 것을 알 수있다.

HTTP/1.1 200 OK
Content-Type: application/octet-stream
Content-Disposition: attachment; filename="스크린샷.png"

(파일의 바이너리 바이트)

Content-Disposition: attachment가 핵심이다. 브라우저에게 "열어서 보여주지 말고 파일로 저장해"라고 지시하고, filename으로 저장될 이름을 알려준다. 여기에 UUID가 아니라 원본 파일명을 넣는다. 업로드 때 원본명을 DB에 따로 저장해둔 이유가 있다.

 

Repository / Service

id로 한 건 조회하는 쿼리를 추가하자

  // repository.xml
  <!-- 파일 id로 상세 조회 -->
  <select id="selectFileById" resultType="com.study.file.entity.FileEntity">
    SELECT id, board_id, origin_name, stored_name, file_path, file_size, file_format
    FROM files
    WHERE id = #{id}
  </select>

 

Service에는 조회 메서드와, 디스크의 실제 파일을 가리키는 Resource 만드는 메서드를 뒀다

  // 파일 id로 상세 조회
  public FileEntity getFileById(Integer id) {
    FileEntity file = fileRepository.selectFileById(id);

    return file;
  }

  // 파일 다운로드
  public Resource loadAsResource(FileEntity file) throws IOException {
    // 폴더 경로 + UUID 저장명으로 실제 경로 조립
    Path filePath = Paths.get(file.getFilePath()).resolve(file.getStoredName());
    // 그 경로를 가리키는 손잡이를 만들어 반환
    return new UrlResource(filePath.toUri());
  }

 

Resource "디스크의 파일을 가리키는 손잡이" 정도로 이해했다. 손잡이를 만드는 시점에 파일을 메모리로  읽는  아니라, 응답을 보낼  스프링이 조금씩 흘려보낸다(스트리밍). 그래서  파일도 메모리 걱정이 없다.

여기서 주목할 점: getFileById는 DTO가 아니라 Entity를 그대로 반환한다. 지금까지는 Service가 항상 DTO를 반환했는데, 이번엔 Controller가 storedName과 filePath를 써야 하고, 그 둘은 DTO에서 일부러 뺀 필드다. 기준을 이렇게 정리했다 — 클라이언트에게 JSON으로 나갈 거면 DTO, 서버 안에서만 쓸 거면 Entity. 단, Entity가 컨트롤러까지 오는  괜찮지만 컨트롤러 밖(JSON 응답)으로 나가면  된다.

 

  // 첨부파일 다운로드 (JSON이 아니라 파일 바이너리를 응답 본문에 실어 보냄)
  @GetMapping("/api/files/{id}/download")
  public ResponseEntity<Resource> downloadFile(@PathVariable Integer id) throws IOException {
    // 장부(DB)에서 파일 정보 조회
    FileEntity file = fileService.getFileById(id);
    // 디스크의 실제 파일을 가리키는 손잡이 받아오기
    Resource resource = fileService.loadAsResource(file);

    // 저장될 파일명 이름표 (한글 깨짐 방지 UTF-8 인코딩)
    ContentDisposition contentDisposition = ContentDisposition.attachment()
        .filename(file.getOriginName(), StandardCharsets.UTF_8)
        .build();

    // 응답 조립: 200 OK + 헤더 2개 + 본문(손잡이)
    return ResponseEntity.ok()
        .header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition.toString())
        .contentType(MediaType.APPLICATION_OCTET_STREAM)
        .body(resource);
  }

지금까지는 return만 하면 스프링이 알아서 "200 + JSON"으로 포장해줬는데, 다운로드는 헤더를 직접 붙여야 해서 ResponseEntity라는 조립 키트로 응답을 수동으로 만든다. 읽히는 그대로 "200으로,  헤더 붙이고,  타입으로, 이걸 본문에"다.

한글 파일명은 헤더 값을 손으로 만들면 깨지는 고전적인 함정이 있어서, 스프링의 ContentDisposition 빌더에 UTF-8을 지정해서 표준 방식으로 인코딩했다.

 

테스트

다운로드는 GET이고 본문이 없어서 브라우저 주소창으로 바로 테스트할  있다. 주소창에 URL을 치는   GET 요청이다.

http://localhost:8080/api/files/2/download 로 접속하니 디스크에는 UUID 이름으로 저장된 파일이 원본 한글 이름 그대로 다운로드됐다.

 

 

트러블 슈팅을 빙자한 삽질 기록

- 테스트하는데 405 Method Not Allowed가 떴다. 코드는 멀쩡했고, 해결책도 간단했다. 원인은 재시작을 안 한 것. 자바 앱은 시작 시점의 코드로 돌기 때문에 소스를 고쳐도 떠 있는 서버는 모른다. 405는 "URL은 아는데 그 메서드 입구가 없다"는 뜻

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
TAG
more
«   2026/08   »
1
2 3 4 5 6 7 8
9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26 27 28 29
30 31
글 보관함