[GoF 디자인 패턴] 책임 연쇄 패턴 (Chain-of-Responsibility) 패턴, 커맨드 (Command) 패턴

JMM·2025년 1월 8일
1

GoF 디자인 패턴

목록 보기
7/11
post-thumbnail

1. 책임 연쇄 패턴 (Chain-of-Responsibility) 패턴 : 요청을 보내는 쪽(sender)과 요청을 처리하는 쪽(receiver)의 분리하는 패턴

• 핸들러 체인을 사용해서 요청을 처리한다.

- 1) 여러 개의 핸들러(Handler)를 체인으로 연결하여, 요청이 각 핸들러를 거치며 처리된다.
- 2) 특정 핸들러가 요청을 처리하지 않으면, 다음 핸들러로 요청을 전달한다.

Before

구조

  1. Request 클래스:

    • 요청 데이터를 캡슐화하는 클래스. 요청의 본문(body)을 저장하고 제공.
    public class Request {
        private String body;
    
        public Request(String body) {
            this.body = body;
        }
    
        public String getBody() {
            return body;
        }
    
        public void setBody(String body) {
            this.body = body;
        }
    }
  2. RequestHandler 클래스:

    • 요청을 처리하는 기본 핸들러.
    • 요청을 받아 출력하는 handler() 메서드 제공.
    public class RequestHandler {
        public void handler(Request request) {
            System.out.println(request.getBody());
        }
    }
  3. LoggingRequestHandler 클래스:

    • 요청을 처리하기 전, 로깅 작업을 추가.
    public class LoggingRequestHandler extends RequestHandler {
        @Override
        public void handler(Request request) {
            System.out.println("로깅");
            super.handler(request);
        }
    }
  4. AuthRequestHandler 클래스:

    • 요청을 처리하기 전, 인증 작업을 추가.
    public class AuthRequestHandler extends RequestHandler {
        @Override
        public void handler(Request request) {
            System.out.println("인증이 되었나?");
            System.out.println("이 핸들러를 사용할 수 있는 유저인가?");
            super.handler(request);
        }
    }
  5. Client 클래스:

    • 요청을 생성하고, 특정 핸들러(LoggingRequestHandler)로 요청을 처리.
    public class Client {
        public static void main(String[] args) {
            Request request = new Request("무궁화 꽃이 피었습니다.");
            RequestHandler requestHandler = new LoggingRequestHandler();
            requestHandler.handler(request);
        }
    }

문제점

  1. 핸들러 체인 부재:

    • 각 핸들러가 독립적으로 동작하며, 체인 형태로 연결되지 않음.
    • 여러 핸들러를 순차적으로 호출하려면 클라이언트 코드에서 수동으로 호출해야 함.
  2. 확장성 부족:

    • 핸들러를 추가하거나 변경하려면 클라이언트 코드를 수정해야 함.
    • 핸들러 간의 동작 순서를 변경하기 어려움.

After

핵심 변경점

  1. 핸들러 체인 도입:

    • 각 핸들러는 다음 핸들러(nextHandler)를 참조하며, 요청을 처리한 뒤 다음 핸들러로 전달.
    • 핸들러 간의 호출 순서를 체인 형태로 구성 가능.
  2. 클라이언트와 핸들러의 분리:

    • 클라이언트는 첫 번째 핸들러만 호출하며, 나머지 핸들러는 체인에서 자동으로 처리.

