트랜잭션을 학습하다 보니 문득 의문점이 생겼습니다. 트랜잭션은 모든 상황에서 꼭 필요할까...?
해당 포스팅에서는 트랜잭션이 꼭 필요한지에 대해 탐구해보려고 합니다.
@Transactional
public void save(final UUID guestUuid) {
final Guest guest = new Guest(guestUuid, CurrentDateTime.create());
guestRepository.save(guest);
}
상단의 코드는 간단한 Guest 저장 로직입니다. 단일 Insert 쿼리문이죠. 프로젝트 당시에는 관성적으로 @Transaction을 붙였는데, 정말 필요한 걸까요? 여기서 말하고자 하는 바는, SimpleJpaRepository에 @Transactional 붙여있는데 굳이 또 붙일 필요가 있나?라는 의문점이 아닙니다. 트랜잭션이 필요한가??에 대한 질문입니다.
트랜잭션을 사용했을 때 어떤 이점이 있을까요?
1. 원자성 보장
여러 작업이 함께 성공하거나 함께 실패하는 것을 보장해 준다.
2. 일관된 읽기 보장 (Isolation)
여러 조회를 할 때 중간에 다른 트랜잭션이 데이터를 바꿔도 영향받지 않는다.
저는 상단의 2가지 경우가 트랜잭션의 핵심이라고 생각합니다. 하지만 단일 Insert 문에서는 트랜잭션의 이점을 누리고 있는 걸까요?
또 다른 경우를 살펴봅시다.
@Transactional(readOnly = true)
public List<ShowSeatInfoResponse> findShowSeatsBy(final Long scheduleId) {
final List<ShowSeatProjection> showSeatProjections = showSeatRepository.findSeatDetailsByScheduleId(scheduleId);
if (showSeatProjections.isEmpty()) {
throw new NotFoundException(
ErrorCode.RESOURCE_NOT_FOUND,
"해당 공연 일정의 좌석 정보를 찾을 수 없습니다. (scheduleId = " + scheduleId + ")"
);
}
return showSeatProjections.stream()
.map(ShowSeatInfoResponse::from)
.toList();
}
공연 좌석을 예매하는 간단한 코드입니다. 해당 코드를 살펴보면 단건 조회임을 알 수 있는데요, 트랜잭션의 이점을 누리고 있는 걸까요?
정리해 보자면,
1) 단건 조회
2) 단건 삽입/업데이트
해당 경우에는 트랜잭션 자체의 이점을 활용하지 못하는 것을 확인할 수 있습니다. 이 경우에는 트랜잭션을 제거해도 되지 않을까요?
트랜잭션의 비용
JPA를 사용하고 있다는 가정하에, JPA를 활용한 모든 쿼리에는 @Transaction이 달립니다. (물론 커스텀 Repository를 만들었다면 사용 안 하겠죠?)
즉, 트랜잭션이 필요 없는 코드에도 트랜잭션을 사용하고 있는 것이죠. 그런데 이게 큰 문제가 될까요?
트랜잭션을 보장하면, 어떤 추가 비용이 드는 걸까요? 아마 트랜잭션이 걸려있다면, 다음과 같은 구조가 될 겁니다.
SET autocommit = 0; -- autocommit 비활성화
-- 실제 비즈니스 쿼리 (예: INSERT INTO guest ...)
COMMIT; -- 트랜잭션 커밋
SET autocommit = 1; -- autocommit 복원
즉, 우리가 날리는 조회 쿼리 + 3개의 추가 쿼리가 함께 날아가게 됩니다. 이는 명확한 네트워크 비용을 소모하는 것이죠. 사소한 거 아닐까요? 과연 얼마나 유의미한 차이가 있는 걸까요?
이를 확인해 보고자 부하테스트를 진행해 봤습니다.
시나리오는 다음과 같습니다.
- 검증하려는 API: UUID를 통한 Guest 조회 로직
- DB에 100만 개의 Guest 존재
- vUser는 500
- 1초에 1개 요청으로 제한
속도 비교
(트랜잭션이 있는 경우 / 트랜잭션이 없는 경우)




호출된 쿼리 비교
(트랜잭션 있을 때)

(트랜잭션 없을 때)

RDS CPU 사용량 비교
(트랜잭션 있을 때 / 트랜잭션 없을 때)


