Home 스프링 예외 처리 (서비스 코드 예외 포함 문제 해결)
Post
Cancel

스프링 예외 처리 (서비스 코드 예외 포함 문제 해결)

기존 @Transactional 애노테이션을 이용한 코드에서도 서비스 계층임에도 리포지토리에서 던지는 체크 예외인 SQLException을 처리하지 못해 throws를 사용해 던져주었다. 이는 서비스가 SQLException에 의존하고 있는 것이다.

이러한 문제를 어떻게 해결할까?

이전 포스트에서 예외에 대해 알아보았다. 체크 예외를 언체크 예외로 바꾸어 주면 된다는 힌트를 얻었다.

이 내용을 바탕으로 의존 문제를 해결해본다.

체크 예외와 인터페이스

이 문제를 해결하기 위한 첫 번째 단계는 인터페이스의 도입이다.

리포지토리에 인터페이스를 도입한다. 이전의 코드는 서비스단에서 리포지토리를 사용할 때 구현체를 직접 지정해주었다.

  • 인터페이스를 도입하면 MemberService는 MemberRepository 인터페이스에만 의존한다.
  • 구현기술을 변경하고 싶다면 DI를 이용해 MemberService 코드의 변경없이 구현체를 갈아 끼우면 된다는 장점이 있다.

인터페이스

1
2
3
4
5
6
public interface MemberRepository {
	Member save(Member member);
	Member findById(String memberId);
	void update(String memberId, int money);
	void delete(String memberId);
}

특정 기술에 종속되지 않는 순수한 인터페이스이다. 이 인터페이스를 기반으로 특정 기술을 사용하는 구현체를 만들도록 한다.

문제?

하지만 인터페이스를 도입한다고해도 SQLException 문제는 해결되지 않는다. 체크예외를 사용하려면 인터페이스에도 해당 체크 예외가 선언되어 있어야 한다. 이 부분이 바로 기존에 인터페이스를 도입하지 않는 이유이다.

1
void update(String memberId, int money) throws SQLException;

이렇게 되는 것이다.

구현 기술을 쉽게 변경하기 위해 인터페이스를 도입하더라도 SQLException 즉, JDBC 기술에 종속적인 인터페이스가 된다. 인터페이스의 목적은 구현체를 쉽게 변경하기 위함인데 이렇게 되면 향후 구현체를 변경할 때 인터페이스도 변경해야 하기 때문에 문제가 생기는 것이다.

해결

인터페이스를 도입하되, 구현체에서 발생하는 체크 예외를 런타임 예외로 바꾸어주면 된다. 인터페이스는 런타임 예외를 따로 선언할 필요가 없고, 이는 곧 인터페이스가 특정 기술에 종속적이지 않다는 것을 의미한다.

런타임 예외의 적용

MyDbException 런타임 예외

체크예외를 런타임 예외로 바꾸어주기 위해 사용자 정의 런타임 예외를 만들어야 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class MyDbException extends RuntimeException{  
	public MyDbException() {  
	}  
	  
	public MyDbException(String message) {  
		super(message);  
	}  
	  
	public MyDbException(String message, Throwable cause) {  
		super(message, cause);  
	}  
	  
	public MyDbException(Throwable cause) {  
		super(cause);  
	}  
}

리포지토리

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Override  
public void update(String memberId, int money) {  
	String sql = "update member set money=? where member_id=?";  
	Connection con = null;  
	PreparedStatement pstmt = null;  
	try {  
		con = getConnection();  
		pstmt = con.prepareStatement(sql);  
		pstmt.setInt(1, money);  
		pstmt.setString(2, memberId);  
		int resultSize = pstmt.executeUpdate();  
		log.info("resultSize={}", resultSize);  
	} catch (SQLException e) {  
		throw new MyDbException(e); //런타임 예외로 전환
	} finally {  
		close(con, pstmt, null);  
	}  
}

update() 메서드만 예시로 든다. save(), findById() 등등 다 같다.

기존 리포지토리 코드를 그대로 사용하지만, MemberRepository 인터페이스를 상속받는다.

throws SQLException이 없어지고, 이를 위해서 SQLException이 발생할 때 만든 런타임 예외를 던져주어 체크 예외를 런타임 예외로 바꾸어 준다.

  • 런타임 예외로 전환할 때 기존 SQLException e의 e를 담아준다.
    • 로그에서 실제 원인이 남지 않는 현상을 방지해야 한다.
    • 반드시 알아야 한다. 이전 포스트에서도 설명했지만 또 강조.

