안녕하세요! GoF 디자인 패턴 연재, 이번 시간에는 행동 패턴(Behavioral Patterns)의 마지막 주자이며 이번 GoF 포스팅의 마지막 대미를 장식하게될 비지터(Visitor) 패턴에 대해 알아보겠습니다. 이 패턴은 객체의 구조는 변경하지 않으면서, 그 구조의 요소들에 대한 새로운 연산(Operation)을 추가할 수 있게 해줍니다.


정의

비지터(Visitor) 패턴데이터 구조(객체 구조)와 해당 구조에 대한 처리(연산)를 분리하는 패턴입니다. 즉, 실제 연산을 수행하는 '방문자(Visitor)' 객체가 데이터 구조를 돌아다니며 각 요소(Element)에 대한 작업을 수행하는 방식입니다.

동물원에 여러 동물(사자, 코끼리, 원숭이)이 있다고 상상해봅시다.

  • 수의사(Visitor 1)는 각 동물을 방문하며 건강 상태를 체크합니다.
  • 먹이 담당자(Visitor 2)는 각 동물을 방문하며 적절한 먹이를 줍니다.

여기서 중요한 점은, 동물(Element) 자체는 '건강 체크'나 '먹이 주기' 로직을 가질 필요가 없다는 것입니다. 새로운 작업, 예를 들어 '인기 투표하기(Visitor 3)'가 필요해지면, 새로운 방문자만 추가하면 될 뿐 기존 동물 클래스들은 전혀 수정할 필요가 없습니다. 이처럼 데이터 구조는 그대로 둔 채, 새로운 기능(연산)을 '방문자(Visitor)' 형태로 손쉽게 추가할 수 있게 하는 것이 비지터 패턴의 핵심입니다.

 

Visitor 패턴의 구현에는 다음과 같은 Class가 사용됩니다.

  • Visitor (방문자): 데이터 구조의 각 ConcreteElement에 대해 visit() 메서드를 선언합니다. 즉, visit(ConcreteElementA), visit(ConcreteElementB) 처럼 각 요소 클래스별로 오버로딩된 메서드를 가집니다.
  • ConcreteVisitor (구체적인 방문자): Visitor 인터페이스를 구현하며, 각 visit() 메서드에 실제 처리 로직을 담습니다.
  • Element (요소): 방문자를 받아들이는 accept(Visitor v) 메서드를 정의하는 인터페이스입니다.
  • ConcreteElement (구체적인 요소): Element 인터페이스를 구현합니다. accept(Visitor v) 메서드 내부에서는 보통 visitor.visit(this)를 호출하여, 방문자가 자신에 맞는 visit 메서드를 실행하도록 합니다.
  • ObjectStructure (객체 구조): Element들의 집합을 관리하며, 클라이언트가 방문자를 통해 모든 요소를 순회할 수 있도록 하는 인터페이스를 제공합니다.

사용예시

컴퓨터의 각 부품(키보드, 모니터 등)에 대해, 부품 정보 표시라는 연산을 비지터 패턴으로 구현해 보겠습니다.

1. 요소 (Element) 및 방문자 (Visitor) 인터페이스 정의

먼저 컴퓨터 부품(ComputerPart)과 방문자(ComputerPartVisitor)의 공통 인터페이스를 정의합니다.

// Visitor 인터페이스
interface ComputerPartVisitor {
    void visit(Keyboard keyboard);
    void visit(Monitor monitor);
    void visit(Mouse mouse);
    void visit(Computer computer);
}

// Element 인터페이스
interface ComputerPart {
    void accept(ComputerPartVisitor computerPartVisitor);
}

2. 구체적인 요소 (ConcreteElement) 클래스 구현

각 컴퓨터 부품 클래스를 구현합니다. accept 메서드가 핵심입니다.

// ConcreteElement A
class Keyboard implements ComputerPart {
    @Override
    public void accept(ComputerPartVisitor computerPartVisitor) {
        computerPartVisitor.visit(this);
    }
}

// ConcreteElement B
class Monitor implements ComputerPart {
    @Override
    public void accept(ComputerPartVisitor computerPartVisitor) {
        computerPartVisitor.visit(this);
    }
}

// ConcreteElement C
class Mouse implements ComputerPart {
    @Override
    public void accept(ComputerPartVisitor computerPartVisitor) {
        computerPartVisitor.visit(this);
    }
}

3. 객체 구조 (ObjectStructure) 및 구체적인 방문자 (ConcreteVisitor) 구현

컴퓨터 부품들을 모아놓은 Computer 클래스와, 부품 정보를 표시하는 ComputerPartDisplayVisitor를 구현합니다.

// ObjectStructure (Composite 역할도 겸함)
class Computer implements ComputerPart {
    ComputerPart[] parts;

    public Computer(){
        parts = new ComputerPart[] {new Mouse(), new Keyboard(), new Monitor()};      
    } 

    @Override
    public void accept(ComputerPartVisitor computerPartVisitor) {
        for (int i = 0; i < parts.length; i++) {
            parts[i].accept(computerPartVisitor);
        }
        computerPartVisitor.visit(this);
    }
}

// ConcreteVisitor 클래스
class ComputerPartDisplayVisitor implements ComputerPartVisitor {
    @Override
    public void visit(Computer computer) {
        System.out.println("컴퓨터 부품들을 표시합니다.");
    }
    @Override
    public void visit(Mouse mouse) {
        System.out.println("마우스를 표시합니다.");
    }
    @Override
    public void visit(Keyboard keyboard) {
        System.out.println("키보드를 표시합니다.");
    }
    @Override
    public void visit(Monitor monitor) {
        System.out.println("모니터를 표시합니다.");
    }
}

