[프로젝트] 56. 실시간 재고 관리 서비스 리팩토링
이 리팩토링의 주요 목표는 재고 데이터의 실시간 처리를 개선하고, 분산 시스템 환경에서의 데이터 일관성을 보장하는 것입니다. 리팩토링 과정에서 중점을 둔 부분은 다음과 같습니다.
1. 실시간 재고 관리
- Redis 사용: 재고 정보를 Redis에 저장하여 빠른 읽기/쓰기 속도를 제공하며, 재고 정보의 실시간 업데이트를 가능하게 합니다. 이는 고객이 최신 재고 상태를 바탕으로 결정을 내릴 수 있게 도와줍니다.
- 재고 정보 구조화: StockService에서는 Redis에 저장된 재고 정보를 CreateStockDto 객체로 변환하여 처리합니다. 이는 데이터를 구조화하고 애플리케이션 내에서의 데이터 처리를 용이하게 합니다.
2. WebClient 연결
- 비동기 처리 도입: WebClient를 통한 비동기 HTTP 요청을 사용하여 재고 업데이트와 같은 네트워크 I/O 작업의 성능을 향상시켰습니다. 이는 시스템의 전반적인 응답성과 처리량을 개선합니다.
- 내부 API 연동: 주문 처리 로직에서 WebClient를 사용하여 내부 재고 관리 서비스와 통신합니다. 주문이 처리될 때마다 관련 재고 정보가 실시간으로 업데이트되어 재고 관리의 정확성을 높입니다.
3. 주문 로직 변경
- 주문과 재고 관리의 분리: 주문 로직에서 직접 재고를 관리하는 대신, 재고 관리 서비스에 요청을 보내 재고를 업데이트합니다. 이는 서비스 간의 책임을 명확히 하여 시스템의 유지보수성을 높입니다.
- 결제 실패 및 성공 처리: 결제 과정에서 실패율을 고려하여 실패와 성공 시 각각의 로직을 구현하였습니다. 실패 시 주문 상태를 적절히 업데이트하고, 필요한 경우 재고를 복원합니다.
StockController
StockController는 재고 관련 HTTP 요청을 처리합니다. 이 컨트롤러는 재고 데이터를 생성, 조회, 업데이트, 삭제하는 API 엔드포인트를 제공합니다. 각 메서드는 StockService를 통해 실제 비즈니스 로직을 처리하며, HTTP 요청에 대한 적절한 응답을 반환합니다.
@RestController
@RequestMapping("/api/stocks")
@RequiredArgsConstructor
public class StockController {
private final StockService stockService;
@GetMapping("/all")
public ResponseEntity<List<CreateStockDto>> getAllStocks() {
List<CreateStockDto> stocks = stockService.findAllStocks();
return ResponseEntity.ok(stocks);
}
@GetMapping("/{stockId}")
public ResponseEntity<CreateStockDto> getStockByStockId(@PathVariable Long stockId) {
Optional<CreateStockDto> stock = stockService.findStockByStockId(stockId);
return stock.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
}
@GetMapping("/order/{orderId}")
public ResponseEntity<List<CreateStockDto>> getStocksByOrderId(@PathVariable Long orderId) {
List<CreateStockDto> stocks = stockService.findStocksByOrderId(orderId);
return ResponseEntity.ok(stocks);
}
@GetMapping("/item/{itemId}")
public ResponseEntity<List<CreateStockDto>> getStocksByItemId(@PathVariable Long itemId) {
List<CreateStockDto> stocks = stockService.findStocksByItemId(itemId);
return ResponseEntity.ok(stocks);
}
@DeleteMapping("/delete/{stockId}")
public ResponseEntity<?> deleteStock(@PathVariable Long stockId) {
stockService.deleteStock(stockId);
return ResponseEntity.ok().build();
}
}
InternalStockController
InternalStockController는 내부 서비스 간 통신을 위한 API 엔드포인트를 제공합니다. 이 컨트롤러는 주로 주문 서비스에서 재고 관리 서비스로 재고 업데이트 요청을 보낼 때 사용됩니다. 예를 들어, 주문이 완료되었을 때 재고 수량을 감소시키는 로직을 처리합니다.
@RestController
@RequestMapping("/api/internal/stocks")
@RequiredArgsConstructor
public class InternalStockController {
private final StockService stockService;
@PostMapping
public ResponseEntity<?> saveStock(@RequestBody CreateStockDto stockDto) {
stockService.saveStock(stockDto);
return ResponseEntity.ok().build();
}
@DeleteMapping("/delete/{orderID}")
public ResponseEntity<?> deleteStocksByOrderId(@PathVariable Long orderID) {
stockService.deleteStocksByOrderId(orderID);
return ResponseEntity.ok().build();
}
@PutMapping("/{itemId}")
public ResponseEntity<?> updateStockQuantity(@PathVariable Long itemId, @RequestParam int stockQuantity) {
stockService.updateStockQuantity(itemId, stockQuantity);
return ResponseEntity.ok().build();
}
}
StockService
StockService는 재고 관리의 핵심 비즈니스 로직을 담당합니다. Redis를 사용하여 재고 데이터를 관리하며, 재고 데이터의 CRUD(Create, Read, Update, Delete) 연산을 수행합니다. 각 메서드는 Redis에서 재고 정보를 조회하거나 업데이트하는 작업을 수행합니다.
@Slf4j
@Service
@RequiredArgsConstructor
public class StockService {
private final StringRedisTemplate redisTemplate;
private final ObjectMapper objectMapper;
public List<CreateStockDto> findAllStocks() {
List<CreateStockDto> stocks = new ArrayList<>();
ScanOptions options = ScanOptions.scanOptions().match("stock:*").count(100).build();
try (Cursor<byte[]> cursor = redisTemplate.getConnectionFactory().getConnection().scan(options)) {
while (cursor.hasNext()) {
String key = new String(cursor.next());
CreateStockDto stock = getStockFromRedis(key);
if (stock != null) {
stocks.add(stock);
}
}
} catch (Exception e) {
log.error("Error retrieving all stocks. Error: {}", e.getMessage(), e);
}
return stocks;
}
private CreateStockDto getStockFromRedis(String key) {
String stockJson = redisTemplate.opsForValue().get(key);
if (stockJson != null && !stockJson.isEmpty()) {
try {
return objectMapper.readValue(stockJson, CreateStockDto.class);
} catch (Exception e) {
log.error("Failed to deserialize stock JSON for key: {}. Error: {}", key, e.getMessage(), e);
return null;
}
}
log.warn("No stock information found for key: {}", key);
return null;
}
public Optional<CreateStockDto> findStockByStockId(Long stockId) {
String key = "stock:" + stockId;
CreateStockDto stock = getStockFromRedis(key);
return Optional.ofNullable(stock);
}
public List<CreateStockDto> findStocksByOrderId(Long orderId) {
return findStocksByIndexKey("orderIndex:" + orderId);
}
public List<CreateStockDto> findStocksByItemId(Long itemId) {
return findStocksByIndexKey("itemIndex:" + itemId);
}
private List<CreateStockDto> findStocksByIndexKey(String indexKey) {
Set<String> stockIds = redisTemplate.opsForSet().members(indexKey);
List<CreateStockDto> stocks = new ArrayList<>();
for (String stockId : stockIds) {
CreateStockDto stock = getStockFromRedis("stock:" + stockId);
if (stock != null) {
stocks.add(stock);
}
}
return stocks;
}
public void deleteStock(Long stockId) {
String key = "stock:" + stockId;
redisTemplate.delete(key);
}
public void saveStock(CreateStockDto stockDto) {
String stockKey = "stock:" + stockDto.getStockId();
String orderIndexKey = "orderIndex:" + stockDto.getOrderId();
String itemIndexKey = "itemIndex:" + stockDto.getItemId();
try {
String stockJson = objectMapper.writeValueAsString(stockDto);
redisTemplate.opsForValue().set(stockKey, stockJson);
redisTemplate.opsForSet().add(orderIndexKey, stockDto.getStockId().toString());
redisTemplate.opsForSet().add(itemIndexKey, stockDto.getStockId().toString());
log.info("Successfully saved stock for stockId: {} as JSON: {}", stockDto.getStockId(), stockJson);
} catch (Exception e) {
log.error("Failed to serialize stock for stockId: {}. Error: {}", stockDto.getStockId(), e.getMessage(), e);
}
}
public void deleteStocksByOrderId(Long orderId) {
String orderIndexKey = "orderIndex:" + orderId;
Set<String> stockIds = redisTemplate.opsForSet().members(orderIndexKey);
if (stockIds != null && !stockIds.isEmpty()) {
stockIds.forEach(stockId -> {
String stockKey = "stock:" + stockId;
redisTemplate.delete(stockKey);
});
redisTemplate.delete(orderIndexKey);
log.info("Deleted all stocks associated with orderId: {}", orderId);
} else {
log.info("No stocks found for orderId: {}, nothing to delete", orderId);
}
}
public void updateStockQuantity(Long itemId, int newStockQuantity) {
String key = "item:" + itemId;
redisTemplate.opsForHash().put(key, "stockQuantity", String.valueOf(newStockQuantity));
}
}
- findAllStocks(): 모든 재고 정보를 조회합니다. Redis에서 "stock:*" 패턴에 매칭되는 모든 키를 스캔하고, 해당 키에 저장된 재고 정보를 가져옵니다.
- findStockByStockId(), findStocksByOrderId(), findStocksByItemId(): 특정 조건(재고 ID, 주문 ID, 상품 ID)에 맞는 재고 정보를 조회합니다.
- deleteStock(): 특정 재고 정보를 삭제합니다.
- saveStock(): 새로운 재고 정보를 저장하거나 기존 재고 정보를 업데이트합니다.
CreateStockDto
CreateStockDto는 재고 데이터를 전달하는 데 사용되는 데이터 전송 객체(Data Transfer Object)입니다. 재고 관련 정보를 캡슐화하고, 서비스 간 데이터 교환 시 사용됩니다.
@Getter
@Setter
public class CreateStockDto {
private Long stockId;
private Long orderId;
private Long itemId;
private Long userId;
private int stockQuantity;
}
OrderService 변경 및 WebClient 연결
OrderService는 주문 관련 비즈니스 로직을 처리합니다. 이 서비스는 주문 생성, 결제 처리, 주문 취소 등의 기능을 수행합니다. 주문 처리 로직에서는 WebClient를 사용하여 재고 관리 서비스의 내부 API에 비동기적으로 요청을 보내 재고 수량을 업데이트합니다. 이를 통해 주문과 재고 관리가 밀접하게 연동되며, 시스템 전체의 일관성을 유지합니다.
@Slf4j
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final ItemRepository itemRepository;
private final UserRepository userRepository;
private final WebClient webClient;
@Transactional
public Long prepareOrder(Long userId, List<CreateOrderItemDto> orderItemDtos) {
User user = findEntityById(userRepository::findById, userId, "회원");
Order order = createAndSaveOrder(user, orderItemDtos);
return order.getId();
}
private Order createAndSaveOrder(User user, List<CreateOrderItemDto> orderItemDtos) {
Order order = new Order(user, OrderStatus.PREPARATION);
orderItemDtos.forEach(dto -> {
Item item = findEntityById(itemRepository::findById, dto.getItemId(), "상품");
validateItemForOrder(item);
OrderItem orderItem = dto.toEntity(item);
order.addOrderItem(orderItem);
});
Order savedOrder = orderRepository.save(order);
savedOrder.getOrderItems().forEach(orderItem -> {
saveStock(createStockDto(savedOrder, orderItem));
});
return savedOrder;
}
private CreateStockDto createStockDto(Order order, OrderItem orderItem) {
CreateStockDto stockDto = new CreateStockDto();
stockDto.setStockId(orderItem.getId());
stockDto.setOrderId(order.getId());
stockDto.setItemId(orderItem.getItem().getId());
stockDto.setUserId(order.getUser().getId());
stockDto.setStockQuantity(orderItem.getCount());
return stockDto;
}
@Transactional
public OrderStatus processOrder(Long userId, Long orderId) {
findEntityById(userRepository::findById, userId, "회원");
Order order = findEntityById(orderRepository::findById, orderId, "주문");
if (order.getStatus() == OrderStatus.ORDER) {
throw new BadRequestException("이미 주문이 완료되었습니다.");
}
return orderPayAndUpdateStatus(order);
}
private OrderStatus orderPayAndUpdateStatus(Order order) {
double randomValue = Math.random();
if (randomValue < 0.2) { // 결제 이탈율 20%
return handlePaymentFailure(order, OrderStatus.CANCEL);
} else if (randomValue < 0.4) { // 0.2 ~ 0.4 범위 내에서 결제 실패 처리 20%
return handlePaymentFailure(order, OrderStatus.FAIL);
}
// 결제 성공
return handlePaymentSuccess(order);
}
private OrderStatus handlePaymentFailure(Order order, OrderStatus failureStatus) {
order.updateStatus(failureStatus);
order.cancel();
deleteStock(order.getId());
return order.getStatus();
}
private OrderStatus handlePaymentSuccess(Order order) {
order.updateStatus(OrderStatus.ORDER);
updateOrderItemsStock(order);
Order savedOrder = orderRepository.save(order);
return savedOrder.getStatus();
}
private void updateOrderItemsStock(Order order) {
// 결제 성공 시 각 주문 항목에 대해 재고 수량 업데이트
order.getOrderItems().forEach(orderItem -> {
int newStockQuantity = orderItem.getItem().getStockQuantity();
updateStockQuantity(orderItem.getItem().getId(), newStockQuantity)
.subscribe(result -> log.info(result),
error -> log.error("Error updating stock: ", error));
});
}
private void validateItemForOrder(Item item) {
if (item instanceof ReservedItem) {
ReservedItem reservedItem = (ReservedItem) item;
LocalDateTime now = LocalDateTime.now();
if (now.isBefore(reservedItem.getReservationStart()) || now.isAfter(reservedItem.getReservationEnd())) {
throw new BadRequestException("예약 가능한 시간이 아닙니다.");
}
}
}
private <T> T findEntityById(Function<Long, Optional<T>> finder, Long id, String entityName) {
return finder.apply(id).orElseThrow(() -> new BadRequestException(entityName + " 정보를 찾을 수 없습니다."));
}
private void validateUser(Order order, Long userId) {
if (!order.getUser().getId().equals(userId)) {
throw new BadRequestException("주문 취소 권한이 없습니다.");
}
}
private final String baseUrl = "http://localhost:8085/api/internal/stocks";
private Mono<String> updateStockQuantity(Long itemId, int stockQuantity) {
String uri = UriComponentsBuilder.fromUriString(baseUrl + "/{itemId}")
.queryParam("stockQuantity", stockQuantity)
.buildAndExpand(itemId)
.toUriString();
return webClient.put()
.uri(uri)
.retrieve()
.bodyToMono(String.class)
.onErrorResume(e -> Mono.just("Error updating stock quantity: " + e.getMessage()));
}
private void saveStock(CreateStockDto stockDto) {
webClient.post()
.uri(baseUrl)
.bodyValue(stockDto)
.retrieve()
.toBodilessEntity()
.block();
}
private void deleteStock(Long orderId) {
String uri = baseUrl + "/delete/{orderId}";
webClient.delete()
.uri(uri, orderId)
.retrieve()
.bodyToMono(Void.class)
.block();
}
}
- prepareOrder(): 주문을 준비하는 과정에서 재고 데이터를 확인하고 주문 항목에 대한 재고를 감소시키는 작업을 수행합니다.
- processOrder(), cancelOrder(), removeOrderItem(): 주문 처리, 취소, 항목 제거 등의 작업 시 재고 수량을 적절히 조정합니다.
고찰
이번 리팩토링은 실시간 재고 관리 시스템의 성능과 확장성을 크게 개선하였습니다. 주문 처리 시 실시간으로 재고 정보가 업데이트되며, 재고 관리의 정확성과 시스템의 반응성이 향상됩니다. WebClient의 비동기 처리 방식은 시스템의 성능을 개선하며, 내부 서비스 간 통신을 보다 효율적으로 만듭니다.