[Spring] 파일 업로드 구현

이신영·2024년 4월 2일
0

S0S

목록 보기
3/8
post-thumbnail

쇼핑몰의 게시글을 올릴때 여러장의 사진을 한번에 올리려면 어떤걸로 받아야할까?

저번 프로젝트에는 하나의 이미지만 업로드가 가능했는데 이번에는 여러 이미지를 업로드가 가능하게끔 할 생각이라 엔티티에서는 List를 사용할 생각이었다. 그럼 여러 이미지를 어떻게 받나? 라는건 MultipartFile을 사용할 생각이다.

컨트롤러에 매핑하고 db에 저장하는 과정을 생각해보자

  • db에 필요한 것 : 다음에 게시글을 열었을때도 보여줘야하므로 이미지 경로
  • 컨트롤러에 필요한 것 : POST요청시에 이미지 경로가 담긴 파라미터
  • 엔티티에 필요한 것 : 경로를 담을 곳

구현중에 발생한 문제

상품 엔티티

public class Product {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    ...
    
    @OneToMany(mappedBy = "product", cascade = CascadeType.REMOVE, fetch = FetchType.LAZY)
    @OrderBy("id asc")
    private List<UploadFile> uploadFiles = new ArrayList<>();

    ...
}

상품에 등록한 파일 엔티티

public class UploadFile {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    ...

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name="product_id")
    private Product product;

    ...
}

문제의 서비스 로직

@Service
@RequiredArgsConstructor
public class UploadFileServiceImpl implements UploadFileService {

    final private UploadFileRepository uploadFileRepository;

    @Value("${image.upload.directory}")
    String imageUploadDirectory;

    @Override
    public UploadFile saveUploadFile(String imageUrl, Product product) {
        UploadFile uploadFile = UploadFile.builder()
                .imageUrl(imageUrl)
                .product(product)
                .build();
        return uploadFileRepository.save(uploadFile);
    }

    @Override
    public UploadFileDTO saveImageFile(MultipartFile file, Product product) {
        // UUID 생성
        String uuid = UUID.randomUUID().toString();

        // 파일 확장자 추출
        String originalFilename = file.getOriginalFilename();

        // UUID를 파일 이름에 추가하여 저장
        String savedFilename = uuid + "_" + originalFilename;

        try {
            // 이미지 파일 저장
            Path uploadPath = Paths.get(imageUploadDirectory, savedFilename);
            Files.copy(file.getInputStream(), uploadPath, StandardCopyOption.REPLACE_EXISTING);
        } catch (IOException e) {
            e.printStackTrace();
            throw new RuntimeException("이미지 파일을 저장하는 중에 오류가 발생했습니다.", e);
        }

        // 저장된 이미지 파일의 경로를 imageUrl로 설정하여 업로드 파일 저장
        String imageUrl = imageUploadDirectory + "/" + savedFilename;

        // UploadFile 엔티티를 생성하고 저장
        UploadFile uploadFile = saveUploadFile(imageUrl, product);

        return new UploadFileDTO(uploadFile.getImageUrl());
    }
}

처음엔 db에서 파일 관리가 편하게 Product 라는 엔티티와 UploadFile 이라는 엔티티를 나눠서 받게끔했는데 다시 게시글을 조회하기위해서 UploadFileimageUrl 을 불러와야하지만 UploadFileProduct에 종속된 관계(1:N)이기때문에 원칙을 준수하면서 서비스로직을 구현하기가 까다로웠다.

설명하자면, 서비스상단에서는 Product에 대한 정보를 UploadFile의 서비스로직이 알아야하기때문에 컨트롤러부분에서 로직의 매개변수를 얻어야한다. 예를들어 saveUploadFile() 같은 메서드를 정의해 Product의 식별 키와 이미지 경로에 대한 정보를 받아서 UploadFile에 넣어줘야한다. 이럴거면 굳이 나눠야할까? 라는생각에 그냥 UplaodFile 엔티티를 과감히 삭제하기로 했다.

만약 테이블의 규모가 크다면 고민해볼 문제지만 현재로서는 과하게 역할을 나눈 느낌이 들었기때문이다.


