FastAPI 동기/비동기 블로킹 이슈 해결하기 Thumbnail

ArgoCD Readiness HealthCheck Timeout 문제 해결

15 min read
문제해결DevopsBackendFastAPI비동기

ArgoCD Readiness HealthCheck Timeout 문제 해결: FastAPI 동기/비동기 블로킹 이슈

썸네일썸네일

ArgoCD Readiness HealthCheck Timeout이란?

ArgoCD는 Kubernetes 클러스터에서 GitOps 방식으로 애플리케이션을 배포하고 관리하는 도구다. 배포된 파드(Pod)가 정상적으로 트래픽을 받을 준비가 되었는지 확인하기 위해 Readiness Probe를 사용한다.

Readiness Probe는 지정된 엔드포인트(일반적으로 /health 또는 /)에 주기적으로 HTTP 요청을 보내며, 다음과 같은 기본 설정을 가진다:

  • periodSeconds: 10초 (체크 주기)
  • timeoutSeconds: 1초 (응답 대기 시간)
  • failureThreshold: 3회 (연속 실패 허용 횟수)

만약 헬스체크 API가 1초 내에 응답하지 못하면 실패로 간주되고, 3회 연속 실패 시 해당 파드는 NotReady 상태로 전환되어 트래픽을 받지 못한다. 심한 경우 서버가 재시작되거나 종료될 수 있다.

배경

최근 진행 중인 사내 프로젝트를 개발하면서, 로컬 환경에서는 완벽하게 동작하던 FastAPI 백엔드 서버가 프로덕션 환경(ArgoCD + Kubernetes)에 배포하는 순간 문제가 발생했다.

증상

  • ArgoCD에서 Readiness probe failed: Get http://...:8000/: context deadline exceeded 에러 반복 발생
  • 서버로 가는 트래픽이 무시됨
  • 가끔 파드가 재시작되거나 완전히 종료됨
  • 특정 작업 중에는 다른 GET API 요청조차 응답하지 않음

배경배경

처음에는 "로컬에서는 잘 되는데 왜 배포에서만 안 되지?"라는 의문을 가졌고, 핵심적인 차이점은 로컬 환경에서는 Readiness Probe를 지속적으로 수행하지 않는다는 점이라는 걸 알게 되었다.

핵심 문제 원인

이 문제를 해결하기 위해 많은 시도와 가설을 세웠었는데, 처음에는

  1. 초기 데이터 로드시에 오래 걸려서 - 로드 시 문제가 생기는 건 아니었음
  2. 리소스가 부족해서 - 리소스가 부족하면 OOMKilled라고 출력되어서 아니었음
  3. 태스크 큐 길이가 부족해서 - 실제로 많은 양의 태스크 요청을 했지만 아니었음

이런 시도를 거쳐서 찾아낸 핵심 문제는 아래와 같다.

1. 헬스체크도 결국 API 요청

Readiness Probe는 별도의 특수한 메커니즘이 아니라, 일반적인 HTTP GET 요청을 /health 엔드포인트로 보내는 것에 불과하다. 즉, 다른 API 요청과 동일한 큐에서 처리된다.

# packages/backend/app/api/v1/endpoints/health.py
@router.get("/health")
async def health_check():
    return {"status": "ok"}

2. 블로킹 작업이 서버 전체를 멈추게 한다

우리 서비스는 다음과 같은 복잡한 작업 플로우를 가지고 있었다:

  1. 이미지 업로드 및 복잡한 파이프라인 처리 (최대 10분 소요 가능)
  2. 처리 결과를 S3에 업로드
  3. S3 URL을 DB에 저장

문제는 비동기 함수(async def) 내에서 동기적 DB 작업을 수행하고 있었다는 점이다.

# ❌ 문제가 있는 코드
async def process_and_save(image_data):
    # 파이프라인 처리 (비동기)
    result = await pipeline.process(image_data)
    
    # S3 업로드 (비동기)
    s3_url = await s3_service.upload(result)
    
    # DB 저장 (동기!) - 이 부분이 문제
    db_service.save_result(s3_url)  # sync function
    
    return s3_url

