본문 바로가기
Spring&SpringBoot/<토비의 스프링 3.1 Vol1.1>, 이일민

DB 종류로부터 예외 추상화하기 (feat. SQLExceptionTranslator 뜯어보기)

by 민휘 2023. 3. 26.

목차

1. 개요

2. DB 종류 추상화하기

3. SQLException -> DataAccessException : 예외 전환

4. JdbcTemplate의 DB 추상화 전략 : 데이터베이스 벤더의 오류 코드 활용해서 예외 포장하기

5. JdbcTemplate에서 예외 전환 찾기

6. SQLExceptionTranslator 분석하기

- a. 예외 판단에 필요한 정보를 가진 클래스 확인

- b. 템플릿 메소드 패턴 적용하여 번역기의 에러 매핑 코드 추상화

- c. 커스텀 예외 정의해보기 : 세 가지 방법 소개

7. 마치며

 

 

개요

 

스프링이 제공하는 DataAccessException은 DB 종류데이터 액세스 기술 종류의 차이를 추상화한다.

 

DB 종류 추상화에서는 JdbcTemplate이 SQLException을 잡아 DataAccessException을 던지는 예외 전략을 알아보고, 어떻게 DataAccessException 계층 구조의 예외가 데이터베이스의 종류와 상관없이 동일한 상황에 일관된 예외를 전달할 수 있는지 살펴본다.

데이터 액세스 기술 종류 추상화에서는 DataAccessException이 데이터 액세스 기술마다 다르게 던지는 예외를 추상화하여 동일한 상황에 일관된 예외를 던지는 방법을 알아본다. (이 내용은 로드 존슨의 Expert One-On-One J2EE Design and Development 9장을 읽어본 후 공개할 예정)

 

토비의 스프링 4장 2절의 목표는 DataAccessException이 어떻게 기술마다 다른 예외 코드나 예외 클래스를 발생 맥락으로 묶어 맥락에 대응하는 추상적인 예외를 던지는지 이해하는 것이다. 이 과정에서 사용되는 런타임 예외 중심의 낙관적인 예외 처리 전략도 확인할 수 있다.

 

🙏 : 코드량이 많아서 DB 종류 추상화와 데이터 액세스 기술 종류 추상화를 나누어 작성하였습니다. 이번 포스팅은 DB 종류 추상화를 다룹니다. 다음 포스팅에서 데이터 액세스 기술 종류 추상화를 다룹니다. 코드는 직접 뜯어서 해석해본 것이니 틀린 정보가 있을 수 있습니다. 지적 환영 — !!

 

 

1. DB 종류 추상화하기

개발자가 데이터베이스를 바꾸어도 애플리케이션 코드를 수정하지 않아도 된다면 정말 멋질 것이다. 스프링이 지향하는 객체지향 원리에도 잘 부합하는 현상이다.

자바는 JDBC라는 표준 데이터베이스 API 라이브러리를 제공한다. 이 표준을 따라 각 DB 업체가 자바 개발자가 사용할 수 있는 드라이버를 만들어 제공한다. 그럼 JDBC를 사용하면 DB 종류에 신경쓰지 않아도 되는 것인가?

슬프게도 그러지 않다. 비표준 SQL과 DB마다 다른 예외 코드가 문제를 일으킨다. 이번 절에서는 데이터베이스마다 다른 예외 코드를 추상화하는 방법을 다룬다. Jdbc API는 sql을 실행하다가 오류가 발생하면 SQLException만을 던지고, 그 안에 에러 코드와 SQL 상태 정보를 담아서 보낸다. 4장 1절에서 소개된 낙관적인 예외 처리 전략은 JdbcTemplate에서도 그대로 적용된다. 체크 예외를 런타임 예외로 전환하는 김에, 예외 원인이 비슷한 에러들은 묶어서 추상화된 예외를 던질 수 있도록 바꾸는 방법을 살펴보자.

 

 

 

2. SQLException → DataAccessException : 예외 전환

예외 전환 방법의 핵심을 복기해보자. 예외 전환을 사용하면 복구 불가능한 체크 예외를 계속 던져야할 때, 런타임 예외로 전환해서 연쇄적인 예외 회피를 끊어낼 수 있었다. 또 의미를 드러내는 예외로 바꾸어 던져서 예외를 추상화할 수 있었다.

스프링의 JdbcTemplate은 sql을 실행하면서 발생하는 체크 예외인 SQLException을 잡아서 런타임 예외로 전환한다. 1절에서 살펴본 예외 전환 방법을 적용하고 있다. 복구할 수 없는 체크 예외가 계속 던져지도록 놔두면 상위 API가 예외 클래스에 의해 오염된다. 체크 예외를 런타임 예외로 전환해서 예외가 퍼지는 것을 막은 것이다.

 

 

 

 

3. JdbcTemplate의 DB 추상화 전략 : 데이터베이스 벤더의 오류 코드 활용해서 예외 포장하기

 

SQLException을 캐치해서 예외 코드를 참고하려면 다음과 같이 작성한다.

if (e.getErrorCode() == MysqlErrorNumbers.EP_DUP_ENTRY) 

Mysql 이름이 그대로 노출되고 있다. 이렇게 코드를 작성한다면 데이터베이스를 Oracle로 바꾸었을 때 에러 코드를 바꿔야 정상적으로 동작할 것이다. 이 방법으로는 DB 종류에 독립적일 수 없다. 그렇다면 우리의 스프링은 어떨까?

스프링은 런타임 예외인 DataAccessException의 서브클래스로 세분회된 예외 클래스들을 정의하고 있다. 데이터베이스마다 에러 코드가 다르겠지만, 예외가 발생한 상황이 유사하면 동일한 DataAccessException의 서브클래스를 반환한다. 예를 들어 SQL 문법 때문에 발생하는 에러라면 BadSqlGrammarException, DB 커넥션을 가져오지 못하면 DataAccessResourceFailureException을, 데이터 제약조건을 위배하거나 일관성을 지키지 않는 작업을 수행한다면 DataIntegrityVilolationException을, 중복키 때문에 발생한 경우는 DuplicatedKeyException을 사용한다.

스프링은 DB 벤더별로 발생하는 에러 코드를 다음과 같이 예외 상황에 매핑해서 관리하고 있다. 오라클을 사용할 때 발생하는 에러 코드들을 예외 상황에 따라 매핑한 xml 파일이다.

SQLException의 예외 코드가 17703이라면 invalidResultSetAccessCodes에 속한다.

<bean id="Oracle" class="org.springframework.jdbc.support.SQLErrorCodes">
		<property name="badSqlGrammarCodes">
			<value>900,903,904,917,936,942,17006,6550</value>
		</property>
		<property name="invalidResultSetAccessCodes">
			<value>17003</value>
		</property>
		<property name="duplicateKeyCodes">
			<value>1</value>
		</property>
		<property name="dataIntegrityViolationCodes">
			<value>1400,1722,2291,2292</value>
		</property>
		<property name="dataAccessResourceFailureCodes">
			<value>17002,17447</value>
		</property>
		<property name="cannotAcquireLockCodes">
			<value>54,30006</value>
		</property>
		<property name="cannotSerializeTransactionCodes">
			<value>8177</value>
		</property>
		<property name="deadlockLoserCodes">
			<value>60</value>
		</property>
	</bean>

 

 

