Computer >> 컴퓨터 >  >> 프로그래밍 >> 프로그래밍

Python 불변(Immutable) 데이터 구조 완벽 가이드: 튜플부터 NamedTuple까지

문제 상황

Python에서 데이터가 임의로 변경되지 않도록 보호하는 불변(Immutable) 데이터 구조를 구현해야 하는 경우가 있습니다. 이 글에서는 리스트와 튜플의 차이부터 NamedTuple을 활용한 불변 클래스 작성법까지 단계별로 살펴보겠습니다.

불변 데이터 구조란 무엇인가?

불변 데이터 구조는 한 번 생성되면 값을 변경할 수 없는 자료구조입니다. 병렬 프로그래밍 환경에서 여러 스레드나 프로세스가 동시에 같은 데이터를 수정하려 할 때 발생하는 경합 조건(race condition)과 예기치 못한 버그를 방지하는 데 매우 유용합니다.

반면 리스트(Array)와 같은 가변(Mutable) 데이터 구조는 언제든지 내용을 바꿀 수 있어 편리하지만, 그만큼 데이터가 의도치 않게 변경될 위험도 함께 커집니다.

STEP 01 — 가변 리스트의 문제점 확인하기

먼저 일반적인 리스트를 만들고 값을 변경해 보겠습니다.

# STEP 01 - 가변(mutable) 리스트 생성

# 선수 명단 배열 정의
atp_players = ['Murray', 'Nadal', 'Djokovic']
print(f" *** 배열의 원본 데이터 - {atp_players}")

실행 결과:

*** 배열의 원본 데이터 - ['Murray', 'Nadal', 'Djokovic']

이제 첫 번째 요소를 'Murray'에서 'Federer'로 바꿔 보겠습니다.

# Murray를 Federer로 변경
atp_players[0] = 'Federer'
print(f" *** 수정된 배열 데이터 - {atp_players}")

실행 결과:

*** 수정된 배열 데이터 - ['Federer', 'Nadal', 'Djokovic']

결론: 리스트는 아무런 제약 없이 값이 변경되었습니다. 혼자만 이 배열을 사용한다면 편리하지만, 실제 운영 환경에서는 여러 프로그램이 동시에 이 배열에 접근해 수정할 수 있으며, 그 결과 예측하지 못한 데이터 오염이 발생할 수 있습니다.

STEP 02 — 튜플(Tuple)로 불변성 확보하기

튜플은 리스트와 달리 생성 후 값을 변경할 수 없습니다. 직접 확인해 보겠습니다.

# STEP 02 - 튜플 변경 시도

try:
    atp_players_tuple = ('Murray', 'Nadal', 'Djokovic')
    print(f" *** 튜플의 원본 데이터 - {atp_players_tuple}")
    atp_players_tuple[0] = 'Federer'
except Exception as error:
    print(f" *** 수정을 시도했지만 오류 발생 - {error}")

실행 결과:

*** 튜플의 원본 데이터 - ('Murray', 'Nadal', 'Djokovic')
*** 수정을 시도했지만 오류 발생 - 'tuple' object does not support item assignment

결론: 위에서 확인했듯이 튜플은 수정이 불가능합니다. 하지만 여기에는 한 가지 예외 사항이 있습니다.

주의! 튜플 안에 리스트가 있으면 값이 변경된다

튜플 자체는 불변이지만, 튜플 내부에 포함된 리스트는 여전히 가변 객체이므로 그 내용은 변경할 수 있습니다.

atp_players_array_in_tuple = (['Murray'], ['Nadal'], ['Djokovic'])
print(f" *** 리스트를 담은 튜플의 원본 데이터 - {atp_players_array_in_tuple}")

atp_players_array_in_tuple[0][0] = 'Federer'
print(f" *** 리스트를 담은 튜플의 수정된 데이터 - {atp_players_array_in_tuple}")

실행 결과:

*** 리스트를 담은 튜플의 원본 데이터 - (['Murray'], ['Nadal'], ['Djokovic'])
*** 리스트를 담은 튜플의 수정된 데이터 - (['Federer'], ['Nadal'], ['Djokovic'])

그렇다면 데이터를 어떻게 완전히 보호할 수 있을까요? 해답은 간단합니다. 내부의 리스트까지 모두 튜플로 변환하면 됩니다.

try:
    atp_players_tuple_in_tuple = (('Murray',), ('Nadal',), ('Djokovic',))
    print(f" *** 튜플을 담은 튜플의 원본 데이터 - {atp_players_tuple_in_tuple}")
    atp_players_tuple_in_tuple[0] = 'Federer'
except Exception as error:
    print(f" *** 수정을 시도했지만 오류 발생 - {error}")

실행 결과:

*** 튜플을 담은 튜플의 원본 데이터 - (('Murray',), ('Nadal',), ('Djokovic',))
*** 수정을 시도했지만 오류 발생 - 'tuple' object does not support item assignment

팁: 요소가 하나뿐인 튜플을 만들 때는 반드시 뒤에 쉼표(,)를 붙여야 합니다. ('Murray')처럼 쉼표 없이 괄호만 사용하면 단순 문자열로 해석됩니다.

