본문 바로가기

개발

Executing an update/delete query - 스케줄러에서 발생하는 예외가 API에서는 발생 했던 이유

🎯 결론 먼저 보기

JPA를 사용한다면 반드시 JpaTransactionManager를 사용해야 합니다.

@Configuration
@EnableTransactionManagement
public class DataSourceConfig {
    
    @Bean("transactionManager")
    public PlatformTransactionManager transactionManager(
            EntityManagerFactory entityManagerFactory) {
        return new JpaTransactionManager(entityManagerFactory);  // ✅ JPA 사용 시
        // return new DataSourceTransactionManager(dataSource);  // ❌ JDBC 전용
    }
}

문제의 핵심:

  • DataSourceTransactionManager는 JDBC 트랜잭션만 관리하며, JPA의 EntityManager를 생성하지 않음
  • QueryDSL, JPQL 등 JPA 쿼리는 EntityManager가 반드시 필요
  • 스케줄러는 OSIV(Open Session In View)가 적용되지 않아 EntityManager가 없어서 실패
  • API(HTTP 요청)는 OSIV가 EntityManager를 생성해줘서 우연히 작동했던 것

🚨 문제 상황

스케줄러를 통해 QueryDSL update 쿼리를 실행하는데 오류가 발생했습니다.

에러 메시지

javax.persistence.TransactionRequiredException: 
Executing an update/delete query

분명히 @Transactional을 붙였는데도 계속 같은 오류가 발생했습니다.


📝 재현 코드

// Repository - QueryDSL update
@Override
public long softDeletePosts(String adminId) {
    return query.update(post)
            .set(post.deleted, true)
            .set(post.deletedBy, adminId)
            .set(post.deletedAt, LocalDateTime.now())
            .where(post.createdAt.lt(LocalDateTime.now().minusDays(1)))
            .execute();
}

// Service
@Transactional
public void cleanupOldPosts() {
    String adminId = "SYSTEM";
    long count = postRepository.softDeletePosts(adminId);
    log.info("Deleted {} posts", count);
}

// 스케줄러에서 실행 (오류 발생 ❌)
@Scheduled(cron = "0 0 2 * * *")  // 매일 새벽 2시
public void scheduledCleanup() {
    postService.cleanupOldPosts();
    // TransactionRequiredException 발생!
}

🔧 시도했던 해결책들 (실패)

먼저 일반적인 트랜잭션 설정 문제라고 생각하고 여러 가지를 시도해봤습니다.

1. Repository에 @Transactional 추가

@Repository
public class PostRepositoryImpl implements PostCustomRepository {
    
    @Transactional  // ← 추가해봄
    @Override
    public long softDeletePosts(String adminId) {
        return query.update(post)
                .set(post.deleted, true)
                // ...
                .execute();
    }
}

결과: 여전히 실패 ❌

2. @Modifying 어노테이션 추가

@Transactional
@Modifying  // ← 추가해봄
@Override
public long softDeletePosts(String adminId) {
    return query.update(post)
            // ...
            .execute();
}

결과: 여전히 실패 ❌

3. Propagation 설정 변경

// REQUIRED로 변경
@Transactional(propagation = Propagation.REQUIRED)
public void cleanupOldPosts() { ... }

// REQUIRES_NEW로 변경
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void cleanupOldPosts() { ... }

// MANDATORY로 변경
@Transactional(propagation = Propagation.MANDATORY)
public void cleanupOldPosts() { ... }

결과: 전부 실패 ❌

4. 스케줄러 메서드에 @Transactional 추가

@Scheduled(cron = "0 0 2 * * *")
@Transactional  // ← 추가해봄
public void scheduledCleanup() {
    postService.cleanupOldPosts();
}

결과: 여전히 실패 ❌


🧪 테스트를 위한 임시 Controller 생성

매번 스케줄러 cron 시간을 수정하고 서버를 재시작한 후 그 시간이 될 때까지 기다리는 것은 너무 비효율적이었습니다.

그래서 같은 Service 메서드를 호출하는 임시 테스트 Controller를 만들어서 빠르게 테스트하기로 했습니다.

// 테스트용 임시 Controller
@RestController
@RequestMapping("/test")
public class TestController {
    
    @PostMapping("/cleanup")
    public ResponseEntity<?> testCleanup() {
        postService.cleanupOldPosts();  // 같은 메서드 호출
        return ResponseEntity.ok("완료");
    }
}

놀라운 발견

