Python 자료형: 리스트·딕셔너리·튜플·세트 사용법과 CPython 내부 구조, 성능 차이

이 글의 핵심

리스트·딕셔너리·튜플·세트의 쓰임과 함께, CPython에서 list와 dict가 어떤 C 구조체에 대응하고 메모리·조회 비용이 어디서 오는지 전문가 수준으로 정리했습니다.

들어가며

Python의 내장 자료형(언어에 기본으로 들어 있는 데이터 종류)은 별도 import 없이 바로 쓸 수 있지만, 언제 무엇을 골라야 하는지와 복사·가변성에서 생기는 함정은 따로 익혀야 합니다. 이 글에서는 리스트, 딕셔너리, 튜플, 세트의 생성과 주요 메서드, 자주 하는 실수, CPython 내부 구조까지 차례로 살펴봅니다. 자료구조 관점(시간 복잡도·알고리즘 문제 풀이)은 배열과 리스트, 스택과 큐, 해시 테이블 글과 함께 보면 이해가 깊어집니다.


리스트 (List)

리스트란?

리스트(List)는 순서가 있고 수정 가능한(mutable) 자료형입니다. 여러 타입의 요소를 담을 수 있습니다. 쇼핑할 때 장바구니에 물건을 순서대로 넣었다가 뺄 수 있는 것과 비슷하게, 끝에 추가(append)하거나 중간을 바꾸는 일이 자유롭습니다. 특징:

  • 순서 유지 (인덱스로 접근)
  • 중복 허용
  • 수정 가능 (추가, 삭제, 변경)
  • 다양한 타입 혼합 가능

리스트 생성과 접근

# 리스트 생성
fruits = ["apple", "banana", "cherry"]
numbers = [1, 2, 3, 4, 5]
mixed = [1, "hello", 3.14, True, [1, 2]]  # 중첩 리스트 가능
# 빈 리스트
empty1 = []
empty2 = list()
# range로 리스트 생성
nums = list(range(10))
print(nums)  # [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
# 인덱싱 (0부터 시작)
print(fruits[0])   # apple (첫 번째)
print(fruits[1])   # banana (두 번째)
print(fruits[-1])  # cherry (뒤에서 첫 번째)
print(fruits[-2])  # banana (뒤에서 두 번째)
# 인덱스 범위 체크
# print(fruits[10])  # IndexError: list index out of range
# 슬라이싱 [시작:끝:간격]
print(numbers[1:4])    # [2, 3, 4] (인덱스 1~3)
print(numbers[:3])     # [1, 2, 3] (처음부터 인덱스 2까지)
print(numbers[2:])     # [3, 4, 5] (인덱스 2부터 끝까지)
print(numbers[::2])    # [1, 3, 5] (간격 2)
print(numbers[::-1])   # [5, 4, 3, 2, 1] (역순)
# 슬라이싱은 새 리스트 반환 (원본 변경 안 됨)
sub = numbers[1:3]
sub[0] = 100
print(numbers)  # [1, 2, 3, 4, 5] (원본 그대로)
print(sub)      # [100, 3]

리스트 메서드 상세

fruits = ["apple", "banana"]
# 추가 메서드
fruits.append("cherry")  # 끝에 하나 추가
print(fruits)  # ['apple', 'banana', 'cherry']
fruits.insert(1, "orange")  # 인덱스 1에 삽입 (기존 요소는 뒤로)
print(fruits)  # ['apple', 'orange', 'banana', 'cherry']
fruits.extend(["grape", "kiwi"])  # 여러 개 추가 (리스트 병합)
print(fruits)  # ['apple', 'orange', 'banana', 'cherry', 'grape', 'kiwi']
# append vs extend 차이
list1 = [1, 2, 3]
list1.append([4, 5])  # 리스트 자체를 요소로 추가
print(list1)  # [1, 2, 3, [4, 5]]
list2 = [1, 2, 3]
list2.extend([4, 5])  # 요소들을 개별적으로 추가
print(list2)  # [1, 2, 3, 4, 5]
# 삭제 메서드
fruits = ["apple", "banana", "cherry", "banana"]
fruits.remove("banana")  # 첫 번째 "banana" 삭제
print(fruits)  # ['apple', 'cherry', 'banana']
del fruits[0]  # 인덱스로 삭제
print(fruits)  # ['cherry', 'banana']
last = fruits.pop()  # 마지막 요소 제거 및 반환
print(last)    # banana
print(fruits)  # ['cherry']
second = fruits.pop(0)  # 특정 인덱스 제거 및 반환
print(second)  # cherry
fruits.clear()  # 전체 삭제
print(fruits)  # []
# 검색 메서드
fruits = ["apple", "banana", "cherry", "banana"]
index = fruits.index("banana")  # 첫 번째 위치
print(index)  # 1
# index("banana", 2)  # 인덱스 2부터 검색
# fruits.index("grape")  # ValueError: 'grape' is not in list
count = fruits.count("banana")  # 개수 세기
print(count)  # 2
# in 연산자
print("apple" in fruits)  # True
print("grape" in fruits)  # False
# 정렬 메서드
numbers = [3, 1, 4, 1, 5, 9, 2]
# sort(): 원본 변경
numbers.sort()
print(numbers)  # [1, 1, 2, 3, 4, 5, 9]
numbers.sort(reverse=True)  # 내림차순
print(numbers)  # [9, 5, 4, 3, 2, 1, 1]
# sorted(): 새 리스트 반환 (원본 유지)
numbers = [3, 1, 4, 1, 5]
sorted_nums = sorted(numbers)
print(numbers)      # [3, 1, 4, 1, 5] (원본 그대로)
print(sorted_nums)  # [1, 1, 3, 4, 5]
# key 함수로 정렬
words = ["banana", "pie", "Washington", "book"]
words.sort(key=len)  # 길이 순
print(words)  # ['pie', 'book', 'banana', 'Washington']
words.sort(key=str.lower)  # 대소문자 무시
print(words)  # ['banana', 'book', 'pie', 'Washington']
# 뒤집기
numbers = [1, 2, 3, 4, 5]
numbers.reverse()  # 원본 변경
print(numbers)  # [5, 4, 3, 2, 1]
# 또는 슬라이싱
reversed_nums = numbers[::-1]  # 새 리스트

