안녕하세요! delay100입니다.
이번에는 e-commerce에서 가장 중요한 2번째 타이틀인 "상품"을 다뤄보려 합니다.
서론
총 4가지 카테고리로 포스팅을 준비했습니다.
먼저 첫 번째는 상품 기본 Entity 정보에 대해 다룹니다.
두 번째로 상품 이미지는 AWS S3로 관리했습니다.
세 번째로 상품의 출시 여부를 @Scheduled를 이용해서 처리했습니다.
네 번째로 Redis를 이용해서 옵션별로 재고를 캐싱했습니다. (-> 상품 주문 포스팅 이후에 내용이 추가될 예정입니다)
1. 상품
1-1. 상품 구조
상품 Entity에서 다룰 점은 크게 2가지입니다.
1. 상품 카테고리가 enum으로 상품 내부에 들어가있습니다.
-> 아래와 같은 이유로 상품 카테고리를 enum으로 관리하여 시스템의 안정성과 효율성을 높이고자 했습니다.
- 타입 안정성: enum을 사용하면 컴파일 시점에 잘못된 카테고리 값이 사용되는 것을 방지할 수 있습니다. 이는 코드의 안정성을 높여줍니다.
- 코드 가독성: enum은 특정한 의미를 가지는 값을 이름으로 표현할 수 있어 코드의 가독성을 향상시킵니다. 이를 통해 카테고리 값을 쉽게 이해하고 관리할 수 있습니다.
- 일관성 유지: enum을 사용하면 정의된 카테고리 값 외의 값이 사용되지 않도록 강제할 수 있습니다. 이는 데이터의 일관성을 유지하는 데 도움이 됩니다.
- 변경 용이성: enum을 사용하면 카테고리 값의 추가나 변경이 용이해집니다. 모든 카테고리 값이 한 곳에서 관리되므로 유지보수가 쉬워집니다.
- 비즈니스 로직 간결화: enum을 사용하면 카테고리와 관련된 비즈니스 로직을 간결하게 작성할 수 있습니다. 예를 들어, switch 문이나 if-else 문에서 카테고리 값을 사용하는 경우, enum을 통해 코드가 더 직관적이고 간결해집니다.
2. 상품 옵션이라는 테이블이 존재합니다.
-> 모든 상품은 하나이상의 상품 옵션을 가지고 있습니다. 즉 상품의 가장 작은 단위를 상품 옵션이라고 볼 수 있습니다.
기존 설계 및 문제점
기존에는 상품 테이블에 상품의 총 재고 컬럼이 존재했습니다. 따라서 상품 총 재고 컬럼이 상품 테이블과 상품 옵션 테이블 두 곳에서 관리되고 있었습니다. 이는 상품 옵션이 없는 상품도 존재할 수 있기 때문에 이렇게 설계했습니다.
하지만, 상품 옵션이 없는 상품에 대해 나중에 상품 옵션이 추가되면, 기존 옵션과 새로 추가된 옵션 두 가지를 추가해야 하는 상황이 발생합니다. 또한, 상품이 있는지 없는지를 확인하기 위해 null 값을 확인해야 하며, 이를 피하기 위해 상품 테이블에 상품 옵션 개수를 나타내는 컬럼을 추가하는 방법도 고려해볼 수 있습니다.
그러나 상품 옵션의 수량이 변경될 때마다 상품 테이블도 업데이트해야 하므로 결합도가 너무 높아지는 문제가 있습니다.
추후 문제점 및 해결 방안
추후 동시성 이슈를 해결하면서 상품 재고 관리가 복잡해졌습니다. 두 테이블을 관리하는 것은 비효율적이고, 상품 옵션이 없는 경우 옵션 ID의 null 값을 확인해야 하는 것은 보안상 위험합니다.
따라서, 상품 옵션이 없는 경우에도 기본 옵션 데이터를 상품에 추가하려고 합니다. 만약 옵션이 없는 상품에 옵션이 추가되더라도, 기본 옵션 데이터를 포함하여 일관된 데이터 관리가 가능합니다. 이는 데이터의 일관성을 유지하고 보안성을 높이는 데 도움이 될 것입니다.
결론
모든 상품에 대해 최소 1개 이상의 상품 옵션을 갖도록 하여 데이터의 일관성과 보안성을 높이고, 결합도를 낮추어 보다 효율적인 상품 재고 관리를 할 수 있게 되었습니다.
1-2. 상품 구현
Github 주소
1-2-1. Product(Entity)
// Product
@Builder
@Getter
@Entity
@AllArgsConstructor
@NoArgsConstructor
@Table(name = "product")
public class Product extends Timestamped {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long productId;
@Column(nullable = false)
private String productTitle;
@Column(nullable = false)
private String productContent;
@Column(nullable = false)
@Enumerated(EnumType.STRING)
private ProductStatusEnum productStatus;
@Column(nullable = false)
private int productWishlistCount;
@Column(nullable = false)
private int productPrice;
@Column(nullable = false)
@Enumerated(EnumType.STRING)
private ProductCategoryEnum productCategory;
@Column(nullable = false)
private LocalDateTime productStartDate;
public static Product from(ProductRequestDto product) {
return Product.builder()
.productTitle(product.getProductTitle())
.productContent(product.getProductContent())
.productStatus(ProductStatusEnum.valueOf(product.getProductStatus().toUpperCase()))
.productWishlistCount(product.getProductWishlistCount())
.productPrice(product.getProductPrice())
.productCategory(ProductCategoryEnum.valueOf(product.getProductCategory().toUpperCase()))
.productStartDate(product.getProductStartDate())
.build();
}
public void setProductWishlistCount(int wishlistCount) {
this.productWishlistCount = wishlistCount;
}
public void setProductStatus(ProductStatusEnum productStatusEnum) {
this.productStatus = productStatusEnum;
}
}
// ProductCategoryEnum
public enum ProductCategoryEnum {
CLOTHING(Category.CLOTHING),
ELECTRONICS(Category.ELECTRONICS),
FOOD(Category.FOOD),
BOOKS(Category.BOOKS);
private final String category;
ProductCategoryEnum(String category) {
this.category = category;
}
public String getCategory() {
return category;
}
public static class Category {
public static final String CLOTHING = "의류";
public static final String ELECTRONICS = "가전제품";
public static final String FOOD = "식품";
public static final String BOOKS = "도서";
}
}
1-2-2. ProductOption(Entity)
// ProductOption
@Builder(access = AccessLevel.PUBLIC)
@Getter
@Entity
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "product_option")
public class ProductOption extends Timestamped {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long productOptionId;
@Column(nullable = false)
private String productOptionTitle;
@Column(nullable = false)
private int productOptionStock;
@Column(nullable = false)
private int productOptionPrice;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name="product_id", nullable = false)
private Product product;
public static ProductOption from(ProductOptionRequestDto productOption, Product product) {
return ProductOption.builder()
.productOptionTitle(productOption.getProductOptionTitle())
.productOptionStock(productOption.getProductOptionStock())
.productOptionPrice(productOption.getProductOptionPrice())
.product(product)
.build();
}
public void setProductOptionStock(int stock) {
this.productOptionStock = stock;
}
}
1-2-3. Product(Controller, Service)
// ProductController 中 일부
/**
* GET
* 상품 정보 리스트 및 상품명 검색
* @param page 페이지 번호
* @param size 한 페이지에 띄울 수
* @param productTitle 상품명
* @return 상품 정보 리스트 DTO
*/
@GetMapping(BASE_PRODUCT)
public ApiResponse<Page<ProductListResponseDto>> getAllProductList(
@RequestParam int page,
@RequestParam int size,
@RequestParam(required = false) String productTitle
) {
return ApiResponse.createSuccess(productService.getAllProductList(page, size, productTitle));
}
/**
* GET
* 상품 정보 상세
* @param productId 상품 아이디
* @return 상품 정보 상세 DTO
*/
@GetMapping(BASE_PRODUCT + "/{productId}")
public ApiResponse<ProductDetailResponseDto> getProductDetail(
@PathVariable Long productId
) {
return ApiResponse.createSuccess(productService.getProductDetail(productId));
}
// ProductService 中 일부
public Page<ProductListResponseDto> getAllProductList(int page, int size, String productTitle) {
Pageable pageable = PageRequest.of(page, size);
Page<Product> products;
if (productTitle == null || productTitle.isEmpty()) {
products = productRepository.findAll(pageable);
} else {
products = productRepository.findByProductTitleContaining(productTitle, pageable);
}
return products.map(product -> {
ImageResponseDto imageResponse = imageService.findImageResponse(ImageTableEnum.PRODUCT, product.getProductId());
return ProductListResponseDto.from(product, imageResponse);
});
}
@Transactional(readOnly = true)
public ProductDetailResponseDto getProductDetail(Long productId) {
Product product = productRepository.findByProductId(productId)
.orElseThrow(() -> new RuntimeException("상품을 찾을 수 없습니다."));
List<ProductOptionDetailResponseDto> productOptionList = productOptionRepository.findByProduct(product).stream()
.map(ProductOptionDetailResponseDto::from)
.collect(Collectors.toList());
List<ImageResponseDto> imageResponseDtoList = imageService.findImageResponseList(ImageTableEnum.PRODUCT, productId);
return ProductDetailResponseDto.from(product, productOptionList, imageResponseDtoList);
}
2. 상품 이미지(AWS S3)
2-1. S3? 그리고.. 다양한 구현 방식
S3는 Simple Storage Service 의 약자
→ 파일을 저장할 수 있는 AWS 서버(참고 블로그)
S3로 이미지 업로드를 구현하는 방법(참고 블로그)
- Stream 업로드 → 너무 느림
- MultipartFile 업로드 → 채택!
- AWS Multipart 업로드 → 오버 엔지니어링
2-2. 동작 방식
꽤나 상세한 블로그 → 채택!
위의 블로그를 참고하여 적용했습니다.
1. 사용자가 상품 정보(상품, 상품 옵션)와 이미지를 가지고 서버에 상품 추가 요청을 보냅니다.
2. S3에 상품 이미지를 저장 요청을 보냅니다.
3. S3에 저장된 상품 이미지의 url을 서버로 응답받습니다.
4. 상품, 상품 옵션 정보와 S3 url 정보를 DB에 담습니다. 테이블은 상품(Product), 상품 옵션(ProductOption), 이미지(Image)에 각각 담아주었습니다.
이러면 상품 추가가 완료됩니다!
상품이 S3에 저장되면 아래와 같이 버킷 형태로 들어가있게 됩니다.
S3에 이미지를 단건, 다건 모두 추가 가능하도록 만들었습니다. uploadMultiImage에서 SingleImage를 호출하는 식으로 구현했습니다.(ImageService 코드 참고)
그리고 전체 ERD를 보면, 이미지가 연관관계 없이 따로 분리되어있습니다. 이는 상품에만 이미지를 귀속하고 싶지 않기 때문에 이렇게 만들었습니다.
연관관계가 없기 때문에 이미지 테이블에 해당 이미지와 "(연결할) 이미지 테이블", "(연결할) 이미지 테이블 ID"를 적어줘야합니다.
위와같이 PRODUCT테이블을 명시하고 테이블 id도 명시해두었습니다. 또한 이미지 url을 저장해둬서, 나중에 상품 정보를 가져올때는 S3를 거칠 필요 없이 DB에 저장된 S3 url을 가져오면 됩니다.
2-3. AWS S3 도입
Github 주소
- Product
- ProductController
- ProductService
- image -> 이미지 Config, Entity, Repository, Service 파일 등 이미지 관련 디렉토리
2-3-1. Product(Controller, Service)
// ProductController
/**
* POST
* 상품 및 상품 옵션 추가
* @param productWithOptionsRequestDto 추가할 상품, 상품 옵션 정보
* @return 추가된 상품 정보 DTO
*/
@PostMapping(BASE_PRODUCT) // 권한 처리 추가
public ApiResponse<ProductResponseDto> createProduct(
@RequestPart ProductWithOptionsRequestDto productWithOptionsRequestDto,
@RequestPart List<MultipartFile> imageFileList
) {
return ApiResponse.createSuccess(productService.createProduct(productWithOptionsRequestDto.getProductRequestDto(), imageFileList, productWithOptionsRequestDto.getProductOptionRequestDto()));
}
// ProductService
public ProductResponseDto createProduct(ProductRequestDto productRequestDto, List<MultipartFile> imageFileList, List<ProductOptionRequestDto> productOptionRequestDtoList) {
// 리스트가 비어 있는지 확인
if (imageFileList == null || imageFileList.isEmpty()) {
throw new IllegalArgumentException("이미지가 없습니다.");
}
for (MultipartFile file : imageFileList) {
if (file.isEmpty()) {
throw new IllegalArgumentException("빈 파일이 포함되어 있습니다.");
}
}
if (productOptionRequestDtoList == null || productOptionRequestDtoList.isEmpty()) {
throw new IllegalArgumentException("상품 옵션이 없습니다.(상품 옵션은 기본1개)");
}
Product product = Product.from(productRequestDto);
productRepository.save(product);
List<ImageResponseDto> imageResponseDto = imageService.uploadMultiImage(
ImageInfoRequestDto.from(ImageTableEnum.PRODUCT, product.getProductId()),
imageFileList
);
// 상품 옵션 추가
createProductOption(product.getProductId(), productOptionRequestDtoList);
return ProductResponseDto.from(product, imageResponseDto);
}
public List<ProductOptionResponseDto> createProductOption(Long productId, List<ProductOptionRequestDto> productOptionRequestDtoList) {
Product product = productRepository.findByProductId(productId)
.orElseThrow(() -> new IllegalArgumentException("상품이 없습니다."));
if (productOptionRequestDtoList == null || productOptionRequestDtoList.isEmpty()) {
throw new IllegalArgumentException("추가할 상품 옵션이 없습니다.(상품 옵션은 기본1개)");
}
List<ProductOption> productOptionList = productOptionRequestDtoList.stream()
.map(optionDto -> ProductOption.from(optionDto, product))
.collect(Collectors.toList());
productOptionRepository.saveAll(productOptionList);
saveProductOptionStockToRedis(productId, productOptionList);
return ProductOptionResponseDto.from(productOptionList);
}
private void saveProductOptionStockToRedis(Long productId, List<ProductOption> optionList) {
for (ProductOption option : optionList) {
redisService.setInitialStock(productId, option.getProductOptionId(), option.getProductOptionStock());
}
}
2-3-2. Image(Entity, Service)
// Image
@Entity
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Image extends Timestamped {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long imageId;
@Column(nullable = false)
private String imageUrl;
@Column(nullable = false)
@Enumerated(EnumType.STRING)
private ImageTableEnum imageTable;
@Column(nullable = false)
private Long imageTableId;
@Column(nullable = false)
private String imageFileName;
public static Image from(ImageRequestDto image) {
return Image.builder()
.imageUrl(image.getImageUrl())
.imageTable(image.getImageTable())
.imageTableId(image.getImageTableId())
.imageFileName(image.getImageFileName())
.build();
}
}
// ImageService
@Slf4j
@Service
@RequiredArgsConstructor
public class ImageService {
private final ImageRepository imageRepository;
private final AmazonS3 amazonS3;
@Value("${AWS_S3_BUCKET}")
private String bucket;
@Transactional
public ImageResponseDto uploadSingleImage(ImageInfoRequestDto imageInfoRequestDto, MultipartFile multipartFile) throws IOException {
String originalFileName = multipartFile.getOriginalFilename(); // 파일 이름에서 공백 제거한 새로운 파일이름 생성
String uuid = UUID.randomUUID().toString();
String uniqueFileName = uuid + "_" + originalFileName.replaceAll("\\s", "_");
String fileName = imageInfoRequestDto.getImageTable() + "/" + uniqueFileName;
File uploadFile = convert(multipartFile);
String uploadImageUrl = putS3(uploadFile, fileName);
removeNewFile(uploadFile);
imageRepository.save(Image.from(ImageRequestDto.from(
uploadImageUrl,
imageInfoRequestDto.getImageTable(),
imageInfoRequestDto.getImageTableId(),
fileName
)));
return ImageResponseDto.builder()
.uploadImageUrl(uploadImageUrl)
.fileName(originalFileName)
.build();
}
public List<ImageResponseDto> uploadMultiImage(ImageInfoRequestDto imageInfoRequestDto, List<MultipartFile> multipartFileList) {
return multipartFileList.stream()
.map(file -> {
try {
return uploadSingleImage(imageInfoRequestDto, file);
} catch (IOException e) {
throw new RuntimeException("이미지 업로드에 실패했습니다.", e);
}
})
.toList();
}
private File convert(MultipartFile file) throws IOException {
String originalFileName = file.getOriginalFilename();
String uuid = UUID.randomUUID().toString();
String uniqueFileName = uuid + "_" + originalFileName.replaceAll("\\s", "_");
File convertFile = new File(uniqueFileName);
if (convertFile.createNewFile()) {
try (FileOutputStream fos = new FileOutputStream(convertFile)){
fos.write(file.getBytes());
} catch (IOException e) {
throw e;
}
return convertFile;
}
throw new IllegalArgumentException(String.format("파일 변환에 실패했습니다. %s", originalFileName));
}
private String putS3(File uploadFile, String fileName) {
amazonS3.putObject(new PutObjectRequest(bucket, fileName, uploadFile)
.withCannedAcl(CannedAccessControlList.PublicRead));
return amazonS3.getUrl(bucket, fileName).toString();
}
private void removeNewFile(File targetFile) {
if (targetFile.delete()) {
log.info("파일 삭제 성공");
} else {
log.info("파일 삭제 실패");
}
}
public List<ImageResponseDto> findImageResponseList(ImageTableEnum imageTableEnum, Long productId) {
return imageRepository.findByImageTableAndImageTableId(imageTableEnum, productId).stream()
.map(image -> ImageResponseDto.from(image.getImageUrl(), image.getImageFileName()))
.toList();
}
public ImageResponseDto findImageResponse(ImageTableEnum imageTableEnum, Long productId) {
return imageRepository.findFirstByImageTableAndImageTableId(imageTableEnum, productId, PageRequest.of(0, 1))
.map(image -> ImageResponseDto.builder()
.uploadImageUrl(image.getImageUrl())
.fileName(image.getImageFileName())
.build())
.orElseThrow(() -> new IllegalArgumentException("이미지를 찾을 수 없습니다."));
}
}
3. 상품 상태 변경(DB Scheduling)
3-1. 동작 방식
사실 이름만 "데이터베이스 스케쥴링"이라고 거창하게 가지고 있지만..
실상은 @Scheduled 어노테이션을 붙혀서 원하는 시간마다 데이터베이스 값을 순회하는 동작을 합니다.
Scheduling을 하기 위해서는 꼭 어플리케이션 최상단(제 프로젝트에서는 ProductshopApplication)에 @EnableScheduling을 적어줘야 합니다.
여기서 이용한 상품 상태 변경 스케줄링은 COMING_SOON(판매 예정) 상태를 AVAILABLE(판매 중) 상태로 변경하는 방식입니다.
예를 들어 상품의 오픈 시각이 오후 2시인 경우, 해당 시각에 DB에서 상품의 상태를 COMING_SOON에서 AVAILABLE로 자동으로 변경합니다.1분마다 체크하도록 크론 표현식을 작성했습니다.(@Scheduled(cron ="0 * * * * *"))
3-2. 상품 상태 변경(DB Scheduiling) 도입
Github 주소
// ProductStatusEnum
public enum ProductStatusEnum {
AVAILABLE(ProductStatus.AVAILABLE),
OUT_OF_STOCK(ProductStatus.OUT_OF_STOCK),
COMING_SOON(ProductStatus.COMING_SOON);
private final String status;
ProductStatusEnum(String status) {
this.status = status;
}
public String getStatus() {
return status;
}
public static class ProductStatus {
public static final String AVAILABLE = "판매중";
public static final String OUT_OF_STOCK = "품절";
public static final String COMING_SOON = "판매예정";
}
}
// ProductStatusUpdateService
@Service
@RequiredArgsConstructor
public class ProductStatusUpdateService {
private final ProductRepository productRepository;
@Scheduled(cron ="0 * * * * *")
@Transactional
public void updateProductStatus() {
List<Product> products = productRepository.findByProductStatusAndProductStartDateBefore(ProductStatusEnum.COMING_SOON, LocalDateTime.now());
for(Product product : products) {
product.setProductStatus(ProductStatusEnum.AVAILABLE);
productRepository.save(product);
}
}
}
// ProductshopApplication
@EnableAsync
@EnableJpaAuditing
@SpringBootApplication
@EnableScheduling // -> 적어줘야 스케줄링 가능!
public class ProductshopApplication {
public static void main(String[] args) {
SpringApplication.run(ProductshopApplication.class, args);
}
}
4. 옵션별 재고 Redis로 추가 관리
이 내용은 상품 주문 이후에 성능 개선 사항으로 추후에 추가된 내용입니다. 상품 주문 정리 포스팅은 여길 참고해주세요!
5. 결과 및 예제(API)
아래의 링크에서 더 자세히 보실 수 있습니다.
API(POSTMAN) : https://documenter.getpostman.com/view/23481846/2sA3kSo3ZJ
6. 이후 추가하고 싶은 기능
6-1. Redis 재고 캐싱 TTL 설정
상품이 만들어질때 무조건 Redis에도 재고를 같이 올리고 있습니다. 그러나 이렇게 하면 Redis를 캐싱의 용도 뿐만 아니라 DB의 용도로도 사용하게 됩니다. 상품 추가와 동시에 Redis에 적재하기 때문에 오랫동안 접근하지 않고 있는 상품에 대해서도 Redis에 값이 떠있게 되는 셈이죠..
이를 해결하기 위해서는 Redis에 상품제한시간(TTL)을 걸어서 처리하면 될 듯 싶습니다. 결국, 상품 주문이 들어왔을 때 DB의 재고 값을 Redis에 재고정보를 올리고, 만약 이미 올려져있다면 적재하지 않는 방향으로 처리해야할 것 같습니다.
이 상황에서 동시성이슈가 발생할 수도 있을 것이라 예상되는데, DB에서 데이터를 가져오는 시점, Redis에 올리는 시점에 대해 Lock을 걸어야하지않나? 생각이 듭니다,, Lock말고 더 좋은 방법이 찾아보면 있을 것 같기도 하고요,! 고민을 좀 더 해봐야할 것 같습니다.
결국, 아래의 2가지를 어떻게 처리할 것인지 고민이 필요할 듯 싶습니다.
1. 상품 재고 캐싱의 TTL 설정을 몇 분 정도 하는게 적당한가?
2. 동시성 이슈?
6-2. 회원 이미지 추가
e-commerce에서 상품 이미지는 필수적이지만, 회원 이미지는 필수적이지 않다고 생각하여 아직 도입하지 않았습니다. 그러나 추후 리뷰 기능 등을 도입할 때 회원을 구분하기 위해 회원 이미지를 추가할 수 있습니다. 이는 사용자 경험을 개인화하고, 신뢰성을 높이는 데 도움을 줄 것입니다.
6-3. Scheduling을 위한 서버 분리하기
현재는 Spring Boot 서버 하나로 스케줄링 작업이 진행되고 있습니다. 이러한 구조는 간단한 설정으로도 스케줄링 작업을 쉽게 관리할 수 있는 장점이 있습니다. 그러나 단일 서버에서 모든 스케줄링 작업을 처리하는 것은 확장성, 신뢰성, 유지보수 측면에서 한계가 있습니다.
- 확장성:
- 단일 서버에서는 스케줄링 작업의 수가 증가할수록 서버의 부하가 증가하게 됩니다. 이는 서버의 성능 저하를 초래할 수 있으며, 결국 전체 시스템의 응답 시간을 느리게 할 수 있습니다. 스케일 아웃을 통해 여러 서버로 부하를 분산시킬 수 있으며, 이를 통해 시스템의 확장성이 향상됩니다.
- 신뢰성:
- 단일 서버에서 스케줄링 작업을 수행할 경우, 서버 장애 시 모든 스케줄링 작업이 중단될 위험이 있습니다. 이로 인해 중요한 작업이 누락되거나 지연될 수 있습니다. 다중 서버 환경에서는 한 서버가 다운되더라도 다른 서버가 스케줄링 작업을 이어받아 처리할 수 있어 시스템의 신뢰성이 높아집니다.
- 유지보수:
- 단일 서버에서 모든 스케줄링 작업을 관리하는 것은 복잡한 유지보수 문제를 야기할 수 있습니다. 특히, 스케줄링 로직이 복잡해질수록 코드의 유지보수가 어려워집니다. 별도의 스케줄링 서버를 도입하면 스케줄링 작업을 보다 체계적으로 관리할 수 있으며, 유지보수가 용이해집니다.
- 성능 관리:
- 단일 서버에서 애플리케이션 로직과 스케줄링 로직을 함께 처리하는 경우, 서로 간섭이 발생할 수 있습니다. 예를 들어, 스케줄링 작업이 많은 리소스를 소모하면 애플리케이션의 응답성이 저하될 수 있습니다. 스케줄링 작업을 전담하는 서버를 별도로 운영하면 이러한 성능 문제를 최소화할 수 있습니다.
- 스케일 아웃:
- 스케줄링 서버를 분리하고 다중 서버로 구성하면, 스케줄링 작업을 여러 서버에 분산시킬 수 있습니다. 이는 필요에 따라 서버를 추가하여 부하를 분산시키는 것이 가능해집니다. 이렇게 하면 시스템의 전체 처리 능력이 향상되고, 트래픽 증가에도 유연하게 대응할 수 있습니다.
결론
따라서, 스케줄링 작업의 효율적이고 안정적인 관리를 위해 전용 스케줄링 서버를 구축하고, 이를 스케일 아웃할 수 있는 구조로 설계하는 것이 바람직합니다. 이를 통해 확장성, 신뢰성, 유지보수성, 성능 관리 측면에서 이점을 얻을 수 있으며, 시스템의 유연성과 처리 능력을 크게 향상시킬 수 있습니다.
긴 글 읽어주셔서 감사합니다! 코드나 동작 방식에 대해 궁금한 점이 있으시면 언제든지 질문해 주세요. 피드백도 환영합니다.
'Study > SpringBoot' 카테고리의 다른 글
[e-commerce 프로젝트] 7. 상품 주문 - 1(GET 메소드들, DB스케줄링) (1) | 2024.07.30 |
---|---|
[e-commerce 프로젝트] 6. 위시리스트, 장바구니 (0) | 2024.07.29 |
[e-commerce 프로젝트] 4. 마이페이지 정보 업데이트 (4) | 2024.07.25 |
[e-commerce 프로젝트] 3. 로그인(SpringSecurity + JWT 로그인 및 인가) (7) | 2024.07.24 |
[e-commerce 프로젝트] 2. 회원가입(네이버 이메일 인증, 개인정보·비밀번호 암호화) (6) | 2024.07.24 |