본문 바로가기

데이터베이스

[transaction 동시성 문제⑥] 테스트

이 시리즈는 데이터베이스 동시성 문제를 겪은 경험과 그 해결책에 대한 내용이 담겨 있습니다.

 

해결-2 에서는 트랜잭션 동시성 문제를 해결하였습니다. 하지만 데드락이 발생하는 문제를 겪었습니다.

그래서 데드락-2 에서는 데드락 문제를 해결하는 여러 방법에 대해 고민해보았고, 결국 쿼리 순서를 바꿔서 해결하는 방법을 선택했습니다.

 

이번 글에서는 이 해결방법이 실제로 잘 적용되는지 확인해보겠습니다.


먼저 Cabinet 엔터티의 정의입니다. @Version 어노테이션 덕분에 (속성 값 변경시) version 값이 자동으로 +1 증가됩니다.

@Entity
@Table(name = "CABINET")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Cabinet {

	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	@Column(name = "CABINET_ID")
	private Long cabinetId;

	/**
	 * 사물함의 상태가 변경될 때 증가하는 버전입니다.
	 * <p>
	 * 동시성 문제 해결을 위한 낙관적 락을 위해 사용됩니다.
	 */
	@Version
	@Getter(AccessLevel.PROTECTED)
	private Long version = 1L;
    
    // 나머지 생략
}

 

아래는 기존의 캐비넷 대여 로직인데, 동시 실행시 데드락이 발생합니다. ↓

왜냐하면 쿼리가 insert lentHisotory -> update cabinet 순서로 실행되기 때문입니다. (이전 글에서 자세한 설명이 있습니다)

@Service
@RequiredArgsConstructor
@Transactional()
public class LentServiceImpl implements LentService {

    // 일부 생략

    @Override
    public void startLentCabinet(Long userId, Long cabinetId) {
        Date now = DateUtil.getNow();
        Cabinet cabinet = cabinetExceptionHandler.getCabinetForUpdate(cabinetId);
        User user = userExceptionHandler.getUser(userId);
        int userActiveLentCount = lentRepository.countUserActiveLent(userId);
        List<BanHistory> userActiveBanList = banHistoryRepository.findUserActiveBanList(userId);
        // 대여 가능한 유저인지 확인
        lentExceptionHandler.handlePolicyStatus(lentPolicy.verifyUserForLent(user, cabinet, userActiveLentCount, userActiveBanList));
        List<LentHistory> cabinetActiveLentHistories = lentRepository.findAllActiveLentByCabinetId(cabinetId);
        // 대여 가능한 캐비넷인지 확인
        lentExceptionHandler.handlePolicyStatus(lentPolicy.verifyCabinetForLent(cabinet, cabinetActiveLentHistories, now));
        // 대여 만료 시간 산정
        Date expiredAt = lentPolicy.generateExpirationDate(now, cabinet, cabinetActiveLentHistories);
        // lentHisotory 객체 생성
        LentHistory lentHistory = LentHistory.of(now, userId, cabinetId, expiredAt);
        // 대여 처리        
        lentRepository.save(lentHistory);
        // 캐비넷 상태 변경
        cabinet.increaseUserCountAndChangeStatus();        
    }
}

 

다수의 쓰레드를 생성하여 위 startLentService 메서드를 실행하는 테스트 코드를 작성합니다.

(동시성 문제가 발생하는지 보기 위함입니다)

@SpringBootTest()
@Transactional
class LentServiceImplTest {
    
    @Autowired
    private LentService lentService;

    @PersistenceContext
    private EntityManager em;
    
    @PersistenceUnit
    private EntityManagerFactory emf;
    
    // 일부 생략
    
