[자바] Exception
자바는 프로그램 실행 시점에 발생할 수 있는 오류를 크게 두 가지로 분류하였다.
- Error → 메모리 부족이나 스택오버플로우와 같이 JVM, 하드웨어 등 시스템의 문제로 발생하는 것으로 개발자가 처리할 수 없는 것.
- Exception →
- RuntimeException → Exception 중 컴파일러가 예외를 체크하지 않으며, 실행 중 발생할 수 있고 예외 처리가 필수가 아니며, 언체크 예외라고도 불림.
- Checked Exception → 컴파일러가 예외를 체크하여 체크 예외라고함.또한 체크 예외에는 예외를 잡아서 복구할 수 있는 예외보다 복구할 수 없는 예외가 더 많다. (ex.. SQLException)
- 반드시 예외 처리를 해야한다. 이 때 예외 처리 방법으로는 try-catch, throws와 같은 방법이 있는데 체크 예외를 사용하면 예를 들어 repository에서 생긴 SQLException을 서비스, 컨트롤러로 던짐으로써 모든 계층이 SQLException을 의존하게 되는 문제가 발생하고 따라서 Exception이 변경된다면 모든 계층에 수정이 필요하게 되며 가독성이 저하되는 문제가 발생하게 된다.
- 따라서 개발에서는 거의 언체크 예외를 사용한다.
체크 예외 | 언체크 예외 | |
확인 시점 | 컴파일 | 런타임 |
처리 여부 | 예외 처리 구문을 명시해야함 | 명시를 할 필요가 없음 |
종류 | RuntimeException을 제외한 모든 예외 | RuntimeException을 상속받은 모든 예외 |
참고
- 스프링 프레임워크가 제공하는 @Transactional 안에서 에러가 발생하면 체크 예외는 롤백이 되지 않고, 언체크 예외는 롤백이 된다.
- 만약 예외를 전환하는 경우 기존 예외를 포함해야 어떤 예외가 발생했는지 확인이 가능.
예외 처리 방법
- 예외를 직접 처리 - try ~ catch(..)
public class Test {
public static void main(String[] args) {
BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
try {
String input = br.readLine();
System.out.println(input); // 예외 발생 시 실행되지 않음.
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
다음과 같이 예외가 발생할 수 있는 상황은 try 블럭에다가 감싸고 만약 try 내부에서 예외가 발생한다면 catch 구문에 정의한 예외(하위 타입 예외 포함) 타입에 맞는 블럭 내부의 코드가 실행되면서 정상 흐름으로 처리된다.
여기서 catch 는 여러 개가 명시될 수 있는데 상위 타입을 먼저 명시하고 하위 타입을 명시하게 되면 컴파일 오류가 난다. → 상위에서 예외를 전부 잡아버리므로 정의한 하위 타입의 catch 블록으로 들어오지 않기 때문!
→ 하위 타입의 예외를 먼저 catch 선언 후 상위 예외를 선언해야 함.
public class Test {
public static void main(String[] args) {
BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
try {
String input = br.readLine();
System.out.println(input);
} catch (IOException e) {
// Exception이 IOException 상위 타입이므로 Exception이 먼저 위에 선언되면 컴파일 오류
throw new RuntimeException(e);
} catch (Exception e) {
System.out.println(e.getMessage());
}
}
}
- 예외를 처리하지 않고 던지기 - throws
public class Test {
public static void main(String[] args) throws IOException {
BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
String input = br.readLine();
System.out.println(input);
}
}
다음과 같이 메서드에서 발생할 수 있는 예외를 throws 예외타입 형식으로 밖으로 던질 수 있다. 마찬가지로 해당 타입과 하위 타입 모두 던질 수 있으며, 현재는 메인 메서드에서 실행하여 예외가 발생하면 예외 정보가 출력되면서 프로그램이 종료되지만, main 내부에서 service를 의존하고 service에서 예외가 발생한다면 해당 메서드를 호출한 main으로 예외가 넘어온다.
- 언체크 예외의 경우
위에서 말했다시피 언체크 예외의 경우에는 try-catch나 throws를 명시를 안해주어도 자동으로 예외가 해당 메서드를 호출하는 곳으로 던져진다.
물론 위처럼 명시를 하여도 상관없지만 대부분 생략
public class TestService {
public void exceptionCall() {
throw new RuntimeException();
}
}
public class Test {
public static void main(String[] args) {
TestService service = new TestService();
service.exceptionCall();
}
}
try-catch-finally
위 예시에는 만약 try 구문에서 예외가 발생한다면 그 밑의 구문은 실행되지 않고 바로 catch 문으로 넘어가는 문제가 있다. 예를 들면 데이터베이스 연결 작업을 하거나, 파일 입출력에 관련된 작업의 경우는 제한된 작업으로 자원을 사용하고 난 다음에는 해당 자원을 해제는 작업을 해야한다. 물론 위에서 catch 구문 하위에 실행되어야 하는 코드를 작성하는 식으로 할 수 있겠지만 이는 catch 구문에 없는 예외가 발생하면 실행되지 않는다는 문제가 있다.
이를 해결하기 위해 finally 기능이 제공된다.
public class Test {
public static void main(String[] args) {
MyResource resource = new MyResource();
try {
//resource.use();
resource.callException();
// 예외 발생 가능성 있는 코드
} catch (Exception e) {
System.out.println(e.getMessage());
} finally {
resource.release();
}
}
static class MyResource {
void use() {
System.out.println("리소스 사용");
}
void callException() throws Exception {
throw new Exception("error");
}
void release() {
System.out.println("리소스 해제");
}
}
}
출력 결과
error
리소스 해제
위 결과처럼 finally 구문은 무조건 호출되며 catch로 잡을 수 없는 예외 발생 시에도 호출된다. (finally 호출 후 throws)
try-with-resources
자바 7부터 지원하는 기능으로 위에서 말한 자원 사용 → 자원 반납 패턴이 반복되면서 이를 더 편리하게 사용할 수 있는 try-with-resources 기능을 지원한다.
위 코드를 try-with-resources 구문으로 바꾸면 아래와 같다.
public class Test {
public static void main(String[] args) {
try (MyResource resource = new MyResource()) {
//resource.use();
resource.callException();
} catch (Exception e) {
System.out.println(e.getMessage());
}
}
static class MyResource implements AutoCloseable {
void use() {
System.out.println("리소스 사용");
}
void callException() throws Exception {
throw new Exception("error");
}
void release() {
System.out.println("리소스 해제");
}
@Override
public void close() { // 리소스 해제 메서드 구현
release();
}
}
}
먼저 예외가 발생할 수 있는 자원에 AutoCloseable 인터페이스를 구현해야한다. 내부의 close 메서드에서 자원을 해제하는 메서드를 구현하면 된다. 그리고 try 괄호에 사용할 자원을 명시하고 finally는 생략해도 자동으로 close()가 호출된다.
주로 자원의 사용과 해제를 함께 묶어서 처리할 때 사용
장점
- 모든 리소스가 닫히는 것을 보장함. 개발자의 실수로 finally를 생략하거나, finally 블럭 안에서 자원을 해제하는 코드를 누락하는 문제 예방 가능
- 명시적으로 close() 호출이 필요 없어 코드가 간결해짐 (자원 해제는 try(…)에 선언한 역순으로 진행)
- try 내부에 사용할 자원을 명시하여 해당 자원의 스코프가 블럭 안으로 한정되어 유지보수가 용이
- 기존에는 try-catch-finally로 catch 이후에 자원을 반납했지만 try-with-resources 구문은 try 블럭이 끝나는 즉시 close() 를 호출하여 빠르게 자원을 반납
Suppressed Exception
throw는 되지만 무시되는 예외로 예외 처리 과정에서 추가적인 예외가 발생할 때 일어난다.
아래 코드를 보면 먼저 try 구문에서 첫 번째 예외가 발생한다. 이는 catch 구문에서 처리되어 RuntimeException이 발생하게 되고 마지막으로 finally 에서 NullPointerException을 던진다.
즉 3개의 예외가 발생하는데 출력 결과를 보면 다음과 같다.
public class SuppressedExceptionTest {
public static void main(String[] args) {
try {
throw new Exception("첫 번째 예외 발생");
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
throw new NullPointerException("두 번째 예외 발생");
}
}
}
출력 결과
Exception in thread "main" java.lang.NullPointerException: 두 번째 예외 발생
at exception.test.SuppressedExceptionTest.main(SuppressedExceptionTest.java:12)
즉 finally에서 발생한 마지막 예외만 콘솔에 출력되며 나머지 예외들에 대한 정보는 무시되는데 이를 Suppressed Exception이라고 한다.
하지만 보통 try 구문에서 일어난 예외가 주 예외이며 finally 에서 발생하는 예외는 부가 예외이다.
따라서 주 예외를 출력하고 부가 예외가 Suppressed Exception이 되어야 한다.
위 코드를 수정하려면 다음과 같이 해야한다.
public class SuppressedExceptionTest {
public static void main(String[] args) {
Throwable t = null;
try {
throw new Exception("첫 번째 예외 발생");
} catch (Exception e) {
t = e;
//throw new RuntimeException(e);
} finally {
try {
throw new NullPointerException("두 번째 예외 발생");
} catch (NullPointerException e) {
e.addSuppressed(t);
throw e;
}
}
}
}
출력 결과
Exception in thread "main" java.lang.NullPointerException: 두 번째 예외 발생
at exception.test.SuppressedExceptionTest.main(SuppressedExceptionTest.java:15)
Suppressed: java.lang.Exception: 첫 번째 예외 발생
at exception.test.SuppressedExceptionTest.main(SuppressedExceptionTest.java:9)
하지만 이는 코드의 가독성이 매우 떨어지게 된다.
이러한 문제 역시 try-with-resources 구문에서 해결되었다.
public class Test {
public static void main(String[] args) {
try (MyResource resource = new MyResource()) {
//resource.use();
resource.callException(); // 주 예외 발생
} catch (Exception e) {
System.out.println("주 예외: " + e.getMessage());
// Suppressed Exception
for (Throwable suppressed : e.getSuppressed()) {
System.out.println("추가 예외: " + suppressed.getMessage());
}
}
}
static class MyResource implements AutoCloseable {
void use() {
System.out.println("리소스 사용");
}
void callException() throws Exception {
throw new Exception("error");
}
void release() throws Exception {
System.out.println("리소스 해제");
throw new Exception("리소스 해제 중 예외 발생");
}
@Override
public void close() throws Exception {
release();
}
}
}
출력 결과
리소스 해제
주 예외: error
추가 예외: 리소스 해제 중 예외 발생
위 코드는 try에서 주 예외가 발생하였고 close()가 호출될 때 추가 예외가 발생된다. 이 때 finally와는 다르게 근본적으로 생겨난 첫 번째 예외가 먼저 출력이 되고, Suppressed 되는 예외는 close 메서드 에서 발생한 예외임을 알 수 있다.
즉 가장 중요한 첫 번째 예외에 추가 예외들도 내부의 Throwable[] 에 기록이 된다. 이 억제된 예외들을 첫 번째 예외에서 getSuppressed 메서드를 통해 확인 가능
또한 try 구문의 괄호 내부에 여러 개의 자원을 정의할 수도 있기에 try 구문 중첩없이 코드가 간결해지고 가독성이 좋아지며 개발자가 깜빡해도 쉽게 자원을 회수할 수 있다는 장점이 있다.
이 때문에 이펙티브 자바에서도 꼭 회수해야 하는 자원을 다룰 때는 try-finally 구문 대신 try-with-resources를 권장한다. (2장 아이템9 → try-finally보다는 try-with-resources를 사용하라!)