4. 클라이언트 (Client) 코드

클라이언트는 객체 구조에 방문자를 전달하여 연산을 수행합니다.

public class Demo {
    public static void main(String[] args) {
        ComputerPart computer = new Computer();
        computer.accept(new ComputerPartDisplayVisitor());
        
        // 실행 결과:
        // 마우스를 표시합니다.
        // 키보드를 표시합니다.
        // 모니터를 표시합니다.
        // 컴퓨터 부품들을 표시합니다.
    }
}

만약 '부품 가격 계산'이라는 새로운 기능이 필요하다면, ComputerPartPricingVisitor라는 새로운 방문자만 추가하면 됩니다. 기존 ComputerPart 클래스들은 전혀 수정할 필요가 없습니다.


결론

Visitor Class는 집합된 객체에 대해 일괄 연산을 여러변, 다양하게 수행하고 싶은 때 사용하며 다음과 같은 경우에도 사용됩니다.

 

  • 다양한 클래스의 객체들로 이루어진 복잡한 객체 구조에 대해 연산을 수행해야 할 때
  • 관련된 연산들을 하나의 방문자 클래스에 모아 관리하고 싶을 때

다만 다음과 같은 조건을 고려해야합니다.

  • 새로운 Element 추가의 어려움: 새로운 ConcreteElement 클래스가 추가되면, 모든 Visitor 인터페이스에 visit(NewElement) 메서드를 추가해야 합니다. 
  • 캡슐화 위반: 방문자는 요소의 내부 상태에 접근해야 하는 경우가 많아, 요소 클래스가 자신의 내부 상태를 노출하는 public 메서드를 제공해야 할 수 있습니다.

비지터 패턴은 데이터 구조와 알고리즘을 분리하는 강력한 메커니즘을 제공합니다. 데이터 구조가 안정적인 반면, 수행해야 할 작업이 자주 변경되거나 확장될 가능성이 높은 시스템에서 그 진가를 발휘합니다.

 

자 지금까지 Gang Of Four의 모든 패턴을 알아보았습니다. 다음 포스팅에서는 기술사를 공부하고 있는 사람으로서 각 패턴에 대해서 핵심적인 내용을 요약하는 시간과 패턴포스트에 올라간 사진들 및 키워드를 확인하는 시간을 가져보겠습니다 다들 긴 시간 수고많으셨습니다~!😀😀

안녕하세요! GoF 디자인 패턴 연재, 이번 시간에는 행위 패턴(Behavioral Patterns)의 한 종류로, 상속을 통해 알고리즘의 구조는 유지하면서 특정 단계들을 서브클래스에서 재정의할 수 있게 해주는 템플릿 메서드(Template Method) 패턴에 대해 알아보겠습니다.


정의

템플릿 메서드(Template Method) 패턴어떤 작업(알고리즘)을 처리하는 일련의 과정을 템플릿처럼 정해진 틀(메서드) 안에 넣어두고, 그 과정의 특정 단계들을 서브클래스에서 자유롭게 구현할 수 있도록 만드는 패턴입니다. 즉, 전체적인 알고리즘의 골격은 부모 클래스에서 정의하고, 구체적인 세부 내용은 자식 클래스에게 위임하는 방식입니다.

라면을 끓이는 과정을 생각해봅시다.

  1. 물을 끓인다.
  2. 면과 스프를 넣는다.
  3. (선택) 계란이나 파 같은 추가 재료를 넣는다.
  4. 그릇에 옮겨 담는다.

여기서 1, 2, 4번 과정은 모든 라면을 끓일 때 동일한 '템플릿'입니다. 하지만 3번 과정은 사람마다 다릅니다. 계란을 넣을 수도, 치즈를 넣을 수도, 아무것도 넣지 않을 수도 있죠. 템플릿 메서드 패턴은 이처럼 전체적인 절차(템플릿)는 고정하되, 그 안의 특정 단계를 자식 클래스가 마음대로 바꿀 수 있게 해주는 구조입니다.


사용예시

 

다양한 종류의 게임을 시작하고 종료하는 공통적인 절차를 템플릿 메서드 패턴으로 구현해 보겠습니다.

1. 추상 클래스 (AbstractClass) 정의

게임 플레이의 전체적인 흐름을 정의하는 Game 추상 클래스를 만듭니다.

// AbstractClass
public abstract class Game {
    
    // 서브클래스에서 오버라이드할 수 없는 템플릿 메서드
    public final void play() {
        // 알고리즘의 골격 (순서)
        initialize();
        startPlay();
        endPlay();
    }

    // 서브클래스에서 반드시 구현해야 하는 부분
    abstract void initialize();
    abstract void startPlay();
    abstract void endPlay();
}


2. 구체적인 클래스(ConcreteClass) 구현
`Game` 클래스를 상속받아 축구 게임과 농구 게임을 구체적으로 구현합니다.

// ConcreteClass A: 축구 게임
class Football extends Game {
    @Override
    void initialize() {
        System.out.println("축구 게임을 초기화합니다.");
    }

    @Override
    void startPlay() {
        System.out.println("축구 게임을 시작합니다!");
    }

    @Override
    void endPlay() {
        System.out.println("축구 게임을 종료합니다.");
    }
}

// ConcreteClass B: 농구 게임
class Basketball extends Game {
    @Override
    void initialize() {
        System.out.println("농구 게임을 준비합니다.");
    }

