이번 글에서는 프록시 패턴과 데코레이터 패턴에 대해 알아보도록 하겠습니다.

이 글은 Java 17을 기준으로 작성되었으며 참고 자료는 다음과 같습니다.

프록시 패턴

프록시(proxy)는 대리인을 뜻합니다. 프록시 패턴은 직접 호출 하는 것이 아닌 대리인을 통해 호출하는 구조를 말합니다. 이를 예시 코드와 함께 알아보겠습니다.

public class Greeting {

    public String getGreeting() {
        System.out.println("[Greeting] 로직 수행");

        return "Hello, World!";
    }
}

위와 같이 인사말을 반환하는 역할을 가진 클래스가 있을 때 직접 호출하는 코드는 다음과 같습니다.

@Test
@DisplayName("프록시를 사용하지 않고 객체를 바로 호출한다.")
void invokeDirectly() {
    /* given */
    final Greeting greeting = new Greeting();

    /* when & then */
    assertThat(greeting.getGreeting()).isEqualTo("Hello, World!");
}

프록시 패턴을 사용하는 이유

기본적으로 프록시 패턴은 기존 클래스를 감싸고 싶을 때 사용합니다. 이때 감싸는 이유는 여러가지가 있는데요. 공통적으로 기존 로직 앞뒤에 무언가를 추가하고 싶은데 수정이 불가능하거나 기존 클래스를 수정하지 않고 싶을 때 사용합니다.

기본적인 프록시 패턴

프록시 패턴으로 변경하기 위해선 기존 클래스와 동일한 메서드를 갖는 인터페이스를 선언해야합니다.

물론 인터페이스 없이 구현할 수도 있지만 매우 강한 결합이 생기는 단점이 있습니다.

public interface GreetingService {

    String getGreeting();
}

이후 기존 클래스가 인터페이스를 구현하도록 변경합니다.

public class Greeting implements GreetingService {

    public String getGreeting() {
        System.out.println("[Greeting] 로직 수행");

        return "Hello, World!";
    }
}

이제 기존 클래스를 필드로 갖는 프록시 클래스를 정의합니다.

@RequiredArgsConstructor
public class GreetingProxy implements GreetingService {

    private final GreetingService greetingService;

    @Override
    public String getGreeting() {
        System.out.println("-----Proxy start-----");
        final String greeting = greetingService.getGreeting();
        System.out.println("-----Proxy end-----");

        return greeting;
    }
}

마지막으로 클라이언트가 인터페이스에 의존하도록 코드를 변경합니다.

@Test
@DisplayName("프록시를 사용하여 객체를 감싼다.")
void invokeProxy() {
    /* given */
    final GreetingService proxyService = new GreetingProxy(new Greeting());

    /* when & then */
    assertThat(proxyService.getGreeting()).isEqualTo("Hello, World!");
}

이로써 클라이언트가 실행하는 대상은 GreetingService로 해당 인터페이스의 구현체가 프록시 객체인지, 실제 객체인지 알 수 없습니다.

이를 클래스 다이어그램으로 표현하면 다음과 같습니다.

Untitled.png

프록시 패턴 응용

기존 클래스를 감싼다는 아이디어를 이용하여 여러 기능을 프록시 패턴으로 구현할 수 있습니다. 여기서는 간단하게 캐싱과 지연 초기화에 대해 다뤄보겠습니다.

캐싱

프록시 패턴을 이용하면 반환 값을 캐싱할 수 있습니다. 따라서 요청이 반복될 때마다 실제 객체를 호출하는 것이 아닌 준비한 값을 반환하여 계산 비용을 아낄 수 있습니다.

@RequiredArgsConstructor
public class GreetingCacheProxy implements GreetingService {

    private final GreetingService greetingService;
    private String cachedGreeting;

    @Override
    public String getGreeting() {
        if (Objects.isNull(cachedGreeting)) {
            System.out.println("Cache miss!");

            cachedGreeting = greetingService.getGreeting();
        }

        return cachedGreeting;
    }
}

위와 같이 캐시 구조를 프록시 패턴으로 구현하고 테스트 코드를 다음과 같이 작성하였습니다.

@Test
@DisplayName("프록시를 사용하여 캐싱할 수 있다.")
void cacheProxy() {
    /* given */
    final GreetingService cacheService = new GreetingCacheProxy(new Greeting());

    /* when & then */
    assertAll(
        () -> assertThat(cacheService.getGreeting()).isEqualTo("Hello, World!"), // miss
        () -> assertThat(cacheService.getGreeting()).isEqualTo("Hello, World!"), // hit
        () -> assertThat(cacheService.getGreeting()).isEqualTo("Hello, World!") // hit
    );
}

