(4) 스프링의 MultipartFile을 활용한 상품 이미지 파일 업로드 기능 구현
상품등록 서비스를 만들 때 이미지 업로드 기능이 필요합니다.
상품 등록에 대한 요구사항은 아래와 같습니다.
요구사항
- 상품 정보 입력 (상품명, 가격, 수량, 상품상태, 상품 카테고리 등등)
- 이미지 파일 여러 개를 등록할 수 있다.
- 업로드한 이미지를 웹 브라우저에서 확인할 수 있다.
스프링은 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) 상품 등록 버튼을 클릭
느낀점 / 보완사항
포스팅을 작성하면서 다음과 같은 보완점들이 필요하다고 생각했습니다.
문제점) 현재 이미지 파일에 대한 예외 처리가 컨트롤러에서 이루어지고 있습니다.
블로그 작성하면서 다시 생각해보니, 컨트롤러에서 예외를 처리하기보다는 서비스 로직에서 처리하는 것이 더 적절하다고 느꼈습니다.
서비스 로직에서 파일이 비어있는 경우나 지원되지 않는 파일 포맷이 업로드될 경우에 대한 예외 처리를 직접 담당하도록 구현하는 것이 좋을 것 같습니다.
추가할 사항) 이미지 수정 및 삭제 기능 추가
이미지 업로드만 구현까지 다루었지만, 추가로 판매자가 이미지를 수정하거나 삭제할 수 있는 기능을 추가하면 좋을 것 같습니다.