Python & AI Tutorials Logo
Python 프로그래밍

18. Python 데이터와 객체 모델: 참조, 비교, 복사

Python이 데이터를 저장하고 관리하는 방식을 이해하는 것은 올바른 프로그램을 작성하는 데 매우 중요합니다. 이 장에서는 Python의 객체 모델(object model)—Python에서 모든 데이터가 어떻게 동작하는지를 지배하는 근본적인 시스템—을 살펴보겠습니다. 어떤 대입(assignment)은 독립적인 복사본을 만들고 어떤 대입은 공유 참조(reference)를 만드는지, 객체를 올바르게 비교하는 방법, 컬렉션을 다룰 때 흔한 함정을 피하는 방법을 배우게 됩니다.

이 지식은 여러분이 이미 겪었을지도 모르는 놀라운 동작을 이해하는 데 도움이 됩니다. 예를 들어, 왜 한 리스트를 수정하면 다른 리스트에도 가끔 영향을 주는지, 또는 두 리스트를 ==로 비교하는 것과 is로 비교하는 것이 왜 다른 결과를 주는지 같은 것들입니다.

18.1) Python에서는 모든 것이 객체입니다

Python에서는 모든 데이터는 객체(object) 입니다. 이는 단지 이론적인 개념이 아니라, 프로그램이 동작하는 방식에 실질적인 영향을 줍니다.

숫자, 문자열, 리스트, 또는 그 외 어떤 값이든 만들면 Python은 메모리에 객체(object) 를 생성합니다. 객체는 다음을 담는 컨테이너입니다:

  • 실제 데이터( 값(value) )
  • 그것이 어떤 종류의 데이터인지에 대한 정보( 타입(type) )
  • 고유 식별자( 동일성(identity) )

직접 확인해 봅시다:

python
# 서로 다른 타입의 객체 생성하기
number = 42
text = "Hello"
items = [1, 2, 3]
 
# 이 변수들 각각은 메모리의 어떤 객체를 가리킵니다
print(number)  # Output: 42
print(text)    # Output: Hello
print(items)   # Output: [1, 2, 3]

정수처럼 단순한 값도 객체입니다. 이는 단순히 숫자를 저장하는 것 이상의 기능을 가진다는 뜻입니다:

python
# 정수는 메서드를 가진 객체입니다
number = 42
print(number.bit_length())  # Output: 6
 
# 문자열은 메서드를 가진 객체입니다
text = "hello"
print(text.upper())  # Output: HELLO
 
# 리스트는 메서드를 가진 객체입니다
items = [3, 1, 2]
items.sort()
print(items)  # Output: [1, 2, 3]

왜 이것이 중요할까요? 변수를 대입하거나 데이터를 함수에 전달할 때 객체를 복사하는 것이 아니라, 같은 객체에 대한 참조(reference) 를 만드는 것이기 때문입니다. 이는 일부 다른 프로그래밍 언어가 동작하는 방식과 근본적으로 다르며, 이 차이를 이해하면 혼란스러운 버그를 많이 예방할 수 있습니다.

python
# 리스트 객체 생성하기
original = [1, 2, 3]
 
# 이것은 새 리스트를 만들지 않습니다 - 또 다른 참조를 만들 뿐입니다
# 동일한 리스트 객체를 가리키도록 합니다
another_name = original
 
# 한 참조를 통해 수정하면 다른 쪽에도 영향을 줍니다
another_name.append(4)
 
print(original)      # Output: [1, 2, 3, 4]
print(another_name)  # Output: [1, 2, 3, 4]

originalanother_name은 메모리에서 같은 리스트 객체를 가리킵니다. another_name을 통해 리스트를 수정하면, 둘 다 같은 객체를 보고 있기 때문에 original에서도 변경이 보입니다.

변수: original

리스트 객체: 1, 2, 3, 4

변수: another_name

이 동작은 참조 의미(reference semantics) 라고 하며, Python 프로그래밍에서 가장 중요한 개념 중 하나입니다. 이 장 전체에서 이를 깊이 있게 살펴보겠습니다.

18.2) 객체의 동일성, 타입, 값

Python의 모든 객체는 이를 정의하는 세 가지 근본적인 특성인 동일성(identity), 타입(type), 값(value) 을 가집니다. 이 특성들을 이해하면 객체가 어떻게 동작하는지, 그리고 객체를 올바르게 비교하는 방법을 더 잘 추론할 수 있습니다.

18.2.1) id()로 객체 동일성 확인하기

객체의 동일성(identity) 은 객체가 생성될 때 Python이 부여하는 고유한 숫자입니다. 이 동일성은 객체의 생애 동안 절대 바뀌지 않으며, 메모리의 영구적인 주소 같은 것입니다.

id() 함수를 사용하면 객체의 동일성을 가져올 수 있습니다:

python
# 객체 생성 후 동일성 확인하기
x = [1, 2, 3]
y = [1, 2, 3]
z = x
 
print(id(x))  # Output: 140234567890123 (example - actual number varies)
print(id(y))  # Output: 140234567890456 (different from x)
print(id(z))  # Output: 140234567890123 (same as x)

실제로 보이는 숫자는 프로그램을 실행할 때마다 달라지지만, 패턴은 동일합니다. xy는 같은 값을 담고 있더라도 서로 다른 객체이기 때문에 동일성이 다릅니다. 반면 z는 같은 객체에 대한 또 다른 이름일 뿐이라서 x와 동일성이 같습니다.

다음은 동일성이 왜 중요한지 보여주는 실용적인 예시입니다:

