
장기 프로그램을 자바를 활용해 객체 지향적으로 구현한다고 생각해보자.
장(가운데 큰 녀석, 체스의 킹과 비슷하다)과 사(장 옆에 있는 두 기물들)를 각각의 객체로 구현하는 것은 자연스럽다.
그렇다면 장과 사의 이동 규칙을 정의할 필요가 있다. 그래서 Jang 클래스와 Sa 클래스를 각각 만들고 각각의 이동 규칙을 정의했다.
장(Jang)
- 잡히면 게임이 끝난다.
- 궁성 안에서만 이동이 가능하다.
- 궁성 내부의 간선을 통해 상,하,좌,우,대각선(간선이 있는 경우) 1칸을 이동 할 수 있다.
사(Sa)
- 궁성 안에서만 이동이 가능하다.
- 궁성 내부의 간선을 통해 상,하,좌,우,대각선(간선이 있는 경우) 1칸을 이동 할 수 있다.
이렇게 각각의 이동 규칙을 정의하니 뭔가 불편하다.
게임 종료 여부나 기물 이름에서 분명한 차이가 존재하니 다른 객체로 나눠야 할 것 같긴 한데, 이동규칙과 관련된 많은 부분이 동일하다.
실제로 코드를 작성하게 된다면 먼저 작성한 클래스의 코드를 복붙해서 나머지 하나를 만들게 될 것이다. 코드 중복이 발생한다.
그냥 복붙해서 만들면 안될까? 안된다. 우리는 깔끔한 코드를 짜야한다.
Claen Code를 짜야한다. Clean Code를 위한 설계 원칙을 지켜야 한다.
왜나하면 개발자의 코드는 한 번 만들면 땡이 아니라 지속적으로 유지보수를 해야 하기 때문이다.
코드 중복은 중복 로직이 변경될 때마다 모든 변경부분을 찾아서 일일히 변경해줘야 하는 단점이 있다.
Clean Code를 위한 DRY 원칙을 위배하게 되는 것이다.
그렇다면 이러한 중복 문제를 해결하기 위한 방법으로는 어떤 것이 있을까?
상속을 고려해볼 수 있다.
상속을 사용하면 중복을 깔-끔하게 제거할 수 있다.
예를 들어 사를 먼저 구현하고 장이 사를 상속할 경우 (장 extends 사) 장에 구현해야 하는 코드는 "잡히면 게임이 끝난다." 하나로 줄어든다.
나머지는 자동으로 이미 사에 구현된 코드가 따라들어오게 된다.
아주 깔끔하고 좋다. 편안하다.
그런데 상속은 대부분의 경우에서 사용되지 않는다. 이렇게 강력하게 중복을 제거할 수 있는 상속을 왜 안쓸까?
너무 강력해서 그렇다. 상속은 부모 클래스와 자식 클래스 사이의 강결합을 초래한다. 부모 클래스의 변경에 자식 클래스가 직접적으로 영향을 받기 때문이다.
(그리고 의미적으로도 좀 찜찜하다. 장은 사의 일종이 아닌데 단순히 기능이 일부 겹친다고 '장은 사의 확장판이다'라고 선언하는 꼴이니.)
그렇다면 변경에도 유연하면서 중복도 줄일 수 있는 방법이 있을까? 이럴 때 조합을 고려해 볼 수 있다.
조합은 슈퍼 클래스를 상속받는 대신 인스턴스 변수로 두고 사용하는 것을 말한다.
서브 클래스에서는 슈퍼 클래스의 인스턴스를 사용해 자신의 메서드를 정의할 수 있다. 약식으로 표현하면 아래와 같다.
class Jang {
private Sa sa;
public move() {
sa.move();
}
...
}
이렇게 하면 Jang에는 move에 대한 코드의 중복을 없앨 수 있고,
만약 Sa의 move가 Jang과 달라진다면 sa.move를 사용하지 않으면 된다. 결합도를 낮춘 것이다. 이렇게 조합의 사용은 유지 보수에서 강점을 지닌다.
다만 이렇게 되면 결합도를 낮춘 만큼 Jang에도 move처럼 각각의 메서드를 정의해주긴 해야 한다.
내부 구현은 sa에서 가져다 쓰지만 결국 시그니처 중복이 발생하게 되는 것이다.
나는 처음 상속을 사용하던 코드에서 주변 안티 상속파의 잔소리에 못 견뎌 조합으로 코드를 변경하던 중 열이 올랐다.
상속으로 깔끔하게 중복이 제거되었던 내 코드에 시그니처 중복이 쌓여가는게 납득할 수 없었다.
(지금 돌이켜보면 이 당시 조합의 유연함을 더욱 납득하기 어려웠던 것은 궁극의 조합법(하단 보너스 참고)을 몰랐기 때문이었다.)
그래서 조합이 상속에 비해 특별히 변경에 유연해 보이지 않았고 차라리 중복이라도 확실하게 없애주는 상속이 낫다고 판단했다.
여튼 그렇게 울며 겨자먹기로 '상속 -> 조합' 으로 코드를 리팩터링 하면서 좀 삐딱해졌다.
그렇게 결합 되는 게 싫으면 그냥 아예 떨어져. 중복 코드로 냅둬!
그래서 원상태로 확 돌려버릴까 싶었다.
웃기게도 머릿속에 아래와 같은 순환이 생겨버린 것이다. 
그렇다면 이 순환 고리는 정말 맞는 것일까? 저 세 가지 상태는 각각의 장단점이 존재하여 트레이드 오프가 가능한 영역일까?
중복과 상속과 조합은 취향 차이인 것 일까?
아니다. 이 글의 첫 문장에서도 알 수 있듯이 우리는 '객체 지향'을 해야 한다. 객체 지향을 하는 가장 큰 이유는 유지 보수의 편의를 위함이다.
따라서 중복 문제가 발생하면 유지 보수에서 가장 큰 이점을 가지는 조합을 우선적으로 고려하고, 절대적 상하관계가 존재하는(is-a 관계) 등의 특수한 상황에서 신중하게 상속을 고려해야 한다.
그리고 사실 상속은 확장에 엄청나게 유리한 최종 진화 형태를 가지고 있다.
.
.
.
유지 보수를 위한 조합 방식의 궁극적인 활용 형태는 인터페이스를 이용한 전략 패턴 Strategy Pattern이다.
사실 위에 서술한 방식처럼 필드로 의존 클래스를 갖는 조합 방식은 완벽하지 않다.
class Jang {
private Sa sa = new Sa(); // Sa 클래스에 직접 의존
public void move() {
sa.move();
}
}
Jang이 나중에 Sa가 아니라 다른 이동 방식을 쓰고 싶어도 Jang의 코드를 수정해야 하기 때문이다.
이는 확장에는 열려있되 수정에는 닫혀있어야 하는 OCP 원칙에 위배된다.
전략 패턴은 이동 전략을 구체적인 클래스에 의존하는 것이 아니라 인터페이스로 추상화하여 OCP도 만족하며 중복 코드도 없애는 기가 막힌 디자인 패턴이다.
class Jang {
private MoveStrategy moveStrategy; // 인터페이스에 의존
// 생성자나 Setter를 통해 외부에서 '전략'을 넣어줌
public Jang(MoveStrategy moveStrategy) {
this.moveStrategy = moveStrategy;
}
// 런타임에 전략을 바꿀 수 있는 Setter (유연성 확보)
public void setMoveStrategy(MoveStrategy moveStrategy) {
this.moveStrategy = moveStrategy;
}
public void move() {
moveStrategy.move();
}
}
처음 보면 좀 복잡해 보일 수도 있다
이렇게 되면 Jang의 이동 전략이 아무리 변경되어도 Jang의 코드는 아예 건드릴 필요가 없다.
그냥 필요한 이동 전략을 구현하고 그것을 주입해주면 된다. (DI, 의존성 주입)
심지어 Setter를 활용하면 런타임에도 Jang의 움직임 전략을 변경할 수 있다.
이 얼마나 유연한가. 동작 변경에 용이하다는 것은 테스트 코드를 위해 클래스를 Mocking할 때에도 유리하게 작용한다.
전략 패턴의 '인터페이스+DI'를 통한 압도적 유연성과 중복 제거 효과가 현재 개발자들에게 조합이 상속보다 (적어도 유지보수 측면에서) 우선시 되는 이유이다.
빙글빙글 돌아가는 조상