와우... 결과가 믿기지 않아서 여러 번 부하테스트를 진행했었는데 비슷한 결과가 계속 나오더라고요... 정말 차이가 많이 나네요. RDS CPU 측에서는 큰 변화가 없지만, 속도에서 많은 차이가 나네요. 트랜잭션이 있는 경우는 auto-commit, commit을 계속 날리기 때문에 어떻게 보면 당연한 건가...? 싶기도 하네요.
트랜잭션을 제거해 보자!
부하테스트로 인해 트랜잭션이 있고 없고에 대해 성능 차이가 나는 것을 확인할 수 있었습니다. 그렇다면 우리가 이전에 알아본 단일 조회, 단일 삽입/수정에 대한 트랜잭션을 없애면 성능상의 이점을 가져갈 수 있지 않을까요?
상단에서 사용한 findGuestByUuid() 코드는 PK 값이 아닌 UUID를 통해 조회하기 때문에, 그냥 @Transactional을 안 붙이면 제거가 됩니다. 하지만 PK값을 통해 조회/삽입/삭제/수정하는 경우는 다른데요, SimpleJpaRepository를 사용하기 때문입니다.
SimpleJpaRepository를 확인하면 클래스 상단에 @Transaction(readOnly = true)가 붙어있는 것을 확인할 수 있습니다.

그리고 읽기 작업이 아닌 경우에는 메서드 상단에 @Transaction이 붙어있습니다.


