Enjoy My Posts

토비의 스프링 4장

Posted on By Geunwon Lim

4장 예외

4.1.1 초난감 예외처리

예외 블랙홀

예외를 잡는것은 문제되지 않지만, 그 후 처리 없이 별문제 없는 것처럼 넘어가 버리는 건 위험하다. 예외 발생 후 화면에 보여주는 것만으로는 부족하다. 개발중에야 눈에 띄지만, 운영중에는 묻히기 쉽상이다(계속 모니터링하지 않는 한, 폭탄으로 남아있는 것이다). 예외 처리의 핵심 원칙은 적절히 복구되든지, 작업을 중단시키고 개발자/운영자에게 확실히 통보돼야 한다는 것이다.

무의미하고 무책임한 throws

기계적, 무책임한 throws도 문제다(throws Exception). 진짜 실행 중 예외 상황이 발생할 수 있는 것인지, 습관적으로 복사한 것인지 알 수가 없다. throws Exception을 하는 함수를 호출하는 쪽에서 결국 어떻게 처리해야할 지 모르고, 적절한 처리를 통해 복구될 수 있는 예외상황도 복구 못하게 된다.

4.1.2 예외의 종류와 특징

자바의 경우, 체크예외(RuntimeException을 상속하지 않는 Exception) 처리가 주로 논의 거리이다. 체크예외가 발생할 수 있는 메소드를 사용할 경우 반드시 예외 처리하는 코드를 함께 작성해야한다. 즉, 사용할 메소드가 체크 예외를 던진다면 이를 잡든지, 다시 던지든지 해야한다.

언체크예외(RuntimeException 클래스를 상속하는 예외들)는 명시적인 예외처리를 강제하지 않는다. 런타임 예외는 주로 프로그램의 오류가 있을 때 발생하도록 의도된 것들이다(NullpointerException, IllegalArgumentException 등). 이런 예외는 코드에서 미리 조건 체크하도록 주의깊게 만들면 피할 수 있고, 피할 수 있지만 개발자가 부주의해서 발생하게 된다. 즉 런타임 예외는 예상하지 못했던 예외상황에서 발생하는 게 아니기 때문에 굳이 처리(catch, throw)를 하지 않아도 된다.

4.1.3 예외처리 방법

예외복구

예외상황을 파악하고 문제를 해결해서 정상 상태로 돌려놓는 것이다. 예를 들어, 특정 파일을 읽으려고 했지만 문제가 있어서 읽히지 않을 경우, 사용자에게 상황을 알려주고 다른 파일을 이용하도록 안내해서 예외상황을 해결할 수 있다. 기본 작업 흐름이 불가능할 경우 다른 작업 흐름으로 자연스럽게 유도해주는 것이다. 이 때, 에러 메시지가 사용자에게 그냥 던져지는 것은 예외 복구라고 볼 수 없다. 예외가 처리됐으면 비록 기능적으로는 사용자에게 예외상황이라고 비쳐도, 애플리케이션에서는 정상적으로 설계된 흐름을 따라 진행돼야 한다.

체크 예외들은 이렇게 예외를 어떤 식으로든 복구할 가능성이 있는 경우에 사용한다. API를 사용하는 개발자로 하여금 예외상황이 발생할 수 있음을 인식하도록 도와주고 이에 대한 적절한 처리를 시도해보도록 요구하는 것이다.

예외처리 회피

예외처리를 자신이 담당하지 않고, 자신을 호출한 쪽으로 던져버리는 것이다. 예를 들어, throws 문을 선언해서 알아서 예외가 던져지도록 하거나, catch 문으로 일단 예외를 잡은 후에 로그를 남기고 다시 예외를 던지는 것이다. 앞서 공부한 콜백 오브젝트는 예외를 회피하는데, 예외 처리는 콜백 오브젝트의 역할이 아니라고 보기 때문이다. 템플릿에서 처리해줘야 한다. 하지만 콜백/템플릿처럼 긴밀하게 역할을 분담하고 있는 관계가 아니라면 자신의 코드에서 발생하는 예외를 그냥 던져버리는 건 무책임한 회피일 수 있다. 예를들어, SQLException은 DAO에서 처리를 해야한다. 예외를 회피하는 것은 예외를 복구하는 것처럼 의도가 분명해야 한다. 긴밀한 관계에 있는 다른 오브젝트에게 예외처리 책임을 분명히 지게 하거나, 자신을 사용하는 쪽에서 예외를 다루는 게 최선의 방법이라는 확신이 있어야 한다.

예외 전환

