[디자인 패턴] 프록시 패턴/데코레이터 패턴

SungBum Park·2022년 8월 25일
2

이 글은 디자인 패턴에서 유사한 패턴인 프록시 패턴과 데코레이터 패턴에 대해 알아본다. 두 패턴은 사실 ‘프록시(proxy)’를 사용하는 패턴이다. 프록시의 뜻은 ‘대리자'라는 뜻인데, 뜻처럼 어떤 일을 대신 해주는 역할을 한다.

프록시는 소프트웨어 환경에서 다양한 곳에서 활용되는 용어인데, 근본적인 기능은 크게 2 가지로 나뉜다.

  • 접근 제어
    • 권한에 따른 접근 차단
    • 캐싱 (캐싱되어 있는 값은 접근할 필요가 없으므로, 캐시는 접근 제어의 역할)
    • 지연 로딩
  • 부가 기능 추가
    • 값 변경
    • 로깅
    • 필터링

프록시 패턴과 데코레이터 패턴은 둘 다 프록시 개념을 사용하지만, 의도에 따라 나뉜다. 이 의도는 프록시의 기능에 따라 나누고 있다.

  • 프록시 패턴 → 접근 제어
  • 데코레이터 패턴 → 부가 기능 추가

두 패턴 모두 프록시를 사용하므로, 사실 구현 방법은 동일하다.

클라이언트는 원래 RealSubject를 사용해야 하지만, 접근 제어 또는 부가 기능을 위해 프록시를 추가하려고 한다. 클라이언트의 코드 변경없이 RealSubject 대신 프록시 객체를 사용하려면, 두 객체는 같은 타입으로 추상화되어야 한다. 따라서, 프록시를 사용하는 클래스 의존 관계는 위처럼 같은 타입의 인터페이스를 추상화하여 사용하게 된다. 이를 런타임 객체 의존 관계를 보면 프록시의 관계가 좀 더 명확하게 보일 것이다.

클라이언트가 런타임에서 실제로 사용하는 객체는 프록시 객체이고, 프록시 객체 내에서 접근 제어 또는 부가 기능을 수행한 후, 프록시가 실제 객체인 RealSubject 기능을 수행하게 된다.

지금까지 프록시에 대해서 알아보았고, 이제 예제를 통해 프록시 패턴과 데코레이터 패턴이 어떻게 사용되는지 살펴보자.

전체 코드는 이 링크에서 볼 수 있습니다.

프록시 패턴 (Proxy Pattern)

프록시 패턴의 대표적인 예는 캐시가 있다. 캐시는 이미 데이터가 있으면, 기존의 데이터를 사용하고 없으면 새로 만든다. 코드 구조는 위에서 살펴본 Subject 인터페이스를 기반으로 한 모습과 동일하다.

public interface Subject {
    String operation();
}
  • operation() 메서드를 호출하면, 반환값으로 문자열 데이터를 받는다.
@Slf4j
public class RealSubject implements Subject {

    @Override
    public String operation() {
        log.info("RealSubject 호출");

        return "data";
    }
}
  • RealSubject 클래스는 Subject 인터페이스를 구현하고, 데이터를 실제로 만드는 역할을 한다.
@Slf4j
public class CacheProxySubject implements Subject {

    private Subject target;
    private String cacheValue;

    public CacheProxySubject(Subject target) {
        this.target = target;
    }

    @Override
    public String operation() {
        log.info("캐시 프록시 호출");

        if (cacheValue == null) {
            log.info("캐시 값이 비어있으므로, 새로 생성");
            cacheValue = target.operation();
        }

        return cacheValue;
    }
}
  • CacheProxySubject 클래스도 역시 Subject 인터페이스를 구현하지만, 캐시 기능을 하는 프록시이다.
  • 필드로 가지고 있는 cacheValue 문자열이 비어있으면, 실제 RealSubject 를 호출하여 데이터를 만든다.
  • cacheValue 가 비어있지 않으면, 현재 가지고 있는 것을 그대로 반환한다.

