땃쥐네

[Design Pattern] 컴포짓 패턴(Composite Pattern) 본문

Design/Design Pattern

[Design Pattern] 컴포짓 패턴(Composite Pattern)

ttasjwi 2023. 1. 1. 17:49

오브젝트의 15장을 읽으며 디자인 패턴 이야기가 나온 부분에서 Composite 패턴을 접했는데,

최근 SpringSecurity의 DelegatingPasswordEncoder 코드를 확인하는 과정에서 컴포짓 패턴이 적용된 것을 확인하게 되었고 컴포짓 패턴이 무엇인지 간단하게 정리해보기로 했다.


클라이언트와 Component의 컴파일 의존관계

 

- 어떤 객체가 '클라이언트'에게 foo 메시지를 전송하였다.

- 클라이언트는 foo 메시지에 자기 나름의 방식대로 처리(메서드)하고 응답 할 책임을 가진다.

- 하지만 이 과정에서 클라이언트 스스로 처리할 수 없는 일은 내부적으로 알고 있는 Component 역할에게 operation() 메시지를 전송하여 협력을 요청한다. 

- Component 역할을 수행할 수 있는 객체로는 Leaf1, Leaf2, Leaf3, ... 등이 있지만 클라이언트는 누가 실제로 협력에 참여하는지 알지 않고 알 필요도 없다. 클라이언트는 그저 누군가가 operation() 메시지를 수신받고 이 메시지를 처리해서 응답해주기만 기대할 뿐이다.

- 실제 코드 상에서 '클라이언트' 클래스의 코드에는 Component 를 의존하는 코드가 작성돼 있을 뿐, 구체적으로 어떤 Component 구현체가 이 코드를 수행하는 지 작성되어 있지 않다.

 

 

런타임 : 다형적인 협력

- 런타임에 클라이언트 객체가 전송한 operation() 메시지는 Leaf1 인스턴스가 수신받고, Leaf1은 자기 나름의 방식대로 이 메시지를 처리한다.

- 컴파일 의존관계 상으로 서로 알지 못 했던 클라이언트-Leaf1이, 런타임에 서로 협력하게 됐다.

- 우리는 이 협력 관계에서 operation()을 수행하는 객체를 다른 객체로 변경하고 싶다면, 생성자든 변경 메서드든, 모종의 방법을  통해 Leaf1이 아닌 다른 객체로 의존관계를 변경해주면 된다. (Leaf2, Leaf3, Leaf4, ... 등) 클라이언트의 코드는 변경할 필요가 없다.


요구사항 변경 : 여러 Component와 동시에 협력해야한다면?

그런데 여기서 문제가 발생한다. 갑자기 요구사항이 변경되어, 하나의 컴포넌트에서 여러 Component들과 협력해야할 상황이 발생한다. 예를 들어, 한 상품에 한 가지 할인 정책이 적용되어 있다가, 여러가지 할인 정책들을 동시에 적용되도록 하라고 요구사항이 변경됐을 때가 이에 해당한다.

 

여기서 큰 난관에 빠진다. 클라이언트는 foo 메서드를 수행하기 위해 한 Component에게 operation 메시지를 전송했었는데, 이제 여러 Component와 동시에 협력해야하므로, 의존관계를 복수의 Component들의 컬렉션으로 변경해야하는 것일까? 그리고, 변경에 맞게 foo 메서드를 다시 작성해야하는 것일까?

 

클라이언트의 코드를 변경하는 것 외에는 답이 없는 것일까?


컴포짓 패턴 : 클라이언트 - 단일객체/복수객체 협력을 동일하게 처리

 

 

답은 '있다'이다.

다수의 Component 를 알고 있는 Composite을 두고 런타임에 Composite 가 실제로 협력할 수 있도록 하면 된다.

 

컴포짓 패턴의 런타임 협력

실제 런타임의 협력은 위와 같다.

 

클라이언트는 여전히 Component를 의존하고, 그 어떤 코드도 변경하지 않았다. Component 역할을 수행하는 객체가 실제로 무엇인지 알 지 못 한다. 그리고 이 협력 관계에 Composite가 참여했을 뿐이다.

 

