오버라이딩(overriding, 재정의)은 부모에게 물려받은 메서드를 자식이 자기 방식으로 다시 정의하는 것이다. 이름은 부모 것과 똑같지만 동작이 다르다. 상속이 부모의 코드를 그대로 물려받는 일이라면, 오버라이딩은 그중 마음에 안 드는 메서드만 골라 자식이 갈아 끼우는 일이다. 자식 객체로 그 메서드를 부르면 부모 버전이 아니라 자식 버전이 실행된다.


왜 이것이 필요한가. 부모에 둔 공통 동작이 모든 자식에게 딱 맞지는 않기 때문이다. 새는 대개 날지만 펭귄은 걷는다. 도형은 넓이를 갖지만 도형 종류마다 계산식이 다르다. 이럴 때 자식마다 새 이름의 메서드를 만들면 부르는 쪽이 종류를 일일이 구분해야 한다. 이름을 부모와 똑같이 두고 속만 바꾸면, 부르는 쪽은 종류를 몰라도 된다. 이번 편은 오버라이딩의 세 가지 얼굴을 코드로 확인한다.

06.1 부모 메서드를 갈아 끼우기

가장 기본이 되는 오버라이딩부터 본다. 도형 Shape의 area()는 자리만 잡아 둔 기본값 0을 돌려준다. 정사각형 Square는 이 area()를 자기 계산으로 덮어쓴다.


class Shape:
def area(self):
return 0
def name(self):
return "도형"
class Square(Shape):
def __init__(self, side):
self.side = side
def area(self):
return self.side ** 2
def name(self):
return "정사각형"

sq = Square(4)
print(sq.name(), sq.area())


정사각형 16


Square 안에 area와 name을 부모와 같은 이름으로 다시 만들었다. 그러자 sq.area()는 부모의 0이 아니라 자식의 4 ** 2, 즉 16을 돌려준다. name()도 "도형"이 아니라 "정사각형"이 나온다. 자식에 같은 이름의 메서드가 있으면 파이썬은 부모 것을 제쳐 두고 자식 것을 먼저 찾아 실행한다. 부모의 area()는 지워진 것이 아니라 자식 버전에 가려졌을 뿐이다.


왜 부모의 area()를 0으로 두었는지 짚고 넘어갈 만하다. Shape는 넓이라는 개념은 있지만 구체적인 도형이 아니라서 넓이를 계산할 방법이 없다. 그래서 부모는 자리만 잡아 두고, 실제 계산은 정사각형, 원 같은 구체적인 자식이 오버라이딩으로 채운다. 부모는 무엇을 할지 이름으로 약속하고, 자식은 어떻게 할지 구현으로 답하는 셈이다. 이 약속과 구현의 분리가 오버라이딩이 하는 일의 본질이다.

06.2 부모 동작을 재사용하며 확장

오버라이딩이 늘 부모 동작을 통째로 버리는 것은 아니다. 부모가 하던 일을 그대로 쓰되 앞뒤로 살만 덧붙이고 싶을 때가 많다. 이럴 때는 자식 안에서 super()로 부모 버전을 먼저 부른 뒤, 그 결과를 가공한다. 기본 로거 Logger의 log() 앞에 시각을 붙이는 경우를 보자.


class Logger:
def log(self, msg):
return f"[LOG] {msg}"
class TimeLogger(Logger):
def log(self, msg):
base = super().log(msg)
return "09:00 " + base

print(TimeLogger().log("서버 시작"))


09:00 [LOG] 서버 시작


TimeLogger의 log()는 부모의 log를 지우지 않는다. super().log(msg)로 부모가 만든 "[LOG] 서버 시작"을 받아 두고, 그 앞에 시각만 붙여 돌려준다. 덕분에 [LOG] 형식을 TimeLogger가 다시 짜지 않는다. 부모가 대괄호 형식을 다른 모양으로 바꾸면 자식도 자동으로 그 변화를 따라간다. 부모 로직을 재사용하면서 필요한 만큼만 확장하는 것, 이것이 오버라이딩과 super()를 함께 쓰는 이유다.


