Spring/[인프런 김영한 스프링 핵심 원리 - 고급편]

[인프런 김영한 스프링 핵심 원리 - 고급편] 프록시 패턴과 데코레이터 패턴

h2boom 2024. 12. 7. 01:06

예제 프로젝트 만들기

  • 예제 프로젝트 종류
    • v1 - 인터페이스와 구현 클래스 (스프링 빈 수동 등록)
    • v2 - 인터페이스 없는 구체 클래스 (스프링 빈 수동 등록)
    • v3 - 컴포넌트 스캔 (스프링 빈 자동 등록)
  • 실무에서 스프링 빈으로 등록할 클래스는 인터페이스가 있는 경우도 없는 경우도 있다.
    • 스프링 빈으로 직접 등록하는 경우도, 컴포넌트 스캔으로 자동 등록하는 경우도 있다.

예제 프로젝트 만들기 - v1

public interface OrderRepositoryV1 {
    void save(String itemId);
}

public class OrderRepositoryV1Impl implements OrderRepositoryV1 {
    @Override
    public void save(String itemId) {
        //저장 로직
        if (itemId.equals("ex")) {
            throw new IllegalStateException("예외 발생!");
        }
        sleep(1000);
    }

    private void sleep(int millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}
  • OrderRepositoryV1 인터페이스 & 구현체 예제

 

public interface OrderServiceV1 {
    void orderItem(String itemId);
}

public class OrderServiceV1Impl implements OrderServiceV1 {
    private final OrderRepositoryV1 orderRepository;

    public OrderServiceV1Impl(OrderRepositoryV1 orderRepository) {
        this.orderRepository = orderRepository;
    }

    @Override
    public void orderItem(String itemId) {
        orderRepository.save(itemId);
    }
}
  • OrderServiceV1 인터페이스 & 구현체 예제

 

@RequestMapping
@ResponseBody
public interface OrderControllerV1 {
    @GetMapping("/v1/request")
    String request(@RequestParam("itemId") String itemId);

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

public class OrderControllerV1Impl implements OrderControllerV1 {

    private final OrderServiceV1 orderService;

    public OrderControllerV1Impl(OrderServiceV1 orderService) {
        this.orderService = orderService;
    }

    @Override
    public String request(String itemId) {
        orderService.orderItem(itemId);
        return "ok";
    }

    @Override
    public String noLog() {
        return "ok";
    }
}
  • OrderControllerV1 인터페이스 & 구현체 예제
    • 스프링 MVC는 @Controller 또는 @RequestMapping 어노테이션이 있어야 스프링 컨트롤러로 인식한다.
      • 스프링 컨트롤러로 인식해야 HTTP URL이 매핑되고 동작하며 인터페이스에도 해당 어노테이션들을 사용 가능하다.
      • 스프링 부트 3.0 이상부터는 @RequestMapping이 있어도 스프링 컨트롤러로 인식하지 않기에 @Controller / @RestController가 필요하다.
    • @ResponseBody : HTTP 메시지 컨버터를 사용해서 응답한다.
    • @RequestParam("itemId") String itemId
      • 인터페이스에서 @RequestParam("itemId") 값을 생략하면 itemId 단어를 컴파일 이후 자바 버전에 따라 인식하지 못할 수 있다.
      • 스프링부트 3.2 이상부터 클래스에서 생략하는 경우 파라미터 인식 문제가 발생
        • 자바 컴파일러에 -parameters 옵션을 넣어야 생략할 수 있다.

 

@Configuration
public class AppV1Config {
    @Bean
    public OrderControllerV1 orderControllerV1() {
        return new OrderControllerV1Impl(orderServiceV1());
    }

    @Bean
    public OrderServiceV1 orderServiceV1() {
        return new OrderServiceV1Impl(orderRepositoryV1());
    }