python
# 같은 성적을 가진 두 학생
student1_grades = [85, 90, 92]
student2_grades = [85, 90, 92]
 
# 이들은 서로 다른 객체입니다(동일성이 다름)
print(id(student1_grades))  # Output: 140234567890123 (example)
print(id(student2_grades))  # Output: 140234567890456 (different)
 
# 하나를 수정해도 다른 하나에는 영향을 주지 않습니다
student1_grades.append(88)
print(student1_grades)  # Output: [85, 90, 92, 88]
print(student2_grades)  # Output: [85, 90, 92]

이제 다른 시나리오를 생각해 봅시다:

python
# 한 학생의 성적을 두 변수가 추적하는 경우
original_grades = [85, 90, 92]
backup_reference = original_grades
 
# 이들은 같은 객체를 가리킵니다(동일성이 같음)
print(id(original_grades))    # Output: 140234567890123 (example)
print(id(backup_reference))   # Output: 140234567890123 (same!)
 
# 어느 이름으로 수정하든 둘 다 영향을 받습니다
backup_reference.append(88)
print(original_grades)     # Output: [85, 90, 92, 88]
print(backup_reference)    # Output: [85, 90, 92, 88]

핵심 통찰: 두 변수가 같은 동일성을 가지면, 메모리에서 정확히 같은 객체를 가리킵니다. 수정되는 객체는 하나뿐이므로, 한 변수를 통해 만든 변경이 다른 변수에서도 그대로 보입니다.

18.2.2) type()으로 객체 타입 확인하기

객체의 타입(type) 은 어떤 종류의 데이터를 담는지, 그리고 어떤 연산을 수행할 수 있는지를 결정합니다. 3장에서 배웠듯이, type() 함수를 사용하면 객체의 타입을 확인할 수 있습니다:

python
# 서로 다른 타입의 객체
number = 42
text = "Hello"
items = [1, 2, 3]
mapping = {"name": "Alice"}
 
print(type(number))   # Output: <class 'int'>
print(type(text))     # Output: <class 'str'>
print(type(items))    # Output: <class 'list'>
print(type(mapping))  # Output: <class 'dict'>

객체의 타입은 생성 이후 절대 바뀌지 않습니다. 정수를 문자열로 바꾸는 것이 아니라, 정수의 값을 기반으로 새 문자열 객체를 만들 수 있을 뿐입니다:

python
# 타입은 생성 시 고정됩니다
x = 42
print(type(x))  # Output: <class 'int'>
 
# 이것은 x의 타입을 바꾸지 않습니다 - 새로운 문자열 객체를 만들고
# 대신 x가 그 새 객체를 가리키게 합니다
x = str(x)
# 원래 정수 객체(42)는 가비지 컬렉션될 때까지 메모리에 존재합니다
# 이제 x는 완전히 다른 객체, 즉 문자열 "42"를 가리킵니다
 
print(type(x))  # Output: <class 'str'>
print(x)        # Output: 42 (이제는 정수가 아니라 문자열)

타입을 이해하는 것은 매우 중요합니다. 타입마다 지원하는 연산이 다르기 때문입니다:

python
# 리스트는 append를 지원합니다
grades = [85, 90]
grades.append(92)
print(grades)  # Output: [85, 90, 92]
 
# 문자열에는 append가 없습니다 - 불변(immutable)입니다
text = "Hello"
# text.append(" World")  # AttributeError: 'str' object has no attribute 'append'
 
# 하지만 문자열은 연결(concatenation)을 지원합니다
text = text + " World"
print(text)  # Output: Hello World

18.2.3) 객체 값

객체의 값(value) 은 객체가 실제로 담고 있는 데이터입니다. 동일성과 타입과 달리, 값은 가변(mutable) 객체(리스트와 딕셔너리 등)에서는 바뀔 수 있지만, 불변(immutable) 객체(정수와 문자열 등)에서는 바뀔 수 없습니다.

python
# 가변 객체는 값이 바뀔 수 있습니다
shopping_cart = ["milk", "bread"]
print(shopping_cart)  # Output: ['milk', 'bread']
 
shopping_cart.append("eggs")
print(shopping_cart)  # Output: ['milk', 'bread', 'eggs']
# 같은 객체(같은 동일성), 다른 값
 
# 불변 객체는 값이 바뀔 수 없습니다
count = 5
print(count)  # Output: 5
 
count = count + 1
print(count)  # Output: 6
# 이는 새로운 동일성을 가진 새 객체를 생성했습니다

다음은 세 가지 특성을 모두 보여주는 완전한 예시입니다:

python
# 리스트 객체 생성하기
data = [10, 20, 30]
 
print("Identity:", id(data))      # Output: Identity: 140234567890123 (example)
print("Type:", type(data))        # Output: Type: <class 'list'>
print("Value:", data)             # Output: Value: [10, 20, 30]
 
# 값 수정하기(동일성과 타입은 그대로 유지됨)
data.append(40)
 
print("Identity:", id(data))      # Output: Identity: 140234567890123 (unchanged)
print("Type:", type(data))        # Output: Type: <class 'list'> (unchanged)
print("Value:", data)             # Output: Value: [10, 20, 30, 40] (changed)

객체

동일성: 고유 ID

타입: class 'list'

값: 10, 20, 30, 40

절대 바뀌지 않음

절대 바뀌지 않음

가변 타입에서는 바뀔 수 있음

이 세 가지 특성을 이해하면, 프로그램에서 객체가 어떻게 동작할지 예측하는 데 도움이 됩니다. 동일성은 두 변수가 같은 객체를 가리키는지 알려주고, 타입은 어떤 연산이 허용되는지 알려주며, 값은 객체가 현재 담고 있는 데이터가 무엇인지 알려줍니다.

