Devin.KR
로그인

파이썬 리스트 튜플 딕셔너리 셋 - 선택 기준과 가변 불변 (파이썬 초급 3단원)

개발자 조회 1

이 단원에서 배우는 것

2단원까지는 값 하나에 이름표 하나를 붙였다. 책 한 권을 다루기엔 충분하지만 서점에는 책이 수백 권 있다. title1, title2, title3… 으로 갈 수는 없다. 이번 단원은 여러 값을 하나로 묶는 네 가지 그릇을 다룬다. 문법 자체는 30분이면 외운다. 진짜 내용은 네 개 중 무엇을 고를 것인가와, 2단원에서 미뤄 둔 "값을 바꿀 수 있다"는 말의 실제 의미다.

  • 리스트·튜플·딕셔너리·셋의 용도를 구분하고 상황에 맞게 고른다.
  • 인덱싱·슬라이싱·주요 메서드로 데이터를 꺼내고 바꾼다.
  • 가변 객체를 다른 이름에 대입하면 왜 원본까지 바뀌는지 설명하고 피한다.

왜 필요한가

자료 구조 선택은 문법 취향이 아니라 성능과 코드 모양을 동시에 결정하는 설계다. 서점 프로그램에서 "이 책이 재고에 있나"를 확인한다고 하자.

책 제목을 리스트에 넣어 뒀다면 파이썬은 첫 번째 원소부터 하나씩 비교한다. 책이 10권이면 아무 문제 없다. 10만 권이면 한 번 확인할 때마다 최대 10만 번 비교한다. 같은 데이터를 셋이나 딕셔너리에 넣어 두면, 파이썬은 제목을 해시값으로 바꿔 해당 자리를 단번에 들여다본다. 10권이든 10만 권이든 걸리는 시간이 거의 같다.

그런데 셋에는 "몇 번째 책"이라는 개념이 없다. 순서가 필요하면 리스트여야 한다. 즉 어느 하나가 우월한 게 아니라 포기하는 것과 얻는 것이 다르다. 아래 표가 이번 단원 전체의 요약이다.

구조표기순서수정중복이럴 때 쓴다
리스트 list[ ]있다가능허용순서대로 쌓이고 계속 바뀌는 목록 (판매 기록, 장바구니)
튜플 tuple( )있다불가허용한 덩어리로 고정된 값 (ISBN·제목·가격 한 세트)
딕셔너리 dict{키: 값}넣은 순서가능키 중복 불가이름으로 찾아야 하는 것 (제목 → 재고 수량)
셋 set{ }없다가능불가중복 제거, 포함 여부 검사 (분류 태그)

문법과 예제

리스트 — 순서가 있고 계속 바뀌는 것

titles = ["파이썬 개론", "자료구조 입문", "리팩터링", "클린 코드"]

print(titles[0])     # 파이썬 개론      ← 번호는 0부터다
print(titles[-1])    # 클린 코드        ← 음수는 뒤에서부터다
print(len(titles))   # 4

슬라이싱은 [시작:끝]이고 끝 번호는 포함하지 않는다. 처음엔 어색하지만 [a:b]의 길이가 항상 b - a라는 편리함이 있다.

print(titles[1:3])   # ['자료구조 입문', '리팩터링']   1번부터 3번 직전까지
print(titles[:2])    # ['파이썬 개론', '자료구조 입문']  처음부터
print(titles[-2:])   # ['리팩터링', '클린 코드']         마지막 두 개

내용을 바꾸는 메서드들이다.

titles.append("SQL 첫걸음")        # 맨 뒤에 추가
titles.insert(0, "알고리즘 입문")   # 0번 자리에 끼워 넣기
titles.remove("클린 코드")          # 값으로 지우기 (없으면 ValueError)
last = titles.pop()                 # 맨 뒤를 꺼내면서 지우기

print("리팩터링" in titles)         # True   ← 포함 여부
print(titles.index("리팩터링"))     # 3      ← 몇 번째인지

정렬은 두 가지가 있고 이 둘의 차이가 초보자 오류 1순위다.

titles.sort()              # 원본을 직접 정렬한다. 돌려주는 값은 None 이다
new_list = sorted(titles)  # 원본은 그대로 두고 정렬된 새 리스트를 돌려준다

튜플 — 한번 정해지면 안 바뀌는 한 덩어리

튜플은 만든 뒤 원소를 바꿀 수 없다. 그래서 "이 세 값은 원래 한 세트이고 따로 놀면 안 된다"는 뜻을 코드로 표현할 때 쓴다. 책 한 권의 ISBN·제목·정가가 그런 예다.

book = ("978-89-1234-567-0", "파이썬 개론", 28000)

isbn, name, price = book        # 언패킹: 한 번에 세 변수로 풀어 준다
print(name, price)              # 파이썬 개론 28000

