Exception Handling 3가지 기법
자바의 예외를 try - catch 블럭으로 잡았다고 해서 끝이 아니다.
예외가 발생하였으면 코드를 수정하여 문제점을 고쳐야 되는 것은 맞지만, 예상할 수 없는 예외인 경우 회피를 하거나 복구 동작을 하는 등 예외를 핸들링하여 처리하는 로직이 필요하다.
예상치 못한 예외가 발생하면 이를 단순히 catch문으로 잡아 에러 메세지를 출력하는 것을 떠나서, 실무에서 어떻게 예외를 효과적으로 처리하는지 방법에 대해 알아보도록 하자.
예외를 처리하는 방법에는 예외 복구, 예외 처리 회피, 예외 전환 방법이 있다.
1. 예외 복구
- 예외 상황을 파악하고 문제를 해결해서 정상 상태로 돌려놓는 방법
- Exception이 발생하여도 어플리케이션은 정상적으로 동작
- 반복문을 이용하여 예외가 발생하더라도 일정 수만큼 재시도를 하여 예외 복구를 시도
- 만일 최대 재시도 횟수를 넘기게 되는 경우 예외를 발생시키거나 혹은 다른 플랜으로 문제를 해결을 시도
- 네트워크에 연결하거나 하는 등의 로직에서 유용
final int MAX_RETRY = 100;
public Object someMethod() {
int maxRetry = MAX_RETRY;
while(maxRetry > 0) {
try {
// ...
return; // 성공시 바로 리턴
} catch(Exception e) {
// 예외 발생시 로그를 출력
} finally {
// 리소스 반납 및 정리 작업
}
--maxRetry; // 실패하면 1000번 반복
}
// 최대 재시도 횟수를 넘기면 직접 예외를 발생
throw new RetryFailedException();
}
위에서 예외 복구는 예외 상황을 파악하고 문제를 해결해서 정상 상태로 돌려놓는 방법이라고 설명했다.
public sendFile(String fileName)() {
File file;
try {
file = FileFindService.find(fileName);
} catch (FileNotFoundException e){
// 기본 파일을 찾아서 전송한다.
file = FileFindService.find("default.png");
}
send(file);
}
그러나 대부분의 상황에서 예외를 복구할 수 있는 경우는 거의 없기 때문에 자주 사용되지 않는다.
예를 들어, 유니크해야 하는 이메일 값이 중복돼서 SQLException이 발생할 경우 복구할 수 있는 방법은 없다. 이런 경우에는 RuntimeException을 발생시키고 입력을 다시 유도하는 것이 현실적인 방법이다.
그러나 예외 복구를 사용할 일이 생긴다면 위처럼 예외를 복구하는 방식보다는 아래처럼 코드의 흐름으로 제어하는 것이 좋다.
public void sendFile(String fileName){
if(FileFindService.existed(filename)){
// 파일이 있는 경우 해당 파일을 찾아서 전송한다.
send(FileFindService.find(fileName));
}else{
// 파일이 없는 경우 기본 이미지를 전송한다.
send(FileFindService.find("default.png"));
}
}
2. 예외 처리 회피
- 예외 처리를 직접 담당하지 않고 호출한 쪽으로 던져 회피하는 방법 (throws)
- 다만 그렇게 추천 되어지는 방법은 아니다
- 호출한 쪽에서 예외를 처리하는 것이 바람직 할때 사용
- 해당 로직에서 예외가 발생했을 때 처리하지 않고 회피하는 것이 최선의 방법일 때만 사용
public void add() throws SQLException {
try {
// ... 생략
} catch(SQLException e) {
e.printStackTrace(); // 로그만 출력하고
throw e; // 다시 날린다
}
}
예외 처리를 직접 담당하지 않고 호출한 쪽으로 던져 회피하는 방법이다. 긴밀하게 역할을 분담하고 있는 관계가 아닐 경우 예외를 던지는 것은 무책임한 방법이다.
public class ObjectMapperUtil {
private final ObjectMapper objectMapper = new ObjectMapper();
// 예외처리를 throws를 통해서 위임하고 있습니다.
public String writeValueAsString(Object object) throws JsonProcessingException {
return objectMapper.writeValueAsString(object);
}
// 예외처리를 throws를 통해서 위임하고 있습니다.
public <T> T readValue(String json, Class<T> clazz) throws IOException {
return objectMapper.readValue(json, clazz);
}
}
writeValueAsString, readValue 메서드는 Checked Exception을 발생시키는 메서드다. 반드시 예외를 처리를 진행해야 한다.
해당 메서드의 테스트 코드다.
그러나 예외 처리를 상위로 던져버리기 때문에 메서드를 사용하는 곳에서 다시 throw를 하던지 예외를 try-catch 하든지 해야 한다. 이렇게 무의하고 반복적인 예외를 던지는 것은 좋지 않다.
3. 예외 전환
- 위의 예외 처리 회피와 비슷하게 메서드 밖으로 예외를 던지지만, 그냥 무작정 던지지 않고 적절한 예외로 전환해서 넘기는 방법이다.
- 조금 더 명확한 의미로 전달되기 위해 적합한 의미를 가진 예외로 변경해서 throws 하는 것이라 보면 된다.
- 이외에도 예외 처리를 상위 클래스로 단순하게 합치기 위해 포장(wrap) 하는 방법도 일컫는다.
예시 1
// 조금 더 명확한 예외로 던진다.
public void add(User user) throws DuplicateUserIdException, SQLException {
try {
// ...
} catch(SQLException e) { // SQLException 예외가 발생하면
if(e.getErrorCode() == MysqlErrorNumbers.ER_DUP_ENTRY) { // 그리고 정확히 어떠한 에러인걸 알았다면
throw DuplicateUserIdException(); // 상위 클래스가 아닌 정확한 예외클래스를 던진다
}
else {
throw e;
}
}
}
// 예외를 단순하게 포장한다.
public void someMethod() throws EJBException {
try {
// ...
}
catch(NamingException | SQLExceptionne | RemoteException e) { // 상세한 예외가 들어와도
throw new EJBException(e); // 상위 예외클래스로 퉁쳐서 포장해서 던진다
}
}
예시 2
public String writeValueAsString(Object object) {
try {
return objectMapper.writeValueAsString(object);
} catch (JsonProcessingException e) {
throw new JsonSerializeFailed(e.getMessage());
}
}
public <T> T readValue(String json, Class<T> clazz) {
try {
return objectMapper.readValue(json, clazz);
} catch (IOException e) {
throw new JsonDeserializeFailed(e.getMessage());
}
}
보통 예외 처리를 하기 위해서 위와 같이 RuntimeException을 상속받은 클래스(UnChecked Exception)로 전환해 던진다.
Checked Exception을 Unckecekd Exception으로 던지고 있기 때문에 메서드를 사용하는 곳에서는 아무런 예외처리를 진행하지 않아도 된다.
물론 해당 예러가 왜 발생했는지에 대해서 에러 메시지 뿐만이 아니라 더욱 구체적인 정보를 전달해주는것이 더 좋다.
정리
- 예외 복구 전략이 명확하고 복구가 가능하면 Checked Exception을 try-catch로 잡아서 예외 복구를 하거나, 코드의 흐름으로 제어하는 것이 좋다.
- 그러나 이러한 경우는 흔하지 않기 때문에 Checked Exception이 발생하면 더 구체적인 UnChecked Exception을 발생시키고 예외에 대한 메시지를 명확하게 전달하는 것이 효과적이다.
- 무책임하게 상위 메서드에 throw로 예외를 던지는 행위를 하지 않는 것이 좋다. 상위 메서드들의 책임이 그만큼 증가하기 때문이다.
- Spring에서 Checked Exception은 기본 트랜잭션에 속성에서 Rollback을 진행하지 않는다.
Exception Handling 주의 사항
1. catch에는 로깅, 복구 등의 로직을 추가하기
- 예외를 아무 로직 없이 catch만 하는 것은 바람직 하지 않다.
- 또한 catch에 단순히 throw만 하는 것도 바람직 하지 않다.
- 로그를 출력하거나, 문제를 원상 복구 시키는 로직을 첨가하는 등 catch만 수행하지 않고 해당 예외에 대한 처리를 해주어야 한다.
try {
// Exception 발생 가능 로직
} catch(Exception e) {
}
try {
// Exception 발생 가능 로직
} catch(Exception e) {
throw e;
}
2. 예외 Stack을 남겨 추적, 유지보수성 높이기
- Exception의 추적성과 유지보수 성을 높이기 위해서, e.toString() 이나 e.getMessage()로 마지막 예외 메세지만 남기기보다, 전체 Exception Stack을 다 넘기는 편이 좋다.
- 대표적인 slf4j 라이브러리의 log.error() 역시 e.printStackTrace()처럼 Exception의 stack도 남긴다.
3. Logging Framework 사용하기
- e.printStackTrace() 대신 LoggingFramwork(slf4j, commons logging, log4j, logback)를 활용하자.
- Logging Framework를 이용하면 로그 파일을 쪼갤 수 있고, 여러 서버의 log를 한곳에서 모아서 보는 System을 활용할 수도 있다.
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class WelcomeWebController {
private final Logger log = LoggerFactory.getLogger(getClass());
public void welcomeWeb(ModelMap model) {
try {
// ...
} catch (ArrayIndexOutOfBoundsException ae) {
//System.out.println(ae + "배열의 길이를 확인해!");
log.info(ae + "배열의 길이를 확인해!");
} catch (NullPointerException ne) {
//System.out.println(ne + "null있다.");
log.info(ne + "null있다.");
} catch (Exception e) {
//System.out.println(e + "그 외 모든 오류들");
log.debug(e + "그 외 모든 오류들");
} finally {
//System.out.println("welcomeWeb Controller에서 오류");
//System.out.println("오류가 나던 안 나던 출력");
log.debug("welcomeWeb Controller에서 오류");
log.debug("오류가 나던 안 나던 출력");
}
}
}
참고