서비스

서비스는 이제 리포지토리의 구현체를 직접 지정해주지 않고 MemberRepository 인터페이스에 의존하도록 바꾸어야 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Slf4j  
public class MemberServiceV4 {  
	private final MemberRepository memberRepository;  
	  
	public MemberServiceV4(MemberRepository memberRepository) {  
		this.memberRepository = memberRepository;  
	}  
	  
	@Transactional  
	public void accountTransfer(String fromId, String toId, int money) {  
		bizLogic(fromId, toId, money);  
	}  
	  
	private void bizLogic(String fromId, String toId, int money) {  
		Member fromMember = memberRepository.findById(fromId);  
		Member toMember = memberRepository.findById(toId);  
		  
		memberRepository.update(fromId, fromMember.getMoney() - money);  
		validation(toMember);  
		memberRepository.update(toId, toMember.getMoney() + money);  
	}  
	  
	private static void validation(Member toMember) {  
		if (toMember.getMemberId().equals("ex")) {  
			throw new IllegalStateException("이체 중 예외 발생");  
		}  
	}  
}
  • 구현체를 지정하지 않고 인터페이스에 의존한다.
    • 생성자도 맞게 수정했다.
  • bizLogic()과 같은 메서드에서 throws SQLException이 사라졌다.

런타임 예외로 전환해줌으로서 서비스 코드는 순수하게 유지할 수 있게 됐다. 향후 다른 구현 기술로 변경하더라도 서비스 계층 코드는 변경하지 않아도 된다.

하지만 아직 문제가 남아있다.

남은 문제

리포지토리에서 넘어오는 특정한 예외의 경우에는 복구를 시도할 수도 있다.

예를 들면 회원 가입 시 DB에서 같은 ID가 중복이 됐을 때, 서비스 계층에서 이러한 부분을 새로운 ID로 만들어서 복구해줄 수도 있다. (hello -> hello12345 대체)

그런데 위와 같은 방식은 MyDbException 이라는 예외만 넘어오기 때문에 예외를 구분할 수 없다. SQL 문법 오류인지, ID 중복 오류인지 등등.. 특정 상황에 예외를 잡아 복구하고 싶을 때 예외를 구분해야 한다.

데이터 접근 예외 직접 만들기

특정 오류를 구분하기 위해 어떻게 해야 할까.

DB는 오류가 발생하면 특정 오류코드를 남긴다. SQLException은 이러한 에러코드를 담고 있고, 이를 활용하면 된다.

1
e.getErrorCode() == 23505
  • H2 데이터베이스 오류 코드
    • 23505 : 키 중복 오류
    • 42000 : SQL 문법 오류

(같은 오류더라도 데이터베이스에 따라 정의된 오류 코드가 다를 수 있다.)

서비스 계층에서는 예외 복구를 위해 키 중복 오류를 확인할 수 있어야 한다. 새로운 ID를 만들어 다시 저장을 시도할 수 있기 때문이다.

리포지토리는 SQLException을 서비스 계층에 던지고 서비스 계층은 이 예외의 오류 코드를 확인해 키 중복 오류인 경우 새로운 ID를 만들어 다시 저장하도록 하면 된다.

그렇다고 SQLException에 들어있는 ErrorCode를 사용하자고 SQLException을 다시 서비스 계층에 던지면, 서비스 계층이 SQLException에 또 의존하게 된다.

따라서 이 부분도 리포지토리에서 예외를 변환해서 던지고, 단 변환할 때 리포지토리에서 SQLException을 이용해 ErrorCode를 확인하여 알맞은 예외로 변환시키면 되는 것이다.

