본문 바로가기

JAVA/Design Patterns

인터페이스로 프로그래밍하기 #깨지기 쉬운 기반 클래스 문제1

반응형


깨지기 쉬운 기반 클래스 문제

이제 결합도의 개념을 상속에 적용해 보자

extends를 사용하는 구현 상속 관계에서는 파생클래스(자식 클래스)가 기반 클래스(부모 클래스)에 강하게 결합되며, 이런 강한 결합은 바람직하지 않다.

디자이너들은 구현 상속으로 인한 강결합으로 인한 부작용을 설명하기 위해 '깨지지 쉬운 기반 클래스 문제'란 별칭을 붙였다.


이때 겉으로는 안전해 보이는 방식으로 기반 클래스를 수정했지만 파생 클래스가 잘못 작동하게 되는 경우가 왕왕 있기 때문에 '깨지지  쉬운'이라는 수식어가 붙게 되었다.

기반 클래스는 기반 클래스만 따로 떨어뜨려 놓고 안전하게 수정할 수 없으며, 모든 파생 클래스를 함께 살펴보고 테스트해 보야야 한다.

당연히 기반 클래스를 사용하는 객체뿐 아니라 파생 클래스를 사용하는 객체들 역시 점검해 보아야 한다.

단순히 기반 클래스를 조금 수정했음에도 불구하고 전체 프로그램이 오작동을 할 수도 있게 되는 것이다.


Class Stack extends ArrayList {

private int topOfStack = 0;


public void push(Object article) {

add(topOfStack++, article);

}


public Object pop() {

return remove(--topOfStack);

}


public void pushMany(Object[] articles) {

for(int i=0; i<article.length; i++) {

push(articles[i]);

}

}

}


사용자가 상속이란 개념을 이용하여 스택의 모든 내용을 얻기(pop) 위해 ArrayList의 clear() 메소드를 이용했다면?

Stack aStack = new Stack();

aStack.push("1");

aStack.push("2");

aStack.clear();

코드는 깔끔하게 컴파일 된다.

하지만 기반 클래스는 스택의 가장 위에 있는 아이템에 대한 인덱스(topOfStack)에 대해 알지 못하며, 이제 Stack은 불가사의한 상태에 빠지게 된다.

만약 위와 같은 상태에서 push()메소드를 호출하면 현재 topOfStack 값인 인덱스의 위치에 새 아이템을 삽입하게 되고, 아래의 2개 아이템이 쓰레기 값임에도 불구하고  스택은 3개의 아이템을 갖고 있는 셈이 된다.


상속을 하게 되면 원하지 않는 메소드(clear() 등) 까지 모두 상속하게 된다.


원하지 않는 메소드가 상속하는 문제를 해결하는 한 가지 방법은 스택이 스택의 내부 상태를 변경하는 모든 메소드를 오버라이드하는 것이다.

파생클래스를 작성한 후에 기반 클래스에 clear()와 같은 메소드를 추가하는 경우에는 속수무책이기 때문이다.

clear() 메소드 문제를 clear() 메소드가 예외를 던지도록 오버라이딩 하는 방법을 사용하는 개발자도 있지만, 이 방법은 유지보수 관점에서 정말 나쁜 아이디어이다.

ArrayList의 규약은 파생 클래스가 기반 클래스의 몇몇 메소드가 작동하길 원하지 않을 때 예외를 던진다고 말해 주지 않느다.

그러므로 이런 행동은 이를 사용하는 클라이언트 입장에선 예측하지 못한 것이다.

Stack은 ArrayList이기 때문에 클라이언트는 Stack에 clear() 메소드를 호출 할 수 있으며, 이 때 예외가 돌아오리라곤 기대하지 않을 것이다.

이와 같이 파생 클래스가 기반 클래스의 규약을 어긴다면, 다형성을 이용한 코딩을 하지 못하게 된다.


파생 클래스가 상속을 원치 않는 기반 클래스의 메소드에서 예외를 던지는 방법은 나쁜 방법이다.

