AOP라..

전홍영·2024년 12월 12일
0

Spring

목록 보기
25/26

스프링의 3대요소 IoC/DI, AOP, PSA 중 AOP에 대해 알아보자. AOP는 Aspect Oriented Programming 한국어로 관점 지향 프로그래밍이다. 사실 이 단어로만 보면 무슨 느낌인지 정말 이해가 안갔다. 예시 코드를 보면서 설명해보겠다.

public class ImageRegisterService {
    @Autowired
    private ImageDao imageDao;

    public void registerImage(Image image) {
        imageDao.insertImage(image);
    }
}

public class ImageUpdateService {
    @Autowired
    private ImageDao imageDao;

    public void updateImage(Image image) {
        Image foundImage = imageDao.selectByName(image.getName());

        if (foundImage == null) {
            throw new NotFoundImageException();
        }

        foundImage.setUrl(image.getUrl());

        imageDao.updateImage(foundImage);
    }
}

이렇게 두개의 이미지 저장과 수정하는 서비스가 있다고 하자. 이 두 서비스 모두 이미지를 파라미터로 받아서 사용한다. 만약 파라미터로 받은 이미지 파일 이름을 검사하는 기능을 두 서비스에서 모두 사용하고자 한다. 그러면 두 서비스 로직에 똑같이 파라미터로 받은 이미지를 검사하는 로직을 추가할 것이다.

그러나 이렇게 똑같은 로직을 추가하게 되면 중복된 코드를 사용하고 수정할 때도 똑같은 부분을 수정해야한다. 이는 굉장히 귀찮고 버그 발생률도 높아지게 된다. 그래서 이러한 공통된 기능을 분리하여 핵심 로직에 추가하지 않아도 실행이 되도록 분리하는 것이 AOP의 핵심이다.

핵심 기능과 공통 기능

핵심 기능은 말 그대로 중요한 로직을 일컫는다. 위 코드에서 이미지를 등록하고 수정하는 로직이 핵심 기능이다. 공통 기능은 이 핵심 로직에서 부가적으로 수행하는 기능을 일컫는다. 위에서 예를 들었던 것처럼 파라미터로 받은 이미지를 검사하거나 해당 메서드의 시간을 측정한다거나 로그를 남긴다던가 이러한 기능이 공통 기능이라 할 수 있다.

그러면 핵심 기능에 공통 기능을 어떻게 삽입할 수 있을까? 삽입하는 방법은 크게 3가지이다. 첫번째 AOP 도구가 소스 코드를 컴파 하기 전에 공통 구현 코드를 소스에 삽입하는 방식이다. 두번째는 클래스를 로딩할 때 바이트 코드에 공통 기능을 삽입하는 방식이다. 마지막 방법은 런타임에 프록시 객체를 생성하여 공통 기능을 삽입하는 방식이다. 스프링 AOP에서는 이 3번째 방식을 이용하여 AOP를 제공하고 있다.

스프링의 AOP

스프링 AOP는 공통 기능을 구현한 클래스만 알맞게 구현하면 자동으로 프록시를 만들어서 공통 기능을 핵심 기능에 삽입해준다. 스프링 AOP를 이해하기 위해서는 AOP에 대한 용어를 알아야 한다.

  • Advice - Advice는 언제 공통 관심 기능을 핵심 로직에 적용할지를 정의한다. 예를 들어 "메서드 시작 전에(언제) 로그를 남긴다(공통 기능). "를 보면 이 언제가 Advice라 할 수 있다.
  • Joinpoint - Advice를 적용 가능한 지점을 의미한다. 예를 들어 메서드 호출, 필드 변경이 있을 수 있는데 스프링에서는 메서드 호출에 대한 Joinpoint만 지원한다.
  • Pointcut - Pointcut은 Joinpoint의 부분 집합으로 실제 Advice가 적용되는 Joinpoint를 나타낸다. 실제 어느 메서드가 실행될 때 공통 기능을 실행할 것인가를 정하는 부분이다.
  • Weaving - Advice를 핵심 로직 코드에 적용하는 것을 말한다.
  • Aspect - 여러 객체에 공통으로 적용되는 기능을 Aspect라고 한다. 트랜잭션이나 보안 등이 좋은 예이다.

그럼 용어를 알아봤으니 실제 코드를 통해서 스프링에서 AOP를 어떻게 제공하고 있는 지 보자. 간단하게 설명하면 Aspect를 만들어 공통 기능을 구현하고 Pointcut을 정의하여 언제 공통 기능을 적용할지 설정한다. 이를 스프링의 어노테이션을 이용하여 구현하면된다. 코드를 보자.

@Aspect
public class ImageNameCensorAspect {
    private final String imageName = "이미지";

