@Repository의 스프링 예외 추상화

최창효·2023년 9월 3일
1
post-thumbnail

스프링의 데이터 접근 예외 추상화

스프링은 데이터 접근과 관련된 예외를 추상화해 제공합니다.

기본적으로 우리는 어떤 데이터베이스 접근 기술을 사용하냐에 따라 받게되는 예외가 다릅니다. 가령 데이터를 찾지 못했을 때 MyBatis에서는 MB-111이라는 에러를 던졌다면 JdbcTemplate에서는 JT-123이라는 에러를 던져주는 형식일 수 있습니다. 만약 우리의 서비스가 이러한 에러를 직접 처리하고 있다면 MyBatis에서 JPA로 기술을 변경했을 때 에러처리에 대한 코드도 모두 수정해줘야 할 겁니다.

이러한 문제를 해결하기 위해 스프링은 데이터 접근과 관련된 예외를 추상화 해 우리에게 제공해주고 있습니다. MyBatis에서 던지는 MB-111번 에러, 그리고 JdbcTemplate에서 던지는 JT-123형태의 에러를 모두 DataAccessException형태의 에러로 변환해 우리에게 전달해 줍니다.

덕분에 우리는 DataAccessException에러를 처리하는 코드만 필요할 뿐 사용하는 기술에 종속적인 에러처리를 설계하지 않아도 됩니다.

스프링 데이터 접근 예외 계층

  • 스프링이 제공하는 모든 데이터 접근과 관련된 예외의 최상위는 DataAccessException입니다. 그리고 DataAccessExceptionRuntimeException을 상속받고 있습니다. 스프링이 제공하는 모든 데이터 접근 관련 예외는 catch하지 못했을 때 throws를 명시적으로 작성할 필요가 없다는 얘기죠.
  • DataAccessExceptionTransient예외와 NonTransient예외로 나뉩니다.
    • Transient는 일시적이라는 뜻으로 Transient하위 예외는 동일한 SQL을 다시 시도했을 때 성공할 가능성이 있습니다.
      ex) 쿼리 타임아웃, 락과 관련된 오류는 DB상태가 좋아지거나 락이 풀렸을 때 다시 시도하면 성공할 수도 있습니다.
    • NonTransient는 일시적이지 않다는 뜻으로 같은 SQL을 그대로 반복해서 실행하면 계속 실패합니다.
      ex) SQL문법 오류, DB제약조건 위배와 같은 경우

스프링이 제공하는 예외 변환기

스프링이 제공하는 SQLExceptionTranslator를 이용하면 개발자가 직접 특정 예외를 스프링 예외로 변환할 수도 있습니다. SQLExceptionTranslator인터페이스는 translate를 구현하도록 명시하고 있으며 이를 이용하면 특정 예외를 스프링 예외로 변환할 수 있습니다.

try{
	// DB works
}catch (SQLException e){
	SQLExceptionTranslator exTranslator = new SQLErrorCodeSQLExceptionTranslator(dataSource);
	DataAccessException springException = exTranslator.translate("작업 명", sql, e);
    log.info("springException", springException);
    throw springException;
}
  • translate()메서드는 첫 인자로 예외에 대한 설명, 두번째 인자로 실행한 sql, 세번째 인자로 발생한 SQLException을 받아 DataAccessException을 반환합니다.

@Repository

스프링에서는 데이터 액세스 계층에서 @Repository어노테이션을 사용하면 데이터베이스 예외가 아닌 DataAccessException계열의 스프링 예외를 받게 됩니다.

@Repository를 통해 해당 빈이 예외 변환 AOP의 적용 대상 빈으로 등록되고, 스프링 부트는 기본적으로 AOP를 적용하고 있기 때문에 가능한 일입니다.

스프링은 PersistenceExceptionTranslationPostProcessor라는 빈 후처리기를 등록해 뒀습니다. 해당 빈 후처리기를 통해 @Repository가 붙은 빈은 PersistenceExceptionTranslationInterceptor라는 어드바이스가 적용됩니다.

PersistenceExceptionTranslationInterceptor는 데이터 액세스 계층의 예외를 캐치하고, 이를 DataAccessException 계열의 예외로 변환합니다. (PersistenceExceptionTranslationInterceptor는 spring-tx모듈에서 관리됩니다.)

코드 보기

