본문 바로가기

프로그래밍/Troubleshooting

[Spring] @Transactional이 비동기(Async)를 만났을 때 벌어지는 일들

반응형

애플리케이션 성능을 위해 비동기 처리는 필수적이다.

 

비동기로 DB 작업을 처리했는데, 트랜잭션이 적용되지 않거나 롤백이 되지 않는 문제를 겪어 글을 정리하여 남기기로했다.

 

아래에 작성하는 코드들은 실제 테스트한 코드가 아니라 많이 축약한 예시코드입니다

 

잘못된 내용이 있다면 피드백 부탁드립니다.

 

1. @Transactional이 붙은 메소드 안에서 CompletableFuture로 비동기 작업을 실행 (트랜잭션이 전파되지 않음)

@Transactional
public CompletableFuture<Void> updateData() {
    return CompletableFuture.runAsync(() -> {
        repository.save(entity); // 새로운 트랜잭션 생성
    });
}

 

@Transactional 은 스레드 로컬 트랜잭션 컨텍스트를 사용한다.

 

CompletableFuture.runAsync로 생성된 새로운 스레드는 부모 스레드의 트랜잭션 컨텍스트를 상속받지 않는다.

 

그 결과 메서드를 감싸던 외부 트랜잭션은 비동기 작업이 끝나기를 기다리지 않고 즉시 커밋되어 비동기 작업이 실패해도 롤백되지 않는다.

 

여기서 근본적인 해결책은 아니지만 CompletableFuture를 직접 사용하는 것보다 간단한 @Async를 사용하기로 했다.

 

2. @Async 와 @Transactional로 비동기를 처리했지만 하나의 서비스 클래스 안에서 비동기 메서드가 트랜잭션 메서드를 호출했다.

 

@Async와 @Transcational 로 비동기를 처리했는데. 트랜잭션이 적용되지 않았다.

 

@Service
public class ExampleService {
    @Async
    public void dataAsync(Data data) {
    	saveData(data);
    }
    
    @Transactional
    public void saveData(Data data) {
    	repository.save(data);
    }
}

 

@Async나 @Transactional은 AOP 프록시를 통해 동작한다.

 

외부에서 processDataAsync를 호춣하면 Spring이 생성한 프록시 객체가 호출을 가로채 비동기 처리를 해준다.

 

그러나 내부에서 saveData를 호출하는것은 프록시를 거치지 않기 때문에 @Transactional 은 무시된다.

 

이 문제는 역할에 따라 클래스(Bean)를 분리하는것이다.

 

비동기 실행의 책임을 가지는, 트랜잭션 책임을 가지는 Service 로 분리하여 사용한다

 

3. 스레드 풀과 DB 커넥션 풀의 크기

 

기존에 DB 커넥션 풀의 크기를 서버 스펙과 서비스 상태에 맞춰 세팅을 했었다.

 

스레드 풀의 크기는 내가 주로 수행하는 비동기 처리의 작업수(?)에 맞춰서 세팅을 했는데 

 

비동기 스레드 풀의 최대 크기가 DB 커넥션 풀의 최대 크기보다 크다면 커넥션을 점유하지 못한 스레드들이 대기 상태에 빠지면서

 

커넥션 풀 고갈 현상이 발생한다.

 

이 문제는 스레드 풀 Max <= DB Pool Max 로 세팅하여 대기 상태를 원천적으로 차단했다.

 

4. 스레드 풀과 DB 커넥션 풀을 세팅했는데도 스레드 부족 현상이 발생했다.

 

Max Size 와 Queue Capacity 까지 세팅했는데도 스레드 부족 현상이 발생했다.

 

쿼리의 응답이 느려 커넥션이 오래 점유 되었고, 다른 요청은 풀에서 기다리다가 비동기 작업이 계속 쌓여 부족현상이 발생했다

 

큐 용량을 설정했더라도, 그 큐가 꽉 차면 Max 까지 스레드를 늘리려하고 그 마저도 초과되면 에러가 난다.

 

이 문제의 해결방법은 

 

DB 작업 속도 개선

분산 처리

queueCapacity 늘리기 등 많은 방법 있겠지만

 

@Async와 CompletableFuture을 함께 써서 데이터를 10개씩 잘라 청크 단위로 비동기 작업을 수행하게했다.

 

private <T> void processInChunks(List<T> items, int chunkSize, Function<T, CompletableFuture<Void>> asyncOperation) {
    for (int i = 0; i < items.size(); i += chunkSize) {
        List<T> chunk = items.subList(i, Math.min(i + chunkSize, items.size()));

        List<CompletableFuture<Void>> futures = chunk.stream()
            .map(asyncOperation) // @Async 메서드 호출
            .toList();

        try {
            // 현재 청크의 작업이 모두 끝날 때까지 대기
            CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
        } catch (Exception e) {
            log.error("Error processing chunk, continuing with next chunk.", e);
        }
    }
}

 

processInChunks( ~, 10, ~>asyncService.dataAsync);

 

청크 단위로 join을 걸어 정확히 10개의 작업만 스레드 풀에 할당하고, 그 작업이 모두 끝나야만 다음 10개를 할당하는 식으로

 

안정적으로 유지되게 하였다.

반응형