본문 바로가기

서버

SpringBoot 3 - 파일업로드 api

728x90

구현내용

 

1. 파일업로드 설정

2. DTO 셋팅

3. 파일등록처리

4. 컨트롤러 생성

5. 썸네일 이미지 처리

6. 이미지 가져오기(READ)

7. 이미지 삭제

8. 도메인 이미지 연결(값 타입 객체)

9. @ElementCollection(값타입 객체) Lazy Loading 처리에 대한 이해와 Test

10. @EntityGraph 사용하여 한번에 쿼리로 조회하기

11. 파일 삭제 (Soft delete)

12. 게시글 수정 

13. 이미지가 포함된 목록처리 

 


[1. 파일업로드 설정]

spring.application.name=zelkova

spring.datasource.driver-class-name=org.mariadb.jdbc.Driver
spring.datasource.url=jdbc:mariadb://localhost:3306/zelkova
spring.datasource.username=root
spring.datasource.password=1234

spring.jpa.hibernate.ddl-auto=update
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.show-sql=true

#file upload
spring.servlet.multipart.max-request-size=30MB
spring.servlet.multipart.max-file-size=10MB

com.zelkova.upload.path=upload

 

 

 


[2. DTO셋팅]

package com.zelkova.zelkova.dto;

import java.util.List;

import org.springframework.web.multipart.MultipartFile;

import java.util.ArrayList;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class ImageDTO {
  private Long ino;
  private String name;
  private String desc;
  private boolean delFlag;

  @Builder.Default
  private List<MultipartFile> files = new ArrayList<>();

  @Builder.Default
  private List<String> uploadFileNames = new ArrayList<>();
}

 


 

[3. 파일등록처리]

package com.zelkova.zelkova.util;

import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;

import jakarta.annotation.PostConstruct;
import lombok.extern.log4j.Log4j2;

@Component
@Log4j2
public class CustomFileUtil {
  // 업로드 경로 기본값을 application properties
  @Value("${com.zelkova.upload.path}")
  private String uploadPath;
  
  // init "@PostConstruct"를 이용해 디렉토리 없으면 생성
  // dependency injection 되는 순간 excute
  @PostConstruct
  public void init() {
    File file = new File(uploadPath);

    if (!file.exists()) {
      file.mkdir();
    }
    uploadPath = file.getAbsolutePath();
    log.info("---------------");
    log.info("--------------- uploadPath : " + uploadPath);
  }
  
  public List<String> saveFiles(List<MultipartFile> files) {
    if (files == null || files.size() == 0) {
      return List.of();
    }

    List<String> uploadFileNames = new ArrayList<>();

    // >>> for 시작
    for (MultipartFile file : files) {
      // 중복값 피하기 위한 UUID
      String savedName = UUID.randomUUID().toString() + "_" + file.getOriginalFilename();

      // 목적지 + 파일명 (파일 자체도 디스크의 주소이기 때문에 path가 될 수 있다.)
      Path path = Paths.get(uploadPath, savedName);

      try {
        // 바이트화 된 multipartFile을 사용할 수 있도록 InputStream에 넣어줌. 
        Files.copy(file.getInputStream(), path);
        uploadFileNames.add(savedName);
      } catch (IOException e) {
        e.printStackTrace();
      }
    }
    // >>> for 끝

    return uploadFileNames;
  }
}

 

이미지처리 프로세스를 재사용을 위해 FileUtil로 빼놓은 것이다. 

Controller나 Service 단에서 처리해도 됨. 

 

CustomFileUtil.java에 대한 내용은 간략하게 설명하면 다음과 같다.

1. init: 주입될 때 파일경로 없으면 생성해주기

 

2. saveFiles: 

2-1.  인자로 파일이 없으면 빈 배열 리턴 후 종료

2-2. 파일이 있다면 이미지파일명에 고유값 부여 

2-3. 업로드경로 + 파일명 => Path 객체 생성 (파일 디스크 입장에서는 디렉토리든 파일이든 Path로 인식가능)

2-4. Files.copy에 넘겨줌 (이미지 바이트를 InputStream 객체로 변환, 바이트를 담을 경로 + 파일)

 


