카카오 테크 기술 블로그를 읽고
조회까지 트랜잭션이 열리는 이유, SimpleJpaRepository의 기본 정책,
제가 구상한 Reader–Writer 분리 전략까지 정리했습니다.
1️⃣ SimpleJpaRepository는 어디까지 트랜잭션을 잡는가?
Spring Data JPA에서 우리가 사용하는 JpaRepository의 기본 구현체는 SimpleJpaRepository입니다.
이 클래스가 바로 트랜잭션의 기본 정책을 정의하고 있습니다.
@Transactional(readOnly = true)
public class SimpleJpaRepository<T, ID> implements JpaRepository<T, ID> {
@Transactional public <S extends T> S save(S entity) { ... }
}
- 클래스 레벨에 @Transactional(readOnly = true)
→ 모든 메서드가 기본적으로 트랜잭션 안에서 실행됩니다. - save, delete 등 쓰기 메서드는 메서드 수준에 @Transactional(readOnly = false)로 덮어씌워집니다.
즉, findById, findAll 같은 단순 조회조차도
트랜잭션을 열고 실행하게 됩니다.
2️⃣ 트랜잭션이 열릴 때 실제로 일어나는 일
트랜잭션이 시작되면 하이버네이트는 내부적으로 다음 작업을 수행합니다.
- set autocommit = 0, set option, commit 등의 세션 설정 쿼리 실행
- JDBC 커넥션을 확보하고 flush 모드를 조정
- MySQL 기준으로 이 오버헤드가 전체 쿼리의 10~20%까지 차지하기도 합니다.
@Transactional(readOnly = true)는 flush 최소화 효과만 있고,
트랜잭션 자체를 없애지는 못합니다.
조회 시 트랜잭션 자체를 열지 않는 전략(propagation = SUPPORTS)을 적용합니다.
3️⃣ Spring Data JPA는 항상 트랜잭션을 잡을까?
“모든 Repository 메서드가 항상 트랜잭션을 여나요?”
➜ 거의 대부분은 예, 하지만 예외가 있습니다.
| SimpleJpaRepository 기본 메서드 | ✅ 열림 (readOnly = true) | findById, findAll 등 |
| @Query SELECT | ✅ 열림 (readOnly = true) | 파생 쿼리 포함 |
| @Query + @Modifying (UPDATE/DELETE) | ❌ 직접 열어야 함 | @Transactional 필수 |
| 직접 EntityManager 사용 | ❌ 자동 트랜잭션 없음 | 수동 관리 필요 |
즉, Spring Data JPA 기본 리포지토리는 대부분 트랜잭션을 연다.
하지만 커스텀 리포지토리나 QueryDSL 구현부는 직접 지정하지 않으면 트랜잭션이 없다.
4️⃣ JPQL / Native Query일 때도 동일할까?
- SELECT: 트랜잭션 없이도 가능.
→ 단, 일관성 보장이나 슬레이브 라우팅용 힌트가 필요하면 readOnly=true 유지. - UPDATE / DELETE / INSERT: 트랜잭션 필수.
→ 없으면 TransactionRequiredException 발생.
즉, JPQL이든 네이티브든 읽기는 옵션, 쓰기는 필수다.
5️⃣ 실무 컨벤션: 최소 경계 + 명시적 트랜잭션
| 읽기 (조회) | @Transactional(readOnly = true, propagation = SUPPORTS) | 트랜잭션 미시작 |
| 쓰기 (변경) | @Transactional | REQUIRED 기본값 |
| 핫패스 조회 | QueryDSL 등 커스텀 구현 | SimpleJpaRepository 기본 우회 |
| 외부 호출 포함 메서드 | 트랜잭션 밖으로 분리 | 롤백 범위 최소화 |
예시
// 읽기 전용
@Transactional(readOnly = true, propagation = Propagation.SUPPORTS)
public OrderDetailDto getOrder(Long id) {
return orderQueryRepository.findDetailById(id)
.orElseThrow(NotFoundException::new);
}
// 쓰기 (변경)
@Transactional
public void approveOrder(ApproveCommand cmd) {
order.approve();
eventPublisher.publish(order);
}
6️⃣ Reader–Writer 분리 전략
설계 개념
- Reader (조회) → propagation = SUPPORTS, readOnly = true
- Writer (변경) → propagation = MANDATORY
- Service (오케스트레이션) → Reader/Writer를 명시적으로 호출
- 조회만 하면 트랜잭션 없음
- 변경 포함 시 Service가 경계를 열고 Writer가 그 안에 참여
이 방식의 장점은 명확합니다.
✅ 읽기에서는 트랜잭션 미시작으로 성능 최적화
✅ 쓰기에서는 트랜잭션 경계를 강제해 안정성 확보
✅ 실수로 트랜잭션 밖에서 변경 시 MANDATORY로 즉시 예외 발생
7️⃣ 코드 예시
// ReaderService.java
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true, propagation = Propagation.SUPPORTS)
public class OrderReader {
private final OrderQueryRepository orderQueryRepository;
// 상위 트랜잭션이 없으면 새로 열지 않음
public Optional<OrderDetailDto> findDetail(Long orderId) {
return orderQueryRepository.findDetailById(orderId);
}
}
// WriterService.java
@Service
@RequiredArgsConstructor
@Transactional(propagation = Propagation.MANDATORY)
public class OrderWriter {
private final OrderRepository orderRepository;
// 반드시 이미 열린 트랜잭션에서만 수행
public void approve(Order order) {
order.approve();
}
}
// ApplicationService.java
@Service
@RequiredArgsConstructor
public class OrderApplicationService {
private final OrderReader reader;
private final OrderWriter writer;
private final OrderRepository orderRepository;
// 조회만 필요할 때 → Reader가 SUPPORTS로 비트랜잭션 실행
public OrderDetailDto getOrder(Long orderId) {
return reader.findDetail(orderId)
.orElseThrow(() -> new NotFoundException("order"));
}
// 변경 포함 시 → 여기서 명시적으로 트랜잭션을 열고 Writer 참여
@Transactional
public void approveOrder(Long orderId) {
Order order = orderRepository.findByIdForUpdate(orderId)
.orElseThrow(() -> new NotFoundException("order"));
writer.approve(order); // MANDATORY: 트랜잭션 밖이면 예외
}
}
그래서 Reader에서는 SUPPORTS, Writer에서는 MANDATORY,
그리고 Service에서 Reader/Writer를 오케스트레이션하며
트랜잭션을 “필요에 의한 명시”로 처리하는 전략을 가져가기로 했다.이 접근은 조회 경로에서 트랜잭션 오버헤드를 제거하면서도,
쓰기 경로의 원자성과 일관성을 명확하게 보장해준다.
⚡ 핵심 요약
- SimpleJpaRepository는 모든 리포지토리 메서드를 기본적으로 트랜잭션 안에서 실행한다.
- readOnly=true는 flush 최소화일 뿐, 트랜잭션을 없애지 않는다.
- 읽기에서는 SUPPORTS, 쓰기에서는 MANDATORY, 그리고 Service가 명시적으로 경계를 관리하도록 한다.
참고자료
'ToyProject > Life As Game' 카테고리의 다른 글
| [Life As Game] DTO of 메서드 남발에 대한 문제 인식 (0) | 2025.12.04 |
|---|---|
| [Life As Game] @LastModifiedDate 사용시 변경감지 후 시간 TOCTOU 문제 (0) | 2025.11.01 |
| [Spring] Spring Boot에서 favicon.ico 404 / NoResourceFoundException 해결 (1) | 2025.08.26 |