참고자료:
예외 처리 1 에서 다룬 프로그램에서 이런 문제가 있었다.
- 정상 흐름과 예외 흐름이 섞여 있기 때문에 코드를 한눈에 이해하기 어렵다. 쉽게 이야기해서 가장 중요한 정상 흐름이 한눈에 들어오지 않는다.
- 심지어 예외 흐름이 더 많은 코드 분량을 차지한다.
이 문제를 점진적으로 해결해보자.
NetworkClientExceptionV2
package exception.ex2;
public class NetworkClientExceptionV2 extends Exception {
private String errorCode;
public NetworkClientExceptionV2(String errorCode, String message) {
super(message);
this.errorCode = errorCode;
}
public String getErrorCode() {
return errorCode;
}
}
예외도 객체이다. 따라서 필요한 필드와 메서드를 가질 수 있다.
- 오류 코드(errorCode): 이전에는 오류 코드를 반환값으로 리턴해서 어떤 오류가 발생했는지 구분했다. 여기서는 어떤 종류의 오류가 발생했는지 구분하기 위해 예외 안에 오류 코드를 보관한다.
- 오류 메시지(message): 오류 메시지에는 어떤 오류가 발생했는지 개발자가 보고 이해할 수 있는 설명을 담아둔다. 오류 메시지는 상위 클래스인 Throwable에서 기본으로 제공하는 기능을 사용한다.
NetworkClientV2
package exception.ex2;
public class NetworkClientV2 {
private final String address;
private boolean connectError;
private boolean sendError;
public NetworkClientV2(String address) {
this.address = address;
}
public void connect() throws NetworkClientExceptionV2 {
if (connectError) {
throw new NetworkClientExceptionV2("connectError", address + " 서버 연결 실패");
}
System.out.println(address + " 서버 연결 성공");
}
public void send(String data) throws NetworkClientExceptionV2 {
if (sendError) {
throw new NetworkClientExceptionV2("sendError", address + " 서버에 데이터 전송 실패: " + data);
}
System.out.println(address + " 서버에 데이터 전송: " + data);
}
public void disconnect() {
System.out.println(address + " 서버 연결 해제");
}
public void initError(String data) {
if (data.contains("error1")) {
connectError = true;
}
if (data.contains("error2")) {
sendError = true;
}
}
}
- 기존의 코드와 대부분 같지만, 오류가 발생했을 때 오류 코드를 반환하는 게 아니라 예외를 던진다.
- 예외를 던지기 때문에 따로 반환값이 필요없다. 반환값을 void로 변경했다.
- 이전에는 반환값을 통해 성공, 실패 여부를 확인했지만 이제 예외 처리 덕분에 메서드가 정상 종료되면 성공이고 예외가 던져지면 예외를 통해 실패를 확인할 수 있다.
- 오류가 발생하면 예외 객체를 만들고 거기에 오류 코드와 오류 메시지를 담아둔다. 그리고 만든 예외 객체를 throw를 통해 던진다.
NetworkServiceV2_1
package exception.ex2;
public class NetworkServiceV2_1 {
public void sendMessage(String data) throws NetworkClientExceptionV2 {
String address = "https://example.com";
NetworkClientV2 client = new NetworkClientV2(address);
client.initError(data);
client.connect();
client.send(data);
client.disconnect();
}
}
예외를 별도로 처리하지 않고 throws를 통해 밖으로 던진다.
MainV2
package exception.ex2;
import java.util.Scanner;
public class MainV2 {
public static void main(String[] args) throws NetworkClientExceptionV2 {
NetworkServiceV2_1 networkService = new NetworkServiceV2_1();
Scanner scanner = new Scanner(System.in);
while (true) {
System.out.println("전송할 문자: ");
String input = scanner.nextLine();
if (input.equals("exit")) {
break;
}
networkService.sendMessage(input);
System.out.println();
}
System.out.println("프로그램을 정상 종료합니다.");
}
}
실행결과:
전송할 문자:
hello
https://example.com 서버 연결 성공
https://example.com 서버에 데이터 전송: hello
https://example.com 서버 연결 해제
전송할 문자:
error1
Exception in thread "main" exception.ex2.NetworkClientExceptionV2: https://example.com 서버 연결 실패
at exception.ex2.NetworkClientV2.connect(NetworkClientV2.java:15)
at exception.ex2.NetworkServiceV2_1.sendMessage(NetworkServiceV2_1.java:11)
at exception.ex2.MainV2.main(MainV2.java:18)
"error1"을 사용자가 입력하면 외부 서버와 연결에 실패한다. 그리고 그 예외를 모든 곳에서 잡지 않았기 때문에 결과적으로 main() 밖으로 예외가 던져진다. 이렇게 되면 예외를 추적할 수 있는 스택 트레이스를 출력하고 프로그램을 종료한다.
남은 문제
- 예외 처리를 도입했지만 예외가 복구되지 않는다. 그래서 예외가 발생하면 프로그램이 종료된다.
- 사용 후 반드시 disconnect()를 호출해서 연결을 해제해야 한다.
예외 복구하기
이번엔 예외를 잡아서 예외 흐름을 정상 흐름으로 복구해보자.
NetworkServiceV2_2
package exception.ex2;
public class NetworkServiceV2_2 {
public void sendMessage(String data) {
String address = "https://example.com";
NetworkClientV2 client = new NetworkClientV2(address);
client.initError(data);
try {
client.connect();
} catch (NetworkClientExceptionV2 e) {
System.out.println("[오류] 코드: " + e.getErrorCode() + ", 메시지: " + e.getMessage());
return;
}
try {
client.send(data);
} catch (NetworkClientExceptionV2 e) {
System.out.println("[오류] 코드: " + e.getErrorCode() + ", 메시지: " + e.getMessage());
return;
}
client.disconnect();
}
}
connect(), send()와 같이 예외가 발생할 수 있는 곳을 try - catch를 사용해서 NetworkClientExceptionV2 예외를 잡았다. 여기서는 예외를 잡으면 오류 코드와 예외 메시지를 출력한다. 예외를 잡아서 처리했기 때문에 이후에는 정상 흐름으로 복귀한다. 여기서는 리턴을 사용해서 sendMessage()를 정상적으로 빠져나간다.
MainV2
package exception.ex2;
import java.util.Scanner;
public class MainV2 {
public static void main(String[] args) throws NetworkClientExceptionV2 {
NetworkServiceV2_2 networkService = new NetworkServiceV2_2();
Scanner scanner = new Scanner(System.in);
while (true) {
System.out.println("전송할 문자: ");
String input = scanner.nextLine();
if (input.equals("exit")) {
break;
}
networkService.sendMessage(input);
System.out.println();
}
System.out.println("프로그램을 정상 종료합니다.");
}
}
실행결과:
전송할 문자:
hello
https://example.com 서버 연결 성공
https://example.com 서버에 데이터 전송: hello
https://example.com 서버 연결 해제
전송할 문자:
error2
https://example.com 서버 연결 성공
[오류] 코드: sendError, 메시지: https://example.com 서버에 데이터 전송 실패: error2
전송할 문자:
error1
[오류] 코드: connectError, 메시지: https://example.com 서버 연결 실패
전송할 문자:
exit
프로그램을 정상 종료합니다.
이제 예외를 잡아서 처리했기 때문에 예외가 복구되고 프로그램도 계속 수행할 수 있다.
남은 문제
- 예외 처리를 했지만 정상 흐름과 예외 흐름이 섞여 있어 코드를 읽기 어렵다.
- 사용 후 반드시 disconnect()를 호출해서 연결을 해제해야 한다.
정상, 예외 흐름 분리하기
NetworkServiceV2_3
package exception.ex2;
public class NetworkServiceV2_3 {
public void sendMessage(String data) {
String address = "https://example.com";
NetworkClientV2 client = new NetworkClientV2(address);
client.initError(data);
try {
client.connect();
client.send(data);
client.disconnect();
} catch (NetworkClientExceptionV2 e) {
System.out.println("[오류] 코드: " + e.getErrorCode() + ", 메시지: " + e.getMessage());
}
}
}
하나의 try 안에 정상 흐름을 모두 담는다.
그리고 예외 부분은 catch 블럭에서 해결한다.
이렇게 하면 정상 흐름은 try 블럭에 들어가고, 예외 흐름은 catch 블럭으로 명확하게 분리할 수 있다.
실행결과:
전송할 문자:
hello
https://example.com 서버 연결 성공
https://example.com 서버에 데이터 전송: hello
https://example.com 서버 연결 해제
전송할 문자:
error1
[오류] 코드: connectError, 메시지: https://example.com 서버 연결 실패
전송할 문자:
error2
https://example.com 서버 연결 성공
[오류] 코드: sendError, 메시지: https://example.com 서버에 데이터 전송 실패: error2
전송할 문자:
exit
프로그램을 정상 종료합니다.
실행 결과를 보면, 에러가 발생하면 다음 라인을 진행하지 않고 바로 catch로 빠지기 때문에 disconnect()를 호출하지 않는다.
남은 문제
- 사용 후 반드시 disconnect()를 호출해서 연결을 해제해야 한다.
리소스 반환 정상적으로 해보기
현재 구조에서 disconnect()를 항상 호출하려면 다음과 같이 생각해 볼 수 있다.
NetworkServiceV2_4
package exception.ex2;
public class NetworkServiceV2_4 {
public void sendMessage(String data) {
String address = "https://example.com";
NetworkClientV2 client = new NetworkClientV2(address);
client.initError(data);
try {
client.connect();
client.send(data);
} catch (NetworkClientExceptionV2 e) {
System.out.println("[오류] 코드: " + e.getErrorCode() + ", 메시지: " + e.getMessage());
}
client.disconnect();
}
}
이 코드를 보면, 예외 처리가 끝난 다음에 정상 흐름의 마지막에 client.disconnect()를 호출했다.
문제없이 동작할 것 같다.
실행결과:
전송할 문자:
hello
https://example.com 서버 연결 성공
https://example.com 서버에 데이터 전송: hello
https://example.com 서버 연결 해제
전송할 문자:
error1
[오류] 코드: connectError, 메시지: https://example.com 서버 연결 실패
https://example.com 서버 연결 해제
전송할 문자:
error2
https://example.com 서버 연결 성공
[오류] 코드: sendError, 메시지: https://example.com 서버에 데이터 전송 실패: error2
https://example.com 서버 연결 해제
전송할 문자:
exit
프로그램을 정상 종료합니다.
정말 문제없이 동작하고 있다. 에러가 발생해도 연결 해제가 항상 실행된다.
그러나, 이 방법에는 큰 문제가 있다. 바로 catch에서 잡을 수 없는 예외가 발생했을 때이다.
잠깐만 connect()를 아래처럼 에러 발생 시 NetworkClientExceptionV2가 아니라 RuntimeException으로 변경해보자.
public void connect() throws NetworkClientExceptionV2 {
if (connectError) {
// throw new NetworkClientExceptionV2("connectError", address + " 서버 연결 실패");
throw new RuntimeException("Ex");
}
System.out.println(address + " 서버 연결 성공");
}
이렇게 변경하면 기존 코드로는 catch에서 connect() 호출 시 에러가 발생하면 잡을 수 없게 된다.
실행결과:
전송할 문자:
hello
https://example.com 서버 연결 성공
https://example.com 서버에 데이터 전송: hello
https://example.com 서버 연결 해제
전송할 문자:
error1
Exception in thread "main" java.lang.RuntimeException: Ex
at exception.ex2.NetworkClientV2.connect(NetworkClientV2.java:16)
at exception.ex2.NetworkServiceV2_4.sendMessage(NetworkServiceV2_4.java:12)
at exception.ex2.MainV2.main(MainV2.java:18)
catch에서 잡기로 했던 NetworkClientExceptionV2가 아닌 다른 에러가 발생하면 잡을 수 없어 예외가 밖으로 던져지게 된다. 그러면 그 하단에 있는 모든 라인은 무시된다. 따라서, client.disconnect() 호출은 무시가 된다.
finally 등장
자바에서 저런 문제를 해결하기 위해 어떤 경우라도 반드시 호출되는 finally 기능을 제공한다.
NetworkServiceV2_5
package exception.ex2;
public class NetworkServiceV2_5 {
public void sendMessage(String data) {
String address = "https://example.com";
NetworkClientV2 client = new NetworkClientV2(address);
client.initError(data);
try {
client.connect();
client.send(data);
} catch (NetworkClientExceptionV2 e) {
System.out.println("[오류] 코드: " + e.getErrorCode() + ", 메시지: " + e.getMessage());
} finally {
client.disconnect();
}
}
}
finally 키워드는 어떤 상황에서도 마지막에 반드시 실행되는 구문이다. 그래서 catch에서 잡을 수 없는 에러가 발생해서 밖으로 던져질 때에도 이 finally를 한 번 거친 후에 밖으로 던져진다.
다음과 같이 catch가 없이 try - finally 만 사용해도 된다.
try {
client.connect();
client.send(data);
} finally {
client.disconnect();
}
그대신 이 경우 예외는 언체크 예외이거나, 체크 예외인 경우엔 throws 예외로 밖으로 던진다고 선언해야 한다. 체크 예외는 반드시 잡거나 던져야 하므로.
정리
자바 예외 처리는 try - catch - finally 구조를 사용해서 쉽게 처리할 수 있다. 덕분에 다음과 같은 이점이 있다.
- 정상 흐름과 예외 흐름을 분리해서, 코드를 읽기 쉽게 만든다.
- 사용한 자원을 항상 반환할 수 있도록 보장해준다.
근데, 이 모든게 체크 예외를 사용하면서 발생하는 번거로움이다. 결국 실무에서는 체크 예외보다 언체크 예외를 더 많이 사용한다. 왜냐하면 실무에선 수십개의 라이브러리를 사용하는데 그 모든 라이브러리에서 쏟아지는 모든 예외를 다 다루고 싶지 않을 것이다. 그리고 결정적으로 그 예외 중 대다수는 복구할 수 있는 예외가 아니라 복구할 수 없는 예외이다. 그런데도 불구하고 체크 예외이기 때문에 throws에 던질 대상을 일일이 명시해야 한다.
그래서 결국, 정말 무조건 체크해야 하는 예외나 해결이 가능한 예외만 잡아 처리하고 그 외에 것들은 신경 쓰지 않는 것이 더 나은 선택일 수 있다. 그러한 방법을 사용해보자.
언체크 예외 사용 시나리오
이번에는 Service에서 호출하는 클래스들이 언체크 예외를 전달한다고 가정해보자.
NetworkException, DatabaseException은 잡아도 복구할 수 없다. 언체크 예외이므로 이런 경우 무시하면 된다.
언체크 예외를 던지는 예시
class Service {
void sendMessage(String data) {...}
}
- 언체크 예외이므로 throws를 선언하지 않아도 된다.
- 사용하는 라이브러리가 늘어나서 언체크 예외가 늘어도 본인이 필요한 예외만 잡으면 되고, throws를 늘리지 않아도 된다.
일부 언체크 예외를 잡아서 처리하는 예시
try {
...
} catch (XxxException) {...}
필요하다면 언체크 예외를 직접 잡아서 처리하면 된다.
이렇게 언체크 예외로 기본을 무시한다고 큰 틀을 잡고 모든 언체크 예외를 공통으로 처리하는 부분이 있으면 된다.
어차피 해결할 수 없는 예외들이기 때문에 이런 경우 고객에게는 현재 시스템에 문제가 있습니다.라고 오류 메시지를 보여주고, 만약 웹이라면 오류 페이지를 보여주면 된다. 그리고 내부 개발자가 지금의 문제 상황을 빠르게 인지할 수 있도록, 오류에 대한 로그를 남겨두면 된다.
이런 시나리오를 토대로 지금까지 다뤘던 코드에 적용해보자.
모든것을 언체크 예외로 변경한다. 우선 NetworkClientException도 두 개로 분류할 수 있다. ConnectException, SendException. connect() 호출 시 발생 가능성이 있는 에러를 ConnectException으로 처리하고 send() 호출 시 발생 가능성이 있는 에러를 SendException으로 처리해서 더 세분화하자.
NetworkClientExceptionV4
package exception.ex4.exception;
public class NetworkClientExceptionV4 extends RuntimeException {
public NetworkClientExceptionV4(String message) {
super(message);
}
}
ConnectExceptionV4
package exception.ex4.exception;
public class ConnectExceptionV4 extends NetworkClientExceptionV4 {
private final String address;
public ConnectExceptionV4(String address, String message) {
super(message);
this.address = address;
}
public String getAddress() {
return address;
}
}
SendExceptionV4
package exception.ex4.exception;
public class SendExceptionV4 extends NetworkClientExceptionV4 {
private final String data;
public SendExceptionV4(String data, String message) {
super(message);
this.data = data;
}
public String getData() {
return data;
}
}
ConnectExceptionV4, SendExceptionV4는 모두 NetworkClientExceptionV4를 상속받는다. 그리고 각 예외 클래스는 본인한테 필요한 필드(address, data)를 가지고 있다.
NetworkClientV4
package exception.ex4;
import exception.ex4.exception.ConnectExceptionV4;
import exception.ex4.exception.SendExceptionV4;
public class NetworkClientV4 {
private final String address;
private boolean connectError;
private boolean sendError;
public NetworkClientV4(String address) {
this.address = address;
}
public void connect() {
if (connectError) {
throw new ConnectExceptionV4(address, "서버 연결 실패");
}
System.out.println(address + " 서버 연결 성공");
}
public void send(String data) {
if (sendError) {
// throw new RuntimeException("ex");
throw new SendExceptionV4(data, address + " 서버에 데이터 전송 실패: " + data);
}
System.out.println(address + " 서버에 데이터 전송: " + data);
}
public void disconnect() {
System.out.println(address + " 서버 연결 해제");
}
public void initError(String data) {
if (data.contains("error1")) {
connectError = true;
}
if (data.contains("error2")) {
sendError = true;
}
}
}
connect(), send() 모두 throws를 사용할 필요가 없다.
NetworkServiceV4
package exception.ex4;
public class NetworkServiceV4 {
public void sendMessage(String data) {
String address = "https://example.com";
NetworkClientV4 client = new NetworkClientV4(address);
client.initError(data);
try {
client.connect();
client.send(data);
} finally {
client.disconnect();
}
}
}
- NetworkServiceV4는 발생하는 예외인 ConnectExceptionV4, SendExceptionV4를 잡아도 복구할 수 없다. 따라서 예외를 밖으로 던진다. 근데 언체크 예외이므로 던지는 걸 throws 선언없이 자동으로 해준다.
- 개발자 입장에선 네트워크 문제가 발생하는데 그 문제가 발생한다고 뭘 할 수 있겠는가? 따라서 해당 예외들을 생각하지 않는 것이 더 나은 선택일 수 있다. 해결할 수 없는 예외들은 다른 곳에서 공통으로 처리된다.
- 이런 방식 덕분에 NetworkServiceV4는 해결할 수 없는 예외보단 본인 스스로의 코드에 더 집중할 수 있다. 따라서 코드가 깔끔해진다.
MainV4
package exception.ex4;
import exception.ex4.exception.SendExceptionV4;
import java.util.Scanner;
public class MainV4 {
public static void main(String[] args) {
NetworkServiceV4 networkService = new NetworkServiceV4();
Scanner scanner = new Scanner(System.in);
while (true) {
System.out.println("전송할 문자: ");
String input = scanner.nextLine();
if (input.equals("exit")) {
break;
}
try {
networkService.sendMessage(input);
} catch (Exception e) {
exceptionHandler(e);
}
System.out.println();
}
System.out.println("프로그램을 정상 종료합니다.");
}
// 공통 예외 처리
private static void exceptionHandler(Exception e) {
// 공통 처리
System.out.println("사용자 메시지: 죄송합니다. 알 수 없는 문제가 발생했습니다.");
System.out.println("==개발자용 디버깅 메시지==");
e.printStackTrace(System.out);
// e.printStackTrace();
//필요하면 예외 별로 별도의 추가 처리 가능
if (e instanceof SendExceptionV4 sendExceptionV4) {
System.out.println("[전송 오류] 전송 데이터: " + sendExceptionV4.getData());
}
}
}
공통 예외 처리
try {
networkService.sendMessage(input);
} catch (Exception e) {
exceptionHandler(e);
}
여기에 예외를 공통으로 처리하는 부분이 존재한다.
- Exception을 잡아서 지금까지 해결하지 못한 모든 예외를 여기서 공통으로 처리한다. Exception을 잡으면 필요한 모든 예외를 잡을 수 있다.
// 공통 예외 처리
private static void exceptionHandler(Exception e) {
System.out.println("사용자 메시지: 죄송합니다. 알 수 없는 문제가 발생했습니다.");
System.out.println("==개발자용 디버깅 메시지==");
e.printStackTrace(System.out);
// e.printStackTrace();
//필요하면 예외 별로 별도의 추가 처리 가능
if (e instanceof SendExceptionV4 sendExceptionV4) {
System.out.println("[전송 오류] 전송 데이터: " + sendExceptionV4.getData());
}
}
- 복구할 수 없는 예외가 발생하면 사용자에게는 시스템 내 알 수 없는 문제가 발생했다고 알린다. (디테일한 내용을 알릴 필요가 없다. 알려서도 안된다)
- 개발자는 빨리 문제를 찾고 디버깅 할 수 있도록 오류 메시지를 남겨두어야 한다.
- 예외도 객체이므로 필요하면 instanceof와 같이 예외 객체의 타입을 확인해서 별도의 추가 처리를 할 수 있다.
참고로, 실무에서는 System.out 이런걸 사용하는 게 아니고 Slf4J, logback 같은 별도의 로그 라이브러리를 사용한다.
정리하자면
1. 대부분의 에러는 어차피 개발자가 복구할 수 없다.
2. 복구할 수 없는 에러를 체크 예외로 던져서 일일이 처리하는 건 예외 지옥에 빠질것이다.
3. 그래서 정말 처리해야 하는 예외만 체크 예외로 하고 나머지는 언체크 예외로 해서 본연의 기능에 충실할 수 있도록 코드를 작성한다. 이는 코드 가독성을 높이고 불필요한 라인을 제거할 수 있다.
4. 실행 중 발생한 언체크 예외를 공통으로 처리하는 부분에서 사용자에겐 문제가 발생했다고 알려주고 개발자는 그 문제를 로그를 통해 빠르게 알아내고 디버깅해서 문제를 빠르게 해결하면 된다.
'JAVA의 가장 기본이 되는 내용' 카테고리의 다른 글
컬렉션 프레임워크 - ArrayList 직접 구현해보기 (0) | 2024.05.10 |
---|---|
제네릭 (0) | 2024.05.08 |
예외 처리 2 (예외 계층) (0) | 2024.04.23 |
예외처리 1 (예외처리가 필요한 이유) (0) | 2024.04.23 |
try-with-resources (0) | 2024.04.23 |