[Spring] AOP(Aspect Oriented Programming)

정은영·2022년 12월 22일
1

CS

목록 보기
18/18

AOP는 관점지향프로그래밍으로 공통된 부분은 좀 더 쉽게 프로그래밍하는 기법입니다.

ex. 클래스에 메서드를 여러 개 구현하고 있는데, 각각 메서드마다 걸리는 실행시간을 알고 싶다.

→ 모든 메서드마다 시작 지점의 시간과 끝나는 지점의 시간을 체크해서 확인한다.

ex. 각각 메서드마다 어떤 일을 하는지 로그에 출력하고 싶다.

→ 모든 메서드에 로그를 출력하는 코드를 작성한다.

이렇게 되면 각각의 메서드가 다른 역할을 하지만, 메서드마다 걸리는 실행시간을 측정하는 기능과 로그를 출력하는 기능은 모두 동일하고 코드의 중복이 발생합니다. 또한 코드가 길어지며 가독성이 안좋아지고 실수도 할 수 있습니다.

이를 AOP로 개선이 가능합니다.

public class Hello {
    public String sayHello(String name) {
    	//로그 남기는 공통 코드
        System.out.println("log: " + new java.util.Date());
        
        String msg = "hello~ " + name;
        return msg;
    }
}
  • 위 코드는 메서드가 실행된 시간을 보여주는 로그를 남기는 코드입니다.
  • 이를 Hello 클래스로부터 분리해 보겠습니다.

public class HelloLog {
    public static void log() {
    	System.out.println("log: " + new java.util.Date());
    }
}
public class Hello {
    public String sayHello(String name) {
    	HelloLog.log(); //공통코드를 호출하는 코드가 포함됨
        
        String msg = "hello~" + name;
        return msg;
    }
}
  • 이렇게 한 줄짜리 코드로 줄어들긴 했지만 이 코드 또한 없애고 싶을 때 AOP를 사용합니다.

AOP에서는 핵심 로직들을 구현한 코드에서 공통기능을 직접 호출하지 않습니다.

AOP에서는 분리한 공통 기능의 호출까지도 관점으로 다루며 이런 모듈로 산재한 관점을 "횡단 관점"이라고 부릅니다.

횡단관점이란, 아래 사진처럼 파란색 박스는 각각 다른 로직을 지닌 메서드들이고, 빨간색 박스가 로그 등 공통 코드입니다. 빨간색 박스가 파란색 박스를 횡단한다는 개념으로 횡단 관점이라고 부릅니다.

AOP는 이런 횡단 관점까지 분리하면서 각 모듈로부터 관점에 대한 코드를 완전히 제거하는 것을 목표로 합니다.

프록시를 이용한 AOP 구현

이전 예시에서는 클래스를 단순히 분리해서 공통 코드인 로그를 남기는 것을 구현했습니다.

public class HelloProxy extends Hello {
    @override
    publid String sayHello(String name) {
    	HelloLog.log(); //공통 코드 실행
        return super.sayHello(name); //핵심 코드 실행
    }
}
  • HelloProxy 클래스가 Hello 클래스를 상속하고 오버 로딩을 구현했습니다.
  • Hello클래스를 호출하면 HelloProxy가 먼저 호출되고 HelloProxy는 로그를 찍고 Hello클래스의 핵심 코드를 실행합니다.
  • 이렇게 구현한 것이 프록시를 이용한 AOP입니다.

하지만 이 방법에는 단점이 있습니다.

외부에서 Hello의 A메서드를 호출해야 프록시를 거쳐서 해당 메서드가 호출됩니다.

하지만 Hello클래스 내의 다른 메서드가 A메서드를 호출하면 프록시를 거치지 않고 바로 호출되기 때문에 공통 코드의 실행이 생략됩니다.

void aaa() {
    bbb();
}

@Transactional
void bbb() {
    ccc();
}

@Transactional
void ccc() {
    ...
}
  • @Transactional어노테이션은 해당 메서드를 트랜잭션처럼 동작하도록 합니다.
  • 외부에서 ccc메서드를 호출하면 정상적으로 트랜잭션이 걸립니다.
  • 하지만 외부에서 bbb를 호출하면, bbb가 내부적으로 ccc를 호출하기 때문에 에러가 나지는 않지만 ccc에 트랜잭션이 정상 작동하지 않습니다. 내부적으로 메서드를 호출하기 때문에 프록시를 거치지 않아서 @Transactional 어노테이션을 거치지 않게 되는 것입니다.