결국 우리가 @Transaction 어노테이션을 붙이지 않아도, Jpa를 이용하고 있다면 의도하지 않은 트랜잭션이 발생하는 상황인 것이죠.
트랜잭션을 없애기 위해, Propagation을 사용해 봅시다. 기본적으로 트랜잭션의 Propagation 기본값은 REQUIRED입니다.
- REQUIRED : 기존 트랜잭션이 존재한다면, 해당 트랜잭션에 참여. 기존 트랜잭션이 없다면 새로운 트랜잭션 생성
- SUPPORTS : 현재 진행 중인 트랜잭션이 있다면 참여하고, 없다면 트랜잭션 없이 실행
- MANDATORY : 현재 진행중인 트랜잭션이 있다면 참여하고, 없다면 예외 던짐
- REQUIRES_NEW : 호출 시점에 진행중인 트랜잭션이 있든 없든 무조건 새로운 트랜잭션 시작, 기존 트랜잭션은 대기
- NOT_SUPPORTED : 트랜잭션 없이 실행, 기존 트랜잭션이 있다면 일시 중단 시킴
- NEVER : 트랜잭션 없이 실행, 기존에 트랜잭션이 있다면 예외를 던짐
- NESTED : 기존 트랜잭션이 있으면 중첩 트랜잭션(부모 트랜잭션 안에 자식 트랜잭션)으로 실행하고, 없으면 새 트랜잭션을 생성
public Guest findById(final Long id) {
return guestRepository.findById(1L)
.orElseThrow();
}
@Repository
public interface GuestRepository extends JpaRepository<Guest, Long> {
@Override
@Transactional(readOnly = true, propagation = Propagation.SUPPORTS)
Optional<Guest> findById(Long id);
이처럼 SimpleJpaRepository를 오버라이딩해서 별도의 트랜잭션 조건을 달아주면, SimpleJpaRepository의 트랜잭션을 무시할 수 있습니다. 상단의 SUPPORTS에 대한 설명을 보면 알 수 있듯이, 해당 메서드를 호출하는 Service Layer에 트랜잭션이 별도로 설정되어 있지 않다면, findById 또한 트랜잭션이 생성되지 않습니다.
트랜잭션 제거 시 주의사항
SimpleJpaRepository에서 제공하는 save(), delete()는 내부적으로 EntityManager의 persist()/merge(), remove()를 호출합니다. JPA 스펙상 이 작업들은 활성 트랜잭션을 필수로 요구하기 때문에, 트랜잭션을 제거하면 다음과 같은 오류가 발생합니다.
Caused by: jakarta.persistence.TransactionRequiredException:
No EntityManager with actual transaction available for current thread - cannot reliably process 'persist' call
쿼리를 재정의해도 마찬가지입니다. 커스텀하게 Insert 쿼리를 만들어도, @Modifying 어노테이션을 붙이면 내부적으로 EntityManager.createNativeQuery(). executeUpdate()를 호출하는데, 이 역시 JPA 스펙상 트랜잭션 콘텍스트를 요구합니다.
즉, 쓰기 작업 시에는 트랜잭션 제거를 포기하든가, JPA 대신 JdbcTemplate 혹은 JdbcClient를 사용하는 방법이 있을 것 같네요.
궁금한 부분
@Transactional(readOnly = true)를 사용했을 때의 이점 중 하나는 Write/Read DB가 나뉘어 있을 때 라우팅에 대한 힌트를 제공해 줍니다.
CQRS 패턴으로 Write/Reader DB를 분리한 환경에서는, @Transactional(readOnly = true) 여부를 기준으로 요청을 어느 DB로 보낼지 결정합니다.
Spring의 AbstractRoutingDataSource를 상속한 클래스에서
TransactionSynchronizationManager.isCurrentTransactionReadOnly()를 호출해 true면 Reader로, false면 Writer로 라우팅 하는 방식이죠.
조회 성능을 위해 트랜잭션을 제거하면 어떨까요? autocommit=0 → 쿼리 → COMMIT → autocommit=1의 오버헤드가 사라지니 분명 이점이 있습니다. 하지만 트랜잭션이 없으면 isCurrentTransactionReadOnly()가 동작하지 않아 라우팅 힌트를 제공하지 못합니다. 모든 요청이 Writer DB로 향하게 되죠.
해결방안: 트랜잭션 어노테이션을 추가하자!
대신, propagation을 사용해 트랜잭션은 비활성화하고, 라우팅 힌트만 주는 것이죠.
@Transactional(readOnly = true, propagation = Propagation.SUPPORTS)
public Guest findById(final Long id) {
return guestRepository.findById(1L)
.orElseThrow();
}
트랜잭션이 열리지 않는데, readOnly = true 즉 라우팅 힌트가 제대로 전달될까요?
TransactionDefinition 인터페이스 메서드 중 isReadOnly() 메서드 상단의 주석을 살펴보면, 다음과 같은 설명이 있습니다.
The read-only flag applies to any transaction context, whether backed by an actual resource transaction (PROPAGATION_REQUIRED/PROPAGATION_REQUIRES_NEW) or operating non-transactionally at the resource level (PROPAGATION_SUPPORTS).
트랜잭션이 열리던 안 열리던, 라우팅 힌트 자체와는 무관하다고 합니다. 이에 대해 추가로 테스트 코드로 검증을 해봤습니다.
public class GuestServiceTransactionTest extends ServiceIntegrationHelper {
@Autowired
private GuestService guestService;
@MockitoSpyBean
private GuestRepository guestRepository;
@Test
@DisplayName("SUPPORTS에서 트랜잭션 없이 호출해도 readOnly 힌트는 제공된다")
void supports_provides_readOnly_hint_without_actual_transaction() {
// given
final UUID guestUuid = UUID.randomUUID();
final Guest guest = guestRepository.save(new Guest(guestUuid, CurrentDateTime.create()));
final AtomicBoolean readOnly = new AtomicBoolean();
final AtomicBoolean transactionActive = new AtomicBoolean();
doAnswer(invocation -> {
readOnly.set(TransactionSynchronizationManager.isCurrentTransactionReadOnly());
transactionActive.set(TransactionSynchronizationManager.isActualTransactionActive());
return Optional.of(guest);
}).when(guestRepository).findByGuestUuid(any());
// when
guestService.findGuestBy(guestUuid);
// then
assertAll(
() -> assertThat(transactionActive.get()).isFalse(),
() -> assertThat(readOnly.get()).isTrue()
);
}
성공합니다!
나도 삭제해 볼까?
혼자 진행하는 프로젝트에서는 상관없지만, 함께 진행하는 프로젝트라면 팀 내 컨벤션을 명확하게 해야 할 듯 싶습니다. 어떤 단일 조회 코드에는 @Transaction(readOnly = true)가 붙어있고, 어떤 조회 메서드에는 트랜잭션이 없으면 엥?? 하면서 리뷰가 달릴 테니까요.
@ReadOnlySingleQuery와 같은 커스텀 어노테이션을 만들어서 @Transaction(readOnly = true) 대신에 사용하거나 다양한 방법이 있을 것 같네요. 중요한 건, 팀원들 모두 트랜잭션에 대한 이해도를 가지고 있어야 하고, 컨벤션을 잘 지켜야 할 것 같네요ㅎㅎ (AI 코드리뷰를 사용하는 것도 하나의 방법이겠네요)
'실험실' 카테고리의 다른 글
| SSE, 동시 접속자가 많아지면 어느 부분이 문제가 될까? (1. 연결 파트) (0) | 2026.04.09 |
|---|---|
| DB 통신 중 연결이 끊긴다면 어떻게 될까? (feat. JPA) (0) | 2025.06.14 |
| JWT payload를 조작하면 어떻게 될까? (feat. 어떻게 검증할까?) (2) | 2025.06.13 |