PersistenceExceptionTranslationPostProcessor는 빈 후처리기입니다.
빈 후처리기는 객체를 빈으로 등록하기 전에 어떠한 조작을 하고 싶을 때 사용하는 기술입니다. 단순한 조작뿐만 아니라 스프링 컨테이너에 등록되는 객체 자체를 바꿔치기 할 수도 있습니다.

빈 후처리기는 어떤 객체에 대해 어떤 조작을 하겠다는 정보가 필요합니다. 이러한 정보를 어드바이저라고 합니다. 어드바이저어드바이스 + 포인트컷으로 구성됩니다.

  • 어드바이스는 '어떤 조작을 하겠다'를 결정해 줍니다. 어드바이스란 적용할 부가기능 그 자체를 의미합니다.
  • 포인트컷은 '어떤 객체에 대해'조작할지를 결정해 줍니다. 포인트컷이란 어드바이스가 적용될 위치를 의미합니다.

한마디로 pointcut에 해당하는 빈들에 대해 advice동작을 실행하라는 의미를 담고 있습니다.

PersistenceExceptionTranslationPostProcessor

public class PersistenceExceptionTranslationPostProcessor extends AbstractBeanFactoryAwareAdvisingPostProcessor {

	private Class<? extends Annotation> repositoryAnnotationType = Repository.class;

	public void setRepositoryAnnotationType(Class<? extends Annotation> repositoryAnnotationType) {
		Assert.notNull(repositoryAnnotationType, "'repositoryAnnotationType' must not be null");
		this.repositoryAnnotationType = repositoryAnnotationType;
	}

	@Override
	public void setBeanFactory(BeanFactory beanFactory) {
		super.setBeanFactory(beanFactory);

		if (!(beanFactory instanceof ListableBeanFactory)) {
			throw new IllegalArgumentException(
					"Cannot use PersistenceExceptionTranslator autodetection without ListableBeanFactory");
		}
		this.advisor = new PersistenceExceptionTranslationAdvisor(
				(ListableBeanFactory) beanFactory, this.repositoryAnnotationType);
	}

}

주목할 부분은 여기입니다.

		this.advisor = new PersistenceExceptionTranslationAdvisor(
				(ListableBeanFactory) beanFactory, this.repositoryAnnotationType);

PersistenceExceptionTranslationPostProcessorPersistenceExceptionTranslationAdvisor라는 어드바이저를 가집니다.
PersistenceExceptionTranslationAdvisor에 의해 어떤 객체가 등록될 때 어떤 동작을 적용할지가 결정됩니다.

PersistenceExceptionTranslationAdvisor

public class PersistenceExceptionTranslationAdvisor extends AbstractPointcutAdvisor {

	private final PersistenceExceptionTranslationInterceptor advice;

	private final AnnotationMatchingPointcut pointcut;


	public PersistenceExceptionTranslationAdvisor(
			PersistenceExceptionTranslator persistenceExceptionTranslator,
			Class<? extends Annotation> repositoryAnnotationType) {

		this.advice = new PersistenceExceptionTranslationInterceptor(persistenceExceptionTranslator);
		this.pointcut = new AnnotationMatchingPointcut(repositoryAnnotationType, true);
	}

	PersistenceExceptionTranslationAdvisor(
			ListableBeanFactory beanFactory, Class<? extends Annotation> repositoryAnnotationType) {

		this.advice = new PersistenceExceptionTranslationInterceptor(beanFactory);
		this.pointcut = new AnnotationMatchingPointcut(repositoryAnnotationType, true);
	}

	@Override
	public Advice getAdvice() {
		return this.advice;
	}

	@Override
	public Pointcut getPointcut() {
		return this.pointcut;
	}

}

PersistenceExceptionTranslationPostProcessor는 beanFactory를 입력받는 두번째 생성자를 호출하고 있습니다.

	PersistenceExceptionTranslationAdvisor(
			ListableBeanFactory beanFactory, Class<? extends Annotation> repositoryAnnotationType) {

		this.advice = new PersistenceExceptionTranslationInterceptor(beanFactory);
		this.pointcut = new AnnotationMatchingPointcut(repositoryAnnotationType, true);
	}

어드바이저가 '포인트컷에 해당하는 빈들에 대해 어드바이스를 적용하라'는 의미이기 때문에 위 코드는
AnnotationMatchingPointcut(repositoryAnnotationType, true);에 대해 PersistenceExceptionTranslationInterceptor(beanFactory);동작을 수행하라는 의미를 담고 있습니다.

