Notice
Recent Posts
Recent Comments
Link
«   2024/07   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30 31
Archives
Today
Total
관리 메뉴

사부작사부작

AOP를 활용한 공통 기능 구현 본문

프로젝트

AOP를 활용한 공통 기능 구현

민철킴 2023. 5. 25. 01:23

본 글은 AOP로 기능 구현한 내용을 다루는 글입니다. 개념적인 설명이 미흡할 수 있고, 틀린 부분이 존재할 수 있습니다. 피드백 해주시면 정말 감사하겠습니다 :) 



어플리케이션 전반에 퍼져있는 공통된 부가 기능들을 관심사(Aspect)라고 한다. 우리가 널리 쓰고 있는 트랜잭션이 Aspect의 예다. 트랜잭션이 필요한 메서드마다 트랜잭션을 얻고 커밋과 롤백 등의 로직을 구현해야 한다면, 코드가 복잡해질 것이다. 이처럼 비지니스 로직과 달리 부가적으로 들어간 기능을 관심사(Aspect)라고 정의하고 이를 비지니스 로직에서 분리시킬 것이다.


Aspect Oriented Programming(AOP)
OOP는 비즈니스 로직의 중복을 제거하기 위함이고 AOP는 인프라 로직 중복을 제거하기 위함이다. 여기서 인프라 로직은 트랜잭션 로직, 로깅, 성능 측정을 예로 들 수 있다. 위의 트랜잭션의 경우처럼 인프라 로직인 많은 중복 코드를 양산할 뿐아니라, 가독성과 유지보수를 어렵게 한다. AOP를 활용해 인프라 로직의 중복을 제거함으로써 개발자는 비즈니스 로직에 집중할 수 있다.

AOP 사용하는 용어
target : 부가 기능을 부여할 대상
advice : 부가 기능을 정의한 메서드
pointcut : advice가 적용될 target을 지정하는 것을 의미

더 많은 용어들이 있지만, 이 3개의 용어를 중점으로 구현한 코드를 설명하려고 한다.

나는 처리율 제한장치와 분산락을 AOP로 구현했다. 
먼저 처리율 제한장치를 구현한 코드다.

@Aspect  
@Component  
@RequiredArgsConstructor  
public class RateLimitAspect {  
    private final RateLimiter rateLimiter;  

    @Pointcut("@annotation(org.springframework.web.bind.annotation.GetMapping) || @annotation(org.springframework.web.bind.annotation.PostMapping)")  
    public void rateLimitPointcut() {}  
  
    @Around("rateLimitPointcut()")  
    public Object rateLimitAround(ProceedingJoinPoint joinPoint) throws Throwable {  
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();  
        HttpServletRequest request = attributes.getRequest();  
        String identifier = getClientIpAddr(request);  
        String methodName = joinPoint.getSignature().getName();  
        
        if (!rateLimiter.isAllMethodAllowed(identifier,5,50)) {  
            throw new TooManyRequestException();  
        }  
        return joinPoint.proceed();  
    }  
}

@Component  
public class RateLimiter {  
  
    private final RedisTemplate<String, Integer> rateLimiterTemplate;
    
	@Autowired  
	public RateLimiter(  
            @Qualifier("rateLimiterRedisTemplateBean") RedisTemplate<String, Integer> rateLimiterTemplate  
	) {  
		this.rateLimiterTemplate = rateLimiterTemplate;  
	}
	
	public boolean isAllMethodAllowed(String identifier, int second, int limit) {  
        String key = identifier;  
        Long count = rateLimiterTemplate.opsForValue().increment(key, 1);  
        rateLimiterTemplate.expire(key, second, TimeUnit.SECONDS);  
        return count <= limit;  
    }  
}

클래스 위에 @Aspect 어노테이션으로 해당 클래스가 AOP 관련 advice와 pointcut을 정의하는 클래스임을 나타내고 있다. 

@Pointcut 어노테이션으로 부가 기능의 대상이 될 타겟 어노테이션을 설정해 주고 있다. @GetMapping, @PostMapping 이 붙은 메서드가 타겟이 되는 것이다.

@Around는 위 @Pointcut 어노테이션이 적용된 rateLimitPointcut() 메서드를 적용 대상으로 설정한다. 
return 문에 작성된joinPoint.proceed()는 대상 메서드를 실행하는 역할을 한다. 그 위에 들어가는 ratelimiter 관련 로직이 advice에 해당하는 로직이다. 처리율 제한 로직이라는 부가 기능을 구현한 것이다.

