디바운스 기법을 적용시켜 동일 사용자의 API 호출을 일정시간 제한한다.
업무 별 로직 수행시간이 다르기 때문에 동적인 적용이 필요함.(단위 ms)
문제가 된 테이블에는 시분초(DTM) 까지만 저장되기 때문에 초가 바뀌면 신청이 이루어진다.
예) 3초999 신청, 4초 000 신청인 경우
-> 이벤트 참여내역 조회 시 둘다 참여내역없음이기 때문에 동시에 인서트 진행
-> 초가 달라졌기 때문에 테이블에 정상 인서트(참여내역 테이블의 복합키 중 DTM이 포함됨)
따라서 현재 테이블 구조를 유지하며 기능을 보완하기 위해 디바운싱으로 최초 호출 이후
일정시간동안 요청을 무시하여 참여내역에 정상 적재 후 API가 실행될 수 있도록 제한한다.
디바운스 적용방식
@Debounce(200)
@PutMapping
public ApiResult<HelloInsertRspDto> insert(@Valid @RequestBody HelloInsertReqDto reqDto) {
return ok(helloService.insert(reqDto));
}
@Override
public boolean preHandle(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull Object handler) {
var stopWatch = new StopWatch("debounceInterceptor");
try {
// 1. 핸들러메소드 검증
stopWatch.start("1. 핸들러메소드 검증");
if (!(handler instanceof HandlerMethod)) {
return true;
}
stopWatch.stop();
// 2. Debounce 대상 여부
stopWatch.start("2. Debounce 대상 여부");
var handlerMethod = (HandlerMethod) handler;
Debounce debounce = handlerMethod.getMethodAnnotation(Debounce.class);
if (debounce == null) {
return true;
}
var requestURI = request.getRequestURI();
if (debounce.value() <= 0) {
log.error("Invalid debounce time: {}ms. request(URI: {})is debounce pass.", debounce.value(), requestURI);
return true;
}
stopWatch.stop();
// 3. 세션 검증
stopWatch.start("3. 세션 검증");
var session = request.getSession(false);
var currentTimeMillis = System.currentTimeMillis();
if (session == null) {
log.error("Request session is invalid. Debounce pass.");
return true;
}
stopWatch.stop();
// 4. debounceMap header 조회
stopWatch.start("4. debounceMap header 조회");
var debounceMapObject = session.getAttribute(DEBOUNCE_MAP);
var debounceMap = new HashMap<String, Long>();
Long lastCallTimeMillis = null;
stopWatch.stop();
// 4-2. 최초 호출 확인 - 동기화 처리
// 최초 호출 시 먼저 진입한 세션만 성공처리될 수 있도록 동기화 블록 설정함.
if (debounceMapObject == null) {
stopWatch.start("4-2. 최초 호출 확인 - 동기화 처리 진입");
synchronized (DEBOUNCE_MAP) {
stopWatch.stop();
debounceMapObject = session.getAttribute(DEBOUNCE_MAP);
// 동기화 처리 중 다른 쓰레드가 대기하기 때문에 진입 시 한번 더 확인함.
if (debounceMapObject == null) {
stopWatch.start("4-2. 최초 호출 확인 - 동기화 처리 수행");
debounceMap.put(requestURI, currentTimeMillis);
session.setAttribute(DEBOUNCE_MAP, debounceMap);
stopWatch.stop();
}
}
}
// 5. debounceMap header 재조회
// 싱크로나이즈 블록에서 병렬처리로 인해 debounceMapObject 가 설정될 수 있기 때문에 다시 체크함.
stopWatch.start("5. debounceMap header 재조회");
if (debounceMapObject != null) {
debounceMap = (HashMap<String, Long>) debounceMapObject; // 캐스팅 에러를 감수하고 최소한의 조건만 사용
// 1) 최종 호출시간 조회
lastCallTimeMillis = debounceMap.get(requestURI);
// 2) 최종 호출시간 갱신
debounceMap.put(requestURI, currentTimeMillis);
session.setAttribute(DEBOUNCE_MAP, debounceMap);
}
stopWatch.stop();
// 6. 디바운싱
if (lastCallTimeMillis == null || lastCallTimeMillis + debounce.value() <= currentTimeMillis) {
return true;
}
var remainingTimeMillis = lastCallTimeMillis + debounce.value() - currentTimeMillis;
log.error("Api called before debounce time(remaining: {}ms). Reset debounce {}ms", remainingTimeMillis, debounce.value());
throw new HandlerDebounceException(String.format("Api called before debounce time(remaining: %dms). Reset debounce %dms"
, remainingTimeMillis, debounce.value()));
} catch (HandlerDebounceException e) {
throw e;
} catch (Exception e) {
log.error(e.getMessage());
log.error("Invalid Debounce process. Debounce is bypass.");
} finally {
log.info(stopWatch.prettyPrint());
}
return true;
}
실 운영에 지장이 생길 순 없기에 검증이 필요하다.
모든 도메인에 적용은 업무 담당자의 협의가 필요하기에 당장 적용시킬 순 없다.
예) 다중 호출 테스트 -> 그에 따른 데이터 정합성 검증
1) 설명
2) 채택되지 않은 사유
synchronized
블록으로 처리함.