캐시 역할을 하는 프록시 패턴의 구현체는 모두 완성하였다. 이를 호출하는 클라이언트를 테스트 코드로 작성해보자.

@Test
void use_proxy_pattern() {
    Subject realSubject = new RealSubject();
    Subject cacheProxy = new CacheProxySubject(realSubject);

    // 처음 프록시 호출로 RealSubject를 호출하여 실제 데이터를 가져온다.
    log.info("----- 클라이언트에서 첫 번째 호출 -----");
    cacheProxy.operation();

    // 두번째 호출부터는 RealSubject를 호출하지 않고, 캐시값을 사용한다.
    log.info("----- 클라이언트에서 두 번째 호출 -----");
    cacheProxy.operation();
}
----- 클라이언트에서 첫 번째 호출 -----
캐시 프록시 호출
캐시 값이 비어있으므로, 새로 생성
RealSubject 호출
----- 클라이언트에서 두 번째 호출 -----
캐시 프록시 호출

결과를 보면, 처음 호출할 때는 캐시 프록시에 데이터가 없으므로 실제 Subject 구현체를 호출하여 데이터를 생성한다. 하지만 두 번째 호출부터는 실제 Subject 구현체를 전혀 사용하지 않고, 가지고 있는 데이터를 캐싱해서 반환하고 있다.

데코레이터 패턴 (Decorator Pattern)

데코레이터 패턴은 기능 추가를 실제 로직 변경없이, 프록시 형태로 독립된 곳에서 수행을 할 수 있다보니 활용도가 매우 높다. 이 글의 예제에서는 이전에 살펴보았던 디자인 패턴 템플릿 메서드 패턴/전략 패턴/템플릿 콜백 패턴 글에서 사용한 예제에서 기능을 추가해보자.

이전 예제에서 문자 알림을 전송하기 위해 SmsService 클래스의 sendSms() 메서드가 전체 전송 기능을 담당하고 있었다. (전략 패턴 예제 기준)

public void sendSms(String cellphoneNumber, String smsContents) {
    log.info("문자 발송 시작");
    long startTime = System.currentTimeMillis();

    boolean result = smsSendingStrategy.execute(cellphoneNumber, smsContents);

    long endTime = System.currentTimeMillis();
    long resultTime = endTime - startTime;
    log.info("문자 발송 결과: 성공 여부 = {}, 소요 시간 = {}ms", result, resultTime);
}

위 메서드 코드에서 실제로 문자 알림을 전송하는 부분은 smsSendingStrategy.execute() 메서드이다. 이 메서드를 호출하기 전에 알림을 실제로 보낼 수 있는 상태인지를 체크해야 하는 요구사항이 발생했다고 가정해보자.

  • 문자를 포함한 모든 알림은 회원 상태가 NORMAL 인 경우에만 전송한다.
  • 문자 알림 전송은 유선 번호인 경우에는 제외한다.

첫 번째 요구사항은 문자 외에도 이메일이나 앱푸시와 같은 모든 알림 수단에 공통적으로 체크해야 하는 사항이고, 두 번째 요구사항은 문자 알림에 특화된 체크사항이다. 이러한 상태 체크는 추가되거나 변경되는 일이 빈번하고, 알림 수단마다 공통적으로 필요한 것도 있다. 이러한 요구사항을 유연하게 대처하려면 어떻게 해야할까?

여기서는 데코레이터 패턴을 사용하여 이러한 요구사항을 유연하게 대처해보자.

데코레이터 패턴도 이 글 처음 프록시에 대해 살펴보았던 것처럼 클래스 구조는 동일하다. 위 요구사항을 바탕으로 다시 클래스 구조를 그려보자.

  • NotificationFilter : 전송할 수 있는지 체크하는 역할의 이름을 필터라고 두고, 알림을 전송할 수 있는지 검사하는 필터 인터페이스이다.
  • CommonFilter: 모든 알림 수단에서 공통적으로 검사하는 필터로, 디폴트 필터 역할을 한다.
    • 회원 상태가 NORMAL 인지 검사
  • SmsFilterDecorator: 문자 알림에만 필요한 체크사항을 검사하는 필터 역할을 한다.
    • 휴대폰 번호가 정확한지 검사