[4. 컨트롤러생성]

package com.zelkova.zelkova.controller;

import java.util.List;
import java.util.Map;

import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

import com.zelkova.zelkova.dto.ImageDTO;
import com.zelkova.zelkova.util.CustomFileUtil;

import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;

@RestController
@Log4j2
@RequiredArgsConstructor
@RequestMapping("/api/image")
public class ImageController {
  
  private final CustomFileUtil fileUtil;

  @PostMapping("/")
  public Map<String, String> register(ImageDTO imageDTO) {
    List<MultipartFile> list = imageDTO.getFiles();

    List<String> uploadFileNames = fileUtil.saveFiles(list);
    
    imageDTO.setUploadFileNames(uploadFileNames);

    log.info("uploadFileNames >>> " + uploadFileNames);

    return Map.of("RESULT", "SUCCESS");
  }
}

 


[5. 썸네일 작업]

package com.zelkova.zelkova.util;

import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;

import jakarta.annotation.PostConstruct;
import lombok.extern.log4j.Log4j2;

import net.coobird.thumbnailator.Thumbnails;

@Component
@Log4j2
public class CustomFileUtil {
  // 업로드 경로 기본값을 application properties
  @Value("${com.zelkova.upload.path}")
  private String uploadPath;
  
  // init "@PostConstruct"를 이용해 디렉토리 없으면 생성
  // dependency injection 되는 순간 excute
  @PostConstruct
  public void init() {
    File file = new File(uploadPath);

    if (!file.exists()) {
      file.mkdir();
    }
    uploadPath = file.getAbsolutePath();
    log.info("---------------");
    log.info("--------------- uploadPath : " + uploadPath);
  }
  
  public List<String> saveFiles(List<MultipartFile> files) {
    if (files == null || files.size() == 0) {
      return List.of();
    }

    List<String> uploadFileNames = new ArrayList<>();

    // >>> for 시작
    for (MultipartFile file : files) {
      // 중복값 피하기 위한 UUID
      String savedName = UUID.randomUUID().toString() + "_" + file.getOriginalFilename();

      // 목적지 + 파일명 (파일 자체도 디스크의 주소이기 때문에 path가 될 수 있다.)
      Path path = Paths.get(uploadPath, savedName);

      try {
        // 바이트화 된 multipartFile을 사용할 수 있도록 InputStream에 넣어줌. 
        Files.copy(file.getInputStream(), path);

        // 썸네일 프로세스 시작
        String contentType = file.getContentType();
        if (contentType.startsWith("image")) {
          Path thumbPath = Paths.get(uploadPath, "s_" + savedName);
          Thumbnails.of(path.toFile())
                    .size(200, 200)
                    .toFile(thumbPath.toFile());
        }

        uploadFileNames.add(savedName);
      } catch (IOException e) {
        e.printStackTrace();
      }
    }
    // >>> for 끝

    return uploadFileNames;
  }
}

 

이미지만 썸네일 작업을 한다. 

4줄 밖에 없으니 하나씩 살펴보자

 

5-1. 저장할 디렉토리 + 파일명 생성하기

Path thumbPath = Paths.get(uploadPath, "s_" + savedName)

 

썸네일이라는 이미지라는 걸 구별하기 위해 

prefix에 "s_" 를 붙여준다

 

5-2. 기존에 저장한 원본 크기 파일을 File화 시키기

Thumbnails.of(path.toFile())

 

원본이 있어서 가공을 할 수 있으므로

기존에 저장되어 있는 원본 파일을 File 타입으로 가져온다. 

 

아래와 같이 가져오면 

File testFile = path.toFile()

 

해당 객체 안에 value라는 키값으로 

byte값이 저장되어있다.

 

 

5-3. 사이즈를 정하여 파일화 시키기

