레이블이 python인 게시물을 표시합니다. 모든 게시물 표시
레이블이 python인 게시물을 표시합니다. 모든 게시물 표시

수요일

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

요약: FastAPI의 async def 엔드포인트 안에서 time.sleep, requests, 동기 DB 드라이버 같은 동기 코드를 실행하면 이벤트 루프가 막혀 모든 요청이 순차 처리됩니다. async를 빼고 def로 선언하거나, httpx·asyncio.sleep 같은 비동기 라이브러리로 교체하거나, asyncio.to_thread로 블로킹 구간만 스레드로 넘기면 해결됩니다. CPU 바운드 작업은 프로세스로 분리하세요.

🔍 검색 키워드: 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로 선언하거나 스레드·프로세스로 넘겨 이벤트 루프를 항상 비워 두세요.

자주 묻는 질문(FAQ)

FastAPI에서 async def와 def 중 무엇을 써야 하나요?

함수 안에서 호출하는 라이브러리가 모두 await 가능한 비동기 라이브러리(httpx, asyncpg 등)라면 async def를, requests나 동기 DB 드라이버처럼 동기 라이브러리를 쓴다면 def를 사용하세요. def 엔드포인트는 FastAPI가 스레드풀에서 실행하므로 이벤트 루프를 막지 않습니다.

async def 안에서 동기 함수를 호출하면 왜 전체 API가 느려지나요?

async def는 이벤트 루프 스레드 하나에서 실행되기 때문입니다. await 없이 오래 걸리는 동기 코드가 실행되는 동안 루프가 멈춰, 같은 프로세스의 다른 모든 요청도 대기합니다.

CPU를 많이 쓰는 작업은 to_thread로 해결되나요?

대부분 해결되지 않습니다. 파이썬의 GIL 때문에 스레드는 CPU 연산을 병렬로 가속하지 못합니다. ProcessPoolExecutor, Celery 같은 별도 워커, 또는 uvicorn --workers로 프로세스를 늘리는 방식이 필요합니다.

이벤트 루프를 막는 코드는 어떻게 찾나요?

PYTHONASYNCIODEBUG=1 환경변수로 서버를 실행하면 일정 시간 이상 루프를 점유한 코루틴이 경고로 출력됩니다. 출력된 Task 위치에서 동기 I/O나 긴 연산을 찾으면 됩니다.

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

참고 문서: FastAPI Concurrency and async / await

최종 업데이트: 2026-10-01 · 작성: 내멋스레 사는 이야기

금요일

.env 파일인데 환경변수가 undefined — dotenv 로딩 에러 완전 해결

🔍 검색 키워드: process.env undefined, dotenv not working, .env 파일 적용 안됨, python-dotenv not loading, 환경변수 undefined, docker 환경변수 안됨, next.js env not working

상황

.env 파일을 분명히 만들었는데 코드에서 undefined가 찍힌다.

# .env
DATABASE_URL=postgresql://localhost/mydb
SECRET_KEY=mysecretkey
console.log(process.env.DATABASE_URL); // undefined ???

또는 Python에서:

import os
print(os.getenv("SECRET_KEY"))  # None ???

배포 환경에서만 안 된다거나, Docker 안에서만 안 된다거나, 로컬은 되는데 서버에선 안 된다거나. 패턴은 다양하지만 원인은 몇 가지로 좁혀진다.


Node.js — dotenv 관련

원인 1: dotenv를 아예 안 불렀거나 너무 늦게 불렀다

// 잘못된 예 — DB 모듈보다 나중에 dotenv 로드
const db = require('./database'); // 이미 process.env 읽음
require('dotenv').config();       // 너무 늦었다
// 올바른 예 — 진입점 파일 최상단에서 먼저
require('dotenv').config();

const db = require('./database');
const app = require('./app');

TypeScript / ES Modules:

import 'dotenv/config'; // 이 방법이 제일 깔끔하다

// 또는
import dotenv from 'dotenv';
dotenv.config();

import { createConnection } from './db';

원인 2: .env 파일 경로가 다르다

