들어가며
블랙잭 미션을 진행하던 중 딜러와 플레이어 사이에 존재하는 중복 코드를 마주하게 되었다. 이 중복을 어떻게 제거할지 고민하던 과정에서 가장 먼저 떠올린 방법은 조합이었다. 평소 객체지향 설계 원칙을 이야기할 때 “상속보다 조합을 사용하라”는 말을 자주 들어왔기 때문이다.
조합으로 구성한 결과, 내부 객체의 기능을 외부로 전달하기 위한 위임 메서드가 계속 늘어나고, 기능이 수정될 때마다 여러 곳을 함께 수정해야 하는 문제가 생겼다.
이 경험을 통해 단순히 “상속보다 조합”이라는 원칙을 따르는 것만으로는 좋은 설계가 만들어지지 않는다는 생각이 들었다.
이 글에서는 블랙잭 미션에서 마주한 문제를 바탕으로 조합과 상속이 무엇인지, 그리고 왜 이 상황에서는 조합이 아니라 상속이 더 적절한 선택이 되었는지를 정리해보려고 한다. 또한 어떤 상황에서 조합과 상속을 각각 활용하면 좋을지에 대해 세운 나의 기준도 함께 공유하고자 한다!
상속(Inheritance)
상속은 한 클래스가 다른 클래스의 속성과 동작을 물려받는 계층적 설계로,
코드 재사용 하기 위해 널리 사용되는 방법이다.
특징
- extends 키워드를 사용하여 상속 표현
- 자식 클래스는 부모 클래스의 필드와 메서드를 상속받음
- 자식 클래스는 부모의 메서드를 재정의(Override) 할 수 있음
- 단일상속만 가능
사용 이유
1. 중복 코드를 제거한다
공통 기능을 부모 클래스에 작성하면 여러 클래스에서 재사용할 수 있기에 중복 코드가 제거된다.
그렇다면 중복 코드를 제거하는 것이 왜 중요할까?
코드가 중복되면, 코드를 수정할 때 필요한 노력이 크게 증가되기 때문이다.
- 같은 로직이 여러 곳에 존재하면 수정해야 할 위치가 늘어난다.
기능을 변경할 때 동일한 로직이 있는 모든 곳을 찾아 수정해야 하기 때문에 작업 범위가 커진다. - 수정해야 하는 부분을 놓칠 가능성도 높아진다.
한 곳만 수정하고 다른 곳을 수정하지 않는다면, 동일한 역할을 하는 코드가 서로 다른 동작을 하게 되는 문제가 발생할 수 있다. - 시간이 지나면서 중복된 코드가 서로 다른 방식으로 수정되기도 쉽다.
이렇게 되면 처음에는 동일한 로직이었던 코드가 점점 다른 책임을 가지게 되고, 결국 코드의 일관성이 무너지게 된다. - 테스트 비용도 증가한다.
중복된 로직이 각각 존재하기 때문에 동일한 기능을 검증하기 위한 테스트도 여러 곳에서 작성하거나 실행해야 한다. 이는 테스트 코드의 양과 유지보수 비용을 함께 증가시킨다.
이처럼 중복 코드는 수정 비용, 실수 가능성, 테스트 비용을 모두 증가시키기 때문에 가능한 한 제거하는 것이 중요하다.
따라서 상속은 이러한 공통 로직을 부모 클래스에 모아 중복을 줄일 수 있다는 장점이 존재한다.
2. 서로 다른 객체를 동일하게 다룰 수 있다
다형성을 활용하여 서로 다른 객체를 동일한 타입으로 다룰 수 있다.
예를 들어 결제 카테고리에서 TossPayment와 CardPayment이 Payment라는 공통 부모 클래스를 상속받는다면,
두 객체를 하나의 타입(Payment)으로 묶어 처리할 수 있다.
이를 통해 객체를 사용하는 쪽의 코드가 단순해진다.
각각의 타입을 구분하여 처리하는 대신, 공통 타입을 기준으로 동일한 메시지를 보내는 방식으로 로직을 구성할 수 있기 때문이다.
interface Payment {
void pay(int amount);
}
class CardPayment implements Payment {
@Override
public void pay(int amount) {
System.out.println("카드로 " + amount + "원 결제");
}
}
class TossPayment implements Payment {
@Override
public void pay(int amount) {
System.out.println("토스로 " + amount + "원 결제");
}
}
List<Payment> payments = List.of(
new CardPayment(),
new TossPayment()
);
for (Payment payment : payments) {
payment.pay(10000);
}
또한 새로운 타입이 추가되더라도 기존 코드를 크게 수정하지 않고 확장할 수 있다.
부모 클래스를 기반으로 동작하기 때문에, 동일한 규약을 따르는 새로운 객체를 추가하는 것만으로 기능을 확장할 수 있다.
class KakaoPayPayment implements Payment {
@Override
public void pay(int amount) {
System.out.println("카카오페이로 " + amount + "원 결제");
}
}
따라서 공통된 메시지를 중심으로 객체들을 유연하게 다룰 수 있게 해주는 구조를 만들 수 있다는 장점을 가진다.
왜 “상속보다 조합”이라고 말할까?
위와 같은 장점이 존재하는데, 객체지향 설계를 공부하다 보면 왜 "상속보다 조합을 사용하라"는 말을 자주 듣게 되는 걸까?
상속이 잘못 사용될 경우 캡슐화가 약해지고, 설계가 유연하지 않아 유지보수 하기 힘들어지기 때문이다.
1. 부모 클래스와 자식 클래스 사이에 강한 결합을 만든다.
자식 클래스는 부모 클래스의 메서드와 필드를 직접 사용할 수 있기 때문에, 자연스럽게 부모 클래스의 구현에 의존하게 된다.
특히 자식 클래스의 메서드에서 super를 사용해 부모 클래스의 메서드를 직접 호출하는 경우 이러한 결합은 더욱 강해진다.
블랙잭 게임에서 카드의 합을 계산한다고 해보자.
class Card {
private int score;
public Card(int score) { this.score = score; }
public int getScore() { return score; }
}
// 부모 클래스
class Participant {
private int score = 0;
// 카드 한 장을 받을 때 점수 누적
public void receiveCard(Card card) {
this.score += card.getScore();
}
// 게임 시작 시 초기 카드 여러 장을 받을 때
public void receiveInitialCards(List<Card> cards) {
for (Card card : cards) {
receiveCard(card);
}
}
}
// 자식 클래스
class Dealer extends Participant {
private int dealerTotalScore = 0;
@Override
public void receiveCard(Card card) {
dealerTotalScore += card.getScore();
super.receiveCard(card);
}
@Override
public void receiveInitialCards(List<Card> cards) {
for (Card card : cards) {
dealerTotalScore += card.getScore();
}
super.receiveInitialCards(cards);
}
public int getDealerTotalScore() {
return dealerTotalScore;
}
}
이제 메인 메서드에서 딜러에게 처음 두 장의 카드(10점, 8점)를 지급해 보자!
public class BlackjackGame {
public static void main(String[] args) {
Dealer dealer = new Dealer();
List<Card> initialCards = List.of(new Card(10), new Card(8));
// 딜러에게 초기 카드 2장을 지급
dealer.receiveInitialCards(initialCards);
System.out.println("딜러의 총 점수: " + dealer.getDealerTotalScore());
}
}
이 때 딜러의 점수는 18점이 아닌, 36점이 나온다..!
왜 36점이 되었을까?
- dealer.receiveInitialCards() 호출
- Dealer 클래스 내부의 for문이 돌아가며 dealerTotalScore에 18점이 더해짐
- super.receiveInitialCards(cards)가 호출되어 부모 클래스(Participant)로 넘어감
- 부모 클래스의 receiveInitialCards 내부에서 다시 for문이 돌며 receiveCard(card)를 두 번 호출
- 이때 다형성으로 인해 Participant가 아닌 오버라이딩된 Dealer의 receiveCard 호출
- Dealer의 receiveCard가 다시 실행되면서 dealerTotalScore에 10점과 8점이 각각 한 번 더 더해짐
이처럼 super 호출이 존재하면 자식 클래스는 부모 클래스가 해당 메서드를 어떻게 구현했는지에 의존하게 된다.
이러한 구조에서는 캡슐화가 약해지며, 취약한 기반 클래스 문제(Fragile Base Class Problem)가 발생할 수 있다.
- 취약한 기반 클래스 문제: 부모 클래스의 작은 변경이 자식 클래스의 동작을 깨뜨릴 수 있는 상황을 의미
또한 자식 클래스가 부모 메서드를 오버라이딩하는 경우에도 문제가 발생할 수 있다.
부모 클래스가 해당 메서드를 내부에서 어떻게 사용하는지에 따라, 자식 클래스의 동작이 의도와 다르게 변경될 수 있기 때문이다.
결과적으로 부모와 자식 클래스의 구현이 강하게 결합되기에, 캡슐화가 약해져 설계의 유연성이 떨어진다.
2. 불필요한 기능까지 함께 물려받는 문제가 존재한다.
상속의 또 다른 문제는 부모 클래스로부터 필요하지 않은 기능까지 함께 물려받을 수 있다는 점이다.
대표적인 예로 자주 언급되는 것이 java.util.Stack이다.
Stack 클래스는 Vector를 상속받아 구현되어 있다. Vector는 리스트 자료구조이기 때문에 다양한 메서드를 제공한다.
예를 들어 add, remove, insertElementAt 같은 메서드들이 존재한다.
문제는 이러한 메서드들이 스택의 개념과는 직접적으로 관련이 없다는 점이다.
스택 자료구조는 기본적으로 push와 pop 같은 동작만 제공하면 충분하다.
하지만 Vector를 상속했기 때문에 Stack은 관련 없는 메서드들까지 모두 외부에 노출하게 된다.
결과적으로 Stack을 사용하는 개발자는 스택 자료구조의 의도와 맞지 않는 방식으로 객체를 사용할 수도 있게 된다.
이러한 제약이 코드로 강제되지 않는다면, 결국 개발자에게 규칙을 일일이 설명하고 지키도록 요구해야 하는데,
과연 이런 방식의 설계가 바람직하다고 볼 수 있을까? 오히려 개발자에게 불필요한 부담을 주게 되는 것이 아닐까?
상속의 한계를 보완하는 조합
조합은 외부 클래스의 인스턴스를 private 필드로 보유하여 클래스 간의 관계를 구성하는 방식이다.
이 방식은 내부 구현을 외부에 드러내지 않으면서 필요한 기능만 제공할 수 있어 캡슐화를 강화한다.
또한 내부에서 사용하는 객체에 직접 의존하지 않고 필드를 통해 기능을 위임하는 구조이기 때문에,
필요에 따라 구성 요소를 다른 구현으로 교체하거나 새로운 기능을 확장하기도 쉽다.
이러한 특성 덕분에 시스템의 유연성과 재사용성도 함께 높아진다.
이제 상속 구조로 인해 한계를 가졌던 Stack을 조합을 활용해 구현해보자!
import java.util.Collection;
import java.util.ArrayList;
public class Stack<E> {
private final ArrayList<E> elements;
public Stack() {
elements = new ArrayList<E>();
}
public void push(E e) {
elements.add(e);
}
public E pop() {
if (elements.isEmpty()) {
throw new EmptyStackException();
}
return elements.remove(elements.size() - 1);
}
public boolean isEmpty() {
return elements.isEmpty();
}
}
이 방식에서 Stack 클래스는 내부에 있는 ArrayList 인스턴스를 통해 필요한 메서드만 선택적으로 활용할 수 있다.
Stack이 ArrayList를 상속하지 않기 때문에 add, remove, get과 같은 모든 메서드가 외부에 노출되지 않는다. 대신 push, pop처럼 스택의 역할에 맞는 메서드만 제공할 수 있다.
또한 Stack이 ArrayList의 구현에 직접적으로 의존하지 않기 때문에, 필요하다면 내부 구현을 LinkedList나 다른 컬렉션으로 교체하더라도 외부 API는 그대로 유지할 수 있다.
블랙잭 도메인 살펴보기
개념을 살펴봤으니, 블랙잭에 적용한 코드를 보기 전에 먼저 블랙잭 도메인의 구조를 간단히 짚고 넘어가 보자.
블랙잭에는 대표적으로 Player와 Dealer라는 두 가지 참여자가 등장한다.
둘은 역할은 다르지만 여러 동작이 두 객체에서 공통적으로 사용되는 구조를 가지고 있다.
- 카드 패의 점수를 계산한다.
- Bust 여부를 확인한다.
- 카드를 한 장 뽑아 패에 추가한다.
- 블랙잭 여부를 판단한다.
- 다른 참여자와 점수를 비교한다.
- 이름, 카드 목록, 현재 점수 등을 조회한다.
이처럼 Player와 Dealer는 카드를 가지고 게임을 진행하는 참여자라는 공통된 개념을 공유하고 있다.
조합으로 설계했을 때의 구조와 한계
Player와 Dealer가 공통적으로 사용하는 로직을 각 클래스마다 작성하면 중복이 발생하니
Participant 클래스를 만들어 공통 로직을 담아보자.
public class Participant {
private final Name name;
private final Hand hand;
public Participant(Name name, Hand hand) {
this.name = name;
this.hand = hand;
}
public boolean isBust() {
GameScore gameScore = hand.calculateTotalScore();
return gameScore.isBust();
}
public void playTurn(Deck deck) {
Card hitCard = deck.drawCard();
hand.receiveCard(hitCard);
}
public boolean isBlackjack() {
return hand.isBlackjack();
}
public boolean hasHigherScore(Participant other) {
return getScore().isBiggerThan(other.getScore());
}
public String getName() {
return name.getValue();
}
public List<Card> getCards() {
return hand.getCards();
}
public GameScore getScore() {
return hand.calculateTotalScore();
}
}
이어서 조합으로 Participant를 Dealer 클래스 내 인스턴스 변수로 주입하여 코드를 작성해보면 다음처럼 정리할 수 있다.
public class Dealer {
private static final int STAND_SCORE = 17;
private static final int VISIBLE_CARD_COUNT = 1;
private final Participant dealer;
public Dealer(Participant dealer) {
this.dealer = dealer;
}
public List<Card> getInitialCards() {
return hand.getInitCards(VISIBLE_CARD_COUNT);
}
public boolean isBust() {
return dealer.isBust();
}
public boolean isBlackjack() {
return dealer.isBlackjack();
}
public List<Card> getCards() {
return dealer.getCards();
}
public GameScore getScore() {
return dealer.getScore();
}
public void playTurn(Deck deck) {
while (dealer.getScore().isLessThan(STAND_SCORE)) {
dealer.playTurn(deck);
}
}
}
위와 같은 설계의 문제는 어떤 게 있을까?
현재 Dealer는 Participant의 기능을 외부로 제공하기 위해, 같은 이름의 메서드를 만들고 내부 객체의 메서드를 그대로 호출만 하고 있다.
만약 Participant 클래스에 새로운 메서드가 추가되거나 기존 메서드의 시그니처(이름, 매개변수 등)가 변경된다면 어떻게 될까?
1. 변경 전파
Participant의 getScore() 메서드 이름을 calculateScore()로 바꾼다고 가정해 보자.
- 수정 포인트: Participant.java (1곳) + Dealer.java 내부의 위임 코드 (1곳) = 총 2곳 수정
기능이 늘어날수록 이 숫자는 비례해서 증가한다.
또한 만약 Participant 클래스 내부 로직을 수정한다면, Dealer가 해당 메서드를 올바르게 위임하고 있는지도 검증해야 하는 피곤함이 존재한다.
2. 보일러플레이트 코드 증가
현재 Dealer에는 다음과 같은 단순 위임 메서드가 존재한다.
- isBust()
- isBlackjack()
- getCards()
- getScore()
이 메서드들은 단순히 내부 participant 객체의 메서드를 호출할 뿐이다.
이러한 위임 코드가 약 15~20줄 정도의 의미 없는 코드를 차지한다.
3. 테스트 코드 중복
Dealer가 Participant의 기능을 위임하고 있기 때문에
DealerTest에서도 버스트, 블랙잭 여부 등 Participant에서 작성했던 것과 비슷한 테스트를 다시 작성하게 될 가능성이 높다.
상속으로 리팩토링
자 이제 조합으로 구현했을 때의 단점을 개선하기 위해 상속으로 리팩토링 해보자.
딜러와 플레이어가 공통적으로 가지는 동작과 상태를 Participant에 모아두고,
딜러와 플레이어만의 고유한 행동은 하위 클래스(Player, Dealer)가 구현하도록 설계했다.
public abstract class Participant {
protected final Name name;
protected final Hand hand;
protected Participant(String name, Hand hand) {
this.name = new Name(name);
this.hand = hand;
}
public abstract List<Card> getInitialCards();
public final boolean isBust() {
GameScore gameScore = hand.calculateTotalScore();
return gameScore.isBust();
}
public final void playTurn(Deck deck) {
Card hitCard = deck.drawCard();
hand.receiveCard(hitCard);
}
public final boolean isBlackjack() {
return hand.isBlackjack();
}
public final boolean hasHigherScore(Participant other) {
return getScore().isBiggerThan(other.getScore());
}
public final String getName() {
return name.getValue();
}
public final List<Card> getCards() {
return hand.getCards();
}
public final GameScore getScore() {
return hand.calculateTotalScore();
}
@Override
public final boolean equals(Object o) {
if (!(o instanceof Participant that)) {
return false;
}
return Objects.equals(getName(), that.getName());
}
@Override
public int hashCode() {
return Objects.hashCode(getName());
}
}
해당 코드를 짧게 설명하자면
- abstract 클래스로 선언함으로써, Participant 클래스를 직접 객체로 만들 수 없게 하고 상속해서 사용하도록 강제했다.
게임에는 Player와 Dealer 같은 구체적인 참여자만 존재하기 때문이다. - Dealer와 Player가 게임 시작 시 카드를 받는 건 동일하지만, 몇 장의 카드를 보여줄 지는 서로 다른 규칙을 가지기에
abstract 메서드로 구현하여 하위 클래스에서 자신의 규칙에 맞게 구현하도록 강제하였다. - 공통으로 사용하는 메서드는 final을 선언하여 하위 클래스에서 override 하지 못하게 했다. (로직 변경 허용x)
public class Dealer extends Participant {
private static final String NAME_VALUE = "딜러";
private static final GameScore STAND_SCORE = new GameScore(17);
private static final int VISIBLE_CARD_COUNT = 1;
public Dealer(Hand hand) {
super(NAME_VALUE, hand);
}
@Override
public List<Card> getInitialCards() {
return hand.getInitCards(VISIBLE_CARD_COUNT);
}
public boolean isStand() {
return getScore().isBiggerThan(STAND_SCORE)
|| getScore().equals(STAND_SCORE);
}
}
- 부모 클래스에서 abstract로 선언한 메서드를 override 하여 자신의 규칙에 맞게 구현하도록 했다.
- Dealer만의 행동인 메서드(isStand)를 생성했다.
📌 전체코드
https://github.com/woowacourse/java-blackjack/pull/961
[🚀 사이클1 - 미션 (블랙잭 게임 실행)] 러키 미션 제출합니다. by Jiihyun · Pull Request #961 · woowacour
체크 리스트 미션의 필수 요구사항을 모두 구현했나요? Participant클래스에서 3개의 인스턴스 변수를 가짐 BlackjackGame클래스에서 while문 안의 if문으로 인해 depth 1 초과 Gradle test를 실행했을 때, 모
github.com
플레이어와 딜러 관계는 왜 상속이 조합보다 유지보수에 유리할까?
조합으로 구현했을 때와 비교하기 위해, 상속으로 구현했을 때 변경이 발생하면 어떤 영향이 일어나는지 살펴보자.
1. 공통 API 변경 시 영향 범위 축소
만약 Participant 클래스에 새로운 메서드가 추가되거나 기존 메서드의 시그니처(이름, 매개변수 등)가 변경된다면 어떻게 될까?
조합에서는 Dealer가 Participant의 기능을 외부로 노출하기 위해 위임 메서드를 가지고 있기 때문에,
Participant의 API가 변경되면 위임 메서드도 함께 수정해야 했다.
반면 상속 구조에서는 상황이 다르다.
- 하위 클래스의 위임 메서드를 수정할 필요 없이, Participant만 수정하면 됨
- Player, Dealer는 해당 메서드를 상속받아 사용하고 있으므로 별도의 수정 없이 동일한 동작 자동 사용
2. 새로운 참여자 타입 추가 시 구현 비용 감소
게임에 AI Player나 VIP Player 등 새로운 참여자 타입이 추가되어야 하는 상황을 떠올려 보자.
이때, 조합에서는 새로운 클래스를 만들 때 다음과 같은 작업이 필요하다.
- 내부에 Participant 객체를 보관
- 필요한 기능들을 다시 위임
- 위임 메서드 작성
상속 구조에서는 이러한 작업이 필요 없다.
- 새로운 참여자 클래스를 생성하고 Participant 상속만 시키면 됨
- 상속을 통해 공통 행동이 이미 제공되므로 별도의 위임 없이 바로 사용 가능
3. 참여자를 공통 타입으로 처리 가능
게임 결과 계산이나 상태 확인 로직에서 모든 참여자를 순회하며 동일한 행동을 수행하는 상황을 가정해 보자.
조합에서는 Player와 Dealer가 동일한 타입 계층에 속하지 않기 때문에,
공통 타입으로 묶기 어렵거나 추가적인 추상화가 필요하다.
하지만 상속 구조에서는 처리가 쉽다.
- 두 클래스 모두 Participant를 상속하기 때문에 상위 클래스인 Participant 타입 하나로 묶을 수 있음
4. IS-A 관계의 자연스러움
도메인 관점에서 두 객체의 관계를 생각해 볼 필요가 있다.
조합으로 설계하면 Dealer 내부에 Participant 인스턴스를 두게 된다.
이를 문장으로 읽으면 다음과 같은 의미가 된다.
“딜러는 참가자를 내부에 가지고 있다.”
하지만 블랙잭 게임의 실제 개념을 생각해 보면 이 관계는 다소 어색하다.
딜러는 참가자를 내부에 보유하는 객체라기보다는 게임에 참여하는 참가자의 한 종류이기 때문이다.
즉 도메인 관점에서 더 자연스러운 관계는 다음과 같다.
“딜러는 참가자의 일종이다.”
이러한 관계는 IS-A 관계이며, 상속 구조가 이를 가장 직접적으로 표현할 수 있다.
블랙잭 게임에서는 플레이어와 딜러는 모두 카드를 받고 점수를 계산하는 ‘참가자’라는 공통 규칙을 따른다.
따라서 Dealer가 Participant를 내부에 가진다고 표현하기보다,
딜러 역시 참가자의 한 종류라고 정의하는 것이 더 자연스럽다.
이 관계에서는 조합을 사용하더라도 행동을 조합해 새로운 책임을 만드는 것이 아니기 때문에
Participant의 기능을 그대로 전달하게 되어 단순 위임 구조가 되기 쉽고, 캡슐화를 지켰다고 보기에도 애매하며, 결국 조합의 장점을 충분히 활용하지 못하게 된다.
반면 상속을 사용하면 공통 규칙은 상위 클래스에서 관리하고 역할별 차이는 하위 클래스에서 표현할 수 있어 구조가 단순해지고 유지보수도 용이해진다.
결과적으로 상속을 사용했을 때
- 도메인 개념을 더 자연스럽게 표현하고
- 공통 로직을 한 곳에서 관리할 수 있으며
- 변경의 영향 범위를 최소화하고 확장을 단순하게 만든다
이러한 이유로 블랙잭에서 Player와 Dealer의 관계는
조합보다 상속 구조가 더 유지보수에 유리한 설계라고 판단했다.
상속 vs 조합, 어떤 경우에 사용하는 것이 좋을까?
블랙잭을 구현하면서 “상속보다 조합”은 절대적인 규칙이 아니라는 것을 느꼈다.
그럼 두 방식은 각각 언제 사용하는 것이 좋은지 고민하며 나만의 기준을 세워봤다.
상속을 고려할 수 있는 경우는 다음과 같다.
1. 상위 클래스가 상속을 고려해 설계된 경우
e.g.) abstract로 메서드로 확장 포인트를 열어두고, final로 변경되면 안 되는 공통 로직을 명확히 제한하는 구조
2. 상위 클래스의 변경이 하위 클래스에 큰 영향을 주지 않는 경우
e.g.) 상위 클래스에 새로운 메서드가 추가되거나 내부 구현이 일부 변경되더라도,
하위 클래스가 super 호출 방식이나 내부 필드 상태에 의존하지 않아 별도의 수정 없이 그대로 동작하는 구조
3. 하위 객체를 부모 타입으로 치환해도 의미가 자연스럽게 유지되는 경우
e.g.) 부모 클래스의 모든 속성과 동작이 자식 클래스에 적합해야 한다.
부모 타입으로 사용했을 때 하위 클래스가 특정 메서드를 UnsupportedOperationException으로 막거나,
부모 클래스에서 보장한 동작을 깨지 않는 구조
결국 상속은 코드 중복을 줄이고 싶은 마음에 사용하는 코드 재사용 수단이 아니라,
IS-A 관계로 치환했을 때 의미가 자연스럽고, abstract, final 등 상속을 고려해 설계된 부모 클래스가 있을 때
제한적으로 사용하는 것이 안전하다고 생각한다!
그 외의 대부분의 경우에는 객체를 조합해 역할을 구성하는 방식이 더 유연한 설계가 된다고 생각한다.
마치며
이번 블랙잭 미션을 진행하며 “상속보다 조합을 우선하라”는 말의 의미를 제대로 생각해 보게 되었다.
처음에는 이 문장을 거의 규칙처럼 받아들이고 상속 사용을 피하려고 했지만, 실제로 코드를 작성해 보니 도메인 관계에 따라 상속이 더 자연스러운 경우도 분명 존재한다는 것을 느낄 수 있었다.
또한 모든 설계에는 장점만 존재하는 것이 아니라 항상 트레이드오프가 존재한다는 것을 다시 한 번 느낄 수 있었다.
상속을 선택했지만, 상속의 장점 중 하나인 다형성을 충분히 활용하지 못한 것 같다는 고민이 들었다. 이 부분에 대해 리뷰어 로빈께 질문을 드렸고, 덕분에 설계를 바라보는 좋은 관점을 얻을 수 있었다.

따라서 단점이 있다는 이유만으로 장점이 더 큰 선택지를 배제하기보다는, 일부 단점이 존재하더라도 해당 상황에서 얻는 이점이 더 크다면 충분히 선택할 수 있는 설계라는 것을 배웠다.
앞으로도 설계를 고민할 때 “모든 장점을 다 활용해야 한다”는 생각보다는, 현재 문제를 해결하는 데 어떤 선택이 가장 적절한지를 기준으로 판단해 보려고 한다!
참고자료
- 상속보다는 조합(Composition)을 사용하자.
- [📚 오브젝트] ch 10 (상속과 코드 재사용) ~ ch 11 (합성과 유연한 설계)
- [📚 이펙티브 자바] Item 18 | 상속보다는 조합(컴포지션)을 사용해라