4장 예외

Jiwon An·2021년 12월 3일
0

백엔드/스프링

목록 보기
4/6

JdbcTemplate을 대표로 하는 스프링의 데이터 액세스 기능에 담겨 있는 예외처리와 관련된 접근 방법에 대해 알아보자.

1. 사라진 SQLException

JdbcContext에서 JdbcTemplate으로 바꾸기 전과 후의 deleteAll() 메서드를 비교해보자.

  • JdbcTemplate 적용 이전에는 있었던 throws SQLException 선언이 적용 후에는 사라졌다.

1.1 초난감 예외처리

먼저 개발자들의 코드에서 종종 발견되는 초난감 예외처리의 사례를 보자.

1.1.1 예외 블랙홀

예외가 발생하면 그것을 catch 블록을 써서 잡아낸 후에 아무것도 하지 않고 별문제 없는 것처럼 넘어가는 것은 정말 위험한 일이다. 왜냐하면 프로그램 실행 중에 어디선가 오류가 있어서 예외가 발생했는데 그것을 무시하고 계속 진행해버리기 때문이다. 결국 발생한 예외로 인해 어떤 기능이 비정상적으로 동작하거나, 메모리나 리소스가 소진되거나, 예상치 못한 다른 문제를 일으킬 것이다. 더 큰 문제는 그 시스템 오류나 이상한 결과의 원인을 찾아내기가 매우 힘들다는 것이다.

  • 콘솔이나 로그에 예외 메시지를 출력하는 건 예외 처리에 아무런 도움이 되지 않는다.
  • 굳이 예외를 잡아서 뭔가 조치를 취할 방법이 없다면 잡지 말아야 한다. 메서드에 throws SQLException을 선언해서 메서드 밖으로 던지고 자신을 호출한 코드에 예외처리 책임을 전가해버려라.

📌 예외를 처리할 때 반드시 지켜야 할 핵심 원칙은 "모든 예외는 적절하게 복구되든지 아니면 작업을 중단시키고 운영자 또는 개발자에게 분명하게 통보돼야 한다." 이다.

1.1.2 무의미하고 무책임한 throws

  • throws Exception이 선언되어 있는 메서드에서는 의미 있는 정보를 얻을 수 없다.
    정말 무엇인가 실행 중에 예외적인 상황이 발생할 수 있다는 건지, 그냥 습관적으로 복사해서 붙여놓은 것인지 알 수가 없다. 결국 이런 메서드를 사용하는 메서드에서도 역시 throws Exception을 따라서 붙이는 수밖에 없다.
  • 결과적으로 적절한 처리를 통해 복구될 수 있는 예외상황도 제대로 다룰 수 있는 기회를 박탈당한다.

1.2 예외의 종류와 특징

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

Error

  • java.lang.Error 클래스의 서브클래스들이다.
  • 주로 자바 VM에서 발생시키는 것이고 애플리케이션 코드에서 잡으려고 하면 안 된다. catch 블록으로 잡아봤자 아무런 대응 방법이 없기 때문이다.
  • 시스템 레벨에서 특별한 작업을 하는 게 아니라면 애플리케이션에서는 이런 에러에 대한 처리는 신경쓰지 않아도 된다.

Exception과 체크 예외

  • java.lang.Exception 클래스와 그 서브클래스들이다.
  • 개발자들이 만든 애플리케이션 코드의 작업 중에 예외상황이 발생했을 경우에 사용된다.
  • Exception 클래스는 체크 예외와 언체크 예외로 구분된다.
    • 체크 예외 : Exception 클래스의 서브클래스이면서 RuntimeException 클래스를 상속하지 않은 것들
      • 체크 예외가 발생할 수 있는 메서드를 사용할 경우 반드시 예외를 처리하는 코드를 함께 작성해야 한다.
      • 사용할 메서드가 체크 예외를 던진다면 이를 catch 문으로 잡든지, 아니면 다시 throws를 정의해서 메서드 밖으로 던져야 한다. 그렇지 않으면 컴파일 에러가 발생한다.
    • 언체크 예외 : RuntimeException을 상속한 클래스
      • RuntimeExceptionException의 서브클래스지만 자바는 이 RuntimeException과 그 서브클래스는 특별하게 다룬다.

RuntimeException과 언체크/런타임 예외

  • java.lang.RuntimeException 클래스를 상속한 예외
  • 명시적인 예외처리를 강제하지 않기 때문에 언체크 예외 또는 런타임 예외라고 불린다.
  • 에러와 마찬가지로 catch 문으로 잡거나 throws로 선언해도 되고 안해도 된다.
  • 런타임 예외는 주로 프로그램의 오류가 있을 때 발생하도록 의도된 것들이다.
    • NullPointerException : 오브젝트를 할당하지 않은 레퍼런스 변수를 사용하려고 시도했을 때 발생한다.
    • IllegalArgumentException : 허용되지 않는 값을 사용해서 메서드를 호출할 때 발생한다.
  • 런타임 예외는 예상하지 못했던 예외상황에서 발생하는 게 아니기 때문에 굳이 catchthrows를 사용하지 않아도 되도록 만든 것이다.

1.3 예외처리 방법

예외를 처리하는 일반적인 방법을 살펴보자.

