재민님 유튜브: https://www.youtube.com/watch?v=PDhN6aiF7QQ
향로님 블로그: https://jojoldu.tistory.com/761
토비님 페이스북 게시글: https://www.facebook.com/tobyilee/posts/pfbid037KmQz4TbwBfgkAXc8JjMjipMesF9iuTTWvMtUKirr3742cGfvVrq4Aft33CGmLWSl
한 때, 테스트에 @Transactional 을 붙일 것이냐 말 것이냐에 대한 논쟁이 개발자 커뮤니티에서 핫했었다.
나는 별다른 생각이 없이, 회사에서 안 쓰기도하고 변경감지 관련해서 한번 실수한 이후로 그냥 안쓰고 있었다.
근데 이게 이번에 동시성과 관련된 블로그 포스팅을 준비하면서 꽤나 삽질을 하게 될줄 몰랐다.
선요약
선 요약을 하자면, 동시성 테스트를 위해 여러 쓰레드를 만들어서 테스트를 하는 과정에서 트랜잭션이 동작하는 방식이 문제를 일으켰다.
문제 상황
대충 요즘 앱들 보면 토스에서 고양이 키우기 같은 서비스들이 많다.
거기서 한 사용자가 먹이를 주는 행위를 엄청 빠르게 하는 경우, 어떻게 동시성 문제에 대해서 어떻게 해결할까? 였다.
여러가지 방식에 대해서는 다음에 포스팅할 예정이고, 오늘은 트랜잭션 처리중 발생한 오류에 대해 집중하고자 한다.
아주 단순하게 만든 코드로, 음식의 개수 ⇒ 펫의 파워가 늘어나는 방식으로 개발을 하였다.
아래 코드는 낙관적 락과 스핀 락을 통해 처리하는 방식이다.
- 낙관적 락을 통해 로직을 수행
- 락 획득에 실패하는 경우 스핀 락을 통해 락 획득
@Component
class FeedingExecutor(
private val foodRepository: FoodRepositoryV2,
private val petRepository: PetRepositoryV2,
) {
@Transactional
fun execute(petId: Long) {
val foodEntity = foodRepository.findByPetId(petId) ?: throw RuntimeException("Food not found")
if (foodEntity.count <= 0) return;
val petEntity = petRepository.findByIdOrNull(petId) ?: throw RuntimeException("Pet not found")
foodEntity.count -= 1
petEntity.power += 1;
return
}
}
@Service
class FeedingServiceV2(
private val feedingExecutor: FeedingExecutor
) {
fun feed(
petId: Long,
) {
while (true) {
try {
feedingExecutor.execute(petId)
return;
} catch (e: ObjectOptimisticLockingFailureException) {
continue
} catch (e: Exception) {
break;
}
}
}
}
아래는 테스트 코드로 32개의 쓰레드 풀을 만들어, 1000번의 광클을 하는 상황을 가정한 것이다.
최초에 먹이는 100개가 있기 때문에, 1000번의 요청을 해도 100 만큼의 power 를 얻을 수 있다.
@SpringBootTest
@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL)
@Transactional
class FeedingServiceV2Test(
private val petRepository: PetRepositoryV2,
private val foodRepository: FoodRepositoryV2,
private val feedingService: FeedingServiceV2,
) {
var petId: Long? = null
var initialFood = 100L
@BeforeEach
fun setUp() {
val petEntity = petRepository.save(PetEntityV2(power = 0))
foodRepository.save(FoodEntityV2(petId = petEntity.id!!, count = initialFood))
petId = petEntity.id!!
}
@Test
fun `낙관적 락 적용`() {
val clickRequestCount = 1000
val latch = CountDownLatch(clickRequestCount)
val executorService = Executors.newFixedThreadPool(32)
for (i in 1..clickRequestCount) {
executorService.execute {
feedingService.feed(petId!!)
latch.countDown()
}
}
// 테스트 스레드 대기
latch.await()
val pet = petRepository.findByIdOrNull(petId!!) ?: throw RuntimeException("Pet not found")
Assertions.assertThat(pet.power).isEqualTo(initialFood)
}
}
결과는? @Transactional 어노테이션이 붙으면 항상 0으로 나온다.
왜 그렇까? 디버깅해보자
낙관적 락과 관련해서 테스트를 하다가 계속 테스트 결과가 0이 나오는 현상이 발생한다.
우선 추측이 되는 부분이 @Transactional 어노테이션 때문이었다.
해당 어노테이션을 제거하면, 테스트는 성공한다.
그렇다면, 왜 트랜잭션 어노테이션이 문제가 될까?
[ 가정 1 - 트랜잭션 전파 때문이다! ]
트랜잭션 전파 때문에, 내부에 런타임 오류를 잡는 것이 문제가 된 것이다!
참고 자료: https://techblog.woowahan.com/2606/
// ObjectOptimisticLockingFailureException 는 런타임에러다.
catch (e: ObjectOptimisticLockingFailureException) {
continue
}
[ 시도 1 - 트랜잭션 타입 변경 ]
아래처럼 트랜잭션 전파 속성을 REQUIRES_NEW 로 변경해보았다.
@Transactional(value = TxType.REQUIRES_NEW)
fun execute(petId: Long) {
val foodEntity = foodRepository.findByPetId(petId) ?: throw RuntimeException("Food not found")
if (foodEntity.count <= 0) return;
val petEntity = petRepository.findByIdOrNull(petId) ?: throw RuntimeException("Pet not found")
foodEntity.count -= 1
petEntity.power += 1;
return
}
여전히 테스트는 실패한다. 그럼 트랜잭션 전파의 문제는 아닌 것이다.
잘 생각해보면, transaction 전파는 thread local 을 이용한다.
그렇기 때문에 트랜잭션 전파는 같은 스레드에서 동작하는데, 동시성 테스트를 위해 새로운 스레드로 요청을 하는 코드에서는 별도의 스레드를 가지게 되어서, 트랜잭션 전파와는 관계가 없다.
[ 가정 2 - 코드 자체가 성공하지 못한다 ]
가정: 실제 먹이를 주는 행위 자체에 성공하지 못한다?!
아래 쪽에 로그를 한번 찍어보았다.
감소 로직이 호출이 제대로 되긴한거야?
try {
feedingExecutor.execute(petId)
logger.info("감소!")
return;
}
어노테이션 O → 안찍힘
어노테이션 X → 찍힘
실제로 감소 로직이 성공하지를 못했다.
왜 그럴까? 에러 로깅을 해보았다.
그래서 catch 문에 로그를 찍어보니 Food에 데이터가 없다.
[ 문제의 원인 ]
글을 읽는 누군가는 감이 왔을지도 모르겠다.
왜 데이터가 없을까??
DB 에서 Isolation level 을 생각해보면, READ UNCOMMITTED 를 제외하고는 커밋하지 않은 데이터를 다른 트랜잭션은 볼 수가 없다.
테스트 트랜잭션이 pet 과 food 를 저장하였으나, 아직 커밋되지 않았다.
그 상태에서 executor 가 동작하는 thread 에서 트랜잭션을 열고 조회를 하면 데이터가 없는 것이다.
만약 같은 스레드라면, 영속성 컨텍스트에 데이터가 있었겠지만 다른 스레드라서 다른 영속성 컨텍스트를 가지기 때문에 데이터를 읽을 수가 없는 것이다.
'스프링부트' 카테고리의 다른 글
AOP 활용 (feat. jpa 호출 로깅) (0) | 2024.06.06 |
---|---|
스프링 AOP - 톺아보기 (1) | 2024.06.06 |
jackson for kotlin part3. 삽질기 (feat. jacksonObjectMapper) (0) | 2024.03.10 |
jackson for kotlin part2. 커스터마이징 (0) | 2024.03.10 |
영속성 컨텍스트와 트랜잭션의 관계 (1) | 2024.02.18 |