2024.09.02 - [Spring Boot] - [Spring Boot] 스프링 이미지 업로드 예제 (0) - 예시 상황, 공통 코드
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 초과하는 이미지 업로드 시
'Spring Boot' 카테고리의 다른 글
[Spring Boot] 스프링 이미지 업로드 예제 (3) - 외부 경로에 업로드 (0) | 2024.09.03 |
---|---|
[Spring Boot] 스프링 이미지 업로드 예제 (2) - DB에 이미지 직접 저장 (0) | 2024.09.02 |
[Spring Boot] 스프링 이미지 업로드 예제 (0) - 예시 상황, 공통 코드 (0) | 2024.09.02 |
[Spring Boot] MapStruct 라이브러리로 엔티티 ↔ DTO 변환 자동으로 매핑하기 (예제 코드) (0) | 2024.08.14 |
[Spring Boot] 데이터 바인딩 예제 (@RequestParam, @ModelAttribute, @RequestBody) (0) | 2024.08.14 |