Post

스프링 고급편

ThreadLocal

  • 스프링에서는 빈에 객체를 등록할 때 싱글톤으로 등록한다.

  • 그렇기 때문에 필드에 주입받은 객체들은 동시성 문제를 해결해야한다. 그때 사용가능한 것이 ThreadLocal이다.

  • 쓰레드 로컬은 각 쓰레드만의 특별한 저장소를 말한다. 즉, 해당 쓰레드만 접근할 수 있는 전용 보관소를 만들어주는 것이다.

    image

  • 사용법

    • 값 저장 : ThreadLocal.set()
    • 값 조회 : ThreadLocal.get()
    • 값 제거 : ThreadLocal.remove()

쓰레드 로컬 - 주의 사항

  • 쓰레드 로컬의 값을 사용 후 제거하지 않으면 WAS처럼 쓰레드 풀을 사용하는 경우 심각한 문제가 발생할 수 있다.

  • 예를 통해 알아보자

    • 사용자 A 저장 요청

      image

    • 사용자 A 저장 요청 종료

      image

    • 사용자 B 조회 요청

      image

    • 위와 같이 사용자 A의 데이터를 사용자 B가 확인하는 문제가 발생한다. 그렇기 때문에 꼭 요청이 끝날 때 ThreadLocal.remove()를 통해 값을 제거해야한다.

템플릿 메서드 패턴과 콜백 패턴

  • 우리의 애플리케이션은 대체로 핵심 기능과 부가 기능으로 나뉘어져있다.
  • 핵심 기능 : 객체가 제공하는 고유의 기능이다. 보통 비즈니스 로직이라고 불리는 부분이다.
  • 부가 기능 : 핵심 기능을 보조하기 위한 기능이다. 예를 들어 트랜잭션이나 로그 추적 로직 등이 있다.
  • 보통 개발을 하다보면 핵심 기능을 제외한 부가 기능들을 위한 코드가 너무 많아져 핵심 기능에 집중이 잘 되지 않는 경우가 많다.
  • 또 좋은 설계란 변하는 것과 변하지 않는 것을 분리하는 것이다.
  • 보통 핵심 기능은 자주 변하고 부가 기능은 변하지 않는다. 이 둘을 분리해서 모듈화해야 한다.
  • 이런 문제를 해결할 수 있는 것이 템플릿 메서드 패턴이다.

템플릿 메서드

image

  • 템플릿 메서드 패턴은 이름 그대로 템플릿을 사용하는 방식이다.

  • 템플릿은 기준이 되는 거대한 틀을 뜻한다.

  • 템플릿이라는 틀에 변하지 않는 부분을 몰아두고, 일부 변하는 부분을 별도로 호출해서 해결한다.

  • 예제 코드

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    
    public abstract class AbstractTemplate {
          
        public void execute() {
            long startTime = System.currentTimeMillis(); 
            //비즈니스 로직 실행
            call(); //상속 
            //비즈니스 로직 종료
            long endTime = System.currentTimeMillis(); 
            long resultTime = endTime - startTime;
            log.info("resultTime={}", resultTime); 
        }
          
        protected abstract void call(); // 자식 클래스에서 상속해 구현
    }
    
  • 위 코드처럼 변하지 않는 하나의 탬플릿을 만들고 변하는 부분은 call() 메서드를 호출하는 방식으로 처리한다.

  • 변하지 않는 부분은 부모 클래스에 두고, 변하는 부분은 자식 클래스에서 상속과 오버라이딩을 사용해서 처리한다.

    image

  • 문제

    • 템플릿 메서드 패턴은 상속을 사용한다. 따라서 상속에 따라오는 문제를 모두 가지고 있다.
    • 자식 클래스가 부모 클래스와 강하게 결합되는 문제가 있다. 의존관계에 대한 문제가 있는 것이다.
    • 만약 부모 클래스의 기능을 자식 클래스에서 모두 사용하지 않다면?
    • 그럼에도 부모 클래스를 알아야된다. 부모 클래스를 수정하면 자식 클래스도 영향을 줄 수도 있다.
    • 이런 설계는 좋은 설계가 아니다.
    • 이외에도 상속은 단점들이 많다 그래서 이런 문제를 해결하는 디자인 패턴이 바로 전략 패턴(Strategy Pattern)이다.

전략 패턴

  • 전략 패턴은 변하지 않는 부분을 Context에 두고, 변하는 부분을 Strategy라는 인터페이스를 만들고 구현하도록 해서 문제를 해결

  • GOF 디자인 패턴이 정의한 전략 패턴의 의도

    • 알고리즘 제품군을 정의하고 각각을 캡슐화하여 상호 교환 가능하게 만들자. 전략을 사용하면 알고리즘을 사용하는 클라이언트와 독립적으로 알고리즘을 변경할 수 있다.

    image

  • 전략 패턴 실행 그림 - Strategy 필드 주입

    image

  • 전략 패턴을 구현하는 방식에는 여러가지가 있다. 대표적으로 strategy 인터페이스를 필드에 주입 받는 방식이 있다.

  • 하지만 필드 주입 방식은 Context와 Strategy를 한번 조립하고 나면 그 이후에 변경이 어렵다는 단점이 있다.

  • 이런 문제를 함수형 인터페이스를 메서드 파라미터로 보내면 해결이 가능하다.

  • Strategy 인터페이스 파라미터로 보내기

    image

