Advance I/Spring

22.09.24

kinggora 2022. 9. 24. 16:20

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

  • v1 - 인터페이스와 구현 클래스 - 스프링 빈으로 수동 등록
  • v2 - 인터페이스 없는 구체 클래스 - 스프링 빈으로 수동 등록
  • v3 - 컴포넌트 스캔으로 스프링 빈 자동 등록

스프링 빈으로 등록할 클래스의 인터페이스 유무, 스프링 빈 수동/자동 등록

다양한 케이스에 프록시를 어떻게 적용하는지 알아보기 위해 다양한 예제를 준비해보자.

예제 프로젝트 만들기 v1

v1 - 인터페이스와 구현 클래스 - 스프링 빈으로 수동 등록

참고: @Controller, @Service, @Repository -> @Component 애노테이션을 포함하여 컴포넌트 스캔의 대상이 됨 (자동 등록)

따라서 수동 등록의 경우 위 애노테이션을 사용하지 않는다.

 

OrderControllerV1 인터페이스

@RequestMapping //스프링은 @Controller 또는 @RequestMapping 이 있어야 스프링 컨트롤러로 인식
@ResponseBody
public interface OrderControllerV1 {

    @GetMapping("/v1/request")
    String request(@RequestParam("itemId") String itemId);

    @GetMapping("/v1/no-log")
    String noLog();

}
  • 컨트롤러 인터페이스는 생소하지만 가능하다.
  • @RequestMapping : 스프링MVC는 타입에 @Controller 또는 @RequestMapping 애노테이션이 있어야 스프링 컨트롤러로 인식한다. 그리고 스프링 컨트롤러로 인식해야, HTTP URL이 매핑되고 동작한다. 이 애노테이션은 인터페이스에 사용해도 된다.
  • @ResponseBody : HTTP 메시지 컨버터를 사용해서 응답한다. 이 애노테이션은 인터페이스에 사용해도 된다.
  • @RequestParam("itemId") String itemId : 인터페이스에는 @RequestParam("itemId") 의 값을 생략하면 itemId 단어를 컴파일 이후 자바 버전에 따라 인식하지 못할 수 있다. 인터페이스에서는 꼭 넣어주자. 클래스에는 생략해도 대부분 잘 동작한다.
  • request() 는 LogTrace 를 적용할 대상이고, noLog() 는 LogTrace 를 적용하지 않을 대상이다.

 

ProxyApplication 클래스

@Import(AppV1Config.class)
@SpringBootApplication(scanBasePackages = "hello.proxy.app") //config 패키지를 컴포넌트 스캔 하지 않기 위해
public class ProxyApplication {

   public static void main(String[] args) {
      SpringApplication.run(ProxyApplication.class, args);
   }

}
  • @Import(AppV1Config.class) : 클래스를 스프링 빈으로 등록한다. 이때는 AppV1Config.class 를 스프링 빈으로 등록한다. 일반적으로 @Configuration 같은 설정 파일을 등록할 때 사용하지만, 스프링 빈을 등록할 때도 사용할 수 있다.
  • @SpringBootApplication(scanBasePackages = "hello.proxy.app") : @ComponentScan 의 기능과 같다. 컴포넌트 스캔을 시작할 위치를 지정한다. 이 값을 설정하면 해당 패키지와 그 하위 패키지를 컴포넌트 스캔한다. 이 값을 사용하지 않으면 ProxyApplication 이 있는 패키지와 그 하위 패키지를 스캔한다.

주의: 강의에서는 @Configuration 을 사용한 수동 빈 등록 설정을 hello.proxy.config 위치에 두고 점진적으로 변경할 예정이다. @Import 를 사용해서 설정 파일을 변경할 수 있도록 하였다.

@Configuration 은 @Component 애노테이션을 포함하고 있어서 컴포넌트 스캔의 대상이 되기 때문에, 컴포넌트 스캔에 의해 hello.proxy.config 위치의 설정 파일들이 스프링 빈으로 자동 등록 되지 않도록 컴포넌스 스캔의 시작 위치를 scanBasePackages=hello.proxy.app 으로 설정하였다.

예제 프로젝트 만들기 v2

v2 - 인터페이스 없는 구체 클래스 - 스프링 빈으로 수동 등록

@Slf4j
@RequestMapping
@ResponseBody
public class OrderControllerV2 { ... }

 

추가 변경 사항

기존: @Import(AppV1Config.class)

변경: @Import({AppV1Config.class, AppV2Config.class})

@Import 안에 배열로 등록하고 싶은 설정파일을 다양하게 추가할 수 있다.

예제 프로젝트 만들기 v3

v3 - 컴포넌트 스캔으로 스프링 빈 자동 등록

@RestController, @Service, @Repository 애노테이션을 통해 컴포넌트 스캔의 대상이 되도록 하자.