리스트 연산

# 연결 (+)
list1 = [1, 2, 3]
list2 = [4, 5, 6]
combined = list1 + list2
print(combined)  # [1, 2, 3, 4, 5, 6]
# 반복 (*)
repeated = [1, 2] * 3
print(repeated)  # [1, 2, 1, 2, 1, 2]
# 길이
print(len(fruits))  # 요소 개수
# 최대/최소/합계
numbers = [3, 1, 4, 1, 5]
print(max(numbers))  # 5
print(min(numbers))  # 1
print(sum(numbers))  # 14

리스트 컴프리헨션 (상세)

# 기본: [표현식 for 변수 in 반복가능객체]
squares = [x**2 for x in range(10)]
print(squares)  # [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
# 조건 포함: [표현식 for 변수 in 반복가능객체 if 조건]
evens = [x for x in range(10) if x % 2 == 0]
print(evens)  # [0, 2, 4, 6, 8]
# if-else: [표현식1 if 조건 else 표현식2 for 변수 in 반복가능객체]
labels = ['짝수' if x % 2 == 0 else '홀수' for x in range(5)]
print(labels)  # ['짝수', '홀수', '짝수', '홀수', '짝수']
# 중첩: 2차원 리스트 생성
matrix = [[i*j for j in range(3)] for i in range(3)]
print(matrix)
# [[0, 0, 0],
#  [0, 1, 2],
#  [0, 2, 4]]
# 평탄화
matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
flat = [num for row in matrix for num in row]
print(flat)  # [1, 2, 3, 4, 5, 6, 7, 8, 9]

리스트 복사 주의사항

# 대입은 복사가 아님 (같은 객체에 이름만 하나 더 붙음)
list1 = [1, 2, 3]
list2 = list1  # 참조 공유 (같은 객체)
list2[0] = 100
print(list1)  # [100, 2, 3] (list1도 변경됨!)
# 얕은 복사 방법 (바깥 리스트만 새로 만듦)
list1 = [1, 2, 3]
list2 = list1.copy()  # 방법 1
list3 = list1[:]      # 방법 2
list4 = list(list1)   # 방법 3
list2[0] = 100
print(list1)  # [1, 2, 3] (원본 유지)
print(list2)  # [100, 2, 3]
# 중첩 리스트는 얕은 복사
matrix = [[1, 2], [3, 4]]
matrix_copy = matrix.copy()
matrix_copy[0][0] = 100
print(matrix)  # [[100, 2], [3, 4]] (원본도 변경!)
# 완전한 깊은 복사
import copy
matrix_deep = copy.deepcopy(matrix)
matrix_deep[0][0] = 999
print(matrix)      # [[100, 2], [3, 4]] (원본 유지)
print(matrix_deep) # [[999, 2], [3, 4]]

여기서 헷갈리기 쉬운 점은 list2 = list1이 복사가 아니라는 사실입니다. Python의 대입은 값을 옮기는 연산이 아니라 이름을 객체에 묶는 연산이라서, 두 이름이 같은 리스트 객체를 가리키게 됩니다. copy()·[:]·list()는 모두 바깥 리스트만 새로 만드는 얕은 복사이고, 안에 든 요소 객체는 그대로 공유합니다. 요소가 정수·문자열처럼 불변 객체라면 얕은 복사로 충분하지만, 중첩 리스트나 딕셔너리가 들어 있으면 copy.deepcopy가 필요합니다.

deepcopy도 공짜는 아닙니다. 객체 그래프 전체를 재귀로 순회하면서 순환 참조를 추적하기 위한 메모 딕셔너리까지 유지하므로, 큰 구조를 루프 안에서 반복해서 deepcopy하면 눈에 띄게 느려집니다. 락·소켓처럼 복사할 수 없는 객체가 섞여 있으면 TypeError: cannot pickle '_thread.lock' object 같은 에러로 멈추기도 합니다. 그래서 실무에서는 “정말 전부 복사해야 하는가”를 먼저 따져 보고, 바뀌는 부분만 새로 만드는 쪽을 선호합니다.

처음 Python으로 격자 문제를 풀 때 흔히 겪는 버그가 grid = [[0] * 3] * 3입니다. grid[0][0] = 1 한 줄만 바꿨는데 세 행의 첫 칸이 모두 1이 됩니다. 바깥쪽 * 3이 안쪽 리스트를 새로 만드는 것이 아니라 같은 객체를 세 번 참조하도록 복제하기 때문입니다. [[0] * 3 for _ in range(3)]처럼 컴프리헨션으로 행마다 새 리스트를 만들어야 하고, 이 버그를 한 번 겪고 나면 “*로 복제한 것이 가변 객체인가”를 습관적으로 확인하게 됩니다.


딕셔너리 (Dictionary)

기본 사용법

딕셔너리는 이름표(키)로 값을 찾는 전화번호부와 비슷합니다. 리스트처럼 순번(0, 1, 2…)으로 찾기보다 의미 있는 키로 저장하므로, 설정 값·사용자 프로필처럼 “항목 이름 → 내용” 구조에 잘 맞습니다.