18.3) 가변 타입과 불변 타입

Python에서 가장 중요한 구분 중 하나는 가변(mutable) 타입과 불변(immutable) 타입의 구분입니다. 이 구분은 객체를 바꾸려 할 때 어떻게 동작하는지에 영향을 주며, 이를 이해하면 많은 흔한 프로그래밍 오류를 예방할 수 있습니다.

18.3.1) 불변 타입: 바뀔 수 없는 값

불변(immutable) 객체는 생성 후 값이 바뀔 수 없는 객체입니다. 불변 객체를 수정하는 것처럼 보이는 연산을 수행하면, Python은 실제로 수정된 값을 가진 새 객체를 생성합니다.

Python의 불변 타입에는 다음이 포함됩니다:

  • 정수 (int)
  • 부동소수점 수 (float)
  • 문자열 (str)
  • 튜플 (tuple)
  • 불리언 (bool)
  • None (NoneType)

정수에서 불변성이 어떻게 작동하는지 봅시다:

python
# 정수 생성하기
x = 100
print("Original x:", x)           # Output: Original x: 100
print("Identity of x:", id(x))    # Output: Identity of x: 140234567890123 (example)
 
# x를 수정하는 것처럼 보이지만, 실제로는 새 객체를 생성합니다
x = x + 1
print("Modified x:", x)           # Output: Modified x: 101
print("Identity of x:", id(x))    # Output: Identity of x: 140234567890456 (different!)

x = x + 1은 값이 101인 완전히 새로운 정수 객체를 만들었기 때문에 동일성이 바뀌었습니다. 값이 100인 원래 객체는 (Python의 가비지 컬렉터가 제거하기 전까지) 여전히 존재하지만, 이제 x는 다른 객체를 가리킵니다.

문자열은 불변성을 더 명확하게 보여줍니다:

python
# 문자열 생성하기
message = "Hello"
print("Original:", message)        # Output: Original: Hello
print("Identity:", id(message))    # Output: Identity: 140234567890789 (example)
 
# 문자열 메서드는 원본을 수정하지 않고 새 문자열을 반환합니다
uppercase = message.upper()
print("Original:", message)        # Output: Original: Hello (unchanged)
print("Uppercase:", uppercase)     # Output: Uppercase: HELLO
print("Identity of original:", id(message))    # Output: Identity of original: 140234567890789 (same)
print("Identity of uppercase:", id(uppercase)) # Output: Identity of uppercase: 140234567891012 (different)

문자열을 수정하는 것처럼 보이는 연산도 실제로는 새 문자열 객체를 생성합니다:

python
# 연결(concatenation)로 문자열 만들기
text = "Python"
print("Before:", text, "- ID:", id(text))  # Output: Before: Python - ID: 140234567891234 (example)
 
text = text + " Programming"
print("After:", text, "- ID:", id(text))   # Output: After: Python Programming - ID: 140234567891567 (different)

불변성이 중요한 이유: 불변 객체는 어떤 부분에서도 실수로 수정할 수 없기 때문에, 프로그램의 서로 다른 부분 사이에서 안전하게 공유할 수 있습니다. 이는 코드를 더 예측 가능하게 만들고 추론하기 쉽게 합니다.

18.3.2) 가변 타입: 바뀔 수 있는 값

가변(mutable) 객체는 생성 후에도 새 객체를 만들지 않고 값을 바꿀 수 있는 객체입니다. 객체의 동일성은 그대로지만, 내용은 수정될 수 있습니다.

Python의 가변 타입에는 다음이 포함됩니다:

  • 리스트 (list)
  • 딕셔너리 (dict)
  • 집합 (set)

리스트로 가변성을 확인해 봅시다:

python
# 리스트 생성하기
numbers = [1, 2, 3]
print("Original:", numbers)        # Output: Original: [1, 2, 3]
print("Identity:", id(numbers))    # Output: Identity: 140234567892345 (example)
 
# 리스트 수정하기 - 같은 객체, 다른 값
numbers.append(4)
print("Modified:", numbers)        # Output: Modified: [1, 2, 3, 4]
print("Identity:", id(numbers))    # Output: Identity: 140234567892345 (same!)

새 객체를 만드는 대신 기존 리스트 객체를 수정했기 때문에 동일성이 바뀌지 않았습니다. 이는 불변 타입이 동작하는 방식과 근본적으로 다릅니다.

딕셔너리와 집합도 가변입니다:

python
# 딕셔너리 예시
student = {"name": "Alice", "grade": 85}
print("Before:", student, "- ID:", id(student))  # Output: Before: {'name': 'Alice', 'grade': 85} - ID: 140234567893012 (example)
 
student["grade"] = 90  # 딕셔너리 수정하기
print("After:", student, "- ID:", id(student))   # Output: After: {'name': 'Alice', 'grade': 90} - ID: 140234567893012 (same)
 
# 집합 예시
unique_numbers = {1, 2, 3}
print("Before:", unique_numbers, "- ID:", id(unique_numbers))  # Output: Before: {1, 2, 3} - ID: 140234567893345 (example)
 
unique_numbers.add(4)  # 집합 수정하기
print("After:", unique_numbers, "- ID:", id(unique_numbers))   # Output: After: {1, 2, 3, 4} - ID: 140234567893345 (same)

18.3.3) 실무에서 가변성이 중요한 이유

가변 타입과 불변 타입의 차이는 여러 변수가 같은 객체를 가리킬 때 결정적으로 중요해집니다:

python
# 불변 예시 - 안전한 공유
x = "Hello"
y = x  # y는 같은 문자열 객체를 가리킵니다
 
# x를 "수정"하면 새 객체가 만들어집니다
x = x + " World"
 
print(x)  # Output: Hello World
print(y)  # Output: Hello (unchanged - y still refers to the original)
python
# 가변 예시 - 공유된 수정
list1 = [1, 2, 3]
list2 = list1  # list2는 동일한 리스트 객체를 가리킵니다
 
# list1을 통해 수정하면 list2에도 영향을 줍니다
list1.append(4)
 
print(list1)  # Output: [1, 2, 3, 4]
print(list2)  # Output: [1, 2, 3, 4] (also changed!)

불변 타입

int, float, str, tuple, bool, None

값이 바뀔 수 없음

연산이 새 객체를 생성함

공유해도 안전함

가변 타입

list, dict, set

값이 바뀔 수 있음

연산이 기존 객체를 수정함

공유 시 주의 필요

가변성을 이해하는 것은 다음에 필수적입니다:

  1. 동작 예측: 어떤 연산이 새 객체를 만들고 어떤 연산이 기존 객체를 수정하는지 알기
  2. 버그 방지: 객체가 공유될 때 의도치 않은 수정이 발생하는 것을 막기
  3. 효율적인 코드 작성: 사용 사례에 맞는 올바른 타입 선택하기
  4. 함수 동작 이해: 함수 인자가 언제 수정될 수 있는지 알기

다음 절들에서는 이런 서로 다른 타입에서 대입이 어떻게 동작하는지, 그리고 필요할 때 독립적인 복사본을 만드는 방법을 살펴보겠습니다.

18.4) 객체에서 대입이 동작하는 방식

Python에서 대입(assignment)은 객체를 복사하지 않고, 객체에 대한 참조(reference) 를 만듭니다. 이 차이를 이해하는 것은 올바른 프로그램을 작성하는 데 매우 중요하며, 특히 가변 타입을 다룰 때 그렇습니다.

18.4.1) 대입은 복사본이 아니라 참조를 만듭니다

x = y라고 쓸 때, Python은 y가 가리키는 객체의 복사본을 만들지 않습니다. 대신 xy가 가리키는 것과 같은 객체를 가리키도록 합니다. 두 변수는 메모리에서 같은 객체의 이름이 됩니다.

먼저 불변 객체로 살펴봅시다:

python
# 정수(불변)로 대입하기
a = 100
b = a  # 이제 b는 a와 같은 정수 객체를 가리킵니다
 
print("a:", a)           # Output: a: 100
print("b:", b)           # Output: b: 100
print("Same object?", id(a) == id(b))  # Output: Same object? True
 
# a를 "수정"하면 새 객체가 생성됩니다
a = a + 1
 
print("a:", a)           # Output: a: 101
print("b:", b)           # Output: b: 100 (unchanged)
print("Same object?", id(a) == id(b))  # Output: Same object? False

불변 객체에서는 보통 이 동작이 안전합니다. 원래 객체를 수정할 수 없기 때문입니다. 값을 바꾸는 연산을 수행하면, Python이 새 객체를 생성합니다.

하지만 가변 객체에서는 동작이 매우 다릅니다:

python
# 리스트(가변)로 대입하기
list1 = [1, 2, 3]
list2 = list1  # list2는 list1과 같은 리스트 객체를 가리킵니다
 
print("list1:", list1)   # Output: list1: [1, 2, 3]
print("list2:", list2)   # Output: list2: [1, 2, 3]
print("Same object?", id(list1) == id(list2))  # Output: Same object? True
 
# list1을 통해 수정하면 list2에도 영향을 줍니다
list1.append(4)
 
print("list1:", list1)   # Output: list1: [1, 2, 3, 4]
print("list2:", list2)   # Output: list2: [1, 2, 3, 4] (also changed!)
print("Same object?", id(list1) == id(list2))  # Output: Same object? True

list1list2는 같은 리스트 객체의 이름입니다. 어느 이름을 통해 리스트를 수정하든 리스트는 하나뿐이므로, 두 이름 모두에서 변경이 보입니다.

불변 타입에서의 대입

처음에는 두 변수가 같은 객체를 가리킴

연산이 새 객체를 생성함

변수들이 서로 독립적이 됨

가변 타입에서의 대입

두 변수가 같은 객체를 가리킴

연산이 공유된 객체를 수정함

두 변수 모두에서 변경이 보임

왜 이것이 중요한지 보여주는 실용적인 예시입니다:

python
# 학생 성적 관리하기
alice_grades = [85, 90, 92]
backup_grades = alice_grades  # 백업을 만들려고 시도함
 
print("Original:", alice_grades)  # Output: Original: [85, 90, 92]
print("Backup:", backup_grades)   # Output: Backup: [85, 90, 92]
 
# 새 성적 추가하기
alice_grades.append(88)
 
# "백업"도 함께 수정되었습니다!
print("Original:", alice_grades)  # Output: Original: [85, 90, 92, 88]
print("Backup:", backup_grades)   # Output: Backup: [85, 90, 92, 88]

이는 백업이 전혀 아닙니다—두 변수는 같은 리스트를 가리킵니다. 진짜 백업을 만들려면 복사(copy)를 만들어야 합니다(18.8절에서 다룹니다).

18.4.2) 함수 호출에서의 대입

인자를 함수에 전달할 때도 Python은 같은 참조 의미(reference semantics)를 사용합니다. 매개변수(parameter)는 같은 객체에 대한 또 다른 이름이 됩니다:

python
# 불변 매개변수를 갖는 함수
def increment(number):
    number = number + 1  # 새 객체를 생성함
    return number
 
value = 5
result = increment(value)
 
print("Original value:", value)    # Output: Original value: 5 (unchanged)
print("Returned result:", result)  # Output: Returned result: 6

매개변수 number는 처음에는 value와 같은 정수 객체를 가리킵니다. number = number + 1을 수행하면 새 정수 객체가 생성되고 number가 그것을 가리키게 됩니다. 원래 객체(그리고 value)는 바뀌지 않습니다.

가변 객체에서는 동작이 다릅니다:

python
# 가변 매개변수를 갖는 함수
def add_item(items, new_item):
    items.append(new_item)  # 원본 리스트를 수정함
 
shopping_list = ["milk", "bread"]
add_item(shopping_list, "eggs")
 
print("Original list:", shopping_list)  # Output: Original list: ['milk', 'bread', 'eggs']

매개변수 itemsshopping_list와 같은 리스트 객체를 가리킵니다. items를 통해 리스트를 수정하면 원본 리스트가 수정됩니다.

다음은 흔한 실수와 이를 피하는 방법입니다:

python
# 실수: 원본을 의도치 않게 수정함
def process_grades(grades):
    grades.append(100)  # 원본을 수정함!
    return grades
 
student_grades = [85, 90, 92]
processed = process_grades(student_grades)
 
print("Original:", student_grades)  # Output: Original: [85, 90, 92, 100] (modified!)
print("Processed:", processed)      # Output: Processed: [85, 90, 92, 100]
 
# 올바른 방법: 원본을 수정하고 싶지 않다면 복사본을 만듭니다
def process_grades_safely(grades):
    # 같은 요소를 가진 새 리스트를 생성하기
    result = grades + [100]  # 연결은 새 리스트를 생성합니다
    return result
 
student_grades = [85, 90, 92]
processed = process_grades_safely(student_grades)
 
print("Original:", student_grades)  # Output: Original: [85, 90, 92] (unchanged)
print("Processed:", processed)      # Output: Processed: [85, 90, 92, 100]

가변 기본 인자에 대한 중요한 참고: 관련된 흔한 함정으로, 가변 객체를 기본 매개변수 값으로 사용하는 경우(예: def func(items=[]):)가 있습니다. 기본 매개변수는 함수가 정의될 때 한 번만 생성되며 호출될 때마다 생성되지 않기 때문에, 기본 리스트가 여러 번의 함수 호출에 걸쳐 값을 누적하는 예기치 않은 동작이 발생할 수 있습니다. 이는 20장에서 자세히 살펴보겠지만, 가변 매개변수를 다룰 때 버그의 빈번한 원인이라는 점을 알아두세요.

18.5) 참조 의미와 객체 별칭

참조 의미(reference semantics) 란 Python에서 변수가 값을 담는 컨테이너가 아니라 객체를 가리키는 이름이라는 뜻입니다. 여러 변수가 같은 객체를 가리킬 때 이를 별칭(aliasing) 이라고 합니다. 별칭을 이해하는 것은 프로그램의 동작을 예측하는 데 필수적입니다.

18.5.1) 별칭이란?

별칭(aliasing) 은 둘 이상의 변수가 메모리에서 같은 객체를 가리킬 때 발생합니다. 변수들은 서로의 "별칭"—같은 것을 가리키는 서로 다른 이름—이 됩니다.

간단한 예시로 별칭을 확인해 봅시다:

python
# 리스트와 별칭 생성하기
original = [1, 2, 3]
alias = original  # alias는 original과 같은 리스트를 가리킵니다
 
print("Original:", original)  # Output: Original: [1, 2, 3]
print("Alias:", alias)        # Output: Alias: [1, 2, 3]
print("Same object?", id(original) == id(alias))  # Output: Same object? True
 
# 별칭을 통해 수정하기
alias.append(4)
 
# 두 이름 모두에서 변경이 보입니다
print("Original:", original)  # Output: Original: [1, 2, 3, 4]
print("Alias:", alias)        # Output: Alias: [1, 2, 3, 4]

메모리에는 리스트 객체가 하나뿐이지만, 그 객체에는 originalalias라는 두 이름이 있습니다. 어느 이름으로 수정하든 같은 기반 객체에 영향을 줍니다.

다음은 학생 기록을 사용한 더 현실적인 예시입니다:

python
# 별칭이 있는 학생 데이터베이스
students = {
    "alice": {"name": "Alice", "grade": 85},
    "bob": {"name": "Bob", "grade": 90}
}
 
# Alice의 레코드에 대한 별칭 만들기
alice_record = students["alice"]
 
print("Alice's grade:", alice_record["grade"])  # Output: Alice's grade: 85
 
# 별칭을 통해 수정하기
alice_record["grade"] = 95
 
# 원래 딕셔너리에서도 변경이 보입니다
print("Updated grade:", students["alice"]["grade"])  # Output: Updated grade: 95

변수 alice_recordstudents["alice"]에 저장된 딕셔너리에 대한 별칭입니다. alice_record를 수정하면, students 딕셔너리에 저장된 같은 딕셔너리를 수정하는 것입니다.

18.5.2) is 연산자로 별칭 감지하기

is 연산자를 사용하면 두 변수가 별칭(같은 객체를 가리킴)인지 확인할 수 있습니다:

python
# 별칭 확인하기
list1 = [1, 2, 3]
list2 = list1      # 별칭
list3 = [1, 2, 3]  # 값은 같지만 다른 객체
 