예외 회피와 비슷하게 예외를 복구해서 정상적인 상태로는 만들 수 없기 대문에 예외를 메소드 밖으로 던지는 것이다. 하지만 예외 회피와 달리, 발생한 예외를 그대로 넘기는 게 아니라 적절한 예외로 전환해서 던진다. 예외 전환은 두 가지 목적으로 사용된다. 첫째는 내부에서 발생한 예외를 그대로 던지는 것이 그 예외상황에 대한 적절한 의미를 부여해주지 못하는 경우에, 의미를 분명하게 해줄 수 있는 예외로 바꿔주기 위해서다. API가 발생하는 기술적인 로우레벨을 상황에 적합한 의미를 가진 예외로 변경하는 것이다. 예를 들어 새로운 사용자를 등록하려고 시도했을 때 아이디가 같은 사용자가 있어서 db 에러가 발생하면 SQLException이 발생한다. 이 경우 그대로 밖으로 던져버리면, DAO를 이용해 사용자를 추가하려고 한 서비스 계층에서는 왜 SQL Exception이 발생했는 지 쉽게 알기 어렵다. 이럴 땐 DuplicateUserIdException 같은 예외로 바꿔주는 게 좋다. 의미가 분명한 예외가 던져지면 서비스 계층 오브젝트에는 적절한 복구 작업을 시도할 수 있다.

  • 첫 번째 전환방법: 보통 전환하는 예외에 원래 발생한 예외를 담아서 중첩 예외로 만드는 것이 좋다.
  • 두 번째 전환방법: 예외를 처리하기 쉽고 단순하게 만들기 위해 포장하는 것이다. 이 때는 의미를 명확하게 하려고 다른 예외로 전환하는 것이 아니고, 주로 예외처리를 강제하는 체크 예외를 언체크 예외인 런타임 예외로 바꾸는 경우에 사용한다.

일반적으로 체크 예외를 계속 던지는 건 무의미하다. 메소드 선언은 지저분해지고, 장점이 없다. SQLException이 컨트롤러까지 전달된다고 해도 의미가 없다. 어차피 복구가 불가능한 예외라면 가능한 한 빨리 런타임 예외로 포장해 던지게 해서 다른 계층의 메소드를 작성할 때 불필요한 throws 선언이 들어가지 않도록 해줘야 한다. 어차피 복구하지 못할 예외라면 애플리케이션 코드에서는 런타임 예외로 포장해서 던져버리고, 예외처리 서비스 등을 이용해 자세한 로그를 남기고, 관리자에게는 메일 등으로 통보해주고, 사용자에게는 친절한 안내 메세지를 보여주는 식의 처리가 바람직하다.

4.1.4 예외처리 전략

런타임 예외의 보편화

일반적으로는 체크 예외가 일반적인 예외를 다루고, 언체크 예외는 시스템 장애나 프로그램상의 오류에 사용한다. 문제는 체크 예외가 복구할 가능성이 조금이라도 있는, 말 그대로 예외적인 상황이기 때문에 자바는 이를 처리하는 catch 블록이나 throws 선언을 강제하고 있다는 점이다. 이건 개발자의 실수를 방지하기도 하지만, 예외를 제대로 다루기 싫게 할만큼 짜증나는 원인이 되기도 한다.

자바를 처음 만들어질 때의 독립형 애플리케이션에서는 통제 불가능한 시스템 예외라고 할지라도 애플리케이션 작업이 중단되지 않게 해주고 상황을 복구해야 했다. 하지만 자바 엔터프라이즈 서버환경은 다르다. 런타임 예외로 바꿔도 된다. 수많은 사용자가 동시에 요청을 보내고 각 요청이 독립적인 작업으로 취급돼, 하나의 요청을 처리하는 중 예외가 발생하면 해당 작업만 중단시키면 되기 때문이다. 대응이 불가능한 체크 예외라면 빨리 런타임 예외로 전환해서 던지는 게 낫다. 예전에는 복구할 가능성이 조금이라도 있다면 체크 예외로 만든다고 생각했는데, 지금은 항상 복구할 수 있는 예외가 아니라면 일단 언체크 예외로 만드는 경향이 있다.

add() 메소드의 예외처리

