[Python] 클래스 이해하기

클래스를 설명할 때 흔히 쓰는 표현은 ‘클래스는 거푸집에 해당하고 객체는 그 거푸집으로 찍어내는 벽돌에 해당한다.’는 것이다. 물론 완전히 틀린 설명은 아닌데, 이 개념에서 출발해서 클래스를 이해하는 것은 객체와 클래스의 관계와 클래스를 어떻게 다룰 것인지 등 여러 관점을 정립하는데 많은 어려움을 유발한다.

이 글은 파이썬 초보자들이 클래스에 대해 접근하고 이해하는데 도움을 주고자 작성됐다.

모든 것은 객체이다.

파이썬에서 통용되는 가장 중요한 대전제는 모든 것이 객체라는 것이다. 1, 2와 같은 숫자값도 C처럼 원시값이 아니라 int 타입의 객체이다. 함수 역시 객체이고 모듈이나 패키지도 객체처럼 취급된다. 모든 것이 객체라면, 클래스 그 자체도 객체라는 말이된다. 그럼 이 시점에서 다시 한 번 되물어보자. 도대체 객체란 무엇인가?

객체를 설명하는 여러 표현들이 있지만, 여기서 필요한 것은 객체는 속성을 담는 그릇 혹은 가방 같은 것이라는 설명이 가장 적절할 것 같다. 속성이란 다른 값(파이썬에서는 모든 값이 객체이므로 결국은 객체)에 이름을 부여해서 다른 객체에 담아두는 것을 말한다. 파이썬은 동적 언어이며, 객체는 실행 시간에 새로운 속성을 추가하거나, 속성을 변경하거나 속성을 제거할 수도 있다. 일단 우리가 알고 있을 법한 내용으로 구성된 간단히 다음 예를 살펴보자.

# 아무것도 없는 빈 클래스를 하나 정의한다.
class Foo:
  pass

obj = Foo()
obj.foo = 2
print(obj.foo) 
# 2

객체 obj 에 대해서 이름이 ‘foo’이고 값이 2 인 속성을 부여(추가)하고 다시 obj.foo 라는 표현을 통해서 그 값을 읽어서 출력하는 코드이다. 우리가 어떤 정보를 객체로 다루는 것은 ‘객체’라는 하나의 단위를 통해서 관련이 있는 혹은 같이 가지고 다니면 편리한 여러 정보를 한 번에 이리저리 주고 받기 편한 것이 가장 큰 이유이다.

여기서 중요한 것. obj 는 클래스 Foo로부터 생성된 ‘객체’이다. 그런데 클래스도 객체라고 했으므로 클래스와 구분하기 위해 ‘인스턴스’라는 표현을 사용하겠다. 즉 클래스는 객체를 찍어내는 거푸집이 아니라 ‘인스턴스’를 찍어내는 거푸집 객체라고 말하는 것이 보다 정확하다 하겠다. 또한 이 글에서는 앞으로 ‘객체’라는 표현은 클래스와 인스턴스를 모두 통칭하는 표현으로 사용하겠다.

객체에서 속성을 액세스하는 법

객체는 속성들을 담아다니는 가방과 같은 것이라 했다. 그렇다면 우리는 속성을 어떻게 액세스할까? 보통 dot syntax(점 문법)라 불리는 표기를 사용하여 인스턴스 이름과 속성이름을 . 으로 연결한 표기를 사용한다. 앞선 예제에서 obj.foo 가 이러한 표기이다.

그럼 obj.foo 를 사용하여 객체 인스턴스로부터 속성값을 액세스하는 것은 어떻게 동작할까? 파이썬은 모든 객체에 대해서 같은 방식을 통해서 속성을 액세스하도록 디자인되어 있다. 여기에 파이선이 객체의 속성을 얻는 공통된 방법을 소개한다.

  1. 모든 객체는 암묵적으로 __dict__ 라는 속성을 가지고 있다. 이 속성은 말 그대로 사전타입이며 객체가 그 자체에 가지고 있는 모든 속성의 이름과, 그 이름이 가리키는 대상을 키, 값의 쌍으로 가지고 있다.
  2. obj.foo 를 평가하면 이는 내부적으로 obj.__dict__['foo'] 로 이해된다. 즉 어떠한 객체의 __dict__ 속성값은 그 객체가 보유하고 있는 모든 로컬 속성들을 담아두는 백그라운드 저장소의 개념이다. (이를 backing storage라는 말을 써서 표현하는데, 파이썬 외에 다른 언어에서도 사용되는 개념이다.)
  3. 만약 obj.bar라는 속성값을 참조하려면 어떻게될까? obj.__dict__['bar']를 찾게될 것인데, 인스턴스 obj는 bar 라는 속성을 가지지 않았으므로 해당 키에 대한 값을 찾을 수 없을 것이다. 사전이라면 KeyError 가 발생하는데, 객체에서는 이것을 AttributeError로 바꿔서 던지게 된다. 즉 액세스할 수 없는 속성을 참조하려하면 예외가 발생하다.