임시 Controller를 통해 호출하니 정상 작동했습니다! ✅

  • 임시 Controller로 호출 → 정상 작동
  • Scheduler에서 호출 → 오류 발생

같은 postService.cleanupOldPosts() 메서드를 호출하는데, 호출하는 곳에 따라 결과가 다릅니다!

이 시점에서 "트랜잭션 설정 문제"가 아니라 스케줄러와 Controller의 실행 환경 차이가 있다는 것을 깨달았습니다.


🔍 원인 분석

1단계: TransactionManager 확인

문제의 시작은 DataSource 설정에 있었습니다.

@Configuration
@EnableTransactionManagement
public class DataSourceConfig {
    
    @Bean("transactionManager")
    public PlatformTransactionManager transactionManager(
            DataSource dataSource) {
        // ⚠️ 문제의 원인!
        DataSourceTransactionManager transactionManager = 
            new DataSourceTransactionManager();
        transactionManager.setDataSource(dataSource);
        return transactionManager;
    }
}

JPA를 사용하는데 DataSourceTransactionManager를 사용하고 있었습니다!

2단계: TransactionManager의 차이 이해

TransactionManager EntityManager 생성 JDBC 트랜잭션 JPA 쿼리 실행

DataSourceTransactionManager ❌ 생성 안함 ✅ 관리 ❌ 불가능
JpaTransactionManager ✅ 생성/관리 ✅ 관리 ✅ 가능

QueryDSL의 update().execute()는 내부적으로 JPA의 EntityManager를 필요로 합니다.

// QueryDSL 내부 동작
query.update(entity)
     .set(...)
     .execute();
     
// ↓ 내부적으로 이렇게 실행됨
entityManager.createQuery("UPDATE ...")
             .executeUpdate();  // ← EntityManager 필요!

3단계: 왜 임시 Controller는 작동했을까? - OSIV의 역할

핵심은 OSIV (Open Session In View) 설정이었습니다.

중요한 발견: 별도로 OSIV 설정을 하지 않았는데, Spring Boot는 기본적으로 OSIV를 활성화합니다!

# application.yml에 명시적으로 설정하지 않았지만
# Spring Boot의 기본값으로 아래와 같이 동작
spring:
  jpa:
    open-in-view: true  # ← Spring Boot 기본값!

OSIV가 활성화되면 OpenEntityManagerInViewFilter/Interceptor가 HTTP 요청마다 EntityManager를 미리 생성해둡니다.

  • 스케줄러: HTTP 요청이 아니므로 OSIV 적용 안됨 ❌
  • 임시 Controller: HTTP 요청이므로 OSIV 적용됨 ✅

이것이 같은 메서드인데 결과가 다른 이유였습니다!

임시 Controller 호출 흐름 (DataSourceTM + OSIV ON)

HTTP Request
    ↓
[OSIV Filter]
EntityManager 세션 OPEN ← 여기서 EntityManager 생성!
    ↓
Controller
    ↓
Service (@Transactional)
→ DataSourceTM: JDBC 트랜잭션만 시작
→ EntityManager는 OSIV가 이미 만들어놨음!
    ↓
Repository: QueryDSL update()
→ EntityManager 있음 ✅
→ JDBC 트랜잭션 있음 ✅
→ 정상 작동!
    ↓
[OSIV Filter]
EntityManager 세션 CLOSE

스케줄러 실행 흐름 (DataSourceTM, OSIV 없음)

@Scheduled 별도 스레드 시작
    ↓
OSIV 없음! (웹 요청이 아니므로)
    ↓
Service (@Transactional)
→ DataSourceTM: JDBC 트랜잭션만 시작
→ EntityManager 생성 안함!
    ↓
Repository: QueryDSL update()
→ EntityManager 없음 ❌
→ TransactionRequiredException 발생!

4단계: 실험으로 검증

가설을 검증하기 위해 OSIV를 끄고 임시 Controller를 호출해봤습니다.

spring:
  jpa:
    open-in-view: false  # 비활성화

결과: 임시 Controller도 스케줄러와 동일하게 실패! ❌

이로써 문제의 원인이 명확해졌습니다:

  • DataSourceTransactionManager는 EntityManager를 생성하지 않음
  • 임시 Controller는 OSIV 덕분에 우연히 작동했던 것
  • 스케줄러는 HTTP 요청이 아니라서 OSIV가 적용되지 않아 실패
  • OSIV를 끄니 임시 Controller도 실패함

