오래 걸리는 AI 작업을 안전하게 처리하기
이 글에서는 오래 걸리는 AI 작업을 큐와 멱등성으로 안전하게 처리하는 방법을 정리합니다.
LLM 서비스에는 요청 즉시 끝나지 않는 작업이 많습니다. PDF를 파싱하고, 문서를 chunk로 나누고, embedding을 만들고, vector DB에 저장하고, 대량 문서를 요약하는 작업은 수 초에서 수 분까지 걸릴 수 있습니다.
이런 작업을 API 요청 안에서 동기 처리하면 사용자는 오래 기다리고, 서버는 쉽게 포화되고, 실패했을 때 복구도 어렵습니다. 그래서 큐가 필요합니다.
하지만 큐를 쓰는 순간 또 다른 문제가 생깁니다. 메시지는 중복될 수 있고, 재시도될 수 있고, 처리 중 worker가 죽을 수 있습니다. 그래서 idempotency가 필요합니다.
분석 기준일: 2026-05-12
실습 기준 환경: FastAPI, PostgreSQL, Redis Queue 또는 SQS/Kafka 개념
주요 참고자료: AWS Builders Library, Amazon SQS Docs, Kafka Docs
핵심 요약
- 오래 걸리는 AI 작업은 API 요청과 분리해야 한다.
- 큐는 장애와 부하를 흡수하지만 중복 처리 가능성을 만든다.
- idempotency key는 같은 작업이 여러 번 실행되어도 결과가 깨지지 않게 한다.
- 작업 상태 테이블은 비동기 처리의 관측성을 높인다.
- retry와 DLQ는 실패를 숨기는 장치가 아니라 분석 가능한 상태로 남기는 장치다.
1. 왜 AI 작업은 오래 걸리는가
AI 백엔드에서 오래 걸리는 작업은 다음과 같습니다.
| 작업 | 오래 걸리는 이유 |
|---|---|
| PDF 파싱 | 파일 크기, OCR, 표/이미지 처리 |
| Chunking | 문서 구조 분석 필요 |
| Embedding 생성 | 외부 모델 호출 또는 batch 처리 |
| Vector 저장 | 대량 insert, index update |
| 대량 요약 | 모델 호출 횟수 증가 |
| Eval 실행 | 테스트 데이터셋 반복 호출 |
이 작업들은 사용자 요청과 같은 lifecycle에 묶어두기 어렵습니다.
2. 동기 처리의 문제
동기 처리는 처음에는 간단합니다.
# 예시입니다.POST /documents/{id}/index→ PDF 파싱→ Chunking→ Embedding→ Vector 저장→ 응답
하지만 문제가 생깁니다.
# 예시입니다.[ ] 요청 시간이 길어진다.[ ] reverse proxy timeout에 걸릴 수 있다.[ ] 사용자는 진행 상태를 알 수 없다.[ ] 실패 시 어디까지 처리됐는지 알기 어렵다.[ ] 같은 요청이 다시 들어오면 중복 색인이 발생할 수 있다.
그래서 비동기 처리로 바꿔야 합니다.
3. Queue 기반 처리 흐름
# 예시입니다.Client→ POST /documents/{id}/index→ API server creates job→ enqueue message→ return job_id→ Worker consumes message→ process indexing→ update job status→ Client polls GET /jobs/{job_id}
이 구조의 장점은 API 서버가 긴 작업을 직접 붙잡지 않는다는 점입니다. 사용자는 job_id를 받고 상태를 조회합니다.
4. Idempotency Key 설계
같은 문서를 같은 버전으로 색인하는 작업은 한 번만 실행되어야 합니다.
# 예시입니다.idempotency_key = document_id + document_version + pipeline_version
예시:
# 예시입니다.doc_123:v7:rag-pipeline-v2
같은 key로 요청이 다시 들어오면 새 job을 만들지 않고 기존 job을 반환합니다.
# 예시 코드입니다.async def create_index_job(document_id: str, document_version: int): key = f"{document_id}:v{document_version}:rag-pipeline-v2" existing = await job_repo.find_by_idempotency_key(key) if existing: return existing job = await job_repo.create( type="DOCUMENT_INDEX", idempotency_key=key, status="PENDING", ) await queue.publish({"job_id": job.id}) return job
5. 작업 상태 테이블 설계
비동기 작업은 상태를 저장해야 합니다.
| 컬럼 | 설명 |
|---|---|
job_id | 작업 ID |
job_type | DOCUMENT_INDEX, EVAL_RUN 등 |
idempotency_key | 중복 방지 key |
status | PENDING, RUNNING, SUCCEEDED, FAILED |
attempt_count | 재시도 횟수 |
last_error_code | 마지막 실패 원인 |
created_at | 생성 시각 |
updated_at | 갱신 시각 |
상태 테이블이 없으면 worker가 실패했을 때 “어디까지 처리됐는지” 알 수 없습니다.
6. Retry와 Backoff
재시도는 필요하지만 무한 재시도는 위험합니다. 특히 외부 모델 API 장애 상황에서 모든 worker가 즉시 재시도하면 장애를 키울 수 있습니다.
추천 정책:
# 예시입니다.1차 실패: 5초 후 재시도2차 실패: 30초 후 재시도3차 실패: 2분 후 재시도4차 실패: DLQ 이동
재시도 가능한 오류와 불가능한 오류도 구분해야 합니다.
| 오류 | 재시도 여부 |
|---|---|
| 외부 API timeout | 가능 |
| rate limit | 가능, backoff 필요 |
| 잘못된 문서 형식 | 불가능 |
| schema validation 실패 | 조건부 가능 |
| 권한 없음 | 불가능 |
7. DLQ 설계
DLQ는 실패 메시지를 버리는 곳이 아닙니다. 분석 가능한 상태로 격리하는 곳입니다.
DLQ에 들어가는 메시지에는 다음 정보가 있어야 합니다.
// 예시 JSON 구조입니다.{ "job_id": "job_123", "idempotency_key": "doc_123:v7:rag-pipeline-v2", "attempt_count": 4, "last_error_code": "EMBEDDING_RATE_LIMIT", "last_error_message": "Rate limit exceeded", "trace_id": "0af..."}
DLQ 메시지는 알림, 대시보드, 재처리 도구와 연결되어야 합니다.
8. 문서 색인 예제
# 예시입니다.1. 사용자가 문서 업로드2. API가 document_version 생성3. index job 생성4. queue publish5. worker가 문서 파싱6. chunk 생성7. embedding 생성8. pgvector에 저장9. job status를 SUCCEEDED로 변경
실패 시:
# 예시입니다.worker timeout→ message visibility timeout 만료→ 다른 worker가 재처리→ 같은 idempotency key 확인→ 이미 처리된 chunk는 skip→ 실패 반복 시 DLQ 이동
9. 운영 지표
| 지표 | 의미 |
|---|---|
queue_depth | 대기 중인 메시지 수 |
job_duration_p95 | 작업 처리 지연 |
job_failure_rate | 실패율 |
retry_count | 재시도 횟수 |
dlq_message_count | 분석 필요한 실패 |
duplicate_request_count | idempotency로 막은 중복 |
10. 실무 체크리스트
# 예시입니다.[ ] 오래 걸리는 작업을 API 요청에서 분리했는가?[ ] job_id로 상태를 조회할 수 있는가?[ ] idempotency_key가 작업 단위에 맞게 설계되었는가?[ ] worker 재시작 후에도 이어서 처리할 수 있는가?[ ] retry 가능한 오류와 불가능한 오류를 구분했는가?[ ] backoff와 최대 재시도 횟수가 있는가?[ ] DLQ 메시지에 trace_id와 error_code가 포함되어 있는가?[ ] DLQ 재처리 절차가 있는가?
11. Q&A
Q1. Redis Queue, SQS, Kafka 중 무엇을 써야 하나요?
초기 학습 프로젝트는 Redis Queue로 충분합니다. 운영 환경에서는 내구성, 재시도, DLQ, 처리량 요구사항에 따라 SQS나 Kafka를 고려합니다.
Q2. 멱등성은 DB unique constraint만으로 충분한가요?
많은 경우 unique constraint가 좋은 출발점입니다. 하지만 작업 상태, 재시도, 부분 성공을 함께 다루려면 별도 job table이 필요합니다.
Q3. 메시지가 한 번만 처리된다고 가정해도 되나요?
그렇게 가정하지 않는 편이 안전합니다. 큐 기반 시스템에서는 중복 가능성을 전제로 idempotent하게 설계하는 것이 현실적입니다.
12. 참고자료와 불확실성
참고자료
- AWS Builders Library — Making retries safe with idempotent APIs: https://aws.amazon.com/builders-library/making-retries-safe-with-idempotent-APIs/
- Amazon SQS Visibility Timeout: https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-visibility-timeout.html
- Amazon SQS Dead-letter Queues: https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-dead-letter-queues.html
- Apache Kafka Documentation: https://kafka.apache.org/documentation/
불확실성
- 큐 선택은 트래픽, 내구성, 팀 운영 경험에 따라 달라집니다.
- 정확히 한 번 처리 semantics는 시스템 전체에서 보장하기 어렵기 때문에, 비즈니스 결과 기준의 멱등성을 우선 설계해야 합니다.
실무 예시: 문서 색인 작업의 중복 실행 막기
문서 embedding 작업은 사용자가 버튼을 두 번 누르거나 worker가 timeout 뒤 재시도할 때 중복되기 쉽습니다. idempotency key는 보통 tenant_id, document_id, document_version, embedding_model을 조합해 만듭니다. 같은 문서 버전과 모델이면 이미 실행 중인 job을 반환하고, 문서가 바뀌었거나 embedding model이 바뀌면 새 job을 만들어야 합니다.
| 상황 | 처리 |
|---|---|
| 같은 문서 같은 버전 재요청 | 기존 job 반환 |
| 같은 문서 새 버전 | 새 job 생성 |
| worker timeout 후 재시도 | idempotency key로 중복 방지 |
| 계속 실패하는 문서 | DLQ로 이동 후 수동 확인 |
실패 사례는 retry를 단순히 많이 하는 것입니다. provider rate limit이나 문서 파싱 오류처럼 재시도해도 바로 해결되지 않는 실패가 있습니다. retry count, last error, next retry time을 job table에 남기고, 일정 횟수 이후에는 DLQ로 보내야 합니다. 큐는 느린 작업을 뒤로 미루는 장치가 아니라 실패를 분류하고 재처리 가능하게 만드는 운영 계층입니다.
작업 화면에는 "처리 중" 하나만 보여주기보다 queued, running, retry_scheduled, failed, completed를 구분하는 편이 좋습니다. 사용자가 같은 문서를 다시 올렸을 때 기존 job을 보여줄 수 있어야 중복 비용도 줄어듭니다.
Safely handle long-running AI tasks
In this article, we outline how to safely handle long-running AI tasks with queues and idempotence.
There are many tasks in LLM services that do not end immediately upon request. Parsing a PDF, splitting the document into chunks, creating embeddings, storing them in a vector DB, and summarizing large documents can take anywhere from seconds to minutes.
If these tasks are processed synchronously within an API request, users will wait a long time, the server will easily become saturated, and recovery in case of failure will be difficult. So we need a queue.
But the moment you use a cue, another problem arises. Messages can be duplicated, retried, and workers can die while processing. So idempotency is needed.
Analysis Base Date: 2026-05-12 Lab Base Environment: FastAPI, PostgreSQL, Redis Queue, or SQS/Kafka Concepts Key References: AWS Builders Library, Amazon SQS Docs, Kafka Docs
Key takeaways
- Long-running AI tasks should be separated from API requests.
- Queues absorb failures and load, but create the possibility of redundant processing.
- The idempotency key prevents the results from being corrupted even if the same task is executed multiple times.
- Task state tables increase observability of asynchronous processing.
- Retry and DLQ are not devices that hide failures, but leave them in a state that can be analyzed.
1. Why AI tasks take so long
Long-running operations in the AI backend include:
| work | Why it takes so long |
|---|---|
| PDF parsing | File size, OCR, table/image processing |
| Chunking | Document structure analysis required |
| Create Embedding | External model call or batch processing |
| Save Vector | Bulk insert, index update |
| Bulk summary | Increase number of model calls |
| Run Eval | Repeated calls to test dataset |
These tasks are difficult to tie to the same lifecycle as user requests.
2. Problems with synchronous processing
Synchronous processing is simple at first.
# This is an example.POST /documents/{id}/indexto PDF parsing→ Chunking→ EmbeddingSave to Vectorto reply
But a problem arises.
# This is an example.[ ] The request time becomes longer.[ ] You may experience a reverse proxy timeout.[ ] The user cannot know the progress status.[ ] In case of failure, it is difficult to know how far it has been dealt with.[ ] If the same request comes in again, duplicate indexes may occur.
So we need to change it to asynchronous processing.
3. Queue-based processing flow
# This is an example.Client→ POST /documents/{id}/index→ API server creates job→ enqueue message→ return job_id→ Worker consumes message→ process indexing→ update job status→ Client polls GET /jobs/{job_id}
The advantage of this structure is that the API server does not grab long operations directly. The user receivesjob_idand queries the status.
4. idempotency key Design
Indexing the same document with the same version must be done only once.
# This is an example.idempotency_key = document_id + document_version + pipeline_version
example:
# This is an example.doc_123:v7:rag-pipeline-v2
If a request comes in again with the same key, the existing job is returned without creating a new job.
# This is example code.async def create_index_job(document_id: str, document_version: int): key = f"{document_id}:v{document_version}:rag-pipeline-v2" existing = await job_repo.find_by_idempotency_key(key) if existing: return existing job = await job_repo.create( type="DOCUMENT_INDEX", idempotency_key=key, status="PENDING", ) await queue.publish({"job_id": job.id}) return job
5. Task status table design
Asynchronous operations must store state.
| column | explanation |
|---|---|
job_id | task id |
job_type | DOCUMENT_INDEX, EVAL_RUN, etc. |
idempotency_key | Duplicate prevention key |
status | PENDING, RUNNING, SUCCEEDED, FAILED |
attempt_count | Number of retries |
last_error_code | Last cause of failure |
created_at | creation time |
updated_at | update time |
Without a state table, it is impossible to know “how far things have gone” when a worker fails.
6. Retry and Backoff
Retries are necessary, but infinite retries are dangerous. Especially in an external model API failure situation, if all workers retry immediately, the failure may increase.
Recommended Policy:
# This is an example.1st failure: Retry in 5 secondsSecond failure: Retry in 30 seconds3rd failure: Retry in 2 minutes4th failure: DLQ move
You should also distinguish between retriable and non-retriable errors.
| error | Retry? |
|---|---|
| External API timeout | possible |
| rate limit | Yes, backoff required |
| Invalid document format | impossibility |
| schema validation failed | Conditionally available |
| No permission | impossibility |
7. DLQ design
DLQ is not a place to dump failure messages. This is where it is isolated so that it can be analyzed.
Messages entering the DLQ must contain the following information:
// This is an example JSON structure.{ "job_id": "job_123", "idempotency_key": "doc_123:v7:rag-pipeline-v2", "attempt_count": 4, "last_error_code": "EMBEDDING_RATE_LIMIT", "last_error_message": "Rate limit exceeded", "trace_id": "0af..."}
DLQ messages should be associated with notifications, dashboards, and reprocessing tools.
8. Document index example
# This is an example.1. User uploads document2. API creates document_version3. Create index job4. queue publish5. Worker parses document6. Chunk creation7. Create embedding8. Save to pgvector9. Change job status to SUCCEEDED
On failure:
# This is an example.worker timeoutto message visibility timeout expiresto be reprocessed by another workerto check the same idempotency keyto skip chunks that have already been processedDLQ movement when to repeat failure
9. Operational indicators
| characteristic | meaning |
|---|---|
queue_depth | Number of waiting messages |
job_duration_p95 | Delay in processing tasks |
job_failure_rate | failure rate |
retry_count | Number of retries |
dlq_message_count | Failure requiring analysis |
duplicate_request_count | Duplication prevented by idempotency |
10. Practical checklist
# This is an example.[ ] Have you separated long-running tasks from API requests?[ ] Can I check the status by job_id?[ ] Is idempotency_key designed for the unit of work?[ ] Can processing continue even after restarting the worker?[ ] retry Did you distinguish between possible and impossible errors?[ ] Is there a backoff and maximum number of retries?[ ] Does the DLQ message include trace_id and error_code?[ ] Is there a DLQ reprocessing procedure?
11. Q&A
Q1. Should I use Redis Queue, SQS, or Kafka?
For initial learning projects, Redis Queue is sufficient. In production environments, consider SQS or Kafka depending on durability, retries, DLQ, and throughput requirements.
Q2. Is DB unique constraint sufficient for idempotency?
In many cases, unique constraints are a good starting point. However, a separate job table is needed to handle job status, retries, and partial successes together.
Q3. Can we assume that messages will only be processed once?
It's safer not to assume that. In a queue-based system, it is realistic to design it to be idempotent, assuming the possibility of redundancy.
12. References and uncertainty
References
- AWS Builders Library — Making retries safe with idempotent APIs:https://aws.amazon.com/builders-library/making-retries-safe-with-idempotent-APIs/
- Amazon SQS Visibility Timeout:https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-visibility-timeout.html
- Amazon SQS Dead-letter Queues:https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-dead-letter-queues.html
- Apache Kafka Documentation:https://kafka.apache.org/documentation/
uncertainty
- Queue selection will depend on traffic, durability, and team operating experience.
- Since exactly-once processing semantics are difficult to guarantee throughout the system, idempotency in business outcome criteria must be designed first.
Practical example: Avoid duplicate execution of document indexing operations
Document embedding operations can easily be duplicated when a user presses a button twice or a worker retries after a timeout. The idempotency key is usually created by combiningtenant_id,document_id,document_version, andembedding_model. If the document version and model are the same, a job already running is returned. If the document or embedding model changes, a new job must be created.
| situation | treatment |
|---|---|
| Re-request the same version of the same document | Return existing job |
| New version of the same document | Create new job |
| Retry after worker timeout | Prevent duplication with idempotency key |
| Documents that keep failing | Go to DLQ and check manually |
A failure case is simply doing too many retries. There are failures that cannot be immediately resolved by retrying, such as provider rate limits or document parsing errors. The retry count, last error, and next retry time must be left in the job table, and sent to DLQ after a certain number of times. A queue is not a device to postpone slow work, but rather an operational layer that classifies failures and makes them reprocessable.
In the task screen, it is better to distinguish betweenqueued,running,retry_scheduled,failed, andcompletedrather than just showing "Processing" alone. When a user uploads the same document again, existing jobs must be displayed to reduce duplication costs.

댓글
GitHub 계정으로 로그인하면 댓글을 남길 수 있습니다. 댓글은 GitHub Discussions를 통해 운영됩니다.