읽기 전

  • 불필요한 코드나 잘못 작성된 내용에 대한 지적은 언제나 환영합니다.
  • 개인적으로 사용해보면서 배운 점을 정리한 글입니다.

문제 제기

자바 상속에는 치명적인 문제가 존재한다고 흔히들 말하며 상위 클래스를 구현하여 자식 클래스에 extend하기 보다 interface 클래스를 구현하여 implement하는 습관을 들이라고 설명한다. 그렇다면 왜 그래야 하는지 정리해보려 한다.

상속의 문제점

개방 폐쇄 원칙(캡슐화) 약화

상속받은 자식 클래스에서 부모 클래스의 public, protected 메소드에 접근이 가능하기 때문에 부모 클래스의 구현사항에 의존하게 된다. 허나 자식 클래스 규칙에 부합하지 않는 부모 클래스 메소드가 존재하고 그 메소드가 의도치 않게 자식 클래스의 코드에서 동작한다면 자식 클래스의 규칙을 위반한 결과가 반환될 수 있기에 문제가 발생한다. 완벽한 캡슐화를 원한다면 상속을 포기하거나 적절한 private 사용으로 인터페이스 은닉과 함께 getter, setter 코드 구현 등 정확하게 상속을 구현해내야 한다.

설계가 유연하지 않음 - 클래스간 결합 문제

상속은 자식 클래스에 기능을 점진적으로 추가하며 확장하는 목적으로는 편리하다. 그러나 자식 클래스가 부모 클래스를 상속하면 상호 간의 의존성(결합도, coupling)이 생긴다. 객체지향 프로그래밍에서는 결합도를 낮추고 클래스에 관련된 메소드를 몰아넣어서 해당 클래스가 갖는 책임에 집중(응집도, cohesion)하고 독립성을 높이라고 설명한다. 따라서, 부모-자식 간의 결합도가 높다면 부모 클래스의 변경으로 인해 자식 클래스가 취약해질 수 있다. 이를 취약한 기반 클래스 문제라고 부르며 상위 부모 클래스 수정 시 모든 자식 클래스를 검증, 수정, 테스트하는 비용이 발생할 수 있다.

리스코프 치환 원칙의 위배

결국 잘못된 상속으로 인한 캡슐화 약화, 클래스 간 결합 두 개의 문제의 결과물로 리스코프 치환 원칙 위배가 발생한다.

리스코프 치환 원칙이란 상위 타입을 사용하는 메소드에 하위 타입의 객체를 매개변수로 전달할 경우에도 정상 동작 해야함을 의미한다. 이를 테면 부모 클래스의 메소드 동작으로 어떤 인과관계가 정해지는 메소드에 자식 클래스의 성질에 기반하여 overriding한 setter 동작이 그 인과관계를 해치는 경우가 발생하는데 이 경우는 자식 클래스가 아니라 별도의 타입으로 정의해야 한다.

리스코프 치환 원칙 위배 예제

수학적 개념으로는 정사각형은 직사각형의 일부에 속하기 때문에 코드 상으로도 의식 없이 extend했다고 가정하자. 직사각형 클래스는 높이, 너비를 따로 입력받아 setter 메소드를 정의하였고 정사각형 클래스는 너비건 높이건 모두 같으므로 두 메소드를 오버라이딩하여 너비, 높이를 동일하게 설정하게끔 정의하였다.

public class Rectangle {
    private int width;
    private int height;

    public void setWidth(final int width) {
        this.width = width;
    }

    public void setHeight(final int height) {
        this.height = height;
    }

    public int getWidth() {
        return width;
    }

    public int getHeight() {
        return height;
    }
}
public class Square extends Rectangle {
    @Override
    public void setWidth(final int width) {
        super.setWidth(width);
        super.setHeight(width);
    }

    @Override
    public void setHeight(final int height) {
        super.setWidth(height);
        super.setHeight(height);
    }
}