✅ 해결 방법

JpaTransactionManager로 변경

@Configuration
@EnableTransactionManagement
public class DataSourceConfig {
    
    @Primary
    @Bean("transactionManager")
    public PlatformTransactionManager transactionManager(
            EntityManagerFactory entityManagerFactory) {
        return new JpaTransactionManager(entityManagerFactory);  // ← 변경!
    }
}

이렇게 변경하면 모든 문제가 해결됩니다!

검증

// 스케줄러 - 정상 작동 ✅
@Scheduled(cron = "0 0 2 * * *")
public void scheduledCleanup() {
    postService.cleanupOldPosts();
}

// 임시 테스트 Controller - 정상 작동 ✅
@PostMapping("/test/cleanup")
public ResponseEntity<?> testCleanup() {
    postService.cleanupOldPosts();
    return ResponseEntity.ok("완료");
}

OSIV 설정(open-in-view)과 무관하게 스케줄러에서도 정상 작동합니다!

이제 더 이상 매번 cron 시간을 기다릴 필요가 없습니다.


📚 핵심 개념 정리

EntityManager란?

JPA에서 엔티티를 관리하는 핵심 인터페이스입니다.

주요 역할:

  • 영속성 컨텍스트(Persistence Context) 관리
  • 1차 캐시 제공
  • 쓰기 지연 (Transactional Write-Behind)
  • 변경 감지 (Dirty Checking)
  • 지연 로딩 (Lazy Loading)

JPA 쿼리 실행 시 필수:

// JPQL
entityManager.createQuery("UPDATE User u SET u.name = :name")
             .executeUpdate();  // ← EntityManager 필요

// QueryDSL
query.update(user)
     .set(user.name, "name")
     .execute();  // ← 내부적으로 EntityManager 사용

// Criteria API
CriteriaUpdate<User> update = entityManager.getCriteriaBuilder()
                                           .createCriteriaUpdate(User.class);
// ← EntityManager 필요

OSIV (Open Session In View)

웹 요청의 시작부터 끝까지 EntityManager 세션을 열어두는 기능입니다.

장점:

  • View 레이어에서도 Lazy Loading 가능
  • 개발 편의성 향상

단점:

  • 데이터베이스 커넥션을 오래 점유 (성능 저하)
  • 트래픽이 많으면 커넥션 풀 고갈 위험

실무 권장:

# 트래픽이 많은 서비스
spring:
  jpa:
    open-in-view: false  # 성능 우선

대신 명시적으로 Fetch Join 사용:

@Query("SELECT u FROM User u JOIN FETCH u.orders WHERE u.id = :id")
User findByIdWithOrders(@Param("id") Long id);

🎯 결론 및 교훈

문제의 본질

JPA를 사용한다면 반드시 JpaTransactionManager를 사용해야 합니다.

DataSourceTransactionManager는 순수 JDBC용이며, JPA의 EntityManager를 관리하지 못합니다.

상황별 정리

구성 임시 Controller 스케줄러 권장

DataSourceTM + OSIV ON ✅ (우연히 작동) ❌ 실패
DataSourceTM + OSIV OFF ❌ 실패 ❌ 실패
JpaTM + OSIV ON ✅ 정상 ✅ 정상
JpaTM + OSIV OFF ✅ 정상 ✅ 정상

배운 점

  1. TransactionManager 선택의 중요성
    • JDBC만 사용: DataSourceTransactionManager
    • JPA 사용: JpaTransactionManager
    • 혼용 시 JpaTransactionManager (JDBC도 관리 가능)
  2. OSIV는 만능이 아니다
    • HTTP 요청(웹 요청)에만 적용됨
    • 스케줄러, 배치, 비동기 작업, 별도 스레드에는 적용 안됨
    • 근본적인 설정 문제를 숨길 수 있음 (임시 Controller는 작동하는 것처럼 보이게 함)
  3. EntityManager의 필수성
    • JPA 쿼리 실행에는 EntityManager가 반드시 필요
    • QueryDSL, JPQL, Criteria API 모두 동일
  4. 문제 해결 접근법
    • "왜 스케줄러는 안 되고 임시 Controller는 될까?" → 환경 차이 분석
    • 테스트를 위한 임시 Controller 생성은 좋은 방법이지만, 근본 원인을 숨길 수 있음
    • 가설 수립 → 실험으로 검증 (OSIV 끄기)
    • 근본 원인 파악 후 해결 (TransactionManager 변경)