이펙티브 자바 완벽 공략 2부

아이템 23. 태그 달린 클래스보다는 클래스 계층구조를 활용하라.

아이템 23. 태그 달린 클래스보다는 클래스 계층구조를 활용하라.


아이템 23. 핵심 정리

아이템 23. 태그 달린 클래스보다는 클래스 계층 구조를 활용하라

하나의 클래스가 서로 다른 종류의 객체를 모두 표현하기 위해 내부에 타입을 구분하는 필드를 두는 경우가 있다.

예를 들어 여러 종류의 도형을 하나의 Figure 클래스로 표현한다고 생각해보자.

class Figure {

    enum Shape {
        RECTANGLE,
        CIRCLE
    }

    final Shape shape;

    // Circle에서 사용
    double radius;

    // Rectangle에서 사용
    double length;
    double width;

    // ...
}

여기에서 shape은 현재 Figure 객체가 어떤 도형인지를 나타낸다.

shape == RECTANGLE
→ 사각형

shape == CIRCLE
→ 원

이처럼 객체의 구체적인 종류를 나타내기 위해 사용되는 필드를 태그(tag) 라고 할 수 있다.

그리고 이런 구조를 가진 클래스를 흔히 태그 달린 클래스(Tagged Class) 라고 부른다.

처음에는 하나의 클래스에서 여러 종류를 처리할 수 있기 때문에 편리해 보인다.

하지만 타입이 늘어나고 기능이 복잡해질수록 여러 문제가 발생한다.


태그 달린 클래스의 기본 구조

먼저 실제 코드를 만들어보자.

public 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);
        }
    }
}

하나의 Figure 클래스가 두 종류의 객체를 모두 표현한다.

Figure

├─ Rectangle 역할
└─ Circle 역할

어떤 역할을 하는지는 다음 필드가 결정한다.

final Shape shape;

객체의 타입이 아니라 필드가 타입을 결정한다

다음 두 객체를 만들어보자.

Figure circle =
        new Figure(10);

Figure rectangle =
        new Figure(10, 20);

둘의 Java 타입은 모두 같다.

circle.getClass()
→ Figure

rectangle.getClass()
→ Figure

하지만 내부적으로는 shape 값에 따라 서로 다른 객체처럼 동작한다.

circle

Figure
shape = CIRCLE
radius = 10
rectangle

Figure
shape = RECTANGLE
length = 10
width = 20

즉 실제 타입 구분을 클래스 계층이 아니라 객체 내부의 데이터가 담당하고 있다.


첫 번째 문제: 서로 관련 없는 필드가 하나의 클래스에 모인다

원에는 반지름이 필요하다.

double radius;

사각형에는 가로와 세로가 필요하다.

double length;
double width;

그런데 태그 기반 구조에서는 이 모든 필드를 하나의 클래스에 넣게 된다.

class Figure {

    double radius;

    double length;
    double width;
}

문제는 Circle 입장에서 생각해보면 명확하다.

Circle에게 필요한 값

radius

그런데 객체에는 다음 필드까지 존재한다.

length
width

전혀 필요하지 않다.

반대로 Rectangle 입장에서는

radius

가 필요 없다.

즉 하나의 클래스 안에 서로 다른 타입의 구현 세부사항이 뒤섞인다.


두 번째 문제: 코드의 가독성이 떨어진다

처음에는 타입이 두 개뿐이라 큰 문제가 없어 보인다.

RECTANGLE
CIRCLE

하지만 타입이 늘어나기 시작하면 어떻게 될까?

RECTANGLE

CIRCLE

SQUARE

TRIANGLE

OVAL

POLYGON

필드도 계속 늘어난다.

double radius;

double length;
double width;

double side;

double base;
double height;

double majorAxis;
double minorAxis;

생성자도 늘어난다.

Figure(double radius)

Figure(double length, double width)

Figure(...)

Figure(...)

그리고 메서드마다 타입을 확인해야 한다.

switch (shape) {

    case CIRCLE:
        ...

    case RECTANGLE:
        ...

    case SQUARE:
        ...

    case TRIANGLE:
        ...
}

한 클래스가 여러 타입의 책임을 모두 가지기 때문에 점점 이해하기 어려워진다.


메서드가 많아질수록 더 심각해진다

면적만 계산한다면 아직 코드가 단순하다.

