728x90
0.기존방식
기존에는 이미지 업로드 기능이 없이 다음처럼 ProductCreateRequestDTO 내부에 url을 받아서 넣는 방식으로 있었기에 이번에 이미지를 업로드해서 하도록 만들었다.
@Operation(summary = "판매자 상품 추가", description = "판매자가 새로운 상품을 추가합니다.")
@PostMapping("/{memberId}/product/create")
@PreAuthorize("hasRole('ROLE_ADMIN') or #memberId == authentication.principal.id")
public ResponseEntity<ProductResponseDTO> createProduct(@Valid @PathVariable Long memberId,
@Valid @RequestBody ProductCreateRequestDTO productCreateRequestDTO) {
ProductResponseDTO responseDTO = productService.createProduct(memberId, productCreateRequestDTO);
return ResponseEntity.status(HttpStatus.CREATED).body(responseDTO); // 201 CREATED
}
public class ProductCreateRequestDTO {
@NotBlank(message = "상품명은 필수 입력값입니다.")
@Size(min = 2, max = 100, message = "상품명은 최소 2자 이상, 최대 100자 이하로 입력해야 합니다.")
@Schema(description = "상품명", example = "무선 이어폰", required = true)
private String name;
@Schema(description = "상품 이미지 URL", example = "https://example.com/product.jpg")
private String image;
@NotBlank(message = "상품 설명은 필수 입력값입니다.")
@Size(max = 500, message = "상품 설명은 최대 500자까지 입력 가능합니다.")
@Schema(description = "상품 설명", example = "고음질 무선 이어폰입니다.", required = true)
private String description;
@NotNull(message = "상품 가격은 필수 입력값입니다.")
@Min(value = 0, message = "상품 가격은 0원 이상이어야 합니다.")
@Schema(description = "상품 가격", example = "129000", required = true)
private Integer price;
@NotNull(message = "재고 수량은 필수 입력값입니다.")
@Min(value = 0, message = "재고 수량은 0개 이상이어야 합니다.")
@Schema(description = "재고 수량", example = "50", required = true)
private Integer stock;
@NotNull(message = "상품 카테고리는 필수 입력값입니다.")
@Schema(description = "상품 카테고리", example = "ELECTRONICS", required = true)
private Product.Category category;
public Product toEntity(Seller seller) {
return Product.builder()
.name(this.name)
.image(this.image)
.description(this.description)
.price(this.price)
.stock(this.stock)
.category(this.category)
.seller(seller)
.build();
}
}
1.컨트롤러(이미지 파일 받기)
기존 컨트롤러는 다음처럼 수정했다.
@Operation(summary = "판매자 상품 생성", description = "판매자가 상품을 생성합니다.")
@PostMapping(value = "/{memberId}/product/create", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasRole('ROLE_ADMIN') or #memberId == authentication.principal.id")
public ResponseEntity<ProductResponseDTO> createProduct(
@PathVariable Long memberId,
@Valid @RequestPart("product") ProductCreateRequestDTO productCreateRequestDTO,
@RequestPart(value = "image", required = true) MultipartFile imageFile) {
ProductResponseDTO responseDTO = productService.createProduct(memberId, productCreateRequestDTO, imageFile);
return ResponseEntity.status(HttpStatus.CREATED).body(responseDTO); // 201 CREATED
}
1-1.consumes = MediaType.MULTIPART_FORM_DATA_VALUE
multipart/form-data 형식의 요청 (파일업로드가 포함된요청)을 허용하도록 설정하는 옵션이다.
@PostMapping(value = "/{memberId}/product/create", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
1-2.@RequestPart vs @RequestBody
기존 @RequestBody 을 사용하고있었을땐 파일 업로드가 지원이 안되기 때문에 각각 @RequestPart로 바꿨다.
@RequestPart는 Multipart 요청에서 JSON 데이터 + 파일을 함께 받을 때 사용한다.
public ResponseEntity<ProductResponseDTO> createProduct(
@PathVariable Long memberId,
@Valid @RequestPart("product") ProductCreateRequestDTO productCreateRequestDTO,
@RequestPart(value = "image", required = true) MultipartFile imageFile) {
1-3.MultipartFile
Spring에서 파일 업로드를 처리할 때 사용하는 인터페이스다. 클라이언트가 multipart/form-data 형식으로 전송한 파일을 MultipartFile 객체로 받을 수 있다.
@RequestPart(value = "image", required = true) MultipartFile imageFile
그러면 다음처럼 Swagger에서 확인하면 파일업로드가 가능해진다.
2.Content type 'application/octet-stream' not supported
하지만 이상태로 바로 사용해볼려 한다면 높은 확률로 다음 오류가 발생하게 될것이다.
브라우저나 클라이언트가 전송하는 데이터의 Content-Type을 설정하지 않으면 자동으로 application/octet-stream으로 처리되기에 Spring Boot의 HttpMessageConverter가 application/octet-stream을 처리할 수 없어서 발생한다.
2-1.MultipartJackson2HttpMessageConverter로 해결
찾아본결과 누가 이 문제를 해결하기 위한 컴포넌트를 만들어 놨다.
Spring의 HttpMessageConverter를 확장하여, multipart/form-data 요청에서 JSON 데이터를 처리하기 위한 변환기를 만드는 클래스이다. @Compenet로 빈을 등록해서 프로젝트에 적용시키도록하자
@Component
public class MultipartJackson2HttpMessageConverter extends AbstractJackson2HttpMessageConverter {
/**
* https://stackoverflow.com/questions/16230291/requestpart-with-mixed-multipart-request-spring-mvc-3-2
* Converter for support http request with header Content-Type: multipart/form-data
*/
public MultipartJackson2HttpMessageConverter(ObjectMapper objectMapper) {
super(objectMapper, MediaType.APPLICATION_OCTET_STREAM);
}
@Override
public boolean canWrite(Class<?> clazz, MediaType mediaType) {
return false;
}
@Override
public boolean canWrite(Type type, Class<?> clazz, MediaType mediaType) {
return false;
}
@Override
protected boolean canWrite(MediaType mediaType) {
return false;
}
}
https://stackoverflow.com/questions/16230291/requestpart-with-mixed-multipart-request-spring-mvc-3-2
@RequestPart with mixed multipart request, Spring MVC 3.2
I'm developing a RESTful service based on Spring 3.2. I'm facing a problem with a controller handling mixed multipart HTTP request, with a Second part with XMLor JSON formatted data and a second part
stackoverflow.com
3.이미지 업로드 로직
이제 엔드포인트에서 이미지를 받아왔으니 이미지를 우선 로컬 폴더에 업로드 하는 로직을 만들자.
3-1.WebConfig
정적 자원을 처리하기 위한 설정을 위해 WebConfig 설정클래스를 만들었다.
요청 경로가 /images/**로 들어오는 모든 요청을 처리하고, 실제 파일 시스템 경로는 C:/uploads/로 지정한다.
요청이 /images/example.jpg라면, Spring은 C:/uploads/example.jpg 경로에서 해당 파일을 찾아 클라이언트에 반환해준다.
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/images/**")
.addResourceLocations("file:C:/uploads/");
}
}
3-2.application.properties
업로드 가능한 파일의 크기를 설정하고 , 업로드된 파일을 저장할 경로를 지정해주는 설정을 해준다.
# 업로드 가능한 최대 파일 크기 설정
spring.servlet.multipart.max-file-size=10MB
spring.servlet.multipart.max-request-size=10MB
# 파일 업로드 경로
spring.servlet.multipart.location=C:/uploads/
3-3.ImageUploadService
파일을 업로드 하는 로직을 담당하는 서비스를 생성해준다. 나는 파일이름을 업로드 시각에 맞춰서 이름이 되도록 하였다. 이미지 확장자의 겨웅 getFileExtension을 사용해서 업로드 파일이름에서 추출하였다. 때문에 이미지가 올라가게 되면 1623767364000.jpg 과같은 이름으로 file.transferTo(new File(filePath));을 통해 C:/uploads/ 디렉토리에 파일을 저장하고 파일 경로를 /images/1623767364000.jpg 처럼 반환해준다.
@Service
@Log4j2
public class ImageUploadService {
private static final String UPLOAD_DIR = "C:/uploads/";
public String uploadImage(MultipartFile file) throws IOException {
if (file == null || file.isEmpty()) {
throw new IOException("Image file is empty");
}
try {
Files.createDirectories(Paths.get(UPLOAD_DIR));
String originalFileName = file.getOriginalFilename();
if (originalFileName == null) {
throw new IOException("Invalid file name");
}
String fileName = System.currentTimeMillis() + "." + getFileExtension(originalFileName);
String filePath = UPLOAD_DIR + fileName;
file.transferTo(new File(filePath));
return "/images/" + fileName;
} catch (IOException e) {
log.error("File upload failed", e);
throw new IOException("Image Upload Fail", e);
}
}
private String getFileExtension(String fileName) {
int index = fileName.lastIndexOf('.');
if (index > 0) {
return fileName.substring(index + 1);
}
return "";
}
}
3-4. ImageUploadService 사용
이제 다음처럼 createProduct 로직에 이미지 업로드 로직을 포함해준다.
public ProductResponseDTO createProduct(Long memberId, ProductCreateRequestDTO requestDTO, MultipartFile imageFile) {
Seller seller = sellerRepository.findByMemberId(memberId)
.orElseThrow(() -> {
String errorMessage = String.format("(MemberId: %d)", memberId);
return new SellerNotFoundException(errorMessage);
});
ProductValidationUtil.validatePriceAndStock(requestDTO.getPrice(), requestDTO.getStock());
String imageUrl;
try {
imageUrl = imageUploadService.uploadImage(imageFile);
} catch (IOException e) {
throw new RuntimeException(e.getMessage());
}
Product product = requestDTO.toEntity(seller, imageUrl);
productRepository.save(product);
return ProductResponseDTO.fromEntity(product);
}
이제 이미지를 업로드하면 다음처럼 정상적으로 기능이 작동한것을 확인할수있다.
728x90
'BackEnd > SpringBoot' 카테고리의 다른 글
[SpringBoot] redis + 스케쥴링으로 조회수 관리 (0) | 2025.03.17 |
---|---|
[SpringBoot] redis로 실시간 인기 제품 (0) | 2025.03.13 |
[SpringBoot] Rate Limiting 기능, AOP 적용,어노테이션 만들기 (0) | 2025.03.12 |