Python list vs tuple vs set: 성능·가변성 차이와 선택 기준

이 글의 핵심

리스트에서 in으로 값을 찾는 코드가 데이터가 늘수록 느려지는 이유는 선형 탐색이기 때문이고, set으로 바꾸면 해시 조회로 바뀝니다. 이처럼 세 자료구조의 차이를 내부 구현에서 설명하고, 불변인 tuple이 딕셔너리 키가 될 수 있는 이유와 메모리 차이까지 짚어 상황에 맞게 고르게 합니다.

들어가며

“리스트만 쓰면 되는 것 아닌가요?” Python을 처음 배울 때 자주 나오는 질문입니다. 이 글에서는 list, tuple, set의 차이를 명확히 이해하며, 상황에 맞는 자료구조를 선택하는 방법을 다룹니다. 비유로 말씀드리면, list는 순서가 있는 줄 서기, tuple은 한 번 찍은 스티커 사진(바꿀 수 없음), set은 중복 없이 모아 두는 주머니에 가깝습니다. “빠른 포함 검사”가 중요하면 set을 떠올리시면 됩니다.

언제 list를, 언제 tuple·set을 쓰나요?

관점listtupleset
성능끝쪽 삽입은 편함, 멤버십은 O(n)불변·해시 가능(요소가 해시 가능할 때)멤버십 평균 O(1)
사용성가변, 순서 있음키로 쓰기 좋은 불변중복 제거·집합 연산
적용 시나리오시퀀스 처리좌표·레코드유일 값, 교집합 등

세 자료구조의 차이는 결국 내부 구현의 차이에서 나옵니다. CPython에서 list와 tuple은 모두 객체 포인터의 연속 배열입니다. 인덱스로 접근하면 주소 계산 한 번으로 원소에 닿으므로 O(1)이지만, 특정 값을 찾으려면 앞에서부터 하나씩 비교해야 하므로 O(n)입니다. set은 해시 테이블입니다. 원소의 해시값으로 저장 위치를 바로 계산하므로 값 검색이 평균 O(1)이지만, 그 대가로 위치(인덱스) 개념이 없고 테이블을 넉넉하게 비워 두느라 메모리를 더 씁니다. “어떤 연산을 자주 하는가”를 먼저 정하면 선택은 대부분 여기서 결정됩니다.


list, tuple, set 비교표

특성listtupleset
가변성가변불변가변
순서유지유지보장 안 됨 (dict와 달리 3.7+에서도 삽입 순서 아님)
중복허용허용불허
인덱싱✅ O(1)✅ O(1)❌ 불가능
검색O(n)O(n)O(1) 평균
추가append O(1)❌ 불가능add O(1)
삭제remove O(n)❌ 불가능remove O(1)
메모리보통적음많음
용도일반 목록불변 데이터, 딕셔너리 키중복 제거, 집합 연산

순서 행은 특히 자주 오해되는 부분입니다. Python 3.7부터 dict는 삽입 순서를 보장하는 것이 언어 명세가 되었지만, set은 그렇지 않습니다. 작은 정수로 만든 set은 해시값이 정수 자신이라 우연히 정렬된 것처럼 출력되는 경우가 많아서({3, 1, 2}가 {1, 2, 3}으로 보임) 순서가 있다고 착각하기 쉽지만, 문자열 set은 실행할 때마다 순서가 바뀔 수도 있습니다. 문자열 해시는 보안상 이유로 프로세스마다 무작위 시드가 적용되기 때문입니다(PYTHONHASHSEED). 중복을 제거하면서 순서도 지켜야 한다면 list(dict.fromkeys(items))가 가장 간단한 관용구입니다.

또 표의 O(1)은 평균입니다. 해시 충돌이 많으면 최악의 경우 O(n)이 될 수 있지만, 내장 타입의 해시 함수는 잘 분산되어 있어 실무에서 걱정할 일은 드뭅니다. 직접 만든 클래스에 __hash__를 정의할 때 모든 객체가 같은 값을 돌려주게 만들면 이 최악의 경우가 실제로 나타납니다.


가변성 차이

list: 가변

