Wireframe

Python 101 – 클래스

이 글을 읽으시는 파이썬을 공부하는 여러분은 아마도 들어보셨겠지만, 파이썬은 객체 지향 언어입니다. 객체 지향 언어는 객체 지향 프로그래밍이라는 패러다임을 따르는 방식으로 설계된 언어입니다. 그리고 클래스는 어떤 객체를 정의해놓은 청사진과 같다고들 합니다. 그래서 클래스가 무엇인지를 이해하려면 먼저 객체가 무엇인지를 알아야겠네요. 위키 백과에서 객체에 대해 찾아보면 다음과 같은 설명을 찾을 수 있습니다.

컴퓨터 과학에서 객체 또는 오브젝트(object)는 클래스에서 정의한 것을 토대로 메모리에 할당된 것으로 프로그램에서 사용되는 데이터 또는 식별자에 의해 참조되는 공간을 의미하며, 변수, 자료 구조, 함수 또는 메소드가 될 수 있다.

여러 편집자들이 고민하여 작성한 설명이겠지만, 이 설명만 들어서는 객체가 무엇인지 알기가 어렵습니다. 사실 객체의 개념은 워낙 추상적이라 일상의 언어로 정확하게 설명하기란 어렵습니다. 그래서 대략의 어설픈 이해를 바탕으로 접근해서, 객체가 어떤 식으로 사용되고 또 어떻게 만들 수 있는지를 받아들이는 편이 좋을 듯 합니다. 사실 학문적으로 엄밀하고 정확한 이해보다는 어떻게 만들고 사용할 수 있는지를 아는 것이 더 중요하니까요.

기본적으로 객체는 하나의 데이터 혹은 서로 연관이 있는 여러 데이터들을 하나로 묶어 놓은 것으로 생각할 수 있습니다. 물론 객체라는 개념 아래에서는 다시 다른 많은 개념과 용어들이 연결됩니다. 이러한 추상적인 개념이 사실 다른 기술적인 해석 없이 비유 수준으로 이해가능할 것인가? 하는 물음에는 저 자신도 좀 회의적이기는 합니다만… 어쨌든 다른 하위 개념들은 나중에 생각하기로하고, 기본적으로는 어떤 값들을 구조화하여 속에 넣어둔 상자나 가방 같은 것이라고 생각할 수는 있겠습니다.

그런데 일반적인 객체 지향 언어에서 객체는 기본적으로 그 값이 속을 들여다 볼 수 있는 구조가 아니기 때문에 속에 들어있는 값의 일부분이나 전체를 보려면 반드시 그 상자가 제공하는 어떤 방법을 제공해야 한다고 말합니다. 이 때 말하는 ‘방법’을 보통 “접근자(accessor)”라 합니다. 객체의 특정한 속성에 대해서 읽기를 수행하는지, 쓰기를 수행하는지에 따라서 접근자는 다시 getter / setter 로 구분하기로 합니다.

다행히 파이썬에서는 뭐 그런 용어가 별로 중요하지 않습니다. 일단 객체는 어떤 상자나 가방 같은 것으로 보기로 했고요, 그 가방 같은 것 속에는 “속성”들이 있습니다. 이 각각의 속성은 어떤 ‘값’을 가리키는 변수일 수도 있고, 혹은 어떤 “기능”일수도 있어요. 중요한 것은 객체 속에 어떤 속성 들이 있고, 그것은 속성의 이름을 통해 이용할 수 있다는 한 가지 사실 뿐입니다. 특별히 “기능”에 해당하는 속성을 “메소드”라고 부르는데, 이것은 해당 객체와 밀접하게 연관되어 있는 함수라고 보면 됩니다.

여기서 잠깐. 만약 파이썬 객체의 속성에서 어떤 것은 값 속성이고 어떤 것은 메소드라고 했습니다. 각 속성은 그저 이름만으로 구분될 뿐인데, 이것을 파이썬은 어떻게 구별할까요?

