💻 프로젝트/QRoad

3분 이상 걸리는 기사 발행 API를 Job 기반 비동기로 전환하며 고민한 것들

SoloQuest 2026. 2. 16. 02:23

프로젝트를 진행하면서 기사 발행 API의 처리 시간이 예상보다 길어졌다.

특히 GPT 기반 기사 분리 및 분석 단계에서 3분 이상이 소요되었다.

문제는 단순히 “오래 걸린다”는 것이 아니었다.

사용자 입장에서는 버튼을 눌렀는데 아무 반응이 없고,
서버가 멈춘 것처럼 보이는 경험이 가장 큰 문제였다.


기존 구조의 문제

기존 API는 단순한 동기 처리였다.

POST /api/admin/publications

이 요청 안에서:

  • 기사 분리
  • 기사 분석
  • 요약
  • 키워드 매핑
  • 연관 기사 생성
  • 최종 저장

모든 과정을 한 번에 처리했다.

그 결과:

  • 응답까지 수 분 대기
  • 서버가 정상 동작 중인지 알 수 없음
  • 사용자 UX 저하

“정확한 퍼센트”를 계산해야 할까?

처음에는 전체 시간을 예측해서
정확한 퍼센트를 계산할까 고민했다.

하지만 곧 이런 생각이 들었다.

  • GPT 호출 시간은 예측 불가능
  • 기사 개수에 따라 처리 시간 변동
  • 외부 API 응답 속도에 따라 달라짐
  • 평균 시간 기반 계산은 실제 체감과 어긋날 가능성 높음

이 기능의 목적은 “정밀한 퍼센트 표시”가 아니라
서버가 정상 동작 중이라는 신호를 주는 것이었다.

그래서 총 시간 기반 계산은 과하다고 판단했다.


선택한 방식: 단계 기반 가중치 퍼센트

전체 과정을 단계로 나누고
각 단계에 퍼센트를 고정값으로 부여했다.

public enum PublicationStep {
    PDF_UPLOADING(
5, "PDF 업로드 준비 중,,,"),
    PDF_READING(
10, "PDF 내용 확인 중,,,"),
    CHUNKING_AND_ANALYZING(
20, "기사 분리 및 분석 중,,,"),
    ANALYSIS_FINALIZING(
70, "기사 분석 결과 정리 중,,,"),
    KEYWORD_MAPPING(
85, "키워드 저장 및 매핑 중,,,"),
    FINDING_RELATED(
90, "연관 기사/정책 생성 중,,,"),
    SAVING(
95, "최종 저장 중,,,"),
    DONE(
100, "처리 완료.");
}

처음에는 퍼센트를 직접 숫자로 변경할까 했지만,
기능이 계속 확장 중이었기 때문에 수정이 잦을 것으로 예상했다.

그래서 단계 정의를 enum으로 분리했다.

이렇게 하면:

  • 단계 추가 시 enum만 수정
  • 메서드 한 줄 추가로 확장 가능
  • 퍼센트 관리가 중앙 집중화됨

확장성을 고려한 구조가 되었다.


Job 기반 비동기 구조로 전환

API 구조도 다음과 같이 분리했다.

1. 작업 시작 API

POST /api/admin/publications
  • jobId 생성
  • ProgressStore 초기화
  • 실제 작업은 @Async로 실행
  • 즉시 jobId 반환

2. 진행률 조회 API

GET /api/admin/publications/{jobId}/progress
  • 단순 조회 전용
  • 절대 비즈니스 로직 실행하지 않음
  • DONE/FAILED까지 polling

중요한 점은 progress API에서 청킹, 분석, GPT 호출 등의 로직을 절대 실행하지 않도록 분리한 것이다.

GET은 오직 상태 조회만 담당한다.


상태 저장 방식에 대한 고민 (AI 제안과 판단 과정)

비동기 Job 구조로 전환하면서 또 하나 고민이 생겼다.
진행 상태를 어디에 저장할 것인가였다.

