[클린 코드] 4. 클래스(class)

클래스(class)는 왜 필요할까?

신입 데이터 엔지니어로써 1년, 클래스를 사용하지않고 잘만 각종 프로그램을 만들어왔다. 기초 사용법은 다 배웠지만, 도통 어디에서 어떻게 활용해야할지를 모르겠다. 클래스는 넘기 힘든 장벽이라고 하는데, “점프 투 파이썬”을 통해 기초부터 차근차근 알아보고, 내 프로그램에 서서히 적용해보자.

클래스가 없어도 좋은 프로그램을 충분히 만들 수 있다. 꼭 필요한 요소는 아니지만, 프로그램을 작성할 때 클래스를 적재적소에 사용하면 프로그래머가 얻을 수 있는 이익은 상당하다.

파이썬으로 계산기 구현

파이썬으로 계산기의 “더하기 기능”을 구현한 파이썬 코드는 아래와 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
result = 0

def add(num):
    global result
    result += num
    return result

print(add(3))
print(add(4))
"""
3
7
"""

그런데 만약, 한 프로그램에서 2대의 계산기가 필요한 상황이 발생하면 아래와 같이 각각 함수를 만들어야 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
result1 = 0
result2 = 0

def add1(num):
    global result1
    result1 += num
    return result1

def add2(num):
    global result2
    result2 += num
    return result2

print(add1(3))
print(add1(4))
print(add2(3))
print(add2(7))
"""
3
7
3
10
"""

하지만 계산기가 3개, 10개, 50개로 점점 많이 필요해진다면 어떻게 해야할까? 분명 상황은 점점 어려워진다. 다만 이 경우에 클래스를 사용하면 다음과 같이 간단하게 해결할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Calculator:
    def __init__(self):
        self.result = 0

    def add(self, num):
        self.result += num
        return self.result

cal1 = Calculator()
cal2 = Calculator()

print(cal1.add(3))
print(cal1.add(4))
print(cal2.add(3))
print(cal2.add(7))
"""
3
7
3
10
"""

Calculator 클래스로 만든 별개의 계산기 cal1, cal2(파이썬에서는 이것을 “객체”라고 부른다)가 각각의 역할을 수행한다. 그리고 계산기(cal1, cal2)의 결괏값 역시 다른 계산기의 결괏값과 상관없이 독립적인 값을 유지한다. 클래스를 사용하면 계산기 대수가 늘어나더라도 객체를 생성만 하면 되기 때문에 함수를 사용하는 경우와 달리 매우 간단해진다. 만약 빼기 기능을 더하려면 Calculator 클래스에 다음과 같은 빼기 기능 함수를 추가해 주면 된다.

1
2
3
4
5
6
7
8
9
10
11
class Calculator:
    def __init__(self):
        self.result = 0

    def add(self, num):
        self.result += num
        return self.result

    def sub(self, num):
        self.result -= num
        return self.result

클래스와 객체

과자 틀과 그것을 사용해 만든 과자라면, 과자 틀은 클래스(class), 과자는 객체(object)와 유사하다. 클래스로 만든 객체에는 중요한 특징이 있다. 바로 객체마다 고유한 성격을 가진다는 것이다. 과자 틀로 만든 과자에 구멍을 뚫거나 베어 물더라도, 다른 과자에는 아무 영향이 없는 것과 마찬가지로 동일한 클래스로 만든 객체들은 서로 전혀 영향을 주지 않는다.

1
2
class Cookie:
    pass

아무 기능을 갖지 않은 껍질뿐인 클래스이다. 하지만 이 클래스도 객체를 생성할 수 있다.

1
2
a = Cookie()
b = Cookie()

객체와 인스턴스의 차이

클래스로 만든 객체를 인스턴스라고도 한다. 차이는 무엇일까? a = Cookie() 이렇게 만든 a는 객체이다. 그리고 a객체는 Cookie의 인스턴스이다. 즉 인스턴스라는 말은 특정 객체(a)가 어떤 클래스(Cookie)의 객체인지를 관계 위주로 설명할 때 사용한다. a는 인스턴스보다 a는 객체라는 표현이 어울리며, a는 Cookie의 객체보다 a는 Cookie의 인스턴스라는 표현이 훨씬 잘 어울린다.

사칙연산 클래스 만들기

