[Spring] Spring에서 이미지파일 여러개 업로드하기

jgoneit·2023년 7월 2일
2

Spring

목록 보기
1/8
post-thumbnail

부트캠프에서 메인프로젝트 서비스 기획을 마무리 지어갈 때 쯤 우리 서비스에서 이미지 업로드 기능을 여러곳에서 사용해야했다. 여러개의 이미지 업로드를 한번에 가능하게 해야했기에 한번도 해 본 경험이 없어 실제 구현에 앞서 샘플프로젝트를 생성해서 구현해보기로 했다.

간단하게 tymeleaf와 springboot를 사용해서 SSR방식으로 샘플 프로젝트를 작성해보았다.

1. Entity

Entity는 총 2개로 구성되어있다. 이미지를 여러개씩 업로드해야하기 때문에 이미지와 게시글이1:N의 관계로 이미지를 별도의 엔티티로 분리해서 Board에서 List형식으로 참조하는 방식이 더 효율 적이라는 생각이 들었다.

1. Image

| Image.class

@NoArgsConstructor
@Data
@Entity
public class Image {

  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;

  private String filename;  // 파일명
  private String filepath;  // 파일경로

  @ManyToOne
  @JoinColumn(name = "board_id")
  // JSON으로 변환 시, 참조하는 객체의 값을 넣지 않음 
  // -> Dto를 사용해서 순환 참조를 해결할 수 있지만 현재는 간단한 샘플 프로젝트로 이 방식을 사용
  @JsonBackReference  
  private Board board;  // 연관관계의 게시글
}

2. Board

| Board.class

@NoArgsConstructor
@Data
@Entity
public class Board {
  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long boardId;

  private String title;
  private String content;
  
  // cascade = CascadeType.ALL : 부모 엔티티(board)에서 생성, 업데이트, 삭제되면 image도 동일하게 처리
  // orphanRemoval = true : 부모 엔티티(board)에서 image를 참조 제거하면 image엔티티에서도 DB에서 삭제
  @OneToMany(mappedBy = "board", cascade = CascadeType.ALL, orphanRemoval = true)
  private List<Image> images = new ArrayList<>(); // 연관된 이미지들
}

2. Repository

Repository는 Jpa에서 제공하는 JpaRepository인터페이스를 상속받아 사용했다.

| BoardRepository.class

@Repository
public interface BoardRepository extends JpaRepository<Board, Long> {}

3. Service

| BoardService.class

@Service
@RequiredArgsConstructor
public class BoardService {

  private final BoardRepository boardRepository;

  // 게시글 작성
  public void createPost(Board board, List<MultipartFile> files) throws IOException {
    List<String> fileNames = new ArrayList<>();
    List<String> filePaths = new ArrayList<>();
    // 파일 저장 경로 지정 - 현재 우리의 프로젝트 경로에 images 폴더를 생성하고 그 안에 저장
    // System.getProperty("user.dir") : 현재 프로젝트 경로(터미널에서 pwd를 수행했을 때 나오는 경로와 동일)
    // 그러나 배포 환경에서 JAR파일 내부에 파일을 저장하게 된다.
    // 배포 환경에서 외부 저장소나 별도의 서버가 필요.
    String imagePath = System.getProperty("user.dir") + "/src/main/resources/static/images/";

    for (MultipartFile file : files) {
      // 랜덤 식별자 생성(UUID로 생성) -> UUID를 사용하여 파일명 중복방지를 처리하고 있지만 DB에서 자동으로 생성하는 고유 ID값을 통해 중복 방지도 가능
      String uuid = UUID.randomUUID().toString();

      // 랜덤 식별자와 파일명 지정(중복 방지)
      // Ex) 1234-1234-1234-1234_파일명.jpg
      String filename = uuid + "_" + file.getOriginalFilename();

      // java.io.File 클래스의 인스턴스 생성
      // 파일 경로와 이름을 지정
      // imagePath(파일경로) + filename(파일명)을 합친 문자열로 file 인스턴스 생성
      File dest = new File(imagePath + filename);

      // 파일 저장
      // transferTo() 메서드를 호출하면 클라이언트가 업로드한 파일의 내용이 (dest)에 지정된 파일 경로로 저장됨.
      file.transferTo(dest);

      Image image = new Image();
      image.setFilename(filename);
      image.setFilepath("/images/" + filename);
      image.setBoard(board);

      // 파일명을 리스트에 저장
      fileNames.add(filename);
      // 파일 경로를 리스트에 저장
      filePaths.add("/images/" + filename);
      // 이미지 저장
      board.getImages().add(image);
    }

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

  // 특정 게시글 조회
  public Board getPost(Long id) {
    // findById() 메서드를 통해 id로 게시글을 찾고, 없으면 예외를 발생시킴
    return boardRepository.findById(id).orElseThrow(
        () -> new IllegalArgumentException("해당 게시글이 없습니다.")
    );
  }

  // 게시글 전체 조회
  public List<Board> boardList() {
    return boardRepository.findAll();
  }

  // 게시글 삭제
  public void deletePost(Long id) {
    boardRepository.deleteById(id);
  }
}

4. Controller

| BoardController

@Controller
@Transactional
@RequiredArgsConstructor
@RequestMapping("/boards")
public class BoardController {