자바 언어에서 메서드 앞에 별도의 참조가 없으면 this를 자동으로 붙여 자기 자신의 인스턴스를 가리킨다. 결과적으로 자기 자신의 내부 메소드를 호출하는 this.ccc()가 되는데 여기서 this는 실제 대상 객체(target)의 인스턴스를 뜻합니다. 결과적으로 이러한 내부 호출은 프록시를 거치지 않게 됩니다.

위빙 - Weaving

위빙(Weaving)은 AOP 프레임워크가 공통 코드를 핵심코드에 삽입하는 것을 말합니다. 위빙에는 3가지 방법이 있습니다.

  • 컴파일 시 위빙
    • 별도의 컴파일러를 통해 핵심 모듈 사이에 관점 형태로 만들어진 공통 관심 코드가 삽입됩니다.
    • 예를 들면 AspectJ가 있습니다.
    • AspectJ를 사용하면 프록시를 이용한 AOP 구현에서의 단점 같은 상황은 일어나지 않지만 사용하기가 귀찮습니다.
  • 클래스 로딩 시 위빙
    • 별도의 Agent를 이용해서 JVM이 클래스를 로딩할 때 해당 클래스의 바이너리 정보를 변경합니다.
    • 즉, Agent가 횡단 관심사 코드가 삽입된 바이너리 코드를 제공하면서 AOP를 지원합니다.
    • ex. AspectWerkz
  • 런타임시 위빙
    • 위빙의 99%는 런타임 시 위빙으로 진행됩니다.
    • 소스 코드나 바이너리 파일의 변경 없이 프록시를 이용하여 AOP를 지원합니다. 프록시를 통해 핵심 코드를 구현한 객체에 접근하며 AOP를 지원합니다.
    • ex. Spring AOP

스프링에서의 AOP

스프링에서는 자체적으로 런타임 때 위빙하는 프록시 기반의 AOP를 지원하고 있습니다. 프록시 기반이기 때문에 외부에서 메서드를 호출할 때만 적용이 가능합니다. 스프링은 완전한 AOP 기능을 제공하는 것이 목적이 아니라, 어플리케이션을 구현할 때 필요한 기능을 제공하는 것을 목적으로 하고 있습니다. spring-boot-starter-aop 라이브러리를 사용하고, @Aspect 같은 어노테이션을 통해 쉽게 AOP를 구현할 수 있습니다.

AOP 시작하기

먼저 스프링 프로젝트에 build.gradle파일에 의존성을 추가합니다.

implementation 'org.springframework.boot:spring-boot-starter-aop'

AOP를 사용하기 위해서는 다음과 같은 Annotation들을 알아야 합니다.

@AspectAOP로 정의하는 클래스를 지정함
@PointcutAOP기능을 메소드, Annotation 등 어디에 적용시킬지 지점을 설정 지점을 설정하기 위한 수식들이 매우 많음
@Before메소드 실행하기 이전
@After메소드가 성공적으로 실행 후 (예외 발생 되더라도 실행 됨)
@AfterReturning메소드가 정상적으로 종료될때
@AfterThrowing메소드에서 예외가 발생할때
@AroundBefore + After 모두 제어 (에외 발생 되더라도 실행 됨)
@GetMapping("/get/{id}")
public String get(@PathVariable Long id, @RequestParam String name) {
	System.out.println("Get Method가 실행됨!");
	System.out.println("Get Method {id}: " + id);
	System.out.println("Get Method {name}: " + name);

	//서비스 로직
    
	return id + " " + name;
}

@PostMapping("/post")
public User post(@RequestBody User user) {
	System.out.println("Post Method가 실행됨!");
   	System.out.println("Post Method {user}: " + user);
        
	//서비스 로직
        
	return user;
}
  • 위 코드는 서비스 로직을 제외하고 메서드의 시작 부분에 실행된 메서드와 들어온 매개변수 값을 출력하는 코드가 추가되어 있습니다.

