동시성 문제와 쉐도우 복싱할 뻔한 이야기 (with. Redis)

komment·2024년 6월 15일
13

2024 개발 일지

목록 보기
2/6
post-thumbnail

서론

  동시성 문제는 실무, 사이드 프로젝트, 어디에서나 빈번하게 발생한다. 예를 들어, 쿠폰 발급 시스템을 구현할 때 우리는 동시성 문제가 발생할 것이라고 빠르게 판단할 수 있다.

  케이크크 서비스에는 좋아요 기능이 있다. 케이크에 대하여 좋아요를 누르면 cake_like 테이블에 insert 동작이 이루어지고, cake 테이블의 해당 레코드의 like_count 데이터를 업데이트(++, --) 동작을 하는데, 이 때 갱신 이상이 발생할 수 있다고 생각했다. 그렇게 Lock을 활용하여 비즈니스 로직을 구성했는데 @태용님께서 다음과 같이 리뷰를 남겨주셨다.

  다음은 Lock을 걸고 실행한 메서드다.

// LikeService.java
	@Transactional
	public void likeCake(final User user, final Long cakeId) {
		final Cake cake = cakeReader.findById(cakeId);
		final CakeLike cakeLike = cakeLikeReader.findOrNullByUserAndCake(user, cake);

		cakeLikeWriter.likeOrCancel(cakeLike, user, cake);
	}
    
// CakeLikeWriter.java
	public void likeOrCancel(final CakeShopLike cakeShopLike, final User user, final CakeShop cakeShop) {
		if (isNull(cakeShopLike)) {
			this.like(cakeShop, user);
		} else {
			this.cancelLike(cakeShopLike);
		}
	}

  코드에서 확인할 수 있듯이 메서드에 트랜잭션이 걸려있다. 그렇다면 동시에 요청이 들어왔을 때 트랜잭션이 충돌할텐데, 트랜잭션은 둘 이상의 트랜잭션이 실행되고 있을 때, 서로의 연산에 끼어들 수 없는 특성(ACID 중 Isolation)을 가지고 있다. 그럼 동시성 문제가 발생하지 않을 확률도 있지 않을까?라는 궁금증이 생겼다.

  어쩌면 나의 쉐도우 복싱 이었던걸까...?

행동1: 직접 시나리오 진행

  동시성 문제가 발생하는지 직접 SQL 명령을 통해 시나리오를 진행하기로 했다. 시나리오는 다음과 같다.

  1. Connection A: 트랜잭션 시작
  2. Connection A: 케이크 조회
  3. Connection A: 케이크의 like_count update
  4. Connection B: 트랜잭션 시작
  5. Conncetion B: 케이크 조회
  6. Conncetion B: 케이크의 like_count update

  다음은 실행한 시나리오에 대한 SQL 스크립트다.

-- Connection A
start transaction;

select * 
from cake 
where cake_id = 1;

update cake 
set like_count = like_count + 1 
where cake_id = 1;

-- Connection B
start transaction;

select * 
from cake 
where cake_id = 1;

update cake 
set like_count = like_count + 1 
where cake_id = 1;

  위와 같이 실행해보니 다음과 같은 쿼리 실행 로그를 확인할 수 있었다.

cakk> start transaction
[2024-06-11 18:20:04] completed in 32 ms
cakk> select *
      from cake
      where cake_id = 1
[2024-06-11 18:20:06] 1 row retrieved starting from 1 in 52 ms (execution: 33 ms, fetching: 19 ms)
cakk> update cake
          set like_count = like_count + 1
      where cake_id = 1
[2024-06-11 18:21:04] [40001][1205] Lock wait timeout exceeded; try restarting transaction

  마지막 로그를 보면 Lock에 의해 대기하다가 타임아웃이 발생한 것을 확인할 수 있다. 만약 중간에 Connection A에서 커밋을 한다면 Connection B의 update 쿼리는 실행된다.

  중요한 것은 갱신 분실은 발생하지 않는다는 점이다. MySql의 디폴트 격리 수준은 REPEATABLE READ인데, 이 격리 수준에서는 트랜잭션 충돌을 막기 위해 충돌 시 Lock이 발생하게 설계돼 있기 때문이다. 따라서 갱신 이상이 발생하지 않았다.

