요구사항
- 유저의 AI 질의 요청을 순서와 가중치에 따라 데드락 없이 안전하게 큐잉
- 소모시키는 지수 백오프(Exponential Backoff) 기반 스케줄러 알고리즘 작성
문제사항
- 멀티스레드 환경(유저 수천 명)에서 AI 질의 요청이 쏟아진다
- 외부 AI API(예: OpenAI, Claude)는 분당 요청 제한(Rate Limit)이 걸려 있다
클라이언트와 서버의 3-way-hanshake을 통해 세션을 맺으면 생성되는 소켓을 Poller events 큐에 넣게 되면 요청에 대한 적절한 처리를 하게 되고 워커 스레드인 http-nio-8080-exec-X 스레드를 통해 Spring Boot의 DispatcherServlet에 접근을 하게 된다.
이때 적절한 컨트롤러를 찾아서 처리를 할텐데 http-nio-8080-exec-X 이 스레드가 AI 요청와 응답을 기다리는 작업을 하게되면 이 스레드는 대기 상태(Waiting)이 된다, 이렇게 되면 스레드는 한정 되어있기 때문에 이런 사용자의 요청이 많이 들어오게 되면 요청을 처리할 수 없게 된다.
그렇기 때문에 http-nio-8080-exec-X 이 스레드를 빨리 놔 주어야 한다, 이때 실행 작업을 이벤드 큐에 넣는 방식으로 사용하게 된다면 즉 비동기 식으로
Acceptor의 스레드(http-nio-8080-Acceptor)가 소켓을 감지한후 처리를 하고 Poller의 events 큐에 작업을 던지는 식으로 하게 되면 Poller의 스레드(http-nio-8080-Poller)가 이후 처리를 하듯이 사용자가 AI 질문 요청을 할때도 마찬가지로 이런 방식을 사용하게 되면 작업은 이벤트 큐에 쌓이게 되고 병렬로 처리가 가능하게 될것이다 (생성된 AI 응답 스레드 수 만큼 병렬 처리 가능)
여기서 드는 의문은 http-nio-8080-exec-X 이 스레드에서 사용자의 요청을 이벤트 큐에 넣고 마무리 된다면 사용자는 응답을 받을 수 없을것이다.
이것의 해결 방법은 컨트롤러 내부에서 유저에게 바로 응답을 주는 대신, "나중에 AI 답변이 오면 여기 가방에 담아서 응답해 줘" 하고 약속하는 가상 객체(DeferredResult 또는 CompletableFuture)를 하나 생성한 후 이것을 이벤트 큐에 담는 방식이다.
이렇게 되면 http-nio-8080-exec-X 이 스레드는 유저에게 답변을 주지 않은 상태로, 톰캣 엔진에게 "나 이 요청 비동기(Async)로 전환했으니까 소켓 커넥션만 끊지 말고 끊어지지 않게 킵 해줘!"라고 말한 뒤, 톰캣 스레드 풀로 즉시 복귀하게 된다.
이후 톰캣 풀과 완전히 격리된 우리만의 AI-Scheduler-Consumer 스레드가 '질문 요청 큐'에서 일감을 하나씩 꺼내 AI의 응답을 받아오게 되면 deferredResult.setResult(답변) 이 순간, 스프링이 가방에 데이터가 들어온 것을 감지하고 톰캣에게 컨텍스트를 다시 이관(Dispatch)하면, 톰캣 풀에서 마침 놀고 있던 스레드를 깨워 깨어난 워커 스레드가 아까 킵해두었던 유저의 소켓을 쥐고, 가방에 담긴 AI 답변을 실어서 브라우저로 최종 HTTP 응답을 주고 완전히 상황을 마감한다.
[1단계: 인프라 아키텍처 관점]
(톰캣 커널 엔진) 어떻게 하드웨어 레벨의 날것의 패킷 트래픽을 서버 마비 없이 대량으로 접수하고 격리할 것인가?
[2단계: 실전 서비스 설계 관점] (AI 스케줄링 파이프라인) 그렇게 인입된 요청 중에서, 특정 비즈니스 제약(AI Rate Limit)과 서비스 규칙(유저 가중치)을 녹여서 실제 가치(Value)로 어떻게 풀어낼 것인가?
에 대한 문제라고 생각한다.
데드락이란
- 멀티 스레드 환경에서 서로 상대방이 필요한 자원을 쥐고 다른 자원을 요구하는 것 (교착상태)
발생 원인 : 이 4가지 조건이 모두 성립 하면 발생
- 상호 배제 (Mutual Exclusion): 자원은 한 번에 한 스레만 가질 수 있다. (포크는 한 명만 쥘 수 있음)
- 점유와 대기 (Hold and Wait): 자원을 하나 쥔 상태에서 다른 자원을 기다린다. (포크A를 쥔 채로 포크B를 요구함)
- 비선점 (No Preemption): 다른 스레드가 쥔 자원을 강제로 뺏어올 수 없다. (상대방 손에 있는 포크를 강탈 못 함)
- 순환 대기 (Circular Wait): 대기하는 주체들이 원형으로 꼬여있다. (스레드 1 ➡️ 스레드 2 ➡️ 스레드 1)
문제 해결 방법
- 상위 발생 원인을 하나만 원천 차단 하면 해결된다.
여기서 데드락이 발생할 수 있는 상황은 사용자의 요청과 서버의 응답을 내려주는 스레드는 http-nio-8080-exec-X 스레드인데 사용자의 AI 요청을 이벤트 큐에 넣고 AI-Scheduler-Consumer 스레드가 처리하는 AI의 답변까지 기다리는 상황이 생기면 http-nio-8080-exec-X 이 스레드는 대기 상태에 걸리게 되고 AI 답변을 받아와서 톰캣에게 답변 받았으니 사용자한테 응답을 해 주어야 하는 상황에서 그걸 처리 할 http-nio-8080-exec-X 스레드가 없어 스레드풀 데드락이 걸리는 상황이다.
구현 구조

