ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [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에 대해 잘 안다고 생각했지만 파일 업로드의 경우 익숙하지 않아서 한 번 예제를 만들어 공부했다. 정리를 해놔도 안 보면 까먹는 것 같다.. 공부한 내용을 정리하는 것도 중요하지만 정리한 것을 꾸준히 복습하는 게 더 중요하다는 것을 느꼈다..