파이썬 Sequence의 분류와 메모리 동작

박태정·5일 전

Python Deep Dive

목록 보기
3/9

이전에 파이썬 함수나 클래스에서 사용할 수 있는 매직 메서드와 튜플의 단점(?)을 보완할 수 있는 각 요소에 이름을 붙여줄 수 있는 네임드 튜플에 대해서 공부를 했었다. 오늘(2월 22일)은 Sequence에 대해 공부했다.

Sequence는 순서가 있는 데이터 구조를 의미 한다.

자료형을 구분하는 방법은 크게 두 가지가 있다.

  • 변경 가능 여부에 따른 분류
  • 저장 방식에 따른 분류

저장 방식에 따른 분류

컨테이너

CJava를 사용하면 우리는 보통 배열을 활용해서 데이터를 담는다.

그리고 Python을 활용하면 보통 리스트를 사용한다.

이 두 개를 사용해보면 처음에는 같아 보이지만 다르다는걸 금세 알아차릴 수 있다. 리스트로 정의한 변수에 값을 넣을 때는 변수의 타입을 고려하지 않고 값을 넣어도 큰 문제가 발생하지 않는다.

list_a = [1, 1.0, '코코']

파이썬에는 이런 타입의 자료형들이 있는데 대표적으로 list, tuple, collections.deque 등 이 있다. 이들을 컨테이너(Container) 라고 한다.

  • 정리하면 컨테이너는 서로 다른 자료형의 참조(주소값)를 저장

플랫

반면 배열을 사용하면 변수를 정의해줄 때 내가 그 통안에 어떤 자료형의 값들을 넣어줄지 미리 정의를 해줘야한다. 그리고 그 자료형만 넣을 수 있다.

from array import array

array_a = array('i', [1,2,3,4])

파이썬에서 Array를 사용할 때 자료형을 지정해줄 때 특정 매칭되어있는 코드를 사용해야한다. 아래와 같다.

코드의미
'i'signed int
'f'float
'd'double
'b'signed char

파이썬에는 이런 타입의 자료형들이 있는데 대표적으로 str, bytes, bytearray, array등이 있다고 한다. 이들을 플랫(Flat) 이라고 한다.'

  • 정리하면 플랫은 당닐 자료형의 값 자체를 연속된 메모리에 저장

변경 가능 여부에 따른 분류

가변(Mutable)

데이터를 저장하는 방식뿐만 아니라 '생성된 이후에 내부 값을 수정할 수 있는가'도 아주 중요한 분류 기준이 된다.

우리가 가장 흔하게 사용하는 list를 생각해보면 리스트는 한 번 만들어 둔 다음에 특정 위치의 값을 다른 값으로 원하는 대로 변경할 수 있다.

list_b = [1,2,3]
list_b[0] = 100

파이썬에는 이런 타입의 자료형들이 있는데 대표적으로 list, bytearray, array, collections.deque 등이 있다. 이들을 가변형(Mutable) 이라고 한다.

  • 정리하면 가변형은 객체가 한 번 생성된 후에도 내부의 값을 자유롭게 수정가능

불변(Immutable)

반면 튜플이나 문자열은 한 번 통 안에 값을 정의하고 나면, 그 안에 있는 특정 요소를 임의로 바꾸는 것이 불가능하다. 만약 억지로 값을 변경하려고 시도하면 에러가 발생한다.

tuple_b = (1, 2, 3)
tuple_b[0] = 100 # TypeError: 'tuple' object does not support item assignment

str_b = "hello"
str_b[0] = "H" # TypeError: 'str' object does not support item assignment

파이썬에서 이런 자료형들을 사용할 때는 값을 수정하는 것이 아니라, 아예 새로운 값을 만들어 다시 덮어씌우는 방식으로 동작하게 된다.

str_b = 'heloo'
str_b = 'hi'

파이썬에는 이런 타입의 자료형들이 있는데 대표적으로 tuple, str, bytes 등 이 있다. 이들을 불변형(Immutable) 이라고 한다.

  • 정리하면 불변형은 한 번 생성된 후에는 절대 내부의 값을 수정할 수 불가능

시퀀스 생성 방식

리스트 컴프리헨션 (List Comprehension)

우리가 보통 리스트에 값을 채워 넣을 때 가장 많이 사용하는 방식은 빈 리스트를 만들고 for문을 돌면서 append()로 하나씩 집어넣는 방식이다.

chars = 'abcdefg'
code_list1 = []

for s in chars:
    code_list1.append(s)
    
# 결과
# ['a','b','c','d','e','f','g']

하지만 파이썬에서는 이것보다 내부 속도가 좀 더 빠르고 깔끔한 방법이 있는데, 바로 리스트 컴프리헨션이다.

code_list2 = [s for s in chars]