NamedTuple로 불변 클래스 만들기

Python에는 NamedTuple이라는 훌륭한 내장 도구가 있습니다. 이를 상속하여 필드가 이름으로 지정된 불변 클래스를 손쉽게 만들 수 있습니다. 먼저 일반적인 방식으로 클래스를 작성해 보겠습니다.

# Python 전통 방식으로 그랜드슬램 우승 정보 클래스 작성
class GrandSlamsPythonWay:
    def __init__(self, player, titles):
        self.player = player
        self.titles = titles

stats = GrandSlamsPythonWay("Federer", 20)
print(f" *** Stats 정보 - {stats.player} - {stats.titles}")

실행 결과:

*** Stats 정보 - Federer - 20

과연 이 클래스는 불변일까요? 'Federer'를 'Nadal'로 바꿔서 확인해 보겠습니다.

stats.player = 'Nadal'
print(f" *** Stats 정보 - {stats.player} - {stats.titles}")

실행 결과:

*** Stats 정보 - Nadal - 20

값이 성공적으로 변경되었습니다. 즉, 일반 클래스로 만든 이 객체는 불변이 아니라는 뜻입니다. 이번에는 NamedTuple을 사용해 같은 클래스를 만들어 기본 동작을 확인해 보겠습니다.

from typing import NamedTuple

class GrandSlamsWithNamedTuple(NamedTuple):
    player: str
    titles: int

stats = GrandSlamsWithNamedTuple("Federer", 20)
print(f" *** Stats 정보 - {stats.player} - {stats.titles}")

stats.player = 'Djokovic'
print(f" *** Stats 정보 - {stats.player} - {stats.titles}")

실행 결과:

*** Stats 정보 - Federer - 20
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
in
     10 print(f" *** Stats 정보 - {stats.player} - {stats.titles}")
     11
---> 12 stats.player = 'Djokovic'
     13 print(f" *** Stats 정보 - {stats.player} - {stats.titles}")

AttributeError: can't set attribute

AttributeError: can't set attribute 오류가 발생했습니다. NamedTuple로 만든 객체는 기본적으로 불변이기 때문입니다. 덕분에 Djokovic은 20개의 그랜드슬램 타이틀에 한발 더 다가서야 하는 상황이 되었네요.

_replace() 메서드로 새로운 객체 생성하기

불변 객체의 값을 "수정"해야 한다면 어떻게 해야 할까요? NamedTuple_replace() 메서드를 제공합니다. 이 메서드는 기존 객체를 변경하는 것이 아니라, 지정한 필드만 새로운 값으로 교체한 복사본을 반환합니다.

djokovic_stats = stats._replace(player="Djokovic", titles=17)
print(f" *** djokovic_stats 정보 - {djokovic_stats.player} - {djokovic_stats.titles}")

실행 결과:

*** djokovic_stats 정보 - Djokovic - 17

원본 stats 객체는 그대로 유지되면서, 변경된 값을 가진 새로운 객체가 생성되었습니다. 이것이 함수형 프로그래밍에서 말하는 불변성을 유지한 상태 업데이트 패턴입니다.

실전 예제: 채소 가게 결제 시스템

마지막으로 지금까지 배운 내용을 모두 활용한 종합 예제를 살펴보겠습니다. 채소 가게의 구매 내역을 관리하는 소프트웨어를 작성한다고 가정해 봅시다.

from typing import Tuple, NamedTuple

# 개별 상품(가격)을 나타내는 클래스
class Prices(NamedTuple):
    id: int
    name: str
    price: int  # 달러 단위 가격

# 구매 내역을 추적하는 클래스
class Purchase(NamedTuple):
    purchase_id: int
    items: Tuple[Prices]

# 채소 상품과 해당 가격 생성
carrot = Prices(1, "carrot", 2)
tomato = Prices(2, "tomato", 3)
eggplant = Prices(3, "eggplant", 5)

# 첫 번째 고객 Tom 씨는 당근과 토마토를 구매했습니다
tom_order = Purchase(1, (carrot, tomato))

# Tom 씨에게 청구할 총 금액 계산
total_cost = sum(item.price for item in tom_order.items)
print(f"*** Tom 씨의 총 결제 금액 - {total_cost}$")

실행 결과

*** Tom 씨의 총 결제 금액 - 5$

마무리 정리

이 글에서 다룬 핵심 내용을 정리하면 다음과 같습니다.

  • 리스트는 언제든 값이 변경되는 가변 자료구조이므로, 공유 데이터로 사용할 때 주의가 필요합니다.
  • 튜플은 불변이지만, 내부에 리스트가 포함되면 해당 부분은 변경 가능합니다. 완전한 불변성을 원하면 모든 요소를 튜플로 구성해야 합니다.
  • NamedTuple은 타입 힌트와 함께 불변 클래스를 간결하게 정의할 수 있으며, 값 변경이 필요할 때는 _replace()로 새 객체를 생성합니다.

불변 데이터 구조를 적극적으로 활용하면 병렬 처리 환경에서의 버그를 줄이고, 코드의 예측 가능성과 안정성을 크게 높일 수 있습니다.