수요일

FastAPI async 블로킹 완벽 해결 — async def인데 요청이 순서대로 처리되는 이유와 이벤트 루프 막힘 해결법

🔍 검색 키워드: FastAPI async 블로킹, FastAPI 이벤트 루프 막힘, FastAPI 응답 느림 async def, FastAPI run_in_threadpool, asyncio to_thread 사용법, FastAPI 동시 요청 처리 지연

증상: async def 엔드포인트인데 동시 요청이 순서대로 처리된다

FastAPI는 비동기 프레임워크인데, 엔드포인트 하나가 느려지면 전혀 관계없는 /health 같은 다른 API까지 함께 느려지거나 타임아웃이 나는 경우가 있습니다. 부하 테스트를 해 보면 동시 요청 수만큼 응답 시간이 선형으로 늘어납니다.

$ hey -n 20 -c 20 http://localhost:8000/report
Response time histogram:
  0.503 [1]  |■
  1.004 [1]  |■
  ...
 10.012 [1]  |■
Total:  10.0121 secs   # 0.5초짜리 작업 20개가 순차 실행됨

WARNING: Task exception was never retrieved / uvicorn: Timeout in /health probe

원인: 이벤트 루프 스레드를 동기 코드가 점유한다

async def 함수는 이벤트 루프 스레드 하나에서 실행됩니다. 이 안에서 await 없이 오래 걸리는 동기 코드를 실행하면, 그동안 루프가 멈춰 다른 모든 요청이 대기합니다. 대표적인 원인은 다음과 같습니다.

  • time.sleep(): 비동기 sleep이 아니라 스레드 전체를 멈춤
  • requests 라이브러리: 동기 HTTP 호출이 루프를 블로킹
  • 동기 DB 드라이버: psycopg2, pymysql, 동기 SQLAlchemy 세션 사용
  • CPU 바운드 작업: 이미지 처리, pandas 대용량 연산, 암호화 해시 등
  • 파일 I/O: 대용량 파일을 open().read()로 동기 처리
import time
import requests
from fastapi import FastAPI

app = FastAPI()

@app.get("/report")
async def report():
    time.sleep(0.5)                     # 루프 전체 블로킹
    r = requests.get("https://api.example.com/data")  # 동기 호출도 블로킹
    return r.json()

해결방법 1: async를 빼고 def로 선언하기

FastAPI는 일반 def 엔드포인트를 스레드풀에서 실행합니다. 내부에서 동기 라이브러리를 쓸 수밖에 없다면 async를 제거하는 것이 가장 간단한 해결책입니다.

@app.get("/report")
def report():                            # async 제거 → 스레드풀에서 실행
    time.sleep(0.5)
    return requests.get("https://api.example.com/data").json()

해결방법 2: 진짜 비동기 라이브러리로 교체

async def를 유지하려면 내부 호출도 모두 await 가능한 라이브러리여야 합니다. requests는 httpx, time.sleep은 asyncio.sleep으로 바꿉니다.

import asyncio
import httpx

@app.get("/report")
async def report():
    await asyncio.sleep(0.5)
    async with httpx.AsyncClient() as client:
        r = await client.get("https://api.example.com/data")
    return r.json()

해결방법 3: 블로킹 구간만 스레드로 넘기기

async def 안에 동기 코드가 일부만 섞여 있다면 해당 구간만 to_thread 또는 run_in_threadpool로 감쌉니다.

import asyncio
from starlette.concurrency import run_in_threadpool

@app.get("/report")
async def report():
    data = await asyncio.to_thread(load_legacy_data)        # Python 3.9+
    text = await run_in_threadpool(render_pdf_text, data)   # Starlette 제공
    return {"text": text}

해결방법 4: CPU 바운드는 프로세스로 분리

스레드는 GIL 때문에 CPU 연산을 가속하지 못합니다. 무거운 연산은 ProcessPoolExecutor나 Celery 같은 워커로 분리하고, uvicorn 워커 수도 함께 늘립니다.