# 딕셔너리 생성
person = {
    "name": "홍길동",
    "age": 25,
    "city": "서울"
}
# 접근
print(person["name"])      # 홍길동
print(person.get("age"))   # 25
print(person.get("job", "없음"))  # 기본값
# 추가/수정
person["job"] = "개발자"   # 추가
person["age"] = 26         # 수정
# 삭제
del person["city"]
job = person.pop("job")    # 제거 및 반환

딕셔너리 메서드

person = {"name": "홍길동", "age": 25}
# 키/값/아이템
keys = person.keys()      # dict_keys(['name', 'age'])
values = person.values()  # dict_values(['홍길동', 25])
items = person.items()    # dict_items([('name', '홍길동'), ('age', 25)])
# 순회 방법
# 방법 1: 키만
for key in person:
    print(key, person[key])
# 방법 2: 키-값 (권장)
for key, value in person.items():
    print(f"{key}: {value}")
# name: 홍길동
# age: 25
# update(): 딕셔너리 병합
person.update({"city": "서울", "job": "개발자"})  # age는 덮어씀
print(person)  # {'name': '홍길동', 'age': 25, 'city': '서울', 'job': '개발자'}
# setdefault(): 키가 없을 때만 추가
person.setdefault("country", "한국")  # 추가
print(person["country"])  # 한국
person.setdefault("name", "김철수")  # 이미 있으므로 무시
print(person["name"])  # 홍길동 (변경 안 됨)
# popitem(): 마지막 키-값 쌍 제거 (Python 3.7+)
last = person.popitem()
print(last)  # ('country', '한국')

person["age"]와 person.get("age") 중 무엇을 쓸지는 “키가 없으면 버그인가”로 판단하는 편이 좋습니다. 반드시 있어야 하는 키라면 []로 접근해 KeyError가 바로 터지게 두는 쪽이 디버깅에 유리합니다. get()으로 기본값을 넣어 두면 오타 난 키("nmae")도 조용히 None이 되어 한참 뒤 엉뚱한 곳에서 문제가 드러납니다.

순회 중에 딕셔너리를 수정하는 것도 자주 부딪히는 함정입니다. for k in d: del d[k]처럼 크기를 바꾸면 RuntimeError: dictionary changed size during iteration이 납니다. for k in list(d):로 키 목록의 스냅숏을 순회하거나, {k: v for k, v in d.items() if 조건}으로 새 딕셔너리를 만드는 방식이 안전합니다. keys()·values()·items()가 반환하는 것은 복사본이 아니라 원본을 비추는 뷰(view) 객체라서, 원본이 바뀌면 뷰의 내용도 함께 바뀐다는 점도 기억해 둘 만합니다.

딕셔너리 컴프리헨션

# 기본: {키표현식: 값표현식 for 변수 in 반복가능객체}
squares = {x: x**2 for x in range(5)}
print(squares)  # {0: 0, 1: 1, 2: 4, 3: 9, 4: 16}
# 조건 포함
even_squares = {x: x**2 for x in range(10) if x % 2 == 0}
print(even_squares)  # {0: 0, 2: 4, 4: 16, 6: 36, 8: 64}
# 키-값 교환
original = {"a": 1, "b": 2, "c": 3}
swapped = {v: k for k, v in original.items()}
print(swapped)  # {1: 'a', 2: 'b', 3: 'c'}
# 리스트를 딕셔너리로
names = ["Alice", "Bob", "Charlie"]
name_dict = {i: name for i, name in enumerate(names)}
print(name_dict)  # {0: 'Alice', 1: 'Bob', 2: 'Charlie'}

중첩 딕셔너리

# 중첩 딕셔너리
users = {
    "user1": {"name": "홍길동", "age": 25},
    "user2": {"name": "김철수", "age": 30}
}
# 접근
print(users["user1"]["name"])  # 홍길동
# 안전한 접근
name = users.get("user1", {}).get("name", "없음")
print(name)  # 홍길동
# 존재하지 않는 키
name = users.get("user3", {}).get("name", "없음")
print(name)  # 없음 (에러 없음)

defaultdict 활용

from collections import defaultdict
# 기본 딕셔너리 문제
word_count = {}
words = ["apple", "banana", "apple", "cherry", "banana", "apple"]
for word in words:
    if word not in word_count:
        word_count[word] = 0
    word_count[word] += 1
print(word_count)  # {'apple': 3, 'banana': 2, 'cherry': 1}
# defaultdict 사용
word_count = defaultdict(int)  # 기본값 0
for word in words:
    word_count[word] += 1  # 키가 없어도 자동으로 0으로 초기화
print(dict(word_count))  # {'apple': 3, 'banana': 2, 'cherry': 1}
# 리스트를 기본값으로
groups = defaultdict(list)
students = [("홍길동", "A"), ("김철수", "B"), ("이영희", "A")]
for name, grade in students:
    groups[grade].append(name)
print(dict(groups))  # {'A': ['홍길동', '이영희'], 'B': ['김철수']}

튜플 (Tuple)

튜플이란?

튜플(Tuple)은 순서가 있고 수정 불가능한(immutable) 자료형입니다. 리스트와 비슷하지만 한 번 생성하면 변경할 수 없습니다. 특징:

  • 순서 유지
  • 중복 허용
  • 수정 불가 (추가, 삭제, 변경 불가)
  • 리스트보다 메모리 효율적
  • 딕셔너리 키로 사용 가능 언제 사용하나?
  • 변경되면 안 되는 데이터 (좌표, 설정값)
  • 함수에서 여러 값 반환
  • 딕셔너리 키로 사용

튜플 생성과 접근