    @Bean
    public OrderRepositoryV1 orderRepositoryV1() {
        return new OrderRepositoryV1Impl();
    }
}
  • 스프링 빈 설정 파일V1 예제

 

@Import(AppV1Config.class)
@SpringBootApplication(scanBasePackages = "hello.proxy.app")
public class ProxyApplication {
	public static void main(String[] args) {
		SpringApplication.run(ProxyApplication.class, args);
	}
}
  • 메인 애플리케이션 클래스
    • @SpringBootApplication 어노테이션의 scanBasePackages : 컴포넌트 스캔을 시작할 위치를 지정하는 속성
      • 기본 값은 메인 애플리케이션이 있는 패키지와 하위 패키지를 스캔한다.
    • scanBasePackages 속성에 의해서 스프링 설정 파일 AppV1Config는 컴포넌트 스캔이 되지 않는다.
      • 원래는 AppV1Config는 @Configuration을 사용했기에 별도로 스프링 빈을 등록하지 않아도 된다.
        • @Configuration 내부에는 @Component가 있기에 기본적으로 컴포넌트 스캔의 대상이 된다.
      • @Import 어노테이션을 사용해서 별도로 스프링 빈으로 등록
    • @Import
      • @Configuration으로 설정한 파일을 두개 이상 사용하는 경우 설정 파일을 등록할 때 사용한다.
      • @Import는 일반적으로 스프링 설정 파일을 등록할 때 사용하지만 스프링 빈을 등록할 때 사용할 수도 있다.

 

  • @Bean vs @Import
    • 공통점 : 둘 다 직접 스프링 빈을 등록할 때 사용
    • @Bean
      • 메소드 레벨에 사용하며 해당 메소드의 반환 객체를 스프링 빈으로 등록할 때 사용한다.
        • 개별 객체 등록
    • @Import
      • 주로 하나의 설정 클래스에서 다른 설정 클래스를 가져와 통합해서 스프링 빈으로 등록할 때 사용한다.


예제 프로젝트 만들기 - v2

  • OrderControllerV2, OrderServiceV2, OrderRepositoryV2, 설정 파일 코드는 V1 구현체와 유사하므로 생략
@Import({AppV1Config.class, AppV2Config.class})
@SpringBootApplication(scanBasePackages = "hello.proxy.app") //주의
public class ProxyApplication {
    public static void main(String[] args) {
        SpringApplication.run(ProxyApplication.class, args);
    }
}
  • 메인 애플리케이션 클래스
    • @Import({~.class, ~.class})처럼 {}안에 ,로 구분하여 여러 클래스를 등록할 수도 있다.

예제 프로젝트 만들기 - v3

  • V2 예제와 차이점
    • OrderRepositoryV3 코드는 V2 코드에서 컴포넌트 스캔을 위해 @Repository 추가
    • OrderServiceV3 코드는 V2 코드에서 컴포넌트 스캔을 위해 @Service 추가
    • OrderControllerV3 코드는 V2 코드에서 컴포넌트 스캔을 위해 @RequestMapping, @ResponseBody 대신 @RestController 추가
  • @Repository, @Service, @Controller / @RestController는 내부에 @Component가 있기에 컴포넌트 스캔의 대상이 된다.

요구사항 추가

  • 기존 요구사항
    • 모든 PUBLIC 메서드의 호출과 응답 정보를 로그로 출력
    • 애플리케이션의 흐름을 변경하면 안됨
      • 로그를 남긴다고 해서 비즈니스 로직의 동작에 영향을 주면 안됨
    • 메서드 호출에 걸린 시간
    • 정상 흐름과 예외 흐름 구분
      • 예외 발생시 예외 정보가 남아야 함
    • 메서드 호출의 깊이 표현
    • HTTP 요청을 구분
      • HTTP 요청 단위로 특정 ID를 남겨서 어떤 HTTP 요청에서 시작된 것인지 명확하게 구분이 가능해야 함
      • 트랜잭션 ID (DB 트랜잭션X)
  • 기존 로그 추적기 예제는 템플릿 메소드 패턴 / 콜백 패턴을 사용하더라도 로그를 남기고 싶은 클래스가 있으면 로그 추적기를 적용하기 위해서 해당 클래스 모두를 고쳐야 한다는 문제점이 있다.

 

  • 요구사항 추가
    • 원본 코드를 전혀 수정하지 않고, 로그 추적기를 적용해라.
    • 특정 메서드는 로그를 출력하지 않는 기능
      • 보안상 일부는 로그를 출력하면 안된다.
    • 다음과 같은 다양한 케이스에 적용할 수 있어야 한다.
      • v1 - 인터페이스가 있는 구현 클래스에 적용
      • v2 - 인터페이스가 없는 구체 클래스에 적용
      • v3 - 컴포넌트 스캔 대상에 기능 적용
  • 추가 요구사항을 통해 원본 코드를 전혀 수정하지 않고 로그 추적기를 도입하는 것이 목표
    • 추가 요구사항을 해결하기 위해 프록시가 필요하다.

프록시