ErrorCode를 활용하는 새로운 예외 만들기

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class MyDuplicateKeyException extends MyDbException{  
	public MyDuplicateKeyException() {  
	}  
	  
	public MyDuplicateKeyException(String message) {  
		super(message);  
	}  
	  
	public MyDuplicateKeyException(String message, Throwable cause) {  
		super(message, cause);  
	}  
	  
	public MyDuplicateKeyException(Throwable cause) {  
		super(cause);  
	}  
}
  • 기존에 사용했던 MyDbException을 상속받아 의미있는 계층을 만든다. 이렇게 하면 데이터베이스 관련 예외라는 계층을 만들 수 있다.
  • 데이터 중복의 경우에만 던지는 예외이다.
  • 직접 만든 예외이기 때문에 JDBC나 JPA같은 특정 기술에 의존하지 않는다. 따라서 이 예외를 사용하더라도 서비스 계층의 순수성을 유지할 수 있다.
    • 다른 기술로 바꾸어도 이 예외는 유지된다.

리포지토리, 서비스에 적용

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
@RequiredArgsConstructor  
static class Service {  
	private final Repository repository;  
	
	public void create(String memberId) {  
		try {  
			repository.save(new Member(memberId, 0));  
			log.info("saveId={}", memberId);  
		} catch (MyDuplicateKeyException e) {  
			log.info("키 중복, 복구 시도");  
			String retryId = generateNewId(memberId);  
			log.info("retryId={}", retryId);  
			repository.save(new Member(retryId, 0));  
		} catch (MyDbException e) {  
			log.info("데이터 접근 계층 예외", e);  
			throw e;  
		}  
	}  
	  
	private String generateNewId(String memberId) {  
		return memberId + new Random().nextInt(10000);  
	}  
}  
  
@RequiredArgsConstructor  
static class Repository {  
	private final DataSource dataSource;  
	  
	public Member save(Member member) {  
	String sql = "insert into member(member_id, money) values(?,?)";  
	Connection con = null;  
	PreparedStatement pstmt = null;  
	  
	try {  
		con = dataSource.getConnection();  
		pstmt = con.prepareStatement(sql);  
		pstmt.setString(1, member.getMemberId());  
		pstmt.setInt(2, member.getMoney());  
		pstmt.executeUpdate();  
		return member;  
	} catch (SQLException e) {  
		if (e.getErrorCode() == 23505) {  
			throw new MyDuplicateKeyException(e);  
		}  
		throw new MyDbException(e);  
	} finally {  
		JdbcUtils.closeStatement(pstmt);  
		JdbcUtils.closeConnection(con);  
	}  
}  

예시로 만든 코드이다.

  • 리포지토리에서 예외가 발생하면 에러코드를 받아 에러코드가 키 중복 오류라면 MyDuplicateKeyException으로 바꿔준다. 그렇지 않다면 MyDbException을 던진다.
  • 런타임 예외이기 때문에 서비스 단계에서 꼭 처리해야 되는 것은 아니지만 잡아서 처리할 수도 있다.
    • MyDuplicateKeyException이라면 기존 입력한 ID에 랜덤 숫자를 넣어 다시 save()를 시도한다.
    • MyDbException이라면 로그를 출력하고 예외를 던진다.

정리

  • SQLException을 통해 데이터베이스에 어떤 오류가 있는지 확인할 수 있다.
  • 예외 변환을 통해 SQLException을 특정 기술에 의존하지 않는 직접 만든 예외인 MyDuplicateException으로 변환할 수 있었다.
  • 리포지토리 계층이 예외를 변환해주어 서비스 계층은 특정 기술에 의존하지 않아도 된다.

하지만 또 문제가 있다.

생겨버린 또다른 문제..

위에서 언급했듯 SQLException에서 ErrorCode를 받아오는 것은 데이터베이스마다 같은 오류라도 ErrorCode가 다를 수 있다.

즉, 추후 데이터베이스를 변경하고 싶다면 ErrorCode로 조건을 걸었던 부분을 전부 수정해야 하는 것이다.

이러한 문제는 스프링이 해결해준다.

스프링 예외 추상화 개념

스프링은 위의 문제를 해결하기 위해 데이터 접근과 관련된 예외를 추상화해 제공해준다.

