[Spring] 멀티 데이터 소스 환경에서 더티체킹이 왜 동작하지 않을까

Hocaron·2024년 1월 9일
1

Spring

목록 보기
36/44
post-custom-banner

Member, Account 라는 2개의 데이터 소스를 사용하는 프로젝트가 있다. 더티체킹을 통해 Account 라는 엔티티의 데이터를 업데이트 하려고 한다. 코드를 보자.

Account 는 더티체킹이 되지 않는다

	@Transactional
	public void update() {
    
		var account = accountRepository.findByMemberId(member.getId())
				.orElseThrow();
		account.updateAccount(UPDATED_ACCOUNT);
	}

더티체킹을 통해 업데이트를 하는 전형적인 로직이다. 왜 updateAccount 메서드 종료 후에 실제 DB 에 반영이 되지 않았을까?

멀티데이터 소스 설정을 보자

@EnableJpaRepositories(
	basePackages = "com.spring.boilerplate.repository.member",
	entityManagerFactoryRef = "memberEntityManagerFactory",
	transactionManagerRef = "memberTransactionManager"
)
public class MemberDataSourceConfig {

	@Primary
	@Bean
	public PlatformTransactionManager memberTransactionManager() {
		JpaTransactionManager transactionManager = new JpaTransactionManager();
		transactionManager.setEntityManagerFactory(memberEntityManagerFactory().getObject());
		return transactionManager;
	}
...
@EnableJpaRepositories(
	basePackages = "com.spring.boilerplate.repository.account",
	entityManagerFactoryRef = "accountEntityManagerFactory",
	transactionManagerRef = "accountTransactionManager"
)
@RequiredArgsConstructor
public class AccountDataSourceConfig {

	@Bean
	public PlatformTransactionManager accountTransactionManager() {
		JpaTransactionManager transactionManager = new JpaTransactionManager();
		transactionManager.setEntityManagerFactory(accountEntityManagerFactory().getObject());
		return transactionManager;
	}
...

Member, Account 두개의 데이터소스가 있을 때, Member 트랜잭션 매니저가 Primary Bean 으로 등록을 해놓았네..?!

그럼 Member 는 더티체킹이 될까?

	@Transactional
	public void update(String email) {
		Member member = memberRepository.findByEmail(email)
				.orElseThrow();

		member.updateEmail(OUTER_CHANGED_EMAIL);
	}

@Transactional 에 아무런 설정이 없는 경우, Member 트랜잭션 매니저가 Primary Bean 으로 등록되어있기 때문에 Member 더티체킹은 정상 동작하는 것을 알 수 있다.

Account(Primary Bean 이 관리하고 있지않은 엔티티)를 업데이트하는 방법에 대해 알아보자

그럼 Account 더티체킹하려면 어떻게 해야하나?

	@Transactional("accountTransactionManager")
	public void updateWithAccountEntityManager() {

		var account = accountRepository.findByMemberId(member.getId())
				.orElseThrow();
		account.updateAccount(UPDATED_ACCOUNT);
	}

@Transactional 에 어떤 트랜잭션 매니저를 주입할 것인지 명시해주면 된다. Account 데이터 소스에 있는 엔티티를 수정하고 싶기 때문에 accountTransactionManager 를 주입해주었다. 결과는 정상 동작한다!

Account 를 더티체킹말고, save() 쿼리 메서드를 통해서도 업데이트를 할 수 있다

