[SpringBoot] 입찰 동시성 처리하기(Redisson, AOP)

이의찬·2023년 8월 8일
0

Springboot

목록 보기
12/12

🐱 Github
https://github.com/BidderOwn/BidderOwn_BE

비더원 프로젝트를 진행하며 동시성 문제가 발생하여서 해결하기 위해 시행착오를 겪은 경험을 정리하였다.
현재는 단일 서버지만 확장성을 위해서 분산락을 사용하였다.

문제 상황

BidderOwn을 개발하며 경매이기 때문에 이전 입찰한 가격보다 더 큰 값을 제시해야한다.
만약 동시에 두 사람이 같은 가격을 제시하면 둘 다 입찰가가 제시되면 안된다.

실제로 입찰 API를 curl로 테스트해보면 같은 값이 두 개 추가된다.

//인증이 필요없는 테스트API이다
curl -X GET "http://localhost:8080/api/v1/bid/test?username=user2&price=1100" &
curl -X GET "http://localhost:8080/api/v1/bid/test?username=user3&price=1100"

아래 이미지를 보면 같은 값임에도 두 개가 추가된 것을 알 수 있다.

해결

Reddison와 Lettuce

이 문제를 해결하기 위해서 redis의 java 라이브러리인 Redisson을 사용하여 분산락을 구현하였다.
Redis를 사용한 이유는 이미 사용 중인 기술 스택이고 팀원들이 추가적으로 학습해야하는 부분이 적기때문에 선정하게 되었다.

기존에 redis 라이브러리로 Lettuce를 사용하였다. Lettuce로 락을 구현할 경우 다음과 같은 단점이 존재한다.

일단 Lettuce는 공식적으로 락을 지원하지 않기 때문에 직접 구현해야한다. 이 때 Retry나 Timeout에 대한 처리를 직접해주어야 한다.
또한 스핀락으로 구현할 경우, 해당 락을 얻기 위해 redis로 지속적인 요청을 보낸다. 그렇기 때문에 레디스에 부하가 생긴다.

반면 Redisson을 사용할 경우 공식적으로 락을 지원하고 있고 스핀락이 아닌, Pub/Sub 방식이다.
락이 해제되면 구독하고 있던 클라이언트에게 해제되었다고 발행을 해줌으로써 락을 획들할 수 있다.

이 프로젝트는 실제 서비스가 아니기때문에 기본에 사용중인 Lettuce로도 충분할 거 같다. 실제로 두 개 다 구현해보았고 이 중 Redisson으로 구현한 것을 정리하였다.

구현

build.gradle

implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.redisson:redisson-spring-boot-starter:3.17.7'

RedissonConfig

@RequiredArgsConstructor
@Slf4j
@Configuration
@EnableRedisRepositories(enableKeyspaceEvents = RedisKeyValueAdapter.EnableKeyspaceEvents.ON_STARTUP)
@DependsOn(value = {"EmbeddedRedisConfig"}) // dev 환경에서는 EmbeddedRedisConfig를 사용하고 있기때문에  의존해야 한다.
public class RedisConfig {

    @Value("${spring.redis.host}")
    private String redisHost;

    @Value("${spring.redis.port}")
    private int redisPort;

    private static final String REDISSON_HOST_PREFIX = "redis://";

    @Bean
    public RedissonClient redissonClient() { -- (1)
        Config config = new Config();
        config.useSingleServer().setAddress(REDISSON_HOST_PREFIX + redisHost + ":" + redisPort);
        return Redisson.create(config);
    }

    @Bean
    public RedissonConnectionFactory redissonConnectionFactory(RedissonClient redisson) {
        return new RedissonConnectionFactory(redisson);
    }

    @Bean
    public RedisTemplate<?, ?> redisTemplate(RedisConnectionFactory connectionFactory) { -- (2)
        RedisTemplate<?, ?> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(connectionFactory);
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
        return redisTemplate;
    }
}

(1) RedissonClient
RedissonClient를 사용하기 위해 Bean으로 등록한다.

(2) RedisTemplate
기존에 Lettuce를 RedisTemplate를 사용하였기때문에 RedisConnectionFactory만 변경함으로써 대부분 기능을 유지할 수 있었다.

Lock

@Transactional
public Bid handleBid(BidRequest bidRequest, String username) throws InterruptedException {

    RLock rLock = redissonClient.getLock("LOCK:" + bidRequest.getItemId()); // -- (1)lock 획득

    try {
        boolean available = rLock.tryLock(5L, 3L, TimeUnit.SECONDS); // -- (2)
        if (!available) throw new WrongBidPriceException(bidRequest.getItemId());

        // 락 획득 후 로직
    } catch (InterruptedException e) {
        throw new InterruptedException();
    } finally {
        try {
            rLock.unlock(); // -- (3) 
        } catch (IllegalMonitorStateException e) {
            log.info("Redisson Lock Already UnLock");
        }
    }
}

(1) 락 획득
Redisson에서는 Lock을 사용하기 위해 RLock 인터페이스를 제공한다.
"LOCK:" + bidRequest.getItemId() 이름으로 락을 가져온다.