  • 스프링은 데이터 접근 계층에 대한 수십 가지의 예외를 정리해 일관된 예외 계층을 제공한다.
  • 각각의 예외는 특정 기술에 종속적이지 않게 설계되어 있다.
    • JDBC든 JPA든 스프링이 제공하는 예외를 사용하면 된다.
  • 각 기술을 사용할 때 발생하는 예외를 스프링이 제공하는 예외로 변환해주는 것도 스프링이 해준다.
  • 스프링 데이터 접근 예외의 최고 상위는 org.springframework.dao.DataAccessException 이다.
    • 그림처럼 런타임 예외를 상속받았기 때문에 스프링이 제공하는 데이터 접근 계층의 모든 예외는 런타임 예외이다.
  • DataAccessException은 크게 2가지로 구분한다.
    • Transient: 일시적이라는 뜻으로, Transient 하위 예외는 동일한 SQL을 다시 시도했을 때 성공할 가능성이 있다.
      • 예를 들어 쿼리 타임아웃, 락과 관련된 오류들이다. 이러한 오류들은 데이터베이스 상태가 좋아지거나, 락이 풀렸을 때 다시 시도하면 성공할 수도 있다.
    • NonTransient: 일시적이지 않다는 뜻으로, 같은 SQL을 반복하면 실패한다.
      • SQL 문법 오류, 데이터베이스 제약조건 위배 등이 있다.

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

스프링이 각 기술을 사용할 때 발생하는 예외를 스프링이 제공하는 예외로 변환해준다고 하였다.

코드를 통해 이러한 예외 변환기를 알아본다.

SpringExceptionTranslatorTest

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Test  
void exceptionTranslator() {  
	String sql = "select bad sadfsdf";  
	  
	try {  
		Connection con = dataSource.getConnection();  
		PreparedStatement pstmt = con.prepareStatement(sql);  
		pstmt.executeQuery();  
	} catch (SQLException e) {  
		assertThat(e.getErrorCode()).isEqualTo(42122);  
		  
		SQLErrorCodeSQLExceptionTranslator exTranslator = new SQLErrorCodeSQLExceptionTranslator();  
		DataAccessException resultEx = exTranslator.translate("select", sql, e);  
		log.info("resultEx", resultEx);  
		assertThat(resultEx.getClass()).isEqualTo(BadSqlGrammarException.class);  
	}  
}

SQLErrorCodeSQLExceptionTranslator가 우리가 사용하는 데이터베이스의 오류코드를 받아 어떤 오류인지 변환해준다.

  • translate(): 적절한 스프링 데이터 접근 예외로 바꾸어 준다.
    • 메서드의 첫번째 파라미터는 읽을 수 있는 설명이고, 두 번째는 실행한 sql, 마지막은 발생된 SQLException을 전달한다.
  • 위의 코드에서는 문법이 잘못되었으므로 BadSqlGrammarException을 반환한다.

변환기 내부 작동 원리

각각의 DB마다 ErrorCode가 다른데 스프링은 어떻게 처리할까.

아래 sql-error-codes.xml 파일을 보면 알 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<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>  
</bean>  
	
<bean id="MySQL" class="org.springframework.jdbc.support.SQLErrorCodes">  
	<property name="badSqlGrammarCodes">  
		<value>1054,1064,1146</value>  
	</property>  
	<property name="duplicateKeyCodes">  
		<value>1062</value>  
	</property>  
</bean>
  • 스프링 SQL 예외 변환기는 SQL ErrorCode를 이 파일에 대입해 어떤 스프링 데이터 접근 예외로 전환해야 할지 찾아낸다.

위 파일을 확인해보면 10개 이상의 흔히 사용하는 대부분의 관계형 데이터베이스를 지원하는 것을 볼 수 있다.

정리

  • 스프링은 데이터 접근 계층에 대한 일관된 예외 추상화를 제공한다.
  • 스프링은 예외 변환기를 통해 SQLException의 ErrorCode에 맞는 적절한 스프링 데이터 접근 예외로 변환해준다.
  • 스프링 예외 추상화 덕분에 특정 기술에 종속적이지 않을 수 있다. 기술의 변경이 있어도 코드 변경은 크지 않다.
  • 단, 스프링이 제공하는 예외를 사용하기 때문에 스프링에 대한 기술 종속성은 발생한다.
    • 스프링에 대한 기술 종속성까지 제거하려면 예외를 모두 직접 정의하고 예외 변환도 직접하면 된다. 실용적인 방법은 아니다.

스프링 예외 추상화 적용

기존 코드에 예외 추상화를 적용해본다.

리포지토리

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@Slf4j  
public class MemberRepositoryV4_2 implements MemberRepository{  
  
	private final DataSource dataSource;  
	private final SQLExceptionTranslator exTranslator;  
	  
