기존 /api/ideas서버에서 파일 직접 업로드 + 외부 API 동기 호출 구조에서 GCS의 signed url을 활용해 47%의 성능 개선을 달성했다. 하지만 여전히 /api/ideas POST 요청 시 4초정도의 시간이 소요됐다.
/api/ideas에 포함된 텍스트 임베딩 API를 동기적으로 호출하는 것이 주된 원인이라고 생각했다. 그래서 텍스트 임베딩하는 로직을 비동기적으로 수행해 문제를 해결하기로 한다.
@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를 조회하는 식으로 구현해줬다.
호기롭게 @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)를 붙여 트랜잭션을 분리한다고 해도, "메인 트랜잭션이 커밋된 이후에 비동기 로직이 실행된다"는 순서를 보장할 수 없기에 문제는 해결되지 않는다.
이 문제를 해결하기 위해서는 "DB 커밋이 확실히 완료된 후에" 비동기 로직을 수행해야 한다. 이를 위해 Spring의 Event 메커니즘과 @TransactionalEventListener를 도입했다.
먼저 이벤트를 정의하고, 업로드 메서드에서 이벤트를 발행하도록 수정했다.
// 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());
}
}
성능 개선 결과
이러한 구조 변경을 통해 사용자는 임베딩 작업이 끝날 때까지 기다릴 필요 없이, 업로드 요청 후 즉시 응답을 받을 수 있게 되었다.

사용자가 체감하는 응답 속도는 4초 대에서 0.1초 대로 획기적으로 단축되었다. (물론 백그라운드에서는 여전히 임베딩 작업이 수행되지만, 사용자 경험에는 영향을 주지 않는다.)
비동기 로직을 적용하면서 테스트 코드 작성 시 주의가 필요했다. @Transactional이 붙은 테스트는 기본적으로 롤백되지만, 비동기 스레드에서 수행되는 로직은 별도의 트랜잭션을 타거나 트랜잭션 범위 밖에서 돌기 때문에 DB에 실제로 데이터가 반영될 수 있다. 따라서 비동기 테스트 시에는 @Transactional을 제거하거나, 명시적으로 데이터를 정리(tearDown)하는 로직을 추가하여 데이터 정합성을 맞춰주어야 한다.
동기와 비동기 로직이 섞여 있을 때, 단순히 @Async만 붙인다고 능사가 아님을 배웠다. 데이터의 라이프사이클과 트랜잭션의 범위를 정확히 이해하고, 이벤트 기반 아키텍처를 적절히 활용해야 데이터 정합성을 잃지 않으면서도 성능을 확보할 수 있다.