4. JdbcTemplate에서 예외 전환 찾기

 

JdbcTemplate의 템플릿 메소드인 execute를 살펴보자. 예외 전환 방법을 어떻게 적용했는지에 집중하면서 아래 코드를 읽어보자.

public class JdbcTemplate extends JdbcAccessor implements JdbcOperations {

	public <T> T execute(ConnectionCallback<T> action) throws DataAccessException {
			Assert.notNull(action, "Callback object must not be null");
	
			Connection con = DataSourceUtils.getConnection(getDataSource());
			try {
				Connection conToUse = con;
				if (this.nativeJdbcExtractor != null) {
					// Extract native JDBC Connection, castable to OracleConnection or the like.
					conToUse = this.nativeJdbcExtractor.getNativeConnection(con);
				}
				else {
					// Create close-suppressing Connection proxy, also preparing returned Statements.
					conToUse = createConnectionProxy(con);
				}
				return action.doInConnection(conToUse);
			}
			catch (SQLException ex) { // 여기서 SQLException 체크 예외를 잡아서
				// Release Connection early, to avoid potential connection pool deadlock
				// in the case when the exception translator hasn't been initialized yet.
				DataSourceUtils.releaseConnection(con, getDataSource());
				con = null;
				// 여기서 런타임 예외를 던진다
				throw getExceptionTranslator().translate("ConnectionCallback", getSql(action), ex);
			}
			finally {
				DataSourceUtils.releaseConnection(con, getDataSource());
			}
		}
}

여기서 catch로 SQLException을 잡아서 getExceptionTranslator를 통해 번역된 예외를 던진다. 내부적으로 어떻게 동작하는지는 아직 모르지만 헤더에서도 알 수 있듯이 DataAccessException를 던지고 있다. DataAccessException는 런타임 예외이다.

public abstract class DataAccessException extends NestedRuntimeException {}
public abstract class NestedRuntimeException extends RuntimeException {}

getExceptionTranslator는 JdbcTemplate이 확장하고 있는 DataAccessException 추상 클래스의 메소드이다.

public synchronized SQLExceptionTranslator getExceptionTranslator() {
		if (this.exceptionTranslator == null) {
			DataSource dataSource = getDataSource();
			if (dataSource != null) {
				this.exceptionTranslator = new SQLErrorCodeSQLExceptionTranslator(dataSource);
			}
			else {
				this.exceptionTranslator = new SQLStateSQLExceptionTranslator();
			}
		}
		return this.exceptionTranslator;
	}

getExceptionTranslator는 SQLExceptionTranslator의 구현체를 선택해서 생성하고 주입하는 팩토리 메소드이다. DataSource가 있으면 에러 코드에 대해 적절한 예외를 받아오고, 없으면 예외 상태에 대해 예외를 받아온다. 네이밍이 SQLTranslator인 것을 보아 데이터베이스 벤더마다 다르게 반환하는 에러 코드와 예외 상태를 추상화해서, 미리 지정해놓은 예외 상황에 대해 추상화한 예외를 받아오도록 책임을 분리한 것 같다. 예를 들어 키 중복 오류에 대해 MySQL은 1062, 오라클은 1, DB2라면 -803 등 각기 다른 예외 코드를 반환하는데, 키 중복 오류 상황에 대해서는 그냥 동일하게 DuplicateKeyException을 반환하도록 추상화한 것을 말한다.

execute가 SQLExceptionTranslator을 받아와서 호출한 translate도 살펴보자.

public interface SQLExceptionTranslator {
	DataAccessException translate(String task, String sql, SQLException ex);
}

SQLExceptionTranslator는 translate라는 유일한 메시지를 수신할 수 있는 함수형 인터페이스이다. 아마 SQL 실행이 실패한 상황에서 실패 관련 정보를 받아서 적절한 예외로 전환하는 역할을 하는 것 같다.

정리해보면 JdbcTemplate의 템플릿 메소드는 SQLException을 잡고, 적절한 의미를 가진 런타임 예외로 바꾸기 위해 해당 책임의 전문가인 SQLExceptionTranslator를 받아와서 예외 번역을 요청하고 있음을 알 수 있다.

 

 

 

5. SQLExceptionTranslator 분석하기

 

흐름이 길어서 간단하게 요약해보면 이러하다.

 

✅ 예외 판단에 필요한 정보 필드를 가지고 있다. 번역기 종류마다 다른데, SQLErrorCodes는 자바 빈으로 관리해서 SQLErrorCodeSQLExceptionTranslator에서 사용하고, 예외 상황은 따로 빈으로 만들지 않고 SQLStateSQLExceptionTranslator 안에서 static으로 정의해 사용하고 있다.

 

✅ 템플릿 메소드의 추상 메소드인 doTranslate에서 에러 정보를 매핑해 런타임 예외를 생성해서 리턴한다. 번역기마다 사용하는 에러 정보가 달라 매핑 코드가 다르기 때문에 템플릿 메소드 패턴을 선택한 것으로 보인다.

 

✅ SQLErrorCodeSQLExceptionTranslator는 사용자 정의 예외 상황을 번역할 수 있다. 사용자가 직접 SQLException의 에러 코드와 예외 클래스의 매핑 정보를 넘기면 SQLErrorCodeSQLExceptionTranslator가 번역해준다. (방법이 세 가지인 듯..!)

 

 

a. 예외 판단에 필요한 정보를 가진 클래스 확인

SQLExceptionTranslator가 어떻게 에러 코드나 예외 상태에 따라 데이터베이스 종류에 종속적이지 않은, 예외의 의미를 가진 DataAccessException의 서브 클래스로 전환하는지 알아보자.

public synchronized SQLExceptionTranslator getExceptionTranslator() {
		if (this.exceptionTranslator == null) {
			DataSource dataSource = getDataSource();
			if (dataSource != null) {
				this.exceptionTranslator = new SQLErrorCodeSQLExceptionTranslator(dataSource);
			}
			else {
				this.exceptionTranslator = new SQLStateSQLExceptionTranslator();
			}
		}
		return this.exceptionTranslator;
	}

일단 에러 코드를 사용해서 예외를 번역하는 SQLErrorCodeSQLExceptionTranslator부터 살펴보자.

public class SQLErrorCodeSQLExceptionTranslator extends AbstractFallbackSQLExceptionTranslator {
	/** Error codes used by this translator */
	private SQLErrorCodes sqlErrorCodes;

에러 코드를 담는 것처럼 보이는 인스턴스 필드가 있다. 살펴보자.

// 데이터베이스의 에러 코드를 담는 빈을 찾았다!
public class SQLErrorCodes {

	private String[] databaseProductNames;

	private boolean useSqlStateForTranslation = false;

	private SQLExceptionTranslator customSqlExceptionTranslator = null; // 얜 뭐지..?

	private String[] badSqlGrammarCodes = new String[0];

	private String[] invalidResultSetAccessCodes = new String[0];