1.3.1 예외 복구

  • 예외상황을 파악하고 문제를 해결해서 정상 상태로 돌려놓는 것이다.
  • 예외처리 코드를 강제하는 체크 예외들은 예외를 어떤 식으로든 복구할 가능성이 있는 경우에 사용한다.

예시 - 1

사용자가 요청한 파일을 읽으려고 시도했는데 해당 파일이 없다거나 다른 문제가 있어 읽히지가 않아서 IOException이 발생했다고 생각해보자.

  • 예외 복구 : 사용자에게 상황을 알려주고 다른 파일을 이용하도록 안내해서 예외상황을 해결한다.
    • 예외로 인해 기본 작업 흐름이 불가능하면 다른 작업 흐름으로 자연스럽게 유도해주는 것이다.
  • 잘못된 복구 : IOException 에러 메시지가 사용자에게 그냥 던져지는 것이다.
    • 예외가 처리됐으면 비록 기능적으로는 사용자에게 예외상황으로 비쳐도 애플리케이션에서는 정상적으로 설계된 흐름을 따라 진행돼야 한다.

예시 - 2

통제 불가능한 외부 요인으로 인해 예외가 발생하면 MAX_RETRY만큼 재시도를 하는 간단한 예다. 사전에 미리 성공 여부를 확인할 수 없고, 재시도가 의미 있는 경우라면 이렇게 최대 횟수만큼 반복적으로 시도함으로써 예외상황에서 복구되게 할 수 있다.

1.3.2 예외처리 회피

  • 예외처리를 자신이 담당하지 않고 자신을 호출한 쪽으로 던져버리는 것이다.
  • throws 문으로 선언해서 예외가 발생하면 알아서 던져지게 하거나 catch 문으로 일단 예외를 잡은 후에 로그를 남기고 다시 예외를 던지는 것이다.
  • 예외처리를 회피하려면 반드시 다른 오브젝트나 메서드가 예외를 대신 처리할 수 있도록 던져줘야 한다.
  • 예외를 회피하는 것은 예외를 복구하는 것처럼 의도가 분명해야 한다. 콜백/템플릿처럼 긴밀한 관계에 있는 다른 오브젝트에게 예외처리 책임을 분명히 지게 하거나, 자신을 사용하는 쪽에서 예외를 다루는 게 최선의 방법이라는 분명한 확신이 있어야 한다.
    • 콜백 오브젝트의 메서드는 모두 throws SQLException이 붙어 있다. 콜백 오브젝트의 메서드는 SQLException에 대한 예외를 회피하고 템플릿 레벨에서 처리하도록 던져준다.

1.3.3 예외 전환

  • 회피와 비슷하게 예외를 복구해서 정상적인 상태로는 만들 수 없기 때문에 예외를 메서드 밖으로 던지는 것이다.
  • 하지만 예외 회피와 달리, 발생한 예외를 그대로 넘기는 게 아니라 적절한 예외로 전환해서 던진다.

예외 전환의 두 가지 목적

1. 내부에서 발생한 예외를 그대로 던지는 것이 그 예외상황에 대한 적절한 의미를 부여해주지 못하는 경우에, 의미를 분명하게 해줄 수 있는 예외로 바꿔주기 위해서다.

  • API가 발생하는 기술적인 로우레벨을 상황에 적합한 의미를 가진 예외로 변경하는 것이다.
  • 의미가 분명한 예외가 던져지면 서비스 계층 오브젝트에는 적절한 복구 작업을 시도할 수가 있다.

예) 새로운 사용자를 등록하려고 시도했을 때 아이디가 같은 사용자가 있어서 DB 에러가 발생하면 JDBC API는 SQLException을 발생시킨다. 로그인 아이디 중복 같은 경우는 충분히 예상 가능하고 복구 가능한 예외상황이다. 이럴 땐 DAO에서 SQLException의 정보를 해석해서 DuplicateUserIdException 같은 예외로 바꿔서 던져주는 게 좋다.

보통 전환하는 예외에 원래 발생한 예외를 담아서 중첩 예외로 만드는 것이 좋다. 중첩 예외는 getCause() 메서드를 이용해 처음 발생한 예외가 무엇인지 확인할 수 있다. 중첩 예외는 새로운 예외를 만들면서 생성자나 initCause() 메서드로 근본 원인이 되는 예외를 넣어주면 된다.

2. 예외를 처리하기 쉽고 단순하게 만들기 위해 포장하는 것이다.

중첩 예외를 이용해 새로운 예외를 만들고 원인이 되는 예외를 내부에 담아서 던지는 방식은 같다. 하지만 의미를 명확하게 하려고 다른 예외로 전환하는 것이 아니다. 주로 예외처리를 강제하는 체크 예외를 런타임 예외로 바꾸는 경우에 사용한다.

예) EJBException을 들 수 있다. EJB 컴포넌트 코드에서 발생하는 대부분의 체크 예외는 비즈니스 로직으로 볼 때 의미 있는 예외이거나 복구 가능한 예외가 아니다. 이런 경우에는 런타임 예외인 EJBException으로 포장해서 던지는 편이 낫다.

📌 EJBExceptionRuntimeException 클래스를 상속한 런타임 예외다. 이렇게 런타임 예외로 만들어서 전달하면 EJB는 이를 시스템 익셉션으로 인식하고 트랜잭션을 자동으로 롤백해준다. 런타임 예외이기 때문에 EJB 컴포넌트를 사용하는 다른 EJB나 클라이언트에서 일일이 예외를 잡거나 다시 던지는 수고를 할 필요가 없다. 이런 예외는 잡아도 복구할 만한 방법이 없기 때문이다.

