프로젝트/개인 프로젝트 V1

(4) 스프링의 MultipartFile을 활용한 상품 이미지 파일 업로드 기능 구현

Thumper 2022. 8. 1. 21:41

상품등록 서비스를 만들 때 이미지 업로드 기능이 필요합니다.
상품 등록에 대한 요구사항은 아래와 같습니다.

요구사항

  • 상품 정보 입력 (상품명, 가격, 수량, 상품상태, 상품 카테고리 등등)
  • 이미지 파일 여러 개를 등록할 수 있다.
  • 업로드한 이미지를 웹 브라우저에서 확인할 수 있다.

스프링은 MultipartFile 이라는 인터페이스로 멀티파트 파일을 매우 편리하게 지원합니다.
스프링 MVC를 활용하여 구현해보자.

이미지 업로드

이미지 업로드 과정을 MVC 구조를 따라 구현했습니다. 각 단계는 다음과 같습니다.

  • 파일 처리 로직은 FileService를 통해 수행됩니다.
  • FileService는 클라이언트로부터 전달된 이미지 파일을 처리한 후, 이미지 경로, 이미지 이름, 저장된 이미지 파일명, 그리고 대표 이미지 여부를 포함한 파일 정보를 DTO로 반환합니다.
  • 그 후, ItemImgService에 이 DTO를 Entity로 변환하고, 이를 통해 ItemImg 엔티티 객체를 생성합니다.
  • 마지막으로, mgRepository.save()을 호출하여 ItemImg 엔티티를 DB에 저장하여 상품 이미지 정보를 관리합니다.

1. application.properties 설정

파일 업로드를 하기 위해서는, 다음과 같이 저장할 위치를 지정해야 합니다.

file.dir=C:/newThing/chimm/
  • 원하는 파일 저장 경로를 application.properties 파일에 설정합니다.
  • 그 후, @Value("${file.dir}") 어노테이션을 사용하여 설정한 경로를 불러와 활용할 수 있습니다.

 

2. ItemImg

ItemImg에 대한 필드는 다음과 같습니다.

  • imgName :사용자가 업로드한 파일명
  • originImgName :서버 내부에서 관리하는 파일명
  • savePath :서버 내부에서 관리하는 파일 경로
  • isMainImg :상품 대표 이미지를 설정한다.

사용자가 업로드한 파일명으로 서버 내부에 파일을 저장하면 안된다.

왜냐하면 서로 다른 사용자가 같은 파일이름을 업로드 하는 경우 기존 파일 이름과 충돌이 날 수 있다.

서버에서는 저장할 파일명이 겹치지 않도록 내부에서 관리하는 별도의 파일명이 필요하다.

 

3. FileService : Multipart를 파일정보 DTO로 반환해준다.

FileService에서 클라이언트가 업로드한 파일을 DB에 저장하는 과정을 처리하며, 설정된 FileDir 경로에 이미지 파일을 저장합니다.

@Service
@Slf4j
public class FileService {

    @Value("${file.dir}")
    private String fileDir;

    public String getFullPath(String filename) {
        return fileDir + filename;
    }


    //multipartFile --> fileInfo 반환
    public FileInfo storeFile(MultipartFile multipartFile) throws IOException {
        if (multipartFile.isEmpty()) {
            return null;
        }
        // 원래 파일명 추출
        String originalFilename = multipartFile.getOriginalFilename();

        // uuid 저장파일명 생성
        String savedFileName = createStoreFileName(originalFilename);

        // 파일을 불러올 때 사용할 파일 경로 (예: /file:/users/.../nameh8787bghh33.png)
        String savedFilePath = fileDir + savedFileName;

        FileInfo fileInfo = new FileInfo();
        fileInfo.updateItmImg(originalFilename,savedFileName,savedFilePath);

        // 실제로 로컬에 uuid 파일명으로 저장하기
        multipartFile.transferTo(new File(savedFilePath));
        log.info("fileInfo={}", fileInfo.getSavePath());
        return fileInfo;

    }


    // uuid 파일명 생성 메서드
    private String createStoreFileName(String originalFilename) {
        String fileExtension = getFileExtension(originalFilename);
        String uuid = UUID.randomUUID().toString();

        //uuid.확장자 로 만들기
        String savedFileName = uuid + "." + fileExtension;
        return savedFileName;
    }