런타임에 협력에 참여하는 Composite는 내부적으로 복수의 Component 들을 알고 있고, operation 메시지를 처리할 때 다른 Component들에게 operation 메시지를 보내고 응답을 받을 수 있다. 이를 기반으로 자기 스스로 클라이언트에게 받은 operation 요청을 처리하고, 응답하면 된다.

 

클라이언트 입장에서는 모르는 새에, 실질적으로 복수의 컴포넌트들과 협력이 이루어진 것이다.

 

 

하나의 컴포넌트에서 모든 것을 구현해도 된다.

결국 컴포짓 패턴을 사용하려는 의도는 단일 몇 개의 Component가 협력하는지 여부를 캡슐화 시키고 클라이언트의 코드를 변경시키지 않는 것에 있다.

 

'패턴'의 구성 요소는 클래스가 아니라, '역할' 및 역할 간의 협력 관계이다.

따라서 위와 같이 하나의 클래스에서 패턴의 템플릿을 구현하더라도, 한 객체에서 세가지 역할을 모두 수행하므로 구조적으로 놓고 보면 컴포짓 패턴과 동일하다.


예시

영화와 할인정책

조영호 님의 '오브젝트'에서는 아래와 같은 예제 코드를 통해 다양한 객체지향 개념을 설명한다.

public class Movie {

    private String title;
    private Duration runningTime;
    private Money fee;
    private DiscountPolicy discountPolicy;

    public Movie(String title, Duration runningTime, Money fee, DiscountPolicy discountPolicy) {
        this.title = title;
        this.runningTime = runningTime;
        this.fee = fee;
        this.discountPolicy = discountPolicy;
    }

    public Money calculateMovieFee(Screening screening) {
        return fee.minus(discountPolicy.calculateDiscountAmount(screening));
    }
    
    // 생략
}
public interface DiscountPolicy {
    Money calculateDiscountAmount(Screening screening);
}

하나의 Movie는 DiscountPolicy를 알고 있고, Movie는 여러 시기에 상영(Screening)이 될 수 있다. Movie는 외부로부터 calculateMovieFee 메시지를 수신받으면 전달받은 상영 정보를 DiscountPolicy에게 calculateDiscountAmount 메시지를 보내 할인 가격을 계산하게 하고, 여기서 전달받은 할인가격을 영화 요금으로부터 차감하여 클라이언트에게 반환한다.

 

 

DiscountPolicy 역할을 수행할 수 있는 할인 정책으로는 '정액할인정책'과 '정률할인정책'이 있다. 정액 할인 정책은 일정 정해진 요금을 할인해주는 정책이고, 정률 할인 정책은 구매액으로부터 일정 비율만큼 할인해주는 정책이다.

 

이후 또 다른 할인 정책을 추가하더라도, DiscountPolicy의 역할을 수행할 수 있는 다른 정책을 추가함으로서, Movie의 코드를 수정하지 않고 다양한 할인 기능을 추가, 확장할 수 있다.

 

그런데 요구사항이 바뀌어서, 여러개의 할인 정책을 동시에 적용해야한다고 하자. 

public class Movie {
	// 생략
    private final List<DiscountPolicy> discountPolicies = new ArrayList<>(); // 컬렉션으로 변경

    public Movie(String title, Duration runningTime, Money fee, List<DiscountPolicy> discountPolicies) {
        this.title = title;
        this.runningTime = runningTime;
        this.fee = fee;
        this.discountPolicies.addAll(discountPolicies);
    }

    public Money calculateMovieFee(Screening screening) {
        Money movieFee = fee;
        for (DiscountPolicy discountPolicy : discountPolicies) {
            movieFee = movieFee.minus(discountPolicy.calculateDiscountAmount(screening));
        }
        return fee;
    }
    
    // 생략
}

가장 단순하게 생각할 수 있는 방법은 클라이언트의 코드를 변경하는 것이다. 클라이언트 내부에서 여러 DiscountPolicy를 알고 있도록 코드를 수정하고, calculateMovieFee의 구현을 변경하는 것이다.

 

하지만 기존에 사용하던 Movie의 상태, 생성자 등이 변경되므로 Movie 인스턴스를 생성하는 측의 코드도 변경해야하고, Movie 내부의 여러 메서드 구현도 많이 변경해야할 수 있다. 큰 변화가 발생하면서 기존의 기능들이 생각하던 대로 돌아가지 않을 수 있다. 기존의 기능을 변경하기 위해 클라이언트의 코드를 변경하게 된다.

