Home » 파이썬 – 사전은 처음이라

파이썬 – 사전은 처음이라

파이썬은 리스트, 튜플, set과 더불어서 가장 기본적인 파이썬의 집합(collection)형 자료 구조입니다. 다른 집합 자료형들이 단일 객체들을 모아놓은 것과 달리, 사전은 각각의 값들이 대응하는 키와 짝을 이뤄 모여있는 형태입니다. 사전은 메뉴판에서 메뉴이름과 가격을 짝지어둔 것과 같은 자료 형식을 표현하는데 딱 알맞은 자료형이며, 그 외에도 많은 활용 방법이 있습니다. 리스트 만큼이나 필수적으로 알고 있어야 하는 데이터 타입인 사전에 대해서 자세히 알아보겠습니다.

사전 만드는 방법 🛠

사전을 만드는 가장 기본적인 방법은 사전 리터럴 문법입니다. 리터럴(literal)은 미리 정해진 표기법을 사용해서 어떤 값을 표현하는 방법입니다. 사전은 기본적으로 중괄호(curly braces, { })를 사용해서 둘러싼 키-값 쌍의 목록으로 표현합니다. 키와 값은 콜론(:)으로 구분하며, 각각의 키-값 쌍은 콤마로 구분합니다. 기본적으로 사전을 만드는 문법은 다음과 같습니다.

aDict = { "name": "홍길동", "age": 2 }

사전의 키에는 정수나 실수, 문자열등이 될 수 있으며, 튜플도 사전의 키로 사용될 수 있습니다. 단 리스트나 다른 사전은 사전의 키가 될 수 없습니다. 파이썬 내장 타입의 객체가 사전의 키가 되려면 불변 객체여야 하는 조건이 있습니다. 사용자가 직접 만든 커스텀 클래스의 인스턴스 객체는 내부 프로퍼티를 변경할 수 있지만, 사전의 키로 사용될 수 있습니다. 사전의 키에 대해서는 뒤에서 다시 설명하겠습니다.

사전을 생성하는 다른 방법으로는 dict() 함수를 사용하는 방법이 있습니다. 각 키와 값을 키워드 인자로 전달하는 겁니다. 위의 예제과 같은 사전은 다음과 같이 만들 수 있습니다. 각 원소의 키와 값을 키워드인자 형식으로 넘겨주는 것입니다.

dict(name="홍길동", age=2)

혹은 키-값 쌍을 하나의 튜플로 만들어서, 이 튜플의 리스트/튜플을 dict() 함수의 인자로 전달하는 것도 가능합니다.

dict([("name", "홍길동"), ("age", 2)])

이 방법은 타이핑에 가장 품이 많이 들어가지만, 실제로는 매우 유용합니다. 만약 키의 리스트와 값의 리스트가 따로 존재하고 이를 하나의 사전으로 묶을 때 사용할 수 있습니다. 두 리스트의 같은 순서에 있는 원소들을 하나로 묶을 때에는 내장함수 zip()을 사용합니다. (zip이라는 이름은 지퍼(zipper)와 같이 마주보는 원소들을 서로 붙인다는 의미로 이해하면 됩니다.) 다음과 같이 사용할 수 있습니다.

keys = ["name", "age"]
values = ["홍길동", 2]
dict(zip(keys, values))

사실 이 방법은 파이썬 쉘에서 가장 손쉽게 사전을 생성해내는 좋은 수단이니, 알아두면 좋을 것 같습니다. 다음과 같이 원소 5개짜리 사전을 한 줄의 코드로 만들 수 있습니다.

dict(zip("abcde", range(5)))
# => { "a": 0, "b": 1, "c": 2, "d": 3, "e": 4 }

빈 사전을 만들 때에는 그냥 {} 이라고 쓰거나 dict()를 쓰면 됩니다.

사전 축약문법 (dictionary comprehension) ✨

리스트 축약(List Comprehension) 문법 처럼, 사전 축약 문법을 쓸 수 있습니다. 리스트 축약 문법과 비슷한데, 대괄호 대신 중괄호를 쓰고, 각 원소에는 키:값의 형태로 식을 써주면 됩니다.

{ expr1:expr2  for ... in expr3 }

예를 들어 몇 개의 영어단어와 그 글자 수를 짝지은 사전은 다음과 같이 생성할 수 있습니다.

words = 'apple banana cherry oragne'
{ word:len(word) for word in words }

키와 값의 리스트가 각각 있는 경우에는 2중 for 문을 돌리는게 아니라 앞에서 설명한 것처럼 zip() 함수를 묶어서 쓰면 됩니다.

keys = [1,2,3,4,5]
values = [10,20,30,40,50]
{ k:v for (k, v) in zip(keys, values) }

어떤 사전의 키-값 관계를 바꿔서 새로운 사전을 만들려면 다음과 같이 할 수도 있겠죠.