book[2] = 25000                 # TypeError: 'tuple' object does not support item assignment

언패킹은 파이썬 코드 어디에나 나온다. 두 변수를 맞바꾸는 관용구도 여기서 나온다.

a, b = b, a     # 임시 변수가 필요 없다

원소가 하나인 튜플에는 콤마가 반드시 필요하다. 괄호가 아니라 콤마가 튜플을 만들기 때문이다.

one = ("파이썬 개론",)   # 원소 1개짜리 튜플
oops = ("파이썬 개론")    # 그냥 문자열이다. 괄호는 아무 의미가 없다

딕셔너리 — 이름으로 찾는 것

서점에서 가장 많이 쓰는 구조다. "파이썬 개론이 몇 권 남았지"에 바로 답한다.

inventory = {
    "파이썬 개론": 3,
    "리팩터링": 0,
    "SQL 첫걸음": 12,
}

print(inventory["파이썬 개론"])   # 3
inventory["클린 코드"] = 5        # 없으면 추가, 있으면 덮어쓰기
del inventory["리팩터링"]         # 삭제

print("SQL 첫걸음" in inventory)  # True   ← in 은 '키'를 검사한다
print(12 in inventory)            # False  ← 값 12 가 있어도 False 다

없는 키를 대괄호로 꺼내면 KeyError로 프로그램이 멈춘다. 없을 수도 있는 키는 get()을 쓴다.

print(inventory.get("유령책"))       # None    ← 없으면 None
print(inventory.get("유령책", 0))    # 0       ← 기본값을 정할 수 있다
print(inventory["유령책"])           # KeyError: '유령책'

키·값·쌍을 통째로 꺼내는 방법도 알아 둔다. 4단원에서 반복문과 함께 쓰면 진가가 나온다.

print(list(inventory.keys()))    # ['파이썬 개론', 'SQL 첫걸음', '클린 코드']
print(list(inventory.values()))  # [3, 12, 5]
print(list(inventory.items()))   # [('파이썬 개론', 3), ('SQL 첫걸음', 12), ('클린 코드', 5)]

출력 순서가 넣은 순서 그대로라는 점을 눈여겨본다. 파이썬 3.7부터 딕셔너리는 삽입 순서를 보장한다. 그 이전 버전에서는 보장되지 않았고, 오래된 블로그 글들이 "딕셔너리는 순서가 없다"고 쓴 이유가 이것이다. 3.11 기준으로는 순서가 있다.

책 한 권의 여러 정보를 담아야 하면 딕셔너리를 값으로 넣는다.

books = {
    "파이썬 개론": {"price": 28000, "stock": 3, "author": "김서점"},
    "SQL 첫걸음": {"price": 22000, "stock": 12, "author": "이질의"},
}
print(books["SQL 첫걸음"]["price"])   # 22000

셋 — 중복 없이, 있는지만 본다

tags = {"초급", "베스트", "품절임박", "초급"}
print(len(tags))          # 3   ← 중복은 만들 때 사라진다
print("베스트" in tags)   # True

new_tags = {"베스트", "신간"}
print(tags & new_tags)    # 교집합 {'베스트'}
print(tags | new_tags)    # 합집합
print(tags - new_tags)    # 차집합 {'초급', '품절임박'}

셋을 출력하면 넣은 순서와 다르게 나오고, 실행할 때마다 달라질 수도 있다. 순서를 포기한 대가로 검색 속도를 얻은 구조이므로 당연한 결과다. 순서가 필요하면 sorted(tags)로 리스트를 만들어 쓴다.

실무에서 셋을 가장 많이 쓰는 곳은 중복 제거다. 판매 기록에서 팔린 책 종류만 뽑는다면 이 한 줄이면 된다.

sold = ["파이썬 개론", "리팩터링", "파이썬 개론", "SQL 첫걸음", "리팩터링"]
kinds = set(sold)
print(len(kinds))         # 3   ← 5건이 팔렸고 종류는 3가지다

빈 셋을 만들 때만 주의한다. {}는 빈 딕셔너리이고, 빈 셋은 set()이다.

실무에서 자주 틀리는 것

1. 대입은 복사가 아니다

가장 큰 사고가 여기서 난다. 리스트를 다른 이름에 대입하면 새 리스트가 만들어지는 게 아니라 같은 값에 이름표가 하나 더 붙을 뿐이다. 2단원의 이름표 비유가 여기서 현실이 된다.

original = ["파이썬 개론", "리팩터링"]
backup = original          # 백업했다고 생각한다

backup.append("클린 코드")
print(original)            # ['파이썬 개론', '리팩터링', '클린 코드']  ← 원본도 바뀌었다
print(original is backup)  # True  ← 애초에 같은 것이다

진짜로 복사하려면 새 리스트를 만들어야 한다.