예제에서의 DuplicatedUserIdException은 충분히 복구 가능한 예외이므로 잡아서 대응 가능하다. 하지만 SQLException은 대부분 복구 불가능한 예외이므로 잡아봤자 처리할 것도 없고, 결국 throws를 계속 타다가 애플리케이션 밖으로 던져질 것이다. 그럴 바에는 그냥 런타임 예외로 포장해 던져버려서 그 밖의 메소드들이 신경쓰지 않게 해주는 편이 낫다. DuplicatedUserIdException도 굳이 체크 예외로 둬야 하는 것은 아니다. 호출하자 마자 처리해야 하는 것은 아니고, 더 앞단에서 처리할 수도 있다. 어디서든 잡아 처리할 수 있으니 굳이 체크예외로 만들지 않고 런타임 예외로 만드는 게 낫다. 대신 처음 메소드에서는 명시적으로 예외를 던진다고 선언해야한다. 그래야 다른 개발자에게 의미있는 정보를 전달해줄 수 있기 때문이다. 이렇게 하면 add() 메소드를 사용하는 오브젝트는 SQLException을 처리하기 위해 불필요한 throws 선언을 할 필요는 없으면서, 필요한 경우 DuplicatedUserIdException을 이용할 수 있다.

이렇게 런타임 예외를 일반화해서 사용하면 여러모로 장점이 많다. 단, 런타임 예외로 만들었기 때문에 사용에 더 주의할 필요가 있다. 컴파일러가 예외처리를 강제하지 않으므로, 신경을 잘 쓰지 않게될 수 있기 때문이다. 런타임 예외를 사용할 땐 문서에 메소드를 사용할 때 발생할 수 있는 예외의 종류, 원인, 활용방법을 적어두자.

런타임 예외 중심 전략은 낙관적인 예외처리 기법이다. 일단 복구할 수 있는 예외는 없다고 가정하고 예외가 생겨도 어차피 런타임 예외이므로 시스템 레벨에서 처리해줄 것이고, 꼭 필요한 경우는 런타임 예외도 잡아서 복구, 대응해줄 수 있으니 문제될 것 없다는 것이다. 직접 처리할 수 없는 예외가 대부분이더라도 혹시 놓치는 예외가 있을 수 있으니 일단 잡고 보도록 강제하는 체크 예외의 비관적 접근법과 대비된다.

애플리케이션 예외

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

비즈니스 로직에 따라 처리 유무가 정해지는 메소드들의 경우, 메소드를 설계하는 방법이 두 가지 있다.

첫 번째 방법은 상황에 따라 다른 종류의 리턴값을 돌려주는 것이다(리턴 값이 일종의 결과 상태를 나타내는 정보로 활용되는 것이다). 이 메소드를 호출한 쪽은 반드시 리턴값을 확인한다. 시스템 오류가 아니므로 기술적으로 보면 두 가지 경우 모두 정상 흐름이다. 하지만 리턴 값으로 결과를 확인하고, 예외상황을 체크하면 불편한 점도 있다. 1. 예외상황에 대한 리턴 값을 명확하게 코드화하고 잘 관리하지 않으면 혼란이 생길 수 있다. 자칫 개발자 사이의 의사소통 문제로 인해 코드가 제대로 작동하지 않을 위험이 있다. 2. 결과를 확인하는 조건문이 자주 등장한다는 것이다. 코드가 지저분해지고 흐름을 파악하고 이해하기 힘들어질 것이다.

두 번째 방법은 정상적인 흐름을 따르는 코드는 그대로 두고, 예외 상황은 비즈니스적인 의미를 띤 예외를 던지도록 하는 것이다. 메소드 호출 직후 예외를 바로 처리할 필요 없고, 예외가 발생할 수 있는 코드를 try 블록 안에 깔끔히 정리해두고 예외상황에 대한 처리는 catch 블록에 모아둘 수 있기 때문에 이해하기도 편하다. 이 때 사용하는 예외는 의도적으로 체크 예외로 만든다. 그래서 잊지 않고 예외상황에 대한 로직을 구현하도록 강제해주는 게 좋다.

4.2 예외 전환

예외 전환의 목적은 두가지였다. 1. 런타임 예외로 포장해서 굳이 필요하지 않은 throws/catch를 줄여주는 것 2. 로우레벨의 예외를 좀 더 의미 있고 추상화된 예외로 바꿔 던져주는 것. SQLException을 DataAccessException으로 포장하는 예에서는, 대부분 복구가 불가능한 예외인 SQLException을 어플리케이션 레벨에서는 신경쓰지 않도록 해준다. 또한 SQLExceptoin이 담긴 다루기 힘든 상세한 예외정보를 의미있고 일관성 있는 예외로 전환해서 추상화해주기도 한다.

4.2.1 JDBC의 한계

