코드 재사용을 위한 상속을 합성으로 바꾸기

MODUGGAGI·2026년 4월 30일

Java

목록 보기
3/3

들어가며

우테코 레벨1의 장기 미션을 진행하면서 기물마다 이동 전략을 구현할 일이 생겼다.

장기의 각 기물들은 이동 전략이 어느 정도 공통된 것들이 존재한다.

  • 졸/병 : 1칸 이동 (자신의 진영 기준 뒤로 이동 불가)
  • 궁/사 : 1칸 이동 (궁성 내부에서만 이동 가능)

이렇게 졸/병과 궁, 사 기물은 모두 1칸만 이동하는 공통된 행마법을 가진다.
1칸 이동이라는 공통 행마법에 이동 조건이 살짝씩 다를 뿐이었다.

그래서 나는 자연스럽게 공통된 로직(1칸 이동)을 추상 부모 클래스로 올리고, 각 기물들의 이동 전략 클래스(자식 클래스)가 상속받는 구조로 설계했다.

자식 클래스에서는 각자 기물의 이동 조건을 위한 검증을 하고, 부모 클래스의 1칸 이동 전략 로직을 재사용하는 것이었다.
코드를 짜면서 전혀 이상함을 느끼지 못했다.

미션 완료 후 여유 시간에 『오브젝트 - 조영호 저』라는 책을 읽게 되었다.

13장 "서브클래싱과 서브타이핑" 을 읽는 순간, 내 코드가 떠올랐다.

난 단순히 중복 로직을 제거하기 위해 상속을 사용했었다...

이게 문제 인식의 시작이었다.


내가 짠 코드 — 서브클래싱

서브 클래싱이란??

먼저 상속의 목적을 두 가지로 나눌 수 있다.

  • 서브클래싱(Subclassing): 코드 재사용만을 목적으로 하는 상속
  • 서브타이핑(Subtyping): is-a 관계가 성립할 때, 다형성을 활용하기 위한 상속

상속은 아래의 2개 질문에 모두 "예" 라고 답할 수 있는 경우에만 사용해야 한다고 한다.

  1. 상속 관계가 is-a 관계를 모델링 하는가?
  2. 클라이언트 입장에서 부모 클래스의 타입으로 자식 클래스를 사용해도 무방한가?

이 2개 질문을 모두 만족시키는 상속이 서브 타이핑이다.

하지만 당시 내 코드를 보면 전형적인 서브클래싱이었다.

이제 부터 살펴볼 예시코드들은 그 로직은 이해하지 않아도 된다.
부모 클래스의 코드를 재사용하고 있는 지점을 중점으로만 보면 된다.

첫 번째 예시 — SingleStepStraightStrategy

SingleStepStraightStrategy (abstract)
  ├── FriendlyPalaceSingleStepStrategy
  └── SoldierStrategy
클래스 구조
public abstract class SingleStepStraightStrategy implements MoveStrategy {

    @Override
    public List<Position> findPath(Position source, Position destination) {
        DirectionInformation directionInformation = new DirectionInformation(source, destination);

        if (isPalace(source, destination)) {
            validatePalaceSingleStepMovement(source, destination, directionInformation);
            return List.of(destination);
        }

        validateSingleStepMovement(directionInformation);
        return List.of(destination);
    }
}
부모 클래스 — 1칸 이동하는 경로 생성 로직 전체 보유
public class FriendlyPalaceSingleStepStrategy extends SingleStepStraightStrategy {
    private final Camp camp;

    @Override
    public List<Position> findPath(Position source, Position destination) {
        Palace.validateFriendlyPalace(camp, destination);  // 고유 검증
        return super.findPath(source, destination);         // 경로 생성은 부모께
    }
}
자식 클래스 1 — 자신만의 고유 검증 추가 후 경로 생성은 부모에게 위임
public class SoldierStrategy extends SingleStepStraightStrategy {
    private final Camp camp;

    @Override
    public List<Position> findPath(Position source, Position destination) {
        camp.validateForwardDirection(...);              // 고유 검증
        return super.findPath(source, destination);     // 경로 생성은 부모께
    }
}
자식 클래스 2 — 자신만의 고유 검증 추가 후 경로 생성은 부모에게 위임
  • FriendlyPalaceSingleStepStrategy는 궁/사 이동 전략 클래스.
  • SoldierStrategy는 졸/병 이동 전략 클래스.