    @Override
    void startPlay() {
        System.out.println("농구 게임, 점프 볼!");
    }

    @Override
    void endPlay() {
        System.out.println("농구 게임, 버저비터와 함께 종료!");
    }
}

3. 클라이언트 (Client) 코드

클라이언트는 구체적인 게임 클래스를 선택하고, play()라는 템플릿 메서드만 호출하면 됩니다.

public class GameLoader {
    public static void main(String[] args) {
        Game game = new Football();
        game.play();

        System.out.println("\n--- 다른 게임 시작 ---");
        
        game = new Basketball();
        game.play();
        
        // 실행 결과:
        // 축구 게임을 초기화합니다.
        // 축구 게임을 시작합니다!
        // 축구 게임을 종료합니다.
        //
        // --- 다른 게임 시작 ---
        // 농구 게임을 준비합니다.
        // 농구 게임, 점프 볼!
        // 농구 게임, 버저비터와 함께 종료!
    }
}


결론

Template Method 패턴은 아래와 같은 경우에 사용됩니다.

 

  • 알고리즘의 전체 구조는 동일하지만, 세부적인 단계에서 약간의 차이가 있는 경우: 코드 중복을 피하고 공통 로직을 부모 클래스에서 효율적으로 관리할 수 있습니다.
  • 알고리즘의 특정 부분을 서브클래스가 확장하거나 변경할 수 있도록 허용하고 싶을 때: 프레임워크나 라이브러리에서 자주 사용되는 방식입니다. 개발자는 정해진 틀 안에서 특정 부분만 구현하면 됩니다.
  • 알고리즘의 중요한 부분은 서브클래스가 변경하지 못하도록 강제하고 싶을 때: 템플릿 메서드를 final로 선언하여 전체 구조의 변경을 막을 수 있습니다.

대신 코드자체가 템플릿 안에서 움직여야하니까 조금 경직될 수는 있겠죠

 

템플릿 메서드 패턴은 상속을 통해 코드 재사용성을 높이고, 일정한 프로세스를 강제하는 매우 고전적이고 강력한 패턴입니다. '헐리우드 원칙(Hollywood Principle)' - "Don't call us, we'll call you." (우리를 부르지 마세요, 우리가 당신을 부를 겁니다.) - 을 가장 잘 보여주는 패턴이기도 합니다. 다음은 대망의 마지막 GoF 패턴, Visitor Pattern을 알아보겠습니다.

안녕하세요! GoF 디자인 패턴 연재, 이번 시간에는 행위 패턴(Behavioral Patterns)의 한 종류로, 객체 간의 일대다(one-to-many) 의존 관계를 정의하여, 어떤 객체(Subject)의 상태가 변하면 그와 연관된 모든 의존 객체(Observer)들이 자동으로 알림을 받고 갱신되도록 만드는 옵서버(Observer) 패턴에 대해 알아보겠습니다.


옵서버(Observer) 패턴한 객체의 상태 변화를 그 객체를 '관찰(Observing)'하는 다른 객체들에게 자동으로 알려주는 발행-구독(Publish-Subscribe) 모델을 구현하는 패턴입니다.

 

가장 쉬운 예는 '유튜브 채널 구독'입니다.

  • 유튜버(Subject, 발행자)가 새로운 영상을 올리면,
  • 그 채널을 구독한 모든 구독자(Observer, 구독자)들에게 자동으로 알림이 갑니다.

여기서 중요한 점은, 유튜버는 자신의 구독자가 누구누구인지, 몇 명인지 일일이 알 필요가 없다는 것입니다. 단지 '구독자 목록'에 대고 "새 영상 올렸어요!"라고 외치기만 하면 됩니다. 구독자들 역시 다른 구독자가 누구인지 알 필요 없이, 오직 유튜버의 소식만 받습니다. 이처럼 발행자와 구독자 간의 결합을 느슨하게(Loose Coupling) 만드는 것이 옵서버 패턴의 핵심입니다

 

옵저버 패턴은 다음과 같은 객체들로 구성됩니다.

  • Subject (주체): 옵서버들을 관리(등록, 삭제)하고, 자신의 상태가 변했을 때 옵서버들에게 알리는(notify) 인터페이스를 제공합니다.
  • ConcreteSubject (구체적인 주체): Subject 인터페이스를 구현하며, 상태를 저장하고 관리합니다. 상태가 변경되면 자신에게 등록된 모든 옵서버들에게 알립니다.
  • Observer (옵서버): Subject의 상태 변화에 대한 알림을 받기 위한 업데이트 인터페이스(예: update())를 정의합니다.
  • ConcreteObserver (구체적인 옵서버): Observer 인터페이스를 구현하며, Subject로부터 알림을 받았을 때 수행할 구체적인 동작을 정의합니다.

사용예시

새로운 뉴스가 발행될 때마다 구독자들에게 알림을 보내는 뉴스 통신사 예제를 통해 옵서버 패턴을 구현해 보겠습니다.

1. 주체 (Subject) 및 옵서버 (Observer) 인터페이스 정의

먼저 표준 인터페이스들을 정의합니다.

// Observer 인터페이스
public interface Observer {
    void update(String news);
}

// Subject 인터페이스
public interface Subject {
    void registerObserver(Observer o);
    void removeObserver(Observer o);
    void notifyObservers();
}

2. 구체적인 주체 (ConcreteSubject) 클래스 구현

