8.1.1 데이터를 분류해야 하는 이유

컬렉션을 활용해 다양한 데이터를 표현할 수 있다. 그런데 그 표현 방식은 프로그래머가 정하기 나름이다.(7.1 컬렉션의 중첩을 참고) 그림 8-1의 좌표평면 위의 점과 도형을 나타내야 한다고 해 보자.

그림 8-1 좌표평면 위의 점과 도형 (준비중)

다음은 이 정보를 나타내는 한 가지 방법이다. 좌표쌍은 'x', 'y'를 키로 하는 매핑, 삼각형과 사각형은 각 꼭지점을 좌표쌍으로 나타낸 매핑으로 정의했다.

코드 8-1 좌표평면 위의 점과 도형

# 좌표쌍
coordinate = {'x': 5, 'y': 3}

# 삼각형
triangle = {
    'point_a': {'x': 0, 'y': 0},  # 좌표쌍의 표현을 도형에도 활용
    'point_b': {'x': 3, 'y': 0},
    'point_c': {'x': 3, 'y': 4},
}

# 사각형
rectangle = {
    'point_a': {'x': 2, 'y': 2},
    'point_b': {'x': 6, 'y': 2},
    'point_c': {'x': 6, 'y': 6},
    'point_d': {'x': 2, 'y': 6},
}

데이터를 작성했다면, 그 데이터를 처리하기 위한 함수도 정의해야 한다. 다음은 두 점 사이의 거리와 도형의 둘레를 계산하는 함수를 정의한 것이다.

코드 8-2 두 점의 거리와 도형의 둘레를 계산하는 함수

import math   # 제곱근(math.sqrt()) 계산을 위해 수학 모듈 임포트

def square(x):
    """전달받은 수의 제곱을 반환한다."""
    return x * x

def distance(point_a, point_b):
    """두 점 사이의 거리를 계산해 반환한다. (피타고라스의 정리)"""
    return math.sqrt(square(point_a['x'] - point_b['x']) +
                     square(point_a['y'] - point_b['y']))

def circumference_of_triangle(shape):
    """삼각형 데이터를 전달받아 둘레를 구해 반환한다."""
    a_to_b = distance(shape['point_a'], shape['point_b'])
    b_to_c = distance(shape['point_b'], shape['point_c'])
    c_to_a = distance(shape['point_c'], shape['point_a'])
    return a_to_b + b_to_c + c_to_a

def circumference_of_rectangle(shape):
    """사각형 데이터를 전달받아 둘레를 구해 반환한다."""
    a_to_b = distance(shape['point_a'], shape['point_b'])
    b_to_c = distance(shape['point_b'], shape['point_c'])
    c_to_d = distance(shape['point_c'], shape['point_d'])
    d_to_a = distance(shape['point_d'], shape['point_a'])
    return a_to_b + b_to_c + c_to_d + d_to_a

# 둘레 계산
circumference_of_triangle(triangle)    # 12.0 반환
circumference_of_rectangle(rectangle)  # 16.0 반환

데이터를 잘 표현했고, 계산도 정확히 수행했다. 다른 위치나 크기가 다른 삼각형이나 사각형을 나타내더라도 동일한 구조의 데이터를 정의한 후 동일한 함수에 적용하려 둘레를 계산할 수 있을 것이다.

문제점

앞의 표현 방법과 함수는 잘 동작하지만 프로그램이 점점 커지는 것(확장성)을 고려하면 다음의 세 가지 문제점을 지적할 수 있다.

  • 문제점 1: 좌표쌍과 도형을 나타내는 방식이 명확히 정의되어 있지 않다. 코드 8-1은 단지 좌표쌍과 도형을 나타내는 한 가지 예일 뿐이다. 다른 프로그래머가 이 프로그램을 수정할 때 이 데이터를 어떻게 취급해야 하는지 알기 어렵다.
  • 문제점 2: 삼각형의 둘레를 계산하는 함수와 사각형의 둘레를 계산하는 함수는 이름, 의미, 내용이 거의 비슷하다. 둘 다 ‘둘레 계산’을 위한 함수이며, 단지 계산 대상이 다를 뿐이다. 그런데 둘을 분리한 탓에 이름이 길어 불편하다. 도형의 종류가 더 많아지거나 넓이 계산 등 다른 함수를 추가로 정의한다면 점점 더 많은 함수 이름을 기억해야 할 것이다. 그냥 도형의 둘레를 계산하는 일반 함수 circumference() 하나만 정의하고, 이 함수가 도형의 종류에 따라 ‘알아서’ 둘레를 계산해 줬으면 좋곘다.
  • 문제점 3: ‘문제점 2’를 해결하기 위해서는 어떤 데이터가 삼각형인지, 사각형인지, 아니면 둘 다 아닌지를 판단할 수 있어야 한다. 앞의 코드에서는 이를 직접적으로 알아낼 방법이 없다.

