1. 문제상황
북마크라는 기능 자체가 누를 때마다 실시간성으로 변화되는 로직이다 보니, 일반적으로 사용자가 북마크를 클릭할 때마다 update 요청이 발생한다.
때문에, (북마크 -> 북마크 해제 -> 북마크 -> .... ) 누를 때마다 불필요한 update 요청이 발생할 것이라 판단하였다.
(나도 언젠가 한번쯤은 이런 거 막 눌러봤던 거 같아서... 이런 생각을 가지게 된 것 같다..ㅎ)
그래서, 불필요한 update 요청을 줄이고자 몇 가지 방식을 생각해 봤다.
- [방식 1] 사용자의 북마크 상태를 캐시 데이터로써 관리하고, 빠른 응답으로 실시간 업데이트를 제공한다.
- (X) 어쨌든 cache에도 당연히 update가 발생하기 때문에, 근본적인 원인을 해결하는 처리방식은 아니라고 판단했다.
- [방식 2] 비동기 처리를 통해 DB Call 작업을 백그라운드에서 처리한다.
비동기 작업을 위한 큐를 생성하고, 백그라운드에서 이 큐에서 작업을 가져와 Update 로직을 수행한다.- (X) 이 생각을 한 이유는 "사용자에게 보이는 북마크는 어차피 JavaScript를 통해 바뀌고 있기에, 백그라운드에서 Update 작업을 하면 어떨까?"라는 생각에서 비롯되었다.
하지만, 이 방법도 어쨌든 update 횟수만큼 쿼리가 발생한다. - (O) 근데, 이 생각에서 "큐"라는 것에 초점을 두었을 때 한 가지 방안이 떠올랐다.
"스택"의 성질을 이용해서 마지막에 들어온 작업만 수행하게끔 하는 방식이다.
(나는 이 방식을 아래 [방식3]과 같이 지연 스케줄링 방식을 적용해서 처리하긴 했다.)
- (X) 이 생각을 한 이유는 "사용자에게 보이는 북마크는 어차피 JavaScript를 통해 바뀌고 있기에, 백그라운드에서 Update 작업을 하면 어떨까?"라는 생각에서 비롯되었다.
- [방식 3] 연속된 요청 처리를 제어하고, 마지막 요청에 대한 처리만 진행하는 방식
- (O) 지연 스케줄링 방식을 적용해서, 사용자가 북마크를 누른 시점으로부터 2초 이내로 다른 액션이 없다면 update 요청을 처리하는 방식이다.
- 2초 이내로 북마크를 다시 눌렀다면, 이전에 사용자가 북마크에 대해 취한 액션(북마크 선택 or 해제)과 비교하여 update 요청을 제거하거나 유지하는 방식이다.
2. 처리방식
전체적인 메커니즘은 아래와 같다.
Map<Long, ScheduledExecutorService> userSchedulers : "사용자별 정의된 스케줄러"
Map<Long, BookmarkAction> userActionMap: "사용자별 북마크 작업정보가 정의된 Map"
Map<Long, ScheduledFuture<?>> userScheduledFutures: "사용자별 예약된 작업 정보(2초 뒤 update문 실행)"
1. 사용자별 스케줄러를 생성하거나 가져오기
- userId를 사용하여 userSchedulers에서 해당 사용자의 ScheduledExecutorService를 가져온다.
만약 해당 사용자에 대한 스케줄러가 없다면, 새로운 스레드 풀을 생성하고 맵에 추가한다.
(스레드 풀은 해당 사용자의 작업을 비동기적으로 실행하기 위해 사용)
2. 사용자별 북마크 작업상태 정보 가져오기
- userActionMap에서 해당 사용자의 북마크 작업정보를 가져온다.
3-1. 2초 이내로 새로운 북마크 작업상태를 업데이트 하는 경우
- userScheduledFutures에 저장된 예약된 작업을 삭제하고, 다시 2초뒤 update 요청이 발생하도록 다시 예약한다.
3-2. 북마크 상태를 업데이트 한 뒤, 2초 동안 아무런 요청도 없는 경우
- userScheduledFutures에 저장한 예약이 실행되어, update 요청이 발생한다.
위 메커니즘을 따르고 세부적인 내용은 코드와 함께 정리해 보자.
public class BookmarkService {
private final Map<Long, ScheduledExecutorService> userSchedulers = new ConcurrentHashMap<>();
private final Map<Long, BookmarkAction> userActionMap = new ConcurrentHashMap<>();
private final Map<Long, ScheduledFuture<?>> userScheduledFutures = new ConcurrentHashMap<>();
private static final int REQUEST_INTERVAL_SECONDS = 2;
@Transactional
public void handleBookmarkAction(
Long userId, Long problemId, boolean requestBookmarked
) {
ScheduledExecutorService scheduler = userSchedulers.computeIfAbsent(
userId, k -> Executors.newScheduledThreadPool(1)
);
BookmarkAction action = userActionMap.get(userId);
ScheduledFuture<?> scheduledFuture = userScheduledFutures.get(userId);
// ... 아래에서 설명
}
}
- userSchedulers에 userId인 스케줄러가 있으면 가져오고, 없으면 스케줄링된 작업을 처리하는 스레드 풀을 하나 생성한다.
- 이전 북마크 작업정보를 가져와, scheduledFutre에 넣어준다.
// ... 상단 코드
if (checkFirstAction(action)) { // 최초 북마크 상태업데이트 요청
action = new BookmarkAction(
userId, problemId, requestBookmarked, LocalDateTime.now()
);
userActionMap.put(userId, action);
scheduledFuture = redeclaredScheduledFuture(userId, scheduler, null);
userScheduledFutures.put(userId, scheduledFuture);
} else if (checkChangeAction(requestBookmarked, action)) { // 2초 이내 북마크 상태업데이트 요청
action.setBookmarked(requestBookmarked);
action.setActionAt(LocalDateTime.now());
scheduledFuture = redeclaredScheduledFuture(userId, scheduler, scheduledFuture);
userScheduledFutures.put(userId, scheduledFuture);
}
// 예약된 작업을 재설정하는 메서드
private ScheduledFuture<?> redeclaredScheduledFuture(
Long userId, ScheduledExecutorService scheduler, ScheduledFuture<?> scheduledFuture
) {
if (scheduledFuture != null) {
// 이미 예약된 작업이 있다면 취소
scheduledFuture.cancel(false);
}
// 2초 후에 processBookmarkAction() 실행을 예약: 실제 update 요청을 담당하는 메서드 & 관련 리소스 해제
scheduledFuture = scheduler.schedule(
() -> processBookmarkAction(userId), REQUEST_INTERVAL_SECONDS, TimeUnit.SECONDS
);
return scheduledFuture;
}
- 최초 북마크 작업 요청인 경우
- 새로운 북마크 정보를 생성한다.
- 새로운 예약을 생성한다. (redeclaredScheduledFuture())
- 이전 북마크 요청으로부터 2초 이내 재요청을 보낸 경우
- 북마크 정보를 업데이트한다.
- 예약되어있던 작업정보를 2초로 다시 재설정한다.
이야기를 결론짓자면 ConcurrentHashMap을 사용해서 각 사용자별로 Map으로 각 사용자별 스레드를 관리하도록 했다.
뿐만 아니라 각 스케줄러도 사용자별로 관리하도록 하도록 하였다.
하지만, 이러한 방식에서도 아래와 같은 고려사항이 있을 것 같다.
- 사용자 수와 동시에 처리해야 하는 요청 수에 따라 스레드 풀의 크기를 조절해야 한다.
- 사용자 수에 따라 스레드 수도 증가하기 때문에, 리소스 사용량을 조절해야 한다.
이 외에도 여러 가지 고려사항이 있을 듯하다.
사용자별로 스레드를 할당하는 방식에서는 스레드의 안정성이나 리소스 할당 등 많은 부분을 고려해야 한다고 생각해 봤을 때,
처음에 고민한 1,2번 방식도 괜찮을 것으로 보인다.
'backend > Spring' 카테고리의 다른 글
무한 스크롤 구현 및 성능개선 (Pagination) (0) | 2023.09.13 |
---|---|
Fetch join과 Paging (0) | 2023.08.14 |
[Spring] 스프링의 DI를 이용한 전략패턴 도입 (0) | 2023.06.08 |
[JPA] 동일 트랜잭션 내 OneToMany 필드 객체의 데이터와 DB 데이터의 불일치 (0) | 2023.04.21 |
[spring] @Configuration의 프록시 객체 (0) | 2023.03.10 |