스프링 핵심원리 - 고급편

최혜정

Updated:

Intro

안녕하세요. 최혜정 사원입니다. 인프런 스프링 핵심원리 고급편을 수강하고 스프링 AOP에 대해서 적어보려고 합니다.

https://www.inflearn.com/course/스프링-핵심-원리-고급편/dashboard

1. 프록시

프록시는 기존 코드를 수정하지 않고 코드 중복을 피할 수 있는 방법으로써 다음과 같은 특징을 지님

  • 핵심 기능의 실행은 다른 객체에 위임하고 부가적인 기능을 제공하는 객체 = 프록시(proxy)
  • 실제 핵심 기능을 실행하는 객체 = 대상 객체
  • 프록시는 핵심 기능을 구현하지 않음
  • 대신 여러 객체에 공통으로 적용할 수 있는 기능을 구현

2. AOP 개념

AOP란 Apsects Oriented Programming 으로, 관점 지향 프로그래밍의 약자이다.
여러 객체에 공통으로 적용할 수 있는 기능을 분리해 재사용성을 높여주는 기법

  • 공통 기능 구현(부가 기능)과 핵심 기능 구현의 분리

실제 요청을 받아 들이고 처리하는 로직을 핵심기능이라고 하고 핵심 기능을 보조하여 가령 로그를 남겨주는 로직을 부가 기능이라고 한다.

캡처1
로그 추적 로직이 부가기능이다. 그런데 이 부가기능을 여러 곳에서 공통으로 사용하면 각 핵심기능마다 부가기능을 추가해줘야한다.
이 경우 코드의 중복이 발생하고, 핵심과 부가기능이 공존하기 때문에 유지보수가 어렵다.

캡처2
이러한 부가 기능은 횡단 관심사(cross-cutting concerns)가 된다. 즉, 하나의 부가 기능이 여러 곳에 동일하게 사용된다는 뜻이다.

그런데 로그찍는 방법이 달라졌는데 컴포넌트가 100개가 넘는다면 100개가 넘는 로직를 뜯어고쳐야한다.
해결방법 -> 이 공통 부가기능인 횡단 관심사를 핵심기능과 완전히 분리하고자 하는 취지에서 나온것이 AOP 개념이다.

AOP

부가기능을 각각의 코드에 넣기보다 따로 빼서 관리하는 것이 유지보수면에서 효율적이다.
그래서 이 “부가기능”과 “부가기능을 어디에 적용할지 선택하는 기능”을 합쳐서 하나의 모듈로 만들었는데, 이것이 Aspect이다.
쉽게 말해서 부가 기능과, 해당 부가 기능을 어디에 적용할지 정의한 것이다.

이렇게 Aspect를 사용한 프로그래밍 방식을 관점 지향 프로그래밍 AOP(Aspect-Oriented Programming)라고 한다.

2-1. AOP 적용 방식

런타임 시점

런타임

  • 스프링이 채택한 방식이다.
  • 런타임 시점이란 자바의 Main 메서드가 실행된 뒤를 말하며, 코드는 이미 런타임 데이터 영역에 적재되어 수정이 불가능하다.
  • 빈 후처리기로 프록시객체로 바꿔치기 방식을 사용하므로, 스프링 빈에만 AOP를 적용할 수 있다.
  • 프록시 객체는 실제 객체와 같은 메서드를 오버라이딩 하면서 추가기능을 수행하도록 했기 때문에, 메서드 실행시점에만 부가기능을 추가할 수 있다.
  • 객체를 스프링 컨테이너를 통해 전달하면 빈 포스트 프로세서에서 AspectJ모듈을 보고 적용대상이면 프록시를 만들고 프록시를 스프링빈에 등록한다.

2-2. 스프링 AOP 용어

  • Joinpoint: AOP를 적용할 수 있는 지점
  • Pointcut: 조인 포인트 중 어드바이스 적용될 위치를 정하는 기능, 즉 어느 메서드에 부가기능을 적용할지 정하는 기능
  • Advice: 부가 기능 로직
  • Advisor: 하나의 Advice + 하나의 Pointcut
  • Weaving: 타겟의 조인포인트에 어드바이스를 적용하는 것을 말한다. 즉 실제객체 코드에 부가기능을 추가하는 것
  • Aspect: 여러 객체에 공통으로 적용되는 기능, 여러 어드바이스 + 포인트컷을 모듈화 한것 (@Aspect)
  • Target : 어드바이스를 적용할 실제객체

3. @Aspect

Aspect

  • @Aspect는 애노테이션 방식으로 Advisor를 편리하게 생성할 때 사용한다.
  • 스프링 앱에 프록시를 적용하려면 포인트컷과 어드바이스로 구성된 어드바이저를 만들어 스프링 빈으로 등록하면 된다.
  • @Around 로 Pointcut 을 명시하고, execute 메서드 내부에 Advice 를 구현하면 끝이다. 그 후 스프링 빈으로 등록하면 된다.

