NumPy 입문: 배열이 파이썬 리스트보다 빠른 이유와 기본 사용법
pandas, scikit-learn, PyTorch, OpenCV 같은 파이썬 데이터 생태계의 주요 라이브러리를 한 겹 벗기면 전부 NumPy 배열이 나옵니다. NumPy를 건너뛰고 pandas부터 배우면 “왜 이 연산은 빠르고 저 루프는 느린지”, “왜 원본이 같이 바뀌는지"를 영영 감으로만 다루게 됩니다. 이 글은 NumPy의 핵심 개념 다섯 개를 원리부터 정리합니다. 결론을 먼저 적으면, NumPy의 본질은 “같은 타입의 숫자를 연속된 메모리에 깔고, 루프를 C에게 맡기는 것” 하나입니다. 나머지 개념은 전부 이 문장에서 따라 나옵니다.
리스트는 왜 느린가: 포인터의 바다 #
파이썬 리스트 [1, 2, 3]은 숫자 세 개가 나란히 놓인 구조가 아닙니다. 객체를 가리키는 포인터 세 개가 놓여 있고, 실제 숫자(int 객체)는 메모리 여기저기에 흩어져 있습니다. 리스트 원소를 백만 번 더하는 루프는 백만 번의 포인터 추적, 백만 번의 타입 확인, 백만 번의 객체 연산을 치릅니다.
ndarray는 반대입니다. 원소가 전부 같은 타입(dtype)이라는 제약을 받아들이는 대가로, 실제 값들이 연속된 메모리에 빈틈없이 깔립니다. 그러면 두 가지가 공짜로 따라옵니다. 타입 확인이 배열당 한 번으로 끝나고, 연산 루프 전체를 컴파일된 C 코드가 돌 수 있습니다. CPU 캐시 효율까지 더해져, 수치 연산에서 리스트 대비 수십〜수백 배 차이가 나는 이유가 이것입니다.
import numpy as np
a = np.array([1.0, 2.5, 3.0]) # dtype은 float64로 추론
z = np.zeros((3, 4)) # 3행 4열, 0으로 채움
r = np.arange(0, 10, 2) # [0 2 4 6 8]
print(a.dtype, z.shape, z.ndim) # float64 (3, 4) 2배열의 신상 정보는 dtype(원소 타입), shape(각 차원의 크기), ndim(차원 수) 세 개로 요약됩니다. NumPy 코드를 읽다 막히면 이 셋을 찍어 보는 것이 디버깅의 시작입니다.
벡터화: 루프를 쓰지 않는 것이 문법입니다 #
NumPy의 사용법은 한 문장으로 요약됩니다. 원소를 순회하는 for 루프를 쓰지 말고, 배열 전체에 연산을 적용하라.
prices = np.array([12000, 45000, 8900, 23000])
# 이렇게 쓰지 않습니다
discounted = [p * 0.9 for p in prices]
# 이렇게 씁니다: 벡터화
discounted = prices * 0.9 # 전체에 곱셈
over_20k = prices[prices > 20000] # 불리언 마스크로 필터링
total = prices.sum()prices * 0.9는 배열 전체를 C 루프 한 번으로 처리합니다. prices > 20000은 True/False 배열(불리언 마스크)을 만들고, 그것을 인덱스로 넣으면 조건에 맞는 원소만 뽑힙니다. “루프 + if"로 하던 일이 전부 이 패턴으로 바뀝니다. 파이썬 for 루프 안에서 ndarray 원소를 하나씩 꺼내고 있다면, 거의 항상 잘못 쓰고 있는 것입니다. 그 순간 NumPy의 속도 이점이 사라지고 리스트보다 느려지기도 합니다.
브로드캐스팅: 모양이 달라도 계산되는 규칙 #
prices * 0.9에서 배열과 스칼라를 곱할 수 있었던 것이 브로드캐스팅의 가장 단순한 사례입니다. 규칙은 이렇습니다. 두 배열의 shape를 뒤에서부터 비교해서, 각 차원이 같거나 한쪽이 1이면 그 차원을 늘려서 맞춥니다.
matrix = np.array([[1, 2, 3],
[4, 5, 6]]) # shape (2, 3)
row = np.array([10, 20, 30]) # shape (3,)
col = np.array([[100], [200]]) # shape (2, 1)
matrix + row # row가 각 행에 더해짐 → (2, 3)
matrix + col # col이 각 열에 더해짐 → (2, 3)“각 행에서 열 평균을 빼는” 정규화가 루프 없이 한 줄이 되는 것이 이 규칙 덕분입니다. 규칙에 맞지 않는 조합은 에러가 나므로, shape 에러를 만나면 두 배열의 shape를 뒤에서부터 나란히 적어 보면 원인이 보입니다.
함정: 슬라이스는 복사가 아니라 뷰입니다 #
여기가 초심자가 가장 크게 데이는 지점입니다. NumPy의 슬라이스는 데이터를 복사하지 않고, 같은 메모리를 바라보는 뷰(view)를 만듭니다.
a = np.arange(10) # [0 1 2 ... 9]
b = a[2:5] # 뷰: a의 메모리를 공유
b[0] = 999
print(a[2]) # 999 — 원본이 바뀌었다!
c = a[2:5].copy() # 독립 사본이 필요하면 명시적으로리스트의 슬라이스(lst[2:5])가 복사본을 주는 것과 정반대라서, 리스트 감각으로 쓰면 원본 오염을 겪습니다. 대용량 배열을 복사 없이 다루기 위한 의도된 설계이므로, 규칙으로 외우는 것이 맞습니다. 잘라서 수정할 거라면 .copy()를 명시한다. 참고로 불리언 마스크 인덱싱(a[a > 5])은 복사본을 만듭니다. 뷰가 되는 것은 연속 범위 슬라이스입니다.
axis: 집계의 방향 읽는 법 #
2차원부터는 집계에 방향이 생깁니다. axis 인자가 그 방향이고, 규칙은 하나입니다. 지정한 axis가 사라진다.
sales = np.array([[10, 20, 30], # 1호점의 1~3월
[40, 50, 60]]) # 2호점의 1~3월
sales.sum() # 210 — 전체
sales.sum(axis=0) # [50 70 90] — 행 방향으로 접음: 월별 합계
sales.sum(axis=1) # [60 150] — 열 방향으로 접음: 지점별 합계shape (2, 3)에서 axis=0을 없애면 (3,)이 되고, axis=1을 없애면 (2,)가 됩니다. “axis=0은 세로, axis=1은 가로"로 외우는 것보다 “그 축이 사라진 결과 shape"로 생각하는 편이 3차원 이상에서도 무너지지 않습니다. 이 감각은 pandas의 axis, 딥러닝 프레임워크의 dim까지 그대로 이어집니다.
NumPy가 답이 아닌 경우 #
도구의 경계도 알아야 합니다.
- 원소가 수십 개 수준의 작은 데이터: 배열 생성 오버헤드가 있어 리스트가 더 빠르고 단순합니다. NumPy는 데이터가 커질수록 이기는 도구입니다.
- 타입이 섞인 데이터, 레이블이 필요한 데이터: 숫자 행렬이 아니라 “이름·날짜·금액이 있는 표"라면 처음부터 pandas가 맞습니다. pandas가 내부에서 NumPy를 씁니다.
- 문자열 처리, 복잡한 분기 로직: 벡터화가 어색한 로직을 억지로 NumPy에 밀어 넣으면 읽을 수 없는 코드가 됩니다. 순수 파이썬이 정답인 영역은 그대로 둡니다.
성능이 정말 문제인지 확인하고 싶다면 감이 아니라 측정입니다. 프로파일링 도구는 py-spy와 모던 파이썬 고급 #7에서 다뤘습니다.
정리 #
- 리스트는 포인터의 나열이라 느리고, ndarray는 같은 dtype을 연속 메모리에 깔아 C 루프와 캐시 효율을 얻습니다. 이것이 NumPy의 전부입니다.
- 문법의 핵심은 벡터화입니다. for 루프 대신 배열 전체 연산과 불리언 마스크를 씁니다. ndarray를 루프로 순회하는 코드는 잘못 쓰고 있다는 신호입니다.
- 브로드캐스팅은 shape를 뒤에서부터 비교해 1인 차원을 늘리는 규칙입니다. shape 에러는 두 배열의 shape를 나란히 적으면 풀립니다.
- 슬라이스는 뷰라서 원본과 메모리를 공유합니다. 잘라서 수정할 거라면
.copy()를 명시합니다. - 집계는 “지정한 axis가 사라진다"로 읽습니다. 이 감각이 pandas와 딥러닝 프레임워크까지 이어집니다.
- 작은 데이터에는 리스트가, 레이블 있는 표에는 pandas가, 복잡한 분기에는 순수 파이썬이 맞습니다. NumPy는 큰 수치 배열의 도구입니다.