구조

  1. RequestHandler (추상 클래스):

    • 모든 핸들러가 상속받는 추상 클래스.

    • 다음 핸들러(nextHandler)를 참조하며, 체인을 구성.

    • handle() 메서드에서 다음 핸들러로 요청을 전달.

      public abstract class RequestHandler {
          private RequestHandler nextHandler;
      
          public RequestHandler(RequestHandler nextHandler) {
              this.nextHandler = nextHandler;
          }
      
          public void handle(Request request) {
              if (nextHandler != null) {
                  nextHandler.handle(request);
              }
          }
      }

      왜 RequestHandler를 인터페이스가 아닌 추상 클래스로 구현하였을까?

      인터페이스만 사용할 경우, 모든 구현 클래스에서 setNext나 handler과 같은 메서드를 직접 구현해야 한다! 공통 로직이 있다면 이를 각 클래스에 반복적으로 생성하거나 별도의 유틸리티 클래스에서 관리해야 하므로, 코드가 장황해질 수 있다.

      그렇다면 추상 클래스를 사용하면 장점이 무엇이 있을까?

      1) 책임 기본 흐름을 보장한다.

    • 다음 핸들러로 요청을 전달하는 공통 로직이 강제되므로, 실수로 생략하는 일을 방지한다.

      2) 확장성을 제공한다.

    • 하위 클래스에서 공통 로직을 오버라이드하거나 기본 동작 위에 추가 작업을 구현할 수 있다!

      3) 간결한 설계가 가능하다.

    • 인터페이스만 사용할 경우, 모든 구현 클래스가 동일한 코드를 반복 작성해야 하거나 별도의 기본 구현 클래스가 필요하다. 추상 클래스는 이런 추가적인 구조 없이 간결하게 설계할 수 있다!


  1. AuthRequestHandler:

    • 체인에서 인증 작업을 처리하는 핸들러.
    public class AuthRequestHandler extends RequestHandler {
        public AuthRequestHandler(RequestHandler nextHandler) {
            super(nextHandler);
        }
    
        @Override
        public void handle(Request request) {
            System.out.println("인증이 되었는가?");
            super.handle(request);
        }
    }
  2. LoggingRequestHandler:

    • 체인에서 로깅 작업을 처리하는 핸들러.
    public class LoggingRequestHandler extends RequestHandler {
        public LoggingRequestHandler(RequestHandler nextHandler) {
            super(nextHandler);
        }
    
        @Override
        public void handle(Request request) {
            System.out.println("로깅");
            super.handle(request);
        }
    }
  3. PrintRequestHandler:

    • 체인에서 요청 출력 작업을 처리하는 핸들러.
    public class PrintRequestHandler extends RequestHandler {
        public PrintRequestHandler(RequestHandler nextHandler) {
            super(nextHandler);
        }
    
        @Override
        public void handle(Request request) {
            System.out.println(request.getBody());
            super.handle(request);
        }
    }
  4. Client 클래스:

    • 핸들러 체인을 구성하고, 첫 번째 핸들러를 통해 요청을 처리.
    public class Client {
        private RequestHandler requestHandler;
    
        public Client(RequestHandler requestHandler) {
            this.requestHandler = requestHandler;
        }
    
        public void doWork() {
            Request request = new Request("이번 놀이는 뽑기입니다.");
            requestHandler.handle(request);
        }
    
        public static void main(String[] args) {
            // 핸들러 체인 구성
            RequestHandler chain = new AuthRequestHandler(
                    new LoggingRequestHandler(
                            new PrintRequestHandler(null)
                    )
            );
    
            Client client = new Client(chain);
            client.doWork();
        }
    }

실행 흐름

  1. 핸들러 체인 구성:

    • AuthRequestHandler → LoggingRequestHandler → PrintRequestHandler.
  2. 클라이언트 실행:

    • 클라이언트는 첫 번째 핸들러(AuthRequestHandler)를 호출.
    • 각 핸들러는 요청을 처리한 뒤, 다음 핸들러로 요청을 전달.
  3. 처리 순서:

    • AuthRequestHandlerLoggingRequestHandlerPrintRequestHandler.
  4. 출력 결과:

    인증이 되었는가?
    로깅
    이번 놀이는 뽑기입니다.

다이어그램

이 부분에서 나는 여러 핸들러가 등장하고, 각 핸들러가 handle() 메서드를 오버라이드하길래 다중 상속인줄 알았지만, 단일 상속이라고 한다.

단일 상속이란 한 클래스가 하나의 부모 클래스만 상속받는 것을 의미하는데, 여기서 보면 AuthRequestHandler, LoggingRequestHandler, PrintRequestHandler는 모두 단일한 부모 클래스인 RequestHandler를 상속받고, 각 클래스는 부모 클래스의 handle() 메서드를 오버라이드할 뿐이기 때문이다.

다중 상속 예시를 보면

public class MultiHandler extends AuthRequestHandler, LoggingRequestHandler {
    // 다중 상속은 자바에서 불가능
}

이런식이고, 물론 자바에서는 다중 상속을 지원하지 않는다!


책임 연쇄 패턴의 장점과 단점

장점

  1. 확장성 증가:

    • 새로운 핸들러를 추가하거나 순서를 변경할 때 클라이언트 코드를 수정할 필요가 없음.
  2. 핸들러와 클라이언트의 분리:

    • 클라이언트는 요청 처리 방식(체인 구조)에 대해 알 필요가 없음.
  3. 핸들러 간 결합도 감소:

    • 각 핸들러는 다음 핸들러에 대해 알지만, 클라이언트와 독립적으로 동작.
  4. 유연성:

    • 체인의 순서를 쉽게 변경하거나 동적으로 추가 가능.

단점

  1. 체인의 길이 증가:

    • 체인의 길이가 길어지면 성능에 영향을 미칠 수 있음.
  2. 디버깅 어려움:

    • 체인의 흐름을 추적하거나 문제를 찾기가 어려울 수 있음.

책임 연쇄 패턴, 실무에서는?

