주요 디자인 패턴

디우·2022년 4월 4일
2

디자인 패턴이란?

객체 지향 설계는 프로그램 기능 구현 뿐 아니라, 동시에 재설계 없이 또는 재설계를 최소화하면서 요구사항의 변화를 수용할 수 있도록 한다.

이런 설계는 "특정 상황"에 맞는 해결책을 빠르게 찾을 수 있도록 도와주는데, 이렇게 반복적으로 사용되는 클래스, 객체의 구성, 객체 간 메시지 흐름에서 일정 패턴을 갖는다. 그리고 이러한 패턴을 잘 습득하면 다음과 같은 이득을 얻을 수 있다.

  • 상황에 맞는 올바른 설계를 더 빠르게 적용
  • 각 패턴의 장단점을 통해 설계 선택의 도움
  • 설계 패턴에 이름을 붙임으로써 문서화, 이해, 유지 보수에 도움

GoF의 디자인 패턴은 객체의 생성, 기능의 확장, 기능의 변경, 구조 등과 관련하여 약 20여개에 이르는 패턴을 정리하고 있는데, 이 중 자주 사용되는 패턴들에 대해서 정리해보려 한다. (GoF의 디자인 패턴에는 속하지 않지만 사용법이 간편하며 해당 패턴 적용으로 인해 얻는 장점이 있는 널(Null) 객체 패턴도 다룬다.)


전략(Strategy) 패턴

만약 요구사항에 따라서 다르게 처리해줘야 하는 경우, 이러한 코드가 콘텍스트에 포함되어 있는 경우, if-else문을 통해서 분기를 해주어야 한다.
이러한 문제를 해결해주기 위해서 우리는 요구사항별 구현체를 별도의 객체로 분리해줄 수 있다.

예를 들어, "기물의 이동" 이라는 요구사항이 있고, 각 기물별로 이동 로직이 다르다고 해보자. 그렇다고 하면 우리는 "기물의 이동" 이라는 것을 추상화하고 각 Concrete Class에서 상황에 맞는 "기물의 이동" 로직을 구현하여 제공할 수 있다.

즉 이렇게, 특정 콘텍스트에서 알고리즘(전략)을 별도로 분리하는 설계 방법이 전략 패턴이다.

전략 패턴에서 콘텍스트는 사용할 전략을 직접 선택하지 않는다. 대신, 콘텍스트의 클라이언트가 콘텍스트에 전략을 주입하여 준다. 그리고 전략이 어떤 메소드를 제공할지의 여부는 콘텍스트가 전략을 어떤 식으로 사용하느냐에 따라 달라진다.

MovingStrategy 인터페이스

public interface MovingStrategy {
    boolean isMovable();
}

RandomMovingStrategy 전략 콘크리트 클래스

public class RandomMovingStrategy implements MovingStrategy {

    private static final int PROGRESS_CONDITION_VALUE = 4;
    private static final int RANDOM_RANGE = 10;

    @Override
    public boolean isMovable() {
        int randomNumber = getRandomNumber();

        return randomNumber >= PROGRESS_CONDITION_VALUE;
    }

    private static int getRandomNumber() {
        return (int) Math.floor(Math.random() * RANDOM_RANGE);
    }
}

전략을 사용하는 콘텍스트인 Car

 public class Car {

    private static final int POSITION_INITIAL_VALUE = 0;

    private final CarName carName;
    private int position = POSITION_INITIAL_VALUE;

    public Car(String name) {
        this.carName = new CarName(name);
    }

	...
    
    public void progress(MovingStrategy movingStrategy) {
        if (movingStrategy.isMovable()) {
            position++;
        }
    }

    public boolean isSamePosition(int position) {
        if (this.position == position) {
            return true;
        }
        return false;
    }
}

Car를 사용하는 코드에서는 RandomMovingStrategy 와 같이 Concrete Class, 즉 전략의 상세 구현에 대한 의존이 발생한다. 이것이 문제로 보일 수 있으나, 전략의 콘크리트 클래스와 클라이언트의 코드가 쌍을 이루기 때문에 유지보수 문제가 발생할 가능성이 줄어든다.