처음에는 AI에게 구조를 물어봤고, AI는 Redis나 DB에 진행 상태를 저장하는 방식을 제안했다.
실제로 많은 예제에서도 이런 구조를 “정석”처럼 소개한다.

특히 다음과 같은 이유 때문이었다.

  • 서버 재시작 시 상태 유실 방지
  • 멀티 인스턴스 환경 대응
  • 보다 안정적인 상태 관리

처음에는 이 방식이 더 견고해 보였다.

하지만 이번 기능의 요구사항을 다시 정리해보았다.

  • 관리자 전용 기능
  • 동시 사용자가 많지 않음
  • 실패 시 재시도 가능
  • 진행 상태는 일시적인 UI 표시용 데이터
  • 서버 재시작 시 유실되어도 치명적이지 않음

진행률 데이터는 영구 보존해야 할 비즈니스 데이터가 아니었다.
단순히 작업 중 현재 상태를 사용자에게 보여주기 위한 임시 정보에 가까웠다.

그렇다면 Redis나 DB를 도입하는 것은 오히려 과설계일 수 있다고 판단했다.

그래서 이번 구현에서는 ConcurrentHashMap 기반 인메모리 저장소를 선택했다.
작업이 완료되거나 실패하면 3분 후 자동 제거하도록 처리했다.

이 과정에서 느낀 점은,

도구가 제안하는 일반적인 정답보다
현재 요구사항에 맞는 선택이 더 중요하다는 것이었다.


진행 상태 저장 구조

진행 상태는 인메모리 기반으로 관리했다.

private final ConcurrentMap<String, PublicationProgressDto> progressMap
    =
new ConcurrentHashMap<>();

상태 전환은 다음과 같이 이루어진다.

public void moveTo(String jobId, PublicationStep step) {
    progressMap.compute(jobId, (ignored, existing) ->
    PublicationProgressDto.builder()
        .status(...)
        .progress(step.getProgress())
        .message(step.getMessage())
        .timestamp(Instant.now())
        .build()
    );
}

DONE 또는 FAILED 상태가 되면
3분 후 자동 제거하도록 처리했다.

cleanupExecutor.schedule(
    () -> progressMap.remove(jobId),
    3,
    TimeUnit.MINUTES
);

병목 발견: 기사 분리 및 분석 단계

실제로 실행해보니 특정 구간에서 진행률이 오래 멈춰 있었다.

가장 오래 걸린 부분은 기사 분리 및 분석 단계였다.

처음 설계에서는 이 구간이 40% → 60% 사이를 차지했는데,
이 구간에서 변화가 없으면 사용자 입장에서는 멈춘 것처럼 느껴질 수 있었다.

그래서 해당 구간을 세분화하며 퍼센트 구간을 늘려줬다.

그리하여 전체 기사 수를 기준으로 현재 처리된 기사 수를 표시하도록 수정했다.

String message="기사 분리 및 분석 중,,, ("
    + boundedProcessed +
"/" + total + ")";

 

퍼센트도 다음과 같이 계산했다.

int start= PublicationStep.CHUNKING_AND_ANALYZING.getProgress();
int end= PublicationStep.ANALYSIS_FINALIZING.getProgress();
int range= end - start;
int progress= start + (boundedProcessed * range) / total;

이로 인해:

  • 멈춘 느낌 제거
  • 자연스러운 퍼센트 증가
  • 사용자 신뢰도 향상

정리

이번 작업에서 중요하게 생각한 것은 두 가지였다.

  1. 정확한 퍼센트보다 중요한 것은 “진행 중이라는 신호”
  2. 과설계를 피하면서 확장 가능한 구조를 만드는 것

단순히 비동기 처리를 한 것이 아니라,

  • UX 개선
  • 역할 분리
  • 확장성 고려
  • 병목 구간 세분화

까지 함께 고민한 작업이었다.


마무리

동기 API를 단순히 비동기로 바꾼 것이 아니라,
“어떻게 보여줄 것인가”까지 설계한 경험이었다.

앞으로도 단순한 기능 구현이 아니라
설계와 사용자 경험까지 고려하는 개발을 하고 싶다.