본문 바로가기

데이터 정합성 확보(3) - 트러블슈팅

@정소민fan2025. 12. 9. 20:53

이전 포스팅에서는 아웃박스 패턴을 구현했다

구현한 코드는 당연히 테스트를 해야한다

하지만 테스트에서 자꾸 이상한 에러가 발생했었다

락 타임아웃

java.sql.SQLException: Lock wait timeout exceeded; try restarting transaction

락이 걸려 대기하다가 타임아웃이 걸려버리는 것이다

 

사실 이전의 코드에서 비관적 락이 걸려있는 코드가 있었다

@Lock(LockModeType.PESSIMISTIC_WRITE) // 비관적 락 사용
@Query(
    "select e " +
            "from OutboxEntity e " +
            "where e.status = 'PENDING' " +
            "order by e.createdAt asc " +
            "limit 10 "
)
fun getPendingList(): List<OutboxEntity>

여기에 락을 걸어둔 이유는 스케줄러에서 이 함수를 호출하기 때문인데, 스케줄러가 핸들러를 호출하고 핸들러가 핸들링을 끝내기도 전에 다시 스케줄링이 시작되어서 이 getPendingList() 함수를 호출하여 같은 엔티티를 또다시 핸들링하는 일을 막기 위해서이다. (사실 지금보니까 낙관적 락을 써도 될것같다)

 

스케줄러의 코드를 보자. getPendingList()handle() 메소드를 같이 실행한다

@Scheduled(fixedRate = 1000)
fun schedule() {
    val pendingList = outboxRepository.getPendingList()
    pendingList.forEach { outbox ->
        try {
            val handler = handlers.find { it.canHandle(outbox.aggregateType) }
            handler?.handle(outbox) ?:
            run {
                log.error { "아웃박스 스케줄러에서 적절한 핸들러를 찾지 못했습니다." }
            }
        } catch (e: Exception) {
            log.error { "메세지 처리 중 오류 발생 : id = ${outbox.id}, message = ${e.message}" }
        }
    }
}

 

핸들러에서는 새로운 트랜잭션을 시작하는 @Transactional이 걸려 있었다

(참고로 OutboxEntity의 도메인 모델이 OutboxMessage이다)

@Transactional(propagation = Propagation.REQUIRES_NEW)
override fun handle(message: OutboxMessage): OutboxMessage { 
	...
	return outboxRepository.save(message)
}

이 트랜잭션은 잘못된 트랜잭션이다. 새로운 트랜잭션에서 시작되기 때문에 이전 트랜잭션에서 락을 잡고 있으면 건드릴 수가 없다. 이 부분이 문제였다.

그리고 테스트에서도 @Transactional이 걸려 있었다. 각 테스트 메소드 간의 데이터 간섭이 없게 하기 위해서였다.

 

그래서 위 그림과 같이 문제가 생긴 것이었다. 테스트 클래스에서 먼저 TX_A가 시작된 다음, schedule() 메소드를 실행했고, outboxRepository에서 getPendingList()로 먼저 Lock을 걸고 있었고, handle()이 호출되며 TX_B가 새로 시작되면서 마지막에 outboxRepository에 save()를 시도했지만 비관적 락이 걸려있어서 기다릴 수밖에 없었기 때문이다.

 

이 문제는 handle 메소드의 트랜잭션을 없애고, 상위 메소드인 schedule에서 트랜잭션을 시작하는 것으로 문제를 해결했다.

하지만 10개의 메세지를 한꺼번에 가져와서 하나의 트랜잭션에서 시작하기 때문에, 하나의 메세지만 핸들링이 실패해도 다른 9개의 메세지 핸들링이 모두 롤백되고 만다. 따라서 getPendingList에서 10개씩 가져오는 것이 아니라, 1개씩 가져와서 핸들링하도록 바꾸는 것이 좋을 것 같다.

레디스 에러

그 다음 에러는 레디스의 데이터를 읽을 때 자꾸 이상한 데이터가 읽히는 것이 문제였다.

알고보니 @Transcation은 RDB의 데이터만 롤백해주고 레디스의 데이터에는 관여하지 못한다. 사실 당연한 말인데 차마 생각하지를 못했다.

이 문제는 이전 테스트에서 레디스에 삽입된 데이터가 다른 테스트에 영향을 미치는 것이 문제였다.

그래 수동으로 레디스를 초기화해주는 코드를 작성하고, @AfterEach로 각 테스트 메소드마다 동작하도록 했다.

override fun cleanUp() {
    redisTemplate.connectionFactory?.connection?.serverCommands()?.flushDb()
}

...

@AfterEach
fun redisCleanup() {
    redisReservationOperations.cleanUp()
}

 

테스트를 꼼꼼히 작성해둔 덕에 트러블슈팅을 할 수 있었다

역시 테스트는 신이야

모두 테스트를 숭배하라

 

다음은 인덱스 튜닝으로 어떻게 성능을 개선시켰는지 써보겠다.

정소민fan
@정소민fan :: 코딩은 관성이야

코딩은 관성적으로 해야합니다 즐거운 코딩 되세요

목차