A. 자바 서블릿 필터 (Servlet Filter)

  • 필터는 HTTP 요청/응답 전후에 공통 작업(전처리/후처리)을 수행할 수 있는 메커니즘을 제공한다.
  • 필터 체인은 여러 개의 필터를 순차적으로 호출하며, 각 필터는 다음 필터로 요청을 전달(chain.doFilter())할 수 있다.

서블릿 필터 코드 분석

  1. 서블릿 필터 설정

    • @WebFilter 어노테이션으로 특정 URL 패턴(/hello)에 대해 필터를 적용.
  2. 필터 동작

    • doFilter() 메서드는 요청/응답의 전처리 및 후처리를 정의.
    • chain.doFilter(request, response)를 호출하여 다음 필터(또는 컨트롤러)로 요청을 전달.

코드

@WebFilter(urlPatterns = "/hello")
public class MyFilter implements Filter {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        System.out.println("게임에 참가하신 여러분 모두 진심으로 환영합니다."); // 전처리
        chain.doFilter(request, response); // 다음 필터 또는 컨트롤러 호출
        System.out.println("꽝!"); // 후처리
    }
}
  1. 결과
    • /hello 엔드포인트에 요청이 들어오면:
      1. 전처리: "게임에 참가하신 여러분 모두 진심으로 환영합니다." 출력.
      2. 컨트롤러 실행: 실제 요청 처리 (HelloController).
      3. 후처리: "꽝!" 출력.

B. 스프링 시큐리티 필터

  • 스프링 시큐리티책임 연쇄 패턴을 활용하여 보안 작업을 처리한다.
  • 필터 체인을 구성하여 요청의 인증, 권한 검사 등의 작업을 수행한다.

스프링 시큐리티 필터 체인

  1. 필터 체인 구성

    • 스프링 시큐리티는 다양한 필터(예: 인증 필터, 권한 검사 필터)를 체인으로 연결하여 요청을 처리.
  2. 설정 예제

    • SecurityConfig 클래스에서 필터 체인을 구성.
    • 아래 설정은 모든 요청을 허용.

코드

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests() // 요청 권한 검사 시작
            .anyRequest().permitAll() // 모든 요청을 허용
            .and(); // 체인 끝
    }
}
  1. 실행 흐름
    • 요청이 들어오면, 스프링 시큐리티의 필터 체인이 실행되어 각 필터에서 필요한 작업(인증, 권한 검사 등)을 수행.

2. 커맨드(Command) 패턴 : 요청을 캡슐화 하여 호출자(invoker)와 수신자(receiver)를 분리하는 패턴.

요청을 처리하는 방법이 바뀌더라도, 호출자의 코드는 변경되지 않는다.

호출자는 요청의 구체적인 실행 방법을 알 필요가 없으며, 요청을 실행하는 로직은 커맨드 객체에 의해 처리된다.


Before

구조

  1. Button 클래스:

    • 특정 객체(예: Light)에 직접적으로 의존하며, 요청을 수행.
    public class Button {
        private Light light;
    
        public Button(Light light) {
            this.light = light;
        }
    
        public void press() {
            light.off();
        }
    }
  2. Light 클래스:

    • 전등의 켜기(on()) 및 끄기(off()) 로직을 구현.
    public class Light {
        private boolean isOn;
    
        public void on() {
            System.out.println("불을 켭니다.");
            this.isOn = true;
        }
    
        public void off() {
            System.out.println("불을 끕니다.");
            this.isOn = false;
        }
    }
  3. 문제점:

    • 강한 결합: Button은 요청(Light)의 구체적인 실행 방법에 강하게 결합.
    • 확장성 부족: 요청 로직이 변경되거나 새로운 요청이 추가되면 Button 클래스의 코드를 수정해야 함.
    • Undo 기능 부재: 실행 취소 로직을 구현하기 어려움.

After

핵심 변경점

  1. 커맨드 객체 도입:

    • 요청을 캡슐화하는 Command 인터페이스를 정의.
    • 각 요청(Light, Game)은 커맨드 객체로 감싸져 호출자(Button)와 분리.
  2. Button 클래스 수정:

    • 요청을 커맨드 객체로 처리.
    • 실행된 커맨드를 스택에 저장하여 실행 취소(Undo) 기능 제공.
  3. 장점:

    • 호출자(Button)와 수신자(Light, Game) 간의 의존성 제거.
    • 새로운 요청 로직 추가 및 변경이 용이.
    • 실행 취소 및 요청의 저장 가능.

