리스트, 딕셔너리, 집합: 데이터 묶음 다루기

여러 값을 한데 담는 리스트, 튜플, 딕셔너리, 집합의 차이와 쓰임새를 익히고, 컴프리헨션, 중첩 데이터, JSON, 그리고 같은 리스트를 두 이름이 공유할 때 생기는 함정까지 다뤄요.

초급강의 5 / 6약 9분

실제 프로그램이 다루는 데이터는 거의 언제나 묶음이에요. 장바구니의 상품들, 학생들의 점수, 회원 한 명의 이름과 이메일과 가입일. 이번 강의에서는 파이썬이 묶음을 담는 네 가지 기본 그릇을 배우고, 상황에 맞는 그릇을 고르는 눈을 길러요.

네 가지 그릇 한눈에 보기

그릇모양순서중복바꿀 수 있나대표 쓰임
리스트 list[1, 2, 3]있음허용예순서 있는 목록
튜플 tuple(1, 2, 3)있음허용아니요고정된 값 묶음
딕셔너리 dict{"a": 1}넣은 순서 유지키는 불가예이름으로 찾기
집합 set{1, 2, 3}없음불가예중복 제거, 포함 검사

리스트: 순서 있는 목록

만들고 꺼내기

scores = [88, 72, 95, 60]
print(scores[0])      # 88  첫 번째
print(scores[-1])     # 60  마지막
print(scores[1:3])    # [72, 95]
print(len(scores))    # 4
print(95 in scores)   # True

문자열에서 배운 인덱싱과 슬라이싱이 그대로 통해요.

바꾸기

fruits = ["사과", "바나나"]
fruits.append("포도")          # 끝에 추가
fruits.insert(0, "딸기")       # 0번 자리에 끼워 넣기
fruits.remove("바나나")         # 값으로 지우기 (처음 만나는 하나)
last = fruits.pop()           # 마지막을 꺼내며 지우기
fruits[0] = "귤"              # 자리를 지정해 바꾸기
print(fruits, last)           # ['귤', '사과'] 포도

정렬: sort와 sorted

nums = [5, 2, 9, 1]
print(sorted(nums))     # [1, 2, 5, 9]  새 리스트를 만들어 돌려줌
print(nums)             # [5, 2, 9, 1]  원본은 그대로

nums.sort(reverse=True) # 원본 자체를 내림차순으로 바꿈
print(nums)             # [9, 5, 2, 1]

원본을 지키고 싶으면 sorted, 원본을 바꿔도 되면 .sort()예요. 기준을 정해 정렬할 수도 있어요.

words = ["banana", "kiwi", "apple"]
print(sorted(words, key=len))   # 길이순: ['kiwi', 'apple', 'banana']

리스트 컴프리헨션

반복문으로 새 리스트를 만드는 일은 너무 흔해서, 한 줄로 쓰는 문법이 따로 있어요.

# 평범한 방법
squares = []
for n in range(1, 6):
    squares.append(n * n)

# 컴프리헨션
squares = [n * n for n in range(1, 6)]   # [1, 4, 9, 16, 25]

# 조건 붙이기
evens = [n for n in range(1, 21) if n % 2 == 0]

# 문자열 다듬기
names = ["  민지 ", "준혁", " 서연"]
clean = [name.strip() for name in names]

읽는 순서는 "range에서 n을 하나씩 꺼내서, 조건에 맞으면, n * n을 담는다"예요. 다만 한 줄이 너무 길어지거나 조건이 복잡해지면 평범한 for문이 더 읽기 쉬워요. 짧고 분명할 때만 쓰세요.

튜플: 바뀌지 않는 묶음

point = (3, 4)
x, y = point          # 풀어서 담기 (언패킹)
print(x, y)           # 3 4

point[0] = 10         # TypeError: 튜플은 바꿀 수 없어요

좌표, 날짜(년, 월, 일), 함수가 돌려주는 여러 값처럼 한 덩어리로 다루고 바뀌면 안 되는 값에 써요. 바꿀 수 없다는 제약이 오히려 실수를 막아 줘요.

언패킹은 두 변수를 맞바꿀 때도 쓸 수 있어요.