이를 AOP로 분리시켜 보겠습니다.

@Aspect
@Component
public class ParameterAop {

    //com/example/aop/controller 패키지 하위 클래스들 전부 적용하겠다고 지점 설정
    @Pointcut("execution(* com.example.aop.controller..*.*(..))")
    private void cut() {}

    //cut() 메서드가 실행 되는 지점 이전에 before() 메서드 실행
    @Before("cut()")
    public void before(JoinPoint joinPoint) {
		
        //실행되는 함수 이름을 가져오고 출력
        MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
        Method method = methodSignature.getMethod();
        System.out.println(method.getName() + "메서드 실행");

        //메서드에 들어가는 매개변수 배열을 읽어옴
        Object[] args = joinPoint.getArgs();
		
        //매개변수 배열의 종류와 값을 출력
        for(Object obj : args) {
            System.out.println("type : "+obj.getClass().getSimpleName());
            System.out.println("value : "+obj);
        }
    }

    //cut() 메서드가 종료되는 시점에 afterReturn() 메서드 실행
    //@AfterReturning 어노테이션의 returning 값과 afterReturn 매개변수 obj의 이름이 같아야 함
    @AfterReturning(value = "cut()", returning = "obj")
    public void afterReturn(JoinPoint joinPoint, Object obj) {
        System.out.println("return obj");
        System.out.println(obj);
    }
}
  • 컨트롤러의 매개변수를 로그에 찍어주는 ParameterAop클래스를 다음과 같이 작성했습니다.
@GetMapping("/get/{id}")
public String get(@PathVariable Long id, @RequestParam String name) {
	//서비스 로직
	return id + " " + name;
}

@PostMapping("/post")
public User post(@RequestBody User user) {
	//서비스 로직
	return user;
}
  • 컨트롤러에 로그를 찍어주는 모든 코드를 삭제하고 위와 같이 수정해주었습니다.

  • 이전보다 코드가 훨씬 간결해지고 가독성도 높아졌습니다.


결과

  • GET 방식으로 request를 보내면 다음과 같이 출력됩니다.

  • POST 방식으로 request를 보내면 다음과 같이 출력됩니다.

이렇게 AOP클래스를 작성하여 컨트롤러에 로그를 찍는 공통적인 부분을 제거하여 코드의 가독성을 높일 수 있고 AOP 클래스를 한번만 작성하여 여러 개의 메서드의 적용할 수 있음을 확인하였습니다.

사용자 지정 Annotatin AOP

위에서 메서드의 매개변수와 리턴 값에 대한 로깅을 남기는 AOP를 작성하였는데, 이번에는 메서드 실행 시간에 대해 로그를 찍어주는 AOP 클래스를 작성해보겠습니다.

이번에는 단순히 클래스를 작성하는 것이 아닌 사용자 지정 어노테이션을 만들어서 해당 어노테이션이 붙어있는 메서드에서 AOP 기능을 수행하도록 코드를 작성해보겠습니다.


@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Timer {}
  • com.example.aop.annotation 패키지를 생성하고 Timer라는 어노테이션 파일을 만들어주었습니다.
@Aspect
@Component
public class TimerAop {

    @Pointcut("execution(* com.example.aop.controller..*.*(..))")
    private void cut() {}
	
    //사용자 지정 어노테이션이 붙은 메서드에도 적용!
    @Pointcut("@annotation(com.example.aop.annotation.Timer)")
    private void enableTimer() {}

    //메서드 전 후로 시간 측정 시작하고 멈추려면 Before, AfterReturning으로는 시간을 공유 할 수가 없음 Around 사용!
    @Around("cut() && enableTimer()")
    public void around(ProceedingJoinPoint joinPoint) throws Throwable {

        //메서드 시작 전
        StopWatch stopWatch = new StopWatch();
        stopWatch.start();

        //메서드가 실행되는 지점
        Object result = joinPoint.proceed();

        //메서드 종료 후
        stopWatch.stop();

        System.out.println("총 걸린 시간: " + stopWatch.getTotalTimeSeconds());
    }
}
  • 위와 같이 TimerAop클래스를 작성해줍니다.