FriendlyPalaceSingleStepStrategySoldierStrategy 모두 부모 클래스 SingleStepStraightStrategy를 상속받은 이유가 단순히 super.findPath() 한 줄을 재사용하기 위함이다.

is-a 관계가 성립해서가 아닌, 경로 생성 로직을 빌려쓰려고 상속을 사용한 전형적인 서브클래싱이다.

두 번째 예시 — StraightThenDiagonalStrategy

StraightThenDiagonalStrategy (abstract)
  ├── HorseStrategy     → getDiagonalCount() = 1
  └── ElephantStrategy  → getDiagonalCount() = 2
클래스 구조
public abstract class StraightThenDiagonalStrategy implements MoveStrategy {

    @Override
    public List<Position> findPath(Position source, Position destination) {
        DirectionInformation directionInformation = new DirectionInformation(source, destination);
        validateMovement(directionInformation);  // 서브클래스에 위임

        if (directionInformation.isRowBiggerThanColumn()) {
            return createRowFirstPath(source, directionInformation);
        }
        return createColumnFirstPath(source, directionInformation);
    }

    protected abstract void validateMovement(DirectionInformation directionInformation);
    protected abstract int getDiagonalCount();
}
부모 클래스 — 1칸 직선 이동 후 대각선 이동 경로 생성 로직 전체 보유
public class HorseStrategy extends StraightThenDiagonalStrategy {
    @Override
    protected void validateMovement(...) { /* 유효성 검증만 */ }

    @Override
    protected int getDiagonalCount() { return 1; }
}
자식 클래스 1 — 자신만의 고유 검증과 대각선 이동 칸수 오버라이딩
public class ElephantStrategy extends StraightThenDiagonalStrategy {
    @Override
    protected void validateMovement(...) { /* 유효성 검증만 */ }

    @Override
    protected int getDiagonalCount() { return 2; }
}
자식 클래스 2 — 자신만의 고유 검증과 대각선 이동 칸수 오버라이딩

StraightThenDiagonalStrategy 클래스는 1칸 직선 이동 후 대각선 이동하는 경로를 생성하는 부모 클래스이다.
자식 클래스는 다음과 같다.

  • HorseStrategy, 마 : 1칸 직선 이동 후 1칸 대각선 이동
  • ElephantStrategy, 상 : 1칸 직선 이동 후 2칸 대각선 이동

두 기물의 이동 방식은 구조가 동일하고, 대각선 이동 칸수에만 차이가 있었다.
그래서 이 역시 StraightThenDiagonalStrategy 부모 클래스를 생성해서 중복 코드를 제거했었다.

이렇게 부모가 흐름 전체를 쥐고, 자식은 validateMovement()getDiagonalCount()(대각선 몇칸 이동 할지 알려주는 getter)만 채우는 템플릿 메서드 패턴을 사용했었다.

ElephantStrategyHorseStrategy 둘 다 findPath()는 전혀 건드리지 않고 조각만 채운다.

이것도 마찬가지로 부모의 경로 생성 로직을 재사용하기 위한 서브클래싱이었다.


서브 클래싱이 안좋은 이유

기존에는 무조건 코드의 중복을 줄이는 것에 목메였다.

그러다 보니 비슷한 로직이나 동일한 코드 조각이 보이면 간편하게 상속을 사용해서 문제를 해결해왔다.

그런데 이렇게 코드 재사용만을 위한 상속, 즉 서브클래싱은 지양해야 한다.

이제부터 그 이유를 살펴보겠다.

1. is-a 관계와 코드 재사용이 강제로 묶인다

SoldierStrategy extends SingleStepStraightStrategy

SoldierStrategySingleStepStraightStrategy를 상속받은 이유는 단 하나였다.
super.findPath() 한 줄을 재사용하기 위해서이다.

그런데 상속을 택하는 순간, 원하든 원하지 않든 두 가지가 동시에 생겨버린다.

  1. SoldierStrategy는 타입 계층상 SingleStepStraightStrategy의 일종이 됨 (is-a 관계)
  2. SoldierStrategySingleStepStraightStrategy의 경로 생성 로직을 재사용함 (super.findPath()코드 재사용)

