추상 클래스(abstract class)는 이 메서드는 반드시 구현하라고 강제하는 설계도다. 자기 자신은 인스턴스로 만들 수 없고, 상속받은 자식이 정해진 메서드를 다 채워야 비로소 쓸 수 있다. 다형성은 여러 클래스가 같은 이름의 메서드를 갖는다는 약속 위에 서 있다. 그런데 그 약속을 깜빡하고 메서드를 빠뜨리면, 실제로 그것을 부르는 먼 코드에 가서야 에러가 터진다. 추상 클래스는 이 실수를 객체를 만드는 순간 바로 잡아 준다.
파이썬은 표준 라이브러리 abc 모듈의 ABC와 @abstractmethod로 추상 클래스를 표현한다. ABC를 상속하고 메서드에 @abstractmethod를 붙이면, 그 메서드는 자식이 반드시 구현해야 하는 의무가 된다. 여러 구현체가 같은 약속, 즉 인터페이스(interface, 규격)를 지키도록 보장하는 도구다. 이번 편은 그 쓰임을 네 예제로 다룬다.
08.1 구현을 강제하는 계약
결제 수단 PaymentMethod는 pay()를 반드시 가져야 한다는 규칙을 세운다. 추상 클래스로 만들면 이 규칙이 강제된다.
from abc import ABC, abstractmethod
class PaymentMethod(ABC):
@abstractmethod
def pay(self, amount):
...
class CardPayment(PaymentMethod):
def pay(self, amount):
return f"카드로 {amount}원 결제"
print(CardPayment().pay(10000))
try:
PaymentMethod()
except TypeError as e:
print("TypeError:", e)
카드로 10000원 결제
TypeError: Can't instantiate abstract class PaymentMethod without an implementation for abstract method 'pay'
CardPayment는 pay를 구현했으므로 정상적으로 만들어져 결제한다. 그러나 PaymentMethod() 자체를 만들려 하면 TypeError(타입 오류)가 난다. pay라는 추상 메서드가 아직 구현되지 않았다는 이유다. 추상 클래스는 반쪽짜리 설계도라서 그 자체로는 물건이 될 수 없다. 반드시 자식이 빈칸을 채워야 한다. pay 몸통에 쓴 ...은 파이썬의 Ellipsis로, 내용 없이 자리만 비워 둔다는 표시다.
08.2 빠뜨리면 만드는 순간 걸린다
추상 클래스의 진짜 이득은 실수를 일찍 잡는 데 있다. 자식이 추상 메서드를 구현하지 않으면, 그 자식마저 인스턴스로 만들 수 없다.
class BadPayment(PaymentMethod):
pass
try:
BadPayment()
except TypeError as e:
print("TypeError:", e)
TypeError: Can't instantiate abstract class BadPayment without an implementation for abstract method 'pay'
BadPayment는 PaymentMethod를 상속했지만 몸통이 pass뿐이라 pay를 구현하지 않았다. 그래서 BadPayment()를 부르는 순간 바로 에러가 난다. 이 시점이 중요하다. 추상 클래스가 없었다면 BadPayment는 아무 문제없이 만들어졌다가, 한참 뒤 없는 pay를 부르는 먼 코드에서야 터졌을 것이다. 추상 클래스는 pay를 빠뜨렸다는 사실을 객체를 만드는 첫 순간에 알려 준다. 버그가 멀리 도망가기 전에 잡는 것이 좋은 설계다.
08.3 인터페이스로 구현체 갈아 끼우기
추상 클래스는 이 자리에는 이런 메서드를 가진 무언가가 온다는 계약서 역할을 한다. 알림 Notifier라는 약속을 두면, 이메일이든 문자든 그 약속만 지키면 끼워 넣을 수 있다. 쓰는 쪽은 종류를 구분하지 않는다.
class Notifier(ABC):
@abstractmethod
def send(self, msg):
...
class EmailNotifier(Notifier):
def send(self, msg):
return f"이메일: {msg}"
class SMSNotifier(Notifier):
def send(self, msg):
return f"문자: {msg}"
def notify_all(notifiers, msg):
return [n.send(msg) for n in notifiers]
print(notify_all([EmailNotifier(), SMSNotifier()], "안녕"))
['이메일: 안녕', '문자: 안녕']
notify_all은 넘어온 것이 이메일 발송기인지 문자 발송기인지 묻지 않고 n.send(msg)만 부른다. Notifier라는 계약이 send의 존재를 보장하기 때문에 안심하고 부를 수 있다. 메서드 이름만 맞으면 통하는 덕 타이핑과 결과는 비슷해 보이지만 차이가 있다. 덕 타이핑은 메서드가 있기를 바랄 뿐이고, 추상 클래스는 없으면 아예 객체를 못 만들게 막는다. 규격을 문서가 아니라 코드로 못 박는 것이다. 카카오톡 알림을 추가하고 싶으면 Notifier를 상속해 send를 구현한 클래스를 하나 만들어 리스트에 넣으면 된다. notify_all은 그대로다.
08.4 공통 뼈대는 부모에, 다른 부분만 자식에
추상 클래스는 빈 메서드만 담는 그릇이 아니다. 공통 로직은 부모에 직접 구현해 두고, 종류마다 달라지는 부분만 추상 메서드로 남길 수 있다. 보고서 Report의 전체 뼈대 generate()는 모든 보고서가 같고, 제목과 본문만 종류마다 다르다.
class Report(ABC):
def generate(self):
return f"제목:{self.title()} / 본문:{self.body()}"
@abstractmethod
def title(self): ...
@abstractmethod
def body(self): ...
class SalesReport(Report):
def title(self):
return "매출"
def body(self):
return "1억원"
print(SalesReport().generate())
제목:매출 / 본문:1억원
generate()는 제목과 본문을 조립하는 공통 뼈대라서 부모 Report에 완성된 형태로 들어 있다. 반면 title과 body는 보고서마다 내용이 다르므로 추상 메서드로 비워 두었다. SalesReport는 title과 body 두 빈칸만 채웠는데, generate는 부모에서 물려받아 그대로 작동한다. 변하지 않는 형식은 부모 한 곳에서 관리하고, 변하는 내용만 자식이 채우는 이 방식을 템플릿 메서드 패턴이라 부른다.
이득은 분명하다. 새 보고서 종류를 추가할 때 title과 body만 쓰면 되고, generate의 형식은 저절로 통일된다. 보고서 전체 틀을 바꾸고 싶으면 부모의 generate 한 곳만 고치면 모든 보고서가 함께 바뀐다. 추상 클래스가 강제하는 빈칸과 부모가 제공하는 공통 뼈대가 한 클래스 안에서 맞물린다.
08.5 정리
추상 클래스는 abc 모듈의 ABC와 @abstractmethod로 만들며, 자식이 반드시 구현해야 할 메서드를 못 박는다. 추상 클래스 자체와 빈칸을 안 채운 자식은 인스턴스로 만들 수 없어, 구현을 빠뜨린 실수가 객체를 만드는 순간 바로 드러난다. 여러 구현체가 같은 규격을 지키게 하는 인터페이스 역할을 하며, 공통 뼈대를 부모에 두고 다른 부분만 자식이 채우는 템플릿 메서드로도 쓴다. 덕 타이핑이 느슨한 약속이라면 추상 클래스는 코드로 강제하는 계약이다. 오늘 본 네 예제를 직접 쳐 보며 구현을 빠뜨렸을 때 언제 에러가 나는지 확인해 보기 바란다.