개요
객체 지향에서 가장 중요한 것은 상속인 것 같습니다.(개인적인 생각) 처음 객체 지향 언어를 접했을 때 느꼈던 조립과 같은 언어 작성이 꽤 인상 깊었기 때문이죠. 하나의 베이직한 클래스를 작성하고 나면, 그 클래스를 바탕으로 사용하던 기능들은 그대로 사용하며, 새로운 기능들을 쉽게 추가할 수 있고, 이름을 따로 설정할 수 있게 되는 매력적인 기능인 것 같습니다.
상속이란 기능은 정말 편리한 기능을 제공하지만, 그 기능만 보고 사용한다면 실수를 초래할 수 있다고 합니다. 이번에는 기능에 초점을 맞춘 것이 아닌, 상속의 의미에 초점을 맞춰 상속의 사용 방법에 대해 알아보려 합니다.
"대장장이 게임의 무기 강화 시스템을 만들려고 한다. 플레이어는 무기고 에서 무기를 꺼내어 강화, 또는 속성 부여를 하고 다시 무기고에 넣으려 한다. 어떻게 만들어야 할까?"
상속의 함정
게임에서 무기를 강화할 때, 무기의 순수한 능력치를 올려 주는 것을 "강화"라고 하고, 무기에 특별한 속성을 부여해 주는 기능을 보통 "인챈트"라고 합니다. 하지만 이 둘은 일단 무기가 있어야 가능한 작업이죠. 따라서 우리는 보관함에서 무기를 가져오는 기능을 먼저 만들어야 합니다.
public class WeaponStorage extends ArrayList <String> {
private final int maxSize=10;
private int nowSize=0;
public String getWeapon() {
if (nowSize>0){
nowSize-=1;
String weapon=super.get(0);
super.remove(0);
return weapon;
}
else{
throw new ArrayIndexOutOfBoundsException();
}
}
public void putWeapon(String weapon){
if (maxSize<=nowSize){
throw new ArrayIndexOutOfBoundsException();
}
super.add(weapon);
nowSize+=1;
}
}
무기를 여러 개 보관해야 하기 때문에 ArrayList를 상속하면 쉽게 구현할 수 있을 것 같아 상속했습니다.(문제 1) WeaponStorage 클래스는 10이라는 크기를 가지고 있어, 그 이상은 무기를 집어 넣을 수 없고, 무기고가 비어 있다면 무기를 꺼낼 수 없게 구현하였습니다.
자 이제 인챈트와 강화 클래스를 작성해야 합니다. 인챈트와 강화는 무기가 없으면 안되기 때문에 반드시 무기를 꺼내어 오는 로직을 필요로 합니다.
따라서 단순히 다시 WeaponStorage를 상속 받았습니다. (문제 2)
public class UpgradeWeapon extends WeaponStorage{
public void upgrade(){
String weapon=super.getWeapon();
//Upgrade logic...
//upgrade complete
super.putWeapon(weapon);
}
}
인챈트 클래스 또한 위와 같이 작성했다고 가정합니다. 그렇게 되면 각 클래스의 관계는 이렇게 됩니다.
이렇게 하면 많은 코드를 사용하지 않고 빨리 구현한 것 같습니다.
하지만 기획을 바꾸는 일이 생겨났습니다. 한 유저가 인챈트와 강화를 동시에 할 수 있게 기능을 추가해 달라는 요구가 들어왔습니다. 따라서 강화, 인챈트, 강화와 인챈트 이렇게 세 가지의 선택지가 생겼습니다.
단순히 생각하면 두 클래스를 상속 받으면 될 것 같습니다.(자바에서는 다중 상속을 지원하지 않으나, 코틀린이나 파이썬, c++을 생각해 봅시다.)
그러나 새로운 강화 시스템이 추가 되었을 때, 또 필요한 만큼 상속을 받아야 할까요?
public class Main {
public static void main(String[] args){
WeaponStorage storage=new WeaponStorage();
storage.add("New Weapon"); //사용 가능하다.
}
}
아시다시피, WeaponStorage는 ArrayList를 상속 받았으며, ArrayList의 add 메소드는 public 입니다. 즉 어디 서든 사용할 수 있다는 단점이 있습니다. 이렇게 되면 위의 maxSize에 벗어나는 개수 임에도 Storage에 저장할 수 있게 됩니다. 치명적인 버그를 초래하겠죠.
또한 put과 add라는 명칭이 혼동될 수도 있습니다. 둘 다 모두 "추가하다"와 "넣다"라는 의미를 가지기 때문에 실수하기 쉽습니다.
문제2
두 번째 문제는 클래스의 양이 매우 많아진다는 것 입니다. 재 사용 만을 고려하여 클래스를 상속 받다 보면 클래스의 양이 많아져 혼동이 오거나, 거미줄처럼 얽힌 구조를 가지게 될 지도 모릅니다. 따라서 문제가 생겼을 때 문제의 원인을 찾기 쉽지 않습니다.
또한 상위 클래스의 로직을 바꾸었을 때, 하위 클래스에게 어느 정도 여파를 끼칠지 가늠할 수 없고, 얼마나 많은 클래스가 문제가 생길지 모른다는 단점도 있습니다. 클래스를 상속받으면, 부모클래스에 의존하는 형태를 띄기 때문에 자식클래스도 쉽게 영향을 받기 때문입니다.
이러한 문제점들을 어떻게 해결할 수 있을까요?
조립
재 사용이라는 관점에서 보면, 굳이 상속을 하지 않아도 됩니다.
클래스로 만든 이상, 인스턴스를 생성하여 기능을 가질 수 있기 때문이죠.
따라서 기능을 재사용 할 때에는 상속 보다는 자신의 필드로 인스턴스를 관리한 다면 더욱 쉽게 해결할 수 있습니다. 동등한 관계로 말이죠.
public class WeaponStorage {
private final int maxSize=10;
private int nowSize=0;
private final ArrayList<String> storage=new ArrayList<>(maxSize); //상속받지 않고 자신의 필드로 조립
public String getWeapon() {
if (nowSize>0){
nowSize-=1;
String weapon=storage.get(0);
storage.remove(0);
return weapon;
}
else{
throw new ArrayIndexOutOfBoundsException();
}
}
public void putWeapon(String weapon){
if (maxSize<=nowSize){
throw new ArrayIndexOutOfBoundsException();
}
storage.add(weapon);
nowSize+=1;
}
}
위와 같이 상속을 받지 않고 자신의 필드로 조립한다면, WeaponStorage를 사용하는 측에서는 더 이상 ArrayList의 add 메소드를 사용할 수 없게 됩니다. ArrayList의 필드가 private로 가려져 있기 때문이죠. 이제 ArrayList에 접근하려면 반드시 WeaponStorage 클래스의 putWeapon, getWeapon을 통해서 Storage를 사용해야 합니다.
오용의 문제1이 해결됩니다.
문제 2도 해결해 봅시다.
public class UpgradeWeapon{
public String upgrade(String weapon){
//Upgrade logic...
weapon+="+";
//upgrade complete
return weapon;
}
}
public class EnchantWeapon {
public String enchant(String weapon){
//enchant logic...
weapon+="fire attribute";
//enchant complete
return weapon;
}
}
먼저 UpgradeWeapon의 upgrade메소드와 EnchantWeapon의 enchant메소드를 바꾸었습니다. 이 클래스를 조립하고 필드로 가질 것을 염두해 두고 말이죠.
따라서 이 두 메소드는 먼저 매개변수로 무기를 받고 난 후, 강화를 하여 무기를 되돌려 줍니다. 자 이제 필드로 이 클래스를 조립해 봅시다.
public class WeaponStorage {
private final int maxSize=10;
private int nowSize=0;
private final ArrayList<String> storage=new ArrayList<>(maxSize); //상속받지 않고 자신의 필드로 조립
private UpgradeWeapon upgradeWeapon=new UpgradeWeapon();
private EnchantWeapon enchantWeapon=new EnchantWeapon();
public String getWeapon() {
if (nowSize>0){
nowSize-=1;
String weapon=storage.get(0);
storage.remove(0);
return weapon;
}
else{
throw new ArrayIndexOutOfBoundsException();
}
}
public void putWeapon(String weapon){
if (maxSize<=nowSize){
throw new ArrayIndexOutOfBoundsException();
}
storage.add(weapon);
nowSize+=1;
}
public void upgrade(){
//무기를 꺼네어 강화한 후 집어넣기
String weapon=getWeapon();
weapon=upgradeWeapon.upgrade(weapon);
putWeapon(weapon);
}
public void enchant(){
//무기를 꺼네어 인챈트 한 후 집어넣기
String weapon=getWeapon();
weapon=enchantWeapon.enchant(weapon);
putWeapon(weapon);
}
}
WeaponStorage에 Enchant와 Upgrade의 기능을 조립하였습니다. 따라서 Storage는 강화와 인챈트 기능을 갖게 되었죠.
밑의 추가된 메소드 enchant와 upgrade는 무기를 꺼내고 넣는 일만 도와줄 뿐, 대부분의 기능은 필드로 조립한 EnchantWeapon, UpgradeWeapon의 메소드로 기능을 대신 수행합니다.
이러한 것을 "위임"이라고 합니다.
이제 두 기능을 동시에 수행하는 것도 어렵지 않습니다. WeaponStorage의 인스턴스를 생성해서 사용하는 곳은 두 개의 메소드를 차례로 실행하면 되기 때문이죠.
public class Main {
public static void main(String[] args){
WeaponStorage storage=new WeaponStorage();
//강화와 인챈트 모두 사용.
storage.upgrade();
storage.enchant();
}
}
지금의 클래스들의 의미는 이렇습니다.
"무기고는 10의 용량을 가지고 있으며, 자신 스스로 인챈트와 강화를 수행할 수 있다."
조금 어색합니다. 문제는 해결되었지만 무기고 안에 인챈트와 강화의 기능이 있다고 생각하기는 쉽지 않죠. 또한 무기고 스스로 행동을 한다는 것도 부자연스럽습니다.
다른 개발자들의 혼동이 예상됩니다. 따라서 조금 더 다듬어 보겠습니다.
public class WeaponStorage {
private final int maxSize=10;
private int nowSize=0;
private final ArrayList<String> storage=new ArrayList<>(maxSize); //상속받지 않고 자신의 필드로 조립
public String getWeapon() {
if (nowSize>0){
nowSize-=1;
String weapon=storage.get(0);
storage.remove(0);
return weapon;
}
else{
throw new ArrayIndexOutOfBoundsException();
}
}
public void putWeapon(String weapon){
if (maxSize<=nowSize){
throw new ArrayIndexOutOfBoundsException();
}
storage.add(weapon);
nowSize+=1;
}
}
public class BlackSmith {
private UpgradeWeapon upgradeWeapon=new UpgradeWeapon();
private EnchantWeapon enchantWeapon=new EnchantWeapon();
private WeaponStorage weaponStorage=new WeaponStorage();
public void upgrade(){
//무기를 꺼네어 강화한 후 집어넣기
String weapon=weaponStorage.getWeapon();
weapon=upgradeWeapon.upgrade(weapon);
weaponStorage.putWeapon(weapon);
}
public void enchant(){
//무기를 꺼네어 인챈트 한 후 집어넣기
String weapon=weaponStorage.getWeapon();
weapon=enchantWeapon.enchant(weapon);
weaponStorage.putWeapon(weapon);
}
}
먼저 WeaponStorage 클래스를 보겠습니다. enchant와 upgrade기능이 사라졌습니다.
이제 WeaponStorage는 자신의 일인 무기 넣기와 무기 빼기의 기능에 집중할 수 있게 되었습니다.
또한 대장장이 BlackSmith클래스를 따로 만들어, 대장장이가 강화와 인챈트 기능을 수행하게 되었습니다. 따라서 위의 클래스의 의미는 이렇게 됩니다.
"대장장이는 자신의 무기고를 가지고 있고, 인챈트와 강화 기술을 가지고 있다."
훨씬 자연스러워 졌습니다. 이제 대장장이 클래스를 통하여 다른 개발자분들도 혼동을 하지 않고 기능을 수행할 것 입니다.
public class Main {
public static void main(String[] args){
//어이 대장장이! 일로 와서 너의 무기고에 있는 무기를 강화랑 인챈트좀 해줘!
BlackSmith blackSmith=new BlackSmith();
blackSmith.upgrade();
blackSmith.enchant();
}
}
그래서 상속은 언제...?
그럼 상속은 언제 사용해야 할까요?
앞서 말한 상속의 기능이 아닌 의미에 중점을 두어야 이를 알 수 있습니다.
상속은 확장의 의미를 갖고 있습니다. 즉 "자식A는 부모B이다"가 성립해야 합니다.
이것을 IS-A 관계라고 합니다.
위의 예시와 비교를 해 보면 기능적인 측면에서 강화는 무기를 필수로 하지만, 그렇다고 "강화는 무기를 꺼내 오기 이다."의 의미는 성립하지 않습니다. 따라서 조립을 해야 하는 대상이죠.
상속은 하나의 클래스를 확장해야 하는 경우에 성립됩니다.
위의 그림은 Android의 View class 계층 구조 입니다. View란 핸드폰 화면에 보여지는 UI의 기본 단위입니다. 따라서 ListView, AdapterView는 모두 View의 확장이며 "ListView는 View이다."가 성립합니다.(IS-A 관계) 따라서 이 때에는 상속을 사용하여도 문제가 발생하지 않습니다.
이번에는 상속에 관하여 다루어 보았습니다.
틀린 부분이 있다면 적극 반영하겠습니다. 감사합니다!
(이 포스팅은 책 "객체 지향과 디자인 패턴"을 읽고 재구성한 글 입니다.)
댓글
댓글 쓰기