item 23 : 태그 달린 클래스보다는 클래스 계층구조를 활용하라

1. 태그 달린 클래스

class Figure {
    enum Shape { RECTANGLE, CIRCLE };
    
    final Shape shape;  // 태그 필드
    
    // 사각형(RECTANGLE)일 때 사용
    double length;
    double width;
    
    // 원(CIRCLE)일 때 사용
    double radius;
    
    // 원용 생성자
    Figure(double radius) {
        shape = Shape.CIRCLE;
        this.radius = radius;
    }
    
    // 사각형용 생성자
    Figure(double length, double width) {
        shape = Shape.RECTANGLE;
        this.length = length;
        this.width = width;
    }
    
    // 면적 계산 (태그 값에 따라 분기 처리)
    double area() {
        switch (shape) {
            case RECTANGLE:
                return length * width;
            case CIRCLE:
                return Math.PI * (radius * radius);
            default:
                throw new AssertionError("알 수 없는 모양: " + shape);
        }
    }
}
  • 불필요한 필드: 각 도형에 필요하지 않은 필드도 클래스에 포함되어 있다. 예를 들어, 원에는 length, width가 필요하지 않고, 사각형에는 radius가 필요 없다.

  • 조건문으로 분기 처리: 태그 값에 따라 area() 메서드에서 조건문을 사용해 분기 처리하는 방식은 복잡하고 실수를 유발할 수 있다.

  • 확장성 부족: 새로운 도형을 추가하려면 switch 문에 새로운 case를 추가해야 하며, 태그 값을 처리할 수 있는 코드도 추가해야 한다.

1) 태그 달린 클래스란?

클래스 내부에 특정 태그 필드를 두고, 그 태그의 값에 따라 클래스의 동작이나 데이터를 다르게 처리하는 방식으로 설계된 클래스를 의미한다. 태그 필드는 클래스의 상태나 모양을 나타내는 역할을 한다. 이 태그 필드에 따라 클래스가 어떤 방식으로 동작할지 결정하는데, 예를 들어 클래스가 여러 가지 다른 타입의 객체를 표현해야 하는 상황에서 태그 필드를 사용해 그 타입을 구분하게 되는 것임.

2) 태그 달린 클래스의 단점

  • 열거 타입 선언, 태그 필드, switch 문 등 쓸데없는 코드가 많다. 여러 구현이 한 클래스에 혼합돼 있어서 가독성도 나쁘다.

  • 다른 의미를 위한 코드도 언제나 함께 하니 메모리도 많이 사용한다. 필드들을 final로 선언하려면 해당 의미에 쓰이지 않는 필드들까지 생성자에서 초기화해야 한다(쓰지 않는 필드를 초기화하는 불필요한 코드가 늘어난다).

  • 또 다른 의미를 추가하려면 코드 를 수정해야 한다. 예를 들어 새로운 의미를 추가할 때마다 모든 switch 문을 찾아 새 의미를 처리하는 코드를 추가해야 하는데 , 하나라도 빠뜨리면 역시 런타임에 문제가 불거져 나올 것이다.

  • 마지막으로, 인스턴스의 타입만으로는 현재 나타내는 의미를 알 길이 전혀 없다.

한마디로, 태그 달린 클래스는 장황하고, 오류를 내기 쉽고, 비효율적이다.

2. 서브타이핑 (subtyping) : 클래스 계층 구조를 이용한 해결 방법

다행히 자바와 같은 객체 지향 언어는 타입 하나로 다양한 의미의 객체를 표현하는 훨씬 나은 수단을 제공한다.

바로 클래스 계층구조를 활용하는 서브타이핑 (subtyping)이다.

클래스를 계층구조로 설계하면 된다. 가장 먼저 루트 클래스 추상 클래스를 정의하고, 태그에 따라 달라지는 메서드를 추상 메서드로 선언한다. 그런 다음 각 도형에 해당하는 하위 클래스를 만들어 각각의 필드를 가지게 하고, 루트 클래스의 추상 메서드를 구현한다.

1) 클래스 계층 구조로 한 코드

// 루트 클래스: 모든 도형의 공통 조상
abstract class Figure {
    abstract double area();
}