	private String[] duplicateKeyCodes = new String[0];

	private String[] dataIntegrityViolationCodes = new String[0];

	private String[] permissionDeniedCodes = new String[0];

	private String[] dataAccessResourceFailureCodes = new String[0];

	private String[] transientDataAccessResourceCodes = new String[0];

	private String[] cannotAcquireLockCodes = new String[0];

	private String[] deadlockLoserCodes = new String[0];

	private String[] cannotSerializeTransactionCodes = new String[0];

	private CustomSQLErrorCodesTranslation[] customTranslations;

SQLErrorCodes 필드의 이름을 잘 살펴보면 예외 상황별로 인스턴스 필드를 하나씩 두고 있다. 이름이 뭔가 익숙하다 했더니, 토비에서 Oracle 코드를 어떻게 매핑하는지 보여주던 그 xml 파일의 빈 클래스인 것 같다. 스프링 깃허브에서 sql-error-codes.xml을 열어보니 아니나 다를까 데이터베이스 벤더마다 오류 코드를 지정하고 있다.

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE beans PUBLIC "-//SPRING//DTD BEAN 2.0//EN" "https://www.springframework.org/dtd/spring-beans-2.0.dtd">

<!--
	- Default SQL error codes for well-known databases.
	- Can be overridden by definitions in a "sql-error-codes.xml" file
	- in the root of the class path.
	-
	- If the Database Product Name contains characters that are invalid
	- to use in the id attribute (like a space) then we need to add a property
	- named "databaseProductName"/"databaseProductNames" that holds this value.
	- If this property is present, then it will be used instead of the id for
	- looking up the error codes based on the current database.
	-->
<beans>
	<bean id="H2" class="org.springframework.jdbc.support.SQLErrorCodes">
		<property name="badSqlGrammarCodes">
			<value>42000,42001,42101,42102,42111,42112,42121,42122,42132</value>
		</property>
		<property name="duplicateKeyCodes">
			<value>23001,23505</value>
		</property>
		<property name="dataIntegrityViolationCodes">
			<value>22001,22003,22012,22018,22025,23000,23002,23003,23502,23503,23506,23507,23513</value>
		</property>
		<property name="dataAccessResourceFailureCodes">
			<value>90046,90100,90117,90121,90126</value>
		</property>
		<property name="cannotAcquireLockCodes">
			<value>50200</value>
		</property>
	</bean>

	<!-- https://help.sap.com/saphelp_hanaplatform/helpdata/en/20/a78d3275191014b41bae7c4a46d835/content.htm -->
	<bean id="HDB" name="Hana" class="org.springframework.jdbc.support.SQLErrorCodes">
		<property name="databaseProductNames">
			<list>
				<value>SAP HANA</value>
				<value>SAP DB</value>
			</list>
		</property>
		<property name="badSqlGrammarCodes">
			<value>
				257,259,260,261,262,263,264,267,268,269,270,271,272,273,275,276,277,278,
				278,279,280,281,282,283,284,285,286,288,289,290,294,295,296,297,299,308,309,
				313,315,316,318,319,320,321,322,323,324,328,329,330,333,335,336,337,338,340,
				343,350,351,352,362,368
			</value>
		</property>
		<property name="permissionDeniedCodes">
			<value>10,258</value>
		</property>
		<!-- ... -->
	</bean>

아무튼 SQLErrorCodes가 자바 빈으로 관리된다는 것은 알았으니 이제 SQLErrorCodeSQLExceptionTranslator로 다시 돌아가서 이 에러 코드 정보를 어떻게 활용하는지 알아보자.

 

sqlErrorCodes를 어디서 활용하나 찍어보니 이런 코드가 나왔다. SQLException에 담긴 예외 코드인 errorCode를 받아와서 예외 상황에 해당하는 에러 코드를 비교하고 있다. 에러 코드를 매핑해서 적절한 의미를 가진 예외를 생성해 반환하는 것을 볼 수 있다.

// 여기서 에러 코드를 매핑해 런타임 예외를 반환한다.
@Override
protected DataAccessException doTranslate(String task, String sql, SQLException ex) {
	// ...
	// Next, look for grouped error codes.
  if (Arrays.binarySearch(this.sqlErrorCodes.getBadSqlGrammarCodes(), errorCode) >= 0) {
      logTranslation(task, sql, sqlEx, false);
      return new BadSqlGrammarException(task, sql, sqlEx);
  }
  else if (Arrays.binarySearch(this.sqlErrorCodes.getInvalidResultSetAccessCodes(), errorCode) >= 0) {
      logTranslation(task, sql, sqlEx, false);
      return new InvalidResultSetAccessException(task, sql, sqlEx);
  }
  else if (Arrays.binarySearch(this.sqlErrorCodes.getDuplicateKeyCodes(), errorCode) >= 0) {
      logTranslation(task, sql, sqlEx, false);
      return new DuplicateKeyException(buildMessage(task, sql, sqlEx), sqlEx);
  }
  // ...
	else if (Arrays.binarySearch(this.sqlErrorCodes.getCannotSerializeTransactionCodes(), errorCode) >= 0) {
      logTranslation(task, sql, sqlEx, false);
      return new CannotSerializeTransactionException(buildMessage(task, sql, sqlEx), sqlEx);
  }
}

 

 

 

b. 템플릿 메소드 패턴 적용하여 번역기의 에러 매핑 코드 추상화

그런데 좀 신경 쓰이는게 있다. @Override된걸 보니 상위 클래스에서 이미 정의하고 있었나본데.. 이름이 doTranslate인거 보니 이거 템플릿 메소드 패턴인가? 하며 들어가봤는데, 역시나 템플릿 메소드의 추상 메소드였다.

public abstract class AbstractFallbackSQLExceptionTranslator implements SQLExceptionTranslator {

	// 알고리즘의 맥락을 정의하는 템플릿 메소드
	public DataAccessException **translate**(String task, String sql, SQLException ex) {
		Assert.notNull(ex, "Cannot translate a null SQLException");
		if (task == null) {
			task = "";
		}
		if (sql == null) {
			sql = "";
		}

		// 부분 알고리즘 호출
		DataAccessException dex = **doTranslate**(task, sql, ex);
		if (dex != null) {
			// Specific exception match found.
			return dex;
		}
		// Looking for a fallback...
		SQLExceptionTranslator fallback = getFallbackTranslator();
		if (fallback != null) {
			return fallback.translate(task, sql, ex);
		}
		// We couldn't identify it more precisely.
		return new UncategorizedSQLException(task, sql, ex);
	}