하지만 도형에 다음 기능들이 필요해졌다고 생각해보자.

area()

perimeter()

draw()

resize()

rotate()

validate()

모든 메서드가 다음과 같은 구조를 가지기 시작할 수 있다.

switch (shape) {

    case RECTANGLE:
        // Rectangle 로직
        break;

    case CIRCLE:
        // Circle 로직
        break;

    case SQUARE:
        // Square 로직
        break;
}

그리고 또 다른 메서드에서도 반복된다.

switch (shape) {
    ...
}

또 다른 메서드에서도 반복된다.

switch (shape) {
    ...
}

결국 하나의 타입을 추가할 때 클래스 전체를 돌아다니며 수정해야 하는 구조가 된다.


세 번째 문제: 불필요한 상태까지 객체가 가진다

메모리 관점에서도 불필요한 상태가 생긴다.

Circle을 표현하는 Figure를 만들었다고 하자.

실제로 필요한 값은 다음뿐이다.

shape
radius

그런데 클래스의 구조상 다음 필드도 객체에 존재한다.

length
width

반대로 Rectangleradius가 필요 없다.

Rectangle

필요
→ length
→ width

불필요
→ radius

타입마다 필요한 데이터가 다른데 하나의 클래스에서 전부 관리하기 때문에 객체의 상태가 깔끔하지 않다.


네 번째 문제: final 필드 사용도 번거로워진다

객체를 불변으로 만들기 위해 모든 필드를 final로 만들고 싶다고 해보자.

final double radius;

final double length;

final double width;

Java의 final 필드는 생성 과정에서 반드시 초기화해야 한다.

그런데 Circle을 만드는 생성자에서는 length, width가 필요 없다.

그럼에도 초기화해야 한다.

Figure(double radius) {

    this.shape = Shape.CIRCLE;

    this.radius = radius;

    this.length = 0;
    this.width = 0;
}

반대로 Rectangle에서는 radius를 억지로 초기화해야 한다.

Figure(
        double length,
        double width
) {

    this.shape = Shape.RECTANGLE;

    this.length = length;
    this.width = width;

    this.radius = 0;
}

객체의 실제 의미와 관계없는 값이 생긴다.

Circle인데

length = 0
width = 0

이 값들은 정상적인 상태를 나타내는 데이터가 아니다.

그저 클래스 구조 때문에 존재하는 값이다.


다섯 번째 문제: 타입만 보고 객체의 종류를 알 수 없다

다음 두 변수를 보자.

Figure figure1;
Figure figure2;

변수의 타입만 봐서는 어떤 도형인지 알 수 없다.

실제 객체를 확인하려면 내부 태그를 봐야 한다.

figure.shape

즉 타입 시스템이 알고 있어야 할 정보를 객체 내부의 값으로 다시 표현하고 있는 셈이다.

Java 타입

Figure

만으로는 부족하고

런타임 데이터

shape = CIRCLE

까지 확인해야 한다.


여섯 번째 문제: 잘못된 상태를 만들 가능성이 있다

태그와 필드 조합이 복잡해지면 유효하지 않은 객체 상태를 만들 가능성도 커진다.

예를 들어 다음 상태를 생각해보자.

shape = CIRCLE

radius = 10

length = 100
width = 200

Circle인데 사각형 정보까지 들어 있다.

또는 더 심각하게

shape = RECTANGLE

length = 0
width = 0

radius = 100

같은 상태가 만들어질 수도 있다.

즉 하나의 객체가 가질 수 있는 상태 조합이 지나치게 많아진다.


타입이 추가되면 문제가 더 커진다

이제 Square를 추가해야 한다고 생각해보자.

먼저 enum을 수정한다.

enum Shape {
    RECTANGLE,
    CIRCLE,
    SQUARE
}

Square에는 변의 길이가 필요하므로 필드를 하나 추가할 수도 있다.

double side;

그런데 다시 생각해보면 Rectangle

length
width

를 그대로 사용해도 될 것 같다.

Square는

length == width

인 Rectangle이라고 생각할 수도 있다.

그렇다면 생성자는 어떻게 만들어야 할까?

기존에

Figure(double radius)

가 이미 Circle 용도로 사용되고 있다면

Figure(double side)

를 Square 생성자로 추가할 수 없다.

둘의 시그니처가 동일하기 때문이다.

Figure(double)

Java는 둘을 구분할 수 없다.