반대로 애플리케이션 로직상에서 예외조건이 발견되거나 예외상황이 발생할 수도 있다. 이런 것은 API가 던지는 예외가 아니라 애플리케이션 코드에서 의도적으로 던지는 예외다. 이때는 체크 예외를 사용하는 것이 적절하다. 비즈니스적인 의미가 있는 예외는 이에 대한 적절한 대응이나 복구 작업이 필요하기 때문이다.

일반적으로 체크 예외를 계속 throws를 사용해 넘기는 건 무의미하다. 복구가 불가능한 예외라면 가능한 한 빨리 런타임 예외로 포장해 던지게 해서 다른 계층의 메서드를 작성할 때 불필요한 throws 선언이 들어가지 않도록 해줘야 한다.

대부분 서버 환경에서는 애플리케이션 코드에서 처리하지 않고 전달된 예외들을 일괄적으로 다룰 수 있는 기능을 제공한다. 어차피 복구하지 못할 예외라면 애플리케이션 코드에서는 런타임 예외로 포장해서 던져버리고, 예외처리 서비스 등을 이용해 자세한 로그를 남기고, 관리자에게는 메일 등으로 통보해주고, 사용자에게는 친절한 안내 메시지를 보여주는 식으로 처리하는 게 바람직하다.

1.4 예외처리 전략

1.4.1 런타임 예외의 보편화

일반적으로는 체크 예외가 일반적인 예외를 다루고, 언체크 예외는 시스템 장애나 프로그램상의 오류에 사용한다고 했다.

자바의 환경이 서버로 이동하면서 체크 예외의 활용도와 가치는 점점 떨어지고 있다. 자칫하면 throws Exception으로 점철된 아무런 의미도 없는 메서드들을 낳을 뿐이다. 그래서 대응이 불가능한 체크 예외라면 빨리 런타임 예외로 전환해서 던지는 게 낫다.

자바 초기부터 있었던 JDK의 API와 달리 최근에 등장하는 표준 스펙 또는 오픈소스 프레임워크에서는 API가 발생시키는 예외를 체크 예외 대신 언체크 예외로 정의하는 것이 일반화되고 있다. 예전에는 복구할 가능성이 조금이라도 있다면 체크 예외로 만든다고 생각했는데, 지금은 항상 복구할 수 있는 예외가 아니라면 일단 언체크 예외로 만드는 경향이 있다. 언체크 예외라도 필요하다면 얼마든지 catch 블록으로 잡아서 복구하거나 처리할 수 있다. 하지만 대개는 복구 불가능한 상황이고 보나마자 RuntimeException 등으로 포장해서 던져야 할 테니 아예 API 차원에서 런타임 예외를 던지도록 만드는 것이다.

1.4.2 add() 메서드의 예외처리

add() 메서드는 DuplicatedUserIdExceptionSQLException, 두 가지의 체크 예외를 던지게 되어 있다.

DuplicatedUserIdExceptionSQLException의 차이

DuplicatedUserIdException은 충분히 복구 가능한 예외이므로 add() 메서드를 사용하는 쪽에서 잡아서 대응할 수 있지만, SQLException은 대부분 복구 불가능한 예외이므로 결국 throws를 타고 계속 앞으로 전달되다가 애플리케이션 밖으로 던져질 것이다. 그럴 바에는 그냥 런타임 예외로 포장해 던져버려서 그 밖의 메서드들이 신경 쓰지 않게 해주는 편이 낫다.

DuplicatedUserIdException의 다른 처리

DuplicatedUserIdException 같은 의미 있는 예외는 add() 메서드를 바로 호출한 오브젝트 대신 더 앞단의 오브젝트에서 다룰 수도 있다. 어디에서든 이 예외를 잡아서 처리할 수 있다면 굳이 체크 예외가 아닌 런타임 예외로 만드는 게 낫다. 대신 add() 메서드는 명시적으로 DuplicatedUserIdException을 던진다고 선언해야 한다. 그래야 add() 메서드를 사용하는 코드를 만드는 개발자에게 의미 있는 정보를 전달해줄 수 있다.

이 방법을 이용해 add()를 수정해보자.

  • 추가 사항
    • 사용자 아이디가 중복됐을 때 사용하는 DuplicatiedUserIdException을 만든다.
      • 필요하면 언제든 잡아서 처리할 수 있도록 별도의 예외로 정의하기는 하지만, 필요 없다면 신경 쓰지 않아도 되도록 RuntimeException을 상속한 런타임 예외로 만든다.
      • 중첩 예외를 만들 수 있도록 생성자를 추가해준다. 메시지나 예외상황을 전달하는 데 필요한 정보를 더 넣을 수도 있다.
    • add() 메서드에서 SQLException을 런타임 예외로 전환해서 던지도록 만든다.
      • SQLException은 런타임 예외가 되어 메서드 선언의 throws에 포함시킬 필요가 없다.

  • 특별한 의미를 가지는 DuplicatedUserIdException 외에 시스템 예외에 해당하는 SQLException은 언체크 예외가 됐기 때문에 불필요한 throws 선언을 할 필요가 없어진다.
  • 언체크 예외로 만들어지긴 했지만 add() 메서드를 사용하는 쪽에서 아이디 중복 예외를 처리하고 싶은 경우 활용할 수 있음을 알려주도록 DuplicatedUserIdException을 메서드의 throws 선언에 포함시킨다.