기본적으로 dotenv는 process.cwd() 기준으로 .env를 찾는다. 실행 위치가 프로젝트 루트가 아니면 못 찾는다.

import path from 'path';
import dotenv from 'dotenv';

dotenv.config({
  path: path.resolve(__dirname, '../.env'),
});

원인 3: .env 파일이 .gitignore에 있고 서버에 없다

로컬에서만 됐던 이유가 이거다. .env는 보통 .gitignore에 들어가 있어서 서버에 배포가 안 된다.

# GitHub Actions — 시크릿 주입
steps:
  - name: Create .env file
    run: |
      echo "DATABASE_URL=${{ secrets.DATABASE_URL }}" >> .env
      echo "SECRET_KEY=${{ secrets.SECRET_KEY }}" >> .env

원인 4: 변수명에 공백이나 따옴표 문제

# 잘못된 .env
DATABASE_URL = postgresql://localhost/mydb   # = 주변 공백 금지

# 올바른 .env
DATABASE_URL=postgresql://localhost/mydb
SECRET_KEY=mysecret
APP_NAME="My App"  # 값에 공백이 필요하면 따옴표 사용

Python — python-dotenv

from dotenv import load_dotenv
import os

# .env 파일 로드 (현재 디렉토리 기준)
load_dotenv()

# 또는 경로 명시
load_dotenv(dotenv_path='/path/to/.env')

# 이미 설정된 환경변수를 덮어쓰려면
load_dotenv(override=True)

print(os.getenv("DATABASE_URL"))
💡 load_dotenv()는 이미 시스템에 설정된 환경변수는 덮어쓰지 않는다. 테스트 환경에서 .env 값으로 강제하려면 override=True를 써야 한다.
# Django settings.py
import os
from pathlib import Path
from dotenv import load_dotenv

BASE_DIR = Path(__file__).resolve().parent.parent
load_dotenv(BASE_DIR / '.env')

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
        'NAME': os.getenv('DB_NAME'),
        'USER': os.getenv('DB_USER'),
        'PASSWORD': os.getenv('DB_PASSWORD'),
        'HOST': os.getenv('DB_HOST', 'localhost'),
        'PORT': os.getenv('DB_PORT', '5432'),
    }
}

Next.js / React — 프레임워크별 규칙

Next.js는 dotenv를 직접 쓰지 않고 자체 env 로딩 시스템을 쓴다.

  • 서버에서만 쓰는 변수: DATABASE_URL=value
  • 클라이언트(브라우저)에서도 쓰는 변수: NEXT_PUBLIC_API_URL=value (반드시 NEXT_PUBLIC_ 접두사)
// 서버 컴포넌트 / API Route에서만 접근 가능
process.env.DATABASE_URL // ✅

// 클라이언트에서 접근하려면 NEXT_PUBLIC_ 접두사 필수
process.env.NEXT_PUBLIC_API_URL // ✅
process.env.API_URL // 클라이언트에서는 undefined ❌

환경 파일 우선순위:

.env.local > .env.development.local > .env.development > .env
⚠️ NEXT_PUBLIC_ 변수는 빌드 시 정적으로 교체된다. 빌드 후 값을 바꿔도 소용없다. 반드시 빌드 전에 설정해야 한다.

Docker / Docker Compose

# docker-compose.yml
services:
  app:
    build: .
    # 방법 1: env_file 지정 (권장)
    env_file:
      - .env

    # 방법 2: 직접 명시
    environment:
      - DATABASE_URL=postgresql://db:5432/mydb
      - SECRET_KEY=${SECRET_KEY}  # 호스트 환경변수에서
# Docker run
docker run --env-file .env myapp
docker run -e DATABASE_URL=xxx -e SECRET_KEY=yyy myapp
⚠️ Dockerfile에서 COPY .env .를 하면 이미지에 시크릿이 박힌다. .dockerignore에 .env를 넣고, 런타임에 주입하는 방식을 써야 한다.
# .dockerignore
.env
.env.*

상황별 체크리스트