from concurrent.futures import ProcessPoolExecutor
pool = ProcessPoolExecutor(max_workers=4)

@app.get("/heavy")
async def heavy():
    loop = asyncio.get_running_loop()
    result = await loop.run_in_executor(pool, cpu_heavy_job, 42)
    return {"result": result}

# 실행: uvicorn main:app --workers 4

블로킹 위치 진단하기

asyncio 디버그 모드를 켜면 루프를 일정 시간 이상 붙잡은 코루틴을 경고로 알려 줍니다.

PYTHONASYNCIODEBUG=1 uvicorn main:app
# Executing <Task ...> took 0.503 seconds  ← 이 시간만큼 루프가 막혔다는 뜻

정리표

원인증상해결
time.sleep, requests 등 동기 I/O동시 요청이 순차 처리됨def로 선언 또는 httpx/asyncio.sleep으로 교체
동기 DB 드라이버DB 쿼리 중 다른 API 정지asyncpg, async SQLAlchemy 또는 to_thread
CPU 바운드 연산연산 중 health check 타임아웃ProcessPool, Celery, uvicorn --workers
원인 불명 지연특정 요청 후 전체 지연PYTHONASYNCIODEBUG=1로 블로킹 코루틴 추적

핵심은 async def 안에서는 await 없이 오래 걸리는 코드를 실행하지 않는다는 원칙입니다. 동기 코드가 섞여 있다면 def로 선언하거나 스레드·프로세스로 넘겨 이벤트 루프를 항상 비워 두세요.

Terraform state lock 에러 해결 — Error acquiring the state lock 원인과 force-unlock 안전하게 쓰는 법

🔍 검색 키워드: Terraform state lock 에러, Error acquiring the state lock 해결, terraform force-unlock 사용법, Terraform S3 DynamoDB lock, Terraform apply 중단 lock 해제, terraform lock ID

증상: terraform plan/apply가 state lock 에러로 멈춘다

팀 단위로 Terraform을 쓰거나 CI에서 apply를 돌리다 보면, 아무 변경도 하지 않았는데 다음과 같은 에러와 함께 명령이 중단되는 경우가 있습니다. 이 에러는 원격 백엔드(S3+DynamoDB, Terraform Cloud 등)에 state lock이 남아 있어서 발생합니다.

Error: Error acquiring the state lock

Error message: ConditionalCheckFailedException: The conditional request failed
Lock Info:
  ID:        3f2a9c1e-7b64-1d0e-9a3f-6c1b2d4e5f70
  Path:      my-bucket/prod/terraform.tfstate
  Operation: OperationTypeApply
  Who:       runner@ci-worker-12
  Version:   1.6.6
  Created:   2026-09-30 01:12:44 +0000 UTC

원인: 다른 실행이 진행 중이거나, 비정상 종료로 lock이 남음

Terraform은 state 파일이 동시에 수정되어 깨지는 것을 막기 위해 plan/apply 전에 lock을 잡고, 끝나면 해제합니다. lock이 남는 대표적인 경우는 다음과 같습니다.

  • 정상적인 동시 실행: 다른 팀원이나 CI 파이프라인이 실제로 apply 중인 경우
  • 비정상 종료: Ctrl+C 강제 종료, CI job 취소·타임아웃, 네트워크 단절로 unlock이 실행되지 못한 경우
  • DynamoDB 잔여 항목: S3 백엔드의 lock 테이블에 이전 실행의 LockID 항목이 그대로 남은 경우
  • 병렬 파이프라인 설계 문제: 같은 state를 쓰는 job이 동시에 트리거되도록 구성된 경우

해결방법 1: 먼저 진짜 실행 중인지 확인하기

가장 중요한 단계입니다. 에러 메시지의 Who, Created, Operation을 확인해 지금도 실제로 실행 중인 작업인지 먼저 확인하세요. 실행 중인 apply의 lock을 강제로 풀면 state가 손상될 수 있습니다. 해당 CI job이 이미 종료됐거나 담당자가 중단했음을 확인한 뒤에만 다음 단계로 넘어갑니다.

