Posts :book: 자바의 정석 #8: 예외처리
Post
Cancel

:book: 자바의 정석 #8: 예외처리

예외처리

1.1 프로그램 오류

  • 컴파일 에러: 컴파일 시에 발생하는 에러
    • 오타, 잘못된 구문, 자료형 체크 등 기본적인 검사
    • 컴파일 에러가 없을 경우 class 파일이 만들어져 실행 가능
  • 런타임 에러: 실행 시에 발생하는 에러
    • 에러: 프로그램 코드에 의해서 수습될 수 없는 심각한 오류
    • 예외: 프로그램 코드에 의해서 수습될 수 잇는 다소 미약한 오류
  • 논리적 에러: 실행은 되지만, 의도와 다르게 동작하는 것

예외는 발새어하더라도 프로그래머가 이에 대한 적절한 코드를 미리 작성해 놓으믕로써 프로그램의 비정상적인 종료를 막을 수 있다.

예외 클래스의 계층 구조

  • Throwable
    • Exception
      • RuntimeException
    • Error
  • RuntimeException클래스들: 프로그래머의 실수로 발생하는 예외
    • 자바의 프로그래밍 요소들과 관계가 깊다
    • ex: 배열의 범위를 벗어나거나(IndexOutOfBounds), 값이 null인 참조변수의 멤버를 호출하거나(NullPointer), 클래스간의 형변환을 잘못하거나(ClassCast), 정수를 0으로 나누려고 할 때(Arithmetic)
  • Exception 클래스들: 사용자의 실수와 같은 외적 요인에 의해 발생하는 예외
    • ex: 존재하지 않는 파일의 이름을 입력하거나(FileNotFound), 실수로 클래스의 이름을 잘못 적었거나(ClassNotFound), 입력한 데이터 형식이 잘못된 경우(DataFormat)

예외처리(exception handling)

  • 정의: 프로그램 실행 시 발생할 수 있는 예외의 발생에 대비한 코드를 작성한 것
  • 목적: 프로그램의 비정상 종료를 막고, 정상적인 실행상태를 유지하는 것

발생한 예외를 처리하지 못하면, 프로그램은 비정상적으로 종료되며, 처리하지 못한 예외(uncaught exception)는 JVM의 예외처리기(UncaughtExceptionHandler)가 받아서 예외의 원인을 화면에 출력한다.

어떤 예외가 발생했는지 알려는 것은 목적이 아니다. 어차피 JVM의 예외처리기가 처리하지 못한 예외를 받아서 예외의 원인을 화면에 출력하기 때문이다.

try-catch 문

1
2
3
4
5
6
7
try {
  // 예외가 발생할 가능성이 있는 문장
} catch (Exception1 e1) {
  // Exception1이 발생할 경우, 이를 처리하기 위한 문장
} catch (Exception2 e2) {
  // Exception2가 발생한 경우, 이를 처리하기 위한 문장
}

try-catch에서의 흐름

  • try 블럭 내에서 예외가 발생한 경우
    • 발생한 예외와 일치하는 catch 블럭이 있는지 확인한다.
    • 일치하는 catch 블럭을 찾게 되면, 그 catch 블럭 내의 문장들을 수행하고 전체 try-catch문을 빠져나가서 그 다음 문장을 계속해서 수행한다. 만약 일치하는 catch블럭을 찾지 못하면, 예외는 처리되지 못한다.
  • try 블럭 내에서 예외가 발생하지 않은 경우,
    • catch블럭을 거치지 않고 전체 try-catch 문을 빠져나가서 수행을 계속한다.

try 블럭에서 예외가 발생하면 예외가 발생한 위치 이후에 있는 try 블럭의 문장들은 실행되지 않으므로, try 블럭에 포함시킬 코드의 범위를 잘 선택해야 한다.