이 문제를 해결하기 위해서는 데이터들을 개별적인 데이터로만 두지 않고, 데이터의 종류를 구분하여 관리할 필요가 있다.

8.1.2 데이터의 종류 약속하기

‘문제점 1’부터 살펴보자. 데이터를 나타내는 방법은 정의하기 나름이다. 모든 프로그래머가 같은 방식을 사용할 리 없다. 다음은 그림 8-1의 정보를 코드 8-1과는 다른 방식으로 나타낸 것이다. 좌표쌍은 매핑이 아닌 (y, x) 형태의 튜플을 사용해 나타냈다. 삼각형도 단순히 꼭지점을 순서대로 튜플에 담아 표현할 수 있다. 사각형은 좌표쌍 네 개 대신 기준 좌표쌍과 넓이, 높이를 이용해 표현해 보았다.

코드 8-3 두 점의 거리와 도형의 둘레를 계산하는 함수

# 좌표쌍 (다른 표현)
coordinate_2 = (3, 5)

# 삼각형 (다른 표현)
triangle_2 = ((0, 0), (3, 0), (3, 4))

# 사각형 (다른 표현)
rectangle_2 = {
    'point': (2, 2),
    'width': 4,
    'height': 4,
}

문제는 데이터를 처리하는 방법이 데이터의 구조에 의해 결정된다는 것이다. 코드 8-1과 코드 8-3처럼 데이터를 제각각 다른 구조로 표현해 놓으면 데이터를 동일한 방법으로 처리할 수가 없다. 당연하게도, 코드 8-3의 데이터는 코드 8-2의 함수로는 계산할 수 없다.

코드 8-4 데이터의 표현 방식이 달라 계산이 불가하다

>>> circumference_of_triangle(triangle_2)
TypeError: tuple indices must be integers or slices, not str

>>> circumference_of_rectangle(rectangle_2)
KeyError: 'point_a'

운이 좋으면 코드 8-4처럼 오류가 발생하여 문제점을 알게 될 것이고, 운이 나쁘면 오류 없이 잘못된 결과가 반환되어 프로그램을 올바르게 수정하지 못할 수도 있다.

주석으로 데이터의 종류 약속하기

이 문제를 해결하려면 프로그램에서 취급하려는 데이터의 종류를 정하고, 그 종류에 따라 데이터를 어떻게 표현할 것인지 약속을 정해야 한다. 예제에서 취급할 데이터의 종류는 이미 정해져 있다. 좌표쌍, 삼각형, 사각형이다. 이 정보들을 어떻게 표현해야 하는지에 관한 약속을 정해 알려주는 방법이 문제다. 아직 데이터 유형을 정의하는 문법을 알지 못한다. 하지만 주석을 이용해 정의해 볼 수는 있다.

코드 8-5 데이터 종류마다 표현 방식을 정해 둔 주석

# <좌표쌍> 표현 약속 ========================
# 다음의 키를 갖는 사전으로 나타낸다.
#     * 'x': x축의 위치 (정수)
#     * 'y': y축의 위치 (정수)
#
# 예: {'x': 5, 'y': 3}

# <삼각형> 표현 약속 ========================
# 다음의 키를 갖는 사전으로 나타낸다.
#     * 'point_a': 첫번째 점의 위치 (좌표쌍)
#     * 'point_b': 두번째 점의 위치 (좌표쌍)
#     * 'point_c': 세번째 점의 위치 (좌표쌍)
#
# 예: {
#         'point_a': {'x': 0, 'y': 0},
#         'point_b': {'x': 3, 'y': 0},
#         'point_c': {'x': 3, 'y': 4},
#     }

# <사각형> 표현 약속 ========================
# 다음의 키를 갖는 사전으로 나타낸다.
#     * 'point_a': 첫번째 점의 위치 (좌표쌍)
#     * 'point_b': 두번째 점의 위치 (좌표쌍)
#     * 'point_c': 세번째 점의 위치 (좌표쌍)
#     * 'point_d': 네번째 점의 위치 (좌표쌍)
#
# 예: {
#         'point_a': {'x': 2, 'y': 2},
#         'point_b': {'x': 6, 'y': 2},
#         'point_c': {'x': 6, 'y': 6},
#         'point_d': {'x': 2, 'y': 6},
#     }