  • 클라이언트 - 서버 개념은 넓게 사용된다.
    • 서버 : 서비스나 상품을 제공하는 사람이나 물건
    • 클라이언트 : 의뢰인
  • 클라이언트는 서버에 필요한 것을 요청하고 서버는 클라이언트의 요청을 처리한다.
    • 해당 개념을 컴퓨터 네트워크에 도입하면 클라이언트는 웹 브라우저, 서버는 요청을 처리하는 웹 서버
    • 해당 개념을 객체에 도입하면 요청하는 객체는 클라이언트, 요청을 처리하는 객체는 서버

 

  • 직접 호출 vs 간접 호출
    • 직접 호출 : 클라이언트가 서버를 직접 호출하고 처리 결과를 직접 받는 것
    • 간접 호출 : 클라이언트가 대리자(Proxy)를 통해서 간접적으로 서버에 요청하는 것
      • 대리자를 영어로 프록시라고 한다.

 

  • 직접 호출과 달리 간접 호출을 하면 대리자가 중간에서 여러가지 일을 할 수 있다.
    • 접근 제어, 캐싱, 부가 기능 추가, 프록시 체인... 등

 

  • 객체에서 프록시가 되려면 클라이언트는 서버에게 요청한 것인지, 프록시에게 요청한 것인지 조차 몰라야한다.
    • 서버와 프록시는 같은 인터페이스를 사용해야 한다.
    • 클라이언트가 사용하는 서버 객체를 프록시 객체로 변경해도 클라이언트 코드를 변경하지 않고 동작할 수 있어야한다.

  • 클라이언트는 서버 인터페이스에만 의존하고 서버와 프록시는 같은 인터페이스를 사용하며 DI를 사용해서 대체가능하다.

 

  • 런타임 객체 의존 관계
    • 런타임에 클라이언트 객체에 DI를 사용해서 Client -> Server에서 Client -> Proxy로 객체 의존관계를 변경해도 클라이언트 코드를 전혀 변경하지 않아도 되며 변경 사실 조차 모른다.

 

  • 프록시 주요 기능
    • 접근 제어
      • 권한에 따른 접근 차단
      • 캐싱
      • 지연 로딩
    • 부가 기능 추가
      • 원래 서버가 제공하는 기능에 추가로 부가 기능을 수행한다.
      • ex) 요청 값이나 응답 값을 중간에 변형, 실행 시간을 측정해서 추가 로그를 남기는 것

 

  • 프록시 패턴과 데코레이터 패턴은 둘 다 프록시를 사용하는 GOF 디자인 패턴이지만 의도에 따라서 구분한다.
    • 프록시 패턴 : 접근 제어가 목적인 프록시를 사용하는 GOF 디자인 패턴 중 하나
    • 데코레이터 패턴 : 새로운 기능 추가가 목적인 프록시를 사용하는 GOF 디자인 패턴 중 하나

프록시 패턴 - 예제 (적용x)

public interface Subject {
    String operation();
}

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

    private void sleep(int millis) {
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}
  • 프록시 패턴 적용을 위한 서버 예제 코드
    • RealSubject는 Subject 인터페이스를 구현
    • operation() : 데이터 조회가 1초 걸린다고 가정

 

public class ProxyPatternClient {
    private Subject subject;

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

    public void execute() {
        subject.operation();
    }
}
  • 클라이언트 예제 코드

 

@Test
public void noProxyTest() {
    RealSubject realSubject = new RealSubject();
    ProxyPatternClient client = new ProxyPatternClient(realSubject);
    client.execute();
    client.execute();
    client.execute();
}
  • 테스트 예제 코드
    • noProxyTest()
      • client.execute()
        • client -> realSubject를 호출해서 1초가 걸리는 데이터 조회를 3번 반복
        • 총 3초의 시간 소요됨
        • 만약 데이터가 한번 조회 후 변하지 않는다면 어딘가에 보관해두고 이미 조회한 데이터를 사용하는 것이 성능상 좋다 => 캐시
  • 프록시 패턴의 주요 목적은 접근 제어이며 캐시는 접근 제어 기능 중 하나다.

프록시 패턴 - 예제 (적용O)

