티스토리 뷰
이건 글을 쓰기 전에 미리 반성하고 시작한다
바쁘다는 핑계로 결국 리액트도 백엔드 API도 제대로 다 구현을 못했다..
부끄럽지만 시간 대비 효율이 안나오는 인간인지라 진득하게 의자에 앉아 있는 시간을 늘려도 아직 한참 모자른거 같다.
빠르게 진행해 보겠습니다
게시판에 첨부파일 기능을 붙이기 시작했다..
왜 JSON으로는 파일을 못 보낼까?
지금까지 만든 API는 전부 요청 본문에 JSON을 실어서 보냈다. 그런데 파일 업로드는 JSON으로 할 수가 없다. JSON은 텍스트 형식이라서, 이미지나 zip 같은 바이너리 데이터를 담을 그릇이 아니기 때문이다.
요청 본문(body)은 그냥 "데이터를 싣는 화물칸"일 뿐이고, 거기에 뭘 싣는지는 그때그때 다르다. 뭘 실었는지는 Content-Type 헤더가 알려준다. JSON을 실으면 application/json, 파일을 실으면 multipart/form-data인 것이다.
multipart/form-data의 생김새
multipart는 이름 그대로 본문을 여러 칸(part)으로 나누는 형식이다. 칸막이 문자열(boundary)로 본문을 나누고, 각 칸에 이름표를 붙인다. 실제 요청은 대략 이렇게 생겼다.
POST /api/boards/1/files HTTP/1.1
Content-Type: multipart/form-data; boundary=----ABC123
------ABC123
Content-Disposition: form-data; name="files"; filename="example1.png"
(example1.png의 바이너리 바이트가 그대로 들어감)
------ABC123
Content-Disposition: form-data; name="files"; filename="example2.jpg"
(example2.jpg의 바이너리 바이트)
------ABC123--
칸 안에는 바이너리를 그대로 넣을 수 있다. 스프링은 이 칸 하나를 MultipartFile 객체로 포장해서 주고, 같은 이름표의 칸이 여러 개면 List<MultipartFile>로 묶어준다. 요구사항이 "게시글당 첨부 2개 이상 가능"이라 List로 받으면 된다.
저장 파일명 설계
파일을 받으면 디스크에는 원본 이름 그대로 저장하지 않고 UUID를 붙여서 저장하고, 원본 이름은 DB에 따로 보관하기로 했다. 이유는 두 가지다.
- 다른 사용자가 같은 이름의 파일을 올리면 덮어써지는 충돌 방지
- 다운로드할 때는 원본 이름으로 돌려줘야 하니까 원본 이름도 버리면 안 됨
// 첨부 파일 업로드
public FileResponse uploadFile(Long boardId, MultipartFile file) throws IOException {
// 사용자가 업로드한 원본 파일 명
String originName = file.getOriginalFilename();
// 저장할 파일 명(UUID)
String storedName = UUID.randomUUID().toString() + "_" + originName;
// "./uploads" 문자열을 자바가 다룰 수 있는 경로 객체로 변환
Path uploadPath = Paths.get(uploadDir);
// 업로드 폴더가 없으면 새로 생성, 이미 있으면 그냥 넘어감
Files.createDirectories(uploadPath);
// 업로드 폴더 경로 + 저장할 파일명을 합쳐서 최종 저장 경로 생성
Path destination = uploadPath.resolve(storedName);
// 디스크에 파일 바이트 쓰기
file.transferTo(destination);
// DB에 파일 정보 저장
FileEntity fileEntity = new FileEntity();
fileEntity.setBoardId(boardId);
fileEntity.setOriginName(originName);
fileEntity.setStoredName(storedName);
fileEntity.setFilePath(uploadDir);
fileEntity.setFileSize(file.getSize());
fileEntity.setFileFormat(originName.substring(originName.lastIndexOf(".") + 1));
fileRepository.insertFile(fileEntity);
return fileMapper.toResponse(fileEntity);
}
// 첨부 파일 여러 개 업로드
public List<FileResponse> uploadFiles(Long boardId, List<MultipartFile> files) throws IOException {
// 업로드 결과(응답 DTO)들을 담을 빈 리스트 생성
List<FileResponse> responses = new ArrayList<>();
// 받은 파일들을 하나씩 꺼내서
for (MultipartFile file : files) {
// 기존의 한 개짜리 업로드 메서드를 재사용하고, 그 결과를 리스트에 추가
responses.add(uploadFile(boardId, file));
}
// 업로드된 파일들의 정보 목록을 반환
return responses;
}
서비스 로직
메서드를 두 개로 나눴다. uploadFile은 파일 한 개를 실제로 저장하는 메서드고, uploadFiles는 받은 파일들을 돌면서 일꾼을 N번 호출하는 메서드다. 여러 개 업로드 = 한 개 업로드 × N회차 니까 저장 로직을 복사하지 않고 기존 메서드를 재사용했다.
// 게시글에 첨부파일 업로드 (multipart/form-data 요청)
@PostMapping("/api/boards/{boardId}/files")
public List<FileResponse> uploadFiles(
@PathVariable Long boardId,
@RequestParam("files") List<MultipartFile> files) throws IOException {
return fileService.uploadFiles(boardId, files);
}
컨트롤러
첨부파일도 게시글에 소속되니까 URL은 댓글 때 쓴 패턴(/api/boards/{boardId}/comments)을 그대로 따랐다.
주의할 점은 @RequestBody가 아니라 @RequestParam이라는 것. @RequestBody는 본문의 JSON을 객체로 바꿔주는 어노테이션이라 multipart에는 못 쓴다. @RequestParam("files")는 이름표가 files인 칸들을 꺼내와라 라는 뜻이고, 괄호 안 문자열이 클라이언트가 보내는 이름표와 정확히 일치해야 한다.


포스트맨으로 업로드, 조회시 잘 작동하는 것을 확인할 수 있었다.
알아두면 좋은거
- 응답 DTO(FileResponse)에서 storedName과 filePath는 뺐다. 서버 내부의 저장 위치는 클라이언트가 알 필요가 없는 정보다
'게시판 만들기 > 3주차' 카테고리의 다른 글
| 만들자 게시판 3주차 (2) - 첨부파일 다운로드 API 구현 (0) | 2026.07.18 |
|---|
