사전은 처음이라

이번 시간에는 사전에 대해서 알아보겠습니다. 사전은 리스트와 더불어 대표적인 (그리고 가장 중요한) 파이썬의 데이터 타입 중 하나입니다. 리스트와 마찬가지로 하나 이상의 값들을 담을 수 있는 집합이며, 특이한 점은 담겨지는 객체들(원소라 부르겠습니다)이 별도의 순서를 가지지 않습니다. 대신 모든 값들은 사전 내에서 참조하기 위한 키를 필요로 하며, 이 키를 통해서 저장된 값을 액세스하게 됩니다. 즉 사전의 모든 원소는 키와 값의 쌍으로 취급됩니다. 논리적으로는 (키, 값)의 튜플로 된 리스트와 비슷한 구조라고 생각해도 무방합니다.

사전 만드는 방법 🛠

사전을 만드는 가장 기본적인 방법은 사전 리터럴 문법입니다. 사전은 중괄호(curly braces)로 감싸지며, 각각의 키값 쌍은 컴마로 구분됩니다. 그리고 개별 원소의 키와 값은 콜론(:)으로 구분합니다. 예를 들면 다음과 같은 식으로 쓴다.

이 문법은 마치 JSON 문법하고 매우 비슷하며, 실제로 json 모듈은 사전을 JSON으로 인코딩하거나, 그 역의 방식으로 동작합니다.

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

사전의 키는 불변(immutable)하다면 어떤 타입의 값이든 될 수 있습니다. 기본적으로 정수나 문자열, 튜플등이 가능하다. 단, 리스트나 사전과 같은 가변 값은 키가 될 수 없습니다. (이에 대한 자세한 설명은 뒤에서 다시 하겠다.) 또 사용자가 정의한 커스텀 클래스의 인스턴스는 사전의 키가 될 수 있습니다.

그외에 사전을 만드는 다른 방법으로는 dict() 함수를 사용하는 방법이 있습니다. dict() 함수는 키워드 인자를 사용해서 각각의 키와 그에 해당하는 값을 넘겨주는 방식으로 쓸 수 있습니다. 단 이때 모든 키워드인자이름은 문자열이므로, 모든 키의 타입이 문자열이 됩니다. 실질적으로 사전을 그냥 생성하기 위해서는 거의 쓰이지 않으며, 여러 사전을 하나로 병합하는 용도로 사용할 수 있습니다.

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

또 dict() 함수에는 (키, 값) 의 형태로 구성되는 튜플의 연속열(리스트나 튜플 등)을 인자로 넘겨줄 수 있으며, dict() 함수는 이를 토대로 각각의 키 값쌍을 원소로 하는 사전을 생성합니다.

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

이렇게 직접 타이핑하는 것은 매우 번거로워 보이지만, 사실 키의 연속열과 값의 연속열을 각각 가지고 있다면 zip() 함수를 사용해서 손쉽게 사전을 생성할 수 있습니다.

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

이는 관련을 가지고 있는 두 개의 리스트의 각 원소를 짝지어서 하나의 사전으로 만드는 방법이며, 파이썬 REPL에서 가장 손쉽게 예시용 사전을 만드는 방법으로 개인적으로 아주 유용하게 쓰는 방법입니다.

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

빈 사전을 만들 때에는  {}dict() 둘 다 써도 상관없지만, 개인적으로는 dict()를 권장합니다. 왜냐하면 중괄호는 사전 뿐만 아니라 set()을 만드는 리터럴이기도 하기 때문인데, {} 만 쓴다면 이것이 빈 사전인지 빈 집합인지 구분하기 어렵기 때문입니다. (물론 보통은 사전으로 간주하겠지만요)

사전 축약문법 ✨

리스트 축약(List Comprehension) 문법과 비슷하게 사전 축약 문법도 존재합니다. 사전 리터럴에 키/값에 대한 두 개의 표현식을 : 으로 연결한다는 것만 제외하면 리스트 축약 문법과 동일합니다.

{ EXPR_k : EXPR_v for (k, v) in SEQ }

주의해야 할 점은 키에 대한 연속열과 값에 대한 연속열이 각각 따로 있을 때 2중 for … in 구문을 사용해서는 안됩니다. 2중 for 문을 사용한 경우, 동일한 키에 대해서 동일한 값이 반복해서 입력되기 때문에 모든 키가 동일한 값을 갖게 됩니다. 두 개의 연속열은 zip 함수를 통해서 하나로 묶어 주어야 합니다.

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

사전의 원소 참조하기 📙