  • 프록시 객체를 적용해서 기존 로직을 수정하지 않고 캐시를 적용

 

@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;
    }
}
  • 프록시 패턴을 적용한 캐시 예제
    • 프록시 객체 CacheProxy도 실제 객체 RealSubject와 모양이 같아야하기에 Subject 인터페이스를 구현
    • private Subject target : 클라이언트가 프록시를 호출하면 프록시가 최종적으로 실제 객체를 호출해야하기 때문에 내부에 실제 객체의 참조를 가지고 있어야 한다.
      • 프록시가 호출하는 대상을 target이라 한다.
    • operation() : cacheValue에 값이 없으면 실제 객체(target)을 호출해서 값을 구하고 저장하고 반환한다. 값이 있다면 실제 객체를 호출하지 않고 cacheValue(캐시 값)를 그대로 반환한다.
      • 처음 조회 이후에는 cacheValue를 통해 매우 빠르게 데이터를 조회할 수 있다.

 

@Test
public void cacheProxyTest() {
    RealSubject realSubject = new RealSubject();
    CacheProxy cacheProxy = new CacheProxy(realSubject);
    ProxyPatternClient client = new ProxyPatternClient(cacheProxy);
    
    client.execute();
    client.execute();
    client.execute();
}
  • 테스트 예제 코드
    • cacheProxyTest()
      • Client에 RealSubject가 아닌 CacheProxy를 주입, CacheProxy에는 RealSubject를 주입
        • Client -> CacheProxy -> RealSubject 런타임 객체 의존 관계가 완성된다.
      • client.execute()
        • 총 3번 호출
          • RealSubject가 아닌 CacheProxy를 호출
        • client.execute() 3번 호출 처리 과정
          1. execute() 호출 -> CacheProxy에 캐시 값이 없음 -> RealSubject 호출, 결과를 캐시에 저장
            (1초 소요)
          2. execute() 호출 -> CacheProxy 캐시 값이 있음 -> CacheProxy에서 즉시 반환 (0초)
          3. execute() 호출 -> CacheProxy 캐시 값이 있음 -> CacheProxy에서 즉시 반환 (0초)
            => 이전에는 총 3초의 시간이 걸렸지만 캐시 프록시를 도입함으로 총 1초의 시간이 소요된다.
  • 프록시 패턴의 핵심은 RealSubject (서버)코드와 ProxyPatternClient(클라이언트) 코드를 전혀 변경하지 않고 프록시를 도입함으로 접근 제어를 한 것이다.
    • 클라이언트 코드 변경 없이 자유롭게 프록시를 넣고 뺄 수 있다.
    • 실제 클라이언트 입장에서는 프록시 객체가 주입되었는지 실제 객체가 주입되었는지 알 수 없다.

데코레이터 패턴 - 예제

public interface Component {
    String operation();
}

@Slf4j
public class RealComponent implements Component {
    @Override
    public String operation() {
        log.info("RealComponent 실행");
        return "data";
    }
}
  • 데코레이터 패턴 서버 예제 코드

 

@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);
    }
}
  • 데코레이터 패턴 클라이언트 예제 코드

 

  • 데코레이터 패턴을 적용해서 프록시로 부가 기능을 추가
    • ex) 요청, 응답 값을 중간에 변형
      실행 시간을 측정해서 추가 로그를 남기는 것

@Slf4j
public class MessageDecorator implements Component {
    private Component component;

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

    @Override
    public String operation() {
        log.info("MessageDecorator 실행");
        String result = component.operation();
        String decoResult = "*****" + result + "*****";
        log.info("MessageDecorator 꾸미기 전={}, 적용 후={}", result, decoResult);
        return decoResult;
    }
}
  • 데코레이터 패턴을 적용해서 응답 값을 꾸며주는 예제
    • 프록시 객체 MessageDecorator는 실제 객체인 RealComponent와 같이 Component를 구현
    • Component component : 프록시가 호출해야하는 대상(실제 객체)을 저장한다.
    • operation() : 프록시와 연결된 대상을 호출 (component.operation())하고 그 응답 값에 *****을 앞뒤로 더해 꾸며준 다음 반환한다.
      • 기존 실제 객체의 응답 값 data -> 꾸민 후 *****data*****

 

  • 기존 예제 (데코레이터 패턴으로 응답 값 꾸미기) + 실행 시간 측정 데코레이터 패턴 적용
@Slf4j
public class TimeDecorator implements Component {
    private Component component;

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

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

        String result = component.operation();

        long endTime = System.currentTimeMillis();
        long resultTime = endTime - startTime;
        log.info("TimeDecorator 종료, resultTIme={}ms", resultTime);

