- 장소 북마크 추가 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를 활용한 분산 락을 도입하여 해결해보려고 한다.