스프링으로 예외 / 반복 처리

이동건 (불꽃냥펀치)·2025년 2월 20일
0

체크예외와 인터페이스

서비스 계층은 가급적 특정 구현 기술에 의존하지 않고 순수하게 유지하는 것이 좋다. 예를 들어서 서비스가 처리할 수 없는 SQlException에 대한 의존을 제거하려면 어떻게 해야할까?

서비스가 처리할 수 없으므로 리포지토리가 던지는 체크예외인 SQLException을 런타임예외로 전환해서 서비스 계층에 던지자. 이렇게 하면 서비스 계층이 예외를 무시할수 있기 때문에 특정 기술에 의존하는 부분을 제거할 수 있다.

런타임 예외 적용 - 인터페이스

 public interface MemberRepository {
      Member save(Member member);
      Member findById(String memberId);
      void update(String memberId, int money);
      void delete(String memberId);
}

MyDbException 런타임 예외

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);
    }
 }   

MemberRepositoryV4_1

@Slf4j
  public class MemberRepositoryV4_1 implements MemberRepository {
      private final DataSource dataSource;
      public MemberRepositoryV4_1(DataSource dataSource) {
          this.dataSource = 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 new MyDbException(e);
        } finally {
            close(con, pstmt, null);
        }
	}
    
    @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);
        	pstmt.executeUpdate();
    	} catch (SQLException e) {
        	throw new MyDbException(e);
    } 	finally {
        	close(con, pstmt, null);
		} 
	}
    .
    .
    .
   private void close(Connection con, Statement stmt, ResultSet rs) {
          JdbcUtils.closeResultSet(rs);
          JdbcUtils.closeStatement(stmt);
          DataSourceUtils.releaseConnection(con, dataSource);
	}
      private Connection getConnection() {
          Connection con = DataSourceUtils.getConnection(dataSource);
          log.info("get connection={} class={}", con, con.getClass());
          return con;
	} 
}
  • 이 코드에서 핵심은 SQLException이라는 체크예외를 MyDbException이라는 런타임 예외로 변환해서 던지는 부분이다

예외변환

catch (SQLException e) {
      throw new MyDbException(e);
}
  • 잘보면 기존 예외를 생성자를 통해서 포함하고 있는 것을 확인할 수 있다. 예외는 원인이 되는 예외를 내부에 포함할 수 있는데 이렇게 해야 원인이되는 기존 예외도 함께 확인 할 수있다

MemberService4

 @Slf4j
  @RequiredArgsConstructor
  public class MemberServiceV4 {
      private final 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 void validation(Member toMember) {
        if (toMember.getMemberId().equals("ex")) {
			throw new IllegalStateException("이체중 예외 발생"); 
            }
	} 
}
  • 이전 버전과 달리 메서드에서 throws SQLException 부분이 제거된 것을 확인할 수 있다



데이터 접근 예외 만들기

데이터베이스 오류에 따라서 특정예외는 복구하고 싶을 수도 있다. 예를 들어 새롭게 회원가입을 시도했는데 기존에 존재하던 ID라서 임의의 숫자를 붙여서 다시 저장될 수 있게 만드는 것이다.

데이터를 DB에 저장할 때 같은 ID가 이미 데이터베이스에 저장되어 있다면 데이터베이스는 오류코드를 반환하고 이 오류 코드를 받은 JDBC 드라이버는 SQLException을 던진다. 그리고 SQLException에는 데이터베이스가 제공하는 errorCode라는 것이 들어있다

SQLException 내부에 들어있는 errorCode 를 활용하면 데이터베이스에서 어떤 문제가 발생했는지 확인할 수 있 다

H2 예시

  • 23505: 키 중복 오류
  • 42000: SQL 문법 오류

