NumPy 기초: 배열 연산, 브로드캐스팅, 벡터화
이 글의 핵심
NumPy 배열은 같은 타입의 숫자를 연속 메모리에 담기 때문에 리스트 루프보다 훨씬 빠르게 계산됩니다. 다만 슬라이스는 복사본이 아니라 뷰라서 수정하면 원본까지 바뀌고, 큰 배열을 합산할 때는 부동소수점 오차도 신경 써야 합니다. 이런 실수를 피하는 기준과 Numba·PyTorch 같은 대안과의 역할 차이도 함께 정리합니다.
들어가며
NumPy는 고성능 수치 계산을 위한 Python의 핵심 라이브러리입니다. Pandas, Matplotlib, scikit-learn, OpenCV의 Python 바인딩이 모두 NumPy 배열을 데이터 교환 형식으로 쓰기 때문에, 데이터 분석이나 머신러닝 코드를 읽으려면 NumPy의 동작 방식부터 알아야 합니다.
NumPy가 빠른 이유는 “C로 구현되어서”만이 아닙니다. Python 리스트는 각 요소가 따로 할당된 Python 객체를 가리키는 포인터 배열이라, [1, 2, 3]의 합을 구하려면 요소마다 객체를 따라가 타입을 확인하고 덧셈 메서드를 찾아야 합니다. ndarray는 같은 타입의 숫자를 연속된 메모리에 원시 값 그대로 담고, 배열 전체에 대해 타입을 한 번만 확인한 뒤 C 루프로 계산합니다. CPU 캐시에 잘 맞고 SIMD 명령어로 여러 값을 한꺼번에 처리할 수도 있습니다. 반대로 말하면, 이 이점은 배열 전체에 한 번에 연산을 걸 때만 생깁니다. NumPy 배열을 Python for 루프로 한 요소씩 다루면 요소를 꺼낼 때마다 Python 객체로 변환하는 비용이 붙어 오히려 리스트보다 느려질 수 있습니다.
설치와 배열 생성
설치
pip install numpy
배열 생성
Python 리스트는 범용 장바구니에 가깝으며, ndarray는 같은 타입의 숫자만 담는 격자에 가깝습니다. 아래는 리스트를 배열로 바꾸고, zeros·arange 등으로 모양을 미리 만드는 예입니다.
import numpy as np
# 리스트에서 생성
arr = np.array([1, 2, 3, 4, 5])
print(arr) # [1 2 3 4 5]
# 2차원 배열
arr2d = np.array([[1, 2, 3], [4, 5, 6]])
print(arr2d)
# [[1 2 3]
# [4 5 6]]
# 특수 배열
zeros = np.zeros((3, 4)) # 0으로 채움
ones = np.ones((2, 3)) # 1로 채움
empty = np.empty((2, 2)) # 초기화 안 함
arange = np.arange(0, 10, 2) # [0, 2, 4, 6, 8]
linspace = np.linspace(0, 1, 5) # [0, 0.25, 0.5, 0.75, 1]
arange와 linspace는 비슷해 보이지만 용도가 다릅니다. arange(start, stop, step)은 간격을 지정하고 끝값을 포함하지 않으며, linspace(start, stop, num)은 개수를 지정하고 기본적으로 끝값을 포함합니다. 실수 간격에는 arange를 피하는 것이 좋습니다. np.arange(0, 1, 0.1)은 부동소수점 오차 때문에 원소 개수가 예상과 달라질 수 있어, 공식 문서도 실수 범위에는 linspace를 권합니다.
np.empty는 메모리를 할당만 하고 값을 채우지 않으므로 이전에 쓰던 쓰레기 값이 들어 있습니다. 모든 칸을 곧바로 덮어쓸 때만 쓰고, 그렇지 않으면 zeros를 쓰십시오. 또 np.array([1, 2, 3])의 dtype은 플랫폼에 따라 달라서 Linux/macOS에서는 int64, NumPy 2.0 이전의 Windows에서는 int32였습니다. 큰 정수를 다루는 코드가 Windows에서만 오버플로가 나는 사례가 여기서 나왔으므로, 범위가 중요하면 dtype=np.int64를 명시합니다.
벡터화 연산과 브로드캐스팅
벡터화 연산
arr = np.array([1, 2, 3, 4, 5])
# 스칼라 연산
print(arr + 10) # [11 12 13 14 15]
print(arr * 2) # [2 4 6 8 10]
print(arr ** 2) # [1 4 9 16 25]
# 배열 간 연산
arr2 = np.array([10, 20, 30, 40, 50])
print(arr + arr2) # [11 22 33 44 55]
print(arr * arr2) # [10 40 90 160 250]
브로드캐스팅
# 1차원 + 2차원
arr = np.array([[1, 2, 3], [4, 5, 6]])
print(arr + 10)
# [[11 12 13]
# [14 15 16]]
# 행렬 + 벡터
matrix = np.array([[1, 2, 3], [4, 5, 6]])
vector = np.array([10, 20, 30])
print(matrix + vector)
# [[11 22 33]
# [14 25 36]]
브로드캐스팅 규칙은 두 배열의 shape를 오른쪽 끝부터 비교합니다. 각 차원의 크기가 같거나, 둘 중 하나가 1이면 호환되고, 1인 쪽이 늘어난 것처럼 계산됩니다. 차원 수가 부족한 쪽은 왼쪽에 1이 채워진 것으로 봅니다. 위 예제는 (2, 3)과 (3,)이므로 (3,)이 (1, 3)으로 간주되고 행 방향으로 반복됩니다. 실제로 데이터를 복사해 늘리는 것이 아니라 같은 값을 반복해 읽는 방식이라 메모리를 추가로 쓰지 않습니다.
흔히 막히는 경우는 열 방향으로 더하고 싶을 때입니다. 행마다 다른 값을 더하려고 (2, 3) 행렬에 np.array([100, 200])을 더하면, 오른쪽 끝 차원 3과 2가 맞지 않아 ValueError: operands could not be broadcast together with shapes (2,3) (2,)가 납니다. 이때는 np.array([100, 200])[:, np.newaxis](shape (2, 1))로 차원을 하나 세워야 합니다. 반대로 규칙이 맞아떨어져서 에러 없이 틀린 결과가 나오는 경우가 더 위험합니다. shape (3,)인 배열과 (3, 1)인 배열을 빼면 에러 대신 (3, 3) 행렬이 만들어지는데, 예측값과 정답의 오차를 구하려다 이렇게 되면 손실 값이 조용히 틀립니다. 연산 결과의 .shape를 한 번씩 확인하는 습관이 가장 확실한 방어입니다.
기본 인덱싱과 불린 인덱싱
기본 인덱싱
arr = np.array([1, 2, 3, 4, 5])
print(arr[0]) # 1
print(arr[-1]) # 5
print(arr[1:4]) # [2 3 4]
# 2차원
arr2d = np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]])
print(arr2d[0, 0]) # 1
print(arr2d[1, :]) # [4 5 6] (1번 행 전체)
print(arr2d[:, 1]) # [2 5 8] (1번 열 전체)
arr2d[0, 0]과 arr2d[0][0]은 같은 값을 돌려주지만, 후자는 먼저 0번 행 전체를 꺼낸 뒤 다시 인덱싱하므로 두 단계를 거칩니다. 다차원 배열은 쉼표로 한 번에 인덱싱하는 것이 빠르고, 특히 arr2d[:, 1]처럼 열을 고르는 것은 arr2d[:][1]로는 불가능합니다(arr2d[:]가 전체 배열이라 [1]은 1번 행이 됩니다).
여기서 슬라이스(arr[1:4], arr2d[1, :])는 모두 뷰를 돌려줍니다. 새 배열처럼 보이지만 원본 메모리를 공유하므로, 뷰를 수정하면 원본이 바뀝니다. 이 동작은 큰 배열의 일부를 복사 없이 다룰 수 있게 해 주는 장점이지만, 원본을 보존해야 하는 코드에서는 버그의 원인이 됩니다.
불린 인덱싱
arr = np.array([1, 2, 3, 4, 5])
# 조건 필터링
mask = arr > 3
print(mask) # [False False False True True]
print(arr[mask]) # [4 5]
# 한 줄로
print(arr[arr > 3]) # [4 5]
불린 인덱싱은 슬라이스와 달리 복사본을 돌려줍니다. 선택된 요소들이 메모리에 연속해 있지 않을 수 있기 때문입니다. 그래서 sub = arr[arr > 3]; sub[0] = 0은 원본을 바꾸지 않지만, arr[arr > 3] = 0처럼 대입문의 왼쪽에 쓰면 원본의 해당 위치가 바뀝니다. 조건을 여러 개 조합할 때는 and/or 대신 &/|를 쓰고 각 조건을 괄호로 감싸야 합니다. arr > 1 and arr < 4는 ValueError: The truth value of an array with more than one element is ambiguous를 내고, 괄호 없이 arr > 1 & arr < 4로 쓰면 &의 우선순위가 비교 연산자보다 높아 엉뚱한 식이 됩니다. 올바른 형태는 arr[(arr > 1) & (arr < 4)]입니다.
reshape로 형태 바꾸기
reshape
arr = np.arange(12)
print(arr) # [0 1 2 3 4 5 6 7 8 9 10 11]
# 3x4 행렬로
matrix = arr.reshape(3, 4)
print(matrix)
# [[ 0 1 2 3]
# [ 4 5 6 7]
# [ 8 9 10 11]]
# 평탄화
flat = matrix.flatten()
print(flat) # [0 1 2 3 4 5 6 7 8 9 10 11]
reshape는 가능하면 데이터를 복사하지 않고 같은 메모리를 다른 모양으로 해석한 뷰를 돌려줍니다. 원소 개수가 맞아야 하므로 arr.reshape(5, 3)은 cannot reshape array of size 12 into shape (5,3) 에러가 납니다. 한 차원을 -1로 두면 나머지로부터 계산해 주어 arr.reshape(-1, 4)는 (3, 4)가 됩니다. 이미지 배치를 모델에 넣을 때 batch.reshape(len(batch), -1)처럼 자주 씁니다.
평탄화에는 flatten()과 ravel() 두 가지가 있습니다. flatten()은 항상 복사본을 만들고, ravel()은 가능하면 뷰를 돌려줍니다. 큰 배열을 읽기만 할 거라면 ravel()이 메모리를 아끼지만, 결과를 수정하면 원본이 바뀔 수 있다는 점을 기억해야 합니다.
기본 통계 함수
기본 통계
arr = np.array([1, 2, 3, 4, 5])
print(np.sum(arr)) # 15
print(np.mean(arr)) # 3.0
print(np.std(arr)) # 1.414 (표준편차)
print(np.min(arr)) # 1
print(np.max(arr)) # 5
# 축 기준 연산
arr2d = np.array([[1, 2, 3], [4, 5, 6]])
print(np.sum(arr2d, axis=0)) # [5 7 9] (열 합)
print(np.sum(arr2d, axis=1)) # [6 15] (행 합)
axis는 “이 축을 따라 합쳐서 없앤다”로 이해하면 헷갈리지 않습니다. shape (2, 3)에서 axis=0으로 합치면 0번 축(행 2개)이 사라져 결과가 (3,), 즉 열마다의 합이 됩니다. axis=1은 1번 축(열 3개)이 사라져 (2,), 행마다의 합입니다. 결과를 원래 배열과 다시 연산해야 한다면 keepdims=True를 주어 (2, 1)처럼 차원을 유지해야 브로드캐스팅이 의도대로 동작합니다. 아래 “정규화된 배치 벡터 연산” 예제가 바로 이 패턴입니다.
np.std는 기본적으로 모집단 표준편차(ddof=0, n으로 나눔)를 계산합니다. 표본 표준편차(n-1로 나눔)가 필요하면 np.std(arr, ddof=1)을 써야 하는데, Pandas의 Series.std()는 기본값이 ddof=1이라 같은 데이터에서 두 라이브러리의 결과가 다르게 나옵니다. 처음 두 라이브러리를 섞어 쓸 때 흔히 겪는 혼란입니다. 결측값(np.nan)이 섞여 있으면 np.mean은 nan을 반환하므로 np.nanmean 계열 함수를 씁니다.
행렬 연산과 선형대수
행렬 연산
# 행렬 곱
A = np.array([[1, 2], [3, 4]])
B = np.array([[5, 6], [7, 8]])
print(np.dot(A, B)) # 행렬 곱
# [[19 22]
# [43 50]]
print(A @ B) # 같은 결과 (Python 3.5+)
# 전치
print(A.T)
# [[1 3]
# [2 4]]
# 역행렬
inv_A = np.linalg.inv(A)
print(inv_A)
# 고유값
eigenvalues, eigenvectors = np.linalg.eig(A)
print(eigenvalues)
A * B와 A @ B는 다릅니다. *는 같은 위치의 원소끼리 곱하는 원소별 곱이고, @(또는 np.dot, np.matmul)가 선형대수의 행렬 곱입니다. MATLAB에서 넘어온 사람이 가장 자주 하는 실수입니다. 2차원 이상에서는 np.dot과 np.matmul의 브로드캐스팅 규칙이 다르므로, 새 코드에는 의미가 명확한 @를 쓰는 편이 좋습니다.
역행렬은 연립방정식 Ax = b를 풀 때 np.linalg.inv(A) @ b로 쓰는 경우가 많은데, 수치해석에서는 권장되지 않는 방법입니다. 역행렬을 명시적으로 구하면 계산량이 더 많고 반올림 오차도 커집니다. 같은 문제는 np.linalg.solve(A, b)로 푸는 것이 빠르고 정확합니다. 행렬이 특이(singular)하면 inv는 LinAlgError: Singular matrix를 내지만, 거의 특이한 행렬에서는 에러 없이 매우 큰 값의 부정확한 결과를 돌려주므로 np.linalg.cond(A)로 조건수를 확인해 보는 것이 안전합니다. 또 eig는 대칭 행렬이 아니면 복소수 고유값을 돌려줄 수 있고, 공분산 행렬처럼 대칭임을 아는 경우에는 더 빠르고 안정적인 np.linalg.eigh를 씁니다.
이미지 배열 처리 예제
import numpy as np
# 이미지를 배열로 (예시)
image = np.random.randint(0, 256, (100, 100, 3), dtype=np.uint8)
# 그레이스케일 변환
gray = np.mean(image, axis=2).astype(np.uint8)
# 밝기 조정 (uint8에서 바로 더하면 255를 넘는 값이 0부터 다시 시작하므로 먼저 넓은 타입으로 변환)
bright = np.clip(image.astype(np.int16) + 50, 0, 255).astype(np.uint8)
# 이미지 크기
print(f"크기: {image.shape}") # (100, 100, 3)
밝기 조정 줄은 이미지 처리에서 가장 흔한 NumPy 버그를 피하기 위한 형태입니다. image가 uint8(0~255)이면 image + 50의 결과도 uint8이라, 230 + 50 = 280이 되어야 할 값이 256을 뺀 24로 감싸져(wrap around) 버립니다. 그 뒤에 np.clip을 해도 이미 24가 된 값은 되돌릴 수 없어, 밝은 부분이 오히려 검게 변하는 얼룩이 생깁니다. 에러도 경고도 나지 않기 때문에 결과 이미지를 눈으로 보기 전까지 모르는 경우가 많습니다. 그래서 먼저 int16이나 float32로 바꿔 계산하고, 마지막에 범위를 자른 뒤 uint8로 되돌립니다.
그레이스케일 변환도 단순 평균은 근사일 뿐입니다. 사람 눈은 초록에 가장 민감하므로 보통 0.299·R + 0.587·G + 0.114·B 같은 가중치를 쓰며, image @ np.array([0.299, 0.587, 0.114])로 한 줄에 계산할 수 있습니다. OpenCV로 읽은 이미지는 채널 순서가 RGB가 아니라 BGR이라는 점도 가중치를 적용할 때 주의해야 합니다.
벡터화와 dtype·복사/뷰
NumPy 배열은 같은 모양의 숫자를 한꺼번에 다루는 공장 라인에 가깝습니다. Python for 루프로 하나씩 제곱하는 대신 배열 전체에 연산을 한 번에 걸면 C 쪽 구현 덕분에 훨씬 빠릅니다. copy()와 슬라이스 뷰의 차이는 결과가 원본을 바꿀지 여부와 직결됩니다.
# ✅ 벡터화 연산 사용
arr = np.arange(1000000)
result = arr ** 2 # 빠름
# ❌ 반복문 사용
result = [x ** 2 for x in arr] # 느림
# ✅ 메모리 효율
# dtype 지정
arr = np.array([1, 2, 3], dtype=np.int32)
# ✅ 복사 vs 뷰
arr_copy = arr.copy() # 복사
arr_view = arr[:] # 뷰 (원본 공유)
어떤 배열이 뷰인지 헷갈릴 때는 np.shares_memory(a, b)로 두 배열이 메모리를 공유하는지 확인하거나, arr_view.base is arr로 원본을 확인할 수 있습니다. 함수 안에서 인자로 받은 배열을 수정하는 코드는 호출자의 배열까지 바꾸므로, 라이브러리 함수를 작성할 때는 입력을 수정하지 않거나 문서에 명시하는 것이 관례입니다.
벡터화 예제에서 리스트 컴프리헨션 쪽이 느린 이유는 앞에서 설명한 대로 요소마다 Python 객체로 변환되기 때문입니다. 벡터화가 항상 공짜는 아니라는 점도 알아 두면 좋습니다. a * b + c 같은 식은 중간 결과마다 임시 배열을 만들기 때문에, 배열이 수 GB라면 메모리가 순간적으로 두세 배 필요해집니다. 이럴 때 np.multiply(a, b, out=a); a += c처럼 out= 인자와 복합 대입으로 제자리 연산을 하거나, 식 전체를 한 번에 계산하는 numexpr·Numba를 검토합니다.
행별 L2 정규화와 흔한 실수
배치 벡터를 행별로 L2 정규화하기
여러 벡터를 행으로 쌓은 뒤 행별 L2 정규화를 벡터화로 수행합니다. 딥러닝 전처리에서 자주 쓰는 패턴입니다.
import numpy as np
rng = np.random.default_rng(0)
X = rng.normal(size=(5, 3))
norms = np.linalg.norm(X, axis=1, keepdims=True)
Xn = X / np.clip(norms, 1e-12, None)
print(np.linalg.norm(Xn, axis=1))
# 모두 1에 가깝게
axis 착각, 뷰와 복사, 정수 오버플로
axis를 착각해 의도와 다른 차원으로 합/평균을 내는 경우.- 뷰와 복사를 구분하지 못해 원본 배열이 의도치 않게 바뀌는 경우.
- 정수 오버플로가 가능한
dtype으로 누적합을 하는 경우.
부동소수점 합산과 메모리 재사용
- 부동소수점은 결합법칙이 성립하지 않습니다. 대규모 합산은
float64나math.fsum패턴을 검토하세요. - 메모리가 빠듯하면
out=인자로 버퍼를 재사용합니다. - Pandas와 섞을 때는
valuesvs 인덱스 정렬을 항상 확인합니다.
NumPy 대신 쓸 만한 라이브러리
| 라이브러리 | 용도 |
|---|---|
| NumPy | 배열 연산, BLAS/LAPACK |
| Numba | 핫루프 JIT |
| JAX/PyTorch | 자동미분·GPU |
추가 리소스
NumPy 요약
- NumPy: 고성능 수치 계산
- ndarray: N차원 배열
- 벡터화: 반복문 없이 연산
- 브로드캐스팅: 크기 자동 맞춤
- 선형대수: 행렬 연산, 고유값
다음 단계
같이 보면 좋은 글
자주 묻는 질문 (FAQ)
Q. 슬라이스한 배열을 수정했더니 원본 배열까지 바뀌는 이유는 무엇인가요?
A. NumPy의 슬라이스는 데이터를 복사하지 않고 원본 메모리를 공유하는 뷰(view)를 반환하기 때문입니다. 그래서 arr_view = arr[:]로 만든 배열의 값을 바꾸면 원본 arr에도 그대로 반영됩니다. 원본과 독립적인 배열이 필요하면 arr.copy()로 명시적으로 복사해야 하며, 이는 Python 리스트의 슬라이스가 새 리스트를 만드는 것과 다른 점입니다.