파이썬에서 모든 것은 객체이므로, 어떤 객체의 모든 속성들 역시 객체입니다. 당연히 그 속성에 해당하는 객체들도 ‘속성’을 가질 겁니다. 모든 객체 중에서 __callable__ 이라는 속성을 가지고 있고,이 속성을 사용하여 호출 가능 여부, 즉 함수나 메소드 같은 것인지를 알 수 있습니다. 그리고 공교롭게도 이 __callable__ 이라는 속성 역시 “호출가능한” 속성입니다. 단, obj.__callable__() 과 같은 문법으로 직접 호출하기 보다는 callable(obj) 이라는 내장 함수를 통해서 특정한 객체가 호출 가능한지를 확인합니다.

그렇다면 한 객체가 어떤 속성들을 보유하고 있는지를 누군가는 결정했을 것입니다. 그리고 이 결정을 내리는 시점에는 실제로는 객체의 유형에 대해 ‘이러한 유형의 객체들은 어떤 어떤 속성들을 가지고 있을 것이다’라는 식으로 의사 결정을 했을 것입니다. 그러한 의사 결정을 코드로 반영한 결과가 바로 클래스 인 것입니다.

따라서 ‘클래스’라는 단어는 객체의 종류 혹은 유형을 의미하는 말일 수도 있는 동시에, 그것을 파이썬 코드로 정의해 놓은 “객체”이기도 합니다. (다시 말하지만 파이썬에서 모든 것은 객체입니다.)

클래스는 객체의 유형을 정의하며, 실제로 객체를 새로 생성하는 일도 수행할 수 있습니다. 붕어빵 틀에서 찍혀나오는 붕어빵처럼, 클래스로부터 찍혀 나오는 객체를 클래스와 구분하기 위해 ‘인스턴스’라는 용어를 사용합니다. 일반적으로 클래스는 그냥 그대로 ‘클래스’로 지칭하기 때문에, 보통 “객체”라고 부르면 객체 인스턴스를 의미한다고 생각하면 됩니다.

클래스 정의하기

클래스는 객체의 유형이므로, 클래스를 정의하는 것은 객체가 가져야 할 속성들의 이름과 초기값을 정의해주는 것으로 이해할 수 있습니다. class 클래스이름: 으로 시작하는 블럭 안에 메소드(함수 형식의 속성)들을 정의해주게 됩니다. 이 때, __init__(self, *) 라는 메소드에서 시작하면 됩니다.

self 는 메소드의 정의에서 객체 인스턴스를 지칭하는 것입니다. self 라는 이름은 관례에 의해 정한 것이지 파이선 예약어로 어떤 의미를 가지는 것은 아닙니다. 클래스 내부에서 정의되는 모든 메소드들은 첫 번째 인자로 객체 인스턴스 자신을 받는다고 생각하면 됩니다. 다음의 간단한 예제를 보겠습니다.

class Foo:
  __init__(self):
    self.name = "Bob"
    self.i = 10    

x = Foo()
print(x.name)
# => Bob
print(x.i)
# => 10

__init__() 메소드는 객체 인스턴스가 생성된 후 초기화를 위해서 항상 호출되는 메소드입니다. 이 곳에서 인스턴스가 가져야 할 속성들과 그 초기값들을 지정할 수 있습니다. 객체 인스턴스를 생성할 때에는 클래스 이름을 함수처럼 사용하여 호출합니다. 모든 객체가 완전히 동일한 값을 가지고 생성되어야 하는 것은 아니며, 필요에 따라서는 초기 속성값의 일부는 클래스 생성자를 호출하는 시점에 인자로 전달할 수 있습니다. 이러한 인자는 __init__() 메소드의 두 번째 이후 인자로 정의하여 그대로 받아서 사용할 수 있습니다. Foo 클래스가 그러한 방식으로 외부로부터 name, i 속성을 받아들이도록 다시 작성해보면 아래와 같습니다.