해결방법 2: 대기 옵션으로 자동 재시도

잠깐 겹친 실행이라면 lock이 풀릴 때까지 기다리게 할 수 있습니다. CI에서는 이 옵션을 기본으로 두는 것이 좋습니다.

terraform plan -lock-timeout=5m
terraform apply -lock-timeout=5m

해결방법 3: terraform force-unlock으로 안전하게 해제

실행 주체가 이미 죽었다고 확인됐다면, 에러 메시지에 나온 Lock ID로 강제 해제합니다.

terraform force-unlock 3f2a9c1e-7b64-1d0e-9a3f-6c1b2d4e5f70

Do you really want to force-unlock?
  Enter a value: yes

Terraform state has been successfully unlocked!

해결방법 4: S3 + DynamoDB 백엔드에서 lock 항목 직접 삭제

force-unlock이 실패하는 경우(권한 문제, 항목 손상 등)에는 lock 테이블의 항목을 직접 확인하고 삭제할 수 있습니다. 이 방법은 최후의 수단이며, 삭제 전에 반드시 항목 내용을 확인하세요.

aws dynamodb get-item --table-name terraform-locks \
  --key '{"LockID":{"S":"my-bucket/prod/terraform.tfstate"}}'

aws dynamodb delete-item --table-name terraform-locks \
  --key '{"LockID":{"S":"my-bucket/prod/terraform.tfstate"}}'

재발 방지: 백엔드 설정과 CI 구성

lock 자체는 문제가 아니라 안전장치입니다. 남는 lock을 줄이려면 백엔드에 lock 테이블을 명시하고, CI에서는 같은 state에 대해 job이 직렬로만 실행되도록 제한합니다.

terraform {
  backend "s3" {
    bucket         = "my-bucket"
    key            = "prod/terraform.tfstate"
    region         = "ap-northeast-2"
    dynamodb_table = "terraform-locks"
    encrypt        = true
  }
}
# GitHub Actions: 같은 state에 대한 apply를 직렬화
concurrency:
  group: terraform-prod
  cancel-in-progress: false

정리표

상황확인 방법조치
다른 실행이 진행 중Who/Created 확인, CI 로그-lock-timeout으로 대기
비정상 종료로 lock 잔존실행 주체가 종료됐는지 확인terraform force-unlock LOCK_ID
force-unlock 실패DynamoDB 항목 조회항목 확인 후 delete-item
반복 발생CI 동시 실행 여부concurrency 직렬화, timeout 설정

핵심은 lock을 풀기 전에 실행 중인 작업이 없는지 반드시 확인하는 것입니다. 확인 없이 force-unlock을 습관처럼 쓰면 두 실행이 동시에 state를 쓰게 되어 복구하기 어려운 손상이 생길 수 있습니다.

Go goroutine leak 완벽 해결 — 고루틴이 계속 늘어나는 원인과 pprof 진단법

🔍 검색 키워드: Go goroutine leak, 고루틴 누수 해결, Go pprof goroutine 분석, Go context cancel 누수, Go 메모리 계속 증가, goroutine 개수 증가 원인

증상: 서버 메모리와 goroutine 수가 계속 늘어난다

Go 서비스를 배포한 뒤 시간이 지날수록 메모리 사용량이 우상향하고, 재시작하면 잠시 정상으로 돌아오는 패턴이 나타납니다. 이때 가장 먼저 의심해야 할 것이 goroutine leak(고루틴 누수)입니다. pprof 엔드포인트로 확인하면 아래와 같이 고루틴 수가 비정상적으로 큽니다.

$ curl http://localhost:6060/debug/pprof/goroutine?debug=1
goroutine profile: total 10432
10380 @ 0x43a5f6 0x4066bc 0x4062f5 0x6f1a2b 0x46f8c1
#	0x6f1a2a	main.fetchAll.func1+0x4a	/app/main.go:42