전략 패턴의 장점은 콘텍스트 코드의 변경 없이 새로운 전략을 추가할 수 있다는 점이다. (즉, OCP을 따르게 된다.)
그리고 이렇게 프로덕트 코드의 변경 없이 새로운 전략을 추가할 수 있다는 점은 앞서 보인 예제처럼 Random과 관련되어 테스트가 어려운 경우에도 빛을 발한다.

	@Test
    @DisplayName("자동차에 기준값 보다 큰 값을 주면 전진 하는지 확인")
    public void 자동차_진행_테스트() {
        //given
        Car car = new Car("woo");

        //when
        car.progress(() -> true);

        //then
        assertThat(car.getPosition()).isEqualTo(1);
    }

    @Test
    @DisplayName("자동차에 기준값 보다 작은 값을 주면 정지 하는지 확인")
    public void 자동차_정지_테스트() {
        //given
        Car car = new Car("woo");

        //when
        car.progress(() -> false);

        //then
        assertThat(car.getPosition()).isEqualTo(0);
    }

위와 같이 프로덕트 코드의 변경이 없이 MovingStrategy의 isMovable()이 false 와 true를 반환하는 경우에 대해서 테스트가 가능해지게 되기 때문이다.


템플릿 메서드(Template Method) 패턴

완전한 동일한 절차를 가지나, 이 절차 중 일부 과정만이 다른 상황에서 사용가능한 패턴이 바로 템플릿 메서드 패턴이다.

예를 들어, 커피를 만드는 절차가 "물을 끓인다. -> 끓는 물에 커피를 우려낸다. -> 커피를 컵에 따른다. -> 설탕과 우유를 추가한다." 이고, 홍차를 만드는 절차가 "물을 끓인다. -> 끓는 물에 차를 우려낸다. -> 차를 컵에 따른다. -> 레몬을 추가한다." 라고 하면, 첫번째와 세번째 절차는 동일하고 나머지 2번의 절차에서만 차이가 존재하는 경우에 템플릿 메서드 패턴을 적용할 수 있다.

템플릿 메소드는 이렇게 실행 과정(단계)는 동일한데, 각 단계 중 일부의 구현만이 다른 경우에 사용할 수 있으며 아래 2가지로 구성된다.

  • 실행 과정을 구현한 상위 클래스
  • 실행 과정의 일부 단계를 구현한 하위 클래스

상위 클래스는 실행 과정을 구현한 메소드를 제공한다. 이 메소드는 기능을 구현하는데 필요한 각 단계를 정의하며 일부 단계는 추상 메소드를 호출하는 방식으로 구현한다. 즉, 공통되는 부분은 상위에서 정의를 하고 서로 다른 단계에 해당하는 부분은 하위에서 구현하는 방식이다.
(공통된 부분과 비공통된 부분을 둘로 나눠서 공통되는 부분은 상위에서 정의하고 비공통되는 부분은 각 하위 클래스에서)

우리는 템플릿 메소드 패턴을 통해서 동일한 실행 과정의 구현을 제공하고, 동시에 하위 타입에서 일부 단계를 구현하도록 함으로써 코드의 중복을 방지할 수 있다.

상위 클래스가 흐름 제어 주체

템플릿 메서드 패턴의 특징은 상위 클래스에서 흐름 제어를 한다는 것이다. 일반적인 경우에는 하위 타입이 상위 타입의 기능을 재사용(오버라이드)할지 여부를 결정하기 때문에 하위 타입에서 제어한다.

반면 템플릿 메서드 패턴은 상위 타입의 템플릿 메서드가 모든 실행 흐름을 제어하고 하위 타입의 메소드는 템플릿 메소드에서 호출되는 구조를 갖는다.

훅(hook) 메소드
템플릿 메소드는 알고리즘 구조(틀) 자체가 고정적인데, 이렇게 너무 정적인(경직된) 패턴을 실제로 사용하기는 어려울 수 있다. 따라서 약간의 옵션을 줄 수 있는 방법이 "hook 메소드"이다.

훅 메소드는 상위 클래스에서 실행 시점에 제어되고, 기본 구현을 제공하면서 하위 클래스에서 알맞게 확장할 수 있는 메소드이다.

템플릿 메서드와 전략 패턴의 조합

앞서 계속해서 언급한 템플릿 메소드는 상속의 방법을 이용한다. 하지만 전략 패턴을 함께 사용함으로써 상속이 아닌 composition의 방식으로 템플릿 메소드 패턴을 활용할 수 있는데, 대표적인 예가 Spring Framework의 Template 으로 끝나는 클래스들이다.