print("list1 is list2:", list1 is list2)  # Output: list1 is list2: True (aliases)
print("list1 is list3:", list1 is list3)  # Output: list1 is list3: False (different objects)
print("list1 == list3:", list1 == list3)  # Output: list1 == list3: True (same value)

is 연산자는 동일성(두 변수가 같은 객체를 가리키는지)을 확인하고, == 연산자는 값(두 객체의 내용이 같은지)을 확인합니다. 이 구분은 18.6절에서 자세히 살펴보겠습니다.

18.5.3) 컬렉션에서의 별칭

객체가 컬렉션에 저장될 때 별칭은 더 복잡해집니다:

python
# 리스트의 리스트 만들기
row = [0, 0, 0]
grid = [row, row, row]  # 세 요소 모두 같은 리스트에 대한 별칭입니다!
 
print("Grid:")
for r in grid:
    print(r)
# Output:
# [0, 0, 0]
# [0, 0, 0]
# [0, 0, 0]
 
# 하나를 수정하면 모든 행에 영향을 줍니다
grid[0][0] = 1
 
print("\nAfter modification:")
for r in grid:
    print(r)
# Output:
# [1, 0, 0]
# [1, 0, 0]
# [1, 0, 0]

이는 2D 그리드를 만들려고 할 때 흔히 하는 실수입니다. 세 행이 모두 같은 리스트의 별칭이므로, 한 행을 수정하면 전부 수정됩니다.

독립적인 행을 만드는 올바른 방법은 다음과 같습니다:

python
# 독립적인 행 만들기
grid = [[0, 0, 0], [0, 0, 0], [0, 0, 0]]  # 각 행은 별도의 리스트입니다
 
print("Grid:")
for r in grid:
    print(r)
# Output:
# [0, 0, 0]
# [0, 0, 0]
# [0, 0, 0]
 
# 이제 하나를 수정해도 해당 행에만 영향을 줍니다
grid[0][0] = 1
 
print("\nAfter modification:")
for r in grid:
    print(r)
# Output:
# [1, 0, 0]
# [0, 0, 0]
# [0, 0, 0]

18.6) 타입 전반에서의 동등성, 동일성, 멤버십(==, is, in)

Python은 객체 간 비교와 관계 확인을 위해 세 가지 기본 연산자를 제공합니다: 동등성(equality)을 위한 ==, 동일성(identity)을 위한 is, 멤버십(membership)을 위한 in입니다. 각 연산자를 언제 사용해야 하는지 이해하는 것은 올바른 프로그램을 작성하는 데 매우 중요합니다.

18.6.1) ==로 동등성 확인하기(값 비교)

== 연산자는 두 객체의 값(value) 이 같은지 확인합니다. 메모리에서 같은 객체인지 여부는 중요하지 않고, 내용이 같은지만 중요합니다.

python
# ==로 값 비교하기
list1 = [1, 2, 3]
list2 = [1, 2, 3]
list3 = list1
 
print(list1 == list2)  # Output: True (same values)
print(list1 == list3)  # Output: True (same values)

list1list2는 메모리에서 서로 다른 객체이지만, 값이 같으므로 ==True를 반환합니다.

==가 다양한 타입에서 어떻게 동작하는지 보겠습니다:

python
# 서로 다른 타입에서의 동등성
print(42 == 42)              # Output: True (same integer value)
print(42 == 42.0)            # Output: True (integer equals float with same value)
print("hello" == "hello")    # Output: True (same string value)
print([1, 2] == [1, 2])      # Output: True (same list contents)
print({"a": 1} == {"a": 1})  # Output: True (same dictionary contents)
 
# 다른 값
print(42 == 43)              # Output: False
print("hello" == "Hello")    # Output: False (case-sensitive)
print([1, 2] == [2, 1])      # Output: False (order matters)

컬렉션에서는 ==깊은 비교(deep comparison) 를 수행합니다—모든 요소가 동등한지 확인합니다:

python
# 중첩 구조에서의 깊은 비교
list1 = [[1, 2], [3, 4]]
list2 = [[1, 2], [3, 4]]
 
print(list1 == list2)  # Output: True (all nested elements are equal)
 
# 내부 리스트가 서로 다른 객체여도
print(id(list1[0]) == id(list2[0]))  # Output: False (different objects)
print(list1[0] == list2[0])          # Output: True (same values)

18.6.2) is로 동일성 확인하기(객체 동일성 비교)

is 연산자는 두 변수가 메모리에서 같은 객체 를 가리키는지 확인합니다. 값이 아니라 동일성을 비교합니다.

python
# is로 동일성 비교하기
list1 = [1, 2, 3]
list2 = [1, 2, 3]
list3 = list1
 
print(list1 is list2)  # Output: False (different objects)
print(list1 is list3)  # Output: True (same object)
 
# id()로 확인하기
print(id(list1) == id(list2))  # Output: False
print(id(list1) == id(list3))  # Output: True

is를 언제 쓰나요? is의 가장 흔한 사용은 None을 확인할 때입니다:

python
# None 확인하기(올바른 방법)
def find_student(name, students):
    """Return student record or None if not found."""
    for student in students:
        if student["name"] == name:
            return student
    return None
 
students = [
    {"name": "Alice", "grade": 85},
    {"name": "Bob", "grade": 90}
]
 
result = find_student("Charlie", students)
 
# None 확인에는 'is'를 사용합니다
if result is None:
    print("Student not found")  # Output: Student not found
else:
    print(f"Found: {result}")

18.6.3) in으로 멤버십 확인하기(포함 여부 확인)

in 연산자는 어떤 값이 컬렉션에 포함되어 있는지 확인합니다. 문자열, 리스트, 튜플, 집합, 딕셔너리에서 동작합니다:

python
# 다양한 타입에서의 멤버십
print(2 in [1, 2, 3])           # Output: True
print("hello" in "hello world")  # Output: True
print("x" in {"x": 10, "y": 20}) # Output: True (checks keys)
print(5 in {1, 2, 3, 4, 5})     # Output: True

딕셔너리에서 in은 키가 존재하는지 확인합니다:

python
# 딕셔너리 멤버십 확인하기
student = {"name": "Alice", "grade": 85, "age": 20}
 
print("name" in student)    # Output: True (key exists)
print("Alice" in student)   # Output: False (value, not key)
print("grade" in student)   # Output: True (key exists)
 
# 값 확인은 .values()에 접근해야 합니다
print("Alice" in student.values())  # Output: True

not in 연산자는 부재를 확인합니다:

python
# 부재 확인하기
shopping_list = ["milk", "bread", "eggs"]
 
if "butter" not in shopping_list:
    print("Don't forget to buy butter!")  # Output: Don't forget to buy butter!

각 연산자를 언제 써야 하는지 요약:

  • 두 객체가 같은 값인지 확인하려면 == 를 사용합니다
  • 두 변수가 같은 객체를 가리키는지 확인하려면 is 를 사용합니다(대개 None 확인 또는 별칭 디버깅)
  • 어떤 값이 컬렉션에 포함되어 있는지 확인하려면 in 을 사용합니다

이 차이를 이해하면 프로그램에서 더 정확하고 올바른 비교를 작성할 수 있습니다.

18.7) 다른 객체를 포함하는 객체 비교하기

객체가 다른 객체를 포함할 때(예: 리스트 안의 리스트, 리스트를 포함하는 딕셔너리) 비교는 더 미묘해집니다. 중첩 구조를 다룰 때 Python이 어떻게 비교하는지 이해하는 것은 복잡한 데이터를 다루는 데 필수적입니다.

18.7.1) 중첩 구조에서 ==가 동작하는 방식

== 연산자는 중첩 구조에서 재귀 비교(recursive comparison) 를 수행합니다. 바깥 컨테이너뿐만 아니라 모든 중첩 객체도 비교합니다:

python
# 중첩 리스트 비교하기
list1 = [[1, 2], [3, 4]]
list2 = [[1, 2], [3, 4]]
 
print(list1 == list2)  # Output: True
 
# 내부 리스트가 서로 다른 객체여도
print(id(list1[0]) == id(list2[0]))  # Output: False
print(list1[0] == list2[0])          # Output: True

Python은 각 요소를 재귀적으로 비교합니다. list1 == list2True가 되려면 중첩된 요소를 포함해 대응되는 모든 요소가 동등해야 합니다.

더 복잡한 예시입니다:

python
# 여러 단계의 중첩 구조
data1 = {
    "students": [
        {"name": "Alice", "grades": [85, 90, 92]},
        {"name": "Bob", "grades": [88, 91, 87]}
    ],
    "class": "Python 101"
}
 
data2 = {
    "students": [
        {"name": "Alice", "grades": [85, 90, 92]},
        {"name": "Bob", "grades": [88, 91, 87]}
    ],
    "class": "Python 101"
}
 
print(data1 == data2)  # Output: True

Python은 다음을 비교합니다:

  1. 최상위에서의 딕셔너리 키와 값("students", "class")
  2. 학생 리스트
  3. 각 학생 딕셔너리("name", "grades" 키)
  4. 각 학생의 성적 리스트
  5. 각 개별 성적 숫자

모든 레벨이 일치해야 비교 결과가 True가 됩니다.

18.7.2) 시퀀스에서는 순서가 중요합니다

시퀀스(리스트와 튜플)에서는 요소의 순서가 중요합니다:

python
# 리스트에서는 순서가 중요합니다
list1 = [[1, 2], [3, 4]]
list2 = [[3, 4], [1, 2]]
 
print(list1 == list2)  # Output: False (different order)
 
# 하지만 집합에서는 순서가 중요하지 않습니다
set1 = {frozenset([1, 2]), frozenset([3, 4])}
set2 = {frozenset([3, 4]), frozenset([1, 2])}
 
print(set1 == set2)  # Output: True (sets are unordered)

18.7.3) 서로 다른 타입의 컬렉션 비교하기

서로 다른 컬렉션 타입(list, tuple, set)은 같은 요소를 포함하더라도 서로 동등하지 않습니다:

python
# 서로 다른 타입 비교하기
print([1, 2, 3] == (1, 2, 3))  # Output: False (list vs tuple)
print([1, 2, 3] == {1, 2, 3})  # Output: False (list vs set)
 
# 요소가 같아도 마찬가지입니다
list_version = [1, 2, 3]
tuple_version = (1, 2, 3)
set_version = {1, 2, 3}
 
print(list_version == tuple_version)  # Output: False
print(list_version == set_version)    # Output: False
print(tuple_version == set_version)   # Output: False

18.8) 리스트, 딕셔너리의 얕은 복사

가변 객체를 다룰 때는 의도치 않은 수정을 피하기 위해 독립적인 복사본을 만들어야 하는 경우가 자주 있습니다. 예를 들어 처리 전에 데이터를 백업할 때, 운영 데이터를 건드리지 않고 테스트 시나리오를 만들 때, 또는 원본을 수정하면 안 되는 함수에 데이터를 전달할 때입니다. Python의 복사 메커니즘이 어떻게 동작하는지 이해하면 필요할 때 진짜로 독립적인 복사본을 만들 수 있습니다.