템플릿 콜백 패턴

  • 정의

    • 프로그래밍에서 콜백(callback) 또는 콜애프터 함수(call-after function)는 다른 코드의 인수로서 넘겨주는 실행 가능한 코드를 말한다. 콜백을 넘겨받는 코드는 이 콜백을 필요에 따라 즉시 실행할 수도 있고, 아니면 나중에 실행할 수도 있다. (위키백과 참고)
  • 템플릿 콜백 패턴은 스프링 내부에서만 자주 사용되는 패턴이다. 전략 패턴에서 템플릿과 콜백 부분이 강조된 패턴

  • 스프링 내부 코드를 잘 살펴보면 JdbcTemplate, RestTemplate 등 다양한 템플릿 콜백을 적용한 패턴이 있다.

    image

  • 그림을 보면 위에서 살펴본 Strategy 패턴과 유사하다. 이름만 다르고 거의 똑같다고 보면 된다.

프록시

  • 애플리케이션은 항상 클라이언트와 서버가 존재한다.

  • 클라이언트는 서버에게 요청을 보내고 서버는 그 요청을 처리한다.

  • 이때 프록시는 클라이언트와 서버 사이에서 대신 어떤 일을 처리해주는 대리자(Proxy)이다.

    image

  • 프록시를 사용하면 얻을 수 있는 이점

    • 접근 제어, 캐싱
    • 부가 기능 추가
    • 프록시 체인
  • 대체 가능

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

프록시 패턴 모양

image

프록시 주요 기능

  • 프록시는 항상 target이라는 실제 구현체 혹은 다음 적용할 프록시를 내부에 가지고 있다.

  • 프록시를 통해 할 수 있는 일은 크게 2가지로 구분 가능하다.

  • 접근 제어, 부가 기능 추가

  • GOF 디자인 패턴에선 이 둘의 의도(intent)에 따라서 프록시 패턴과 데코레이터 패턴으로 구분한다.

    • 프록시 패턴 : 접근 제어가 목적

    • 데코레이터 패턴 : 새로운 기능 추가가 목적

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

  • 이 둘은 Proxy를 이용해 둘의 모양이 아주 유사하다. 이름만 다르고 거의 똑같다.
  • 이 둘의 차이를 어떻게 구분할까?
  • 중요한 것은 의도(intent)다.
  • 의도(intent)
    • 프록시 패턴의 의도 : 다른 개체에 대한 접근을 제어하기 위해 대리자를 제공
    • 데코레이터 패턴의 의도 : 객체에 추가 책임(기능)을 동적으로 추가하고, 기능 확장을 위한 유연한 대안 제공

동적 프록시

  • 프록시 코드를 직접 작성하는 일은 개발자 입장에서 매우 번거롭다.
  • 프록시를 적용해야될 클래스가 많아지면 많아질수록 그만큼의 일이 추가된다.
  • 이런 부분을 해결해주는 것이 동적 프록시다.
  • 동적 프록시는 프록시 객체를 동적으로 만들어주는 기술이다.
  • 우리는 프록시를 적용할 코드를 하나만 만들어두고 나머지는 동적 프록시를 통해 프록시를 만들면 된다.
  • 동적 프록시 기술에는 JDK 동적 프록시나 CGLIB가 있고 두 오픈소스 모두 리플렉션을 기반으로 동작한다.

리플렉션

  • 리플렉션이란 클래스나 메서드의 메타정보를 이용해 메소드 호출이나 메서드 정보 처리 등을 가능하게 만들어주는 기술이다.

  • 리플렉션을 사용하면 애플리케이션을 동적으로 유연하게 만들 수 있다.
  • 하지만 리플렉션은 런타임에 동작해, 컴파일 시점에 오류를 잡을 수 없다는 단점이 있다.
  • 그렇기 때문에 개발자가 직접 리플렉션을 사용하는 일은 드물다.

JDK 동적 프록시

  • 동적 프록시 오픈소스 중 하나인 JDK는 기본적으로 인터페이스를 기반으로 프록시를 만들어준다.

  • 인터페이스가 없으면 동작하지 않는다.

  • 실행 순서 그림

    image-20240727215941462

  • JDK 동적 프록시 기술을 적용하면 서로 다른 클래스에 대해 각각 프록시 객체를 만들지 않아도 된다.

    image

    image

  • 한계

    • JDP 동적 프록시는 인터페이스가 필수다.
    • 그렇기 때문에 구현 클래스만 있는 경우에는 적용이 안된다.
    • 이런 부분은 CGLIB라는 오픈소스 라이브러리를 사용하면 된다.

