Coding/개발 서적

[토비의 스프링 3.1 Vol 1] 04장. 예외

kangplay 2025. 2. 27. 18:32

4장에서는 개발자들이 간과하기 쉬운 예외에 대해 다룬다. 모든 예외는 적절하게 복구되든지 아니면 작업을 중단시키고 운영자 또는 개발자에게 분명하게 통보돼야 한다.

예외의 종류와 특징 

 자바에서 throw를 통해 발생시킬 수 있는 예외는 크게 세 가지가 있다.

  • Error: 에러는 시스템에 뭔가 비정상적인 상황이 발생했을 경우에 사용된다. 애플리케이션에서는 이런 에러에 대한 처리는 신경 쓰지 않고, Exception부터 잡으면 된다.
  • Exception와 체크 예외: Exception과 그 하위 예외는 모두 컴파일러가 체크하는 체크예외이다. 단, RuntimeException은 예외로 한다. 체크 예외가 발생할 수 있는 메소드를 사용할 경우 반드시 예외를 처리하는 코드를 함께 작성해야 한다. 그렇지 않으면 컴파일 에러가 발생한다.
    • 예) IOException, SQLException 
  • RuntimeException과 언체크/런타임 예외: RuntimeException을 상속한 예외들은 명시적인 예외처리를 강제하지 않기 때문에 언체크 예외라고 불린다. 또는 런타임 예외라고도 한다. 피할 수 있지만 개발자가 부주의해서 발생할 수 있는 경우에 발생하도록 만든 것이 런타임 예외로, 굳이 catch나 throws를 사용하지 않아도 되도록 만든 것이다.

예외 처리 방법

예외는 잡아서 처리하거나, 처리할 수 없으면 밖으로 던져야 한다.

  • 예외 복구
    • catch 키워드를 이용해 예외 상황을 파악하고 문제를 해결해서 정상 상태로 돌려놓는 것이다.
    • 네트워크 접속이 원활하지 않아서 예외가 발생했다면 일정 시간 대기했다가 다시 접속을 시도해보는 방법을 사용해서 예외 상황으로부터 복구를 시도할 수 있다. 
    • 이처럼 체크 예외들은 예외를 어떤 식으로든 복구할 가능성이 있는 경우에 사용한다. 

 

  • 예외 처리 회피
    • throws 키워드를 이용해 예외를 자신이 처리하지 않고 자신을 호출한 쪽으로 던져버리는 것이다.
    • JdbcTemplate이 사용하는 콜백 오브젝트는 발생하는 SQLException을 자신이 처리하지 않고 템플릿 레벨에서 처리하도록 던져버린다. SQLException을 처리하는 일은 콜백 오브젝트의 역할이 아니라고 보기 때문이다.  
    • 하지만 템플릿 콜백 패턴처럼 긴밀하게 역할을 분담하고 있는 관계가 아니라면 자신의 코드에서 발생하는 예외를 그냥 던져버리는 건 무책임한 책임 회피일 수 있다. 즉, 예외를 회피하는 것은 의도가 분명해야 한다!

* 여러 요청을 받고 수행하는 웹 애플리케이션은 하나의 예외로 종료되면 안되므로 예외를 처리하지 못하고 계속 던지면, WAS가 해당 예외를 받아서 오류 페이지를 전달하는 등의 처리를 한다.  

* throws Exception 는 모든 체크 예외를 다 던지는 문제가 발생하므로 다른 체크 예외를 체크할 수 있는 기능이 무효화된다. 따라서 Exception 자체를 밖으로 던지는 것은 좋지 않은 방법이다.

  • 예외 전환
    • 예외 회피와 비슷하지만, 발생한 예외를 그대로 넘기는 게 아니라 적절한 예외로 전환해서 던진다는 특징이 있다.
    • 예외 전환은 다음과 같은 두 가지 목적으로 사용된다.
      • 내부에서 발생한 예외를 그대로 던지는 것이 그 예외 상황에 대한 적절한 의미를 부여해주지 못하는 경우에, 의미를 분명하게 해줄 수 있는 예외로 바꿔주기 위해서
      • 예외를 처리하기 쉽고 단순하게 만들기 위해 포장하는 것으로, 주로 예외 처리를 강제하는 체크 예외를 언체크 예외인 런타임 예외로 바꾸는 경우에 사용한다.
    • 예외를 전환할 때는 꼭! 기존 예외를 포함해야 한다. 예외를 포함하지 않으면 기존 예외에 대한 스택 트레이스를 확인할 수 없다.
public void call() {
	try {
		runSQL();
	} catch (SQLException e) {
		throw new RuntimeSQLException(e); //기존 예외(e) 포함
		}
	}

예외 처리 전략