수정 결과

수정된 Product 엔티티

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Product {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    ...
    // 여러 이미지 URL을 저장하는 리스트
    @ElementCollection
    private List<String> imageUrls = new ArrayList<>();

    @Builder
    public Product(String productName, String category, String writerName, int price, String description) {
        this.productName = productName;
        this.category = category;
        this.writerName = writerName;
        this.price = price;
        this.description = description;
    }

    // 게시글에 이미지 URL 추가
    public void addImageUrl(String imageUrl) {
        this.imageUrls.add(imageUrl);
    }


}

DTO

@Getter @Setter
@NoArgsConstructor
public class ProductDTO {
    private String productName;
    private String category;
    private String writerName;
    private int price;
    private String description;
    private List<String> imageUrls;
    
}

URL 정보를 리스트로 둔다음 Builder 패턴과 addImageUrl을 통해 엔티티의 등록이 이루어진다.

수정된 서비스 로직

@Service
@RequiredArgsConstructor
public class ProductServiceImpl implements ProductService{

    final private ProductRepository productRepository;

    @Value("${image.upload.directory}")
    String imageUploadDirectory;

    @Override
    @Transactional
    public void uploadProduct(ProductDTO productDTO, MultipartFile[] imageFiles) {
        if (productDTO == null) {
            throw new IllegalArgumentException("상품정보가 유효하지 않습니다.");
        }

        // ProductDTO로부터 상품 정보를 추출하여 Product 엔티티를 생성
        Product product = Product.builder()
                .productName(productDTO.getProductName())
                .category(productDTO.getCategory())
                .writerName(productDTO.getWriterName())
                .price(productDTO.getPrice())
                .description(productDTO.getDescription())
                .build();

        // 이미지 파일 처리
        if (imageFiles != null && imageFiles.length > 0) {
            for (MultipartFile imageFile : imageFiles) {
                // 각 이미지 파일을 저장하고 저장된 파일의 URL을 추출하여 Product 엔티티에 추가
                String savedImageUrl = saveImageFile(imageFile);
                product.addImageUrl(savedImageUrl);
            }
        }

        // 상품을 저장
        productRepository.save(product);
    }

    private String saveImageFile(MultipartFile imageFile) {
        // UUID를 사용하여 파일 이름 생성
        String uuid = UUID.randomUUID().toString();
        String fileExtension = imageFile.getOriginalFilename().substring(imageFile.getOriginalFilename().lastIndexOf("."));
        String savedFilename = uuid + fileExtension;

        // 이미지 파일을 저장할 경로 설정
        Path uploadPath = Paths.get(imageUploadDirectory, savedFilename);

        // 이미지 파일 저장
        try {
            Files.copy(imageFile.getInputStream(), uploadPath, StandardCopyOption.REPLACE_EXISTING);
        } catch (IOException e) {
            e.printStackTrace();
            throw new RuntimeException("이미지 파일을 저장하는 중에 오류가 발생했습니다.", e);
        }

        // 저장된 이미지 파일의 경로를 반환
        return savedFilename;
    }
}

컨트롤러

    @PostMapping("/upload")
    public String uploadProduct(@Valid @ModelAttribute("productDTO") ProductDTO productDTO,
                                @RequestParam("imageFiles") MultipartFile[] imageFiles,
                                BindingResult bindingResult) {
        if (bindingResult.hasErrors()) {
            return "product/uploadForm";
        }

        // 이미지 파일들과 함께 상품 정보를 서비스로 전달하여 상품을 업로드
        productService.uploadProduct(productDTO, imageFiles);

        return "redirect:/";
    }

대략적으로 구조를 설명해보자면

Entity, DTO : 게시글, 이미지정보 담당
Service : uuid로 생성된 이미지를 로컬에 저장하고 db에 url을 넣음
Controller : ProductDTO와 MultipartFile로 게시글과 파일정보를 받고 서비스로직으로 전달하여 처리


간단 테스트

이미지를 두개 이상 선택하고 Post하면?

db

잘 들어가는 모습

profile
후회하지 않는 사람이 되자 🔥

0개의 댓글