주요글: 도커 시작하기
반응형
n-티어 또는 n-레이어 구조를 갖는 어플리케이션에서의 알맞은 예외 처리에 대해서 살펴본다.

자바에서의 예외 처리

자바를 이용하여 프로그래밍을 해본 사람은 다들 다음과 같은 형태의 문장에 익숙할 것이다.

  try {
     conn = DriverManager.getConnection("jdbc:odbcd:...", "user", "passwd");
     pstmt = conn.prepareStatement("update tname1 set field1 = ? where field2 = ?");
     pstmt.setString(1, "xx");
     pstmt.setString(2, "yy");
     pstmt.executeUpdate();
  } catch(SQLException ex) {
     ...
  } finally {
     ...
  }

여기서 try / catch / finally 블럭은 자바에서 예외 처리를 하는 기본 메커니즘이다. try 와 catch 사이에 있는 블럭에서 발생하는 예외는 각각의 catch 를 통해서 처리되며, finally 블럭을 통해서 최종적으로 필요한 작업을 수행하게 된다.

자바에서의 예외는 스택을 통해 메소드를 호출한 역순으로 전달되기 때문에, 어느 부분에서 예외가 발생했는 지 손쉽게 추적할 수 있다는 장점을 제공하고 있다. 또한, 스택을 통해 전달되기 때문에 예외를 처리하길 원하는 부분에서만 try / catch 블럭을 사용하여 예외를 처리하면 된다는 장점을 제공하고 있다.

예외를 스택을 통해 전달하고 싶은 경우에는 다음과 같이 메소드를 정의할 때 throws 구문을 사용하여 전달할 예외를 지정해주면 된다.

  public void insertData(DataBean b) throws SQLException {
     ...
     conn = dbPoolMgr.getConnection();
     pstmt = conn.prepareStatement("update tname1 set ... ");
     ...
  }

이와 같이 각 메소드는 try / catch 블럭을 사용하지 않고 메소드를 정의할 때 throws 구문을 사용함으로써 그 메소드를 호출한 다른 부분에서 예외를 처리할 수 있도록 할 수 있다. 하지만, 이러한 단순한 예외의 전달은 때로는 어플리케이션의 의미를 약화시키는 원인이 되기도 한다.

n-티어/n-레이어 구조를 갖는 어플리케이션

그냥 단순히 테스트 하는 용도로 프로그래밍을 할 때는 메소드에서 발생한 예외를 throws 구문을 통해서 그대로 전달하는 것이 문제가 되지 않는다. 하지만, n-티어 또는 n-레이어의 구조를 갖는 어플리케이션에서 이처럼 발생한 예외를 그대로 상위 레이어 또는 이전 티어로 전달하는 것은 예외를 처리하는 데 있어 올바르지 못한 방법이다. 예를 들어, 다음과 같이 n-티어 구조를 갖는 어플리케이션이 있다고 해 보자.


위 그림은 크게 클라이언트와 서버 그리고 그림에 표시하진 않았지만 비지니스 로직 뒤로 숨겨진 레거시 시스템의 3-티어 형태의 구조를 취하고 있다. 여기서 비즈니스 로직을 담당하는 부분은 데이터베이스 커넥션이나 EJB 컴포넌트를 사용하게 될 것이다. 이 과정에서 비즈니스 로직은 필요한 자원을 할당받을 수 없는 경우가 발생할 수 있다. 예를 들어, 데이터베이스 커넥션을 할당받지 못할 수도 있고, EJB 컴포넌트를 생성하거나 사용하는 과정에서 예외가 발생할 수도 있다. 다음 메소드는 비즈니스 로직 부분에서 이처럼 예외가 발생할 때 단순히 스택에 전달하는 형태를 보여주고 있다.

  public class SomeBean extends ... {
     ...
     public Person[] getPersonAtOffice(String officeCode)
        throws SQLException, RMIException {
        
        // 내부적으로 데이터베이스를 사용하며,
        // RMI를 통해서 리모트 객체를 사용한다.
        
        ...
        return personList;
     }
     ...
  }

getPersonAtOffice() 메소드가 내부적으로 JDBC API와 RMI API를 사용한다고 할 경우 예외와 관련된 특별한 처리를 하지 않는다면 위 코드와 같이 throws 구문에 메소드 내부에서 발생할 수 있는 모든 예외 타입을 명시해주어야 한다.

이제 클라이언트 티어의 요청을 처리하는 서버 티어를 살펴보자. 서버 티어는 클라이언트 티어로부터 요청이 발생할 때 마다 비즈니스 로직 부분을 사용하게 되며, getPersonAtOffice() 메소드 역시 사용하게 될 것이다. 따라서 서버 티어에는 다음과 같은 형태의 코드가 필요할 것이다.

  public void someMethodOfServerTier( .. ) throws SQLException, RMIException {
     ...
     SomeBean sb = ...;
     ...
     Person[] employee = sb.getPersonAtOffice("o-1-00001");
     ...
  }

