
🎯 결론 먼저 보기
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 | ✅ 정상 | ✅ 정상 | ✅ |
배운 점
- TransactionManager 선택의 중요성
- JDBC만 사용: DataSourceTransactionManager
- JPA 사용: JpaTransactionManager
- 혼용 시 JpaTransactionManager (JDBC도 관리 가능)
- OSIV는 만능이 아니다
- HTTP 요청(웹 요청)에만 적용됨
- 스케줄러, 배치, 비동기 작업, 별도 스레드에는 적용 안됨
- 근본적인 설정 문제를 숨길 수 있음 (임시 Controller는 작동하는 것처럼 보이게 함)
- EntityManager의 필수성
- JPA 쿼리 실행에는 EntityManager가 반드시 필요
- QueryDSL, JPQL, Criteria API 모두 동일
- 문제 해결 접근법
- "왜 스케줄러는 안 되고 임시 Controller는 될까?" → 환경 차이 분석
- 테스트를 위한 임시 Controller 생성은 좋은 방법이지만, 근본 원인을 숨길 수 있음
- 가설 수립 → 실험으로 검증 (OSIV 끄기)
- 근본 원인 파악 후 해결 (TransactionManager 변경)
'개발' 카테고리의 다른 글
| 디자인 패턴을 적용해보자 - 팩토리(4). (pizza 예제 아님) (0) | 2023.04.05 |
|---|---|
| 디자인 패턴을 적용해보자 - 팩토리(3). (pizza 예제 아님) (0) | 2023.04.05 |
| 디자인 패턴을 적용해보자 - 팩토리(2). (pizza 예제 아님) (0) | 2023.04.05 |
| 디자인 패턴을 적용해보자 - 팩토리(1). (pizza 예제 아님) (0) | 2023.04.05 |
| JAVA HashMap의 용량은 왜 2의 거듭제곱일까? - 자료구조(9) (0) | 2022.05.30 |