Spring Boot

[Spring Boot] 스프링 이미지 업로드 예제 (1) - 프로젝트 내부 디렉토리에 업로드

공대생안씨 2024. 9. 2. 17:39

2024.09.02 - [Spring Boot] - [Spring Boot] 스프링 이미지 업로드 예제 (0) - 예시 상황, 공통 코드

 

[Spring Boot] 스프링 이미지 업로드 예제 (0) - 예시 상황, 공통 코드

1. 예제 프로젝트 설명 게시글을 작성하는 기본 CRUD 프로젝트 가정게시글 작성 시 이미지 업로드를 포함하여 구현하고 싶음 2. 공통 코드이미지 업로드 로직을 제외한 기본적인 게시글 작성, 조

blogan99.tistory.com

 

1. 설명

  • 프로젝트 내부 디렉토리 (resources > static > image_upload) 에 업로드 된 이미지 저장할 것
  • 이미지 이름 중복 방지를 위해 "UUID로 생성한 랜덤값 + 실제 이미지 이름" 으로 저장
  • 이미지 업로드 시 처리할 예외 (조건 추가)
    • 파일이 이미지 형식이 아니라면 예외 발생
    • 이미지 최대 업로드 개수는 5개로 제한
    • 이미지 최대 용량은 10MB로 제한
이전 공통 코드를 기반으로 작성하되 V1을 붙인 클래스를 생성함

 

2. 추가 코드

2-1. application.yml

  • 추가된 조건 (용량 제한) 에 의해 파일 크기 설정 관련 코드 추가
spring:
  servlet:
    multipart:
      max-file-size: 10MB     # 파일 1개당 최대 용량
      max-request-size: 50MB  # 파일 여러개일 때 총 최대 용량

 

2-2. domain

  • PostImagesV1 (생성)
    • 이미지의 저장 경로를 저장할 엔티티 생성
    • Post와 다대일 양방향 연관관계 매핑
@Entity
@Getter @Setter
@NoArgsConstructor
@Table(name = "post_images_v1")
public class PostImagesV1 {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "post_images_id")
    private Long id;

    // 다대일 양방향 연관관계
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "post_id")
    private PostV1 post;

    // 저장한 이미지 경로 (UUID + 이미지 이름)
    private String imagePath;

    // 연관관계 편의 메서드
    public void setPost(PostV1 post) {
        this.post = post;
        post.getPostImagesList().add(this);
    }

    public PostImagesV1(String imagePath) {
        this.imagePath = imagePath;
    }
}

 

  • PostV1 (수정)
@Entity
@Getter @Setter
@NoArgsConstructor
@Table(name = "post_v1")
public class PostV1 {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "post_id")
    private Long id;

    private String title;
    private String content;

    // 일대다 양방향 연관관계
    @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<PostImagesV1> postImagesList = new ArrayList<>();


    public PostV1(String title, String content) {
        this.title = title;
        this.content = content;
    }
}
더보기

기존에서 추가한 코드

  • PostImagesV1과 일대다 양방향 연관관계 매핑 추가
// 일대다 양방향 연관관계
@OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true)
private List<PostImages> postImagesList = new ArrayList<>();

 

  • PostV1Dto (수정)
@Data
@NoArgsConstructor
@AllArgsConstructor
public class PostV1Dto {
    private String title;
    private String content;

    // 다수의 이미지 (리스트) 입력받기 위해 추가
    private List<MultipartFile> imageList;
}
더보기

 기존에서 추가한 코드

  • 이미지를 입력받기 위해 MultipartFile 리스트 필드 추가
// 다수의 이미지 (리스트) 입력받기 위해 추가
private List<MultipartFile> imageList;

 

2-3. repository

  • PostImagesV1Repository (생성)
    • Post와 동일하게 JpaRepository를 상속받은 레파지토리 생성
@Repository
public interface PostImagesRepository extends JpaRepository<PostImages, Long> {
}

 

2-4. service

  • PostImagesV1Service (생성)
    • 이미지 이름 생성 (중복 방지), 예외 처리, 이미지 업로드, 엔티티화 등 처리
@Service
@RequiredArgsConstructor
public class PostImagesV1Service {

    private final PostImagesV1Repository postImagesV1Repository;

    @Transactional
    public void saveImage(List<MultipartFile> imageList, PostV1 post) {
        // 업로드한 파일이 없다면 리턴
        if(imageList == null) return;

        // 사진 업로드 개수 <= 5 인지 체크
        if(imageList.size() > 5) throw new IllegalArgumentException("최대 사진 업로드 개수는 5개 입니다!");

        // 업로드 파일의 타입이 사진인지 체크
        for (MultipartFile image : imageList) {
            if(!isImageFile(image)) throw new IllegalArgumentException("사진 이외의 파일은 업로드 불가능합니다!");
        }

        // 각 사진에 대해 업로드와 db에 저장
        for (MultipartFile image : imageList) {

            // 파일이 비어있는 경우 패스
            if(Objects.requireNonNull(image.getOriginalFilename()).isEmpty()) continue;

            // 이미지 이름 앞에 랜덤값 추가 (중복 안되게)
            String imageName = addUUID(image.getOriginalFilename());

            // 이미지 업로드
            try {
                uploadImage(image, imageName);
            } catch (IOException e) {
                throw new RuntimeException("이미지 업로드를 실패했습니다!");
            }

            // PostImages 엔티티 저장
            savePostImages(imageName, post);
        }

    }

    // 파일이 이미지 형식인지 검사하는 메서드
    private static boolean isImageFile(MultipartFile image) {
        String contentType = image.getContentType();
        return contentType != null && (contentType.startsWith("image/"));
    }