위를 코드로 구현해보자. 먼저, 문자 전송을 담당하는 SmsService 클래스 전체 코드는 다음과 같다.

@Slf4j
public class SmsService {

    private final SmsSendingStrategy smsSendingStrategy;
    private final NotificationFilter notificationFilter;

    public SmsService(SmsSendingStrategy smsSendingStrategy, NotificationFilter notificationFilter) {
        this.smsSendingStrategy = smsSendingStrategy;
        this.notificationFilter = notificationFilter;
    }

    public void sendSms(NotificationRequest request) {
        log.info("문자 발송 시작");
        long startTime = System.currentTimeMillis();

        if (!notificationFilter.canSend(request)) {
            log.info("문자를 발송할 수 없는 상태입니다.");
            return;
        }

        boolean result = smsSendingStrategy.execute(request.getCellphoneNumber(), request.getSmsContents());

        long endTime = System.currentTimeMillis();
        long resultTime = endTime - startTime;
        log.info("문자 발송 결과: 성공 여부 = {}, 소요 시간 = {}ms", result, resultTime);
    }
}
  • 데코레이터 패턴을 적용한 알림 필터(NotificationFilter)가 필드로 추가되었다.
  • 회원 상태 검사를 위한 데이터를 추가해야하는데, 이를 원래 알림에 필요한 데이터와 함께 NotificationRequest 클래스로 추출하였다. (아래 코드 참고)
  • 필터가 적용되는 시점은 실제 전송하기 전에 검사를 한다.
@AllArgsConstructor
@NoArgsConstructor
@Getter
@ToString
public class NotificationRequest {
    private String memberStatus;
    private String cellphoneNumber;
    private String smsContents;
}
  • 회원 상태를 담고 있는 membserStatus 필드가 추가되었다. (사실, 문자열보다는 enum으로 관리하는 것이 훨씬 유연하게 상태를 관리할 수 있다.)

데코레이터 패턴의 핵심인 알림 필터 코드는 다음과 같다.

public interface NotificationFilter {

    boolean canSend(NotificationRequest request);
}
public class CommonFilter implements NotificationFilter {

    @Override
    public boolean canSend(NotificationRequest request) {
        return "NORMAL".equals(request.getMemberStatus());
    }
}
public class SmsFilterDecorator implements NotificationFilter {

    private static final Pattern CELLPHONE_NUMBER_PATTERN = Pattern.compile("^01(?:0|1|[6-9])-(?:\\d{3}|\\d{4})-\\d{4}$");

    private final NotificationFilter notificationFilter;

    public SmsFilterDecorator(NotificationFilter notificationFilter) {
        this.notificationFilter = notificationFilter;
    }

    @Override
    public boolean canSend(NotificationRequest request) {
        boolean result = notificationFilter.canSend(request);
        if (!result) {
            return false;
        }

        Matcher matcher = CELLPHONE_NUMBER_PATTERN.matcher(request.getCellphoneNumber());

        return matcher.find();
    }
}
  • 기존 공통 알림 필터 기능에 문자 알림을 위한 필터 기능이 추가되었다. (공통 알림 필터가 아닌 이전에 추가된 데코레이터 필터 기능으로도 볼 수 있다.)

마지막으로, 이를 사용하는 클라이언트 코드를 살펴보자. 총 3가지 경우를 살펴보는 것이 데코레이터 패턴이 정상적으로 적용되었는지 확인할 수 있을 것이다.

  1. 정상적으로 문자 알림을 발송하는 경우
  2. 회원 상태가 NORMAL 이 아니어서, 문자 발송을 하지 않는 경우
  3. 휴대폰 번호가 정상적이지 않아서, 문자 발송을 하지 않는 경우

1. 정상적으로 문자 알림을 발송하는 경우