하지만 모든 복사 방법이 완전히 독립적인 복사본을 만드는 것은 아닙니다. 얕은 복사(shallow copy)깊은 복사(deep copy) 의 차이를 이해하는 것은 미묘한 버그를 피하는 데 매우 중요합니다.

18.8.1) 얕은 복사란?

얕은 복사(shallow copy) 는 새 객체를 만들지만, 그 안에 들어 있는 객체들까지 복사하지는 않습니다. 대신 새 객체는 원본과 같은 중첩 객체들을 가리키는 참조를 담습니다.

단순한 리스트로 확인해 봅시다:

python
# 단순 리스트의 얕은 복사 만들기
original = [1, 2, 3]
copy = original.copy()  # 얕은 복사 생성
 
print("Original:", original)  # Output: Original: [1, 2, 3]
print("Copy:", copy)          # Output: Copy: [1, 2, 3]
 
# 서로 다른 객체입니다
print("Same object?", original is copy)  # Output: Same object? False
 
# 복사본을 수정해도 원본에는 영향을 주지 않습니다
copy.append(4)
 
print("Original:", original)  # Output: Original: [1, 2, 3]
print("Copy:", copy)          # Output: Copy: [1, 2, 3, 4]

정수처럼 불변 객체를 담는 단순 리스트에서는 얕은 복사가 완벽하게 동작합니다. 복사본은 원본과 독립적입니다.

하지만 중첩 구조에서는 어떻게 될까요? 얕은 복사의 한계가 드러나는 지점을 봅시다:

python
# 중첩 리스트에서의 얕은 복사
original = [[1, 2], [3, 4]]
copy = original.copy()
 
print("Original:", original)  # Output: Original: [[1, 2], [3, 4]]
print("Copy:", copy)          # Output: Copy: [[1, 2], [3, 4]]
 
# 바깥 리스트는 서로 다른 객체입니다
print("Same outer list?", original is copy)  # Output: Same outer list? False
 
# 하지만 중첩 리스트는 같은 객체입니다
print("Same nested list?", original[0] is copy[0])  # Output: Same nested list? True
 
# 중첩 리스트를 수정하면 둘 다 영향을 받습니다
copy[0].append(99)
 
print("Original:", original)  # Output: Original: [[1, 2, 99], [3, 4]]
print("Copy:", copy)          # Output: Copy: [[1, 2, 99], [3, 4]]

원본 리스트

중첩 리스트 1: 1, 2, 99

중첩 리스트 2: 3, 4

얕은 복사

18.8.2) 리스트의 얕은 복사 만들기

리스트의 얕은 복사를 만드는 방법은 여러 가지가 있습니다:

python
# 방법 1: copy() 메서드 사용
original = [[1, 2], [3, 4]]
copy1 = original.copy()
 
# 방법 2: 리스트 슬라이싱 사용
copy2 = original[:]
 
# 방법 3: list() 생성자 사용
copy3 = list(original)
 
# 세 가지 모두 얕은 복사입니다
print(copy1)  # Output: [[1, 2], [3, 4]]
print(copy2)  # Output: [[1, 2], [3, 4]]
print(copy3)  # Output: [[1, 2], [3, 4]]
 
# 바깥 리스트는 다릅니다
print(original is copy1)  # Output: False
print(original is copy2)  # Output: False
print(original is copy3)  # Output: False
 
# 하지만 내부 리스트는 공유됩니다
print(original[0] is copy1[0])  # Output: True
print(original[0] is copy2[0])  # Output: True
print(original[0] is copy3[0])  # Output: True

18.8.3) 딕셔너리의 얕은 복사 만들기

딕셔너리도 얕은 복사를 지원합니다:

python
# 방법 1: copy() 메서드 사용
original = {"name": "Alice", "grade": 85}
copy1 = original.copy()
 
# 방법 2: dict() 생성자 사용
copy2 = dict(original)
 
# 둘 다 얕은 복사입니다
print(copy1)  # Output: {'name': 'Alice', 'grade': 85}
print(copy2)  # Output: {'name': 'Alice', 'grade': 85}
 
# 서로 다른 객체입니다
print(original is copy1)  # Output: False
print(original is copy2)  # Output: False
 
# 복사본을 수정해도 원본에는 영향을 주지 않습니다
copy1["grade"] = 90
 
print("Original:", original)  # Output: Original: {'name': 'Alice', 'grade': 85}
print("Copy:", copy1)         # Output: Copy: {'name': 'Alice', 'grade': 90}

하지만 중첩 구조에서는 같은 얕은 복사 한계가 적용됩니다:

python
# 중첩 딕셔너리에서의 얕은 복사
original = {
    "name": "Alice",
    "grades": [85, 90, 92]
}
 
copy = original.copy()
 
print("Original:", original)  # Output: Original: {'name': 'Alice', 'grades': [85, 90, 92]}
print("Copy:", copy)          # Output: Copy: {'name': 'Alice', 'grades': [85, 90, 92]}
 
# 딕셔너리는 서로 다른 객체입니다
print("Same dict?", original is copy)  # Output: Same dict? False
 
# 하지만 grades 리스트는 같은 객체입니다
print("Same grades list?", original["grades"] is copy["grades"])  # Output: Same grades list? True
 
# grades 리스트를 수정하면 둘 다 영향을 받습니다
copy["grades"].append(88)
 
print("Original:", original)  # Output: Original: {'name': 'Alice', 'grades': [85, 90, 92, 88]}
print("Copy:", copy)          # Output: Copy: {'name': 'Alice', 'grades': [85, 90, 92, 88]}
© 2025. Primesoft Co., Ltd.
support@primesoft.ai