이 클래스들은 템플릿 메소드를 실행할 때 변경되는 부분을 실행할 객체를 인자로 받는 방식으로 구현되어 있다.

  • 템플릿 메서드가 하위 타입에서 재정의할 메소드를 호출하고 있다면 TransactionTeamplate의 execute() 메소드는 파라미터로 전달받은 action의 메소드를 호출한다.

템플릿 메소드 패턴과 전략 패턴을 함께 사용하게 되면 상속을 사용하는 템플릿 메소드 패턴보다 유연함을 가지고, 불필요한 클래스의 증가를 피할 수 있으며 런타임에 교체할 수 있다는 장점을 얻게 된다.
하지만, 상속 방식의 경우, 훅 메소드를 재정의하는 방법으로 하위 클래스에서 쉽게 확장 가능하다는 장점 또한 존재한다.


상태(State) 패턴

상태패턴은 상태 자체를 하나의 객체로 하여 추상화하는 패턴이다. 만약상태가 많아질수록 복잡해지는 조건문이 여러 코드에서 중복해서 출현하고 있다면 상태 패턴을 고려해볼만 하다.
즉, 상태에 따라 동일한 기능 요청 처리를 다르게 하는 상황에서 적용해볼 수 있으며, 이렇게 상태에 따라 다르게 동작해야할 때 사용할 수 있는 패턴이 상태(State) 패턴이다.

상태 패턴에서 중요한 점은 상태 객체 자체가 기능을 제공한다는 점이다. 콘텍스트는 클라이언트로부터 기능 실행 요청을 받으면 상태 객체에 처리를 위임하는 방식으로 구현한다.

public class ChessGame {
	private State state;
    
    ...
    
    public void progress(Command command) {
        state = state.execute(command, chessBoard);
    }
    ...
}

상태 패턴의 장점은 새로운 상태가 추가되더라도 콘텍스트 코드가 받는 영향은 최소화된다는 점이다. 여기서 State 인터페이스를 구현하는 새로운 상태가 추가되어도 ChessGame은 변경이 없거나 최소화 될 것이다.
또한 상태에 따른 동작을 구현한 코드가 각 상태 별로 구분되기 때문에 상태 별 동작을 수정하기가 쉽다.

public interface State {

    boolean isEnd();

    State execute(Command command, ChessBoard chessBoard);
}
public final class Ready implements State {

    @Override
    public boolean isEnd() {
        return false;
    }

    @Override
    public State execute(Command command, ChessBoard chessBoard) {
        if (command.isStart()) {
            return new White();
        }
        if (command.isEnd()) {
            return new End();
        }
        if (command.isStatus()) {
            throw new IllegalArgumentException("게임 시작 이후 점수 출력이 가능합니다.");
        }
        return new Ready();
    }
}
public class White implements State {

    @Override
    public boolean isEnd() {
        return false;
    }

    @Override
    public State execute(Command command, ChessBoard chessBoard) {
        if (command.isMoveCommand()) {
            checkTeam(command, chessBoard);

            chessBoard.move(command);

            return new Black();
        }
        if (command.isEnd()) {
            return new End();
        }
        return this;
    }
}
public class Black implements State {

    @Override
    public boolean isEnd() {
        return false;
    }

    @Override
    public State execute(Command command, ChessBoard chessBoard) {
        if (command.isMoveCommand()) {
            checkTeam(command, chessBoard);

            chessBoard.move(command);

            return new White();
        }
        if (command.isEnd()) {
            return new End();
        }
        return this;
    }
}
public class End implements State {

    @Override
    public boolean isEnd() {
        return true;
    }

    @Override
    public State execute(Command command, ChessBoard chessBoard) {
        if (!command.isEnd()) {
            throw new IllegalArgumentException("이미 게임이 종료되었습니다.");
        }
        return this;
    }
}

상태 변경은 누가?

상태 패턴을 적용할 때 고려할 문제는 콘텍스트의 상태 변경을 누가 하느냐에 대한 것인데, 이러한 상태 변경을 하는 주체는 콘텍스트 혹은 상태 객체 둘 중 하나가 된다.
앞서 보인 예제 코드에서는 각 상태에서 다음 상태로 콘텍스트의 상태를 변경해주었다.

콘텍스트의 상태 변경을 누가 할지는 주어진 상황에 맞게 정해주어야 한다.