	// 서브 클래스에서 정의해서 사용하는 부분 알고리즘
	protected **abstract** DataAccessException **doTranslate**(String task, String sql, SQLException ex);
}

알고리즘 흐름에서 핵심인 번역 전후로 리소스의 null 체크와 번역 실패 흐름을 정리하고 있다. 이 doTranslate는 SQLExceptionTranslator마다 다르게 정의되어있을 것 같다. 에러 코드를 사용하는 번역기는 에러 코드 빈을 참조해 예외를 매핑할 것이고, sql 예외 상황을 사용하는 번역기는 나름의 정보를 참조해 예외를 매핑할 것이다.

이 코드는 execute에 있던 또 다른 번역기인 SQLStateSQLExceptionTranslator이다. 에러 상황 정보를 가지고 번역을 하고 있다. doTranslate에서 에러 상황 필드를 사용해 런타임 예외를 매핑하고 있는 것을 알 수 있다.

public class SQLStateSQLExceptionTranslator extends AbstractFallbackSQLExceptionTranslator {
	@Override
	protected DataAccessException doTranslate(String task, String sql, SQLException ex) {
		String sqlState = getSqlState(ex);
		if (sqlState != null && sqlState.length() >= 2) {
			String classCode = sqlState.substring(0, 2);
			if (logger.isDebugEnabled()) {
				logger.debug("Extracted SQL state class '" + classCode + "' from value '" + sqlState + "'");
			}
			if (BAD_SQL_GRAMMAR_CODES.contains(classCode)) {
				return new BadSqlGrammarException(task, sql, ex);
			}
			// ..
	}
}

그런데 특이한 점은 여기선 빈으로 관리하지 않고 그냥 코드에 static으로 에러 코드를 냅다 필드에 추가했다. 왜 이랬을까.. 아마 에러 코드 가짓수가 적고, DB마다 예외 상황이 하나씩이어서 그랬을 수도 있고. 아님 독립적인 빈으로 관리할 만큼 자주 사용되는 리소스가 아니어서 그런 것일수도 있을 것 같다. (아시는 분들은 댓글 달아주세요.. 보는 사람이 있을진 모르겠지만..)

static {
		BAD_SQL_GRAMMAR_CODES.add("07");	// Dynamic SQL error
		BAD_SQL_GRAMMAR_CODES.add("21");	// Cardinality violation
		BAD_SQL_GRAMMAR_CODES.add("2A");	// Syntax error direct SQL
		BAD_SQL_GRAMMAR_CODES.add("37");	// Syntax error dynamic SQL
		BAD_SQL_GRAMMAR_CODES.add("42");	// General SQL syntax error
		BAD_SQL_GRAMMAR_CODES.add("65");	// Oracle: unknown identifier
		BAD_SQL_GRAMMAR_CODES.add("S0");	// MySQL uses this - from ODBC error codes?

		DATA_INTEGRITY_VIOLATION_CODES.add("01");	// Data truncation
		DATA_INTEGRITY_VIOLATION_CODES.add("02");	// No data found
		DATA_INTEGRITY_VIOLATION_CODES.add("22");	// Value out of range
		DATA_INTEGRITY_VIOLATION_CODES.add("23");	// Integrity constraint violation
		DATA_INTEGRITY_VIOLATION_CODES.add("27");	// Triggered data change violation
		DATA_INTEGRITY_VIOLATION_CODES.add("44");	// With check violation

		DATA_ACCESS_RESOURCE_FAILURE_CODES.add("08");	 // Connection exception
		DATA_ACCESS_RESOURCE_FAILURE_CODES.add("53");	 // PostgreSQL: insufficient resources (e.g. disk full)
		DATA_ACCESS_RESOURCE_FAILURE_CODES.add("54");	 // PostgreSQL: program limit exceeded (e.g. statement too complex)
		DATA_ACCESS_RESOURCE_FAILURE_CODES.add("57");	 // DB2: out-of-memory exception / database not started
		DATA_ACCESS_RESOURCE_FAILURE_CODES.add("58");	 // DB2: unexpected system error

		TRANSIENT_DATA_ACCESS_RESOURCE_CODES.add("JW");	 // Sybase: internal I/O error
		TRANSIENT_DATA_ACCESS_RESOURCE_CODES.add("JZ");	 // Sybase: unexpected I/O error
		TRANSIENT_DATA_ACCESS_RESOURCE_CODES.add("S1");	 // DB2: communication failure

		CONCURRENCY_FAILURE_CODES.add("40");	// Transaction rollback
		CONCURRENCY_FAILURE_CODES.add("61");	// Oracle: deadlock
	}

 

 

c. 커스텀 예외 정의해보기

미리 말합니다. 이 부분은 좀 지저분합니다.. 제가 아직 리플렉션을 제대로 이해하지 못한 상태에서 작성해서 정확하지 않은 부분이 있습니다. (추후에 리플렉션을 공부하면 내용 보충하겠습니다)

이 파트에서 해보는 것은 스프링이 정의해놓은 에러 코드와 DataAccessException 서브클래스를 사용하는 것이 아닌, 내가 직접 에러 코드와 예외 클래스를 매핑해서 Translator에게 번역을 위임하는 작업을 해봅니다. 세 가지 방법을 소개합니다.

한편, 아까 전부터 SQLErrorCodeSQLExceptionTranslator에서 엄청 신경 쓰이던게 있는데 바로 SQLExceptionTranslator의 이 필드들이다. 이게 뭐지..?

public class SQLErrorCodeSQLExceptionTranslator extends AbstractFallbackSQLExceptionTranslator {