예외의 발생과 catch 블럭

  • 예외가 발생하면, 발생한 예외에 해당하는 클래스의 인스턴스가 생성된다.
  • 예외가 발생한 문장이 try블럭에 포함되어 있다면, 예외를 처리할 수 있는 catch 블럭이 있는지 찾는다.
  • 첫 번째 catch 블럭부터 차례로 내려가면서 catch 블럭의 괄호() 내에 선언된 참조변수의 타입생성된 예외클래스의 인스턴스instanceof 연산자를 이용해서 검사한다. 검사결과가 true인 catch 블럭을 만날 때까지 검사는 계속된다.
  • 검사 결과가 true인 catch 블럭이 없다면 예외는 처리되지 않는다.

printStackTrace()와 getMessage()

예외가 발생했을 때 생성되는 예외 클래스의 인스턴스에는 발생한 예외에 대한 정보가 담겨있으며, getMessage()와 printStackTrace()를 통해서 이 정보를 얻을 수 있다. catch 블럭의 괄호()에 선언된 참조변수를 통해 이 인스턴스에 접근할 수 있다.

  • printStackTrace() : 예외 발생 당시에 호출 스택(Call Stack)에 있었던 메서드의 정보와 예외 메시지를 화면에 출력한다.
  • getMessage(): 발생한 예외 클래스의 인스턴스에 저장된 메시지를 읽을 수 있다.

멀티 catch 블럭

JDK1.7부터 여러 catch 블럭을 | 기호를 이용해서 하나의 catch 블럭을 합칠 수 있게 되었다. 이때 |는 논리 연산자가 아니라 기호(단순한 약속)이다.

1
2
3
4
5
6
try {
} catch (ExceptionA e) {
  e.printStackTrace();
} catch (ExceptionB e) {
  e.printStackTrace();
}

위와 같은 코드를 다음과 같이 바꿀 수 있다.

1
2
3
4
try {} 
catch (ExceptionA | ExceptionB e) {
  e.printStackTrace();
}

만일 |기호로 연결된 예외 클래스가 상속 관계에 있다면 컴파일 에러가 발생한다. 왜냐하면 수퍼 클래스만 써 주는 것과 똑같기 때문이다. 불필요한 코드는 제거하라는 의미에서 에러가 발생하는 것이다.

그리고 멀티 catch는 하나의 catch블럭으로 여러 예외를 처리하는 것이기 때문에, 멀티 catch블럭 내에서는 실제로 어떤 예외가 발생한 것인지 알 수 없다. 그래서 참조변수 e로 멀티 catch 블럭에 | 기호로 연결된 예외 클래스들의 공통 분모인 조상 예외 클래스에 선언된 멤버만 사용할 수 있다.

필요하다면 instanceof 연산자로 나눈 다음 형변환을 사용해서 각 예외의 멤버를 사용할 수 있지만, 이럴 거면 멀티 catch 문을 쓰지 않는게 낫다.

멀티 catch 블럭에 선언된 참조변수 e는 상수이므로 값을 변경할 수 없다는 제약이 있다. 이는 여러 catch 블럭이 하나의 참조변수를 공유하기 때문에 생기는 제약이다. 하지만 실제 참주변수 값을 바꿀 일은 없을 것이다.

예외 발생시키기

  • 먼저, 연산자 new를 이용해서 발생시키려는 예외 클래스의 객체를 만든다.
    • Exception e = new Exception("고의로 발생시켰음!")
      • 인스턴스 생성할 때 String을 넣어주면 이 문자열이 Exception 인스턴스에 메시지로 저장된다. 이 메시지는 getMessage()를 이용해서 얻을 수 있따.
  • 키워드 throw를 이용해서 예외를 발생시킨다.
    • throw e

Exception 클래스들에 대해 예외처리를 하지 않으면 컴파일조차 되지 않는다. RuntimeException을 발생시켰을 때는 컴파일이 성공적으로 된다. 이 예외는 프로그래머가 실수로 발생하는 것이기 때문에 예외처리를 강제하지 않는다. 만일 RuntimeException 클래스들에 속하는 예외가 발생할 가능성이 있는 코드에도 예외를 처리해야 한다면, 참조변수와 배열이 사용되는 모든 곳에 예외 처리를 해주어야 할 것이다.