class Foo:
  def __init__(self, name, i):
    self.name = name
    self.i = i

f = Foo("Bob", 10)
g = Foo("Tom", 20)

print(f.name, f.i)
# => Bob 10
print(g.name, g.i)
# => Tom 20

클래스 속성과 인스턴스 속성

__init__() 내에서는 주로 self.x 와 같은 식으로 인스턴스가 가지는 속성을 정의하게 됩니다. 그 외에도 클래스 자체에도 속성을 정의할 수 있습니다. 이를 클래스 속성이라고 하는데, class 블럭 내에서 다른 메소드에 속하지 않는 영역에 변수를 정의하는 것처럼 정의할 수 있습니다. 클래스 속성은 클래스 객체 자체의 속성인 동시에, 해당 클래스의 모든 인스턴스 객체가 공통적으로 가질 수 있는 값입니다.

class Foo:
  y = 1
  def __init__(self, x):
    self.x = x

f = Foo(1)
g = Foo(2)

f.x   # => 1
f.y   # => 1
g.x   # => 2
g.y   # => 1

Foo.y = 3

Foo.y # => 3
f.y   # => 3
g.y   # => 3

f.y = 2
f.y   # => 2
Foo.y # => 3
g.y   # => 3

만약 어떤 객체 인스턴스에 대해서 클래스 속성과 이름이 같은 속성을 새로 정의한다면, 그 객체의 해당 속성은 새로운 값을 갖게 됩니다. 만약 클래스의 속성을 변경한다면, 해당 클래스의 인스턴스 객체들은 그 속성을 공유하므로 바뀐 값을 사용하게 될 것입니다. 반대로 특정 인스턴스에 대해서 클래스 속성과 같은 이름의 속성값을 다시 정의할 수 있습니다. 하지만 이것은 클래스 속성을 변경하지 못합니다. 그냥 새로운 인스턴스 객체가 생성되면서 클래스 속성을 더 이상 참조하지 않게 되는 것입니다.

메소드 정의하기

메소드(엄밀함을 따지면 문장이 너무 길어지고 피곤하니까, 그냥 메소드라고 하겠습니다.)는 클래스 블럭 내에서 함수의 형식으로 정의됩니다. __init__(self) 도 메소드의 한 종류니까, 사실 가장 결정적인 예제를 이미 앞에서 접했다고 볼 수 있습니다. 메소드들은 클래스 블럭 내에서 정의되었다는 특징 외에도 중요한 한가지 특징을 더 갖습니다. 바로 첫번째 인자, self 입니다. 첫번째 인자의 이름이 self 인 것은 순전히 관습적으로 그렇게 많이 쓰기 때문이며, 다른 언어와 비슷하게 this 같은 걸로 써도 상관 없습니다. 즉 self는 파이썬에서 예약어로 등록된 단어가 아닙니다.

메소드를 정의할 때의 첫번째 인자, 보통은 self 인 이 이름은 객체 자신을 가리키는 참조입니다. 즉 파이썬에서 메소드는 사실상 일반적인 함수와 크게 차이가 없으며, 호출 시점에 객체는 해당 함수에 자기 자신을 첫번째 인자로 끼워넣어서 호출하는 방식으로 구현됩니다. 어쨌든 self를 사용하여 메소드에서는 객체 자신을 참조할 수 있으므로, 객체의 다른 속성을 참조하거나 다른 메소드를 호출할 수 있습니다.

클래스 메소드

클래스 메소드는 인스턴스가 아닌 클래스 레벨에서도 호출이 가능한 메소드입니다. 클래스 메소드는 선언할 때 @classmethod 라는 데코레이터를 사용합니다. 보통 클래스 메소드의 첫번째 인자는 self 가 아닌 cls를 사용하는데, 클래스 메소드의 첫번째 인자로는 객체 자신보다는 클래스 자신이 넘겨지기 때문입니다. “클래스 레벨에서도”라는 말은 인스턴스에 대해서도 클래스 메소드로 정의된 메소드를 호출할 수 있습니다. 물론 이 경우에도 클래스 메소드의 첫 인자는 클래스 자신이 됩니다.

