파이썬의 데이터 타입 이해하기
데이터 기반 과학과 연산을 효율적으로 수행하려면 데이터가 메모리에서 어떻게 저장되고 조작되는지 이해해야 합니다. 이번 섹션에서는 파이썬 언어 자체에서 데이터 배열을 처리하는 방법과 NumPy가 이를 어떻게 개선했는지를 비교하며 살펴보겠습니다. 이 차이를 이해하는 것은 앞으로 이 책에서 다룰 수많은 내용을 파악하는 데 필수적입니다.
파이썬 사용자들이 꼽는 가장 큰 매력 중 하나는 바로 동적 타이핑(dynamic typing)입니다. C나 자바(Java) 같은 정적 타입 언어에서는 각 변수를 명시적으로 선언해야 하지만, 파이썬 같은 동적 타입 언어에서는 이 과정을 생략합니다. 예를 들어 C에서는 다음과 같이 변수를 선언하고 작업을 수행합니다.
``C /* C 코드 */ int result = 0; for(int i=0; i<100; i++){ result += i; }
파이썬에서는 동일한 작업을 다음과 같이 작성합니다.
``python
# 파이썬 코드
result = 0
for i in range(100):
result += i
주요 차이점을 살펴보죠. C에서는 각 변수의 데이터 타입을 명시적으로 선언해야 하지만, 파이썬에서는 변수의 타입이 동적으로 결정됩니다. 즉, 다음과 같이 어떤 종류의 데이터든 자유롭게 변수에 할당합니다.
``python # 파이썬 코드 x = 4 x = “4”
여기서는 `x`에 담긴 값을 정수에서 문자열로 바꿨습니다. 만약 C에서 이런 작업을 시도한다면(컴파일러 설정에 따라) 컴파일 오류가 나거나 예상치 못한 결과가 발생할 것입니다.
``C
/* C 코드 */
int x = 4;
x = "4"; // 실패
이러한 유연함은 파이썬과 같은 동적 타입 언어를 편리하고 쓰기 쉽게 만드는 핵심 요소입니다. 하지만 이 유연함이 내부적으로 어떻게 작동하는지 이해하는 것은 효율적인 데이터 분석을 위해 매우 중요합니다. 파이썬의 변수는 단순히 값만 담고 있는 것이 아니라, 그 값의 타입에 대한 정보까지 함께 들고 있기 때문입니다. 이에 대해 좀 더 자세히 알아보겠습니다.
파이썬 정수는 단순한 정수가 아닙니다
파이썬의 기본 구현체인 CPython은 C 언어로 작성되었습니다. 이 말은 모든 파이썬 객체가 실제로는 값 외에 여러 정보를 담고 있는 C 구조체(struct)라는 뜻입니다. 예를 들어 파이썬에서 x = 10000과 같이 정수를 정의할 때, x는 단순한 “원시(raw)” 정수가 아닙니다. 사실은 여러 값을 포함하는 복합적인 C 구조체를 가리키는 포인터입니다. 파이썬 3.10 소스 코드를 보면 정수(long) 타입의 정의는 대략 다음과 같은 형태를 띱니다(C 매크로를 확장한 모습).
``C struct _longobject { long ob_refcnt; PyTypeObject *ob_type; size_t ob_size; long ob_digit[1]; };
파이썬 3.10에서 정수 하나는 실제로는 다음 네 부분으로 구성됩니다.
- `ob_refcnt`: 파이썬이 메모리 할당과 해제를 자동으로 관리하기 위해 사용하는 참조 횟수(reference count)
- `ob_type`: 변수의 타입을 식별하는 정보
- `ob_size`: 데이터 멤버의 크기
- `ob_digit`: 파이썬 변수가 실제로 나타내고자 하는 정수 값
이 때문에 아래 그림처럼 C 같은 컴파일 언어에 비해 파이썬에서 정수를 저장할 때 약간의 오버헤드가 발생하게 됩니다.

