Polymorphism
한국어로는 '다형성' 이라고 번역되나 abstraction 과 마찬가지로 영어 단어로 봐야 그 의미가 제대로 이해된다.
polymorphism 은 poly + morph + ism 의 합성어로, poly 는 '다수의' 라는 의미를 갖고 있고, morph 는 '모양' 을 가리킨다.
그래서 한국어로도 다형성이라고 번역을 한 것인데, 사실 코드에서 '모양' 이라고 하면 잘 와닿지 않는다.
소스코드에서 '모양, 형태' 라는 말을 더 와닿게 번역하면 '구현' 이 된다.
즉, polymorphism은 구현이 다양하게 있다는 뜻이다.
하지만 '구현이 많다' 라는 말로도 의미가 잘 와닿지 않는데, polymorphism 은 이 앞에 하나의 말이 더 붙어야 그 의미가 완전해진다.
one interface, multiple implementation
말 그대로 하나의 인터페이스에 여러가지 구현이 존재할 수 있다는 것이다.
그리고 이것이 다형성의 진짜 의미이다.
상속의 개념에서 보면 interface 는 superclass, implementation 은 subclass 로 볼 수도 있다.
인터페이스는 외부에 공개되는 부분이고, 구현은 감춰진다.
subclass가 superclass에 의해 가려지는, 일종의 클래스 단위의 캡슐화와 같은 것이다.
superclass 가 subclass 를 가리고 있기 때문에 superclass를 사용하는 객체 입장에서는 그 밑에 어떤 서브클래스가 응답하는지, 심지어 서브클래스가 존재하는지도 알 수 없다.
책에서는 '하나의 메세지에 대해 서로 다른 객체가 서로 다른 방법으로 응답할 수 있는 것' 이라고 설명하기도 한다.
지난 글에서 정리했듯 객체지향에서 message passing 은 객체가 갖고 있는 메서드의 호출과 같다.
message = method 인 것이다.
그래서 이 말을 다르게 말하면 '하나의 method 호출에 대해 서로 다른 객체가 서로 다른 방법으로 응답할 수 있는 것' 이라고 할 수 있다.
여기에서 '하나의 method' 가 인터페이스에서 정의한 메서드가 되고, 인터페이스에서 정의한 메서드를 상속받아 실제로 구현한 객체들 중 하나가 이 메서드 호출을 처리하여 응답할 수 있다.
이때 메세지를 전송하는 (=메서드를 호출하는) 객체는 이 메세지를 누가 받는지 알 필요가 없다.
그냥 메세지를 전송했고, 누군가 잘 받아서 처리한 뒤 받은 응답을 그대로 사용할 뿐이다.
마치 우리가 택배를 보낼 때 구체적으로 배송받으려는 물품이 어떤 허브와 집화창고를 거쳐서 왔는지 알 필요가 없는 것과 같다.
그냥 주문했고, 우리집으로 배송만 오면 된다.
polymorphism을 통해 늘 그렇듯 컴포넌트를 쉽게 재사용할 수 있고, 컴포넌트 단위로 모듈화되어 코드간 의존성이 감소하고 유지보수가 용이해진다.
polymorphism은 operation overiding과 dynamic binding으로 구현된다.
이제 구체적인 예시를 보면서 어떻게 polymorphism 을 적용할 수 있는지 알아보자.
Exmaple 1
어떤 운송 회사에서 각 운송수단 별 수익금을 관리하는 시스템을 만들고자 한다.
현재 운송 수단에는 택시가 있다.
먼저 택시 운전사들의 수익금을 관리하는 시스템을 만들기위해 TaxiDriver 클래스를 정의한다.
class TaxiDriver {
private:
char driverName[10];
int driverAge;
int baseSalary;
int bonusMoney;
public:
TaxiDriver(const char* driverName, int driverAge, int baseSalary, int bonusMoney);
char* getDriverName();
int getDriverAge();
int getSalary(); // taxi driver 의 salary 는 base salary + bonus money
void showDriverInfo() const;
};
택시 운전사는 이름과 나이를 기본적으로 가지며, 기본급과 추가급의 합으로 월급을 받는다.
다음으로 운수회사 클래스를 작성해보자.
#include <iostream>
#include "TaxiDriver.h"
using namespace std;
class TransportationCompany {
private:
TaxiDriver* driverList[50];
int numDrivers;
public:
TransportationCompany() : numDrivers(0) {};
void addDriver(TaxiDriver* driver) {
driverList[numDrivers++] = driver;
}
void showTotalSalary() const
{
int sum=0;
for(int i=0; i < numDrivers; i++)
sum += driverList[i]->getSalary();
cout << "total salary: " << sum << endl;
}
void showAllDriversInfo() const
{
for (int i = 0; i < numDrivers; i++)
driverList[i]->showDriverInfo();
}
~TransportationCompany() {
for(int i=0; i < numDrivers; i++)
delete driverList[i];
}
};
별도 모듈에 작성된 운수회사 클래스는 TaxiDriver 객체를 갖고 있고 (has-a 관계)
회사의 모든 직원의 월급 합을 계산할 때 각각의 taxi driver 객체가 가진 getSalary() 함수를 통해 가져온 월급으로 계산한다.
현재 운수회사에는 택시 운전사만 있고, 이렇게 만든 서비스는 문제없이 잘 돌아간다.
그런데 운수회사가 성장해서 이젠 버스까지 사업 영역을 넓혔다고 해보자.
이제 운수회사에는 버스 운전사들이 추가적으로 고용되었다.
class BusDriver
{
private:
char driverName[10]; // 버스운전사 이름
int driverAge; // 버스운전사 나이
int workingHours; // 버스 운전사 근무시간
int payPerHour; // 버스 운전사 시간 당 보수
public:
BusDriver(const char* driverName, int driverAge, int workingHours, int payPerHour);
char* getDriverName() const;
int getDriverAge() const;
int getSalary() const; // workingHours와 payPerHour의 곱을 반환함
void showDriverInfo() const; // driverName, driverAge, workingHours 및 payPerHour 출력
};
버스 운전사는 이름과 나이를 공통적으로 받는데, 택시드라이버와 다르게 근무 시간과 근무시간당 보수 정보를 갖는다.
그리고 월급을 계산할 때는 근무 시간과 시간당 보수의 곱을 월급으로 받는다.
즉, 택시 드라이버와 월급을 계산하는 로직이 다르다.
또한 내부에서 갖고있는 attribute 정보가 다르므로, showDriverInfo() 함수로 출력하는 정보 역시 달라진다.
근데 이렇게 버스 운전사 정보를 정의하고 보니 택시 운전사와 겹치는 attribute 와 operation 이 존재한다.
그리고 이를 자연스럽게 상위 클래스로 묶어보내면 더 좋겠다는 생각을 할 수 있다. (bottom-up 접근 방법)
그러면 이렇게 클래스 구조를 개선할 수 있다.
이름과 나이라는 공통 attribute 와, getDriverName, getDriverAge 라는 operation 은 상위클래스로 묶어보내고,
비록 이름은 같지만 구현이 다른 getSalary, showDriverInfo 는 서브 클래스에 그대로 놔둔다.
그리고 TransportationCompany에 추가된 BusDriver 객체를 추가하고,
추가된 BusDriver에 대해서도 함께 동작을 수행하도록 기존 로직도 수정해준다.
사실 여기에서부터 벌써 불편한 점이 생긴다.
새로운 종류의 Driver 가 추가된 것으로 TransportationCompany에 너무 많은 변경이 일어나기 때문이다.
변경이 많이 일어나는 이유는 TransportationCompany가 각 Driver 클래스의 세부적인 구현들을 모두 알고 있어야 하기 때문에 발생한다.
즉, 각 Driver 클래스의 세부 구현에 의존하여 TransportationCompany가 구현되어 있기 때문에 세부 구현이 바뀌거나 추가될 때마다 TransportationCompany도 변경이 일어난다.
이제 운수 회사에 새로운 직원이 들어올 때마다 직원을 추가하는 DriverManager 클래스가 있다고 해보자.
그리고 DriverManager 클래스에서 addDriver() 메서드를 위와 같이 구현했다고 해보자.
이 코드를 통해서도 Transporation Company가 세부구현에 의존한다는 것을 확인할 수 있다.
먼저 새로운 종류의 Driver 가 추가될 때마다 DriverManager 도 수정되어야 한다는 점에서 이 클래스도 세부 구현에 의존하고 있다.
뿐만 아니라 Transportation Company 에는 TaxiDriver, BusDriver 를 추가할 수 있다는 정보를 알고 있어야 이 코드를 작성하고 유지보수 할 수 있다.
만약 TransportationCompany 클래스를 개발하는 팀과 Driver 를 개발하는 팀이 서로 다른 팀이라고 해보자.
그러면 Driver 를 개발하는 팀에서 새로운 Driver 를 개발할 때마다 매번 TransportationCompany 팀에 해당 Driver 를 추가하는 기능을 만들어달라고 요구하고, 그 기능이 추가되고 나면, 그제서야 DriverManager 클래스에 해당 Driver를 실제로 추가하는 로직을 작성할 수 있다.
각 Driver, TransportationCompany, DriverManager 클래스가 얼마나 강하게 결합되어 있는지 느낄 수 있다.
각 클래스의 모듈화가 전혀 안된 것이다.
이 문제를 해결하기 위해 DriverManager 클래스도, TransportationCompany 클래스도 '세부 구현' 이 아니라 '인터페이스' 에 의존하도록, polymorphism 을 적용하여 수정해보자.
각각의 Driver가 변해도, TransportationCompany가 변해도, DriverManager 가 변해도 서로에게 영향을 주지 않고 독립적으로 개발할 수 있도록 모듈화하는 것이다.
모듈화를 할 때는 각 모듈이 서로 결합할 때 활용할 '인터페이스' 가 필요하다.
그리고 아직 완전하지는 않지만 우리는 Driver 라는 superclass라는 인터페이스를 활용할 수 있다.
TransportationCompany, DriverManager 모두 어떤 Driver 종류가 있는지는 모른채 그냥 Driver 라는 인터페이스에만 의존하여 구현하는 것이다.
그리고 각 세부적인 Driver의 로직은 Driver 라는 인터페이스에 맞춰 각자 구현하면 된다.
Polymorphism을 구현할 때는 2가지가 필요하다.
1. Operation Overiding (subclass 가 superclass의 operation을 재정의하는 것)
2. Dynamic Binding (런타임에서 실제 포인터가 가리키고 있는 객체의 operation을 결정해서 호출하는 것)
c++ 에서는 virtual 키워드를 사용하여 이를 구현할 수 있다.
이제 Driver 클래스를 인터페이스로 만들기 위해, 세부적으로 구현해야하는 껍데기 함수를 virtual 키워드를 사용하여 정의해준다.
(virtual 키워드를 사용하고 뒤에 = 0 까지 붙이면 순수 가상함수가 되어 Driver 클래스에서 구현하고 있지 않은 함수를 의미하게 된다.)
그리고 TaxiDriver, BusDriver 는 getSalary, showDriverInfo 함수를 오버라이딩하여 자체적으로 구현한다.
이때 동적바인딩과 관련하여 주의할 점은, 위 그림에서 서브클래스의 객체를 슈퍼클래스 타입 변수에 넣는 것은 가능하지만, 슈퍼 클래스의 객체를 서브클래스 타입의 변수에 넣는 것은 할 수 없다는 것이다.
IS-A 관계를 생각해보면 taxi driver is a driver 는 말이 되므로, 택시 드라이버를 드라이버에 넣는 것은 가능하다.
하지만 driver is a taxi driver 는 말이 안되므로 드라이버를 택시 드라이버 변수에 넣는 것은 할 수 없다.
이제 기존의 TransportationCompany 와 DriverManager 를 인터페이스를 사용하도록 수정해보자.
TransportationCompany는 더이상 TaxiDriver, BusDriver 를 갖지 않고, Driver 배열을 갖는다.
그리고 showAllDriverInfo() 와 showTotalSalary 를 구현할 때는 Driver 클래스에서 정의한 showDriverInfo(), getSalary() 메서드를 호출한다.
이 메서드의 동작은 동적 바인딩되어, 실제로 저장된 객체가 TaxiDriver 라면 TaxiDriver 의 구현으로, BusDriver 라면 BusDriver의 구현대로 동작할 것이다.
(만약 virtual 키워드를 사용하지 않았다면 정적 바인딩이 되어 Driver 클래스에 작성한 로직이 호출된다.)
또한 이 코드를 보면 '클래스에 대한 캡슐화' 도 이해할 수 있다.
Driver와 분리된 별도 모듈에서 별도의 팀이 개발하는 TransportationCompany 는 Driver 라는 인터페이스만 사용해서 구현하며,
구체적으로 어떤 종류의 Driver가 있고, 각 Driver는 어떤식으로 getSalary() 를 처리하는지 알 필요가 없다.
심지어 Driver 를 상속한 클래스가 존재하는지도 알 필요가 없다.
따라서 TaxiDriver, BusDriver가 Driver 클래스에 의해 캡슐화되어 코드간 의존성이 줄어드는 효과를 얻을 수 있으며,
polymorphism을 가리켜 high level 에 대한 캡슐화라고 표현하기도 한다.
DriverManager 클래스도 마찬가지로 Driver 인터페이스를 사용하여 내부 로직을 구현한다.
이제 드라이버를 추가할 때도 Driver 변수에 담아서 addDriver 메서드로 넘긴다.
새로운 종류의 Driver 가 추가되어도 new SomeDriver() 와 같은 코드는 추가되겠지만, 다른 부분은 전혀 바뀔 필요가 없게 된다.
그리고 이것은 인터페이스가 바뀌지 않기 때문에 가능한 일이다.
따라서 인터페이스는 변하지 않도록 일반화해서 잘 설계해야 한다.
그런 와중에 회사가 더 성장하여 새롭게 TruckDriver 라는 새 Driver 클래스가 추가되었다고 해보자.
그러면 이젠 이렇게 하나하나 다 정의할 필요가 없다.
TruckDriver 도 기존의 Driver 인터페이스를 맞춰서 구현하도록 하고, 필요한 자신만의 attribute 가 있다면 subclass 에 추가해주면 된다. (top-down 방식으로 상속 관계를 추가한다.)
그리고 이렇게만 추가하면 TransportationCompany 클래스는 아무런 변경 없이도 문제없이 TruckDriver 에 대한 기능까지 처리할 수 있게 된다.
또한 각 운전사들에 대한 노동 조합과 관련된 클래스를 만든다고 할 때도, Driver 라는 인터페이스를 활용해서 개발하면 각 Driver의 세부 구현을 몰라도 인터페이스만을 활용하여 로직을 작성할 수 있으므로 xxxDriver 와 LaborUnion 간의 완벽한 모듈화가 이루어졌음을 알 수 있다.
Example 2
나머지 예시는 가볍게 살펴보자.
그림판 프로그램을 만들려고 한다.
이때 캔버스에 사각형, 원, 타원을 그릴 수 있다고 해보자.
각 도형을 클래스로 정의하고 보니 draw() 라는 메서드가 공통적으로 들어있는 것을 볼 수 있다.
이를 기반으로 Shape 라는 인터페이스를 정의하고, 공통 메서드인 draw() 를 순수 가상함수로 만들어 인터페이스에 정의한 뒤, 각 subclass가 구현하도록 만든다.
외부 모듈에서 각 도형의 draw() 기능을 이용해야 하는 상황이 생기면, Shape 라는 인터페이스를 사용하여 해당 기능을 구현한다.
실제로 프로그램이 실행될 때는 shape 가 가리키는 실제 객체의 draw() 기능이 동적바인딩 되어 실행될 것이다.
Example 3
이제는 이 그림 한장만 봐도 polymorphism 이 적용된 것을 알 수 있다.
Employee 라는 인터페이스에 calcuatePay() 라는 가상함수를 정의하고, FullTimeEmployee, PartTimeEmployee, TempEmployee 에서는 각 기능을 상속받아 구현한다.
이 3가지 클래스의 구현은 모두 서로 다르겠지만 Employee 라는 인터페이스 정의에 맞춰서 개발하였기 때문에 외부 모듈에서는 Employee 인터페이스만 가져다가 calculatePay() 를 호출하면 세부 구현을 알지 못해도 급여를 계산할 수 있다.
따라서 ploymorphism 에서 말하는 one interface, multiple implementation 을 이 그림에서 그대로 볼 수 있다.
Example 4
지금까지는 C++ 기준으로 객체지향 코드를 살펴봤다면, 또 다른 대표적인 객체지향 언어인 Java 기준에서도 살펴보자.
먼저 자바와 C++ 을 비교해보면, C++ 에서는 서브 클래스의 함수 포인터를 저장하는 '가상함수' 라는 표현을 사용하고 있다면,
자바에서는 '추상 메서드' 라는 표현을 사용하고, 자바의 '추상 메서드' 는 구현을 가질 수 없기에 c++ 의 '순수 가상함수' 에 대응되는 개념이다.
C++ 에서는 abstract class 라는 말이 존재하는데, 1개 이상의 순수 가상함수를 포함한 클래스를 말한다.
자바에도 abstract class 가 있고, 마찬가지로 1개 이상의 '추상 메서드'를 가진 클래스를 말한다.
abstract class 는 기본적으로 '클래스' 이기 때문에 그 내부에 멤버 변수를 가질 수 있고, 다른 클래스에서 abstract class 의 기능을 가져와 구현할 때는 '상속' 받아서 구현해야 한다.
그런데 자바에는 interface 라는 개념이 별도로 존재한다.
자바의 interface 는 오직 '추상 메서드' 만을 가진 클래스를 말하는데, 기본적으로 class 가 아니기 때문에 멤버 변수를 가질 수 없고 메서드만 정의할 수 있다.
또한 다른 클래스에서 interface 내부 메서드를 구현할 때는 '상속' 대신 '구현'을 해야한다.
자바에서는 클래스를 상속할 때 extends 를 사용하고, 인터페이스를 구현할 때는 implements 를 사용한다.
(뒤에 s가 붙는 것에 주의)
이번엔 자바를 사용하여 자율주행 자동차를 개발하는 예시를 보자.
자동차 제조 연합회에서 산업 표준 API (인터페이스) 를 정의해두고, 각 자동차 제조회사는 그 인터페이스에 맞추어 자동차를 개발한다.
이때 각 자동차에서 보내는 자신의 위치 정보를 기반으로 자동차 이동 경로를 제어하는 소프트웨어를 개발하려고 할 때, 우리는 자동차들의 세부적인 구현을 알 필요 없이, 인터페이스 기능을 활용하여 자동차 이동 경로 제어 소프트웨어를 개발할 수 있다.
각 자동차에서 사용하는 인터페이스는 위와 같이 회전, 차선 변경, 오디오 채널을 켜고 끄는 기능과 같은 동작이 정의되어 있다.
java 를 사용하였기 때문에 interface 키워드를 사용한 것을 볼 수 있다.
그리고 각 제조사별 자동차는 이 인터페이스에 맞추어 실제 동작을 구현한다.
이처럼 인터페이스를 사용하여 개발하면 소프트웨어를 컴포넌트 모듈의 조합으로 개발할 수 있어 효율적이다.
따라서 소프트웨어를 설계할 때는 제일 먼저 인터페이스를 정의해야 한다.
인터페이스를 설계할 때는 '최소화' 와 '무변경' 원칙을 신경써서 설계해야 한다.
최소한의 기능을 public 으로 열어두어야 하고, 한번 설계하여 적용한 인터페이스는 바뀌지 않도록 최대한 일반화하여 설계해야 한다.
마지막으로 객체지향의 장점을 정리하면서 객체지향 정리를 마무리하려고 한다.
객체지향은 소프트웨어 개발 비용을 줄여준다. 기존 컴포넌트를 재사용할 수 있기 때문이다.
또한 상속과 polymorphism 은 모듈화를 할 수 있도록 도와주어 소프트웨어 퀄리티를 높여준다.
서브 시스템들 간 결합도가 줄어들고 새로운 기능을 확장하고 유지보수하기에 용이해진다.
최종적으로 객체지향은 코드 분석, 설계, 구현 사이의 전환을 용이하게 해준다.
기존의 설계를 보고 코드를 분석한다거나, 구현을 보고 설계를 수정하는 등 이 사이사이 전환이 더 쉬워진다.
'CS > 소프트웨어공학' 카테고리의 다른 글
[소프트웨어공학] 9. Modeling Concept (1) | 2025.04.19 |
---|---|
[소프트웨어공학] 7. 객체지향 (1) - abstraction, 캡슐화, 상속 (0) | 2025.04.17 |
[소프트웨어공학] 6. Agile Software Development (0) | 2025.04.17 |
[소프트웨어공학] 5. RUP (Rational Unified Process) (1) | 2025.04.17 |
[소프트웨어공학] 4. Process Activity (1) | 2025.04.16 |