증상원인확인/해결
로컬만 됨, 서버 안 됨.env가 서버에 없음CI/CD 시크릿 주입 또는 서버에 .env 생성
dotenv.config() 했는데 undefinedimport 순서 문제진입점 최상단에서 dotenv 먼저 로드
특정 변수만 undefined변수명 오타, 공백.env 파일 문법 확인
Docker 안에서 undefinedenv_file 미설정env_file 또는 -e 옵션으로 주입
Next.js 클라이언트에서 undefinedNEXT_PUBLIC_ 접두사 누락접두사 추가 후 재빌드
Python에서 Noneload_dotenv() 미호출import 후 load_dotenv() 호출
기존 시스템 변수가 우선override 미설정load_dotenv(override=True)

디버깅 팁

// 로드된 환경변수 키 목록 확인
console.log('ENV keys:', Object.keys(process.env).filter(k => !k.startsWith('npm_')));

// .env 경로 확인
const path = require('path');
console.log('Looking for .env at:', path.resolve(process.cwd(), '.env'));
# .env 파일에서 읽은 값만 확인 (시스템 변수 제외)
from dotenv import dotenv_values
config = dotenv_values(".env")
print(config)

마무리

환경변수 에러는 대부분 세 가지 중 하나다: .env 파일이 없거나, dotenv 로드를 너무 늦게 했거나, 프레임워크별 규칙을 무시했거나.

서버에 배포했을 때 갑자기 안 된다면 .env가 서버에 실제로 존재하는지부터 확인하고, Docker면 --env-file이나 env_file로 주입됐는지 확인한다. Next.js면 클라이언트에서 쓰는 변수에 NEXT_PUBLIC_ 붙이고 재빌드. 이 세 가지가 90%다.

목요일

Python 메모리 누수 디버깅 — tracemalloc으로 증가 지점 찾고 순환 참조 걷어내기

🔍 검색 키워드: python 메모리 누수, tracemalloc 사용법, gc.collect 효과 없음, 순환 참조 메모리 누수, gc.get_referrers, 파이썬 RSS 계속 증가

요약: 파이썬에서 메모리가 계속 늘어나는 원인은 대부분 가비지 컬렉터의 실패가 아니라, 전역 캐시나 리스트처럼 아무도 놓아주지 않는 참조다. tracemalloc의 스냅샷 비교로 어느 파일 몇 번째 줄에서 할당이 늘어나는지 특정하고, 그 객체를 붙잡고 있는 범인은 gc.get_referrers()로 확인한 뒤 참조를 끊으면 해결된다.

파이썬에서 메모리 누수는 왜 생기나요?

파이썬은 참조 카운팅으로 객체를 즉시 해제하고, 참조 카운팅이 풀지 못하는 순환 참조는 세대별 가비지 컬렉터가 따로 회수한다. 그래서 참조가 하나라도 살아 있으면 그 객체는 애초에 회수 대상이 아니다. 흔히 말하는 파이썬의 메모리 누수는 대부분 이 경우이고, 코드상으로는 다음 세 가지 형태로 나타난다.

  • 모듈 전역 dict·list·set에 계속 쌓이는데 아무도 비우지 않는 경우(가장 흔하다)
  • 순환 참조가 있는데 GC를 꺼 두었거나 수집 주기 사이에 메모리가 치솟는 경우
  • C 확장 모듈이 직접 할당한 메모리(파이썬 추적 도구로는 보이지 않는다)

테스트 환경

아래 출력은 전부 이 환경에서 직접 실행한 결과다.

  • macOS 27.0.1 (Darwin 27.0.0, arm64)
  • python3.12 --version 출력: Python 3.12.13

재현 1. 전역 캐시가 메모리를 먹는 과정

import tracemalloc, gc

CACHE = {}          # 모듈 전역 캐시 - 아무도 비우지 않는다

def handle(i):
    CACHE[i] = {"payload": "x" * 1000, "idx": i}

tracemalloc.start()
for i in range(1000):
    handle(i)
snap1 = tracemalloc.take_snapshot()

for i in range(1000, 11000):
    handle(i)