  private final static String Board_DEFAULT_URL = "/board";
  private final BoardService boardService;

  // 게시글 작성
  @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
  @ResponseBody
  // @ModelAttribute 어노테이션은 요청 파라미터를 도메인 모델에 자동으로 바인딩하는 데 사용된다. 따라서 사용하는 데이터베이스 타입 (예: H2, MySQL, PostgreSQL 등)과는 상관 없이 사용할 수 있다.
  // 이 부분은 SSR방식에서 사용하기 때문에
  // CSR방식으로 개발한다면 유어클래스에서 배웠던 것 처럼 @Requestbody Board board로 사용.
  public ResponseEntity createPost(@ModelAttribute Board board, @RequestPart("files") List<MultipartFile> files) throws IOException {

    boardService.createPost(board, files);

    // 새로 생성된 게시글에 접근할 수 있는 URL을 생성
    URI uri = URI.create(Board_DEFAULT_URL + board.getBoardId());

    // ResponseEntity.created() 메서드를 사용하여 헤더에 `201 Created`상태코드와 함께 Location 정보를 담아서 응답
    return ResponseEntity.created(uri).body(board);
  }

  // 게시글 조회
  @Transactional(readOnly = true)
  @GetMapping("/{board-id}")
  public String getPost(@PathVariable("board-id") Long id, Model model) {
    Board board = boardService.getPost(id);

    // model은 SSR방식에서 HTML을 동적으로 생성하고 데이터를 바인딩(로딩해서 완전한 형태의 페이지를 만든다)해서 클라이언트에 전송
    // 그렇기 때문에 CSR방식으로 개발하게 되는 페이지의 경우 Model 객체를 사용할 필요가 없어요!<중요>
    // 페이지에서 데이터를 가져와서 표시해야 하는 경우 Model 객체가 필요.
    // Model 객체에 데이터를 추가하고 해당 데이터를 뷰에서 사용하여 동적으로 HTML을 생성.
    // 이는 GET 요청과 같이 사용자에게 무언가를 표시해야 하는 경우에 주로 사용됨.
    model.addAttribute("board", board);

    return "boardDetails";
  }

  // 게시글 전체 조회
  @GetMapping
  @Transactional(readOnly = true)
  @ResponseBody
  public List<Board> boardList() {
    return boardService.boardList();
  }

  // 게시글 삭제
  @DeleteMapping("/{board-id}")
  @ResponseBody
  public ResponseEntity deletePost(@PathVariable("board-id") Long id) {
    boardService.deletePost(id);

    return ResponseEntity.noContent().build();
  }
}

코드에 대한 설명은 주석으로 처리하여 따로 작성하지는 않았다.

5. 기타

db는 H2 database를 사용했으며 파일 업로드 크기제한과 뷰 리졸버 설정 등을 .yml파일에서 설정했다.
| application.yml

	... 생략
  servlet:
    multipart:
      # 각 파일의 최대 크기와 전체 요청 본문의 최대크기 설정
      max-file-size: 10MB
      max-request-size: 10MB
  mvc:
    view:
      # View Resolver의 Prefix와 Suffix를 설정
      prefix: /templates/
      suffix: .html
  web:
    resources:
      static-locations: "classpath:/static/, file:src/main/resources/static/" # 정적 리소스의 위치와, 해당 위치에서 파일이 제공되도록 설정
      add-mappings: true
  thymeleaf:
     cache: false

| BoardDetails.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title>Board Details</title>
</head>
<body>
<h1>Board Details</h1>

<div>
    <h2 th:text="${board.title}"></h2>
    <p th:text="${board.content}"></p>
    <div th:each="image : ${board.images}">
        <img th:src="@{${image.filepath}}" th:alt="${image.filename}" th:width="500px" th:height="500px">
    </div>
</div>

</body>
</html>

6. Postman 테스트 방법

1. HTTP 메서드와 URI를 설정

2. Request Header

  • Headers에 Content-Type을 mulipart/form-data로 설정

3. Request Body

  • Body에서 form-data를 선택한 후 다음과 같이 작성
  • 파일은 1개부터 총합 최대 10MB까지 업로드 가능하게 설정되어있음

4. HTTP STATUS 201 Created를 확인

5. http://localhost:8080/boards/{board-id}에 접속해서 결과를 확인

profile
be to BE Dev

0개의 댓글

관련 채용 정보