Java

[Effective Java] 아이템 23 : 태그 달린 클래스보다는 클래스 계층구조를 활용하라

jwKim96 2023. 1. 10. 23:45

예제 코드는 여기서 보실 수 있습니다.

0. 들어가며

이번 아이템은 상속을 활용해야하는 이유에 대한 내용을 담고있습니다.

아래에서 천천히 알아보겠습니다.

1. 태그 달린 클래스

아이템의 제목에서도 그렇고, 책에서도 태그 달린 클래스 라는 말이 자주 등장하는데요.

그 의미가 궁금해졌습니다.

1.1 태그란?

저는 태그란 클래스를 더 세분화 시키기 위한 필드/상태라고 이해했습니다.

특정 클래스에 이런 상태를 붙여서 더 세분화된 것이 태그 달린 클래스인 것이죠.

제가 친구들에게 “얼음이 녹으면?” 이라는 질문을 합니다.

그러면 친구들은 다음과 같이 대답합니다.

  • 문과 친구 : “얼음이 녹으면 봄이 온다는 거지”
  • 이과 친구 : “물이 되지”

그러면 저는 친구라는 클래스에 질문을 했지만, 문과인지 이과인지에 따라 다른 결과가 나올 것 입니다.

즉, 친구는 세분화된 특징에 따라 다르게 동작한 것이죠.

이 내용은 아래 예제 코드를 보면 더 쉽게 이해할 수 있습니다. (추가 설명을 위해 책의 예제와는 조금 다르게 작성했습니다.)

class Shape {
    private enum ShapeType { TRIANGLE, RECTANGLE }

    private ShapeType shapeType;

    private double width;
    private double height;

    public static Shape createTriangle(double width, double height) {
        Shape triangle = new Shape();
        triangle.shapeType = ShapeType.TRIANGLE;
        triangle.width = width;
        triangle.height = height;

        return triangle;
    }

    public static Shape createRectangle(double width, double height) {
        Shape rectangle = new Shape();
        rectangle.shapeType = ShapeType.RECTANGLE;
        rectangle.width = width;
        rectangle.height = height;

        return rectangle;
    }

    // 넓이 계산
    public double area() {
        switch (this.shapeType) {
            case TRIANGLE: return (width * height) / 2;
            case RECTANGLE: return width * height;
            default:
                throw new IllegalStateException("지원하지 않는 모양입니다.");
        }
    }
}

Shape 이라는 클래스는 도형 타입, 정보, 계산기능을 갖고있습니다.

하지만 이 코드가 좋은 코드가 아닐 확률이 아주 높습니다.

그 이유는 객체지향 설계 원칙의 SRP, OCP 를 위반했기 때문입니다.

  • SRP 위반 : 삼각형, 사각형의 정보를 담고 넓이를 계산하는 책임을 모두 가지고 있음
  • OCP 위반 : 다른 도형을 추가하려면 기존 클래스(Shape)를 수정해야 함

그러면 이 둘을 만족하는 구조로 가기 위해서는 어떻게 해야할까요.

책에서 제시하는 답은 추상화계층구조 를 만들어 상속을 이용하는것 입니다.

1.2 상속을 해야하는 이유

위의 기존 소스코드는 정리하면 다음과 같은 문제가 있었습니다.

  • 장황하다
    • 삼각형만 알고싶은데, 사각형의 정보까지 다 들어있어 파악하기 쉽지 않음
  • 오류를 내기 쉽다
    • 상태에 따른 여러 분기문이 있기 때문에 오류를 만들어내기 더 쉬운 환경임(area 메서드)
  • 인스턴스의 타입만으로는 어떤 도형인지 알 수 없다

책에서는 이를 해결하기 위한 방법인 상속 을 제시합니다.

그런데 상속을 하기 위해서는 계층구조를 설계해야합니다.

그래서 우리는 위에서 도출해 낼 수 있는 3가지 개념(Shape, Triangle, Rectangle)의 관계를 정의해야 합니다.

그리고 이 관계를 바탕으로 계층 구조를 설계하고, 이를 코드로 옮기면 되는것이죠.

image

각 개념들은 다음과 같이 관계를 정리할 수 있습니다.

그리고 이를 소스 코드로 옮기게 되면 다음과 같습니다.

abstract class AbstractShape {

    protected final double width;
    protected final double height;

    public AbstractShape(double width, double height) {
        this.width = width;
        this.height = height;
    }

    public abstract double area();
}

class Triangle extends AbstractShape {

    public Triangle(double width, double height) {
        super(width, height);
    }

    @Override
    public double area() {
        return (width * height) / 2;
    }
}

class Rectangle extends AbstractShape {

    public Rectangle(double width, double height) {
        super(width, height);
    }

    @Override
    public double area() {
        return width * height;
    }
}

공통된 부분을 추상화한 상위 개념(AbstractShape)에 선언하고, 하위 개념에서 구현하는 형태로 되었습니다.