이제 약속을 정해 두었으므로 다른 프로그래머가 이 프로그램을 수정하더라도 약속에 따라 데이터를 정의하고 활용할 것을 기대할 수 있다. 이 약속을 충실히 따라주기만 한다면 누군가가 새로 데이터를 만들었을 때도 코드 8-2의 함수로 계산할 수도 있다. 하지만 이 약속을 주석으로 작성했다는 것이 마음에 걸린다. 주석은 컴퓨터가 읽지 못하므로 프로그래머가 이 약속을 지키고 있는지를 컴퓨터가 확인해주지 못한다. 일단 계속해서 앞에서 지적한 ‘문제점 2’와 ‘문제점 3’을 살펴보자.

연습문제

연습문제 8-1 체스말, 바둑돌 정의하기

다음은 사전을 이용해 체스말 데이터를 나타낸 예다. 이 예에서 ‘x’, ‘y’가 체스 말의 위치, ‘color’가 색, ‘role’이 말의 역할이다.

  • {'x': 'A', 'y': '8', 'color': 'black', 'role': '룩'}
  • {'x': 'E', 'y': '1', 'color': 'white', 'role': '킹'}

마찬가지로 바둑돌 데이터도 나타내 보았다. 이 예에서 ‘x’, ‘y’가 돌의 위치, ‘order’가 몇 수째에 둔 돌인지를 나타내는 수, ‘color’가 돌의 색이다.

  • {'x': 8, 'y': 14, 'order': 83, 'color': '흑'}
  • {'x': 12, 'y': 3, 'order': 84, 'color': '백'}

체스말 데이터와 바둑돌 데이터를 각각 일반화하여, 그 데이터 종류를 주석으로 정의해 보아라.

8.1.3 데이터의 종류 구별하기

‘문제점 2’는 데이터의 종류마다 대응하는 함수를 만들지 않고 다양한 데이터를 처리할 수 있는 일반 함수 하나를 정의하고 싶다는 것이다. 이 문제는 if 문을 이용해 데이터의 종류에 따라 다른 동작을 하도록 하여 해결할 수 있다. 한 데이터의 유형을 확인하려면 type() 함수를 이용하면 된다.(4.5 데이터 유형의 확인과 변환을 참고) 다음은 이 방법으로 코드 8-2의 circumference_of_triangle() 함수와 circumference_of_rectangle() 함수를 하나로 합친 것이다. 하지만 실제로는 제대로 동작하지 않는다. 코드를 읽어보고 왜 그런지 생각해 보자.

코드 8-6 둘레 계산 함수를 일반 함수로 정의

def circumference(shape):
    """도형 데이터를 전달받아 둘레를 구해 반환한다."""
    
    # 도형의 데이터 유형이 <삼각형>인 경우
    if type(shape) == '삼각형':
        a_to_b = distance(shape['point_a'], shape['point_b'])
        b_to_c = distance(shape['point_b'], shape['point_c'])
        c_to_a = distance(shape['point_c'], shape['point_a'])
        return a_to_b + b_to_c + c_to_a
    
    # 도형의 데이터 유형이 <사각형>인 경우
    elif type(shape) == '사각형':
        a_to_b = distance(shape['point_a'], shape['point_b'])
        b_to_c = distance(shape['point_b'], shape['point_c'])
        c_to_d = distance(shape['point_c'], shape['point_d'])
        d_to_a = distance(shape['point_d'], shape['point_a'])
        return a_to_b + b_to_c + c_to_d + d_to_a
    
    # 지원하지 않는 데이터인 경우
    else:
        return None

# 둘레 계산
circumference(triangle)   # None 반환
circumference(rectangle)  # None 반환

위 코드에서 둘레 계산 함수를 잘 정의했지만, 함수를 실제로 호출해 보면 입력한 데이터의 종류와 관계없이 항상 None이 반환된다. 그 이유는 함수에서 도형의 데이터의 종류를 확인할 때 비교 대상이 앞에서 주석으로 정의한 삼각형사각형이기 때문이다. 이 정의는 주석을 통해 이루어졌으므로 프로그래머 사이의 약속일 뿐, 컴퓨터는 이 약속을 이해할 수 없다. type(triangle)type(rectangle)을 평가한 값은 컴퓨터의 입장에서는 둘 다 사전일 뿐이다. 한 번 확인해 보자.

코드 8-7 삼각형과 사각형 데이터 둘 다 사전이다

>>> type(triangle)
<class 'dict'>

>>> type(rectangle)
<class 'dict'>

삼각형과 사각형은 둘 다 사전 유형이므로, type() 함수로는 <class 'dict'>가 반환될 뿐이다. 이는 당연히 문자열 '삼각형'과는 다르다. 즉, type() 함수로는 도형의 종류를 구별할 수가 없다. 이것은 앞에서 지적한 ‘문제점 3’에 해당된다.