사전의 원소를 참조하는 방법은 리스트와 비슷합니다. 다만 차이가 있다면 앞서 언급한대로 리스트에서는 정수 인덱스를 사용하여 원소를 참조하지만, 사전은 키를 통해서 원소를 참조합니다. 또한 사전은 슬라이스 문법을 지원하지 않습니다.

# aDict에서 'name'에 해당하는 값을 얻는다.
name = aDict["name"]
# "홍길동"

age = aDict["age"]
# 21

존재하지 않는 키를 액세스하려고하면 KeyError가 발생합니다. (이를 위한 해결책을 뒤에서 설명합니다.)

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

사전은 가변적인(mutable한) 컨테이너며, 특정 키의 값을 새로운 값으로 교체하거나 새 키값 쌍을 할당하는 것도 가능합니다.


aDict["city"] = "Seoul"
# 새로운 항목인 "city": "Seoul"을 추가했다.
# {'name': '홍길동', 'age': 21, 'city': 'Seoul'}

aDict["age"] = 43
# "age"의 값을 43으로 교체했다.
#{'name': '홍길동', 'age': 43, 'city': 'Seoul'}

특정 키의 멤버십 확인

사전에 특정한 키-값쌍이 존재하는지에 대한 여부는 in 연산자를 써서 알아낼 수 있습니다. 이것은 리스트나 튜플에서 특정한 원소가 존재하는지를 알아내는 것과 같은 문법입니다. 다만, 리스트나 튜플과 같이 순서대로 원소를 저장하는 집합에서는 원소를 찾는데 걸리는 (최악의) 시간이 집합의 크기에 비례하지만, 사전에서는 키의 존재여부를 검사하는 데에는 (거의 항상) 고정 시간이 소요됩니다.

"age" in aDict
# True

"email" in aDict
# False

리스트에서 어떤 값을 찾기 위해서는 맨 앞에서부터 비교해서 그 값을 찾을 때까지 반복하기 때문에, 찾는 값이 하필 리스트의 맨 마지막 원소라면 그만큼 시간이 오래 걸릴 수 있지만, 사전은 주어진 키의 해시값을 이용해서 값의 위치를 바로 알 수 있기 때문에 멤버십 검사에 대한 비용이 훨씬 적습니다. 따라서 특정 원소가 존재하는지 검사를 반복적으로 많이 수행해야 하는 케이스에서는 사전을 고려해보는 것이 좋습니다. 만약 키-값 쌍이 아닌 값만 이런 식으로 검사해야하는 경우라면 set을 적용하는 전략을 선택할 수도 있습니다.

디폴트 처리

임의의 키를 받아서 그 키에 대한 사전의 값을 찾는 상황을 생각해보겠습니다. 실제로 이런 경우는 상당히 많이 만날 수 있습니다. 존재하지 않는 키를 사전에 요청하면 키 에러가 발생하기 때문에 먼저 키가 사전에 있는지를 검사해야 합니다. 만약 존재하지 않는다면 0이든 None이든 뭐든 본인이 정한 디폴트값을 사용하려 한다면 다음과 같이 멤버십 검사를 먼저 수행하게 될 겁니다.

key = ...

if key in aDict:
  result = aDict[key]
else:
  result = None

그런데 사실 이런 상황은 매우 흔하기 접하기 때문에 그 때마다 if 문을 앞세워서 코드를 작성하는 것은 제법 번거롭습니다. 이런 경우는 .get() 메소드를 사용할 것을 추천합니다. 이 메소드는 두 개의 인자를 받는데, 첫번째 인자는 키이고 두 번째 인자는 해당 키가 없을 때 리턴해줄 디폴트 값입니다. (이 두 번째 인자는 생략할 경우 None으로 간주됩니다.)

# 위 코드와 동일
result = aDict.get(key, None)

예를 들어 중복된 원소를 포함하는 리스트가 있고, 이 리스트에서 각 원소의 출현 빈도를 세는 문제를 생각할 수 있습니다. 이 경우가 위의 코드를 활용하기 좋은 케이스입니다. 빈도 수를 c 라는 사전에 기록하고, 여기에서 키는 원소가 되고 값은 그 원소의 출현빈도라 할 때, 다음과 같이 코드를 작성할 수 있습니다.

# 1~10의 정수 500개를 갖는 리스트는 난수로 생성합니다.
from random import randrange

xs = [randrange(10) + 1 for _ in range(50)]
c = dict()
for x in xs:
  c[x] = c.get(x, 0) + 1

.get() 과 비슷한 메소드로 .setdefault()가 있습니다. get()과 다른점은 setdefault()는 해당 키가 없을 때에는 두 번째 인자를 그 값 사용해서 키-값 쌍을 추가해준다는 점입니다.

