⚙️Backend

트래픽도 적은데 CPU가 70%? 4-worker 환경에서 scheduler 중복 잡은 이야기

관리자 모니터링에서 CPU 70% 스파이크를 발견했다. 트래픽은 거의 없는데 왜? 원인은 gunicorn 4 워커 각각이 APScheduler 를 따로 돌리고 있던 것. PostgreSQL advisory lock 으로 단일 인스턴스화했다.

📅 2026년 5월 10일·📖 6분 읽기·👁 40

증상

관리자 페이지의 운영 모니터링 탭에서 CPU 사용률을 보다가 이상한 패턴을 발견했다. 사용자도 거의 없는 새벽 시간대에 CPU 가 70%+ 까지 튀고 있었다.

로그를 봤다.

00:01:23 [profile_analyzer] running for user_id=42
00:01:23 [profile_analyzer] running for user_id=42
00:01:23 [profile_analyzer] running for user_id=42
00:01:23 [profile_analyzer] running for user_id=42

같은 작업이 정확히 4번씩 찍혀있다. gunicorn 워커 4개가 각각 APScheduler 를 돌리고 있었다.

왜 이런 일이

FastAPI lifespan 에서 scheduler 를 시작하는 코드는 이렇게 생겼다.

@asynccontextmanager
async def lifespan(app: FastAPI):
    scheduler.add_job(profile_analysis_job, "cron", hour=15)
    scheduler.start()
    yield

gunicorn 이 워커를 4개 띄우면 lifespan 도 4번 실행된다. scheduler 가 4개 생긴다. 매일 KST 자정마다 같은 작업이 4번 돈다.

비용 계산: profile_analysis 한 번에 약 ₩120. 매일 4번 돌면 ₩480. 월 ₩14,400 누수.

해결책 후보

  1. 워커 수 1개로 줄이기 — 처리량 손해. 탈락
  2. 별도 worker 프로세스 분리 — systemd unit 추가. 운영 복잡도 증가
  3. Redis lock — Redis 의존성 추가. 인프라 부담
  4. PostgreSQL advisory lock — 이미 PG 쓰니 0 의존성. 채택

PostgreSQL advisory lock

PG 의 pg_try_advisory_lock(key) 는 어드바이저리(약속 기반) 락이다. 데이터에 영향 없이, 어떤 정수 키 하나에 대해 클러스터 전체에서 단 하나의 세션만 lock 을 잡을 수 있다. 세션이 끊기면 자동 해제.

SCHEDULER_LOCK_KEY = 0x52494F4C  # ASCII "RIOL"

@asynccontextmanager async def lifespan(app: FastAPI): pool = await Database.get_pool()

# 풀에서 connection 하나를 영구 점유 (놓으면 lock 도 풀림)
lock_conn = await pool.acquire()
got = await lock_conn.fetchval(
    "SELECT pg_try_advisory_lock($1)", SCHEDULER_LOCK_KEY
)

if got:
    scheduler.add_job(profile_analysis_job, "cron", hour=15)
    scheduler.start()
    logger.info(f"[Scheduler] this worker (pid={os.getpid()}) holds lock")
else:
    await pool.release(lock_conn)
    logger.info(f"[Scheduler] worker (pid={os.getpid()}) skipped — another holds lock")

yield

핵심 포인트

  • try_ 가 붙은 함수를 써야 한다. 일반 pg_advisory_lock 은 잡을 때까지 대기하므로 4 워커가 줄 서 있게 된다
  • 락을 잡은 connection 을 풀에 돌려주면 안 된다. 다른 쿼리에 재사용되면서 묵시적 commit 시점에 락이 풀릴 수 있다
  • 락 키는 32-bit signed int 또는 (int, int) 페어. 적당한 ASCII 값을 쓰면 디버깅할 때 알아보기 쉽다

검증

배포 후 PG 에 직접 들어가서 확인했다.

SELECT locktype, classid, objid, pid, mode, granted
FROM pg_locks
WHERE locktype = 'advisory';
 locktype | classid |  objid   |  pid  |     mode      | granted
----------+---------+----------+-------+---------------+---------
 advisory |       0 | 1380733260 | 12847 | ExclusiveLock | t
(1 row)

한 워커만 락을 잡았다. 다른 3 워커는 정상적으로 API 트래픽만 처리한다.

결과

지표이전이후
profile_analysis 실행 횟수/일4회1회
일일 LLM 비용₩480₩120
새벽 CPU 스파이크70%+20% 이하

월 ₩14,400 → ₩3,600. 75% 절감.

배운 것

  • gunicorn --preload 를 켜도 lifespan 은 워커마다 실행된다. lifespan 코드는 워커 수 만큼 곱해진다고 생각해야 한다
  • "한 번만 실행되어야 하는 코드" 가 lifespan 에 있다면 singleton 보장이 별도로 필요하다
  • PG advisory lock 은 0 비용 singleton 도구다. 이미 PG 를 쓰는 서비스라면 안 쓸 이유가 없다

📌 2026년의 코멘트

이 패턴은 scheduler 외에도 "워커 단일 캐시 워밍업", "한 워커가 슬랙 알림 보내기" 같은 곳에 응용 가능하다. lifespan 에 들어가는 모든 사이드 이펙트를 의심하는 습관이 들었다.

태그

#FastAPI#gunicorn#PostgreSQL#APScheduler#성능

📨 박주니에게 한마디

스팸·악성 메시지 방지를 위해 구글 로그인 후 메시지를 보낼 수 있어요. 비공개로 전달되며, 운영자 외에는 볼 수 없습니다.

Google 로그인 후 메시지 남기기