‘문제점 3’을 해결하기 위한 한 가지 방편은 사전 속에 데이터의 종류에 관한 정보를 추가하는 것이다. 이를 위해 triangle 변수와 rectangle 변수의 사전 데이터에 'type'키를 새로 추가해 보자.

코드 8-8 데이터 종류를 나타내는 정보를 사전에 추가

triangle['type'] = '삼각형'
rectangle['type'] = '사각형'

이제 도형 데이터의 'type' 키를 조사하면 데이터의 종류를 구할 수 있다. 따라서 코드 8-6의 둘레 계산 함수를 다음과 같이 수정하면 원하는 결과를 얻을 수 있다.

코드 8-9 둘레 계산 함수를 일반 함수로 정의

def circumference(shape):
    """도형 데이터를 전달받아 둘레를 구해 반환한다."""
    
    # 도형의 데이터 유형이 <삼각형>인 경우
    if shape['type'] == '삼각형':    # 키를 조회하도록 수정
        a_to_b = distance(shape['point_a'], shape['point_b'])
        b_to_c = distance(shape['point_b'], shape['point_c'])
        c_to_a = distance(shape['point_c'], shape['point_a'])
        return a_to_b + b_to_c + c_to_a
    
    # 도형의 데이터 유형이 <사각형>인 경우
    elif shape['type'] == '사각형':  # 키를 조회하도록 수정
        a_to_b = distance(shape['point_a'], shape['point_b'])
        b_to_c = distance(shape['point_b'], shape['point_c'])
        c_to_d = distance(shape['point_c'], shape['point_d'])
        d_to_a = distance(shape['point_d'], shape['point_a'])
        return a_to_b + b_to_c + c_to_d + d_to_a
    
    # 지원하지 않는 데이터인 경우
    else:
        return None

# 둘레 계산
circumference(triangle)   # 12.0 반환
circumference(rectangle)  # 16.0 반환

이로써 앞서 지적한 세 가지 문제를 모두 해결했다. 하지만 이는 문제를 직접 해결하기보다는 지금까지 학습한 내용을 활용해 간접적으로 해결한 것이어서, 몇 가지 아쉬움이 남는다.

  • 데이터의 종류를 주석으로 정의하지 않고, 프로그래밍 언어가 지원하는 정식 데이터 유형으로 정의하고 싶다. 그래서 정의한 데이터의 종류를 프로그래머만이 아니라 컴퓨터도 인식하도록 하고 싶다.
  • 일반 함수를 정의함으로써 여러 개의 함수를 정의하지 않아도 되는 점은 편리해졌다. 하지만 하나의 함수의 길이가 너무 길어졌다. 데이터의 종류가 많아지면 훨씬 더 길어질 것 같다. 함수의 이름을 간단히 유지하면서, 데이터 유형마다 별도의 함수를 정의할 수 있다면 좋겠다.
  • 'type' 키를 사용하려면 데이터가 꼭 사전으로 이뤄져야 한다. 게다가 특별한 키를 사용하기 때문에 이 키의 용도를 별도로 설명해야하는 의사소통 비용이 추가로 발생한다. 그냥 다른 파이썬 데이터 유형처럼 type() 함수를 이용해 데이터 유형을 식별할 수 있다면 가장 좋겠다.

다음 절에서부터 파이썬에서 데이터 유형을 관리하는 공식 기능인 클래스를 이용해 이 아쉬움을 해결해 볼 것이다. 다만, 클래스를 사용하는 것이 필수는 아니라는 점을 언급하고 싶다. 프로그램의 규모가 크지 않다면 이 절에서 사용한 방법들(주석을 이용한 데이터 정의, 일반 함수, 키를 이용한 데이터 종류 식별)을 잘 활용하는 것으로도 충분할 수 있다. 클래스는 이 방법을 좀 더 체계적으로 적용하기 위한 기능일 뿐이다.

연습문제

연습문제 8-2 체스말, 바둑돌 출력하기

연습문제 8-1에서 정의한 체스말 또는 바둑돌 데이터를 전달받아 화면에 출력하는 함수 print_piece()를 정의해라. 이 함수는 전달받은 데이터가 체스말인지, 바둑돌인지를 식별해 각각 다른 방식으로 출력해야 한다. 다음은 이 함수를 실행한 예다.

>>> print_piece(체스말_데이터)
black 룩이 A8 위치에 놓여 있어요.

>>> print_piece(바둑돌_데이터)
제 84 수: 백이 (12, 3) 위치에 두었습니다.

힌트: 필요하다면 데이터 유형을 식별하기 위한 정보를 데이터에 추가해라.