CGLIB

  • CGLIB : Code Generator Library
  • CGLIB는 바이트코드를 조작해서 동적으로 클래스를 생성하는 기술을 제공하는 라이브러리다.
  • CGLIB는 인터페이스 없이 구현 클래스만 가지고도 동적 클래스를 만들어준다.
  • JDK 동적 프록시과 가장 큰 차이점은 JDK 동적 프록시 = implement, CGLIB = extends 를 사용해 프록시를 만든다.
  • CGLIB 제약
    • 부모 클래스의 생성자를 체크해야 한다. CGLIB는 자식 클래스를 동적으로 생성하기 때문에 기본 생성자가 필요하다. (super()와 같은 부모 클래스의 생성자)
    • 클래스에 final 키워드가 붙으면 상속 불가능 -> 예외 발생
    • 메서드에 final 키워드가 붙으면 해당 메서드 오버라이딩 X -> CGLIB 프록시 로직 동작 X

프록시 팩토리

  • 우리는 지금까지 동적 프록시에 대해 알아보았다.

  • 하지만 각각 서로 다른 제약과 한계가 있었고 이런 부분을 개발자가 다 신경쓰기는 쉬운 일이 아니다.

  • 그렇기 때문에 스프링은 동적 프록시 기술을 추상화해서 제공하는데 그것이 바로 프록시 팩토리다.

    image

  • 위 그림처럼 프록시 팩토리가 요청에 따라 알맞는 프록시 기술을 선택해 동적 프록시를 생성해준다.

  • 그럼 두 기술은 부가 기능을 적용하기 위해 사용하는 것들이 다른데 어떻게 추상화했을까? (JDK 동적 프록시 = InvocationHandler, CGLIB = MethodInterceptor를 사용)

  • 바로 Advice라는 새로운 개념을 만들었다. 개발자는 그냥 Advice만 만들면 된다.

    image

  • 또한 필터링이나 특정 조건에 맞을 때 프록시 로직을 적용하는 Pointcut이라는 개념도 도입했다.

  • 이런 추상화 덕분에 CGLIB나 JDK 동적 프록시 기술에 의존하지 않고 동적 프록시를 적용 가능해졌다.

  • 참고! 스프링은 기본적으로 항상 CGLIB를 사용하게 되어있다 그 이유는 나중에 살펴보자.

포인트컷, 어드바이스, 어드바이저

  • 포인트컷(Pointcut) : 어디에 부가 기능을 적용할지, 말지를 판단하는 필터링 로직. 주로 클래스와 메서드 이름으로 필터링한다.

  • 어드바이스(Advice) : 프록시가 호출하는 부가 기능 로직

  • 어드바이저(Advisor) : 단순하게 하나의 포인트컷과 하나의 어드바이스를 가지고 있는 것

  • 이렇게 분리함으로써 역할과 책임을 명확하게 분리 가능하다.

    • 포인트컷은 대상 여부를 필터링만을
    • 어드바이스는 부가 기능 로직만을
    • 둘이 합쳐놓은 것을 어드바이저

    image

    • 포인트컷에 걸리는 경우 Advice를 호출하지 않고 실제 target을 호출한다.
  • Advisor 여러개 적용시

    • 프록시 팩토리는 하나의 프록시에 여러 어드바이저를 적용하게 만들어두었다.

    • 여기서 중요한 점은 스프링 AOP(= Advisor)에 대해 공부할 때 AOP 적용 수 만큼 프록시가 생성되는 것이 아니라는 것이다. 위에서 설명한 것처럼 하나의 프록시에 여러 어드바이저가 적용되는 것이다.

      image

스프링 빈 후처리기

  • 지금까지 인터페이스가 있을 때와 구체 클래스만 있을 때 동적 프록시를 적용하는 법에 대해 알아보았다.
  • 그럼 우리가 자주 사용하는 컴포넌트 스캔을 이용해 스프링 빈에 등록하는 경우에는 어떻게 동적 프록시를 적용할까?
  • 그 답은 스프링 빈 후처리기라는 기능에 있다.
  • 스프링 부트를 사용하면 자동으로 AnnotationAwareAspectJAutoProxyCreator 라는 빈 후처리기가 등록되어있다.