3-1. @Aspect -> Advisor 변환 과정

@Aspect를 Advisor로 변환하는 것은 자동 프록시 생성기가 담당한다.

자동 프록시 생성기가 하는일

  1. @Aspect 를 Advisor로 변환하는 일
  2. Advisor를 바탕으로 프록시객체를 생성

@Aspect를 어드바이저로 변환해서 저장하는 과정

어드바이저 생성

  1. 스프링 어플리케이션 로딩 시점에 자동 프록시 생성기가 호출된다.
  2. 스프링 컨테이너에서 @Aspect 애노테이션이 붙은 스프링 빈을 모두 조회한다.
  3. @Aspect 어드바이저 빌더를 통해 @Aspect 애노테이션 정보를 기반으로 Advisor 를 생성한다.
  4. 생성한 어드바이저를 @Aspect 어드바이저 빌더 내부에 저장한다.

프록시객체 생성과정

자동프록시생성기

  1. 생성: 스프링 빈 대상이 되는 객체를 생성한다. (@Bean, 컴포넌트 스캔 모두 포함)
  2. 전달: 생성된 객체를 빈 저장소에 등록하기 직전에 빈 후처리기에 전달한다.
    3-1. Advisor 빈 조회: 스프링 컨테이너에서 Advisor 빈을 모두 조회한다.
    3-2. @Aspect Advisor 조회: @Aspect 어드바이저 빌더 내부에 저장된 Advisor를 모두 조회한다.
  3. 프록시 적용 대상 체크 : 조회한 Advisor에 포함되어 있는 포인트컷을 사용해서 해당 객체가 프록시를 적용할 대상인지 아닌지 판단한다.
  4. 프록시 생성: 프록시 적용 대상이면 프록시를 생성하고 프록시를 반환한다. 그래서 프록시를 스프링빈으로 등록한다.
  5. 빈 등록: 반환된 객체는 스프링 빈으로 등록된다.

4. 스프링 AOP 구현

4-1. 기본 AOP 구현

핵심기능

핵심기능

package hello.aop.order;

@Slf4j
@Service
public class OrderService {

    private final OrderRepository orderRepository;

    public OrderService(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    public void orderItem(String itemId) {
        log.info("[orderService] 실행");
        orderRepository.save(itemId);
    }
}
package hello.aop.order;

@Slf4j
@Repository
public class OrderRepository {

    public String save(String itemId) {
        log.info("[orderRepository] 실행");
        //저장 로직
        if (itemId.equals("ex")) {
            throw new IllegalStateException("예외 발생!");
        }
        return "ok";
    }
}

부가기능

@Slf4j
@Aspect
public class AspectV1 {
	
	//hello.aop.order 패키지와 하위 패키지
	@Around("execution(* hello.aop.order..*(..))") // 포인트컷
	public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable { // advice
		log.info("[log] {}", joinPoint.getSignature()); // join point 시그니처 -> 메서드 정보 찍힘
		return joinPoint.proceed(); // target 호출
	}
}
  • @Around 로 Pointcut 을 설정하고, doLog 라는 메서드로 Advice 를 설정하였다.
  • Pointcut 은 hello.aop.order 패키지의 모든 클래스들에 적용되도록 하였고, Advice 는 타겟 메서드 호출전에 시그니처를 출력하도록 하였다.
  • @Around 는 반드시 ProceedingJoinpoint 를 파라미터로 가져야한다. joinPoint 가 갖고있는 proceed() 메서드를 호출해야 타겟의 메서드가 실행되기 때문이다.
  • AspectV1을 스프링빈으로 등록하면 OrderService와 OrderRepository의 모든 메서드는 AOP 적용의 대상이 된다.

포인트컷

@Around(“execution(* hello.aop.order..*(..))”) 에서

  • execution : 포인트컷 지정자 (부가 기능이 적용될 대상 메서드 선정)
  • *: 리턴 타입 → 현재는 모든 타입 리턴
  • hello.aop.order..* : 타겟이 되는 메서드 지정
    • .은 정확하게 해당 위치의 패키지
    • ..은 해당 위치의 패키지와 그 하위 패키지도 포함
  • (..) : 파라미터 -> (..)은 파라미터의 타입과 파라미터 수가 상관없다

실행결과

실행결과

실행결과사진

doLog advice가 적용됐다.

4-2. 포인트컷 분리

포인트컷 시그니처

@Slf4j
@Aspect
public class AspectV2 {
	
	//hello.aop.order 패키지와 하위 패키지
	@Pointcut("execution(* hello.aop.order..*(..))") // 포인트컷
	private void allOrder(){} //pointcut signature
	
