8-1. 예외 처리

Hyun Jun·2022년 1월 22일
0

자바의 정석

목록 보기
34/52
post-thumbnail
post-custom-banner

예외 처리

프로그램 오류

에러는 발생 시점에 따라 다음과 같이 구분함

  • 컴파일 에러 (Compile-time Error): 컴파일 도중(*.java*.class)에 발생하는 에러

  • 런타임 에러 (Run-time Error): 애플리케이션의 실행 도중에 발생하는 에러

이외에도 논리적 에러가 있음

  • 논리적 에러 (Logical Error): 컴파일과 실행 모두 문제 없지만, 의도대로 동작하지 않는 경우 (에러로 간주)

 

런타임 에러는 2가지로 구분함

  1. 에러 (Error)

    코드를 사용하여 수습할 수 없는 심각한 오류

    ex) 메모리부족(OutofMemoryError), 스택오버플로우(StackOverflowError)

  2. 예외 (Exception)

    코드로 수습할 수 있는 상대적으로 미약한 오류

    ex) 입출력작업오류(IOException), 파일찾기/권한오류(FileNotFoundException)

 

예외 클래스의 계층 구조

Java에서는 에러와 예외 모두 클래스로 정의되어있음. (Object 클래스의 자손인 셈)

Exception 클래스(예외의 최고 조상) 밑으로 여러 예외 클래스들이 존재하는데,

= 외부 요인(사용자의 동작)으로 발생할 수 있는 예외들

이중에서 RuntimeException 클래스는 밑으로 또 여러 가지 하위 예외 클래스들이 있음.

= 주로 프로그래머의 실수에 의해 발생할 수 있는 예외들

 

예외 처리하기: try-catch문

예외의 발생에 대비해 코드를 작성해 놓는 것. 애플리케이션의 비정상적 종료를 막기 위함

처리되지 못한 예외(uncaught exception)는 JVM의 예외처리기(UncaughtExceptionHandler)가 받아서 화면에 원인을 출력해줌

 

try-catch문으로 예외를 처리할 수 있음

try {
    // 예외 발생 가능성이 있는 문장들
} catch (Exception1 e1) {
    // Exception1이 발생했을 때, 처리를 위한 문장들
} catch (Exception2 e2) {
    // Exception2가 발생했을 때, 처리를 위한 문장들
} ...

catch 블럭은 1개 이상이 올 수 있고, 예외가 발생하면 이 중 일치하는 항목이 담긴 블럭이 실행됨

예외가 발생했는데 이 중 일치하는 항목이 없으면 예외 처리에 실패함

경우에 따라 try블럭이나 catch블럭 안에 또 다른 try-catch문이 들어갈 수 있음

try {
    try {
        ...
    } catch (Exception1 e1) {
        ...
    }
    ...
} catch (Exception2 e2) {
    try {
        ...
    } catch (Exception3 e3) {
        ...
    }
    ...
}

catch 블럭에 있는 변수는 해당 블럭 안에서만 유효하므로 e1e2가 같은 이름이었어도 상관 없음.

그러나 e2e3는 같은 블럭 안에 있으므로 같은 이름으로 사용하면 안됨.

 

class ExceptionExample {
    public static void main(String[] args) {
        int number = 100;
        int result = 0;

        for (int i = 0; i < 10; i++) {
            result = number / (int)(Math.random() * 10); // number를 0~9 사이의 임의의 정수로 나누는 작업 (0으로 나눌 시 에러 소지 있음)
            System.out.println(result);
        }
    }
}

위 예시는 정수를 0으로 나누는 작업이 일어날 수 있기 때문에 실행하다보면 ArithmeticException이 발생하고 비정상 종료됨

그래서 에러 발생 가능성이 있는 지점에서 try-catch문을 사용해서 비정상 종료를 방지할 수 있음

class ExceptionExample {
    public static void main(String[] args) {
        int number = 100;
        int result = 0;

        for (int i = 0; i < 10; i++) {
            try {
                result = number / (int)(Math.random() * 10);
                System.out.println(result);
            } catch (AritmeticException e) {
                System.out.println("0"); // 정수를 0으로 나누는 케이스가 나올 경우, 0을 출력하도록 함
            }
        }
    }
}

 

try-catch문에서의 흐름

Case 1) try블럭 내에서 예외가 발생한 경우

