분산락 구현과 JMeter, JUnit 테스트

박태현·2025년 6월 30일
0

예약 프로젝트

목록 보기
7/8

단일 사용자의 빠른 반복 요청( JMeter )과 다중 사용자의 동시 요청( JUnit )을 통해 구현한 분산락을 통한 동시성 제어, 멱등성 처리를 통한 중복 방지가 예상한대로 잘 작동하는지 테스트 해보았습니다.

동시성 이슈


흔히 따닥 이슈라고 불리는 문제는, 사용자가 버튼을 빠르게 여러 번 클릭하면서 동일한 API 요청이 중복으로 발생하는 현상을 말합니다.

이러한 문제가 발생하면, 비즈니스 로직에 예외 처리를 해두었더라도 여러 요청이 예외 없이 모두 정상 처리되는 상황이 생길 수 있습니다.

이는 예약이 중복으로 처리되거나, 리워드가 중복 지급되는 등 심각한 문제의 원인이 됩니다.

이번 프로젝트에서 좌석 예약과 리워드 지급 로직을 다루고 있는 만큼 이에 대한 문제를 해결해보려고 합니다.

해결 방안

동시성을 제어하는 여러 방법들 중 DB단 베타 락 방식이나 애플리케이션 단 분산 락 방식 등등이 있지만 이번 프로젝트에서는 Redis를 활용한 애플리케이션 단 분산 락 방식을 사용했습니다.

그 이유는 Redis가 단일 서버 환경뿐만 아니라 분산 서버 환경에서도 락을 효과적으로 관리할 수 있고, 동시성 제어는 애플리케이션 레이어에서 처리하는 것이 유지보수에 더 효과적이라고 생각했으며, 컨트롤러나 서비스 단에서 빠르게 실패를 유도할 수 있어, 리소스 낭비를 덜 수 있을 것이라고 생각했기 때문입니다.

반면, DB 베타 락은 예외가 Repository 계층에서 발생하기 때문에, 불필요하게 로직이 모두 실행된 후에 실패를 감지하게 되어 문제가 있다고 보았습니다.

구현

Redis 분산 락은 Mutex Lock 방식을 사용하여 구현하였습니다.