정적 메소드

메소드 내에서 참조하는 대상이 객체 인스턴스인지 클래스 그 자체인지에 따라서 메소드는 인스턴스 메소드(그냥 일반적으로 메소드라고 부름)와 클래스 메소드로 구분된다고 했습니다. 그 외에도 정적 메소드(static method)를 클래스에 추가할 수 있습니다. 정적 메소드는 다른 메소드라는 이름이 붙은 것들과 다르게 완전히 일반적인 함수와 동일하게 작동합니다. self 나 cls 같은 묵시적인 참조를 인자로 받지 않습니다. 그냥 단순히 함수를 클래스에 붙여놓은 것과 똑같습니다. 정적 메소드는 보통, 특정한 클래스 타입과 관련되어 있지만 클래스를 직접적으로 참조하지 않는 함수를 만들 때 사용합니다. 예를 들어 set 자료구조에는 두 집합의 합집합을 만드는 set.union() 메소드가 있습니다. 합집합을 만들 두 개의 집합을 인자로 받기 때문에, 이 함수는 일반적인 자유함수와 다르지 않지만, 클래스의 메소드처럼 이름이 붙어 있기 때문에 짧은 이름으로도 더 분명한 뜻을 표현할 수 있습니다. 정적 메소드는 보통 그러한 경우에 많이 사용합니다.

프로퍼티

변수의 형태로 정의되는 객체의 속성은 특정한 값에 대한 저장소의 역할을 합니다. 그런데 어떤 속성들은 그 값을 저장하지 않고 다른 값으로부터 만들어 낼 수 있는 것들도 있습니다. 예를 들어, 삼각형을 클래스로 표현했고, 이 클래스의 속성에는 높이와 밑변의 길이가 있다고 합시다. 이 삼각형의 넓이를 구하는 방법에는 두 가지가 있을 수 있습니다. 먼저 메소드를 사용하는 것입니다. triangle.getArea() 와 같은 식으로 넓이를 반환하는 메소드를 작성할 수 있습니다. 다른 한 가지는 객체의 속성으로 넓이 값을 저장해두는 것입니다.

두 번째 방법은 여러 가지로 문제가 있습니다. 먼저 객체의 속성값은 언제든지 변할 수 있기 때문에, 밑변의 길이나 높이가 변경되었을 때, 그 전에 ‘저장’해 놓은 넓이값은 더 이상 정확하지 않은 값이된다는 문제가 있습니다. 그렇다면 첫번째 방법이 가장 나은 선택일까요? 정말 상식과는 반대되지만, 삼각형의 넓이 속성을 변경할 수 있게 하고 싶다면요? 삼각형의 넓이 속성을 변경하면, 밑변은 고정되고 높이가 그에 맞게 변하도록 할 수도 있을 것입니다.

그런데 이렇게 값을 참조하거나 변경하는 문법을 t.area, t.area = 10 과 같은 속성을 제어하는 문법을 사용하고 싶을 수도 있습니다.이것을 가능하게 하는 것이 “프로퍼티”라는 기능입니다. @property 데코레이터를 사용하여 “getter” 메소드를 작성하면 저장된 속성을 가공하여 속성을 만드는 것이 가능합니다. 그리고 @somProperty.setter 와 같은 문법으로 “setter” 메소드를 작성할 수도 있습니다. 예시에서 설명한 삼각형 클래스를 실제로 만들어보면 아래와 같습니다.

class Triangle:
  def __init__(self, b, h):
    self.b = b
    self.h = h

  @property
  def area(self):
    return self.b * self.h / 2

  @area.setter
  def area(self, val):
    self.h = val * 2 / self.b