물론 위 코드를 다음과 같이 할 수도 있다.

  public void someMethodOfServerTier( .. ) {
     ...
     SomeBean sb = ...;
     try {
        ...
        Person[] employee = sb.getPersonAtOffice("o-1-00001");
        ...
     } catch(SQLException ex) {
        // 알맞은 예외 처리
        ...
     } catch(RMIException ex) {
        // 알맞은 예외 처리
        ...
     }
     ...
  }

먼저 첫번째 경우처럼 서버 티어의 메소드에서도 throws 구문을 사용하여 내부에서 발생하는 예외를 단순히 전달한다고 해보자. 이 경우 서버 티어에서 발생할 수 있는 모든 예외(즉, 비즈니스 로직 부분에서 발생할 수 있는 예외를 포함한 모든 예외)가 클라이언트 티어에 전달될 것이다. 따라서 클라이언트 티어는 다음과 같은 형태의 코드를 취할 수 밖에 없게 된다.

  try {
     ...
     serverTier.someMethodOfServerTier();
     ...
  } catch(SQLException ex) {
     ...
  } catch(RMIException ex) {
     ...
  }

단순하게 생각해보면 이렇게 비즈니스 로직에서 발생한 예외를 최종 사용자인 클라이언트 티어에서 처리하는 것이 잘못된 것은 아니다. 하지만, 좀더 사고의 틀을 넓혀서 서버 티어가 다수의 비즈니스 로직 객체를 사용하고 또한 클라이언트 티어가 다수의 서버 티어에 존재하는 객체를 사용한다고 해 보자. 이 경우 많은 비즈니스 객체에 존재하는 메소드는 같은 타입의 예외를 발생하기도 하고 서로 다른 타입의 예외를 발생하기도 한다. 이 경우 서버 티어에서 단순히 발생한 예외를 클라이언트 티어에 전달한다고 할 경우 클라이언트 티어는 다음과 같은 형태의 코드를 갖게 된다.

  try {
     ...
     serverTier1.someMethod();
     serverTier2.someMethod2();
     ...
  } catch(SQLException ex) {
     ...
  } catch(RMIException ex) {
     ...
  } catch(SomeException ex) {
     ...
  } catch( .. ) {
     ...
  } finally {
     ...
  }

위 코드에 어떤 문제점이 있는 지 쉽사리 감이 오지 않을 수도 있다. 그런 회원들을 위해 n-티어에서 예외를 단순히 다음 티어로 전달할 경우의 문제점을 하나씩 추려내 보자.

첫번째 문제점 - 각각의 도메인은 서로 다른 부분에 관심이 있다!

첫번째 문제점은 n-티어로 구성되었든 n-레이어로 구성되었든지 간에 각 계층은 특정한 도메인에만 관심을 갖는다. 예를 들어, 앞의 3-티어 형태의 구조에서 클라이언트 티어는 오직 클라이언트 티어에서 처리해야 하는 내용에만 관심이 있을 뿐 비즈니스 로직 부분에서 어떤 예외가 발생하는 지에 대해서는 별다른 관심을 갖지 않는다. 단지, 서버 티어가 올바르게 실행되는 지 또는 올바르게 실행되지 않았는지에 대한 여부만 알면 된다.

예를 들어, 서버 티어에서 Class.forName()을 사용하여 특정한 클래스를 로딩한다고 해 보자. forName() 메소드는 classNotFoundException, ExceptionInInitializerError 등의 예외가 발생할 수 있다. 하지만, 클라이언트 티어는 이 모든 예외가 발생했는 지의 여부에 대해서는 알고 싶지 않을 것이다. 단지, 서버 티어가 실행되는 도중에 내부적으로 어떤 예외가 발생했다는 정도의 내용을 알고 싶어할 뿐이다.

두번째 문제점 - High Coupling

소프트웨어 공학의 주요 규칙 중에 Low Coupling 이라는 것이 존재한다. 이 말은 각 클래스 또는 각 컴포넌트, 좀더 확장하면 각 도메인 간에 의존 정도를 최소화해야 한다는 것을 의미한다. 이 규칙은 n-티어/n-레이어 구조에도 적용된다. 각 티어간 또는 각 레이어 간에 연관성이 높아지면 높아질 수록 각 티어나 레이어의 모듈화 정도는 낮아지게 되며, 따라서 재사용 가능성은 그 만큼 떨어지게 된다.

