저번 시간 하위 클래스 샌드박스의 정리를 위해 해당 내용을 책에서 읽던 도중, ”요즘은 여러 프로그래머 집단에서 ‘상속’을 나쁘게 생각한다.”는 내용이 있었다.
왜 상속을 안좋게 생각하는 거지?
상속으로 코드의 재사용성을 늘릴 수 있는 것 아닌가? 라고 생각했었다.
그러나 우리가 반사적으로 생각하는 상속 구조의 클래스들 역시 유지보수 면에서 문제가 발생하기 때문에 어느정도 특성을 공유하는 클래스들을 구현할 때 개발자들은 상속을 통해 만드는 것이 아니라 지금 소개하는 타입 객체 패턴을 통해 만들게 된다.
지금부터 일반적인 상속 구조와 타입 객체 패턴 간의 차이점을 위주로 타입 객체 패턴에 대해 알아보도록 하자.
일반적인 상속 구조..?
앞서 말한 일반적인 상속 구조의 구현부터 이야기를 해보자.
Monster 클래스를 부모로 두고 여러 종류의 몬스터 클래스를 구현한다고 하면 다음과 같이 구현할 수 있다.
public class Monster
{
private int _hp;
public virtual void Attack()
{
Debug.Log("몬스터의 공격!");
}
protected Monster(int startingHealth)
{
_hp = startingHealth;
}
}
public class Dragon : Monster
{
public Dragon() : base(230) { }
public virtual void Attack()
{
Debug.Log("드래곤의 공격!");
}
}
public class Troll : Monster
{
public Troll() : base(130) { }
// 대충 이런 식으로....?
}
이 때 Attack() 함수를 오버라이드해서 다양한 방식으로 몬스터의 공격을 구현하게 된다.
이제 다음과 같은 상황에 어떻게 되는지 보자.
상황 1. 작성해야 하는 몬스터들의 종류가 수백가지라면?
반복해서 하위 클래스를 작성하고 컴파일해야한다.
상황 2. 특정 몬스터의 체력과 같은 스탯을 수정해야한다면?
스크립트에 들어가 수치를 변경한 뒤 다시 컴파일해줘야 한다!
코드 관리 편하게 하자고 객체지향을 추구하는 것 아니었나?
오히려 수정하기가 더 복잡하다!
이러한 문제들은 우리가 개발을 하면서 반사적으로 몬스터의 종족 개념을 클래스의 상속 관계로 구현하려 했기 때문에 생긴 문제이다.
타입 객체 패턴의 기본적인 형태
앞서 언급한 몬스터의 종족 개념을 상속 관계가 아니라 종족 클래스로 구현하는 것은 어떤가?
다음과 같이 말이다.
using System;
public class Breed
{
public int Health { get; private set; }
public Action AttackAction;
Breed(int health, Action attack)
{
Health = health;
AttackAction = attack;
}
}
public class Monster
{
private int _health;
private Breed _breed;
public Monster(Breed breed)
{
_health = breed.Health;
_breed = breed;
}
public void Attack() => _breed?.AttackAction();
}
위 코드의 구성은 이렇게 된다.
Breed
라는 타입 객체 (Type Object) 클래스 와
Monster
라는 타입 사용 객체 (Typed Object) 클래스를 정의한다.
이 때 타입 사용 객체에서 타입 객체를 인스턴스로 받아오기 때문에 다른 타입 객체 인스턴스들은 서로 다른 타입 (여기서는 종족) 이 되는 것이다.
이러면 어떤 짓이 가능하느냐?
타입 간에 같은 기능을 가지는 부분은 타입 사용 객체 (Monster) 에 작성하고 타입끼리 서로 다른 데이터와 기능은 타입 객체 (Breed) 에 작성하면 되는 것이다!
즉, 상속 처리를 하드코딩하지 않고도 상속 받는 느낌의 기능을 구현할 수 있는 것이다.
주의사항
언어에서 기본적으로 지원하는 상속 기능을 피하고 우리만의 패턴으로 구현한 것이기 때문에 당연히 신경써야 하는 부분들이 있다.
- 타입 객체를 직접 관리해야 한다.
타입 객체를 필요로 하는 인스턴스가 존재하면 메모리에 타입 객체가 항상 존재해야 하고, 해당 타입 객체를 우리가 알맞게 지정해줘야 한다.
- 타입 별로 동작을 표현하기 더 어렵다.
오버라이드해서 클래스 별 동작을 구현하는 상속을 사용하지 않으니, 타입 별 동작을 구현하기가 좀 어렵다.
위의 코드에서도 당장 Delegate(Action)를 이용하여 구현했지 않은가.
이러면 해당 동작을 구현하기 위한 Action 메서드 역시 어딘가에 미리 구현해놓아야 한다.
팩토리 메서드 패턴과 섞어보기
위에서 구현한 타입 객체 패턴은 메모리 할당이 먼저 이루어지고 그 메모리에 객체를 할당하는 방식이기 때문에 초기화되지 않은 객체가 메모리에 올라가게 되는 것이다.
이는 객체 생성 과정을 제어할 수가 없는 것이다.
클래스의 생성자가 호출될 때 타입 객체가 알아서 새로운 인스턴스를 생성하게 만들 수는 없는걸까?
Breed에 다음과 같은 코드를 추가하자.
public class Breed
{
public Monster newMonster()
{
return new Monster(this);
}
}
위의 newMonster
가 팩토리 메서드 패턴의 생성자이다.
팩토리 메서드 패턴에 대해서는 다음에 자세히 소개할 수 있었으면 좋겠다.
이러면 이전 코드에서 몬스터를 다음과 같이 생성했던 것을
// 이전 방식
Monster monster = new Monster(someBreed);
다음과 같이 바꿀 수 있게 된다.
// 수정된 방식
Monster monster = someBreed.newMonster();
이제 Breed
가 Monster
의 초기화 제어권을 가져오기 때문에
Monster
가 생성되기 전에 객체가 생성될 메모리 위치를 정의한다던지 등의 작업이 가능해진다.
따라서 타입 객체에서 메모리 할당을 제어하고 싶은 경우 다음과 같이 팩토리 메서드 패턴과 결합하고, 그렇지 않고 외부 코드에서 메모리 할당을 제어하고 싶은 경우 기존처럼 객체를 생성한 뒤 타입 객체를 넘겨주도록 하자.
타입 객체의 상속 구현하기
타입 객체 사이에도 일종의 계층 (Hierarchy) 을 가지도록 만들 수가 있다.
위의 예시대로라면 종족 타입이 상위 종족과 하위 종족으로 나뉠 수 있다는 소리이다.
다음과 같이 Breed
가 parent
속성을 가지도록 정의해주자.
public class Breed
{
private Breed _parent;
public int Health { get; private set; }
public Action AttackAction;
Breed(Breed parent, int health, Action attack)
{
_parent = parent;
Health = health;
AttackAction = attack;
}
public Monster newMonster()
{
return new Monster(this);
}
}
상속받는 종족 객체가 parent
로 들어가게 되는 것이다.
최상위 종족은 parent
를 null
로 해주면 되는 것이다.
이제 parent가 null이 아닌, 즉 상속을 받는 타입 객체들은 어떤 속성을 상속받을 지, 또는 상속 받지 않을지 (오버라이드할지) 를 직접 결정해주면 된다.
다음은 상속받은 타입 객체의 health가 0이면 상속을 받고, 아니면 오버라이드를 하도록 작성한 코드이다.
public int Health
{
get
{
// 오버라이딩
if (_health != 0 || _parent == null)
return _health;
// 상속
return _parent._health;
}
}
뭔가 앞에서 배웠던 프로토타입 패턴이 생각나지 않는가?
이처럼 상속을 지원하도록 만든다면 중복 작업을 줄일 수 있다는 장점이 있지만, 속성값을 얻어오기 위해 부모 타입을 찾아가야 한다는 점에서 오버헤드가 발생한다.
심지어 다중 타입 상속까지 지원하도록 만들면?
대부분의 데이터 중복을 없앨 수 있는 대신 코드가 매우 복잡해진다는 단점이 따라온다.
어떻게 사용할 것인가?
타입 객체 패턴은 어찌보면 만능으로 보이지만서도 활용하기가 매우 어렵다.
조금이라도 더 복잡해지면 이해하기가 힘들어지고, 사용성이 떨어지기 때문이다.
다음은 타입 객체를 다루기 위해 고려할 사항들을 정리한 것이다.
고려 사항 1. 타입 객체를 숨길 것인가? 노출할 것인가?
다음처럼 타입 사용 객체에서 타입 객체를 반환해줄 수 있다.
public class Monster
{
private Breed _breed;
public Breed GetBreed { get; }
}
타입 객체를 노출하면 다음과 같은 장점이 생긴다.
- 타입 사용 클래스 인스턴스를 통하지 않아도 외부에서 타입 객체에 접근이 가능하다.
- 타입 객체가 공개 API의 일부가 된다.
- 타입 객체 메서드를 전부 포워딩하지 않아도 좋다 (외부 공개 메서드를 따로 작업하지 않아도 된다) .
노출을 하지 않고 숨겼을 때, 즉 캡슐화를 했을 때는 다음과 같은 장점을 가진다.
- 타입 객체 패턴의 복잡성이 다른 코드에 드러나지 않는다.
- 타입 객체로부터 동작을 선택적으로 오버라이드 할 수 있다.
고려 사항 2. 타입을 바꿀 수 있는가?
바꿀 수 없게 만든다면 코드를 구현하고 이해하기가 더 쉬워진다.
이는 즉 디버깅하기가 쉬워진다는 의미이기도 하다.
타입을 바꿀 수 있게 만든다면 코드가 복잡해지는 대신 (타입 사용 객체와 타입 객체는 강하게 커플링되기 때문에 주의가 필요하다!) 객체 생성 횟수가 줄어든다.
몬스터가 죽으면 좀비 몬스터로 태어나는 기능을 구현한다고 했을 때, 타입 객체의 참조만 바꿔주는 방식으로 구현이 가능하기 때문에 메모리 낭비를 줄일 수 있다.
다른 패턴과의 차이점
앞서 언급한 프로토타입 패턴이 생각나는 이유는 이런 타입 객체 패턴은 프로토타입 패턴과 같은 문제에 대해 다른 식으로 접근하는 패턴이기 때문이다.
두 패턴 모두 여러 객체끼리 데이터와 동작을 공유하기 위한 패턴이기 때문이다.
인스턴스가 여러 데이터를 공유하고, 메모리를 절약하도록 하기 위한 경량 패턴과도 비슷한 점이 매우 많다.
상태 패턴과도 비슷한 점이 많다. 다른 객체에 자신을 정의하는 부분의 일부를 위임하기 때문이다.
타입 객체 패턴에서 타입 객체를 교체할 수 있다면, 타입 객체가 상태 패턴의 기능까지 같이 한다고 볼 수 있겠다.
Uploaded by N2T
'개발 > 게임 디자인 패턴' 카테고리의 다른 글
게임 디자인 패턴 11. 이벤트 큐 (Event Queue) 패턴 (1) | 2023.11.06 |
---|---|
게임 디자인 패턴 10. 컴포넌트 패턴 (Component Pattern) (0) | 2023.11.03 |
게임 디자인 패턴 8. 하위 클래스 샌드박스 (Subclass Sandbox Pattern) (1) | 2023.11.01 |
게임 디자인 패턴 7. 이중 버퍼 패턴 (Double Buffer Pattern) (0) | 2023.10.31 |
게임 디자인 패턴 6. 상태 패턴 (State Pattern) (1) | 2023.10.30 |