외부 API 호출 비동기 처리로 96% 성능 개선하기

ideafy·2025년 11월 19일

프로젝트

목록 보기
23/25
post-thumbnail

문제

기존 /api/ideas서버에서 파일 직접 업로드 + 외부 API 동기 호출 구조에서 GCS의 signed url을 활용해 47%의 성능 개선을 달성했다. 하지만 여전히 /api/ideas POST 요청 시 4초정도의 시간이 소요됐다.

문제 해결

/api/ideas에 포함된 텍스트 임베딩 API를 동기적으로 호출하는 것이 주된 원인이라고 생각했다. 그래서 텍스트 임베딩하는 로직을 비동기적으로 수행해 문제를 해결하기로 한다.

@Async로 임베딩 메서드 비동기 처리

@Async, CompletableFuture, Future 등 다양한 선택지가 있지만, 현재는 "외부 API 호출 + DB 저장"의 간단한 로직만 비동기로 수행하면 되기 때문에 @Aysnc를 사용해 텍스트 임베딩 로직의 비동기 처리를 하기로 결정했다.

	@Async
    @Transactional
    public void embedIdeaPost(Long ideaPostId) {
        IdeaPost ideaPost = ideaPostRepository.findById(ideaPostId)
                .orElseThrow(() -> new IdeaPostNotFound(ideaPostId));
        String text = String.format("summary: %s, problem: %s, solution: %s",
                ideaPost.getSummary(),
                ideaPost.getProblem(),
                ideaPost.getSolution());
        float[] summaryEmbedding = textEmbeddingUtil.embedText(text);
        ideaPost.setSummaryEmbedding(summaryEmbedding);
    }

결과적으로 IdeaPost 저장IdeaPost 임베딩은 별개로 수행되어야 하기 때문에 메서드를 분리해주고, 따로 IdeaPost를 조회하는 식으로 구현해줬다.

비동기 트랜잭션 Race Condition 발생

호기롭게 @Async를 적용하고 테스트를 돌렸는데, 예상치 못한 EntityNotFoundException이 발생했다.

java.lang.RuntimeException: com.example.ideafyy.exception.IdeaPostNotFound: IdeaPost not found with id: 105

분명 ideaPostRepository.save(ideaPost)를 호출해서 ID가 생성된 것을 확인하고 비동기 메서드에 ID를 넘겨줬는데, 비동기 메서드 내부에서 findById를 수행할 때는 해당 데이터가 없다고 나오는 것이다.

원인 분석: 트랜잭션 커밋과 비동기 실행 시점의 차이

원인은 메인 스레드의 트랜잭션 커밋 시점과 비동기 스레드의 조회 시점이 맞지 않았기 때문이다.

  • 메인 스레드 (uploadIdeaPost2): save()를 호출하면 영속성 컨텍스트에는 저장되지만, 메서드가 완전히 종료되어 트랜잭션이 커밋되기 전까지는 실제 DB에 INSERT 쿼리가 날아가지 않거나, 다른 트랜잭션에서 볼 수 없는 상태다.

  • 비동기 스레드 (embedIdeaPost): @Async로 인해 별도의 스레드에서 즉시 실행된다. 이때 메인 스레드의 트랜잭션은 아직 커밋되지 않았을 확률이 높다.

비동기 스레드는 격리 수준(Isolation Level)에 의해 아직 커밋되지 않은 데이터를 조회할 수 없으므로, DB에서 해당 ID를 찾지 못하고 에러를 뱉는다.

단순히 비동기 메서드에 @Transactional(propagation = Propagation.REQUIRES_NEW)를 붙여 트랜잭션을 분리한다고 해도, "메인 트랜잭션이 커밋된 이후에 비동기 로직이 실행된다"는 순서를 보장할 수 없기에 문제는 해결되지 않는다.

해결: Spring Event와 @TransactionalEventListener

이 문제를 해결하기 위해서는 "DB 커밋이 확실히 완료된 후에" 비동기 로직을 수행해야 한다. 이를 위해 Spring의 Event 메커니즘과 @TransactionalEventListener를 도입했다.