	public MemberRepositoryV4_2(DataSource dataSource) {  
		this.dataSource = dataSource;  
		this.exTranslator = new SQLErrorCodeSQLExceptionTranslator(dataSource);  
	}  
	  
	@Override  
	public Member save(Member member) {  
		String sql = "insert into member(member_id, money) values(?, ?)";  
		  
		Connection con = null;  
		PreparedStatement pstmt = null;  
		  
		try {  
			con = getConnection();  
			pstmt = con.prepareStatement(sql);  
			pstmt.setString(1, member.getMemberId());  
			pstmt.setInt(2, member.getMoney());  
			pstmt.executeUpdate();  
			return member;  
		} catch (SQLException e) {  
			throw exTranslator.translate("save", sql, e); //변환
		} finally {  
			close(con, pstmt, null);  
		}  
	}

...

SQLExceptionTranslator를 사용해 SQLException을 이용하여 알아서 알맞은 오류로 변환되도록 해주었다.

서비스 코드에서는 스프링 데이터 접근 예외에 대해 처리해주고 싶은 부분만 처리해주면 되는 것이다.

정리

@Transactional로 커넥션을 유지해야 하는 문제 등등의 트랜잭션 문제를 해결할 수 있었고 서비스 계층에는 트랜잭션 관련 코드를 없앨 수 있었다. 그리고 이제는 예외에서 발생할 수 있는 부분을 스프링 예외 추상화 덕분에 전부 해결하였다.

서비스 계층은 특정 리포지토리의 구현 기술과 예외에 종속적이지 않을 수 있게 되었다. 서비스 계층을 순수하게 유치했고 즉, DI를 제대로 활용할 수 있게 된 것이다.

이제 서비스 계층의 문제는 전부 해결했다. 하지만 JDBC 기술을 사용하는 리포지토리에서의 문제가 남아있다.

JDBC 반복 문제

리포지토리의 코드를 보면 커넥션을 가져오고, prepareStatement 세팅하고 예외를 잡아서 스프링 예외로 변환해주고, 사용했던 것들을 닫거나 반환하는 이런 모든 코드가 모든 메서드에 반복된다.

  • 커넥션 조회, 커넥션 동기화
  • PreparedStatement 생성 및 파라미터 바인딩
  • 쿼리 실행
  • 결과 바인딩
  • 예외 발생 시 스프링 예외 변환기 실행
  • 리소스 종료

스프링은 이러한 JDBC 반복 문제를 해결하기 위해 JdbcTemplate 라는 템플릿을 제공한다.

리포지토리

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
@Slf4j  
public class MemberRepositoryV5 implements MemberRepository{  
  
	private final JdbcTemplate template;  
	  
	public MemberRepositoryV5(DataSource dataSource) {  
		this.template = new JdbcTemplate(dataSource);  
	}  
	  
	@Override  
	public Member save(Member member) {  
		String sql = "insert into member(member_id, money) values(?, ?)";  
		template.update(sql, member.getMemberId(), member.getMoney());  
		return member;  
	}  
	  
	@Override  
	public void update(String memberId, int money) {  
		String sql = "update member set money=? where member_id=?";  
		template.update(sql, money, memberId);  
	}  
	  
	@Override  
	public void delete(String memberId) {  
		String sql = "delete from member where member_id=?";  
		template.update(sql, memberId);  
	}  
	  
	@Override  
	public Member findById(String memberId) {  
		String sql = "select * from member where member_id = ?";  
		  
		return template.queryForObject(sql, memberRowMapper(), memberId);  
	}  
	  
	private RowMapper<Member> memberRowMapper() {  
		return (rs, rowNum) -> {  
			Member member = new Member();  
			member.setMemberId(rs.getString("member_Id"));  
			member.setMoney(rs.getInt("money"));  
			return member;  
		};  
	}  
}

코드가 정말 간결해지는 것을 볼 수 있다.

getConnection() 같은 메서드를 만들어 사용하지 않아도 되고, close()를 해줄 필요 없다. 트랜잭션을 위한 커넥션 동기화, 예외 발생 시 스프링 예외 변환도 다 해주는 것이다. template가 아주 간단하게 중복되던 부분을 다 알아서 해준다.

조회의 경우에만 매핑하는 코드가 필요할 뿐이다.

This post is licensed under CC BY 4.0 by the author.