요구사항 추가

1. 원본 코드를 전혀 수정하지 않고, 로그 추적기를 적용해라.

2. 특정 메서드는 로그를 출력하지 않는 기능

  보안상 일부는 로그를 출력하면 안된다.

3. 다음과 같은 다양한 케이스에 적용할 수 있어야 한다.

  v1 - 인터페이스가 있는 구현 클래스에 적용

  v2 - 인터페이스가 없는 구체 클래스에 적용

  v3 - 컴포넌트 스캔 대상에 기능 적용

프록시, 프록시 패턴, 데코레이터 패턴 - 소개

클라이언트와 서버

클라이언트( Client )와 서버( Server )라고 하면 개발자들은 보통 서버 컴퓨터를 생각한다.

사실 클라이언트와 서버의 개념은 상당히 넓게 사용된다.

클라이언트는 의뢰인이라는 뜻이고, 서버는 '서비스나 상품을 제공하는 사람이나 물건'을 뜻한다. 따라서 클라이언트와 서버의 기본 개념을 정의하면 클라이언트는 서버에 필요한 것을 요청하고, 서버는 클라이언트의 요청을 처리하는 것이다.

 

이 개념을 우리가 익숙한 컴퓨터 네트워크에 도입하면 클라이언트는 웹 브라우저가 되고, 요청을 처리하는 서버는 웹 서버가 된다. 이 개념을 객체에 도입하면, 요청하는 객체는 클라이언트가 되고, 요청을 처리하는 객체는 서버가 된다.

직접 호출과 간접 호출

클라이언트와 서버 개념에서 일반적으로 클라이언트가 서버를 직접 호출하고, 처리 결과를 직접 받는다.

 

클라이언트가 어떤 대리자를 통해서 대신 간접적으로 서버에 요청할 수 있다. 

여기서 대리자를 영어로 프록시(Proxy)라 한다.

 

*예시*

직접 호출과 다르게 간접 호출을 하면 대리자가 중간에서 여러가지 일을 할 수 있다.

 

누군가에게 대신 어떤 일을 부탁하는 상황

  • 엄마에게 라면을 사달라고 부탁 했는데, 엄마는 그 라면은 이미 집에 있다고 할 수도 있다. 그러면 기대한 것 보다 더 빨리 라면을 먹을 수 있다. (접근 제어, 캐싱)
  • 아버지께 자동차 주유를 부탁했는데, 아버지가 주유 뿐만 아니라 세차까지 하고 왔다. 클라이언트가 기대한 것 외에 세차라는 부가 기능까지 얻게 되었다. (부가 기능 추가)
  • 그리고 대리자가 또 다른 대리자를 부를 수도 있다. 예를 들어서 내가 동생에게 라면을 사달라고 했는데, 동생은 또 다른 누군가에게 라면을 사달라고 다시 요청할 수도 있다. 중요한 점은 클라이언트는 대리자를 통해서 요청했기 때문에 그 이후 과정은 모른다는 점이다. 동생을 통해서 라면이 나에게 도착하기만 하면 된다. (프록시 체인)

 

대체 가능

여기까지 보면 아무 객체나 프록시가 될 수 있는 것 같다.

객체에서 프록시가 되려면, 클라이언트는 서버에게 요청을 한 것인지, 프록시에게 요청을 한 것인지 조차 몰라야 한다.

즉, 서버와 프록시는 같은 인터페이스를 사용해야 한다. 그리고 클라이언트가 사용하는 서버 객체를 프록시 객체로 변경해도 클라이언트 코드를 변경하지 않고 동작할 수 있어야 한다. 

 

클래스 의존 관계

클라이언트, 프록시, 서버: 클래스 의존관계

클래스 의존관계를 보면 클라이언트는 서버 인터페이스( ServerInterface )에만 의존한다. 그리고 서버와 프록시가 같은 인터페이스를 사용한다.

-> 의존성 주입(D.I) 를 통해 프록시와 서버 대체 가능

 

런타임(애플리케이션 실행 시점) 객체 의존 관계

프록시 도입 전

클라이언트 인스턴스가 서버 인스턴스에 의존

 

프록시 도입 후

클라이언트 객체에 DI를 사용해서 서버 객체 대신 프록시 객체를 주입한다.

프록시는 서버 인터페이스를 구현하고 서버처럼 동작하기 때문에 클라이언트 코드를 전혀 변경하지 않아도 된다.

클라이언트 입장에서는 변경 사실 조차 모른다.

 

DI를 사용하면 클라이언트 코드의 변경 없이 유연하게 프록시를 주입할 수 있다.

프록시의 주요 기능

접근 제어

  • 권한에 따른 접근 차단
  • 캐싱 
  • 지연 로딩