문제는 이 두 가지 목적이 원래 다르다는 점이다.

코드를 재사용하고 싶었을 뿐인데, 타입 계층 관계까지 덩달아 생겨버린다.
그리고 같은 로직이 필요한 새 기물이 생기면? 그 기물도 반드시 이 상속 계층에 끼워 넣어야 한다.

코드 재사용을 위해 만든 상속 계층이 점점 커지는 구조가 된다.

2. 부모의 내부 구현에 강하게 결합된다

두 번째 예시인 StraightThenDiagonalStrategy를 다시 보자.

public abstract class StraightThenDiagonalStrategy implements MoveStrategy {
    protected abstract int getDiagonalCount();
}

자식 클래스는 이 메서드를 구현해야 한다.

HorseStrategy는 1을, ElephantStrategy는 2를 반환한다.
그런데 이 값을 올바르게 반환하려면, 자식은 부모가 이 값을 내부에서 언제, 어떻게 쓰는지 알아야 한다.

부모 내부 로직을 모른 채 그냥 구현하면, 의도와 다르게 동작할 수 있다.
자식이 부모에 강하게 결합된 것이다.

자식 클래스는 부모의 인터페이스(무엇을 하는지) 뿐만 아니라, 내부 동작 방식(어떻게 하는지) 까지 알아야 올바르게 동작할 수 있다.

합성은 이 문제를 해결한다.

public class HorseStrategy implements MoveStrategy {
    private final PathGenerator pathGenerator;
    
    @Override
    public List<Position> findPath(Position source, Position destination) {
        // 로직 생략...
        return pathGenerator.generatePath(source, destination);
    }

generatePath()sourcedestination을 받아서 경로를 반환한다는 것만 알면 된다.

내부에서 어떤 알고리즘을 쓰는지, 어떤 순서로 동작하는지는 몰라도 된다.

합성으로 바꾸는 코드는 바로 밑에서 살펴본다.
여기선 PathGenerator를 사용해서 부모의 내부 구현을 알지 못해도 괜찮게 바뀐 것에 주목하자.

3. 자식 클래스를 부모 클래스로 완전히 대체할 수 없다

이건 리스코프 치환 원칙(LSP) 위반이다.

리스코프 치환 원칙이란, 자식 클래스는 부모 클래스를 완전히 대체할 수 있어야 한다는 원칙이다.

SoldierStrategySingleStepStraightStrategy 타입으로 사용하는 클라이언트를 생각해보자.

SingleStepStraightStrategy strategy = new SoldierStrategy(camp);

클라이언트가 SingleStepStraightStrategy에게 기대하는 것은 단순하다.

어느 방향이던 1칸 이동을 기대

그런데 실제 객체인 SoldierStrategy는 다르다.

뒤로는 이동 불가.
뒤를 제외한 상, 좌, 우 방향 1칸 이동 가능

부모를 기대하고 짠 코드에서, 자식은 부모보다 더 엄격한 조건을 추가하고 있다.

자식이 부모를 완전히 대체할 수 없는 것이다.

이것도 서브클래싱의 필연적인 부작용이다.
is-a 관계가 성립하지 않는 상태에서 코드 재사용을 위해 억지로 상속을 쓰다 보니, 자식 클래스가 부모보다 엄격한 조건을 가져서 부모가 처리할 수 있는 일을 처리하지 못하는 상황이 생겨버린다.


합성으로 바꾸기

핵심 아이디어는 하나다.

이전: 경로 생성 로직을 상속받아서
이후: 경로 생성 로직을 주입받아서

public class SoldierStrategy implements MoveStrategy {
    private final Camp camp;
    private final PathGenerator pathGenerator;

    public SoldierStrategy(Camp camp) {
        this.camp = camp;
        pathGenerator = new SingleStepPathGenerator();
    }

    @Override
    public List<Position> findPath(Position source, Position destination) {
        DirectionInformation directionInformation = new DirectionInformation(source, destination);
        camp.validateForwardDirection(directionInformation.calculateRowDirection());

        return pathGenerator.generatePath(source, destination);
    }
}
합성을 통해 경로 생성을 PathGenerator에 위임하는 SoldierStrategy
public class ElephantStrategy implements MoveStrategy {

    private static final int DIAGONAL_COUNT = 2;

    private final PathGenerator pathGenerator;