(2) tryLock
tryLock(long waitTime, long leaseTime, TimeUnit unit);메서드를 통해서 락 획득을 시도한다.
waitTime까지 획득을 시도하고, leaseTime이 지나면 락을 해제한다.

waitTime: 락 획득을 위한 대기 시간
leaseTime: 락 임대 시간
unit 시간 단위

(3) Unlock
모든 처리가 끝난 후에 락을 해제한다.

이 코드는 락이 필요한 메서드마다 위에 코드처럼 작성해야하고 비즈니스로직과 분산락 처리 로직의 관심을 분리하기 위해 어노테이션과 aop를 적용하여 분리해보자.

Annotation 기반

메서드 위에 @DistributedLock 어노테이션을 붙이게 되면 락을 획득하고 처리후 락을 다시 해제하도록 구현하였다.

@DistributedLock

이 어노테이션에 포함된 값은 Lock 이름과 tryLock에 사용될 값이다.
key는 락이름("Lock" + {key})으로 사용된다.
나머지 TimeUnit은 waitTime과 leaseTime의 시간 단위가 된다.

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {
    // 락 이름
    String key();

    // 락 시간 단위
    TimeUnit timeUnit() default TimeUnit.SECONDS;

    // 락을 기다리는 시간
    long waitTime() default 3L;

    // 락을 획득한 이후 leaseTime 이 지나면 락을 해제
    long leaseTime() default 2L;
}

CustomSpringELParser

아래 코드를 살펴보면 .getLock({Lock key})에 값을 동적으로 넘겨야한다.
RLock rLock = redissonClient.getLock("LOCK:" + bidRequest.getItemId());

그러기 위해 SpEL 표현식으로 넘기고 이를 파싱하여 동적으로 사용하기 위해 CustomSpringELParser를 구현하여 사용한다.

이 클래스는 SpEL 표현식을 파싱하는데에 사용된다.


/**
*
* @param parameterNames 메서드의 파라미터 이름 e.g.) {"a", "b"}
* @param args 메서드의 실제 값 e.g.) 10, 20
* @param key 넘어온 값 중 파싱하고 싶은 표현식 e.g.) "#a + #b"
* @return 표현식의 값 e.g.) 30
*/
public class CustomSpringELParser {
    private CustomSpringELParser() {}

    public static Object getDynamicValue(String[] parameterNames, Object[] args, String key) { //-- (1)
        ExpressionParser parser = new SpelExpressionParser();
        StandardEvaluationContext context = new StandardEvaluationContext();

        for (int i = 0; i < parameterNames.length; i++) {
            context.setVariable(parameterNames[i], args[i]);
        } // -- (2)

        return parser.parseExpression(key).getValue(context, Object.class); // -- (3)
    }
}

(1) 파라미터

  • parameterNames: 파라미터 변수 이름
  • args: 파라미터의 실제 값
  • key: 표현식

(2) for
StandardEvaluationContext는 SpEL 파싱에 필요한 모든 컨텍스트 정보를 저장한다.
루프를 통해서 파라미터 이름과 실제 값을 같이 넣어주어서 저장한다.

(3) return
주어진 컨텍스트를 key로 파싱하여 실제 값을 리턴한다.

실제 예를 보며 이해해보자.
주어진 값은 다음과 같다.

parameterNames = {"a", "b"}
args = {10, 20}
key = "#a + #b"

getDynamicValue(parameterNames, args, key) 메서드로 넘기게 되면 다음과 같은 과정으로 값이 리턴된다.

  1. context: {a = 10, b = 20}
    실제 변수 a에 10을 넣고 b에 20을 넣는다.
  2. parser.parseExpression(key) => a + b
    파싱을 통해 #a에 10을 넣고 #b에 20을 넣음으로써 30을 리턴하게 된다.

실제 코드는 아래와 같다.

@DistributedLock(key="#bidRequest.getItemId()")
public Bid handleBid(BidRequest bidRequest, String username) {...}

위에 예처럼 표현하면,

parameterNames = {"bidRequest", "username"}
args = {bidRequest객체주소값, "user1"}
key = "#bidRequest.getItemId()"

이렇게 넘어오게 되고 결국 bidRequest.getItemId() 실제 값을 리턴한다.

DistributedLockAop

이제 aop코드를 작성해보자.

@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
public class DistributedLockAop {
    private static final String REDISSON_LOCK_PREFIX = "LOCK:";

    private final RedissonClient redissonClient;
    private final AopForTransaction aopForTransaction; // -- (1)