📌 런타임 예외의 일반화
런타임 예외를 일반화해서 사용하는 방법은 여러모로 장점이 많다. 단, 런타임 예외로 만들었기 때문에 사용에 더 주의를 기울일 필요도 있다. 컴파일러가 예외처리를 강제하지 않으므로 신경 쓰지 않으면 예외상황을 충분히 고려하지 않을 수도 있기 때문이다. 런타임 예외를 사용하는 경우에는 API 문서나 레퍼런스 문서 등을 통해, 메서드를 사용할 때 발생할 수 있는 예외의 종류와 원인, 활용 방법을 자세히 설명해두자.

1.4.3 애플리케이션 예외

시스템 또는 외부의 예외상황이 원인이 아니라 애플리케이션 자체의 로직에 의해 의도적으로 발생시키고, 반드시 catch 해서 무엇인가 조치를 취하도록 요구하는 예외가 있다. 이런 예외들을 일반적으로 애플리케이션 예외라고 한다.

예를 들어, 사용자가 요청한 금액을 은행계좌에서 출금하는 기능을 가진 메서드가 있다고 생각해보자. 여기선 현재 잔고를 확인하고, 허용하는 범위를 넘어서 출금을 요청하면 출금 작업을 중단시키고, 적절한 경고를 사용자에게 보내야 한다.

이런 기능을 담은 메서드를 설계하는 방법이 두 가지 있다.

1. 정상적인 출금처리를 했을 경우와 잔고 부족이 발생했을 경우에 각각 다른 종류의 리턴 값을 돌려준다.

  • 이것은 시스템 오류가 아니므로 기술적으로 보면 두 가지 경우 모두 정상 흐름이다. 하지만 이렇게 리턴 값으로 결과를 확인하고, 예외상황을 체크하면 불편한 점도 있다.
  • 문제 1 : 우선 예외상황에 대한 리턴 값을 명확하게 코드화하고 잘 관리하지 않으면 혼란이 생길 수 있다.
    • 정상적인 처리가 안 됐을 때 전달하는 값의 표준은 없다.
  • 문제 2 : 결과 값을 확인하는 조건문이 자주 등장한다.

2. 정상적인 흐름을 따르는 코드는 그대로 두고, 잔고 부족과 같은 예외상황에서는 비즈니스적인 의미를 띤 예외를 던진다.

  • 정상적인 흐름을 따르지만 예외가 발생할 수 있는 코드를 try 블록 안에 정리해두고 예외상황에 대한 처리는 catch 블록에 모아둘 수 있기 때문에 코드를 이해하기도 편하다.
    • 예) 잔고 부족인 경우 InsufficientBalanceException 등을 던진다.
  • 이때 사용하는 예외는 의도적으로 체크 예외로 만든다. 그래서 개발자가 잊지 않고 잔고 부족처럼 자주 발생 가능한 예외상항에 대한 로직을 구현하도록 강제해주는 게 좋다.
  • 애플리케이션 예외인 InsufficientBalanceException을 만들 때는 예외상황에 대한 상세한 정보를 담고 있도록 설계할 필요가 있다. 잔고가 부족한 경우라면 현재 인출 가능한 최대 금액은 얼마인지 확인해서 예외 정보에 넣어준다면 좋을 것이다.

1.5 SQLException은 어떻게 됐나?

DAO에 존재하는 SQLException에 대해 생각해보자.

SQLEception은 과연 복구가 가능한 예외인가?

99%의 SQLException은 코드 레벨에서는 복구할 방법이 없다. 프로그램의 오류 또는 개발자의 부주의 때문이거나 통제할 수 없는 외부상황 때문에 발생하는 것이다. 예를 들어 SQL 문법이 틀렸거나, 제약조건을 위반했거나, DB 서버가 다운됐거나, 네트워크가 불안정하거나, DB 커넥션 풀이 꽉 차서 DB 커넥션을 가져올 수 없는 경우 등이다.

시스템의 예외라면 당연히 애플리케이션 레벨에서 복구할 방법이 없다. 관리자나 개발자에게 빨리 예외가 발생했다는 사실이 알려지도록 전달하는 방법밖에는 없다. 마찬가지로 애플리케이션 코드의 버그나 미처 다루지 않았던 범위를 벗어난 값 때문에 발생한 예외도 역시 복구할 방법이 없다. 개발자가 빨리 인식할 수 있도록 발생한 예외를 빨리 전달하는 것 외에는 할 수 있는 게 없다.

이렇듯 대부분의 SQLException은 복구가 불가능하기 때문에 예외처리 전략을 적용해야 한다. 필요도 없는 기계적인 throws 선언이 등장하도록 방치하지 말고 가능한 한 빨리 런타임 예외로 전환해줘야 한다.