lst = [1, 2, 3]
lst.append(4)      # ✅ [1, 2, 3, 4]
lst[0] = 10        # ✅ [10, 2, 3, 4]
lst.remove(2)      # ✅ [10, 3, 4]
  • append: 끝에 O(1) 평균으로 붙입니다(재할당이 나면 순간적으로 더 드는 경우가 있습니다).
  • 인덱스 대입: 임의 위치의 요소를 바꿉니다.
  • remove(x): 값이 x인 첫 번째 항목 하나를 제거합니다(없으면 예외).

tuple: 불변

tup = (1, 2, 3)
tup.append(4)      # ❌ AttributeError: 'tuple' object has no attribute 'append'
tup[0] = 10        # ❌ TypeError: 'tuple' object does not support item assignment
  • 한 번 만들면 요소 추가·치환이 되지 않아, 딕셔너리 키나 집합 원소로 쓰기에 안전한 경우가 많습니다(요소가 모두 해시 가능할 때).

튜플의 불변성은 “튜플이 가리키는 대상이 바뀌지 않는다”는 뜻이지 “안의 값이 절대 바뀌지 않는다”는 뜻은 아닙니다. t = ([1, 2], 3)에서 t[0] = [9]는 에러지만 t[0].append(3)은 성공해 t가 ([1, 2, 3], 3)이 됩니다. 튜플은 리스트 객체에 대한 참조를 그대로 들고 있을 뿐이기 때문입니다. 이런 튜플은 해시도 불가능해서 hash(t)나 딕셔너리 키로 쓰면 TypeError: unhashable type: 'list'가 납니다. 흥미로운 사례로 t[0] += [4]는 리스트를 실제로 바꾼 뒤에 튜플 원소 대입 단계에서 TypeError가 나서, 에러가 났는데도 값은 바뀌어 있는 기묘한 결과가 됩니다.

불변이라서 얻는 실질적인 이점도 있습니다. 튜플은 여러 곳에서 공유해도 누군가 내용을 바꿀 걱정이 없어서 함수 기본 인자나 모듈 상수로 안전하게 쓸 수 있습니다. 리스트를 함수 기본 인자로 쓰는 def f(items=[])는 호출 사이에 같은 리스트가 공유되어 값이 계속 쌓이는 유명한 버그를 만들지만, 튜플 기본값에는 이런 문제가 없습니다.

set: 가변 (하지만 순서 없음)

s = {1, 2, 3}
s.add(4)           # ✅ {1, 2, 3, 4}
s.remove(2)        # ✅ {1, 3, 4}
s[0]               # ❌ TypeError: 'set' object is not subscriptable
  • add/remove는 집합 연산에 맞춰져 있으며, 인덱스 접근은 지원하지 않습니다(순서가 보장되지 않기 때문입니다).

remove(x)는 원소가 없으면 KeyError를 던지고, discard(x)는 조용히 넘어갑니다. “있으면 지운다”가 의도라면 discard가 맞습니다. 빈 set을 만들 때 {}를 쓰면 빈 dict가 만들어지므로 반드시 set()을 써야 한다는 점도 처음에 자주 실수하는 부분입니다. 또 set을 순회하는 도중에 원소를 추가·삭제하면 RuntimeError: Set changed size during iteration이 나므로, 조건에 맞는 원소를 지우려면 s -= {x for x in s if cond(x)}처럼 새 set을 만들어 빼거나 for x in list(s):로 복사본을 순회합니다.


검색과 추가·삭제 성능

검색 성능

import time
# 데이터 준비
n = 100000
lst = list(range(n))
tup = tuple(range(n))
s = set(range(n))
# list 검색: O(n)
start = time.time()
for _ in range(1000):
    99999 in lst
print(f"list: {time.time() - start:.3f}s")  # 약 1.2s
# tuple 검색: O(n)
start = time.time()
for _ in range(1000):
    99999 in tup
print(f"tuple: {time.time() - start:.3f}s") # 약 1.1s (list보다 약간 빠름)
# set 검색: O(1)
start = time.time()
for _ in range(1000):
    99999 in s
print(f"set: {time.time() - start:.3f}s")   # 약 0.0001s (수천 배 이상 차이)