@Test
void success_sending_sms() {
    SmsSendingStrategy smsSendingStrategy = new SmsSendingStrategyA();
    // 필터 설정
    NotificationFilter commonFiler = new CommonFilter();
    NotificationFilter smsFilter = new SmsFilterDecorator(commonFiler);

    // 문자 알림 데이터 설정
    NotificationRequest request = new NotificationRequest("NORMAL", "010-1111-2222", "Hello!");

    // SmsService 생성
    SmsService service = new SmsService(smsSendingStrategy, smsFilter);

    service.sendSms(request);
}
문자 발송 시작
A 외부 서비스 사용: [010-1111-2222]에게 'Hello!' 내용 문자 보내기
문자 발송 결과: 성공 여부 = true, 소요 시간 = 1ms

2. 회원 상태가 NORMAL 이 아니어서, 문자 발송을 하지 않는 경우

@Test
void fail_sending_sms_when_invalid_member_status() {
    SmsSendingStrategy smsSendingStrategy = new SmsSendingStrategyA();
    // 필터 설정
    NotificationFilter commonFiler = new CommonFilter();
    NotificationFilter smsFilter = new SmsFilterDecorator(commonFiler);

    // 문자 알림 데이터 설정
    NotificationRequest request = new NotificationRequest("INVALID", "010-1111-2222", "Hello!");

    // SmsService 생성
    SmsService service = new SmsService(smsSendingStrategy, smsFilter);

    service.sendSms(request);
}
문자 발송 시작
문자를 발송할 수 없는 상태입니다.

3. 휴대폰 번호가 정상적이지 않아서, 문자 발송을 하지 않는 경우

@Test
void fail_sending_sms_when_invalid_cellphone_number() {
    SmsSendingStrategy smsSendingStrategy = new SmsSendingStrategyA();
    // 필터 설정
    NotificationFilter commonFiler = new CommonFilter();
    NotificationFilter smsFilter = new SmsFilterDecorator(commonFiler);

    // 문자 알림 데이터 설정
    NotificationRequest request = new NotificationRequest("NORMAL", "02-1111-2222", "Hello!");

    // SmsService 생성
    SmsService service = new SmsService(smsSendingStrategy, smsFilter);

    service.sendSms(request);
}
문자 발송 시작
문자를 발송할 수 없는 상태입니다.

실제 운영 환경이라면, 알림을 보내지 못하는 경우에 로그나 예외를 던져서 정확히 어떤 이유로 필터에서 걸러졌는지 명시해야 한다.

정리

이 글에서는 디자인 패턴 중 동일하게 ‘프록시(proxy)’ 개념을 사용하는 두 패턴인 프록시 패턴과 데코레이터 패턴을 살펴보았다. 처음에 단순히 이 두 패턴을 따로 보았을 때, 특히 데코레이터 패턴을 이해하기가 쉽지 않았다. 그런데 김영한님의 인프런 강의 중 ‘스프링 핵심 원리 - 고급편’에서 프록시에 대해 심도있게 배울 수 있는데, 여기서 두 패턴에 대해서 깔끔하게 정리해주셨다. 프록시에 대해 자세히 코드에 어떻게 적용되는지 알고 싶고, 스프링 AOP의 원리에 대해서 알고 싶다면 이 강의를 강력 추천한다.

앞서 말한 강의에서도 자세히 나오지만, 프록시를 직접 구현하는 일은 비용이 적지 않은 작업이다. 그리고 단순 반복이 많을 수도 있다. 이를 해결하고자 나온 개념이 ‘동적 프록시 기술'이다. 개발자가 프록시 객체를 코드로 구현하여 정적인 시점(컴파일 시점)에서 생성하는 것이 아닌, 개발자는 설정과 필요한 로직만 구현하고 런타임 시점에 프록시 객체를 자동으로 생성해주는 것이 동적 프록시 기술이다. 그리고 더 나아가 이를 사용하기 편하도록 추상화한 것이 Spring AOP 기술이다. 이에 대해서는 다음 글에서 좀 더 자세히 살펴볼 예정이다.

참고자료

profile
https://parker1609.github.io/ 블로그 이전

0개의 댓글