// 하위 클래스: 원
class Circle extends Figure {
    final double radius;

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

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

// 하위 클래스: 사각형
class Rectangle extends Figure {
    final double length;
    final double width;

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

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

2) 클래스 계층구조의 장점

  • 불필요한 필드 제거: 각 도형에 맞는 필드만 남기고 불필요한 필드는 모두 제거했다.

  • 분기 처리 제거: 조건문을 사용한 분기 처리가 필요 없다. 각 도형 클래스에서 고유한 로직을 구현할 수 있어 코드를 간결하게 유지할 수 있다.

  • 유연한 확장성: 새로운 도형을 추가할 때마다 루트 클래스를 건드릴 필요 없이 독립적인 하위 클래스를 추가하면 된다.

  • 컴파일 타임 검증: 컴파일러가 각 클래스가 필요한 필드를 모두 초기화하고, 추상 메서드를 구현했는지 확인해준다. 따라서 실수로 인한 런타임 오류가 줄어든다.

3) 추가적인 확장 (정사각형 예시)

계층 구조로 설계했을 경우, 새로운 도형을 쉽게 확장할 수 있다. 예를 들어, 정사각형을 사각형의 특별한 형태로 정의할 수 있다.

class Square extends Rectangle {
    Square(double side) {
        super(side, side);  // 정사각형은 길이와 너비가 동일
    }
}

3. 추상 클래스를 쓰는 때

아이템 20에서는 추상클래스보다는 인터페이스를 고려하라고 했는데..? 왜 위에 계층 구조일 때는 추상클래스를 이용한 걸까? 🤔

추상 클래스를 사용하는 이유는 상황에 따라 다르다. 위에서 인터페이스가 더 유연하고 다중 상속이 가능하다고 했지만, 추상 클래스가 필요한 경우도 있다. 추상 클래스를 선택하는 주요 이유를 몇 가지 설명해보자

1) 공통 상태나 구현이 필요할 때

추상 클래스를 사용하는 가장 큰 이유는 공통 상태(필드)를 공유하거나 기본 구현을 제공하기 위해서이다.

예를 들어, 여러 서브 클래스들이 공통적으로 사용할 인스턴스 변수메서드가 있다면, 추상 클래스로 이 부분을 상속받아 사용하게 할 수 있다.

  • 예시: 모든 도형 클래스에서 색깔이나 위치 등의 공통 필드를 유지하고 싶다면, 추상 클래스가 적합하다. 이렇게 하면 공통된 상태를 상속받아 사용할 수 있고, 코드 중복을 줄일 수 있다.

abstract class Shape {
    String color;  // 공통 상태 (필드)
    
    // 공통 구현을 위한 메서드
    void setColor(String color) {
        this.color = color;
    }

    abstract double area();  // 서브 클래스가 구현해야 하는 추상 메서드
}

이 경우 모든 도형은 색깔을 갖고 있고, 색깔을 변경할 수 있다. 이 기능을 모든 도형 서브 클래스에 일일이 구현하는 대신, 추상 클래스를 통해 공통으로 상속받는다.

2) 공통된 행동(메서드)을 정의할 때

인터페이스상태(필드)를 가질 수 없고, 메서드의 기본 구현을 제공할 수 있는 디폴트 메서드도 일부 제한적이다. 하지만 추상 클래스는 상속받는 클래스에 기본 메서드 구현을 제공할 수 있다.

예를 들어, 도형 클래스에서 면적을 구하는 area() 메서드는 각 도형마다 다르게 구현되겠지만, toString() 같은 공통적인 동작은 추상 클래스에서 미리 정의해줄 수 있다.

abstract class Shape {
    abstract double area();

    @Override
    public String toString() {
        return "This is a shape";
    }
}

모든 도형 서브 클래스는 toString() 메서드를 자동으로 상속받아 사용할 수 있게 된다. 만약 인터페이스였다면 이런 공통적인 구현을 제공하기가 어렵다.

3) 상속의 편리함

추상 클래스는 상속받는 클래스들이 자동으로 기본 구현을 가지게 하고, 필요한 부분만 오버라이드할 수 있게 도와준다. 이는 코드 중복을 줄이고, 일관된 방식으로 기능을 제공하는 데 유리하다.

