Effective Python Item 37. 내장 타입을 여러 단계로 내포시키기보다는 클래스를 합성하라.

    들어가기 전

     


    요약

    • 딕셔너리, 긴 튜플이 여러 Depth로 내포되는 경우 클래스로 분리해 가독성 확보, 캡슐화를 하는 것이 필수다.

     


    Item 37. 내장 타입을 여러 단계로 내포시키기보다는 클래스를 합성하라.

    파이썬은 딕셔너리 타입을 제공해준다. 딕셔너리에는 동적으로 Key / Value를 추가할 수 있다. 이것은 편리함도 있지만, 문제점을 가져오기도 한다. 딕셔너리를 사용할 때 고려해야할 점을 살펴보자.

    1. 딕셔너리는 동적으로 손쉽게 Key / Value를 추가할 수 있다. 따라서 확장이 쉽다. 
    2. 쉬운 확장 때문에 여러 Depth를 내포하게 되면, 유지보수하기 어려운 코드가 된다. 

    그렇다면 딕셔너리가 여러 Depth를 내포하면 왜 유지보수하기 어려운 코드가 된다는 것일까? 

    student_grades = { 
      "john": 99, 
    }
    
    for name, grade in student_grade.items():
      print(f'{name = }, {grade = }')

    하나의 Depth만 가지는 딕셔너리의 사용 예시는 위와 같다. 

    1. 위 코드는 명확하게 "학생 이름 : 점수"가 딕셔너리에 저장된 것을 이해할 수 있다.
    2. 딕셔너리를 Iteration 해야할 경우, 한 번의 For문만 반복하면 되기 때문에 코드도 깔끔하다. 

    현재까지는 읽기도 어렵지 않고, 사용하기도 어렵지 않다. 또한 의미파악도 쉬운 코드다. 그러나 여기서 딕셔너리의 Depth가 추가되면 점점 읽기 어려워진다.

    student_grades = { 
      "english": {"john": 99}, 
      "math": {"john": 30}
    }
    
    for subject, grade_dict in student_grade.items():
      for name, grade in grade_dict.items():
        print(f'{subject = } / {name = } / {grade = }')

    딕셔너리에 계층 구조로 데이터가 저장되어야 한다는 내용이 추가되었다.

    1. 첫번째 딕셔너리에서는 과목명 : 학생 점수 딕셔너리를 포함한다. 
    2. 두번째 딕셔너리는 학생 이름 : 점수를 저장한다. 

    딕셔너리에 Depth 하나만 추가했지만 꽤나 읽기 어려워졌다. 어떤 관점에서 읽기 어려워졌다는 것일까?

    1. 각 딕셔너리의 Key, Value가 무엇을 의미하는지 알 수 없음. 
    2. 딕셔너리를 iteration 하기 위해서 알아야 할 부분이 너무 많아짐. 
    3. 만약 리스트, 튜플을 사용해서 확장을 하는 경우 리스트의 각 인덱스가 무엇을 의미하는지 알 수 없음. 경계조건 문제도 있을 수 있음. 

    마지막으로 Depth 하나를 더 추가해보자.

    student_grades = { 
      "4-1": {
      	"english": {"john": 99}, 
      	"math": {"john": 30}
      	},
      "4-2": {
      	"english": {"john": 30}, 
      	"math": {"john": 60}
      }  
    }
    
    for semester, second_dict in student_grade.items():
      for subject, grade_dict in second_dict.items():
        for name, grade in grade_dict.items():
          print(f'{subject = } / {name = } / {grade = }')

    이제 student_grades 딕셔너리는 학기별로 과목별 학생들의 점수를 보관하는 변수가 된다. 한글로 표현해도 이해하기 어려운 말인데, 코드로 읽으면 더 읽기 어렵다. 

    앞서 언급한 3가지 단점은 Depth가 추가될 수록 점점 더 심해진다. 이런 이유 때문에 딕셔너리는 확장에 유용한 자료구조지만, 딕셔너리를 이용해 자유분방하게 확장할 경우 읽기 어렵고 유지보수하기 어려운 코드가 된다는 것이다. 

    여기서 하고 싶은 이야기를 정리하면 다음과 같다. 

    1. 딕셔너리, 리스트 같은 자료구조를 이용하면 동적으로 내용을 확장해 나갈 수 있다.
    2. 동적으로 계속 확장해나가면 읽기 어려운 코드가 된다. 
    3. 만약 내장 자료구조의 중첩이 3단계가 된다면, 클래스로 분리해야만 한다. 

    마지막 이야기가 가장 핵심이다. 아래에서 문제가 되는 코드 예시를 살펴보고, 어떻게 해결되는지도 함께 살펴보자. 


    코드1. 1-Depth 딕셔너리

    class SimpleGradeBook:
        def __init__(self):
            self._grades = {}
    
        def add_student(self, name):
            self._grades[name] = []
    
        def report_grade(self, name, score):
            self._grades[name].append(score)
    
        def average_grade(self, name):
            grades = self._grades[name]
            return sum(grades) / len(grades)
    
    
    book = SimpleGradeBook()
    book.add_student('아이작 뉴턴')
    book.report_grade('아이작 뉴턴', 90)
    book.report_grade('아이작 뉴턴', 95)
    book.report_grade('아이작 뉴턴', 85)
    
    print(book.average_grade('아이작 뉴턴'))
    1. SimpleGradeBook 클래스는 1-Depth 딕셔너리를 이용해 내부적으로 데이터를 저장한다. 
    2. 데이터를 저장할 수 있는 공개 API로 add_student(), report_grade(), average_grade()를 제공한다. 

    아직까지 1-Depth 딕셔너리이기때문에 클래스를 읽고 무슨 내용인지 파악하는데 아무 문제가 없다. 

     


    코드2. 2-Depth 딕셔너리

    from collections import defaultdict
    
    class BySubjectGradebook:
        def __init__(self):
            self._grades = {} # 외부 dict
    
        def add_student(self, name):
            self._grades[name] = defaultdict(list) # 내부 dict
    
        # name: 학생 이름 / subject : 과목 / grade : 점수
        def report_grade(self, name, subject, grade):
            by_subject = self._grades[name]
            grade_list = by_subject[subject]
            grade_list.append(grade)
    
        def average_grade(self, name):
            by_subject = self._grades[name]
            total, count = 0,0
            for grades in by_subject.values():
                total += sum(grades)
                count += len(grades)
            return total / count
    
    
    book = BySubjectGradebook()
    book.add_student('아이작 뉴턴')
    book.report_grade('아이작 뉴턴', '수학', 90)
    book.report_grade('아이작 뉴턴', '체육', 95)
    book.report_grade('아이작 뉴턴', '물리', 85)
    
    print(book.average_grade('아이작 뉴턴'))
    1. BySubjectGradeBook은 내부적으로 2-Depth 딕셔너리를 이용해 데이터를 저장한다. 
    2. 학생 이름별로 과목별 점수를 저장한다. 

    1-Depth 딕셔너리보다 조금 읽기는 어려워졌지만, 아직까지 읽는데 문제 없다. 클라이언트에서 사용하는 코드도 크게 복잡해 보이지 않는다. 


    코드3. 3-Depth 딕셔너리

    from collections import defaultdict
    
    
    class WeightedGradebook:
        def __init__(self):
            self._grades = {} # 외부 dict
    
        def add_student(self, name):
            self._grades[name] = defaultdict(list) # 내부 dict
    
        # name: 학생 이름 / subject : 과목 / grade : 점수
        def report_grade(self, name, subject, score, weight):
            by_subject = self._grades[name]
            grade_list = by_subject[subject]
            grade_list.append((score, weight)) # 이제 weight를 튜플로 같이 추가함.
    
        def average_grade(self, name):
            by_subject = self._grades[name]
    
            score_sum, score_count = 0, 0
            for subject, scores in by_subject.items():
                subject_avg, total_weight = 0, 0
    
                for score, weight in scores:
                    subject_avg += score * weight
                    total_weight += weight
    
                score_sum += subject_avg / total_weight
                score_count += 1
    
            return score_sum / score_count
    
    
    book = WeightedGradebook()
    book.add_student('아이작 뉴턴')
    book.report_grade('아이작 뉴턴', '수학', 90, 0.05)
    book.report_grade('아이작 뉴턴', '체육', 95, 0.15)
    book.report_grade('아이작 뉴턴', '물리', 85, 0.80)
    
    print(book.average_grade('아이작 뉴턴'))
    1. WeightedGradeBook은 3-Depth 딕셔너리를 사용한다.
    2. 학생 이름 / 과목 / 점수 / 점수별 가중치를 3-Depth 딕셔너리를 이용해 저장한다. 

    지금부터는 코드를 읽는데 점점 잡음이 발생한다. 어떤 잡음이 발생할까? 

    1. average_grade() 메서드를 살펴보면, 내부에 2중 For문이 도는 것을 볼 수 있다. nested For문은 코드를 더욱 읽기 어렵게 만든다. 
    2. 각 딕셔너리의 Key, Value가 무엇을 의미하는지 알기 어렵다. 특히 TypedTuple도 아니기 때문에 WeightedGradeBook 코드만 봤을 때는 각 딕셔너리의 역할을 거의 유추할 수 없다. 
    3. 클라이언트가 사용하는 코드도 어려워졌다. 예를 들어 ('아이작 뉴턴', '수학', 90, 0.05) 코드를 보자. 여기서 90, 0.05는 각각 무엇을 의미하는 코드인지 유추하기 쉬울까? 유추하기 어렵다. 

    앞서 이야기 했던 것처럼 내장 딕셔너리는 확장하기 쉽지만, 너무 확장할 경우 유지보수하기 어려운 코드가 된다. 이 부분은 반드시 클래스로 분리해야만 한다. 

     


    코드4. 클래스로 리팩토링

    @dataclass
    class Grade:
        score: int
        weight: float
    
    
    class Subject:
    
        def __init__(self):
            self._grades = []
    
        def report_grade(self, grade):
            self._grades.append(grade)
    
        def average_grade(self):
            total, total_weight = 0, 0
            for grade in self._grades:
                total += grade.score * grade.weight
                total_weight += grade.weight
    
            return total / total_weight
    
    
    class Student:
        subject_list: defaultdict[Subject]
    
        def __init__(self):
            self._subjects = defaultdict(Subject)
    
        def get_subject(self, name):
            return self._subjects[name]
    
        def average_grade(self):
            total, count = 0, 0
            for subject in self._subjects.values():
                total += subject.average_grade()
                count += 1
            return total / count
    
    
    class Gradebook:
        def __init__(self):
            self._students = defaultdict(Student)
    
        def get_student(self, name):
            return self._students[name]
    
    
    book = Gradebook()
    albert = book.get_student('아인슈타인')
    math = albert.get_subject('수학')
    math.report_grade(Grade(75, 0.05))
    math.report_grade(Grade(80, 0.05))
    math.report_grade(Grade(100, 0.05))
    
    print(albert.average_grade())
    1. 3-Depth 딕셔너리를 각각 클래스로 분리한 코드다.
    2. 코드의 전체적인 길이는 더 늘어났지만, 훨씬 읽기 편리한 코드가 되었다. 
    3. 읽었을 때 이해가 되는 코드이기 때문에 유지보수 하기도 수월하고, 내용을 파악하기도 쉽다. 
    4. 클라이언트가 사용하는 코드도 좀 더 명시적으로 바뀌었다. 이전에는 함수에서 위치 인자를 이용해 덜 명시적이었다. 그러나 지금은 math.report_grade()가 사용되는 것처럼 어떤 과목에 어떤 점수를 추가하는지 등이 더 명시적이다. 

    이처럼 내장 자료형이 많이 내포되어 확장된 경우는 어려운 코드가 될 수 밖에 없으니, 클래스로 반드시 분리하는 것이 필요하다. 

     

    댓글

    Designed by JB FACTORY