super()는 생성자만이 아니라 어떤 메서드든 부모 버전을 불러올 수 있다. 완전히 새로 쓸지, 부모 것을 받아 확장할지는 자식이 상황에 따라 고른다. log 하나에도 두 선택지가 있는 셈이다.

06.3 바꾼 것만 바뀐다

오버라이딩의 또 다른 성질은 자식이 손대지 않은 메서드는 부모 것이 그대로 남는다는 점이다. 필요한 메서드만 골라 바꾸고 나머지는 물려받은 채로 둔다. 새 Bird는 난다. 펭귄은 걷도록 오버라이드하고, 참새는 아무것도 바꾸지 않아 부모의 나는 동작을 그대로 쓴다.


class Bird:
def move(self):
return "난다"
class Penguin(Bird):
def move(self):
return "걷는다"
class Sparrow(Bird):
pass

print("펭귄:", Penguin().move())
print("참새:", Sparrow().move())


펭귄: 걷는다
참새: 난다


Penguin은 move()를 다시 정의했으므로 "걷는다"가 나온다. Sparrow는 몸통이 pass뿐이라 move()를 건드리지 않았고, 그래서 부모 Bird의 "난다"를 그대로 물려받는다. 자식은 부모와 다르게 굴어야 하는 부분만 오버라이드하면 된다. 같아도 되는 부분까지 굳이 다시 쓰면 그것은 결국 같은 코드를 두 번 쓰는 중복이 되어 버린다. 바꿀 것만 바꾸고 나머지는 부모에 맡기는 절제가 좋은 상속 설계다.


여기서 부모 Bird는 새 세 마리의 공통 기준선 역할을 한다. 참새처럼 기준선 그대로 두어도 되는 자식은 아무것도 안 쓰고, 펭귄처럼 기준선을 벗어나는 자식만 move()를 갈아 끼운다. 만약 오버라이딩이 없어서 자식마다 fly, walk 같은 다른 이름의 메서드를 만들었다면, 여러 새를 한 리스트에 담아 똑같이 다루는 일이 불가능했을 것이다. 이름을 move로 통일하고 속만 바꾸기 때문에 참새와 펭귄을 구분 없이 나란히 세울 수 있다.

06.4 오버라이딩이 다형성으로 이어지는 이유

세 예제를 한 줄로 꿰면 이렇다. 부모가 정한 메서드 이름은 그대로 두고, 자식마다 그 속을 다르게 채운다. 그러면 부르는 쪽은 area()나 move()라는 같은 이름만 알면 되고, 실제로 어떤 계산이 일어나는지는 객체가 알아서 정한다.


Square와 Penguin을 다시 보면, sq.area()와 penguin.move()는 코드 모양이 똑같은 호출이지만 객체 종류에 따라 다른 결과를 낸다. 부르는 쪽에 if 문으로 종류를 따지는 분기가 없다. 이렇게 같은 이름의 호출이 객체마다 다르게 동작하는 성질이 다형성이며, 오버라이딩은 그 다형성을 상속 안에서 실현하는 문법이다. 이름은 계약처럼 고정하고 구현만 갈아 끼운다는 감각을 여기서 잡아 두면 된다.

06.5 정리

오버라이딩은 부모에게 물려받은 메서드를 자식이 같은 이름으로 다시 정의해 동작을 바꾸는 것이다. 자식 버전이 있으면 그것이 부모 버전을 가리고 먼저 실행된다. 부모 동작을 완전히 버릴 수도 있고, super()로 부모 결과를 받아 확장할 수도 있다. 자식이 손대지 않은 메서드는 부모 것이 그대로 남으므로, 달라야 하는 부분만 골라 바꾸는 것이 원칙이다. 이름은 고정하고 속만 바꾸는 이 방식이 곧 다형성으로 이어진다. 오늘 본 세 예제를 직접 쳐 보며 어떤 버전이 실행되는지 눈으로 따라가 보기 바란다.