행동2: 테스트 코드를 이용한 검증

  앞선 행동1은 천천히 시나리오를 진행했다. 이번엔 실제 동작을 통해 동시성 문제가 발생하는지 검증하기 위해 테스트 코드를 작성해보았다.

    @RepeatedTest(100)
	void executeLikeCakeWithoutLock() throws InterruptedException {
		// given
		final int threadCount = 100;
		final Long cakeId = 1L;

		ExecutorService executorService = Executors.newFixedThreadPool(32);
		CountDownLatch latch = new CountDownLatch(threadCount);

		// when
		for (int i = 0; i < threadCount; i++) {
			final int index = i;

			executorService.submit(() -> {
				try {
					likeService.likeCake(userList.get(index), cakeId);
				} finally {
					latch.countDown();
				}
			});
		}

		latch.await();

		// then
		final Cake cake = cakeReader.findById(cakeId);
		assertEquals(100, cake.getLikeCount().intValue());
	}

  위와 같이 최대 32개의 Thread를 갖는 고정 크기 Thread Pool을 생성해주고, 비동기적으로 실행되는 작업들의 완료를 기다리기 위해 CountDownLatch 객체를 활용했다. @RepeatedTest(100)를 통해 해당 TC를 100번 반복한 결과는 다음과 같았다.

  단 한번도 성공하지 못했다. 시간이 오래 걸리더라도 정상 동작 할 것이라 예상했지만 결과를 보니 모든 TC가 약 10번의 동작만 성공하고, 나머지 약 90번의 동작은 실패했다. 그 이유는 바로 데드락(Deadlock) 이었다.

17:04:59.673 [pool-100-thread-4] WARN  o.h.e.jdbc.spi.SqlExceptionHelper - SQL Error: 1213, SQLState: 40001
17:04:59.673 [pool-100-thread-4] ERROR o.h.e.jdbc.spi.SqlExceptionHelper - Deadlock found when trying to get lock; try restarting transaction

  위의 로그를 보면 Lock을 얻기 위해 순환 대기하다가 Deadlock이 발생한 것을 알 수 있다. 행동1행동2에서 계속 Lock에 대한 이야기가 나오고 있는데, 먼저 S-Lock과 X-Lock에 대해 살펴보자.

Shared lock (S)

  • row-level lock
  • SELECT 위한 read lock
  • S-Lock이 걸려있는 동안 다른 트랜잭션이 해당 row에 대해 X-Lock 획득 불가능, S-Lock 획득 가능
  • 한 row에 대해 여러 트랜잭션이 S-Lock을 동시 획득 가능

Exclusive lock (X)

  • row-level lock
  • UPDATE, DELETE 위한 write lock
  • X-lock이 걸려있으면 다른 트랜잭션이 해당 row에 대해 X, S lock을 모두 획득하지 못하고 대기

(MySQL InnoDB Lock & Deadlock 관련 레퍼런스)


  그럼 이제 Sql 스크립트를 통해 어느 시점에 락이 걸리는지 천천히 살펴보자.

start transaction;

  먼저 트랜잭션을 시작할 때는 아무런 Lock이 걸리지 않는다.

select * 
from cake 
where cake_id = 1;

  REPEATABLE READ 격리 수준에서 SELECT 쿼리는 일관된 읽기를 제공하기 위해 MVCC(스냅샷 읽기라고도 불린다)를 사용한다. 따라서 해당 시점의 일관된 데이터를 제공하고, 어떠한 Lock도 걸리지 않는다.

update cake 
set like_count = like_count + 1 
where cake_id = 1;

  UPDATE 쿼리는 다른 트랜잭션이 해당 행을 읽거나 쓰는 것을 막기 위해 X-Lock을 필요로 한다. X-Lock은 트랜잭션이 커밋되거나 롤백될 때까지 유지되는데, UPDATE는 내부적으로 해당 행을 읽기 위해 S-Lock을 걸었다가, 곧바로 X-Lock으로 승격시킨다.

  만약 두 쓰레드가 동시에 UPDATE 동작을 하며 X-Lock을 얻기를 시도했는데, 쓰레드 A는 쓰레드 B의 S-Lock 때문에, 쓰레드 B는 쓰레드 A의 S-Lock 때문에, 즉 둘다 X-Lock을 얻지 못하게 된다.

  이러한 문제로 꼭! 동시성 처리가 필요하다는 결론을 내리게 되었다.

해결1: Lettuce를 활용한 Lock 구현

  동시성 문제를 처리하는 방법은 여러 가지가 있는데, 낙관적 락을 활용할 수도 있고, 비관적 락을 활용할 수도 있고, MySql에서 지원하는 Named Lock을 활용할 수도 있다. (Jpa에서 Pessimistic Lock과 Optimistic Lock을 지원하고 있다.)

  하지만 RDS에 Lock을 거는 부하와 현재 프로젝트에서 Redis를 사용하기로 결정한 것을 보았을 때, Redis를 활용하여 Lock을 구현하기로 결정했고, 그 중에서도 Spring-starter-data-redis 라이브러리에서 기본으로 제공하는 Lettuce를 먼저 사용해보기로 했다.

