이펙티브 자바 완벽 공략 2부
아이템 23. 태그 달린 클래스보다는 클래스 계층구조를 활용하라.
아이템 23. 태그 달린 클래스보다는 클래스 계층구조를 활용하라.
- 아이템 23. 태그 달린 클래스보다는 클래스 계층구조를 활용하라.
- 아이템 23. 핵심 정리
- 아이템 23. 태그 달린 클래스보다는 클래스 계층 구조를 활용하라
- 태그 달린 클래스의 기본 구조
- 객체의 타입이 아니라 필드가 타입을 결정한다
- 첫 번째 문제: 서로 관련 없는 필드가 하나의 클래스에 모인다
- 두 번째 문제: 코드의 가독성이 떨어진다
- 메서드가 많아질수록 더 심각해진다
- 세 번째 문제: 불필요한 상태까지 객체가 가진다
- 네 번째 문제: final 필드 사용도 번거로워진다
- 다섯 번째 문제: 타입만 보고 객체의 종류를 알 수 없다
- 여섯 번째 문제: 잘못된 상태를 만들 가능성이 있다
- 타입이 추가되면 문제가 더 커진다
- 결국 조건문이 더 늘어난다
- 태그 달린 클래스는 새로운 타입이 추가될수록 복잡해진다
- 클래스 계층 구조를 사용하면 문제를 해결할 수 있다
- Rectangle은 Rectangle의 상태만 가진다
- Circle은 Circle의 상태만 가진다
- 태그 필드가 사라진다
- switch문도 사라진다
- 조건 분기가 다형성으로 바뀐다
- 새로운 타입을 추가해보자
- 상황에 따라 Rectangle을 활용할 수도 있다
- 클래스 계층 구조의 장점
- if와 switch가 많다면 설계를 의심해볼 수 있다
- 타입 코드 냄새와 연결해서 생각할 수 있다
- 실무에서 흔히 볼 수 있는 형태
- 클래스 계층으로 분리할 수 있다
- 잘못된 상태도 줄어든다
- 상태 공간을 줄이는 설계
- 그렇다고 모든 enum과 switch를 상속으로 바꿔야 하는 것은 아니다
- 상태와 타입을 구분하자
- 아이템 18과 모순되는 것은 아니다
- 상속은 타입 계층을 표현할 때 강력하다
- Figure는 상속을 고려해서 설계해야 한다
- 태그 클래스와 클래스 계층 구조 비교
- 태그 달린 클래스를 발견했을 때 리팩터링 방향
- 클래스 계층 구조로 분리하는 과정
- 전체 변화
- 이 설계가 객체지향적으로 더 자연스러운 이유
- 핵심 정리
- 한 줄 정리
- 아이템 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
반대로 Rectangle은 radius가 필요 없다.
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, 필드, 생성자, 조건문 등 기존 클래스의 여러 부분을 함께 수정해야 할 가능성이 높다.
- 클래스 계층 구조를 사용하면 공통적인 부분만 상위 타입에 두고 타입별 상태와 동작을 각각의 하위 클래스로 분리할 수 있다.
Circle은radius만,Rectangle은length,width만 가지므로 객체 상태가 훨씬 명확해진다.switch(shape)와 같은 조건 분기를 오버라이딩과 동적 디스패치로 대체할 수 있다.- 새로운 타입이 필요하면 새로운 하위 클래스를 추가하는 방식으로 확장할 수 있다.
- 하나의 클래스에서 동일한 타입 필드를 기준으로
if나switch가 반복된다면 태그 클래스가 숨어 있는지 확인해볼 수 있다. - 다만 모든 enum과 조건문을 상속으로 변경해야 하는 것은 아니다.
- 주문의
CREATED → PAID → SHIPPED처럼 하나의 객체가 시간에 따라 변하는 상태와 처음부터 서로 다른 객체 타입은 구분해야 한다. - 아이템 18의 “상속보다 합성을 사용하라”와 모순되는 내용이 아니다.
- 단순히 구현 코드를 재사용하기 위한 상속은 신중해야 하지만
Circle is-a Figure처럼 실제 타입 계층을 자연스럽게 표현하는 상속은 좋은 활용 사례가 될 수 있다. - 상위 클래스를 확장 목적으로 제공한다면 상속용 클래스이므로 상속을 고려한 설계 역시 함께 적용해야 한다.
- 핵심은 타입을 나타내는 데이터를 객체 안에 넣고 계속 분기하기보다 실제 서로 다른 타입이라면 타입 시스템과 다형성으로 표현하는 것이다.
한 줄 정리
하나의 클래스가 태그 필드와 반복되는
if·switch로 여러 종류의 객체를 동시에 표현하고 있다면 각 타입의 상태와 행동을 별도의 하위 클래스로 분리해, 태그 값이 아니라 실제 타입과 다형성이 객체의 종류와 동작을 표현하도록 만드는 것이 더 명확하고 확장하기 쉬운 설계다.