Untitled.png

클라이언트가 인터페이스에 3번의 요청을 보냈으나 프록시 패턴으로 캐싱하였기 때문에 실제 로직은 1번만 수행됩니다.

지연 초기화

또 프록시 패턴을 이용하면 객체가 필요할 때 초기화하는 지연 초기화를 구현할 수 있습니다. 객체 생성에 많은 자원이 필요하지만 바로 사용하지 않는 경우 지연 초기화를 이용하여 객체가 필요한 시점에 생성하도록 할 수 있습니다.

public class GreetingLazyInitProxy implements GreetingService {

    private GreetingService greetingService;

    @Override
    public String getGreeting() {
        if (greetingService == null) {
            System.out.println("Lazy initialization!");
            
            greetingService = new Greeting();
        }

        return greetingService.getGreeting();
    }
}

위와 같이 지연 초기화를 프록시 패턴으로 구현하고 테스트 코드를 다음과 같이 작성하였습니다.

@Test
@DisplayName("프록시를 사용하여 지연 초기화할 수 있다.")
void lazyInitializeProxy() {
    /* given */
    final GreetingService lazyService = new GreetingLazyInitProxy();

    /* when & then */
    assertThat(lazyService.getGreeting()).isEqualTo("Hello, World!");
}

Greeting 인스턴스는 GreetingService의 구현체가 생성될 때 생성되는 것이 아니라 클라이언트가 GreetingService에 요청을 할 때 생성됩니다. 이러한 지연 초기화 방식은 JPA Hibernate에서도 사용됩니다.

데코레이터 패턴

프록시 패턴과 유사하게 데코레이터 패턴도 기존 로직을 감쌀 때 사용합니다. 대신 반환 값을 그대로 전달하는 프록시 패턴과 달리 데코레이터 패턴은 반환 값을 수정하여 전달합니다. 이러한 모습이 반환 값에 장식을 붙인다고 하여 데코레이터 패턴이라고 부릅니다.

언제 사용할까?

실행되는 로직은 같으나 반환 값이 사용되는 곳에 따라 모습이 바뀌어야하는 경우를 가정해봅시다.

  • A 서비스: 인사말은 모두 대문자여야해
  • B 서비스: 인사말은 모두 소문자여야해
  • C 서비스: 인사말 앞에 현재 시간을 붙여줘야해

세 서비스 모두 인사말이 필요하지만 반환 값을 서로 다르게 해줘야합니다. 이러한 요구사항을 데코레이터 패턴으로 해결할 수 있습니다.

@RequiredArgsConstructor
public class GreetingDecorator implements GreetingService {

    private final GreetingService greetingService;

    /**
     * 인사말을 대문자로 장식하여(decorate) 반환한다.
     */
    @Override
    public String getGreeting() {
        return greetingService.getGreeting().toUpperCase();
    }
}

위 코드는 Greeting 객체를 감싸 반환 값을 대문자로 바꾸는 데코레이터입니다.

프록시 패턴과 데코레이터 패턴

프록시 패턴과 데코레이터 패턴은 모두 동일한 구조를 갖습니다. 그러나 반환 값을 처리하는 방법이 다릅니다.

  • 프록시 패턴: 반환 값을 그대로 전달
  • 데코레이터 패턴: 반환 값을 장식

두 패턴 모두 클라이언트가 인터페이스에 의존을 하기 때문에 OCP(open closed principle)과 DIP(dependency inversion principle)을 만족합니다.

그러나 패턴을 구현하기 위해 새로운 클래스가 추가되어야하기 때문에 구조가 복잡해질 수 있습니다. 또, 서비스 객체를 한 번 감싸기 때문에 응답 속도가 늦어질 수 있습니다.

마무리하며

이번 글에서는 프록시 패턴과 데코레이터 패턴이 무엇인지, 언제 사용하는지에 대해 알아보았습니다. 동일한 패턴의 구조를 갖는 두 패턴이 헷갈릴 수도 있지만 반환 값을 어떻게 처리하는 지에 집중한다면 쉽게 구분할 수 있을 것입니다.

디자인 패턴에 집중하다보면 디자인 패턴에 코드를 우겨넣는 상황이 발생할 수 있습니다. 이를 방지하기 위해선 문제 상황을 정확히 이해하고 디자인 패턴을 적절하게 사용하고 있는지 의문을 갖는 습관이 필요하다고 생각합니다.

이 글에서 사용한 코드는 아래에서 확인할 수 있습니다.

댓글남기기