옵서버들을 관리하고 새로운 뉴스를 발행하는 NewsAgency 클래스를 구현합니다.

import java.util.ArrayList;
import java.util.List;

// ConcreteSubject 클래스
public class NewsAgency implements Subject {
    private List<Observer> observers;
    private String latestNews;

    public NewsAgency() {
        this.observers = new ArrayList<>();
    }

    @Override
    public void registerObserver(Observer o) {
        observers.add(o);
    }

    @Override
    public void removeObserver(Observer o) {
        observers.remove(o);
    }

    @Override
    public void notifyObservers() {
        for (Observer observer : observers) {
            observer.update(latestNews);
        }
    }

    // 새로운 뉴스가 생기면 옵서버들에게 알림
    public void setNews(String news) {
        this.latestNews = news;
        System.out.println("\n[뉴스 속보] " + news);
        notifyObservers();
    }
}

3. 구체적인 옵서버 (ConcreteObserver) 클래스 구현

뉴스를 구독하는 NewsSubscriber 클래스를 구현합니다.

// ConcreteObserver 클래스
public class NewsSubscriber implements Observer {
    private String name;

    public NewsSubscriber(String name) {
        this.name = name;
    }

    @Override
    public void update(String news) {
        System.out.println(name + " 님이 새 소식을 받았습니다: " + news);
    }
}

4. 클라이언트 (Client) 코드

클라이언트는 주체와 옵서버들을 생성하고, 구독 관계를 설정합니다.

public class NewsSystem {
    public static void main(String[] args) {
        // 1. 주체(뉴스 통신사) 생성
        NewsAgency agency = new NewsAgency();

        // 2. 옵서버(구독자)들 생성
        Observer sub1 = new NewsSubscriber("구독자A");
        Observer sub2 = new NewsSubscriber("구독자B");

        // 3. 주체에 옵서버들 등록 (구독)
        agency.registerObserver(sub1);
        agency.registerObserver(sub2);

        // 4. 주체의 상태 변경 -> 옵서버들에게 자동 알림
        agency.setNews("디자인 패턴 스터디 시작!");
        
        // 5. 옵서버 한 명 구독 취소
        agency.removeObserver(sub2);
        
        // 6. 다시 상태 변경
        agency.setNews("옵서버 패턴 학습 중!");
        
        // 실행 결과:
        //
        // [뉴스 속보] 디자인 패턴 스터디 시작!
        // 구독자A 님이 새 소식을 받았습니다: 디자인 패턴 스터디 시작!
        // 구독자B 님이 새 소식을 받았습니다: 디자인 패턴 스터디 시작!
        //
        // [뉴스 속보] 옵서버 패턴 학습 중!
        // 구독자A 님이 새 소식을 받았습니다: 옵서버 패턴 학습 중!
    }
}


결론

옵저버 패턴은 위 예시처럼 한 객체의 상태 변화에 따라 다른 객체들의 상태도 변경되어야 하지만, 어떤 객체들이 변경되어야 하는지 미리 알 수 없는 경우에서 뿐 아니라 다음의 경우에도 사용됩니다.

  • 발행-구독 모델이 필요한 모든 경우: GUI 이벤트 처리, MVC 패턴(Model-View 관계) 등
  • 객체들 간의 결합도를 최대한 낮추고 싶을 때: SubjectObserver는 서로의 구체적인 클래스를 몰라도 인터페이스를 통해 통신하므로, 독립적으로 재사용하고 수정할 수 있습니다.

옵저버 패턴을 구현하고 운영할때는 아래와 같은 사항을 주의해야합니다.

  • 예상치 못한 업데이트 순서: Subject는 등록된 Observer들을 순회하며 알림을 보내지만, 특정 순서에 따라 업데이트가 일어나야 하는 경우에는 주의가 필요합니다.
  • 불필요한 업데이트 가능성: Subject의 사소한 변화에도 모든 Observer에게 알림이 갈 수 있습니다. 이를 해결하기 위해 push 방식(Subject가 모든 정보를 보냄) 대신 pull 방식(Observer가 필요한 정보만 가져감)을 사용하기도 합니다.

즉, 옵서버 패턴은 객체 간의 느슨한 결합을 통해 유연하고 재사용 가능한 시스템을 만드는 데 핵심적인 역할을 합니다. 이벤트 기반 시스템을 구축할 때 가장 먼저 고려해볼 만한 강력한 패턴입니다. 다음으로는 상속관계에서 미리 메서드를 정의해는, Template Method 패턴을 알아보겠습니다.

안녕하세요! GoF 디자인 패턴 연재, 이번 시간에는 행위 패턴(Behavioral Patterns)의 한 종류로, 유사한 알고리즘들을 각각의 클래스로 캡슐화하고, 런타임에 동적으로 알고리즘을 교체할 수 있게 해주는 스트래티지(Strategy) 패턴에 대해 알아보겠습니다.


정의

스트래티지(Strategy) 패턴다양한 전략(알고리즘)들을 각각의 클래스로 정의하고, 이들을 서로 바꿔서 사용할 수 있게 하는 패턴입니다. 즉, '어떻게(How)' 일을 처리할 것인지를 나타내는 '전략'을 객체로 만들어, 필요에 따라 클라이언트가 선택하여 사용할 수 있도록 합니다.

서울에서 부산까지 가는 방법을 생각해보겠습니다.

  • KTX를 이용하는 전략(Strategy A)
  • 버스를 이용하는 전략(Strategy B)
  • 비행기를 이용하는 전략(Strategy C)