결국 조건문이 더 늘어난다

그러면 이런 식의 코드를 만들게 될 가능성이 있다.

if (length == width) {
    shape = Shape.SQUARE;
} else {
    shape = Shape.RECTANGLE;
}

그리고 면적 계산에서도

switch (shape) {

    case RECTANGLE:
    case SQUARE:
        return length * width;

    case CIRCLE:
        return Math.PI
                * radius
                * radius;
}

처럼 새로운 분기가 추가된다.

타입 하나를 추가했을 뿐인데

enum 수정

필드 수정

생성자 수정

초기화 로직 수정

area() 수정

다른 메서드의 switch 수정

등 기존 클래스 여러 부분을 함께 건드리게 된다.


태그 달린 클래스는 새로운 타입이 추가될수록 복잡해진다

전체적인 변화는 다음과 같다.

처음

Figure
├─ RECTANGLE
└─ CIRCLE

새 타입을 추가하면

Figure
├─ RECTANGLE
├─ CIRCLE
└─ SQUARE

하지만 실제 코드는 단순히 enum 값 하나만 늘어나는 것이 아니다.

Shape 추가
   ↓
필드 추가 또는 재사용 고민
   ↓
생성자 변경
   ↓
초기화 조건 추가
   ↓
switch 변경
   ↓
관련 메서드 모두 변경

타입이 하나 추가될 때마다 기존 클래스가 점점 비대해진다.


클래스 계층 구조를 사용하면 문제를 해결할 수 있다

이 문제는 상속을 이용한 클래스 계층 구조로 바꾸면 훨씬 자연스럽게 해결할 수 있다.

먼저 모든 도형의 공통적인 부분만 상위 클래스에 남긴다.

public abstract class Figure {

    public abstract double area();
}

Figure는 이제 특정 도형의 데이터를 직접 가지고 있지 않는다.

radius

length

width

같은 필드가 없다.

오직

모든 Figure는
자신의 면적을 계산할 수 있다.

라는 공통적인 계약만 정의한다.


Rectangle은 Rectangle의 상태만 가진다

