예외 블랙홀예외의 종류와 특징예외 처리 방법예외 복구예외 처리 회피예외 전환예외처리 전략런타임 예외의 보편화애플리케이션 예외SQLException은 왜 사라졌을까? (JdbcTemplate에서)예외 전환JDBC의 한계DB 에러코드 매핑을 통한 전환DAO 인터페이스와 DataAccessException 계층구조DAO 인터페이스와 구현의 분리DataAccessException 활용 시 주의사항
예외 블랙홀
- 예외를 처리할 때 반드시 지켜야 할 핵심 원칙은 한가지다. 모든 예외는 적절하게 복구 되든지 아니면 작업을 중단시키고 운영자 또는 개발자에게 분명하게 통보돼야 함
- 콘솔이나 로그에 예외 메시지를 출력하는 건 아무런 도움이 되지 않는다.
- 또한 메서드에서 Exception 을 무조건 throw 하도록 선언하는 것도 문제가 있음
⇒ 적절한 처리를 통해 복구될 수 있는 예외상황도 제대로 다룰 수 있는 기회를 박탈당함
예외의 종류와 특징
Error
- 에러는 시스템에 뭔가 비정상적인 상황이 발생했을 때 사용된다.
- 주로 Java VM 에서 발생시키는 것이고 애플리케이션 코드에서 잡으려고 하면 안됨
- OutOfMemoryError나 ThreadDeath같은 에러는 catch 블록으로 잡아봤자 아무런 대응 방법이 없기 때문에
- 시스템 레벨에서 특별한 작업을 하는 게 아니라면 애플리케이션에서는 이런 에러에 대한 처리는 신경 쓰지 않아도 된다.
Exception과 체크 예외 ⇒ 일반적인 예외
- Exception 클래스는 다시 Checked Exception 과 Unchecked Exception(Runtime Exception 클래스를 상속한)으로 구분된다
- 체크 예외가 발생할 수 있는 메서드를 사용할 경우 반드시 예외 처리 코드를 함께 작성해야 함
- 복구가 가능한 예외이므로 꼭 catch 구절을 작성해야 하는 것임
- 애플리케이션 로직상에서 예외조건이 발견되거나 예외상황이 발생할 수도 있음. 이런 것은 API 가 던지는 예외가 아니라 애플리케이션 코드에서 의도적으로 던지는 예외임. 이때는
Checked Exception
을 사용하는 것이 적절. 비즈니스적인 의미가 있는 예외는 이에 대한 적절한 대응이나 복구 작업이 필요하기 때문
RuntimeException과 언체크/런타임 예외 ⇒ 시스템 장애나 프로그램상의 오류
- 런타임 예외는 catch 문으로 잡거나 throws로 선언하지 않아도 됨
- 물론 명시적으로 잡거나 throws로 선언해줘도 상관없음
- 런타임 예외는 주로 프로그램의 오류가 있을 때 발생하도록 의도된 것들임
- 피할 수 있지만 개발자가 부주의해서 발생할 수 있는 경우에 발생하도록 만든 것이 런타임 예외임
- 런타임 예외는 예상하지 못했던 예외상황에서 발생하는 게 아니기 때문에 굳이 catch나 throws를 사용하지 않아도 되도록 만든 것.
- 런타임 예외는 잡아도 복구할 수 있는 방법이 없다. 어차피 복구가 불가능한 예외는 런타임 예외로 던진 후, 다른 계층의 메소드를 작성할 때 불필요한 throws 선언이 들어가지 않도록 해주어야 함
- 대부분 서버환경에서는 애플리케이션 코드에서 처리하지 않고 전달된 예외들을 일괄적으로 다룰 수 있는 기능을 제공함. 어차피 복구하지 못할 예외라면 애플리케이션 코드에서는 런타임 예외로 포장해서 던져버리고, 예외처리 서비스 등을 이용해 자세한 로그를 남기고, 관리자에게는 메일 등으로 통보해주고, 사용자에게는 친절한 안내 메시지를 보여주는 식으로 처리하는 게 바람직함
예외 처리 방법
예외 복구
- 예외상황을 파악하고 문제를 해결해서 정상 상태로 돌려놓는 것
- 예외로 인해 기본 작업 흐름이 불가능하면 다른 작업 흐름으로 자연스럽게 유도해주는 것. 이런 경우 예외상황은 정상으로 돌아오고 예외를 복구했다고 볼 수 있음
- 네트워크가 불안해서 가끔 서버에 접속이 잘 안되는 열악한 환경에 있는 시스템이라면 원격 DB 서버에 접속하다 실패해서 SQLException이 발생하는 경우에 재시도를 해볼 수 있음
- 네트워크 접속이 원활하지 않아서 에외가 발생했다면 일정 시간 대기했다가 다시 접속을 시도해보는 방법을 사용해서 예외상황으로부터 복구를 시도할 수 있음
- 예외 처리를 강제하는 체크 예외들은 이렇게 예외를 어떤 식으로든 복구할 가능성이 있는 경우에 사용함
예외 처리 회피
- 예외처리를 자신이 담당하지 않고 자신을 호출한 쪽으로 던져버리는 것임
- 예외를 회피하는 것은 예외를 복구하는 것처럼 의도가 분명해야 한다.
- 콜백/템플릿 처럼 긴밀한 관계에 있는 다른 오브젝트에게 예외처리 책임을 분명히 지게 하거나,
- 자신을 사용하는 쪽에서 예외를 다루는 게 최선의 방법이라는 확신이 있어야 함
예외 전환
- 예외 회피와 비슷하게 예외를 복구해서 정상적인 상태로는 만들 수 없기 때문에 예외를 메서드 밖으로 던지는 것임. 그러나 예외 회피와는 다르게 발생한 예외를 그대로 넘기는 것이 아니라 적절한 예외로 전환해서 던진다는 특징이 있음
- 예외 전환의 두 가지 목적
- 내부에서 발생한 예외를 그대로 던지는 것이 그 예외상황에 대한 적절한 의미를 부여해주지 못하는 경우에, 의미를 분명하게 해줄 수 있는 예외로 바꿔주기 위해서임
- 로그인 아이디 중복 같은 경우는 충분히 예상 가능하고 복구 가능한 예외 상황임. 이럴 때는 DAO에서 SQLException의 정보를 해석해서 DuplicateUserIdException 같은 예외로 바꿔서 던져주는 게 좋음
- 의미가 분명한 예외가 던져지면 서비스 계층 오브젝트에는 적절한 복구 작업을 시도할 수가 있음
- 서비스 계층 오브젝트에서 SQLException의 원인을 해석해서 대응하는 것도 불가능하지는 않지만, 특정 기술의 정보를 해석하는 코드를 비즈니스 로직을 담은 서비스 계층에 두는 건 매우 어색함 ⇒ DAO 메소드에서 기술에 독립적이며 의미가 분명한 예외로 전환해서 던져줄 필요가 있음.
- 런타임 예외로 포장하여 불필요한 catch/throws를 줄여주는 것
- 보통 전환하는 예외에 원래 발생한 예외를 담아서 중첩 예외로 만드는 것이 좋음
catch(SQLException e) { ... throw DuplicateUserIdException(e); catch(SQLException e) { ... throw DuplicateUserIdException().initCause(e);
예외처리 전략
런타임 예외의 보편화
- 자바가 처음 만들어질 때 많이 사용되던 애플릿이나 AWT, 스윙을 사용한 독립형 애플리케이션에서는 통제 불가능한 시스템 예외라고 할지라도 애플리케이션의 작업이 중단되지 않게 해주고 상황을 복구해야 했음
- 예를 들어 워드가 열 파일을 못찾았다고 해서 애플리케이션이 종료되면 안됨
- 그러나, 자바 엔터프라이즈 서버환경은 다름. 수많은 사용자가 동시에 요청을 보내고 각 요청이 독립적인 작업으로 취급됨. 하나의 요청을 처리하는 중에 예외가 발생하면 해당 작업만 중단시키면 그만인 것.
- 독립형 애플리케이션과 달리 서버의 특정 계층에서 예외가 발생했을 때 작업을 일시 중지하고 사용자와 바로 커뮤니케이션하면서 예외 상황을 복구할 수 있는 방법이 없다
- 자바의 환경이 서버로 이동하면서, 체크 예외의 활용도와 가치는 점점 떨어지고 있다. 대응이 불가능한 체크 예외라면 빨리 런타임 예외로 전환해서 던지는 게 낫다.
- 자바 초기부터 있었던 JDK의 API와 달리 최근에 등장하는 표준 스펙 또는 오픈소스 프레임워크에서는 API가 발생시키는 예외를 CheckedException 대신 Unchecked Exception으로 정의하는 것이 일반화되고 있음
- 런타임 예외를 일반화해서 사용하는 방법은 여러모로 장점이 많음. 단 런타임 예외로 만들었기 때문에 사용에 더 주의를 기울일 필요가 있음.
- 런타임 예외를 사용하는 경우에는 API 문서나 레퍼런스 문서 등을 통해, 메소드를 사용할 때 발생할 수 있는 예외의 종류와 원인, 활용 방법을 자세히 설명해두자
애플리케이션 예외
- 런타임 예외 중심의 전략은 굳이 이름을 붙이자면 낙관적인 예외처리 기법이라고 할 수 있음
- 복구할 수 있는 예외는 없다고 가정하고 예외가 생겨도 어차피 런타임 예외이므로 시스템 레벨에서 알아서 처리해줄 것이고, 꼭 필요한 경우는 런타임 예외라도 잡아서 복구하거나 대응해줄 수 있으니 문제 될 것이 없다는 낙관적인 태도를 기반으로 함
- 직접 처리할 수 없는 예외가 대부분이라 하더라도 일단 잡고보도록 강제하는 체크 예외의 비관적인 접근 방법과 대비됨
- 반면에 시스템 또는 외부의 예외상황이 원인이 아니라 애플리케이션 자체의 로직에 의해 의도적으로 발생시키고, 반드시 catch 해서 무엇인가 조치를 취하도록 요구하는 예외 ⇒ 애플리케이션 예외
- 예를 들어 사용자가 요청한 금액을 은행계좌에서 출금하는 기능을 가진 메서드가 있다고 할 때, 당연히 현재 잔고를 확인하고, 허용하는 범위를 넘어서 출금을 요청하면 출금 작업 중단시키고, 적절한 경고를 사용자에게 보내야 함
- 설계방법 1: 정상 출금처리와 잔고 부족 발생했을 경우 반환되는 값 달리 함. 그러나 이는 혼란이 생길 수 있음
- 2 : 정상적인 흐름을 따르는 코드는 그대로 두고, 잔고 부족과 같은 예외 상황에서는 비지니스적인 의미를 딴 예외를 던지도록 만드는 것
- 정상적인 흐름을 따르지만 예외가 발생할 수 있는 코드를 try 블록 안에 깔끔하게 정리해두고 예외상황에 대한 처리는 catch 블록에 모아 둘 수 있기 때문에 코드 이해하기도 편함. if 문 남발할 필요가 없음
- 예외를 만들 때는 예외상황에 대한 상세한 정보를 담고 있도록 설계. 잔고가 부족한 경우라면 현재 인출 가능한 최대 금액은 얼마인지 확인해서 예외 정보에 넣어둔다면 좋을 것임
try { BigDecimal balance = account.withdraw(amount); ... // 정상 처리 결과 출력하도록 진행 } catch (InsufficientBalanceException e) { // 체크 예외 BigDecimal availFunds = e.getAvailFunds(); ... // 잔고 부족 안내 메시지를 준비하고 이를 출력하도록 진행
SQLException은 왜 사라졌을까? (JdbcTemplate에서)
- SQLException 은 복구가 가능한 예외가 아니다. 99%의 SQLException은 코드 레벨에서는 복구할 방법이 없음. 프로그램의 오류, 개발자의 부주의 때문에 발생하는 경우이거나 통제할 수 없는 외부상황 때문에 발생하는 것임
- 시스템의 예외라면 당연히 애플리케이션 레벨에서 복구할 방법이 없음. 관리자나 개발자에게 빨리 예외가 발생했다는 사실을 알려주어야 함
- 스프링의 JdbcTemplate은 어차피 복구가 불가능한 SQLException을 체크 예외로 그대로 던지는 것이 아닌, 언체크 예외로 전환해서 throws를 하게 함. 내부적으로 런타임 예외인 DataAccessException으로 포장해서 던짐 → SQLException의 catch가 불필요해 진 것
예외 전환
JDBC의 한계
비표준 SQL(특정 DB의 특별한 기능 or 최적화된 SQL 만들 때 사용)
- 비표준 SQL을 사용함으로써 DAO 코드가 특정 DB에 종속적인 코드가 되어버림
이 문제의 해결책
- 표준 SQL만 사용하는 방법 → 비현실적
- DB별로 별도의 DAO를 만들거나
- SQL을 외부에 독립시켜서 DB에 따라 변경해 사용하는 방법이 있음
호환성 없는 SQL Exception의 DB 에러정보
- DB마다 SQL만 다른 것이 아니라 에러의 종류와 원인도 제각각임. 그래서 JDBC는 데이터 처리 중에 발생하는 다양한 예외를 SQLException 하나에 모두 담아버림 ⇒ 호환성 없는 에러코드와 표준을 잘 따르지 않는 상태 코드를 가진 SQL Exception 만으로 DB 에 독립적인 유연한 코드를 작성하는 건 불가능에 가까움
DB 에러코드 매핑을 통한 전환
- 스프링은
DataAccessException
이라는SQLException
을 대체할 수 있는 런타임 예외를 정의하고 있을 뿐 아니라DataAccessException
의 서브클래스로 세분화된 예외 클래스들을 정의하고 있음 - SQL 문법 에러 → BadSqlGrammarException
- DB 커넥션 가져오지 못했을 때 → DataAccessResourceFailureException
- 데이터의 제약조건을 위배했거나 일관성 지키지 않는 작업 수행 시 → DataIntegrityViolationException
DB마다 에러코드가 제각각 이기에 스프링은 DB별 에러 코드를 분류해서 스프링이 정의한 예외 클래스와 매핑해놓은 에러 코드 매핑정보 테이블을 만들어두고 이를 이용함
<bean id="MySQL" class="org.springframework.jdbc.support.SQLErrorCodes"> <property name="databaseProductNames"> <list> <value>MySQL</value> <value>MariaDB</value> </list> </property> <property name="badSqlGrammarCodes"> <value>1054,1064,1146</value> </property> <property name="duplicateKeyCodes"> <value>1062</value> </property> ...
- JdbcTemplate은 SQLException을 단지 런타임 예외인 DataAccessException으로 포장하는 것 뿐 아니라 DB의 에러코드를 DataAccessException 계층구조의 클래스 중 하나로 매핑해줌
DAO 인터페이스와 DataAccessException 계층구조
DataAccessException은 의미가 같은 예외라면 데이터 액세스 기술의 종류와 상관없이 일관된 예외가 발생하도록 만들어줌
데이터 액세스 기술에 독립적인 추상화된 예외를 제공하는 것
DAO 인터페이스와 구현의 분리
- DAO를 굳이 따로 만들어서 사용하는 이유 ? → 가장 중요한 이유는 데이터 액세스 로직을 담은 코드를 성격이 다른 코드에서 분리해놓기 위해서임
- 또한 분리된 DAO는 전략 패턴을 적용해 구현 방법을 변경해서 사용할 수 있게 만들기 위해서이기도 함
- DAO를 사용하는 쪽에서는 DAO가 내부에서 어떤 데이터 액세스 기술을 사용하는 지 신경쓰지 않아도 됨
- 그런 면에서 DAO는 인터페이스를 사용해 구체적인 클래스 정보와 구현 방법을 감추고, DI를 통해 제공되도록 만드는 것이 바람직함
- 인터페이스로 메소드의 구현을 추상화 했을 때 발생하는 문제 ⇒ JDBC, JPA, Hibernate 별로 던지는 예외가 다름 ⇒ DataAccessException 예외 사용하기!
public void add(User user) throws PersistentException; // JPA public void add(User user) throws HibernateException; // Hibernate public void add(User user) throws JdoException; // JDO
인터페이스 사용
, 런타임 예외 전환
과 함께 DataAccessException 예외 추상화
를 적용하면 데이터 액세스 기술과 구현 방법에 독립적인 이상적인 DAO를 만들 수 있음DataAccessException 활용 시 주의사항
DuplicateKeyException
은 아직까지는 JDBC를 이용하는 경우에만 발생함- 그 이유는 JDBC는 SQLException에 담긴 DB의 에러 코드를 바로 해석하는 반면
- JPA나 하이버네이트, JDO 등에서는 각 기술이 재정의한 예외를 가져와 스프링이 최종적으로
DataAccessException
으로 변환하기에 DB의 에러코드와 달리 예외가 세분화되어 있지 않기 때문 - 예를 들어 하이버네이트는 중복 키가 발생하는 경우 하이버네이트의
ConstraintViolationException
을 발생시킴