비동기 작업(웹 성능 측정, 보안 점검, AI 개선안 생성)은 완료 시점을 예측하기 어렵습니다. 폴링으로 상태를 계속 조회하면 네트워크·서버 자원을 낭비합니다.
본 글은 결과가 준비되는 순간에만 응답을 반환하는 롱폴링 패턴을 스프링 부트로 구현한 과정을 정리했습니다.
단순 코드 나열이 아니라, 왜 이런 코드를 선택했는지까지 설계 의도를 작성해봤습니다.
0. 통신 흐름 요약
실행 플로우
- 확장프로그램 미니창에서 성능 & 보안 분석 실행
- POST /api/tests → testId 응답
- GET /api/tests/{testId}/wait?topic=CORE_READY (롱폴링)
- 백엔드가 CORE_READY 달성 시 즉시 응답
- UI가 성능/보안 결과 조회(GET /scores 등) 후 표시
- 이어서 GET /api/tests/{testId}/wait?topic=AI_READY (롱폴링)
- 백엔드가 AI_READY 달성 시 즉시 응답 → 대시보드 상세 조회
API 흐름
POST /api/tests # testId 발급
GET /api/tests/{testId}/wait?topic=CORE_READY # 1차 준비 신호 대기
POST /api/tests/{testId}/web-vitals
POST /api/tests/{testId}/web-vitals/ai
# 응답: CORE_READY → web/security/scores 조회 가능
GET /api/tests/{testId}/wait?topic=AI_READY # 2차 준비 신호 대기
# 응답: AI_READY → ai_recommendations/expectations 조회 가능
1. 토픽 정의: 어떤 이벤트를 기다릴지 명확히
public enum LongPollingTopic {
CORE_READY, // 1차: web + security + scores 완료
AI_READY // 2차: AI 결과 완료
}
왜 이렇게 했는가
- 문자열 파라미터 대신 열거형으로 고정합니다. 오타·스펙 변조를 원천 차단하고 컴파일 타임에 검증합니다.
- 프런트·백 간 계약(Contract)이 단단해집니다.
2. WaitKey: 어떤 그룹을 기다리는지 식별
public final class WaitKey { private final UUID testId; private final LongPollingTopic topic; // equals/hashCode: testId + topic 기준 }
왜 이렇게 했는가
- (testId, topic) 조합이 곧 “대기 그룹”입니다.
- equals/hashCode를 키로 쓰면 Map<WaitKey, ...> 에서 안전하게 그룹을 분리·관리할 수 있습니다.
- 동일 테스트라도 단계(topic)가 다르면 독립적으로 기다릴 수 있습니다.
3. LongPollingManager: 등록/해제/완료 브로드캐스트 허브
@Component
public class LongPollingManager {
private final Map<WaitKey, Set<DeferredResult<ResponseEntity<?>>>> waiters
= new ConcurrentHashMap<>();
3-1) register: 대기자 등록
public DeferredResult<ResponseEntity<?>> register(WaitKey key, long timeoutMillis) {
DeferredResult<ResponseEntity<?>> dr = new DeferredResult<>(timeoutMillis);
waiters.computeIfAbsent(key, k -> ConcurrentHashMap.newKeySet()).add(dr);
Runnable cleanup = () -> {
Set<DeferredResult<ResponseEntity<?>>> set =
waiters.getOrDefault(key, Collections.emptySet());
set.remove(dr);
if (set.isEmpty()) waiters.remove(key);
};
dr.onTimeout(() -> { dr.setResult(ResponseEntity.noContent().build()); cleanup.run(); });
dr.onError(ex -> { dr.setErrorResult(ResponseEntity.internalServerError().body(ex.getMessage())); cleanup.run(); });
dr.onCompletion(cleanup);
return dr;
}
왜 이렇게 했는가
- DeferredResult 를 쓰면 서블릿 작업 스레드를 즉시 반환해 스레드 고갈을 방지합니다. HTTP 연결은 열린 채로 서버 이벤트를 기다립니다.
- ConcurrentHashMap.newKeySet()은 동시성에 안전하고, 키별로 대기자 집합을 빠르게 관리합니다.
- cleanup 후처리를 모든 종료 경로(timeout/error/completion)에 걸어 메모리 누수와 유령 엔트리를 방지합니다.
- 타임아웃 시 204 No Content로 응답해 프런트가 재요청을 간단히 반복할 수 있게 했습니다.
3-2) complete: 그룹 전체에 한 번에 응답
public void complete(WaitKey key, Object payload) {
Set<DeferredResult<ResponseEntity<?>>> set = waiters.remove(key);
if (set == null) return;
for (DeferredResult<ResponseEntity<?>> dr : set) {
if (!dr.hasResult()) dr.setResult(ResponseEntity.ok(payload));
}
}
왜 이렇게 했는가
- remove 로 한 번에 회수해 중복 완료 신호가 와도 재응답을 막습니다.
- 이미 타임아웃 등으로 결과가 있는 요청은 hasResult()로 건너뜁니다.
- 이 시점의 setResult(...) 가 실제 HTTP 응답 전송 트리거입니다.
4. 컨트롤러: wait 엔드포인트
@RestController
@RequestMapping("/api/tests")
public class LongPollingController {
private final LongPollingManager manager;
@GetMapping("/{testId}/wait")
public DeferredResult<ResponseEntity<?>> waitFor(
@PathVariable UUID testId,
@RequestParam LongPollingTopic topic,
@RequestParam(defaultValue = "60") int timeoutSec
) {
long timeoutMillis = timeoutSec * 1000L;
return manager.register(new WaitKey(testId, topic), timeoutMillis);
}
}
왜 이렇게 했는가
- 단일 엔드포인트 + 토픽 파라미터 구조로 확장성·가독성을 확보합니다.
- 타임아웃을 파라미터로 받아 환경별로 유연하게 튜닝할 수 있습니다.
5. 공통 페이로드: 프런트 분기 단순화
public record PhaseReadyPayload(
LongPollingTopic type,
UUID testId,
Instant at
) {}
왜 이렇게 했는가
- 응답 스키마를 통일하면 프런트는 type만 보고 분기합니다.
- 불필요한 if-else와 타입 변환이 줄어듭니다.
6. TxAfterCommit: 커밋 이후에만 신호 발사
public final class TxAfterCommit {
private TxAfterCommit() {}
public static void run(Runnable action) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override public void afterCommit() { action.run(); }
});
}
}
왜 이렇게 했는가
- READY 신호가 나갔는데 DB 커밋이 실패하면 정합성 붕괴가 발생합니다.
- 트랜잭션이 성공적으로 커밋된 직후에만 complete()를 호출해 항상 최신 상태를 보장합니다.
7. LogicStatusServiceImpl: 부분 갱신 → 조건 충족 → 브로드캐스트
@Transactional
public void onPartialUpdate(UUID testId, Channel channel) {
switch (channel) {
case WEB -> repo.markWebReceived(testId);
case SECURITY -> repo.markSecReceived(testId);
}
boolean scoresMarked = markScoresReadyIfEligible(testId);
if (scoresMarked) {
scoresService.calcAndSave(testId);
TxAfterCommit.run(() -> {
longPollingManager.complete(
new WaitKey(testId, LongPollingTopic.CORE_READY),
new PhaseReadyPayload(LongPollingTopic.CORE_READY, testId, Instant.now())
);
});
}
boolean aiMarked = markAiTriggeredIfEligible(testId);
if (aiMarked) {
aiService.invokeAsync(testId);
TxAfterCommit.run(() -> {
longPollingManager.complete(
new WaitKey(testId, LongPollingTopic.AI_READY),
new PhaseReadyPayload(LongPollingTopic.AI_READY, testId, Instant.now())
);
});
}
}
왜 이렇게 했는가
- 부분 결과가 축적되다가 임계조건을 만족하면 그 시점에만 신호를 보냅니다.
- 점수 계산/AI 호출은 트랜잭션 안에서 트리거하고, 신호 발사는 afterCommit 으로 미룹니다.
- 한 번 complete 되면 동일 키의 대기자들이 일괄 응답을 수신합니다.
8. 전체 동작: 요청→대기→커밋→응답 타임라인
- 프런트가 GET /wait?topic=CORE_READY를 호출합니다. 컨트롤러는 DeferredResult를 등록하고 즉시 반환합니다(스레드 반납).
- 백그라운드 작업이 진행되며 상태가 LogicStatus에 반영됩니다.
- 조건 만족 시 TxAfterCommit.run(complete(...))로 커밋 후 브로드캐스트가 예약됩니다.
- 커밋 성공 직후 complete()가 호출되어 대기자 전원에게 200 OK + PhaseReadyPayload가 전송됩니다.
- 프런트는 응답 수신 즉시 후속 API를 호출해 상세 데이터를 뷰에 반영합니다.
9. 설계 선택의 핵심 논리
- DeferredResult: 톰캣 스레드 점유 없이 연결을 유지해 대량 동시 대기에 강합니다.
- ConcurrentHashMap + KeySet: 토픽·테스트별 대기자를 충돌 없이 안전하게 관리합니다.
- cleanup 전면 적용: 타임아웃/에러/정상 종료 모두에서 누수 없이 정리합니다.
- remove-then-broadcast: 중복 완료 신호에도 중복 응답이 발생하지 않습니다.
- afterCommit-only: 항상 커밋된 최신 스냅샷 기준으로만 신호를 내보냅니다.
10. 프런트 사용 예시(의사 코드)
// CORE_READY 대기
async function waitCoreReady(testId: string) {
const resp = await fetch(`/api/tests/${testId}/wait?topic=CORE_READY&timeout=60`);
if (resp.status === 204) return waitCoreReady(testId); // 재요청
const payload = await resp.json(); // { type: "CORE_READY", testId, at }
return payload;
}
// 이후 상세 조회
async function onCoreReady(testId: string) {
const scores = await fetch(`/api/tests/${testId}/scores`).then(r => r.json());
// ... UI 반영
}
왜 이렇게 했는가
- 204 타임아웃은 정상 경로입니다. 재요청 루프가 간단합니다.
- 신호는 “준비 완료 알림”이고, 실제 데이터는 별도 GET으로 가져옵니다.
롱폴링 응답 크기를 작게 유지해 안정성을 높입니다.
마무리
이 구현은 **“작업 완료 시점에만 응답을 반환”**한다는 롱폴링의 간결한 철학을 그대로 코드로 옮겼습니다.
핵심은 스레드 점유 없이 대기하고, 커밋 이후에만 신호를 발사하며, 그룹 단위로 일괄 응답하는 것입니다.
이 덕분에 폴링 오버헤드를 제거하면서도 안정적인 UX를 제공합니다.
'프로젝트 > 웹 성능 테스트' 카테고리의 다른 글
| 싸피 백엔드 과정: Spring Security (0) | 2025.11.30 |
|---|---|
| 장애 대응을 위한 메트릭 모니터링 단 구축(1. Actuator + Micrometer) (0) | 2025.11.28 |
| 비동기 통신 방식 비교와 A/B 테스트 설계 (0) | 2025.11.09 |
| Nginx 도입 2편: 적용 (0) | 2025.11.09 |
| Nginx 도입 1편: 왜 Nginx (0) | 2025.11.06 |