1
2
3
4
5
6
7
8
try {
  int arr = new int[10];
  System.out.println(arr[0]);
} catch (IndexOutOfBoundsException e) {
  //...
} cathc (NullPointException e) {
  //...
}
  • checked 예외: 컴파일러가 예외처리를 확인하는 Exception 클래스들과 Error 클래스들
  • unchecked 예외: 컴파일러가 예외처리를 확인하지 않는 RuntimeException 클래스들

메서드에 예외 선언

메서드의 선언부에 예외를 선언함으로써 메서드를 사용하려는 사람이 메서드의 선언부를 보았을 때, 이 메서드를 사용하기 위해서는 어떤 예외들이 처리되어져야 하는지 쉽게 알 수 있다.

기존 언어는 메서드에 예외선언을 하지 않았기 때문에 어떤 종류의 예외가 발생할 가능성이 있는지 충분히 예측하기 힘들었다. 그러나 자바에서는 메서드를 작성할 대 메서드 내에서 발생할 가능성이 있는 예외를 메서드 선언부에 명시하여 이 메서드를 사용하는 쪽에서 이에 대한 처리를 하도록 강요하기 때문에 보다 더 견고한 프로그램 코드를 작성할 수 있도록 한다.

메서드에 예외를 선언할 때 일반적으로 RuntimeException 클래스들은 적지 않는다. 보통 반드시 처리해줘야 하는예외들만 선언한다.

사실 예외를 메서드의 trhwos에 명시하는 것은 예외를 처리하는 것이 아니라, 자신(예외가 발생할 가능성이 있는 메서드)을 호출한 메서드에게 예외를 전달하여 예외처리를 떠맡기는 것이다. 예외를 전달받은 메서드는 또 자신을 호출한 메서드에게 전달할 수 있고, 이렇게 계속 호출 스택에 있는 메서드를 따라 전달되다가 마지막에 있는 main 메서드에서도 예외가 처리되지 않으면 main 메서드마저 종료되어 프로그램 전체가 종료된다.

1
2
3
4
java.lang.Exception
	at ExceptionEx12.method2 (ExceptionEx12.java:11)
	at ExceptionEx12.method1 (ExceptionEx12.java:7)
	at ExceptionEx12.main (ExceptionEx12.java:3)

이 예외 메시지로 다음과 같은 사실을 알 수 있다.

  • 예외가 발생했을 때, 모두 3개의 메서드(main, method1, method2)가 호출 스택에 있었으며,
  • 예외가 발생한 곳은 제일 윗줄에 있는 method2()라는 것과
  • main메서드가 method1()를, 그리고 method1()은 method2()를 호출했다는 것을 알 수 잇다.

예외를 선언하여 호출한 메서드에 넘겨줄 수 있지만 이것으로 예외가 처리된 것은 아니고 단순히 전달만 한 것이다. 결국 어느 한 곳에서는 반드시 try-catch 문으로 예외처리를 해야 한다. 예외처리를 하고 printStackTrace()를 통해 호출 스택의 내용을 알 수 있다.

예외가 발생한 메서드에서 예외를 처리할 수도 있고, 예외가 발생한 메서드를 호출한 메서드에서 처리할 수 ㅇ맀다. 또는 두 메서드가 예외처리를 분담할 수도 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class ExceptionEx16 {
  public static void main(String[] args) {
    try {
      File f = createFile(args[0]);
      System.out.println(f.getName() + "파일이 성공적으로 생성되었습니다.");
    } catch (Exception e) {
      System.out.println(e.getMessage() + "다시 입력해주시기 바랍니다.");
    }
  }
  
  public static File createFile(String filename) throws Exception {
    if (fileName == nulll || fileName.equals(""))
      throw new Exception("파일이름이 유효하지 않습니다.");
    File f = new File(fileName);
    f.createNewFile();
    return f;
  }
}

finally 블럭

