AOP는 우리말로 번역하면 관점 지향 프로그래밍입니다. 여기서 관점이라는 대해 생소하기 때문에 해당 단어에 대한 위키피디아의 설명부터 말씀드리겠습니다.
An aspect of a program is a feature linked to many other parts of the program, but which is not related to the program's primary function. An aspect crosscuts the program's core concerns, therefore violating its separation of concerns that tries to encapsulate unrelated functions. For example, logging code can crosscut many modules, yet the aspect of logging should be separate from the functional concerns of the module it cross-cuts. Isolating such aspects as logging and persistence from business logic is at the core of the aspect-oriented programming (AOP) paradigm.
프로그램에서 관점이란 여러 다른 부분들과 연결된 기능이지만, 프로그램의 주요 기능과는 관련없는 것을 말한다. 관점은 프로그램의 핵심 관심사를 교차하므로 관련없는 기능을 캡슐화하려는 것을 어렵게 만든다.
예를 들어, 로그를 기록하는 코드는 많은 모듈들을 교차할 수 있다. 하지만 로깅이라는 관점은 모듈의 기능적 관심사와 분리되어야 한다. 로깅과 지속성과 같은 관점들을 비즈니스 로직에서 분리하는 것은 관점 지향 프로그래밍 패러다임의 핵심이다.
(출처 : https://en.wikipedia.org/wiki/Aspect_(computer_programming)
여기서 말하는 관점은 다음과 같습니다.
1. 관점은 프로그램의 핵심 관심사(core conerns)를 교차(cross-cut)하므로 캡슐화하기 어렵게 한다.
2. 관점은 기능적 관심사(functional concerns)와 분리되어야 한다.
이에 대해 더 이해하기 쉽도록 예시를 들어 설명하겠습니다.
위와 같은 이쑤시개가 꽂힌 햄버거를 만드는 경우를 상상해보겠습니다. 빵을 놓고, 재료들을 얹은 뒤에, 다시 빵을 덮고, 이쑤시개를 꽂아 마무리합니다.
하지만 이쑤시개가 엄청 짧다면 만드는 과정이 어떻게 될까요? 빵을 놓고, 이쑤시개를 꽂고, 야채를 얹고, 이쑤시개를 꽂고, 패티를 얹고, 이쑤시개를 꽂고, 야채를 얹고, 이쑤시개를 꽂고, 다시 빵을 덮고, 이쑤시개를 꽂아 마무리하게 됩니다.
이 때, 손님이 이쑤시개를 빼달라고 요청하게 된다면 작게 꽂힌 이쑤시개들을 하나 하나 찾아 빼는 수고를 하게 될 것입니다.
여기서 빵과 야채, 패티들은 AOP에서 말하는 핵심 관심사이며 수직으로 꽂힌 이쑤시개는 핵심 관심사와 크게 관련이 없는 Aspect입니다.
앞선 수제 햄버거 비유를 통해 여기저기 흩어진 Aspect들은 코드의 재사용성, 캡슐화를 해칠 뿐더러 유지보수성 또한 낮추게 되는 사실을 알 수 있었습니다.
이러한 관점들은 한데 모아 캡슐화하게 된다면 여러 문제들을 손쉽게 해결할 수 있습니다.
저는 게시글을 등록하거나 수정하는 기능을 처리하기 전에 해당 요청을 보낸 사용자가 로그인을 한 상태인지 확인하는 관점을 분리하도록 하겠습니다.
// PostApiController.java
@PostMapping("/api/post")
public ApiResult<?> save(@RequestBody Post.SaveRequest requestDto, Authentication authentication) {
try {
if (authentication == null || isAnonymous(authentication)) {
throw AccessDeniedException("로그인 인증 토큰과 함께 요청을 보내주세요.");
}
requestDto.setMemberId((String)authentication.getPrincipal());
return ApiUtils.success(postService.save(requestDto));
}
catch (ValidationException | EntityNotFoundException e) {
return ApiUtils.error(e.getMessage(), HttpStatus.BAD_REQUEST);
}
}
private bool isAnonymous(Authenticaion authentication) {
return authentication.getAuthorities().stream()
.map(authority -> authority.getAuthority())
.anyMatch(authority -> authority.equals("ROLE_ANONYMOUS"));
}
해당 기능의 핵심 관심사와는 무관한 로그인 확인 기능 때문에 private 메소드와 함께 예외 처리를 해주는 코드가 추가되어 핵심 관심사를 파악하기 힘든 모습입니다.
여기서 어노테이션 기반의 AOP 패러다임을 추가하겠습니다.
// PostApiController.java
@LoginCheck
@PostMapping("/api/post")
public ApiResult<?> save(@RequestBody Post.SaveRequest requestDto, Authentication authentication) {
try {
requestDto.setMemberId((String)authentication.getPrincipal());
return ApiUtils.success(postService.save(requestDto));
}
catch (ValidationException | EntityNotFoundException e) {
return ApiUtils.error(e.getMessage(), HttpStatus.BAD_REQUEST);
}
}
위의 코드는 AOP를 적용해 요청을 보낸 사용자의 로그인 여부를 확인하는 관점을 분리한 코드입니다.
위와 같이 적용하기 위해서, 우선 LoginCheck라는 커스텀 어노테이션을 작성합니다. 해당 어노테이션이 적용되는 위치는 메소드, 해당 어노테이션이 유지되는 기간은 런타임 환경까지로 지정했습니다.
// LoginCheck.java
import java.lang.annotation.*;
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginCheck {
}
@Target의 경우는 직관적으로 이해하기 쉬우니 넘어갈 수 있었지만 @Retention의 경우 어노테이션이 유지되는 기간이 왜 중요한지 의문이 들어 찾아본 결과 잘 정리된 글을 찾을 수 있었습니다.
RetentionPolicy
는 SOURCE
, CLASS
, RUNTIME
이렇게 3 종류로 나뉘며 각각의 역할은 다음의 링크를 참고하면 더 깊이 이해하실 수 있을 것이라 생각됩니다. (@Rentention 정리글, JEONG_AMATEUR, 아무 관심 없던 @Retention 어노테이션 정리(RetentionPolicy SOURCE vs CLASS vs RUNTIME))
그 다음으로 @LoginCheck
어노테이션이 붙은 메소드들을 실행하기 전에 요청을 보낸 사용자의 로그인 정보가 있는지 확인하는 관점을 처리하기 위해 Aspect 클래스를 작성했습니다.
// LoginCheckAspect.java
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.*;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import lombok.extern.log4j.Log4j2;
@Aspect
@Component
@Log4j2
public class LoginCheckAspect {
@Before("@annotation(com.kyu0.jungo.aop.LoginCheck)") // 해당 어노테이션이 붙은 메소드를 실행하기 전에 아래의 메소드를 실행한다.
public void loginCheck(JoinPoint joinPoint) throws AccessDeniedException {
log.info("Login Check in \"{}\"", joinPoint.getTarget());
// SecurityContext에 등록된 Authentication 객체를 조회한다.
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
log.info("Authentication : {}", authentication);
// 익명의 사용자가 보낸 요청이라면 예외를 발생시킨다.
if (isAnonymous(authentication)) {
throw new AccessDeniedException("로그인 인증 토큰과 함께 요청을 보내주세요.");
}
}
private boolean isAnonymous(Authentication authentication) {
return authentication.getAuthorities().stream()
.map((authority) -> authority.getAuthority())
.anyMatch((authority) -> authority.equals("ROLE_ANONYMOUS"));
}
}
AspectJ 라이브러리는 스프링 컨테이너에 의해 관리되지 않으므로 @Component를 추가해 Aspect 클래스가 관리될 수 있도록 하고 해당 클래스가 관점을 모듈화한 클래스라는 것을 나타내기 위해 @Aspect를 추가합니다. (관련글, stack-overflow)
또, 사용자가 로그인했는지 확인하는 메소드인 loginCheck(JoinPoint)
메소드를 작성한 후에 loginCheck(JoinPoint)
메소드는 @LoginCheck
어노테이션이 붙은 메소드가 실행되기 전에 먼저 실행한다는 것을 나타내기 위해 @Before("annotation(패키지명.LoginCheck)")
어노테이션을 추가해줬습니다.
마지막으로 AspectJ의 어노테이션의 처리 지원을 활성화하기 위해 최상위 클래스에 @EnableAspectJAutoProxy
어노테이션을 추가합니다.
// JungoApplication.java
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.EnableAspectJAutoProxy;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
@EnableJpaAuditing
@EnableAspectJAutoProxy(proxyTargetClass = true)
@SpringBootApplication
public class JungoApplication {
public static void main(String[] args) {
SpringApplication.run(JungoApplication.class, args);
}
}
AOP에서는 몇 가지 사용되는 용어가 있고, 앞선 예제 코드에서 사용한 AspectJ가 가지고 있는 일부 개념들이 있습니다. 이번 목차에서는 해당 용어와 개념들을 정리하도록 하겠습니다.
용어 | 설명 |
---|---|
Target | 핵심 관심사를 실행하는 코드(메소드)입니다. |
Aspect | 핵심 관심사와 무관한 관점들(ex. 로깅, 트랜잭션 등)을 모듈화한 클래스입니다. |
JoinPoint | 메소드 실행, 예외 처리 등과 같이 어플리케이션의 실행에 포함되는 특정 지점들을 말합니다. Spring AOP 에서는 항상 메소드 실행이 JoinPoint가 됩니다. |
Advice | 특정 JoinPoint에서 실행되는 작업(메소드)입니다. |
PointCut | 여러 JoinPoint 중에 어떤 JoinPoint에서 Advice가 수행되어야 하는지에 대한 표현식입니다. |
Weaving | Aspect를 다른 Object와 결합하여 Advised Proxy Object를 생성하는 작업을 말합니다. Spring AOP에서는 런타임에 Weaving이 실행됩니다. |
(참고자료, Spring AOP Example Tutorial)
JoinPoint 객체 : Aspect가 적용된 메소드에 대한 정적 정보(static information)들을 담고 있는 객체입니다. 메소드 시그니처(리턴 타입, 메소드명, 인자 등)에 대한 정보나 매개변수 정보 등을 가지고 있습니다.
Provides reflective access to both the state available at a join point and static information about it. This information is available from the body of advice using the special form thisJoinPoint. The primary use of this reflective information is for tracing and logging applications.
출처 : JoinPoint 객체에 대한 JavaDoc 주석
Advise 관련 어노테이션 : Aspect 클래스에 선언된 메소드에 선언되는 어노테이션이며 앞선 예제 코드에서는 @Before 어노테이션을 사용했습니다.
@Before, @After, @Around 등의 어노테이션이 있으며 각각 메소드 실행 전, 메소드 실행 후, 메소드 실행 전후를 시점으로 Advise를 적용할 수 있도록 도와주는 역할을 합니다.
추가로 @Before, @After 어노테이션이 적용된 메소드는 인자로 JoinPoint 객체를 받을 수 있으며 @Around 어노테이션이 적용된 메소드는 ProceedingJoinPoint 객체를 받을 수있는데, 이는 @Around Advise 실행 중 JoinPoint를 실행할 수 있어야 하기에 인자의 타입이 다른 것입니다.
// example.java
@Around("execution (*)")
public void example(ProceedingJoinPoint joinPoint) throws Throwable {
log.info("{} 메소드 실행 전", joinPoint.getSignature().getName());
joinPoint.proceed();
log.info("{} 메소드 실행 후", joinPoint.getSignature().getName());
}
핵심 기능과 관련없는 코드들이 여기저기 흩뿌려져 있는 코드들을 보면서 깔끔하게 개선할 수 있는 AOP에 대해 다뤄보고 싶어 게시글을 작성하게 되었습니다.
최대한 이해를 돕고자 비유와 예시를 들어 설명했는데 잘 전달되었으면 좋겠습니다. ㅎㅎ
잘못된 내용이나 오타 지적 언제나 환영입니다.