어떤 교통수단을 선택하든 '이동한다'는 목적은 동일하지만, 이동하는 방법(알고리즘)은 각각 다릅니다. 스트래티지 패턴은 이처럼 다양한 방법(전략)들을 각각의 독립된 객체로 만들고, '여행'이라는 문맥(Context) 안에서 이 전략들을 자유롭게 갈아 끼울 수 있게 해줍니다.

 

Strategy 패턴의 구현에는 다음과 같은 요소들이 사용됩니다.

  • Strategy (전략): 모든 구체적인 전략들이 구현해야 할 공통 인터페이스입니다. 보통 알고리즘을 실행하는 단일 메서드(예: execute())를 정의합니다.
  • ConcreteStrategy (구체적인 전략): Strategy 인터페이스를 구현하는 실제 알고리즘 클래스들입니다. (예: KtxStrategy, BusStrategy)
  • Context (문맥): Strategy 객체를 참조하는 주체 객체입니다. 클라이언트로부터 받은 요청을 자신이 참조하는 Strategy 객체에게 위임하여 처리합니다. Context는 구체적인 전략이 무엇인지는 알지 못합니다.

사용예시

쇼핑 카트에서 결제할 때, 다양한 결제 방법(신용카드, 카카오페이)을 선택하여 사용하는 예제를 통해 스트래티지 패턴을 구현해 보겠습니다.

1. 전략 (Strategy) 인터페이스 정의

모든 결제 방식이 공통으로 가질 인터페이스를 정의합니다.

// Strategy 인터페이스
public interface PaymentStrategy { void pay(int amount); }

2. 구체적인 전략 (ConcreteStrategy) 클래스 구현

실제 결제 알고리즘을 담고 있는 클래스들을 구현합니다.

// ConcreteStrategy A: 신용카드 결제
class CreditCardStrategy implements PaymentStrategy {
    private String name;
    private String cardNumber;

    public CreditCardStrategy(String name, String cardNumber) {
        this.name = name;
        this.cardNumber = cardNumber;
    }

    @Override
    public void pay(int amount) {
        System.out.println(amount + "원을 신용카드(" + cardNumber + ")로 결제했습니다.");
    }
}

// ConcreteStrategy B: 카카오페이 결제
class KakaoPayStrategy implements PaymentStrategy {
    private String email;

    public KakaoPayStrategy(String email) {
        this.email = email;
    }

    @Override
    public void pay(int amount) {
        System.out.println(amount + "원을 카카오페이(" + email + ")로 결제했습니다.");
    }
}

3. 문맥 (Context) 클래스 구현

결제 전략을 사용하여 실제 결제를 수행하는 ShoppingCart 클래스를 구현합니다.

// Context 클래스
public class ShoppingCart {
    private int amount = 0;

    public ShoppingCart(int amount) { this.amount = amount; }

    // 클라이언트가 선택한 전략을 받아 결제를 수행
    public void checkout(PaymentStrategy paymentMethod) { paymentMethod.pay(amount); }
}

4. 클라이언트 (Client) 코드

클라이언트는 결제 시점에 사용할 구체적인 전략을 선택하여 Context에 전달합니다.

public class OnlineMarket {
    public static void main(String[] args) {
        // 1. Context 객체 생성
        ShoppingCart cart = new ShoppingCart(10000);

        // 2. 신용카드로 결제 (전략 선택)
        cart.checkout(new CreditCardStrategy("홍길동", "1234-5678-9012-3456"));
        
        // 3. 카카오페이로 결제 (다른 전략 선택)
        cart.checkout(new KakaoPayStrategy("gildong@example.com"));

        // 실행 결과:
        // 10000원을 신용카드(1234-5678-9012-3456)로 결제했습니다.
        // 10000원을 카카오페이(gildong@example.com)로 결제했습니다.
    }
}


결론

Strategy 패턴을 이용하면 동적으로 런타임 도중에 동일한 객체에 대한 알고리즘을 다르게 하여 적용이 가능합니다. 그 외 아래와 같은 상황에서도 Strategy 패턴을 활용하기 좋습니다,

 

  • if/else나 switch 문을 사용하여 알고리즘을 선택하는 코드를 피하고 싶을 때: 스트래티지 패턴은 조건문을 없애고, 새로운 전략을 추가할 때 기존 코드를 수정할 필요가 없게 만듭니다(OCP 준수).
  • 알고리즘의 세부 구현을 클라이언트로부터 숨기고 싶을 때: 클라이언트는 Strategy 인터페이스만 알면 되므로, 알고리즘이 어떻게 구현되었는지 알 필요가 없습니다.

지난시간에 알아본 스테이트 패턴과 유사점이 있어서 기술사 답안에 쓰면 좋을거 같은데, 스트래티지 패턴은 스테이트 패턴과 구조가 매우 유사하지만, 의도에서 큰 차이가 있습니다.

  • 스트래티지 패턴: '어떻게' 할 것인가에 초점을 맞춥니다. 클라이언트가 다양한 전략 중 하나를 선택하여 Context에 주입하고, Context는 그 전략을 그대로 사용합니다. Context의 상태와는 무관하게 전략을 바꿀 수 있습니다.
  • 스테이트 패턴: '무엇을' 할 수 있는가에 초점을 맞춥니다. ContextState 객체 스스로가 자신의 내부 상태에 따라 행동과 다음 상태를 결정하고 변경합니다. 클라이언트가 상태를 직접 선택하지 않습니다.