스프링의 JdbcTemplate은 바로 이 예외처리 전략을 따르고 있다. JdbcTemplate 템플릿과 콜백 안에서 발생하는 모든 SQLException을 런타임 예외인 DataAccessException으로 포장해서 던져준다. 따라서 JdbcTemplate을 사용하는 UserDao 메서드에선 꼭 필요한 경우에만 런타임 예외인 DataAccessException을 잡아서 처리하면 되고 그 외의 경우에는 무시해도 된다. 그래서 DAO 메서드에서 SQLException이 모두 사라진 것이다.

JdbcTemplateupdate(), queryForInt(), query() 메서드는 모두 throws DataAccessException이라고 되어 있다. 하지만 런타임 예외이므로 update()를 사용하는 메서드에서 이를 잡거나 다시 던질 의무는 없다.

public int update(final String sql) throws DataAccessException { ... }

그 밖에도 스프링의 API 메서드에 정의되어 있는 대부분의 예외는 런타임 예외다.

2. 예외 전환

예외 전환의 목적

  • 런타임 예외로 포장해서 굳이 필요하지 않은 catch/throws를 줄여주는 것
  • 로우레벨의 예외를 좀 더 의미 있고 추상화된 예외로 바꿔서 던져주는 것
  • 예) 스프링의 JdbcTemplate이 던지는 DataAccessException
    • SQLException을 런타임 예외로 포장해서 복구 불가능한 예외에 대해 애플리케이션 레벨에서 신경 쓰지 않도록 해준다.
    • SQLException에 담긴 다루기 힘든 상세한 예외정보를 의미 있고 일관성 있는 예외로 추상화해준다.

2.1 JDBC의 한계

JDBC는 자바를 이용해 DB에 접근하는 방법을 추상화된 API 형태로 정의해놓고, 각 DB 업체가 JDBC 표준을 따라 만들어진 드라이버를 제공하게 해준다. 하지만 DB 종류에 상관없이 사용할 수 있는 데이터 액세스 코드를 작성하는 일은 쉽지 않다. 현실적으로 DB를 자유롭게 바꾸어 사용할 수 있는 DB 프로그램을 작성하는 데는 두 가지 문제가 있다.

2.1.1 비표준 SQL

  • 첫번째 문제 : JDBC 코드에서 사용하는 SQL

    대부분의 DB는 표준을 따르지 않는 비표준 문법과 기능도 제공하고, 이런 비표준 특정 DB 전용 문법은 매우 넓게 사용되고 있다. 해당 DB의 특별한 기능을 사용하거나 최적화된 SQL을 만들 때 유용하기 때문이다.

    특별한 기능을 제공하는 함수를 SQL에 사용하려면 대부분 비표준 SQL 문장이 만들어진다. 이렇게 작성된 비표준 SQL은 결국 DAO 코드에 들어가고, 해당 DAO는 특정 DB에 대해 종속적인 코드가 된다. 보통은 DB가 자주 변경되지도 않고, 사용하는 DB에 최적화하는 것이 중요하므로 비표준 SQL을 거리낌없이 사용한다.

  • 해결책

    • 호환 가능한 표준 SQL만 사용하는 방법 -> 간단한 예제 프로그램 외엔 현실성이 없다.
    • DB별로 별도의 DAO를 만드는 방법
    • SQL을 외부에 독립시켜서 DB에 따라 변경해 사용하는 방법

2.1.2 호환성 없는 SQLException의 DB 에러정보

  • 두번째 문제 : SQLException

    JDBC API는 SQLException만 던지지만, DB마다 SQL만 다른 것이 아니라 에러의 종류와 원인도 제각각이다. 예외가 발생한 원인은 SQLException 안에 담긴 에러 코드와 SQL 상태정보를 참조해봐야 한다. 그런데 DB 벤더마다 정의한 고유한 에러 코드를 사용하기 때문에 DB 에러 코드또한 DB별로 모두 다르다.

    그래서 SQLException은 예외가 발생했을 때의 DB 상태를 담은 SQL 상태정보를 부가적으로 제공한다. 예를 들어 테이블이 존재하지 않는 경우 42S02와 같은 식이다. 이 상태정보는 DB에 독립적이다.
    그런데 문제는 DB의 JDBC 드라이버에서 SQLException을 담을 상태 코드를 정확하게 만들어주지 않는다는 점이다.

  • 해결책 : 없음
    호환성 없는 에러 코드와 표준을 잘 따르지 않는 상태 코드를 가진 SQLException만으로 DB에 독립적인 유연한 코드를 작성하는 건 불가능하다.

2.2 DB 에러 코드 매핑을 통한 전환

SQL 상태 코드는 JDBC 드라이버를 만들 때 들어가는 것이므로 같은 DB라고 하더라도 드라이버를 만들 때마다 달라지기도 하지만, DB 에러 코드는 DB에서 직접 제공해주는 것이니 버전이 올라가더라도 어느 정도 일관성이 유지된다.

해결 방법은 DB별 에러 코드를 참고해서 발생한 예외의 원인이 무엇인지 해석해주는 기능을 만드는 것이다. 스프링은 데이터 액세스 작업 중에 발생할 수 있는 예외상황을 수십 가지 예외로 분류하고 이를 추상화해 정의한 다양한 예외 클래스를 제공한다.

문제는 DB마다 에러 코드가 다르다는 것이다. 대신 스프링은 DB별 에러 코드를 분류해서 스프링이 정의한 예외 클래스와 매핑해놓은 에러 코드 매핑정보 테이블을 만들어두고 이를 이용한다.