        return result;
    }
}
  • 데코레이터 패턴을 적용해 시간 측정하는 예제 코드
    • client -> timeDecorator -> messageDecorator -> realComponent 순서로 호출 (반환은 역순으로)
    • 프록시 체인 방식으로 한 프록시의 응답 값을 다른 프록시가 꾸며주며 계속 체인 형식으로 이어질 수 있다.

 

  • 데코레이터 패턴을 적용하려면 항상 꾸며줄 대상이 있어야한다.
    • 꾸며줄 대상(component)을 내부에 필드로 가지고 있어야하며 항상 호출해줘야 한다.
      • 이 부분이 중복되기에 component를 필드로 가지고 있는 Decorator 추상 클래스를 만드는 방법도 있다.
        => 추상 클래스로 만들면 클래스 다이어그램에서 어떤 것이 실제 컴포넌트인지 데코레이터인지 명확하게 구분할 수 있게 된다.

 

  • 프록시 패턴 vs 데코레이터 패턴
    • 둘 다 모양이 거의 같지만 디자인 패턴에서 중요한 것은 겉모양이 아닌 패턴을 만든 의도가 중요하다.
    • 프록시 패턴의 의도 : 다른 개체에 대한 접근을 제어하기 위해 대리자(proxy)를 제공
    • 데코레이터 패턴의 의도 : 객체에 추가 책임(기능)을 동적으로 추가하고 기능 확장을 위한 유연한 제공
  • 프록시를 사용할 때 프록시의 목적이 접근 제어라면 프록시 패턴, 새로운 기능 추가가 목적이라면 데코레이터 패턴이 된다.

인터페이스 기반 프록시  - 적용

  • 인터페이스와 구현체가 있는 V1 예제에 프록시를 도입함으로 기존 코드를 수정하지 않고 로그 추적기능 추가

 

 

@RequiredArgsConstructor
public class OrderControllerInterfaceProxy implements OrderControllerV1 {
    private final OrderControllerV1 target;
    private final LogTrace logTrace;

    @Override
    public String request(String itemId) {
        TraceStatus status = null;
        try {
            status = logTrace.begin("OrderController.request()");
            //target 호출
            String result = target.request(itemId);
            logTrace.end(status);
            return result;
        } catch (Exception e) {
            logTrace.exception(status, e);
            throw e;
        }
    }

    @Override
    public String noLog() {
        return target.noLog();
    }
}
  • ControllerV1에 프록시 패턴 적용한 예제
    • 프록시에서 실제 객체(ControllerV1)을 호출하기 위해 내부에 target을 가지고 있다

 

@RequiredArgsConstructor
public class OrderServiceInterfaceProxy implements OrderServiceV1 {
    private final LogTrace logTrace;
    private final OrderServiceV1 target;

    @Override
    public void orderItem(String itemId) {
        TraceStatus status = null;
        try {
            status = logTrace.begin("OrderService.orderItem()");
            //target 호출
            target.orderItem(itemId);
            logTrace.end(status);
        } catch (Exception e) {
            logTrace.exception(status, e);
            throw e;
        }
    }
}
  • ServiceV1에 프록시 패턴 적용한 예제
    • 프록시에서 실제 객체(ServiceV1)을 호출하기 위해 내부에 target을 가지고 있다

 

@RequiredArgsConstructor
public class OrderRepositoryInterfaceProxy implements OrderRepositoryV1 {
    private final LogTrace logTrace;
    private final OrderRepositoryV1 target;

    @Override
    public void save(String itemId) {
        TraceStatus status = null;
        try {
            status = logTrace.begin("OrderRepository.save()");
            //target 호출
            target.save(itemId);
            logTrace.end(status);
        } catch (Exception e) {
            logTrace.exception(status, e);
            throw e;
        }
    }
}
  • RepositoryV1에 프록시 패턴 적용한 예제
    • 프록시에서 실제 객체(RepositoryV1)을 호출하기 위해 내부에 target을 가지고 있다
  • Controller, Service, Repository 모두 프록시를 사용함으로 기존 코드를 변경하지 않고 로그 추적 기능을 추가

 

@Configuration
public class InterfaceProxyConfig {

    @Bean
    public OrderControllerV1 orderController(LogTrace logTrace) {
        OrderControllerV1Impl controllerImpl = new OrderControllerV1Impl(orderService(logTrace));
        new OrderControllerInterfaceProxy(controllerImpl, logTrace);
    }

