FireDrago

[우테코] 전략패턴을 사용하여 리팩토링해보기 본문

프로그래밍/디자인패턴

[우테코] 전략패턴을 사용하여 리팩토링해보기

화이용 2025. 4. 7. 15:28

우테코 Lv1 마지막 장기 미션을 복습하면서 전략패턴을 적용해보았다.
해당 코드는 '장기' 게임의 초기 기물 배치를 수행하는 코드이다.
장기는 '상차림'이라는 4가지 기물 배치 방법이 있는데 이에 따라 초기 기물 배치가 달라진다.
이를 구현한 코드를 살펴보자

문제상황

public class BoardInitiator {
    // 전체 보드 조합
    public Map<Position, Piece> generateInitialPieces(PieceSetup hanPieceSetup, PieceSetup choPieceSetup) {
        Map<Position, Piece> fixedInitialPieces = generateFixedInitialPieces();
        addHanSetupPieces(hanPieceSetup, fixedInitialPieces);
        addChoSetupPieces(choPieceSetup, fixedInitialPieces);
        return fixedInitialPieces;
    }

    // 상차림에 따른 구현체 로직 분기
    private void addHanSetupPieces(PieceSetup hanPieceSetup, Map<Position, Piece> fixedInitialPieces) {
        if (hanPieceSetup == PieceSetup.LEFT_SETUP) {
            fixedInitialPieces.putAll(generateHanLeftSetupPieces());
            return;
        }
        if (hanPieceSetup == PieceSetup.RIGHT_SETUP) {
            fixedInitialPieces.putAll(generateHanRightSetupPiece());
            return;
        }
        if (hanPieceSetup == PieceSetup.INNER_SETUP) {
            fixedInitialPieces.putAll(generateHanInnerSetupPiece());
            return;
        }
        fixedInitialPieces.putAll(generateHanOuterSetupPiece());
    }

    private void addChoSetupPieces(PieceSetup choPieceSetup, Map<Position, Piece> fixedInitialPieces) {
        if (choPieceSetup == PieceSetup.LEFT_SETUP) {
            fixedInitialPieces.putAll(generateChoLeftSetupPieces());
            return;
        }
        if (choPieceSetup == PieceSetup.RIGHT_SETUP) {
            fixedInitialPieces.putAll(generateChoRightSetupPiece());
            return;
        }
        if (choPieceSetup == PieceSetup.INNER_SETUP) {
            fixedInitialPieces.putAll(generateChoInnerSetupPiece());
            return;
        }
        fixedInitialPieces.putAll(generateChoOuterSetupPiece());
    }

    // 상차림과 관련없는 고정 기물 배치
    private Map<Position, Piece> generateFixedInitialPieces() {
        Map<Position, Piece> fixedPieces = new HashMap<>();
        ... 상차림과 관련없는 고정된 기물 배치 로직...
        return fixedPieces;
    }

    // 상차림별 기물 배치
    private Map<Position, Piece> generateHanLeftSetupPieces() {
        Map<Position, Piece> hanLeftSetup = new HashMap<>();
        hanLeftSetup.put(new Position(2, 1), new Piece(Side.HAN, PieceType.ELEPHANT, new ElephantMoveBehavior()));
        hanLeftSetup.put(new Position(3, 1), new Piece(Side.HAN, PieceType.KNIGHT, new KnightMoveBehavior()));
        hanLeftSetup.put(new Position(7, 1), new Piece(Side.HAN, PieceType.ELEPHANT, new ElephantMoveBehavior()));
        hanLeftSetup.put(new Position(8, 1), new Piece(Side.HAN, PieceType.KNIGHT, new KnightMoveBehavior()));
        return hanLeftSetup;
    }

    ... 중략 ...

    private Map<Position, Piece> generateChoInnerSetupPiece() {
        Map<Position, Piece> choInnerSetup = new HashMap<>();
        choInnerSetup.put(new Position(2, 10), new Piece(Side.CHO, PieceType.KNIGHT, new KnightMoveBehavior()));
        choInnerSetup.put(new Position(3, 10), new Piece(Side.CHO, PieceType.ELEPHANT, new  ElephantMoveBehavior()));
        choInnerSetup.put(new Position(7, 10), new Piece(Side.CHO, PieceType.ELEPHANT, new ElephantMoveBehavior()));
        choInnerSetup.put(new Position(8, 10), new Piece(Side.CHO, PieceType.KNIGHT, new KnightMoveBehavior()));
        return choInnerSetup;
    }
}

