티스토리 뷰

문제 상황


이전 글에서 Redisson을 사용해 분산 락을 구축했습니다. 이후 테스트를 통해 동시성 문제가 해결된 것을 확인할 수 있었습니다. 하지만, 다른 서비스에서 해당 메소드를 호출한 경우 동시성 문제가 재발되었습니다. 아래와 같은 상황입니다.

 

TripMemberService에서는 트랜잭션을 시작하고, TripMemberCreateService의 create 메소드를 호출합니다. TripMemberCreateService에서는 분산 락을 획득하고, 여행 참여 인원을 1 증가시킨 뒤 여행 멤버를 생성합니다. 

@Service
@RequiredArgsConstructor
public class TripMemberService {

    private final TripMemberCreateService tripMemberCreateService;

    @Transactional
    public void create(Long tripId, Long memberId) throws InterruptedException {
        tripMemberCreateService.create(tripId, memberId);
    }
}
@Service
@RequiredArgsConstructor
public class TripMemberCreateService {
    private final TripQueryService tripQueryService;
    private final MemberQueryService memberQueryService;
    private final RedissonClient redissonClient;
    
    public void create(Long tripId, Long memberId) {
        String key = "LOCK-TRIPMEMBER-CREATE-" + tripId;
        RLock lock = redissonClient.getLock(key);

        try {
            boolean availableLock = lock.tryLock(5, 3, TimeUnit.SECONDS);

            if (!availableLock) {
                System.out.println("LOCK 획득 실패");
                return;
            }
            createTripMember(tripId, memberId);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }

    private void createTripMember(Long tripId, Long memberId) {
        Trip trip = tripQueryService.findById(tripId);
        Member member = memberQueryService.findById(memberId);
        validateJoinedAble(trip, member);
        trip.join(member);
    }
}

 

그리고, 10명의 사용자가 동시에 여행 참여 요청을 보내는 경우에 대해 테스트했습니다. 테스트 실행 후 여행 참여 인원 수는 10이 되어야 합니다. 하지만, 아래와 같이 동시성 문제가 발생했습니다. 

 

원인을 분석하기 위해 각 스레드에 대해 LOCK 획득 시, LOCK 해지 시 로그를 찍고, 각 스레드에서 조회한 여행 참여 인원 수에 대해 출력해봤습니다. 그 결과, 아래와 같이 각 스레드에서 조회한 여행 참여 인원 수가 같은 것을 확인할 수 있었습니다. 앞의 숫자는 스레드 ID 입니다.

 

 

문제 원인 분석


문제의 원인은 트랜잭션 커밋 시점에 있었습니다. Spring Transacitonal의 기본 전파 속성은 REQUIRED입니다. 이는 부모 트랜잭션이 존재한다면 부모 트랜잭션에 합류하고, 그렇지 않다면 새로운 트랜잭션을 만듭니다. 따라서, TripMemberCreateService의 create 메소드는 이를 호출한 TripMemberService의 트랜잭션에 합류하게 됩니다. 그렇다면, 트랜잭션과 동시성 문제의 재발은 어떠한 연관이 있을까요?  

 

1. 락의 해제 시점이 트랜잭션 커밋 시점보다 빠른 경우

 

Thread A  Thread B
Transaction A 시작 Transaction B 시작
Lock 획득 Lock 획득 대기
여행 참여 인원 수 조회 -> currentCount = 4
여행 참여 인원 수 증가 -> currentCount = 5
Lock 해제
  Lock 획득 (Transaction A Commit 전)
Transaction A Commit 여행 참여 인원 수 조회 -> currentCount = 4
여행 참여 인원 수 증가 -> currentCount = 5
Lock 해제
  Transaction B Commit

 

1. Thread A, Thread B 두 스레드가 여행 참여를 위해 메소드에 동시에 접근한다.

2. Thread A가 간발의 차이로 락을 선점하고 여행 참여 인원 수를 조회한다.

3. Thread A는 여행 참여 인원 수를 1 증가하고, 락을 해제한다. (이때 트랜잭션은 커밋되지 않은 상태)

4. Thread B는 락이 해제되었다는 신호를 받고 락을 획득하여 여행 참여 인원 수 조회한다.

5. Thread A에서 여행 참여 인원 수를 증가했지만, 아직 Thread A의 트랜잭션이 커밋되지 않았으므로