t = Triangle(5, 6)
print(t.area) # => 15.0

t.area = 80
print(t.area) # => 80.0
print(t.h)    # => 32.0

프로퍼티는 객체 내의 저장공간에 묶인 값을 사용할 때도 있지만, 위 예제처럼 다른 속성들로부터 계산된 결과를 사용하기도 합니다. Swift나 Objective-C 같은 언어는 객체 내의 속성을 property 라고 부르는데, 파이썬의 프로퍼티와 같이 계산된 결과를 사용하는 종류에 대해서 특별히 “computed property”라는 이름으로 부르기도 합니다.

상속

클래스 개념을 사용하는 객체 지향언어들에는 클래스의 상속이라는 개념이 존재합니다. 각기 다른 여러 타입의 객체들이 있을 때, 이 중 어떤 객체들은 비슷한 특성을 공유하는 것들이 있을 수 있습니다. 동물을 예로 들어보겠습니다. 고양이, 원송이, 돼지, 닭, 오리, 참새라는 타입들이 있다고 할 때, 고양이, 원숭이, 돼지는 각각 다른 타입(?)의 동물이지만 이들이 공유하는 공통점이 있습니다. 닭, 오리, 참새도 공유하는 공통점이 있죠. 뿐만아니라 이 모든 동물들은 네발 동물인지 새인지의 차이를 넘어서 공유하는 공통점도 가지고 있습니다. 이러한 점을 ‘계통’이라는 측면에서 살펴보면 우선 모든 동물들은 ‘동물’이라는 공통적인 분류에 해당합니다. 이 ‘동물’은 다시 ‘네발 동물’과 ‘새’로 구분됩니다. ‘네 발 동물’은 다시 고양이, 원숭이, 돼지로 분류될 수 있고, ‘새’는 닭, 오리, 참새로 분류될 수 있습니다.

상속은 어떤 클래스를 상위 분류로 두고 그 상위분류에 대해서 보다 세부적이고 구체적인 특성을 더한 하위 분류를 만드는 방법입니다. 위의 동물들의 계통을 클래스에 비유한다면, 가장 근원적인 공통점만을 가지는 Animal 이라는 클래스를 정의할 수 있습니다. 이 Animal을 상속하여 네 발 동물, 새를 각각 표현하는 FourLeggedAnimal, Bird 라는 클래스를 정의할 수 있습니다.

class Animal:
  legs = 4
  wings = 0
  tails = 0

class FourLegged(Animal):
  tails = 1   # <- 상속받은 속성을 변경

class Bird(Animal):
  legs = 2
  wings = 2
  beak = 1    # <- 새로운 속성 추가

class Cat(FourLegged):
  pass

상속을 사용하면 어떤 공통된 속성이나 동작을 가진 분류로부터 약간의 변형을 더한 새로운 파생 타입을 만들어서 사용할 수 있습니다. 이러한 상속이 적극적으로 사용되는 분야가 바로 GUI입니다. 여러분이 컴퓨터에서 사용하는 많은 프로그램들은 창, 스크롤바, 버튼, 텍스트 입력 필드와 같은 구성요소들을 사용하여 화면을 만듭니다. 버튼 종류들은 약간 차이가 있는 것도 있지만, 대부분 비슷하게 생겼고 하는 동작도 비슷합니다. 그리고 버튼과 텍스트 필드는 다르게 생기고 하는 일도 다른 것 같지만, 사용자에게 어떤 정보 (상태나 문자열값)을 표시하고, 사용자로부터 입력을 받는다는 공통된 동작도 합니다. 즉 이러한 GUI 구성요소들은 어떤 근원적인 클래스로부터 그 계통이 시작되어 상속을 통한 파생 타입을 만들면서 특화된 특징을 위한 차이를 조금씩 만들어서 그 형태를 갖추게 되었다고 볼 수 있습니다.