	@Transactional
	public void updateWithAccountEntityManager() {

		var account = accountRepository.findByMemberId(member.getId())
				.orElseThrow();
		account.updateAccount(UPDATED_ACCOUNT);
        accountRepository.save(account);
	}

accountRepository 는 EnableJpaRepositories 설정을 통해 account 트랜잭션 매니저가 관리하도록 설정하였으므로, save() 쿼리 메서드 내에 있는 @Transactional은 Account 트랜잭션 매니저가 관리하게 된다. 결과적으로 save() 쿼리 메서드를 사용한다면, 업데이트가 가능하다.

@EnableJpaRepositories(
	basePackages = "com.spring.boilerplate.repository.account",
	entityManagerFactoryRef = "accountEntityManagerFactory",
	transactionManagerRef = "accountTransactionManager"
)
@RequiredArgsConstructor
public class AccountDataSourceConfig {

흥미로운 실험을 해보자 👩‍🔬

A 메서드 내에서 B 메서드를 호출한다.
A 는 Member 를 업데이트하고, B 메서드에 파라미터로 업데이트한 Member 를 넘긴다. B 는 받은 Member 를 업데이트하고 Accoun 를 조회하여 업데이트한다.

member 엔티티 매니저가 수행하는 내부 트랜잭션에서 account 더티체킹이 동작하지 않는다.

-- A 몌서드
	@Transactional
	public Member update(String email) {
		Member member = memberRepository.findByEmail(email)
				.orElseThrow();

		member.updateEmail(OUTER_CHANGED_EMAIL);
		accountService.update(member); // B 메서드 호출
		return member;
	}

-- B 메서드
	@Transactional
	public void update(Member member) {
		member.updateEmail(INNER_UPDATED_EMAIL);
		var account = accountRepository.findByMemberId(member.getId())
				.orElseThrow();
		account.updateAccount(UPDATED_ACCOUNT);
	}
    @DisplayName("member 엔티티 매니저가 수행하는 내부 트랜잭션에서 account 더티체킹이 동작하지 않는다.")
    @Test
    void dirtyCheckingWithPrimaryEntityManagerTest() {
        memberService.update(NEW_EMAIL);

        var account = accountRepository.findById(accountId).get();
        assertThat(account.getAccount()).isEqualTo(NEW_ACCOUNT);
    }

account 엔티티 매니저가 수행하는 내부 트랜잭션에서 account 더티체킹이 동작한다.

-- A 몌서드
	@Transactional
	public Member updateWithSpecificEntityManager(String email) {
		Member member = memberRepository.findByEmail(email)
				.orElseThrow();
		member.updateEmail(OUTER_CHANGED_EMAIL);
		accountService.updateWithAccountEntityManager(member); // B 메서드 호출
		return member;
	}

-- B 메서드
	@Transactional("accountTransactionManager")
	public void updateWithAccountEntityManager(Member member) {
		member.updateEmail(INNER_UPDATED_EMAIL);
		var account = accountRepository.findByMemberId(member.getId())
				.orElseThrow();
		account.updateAccount(UPDATED_ACCOUNT);
	}
    @DisplayName("account 엔티티 매니저가 수행하는 내부 트랜잭션에서 account 더티체킹이 동작한다.")
    @Test
    void dirtyCheckingWithAccountEntityManagerTest() {
        memberService.updateWithSpecificEntityManager(NEW_EMAIL);

        var account = accountRepository.findById(accountId).get();
        assertThat(account.getAccount()).isEqualTo(UPDATED_ACCOUNT);
    }

member 엔티티 매니저가 수행하는 내부 트랜잭션에서 member 더티체킹이 동작한다.

-- A 몌서드
	@Transactional
	public Member update(String email) {
		Member member = memberRepository.findByEmail(email)
				.orElseThrow();

		member.updateEmail(OUTER_CHANGED_EMAIL);
		accountService.update(member); // B 메서드 호출
		return member;
	}

-- B 메서드
	@Transactional
	public void update(Member member) {
		member.updateEmail(INNER_UPDATED_EMAIL);
		var account = accountRepository.findByMemberId(member.getId())
				.orElseThrow();
		account.updateAccount(UPDATED_ACCOUNT);
	}
    @DisplayName("member 엔티티 매니저가 수행하는 내부 트랜잭션에서 member 더티체킹이 동작한다.")
    @Test
    void dirtyCheckingWithPrimaryEntityManagerTest() {
        memberService.update(NEW_EMAIL);

        var member = memberRepository.findById(memberId).get();
        assertThat(member.getEmail()).isEqualTo(INNER_UPDATED_EMAIL);
    }

account 더티체킹 동작하지 않는 코드와 동일하다. Member 는 더티체킹이 동작하는 것을 알 수 있다.

account 엔티티 매니저가 수행하는 내부 트랜잭션에서 member 더티체킹이 동작한다.

-- A 몌서드
	@Transactional
	public Member updateWithSpecificEntityManager(String email) {
		Member member = memberRepository.findByEmail(email)
				.orElseThrow();
		member.updateEmail(OUTER_CHANGED_EMAIL);
		accountService.updateWithAccountEntityManager(member); // B 메서드 호출
		return member;
	}

-- B 메서드
	@Transactional("accountTransactionManager")
	public void updateWithAccountEntityManager(Member member) {
		member.updateEmail(INNER_UPDATED_EMAIL);
		var account = accountRepository.findByMemberId(member.getId())
				.orElseThrow();
		account.updateAccount(UPDATED_ACCOUNT);
	}
    @DisplayName("account 엔티티 매니저가 수행하는 내부 트랜잭션에서 member 더티체킹이 동작한다.")
    @Test
    void dirtyCheckingWithAccountEntityManagerTest() {
        memberService.updateWithSpecificEntityManager(NEW_EMAIL);

        var member = memberRepository.findById(memberId).get();
        assertThat(member.getEmail()).isEqualTo(INNER_UPDATED_EMAIL);
    }

account 더티체킹 동작하는 코드와 동일하다. Member 는 여기서도 더티체킹이 동작하는 것을 알 수 있다 😬

내부 메서드인 B 메서드 수행 이후에 A 메서드에서 예외가 발생하면, B 는 롤백될까?

-- A 메서드
	@Transactional
	public Member updateWithSpecificEntityManagerAndThrow(String email) {
		Member member = memberRepository.findByEmail(email)
				.orElseThrow();
		member.updateEmail(OUTER_CHANGED_EMAIL);
		accountService.updateWithAccountEntityManager(member); // B 메서드
		throw new RuntimeException();
	}
    
-- B 메서드
	@Transactional("accountTransactionManager")
	public void updateWithAccountEntityManager(Member member) {
		member.updateEmail(INNER_UPDATED_EMAIL);
		var account = accountRepository.findByMemberId(member.getId())
				.orElseThrow();
		account.updateAccount(UPDATED_ACCOUNT);
	}
    @DisplayName("내부 트랜잭션은 account 엔티티 매니저가 수행해서, member 만 롤백된다.")
    @Test
    void updateWithSpecificEntityManagerAndThrow() {
        assertThatExceptionOfType(Exception.class)
                .isThrownBy(() -> memberService.updateWithSpecificEntityManagerAndThrow(NEW_EMAIL));

        var member = memberRepository.findById(memberId).orElseThrow();
        var account = accountRepository.findById(accountId).orElseThrow();
        assertThat(member.getEmail()).isEqualTo(NEW_EMAIL);
        assertThat(account.getAccount()).isEqualTo(UPDATED_ACCOUNT);
    }

그렇다... B 메서드는 accountTransactionManager 가 관리하기 때문에 롤백되지 않는다...

하지만 데이터 정합성을 위해서 둘다 롤백이 필요한 경우가 더 많을 것이다. 이 경우에는 어떻게 해야할까?

TransactionSynchronizationManager 디버깅하면 이것저것 알아볼 수 있다(번외)