snap2 = tracemalloc.take_snapshot()

for stat in snap2.compare_to(snap1, "lineno")[:3]:
    print(stat)
print("CACHE len:", len(CACHE))
print("gc.collect() ->", gc.collect())

직접 실행 결과

leak1.py:6: size=2543 KiB (+2337 KiB), count=21916 (+20000), average=119 B
leak1.py:13: size=312 KiB (+312 KiB), count=10000 (+10000), average=32 B
CACHE len: 11000
gc.collect() -> 0

tracemalloc 결과는 어떻게 읽나요?

각 줄은 파일:줄번호: size=현재 누적 크기 (증가분), count=블록 수 (증가분), average=블록당 평균 형식이다. 위 출력에서 6번째 줄, 즉 CACHE[i] = {...}가 두 스냅샷 사이에 2,337 KiB · 블록 20,000개를 더 썼다. 범인이 한 줄로 특정된 것이다. 13번째 줄은 그 줄을 호출한 반복문이다.

그리고 마지막 줄이 핵심이다. gc.collect()가 0을 반환했다. 회수할 것이 없다는 뜻이다. CACHE가 모든 객체의 참조를 쥐고 있으므로 가비지 컬렉터 입장에서는 전부 멀쩡히 살아 있는 객체다. 메모리가 느는데 gc.collect()를 아무리 불러도 소용없다면 십중팔구 이 상황이다.

호출 경로까지 보고 싶다면

어느 줄인지는 알겠는데 그 줄이 어디서 불렸는지 모를 때는 tracemalloc.start()에 프레임 수를 넘기고 statistics("traceback")을 쓴다.

