[Spring] JPA에서 @Transactional을 사용하는 이유

AIR·2024년 12월 9일

트랜잭션 ACID


트랜잭션은 ACID라 하는 원자성(Atomicity), 일관성(Consistency), 격리성(Isolation), 지속성(Durability)을 보장해야 한다.

  • 원자성: 트랜잭션 내에서 실행한 작업들은 마치 하나의 작업인 것처럼 모두 성공 하거나 모두 실패해야 한다.
  • 일관성: 모든 트랜잭션은 일관성 있는 데이터베이스 상태를 유지해야 한다.
  • 격리성: 동시에 실행되는 트랜잭션들이 서로에게 영향을 미치지 않도록 격리한다.
  • 지속성: 트랜잭션을 성공적으로 끝내면 그 결과가 항상 기록되어야 한다.

즉, 트랜잭션은 데이터베이스 작업이 원자적으로 수행되도록 보장하며, 모든 작업이 성공해야만 변경 사항이 커밋되고, 하나라도 실패하면 모든 변경 사항이 롤백된다.

TransactionTemplate


트랜잭션은 다음과 같은 패턴이 반복된다.

  • 트랜잭션 시작
  • 비즈니스 로직 실행
  • 성공하면 커밋
  • 실패하면 롤백
//트랜잭션 시작
TransactionStatus status = transactionManager.getTransaction(new DefaultTransactionDefinition());
try {
  bizLogic(fromId, toId, money); //비즈니스 로직
  transactionManager.commit(status); //성공시 커밋
} catch (Exception e) {
  transactionManager.rollback(status); //실패시 롤백
  throw new IllegalStateException(e);
}

이런 형태는 각각의 서비스에서 반복된다. 달라지는 부분은 비즈니스 로직 뿐이다. 이럴 때 템플릿 콜백 패턴을 활용해서 반복 문제를 해결할 수 있는데, 스프링에서는 TransactionTemplate이라는 기능을 제공한다.

txTemplate.executeWithoutResult((status) -> {
  try {
    bizLogic(fromId, toId, money); //비즈니스 로직
  } catch (SQLException e) {
    throw new IllegalStateException(e);
  }
});

트랜잭션 템플릿 덕분에 트랜잭션을 시작하고, 커밋하거나 롤백하는 코드가 모두 제거되었다. 하지만 아직 서비스 계층에 순수한 비즈니스 로직만 남기지는 못했다.

@Transactional


@Transactional을 사용하면 스프링이 AOP를 사용해서 트랜잭션을 편리하게 처리해준다. 트랜잭션 프록시가 트랜잭션 처리 로직을 모두 가져간다. 그리고 트랜잭션을 시작한 후에 실제 서비스를 대신 호출한다. 트랜잭션 프록시 덕분에 서비스 계층에는 순수한 비즈니즈 로직만 남길 수 있다.

프록시 도입 전

프록시 도입 후

선언적 트랜잭션 관리 vs 프로그래밍 방식 트랜잭션 관리

  • 선언적 트랜잭션 관리(Declarative Transaction Management)
    • @Transactional 애노테이션 하나만 선언해서 매우 편리하게 트랜잭션을 적용하는 것을 선언적 트랜잭션 관리라 한다.
    • 선언적 트랜잭션 관리는 과거 XML에 설정하기도 했다. 이름 그대로 해당 로직에 트랜잭션을 적용하겠다 라고 어딘가에 선언하기만 하면 트랜잭션이 적용되는 방식이다.
  • 프로그래밍 방식의 트랜잭션 관리(programmatic transaction management)
    • 트랜잭션 매니저 또는 트랜잭션 템플릿 등을 사용해서 트랜잭션 관련 코드를 직접 작성하는 것을 프로그래밍 방식의 트랜잭션 관리라 한다.

선언적 트랜잭션 관리가 프로그래밍 방식에 비해서 훨씬 간편하고 실용적이기 때문에 실무에서는 대부분 선언적 트랜잭션 관리를 사용한다. 개발자는 트랜잭션이 필요한 곳에 @Transactional 애노테이션 하나만 추가하면 된다. 나머지는 스프링 트랜잭션 AOP가 자동으로 처리해준다.