이는 LSP를 어기는 것이며 결과적으로 OCP까지 지킬 수 없게 된다.


예외 던지기 전략은 또한 컴파일 타임 에러를 런타임으로 옮기게된다. 단순히 메소드가 선언되어 있지 않다면, 컴파일러는 메소드를 찾을 수 없다는 에러를 던질 것이다. 하지만 메소드가 존재하지만 예외를 던진다면, 프로그램이 실제로 실행될 때까지 이 에러를 알지 못하게 된다.


예외 던지기 전략은 컴파일 타임 에러를 런타임 에러로 바꾸는 문제 또한 갖고 있다.


또한 개념적 관점에서 역시 ArrayList를 상속하여 Stack을 정의하는 것은 옳지 않은 생각이다.

스택은 ArrayList가 제공하는 상당 수의 메소드를 필요하지 않으며, 상속을 통해 이런 메소드에까지 접근을 제공하는 것은 좋은 계획이 아니다.

즉 ArrayList의 많은 연산이 Stack에서는 말이 되지 않는다.


Stack is a ArrayList 가 아니다.


자바가 제공하는 Stack 클래스는 topOfStack 인덱스를 위해 기반 클래스의 size() 메소드를 사용하기 때문에 clear(). 메소드로 인해 문제가 발생하지는 않는다. 하지만 여전히 java.util.Stack은 java.util.Vector를 상속하면 안된다.

예를 들어, Vector로부터 상속한 removeRang()와 insertElementAt() 메소드는 스택에서는 의미 없는 것이며, 사용자가 이런 메소드를 Stack 클래스에서 호출하는 것을 막을 방법이 없다.


스택을 설계하는 좀 더 나은 아이디어는 상속 대신 캡슐화(보통 합성이라고한다)를 사용하는 것이다.

상속한 메소드가 전혀 없다.

Class Stack {

private int topOfStack = 0;

private ArrayList theData = new ArrayList();


public void push(Object article) {

theData.add(topOfStack++, article);

}


public Object pop() {

return theData.remove( --topOfStack);

}


public void pushMany(Object[] articles) {

for(int i=0; i<articles.length; i++) {

push(articles[i]);

}

}


public int size() {

return theData.size();

}

}


Stack과 ArrayList 간의 결합 관계는 첫 번째 버전에 비해 많이 느슨해졌다.

더 이상 원하지 않지만 상속한 메소드에 대해 걱정할 필요가 없다.

만약 ArrayList에서 Stack 클래스를 깨뜨리는 변화가 일어났다 하더라도, 이를 위해 Stack 클래스만 변경하면 되며 Stack 객체를 이용한 어떤 코드도 재작성할 필요가 없다.

이 버전에서는 Stack이 ArrayList로부터 더 이상 size() 메소드를 상속받고 있지 않기 때문에 size() 메소드를 제공해 주어야한다.


상속대신 합성을 통해 ArrayList를 재사용하면 Stack과 ArrayList 간의 결합도가 상당히 낮아지며 깨지기 쉬운 기반 클래스로 인한 문제도 어느정도 해결된다. 상속 대신 합성을 통해 재사용하라.



일정시간동안 Stack의 최대 크기와 최소 크리를 기억하고 있는 변형 Stack만든다고 해보자

class MoniterableStack extends Stack {

private int highWaterMark = 0;

private int lowWaterMark = 0;


@Override

public void push(Object article) {

super.push(article);

if(size() > highWaterMark) highWaterMark = size();


}


@Override

public Object pop(){

Object popedItem =  super.pop();

if(size() < lowWaterMark)

lowWaterMark = size();

return popedItem;

}


public int maximumSize() { return highWaterMark; }

public int minmumSize() { return lowWaterMark; }

public void resetMarks() { highWaterMark = lowWaterMark = size(); }


}

새로운 클래스는 좋아보인다.

하지만 불행히도 push() 메소드를 이용하는 pushMany() 메소드를 상속했다.