1
2
3
4
5
6
7
8
9
10
a = FourCal()
a.setdata(4, 2)
print(a.add())
# 6
print(a.mul())
# 8
print(a.sub())
# 2
print(a.div())
# 2

위의 명령어가 working하는 코드를 짜보자.

1
2
3
4
5
class FourCal:
    # 객체에 숫자 지정할 수 있게 만들기
    def setdata(self, first, second):
        self.first = first
        self.second = second

setdata함수를 만들었다. 클래스 안의 함수는 “메서드(Method)”라고 부른다.

setdata 메서드는 매개변수로 self, first, second 3개의 입력값을 받는다. 그런데 일반 함수와는 달리 메서드의 첫 번째 매개변수 self는 특별한 의미를 가진다. 첫 번째 매개변수 self에는 setdata메서드를 호출한 객체 a가 자동으로 전달된다.

파이썬 메서드의 첫 번째 매개변수 이름은 관례적으로 self를 사용한다. 객체를 호출할 때 호출한 객체 자신이 전달되기 때문에 self라는 이름를 사용한 것이다. 물론 self말고 다른 이름을 사용해도 상관없다. 이것은 파이썬만의 독특한 특징이다. 앞으로 self를 호출된 객체라고 생각하면 될 것 같다.

a.setdata(4, 2)처럼 호출하면 다음과 같이 해석된다.

1
2
3
4
5
6
self.first = 4
self.second = 2

# self는 전달된 객체 a이므로 다음과 같이 해석된다.
a.first = 4
a.second = 2

위와 같이 객체에 생성되는 객체만의 변수를 “객체변수”라고 부른다.

1
2
3
4
5
6
print(a.first)
print(a.second)
"""
4
2
"""

나머지 기능을 구현하면 아래와 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class FourCal:
    def setdata(self, first, second):
        self.first = first
        self.second = second
        
    def add(self):
        return self.first + self.second
    
    def mul(self):
        return self.first * self.second
    
    def sub(self):
        return self.first - self.second
    
    def div(self):
        return self.first / self.second
    
a = FourCal()
a.setdata(4, 2)
print(a.add())
print(a.mul())
print(a.sub())
print(a.div())

생성자 (Constructor)

우리가 만든 FourCal클래스를 다음과 같이 사용해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
a = FourCal()
a.add()
"""
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
Cell In [2], line 2
      1 a = FourCal()
----> 2 a.add()

Cell In [1], line 7, in FourCal.add(self)
      6 def add(self):
----> 7     return self.first + self.second

AttributeError: 'FourCal' object has no attribute 'first'
"""

위와 같은 에러가 전시되는 이유는 무조건 setdata메서드를 수행해야 객체 a의 객체변수 firstsecond가 생성되기 때문이다. 이렇게 초기값을 설정해야 할 필요가 있을 때는, setdata와 같은 메서드를 호출하여 초깃값을 설정하기보다 생성자를 구현하는 것이 안전한 방법이다. “생성자(Constructor)”란 객체가 생성될 때 자동으로 호출되는 메서드를 의미한다.

파이썬 메서드 이름으로 __init__를 사용하면 이 메서드는 생성자가 된다. 다음과 같이 FourCal 클래스에 생성자를 추가해 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class FourCal:
    def __init__(self, first, second):
        self.first = first
        self.second = second
        
    def add(self):
        return self.first + self.second
    
    def mul(self):
        return self.first * self.second
    
    def sub(self):
        return self.first - self.second
    
    def div(self):
        return self.first / self.second

__init__ 메서드는 setdata 메서드와 이름만 다르고 모든 게 동일하다. 단 메서드 이름을 __init__으로 했기 때문에 생성자로 인식되어 객체가 생성되는 시점에 자동으로 호출되는 차이가 있다. 이젠 클래스를 호출할 때 부터 파라미터를 전달해야한다.

1
a = FourCal(4, 2)

클래스의 상속

상속(Inheritance)은 “물려받다”라는 뜻으로, 클래스에도 이 개념을 적용할 수 있다. 어떤 클래스를 만들 때 다른 클래스의 기능을 물려받을 수 있게 만드는 것이다. 상속 개념을 활용하여 우리가 만든 FourCal 클래스에 $a^b$를 구할 수 있는 기능을 추가할 것이다.

FourCal 클래스를 상속하는 MoreFourCal 클래스는 다음과 같이 간단하게 만들 수 있다.

