본문 바로가기

Programing/Framework

[Spring] @Repository Bean에서 예외 변환 없이 전파하기

@Component 는 스프링 IOC 컨테이너에 Bean 을 등록할 때 붙이는 어노테이션이다.

실제 업무를 하다보면 @Controller, @Service, @Repository 로 유스케이스 별로 특정 의미를 더 부여하여 특징을 부여할 수 있다.

더 자세한 것은 스프링 레퍼런스 1.10.1. @Component and Further Stereotype Annotations 참고

 

@Repository 의 경우 다른 스테레오 타입과 달리 예외 변환기(Exception Translation)를 거친다는 것이 차이점이 있다.

이유는 하이버네이트나 JPA 같은 DAO를 사용하게 되면 구현체에 따라 반환하는 예외가 다양해질 수 있기 때문이다.
세부 사항의 예외가 프레임워크를 넘어 던져지면 구현체의 예외를 알아야 하기 때문이다. 스프링의 예외로 래핑을 해준다.

 

문제는 @Repository 로 만든 빈에서도 방어용으로 예외를 던져야 할 경우가 있다.

만약 예외 클래스가 IllegalStateException 이나 IllegalArgumentException 를 상속한 경우에는 예외를 throw 했을 경우 예외 변환기가 예외 클래스를 바꾸어 버린다. 그래서 예상과 다르게 동작할 수 있다.

 

아래의 @Repository 에서는 @Service 동일한 유효성 검증 확인을 수행하고 있다.

@Service
class InvestmentService(
    private val investmentProductRepository: InvestmentProductRepository,
    private val investmentRepository: InvestmentRepository,
) {
    fun investment(userInvestment: UserInvestment) {
        val productId = userInvestment.productId

        val totalInvestingAmount = findTotalInvestingAmount(productId)
        val currentInvestingAmount = findCurrentInvestingAmount(productId)
        val userInvestingAmount = userInvestment.investingAmount

        // 동일한 부분이라는 부분이 여기 부분이다.
        if (currentInvestingAmount + userInvestingAmount > totalInvestingAmount) {
            throw ProductSoldOutException("상품 투자가 종료되었습니다.")
        }
        // 예외를 위에서 던지고 있다.

        investmentRepository.save(userInvestment, totalInvestingAmount)
    }
@Repository
class InvestmentRepositoryImpl(
    private val investmentJpaRepository: InvestmentJpaRepository,
    private val investmentCacheRepository: InvestmentCacheRepository,
) : InvestmentRepository {

    override fun save(userInvestment: UserInvestment, totalInvestingAmount: Long): Long {
        val jpaEntity = InvestmentJpaEntity(userInvestment)
        val afterAmount = investmentCacheRepository.incrementInvestingAmountAndCountBy(
            userInvestment.productId, userInvestment.investingAmount
        )
        // 동일한 부분이라는 부분이 여기 부분이다.
        if (afterAmount > totalInvestingAmount) {
            investmentCacheRepository.rollbackInvestingAmountAndCountBy(
                userInvestment.productId, userInvestment.investingAmount
            )
            throw ProductSoldOutException("상품 투자가 종료되었습니다.")
        }
        // 예외를 위에서 던지고 있다. 다만 다른 부분은 rollback을 하는 부분이 차이가 있다.
        
        val savedEntity = investmentJpaRepository.save(jpaEntity)
        return savedEntity.investmentId!!
    }

하지만 응답으로 발생하는 JSON은 아래와 같이 다르다. 전역 예외 처리기가 예외 모델로 응답을 처리하고 있다.

@Service 의 예외로 발생하는 응답

{
    "code": "PRODUCT_SOLD_OUT",
    "message": "상품 투자가 종료되었습니다."
}

@Repository 의 예외로 발생하는 응답

{
    "code": "INVALID_DATA_ACCESS_API_USAGE",
    "message": "상품 투자가 종료되었습니다.; nested exception is com.kakaopay.investment.core.error.ProductSoldOutException: 상품 투자가 종료되었습니다."
}

InvestmentService 및 InvestmentRepositoryImpl 둘다 ProductSoldOutException 를 던졌지만,

InvestmentRepositoryImpl 에서 던진 ProductSoldOutException 예외는 InvalidDataAccessApiUsageException 로 변환된어 던져진 것이다.

EntityManagerFactoryUtils 의 convertJpaAccessExceptionIfPossible 에서 변환한다.

변환과정에서는 PersistenceExceptionTranslationInterceptor 에 의해 등록된 PersistenceExceptionTranslator 가 수행된다.

등록된 ChainedPersistenceExceptionTranslator 와 예외는 DataAccessUtils 로 넘겨져서 처리한다.

만약 의도적으로 변환 과정을 제외시키려면 어떻게 해야 하나?

Java 의 경우는 throws 로 예외 클래스를 명시해주면 된다. 마치 Checked 예외 처럼 말이다.

그 이유는 해당 변환이 catch 하고 난 직후에 코드에 적혀있다.

alwaysTranslate 의 값이 false 이거나 해당 메서드에 예외 선언이 표시되어 있는 경우에는 catch 예외를 잡아 변환 없이 그냥 그대로 던지도록 구현이 되어 있다.

그러면 Kotlin 에서는 어떻게 하면 될까?

아래와 같이 @kotlin.jvm.Throws 를 붙여주고 예외를 명시해주면 된다.

    @kotlin.jvm.Throws(ProductSoldOutException::class)
    override fun save(userInvestment: UserInvestment, totalInvestingAmount: Long): Long {

리팩토링을 하여 @Throws 로 쓰는 것이 보기에는 깔끔해보인다.

Add import for 'kotlin.jvm.Throws' 를 수행한다.

참고로 java.lang 패키지의 예외는 IllegalStateException, IllegalArgumentException 에 대해서만 각각 InvalidDataAccessApiUsageException 및 InvalidDataAccessApiUsageException 로 변환을 한다.

그 이유는 EntityManagerFactoryUtils 의 convertJpaAccessExceptionIfPossible 에 그렇게 정의가 되어 있기 때문이다.

마지막까지 매칭되는 것이 없으면 null 을 반환하는데, DataAccessUtils의 translateIfNecessary 에서는 null 을 반환하는 경우에는 원래의 예외(raw exception)을 반환하기 때문이다.

 

참고

stackoverflow - throws Exception in a method with Kotlin