JDBC는 자바를 이용해 DB에 접근하는 방법을 추상화된 API 형태로 정의해놓고, 각 DB 업체가 JDBC 표준을 따라 만들어진 드라이버를 제공하게 해준다. 내부 구현은 DB마다 다르겠지만, 표준 인터페이스를 통해 기능을 제공해주기 때문에 자바 개발자들은 표준화된 JDBC API에만 익숙해지면 DB 종류와 상관 없이 일관된 방법으로 개발할 수 있다. 인터페이스를 사용하는 객체지향 프로그래밍 방법의 장점을 잘 느낄 수 있다.

하지만 DB 종류에 상관없이 사용 가능한 데이터 액세스 코드를 작성하는 일은 쉽지 않다. DB를 자유롭게 바꾸어 사용할 수 있는 DB 프로그램을 작성하는 데에는 두 걸림돌이 있다.

비표준 SQL

SQL은 어느 정도 표준화된 언어이고 몇 가지 규약이 있긴 하지만, 대부분의 DB는 표준을 따르지 않는 비표준 문법, 기능을 제공한다. 특정 목적을 위한 비표준 SQL이 결국 DAO 코드에 들어가고, 그러면 그 DAO는 특정 DB에 종속적이게 된다. 보통은 DB가 잘 변경되지 않고, 사용하는 DB에 최적화하는 것이 중요하므로 비표준 SQL을 거리낌없이 사용한다. 하지만 DB의 변경 가능성을 고려해서 유연하게 만들어야 한다면 SQL은 꽤 큰 걸림돌이 된다.

이 문제의 해결책은 호환 가능한 표준 SQL만 사용하는 방법, DB별로 별도의 DAO를 만들거나 SQL을 외부에 독립시켜서 DB에 따라 변경해 사용하는 방법이 있다. 표준 SQL만 사용하는 방법은 간단한 예제 프로그램이 아니고서는 현실성이 없다. 결국 사용할 수 있는 방법은 DAO를 DB별로 만들어 사용하거나 SQL을 외부에서 독립시켜서 바꿔쓸 수 있게 하는 것이다.

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

두 번째 문제는 SQLException이다. DB를 사용하다가 발생할 수 있는 예외의 원인은 다양하다. 문제는 DB마다 SQL만 다른 것이 아니라 에러의 종류와 원인도 제각각이라는 점이다. 그래서 JDBC는 다양한 예외를 그냥 SQLException하나에 담아버린다. 예외가 발생한 원인은 SQLException 안에 담긴 에러 코드와 SQL 상태정보를 참조해봐야 한다. 그런데 DB에러코드는 DB별로 다 다르다. 앞선 예제에서도 MySql 전용으로 에러를 전환해준 것이었다. 결론적으로, SQLException만으로 DB에 독립적인 유연한 코드를 작성하는 건 불가능에 가깝다.

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

DB 종류가 바뀌더라도 DAO를 수정하지 않으려면 위의 두 문제를 해결해야 한다. 여기서는 SQLException의 비표준 에러코드와 SQL 상태정보에 대한 해결책을 알아본다.

SQLException에 담긴 SQL 상태 코드는 신뢰할 만한 게 아니므로 더 이상 고려하지 않는다. 차라리 DB전용 에러코드가 더 정확하다. 해결방법은 DB별 에러코드를 참고해서 발생한 예외의 원인이 무엇인지 해석해주는 기능을 만드는 것이다. 예를 들어 중복 오류가 발생하는 경우 MySql은 1062, 오라클은 1이라는 에러코드를 받게된다. 이런 에러코드 값을 확인할 수 있으면 키 중복 때문에 발생하는 SQLExeption을 DuplicateKeyException 이라는 의미가 분명히 드러나는 예외로 전환할 수 있다. DB 종류에 상관없이 동일한 상황에서 일간된 예외를 전달받을 수 있다면 효과적인 대응이 가능하다. 문제는 DB마다 에러 코드가 제각각이라는 점이다. DAO메소드나 JdbcTemplate등의 코드에서 일일이 DB별로 에러 코드의 종류를 확인하는 작업을 수행하는 건 부담이 너무 크다. 대신 스프링은 DB별 에러코드를 분류해서 스프링이 정의한 예외 클래스와 매핑해놓은 에러 코드 매핑정보 테이블을 만들어도고 이를 이용한다. 드라이버나 DB 메타정보를 참고해서 적절한 예외 클래스를 선택하기 때문에 DB가 달라져도 같은 종류의 에러라면 동일한 예외를 받을 수 있다.

JdbcTemplate을 이용한다면, JdbcTemplate이 적절한 처리를 해주기 때문에 DB관련 예외는 거의 신경쓰지 않아도 된다. 그런데 중복키 에러가 발생했을 때 애플리케이션에서 직접 정의한 예외를 발생시키고 싶을 수 있다. 그럴 경우 기정의된 에러를 새롭게 정의한 에러로 전환해주면 된다.

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

