프로젝트를 진행하면서 기능을 구현할 때 예외가 발생하는 경우가 종종 있었다.
그때는 마냥 throws를 이용해 예외를 던지거나 try catch를 이용하는 선에서 그쳤지, 예외를 어떻게 처리해야 하는지, 구조는 어떻게 되어있고, 각 예외의 차이는 무엇인지 하는 것에 대해서는 잘 알지 못하였다.
하지만 자바 개발자가 되려고 하는만큼 필수적인 역량중 하나인 예외 처리에 대해 알아보고자 한다.
- 예외는 잡아서 처리하거나, 처리할 수 없을 경우 외부로 던져야 한다.
- 예외를 잡거나 던질 때 해당 예외와 그 자식 예외도 함께 처리된다.
- cacth의 경우 그 자식 예외도 모두 잡히고,
- throws의 경우 그 자식 예외도 모두 던져진다.
코드로 확인해보자.
@Slf4j
public class CheckedTest {
// service에서 예외를 잡아서 처리했기 때문에 정상 흐름으로 반환되어
// 이후 코드도 정상적으로 처리를 진행한다.
@Test
void checked_catch() {
Service service = new Service();
service.callCatch();
log.info("예외 처리 완료");
}
@Test
void checked_throw() {
Service service = new Service();
// 이렇게 처리하면 예외를 처리하지 않았으므로 컴파일 에러가 발생함
// service.callThrow();
// 따라서 테스트에서는 아래처럼 처리를 해야함
Assertions.assertThatThrownBy(() -> service.callThrow())
.isInstanceOf(MyCheckedException.class);
}
/*
* Exception을 상속받은 예외는 체크 예외가 된다.
* */
static class MyCheckedException extends Exception{
public MyCheckedException(String message) {
super(message);
}
}
/*
* Checked 예외는
* 예외를 잡아서 처리하거나, 던지거나 둘 중 하나를 필수로 선택해야 한다.
* */
static class Service {
Repository repository = new Repository();
/*
* 예외를 잡아서 처리하는 코드
* */
public void callCatch() {
// 컴파일이 해당 예외처리 여부를 체크해주는 예외가 체크 예외
try {
repository.call();
} catch (MyCheckedException e) {
// 로그로 예외 처리할 때 exception을 스택트레이스로 출력할 때는 마지막 파라미터로 넣어주면 됨
log.info("예외 처리, message", e.getMessage(), e);
}
}
public void callThrow() throws MyCheckedException {
repository.call();
}
}
/*
* 체크 예외를 밖으로 던지는 코드
* 체크 예외는 예외를 잡지 않고 밖으로 던지려면 throws 예외를 메서드에 필수로 선언해야한다.
* */
static class Repository {
// 모든 예외는 잡아서 처리를 하거나 던져야 하므로 여기서는 던짐
public void call() throws MyCheckedException {
throw new MyCheckedException("ex");
}
}
}
static class MyCheckedException extends Exception{
public MyCheckedException(String message) {
super(message);
}
}
그럼 테스트를 확인해보자.
1. checked_catch()의 경우
예외를 처리해줬기 때문에 그 이후 로직이 정상적으로 처리된 것을 확인할 수 있다.
2. checked_throw()의 경우
Assertion를 이용해 예외가 발생한 것을 확인할 수 있다.
코드로 확인해보자.
@Slf4j
public class UncheckedTest {
/*
* RuntimeException을 상속받은 예외는 언체크 예외가 된다.
* 언체크 예외는 컴파일러가 체크하지 않는 예외를 말한다.
* 다만 throws를 선언해두면 중요한 예외의 경우 개발자가 ide를 통해 인지할 수 있다.
* */
static class MyUncheckedException extends RuntimeException{
public MyUncheckedException(String message) {
super(message);
}
}
@Test
void unchecked_throw() {
Service service = new Service();
service.callCatch();
Assertions.assertThatThrownBy(() -> service.callThrow())
.isInstanceOf(MyUncheckedException.class);
}
/*
* Unchecked예외는
* 예외를 잡거나, 던지지 않아도 된다.
* 예외를 잡지 않으면 자동으로 밖으로 던진다.
* */
static class Service {
Repository repository = new Repository();
// 필요한 경우 예외를 잡아서 처리하면 된다.
public void callCatch() {
try {
repository.call();
} catch (MyUncheckedException e) {
// 예외 처리 로직
log.info("예외 처리, message={}", e.getMessage(), e);
}
}
/*
* 예외를 잡지 않아도 된다. 자연스럽게 상위로 넘어간다.
* 체크 예외와 다르게 throws 예외 선언을 하지 않아도 된다.
* */
public void callThrow() {
repository.call();
}
}
static class Repository{
public void call() {
throw new MyUncheckedException("ex");
}
}
}
public void callThrow() {
repository.call();
}
static class Repository{
public void call() {
throw new MyUncheckedException("ex");
}
}
그럼 테스트를 확인해보자
그럼 언제 체크 예외를 사용하고, 언제 언체크 예외를 사용할까?
앞선 설명만 봐서는 컴파일러가 예외를 체크해준다는 안정성이 있으므로 체크 예외를 사용하는게 더 좋지 않나? 하는 생각이 들었다. 그런데 왜 체크 예외를 기본으로 사용하는게 문제가 될까?
아래의 그림을 보자
코드로 확인해보자.
public class CheckedAppTest {
@Test
void checked() {
Controller controller = new Controller();
Assertions.assertThatThrownBy(() -> controller.request())
.isInstanceOf(Exception.class);
}
static class Controller {
Service service = new Service();
public void request() throws SQLException, ConnectException {
service.logic();
}
}
static class Service {
Repository repository = new Repository();
NetworkClient networkClient = new NetworkClient();
public void logic() throws ConnectException, SQLException {
repository.call();
networkClient.call();
}
}
static class NetworkClient {
public void call() throws ConnectException {
throw new ConnectException("연결 실패");
}
}
static class Repository {
public void call() throws SQLException {
throw new SQLException("ex");
}
}
}
여기서 2가지 문제를 확인할 수 있다.
앞에서 체크 예외를 활용했을 경우를 확인해보았다. 그럼 언체크 예외(런타임 예외)의 경우는 어떻게 흘러갈까? 아래의 그림을 보자.
코드로 확인해보자.
public class UncheckedAppTest {
@Test
void unchecked() {
Controller controller = new Controller();
Assertions.assertThatThrownBy(() -> controller.request())
.isInstanceOf(Exception.class);
}
@Test
void printEx() {
}
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 RuntimeConnectionException("연결 실패");
}
}
static class Repository {
public void call(){
try {
runSQL();
} catch (SQLException e) {
throw new RuntimeSQLException(e);
}
}
public void runSQL() throws SQLException {
throw new SQLException("ex");
}
}
static class RuntimeConnectionException extends RuntimeException {
public RuntimeConnectionException(String message) {
super(message);
}
}
static class RuntimeSQLException extends RuntimeException {
public RuntimeSQLException(Throwable cause) {
super(cause);
}
}
}
static class Service {
Repository repository = new Repository();
NetworkClient networkClient = new NetworkClient();
public void logic(){
repository.call();
networkClient.call();
}
}
아래의 그림을 보자.
런타임 예외의 경우 컴파일러가 체크하지 않기 때문에 개발자가 놓치기 쉽다.
따라서 문서화를 잘 해두고, 중요한 예외의 경우 throws를 선언해 개발자가 인지할 수 있도록 명시해두자.
컴파일러가 예외 처리를 체크해주기 때문에 개발자가 실수로 예외처리를 누락하는 것을 막아준다.
예외를 처리할 수 없을 때마다 throws에 모든 예외를 선언해줘야 한다.
의존 관계에서 문제가 발생한다.
컨트롤러나 서비스에서 예외를 신경쓰지 않아도 되고, 의존 관계도 최소화할 수 있다.
컴파일러가 예외를 체크해주지 않기 때문에 예외를 놓치기 쉽다.
@Test
void printEx() {
Controller controller = new Controller();
try {
controller.request();
} catch (Exception e) {
//e.printStackTrace();
// 로그에 예외 스택 트레이스 포함
log.info("ex", e);
}
}
static class Repository {
public void call(){
try {
runSQL();
} catch (SQLException e) {
// 기존 예외 포함
throw new RuntimeSQLException(e);
}
}
public void runSQL() throws SQLException {
throw new SQLException("ex");
}
}
static class RuntimeSQLException extends RuntimeException {
public RuntimeSQLException(Throwable cause) {
super(cause);
}
}
이것으로 자바 예외 처리에 대해 알아보았다. 여태 예외처리를 하면서 예외의 계층이나 처리 방법에 대해 별다른 생각 없이 사용해왔는데, 좀 더 깊이 알아볼 수 있는 시간이라 좋았다. 다음 포스팅에서는 스프링을 이용한 예외 처리에 대해 알아보도록 하자!!
출처 : 김영한 스프링 DB 1편