-
스프링의 DB 관련 예외 추상화개발/Spring(boot) 2025. 1. 5. 22:15
이번엔 DB의 예외를 다루면서 기술에 종속된다는 한계점을 겪고 좀 더 나은 방법에 대해 고민해보던 도중
예외 추상화라는 키워드를 찾아 해당 내용에 대해 작성해보려 한다.
먼저 어떤 과정에서 해당 내용을 찾게 되었는지 간략하게 작성해보자면 아래와 같다.
업무를 하던 도중 정상적인 요청에 대해서도 Unique key 제약조건이 순간적으로 깨질 수 있는 상황이 발생했다.
예를들어 유저의 이름이 Unique할 때 여러 명의 유저 이름을 동시에 변경하려고 하면 문제가 생겼었다.
그래서 아래와 같은 방식으로 해결하면서 일시적으로 해결했다.
// 1. 더미 데이터로 이름 변경 private void setDummy(List<User> updateDummeyUsers) { for (User user : updateDummeyUsers) { queryFactory.update(user) .set(user.name, UUID.randomUUID().toString()) .where(user.id.eq(user.getId())) .execute(); } } // 2. 실제 데이터 반영 public void updatePosition(List<User> updateUsers) { try { for (User user : updateUsers) { queryFactory.update(user) .set(user.name, user.getName()) .where(user.id.eq(user.getId())) .execute(); } } catch (PersistenceException e) { if (e.getCause() instanceof ConstraintViolationException) { throw new CustomException("중복되는 이름 요청"); } else { throw e; } } }
그리고 이 과정에서 아래와 같은 한계점을 겪게 되었다.
PersistenceException과 ConstraintViolationException 모두 내부 구현을 보면 아래와 같은 코드가 있다.
package org.hibernate.exception;
즉 hibernate에 포함되어 있는 것을 알 수 있다.
그렇다면 해당 코드는 결국 hibernate에 종속되는 예외 처리이고 특정 기술에 종속되는 방법은 결국 유지보수에서 어려움을 겪을 수 있다.
(특정 기술에 종속되는 코드를 피하기 위해 다양한 디자인 패턴들이 있으므로)
그래서 문제가 있다고 느꼈지만 해당 기능을 구현할 당시에는 이 점에 대해 대안을 찾지 못했고, repository layer로 예외 처리를 내려 해당 기술와 맞닿아 있는 부분에 예외 처리 코드를 작성해두었다.
즉 hibernate를 사용하는곳에만 hibernate관련 예외를 catch하도록 하고 service layer에서는 custom exception만 다루도록 작성했다.
그리고 우연히 스프링에서 DB 관련 예외를 추상화한 Excpetion이 있다는 것을 알게 되었고 이 방법을 사용하면 특정 기술에 종속되지 않고 DB에서 발생할 수 있는 예외를 모두 처리할 수 있을 것으로 예상된다. 그럼 이제 해당 내용에 대해 작성해보고자 한다.
먼저 예외 추상화에 대해 찾아보기 전에 아래와 같은 방식이 아닐까? 라고 생각해보았다.
try { // ... } catch (Exception e) { if (e.getCause() instanceof AException) { throw new CustomException("중복되는 이름 요청 예외 A"); } if (e.getCause() instanceof BException) { throw new CustomException("중복되는 이름 요청 예외 B"); } else { throw e; } }
그리고 해당 내용에 대해 찾아보니 아래와 같이 상당히 유사한 내용이 나왔다.
org.springframework.orm.jpa.vendor.HibernateJpaDialect
package org.springframework.orm.jpa.vendor; ... public DataAccessException translateExceptionIfPossible(RuntimeException ex) { if (ex instanceof HibernateException) { return convertHibernateAccessException((HibernateException) ex); } if (ex instanceof PersistenceException && ex.getCause() instanceof HibernateException) { return convertHibernateAccessException((HibernateException) ex.getCause()); } return EntityManagerFactoryUtils.convertJpaAccessExceptionIfPossible(ex); }
그럼 이 내용에 대해 조금 더 자세히 알아보자.
먼저 스프링부트에서는 DAO에 대해 아래와 같이 예외를 추상화한다고 한다.
복잡해보이지만 결국 저 많은 예외들이 Catch되어 DataAccessException으로 다시 throw 된다 정도로 이해하면 될 것 같다.
하지만 원래 목적이었던 PersistenceException, ConstraintViolationException이 그림에서는 보이지 않았고 문서에 해당 내용이 작성되어 있었다.
"In addition to JDBC exceptions, Spring can also wrap JPA- and Hibernate-specific exceptions, converting them to a set of focused runtime exceptions."
(Spring은 JDBC 예외뿐만 아니라 JPA 및 Hibernate에 특화된 예외도 감싸서, 특정 목적에 맞는 런타임 예외 집합으로 변환할 수 있습니다.)
PersistenceException과 ConstraintViolationException 모두 hibernate에 포함되어 있었으므로 이 역시 어디선가 변환되어 있으리라 예상되었다.
그리고 이 내용은 위에 코드에서 잠깐 보았듯이 바로 org.springframework.orm.jpa.vendor.HibernateJpaDialect에서 변환해주고 있다.
PersistenceException, ConstraintViolationException을 DataAccessException으로 변환하는 코드
org.springframework.orm.jpa.vendor.HibernateJpaDialect
@Override @Nullable public DataAccessException translateExceptionIfPossible(RuntimeException ex) { if (ex instanceof HibernateException) { return convertHibernateAccessException((HibernateException) ex); } if (ex instanceof PersistenceException && ex.getCause() instanceof HibernateException) { return convertHibernateAccessException((HibernateException) ex.getCause()); } return EntityManagerFactoryUtils.convertJpaAccessExceptionIfPossible(ex); } /** * Convert the given HibernateException to an appropriate exception * from the {@code org.springframework.dao} hierarchy. * @param ex the HibernateException that occurred * @return the corresponding DataAccessException instance */ protected DataAccessException convertHibernateAccessException(HibernateException ex) { if (this.jdbcExceptionTranslator != null && ex instanceof JDBCException) { JDBCException jdbcEx = (JDBCException) ex; DataAccessException dae = this.jdbcExceptionTranslator.translate( "Hibernate operation: " + jdbcEx.getMessage(), jdbcEx.getSQL(), jdbcEx.getSQLException()); if (dae != null) { throw dae; } } if (ex instanceof JDBCConnectionException) { return new DataAccessResourceFailureException(ex.getMessage(), ex); } if (ex instanceof SQLGrammarException) { SQLGrammarException jdbcEx = (SQLGrammarException) ex; return new InvalidDataAccessResourceUsageException(ex.getMessage() + "; SQL [" + jdbcEx.getSQL() + "]", ex); } if (ex instanceof QueryTimeoutException) { QueryTimeoutException jdbcEx = (QueryTimeoutException) ex; return new org.springframework.dao.QueryTimeoutException(ex.getMessage() + "; SQL [" + jdbcEx.getSQL() + "]", ex); } if (ex instanceof LockAcquisitionException) { LockAcquisitionException jdbcEx = (LockAcquisitionException) ex; return new CannotAcquireLockException(ex.getMessage() + "; SQL [" + jdbcEx.getSQL() + "]", ex); } if (ex instanceof ConstraintViolationException) { ConstraintViolationException jdbcEx = (ConstraintViolationException) ex; return new DataIntegrityViolationException(ex.getMessage() + "; SQL [" + jdbcEx.getSQL() + "]; constraint [" + jdbcEx.getConstraintName() + "]", ex); } // 찾고있던 hibernate의 ConstraintViolationException if (ex instanceof DataException) { DataException jdbcEx = (DataException) ex; return new DataIntegrityViolationException(ex.getMessage() + "; SQL [" + jdbcEx.getSQL() + "]", ex); } }
그리고 translateExceptionIfPossible는 아래와 같이 EntityManagerFactoryBean이나 JpaTransactionManager 등에서 호출되고 있다.
아래는 JpaTransactionManager 내에서 translateExceptionIfPossible를 호출하는 코드이다.
이와같이 hibernate 관련된 다른곳에서도 translateExceptionIfPossible를 호출하리라 충분히 예상 가능하다.
코드를 보다 보면 아직 의문이 하나 남아있다. 위에 코드블럭으로 작성한 내용 도중 convertHibernateAccessException의 return type은 DataAccessException이다. 하지만 실제로 Return하는 예외들을 보면 DataAccessException은 하나도 없다.
protected DataAccessException convertHibernateAccessException(HibernateException ex) { if (ex instanceof JDBCConnectionException) { return new DataAccessResourceFailureException(...); } if (ex instanceof SQLGrammarException) { return new InvalidDataAccessResourceUsageException(...); } if (ex instanceof QueryTimeoutException) { return new org.springframework.dao.QueryTimeoutException(...); } if (ex instanceof LockAcquisitionException) { return new CannotAcquireLockException(...); } if (ex instanceof ConstraintViolationException) { return new DataIntegrityViolationException(...); } if (ex instanceof DataException) { return new DataIntegrityViolationException(...); } }
그리고 비슷한 방식을 최근에 사용한 적이 있어 "저 Exception들은 전부 DataAccessException을 상속받고 있지 않을까?" 라고 예상해보았다.
세 개의 예외정도만 살펴보자.
- DataIntegrityViolationException (ConstraintViolationException에서 변환)
/** * Exception thrown when an attempt to insert or update data * results in violation of an integrity constraint. Note that this * is not purely a relational concept; integrity constraints such * as unique primary keys are required by most database types. */ public class DataIntegrityViolationException extends NonTransientDataAccessException { public DataIntegrityViolationException(String msg) { super(msg); } public DataIntegrityViolationException(String msg, Throwable cause) { super(msg, cause); } }
"데이터를 삽입하거나 업데이트하려는 시도가 무결성 제약 조건을 위반할 때 발생하는 예외" 라고 작성되어있고
NonTransientDataAccessException를 상속받는다
기존에 내가 Unique 제약조건을 위반했을 때 throw되는 예외이므로 동일한 내용으로 이해할 수 있다.
- InvalidDataAccessResourceUsageException (SQLGrammarException에서 변환)
/** * Root for exceptions thrown when we use a data access resource incorrectly. * Thrown for example on specifying bad SQL when using a RDBMS. * Resource-specific subclasses are supplied by concrete data access packages. * */ public class InvalidDataAccessResourceUsageException extends NonTransientDataAccessException { public InvalidDataAccessResourceUsageException(String msg) { super(msg); } public InvalidDataAccessResourceUsageException(String msg, Throwable cause) { super(msg, cause); } }
"데이터 액세스 리소스를 잘못 사용했을 때 발생하는 예외들의 최상위 클래스" 라고 작성되어있고
동일하게 NonTransientDataAccessException를 상속받는다.
SQLGrammarException이 다시 throw하는 예외이므로 역시 동일한 내용으로 이해할 수 있다.
추가적으로 "예외들의 최상위 클래스" 라고 작성된걸 보고 확인해보니 다른 리소스 관련 예외들이 InvalidDataAccessResourceUsageException 를 상속 받고 있었다.
- NonTransientDataAccessException(위의 두 예외가 상속)
/** * Root of the hierarchy of data access exceptions that are considered non-transient - * where a retry of the same operation would fail unless the cause of the Exception * is corrected. */ public abstract class NonTransientDataAccessException extends DataAccessException { public NonTransientDataAccessException(String msg) { super(msg); } public NonTransientDataAccessException(@Nullable String msg, @Nullable Throwable cause) { super(msg, cause); } }
드디어 DataAccessException을 상속받는 예외를 찾았다.
"재시도하더라도 예외의 원인이 수정되지 않으면 동일한 작업이 실패할 것으로 간주되는,비일시적(non-transient) 데이터 액세스 예외 계층의 최상위 클래스" 라고 작성되어 있다.
비일시적 예외와 대비되는 일시적 예외는 이와 같이 설명된다.
non-transient (비일시적) 예외
- 비일시적 예외는 재시도로 해결되지 않는 예외
- 예외가 발생한 근본적인 원인을 수정하지 않으면 동일한 작업을 반복하더라도 계속 실패
transient(일시적) 예외
- 재시도로 해결될 가능성이 있는 예외.
- 예: 네트워크 연결 문제, 데이터베이스 연결 시간 초과 등
Unique 제약 조건 위배, SQL 문법 오류 모두 예외가 발생한 근본적인 원인을 수정하지 않으면 동일한 작업을 반복하더라도 계속 실패하므로 비일시적 예외라고 볼 수 있다.
다시 처음에 이 내용을 작성했던 이유로 돌아가보자
public void updatePosition(List<User> updateUsers) { try { for (User user : updateUsers) { queryFactory.update(user) .set(user.name, user.getName()) .where(user.id.eq(user.getId())) .execute(); } } catch (PersistenceException e) { if (e.getCause() instanceof ConstraintViolationException) { throw new CustomException("중복되는 이름 요청"); } else { throw e; } } }
여기서 catch block 내부에 PersistenceException과 ConstraintViolationException이 hibernate에 종속되는 예외인 것이 문제였다.
그럼 앞서 작성한 내용을 바탕으로 아래와 같이 변경할 수 있다.
public void updatePosition(List<User> updateUsers) { try { for (User user : updateUsers) { queryFactory.update(user) .set(user.name, user.getName()) .where(user.id.eq(user.getId())) .execute(); } } catch (DataAccessException e) { if (e.getCause() instanceof DataIntegrityViolationException) { throw new CustomException("중복되는 이름 요청"); } else { throw e; } } }
스프링의 DB 관련 예외 추상화에 대해 작성해보았다.
스프링의 예외는 작성한 내용보다 훨씬 더 깊고 자세하게 예외가 추상화되어 있는 것으로 알고있다.
예를들어 MySQL과 PostgreSQL에서 Unique Key 제약 조건 오류로 인해 예외가 온다면 이는 다른 방식의 오류 코드로 springboot에 전달될 것 이다. 하지만 이를 스프링이라는 프레임워크가 동일한 예외로 처리할 수 있도록 추상화해 두었다는 점이 신기했다.
예를들어 JDBCException을 사용하는 파일들이고 익숙한 DB 이름들을 볼 수 있다.
(H2, HSQL, MySQL, Oracle, PostgreSQL)
앝게나마 스프링의 내부 코드를 보아 꽤나 재밌었던 경험이었다.
출처
https://docs.spring.io/spring-framework/reference/data-access/dao.html#dao-exceptions
반응형'개발 > Spring(boot)' 카테고리의 다른 글
Custom Annotation을 만들고 AOP로 활용하는 법 (0) 2025.01.19 @Transactional을 final class에 붙이면 안되는 이유(AOP의 동작원리) (0) 2025.01.12 ApplicationEventPublisher와 EventListener (0) 2024.12.01