    // 이미지 이름 중복 피하기 위해 이미지 이름 앞에 UUID 추가하는 메서드
    private static String addUUID(String originalFilename) {
        return UUID.randomUUID().toString().replace("-", "") + "_" + originalFilename;
    }

    // 이미지를 프로젝트 내에 업로드하는 메서드
    private static void uploadImage(MultipartFile image, String imageName) throws IOException {
        // 이미지를 실제 업로드 할 경로 (resources > static > image_upload 에 imageName으로 업로드)
        String uploadPath = "src/main/resources/static/image_upload/" + imageName;

        // 업로드 경로를 Path 객체로 변환
        Path path = Paths.get(uploadPath);

        // 경로의 부모 디렉토리가 존재하지 않으면 생성
        Files.createDirectories(path.getParent());

        // 이미지 바이트를 파일에 기록
        Files.write(path, image.getBytes());
    }

    // PostImages 엔티티 생성 및 저장하는 메서드
    private void savePostImages(String imageName, PostV1 post) {
        // db에 저장할 이미지 경로
        String dbImagePath = "/image_upload/" + imageName;

        PostImagesV1 postImages = new PostImagesV1(dbImagePath);

        // 연관관계 편의 메서드 통해서 Post 를 설정
        postImages.setPost(post);

        // 레파지토리에 엔티티 저장
        postImagesV1Repository.save(postImages);
    }
}

 

  • PostV1Service (수정)
@Service
@RequiredArgsConstructor
public class PostV1Service {

    private final PostV1Repository postV1Repository;
    private final PostImagesV1Service postImagesV1Service;

    // 모든 게시글 리턴
    public List<PostV1> findAll() {
        return postV1Repository.findAll();
    }

    // 특정 게시글 리턴
    public PostV1 findById(Long postId) {
        return postV1Repository.findById(postId).orElse(null);
    }

    // 게시글 저장
    @Transactional
    public void save(PostV1Dto postDto) {
        PostV1 post = new PostV1(postDto.getTitle(), postDto.getContent());

        // 이미지 저장 로직 추가
        postImagesV1Service.saveImage(postDto.getImageList(), post);
        postV1Repository.save(post);
    }
}
더보기

기존에서 추가한 코드

  • 이미지 저장을 위한 postImagesV1Service의 saveImage() 호출 코드 추가
// 이미지 저장 로직 추가
postImagesV1Service.saveImage(postDto.getImageList(), post);

 

2-5. controller

  • PostV1ApiController (수정)
@RestController
@RequiredArgsConstructor
@RequestMapping("/v1/api/posts")
public class PostV1ApiController {

    private final PostV1Service postV1Service;

    // 모든 게시글 조회
    @GetMapping
    public ResponseEntity<List<PostV1>> findAllPosts() {
        return ResponseEntity.ok(postV1Service.findAll());
    }

    // 특정 게시글 단건 조회
    @GetMapping("/{postId}")
    public ResponseEntity<PostV1> findPost(@PathVariable("postId") Long postId) {
        PostV1 post = postV1Service.findById(postId);
        return post == null ? ResponseEntity.status(HttpStatus.NO_CONTENT).body(null) : ResponseEntity.ok(post);
    }

    // 게시글 작성
    @PostMapping(consumes = "multipart/form-data")
    public ResponseEntity<Void> savePost(@ModelAttribute PostV1Dto postDto) {

        postV1Service.save(postDto);

        return ResponseEntity.status(HttpStatus.CREATED).build();
    }
}
더보기

 기존에서 추가한 코드

  • 게시글 작성 시 이미지도 입력받게 수정함
  • form-data를 입력받기 위한 설정 : consumes = "multipart/form-data"
  • @RequestBody → @ModelAttribute 로 수정
  • 기존의 ResponseEntity<Post> 반환 시 Post ↔ PostImages 간 무한 참조 발생
    ⇒ ResponseEntity<Void> 반환 타입으로 변경
// 게시글 작성
@PostMapping(consumes = "multipart/form-data")
public ResponseEntity<Void> savePost(@ModelAttribute PostV1Dto postDto) {

    postV1Service.save(postDto);

    return ResponseEntity.status(HttpStatus.CREATED).build();
}

 

  • PostV1Controller (생성)
    • 입력받은 이미지를 실제 확인하기 위해 뷰를 반환하는 컨트롤러 생성
@Controller
@RequiredArgsConstructor
@RequestMapping("/v1/posts")
public class PostV1Controller {

    private final PostV1Service postV1Service;

    @GetMapping("/{postId}")
    public String home(@PathVariable("postId") Long postId, Model model) {
        model.addAttribute("post", postV1Service.findById(postId));

        return "v1_home";
    }
}

 

2-6. html

  • v1_home.html (생성)
    • 이미지를 실제 확인하기 위해 간단한 html 페이지 생성
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org" >

<h1>제목: [[${post.title}]]</h1>
<h1>내용: [[${post.content}]]</h1>

<div th:each="post_image : ${post.postImagesList}">
    <img th:src="${post_image.imagePath}" style="width: 200px"/>
</div>

</html>

 

3. 실행 결과

3-1. 이미지 첨부 게시글 작성 (POST localhost:8080/v1/api/posts) 

 

3-2. 이미지 업로드 확인 (resources > static > image_upload)

 

3-3. MySQL 확인

 

3-4. 특정 게시글 조회 (http://localhost:8080/v1/posts/1)

3-5. 예외 처리 확인

  • 업로드 이미지 개수가 5개 초과 시

 

  • 이미지 형식이 아닌 파일 업로드 시

 

  • 10mb 초과하는 이미지 업로드 시