    // 확장자 추출 메서드
    private String getFileExtension(String originalFilename) {
        String extension = originalFilename.substring(originalFilename.lastIndexOf("."));
        return extension;
    }

}

위의 코드를 보면 알 수 있듯이, 파일 업로드 과정을 최대한 세분화하여 Extract Method 방식으로 구현했습니다.

메서드 이름은 각 과정의 역할을 명확히 나타내도록 작성했으며, 각각의 메서드는 다음과 같은 역할을 수행합니다.

storeFile() 메소드:

multipartFile를 받아, fileInfo(상품 이미지 정보 DTO)로 반환해준다.

createStoreFileName() 메소드:

서버 내부에서 관리하는 파일명은 유일한 이름을 생성하는 UUID 를 사용해서 충돌하지 않도록 한다.

getFileExtension() 메소드:

확장자를 별도로 추출해서 서버 내부에서 관리하는 파일명에도 붙여준다.

 

4. ItemImgService : 상품 이미지를 DB에 저장한다.

ItemImgService에서 상품 이미지를 저장하는 로직을 처리하며, 해당 코드는 아래와 같습니다.

@Service
@Transactional
@RequiredArgsConstructor
public class ItemImgService {

    private final FileService fileService;
    private final ItemImgRepository imgRepository;


    // DTO --> ItemImg (Entity) 저장
    public Long saveItemImg(ItemInfo itemInfo, MultipartFile multipartFile) throws IOException {
        FileInfo fileInfo = fileService.storeFile(multipartFile);

        ItemImg imgEntity = ItemImg.imgBuilder()
                .originImgName(fileInfo.getOriginImgName())
                .imgName(fileInfo.getImgName())
                .savePath(fileInfo.getSavePath())
                .reImgYn(itemInfo.getRepImgYn())
                .item(itemInfo.getItem())
                .build();

        ItemImg saved = imgRepository.save(imgEntity);
        return saved.getId();

    }
}
  • FileService의 storeFile()를 호출하여 MultipartFile을 저장합니다.
  • ItemImg 엔티티를 생성할 때, 파일 정보와 대표 이미지 여부를 fileInfo와 itemInfo에서 추출하여 빌더 패턴을 사용하여 생성합니다.
  • 생성된 ItemImg 엔티티를 데이터베이스에 저장하고, 저장된 이미지의 ID를 반환합니다.

 

 

5. ItemService

ItemService에서 판매할 상품을 등록하는 로직을 처리하며, 상품 이미지 업로드는 ItemImgService를 생성자 주입을 통해 호출하여 처리합니다.

saveItem (상품저장)

  • 로그인된 계정으로 상품등록한다.
  • 상품 입력정보(상품명, 가격...)와 이미지 파일을 매개변수로 받는다.
  • 대표 이미지(상품 썸네일)는 list의 첫번째 값으로 지정한다.
@Service
@Transactional
@RequiredArgsConstructor
@Slf4j
public class ItemService {
    private final ItemRepository itemRepository;
    private final ItemImgService imgService;
    private final ItemImgRepository imgRepository;

    ...

    // 상품 단건 상세조회
    @Transactional(readOnly = true)
    public ItemFormDto getItemDetail(Long itemId) {
        List<ItemImg> itemImgList = imgRepository.findAllByItem_id(itemId);
        List<ItemImgDto> itemImgDtoList = new ArrayList<>();

        for (ItemImg itemImg : itemImgList) {
            ItemImgDto itemImgDto = ItemImgDto.of(itemImg);
            itemImgDtoList.add(itemImgDto);
        }

        //item(Entity) -> itemformDto(DTO) --> itemimgdtoList (setting)
        Item item = itemRepository.findById(itemId)
                .orElseThrow(() -> new IllegalArgumentException("Invalid board id= " + itemId ));
        ItemFormDto itemFormDto = ItemFormDto.of(item);
        itemFormDto.setItemImgDtoList(itemImgDtoList);
        return itemFormDto;
    }

getItemDetail (상품 상세페이지)