부가 기능 추가

  • 원래 서버가 제공하는 기능에 더해서 부가 기능을 수행한다.
  • 예) 요청 값이나, 응답 값을 중간에 변형한다.
  • 예) 실행 시간을 측정해서 추가 로그를 남긴다.

 

둘 다 프록시를 사용하는 방법이지만 GOF 디자인 패턴에서는 이 둘을 의도(intent)에 따라서 프록시 패턴과 데코레이터 패턴으로 구분한다.

  • 프록시 패턴: 접근 제어가 목적
  • 데코레이터 패턴: 새로운 기능 추가가 목적

참고: 프록시라는 개념은 클라이언트 서버라는 큰 개념안에서 자연스럽게 발생할 수 있다. 프록시는 객체안에서의 개념도 있고, 웹 서버에서의 프록시도 있다. 객체안에서 객체로 구현되어있는가, 웹 서버로 구현되어 있는가 처럼 규모의 차이가 있을 뿐 근본적인 역할은 같다. 

프록시 패턴 적용 전

클래스 의존 관계

  • Subject: 서버 인터페이스
  • RealSubject: 서버 구현체
  • Client 는 Subject 인터페이스 의존

 

런타임 객체 의존 관계

 

서버 인터페이스 ( Subject )

public interface Subject {
    String operation();
}

 

서버 구현체 ( RealSubject )

@Slf4j
public class RealSubject implements Subject {

    @Override
    public String operation() {
        log.info("실제 객체 호출");
        sleep(1000);
        return "data";
    }