이러한 구조는 다음과 같은 장점이 있습니다.

  • 코드가 간결하고 명확함
    • 각 클래스에는 필요한 정보들만 있음
    • 모든 필드가 final 이기 때문에 불변 객체의 장점도 가짐
    • 상태에 따른 분기문이 없어, 오류 발생 확률이 줄어듦
  • 인스턴스의 타입만 봐도 어떤 도형인지 알 수 있음
  • 그리고 코드 중복을 줄여줌
    • width, height 등을 AbstractShape 에 선언하여 사용

그리고 이전 코드는 SRP, OCP 를 위반했지만 이 코드는 이 두 원칙을 지키고 있습니다.

  • SPR
    • AbstractShape : 도형의 추상화 개념, 넓이 기능 제공
    • Triangle : 삼각형에 대한 넓이 기능을 구현
    • Rectangle : 사각형에 대한 넓이 기능을 구현
  • OCP
    • 다른 도형이 추가되어도 AbstractShape 을 상속한 다른 클래스를 만들면 됨
      (기존 클래스들에는 전혀 영향을 주지 않음)

1.3 상속을 하지 말아야 하는 이유

책에서는 1.2 까지의 내용으로 끝나지만, 상속에 대해 조금 더 짚어봐야할 얘기들이 있습니다.

그것은 바로 상속이 만능은 아니라는 것 입니다.

관련된 내용은 아이템 18번에서 상속이 야기하는 문제상황들을 통해 확인할 수 있었는데요.

그러면 어떤 경우에 상속을 하지 말아야 할까요?

1) 코드 중복 제거

이번 예제처럼 코드 중복을 제거하기 위한 상속 구조는 지양하는것이 좋습니다.

위 예제에서의 클래스들을 간단히 정리해보면 다음과 같습니다.

  • AbstractShape
    • width
    • height
    • AbstractShape(width, height)
  • Triangle extends AbstractShape
    • area()
  • Rectangle extends AbstractShape
    • area()

Triangle, Rectangle 에서 선언되어 사용되던 중복된 두 필드 선언을 AbstractShape 으로 옮겼었습니다.

이 덕분에 Triangle, Rectangle 은 ‘중복된 코드가 제거되어’ 더 간결하고 깔끔한 코드가 되었는데요.

하지만 중복 제거를 위해 상속을 이용한 경우에는 더 큰 문제를 야기할 수 있습니다.

2) 새 클래스 추가

image

Circle 이라는 새로운 클래스가 필요해졌습니다.

이 클래스는 width, height 는 사용하지 않고, 대신 radius 라는 필드를 필요로 합니다.

이렇게 변하게 된 AbstractShape, Circle 의 코드는 다음과 같습니다.

abstract class AbstractShape {

        // 더 이상 불변 객체가 아님
    protected double width;
    protected double height;
    protected double radius;

        // 상속을 사용하기 전과 똑같은 구조의 생성자
    public AbstractShape(double radius) {
        this.radius = radius;
    }

        // 상속을 사용하기 전과 똑같은 구조의 생성자
    public AbstractShape(double width, double height) {
        this.width = width;
        this.height = height;
    }

    public abstract double area();
}

class Circle extends AbstractShape {

        // 여기서는 radius 만 필요하지만 불필요한 필드인 width, height 모두 접근 가능함

    public Circle(double radius) {
        super(radius);
    }

    public double area() {
        return Math.PI * (radius * radius);
    }
}

단지 Circle 클래스만 추가하고 싶었을 뿐인데, 이전의 동일한 문제들이 다시 생겨났습니다.

그래서 이를 해결하기 위해서 다시 코드를 수정합니다.

abstract class AbstractShape {
    public abstract double area();
}

class Triangle extends AbstractShape {

    private final double width;
    private final double height;

    public Triangle(double width, double height) {
        this.width = width;
        this.height = height;
    }

    @Override
    public double area() {
        return (width * height) / 2;
    }
}

class Rectangle extends AbstractShape {

    private final double width;
    private final double height;

    public Rectangle(double width, double height) {
        this.width = width;
        this.height = height;
    }

    @Override
    public double area() {
        return width * height;
    }
}

class Circle extends AbstractShape {

    private final double radius;

    public Circle(double radius) {
        this.radius = radius;
    }

        @Override
    public double area() {
        return Math.PI * (radius * radius);
    }
}

이렇게 각자 필요한 필드를 관리하고 공통된 기능(area)을 구현하는데 신경을 쓰면 됩니다.

모든 클래스들은 불변이며 SRP, OCP 를 지키는 좋은 구조가 되었습니다.

그리고 여기서 한단계 더 나아간다면, 다음과 같이 변경할 수 있습니다.

interface Area {
    double area();
}

class Triangle implements Area {
    ...
}

class Rectangle implements Area {
    ...
}

class Circle implements Area {
    ...
}

Shape 를 Area 라고 변경하면, 다중상속을 통해 다른 기능도 추가할 수 있는 유연한 구조가 가능해 집니다.