DataAccessException은 JDBC에 종속되지 않고, 다른 데이터 엑세스 기술(Jpa 등)에서 발생하는 예외에도 적용돼, 의미가 같은 예외라면 기술의 종류와 상관없이 일관된 예외가 발생하도록 해준다(액세스 기술에 독립적인 추상화된 예외를 제공하는 것이다). 스프링은 왜 이렇게 기술에 독립적인 예외를 정의하고 사용하게할까.

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

DAO를 굳이 따로 만드는 이유는 무엇일까? 가장 중요한 이유는 데이터 액세스 로직을 담은 코드를 성격이 다른 코드에서 분리해놓기 위해서다. 또한 분리된 DAO는 전략 패턴을 적용해 구현 방법을 변경해서 사용할 수 있게 만들기 위해서이기도 하다. DAO를 사용하는 쪽에서는 DAO가 내부에서 어떤 데이터 액세스 기술을 사용하는 지 신경쓰지 않아도 된다. User와 같이 자바빈으 로 만들어진, 특정 기술에 독립적인 단순한 오브젝트를 주고받으면서 데이터 액세스 기능을 사용하기만 하면 된다. 그런 면에서 DAO는 인터페이스를 사용해 구체적인 클래스 정보와 구현 방법을 감추고, DI를 통해 제공되도록 만드는 것이 바람직하다.

그런데 DAO의 사용 기술과 구현 코드는 전략 패턴과 DI를 통해서 DAO를 사용하는 클라이언트에게 감출 수 있지만, 메소드 선언에 나타나는 예외정보가 문제될 수 있다. 기술에 독립적인 인터페이스로 만드려면 다음과 같이 정의해야 한다.

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

하지만 이렇게 하기 어렵다. DAO에서 사용하는 데이터 액세스 기술의 API가 예외를 던지기 때문이다. 즉, 예외 때문에 인터페이스에서도 throws ‘특정 기술에 종속된 에러’ 를 해야하고, 특정 기술에 종속돼버린다.

결국 인터페이슬호 메소드의 구현은 추상화했지만 구현 기술마다 던지는 예외가 다르기 때문에 메소드의 선언이 달라진다는 문제가 발생한다. DAO 인터페이스를 기술에 완전히 독립적으로 만들려면 예외가 일치하지 않는 문제도 해결해야 한다. 가장 단순한 해결방법은 모든 예외를 다 받아주는 Exception으로 선언하는 것이다. 간단하긴 하지만 무책임하다. 다행히 하이버네이트, JPA 등은 런타임예외를 사용하기 때문에, throws가 강제되지 않는다. JDBC하나 남았는데, 이 때는 DAO 메소드 내에서 런타임 예외로 포장해주면 된다. 그러면 처음 의도했던대로 throws 없이 인터페이스 정의 가능하다. 하지만 이걸로 충분할까?

대부분의 데이터 액세스 예외는 애플리케이션에서는 복구 불가능하거나 할 필요가 없다. 그렇다고 모든 예외를 다 무시해야 하는 건 아니다. 또한 애플리케이션에서는 사용하지 않더라도 시스템 레벨에서 데이터 액세스 예외를 의미있게 분류할 필요도 있다. 문제는 데이터 액세스 기술이 달라지면 같은 상황에서도 다른 종류의 예외가 던져진다는 점이다(예: JDBC-SQLException, JPA-PersistenceException). 따라서 DAO를 사용하는 클라이언트 입장에서는 DAO의 사용 기술에 따라 예외 처리 방법이 달라져야 한다. 결국 클라이언트가 DAO의 기술에 의존적이게 된다. 단지 인터페이스로 추상화하고, 일부 기술에서 발생하는 체크 예외를 런타임 예외로 전환하는 것만으론 불충분하다.

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

그래서 스프링은 자바의 다양한 데이터 액세스 기술을 사용할 때 발생하는 예외들을 추상화해서 DataAccessException 계층구조 안에 정리해놓았다. 스프링의 DataAccessException은 자바의 주요 데이터 액세스 기술에서 발생할 수 있는 대부분의 예외를 추상화하고 있다. JdbcTemplate과 같이 스프링의 데이터 액세스 지원 기술을 이용해 DAO를 만들면 사용 기술에 독립적인 일관성있는 예외를 던질 수 있다. 결국 인터페이스 사용, 런타임 예외 전환과 함께 DataAccessException 예외 추상화를 적용하면 데이터 액세스 기술과 구현 방법에 독립적인 이상적인 DAO를 만들 수 있다.

4.3 정리

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