언뜻 보기에 그리 나쁜 선택은 아닌 것 같다.

구현 상속의 핵심은 기반 클래스의 메소도를 재사용하는 것이기 때문이다.

하지만 어느날, 누군가 프로파일러를 실행해 보았는데 실제 실행시 Stack에 중대한 병목(bottleneck)이 있음을 알게 되었다.

부주의한 유지보수 프로그래머는 Stack 클래스가 더 이상 ArrayList를 사용하지 않도록 재작성하였다.

class Stack {

private int topOfStack = -1;

private Object [] theData = new Object[1000];

public void push(Object article) {

theData[ ++topOfStack] = article;

}


public Object pop() {

Object popped = theData[topOfStack--];

theData[topOfStack] = null;  //메모리 누수를 방지한다.

return popped;

}


public void pushMany(Object [] articles) {

assert (topOfStack + articles.length) < theData.length;

System.arraycopy(articles, 0, theData, topOfStack+1, articles.length);

topOfStack += articles.length;

}

//현재 스택 크기

public int size() {

return topOfStack + 1;

}

}

더 이상 pushMany() 메소드가 push()를 여러 번 호출 하지 않는다는 점을 주의깊게 보자

새로운 버전은 잘 동작한다.

그리고 적어도 속도 측면에서는 전 버전보다 좋다.

하지만 이를 상속한 MoniterableStack은 pupMany()가 호출되면 더 이상 제대로 동작하지 않는다.

MoniterableStack이 pushMany()를 호출 했을 때 더 이상  push() 메소드가 호출되지 않기 때문이다.

만약 MoniterableStack의 pushMany() 메소드를 호출한다면 highWaterMark는 갱신되지 않는다.

Stack은 깨지기 쉬운 기반 클래스인 셈이다.


MonitorableStack에서 pushMany() 메소드를 오버라이딩하였으며, 문제점을 해결했다.

public void pushMany(Object[] articles) {

for(int i=0; i<articles.length; i++)

push(articles[i]);

}


하지만 비슷한 문제가 언제든 발생할 수 있으며, 기반 클래스를 수정할 때마다 파생 클래스들이 모두 제대로 동작하는지 검사해 주어야한다.


구현상속을 사용하면 기반 클래스를 수정할 때 마다 파생 클래스들이 제대로 작동하는지 테스트를 해야한다.


만약 명시적으로 아이템을 하나하나 꺼내지 않고도 스택을 비울수 있어야 한다는 새로운 요구사항이 생긴다면?

Stack 클래스에서 코드 추가

//추가 스택 비우기

public void discardAll() {

theData = new Object[1000];

topOfStack  = -1;

}


새로 추가한 메소드는 안전하고 합당해보인다. 하지만 기반 클래스의 수정이 또 다시 파생 클래스를 깨뜨리게 된다.

discardAll()은 pop()을 호출하지 않기 때문에 MonitorableStack에서 이 메소드를 호출한다면 최대-최소값이 갱신되지 않는다.

깨지기 쉬운 기반 클래스 문제가 발생할 확률을 줄일 수 있을까?

만약 기반 클래스를 수정할 때마다 파생 클래스에서 기반 클래스의 모든 메소드를 오버라이딩 해야 한다면 기반 클래스를 확장하고 있는 것이 아니라 인터페이스를 구현하고 있는 것이다.


기반 클래스를 수정할 때마다 파생 클래스를 검토해 보아야 한다면 이는 기반 클래스를 확장하고 있는 것이 아니라 인터페이스를 구현하고 있는 것이다.


인터페이스를 상속을 이용하면 상속된 기능이 없기 때문에 잘못될 일이 없다.

만약 Stack이 인터페이스였고, SimpleStack과 MonitorableStack 둘다 이를 구현했다면 코드는 훨씬 강건했을 것이다.


구현상속 버전과 똑같은 유연성을 갖는다.

실제로 어떤 스택 구현을 사용하든 Stack 추상화 관점에서 코딩을 할 수 있을 것이다.