빈 후처리기(BeanPostProcessor)

  • 스프링이 빈 저장소에 등록할 목적으로 생성한 객체를 빈 저장소에 등록하기 직전에 조작하는 기능이다.

  • 빈을 조작하고 변경할 수 있는 후킹 포인트이다.

  • 빈 후처리기 과정

    image

  • 빈 후처리기를 이용해 객체 바꾸기

    image

  • 이런 식으로 빈에 등록되기 직전에 원하는 로직을 적용할 수 있다. 즉, 객체가 등록되기 전에 프록시 객체로 바꿀 수 있다는 것이다.

    image

  • 우리가 만약 빈 후처리기로 프록시 생성을 적용하고 싶은 객체를 필터링하고 싶다면 어떻게 해야될까?

  • 잘 생각해보면 Advisor에는 필터링 로직을 담당하는 포인트컷이 포함되어있다. 이걸 사용하면 좋을 것 같다.

  • 결과적으로 포인트컷은 두 곳에서 사용된다.

    1. 프록시 적용 대상 여부를 체크하기 (빈 후처리기 - 자동 프록시 생성 시)

    2. 프록시의 어떤 메서드가 호출 되었을 때 어드바이스를 적용할지 (프록시 내부)

  • 자동 프록시 생성기의 작동 과정 image

    • 그림과 같이 스프링 컨테이너에의 모든 Advisor를 조회한 후 포인트컷을 사용해 객체를 필터링한다.
    • 프록시를 모든 곳에서 생성하는 것은 비용 낭비이기 때문에 필요한 곳에만 적용하기 위해 스프링이 도입한 방법이다.

@Aspect

  • 스프링에서 프록시를 적용하고 싶으면 포인트컷과 어드바이스로 구성되어 있는 어드바이저를 만들어서 스프링 빈으로 등록하면 된다.

  • 그러면 스프링 컨테이너에서 자동 프록시 생성기를 통해 알아서 모든 Advisor를 찾아서 알맞는 프록시를 적용시켜준다.

  • 스프링은 @Aspect 어노테이션으로 매우 편리하게 어드바이저를 생성할 수 있게 지원한다. (정확히 말하자면 AspectJ 프로젝트에서 제공하는 어노테이션)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    
    @Slf4j 
    @Aspect
    public class LogTraceAspect {
        private final LogTrace logTrace;
          
        public LogTraceAspect(LogTrace logTrace) { 
       		this.logTrace = logTrace;
        }
                                   
        @Around("execution(* hello.proxy.app..*(..))") // 포인트컷
    	public Object execute(ProceedingJoinPoint joinPoint) throws Throwable {
        	// 어드바이스 로직 작성
              
            joinPoint.proceed(); // 타겟 호출
        }
    }
    

@Aspect 프록시

  • 자동 프록시 생성기는 Advisor를 자동으로 찾아와서 프록시를 생성하고 적용해준다고 설명했다.

  • 여기에 자동 프록시 생성기는 @Aspect를 찾아서 그 객체를 Advisor로 만들어준다.

    image

  • @Aspect를 Advisor로 변환해서 저장하는 과정

    image

  • @Aspect 어드바이저 빌더

    • BeanFactoryAspectJAdvisorsBuilde 클래스. @Aspect의 정보를 기반으로 포인트컷, 어드바이스, 어드바이저를 생성하고 보관한다.
    • @Aspect의 정보를 기반으로 어드바이저를 만들고, 빌더 내부에 캐시해 저장한다. 캐시에 어드바이저가 이미 만들어져 있는 경우 캐시에서 꺼내서 반환
  • @Aspect 어드바이저 적용된 자동 프록시 생성기

    image

  • 실무에서는 프록시를 적용할 때 대부분 @Aspect 방식을 사용한다.

횡단 관심사

  • 지금까지 알아본 프록시로 애플리케이션에 전반적으로 사용되는 기능들을 어떻게 추상화하고 핵심 기능에서 분리할 수 있는지 알아보았다.

  • 이런 애플리케이션 여러 기능들 사이에 전반적으로 걸쳐서 들어가 있는 관심사를 횡단 관심사(cross-cutting concerns)라고 한다.

    image

  • 스프링에서는 스프링 AOP를 사용해 이런 횡단 관심사를 해결한다.

스프링 AOP

  • 스프링 AOP는 부가 기능을 효과적으로 처리할 수 있는 기능이다.
  • 우리가 AOP 없이 부가 기능을 적용하고 관리하기는 매우 번거롭다. 다음과 같은 문제가 발생한다.
    • 부가 기능을 적용할 때 많은 반복이 필요
    • 부가 기능이 여러 곳에 퍼져서 중복 코드 발생
    • 부가 기능을 변경할 때 중복 때문에 많은 수정 필요
    • 부가 기능의 적용 대상을 변경할 때 많은 수정 필요
  • 이런 애플리케이션 전반에 부가 기능을 적용하는 문제는 일반적인 OOP 방식으로는 해결이 어렵다.
  • 이런 문제를 해결하기 위해 고안한 방법이 AOP이다.