주석의 시간은 한 환경에서의 예시이고, 머신과 Python 버전에 따라 달라집니다. 중요한 것은 절대값이 아니라 증가 방식입니다. 리스트 검색은 찾는 값이 끝에 있을 때 10만 번 비교해야 하므로, 데이터가 10배가 되면 시간도 10배가 됩니다. set 검색은 데이터 크기와 거의 무관하게 해시 계산 한 번과 비교 몇 번으로 끝납니다. 짧은 구간을 정확히 재려면 time.time()보다 해상도가 높은 time.perf_counter()를 쓰거나, 반복 실행과 가비지 컬렉션 영향까지 처리해 주는 timeit 모듈(python -m timeit -s "s=set(range(100000))" "99999 in s")을 쓰는 것이 정확합니다.

실무에서 이 차이가 드러나는 전형적인 코드는 반복문 안의 멤버십 검사입니다. for order in orders: if order.user_id in banned_ids:에서 banned_ids가 리스트라면 전체 비용은 주문 수 × 차단 목록 길이가 됩니다. 데이터가 적을 때는 멀쩡하다가 두 목록이 수만 건씩 쌓이면 갑자기 몇 분씩 걸리는 배치 작업이 되는데, 이런 코드는 banned_ids = set(banned_ids) 한 줄로 해결되는 경우가 많습니다. 반대로 한 번만 검색할 거라면 set으로 변환하는 데 O(n)이 들어서 오히려 손해이므로, set 변환은 여러 번 검색할 때 이득이라는 점도 기억해 두어야 합니다.

추가/삭제 성능

# list.append: O(1) 평균, O(n) 최악 (재할당)
lst = []
for i in range(100000):
    lst.append(i)  # 빠름
# list.insert(0, x): O(n) (모든 요소 이동)
lst.insert(0, -1)  # 느림
# set.add: O(1) 평균
s = set()
for i in range(100000):
    s.add(i)  # 빠름

append가 “평균 O(1)“인 이유는 리스트가 공간이 부족할 때마다 필요한 것보다 조금 더 크게 배열을 다시 할당하기 때문입니다. 재할당 자체는 모든 원소를 복사하는 O(n) 작업이지만 드물게 일어나므로, 많은 append에 나눠 보면 한 번당 비용은 상수가 됩니다(분할 상환 분석). 반면 insert(0, x)와 pop(0)은 매번 모든 원소를 한 칸씩 옮겨야 하므로 O(n)입니다. 앞쪽에서 넣고 빼는 큐가 필요하다면 리스트 대신 collections.deque를 써야 하며, deque는 양쪽 끝의 추가·삭제가 모두 O(1)입니다. 리스트를 큐처럼 쓰면서 pop(0)을 반복하는 코드는 데이터가 커지면 눈에 띄게 느려지는 흔한 성능 문제입니다.


메모리 사용량

메모리 비교

import sys
data = range(10000)
lst = list(data)
tup = tuple(data)
s = set(data)
print(f"list: {sys.getsizeof(lst):,} bytes")   # 약 80,056 bytes (64비트 CPython 기준)
print(f"tuple: {sys.getsizeof(tup):,} bytes")  # 약 80,040 bytes
print(f"set: {sys.getsizeof(s):,} bytes")      # 약 524,504 bytes (6배 이상)

왜 차이가 나나?

  • tuple: 크기가 고정이라 원소 포인터 배열을 객체 안에 바로 담음, 여유 공간 없음
  • list: 포인터 배열을 별도로 두고, append로 키울 때는 여유 공간을 미리 확보
  • set: 해시 테이블 오버헤드 (빠른 검색 대가)

수치는 64비트 CPython에서 원소 하나당 포인터 8바이트를 기준으로 한 값이며 버전에 따라 조금씩 다릅니다. list(range(...))처럼 길이를 미리 아는 방식으로 만든 리스트는 여유 공간 없이 딱 맞게 할당되어 튜플과 거의 같은 크기가 됩니다. 같은 1만 개를 append로 하나씩 넣어 만들면 여유 공간 때문에 조금 더 커집니다. set이 몇 배나 큰 이유는 해시 테이블이 충돌을 줄이기 위해 일정 비율 이상을 비워 두고, 각 칸에 포인터뿐 아니라 해시값까지 저장하기 때문입니다.