try블럭 내 예외 발생지점 이후의 문장 무시 → 발생한 예외와 일치하는 catch 블럭 확인 → 일치하는 catch블럭 내의 문장 실행 → 전체 try-catch문을 빠져나가 다음 문장 실행

일치하는 catch블럭이 없는 경우, 예외 처리에 실패하여 애플리케이션이 비정상 종료됨

 

Case 2) try블럭 내에서 예외가 발생하지 않은 경우

전체 try-catch문을 빠져나가 다음 문장 실행

 

📌 [유의] Case 1의 경우 try블럭 내 예외 발생지점 이후의 문장이 무시된다는 점을 유의하자.

 

예외의 발생과 catch블럭

예외가 처리되는 과정의 내부 로직을 보자.

  1. try블럭 내에서 예외가 발생하면, 예외에 해당하는 클래스의 인스턴스가 생성됨

    위에서 본 예시에서는 ArithmeticException의 인스턴스 생성

  2. 생성된 예외 클래스의 인스턴스를 가지고 첫번째 catch블럭부터 차례대로 내려가면서 괄호 내에 선언된 참조변수의 타입과 instanceof 연산자로 검사를 거침

  3. true가 반환되는 catch블럭의 문장들을 수행

    true를 반환하는 블럭이 아무 것도 없다면 예외 처리 실패

  4. 전체 try-catch문을 빠져나옴

 

catch블럭 괄호의 참조변수 타입을 모든 예외의 조상 클래스인 Exception으로 선언하면,

instanceof 연산 결과가 무조건 true가 되어 모든 종류의 예외를 잡아낼 수 있다는 것을 알 수 있음

class ExceptionExample {
    public static void main(String[] args) {
        int number = 100;
        int result = 0;

        for (int i = 0; i < 10; i++) {
            try {
                result = number / (int)(Math.random() * 10);
                System.out.println(result);
            } catch (AritmeticException e) {
                System.out.println("Caught an ArithmeticException");
            } catch (Exception e) {
                System.out.println("Caught an exception besides ArithmeticException");
                // ArithmeticException을 제외한 다른 종류의 예외가 발생했다면 이 문장이 실행되었을 것임
            }
        }
    }
}

위 예시에서는 이미 첫번째 catch블럭이 실행되어 두번째는 실행될 일이 없겠지만,

👨‍💻 try-catch문의 마지막 catch블럭에 Exception 타입의 참조변수를 걸어놓으면 어떤 종류의 예외가 발생하더라도 최종적으로는 여기서 처리가 가능하다는 점을 보여줌.

 

printStackTrace()와 getMessage()

예외 발생 시 생성되는 예외 클래스의 인스턴스에는 발생한 예외에 대한 정보가 담겨 있음

java.lang 패키지의 Throwable 클래스에서 제공하는 printStackTracegetMessage를 통해 이 정보들을 얻을 수 있음

Exception 클래스가 Throwable 클래스를 상속 받기 때문

 

메서드반환 타입설명
printStackTracevoid예외 발생 당시 Call Stack에 있었던 메서드의 정보, 예외 메시지를 출력
getMessageString발생한 예외 클래스의 인스턴스에 저장된 메시지를 문자열로 반환

 

예시)

class ExceptionExample {
    public static void main(String[] args) {
        System.out.println(1);
        System.out.println(2);
        try {
            System.out.println(0 / 0);
            System.out.println(3);
        } catch (ArithmeticException e) {
            e.printStackTrace();
            System.out.println(e.getMessage());
        }
        System.out.println(4);
    }
}

실행 결과)

1
2
java.lang.ArithmeticException: / by zero
    at ExceptionExample.main(Time.java:6)
/ by zero
4

중간에 발생한 예외를 처리하고 main메서드의 모든 문장을 수행한 뒤 정상적으로 종료됨

 

멀티 catch블럭

여러 catch블럭을 |기호를 이용해 하나의 블럭으로 합친 것 (JDK 1.7부터 지원)

 

예시)

아래 2개의 catch블럭을

try {
    ...
} catch (Exception1 e) {
    e.printStackTrace();
} catch (Exception2 e) {
    e.printStackTrace();
}

멀티 catch블럭으로 하나로 합치면,

try {
    ...
} catch (Exception1 | Exception2 e) {
    e.printStackTrace();
}

중복되는 형식의 코드를 줄일 수 있다는 장점이 있음

