상속과 컴포지션. 무엇을 선택할지 어떻게 판단해야 하는가?
OO에서 재사용성을 중요시하는 이유
Don't reinvent the wheel!
- 바퀴는 이미 동작과 상태가 명확한 물체
- 이걸 가지고 다른 유용한 물체를 만들자.
클래스를 재사용하면 좋은 점
설계와 코딩에 드는 시간을 절약
- OO외의 프로그래밍에도 적용되는 올바른 원칙
- 하지만 실전에서 100% 적용 불가
- 프로그램이 미래에 어떻게 변할지 완전히 예측 불가
- 재사용성에 눈이 멀어 잘못된 바퀴를 장착 시킬 수 있음
- 원시시대에 사용했던 돌덩이 바퀴를 자동차에 사용
- 지금 굳이 사용안하는데 재사용 생각하고 가독성 떨어트리는 코드를 생산할 수도 있음
- 즉, 주관적임, 상식의 문제임
테스트에 걸리는 시간을 절약
- 이미 테스트까지 끝낸 클래스를 다시 테스트할 필요가 없음
- 상속시 부모 클래스는 이미 테스트가 끝난 상황
- 부모 클래스는 테스트 할 필요가 없다고 주장도 하는 편
- 하지만 실제로는 그렇지 않은 경우가 빈번
- 새로운 방법으로 부모 클래스를 사용할 수도 있음
- 부모 클래스를 변경할 수도 있음
관리 비용을 절약
- 코드 중복이 없음
- 한 곳만 고치고 다른 곳을 실수로 안 고칠 가능성이 없음
- 관련된 코드가 모두 한 파일에 있음
- 해당 파일을 열면 모든 로직 및 데이터 파악 가능
- 다만, 재사용성을 위해 클래스를 잘게 나누다보면 파일 수가 많아짐
OO 모델링은 많은 연습이 필요
- Best practice로 커버 불가
- 주관적이기 때문
- 너무나 다양한 프로그램이 있기 때문
- 즉, 다양한 문제 타입에 따라 맞는 해결책은 다름
- 지름길은 없다.
- 남들보다 많이 해보고, 많이 고민해보면 빠르게 늘 수 있다.
- 도움
- 다른 사람의 코드 사용
- 다만, 보고 안다고 착각하지는 마라.
- 괜찮은 프레임워크
- 코드 리뷰
상속 vs 컴포지션
- 둘 다 재사용성이 목적
- 둘 중 하나를 고른 원칙이 필요
성능적 측면
- 메모리 문제: 상속을 사용하는 것이 보다 좋다.
- 개체를 생성하면, 메모리에 하나의 덩어리로 들어가게 됨
- 그렇기 때문에 어떤 방식을 사용하느냐에 따라 메모리에 들어가는 방식이 변하게 된다.
Rectangle
의 경우 가로 세로를 표현하고, Box
는 깊이까지 표현하는 코드를 짠다고 생각해보자.
Box box = new Box(10, 20, 30);
Rectangle rectangle = new Rectangle(10, 20);
Box box = new Box(rectangle, 30);
- 상속같은 경우 새로운 개체를 만들면(
new
), 내부에서 다른 개체를 만들지 않고 한 덩어리로 메모리 할당을 한다.
- 그와 반대로 컴포지션은 메모리가 여러 덩어리로 생성이 될 수 있다.
- 다른 개체를 생성해서 넘겨주어야 하기 때문이다. 참조한 주소만을 가지고 있기 때문에, 결국은 다른 곳에 메모리 덩어리가 생성된다.
- 즉, 여기서 알 수 있는 점은 상속과 컴포지션에서 메모리 상에 차이가 발생한다는 것이다.
- 그리고 이는 실행 성능에 영향을 미친다.
- 그렇기에 성능을 주요시 하는 작업의 경우에는 고려해야하는 대상으로 변모한다.
첫번째 병목점 (캐시 최적화)
CPU와 메모리 사이의 데이터 전송
- 메모리 -> 버스(CPU쪽으로 데이터를 이동해주는 녀석) -> CPU
- 여기서 CPU 처리 속도, 데이터 이동 속도가 다르게 되어 병목이 생긴다.
- 이런 문제를 해결하고자 캐시 메모리가 있는 것.
- 맨날 계산하고 필요할 때마다 메모리한테 요청, 버스타고 이동, 연산 이렇게 하다보면 너무 오래걸리니,
- 어차피 4kb 계산위해 읽어올 거면 한번에 읽어오고, 캐시쪽에 저장해두는게 더 빠르다는 것
- 이런 걸 CPU 최적화라고 한다. 요즘은 캐시 메모리가 계속 증가하는 추세. CPU 성능에 영향을 많이 준다는 반증이겠지?
- 이러한 점에서 상속 모델로 만든 개체는 한번에 캐시 메모리에 들어갈 가능성이 높다.
- 컴포지션 모델로 만든 개체의 경우 개체 내 부품 수만큼 캐시 메모리로 로딩할 가능성이 높다.
두번째 병목점 (메모리 할당/해제)
- 메모리 할당(
new
)과 해제(delete
, release
)
- 프로그래밍 언어에 따라 둘 중 특히 느린 것이 "반드시" 있다. (이부분 확인 필요)
- 상속은 메모리 할당과 해체가 딱 한번씩 생긴다.
- 컴포지션은 기본적으로 바깥쪽 만들때 한번, 부품 수만큼씩 추가로 필요하다.
다형성
- 다형성을 사용하여 모든 하위 개체에 동시에 명령을 내리고 싶은 경우: 상속 사용
유지보수
- 관리의 효율성을 위해 특정 방식으로 가는 경우가 있다.
상속이 나은 경우
- 컴포지션으로 가게 되었을 때, 오히려 코드가 많이 발생하는 경우
- 결국 조립해서 사용하는 개체에서 부품의 메서드를 호출해서 값을 가져와야 한다면, 필요없는 signature를 만들어서 처리해야 한다.
- 릴레이 함수 (하위에서 값만 가져와서 넘겨주는)가 생기게 된다.
컴포지션이 나은 경우
- 깊은 상속관계
- 중간의 클래스를 바꾸면 아래 클래스도 모두 바뀐다.
- 자식 클래스에서 문제 없는지 모두 확인해야 한다.
- 컴포지션도 비슷한 문제가 있긴 하나, 조립의 측면이 더 강하기 때문에 덜하다.
그외 일반적인 상황
- 상식적으로 생각할 것
- 이 프로그램을 작성하는 이해관계자들 간의 공통된 합의를 생각하자.
- has-a: 컴포지션 - 개념이 분리되는 것이 맞다면
- is-a: 상속 - 개념이 포함되는 것이 맞다면
Entity Component System(ECS)
- 컴포지션을 선호하는 또 다른 예
- 코드 변경 없이 자유롭게 개체를 만들 수 있도록 하는 것이 목적
- 아키텍쳐 패턴 중 하나다.
- 게임 업게에서 많이 사용한다. (Unity3D)
- 게임에서 개체 설계를 하려면 깊은 상속을 사용해야 하는 경우가 많다.
- GameObject <- PhysicsObject <- Character <- Player...
- 그런데 그러다 보니 기획에서 변경이 들어왔을 때, 이를 바꾸는게 상당히 귀찮은 작업이 될 수 있다.
- 개발자: 왜 기획에서 초창기에 확인을 안하지?
- 기획자: 안해보고 어떻게 판단?
- 업무 효율성이 매우 떨어짐.
문제: 재컴파일 없이 기획자가 원하는 대로 개체를 조립할 수 없을까?
- Entitiy Component System
GameObject
는 Component
를 List로 들고 있다.
- 각각은
update()
하는 방법을 override하여 상황에 맞게 구현한다.
- 여기서 생각해야 할 것은,
NPC
, Player
, Effect
와 같은 클래스가 사라졌다는 것이다.
- 보다 일반화된 개념으로 자리잡아두고 처리하고 있음
- 이 Component를 조립한 결과로, NPC, Player와 같은 개체를 만들 수 있음
Reference