로그나 CSV가 커지면 잘 돌던 스크립트가 어느 날 MemoryError를 내거나, 작업 관리자에서 파이썬 하나가 몇 GB를 잡고 PC 전체가 느려집니다. 원인은 대부분 코드 한 줄, 파일을 “한 번에” 읽는 습관입니다. 이 글에서는 파이썬 메모리 사용량 줄이기를 주제로, 같은 512MB CSV(1,500만 행)를 일곱 가지 방식으로 읽어 같은 집계를 내고 최대 메모리를 직접 재 봤습니다. 이 노트북(윈도우 11, Python 3.12.10)에서 f.read()로 읽으면 최대 2,667MB, for line in f로 읽으면 15MB였습니다. 결과 숫자는 두 방식 모두 똑같았습니다.
시험 조건: 512MB 판매 기록 CSV
실제 업무 파일 대신 같은 모양의 판매 기록을 만들어 썼습니다. 한 줄은 2026-10-01,광주,모니터,2,289000처럼 날짜·지역·상품·수량·단가이고, 할 일은 “지역별 매출(수량×단가) 합계”입니다. 아래 스크립트로 1,500만 행을 만드는 데 25.2초, 파일 크기는 512.1MB였습니다.
"""테스트용 판매 기록 CSV 만들기: python make_data.py 행수"""
import random
import sys
import time
from pathlib import Path
rows = int(sys.argv[1]) if len(sys.argv) > 1 else 15_000_000
regions = ["서울", "부산", "대구", "인천", "광주", "대전", "울산", "세종"]
products = [("키보드", 45000), ("마우스", 23000), ("모니터", 289000), ("USB 허브", 19000), ("웹캠", 67000)]
random.seed(42)
start = time.perf_counter()
out = Path(__file__).with_name("sales.csv")
with open(out, "w", encoding="utf-8", newline="") as f:
f.write("date,region,product,qty,price\n")
buf = []
for i in range(rows):
name, price = random.choice(products)
day = 1 + i * 31 // rows
buf.append(f"2026-10-{day:02d},{random.choice(regions)},{name},{random.randint(1, 5)},{price}\n")
if len(buf) == 100_000:
f.write("".join(buf))
buf.clear()
f.write("".join(buf))
print(f"{rows:,}행 생성, {time.perf_counter() - start:.1f}초")
메모리는 윈도우가 프로세스마다 기록하는 “최대 작업 집합(Peak Working Set)”, 즉 그 프로세스가 실제로 가장 많이 쓴 메모리를 읽어 왔습니다. 방식마다 파이썬을 따로 띄워 서로 영향을 주지 않게 했고, 아무것도 안 한 파이썬은 13MB였습니다. 이 측정 함수는 윈도우 전용입니다.
"""윈도우에서 현재 프로세스의 최대 메모리(Peak Working Set)를 MB로 돌려줍니다."""
import ctypes
from ctypes import wintypes
class PMC(ctypes.Structure):
_fields_ = [('cb', wintypes.DWORD), ('PageFaultCount', wintypes.DWORD),
('PeakWorkingSetSize', ctypes.c_size_t), ('WorkingSetSize', ctypes.c_size_t),
('QuotaPeakPagedPoolUsage', ctypes.c_size_t), ('QuotaPagedPoolUsage', ctypes.c_size_t),
('QuotaPeakNonPagedPoolUsage', ctypes.c_size_t), ('QuotaNonPagedPoolUsage', ctypes.c_size_t),
('PagefileUsage', ctypes.c_size_t), ('PeakPagefileUsage', ctypes.c_size_t)]
def peak_mb():
pmc = PMC()
pmc.cb = ctypes.sizeof(PMC)
k32 = ctypes.WinDLL('kernel32')
k32.GetCurrentProcess.restype = wintypes.HANDLE
gpmi = ctypes.WinDLL('psapi').GetProcessMemoryInfo
gpmi.argtypes = [wintypes.HANDLE, ctypes.POINTER(PMC), wintypes.DWORD]
gpmi(k32.GetCurrentProcess(), ctypes.byref(pmc), pmc.cb)
return pmc.PeakWorkingSetSize / 1024 / 1024
방식별 코드: 같은 집계, 읽는 법만 다르게
일곱 가지 읽는 방식을 한 파일에 함수로 넣고, python methods.py 방식이름으로 하나씩 실행했습니다.
"""같은 집계(지역별 매출 합계)를 읽는 방식만 바꿔 실행: python methods.py 방식이름 [파일]"""
import sys
import time
from collections import defaultdict
from pathlib import Path
from memstat import peak_mb
HERE = Path(__file__).parent
def add(totals, line):
_, region, _, qty, price = line.rstrip("\n").split(",")
totals[region] += int(qty) * int(price)
def m_read(path):
totals = defaultdict(int)
with open(path, encoding="utf-8") as f:
text = f.read() # 파일 전체를 문자열 하나로
for line in text.splitlines()[1:]: # 줄 목록을 또 하나 만듦
add(totals, line)
return totals
def m_readlines(path):
totals = defaultdict(int)
with open(path, encoding="utf-8") as f:
lines = f.readlines() # 줄 목록 전체를 메모리에
for line in lines[1:]:
add(totals, line)
return totals
def m_iter(path):
totals = defaultdict(int)
with open(path, encoding="utf-8") as f:
next(f) # 머리글 건너뛰기
for line in f: # 한 줄씩만 메모리에
add(totals, line)
return totals
def m_iter_collect(path):
rows = []
with open(path, encoding="utf-8") as f:
next(f)
for line in f: # 한 줄씩 읽지만
rows.append(line.rstrip("\n").split(",")) # 전부 리스트에 모음
totals = defaultdict(int)
for _, region, _, qty, price in rows:
totals[region] += int(qty) * int(price)
return totals
def m_csv(path):
import csv
totals = defaultdict(int)
with open(path, encoding="utf-8", newline="") as f:
reader = csv.reader(f)
next(reader)
for _, region, _, qty, price in reader:
totals[region] += int(qty) * int(price)
return totals
def m_pandas(path):
import pandas as pd
df = pd.read_csv(path)
df["amount"] = df["qty"] * df["price"]
return df.groupby("region")["amount"].sum().to_dict()
def m_pandas_chunk(path):
import pandas as pd
totals = defaultdict(int)
for chunk in pd.read_csv(path, chunksize=500_000, usecols=["region", "qty", "price"]):
amount = chunk["qty"] * chunk["price"]
for region, value in amount.groupby(chunk["region"]).sum().items():
totals[region] += int(value)
return totals
METHODS = {name[2:]: fn for name, fn in globals().items() if name.startswith("m_")}
if __name__ == "__main__":
sys.stdout.reconfigure(encoding="utf-8")
name = sys.argv[1]
path = sys.argv[2] if len(sys.argv) > 2 else HERE / "sales.csv"
start = time.perf_counter()
totals = METHODS[name](path)
elapsed = time.perf_counter() - start
total = sum(int(v) for v in totals.values())
print(f"{name:13} 시간 {elapsed:6.1f}초 최대 메모리 {peak_mb():8,.0f} MB 매출 합계 {total:,} 서울 {int(totals['서울']):,}")
$ python methods.py read
read 시간 13.2초 최대 메모리 2,667 MB 매출 합계 3,986,034,989,000 서울 498,489,763,000
$ python methods.py readlines
readlines 시간 12.7초 최대 메모리 1,906 MB 매출 합계 3,986,034,989,000 서울 498,489,763,000
$ python methods.py iter
iter 시간 11.2초 최대 메모리 15 MB 매출 합계 3,986,034,989,000 서울 498,489,763,000
$ python methods.py csv
csv 시간 13.3초 최대 메모리 15 MB 매출 합계 3,986,034,989,000 서울 498,489,763,000
f.read()는 파일 크기의 약 5.2배, readlines()는 약 3.7배를 썼습니다. 512MB 파일이 메모리에서 몇 배로 불어나는 이유는 파이썬 문자열 구조 때문입니다. 한글이 섞인 문자열은 글자당 2바이트로 저장되고, 줄 하나하나가 별도 객체라 객체마다 머리 부분이 붙습니다. read()는 여기에 파일 전체 문자열과 splitlines()가 만든 줄 목록을 동시에 들고 있어서 더 큽니다. 반면 for line in f와 csv.reader는 파일 크기와 관계없이 15MB였고, 시간도 11~13초로 통째로 읽기와 차이가 없었습니다.
함정 1: 한 줄씩 읽어도 리스트에 모으면 소용없습니다
“한 줄씩 읽으라”는 말만 따르고, 읽은 줄을 나중에 처리하려고 리스트에 모아 두는 코드도 흔합니다. 위 m_iter_collect가 그 형태입니다.
$ python methods.py iter_collect
iter_collect 시간 28.2초 최대 메모리 5,919 MB 매출 합계 3,986,034,989,000 서울 498,489,763,000
일곱 방식 중 가장 나빴습니다. 줄마다 split으로 만든 리스트와 문자열 다섯 개가 1,500만 번 쌓여서 파일 크기의 약 11.6배인 5.9GB가 됐고, 시간도 두 배 넘게 걸렸습니다. 다시 돌려도 5,920MB였습니다. 메모리를 아끼는 핵심은 “한 줄씩 읽기”가 아니라 “읽은 줄을 바로 처리하고 버리기”입니다. 합계·개수처럼 결과만 남기면 됩니다.
pandas를 쓴다면 chunksize
$ python methods.py pandas
pandas 시간 10.8초 최대 메모리 986 MB 매출 합계 3,986,034,989,000 서울 498,489,763,000
$ python methods.py pandas_chunk
pandas_chunk 시간 7.6초 최대 메모리 119 MB 매출 합계 3,986,034,989,000 서울 498,489,763,000
pandas로 전체를 읽으면 986MB였습니다. 숫자 열을 파이썬 객체가 아니라 배열로 저장해서 read()보다는 훨씬 작지만, 파일 크기의 두 배 가까이 됩니다. chunksize=500_000으로 50만 행씩 읽어 부분 합계를 더해 가면 119MB로 줄었고, 필요한 열만 usecols로 읽은 덕분인지 시간도 일곱 방식 중 가장 짧았습니다(1회차 7.6초, 2회차 10.1초).
처음 난 오류: 바이트 블록으로 끊어 읽다가 줄이 잘림
“정해진 크기만큼 끊어 읽으면 더 빠르지 않을까” 싶어 16MB씩 바이너리로 읽는 버전을 먼저 짰습니다.
"""정해진 크기(바이트)씩 끊어 읽기 — 첫 버전"""
import sys
import time
from collections import defaultdict
from pathlib import Path
from memstat import peak_mb
sys.stdout.reconfigure(encoding="utf-8")
path = Path(__file__).with_name("sales.csv")
CHUNK = 16 * 1024 * 1024 # 16MB
start = time.perf_counter()
totals = defaultdict(int)
with open(path, "rb") as f:
f.readline() # 머리글
while True:
block = f.read(CHUNK)
if not block:
break
for line in block.decode("utf-8").splitlines():
_, region, _, qty, price = line.split(",")
totals[region] += int(qty) * int(price)
print(f"시간 {time.perf_counter() - start:.1f}초 최대 메모리 {peak_mb():,.0f} MB 매출 합계 {sum(totals.values()):,}")
$ python chunk_naive.py
Traceback (most recent call last):
File "...chunk_naive.py", line 22, in <module>
_, region, _, qty, price = line.split(",")
ValueError: not enough values to unpack (expected 5, got 2)
값이 다섯 개여야 하는 줄에서 두 개만 나왔습니다. 블록 경계를 찍어 보니 원인이 보였습니다.
블록 1 끝: b',19000\n2026-10-01,\xea\xb4\x91\xec\xa3\xbc' | 블록 2 시작: b',\xeb\xaa\xa8\xeb\x8b\x88\xed\x84\xb0,2,289000\n2026'
블록 3: 'utf-8' codec can't decode bytes in position 16777214-16777215: unexpected end of data
블록 33개 중 utf-8로 디코딩이 안 되는 블록 15개
첫 블록이 “2026-10-01,광주”에서 끝나고, 둘째 블록이 “,모니터,2,289000″으로 시작했습니다. 한 줄이 두 블록에 나뉘어 들어간 것입니다. 더 위험한 문제도 있었습니다. 이번엔 ValueError가 먼저 났지만, 33개 블록 중 15개는 3바이트짜리 한글 글자 한가운데서 끊겨 있어서 UnicodeDecodeError도 날 상황이었습니다. 고친 방법은 블록마다 마지막 줄바꿈 뒤의 꼬리를 떼어 다음 블록 앞에 붙이고, 완성된 줄만 디코딩하는 것입니다. 줄바꿈 기준으로 자르니 글자 중간 절단도 함께 해결됩니다.
"""정해진 크기(바이트)씩 끊어 읽기 — 줄이 잘린 꼬리는 다음 블록으로 넘김"""
import sys
import time
from collections import defaultdict
from pathlib import Path
from memstat import peak_mb
sys.stdout.reconfigure(encoding="utf-8")
path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path(__file__).with_name("sales.csv")
CHUNK = int(sys.argv[2]) * 1024 * 1024 if len(sys.argv) > 2 else 16 * 1024 * 1024
start = time.perf_counter()
totals = defaultdict(int)
rest = b""
with open(path, "rb") as f:
f.readline() # 머리글
while True:
block = f.read(CHUNK)
if not block:
break
block = rest + block
cut = block.rfind(b"\n") + 1 # 마지막 줄바꿈 바로 뒤
rest = block[cut:] # 잘린 줄은 다음 블록 앞에 붙임
for line in block[:cut].decode("utf-8").splitlines():
_, region, _, qty, price = line.split(",")
totals[region] += int(qty) * int(price)
if rest.strip(): # 마지막 줄에 줄바꿈이 없을 때
_, region, _, qty, price = rest.decode("utf-8").strip().split(",")
totals[region] += int(qty) * int(price)
print(f"블록 {CHUNK // 1024 // 1024}MB 시간 {time.perf_counter() - start:.1f}초 최대 메모리 {peak_mb():,.0f} MB 매출 합계 {sum(totals.values()):,} 서울 {totals['서울']:,}")
$ python chunk_fixed.py sales.csv 1
블록 1MB 시간 13.5초 최대 메모리 21 MB 매출 합계 3,986,034,989,000 서울 498,489,763,000
$ python chunk_fixed.py
블록 16MB 시간 12.5초 최대 메모리 115 MB 매출 합계 3,986,034,989,000 서울 498,489,763,000
$ python chunk_fixed.py sales.csv 64
블록 64MB 시간 12.5초 최대 메모리 396 MB 매출 합계 3,986,034,989,000 서울 498,489,763,000
결과는 맞게 나왔지만 기대했던 속도 이득은 없었습니다. 블록 크기와 관계없이 12.5~13.5초로 for line in f(11.0~11.2초)보다 오히려 느렸고, 메모리는 블록 크기만큼 더 썼습니다. 파이썬 파일 객체가 이미 내부 버퍼로 나눠 읽고 있기 때문입니다. 줄 단위 텍스트라면 직접 블록을 나눌 이유가 없었습니다.
메모리가 적은 PC에서는 어떻게 될까: 500MB로 묶어 보기
이 노트북은 메모리가 32GB라 위 방식들이 다 끝까지 돌았습니다. 메모리가 빠듯한 환경을 흉내 내려고, 윈도우의 Job Object로 프로세스 하나가 쓸 수 있는 메모리를 제한하고 같은 코드를 돌렸습니다. 윈도우 전용 코드입니다.
"""메모리가 적은 PC를 흉내: 이 프로세스가 쓸 수 있는 메모리를 N MB로 묶고 실행
python limit_run.py 제한MB 방식이름 (윈도우 Job Object 사용)"""
import ctypes
import sys
import time
from ctypes import wintypes
import methods
from memstat import peak_mb
JOB_OBJECT_LIMIT_PROCESS_MEMORY = 0x100
JobObjectExtendedLimitInformation = 9
class IO_COUNTERS(ctypes.Structure):
_fields_ = [(n, ctypes.c_ulonglong) for n in
("ReadOp", "WriteOp", "OtherOp", "ReadBytes", "WriteBytes", "OtherBytes")]
class BASIC(ctypes.Structure):
_fields_ = [("PerProcessUserTimeLimit", ctypes.c_longlong), ("PerJobUserTimeLimit", ctypes.c_longlong),
("LimitFlags", wintypes.DWORD), ("MinimumWorkingSetSize", ctypes.c_size_t),
("MaximumWorkingSetSize", ctypes.c_size_t), ("ActiveProcessLimit", wintypes.DWORD),
("Affinity", ctypes.c_size_t), ("PriorityClass", wintypes.DWORD),
("SchedulingClass", wintypes.DWORD)]
class EXTENDED(ctypes.Structure):
_fields_ = [("Basic", BASIC), ("Io", IO_COUNTERS), ("ProcessMemoryLimit", ctypes.c_size_t),
("JobMemoryLimit", ctypes.c_size_t), ("PeakProcessMemoryUsed", ctypes.c_size_t),
("PeakJobMemoryUsed", ctypes.c_size_t)]
def limit_memory(mb):
k32 = ctypes.WinDLL("kernel32", use_last_error=True)
k32.CreateJobObjectW.restype = wintypes.HANDLE
k32.GetCurrentProcess.restype = wintypes.HANDLE
k32.SetInformationJobObject.argtypes = [wintypes.HANDLE, ctypes.c_int, ctypes.c_void_p, wintypes.DWORD]
k32.AssignProcessToJobObject.argtypes = [wintypes.HANDLE, wintypes.HANDLE]
job = k32.CreateJobObjectW(None, None)
info = EXTENDED()
info.Basic.LimitFlags = JOB_OBJECT_LIMIT_PROCESS_MEMORY
info.ProcessMemoryLimit = mb * 1024 * 1024
if not k32.SetInformationJobObject(job, JobObjectExtendedLimitInformation, ctypes.byref(info), ctypes.sizeof(info)):
raise ctypes.WinError(ctypes.get_last_error())
if not k32.AssignProcessToJobObject(job, k32.GetCurrentProcess()):
raise ctypes.WinError(ctypes.get_last_error())
if __name__ == "__main__":
sys.stdout.reconfigure(encoding="utf-8")
limit, name = int(sys.argv[1]), sys.argv[2]
if "blas1" in sys.argv[3:]:
import os
os.environ["OPENBLAS_NUM_THREADS"] = "1" # numpy가 스레드마다 잡는 버퍼 줄이기
print("OPENBLAS_NUM_THREADS=1")
limit_memory(limit)
start = time.perf_counter()
try:
totals = methods.METHODS[name](methods.HERE / "sales.csv")
print(f"제한 {limit}MB {name}: 성공 {time.perf_counter() - start:.1f}초, 최대 {peak_mb():,.0f} MB, 합계 {sum(int(v) for v in totals.values()):,}")
except MemoryError as e:
print(f"제한 {limit}MB {name}: MemoryError {e!r} ({time.perf_counter() - start:.1f}초 만에)")
$ python limit_run.py 500 read
제한 500MB read: MemoryError MemoryError() (0.0초 만에)
$ python limit_run.py 500 readlines
제한 500MB readlines: MemoryError MemoryError() (0.7초 만에)
$ python limit_run.py 500 iter_collect
제한 500MB iter_collect: MemoryError MemoryError() (1.4초 만에)
$ python limit_run.py 500 pandas
제한 500MB pandas: MemoryError MemoryError() (1.1초 만에)
$ python limit_run.py 300 iter
제한 300MB iter: 성공 13.3초, 최대 15 MB, 합계 3,986,034,989,000
$ python limit_run.py 300 csv
제한 300MB csv: 성공 13.2초, 최대 15 MB, 합계 3,986,034,989,000
통째로 읽는 방식은 전부 1.4초 안에 MemoryError로 멈췄고, 한 줄씩 처리하는 두 방식은 300MB 제한에서도 끝까지 같은 합계를 냈습니다.
예상 밖: 119MB짜리 pandas chunksize가 500MB 제한에서 실패
$ python limit_run.py 500 pandas_chunk
제한 500MB pandas_chunk: MemoryError MemoryError() (0.3초 만에)
# import pandas 직후: 실제 사용 66MB, 커밋(예약) 527MB
# OPENBLAS_NUM_THREADS=1로 import하면: 실제 사용 65MB, 커밋 45MB
$ python limit_run.py 500 pandas_chunk blas1
OPENBLAS_NUM_THREADS=1
제한 500MB pandas_chunk: 성공 9.7초, 최대 117 MB, 합계 3,986,034,989,000
최대 메모리가 119MB였던 chunksize 방식이 500MB 제한에서 0.3초 만에 실패했습니다. 확인해 보니 Job Object의 제한은 실제 사용량이 아니라 “커밋”, 즉 프로세스가 미리 예약해 둔 메모리 기준이었고, import pandas만 해도 실제 사용은 66MB인데 커밋은 527MB였습니다. 500MB 제한에서 import pandas만 실행하자 파이썬 오류 메시지도 없이 프로세스가 종료 코드 127로 끝났습니다. numpy가 쓰는 OpenBLAS가 CPU 스레드(이 노트북은 16개)마다 버퍼를 잡는 것으로 보여 OPENBLAS_NUM_THREADS=1을 주고 import하자 커밋이 45MB로 줄었고, 같은 제한에서 끝까지 돌았습니다. 메모리가 작은 서버나 컨테이너에서 pandas 스크립트가 데이터와 관계없이 시작하자마자 죽는다면 이 부분도 의심해 볼 만합니다.
한눈에 비교
| 방식 | 최대 메모리 | 시간(1·2회차) | 500MB 제한 |
|---|---|---|---|
| f.read() | 2,667MB | 13.2·11.8초 | MemoryError |
| readlines() | 1,906MB | 12.7·14.8초 | MemoryError |
| 한 줄씩 읽어 리스트에 모음 | 5,919MB | 28.2·24.2초 | MemoryError |
| for line in f | 15MB | 11.2·11.0초 | 성공(300MB도 성공) |
| csv.reader | 15MB | 13.3·12.6초 | 성공(300MB도 성공) |
| pandas 전체 | 986MB | 10.8·11.2초 | MemoryError |
| pandas chunksize | 119MB | 7.6·10.1초 | OPENBLAS_NUM_THREADS=1일 때만 성공 |
메모리 수치는 두 번 돌려도 거의 같았고, 시간은 실행마다 1~3초씩 흔들렸습니다. 시간 차이보다 메모리 차이가 훨씬 큰 결과입니다.
마무리
같은 512MB 파일, 같은 합계인데 메모리는 15MB부터 5,919MB까지 400배 가까이 차이 났습니다. 차이를 만든 건 읽은 데이터를 들고 있느냐 바로 버리느냐였습니다.
가장 작은 첫걸음은 이렇습니다.
- 스크립트에서
.read(),.readlines(),pd.read_csv(...)를 검색해 보고, 파일이 커질 수 있는 곳이면for line in f나csv.reader,chunksize로 바꿉니다. - 한 줄씩 읽는 루프 안에
.append(...)가 있다면, 정말 전부 모아야 하는지 확인하고 합계·개수 같은 결과만 남기도록 고칩니다. - 바꾼 뒤에는 위
memstat.py로 최대 메모리를 한 번 재 보고, 결과 숫자가 바꾸기 전과 같은지 대조합니다.
다음 글에서는 파일 수백 개의 이름을 한 번에 바꾸되, 실제로 바꾸기 전에 미리보기를 보여 주고 잘못 바꿨을 때 되돌릴 수 있는 스크립트를 만들어 보겠습니다.