간단히 말해, 스트래티지 패턴은 외부에서 주입된 전략을 사용하고, 스테이트 패턴은 내부 상태에 따라 행동이 자동 전환됩니다.

스트래티지 패턴은 '상속보다는 합성을 사용하라'는 객체 지향 원칙을 잘 보여주는 또 다른 훌륭한 예입니다. 알고리즘을 독립적인 객체로 다룸으로써, 시스템을 훨씬 더 유연하고 확장 가능하게 만들어줍니다. 다음 포스팅에서는 외부의 상태관찰에 따른 디자인 행위패턴, 옵저버 패턴을 알아보겠습니다.

안녕하세요! GoF 디자인 패턴 연재, 이번 시간에는 행위 패턴(Behavioral Patterns)의 한 종류로, 객체의 내부 상태가 변경됨에 따라 객체의 행동을 바꿀 수 있게 해주는 스테이트(State) 패턴에 대해 알아보겠습니다.


정의

스테이트(State) 패턴객체가 자신의 내부 상태(Internal State)에 따라 스스로 행동을 변경할 수 있게 하는 패턴입니다. 이 패턴을 사용하면, 마치 객체의 클래스가 바뀌는 것처럼 보이게 됩니다.

가장 대표적인 예는 '자판기'입니다.

  • '동전 없음(NoCoin)' 상태: 동전을 넣을 수만 있습니다.
  • '동전 있음(HasCoin)' 상태: 음료를 선택하거나 동전을 반환받을 수 있습니다.
  • '판매 중(Sold)' 상태: 음료가 나오는 중이므로 다른 행동을 할 수 없습니다.
  • '매진(SoldOut)' 상태: 어떤 행동도 할 수 없습니다.

만약 if/elseswitch 문으로 이 모든 상태와 행동을 관리한다면 코드는 매우 복잡해질 것입니다. 스테이트 패턴은 각 상태를 별도의 클래스로 분리하고, 자판기(Context)는 현재 상태를 나타내는 '상태 객체'를 가집니다. 그리고 모든 행동을 현재 상태 객체에게 위임합니다. 이렇게 하면 상태가 변경될 때마다 자판기의 행동이 자연스럽게 바뀌게 됩니다.

 

State 패턴의 주요한 구성은 다음과 같습니다.

  • Context (문맥): 상태를 가지는 주체 객체입니다. 현재 상태를 나타내는 State 객체의 인스턴스를 유지하며, 클라이언트의 요청을 현재 State 객체에게 전달합니다. (예: VendingMachine)
  • State (상태): 특정 상태가 가져야 할 행동들을 정의하는 공통 인터페이스입니다. (예: insertCoin(), selectItem())
  • ConcreteState (구체적인 상태): State 인터페이스를 구현하는 실제 상태 클래스들입니다. 각 상태에서 가능한 행동을 구체적으로 구현하며, 특정 행동이 수행된 후 Context의 상태를 다음 상태로 변경하는 역할을 합니다. (예: NoCoinState, HasCoinState)

사용예시

앞서 설명한 자판기 예제를 통해 스테이트 패턴을 구현해 보겠습니다.

1. 상태 (State) 인터페이스 정의

자판기의 모든 상태에서 발생할 수 있는 행동들을 인터페이스로 정의합니다.

// State 인터페이스
public interface State {
    void insertCoin();
    void ejectCoin();
    void selectItem();
    void dispense();
}

2. 문맥 (Context) 클래스 구현

상태를 관리하는 VendingMachine 클래스를 구현합니다.

// Context 클래스
public class VendingMachine {
    // 모든 상태들을 인스턴스로 가짐
    private State noCoinState;
    private State hasCoinState;
    private State soldState;
    private State soldOutState;

    private State currentState; // 현재 상태
    private int count = 0; // 음료 재고

    public VendingMachine(int initialStock) {
        noCoinState = new NoCoinState(this);
        hasCoinState = new HasCoinState(this);
        soldState = new SoldState(this);
        soldOutState = new SoldOutState(this);
        
        this.count = initialStock;
        if (initialStock > 0) {
            currentState = noCoinState;
        } else {
            currentState = soldOutState;
        }
    }

    // 행동들을 현재 상태 객체에 위임
    public void insertCoin() { currentState.insertCoin(); }
    public void ejectCoin() { currentState.ejectCoin(); }
    public void selectItem() { currentState.selectItem(); }
    
    // 상태 변경 메서드들
    void setState(State state) { this.currentState = state; }
    public State getNoCoinState() { return noCoinState; }
    public State getHasCoinState() { return hasCoinState; }
    public State getSoldState() { return soldState; }
    public State getSoldOutState() { return soldOutState; }
    
    public void releaseItem() {
        System.out.println("음료수가 나왔습니다.");
        if (count > 0) {
            count--;
        }
    }
    public int getCount() { return count; }
}

3. 구체적인 상태 (ConcreteState) 클래스 구현

각각의 상태를 클래스로 구현합니다. 각 클래스는 특정 상태에서 할 수 있는 일과, 다음 상태로의 전환을 책임집니다.

// ConcreteState A: 동전 없음 상태
class NoCoinState implements State {
    VendingMachine machine;
    public NoCoinState(VendingMachine machine) { this.machine = machine; }
    public void insertCoin() {
        System.out.println("동전을 넣으셨습니다.");
        machine.setState(machine.getHasCoinState()); // 상태를 '동전 있음'으로 변경
    }
    public void ejectCoin() { System.out.println("반환할 동전이 없습니다."); }
    public void selectItem() { System.out.println("먼저 동전을 넣어주세요."); }
    public void dispense() { /* Do nothing */ }
}

