[Python] 리스트(List) 자료구조 : 특징과 메서드 + 내부 동작 심층 분석

Cheon SejunCheon Sejun
6 min read

파이썬에서 가장 기본적으로 활용되는 자료구조인 리스트(List)의 특징과 메서드를 설명한다. 또한 리스트의 표면적인 사용법을 넘어, 그 내재적 특징과 핵심적인 내부 동작 원리를 깊이 있게 탐구하고 정리해 보고자 한다.

리스트의 기본적인 4가지 특성을 먼저 정의하고, 실용적인 메서드들을 정리한 뒤, 메모리 관리 방식과 연산자 오버로딩의 근간이 되는 던더(Dunder) 메서드까지 단계적으로 분석한다.

1. 리스트(List)의 4가지 핵심 특징

리스트 자료구조는 다음과 같은 네 가지 핵심적인 특징으로 정의된다.

1-1. 순서(Ordered)

리스트의 모든 요소는 명확한 순서를 가지며, 이 순서는 데이터가 추가된 순서대로 유지된다. 각 요소는 고유한 인덱스(index)를 통해 접근할 수 있으며, 이는 특정 위치의 데이터를 조회하거나 수정하는 것을 가능하게 한다.

# 인덱스는 0부터 시작한다.
my_list = [10, 20, 30, 40, 50]

# 인덱싱(Indexing): 1번 인덱스 요소에 접근
print(my_list[1]) # 출력: 20

# 슬라이싱(Slicing): 2번 인덱스부터 4번 인덱스 이전까지의 서브셋 추출
print(my_list[2:4]) # 출력: [30, 40]

순서가 보장된다는 특성은 반복문 활용의 기반이 된다. for 구문에서는 내부적으로 이터레이터(iterator) 프로토콜을 사용하여 리스트의 첫 요소부터 마지막 요소까지 순차적으로 순회하며, 모든 요소에 대한 탐색이 완료되면 자동으로 반복을 종료한다.

1-2. 중복 허용(Allows Duplicates)

리스트는 동일한 값을 가진 요소를 여러 개 포함하는 것을 허용한다.

my_list = [10, 10, 10]
print(my_list) # 출력: [10, 10, 10]

1-3. 가변성(Mutable)

리스트는 '변경 가능한(Mutable)' 객체이다. 이는 객체가 생성된 이후에도 내부 요소를 수정, 추가, 삭제하는 것이 가능하다는 의미다.

my_list = [100, 200, 300]
print(f"수정 전: {my_list}") # 출력: 수정 전: [100, 200, 300]

# 0번 인덱스의 값을 1000으로 변경
my_list[0] = 1000
print(f"수정 후: {my_list}") # 출력: 수정 후: [1000, 200, 300]

1-4. 이종 데이터 타입 허용(Heterogeneous)

하나의 리스트 안에 정수, 문자열, 또 다른 리스트나 튜플 등 서로 다른 자료형의 데이터를 함께 저장할 수 있다.

my_list = [1, "hello", [10, 20], (30, 40)]
print(my_list) # 출력: [1, 'hello', [10, 20], (30, 40)]

2. 주요 리스트 메서드(Method)

리스트 객체는 데이터 조작을 위한 다양한 내장 메서드를 정리해 보았다. 주요 메서드는 다음과 같다.