    @Bean
    public OrderServiceV1 orderService(LogTrace logTrace) {
        OrderServiceV1Impl serviceImpl = new OrderServiceV1Impl(orderRepository(logTrace));
        return new OrderServiceInterfaceProxy(logTrace, serviceImpl);
    }

    @Bean
    public OrderRepositoryV1 orderRepository(LogTrace logTrace) {
        OrderRepositoryV1Impl repositoryImpl = new OrderRepositoryV1Impl();
        return new OrderRepositoryInterfaceProxy(logTrace, repositoryImpl);
    }
}
  • 의존관계 설정 클래스
    • 기존 V1 예제에서는 스프링 빈이 ~Impl과 같은 실제 객체를 반환
    • 프록시를 사용함으로 프록시를 실제 객체 대신 스프링 빈으로 등록하고 실제 객체는 등록하지 않는다.
      • 프록시 객체는 내부에서 실제 객체를 참조해야 한다.
      • 스프링 빈을 주입 받으면 실제 객체 대신 프록시 객체가 주입되고 실제 객체는 프록시 객체에 의해 호출된다.
      • 실제 객체는 프록시 객체에 의해 참조되고 있기에 가비지 컬렉션되지 않는다.

  • 프록시를 적용함으로 스프링 컨테이너에는 프록시 객체가 등록되고 스프링 빈으로 관리한다.
    • 프록시 객체는 스프링 컨테이너가 관리하며 자바 힙 메모리에 올라간다.
    • 실제 객체는 자바 힙 메모리에는 올라가지만 스프링 컨테이너가 관리하지는 않는다.

 

  • 런타임 호출 과정
    1. client가 OrderControllerV1 호출
    2. OrderControllerV1 프록시가 호출되고 로직 수행
    3. OrderControllerV1 프록시가 실제 객체 OrderControllerV1Impl 호출되고 로직 수행
    4. 실제 객체 OrderControllerV1Impl가 OrderServiceV1 호출
    5. OrderServiceV1 프록시가 호출되고 로직 수행
      이하 반복(Repository까지)

구체 클래스 기반 프록시 - 예제

@Slf4j
public class ConcreteLogic {
    public String operation() {
        log.info("ConcreteLogic 실행");
        return "data";
    }
}
  • 인터페이스가 없는 구체 클래스 (서버) 예제

 

@Slf4j
public class TimeProxy extends ConcreteLogic {
    private ConcreteLogic concreteLogic;

    public TimeProxy(ConcreteLogic concreteLogic) {
        this.concreteLogic = concreteLogic;
    }

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

        String result = concreteLogic.operation();

        long endTime = System.currentTimeMillis();
        long resultTime = endTime - startTime;
        log.info("TimeDecorator 종료, resultTIme={}ms", resultTime);

        return result;
    }
}
  • 구체 클래스에 프록시 적용 예제
    • 프록시 TimeProxy는 시간 측정하는 부가 기능을 제공
    • 프록시를 사용하기 위해서 인터페이스를 구현해서 만든 것이 아닌 ConcreteLogic클래스를 상속받아서 만든다.

 

public class ConcreteClient {
    private ConcreteLogic concreteLogic;

    public ConcreteClient(ConcreteLogic concreteLogic) {
        this.concreteLogic = concreteLogic;
    }

    public void execute() {
        concreteLogic.operation();
    }
}
  • 인터페이스가 없는 구체 클래스 (클라이언트) 예제
    • 생성자에는 ConcreteLogic을 주입 받지만 ConcreteLogic 뿐만 아니라 TimeProxy도 주입받을 수 있다.
      • ConcreteClient는 ConcreteLogic을 의존하는데 다형성에 의해 ConcreteLogic에는 ConcreteLogic과 TimeProxy 모두 들어갈 수 있다.
      • ex) ConcreteLogic = concreteLogic (본인의 타입을 할당)
        ConcreteLogic = timeProxy (자식 타입을 할당)

 

  • 자바에서 다형성은 인터페이스나 클래스를 구분하지 않고 모두 적용된다.
    • 해당 타입과 하위 타입 모두 다형성의 대상이 된다.

구체 클래스 기반 프록시 - 적용

@RequiredArgsConstructor
public class OrderRepositoryConcreteProxy extends OrderRepositoryV2 {
    private final LogTrace logTrace;
    private final OrderRepositoryV2 target;

