업로드된 이미지를 Cloudinary(미디어 서버)에 전송하고, AI(Gemini API)로 분석하는 작업은 네트워크 상황에 따라 2~5초가량 소요됩니다. 유저를 멍하니 5초 동안 멈춰둔 채 기다리게 할 수 없어, 응답성을 높이고자 로직을 Spring @Async를 활용해 비동기로 분리하려다 영속성 컨텍스트 지옥에 빠진 트러블슈팅 경험입니다.

1. 섣부른 비동기 처리의 시도
요청이 오면 "업로드 대기열에 등록되었습니다"라는 성공 응답을 클라이언트에 0.1초 만에 내려주고, 무거운 작업은 백그라운드 스레드에 던져버리도록 설계했습니다.
// ❌ 처음 시도한 코드 (에러 발생)
@Async // 새로운 백그라운드 스레드풀에서 실행
public void processUploadAsync(User user, byte[] file) {
// 1. 3~5초가 걸리는 외부 API 통신 (Blocking)
String url = cloudinaryService.upload(file);
// 2. 💥 에러 발생 포인트!
Card card = new Card(user, url);
cardRepository.save(card);
}
결과는 대실패였습니다. 업로드는 잘 되었으나 마지막에 DB 저장 시 Detached Entity 에러가 발생하며 롤백되었습니다.
org.hibernate.LazyInitializationException:
could not initialize proxy [com.gacha.user.entity.User#1] - no Session
2. 원인 파악: 스레드(ThreadLocal)와 OSIV의 한계
Spring Data JPA는 기본적으로 웹 요청(Request)을 처리하는 단일 스레드(ThreadLocal)에 트랜잭션과 영속성 컨텍스트(EntityManager)를 바인딩하여 묶어둡니다. (Open Session In View 패턴 포함)
[시간순 흐름]
Main Thread ──────────────────────────────────────────
┌────────────────────────────────────┐
│ 트랜잭션 열림 + 영속성 컨텍스트 │
│ 1. User 조회 (Managed 상태) │
│ 2. @Async 호출 (User 객체 전달) │
│ 3. HTTP 200 OK 반환 │
└────────────────────────────────────┘
↑ 트랜잭션 종료, 영속성 컨텍스트 닫힘
Async Thread ──────────────────────────────────────────
┌─────────────────────────────────────┐
│ 4. Cloudinary 업로드 (3초 대기) │
│ 5. user.getCards()... → 💥 에러! │
│ User는 이미 Detached 상태! │
└─────────────────────────────────────┘
- 메인 스레드에서 유저(
User) 객체를 조회해 비동기 메서드로 파라미터로 넘겼습니다. - 메인 스레드는 즉시 200 OK를 반환하고 트랜잭션을 종료하며 영속성 컨텍스트를 닫아버립니다.
- 새로운 백그라운드 스레드에서 수 초의 외부 API 통신이 끝난 뒤, 넘겨받은
User객체를 이용해 새로운 릴레이션을 맺으려 시도합니다. - 그러나 JPA 입장에서 해당
User는 이미 관리 대상이 아닌 준영속(Detached) 상태의 껍데기 객체입니다. 따라서 저장을 거부한 것입니다.
3. 해결책: 트랜잭션 분리와 식별자(ID) 전달 패턴
엔티티 객체 자체를 다른 스레드의 파라미터로 넘기는 것은 시한폭탄을 넘기는 것과 같습니다. 객체 대신 엔티티의 식별자(PK ID)만 넘기고, 비동기 스레드 내부에서 완전히 독립된 새로운 트랜잭션을 열어 다시 로드(Proxy 초기화)하도록 리팩토링했습니다.
// ✅ 해결된 코드 — ID만 전달하고 새 트랜잭션에서 재조회
@Async
public void processUploadAsync(Long userId, byte[] file) {
// 외부 API 통신: 여기는 트랜잭션을 걸지 않아 커넥션 낭비를 막음
String url = cloudinaryService.upload(file);
// DB 저장: 새로운 스레드 안에서 별도 트랜잭션 개시
saveCardTransaction(userId, url);
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveCardTransaction(Long userId, String url) {
// 새로운 영속성 컨텍스트에 엔티티를 새롭게 로드(Attached)
User user = userRepository.findById(userId).orElseThrow();
Card card = new Card(user, url);
cardRepository.save(card); // ✅ 정상 저장!
}
4. 마무리
비동기 처리는 유저의 응답 시간을 비약적으로 개선하지만, JPA의 스레드 로컬 바인딩 특성과 생명주기를 완벽하게 이해하지 못하면 런타임 에러의 원흉이 됩니다. 비동기 큐잉을 할 때는 파라미터로 순수한 식별자(Long)나 불변 객체(DTO)만 전달해야 한다는 개발 패턴을 뼈저리게 배울 수 있었습니다.