    private void sleep(int millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

 

 

데이터를 클라이언트에 반환하기 전에 1초 지연 -> 요청을 처리하는데 1초의 시간이 걸리는 상황을 가정

 

클라이언트 ( ProxyPatternClient )

public class ProxyPatternClient {
    private Subject subject;

    public ProxyPatternClient(Subject subject) {
        this.subject = subject;
    }

    public void execute(){
        subject.operation();
    }
}

Subject 객체를 생성자를 통해 받아서 execute() 를 통해 실행

실제로는 RealSubject 객체가 주입된다.

 

noProxyTest()

@Test
void noProxyTest(){
    RealSubject realSubject = new RealSubject();
    ProxyPatternClient client = new ProxyPatternClient(realSubject);
    client.execute();
    client.execute();
    client.execute();
}

 

3번 요청 -> 3초 

 

변하지 않는 데이터라면 한 번 조회했을 때 어딘가에 보관해두고, 같은 요청이 들어오면 보관한 데이터를 사용하는 것이 성능상 좋다. 이런 것을 캐시라고 한다.

프록시 패턴의 주요 기능은 접근 제어이다. 캐시도 접근 자체를 제어하는 기능 중 하나이다.

 

이미 개발된 로직을 전혀 수정하지 않고, 프록시 객체를 통해서 캐시를 적용해보자.

프록시 패턴 - 접근 제어

클래스 의존 관계

 

런타임 객체 의존 관계

 

프록시 ( CacheProxy )

@Slf4j
public class CacheProxy implements Subject {

    private Subject target;
    private String cacheValue;

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

    @Override
    public String operation() {
        log.info("프록시 호출");
        if(cacheValue == null){
            cacheValue =  target.operation();
        }
        return cacheValue;
    }
}

 

  • private Subject target : 클라이언트가 프록시를 호출하면 프록시가 최종적으로 실제 객체를 호출해야 한다. 따라서 내부에 실제 객체의 참조를 가지고 있어야 한다. 이렇게 프록시가 호출하는 대상을 target 이라 한다.
  • operation() : 구현한 코드를 보면 cacheValue 에 값이 없으면 실제 객체( target )를 호출해서 값을 구한다. 그리고 구한 값을 cacheValue 에 저장하고 반환한다. 만약 cacheValue 에 값이 있으면 실제 객체를 전혀 호출하지 않고, 캐시 값을 그대로 반환한다. 따라서 처음 조회 이후에는 매우 빠르게 데이터를 조회할 수 있다.

cacheProxyTest()

@Test
void cacheProxyTest(){
    //의존 관계: ProxyPatternClient -> CacheProxy -> RealSubject
    RealSubject realSubject = new RealSubject();
    CacheProxy cacheProxy = new CacheProxy(realSubject);
    ProxyPatternClient client = new ProxyPatternClient(cacheProxy);
    client.execute();
    client.execute();
    client.execute();
}

클라이언트에 실제 realSubject 객체가 아닌 realSubject 를 알고 있는 cacheProxy 객체를 넘긴다.

 

*실행 결과*

CacheProxy - 프록시 호출
RealSubject - 실제 객체 호출
CacheProxy - 프록시 호출
CacheProxy - 프록시 호출

 

=> 처리 과정

1. client의 cacheProxy 호출 -> cacheProxy에 캐시 값이 없다. -> realSubject를 호출, 결과를 캐시에 저장 (1초)

2. client의 cacheProxy 호출 -> cacheProxy에 캐시 값이 있다. -> cacheProxy에서 즉시 반환 (0초)

3. client의 cacheProxy 호출 -> cacheProxy에 캐시 값이 있다. -> cacheProxy에서 즉시 반환 (0초)

 

정리

프록시 패턴의 핵심은 클라이언트와 서버의 코드 변경 없이 프록시를 도입해서 접근 제어를 했다는 점이다.

실제 클라이언트 입장에서는 프록시 객체가 주입되었는지, 실제 객체가 주입되었는지 알지 못한다.

데코레이터 패턴 적용 전

클래스 의존 관계

  • Component: 서버 인터페이스
  • RealComponent: 서버 구현체
  • Client 는 Component 인터페이스 의존

 

런타임 객체 의존 관계

 

서버 인터페이스 ( Component )

public interface Component {
    String operation();
}

 

서버 구현체 ( RealComponent )

@Slf4j
public class RealComponent implements Component{
    @Override
    public String operation() {
        log.info("RealComponent 실행");
        return "data";
    }
}

 

클라이언트 ( DecoratorPatternClient )

@Slf4j
public class DecoratorPatternClient {

    private Component component;

    public DecoratorPatternClient(Component component) {
        this.component = component;
    }

    public void execute(){
        String result = component.operation();
        log.info("result={}", result);
    }
}

 

noDecoratorTest() 

@Test
void noDecoratorTest(){
    Component realComponent = new RealComponent();
    DecoratorPatternClient client = new DecoratorPatternClient(realComponent);
    client.execute();
}

 

프록시 패턴에서 설명한 내용에서 클래스 이름만 바뀐 정도

데코레이터 패턴 - 부가 기능 추가

응답 값을 꾸며주는 데코레이터

클래스 의존 관계

 

런타임 객체 의존 관계

 

메세지 데코레이터 ( MessageDecorator )

@Slf4j
public class MessageDecorator implements Component{

    private Component component;

    public MessageDecorator(Component component) {
        this.component = component;
    }

    @Override
    public String operation() {
        log.info("MessageDecorator 실행");

        //data -> *****data*****
        String result = component.operation();
        String decoResult = "*****" + result + "*****";
        log.info("MessageDecorator 꾸미기 적용 전={}, 적용 후={}", result, decoResult);
        return decoResult;
    }
}

 

decoratorTest1()

@Test
void decoratorTest1(){
    //의존 관계: client -> messageDecorator -> realComponent
    Component realComponent = new RealComponent();
    Component messageDecorator = new MessageDecorator(realComponent);
    DecoratorPatternClient client = new DecoratorPatternClient(messageDecorator);
    client.execute();
}

클라이언트에 실제 realComponent 객체가 아닌 realComponent를 알고 있는 messageDecorator 객체를 넘긴다.

 

*실행 결과*

MessageDecorator - MessageDecorator 실행

RealComponent - RealComponent 실행

MessageDecorator - MessageDecorator 꾸미기 적용 전=data, 적용 후=*****data*****

DecoratorPatternClient - result=*****data*****

실행 시간을 측정하는 데코레이터

기존 데코레이터 + 실행 시간 측정 데코레이터 추가 => 프록시 체인

 

클래스 의존 관계

 

런타임 객체 의존 관계

 

시간 측정 데코레이터 ( TimeDecorator )

@Slf4j
public class TimeDecorator implements Component{

    private final Component component;

    public TimeDecorator(Component component){
        this.component = component;
    }

    @Override
    public String operation() {
        log.info("TimeDecorator 실행");
        long startTime = System.currentTimeMillis();
        String result = component.operation(); //messageDecorator.operation()
        long endTime = System.currentTimeMillis();

        long resultTime = endTime - startTime;
        log.info("TimeDecorator 종료, resultTime={}", resultTime);
        return result;
    }
}

 

decoratorTest2()

@Test
void decoratorTest2(){
    //의존 관계: client -> timeDecorator -> messageDecorator -> realComponent
    Component realComponent = new RealComponent();
    Component messageDecorator = new MessageDecorator(realComponent);
    TimeDecorator timeDecorator = new TimeDecorator(messageDecorator);
    DecoratorPatternClient client = new DecoratorPatternClient(timeDecorator);
    client.execute();
}

 

*실행 결과*

TimeDecorator 실행

MessageDecorator 실행

RealComponent 실행

MessageDecorator 꾸미기 적용 전=data, 적용 후=*****data*****

TimeDecorator 종료, resultTime=7ms

result=*****data*****

 

TimeDecorator 가 MessageDecorator 를 실행하고 실행 시간을 측정하여 출력