  • 상품ID로 상품 이미지 엔티티를 조회한다.
  • 상품 이미지 엔티티를 itemImgDto(DTO)로 변환한다.
  • 상품ID로 상품 엔티티를 조회한다.
  • 상품 엔티티를 itemFormDto(DTO)로 변환한다.
  • ModelMapper로 간단하게 엔티티를 DTO로 변환한다.
  • ItemImgDto, itemFormDto를 상품 상세페이지에 들어갈 DTO로 변환한다.

 

6. DTO로 사용하기

상품을 등록하고 조회할 때, 아래와 같은 DTO를 만들어 사용했습니다.

 

1) FileInfo

FileInfo는 상품 이미지 저장과정에서 사용하는 DTO입니다.

fileService에서 fileInfo.updateItmImg(originalFilename,savedFileName,savedFilePath)하여 데이터를 저장합니다.

  • originImgName : 회원이 업로드한 이미지의 파일명
  • imgName : 서버 내부에서 관리하는 파일명
@Data
public class FileInfo {

    private String imgName; // 서버 내부에서 관리하는 파일명
    private String originImgName; // 회원이 업로드한 이미지의 파일명
    private String savePath;  // 서버에 저장된 이미지 경로

    //dto :set() 메소드를 만들었다.
    public void updateItmImg(String originImgName, String imgName, String savePath) {
        this.originImgName = originImgName;
        this.imgName = imgName;
        this.savePath = savePath;
    }
}

2) ItemInfo

ItemImg 엔티티 생성을 위한 DTO로, '대표 이미지 여부'와 '해당 이미지가 속한 상품' 정보를 관리합니다.

@Data
public class ItemInfo {

    private String repImgYn; // 해당 이미지가 그 상품의 썸네일인지?
    private Item item; // 해당 이미지는 어떤 상품의 이미지인지?
}

3) ItemImgDto

상품 상세 정보를 조회할 때 사용되는 이미지 정보를 담는 DTO로, 상품 이미지를 조회했을 때 반환되는 데이터입니다.

@Data
public class ItemImgDto {
    private Long id; // 상품 이미지 PK

    private String imgName; // 상품 이미지 - 서버에 저장된 이름

    private String oriImgName; // 상품 이미지 - 업로드했을 때의 이미지 파일명

    private String savePath; // 상품 이미지 - 서버에 저장된 이미지의 경로

    private String repImgYn; // 상품 이미지 - 대표 이미지 여부

    private static ModelMapper modelMapper = new ModelMapper();


    //ItemImg(엔티티) ---> ItemImgDto(DTO) 변환
    public static ItemImgDto of(ItemImg itemImg) {
        return modelMapper.map(itemImg,ItemImgDto.class);

    }
}

of() 로직

ModelMapper를 사용하여 엔티티를 DTO로 간편하게 변환하였습니다.

  • ModelMapper는 객체 간 변환을 쉽게 처리해주는 라이브러리입니다. 위의 DTO 코드처럼 ItemImg 엔티티를 ItemImgDto DTO 타입으로 변환하는 작업을 처리했습니다.
  • 엔티티와 DTO가 서로 동일한 필드명을 사용했기 때문에, 자동으로 매핑하여 ItemImgDto 객체를 반환합니다.

4) ItemFormDto

상품 등록 시 상품 정보를 받는 DTO로, Item 엔티티를 생성할 때 해당 DTO를 사용합니다.

@Data
public class ItemFormDto {

    private Long id; // 상품 PK
    private String itemName; // 상품명
    private Integer price; // 상품 가격
    private Integer quantity; // 상품 수량
    private String itemType; // 상품 품질상태 (ex: 최상/상/중...)
    private String categoryType; // 상품 카테고리 분류 (ex: 책/음반)
    private ItemSellStatus status; //판매여부 (ex: 판매중/상품 소진&판매완료)

    //이미지dto를 저장하는 리스트
    private List<ItemImgDto> itemImgDtoList = new ArrayList<>();


    //dto에서 item을 생성하는 메서드
    public Item toEntity() {
        return Item.itemBuilder()
                .itemName(itemName)
                .price(price)
                .stockQuantity(quantity)
                .itemType(itemType)
                .categoryType(categoryType)
                .status(status)
                .build();
    }

