[프로젝트] 동시성 이슈에 Spring AOP를 적용해보자 (With Visitor 패턴)

mingeloper·2024년 1월 4일
0

프로젝트[KOING]

목록 보기
2/7

안녕하세요
이번 포스팅에서는 저번에 해결했던 동시성 이슈에 AOP를 적용한 경험을 공유해드리겠습니다.

문제 상황

이전 포스팅 Redis를 활용한 동시성 이슈 해결 을 통해 Redis를 활용하여 동시성 이슈를 해결한 경험을 공유해드렸습니다.
하지만 동시성 이슈가 발생하는 부분이 한 군데가 아니기 때문에 중복된 코드가 발생하게 되었습니다.
이를 해결하고 싶었고 Spring AOP를 활용하기로 했습니다.

왜 Spring AOP를 선택했을까?

코드를 재사용 하기 위한 방법은 여러개가 있습니다.
기본적으로 겹치는 코드를 class나 interface의 메소드로 만들고 이를 활용하는 방법이 있습니다.
구체적으로 Template Method 방식을 활용하는 방식이 있죠.
그리고 Spring AOP를 활용해서 하는 방법까지 두가지 방법을 고민했습니다.

제 상황을 말씀 드리면,
저번에 동시성 이슈를 적용한 투어 신청 부분과 추가적으로 적용하려는 좋아요 기능의 함수가 조금씩 다른 부분이 있었습니다.
lockName을 설정해주는 부분이나, 인자로 받는 부분 등이 달랐는데요, 이 부분을 위와 같은 방식으로 처리하면 비슷한 코드들이 발생할 수 밖에 없을 것 같았고 이미 구현된 부분의 코드를 많이 수정하고 싶지 않았습니다.

무엇보다 Templete Method와 같은 방식은 핵심 로직 코드의 재사용성을 높일 때 사용하는 것이 더 옳다고 생각했고, 동시성 이슈와 같이 핵심 로직이 아닌, 부가적인 관점을 재사용할 때에는 AOP를 활용하는 것이 알맞다고 생각했습니다.

Spring AOP 적용!

그럼 본격적으로 Spring AOP를 적용해 보겠습니다.

일단 AOP에 관한 포스팅에서 알아보았듯이 Spring에서는 AOP를 활용할 수 있도록 여러가지를 만들어 놓았는데요 이를 활용하여 구현했습니다.

1. RedissonLock.class

가장 먼저 AOP를 로직을 적용할 메소드에 부착할 어노테이션 interface를 선언해주었습니다.

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RedissonLock {
    // wait time : lock 획득을 시도하고, 초과히면 lock 획득에 실패, false를 리턴.
    long waitTime() default 3L;

    // lease time : lock 획득 이후, lease time 이 지나면 lock을 해제.
    long leaseTime() default 30L;

    TimeUnit timeUnit() default TimeUnit.SECONDS;
}

waitTime과 leaseTime을 각각 default 값으로 정의해 주었는데요,
결제 로직까지 고려해 leaseTime을 좀 길게 설정해주었습니다.
저는 lock의 key값을 객체에 따라 다르게 해줄 생각으로 따로 key값을 입력받지 않았습니다만,
key 값을 입력받고 싶다면

String key() default "key";

이런식으로 key 값을 추가해줄 수 있습니다.

2. RedissonCallTransaction.class

그리고 RedissonCallTransaction class를 생성했습니다.
해당 class를 통해서 joinPoint로 들어오는 메소드를 새로운 transaction을 생성해 실행되도록 했습니다.

@Component
public class RedissonCallTransaction {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public Object proceed(final ProceedingJoinPoint joinPoint) throws Throwable {
        return joinPoint.proceed();
    }
}

이 부분은 앞선 redis 적용 포스팅에서도 언급했듯이,
transaction이 commit 되기전에 lock을 release하면 데이터의 정합성 문제로 동시성 이슈가 발생하는 것을 막기 위함입니다.
이렇게 새로운 trasaction을 생성하고 완료된 후 lock을 release 하도록 해주는 class 입니다.

3. RedissonLockAop.class

AOP 로직을 담고있는 RedissonLockAop class 입니다.

@Aspect
@Component
@RequiredArgsConstructor
@DependsOn("embeddedRedisConfig")
public class RedissonLockAop {

    private final RedissonClient redissonClient;
    private final RedissonCallTransaction redissonCallTransaction;
    private final Logger LOGGER = LoggerFactory.getLogger(RedissonLockAop.class);