// ConcreteState B: 동전 있음 상태
class HasCoinState implements State {
    VendingMachine machine;
    public HasCoinState(VendingMachine machine) { this.machine = machine; }
    public void insertCoin() { System.out.println("이미 동전이 있습니다."); }
    public void ejectCoin() {
        System.out.println("동전을 반환합니다.");
        machine.setState(machine.getNoCoinState()); // 상태를 '동전 없음'으로 변경
    }
    public void selectItem() {
        System.out.println("음료를 선택하셨습니다.");
        machine.setState(machine.getSoldState()); // 상태를 '판매 중'으로 변경
        machine.getSoldState().dispense(); // 바로 음료 배출 로직 호출
    }
    public void dispense() { /* Do nothing */ }
}

// 다른 상태들(SoldState, SoldOutState)도 유사하게 구현...
class SoldState implements State {
    VendingMachine machine;
    public SoldState(VendingMachine machine) { this.machine = machine; }
    public void insertCoin() { System.out.println("음료가 나오는 중입니다."); }
    public void ejectCoin() { System.out.println("이미 음료를 선택하셨습니다."); }
    public void selectItem() { System.out.println("음료가 나오는 중입니다."); }
    public void dispense() {
        machine.releaseItem();
        if (machine.getCount() > 0) {
            machine.setState(machine.getNoCoinState());
        } else {
            System.out.println("죄송합니다. 매진되었습니다.");
            machine.setState(machine.getSoldOutState());
        }
    }
}


결론

State 패턴은 객체의 정적인 '상태'와 그 상태에 따른 행동을 정의하는 모델로 다음의 경우에 활용되기 용이합니다.

 

  • 객체의 행동이 그 객체의 상태에 따라 달라져야 할 때: 즉, 상태에 따라 메서드가 다른 동작을 해야 할 때 유용합니다.
  • 객체의 상태에 따른 조건문(if/else, switch)이 매우 복잡하고 많을 때: 스테이트 패턴은 이런 복잡한 조건문들을 각각의 상태 클래스로 분리하여 코드를 단순하고 명확하게 만듭니다.
  • 상태 전이 로직을 명확하게 표현하고 싶을 때: 각 상태 클래스는 자신의 다음 상태가 무엇인지 명확하게 정의하므로, 상태 변화의 흐름을 파악하기 쉽습니다.

다만 상태에 따른 객체의 개수가 많아짐에 따라 구현해야 할 ConcreteState 클래스의 수가 늘어나 시스템이 복잡해질 수 있습니다. 상태가 몇 개 없고 거의 변하지 않는다면 굳이 이 패턴을 사용할 필요는 없습니다.

 

스테이트 패턴은 복잡한 상태 기반의 로직을 체계적이고 관리하기 쉬운 구조로 만들어주는 강력한 패턴입니다. '상태' 자체를 객체로 다룸으로써, 객체 지향적인 방식으로 상태 머신(State Machine)을 구현할 수 있습니다. 다음 패턴으로는 알고리즘 전략에 기반한 모델, Strategy pattern을 알아보겠습니다.

안녕하세요! GoF 디자인 패턴 연재, 이번 시간에는 행동 패턴(Behavioral Patterns)의 한 종류로, 간단한 언어의 문법을 정의하고 그 문장을 해석하는 방법을 제공하는 인터프리터(Interpreter) 패턴에 대해 알아보겠습니다.


정의

인터프리터(Interpreter) 패턴특정 언어에 대해, 그 언어의 문법을 위한 표현(Representation)을 정의하고, 이 표현을 사용하여 문장을 해석하는 해석기(Interpreter)를 만드는 패턴입니다. 즉, 해결하려는 특정 문제에 대해 간단한 '미니 언어'를 만들고, 그 언어의 규칙에 따라 동작하는 프로그램을 만드는 방식입니다.

 

예를 들어, "A 그리고 B" 또는 "C 또는 (D 그리고 E)"와 같은 논리식을 계산해야 한다고 생각해봅시다. 여기서 '그리고', '또는'은 연산자이고, A, B, C 등은 피연산자입니다. 인터프리터 패턴은 이러한 문법 구조(논리식)를 클래스 구조로 표현합니다. '그리고' 연산을 위한 클래스, '또는' 연산을 위한 클래스, 피연산자를 위한 클래스 등을 만들어, 이 클래스들을 조합(트리 구조)하여 전체 논리식을 해석하고 결과를 도출합니다. 컴파일러나 SQL 파서처럼 복잡한 언어를 해석하는 데에도 이 패턴의 원리가 적용되지만, 주로 해결해야 할 문제가 특정 패턴의 언어로 표현될 수 있을 때 유용하게 사용됩니다.

 

인터프리터 패턴은 보통 컴포지트 패턴과 유사한 트리 구조를 가집니다.

  • AbstractExpression (추상 표현식): 언어의 모든 문법 요소(터미널, 논터미널)가 구현해야 할 공통 인터페이스입니다. 보통 interpret()라는 해석 메서드를 정의합니다.
  • TerminalExpression (종단 표현식): 언어의 가장 기본적인 문법 요소(종단 기호)를 나타냅니다. 트리 구조의 잎(Leaf) 노드에 해당합니다. (예: 변수, 상수)
  • NonterminalExpression (비종단 표현식): 다른 표현식들을 자식으로 가지는 문법 규칙을 나타냅니다. 트리 구조에서 가지(Branch) 노드에 해당하며, 자식 표현식들의 결과를 조합하여 자신의 결과를 만듭니다. (예: 덧셈, 뺄셈 연산자)
  • Context (문맥): 인터프리터가 문장을 해석하는 데 필요한 전역 정보(예: 변수의 값)를 담고 있습니다.
  • Client (클라이언트): 해석할 문장을 받아 파싱하여 추상 구문 트리(AST)를 구성하고, interpret() 메서드를 호출하여 해석을 시작합니다.

