Easy Understanding

웹 백엔드 관점으로 본 디자인 패턴 정리(4) - 데코레이터 패턴(Decorator Pattern) 본문

Study

웹 백엔드 관점으로 본 디자인 패턴 정리(4) - 데코레이터 패턴(Decorator Pattern)

appleg1226 2022. 5. 6. 11:44

 

- 필요 개념: 상속, 컴포지션 둘 다
- 활용도: 가끔 쓸모있을 수준
- 난이도: 관점에 따라서는 다소 복잡함
- 패턴이 필요한 상황: 메서드에 다양한 기능(적어도 세네가지 이상)을 추가하고 싶을 때

 

데코레이터 패턴은 개념은 쉬운데 구현이 좀 복잡하다.

처음에는 이게 뭔 소린가하면서 넘어갔는데, 두세번 정도 읽다보니 깨달음이 온 패턴이다.

 

일단 세부 구현 방법보다는 그 쓸모가 어디에 있을지를 알아보자.

 

헤드퍼스트에서는 커피에 올리는 토핑으로 설명을 한다.

여러 개의 토핑을 다양한 조합으로 올렸을 때 가격을 어떻게 계산할 것이냐? 라는 가벼운 예시를 든다.

아 그런데 역시나 패턴을 이해하는 데에는 도움이 되지만 실질적으로 어디에 쓸거냐에 대한 인사이트는 그닥..

 

데코레이터 패턴은 패턴 이름 그대로 꾸며주는 패턴이다.

무엇을 꾸며주냐면 '메서드'를 꾸며준다.

원래 존재하는 메서드의 결과값을 이용해서 무엇인가 처리하거나,

메서드 실행 전후 단계에 특별한 처리를 해줄 수가 있다.

 

다음은 일반으로 마주칠 수 있는 평범한 getter 메서드이다.

public BigDecimal getPrice(){
  return this.price;
}

그런데 이런 요구사항이 들어온다.

'가격을 다른 화폐 단위로 바꿔달라.'

'가격을 특정 rate를 이용해서 가공해서 보여달라'

 

그러면 일반적으로는 getPrice를 사용하는 곳에서 받아서 해당 처리를 해주면 된다.

아래와 같이 getPrice를 받아서 달러로 바꿔주면 된다.

public BigDecimal getPrice(){
  return this.price;
}

public BigDecimal getDollarPrice(){
  return getPrice() * DOLLAR_RATE;
}

꾸민다는게 특별한게 아니고 이렇게 특정 메서드의 행위를 받아서 무엇인가 추가 처리를 해준다는 것이다.

메서드 내부 동작을 관여하는 것이 아니라 그 겉을 꾸미기 때문에 데코레이터 패턴이다.

 

아니면 결과를 가공하지 않고 그대로 보내줄 수도 있다.

아래의 예시는 스프링 JPA 등을 사용했을 때 자주 마주치는 코드 예시다.

public User save(User user){
  return userRepository.save(user);
}

여기서 이 User를 로깅하고 싶은 니즈가 있다고 하자.

public User save(User user){
  return userRepository.save(user);
}

public User logUser(User user){
  User savedUser = this.save(user);
  log.info(savedUser);
  return savedUser;
}

이렇게 밖에서 감싸서 로깅을 해주고 결과값은 그대로 넘기면 된다. 

이렇게 특정 메서드의 외부에 추가 기능을 더해야할 니즈가 있다면 이 패턴을 사용하면 된다. 

 

아직 데코레이터 패턴을 설명하지 않았지만 데코레이터 패턴이 강력해 보이는 코드는 다음과 같은데

구현이 이렇게 이루어 진다.

UserRepository userRepository = new UserPrintDecorator(
                                  new UserCachingDecorator(
                                    new UserIdEncryptDecorator(
                                      new UserNotificationDecorator(new UserRepositoryImpl()))));
                                      
userRepository.save(user);

이렇게 호출하면 user가 save되면서 print, caching, id-encrypt, notification이 다 한 방에 이루어진다.

내부적으로는 가장 안쪽의 userRepositoryImpl의 save부터 바깥을 감싸고 있는 데코레이터의 save들이 연쇄적으로 실행되는 구조다. 

 

위의 코드를 보면 알겠지만 상황에 따라 필요한 데코레이터를 고르기만 하면 된다.

그리고 어떻게 순서를 바꿔도 동작은 한다.(물론 동작의 결과가 같다는 보장은 없다) 

 

저걸 원래하던대로 상속으로 구현하면 

UserRepository를 상속해서 UserPrintCachingEncryptNotificationRepositoryImpl 이라는 엄청난 구현체가 나올 것이다.

또한 만약 캐싱을 빼고 싶다면 UserPrintEncryptNotificationRepositoryImpl 와 같은 것이 나타날 것이다.

 

이걸 사용하려면 일단 상속에 필요한 기능이 복잡한 상황이어야 한다는 점이다.

그렇지 않다면야 그냥 상속을 사용하면 된다. 

 

일단 내부적으로 상속과 컴포지션이 사용되는데,

컴포지션이 사용된다는 것은 생성자를 통해서 같은 유형의 클래스를 제공받는 데에서 알 수가 있다.

하나만 클래스 구조를 확인해보면 이렇게 되어 있다. 

interface UserRepository {
  User save(User user);
}

interface UserRepositoryDecorator extends UserRepository {}

class UserPrintDecorator implements UserRepositoryDecorator {
  private UserRepository userRepository;

  public UserPrintDecorator(UserRepository repository){
    this.userRepository = userRepository;
  }
  
  public User save(User user){
    User savedUser = this.userRepository.save(user);
    System.out.println(savedUser);
    return savedUser;
  }
}

생성자로 같은 인터페이스의 클래스를 받아서 그걸 내부적으로 사용하고 있다. 

 

데코레이터 패턴은 강력하고 좋은 패턴이다.

그러나 우리의 스프링에는 이러한 행위를 도와주는 좋은 기능들이 많이 있다. 

캐싱이나 로깅 같은건 AOP를 이용해서 처리할 수도 있고, 다양한 어노테이션이 존재한다. 

 

애초에 데코레이터는 프록시 패턴과 구현 방식이 비슷하며 할 수 있는 것도 겹치는 것이 많다.

두 번째 예시(Repository) 같은 경우는 프록시(AOP가 더해진)로 풀어나가는 것도 좋은 것 같다.     

 

그리고 public 메서드가 많은 인터페이스라면 데코레이터 패턴의 도입을 고민해봐야 한다.

어려운 건 없지만 똑같은 코드가 너무 많아지고 몸집이 너무 커질 가능성이 있다.

 

패턴은 꼭 필요한게 아니라면 항상 많이 고민해보자