요약하면 파이썬은 객체의 속성에 대해서 이름과 참조 대상을 짝지어 사전에 저장하고 점 문법으로 액세스할 때 이 사전을 찾아보게 된다는 것이다. 그럼 객체 인스턴스와 클래스 사이에서는 어떤 일이 발생할까? 다음의 간단한 예를 살펴보자.

객체에 속성을 정의하는 법

class Foo:
  x = 1

f = Foo()

print(f.x) #-> 1

이 예는 흔히 ‘클래스 속성’이라 부르는 형태로 클래스에 속성을 지정하고 액세스하는 것을 보인 것이다. 클래스 Foo 에 x라는 속성을 정의하고 인스턴스 f를 생성했다. 이 때 f.x 를 액세스하면 어떤 순서로 1이라는 정수 객체를 찾게 되는 것일까?

  1. 먼저 f.x 를 통해서 인스턴스 f의 __dict__ 속성에 ‘x’라는 키 이름을 주고 값을 찾을 것이다. 하지만 실제로 인스턴스 f에 대해서 속성 x는 정의된 적이 없으므로 그 이름을 찾을 수 없다. (실제로 f.__dict__를 출력해보면 빈 사전이 표시된다.)
  2. 그렇다면 파이썬은 인스턴스 f의 클래스를 찾는다. (이 때 f.__class__ 속성이 사용된다.) 이는 Foo 클래스를 찾게 된다.
  3. 다시 Foo의 속성 중에 x 가 있는지 찾는다. 여기서 1이라는 정수 객체를 찾아서 리턴하게 된다.

이 때 우리는 마치 클래스가 자신의 인스턴스 객체에게 속성을 빌려주는 것처럼 행동하는 것을 알 수 있다. 다음 코드를 조금 더 살펴보자.

g = Foo()
g.x #-> 1
g.x = 2
g.x #-> 2
f.x #-> 1

Foo의 새로운 인스턴스 g를 만들고 g.x = 2 를 썼다. 바로 위의 g.x 를 참조했을 때 1이라는 값, 즉 메소드 속성을 출력했으므로 g.x = 2 라고하면 그 메소드 속성이 변경될 것이라고 예상할 수 있다. 하지만 출력해보면 g.x 는 2이고 f.x 는 여전히 1이다. 무슨 일이 생긴 걸까?

그것은 g.x = 2 구문이 실행될 때 발생하는 것은 말 그대로 "객체 g에 속성 x를 2로 정의한다"는 동작이 실행된 것이다. 이것은 객체 g의 인스턴스 속성 ‘x’ 가 정의되는 것을 말한다. 즉 g.x = 2가 실행되고 나면 g.__dict__ 에 새로운 키 ‘x’가 추가되는 것을 의미하고 이는 객체 g가 속성 x에 대해서 더이상 클래스로부터 속성을 빌려오지 않게 되는 것을 뜻한다.

파이썬 교재들에서 말하는 ‘클래스 속성’에 대해서 요약해보면 대충 이런 말들을 하고 있다.

  1. class 정의 블럭에서 def __init__() 바깥에서 정의되는 속성이다.
  2. 해당 클래스의 모든 인스턴스에 공통으로 적용되는 속성이다.

정확하게 말하자면 이 설명들은 모두 틀렸다. 클래스도 객체, 인스턴스도 객체이다. 따라서 Foo.y = 2 와 같이 클래스 객체도 실행 시간에 속성을 추가로 정의하거나 변경, 제거할 수 있다. 또한 클래스가 가지고 있는 속성은 인스턴스가 같은 이름의 속성을 가지지 않고 있을 때 빌려주는 것이지, 그 클래스로부터 생성되는 인스턴스에 자동으로 ‘부여’되거나 ‘적용’되지는 않는다.

결정적으로 클래스는 인스턴스를 찍어내는 틀이라기 보다는 인스턴스가 의존하고 있는 속성 가방이라고 봐야 하며, 클래스와 인스턴스는 결국에는 서로 다른 객체라는 것이다.

메소드

메소드는 보통 객체지향 프로그래밍에서 특정한 객체에 묶여있는(bound)함수를 말한다. 객체에 묶여 있다는 것은 함수가 해당 객체의 멤버변수(파이썬에서는 인스턴스 속성)를 액세스할 수 있다는 의미이다. 만약 어떤 클래스의 인스턴스가 100개 존재할 때, 각각의 메소드는 자신과 묶여 있는 객체 인스턴스의 멤버를 그 내부에서 참조할 수 있다는 것이다.