Mutex Lock은 지정한 최대 대기 시간 내에 지속적으로 락을 획득할 때까지 확인하며 기다리고, 락을 획득하지 못하면 실패하도록 하여 리소스 낭비를 줄이고 빠르게 실패를 처리할 수 있도록 합니다.

  • Redis 설정 코드
    @Configuration
    class RedisConfig(
    
        @Value("\${spring.redis.host}")
        val host: String,
    
        @Value("\${spring.redis.port}")
        val port: Int,
    ) {
    
        @Bean
        fun redisConnectionFactory(): RedisConnectionFactory {
            return LettuceConnectionFactory(host, port)
        }
    
        @Bean
        fun redisTemplate(): RedisTemplate<*, *> {
            return RedisTemplate<Any, Any>().apply {
                connectionFactory = redisConnectionFactory()
    
                keySerializer = StringRedisSerializer()
                hashKeySerializer = StringRedisSerializer()
                valueSerializer = StringRedisSerializer()
            }
        }
    }

  • Redis를 사용하여 락 획득, 해제를 구현하는 클래스

    @Component
    class LockManager(
    	private val redisTemplate: RedisTemplate<String, String>
    ) {
    
    	// setIfAbsent : Redis의 SETNX 명령 → 해당 key가 Redis에 없을 때만 값을 설정
    	// 키가 이미 존재하면 false, 없으면 true를 반환, 예외가 발생하면 null 반환, TTL은 3초로
    	// 뮤텍스 방식으로 구현
    	fun tryMutexLock(
    		key: String, maxWaitMillis: Long = 3000, retryDelayMillis: Long = 100
    	): Boolean {
    		val start = System.currentTimeMillis()
    
    		// 최대 3초까지 wait 하다가 lock을 얻지 못하면 false 반환
    		while (System.currentTimeMillis() - start < maxWaitMillis) {
    			val success = redisTemplate.opsForValue().setIfAbsent(key, "lock", Duration.ofSeconds(5)) == true
    
    			if (success) {
    				return true // 락 획득 성공
    			}
    
    			Thread.sleep(retryDelayMillis) // 0.1초 기다렸다가 다시 시도 ( Blocking Polling )
    		}
    
    		return false // 최대 대기 시간 초과된 것
    	}
    
    	// 락 해제
    	fun unlock(key: String): Boolean = redisTemplate.delete(key)
    }

  • 고차함수를 사용하여 특정 비즈니스 로직에 락을 걸거나, 예외를 던짐

    /*
    * 특정 비즈니스 로직을 실행하기 전 락을 획득했을 때만 실행되도록 하는 코드
    * */
    @Component
    class RedisLockUtil(
    	private val lockManager: LockManager
    ) {
    
    	init {
    		manager = lockManager
    	}
    
    	companion object {
    		private val log = LoggerFactory.getLogger(this::class.java)
    
    		private lateinit var manager: LockManager
    
    		/*
    		* 특정 비즈니스 로직에서 Lock 획득을 시도하고 획득하지 못하면 예외를 던짐
    		* */
    		fun <T> acquireLockAndRun(key: String, block: () -> T): T {
    
    			if (key.isBlank()) {
    				log.error("[RedisLockError] key is blank")
    				return block()
    			}
    
    			// lock 획득 시도
    			val acquired = acquiredLock(key)
    
    			return if (acquired) {
    				proceedWithLock(key, block)
    			} else {
    				throw ReserveException(HttpStatus.CONFLICT, ErrorCode.REDIS_FAILED_TO_ACQUIRED_LOCK)
    			}
    		}
    
    		/*
    		* Lock 획득을 시도
    		* */
    		private fun acquiredLock(key: String): Boolean {
    			return try {
    				val isAquired = manager.tryMutexLock(key)
    	
    				if (isAquired) log.info("Lock 획득 성공")
    	
    				isAquired
    			} catch (e: Exception) {
    				log.error("[RedisLockError] failed to acquire lock. key: $key", e)
    					false
    			}
    		}
    
    		/*
    		* 특정 비즈니스 로직을 실행하고, 실행이 끝나면 Lock을 해제
    		* */
    		private fun <T> proceedWithLock(key: String, block: () -> T): T {
    			return try {
    				block()
    			} catch (e: Exception) {
    				throw e
    			} finally {
    				releaseLock(key)
    			}
    		}
    
    		/*
    		* 획득한 Lock 해제
    		* */
    		private fun releaseLock(key: String): Boolean {
    			return try {
    				val isRelease = manager.unlock(key)
    
    				if (isRelease) log.info("Lock 반환 성공")
    
    				isRelease
    			} catch (e: Exception) {
    				log.error("[RedisLockError] failed to unlock. key: $key", e)
    				false
    			}
    		}
    	}
    }

테스트1 - JMeter 테스트 ( 특정 한 사용자의 따닥 이슈 체크 )

이번 좌석 예약 로직에는 멱등성을 보장하기 위한 API 처리와, 동시성 문제를 해결하기 위한 Redis 기반의 뮤텍스락 로직을 구현하였습니다.

이러한 기능들이 실제로 잘 작동하는지 확인하기 위해 JMeter를 활용한 테스트를 진행하려고 합니다.

  • 요청이 정상적으로 락을 획득했는지
  • 락이 적용된 상태에서 비즈니스 로직이 실행되었는지
  • 멱등성 응답이 일관되게 반환되는지

먼저, 멱등 키를 기반으로 만료되지 않은 요청 이력이 존재하는지 확인

만약 유효한 요청 이력이 존재하면, 해당 이력의 응답을 그대로 반환합니다.

이력이 없거나 만료되었다면, 외부 클라이언트를 호출하여 요청을 처리합니다.

  • 호출에 성공한 경우, 요청을 정상 수행하고 성공 이력을 저장합니다.
  • 호출에 실패한 경우, 실패 이력을 저장한 후 예외를 반환합니다.

따라서, 성공한 동일 요청에 대해서는 그 응답을 그대로 재사용하고, 실패한 동일 요청에 대해서도 동일한 응답을 반환함으로써 멱등성이 유지됩니다.

JMeter 설정


  • 동일한 예약 요청이 여러 번 오도록 Thread의 수를 설정
  • 헤더 값과 Body 값 설정

테스트 결과