	@Around("allOrder()")
	public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable { // advice
		log.info("[log] {}", joinPoint.getSignature()); // join point 시그니처 -> 메서드 정보 찍힘
		return joinPoint.proceed(); // target 호출
	}
}
  • @Pointcut 을 통해 포인트컷을 별도의 메서드로 분리할 수 있다.
  • 메서드의 반환 타입은 void 여야하고, 코드내용은 비워둔다.
  • Advice 에서는 직접 포인트컷 표현식을 사용해도 되지만 그림과 같이 포인트컷 시그니처를 사용해도 된다. allOrder() 처럼 사용하였다.
  • 포인트컷 시그니처를 사용하면 포인트컷에 이름을 부여할 수 있는 장점이 있다. 자주 사용하는 포인트컷은 시그니처로 만들어 사용하자.

포인트컷 참조

public class Pointcuts {
	
	//hello.aop.order 패키지와 하위 패키지
		@Pointcut("execution(* hello.aop.order..*(..))") // 포인트컷
		public void allOrder(){} //pointcut signature
		
		//클래스 이름 패턴이 *Service
		@Pointcut("execution(* *..*Service.*(..))")
		public void allService(){}
		
		//allOrder && allService
		@Pointcut("allOrder() && allService()")
		public void orderAndService(){}
}
  • 포인트컷 시그니처를 한 클래스에 몰아넣고 외부 클래스의 어드바이스에서 이를 참조할 수도 있다.
  • 외부 클래스에서 사용하게 할 경우 접근제어자를 public 으로 설정해야한다.
@Slf4j
@Aspect
public class AspectV4Pointcut {
	
	@Around("hello.aop.order.aop.Pointcuts.allOrder()")
	public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable { // advice
		log.info("[log] {}", joinPoint.getSignature()); // join point 시그니처 -> 메서드 정보 찍힘
		return joinPoint.proceed(); // target 호출
	}
}

외부 포인트컷을 사용하는 어드바이스는 “패키지명.클래스명.포인트컷 시그니처 명” 으로 포인트컷을 사용할 수 있다.

4-3. 어드바이스 추가

여러 Advice 적용

하나의 타겟에 여러 Pointcut 이 매칭되어, 여러개의 Advice 가 적용될 수 있다.

@Slf4j
@Aspect
public class AspectV3 {
	
	//hello.aop.order 패키지와 하위 패키지
	@Pointcut("execution(* hello.aop.order..*(..))") // 포인트컷
	private void allOrder(){} //pointcut signature
	
	//클래스 이름 패턴이 *Service
	@Pointcut("execution(* *..*Service.*(..))")
	private void allService(){}
	
	@Around("allOrder()")
	public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable { // advice
		log.info("[log] {}", joinPoint.getSignature()); // join point 시그니처 -> 메서드 정보 찍힘
		return joinPoint.proceed(); // target 호출
	}
	
	//hello.aop.order 패키지와 하위 패키지 이면서 클래스 이름 패턴이 *Service ==> OrderService 클래스
	@Around("allOrder() && allService()")
	public Object doTransaction(ProceedingJoinPoint joinPoint) throws Throwable {
		try {
			log.info("[트랜잭션 시작] {}", joinPoint.getSignature());
			Object result = joinPoint.proceed();
			log.info("[트랜잭션 커밋] {}", joinPoint.getSignature());
			return result;
		} catch (Exception e) {
			log.info("[트랜잭션 롤백] {}", joinPoint.getSignature());
			throw e;
		} finally { // 무조건 호출
			log.info("[트랜잭션 릴리즈] {}", joinPoint.getSignature());
		}
	}
}
  • allOrder() 포인트컷은 ‘hello.aop.order’ 패키지와 하위 패키지를 대상으로 한다.
  • allService() 포인트컷은 타입 이름 패턴이 XxxService처럼 Service로 끝나는 것을 대상으로 한다.
  • 상단의 Pointcut 는 allOrder 에 매칭되고, 하단의 Pointcut 는 allOrder 중 allService 에 매칭된다.
  • 따라서 Service 클래스는 doLog 와 doTransaciton 두 Advice 가 모두 적용된다는 것이다.

실행결과
실행결과1

클라이언트 -> doLog() -> doTransaction() -> orderService.orderItem() -> doLog() -> orderRepository.save()

4-4. 어드바이스 순서

  • 어드바이스는 기본적으로 순서를 보장하지 않는다. 순서를 지정하고 싶으면 @Aspect 적용 단위로 @Order 매노테이션을 적용해야한다.
  • 기본적으로 하나의 @Aspect 내에서 Advice 메서드들은 순서가 보장되지 않고, 클래스 단위로만 순서를 정할 수 있다.
  • doLog 와 doTranscation 을 별도의 클래스로 분리하여야 한다.
  • @Order 로 Aspect 의 순서를 정할 수 있으며 낮을수록 먼저실행된다.
@Slf4j
public class AspectV5Order {
	