정상 서비스라면 요청이 끝난 뒤 고루틴 수가 원래 수준으로 돌아와야 하는데, 요청 수에 비례해 계속 쌓인다면 어딘가에서 고루틴이 영원히 블로킹되어 있다는 뜻입니다.

원인: 아무도 받지 않는 채널, 끝나지 않는 대기

고루틴은 스스로 종료되지 않으면 GC가 회수하지 못합니다. 대표적인 누수 원인은 다음과 같습니다.

  • 수신자 없는 채널 전송: 언버퍼드 채널에 값을 보내는데 받는 쪽이 이미 타임아웃으로 빠져나간 경우
  • context 미전파: HTTP 요청이 취소되어도 하위 고루틴에 ctx를 넘기지 않아 계속 실행되는 경우
  • time.Ticker 미정지: Stop()을 호출하지 않아 고루틴과 타이머가 남는 경우
  • 무한 for-select 루프: 종료 조건(done 채널, ctx.Done())이 없는 워커

아래는 가장 흔한 누수 코드입니다. 타임아웃이 발생하면 fetch 고루틴은 ch에 값을 보내려고 영원히 대기합니다.

func fetchWithTimeout(url string) (string, error) {
    ch := make(chan string) // 언버퍼드 채널
    go func() {
        ch <- slowRequest(url) // 받는 쪽이 없으면 영원히 블로킹
    }()
    select {
    case res := <-ch:
        return res, nil
    case <-time.After(1 * time.Second):
        return "", errors.New("timeout") // 여기서 반환하면 위 고루틴은 누수
    }
}

해결방법 1: 버퍼 채널로 전송 블로킹 제거

채널 버퍼를 1로 두면 받는 쪽이 사라져도 고루틴은 값을 넣고 정상 종료됩니다. 결과가 하나뿐인 경우 가장 간단한 해결책입니다.

ch := make(chan string, 1) // 버퍼 1: 수신자가 없어도 전송이 완료됨

해결방법 2: context로 취소 전파

요청 단위 작업은 반드시 context를 받아 하위 호출까지 전달하고, 블로킹 지점마다 ctx.Done()을 함께 select 합니다.

func fetchWithTimeout(ctx context.Context, url string) (string, error) {
    ctx, cancel := context.WithTimeout(ctx, time.Second)
    defer cancel() // 반드시 호출해서 타이머 리소스 해제
    ch := make(chan string, 1)
    go func() { ch <- slowRequestCtx(ctx, url) }()
    select {
    case res := <-ch:
        return res, nil
    case <-ctx.Done():
        return "", ctx.Err()
    }
}

해결방법 3: 워커 종료 조건과 Ticker 정리

func worker(ctx context.Context) {
    t := time.NewTicker(time.Minute)
    defer t.Stop() // Ticker는 반드시 Stop
    for {
        select {
        case <-t.C:
            doWork()
        case <-ctx.Done():
            return // 종료 조건 필수
        }
    }
}

누수 진단 방법: pprof와 테스트

운영 중에는 net/http/pprof를 열어 goroutine 프로파일을 두 번 떠서 비교하면 어느 코드 위치에서 고루틴이 쌓이는지 바로 보입니다. 개발 단계에서는 go.uber.org/goleak 라이브러리로 테스트 종료 시점에 남은 고루틴을 자동 검출할 수 있습니다.

go tool pprof http://localhost:6060/debug/pprof/goroutine
(pprof) top
(pprof) list main.fetchAll

정리표

누수 원인증상해결
수신자 없는 채널 전송chan send 상태로 고루틴 누적버퍼 채널(1) 또는 select+ctx
context 미전파요청 취소 후에도 작업 지속ctx 전달, WithTimeout+defer cancel
Ticker 미정지타이머와 고루틴 잔존defer t.Stop()
종료 조건 없는 루프워커가 영구 실행ctx.Done() 케이스 추가

핵심은 모든 고루틴은 종료 경로를 가져야 한다는 원칙입니다. 고루틴을 시작하는 코드를 쓸 때 “이 고루틴은 언제 끝나는가?”를 먼저 답할 수 없다면 누수 후보입니다.