앞서 개수를 세는 것과 비슷한 예제를 하나 더 보겠습니다. 임의의 정수 리스트가 있을 때, 2, 3, 5, 7의 배수들을 각각 구해서 하나의 사전에 정리하는 작업을 수행해보겠습니다. 결과는 아마 {2:[ … ], 3: [ … ], 5: […], 7:[…] } 형태의 사전이 될 것입니다. setdefault()를 사용한 코드와 사용하지 않은 코드가 어떻게 달라지는지 잘 살펴보세요.

## setdefault를 사용하지 않는다면

xs = [randrange(1000) + 1 for _ in range(50)]
c = dict()
for x in xs:
  for p in (2, 3, 5, 7):
    if p not in c:  # p가 c의 키가 아니라면
      c[p] = []
    if x % p == 0:
      c[p].append(x)


## setdefault를 사용하면

xs = [randrange(1000) + 1 for _ in range(50)]
c = dict()
for x in xs:
  for p in (2, 3, 5, 7):
    if x % p == 0:
      c.setdefault(p, []).append(x)

원소를 제거하기

사전에 이미 추가한 키값쌍을 제거하기 위한 방법으로는 pop(), popitem()의 두 가지 메소드가 있습니다. 먼저 pop()get()과 비슷하게 동작하는데, 키와 디폴트값을 인자로 받아서 해당 키가 존재하면 그 값을, 존재하지 않으면 디폴트값을 리턴합니다. 이 때 키가 존재하면 해당 키값쌍은 사전에서 제거되며, 키가 존재하지 않느다면 사전은 변경되지 않습니다. 이에 비해 popitem()은 인자를 받지 않으며 사전 내의 키값쌍 중에서 임의의 하나를 제거하면서 리턴합니다. 그런데 뒤에서 설명하겠지만, 파이썬 3.5부터는 사전이 추가된 순서를 유지하기 때문에 지금은 가장 최근에 추가된 키값쌍을 제거한다고 생각하면 됩니다. 즉 popitem()은 (키, 값)의 튜플로 되어 있는 스택에서 pop하는 것과 같은 동작을 수행할 수 있습니다!

원소의 순서?!.⛓

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

좀 더 자세히 말하자면 pypy프로젝트에서 먼저 이러한 방식을 도입하고 후에 이것이 파이썬에 도입되었습니다.

삽입된 순서를 기억할 수 있게 되면서, 함수의 키워드 인자들도 (이들도 내부적으로 사전으로 만들어져 넘겨집니다.) 쓰여진 순서를 유지할 수 있게 되는 것이 가능해지기도 했습니다.( Python 3.6 부터) 많은 장점들이 있어서 Python 3.7 부터는 완전히 사전의 표준 구현으로 이 방식이 자리잡게 되었습니다.

따라서 파이썬 3.5 이상에서는 popitem()은 추가된 역순으로 키값 쌍을 제거하는 동작으로 이해하면 됩니다.

이렇게 사전이 기본적으로 원소의 순서를 보존하기 전에도 순서를 보존하는 사전이 필요한 경우는 종종 있었습니다. 이 경우에는 collections 모듈에서 정의하는 OrderedDict 를 사용할 수 있었고, 여전히 사용가능합니다. OrderedDict는 dict의 서브 클래스로 원소가 추가된 순서를 기억합니다. 특히 move_to_end() 메소드를 지원하는데, 이는 주어진 키에 대한 키값 쌍을 사전의 맨 끝이나 맨 앞으로 빠르게 이동시킬 수 있습니다.

특히 맨 끝으로 이동하는 경우 move_to_end(key)aDict[key] = aDict.pop(key)와 같은 결과를 내지만 내부적으로 더 빠르게 동작합니다.

사전을 순회하기

for ... in 반복문을 사전에 대해서도 적용할 수 있습니다. 기본적으로 for ... in 을 사전에 적용하면, 해당 사전의 모든 키를 순회하게 됩니다. 만약 사전의 모든 키와 값을 출력해보고 싶다면 다음과 같이 하면 됩니다.

d = { .... }
for key in d:
  print("{} : {}".format(key, d[key]))

그 외에 사전으로부터 반복가능한 객체를 얻는 방법은 다음과 같은 것들이 있습니다.

  • keys() : 사전의 각 키를 반복가능한 객체로 얻을 수 있다.
  • values() : 사전의 각 값을 반복가능한 객체로 얻을 수 있다.
  • items(): (키, 값)의 쌍(튜플)을 반복가능한 객체로 얻을 수 있다.

items()를 쓴다면 위 코드를 다음과 같이 보다 간단하게 만들 수 있겠네요

