본문 바로가기
Spring-Boot

Spring 테스트에서 @Transactional과 비관적 락 사용 시 주의점

by 준형코딩 2024. 10. 14.

 

 

오늘은 Spring 테스트 코드를 통해서 방 입장 동시성 문제를 해결한 비관적 락 코드를 테스트하면서 발생한 문제에 대해서 다뤄보겠습니다.

문제 상황

Spring에서 데이터베이스 테스트를 작성할 때, 우리는 종종 @Transactional 어노테이션을 사용합니다. 이 어노테이션은 각 테스트 메서드를 하나의 트랜잭션으로 감싸고, 테스트가 끝나면 자동으로 롤백 하여 데이터베이스를 초기 상태로 되돌립니다.

이번 테스트에서는 비관적 락을 통해 5명 제한인 방에 10명이 동시에 입장을 시도하면  5명이 방에 참여를 하고 5명은 참여하지 못하는 것을 테스트 시나리오로 수립하였습니다. 그러나 생각했던 시나리오와는 달리 계속해서 lock timeout이 발생하였습니다.

 

원인을 파악하기전에 작성한 코드를 먼저 살펴보겠습니다.

 

테스트 코드

 @Transactional
 @Test
    void testConcurrentRoomJoining() throws InterruptedException {
        ExecutorService executorService = Executors.newFixedThreadPool(CONCURRENT_USERS);
        CountDownLatch latch = new CountDownLatch(CONCURRENT_USERS);
        AtomicInteger successfulJoins = new AtomicInteger(0);
        AtomicInteger failedJoins = new AtomicInteger(0);

        for (int i = 0; i < CONCURRENT_USERS; i++) {
            final int userId = i + 1;
            executorService.submit(() -> {
                try {
                    CustomOAuth2User oAuth2User = new CustomOAuth2User(
                            new OAuth2UserResponse("User" + userId, "User" + userId, "ROLE_USER"));
                    roomService.joinRoom(testRoom.getId(), oAuth2User);
                    successfulJoins.incrementAndGet();
                } catch (Exception e) {
                    failedJoins.incrementAndGet();
                    exceptions.add(e);
                } finally {
                    latch.countDown();
                }
            });
        }

        latch.await(30, TimeUnit.SECONDS);
        executorService.shutdown();
        executorService.awaitTermination(1, TimeUnit.MINUTES);
        
        assertEquals(MAX_PARTICIPANTS, successfulJoins.get(), "성공적으로 참가한 사용자 수가 최대 참가자 수와 일치해야 합니다.");
        assertEquals(CONCURRENT_USERS - MAX_PARTICIPANTS, failedJoins.get(), "실패한 참가 시도 수가 예상과 일치해야 합니다.");

 

 

방 입장 함수

    @Transactional
    public RoomResponse joinRoom(Long id, CustomOAuth2User oAuth2User) {

        Room room = roomRepository.findByIdWithLock(id).orElseThrow(() -> new BadRequestException(NOT_FOUND_ROOM_ID));

        User user = userRepository.findBySocialLoginId(oAuth2User.getName())
                .orElseThrow(() -> new BadRequestException(NOT_FOUND_USER_ID));
        
        if (room.isAleadyParticipant(oAuth2User.getName())) {
            throw new BadRequestException(ALREADY_EXIST_USER);
        }

        if (room.isExceedMaxParticipants()) {
            throw new BadRequestException(EXCEED_MAX_PARTICIPANTS);
        }

        if (room.isBlackUser(user)) {
            throw new BadRequestException(BLACKED_USER);
        }

        Participant participant = new Participant(user, room, 0L);

        return RoomResponse.from(room.addParticipant(participant));
    }

 

 

비관적 락 적용 부분

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @QueryHints({@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000")})
    @Query("SELECT r FROM Room r WHERE r.id = :id")
    Optional<Room> findByIdWithLock(@Param("id") Long id);

 

문제의 원인

  1. 트랜잭션 경계: @Transactional은 테스트 메서드 전체를 하나의 트랜잭션으로 감쌉니다.
  2. 프록시 동작: Spring은 @Transactional이 붙은 메서드를 프록시로 감싸 트랜잭션을 관리합니다.
  3. 락 획득과 해제: 비관적 락은 트랜잭션이 커밋되거나 롤백될 때 해제됩니다.

 

위 테스트 코드를 보면 테스트 코드에 Transactional 어노테이션이 적용되어 있는 것을 보실 수 있습니다. 일반적인 테스트에서 데이터를 롤백 시키기 위해서 Transactional 어노테이션을 적용시키지만 비관적 락을 테스트하는 상황에서 Transcational을 사용하게 되면 Test 내에서 10개의 비관적 락이 동시에 발생하게 되는데 이 락은 test 코드를 감싼 Transcational을 기준으로 동작하기 때문에 테스트가 종료될 때까지 락이 풀리지 않는 문제가 발생한 것이었습니다. 결국 다른 모든 스레드들이 계속 대기하다가 락 타임아웃이 발생하게 되었습니다.

 

구체적인 문제 시나리오

  1. 락 타임아웃 발생: 테스트 메서드가 하나의 큰 트랜잭션으로 실행되므로, 첫 번째 스레드가 획득한 락이 테스트 종료 시까지 해제되지 않습니다. 이로 인해 다른 스레드들은 계속 대기하다가 락 타임아웃이 발생할 수 있습니다.
  2. 데드락 위험 증가: 여러 스레드가 동시에 락을 획득하려고 시도하지만, 실제로는 하나의 트랜잭션 내에서 동작하므로 데드락 발생 가능성이 높아집니다.
  3. 실제 동작과의 차이: 운영 환경에서는 각 요청이 독립적인 트랜잭션으로 처리되지만, 테스트 환경에서는 하나의 트랜잭션으로 묶이므로 실제 동작과 차이가 발생합니다.
  4. 테스트 결과의 신뢰성 저하: 위의 이유들로 인해, 테스트 결과가 실제 운영 환경의 동작을 정확히 반영하지 못할 수 있습니다.

해결 방안

  1. @Transactional 제거: 테스트 메서드에서 @Transactional을 제거하고, 필요한 경우 수동으로 트랜잭션을 관리합니다.
  2. 프로그래밍적 트랜잭션 관리: TransactionTemplate을 사용하여 각 스레드 내에서 개별적으로 트랜잭션을 관리합니다.
  3. 격리된 테스트 데이터: 각 테스트가 독립적인 데이터셋을 사용하도록 설정하여 @Transactional 없이도 테스트 간 영향을 최소화합니다.
  4. 통합 테스트 분리: 비관적 락과 관련된 테스트는 별도의 통합 테스트 suite로 분리하여 실제 환경과 유사한 조건에서 실행합니다.

결론

Spring 테스트에서 @Transactional과 비관적 락을 함께 사용할 때는 주의가 필요합니다. 테스트 환경이 실제 운영 환경의 동작을 정확히 반영하지 못할 수 있으므로, 위에서 제시한 해결 방안들을 고려하여 테스트 전략을 수립해야 합니다. 이를 통해 더 신뢰성 있는 테스트 결과를 얻을 수 있으며, 실제 운영 환경에서 발생할 수 있는 문제들을 사전에 발견하고 해결할 수 있습니다.