1
2
class MoreFourCal(FourCal):
    pass

MoreFourCal 클래스는 FourCal 클래스를 상속했으므로 FourCal 클래스의 모든 기능을 사용할 수 있어야 한다.

1
2
3
4
5
6
7
8
9
10
11
a = MoreFourCal(4, 2)
print(a.add())
print(a.mul())
print(a.sub())
print(a.div())
"""
6
8
2
2.0
"""

상속은 왜 해야할까?

보통 상속은 기존 클래스를 변경하지 않고 기능을 추가하거나 기존 기능을 변경하려고 할 때 사용한다. “클래스에 기능을 추가하고 싶으면 기존 클래스를 수정하면 되는데 왜 굳이 상속을 받아서 처리하지?”라는 의문이 들 수도 있다. 하지만 기존 클래스가 라이브러리 형태로 제공되거나 수정이 허용되지 않는 상황에서는 상속을 해야한다.

1
2
3
4
5
a = MoreFourCal(4, 2)
a.pow()
# 16
a.add()
# 6

새로 만든 pow 함수 뿐 아니라, 기존의 add함수도 잘 수행한다.

메서드 오버라이딩(Method Overriding)

기존 클래스를 다음과 같이 실행해본다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
a = FourCal(4, 0)
a.div()
"""
---------------------------------------------------------------------------
ZeroDivisionError                         Traceback (most recent call last)
Cell In [3], line 1
----> 1 a.div()

Cell In [1], line 16, in FourCal.div(self)
     15 def div(self):
---> 16     return self.first / self.second

ZeroDivisionError: division by zero
"""

위와 같은 ZeroDivisionError가 전시된다. 하지만 0으로 나눌 때 오류가 아닌 0을 리턴하도록 하려면 어떻게 해야할까?

FourCal 클래스를 상속하는 SafeFourCal 클래스를 만들어본다.

1
2
3
4
5
6
class SafeForuCal(FourCal):
    def div(self):
        if self.second == 0:
            return 0
        else:
            return self.first / self.second

FourCaldiv 메서드를 동일한 이름으로 다시 작성하였다. 이렇게 부모 클래스에 있는 메서드를 동일한 이름으로 다시 만드는 것을 메서드 오버라이딩(Method Overring)이라고 한다. 이렇게 메서드를 오버라이딩하면 부모클래스의 메서드 대신 오버라이딩한 메서드가 호출된다.

1
2
3
a = SafeForuCal(4, 0)
a.div()
# 0

클래스 변수

객체변수는 다른 객체들의 영향을 받지 않고 독립적으로 그 값을 유지한다. 이번에는 객체변수와는 성격이 다른 클래스 변수에 대해 알아볼 것이다.

1
2
class Family:
    lastname = "김"

Family 클래스에 선언한 lastname이 바로 클래스 변수이다. 클래스 변수는 클래스 안에 함수를 선언하는 것과 마찬가지로 클래스 안에 변수를 선언하여 생성한다.

1
2
3
4
5
6
7
8
Family.lastname
# '김'

a = Family()
b = Family()

a.lastname, b.lastname
# ('김', '김')

위 처럼 두가지 형태 모두 사용할 수 있다.

만약 Family 클래스의 lastname을 다음과 같이 “박”이라는 문자열로 바꾸면 어떻게 될까?

1
2
3
Family.lastname = "박"
a.lastname, b.lastname
# ('박', '박')

클래스 변수 값을 변경했더니, 클래스로 만든 객체의 lastname 값도 모두 변경된다는 것을 확인할 수 있다. 즉 클래스 변수는 클래스로 만든 모든 객체에 공유된다는 특징이 있다.

클래스 변수와 동일한 이름의 객체 변수를 생성하면?

1
2
3
4
5
6
a.lastname = "최"
a.lastname
# '최'

Family.lastname, b.lastname
# ('박', '박')

Family 클래스의 lastname이 바뀌는 것이 아니라 a 객체에 lastname이라는 객체변수가 새롭게 생성된다. 즉, 객체변수는 클래스 변수와 동일한 이름으로 생성할 수 있다. a.lastname 객체변수를 생성하더라도 Family 클래스의 lastname과는 상관없다는 것을 확인할 수 있었다.