BoardInitiator 클래스의 역할을 살펴보자.

  1. 상차림 결정 : 입력받은 PieceSetup 열거형 값에 따라 어떤 구체적인 기물 배치 로직을 실행할지 결정한다.
  2. 고정 기물 생성 및 배치: 상차림 종류와 관계없이 항상 동일하게 배치되는 기물들을 생성하고 초기화한다.
  3. 각 상차림별 기물 생성 및 배치: 특정 상차림(좌상, 우상, 내상, 외상 등)에 해당하는 기물들의 위치와 종류를 정의하고 생성한다.
  4. 전체 초기 보드 상태 조합: 고정 기물과 선택된 상차림 기물들을 합쳐 최종적인 초기 보드 상태를 만든다.

단일 책임 원칙 (SRP) 위반

단일 책임 원칙에 따르면 한 클래스의 변경요인은 하나여야한다.
반면 BoardInitiator클래스는 변경의 요인이 4가지나 된다.
이제 전략패턴을 도입하여 위 코드를 리팩토링 해보자

전략패턴

전략패턴은 실행(런타임) 중에 알고리즘 전략을 선택하여 객체 동작을 실시간으로 바뀌도록 할 수 있게 하는

행위 디자인 패턴 이다.

public interface SetupInitiator {
    Map<Position, Piece> generateInitialPieces();
}

먼저 각 상차림 전략별 구현체를 생성하는 인터페이스를 정의한다.

public class ChoLeftSetupInitiator implements SetupInitiator {

    @Override
    public Map<Position, Piece> generateInitialPieces() {
        Map<Position, Piece> choLeftSetup = FixedPiecesGenerator.generateChoFixedInitialPieces();
        choLeftSetup.put(new Position(2, 10), new Piece(Side.CHO, PieceType.ELEPHANT, new ElephantMoveBehavior()));
        choLeftSetup.put(new Position(3, 10), new Piece(Side.CHO, PieceType.KNIGHT, new KnightMoveBehavior()));
        choLeftSetup.put(new Position(7, 10), new Piece(Side.CHO, PieceType.ELEPHANT, new ElephantMoveBehavior()));
        choLeftSetup.put(new Position(8, 10), new Piece(Side.CHO, PieceType.KNIGHT, new KnightMoveBehavior()));
        return choLeftSetup;
    }
}

각 상차림 구현체는 구체적인 상차림 배치를 구현한다.
이때 FixedPiecesGenerator.generateChoFixedInitialPieces(); 를 주목하자

각 전략 구현체는 자신만의 고유한 기물배치뿐만 아니라 모든 로직이 공통으로 가지는 기물까지 생성한다. 

중복코드 발생의 문제가 있지만, 공통코드를 상위 클래스나 상속을 사용할때의 단점이 크다고 판단했다. 

새로운 요구사항이 공통로직까지 변경해야하는 경우라면, 공통로직을 가진 상위클래스까지 함께 변경해야한다.

그래서 공통로직을 모든 구현체 안에서 가지도록 만들었다. 새로운 클래스는 공통로직을 사용하지 않아도 된다.

OCP vs 중복코드 제거 중에서 OCP원칙을 더 중요하다고 판단한 것이다.

public class BoardInitiator {

    private SetupInitiator hanSetupInitiator;
    private SetupInitiator choSetupInitiator;

    public BoardInitiator(PieceSetup hanPieceSetup, PieceSetup choPieceSetup) {
        this.hanSetupInitiator = SetupInitiatorFactory.createSetupInitiator(Side.HAN, hanPieceSetup);
        this.choSetupInitiator = SetupInitiatorFactory.createSetupInitiator(Side.CHO, choPieceSetup);
    }

    public Map<Position, Piece> generateInitialPieces() {
        Map<Position, Piece> initialPieces = new HashMap<>();
        initialPieces.putAll(hanSetupInitiator.generateInitialPieces());
        initialPieces.putAll(choSetupInitiator.generateInitialPieces());
        return initialPieces;
    }
}

`BoardInitiator`는 각 상차림 기물이 어떻게 기물을 배치하는지 전혀 모른다. `SetupInitiatorFactory` 가 조건에 맞는 상차림 구현체

를 찾아주고 그 구현체에게 생성을 호출하는 역할만 한다. 깔끔하게 책임이 분리되었다.