클래스와 변수 이름을 보고 다음과 같은 동작을 예상할 수 있었습니다.

  • new AnnotationMatchingPointcut(repositoryAnnotationType, true);
    • AnnotationMatchingPointcut: 어노테이션을 바탕으로 포인트컷을 찾겠다. 즉, 특정 어노테이션이 붙은 객체에 대해 작업을 진행하겠다는 의미라 생각됩니다.
    • repositoryAnnotationType: @Repository를 활용하는 repositoryAnnotation과 관련된 어노테이션을 활용하는 객체들을 모두 pointcut으로 활용할 것으로 보입니다.
  • PersistenceExceptionTranslationInterceptor(beanFactory);
    • DB와 관련된 작업(Persistence)을 할 때 발생하는 예외(Exception)를 가로채(Interceptor) 그 예외를 변환(Tranlsation)하는 작업을 할 거 같습니다.

PersistenceExceptionTranslationInterceptor의 invoke메서드

	@Override
	@Nullable
	public Object invoke(MethodInvocation mi) throws Throwable {
		try {
			return mi.proceed();
		}
		catch (RuntimeException ex) {
			// Let it throw raw if the type of the exception is on the throws clause of the method.
			if (!this.alwaysTranslate && ReflectionUtils.declaresException(mi.getMethod(), ex.getClass())) {
				throw ex;
			}
			else {
				PersistenceExceptionTranslator translator = this.persistenceExceptionTranslator;
				if (translator == null) {
					Assert.state(this.beanFactory != null,
							"Cannot use PersistenceExceptionTranslator autodetection without ListableBeanFactory");
					translator = detectPersistenceExceptionTranslators(this.beanFactory);
					this.persistenceExceptionTranslator = translator;
				}
				throw DataAccessUtils.translateIfNecessary(ex, translator);
			}
		}
	}
  • 전체적으로 mi.proceed()를 실행하다가 RuntimeException이 발생했을 때 어떤 작업을 하라라는 의미를 담고 있습니다.
    이번 내용과 관련이 깊은 부분은 가장 마지막의 throw DataAccessUtils.translateIfNecessary(ex, translator);부분입니다. 최종적으로 RuntimeException ex를 다른 예외로 반환해 throw한다는 걸 확인할 수 있습니다.

DataAccessUtils의 translateIfNecessary메서드

	public static RuntimeException translateIfNecessary(
			RuntimeException rawException, PersistenceExceptionTranslator pet) {

		Assert.notNull(pet, "PersistenceExceptionTranslator must not be null");
		DataAccessException dae = pet.translateExceptionIfPossible(rawException);
		return (dae != null ? dae : rawException);
	}
  • translateExceptionIfPossible을 통해 원본 예외(rawException)을 변경할 수 있다면 DataAcessException으로 바꾸고 있다는 걸 확인할 수 있습니다.

혼란스러웠던 내용

  • 내용을 찾아보던 중 JPA 자체에서 예외 변환을 수행한다는 내용을 보게 됐습니다. 이걸 보고 저는 JPA가 자체적으로 DataAccessException예외로 변환해 준다는 내용으로 이해했습니다. 그래서 '@Repository를 선언하면 이미 JPA가 변환한 DataAccessException을 다시 한번 PersistenceExceptionTranslationPostProcessor를 통해 DataAccessException로 변환하는 중복 작업이 발생하는건가?'라는 의문이 생겼습니다.
    • JPA 자체에서 예외 변환을 수행한다는 의미는 각각의 DB마다 다른 예외를 PersistenceException예외로 변환해 준다는 의미였습니다.
    • 그렇기 때문에 @Repository를 선언했을 때 PersistenceExceptionTranslationPostProcessor는 JPA의 PersistenceExceptionDataAccessException로 변환해 줍니다.
  • 결국 JPA는 내부적으로 각각의 DB마다 다른 예외를 PersistenceException으로 변환해주고, JPA의 PersistenceException은 PersistenceExceptionTranslationPostProcessor에 의해 DataAccessException으로 변환됩니다.

기타 - 해당 글을 작성하게 된 계기

Pure Java + MyBatis로 작업한 프로젝트를 SpringBoot + MyBatis로 변환하는 과정에서 Dao계층에 단순히 @Mapper만 적용해 뒀었는데 공통 예외처리를 위해 @Repository가 필요하다는 팀원이 설명을 듣고 해당 내용에 대해 찾아보게 되었습니다.

profile
기록하고 정리하는 걸 좋아하는 개발자.

0개의 댓글