분산 락을 이용해 동시성 이슈를 해결해보자

DaeHoon·2023년 8월 3일
0

주문번호 중복채번 이슈

object OrderNumberGenerator {
 fun generateOrderNo(): String {
    val timestamp = Instant.now().epochSecond.toString().substring(1, 10)
    val microsecond = LocalDateTime.now().format(DateTimeFormatter.ofPattern("SSSSSS")).toString()

    return timestamp + microsecond[2] + "-" + microsecond.substring(3)
  }
}
  • 위의 코드는 주문번호 채번 로직입니다. 현재 시간을 기준으로 "앞의 숫자 열자리 - 현재시간의 microsecond의 세자리"로 주문번호를 생성하고 있습니다.
  • 위와 같이 시간을 기준으로 주문번호를 생성하고 있어 확률은 낮지만 중복으로 주문번호가 들어오는 경우가 종종 생깁니다.
  • synchronized 키워드도 붙여볼까 생각했지만 단일 프로세스 환경에서만 사용할 수 있다는 한계가 있습니다. 즉 멀티 프로세스 환경에서도 동시성 이슈를 해결할 수 있는 분산 락을 생각하게 되었습니다.

Redis 의 Redisson 

  • 라이브러리 선정이유분산 락을 레디스로 구현하려고 보니 Lettuce 와 Redisson 두 개의 레디스 클라이언트가 존재하는데, 두 개의 락 사용 방식에 차이가 있습니다. 

1. Lock interface 지원

  • Lettuce로 분산 락을 사용하기 위해서는 setnx setex을 이용해 분산 락을 직접 구현해야 하고 retry, timeout 기능도 직접 구현해야 하는 번거로움이 있습니다.
  • 이에 비해 Redisson 은 별도의 Lock interface를 지원합니다. 락에 대해 타임아웃과 같은 설정을 지원하기에 락을 보다 안전하게 사용할 수 있습니다.

2. 락 획득 방식  

  • Lettuce는 분산락 구현 시 setnx, setex과 같은 명령어를 이용해 지속적으로 Redis에게 락이 해제되었는지 요청을 보내는 스핀락 방식으로 동작합니다. 요청이 많을수록 Redis가 받는 부하는 커지게 됩니다.
  • 이에 비해 Redisson은 Pub/Sub 방식을 이용하기에 락이 해제되면 락을 subscribe 하는 클라이언트는 락이 해제되었다는 신호를 받고 락 획득을 시도하게 됩니다.

코드

Configuration

Dependency

  implementation ("org.redisson:redisson-spring-boot-starter:3.23.2")
  • 의존성을 추가합니다.

Config

  @Bean
  fun redissonClient(): RedissonClient? {
    var redisson: RedissonClient? = null
    val config = Config()
    config.useSingleServer().setAddress("$REDISSON_HOST_PREFIX$host:$port")
    logger.info(config.useSingleServer().address)
    redisson = Redisson.create(config)
    return redisson
  }
  • 설정을 빈으로 등록합니다.

AOP

DistributedLockAspect

@Aspect
@Component
class DistributedLockAspect(
  private val redissonClient: RedissonClient,
  private val aopForTransaction: AopForTransaction,
): Log {
  companion object {
    private const val REDISSON_KEY_PREFIX = "RLOCK_"

  }
  @Around("@annotation(kr.co.annotation.DistributedLock)")
  fun lock(joinPoint: ProceedingJoinPoint): Any {
    val signature = joinPoint.signature as MethodSignature
    val method = signature.method
    val distributedLock = method.getAnnotation(DistributedLock::class.java)
    val key: String = REDISSON_KEY_PREFIX + getDynamicValue(
      signature.parameterNames,
      joinPoint.args,
      distributedLock.key
    )
    val rLock = redissonClient.getLock(key)  // (1)
    try {
      val available = rLock.tryLock(
        distributedLock.waitTime,
        distributedLock.leaseTime,
        distributedLock.timeUnit
      )   // (2)
      if (!available) {
        logger.info("get lock failure {}", key)
        return false
      }
      logger.info("get lock success {}", key)
      return aopForTransaction.proceed(joinPoint)  // (3)
    } catch (e: Exception) {
      Thread.currentThread().interrupt()
      throw InterruptedException()
    } finally {
      rLock.unlock()   // (4)
    }
  }
}