tracemalloc.start(5)          # 콜 스택 5프레임까지 보관
...
top = snap.statistics("traceback")[0]
print("크기:", top.size // 1024, "KiB / 블록:", top.count)
for line in top.traceback.format():
    print(line)

직접 실행 결과

크기: 72 KiB / 블록: 1
  File "tb.py", line 7
    for i in range(2000): outer(i)
  File "tb.py", line 4
    def outer(i): inner(i)
  File "tb.py", line 3
    def inner(i): CACHE[i] = "x" * 1000

재현 2. RSS로 확인할 때 흔히 속는 지점

import resource, gc

def rss_mb():
    return resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024 / 1024

CACHE = []
print("start  RSS:", round(rss_mb(), 1), "MB")
for i in range(50_000):
    CACHE.append({"id": i, "payload": "x" * 500})
print("적재 후 RSS:", round(rss_mb(), 1), "MB  / 항목:", len(CACHE))
CACHE.clear()
gc.collect()
print("clear 후 RSS:", round(rss_mb(), 1), "MB")

직접 실행 결과

start  RSS: 13.2 MB
적재 후 RSS: 24.5 MB  / 항목: 50000
clear 후 RSS: 24.5 MB

5만 건을 담았더니 11MB가 늘었다. 그런데 CACHE.clear()와 gc.collect()를 했는데도 숫자가 24.5MB 그대로다. 메모리가 안 풀린 것처럼 보이지만 사실이 아니다. ru_maxrss는 지금 쓰는 메모리가 아니라 지금까지의 최대치라서 원래 줄어들지 않는다. 해제 여부를 보려면 ru_maxrss가 아니라 tracemalloc의 현재 스냅샷이나 psutil의 현재 RSS를 봐야 한다.

단위도 함정이다. 같은 환경에서 ru_maxrss 원본 값은 13746176이었고, 바이트로 읽으면 13.1MB로 상식적인 값이 된다(킬로바이트로 읽으면 13GB가 되어 말이 안 된다). 파이썬 공식 문서는 이 필드의 단위를 명시하지 않고 각 OS의 getrusage(2) man 페이지를 보라고 안내하므로, 코드에 숫자를 하드코딩하기 전에 실행 환경에서 한 번 확인하는 편이 안전하다.

재현 3. 순환 참조는 정말 누수인가요?

자주 오해하는 부분이다. 실제로 확인해 보자.

import gc

class Node:
    def __init__(self, name):
        self.name = name
        self.peer = None
        self.blob = bytearray(100_000)

def make_cycle():
    a, b = Node("a"), Node("b")
    a.peer = b
    b.peer = a          # 순환 참조
    return

gc.collect()
gc.disable()            # GC를 끄고 참조 카운트만 남긴다
before = len(gc.get_objects())
for _ in range(500):
    make_cycle()
after = len(gc.get_objects())
print("gc.disable() 상태 - 추적 객체 증가:", after - before)

gc.enable()
print("gc.collect()가 회수한 객체 수:", gc.collect())
print("회수 후 추적 객체 증가:", len(gc.get_objects()) - before)

직접 실행 결과

gc.disable() 상태 - 추적 객체 증가: 1000
gc.collect()가 회수한 객체 수: 1000
회수 후 추적 객체 증가: -1

GC를 끈 동안에는 함수가 끝났는데도 객체 1,000개(500쌍)가 그대로 남았고, GC를 켜고 수집하자 1,000개가 전부 회수됐다. 즉 순환 참조 자체는 파이썬이 알아서 처리한다. 성능 때문에 gc.disable()을 해 둔 코드가 있다면 그쪽이 진짜 위험 지점이다. 참고로 공식 문서에 따르면 gc.collect()의 반환값은 "회수된 객체 수와 회수 불가능한 객체 수의 합"이다.

__del__이 있으면 영영 못 지운다던데 사실인가요?

지금은 사실이 아니다. 파이썬 3.4에서 PEP 442가 적용되면서 바뀌었다. 공식 gc 문서는 "Changed in version 3.4: Following PEP 442, objects with a __del__() method don't end up in gc.garbage anymore"라고 명시한다. 직접 확인해 보면 이렇다.

import gc
class WithDel:
    def __init__(self): self.peer = None
    def __del__(self): pass
a, b = WithDel(), WithDel()
a.peer = b; b.peer = a
del a, b
print("gc.collect() ->", gc.collect())
print("gc.garbage:", gc.garbage)

직접 실행 결과

gc.collect() -> 8
gc.garbage: []

회수됐고 gc.garbage는 비어 있다. 오래된 블로그 글을 보고 __del__을 지우러 다닐 필요는 없다.

참조를 붙잡고 있는 범인은 어떻게 찾나요?

tracemalloc이 "이 줄에서 객체가 늘어난다"까지 알려줬다면, 다음 질문은 "그래서 누가 안 놓아주고 있나"다. 여기에 gc.get_referrers()를 쓴다.

import gc
registry = {}
class Session: pass

s = Session()
registry["user-1"] = s
del s                     # 지역 변수는 지웠는데도

target = [o for o in gc.get_objects()
          if type(o).__name__ == "Session"][0]
for r in gc.get_referrers(target):
    print(type(r).__name__, "->", str(r)[:60])

직접 실행 결과

dict -> {'user-1': <__main__.Session object at 0x1097bac90>}
dict -> {'__name__': '__main__', '__doc__': None, '__package__': Non

첫 줄이 범인이다. registry 딕셔너리가 Session을 붙잡고 있다. 두 번째는 모듈 전역 네임스페이스라 항상 따라 나온다. 공식 문서는 get_referrers()에 대해 "디버깅 외의 목적으로는 사용하지 말라"고 경고하고, 정확한 결과를 위해 호출 전에 collect()를 먼저 부르라고 안내한다.

실무에서는 어떤 순서로 해결하나요?

  • 1단계. 앱 시작 지점에서 tracemalloc.start()를 호출하고, 운영 중 주기적으로 스냅샷을 떠 둔다(관리자 전용 엔드포인트나 시그널 핸들러에 붙이면 편하다).
  • 2단계. 메모리가 늘어난 구간의 두 스냅샷을 compare_to(old, "lineno")로 비교해 증가 상위 10줄을 본다.
  • 3단계. 줄이 특정되면 statistics("traceback")으로 호출 경로까지 확인한다.
  • 4단계. 그 줄이 만든 객체를 누가 붙잡고 있는지 gc.get_referrers()로 확인한다.
  • 5단계. 참조를 끊는다. 캐시라면 functools.lru_cache(maxsize=...)처럼 상한이 있는 자료구조로 바꾸고, 수명이 요청 단위인 객체는 전역이 아니라 요청 스코프에 두며, 레지스트리 성격이면 weakref.WeakValueDictionary를 검토한다.

상한을 두는 것만으로 충분한 경우가 많다. 아래는 maxsize를 준 캐시를 1만 번 호출한 결과다.

import functools
@functools.lru_cache(maxsize=128)
def f(i): return "x" * 1000
for i in range(10000): f(i)
print(f.cache_info())

직접 실행 결과

CacheInfo(hits=0, misses=10000, maxsize=128, currsize=128)

1만 번을 호출했지만 캐시에 남은 항목은 128개다. 상한 없는 dict였다면 1만 개가 그대로 쌓였을 것이다.

주의할 점과 한계

  • tracemalloc은 파이썬 할당기를 거친 메모리만 추적한다. C 확장 모듈이 직접 malloc한 영역은 잡히지 않으므로, tracemalloc 수치는 안 느는데 RSS만 느는 경우에는 C 확장을 의심해야 한다.
  • tracemalloc 자체가 메모리와 속도를 쓴다. 프레임 수를 크게 잡을수록 비용이 커지므로 운영 환경에서는 필요한 구간에만 켜는 편이 좋다.
  • RSS가 줄지 않는다고 해서 누수는 아니다. 파이썬이 객체를 해제해도 할당기가 OS에 메모리를 바로 돌려주지 않는 경우가 많다.
  • 이 글에서 직접 재현하지 않은 것: C 확장 모듈의 누수, 멀티프로세스·멀티스레드 환경에서의 누적. 이 경우는 위 도구만으로 부족하다.

정리표

증상확인 방법조치
메모리가 계속 느는데 gc.collect()가 0을 반환tracemalloc compare_to("lineno")참조를 쥔 전역 캐시·리스트를 비우거나 상한 적용
어느 줄인지는 알지만 호출 경로를 모름tracemalloc.start(5) + statistics("traceback")호출 경로상의 적절한 지점에서 참조 해제
누가 참조를 쥐고 있는지 모름gc.collect() 후 gc.get_referrers(obj)지목된 컨테이너에서 제거, weakref 검토
RSS가 줄지 않음ru_maxrss가 아니라 현재 RSS 확인ru_maxrss는 최대치라 원래 줄지 않음
순환 참조가 쌓임gc.isenabled() 확인gc.disable()을 걷어내거나 주기적 collect()
tracemalloc은 그대로인데 RSS만 증가사용 중인 C 확장 목록 점검확장 모듈 쪽 이슈 트래커 확인

자주 묻는 질문(FAQ)

gc.collect()를 주기적으로 호출하면 메모리 누수가 해결되나요?

대부분 해결되지 않는다. 직접 재현한 전역 캐시 예제에서 gc.collect()는 0을 반환했다. 캐시가 참조를 쥐고 있는 한 가비지 컬렉터가 회수할 대상 자체가 없기 때문이다. gc.collect()가 효과를 보는 경우는 순환 참조가 많은데 수집이 미뤄지고 있을 때로 한정된다.

tracemalloc은 운영 서버에 켜도 되나요?

켤 수 있지만 비용이 있다. tracemalloc은 모든 할당의 호출 프레임을 기록하므로 메모리와 CPU를 추가로 쓰고, start()에 넘기는 프레임 수가 클수록 비용이 커진다. 문제가 재현되는 시간대나 특정 워커에만 켜고, 스냅샷을 뜬 뒤 끄는 방식을 권한다.

순환 참조가 생기지 않게 코드를 짜야 하나요?

신경 쓸 필요는 거의 없다. 직접 확인한 결과 순환 참조로 만든 객체 1,000개는 gc.collect() 한 번에 전부 회수됐고, __del__이 있어도 파이썬 3.4부터는 gc.garbage에 남지 않는다. 정말 문제가 되는 상황은 성능 때문에 gc.disable()을 해 둔 경우다.

메모리가 늘어나는데 tracemalloc 수치는 그대로입니다. 왜 그런가요?

tracemalloc은 파이썬 할당기를 거친 메모리만 추적하기 때문이다. NumPy, 이미지 처리, DB 드라이버처럼 C로 작성된 확장 모듈이 자체적으로 할당한 메모리는 집계되지 않는다. 이 경우에는 해당 확장 모듈 쪽의 메모리 관련 옵션이나 알려진 이슈를 먼저 확인해야 한다.

관련 글: Python ModuleNotFoundError 해결 — pip install 했는데 왜 못 찾냐 · Python SSL 인증서 에러 완전 정복: CERTIFICATE_VERIFY_FAILED 원인과 해결법

공식 문서: Python — tracemalloc · Python — gc

최종 업데이트: 2026-10-13 · 작성: 내멋스레 사는 이야기

월요일

Python SSL 인증서 에러 완전 정복: CERTIFICATE_VERIFY_FAILED 원인과 해결법

🔍 검색 키워드: CERTIFICATE_VERIFY_FAILED · Python SSL 에러 · ssl.SSLCertVerificationError · Python requests SSL 오류 · macOS Python SSL · Python urllib SSL 인증서 오류

Python으로 외부 API 호출하거나 크롤링하다 보면 이 에러 한 번쯤은 만난다.

ssl.SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED]
certificate verify failed: unable to get local issuer certificate (_ssl.c:1129)

이거 처음 보면 당황스럽다. 에러 메시지만 봐서는 뭐가 문제인지 감도 안 온다. 원인부터 단계별 해결까지 정리한다.


왜 이 에러가 나는가

Python이 HTTPS 요청을 보낼 때 서버의 SSL 인증서를 검증한다. 이 과정에서 신뢰할 수 있는 루트 인증서(CA) 목록을 참조하는데, 여기에 서버 인증서 체인이 없으면 에러가 난다.

주요 원인은 크게 세 가지다.

  • Python 자체 CA 번들이 오래됨 — macOS에서 Python 3.6+ 공식 배포판은 시스템 인증서를 쓰지 않고 번들로 따로 관리하는데, 이게 업데이트 안 되면 발생한다.
  • 회사 네트워크의 프록시/방화벽 — 기업 환경에서 HTTPS 트래픽을 자체 인증서로 중간에서 가로채는(MITM) 경우, Python이 그 인증서를 모른다.
  • 자체 서명(self-signed) 인증서를 가진 서버 — 내부 개발 서버나 테스트 환경에서 자주 발생.

상황별 체크리스트

상황원인권장 해결책
macOS + Python 공식 설치번들 CA 미업데이트Install Certificates.command 실행
회사 내부 네트워크기업 프록시 인증서기업 CA 인증서 추가
내부 개발 서버self-signed 인증서verify=인증서경로 지정
Docker/CI 환경CA 번들 미포함certifi 패키지 + 환경변수 설정
갑자기 발생 (예전엔 됐는데)Python 또는 OS 업그레이드certifi 재설치

레벨별 해결 방법

초보자 — 일단 돌아가게 만들기 (비추천)

개발 중에 빠르게 확인만 할 때 쓰는 방법이다. 절대 프로덕션에 쓰면 안 된다.

import requests

# SSL 검증 비활성화 — 개발용으로만!
response = requests.get("https://example.com", verify=False)

중급자 — certifi로 CA 번들 업데이트

가장 권장하는 방법이다. certifi는 Mozilla가 관리하는 신뢰할 수 있는 CA 목록을 Python에서 쓸 수 있게 패키징한 라이브러리다.

pip install --upgrade certifi
import requests
import certifi

response = requests.get("https://example.com", verify=certifi.where())
print(response.status_code)

환경변수로 전역 적용 (추천)

# Linux/macOS
export SSL_CERT_FILE=$(python -c "import certifi; print(certifi.where())")
export REQUESTS_CA_BUNDLE=$(python -c "import certifi; print(certifi.where())")

macOS 전용 — Install Certificates.command 실행

# 해당 버전의 Install Certificates 스크립트 실행 (버전에 맞게 경로 수정)
open /Applications/Python\ 3.12/Install\ Certificates.command

고급자 — 기업 CA 인증서 추가

import requests

# 단일 파일
response = requests.get("https://internal.company.com", verify="/path/to/company-ca.crt")
import certifi

with open("/path/to/company-ca.crt", "r") as f:
    company_cert = f.read()

with open(certifi.where(), "a") as bundle:
    bundle.write("\n" + company_cert)

Docker/CI 환경

FROM python:3.12-slim

RUN pip install certifi

ENV SSL_CERT_FILE=/usr/local/lib/python3.12/site-packages/certifi/cacert.pem
ENV REQUESTS_CA_BUNDLE=/usr/local/lib/python3.12/site-packages/certifi/cacert.pem
# GitHub Actions
- name: Fix SSL certificates
  run: |
    pip install --upgrade certifi
    echo "SSL_CERT_FILE=$(python -c 'import certifi; print(certifi.where())')" >> $GITHUB_ENV
    echo "REQUESTS_CA_BUNDLE=$(python -c 'import certifi; print(certifi.where())')" >> $GITHUB_ENV

urllib / aiohttp 사용자 참고

import ssl, urllib.request, certifi

ctx = ssl.create_default_context(cafile=certifi.where())
req = urllib.request.Request("https://example.com")
with urllib.request.urlopen(req, context=ctx) as response:
    data = response.read()
import aiohttp, ssl, certifi

async def fetch(url):
    ssl_ctx = ssl.create_default_context(cafile=certifi.where())
    connector = aiohttp.TCPConnector(ssl=ssl_ctx)
    async with aiohttp.ClientSession(connector=connector) as session:
        async with session.get(url) as response:
            return await response.text()

절대 하면 안 되는 것

⚠️ 프로덕션에서 이런 코드 보이면 반드시 수정해야 한다.
# ❌ 절대 하지 말 것
import ssl
ssl._create_default_https_context = ssl._create_unverified_context

# ❌ 이것도 마찬가지
requests.get(url, verify=False)

# ❌ 환경변수로 전체 비활성화
# PYTHONHTTPSVERIFY=0

SSL 검증을 끄는 순간 중간자 공격(MITM)에 완전히 노출된다. 개발 편의를 위해 껐다가 프로덕션에 그대로 배포되는 사고가 실제로 발생한다.


정리

우선순위해결책상황
1순위Install Certificates.commandmacOS + 공식 Python 설치
2순위pip install --upgrade certifi + 환경변수대부분의 환경
3순위기업 CA 인증서 추가회사 내부 네트워크
4순위verify=False로컬 개발 일시적 확인 (프로덕션 절대 불가)
💡 실무에서 이 에러를 자주 만나는 패턴은 Python 버전 업그레이드 직후, 또는 새 팀원이 회사 환경 세팅할 때다. 팀 위키에 이 내용 정리해두면 반복 질문 많이 줄어든다.

금요일

파이썬 기본 문법 1

https://www.python.org/downloads/

INSTALL *. ADD PYTHON 버전명 TO PATH 체크(고급사용자라면 체크 안해도됨)

ide or idle 선택하여 시작

print(“6+4=”, sum(6,4))

6+4= 10 # 6+4 = 10

x,y=3,4

print(x,y)

3 4

print(y,x)

4 3

name=input()

투이아빠

print(name)

투이아빠

stud_num=input(“학번:”)

학번:20180604

name=input(“이름:”)

이름:투이아빠

print(stud_num, name)

20180604 투이아빠

x=int(input(“정수:”))

정수:100

y=int(input(“정수:”))

정수:200

print(x+y)

300

jetbrains사 pycharm 설치

jetbrains 유료 계정이 있음으로 사이트 내 pycharm을 설치부터 시작

https://www.jetbrains.com/pycharm/?fromMenu