클래스를 상속할 때에는 class 클래스이름(부모클래스): 와 같이 class 구문의 맨 끝에 괄호를 열고 그 속에 상속하고자 하는 부모 클래스의 이름을 넣습니다. 이렇게만 하고, 아무런 추가 정의가 없어도 새로 정의되는 자식 클래스는 부모 클래스의 모든 속성과 메소드를 그대로 물려 받습니다. 그리고는 부모 클래스의 속성과 메소드를 재정의 하거나, 새로운 속성과 메소드를 추가할 수 있습니다. 이렇게 하여 부모 메소드를 확장하고 더 구체적인 형태로 다음어 나갈 수 있습니다.

오버라이드

부모 클래스에서 정의된 속성이나 메소드를 자식 클래스에서 변경하는 것을 오버라이드(override)라고 합니다. 클래스 속성을 오버라이드하는 것은 간단합니다. 그냥 속성 값을 바꿔서 정의해주면 되는데요, 메소드의 경우에는 조금 독특한 형태로 오버라이드를 구현합니다.

class Foo:
  y = 2
  def __init__(self, x):
    self.x = x

class Bar(Foo):
  def __init__(self, x, z):
    super().__init__(x)
    self.z = z

위 예제는 간단한 초기화 메소드의 오버라이드 방법을 보여줍니다. 먼저 부모클래스인 Foo는 클래스 속성인 y와 인스턴스 속성인 x를 정의했습니다. 그리고 객체를 생성할 때에는 x의 값을 전달해주게 됩니다. 이를 상속받는 Bar는 추가적인 속성인 z를 가지고 있습니다. 그리고 생성시에는 x 뿐만 아니라 z를 전달합니다. 그런데 Bar.__init__() 에서는 self.x = x 라는 표현을 사용하지 않았습니다. 대신 super().__init__(x) 를 사용해서 부모 클래스의 초기화 메소드를 호출합니다.

자식 클래스는 부모 클래스로부터 속성들을 상속받지만, 그 내부 구현이 어떤지는 알지 못합니다. 따라서 부모가 하는 것과 똑같은 내부 초기화를 수행하는 코드를 따로 작성하는 것이 아니라, 부모 클래스의 해당 메소드를 호출하여, 부모에서 제공한 기능을 사용한 후, 다시 자신에게서 추가된 속성들을 초기화합니다. 이러한 오버라이드는 비단 초기화 메소드인 __init__() 뿐만 아니라, 다른 메소드에서도 비슷한 방식으로 구현합니다.

이상으로 클래스에 대한 가장 기초적인 내용을 살펴보았습니다. 사실 이것보다 훨씬 더 많은 다루어야 할 내용이 있지만, 기본적으로 알아두어야 할 내용만 간단히 소개했습니다. 클래스를 활용하는 방법에 관해서는 블로그에 몇 개의 읽어볼만한 글이 더 있으니, 한 번 검색해보는 것도 좋겠죠. 사실 객체지향언어를 사용한다고 해서 반드시 클래스를 작성하고 이를 기반으로 문제를 해결해야 하는 것은 아닙니다. 반대로 C와 같은 절차형 언어에서도 객체지향의 방법론을 적용하지 못하는 것도 아니고요. 사전이나 튜플과 같은 자료형을 적절하게 활용하고, 코루틴 등을 잘 활용하면 더욱 효율적이고 간결한 코드를 작성하는 것도 가능합니다. 다만, 파이썬이 갖는 다른 객체 지향 언어와는 다른 성격이라면, 어떤 문제를 해결하는데 특화된 클래스를 누군가는 만들어 두었을 가능성이 크기 때문에, 다른 사람이 만들어놓은 클래스를 잘 활용하고 확장하는 스킬이 더 중요하다는 점입니다.

그럼 다음 시간에도 흥미로운 주제를 가지고 공부해보도록 하겠습니다.

Exit mobile version