	private static final int MESSAGE_ONLY_CONSTRUCTOR = 1;
	private static final int MESSAGE_THROWABLE_CONSTRUCTOR = 2;
	private static final int MESSAGE_SQLEX_CONSTRUCTOR = 3;
	private static final int MESSAGE_SQL_THROWABLE_CONSTRUCTOR = 4;
	private static final int MESSAGE_SQL_SQLEX_CONSTRUCTOR = 5;

어디서 사용되나 찍어봤는데 createCustomException이 나왔다. 주석을 읽어보니 사용자 정의 DataAccessException를 만드는 부분인 것 같다. SQL을 실행했을 때 발생한 SQLException에 대해 전환하고 싶은 런타임 예외를 넘겨주면 이 예외를 DataAccessException 타입으로 만들어주는 듯 하다.

코드를 보니 리플렉션을 사용하고 있다. 아마 사용자 정의 예외 클래스가 다른 클래스를 상속하거나 인터페이스에 종속적이지 않도록 Class 타입만으로 매핑이 가능하게 하기 위해 리플렉션을 사용한 듯 하다. (아직 리플렉션 공부하지 않아서 틀릴 수도 있습니다..) 코드를 살펴보면 생성자 매개변수의 개수와 타입을 조건문으로 하드코딩해서 생성자 종류를 분류하고 있다. 사용자 정의 예외 클래스의 생성자 매개변수로 <String String SQLException>, <String String Throwable> 등 다양한 조합을 지원하고 있다. 이 조합을 만족하지 않은 생성자를 가진 예외 클래스라면 DataAccessException으로 변환할 수 없고 예외가 터진다.

protected DataAccessException createCustomException(
			String task, String sql, SQLException sqlEx, Class exceptionClass) {

		// find appropriate constructor
		try {
			int constructorType = 0;
			Constructor[] constructors = exceptionClass.getConstructors();
			for (int i = 0; i < constructors.length; i++) {
				Class[] parameterTypes = constructors[i].getParameterTypes();
				if (parameterTypes.length == 1 && parameterTypes[0].equals(String.class)) {
					if (constructorType < MESSAGE_ONLY_CONSTRUCTOR)
						constructorType = MESSAGE_ONLY_CONSTRUCTOR;
				}
				if (parameterTypes.length == 2 && parameterTypes[0].equals(String.class) &&
						parameterTypes[1].equals(Throwable.class)) {
					if (constructorType < MESSAGE_THROWABLE_CONSTRUCTOR)
						constructorType = MESSAGE_THROWABLE_CONSTRUCTOR;
				}
				// ..
			}

			// invoke constructor
			Constructor exceptionConstructor = null;
			switch (constructorType) {
				case MESSAGE_SQL_SQLEX_CONSTRUCTOR:
					Class[] messageAndSqlAndSqlExArgsClass = new Class[] {String.class, String.class, SQLException.class};
					Object[] messageAndSqlAndSqlExArgs = new Object[] {task, sql, sqlEx};
					exceptionConstructor = exceptionClass.getConstructor(messageAndSqlAndSqlExArgsClass);
					return (DataAccessException) exceptionConstructor.newInstance(messageAndSqlAndSqlExArgs);
				case MESSAGE_SQL_THROWABLE_CONSTRUCTOR:
					Class[] messageAndSqlAndThrowableArgsClass = new Class[] {String.class, String.class, Throwable.class};
					Object[] messageAndSqlAndThrowableArgs = new Object[] {task, sql, sqlEx};
					exceptionConstructor = exceptionClass.getConstructor(messageAndSqlAndThrowableArgsClass);
					return (DataAccessException) exceptionConstructor.newInstance(messageAndSqlAndThrowableArgs);
				default:
            if (logger.isWarnEnabled()) {
               logger.warn("Unable to find appropriate constructor of custom exception class [" +
                     exceptionClass.getName() + "]");
            }
            return null;
         }
   }
   catch (Throwable ex) {
      if (logger.isWarnEnabled()) {
         logger.warn("Unable to instantiate custom exception class [" + exceptionClass.getName() + "]", ex);
      }
      return null;
   }
}

 

 

 

 

커스텀 첫번째 방법 : CustomSQLErrorCodesTranslation 정의해서 SQL 예외 커스텀하기

createCustomException가 일반적인 사용자 예외 클래스를 유연하게 DataAccessException으로 바꿔준다는 것은 알았다. 그럼 createCustomException을 사용하는 부분도 살펴보자. 동일한 클래스에 있는 doTranslate에서 사용하고 있다. 아까 살펴봤듯이 템플릿 메소드의 추상 메소드 부분이다.

public class SQLErrorCodeSQLExceptionTranslator extends AbstractFallbackSQLExceptionTranslator {
	@Override
	protected DataAccessException doTranslate(String task, String sql, SQLException ex) {
		// ...
		if (errorCode != null) {
			// Look for defined custom translations first. (스프링 기본 코드보다 우선순위 높음!)
			CustomSQLErrorCodesTranslation[] customTranslations = this.sqlErrorCodes.getCustomTranslations();
			if (customTranslations != null) {
				for (int i = 0; i < customTranslations.length; i++) {
					CustomSQLErrorCodesTranslation customTranslation = customTranslations[i];
					if (Arrays.binarySearch(customTranslation.getErrorCodes(), errorCode) >= 0) {
						if (customTranslation.getExceptionClass() != null) {
							// 여기서 createCustomException 사용!! getExceptionClass로 사용자 예외 가져옴
							DataAccessException customException = createCustomException(
									task, sql, sqlEx, customTranslation.getExceptionClass());
							if (customException != null) {
								logTranslation(task, sql, sqlEx, true);
								return customException;
							}
						}
					}
				}
			}
		// ... 스프링이 정의한 SQL 에러 코드 매핑

doTranslate의 매개변수로 받아온 task, sql, SQLException은 그대로 넘기고 있고, 사용자 정의 예외 클래스는 customTranslation.getExceptionClass()로 가져오고 있다. 사용자 예외 클래스는 CustomSQLErrorCodesTranslation가 가지고 있고, 이 Translation은 SqlErrorCodes 클래스의 getCustomTranslations로 가져올 수 있다. 처음에 봤던 바로 그 클래스다. 에러 코드를 예외 상황에 매핑해놓은 바로 그 클래스! 이 에러 정보를 가진 클래스에 사용자가 지정한 에러 코드와 예외 상황의 클래스를 따로 지정해서 넘겨주면, 번역기가 기본 에러 코드와 예외 클래스를 매핑했던 것처럼 해주나보다.

CustomSQLErrorCodeTranslation은 이렇게 생겼다. SQLException의 에러 코드와 발생시킬 예외 클래스를 필드로 가지고 있고, 게터와 세터를 가지고 있다. ExcetpionClass의 세터 부분을 보면 사용자에게 받은 예외 클래스가 DataAccessException 클래스로 변환 가능한지 체크한 후 넘긴다. 리플렉션 공부하고 나면 이거 보충해서 써야지.. (아직은 왜 리플렉션을 선택했는지 이유를 잘 모르겠다..그냥 DataAccessException 타입 쓰면 안되나?)

public class CustomSQLErrorCodesTranslation {

	private String[] errorCodes = new String[0];

	private Class exceptionClass;

	/**
	 * Set the SQL error codes to match.
	 */
	public void setErrorCodes(String[] errorCodes) {
		this.errorCodes = StringUtils.sortStringArray(errorCodes);
	}

	/**
	 * Return the SQL error codes to match.
	 */
	public String[] getErrorCodes() {
		return this.errorCodes;
	}

	/**
	 * Set the exception class for the specified error codes.
	 */
	public void setExceptionClass(Class exceptionClass) {
		if (!DataAccessException.class.isAssignableFrom(exceptionClass)) {
			throw new IllegalArgumentException("Invalid exception class [" + exceptionClass +
					"]: needs to be a subclass of [org.springframework.dao.DataAccessException]");
		}
		this.exceptionClass = exceptionClass;
	}

	/**
	 * Return the exception class for the specified error codes.
	 */
	public Class getExceptionClass() {
		return this.exceptionClass;
	}

}

CustomSQLErrorCodeTranslation를 사용하는 부분도 살펴보자. SQLErrorCodes는 디폴트로 지정해놓은 에러 코드와 함께 커스텀 에러 코드-예외 클래스 정보를 필드로 가지고 있다. 이제SQLErrorCodeSQLExceptionTranslator의 doTranslate에서 SQLErrorCodes의 CustomSQLErrorCodesTranslation를 가져와, 에러 코드와 예외 클래스를 참고해서 DataAccessException 클래스를 만들어 던질 것이다.

public class SQLErrorCodes {

	private CustomSQLErrorCodesTranslation[] customTranslations; // 필드

	public SQLExceptionTranslator getCustomSqlExceptionTranslator() { // 게터
		return customSqlExceptionTranslator;
	}
	
	public void setCustomSqlExceptionTranslatorClass(Class customSqlExceptionTranslatorClass) {
		if (customSqlExceptionTranslatorClass != null) {
			try {
				this.customSqlExceptionTranslator =
						(SQLExceptionTranslator) customSqlExceptionTranslatorClass.newInstance();
			}
			catch (InstantiationException e) {
				throw new InvalidDataAccessResourceUsageException(
						"Unable to instantiate " + customSqlExceptionTranslatorClass.getName(), e);
			}
			catch (IllegalAccessException e) {
				throw new InvalidDataAccessResourceUsageException(
						"Unable to instantiate " + customSqlExceptionTranslatorClass.getName(), e);
			}
		}
	}
}

// doTranslate
public class SQLErrorCodeSQLExceptionTranslator extends AbstractFallbackSQLExceptionTranslator {
	@Override
	protected DataAccessException doTranslate(String task, String sql, SQLException ex) {
			// ...
			CustomSQLErrorCodesTranslation[] customTranslations = this.sqlErrorCodes.getCustomTranslations();
			// for ...
					CustomSQLErrorCodesTranslation customTranslation = customTranslations[i];
					// ...
							DataAccessException customException = createCustomException(
									task, sql, sqlEx, customTranslation.getExceptionClass());
					// ...					
					return customException;
							}
						}
					}
				}
			}

 

 

 

 

🕺코드로 직접 예외를 커스텀해보자!🕺

 

 

 

 

ErrorCodes에 CustomSQLErrorCodesTranslation을 넘겨줘서 에러 코드와 발생시킬 예외 상황을 커스텀해보자. MySql의 중복 키 에러인 1062가 발생했을 때 내가 직접 정의한 런타임 예외 클래스인 DuplicateUserException 를 터뜨리는 작업을 해보려고 한다.