{ v:k for (k, v) in aDict.items() }

하여튼 파이썬에서 축약문법은 아주아주 익숙해질 수 있도록 많이 연습하시는 것을 잊지 마세요.

사전의 원소 참조하기 📙

사전은 키-값 쌍의 집합인데, 이 때 키는 짝지어진 값을 찾는 용도로 사용됩니다. 사전에서 특정한 값을 찾을 때에는 해당 키를 사용해서 찾는데, 이 때 문법은 리스트와 비슷합니다. 사전은 가변적인 집합 컨테이너이기 때문에 이 문법을 사용해서 기존 값을 변경하거나, 추가할 수 있습니다. del 구문을 사용하면 특정 키-값 쌍을 사전에서 제거할 수 있습니다.

d = dict(zip(["name", "age"], ["홍길동", 21]))
print(d["name"])
# => 홍길동

d["gender"] = "male"
d["age"] = 25
d["foo"] = 1
del d["foo"] # 키 foo와 그 값을 제거합니다.

만약 사전에서 존재하지 않는 키를 사용해서 값을 찾으려고 하면 KeyError가 발생합니다.

address = aDict["address"]
KeyError: 'address'

특정 키의 멤버십 확인

사전이 어떤 키를 가지고 있는지를 알아보려면, 간단히 in 연산자를 쓰면 됩니다. 리스트에서 특정 값이 있는지를 살펴보거나, 어떤 문자열이 다른 문자열에 포함되는지를 알아볼 때에도 in을 쓸 수 있는 것과 같은 문법입니다.

"age" in aDict
# True
"email" in aDict
# False

그런데 이렇게 특정한 키를 사전에서 찾는 것은 매우 빠릅니다. 예를 들어 길이가 1,000,000인 리스트에서 어떤 값이 포함돼 있는지를 알아본다고 하겠습니다. 그러면 a[0]부터 하나씩 비교를 해야하고, 운이 없다면 a[999999]까지 비교를 마친 후에 해당 값이 리스트에 포함돼있지 않다는 것을 알게 됩니다. 하지만 사전에서는 in 연산이 매우 빠릅니다. 사전은 해시테이블이라는 구조를 사용해서 만들어져 있습니다. 즉 키에 대한 해시값을 구하고, 그 해시값이 가리키는 위치에 값이 있는지를 검사하기 때문에 크기가 아무리 크더라도 곧장 키의 존재 여부를 알 수 있습니다.

이렇게 멤버십 검사가 매우 빠르다는 특성을 이용해서, 사전을 캐시로 활용하여 알고리듬의 성능을 높일 수 있습니다. (관련글)

존재하지 않는 키를 안전하게 처리하는 방법

사전에서 없는 키를 찾으려고 하면 KeyError가 발생한다고 앞에서 이야기했었습니다. 이런 에러를 방지하기 위해서는 키의 존재여부를 미리 확인하면 됩니다. 이는 in 연산자로 알고 있다고 했으니, 다음과 같은 식으로 미지의 키로 액세스하는 코드를 작성할 수 있습니다. (존재하지 않는 키의 값을 None으로 간주합니다.)

# mydict에서 k 값의 키를 찾고, 없으면 None으로 간주하기
if k in mydict:
  a = mydict[k]
else:
  a = None

# 한 줄에 쓰면
a = mydict[k] if k in mydict else None

그런데 실제로 코드를 작성할 때에는 이런 상황을 제법 많이 만나게 됩니다. 이런 경우에는 .get(키, 디폴트값=None) 메소드를 활용하면 좋습니다. 이 메소드는 인자로 받은 키가 사전에 있으면, 해당 키에 대응하는 값을 리턴하고, 키가 없다면 디폴트값을 리턴합니다. 디폴트값은 생략하면 None 입니다. 따라서 위 예시는 아래와 같이 간단히 표현이 가능합니다.

a = mydict.get(k)

다음 예제는 난수 리스트에서 각 숫자가 몇 개나 나오는지를 세는 예시입니다. 사전에는 등장한 정수와 그 정수의 등장 횟수가 저장됩니다.

from random import randint
xs = [randint(1, 100) for _ in range(1_000_000)]
cnt = {}
for x in xs:
  cnt[x] = cnt.get(x, 0) + 1

.get(k) 과 비슷하게 .setdefault(key, value)가 있습니다. get() 메소드는 지정한 키가 만약 사전에 없다면 주어진 디폴트값을 대신 리턴하는 것만 합니다. 반면 setdefault()는 지정한 키가 없다면 디폴트 값을 리턴하는 것까지는 동일한데, 이 때 키-디폴트값을 해당 사전에 추가해줍니다. 이 메소드는 특히 사전의 값이 정수나 문자열 같은 단일 값이 아니라 리스트 같은 것일 때 유용하게 사용될 수 있습니다.