	@Pointcut("execution(* *..*Service.*(.., Image))")
    public void target() {
    }
    
    @Around("com.example.spring.AOP.ImageServicePointcut.target()")
    public Object execute(ProceedingJoinPoint joinPoint) throws Throwable {
        Image targetImage = (Image) joinPoint.getArgs()[0];

        if (!targetImage.getName().startsWith(imageName)) {
            System.out.println("이미지 이름 검사 실행 - 잘못된 이미지 이름");
            throw new WrongImageException();
        }

        System.out.println("이미지 이름 검사 실행 - 정상적인 이미지 이름");
        return joinPoint.proceed();
    }
}

public class ImageRegisterService {
    @Autowired
    private ImageDao imageDao;

    public void registerImage(Image image) {
        imageDao.insertImage(image);
        System.out.println("이미지 등록 성공!");
    }
}

일단 기존의 핵심 로직은 전혀 수정한 부분이 없다. 그러면 ImageNameCensorAspect를 보자. 일단 @Aspect가 붙어져 있는 것을 볼 수 있다. @Aspect가 붙어 있는 클래스는 공통 기능을 제공하는 클래스임을 명시하는 것이다. 그리고 @Pointcut이라는 어노테이션을 볼 수 있다. 이는 공통 기능을 적용할 대상을 설정하는 부분이다. @Pointcut의 속성에 대해서는 뒤에서 설명하겠다. @Aroud 어노테이션이 붙은 메서드가 바로 공통 기능인데 @Aroud 속성으로 Pointcut을 지정해주면 공통 기능 적용 대상에 대해 @Aroud가 붙은 메서드가 실행된다는 것이다.

따라서 위의 예시 코드를 보면 Service로 끝나는 클래스의 메서드 중 Image 객체를 파라미터로 받는 메서드를 대상으로 execute() 메서드를 실행한다고 설명할 수 있다. execute() 메서드는 공통 기능이고 @Pointcut으로 지정한 메서드가 핵심 로직 메서드이다. 그럼 이렇게 공통 기능을 구현한 클래스를 스프링 컨테이너에 등록해보자.

@Configuration
@EnableAspectJAutoProxy
public class AppCtx {
    @Bean
    public ImageDao imageDao() {
        return new ImageDao();
    }

    @Bean
    public ImageNameCensorAspect imageNameCensorService() {
        return new ImageNameCensorAspect();
    }
    
    ...
}

코드를 보면 기존에 설정 클래스에 볼 수 없었던 어노테이션이 있다. @EnableAspectJAutoProxy라는 어노테이션이 있는데 이는 프록시 생성과 관련된 AnnotationAwareAspectJAutoProxyCreator 객체를 빈으로 등록한다. 이 객체를 등록함으로써 관련된 다양한 설정을 대신 설정해준다. Enable이 붙은 다른 어노테이션들도 다양한 설정을 개발자 대신 해줌으로써 개발자가 쉽게 스프링을 사용할 수 있도록 도와준다. (@EnableWebMvc와 같이 다양한 류의 설정 어노테이션들이 존재한다.)

이렇게 공통 기능도 스프링 컨테이너에 등록하고 어떤 대상에 공통 기능을 적용할지도 모두 마쳤다. 테스트 코드를 통해 어떻게 작동되는지 보자.

@Test
void AOP_적용하기() {
    AnnotationConfigApplicationContext appCtx = new AnnotationConfigApplicationContext(AppCtx.class);
    ImageRegisterService imageRegisterService = appCtx.getBean("imageRegisterService", ImageRegisterService.class);
    
    Image image = new Image( "url", "이미지");

    imageRegisterService.registerImage(image);
}

consoel : 
이미지 이름 검사 실행 - 정상적인 이미지 이름
이미지 등록 성공!

테스트를 해보니 핵심 기능을 수정한 적이 없지만 이미지를 검사하는 공통 기능이 수행된 것을 볼 수 있다.

어떻게 작동하는 거지?

그러면 이제 어떻게 스프링에서 핵심 로직은 건드리지 않고도 공통 기능을 삽입할 수 있는지에 대해 알아보자. 먼저 아까 프록시를 통해 공통 기능을 삽입한다고 했다. 어디서 프록시가 생성된 걸까? 위의 테스트 코드를 보면 getBean()을 통해서 빈 객체를 컨테이너에서 가져와서 핵심 기능을 수행했다. 이 빈 객체를 getClass()를 통해 보면 다음과 같이 나온다.

class com.example.spring.AOP.ImageRegisterService$$SpringCGLIB$$0