    private static ModelMapper modelMapper = new ModelMapper();


    //item(엔티티) ---> itemFormDto(DTO)로 변환
    public static ItemFormDto of(Item item) {
       return modelMapper.map(item,ItemFormDto.class);
    }


}

of() 로직

ModelMapper를 사용하여 엔티티를 DTO로 간편하게 변환하였습니다.

  • ModelMapper는 객체 간 변환을 쉽게 처리해주는 라이브러리입니다. 위의 DTO 코드처럼 Item 엔티티를 ItemFormDto DTO 타입으로 변환하는 작업을 처리했습니다.
  • 엔티티와 DTO가 서로 동일한 필드명을 사용했기 때문에, 자동으로 매핑하여 ItemFormDto 객체를 반환합니다.


7. Controller

    // 상품 등록 요청
    @PostMapping("/item/new")
    public String itemNew(@Login User loginMember, ItemFormDto itemFormDto, BindResult bindResult,
                          Model model, @RequestParam("itemImgFile") List<MultipartFile> itemImgFileList, RedirectAttributes redirectAttributes) throws IOException {


        if (itemImgFileList.get(0).isEmpty() && itemFormDto.getId() == null) {
            model.addAttribute("errorMessage", "첫번째 상품 이미지는 필수 입력 값 입니다.");
            return "item/itemForm";
        }

        Long id = itemService.saveItem(loginMember, itemFormDto, itemImgFileList);
        log.info("itemInfo={}", itemService.findById(id).toString());

        redirectAttributes.addAttribute("itemId", id);
        return "redirect:/item/{itemId}";
    }

    // 상품 단건 조회 요청
    @GetMapping("/item/{itemId}")
    public String itemDetail(@PathVariable Long itemId, Model model) {
        ItemFormDto formDto = itemService.getItemDetail(itemId);
        for (ItemImgDto itemImgDto : formDto.getItemImgDtoList()) {
            log.info("img imgName={} savePath={}", itemImgDto.getImgName(),itemImgDto.getSavePath());
        }
        model.addAttribute("item", formDto);
        return "item/itemDetailV2";
    }

    // 상품 이미지 조회
    @ResponseBody
    @GetMapping("/images/{filename}")
    public Resource downloadImage(@PathVariable String filename) throws MalformedURLException {
        //file:/users/.../nameh8787bghh33.png 이 uuid 경로를 찾아가지고,
        //urlResource가 찾는다.
        return new UrlResource("file:" + fileService.getFullPath(filename));
    }
  • @PostMapping("item/new") : 상품등록
  • @GetMapping("/item/{itemId}") : 상품 상세 페이지
  • @GetMapping("/images/{filename}") :이미지 조회
    • UrlResource 로 파일을 읽어서 @ResponseBody 로 이미지 바이너리를 반환한다.
    • UrlResource는 Resource 인터페이스의 구현체로 new UrlResource("file:" + "파일이 저장된 경로") 로 사용하면된다.


8. 실행화면 

실행해보면 여러 이미지 파일을 한번에 업로드 할 수 있다.

 

1)  상품을 등록

상품입력 예시

 

 

2) 상품 등록 버튼을 클릭 

전체 화면 캡처




느낀점 / 보완사항

포스팅을 작성하면서 다음과 같은 보완점들이 필요하다고 생각했습니다.

문제점) 현재 이미지 파일에 대한 예외 처리가 컨트롤러에서 이루어지고 있습니다.

블로그 작성하면서 다시 생각해보니, 컨트롤러에서 예외를 처리하기보다는 서비스 로직에서 처리하는 것이 더 적절하다고 느꼈습니다.
서비스 로직에서 파일이 비어있는 경우나 지원되지 않는 파일 포맷이 업로드될 경우에 대한 예외 처리를 직접 담당하도록 구현하는 것이 좋을 것 같습니다.

추가할 사항) 이미지 수정 및 삭제 기능 추가

이미지 업로드만 구현까지 다루었지만, 추가로 판매자가 이미지를 수정하거나 삭제할 수 있는 기능을 추가하면 좋을 것 같습니다.