자바의 에러
프로그램 실행 중 어떤 원인으로 프로그램이 오작동하거나 비정상적으로 종료되는 경우가 있다. 이러한 결과를 초래하는 원인을 에러 또는 오류라고 한다. 에러의 종류는 두 가지로 나눌 수 있다.
- 컴파일 에러 : 컴파일 시 발생하는 에러. (잘못된 구문, 오타, 자료형 문제 등)
- 런타임 에러 : 실행 시 발생할 수 있는 에러.
예외 계층
자바의 런타임 에러. 즉 예외의 계층은 아래와 같다.
- Object : 모든 객체의 부모는 Object이다. 예외도 객체이므로 Object를 상속한다.
- Throwable : 최상위 예외이다.
- Error, Exception : 자바는 예외를 크게 Error와 Exception 객체로 구분한다. Error 타입 예외가 발생하면 매우 심각한 오류이므로 프로그램의 비정상 종료를 막을 수 없다. 그래서 애플리케이션 개발자는 Error 타입 예외를 잡지 않고 발생하도록 둔다. 반면에 Exception 타입 예외는 수습할 수 있는 오류로 프로그램의 비정상 종료를 막을 수 있다. 그래서 애플리케이션 로직에서 Exception 타입 예외를 캐치해 수습할 수 있다. 정리하면 애플리케이션에서 사용하는 예외는 Exception 타입 예외이다. (참고로 Error는 언체크 예외이다.)
- 체크 예외, 언체크 예외 : 자바의 예외 처리 문법 때문에 생긴 분류이다. RuntimeException 타입 예외를 제외한 Exception 타입 예외를 체크 예외라고 부르며 RuntimeException 타입 예외를 언체크 예외 또는 런타임 예외라고 부른다. 체크 예외는 컴파일러가 체크하며 언체크 예외는 컴파일러가 체크하지 않는다.
예외 기본 규칙
예외가 발생하면 잡아서 처리할 수 있다. 처리하지 않으면 예외는 밖으로(호출한 메서드로) 던져진다. 즉 throw로 예외가 발생되면 catch로 예외를 잡아서 처리하는데, 예외를 잡는 catch가 없으면 예외는 자동으로 호출 메서드로 던져진다. 아래 그림의 5번에서 예외를 잡아 처리했더니 더 이상 예외가 던져지지 않고, 애플리케이션이 정상 흐름으로 동작한다.
반면에 예외를 처리하지 못하면 계속 호출한 메서드로 예외가 던져진다.
예외를 잡거나 던질 때 지정한 예외뿐만 아니라 그 예외의 자식들도 처리된다. 예를 들어 Exception을 catch로 잡으면 그 하위 예외들도 모두 잡을 수 있다. 또한 Exception을 throws로 선언하면 해당 메서드는 Exception 뿐만 아니라 하위 예외들도 발생해 던질 수 있다는 의미이다.
만약 예외가 처리되지 못하고 계속 던져지면 어떻게 될까? 자바의 Main 메서드에서 예외가 던져지면 예외 로그가 출력되면서 프로그램이 종료된다. 톰캣 등의 서블릿 컨테이너를 사용하고 있다면 서블릿 컨테이너가 서블릿을 호출하므로 서블릿(또는 애플리케이션)에서 발생한 예외가 서블릿 컨테이너까지 전파된다. 서블릿 컨테이너까지 예외가 전파되면 서블릿 컨테이너의 예외 처리기가 예외를 잡아 처리한다. 주로 사용자에게 오류 페이지를 보여주는 식으로 처리한다. 때문에 예외가 WAS까지 전파되어도 처리되기에 WAS는 종료되지 않는다.
체크 예외, 언체크(런타임) 예외
체크 예외는 컴파일러가 체크하는 예외이다. 무엇을 체크한다는 건가? 체크 예외를 처리하지 않고 던질 시 컴파일러는 throws를 통한 예외 선언이 됐는지를 체크한다. 만약 되어있지 않다면 컴파일 에러를 뱉는다.
class Service {
Repository repository = new Repository();
//예외 처리
public void method1() {
try {
repository.run();
} catch (MyCheckedException e) {
System.out.println(e.getMessage());
e.printStackTrace();
}
}
//예외 던짐 시 - 예외 선언 필수
public void method2() throws MyCheckedException {
repository.run();
}
}
class Repository {
//예외 던짐 시 - 예외 선언 필수
public void run() throws MyCheckedException{
throw new MyCheckedException("MyCheckedException 발생");
}
}
//Exception을 상속받은 예외는 체크 예외가 된다.
class MyCheckedException extends Exception {
public MyCheckedException(String message) {
super(message);
}
}
체크 예외의 장점으로 예외가 누락되지 않도록 컴파일러가 체크한다는 장점이 있다. 하지만 이로 인해 생긴 단점으로 체크 예외는 사용되지 않는 추세다. 다른 언어들도 체크 예외를 제공하지 않는다.
체크 예외와 달리 언체크 예외를 던질 시 throws를 통한 예외 선언을 생략해도 된다. 즉 컴파일러가 체크하지 않는다.
class Service {
Repository repository = new Repository();
//예외 처리
public void method1() {
try {
repository.run();
} catch (MyUnCheckedException e) {
System.out.println(e.getMessage());
e.printStackTrace();
}
}
//예외 던짐 시 - 예외 선언 생략 가능
public void method2() {
repository.run();
}
}
class Repository {
//예외 던짐 시 - 예외 선언 생략 가능
public void run() {
throw new MyUnCheckedException("MyUnCheckedException 발생");
}
}
//RuntimeException을 상속받은 예외는 언체크 예외가 된다.
class MyUnCheckedException extends RuntimeException {
public MyUnCheckedException(String message) {
super(message);
}
}
정리하면 체크 예외와 언체크 예외의 차이는 예외를 던질 때 throws를 통한 예외 선언이 필수인가, 생략할 수 있는가이다.
예외 정보
예외 객체에는 예외에 대한 정보가 담겨있으며 getMessage()와 getStackTrace()를 통해 예외 정보에 접근할 수 있다.
- getMessage() : 예외 객체에 담긴 메시지를 얻을 수 있다.
- getStackTrace() : 예외 발생 당시 호출스택에 있었던 메서드의 정보를 얻을 수 있다.
getStackTrace()는 예외가 어디서부터 발생해 전파됐는지를 쉽게 알 수 있기 때문에 자주 사용된다.
언체크 예외를 사용하자.
체크 예외는 사용하지 않는 추세이며 다른 언어들도 체크 예외를 도입하지 않는다. 아래의 두 가지 문제 때문이다.
1. 대부분의 예외는 복구 불가능하다.
대부분의 예외는 애플리케이션 개발자가 복구할 수 없는 예외이다. 예를 들어 SQL 잘못된 문법 문제, 외부 라이브러리 코드 문제, DB 관련 문제로 SQLException이 발생해 던져질 수 있다. 하지만 개발자는 SQLException을 잡아 애플리케이션을 원래대로 복구할 수 없다. 예외가 발생하면 사용자에게 오류 화면을 띄어주고 빠르게 예외 원인을 파악해 같은 예외가 발생하지 않도록 조치할 뿐이다. 애플리케이션은 예외 처리 코드를 한 곳에 모으기 위해 예외 공통 처리기를 두는데 이 예외 공통 처리기에서 예외 로그를 남기고 사용자에게 오류 화면을 띄어주는 식으로 예외를 처리한다.
2. 불필요한 의존 관계가 생긴다.
앞서 말했듯 대부분의 예외는 복구가 불가능하다. 때문에 리포지토리, 서비스 계층은 예외를 던지며 예외 공통 처리기까지 예외가 던져진다. 이 과정에서 체크 예외는 throws를 통한 예외 선언이 필수기 때문에 예외에 알 필요가 없는 계층들이 예외에 의존하는 불필요한 의존 관계가 생긴다. 아래 그림에서 Repository 계층에서 발생한 SQLException, NetworkClient 계층에서 발생한 ConnectException는 오직 예외 공통 처리기에서 사용되는데 중간 계층인 Service와 Controller도 SQLException, ConnectException에 의존하게 되는 불필요한 의존 관계가 생긴다.
만약 Repository의 구현 기술이 바뀌어 SQLException이 아닌 다른 예외가 발생한다면 예외 공통 처리기뿐만 아니라 해당 예외를 의존하는 애플리케이션의 많은 부분이 수정되어야만 한다.
하지만 런타임 예외를 사용하면 아래 그림과 같이 예외 공통 처리기만 예외에 의존할 뿐 다른 계층은 예외에 의존하지 않는다. 런타임 예외는 throws를 통한 예외 선언이 필수가 아니기 때문이다. 그래서 Repository의 구현 기술이 바뀌어 다른 예외를 던져도 예외에 의존하는 건 예외 공통 처리기뿐이므로 예외 공통 처리기만 수정하면 된다.
정리하면 체크 예외를 사용할 시 컴파일러가 체크하기 때문에 예외의 누락을 방지할 수 있다. 하지만 불필요한 의존 관계가 늘어나 변경의 영향이 너무 커지기에 언체크 예외를 사용하는 추세이다. 언체크 예외를 사용해 필요한 경우에는 잡아서 처리하고, 그렇지 않으면 자연스럽게 던지도록 두어 앞단의 예외 공통 처리기가 처리하도록 만들 수 있다. 체크 예외가 마냥 나쁘다는 건 아니다. 반드시 처리해야 할 중요한 예외는 체크 예외로 사용해 예외의 누락을 방지하는 용도로 사용할 수 있다.
예외 전환, 문서화
최신 라이브러리는 언체크 예외를 사용하지만 오래된 자바의 라이브러리, 다른 라이브러리는 체크 예외를 사용한다. 때문에 체크 예외를 언체크 예외로 전환하는 작업이 필요하다. 아래 코드를 보자.
package hello.jdbc.exception.basic;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import java.sql.SQLException;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@Slf4j
public class UncheckedAppTest {
@Test
void unchecked() {
Controller controller = new Controller();
assertThatThrownBy(() -> controller.request())
.isInstanceOf(Exception.class);
}
@Test
void printEx() {
Controller controller = new Controller();
try {
controller.request();
} catch (Exception e) {
log.info("ex", e);
}
}
static class Controller {
Service service = new Service();
public void request() {
service.logic();
}
}
static class Service {
Repository repository = new Repository();
NetworkClient networkClient = new NetworkClient();
public void logic() {
repository.call();
networkClient.call();
}
}
static class NetworkClient {
public void call() {
throw new RuntimeConnectException("연결 실패");
}
}
static class Repository {
public void call() {
try {
runSQL();
} catch (SQLException e) {
throw new RuntimeSQLException(e);
}
}
private void runSQL() throws SQLException {
throw new SQLException("ex");
}
}
static class RuntimeConnectException extends RuntimeException {
public RuntimeConnectException(String message) {
super(message);
}
}
static class RuntimeSQLException extends RuntimeException {
public RuntimeSQLException() {
}
public RuntimeSQLException(Throwable cause) {
super(cause);
}
}
}
Repository에서 체크 예외가 발생하면 언체크 예외로 전환해 던진다. 이때 꼭 기존 예외를 포함해 전환해야 한다. 그렇지 않으면 기존의 발생한 체크 예외 정보(예외 종류, 메시지, 스택 트레이서 등)를 잃어버리기 때문이다.
문서화
언체크 예외는 누락할 수 있기 때문에 문서화가 중요하다. 문서화를 통해 언체크 예외가 누락되지 않도록 주의가 필요하다.
예) 문서에 예외 명시, JPA EntityManager
/** * Make an instance managed and persistent.
* @param entity entity instance
* @throws EntityExistsException if the entity already exists.
* @throws IllegalArgumentException if the instance is not an
* entity
* @throws TransactionRequiredException if there is no transaction when
* invoked on a container-managed entity manager of that is of type
* <code>PersistenceContextType.TRANSACTION</code>
*/
public void persist(Object entity);
예) 문서에 예외 명시 + 코드 명시 (코드에도 명시되어 있어 IDE로 확인하기 편하다)
/**
* Issue a single SQL execute, typically a DDL statement.
* @param sql static SQL to execute
* @throws DataAccessException if there is any problem
*/
void execute(String sql) throws DataAccessException;
'Java > Basic' 카테고리의 다른 글
Java 람다식 (0) | 2023.04.05 |
---|---|
Java 익명 클래스 (0) | 2023.04.04 |
Regex (0) | 2023.01.23 |
[JVM - 2] ClassLoader (0) | 2022.10.25 |
Java 정렬 (0) | 2022.09.07 |