public class Movie {

    private String title;
    private Duration runningTime;
    private Money fee;
    private DiscountPolicy discountPolicy;

    public Movie(String title, Duration runningTime, Money fee, DiscountPolicy discountPolicy) {
        this.title = title;
        this.runningTime = runningTime;
        this.fee = fee;
        this.discountPolicy = discountPolicy;
    }

    public Money calculateMovieFee(Screening screening) {
        return fee.minus(discountPolicy.calculateDiscountAmount(screening));
    }
    
    // 생략
}
public class OverlappedDiscountPolicy extends DiscountPolicy {

    private final List<DiscountPolicy> discountPolicies = new ArrayList<>();

    public OverlappedDiscountPolicy(DiscountPolicy ... discountPolicies) {
        this.discountPolicies.addAll(List.of(discountPolicies));
    }

    @Override
    protected Money getDiscountAmount(Screening screening) {
        Money result = Money.ZERO;
        for(DiscountPolicy each : discountPolicies) {
            result = result.plus(each.calculateDiscountAmount(screening));
        }
        return result;
    }
}

 

 

이 상황에서 컴포짓 패턴은 좋은 해답이 될 수 있다. 기존의 DiscountPolicy를 역할을 구현한 OverlappedDiscountPolicy를 통해 중복 할인 정책을 구현할 수 있다.

 

OverlappedDiscountPolicy는 내부적으로 여러가지 DiscountPolicy들을 알고 있고, 이들을 통해 할인 가격들을 구하게 하여(calculateDiscountAmount) 모두 합산한 결과를 클라이언트에게 반환할 수 있다. DiscountPolicy 역할을 수행하는 객체로 OverlappedDiscountPolicy를 주입해주면 클라이언트의 코드를 변경하지 않고,  기존 기능을 확장할 수 있는 것이다.

 

스프링 시큐리티의 AuthenticationProvider과 PasswordEncoder의 협력

또 다른 예로, 스프링 시큐리티의 PasswordEncoder, DelegatingPasswordEncoder가 있다.

스프링 시큐리티의 동작 원리에 대해 깊게 설명하면 이 글의 설명 목적과 맞지 않으므로 일부 컴포넌트만 간단하게 설명하고 넘어가도록 하겠다.

 

package org.springframework.security.authentication;

public interface AuthenticationProvider {


	Authentication authenticate(Authentication authentication) throws AuthenticationException;
	boolean supports(Class<?> authentication);

}
package org.springframework.security.crypto.password;

public interface PasswordEncoder {

	String encode(CharSequence rawPassword);

	boolean matches(CharSequence rawPassword, String encodedPassword);

	default boolean upgradeEncoding(String encodedPassword) {
		return false;
	}

}

 

AuthenticationProvider는 외부에서 들어온 인증 요청이 들어왔을 때, 해당 인증 요청이 자신이 처리할 수 있는 인증 유형인지 판단할 수 있고(supports), 처리할 수 있다면 이를 처리(authenticate)할 책임을 가진다. 일반적으로 AuthenticationProvider는 사용자 정보를 조회해오는 UserDetailsService 와 암호화/암호 검증을 수행하는 PasswordEncoder를 사용하여 인증 로직을 수행한다.(개발자 나름의 구현 방식을 통해 그 이외의 방법을 사용할 수도 있다.)

 

PasswordEncoder는 여러가지 구현체가 있다. 암호를 평문 그대로 인코딩하는 NoOpPasswordEncoder, SHA-256 해싱 기법을 이용한 StandardPasswordEncoder, BCrypt 방식으로 암호를 해싱하는 BCryptPasswordEncoder, 그 외에도 SCryptPasswordEncoder, Pbkdf2PasswordEncoder 등이 있으며, 개발자 스스로 PasswordEncoder를 구현할 수도 있다.

여기서 간단한 시나리오를 들어보려고 한다. X 사에서는 SHA-256 을 이용해 암호를 해싱하는 StandardPasswordEncoder를 사용하고 있었다. 그런데 StandardPasswordEncoder는 강도가 약한 해싱 알고리즘을 사용하기 때문에 보안상의 큰 위협이 되고 있다.(실제로 StandardPasswordEncoder는 Deprecated되었다.)

 

