티스토리 뷰

이건 글을 쓰기 전에 미리 반성하고 시작한다
바쁘다는 핑계로 결국 리액트도 백엔드 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에 따로 보관하기로 했다. 이유는  가지다.

  1. 다른 사용자가 같은 이름의 파일을 올리면 덮어써지는 충돌 방지
  2. 다운로드할 때는 원본 이름으로 돌려줘야 하니까 원본 이름도 버리면  
  // 첨부 파일 업로드
  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는 뺐다. 서버 내부의 저장 위치는 클라이언트가 알 필요가 없는 정보다
공지사항
최근에 올라온 글
최근에 달린 댓글
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
글 보관함