간단한 예를 하나 들어보겠습니다. 주어진 난수 배열에서 2, 3, 5, 7의 배수를 찾아서 각각의 리스트를 만드는 코드입니다. setdefault()를 사용하지 않고 구현할 수 있습니다. 직접 구현해보고 비교해보는 것도 좋겠습니다.

from random import randrange
xs = [randrange(1000) + 1 for _ in range(10000)]
result = {}
for x in xs:
    for k in (2, 3, 5, 7):
        if x % k == 0:
            result.setdefault(k, []).append(x)

원소를 제거하기

사전에서 특정한 키-값 쌍을 제거하는데에는 del 구문을 쓴다고 했습니다만, 사실 (저는) 잘 쓰지 않습니다. 대신에 pop(key) 메소드나 popitem() 메소드를 사용할 수 있습니다. pop(key) 메소드는 전달된 키에 대한 값을 리턴하면서, 해당 키-값 쌍을 사전에서 제거합니다. 리스트에서 .pop()은 맨 끝에 있는 원소를 떼내는 동작임에 반해, 사전에서 pop()은 반드시 키를 지정해야 한다는 차이가 있습니다.

참, pop() 메소드에서 전달한 키가 사전에 존재하지 않는다면, KeyError가 발생합니다.

이 차이는 사전은 키를 기준으로 값을 찾는 구조이기 때문에, 키-값 쌍의 원소들 사이에는 순서가 없었기 때문입니다. 근데 여기서 ‘없없다’고 과거형을 쓴 이유는…. 현재는 순서가 있습니다. 파이썬 3.5부터는 사전의 키-값 쌍은 추가된 순서를 유지하고 있습니다.

popitem() 메소드는 원래 사전 내에서 아무 원소나 제거하면서 리턴하는 용도로 썼습니다. 그런데 파이썬 3.5부터 사전내 원소에도 순서가 생기면서, 가장 최근에 추가된 키-값 쌍을 제거하면서, (키, 값)의 튜플을 리턴하도록 동작합니다.

원래 사전은 키를 기반으로 원소를 탐색하며, 저장되는 키-값 쌍에는 순서가 있을 수 없었습니다. (실제로 저장되는 값은 해시값에 의해서 위치가 결정되었기 때문입니다.) 하지만 파이썬 3.5부터는 메모리의 효율성을 높이기 위해서 별도의 배열에 키 테이블을 저장하는 방식을 도입했고 그 결과 사전에 키값 쌍이 추가된 순서가 유지되게 되었습니다.

TMI: pypy라는 파이썬 구현체에서 성능을 높일 목적으로 이러한 방식을 도입한 것이 거꾸로 파이썬으로 도입되어 현재 표준 파이썬 구현에 포함되었습니다.

사전을 순회하기

사전의 각 원소는 키-값의 쌍이므로 사전에 대해서는 다음 세 가지 경우에 대해 순회가 가능합니다.

  • dict.keys() : 사전의 각 키
  • dict.values() : 사전의 각 값
  • dict.items() : 사전의 각각의 (키, 값) 쌍의 튜플

위 메소드의 결과는 모두 반복가능한 객체이므로 for _ in ... 구문에 사용할 수 있습니다. 사전에 대해서 순회하는 경우에는 각각의 키를 순회하는 것으로 간주됩니다.

사전 병합과 업데이트

사전에 이미 존재하는 원소에서 값을 바꾸려면 d[key] =newValue와 같이 업데이트 할 수 있습니다. 이 동작은 이미 존재하는 키인 경우에는 값을 교체하고, 존재하지 않는 키의 경우에는 키와 값을 새로 추가합니다. 만약 추가해야할 키-값 쌍이 다른 사전에 있는 많은 양이라면 어떻게 해야할까요? 우선 for 문을 사용해서 업데이트 하는 방법을 생각할 수 있습니다.

# dict_a <- dict_b의 내용을 병합한다.
for (k, v) in dict_b.items():
    dict_a[k] = v

이것을 보다 빠르고 간단하게 처리할 수 있는 방법으로 update() 메소드가 있습니다. update 메소드는 사전을 인자로 받을 수 있고, 키워드 인자를 사용해서 추가적인 갱신을 할 수 있습니다.

dict_a.update(dict_b)

주의할 것은 update() 메소드는 inplace 변경으로, 호출된 사전 자신을 변경하며, 값을 리턴하지 않습니다. (None을 리턴합니다.) 만약, 두 사전을 변경하지 않고, 키-값 쌍을 합친 또 다른 사전을 만들려면 어떻게 해야할까요? for 문을 두 번 돌거나, 새 사전을 만들어서 update()를 두 번 사용하는 방법등이 있습니다. 보다 우아하게 처리할 수 있는 방법으로는 dict() 생성자에 두 개의 사전을 ‘풀어서’ 넣는 방법이 있습니다.