반드시 한쪽 변을 다른 변보다 길게 만드는 메소드를 정의하고 매개변수로 직사각형 타입을 받게 하자.

public class Test {
    public static void main(String[] args) {
        Test test = new Test();
        Rectangle foo = new Rectangle(5, 3);
        Square bar = new Square(4);
        System.out.println("expected: " + foo.getWidth() + ", " + (foo.getWidth() + 1));
        test.increaseHeight(foo);
        System.out.println(foo.getWidth() + ", " + foo.getHeight());
        System.out.println("expected: " + bar.getWidth() + ", " + (bar.getWidth() + 1));
        test.increaseHeight(bar);
        System.out.println(bar.getWidth() + ", " + bar.getHeight());
    }

    public void increaseHeight(final Rectangle rectangle) {
        if (rectangle.getHeight() <= rectangle.getWidth()) {
            rectangle.setHeight(rectangle.getWidth() + 1);
        }
    }
}

직사각형의 하위 클래스인 정사각형 타입을 매개변수로 넣으면 한쪽 변은 절대로 다른 변보다 길어질 수 없다. 그러므로 메소드를 호출한 뒤 반드시 사각형의 두 변 사이 차이가 존재한다는 사실이 성립하지 않는다.

expected: 5, 6
5, 6
expected: 4, 5
5, 5

따라서, 정의한 메소드가 올바른 결과를 갖기 위해서는 메소드에서 매개변수 타입을 체크하는 instanceof 연산자를 이용한 분기문을 정의해야 한다. 설사, 타입 체크하는 분기문을 선언하여 메소드의 정상동작을 보장하여도 타입 체킹하는 행위 자체가 이미 원칙 위반에 해당하여 정의하고자 하는 메소드는 직사각형 타입 확장에 열려있지 않음을 의미한다. 따라서 정사각형 타입이 직사각형 타입 하위에 속함이 적합해 보이지만 엄밀하게 정사각형은 직사각형이 갖는 모든 성질을 만족할 수 없기 때문에 직사각형 타입을 매개변수로 받아 한쪽 변 길이를 다른 쪽 변보다 길게 만드는 메소드를 정의하고자 한다면 직사각형 타입과 정사각형 타입은 별개의 타입으로 선언해야 한다.

잘못된 상속 예제 케이스

상속은 코드 재사용과 다형성을 목적으로만 사용해선 안된다. 애매한 설계로 인해 문제가 발생한 케이스 2개를 보통 예제 케이스로 소개한다.

Stack 클래스에서의 상위 클래스(Vector) 메소드 호출

Stack 클래스의 경우 Vector 클래스를 상속받는다. 따라서 Vector 클래스의 public/protected 인터페이스는 모두 접근이 가능함을 의미한다. Vector 클래스의 add 메소드는 public으로 정의되어 있어 stack 클래스 안에서 사용할 수 있다. 그런데 add 메소드를 Stack 클래스에서 호출하면 Stack 클래스 규칙에 맞지 않는 결과가 반환될 수 있다.

Stack<String> stack = new Stack<>();
stack.push("1st");
stack.push("2nd");
stack.push("3rd");
stack.add(0, "4th");

assertEquals("4th", stack.pop()); //에러

위 코드의 경우 Stack 클래스의 원소를 더하는 연산은 항상 FILO를 준수해야 한다. 허나 add 메소드로 넣은 원소는 가장 맨 앞에 위치하므로 pop연산 시 LIFO 원칙에 위배된다. 또한 Vector 클래스의 get 메소드도 Stack에서 사용할 수 있어 원소를 확인하고자 하면 pop연산으로 값을 확인해야 함에도 get 메소드를 사용하여 조회할 수 있어 역시 Stack 규칙에 어긋난다.

Properties Class에서의 상위 클래스(Hashtable) 메소드 호출

