티스토리 뷰
요구사항에 보면 ' 해당 첨부의 서버 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은 아는데 그 메서드 입구가 없다"는 뜻
'게시판 만들기 > 3주차' 카테고리의 다른 글
| 만들자 게시판 3주차 (1) - 첨부파일 업로드 API 구현 (0) | 2026.07.18 |
|---|
