예외처리

1

Spring

목록 보기
1/12
post-thumbnail

예외 처리

무의미하고 무책임한 예외처리

옛날옛날시절 자바에서는 SQLException(DB관련 오류)이라는 녀석을 항상 메서드()throw SQLException{...} 해주어야 했는데 JDBCTemplete이 등장하면서 사라져버렸다.

JDBCTemplete이 뭘 했길래 갑자기 SQLException이라는 녀석이 어디로 갔는지 알아보기 전에 개발자들이 많이 저지르는 초난감한 예외처리 코드를 살펴보자

try{ ... 

} catch(SQLException e){

}

위와 같은 코드는 예외 발생을 무시해버리고 “정상적인 상황인것 처럼 다음 라인으로 넘어가겠다”는 분명한 의도가 있는것이 아니라면 작은 프로젝트여도 절대 사용하지말자.

예외가 발생하면 그것을 catch 블록을 사용해서 잡는것 까지는 좋지만 예외를 아무일도 없었다는듯 넘겨버리는 행위는 매우 위험한 일이다. 왜냐하면 프로그램 실행 중 어디선가 오류가 있어서 예외가 발생했는데 그것을 무시하고 진행해버린다는 의미이다.

  • 좀 더 쉽게 말하자면 횡단보도를 건너다 차에 치였는데 병원에 가지 않고 그대로 갈 길 가는것과 유사한 행위라고 보면된다.

2번째 초난감 예외처리 코드를 보자

try{ ...

} catch(SQLException e){

System.out.println(e);

}

===== 또는 ===== 

try{ ...

} catch(SQLException e){

e.printStackTrace( );

}

진짜 어이가 없긴하다. 이런말 하면 안되지만 솔직히 우리 학교 자바 시간에 이런식으로 예외 처리하는것을 몇 번 봐왔다.(물론 쌤들이 시간이 없어서 그렇게 예외처리 한거일거다. 그렇게 믿고있다.)

아직 예외처리를 잘 모른다면 무엇이 문제인지 눈에 잘 들어오지 않을 수 있다.

하지만 이는 첫 번째 경우와 같이 매우 위험한 코드이다.

기억하자. 예외를 처리할 때 반드시 지켜야 할 핵심 원칙은 한 가지다.

  • 모든 예외는 적절하게 복구되던지 아니면 작업을 중단시키고 개발자에게 분명하게 전달(통보)되어야 한다.

자 다시한번 교통사고로 예를 들겠다.

  • 이번엔 무단횡단으로 하자. 무단횡단을 하다가 차에 치였다. 그러면 우리는 다른 사람들에 의해 조치를 취해져야한다. 하지만 위와 같은 상황은 사람들이 쓰러져 있는 내 주변에 내가 차에 치였다고 적혀있는 표지판을 세워 놓기만 하는것과 같다. 이러한 조치가 취해졌을 때 내가 살 수 있는 방법은 119가 하루종일 내가 무단횡단한 도로를 보고있는 수 밖에 없다.

그렇다. 개발자는 항상 콘솔창을 보고 있을 수 없다. 그렇게 한가한 개발자는 없으니까 결국 콘솔에 출력되는 에러 코드를 보지 못하면 결국 첫번째 상황과 동일한 상황이다.

자 이제 여기서 SQLException이 나타나는 이유에 대해 잠시 설명하겠다.

  • SQL의 문법 에러
  • DB에서 처리할 수 없을 정도로 데이터 액세스 로직의 심각한 버그
  • 서버가 죽거나 네트워크가 끊김

위와 같은 상황은 매우 심각한 상황이다. 아직도 이런 상황들을 위와 같은 예외처리로 작성하고 싶다면 진지하게 프론트엔드 쪽으로 진로를 재설정해보자.

여태 너무 안좋은 예외처리만 봐왔다. 그나마 나은 예외처리를 보자

try{ ...

} catch(SQLException e){

e.printStackTrace( );

System.exit(1);

}

이렇게 만든 예외가 좋다는것이 아니라 예외를 무시하거나 잡아먹는 코드, 즉 무의미 무책임한 코드를 만들지 말라는 뜻이다. 만약 예외를 잡아 조치를 취할 수 없는 상황이라면 차라리 메소드에 throws Exception을 선언해서 메서드 밖으로 던지고 자신을 호출한 코드에 예외처리 책임을 전가하자

무의미 무책임 예외처리 코드를 작성할 바엔 throws Exception을 선언하라고 했지만 이런 무책임한 throws도 심각한 문제점이 있다.

자신이 사용하려고 하는 메서드에 throws Exception이 선언되어 있다고 가정해보자. 그런 메서드 선언에서는 의미 있는 정보를 얻을 수 없다. 정말 무엇인가 실행 중에 예외적인 상황이 발생할 수 있는지 아니면 자신이 습관적으로 붙은 throws Exceoption인지 말이다.

