파이썬 메모리 사용량 줄이기 — 큰 파일 한 번에 읽지 않는 법, 512MB CSV로 방식별 실측

로그나 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,667MB13.2·11.8초MemoryError
readlines()1,906MB12.7·14.8초MemoryError
한 줄씩 읽어 리스트에 모음5,919MB28.2·24.2초MemoryError
for line in f15MB11.2·11.0초성공(300MB도 성공)
csv.reader15MB13.3·12.6초성공(300MB도 성공)
pandas 전체986MB10.8·11.2초MemoryError
pandas chunksize119MB7.6·10.1초OPENBLAS_NUM_THREADS=1일 때만 성공

메모리 수치는 두 번 돌려도 거의 같았고, 시간은 실행마다 1~3초씩 흔들렸습니다. 시간 차이보다 메모리 차이가 훨씬 큰 결과입니다.

마무리

같은 512MB 파일, 같은 합계인데 메모리는 15MB부터 5,919MB까지 400배 가까이 차이 났습니다. 차이를 만든 건 읽은 데이터를 들고 있느냐 바로 버리느냐였습니다.

가장 작은 첫걸음은 이렇습니다.

  1. 스크립트에서 .read(), .readlines(), pd.read_csv(...)를 검색해 보고, 파일이 커질 수 있는 곳이면 for line in f나 csv.reader, chunksize로 바꿉니다.
  2. 한 줄씩 읽는 루프 안에 .append(...)가 있다면, 정말 전부 모아야 하는지 확인하고 합계·개수 같은 결과만 남기도록 고칩니다.
  3. 바꾼 뒤에는 위 memstat.py로 최대 메모리를 한 번 재 보고, 결과 숫자가 바꾸기 전과 같은지 대조합니다.

다음 글에서는 파일 수백 개의 이름을 한 번에 바꾸되, 실제로 바꾸기 전에 미리보기를 보여 주고 잘못 바꿨을 때 되돌릴 수 있는 스크립트를 만들어 보겠습니다.

댓글 남기기