public class Rectangle
        extends Figure {

    private final double length;
    private final double width;

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

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

Rectangle 객체에는 정말 필요한 상태만 존재한다.

length
width

radius는 존재하지 않는다.


Circle은 Circle의 상태만 가진다

public class Circle
        extends Figure {

    private final double radius;

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

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

Circle에는 오직 다음 상태만 있다.

radius

Rectangle과 관련된

length
width

는 존재하지 않는다.


태그 필드가 사라진다

기존에는 객체의 종류를 다음 값으로 판별했다.

shape == Shape.CIRCLE

하지만 클래스 계층 구조에서는 객체 타입 자체가 정보를 가지고 있다.

Figure circle =
        new Circle(10);

Figure rectangle =
        new Rectangle(10, 20);

구조는 다음과 같다.

Figure
   ↑
   ├─ Circle
   └─ Rectangle

이제

Circle은 Circle이다.

Rectangle은 Rectangle이다.

타입을 나타내기 위한 별도의 태그가 필요 없다.


switch문도 사라진다

태그 기반 구현에서는 다음 코드가 필요했다.

double area() {

    switch (shape) {

        case RECTANGLE:
            return length * width;

        case CIRCLE:
            return Math.PI
                    * radius
                    * radius;

        default:
            throw new AssertionError(shape);
    }
}

클래스 계층 구조에서는 각 타입이 자신의 계산을 담당한다.

public class Rectangle
        extends Figure {

    @Override
    public double area() {
        return length * width;
    }
}
public class Circle
        extends Figure {

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

이제 클라이언트는 단순히 호출하면 된다.

Figure figure =
        new Circle(10);

double area =
        figure.area();

어떤 구현을 사용할지는 다형성이 결정한다.


조건 분기가 다형성으로 바뀐다

두 설계를 비교해보자.

태그 기반

Figure.area()

↓

shape 확인

↓

if / switch

↓

타입에 맞는 코드 실행

클래스 계층 구조

Figure.area()

↓

동적 디스패치

↓

Circle.area()
또는
Rectangle.area()

즉 다음 변화가 일어난다.

조건문을 이용한 타입 구분

↓

다형성을 이용한 타입별 동작

객체지향적인 클래스 계층 구조가 잘 맞는 대표적인 상황이다.


새로운 타입을 추가해보자

이번에는 Square를 추가해보자.

태그 기반 클래스에서는 기존 Figure를 여러 곳 수정해야 했다.

클래스 계층 구조에서는 새로운 클래스만 만들 수 있다.

public class Square
        extends Figure {

    private final double side;

    public Square(double side) {
        this.side = side;
    }

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

기존 Figure는 수정하지 않는다.

Circle도 수정하지 않는다.

Rectangle도 수정하지 않는다.

기존 코드

Figure
Circle
Rectangle

변경 없음


새 코드

Square 추가

확장성이 훨씬 좋아진다.


상황에 따라 Rectangle을 활용할 수도 있다

Square를 Rectangle의 특수한 형태로 모델링하고, Rectangle의 설계가 상속에 적합하다면 기존 구현을 재사용하는 방식도 생각할 수 있다.

public class Square
        extends Rectangle {

    public Square(double side) {
        super(side, side);
    }
}

그러면 면적 계산도 Rectangle의 구현을 그대로 사용할 수 있다.

Square
   ↓
Rectangle
   ↓
Figure

다만 클래스 사이에 실제로 자연스러운 하위 타입 관계가 성립하고 상위 클래스가 상속을 고려해 설계되었는지는 별도로 검토해야 한다.

단순히 코드 몇 줄을 재사용하기 위해 상속을 선택해서는 안 된다.


클래스 계층 구조의 장점

태그 기반 구조를 클래스 계층으로 바꾸면 여러 문제가 자연스럽게 해결된다.

불필요한 필드가 사라진다

Circle

radius만 존재
Rectangle

length
width만 존재

생성자가 단순해진다

new Circle(10);
new Rectangle(10, 20);

각 타입에 필요한 정보만 받는다.


final 필드를 자연스럽게 사용할 수 있다

private final double radius;
private final double length;
private final double width;

필요하지 않은 필드에 의미 없는 기본값을 넣지 않아도 된다.


객체 타입이 명확하다

Circle
Rectangle

객체의 종류가 클래스 자체로 표현된다.

별도의

shape

필드가 필요 없다.


조건문이 줄어든다

if (shape == ...)

switch (shape)

같은 분기 로직이 다형성으로 대체된다.


새로운 타입을 추가하기 쉽다

새로운 타입을 표현하는 새로운 클래스를 추가하면 된다.

class Triangle extends Figure {
}

기존 타입들의 코드에 새로운 조건을 계속 끼워 넣을 필요가 줄어든다.


if와 switch가 많다면 설계를 의심해볼 수 있다

이 아이템은 리팩터링 관점에서도 중요한 힌트를 준다.

어떤 클래스 안에 다음과 같은 코드가 반복적으로 나타난다고 생각해보자.

switch (type) {
    ...
}

또는

if (type == A) {
    ...
} else if (type == B) {
    ...
} else if (type == C) {
    ...
}

한 번 등장한다고 해서 무조건 잘못된 설계는 아니다.

하지만 동일한 타입 구분을 기준으로 여러 메서드에서 반복적으로 분기하고 있다면 한번 의심해볼 필요가 있다.

calculate()

→ switch(type)


validate()

→ switch(type)


save()

→ switch(type)


send()

→ switch(type)

이 구조는 실제로는 서로 다른 객체 타입들을 하나의 클래스에 억지로 모아놓은 것일 수 있다.


타입 코드 냄새와 연결해서 생각할 수 있다

다음과 같은 필드가 있다고 해보자.

private Type type;

그리고 클래스 여러 곳에서

switch (type)

이 반복된다면 type이 사실상 타입 코드(Type Code) 역할을 하고 있을 가능성이 있다.

type = CARD

type = PAYPAL

type = BANK_TRANSFER

이런 구조가 크고 복잡해지면

Payment

├─ CardPayment
├─ PayPalPayment
└─ BankTransferPayment

같은 타입 계층으로 나누는 것을 검토할 수 있다.

핵심은

"이 조건문을 없애자."

가 아니라

"이 조건문이 사실 서로 다른 타입의 존재를 나타내고 있는 것은 아닌가?"

를 생각하는 것이다.


실무에서 흔히 볼 수 있는 형태

예를 들어 결제 객체가 있다고 생각해보자.

public class Payment {

    private PaymentType type;

    private String cardNumber;

    private String paypalAccount;

    private String bankAccount;

    public void pay() {

        switch (type) {

            case CARD:
                // 카드 결제
                break;

            case PAYPAL:
                // PayPal 결제
                break;

            case BANK_TRANSFER:
                // 계좌이체
                break;
        }
    }
}

각 결제 타입마다 필요한 데이터가 전혀 다르다.

CARD
→ cardNumber

PAYPAL
→ paypalAccount

BANK_TRANSFER
→ bankAccount

그런데 모든 필드가 하나의 클래스에 존재한다.

Figure와 매우 비슷한 구조다.


클래스 계층으로 분리할 수 있다

public abstract class Payment {

    public abstract void pay();
}

카드 결제는 자신의 데이터만 가진다.

public class CardPayment
        extends Payment {

    private final String cardNumber;

    public CardPayment(
            String cardNumber
    ) {
        this.cardNumber = cardNumber;
    }

    @Override
    public void pay() {
        // 카드 결제
    }
}

PayPal도 마찬가지다.

public class PayPalPayment
        extends Payment {

    private final String paypalAccount;

    public PayPalPayment(
            String paypalAccount
    ) {
        this.paypalAccount =
                paypalAccount;
    }

    @Override
    public void pay() {
        // PayPal 결제
    }
}

이제

paymentType

이라는 태그가 필요하지 않다.

각 객체의 타입 자체가 어떤 결제인지 표현한다.


잘못된 상태도 줄어든다

태그 구조에서는 다음과 같은 객체를 만들 가능성이 있다.

type = CARD

cardNumber = null

paypalAccount = "abc@test.com"

bankAccount = null

타입은 CARD인데 PayPal 정보가 들어 있다.

객체 상태가 모순된다.

클래스 계층으로 분리하면

new CardPayment(cardNumber);

자체가 카드 결제 객체다.

PayPal 필드 자체가 존재하지 않는다.

따라서 표현 가능한 잘못된 상태의 수를 줄일 수 있다.


상태 공간을 줄이는 설계

이 관점은 객체 설계에서 중요하다.

태그 클래스는 하나의 객체가 매우 많은 상태 조합을 가질 수 있다.

shape

radius

length

width

side

각 필드의 값 조합을 모두 고려해야 한다.

하지만 타입을 나누면

Circle

radius
Rectangle

length
width

처럼 각 객체가 표현할 수 있는 상태가 단순해진다.

즉 다음 원칙과 연결된다.

만들 수 있는 잘못된 상태 자체를 줄이는 것이 좋은 객체 설계다.


그렇다고 모든 enum과 switch를 상속으로 바꿔야 하는 것은 아니다

중요한 점도 있다.

다음 코드가 있다고 해서

switch (status) {
}

무조건 클래스 계층으로 분리해야 하는 것은 아니다.

단순한 상태 전이처럼 enum이 훨씬 자연스러운 문제도 있다.

OrderStatus

CREATED
PAID
SHIPPED
COMPLETED

이들은 서로 다른 객체 타입이라기보다 하나의 Order가 시간에 따라 가지는 상태일 수 있다.

CREATED
   ↓
PAID
   ↓
SHIPPED

이런 경우에는 태그 기반 타입 구분과 성격이 다르다.

따라서 중요한 질문은 다음이다.

이 값은 객체가 현재 가지는 상태인가?

아니면 실제로 서로 다른 종류의 객체를
억지로 하나의 클래스에 표현한 것인가?

후자라면 클래스 계층 구조를 검토할 가치가 크다.


상태와 타입을 구분하자

예를 들어 주문 상태는 하나의 객체가 변하면서 달라질 수 있다.

Order

CREATED
↓
PAID
↓
SHIPPED

반면 Circle이 어느 순간 Rectangle로 변하는 것이 일반적인 모델은 아니다.

Circle

≠

Rectangle

처음부터 다른 종류의 객체다.

이런 차이를 기준으로 태그 클래스 여부를 판단할 수 있다.


아이템 18과 모순되는 것은 아니다

앞에서는

상속보다는 합성을 사용하라.

라고 했다.

이번에는 오히려 상속을 이용해 문제를 해결하고 있다.

모순처럼 보일 수 있지만 두 아이템이 다루는 문제는 다르다.

아이템 18에서 경계했던 것은 구현 재사용을 목적으로 구체 클래스를 함부로 상속하는 것이다.

이번 구조는

Figure
   ↑
Circle

Figure
   ↑
Rectangle

처럼 실제 타입 계층을 표현하기 위해 상속을 사용한다.

Circle is-a Figure
Rectangle is-a Figure

라는 관계가 자연스럽다.

이런 상황은 상속과 다형성이 잘 어울리는 대표적인 사례다.


상속은 타입 계층을 표현할 때 강력하다

상속을 사용할지 고민할 때 다음 두 상황을 구분하면 이해하기 쉽다.

단순 구현 재사용

"부모 클래스에 코드가 있으니까
그거 쓰려고 extends 하자."

이런 접근은 신중해야 한다.

진짜 타입 계층

Circle is-a Figure

Rectangle is-a Figure

의미적으로 자연스러운 하위 타입 관계를 표현한다면 상속이 좋은 선택이 될 수 있다.

즉 상속 자체가 문제인 것이 아니라 어떤 목적으로 상속하는가가 중요하다.


Figure는 상속을 고려해서 설계해야 한다

Figure가 하위 클래스 확장을 목적으로 만들어졌다면 상속용 클래스가 된다.

public abstract class Figure {

    public abstract double area();
}

따라서 이전 아이템에서 살펴본 상속 설계 원칙도 함께 생각해야 한다.

예를 들어

어떤 메서드를 재정의하도록 허용할 것인가?

어떤 동작을 하위 클래스가 알아야 하는가?

생성 과정에서 재정의 가능한 메서드를 호출하고 있지는 않은가?

외부에서 새로운 하위 클래스를 만드는 것이 자연스러운가?

등을 고려해야 한다.

즉 태그 클래스를 단순히 상속 구조로 바꾼다고 모든 설계 문제가 자동으로 해결되는 것은 아니다.

상속을 선택했다면 상속을 위한 클래스답게 설계해야 한다.


태그 클래스와 클래스 계층 구조 비교

구분태그 달린 클래스클래스 계층 구조
타입 구분필드 값실제 클래스 타입
데이터모든 타입의 필드가 한 클래스에 존재각 타입에 필요한 필드만 존재
타입별 동작if, switch메서드 오버라이딩
새로운 타입 추가기존 클래스 여러 부분 수정새로운 하위 클래스 추가
불변 객체 설계불필요한 필드 초기화 가능필요한 필드만 final
잘못된 상태만들기 쉬움상대적으로 줄어듦
가독성타입 증가 시 급격히 저하타입별 코드 분리
유지보수하나의 클래스가 계속 커짐책임별 클래스 분리
다형성활용하기 어려움자연스럽게 활용 가능

태그 달린 클래스를 발견했을 때 리팩터링 방향

다음과 같은 코드가 반복적으로 보인다고 생각해보자.

if (type == A) {
    ...
} else if (type == B) {
    ...
}

또는

switch (type) {

    case A:
        ...

    case B:
        ...

    case C:
        ...
}

그렇다면 다음 순서로 생각해볼 수 있다.

1. type이 실제 객체 종류를 나타내는가?

↓

2. 타입마다 사용하는 필드가 다른가?

↓

3. 타입마다 동작도 다른가?

↓

4. 같은 switch가 여러 메서드에서 반복되는가?

↓

5. 새로운 타입이 추가될 때
   기존 클래스 여러 곳을 수정해야 하는가?

여러 항목에 해당한다면 각각을 별도의 클래스로 분리하는 방향을 검토할 수 있다.


클래스 계층 구조로 분리하는 과정

태그 클래스를 발견했다고 가정해보자.

Figure

shape
radius
length
width

area()

먼저 모든 타입에 공통적인 부분을 찾는다.

모든 Figure는 area()를 가진다.

상위 타입으로 이동한다.

abstract class Figure {

    abstract double area();
}

그다음 타입별 데이터를 각각의 클래스로 이동한다.

Circle

radius
Rectangle

length
width

마지막으로 타입별 조건 로직을 각각의 클래스 메서드로 이동한다.

switch(shape)

↓

Circle.area()

Rectangle.area()

구조가 훨씬 단순해진다.


전체 변화

리팩터링 전에는 다음과 같다.

Figure

├─ shape
├─ radius
├─ length
├─ width
│
└─ area()
      │
      └─ switch(shape)

리팩터링 후에는 다음과 같다.

        Figure
          │
        area()
        /    \
       /      \
  Circle     Rectangle
    │            │
 radius      length, width
    │            │
 area()       area()

타입 정보와 행동이 각 클래스 안에서 함께 관리된다.


이 설계가 객체지향적으로 더 자연스러운 이유

태그 기반 코드는 외부 코드가 객체 종류를 확인해서 행동을 결정한다.

타입 확인

↓

행동 선택

클래스 계층 구조에서는 객체 자신이 자신의 행동을 알고 있다.

객체에게 요청

↓

객체가 자신의 타입에 맞는 동작 실행

예를 들어 클라이언트는 다음만 알고 있으면 된다.

double area =
        figure.area();

이 객체가 Circle인지 Rectangle인지 확인할 필요가 없다.

이것이 다형성이 제공하는 중요한 장점이다.


핵심 정리

  • 태그 달린 클래스는 하나의 클래스가 여러 종류의 객체를 표현하기 위해 타입을 구분하는 필드를 가지고 있는 구조다.
  • Figure.shape처럼 enum 필드가 객체의 실제 종류를 결정한다면 대표적인 태그 클래스 형태다.
  • 태그 클래스에서는 서로 다른 타입에 필요한 필드와 메서드가 하나의 클래스에 모두 모이게 된다.
  • Circle에서는 필요 없는 Rectangle 필드가 존재하고 Rectangle에서는 필요 없는 Circle 필드가 존재할 수 있다.
  • 이런 구조는 클래스가 커질수록 가독성, 디버깅, 유지보수를 어렵게 만든다.
  • 불필요한 상태를 함께 가지고 있기 때문에 객체 구조도 복잡해진다.
  • 모든 필드를 final로 만들려면 실제 타입에서 사용하지 않는 필드까지 의미 없는 값으로 초기화해야 할 수 있다.
  • 객체 타입 자체는 모두 Figure이므로 실제 종류를 알기 위해 별도의 태그 값을 확인해야 한다.
  • 태그와 필드의 조합이 어긋나는 잘못된 객체 상태를 만들 가능성도 커진다.
  • 새로운 타입이 추가되면 enum, 필드, 생성자, 조건문 등 기존 클래스의 여러 부분을 함께 수정해야 할 가능성이 높다.
  • 클래스 계층 구조를 사용하면 공통적인 부분만 상위 타입에 두고 타입별 상태와 동작을 각각의 하위 클래스로 분리할 수 있다.
  • Circleradius만, Rectanglelength, width만 가지므로 객체 상태가 훨씬 명확해진다.
  • switch(shape)와 같은 조건 분기를 오버라이딩과 동적 디스패치로 대체할 수 있다.
  • 새로운 타입이 필요하면 새로운 하위 클래스를 추가하는 방식으로 확장할 수 있다.
  • 하나의 클래스에서 동일한 타입 필드를 기준으로 ifswitch가 반복된다면 태그 클래스가 숨어 있는지 확인해볼 수 있다.
  • 다만 모든 enum과 조건문을 상속으로 변경해야 하는 것은 아니다.
  • 주문의 CREATED → PAID → SHIPPED처럼 하나의 객체가 시간에 따라 변하는 상태와 처음부터 서로 다른 객체 타입은 구분해야 한다.
  • 아이템 18의 “상속보다 합성을 사용하라”와 모순되는 내용이 아니다.
  • 단순히 구현 코드를 재사용하기 위한 상속은 신중해야 하지만 Circle is-a Figure처럼 실제 타입 계층을 자연스럽게 표현하는 상속은 좋은 활용 사례가 될 수 있다.
  • 상위 클래스를 확장 목적으로 제공한다면 상속용 클래스이므로 상속을 고려한 설계 역시 함께 적용해야 한다.
  • 핵심은 타입을 나타내는 데이터를 객체 안에 넣고 계속 분기하기보다 실제 서로 다른 타입이라면 타입 시스템과 다형성으로 표현하는 것이다.

한 줄 정리

하나의 클래스가 태그 필드와 반복되는 if·switch로 여러 종류의 객체를 동시에 표현하고 있다면 각 타입의 상태와 행동을 별도의 하위 클래스로 분리해, 태그 값이 아니라 실제 타입과 다형성이 객체의 종류와 동작을 표현하도록 만드는 것이 더 명확하고 확장하기 쉬운 설계다.


© 2020. All rights reserved.

SIKSIK