단, Exception1Exception2가 조상-자손 관계라면 컴파일 에러 발생

가령, Exception2Exception1의 자손인 경우 그냥

catch (Exception1 e) {
   e.printStackTrace();
}

라고 쓸 수 있기 때문에 컴파일러가 코드를 줄이라는 의미에서 던지는 에러

또한, catch블럭 내에서는 |로 연결된 예외 클래스들의 공통 조상이 가진 메서드만 쓸 수 있게됨.

if문 조건식으로 instanceof 연산을 돌려 타입을 좁힌 이후에 개별 예외 클래스의 메서드로 접근하는 것은 가능함

 

예외 발생시키기

throw 키워드를 사용해 고의적으로 예외를 발생시킬 수 있음

  1. new 연산자로 발생시키려는 예외 클래스의 인스턴스를 생성

    Exception e = new Exception("예외 메시지를 적는 곳");

    예외 메시지는 String

  2. throw 키워드로 예외를 발생시키기

    throw e;

1, 2단계를 합쳐 간소화하면,

throw new Exception("예외 메시지");

이 때 지정한 예외 메시지는 getMessage 메서드를 이용해서 얻을 수 있음

 

예외를 발생시켜보면, RuntimeException과 하위 클래스들을 제외한 Exception의 하위 클래스들은 예외 처리를 해주지 않으면 컴파일조차 되지 않음.

그러나 RuntimeException과 하위 클래스들은 따로 예외 처리를 안해줘도 컴파일 시켜줌.

런타임 예외들은 프로그래머의 실수에서 나오는 예외이기 때문에 예외 처리를 강제하지 않음

 

메서드에 예외 선언하기

선언부에 throws 키워드를 사용해 메서드에서 발생 가능한 예외들을 적어주면 됨 (예외들은 쉼표로 구분)

void method() throws Exception1, Exception2, Exception3, ... , ExceptionN {
    ...
}

이렇게 선언부에 예외들을 나열해놓으면, 메서드의 사용자가 미리 어떤 예외들을 처리해야할지 알 수 있음

앞서 보았듯이, RuntimeException 계열의 예외들은 예외 처리가 강제되지 않으므로 일반적으로는 메서드의 예외 선언에 포함시켜놓지 않음

 

선언부에 명시된 예외는 처리된게 아니고, 엄밀히는 자신을 호출할 메서드에게 전달하여 떠넘긴 것임

따라서, 결국 어느 한 곳에서는 반드시 try-catch문으로 예외 처리를 해주어야만 애플리케이션의 비정상적 종료를 막을 수 있음

 

👨‍💻 예외가 발생한 메서드에서 예외 처리하기 vs 해당 메서드를 호출한 곳에서 예외 처리하기

예시)

import java.io.*;

class ExceptionExample {
    public static void main(String[] args) {
        try {
            File f = createFile(args[0]); // Command Line 으로 넘겨받은 문자열을 제목으로 하는 파일을 생성
            System.out.println(f.getName() + "파일 생성 완료");
        } catch (Exception e) {
            System.out.println(e.getMessage() + "다시 입력해주세요");
        }
    }

    static File createFile(String fileName) throws Exception {
        if (fileName == null || fileName.equals("")) {
            throw new Exception("유효하지 않은 파일 이름입니다");
        }
        File f = new File(fileName); // File 인스턴스 생성
        f.createNewFile(); // 실제 파일 생성
        return f;
    }
}

위 예시에서는 예외가 발생한 createFile 메서드가 아닌, 해당 메서드를 호출한 main 메서드에서 예외 처리를 진행함

여기서 예외를 발생시키는 요인은 잘못된 파일 이름인데, 이를 다시 받아올 수 있는(= 근본적 문제 해결이 가능한) 곳은 main 메서드 쪽이기 때문

이처럼 예외가 발생한 메서드에서 자체적인 해결이 안될 경우, 선언부에 예외를 선언해놓고 그 메서드를 호출한 곳 중 문제 해결이 가능한 곳에서 해결하는 것이 나음.

 

finally 블럭

예외의 발생 여부와 관계 없이 실행시킬 코드를 포함하는 블럭

try-catch문 뒤에 붙이며, 선택 사항임