print('\n'.join('{} : {}'.format(*item) for item in d.items()))

업데이트

사전의 특정 원소는 키를 통해서 액세스할 수 있고, 그 값을 변경할 수도 있다고 했습니다. 하나의 원소는 이런 식으로 교체나 추가가 가능하지만, 교체해야 하는 데이터가 많다면 어떻게 할까요? (당연히 for 문을 쓰겠지만…) update()는 해당 사전에 대해서 새로운 키값쌍의 집합이나 다른 사전의 데이터를 병합해 넣는 역할을 수행할 수 있습니다. 쉽게 생각하면 또 다른 사전으로부터 키값 쌍을 읽어와서 사전에 추가하거나 갱신하는 것이 가능하다는 이야기입니다.

d1 = { ... 원래 있던 사전 ... }
d2 = { 새로운 사전 }

# d1에 hello라는 키가 없다면 추가하고, 
# 있다면 그 값을 1로 변경하려면?
d1['hello'] = 1

for (k, v) in d2.items():
  d1[k] = v

# 위 코드는 간단히 update를 통해서 한 번에 수행가능합니다.
d1.update(d1, hello=1)

두 사전을 더해서 새로운 사전을 만들기

update() 메소드는 하나의 사전에 다른 사전의 데이터를 병합해 넣는 동작입니다. 두 사전을 더할 때, 기존 사전을 변경하지 않고 새로운 사전을 만들려면 어떻게 해야할까요?

합해진 사본을 만드는 셈이니, dict()를 사용하면 됩니다. 사전을 풀어서 키워드 인자로 전달해주면 그대로 구성할 수 있습니다. 참고로 dict()의 첫번째 인자는 사전객체여도 되기 때문에 d1, d2 두 개의 사전을 더해서 d3를 만드는 방법은 다음과 같습니다.

d3 = dict(d1, **d2)
# 덤으로 이렇게도...
d4 = dict(d1, **d2, **d3, hello=1)

함수의 키워드 인자

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


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

반대로 함수를 호출할 때에도 사전이 있다면 **d3 와 같이 이를 분해해서 각 키값 쌍을 키=값 형태의 키워드 인자로 분해해서 적용하는 것이 가능합니다.

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

리스트는 원소를 순서대로 추가합니다. 따라서 정수 인덱스를 통해 특정한 위치의 값을 액세스하는 것은 리스트의 크기에 상관없이 같은 시간이 걸리게 됩니다. (O(1)) 하지만 특정한 값을 찾기 위해서는 리스트의 맨 앞에서부터 해당 값과 같은 값을 찾아나가기 때문에 최악의 경우, 리스트의 길이만큼의 비교 연산을 반복해야 합니다. (O(n))

집합 내에서 특정한 원소를 빠르게 찾기 위한 자료 구조 중에 해시테이블이 있습니다. 해시 테이블은 충분한 크기의 배열을 내부에 마련해두고, 저장하고자 하는 값을 (그 타입이 무엇이든) 어떤 정수로 변환하고, 해당 정수값을 인덱스로 하는 위치에 값을 저장합니다. 이 때, 값을 정수로 변환하는 동작을 수행하는 함수를 해시 함수라고 합니다. 해시 함수는 임의의 입력값을 특정한 범위의 정수로 변환하는 함수입니다. 파이썬은 내부적으로 신뢰할만한 해시함수를 내장하고 있으며, 사전은 키의 해시를 이용해서 값을 저장하는 방식을 사용합니다.

해시테이블이 주어진 키에 대해서 정확한 값을 찾을 수 있는 것은 “동일한 입력에 대해서 동일한 출력”을 하는 함수이기 때문입니다. 따라서 해시 함수가 충분히 간단하기만 하다면 집합의 크기가 아무리 크더라도 값의 내부위치를 찾는데에는 비교적 아주 짧은 고정된 시간이 걸리게 됩니다.

하지만 해시 함수를 신뢰하기 위해서는 저장된 값이 불변해야 한다는 조건이 있습니다. 어떤 키와 값의 쌍을 사전에 삽입할 때에는 그 당시의 키의 내용을 바탕으로 해시값이 생성됩니다. 만약 차후에 키의 내용이 변조된다면 해당 키에 대한 해시값이 변경되기 때문에 올바른 값을 찾을 수 없게 됩니다. 따라서 사전의 키로 사용되는 객체는 정수, 실수, 문자열, 튜플과 같은 불변 값이어야 합니다.

사전과 비슷하게 내부적으로 해시맵을 사용하는 컨테이너가 또 있습니다. 바로 set 타입입니다. set 타입의 원소가 될 수 있는 타입역시 해시가 가능해야 합니다.