성공적인 요청에 대한 API 테스트

  • 동시에 동일한 예약 요청이 두 번 온 경우
    첫 번째 요청에서는 예약이 정상적으로 처리되고, 두 번째 요청은 첫 번째 요청의 응답이 그대로 반환되는 것을 확인할 수 있습니다.

  • 동시에 예약 요청이 세 번 이상 온 경우
    동시 요청 수를 늘려 동일한 예약 요청을 세 번 이상 동시에 수행해본 결과 역시, 첫 번째 요청을 제외한 나머지 요청은 첫 번째 요청의 응답을 그대로 반환하는 것을 볼 수 있습니다.

실패한 요청에 대한 API 테스트

  • 동시에 동일한 실패 예약 요청이 두 번 온 경우
  • 동시에 동일한 실패 예약 요청이 세 번 이상 온 경우 ( 에러 발생 )

    **❌ 여러 개의 동시 요청 중 멱등 키 중복 저장 오류가 발생 ❌	**
    ![](https://velog.velcdn.com/images/ayeah77/post/2a0d522f-6e43-4b8c-a70b-29019baf5f62/image.png)
    
    
    로직의 흐름이 `예약 로직 실행 → 멱등 키 저장` 순서로 이루어져 있고, Redis 분산 락이 예약 로직에만 적용되어 있기 때문
    1. 요청 A가 들어옴 → Redis 락 획득

    2. A가 예약 로직 실행 중 ( doReserveSeats ) → 이 시점에는 다른 요청은 락을 못 잡음

    3. A의 예약 로직 종료 → 락 해제됨

    4. 요청 B가 그 직후 들어옴 → 락 획득 성공 → 하지만 좌석은 이미 예약됨 상태

    5. 요청 A는 이제 멱등성 키 저장 시도 → 성공

    6. 요청 B도 실패 응답을 가지고 멱등성 키 저장 시도 → 중복 키 예외( Duplicate Key ) 발생

      이 문제를 해결하려면, 예약 로직과 멱등 키 저장 로직을 하나의 흐름으로 묶어 Redis 분산 락의 적용 범위 안에서 함께 처리해야 합니다.

      // 기존 예약 로직만 분산락을 적용하는 코드
      return idempotencyManager.execute(
          key = idempotencyKey,
          url = "/seat/reserve",
          method = "POST",
      ) {
              RedisLockUtil.acquireLockAndRun(
                  "${member.username}:${reservationRequest.screenInfoId}:doReserve"
              ){
                      doReserveSeats(reservationRequest, member)
                  }
          }
      
      ↓↓↓↓↓ 멱등키 저장 로직도 분산락의 적용 범위에 들어가도록 아래와 같이 변경
      
      return RedisLockUtil.acquireLockAndRun(
          "${member.username}:${reservationRequest.screenInfoId}:doReserve"
      ) { idempotencyManager.execute(
                  key = idempotencyKey,
                  url = "/seat/reserve",
                  method = "POST",
              ) { doReserveSeats(reservationRequest, member) }
          }




테스트2 - JUnit 테스트 ( 여러 사용자가 동시 예약 )


@SpringBootTest
class ReserveTest {

	@Autowired
	private lateinit var reserveService: ReserveService

	@Autowired
	private lateinit var seatRepository: SeatRepository

	@Autowired
	private lateinit var jwtUtil: JwtUtil

	@Test
	fun `여러 사용자가 동시에 좌석 예약을 시도하면 하나만 성공`() {

		// given
		val threadCount = 5
		val executor = Executors.newFixedThreadPool(threadCount)
		val latch = CountDownLatch(threadCount)
	
		val screenInfoId = 2L
		val seatNumber = "D1"
		
		val tokenList = (1..threadCount).map { 
			jwtUtil.createToken(
				username = "@JTest0$it",
				name = "@JTest0$it",
				email = "@JTest0$it",
				role = "0",
				category = "access",
				expired = 600_000L,
			)
		}
	
		val request = ReserveRequest(
			screenInfoId = screenInfoId,
			seats = listOf(seatNumber),
			rewardDiscount = 0
		)
	
		// when
		repeat(threadCount) { index ->
			executor.submit {
				try {
					val idempotencyKey = UUID.randomUUID().toString()
					reserveService.reserveSeats(request, tokenList[index], idempotencyKey)
				} catch (e: ReserveException) {
					throw e
				} finally {
					latch.countDown()
				}
			}
		}
	
		latch.await()
	
		// then
		val reservedSeat = seatRepository.findByScreenInfoAndSeatNumber(screenInfoId, seatNumber)
		assertEquals(true, reservedSeat?.is_reserved)
	
		val reservedUsername = reservedSeat?.reserveInfo?.member?.username
		println("좌석 예약자: $reservedUsername")
	}
}

  • 사전 준비
    threadCount 수 만큼 “@Test0x”의 member 생성해놓기

  • Executors.newFixedThreadPool( threadCount )
    고정된 수의 스레드 풀 생성
    threadCount만큼의 스레드를 한꺼번에 실행 가능한 풀로 생성하여, 여러 요청을 동시에 실행할 수 있도록 함

  • CountDownLatch( threadCount )
    스레드가 모두 끝날 때까지 대기하는 동기화 도구
    스레드가 작업을 마칠 때마다 latch.countDown()을 호출하고, 메인 테스트 스레드는 latch.await()에서 대기
    latch.await()을 사용하지 않으면 워커 스레드의 예약 요청이 끝나기 전에 이후 코드가 실행되어버려 검증 코드가 먼저 실행되기에 원하는 검증이 이루어지지 않을 수 있습니다.


❌❌ 이렇게 코드를 작성하고 실행해보면 → 한 예약 요청에 대해 모든 사용자가 예약이 되어버림

예약 요청한 좌석이 예약 가능한 상태인지 확인하는 메서드

fun findAndValidateSeat(screenInfoId: Long?, seatNumber: String, member: Member): Seat {
	val seat = seatRepository.findByScreenInfoAndSeatNumber(screenInfoId, seatNumber)
		?: throw ReserveException(HttpStatus.BAD_REQUEST, ErrorCode.NOT_EXIST_SEAT_INFO)

	if (seat.is_reserved == true) {
		log.info {"좌석 중복 예약 시도 - 사용자: ${member.username}, 좌석: $seatNumber"}
		throw ReserveException(HttpStatus.CONFLICT, ErrorCode.SEAT_ALREADY_RESERVED)
	}

	return seat
}

위와 같이 특정 좌석에 대해 예약 가능한 상태인지 체크하는 로직이 있음에도 불구하고 동시성 환경에서는 모든 요청이 동시에 동일한 좌석을 비어 있다고 인식하여 예외가 발생하지 않고 seat.is_reserved == false 조건을 모두 통과 해버리기 때문에 5명의 사용자 모두 예약 요청이 성공하게 됩니다.

왜 위와 같은 문제가 생겼을까 ?, 어떻게 해결해야 할까 ?

fun reserveSeats(reserveRequest: ReserveRequest, token: String, idempotencyKey: String): ResponseEntity<String> {

	val username = jwtUtil.getUsername(token)

	val member = memberRepository.findByUsername(username)
		?: throw ReserveException(HttpStatus.BAD_REQUEST, ErrorCode.NOT_EXIST_MEMBER_INFO)

	return RedisLockUtil.acquireLockAndRun("${member.username}:${reserveRequest.screenInfoId}:doReserve")
	{ idempotencyManager.execute(
			key = idempotencyKey,
			url = "/seat/reserve",
			method = "POST",
		) { doReserveSeats(reserveRequest, member) }
	}
}
  • 원인
    락을 획득할 때 사용자 이름( member.username )을 기준으로 분산락 키를 생성했기 때문에, 각 사용자마다 서로 다른 락이 생성되어 여러 사용자가 동시에 seat.is_reserved == false 조건을 통과하면서 중복 예약이 발생하게 됩니다.

  • 해결
    분산락 키를 예약 요청 단위( reservationNumber ) 또는 좌석 식별자 기준으로 변경하여 하나의 좌석 또는 하나의 요청에 대해 오직 하나의 트랜잭션만 진입 가능하도록 변경해야 합니다.

return RedisLockUtil.acquireLockAndRun("${member.username}:${reserveRequest.screenInfoId}:doReserve")

위 부분을 아래와 같이 변경 ↓↓↓↓

return RedisLockUtil.acquireLockAndRun("${reserveRequest.reservationNumber}:${reserveRequest.screenInfoId}:doReserve")

변경 후 테스트 성공

⇒ 예약이 성공한 사용자만 성공적으로 예약 비용이 차감되는 모습

profile
꾸준하게

0개의 댓글