# 튜플 생성
point = (10, 20)
person = ("홍길동", 25, "서울")
single = (42,)  # 요소 1개일 때 쉼표 필수!
# 괄호 없이도 가능
coords = 10, 20, 30
print(type(coords))  # <class 'tuple'>
# 빈 튜플
empty1 = ()
empty2 = tuple()
# 리스트를 튜플로
numbers = tuple([1, 2, 3, 4, 5])
print(numbers)  # (1, 2, 3, 4, 5)
# 인덱싱/슬라이싱 (리스트와 동일)
print(person[0])    # 홍길동
print(person[-1])   # 서울
print(person[1:])   # (25, '서울')
# ❌ 수정 불가
# person[1] = 26  # TypeError: 'tuple' object does not support item assignment
# person.append(100)  # AttributeError: 'tuple' object has no attribute 'append'
# 튜플 메서드 (2개만 존재)
numbers = (1, 2, 3, 2, 4, 2)
print(numbers.count(2))  # 3 (2의 개수)
print(numbers.index(3))  # 2 (3의 인덱스)

튜플 언패킹 (Unpacking)

# 기본 언패킹
name, age, city = ("홍길동", 25, "서울")
print(name, age, city)  # 홍길동 25 서울
# 함수 반환값
def get_user():
    return "홍길동", 25, "서울"  # 튜플 반환
name, age, city = get_user()
print(name)  # 홍길동
# 값 교환 (Python의 강력한 기능)
a, b = 1, 2
a, b = b, a  # 교환
print(a, b)  # 2 1
# * 연산자로 나머지 받기
numbers = (1, 2, 3, 4, 5)
first, *rest, last = numbers
print(first)  # 1
print(rest)   # [2, 3, 4] (리스트!)
print(last)   # 5
# 여러 변수에 동시 할당
x, y, z = 10, 20, 30
print(x, y, z)  # 10 20 30

튜플 vs 리스트 비교

특징튜플리스트
수정 가능❌✅
속도빠름느림
메모리적음많음
메서드2개11개
딕셔너리 키✅❌
사용 시점불변 데이터가변 데이터
import sys
# 메모리 비교
list_data = [1, 2, 3, 4, 5]
tuple_data = (1, 2, 3, 4, 5)
print(sys.getsizeof(list_data))   # 104 bytes
print(sys.getsizeof(tuple_data))  # 80 bytes
# 딕셔너리 키로 사용
# locations = {[10, 20]: "A"}  # TypeError: unhashable type: 'list'
locations = {(10, 20): "A", (30, 40): "B"}  # 튜플은 가능
print(locations[(10, 20)])  # A

표의 “속도 빠름/느림”은 걸러서 읽어야 합니다. 튜플이 리스트보다 확실히 유리한 곳은 생성 비용과 메모리입니다. 상수 튜플 (1, 2, 3)은 컴파일 시점에 상수로 접혀 매번 새로 만들지 않고, 과할당 여유 공간도 없어서 getsizeof 값이 더 작습니다(위 바이트 수는 64비트 CPython 기준이며 버전에 따라 다릅니다). 반면 인덱싱이나 순회는 둘 다 포인터 배열을 읽는 구조라 체감할 만한 차이가 거의 없습니다. 튜플을 고르는 진짜 이유는 속도보다 “이 값은 바뀌지 않는다”는 의도를 코드로 드러내고, 해시 가능한 키로 쓸 수 있다는 데 있습니다.

단, 튜플이 해시 가능하려면 안에 든 요소도 모두 해시 가능해야 합니다. {(1, [2, 3]): "x"}는 TypeError: unhashable type: 'list'로 실패합니다. 아래 “불변성 주의사항”에서 보듯 튜플의 불변성은 “요소 참조를 바꿀 수 없다”는 뜻이지, 안에 든 객체까지 얼려 준다는 뜻이 아니기 때문입니다.

튜플의 불변성 주의사항

# 튜플 자체는 불변이지만, 내부 가변 객체는 변경 가능
tuple_with_list = (1, 2, [3, 4])
# tuple_with_list[0] = 100  # TypeError (튜플 요소 변경 불가)
tuple_with_list[2].append(5)  # 내부 리스트는 변경 가능
print(tuple_with_list)  # (1, 2, [3, 4, 5])
# 튜플 "수정" (실제로는 새 튜플 생성)
original = (1, 2, 3)
modified = original + (4, 5)  # 새 튜플
print(original)  # (1, 2, 3)
print(modified)  # (1, 2, 3, 4, 5)

세트 (Set)

세트란?

세트(Set)는 순서가 없고 중복을 허용하지 않는 자료형입니다. 수학의 집합과 동일합니다. 특징:

  • 순서 없음 (인덱스 접근 불가)
  • 중복 불가 (자동 제거)
  • 수정 가능 (추가, 삭제)
  • 멤버십 테스트 빠름 (O(1))
  • 집합 연산 지원 언제 사용하나?
  • 중복 제거
  • 멤버십 테스트 (in 연산)
  • 집합 연산 (합집합, 교집합, 차집합)

세트 생성과 연산

# 세트 생성
fruits = {"apple", "banana", "cherry"}
numbers = {1, 2, 3, 4, 5}
# 빈 세트 (주의: {}는 딕셔너리!)
empty = set()  # 올바른 방법
# empty = {}  # 이건 딕셔너리!
# 리스트를 세트로 (중복 제거)
numbers = [1, 2, 2, 3, 3, 3, 4]
unique = set(numbers)
print(unique)  # {1, 2, 3, 4}
# 문자열을 세트로
chars = set("hello")
print(chars)  # {'h', 'e', 'l', 'o'} (중복 'l' 제거)
# 추가
fruits = {"apple", "banana"}
fruits.add("cherry")
print(fruits)  # {'apple', 'banana', 'cherry'}
fruits.add("apple")  # 중복은 무시됨
print(fruits)  # {'apple', 'banana', 'cherry'}
# 여러 개 추가
fruits.update(["orange", "grape"])
print(fruits)  # {'apple', 'banana', 'cherry', 'orange', 'grape'}
# 삭제
fruits.remove("banana")  # 없으면 KeyError
print(fruits)  # {'apple', 'cherry', 'orange', 'grape'}
fruits.discard("grape")  # 없어도 에러 안 남
fruits.discard("kiwi")   # 에러 없음
# pop(): 임의의 요소 제거 (순서 없으므로 어떤 요소인지 모름)
item = fruits.pop()
print(item)  # 임의의 과일
fruits.clear()  # 전체 삭제