결국 throws Exception을 사용하면 적절한 처리를 통해 복구될 수 있는 예외 상황도 제대로 다룰 수 있는 기회를 박탈당한다는 문제점이 있다.

결론 : 위의 두 가지의 나쁜 습관은 어떤 경우에도 용납하지 말자. (무의미 무책임 예외처리, throws Exception → 테스트 작성할 때 정도는 괜찮다고 생각함)

예외의 종류와 특징

그렇다면 예외를 어떻게 다루어야 할까 ?

예외처리에 관해서는 자바 개발자들 사이에서도 오랫동안 많은 논쟁이 있었다고 한다.

가장 큰 이슈는 체크 예외라고 불리는 명시적인 처리가 필요한 예외를 사용하고 다루는 방법이다.

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

  • Error
  • Exception과 체크 예외
  • RuntimeException과 언체크/런타임 예외

Error

첫째는 java.lang.Error 클래스의 서브 클래스들이다. 에러는 시스템에 뭔가 비정상적인 상황이 발생했을 경우에 사용된다. 그래서 주로 자바 가상 머신(JVM)에서 발생시키는 것이고 코드 상에서 이러한 에러를 잡으려고 하면 안된다. 잡기 귀찮을뿐더러 애초에 catch로 잡아봤자 할 수 있는게 없다 ㅋ..

Exception과 체크 예외

자바에서 Exception의 구조가 어떻게 생겼는지 알고있는가 ?

java.lang.Exception 클래스와 그 서브클래스로 정의되는 예외들은 에러와 달리 개발자들이 만든 애플리케이션 코드 작업 중에 예외상황이 발생했을 경우에 사용된다.

Exception 클래스는 다시 체크 예외와 언체크 예외로 구분된다.

  • 체크 예외 - RuntimeException 클래스를 상속받지 않음
  • 언체크 예외 - RuntimeException 클래스를 상속받음

일반적으로 예외라고 하면 Exception 클래스의 서브클래스 중에서 RuntimeException을 상속하지 않은 것 만을 말하는 체크 예외라고 생각해도 좋다.

체크 예외가 발생할 수 있는 메서드를 사용할 경우 반드시 예외를 처리하는 코드를 함께 작성해야 한다. 즉 사용할 메서드가 예외를 던진다면 catch로 잡던지 throws를 정의해서 메서드 밖으로 던져야한다. 그렇지 않으면 컴파일 에러가 발생한다.

RuntimeException과 언체크/런타임 예외

먼저 예외를 처리하는 일반적인 방법을 살펴보고 나서 효과적인 예외처리 전략을 생각 해보자

  • 예외 복구 - 예외상황을 파악하고 문제를 해결해서 정상 상태로 돌려놓는 방법
  • 예외 처리 회피 - 예외처리를 자신이 담당하지 않고 자신을 호출한 쪽으로 던져버리는 방법
  • 예외 전환 - 예외를 복구해서 정상적인 상태로 만들수 없을 때 예외를 메서드 밖으로 던지는 방법

예외 복구

예를 들어 보자

사용자가 요청한 파일을 읽으려고 시도했는데 해당 파일이 존재하지 않거나 찾을 수 없을 때 IOException이 발생했다. 이때는 사용자에게 상황을 알려주고 다른 파일을 이용하도록 안내해서 예외 상황을 해결할 수 있다.

예외로 인해 기본 작업 흐름이 불가능하다면 다른 작업 흐름으로 자연스럼게 유도해주는 것이다.

이런 경우 예외상황은 다시 정상으로 돌아오고 예외를 복구했다고 볼 수 있다.

단, IOException 에러 메시지가 사용자에게 그냥 던져지는 것은 예외 복구라고 볼 수 없다. 예외가 처리됐으면 비록 기능적으로는 사용자에게 예외상황으로 비쳐도 애플리케이션에서는 정상적으로 설계된 흐름을 따라 진행되어야 한다.

네트워크가 불안해서 가끔 서버에 접속이 잘 안되는 열악한 환경에서 서비스를 제공하는 애플리케이션에서 원격 DB 접속에 실패해서 SQlException이 발생하는 경우에 재시도를 해볼 수 있다.

예외처리 코드를 강제하는 체크 예외들은 이렇게 예외를 어떤 식으로든 복구할 가능성이 있는 경우에 사용한다.