    @Override
    public void save(String itemId) {
        TraceStatus status = null;
        try {
            status = logTrace.begin("OrderRepository.save()");
            target.save(itemId);
            logTrace.end(status);
        } catch (Exception e) {
            logTrace.exception(status, e);
            throw e;
        }
    }
}
  • OrderRepositoryV2 프록시 예제
    • 인터페이스가 아닌 OrderRepositoryV2 클래스를 상속받아서 프록시를 만든다.
    • 프록시 내부에는 내부에서 호출할 실제 객체(target)를 가지고 있다.

 

public class OrderServiceConcreteProxy extends OrderServiceV2 {
    private final LogTrace logTrace;
    private final OrderServiceV2 target;

    public OrderServiceConcreteProxy(LogTrace logTrace, OrderServiceV2 target) {
        super(null);
        this.logTrace = logTrace;
        this.target = target;
    }

    @Override
    public void orderItem(String itemId) {
        TraceStatus status = null;
        try {
            status = logTrace.begin("OrderService.orderItem()");
            target.orderItem(itemId);
            logTrace.end(status);
        } catch (Exception e) {
            logTrace.exception(status, e);
            throw e;
        }
    }
}
  • OrderServiceV2 프록시 예제
    • 인터페이스가 아닌 OrderServiceV2 클래스를 상속받아서 프록시를 만든다.
    • 프록시 내부에는 내부에서 호출할 실제 객체(target)를 가지고 있다.
    • 클래스 기반 프록시의 단점
      • 자바 기본 문법에 의해 자식 클래스를 생성할 때는 항상 super()로 부모 클래스의 생성자를 호출해야 한다.
        • super(...)를 생략하면 기본 생성자를 호출하는 super()가 자동으로 생성된다.
        • 예제에서 부모 클래스 OrderServiceV2는 기본 생성자가 없고 파라미터 1개를 필수로 받는 생성자만 있기에 파라미터를 넣어서 super(...)를 호출해야 한다.
      • 인터페이스 기반 프록시는 이런 고민을 하지 않아도 된다.
    • super(null) : 클래스 기반 프록시는 부모 객체의 기능을 사용하지 않기 때문에 super(null)로 입력해도 된다.

 

public class OrderControllerConcreteProxy extends OrderControllerV2 {
    private final LogTrace logTrace;
    private final OrderControllerV2 target;

    public OrderControllerConcreteProxy(LogTrace logTrace, OrderControllerV2 target) {
        super(null);
        this.logTrace = logTrace;
        this.target = target;
    }

    @Override
    public String request(String itemId) {
        TraceStatus status = null;
        try {
            status = logTrace.begin("OrderController.request()");
            String result = target.request(itemId);
            logTrace.end(status);
            return result;
        } catch (Exception e) {
            logTrace.exception(status, e);
            throw e;
        }
    }

    @Override
    public String noLog() {
        return target.noLog();
    }
}
  • OrderControllerV2 프록시 예제
    • 인터페이스가 아닌 OrderControllerV2 클래스를 상속받아서 프록시를 만든다.
    • 프록시 내부에는 내부에서 호출할 실제 객체(target)를 가지고 있다.
    • noLog()
      • target은 결국 상속받은 부모 클래스와 같기에 target 대신 super를 사용해도 되지 않을까? 
      • target.noLog() 대신 super.noLog()로 호출할 수 있는 것처럼 보이지만 super.noLog()를 사용하면 오버라이딩한 noLog()도 호출돼서 의도한대로 결과가 나오지 않기에 프록시 객체와 원본 객체를 명확하게 분리하기 위해서 target으로 별도로 나눴다.

인터페이스 기반 프록시와 클래스 기반 프록시

  • 인터페이스 기반 프록시 vs 클래스 기반 프록시
    • 인터페이스가 없어도 클래스 기반으로 프록시를 생성할 수 있다.
    • 인터페이스 기반 프록시
      • 인터페이스만 같으면 모든 곳에 적용할 수 있다.
    • 클래스 기반 프록시
      • 해당 클래스에만 적용할 수 있다.
      • 상속을 사용함으로 인해 생기는 제약이 있다.
        • 부모 클래스의 생성자를 호출해야 한다.
        • 클래스에 final 키워드가 붙으면 상속이 불가능하다.
        • 메소드에 final 키워드가 붙으면 해당 메소드를 오버라이딩 할 수 없다.
  • 인터페이스 기반 프록시가 상속에서 자유롭고 프로그래밍 관점에서도 역할과 구현을 명확하게 나누기에 더 좋다.
    • 인터페이스가 없으면 안된다는 것과 캐스팅 관련해서 단점이 있다.
  • 인터페이스는 구현을 변경할 가능성이 있을 때 효과적이지만 가능성이 거의 없는 경우 인터페이스를 사용하는 것은 번거롭고 실용적이지 못하기에 인터페이스 없이 구체 클래스를 사용하는 것이 더 좋은 경우도 있다.

 

  • 프록시 클래스를 사용하면 기존 코드를 변경하지 않아도 부가 기능을 추가할 수 있다.
    • 문제점 : 프록시를 적용해야 하는 클래스가 많을수록 만들어야하는 프록시 클래스가 많아진다.
    • 해결 방법 : 동적 프록시 기술을 사용하면 된다.

