SpotEditor 프로젝트 ‐ @Transactional 어노테이션과 동시성 테스트의 문제점 및 해결방법과 한계 - dnwls16071/Backend_Study_TIL GitHub Wiki

📚 문제 상황

  • 장소 북마크 추가 API 및 삭제 API 동시성 문제를 확인하기 위해 테스트 코드를 작성했다. 사실 서비스 레이어 코드를 작성하면서 아무런 생각없이 @Transactional 어노테이션을 붙였는데 이것이 화근이 되어 동시성 문제를 정확히 체크할 수 없는 문제가 발생한 것이다.
  • 해결 과정을 들여다보기 전에 @Transactional 어노테이션을 붙이게 되면 어떤 식으로 동작을 하는지 알아보자.

@Transactional 어노테이션을 붙이게 되면 일어나는 변화에 대해서는 참고 링크에 대해서 볼 수 있다.

  • @Transactional 어노테이션을 붙이게 되면 AOP에 의해 비즈니스 로직을 담당하는 실제 부분과 트랜잭션 처리 로직을 담당하는 프록시 부분으로 명확하게 분리가 된다.
  • 멀티 쓰레드 상황에서는 요청 쓰레드마다 트랜잭션이 생겨나게 된다.
  • @Transactional은 실제 서비스를 호출하면서 서비스 로직을 실행하게 되고 만약 성공하면 커밋, 실패하면 롤백이 된다.
  • 각 트랜잭션은 격리성이라는 특징에 의해서 커밋되지 않은 데이터에 접근할 수 없기 때문에 조회에 실패하게 되는데 이 때, 실패하게 되면 롤백이 된다.
  • 이 롤백 동작으로 인해서 데이터가 제대로 반영되지 않아 다른 쓰레드가 동일한 데이터를 사용할 수 없기 때문에 문제가 발생하는 것으로 원인을 분석했다.

📚 어떻게 해결을 해봤는지?

  • @Transactional 어노테이션의 명확한 동작 방식을 알았기 때문에 @Transactional 어노테이션을 제거해주었습니다. 테스트 종료 후 수동으로 데이터를 초기화하기 위해 @AfterEach 어노테이션을 사용하여 각각의 독립된 테스트 실행을 보장해주었습니다.
@SpringBootTest            
@ActiveProfiles("test") // 프로필 지정
class BookmarkServiceTest {

	@Autowired private PlaceRepository placeRepository;
	@Autowired private UserRepository userRepository;
	@Autowired private BookmarkFacade bookmarkFacade;
	@Autowired private BookmarkRepository bookmarkRepository;

	@AfterEach // 테스트가 실행된 직후 해당 어노테이션이 붙은 메서드가 실행된다.
	void tearDown() {
		bookmarkRepository.deleteAll();    // 북마크 리포지토리 수동 제거
		placeRepository.deleteAll();       // 장소 리포지토리 수동 제거
		userRepository.deleteAll();        // 유저 리포지토리 수동 제거
	}

	@Test
	@DisplayName("여러 쓰레드가 동시에 북마크 추가 요청시 쓰레드 개수만큼 북마크 개수 역시 증가해야 한다")
	void 여러_쓰레드가_동시에_북마크_추가_요청시_쓰레드_개수만큼_북마크_개수_역시_증가해야_한다() throws InterruptedException {

		// given
		Place place = createAndSavePlace();
		List<User> users = createAndSaveUsers(100);

		// when
		int threadCount = 100;
		executeMultiThreadTest(threadCount, users, place.getId(), true);

		// then
		Place findPlace = placeRepository.findById(place.getId()).orElseThrow(() -> new PlaceException(NOT_FOUND_PLACE));
		assertThat(findPlace.getBookmark()).isEqualTo(threadCount);
	}

	@Test
	@DisplayName("여러 쓰레드가 동시에 북마크 삭제 요청시 쓰레드 개수만큼 북마크 개수 역시 감소해야 한다")
	void 여러_쓰레드가_동시에_북마크_삭제_요청시_쓰레드_개수만큼_북마크_개수_역시_감소해야_한다() throws InterruptedException {
		// given
		List<User> users = createAndSaveUsers(100);
		Place place = createAndSavePlace();

		int threadCount = 100;
		executeMultiThreadTest(threadCount, users, place.getId(), true);

		Place placeAfterAdd = placeRepository.findById(place.getId()).orElseThrow(() -> new PlaceException(NOT_FOUND_PLACE));
		assertThat(placeAfterAdd.getBookmark()).isEqualTo(threadCount);

		// when
		executeMultiThreadTest(threadCount, users, place.getId(), false);

		// then
		Place findPlace = placeRepository.findById(place.getId()).orElseThrow();
		assertThat(findPlace.getBookmark()).isEqualTo(0);
	}

	private void executeMultiThreadTest(int threadCount, List<User> users, Long placeId, boolean isAdd) throws InterruptedException {
		
                ExecutorService executorService = Executors.newFixedThreadPool(32);
		CountDownLatch latch = new CountDownLatch(threadCount);

		for (int i = 0; i < threadCount; i++) {
			final int index = i;
			executorService.submit(() -> {
				try {
					BookmarkCommand command = new BookmarkCommand(users.get(index).getId(), placeId);
					if (isAdd) {
						bookmarkFacade.addBookmark(command);
					} else {
						bookmarkFacade.removeBookmark(command);
					}
				} catch (InterruptedException e) {
					throw new RuntimeException(e);
				} finally {
					latch.countDown();
				}
			});
		}

		latch.await();
		executorService.shutdown();
	}

	private List<User> createAndSaveUsers(int count) {
		List<User> users = new ArrayList<>();
		for (int i = 0; i < count; i++) {
			User user = User.builder()
					.email("test" + i + "@example.com")
					.name("test" + i)
					.build();
			users.add(user);
		}
		return userRepository.saveAll(users);
	}

	private Place createAndSavePlace() {
		User owner = User.builder()
				.email("[email protected]")
				.name("owner")
				.build();
		userRepository.save(owner);

		Place place = Place.builder()
				.user(owner)
				.address(Address.builder()
						.address("테스트주소")
						.roadAddress("테스트도로주소")
						.latitude(37.123)
						.longitude(128.123)
						.sido("테스트시도")
						.bname("테스트법정동")
						.sigungu("테스트시군구")
						.build())
				.description("장소 설명")
				.name("장소 이름")
				.category(TOUR)
				.build();

		return placeRepository.save(place);
	}
}

📚 한계

  • 여기까지 힘들게 이해하면서 낙관적 락을 이용해서 동시성 제어를 처리한 줄 알았다. 하지만 뒤에 가서 다시 테스트 코드를 돌려본 결과 낙관적 락을 이용한 동시성 제어 테스트 과정에서 데드락이 발생한 이슈를 맞이하게 되었다. 결국 이 부분을 해결하기 위해 기존에 Master-Slave 구조로 구성한 Redis를 활용한 분산 락을 도입하여 해결해보려고 한다.
⚠️ **GitHub.com Fallback** ⚠️