1) 락의 이름으로 RLock 인스턴스를 가져온다.
2) 정의된 waitTime까지 획득을 시도한다, 정의된 leaseTime이 지나면 잠금을 해제한다.
3) DistributedLock 어노테이션이 선언된 메서드를 별도의 트랜잭션으로 실행한다.
4) 종료 시 무조건 락을 해제한다.

DistributedLock Annotation

/**
 * Redisson Distributed Lock annotation
 */
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class DistributedLock(
  /**
   * 락의 이름
   */
  val key: String,
  /**
   * 락의 시간 단위
   */
  val timeUnit: TimeUnit = TimeUnit.SECONDS,
  /**
   * 락을 기다리는 시간 (default - 5s)
   * 락 획득을 위해 waitTime 만큼 대기한다
   */
  val waitTime: Long = 1L,
  /**
   * 락 임대 시간 (default - 3s)
   * 락을 획득한 이후 leaseTime 이 지나면 락을 해제한다
   */
  val leaseTime: Long = 3L,
)
  • 분산 락 로직을 AOP로 구현한 이유
    • 서비스 코드에는 비즈니스 로직만 있는게 좋고 분산 락 처리 로직이 들어 있는 것이 보기 안 좋아 분리
    • 분산 락의 추가 요구사항에 대해서 공통으로 관리하기 용이
    • 락을 얻기 위한 대기 시간, 해제 시간을 어노테이션의 파라미터로 사용이 가능

CustomSpringELParser

/**
 * Spring Expression Language Parser
 */
object CustomSpringELParser {
  fun getDynamicValue(parameterNames: Array<String>, args: Array<Any>, key: String): String? {
    val parser: ExpressionParser = SpelExpressionParser()
    val context = StandardEvaluationContext()
    for (i in parameterNames.indices) {
      context.setVariable(parameterNames[i], args[i])
    }
    return parser.parseExpression(key).getValue(context, String::class.java)
  }
}
  • 전달받은 Lock의 이름을 Spring Expression Language 로 파싱하여 읽어옵니다.

AopForTransaction

@Component
class AopForTransaction {
  @Transactional(propagation = Propagation.REQUIRES_NEW)
  fun proceed(joinPoint: ProceedingJoinPoint): Any {
    return joinPoint.proceed()
  }
}
  • @DistributedLock 이 선언된 메서드는 Propagation.REQUIRES_NEW 옵션을 지정해 부모 트랜잭션의 유무에 관계없이 별도의 트랜잭션으로 동작하게끔 설정했습니다.
  • 동시성 환경에서의 데이터의 정합성을 보장하기 위해서 락이 획득 됨 -> 트랜잭션 실행 -> 트랜잭션 커밋 -> 락 해제 순으로 동작하게 구현했습니다.

테스트