드라이버나 DB 메타정보를 참고해서 DB 종류를 확인하고 DB별로 미리 준비된 매핑정보를 참고해서 적절한 예외 클래스를 선택하기 때문에 DB가 달라져도 같은 종류의 에러라면 동일한 예외를 받을 수 있는 것이다. 그래서 JdbcTemplate을 이용하면 JDBC에서 발생하는 DB 관련 예외는 거의 신경 쓰지 않아도 된다.

그런데 애플리케이션에서 직접 정의한 예외를 발생시키고 싶을 수 있다. 예를 들어, 애플리케이션 레벨의 체크 예외인 DuplicateUserIdException을 던지게 하고 싶다면 스프링의 DuplicationKeyException 예외를 전환해주는 코드를 DAO 안에 넣으면 된다.

JDBC 4.0부터는 기존에 JDBC의 단일 예외 클래스였던 SQLException을 스프링의 DataAccessException과 비슷한 방식으로 좀 더 세분화해서 정의하고 있다. 하지만 SQLException의 서브클래스이므로 여전히 체크 예외라는 점과 그 예외를 세분화하는 기준이 SQL 상태정보를 이용한다는 점이 여전히 문제다. 그래서 아직은 스프링의 에러 코드 매핑을 통한 DataAccessException 방식을 사용하는 것이 이상적이다.

2.3 DAO 인터페이스와 DataAccessException 계층구조

자바는 JDBC 외에도 데이터 액세스를 위한 표준 기술인 JDO, JPA 등이 존재한다.DataAccessException은 JDBC 외의 자바 데이터 액세스 기술에서 발생하는 예외에도 적용된다.DataAccessException은 의미가 같은 예외라면 데이터 액세스 기술의 종류와 상관없이 일관된 예외가 발생하도록 만들어준다. 데이터 액세스 기술에 독립적인 추상화된 예외를 제공하는 것이다.

2.3.1 DAO 인터페이스와 구현의 분리

📌 DAO를 따로 사용하는 이유

  • 데이터 액세스 로직을 담은 코드를 성격이 다른 코드에서 분리해놓기 위해서
  • 분리된 DAO는 전략 패턴을 적용해 구현 방법을 변경해서 사용할 수 있게 만들기 위해서

DAO를 사용하는 쪽에서는 DAO가 내부에서 어떤 액세스 기술을 사용하는지 신경 쓰지 않아도 된다. 그런 면에서 DAO는 인터페이스를 사용해 구체적인 클래스 정보와 구현 방법을 감추고, DI를 통해 제공되도록 만드는 것이 바람직하다.
그런데 DAO의 사용 기술과 구현 코드는 전략 패턴과 DI를 통해서 DAO를 사용하는 클라이언트에게 감출 수 있지만, 메서드 선언에 나타나는 예외정보가 문제가 될 수 있다.

예를 들어, UserDao의 인터페이스를 분리해서 기술에 독립적인 인터페이스로 만들려면 다음과 같이 정의해야하지만, JDBC API를 사용하는 UserDao 구현 클래스의 add() 메서드라면 SQLException을 던지기 때문에 인터페이스 메서드에도 throws가 있어야 한다.

public interface UserDao {
	public void add(User user);

이렇게 정의한 인터페이스는 JDBC가 아닌 데이터 액세스 기술로 DAO 구현을 전환하면 사용할 수 없다. 데이터 액세스 기술의 API는 자신만의 독자적인 예외를 던지기 때문이다. 다행히도 JDBC 이후에 등장한 JDO, Hibernate, JPA 등의 기술은 런타임 예외를 사용한다. 그래서 JDBC API를 직접 사용하는 DAO의 경우엔 DAO 메서드 내에서 런타임 예외로 포장해기만 하면 처음 선언했던 대로 해도 된다.

대부분의 데이터 액세스 예외는 애플리케이션에서는 복구 불가능하거나 할 필요가 없지만, 비즈니스 로직에서 의미 있게 처리할 수 있는 예외도 있다. 애플리케이션에서는 사용하지 않더라도 시스템 레벨에서 데이터 액세스 예외를 의미 있게 분류할 필요도 있다. 문제는 데이터 액세스 기술이 달라지면 같은 상황에서도 다른 종류의 예외가 던져진다는 점이다. 따라서 DAO를 사용하는 클라이언트 입장에서는 DAO의 사용 기술에 따라서 예외 처리 방법이 달라져야 한다. 결국 클라이언트가 DAO의 기술에 의존적이 될 수밖에 없다.

2.3.2 테이터 액세스 예외 추상화와 DataAccessException 계층구조

그래서 스프링은 자바의 다양한 데이터 액세스 기술을 사용할 때 발생하는 예외들을 추상화해서 DataAccessException 계층구조 안에 정리해놓았다.

DataAccessException은 자바의 주요 데이터 액세스 기술에서 발생할 수 있는 대부분의 예외를 추상화하고 있다. 데이터 액세스 기술에 상관없이 공통적인 예외도 있지만 일부 기술에서만 발생하는 예외도 있다. 스프링의 DataAccessException은 이런 일부 기술에서만 공통적으로 나타나는 예외를 포함해서 데이터 액세스 기술에서 발생 가능한 대부분의 예외를 계층구조로 분류해놓았다.

예) InvalidDataAccessResourceUsageException

  • JDBC, JDO, JPA, Hibernate에 상관없이 데이터 액세스 기술을 부정확하게 사용했을 때 발생한다.
  • JDBC에선 BadSqlGrammarException
  • Hibernate에선 HibernateQueryException 또는 TypeMismatchDataAccessException
  • 스프링이 기술의 종류에 상관없이 이런 성격의 예외를 InvalidDataAccessResourceUsageException 타입의 예외로 던져주므로 시스템 레벨의 예외처리 자겁을 통해 개발자에게 빠르게 통보해주도록 만들 수 있다.