a, b = 1, 2
a, b = b, a
print(a, b)   # 2 1

딕셔너리: 이름으로 찾기

리스트는 번호로 찾지만, 딕셔너리는 키라는 이름으로 값을 찾아요.

user = {
    "name": "민지",
    "age": 30,
    "email": "[email protected]",
}
print(user["name"])        # 민지
user["age"] = 31           # 바꾸기
user["city"] = "서울"       # 새 키 추가
del user["email"]          # 지우기

없는 키 다루기

print(user["phone"])              # KeyError!
print(user.get("phone"))          # None  (오류 없이)
print(user.get("phone", "없음"))   # 없음  (기본값 지정)
print("phone" in user)            # False

데이터가 있을지 없을지 모를 때는 get을 쓰세요. 외부에서 받은 데이터를 다룰 때 특히 중요해요.

하나씩 꺼내기

for key, value in user.items():
    print(f"{key}: {value}")

print(list(user.keys()))     # 키만
print(list(user.values()))   # 값만

세기: 딕셔너리의 대표 쓰임

문장에서 단어가 몇 번 나오는지 세어 볼게요.

text = "사과 바나나 사과 포도 사과 바나나"
count = {}
for word in text.split():
    count[word] = count.get(word, 0) + 1
print(count)   # {'사과': 3, '바나나': 2, '포도': 1}

get(word, 0)이 핵심이에요. 처음 보는 단어면 0에서 시작하고, 본 적 있으면 지금 값에 1을 더해요. 표준 라이브러리의 Counter를 쓰면 더 짧아져요.

from collections import Counter
count = Counter(text.split())
print(count.most_common(2))   # [('사과', 3), ('바나나', 2)]

집합: 중복 없는 모음

tags = ["python", "ai", "python", "web", "ai"]
unique = set(tags)
print(unique)            # {'python', 'ai', 'web'}  순서는 보장 안 됨

a = {"python", "sql", "linux"}
b = {"python", "javascript"}
print(a & b)   # 교집합 {'python'}
print(a | b)   # 합집합
print(a - b)   # 차집합 {'sql', 'linux'}

집합이 빠른 이유

x in 리스트는 처음부터 끝까지 하나씩 비교해요. 원소가 100만 개면 최악의 경우 100만 번이에요. 반면 x in 집합은 원소가 몇 개든 거의 한 번에 찾아요. 집합과 딕셔너리는 해시라는 방법으로 값의 자리를 바로 계산하기 때문이에요(알고리즘 트랙의 해시 테이블 강의에서 원리를 다뤄요).

banned = set(open_banned_word_list())   # 금칙어 수만 개라면
if word in banned:                      # 집합이라 즉시 확인
    ...

"이 값이 들어 있나?"를 많이 묻는다면 리스트 대신 집합을 쓰세요.

중첩 데이터

현실의 데이터는 묶음 안에 묶음이 들어 있어요. 가장 흔한 모양은 딕셔너리의 리스트예요.

products = [
    {"name": "키보드", "price": 89000, "tags": ["전자", "입력"]},
    {"name": "마우스", "price": 35000, "tags": ["전자", "입력"]},
    {"name": "책상", "price": 150000, "tags": ["가구"]},
]

# 5만 원 이하 상품 이름만
cheap = [p["name"] for p in products if p["price"] <= 50000]

# 가격순 정렬
by_price = sorted(products, key=lambda p: p["price"])

# 전체 합계
total = sum(p["price"] for p in products)

lambda p: p["price"]는 이름 없는 짧은 함수예요. "상품 p를 받아 가격을 돌려준다"는 뜻이고, 정렬 기준을 정할 때 자주 써요.

JSON: 데이터를 주고받는 표준 모양

웹 API와 설정 파일은 대부분 JSON 형식을 써요. 파이썬의 딕셔너리, 리스트와 모양이 거의 같아서 바꾸기 쉬워요.

import json

data = {"name": "민지", "skills": ["python", "sql"], "active": True}

text = json.dumps(data, ensure_ascii=False, indent=2)   # 파이썬 → JSON 문자열
print(text)