    @Test @DisplayName("동시 대여 문제가 발생하는지 확인합니다")
    @Rollback(value = false)
    void concurrencyLent1() throws InterruptedException {
        // given
        List<Long> userIdList = em.createQuery("select u.userId from User u where u.name like :pattern")
                .setParameter("pattern", "normalUser%")
                .getResultList();
        Cabinet cabinet = sharedAvailableCabinet0;
        final CabinetStatus originalStatus = cabinet.getStatus();
        final Long cabinetId = cabinet.getCabinetId();
        final Integer originalCurrentUserCounter = cabinet.getCurrentUserCounter();
        // when
        int numberOfThreads = 10; // 동시에 실행할 스레드 수
        CountDownLatch latch = new CountDownLatch(numberOfThreads);
        AtomicInteger exceptionCount = new AtomicInteger(0);
        
        // 10개의 쓰레드들이 동시에 lentService.startLentCabinet 메서드를 실행
        for (int i = 0; i < numberOfThreads; i++) {
            // final 변수 생성
            final Long finalUserId = userIdList.get(i); 
            // 쓰레드들이 서로 다른 (데이터베이스와 연결된) 커넥션을 가짐
            new Thread(() -> {
                try {
                    lentService.startLentCabinet(finalUserId, cabinetId);
                } catch (Exception e) {
                    System.out.println("e = " + e);
                    boolean isOptimisticLockException = e instanceof OptimisticLockException;
                    boolean isServiceException = e instanceof ServiceException;
                    boolean isDataIntegrityViolationException = e instanceof DataIntegrityViolationException;
                    assertTrue(isOptimisticLockException || isServiceException || isDataIntegrityViolationException);
                    exceptionCount.getAndIncrement();
                } finally {
                    latch.countDown();
                }
            }).start();
        }
        // 모든 스레드가 종료될 때까지 대기
        latch.await(); 
        
        // 데이터들이 우리의 정책에 위반되지 않고 정합적으로 존재하는지 확인합니다
        // 예를들면, 사물함 대여자 수가 maxUser보다 크지는 않는지 체크합니다.
        EntityManager emForSelect = emf.createEntityManager();
        Integer counter = (Integer) emForSelect.createNativeQuery("select c.current_user_counter from cabinet as c where c.cabinet_id = :cabinetId")
                .setParameter("cabinetId", cabinetId)
                .getSingleResult();
        emForSelect.close();

        // then 실제 대여자수가 maxUser 값보다 작아야 합니다
        assertTrue(counter <= cabinet.getMaxUser());
        
        // 10개의 쓰레드들이 실행되어 DB 상태가 변경되었는데, 이를 원래 상태로 복구하는 작업
        em.createQuery("delete from LentHistory lh where lh.userId >= 23").executeUpdate();
        em.createQuery("update Cabinet c set c.status = :status, c.currentUserCounter = :originalCurrentUserCounter where c.cabinetId = :cabinetId")
                .setParameter("status", originalStatus)
                .setParameter("originalCurrentUserCounter", originalCurrentUserCounter)
                .setParameter("cabinetId", cabinetId)
                .executeUpdate();
    }
    
}

참고로 위 테스트 코드에서 2가지 부분에 설명이 필요합니다.

하나는

  • 보통 테스트코드에서 @Rollback(/* default true */)로 하지 않나? 왜 false로 주었나?

위 테스트 코드 중간부분을 보면 10개의 쓰레드를 생성하여 각각 lentService 코드를 실행하도록 해두었습니다.

테스트 코드에 @Rollback(value = true)를 붙였다고 할 때, 이 설정이 그 10개의 쓰레드들에도 적용될까요? 안 됩니다.

 

보통 테스트 코드가 서비스 코드를 감싼 형태로 실행되어서 (테스트 코드의) @Rollback 설정이 서비스 코드 실행에도 적용이 됩니다.

하지만 이는 테스트 코드와 서비스 코드가 동일 쓰레드에서 실행될 때 국한되는 얘기입니다. (출처)

(출처에서는 @Transacion에 대해 얘기하고 있지만, @Rollback에도 그대로 적용되는 얘기입니다)

 

현재 테스트 코드가 서비스 코드를 감싼 형태로 보이긴 하지만, 별개의 쓰레드에서 lentService 코드가 실행되고 있습니다.

따라서 @Rollback 설정이 lentService 실행에 적용되지 않고, 그래서 lentService의 실행결과는 모두 DB에 반영되는 것입니다.

 

별개의 쓰레드에서 실행되는 lentService 서비스 코드가 롤백이 되게 하기 위해 여러 시도를 해보았으나 실패하였습니다.

결국, 오리지날 상태를 기억해두었다가 별개의 쓰레드가 실행되어 바뀐 DB 상태를 오리지날 상태로 되돌리는 방법으로 구현했습니다. ↓