애스팩트

  • 위의 문제를 해결하기 위해 부가 기능을 핵심 기능에서 분리하고 한 곳에서 관리하고 어디에 적용할지 선택하는 기능을 만들었다.

  • 이렇게 부가 기능과 부가 기능 적용 필터링 기능을 합해서 하나의 모듈로 만들었는데 그게 바로 애스팩트(aspect)이다. (@Aspect가 바로 이 애스팩트)

  • 애스팩트는 관점이라는 뜻이 있는데 애플리케이션을 바라보는 관점을 하나하나의 기능 -> 횡단 관심사 관점으로 달리 보는 것이다.

  • 이렇게 애스팩트를 사용한 프로그래밍 방식을 관점 지향 프로그래밍 - AOP(aspect-Oriented Programming)이라고 부른다.

  • AOP는 OOP를 대체하기 위한 것이 아니라 OOP의 부족한 부분을 보조하는 목적으로 개발되었다.

    image

  • AspectJ 프레임워크

    • AOP의 대표적인 구현으로 AspectJ 프레임워크가 있다. 스프링도 AOP를 지원하지만 대부분 AspectJ의 문법을 차용하고, 기능 일부만 제공한다.

AOP 적용 방식

  • AOP를 사용하면 핵심 기능과 부가 기능이 코드상 완전히 분리되어서 관리된다.

  • AOP는 어떻게 부가 기능 로직을 실제 로직에 추가할까?

  • 이렇게 애스팩트(부가 기능)이 실제 코드에 추가 되는 것을 위빙(Weaving)이라 한다.

  • 3가지 방법이 있다.

    • 컴파일 시점
    • 클래스 로딩 시점
    • 런타임 시점(프록시 사용)
  • 컴파일 시점 - 위빙

    image-20240728164901080

    • 소스코드를 컴파일 시점에 부가 기능 로직을 추가할 수 있다.

    • 이떄는 AspectJ가 제공하는 특별한 컴파일러를 사용해야 한다.

    • 이 컴파일러는 Aspect를 확인해서 해당 클래스가 적용 대상인지 먼저 확인하고, 적용 대상인 경우 부가 기능 로직을 실제 컴파일된 코드에 붙여버린다.

    • 단점 - AspectJ가 제공하는 컴파일러를 써야되고 복잡하다.

  • 클래스 로딩 시점 - 위빙

    image-20240728165303642

    • 자바를 실행하면 자바 언어는 .class 파일을 JVM 내부의 클래스 로더에 보관한다.
    • 이때 중간에서 .class 파일을 조작한 다음 JVM에 올릴 수 있다. 이런 기능을 java Instrumentation이라고 한다.
    • 이 시점에 애스팩트를 적용하는 것을 로드 타임 위빙이라고 한다.
    • 단점 - 로드 타임 위빙은 자바를 실행할 때 java -javaagent 라는 옵션을 통해 클래스 로더 조작기를 지정해야되는데 이 부분이 번거롭고 운영하기 어렵다.
  • 런타임 시점 - 위빙

    image

    • 우리가 위에서 학습한 그 프록시 방식의 AOP이다.
    • 프록시를 사용하기 때문에 AOP 기능에 일부 제약이 있다. 하지만 컴파일러 위빙이나 로드 타임 위빙을 별도로 사용하지 않고 스프링만 있으면 AOP를 적용할 수 있다.
  • 각 차이점

    • 컴파일 시점 : 실제 대상 코드에 애스팩트 적용. AspectJ를 직접 사용
    • 클래스 로딩 시점 : 실제 대상 코드에 애스팩트 적용. AspectJ를 직접 사용
    • 런타임 시점 : 실제 대상 코드는 유지. 대신 프록시를 통해 부가 기능 적용. 스프링 AOP는 이 방식 사용
  • AOP 적용 위치

    • 적용 가능 지점(조인 포인트) : 생성자, 필드 값, static 메서드, 메서드 실행
    • AspectJ를 직접 사용하는 방식을 사용하면 바이트코드를 직접 조작하기 때문에 위 조인 포인트에 모두 적용할 수 있다.
    • 하지만 프록시 방식은 메서드 실행 지점에만 AOP를 적용할 수 있다. (메서드 오버라이딩 개념으로 동작하기 때문)
  • 참고! : 스프링은 AspectJ의 문법을 차용하는거지 AspectJ를 직접 사용하는 것은 아니다. 또 AspectJ를 사용하면 더 다양한 기능을 사용 가능하지만 사용법이 복잡하고 별도의 옵션을 적용해야한다. 실무에서는 스프링 AOP(프록시 방식)로 대부분의 문제가 해결 가능하다.

AOP 용어 정리