예를 들어, 자바의 AbstractList, AbstractMap 같은 추상 클래스는 모든 리스트나 맵 구현체가 공통적으로 사용하는 메서드를 미리 정의하고, 각 구현체는 그 메서드를 상속받아 사용할 수 있다. 이는 구체적인 구현을 간편하게 만들 수 있게 해준다.

4) 제한된 상속

추상 클래스는 클래스 계층구조 내에서 좀 더 엄격하게 상속을 제한하고자 할 때 사용된다. 인터페이스는 아무 클래스나 구현할 수 있지만, 추상 클래스는 하나의 부모 클래스만 상속할 수 있기 때문에, 일관된 계층구조를 유지하고 싶을 때 사용된다.

예를 들어, Shape라는 추상 클래스가 있다면, 이 추상 클래스를 상속받은 Circle, Rectangle 등은 모두 같은 상속 구조를 공유하게 된다. 이는 코드 유지보수성과 일관성을 높이는 데 기여할 수 있다.

5) 공통 인터페이스를 위한 골격 구현 제공

추상 클래스는 인터페이스와 함께 골격 구현(Skeletal Implementation)을 제공하는 데 매우 유용하다.

즉, 인터페이스로는 타입을 정의하고, 추상 클래스로는 기본 동작을 정의해서, 서브 클래스는 필요한 부분만 구현하면 된다.

예를 들어, 자바의 AbstractListList 인터페이스를 구현한 추상 클래스로, List의 많은 메서드를 기본적으로 구현하고 있다. 이렇게 하면 새로운 List 구현체를 만들 때, AbstractList를 상속받아서 편리하게 구현할 수 있다.

abstract class AbstractList<E> implements List<E> {
    @Override
    public boolean isEmpty() {
        return size() == 0;
    }
    
    @Override
    public boolean contains(Object o) {
        for (E e : this) {
            if (e.equals(o)) return true;
        }
        return false;
    }
    
    // 더 많은 메서드를 기본 구현으로 제공
}

이 방식은 새로운 List 구현체를 만들 때, 핵심 메서드만 구현하고 나머지는 자동으로 상속받을 수 있게 해준니다.

결론: 왜 추상 클래스를 사용하나?

인터페이스가 더 유연하고 다중 상속이 가능하더라도, 공통 상태(필드)를 공유하거나 기본 동작을 제공해야 하는 상황에서는 추상 클래스가 더 적합하다. 특히 공통된 데이터나 구현을 상속하고, 여러 하위 클래스에서 공통적으로 사용하는 공통 동작을 유지해야 할 때는 추상 클래스가 더 적합한 선택이 된다.

결론적으로, 상황에 따라 추상 클래스가 더 적합할 때가 있고, 이런 경우라면 추상 클래스를 선택하는 것이 좋다.

4. 인터페이스를 통한 서브타이핑

인터페이스를 사용한 계층 구조도 서브타이핑(subtyping)이라고 할 수 있다.

서브타이핑은 객체 지향 프로그래밍에서 하위 타입(subtype)이 상위 타입(supertype)의 역할을 할 수 있는 관계를 의미한다.

즉, 상위 타입의 메서드를 하위 타입이 모두 구현해서 하위 타입 객체가 상위 타입으로 동작할 수 있는 상황을 말합니다.

1) 서브타이핑과 인터페이스

서브타이핑의 핵심은 "하위 타입이 상위 타입의 모든 동작을 지원해야 한다"는 점이다. 인터페이스는 이 서브타이핑의 규칙을 따르는 대표적인 수단 중 하나이다. 인터페이스는 클래스가 반드시 구현해야 할 메서드들의 집합을 정의하므로, 그 인터페이스를 구현한 클래스들은 인터페이스 타입으로 참조할 수 있다.

즉, 인터페이스를 기반으로 계층 구조를 만들면, 상위 인터페이스 타입을 구현한 하위 클래스가 상위 인터페이스로 동작할 수 있기 때문에, 이것도 서브타이핑에 해당한다.

예시

interface Shape {
    double area(); // 모든 도형이 구현해야 하는 메서드
}

