배경
요구사항 명세서를 받게 되었을 때 주문을 취소하는 부분에 '5분 이후 취소 불가'라고 되어 있어서 단순히 DB에 저장할 때 생성시간 저장해놓고, 주문 취소 API가 날라올 시 조회 후 생성 시간이 5분 초과면 삭제 못하게 하면 되는거 아닌가? 라고 간단하게 생각하고 구현했습니다.
하지만 고객(튜터)분들이 요구했던 내용과는 조금 달랐죠. 튜터분들의 요구사항은 주문 후 5분까지 DB에 저장되지 않았다가 주문 취소가 없을 시 DB에 저장 쿼리를 보내는 것을 구현해보라는 것이었습니다... 역시 사람은 각자 생각하는게 다르더군요. 또 한번 소통의 중요성과 중간 보고 중요성을 느낍니다! ㅎㅎ
무튼 이런 요구사항을 알고 나니 조금 복잡해졌습니다.. 어떻게 구현해야할까...? 조금 힌트를 얻어보니 tread를 이용하는 방법과 scheduler를 이용하는 방법이 있더군요. tread를 이용하는 방법은 요청이 많이 들어오게 될 시, thread를 너무 많이 생성하게 되어 메모리에 무리가 갈 수 있기 때문에 scheduler를 이용하는 방법을 택해서 진행해보겠습니다.
내용
먼저 어떻게 구현해야 할까.. 고민을 좀 많이 했습니다. DB에 저장하지 않고 하려면 따로 객체를 만들어 저장해놓고 scheduler를 이용해 특정 작업이 실행되게 해야하는데.. 어떻게 할까 고민하다가 GPT에게 질문을 던져보았습니다.
주문이 여러개 들어오면 보통 thread가 다중 환경이 되긴 할텐데 어떻게 구현했는지 봤더니 ConcurrentHashMap을 이용해 객체를 저장해두더군요. 왜 ConcurrentHashMap을 사용할까? 구글리을 통해 검색을 해보기 멀티 tread 환경에서 다중 읽기가 가능하지만 스레드간 동기화 락을 걸어 쓰기에는 안전하다고 하는군요. 아래는 참고한 글입니다.
https://parkmuhyeun.github.io/woowacourse/2023-09-09-Concurrent-Hashmap/
자 그럼 이제 사용할 것도 정했으니 scheduler를 바로 쓰면 되겠지? 라고 생각했는데 스케쥴러는 단순히 특정 반복 시간마다 진행을 할 수 있도록 한다는군요. 이렇게 되면 5분마다 scheduler를 통해 주문을 db에 저장한다고 했을 때, 5분이 지나서 바로 주문이 들어온 것은 대략 5분이 지나야 db에 들어가게 되고, 5분이 지나서 다시 4분이 지났을 때 쯤 주문이 들어오게 되면 이 주문은 1분만 기다려도 바로 DB에 들어가게 됩니다.
이런 사항이 있다는걸 생각해내고 어떻게 해결할까.. 구글링과 gpt의 도움을 받으니 TaskScheduler라는 것을 이용하면 될 것 같다고 합니다. TaskScheduler는 특정 작업을 시간에 맞춰 작업을 할 수있도록 해준다는군요! 이 방법을 이용해봐야 겠습니다. 참고한 글들은 다음과 같습니다.
https://velog.io/@carrot1st/Thread-vs-Task-vs-Event
이 글도 Thread와 Runnable을 이애하는데 도움이 됐습니다.
https://mangkyu.tistory.com/258
먼저 Scheulder를 사용하기 위해 설정을 해놓을겁니다.
main에 @EnableScheduling을 달아주게 되면 프로젝트 전역에서 사용 가능하겠죠?
@SpringBootApplication
@EnableAspectJAutoProxy
@EnableScheduling
@EnableJpaAuditing(auditorAwareRef = "userAuditorAware")
public class HarmonyApplication {
public static void main(String[] args) {
SpringApplication.run(HarmonyApplication.class, args);
}
}
다음으로 Scheduler를 사용하기 위한 Config 설정입니다.
Poolsize 같은 경우 thread pool의 개수를 정해줍니다. 트래픽의 정도에 따라 정해주면 될것 같은데, 저는 20정도로 했습니다. 이 부분의 설정은 경험이 많이 필요할 듯합니다.
@Configuration
public class SchedulerConfig {
@Bean
public TaskScheduler taskScheduler() {
ThreadPoolTaskScheduler taskScheduler = new ThreadPoolTaskScheduler();
taskScheduler.setPoolSize(20);
taskScheduler.setThreadNamePrefix("order-scheduler-");
return taskScheduler;
}
}
다음으로 주문을 생성할 때 만든 메서드를 수정해야겠습니다.
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final MenuRepository menuRepository;
private final StoreRepository storeRepository;
private final UserRepository userRepository;
private final OrderMenuRepository orderMenuRepository;
private final TaskScheduler taskScheduler;
private final ConcurrentHashMap<UUID, Order> pendingOrders = new ConcurrentHashMap<>();
public OrderResponseDto createOrder(OrderRequestDto orderRequestDto, User user) {
User userInfo = userRepository.findByEmail(user.getEmail()).orElseThrow(()
-> new IllegalArgumentException("유저 정보를 확인해 주세요"));
Address address = getAddress(orderRequestDto, userInfo);
int total_price = getTotalPrice(orderRequestDto);
Order order = buildOrder(orderRequestDto, address, userInfo, total_price);
Payments payments = buildPayments(userInfo, total_price, order);
addMenuList(orderRequestDto, order);
order.addPayments(payments);
userInfo.addOrder(order);
userInfo.addPayments(payments);
pendingOrders.put(order.getOrderId(), order);
// 결제 내역 확인, 주문상태(pending) 등등 확인 필요 로직이 필요할듯.
taskScheduler.schedule(new SecuredRunnable(() -> autoSaveOrder(order.getOrderId())), Instant.now().plusSeconds(300));
return OrderResponseDto.orderTimeFrom(order);
}
//... 다른 로직들...
}
필요한 필드들을 final로 선언후 사용하도록 설정하도록 합니다. ConcurrentHashMap도 사용할거기 때문에 선언해주구요. schedule 기능을 이용하기 위해서 @EnableScheduling을 main 클래스 위에 달아주어야 합니다. TaskScheduler의 schedule메서드를 활용해 들어온 주문이 5분이라는 시간이 지나면 저장되도록 코드를 작성해줍니다.
여기서 주의할점은 저는 Spring Security를 활용해 프로젝트를 구성하였는데, TaskScheduler 동작 시 ContextHolder에 요청을 보낸 client에 대한 정보가 따로 들어있지 않기 때문에 Runnable 인터페이스를 구현하여 ContextHolder에 객체를 저장할 당시의 정보를 담아주었습니다. 이 부분도 아래에 코드로 보여드리겠습니다.
다음으로 autoSaveOrder 메서드입니다.
private void autoSaveOrder(UUID orderId) {
Order order = pendingOrders.remove(orderId);
if (order != null) {
orderRepository.save(order);
}
}
ConcurrentHashMap에서 객체를 빼내와서 저장을 하도록만 구현해주었습니다.
다음으로 주문 취소에 관한 메서드입니다.
public OrderResponseDto cancelOrder(UUID orderId, User user) {
Optional<Order> optionalOrder = orderRepository.findById(orderId);
Role userRoleEnum = user.getRole();
UUID userId = user.getUserId();
if (optionalOrder.isPresent()) {
if (isUser(userRoleEnum)) {
UUID orderedUserId = optionalOrder.get().getUser().getUserId();
if (!userId.equals(orderedUserId)) {
throw new IllegalArgumentException("다른 유저의 주문은 취소할 수 없습니다.");
}
}
throw new IllegalArgumentException("주문이 5분이 경과되어 더이상 취소가 불가능합니다.");
}
Order orderInThread = pendingOrders.get(orderId);
if (orderInThread == null) {
throw new IllegalArgumentException("해당 주문이 없습니다.");
} else {
if (isUser(userRoleEnum)) {
UUID orderUserId = orderInThread.getUser().getUserId();
if (!userId.equals(orderUserId)) {
throw new IllegalArgumentException("다른 유저의 주문은 취소할 수 없습니다.");
}
pendingOrders.remove(orderId);
}
}
return OrderResponseDto.orderTimeFrom(orderInThread);
}
저는 일단 DB에 저장되어 있지 않는 것을 조건에 넣긴 했는데, 다른 좋은 방법도 있을 것 같습니다.
다음으로 Runnable 인터페이스를 구현한 SecuredRunnable 클래스입니다.
public class SecuredRunnable implements Runnable {
private final Runnable task; // 실행할 Runnable 작업
private final SecurityContext securityContext; // 현재 보안 컨텍스트
public SecuredRunnable(Runnable task) {
this.task = task;
this.securityContext = SecurityContextHolder.getContext(); // 현재 보안 컨텍스트를 저장
}
@Override
public void run() {
try {
SecurityContextHolder.setContext(securityContext); // 저장한 보안 컨텍스트를 설정
task.run(); // 실제 작업 실행
} finally {
SecurityContextHolder.clearContext(); // 작업이 끝난 후 보안 컨텍스트 삭제
}
}
}
현재 SecurityContextHolder에 담긴 인증 정보를 담아두도록 하였습니다. 멀티스레드 환경에서도 현재 스레드의 보안 정보를 안전하게 사용할 수 있도록 설정해줍니다.
postman으로 확인 결과 정상적으로 동작하네요! 뿌듯합니다 ㅎㅎ
'개발 > Java' 카테고리의 다른 글
Java GC(Garbage Collector) (1) | 2024.12.04 |
---|---|
디버깅 (0) | 2024.11.25 |
Fetch Join (0) | 2024.11.24 |
Block vs Non-Block & Sync vs Async (0) | 2024.11.23 |
Thread Pool (0) | 2024.11.22 |