클래스에서 클래스 변수보다는 객체변수가 훨씬 중요하다. 실무 프로그래밍을 할 때도 클래스 변수보다는 객체변수를 사용하는 비율이 훨씬 높다.

SRP(Single Responsibility Principle, 단일 책임 원칙)

  • 하나의 클래스는 하나의 책임만 가지도록 한다.

as-is

1
2
3
4
5
6
7
8
# Store가 많은 역할을 혼자서 수행
class Store:
    def communicate_user(self):
        ...
    def manage_products(self):
        ...
    def manage_money(self):
        ...

to-be

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# 책임을 나눠서 Manger 클래스에게 책임 전가

class CounterManager:
    def communicate_user(self):
        ...

class ProductManager:
    def manage_products(self):
        ...

class Owner:
    def manage_money(self):
        ...

class Store:
    def __init__(self, counter_manager: CounterManager, product_manager: ProductManager, owner: Owner):
        self.counter_manager = counter_manager
        self.product_manager = product_manager
        self.owner = owner

    def sell_product(self):
        self.counter_manager.communicate_user()
        ...
    def manage_products(self):
        ...

응집도

응집도는 클래스의 변수와 메서드들이 얼마나 유기적으로 엮여있냐를 나타내는 지표이다.

  • 응집도가 높을수록 클래스의 메서드들은 인스턴스 변수들을 많이 사용한다.
  • 응집도가 낮을수록 클래스의 메서드들은 인스턴스 변수들을 적게 혹은 사용하지 않는다.

as-is

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class LowCohesion:
    def __init__(self):
        self.a = ...
        self.b = ...
        self.c = ...

    def process_a(self):
        print(self.a)

    def process_b(self):
        print(self.b)

    def process_c(self):
        print(self.c)

to-be

1
2
3
4
5
6
7
8
9
10
11
12
class HighCohesion:
    def __init__(self):
        self.abc = ...

    def process_a(self):
        self.abc.process_a()

    def process_b(self):
        self.abc.process_b()

    def process_c(self):
        self.abc.process_c()

변경하기 용이하게

새 기능을 수정하거나 기존 기능을 변경할 때, 코드의 변경을 최소화하는 게 중요하다. 일반적으로 클래스(객체)는 구현(Concrete)과 추상(Abstract)으로 나뉘게 된다. 구현에는 실제 동작하는 구체적인 코드가, 추상은 인터페이스나 추상 클래스처럼 기능을 개념화한 코드가 들어간다. 일반적으로 변경하기 쉽게 설계하기 위해선 추상화를 해두고 구체 클래스에 의존하지 않고 추상 클래스(인터페이스)에 의존하도록 코드를 짜는 것이 중요하다.

as-is

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
class Developer:
    def coding(self):
        print("코딩을 합니다")

class Designer:
    def design(self):
        print("디자인을 합니다")

class Analyst:
    def analyze(self):
        print("분석을 합니다")

class Company:
    def __init__(self, employees): #구체 클래스에 의존한다. 
        self.employees = employees

    # employee가 다양해질수록 코드를 계속 변경해야 한다.
    def make_work(self):
        for employee in self.employees:
            if isinstance(employee, Developer):
                employee.coding()
            elif isinstance(employee, Designer):
                employee.design()
            elif isinstance(employee, Analyst):
                employee.analyze()

dev1 = Developer()
dev2 = Developer()
designer1 = Designer()
analyst1 = Analyst()

company = Company([dev1, dev2, designer1, analyst1])
company.make_work()
"""
코딩을 합니다
코딩을 합니다
디자인을 합니다
분석을 합니다
"""

to-be

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class Employee(metaclass=abc.ABCMeta):
    @abc.abstractmethod
    def work(self):
        ...

class Developer(Employee):
    def work(self):
        print("코딩을 합니다")

class Designer(Employee):
    def work(self):
        print("디자인을 합니다")

class Analyst(Employee):
    def work(self):
        print("분석을 합니다")

# 상속을 통해 쉽게 구현이 가능함 -> 확장에 열려있다.
class Manager(Employee):
    def work(self):
		print("매니징을 합니다")

class Company:
    def __init__(self, employees: List[Employee]): # 추상 클래스에 의존한다.
        self.employees = employees

    # employee가 늘어나더라도 변경에는 닫혀있다.
    def make_work(self):
        for employee in self.employees:
            employee.work()
0%