class Circle implements Shape {
    private double radius;

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

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

class Rectangle implements Shape {
    private double length;
    private double width;

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

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

public class Main {
    public static void main(String[] args) {
        Shape circle = new Circle(5);
        Shape rectangle = new Rectangle(4, 6);

        System.out.println("Circle Area: " + circle.area());
        System.out.println("Rectangle Area: " + rectangle.area());
    }
}

위 코드에서 Shape는 인터페이스이고, CircleRectangleShape 인터페이스를 구현한 클래스들이다. CircleRectangle 객체는 모두 Shape 타입으로 참조될 수 있고, Shape 인터페이스에 정의된 area() 메서드를 사용할 수 있다. 이것이 바로 서브타이핑

2) 추상 클래스 vs 인터페이스: 서브타이핑 관점

  • 추상 클래스는 공통된 상태(필드)와 구현을 공유하는 데 강점이 있지만, 단일 상속만 가능하다. 즉, 한 클래스가 하나의 부모 추상 클래스만 가질 수 있다.

  • 인터페이스는 상태를 가질 수 없지만, 다양한 구현체가 동일한 동작을 보장하도록 다중 상속이 가능하다. 즉, 하나의 클래스가 여러 개의 인터페이스를 구현할 수 있습니다.

둘 다 서브타이핑을 지원하지만, 인터페이스는 더 유연한 계층 구조를 설계할 수 있게 도와준다.

서브타이핑은 상위 타입(인터페이스나 추상 클래스)의 동작을 하위 타입이 구현해서 상위 타입처럼 동작할 수 있는 관계를 말하며, 인터페이스는 서브타이핑을 구현하는 중요한 방법 중 하나이다.

5. 스터디에서 추가 설명 : API SERVER APPLICATION 에서 Entity를 클래스 계층구조로 만드려면

출처 : https://antique-banon-928.notion.site/Effective-Java-20-25-10-17-11d82aee42598045ab1ded57b536635b

1) 퍼시스턴스 레이어 활용

퍼시스턴스 레이어에서 단순히 DB에 저장되어 있는 값을 꺼내오는 역할만 하지 않고, 계층구조의 타입으로 변환해주는 역할을 해준다.

레이어드 아키텍쳐

퍼시스턴스 레이어로 Repository, Dao Layer 총 두계층으로 나눈다.Dao는 DB에서 값을 꺼내오는 역할을, Repository는 계층구조의 타입으로 전환해주는 역할을 맡는다.

예시

이미지 퀴즈, 텍스트 퀴즈 총 2 종류의 퀴즈가 존재한다.

Dao

  • QuizEntity 타입값을 가지게 되기 때문에 객체지향적으로 좋다는 것

@Repository
@RequiredArgsConstructor
public class QuizDao {

    private final JPAQueryFactory queryFactory;
    
    public List<QuizEntity> findQuiz(QuizType quizType) {
        return queryFactory
                .select(quiz)
                .from(quiz)
                .where(quiz.type.eq(quizType))
                .fetch();
    }
}

Repository

@Repository
@RequiredArgsConstructor
public class QuizRepository {

    private final QuizDao quizDao;

    public List<TextQuiz> findTextQuiz() {
        return quizDao.findQuiz(QuizType.TEXT)
                .map(TextQuiz::from).collect(Collectors.toList());
    }

    public List<ImageQuiz> findImageQuiz() {
        return quizDao.findQuiz(QuizType.IMAGE)
                .map(ImageQuiz::from).collect(Collectors.toList());
    }
}

최종 정리

  • 태그 달린 클래스를 써야 하는 상황은 거의 없다. 새로운 클래스를 작성하는 데 태그필드가 등장한다면 태그를 없애고 계층구조로 대체하는 방법을 생각해보자.

  • 클래스 계층구조로 변환함으로써 코드가 더 간결해지고, 각 클래스의 역할이 명확해졌다. 태그 달린 클래스는 유지보수와 확장성이 떨어질 수 있지만, 클래스 계층구조로 변환하면 이러한 문제들을 해결할 수 있다.

기존 클래스가 태그 필드를 사용하고 있다면 계층구조로 리팩터링하는 걸 고민해보자.

Last updated