image

  • 조인 포인트(Join Point)
    • 어드바이스가 적용될 수 있는 위치. 메소드 실행, 생성자 호출, 필드 값 등등
    • 조인 포인트는 추상적인 개념. AOP를 적용할 수 있는 모든 지점이라고 생각하자.
  • 포인트컷(Pointcut)
    • 조인 포인트 중에서 어드바이스가 적용될 위치를 필터링하는 기능
    • 주로 AspectJ 표현식을 사용해서 지정
  • 타켓(Target)
    • 어드바이스를 받는 객체. 포인트컷으로 결정
  • 어드바이스(Advice)
    • 부가 기능 로직
    • 특정 조인 포인트에서 Aspect에 의해 취해지는 조치
    • Around, Before, After와 같은 어드바이스가 있음
  • 애스팩트(Aspect)
    • 어드바이스 + 포인트컷을 모듈화 한 것
    • @Aspect
    • 여러 어드바이스와 포인트 컷이 함께 존재
  • 어드바이저(Advisor)
    • 하나의 어드바이스와 하나의 포인트 컷으로 구성
    • 스프링 AOP에서만 사용되는 용어
  • 위빙(Weaving)
    • 포인트컷으로 결정한 타겟의 조인 포인트에 어드바이스를 적용하는 것
    • AOP 적용을 위해 애스팩트를 객체 연결한 상태
  • AOP 프록시
    • AOP 기능을 구현하기 위해 만든 프록시 객체 (JDK 동적 프록시, CGLIB)