주의할 점은 sys.getsizeof가 컨테이너 자체의 크기만 잰다는 것입니다. 원소가 가리키는 정수·문자열 객체의 크기는 포함되지 않습니다. 1만 개의 서로 다른 문자열을 담은 리스트라면 실제 메모리 사용량은 포인터 배열 80KB에 문자열 객체들의 크기가 더해진 값입니다. 전체 크기를 알고 싶다면 tracemalloc으로 할당량을 측정하거나 pympler 같은 도구를 쓰는 편이 정확합니다. 대용량 숫자 데이터라면 리스트보다 array 모듈이나 NumPy 배열이 원소를 객체가 아닌 원시 값으로 저장해 메모리를 크게 줄여 줍니다.


상황별로 고르기

선택 플로우차트

아래 다이어그램은 결정 → 분기 → 결과 순으로 읽으시면 됩니다. 순서가 필요하면 list/tuple 쪽으로, 순서 없이 유일 값만 필요하면 set으로 가는 흐름입니다.

graph TD
    A[자료구조 선택] --> B{순서가 중요한가?}
    B -->|Yes| C{수정이 필요한가?}
    B -->|No| D[set]
    C -->|Yes| E[list]
    C -->|No| F{딕셔너리 키로 쓰나?}
    F -->|Yes| G[tuple]
    F -->|No| H{고정된 레코드인가?}
    H -->|Yes| G
    H -->|No| E

마지막 분기는 “성능”보다 의미로 판단하는 편이 맞습니다. 튜플이 리스트보다 생성과 순회가 약간 빠르고 메모리도 조금 적은 것은 사실이지만, 그 차이는 대부분의 코드에서 체감되지 않습니다. 파이썬 관례에서 튜플은 “위치마다 의미가 다른 고정 길이 레코드”(좌표 (x, y), (이름, 나이)), 리스트는 “같은 종류의 값이 여러 개 있는 목록”을 표현합니다. 이 관례를 따르면 코드를 읽는 사람이 자료구조만 보고도 용도를 짐작할 수 있습니다.

상황별 선택

# 1. 일반 목록 → list
users = ['Alice', 'Bob', 'Charlie']
users.append('David')
# 2. 불변 데이터 → tuple
point = (10, 20)  # 좌표
rgb = (255, 0, 0)  # 색상
# 3. 중복 제거 → set
unique_ids = set([1, 2, 2, 3, 3, 3])  # {1, 2, 3}
# 4. 빠른 검색 → set
allowed_users = {'alice', 'bob', 'charlie'}
if username in allowed_users:  # O(1)
    grant_access()
# 5. 딕셔너리 키 → tuple (불변만 가능)
cache = {
    (10, 20): 'result1',  # ✅ tuple
    [10, 20]: 'result2',  # ❌ TypeError: unhashable type: 'list'
}
# 6. 함수 반환값 (여러 값) → tuple
def get_user():
    return ('Alice', 25, '[email protected]')  # tuple
name, age, email = get_user()  # 언패킹

set 인덱싱, tuple 수정, list를 키로 쓰는 실수

set에 인덱싱

s = {1, 2, 3}
print(s[0])  # ❌ TypeError: 'set' object is not subscriptable
# 해결: list로 변환
print(list(s)[0])  # ✅ 하지만 순서는 보장 안 됨

“아무 원소나 하나” 꺼내면 된다면 next(iter(s))가 전체를 리스트로 복사하지 않아 훨씬 가볍습니다. 원소를 꺼내면서 제거까지 하려면 s.pop()을 쓰는데, 어떤 원소가 나올지는 역시 정해져 있지 않습니다. “가장 작은 값”이나 “처음 넣은 값”이 필요하다면 set이 맞지 않는 상황이므로 min(s)로 매번 찾거나, 정렬이 필요하면 sorted(s), 순서가 필요하면 처음부터 list나 dict를 쓰는 편이 낫습니다.

tuple 수정 시도

tup = (1, 2, 3)
tup[0] = 10  # ❌ TypeError
# 해결: 새 tuple 생성
tup = (10,) + tup[1:]  # ✅ (10, 2, 3)

list를 딕셔너리 키로 사용

cache = {}
key = [1, 2, 3]
cache[key] = 'value'  # ❌ TypeError: unhashable type: 'list'
# 해결: tuple 사용
key = (1, 2, 3)
cache[key] = 'value'  # ✅