원래는 ImageRegisterService뒤에 SpringCGLIB라는 문자가 붙지 않고 싱글톤 객체가 반환되어야 한다. 그러나 이는 SpringCGLIB라는 프록시 객체를 참조하고 있는 것을 알 수 있다. 이 프록시 객체가 어떻게 생성되는지 이 포스트를 참조하자.

본론으로 돌아가서 런타임 중에 프록시 객체를 생성해서 공통 기능을 삽입한다고 했다. 따라서 ImageRegisterService 프록시 객체를 스프링이 자동으로 생성한 뒤 개발자가 설정한 pointcut에 따라서 공통 기능이 실행되고 핵심 기능이 실행되는 것이다. 이렇게 말로만 설명하면 이해가 잘 되지 않기 때문에 코드를 통해 보자.

@Aspect
public class ImageNameCensorAspect {
    private final String imageName = "이미지";

	--(1)--
	@Pointcut("execution(* *..*Service.*(.., Image))")
    public void target() {
    } 
    
    --(2)--
    @Around("target()")
    public Object execute(ProceedingJoinPoint joinPoint) throws Throwable {
    	--(3)--
        Image targetImage = (Image) joinPoint.getArgs()[0];

        if (!targetImage.getName().startsWith(imageName)) {
            System.out.println("이미지 이름 검사 실행 - 잘못된 이미지 이름");
            throw new WrongImageException();
        }

        System.out.println("이미지 이름 검사 실행 - 정상적인 이미지 이름");
        --(4)--
        return joinPoint.proceed();
    }
}

코드에 숫자를 보자. (1) 부분에는 적용할 대상을 지정하는 Pointcut을 설정하였다. (2) 부분에서는 @Around를 통해 공통 기능을 구현한 부분이다. 이 excute()라는 메서드가 ProceedingJoinPoint 인터페이스를 파라미터로 받고 있는데 이 파라미터가 핵심 기능을 수행하는 메서드라고 생각하면된다. 그래서 (3)에서 joinPoint의 getArgs()를 통해 핵심 기능 메서드의 파라미터를 가져와서 검사하는 것이다. 그리고 (4)부분을 보면 joinPoint의 proceed()를 통해 핵심 기능을 수행한다.

정리를 해보자면 클라이언트가 핵심 기능을 호출하면 -> 스프링에서 해당 빈 객체의 프록시 객체를 생성하고 -> 프록시 객체가 공통 기능을 호출(execute) -> 공통 기능을 수행면서 ProceedJoinpoint의 proceed가 호출되면 -> 핵심 기능을 수행하게 되면서 -> return -> return -> return ... 이런 방식으로 스프링의 AOP 기능이 수행되는 것이다.

ProceedJoinpoint에는 어떤 기능이 있지?

ProceedJoinpoint는 호출되는 대상 객체에 대한 정보를 담고 있는 인터페이스이다. 따라서 공통 기능 구현에 필요한 정보를 제공하는 메서드를 가지고 있는데 대표적으로 호출되는 메서드에 대한 정보를 가져오는 getSignature(), 대상 객체를 구하는 getTarget(), 파리미터 목록을 가져오는 getArgs()가 있다. 여기서 getSignature()를 통해 Signature라는 인터페이스를 반환받는데 이 인터페이스는 메서드 이름, 메서드의 다양한 정보(파라미터 타입, 반환 타입)을 제공한다.

위의 (3)을 보면 joinpoint의 파라미터를 가져와서 공통 기능 구현할 때 사용한 것을 볼 수 있다.

스프링 AOP 어노테이션을 자세히 살펴보자

이제까지 나온 어노테이션은 @Aspect, @Pointcut, @EnableAspectJAutoProxy, @Around 정도이다.

@Aspect

@Aspect는 공통 기능을 구현한 클래스에 붙여주는 어노테이션이다.

@Pointcut

@Pointcut은 공통 기능을 적용할 타겟을 설정하는 어노테이션이다. @Pointcut의 속성에는 execution 명시자를 지정할 수 있는데 이 execution이 Advice를 적용할 메서드를 지정할 때 사용된다.

execution은 다음과 같은 형식을 지닌다.

execution(수식어패턴?리턴타입패턴 클래스이름패턴?메서드이름패턴(파라미터패턴))

포스터를 통해 예시를 보면 좋을 것 같다. execution을 원하는 메서드에 지정하여 공통 기능을 수행하도록 할 수 있다.

그리고 @Pointcut은 재사용이 가능하기 때문에 따로 설정을 해두면 같은 타겟을 향한 공통 기능은 pointcut을 공유하여사용하면 코드의 재사용성을 높일 수 있다.

@EnableAspectJAutoProxy

이 어노테이션은 다양한 프록시 설정을 해준다고 위에서 설명했다.