Hashtable 클래스로부터 상속되었기에 Properties 클래스에서 put 메소드를 호출할 수 있었다. 다만, Properties 클래스를 위해 새롭게 getProperty, setProperty를 정의한 것이 화근이었다. HashTable의 메소드는 삽입할 시 키 타입의 제약이 없어 키를 String 객체가 아닌 다른 타입의 객체로 넣을 수 있다. 다만, 이렇게 규칙을 준수하지 않은 채로 삽입하면 Properties는 객체에 String 키 값을 제외한 다른 타입의 키는 존재하지 않는다고 전제하기 때문에 propertyNames() 메소드 등에서 Exception이 발생한다.
해당 클래스를 설계한 이펙티브 자바의 저자 조슈아 블로치는 본인의 책에서 문제점을 인식하고 고치려 시도한 시점에는 너무나 많은 사용자들이 문자열 이외의 타입을 키로 삽입한 상태였기에 수정할 수 없었다고 설명한다.

리스코프 치환 원칙 위배 해결

상속을 하려면 자식 클래스가 부모 클래스에 대해 "is a kind of(소위 IS-A)" 관계를 만족해야 한다. 즉, 하위 클래스가 상위 클래스의 모든 역할을 수행하며 분류에 정확히 속하는지 확인하고 수행해야 한다. 단순히 다형성과 코드 재사용만 생각해서 사용하면 조금씩 기능이 어긋나면서 낭패를 볼 수 있다. 만약 애매하게 기능이 포함된다면 Has-A 관계가 성립하므로 컴포지션 패턴을 사용해 새로운 클래스로 정의해야 한다.

Development_Java_inheritance_problem_composite_pattern_001.png

Composite pattern

Development_Java_inheritance_problem_composite_pattern_002.png

composition(조합 or 합성)이란? 기존 클래스를 확장하지 않고 새로운 클래스를 생성하여 private 필드로 기존 클래스의 인스턴스를 참조하도록 설계하는 방법이다. 클래스 다이어그램을 보면 component가 leaf와 composite는 directory를 implement하지만 composite는 directory에서 파생된 복수 개의 객체를 다룰 수 있어 마치 재귀적인 구조를 이루는 형태가 된다.

  1. Component : Base Component라고도 하며 interface 혹은 추상 클래스로 정의한다. 이 인터페이스를 사용해 leaf와 composite를 정의한다.
  2. leaf : 하위 객체를 정의하는 클래스로 Component 인터페이스에 정의된 메소드를 제외한 다른 메소드는 정의할 수 없다.
  3. composite : base component 클래스 메소드와 leaf 클래스 객체에 대해 동작하는 메소드들을 정의한다. leaf 클래스 객체들에 대한 헬퍼 클래스로 볼 수 있다.
  4. client : base coponent 객체(leaf 클래스 객체들)을 사용하는 composition 객체를 다룬다. 사실상 모든 클래스에 접근한다.

composite pattern 적용 예제

  • Base Component
public interface Worker {
    void printDepartment();

    void printWorkerName();

    void printWorkerId();
}
  • Leaf
public class Engineer implements Worker {
    private Integer id;
    private String name;

    public Engineer(int id, String name) {
        this.id = id;
        this.name = name;
    }

    public void printWorkerId() {
        System.out.println(id);
    }

    public void printWorkerName() {
        System.out.println(name);
    }

    public void printDepartment() {
        System.out.println(getClass().getSimpleName());
    }
}
public class Designer implements Worker {
    private Integer id;
    private String name;

    public Designer(int id, String name) {
        this.id = id;
        this.name = name;
    }

    public void printWorkerId() {
        System.out.println(id);
    }

    public void printWorkerName() {
        System.out.println(name);
    }

    public void printDepartment() {
        System.out.println(getClass().getSimpleName());
    }

}
  • Composite
import java.util.ArrayList;

public class WorkerList implements Worker {
    private ArrayList<Worker> workerArrayList = new ArrayList<>();