    @Around("@annotation(패키지...) && args(acceptor)")
    public Object lock(final ProceedingJoinPoint joinPoint, CommandAcceptor acceptor) throws Throwable {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        RedissonLock redissonLock = method.getAnnotation(RedissonLock.class);

        LockNameVisitor lockNameVisitor = new LockNameVisitor();
        acceptor.accept(lockNameVisitor);

        String lockName = lockNameVisitor.getLockName();

        RLock rLock = redissonClient.getLock(lockName);

        try {
            System.out.println(Thread.currentThread().getName() + " " + lockName + " " + "tryLock");
            boolean available = rLock.tryLock(redissonLock.waitTime(), redissonLock.leaseTime(), redissonLock.timeUnit());
            System.out.println(Thread.currentThread().getName() + " " + lockName + " " + "getLock");
            if (!available) {
                return ErrorResponse.error(ErrorCode.NOT_ACCEPTABLE_DURING_PAYMENT_EXCEPTION);
            }

            return redissonCallTransaction.proceed(joinPoint);
        } catch (Exception e) {
            throw new InterruptedException();
        } finally {
            rLock.unlock();
            System.out.println(Thread.currentThread().getName() + " " + lockName + " " + "releaseLock");
        }
    }
}

class 맨 위에 @Aspect 어노테이션을 선언해서 해당 class가 AOP 로직을 담고있는 class라고 정의해주었습니다.
lock 메소드 위해 @Around 어드바이스를 붙여 해당 로직이 타겟 메소드 실행 전과 실행 후에 삽입될 수 있게 했습니다.
@annotation의 괄호안에 RedissonLock의 경로를 넣어주었고, args를 통해 타겟 메소드의 인자를 넣어줄 수 있습니다.

이전 포스팅에서 파사드 패턴으로 처리한 로직과 동일합니다.

위에 @DependsOn 어노테이션은 제가 embedded redis를 사용하는데, 해당 AOP가 redis가 켜지기 전에 생성되는 것을 막기위해 넣었습니다.
이 어노테이션이 없으면 redis 연결이 안되어서 오류가 발생하더군요...
생성되는 타이밍에 대한 부분이 잘 이해가 안돼서 추후 공부해보고 포스팅 해보겠습니다.

이렇게 하면 AOP로의 전환이 끝났습니다... 만
코드를 보시며 visitor를 발견한 분이 계실겁니다.

4. Visitor 패턴

이 부분은 사실 Spring AOP와는 관련이 없는 부분입니다만,
제가 당면한 문제를 해결하기 위해 고민하고 적용한 부분이라 적어보겠습니다.

아까 RedissonLock.class를 설명드리며 저는 객체에 따라 lock의 key값을 다르게 하고, AOP를 사용하는 함수 인자가 다르다고 말씀드렸습니다.
함수 인자에서 전달받은 객체 값을 사용하기에 함수 인자를 받아와야 했고 그 값으로 key값을 생성하도록 했습니다.

ui layer에서 application layer로 요청을 보낼 때 command dto를 활용하고 있는데, 투어 신청의 command와 좋아요 요청 command가 당연히 다른 class 였습니다.
따라서 메소드 오버로딩을 활용해야 하나 고민이 되었습니다.
그러면 또 중복 코드가 발생할 것이기에 상위 interface인 command를 정의하고 두 개의 command dto가 command를 implements 하도록 수정 했습니다.

하지만 command dto 마다 가져와야하는 값이 다르기에 if 절과 instanceof 를 활용하여 분기를 하게 되었습니다.
해당 코드는 확장성과 가독성을 해치기에 고민 끝에 visitor 패턴을 적용하기로 했습니다.
Visitor 패턴을 활용하면 확장에 용이하고 추가 할 때에도 기존 코드를 수정하지 않아도 되는 장점이 있습니다.
이를 통해 메소드 별로 무사히 key값을 정의하고 사용할 수 있게 되었습니다.

Visitor 패턴에 대한 자세한 설명은 블로그 링크로 대체하겠습니다.
https://velog.io/@newtownboy/%EB%94%94%EC%9E%90%EC%9D%B8%ED%8C%A8%ED%84%B4-%EB%B0%A9%EB%AC%B8%EC%9E%90%ED%8C%A8%ED%84%B4Visitor-Pattern

5. 사용


이제 이렇게 동시성 이슈를 해결해야 하는 메소드 위에 어노테이션을 붙여서 사용하시면 됩니다!


테스트!


결과는 Spring AOP를 적용하기 전과 동일합니다!



참고
profile
내일 더 성장하고 가치를 개발하는 개발자

0개의 댓글