예) 낙관적인 락킹 - ObjectOptimisticLockingFailureException

  • 낙관적인 락킹
    같은 정보를 두 명 이상의 사용자가 동시에 조회하고 순차적으로 업데이트를 할 때, 뒤늦게 업데이트한 것이 먼저 업데이트한 것을 덮어쓰지 않도록 막아주는 데 쓸 수 있는 편리한 기능이다.

  • JDO, JPA, Hibernate처럼 오브젝트/엔티티 단위로 정보를 업데이트하는 경우 발생한다.

  • 이런 예외들은 사용자에게 적절한 안내 메시지를 보여주고, 다시 시도할 수 있도록 해줘야 한다.

  • 스프링의 예외 전환 방법을 적용하면 기술에 상관없이 DataAccessException의 서브클래스인 ObjectOptimisticLockingFailureException으로 통일시킬 수 있다.

  • ORM 기술이 아닌 JDBC 등을 이용해 직접 낙관적인 락킹 기능을 구현한 경우

    • 슈퍼 클래스인 OptimisticLockingFailureException을 상속해서 정의해 사용할 수도 있다.
  • 기술에 상관없이 낙관적인 락킹이 발생했을 때 일관된 방식으로 예외처리를 해주려면 OptimisticLockingFailureException을 잡도록 만들면 된다.

따라서 JdbcTemplate과 같이 스프링의 데이터 액세스 지원 기술을 이용해 DAO를 만들면 사용 기술에 독립적인 일관성 있는 예외를 던질 수 있다. 결국 인터페이스 사용, 런타임 예외 전환과 함께 DataAccessException 예외 추상화를 적용하면 데이터 액세스 기술과 구현 방법에 독립적인 이상적인 DAO를 만들 수 있다.

2.4 기술에 독립적인 UserDao 만들기

2.4.1 인터페이스 적용

UserDao 클래스를 인터페이스와 구현으로 분리해보자.

📌 인터페이스 구분 방법

  • 인터페이스 이름 앞에 I라는 접두어 붙이는 방법
  • 인터페이스 이름은 가장 단순하게 하고 구현 클래스는 각각의 특징을 따르는 이름을 붙이는 방법

  • UserDaosetDataSource() 메서드는 인터페이스에 추가하면 안된다.
    • setDataSource() 메서드는 UserDao의 구현 방법에 따라 변경될 수 있는 메서드이다.
    • UserDao를 사용하는 클라이언트가 알고 있을 필요도 없다.

변경 사항

  1. 인터페이스 이름 : UserDao, 구현 클래스 이름 : UserDaoJdbc
  2. 스프링 설정파일 클래스 변경 : UserDao -> UserDaoJdbc

