Spring Boot

[Spring Boot] 스프링 이미지 업로드 예제 (3) - 외부 경로에 업로드

공대생안씨 2024. 9. 3. 16:20
  • 지난 게시글

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

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

2024.09.02 - [Spring Boot] - [Spring Boot] 스프링 이미지 업로드 예제 (2) - DB에 이미지 직접 저장


1. 설명

  • 예제(2) 에서 DB에 이미지를 직접 저장함
    • 실제 프로젝트 배포 시 트래픽이 많아져서 업로드 되는 이미지가 많다면 DB에 무리가 갈 수 있음 ⇒ 성능 저하 초래 가능!
    • 서버 ↔ 클라이언트 간 이동해야 할 데이터 양이 많아짐 ⇒ 속도 느려질 수 있음!
  • 외부 경로에 이미지를 업로드 하고 DB에는 이미지 이름만 저장하는 방식으로 해결
    • 예제(1)의 정적 리소스 로드 문제점과 예제(2)의 DB 직접 저장 문제점을 모두 해결 가능하다고 판단
    • 이미지 이름에 UUID 추가, 예외 조건 등은 이전과 동일하게 작성
이전 공통 코드, 예제(1),(2) 코드를 기반으로 작성하되 V3를 붙인 클래스를 생성함

 

2. 추가 코드

2-1. application.yml

  • 이전과 동일

 

2-2. config > WebMvcConfig.java

  • 스프링의 설정 인터페이스 (WebMvcConfigurer 인터페이스) 구현체
  • 외부 경로에 업로드(저장)된 이미지를 불러오는 설정을 추가함
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/v3/img/**")
                .addResourceLocations("file:///Users/anchangmin/Desktop/image_upload/");
    }
}
  • addResourceHandler
    • 설정한 경로 (여기서는 "/v3/img/" 하위 모두) 로 이미지를 요청할 때 해당 요청을 처리할 리소스 핸들러 등록
  • addResourceLocations
    • 리소스 핸들러에 실제 파일이 위치한 외부 경로를 설정함
    • 실제 외부 경로 앞에 file:// 을 붙여야 함!
쉽게 말해,

/v3/img/example.jpg 로 이미지 요청을 보내면
서버가 실제로 /Users/anchangmin/Desktop/image_upload/example.jpg 파일을 찾아서 반환함!

 

2-3. domain

  • PostImagesV3
    • 예제(1) 과 유사하지만 여기서는 이미지 이름만 저장 (경로는 위에서 작성한 리소스 핸들러가 해결해 줄 것!)
@Entity
@Getter @Setter
@NoArgsConstructor
@Table(name = "post_images_v3")
public class PostImagesV3 {

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

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

    // 저장한 이미지 이름 (UUID + 이미지 이름)
    private String imageName;

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

    public PostImagesV3(String imageName) {
        this.imageName = imageName;
    }
}

 

  • PostV3
    • 예제(1), 예제(2)와 동일
@Entity
@Getter @Setter
@NoArgsConstructor
@Table(name = "post_v3")
public class PostV3 {
    @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<PostImagesV3> postImagesList = new ArrayList<>();


    public PostV3(String title, String content) {
        this.title = title;
        this.content = content;
    }
}

 

  • PostV3Dto
    • 예제(1), (2)와 동일
@Data
@NoArgsConstructor
@AllArgsConstructor
public class PostV3Dto {
    private String title;
    private String content;

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

 

2-4. repository

  • PostImagesV3Repository
    • 예제(1), (2)와 동일
@Repository
public interface PostImagesV3Repository extends JpaRepository<PostImagesV3, Long> {
}

 

  • PostV3Repository
    • 예제(1), (2)와 동일
@Repository
public interface PostV3Repository extends JpaRepository<PostV3, Long> {
}

 

2-5. service

  • PostImagesV3Service
    • 예외 처리 코드, 이미지 이름에 UUID 추가 등은 예제(1), (2)와 동일
    • 이미지를 외부 경로에 저장 + PostImages에는 이미지 이름 저장
@Service
@RequiredArgsConstructor
public class PostImagesV3Service {

    private final PostImagesV3Repository postImagesV3Repository;

    @Transactional
    public void saveImage(List<MultipartFile> imageList, PostV3 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 {
        // 이미지를 실제 업로드 할 경로 (Users/anchangmin/Desktop/image_upload/ 에 imageName으로 업로드)
        String uploadPath = "/Users/anchangmin/Desktop/image_upload/" + imageName;

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

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

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

    // PostImages 엔티티 생성 및 저장하는 메서드
    private void savePostImages(String imageName, PostV3 post) {

        // PostImages 이미지 이름 설정함
        PostImagesV3 postImages = new PostImagesV3(imageName);

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

        // 레파지토리에 엔티티 저장
        postImagesV3Repository.save(postImages);
    }
}
더보기

예제(1)과 다른 점

  • 예제(1)에서는 이미지를 프로젝트 내부 경로에 저장했음
  • 예제(1)의 PostImages 에 이미지 경로를 저장했지만 여기서는 이미지 이름만 저장함
    • 앞서 작성했듯 리소스 핸들러가 자동으로 경로 처리!
// 이미지를 실제 업로드 할 경로 (Users/anchangmin/Desktop/image_upload/ 에 imageName으로 업로드)
String uploadPath = "/Users/anchangmin/Desktop/image_upload/" + imageName;

여기서 리소스 핸들러에 작성한 외부 경로를 넣어주면 됨 (file:// 제외)

 

  • PostV3Service
    • 예제(1), (2)와 동일
@Service
@RequiredArgsConstructor
public class PostV3Service {

    private final PostV3Repository postV3Repository;
    private final PostImagesV3Service postImagesV3Service;

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

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

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

        // 이미지 저장 로직 추가
        postImagesV3Service.saveImage(postDto.getImageList(), post);
        postV3Repository.save(post);
    }
}

 

2-6. controller

  • PostV3ApiController
    • 예제(1), (2)와 동일
@RestController
@RequiredArgsConstructor
@RequestMapping("/v3/api/posts")
public class PostV3ApiController {

    private final PostV3Service postV3Service;

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

    // 특정 게시글 단건 조회
    @GetMapping("/{postId}")
    public ResponseEntity<PostV3> findPost(@PathVariable("postId") Long postId) {
        PostV3 post = postV3Service.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 PostV3Dto postDto) {

        postV3Service.save(postDto);

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

 

  • PostV3Controller
    • 예제(1), (2)와 동일
@Controller
@RequiredArgsConstructor
@RequestMapping("/v3/posts")
public class PostV3Controller {

    private final PostV3Service postV3Service;

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

        return "v3_home";
    }
}

 

2-7. html

  • 예제(1)과 유사하지만 이미지 이름 앞에 registry.addResourceHandler() 에 작성한 uri를 추가
    • 여기서는 '/v3/img/' 를 추가
<!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="${'/v3/img/' + post_image.imageName}" style="width: 200px"/>
</div>

</html>

 

3. 실행 결과

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

 

3-2. MySQL 확인

 

3-3. 외부 경로에 이미지 업로드 확인

 

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

 

3-5. 예외 처리 및 리소스 로드 확인

  • 예제(1), (2)와 동일한 예외 처리 결과 확인 가능함
  • 프로젝트가 다시 빌드되지 않더라도 정적 리소스가 외부 경로에서 로드 되는 것을 확인 가능함
댓글수0