분산락에 대한 코드 정당성 검증

구범모·2023년 12월 11일

서론

이전 글에서 분산락에 대한 기준을 어느정도 세웠다.

이번에는 분산락을 적용한 다른 코드가 있는데, 내가 세웠던 기준에 대해 부합하여 코드를 작성했는지 검증해 보고 개선점이 있다면 그 포인트를 잡아보려 한다.

비즈니스 용어

💡 제안 : A사용자의 물건으로 B사용자의 물건에게 거래를 제안하는 것. 같은 가격대의 물건일 시 **오퍼**, 다른 가격대의 물건일 시 **찔러보기**라는 이름으로 제안 요청을 한다.

전제 조건

  1. 우리 프로젝트는 향후 다중 서버로 확장할 것을 가정한다.
  2. 다중 서버에서 A→B요청, B→A요청들이 들어올 시 block해야 한다.
  3. 단일 서버 Multi thread에서 동시에 A→B 제안 요청이 두개 들어오거나 A→B, B→A 제안요청이 들어올 시 block해야 한다.
  4. A→B요청, B→A요청이 동시에 생길 수 없다. db에서 unique key로 관리한다.

분산락 적용 코드

@Transactional
@DistributedLock(key = "#lockName")
public SuggestionResponseDTO createSuggestion(
    String token,
    String lockName,
    String suggestionType,
    SuggestionRequestDTO requestDto
) {
		...

    Card fromCard = cardRepository.findByCardIdAndUser(requestDto.fromCardId(), user)
        .orElseThrow(() -> new BaseException(ErrorCode.USER_NOT_MATCHED));

    Card toCard = cardRepository.findById(requestDto.toCardId())
        .orElseThrow(() -> new BaseException(ErrorCode.CARD_NOT_FOUND));
	
		//validation 로직
    validateCard(fromCard, toCard);

    ...

    return SuggestionResponseDTO.from(savedSuggestion);
}

위 코드의 로직을 간단히 설명하자면 다음과 같다.

  1. requestDTO는 제안을 보낸 카드Id(fromCardId), 제안을 받는 카드Id(toCardId)가 있다.
  2. @DistributedLock(커스텀 어노테이션. AOP를 이용하여 새로운 트랜잭션을 만들어서 파라미터로 받은 lockName을 fromCardId, toCardId를 key로써 이용하여 내부적으로 redisson.tryLock을 호출한다.)으로 메소드단에 락을 건다.
  3. 이때, A → B제안요청과 B → A제안요청은 같은 lockName으로 만들어지도록 처리해 두었다.
  4. 따라서 전제조건 2~4번을 다음과 같이 커버할 수 있다.
    1. 2번 : redis의 분산락을 이용한다.
    2. 3번 : 분산락 + (A→B요청, B→A요청)을 같은 키로 만들었으므로, 동시에 요청이 들어와도 하나의 요청은 block된다. 먼저 온 요청이 entity로 만들어 진 후 이후에 들어온 요청이 entity로 만들어지려 할 때, validation 로직에 의해 예외를 던진다. (결과적으로 A→B요청, B→A요청 둘다 존재할 수 없게 된다.)
    3. 4번 : 분산락에 의해 먼저 들어온 A→B요청이 메소드 lock을 먼저 잡고, 이후 들어온 A→B요청은 block된다. 먼저 온 요청이 entity로 만들어 진 후 이후에 들어온 요청이 entity로 만들어지려 할 때, validation 로직에 의해 예외를 던진다. (결과적으로 A→B요청 2개가 존재할 수 없게 된다.)

더 가벼운 방법으로 해결할 수 없을까?

나는 이전 글에서 분산락 vs Non분산락 판단 기준을 다음과 같이 세웠다.

  1. 다중 서버 환경.
  2. 메소드의 사용 순서가 중요한 상황.
    (ex : A쓰레드 실행 이후 B쓰레드 실행 결과와 B쓰레드 실행 이후 A쓰레드 실행 결과가 다를 수 있는경우. 즉, 이후에 실행될 쓰레드에게 멱등성이 보장되지 않을 경우.)
  3. 실시간 업데이트가 중요한 상황.
    로컬 캐시에 저장 이후 추후에 업데이트 해야하지 않고, 당장 메소드단에 락을 걸어서 바로 db에 업데이트를 해야 할 상황.
    (ex : 재고가 한정되어 있어 바로바로 업데이트 하여 다음 사용자가 구매할 수 있는지 여부를 표시해야 할 경우.)

여기에 제안을 생성하는 상황을 대입해 보자.

1번 : 우리 프로젝트 상황과 부합한다.

2번 : A→B요청과 B→A요청은 엄밀히 다르다. (제안 entity에서 fromCard와 toCard가 별도의 column으로 관리되기 때문에.) 따라서 사용 순서가 중요하다.

3번 : A→B요청을 실시간으로 영속화 하여, 이후 들어올 A→B요청 혹은 B→A요청을 block하여 데이터베이스 무결성을 지켜야 하기 때문에 실시간 업데이트(영속화) 또한 중요하다.

결론 : 일단 분산락 사용 기준에는 부합한다.

로컬 캐시로는 해결할 수 없을까?

전제조건 2번 상황 : 하나의 WAS에서 A→B요청을 캐싱하고 다른 WAS서버에서 A→B요청 혹은 B→A요청을 날린다고 가정해 보자. 이럴땐 못 막는다.

전제조건 3번 상황 : 하나의 WAS에서 A→B요청을 캐싱하고 동시에 A→B요청 혹은 B→A요청이 들어올 시, 캐시에 존재하는지 검사하면 되므로 막을 수 있다.

전제조건 4번 상황 : 3번 상황과 같이 막을 수 있다.

위의 방법들은 synchronized를 적용해도 해당 키워드는 각각의 서버에서만 적용되기 때문에, 결국 2번 상황을 막지 못한다.

db unique key로 해결할 수 없을까?

일단 unique key를 걸어두긴 하였다. 하지만 어플리케이션단에서 방어로직을 작성하지 않아 write작업이 실패할 때 생기는 비용(작업 롤백) vs 어플리케이션단에서 방어로직이 실행되는 비용(캐싱 확인)을 비교했을 때 어플리케이션단에서 체크하는 것이 더 이득이라고 판단을 했다.

결론

위의 내용을 종합하여, 결론적으로 이 기능에는 기존의 선택대로 분산락 적용이 맞다고 생각한다.

profile
우상향 하는 개발자

0개의 댓글