SpotEditor 프로젝트 ‐ 북마크 동시성 이슈를 분산 락으로 해결하기 - dnwls16071/Backend_Study_TIL GitHub Wiki

📚 문제 상황과 이에 대한 1차 해결 방법 모색

  • 이전 포스팅
  • 장소에 대한 북마크 추가나 삭제시 장소라는 공유 자원에 접근하는 것이기 때문에 동시성 이슈가 발생할 것이라고 예측했고 이에 대해 여러가지 선택지를 비교해보았다.
- JPA 낙관적 락 : 실제로 Lock을 이용하지 않고 `@Version` 어노테이션을 이용한 버전 값을 이용함으로써 정합성을 맞추는 방법, 먼저 데이터를 읽은 후 UPDATE를 수행할 때 현재 쓰레드가 읽은 버전이 엔티티의 버전과 맞는지 확인하여 업데이트를 한다. 이 방법의 경우 버전 값에 따른 재시도 로직을 개발자가 직접 구현해야 한다.

- JPA 비관적 락 : 실제로 DB에 Lock을 걸어서 정합성을 맞추는 방법이다. 배타적 락을 걸게 되면 다른 트랜잭션에서는 Lock이 해제되기 전에 데이터를 가져갈 수 없게 된다. DB로의 직접적인 접근이 발생하고 이에 따라 데드락(2개 이상의 작업이 상대방의 작업이 끝나기만을 기다리다가 결과적으로 아무것도 완료되지 못하는 상태)의 발생 위험이 증가한다.

- Redis 분산 락 : Redis를 이용해 분산 환경에서 동시성을 제어하는 방식이다. Redisson과 같은 라이브러리를 활용하면 스핀락 방식이 아닌 pub/sub 방식으로 구현되어 있어 효율적이다. 락 획득 시도 실패시 자동으로 재시도하는 로직이 구현되어 있으며 락이 타임아웃 설정을 통해 데드락을 방지할 수 있다.
  • 프로젝트 당시 환경에서는 Redis 시스템을 구축하지 않았기 때문에 선택지에서 보류되었고 JPA의 낙관적 락과 비관적 락이 있었다. 북마크의 특성상 티켓팅과 동일한 성격의 동시성 이슈라고 보긴 어려웠고 그래서 낙관적 락을 이용해서 락을 사용하지 않고 동시성 제어를 구현하고자 했고 겉으로는 성공이 된 것처럼 보였다.
  • 허나 테스틐 코드를 돌리면서 항상 일관적인 결과를 얻기가 어려웠다. 테스트를 실행시키면 계속 무한 루프 상태로 돌아가는 모습을 목격한 적이 있었고 이를 직접 확인한 결과 아래와 같이 데드락이 발생한 것을 볼 수 있었다.
  • 받아들이기가 어려웠다. 분명 JPA의 낙관적 락은 Lock을 사용하지 않는데 왜 데드락이 발생한 것인지 이해할 수 없었다.

[ 외래키와 데드락 ]

  • 데드락이란, 둘 이상의 프로세스가 다른 프로세스가 점유하고 있는 자원을 서로 기다릴 때 무한 대기에 빠지는 상황을 말한다.
  • 이 데드락의 대표적인 예시로 식사하는 철학자를 꼽을 수 있다.
  • 외래키란, 두 테이블을 서로 연결하는 데 사용되는 키이다. 외래키가 포함된 테이블을 자식 테이블이라고 하고 외래키 값을 제공하는 테이블을 부모 테이블이라고 한다.
  • 우리 프로젝트의 장소와 북마크 엔티티를 놓고 보자면 외래키 값을 제공해주는 부모 테이블이 장소 엔티티가 되는 것이고 외래키가 포함된 자식 테이블이 북마크 엔티티가 되는 것이다.
  • 외래키는 부모 테이블이나 자식 테이블에 데이터가 있는지 체크하는 작업이 필요하므로 잠금이 여러 테이블로 전파되고 그로 인해 데드락이 발생할 수 있다.
  • 그래서 북마크를 추가하는 작업(자식 row insert)을 하게 되면 장소의 북마크 수가 증가하는 작업(부모 row update)가 발생하면서 데드락이 발생하는 것이었다.
  • 자식 테이블에 대한 쓰기 쿼리를 수행할 때 외래키 제약 조건으로 인해 부모 잠금 상태를 확인 후 문제가 없으면 쿼리를 수행하고 정합성을 유지하기 위해 부모 테이블의 해당 row를 공유 잠금한다.

[ 공유 락(Shared Lock, s-lock) ]

  • 공유 락은 읽기 락이라고도 불린다. 공유 락이 걸린 데이터에 대해서는 읽기 연산만 가능하며, 쓰기 연산은 실행이 불가능하다.
  • 공유 락이 걸린 데이터에 대해서 다른 트랜잭션도 똑같이 공유 락을 획득할 수 있으나 배타 락을 획득할 수 없다.
  • 공유 락이 걸려도 읽기 작업은 가능하고 조회한 데이터가 트랜잭션 내내 변경되지 않음을 보장한다.

[ 배타 락(Exclusive Lock, x-lock) ]

  • 배타 락은 쓰기 락이라고도 불린다. 데이터에 대해 배타 락을 획득한 트랜잭션은 읽기, 쓰기 연산을 모두 수행할 수 있다.
  • 다른 트랜잭션은 배타 락이 걸린 데이터에 대해 읽기 작업도, 쓰기 작업도 수행할 수 없다.
  • 즉, 배타 락이 걸려있다면 다른 트랜잭션은 공유 락, 배타 락, 둘 다 획득할 수가 없다.
  • 배타 락을 획득한 트랜잭션이 해당 데이터에 대한 독점권을 가진다.

📚 문제 해결을 위해 Redisson을 활용한 분산 락을 도입

  • Redis 환경을 구축했기에 Redisson을 활용한 분산 락을 도입해 해결해보기로 했다.
@Slf4j
@Component
@RequiredArgsConstructor
public class BookmarkFacade {

	private final BookmarkService bookmarkService;
	private final RedissonClient redissonClient;

	public void addBookmark(Long userId, BookmarkCommand command) {

                // placeId를 키로 사용하여 락 객체 생성
		RLock lock = redissonClient.getLock(command.placeId().toString());

		try {
                        // 락 획득 시도: 최대 10초 동안 시도, 획득 후 1초 동안 유지
			boolean b = lock.tryLock(10, 1, TimeUnit.SECONDS);

			if (!b) {
				log.warn("락 획득 실패");
				return;
			}
			bookmarkService.addBookmark(userId, command);
		} catch (InterruptedException e) {
                        // 서버 관련 정보를 노출시키지 않는 에러 코드를 반환
			throw new BookmarkException(BOOKMARK_PROCESSING_FAILED);
		} finally {
                        // 락을 해제
			lock.unlock();
		}
	}

	public void removeBookmark(Long userId, BookmarkCommand command) throws InterruptedException {

		RLock lock = redissonClient.getLock(command.placeId().toString());

		try {
			boolean b = lock.tryLock(10, 1, TimeUnit.SECONDS);

			if (!b) {
				log.warn("락 획득 실패");
				return;
			}
			bookmarkService.removeBookmark(userId, command);
		} catch (InterruptedException e) {
			throw new BookmarkException(BOOKMARK_PROCESSING_FAILED);
		} finally {
			lock.unlock();
		}
	}
}