즉 인터페이스를 사용하여 다양한 구체 클래스 객체를 다형적으로 접근할 수 있다.

두 구현 모두 public 인터페이스의 모든 메소드를 구현해야 하기 때문이다.

그러면서도 구현 상속에서와 같은 위험은 훨씬 덜하다.

인터페이스 상속을 사용하면 안전하게 다형성을 획득할 수 있다.


구현 상속에 언제나 '나쁘다'라고 말하려 하는 것은 아니다.

하지만 구현 상속은 잠재적인 유지보수의 문제가 있다. 구현 상속은 OO시스템의 근간을 이루며, 이를 모두 제거할 수 없고, 또한 그렇기를 원하지 않는다. 구현 상속은 위험하기 때문에 이를 사용하기 전에는 충분히 심사숙고해야 한다는 것이다.


구현 상속은 필요한 기능이지만, 위험하기 때문에 사용하기 전에 충분히 심사숙고해야 한다.


MonitorableStack에서 하듯 위임 모델을 사용하여 인터페이스를 구현하는 것이 안전하다.

(기능성 클래스를 상속하는 대신 합성하고, 합성한 클래스에서 인터페이스 연산을 위임한다.)

두 전략 모두 시스템에 상속을 추가하는 방법이 된다.


위임(합성)을 통해 인터페이스를 구현하자


하지만 모든 디자인 결정에서와 마찬가지로 모든 해결책에는 트레이드 오프가 있다. 

위임을 사용하면 잠재적인 깨지기 쉬운 기반 클래스 문제를 해결하는 대신 구현의 용이함을 어느 정도 포기해야한다.

그리고 단순히 다른 클래스에 위임을 하는 메소드들은 코드 사이즈를 키우게 된다.

즉 좀 더 구현하기가 고된 것이다. 인터페이스 상속과 위임을 이용할지, 아니면 코딩 양을 줄이기 위해 잠재적으로 찾기 어려운 버그를 잉태할 가능성이 있는 구현 상속을 이용할지는 결정할 몫이다.

만약 클래스가 200개의 메소드를 갖고 있고, 위임 모델에서 이 모두를 구현해야 한다면 아마도 위험을 감수할 만한 가치가 있을지도 모르겠다.


구현 상속은 구현이 편리한 대신 위험하고, 인터페이스 상속은 안전한 대신 구현이 고될수 있다. 가능하면 인터페이스 상속을 사용하라.


public interface Stack {


void push( Object o);

Object pop();

void pushMany(Object[] articles);

int size();

}


package design.chapter2.improv;


import java.util.ArrayList;


public class SimpleStack implements Stack {


private int topOfStack = 0;

private ArrayList theData = new ArrayList();


@Override

public void push(Object article) {

theData.add(topOfStack++,article);

}


@Override

public Object pop() {

return theData.remove( --topOfStack);

}


@Override

public void pushMany(Object[] articles) {

for (int i = 0; i < articles.length; i++) {

push(articles[i]);

}

}


@Override

public int size() {

return theData.size();

}


}



package design.chapter2.improv;


public class MonitorableStack implements Stack {


private int highWaterMark = 0;

private int lowWaterMark = 0;


SimpleStack stack = new SimpleStack();


@Override

public void push(Object o) {

stack.push(o);

if(stack.size() > highWaterMark)

highWaterMark = stack.size();

}


@Override

public Object pop() {

Object returnValue = stack.pop();

if(stack.size() < lowWaterMark)

lowWaterMark = stack.size();

return returnValue;

}


@Override

public void pushMany(Object[] articles) {

for (int i = 0; i < articles.length; i++) {

push(articles[i]);

}


if(stack.size() > highWaterMark)

highWaterMark = stack.size();

}


@Override

public int size() {

return stack.size();

}


public int maxiumSize() { return highWaterMark; }

public int minumSize() { return lowWaterMark; }

public void resetMarks() { highWaterMark = lowWaterMark = size(); }


}


반응형