본문 바로가기
실험실

트랜잭션, 꼭 필요할까?

by CodingMasterLSW 2026. 2. 8.

트랜잭션을 학습하다 보니 문득 의문점이 생겼습니다. 트랜잭션은 모든 상황에서 꼭 필요할까...?

 

해당 포스팅에서는 트랜잭션이 꼭 필요한지에 대해 탐구해보려고 합니다.


@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 코드리뷰를 사용하는 것도 하나의 방법이겠네요)