	@Transactional
	public Member updateAndInnerThrow(String email) {
		Member member = memberRepository.findByEmail(email)
				.orElseThrow();
		member.updateEmail(OUTER_CHANGED_EMAIL);
		accountService.updateAndThrow(member);
		TransactionSynchronizationManager.getResourceMap(); // 🔥🔥🔥
		return member;
	}

🔥🔥🔥 디버깅해보자. 프록시, 히카리 데이터 소스 설정 등을 볼 수 있다. 알려주신 규현님께 감사합니다🙇‍♀️

데이터 정합성을 위해 둘다 롤백 시키고 싶은데, 어떤 방법이 있으려나

B 메서드를 제일 나중에 수행할 수 있도록 위치 변경

	@Transactional
	public Member updateAndInnerThrow(String email) {
		Member member = memberRepository.findByEmail(email)
				.orElseThrow();
		member.updateEmail(OUTER_CHANGED_EMAIL);
		accountService.updateAndThrow(member); // B 메서드
		return member;
	}
    @DisplayName("내부에서 예외가 발생하여, 둘다 롤백된다.")
    @Test
    void updateAndInnerThrow() {
        assertThatExceptionOfType(Exception.class)
                .isThrownBy(() -> memberService.updateAndInnerThrow(NEW_EMAIL));

        var member = memberRepository.findById(memberId).orElseThrow();
        var account = accountRepository.findById(accountId).orElseThrow();
        assertThat(member.getEmail()).isEqualTo(NEW_EMAIL);
        assertThat(account.getAccount()).isEqualTo(NEW_ACCOUNT);
    }

B 메서드가 제일 나중에 수행될 수 있도록 하여, A 메서드에서 예외 발생 시에는 B 가 수행되지 못하도록하여 데이터 정합성을 처리한다. B 에서 수행시에 발생한 예외는 A 에서도 롤백이 되므로 데이터 정합성을 맞출 수 있다.

JTX 같은 글로벌 트랜잭션 사용

위 경우는 B 메서드 반환값이 void 로, B 에서 처리한 데이터를 A 에서 사용하지 않는다. 하지만 B 에서 처리한 데이터로 나머지 A 로직에서 추가로 처리해야 하는 경우도 있다. 이런 경우 글로벌 트랜잭션(분산 트랜잭션)을 사용할 수 있는데... 다음 블로그 포스트에서 알아보자.

정리

  • 멀티데이터 소스를 사용하는 경우, 트랜잭션 사용시 어떤 트랜잭션 매니저가 주입되었는지에 따라 더티체킹 가능 / 불가능이 나뉜다.
  • 데이터 정합성을 위해 글로벌 트랜잭션을 사용할 수 있다.

모든 테스트 코드는 여기에서 확인할 수 있다.

profile
기록을 통한 성장을
post-custom-banner

2개의 댓글

comment-user-thumbnail
2024년 1월 10일

오늘도 배워 갑니닷!! 👍👍👍

1개의 답글