메서드설명사용 예시
append(x)리스트의 맨 마지막에 요소 x를 추가한다.l = [1, 2]; l.append(3) # [1, 2, 3]
extend(iterable)리스트에 다른 반복 가능한(iterable) 객체의 모든 요소를 추가하여 확장한다.l = [1, 2]; l.extend([3, 4]) # [1, 2, 3, 4]
insert(i, x)인덱스 i 위치에 요소 x를 삽입한다.l = [1, 2]; l.insert(1, 100) # [1, 100, 2]
remove(x)리스트에서 첫 번째로 발견되는 요소 x를 삭제한다.l = [1, 2, 1]; l.remove(1) # [2, 1]
pop(i)인덱스 i 위치의 요소를 반환하고 해당 요소를 리스트에서 삭제한다. i 생략 시 마지막 요소를 대상으로 한다.l = [1, 2, 3]; val = l.pop(1) # val=2, l=[1, 3]
clear()리스트의 모든 요소를 삭제하여 빈 리스트로 만든다.l = [1, 2, 3]; l.clear() # []
index(x)요소 x가 처음 나타나는 인덱스를 반환한다. 요소가 존재하지 않으면 ValueError가 발생한다.l = [10, 20, 30]; l.index(20) # 1
count(x)리스트 내에 요소 x가 몇 개 존재하는지 개수를 반환한다.l = [1, 1, 2, 3]; l.count(1) # 2
sort()리스트의 요소를 원본 상태에서(in-place) 정렬한다. (기본값: 오름차순)l = [3, 1, 2]; l.sort() # l은 [1, 2, 3]이 됨
reverse()리스트 요소의 순서를 원본 상태에서(in-place) 뒤집는다.l = [1, 2, 3]; l.reverse() # l은 [3, 2, 1]이 됨
copy()리스트의 얕은 복사본(shallow copy)을 생성하여 반환한다.l1 = [1, 2]; l2 = l1.copy() # l2는 [1, 2], l1과 다른 객체

3. 내부 동작 심층 분석

단순한 기능 활용을 넘어 리스트의 내부 동작 방식을 확인해 보며 몇 가지 흥미로운 지점을 발견할 수 있다.

3-1. 동일 값 요소의 메모리 주소 공유 현상

동일한 값을 가진 리스트 요소들의 메모리 주소(id())를 확인하면 모두 같은 주소를 참조하는 현상을 관찰할 수 있다.

# 리스트 내 동일한 값의 요소들은 같은 메모리 주소를 공유한다.
l = [10000, 10000, 10000]
print(f'l[0] id: {id(l[0])}')
print(f'l[1] id: {id(l[1])}')
print(f'l[2] id: {id(l[2])}')
# 출력 예:
# l[0] id: 2258941012848
# l[1] id: 2258941012848
# l[2] id: 2258941012848

이 현상은 파이썬의 변수 할당 방식과 메모리 관리 효율화 전략에서 기인한다. 리스트에서는 값이 동일할 경우 메모리를 절약하기 위해서 같은 주소 값을 참조하는 식으로 동작한다. 파이썬에서 변수는 값을 직접 저장하는 컨테이너가 아니라, 메모리에 생성된 객체를 가리키는 이름(참조)이다. 특히 숫자나 문자열과 같은 '불변(Immutable)' 객체의 경우, 파이썬은 동일한 값을 가진 객체를 중복 생성하지 않고 메모리에 이미 존재하는 객체를 재활용하는 경향이 있다.

파이썬은 효율성을 위해 -5부터 256까지의 정수를 미리 메모리에 생성해두는 '정수 인터닝(Integer Interning)' 메커니즘을 사용한다. 이 범위의 숫자는 언제나 동일한 객체를 참조하는 것이 언어 차원에서 보장된다.

정수 이터닝(Integer Interning)이란?

파이썬은 작은 범위의 정수 객체를 미리 생성해두고, 프로그램 내에서 해당 범위의 정수가 필요할 때마다 새로운 객체를 만드는 대신 이미 존재하는 객체를 재사용한다. 이렇게 하면 메모리 사용량을 줄이고, 객체 생성에 필요한 시간을 절약할 수 있다.

# -5 ~ 256 범위의 정수는 항상 같은 객체이다.
x = 10
y = 10
print(f'x is y: {x is y}') # 출력: x is y: True

하지만 일반 변수 할당에서는 이 동작이 항상 보장되지는 않으므로 주의가 필요하다.

# 일반 변수에서의 주소 비교는 주의가 필요하다.
a = 10000
b = 10000

# 값은 동일하지만(==), 주소가 동일한지(is)는 보장되지 않는다.
print(f'a == b: {a == b}') # 출력: a == b: True
print(f'a is b: {a is b}') # 실행 환경에 따라 True 또는 False

# CPython 인터프리터가 최적화를 위해 때때로 동일 객체를 재사용하기도 하지만,
# 이는 언어 명세가 아니므로 이 동작에 의존해서는 안 된다.
# 객체의 주소까지 같은지 비교할 때는 항상 `is` 연산자를 사용해야 한다.