back = json.loads(text)                                 # JSON 문자열 → 파이썬
print(back["skills"][0])                                # python

ensure_ascii=False를 넣어야 한글이 민 같은 기호로 바뀌지 않고 그대로 나와요.

함정: 같은 리스트를 두 이름이 공유할 때

a = [1, 2, 3]
b = a
b.append(4)
print(a)   # [1, 2, 3, 4]  a도 바뀌었어요!

변수는 이름표라고 했죠. b = a는 새 리스트를 만드는 게 아니라 같은 리스트에 이름표를 하나 더 붙인 것이에요. 독립된 복사본이 필요하면 복사해야 해요.

b = a.copy()        # 또는 list(a), a[:]

안에 리스트가 또 들어 있는 중첩 구조라면 바깥만 복사돼요. 전부 복사하려면 copy.deepcopy()를 써요. 이 함정은 숙련자도 가끔 빠지는 버그의 단골 원인이에요.

어떤 그릇을 고를까

  • 순서가 중요하고, 값을 추가하거나 바꾼다 → 리스트
  • 몇 개의 값이 한 덩어리이고 바뀌면 안 된다 → 튜플
  • 이름(키)으로 값을 찾는다, 무언가를 센다 → 딕셔너리
  • 중복을 없앤다, 포함 여부를 빠르게 확인한다 → 집합

실습: 성적 관리 프로그램

students = [
    {"name": "민지", "scores": [88, 92, 79]},
    {"name": "준혁", "scores": [72, 65, 80]},
    {"name": "서연", "scores": [95, 98, 91]},
]

for s in students:
    s["avg"] = sum(s["scores"]) / len(s["scores"])

ranked = sorted(students, key=lambda s: s["avg"], reverse=True)
for rank, s in enumerate(ranked, start=1):
    print(f"{rank}등 {s['name']}: 평균 {s['avg']:.1f}점")

도전 과제

  1. 평균 80점 이상인 학생 이름만 리스트로 뽑아 보세요(컴프리헨션으로).
  2. 과목이 세 개라고 할 때, 과목별 평균을 구해 보세요. 힌트: zip(*[s["scores"] for s in students])
  3. 결과를 report.json 파일로 저장해 보세요. 파일 쓰기는 다음 강의에서 배우지만, 미리 찾아봐도 좋아요.

자주 하는 실수

  • 딕셔너리에 없는 키를 바로 꺼내기. KeyError가 나요. 확실하지 않으면 get을 쓰세요.
  • sort의 결과를 담기. nums = nums.sort()라고 쓰면 nums가 None이 돼요. .sort()는 원본을 바꾸고 아무것도 돌려주지 않아요.
  • 반복하면서 같은 리스트에서 지우기. 순회 중에 원소를 지우면 일부를 건너뛰어요. 새 리스트를 만들어 담으세요.
  • 복사 대신 공유. b = a는 복사가 아니에요.

점검 문제

  1. [n % 3 for n in range(6)]의 결과는?
  2. d = {"a": 1}일 때 d.get("b", 0) + 5의 값은?
  3. 중복을 제거하면서 원래 순서를 유지하려면 어떻게 할까요?
  4. x = [[0] * 3] * 2로 만든 뒤 x[0][0] = 9를 하면 x는 어떻게 될까요?

정답

  1. [0, 1, 2, 0, 1, 2]
    1. 키 "b"가 없으니 기본값 0을 돌려받아요.
  2. list(dict.fromkeys(items))를 쓰면 돼요. 딕셔너리는 넣은 순서를 유지하고 키는 중복되지 않아요.
  3. [[9, 0, 0], [9, 0, 0]]이 돼요. * 2가 같은 안쪽 리스트를 두 번 공유하기 때문이에요. 독립된 줄을 원하면 [[0] * 3 for _ in range(2)]처럼 만들어야 해요.

다음 강의

다음 강의는 프로그래밍 기초의 마지막이에요. 파일을 읽고 쓰는 법, 오류가 나도 프로그램이 멈추지 않게 하는 예외 처리를 배우고, 지금까지 배운 것을 모아 실제로 쓸모 있는 자동화 프로그램을 만들어요.