리뷰 CRUD 기능을 구현하면서 이미지 업로드와 삭제 기능은 따로 다루지 않았기에, 이번 글에서는 이를 중점적으로 공부하고 기록해 보려 합니다. 서버에 파일을 업로드하고 저장하는 과정과 업로드된 이미지를 삭제하는 방법까지 하나씩 실습하며 정리할 예정입니다.
업로드 및 삭제 파일 관리를 위한 클래스 생성
package com.demo.domain;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import org.springframework.web.multipart.MultipartFile;
public class FileUploadUtil {
public static void saveFile(String uploadDir, String fileName, MultipartFile multipartFile) throws IOException {
Path uploadPath = Paths.get(uploadDir);
if (!Files.exists(uploadPath)) {
Files.createDirectories(uploadPath);
}
try {
Path filePath = uploadPath.resolve(fileName);
Files.copy(multipartFile.getInputStream(), filePath);
} catch (IOException e) {
throw new IOException("Could not save file: " + fileName, e);
}
}
// 이미지 삭제 메서드
public static void deleteFile(String filePath) throws IOException {
Path path = Paths.get(filePath);
Files.deleteIfExists(path);
}
}
saveFile(StringuploadDir,StringfileName,MultipartFilemultipartFile) : 이 메서드는 업로드된 파일을 서버에 저장하는 역할을 합니다.
- uploadDir: 파일이 저장될 경로
- fileName: 저장될 파일의 이름
- multipartFile: 클라이언트에서 업로드한 파일 데이터를 포함하는 MultipartFile 객체
- Path uploadPath = Paths.get(uploadDir) : uploadDir은 파일이 저장될 경로를 나타내며, Paths.get(uploadDir)을 통해 이 경로를 Path 객체로 변환
- 첫 if문의 Files.createDirectories(uploadPath) : 해당 경로가 존재하지 않으면 createDirectories를 통해 디렉터리를 생성합니다.
- 파일 경로를 Path filePath = uploadPath.resolve(fileName) 로 생성하고, Files.copy(multipartFile.getInputStream(), filePath); 를 통해 multipartFile에서 가져온 파일의 InputStream을 해당 경로로 복사하여 파일을 저장합니다.
deleteFile(String filePath) : 이 메서드는 서버에 저장된 파일을 삭제하는 역할을 합니다.
- Path path = Paths.get(filePath) : filePath는 삭제하려는 파일의 경로를 나타내며, 이 Path 객체를 통해 이후의 파일 삭제 작업을 수행하게 됩니다.
- Files.deleteIfExists(path) : 지정된 경로에 파일이 존재하는 경우에만 파일을 삭제합니다. 파일이 존재하지 않으면 아무 작업도 수행하지 않습니다.
리뷰 작성.html
<div class="review-write-container">
<h1>리뷰 작성</h1>
<div class="review-write-content">
<form class="review-write-form" id="review-write-form" method="post" enctype="multipart/form-data">
<input type="text" id="title" name="title" placeholder="제목을 입력해주세요." />
<textarea rows="10" cols="20" name="content" id="content" placeholder="내용을 입력해주세요."></textarea>
<label for="uploadFile">이미지 업로드 (선택 사항):</label>
<input class="uploadFile" type="file" name="uploadFile" id="uploadFile" multiple>
</form>
</div>
<button class="write-btn" type="button" onclick="window.location.href='/review'">목록으로</button>
<button class="write-btn" type="button" onclick="review_write()">리뷰 작성</button>
</div>
<input class="uploadFile" type="file" name="uploadFile" id="uploadFile" multiple>
- <input type="file"> 필드를 통해 사용자가 파일을 선택할 수 있게 합니다.
- name="uploadFile" 속성은 파일이 폼을 통해 서버로 전송될 때 해당 파일 필드의 이름을 지정해 주는 부분입니다.
- multiple 속성은 여러 개의 파일을 동시에 선택할 수 있도록 합니다.
<form class="review-write-form" id="review-write-form" method="post" enctype="multipart/form-data">
- enctype="multipart/form-data" 속성은 파일 업로드가 포함된 폼 전송을 위해 필수적으로 설정해야 합니다. 이 속성이 있어야만 이미지 파일이나 기타 데이터를 서버로 제대로 전송할 수 있습니다.
- method="post"로 설정해 데이터를 안전하게 서버로 전송합니다.
작성 처리 Controller
// 리뷰 작성 처리
@PostMapping("/review-write-action")
public String reviewWriteAction(Review vo, Model model, HttpSession session,
@RequestParam("uploadFile") MultipartFile[] uploadFile) {
Member loginUser = (Member) session.getAttribute("loginUser");
if (loginUser == null) {
model.addAttribute("message", "로그인 페이지로 이동");
model.addAttribute("text", "리뷰 작성은 로그인 후 이용 가능합니다.");
model.addAttribute("messageType", "info");
return "login/login";
}
vo.setMember(loginUser); // 로그인한 사용자 정보를 리뷰에 설정
List<String> fileUrls = new ArrayList<>();
for (MultipartFile file : uploadFile) {
if (!file.isEmpty()) {
// 파일 경로
String uploadDir = "C:/ThisIsJava/SpringBootWorkspace/Book/uploads/";
// 파일 이름 수정
String originalName = file.getOriginalFilename();
String fileExtension = originalName.substring(originalName.lastIndexOf("."));
String uuid = UUID.randomUUID().toString();
String fileName = uuid + fileExtension;
try {
// 파일 저장
FileUploadUtil.saveFile(uploadDir, fileName, file);
// URL 생성
String fileUrl = "/uploads/" + fileName;
fileUrls.add(fileUrl);
} catch (IOException e) {
e.printStackTrace();
model.addAttribute("message", "파일 업로드 중 오류 발생");
model.addAttribute("messageType", "error");
return "review/review-write";
}
vo.setUploadedImages(fileUrls); // 저장된 파일 경로를 Review 객체에 설정
}
}
reviewService.insertReview(vo);
return "redirect:/review";
}
@RequestParam("uploadFile")MultipartFile[]uploadFile : html폼에서 name="uploadFile"로 지정된 파일 입력 요소의 데이터를 받아오는 역할을 합니다. MultipartFile[] 배열로 설정되어 있으므로 사용자가 여러 개의 파일을 업로드할 수 있으며, 각 파일이 MultipartFile 객체로 배열에 저장됩니다.
1. 파일 업로드 처리 준비
List<String> fileUrls = new ArrayList<>();
for (MultipartFile file : uploadFile) {
if (!file.isEmpty()) {
fileUrls 리스트를 생성하여 업로드된 파일의 URL을 저장합니다. uploadFile 배열을 반복하며, 각 파일이 비어있지 않은 경우에만 업로드 처리를 진행합니다.
2. 파일 저장 로직
String uploadDir = "파일 업로드 경로";
String originalName = file.getOriginalFilename();
String fileExtension = originalName.substring(originalName.lastIndexOf("."));
String uuid = UUID.randomUUID().toString();
String fileName = uuid + fileExtension;
- 업로드된 파일이 저장될 디렉터리 경로인 uploadDir을 지정합니다.
- 파일 이름이 중복되지 않도록 UUID를 사용해 새 파일 이름을 생성하고, 원본 파일 확장자를 유지합니다.
- UUID.randomUUID().toString()는 고유한 파일 이름을 생성하기 위해 사용됩니다.
3. 파일 저장 및 url 생성
try {
FileUploadUtil.saveFile(uploadDir, fileName, file);
String fileUrl = "/uploads/" + fileName;
fileUrls.add(fileUrl);
} catch (IOException e) {
e.printStackTrace();
model.addAttribute("message", "파일 업로드 중 오류 발생");
model.addAttribute("messageType", "error");
return "review/review-write";
}
- FileUploadUtil.saveFile(uploadDir, fileName, file) 메서드를 호출해 실제 파일을 서버에 저장합니다. saveFile은 주어진 경로에 파일을 복사하여 저장하는 역할을 합니다.
- 저장된 파일의 URL을 생성하여 fileUrls 리스트에 추가합니다.
- 파일 업로드 중 오류가 발생하면 예외를 처리하고 에러 메시지를 보여주는 페이지로 이동합니다.
4. Review 객체에 이미지 URL 설정 및 저장
vo.setUploadedImages(fileUrls); // 저장된 파일 경로를 Review 객체에 설정
reviewService.insertReview(vo);
- 업로드된 이미지의 URL 리스트 fileUrls를 Review 객체(vo)의 uploadedImages 속성에 설정합니다.
- reviewService.insertReview(vo)를 호출하여 리뷰 정보를 데이터베이스에 저장합니다.
테스트





DB에 저장 되었는지 확인


리뷰 수정 html
<div class="review-edit-container">
<h1>리뷰 수정</h1>
<form class="review-edit-form" id="review-edit-form" method="post" enctype="multipart/form-data">
<input type="hidden" name="review_seq" th:value="${review.review_seq}" />
<!-- 이미 업로드된 이미지 표시 -->
<div class="uploaded-images">
<th:block th:if="${#lists.isEmpty(review.uploadedImages)}">
<div class="uploaded-image">
<img th:src="@{/images/no_img.jpg}" alt="이미지 없음" style="width:200px; height:200px;">
</div>
</th:block>
<th:block th:unless="${#lists.isEmpty(review.uploadedImages)}">
<th:block th:each="image, iterStat : ${review.uploadedImages}">
<div class="uploaded-image">
<img th:src="@{${image}}" alt="이미지 없음" style="width:200px; height:200px;">
<button type="button" th:data-review-seq="${review.review_seq}"
th:data-index="${iterStat.index}" class="edit-imgdelete-btn"
onclick="deleteImage(this)">삭제
</button>
</div>
</th:block>
</th:block>
</div>
<label>제목</label>
<input type="text" name="title" id="title" th:value="${review.title}" />
<label>내용</label>
<textarea name="content" id="content" th:text="${review.content}"></textarea>
<input type="file" name="uploadFile" id="uploadFile" multiple>
</form>
<div class="edit-bottom">
<button class="edit-btn" type="button" onclick="window.location.href='/review'">목록으로</button>
<button class="edit-btn" type="button" onclick="update_review()">리뷰 수정</button>
</div>
</div>

1. 삭제 버튼.html
<th:block th:unless="${#lists.isEmpty(review.uploadedImages)}">
<th:block th:each="image, iterStat : ${review.uploadedImages}">
<div class="uploaded-image">
<img th:src="@{${image}}" alt="이미지 없음" style="width:200px; height:200px;">
<button type="button" th:data-review-seq="${review.review_seq}"
th:data-index="${iterStat.index}" class="edit-imgdelete-btn"
onclick="deleteImage(this)">삭제
</button>
</div>
</th:block>
</th:block>
- th:unless와 th:each는 Thymeleaf 템플릿 엔진의 구문으로, review.uploadedImages 목록이 비어있지 않으면 각 이미지를 반복문을 통해 출력합니다.
- 각 이미지는 uploaded-image 클래스를 가진 div 안에 img 태그로 표시되며, 삭제 버튼이 함께 표시됩니다.
- 버튼에는 data-review-seq와 data-index 속성이 있어 각 이미지와 관련된 정보를 JavaScript로 전달할 수 있습니다.
사용자가 삭제 버튼을 클릭하면 deleteImage() 함수가 호출됩니다.
2. 삭제 함수.js (JavaScript)
// 이미지 삭제
function deleteImage(buttonElement) {
// html에서 버튼을 data- 속성을 이용해서 전달된 값을 가져옴
const reviewSeq = buttonElement.getAttribute('data-review-seq');
const imageIndex = buttonElement.getAttribute('data-index');
const remainingImages = [];
$(".uploaded-image").each(function(index, element) {
if (index !== imageIndex) {
remainingImages.push($(element).find("img").attr("src"));
}
});
// 삭제된 이미지를 제외한 나머지 이미지를 서버로 전송
$("#review-edit-form").append(
$("<input>", {
type: "hidden",
name: "uploadedImages",
value: remainingImages.join(",")
})
);
$.ajax({
url: '/delete-image',
type: 'GET',
data: {
reviewSeq: reviewSeq,
imageIndex: imageIndex
},
success: function(response) {
swal.fire({
title: '삭제 성공',
text: '이미지를 성공적으로 삭제하였습니다.',
icon: 'success',
confirmButtonText: '확인'
}).then((result) => {
if (result.isConfirmed) {
location.reload();
}
});
},
error: function(xhr, status, error) {
swal.fire({
title: '삭제 실패',
text: '이미지 삭제 중 오류가 발생했습니다.',
icon: 'error',
confirmButtonText: '확인'
});
}
});
}
이 함수는 다음과 같은 작업을 수행합니다.
- 버튼의 데이터 속성 읽기
버튼 클릭 시 data-review-seq와 data-index 값을 읽어옵니다. 이를 통해 삭제할 이미지와 관련된 정보를 서버로 전달합니다. - 나머지 이미지 리스트 생성
$(".uploaded-image").each() 반복문을 사용하여 현재 페이지에 표시된 이미지들을 확인하고, 삭제된 이미지를 제외한 나머지 이미지들의 src 값을 remainingImages 배열에 저장합니다. - 서버로 AJAX 요청
$.ajax() 메서드를 사용하여 GET 요청을 /delete-image URL로 보내며, reviewSeq와 imageIndex를 전달합니다. - 성공 시 처리: 이미지 삭제가 성공적으로 완료되면 swal.fire()를 사용하여 성공 메시지를 표시하고, 사용자가 확인을 누르면 페이지를 새로 고칩니다.
- 실패 시 처리: 이미지 삭제 중 오류가 발생하면 에러 메시지를 띄웁니다.
3. Controller에서 이미지 삭제 처리
// 리뷰 이미지 삭제
@GetMapping("/delete-image")
@ResponseBody
public ResponseEntity<String> deleteImage(@RequestParam("reviewSeq") int review_seq,
@RequestParam("imageIndex") int imageIndex) {
try {
reviewService.deleteImage(review_seq, imageIndex); // 이미지 삭제 서비스 메서드 호출
return ResponseEntity.ok("이미지 삭제 성공");
} catch(Exception e) {
return ResponseEntity.status(500).body("이미지 삭제 실패 : " + e.getMessage());
}
}
- 매개변수: reviewSeq와 imageIndex를 @RequestParam으로 받아옵니다.
- 서비스 호출: reviewService.deleteImage() 메서드를 호출하여 데이터베이스에서 해당 이미지를 삭제합니다.
- 응답: 삭제 성공 시 "이미지 삭제 성공" 메시지를 반환하고, 실패 시 500 에러와 함께 에러 메시지를 반환합니다.
+ Service의 deleteImage() 메서드
// 이미지 삭제
@Override
@Transactional
public void deleteImage(int review_seq, int imageIndex) {
Review review = reviewRepo.getReviewBySeq(review_seq);
List<String> uploadImages = review.getUploadedImages();
if (imageIndex >= 0 && imageIndex < uploadImages.size()) {
// 이미지 리스트에서 해당 인덱스의 이미지 삭제
String imageUrlToRemove = uploadImages.remove(imageIndex);
try {
// 파일 시스템에서 이미지 삭제
FileUploadUtil.deleteFile(imageUrlToRemove);
} catch (IOException e) {
// 파일 삭제 중 오류가 발생하면 예외 처리
throw new RuntimeException("이미지 파일 삭제에 실패했습니다: " + imageUrlToRemove, e);
}
// 변경된 이미지 리스트를 Review 객체에 설정
review.setUploadedImages(uploadImages);
reviewRepo.save(review);
} else {
// 잘못된 이미지 인덱스가 전달되면 예외 처리
throw new IllegalArgumentException("유효하지 않은 이미지 인덱스입니다: " + imageIndex);
}
}
- Review review = reviewRepo.getReviewBySeq(review_seq) 를 호출하여 데이터베이스에서 review_seq에 해당하는 Review 객체를 조회합니다.
- List<String> uploadImages = review.getUploadedImages() 를 통해 해당 리뷰에 업로드된 이미지들의 URL을 리스트 형태로 가져옵니다.
- if (imageIndex >= 0 && imageIndex < uploadImages.size()) {삭제하려는 이미지가 실제로 존재하는지 검사하고, 유효하지 않은 인덱스 값이 들어오면 IllegalArgumentException 예외가 발생합니다.
- String imageUrlToRemove = uploadImages.remove(imageIndex) 를 통해 imageIndex에 해당하는 이미지를 리스트에서 제거합니다. (imageUrlToRemove에는 삭제된 이미지의 URL이 저장됩니다.)
- FileUploadUtil.deleteFile(imageUrlToRemove) 를 호출하여 실제 파일 시스템에서 해당 이미지를 삭제합니다.
- 파일 삭제 중 오류가 발생하면 IOException이 발생하며, 이를 RuntimeException으로 감싸서 던집니다. 이는 트랜잭션에서 예외가 발생하면 전체 작업이 롤백되도록 하기 위함입니다.
- review.setUploadedImages(uploadImages) 리스트에서 해당 이미지를 제거한 후, 변경된 리스트를 review 객체에 다시 설정합니다. 그 후, reviewRepo.save(review)를 호출하여 데이터베이스에 리뷰 정보를 업데이트합니다.
테스트





'SpringBoot 프로젝트' 카테고리의 다른 글
Spring Boot - 댓글 CRUD기능 구현하기 (2) (1) | 2024.11.22 |
---|---|
Spring Boot - 댓글 CRUD기능 구현하기 (1) (2) | 2024.11.21 |
Spring Boot - 리뷰 CRUD기능 구현하기 (4) (1) | 2024.11.19 |
Spring Boot - 리뷰 CRUD기능 구현하기 (3) (2) | 2024.11.19 |
Spring Boot - 리뷰 CRUD기능 구현하기 (2) (0) | 2024.11.19 |