    Thread B는 여행 참여 인원 수를 4로 조회한다.

6. Thread B는 여행 참여 인원을 1 증가하고, 락을 해제한 뒤 커밋한다. (DB에는 4+1=5로 여행 참여 인원 수 반영)

 

두 스레드 중 Thread A가 먼저 락을 획득해 여행 참여 인원 수를 업데이트 했지만, 트랜잭션을 커밋하기 전 락을 해제했습니다. 이로 인해 업데이트 값이 DB에 반영되지 않았고, 이후 락을 획득한 Thread B는 여행 참여 인원 수를 4로 조회한 뒤 값을 1 증가시켰습니다. 이렇듯 락의 해제가 트랜잭션 커밋보다 먼저 이뤄지면 업데이트된 값이 DB에 반영되지 않아 데이터 정합성이 깨질 수 있습니다.

 

 

2. 트랜잭션 커밋 이후 락을 해제하는 경우

 

Thread A Thread B
Lock 획득 Lock 획득 대기
Transaction A 시작
여행 참여 인원 수 조회 -> currentCount = 4
여행 참여 인원 수 증가 -> currentCount = 5
Transaction A Commit
Lock 해제
  Lock 획득
Transaction B 시작
여행 참여 인원 수 조회 -> currentCount = 5
여행 참여 인원 수 조회 -> currentCount = 6
Transaction B Commit
Lock 해제

 

1. Thread A, Thread B 두 스레드가 여행 참여를 위해 메소드에 동시에 접근한다.

2. Thread A가 간발의 차이로 락을 선점하고 여행 참여 인원 수를 조회한다. (인원 수 = 4)

3. Thread A는 여행 참여 인원 수를 1 증가하고, 트랜잭션 커밋 후 락을 해제한다. (DB 인원 수 = 5)

4. Thread B는 락이 해제되었다는 신호를 받고 락을 획득한 뒤 여행 참여 인원 수 조회한다. (인원 수 = 5)

5. Thread B는 여행 참여 인원 수를 1 증가하고, 트랜잭션 커밋 후 락을 해제한다. (DB 인원 수 = 6)

 

두 스레드가 동시에 접근한 경우에도 여행 참여 인원 수가 정상적으로 증가하게 됩니다. 트랜잭션 커밋 후 락을 해제함으로써 이후 락을 획득한 스레드에서 DB에 업데이트된 데이터를 읽어올 수 있게 되는 것입니다. 트랜잭션 커밋 후 락을 해제해 동시성 환경에서도 데이터 정합성을 보장할 수 있습니다. 그럼 어떻게 트랜잭션 커밋 후 락을 해제할 수 있을까요?

 

 

문제 해결 


트랜잭션 커밋 이후 락을 해제하는 방법은 간단합니다. 공유 자원을 사용하는 트랜잭션과 락을 획득하고 해제하는 트랜잭션을 분리하면 됩니다. @Transactional(propagation = Propagation.REQUIRES_NEW)을 사용해 트랜잭션을 분리할 수 있습니다. 트랜잭션을 분리하면 락을 획득한 뒤 새로운 트랜잭션 내에서 공유 자원을 업데이트하고 트랜잭션을 커밋합니다. 그 후, 락을 해제합니다. 이를 통해 공유 자원 의 정합성을 보장할 수 있습니다. 이를 구현한 코드는 아래와 같습니다.

@Service
@RequiredArgsConstructor
public class TripMemberFacade {

    private final TripMemberCreateService tripMemberCreateService;
    private final RedissonClient redissonClient;

    @Transactional
    public void create(Long tripId, Long memberId) throws InterruptedException {
        String key = "LOCK-TRIPMEMBER-CREATE-" + trip.getId();
        RLock lock = redissonClient.getLock(key);

        try {
            boolean availableLock = lock.tryLock(5, 3, TimeUnit.SECONDS);

            if (!availableLock) {
                System.out.println("LOCK 획득 실패");
                return;
            }
            tripMemberCreateService.create(tripId, memberId);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }
}
@Service
@RequiredArgsConstructor
public class TripMemberCreateService {