@Transactional 사용 예제


트랜잭션은 여러 데이터베이스 작업을 하나의 논리적 단위로 묶어야 할 때 사용한다. JPA를 사용한다면 단일 작업에 대해서는 이미 @Transactional이 선언되어 있기 때문에 직접 선언할 필요가 없다.

가령 JpaRepository의 구현체의 save메서드를 보면 이미 @Transactional이 선언되어 있다.

@Override
@Transactional
public <S extends T> S save(S entity) {

	Assert.notNull(entity, "Entity must not be null");
    
	if (entityInformation.isNew(entity)) {
		entityManager.persist(entity);
		return entity;
	} else {
		return entityManager.merge(entity);
	}
}

@Transactional 테스트

테스트를 진행하기 위해 TestService를 작성한다. Member 엔티티의 email 컬럼은 유니크 제약 조건으로 설정하여, 동일한 이메일로 저장할 경우 RuntimeException예외가 발생한다.

@Service
@RequiredArgsConstructor
public class TestService {

    private final MemberRepository memberRepository;
    
    /**
     * @Transactional으로 transactionalTest는 하나의 트랜잭션 내에서 실행되며,
     * saveFailure에서 예외가 발생되어 이전 데이터도 롤백되어 데이터베이스에 반영되지 않는다.
     */
    @Transactional
    public void transactionalTest() {
        saveSuccess();
        saveFailure();
    }

    /**
     * saveSuccess와 saveFailure는 별개의 트랜잭션으로 동작하며,
     * saveFailure에서 예외가 발생되어도
     * saveSuccess에서 저장한 데이터는 데이터베이스에 정상적으로 커밋된다.
     */
    public void noTransactionalTest() {
        saveSuccess();
        saveFailure();
    }

    private void saveSuccess() {
        Member member = Member.builder()
                .email("test@email.com")
                .build();
        memberRepository.save(member);
    }

    private void saveFailure() {
        Member member = Member.builder()
                .email("test@email.com")
                .build();
        memberRepository.save(member);
    }
}

테스트 코드를 작성하여 결과를 확인한다.

@SpringBootTest
class TestServiceTest {

    @Autowired
    private TestService testService;

    @Autowired
    private MemberRepository memberRepository;

    @BeforeEach
    void setUp() {
        memberRepository.deleteAll();
    }

    @Test
    @DisplayName("@Transactional 선언 시, 예외 발생 시 데이터가 롤백됨")
    void transactionalTest() {
        //given
        long initialCount = memberRepository.count();

        //when
        assertThrows(RuntimeException.class, testService::transactionalTest);

        //then
        long finalCount = memberRepository.count();
        assertEquals(initialCount, finalCount);
    }

    @Test
    @DisplayName("@Transactional 선언하지 않을 시, 예외 발생 시 데이터가 롤백되지 않음")
    void noTransactionalTest() {
        //given
        long initialCount = memberRepository.count();

        //when
        assertThrows(RuntimeException.class, testService::noTransactionalTest);

        //then
        long finalCount = memberRepository.count();
        assertNotEquals(initialCount, finalCount);
    }
}

테스트가 모두 성공하는 것을 확인할 수 있다.

@Transactional(readOnly=true)


트랜잭션은 기본적으로 읽기, 쓰기가 모두 가능한 트랜잭션이 생성된다. readOnly=true옵션을 사용하면 읽기 전용 트랜잭션이 생성되며 등록, 수정, 삭제가 안되고 읽기 기능만 작동한다.

최적화

JPA에서는 다양한 최적화가 발생한다.

  • 읽기 전용 트랜잭션의 경우 커밋 시점에 플러시를 호출하지 않는다. 읽기 전용이니 변경에 사용되는 플러시를 호출할 필요가 없다.
  • 변경이 필요 없으니 변경 감지를 위한 스냅샷 객체도 생성하지 않는다.
profile
백엔드

0개의 댓글