Python 동시성과 성능 — 컷오버 맥락 + 일반 학습
날짜: 2026-04-21
계기: SQLite "database is locked" → PostgreSQL 컷오버 작업
근거 자료: ~/workspace/pypy/interpark_camping/ 구조, 이번 세션 컷오버 진행
0. 이 문서의 구조
- 컷오버 맥락: 캠핑 모니터에서 실제로 부딪힌 병목과 의사결정 (이번 세션에서 일어난 일)
- 일반 학습: CPU bound vs I/O bound, GIL, threading, asyncio, aiohttp, multiprocessing
- DB 측면: SQLite vs PG의 동시성 모델, INSERT 성능 패턴
- 의사결정 가이드: 부하 유형 → 도구 선택 표
기초 개념부터 쌓아 올리는 순서로 작성. 중간 용어를 먼저 던지지 않는다 (feedback_explanation_from_fundamentals.md 룰 준수).
1. 컷오버 맥락 — 캠핑 모니터에서 일어난 일
1-1. 시스템 구성 (병목을 이해하려면 먼저 그림)
[Telegram 사용자]
↑↓
[bot.py] ← long-running 프로세스, /usr/bin/python3 (이전) → anaconda3 (지금)
↓ (read/write)
[state.db (SQLite)] ← 단일 파일 DB, WAL 모드
↑ (write)
[monitor.py] ← systemd timer가 매 10초 호출, single-tick 스타일 (1회 실행 후 종료)
↑ (read/write)
[web/app.py (FastAPI/uvicorn)] ← anaconda3, 항시 가동
3개의 프로세스가 같은 DB 파일에 동시에 쓰기 시도. 이게 병목의 출발점.
1-2. "database is locked"의 정체
SQLite는 파일 한 개를 DB로 본다. 동시 쓰기를 막으려고 락(file lock)을 잡는다.
- DELETE 모드(기본): 쓰기 시작 → 전체 파일 락 → 다른 프로세스는 읽기도 못 함.
- WAL(Write-Ahead Log) 모드: 읽기는 락 안 잡음, 쓰기는 한 번에 1명. 대신 변경 내역을
.wal파일에 먼저 적고 나중에 main 파일에 머지. → state.db-wal, state.db-shm 파일이 생기는 이유.
WAL 모드에서도 동시 쓰기 1개 한계는 그대로다. 두 프로세스가 동시에 INSERT를 시도하면 한쪽은 BUSY. SQLite는 busy_timeout을 걸어 일정 시간 재시도하지만, 시간 초과 시 "database is locked" 예외.
이번 시스템은 monitor가 매 10초 깨어나 SELECT/INSERT를 하고, 동시에 web에서도 사용자가 구독 수정 시 INSERT/UPDATE를 한다. 충돌 빈도는 낮지만 0이 아니다 → 가끔 로그인 실패 등 발생.
1-3. 왜 PostgreSQL은 풀어주나
PG는 MVCC(Multi-Version Concurrency Control)를 쓴다. 핵심:
- 모든 row는 "version"을 가진다. 누군가 UPDATE 하면 새 version을 만들고, 기존 version은 다른 트랜잭션이 끝날 때까지 남아 있는다.
- 그래서 읽기는 쓰기를 막지 않고, 쓰기도 다른 row의 쓰기를 막지 않는다 (같은 row 충돌만 막힘).
- 동시 INSERT는 거의 무제한 (시퀀스 발급만 짧게 직렬화).
또한 PG는 client-server 모델 — 데이터 파일에 직접 락을 거는 대신 PG 서버가 모든 요청을 받아 큐잉/조정한다. 다중 라이터에 자연스럽게 강함.
1-4. 인터프리터 선택 — 왜 anaconda3인가
이번 컷오버에서 /usr/bin/python3 → /home/jai/anaconda3/bin/python3로 바꿨다. 이유:
- psycopg2 설치 위치 차이: anaconda3엔 이미 있고, system python엔 없으며 system python엔 pip도 없음(No module named pip). apt로 python3-pip + python3-psycopg2 설치도 가능하지만 sudo 필요 + system 패키지 충돌 위험.
- 일관성: web 서비스는 이미 anaconda3 사용 중이었음. 3개 서비스가 같은 인터프리터 쓰면 환경 가설을 디버깅에서 줄일 수 있다.
- PEP 668: Ubuntu 22.04+의 system python은 외부 pip 설치를 막는다("externally-managed-environment"). venv나 별도 인터프리터를 쓰는 게 정석.
이건 성능 이슈는 아니지만 환경 격리의 일종. 같은 코드라도 어느 인터프리터로 도는지에 따라 사용 가능한 라이브러리가 다르다.
1-5. monitor의 "single-tick" 모델
monitor.py는 systemd timer가 매 10초 실행, 한 번 돌고 종료한다. 즉 장기 실행 프로세스가 아니다.
장점:
- 메모리 누수, FD 누수 등을 주기적으로 자연 청소.
- 코드 변경 후 재시작 부담 없음 (다음 tick부터 새 코드).
단점:
- 프로세스 시작 비용이 매번 발생: Python 인터프리터 부팅, import, DB 연결 → 매 10초마다 약 2초 CPU. 이게 monitor의 큰 비용 항목.
- 인메모리 캐시 불가: 메모리 상태를 다음 tick으로 못 넘김 → 외부 디스크 캐시(cache.<id>.json 파일들)로 우회.
- 부팅 + 종료 사이의 짧은 기회에서만 외부 API 호출 가능 → 장기 비동기 작업과 안 맞음.
선택의 트레이드오프: 이 코드의 단순함과 안정성을 위해 시작 비용을 받아들였다. monitor가 30초~1분 단위로 돌게 되면 단발 모델 유지가 더 어려워질 수 있다 (데몬 모델 + 내부 스케줄러로 옮길 시점).
1-6. cache.*.json 파일
이번에 발견한 5개의 캐시 파일(cache.21001160.json 등)은 외부 API(인터파크 등) 응답을 디스크에 캐시한 것. I/O bound 절약 + 외부 호출 비용 절약. monitor가 single-tick이라 인메모리 캐시가 의미 없으니 디스크로 우회.
이것도 패턴이다: 장기 실행 프로세스가 아니면 캐시는 디스크/외부 저장소가 자연스럽다.
2. 일반: CPU bound vs I/O bound
2-1. 정의부터
코드가 시간을 어디에 쓰는가로 분류한다.
- CPU bound: 시간의 대부분을 계산에 쓴다. 예: 이미지 변환, 큰 행렬 곱, 암호화, 정규식 매칭, JSON 파싱(큰 거).
- I/O bound: 시간의 대부분을 외부에서 데이터 오기를 기다림. 예: HTTP 요청, DB 쿼리, 파일 읽기, sleep.
이 구분이 왜 중요? 동시성 도구 선택이 갈린다.
- I/O bound는 기다리는 시간이 길어 동시 실행이 효과적.
- CPU bound는 진짜로 일하는 거라 동시 실행해도 한 코어만 쓰면 의미 없음.
2-2. 측정 방법
추측하지 말고 측정한다.
import time
t0 = time.perf_counter()
do_something()
print(time.perf_counter() - t0)
또는 time 명령어:
time python script.py
# real 0m5.000s ← 벽시계 시간
# user 0m1.000s ← 실제 CPU 사용 시간
# sys 0m0.100s ← 커널에서 보낸 시간
real >> user 면 I/O bound, real ≈ user 면 CPU bound.
3. GIL — Python 멀티스레딩의 한계 이해
3-1. GIL이 뭔가
CPython 인터프리터는 한 번에 하나의 스레드만 Python 바이트코드를 실행한다. 이걸 강제하는 락이 GIL(Global Interpreter Lock).
왜 있는가? CPython의 메모리 관리(특히 reference count)가 thread-safe가 아니라서, 락 하나로 묶어두는 게 가장 단순했음. 역사적 결정.
3-2. 결과
- CPU bound 작업을 멀티스레드로 돌려도 1코어만 쓴다. 4코어 머신에서 4개 스레드로 큰 행렬 곱 돌려도 1배 빠르지도 않다 (오히려 락 경쟁으로 느려질 수 있음).
- I/O bound 작업은 다르다: I/O 대기 중에는 GIL을 풀어준다. 그래서 멀티스레드로 동시에 100개 HTTP 요청은 실제로 동시에 실행된다 (각 스레드가 대기 → GIL 다른 스레드에 양보).
3-3. GIL의 미래
Python 3.13에서 no-GIL 빌드(PEP 703) 실험 시작. 한동안은 GIL 있다고 가정하고 코드 짜는 게 안전. 어차피 노-GIL이 와도 thread-safe 코드를 새로 짜야 한다.
4. 멀티스레딩 (threading)
4-1. 언제 쓰나
I/O bound 동시 처리. 예:
- 동시에 여러 URL fetch
- 여러 파일 동시 읽기/쓰기
- 여러 DB 쿼리 병렬
4-2. 기본 사용
from concurrent.futures import ThreadPoolExecutor
import requests
urls = [...]
with ThreadPoolExecutor(max_workers=10) as ex:
results = list(ex.map(requests.get, urls))
max_workers는 보통 코어 수가 아닌 동시 I/O 슬롯 수로 정한다. HTTP면 10~50 정도가 흔함. 너무 크면 OS의 파일 디스크립터/네트워크 자원이 바닥남.
4-3. 주의
- 공유 자료구조 접근 시 락 필요(
threading.Lock). list/dict의 일부 연산은 atomic이지만 모두는 아님. - 예외 처리는 future.result()를 호출할 때만 발생 — 무시하면 silent failure.
5. 코루틴과 asyncio
5-1. 코루틴이 뭔가
함수가 중간에 멈췄다가 다시 이어 실행될 수 있는 기능. 멈추는 지점이 await. Python에선 async def로 정의.
async def fetch(url):
response = await http_get(url) # 이 시점에 다른 task로 양보
return response.text
5-2. 이벤트 루프
asyncio의 핵심은 단일 스레드에서 도는 이벤트 루프. 루프가 task 목록을 들고:
1. task A 실행 → await에서 멈춤 (예: 네트워크 응답 대기)
2. task B 실행 → 같은 식
3. ...
4. A의 응답이 도착했다는 신호 (epoll 등) → A 재개
5. ...
스레드는 1개지만 동시 task는 수천 개 가능. 컨텍스트 전환이 OS 스레드 전환보다 100~1000배 가벼움.
5-3. 멀티스레드와 비교
| 측면 | 멀티스레드 | asyncio |
|---|---|---|
| 동시성 단위 | OS 스레드 | 코루틴 (Python 객체) |
| 컨텍스트 전환 비용 | 마이크로초 | 나노초 |
| 동시 100개 | 메모리 100MB+ (스레드당 1MB+) | 거의 무료 |
| 동시 10000개 | 어려움 (스레드 한계) | 가능 |
| GIL 영향 | I/O 대기 시 자동 양보 | 처음부터 1스레드라 무관 |
| 코드 침투성 | 작음 (threading.Thread만) | 크다 (모든 함수가 async로 물듦) |
asyncio의 함정: 한 군데서 동기 코드가 들어가면 전체 이벤트 루프가 멈춘다. 예를 들어 asyncio 안에서 requests.get(...)(동기)를 부르면 다른 모든 task 정지. 그래서 비동기 라이브러리(aiohttp 등)와 짝을 맞춰야 한다.
6. aiohttp — async HTTP 클라이언트
6-1. requests vs aiohttp
# 동기 (requests) — 순차
import requests
for url in urls:
requests.get(url) # 1초 × N
# 비동기 (aiohttp) — 동시
import aiohttp, asyncio
async def main():
async with aiohttp.ClientSession() as session:
results = await asyncio.gather(*[session.get(u) for u in urls])
100개 URL을 fetch한다고 치면:
- 순차: 100 × 평균응답시간
- 비동기: 거의 1 × 평균응답시간 (전부 동시 출발)
6-2. 적용 시점
- 캠핑 모니터에서 한 tick에 여러 공연/캠핑장 잔여석 조회 → 비동기로 묶으면 tick 시간 대폭 단축.
- 단, monitor의 single-tick 모델 + Python 부팅 비용 2초가 더 큰 비용일 수 있음. 측정 후 판단.
6-3. 동시 요청 제한
상대 서버에 부담 주거나 차단 당할 수 있어 보통 asyncio.Semaphore로 제한:
sem = asyncio.Semaphore(10)
async def fetch(url):
async with sem:
return await session.get(url)
7. 멀티프로세싱 — CPU bound용
GIL 우회의 정석은 다른 프로세스를 띄우는 것. 각 프로세스는 자기 GIL을 가짐.
from concurrent.futures import ProcessPoolExecutor
with ProcessPoolExecutor(max_workers=4) as ex:
results = list(ex.map(heavy_compute, data_chunks))
비용:
- 프로세스 시작 비용 (수십~수백 ms)
- 데이터 직렬화(pickle) — 큰 객체 주고받으면 느림
- 메모리 분리 — 같은 큰 데이터를 4번 들고 있게 됨
대안:
- C 확장 (numpy, pandas) — 내부에서 GIL 풀고 native 코드로 동시 실행
- Cython, Rust(pyo3), C++(pybind11) — hot path만 native로 빼기
- ML이면 GPU/TPU로 위임
7-1. 캠핑 모니터 맥락
CPU bound 부분이 거의 없다 (외부 API + DB가 대부분 시간). 그래서 멀티프로세싱이 의미 없음. 만약 큰 JSON 파싱이나 PDF 변환 같은 게 들어오면 검토 대상.
8. DB INSERT 성능
이번 컷오버에서 60,385건을 1.50초에 PG로 옮겼다(migrate_to_pg.py). 이 정도가 되는 이유와 더 빠르게 만드는 방법.
8-1. 단건 INSERT vs executemany vs COPY
# (가장 느림) 단건 N회 — 매번 네트워크 왕복 + 파싱
for row in rows:
cur.execute("INSERT ... VALUES (%s, %s, ...)", row)
# (중간) executemany — 클라이언트가 한 번에 묶어 전송
cur.executemany("INSERT ... VALUES (%s, %s, ...)", rows)
# (가장 빠름, PG 전용) COPY — 바이너리/텍스트 스트림으로 적재
cur.copy_expert("COPY tab FROM STDIN WITH CSV", io.StringIO(csv_data))
대충 단건 1배 → executemany 5~20배 → COPY 30~100배.
이번 마이그레이션 스크립트는 executemany 방식 (소스 보면 psycopg2.extras.execute_values 또는 비슷). 60k건 1.5초면 충분히 빠름. 600k건 정도부터 COPY 검토.
8-2. 트랜잭션 묶기
각 INSERT를 commit하면 매번 디스크 sync(WAL flush). 트랜잭션을 묶어 한 번에 commit하면 디스크 I/O가 줄어든다. 마이그레이션 스크립트가 한 트랜잭션으로 묶어두면 자동으로 적용됨.
8-3. 인덱스 영향
INSERT 시 모든 인덱스를 갱신해야 한다. 인덱스 N개면 INSERT 비용 ≈ N+1배.
- 대량 적재 시: 인덱스 DROP → COPY → CREATE INDEX (마지막에 한 번에 만드는 게 합산해서 빠름)
- 스키마 설계 시: 꼭 필요한 인덱스만
8-4. PG 특수: session_replication_role = 'replica'
마이그레이션 스크립트가 쓴 트릭. FK 제약과 트리거를 임시로 비활성화해 INSERT 순서/검증 비용을 낮춘다. 데이터가 정합성을 외부에서 보장하는 경우(예: 다른 DB에서 그대로 가져오는 마이그레이션)에만 사용. 일반 운영에선 위험.
8-5. SQLite도 같은 원칙
BEGIN; ... COMMIT;로 트랜잭션 묶기executemany사용- WAL 모드 +
synchronous=NORMAL로 fsync 비용 ↓ (장애 시 위험 약간 ↑) - 인덱스는 적재 후 만들기
9. 의사결정 가이드
9-1. "느려요"의 첫 분류
1. 어느 부분이 느린가? → 측정 (cProfile, time, py-spy)
2. CPU bound인가 I/O bound인가? → user time vs real time
3. 그 다음 도구 선택
9-2. 도구 매트릭스
| 부하 유형 | 동시성 N | 추천 도구 | 비고 |
|---|---|---|---|
| I/O bound | 작음 (~10) | threading (ThreadPoolExecutor) |
코드 변경 적음 |
| I/O bound | 중간 (~100) | asyncio + 비동기 라이브러리 |
코드 큰 폭 변경 |
| I/O bound | 큼 (1000+) | asyncio 필수 |
스레드 한계 |
| CPU bound | 작음 (코어 수 정도) | multiprocessing |
또는 numpy/native |
| CPU bound | 큼 | 분산 (Dask, Ray, Spark) 또는 GPU | 단일 머신 한계 |
| 혼합 | - | asyncio + ProcessPoolExecutor 조합 | CPU 무거운 코루틴은 별도 프로세스로 위임 |
| DB 대량 INSERT | - | executemany 우선, 더 필요하면 COPY | 트랜잭션 묶기 + 인덱스 검토 필수 |
9-3. 캠핑 모니터에 적용한다면
- 현재 monitor의 가장 큰 비용 = Python 부팅 2초/회 → tick 주기를 늘리는 게 가장 큰 절감 (10초 → 30초)
- 외부 API 동시 조회 → aiohttp로 묶기 (효과 있음, 단 부팅 비용 비중이 크면 미미)
- DB 쪽 → PG로 갔으니 동시 라이터 병목은 해소됨. 추가 최적화 여유 큼
- bot.py는 long-running이라 asyncio 도입 가능. 텔레그램 봇 라이브러리도 보통 asyncio 지원.
10. 더 공부할 만한 주제
- PEP 703 (no-GIL CPython): GIL 제거가 라이브러리 호환성에 미치는 영향
- uvloop: asyncio 이벤트 루프를 C로 재구현 → 2~4배 빠름
- asyncpg: PG의 비동기 드라이버. psycopg2 대비 10배+ throughput
- PG 튜닝:
shared_buffers,effective_cache_size,work_mem,max_connections,pgbouncer - structured concurrency:
asyncio.TaskGroup(Python 3.11+), trio 라이브러리 - 저수준 I/O: epoll, io_uring, Reactor vs Proactor 패턴
- profiling 도구: py-spy(샘플링, 운영 환경 안전), scalene(메모리+CPU+GPU)
11. 다음 작업에 적용할 한 가지
캠핑 모니터의 monitor.py 한 tick을 측정해서 부팅 비용 vs 실제 작업 비용을 분리해보면 어디에 손대야 할지 답이 나온다. 측정 명령:
time /home/jai/anaconda3/bin/python3 -c "import time; t=time.perf_counter(); import monitor; print('import:', time.perf_counter()-t)"
time /home/jai/anaconda3/bin/python3 monitor.py
이 두 숫자의 비율이 모니터의 시간 분포를 그대로 보여준다.
'공부' 카테고리의 다른 글
| 매시간 윤리 학습 자료가 텔레그램으로 오는 봇 만들기 — 그리고 같은 봇 토큰으로 polling 두 번 시작하면 안 되는 이유 (0) | 2026.04.28 |
|---|---|
| 로컬 LLM 에 도구 축소 + 시스템 프롬프트 주입 — 작은 모델이 검색 루프에 빠지지 않게 (0) | 2026.04.28 |
| Claude Code 의 구조 — 디렉토리 컨벤션과 검색 우선순위로 이해하기 (5) | 2026.04.28 |
| 에이전트 설계 + LangChain·LangGraph 비교 — Claude Code 가 같은 문제를 어떻게 푸는가 (0) | 2026.04.28 |
| AI 사용 평가 — 캠핑 모니터 PostgreSQL 컷오버와 멀티에이전트 거버넌스 회고 (0) | 2026.04.28 |