그런데, 앞에서와 같이 첫번째 티어에서 발생한 모든 예외를 그대로 두번째 티어로 던지게 되면 그 만큼 클라이언트 티어는 서버 티어에 대한 많은 내용을 알아야 서버 티어를 사용할 수 있게 된다. 심지어 비즈니스 로직에서 발생하는 예외에 대한 내용까지 알아야 하는 경우도 있다. 즉, 클라이언트 티어는 그 만큼 서버 티어에 의존적이 되는 것이다. 만약 클라이언트 티어를 사용하는 또 다른 티어가 존재한다면? 각 티어 간의 의존성은 더욱 더 심화될 것이다.

n-티어/n-레이어 구조에서의 예외 처리

앞에서 살펴본 문제점들의 해결 방법은 각 티어 만의 예외를 발생하도록 예외 처리를 하는 것이다. 예를 들어, RMI를 생각해보자. RMI 클라이언트(스텁) 부분은 RMI 서버(스켈레톤)에서 어떠한 예외가 발생하는 지에 상관없이 오직 RMIException 만을 처리하면 된다. 즉, RMI 서버는 내부에서 어떠한 예외가 발생하든지간에 RMI 클라이언트에는 RMIException 만을 던지는 것이다. EJB의 경우에도 EJBException을 발생함으로써 EJB 컴포넌트를 사용하는 클라이언트는 EJB 컴포넌트 내부에서 어떤 예외가 발생하는 지 알 필요없이 EJBException 만을 처리하면 되도록 하고 있다.

RMI나 EJB의 경우에서처럼 n-티어나 n-레이어 구조를 갖는 어플리케이션에서 역시 각각의 티어/레이어는 그 층만의 예외를 발생하도록 설계하는 것이 좋다. 예를 들어, 웹 사이트의 회원을 관리해주는 역할을 담당하는 레이어가 있다고 해 보자. 그 레이어는 MemberManager와 같이 회원 관리를 하기 위한 클래스가 존재할 것이며, 그 클래스는 새로운 회원을 추가할 수 있는 addNewMember()와 같은 메소드가 정의되어 있을 것이다. 이때, addNewMember()는 새로운 회원을 추가하는 데 실패할 수도 있다. 예를 들면, 커넥션 풀로부터 데이터베이스 커넥션을 구하는 데 실패할 수도 있고 이미 사용중인 아이디를 새로 추가할 회원의 아이디를 사용해서 발생하는 SQLException도 발생할 수 있다. 이런 경우 addNewMember()는 다음과 같은 형태의 코드를 갖도록 해야 한다.

class MemberManager .. {

   public void addNewMember(MemberData data) throws AddMemberException {
      try {
         //
      } catch(CannotGetConnectionException ex) {
         throw new AddMemberException(ex.getMessage());
      } catch(SQLException ex) {
         throw new AddMemberException(ex.getMessage());
      } finally {
         ...
      }
   }
}

위 코드를 보면 addNewMember() 메소드는 내부에서 발생하는 어떠한 예외도 그대로 throw 하지 않는다. 대신 발생한 예외들을 AddMemberException 예외로 변환한 후에 AddMemberException 예외를 throw 한다. 이렇게 함으로써 MemberManager 클래스의 addNewMember() 메소드를 사용하는 게층 내지 레이어는 여러 타입의 예외를 처리하지 않아도 되며 오직 AddMemberException 예외만을 처리하면 된다. 또한 AddMemberException 예외는 회원을 추가하는 과정에서 에러가 발생했다는 것을 나타내므로 SQLException 예외에 비해 더 쉽게 예외의 의미를 알 수 있게 된다.

결론

이 글에서 설명한 예외 처리 방식은 Convert Exceptions 패턴을 사용한 것이다. Convert Exceptions 패턴을 사용하여 예외를 처리하게 되면 다음과 같은 결과를 얻을 수 있게 된다.

  • 메소드는 관련된 문제 영역(Problem Domain을 의미하는데, 여기서 Domain은 특정한 분야 내지 영역을 의미한다고 볼 수 있다)과 관련없는 예외를 throws 하기 보다는 catch 함으로써 그 메소드를 호출한 객체가 관련되어 있지 않은 도메인에 대한 의존성을 갖도록 하지 않는다.
  • 도메인과 관련되지 않은 예외를 처리할 필요가 없기 때문에 이해하기가 쉬우며, 더욱 응집도(cohesive)가 높아지고, 또한 유지보수하는 것 역시 단순해지고 쉬워진다.
물론, 모든 경우에 있어서 Convert Exceptions 패턴을 사용하는 것이 올바른 것은 아니겠지만, 최소한 n-티어/n-레이어 구조를 갖는 어플리케이션의 경우 Convert Exceptions 패턴을 사용함으로써 좀더 이해하기 쉬운 그리고 좀더 객체 지향적인 개발을 할 수 있게 될 것이다.

+ Recent posts