NumPy, Pandas 기초 (1) - 왜 리스트로는 안 되는가: 벡터화와 dtype
Python 리스트의 실제 메모리 구조에서 출발해 NumPy 배열이 왜 수십 배 빠른지, 벡터화란 무엇인지, dtype이 왜 배열의 정체성인지를 정리한 시리즈 첫 편입니다.
NumPy와 pandas를 기초부터 정리하는 6편 시리즈의 첫 글입니다. 시리즈 소개와 실습 환경, 데이터 준비는 0편을 참고하세요.
모든 것이 이 두 줄로 시작한다
ML 공부를 시작하면 어떤 교재든 첫 셀이 똑같습니다.
1
2
import numpy as np
import pandas as pd
np, pd라는 약어는 사실상 표준 관례라 다르게 쓰면 오히려 코드 리뷰에서 지적받습니다. 그런데 Python에는 이미 리스트가 있는데 왜 모두가 이 두 라이브러리부터 불러올까요? “빠르니까”라는 답은 절반만 맞습니다. 왜 빠른지를 이해해야 나중에 “내 코드는 NumPy를 쓰는데 왜 느리지?”라는 질문에 답할 수 있습니다.
먼저 생태계에서의 위치부터 잡겠습니다.
- NumPy — 다차원 배열(
ndarray)과 그 위의 수치 연산. Python 수치 계산 생태계 전체의 기반입니다. - pandas — NumPy 배열에 행·열 라벨을 붙인 표 형태 자료구조(
DataFrame). 실무 데이터 처리의 표준입니다. - 그 위의 모든 것 — scikit-learn은 입력으로 NumPy 배열을 받고, matplotlib은 NumPy 배열을 그리고, PyTorch 텐서는 NumPy 배열과 상호 변환됩니다.
즉 어떤 ML 라이브러리를 쓰든 데이터는 결국 ndarray의 모습으로 흘러갑니다. 이 시리즈가 NumPy에서 시작하는 이유입니다.
Python 리스트의 실제 모습
[1, 2, 3]이라는 리스트가 메모리에 어떻게 놓이는지 뜯어보면 문제가 보입니다.
Python에서는 정수 하나도 객체입니다. 1이라는 값은 타입 정보, 참조 카운트 등을 포함한 28바이트짜리 객체이고, 리스트는 그 객체들이 어디 있는지 가리키는 포인터의 배열입니다. 원소들은 메모리 여기저기에 흩어져 있을 수 있습니다.
1
2
3
4
import sys
sys.getsizeof(1) # 28 — 정수 "객체" 하나의 크기
sys.getsizeof([1, 2, 3]) # 88 — 포인터 배열 부분만. 원소 객체는 별도
이 구조에서 100만 개 숫자를 더한다는 것은:
- 포인터를 따라가 객체를 찾고 (메모리 곳곳으로 점프)
- “이거 정수 맞아? 더하기 연산 있어?” 타입을 확인하고 (동적 타이핑 비용)
- 결과를 새 객체로 만드는 (객체 생성 비용)
작업을 100만 번 반복하는 일입니다. 유연함의 대가로 모든 원소마다 인터프리터가 개입합니다.
ndarray — 제약을 받아들이고 속도를 얻다
NumPy의 ndarray는 정확히 반대의 선택을 합니다. 모든 원소가 같은 타입이어야 한다는 제약을 받아들이는 대신:
- 원소가 객체가 아니라 날것의 숫자 값으로, 메모리에 빈틈없이 연속으로 저장됩니다.
int64100만 개면 정확히 800만 바이트입니다. - 타입이 배열 전체에 한 번만 기록되므로(
dtype), 원소마다 타입 확인이 필요 없습니다. - 덕분에 반복문 전체를 컴파일된 C 코드가 한 번에 돌 수 있고, CPU 캐시와 SIMD 명령의 이점도 그대로 받습니다.
이렇게 “반복문을 인터프리터 밖으로 밀어내는 것”이 벡터화(vectorization)입니다. 직접 재보면 차이가 극적입니다.
1
2
3
4
5
6
7
8
9
10
import numpy as np
arr = np.arange(1_000_000)
lst = list(range(1_000_000))
# 벡터화 — C 레벨 반복
%timeit (arr * 2).sum() # 약 1 ms
# Python 반복문 — 원소마다 인터프리터 개입
%timeit sum(x * 2 for x in lst) # 약 60 ms
환경에 따라 수치는 다르지만 대체로 수십~수백 배 차이가 납니다. 여기서 이 시리즈 전체를 관통하는 규칙이 나옵니다.
NumPy·pandas를 쓰는 코드에서
for문으로 원소를 하나씩 순회하고 있다면, 대부분 뭔가 잘못하고 있다는 신호다. 같은 일을 하는 벡터화 연산이 거의 항상 존재한다.
주의할 점: NumPy 배열을 쓴다고 자동으로 빨라지는 게 아닙니다. for x in arr:처럼 배열을 Python 반복문으로 돌면 리스트보다 오히려 느립니다 — 원소를 꺼낼 때마다 NumPy 값이 Python 객체로 포장되는 비용이 추가되기 때문입니다. 빠른 것은 배열이 아니라 배열 단위 연산입니다.
벡터화 사고방식으로 갈아타기
벡터화는 문법이 아니라 사고방식입니다. “원소를 하나씩 보며 무언가 한다”를 “배열 전체에 한 번에 묻는다”로 바꾸는 연습이 필요합니다. 자주 나오는 전환 패턴 세 가지를 미리 손에 붙여둡니다.
조건을 만족하는 개수 세기 — 비교 연산의 결과는 True/False 배열이고, True는 산술에서 1로 취급됩니다. 그래서 “세기”는 sum이 됩니다.
1
2
3
4
5
6
7
count = 0
for s in scores: # 반복문 사고
if s >= 90:
count += 1
(scores >= 90).sum() # 벡터화 사고 — True를 더한다: A학점 인원
(scores >= 90).mean() # 덤: 개수 대신 비율이 필요하면 mean
존재 여부 묻기 — “하나라도 있는가”는 any, “전부 그런가”는 all입니다.
1
2
(scores < 60).any() # 낙제점이 하나라도 있는가 — 데이터 검증에 자주
(scores <= 100).all() # 전부 100점 이하인가
원소별로 고르거나 자르기 — 두 배열 중 큰 쪽을 고르는 것도, 범위 밖 값을 자르는 것도 반복문이 필요 없습니다.
1
2
np.maximum(midterm, final) # 원소별 최댓값 — 중간·기말 중 높은 점수 인정
scores.clip(0, 100) # 0 미만은 0으로, 100 초과는 100으로
이 세 패턴만 익어도 실무에서 쓰던 반복문의 절반은 사라집니다. “세기는 sum, 존재는 any, 전부는 all” — 시리즈 내내 이 감각을 다시 만나게 됩니다.
dtype — 배열의 정체성
ndarray를 규정하는 것은 shape(모양)과 dtype(원소 타입) 둘입니다. 그중 dtype은 “얼마나 정밀하게, 얼마나 큰 값을, 몇 바이트로 담을 것인가”라는 계약입니다.
| dtype | 크기 | 담을 수 있는 값 |
|---|---|---|
int8 | 1바이트 | -128 ~ 127 |
int16 | 2바이트 | -32,768 ~ 32,767 |
int32 | 4바이트 | 약 ±21억 |
int64 | 8바이트 | 약 ±922경 (정수 기본값) |
float32 | 4바이트 | 유효숫자 약 7자리 |
float64 | 8바이트 | 유효숫자 약 16자리 (실수 기본값) |
bool | 1바이트 | True / False |
기본값인 int64/float64는 안전하지만, 데이터가 커지면 낭비가 눈에 보이기 시작합니다. 행 1억 개짜리 정수 컬럼이면 int64는 800MB, int32는 400MB, int16은 200MB입니다. 추측할 필요 없이 nbytes로 직접 확인할 수 있습니다.
1
np.zeros(100_000_000, dtype="int64").nbytes / 1e6 # 800.0 (MB)
값의 범위를 알면 타입을 내릴 수 있습니다. 시험 점수는 0~100 사이니 int8이면 충분하고, 학번은 아홉 자리라도 int32 범위(약 ±21억) 안에 넉넉히 들어갑니다.
1
2
df["score"] = df["score"].astype("int8")
df["student_id"] = df["student_id"].astype("int32")
다운캐스트의 조건: 값의 범위를 먼저 확인한다
작은 dtype으로 내릴 때 반드시 지켜야 할 순서가 있습니다. 범위를 확인하고 내린다. astype은 값이 범위를 벗어나도 에러를 내지 않고 조용히 값을 망가뜨릴 수 있기 때문입니다.
1
2
a = np.array([40_000])
a.astype("int16") # [-25536] — 에러 없이 값이 뒤틀린다 (overflow)
그래서 실무 순서는 이렇습니다.
1
2
3
col = df["score"]
col.min(), col.max() # (0, 100) — int8 범위 안임을 확인
df["score"] = col.astype("int8")
pandas에는 pd.to_numeric(col, downcast="integer")처럼 범위를 보고 알아서 가장 작은 타입을 골라주는 도구도 있습니다.
혼합 타입을 넣으면 무슨 일이 일어나는가
같은 타입만 담을 수 있다면, 섞어서 넣으면 어떻게 될까요? NumPy는 에러 대신 모두를 표현할 수 있는 타입으로 전체를 승격시킵니다.
1
2
3
np.array([1, 2, 3]).dtype # int64
np.array([1, 2, 3.0]).dtype # float64 — 정수가 실수로 끌려간다
np.array([1, 2, "3"]).dtype # <U21 — 전부 문자열이 된다
마지막 줄이 실무에서 자주 만나는 사고입니다. CSV에 숫자 컬럼 어딘가에 문자 하나가 섞여 있으면 컬럼 전체가 문자열(object)로 읽히고, 평균을 내는 순간 에러가 나거나 — 더 나쁘게는 — 이상한 결과가 조용히 나옵니다. 3편에서 다룰 “데이터 첫 점검”에서 info()로 dtype부터 확인하는 이유입니다.
실수는 근사값이다
dtype 이야기의 마지막 조각은 float의 본질입니다. 컴퓨터의 이진 부동소수점은 0.1 같은 십진 소수를 정확히 표현하지 못하고, 아주 가까운 근사값으로 저장합니다. 그 오차가 연산에서 드러납니다.
1
2
0.1 + 0.2 == 0.3 # False — 0.30000000000000004
np.isclose(0.1 + 0.2, 0.3) # True — 허용 오차 안에서 비교
실수를 ==로 비교하는 코드는 언젠가 반드시 배신합니다. 원소별 비교는 np.isclose, 배열 전체 비교는 np.allclose가 표준 도구이고, 테스트 코드에서 계산 결과를 검증할 때도 이걸 씁니다.
같은 맥락에서 float32는 유효숫자가 약 7자리뿐입니다. 큰 값들의 누적 합계나 미세한 차이가 중요한 계산에서는 float64를 유지하는 것이 안전하고, float32로 내리는 것은 모델 입력처럼 정밀도 손실이 허용된다고 판단한 지점에서만 합니다. 정수 다운캐스트에 “범위 확인”이 필요했다면, 실수 다운캐스트에는 “정밀도 판단”이 필요한 셈입니다.
그래도 리스트를 쓰는 경우
NumPy가 항상 정답은 아닙니다. 리스트가 맞는 경우도 분명히 있습니다.
- 타입이 정말로 섞여 있는 데이터 — 설정값 목록, 함수 목록처럼 수치 연산 대상이 아닌 것들
- 길이가 계속 변하는 경우 —
append가 잦다면 리스트가 낫습니다. NumPy의np.append는 매번 배열 전체를 새로 복사하므로 반복문 안에서 쓰면 최악의 성능이 나옵니다. 리스트에 모은 뒤 마지막에np.array(lst)한 번으로 변환하는 것이 정석입니다. - 원소가 몇 개 안 되는 경우 — 배열 생성 오버헤드가 이득보다 클 수 있습니다.
기준은 단순합니다. 동질적인 값 덩어리에 수치 연산을 한다면 NumPy, 아니면 리스트.
정리
| 개념 | 한 줄 요약 |
|---|---|
| 리스트 | 포인터의 배열. 유연하지만 원소마다 인터프리터가 개입 |
| ndarray | 단일 dtype, 연속 메모리. 배열 단위 연산이 C 레벨에서 돈다 |
| 벡터화 | 반복문을 인터프리터 밖으로. for문이 보이면 의심 |
| 벡터화 패턴 | 세기는 (조건).sum(), 존재는 any, 전부는 all |
| dtype | 배열의 타입이자 메모리 예산. 다운캐스트 전에 min/max 확인 |
| 타입 승격 | 섞어 넣으면 에러 대신 전체가 끌려간다. 문자 하나가 컬럼을 오염 |
| float 비교 | ==가 아니라 isclose/allclose. 실수는 근사값 |
다음 편에서는 이 배열을 실제로 다루는 법 — 만들기, 골라내기(인덱싱), 그리고 처음엔 반드시 헷갈리는 axis와 브로드캐스팅을 정리합니다.