요약
- buffered cursor(기본)는
execute()시점에 전체 결과셋을 클라이언트 메모리로 한 번에 받고, SSCursor(server-side/unbuffered)는 결과를 서버에 둔 채 fetch마다 행을 흘려보낸다. - 트레이드오프는 "클라이언트 메모리·긴 단일 블로킹 ↔ 커넥션/서버 점유 시간"의 맞교환이다. 대용량 결과를 메모리에 올리지 못할 때 SSCursor를 쓴다.
본문
두 커서의 동작 차이
MySQLdb(mysqlclient)·PyMySQL의 기본 Cursor는 buffered(client-side)다. 내부적으로 mysql_store_result()를 호출해 execute() 순간 전체 결과셋을 서버→클라이언트로 전송받아 메모리에 보관한다. 이후 fetch*는 이미 받아둔 로컬 버퍼에서 꺼낼 뿐이다.
SSCursor/SSDictCursor는 server-side(unbuffered)다. mysql_use_result()를 써서 결과를 서버에 남겨두고, fetch 요청이 올 때마다 행을 전송한다. 클라이언트 메모리엔 항상 처리 중인 일부 행만 올라온다.
from MySQLdb.cursors import SSCursor
cur = conn.cursor() # buffered: execute에서 전부 적재 → OOM 위험
cur.execute("SELECT * FROM huge_table")
cur = conn.cursor(SSCursor) # server-side: 즉시 반환, 서버가 보관
cur.execute("SELECT * FROM huge_table")
for row in cur: # 한 행씩 받아옴 → 메모리 일정
process(row)
스트리밍의 의미
"행을 한 줄씩 따로 TCP로 보낸다"는 뜻은 아니다. 서버가 결과를 들고 있다가 클라이언트가 읽어가는 만큼만 네트워크 버퍼에서 소비하는 구조다. 비유하면 buffered는 파일을 통째로 read(), SSCursor는 줄 단위로 흘려 읽는 제너레이터다.
핵심 트레이드오프 — CPU를 덜 쓰는 게 아니다
- buffered:
execute()가 전체 전송이 끝날 때까지 길게 블로킹 → 대신 끝나면 커넥션을 바로 놓아준다. - SSCursor:
execute()는 즉시 반환, 각 fetch는 작은 단위라 긴 단일 블로킹이 없다 → 대신 끝까지 fetch할 때까지 커넥션을 점유한다.
총 CPU/네트워크 작업량은 SSCursor가 오히려 약간 더 많다(round trip이 많고 행당 오버헤드가 붙음). 이득은 양이 줄어서가 아니라 (1) 긴 블로킹이 짧은 fetch로 쪼개지고(chunked), (2) 클라이언트가 현재 행을 처리하는 동안 서버가 다음 행을 준비해 전송과 처리가 겹친다(pipelining). buffered는 "전송 전부 끝 → 그 다음 처리"로 직렬이다.
함정: 내 행 처리 로직이 느리면 커넥션을 점유한 채 서버가 결과셋/락/리소스를 그만큼 오래 들고 있게 된다(트랜잭션 안이면 그 트랜잭션이 길어짐). buffered는 빨리 다 빨아들이고 커넥션을 놓아주니 이 점에선 서버 친화적이다. 그래서 기본값이 buffered다.
드라이버별 차이 (주의 — mysqlclient ≠ PyMySQL)
| mysqlclient | PyMySQL | |
|---|---|---|
unbuffered rowcount |
-1(또는 부분값) |
uint64 sentinel(18446744073709551615) — 둘 다 "총 행 수를 미리 알 수 없다"가 핵심 |
| 결과 미소비 후 같은 커넥션에 다른 쿼리 | Commands out of sync 에러 |
자동으로 남은 행을 drain하여 에러 없이 진행 |
공통점은 결과셋을 끝까지 소비(또는 close)해야 한다는 제약이고, 위반 시 동작만 드라이버마다 다르다.
SQLAlchemy에서 켜기
execution_options(stream_results=True) 또는 yield_per를 쓰면 server-side 스트리밍을 사용한다. MySQL dialect는 내부적으로 MySQLdb.cursors.SSCursor/pymysql.cursors.SSCursor를 쓴다.
관련 노트
- Redis KEYS와 SCAN은 둘 다 keyspace bucket을 순회하며 차이는 한 호흡에 보느냐 커서로 잘라서 보느냐다
- N+1 쿼리 해결은 쿼리당 고정비용만 회수하고 행당 파싱 비용은 줄이지 않는다
- pymysql 파싱 CPU의 원인 쿼리는 performance_schema digest의 SUM_ROWS_SENT 랭킹으로 특정한다
- 커넥션 풀 대기는 프로세스당 실사용량으로 풀 고갈과 재연결 churn을 가른다
- 커서 페이지네이션은 offset의 중복·누락과 deep skip 비용을 정렬 키 seek로 해결한다
참고
- https://github.com/PyMySQL/mysqlclient/blob/main/src/MySQLdb/cursors.py
- https://github.com/PyMySQL/PyMySQL/blob/main/pymysql/cursors.py
- https://docs.sqlalchemy.org/en/20/core/connections.html#using-server-side-cursors-a-k-a-stream-results
- https://docs.sqlalchemy.org/en/20/dialects/mysql.html#server-side-cursors