// LikeService.java
	@Transactional
	public void likeCake(final User user, final Long cakeId) {
		final Cake cake = cakeReader.findById(cakeId);
		final CakeLike cakeLike = cakeLikeReader.findOrNullByUserAndCake(user, cake);

		cakeLikeWriter.likeOrCancel(cakeLike, user, cake);
	}

  먼저 비즈니스 로직이다. likeOrCancel()에서 X-Lock 얻기를 시도한다. 해당 메서드의 Query들은 하나의 Transaction으로 묶인다.

// LockRedisRepository.java
	@Transactional
	public void executeWithLock(final RedisKey key, final long timeout, Runnable task) {
		final String lockName = key.getValue();

		try {
			while (!getLock(lockName, timeout)) {
				Thread.sleep(100);
			}

			task.run();
		} catch (InterruptedException e) {
			throw new CakkException(ReturnCode.LOCK_RESOURCES_ERROR);
		} finally {
			releaseLock(lockName);
        }
	}
    
    private boolean getLock(final String key, final long timeout) {
		return redisStringValueTemplate.saveIfAbsent(key, "lock", timeout, TimeUnit.MILLISECONDS);
	}

	private void releaseLock(final String key) {
		final boolean result = redisStringValueTemplate.delete(key);

		checkResult(result);
	}

  다음으로 Lock을 구현하였다. saveIfAbsent() 메서드는 Redis의 SETNX(set not exist)와 같다. 스핀락을 통해 대기하다가 Lock을 얻으면 동작 후 Lock을 해제한다. (여기서 Lock을 얻는 행위는 Redis가 싱글 쓰레드로 동작하기에 동시성 문제가 발생하지 않는다.)

// LikeService.java
	@Transactional
	public void likeCakeWithLock(final User user, final Long cakeId) {
		lockRedisRepository.executeWithLock(RedisKey.LOCK_CAKE_LIKE, 100L,
			() -> likeCake(user, cakeId)
		);
	}

  마지막으로 likeCakeWithLock() 메서드를 구성해주었다. likeCake() 메서드에 @Transactional 어노테이션이 있기 때문에 해당 메서드도 똑같이 @Transactional을 붙여 주어야 한다. 그렇게 TC를 돌려보았는데...

  @RepeatedTest(100)으로 여러 번 돌려보아도 모두 실패했다. 문제는 다음과 같다.

문제1: 트랜잭션이 끝나기 전에 Lock이 해제된다.

  트랜잭션 종료 전에 Lock이 해제되어 갱신 분실이 발생한 것이다. 그렇게 변경한 코드는 다음과 같다.

// LockRedisRepository.java
	public void executeWithLock(final RedisKey key, final long timeout, Runnable task) {
		final String lockName = key.getValue();

		try {
			while (!getLock(lockName, timeout)) {
				Thread.sleep(100);
			}

			task.run();
		} catch (InterruptedException e) {
			throw new CakkException(ReturnCode.LOCK_RESOURCES_ERROR);
		}
	}

  releaseLock() 메서드를 지우고 timeout을 활용하여 자연스럽게 Lock이 해제되도록 바꿔보았다. timeout의 값에 따라 결과가 다르지만 100ms정도 주었을 때 TC가 통과하는 것을 확인할 수 있었..지만 당연히 올바른 해결책은 아니라고 생각했다. 문제가 트랜잭션 종료 전에 Lock이 해제된 것이기 때문에 해결 또한 트랜잭션 종료 후에 Lock이 해제되도록 구현하여야 한다.

문제2: Spin Lock이 주는 부하

while (!getLock(lockName, timeout)) {
	Thread.sleep(100);
}

  별개로 또 다른 문제는 위의 코드다. Lettuce는 Lock을 얻기 위한 retry나 waitTime을 지원하지 않는다. Thread.sleep()을 활용한 스핀락(Spin Lock)은 Redis에 부하를 준다.

  위의 두 문제를 해결하기 위해 Redisson을 도입하고, Aop를 도입하기로 결정했다.

해결2: Reddison을 활용한 Lock 구현

i) Redisson을 이용한 LockRepository 구현하기

@RedisRepository
@RequiredArgsConstructor
public class LockRedisRepository {

	private final RedissonClient redissonClient;

	public Object executeWithLock(final ExecuteWithLockParam param) {
		final String lockName = param.keyAsString();
		final Supplier<Object> supplier = param.supplier();
		final RLock rLock = redissonClient.getLock(lockName);

		try {
			boolean available = rLock.tryLock(param.waitTime(), param.leaseTime(), param.timeUnit());

			if (!available) {
				return false;
			}

			return supplier.get();
		} catch (InterruptedException e) {
			throw new RuntimeException(e);
		} finally {
			rLock.unlock();
		}
	}
}

  먼저, Redisson을 활용하여 LockRedisRepository를 재구성 해주었다. 파라미터로 받는 ExecuteWithLockParam.java는 다음과 같다.