이렇게 적용을 하면, @PostMapping 또는 @GetMapping 어노테이션이 붙은 컨트롤러 메서드는 실제 메서드가 실행되기 전에 위의 adivce로직이 실행된다. 위 경우엔 같은 ip로 5초 동안 50번 넘게 요청한 경우에 429(TooManyRequest) 에러를 내며, 대상 메서드는 실행되지 않게 된다.

advice 를 적용하는 부분은 @Around, @Before, @After  가능하다. 각각은 대상 메서드 실행 전과 후에 advice 로직을 적용하는 걸로 구분이 되며, 위의 내 경우엔 대상 메서드 실행 전에만 advice 로직이 적용됨으로 @Before 를 써도 무방하다. 또한 메서드의 예외 지점에도 pointcut을 적용할 수 있다(@AfterThrowing).

@Aspect  
@Component  
@RequiredArgsConstructor  
@Slf4j  
public class LockAspect {  
  
    private final RedissonClient redissonClient;  
    private final Transaction4Aop transaction4Aop;  
  
    @Around("@annotation(com.project.book.common.config.aop.DistributedLock)")  
    public Object lock(final ProceedingJoinPoint joinPoint) throws Throwable {  
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();  
        Method method = signature.getMethod();  
        DistributedLock annotation = method.getAnnotation(DistributedLock.class);  
  
        String key = annotation.key();  
        RLock lock = redissonClient.getLock(key);  
  
        try {  
            boolean available = lock.tryLock(annotation.waitTime(), annotation.leaseTime(), annotation.timeUnit());  
  
            if (!available) {  
                return false;  
            }  
  
            return transaction4Aop.proceed(joinPoint);  
        } catch (InterruptedException | IOException e) {  
            log.error("DistributedLock error : " + e.getMessage());  
            throw new RuntimeException();  
        } finally {  
            if (lock.isHeldByCurrentThread() && lock.isLocked()) {  
                lock.unlock();  
            }  
        }  
    }  
}

이젠 분산락을 적용한 AOP 로직을 보겠다. 위의 rateLimit와 다른 부분은 따로 @Pointcut 어노테이션을 사용하지 않고, @Around에 Pointcut 표현식으로 적용될 어노테이션을 직접 입력해 줬다. 

위의 rateLimitAspect처럼 pointcut 지정 메서드를 만들어 동일한 pointcut을 참조해야 하는 경우에 재사용에 유리하게 구성해도 되고, LockAspect 클래스처럼 별도의 @Pointcut 없이 @Around 어노테이션에 직접 지정할 수도 잇따.

그리고 여기선 transaction4Aop.proceed(joinPoint); 로 대상 메서드를 호출하는데 호출 전, 후에 로직이 들어가니 @Before나 @After 가 아닌 @Around가 적절하다.

 

인텔리제이에서는 만들어준 Aspect가 어디에 적용되는지 쉽게 확인할 수 있다. 

 

 

프록시 패턴

 

Spring AOP는 프록시 패턴을 기반으로 동작한다. 부가 기능을 프록시로 감싸서 실행을 하는 방식이다. 여기서 부가 기능이 advice다. 
런타임 시에 스프링은 Aspect 클래스를 검색한다. 그리고 타겟 클래스를 감싼 프록시 객체를 생성하는데 여기엔 정의된 advice를 포함해서 프록시 객체가 만들어진다. 프록시 객체는 타겟 클래스의 모든 메서드를 포함하고 있는건 아니다. pointcut에 매칭되는 타겟 메서드에 대해서만 감싸지며, 타겟 메서드가 아니라면 프록시 객체에 포함되지 않는다. 이렇게 pointcut으로 지정된 메서드들의 요청은 프록시 객체에 먼저 전달되어, 프록시 객체는 개발자가 구현한 어드바이스 로직을 처리한 후에 타겟 객체의 메서드를 실행한다. advice가 @Before를 제외한 다른 종류인 경우, 타겟 객체의 메서드가 실행된 후에 반환값을 프록시로 전달되어 추가 로직 등의 작업을 거친 후에 클라이언트로 반환된다.

 

참고 
https://gmoon92.github.io/spring/aop/2019/01/15/aspect-oriented-programming-concept.html
https://tecoble.techcourse.co.kr/post/2021-06-25-aop-transaction/