	@Aspect
	@Order(2)
	public static class LogAspect {
		@Around("hello.aop.order.aop.Pointcuts.allOrder()")
		public Object doLog(ProceedingJoinPoint joinPoint) throws Throwable { // advice
			log.info("[log] {}", joinPoint.getSignature()); // join point 시그니처 -> 메서드 정보 찍힘
			return joinPoint.proceed(); // target 호출
		}
	}

	@Aspect
	@Order(1)
	public static class TxAspect {
		//hello.aop.order 패키지와 하위 패키지 이면서 클래스 이름 패턴이 *Service
		@Around("hello.aop.order.aop.Pointcuts.orderAndService()")
		public Object doTransaction(ProceedingJoinPoint joinPoint) throws Throwable {
			try {
				log.info("[트랜잭션 시작] {}", joinPoint.getSignature());
				Object result = joinPoint.proceed();
				log.info("[트랜잭션 커밋] {}", joinPoint.getSignature());
				return result;
			} catch (Exception e) {
				log.info("[트랜잭션 롤백] {}", joinPoint.getSignature());
				throw e;
			} finally { // 무조건 호출
				log.info("[트랜잭션 릴리즈] {}", joinPoint.getSignature());
			}
		}
	}
}

실행결과
실행결과2

변경결과 TxAspect 가 먼저 실행된다.

4-5. 어드바이스 종류

  • @Around: 메서드 호출 전후에 수행, 가장 강력한 어드바이스
  • @Before: 조인 포인트 실행 이전에 실행 (joinPoint.proceed() 이전에 실행)
  • @After Returning: 조인 포인트가 정상 완료후 실행
  • @After Throwing: 메서드가 예외를 던지는 경우 실행
  • @After: 조인 포인트가 정상 또는 예외에 관계없이 실행(finally)

@Around

@Around("hello.aop.order.aop.Pointcuts.orderAndService()")
public void doAround(ProceedingJoinPoint joinPoint) {
	log.info("{}, {}" joinPoint.getSignature());
    joinPoint.proceed();
}
  • @Around 는 타겟 메서드 실행 전후에 작업을 수행한다.
  • 타겟 메서드의 실행 여부를 결정하거나, 타겟 메서드를 여러번 실행할 수도 있는 막강한 어드바이스이다.
  • @Around는 항상 joinPoint.proceed()를 호출해야한다. -> 실수로 호출하지 않으면 타겟이 호출되지 않는다.
  • proceed()는 다음 어드바이스나 타겟을 호출한다.

@Before

@Before("hello.aop.order.aop.Pointcuts.orderAndService()")
    public void doBefore(JoinPoint joinPoint) {
	log.info("[before] {}", joinPoint.getSignature());
    }
  • @Before 어드바이스는 타겟 메서드 호출전에 수행한다.
  • ProceedingJoinPoint.proceed() 자체를 사용하지 않는다. 어드바이스가 종료되면 자동으로 다음 타겟을 호출한다.

@After Returning

@AfterReturning(value = "hello.aop.order.aop.Pointcuts.orderAndService()", returning="result")
    public void doReturn(JoinPoint joinPoint, Object result) {
	log.info("[return] {} return={}", joinPoint.getSignature(), result);
    }
  • @AterReturning 어드바이스는 타겟 메서드가 반환된 뒤에 수행된다.
  • returning 속성명과 파라미터명이 일치해야한다. (result)
  • result는 해당 메서드의 리턴객체를 그대로 가져올 수 있다.
  • 파라미터의 타입을 반환하는 메서드를 대상으로만 실행된다.

@After Throwing

@AfterThrowing(value = "hello.aop.order.aop.Pointcuts.orderAndService()", throwing = "ex")
    public void doThrowing(JoinPoint joinPoint, Exception ex) {
	log.info("[ex] {} message={}", ex);
    } 
  • @AfterThrowing 은 타겟메서드 실행 중 예외가 발생하면 실행된다.
  • throwing 속성명과 파라미터 이름이 일치해야한다. (ex)
  • ex는 해당 메서드에서 발생한 예외를 가져올 수 있다.
  • 파라미터의 타입의 예외를 던지는 메서드를 대상으로만 실행된다.

@After

@After(value = "hello.aop.order.aop.Pointcuts.orderAndService()")
    public void doAfter(JoinPoint joinPoint) {
	log.info("[after] {}", joinPoint.getSignature());
    }
  • @After 는 메서드실행이 종료되면 실행된다.
  • finally 와 유사하다.

Leave a comment