1. 로직 변경 구조

  • 기존: 서비스 로직에서 직접 비동기 메서드 호출
  • 변경:
    1. 서비스 로직은 IdeaPost 저장 후 이벤트 발행 (Publish)
    1. 이벤트 리스너가 이벤트를 감지
    2. 리스너는 트랜잭션이 커밋된 직후(AFTER_COMMIT) 에만 비동기 메서드를 실행하도록 설정

2. 코드 구현

먼저 이벤트를 정의하고, 업로드 메서드에서 이벤트를 발행하도록 수정했다.

// 1. 이벤트 클래스 정의
public record IdeaPostSavedEvent(Long ideaPostId) {}

// 2. 업로드 메서드 (이벤트 발행)
@Transactional
public Long uploadIdeaPost2(Long userId, IdeaPostUploadRequest2 request) {
    // ... (IdeaPost 생성 및 저장 로직) ...
    ideaPostRepository.save(ideaPost);

    // 직접 비동기 메서드를 호출하는 대신 이벤트 발행
    eventPublisher.publishEvent(new IdeaPostSavedEvent(ideaPost.getId()));
    
    return ideaPost.getId();
}

그리고 이벤트를 받아 처리할 리스너를 구현했다. 여기가 핵심이다.

@Component
@RequiredArgsConstructor
public class IdeaPostEventListener {

    private final EmbeddingAsyncService embeddingAsyncService;

    @Async // 별도 스레드에서 비동기 실행
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) // 핵심: 커밋 완료 후 실행
    public void handleIdeaPostSaved(IdeaPostSavedEvent event) {
        // 커밋이 완료되었으므로, DB 조회 시 데이터 존재가 보장됨
        embeddingAsyncService.embedIdeaPost(event.ideaPostId());
    }
}
  • @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT): 이 옵션을 통해 메인 트랜잭션이 성공적으로 커밋된 뒤에만 리스너 메서드가 실행되도록 강제했다.
  • @Async: 리스너 내부 로직(임베딩 API 호출)이 메인 응답을 블로킹하지 않도록 비동기 처리를 유지했다.

결과 및 배운 점

성능 개선 결과
이러한 구조 변경을 통해 사용자는 임베딩 작업이 끝날 때까지 기다릴 필요 없이, 업로드 요청 후 즉시 응답을 받을 수 있게 되었다.

  • 기존 (동기 처리): 약 9.5초 (파일 업로드 + 임베딩)
  • 1차 개선 (Signed URL): 약 5초 (임베딩 대기 시간 존재)
  • 최종 개선 (Signed URL + 비동기 임베딩): 약 170ms

사용자가 체감하는 응답 속도는 4초 대에서 0.1초 대로 획기적으로 단축되었다. (물론 백그라운드에서는 여전히 임베딩 작업이 수행되지만, 사용자 경험에는 영향을 주지 않는다.)

주의할 점 (테스트 코드)

비동기 로직을 적용하면서 테스트 코드 작성 시 주의가 필요했다. @Transactional이 붙은 테스트는 기본적으로 롤백되지만, 비동기 스레드에서 수행되는 로직은 별도의 트랜잭션을 타거나 트랜잭션 범위 밖에서 돌기 때문에 DB에 실제로 데이터가 반영될 수 있다. 따라서 비동기 테스트 시에는 @Transactional을 제거하거나, 명시적으로 데이터를 정리(tearDown)하는 로직을 추가하여 데이터 정합성을 맞춰주어야 한다.

결론

동기와 비동기 로직이 섞여 있을 때, 단순히 @Async만 붙인다고 능사가 아님을 배웠다. 데이터의 라이프사이클과 트랜잭션의 범위를 정확히 이해하고, 이벤트 기반 아키텍처를 적절히 활용해야 데이터 정합성을 잃지 않으면서도 성능을 확보할 수 있다.

profile
재밌게 공부하고 싶어요

0개의 댓글