여기서 `PyObject_HEAD`는 참조 횟수와 타입 코드 등 앞서 언급한 정보들을 담고 있는 부분입니다.
결정적인 차이를 주목해 보세요. C 정수는 정수 값이 담긴 메모리 위치를 가리키는 이름표와 같지만, 파이썬 정수는 정수 값뿐만 아니라 객체의 모든 정보를 담고 있는 메모리 위치를 가리키는 포인터입니다.
이러한 구조 덕분에 파이썬은 무척 자유롭고 동적인 코딩이 가능합니다.
하지만 모든 파이썬 타입이 들고 있는 이 방대한 정보는 그만큼의 대가를 치러야 하며, 특히 이런 객체들이 대량으로 모여 있는 구조에서 그 비용이 확연히 드러납니다.
## 파이썬 리스트는 단순한 리스트 그 이상입니다
이제 여러 파이썬 객체를 담고 있는 데이터 구조를 쓸 때 어떤 일이 일어나는지 보시죠.
파이썬에서 가장 대중적으로 쓰이는 가변형 컨테이너는 리스트(list)입니다.
다음과 같이 정수 리스트를 만들 수 있습니다.
::: {#cell-6 .cell quarto-private-1='{"key":"jupyter","value":{"outputs_hidden":false}}' execution_count=1}
``` {.python .cell-code}
L = list(range(10))
L
```
::: {.cell-output .cell-output-display execution_count=1}
```
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
```
:::
:::
::: {#cell-7 .cell quarto-private-1='{"key":"jupyter","value":{"outputs_hidden":false}}' execution_count=2}
``` {.python .cell-code}
type(L[0])
```
::: {.cell-output .cell-output-display execution_count=2}
```
int
```
:::
:::
문자열 리스트도 마찬가지입니다.
::: {#cell-9 .cell quarto-private-1='{"key":"jupyter","value":{"outputs_hidden":false}}' execution_count=3}
``` {.python .cell-code}
L2 = [str(c) for c in L]
L2
```
::: {.cell-output .cell-output-display execution_count=3}
```
['0', '1', '2', '3', '4', '5', '6', '7', '8', '9']
```
:::
:::
::: {#cell-10 .cell quarto-private-1='{"key":"jupyter","value":{"outputs_hidden":false}}' execution_count=4}
``` {.python .cell-code}
type(L2[0])
```
::: {.cell-output .cell-output-display execution_count=4}
```
str
```
:::
:::
동적 타이핑 덕분에 서로 다른 타입의 데이터가 섞인 리스트도 아주 쉽게 만들 수 있습니다.
::: {#cell-12 .cell quarto-private-1='{"key":"jupyter","value":{"outputs_hidden":false}}' execution_count=5}
``` {.python .cell-code}
L3 = [True, "2", 3.0, 4]
[type(item) for item in L3]
```
::: {.cell-output .cell-output-display execution_count=5}
```
[bool, str, float, int]
```
:::
:::
하지만 이러한 유연함에는 오버헤드가 따릅니다. 리스트의 각 항목은 고유한 타입 정보와 참조 횟수 등을 포함한 독립적인 파이썬 객체여야 하기 때문입니다.
만약 리스트의 모든 요소가 같은 타입이라면 이러한 중복 정보는 낭비가 됩니다. 이럴 때는 고정 타입 배열(fixed-type array)에 데이터를 담는 것이 훨씬 효율적입니다.
동적 타입 리스트와 고정 타입(NumPy 스타일) 배열의 차이는 아래 그림에 잘 나타나 있습니다.

내부 구현 방식을 보면, 배열은 연속된 데이터 블록 하나를 가리키는 단일 포인터를 포함합니다.
반면 파이썬 리스트는 여러 포인터가 담긴 블록을 가리키며, 각 포인터는 다시 앞서 보았던 파이썬 정수와 같은 객체를 하나씩 가리키는 구조입니다.
리스트의 장점은 유연함에 있습니다. 리스트의 각 요소는 데이터와 타입 정보를 모두 포함하고 있어 어떤 타입의 데이터든 담을 수 있습니다.
반면 NumPy 스타일의 고정 타입 배열은 이러한 유연함은 부족하지만, 데이터를 저장하고 처리하는 데는 비교할 수 없을 만큼 효율적입니다.
## 파이썬의 고정 타입 배열
파이썬은 데이터를 효율적인 고정 타입 버퍼에 저장할 수 있는 몇 가지 옵션을 제공합니다.
내장 `array` 모듈(파이썬 3.3부터 지원)을 사용하면 타입이 일치하는 밀집 배열을 만들 수 있습니다.
::: {#cell-17 .cell quarto-private-1='{"key":"jupyter","value":{"outputs_hidden":false}}' execution_count=6}
``` {.python .cell-code}
import array
L = list(range(10))
A = array.array("i", L)
A
```
::: {.cell-output .cell-output-display execution_count=6}
```
array('i', [0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
```
:::
:::
여기서 `'i'`는 리스트의 내용물이 정수임을 나타내는 타입 코드입니다.
하지만 이보다 훨씬 유용하고 강력한 것이 바로 NumPy 패키지의 `ndarray` 객체입니다.
파이썬의 `array` 객체가 단순히 데이터를 효율적으로 저장하기만 한다면, NumPy는 그 데이터에 대한 효율적인 *연산* 기능까지 더해줍니다.
이 연산들에 대해서는 이후 섹션에서 자세히 살펴볼 것입니다. 먼저 NumPy 배열을 만드는 다양한 방법부터 알아보죠.
## 파이썬 리스트에서 배열 만들기
먼저 NumPy를 `np`라는 별칭으로 불러옵니다.
::: {#cell-20 .cell}
``` {.python .cell-code}
import numpy as np
```
:::
이제 `np.array`를 사용해 파이썬 리스트를 배열로 바꿀 수 있습니다.
::: {#cell-22 .cell quarto-private-1='{"key":"jupyter","value":{"outputs_hidden":false}}' execution_count=8}
``` {.python .cell-code}
# 정수 배열
np.array([1, 4, 2, 5, 3])
```
::: {.cell-output .cell-output-display execution_count=8}
```
array([1, 4, 2, 5, 3])
```
:::
:::
파이썬 리스트와 달리 NumPy 배열은 단일 타입의 데이터만 담을 수 있다는 점을 꼭 기억하세요.
만약 타입이 서로 섞여 있다면, NumPy는 데이터의 손실을 최소화하는 방향으로 타입을 강제로 맞춥니다(업캐스팅). 예를 들어 정수와 실수가 섞여 있다면 모두 실수형으로 변환됩니다.
::: {#cell-24 .cell quarto-private-1='{"key":"jupyter","value":{"outputs_hidden":false}}' execution_count=9}
``` {.python .cell-code}
np.array([3.14, 4, 2, 3])
```
::: {.cell-output .cell-output-display execution_count=9}
```
array([3.14, 4. , 2. , 3. ])
```
:::
:::
배열의 데이터 타입을 직접 지정하고 싶다면 `dtype` 키워드를 사용하면 됩니다.
::: {#cell-26 .cell quarto-private-1='{"key":"jupyter","value":{"outputs_hidden":false}}' execution_count=10}
``` {.python .cell-code}
np.array([1, 2, 3, 4], dtype=np.float32)
```
::: {.cell-output .cell-output-display execution_count=10}
```
array([1., 2., 3., 4.], dtype=float32)
```
:::
:::
마지막으로 NumPy 배열은 다차원으로 확장될 수 있습니다. 다음은 리스트의 리스트를 사용해 다차원 배열을 초기화하는 예시입니다.
::: {#cell-28 .cell quarto-private-1='{"key":"jupyter","value":{"outputs_hidden":false}}' execution_count=11}
``` {.python .cell-code}
# 중첩 리스트를 사용한 다차원 배열 생성
np.array([range(i, i + 3) for i in [2, 4, 6]])
```
::: {.cell-output .cell-output-display execution_count=11}
```
array([[2, 3, 4],
[4, 5, 6],
[6, 7, 8]])
```
:::
:::
내부 리스트들이 각각 2차원 배열의 행이 됩니다.
## 처음부터 배열 만들기
특히 규모가 큰 배열을 다룰 때는 NumPy의 내장 함수를 사용해 처음부터 배열을 생성하는 것이 훨씬 효율적입니다.
자주 쓰이는 예시들을 살펴보겠습니다.
::: {#cell-31 .cell quarto-private-1='{"key":"jupyter","value":{"outputs_hidden":false}}' execution_count=12}
``` {.python .cell-code}
# 0으로 채워진 길이 10의 정수 배열 생성
np.zeros(10, dtype=int)
```
::: {.cell-output .cell-output-display execution_count=12}
```
array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0])
```
:::
:::
::: {#cell-32 .cell quarto-private-1='{"key":"jupyter","value":{"outputs_hidden":false}}' execution_count=13}
``` {.python .cell-code}
# 1로 채워진 3x5 실수형 배열 생성
np.ones((3, 5), dtype=float)
```
::: {.cell-output .cell-output-display execution_count=13}
```
array([[1., 1., 1., 1., 1.],
[1., 1., 1., 1., 1.],
[1., 1., 1., 1., 1.]])
```
:::
:::
::: {#cell-33 .cell quarto-private-1='{"key":"jupyter","value":{"outputs_hidden":false}}' execution_count=14}
``` {.python .cell-code}
# 3.14로 가득 채워진 3x5 배열 생성
np.full((3, 5), 3.14)
```
::: {.cell-output .cell-output-display execution_count=14}
```
array([[3.14, 3.14, 3.14, 3.14, 3.14],
[3.14, 3.14, 3.14, 3.14, 3.14],
[3.14, 3.14, 3.14, 3.14, 3.14]])
```
:::
:::
::: {#cell-34 .cell quarto-private-1='{"key":"jupyter","value":{"outputs_hidden":false}}' execution_count=15}
``` {.python .cell-code}
# 0부터 시작해 2씩 증가하며 20 미만까지 이어지는 수열 배열 생성
# (파이썬의 내장 range() 함수와 비슷합니다)
np.arange(0, 20, 2)
```
::: {.cell-output .cell-output-display execution_count=15}
```
array([ 0, 2, 4, 6, 8, 10, 12, 14, 16, 18])
```
:::
:::
::: {#cell-35 .cell quarto-private-1='{"key":"jupyter","value":{"outputs_hidden":false}}' execution_count=16}
``` {.python .cell-code}
# 0과 1 사이를 일정한 간격으로 나눈 5개의 값으로 된 배열 생성
np.linspace(0, 1, 5)
```
::: {.cell-output .cell-output-display execution_count=16}
```
array([0. , 0.25, 0.5 , 0.75, 1. ])
```
:::
:::
::: {#cell-36 .cell quarto-private-1='{"key":"jupyter","value":{"outputs_hidden":false}}' execution_count=17}
``` {.python .cell-code}
# 0과 1 사이의 균등 분포에서 무작위 값을 추출한 3x3 배열 생성
np.random.random((3, 3))
```
::: {.cell-output .cell-output-display execution_count=17}
```
array([[0.09610171, 0.88193001, 0.70548015],
[0.35885395, 0.91670468, 0.8721031 ],
[0.73237865, 0.09708562, 0.52506779]])
```
:::
:::
::: {#cell-37 .cell quarto-private-1='{"key":"jupyter","value":{"outputs_hidden":false}}' execution_count=18}
``` {.python .cell-code}
# 평균 0, 표준편차 1인 정규 분포에서 무작위 값을 추출한 3x3 배열 생성
np.random.normal(0, 1, (3, 3))
```
::: {.cell-output .cell-output-display execution_count=18}
```
array([[-0.46652655, -0.59158776, -1.05392451],
[-1.72634268, 0.03194069, -0.51048869],
[ 1.41240208, 1.77734462, -0.43820037]])
```
:::
:::
::: {#cell-38 .cell quarto-private-1='{"key":"jupyter","value":{"outputs_hidden":false}}' execution_count=19}
``` {.python .cell-code}
# [0, 10) 구간에서 임의의 정수를 추출한 3x3 배열 생성
np.random.randint(0, 10, (3, 3))
```
::: {.cell-output .cell-output-display execution_count=19}
```
array([[4, 3, 8],
[6, 5, 0],
[1, 1, 4]])
```
:::
:::
::: {#cell-39 .cell quarto-private-1='{"key":"jupyter","value":{"outputs_hidden":false}}' execution_count=20}
``` {.python .cell-code}
# 3x3 단위 행렬 생성
np.eye(3)
```
::: {.cell-output .cell-output-display execution_count=20}
```
array([[1., 0., 0.],
[0., 1., 0.],
[0., 0., 1.]])
```
:::
:::
::: {#cell-40 .cell quarto-private-1='{"key":"jupyter","value":{"outputs_hidden":false}}' execution_count=21}
``` {.python .cell-code}
# 초기화되지 않은 세 개의 정수 배열 생성;
# 값은 해당 메모리 위치에 이미 존재하던 값이 들어갑니다.
np.empty(3)
```
::: {.cell-output .cell-output-display execution_count=21}
```
array([1., 1., 1.])
```
:::
:::
## NumPy 표준 데이터 타입
NumPy 배열은 단일 타입의 값만 가지므로, 각 타입의 특징과 제한 사항을 잘 아는 것이 중요합니다.
NumPy는 C 언어를 기반으로 구축되었기 때문에 C나 포트란(Fortran) 사용자들에게 익숙한 타입들이 많이 보일 것입니다.
표준 NumPy 데이터 타입들은 아래 표에 정리되어 있습니다.
배열을 만들 때 문자열을 사용해 타입을 지정합니다.
``python
np.zeros(10, dtype='int16')
혹은 NumPy 객체를 직접 사용해도 됩니다.
``python np.zeros(10, dtype=np.int16) ````
| 데이터 타입 | 설명 |
|---|---|
bool_ |
불리언 값(True 또는 False) 저장 (1바이트) |
int_ |
기본 정수 타입 (C의 long과 동일, 보통 int64 또는 int32) |
intc |
C의 int와 동일 (보통 int32 또는 int64) |
intp |
인덱싱에 쓰이는 정수 (C의 ssize_t와 동일, 보통 int32 또는 int64) |
int8 |
1바이트 정수 (-128 ~ 127) |
int16 |
정수 (-32768 ~ 32767) |
int32 |
정수 (-2147483648 ~ 2147483647) |
int64 |
정수 (-9223372036854775808 ~ 9223372036854775807) |
uint8 |
부호 없는 1바이트 정수 (0 ~ 255) |
uint16 |
부호 없는 정수 (0 ~ 65535) |
uint32 |
부호 없는 정수 (0 ~ 4294967295) |
uint64 |
부호 없는 정수 (0 ~ 18446744073709551615) |
float_ |
float64의 약칭 |
float16 |
반정밀도 부동소수점 (부호 1비트, 지수 5비트, 가수 10비트) |
float32 |
단정밀도 부동소수점 (부호 1비트, 지수 8비트, 가수 23비트) |
float64 |
배정밀도 부동소수점 (부호 1비트, 지수 11비트, 가수 52비트) |
complex_ |
complex128의 약칭 |
complex64 |
두 개의 32비트 부동소수점으로 표현되는 복소수 |
complex128 |
두 개의 64비트 부동소수점으로 표현되는 복소수 |
엔디안(big-endian 또는 little-endian) 지정 같은 더 복잡한 타입 설정도 가능합니다. 자세한 내용은 NumPy 공식 문서를 참고하세요. 또한 NumPy는 구조화된 데이터: NumPy의 구조화된 배열 섹션에서 다룰 복합 데이터 타입도 지원합니다.