리스트가 딕셔너리 키가 될 수 없는 이유는 해시 테이블의 전제 때문입니다. 딕셔너리는 키를 넣을 때 해시값으로 저장 위치를 정하는데, 키로 쓴 리스트가 나중에 바뀌면 해시값도 바뀌어 그 키를 다시는 찾을 수 없게 됩니다. 그래서 Python은 가변 컨테이너에 __hash__를 아예 정의하지 않습니다. 이 에러는 함수 인자를 캐시 키로 쓸 때 가장 자주 만납니다. functools.lru_cache로 감싼 함수에 리스트를 넘기면 TypeError: unhashable type: 'list'가 나므로, 호출하는 쪽에서 tuple(items)로 바꿔 넘기도록 설계해야 합니다. set을 키로 쓰고 싶다면 불변 버전인 frozenset을 쓰면 되고, 순서와 상관없이 같은 원소 조합을 같은 키로 취급하고 싶을 때 특히 유용합니다.


comprehension, set 연산, namedtuple

list comprehension

# 짝수만 필터링
even = [x for x in range(10) if x % 2 == 0]
# 중첩 리스트
matrix = [[i*j for j in range(5)] for i in range(5)]

set 연산

a = {1, 2, 3, 4}
b = {3, 4, 5, 6}
print(a | b)  # 합집합: {1, 2, 3, 4, 5, 6}
print(a & b)  # 교집합: {3, 4}
print(a - b)  # 차집합: {1, 2}
print(a ^ b)  # 대칭 차집합: {1, 2, 5, 6}

Named tuple

from collections import namedtuple
Point = namedtuple('Point', ['x', 'y'])
p = Point(10, 20)
print(p.x)  # 10 (인덱스 대신 이름으로 접근)
print(p[0]) # 10 (인덱스도 가능)

set 연산자는 두 set을 다룰 때 반복문을 쓰는 것보다 짧고 빠릅니다. 예를 들어 “어제 로그인했지만 오늘은 안 한 사용자”는 yesterday - today 한 줄로 끝나고, 두 목록의 공통 태그는 a & b로 구합니다. 연산자는 양쪽이 모두 set이어야 하지만 메서드 형태(a.intersection(some_list))는 아무 iterable이나 받는다는 차이가 있습니다.

namedtuple은 튜플의 가벼움과 불변성을 유지하면서 필드 이름을 붙여 (이름, 나이, 이메일) 같은 반환값의 가독성 문제를 해결합니다. get_user()가 튜플을 반환하는 앞의 예제는 언패킹 순서를 틀리면 조용히 잘못된 값이 들어가는데, namedtuple이나 typing.NamedTuple을 쓰면 user.email처럼 이름으로 접근할 수 있습니다. 필드를 나중에 바꿔야 하거나 기본값·메서드가 많이 필요하다면 @dataclass가 더 맞고, 불변으로 두고 싶다면 @dataclass(frozen=True)로 해시 가능한 레코드를 만들 수 있습니다.


마무리

Python 자료구조 선택의 핵심:

  1. 순서 + 수정 → list
  2. 순서 + 불변 → tuple
  3. 중복 제거 + 빠른 검색 → set
  4. 성능 측정 → 상황에 맞게 선택 핵심: 각 자료구조의 특성을 이해하며, 문제에 맞는 것을 선택하시면 성능이 크게 개선됩니다. 데이터가 컨베이어 위에서 순서대로 처리되어야 한다면 list/tuple, 중복 제거·합집합 같은 공정이 중요하면 set을 먼저 떠올려 보시면 됩니다.

같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. set에서 s[0]처럼 첫 번째 요소를 꺼내려고 하면 왜 에러가 나나요?

A. set은 순서가 없는 해시 기반 컬렉션이라 인덱스로 접근할 수 없고, TypeError: 'set' object is not subscriptable이 발생합니다. list(s)[0]처럼 리스트로 바꾸면 꺼낼 수는 있지만 어떤 요소가 첫 번째가 될지는 보장되지 않습니다. 순서와 인덱스 접근이 필요하다면 처음부터 list를 쓰고, 중복 제거와 빠른 멤버십 검사가 목적일 때 set을 쓰는 것이 맞습니다.