3. 동기/비동기 혼용의 함정

FastAPI는 비동기 프레임워크이지만, 동기 함수도 호출할 수 있다. 문제는 async def 함수 내에서 동기 I/O 작업을 호출하면 이벤트 루프가 블로킹된다는 점이다.

비동기 함수 내 동기 작업비동기 함수 내 동기 작업

함수 타입DB 작업 타입결과이유
동기 (def)동기 (sync SQLAlchemy)✅ 정상 동작FastAPI가 자동으로 스레드 풀에서 처리해 블로킹 없음
비동기 (async def)동기 (sync SQLAlchemy)블로킹 발생이벤트 루프가 sync I/O 대기하며 다른 요청 차단
비동기 (async def)비동기 (AsyncSession)최적완전 비동기 처리로 고성능 동시성 달성

우리의 경우 2번째 케이스였다. S3 업로드와 DB 저장이 수 초에서 수십 초 소요되는 동안, 이벤트 루프가 완전히 멈춰서 다른 요청(헬스체크 포함)이 대기하게 되었다.

4. 실제 발생한 시나리오

[시간 0초] 파이프라인 시작 (task A)
[시간 590초] 처리 완료, S3 업로드 시작 (비동기 OK)
[시간 595초] S3 업로드 완료, DB 저장 시작 (동기 - 블로킹 시작!)
[시간 596초] ArgoCD readiness probe 요청 → 대기 중...
[시간 597초] 1초 timeout → probe failed (1/3)
[시간 607초] 2번째 probe 요청 → 여전히 대기 중...
[시간 608초] timeout → probe failed (2/3)
[시간 610초] DB 저장 완료 (블로킹 해제)
[시간 617초] 3번째 probe 성공

3회 연속 실패가 발생하지 않으면 다행이지만, DB 작업이 더 길어지거나 동시에 여러 작업이 진행되면 파드가 NotReady 상태로 전환된다. 병렬처리 기능을 도입하고 나서는 헬스체크가 더 실패할 확률이 높아지면서 작업을 처리하다가 서버가 unhealthy 상태로 들어가는 모습을 자주 봤었다.

해결 방법

처음에는 헬스체크 설정을 바꿔서 조금 기다리더라도 서버가 죽지는 않게 하려고 했으나, 이는 근본적인 해결 방법이 아니라고 생각을 했다. 이후 찾은 해결방법으로는 아래와 같다.

1. 모든 DB 작업을 비동기로 전환

가장 근본적인 해결책은 동기 DB 작업을 비동기로 전환하는 것이다.

# ✅ 수정된 코드
from sqlalchemy.ext.asyncio import AsyncSession

async def process_and_save(image_data, db: AsyncSession):
    # 파이프라인 처리 (비동기)
    result = await pipeline.process(image_data)
    
    # S3 업로드 (비동기)
    s3_url = await s3_service.upload(result)
    
    # DB 저장 (비동기!)
    await db_service.save_result_async(db, s3_url)
    
    return s3_url

2. SQLAlchemy 비동기 세션 구성

PostgreSQL/Aurora DB를 사용하는 경우 asyncpg 드라이버를 통해 비동기 세션을 구성할 수 있다.

# packages/backend/app/core/database.py
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker

# PostgreSQL/Aurora 연결 (asyncpg 사용)
engine = create_async_engine(
    "postgresql+asyncpg://user:pass@host:5432/dbname",
    echo=False,
    pool_pre_ping=True,
)

AsyncSessionLocal = async_sessionmaker(
    engine,
    class_=AsyncSession,
    expire_on_commit=False,
)

async def get_db():
    async with AsyncSessionLocal() as session:
        yield session

3. 서비스 레이어 비동기 변환

기존의 동기 DB 작업을 비동기로 변환했다. 특히 트랜잭션 처리가 필요한 경우 async with 구문을 활용했다.

# Before (동기)
def save_task_result(task_id: str, result_url: str):
    with SessionLocal() as db:
    ...

