API 다중 호출 이슈 처리1 - 구현

김동영·2022년 11월 12일
0

업무일지

목록 보기
2/3

1. 이슈

  • 한번 밖에 신청할 수 없는 이벤트 참여를 여러번 신청한 사용자가 발생함.
  • 이로 인해 데이터 정합성이 깨지게 되어 이벤트 중간보상 및 참여 시 오류가 발생함

2. 분석

  • 1초 내 이벤트 참여 API가 다중 호출됨.
  • 이벤트 참여내역 조회 후 없는 경우 참여신청 진행하는 방식
  • 이벤트 참여 API 내 여러 테이블에 DML 작업할 하기 때문에 트랜잭션을 설정함
  • 그러다보니 동시에 호출되는 경우 참여내역 조회는 DB Isolation 2단계(Read Committed) 에 따라 여러 API 모두 참여내역이 없는 것으로 조회된다.
  • 그 결과, 호출된 API 모두 이벤트 참여 로직을 수행하여 여러번의 참여 내역이 저장됨.
    참여내역은 참여일시(시분초) 까지 키 값으로 설정되어있기 때문에 중복없이 입력될 수 있었음.

3. 대응방안

1. 백엔드

  • 디바운스 기법을 적용시켜 동일 사용자의 API 호출을 일정시간 제한한다.

  • 업무 별 로직 수행시간이 다르기 때문에 동적인 적용이 필요함.(단위 ms)

    문제가 된  테이블에는 시분초(DTM) 까지만 저장되기 때문에 초가 바뀌면 신청이 이루어진다.
    예) 3초999 신청, 4초 000 신청인 경우 
      -> 이벤트 참여내역 조회 시 둘다 참여내역없음이기 때문에 동시에 인서트 진행
      -> 초가 달라졌기 때문에 테이블에 정상 인서트(참여내역 테이블의 복합키 중 DTM이 포함됨)
      
    따라서 현재 테이블 구조를 유지하며 기능을 보완하기 위해 디바운싱으로 최초 호출 이후 
    일정시간동안 요청을 무시하여 참여내역에 정상 적재 후 API가 실행될 수 있도록 제한한다.
  • 디바운스 적용방식

    1. 호출 제한할 핸들러(컨트롤러 메소드) 에 커스텀 어노테이션 @Debounce 선언
      이하 디바운스 어노테이션으로 명명
    @Debounce(200) 
     @PutMapping
     public ApiResult<HelloInsertRspDto> insert(@Valid @RequestBody HelloInsertReqDto reqDto) {
         return ok(helloService.insert(reqDto));
     }
    1. 디바운스 어노테이션 인터셉터 구현
    @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. 다중호출 테스트

99. 기타

99-1. 고려할 사항

  • 문제가 발생한 API 에 대해 우선 검증/적용 후 확장여부를 결정해야 한다.
    실 운영에 지장이 생길 순 없기에 검증이 필요하다.
    모든 도메인에 적용은 업무 담당자의 협의가 필요하기에 당장 적용시킬 순 없다.
  • 개별 API에 적용 후 해당 API 실행에 대한 충분한 검증이 필요하다.
    예) 다중 호출 테스트 -> 그에 따른 데이터 정합성 검증
  • 위 과정에서 결국 다중 호출 문제에 대해 분석하게 되고 분석한 내용을 바탕으로
    방어 로직 자체를 구현하면 API 호출 자체에 대한 처리를 하지 않을 수 있다.
  • 이런 상황에서도 API 호출 제한을 거는 것이 맞는지...?

99-2. 채택되지 않은 방식 - 토큰-버켓 알고리즘

1) 설명

  • API 호출 횟수 제한에 주로 사용되는 알고리즘
  • 과도한 트래픽, 디도스 공격 등의 문제로 서버가 다운되는 현상을 막기 위해
    서버 스펙에 맞게 API 호출 총량을 제한할 때 주로 사용한다.
    • 매 시간마다 토큰을 버켓에 저장
    • API 호출 별 토큰을 소비하여 없는 경우 오류 발생
    • 매 시간마다 최대 호출 수를 제한시킨다.

2) 채택되지 않은 사유

  • API 호출 총량을 제한하는게 목적이 아니기 때문에 맞지 않는다.
  • 사용자 별 API 호출 제한을 할 수 없음.

99-3. 멀티 프로세스 처리 TBD (23.06.05 ~ )

  • 하나의 프로세스 내 멀티 쓰레드 고려
    • 안정성을 위해 synchronized 블록으로 처리함.
  • Kubernetes 등 여러 개의 프로세스라면 위 방식은 통하지 않는다.
    • 이 경우 공유 자원인 세션에 대해 락을 걸 수 있도록 처리해야한다.
profile
프레임워크와 함께하는 백엔드 개발자입니다.

0개의 댓글