집합 연산 상세

a = {1, 2, 3, 4}
b = {3, 4, 5, 6}
# 합집합 (Union): 두 집합의 모든 요소
print(a | b)           # {1, 2, 3, 4, 5, 6}
print(a.union(b))      # {1, 2, 3, 4, 5, 6}
# 교집합 (Intersection): 공통 요소
print(a & b)              # {3, 4}
print(a.intersection(b))  # {3, 4}
# 차집합 (Difference): a에만 있는 요소
print(a - b)            # {1, 2}
print(a.difference(b))  # {1, 2}
# 대칭 차집합 (Symmetric Difference): 한쪽에만 있는 요소
print(a ^ b)                      # {1, 2, 5, 6}
print(a.symmetric_difference(b))  # {1, 2, 5, 6}
# 부분집합/상위집합 확인
c = {1, 2}
print(c.issubset(a))    # True (c는 a의 부분집합)
print(a.issuperset(c))  # True (a는 c의 상위집합)
# 서로소 집합 확인
d = {7, 8, 9}
print(a.isdisjoint(d))  # True (공통 요소 없음)
print(a.isdisjoint(b))  # False (3, 4가 공통)

세트 실전 활용

# 1. 중복 제거 (순서 유지 필요 없을 때)
emails = ["[email protected]", "[email protected]", "[email protected]", "[email protected]"]
unique_emails = list(set(emails))
print(unique_emails)  # ['[email protected]', '[email protected]', '[email protected]']
# 2. 중복 제거 (순서 유지 필요할 때)
def remove_duplicates(items):
    seen = set()
    result = []
    for item in items:
        if item not in seen:
            seen.add(item)
            result.append(item)
    return result
emails = ["[email protected]", "[email protected]", "[email protected]", "[email protected]"]
unique = remove_duplicates(emails)
print(unique)  # ['[email protected]', '[email protected]', '[email protected]']
# 3. 빠른 멤버십 테스트
# 리스트: O(n)
large_list = list(range(100000))
# 10000 in large_list  # 느림
# 세트: O(1)
large_set = set(range(100000))
# 10000 in large_set  # 빠름
# 4. 두 리스트의 공통 요소
list1 = [1, 2, 3, 4, 5]
list2 = [4, 5, 6, 7, 8]
common = list(set(list1) & set(list2))
print(common)  # [4, 5]

위 예제의 출력 주석은 이해를 돕기 위한 한 가지 예시일 뿐, 세트의 순회 순서는 보장되지 않습니다. 특히 문자열 해시는 보안상 이유로 프로세스마다 무작위 시드(PYTHONHASHSEED)를 쓰기 때문에, 같은 코드라도 실행할 때마다 set(["b", "a", "c"])의 출력 순서가 달라질 수 있습니다. 테스트에서 list(set(...)) 결과를 기대값 리스트와 그대로 비교하면 가끔씩만 실패하는 불안정한 테스트가 되므로, 순서가 필요하면 sorted()를 거치거나 세트끼리 비교해야 합니다.

순서를 유지한 중복 제거는 앞의 remove_duplicates 함수처럼 직접 짜도 되지만, list(dict.fromkeys(emails)) 한 줄로도 됩니다. 딕셔너리가 삽입 순서를 보장한다는 점을 이용한 관용구이고, 루프가 C 수준에서 돌아서 보통 직접 짠 루프보다 빠릅니다. 다만 요소가 해시 가능해야 한다는 제약은 세트와 같아서, 딕셔너리 리스트처럼 해시할 수 없는 요소의 중복 제거에는 쓸 수 없습니다.


실전 예제

예제 1: 학생 성적 관리 시스템

# 학생 데이터 (리스트 + 딕셔너리)
students = [
    {"name": "홍길동", "scores": [85, 90, 78]},
    {"name": "김철수", "scores": [92, 88, 95]},
    {"name": "이영희", "scores": [78, 82, 80]}
]
# 평균 계산 및 등급 부여
for student in students:
    avg = sum(student["scores"]) / len(student["scores"])
    
    if avg >= 90:
        grade = "A"
    elif avg >= 80:
        grade = "B"
    else:
        grade = "C"
    
    print(f"{student['name']}: 평균 {avg:.1f}, 등급 {grade}")
# 출력:
# 홍길동: 평균 84.3, 등급 B
# 김철수: 평균 91.7, 등급 A
# 이영희: 평균 80.0, 등급 B

예제 2: 텍스트 분석 (딕셔너리 + 세트)

text = """
Python is powerful. Python is easy.
Python is popular. Python is versatile.
"""
# 단어 추출 (소문자 변환, 구두점 제거)
words = text.lower().replace(".", "").split()
# 단어 빈도 (딕셔너리)
word_count = {}
for word in words:
    word_count[word] = word_count.get(word, 0) + 1
# 빈도순 정렬
sorted_words = sorted(word_count.items(), key=lambda x: x[1], reverse=True)
print("단어 빈도:")
for word, count in sorted_words[:5]:  # 상위 5개
    print(f"  {word}: {count}")