@SpringBootTest
class RedissonLockServiceTest(
  val testRedissonLockService: TestRedissonLockService,
): BehaviorSpec({
  afterContainer {
    clearAllMocks()
  }
  Given("스레드가 주어지고") {
    val numberOfThreads = 20
    val executorService = Executors.newFixedThreadPool(numberOfThreads)
    val orderNo = generateOrderNo()
    When("20명이 동시에 락을 얻으려고 요청한다.") {
      for (i in 0 until numberOfThreads) {
        executorService.submit {
          testRedissonLockService.testLock(orderNo)
        }
    }
  }
})
@Service
class TestRedissonLockService(
): Log {
  @DistributedLock(key = "#orderNo", waitTime = 0L, leaseTime = 1L)
  fun testLock(orderNo: String) {
    logger.info(orderNo)
  }
}
  • 단순히 주문번호만 호출해주는 크리티컬 섹션을 얻기 위해 20명이 동시에 요청을 보내는 상황이라고 가정해봅시다.
output
2023-08-03 15:05:48.825  INFO 3952 --- [ool-4-thread-15] kr.co.aop.DistributedLockAspect   : get lock success RLOCK_6910427482-971
2023-08-03 15:05:48.826  INFO 3952 --- [pool-4-thread-1] kr.co.aop.DistributedLockAspect   : get lock failure RLOCK_6910427482-971
2023-08-03 15:05:48.826  INFO 3952 --- [ool-4-thread-19] kr.co.aop.DistributedLockAspect   : get lock failure RLOCK_6910427482-971
2023-08-03 15:05:48.826  INFO 3952 --- [pool-4-thread-3] kr.co.aop.DistributedLockAspect   : get lock failure RLOCK_6910427482-971
2023-08-03 15:05:48.826  INFO 3952 --- [pool-4-thread-2] kr.co.aop.DistributedLockAspect   : get lock failure RLOCK_6910427482-971
2023-08-03 15:05:48.827  INFO 3952 --- [ool-4-thread-14] kr.co.aop.DistributedLockAspect   : get lock failure RLOCK_6910427482-971
2023-08-03 15:05:48.827  INFO 3952 --- [ool-4-thread-18] kr.co.aop.DistributedLockAspect   : get lock failure RLOCK_6910427482-971
2023-08-03 15:05:48.826  INFO 3952 --- [pool-4-thread-8] kr.co.aop.DistributedLockAspect   : get lock failure RLOCK_6910427482-971
2023-08-03 15:05:48.827  INFO 3952 --- [pool-4-thread-6] kr.co.aop.DistributedLockAspect   : get lock failure RLOCK_6910427482-971
2023-08-03 15:05:48.828  INFO 3952 --- [pool-4-thread-5] kr.co.aop.DistributedLockAspect   : get lock failure RLOCK_6910427482-971
2023-08-03 15:05:48.827  INFO 3952 --- [ool-4-thread-16] kr.co.aop.DistributedLockAspect   : get lock failure RLOCK_6910427482-971
2023-08-03 15:05:48.829  INFO 3952 --- [ool-4-thread-12] kr.co.aop.DistributedLockAspect   : get lock failure RLOCK_6910427482-971
2023-08-03 15:05:48.828  INFO 3952 --- [ool-4-thread-17] kr.co.aop.DistributedLockAspect   : get lock failure RLOCK_6910427482-971
2023-08-03 15:05:48.829  INFO 3952 --- [ool-4-thread-11] kr.co.aop.DistributedLockAspect   : get lock failure RLOCK_6910427482-971
2023-08-03 15:05:48.830  INFO 3952 --- [pool-4-thread-9] kr.co.aop.DistributedLockAspect   : get lock failure RLOCK_6910427482-971
2023-08-03 15:05:48.830  INFO 3952 --- [pool-4-thread-7] kr.co.aop.DistributedLockAspect   : get lock failure RLOCK_6910427482-971
2023-08-03 15:05:48.831  INFO 3952 --- [ool-4-thread-20] kr.co.aop.DistributedLockAspect   : get lock failure RLOCK_6910427482-971
2023-08-03 15:05:48.832  INFO 3952 --- [ool-4-thread-13] kr.co.aop.DistributedLockAspect   : get lock failure RLOCK_6910427482-971
2023-08-03 15:05:48.832  INFO 3952 --- [ool-4-thread-10] kr.co.aop.DistributedLockAspect   : get lock failure RLOCK_6910427482-971
2023-08-03 15:05:48.834  INFO 3952 --- [pool-4-thread-4] kr.co.aop.DistributedLockAspect   : get lock failure RLOCK_6910427482-971
2023-08-03 15:05:48.856  INFO 3952 --- [ool-4-thread-15] k.c.m.l.service.TestRedissonLockService  : 6910427482-971
  • [pool-4-thread-15] 가 락을 얻었다고 로그가 찍히고 나머지 스레드들은 락을 못 얻어서 실패 로그가 찍힙니다.
  • 이 후 [pool-4-thread-15] 주문번호를 INFO 레벨의 로그를 찍습니다.
profile
평범한 백엔드 개발자

0개의 댓글