암호화 기법을 다른 PasswordEncoder(예를 들어 BCryptPasswordEncoder)로 변경하기로 결정했다. 하지만 해싱기법은 기존의 암호화된 암호들을 평문으로 복구시키는 복호화가 불가능하다. 평문을 해싱을 통해 인코딩된 암호문자열로 변환하는 것은 가능해도 DB에 저장된 암호를 일괄적으로 역으로 평문으로 변환한뒤 BCrypt 방식으로 일괄적으로 변환할 수 없다.

그래서, 기존의 사용자들이 인증 요청이 올 때마다, 기존 StandardPasswordEncoder과 비교하여 인증로직을 수행하고, 내부적으로는 사용자의 패스워드를 새로운 BCryptPasswordEncoder를 통해 다시 인코딩하여 저장할 수 있도록 코드를 작성하기로 했다. 이렇게 하면 이후부터 해당 사용자는 BCryptPasswordEncoder를 통해 인증로직을 수행할 수 있게 된다. 새로 가입하는 사용자들에 대해서는 새로운 BCryptPasswordEncoder를 통해서만 암호화할 수 있도록 한다.

 

문제는 이 방식을 구현하기 위해서는 클라이언트인 AuthenticatonProvider의 의존관계, 내부 구현 메서드 등 구현을 변경해야한다.

 

 

 

public class DelegatingPasswordEncoder implements PasswordEncoder {

	private static final String PREFIX = "{";
	private static final String SUFFIX = "}";
	private final String idForEncode;
	private final PasswordEncoder passwordEncoderForEncode;
	private final Map<String, PasswordEncoder> idToPasswordEncoder;
	private PasswordEncoder defaultPasswordEncoderForMatches = new UnmappedIdPasswordEncoder();
    
    // 생략
}

 

이런 문제를 해결하기 위한 솔루션으로 Spring Security는 DelegatingPasswordEncoder라는 구현체를 제공한다. DelegatingPasswordEncoder에는 여러가지 PasswordEncoder를 등록해둘 수 있고, 사용자의 요청이 들어올 때 암호를 확인하여 자신이 가진 PasswordEncoder 들 중에서 지원 가능한 PasswordEncoder를 찾아 이를 통해 인증을 수행할 수 있게 할 수 있다.

 

하나의 PasswordEncoder 구현체(Composite)가 여러 PasswordEncoder들과 협력하지만 이 사실을 클라이언트에게 은닉하므로 이는 컴포짓 패턴을 사용한 예이다.

 

실제로 어떤 PasswordEncoder를 사용하는 지 확인하는 방법으로는 암호화된 암호 앞에 해당 PasswordEncoder에 대응하는 특정 접두사를 붙이는 기법을 사용하는데 이에 관해서 설명하면 이 글의 범위를 벗어나므로 생략한다.

 

중요한 것은 클라이언트인 AuthenticationProvider의 코드를 수정하지 않고, 여러 PasswordEncoder와 협력할 수 있도록 기능을 확장할 수 있다는 점이다.


마치며 : 디자인 패턴 사용시 주의 점

- 패턴은 어디까지나 설계의 출발점이지 목적지가 되어선 안 된다. 현재의 요구사항, 적용 기술, 프레임워크에 적합하지 않다면(예를 들어 ORM 기술의 연관관계 매핑 등의 제약사항을 고려했을 때 컴포짓 패턴은 안 맞을 수 있다.) 패턴을 그대로 따르지 말고, 목적에 맞게 패턴을 변경을 수정해야 한다.

- 어떤 협력관계를 구성할 때 디자인 패턴을 맹목적으로 믿고 사용하는 것은 위험하다.

- 컨텍스트의 적절성을 무시한 채 패턴의 구조에 초점을 맞추면 불필요하게 복잡하고, 난해하며 유지보수하기 어려운 시스템을 낳을 가능성이 있다.

- 패턴은 공통적인 문제에 대해 적절한 해법을 제공하지만, 우리가 직면한 문제에 적합하지 않을 수 있다. 패턴을 사용하더라도 현재 우리가 직면한 문제에 적합하도록 수정할 수 있어야 한다.


참고자료

- 참고자료 : 조영호 님의 '오브젝트'

- 같이 참고할 만한 서적 : GOF의 디자인 패턴

 

Comments