Thumbnails.of(path.toFile()
    .size(200, 200)
    .toFile(thumbPath.toFile();

 


[6. 이미지 가져오기 - CustomFileUtil.java, ImageController.java (두가지 파일 소스 추가)] 

 

CustomFileUtil.java

package com.zelkova.zelkova.util;

import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;

import jakarta.annotation.PostConstruct;
import lombok.extern.log4j.Log4j2;

import net.coobird.thumbnailator.Thumbnails;

@Component
@Log4j2
public class CustomFileUtil {
  // 업로드 경로 기본값을 application properties
  @Value("${com.zelkova.upload.path}")
  private String uploadPath;
  
  // init "@PostConstruct"를 이용해 디렉토리 없으면 생성
  // dependency injection 되는 순간 excute
  @PostConstruct
  public void init() {
    File file = new File(uploadPath);

    if (!file.exists()) {
      file.mkdir();
    }
    uploadPath = file.getAbsolutePath();
    log.info("---------------");
    log.info("--------------- uploadPath : " + uploadPath);
  }
  
  public List<String> saveFiles(List<MultipartFile> files) {
    if (files == null || files.size() == 0) {
      return List.of();
    }

    List<String> uploadFileNames = new ArrayList<>();

    // >>> for 시작
    for (MultipartFile file : files) {
      // 중복값 피하기 위한 UUID
      String savedName = UUID.randomUUID().toString() + "_" + file.getOriginalFilename();

      // 목적지 + 파일명 (파일 자체도 디스크의 주소이기 때문에 path가 될 수 있다.)
      Path path = Paths.get(uploadPath, savedName);

      try {
        // 바이트화 된 multipartFile을 사용할 수 있도록 InputStream에 넣어줌. 
        Files.copy(file.getInputStream(), path);

        // 썸네일 프로세스 시작
        String contentType = file.getContentType();
        if (contentType.startsWith("image")) {
          Path thumbPath = Paths.get(uploadPath, "s_" + savedName);

          Thumbnails.of(path.toFile())
              .size(200, 200)
              .toFile(thumbPath.toFile());
        }

        uploadFileNames.add(savedName);
      } catch (IOException e) {
        e.printStackTrace();
      }
    }
    // >>> for 끝

    return uploadFileNames;
  }
  
  public ResponseEntity<Resource> getFile(String filename) {
    Resource resource = new FileSystemResource(uploadPath + File.separator + filename);

    if (!resource.isReadable()) {
      resource = new FileSystemResource(uploadPath + File.separator + "default.png");
    }

    HttpHeaders headers = new HttpHeaders();
    
    try {
      // probeContentType: file이면 file로 타입을 맞춰주고 아니라면 타입을 체크해 넣어줌
      headers.add("Content-Type", Files.probeContentType(resource.getFile().toPath()));
    } catch (IOException e) {
      return ResponseEntity.internalServerError().build();
    }
    
    return ResponseEntity.ok().headers(headers).body(resource);
  }
}

 

6-1. getFile 메소드에서는 filename을 넣으면 해당 파일을 리턴해준다. 없으면 기본이미지 리턴

6-2. Files.probeContentType은 Path(디렉토리 + 파일명)을 넣어주면 probe(조사) 하여 ContentType을 알아서 넣어줌

 


 

ImageController.java

package com.zelkova.zelkova.controller;

import java.util.List;
import java.util.Map;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.core.io.Resource;

import com.zelkova.zelkova.dto.ImageDTO;
import com.zelkova.zelkova.util.CustomFileUtil;

import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;

@RestController
@Log4j2
@RequiredArgsConstructor
@RequestMapping("/api/image")
public class ImageController {
  
  private final CustomFileUtil fileUtil;

  @PostMapping("/")
  public Map<String, String> register(ImageDTO imageDTO) {
    List<MultipartFile> list = imageDTO.getFiles();

    List<String> uploadFileNames = fileUtil.saveFiles(list);

    imageDTO.setUploadFileNames(uploadFileNames);

    log.info("uploadFileNames >>> " + uploadFileNames);

    return Map.of("RESULT", "SUCCESS");
  }
  
  @GetMapping("/view/{filename}")
  public ResponseEntity<Resource> viewFile(@PathVariable(name = "filename") String filename) {
    ResponseEntity<Resource> resource = fileUtil.getFile(filename);
    return resource;
  }
}

 

 


[7. 이미지 삭제]

 

CustomFileUtil.java

... 생략


public void deleteFiles(List<String> fileNames) {
    if (fileNames.size() == 0 || fileNames == null) {
      return;
    }

    for (String name : fileNames) {
      Path thumbPath = Paths.get(uploadPath, "s_" + name);
      Path originPath = Paths.get(uploadPath, name);

      try {
        Files.deleteIfExists(thumbPath);
        Files.deleteIfExists(originPath);
      } catch (IOException e) {
        e.printStackTrace();
      }

    }
  }

 

 


[8. 도메인의 이미지 연결]

 

JPA에서

도메인 하나당 테이블로 생성이된다.

 

게시글 이미지 데이터를 담는 

BoardImage 테이블은 

Board 테이블에 종속적이다. 

 

게시글 하나에 

여러 이미지가 묶여있을 수 있다. 

 

그러기 위해서는

'값 타입 객체' 를 사용해야한다. 

 

아래 두 가지의 수행

- 'Board.java' 에서 값 타입 객체로 사용할 이미지리스트를  '@ElementCollection'으로 선언
- 'BoardImage.java' 에서 '나는 값 타입 객체예요' 를 의미하는 '@Embeddable' 선언

 

 

Board.java

package com.zelkova.zelkova.domain;

import java.time.LocalDate;
import java.util.List;

import jakarta.persistence.ElementCollection;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.*;

@Entity
@Table(name = "board")
@Getter
@ToString(exclude="imageList")
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class Board {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long bno;

    private String title;

    private String writer;

    private boolean isDel;

    private LocalDate date;

    @ElementCollection
    @Builder.Default
    private List<BoardImage> imageList = new ArrayList<>();

    public void changeTitle(String title) {
        this.title = title;
    }

    public void changeWriter(String writer) {
        this.writer = writer;
    }

    public void changeIsDel(boolean isDel) {
        this.isDel = isDel;
    }

    public void changeDate(LocalDate date) {
        this.date = date;
    }

    public void addImage(BoardImage image) {
        image.setOrd(this.imageList.size());
        imageList.add(image);
    }

    public void addImageString(String fileName) {
        BoardImage boardImage = BoardImage.builder()
                .fileName(fileName)
                .build();

        addImage(boardImage);
    }

    public void clearList() {
        this.imageList.clear();
    }

}

 

 

BoardImage.java

package com.zelkova.zelkova.domain;

import jakarta.persistence.Embeddable;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;

@Embeddable
@Getter
@ToString
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class BoardImage {
  int ord;

  private String fileName;

  public void setOrd(int ord) {
    this.ord = ord;
  }
}

 

 

조회해보면

board에는 board_image_list 테이블에 대한 내용이 없다.

 

board_image_list 테이블에는

board키를 외래키로 잡혀있다. 

 

 

 


[9.@ElementCollection(값타입 객체) Lazy Loading 처리]

 

게시글 테이블 안에 있는

게시글이미지리스트 '값 타입 객체'가 존재한다.

 

테이블은 두개이므로 

쿼리도 두 번 실행된다. 

 

이미지가 없는 경우는 'Board' 테이블만 접근하면 된다. 

이것을 Lazy Loading 방식이라고 한다.

 

두번 다 실행하여

성공해야 소스가 종료되도록

@Transactional 을 사용한다

 

아래는 테스트 소스

    @Test
    @Transactional
    public void testRead2() {
        // bno 를 이용해 가져옴
        long id = 10L;

        // 쿼리 한번 실행!
        Optional<Board> result = boardRepository.findById(id);

        // 가져오는 게 없는 경우 처리
        
        Board board = result.orElseThrow();

        // 이미지 리스트 접근
        // 쿼리 두번 실행!
        List<BoardImage> list = board.getImageList();
        log.info("------ imageList " + list);
    }

 


[10. @EntityGraph로 @Transactional 사용하지 말기]

package com.zelkova.zelkova.repository;

import java.util.Optional;

import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import com.zelkova.zelkova.domain.Board;

public interface BoardRepository extends JpaRepository<Board, Long>{
    @EntityGraph(attributePaths = "imageList")
    @Query("select b from Board b where b.bno = :bno")
    Optional<Board> selectOne(@Param("bno") Long bno);
}

 

@EntityGraph에서의

'attributePaths'는 

'값 타입 객체' 로 잡아 놓은 테이블을 

Join 대상으로 잡아준다. 

 


[11. 파일 삭제 (soft delete)]

package com.zelkova.zelkova.repository;

import java.util.Optional;

import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import com.zelkova.zelkova.domain.Board;

public interface BoardRepository extends JpaRepository<Board, Long>{
    @EntityGraph(attributePaths = "imageList")
    // attributePath는 '값 타입 객체'로 선언된 필드값을 join 대상 테이블로 잡아준다
    @Query("select b from Board b where b.bno = :bno")
    Optional<Board> selectOne(@Param("bno") Long bno);

    @Modifying
    @Query("update Board b set b.isDel = :flag where b.bno = :bno")
    void updateToDelete(@Param("flag") boolean flag, @Param("bno") Long bno);
}

 

@Modifying 어노테이션을 이용해

@Query에 데이터 변환 쿼리가 있다는 걸 알려줍니다. 

 

아래는 테스트 코드입니다.

    @Commit
    @Transactional
    @Test
    public void testDel() {
        Long bno = 1L;
        boardRepository.updateToDelete(true, bno);
    }

 

@Commit 어노테이션은

-> 테스트 임에도 불구하고 DB에 적용시키려면 해당 어노테이션을 붙이면 된다. 


[12. 게시글 수정]

    @Test
    public void testUpdate() {
        // 글 하나를 가져와서
        Long bno = 10L; //tomhoon9
        Optional<Board> result = boardRepository.selectOne(bno);
        Board board = result.orElseThrow();

        // 이미지 리스트 모두 비우기
        board.clearList();
        
        // 새로운 이미지 추가하기 
        board.addImageString(UUID.randomUUID().toString() + "_" + "NEWIMAGE1.jpg");
        board.addImageString(UUID.randomUUID().toString() + "_" + "NEWIMAGE2.jpg");

        // title, writer, content를 바꾸기
        board.changeContent("Hello!");
        board.changeWriter("tomhoony@@@@@");
        board.changeTitle("using custom set method instead of setter");

        // 저장하기
        boardRepository.save(board);
    }

 

수정하는 과정은 아래와 같다.

1. DB에 있는 하나의 튜플을 가져온다.

2. setter 메소드로 속성 수정하기

3. DB에 저장하기

 


[13. 이미지가 포함된 목록처리]

 

JPQL을 이용해서 JOIN해서 

출력할 수 있다. 

 

BoardRepository.java

package com.zelkova.zelkova.repository;

import java.util.Optional;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import com.zelkova.zelkova.domain.Board;

public interface BoardRepository extends JpaRepository<Board, Long>{
    @EntityGraph(attributePaths = "imageList")
    // attributePath는 '값 타입 객체'로 선언된 필드값을 join 대상 테이블로 잡아준다
    @Query("select b from Board b where b.bno = :bno")
    Optional<Board> selectOne(@Param("bno") Long bno);

    @Modifying
    @Query("update Board b set b.isDel = :flag where b.bno = :bno")
    void updateToDelete(@Param("flag") boolean flag, @Param("bno") Long bno);

    @Query("select b, bi from Board b left join b.imageList bi where bi.ord = 0 and b.isDel = false")
    Page<Object[]> selectList(Pageable pagebale);
}

 

selectList를 보면 

Board 테이블 안에 

'imageList' 칼럼이 있다.

 

해당 칼럼은 테이블 형태로 저장되어 있기 때문에

join을 통해 결과물을 가져온다. 

 

테스트 코드를 보며 

좀 더 이해하기

    @Test
    public void testList2() {
        Pageable pageable = PageRequest.of(0, 10, Sort.by("bno").descending());
        Page<Object[]> result = boardRepository.selectList(pageable);

        result.getContent().forEach(arr -> log.info(Arrays.toString(arr)));
    }

 

조인 쿼리를 거쳐 나온 결과의 타입은

"Page<Object[]>" 이다.

 

해당 데이터의 형태는 아래와 같다.

 

 

 

Pageable 타입을 인자로 던져

Page<Object[]>로 받는 게 아직 많이 어색하다