finall블럭은 예외의 발생여부와 상관없이 실행되어야할 코드를 포함시킬 목적으로 사용된다.

1
2
3
4
5
6
7
8
try {
  // 예외가 발생할 가능성이 있는 문장
} catch (Exception1 e1) {
  // 예외 처리를 위한 문장
} finally {
  // 예외 발생 여부에 관계 없이 항상 수행되어야 하는 문장
  // try-catch 문의 마지막에 위치
}

try 문에서 return 문이 실행되는 경우finally 블럭의 문장들이 먼저 실행된 후에 현재 실행 중인 메서드를 종료한다.

자동 자원 반환: try with resources문

이 구문은 주로 입출력과 관련된 클래스를 사용할 때 유용하다. 입출력 클래스 중 사용한 자원을 닫아줘야 하는 경우가 있다. 작업 중에 예외가 발생하더라도 닫히도록 finally 블록에 넣어야 하는데, 문제는 close() 메서드 자체도 예외가 발생할 수 있는 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
try {
  fis = new FileInputStream("score.dat");
  dis = new DataInputStream(fis);
  //..
} catch (IOException ie) {
  ie.printStackTrace();
} finally {
  try {
    if (dis != null)
      dis.close();
  } catch (IOException ie) {
    ie.printStackTrace();
  }
}

코드 가독성이 떨어질 뿐만 아니라, try 블럭과 finally 블럭 모두에서 예외가 발생하면 try 블럭의 예외는 무시된다.

1
2
3
4
5
6
7
8
9
10
11
12
try (fis = new FileInputStream("score.dat");
  dis = new DataInputStream(fis)) {
  while (true) {
    score = dis.readInt();
    System.out.println(score);
    sum += score;
  }
} catch (EOFException e) {
  System.out.println("점수의 총 합은" + sum + "입니다. ");
} catch (IOException e) {
  ie.printStackTrace();
}

위와 같이 try with resources 문의 괄호 안에 객체를 생성하는 문장을 넣으면 이 객체는 따로 close()를 호출하지 않아도 try 블럭이 벗어나는 순간 자동적으로 close()가 호출된다. 그 다음 catch 블럭 또는 finally 블럭이 수행된다.

try 블럭 괄호 안에 변수를 선언한다면 선언된 변수는 try 블럭 내에서만 사용할 수 있다.

단, 자동으로 객체의 close()가 호출될 수 있으려면 클래스가 AutoCloseable 인터페이스를 구현한 것이어야만 한다.

1
2
3
public interface AutoCloseable {
  void close() throws Exception;
}

그런데, 위 코드의 close()도 Exception을 발생시킬 수 있다. 만일 자동 호출된 close() 에서 예외가 발생된다면 어떻게 될까?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
package com.monica.textbook.chap8;

class TryWithResourceEx {
  public static void main(String[] args) {
    try (CloseableResource cr = new CloseableResource()) {
      cr.exceptionWork(false);
    } catch (WorkException e) {
      e.printStackTrace();
    } catch (CloseException e) {
      e.printStackTrace();
    }
    System.out.println();

    try (CloseableResource cr = new CloseableResource()) {
      cr.exceptionWork(true);
    } catch(WorkException e) {
      e.printStackTrace();
    } catch (CloseException e) {
      e.printStackTrace();
    }
  }
}


