응답이 90초 걸리는 외부 API를 어떻게 붙일 것인가
일반적인 REST API 호출은 수백 밀리초 내에 응답이 온다. 우리가 익숙한 패턴 — Controller에서 Service를 호출하고, Service에서 RestTemplate이나 WebClient로 외부 API를 찍고, 응답을 받아 가공해서 리턴 — 은 이 전제 위에 성립한다.
그런데 AI 영상 생성 API는 다르다. 상하이 AI 기업 MiniMax(홍콩증시 100)가 2026년 7월 31일에 공개한 H3 모델의 경우, 이미지 한 장을 입력으로 2K 해상도 10초 영상을 생성하는 데 90~150초가 걸린다. 이 모델은 텍스트·이미지·영상·오디오를 통합 입력으로 받아 스테레오 음성이 포함된 영상을 한 번에 생성하는 옴니모달 아키텍처를 사용하며, Artificial Analysis 리더보드에서 Video Editing 1위를 기록한 바 있다.
이 글에서는 미니맥스 h3 API처럼 응답 레이턴시가 분 단위인 외부 API를 Spring Boot 서비스에 통합할 때 사용할 수 있는 비동기 처리 패턴 세 가지를 비교한다. 영상 생성에 한정된 이야기가 아니라, 장시간 처리 외부 API 전반에 적용 가능한 설계 원칙이다.
공통 전제: 동기 호출은 왜 안 되는가
명확히 하자. Tomcat 기본 스레드 풀은 200개다. 동기 호출로 영상 생성 API를 붙이면, 요청 하나당 스레드 하나가 90초간 블로킹된다. 동시 200명이 영상 생성을 요청하면 스레드 풀이 고갈되고, 그 이후의 모든 요청 — 영상 생성과 무관한 일반 API 요청 포함 — 이 큐에 쌓여 타임아웃된다.
결론: 분 단위 외부 호출은 비동기 처리가 필수다. 문제는 "어떤 방식의 비동기"를 선택하느냐다.
패턴 1: WebClient + CompletableFuture (가장 간단)
Spring WebFlux의 WebClient를 사용해 논블로킹으로 API를 호출하고, CompletableFuture로 결과를 비동기 처리하는 패턴.
java
@Service
public class VideoGenerationService {
private final WebClient webClient;
public VideoGenerationService(WebClient.Builder builder) {
this.webClient = builder
.baseUrl("https://api.minimax.io")
.build();
}
public CompletableFuture<String> generateVideoAsync(String imagePath, String prompt) {
return webClient.post()
.uri("/v1/video/generate")
.header("Authorization", "Bearer " + apiKey)
.bodyValue(new VideoRequest(imagePath, prompt, 6, "1080p"))
.retrieve()
.bodyToMono(VideoResponse.class)
.map(VideoResponse::getVideoUrl)
.toFuture();
}
}Controller에서는 DeferredResult를 사용해 서블릿 스레드를 즉시 반환한다.
java
@PostMapping("/api/videos/generate")
public DeferredResult<ResponseEntity<VideoResult>> generate(@RequestBody VideoRequest req) {
DeferredResult<ResponseEntity<VideoResult>> result = new DeferredResult<>(180_000L);
videoService.generateVideoAsync(req.getImagePath(), req.getPrompt())
.thenApply(url -> ResponseEntity.ok(new VideoResult(url)))
.exceptionally(ex -> ResponseEntity.status(500).build())
.thenAccept(result::setResult);
return result;
}장점: 구현이 간단하다. 별도 인프라(메시지 큐 등) 없이 Spring Boot 단독으로 동작한다.
단점: 서버가 재시작되면 진행 중인 요청이 유실된다. 재시도 로직을 직접 구현해야 한다. 대량 요청 시 Netty 이벤트 루프에 부하가 집중된다. 클라이언트가 HTTP 연결을 90초간 유지해야 하므로 프록시/로드밸런서 타임아웃 설정도 필요하다.
적합한 경우: 트래픽이 적고, 요청 유실 허용 가능하며, 인프라를 최소화하고 싶을 때. 초기 MVP에 적합.
패턴 2: 메시지 큐 + Worker (가장 안정적)
요청을 즉시 큐에 넣고 202 Accepted를 반환한 뒤, 별도 Worker 프로세스가 큐에서 꺼내 API를 호출하는 패턴. RabbitMQ, AWS SQS, 또는 Redis Stream을 큐로 사용할 수 있다.
java
// Controller: 요청 접수 후 즉시 반환
@PostMapping("/api/videos/generate")
public ResponseEntity<JobResponse> generate(@RequestBody VideoRequest req) {
String jobId = UUID.randomUUID().toString();
VideoJob job = new VideoJob(jobId, req.getImagePath(), req.getPrompt());
rabbitTemplate.convertAndSend("video.generation.queue", job);
return ResponseEntity.accepted().body(new JobResponse(jobId, "QUEUED"));
}
// 클라이언트는 polling으로 상태 확인
@GetMapping("/api/videos/jobs/{jobId}")
public ResponseEntity<JobStatus> getStatus(@PathVariable String jobId) {
return ResponseEntity.ok(jobRepository.findStatus(jobId));
}java
// Worker: 큐에서 메시지를 소비하여 처리
@RabbitListener(queues = "video.generation.queue")
public void processVideoJob(VideoJob job) {
try {
jobRepository.updateStatus(job.getId(), "PROCESSING");
String videoUrl = videoApiClient.generate(job.getImagePath(), job.getPrompt());
jobRepository.complete(job.getId(), videoUrl);
} catch (Exception e) {
jobRepository.fail(job.getId(), e.getMessage());
// RabbitMQ DLQ로 이동 → 수동 재처리 또는 자동 재시도
}
}장점: 서버 재시작 시에도 큐에 메시지가 남아 있어 유실이 없다. Worker를 수평 확장할 수 있다. 재시도, DLQ(Dead Letter Queue), 속도 제한(rate limiting)을 큐 레벨에서 자연스럽게 구현 가능. 클라이언트가 HTTP 연결을 오래 유지할 필요가 없다.
단점: RabbitMQ 같은 메시지 브로커 인프라가 추가된다. 클라이언트가 polling으로 완료를 확인해야 하므로 UX 설계가 필요하다(또는 WebSocket/SSE로 실시간 알림 구현).
적합한 경우: 프로덕션 환경. 트래픽이 많거나, 요청 유실이 허용되지 않거나, 여러 서비스에서 영상 생성 기능을 공유해야 할 때.
패턴 3: Webhook 콜백 (인프라 최소화 + 신뢰성)
API 호출 시 콜백 URL을 전달하고, 영상 생성이 완료되면 외부 API가 우리 서버로 POST를 보내주는 패턴. 메시지 큐 없이도 비동기 처리가 가능하다.
java
// 요청 시 콜백 URL 전달
@PostMapping("/api/videos/generate")
public ResponseEntity<JobResponse> generate(@RequestBody VideoRequest req) {
String jobId = UUID.randomUUID().toString();
String callbackUrl = baseUrl + "/api/videos/webhook/" + jobId;
videoApiClient.generateWithCallback(
req.getImagePath(), req.getPrompt(), callbackUrl
);
jobRepository.save(new VideoJob(jobId, "PENDING"));
return ResponseEntity.accepted().body(new JobResponse(jobId, "PENDING"));
}
// 외부 API가 완료 시 호출하는 엔드포인트
@PostMapping("/api/videos/webhook/{jobId}")
public ResponseEntity<Void> handleCallback(
@PathVariable String jobId,
@RequestBody WebhookPayload payload) {
jobRepository.complete(jobId, payload.getVideoUrl());
notificationService.notifyClient(jobId); // WebSocket 또는 SSE
return ResponseEntity.ok().build();
}장점: 메시지 큐 인프라 불필요. 클라이언트 polling 빈도를 줄일 수 있다(WebSocket/SSE 알림과 결합 시 실시간 반영). API 제공자 측에서 재시도를 처리해주는 경우가 많다.
단점: Webhook 수신 엔드포인트가 외부에서 접근 가능해야 하므로 보안(서명 검증, IP 화이트리스트) 처리가 필요하다. 외부 API가 Webhook을 지원하지 않으면 사용 불가. 콜백이 유실되었을 때의 fallback 로직도 필요하다(타임아웃 후 polling으로 전환).
적합한 경우: API가 Webhook을 공식 지원하고, 메시지 큐 운영 부담을 피하고 싶을 때. 중소 규모 서비스에 적합.
세 패턴 비교 요약
기준 | 패턴 1 (WebClient) | 패턴 2 (메시지 큐) | 패턴 3 (Webhook) |
|---|---|---|---|
추가 인프라 | 없음 | 메시지 브로커 | 없음 (보안 설정 필요) |
요청 유실 내성 | 낮음 | 높음 | 중간 |
수평 확장성 | 제한적 | 우수 | 보통 |
구현 복잡도 | 낮음 | 중간 | 중간 |
클라이언트 연결 유지 | 필요 (90초+) | 불필요 | 불필요 |
재시도 처리 | 직접 구현 | 큐 레벨 자동 | API 측 의존 |
공통으로 챙겨야 할 것들
어떤 패턴을 쓰든 다음 세 가지는 반드시 구현해야 한다.
1. 멱등성 보장. 같은 요청이 중복 실행되어도 영상이 두 번 생성되지 않도록 요청 ID 기반 중복 체크. 특히 패턴 2에서 메시지 재전달 시 필수.
2. 타임아웃과 서킷 브레이커. 외부 API가 300초 이상 무응답일 경우 재시도 또는 포기 판단. Resilience4j의 CircuitBreaker와 Retry 조합이 Spring Boot 생태계에서 가장 자연스럽다.
java
@CircuitBreaker(name = "videoApi", fallbackMethod = "fallback")
@Retry(name = "videoApi", fallbackMethod = "fallback")
public String generateVideo(String imagePath, String prompt) {
return videoApiClient.generate(imagePath, prompt);
}
public String fallback(String imagePath, String prompt, Throwable t) {
log.error("Video generation failed after retries", t);
return null; // 또는 DLQ에 적재
}3. 비용 제어. API 호출 = 과금이다. 사용자가 무한으로 생성 요청을 보내지 못하도록 Rate Limiter(Bucket4j 또는 Redis 기반)를 Controller 앞단에 배치. 사용자당 일일/월간 호출 한도를 설정하고, 초과 시 429 Too Many Requests를 반환.
어떤 패턴을 선택할 것인가
MVP나 사이드 프로젝트 → 패턴 1. 빠르게 붙이고 돌아가는지 확인.
프로덕션 서비스 → 패턴 2. 안정성과 확장성이 확보되고, 나중에 다른 AI API(이미지 생성, 음성 합성 등)로 확장할 때도 같은 큐 인프라를 재사용할 수 있다.
이미 Webhook 기반 아키텍처를 운영 중인 서비스 → 패턴 3. 기존 인프라와 패턴이 일관성을 유지한다.
어떤 패턴을 쓰든, 핵심은 "동기 호출하지 않는다"는 원칙이다. 90초짜리 외부 호출을 서블릿 스레드에서 동기로 기다리는 순간 서비스 전체가 위험해진다. 이건 영상 생성 API에만 해당하는 이야기가 아니라, 앞으로 점점 더 많아질 장시간 처리 AI API 전반에 적용되는 설계 원칙이다.