그런데 엄밀하게 파이썬에서는 메소드가 이렇게 돌아가지 않는다. 즉 메소드는 인스턴스 객체에 실제로 묶여있지 않다, 대신 약간의 꼼수를 써서 그렇게 보이도록 만든 것이다. 그렇다면 파이썬의 메소드는 어떻게 구현될까? 일단 기술적으로 파이썬의 메소드는 “특정 객체의 호출가능한 속성”이다. ‘호출 가능하다’는 것을 함수의 속성이며, 파이썬에서는 모든 것이 객체이기 때문에 함수 역시 객체이다. 결국 호출가능하다는 사실을 파이썬 해석기에게 알려주기 위해서 모든 파이썬 함수는 내부에 __call__이라는 속성을 가지고 있다. (실제로 f() 와 같은 식으로 어떤 객체를 ‘호출’하면 해당 객체의 __call__ 속성에 해당하는 함수가 실행된다.)

특정 객체가 호출가능한지를 실행 시점에 조사하고 싶다면 .__call__ 속성을 확인하는 것보다 기본함수 callable(obj)을 사용해서 obj가 호출가능한 객체 인지를 조사하는 방식을 권장한다.

그렇다면 어떻게 파이썬의 메소드는 자신과 묶여있는 객체를 알 수 있을까? 그것은 ‘인스턴스 객체의 속성으로 액세스된 함수는 첫 인자를 무조건 해당 객체로 받는다’는 암묵적인 약속이 있어서 가능한 것이다. (이것은 오직 파이썬 만의 특성은 아니며, Objective-C 역시 일반적인 C함수의 첫 인자로 ‘묶여있을’ 객체를 받는 것으로 메소드를 구현한다.)

그렇기 때문에 메소드를 정의하는 코드를 보면 항상 첫번째 인자로 self 가 넘겨지도록 정의되어 있음을 볼 수 있다. 이 self는 파이썬의 키워드가 아니며 관습적으로 사용되는 인자명이다.

class Spam:
  x = 3
  def bar(self, y):
    return self.x + y

ham = Spam()
print(ham.bar(2))
# -> 5

위 코드는 다음과 같이 동작한다.

  1. 클래스 Spam에 bar 라는 메소드를 정의한다.
  2. bar 메소드는 self 외에 y라는 추가 인자를 받게끔 되어 있다. 메소드 내부에서는 self로 넘겨진 객체의 x 속성과 인자 y를 더한 값을 리턴한다.
  3. ham 이라는 인스턴스를 만들고 여기에 2를 주어 실행한다.
  4. 실제 실행은 함수 bar(ham, 2)로 실행된다.

정리하자면 메소드는 관습적으로 첫번째 인자인 self를 무조건 받게끔 정의되며, 객체 인스턴스 외부에서 호출되는 경우 첫번째 인자가 가려져서 없는 것처럼 보이며, 실제 호출 시에는 묶여있는 인스턴스가 첫번째 인자로 들어간다.

클래스의 메소드

메소드는 호출가능한(callable) 객체를 가리키는 속성이다. 인스턴스 객체에서 메소드를 호출할 때에는 미리 정해진 특별한 약속에 따라 객체이름.메소드명으로 노출되는 함수는 첫번째 인자가 생략된 함수처럼보이게 된다. 그러나 클래스 객체에 대해서 이 메소드는 다른 여느 값 타입의 속성과 마찬가지로 그저 타입이 함수인 속성으로만 취급된다.

ham.bar
#  <bound method Spam.bar of <__main__.Spam object at 0x000002051F6B8FD0>>
# 인스턴스 메소드는 해석기에서 인스턴스 ham에 '묶인' 메소드라고 표시된다.
ham.bar(2)
# -> 5

Spam.bar
# <function __main__.Spam.bar(self, y)>
# Spam의 bar 속성은 일반 함수 객체를 가리킨다. 
# 호출시 첫번째 인자가 생략되지 않는다.
Spam.bar(2)

TypeError: bar() missing 1 required positional argument: 'y'

메소드의 경우에도 일반적인 값 속성처럼 클래스의 속성으로도 호출가능하고 인스턴스로부터도 동일하게 호출하도록 할 수 있을까? 이것을 가능하게 해주는 것이 @classmethod 데코레이터이다. 이 데코레이터를 사용했을 때 함수의 동작은 다음과 같은 차이를 가진다.

  1. 첫번째 인자는 self 대신 cls를 쓴다. (‘클래스’를 의미하며, 이 역시 관습적이다.)
  2. 해당 인자로는 클래스 객체가 전달된다. 따라서 참조가능한 속성은 클래스 속성만 참조된다.

클래스 메소드를 작성하는 방법은 다음과 같다.