# 고유 단어 개수 (세트)
unique_words = set(words)
print(f"\n고유 단어 수: {len(unique_words)}")
# 출력:
# 단어 빈도:
#   python: 4
#   is: 4
#   powerful: 1
#   easy: 1
#   popular: 1
# 
# 고유 단어 수: 6

예제 3: 장바구니 시스템 (딕셔너리 + 리스트)

# 상품 정보
products = {
    "P001": {"name": "노트북", "price": 1200000},
    "P002": {"name": "마우스", "price": 30000},
    "P003": {"name": "키보드", "price": 80000}
}
# 장바구니 (상품ID: 수량)
cart = {}
# 상품 추가
def add_to_cart(product_id, quantity=1):
    if product_id in products:
        cart[product_id] = cart.get(product_id, 0) + quantity
        print(f"{products[product_id]['name']} {quantity}개 추가")
    else:
        print("존재하지 않는 상품입니다.")
# 총액 계산
def calculate_total():
    total = 0
    for product_id, quantity in cart.items():
        price = products[product_id]["price"]
        total += price * quantity
    return total
# 실행
add_to_cart("P001", 1)  # 노트북 1개 추가
add_to_cart("P002", 2)  # 마우스 2개 추가
add_to_cart("P003", 1)  # 키보드 1개 추가
print(f"\n총액: {calculate_total():,}원")  # 총액: 1,340,000원

예제 4: 친구 추천 시스템 (세트)

# 사용자별 친구 목록
friends = {
    "홍길동": {"김철수", "이영희", "박민수"},
    "김철수": {"홍길동", "박민수", "최지훈"},
    "이영희": {"홍길동", "최지훈", "정수진"}
}
# 공통 친구 찾기
def find_common_friends(user1, user2):
    return friends[user1] & friends[user2]
# 친구 추천 (친구의 친구 - 나와 내 친구 제외)
def recommend_friends(user):
    my_friends = friends[user]
    candidates = set()
    
    for friend in my_friends:
        candidates.update(friends[friend])
    
    # 나 자신과 이미 친구인 사람 제외
    recommendations = candidates - my_friends - {user}
    return recommendations
print("홍길동과 김철수의 공통 친구:", find_common_friends("홍길동", "김철수"))
# 홍길동과 김철수의 공통 친구: {'박민수'}
print("홍길동에게 추천할 친구:", recommend_friends("홍길동"))
# 홍길동에게 추천할 친구: {'최지훈', '정수진'}

예제 5: 데이터 변환 파이프라인

# CSV 데이터 (문자열)
csv_data = """name,age,city
홍길동,25,서울
김철수,30,부산
이영희,28,서울
박민수,35,대구"""
# 1단계: 문자열 → 리스트
lines = csv_data.strip().split("\n")
header = lines[0].split(",")
rows = [line.split(",") for line in lines[1:]]
# 2단계: 리스트 → 딕셔너리 리스트
users = []
for row in rows:
    user = {}
    for i, key in enumerate(header):
        user[key] = row[i]
    users.append(user)
print("딕셔너리 리스트:")
for user in users:
    print(user)
# 3단계: 도시별 그룹핑 (딕셔너리 + 리스트)
city_groups = {}
for user in users:
    city = user["city"]
    if city not in city_groups:
        city_groups[city] = []
    city_groups[city].append(user["name"])
print("\n도시별 사용자:")
for city, names in city_groups.items():
    print(f"{city}: {', '.join(names)}")
# 4단계: 고유 도시 (세트)
cities = {user["city"] for user in users}
print(f"\n고유 도시: {cities}")
# 출력:
# 딕셔너리 리스트:
# {'name': '홍길동', 'age': '25', 'city': '서울'}
# {'name': '김철수', 'age': '30', 'city': '부산'}
# {'name': '이영희', 'age': '28', 'city': '서울'}
# {'name': '박민수', 'age': '35', 'city': '대구'}
# 
# 도시별 사용자:
# 서울: 홍길동, 이영희
# 부산: 김철수
# 대구: 박민수
# 
# 고유 도시: {'서울', '부산', '대구'}

성능 비교와 선택 가이드

시간 복잡도 비교

연산리스트튜플딕셔너리세트
인덱스 접근O(1)O(1)--
키/값 접근--O(1)-
검색 (in)O(n)O(n)O(1)O(1)
추가 (끝)O(1)-O(1)O(1)
삽입 (중간)O(n)---
삭제O(n)-O(1)O(1)
정렬O(n log n)---

자료형 선택 가이드

# 1. 순서가 중요하고 수정이 필요한 경우 → 리스트
todo_list = ["코딩", "운동", "독서"]
todo_list.append("명상")
# 2. 순서가 중요하지만 수정이 불필요한 경우 → 튜플
coordinates = (37.5665, 126.9780)  # 서울 좌표 (변경 불가)
# 3. 키로 빠르게 접근해야 하는 경우 → 딕셔너리
user_db = {"user123": {"name": "홍길동", "email": "[email protected]"}}
user = user_db["user123"]  # O(1) 접근
# 4. 중복 제거나 집합 연산이 필요한 경우 → 세트
tags1 = {"python", "coding", "tutorial"}
tags2 = {"python", "web", "tutorial"}
common_tags = tags1 & tags2  # {'python', 'tutorial'}
# 5. 빠른 멤버십 테스트가 필요한 경우 → 세트
allowed_users = {"admin", "user1", "user2"}  # 세트 (빠름)
if username in allowed_users:  # O(1)
    grant_access()

CPython 구현 관점: list와 dict의 메모리·레이아웃