  1. jdbcTemplate 대신 SQLExcpetion을 직접 터뜨리는 PreparedStatement의 executeUpdate를 사용하는 JdbcContext를 사용한다. (이건 이전 예제에서 정의해 사용한 것)
  2. JdbcContext를 사용하는 UserDaoJdbcContext에 add 메소드를 사용한다. JdbcContext를 사용하므로 SQLException을 캐치한다.
  3. 캐치한 부분에서 CustomSQLErrorCodesTranslation 을 정의한다. 이 클래스를 SQLErrorCodes에 넘겨 생성하고, 이렇게 만든 SQLErrorCodes를 SQLErrorCodeSQLExceptionTranslator 에 넘긴다. 마지막으로 번역을 요청해 받아온 DataAccessException을 던진다.
  4. 테스트 코드에서 UserDaoJdbcContext 를 사용해 add를 두번 실행한다. DuplicateUserException 이 터지면 성공하는 테스트이다.
// 1. JdbcContext
public class JdbcContext {

    DataSource dataSource;

    public void setDataSource(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    public void workWithStatementStrategy(StatementStrategy stmt) throws SQLException {
        Connection c = null;
        PreparedStatement ps = null;

        try {
            c = dataSource.getConnection();
            ps = stmt.makePreparedStatement(c);
            ps.executeUpdate();
        } catch (SQLException e) {
            throw e;
        } finally {
            if (ps != null) {
                try {
                    ps.close();
                } catch (SQLException e) {}
            }
            if (c != null) {
                try {
                    c.close();
                } catch (SQLException e) {}
            }
        }
    }

    public void executeSQL(String sql) throws SQLException {
        workWithStatementStrategy(c -> c.prepareStatement(sql));
    }
}
// 2 & 3. UserDaoJdbcContext
public class UserDaoJdbcContext {

    JdbcContext jdbcContext;

    public void setDataSource(DataSource dataSource) {
        this.jdbcContext = new JdbcContext();
        this.jdbcContext.setDataSource(dataSource);
    }

		// 커스텀 SQL 에러 정의 : 1062번 MySQL 중복키 에러에 대해 직접 정의한 예외를 던진다
    private CustomSQLErrorCodesTranslation getCustomSQLErrorCodesTranslation() {
        CustomSQLErrorCodesTranslation c = new CustomSQLErrorCodesTranslation();
        c.setErrorCodes(new String[] {"1062"}); // MySQL DuplicateKeyError
        c.setExceptionClass(DuplicateUserException.class); // 이 코드 아래에 있음
        return c;
    }

    public void add(User user) {
        String sql = "insert into users(id, name, password) values(?,?,?)";
        StatementStrategy addStatementStrategy = c -> {
            PreparedStatement ps = c.prepareStatement(sql);
            ps.setString(1, user.getId());
            ps.setString(2, user.getName());
            ps.setString(3, user.getPassword());
            return ps;
        };
        try {
            jdbcContext.workWithStatementStrategy(addStatementStrategy);
        } catch (SQLException e) { // SQLException 캐치!
						// 커스텀 SQL 에러
            CustomSQLErrorCodesTranslation addErrorCode = getCustomSQLErrorCodesTranslation();
						// 커스텀 SQL 에러를 담은 SQLErrorCodes 빈 생성
            SQLErrorCodes sqlErrorCodes = new SQLErrorCodes();
            sqlErrorCodes.setCustomTranslations(new CustomSQLErrorCodesTranslation[] {addErrorCode});
            // SQLErrorCodeSQLExceptionTranslator 생성
						SQLErrorCodeSQLExceptionTranslator sqlExceptionTranslator = new SQLErrorCodeSQLExceptionTranslator();
            sqlExceptionTranslator.setSqlErrorCodes(sqlErrorCodes); // SQLErrorCodes로 번역 요청

            throw sqlExceptionTranslator.translate("Add user", sql, e); // 번역한 DataAccessException 반환
        }
    }
}

// 직접 정의한 예외 클래스
public class DuplicateUserException extends DataAccessException {

    public DuplicateUserException(String msg) {
        super(msg);
    }

    public DuplicateUserException(String msg, Throwable cause) {
        super(msg, cause);
    }
}
// 4. UserDaoJdbcTest
public class UserDaoJdbcTest {

    private UserDaoJdbcContext daoJdbcContext;
    private DataSource dataSource;
    private User user1;

    @Before
    public void setUp() throws SQLException {
        this.user1 = new User("minpearl", "민휘", "lololo");

        DataSource dataSource = new SingleConnectionDataSource("jdbc:mysql://localhost/toby", "root", "star0826", true);
        this.daoJdbcContext = new UserDaoJdbcContext();
        daoJdbcContext.setDataSource(dataSource);
    }

    @Test(expected = DuplicateUserException.class)
    public void add() {
        daoJdbcContext.add(user1);
        daoJdbcContext.add(user1);
    }

}

 

 

 

테스트 성공. 직접 정의한 DuplicateUserException가 잘 터진 것을 알 수 있다.

이렇게 스프링이 매핑해놓은 에러 코드와 DataAccessException 서브 클래스를 사용하는 것이 아닌, 직접 정의한 에러 코드와 예외 클래스를 사용해 SQLException 체크 예외를 런타임 예외로 바꿔보았다.

클래스가 많이 등장해서 이해하기 좀 어려운데, 클래스의 역할을 정리해서 협력 구조를 정리해보자.