@Builder
public record ExecuteWithLockParam(
	RedisKey key,
	Supplier<Object> supplier,
	long waitTime,
	long leaseTime,
	TimeUnit timeUnit
) {

	public String keyAsString() {
		return key.getValue();
	}
}

ii) Aop를 활용한 트랜잭션 종료 이후 Lock 해제

  이제 Aop를 활용해보자. 먼저, Aop에서 트랜잭션을 분리하기 위해 다음과 같은 클래스를 구현하였다. (해당 레퍼런스를 참고하였다.)

@Component
public class AopForTransaction {

	@Transactional(propagation = Propagation.REQUIRES_NEW)
	public Object proceed(final ProceedingJoinPoint joinPoint) {
		try {
			return joinPoint.proceed();
		} catch (Throwable e) {
        	if (e instanceof CakkException) {
            	throw (CakkException) e;
            }
            
			throw new RuntimeException(e);
		}
	}
}

  @Transactional에 propagation = Propagation.REQUIRES_NEW 옵션을 지정하면 부모 트랜잭션과 상관없이 별도의 트랜잭션으로 동작하게 된다.

  다음은 분산락 관련 어노테이션이다.

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {
	
	TimeUnit timeUnit() default TimeUnit.MILLISECONDS;

	long waitTime() default 5000L;

	long leaseTime() default 3000L;
}

  Redisson에서 설정할 timeUnit, waitTime, leaseTime에 대한 데이터를 담고 있다.

  마지막으로 DistributedLockAspect.java의 로직이다.

	@Around("@annotation(com.cakk.api.annotation.DistributedLock)")
	public Object lock(final ProceedingJoinPoint joinPoint) {
		final MethodSignature signature = (MethodSignature) joinPoint.getSignature();
		final Method method = signature.getMethod();
		final DistributedLock distributedLock = method.getAnnotation(DistributedLock.class);

		final RedisKey key = RedisKey.getLockByMethodName(method.getName());

		final ExecuteWithLockParam param = ExecuteWithLockParam.builder()
			.key(key)
			.waitTime(distributedLock.waitTime())
			.leaseTime(distributedLock.leaseTime())
			.timeUnit(distributedLock.timeUnit())
			.supplier(() -> aopForTransaction.proceed(joinPoint))
			.build();

		return lockRedisRepository.executeWithLock(param);
	}

  @DistributedLock가 설정된 메서드에 대하여 동작하고, 메서드 명과 설정값들을 활용하여 Lock을 걸고 해제한다. 마지막으로 적용한 메서드와 테스트는 다음과 같다.

// LikeService.java
. . . 
	// @DistributedLock(timeUnit = TimeUnit.MILLISECONDS, waitTime = 5000L, leaseTime = 3000L)
	@DistributedLock
	public void likeCake(final User user, final Long cakeId) {
		final Cake cake = cakeReader.findById(cakeId);
		final CakeLike cakeLike = cakeLikeReader.findOrNullByUserAndCake(user, cake);

		cakeLikeWriter.likeOrCancel(cakeLike, user, cake);
	}
. . . 

// LikeConcurrencyTest.java
. . .
	@TestWithDisplayName("케이크 좋아요 동작 시, 동시성 문제가 발생하지 않는다.")
	void executeLikeCakeWithLock() throws InterruptedException {
		// given
		final int threadCount = 100;
		final Long cakeId = 1L;

		ExecutorService executorService = Executors.newFixedThreadPool(32);
		CountDownLatch latch = new CountDownLatch(threadCount);

		// when
		for (int i = 0; i < threadCount; i++) {
			final int index = i;

			executorService.submit(() -> {
				try {
					likeService.likeCake(userList.get(index), cakeId);
				} finally {
					latch.countDown();
				}
			});
		}

		latch.await();

		// then
		final Cake cake = cakeReader.findById(cakeId);
		assertEquals(100, cake.getLikeCount().intValue());
	}
. . .

  테스트 결과는 다음과 같다.

마무리

  결론적으로는 쉐도우 복싱은 아니었지만, 동시성 문제의 원인과 해결에 대해 좀 더 세심하게 살펴볼 수 있었던 좋은 경험이었다.

어쨌든 좋은 경험했고, 지식 +1 했잖아~
한잔해~


포스팅과 관련된 코드는 케이크크 서버 Github에 저장돼 있습니다.

profile
안녕하세요. 서버 개발자 komment 입니다.

0개의 댓글