public class SetupInitiatorFactory {

    public static SetupInitiator createSetupInitiator(Side side, PieceSetup pieceSetup) {
        if (side == Side.CHO) {
            return createChoSetupInitiator(pieceSetup);
        }
        return createHanSetupInitiator(pieceSetup);
    }

    private static SetupInitiator createChoSetupInitiator(PieceSetup pieceSetup) {
        return switch (pieceSetup) {
            case RIGHT_SETUP -> new ChoRightSetupInitiator();
            case LEFT_SETUP -> new ChoLeftSetupInitiator();
            case INNER_SETUP -> new ChoInnerSetupInitiator();
            case OUTER_SETUP -> new ChoOuterSetupInitiator();
        };
    }

    private static SetupInitiator createHanSetupInitiator(PieceSetup pieceSetup) {
        return switch (pieceSetup) {
            case RIGHT_SETUP -> new HanRightSetupInitiator();
            case LEFT_SETUP -> new HanLeftSetupInitiator();
            case INNER_SETUP -> new HanInnerSetupInitiator();
            case OUTER_SETUP -> new HanOuterSetupInitiator();
        };
    }
}

 

`SetupInitiatorFactory` 클래스는 조건에 맞는 구현체를 찾고 생성하는 역할을 한다.

만약 구현체가 더 많아진다면 Map을 사용하여 구현체를 반환하는 방식도 가능할 것이다. 

지금은 구현체가 많지않아 `switch - case` 문을 사용했다.

중복 제거보다 OCP

각 상차림 전략은 공통 기물 배치 로직을 지닌다.
즉 '상'과 '마'를 제외한 나머지 기물들은 어떤 상차림을 선택하든 같은 위치에 배치된다.
그렇다면 각 전략은 공통 배치 기물 중복 로직을 어떻게 처리해야 할까?

 

공통로직을 상위 클래스에 위임하는 상황부터 생각해보자

정식 장기 룰은 아니지만 북한 장기룰에서는 '차' 기물까지 배치를 변경할 수 있다.
만약 북한식 장기룰을 도입하게 된다면 새로운 클래스를 추가하는 것으로 부족하다.
공통로직을 상속하는 상위 클래스가 함께 수정되어야 한다.
OCP 원칙을 위반하게 된다.

 

그렇다면 중복 로직을 각 전략마다 유지하는 것이 맞을까? 내 결론은 맞다
전략패턴의 목적은 각 전략을 캡슐화하여 외부에서 구현로직을 모르도록 하는데에 있다.
그렇다면 공통로직을 사용하더라도 각 전략은 완결된 전략을 구현하기 위해 중복로직이 필요하다고 생각했다.
공통로직을 유틸클래스로 분리하여, 공통로직을 사용하지 않는 클래스가 추가되는 상황에서 선택적으로 호출할 수 있도록 했다.

결론

1. 단일 책임 원칙(SRP)을 지키기 위해 전략패턴을 도입

  • BoardInitiator 클래스의 책임을 '상차림 전략' 별 구현체로 분리함으로써 각 책임을 분명하게 나눌 수 있었다.
  • 변경의 이유(상차림 전략 추가/변경, 고정 기물 로직 변경 등)를 각각 독립적으로 다룰 수 있게 되었다.

2. OCP(Open-Closed Principle)와 중복 제거의 균형

  • 공통 기물 배치 로직을 상속으로 분리할 경우 확장성에 제약이 생기므로, 각 전략에서 중복을 허용하고 명시적으로 구성하는 방식을 선택했다.
    대신 공통 로직은 유틸 클래스로 분리하여 필요 시 선택적으로 호출할 수 있도록 하여, OCP를 지키면서도 코드 중복을 최소화했다.

3. 전략패턴을 통해 각 상차림 전략을 독립적으로 캡슐화

  • 새로운 전략 추가 시, 기존 코드를 수정하지 않고 새로운 클래스만 추가하면 된다.
    이는 장기 룰 변경이나 확장에 유연하게 대응할 수 있도록 해준다.

4. 가독성과 유지보수성 향상

  • BoardInitiator는 더 이상 상차림 로직을 몰라도 되며, 각 전략 클래스는 자신만의 로직에 집중할 수 있게 되었다.
    테스트 코드도 전략 단위로 분리 가능해졌으며, 테스트 범위와 책임이 명확해졌다.