트랜잭션에 대해

최창효·2023년 7월 6일
0
post-thumbnail
post-custom-banner

한 줄 요약: 트랜잭션은 데이터베이스의 상태를 변화시키기 위한 작업의 단위이다.

지금까지 잘못 알고 있던 사실

저는 지금껏 Service Layer에 존재하는 모든 메서드에 @Transactional을 사용했었습니다.
제가 메서드마다 트랜잭션을 선언했던 이유는 간단합니다. 메서드의 모든 작업이 하나의 묶음으로 진행되길 원했기 때문입니다.

문제 인지

어느날 외부 API를 활용하는 작업을 진행하면서 트랜잭션 내부에서 외부 API를 호출하는 건 좋지 않다는 사실을 알게 되었습니다. 그래서 리팩토링을 시작했습니다.

우선 이랬던 코드를

@Transactional
public ExchangeMoneyResponse exchangeMoney(Currency source, Currency target, double givenRemittance){
	double exchangeRate = getExchangeRate(source,target); // 외부 API 호출 로직
	Exchange exchange = Exchange.of(source,target,remittance,exchangeRate);
	exchangeRepository.save(exchange);
	return new ExchangeMoneyResponse(exchange.calculateRemittance(),target);
}

이렇게 수정했습니다.

@Transactional
public ExchangeMoneyResponse exchangeMoney(Currency source, Currency target, double givenRemittance){
    double exchangeRate = getExchangeRate(source,target); // 외부 API 호출 로직
    Exchange exchange = saveExchange(source,target,remittance,exchangeRate);
    return new ExchangeMoneyResponse(exchange.calculateRemittance(),target);
}

@Transactional
public Exchange saveExchange(Currency source, Currency target, Remittance remittance, double exchangeRate){
    Exchange exchange = Exchange.of(source,target,remittance,exchangeRate);
    return exchangeRepository.save(exchange);
}
  • 데이터를 저장하는 로직을 별도의 메서드로 분리했습니다. 하지만 여전히 외부 API 호출 로직은 트랜잭션 내부에서 호출되고 있습니다. 아직까지 저는 모든 메서드가 @Transactional이 필요하다는 잘못된 생각에 사로잡혀 있는 상태입니다. 위 코드는 메서드만 분리했을 뿐 결과적으로 아무것고 달라진 게 없습니다.

한번 더 공부한 뒤 수정한 결과는 아래와 같습니다.

public ExchangeMoneyResponse exchangeMoney(Currency source, Currency target, double givenRemittance){
    double exchangeRate = getExchangeRate(source,target); // 외부 API 호출 로직
    Exchange exchange = saveExchange(source,target,remittance,exchangeRate);
    return new ExchangeMoneyResponse(exchange.calculateRemittance(),target);
}

@Transactional
public Exchange saveExchange(Currency source, Currency target, Remittance remittance, double exchangeRate){
    Exchange exchange = Exchange.of(source,target,remittance,exchangeRate);
    return exchangeRepository.save(exchange);
}
  • exchangeMoney메서드는 @Transactional이 필요하지 않습니다.
  • 이렇게 리팩토링 하면 Exchange라는 Entity를 저장할 때 필요한 exchangeRate라는 외부 호출을 통한 값을 구하는 과정은 트랜잭션에 포함되지 않게 됩니다.

생각해보면 트랜잭션은 작업이 정상적이지 않을 때 롤백하기 위한 기능입니다. DB와 관련되지 않은 작업에서 '롤백'이라는 개념은 어울리지 않습니다. 메서드 스택이 소멸되면 어차피 안에 있던 변수들은 함께 사라지게 되는데 굳이 '롤백'이라는 의미를 지닐 필요가 없다는 걸 깨달았습니다.

또 다른 문제

아쉽게도 위 코드 역시 우리가 원하는대로 동작하지 않습니다. TransactionSynchronizationManager.isActualTransactionActive()는 해당 메서드에 트랜잭션이 적용되는지 확인할 수 있는 코드입니다. 이를 통해 트랜잭션 적용 여부를 확인해 보겠습니다.