# After (비동기)
async def save_task_result_async(db: AsyncSession, task_id: str, result_url: str):
    async with db.begin():  # 트랜잭션 보장
    ...

4. 순서 보장이 필요한 경우 트랜잭션 활용

비동기 작업은 기본적으로 순서가 보장되지 않는다. 예를 들어, "상태를 'processing'으로 변경 → 결과 저장 → 상태를 'completed'로 변경" 같은 작업은 순서가 중요하다.

async def process_with_state_management(db: AsyncSession, task_id: str):
    async with db.begin():  # 트랜잭션 시작
        # 1. 상태 변경
        await update_task_status(db, task_id, "processing")
        
        # 2. 파이프라인 작업 (트랜잭션 밖에서 수행)
        # 트랜잭션 내에서 오래 걸리는 작업을 하면 안 됨
        
    # 작업 수행
    result = await pipeline.process()
    
    async with db.begin():  # 새로운 트랜잭션
        # 3. 결과 저장
        await save_result(db, task_id, result)
        
        # 4. 상태 완료로 변경
        await update_task_status(db, task_id, "completed")

트랜잭션 순서보장트랜잭션 순서보장

배운 점

FastAPI의 동기/비동기 혼용은 치명적이다

최근 프로젝트에서 백엔드를 많이하고 있다. 프론트엔드에서의 비동기랑은 약간 차이가 있어서 배우면서 하고 있다.. ㅎㅎ

FastAPI는 유연하게 동기와 비동기 함수를 모두 지원하지만, 비동기 함수 내에서 동기 I/O를 호출하면 이벤트 루프가 블로킹된다고 한다. 특히 다음과 같은 작업은 반드시 비동기로 처리해야 한다.

  • 데이터베이스 쿼리 (SQLAlchemy AsyncSession)
  • 외부 API 호출 (httpx.AsyncClient)
  • 파일 I/O (aiofiles)
  • S3/클라우드 스토리지 작업 (aioboto3)

로컬 환경과 프로덕션 환경의 차이

로컬에서는 Readiness Probe가 없기 때문에 블로킹 문제가 드러나지 않는다. 반면 Kubernetes 환경에서는 주기적인 헬스체크가 서버의 건강 상태를 감시하므로, 짧은 블로킹도 치명적일 수 있다.

I/O 작업은 비동기로 설계하는 것이 좋겠다고 생각했다.

처음부터 올바른 아키텍처를 설계하자

이미 작성된 동기 코드를 비동기로 전환하는 작업은 매우 시간 소모적이다. 우리 프로젝트에서는 다음 파일들을 모두 수정해야 했다.

  • app/services/db_service.py (150+ 줄)
  • app/core/event_handlers.py (100+ 줄)
  • 각종 API 엔드포인트

프로젝트 초기에 동기/비동기 방침을 명확히 정하고, 일관되게 적용하자


결론

"로컬에서는 되는데 배포하면 안 된다"는 문제의 근본 원인은 FastAPI 비동기 함수 내 동기 I/O 블로킹이었다. ArgoCD의 Readiness Probe가 이를 드러내는 계기가 되었고, 모든 DB 작업을 비동기로 전환하여 해결했다.

이 과정에서 얻은 가장 큰 학습은 처음부터 비동기 아키텍처를 일관되게 설계하는 것의 중요성이다. 백엔드 개발, 특히 고성능 비동기 서버를 구축할 때는 동기/비동기 경계를 명확히 하고, I/O 작업은 무조건 비동기로 처리해야 한다.

요즘에는 AI 보조 도구가 너무 잘 되어있어서 가끔은 내가 만들고 있는 프로덕트에 대한 이해가 낮아질 수 있겠다는 생각이 들었다.

이런 문제 해결도 결국에는 비동기와 동기, 이벤트루프와 블로킹, 트랜잭션 등의 개념을 알아야하는거구나 하는 생각도 했다. AI가 발전해도 살아남으려면 기본 개념 파악이 우선시 되어야 하는 것 같다.

Table of Contents

0
추천 글