스프링 AOP 구현

  • @Aspect로 Aspect 기본 구조

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    
    @Slf4j 
    @Aspect
    public class AspectV1 {
    	//hello.aop.order 패키지와 하위 패키지
        @Around("execution(* hello.aop.order..*(..))")
        public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable { 
        	log.info("[log] {}", joinPoint.getSignature()); //join point 시그니처
        	return joinPoint.proceed();
        } 
    }
    
    • @Around로 작성한 것이 포인트컷
    • 실제 메서드 안에 있는 로직이 어드바이스가 된다.
  • @Aspect만 있으면 컴포넌트 스캔의 대상이 안된다. 그래서 직접 빈으로 등록하거나 @Componet를 사용해 등록해야한다.

  • 포인트컷 분리

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    
    @Aspect
    public class AspectV2 {
    	//hello.aop.order 패키지와 하위 패키지
        @Pointcut("execution(* hello.aop.order..*(..))") //pointcut expression 
    	private void allOrder(){} //pointcut signature    
          
        @Around("allOrder()")
    	public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable { 
            log.info("[log] {}", joinPoint.getSignature());
    		return joinPoint.proceed();
        } 
    }
    
    • 위의 코드같이 포인트컷을 메서드로 분리해서 사용할 수 있다.

    • 메서드의 반환 타입은 void로 하고 내용은 비워야한다.

    • 여러개의 포인트 컷을 만들고 조합해서도 사용할 수 있다.

      1
      
      @Around("allOrder() && allService()")
      
    • &&(AND), (OR), !(NOT) 3가지로 조합이 가능하다.
  • 포인트컷 다른 클래스로 분리

    • 포인트컷을 모아둔 클래스를 만들어놓고 관리하거나 사용할 수 있다.

      1
      
      @Around("hello.aop.order.aop.Pointcuts.allOrder()")
      
    • 사용시에는 패키지명까지 적어줘야한다.

  • 어드바이스 순서 지정

    • 어드바이스는 기본적으로 순서를 보장 X

    • 순서를 지정하고 싶으면 @Aspect 적용 단위로 @Order 어노테이션을 적용해야한다.

    • 근데 이 방식의 문제는 클래스 단위로 적용할 수 있다는 점이다.

    • 만약 하나의 애스팩트에 여러 어드바이스가 있다면 순서가 보장이 안된다. 따라서 애스팩트를 별도의 클래스로 분리해야한다.

      1
      2
      3
      4
      5
      6
      7
      8
      9
      
      @Aspect
      @Order(2)
      public class LogAspect {
      }
          
      @Aspect
      @Order(1)
      public class TestAspect {
      }
      
  • 어드바이스 종류

    • 위의 예제에서는 보통 @Around를 사용했지만 그 외에도 여러가지 종류가 있다.
    • @Around : 메서드 호출 전후에 수행, 가장 강력한 어드바이스, 조인 포인트 실행 여부 선택, 반환 값 변환, 예외 변환 등이 가능
    • @Before : 조인 포인트 실행 이전에 실행
    • @AfterReturning : 조인 포인트가 정상 완료 후 실행
    • @AfterThrowing : 메서드가 예외를 던지는 경우 실행
    • @After : 조인 포인트가 정상 또는 예외에 관계없이 실행(finally
    • 특징 : @Around를 제외하고 나머지는 JoinPoint를 사용할 수 있다. @Around는 ProceedingJoinPoint를 사용한다.
    • JoinPoint 인터페이스의 주요 기능
      • getArgs() : 메서드 인수를 반환
      • getThis() : 프록시 객체를 반환
      • getTarget() : 대상 객체를 반환
      • getSignature() : 조언되는 메서드에 대한 설명을 반환
      • toString() : 조언되는 방법에 대한 유용한 설명을 인쇄
    • ProceedingJoinPoint 인터페이스의 기능
      • proceed() : 다음 어드바이스나 타겟을 호출
    • JoinPoint와 ProceedingJoinPoint의 차이
      • JoinPoint는 proceed()을 호출할 수 없다. 그 반대로 ProceedingJoinPoint는 proceed()를 호출할 수 있다.
      • @Around 사용 시 proceed()을 호출하지 않으면 다음 대상이 호출되지 않는다. 그 반면에 @Before와 같은 어드바이스는 proceed() 자체를 사용하지 않는다. 자동으로 다음 타겟이 호출된다. 즉, @Around를 제외한 다른 어드바이스들은 흐름을 직접 변경할 수 없다.

    image

  • 그럼 @Around만 사용하면 되는데 왜 다른 어드바이스가 필요할까?

    • 그 이유는 한 문장으로 설명이 가능하다. 좋은 설계는 제약이 있는 것이다.
    • @Aroud만 사용한다면 개발자가 한눈에 이 어드바이스가 어떤 역할을 하는지 명확하게 알기 힘들다.
    • 또 @Around 사용시 proceed()를 호출하지 않으면 다음 타겟이 호출되지 않는 치명적인 버그가 발생한다. 즉, 실수할 가능성이 존재한다는 것이다.
    • 그렇기 때문에 어드바이스의 역할을 명확히하고 의도가 뭔지 파악하기 쉽기 때문에 다른 어드바이스를 사용하는 것이다.

스프링 AOP - 포인트컷

  • 포인트컷 지시자

    • execution : 메소드 실행 조인 포인트를 매칭, 가장 많이 사용
    • within : 특정 타입 내의 조인 포인트 매칭
    • args : 인자가 주어진 타입의 인스턴스인 조인 포인트
    • this : 스프링 빈 객체(스프링 AOP 프록시)를 대상으로 하는 조인 포인트
    • target : Target 객체(스프링 AOP 프록시가 가르키는 실제 대상)를 대상으로 하는 조인 포인트
    • @target : 실행 객체의 클래스에 주어진 타입의 어노테이션이 있는 조인 포인트
    • @within : 주어진 어노테이션이 있는 타입 내 조인 포인트
    • @annotation : 메서드가 주어진 어노테이션을 가지고 있는 조인포인트
    • @args : 전달된 실제 인수의 런타임 타입이 주어진 타입의 어노테이션을 갖는 조인 포인트
    • bean : 스프링 전용 포인트컷 지시자, 빈의 이름으로 포인트컷 지정
  • execution 문법

    1
    
    execution(modifiers-pattern? ret-type-pattern declaring-type-pattern?name-pattern(param-pattern) throws-pattern?)
    
    • ? 붙은 패턴들은 생략 가능
    • *같은 패턴 사용 가능
    • . : 정확하게 해당 위치의 패키지
    • .. : 해당 위치의 패키지와 그 하위 패키지도 포함
    • ex)

      • 접근제어자?: public
      • 반환타입: String
      • 선언타입?: hello.aop.member.MemberServiceImpl
      • 메서드이름: hello
      • 파라미터: (String)
      • 예외?: 생략
    • 파라미터 매칭 규칙
      • (String) : 정확하게 String 타입 파라미터
      • () : 파라미터가 없어야 한다.
      • (*) : 정확히 하나의 파라미터, 단 모든 타입을 허용한다. *
      • (*, *) : 정확히 두 개의 파라미터, 단 모든 타입을 허용한다.*
      • (..) : 숫자와 무관하게 모든 파라미터, 모든 타입을 허용한다. 참고로 파라미터가 없어도 된다. 0..* 로 이해하면 된다.
      • (String, ..) : String 타입으로 시작해야 한다. 숫자와 무관하게 모든 파라미터, 모든 타입을 허용한다. 예) (String) , (String, Xxx) , (String, Xxx, Xxx) 허용
  • 주의! 다음 포인트컷 지시자는 단독으로 사용하면 안됨 args, @args, @target

    args, @args, @target의 경우 실체 객체 인스턴스가 생성되고 실행될 때 어드바이스 적용 여부를 확인할 수 있다.

    실행 시점에 일어나는 포인트컷 적용 여부도 결국 프록시가 있어야 실행 시점에 판단이 가능하다. 프록시가 없다면 판단 자체가 불가능하다.

    그런데 스프링 컨테이너가 프록시를 생성하는 시점은 스프링 컨테이너가 만들어지는 애플리케이션 로딩 시점에 적용할 수 있다.

    따라서 args, @args, @target 같은 포인트컷 지시자가 있으면 스프링은 모든 스프링 빈에 AOP를 적용하려고 시도한다.

    문제는 이렇게 모든 스프링 빈에 AOP를 적용하려고하면 스프링이 내부에서 사용 중인 빈 중에 final로 지정된 빈들도 있기 때문에 오류가 발생

    따라서 이런 표현식은 최대한 프록시 적용 대상을 축소하는 표현식과 함께 사용해야한다.

  • 매개변수 전달

    • 다음 포인트컷 지시자를 사용해서 어드바이스에 매개변수를 전달 가능하다.

      this, target, args,@target, @within, @annotation, @args

      1
      2
      3
      4
      
      @Before("allMember() && args(arg,..)") 
      public void logArgs3(String arg) { 
          log.info("[logArgs3] arg={}", arg); 
      }
      
      • 포인트컷의 이름과 매개변수의 이름을 맞추어야한다.