정리

  • 프록시 : 클라이언트와 서버 사이에서 요청을 처리하고 응답해주는 역할
    • 객체에서 프록시가 되려면 클라이언트는 서버에게 요청한 것인지, 프록시에게 요청한 것인지 조차 몰라야한다.
      • 서버와 프록시는 같은 인터페이스를 사용해야 한다.
      • 클라이언트가 사용하는 서버 객체를 프록시 객체로 변경해도 클라이언트 코드를 변경하지 않고 동작할 수 있어야한다.
    • 주요 기능
      • 접근 제어
        • 권한에 따른 접근 차단
        • 캐싱
        • 지연 로딩
      • 부가 기능 추가
        • 원래 서버가 제공하는 기능에 추가로 부가 기능을 수행한다.
        • ex) 요청 값이나 응답 값을 중간에 변형, 실행 시간을 측정해서 추가 로그를 남기는 것

 

  • 프록시 패턴 vs 데코레이터 패턴
    • 둘 다 모양이 거의 같고 프록시를 사용하지만 디자인 패턴에서 중요한 것은 겉모양이 아닌 패턴을 만든 의도가 중요하다.
    • 프록시 패턴의 의도 : 다른 개체에 대한 접근을 제어하기 위해 대리자(proxy)를 제공
    • 데코레이터 패턴의 의도 : 객체에 추가 책임(기능)을 동적으로 추가하고 기능 확장을 위한 유연한 제공
  • 프록시를 사용할 때 프록시의 목적이 접근 제어라면 프록시 패턴, 새로운 기능 추가가 목적이라면 데코레이터 패턴이 된다.

 

  • 프록시를 사용하면 프록시 객체를 실제 객체 대신 스프링 빈으로 등록하고 실제 객체는 등록하지 않는다.
    • 프록시 객체는 내부에서 실제 객체를 참조해야 한다.
    • 스프링 빈을 주입 받으면 실제 객체 대신 프록시 객체가 주입되고 실제 객체는 프록시 객체에 의해 호출된다.
    • 실제 객체는 프록시 객체에 의해 참조되고 있기에 가비지 컬렉션되지 않는다.

 

  • 인터페이스 기반 프록시 vs 클래스 기반 프록시
    • 인터페이스가 없어도 클래스 기반으로 프록시를 생성할 수 있다.
    • 인터페이스 기반 프록시
      • 인터페이스만 같으면 모든 곳에 적용할 수 있다.
    • 클래스 기반 프록시
      • 해당 클래스에만 적용할 수 있다.
      • 상속을 사용함으로 인해 생기는 제약
        1. 부모 클래스의 생성자를 호출해야 한다.
        2. 클래스에 final 키워드가 붙으면 상속이 불가능하다.
        3. 메소드에 final 키워드가 붙으면 해당 메소드를 오버라이딩 할 수 없다.
  • 인터페이스 기반 프록시가 상속에서 자유롭고 프로그래밍 관점에서도 역할과 구현을 명확하게 나누기에 더 좋다.

출처 : [인프런 김영한 스프링 핵심 원리 - 고급편]

https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-%ED%95%B5%EC%8B%AC-%EC%9B%90%EB%A6%AC-%EA%B3%A0%EA%B8%89%ED%8E%B8/dashboard

 

스프링 핵심 원리 - 고급편 강의 | 김영한 - 인프런

김영한 | 스프링의 핵심 원리와 고급 기술들을 깊이있게 학습하고, 스프링을 자신있게 사용할 수 있습니다., 핵심 디자인 패턴, 쓰레드 로컬, 스프링 AOP스프링의 3가지 핵심 고급 개념 이해하기

www.inflearn.com