포스트

NumPy 기초 (1) - Why we use Numpy: vertorization and dtype

NumPy 배열이 왜 빠르고 메모리를 효율적으로 사용하는지, vectorization과 dtype을 중심으로 정리합니다.

NumPy 기초 (1) - Why we use Numpy: vertorization and dtype

NumPy를 기초부터 정리하는 시리즈의 첫 글입니다. 시리즈 소개와 실습 환경 준비는 0편을 참고하세요.

NumPy 소개

NumPy(Numerical Python)은 Python에서 수치, 행렬 계산 등을 효율적으로 처리하기 위한 라이브러리입니다. NumPy를 사용하는 여러 이유가 있지만 큰 이유는 아래 세 가지입니다.

  1. Vectorization: Python 반복문 없이 배열 전체를 한 번에 계산할 수 있습니다.
  2. Memory efficiency: 같은 자료형의 데이터를 조밀하게 저장합니다.
  3. dtype: 배열 원소의 자료형과 크기를 명확하게 지정할 수 있습니다.

이외에도 풍부한 생태계, C 기반 구현 등 여러 이유가 있습니다.

Python List는 뭐가 문제였는가?

Python에는 내장 자료형인 리스트가 있습니다. 리스트는 각 요소(element)를 객체(object)로 관리하고, 값 자체가 아니라 그 객체들을 가리키는 포인터(pointer)의 배열로 저장합니다.

문제는 파이썬에서는 정수 하나도 객체라는 점입니다. 1이라는 값은 숫자 4 byte에 타입 정보, 참조 카운트 같은 관리 정보가 붙어 28바이트를 차지합니다. 그리고 리스트는 이 객체들을 직접 담지 않고, 주소만 담는 포인터 배열입니다. 값은 객체로 포장되고 리스트는 포인터만 담는 이중 구조가, 파이썬이 기본적으로 메모리를 많이 쓰는 이유이기도 합니다.

1
2
3
4
import sys

print(sys.getsizeof(1))         # 28
print(sys.getsizeof([1,2,3,4])) # 88

이 코드를 실행하면, Python에선 88바이트를 차지합니다. 그마저도 포인터 배열 부분만이고, list 내 원소 객체들은 별도입니다. C에선 16 byte 짜리 배열이지만 Python에선 5배 정도가 됩니다. 이런 구조가 파이썬이 기본적으로 메모리를 많이 쓰는 이유이기도 합니다. Python list에서 100만 개 숫자를 더한다는 것은:

  1. 포인터를 따라가 객체를 찾고 (pointer chasing)
  2. 객체마다 타입을 확인하고 (dynamic typing overhead)
  3. 결과를 새 객체로 만드는 (object creation overhead)

위 세 작업을 100만 번 반복하기 때문에 실행 시간이 오래 걸리게 됩니다.

C가 빠르고 Python이 느린 것은 실행 방식의 차이입니다. C는 실행 전에 소스 전체가 기계어로 번역되는 컴파일 언어입니다. 변수의 타입도 컴파일 시점에 전부 결정됩니다. 그래서 100만 개를 더하는 반복문은 “메모리에서 4바이트를 읽고, 더하고, 다음 칸으로”라는 기계어 몇 줄이 되고, CPU는 이것을 그대로 반복합니다. 반면, Python은 실행할 때 소스를 바이트코드로 한 번 컴파일하고, 그 바이트코드를 인터프리터가 한 명령씩 해석하며 실행합니다. 덧셈 하나마다 “이 값의 타입이 무엇인지, 이 타입의 덧셈이 무엇인지”를 매번 확인하고, 결과도 매번 새 객체로 만들기 때문에 작업 시간이 오래 걸리게 됩니다.

이러한 문제를 해결하기 위해 NumPy는 반복문을 Python 밖으로 넘깁니다. NumPy의 C 코드는 설치 시점에 이미 기계어로 컴파일되어 있고, import numpy가 이 기계어를 메모리에 올립니다. 배열 연산을 부를 때마다 Python에서 이 기계어 함수로 제어가 넘어가므로, 100만 개를 도는 반복문에 인터프리터가 개입하지 않아 실행시간이 단축됩니다.

Vectorization

벡터화(Vectorization)는 원소를 하나씩 도는 반복문 없이 배열 전체를 하나의 연산으로 처리하는 방식입니다. 이것이 가능한 이유는 ndarray의 구조에 있습니다. ndarray(N-dimensional array)는 NumPy의 핵심 자료구조로, 배열 내부의 모든 원소가 같은 자료형이어야 합니다. 그 대신 원소를 객체로 관리하지 않고, 값 자체를 하나의 연속된 메모리에 저장합니다.

  • 원소가 객체가 아니라 raw 값으로, 메모리에 연속으로 저장됩니다.
  • 타입이 배열 전체에 한 번만 기록되므로(dtype), 원소마다 타입 확인이 필요 없습니다.
  • 벡터화를 할 경우, 반복문 전체를 컴파일된 C 코드가 한 번에 돌 수 있고, CPU 캐시와 SIMD 명령의 이점도 그대로 받습니다.

직접 재보면 차이가 극적입니다.

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 배열을 쓴다고 항상 빨라지는 게 아닙니다. for x in arr:처럼 ndarray 배열을 Python 반복문으로 돌면 리스트보다 오히려 느립니다. 원소를 꺼낼 때마다 NumPy 값이 Python 객체로 포장되는 비용이 추가되기 때문입니다. NumPy가 Python list보다 빠른 경우는 배열 단위 연산을 할 때입니다.

dtype

넘파이의 자료형 은 shape와 dtype이 있다 인가? ndarray를 규정하는 것은 shape(모양)과 dtype(원소 타입) 둘입니다. 그중 dtype은 “얼마나 정밀하게, 얼마나 큰 값을, 몇 바이트로 담을 것인가”라는 계약입니다.

dtype크기담을 수 있는 값
int81바이트-128 ~ 127
int162바이트-32,768 ~ 32,767
int324바이트약 ±21억
int648바이트약 ±922경 (정수 기본값)
float324바이트유효숫자 약 7자리
float648바이트유효숫자 약 16자리 (실수 기본값)
bool1바이트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)로 읽히고, 평균을 내는 순간 에러가 나거나 더 나쁘게는 이상한 결과가 조용히 나옵니다. Pandas 기초 1편에서 다룰 “데이터 첫 점검”에서 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와 브로드캐스팅을 정리합니다.

다음 글: NumPy 기초 (2) - 배열 만들기, 고르기, 접기

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.