try {
    ...
} catch (Exception e) {
    ...
} finally {
    ...
}
  • 예외가 발생한 경우: trycatchfinally 순으로 진행

  • 예외가 발생하지 않은 경우: tryfinally 순으로 진행

trycatch블럭에서 return문이 있어도, 메서드를 종료하기 전에 finally블럭이 실행된 후에 종료됨

 

자동 자원 반환: try-with-resources문

입출력(I/O) 관련 클래스를 사용하면서 예외 처리를 해야할 때 유용함

입출력 작업 도중에 발생하는 예외, 자원 반환 과정에서 발생하는 예외 등

try {
    fis = new FileInputSystem("name.dat");
    dis = new DataInputSystem(fis);
    ...
} catch (IOException e) {
    e.printStackTrace();
} finally {
    dis.close();
}

입출력 작업이 끝나면 클래스를 꼭 닫아줘야해서 finally블럭에 close 메서드를 넣었음.

그런데 문제는, close 실행 중에도 예외가 발생할 수 있다는 것.

try {
    fis = new FileInputSystem("name.dat");
    dis = new DataInputSystem(fis);
    ...
} catch (IOException e) {
    e.printStackTrace();
} finally {
    try {
        if (dis != null) dis.close();
    } catch (IOException e) {
        e.printStackTrace();
    }
}

그래서 finally블럭 내부에도 try-catch문을 넣어 예외 처리를 해주었는데, 코드 가독성도 떨어지고

무엇보다도 원래의 try블럭과 finally블럭에서 동시에 예외가 발생하면 try블럭에서 발생한 것은 무시되는 문제가 있음.

이런 문제점을 개선하기 위해 try-with-resources문이 추가되었음

try (FileInputStream fis = new FileInputStream("name.dat");
DataInputStream dis = new DataInputStream(fis)) {
    ...
} catch (IOException e) {
    ...
} ...

try-with-resources문이 적용된 모습.

try블럭의 괄호안에 인스턴스를 생성하는 문장을 넣으면, 이 인스턴스는 try블럭을 벗어나는 즉시 close 메서드를 호출하게 되어있음

단, 해당 클래스가 AutoCloseable 인터페이스를 구현한 것이어야 함

그리고 나서 catch블럭이나 finally블럭이 수행됨.

 

👨‍💻 만약 try블럭에서 작업 관련 에러와 close 관련 에러가 동시에 발생하면 어떻게 될까?

예시)

class TryWithResourcesTest {
    public static void main(String[] args) {
        // Case 1: 작업 도중 예외는 발생X, close 실행 중에만 예외 발생
        try (CloseableResource cr = new CloseableResource()) {
            cr.work(false);
        } catch (WorkException | CloseException e) {
            e.printStackTrace();
        }

        System.out.println();

        // Case 2: 작업 도중, 그리고 close 실행에서 모두 예외 발생
        try (CloseableResource cr = new CloseableResource()) {
            cr.work(true);
        } catch (WorkException | CloseException e) {
            e.printStackTrace();
        }
    }
}

class CloseableResource implements AutoCloseable {
    public void work(boolean exception) throws WorkException {
        System.out.println("work 메서드가 " + (exception ? "예외를 발생시키지 않음" : "예외를 발생시킴"));
        if (exception) {
            throw new WorkException("WorkException 예외가 발생함!");
        }
    }

    public void close() throws CloseException {
        System.out.println("close 메서드가 호출됨");
        throw new CloseException("CloseException 예외가 발생함!");
    }
}

class WorkException extends Exception {
    WorkException(String message) {
        super(message);
    }
}

class CloseException extends Exception {
    CloseException(String message) {
        super(message);
    }
}

예외 2개가 동시에 발생할 수는 없지만, 억제된 예외(suppressed exception)이라는 개념을 통해서는 2개 모두 출력할 수 있음.

우선 예시에서 취급하는 예외는 2가지:

  • WorkException: try블럭의 작업(work 메서드)을 수행할 때 발생할 수 있는 예외

  • CloseException: try블럭에서 작업완료 후, 자동으로 close가 호출될 때 발생할 수 있는 예외

    try문의 괄호 안에 들어간 인스턴스의 타입인 CloseableResourceAutoCloseable 인터페이스를 구현한 클래스이므로, 자동으로 close 호출이 가능

 

작업을 상징하는 work메서드는 인자가 true면 작업 에러(WorkException)을 발생시키고, false면 발생시키지 않음.