적용

  1. UserDao를 상속받은 UserDaoJdbc
    public class UserDaoJdbc implements UserDao {
  2. 스프링 설정파일 클래스 변경

2.4.2 테스트 보완

UserDao 인스턴스 변수 선언은 UserDaoJdbc로 변경할 필요가 없다.

  • @Autowired는 스프링의 컨텍스트 내에서 정의된 빈 중에서 인스턴스 변수에 주입 가능한 타입의 빈을 찾아주기 때문이다.
  • UserDaoJdbc 오브젝트는 UserDao 타입이다.
  • 경우에 따라 의도적으로 UserDaoJdbc dao라고 선언할 수도 있다. 중요한 건 테스트의 관심이다.
    • 그 구현 기술에 상관없이 DAO의 기능이 동작하는 데만 관심이 있다면, UserDao 인터페이스로 받아서 테스트하는 편이 낫다. 나중에 다른 데이터 액세스 기술로 DAO 빈을 변경한다고 하더라도 이 테스트는 여전히 유효하다.
    • 특정 기술을 사용한 UserDao의 구현 내용에 관심을 가지고 테스트하려면 테스트에서 @Autowired로 DI받을 때 UserDaoJdbc같이 특정 타입을 사용해야 한다.

추가 사항

  • 중복된 키를 가진 정보를 등록했을 때 어떤 예외가 발생하는지 확인하자.
    • 스프링의 DataAccessException 예외 중의 하나가 던져져야 한다.
    • 예외를 검증해주는 assertThrows()를 이용한다.

  • DuplicateKeyException이 발생한다.

2.4.3 DataAccessException 활용 시 주의사항

DuplicateKeyException은 아직까지는 JDBC를 이용하는 경우에만 발생한다. SQLException에 담긴 DB의 에러 코드를 바로 해석하는 JDBC의 경우와 달리 JPA나 Hibernate, JDO 등에서는 각 기술이 재정의한 예외를 가져와 스프링이 최종적으로 DataAccessException으로 변환하는데, DB의 에러 코드와 달리 이런 예외들은 세분화되어 있지 않기 때문이다.

또한, DataAccessException이 기술에 상관없이 어느 정도 추상화된 공통 예외로 변환해주긴 하지만 근본적인 한계 때문에 완벽하다고 기대할 수는 없다. 따라서 DataAccessException을 잡아서 처리하는 코드를 만들려고 한다면 미리 학습 테스트를 만들어서 실제로 전환되는 예외의 종류를 확인해둘 필요가 있다.

만약 DAO에서 사용하는 기술의 종류와 상관없이 동일한 예외를 얻고 싶다면 DuplicatedUserIdException처럼 직접 예외를 정의해두고, 각 DAO의 add() 메서드에서 좀 더 상세한 예외 전환을 해줄 필요가 있다.

학습 테스트를 해보자

SQLException을 직접 해석해 DataAccessException으로 변환하는 코드의 사용법을 살펴보자.

  • 추가 사항

    1. SQLErrorCodeExceptionTranslator를 사용한다.
      스프링에서 SQLExceptionDataAccessException으로 전환하는 가장 보편적이고 효과적인 방법은 DB 에러 코드를 이용하는 것이다. SQLException을 코드에서 직접 전환하고 싶다면 SQLExceptionTranslator 인터페이스를 구현한 클래스 중에서 SQLErrorCodeExceptionTranslator를 사용하면 된다.

    2. UserDaoTestDataSource 변수를 추가해서 DataSource 타입의 빈을 받아두자.
      SQLErrorCodeExceptionTranslator는 에러 코드 변환에 필요한 DB의 종류를 알아내기 위해 현재 연결된 DataSource를 필요로 한다.

DataSource를 사용해 SQLException에서 직접 DuplicateKeyException으로 전환하는 기능을 확인해보는 학습 테스트다.

  1. JdbcTemplate을 사용하는 UserDao를 이용해 강제로 DuplicateKeyException을 발생시킨다.
    가져온 DuplicateKeyException은 중첩된 예외로 JDBC API에서 처음 발생한 SQLException을 내부에 갖고 있다. getRootCause() 메서드를 이용하면 중첩되어 있는 SQLException을 가져올 수 있다.

  2. 스프링의 예외 전환 API를 직접 적용해 DuplicateKeyException이 만들어지는지 확인한다.
    2-1. 주입받은 DataSource를 이용해 SQLErrorCodeExceptionTranslator의 오브젝트를 만든다.
    2-2. 거기에 SQLException을 파라미터로 넣어서 translate() 메서드를 호출해주면 SQLExceptionDataAccessException 타입의 예외로 변환해준다.
    2-3. 변환된 DataAccessException 타입의 예외가 정확히 DuplicateKeyException 타입인지 확인한다.

    📌 assertThat().isInstanceOf() 뒤에 클래스를 넣으면 오브젝트의 equals() 비교 대신 주어진 클래스의 인스턴스인지 검사해준다.

    • 책에선 assertThat()is() 메서드에 클래스를 넣는다.

JDBC 외의 기술을 사용할 때도 DuplicateKeyException을 발생시키려면 SQLException을 가져와서 직접 예외 전환을 하는 방법을 생각해볼 수 있다. 또는 JDBC를 이용하지만 JdbcTemplate과 같이 자동으로 예외를 전환해주는 스프링의 기능을 사용할 수 없는 경우라도 SQLException을 그대로 두거나 의미 없는 RuntimeException으로 뭉뚱그려서 던지는 대신 스프링의 DataAccessException 계층의 예외로 전환하게 할 수 있다.

3. 정리

4장에선 엔터프라이즈 애플리케이션에서 사용할 수 있는 바람직한 예외처리 방법을 살펴봤다. 또한 JDBC 예외의 단점이 무엇인지 살펴보고, 스프링이 제공하는 효과적인 데이터 액세스 기술의 예외처리 전략과 기능에 대해서도 알아봤다.

  • 예외를 잡아서 아무런 조치를 취하지 않거나 의미 없는 throws 선언을 남발하는 것은 위험하다.
  • 예외는 복구하거나 예외처리 오브젝트로 의도적으로 전달하거나 적절한 예외로 전환해야 한다.
  • 좀 더 의미 있는 예외로 변경하거나, 불필요한 catch/throws를 피하기 위해 런타임 예외로 포장하는 두 가지 방법의 예외 전환이 있다.
  • 복구할 수 없는 예외는 가능한 한 빨리 런타임 예외로 전환하는 것이 바람직하다.
  • 애플리케이션의 로직을 담기 위한 예외는 체크 예외로 만든다.
  • JDBC의 SQLException은 대부분 복구할 수 없는 예외이므로 런타임 예외로 포장해야 한다.
  • SQLException의 에러 코드는 DB에 종속되기 때문에 DB에 독립적인 예외로 전환될 필요가 있다.
  • 스프링은 DataAccessException을 통해 DB에 독립적으로 적용 가능한 추상화된 런타임 예외 계층을 제공한다.
  • DAO를 데이터 액세스 기술에서 독립시키려면 인터페이스 도입과 런타임 예외 전환, 기술에 독립적인 추상화된 예외로 전환이 필요하다.
profile
아무것도 모르는 백엔드 3년차 개발자입니다 :)

0개의 댓글