new_dict = dict(**dict_a, **dict_b)

하지만, 이 경우에 dict_a, dict_b 두 사전에 공통으로 포함되는 키가 있다면 에러가 나게 됩니다. 이런 방식으로 처리할 수도 있겠네요.

from itertools import chain
new_dict = dict(chain(dict_a.items(), dict_b.items())

여러 모로 조금 불편한 감이 있습니다. 파이썬 3.8까지는 두 개의 사전을 병합해서 새로운 사전을 만드는 보다 편한 방법이 없었는데, 파이썬 3.9에서 사전 병합 연산자가 새로 도입되었습니다. (PEP 584) 따라서 파이썬 3.9.x 이상 버전에서는 다음과 같이 두 사전을 병합할 수 있습니다.

new_dict = dict_a | dict_ b
# update() 메소드도 |= 연산자로 대체 가능
dict_a |= dict_b

함수의 키워드 인자와 사전

함수를 정의할 때 가변 인자를 정의하는 것과 비슷하게 가변 키워드 인자를 지정할 수 있습니다. 파라미터 이름 앞에 **를 붙이면, 이는 가변 키워드 인자를 선언하는 것이며, 이후 모든 키워드인자들은 파라미터 이름으로된 사전으로 함수 내부로 전달됩니다. 평소에는 잘 사용될 일이 없겠지만, 데코레이터 같은 걸 만들 때, 많이 사용될 수 있습니다.


# 함수를 하나 받아서, 수행시간을 출력해주는 함수로 변환
def timeit(f):
  def wrapped(*a, **b):
    start = time.time()
    r = f(*a, **b)
    end = time.time()
    print(end - start)
    return r
  return wrapped

가변 키워드 인자를 선언하는 것과 똑같은 문법으로, 함수를 호출할 때, 사전앞에 **를 붙여서 사전의 키-값 쌍을 키워드=인자값의 형태로 분해해서 전달하는 것이 가능합니다. 앞서 dict() 함수를 호출할 때 dict(k1=v1, k2=v2,..)의 형태로 호출할 수 있다고 했는데, 이 문법을 사용해서 다음과 같이 사전을 복사하는게 가능합니다.

copied_a = dict(**dict_a)

조금 더 깊이 – 사전의 키와 해시에 대해

사전에서 멤버십 검사 (특정한 키가 있는지 체크하는 검사)는 리스트나 튜플과 같은 연속열 객체 대비해서 매우 빠르다는 이야기를 앞에서 했습니다. 이것은 사전에서 내부적으로 값을 저장하는 방식과 관련이 있습니다. 사전은 해시테이블이라는 구조를 사용하여 데이터를 관리합니다. 실제로 객체를 저장할 때에는 키와 값을 분리하여 저장합니다. 사전에 저장할 키와 값이 주어지면 파이썬은 먼저 키로 주어진 객체에 대해서 해시 연산을 수행합니다. 이 해시 연산을 수행하는 방법은 해당 객체 내부의 __hash__() 메소드를 호출하게 됩니다. 이렇게 얻은 해시값이 가리키는 공간에 값을 지정합니다.

(opens in a new tab)

이렇게하면 불연속적인 메모리 영역 내에 값들을 저장하게 되면 낭비되는 공간이 발생하지만, 나중에 저장된 값을 찾을 때에는 주어진 키에 대해 해시를 구하기만 하면 값이 있는지 여부를 바로 알 수 있습니다. 이렇게해서 사전은 멤버십 테스트를 빠르게 수행합니다.

파이썬 내장 타입들의 해시 값은 값 자체에 의해 결정됩니다. 그런데 사전이나 리스트는 내부의 원소 구성이 변경될 수 있고, 이렇게 내부 원소가 변경되면 객체 자체의 해시 값이 변하게 됩니다. 따라서 사전이나 리스트를 키로 사용하면 나중에 해당 객체를 사용했을 때, 저장된 값을 찾지 못하는 상황이 발생할 수 있습니다. 대신 문자열이나 튜플 같은 경우에는 내부 원소가 고정된 불변데이터(immutable)이기 때문에 해시가 변하지 않는 것이 보장됩니다.

커스텀 클래스의 인스턴스 같은 경우에는 해당 객체의 고유한 id 값을 해시로 사용하게 됩니다. 따라서 이 역시 변하지 않기 때문에 사전의 키로 사용하는데에는 아무런 문제가 없습니다.

이렇게해서 오늘은 파이썬에서 사전 타입의 데이터를 사용하는 기본적인 방법에 대해서 살펴보았습니다. 사전을 활용하는 여러 경우에 대해서는 다음에 기회가 되면 또 소개하도록 하겠습니다. 그럼 안녕~

댓글 남기기