이 2가지 케이스를 각각의 try-with-resources문에 담아 출력 결과를 살펴봄.

 

실행 결과)

work 메서드가 예외를 발생시키지 않음
close 메서드가 호출됨
CloseException: CloseException 예외가 발생함!
    at CloseableResource.close(TryWithResourcesTest.java:28)
    at TryWithResourcesTest.main(TryWithResourcesTest.java:5)

work 메서드가 예외를 발생시킴
close 메서드가 호출됨
WorkException: WorkException 예외가 발생함!
    at CloseableResource.work(TryWithResourcesTest.java:22)
    at TryWithResourcesTest.main(TryWithResourcesTest.java:11)
    Suppressed: CloseException: CloseException 예외가 발생함!
        at CloseableResource.close(TryWithResourcesTest.java:28)
        at TryWithResourcesTest.main(TryWithResourcesTest.java:10)

Case 1에서는 예외가 1가지만 발생해서 일반적으로 알고 있는 형태로 에러가 출력되었음.

Case 2에서는 2가지 예외가 발생했는데, WorkException을 대표로 하고 그 안에 Suppressed(억제된 예외)로 CloseException이 포함되어 출력되었음

만약 기존 try-catch문을 사용했다면 먼저 발생한 WorkException이 무시되고, CloseException만 출력되었을 것임

 

사용자정의 예외 만들기

기존에 정의되어있는 예외 말고도, 사용자가 직접 예외 클래스를 정의하여 사용할 수 있음

가능하면 완전히 새로 만들기보다는, 기존 예외 클래스를 활용하는 것이 좋음

보통 Exception클래스나 RuntimeException클래스로부터 상속 받아 만듦

class CustomException extends Exception {
    CustomException(String message) {
        super(message);
    }
}

기존에는 주로 Exception을 상속 받아 Checked Exception(반드시 예외처리를 해야함)로 작성하는 것이 일반적이었는데,

최근에는 RuntimeException을 상속받아 Unchecked Exception(선택적으로 예외처리를 함)로 작성하는 추세임.

Checked Exception은 예외 처리가 불필요한 경우까지도 try-catch문을 넣어야해서 코드가 복잡해지기 때문

 

예외 되던지기(exception re-throwing)

한 메서드에 발생할 수 있는 예외가 여러개일 때, 몇개는 try-catch문으로 직접 처리하고, 나머지는 메서드 선언부에 선언하여 자신을 호출하는 다른 메서드에게 처리를 전가할 수 있음.

또는 1가지 예외에 대해서도 예외가 발생한 메서드와 그 메서드를 호출한 메서드 양쪽 모두에서 처리하도록 할 수도 있음.

이러한 방식은 예외를 처리한 후에 인위적으로 다시 발생시키는 방법인 예외 되던지기(exception re-throwing)를 통해 구현할 수 있음

 

class ExceptionReThrowExample {
    public static void main(String[] args) {
        try {
            reThrow();
        } catch (Exception e) {
            System.out.println("main 메서드에서 예외를 처리함");
        }
    }

    static void reThrow() throws Exception {
        try {
            throw new Exception();
        } catch (Exception e) {
            System.out.println("reThrow 메서드에서 예외를 처리함");
            throw e; // 다시 예외를 발생시킴
        }
    }
}

reThrow메서드에서 예외를 처리하고 나서, 다시 발생시킨 예외를 main에서 받아 또 처리했음

 

원칙적으로는 try블럭에 return문이 존재할 경우, catch블럭에서도 return문이 필요한데, 예외 되던지기를 사용할 때는 catch블럭에서 throw로 예외를 다시 던지는 것으로 return문을 대신할 수 있음

finally블럭에도 return문을 넣을 수 있는데, trycatch블럭의 return문 다음으로 수행되며, 최종적으로는 finallyreturn 값이 반환됨.

 

연결된 예외(chained exception)