지금까지 살펴 본 예외의 종류와 처리 방법 등을 기준으로 일관된 예외 처리 전략을 정리해보자.

  • 일반적으로 언체크(런타임) 예외를 사용하자.
    • 체크 예외를 사용하면 아래에서 올라온 복구 불가능한 예외를 서비스, 컨트롤러 같은 각각의 클래스가 모두 알고 있어야하므로 불필요한 의존 관계 문제가 발생하게 된다. 
    • 해결이 불가능한 공통 예외는 별도의 오류 로그를 남기고, 개발자가 오류를 빨리 인지할 수 있도록 메일 등을 통해서 전달 받아야 한다.

 

  • 체크예외는 비즈니스 로직상 의도적으로 던지는 예외에만 사용하자.
    • 해당 예외를 반드시 처리해야 하는 문제일 때만 체크 예외를 사용해야 한다.
    • 예) 계좌 이체 실패 예외, 결제시 포인트 부족 예외, 로그인 ID, PW 불일치 예외 
      • 정상적인 흐름을 따르는 코드는 그대로 두고, 잔고 부족과 같은 예외 상황에서는 비즈니스적인 의미를 뜬 예외를 던지도록 만들어 잔고 부족 안내를 출력하는 등의 예외 처리를 강제하도록 만드는 것이 안전하다.
  • 런타임 예외는 놓칠 수 있기 때문에 문서화가 중요하다.

 

 

//문서화 예시 

/**
* Make an instance managed and persistent.
* @param entity entity instance
* @throws EntityExistsException if the entity already exists.
* @throws IllegalArgumentException if the instance is not an
* entity
* @throws TransactionRequiredException if there is no transaction when
* invoked on a container-managed entity manager of that is of type
* <code>PersistenceContextType.TRANSACTION</code>
*/
public void persist(Object entity);

대부분의 예외는 복구가 불가능하다. SQLException 또한, SQL 문법이 틀렸거나, 제약 조건을 위반했거나, DB 서버가 다운됐거나 등 애플리케이션 레벨에서 복구가 불가능한 예외가 대부분이다. 이런 경우 예외 처리 전략을 적용하여 가능한 빨리 언체크/런타임 예외로 전환해줘야 한다.

 

*JdbcTemplate 또한 이 예외 처리 전략을 따르고 있다. 모든 SQLException을 런타임 예외인 DataAccessException으로 포장해서 던져준다.

JDBC 의 한계 - 예외 변환으로 해결

서비스 계층은 가급적 특정 구현 기술에 의존하지 않고, 순수하게 유지하는 것이 좋다. 이렇게 하려면 예외에 대한 의존도 함께 해결해야 한다. 다음과 같이 인터페이스를 도입해서 DB 접근 기술을 쉽게 변경할 수 있다. 

DAO의 사용 기술과 구현 코드는 전략 패턴과 DI를 통해서 DAO를 사용하는 클라이언트에게 감출 수 있지만, SQLException이 체크 예외이기 때문에, 메소드 선언에 나타나는 예외 정보가 문제가 될 수 있다. 인터페이스 구현체가 체크 예외를 던지려면, 인터페이스 메서드에 먼저 체크 예외를 던지는 부분이 선언되어있어야 하기 때문이다. 즉, JDBC 기술에 종속적이게 된다.

NoSQL은 SQLException을 사용하지 않고, JPA,Hirbernate,JDO는 각각 다른 런타임 예외로 감싸기 때문에 SQLException을 직접 던지는 것은 JDBC API 뿐이다.

 

체크 예외를 다음과 같이 런타임 예외로 변환하면, 해당 문제를 해결할 수 있다.

catch (SQLException e) {
	throw new MyDbException(e);
}

 

JDBC의 한계 - 예외 추상화로 해결

대부분의 데이터 액세스 예외는 애플리케이션에서는 복구 불가능하거나 할 필요가 없다. 하지만, 모든 예외를 무시해야 하는 건 아니다. 특정 상황에는 예외를 잡아서 복구하고 싶으면 예외를 어떻게 구분해서 처리할 수 있을까?

 

예를 들어, 중복된 ID이면 ID 뒤에 숫자를 붙여서 새로운 ID를 만들어야 한다고 가정해보자. 이는 데이터베이스가 반환하는 오류 코드를 보고 다시 저장을 시도하는 로직이 진행되어야할 것이다.

 

같은 오류여도 각각의 데이터베이스마다 정의된 오류 코드가 다르기 때문에, 특정 기술에 종속된 예외가 아닌 추상화된 런타임 예외로 변환할 필요가 있다. 스프링은 이런 문제를 해결하기 위해 데이터 접근과 관련된 예외를 추상화해서 제공한다.

TransientDataAccessException은 다시 시도하면 될 가능성이 있는 일시적인 예외를, NonTransientDataAccessException은 복구 불가능한 예외이다.

스프링은 위와 같이 일관된 예외 계층을 제공하며, 각각의 예외는 특정 기술에 종속적이지 않게 설계되어 있다. 

 

에러코드를 직접 하나하나 확인하고 스프링이 만들어준 예외로 변환하는 것은 불가능하다. 따라서 스프링은 예외 변환기를 제공한다.

SQLExceptionTranslator exTranslator = new
SQLErrorCodeSQLExceptionTranslator(dataSource);
DataAccessException resultEx = exTranslator.translate("select", sql, e);
📝 JDBC 한계 정리
리포지토리에서 JDBC를 사용하는 반복 코드가 JdbcTemplate으로 대부분 제거되고, 스프링이 제공하는 예외 추상화와 예외 변환기 덕분에 서비스 계층의 순수성을 유지하며, 데이터베이스 접근 기술에 종속적이지 않게 되었다.