1차 구현
1. AIRequestTask.java (일감 데이터 가방)
- 책임: 큐에 들어갈 일품 세트입니다.
- 핵심 필드:
- 유저의 질문 내용 (prompt)
- 유저의 가중치 등급 (UserGrade)
- 요청 시간 (requestedTime - 가중치가 같을 때 선착순 정렬용)
- CompletableFuture<String> responseFuture
- 특이사항: PriorityBlockingQueue가 스스로 순서를 정렬할 수 있도록 자바의 Comparable<AIRequestTask> 인터페이스를
구현.
2. AIController.java (톰캣 스레드 해방 창구)
- 책임: 유저의 HTTP 요청을 가장 먼저 받아서 큐에 넣는 엔드 포인트.
- 핵심 로직:
- 요청이 들어오면 빈 가방(CompletableFuture)을 포함한 AIRequestTask를 생성합니다.
- 스케줄러의 이벤트 큐에 이 태스크를 툭 집어넣습니다.
- exec-X 스레드는 가방(CompletableFuture)만 톰캣 엔진에 리턴하면서 즉시 반환.
3. AIScheduler.java (이벤트 큐 보유 및 지수 백오프 컨슈머 엔진)
- 책임: 이벤트 큐를 필드로 가지는 클래스이자, 백그라운드 스레드들을 관리.
- 핵심 필드: PriorityBlockingQueue<AIRequestTask> eventQueue
- 핵심 로직:
- 클래스가 초기화될 때 AI-Scheduler-Consumer 백그라운드 스레드 3개를 생성하고 구동(while(true))
- 이 컨슈머 스레드들이 큐에서 가중치 순으로 일감을 하나씩 꺼내 외부 API를 호출합니다.
- 에러(Rate Limit) 발생 시 지수 백오프 알고리즘을 돌리며 현재 스레드만 잠시 슬립(Thread.sleep)시킵니다.
- 성공 시 task.getResponseFuture().complete(답변)을 호출하여 스프링 관제탑을 통해 톰캣 유휴 스레드(exec-Y)를 깨워 응답을 마무리.
디버깅
외부 AI 호출 기능은 임시로 구현 했기 때문에 Thread.sleep(10000); 를 사용해 외부 AI 요청 시간을 10초 준다.
1. Postman을 통해 AI 질문 API 호출

2. Tomcat의 http-nio-8080-exec-9 스레드가 4번의 코드로 작업을 큐에 삽입 후 5번 코드를 실행 이때
AI-Scheduler-Consume 스레드가 큐에있는 작업을 잡아 실행 (두 스레드 모두 RUNNING)

3. 5번 코드를 실행 후 http-nio-8080-exec-9가 반환 돼 WAIT 상태로 들어간것을 확인

4. AI의 응답이 10초 있다 전송, 사용자한테 응답을 해 주기위한 http-nio-8080-exec-9 스레드 실행

5. 실행된 톰켓 워커 스레드를 통해 세션이 끊기지 않고 응답 전송

이렇게 비동기 시, 스레드의 활성 상태를 보고 톰캣 커널 엔진과 서블릿 컨테이너가 하부 하드웨어 레벨에서 어떻게 돌아가는지 확인 할 수 있었다.
'ai' 카테고리의 다른 글
| 대용량 AI 프롬프트 토큰(Token) 분할 및 슬라이딩 윈도우 알고리즘 (0) | 2026.06.20 |
|---|