    private final TripQueryService tripQueryService;
    private final MemberQueryService memberQueryService;
    
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void create(Long tripId, Long memberId) {
        Trip trip = tripQueryService.findById(tripId);
        Member member = memberQueryService.findById(memberId);
        validateJoinedAble(trip, member);
        trip.join(member);
    }
}

 

추가로, 고려해야할 사항이 한가지 더 있습니다. 데이터베이스 커넥션(DBCP) 부족으로 인한 데드락 문제입니다. 트랜잭션을 분리했기 때문에 새로운 데이터베이스 커넥션이 필요합니다. 즉, 여행 요청에 대해 2개의 데이터베이스 커넥션이 필요하게 됩니다. Spring의 HikariCP 기본값은 10입니다. 만약 동시 요청이 10개가 발생한 경우, TripMemberService에서 커넥션 10개를 모두 사용합니다. 그 후,  TripMemberCreateService의 REQUIRES_NEW 트랜잭션 전파 속성으로 인해 새로운 커넥션을 받으려고 할 것입니다. 

 

이는, Thread간 Connection을 차지하기 위한 Race Condition이 발생한 상태입니다. 이로 인해 HikariCP에 대한 Thread간 Dead lock이 발생하게 됩니다. 이를 해결하기 위해 HikariCP의 maximum pool size를 적절히 설정해주어야 합니다.  HikariCP maximum pool size 공식 참고 

 

HikariCP wiki의 Dead lock을 피할 수 있는 Maximum pool size 공식을 사용해 커넥션 풀 사이즈를 설정해보겠습니다. 현재 테스트에서는 스레드를 10개 사용할 것이기 때문에 HikariCP Maximum pool size를 11로 설정했습니다. (10 x (2-1) + 1 )

(Tn : 전체 Thread 갯수, Cm : 하나의 Task에서 동시에 필요한 Connection 수)

pool size = Tn x (Cm - 1) + 1
spring:
  datasource:
    hikari:
      maximum-pool-size: 11

 

테스트 결과, 아래와 같이 동시성 문제가 해결된 것을 확인할 수 있습니다. 락과 관련된 트랜잭션과 공유자원에 접근하는 트랜잭션을 분리함으로써 데이터의 정합성을 보장해 동시성 문제를 해결했습니다. 

 

 

마무리하며


트랜잭션의 전파 속성을 통해 락과 관련된 로직과 공유자원에 접근하는 로직의 트랜잭션을 분리함으로써 동시성 재발 문제를 해결했습니다. 또한, 트랜잭션 분리는 데이터베이스 커넥션이 필요한 작업이고, 하나의 작업에서 2개의 커넥션을 사용하는 것은 곧 HikariCP에 대한 Thread간 Dead lock으로 이어질 수 있다는 것을 알게되었습니다. 현재 설정한 HikariCP의 maximum pool size는 Dead lock을 피하기 위한 최소 커넥션 풀 사이즈입니다. 추후 성능 테스트를 통해 최적의 HikariCP 사이즈를 찾아볼 예정입니다.

 

References

Spring Redisson 분산 락 

https://helloworld.kurly.com/blog/distributed-redisson-lock/#4-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%8B%9C%EB%82%98%EB%A6%AC%EC%98%A4%EB%A5%BC-%EA%B2%80%EC%A6%9D%ED%95%B4-%EB%B3%B4%EC%9E%90

https://cl8d.tistory.com/112

https://developer-nyong.tistory.com/76

https://incheol-jung.gitbook.io/docs/q-and-a/spring/redisson-trylock

https://hyperconnect.github.io/2019/11/15/redis-distributed-lock-1.html

https://velog.io/@znftm97/%EB%8F%99%EC%8B%9C%EC%84%B1-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0-V3-%EB%B6%84%EC%82%B0-DB-%ED%99%98%EA%B2%BD%EC%97%90%EC%84%9C-%EB%B6%84%EC%82%B0-%EB%9D%BDDistributed-Lock-%ED%99%9C%EC%9A%A9

 

HikariCP
https://techblog.woowahan.com/2664/

https://techblog.woowahan.com/2663/

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/10   »
1 2 3 4 5
6 7 8 9 10 11 12
13 14 15 16 17 18 19
20 21 22 23 24 25 26
27 28 29 30 31
글 보관함