예외에도 인과 관계가 성립할 수 있음. 예외 A가 예외 B를 유발했다면, A가 B의 원인 예외(cause exception라고 부름.

발생한 예외에 initCause 메서드를 사용해 원인 예외를 지정할 수 있음.

try {
    ...
} catch (CauseException ce) {
    ResultException re = new ResultException(); // (결과)예외 생성
    re.initCause(ce); // 잡아낸 예외를 원인 예외로 지정함
    throw re; // (결과)예외 되던지기
}
메서드매개 변수반환 타입설명
initCauseThrowable causeThrowable인자로 넘어온 예외를 원인 예외로 지정함
getCause-Throwable원인 예외를 반환함

 

연결된 예외를 사용하는 이유

  1. 서로 상속 관계가 아니더라도 여러 가지 예외들을 큰 분류로 묶어서 다루기 위해

    원인 예외 1개로 인해 N개의 예외가 발생했다면, 원인 예외를 지정해줌으로써 N개의 예외들을 묶어서 다룰 수 있음

 

  1. Checked 예외를 Unchecked 예외로 바꿀 수 있게 하기 위해

    Checked 예외가 발생해도 처리할 수 없는 경우가 있는데, 이 때 실속없이 try-catch문을 쓰기 보다는 Unchecked 예외로 바꿔주는게 나음.

    initCause를 사용하는 대신 RuntimeException의 생성자를 활용해 원인 예외의 인스턴스를 감싸주는 방법도 있음.

    RuntimeException(Throwable causeException); // 원인 예외를 지정하는 생성자

    이렇게 해서 예외를 Unchecked로 바꾼 뒤에는 메서드 선언부의 throws로 선언하지 않아도 됨

 

예시)

class ChainedExceptionExample {
    public static void main(String[] args) {
        try {
            install();
        } catch (InstallException e) {
            e.printStackTrace();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    static void install() throws InstallException {
        try {
            startInstall();
            copyFiles();
        } catch (SpaceException | MemoryException e) {
            InstallException ie = new InstallException("설치 작업 도중 예외 발생");
            ie.initCause(e);
            throw ie;
        } finally {
            deleteTempFiles();
        }
    }

    static void startInstall() throws SpaceException, MemoryException {
        if (!enoughSpace()) {
            throw new SpaceException("설치할 공간이 부족합니다");
        }
        if (!enoughMemory()) {
            throw new MemoryException("메모리가 부족합니다");
        }
    }

    static void copyFiles() { ... }
    static void deleteTempFiles() { ... }

    static boolean enoughSpace() {
        ...
        return false;
    }
    static boolean enoughMemory() {
        ...
        return true;
    }
}

class InstallException extends Exception {
    InstallException(String message) {
        super(message);
    }
}

class SpaceException extends Exception {
    SpaceException(String message) {
        super(message);
    }
}

class MemoryException extends Exception {
    MemoryException(String message) {
        super(message);
    }
}

main메서드에서의 주 작업인 install을 진행할 때 InstallException이 발생할 수 있음.

InstallException의 원인 예외로는 2가지(SpaceException, MemoryException)가 있어 총 3종류의 사용자 정의 예외 클래스가 선언되어있음.

install 메서드의 catch블럭에서는 두 원인 예외를 각각 잡았을 때, initCause 메서드로 원인 예외임을 지정해주고(=chaining), 그 둘을 포괄하는 InstallException의 인스턴스를 되던짐.

 

startInstall 메서드에서 MemoryException을 Unchecked로 만들어주는 방법도 있음

    static void install() throws InstallException {
        try {
            startInstall();
            copyFiles();
        } catch (SpaceException e) { // MemoryExeption을 지워줬음
            InstallException ie = new InstallException("설치 작업 도중 예외 발생");
            ie.initCause(e);
            throw ie;
        } finally {
            deleteTempFiles();
        }
    }

static void startInstall() throws SpaceException { // MemoryException을 지워줬음
    if (!enoughSpace()) {
            throw new SpaceException("설치할 공간이 부족합니다");
        }
    if (!enoughMemory()) {
        throw new RuntimeException(new MemoryException("메모리가 부족합니다")); // RuntimeException의 생성자로 감싸서 Unchecked 예외로 바꿨음
    }
}

실행 결과)

InstallException: 설치 작업 도중 예외 발생
    at ChainedExceptionExample.install(TryWithResourcesExample.java:17)
    at ChainedExceptionExample.main(TryWithResourcesExample.java:4)
Caused by: SpaceException: 설치할 공간이 부족합니다
    at ChainedExceptionExample.startInstall(TryWithResourcesExample.java:27)
    at ChainedExceptionExample.install(TryWithResourcesExample.java:14)
    ... 1 more
profile
Back-end Engineer 👨‍💻
post-custom-banner

0개의 댓글