class Foo:
  x = 3
  @classmethod
  def bar(cls, y):
    return cls.x + y

f = Foo()
f.x = 2
f.bar(2)
# 5

Foo.bar(3)
# 6

위 예에서 bar는 클래스 메소드로 정의되었다. 인스턴스 f를 만든 후 f.x = 2 를 사용해서 인스턴스 속성 x를 정의했다. f.bar(2)를 호출하더라도 이 때 bar는 클래스의 메소드이며, bar 내부에서 참조하는 객체는 인스턴스인 f가 아닌 Foo 클래스이다. 이 메소드는 어떤 인스턴스로부터 호출되더라도 Foo.x를 참조하기 때문에 2가 아닌 3을 액세스하게 된다. 따라서 이 예제에서의 최종 결과는 5가 리턴된다.

또한 Foo.bar의 형태로 접근했을 때에도 bar는 Foo에 묶여 있기 때문에 Foo 클래스 객체 자신을 첫번째 인자로 받아서 정상적으로 실행할 수 있게 된다.

초기화 메소드

보통은 쓸모있는 클래스를 만들 때에는 어김없이 def __init__(self, ... ) 로 시작하는 메소드를 정의하게 된다. 이 메소드는 아주 특별히 ‘초기화 메소드’라고 한다. f = Foo()와 같이 클래스 이름 자체를 호출하여 새 인스턴스를 만들면 내부적으로 다음과 같은 일이 벌어진다.

  1. __dict__ 속성이 빈 사전인 새로운 파이썬 객체가 하나 생성된다.
  2. __class__ 속성 값에 클래스 객체 Foo 가 바인딩된다.
  3. 새로 생성된 객체에 __init__ 메소드가 호출된다.

따라서 초기화 메소드는 인스턴스가 최초로 생성된 직후에, 다른 곳에 쓰이기 전에 필요한 인스턴스 속성들을 정의해두는 곳으로 생각하면 된다.

만약 인스턴스를 새로 생성할 때, 어떤 속성값들을 외부로부터 받아야 한다면 __init__ 메소드의 두 번째 인자 이후를 지정하면 된다. 그리고 인스턴스를 생성할 때 필요한 데이터를 순서대로 넘겨준다. 예를 들면 다음과 같은 식이다.

class Friend:
  def __init__(self, name, age):
    self.name = name
    self.age = age

h = Friend("Sammy", 30)
h.name #-> "Sammy"
h.age #-> 30

이 Friend 클래스는 어떠한 클래스 속성도 가지고 있지 않다. 대신에 초기화 메소드에서 개별 인스턴스가 가져야 할 필수적인 정보인 이름과 나이를 받으며, 인스턴스 생성 즉시 name, age 라는 인스턴스 속성으로 해당 데이터를 연결해준다.

일반적인 클래스의 사용에서는 생성된 인스턴스에 대해서 실행 시간에 점문법으로 임의의 속성을 할당하기 보다는 초기화 메소드를 통해서 미리 약속한 인스턴스 속성들을 준비한다. 따라서 f 라는 인스턴스 객체가 Friend 클래스의 인스턴스라면 name, age라는 속성을 가지고 있을 것이라는 약속이 성립될 수 있다.

만약 이러한 약속이 필요없이 임의의 이름으로 데이터 속성을 저장하는 용도라면 굳이 클래스를 만들 필요 없이 사전객체를 사용하는 것이 더 간단한 일이다.

정리

여기까지 파이썬의 클래스와 관련하여 많이 질문을 받았던 (혹은 질문을 봐 왔던) 내용들을 정리해보았다. 거푸집이나 붕어빵틀의 비유가 관념적으로 클래스-인스턴스간의 관계와 비슷하지만, 그것은 실제 클래스-인스턴스 사이의 관계에 의해서 보여지는 일부 결과가 이러한 관계를 연상시킬 뿐이라는 점을 알아두었으면 좋겠다. 글에서 소개한 클래스와 인스턴스의 관계에 대해서 다음의 내용만큼은 꼭 이해하도록 하자.

  1. 객체는 속성을 담아두는 가방이다.
  2. 클래스는 ‘객체를 위한 설계도’나 ‘청사진’이 아니라 그 자체가 엄연한 객체이다.
  3. 클래스는 인스턴스가 가지지 않은 속성을 빌려주는 창고의 역할을 한다. 이것을 backing storage라 하기도 한다.
  4. 초기화 메소드는 인스턴스 객체를 생성한 후에 실행되는 메소드로, 말 그대로 인스턴스 객체가 처음부터 가지고 있어야 할 속성들을 등록해주는 일을 한다.

클래스와 관련하여 아직 상속 개념에 대해서는 분량 관계로 이 글에서는 다루지 않았으며, 기회가 될 때 다시 한 번 이야기하도록 하겠다.