  • JdbcContext : Jdbc를 직접 사용하는 클래스. SQLException을 던지는 Jdbc의 API를 호출한다. SQLException를 그대로 던진다.
  • UserDaoJdbcContext : SQL을 만들고, SQLException을 그대로 던지는 JdbcContext의 메소드를 호출한다. SQLException을 잡아서 처리한다.
  • CustomSQLErrorCodesTranslation : 사용자가 직접 정의한 SQL 에러 코드와 사용자 예외 클래스의 매핑 정보를 담는다.
  • SQLErrorCodes : CustomSQLErrorCodesTranslation을 포함해 스프링이 기본적으로 정의한 에러 코드와 예외 클래스의 정보를 담는 자료구조 빈이다.
  • SQLErrorCodeSQLExceptionTranslator : SQLErrorCodes를 사용한다. 발생한 SQLException의 에러코드를 SQLErrorCodes의 에러코드에서 찾아 적절한 예외 클래스를 DataAccessException 타입(런타임 예외)으로 반환한다. doTranslate에서 스프링 정의 코드보다 커스텀 에러 코드를 먼저 검사하므로, 우선순위는 커스텀 에러가 더 높다.

지금까지 ErrorCodes에 CustomSQLErrorCodesTranslation 를 사용해 에러 코드를 커스텀하는 방법을 알아봤다. 그런데 사실 에러 코드를 커스텀하는 방법은 이것 말고도 더 있다. (거짓말이지..) SQLErrorCodeSQLExceptionTranslator의 doTranslate 앞 부분을 보면 다른 방식으로 사용자 정의 에러 코드와 예외 클래스를 가져온다. 이 부분도 한번 살펴보자.

 

 

 

커스텀 두번째 방법 : SQLExceptionTranslator 정의해서 SQL 예외 커스텀하기

public class SQLErrorCodeSQLExceptionTranslator extends AbstractFallbackSQLExceptionTranslator {
	@Override
	protected DataAccessException doTranslate(String task, String sql, SQLException ex) {
		// ...

		// First, try custom translation from overridden method.
		DataAccessException dex = customTranslate(task, sql, sqlEx);
		if (dex != null) {
			return dex;
		}
		
		// 이번에 분석할 것은 바로 여기
		// Next, try the custom SQLException translator, if available.
		if (this.sqlErrorCodes != null) {
			SQLExceptionTranslator customTranslator = this.sqlErrorCodes.getCustomSqlExceptionTranslator();
			if (customTranslator != null) {
				DataAccessException customDex = customTranslator.translate(task, sql, sqlEx);
				if (customDex != null) {
					return customDex;
				}
			}
		}

		// 아까 분석한 CustomSQLErrorCodesTranslation[]을 사용해 분석하는 코드
		// Check SQLErrorCodes with corresponding error code, if available.

커스텀 예외를 찾아서 번역하는 코드가 크게 세 문단이다. 초반에 등장할수록 높은 우선순위를 가지는 방식이다. 코드에 First와 Next가 보인다. 이후에 나온 세번째 코드 문단은 우리가 아까 분석한 CustomSQLErrorCodesTranslation를 사용하는 코드이므로 넘어가자.

우선 Next 주석을 보니 CustomSqlExceptionTranslator를 가져와 사용하고 있다. 따라가보면 SQLErrorCodes에 SQLExceptionTranslator 필드가 있는 것을 알 수 있다.

public class SQLErrorCodes {
	// 필드
	private SQLExceptionTranslator customSqlExceptionTranslator = null;
	// 스프링이 기본으로 정의하는 예외 상황들
	// 아까 본 CustomSQLErrorCodesTranslation

	// getter
	public SQLExceptionTranslator getCustomSqlExceptionTranslator() {
		return customSqlExceptionTranslator;
	}

	// 리플렉션을 사용하는 setter
	public void setCustomSqlExceptionTranslatorClass(Class customSqlExceptionTranslatorClass) {
		if (customSqlExceptionTranslatorClass != null) {
			try {
				this.customSqlExceptionTranslator =
						(SQLExceptionTranslator) customSqlExceptionTranslatorClass.newInstance();
			}
			catch (InstantiationException e) {
				throw new InvalidDataAccessResourceUsageException(
						"Unable to instantiate " + customSqlExceptionTranslatorClass.getName(), e);
			}
			catch (IllegalAccessException e) {
				throw new InvalidDataAccessResourceUsageException(
						"Unable to instantiate " + customSqlExceptionTranslatorClass.getName(), e);
			}
		}
	}

이 방법은 CustomSQLExceptionTranslator을 직접 정의해서 SQLErrorCodes 에 세팅하는 방법이다. 여기에 에러 코드와 터뜨릴 예외 클래스 정보를 담고, translate 메소드를 구현해야 한다. 구현하는 translate는 DataAccessException 타입의 런타입 예외를 반환한다. 이제 SQLErrorCodes를 읽는 SQLErrorCodeSQLExceptionTranslator가 doTranslate에서 CustomSQLExceptionTranslator의 translate가 반환하는 런타임 예외를 받아 던진다.

직접 코드로 작성해보자!

SQLExceptionTranslator을 구현해서 커스텀 예외를 매핑해보자. 매핑할 정보는 아까와 동일한데, MySQL의 DuplicateKey 에러 코드인 1062와 DataAccessException의 서브 클래스인 DuplicateUserException를 사용한다.

// SQLExceptionTranslator를 구현한 CustomUserDuplicateSQLExceptionTranslator 정의
public class CustomUserDuplicateSQLExceptionTranslator implements SQLExceptionTranslator {
    private String errorCode;
    private Class<? extends DataAccessException> exceptionClass;

    public CustomUserDuplicateSQLExceptionTranslator() {
        this.errorCode = "1062";
        this.exceptionClass = DuplicateUserException.class;
    }

		// 생성자로 초기화한 에러 매핑 정보를 가지고 직접 예외를 전환하는 책임
    @Override
    public DataAccessException translate(String task, String sql, SQLException sqlException) {
        if (sqlException.getErrorCode() == Integer.parseInt(errorCode)) {
            try {
                return exceptionClass.getDeclaredConstructor(String.class, Throwable.class).newInstance(
                        "Duplicate user: " + sqlException.getMessage(), sqlException);
            } catch (Exception e) {
                // rethrow exception if anything goes wrong
                throw new RuntimeException(e);
            }
        } else {
            return new SQLExceptionSubclassTranslator().translate(task, sql, sqlException);
        }
    }
}
// UserDao에서 CustomUserDuplicateSQLExceptionTranslator를 세팅한 SQLErrorCodes 생성
// SQLErrorCodeSQLExceptionTranslator에 SQLErrorCodes를 넘겨서 번역 요청
// SQLErrorCodeSQLExceptionTranslator의 doTranslate에서는
// SQLErrorCodes의 customSqlExceptionTranslator이 null이 아니므로
// customSqlExceptionTranslator의 translate한 결과를 반환한다.
public class UserDaoJdbcContext {

	public void addWithCustomTranslator(User user) {
		String sql = "insert into users(id, name, password) values(?,?,?)";
		StatementStrategy addStatementStrategy = c -> {
		PreparedStatement ps = c.prepareStatement(sql);
			ps.setString(1, user.getId());
			ps.setString(2, user.getName());
			ps.setString(3, user.getPassword());
			return ps;
		};
		try {
			jdbcContext.workWithStatementStrategy(addStatementStrategy);
		} catch (SQLException e) {
			SQLErrorCodes sqlErrorCodes = SQLErrorCodesFactory.getInstance().getErrorCodes(dataSource);
			sqlErrorCodes.setCustomSqlExceptionTranslatorClass(CustomUserDuplicateSQLExceptionTranslator.class);

			SQLErrorCodeSQLExceptionTranslator sqlExceptionTranslator = new SQLErrorCodeSQLExceptionTranslator();
			sqlExceptionTranslator.setSqlErrorCodes(sqlErrorCodes);

			throw sqlExceptionTranslator.translate("Add user", sql, e);
		}
	}
}
// 중복 추가시 DuplicateUserException가 발생하는지 테스트!
public class UserDaoJdbcTest {
    private UserDaoJdbcContext daoJdbcContext;
    private DataSource dataSource;
    private User user1;

    @Before
    public void setUp() throws SQLException {
        this.user1 = new User("minpearl", "민휘", "lololo");

        DataSource dataSource = new SingleConnectionDataSource("jdbc:mysql://localhost/toby", "root", "star0826", true);
        this.daoJdbcContext = new UserDaoJdbcContext();
        daoJdbcContext.setJdbcContext(dataSource);
    }

    @Test(expected = DuplicateUserException.class)
    public void addWithCustomTranslator() {
        daoJdbcContext.addWithCustomTranslator(user1);
        daoJdbcContext.addWithCustomTranslator(user1);
    }

}

 

테스트 성공 ! 🧚‍♂️ 우리가 예상한대로 커스텀하게 매핑한 예외가 터지는 것을 확인했다.

 

 

커스텀 세번째 방법 : SQLExceptionTranslator 서브 클래스 정의해서 SQL 예외 커스텀하기

마지막으로 우선순위가 가장 높았던 doTranslate의 첫번째 문단을 살펴보자.

public class SQLErrorCodeSQLExceptionTranslator extends AbstractFallbackSQLExceptionTranslator {
	@Override
	protected DataAccessException doTranslate(String task, String sql, SQLException ex) {
		// ...

		// 바로 여기!
		// First, try custom translation from overridden method.
		DataAccessException dex = customTranslate(task, sql, sqlEx);
		if (dex != null) {
			return dex;
		}

customTranslate를 호출하길래 찍어보니 protected고 null을 반환한다. 훅처럼 생겼는데 이거? 하고 주석을 살펴보니, SQLErrorCodeSQLExceptionTranslator 의 서브 클래스가 에러 코드와 예외 상황을 커스텀하게 매핑하고 싶을 때 이 메소드를 오버라이딩한다고 한다. SQLException을 DataAccessException으로 전환한다고 하니 여기서도 낙관적인 예외 처리 전략을 확인할 수 있다.

// Subclasses can override this method to attempt a custom mapping 
// from SQLException to DataAccessException.
protected DataAccessException customTranslate(String task, String sql, SQLException sqlEx) {
	return null;
}

SQLErrorCodeSQLExceptionTranslator 의 서브 클래스를 정의해서 커스텀 예외를 매핑해보자. 매핑할 정보는 아까와 동일한데, MySQL의 DuplicateKey 에러 코드인 1062와 DataAccessException의 서브 클래스인 DuplicateUserException를 사용한다.

// SQLErrorCodeSQLExceptionTranslator 의 서브 클래스를 정의
public class CustomUserDuplicateSQLExceptionTranslator implements SQLExceptionTranslator {
    private String errorCode;
    private Class<? extends DataAccessException> exceptionClass;

    public CustomUserDuplicateSQLExceptionTranslator() {
        this.errorCode = "1062";
        this.exceptionClass = DuplicateUserException.class;
    }

    @Override
    public DataAccessException translate(String task, String sql, SQLException sqlException) {
        if (sqlException.getErrorCode() == Integer.parseInt(errorCode)) {
            try {
                return exceptionClass.getDeclaredConstructor(String.class, Throwable.class).newInstance(
                        "Duplicate user: " + sqlException.getMessage(), sqlException);
            } catch (Exception e) {
                // rethrow exception if anything goes wrong
                throw new RuntimeException(e);
            }
        } else {
            return new SQLExceptionSubclassTranslator().translate(task, sql, sqlException);
        }
    }
}
// UserDao에서 CustomUserDuplicateSQLExceptionTranslator 사용
public class UserDaoJdbcContext {

    public void addWithCustomSQLErrorCodeTranslator(User user) {
        String sql = "insert into users(id, name, password) values(?,?,?)";
        StatementStrategy addStatementStrategy = c -> {
            PreparedStatement ps = c.prepareStatement(sql);
            ps.setString(1, user.getId());
            ps.setString(2, user.getName());
            ps.setString(3, user.getPassword());
            return ps;
        };
        try {
            jdbcContext.workWithStatementStrategy(addStatementStrategy);
        } catch (SQLException e) {
            SQLErrorCodeSQLExceptionTranslator sqlExceptionTranslator = new CustomSQLErrorCodeTranslator(dataSource);
            throw sqlExceptionTranslator.translate("Adding user failed", sql, e);
        }
    }
}
// 중복 발생했을 때 DuplicateUserException 터지는지 테스트
public class UserDaoJdbcTest {
    private UserDaoJdbcContext daoJdbcContext;
    private DataSource dataSource;
    private User user1;

    @Before
    public void setUp() throws SQLException {
        this.user1 = new User("minpearl", "민휘", "lololo");

        DataSource dataSource = new SingleConnectionDataSource("jdbc:mysql://localhost/toby", "root", "star0826", true);
        this.daoJdbcContext = new UserDaoJdbcContext();
        daoJdbcContext.setJdbcContext(dataSource);
    }

    @Test(expected = DuplicateUserException.class)
    public void addWithCustomSQLErrorCodeTranslator() {
        daoJdbcContext.addWithCustomSQLErrorCodeTranslator(user1);
        daoJdbcContext.addWithCustomSQLErrorCodeTranslator(user1);
    }

}

테스트 성공 ! 🧚‍♂️ 우리가 예상한대로 커스텀하게 매핑한 예외가 터지는 것을 확인했다.

 

 

마치며

생각보다 길었던 SQLExceptionTranslator 분석을 여기서 마친다.

 

책을 읽을 때, 이번 절은 낙관적인 예외 처리 전략과 기술 종류 추상화를 설명하기 위해 코드보다는 클래스의 역할이나 장단점을 설명하는 부분이 많았다. 이번 분석을 통해 런타임 예외 전환과 추상화라는 협력을 만들어내는 오브젝트들의 협력이 어떻게 이루어는지 코드로 살펴볼 수 있어 정말 재미있었다. 여담으로, 분석하는 것은 그리 많은 시간이 들지 않지만 블로그로 정리하고 글을 다듬고 주석을 추가하는 과정이 정말 오래 걸린다. (사실 커스텀 예외는 다들 별로 궁금해할 것 같지는 않은데..) 다음엔 좀 적당히 줄여야겠다..