서비스 계층에서는 예외 복구를 위해 키 중복 오류를 확인 할 수 있어야한다. 그래야 새로운 ID를 만들어서 다시 저장을 시도할 수 있기 때문이다. 이러한 과정이 예외를 확인해서 복구하는 과정으로 리포지토리는 SQLException을 서비스 계층에 던지고 서비스 계층은 이 예외의 오류코드를 확인해서 키 중복 오류인 경우 새로운 ID를 만들어서 다시 저장하면 된다. 하지만 SQLException에 들어있는 오류코드를 활용하기 위해 SQLException을 서비스 계층으로 던지면 서비스 계층이 JDBC 기술의 의존하게 되면서 서비스 계층의 순수성이 무너진다. 이 문제를 해결하려면 앞서 해던것처럼 예외를 변환해서 던지면 된다.

변환을 위한 예외 생성 - MyDuplicatekeyException

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을 상속받아서 의미있는 계층을 형성한다. 이렇게 하면 데이터베이스 관련 예외라는 계층을 만들 수 있다
  • 이 예외는 데이터 중복 발생시에만 던져야 한다
  • 이 예외는 사용자 정의 예외로 특정기술에 종속되지 않는다

Test 예시

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);
	}
}



스프링 예외 추상화 이해

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

  • 스프링은 데이터 접근 계층에 대한 수십개의 예외를 정리해서 일관된 예외 계층을 제공
  • 각각의 예외는 특정 기술에 종속적이지 않게 설계됨
  • JDBC나 JPA를 사용할 때는 예외를 스프링이 제공하는 예외로 변환해주는 역할도 제공
  • 런타임 예외를 상속받아서 스프링이 제공하는 데이터 접근 계층의 모든 예외는 런타임 예외이다

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

@Slf4j
public class SpringExceptionTranslatorTest {
    DataSource dataSource;
    @BeforeEach
    void init() {
        dataSource = new DriverManagerDataSource(URL, USERNAME, PASSWORD);
    }
    @Test
    void sqlExceptionErrorCode() {
        String sql = "select bad grammar";
        try {
            Connection con = dataSource.getConnection();
            PreparedStatement stmt = con.prepareStatement(sql);
            stmt.executeQuery();
        } catch (SQLException e) {
            assertThat(e.getErrorCode()).isEqualTo(42122);
            int errorCode = e.getErrorCode();
            log.info("errorCode={}", errorCode);
            //org.h2.jdbc.JdbcSQLSyntaxErrorException
            log.info("error", e);
		} 
	}
}
  • 기존에 사용했던 데이터베이스 오류 코드 확인

스프링이 제공하는 변환기

  SQLExceptionTranslator exTranslator = new
  SQLErrorCodeSQLExceptionTranslator(dataSource);
  DataAccessException resultEx = exTranslator.translate("select", sql, e);
  • translate() 메서드의 첫번째 파라미터는 읽을 수 있는 설명이고 두번째는 실행한 sql, 마지막은 발생된 SQLException을 전달하면된다. 이렇게 하면 스프링 데이터 접근 계층의 예외로 변환해서 반환해 준다
  @Test
  void exceptionTranslator() {
      String sql = "select bad grammar";
      try {
          Connection con = dataSource.getConnection();
          PreparedStatement stmt = con.prepareStatement(sql);
          stmt.executeQuery();
      } catch (SQLException e) {
          assertThat(e.getErrorCode()).isEqualTo(42122);
          //org.springframework.jdbc.support.sql-error-codes.xml
          SQLExceptionTranslator exTranslator = new
  		 SQLErrorCodeSQLExceptionTranslator(dataSource);
          //org.springframework.jdbc.BadSqlGrammarException
          DataAccessException resultEx = exTranslator.translate("select", sql, e);
          log.info("resultEx", resultEx);
  		  assertThat(resultEx.getClass()).isEqualTo(BadSqlGrammarException.class);
      }
}
  • 이 예제에서 눈에 보이는 타입은 DataAccessException이지만 실제로는 BasSqlGrammarException이 반환됨

스프링 예외 추상화 적용


  @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);
		}
	}
    .
    .
    .
}






출처: https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-db-1

profile
자바를 사랑합니다

0개의 댓글

관련 채용 정보