이 절은 사용법이 아니라 CPython 소스 수준에서 자주 언급되는 모델을 요약합니다. 필드 이름·내부 최적화는 마이너·메이저 버전마다 바뀔 수 있으므로, 여기서는 “왜 이런 복잡도·메모리 특성이 나오는가”를 잡는 데 초점을 둡니다.

리스트(list): 가변 배열과 과할당(overallocation)

CPython의 list는 논리적으로 PyObject* 포인터들의 연속 구역을 가리키는 구조로 이해할 수 있습니다(구현 세부는 Objects/listobject.c 계열). 핵심 아이디어는 다음과 같습니다.

  • ob_size(논리 길이): len()에 대응하는 요소 개수입니다.
  • allocated(할당 용량): 실제로 예약해 둔 슬롯은 종종 논리 길이보다 큽니다. append가 자주 일어날 때 매번 realloc을 호출하지 않도록 미리 여유 공간을 잡는 전략이 있습니다.
  • 과할당: 끝에 붙이는 연산은 상각(amortized) 복잡도 O(1) 로 기대할 수 있지만, 중간 삽입·삭제는 뒤쪽 요소를 밀거나 당겨야 하므로 최악 O(n) 입니다.

메모리 레이아웃 관점에서 리스트는 “객체들을 힙 곳곳에 두되, 리스트 본체는 그 객체들을 가리키는 포인터 배열을 가진다”고 보면 됩니다. 따라서 리스트가 커질수록 포인터 배열 자체와 요소 객체가 각각 메모리를 차지합니다(작은 정수 등은 interning 덕에 중복이 줄 수 있으나 일반화는 금물).

딕셔너리(dict): 해시 테이블과 삽입 순서

Python 3.7+에서는 dict가 삽입 순서를 보장합니다. 이는 “연결 리스트로만 순서를 유지한다”기보다, 내부 엔트리 배열과 인덱스·해시 설계가 맞물린 결과로 이해하는 편이 정확합니다. CPython 3.6 이후의 컴팩트 dict(compact dict) 아이디어는, 키·값을 분리된 밀집 배열에 두고 해시 테이블 쪽은 인덱스 역할을 하도록 해 캐시 친화성과 메모리를 동시에 노리는 방향입니다(세부는 버전별로 진화함).

조회·삽입의 평균적인 비용은 O(1) 에 가깝지만, 다음을 기억해야 합니다.

  • 해시 충돌·탐사(probing): 같은 슬롯에 몰리면 느려질 수 있습니다.
  • 리사이즈: 테이블이 차면 더 큰 테이블로 옮기며 재배치가 발생합니다. 한 번에 많은 키를 넣을 때 간헐적인 스파이크가 찍힐 수 있습니다.
  • 키로 쓸 수 있는 타입: __hash__와 __eq__ 규약을 만족해야 합니다. 가변 객체를 키로 쓰면 안 되는 이유도 여기서 나옵니다(해시가 변하면 슬롯을 찾을 수 없음).

dict와 list를 고를 때 구현 관점 힌트

  • 인덱스로 순차 접근이 중요하고 많이 붙이는 작업이면 리스트가 자연스럽습니다.
  • 키로 빠른 조회가 핵심이면 dict가 맞습니다. 다만 매우 큰 dict는 메모리 사용량이 커질 수 있으며, 작은 dict를 아주 많이 만들면 오버헤드가 누적됩니다.
  • 알고리즘 글(시간 복잡도 표)과 함께 보면, “평균 O(1)의 조건”이 무엇인지 더 잘 정렬됩니다.

자주 하는 실수와 해결법

실수 1: 빈 세트를 {}로 생성

# ❌ 잘못된 방법
empty = {}
print(type(empty))  # <class 'dict'> (딕셔너리!)
# ✅ 올바른 방법
empty = set()
print(type(empty))  # <class 'set'>

실수 2: 리스트 복사 시 참조 문제

# ❌ 잘못된 방법
list1 = [1, 2, 3]
list2 = list1  # 참조만 복사
list2[0] = 100
print(list1)  # [100, 2, 3] (원본도 변경!)
# ✅ 올바른 방법
list1 = [1, 2, 3]
list2 = list1.copy()  # 또는 list1[:] 또는 list(list1)
list2[0] = 100
print(list1)  # [1, 2, 3] (원본 유지)

실수 3: 딕셔너리 키 에러

# ❌ 잘못된 방법
person = {"name": "홍길동"}
# print(person["age"])  # KeyError: 'age'
# ✅ 올바른 방법 1: get() 사용
age = person.get("age", 0)  # 기본값 0
print(age)  # 0
# ✅ 올바른 방법 2: in 체크
if "age" in person:
    print(person["age"])
else:
    print("나이 정보 없음")

실수 4: 튜플 요소 1개 생성

# ❌ 잘못된 방법
single = (42)
print(type(single))  # <class 'int'> (정수!)
# ✅ 올바른 방법
single = (42,)  # 쉼표 필수
print(type(single))  # <class 'tuple'>

실수 5: 세트는 순서가 없음

# ❌ 잘못된 방법
numbers = {3, 1, 4, 1, 5}
# print(numbers[0])  # TypeError: 'set' object is not subscriptable
# ✅ 올바른 방법: 리스트로 변환 후 접근
numbers_list = list(numbers)
print(numbers_list[0])  # 1 (정렬되지 않은 순서)
# 정렬이 필요하면
sorted_numbers = sorted(numbers)
print(sorted_numbers)  # [1, 3, 4, 5]

고급 활용 패턴

패턴 1: 리스트 필터링과 변환

# 데이터
users = [
    {"name": "홍길동", "age": 25, "active": True},
    {"name": "김철수", "age": 17, "active": False},
    {"name": "이영희", "age": 30, "active": True}
]
# 활성 성인 사용자만 추출
active_adults = [
    user["name"] 
    for user in users 
    if user["age"] >= 18 and user["active"]
]
print(active_adults)  # ['홍길동', '이영희']