구조

  1. Command 인터페이스:

    • 모든 커맨드 객체가 구현해야 하는 인터페이스.
    • execute()undo() 메서드를 정의.
    public interface Command {
        void execute();
        void undo();
    }
  2. 요청별 커맨드 객체:

    • 각 요청(Game, Light)은 커맨드 객체로 캡슐화.

      public class LightOnCommand implements Command {
          private Light light;
      
          public LightOnCommand(Light light) {
              this.light = light;
          }
      
          @Override
          public void execute() {
              light.on();
          }
      
          @Override
          public void undo() {
              light.off();
          }
      }
      public class LightOffCommand implements Command {
      
       private Light light;
      
       public LightOffCommand(Light light) {
           this.light = light;
       }
      
       @Override
       public void execute() {
           light.off();
       }
      
       @Override
       public void undo() {
           new LightOnCommand(this.light).execute();
       }
      }
      public class GameStartCommand implements Command {
          private Game game;
      
          public GameStartCommand(Game game) {
              this.game = game;
          }
      
          @Override
          public void execute() {
              game.start();
          }
      
          @Override
          public void undo() {
              game.end();
          }
      }
      public class GameEndCommand implements Command {
      
       private Game game;
      
       public GameEndCommand(Game game) {
           this.game = game;
       }
      
       @Override
       public void execute() {
           game.end();
       }
      
       @Override
       public void undo() {
           new GameStartCommand(this.game).execute();
       }
      }
      
  3. Button 클래스:

    • 요청을 커맨드 객체로 처리하며, 실행 취소 기능을 제공.
    public class Button {
        private Stack<Command> commands = new Stack<>();
    
        public void press(Command command) {
            command.execute();
            commands.push(command);
        }
    
        public void undo() {
            if (!commands.isEmpty()) {
                Command command = commands.pop();
                command.undo();
            }
        }
    }
  4. 클라이언트 코드:

    • 호출자(Button)는 요청의 세부 사항을 알 필요 없이 커맨드 객체를 통해 요청을 실행.
    public static void main(String[] args) {
        Button button = new Button();
        button.press(new GameStartCommand(new Game()));
        button.press(new LightOnCommand(new Light()));
        button.undo();
        button.undo();
    }

실행 흐름

  1. 요청 실행:

    • Button.press() 메서드가 호출되면, 전달된 커맨드 객체의 execute() 메서드 실행.
    • 커맨드 객체는 요청(Light, Game)을 처리.
  2. 실행 취소:

    • Button.undo() 메서드가 호출되면, 가장 최근에 실행된 커맨드의 undo() 메서드 실행.
  3. 출력 결과:

    게임을 시작합니다.
    불을 켭니다.
    불을 끕니다.
    게임을 종료합니다.

다이어그램


커맨드 패턴의 장단점

장점

  1. 요청과 호출자의 분리:

    • 호출자(Button)와 요청(Light, Game) 간의 강한 결합 제거.
    • 호출자는 요청의 구체적인 실행 방법을 알 필요 없음.
  2. 유연성 증가:

    • 새로운 요청을 추가하거나 변경할 때 호출자 코드를 수정할 필요 없음.
  3. Undo/Redo 기능 지원:

    • 커맨드 객체를 스택에 저장하여 실행 취소 및 재실행 가능.
  4. 요청의 저장 및 관리:

    • 커맨드 객체를 저장하고 나중에 다시 실행할 수 있음.

단점

  1. 복잡성 증가:
    • 요청마다 별도의 커맨드 클래스를 정의해야 하므로 클래스 수가 늘어날 수 있음.
  2. 오버헤드:
    • 간단한 요청 처리에는 불필요하게 복잡한 구조가 될 수 있음.

커맨드 패턴, 실무에서는?

A. 자바의 활용

  1. ExecutorService와 람다:

    • 자바의 ExecutorService는 커맨드 패턴을 활용하여 작업을 캡슐화.
    • 작업을 스레드 풀에서 실행.
    public class CommandInJava {
        public static void main(String[] args) {
            Light light = new Light();
            Game game = new Game();
    
            ExecutorService executorService = Executors.newFixedThreadPool(4);
            executorService.submit(light::on);
            executorService.submit(game::start);
            executorService.submit(game::end);
            executorService.submit(light::off);
            executorService.shutdown();
        }
    }

B. 스프링에서의 활용

  1. 데이터 저장 커맨드:

    • 스프링에서는 커맨드 객체를 활용하여 요청 데이터를 데이터베이스에 저장.
    public class CommandInSpring {
        private DataSource dataSource;
    
        public CommandInSpring(DataSource dataSource) {
            this.dataSource = dataSource;
        }
    
        public void add(Command command) {
            SimpleJdbcInsert insert = new SimpleJdbcInsert(dataSource)
                    .withTableName("command")
                    .usingGeneratedKeyColumns("id");
    
            Map<String, Object> data = new HashMap<>();
            data.put("name", command.getClass().getSimpleName());
            data.put("when", LocalDateTime.now());
            insert.execute(data);
        }
    }

출처 : 코딩으로 학습하는 GoF의 디자인 패턴

0개의 댓글