int maxretry = MAX_RETRY;
while(maxretry --> 0){
	try{ ...
	} catch(SomeException e){
		// 로그 출력, 정해진 시간 대기
	}finally {
		// 리소스 반납, 정리 작업
}

예외처리 회피

예외처리 회피는 throws 문으로 선언해서 예외가 발생하면 알아서 던져지게 하거나 catch 문으로 일단 예외를 잡은 후에 로그를 남기고 다시 예외를 던지는 것이다.

빈 catch 블록으로 잡아서 예외가 발생하지 않은 것처럼 만드는 경우는 드물지만 특별한 의도를 가지고 예외를 복구했거나 아무 개념 없어서 그런 것이지 회피한 것이라고 볼 수 없다.

예외처리를 회피하려면 반드시 다른 오브젝트나 메서드가 예외를 대신 처리할 수 있도록 아래와 같이 던져 주어야한다.

public void add( ) throws SQLException{
	// JDBC API
}

또는

public void add() throws SQlException {
	try {
		// JDBC API
	catch(SQLException e){
		// 로그 출력
		throw e;
	}
}

즉, main( ) → sub1 → sub2 → doSomething 과 같은 순서로 호출을 할 때 예외를 회피하였기 때문에 반드시 상위 메서드에서 예외를 처리 해야한다.

// 위에서 설명했듯이 e.printStackTrace( );으로 예외를 처리하면 안된다.

예외를 회피하는 것은 예외를 복구하는 것처럼 의도가 분명해야한다. 콜백/템플릿처럼 긴밀한 관계에 있는 다른 오브젝트에게 예외처리 책임을 분명히 지게 하거나, 자신을 사용하는 쪽에서 예외를 다루는게 최선의 방법이라는 분명한 확신이 있어야 한다.

예외 전환

예외 전환을 사용하는 경우는 크게 2가지이다.

  • 내부에서 발생한 예외를 그대로 던지는 것에 의미를 부여할 수 없을 때
  • 체크 예외를 언체크 예외로 전환하여 쉽고 단순하게 만들기 위해

내부에서 발생한 예외를 그대로 던지는 것에 의미를 부여할 수 없을 때

public void add(User user, Long id) throws DuplicateUserException, SQLException{
	try { userService.join(user);
	} catch(SQLException e){
		if(userService.findUser(id) != null){
	throw new DuplicateUserException;
	}else {
		throw e;
	} 
}

위의 코드는 회원가입 기능을 담당하는 메서드이다. 여기서의 예외상황은 당연히 SQLException이다. 그런데 만약 서비스 계층에서 SQLException이라는 예외를 받으면 어디서 예외가 발생한지 알기 쉽지 않다. 그래서 알기 쉬운 이름으로 예외를 전환하여 서비스 계층에서 이해하기 쉽도록 설계하는 것이 기본이다.

회원가입을 하는데 이미 동일한 회원이 있다면 기본 예외(SQLException) 대신 새로운 예외(DuplicateUserException)을 발생한다.

여기서 의문점이 들 수 있다. 왜 SQLException을 사용하면 안되는가 ?

이 답은 간단하다. 데이터 베이스에 사용자를 추가하려고 한 서비스 계층들에서는 왜 SQLException이 발생하였는지 쉽게 알 방법이 없다. 회원가입 아이디 중복같은 경우는 충분히 예상 가능하고 복구할 수 있는 예외 상황이다.

따라서 SQLException 보다는 더 이해하기 쉽고 발생 원인을 알 수 있는 방법으로 이런 방법을 채택하여 해결할 수 있다.

서비스 계층 오브젝트에서 SQLException의 원인을 해석해서 대응하는 것도 불가능하지는 않지만 특정 기술의 정보를 해석하는 코드를 비즈니스 로직에 두는 것은 SRP를 위반하며 바람직하지 않다.

따라서 예외는 기술에 독립적이며 의미가 분명한 예외로 전환해서 던져줄 의무가 있다.

체크 예외를 언체크 예외로 전환하여 쉽고 단순하게 만들기 위해

두번째 예외 전환 방법은 예외를 처리하기 쉽고 단순하게 만들기 위해 포장(wrap)하는 것이다. 좀만 풀어서 설명하자면 중첩 예외를 사용해 새로운 예외를 만들고 원인(cause)이 되는 예외를 내부에 담아서 던지는 방식과 같다.이렇게 설명해도 뭔말인지 알아듣기 어렵다는걸 인정한다. 코드를 보면서 이해해보자

try{ ...
} catch(NameException ne){
		throw new EJBException(ne);
} ...

이제는 이해할 수 있을것이다. 이 방법은 새로운 예외로 전환(throw new EJBException(ne))하여 쉽고 단순하게 만드는 것이다.

그렇다면 왜 체크 예외를 언체크 예외로 바꾸는가 ?

예외를 이러한 방식이으로 전환하는데는 이유가 있다. 체크 예외는 런타임 예외(RuntimeException) 클래스를 상속 받지 않은 클래스이며 예외 처리를 강제적으로 해주어야한다.(기억나지 않는다면 위에 예외 클래스 구조를 다시보고 오자)

하지만 언체크 예외는 런타임 예외 클래스를 상속 받아 예외 처리를 하지 않아도 언체크 예외 특성상 EJB는 시스템 익셉션으로 인식하고 트랜잭션을 자동으로 롤백해준다.

즉, 런타임 예외이기 때문에 EJB 컴포넌트를 사용하는 다른 EJB나 클라이언트에서 일일이 예외를 잡거나 다시 수고를 할 필요가 없다는 뜻이다.

반대로 애플리케이션에서 로직상 예외조건이 발견되거나 예외상황이 발생할 수도 있다. 이런것은 API가 던지는 예외가 아니라 애플리케이션 코드에서 의도적으로 던지는 예외이다.

이 떄는 언체크 예외보다는 체크 예외를 사용하는 것이 더 적절하다. 비즈니스적 의미가 있는 예외는 이에 대한 적절한 대응이나 복구 작업이 필요하기 때문이다.

하지만 SQLException을 계속 throws로 던진다고 무슨 의미가 있을까; 어차피 복구가 불가능한 예외라면 계속 붙잡고 있지말고 가능한 한 빨리 런타임 예외로 전환하여 다른 메서드를 작성할 때 불필요한 thorws를 없애는데에 시간을 사용하자.

이런 생각이 들었을지는 모르겠다. “우리가 어떻게 EJBException이 런타임 익셉셥인줄 알고 사용하냐; 다른 방법으로 체크 익셉션 → 언체크 익셉션으로 전환하는 방법은 없냐?” 라고 물으면 아주 그냥 대단하다고 생각한다.

public class DuplicateUserIdException extends RuntimeException { 
	public DubplicateUserIdException(Throwable cause){
		super(cause); 
}

위와 같이 선언이 가능하다 ; 무슨 의미인지는 다 알거라고 생각한다.

그렇다면 위의 클래스를 사용해보자

public void ... throws DuplicateUserIdException {
	try {
		.. user 정보 DB에 추가하는 코드
	} catch(SQLException e){
		throw new DuplicateUserIdException(e);
	}
}

위의 코드는 결국 개방 폐쇄원칙(OCP)을 잘 지킨 모범 사례이다.

기능의 확장을 다른 메서드를 구현하여 성립시키고 그 메서드를 가져다 쓰는 메서드는 코드를 수정하지 않아도 된다. 조금은 수정하긴 해야한다. 수정이라는것보단 지우는 것에 더 가깝지만 말이다.

위의 메서드를 정리해보자면 체크 익셉션인 SQLException 대신 언체크 익셉션인 RuntimeException인 DuplicateUserIdException을 만들고 그것을 사용하여 더 알기 쉬운 의미로 예외를 서비스 계층에 반환해주는 것이다.

즉 위의 2개는 예외 전환 방법 2가지를 다 만족한 매우 성공적인 예외 처리이다.

그래서 SQLException은 복구할 수 있는가 ?

먼저 생각해볼 사항은 SQLException(체크 예외)은 “과연 복구가 가능한 예외” 인가 이다. 마이바티스 기준 99%의 SQLException은 코드 레벨에서 복구할 수준이 아니다.

마이바티스에서 SQLException이 발생하는 이유는

  • SQL 문법상의 오류
  • DB 서버 다운
  • 네트워크 불안정

등등 미친 상황들 때문에 발생한다. 시스템 예외라면 당연히 코드 레벨에서 복구할 수 없다는 뜻이다.

관리자나 개발자에게 빨리 예외가 발생했다는 사실이 알려지도록 전달하는 방법밖에 없다.

예외 처리 정리

  • 예외를 무의미 무책임하게 throws 선언을 남발하지 마라
  • 예외는 복구하거나 예외처리 오브젝트로 의도적으로 전달하거나 적절한 예외로 전환해라
  • 좀 더 의미 있는 예외로 변경하거나, 불필요한 catch/throws를 피하기 위해 런타임 예외로 포장하는 두 가지 방법의 예외 전환이 있다.
  • 복구할 수 없는 예외는 가능한 한 빨리 런타임 예외로 전환해라
  • 애플리케이션의 로직을 담기 위한 예외는 체크 예외로 만든다.
  • JDBC의 SQLException은 대부분 복구할 수 없는 예외이므로 런타임 에러로 포장해라
  • SQLException의 에러 코드는 DB에 종속되기에 DB에 독립적인 예외로 전환될 필요가 있다.
  • 스프링은 DataAccessException을 통해 DB에 독립적으로 적용 가능한 추상화된 런타임 예외 계층을 제공한다.

reference

0개의 댓글