[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 로 변환된어 던져진 것이다.
변환과정에서는 PersistenceExceptionTranslationInterceptor 에 의해 등록된 PersistenceExceptionTranslator 가 수행된다.
만약 의도적으로 변환 과정을 제외시키려면 어떻게 해야 하나?
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 로 쓰는 것이 보기에는 깔끔해보인다.
참고로 java.lang 패키지의 예외는 IllegalStateException, IllegalArgumentException 에 대해서만 각각 InvalidDataAccessApiUsageException 및 InvalidDataAccessApiUsageException 로 변환을 한다.
그 이유는 EntityManagerFactoryUtils 의 convertJpaAccessExceptionIfPossible 에 그렇게 정의가 되어 있기 때문이다.
마지막까지 매칭되는 것이 없으면 null 을 반환하는데, DataAccessUtils의 translateIfNecessary 에서는 null 을 반환하는 경우에는 원래의 예외(raw exception)을 반환하기 때문이다.
참고
stackoverflow - throws Exception in a method with Kotlin