결론적으로 리스트의 각 슬롯에는 값 자체가 아닌, 메모리상에 존재하는 객체에 대한 참조(주소값)가 저장된다. 이것이 id() 값이 동일하게 나타날 수 있는 이유다.

관련 추가 학습 주제

  • Mutable vs Immutable: 파이썬 객체의 '가변성'과 '불변성'에 따른 동작 차이를 명확히 이해하는 것은 필수적이다.

  • 얕은 복사(Shallow Copy)와 깊은 복사(Deep Copy): copy() 메서드의 동작 방식인 얕은 복사와 깊은 복사의 차이를 학습하면, 리스트 복사 시 발생 가능한 잠재적 오류를 예방할 수 있다.

3-2. 연산자와 던더 메서드(Dunder Method)

dir(list)를 실행해보면 __add__, __mul__ 과 같이 양쪽에 밑줄 두 개가 있는 특별한 형태의 메서드들을 볼 수 있다. 이를 던더(Dunder, Double Underscores) 메서드 또는 매직 메서드라고 부른다.

이 메서드들은 +, * 와 같은 파이썬의 기본 연산이나 내장 함수가 특정 객체에 사용될 때, 내부적으로 호출되도록 약속되어 있다. 리스트에 적용되는 +* 연산이 대표적인 예시.

l = [1, 2, 3]
# 아래 두 코드는 내부적으로 동일하게 동작합니다.
print(l + l)         # l.__add__(l) 호출
# 출력: [1, 2, 3, 1, 2, 3]

print(l * 3)         # l.__mul__(3) 호출
# 출력: [1, 2, 3, 1, 2, 3, 1, 2, 3]

이처럼 객체가 특정 연산자에 반응하여 미리 정의된 동작을 수행하게 하는 것을 연산자 오버로딩(Operator Overloading)이라 한다.

3-3. 객체의 문자열 표현: __str__ vs __repr__

던더 메서드는 연산자 오버로딩뿐만 아니라, 객체가 어떻게 문자열로 표현될지를 정의하는 데에도 핵심적인 역할을 한다. 이와 관련된 대표적인 던더 메서드가 __str____repr__ 이다.

둘의 차이는 커스텀 클래스를 정의해보면 명확하게 드러나는데

class A:
    def __str__(self):
        return 'hello (from __str__)'

    def __repr__(self):
        return 'world (from __repr__)'

a = A()

# print() 함수는 __str__ 메서드를 우선적으로 찾아 호출합니다.
print(a) # 출력: hello (from __str__)

# 변수를 직접 실행하면 __repr__ 메서드가 호출됩니다.
a # 출력: world (from __repr__)
  • __str__: print() 함수와 같이 '사람이 읽기 좋은(human-readable)' 형태의 비공식적인 문자열 출력이 필요할 때 사용된다.

  • __repr__: 객체를 명확히 식별할 수 있는 '공식적인(official)' 문자열 표현이 필요할 때 사용된다. 주로 개발 및 디버깅 과정에서 객체의 상태를 정확히 파악하는 데 목적이 있다. 만약 __str__이 정의되지 않았다면, print() 함수는 __repr__을 대신 호출합니다.

결론

파이썬 리스트의 핵심적인 네 가지 특징(순서, 중복, 가변성, 이종 타입 허용)을 시작으로, 주요 메서드의 기능, 그리고 내부적으로 메모리를 관리하고 연산을 처리하는 원리까지 정리해 보았다.

단순히 자료형을 사용하는 수준을 넘어, 자료구조의 내부 동작 원리를 이해하는 것은 예측 가능하고 효율적인 코드를 작성하는 데 필수적인 기반이 된다고 생각한다. 특히 객체의 메모리 참조 방식과 던더 메서드를 통한 연산자 오버로딩 개념은 파이썬의 객체 지향적 특성을 이해하는 중요한 부분이라고 생각하고 이후 추가 학습이 필요하다고 생각한다.

0
Subscribe to my newsletter

Read articles from Cheon Sejun directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Cheon Sejun
Cheon Sejun