    public void printDepartment() {
        for (Worker worker : workerArrayList) {
            worker.printDepartment();
        }
    }

    public void printWorkerName() {
        for (Worker worker : workerArrayList) {
            worker.printWorkerName();
        }
    }

    public void printWorkerId() {
        for (Worker worker : workerArrayList) {
            worker.printWorkerId();
        }
    }

    public void addWoker(Worker worker) {
        workerArrayList.add(worker);
    }

    public void removeWorker(Worker worker) {
        workerArrayList.remove(worker);
    }
}
  • Client
public class Test {
    public static void main(String[] args) {
        Engineer kim = new Engineer(1, "kim");
        Engineer kang = new Engineer(2, "kang");
        Designer choi = new Designer(3, "choi");
        Designer kwon = new Designer(4, "kwon");
        WorkerList workerList = new WorkerList();
        workerList.addWoker(kim);
        workerList.addWoker(kang);
        workerList.addWoker(choi);
        workerList.addWoker(kwon);
        workerList.printDepartment();
        workerList.printWorkerName();
    }
}
Engineer
Engineer
Designer
Designer
kim     
kang    
choi    
kwon

Properties 클래스 개선

Properties 클래스는 키와 값이 모두 String으로 이루어져야 한다. 그리고 기본 속성은 Map 타입에 속하기 때문에 Map클래스를 Component 클래스로 삼고 Leaf 클래스로서 Map<String, String>을 implements하여 구현해야 한다.

import java.util.Collection;
import java.util.Map;
import java.util.Set;

public class Properties implements Map {
    /*
    Map에 정의된 메소드들 overriding
    Leaf 클래스이므로 이외의 메소드를 정의하면 안됨
    */
}

Composite 클래스는 Properties만의 특징에 기반한 새로운 메소드를 정의하는 Helper 클래스로 정의를 해야하는데 Composite Pattern의 정의대로라면 composite 클래스는 component 클래스를 인자로 받는 클래스로 뭔가 맞지 않다. 실력이 좋았다면 제대로 개선해볼 수 있겠으나 지금 실력 상황에서 이런 방식으로는 의도대로 구현 방법이 떠오르지 않는다.

그렇다면 Properties 클래스는 Map 타입을 implement하여 구현된 Hashtable 클래스와 대부분의 특징을 공유하지만 스트링 키를 보장해야 한다는 점에 기인하여 메소드 동작만 조금 다르다는 점을 주목해보자. 캡슐화를 강화한다는 측면에서 Properties 클래스가 어떠한 클래스도 implements, extend하지 않은 채 독립적인 클래스로 정의하였다. 그리고 private 인스턴스 변수에 Hashtable 객체를 할당한 뒤 해당 객체에 값을 넣고 조회하기 위한 getter, setter 메소드를 정의해보자.

import java.util.Enumeration;
import java.util.Hashtable;

public class Properties {
    /*
    별도의 클래스 선언하여 상위 클래스 봉쇄
    상위 특징을 갖는 클래스를 private 선언하여 외부에서 접근할 수 없도록 캡슐화
    */
    private final Hashtable<String, String> table = new Hashtable<String, String>();

    public String getProperty(String key) {
        return table.get(key);
    }

    public void setProperty(String key, String value) {
        table.put(key, value); 
    }

    // key, value String 보장으로 Exeception 차단
    public Enumeration<String> propertyNames() {
        return table.keys(); 
    }
}

Properties의 문제가 Hashtable의 메소드를 호출할 수 있다는 점이었다. 그러나 위 코드처럼 private 인스턴스로 encapsulation을 강화한 뒤 getter, setter 메소드에 매개변수로 String 타입을 강제하면 기존 propertyNames() 메소드 동작 시 키가 String 타입이 아니면 Exception을 발생시키던 문제 등을 해결할 수 있다.

+ Recent posts