-
[Spring MVC] 파일 업로드 예제Spring Framework 2024. 5. 24. 11:56
목표
- 스프링 MVC에서 폼으로부터 제출 받은 파일을 저장하고, 저장된 파일을 불러오는 방법을 익힌다.
요구사항
- 상품을 등록할 때 상품명, 가격, 여러개의 사진을 등록할 수 있다.
- 상품 목록에서 특정 상품을 누르면 상품 정보를 확인할 수 있다.
엔티티 설정
Item
- 하나의 상품은 여러 개의 사진을 가질 수 있다.
@Data @Entity public class Item { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String itemName; private Integer price; @OneToMany(mappedBy = "item", cascade = CascadeType.ALL, orphanRemoval = true) private List<ItemImage> images = new ArrayList<>(); }
public interface ItemRepository extends JpaRepository<Item, Long> { }
ItemImage
- 하나 이상의 사진은 하나의 상품을 가질 수 있다.
- 사진 파일 자체를 데이터베이스에 저장하면 용량과 성능 면에서 좋지 않으므로 사진 파일은 서버에 저장하고 데이터베이스에는 서버에 저장되어 있는 사진의 경로를 저장해야 한다.
@Data @Entity public class ItemImage { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String storeImageName; // 서버에 저장되는 이미지 이름 private String imagePath; // 서버 이미지 경로 @ManyToOne @JoinColumn(name = "item_id") private Item item; public ItemImage(String storeImageName, String imagePath) { this.storeImageName = storeImageName; this.imagePath = imagePath; } public ItemImage() { } }
public interface ItemImageRepository extends JpaRepository<ItemImage, Long> { List<ItemImage> findByItemId(Long itemId); }
서버 저장소
FileStore
- multipartFile.transferTo() : 서버에 파일 저장
- 사용자가 제출한 사진 이름을 그대로 사용해서는 안 된다. 다른 사용자가 같은 이름의 사진을 업로드할 수 있기 때문이다.
- 서버에서는 랜덤값으로 사진을 저장하고, 확장자를 확인하기 위해 확장자를 붙였다.
@Component public class FileStore { @Value("${file.dir}") private String fileDir; // 실제 파일 데이터가 저장될 서버 위치 // 서버에 파일을 저장 후 ItemImage 반환 public ItemImage storeFile(MultipartFile multipartFile) throws IOException { if (multipartFile.isEmpty()) { return null; } // 클라이언트가 업로드한 파일명을 서버에 저장할 고유한 파일명으로 변경한다. String originalFilename = multipartFile.getOriginalFilename(); String storeFileName = createStoreFileName(originalFilename); // fileDir 경로에 고유한 파일명으로 파일을 저장한다. String fullPath = getFullPath(storeFileName); multipartFile.transferTo(new File(fullPath)); return new ItemImage(storeFileName, fullPath); } // 여러 이미지 파일을 저장한 후 List<ItemImage>을 반환한다. public List<ItemImage> storeFiles(List<MultipartFile> multipartFiles) throws IOException { List<ItemImage> storeFileResult = new ArrayList<>(); for (MultipartFile multipartFile : multipartFiles) { if (!multipartFile.isEmpty()) { storeFileResult.add(storeFile(multipartFile)); } } return storeFileResult; } // 서버 내부에서 관리할 고유한 파일명을 생성하여 반환한다. private String createStoreFileName(String originalFilename) { String ext = extractedExt(originalFilename); String uuid = UUID.randomUUID().toString(); return uuid + "." + ext; } // 파일명과 함께 파일이 저장될 전체 경로를 만들어 반환한다. public String getFullPath(String filename) { return fileDir + filename; } // 서버에서도 확장자를 확인하기 위해 파일명에서 확장자를 추출하여 반환한다. private String extractedExt(String originalFilename) { int pos = originalFilename.lastIndexOf("."); return originalFilename.substring(pos + 1); } }
DTO
ItemDTO
@Data public class ItemDTO { private String itemName; private Integer price; private List<MultipartFile> imageFiles; }
컨트롤러
ItemController
@Slf4j @Controller @RequestMapping("/item") @RequiredArgsConstructor public class ItemController { private final ItemService itemService; @GetMapping("/add") public String addItemForm() { return "addItemForm"; } @PostMapping("/add") public String addItem(@ModelAttribute ItemDTO itemDTO) throws IOException { itemService.addItemProcess(itemDTO); return "redirect:/"; } @GetMapping("/list") public String itemList(Model model) { model.addAttribute("items", itemService.getItemList()); return "itemList"; } @GetMapping("/{itemId}") public String item(@PathVariable Long itemId, Model model) { model.addAttribute("item", itemService.getItem(itemId)); model.addAttribute("itemImages", itemService.getItemImages(itemId)); return "item"; } }
서비스
ItemService
@Service @Transactional @RequiredArgsConstructor public class ItemService { private final FileStore fileStore; private final ItemRepository itemRepository; private final ItemImageRepository itemImageRepository; public void addItemProcess(ItemDTO itemDTO) throws IOException { // 서버에 이미지 저장 List<ItemImage> uploadFiles = fileStore.storeFiles(itemDTO.getImageFiles()); // DB에 상품 정보 저장 Item item = new Item(); item.setItemName(itemDTO.getItemName()); item.setPrice(itemDTO.getPrice()); for (ItemImage itemImage : uploadFiles) { itemImage.setItem(item); } item.setImages(uploadFiles); itemRepository.save(item); } public Item getItem(Long itemId) { return itemRepository.findById(itemId).orElse(null); } public List<ItemImage> getItemImages(Long itemId) { return itemImageRepository.findByItemId(itemId); } public List<Item> getItemList() { return itemRepository.findAll(); } }
상품 등록
addItemForm.html
- 파일 업로드를 하기 위해 form 태그에 enctype="multipart/form-data"을 지정한다.
- 여러 개의 이미지를 업로드할 수 있도록 multiple="multiple" 속성을 추가한다.
<html lang="ko"> <head> <meta charset="UTF-8"> <title>Write Form</title> </head> <body> <h2>상품 등록</h2> <hr> <form action="/item/add" method="post" enctype="multipart/form-data"> 상품 이름<input type="text" name="itemName"> <br> 상품 가격<input type="number" name="price"> <br> 상품 사진<input type="file" name="imageFiles" multiple="multiple"> <br> <input type="submit"> </form> </body> </html>
상품 목록
itemList.html
<html lang="ko" xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"> <title>Item List</title> </head> <body> <h2>상품 목록</h2> <hr> <table> <thead> <tr> <th>번호</th> <th>이름</th> <th>가격</th> </tr> </thead> <tbody> <tr th:each="item, iterStat : ${items}"> <td><a th:href="@{/item/{id}(id=${item.id})}" th:text="${iterStat.index + 1}"></a></td> <td><a th:href="@{/item/{id}(id=${item.id})}" th:text="${item.itemName}">상품 이름</a></td> <td th:text="${item.price}"></td> </tr> </tbody> </table> </body> </html>
특정 상품 확인
item.html
- 브라우저는 <img> 태그를 만나면 src 속성에 지정된 URL로 HTTP 요청을 보내어 해당 이미지 파일을 서버에서 가져온다.
- 이러한 방식을 사용하면 이미지 로드는 페이지 렌더링과 별개로 진행되므로 사용자는 이미지가 로드되는 동안에도 페이지를 볼 수 있어 사용자 경험이 향상된다.
<html lang="ko" xmlns:th="http://www.thymeleaf.org"> <head> <meta charset="UTF-8"> <title>Item</title> </head> <body> <h2>상품 정보</h2> <hr> <b>이름</b> <span th:text="${item.itemName}"></span> <br> <b>가격</b> <span th:text="${item.price}"></span> <br> <b>이미지</b> <br> <img th:each="imageFile : ${itemImages}" th:src="|/images/${imageFile.storeImageName}|" width="300" height="300"/> </body> </html>
ImageController
- @ResponseBody를 사용하여 서버에서 바이너리 데이터를 읽어와 스트림으로 변환하여 응답한다.
@Controller @AllArgsConstructor public class ImageController { private final FileStore fileStore; @ResponseBody // 바이너리 데이터 -> 스트림 @GetMapping("/images/{storeFileName}") public Resource downloadImage(@PathVariable String storeFileName) throws MalformedURLException { return new UrlResource("file:" + fileStore.getFullPath(storeFileName)); } }
스프링 MVC에 대해 잘 안다고 생각했지만 파일 업로드의 경우 익숙하지 않아서 한 번 예제를 만들어 공부했다. 정리를 해놔도 안 보면 까먹는 것 같다.. 공부한 내용을 정리하는 것도 중요하지만 정리한 것을 꾸준히 복습하는 게 더 중요하다는 것을 느꼈다..
'Spring Framework' 카테고리의 다른 글
[Spring Data JPA] open-in-view 설정에 대해서 (0) 2024.07.12 [Spring Data JPA] 벌크 연산 후 영속성 컨텍스트를 초기화 해야 하는 이유 (0) 2024.07.09 [Spring Security] OAuth2 소셜 로그인 중복 사용자 검증 (0) 2024.05.22 [Spring Security] JWT - Refresh 토큰 발급 (0) 2024.05.21 [Spring Security] OAuth2 소셜 로그인 (JWT 방식) (0) 2024.05.20