    @Around("@annotation(site.bidderown.server.base.aop.lock.DistributedLock)")
    public Object lock(final ProceedingJoinPoint joinPoint) throws Throwable { // -- (2) ProceedingJoinPoint
        MethodSignature signature = (MethodSignature) joinPoint.getSignature(); // 호출된 메서드 정보
        Method method = signature.getMethod();
        DistributedLock distributedLock = method.getAnnotation(DistributedLock.class); // 어노테이션 정보 가져오기

		// key = "LOCK:{상품ID}"
        String key = REDISSON_LOCK_PREFIX + CustomSpringELParser.getDynamicValue(
                signature.getParameterNames(), // 파라미터 변수 이름
                joinPoint.getArgs(), // 파라미터 변수 실제 값
                distributedLock.key() // @DistributedLock(key="값")
        ); // 넘어온 메서드 정보로 실제 key 값을 파싱

        RLock rLock = redissonClient.getLock(key); // lock 획득

        try {
            boolean available = rLock.tryLock(distributedLock.waitTime(), distributedLock.leaseTime(), distributedLock.timeUnit());
            if (!available) return false;

            return aopForTransaction.proceed(joinPoint); // -- (3)
        } catch (InterruptedException e) {
            throw new InterruptedException();
        } finally {
            try {
                rLock.unlock();
            } catch (IllegalMonitorStateException e) {
                log.info("Redisson Lock Already UnLock {} {}",
                        method.getName(), key
                );
            }
        }
    }
}

위에서 알아본 코드는 대부분 똑같고 유심히 봐야하는 코드는 key를 만드는 과정과 aopForTransaction.proceed() 이다.

(1) AopForTransaction

@Component
public class AopForTransaction {
    @Transactional
    public Object proceed(final ProceedingJoinPoint joinPoint) throws Throwable {
        return joinPoint.proceed();
    }
}

메서드에 트랜잭션을 적용시킨다. 부모 트랜잭션을 받아서 사용해도 되고, 트랜잭션 전파를 Propagation.REQUIRES_NEW로 설정하여서 단독적인 트랜잭션을 사용할 수 있다.

현재 프로젝트에서는 특별히 필요하지 않지만 언제든 트랜잭션과 관련된 설정을 따로 할 수 있기 때문에 분리하였다.

(2) ProceedingJoinPoint
ProceedingJoinPoint는 AOP에서 사용되는 인터페이스로 주로 @Around에서 사용된다. 이 인터페이스를 사용하면 메서드의 실행을 제어하거나 실행 전/후로 로직을 실행할 수 있다.

  • proceed(): 이 메서드를 호출하면 원래의 대상 메서드가 실행된다.
    (이 메서드를 호출하지 않으면 원래의 메서드가 실행되지 않는다.)

  • getArguments(): 현재 호출 중인 메서드의 argument를 가져온다.
    여기서는 BidRequest객체를 사용하기 위해 사용한다.

  • getSignature(): 실행 중인 메서드에 대한 정보를 가져온다.
    여기서는 파라미터 이름과 메서드 이름, 어노테이션 정보를 가져온다.

(3) aopForTransaction.proceed()
(1)에서 알아본 Transaction으로 ProceedingJoinPoint 객체를 넘겨서 메서드를 실행시키도록 한다.

위에서 handleBid() 메서드안에 구현했던 락과 방식 자체는 똑같다.

이제 handleBid()을 리팩토링하자.

handleBid() 메서드

@DistributedLock(key = "#bidRequest.getItemId()")
public Bid handleBid(BidRequest bidRequest, String username) {
    Item item = itemService.getItem(bidRequest.getItemId());

    if (!isBidding(item)) {
        throw new BidEndItemException(item.getId());
    }

    Integer maxPrice = bidRepository.findMaxPrice(item);
    int bidPrice = bidRequest.getItemPrice();

    if (!availableBid(item, maxPrice, bidPrice)) {
        throw new WrongBidPriceException(item.getId());
    }

    Member bidder = memberService.getMember(username);
    Optional<Bid> opBid = bidRepository.findByItemAndBidder(item, bidder);

    if (opBid.isEmpty()) {
        return create(bidPrice, item, bidder);
    }

    Bid bid = opBid.get();
    bid.updatePrice(bidPrice);

    return bid;
}

@DistributedLock 어노테이션을 붙이고 key로 Lock을 구분할 이름의 패턴을 스트링으로 넘겨주면 된다.

테스트

이제 처음에 했던 동시성 테스트를 한번 더 해보자.

curl -X GET "http://localhost:8080/api/v1/bid/test?username=user2&price=1100" &
curl -X GET "http://localhost:8080/api/v1/bid/test?username=user3&price=1100"

이전과 똑같이 curl로 테스트해보자.

정상적으로 하나만 입력된 것을 알 수 있다.

회고

AOP를 사용하여서 주요 로직과 락을 획득하는 로직을 분리하면서 스프링의 AOP 중요성과 의미를 다시 알게되었다.
또한 Lock과 직접적인 관련은 없지만 Lettuce에서 Redisson으로 리팩토링하며 Spring redis data의 높은 추상화에 감탄하였다. Redis 기능을 RedisTemplate를 사용하여서 많은 부분 구현하였는데 RedissonConnectionFactory만 변경하면 되기 때문에 코드 수정이 거의 없었다.
ExecutorService를 사용하여서 테스트 코드를 작성하였으나 정상적으로 작동을 하지 않아서 블로그에 담지 못했다. 오류를 해결되면 추가해야겠다.

1개의 댓글

comment-user-thumbnail
2023년 8월 8일

좋은 글이네요. 공유해주셔서 감사합니다.

답글 달기