스프링 AOP - 주의 사항

  • 스프링 AOP는 @Transactional과 마찬가지로 내부호출에 대한 문제가 발생할 수 있다. 주의하자.

타입 캐스팅

  • JDK 동적 프록시와 CGLIB를 사용해서 AOP 프록시를 만드는 방법에는 각각 장단점이 있다.

  • JDK 동적 프록시의 한계

    • 인터페이스 기반으로 만드는 JDK 동적 프록시는 구체 클래스로 타입 캐스팅이 불가능한 한계가 있다.

      image

    • 캐스팅시

      image

    • 그림과 같이 구체 클래스로 캐스팅하면 예외가 발생한다. (ClassCastException.class 발생)

    • 그 이유는 JDK 동적 프록시는 인터페이스를 기반으로 프록시를 생성하기 때문이다.

    • 인터페이스를 기반으로 만드니 JDK Proxy는 MemberService 인터페이스에 대해서는 알고 있지만 그 구체 클래스인 MemberServiceImpl에 대해서는 전혀 모른다. 그렇기 때문에 캐스팅이 불가능하다.

  • CGLIB

    image

    • 캐스팅시

    image-20240728212949433

    • CGLIB의 경우 캐스팅이 성공한다.
    • 그 이유는 CGLIB는 구체클래스를 상속해서 Proxy를 만들기 때문에 캐스팅이 다 가능하다.

프록시 기술과 한계 - 의존관계 주입

  • 위에서 살펴본 캐스팅에 진짜 문제는 의존관계 주입을 할 때 발생한다.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    
    public class ProxyDITest {
        @Autowired MemberService memberService; //JDK 동적 프록시 OK, CGLIB OK
        @Autowired MemberServiceImpl memberServiceImpl; //JDK 동적 프록시 X, CGLIB OK 
        @Test
    	void go() {
            log.info("memberService class={}", memberService.getClass()); 
            log.info("memberServiceImpl class={}", memberServiceImpl.getClass()); 
            memberServiceImpl.hello("hello");
        } 
    }
    
  • 이때 JDK 동적 프록시를 사용하면 타입 관련 예외가 발생한다.

    image

    • @Autowired MemberServiceImpl memberServiceImpl 문제는 이 부분에서 발생한다.
    • JDK Proxy는 인터페이스 기반으로 만들어져서 MemberServiceImpl 타입이 뭔지 전혀 모른다. 그래서 주입할 수 없다.
  • CGLIB 사용

    image

    • CGLIB의 경우 구체 클래스를 기반으로 만들어져서 주입이 가능하다.

프록시 기술의 한계 - CGLIB

  • CGLIB도 여러 문제점이 있다. 다음과 같다.

  • 대상 클래스에 기본 생성자 필수

    • 구체 클래스를 상속 받기 때문에 자식 클래스의 생성자에서 부모 클래스의 생성자를 호출해야한다. (super()와 같은)
    • CGLIB는 프록시 팩토리가 만들어주기 때문에 기본 생성자를 필요로하고 기본 생성자를 필수로 만들어야한다.
  • 생성자 2번 호출 문제

      1. 실제 target의 객체를 생성할 때, 2. 프록시 객체를 생성할 때 부모 클래스의 생성자 호출

      image

  • final 키워드 클래스, 메서드 사용 불가

스프링 해결책

  • 일단 처음으로 CGLIB를 스프링 내부에 포함시켜 별도의 라이브러리를 설치해야하는 번거로움 제거
  • 기본 생성자 필수 문제 해결
    • 스프링 4.0부터 objenesis라는 특별한 라이브러리를 사용해 기본 생성자 없이 객체 생성이 가능해졌다. 참고로 이 라이브러리는 생성자 호출 없이 객체를 생성할 수 있게 해준다.
  • 생성자 2번 호출 문제
    • 이것도 위의 objenesis 라이브러리로 해결
  • 이런 문제들을 해결함으로써 스프링은 CGLIB를 기본 사용하도록 정책을 정했다.
This post is licensed under CC BY 4.0 by the author.