// 오리지날 상태 기억
final CabinetStatus originalStatus = cabinet.getStatus();
final Long cabinetId = cabinet.getCabinetId();
final Integer originalCurrentUserCounter = cabinet.getCurrentUserCounter();

// 별개의 쓰레드들이 lentService 코드 실행

// 쓰레드들이 종료될 때까지 대기

// 오리지날 상태로 되돌리기
em.createQuery("delete from LentHistory lh where lh.userId >= 23").executeUpdate();
em.createQuery("update Cabinet c set c.status = :status, c.currentUserCounter = :originalCurrentUserCounter
                               where c.cabinetId = :cabinetId")
                .setParameter("status", originalStatus)
                .setParameter("originalCurrentUserCounter", originalCurrentUserCounter)
                .setParameter("cabinetId", cabinetId)
                .executeUpdate();

 

나머지 하나는

  • 기존에 em (EntityManager 객체)가 있는데, 왜 추가로 emForSelect(EntityManager 객체)를 생성하나?
    그리고 왜 createNativeQuery 메서드를 사용했나?

이는 1차 캐시트랜잭션 고립수준과 관련이 있습니다.

(코드에서 필요한 부분만 보겠습니다.)

final Integer originalCurrentUserCounter = cabinet.getCurrentUserCounter();
10개의 쓰레드들이 각각 lentService 코드 실행 (DB에서 cabinet의 값을 바꿔놓음)
// 10개의 쓰레드들이 종료될 때까지 대기
counter = cabinet.getCurrentUserCounter(); //  현재 DB의 상태값을 synchronous하게 잘 들고 올까요? NO!
assertTrue(counter <= cabinet.getMaxUser());

이렇게 하면 counter 값은 실제 DB에서 synchronous하게 읽어온 값이 아닙니다.

왜냐하면 jpa는 엔터티 값을 읽어올 때 그 엔터티가 영속성 컨텍스트에서 있으면 그 값을 읽어옵니다.

(만약 그 엔터티가 영속성 컨텍스트에 없다면 2차캐시에서 찾거나, DB에 SELECT 쿼리를 날려서 값을 읽습니다.)

그렇기에 DB와 동기화되지 않는(= synchronous 하지 않는) 값을 들고 올 수 있습니다.

 

jpa에 대해 공부하면 "DB와 동기화 시키기 위해서 flush나 refresh를 사용한다" 라는 말을 들어보았습니다.

이것을 사용하면 될까요? 안 됩니다.

 

왜냐하면 mysql 디폴트 트랜잭션 고립수준은 repeatable-read인데,
이는 한 번 읽은 값은 (해당 트랜잭션에서) 몇 번을 다시 읽어도 (실제 DB에선 변경이 일어났는데도 불구) 매번 동일한 값으로 읽습니다.

따라서 위 코드상에서 초반부에 읽은 currentUsserCounter 값을 (10개의 쓰레드 실행 후에도) 그대로, 똑같이 읽어오겠네요.

 

따라서 또다른 커넥션을 생성하여, 그 커넥션으로 값을 읽어와야 현재 DB의 상태값을 synchronous하게 들고 올수 있습니다.

이를 위해 EntityManagerFactory를 DI 받고, emf.createEntityManager 메서드를 통해
새 커넥션을 가지는 emForSelect를 생성하였습니다.

 

그리고 emForSelect를 통해 값을 읽어 올 때, jpa의 2차캐시를 참조하지 않을까 하는 우려 때문에
DB에서 값을 직접 읽도록 강제하고자 createNativeQuery 메서드를 이용해 값을 읽어온 것입니다.

 

위 테스트 코드 자체에 대한 설명은 다 했습니다.

이제 테스트 코드를 실행하고 출력 로그의 일부를 보겠습니다.

(jpa의 show_sql에 의한 로그와 System.out.println("e = ", e)에 의한 로그가 섞여 있습니다..)

org.springframework.dao.CannotAcquireLockException(= 데드락 발생)이 발생함을 확인할 수 있습니다. 

 

 

이제 쿼리 순서를 바꿔서 데드락을 해결해 보겠습니다.

@Service
@RequiredArgsConstructor
@Transactional()
public class LentServiceImpl implements LentService {

    // 일부 생략

    @Override
    public void startLentCabinet(Long userId, Long cabinetId) {
        Date now = DateUtil.getNow();
        Cabinet cabinet = cabinetExceptionHandler.getCabinetForUpdate(cabinetId);
        User user = userExceptionHandler.getUser(userId);
        int userActiveLentCount = lentRepository.countUserActiveLent(userId);
        List<BanHistory> userActiveBanList = banHistoryRepository.findUserActiveBanList(userId);
        // 대여 가능한 유저인지 확인
        lentExceptionHandler.handlePolicyStatus(lentPolicy.verifyUserForLent(user, cabinet, userActiveLentCount, userActiveBanList));
        List<LentHistory> cabinetActiveLentHistories = lentRepository.findAllActiveLentByCabinetId(cabinetId);
        // 대여 가능한 캐비넷인지 확인
        lentExceptionHandler.handlePolicyStatus(lentPolicy.verifyCabinetForLent(cabinet, cabinetActiveLentHistories, now));
        // 캐비넷 상태 변경
        cabinet.increaseUserCountAndChangeStatus();
        // flush를 통해 현재 시점까지의 create, update, delete 작업이 DB에 바로 반영됨
        entityManager.flush();
        // 대여 만료 시간 산정
        Date expiredAt = lentPolicy.generateExpirationDate(now, cabinet, cabinetActiveLentHistories);
        // lentHisotory 객체 생성
        LentHistory lentHistory = LentHistory.of(now, userId, cabinetId, expiredAt);
        // 대여 처리        
        lentRepository.save(lentHistory);       
    }
}

위에서 쿼리가 update cabinet  -> insert lentHisotory 순서로 실행되도록 바꿨습니다. ↑

 

참고로 위 변경된 코드에서 1가지 설명이 필요한 부분이 있습니다.

  • entityManager.flush(); 왜 썼나?

이는 hibernate가 쿼리를 날리는 순서와 관련이 있습니다. 스택오버플로우스프링 독스를 보면 아래 사실을 알 수 있습니다.

Execute all SQL (and second-level cache updates) in a special order
so that foreign-key constraints cannot be violated:
1. Inserts, in the order they were performed
2. Updates
3. Deletion of collection elements
4. Insertion of collection elements
5. Deletes, in the order they were performed

jpa는 update 메서드가 따로 없고, 엔터티의 속성을 변경하면 dirty-check라는 기능 덕에 자동으로 update 쿼리가 실행됩니다.

편한 기능이지만, update 쿼리가 insert 쿼리보다 먼저 실행되게 하고 싶은 우리에겐 불편한 기능입니다.

(왜냐하면 위 우선순위에 따르면 update 쿼리가 insert 보다 항상 뒤늦게 실행되니까요.)

update 메서드가 있다면 그것을 통해 쿼리 순서를 지정할 수 있겠는데, update 메서드가 없으니 그 방법은 안 되겠네요.

 

따라서 cabinet 속성값을 변경한 이후lentRepository.save() 가 호출되기 전 그 사이에서 flush를 해줍니다.

flush는 호출시점의 (생성 혹은 삭제 혹은 변경된) 엔터티들의 상태를 DB에 반영하는 기능을 수행합니다.
따라서 이렇게 하여 update 쿼리가 insert 쿼리보다 먼저 실행되게 할 수 있는 것입니다.

 

위의 테스트 코드를 다시 실행해 봅시다.

(jpa의 show_sql에 의한 로그와 System.out.println("e = ", e)에 의한 로그가 섞여 있습니다..)

일단 assertTrue(counter <= cabinet.getMaxUser());  테스트는 통과하였습니다. (동시성 문제 해결)

 

그리고 이제 더 이상 org.springframework.dao.CannotAcquireLockException 예외는 발생하지 않습니다. (데드락 문제 해결)

대신 javax.persistence.OptimisticLockException 예외가 발생함을 확인할 수 있습니다. (낙관적 락에서 롤백을 위해 발생하는 예외)

(이는 @Version 어노테이션 때문에 발생하는 예외입니다.)
(UPDATE cabinet SET version = :version + 1 WHERE version = :version 에서 변화된 쿼리가 0개이면 발생시킵니다.)


드디어 동시성 문제데드락 문제를 모두 해결하였습니다.