public ExchangeMoneyResponse exchangeMoney(Currency source, Currency target, double givenRemittance){
    boolean txActive = TransactionSynchronizationManager.isActualTransactionActive();
    System.out.println("exchangeMoneyMethod: "+txActive);

    double exchangeRate = getExchangeRate(source,target); // 외부 API 호출 로직
    Exchange exchange = saveExchange(source,target,remittance,exchangeRate);
    return new ExchangeMoneyResponse(exchange.calculateRemittance(),target);
}

@Transactional
public Exchange saveExchange(Currency source, Currency target, Remittance remittance, double exchangeRate){
    boolean txActive = TransactionSynchronizationManager.isActualTransactionActive();
	System.out.println("saveExchangeMethod: "+txActive);

    Exchange exchange = Exchange.of(source,target,remittance,exchangeRate);
    return exchangeRepository.save(exchange);
}

위와 같이 설정한 뒤 코드를 실행하면 다음과 같은 결과가 나옵니다.

exchangeMoney는 애초에 @Transactional을 적용하지 않았기 때문에 트랜잭션이 적용되지 않는게 쉽게 이해됩니다. 하지만 saveExchange메서드는 @Transactional을 선언했는데 왜 트랜잭션이 적용되지 않았을까요?

스프링의 @Transactional프록시 방식으로 동작합니다. @Transactional이 활용된 클래스는 원본 객체가 아닌 프록시 객체가 등록되고, 트랜잭션이 적용된 메서드는 프록시 객체에서 전후로 트랜잭션 작업을 처리하고 중간에 원본 메서드가 호출되는 방식이 됩니다.
그렇기 때문에 트랜잭션이 적용되려면 항상 프록시 객체를 통해 원본 객체를 호출해야 합니다.

원본 객체인 originalClass는 다음과 같이 생겼다면

public class OriginalClass{
	@Transactional
	public void txMethod(){}
}

스프링 빈에 등록되는 프록시 객체는 대략적으로 다음과 같은 모양일 겁니다.

public class ProxyClass{
	@Autowired
	private OriginalClass originalClass;
    
    public void txMethod(){
    	tx.start();
        originalClass.txMethod();
        tx.end();
    }    
}

이러한 내용을 바탕으로 우리 코드를 다시 살펴보겠습니다.

public class ExchangeService{
	public ExchangeMoneyResponse exchangeMoney(Currency source, Currency target, double givenRemittance){
    	double exchangeRate = getExchangeRate(source,target);
	    Exchange exchange = saveExchange(source,target,remittance,exchangeRate);
	    return new ExchangeMoneyResponse(exchange.calculateRemittance(),target);
	}
	@Transactional
	public Exchange saveExchange(Currency source, Currency target, Remittance remittance, double exchangeRate){
    	Exchange exchange = Exchange.of(source,target,remittance,exchangeRate);
    	return exchangeRepository.save(exchange);
	}
}
  • ExchangeService의 saveExchange에 @Transactional이 적용됐기 때문에 프록시 객체로 스프링 빈에 등록될 겁니다.
public class ExchangeServiceProxy{
	@Autowired
    private ExchangeService exchangeService;
    
	public ExchangeMoneyResponse exchangeMoney(Currency source, Currency target, double givenRemittance){
    	return exchangeService.ExchangeMoney();
	}
    
	public Exchange saveExchange(Currency source, Currency target, Remittance remittance, double exchangeRate){
    	tx.start();
        Exchange value = exchangeService.saveExchange();
        tx.end();
        return value;
	}
}
  • exchangeMoney는 트랜잭션이 설정되어 있지 않기 때문에 곧바로 원본 객체 인스턴스(exchangeService)의 ExchangeMoney를 호출합니다.
  • 원본 객체인 ExchangeServiceexchangeMoneysaveExchange메서드를 호출합니다. 이때 메서드를 호출하는 saveExchange(source,target,remittance,exchangeRate);는 사실 this.saveExchange(source,target,remittance,exchangeRate);입니다.
  • 여기서 thisExchangeService를 의미하기 때문에 ExchangeServiceProxysaveExchange가 아니라 ExchangeServicesaveExchange가 실행되고, 이는 트랜잭션이 적용되지 않는 일반 메서드가 됩니다.