사용예시

후위 표기법(Postfix Notation)으로 작성된 간단한 수식을 계산하는 예제를 통해 인터프리터 패턴을 구현해 보겠습니다. (예: "4 3 2 - 1 + *")

1. 추상 표현식 (AbstractExpression) 인터페이스 정의

모든 표현식(숫자, 연산자)이 공통으로 가질 인터페이스를 정의합니다.

import java.util.Map;

// AbstractExpression 인터페이스
public interface PostfixExpression {
    int interpret(Map<Character, Integer> context);
}

2. 터미널 및 비종단 표현식 (Terminal & Nonterminal) 클래스 구현

숫자를 나타내는 NumberExpression과 연산자를 나타내는 PlusExpression, MinusExpression 등을 구현합니다.

// TerminalExpression: 숫자를 나타내는 표현식
class NumberExpression implements PostfixExpression {
    private int number;

    public NumberExpression(int number) {
        this.number = number;
    }

    @Override
    public int interpret(Map<Character, Integer> context) {
        return this.number;
    }
}

// NonterminalExpression: 덧셈 연산
class PlusExpression implements PostfixExpression {
    private PostfixExpression left;
    private PostfixExpression right;

    public PlusExpression(PostfixExpression left, PostfixExpression right) {
        this.left = left;
        this.right = right;
    }

    @Override
    public int interpret(Map<Character, Integer> context) {
        return left.interpret(context) + right.interpret(context);
    }
}

// NonterminalExpression: 뺄셈 연산
class MinusExpression implements PostfixExpression {
    private PostfixExpression left;
    private PostfixExpression right;

    public MinusExpression(PostfixExpression left, PostfixExpression right) {
        this.left = left;
        this.right = right;
    }

    @Override
    public int interpret(Map<Character, Integer> context) {
        return left.interpret(context) - right.interpret(context);
    }
}

3. 클라이언트 (Client) 코드

클라이언트는 후위 표기법 문자열을 파싱하여 표현식 트리를 만들고, 최종 결과를 계산합니다.

import java.util.Stack;

public class PostfixParser {

    public static PostfixExpression parse(String expression) {
        Stack<PostfixExpression> stack = new Stack<>();
        for (char c : expression.toCharArray()) {
            // 연산자를 만나면 스택에서 두 개의 표현식을 꺼내 새로운 연산 표현식을 만듦
            if (isOperator(c)) {
                PostfixExpression right = stack.pop();
                PostfixExpression left = stack.pop();
                stack.push(getOperatorInstance(c, left, right));
            } 
            // 숫자를 만나면 숫자 표현식을 만들어 스택에 넣음
            else if (Character.isDigit(c)) {
                stack.push(new NumberExpression(Character.getNumericValue(c)));
            }
        }
        return stack.pop();
    }

    private static boolean isOperator(char c) {
        return c == '+' || c == '-';
    }

    private static PostfixExpression getOperatorInstance(char c, PostfixExpression left, PostfixExpression right) {
        switch (c) {
            case '+':
                return new PlusExpression(left, right);
            case '-':
                return new MinusExpression(left, right);
        }
        return null;
    }
    
    public static void main(String[] args) {
        String expression = "43-2+"; // (4-3)+2
        PostfixExpression parsedExpression = parse(expression);
        
        // Context는 이 예제에서 사용되지 않음
        int result = parsedExpression.interpret(null); 
        System.out.println("결과: " + result); // 결과: 3
    }
}


결론

인터프리터 패턴은 다음의 경우에 사용됩니다

 

  • 해결하려는 문제를 간단한 언어로 표현할 수 있을 때: SQL, 정규 표현식처럼 특정 도메인에 특화된 언어를 처리해야 할 때 유용합니다.
  • 언어의 문법이 비교적 단순할 때: 문법이 복잡해지면 클래스 계층이 너무 깊고 복잡해져 관리하기 어렵습니다.
  • 문법을 변경하거나 새로운 표현식을 추가하는 것이 쉬워야 할 때: 새로운 AbstractExpression의 서브클래스를 만드는 것으로 쉽게 문법을 확장할 수 있습니다.

그러나 다음의 사항을 고려해야합니다.

  • 복잡한 문법 처리의 어려움: 문법 규칙이 많아지면 클래스의 수가 급격히 늘어나고 시스템이 매우 복잡해집니다. 이런 경우에는 파서 생성기(Parser Generator) 같은 전문적인 도구를 사용하는 것이 더 효율적입니다.
  • 성능 문제: 재귀적인 호출 구조로 인해 성능이 중요한 시스템에는 적합하지 않을 수 있습니다.

인터프리터 패턴은 특정 도메인의 문제를 언어적인 관점에서 해결하는 독특한 방법을 제공합니다. 비록 모든 상황에 적합한 만능 해결책은 아니지만, 적절한 문제에 적용했을 때 확장성 있는 설계를 가능하게 합니다. 다음 시간에는 객체상태기반의 패턴, State 패턴을 알아보겠습니다.

+ Recent posts