Spring) Transaction Handling 은 어떻게 하면 좋을까?

박우영·2023년 11월 19일
0

자바/코틀린/스프링

목록 보기
35/35

개요

회사에서 코드를 보며 든 생각이 Hexagonal 아키텍처에 Adapter와 Service중
Transaction 의 위치를 어디에 하는게 더 좋을까 라는 생각이 들었는데

물론 어느게 맞고 틀리다가 아닌 해당 비즈니스 로직에 맞춰 작성하는것이 맞다고 생각하지만
흔히 말하는 best practice가 궁금했고 Transaction을 어떻게 관리해야 내가 작성한 비즈니스 로직을 handling 하기 좋은 방법에 대해 고민한 것을 공유하고자 합니다.

@Transactional 어디에 둬야할까?

2가지 예시로 비교를 해보겠습니다.
1. Service(@Transacitonal) - Repository
2. Service - outPort(@Transacitonal) - Repository
Repository는 JpaRepository를 상속받으며 save 메소드를 활용합니다.

SimpleJpaRepository

	@Transactional
	@Override
	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);
		}
	}

Save function 의 RuntimeException 이 발생하는 경우는 2가지 경우 입니다.

  1. IllegalArgumentException: parameter 의 entity 가 null 일경우
  2. OptimisticLockingFailureException: 최소한 하나의 엔티티가 낙관적 락을 사용하고 버전 속성이 영속성 저장소에서 찾은 값과 다를 때, 최소한 하나의 엔티티가 데이터베이스에 존재한다고 가정되지만 실제로는 존재하지 않을 경우 발생

그렇다면 Outport 에 Transaction이 있고, Service 는 Facade 의 역할만 하면 어떻게 될까요??

한가지 예시를 들어보면 Member Entity가 생성되었을때 장바구니 Entity 가 생성되는 경우라고 해보겠습니다.

flow 를 그려보면 다음과 같이 두개의 Entity 가 Atomic 하게 동작하는 것을 원한다는 것을 알 수 있습니다.

만약 코드로 표현 하면 어떻게 될까요?

먼저 Transaction 을 OutPort 에서 구현했을때 입니다.

Tansactional in OutPort

Person outPort

@Component
class PersonAdapter(
    val personRepository: PersonRepository,
) : PersonPort {
    @Transactional
    override fun createPerson(): Person {
        val person = Person(
            name = "name",
            10,
        )
        personRepository.save(person)
        return person
    }
 }

cart outPort

@Component
class CartAdapter(
    private val cartRepository: CartRepository,
) : CartPort {
    @Transactional
    override fun save() {
        val cart = Cart()
        cartRepository.save(cart)
    }
}

service

@Service
class NoTransactionService(
    private val personAdapter: PersonAdapter,
    private val cartPort: CartPort,
) {
    fun save() {
        personAdapter.save()
        cartPort.save()
    }
}

매우 간단하게 작성해봤습니다. 성공한 경우 문제되지 않겠죠 하지만 Cart Entity 를 생성하는데 실패했다면 어떻게 동작할까요?

Cart 를 save 하는 Transaction 에서 RuntimeException 을 발생시키고 호출해보겠습니다.

SELECT p.id as person_id, c.id as cart_id FROM person p left join cart as c on 1=1;

쿼리를 날려보면 다음과 같은 결과를 얻을 수 있습니다.

이미 Person 의 쿼리가 commit 이 이루어져 Person 과 Cart 의 Transaction 이 Atomic 하게 이뤄지지 않고있죠

물론 실제로 작성할땐 이렇게 하지 않겠지만 이런 트랜잭션 을 Handling 하기 위해선 실제 비즈니스 로직이 이루어지는 Service 에서 Transaction 을 사용하는것이 조금 더 적합하지 않을까 라는 생각입니다.

그럼 다른 경우는 없을까?

물론 다른 경우도 있을거라 생각합니다. 부모 트랜잭션에 영향을 끼치고 싶지 않을 경우

그걸 위해 Spring 에서는 트랜잭션 전파를 제공해주고 있습니다.

이번엔 Person Entity 를 생성하고 Person Entity 가 생성되었다는 Log 를 기록하는 경우 입니다.

Service 에서 Transactional 을 작성했을때 PersonLog 에서 실패한다면 당연히 Rollback 이 이뤄질 겁니다.

Swallowed Exception 은 어때?

그럼 여기서 궁금한 점이 있을건데 그냥 RunTimeException 을 try catch 로 잡아버리면 되는거 아냐? 라고 생각하실 수 있을겁니다. 물론 부모 트랜잭션은 성공하겠지만 우리가 원하는 자식 트랜잭션 에서의 Atomic 또한 잃게 될 것입니다.

여러개의 트랜잭션에서 자식 트랜잭션이 부모 트랜잭션에 영향을 끼치지 않는 방법 을 소개하는 거라고 생각하시면 되겠습니다.

Requires New

물론 다른 여러가지 방법이 있겠지만 제가 선택한 방법은 Requires New 를 선언하고 해당 자식 트랜잭션을 try catch 로 Swallowed 하는 방법 입니다.

log service

@Service
class PersonLogService(
    private val personLogPort: PersonLogPort,
) {
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    fun save() {
        personLogPort.save()
    }
}

다음과 같이 쿼리를 날려보면

SELECT p.id as person_id, l.id as log_id from person p left join person_log as l on 1=1;

자식 트랜잭션이 부모 트랜잭션에 영향을 끼치지 않은 것을 확인 할 수 있습니다.

물론 더 많은 방법들이 존재하고 더 좋은 solution이 있겠지만 Spring Transaction 에 대해 감을 못잡으셨던 분들에게 좋은 자료가 되어으면 합니다.

0개의 댓글