🫡 요구사항
촬영장비 렌탈 플랫폼 DayFilm 의 아이템(상품) 도메인을 맡아 서버 개발을 진행했다.
아이템을 등록할 때, 여러 장의 사진과 함께 등록해야 하는 요구사항이 있었고 배포 Tool로 AWS 를 선택했기 때문에
S3 Bucket 에 등록하기로 결정하였다.
🫡 엔티티
Item.class
@Entity
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "ITEM_TABLE")
@Getter
public class Item {
@Id @GeneratedValue
@Column(name = "item_id")
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name="store_id")
private Store store;
@Column(nullable = false)
private String storeName;
@Column(nullable = false)
private String title;
@Column(nullable = false)
@Enumerated(EnumType.STRING)
private Category category;
@Column(nullable = false)
private String detail;
@Column(nullable = false)
private Integer pricePerOne;
private Integer pricePerFive;
private Integer pricePerTen;
@Column(nullable = false)
private String brandName;
@Column(nullable = false)
private String modelName;
@Column(nullable = false)
@Enumerated(EnumType.STRING)
private Method method;
@Column(nullable = false)
private Boolean use_yn;
@Column(nullable = false)
private Integer quantity;
@Column(nullable = false)
@OneToMany(fetch = FetchType.LAZY, mappedBy = "item", cascade = CascadeType.DELETE)
private List<ItemImage> itemImages;
...
private LocalDateTime createdDate;
private LocalDateTime modifiedDate;
public void addItemImage(ItemImage itemImage) {
this.itemImages.add(itemImage);
itemImage.setItem(this);
}
public void checkQuantity(Integer quantity) {
if(quantity <= 0) {
this.use_yn = Boolean.FALSE;
}
}
public void clearImages() {
this.itemImages.clear();
}
...
}
로직 상 많은 필요한 필드 값들과 메소드들이 존재하지만, Item 엔티티 와 ItemImage 엔티티의 관계를 주의 깊게 보면 된다.
하나의 **Item 은 여러개의 ItemImage 를 가지기 때문에 I:N 관계를 맺고 있다.**
기본적으로 지연로딩 정책을 사용했으며, **Item 이 삭제되면 해당 이미지들도 삭제하기 위해 CascadeType.DELETE 를 적용했다.**
또한 연관관계 편의 메소드를 통해 관계 주입 방식을 선택했다.
ItemImage.class
@Entity
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Getter @Setter
@Table(name="ITEM_IMAGE_TABLE")
public class ItemImage {
@Id @GeneratedValue
@Column(name = "image_id")
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name="item_id")
private Item item;
@Column(nullable = false)
private String imagePath;
@Column(nullable = false)
private String imageName;
@Column(nullable = false)
private Integer orderNumber;
}
웹에서 보여지는 이미지의 순서를 나타내는 orderNumber 필드 값이 있으며, 저장될 S3 Bucket 경로인 imagePath 과 이름 필드 값이 존재한다.
🫡 설정
build.gradle
implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'
SpringBoot 에서 AWS 관련 클래스와 메소드를 편하게 사용하기 위해 build.gradle 파일에 해당 의존성을 추가해준다.
application.yml
cloud:
aws:
s3:
bucket: day-film-bucket
filePath: ${cloud.aws.s3.filePath}
credentials:
instanceProfile: true
accessKey: ${cloud.aws.credentials.accessKey}
secretKey: ${cloud.aws.credentials.secretKey}
region:
static: ap-northeast-2
stack:
auto: false
S3 Bucket 을 사용하려면 당연히 AWS 계정과 S3 Bucket 이 필요하다.
해당 내용은 관련된 포스팅이 이미 많이 존재하므로 넘어가겠다.
필자는 profile 환경 변수를 통해 보안처리가 되야할 항목들을 저장해 관리하였다.
AwsS3Config.class
@Configuration
public class AwsS3Config {
@Value("${cloud.aws.credentials.accessKey}")
private String accessKey;
@Value("${cloud.aws.credentials.secretKey}")
private String secretKey;
@Value("${cloud.aws.region.static}")
private String region;
@Bean
public AmazonS3 amazonS3Client() {
AWSCredentials credentials = new BasicAWSCredentials(accessKey, secretKey);
return AmazonS3ClientBuilder
.standard()
.withCredentials(new AWSStaticCredentialsProvider(credentials))
.withRegion(region)
.build();
}
}
AWS의 S3 서비스를 사용하기 위해 선언한 설정 클래스이다. 분석해보자.
- @Configuration 어노테이션을 통해 해당 클래스가 Spring 컨테이너에 의해서 처리되어야 하는 설정 빈 클래스임을 정의한다.
- @Value 어노테이션을 통해 application.yml 에서 값을 주입해 사용하는 변수임을 정의한다.
- @Bean 어노테이션을 통해, amazonS3Client 메소드가 Spring 컨테이너에서 관리해야하는 Bean 을 생성하는 메소드임을 정의한다.
- amazonS3Client 메소드는 AWS S3 서비스와 통신하기 위해 필요한 AmazonS3 인스턴스를 반환한다.
- 권한과 인증을 위해 Iam 계정 accessKey, secretKey 키를 파라미터로 넘겨 인증 인스턴스 credentials 를 생성한다.
- 빌더 패턴을 통해 커스텀 AmazonS3 인스턴스를 반환할 수 있다.
- 기본 설정을 사용함을 명시하는 .standard() 와 인증에 사용할 Provider 파라미터에 이전에 생성한 인증 인스턴스를 넘겨준 후, 클라이언트에 사용할 AWS 지역을 설정한 후 해당 객체를 반환한다.
🫡 컨트롤러
ItemController.class
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping(EndPoint.ITEM)
@Api(tags = "상품")
public class ItemController {
private final ItemService itemService;
@PostMapping(value = "/store-write", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@ApiOperation(value = "상품 등록", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@ApiImplicitParams(value = {
@ApiImplicitParam(name = "data", value = "Item 정보들", required = true, dataTypeClass = InsertItemRequestDto.class, paramType = "form"),
@ApiImplicitParam(name = "images", value = "Image files", required = true, dataType = "MultipartFile", paramType = "form")
})
public ResponseEntity<SuccessResponse> createItem(@RequestPart List<MultipartFile> images, @RequestPart InsertItemRequestDto data) {
itemService.createItem(images, data);
return ResponseEntity.status(HttpStatus.OK)
.body(new SuccessResponse(CodeSet.OK));
}
}
핸들러매핑을 통해 연결될 Controller 이다. MultPartForm
형식으로 이미지를 받기 때문에 @RequestPart
어노테이션을 통해 데이터를 받아야 한다. 여러장의 이미지가 넘어올 수 있기 때문에 List
형식으로 이미지를 처리하였다. 그 외 아이템에 대한 추가 정보들은 dto 를 통해 매핑했다. 데이터를 받은 후 서비스 메소드를 호출한다.
🔥 @RequestPart 를 사용하면 Swagger 문서에 request dto 가 제대로 명시되지 않는 것을 확인할 수 있다.
혹시 해결 방법을 알고 계신다면, 댓글로 남겨주시면 감사합니다 🙇🏻♂️
🫡 서비스
ItemServiceImpl.class
@Override
@Transactional
public void createItem(List<MultipartFile> images, InsertItemRequestDto dto) {
try {
Store store = storeRepository.findById(dto.getStoreId())
.orElseThrow(() -> new CustomException("해당 번호에 해당하는 가게를 찾을 수 없습니다."));
Item item = Item.builder()
.store(store)
.storeName(store.getStoreName())
.title(dto.getTitle())
.category(dto.getCategory())
.detail(dto.getDetail())
.pricePerOne(dto.getPricePerOne())
.pricePerFive(dto.getPricePerFive())
.pricePerTen(dto.getPricePerTen())
.brandName(dto.getBrandName())
.modelName(dto.getModelName())
.method(dto.getMethod())
.use_yn(Boolean.TRUE)
.quantity(dto.getQuantity())
.itemImages(new ArrayList<>())
.products(new ArrayList<>())
.createdDate(LocalDateTime.now())
.modifiedDate(LocalDateTime.now())
.build(); //아이템 엔티티 먼저 생성
item.checkQuantity(item.getQuantity()); // 시작 재고가 0이 아닌지 체크
for (int i = 0; i < item.getQuantity(); i++) {
Product product = Product.builder()
.productStatus(ProductStatus.AVAILABLE)
.build();
item.addProduct(product);
}
if (!CollectionUtils.isNullOrEmpty(images)) {
int count = 1;
for (MultipartFile image : images) {
//개수 제한을 걸 경우, 조건문으로 예외 터트리면 됨.
String filename = store.getStoreName() + "/" + dto.getModelName() + count;
ImageInfoDto imageInfoDto = s3UploadService.uploadFile(image, filename);
ItemImage itemImage = ItemImage.builder()
.imagePath(imageInfoDto.getImagePath())
.imageName(imageInfoDto.getImageName())
.orderNumber(count)
.build();
item.addItemImage(itemImage);
count++;
}
}
itemRepository.save(item);
itemImageRepository.saveAll(item.getItemImages());
} catch (IOException e) {
log.info("error message : {}", e.getMessage());
throw new CustomException("Item 생성 실패");
}
}
createItem 메소드의 로직을 분석해보자.
- 로직 내에
IOException
이 발생할 수 있으므로 try-catch 문으로 로직을 감쌌다. - 아이템을 등록하려면, 작성자인 가게 엔티티가 필요하다. 검색 조건은 간단하게 pk 를 통해서 구현했으며 없을 시 예외를 터트렸다.
- 아이템을 등록하기 위해, 빌더 패턴을 통해 dto 값과 매핑해 인스턴스를 생성해준다. 이 때
ItemImage
를NULL False
설정을 했으므로 빈ArrayList
를 생성해 넣어준다. - 실재 재고인
Product
엔티티를 재고 수만큼 생성해 주입시켜준다. ( 해당 포스팅과는 크게 연관되지 않으니 넘어가도 된다. ) images
값이 빈 값이 아니라면ItemImage
를 생성하고 S3 에 등록하는 로직이 실행된다. count 지역변수 값이 있는 이유는 각 이미지에 대한 이름을 명시할 때 사용되기 때문이다. 실제 이미지 사진이름을 그대로 사용하게 되면 S3 에서 관리하기 힘들기 때문에 로직 내에서 규격을 정해서 사용하는 것이 좋다. 필자는 가게이름(폴더처리)/모델명+count 값으로 규격을 정했다. ex)ProRental/AUTOBOY1- S3 에 등록하는 로직이 정상적으로 실행되서 반환 값을 받게 되면, 빌더 패턴을 통해
ItemImage
값을 반환 값으로 세팅해준 후 미리 정의한 연관관계 편의 메소드를 통해 연관관계를 맺는다. - 모든 이미지에 대한 처리가 끝나면, insert 쿼리를 실행한 후 로직을 종료한다. 트랜잭션이 끝나면 자동으로
commit
,flush
처리가 되기 때문에 데이터베이스에 정상적으로 반영된다.
ItemServiceImpl.class
@Service
@Slf4j
@RequiredArgsConstructor
public class S3UploadService {
private final AmazonS3Client amazonS3Client;
@Value("${cloud.aws.s3.bucket}")
public String bucket;
@Value("${cloud.aws.s3.filePath}")
private String filePath;
public ImageInfoDto uploadFile(MultipartFile multipartFile, String fileName) throws IOException {
String imagePath = "https://"+filePath+fileName;
//파일 형식 구하기
String contentType = multipartFile.getContentType();
try {
ObjectMetadata metadata = new ObjectMetadata();
metadata.setContentLength(multipartFile.getSize());
metadata.setContentType(contentType);
amazonS3Client.putObject(new PutObjectRequest(bucket, fileName, multipartFile.getInputStream(), metadata)
.withCannedAcl(CannedAccessControlList.PublicRead));
} catch (AmazonServiceException e) {
log.info("error message : {}", e.getMessage());
} catch (SdkClientException e) {
log.info("error message : {}", e.getMessage());
}
return new ImageInfoDto(imagePath, fileName);
}
/*S3의 파일 삭제*/
public void deleteFile(String fileName) {
amazonS3Client.deleteObject(new DeleteObjectRequest(bucket, fileName));
}
}
uploadFile 메소드의 로직을 분석해보자.
- 파일이 저장될 실제 경로를 설정해주어야 한다. 기존에 정의한 S3 경로와 파라미터로 받은 파일 이름을 더해 설정하였다.
- 사진에 대한
ContentType
을 설정해주어야 한다. .jpg, .png 등 다양한 확장자가 존재하고 포맷을 맞춰주기 위함이다. - S3 와 연동하는 과정 내에서 발생할 예외처리를 위해 try-catch 문으로 로직을 감쌌다.
- 사진에 대한 정보인
metaData
를 생성한 후,size
와 정의한contentType
을 설정해준 후, DI 로 주입 받은amazonS3Client
의putObject
메소드를 통해 파일을 등록한다. - 성공적으로 로직을 마치면,
imagePath
,fileName
을 반환한다. 사실 fileName 은 반환할 필요가 없는데 ItemImage 를 생성할 때 보기 좋게 하려고 반환하였다.
ORM 은 DataJpa, QueryDSL 을 사용하고 있다. 아이템을 저장할 때는 DataJpa 인터페이스의 save 메소드를 사용했기 때문에 repository 코드는 생략하겠다.
'개인 공부 > Spring' 카테고리의 다른 글
[Spring] Redis Cache 사용하기 (0) | 2023.03.10 |
---|---|
[Spring] Bean Scope (0) | 2022.11.22 |
[Spring] Multi Thread (2) | 2022.11.21 |
[Spring] HashMap 으로 Cache 구현하기 (0) | 2022.11.14 |
[Spring] Redis vs EHcache vs HashMap (0) | 2022.11.09 |