콘텍스트가 상태를 변경하는 방식
비교적 상태 개수가 적고 상태 변경 규칙이 거의 바뀌지 않는 경우에 유리하다. -> 상태 종류가 지속적으로 변경되거나 상태 변경 규칙이 자주 바뀔 경우 콘텍스트의 상태 변경 처리 코드가 복잡해질 가능성이 높기 때문이다.(상태 변경의 유연함이 떨어짐)

상태 객체에서 콘텍스트의 상태를 변경하는 방식
콘텍스트에 영향을 주지 않으면서 상태를 추가하거나 상태 변경 규칙을 바꿀 수 있다. 하지만 상태 변경 규칙이 여러 클래스에 분산되어 있기 때문에, 상태 구현 클래스가 많아질수록 상태 변경 규칙을 파악하기 어려워지는 단점 또한 존재한다. 또한 한 클래스에서 다른 상태 클래스에 대한 의존도 발생하게 된다.


널(Null) 객체 패턴

자바에서는 "null" 기능(키워드)를 제공하며 어떠한 객체도 참조하고 있지 않다는 의미가 된다.

//command 참조 변수가 어떠한 객체도 참조하고 있지 않음을 의미한다.
Command command = null;

그런데 만약 Command 클래스에서 다음과 같은 기능을 제공한다고 생각해보자.

	public boolean isMoveCommand() {
        return command.startsWith(MOVE);
    }

그런데 만약 Command command = null 과 같은 코드가 있다면 "null"에 대한 검사 없이 command.isMoveCommand() 와 같이 메소드를 호출시 NullPointerException 이 발생하게 되며 프로그램은 종료된다.

따라서 우리는 if문을 통해서 null을 검사하는 코드를 추가해야하는데, 이는 개발자의 실수를 유발할 가능성이 높다.
예를 들어, 아래 코드처럼 한 객체에 대한 null 검사를 여러 코드에서 사용한다고 가정해보자.

public void someMethod(MyObject obj) {
	if (obj != null) {
    	obj.someOperation();
    }
    ...
    callAnyMethod(obj, other);
}
private void callAnyMethod(MyObject obj, Other other) {
	if (other.someOp()) {
    	if (obj != null) {
        	obj.process();
        }
    } else {
    	if (obj != null) {
        	obj.processOther();
        }
    }
}

[출처: 개발자가 반드시 정복해야할 객체지향과 디자인 패턴(최범균 지음), p. 255]

위와 같은 코드는 앞서 언급한 것과 같이 개발자가 null에 대한 검사를 누락하기 쉽고 도중에 NullPointerException 을 발생시키기 쉽다.

널(Null) 객체 패턴은 null 검사 코드 누락에 따른 문제를 없애준다. 이 패턴은 null을 리턴하지 않고 null 을 대신해서 사용할 객체를 반환해줌으로써 null 검사에 대한 코드를 제거하게끔 해준다. 그리고 이 패턴은 구현이 간단하며 다음과 같은 방법으로 구현한다.

  • null 대신 사용될 클래스를 구현한다. 이 클래스는 상위타입을 상속받으며, 아무 기능도 수행하지 않는다.
  • null을 리턴하는 대신 null을 대체할 클래스의 객체를 리턴한다.
public class NullSpecialDiscount extennds SpecialDiscount {
	@Override
    public void addDetailTo(Bill bill) {
    }
}

[출처: 개발자가 반드시 정복해야할 객체지향과 디자인 패턴(최범균 지음), p. 256]

위와 같은 식으로 "널 객체 패턴"을 적용할 수 있다.
특별 할인 내역을 명세서에 등록하는 상황에서 특별 할인 내역을 처리하기 위해 SpecialDiscount 클래스를 사용한다고 할 때 SpecialDiscount 객체가 null일 때를 대신해서 NullSpecialDiscount 객체를 사용할 수 있게 된다.

그리고 이 객체에서 addDetailTo() 메소드가 호출되는 경우, 해당 메소드는 아무런 기능을 수행하지 않도록 함으로써 null처럼 동작하도록 한다. 그리고 이는 NullPointerException을 발생시키지 않고, 프로그램 흐름을 이어가게 된다.

마지막으로 정리하면 널 객체 패턴은 null 검사 코드(if문)의 사용을 제거해주며 코드가 간결해지고, 코드의 가독성을 높여줄 수 있다. 또한 향후의 유지보수 측면에서도 장점을 얻을 수 있다.

profile
꾸준함에서 의미를 찾자!

0개의 댓글