ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Toby spring] 사라진 SQLException
    카테고리 없음 2023. 4. 20. 09:53
    자바 개발자들이 가장 신경 쓰기 귀찮아하는 것 중 하나가 바로 예외처리다. 정상적인 결과와 흐름을 보여주는 코드를 만들기도 버거운데 예외상황까지 처리해야 한다는 사실이 부담스러울 수도 있다. 이전에 다시 보는 초난감 DAO 에서 JdbcContext로 만들었던 코드를 스프링 JdbcTemplate을 적용하도록 바꾸면서 설명하지 않고 은글슬쩍 넘어간 부분이 있다.
    public void deleteAll() throws SQLException {
      this.jdbcContext.executeSql("delete from users");
    }
    
    public void deleteAll() {
      this.jdbcTemplate.update("delete from users");
    }
    
    두개의 메서드의 다른게 있다면 throws SQLException 이 사라졌다. SQLException은 JDBC API의 메서드들이 던지는 것이므로 당연히 있어야 한다. 그렇다면 SQLException은 과연 어디로 간 것일까?

    초난감 예외처리

    JdbcTemplate이 어떤 짓을 했길래 SQLException이 사라졌는지 알아보기 전에 먼저 개발자들의 코드에서 종종 발견되는 초난감 예외처리의 대표선수들을 알아보자.

    예외 블랙홀

    try {
      //...
    } catch (SQLException e) {
    }
    
    JDBC API를 썼더니 IDE가 친절하게도 빨간 줄을 그어주며 처리되지 않은 예외가 있다고 에러를 표시를 해준다. 이는 자바 기초 시간에 배운 대로 try/catch 블록을 둘러싸주는 것으로 해결한다. 컴파일러 에러 메시지도 없어지고 간단한 예제라면 별문제 없이 잘 동작한다. 예외가 발생하면 그것을 catch 블록을 써서 잡아내는 것까지는 좋은데 아무것도 하지 않고 별문제 없는 것처럼 버리는 건 정말 위험한 일이다. 원치 않는 예외가 발생하는 것보다도 훨씬 더 나쁜일이다. 왜냐하면 프로그램 실행 중에 어디선가 오류가 있어서 예외가 발생하는데 그것을 무시하고 진행해버리기 때문이다. 초남간 예외처리 코드
    try {
      //...
    } catch (SQLException e) {
      System.out.println(e);
    }
    
    try {
      //...
    } catch (SQLException e) {
      e.getStackTrace();
    }
    
    예외가 발생하면 화면에 출력해주는데 뭐가 문제일까? 개발 중에는 IDE 콘솔이나 서버 실행창에 메시지가 눈에 확 띄게 보이니 문제가 생겨도 금방 알아차려 조치를 취할 수 있을지 모르겠다. 그래 봤자 다른 로그나 메시지에 금방 묻혀버리면 놓치기 쉽상이다. 운영서버 같은경우에는 누군가가 계속 모니터링하지 않는 한 이 예외 코드는 심각한 폭탄으로 남아 있을 것이다. 예외를 처리할 때 반드시 지켜야 할 핵심 원칙은 한 가지다. 모든 예외는 적절하게 복구되든지 아니면 작업을 중단시키고 운영자 또는 개발자에게 분명하게 통보돼야 한다. SQLException이 발생하는 이유는 SQL에 문법 에러가 있거나 DB에서 처리할 수 없을 정도로 데이터 엑세스 로직에 심각한 버그가 있거나 서버가 죽거나 네트워크가 끊기는 등의 심각한 상황이 벌어졌기 때문이다. 차라리 이럴 바엔 아래와 같이 만드는 것이 백배 낫다.
    try {
      //...
    } catch (SQLException e) {
      e.getStackTrace();
      System.exit(1);
    }
    
    물론 실전에서 이렇게 만드라는 것은 아니다. 예외를 무시하거나 잡아먹어 버리는 코드는 만들지 말라는 뜻이다. 굳이 예외를 잡아서 뭔가 조치 방법이 없다면 잡지 말아야 한다. 메서드에 throws SQLException을 선언해서 메소드 밖으로 던지고 자신을 호출한 코드에 예외처리 책임을 전가해버려라.

    무의미하고 무책함한 throws

    public void method1() throws Exception{
      method2();
    }
    
    public void method2() throws Exception{
      method3();
    }
    
    public void method3() throws Exception{
    
    }
    
    EJB가 한창 쓰이더 시절에 흔히 볼 수 있던 코드다. API 등에서 발생하는 예외를 일일이 catch하기도 귀찮고 별 필요도 없으며 매번 정확하게 예외 이름을 적어서 선언하기도 귀찮으니 아예 throws Exception이라는 모든 예외를 무조건 던저버리는 선언을 모든 메서드에 기걔적으로 넣는 것이다. 예외를 흔적도 없이 먹어치우는 예외 블랙홀 보다는 조금 낫긴 하지만 이런 무책임한 throws 선언도 심각한 문제점이 있다. 자신이 사용하려고 하는 메서드에 throws Exception이 선언되어 있다고 생각해보자. 그런 메서드 선언에서는 의미 있는 정보를 얻을 수 없다. 정말 무엇인가 실행 중에 예외적인 상황이 발생할 수 있다는 것인지 아니면 그냥 습관적으로 복사해서 붙여놓은 것인지 알 수 가 없다. 결과적으로 적절한 처리를 통해 복구 될 수 있는 예외 상황도 제대로 다룰 수 있는 기회를 박탈당한다.

    예외의 종류와 특징

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

    Error

    첫째는 java.lang.Error 클래스의 서브 클래스들이다. 에러는 시스템에 뭔가 비정상적인 상황이 발생했을 경우에 사용된다. 주로 VM에서 발생시키는 것이고 애플리케이션 코드에서 잡으려고 하면 안된다. OutofMemoryError 나ThreadDeath 같은 에러는 catch로 잡아봤자 대응 방법이 없다.

    Exception과 체크 예외

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

    RuntimeException과 언체크/런타임 예외

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

    예외처리 방법

    예외 복구

    첫 번째 예외처리 방법은 예외상황을 파악하고 문제를 해결해서 정상 상태로 돌려놓는 것이다. 예를 들어 사용자가 요청한 파일을 읽으려고 시도했는데 해당 파일이 없다거나 다른 문제가 있어 읽히지기 않아 IOException이 발생했다고 가정해보자. 이때는 사용자에게 상황을 알려주고 다른 파일을 이용하도록 안내해서 예외상황을 해결할 수 있다. 예외로 인해 기본 작업 흐름이 불가능하면 다른 작업 흐름으로 자연스럽게 유도해주는 것이다. 이런 경우 예외상황은 다시 정상으로 돌아오고 예외를 복구했다고 볼 수 있다. 단 IOException 에러 메시지가 사용자에게 그냥 던져지는 것은 예외 복구라 볼 수 없다. 예외가 처리 됐으면 비록 기능적으로는 사용자에게 예외상황으로 비쳐도 애플리케이션는 정상적으로 설계된 흐름을 따라 진행돼야 한다. 네트워크가 불안해서 가끔 서버에 접속이 잘 안되는 열악한 환경에 있는 시스템이라면 원격 DB 서버에 접속하다 실패애서 SQLException이 발생하는 경우에 재시도를 해볼 수 있다. 일정시간 대기 했다가 다시 접속을 시도해보는 방법을 사용해서 예외 상황으로부터 복구를 시도 해 볼 수 있다.
    int maxretry = MAX_RETRY;
    while (maxretry-- > 9) {
      try {
        //... 예외가 발생 할 수 있는 코드
        return;  //성공
      } catch (SomeException e) {
        //로그 출력 일정 시간 만큼 대기
      } finally {
        //리소스 반납
      }
    }
    //최대 재시도 횟수를 넘기면 에러
    throw new RetryFailedException();
    

    예외 처리 회피

    두번째 방법은 예외처리를 자신이 담당하지 않고 자신을 호출한 쪽으로 던져버리는 것이다. throws 문으로 선언해서 예외가 발생하면 알아서 던져지게 하거나 catch 문으로 일단 예외를 잡은 후 로그를 남기고 다시 예외를 던지는 rethorw 것이다.
    public void add() throws SQLException{
      // JDBC API
    }
    
    public void add() throws SQLException {
      try {
        // JDBC API
      } catch (SQLException e) {
        //로그 출력
        throw e;
      }
    }
    
    JdbcContext나 JdbcTemplate이 사용하는 콜백 오브젝트는 메서드 선언을 보면 알겠지만 ResultSet이나 PreparedStatement 등을 이용해서 작업하다 발생하는 SQLException을 자신이 처리하지 않고 템플릿으로 던져버린다. 콜백 오브젝트의 메서드는 모두 throws SQLException이 붙어 있다. SQLException을 처리하는 일은 콜백 오브젝트의 역할이 아니라고 보기 때문이다. 콜백 오브젝트의 메서드는 SQLException에 대한 예외를 회피하고 템플릿 레벨에서 처리하도록 던져준다. 하지만 콜백과 템플릿 처럼 긴밀하게 역할을 분담하고 있는 관계가 아니라면 자신의 코드에서 발생하는 예외를 그냥 던져버리는 건 무책임한 책임회피일 수 있다. 만약 DAO가 SQLException을 생각 없이 던져버리면 어떻게 될까? DAO를 사용하는 서비스 계층이나 웹 컨트롤러에서 과연 SQLException을 제대로 처리할 수 있을까? 아마도 이런 경우라면 DAO에서 던진 SQLException을 서비스 계층 메서드가 다시 던지고, 컨트롤러도 다시 던지도록 선언해서 예외는 그냥 서버로 전달되고 말것이다. 아마도 throws SQLException과 같이 구체적인 예외를 던지도록 선언하기가 귀찮아서 모든 예외를 생각없이 던져버리게 하는 throws Exception을 사용할 가능성이 높다.

    예외 전환

    마지막으로 예외를 처리하는 방법은 예외전환을 하는 것이다. 예외 회피와 비슷하게 예외를 복구해서 정상적인 상태로는 만들수 없기 때문에 예외를 메서드밖으로 던지는 것이다. 하지만 예외 회피와 달리 발생한 예외를 그대로 넘기는 게 아니라 적절한 예외로 전환해서 던진다는 특징이 있다. 예외전환은 보통 두가지 목적으로 사용된다. 첫번째는 내부에서 발생한 예외를 그대로 던지는 것이 그 예외상황에 대한 적절한 의미를 부여해주지 못하는 경우에 의미를 분명하게 해줄 수 있는 예외로 바꿔주기 위해서다. 예를 들어 새로운 사용자를 등록하려고 시도했을 때 아이디가 같은 사용자가 있어서 DB에러가 발생하면 JDBC API는 SQLException을 발생시킨다. 이 경우 DAO 메서드가 SQLException을 그대로 밖으로 던져버리면, DAO를 이용해 사용자를 추가하려고 한 서비스 계층 등에서 왜 SQLException이 발생했는지 쉽게 알 방법이 없다. DAO 메서드에서 기술에 독립적이며 의미가 분명한 예외로 전달해서 던져줄 필요가 있다.
    public void add(User user) throws DuplicateUserIdException, SQLException {
      try {
    //       user
      } catch (SQLException e) {
        //Mysql의 duplicate Entry 예외 이면 전환
        if (e.QetErrorCode() == MysQIErrorNumbers.ER_DUP_ENTRY) {
          throw new DuplicateUserIdException();
        } else{
          //그렇지 않으면 SQLException
          throw e;   
        }
      }
    }
    
    보통 전환하는 예외에 원래 발생한 예외를 담아서 중첩 예외로 만드는 것이 좋다.
    try {
      //...
    } catch (SQLException e) {
      throw DuplicateUserIdException(e);
    }
    
    try {
      //...
    } catch (SQLException e) {
      throw DuplicateUserIdException().initCause(e);
    }
    
    두 번쨰 전환 방법은 예외를 처리하기 쉽고 단순하게 만들기 위해 포장하는 것이다. 중첩예외를 이용해 새로운 예외를 만들고 원인(cause)이 되는 예외를 내부에 담아서 던지는 방식은 같다.

    예외 처리 전략

    사실 자바의 예외를 이용하는 것은 간단하다. 하지만 예외를 효과적으로 사용하고, 예외가 발생하는 코드를 깔끔하게 정리하는 데는 여러 가지 신경 써야 할 사항이 많다.

    런타임 예외의 보편화

    수많은 사용자가 동시에 요청을 보내고 각 요청이 독립적인 작업으로 취급된다. 하나의 요청을 처리하는 중에 예외가 발생하면 해당 작업만 중단 시키면 그만이다. 독립형 애플리케이션과 달리 서버의 특정 계층에서 예외가 발생했을 때 작업을 일시 중지하고 사용자와 바로 커뮤니케이션하면서 예외상황을 복구할 수 있는 방법은 없다. 차라리 예외상황을 미리 파악하도 예외가 발생하지 않도록 차단하는게 좋다. 자바의 환경이 서버로 이동하면서 체크 예외의 활용도와 가치는 점점 떨어지고 있다. 자칫하면 throws Exception으로 점철된 아무런 의미도 없는 메서드들을 낳을 뿐이다. 대응 불가능한 체크 예외라면 빨리 런타임 예외로 전환해서 던지는게 낫다.

    add() 메서드의 예외 처리

    ID 중복이라면 좀 더 의미 있는 예외인 DuplicateUserIdException으로 전환해주고 아니라면 SQLException을 그대로 던지게 했다. DuplicateUserIdException은 충분히 복구 가능한 예외이므로 add() 메서드를 사용하는 쪽에서 잡아서 대응할 수 있다. 하지만 SQLException은 대부분 복구 불가능한 예외이므로 잡아봤자 처리할 것도 없고, 결국 throws를 타고 계속 앞으로 전달되다가 애플리케이션 밖으로 던져질 것이다. 그럴 바에는 그냥 런타임 예외로 포장해 던져버리서 그밖의 메서드들이 신경 쓰지 않게 해주는 편이 낫다. DuplicateUserIdException도 굳이 체크 예외로 둬야 하는 것이 아니다. DuplicateUserIdException처럼 의미 있는 예외는 add() 메서드를 바로 호출한 오브젝트 대신 더 앞단의 오브젝트에서 다룰 수도 있다. 어디든 DuplicateUserIdException을 잡아서 처리할 수 있다면 굳이 체크 예외로 만들지 않고 런타임 예외로 만드는 게 낫다.
    class DuplicateUserldException extends RuntimeException {
      public DuplicateUserldException(Throwable cause) {
        super(cause);
      }
    }
    
    public void add(User user) throws DuplicateUserIdException {
      try {
    //       user
      } catch (SQLException e) {
        //Mysql의 duplicate Entry 예외 이면 전환
        if (e.QetErrorCode() == MysQIErrorNumbers.ER_DUP_ENTRY) {
          throw new DuplicateUserIdException(e);
        } else{
          //예외 포
          throw new RuntimeException(e);
        }
      }
    }
    
    이제 이 add() 메서드를 사용하는 오브젝트는 SQLException을 처리하기 위해 불필요한 throws 선언을 할 필요는 없으면서 필요한 경우 아아디 중복 상황을 처리하게 위해 DuplicateUserIdException을 이용할 수 있다. DuplicateUserIdException이 발생한 경우라면 사용자가 요청한 아이디 대신 사용할 수 있는 추천 아이디를 만들어 아이디 중복 메시지와 함께 제공해주는 방법을 사용하면 좋을 듯하다. 이렇게 런타임 예외를 일반화해서 사용하는 방법은 여러모로 장점이 많다. 단 런타임 예외로 만들었기 때문에 사용에 더 주의를 기울일 필요도 있다.

    애플리케이션 예외

    런타임 예외 중심의 전략은 굳이 이름을 붙이자면 낙관적인 예외처리 기법이라고 할 수 있다. 일단 복구할 수 있는 예외는 없다고 가정하고 예외가 생겨도 어차피 런타임 예외이므로 시스템 레벨에서 알아서 처리해줄 것이다 꼭 필요한 경우는 런타임 예외라도 잡아서 복구하거나 대응해줄 수 있으니 문제 될 것이 없다는 낙관적인 태도를 기반으로 하고 있다. 이런 면에서 직접 처리할 수 없는 예외가 대부분이라고 하더라도 홀시 놓치는 예외가 있을 수 있으니 일단 잡고 보도록 강제하는 체크 예외를 비관적인 접근 방법과 대비 된다. 시스템 또는 외부의 예외상황이 원인이 아니라 애플리케이션 자체의 로직에 의해 의도적으로 발생시키고 반드시 catch 해서 무엇인가 조치를 취하도록 요구 하는 예외도 있다. 예를 들어 사용자가 요청한 금액을 은행계좌에서 출금하는 기능을 가진 메서드가 있다고 가정하자. 무턱대도 출금을 허용하고, 현재 잔고가 얼마인지 상관없이 요청한 금액만큼 계좌 잔액을 차감하도록 만드는 개발자는 없을 것이다. 당연히 현재 잔고를 확인하고 허용하는 범위를 넘어서 출금을 요청하면 출금 작업을 중단시키고 적절할 경고를 사용자에게 보내야 한다. 이런 기능을 담는 메서드를 설계하는 방법이 두가지 있다. 첫 번째 방법은 정상적인 출금 처리를 했을 경우와 잔고가 부족했을 경우에 각각 다른 종류의 리턴 값을 돌려주는 것이다. 만약 정상적인 출금이 처리 된 경우에는 요청금액 자체를 리턴하고 잔고가 부족한 경우라면 0 또는 -1 같은 특별한 값으로 리턴한다. 예외 상황에 대한 리턴 값을 명확하게 코드화하고 잘 관리 하지 않으면 혼란이 생길 수 있다. 정상적인 처리가 안 됐을 때 전달하는 값의 표준 같은 것은 없다. 어떤 개발자는 0 다른 개발자는 -1 이나 -999를 돌려주는 개발자도 있을 것이다. 결과 값에 대한 정책이 완벽하게 갖춰져 있고, 사전에 상수로 정의해둔 표준 코드를 사용하지 않는다면 자칫 개발자 사이의 의사소통 문제로 인해 제대로 동작하지 않을 위험이 있다. 두 번째 방법은 정상적인 흐름을 따르는 코드는 그대로 두고, 잔고 부족과 같은 예외 상황에서는 비지니스적인 의미를 띈 예외를 던지도록 만드는 것이다. 잔고가 부족인 경우라면 InsufficientBalanceException 등을 던진다. 예외 상황을 처리하는 catch 블록을 메서드 호출 직후에 둘 필요는 없다. 정상적인 흐름을 따르지만 예외가 발생할 수 있는 코드 try 블록 안에 깔끔하게 정리해두고 예외 상황에 대한 처리는 catch 블록에 모아 둘 수 있기 때문에 코드를 이해하기도 편하다. 번거로운 if문을 남발하지 않아도 된다. 이때 사용하는 예외는 의도적으로 체크 예외로 만든다. 그래서 개발자가 잊지 않고 잔고 부족처럼 자주 발생 가능한 예외상황에 대한 로직을 구현하도록 강제해주는 게 좋다.
    try {
      BigDecimal balance = account.withdraw(amount);
    } catch(InsufficientBalanceException e) { //체크 예외
      //잔고 금액을 가져
      BigDecimal availFunds = e.getAvailFunds();
      //잔고 부족 안내 메시지후 이를 출
    }
    

    SQLException은 어떻게 됐나?

    지금까지 다룬 예외에 대한 내용은 JdbcTemplate을 적용하는 중에 throws SQLException 선언이 왜 사라졌는가를 설명하는 데 필요 한 것이었다. 스프링의 예외 처리 전략과 원칙을 알고 있어야 하기 때문이다. 지금까지 살펴본 예외처리에 관한 내용을 바탕으로 생각해보자. 먼저 생각해볼 사항은 SQLException은 과연 복구가 가능한 예외인가이다. 99%의 SQLException은 코드 레벨에서는 복구할 방법이 없다. 프로그램의 오류 또는 개발자의 부주의 때문에 발생하는 경우이거나 통제할 수 없는 외부상황 떄문에 발생하는 것이다. 예를 들어 SQL 문법이 틀렸거나, 재약조건을 위반, DB 서버가 다운됐다거나 네트워크 불안정, DB 커넥션 풀이 꽉차서 DB 커넥션을 가져올 수 없는 경우 등이다. 시스템의 예외라면 당연히 애플리케이션 레벨에서 복구할 방법이 없다. 관리자나 개발자에게 빨리 예외가 발생했다는 사실이 알려지도록 전달하는 방법밖에는 없다. 앞에서 잠깐 살펴본 ID 중복 문제를 시행착오 기법으로 확인하는 조금 무식한 접근방법 정도라면 모를까 대부분의 SQLException은 복구가 불가능하다. 더군다나 DAO밖에서 SQLException을 다룰 수 있는 가능성은 거의 없다. 따라서 예외처리 전략을 적용해야 한다. 필요도 없는 기계적인 throws 선언이 등장하도록 방치하지 말고 가능한 빨리 언체크/런타임 예외로 전환해줘야 한다. 스프링의 JdbcTemplate은 바로 이 예외처리 전략을 따르고 있다. JdbcTemplate 템플릿과 콜백 안에서 발생하는 모든 SQLException을 런타임 예외인 DataAccessException으로 포장해서 던져준다. 따라서 JdbcTemplate을 사용하는 UserDao 메서드에선 꼭 필요한 경우에만 런타임 예외인 DataAccessException을 잡아서 처리하면 되고 그 외의 경우에는 무시해도 된다. 그래서 DAO 메서드에서 SQLException이 모두 사라진 것이다. JdbcTemplate의 update(), queryForInt(), query() 메서드 선언을 잘 살펴보면 다음과 같이 모둔 throws DataAccessException이라고 되어 있음을 발견할 수 있다. throws로 선언되어 있긴 하지만 DataAccessException이 런타임 예외이므로 update()를 사용하는 메서드에서 이를 잡거나 다시 던질 의무는 없다.
    public int update(final String sql) throws DataAccessException{
    
    }
    
    그 밖에도 스프링의 API 메서드에 정의되어 있는 대부분의 예외는 런타임 예외다. 따라서 발생 가능한 예외가 있다고 하더라도 이를 처리하도록 강제하지 않는다.

    댓글

Designed by Tistory.