@Around

@Around는 pointcut으로 지정한 메서드 전/후 또는 익셉션 발생 지점에 실행할 공통 기능 메서드에 붙여주는 어노테이션이다. Around Advice는 대상 객체의 메서드 실행 전, 후 또는 익셉션 발생 시점에 공통 기능을 수행할 수 있도록 해준다.

@Around 외에도 메서드 실행 전에 공통 기능을 수행하도록 하는 @Before, 메서드 실행 후에 공통 기능이 실행되는 @After과 같은 어노테이션이 존재한다.

만약 여러 Advice를 적용하면 우선순위가 어떻게 될까?

한 Pointcut에 여러 개의 Advice를 적용한다면 어떤 것이 먼저 실행될까? 예를 들어 내가 해당 메서드의 시간을 측정하는 Advice와 이미지 이름을 검사하는 Advice를 한 타겟에 적용시키고 싶다고 하자. 이미지 이름을 검사하는 시간도 포함하여 메서드 실행 시간을 측정하고 싶은데 어떻게 하면 좋을까?

어떤 Aspect가 먼저 적용될지는 스프링 프레임워크나 자바 버전에 따라 달라질 수 있다. 그렇기 때문에 순서를 직접 정해주면 해당 순서에 따라 실행되도록 @Order를 통해 구현하면 된다.

@Aspect
@Order(1)
public class ExeTimeAspect {
    @Pointcut("execution(* *..*Service.*(.., Image))")
    public void target() {
    }

    @Around("target()")
    public Object measure(ProceedingJoinPoint joinPoint) throws Throwable {
        System.out.println("메서드 실행 시간 측정 시작!");
        long start = System.nanoTime();
        try {
            return joinPoint.proceed();
        } finally {
            long finish = System.nanoTime();
            System.out.println("메서드 실행 시간 측정 종료!");

            Signature signature = joinPoint.getSignature();

            System.out.printf("%s.%s(%s) 실행 시간 : %d ns \n",
                    joinPoint.getTarget().getClass().getSimpleName(),
                    signature.getName(), Arrays.toString(joinPoint.getArgs()),
                    (finish - start));
        }
    }
}


@Aspect
@Order(2)
public class ImageNameCensorAspect {
    private final String imageName = "이미지";

    @Around("com.example.spring.AOP.ExeTimeAspect.target()")
    public Object execute(ProceedingJoinPoint joinPoint) throws Throwable {
        Image targetImage = (Image) joinPoint.getArgs()[0];

        if (!targetImage.getName().startsWith(imageName)) {
            System.out.println("이미지 이름 검사 실행 - 잘못된 이미지 이름");
            throw new WrongImageException();
        }

        System.out.println("이미지 이름 검사 실행 - 정상적인 이미지 이름");
        return joinPoint.proceed();
    }
}

두 코드를 보면 서로 같은 pointcut에 대해 Advice를 적용하고자 한다. 요구사항에 따르면 시간 측정을 하고 싶은데 이미지 이름 검사를 하는 부분도 포함시키고자 한다. 그래서 시간 측정을 시작하고 이미지 이름 검사를 하고 시간 측정을 마치도록 순서를 정하고자하였다. 따라서 ExeTimeAspect의 measure()를 먼저 실행하고 ImageNameCensorAspect의 execute()를 실행하도록 @Order를 통해 순서를 지정해주었다. 이렇게 순서를 지정해주고 테스트를 해보면 다음과 같이 출력이 된다.

console:
메서드 실행 시간 측정 시작!
이미지 이름 검사 실행 - 정상적인 이미지 이름
이미지 등록 성공!
메서드 실행 시간 측정 종료!

이렇게 요구사항에 맞게 @Order 통해 Advice 적용 순서를 정해주었다. 정리하면 클라이언트가 register() 요청이 오면 프록시 객체가 @Order(1) Aspect를 먼저 수행하기 시작하고 proceed()가 실행되면 @Order(2) Aspect가 수행하면서 핵심 기능을 수행하고 return -> return -> return -> return 되는 방식으로 수행된다.

이렇게 AOP에 대해서 알아보았다. AOP는 간단하게 공통 기능과 핵심 기능을 분리하는 프로그래밍 기법이라고 할 수 있다. AOP는 객체지향개발에 매우 중요한 부분이며 OCP 개방폐쇄원칙 확장에는 열려있고 수정에는 닫혀있다를 잘 따르는 개발 방법이라고 할 수 있다. 핵심 기능을 수정하지 않고 공통 기능을 추가함으로써 확장에는 열려있고 수정에는 닫혀있음을 보여준다.

참고

profile
Don't watch the clock; do what it does. Keep going.

0개의 댓글