패턴 2: 딕셔너리 병합 (Python 3.9+)

# 방법 1: | 연산자 (Python 3.9+)
dict1 = {"a": 1, "b": 2}
dict2 = {"b": 3, "c": 4}
merged = dict1 | dict2  # dict2가 우선
print(merged)  # {'a': 1, 'b': 3, 'c': 4}
# 방법 2: update()
dict1 = {"a": 1, "b": 2}
dict1.update(dict2)
print(dict1)  # {'a': 1, 'b': 3, 'c': 4}
# 방법 3: ** 언패킹
merged = {**dict1, **dict2}
print(merged)  # {'a': 1, 'b': 3, 'c': 4}

패턴 3: 리스트를 딕셔너리로 그룹핑

from collections import defaultdict
# 데이터
transactions = [
    ("2026-03-01", "식비", 15000),
    ("2026-03-01", "교통비", 5000),
    ("2026-03-02", "식비", 20000),
    ("2026-03-02", "쇼핑", 50000),
    ("2026-03-03", "식비", 12000)
]
# 날짜별 그룹핑
by_date = defaultdict(list)
for date, category, amount in transactions:
    by_date[date].append((category, amount))
print("날짜별 지출:")
for date, items in by_date.items():
    total = sum(amount for _, amount in items)
    print(f"{date}: {total:,}원")
# 카테고리별 합계
by_category = defaultdict(int)
for _, category, amount in transactions:
    by_category[category] += amount
print("\n카테고리별 지출:")
for category, total in by_category.items():
    print(f"{category}: {total:,}원")
# 출력:
# 날짜별 지출:
# 2026-03-01: 20,000원
# 2026-03-02: 70,000원
# 2026-03-03: 12,000원
# 
# 카테고리별 지출:
# 식비: 47,000원
# 교통비: 5,000원
# 쇼핑: 50,000원

패턴 4: 세트로 권한 관리

# 역할별 권한
permissions = {
    "admin": {"read", "write", "delete", "manage_users"},
    "editor": {"read", "write"},
    "viewer": {"read"}
}
# 사용자 역할
user_roles = {
    "홍길동": ["admin"],
    "김철수": ["editor", "viewer"],
    "이영희": ["viewer"]
}
# 사용자의 모든 권한 계산
def get_user_permissions(username):
    user_perms = set()
    for role in user_roles.get(username, []):
        user_perms.update(permissions.get(role, set()))
    return user_perms
# 권한 확인
def has_permission(username, permission):
    return permission in get_user_permissions(username)
# 테스트
print("홍길동 권한:", get_user_permissions("홍길동"))
# {'read', 'write', 'delete', 'manage_users'}
print("김철수 권한:", get_user_permissions("김철수"))
# {'read', 'write'}
print("이영희가 write 가능?", has_permission("이영희", "write"))
# False

연습 문제

문제 1: 리스트 회전

리스트를 오른쪽으로 k번 회전하세요.

def rotate_list(nums, k):
    if not nums or k == 0:
        return nums
    k = k % len(nums)  # k가 리스트 길이보다 클 경우
    return nums[-k:] + nums[:-k]
# 테스트
print(rotate_list([1, 2, 3, 4, 5], 2))  # [4, 5, 1, 2, 3]
print(rotate_list([1, 2, 3, 4, 5], 7))  # [4, 5, 1, 2, 3] (7 % 5 = 2)

문제 2: 두 딕셔너리의 차이 찾기

두 딕셔너리를 비교하여 다른 키-값 쌍을 찾으세요.

def dict_diff(dict1, dict2):
    all_keys = set(dict1.keys()) | set(dict2.keys())
    diff = {}
    
    for key in all_keys:
        val1 = dict1.get(key)
        val2 = dict2.get(key)
        if val1 != val2:
            diff[key] = {"old": val1, "new": val2}
    
    return diff
# 테스트
old = {"name": "홍길동", "age": 25, "city": "서울"}
new = {"name": "홍길동", "age": 26, "city": "부산", "job": "개발자"}
print(dict_diff(old, new))
# {'age': {'old': 25, 'new': 26}, 
#  'city': {'old': '서울', 'new': '부산'}, 
#  'job': {'old': None, 'new': '개발자'}}

문제 3: 중첩 리스트 평탄화

중첩된 리스트를 1차원 리스트로 변환하세요.

def flatten(nested_list):
    result = []
    for item in nested_list:
        if isinstance(item, list):
            result.extend(flatten(item))  # 재귀
        else:
            result.append(item)
    return result
# 테스트
nested = [1, [2, 3], [4, [5, 6]], 7]
print(flatten(nested))  # [1, 2, 3, 4, 5, 6, 7]
# 컴프리헨션 버전 (1단계만)
nested = [[1, 2], [3, 4], [5, 6]]
flat = [num for sublist in nested for num in sublist]
print(flat)  # [1, 2, 3, 4, 5, 6]

자주 묻는 질문 (FAQ)

Q. list2 = list1으로 복사했는데 원본까지 바뀌는 이유는 무엇인가요?

A. list2 = list1은 새 리스트를 만드는 것이 아니라 같은 리스트 객체를 가리키는 이름을 하나 더 만드는 것입니다. 그래서 list2[0]을 바꾸면 list1에서도 바뀐 값이 보입니다. 독립된 복사본이 필요하면 list1.copy(), list1[:], list(list1)을 쓰고, 안쪽에 리스트가 중첩되어 있다면 이 방법들은 얕은 복사라서 copy.deepcopy가 필요합니다.


같이 보면 좋은 글