정리

  • @Transactional이 선언된 클래스는 원본 객체가 아니라 프록시 객체가 스프링 빈에 등록된다.
  • 트랜잭션이 적용되려면 항상 프록시 객체를 통해 원본 객체를 호출해야 합니다.
  • 원본 객체 내부에서 곧바로 @Transactional이 선언된 메서드를 호출(내부 호출)하더라도 이는 프록시 객체를 호출하는 게 아니라 원본 객체를 곧바로 호출하는 방식이기 때문에 트랜잭션이 적용되지 않습니다.

해결 방법

내부 호출로 원본 객체를 직접 호출했기 때문에 프록시 객체를 활용하지 못했습니다.
해당 메서드를 별도의 클래스로 분리하면 더 이상 내부 호출이 아니기 때문에 정상적으로 프록시 객체를 활용할 수 있게 됩니다.

트랜잭션을 적용했던 saveExchange메서드를 별도의 클래스에서 정의했습니다.

@Component
@RequiredArgsConstructor
public class SaveExchange {
    private final ExchangeRepository exchangeRepository;

    @Transactional
    public Exchange saveExchange(Currency source, Currency target, Remittance remittance, double exchangeRate){
        boolean txActive = TransactionSynchronizationManager.isActualTransactionActive();
        System.out.println("saveExchangeMethod: "+txActive);

        Exchange exchange = Exchange.of(source,target,remittance,exchangeRate);
        return exchangeRepository.save(exchange);
    }
}

ExchangeService는 정의된 클래스를 주입받아 호출합니다. 이 과정에서 프록시 객체를 주입받게 되므로 트랜잭션을 원하는대로 활용할 수 있습니다.

@Service
@RequiredArgsConstructor
public class ExchangeService {
	// 주입
    private final SaveExchange saveExchange;
    
    public ExchangeMoneyResponse exchangeMoney(Currency source, Currency target, double givenRemittance){
        Remittance remittance = Remittance.from(givenRemittance);
        double exchangeRate = getExchangeRate(source,target);

        boolean txActive = TransactionSynchronizationManager.isActualTransactionActive();
        System.out.println("exchangeMoneyMethod: "+txActive);

        Exchange exchange = saveExchange.saveExchange(source,target,remittance,exchangeRate);

        return new ExchangeMoneyResponse(exchange.calculateRemittance(),target);
    }    
}

실행 결과는 다음과 같습니다.

처음 목표한대로 saveExchange메서드에 대해서만 트랜잭션이 적용되고, 외부 API통신을 진행하는 getExchangeRate작업을 담은 exchangeMoney메서드는 트랜잭션이 적용되지 않았습니다.

결론

  • Service Layer의 메서드에 트랜잭션을 남발하지 말자.
  • 트랜잭션은 DB와 연관된 작업이 일관성있게 진행되기 위해 필요한 것이다.
  • 트랜잭션 작업동안 해당 쓰레드는 DB 커넥션을 가지고 있게 된다. 그렇기 때문에 외부 API호출 등 시간이 오래 걸리는 작업을 트랜잭션 안에서 실행하면 DB 커넥션이 부족할 수 있으므로 시간이 오래 걸리는 작업은 트랜잭션 외부에서 실행하자.
  • 내부 호출로 실행되는 메서드는 @Transactional이 선언되어 있더라도 트랜잭션이 적용되지 않는다.
profile
기록하고 정리하는 걸 좋아하는 개발자.
post-custom-banner

0개의 댓글