backup = original[:]        # 슬라이싱으로 통째 복사
backup = list(original)     # 또는 이렇게
backup = original.copy()    # 또는 이렇게

다만 이 세 가지는 모두 얕은 복사다. 바깥 리스트만 새로 만들고 안에 든 리스트나 딕셔너리는 여전히 공유한다.

books = [["파이썬 개론", 3], ["리팩터링", 0]]
shallow = list(books)
shallow[0][1] = 999
print(books)      # [['파이썬 개론', 999], ['리팩터링', 0]]  ← 안쪽은 같이 바뀐다

import copy
deep = copy.deepcopy(books)   # 속까지 전부 새로 만든다
deep[0][1] = 1
print(books)      # [['파이썬 개론', 999], ['리팩터링', 0]]  ← 이번엔 안 바뀐다

튜플이 안전한 이유가 이것이다. 애초에 못 바꾸니 이런 사고가 날 수 없다. "이 목록은 절대 안 바뀐다"면 튜플로 두는 것이 방어책이다. 단, 튜플 안에 리스트를 넣으면 그 리스트의 내용은 바뀐다. 튜플이 지키는 것은 "몇 번째 칸이 무엇을 가리키는가"까지다.

book = ("파이썬 개론", ["초급", "베스트"])
book[1].append("품절임박")     # 이건 된다
print(book)                    # ('파이썬 개론', ['초급', '베스트', '품절임박'])

2. sort() 의 결과를 변수에 담는다

titles = titles.sort()   # titles 가 None 이 된다
print(titles[0])         # TypeError: 'NoneType' object is not subscriptable

sort(), append(), remove(), insert()는 모두 원본을 바꾸고 None을 돌려준다. 파이썬이 정한 일관된 규칙이다. 원본을 바꾸는 메서드는 결과를 돌려주지 않는다. 새 값을 받고 싶으면 sorted()처럼 원본을 건드리지 않는 함수를 쓴다. 오류 메시지에 'NoneType' object가 보이면 십중팔구 이 실수다.

3. 없을 수도 있는 키를 대괄호로 꺼낸다

내 컴퓨터의 시험 데이터에는 항상 그 키가 있으니 개발 중엔 멀쩡하다. 운영 데이터에 빠진 항목이 하나 들어오는 순간 KeyError로 죽는다. 키가 없을 가능성이 조금이라도 있으면 get()에 기본값을 준다. 특히 재고처럼 "없으면 0"이 자연스러운 값은 inventory.get(title, 0)이 정답이다.

4. 딕셔너리 키에 리스트를 쓴다

d = {["초급", "베스트"]: "분류A"}   # TypeError: unhashable type: 'list'

키는 해시값을 계산할 수 있어야 하고, 그러려면 값이 변하지 않아야 한다. 리스트는 바뀌므로 키가 될 수 없다. 여러 값을 묶어 키로 쓰려면 튜플을 쓴다. 셋의 원소도 같은 이유로 튜플은 되고 리스트는 안 된다. "튜플이 왜 필요한가"에 대한 가장 실용적인 답이 이것이다.

스스로 확인하기

  1. 판매 기록 sold = ["파이썬 개론", "리팩터링", "파이썬 개론"]에서 팔린 권수팔린 책 종류 수를 각각 구하는 코드는?
  2. 다음 코드의 출력은 무엇인가?
    a = {"파이썬 개론": 3}
    b = a
    b["파이썬 개론"] = 0
    print(a["파이썬 개론"])
  3. 재고 딕셔너리에서 "클린 코드"의 재고를 꺼내되, 등록되지 않은 책이면 0으로 처리하려 한다. 코드 한 줄로 쓰면?

정답

  1. 권수는 len(sold)로 3, 종류는 len(set(sold))로 2다. 같은 데이터를 어느 그릇에 담아 보느냐로 다른 질문에 답하는 예다. 참고로 책별 판매 수량은 sold.count("파이썬 개론")으로 2가 나온다.
  2. 0이 출력된다. b = a는 복사가 아니라 같은 딕셔너리에 이름표를 하나 더 붙인 것이므로 b를 고치면 a도 고쳐진다. 원본을 지키려면 b = a.copy()가 필요하다. 리스트뿐 아니라 딕셔너리와 셋에도 똑같이 적용되는 규칙이다.
  3. stock = inventory.get("클린 코드", 0)이다. inventory["클린 코드"]는 없을 때 KeyError로 프로그램을 멈추고, get()에 기본값을 주지 않으면 None이 나와 뒤에서 None + 1 같은 TypeError로 이어진다. 기본값까지 주는 것이 한 세트다.

다음 단원에서는 이 그릇들을 열어 하나씩 처리하는 방법을 다룬다. 지금까지는 inventory["파이썬 개론"]처럼 한 건씩 이름을 적어 꺼냈지만, 4단원의 반복문부터는 몇 권이 들어 있든 같은 코드로 처리한다.