class CloseableResource implements AutoCloseable {
  public void exceptionWork(boolean exception) throws WorkException {
    System.out.println("exceptionWork(" + 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 msg) { super(msg); }
}

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

실행결과

1
2
3
4
5
6
7
8
9
10
11
12
13
14
exceptionWork(false)가 호출됨!
close()가 호출됨
com.monica.textbook.chap8.CloseException: CloseException 발생!

exceptionWork(true)가 호출됨!
	at com.monica.textbook.chap8.CloseableResource.close(TryWithResourceEx.java:35)
	at com.monica.textbook.chap8.TryWithResourceEx.main(TryWithResourceEx.java:7)
close()가 호출됨
com.monica.textbook.chap8.WorkException: WorkException 발생!
	at com.monica.textbook.chap8.CloseableResource.exceptionWork(TryWithResourceEx.java:30)
	at com.monica.textbook.chap8.TryWithResourceEx.main(TryWithResourceEx.java:15)
	Suppressed: com.monica.textbook.chap8.CloseException: CloseException 발생!
		at com.monica.textbook.chap8.CloseableResource.close(TryWithResourceEx.java:35)
		at com.monica.textbook.chap8.TryWithResourceEx.main(TryWithResourceEx.java:16)

첫 번째는 일반적인 예외가 발생했을 때와 같은 형태로 출력되었지만, 두 번재는 출력 형태가 다르다. 먼저 exceptionWork()에서 발생한 예외에 대한 내용이 출력되고, close()에서 발생한 예외는 ‘억제된(suppressed)’이라는 의미의 머리말과 함께 출력되었다. 두 예외가 동시에 발생할 수는 없기 때문에, 실제 발생한 예외를 WorkException으로 하고, CloseException은 억제된 예외로 다룬다. 억제된 예외에 대한 정보는 실제로 발생한 예외인 WorkException에 저장된다.

Throwable에는 억제된 예외와 관련된 다음과 같은 메서드가 정의되어 있다.

1
2
void addSuppressed(Throwable exception); // 억제된 예외를 추가
Throwable[] getSuppressed(); // 억제된 예외(배열)을 반환

만일 기존의 try- catch문을 사용했다면, 먼저 발생한 WorkException은 무시되고, 마지막으로 발생한 CloseException에 대한 내용만 출력되었을 것이다.

사용자정의 예외 만들기

가능하면 새로운 예외 클래스를 만들기보다 기존 예외 클래스를 활용하자.

기존에의 예외 클래스는 주로 Exception을 상속받아서 checked예외로 작성하는 경우가 많았지만, 요즘은 예외처리를 선택적으로 할 수 있도록 RuntimeException을 상속받아서 작성하는 쪽으로 바뀌어가고 있다. checked 예외는 반드시 예외처리를 해주어야 하기 때문에 예외처리가 불필요한 경우에도 try-catch 문을 넣어서 코드가 복잡해지기 때문이다.

예외처리를 강제하도록 한 이유는 프로그래밍 경험이 적은 사람들도 보다 견고한 프로그램을 작성할 수 있게 유도하기 위한 것이었는데, 요즘은 프로그래밍 환경이 많이 달라졌다. 그때 자바를 설계하던 사람들은 자바가 주로 소형 가전기기나 데스크탑에서 실행될 것이라고 생각했지만, 현재 자바는 모바일이나 웹 프로그래밍에서 주로 쓰이고 있다. 이처럼 프로그래밍 환경이 달라진 만큼 필수적으로 처리해야만 할 것 같던 예외들이 선택저그올 처리해도 되는 상황으로 바뀌는 경우가 발생하고 있다. 그래서 필요에 따라 예외처리의 여부를 선택할 수 있는 ‘unchecked’ 예외가 환영받고 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
package com.monica.textbook.chap8;

class NewExceptionTest {
  public static void main(String[] args) {
    
    try {
      startInstall();
      copyFiles();
    } catch (SpaceException e) {
      System.out.println("에러 메시지 : " + e.getMessage());
      e.printStackTrace();
      System.out.println("공간을 충분히 확보한 후에 다시 설치하시기 바랍니다.");
    } catch (MemoryException e) {
      System.out.println("에러메시지 : "+ e.getMessage());
      e.printStackTrace();
      System.gc();
      System.out.println("다시 설치를 시도하세요.");
    } 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 false;
  }
  
}

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

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

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

단 하나의 예외에 대해서도 예외가 발생한 메서드와 호출한 메서드 양쪽에서 처리하도록 할 수 있다. 이것은 예외를 처리한 후 인위적으로 다시 발생시키는 방법을 통해서 가능한데, 이것을 예외 되던지기(exception re-throwing)라고 한다. 먼저 예외가 발생할 가능성이 있는 메서드에서 try-catch 문을 사용해서 예외를 처리해주고, catch문에서 필요한 작업을 행한 후 throw문을 사용해 예외를 다시 발생시킨다. 이 대는 예외가 발생할 메서드에서 try-catch문을 사용하여 예외처리를 해주는 동시에 메서드 선언부에 발생할 예외를 throws에 지정해준다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class ExceptionEx17 {
  public static void main(String[] args) {
    try {
      method1();
    } catch (Exception e) {
      System.out.println("main메서드에서 예외가 처리되었습니다.");
    }
  }
  
  static void method1() throws Exception {
    try {
      throw new Exception();
    } catch (Exception e) {
      System.out.println("method1메서드에서 예외가 처리되었습니다.");
      throw e;
    } 
  }
}

반환값이 있는 return문의 경우 catch블럭에도 return 문이 있어야 한다. 예외가 발생했을 경우에도 값을 반환해야 하기 때문이다. 또는 catch 블럭에서 예외 되던지기를 해서 호출한 메서드로 예외를 전달하면, return 문이 없어도 된다. 그래서 검증에서도 asssert 대신 AssertError를 생성해서 던진다.

finally 블럭 내에서도 return문을 사용할 수 있으며, try블럭이나 catch 블럭의 return 문 다음에 수행된다. 최종적으로 finally 블럭 내의 return 문의 값이 반환된다.

1
2
3
4
5
6
7
8
9
10
11
12
static int method1() throws Exception {
  try {
    System.out.println("method1()이 호출되었습니다.");
    return 0; // 실행중인 메서드를 종료
  } catch (Exception e) {
    e.printStackTrace();
    //return 1;
    throw new Exception();
  } finally {
    System.out.println("finally 블록 실행");
  }
}

연결된 예외(chained exception)

한 예외가 다른 예외를 발생시킬 수도 있다.

1
2
3
4
5
6
7
8
try {
  startInstall();
  copyFiles();
} catch (SpaceException e) {
  InstallException ie = new InstallException("설치중 예외발생");
  ie.initCause(e);
  throw ie;
} //..

initCause()는 Exception 클래스의 조상인 Throwable 클래스에 정의되어 있기 때문에 모든 예외에서 사용할 수 있다.

1
2
Throwable initCause(Throwable cause); // 지정한 예외를 원인 예외로 등록
Throwable getCause(); // 원인 예외를 반환

원인 예외로 등록해서 다시 예외를 발생시키는 이유는 여러 가지 예외를 하나의 큰 분류의 예외로 묶어서 다루기 위함이다. 그렇다고 상속관계로 만들면 발생한 예외가 어떤 것인지 알 수 없다는 문제가 생긴다. 또한 다른 상속관계가 존재할 때 변경하기도 부담스럽다.

1
2
3
public class Throwable implements Serializable {
  private Throwable cause = this; // 객체 자신(this)을 원인 예외로 등록 
}

또 다른 이유는 checked 예외를 unchecked 예외로 바꿀 수 있도록 하기 위해서이다. 의미없는 try-catch 문을 추가하하지 않고 checked 예외를 unchecked 예외로 바꾸면 예외처리가 선택적으로 되니 억지로 예외처리를 하지 않아도 된다.

1
2
3
4
5
6
7
static void startInstall() throws SpaceException, MemoryException {
  if (!enoughSpace())
    throw new SpaceException("설치할 공간이 부족합니다.");
  if (!enoughMemory())
    throw new RuntimeException(new MemoryException("메모리가 부족합니다."));
}

위의 코드에서 initCase()대신 RutimeException 생성자를 사용했다.

1
RuntimeException(Throwable cause); // 원인 예외를 등록하는 생성자.
This post is licensed under CC BY 4.0 by the author.

💻 HTTP #8: HTTP 조건부 요청

코어 자바스크립트 #2.11: 논리연산자

Loading comments from Disqus ...