@Timer
@DeleteMapping("/delete")
public void delete() {

	//삭제 서비스 로직: 소요시간 3초
	try {
		Thread.sleep(3000);
	} catch (InterruptedException e) {
		e.printStackTrace();
	}
}
  • 컨트롤러에서 3초 걸리는 로직을 가지고 있는 delete() 메서드를 작성해주고 위에서 만든 Timer 어노테이션을 붙여줍니다.

결과

  • 해당 API로 request를 보내면 다음과 같은 결과를 볼 수 있습니다.
  • 시간 측정 AOP를 작성하기 이전에 작성한 메서드 이름, 리턴 값 등을 출력하는 AOP도 함께 적용되어 결과가 나온 것을 확인할 수 있습니다.

값을 변경하는 AOP

들어오는 값에 대해 동일한 전처리나 가공이 필요하면 AOP로 구현할 수 있습니다.

Filter나 Interceptor로 변환할 수는 있지만 스프링 부트의 내장 서버인 톰캣은 Body를 한번 읽으면 다시 못 읽게 막아놨기 때문에 변환하기가 어렵습니다. 하지만 AOP를 적용하면, 들어오는 객체나 값은 Filter와 Interceptor를 지나서 객체화되어 왔기 때문에 값을 변환해주기 비교적 쉽습니다.

혹은 암호화된 데이터가 들어오면 코드에서 복호화하지 않고 AOP에서 복호화 완료된 상태로 받을 수 있으며, 그 역의 작업도 가능합니다.

혹은 가공된 데이터를 보낼 때 특정 회원들이나 특정 서버에 보내고 싶을 때도 AOP를 사용할 수 있습니다.

데이터를 주고받을 때 암호화된 상태로 받는데, 코드에서 복호화하지 않고 AOP에서 복호화되어 컨트롤러 메서드로 떨어질 수 있게 AOP를 작성해 보겠습니다.

@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Decode {}
  • com.example.aop.annotation 패키지에 Decode라는 어노테이션 파일을 만들어줍니다.
@Aspect
@Component
public class DecodeAop {

    @Pointcut("execution(* com.example.aop.controller..*.*(..))")
    private void cut() {}

    @Pointcut("@annotation(com.example.aop.annotation.Decode)")
    private void enableDecode() {}
	
    //암호화된 데이터 들어오면 복호화!
    @Before("cut() && enableDecode()")
    public void before(JoinPoint joinPoint) throws UnsupportedEncodingException {
        
        //메서드로 들어오는 매개변수들
        Object[] args = joinPoint.getArgs();

        for(Object arg : args) {
            if(arg instanceof User)  {
            	//클래스 캐스팅
                User user = User.class.cast(arg);
                String base64Email = user.getEmail();
                //복호화
                String email = new String(Base64.getDecoder().decode(base64Email), "UTF-8");
                user.setEmail(email);
            }
        }
    }
	
    //리턴할때는 암호화 하여 리턴
    @AfterReturning(value = "cut() && enableDecode()", returning = "returnObj")
    public void afterReturn(JoinPoint joinPoint, Object returnObj) {

        if(returnObj instanceof User) {
       		//클래스 캐스팅
            User user = User.class.cast(returnObj);
            String email = user.getEmail();
            //암호화
            String base64Email = Base64.getEncoder().encodeToString(email.getBytes());
            user.setEmail(base64Email);
        }
    }
}
  • DecodeAop코드를 작성해줍니다.
@Decode
@PutMapping("/put")
public User put(@RequestBody User user) {
	//서비스 로직
	return user;
}
  • ApiControllerput()메서드를 작성해주었습니다.

결과

  • 다음과 같은 Body를 가진 PUT request를 보냅니다.

  • 결과로 다음과 같이 request와 같은 객체가 리턴되는 것을 볼 수 있습니다.

  • 들어오는 객체와 리턴 객체를 로그에 출력해주는 AOP에 의해서 콘솔 창을 확인해보면, 암호화되어 들어온 이메일이 복호화된 상태로 메서드에 들어오고, 리턴되는 객체에서 이메일이 DecodeAop에 의해 암호화되는 것을 확인할 수 있습니다.

Reference

0개의 댓글