기존의 for문과 append를 사용하는 것보다 코드가 간결해질 뿐만 아니라, 파이썬 내부적으로 최적화되어 동작하기 때문에 속도가 약간 더 우세하다. 필요하다면 map, filter 함수와 결합하거나 아예 대체할 수도 있다.

  • mapfilter는 챕터06의 제너레이터에서 더 자세히 다룬다.

  • 리스트 컴프리헨션은 리스트를 가장 파이썬답고 빠르게 만드는 최적의 방법 -> 이걸 Pythonic 하다고 말을 한다고 한다.

제네레이터

리스트 컴프리헨션이 좋긴 하지만, 만약 다뤄야 할 데이터가 1억 개라면 어떻게 될까? 리스트는 1억 개의 데이터를 메모리에 전부 올려두기 때문에 프로그램이 뻗어버릴 수 있다. 이때 사용하는 것이 제네레이터다. 리스트 컴프리헨션에서 대괄호 []를 소괄호 ()로만 바꿔주면 된다.

tuple_g = (ord(s) for s in chars)

print(tuple_g) # <generator object <genexpr> at 0x1033313c0>
print(next(tuple_g)) # 값을 호출할 때마다 하나씩 생성

제네레이터는 모든 데이터를 메모리에 미리 올려두지 않는다. 대신 어떤 값을 출력할지 준비만 하고 있다가, next()로 호출할 때마다 그 순간에 하나씩 값을 생성해서 뱉어낸다.

  • 제네레이터는 대용량 데이터 처리 시 메모리를 거의 쓰지 않는 매우 효율적인 생성 방식

  • 나는 C언어계열의 포인터 느낌을 생각하면 이해했다.

다차원 리스트 생성 시 주의점

얕은 복사

파이썬에서 2차원 리스트(배열)를 만들 때 사람들이 가장 많이 하는 실수가 바로 곱하기(*) 연산자를 사용하는 것이다.

marks2 = [['~'] * 3] * 4

이렇게 만들면 우리가 원하는 대로 4x3 크기의 리스트가 만들어진 것처럼 보인다. 하지만 특정 위치의 값을 변경해보면 대참사가 일어난다.

marks2[0][1] = 'X'
print(marks2) 
"""
결과: [
  ['~', 'X', '~'], 
  ['~', 'X', '~'], 
  ['~', 'X', '~'], 
  ['~', 'X', '~']]
"""

첫 번째 행의 값만 바꿨는데 모든 행의 값이 똑같이 바뀌어버린다.

[['~'] * 3] 이라는 리스트 객체를 딱 하나만 생성한 뒤, 그 주소값만 4번 복사해서 넣었기 때문이다. id()로 주소값을 확인해보면 4개의 행이 모두 동일한 주소를 가리키고 있다.

이게 곱하기 연산자를 활용한 다차원 리스트 생성은 주소값만 복사하는 '얕은 복사' 라서 분신술을 한 느낌이다. (같은 놈이 여러 개)

깊은 복사

이 문제를 해결하고 안전하게 다차원 리스트를 만드려면, 앞서 배운 리스트 컴프리헨션을 사용해야 한다.

marks1 = [['~'] * 3 for _ in range(4)]

marks1[0][1] = 'X'
print(marks1) 
"""
결과: [
  ['~', 'X', '~'], 
  ['~', '~', '~'], 
  ['~', '~', '~'], 
  ['~', '~', '~']]
"""

이번에는 첫 번째 행의 값만 아주 정상적으로 변경된 것을 확인할 수 있다.

컴프리헨션을 사용하면 반복문(for)이 4번 돌 때마다 매번 메모리에 독립적인 새로운 리스트를 할당하여 생성한다. id()로 확인해보면 4개의 행이 모두 다른 주소값을 가진다.

다차원 리스트를 만들 때는 컴프리헨션을 사용한 '깊은 복사' 방식을 사용하면 쉽고 안전하게 만들 수 있다.

  • 이번에 머신러닝을 배우고 AICE 시험을 준비하면서도 만들어진 DataFrame을 다른 변수에 넣어줄 때 df2 = df1.copy()를 해주지 않고 그냥 df2 = df1을 해줘서 많이 애먹었던 기억이 난다.

시퀀스란 한마디로 '순서가 있는 데이터의 묶음'을 뜻한다.

'시퀀스는 단순히 데이터를 나열하는 것 뿐인데 왜 갑자기 제네레이터나 주소값 복사 같은 내용을 다뤘을까?'

공부를 하면서 이런 의문이 들었다.

이유는 수많은 데이터를 순서대로 다루는 시퀀스의 특성상 성능, 메모리, 버그 방지가 가장 중요하기 때문이라고 한다.

  • 컴프리헨션 & 제네레이션: 대규모 데이터를 다룰 때 성능 향상 과 메모리
  • 얕은 복사 & 깊은 복사: 주소가 꼬이는 버그 방지

다시 정리하면 시퀀스는 리스트, 튜플, 배열 같이 데이터를 담는 그릇을 의미 한다. 이번에 공부하면서 이러한 시퀀스 자료들을 어떻게 하면 효율적이고 안전하게 사용하는 방법에 대해 배웠다.

0개의 댓글