    public ElephantStrategy() {
        this.pathGenerator = new StraightThenDiagonalPathGenerator(DIAGONAL_COUNT);
    }

    @Override
    public List<Position> findPath(Position source, Position destination) {
        validateMovement(new DirectionInformation(source, destination));
        return pathGenerator.generatePath(source, destination);
    }

    // ...
}
합성을 통해 경로 생성을 PathGenerator에 위임하는 ElephantStrategy

이전엔 부모 클래스가 흐름 전체를 제어하고 자식이 조각을 채우는 구조였다면,

이후엔 각 전략이 흐름 전체를 직접 소유하고, 경로 생성만 PathGenerator에 위임하는 구조로 바뀌었다.

Before / After 비교

이전 (상속)이후 (합성)
관계extendsimplements + has-a
경로 생성super.findPath() 호출pathGenerator.generatePath() 위임
흐름 제어부모 클래스각 Strategy가 직접 소유
재사용 방식상속 계층에 묶임어디서든 필드로 주입

이제 변화로 바뀐 것들을 하나씩 살펴보자.

1. 부모 클래스의 변경이 자식에게 전파되지 않는다

SingleStepPathGenerator.generatePath()가 바뀌어도,
PathGenerator 인터페이스만 유지되면 각 Strategy는 신경 쓸 필요가 없다.

2. 클래스 간 관계가 명확해진다

SoldierStrategy implements MoveStrategy

졸/병 이동 전략이동 전략의 일종이다. (자연스러운 is-a)

SoldierStrategy has-a SingleStepPathGenerator

졸/병 이동 전략은 1칸 경로 생성 방식을 갖는다.

3. 경로 생성 로직을 어디서든 재사용할 수 있다

이전엔 추상 클래스를 상속해야만 경로 생성 로직을 쓸 수 있었다.

이제는 SingleStepPathGenerator를 어디서든 필드로 가질 수 있다.
새로운 기물이 생겨도 상속 계층에 끼워 넣을 필요 없이 그냥 주입하면 된다.


그래서 상속은 언제 써야 할까

합성이 무조건 더 좋다는 건 아니다.

서브클래싱을 목적으로 상속을 쓰는 게 문제다.

오브젝트 13장에서는 아래와 같이 정리했다.

상속을 사용하는 목적이 단순히 코드 재사용이라면, 그건 서브클래싱이다.
진짜 상속은 서브타이핑, 즉 is-a 관계가 성립할 때 사용해야 한다.

그래서 상속을 사용하기 전에는 꼭 아래의 질문들에 전부 다 "예"로 대답할 수 있는지 확인해봐야 한다.

  1. 상속 관계가 is-a 관계를 모델링하는가?
    • [자식 클래스]는 [부모 클래스]다 라고 말해도 이상하지 않아야 한다.
    • 그런데 이때 어휘적으로 말이 되는지보다 기대되는 행동이 일치하는지가 더 중요하다.
  2. 클라이언트 입장에서 부모 타입으로 자식을 사용해도 무방한가?
    • 클라이언트는 부모 클래스와 자식 클래스의 차이점을 몰라도 된다.

이 2개 질문에 대해서 모두 만족해야 서브 타이핑으로써의 상속이다.
하나라도 "아니요"라면, 합성을 먼저 고려해보아야 한다.


마무리

당시 나는 이렇게 생각했다.

코드가 중복되니까 추상 부모 클래스로 만들고 상속받으면 되겠지

그런데 돌아보면 불필요한 타입 계층의 결합을 만들면서 객체 지향의 원칙을 어긴 것이었다.

이전 구조는 경로 생성 로직을 재사용하기 위해 상속 계층에 억지로 묶인 구조였고,
리팩터링 후엔 각 Strategy가 독립적으로 존재하면서 필요한 로직만 골라 합성하는 구조가 되었다.

상속은 코드 중복을 간편하게 제거할 수 있지만 간편한만큼 강력한 도구이기 때문에 쉽게 문제가 될 수 있다는 것을 알게되었다.

앞으로는 상속 관계가 진짜 is-a 관계를 모델링 하는지, 리스코프 치환 원칙을 만족하는지를 따져보고 올바른 서브 타이핑 방식으로써 상속을 사용해야겠다.

profile
개발을 잘하고 싶은 사람

0개의 댓글