프로그램을 실행하다 보면 개발자가 예상하지 못한 상황이 발생할 수 있다.
사용자가 숫자를 입력해야 하는 곳에 문자열을 입력하거나, 존재하지 않는 파일을 읽으려고 할 수도 있다. 배열의 범위를 벗어난 인덱스에 접근하거나 null인 객체의 메서드를 호출할 수도 있다.
int result = 10 / 0;
위 코드는 실행 중 ArithmeticException을 발생시킨다.
예외를 처리하지 않으면 프로그램은 해당 지점에서 비정상적으로 종료될 수 있다. 그러나 모든 오류 상황에서 프로그램을 즉시 종료하는 것이 적절한 것은 아니다.
잘못된 입력을 다시 요청하거나, 파일이 없다는 메시지를 출력하거나, 작업을 취소한 뒤 안전하게 다음 흐름으로 이동할 수 있어야 한다.
Java는 이러한 상황을 처리하기 위해 예외 처리(Exception Handling) 기능을 제공한다.
이번 글에서는 예외 클래스의 계층 구조부터 Checked Exception과 Unchecked Exception의 차이, try-catch-finally, try-with-resources, throw와 throws, 예외 변환, 사용자 정의 예외까지 정리해보려고 한다.
프로그램 실행 중 정상적인 흐름을 방해하는 문제는 크게 Error와 Exception으로 구분할 수 있다.
Throwable
├─ Error
└─ Exception
Throwable은 Java에서 오류와 예외를 표현하는 최상위 클래스다.
Error는 일반적으로 애플리케이션 코드에서 복구하기 어려운 심각한 문제를 나타낸다.
대표적인 예는 다음과 같다.
OutOfMemoryErrorStackOverflowErrorVirtualMachineErrorpublic static void recursive() {
recursive();
}
위 메서드를 계속 호출하면 호출 스택이 가득 차면서 StackOverflowError가 발생할 수 있다.
Error는 대부분 개발자가 try-catch로 복구하기보다 원인을 찾아 프로그램이나 실행 환경을 수정해야 하는 문제다.
Exception은 프로그램에서 처리하거나 복구할 가능성이 있는 예외 상황을 나타낸다.
대표적인 예는 다음과 같다.
null 객체 사용예외는 모두 객체이며, 예외가 발생하면 JVM은 해당 상황을 표현하는 예외 객체를 생성한다.
예외 상황 발생
→ 예외 객체 생성
→ 예외 객체를 던짐
→ 처리 가능한 catch 탐색
Java 예외는 다음과 같은 계층 구조를 가진다.
Throwable
├─ Error
└─ Exception
├─ RuntimeException
│ ├─ NullPointerException
│ ├─ ArithmeticException
│ ├─ ClassCastException
│ ├─ NumberFormatException
│ └─ IndexOutOfBoundsException
│
├─ IOException
├─ SQLException
├─ ClassNotFoundException
└─ ...
RuntimeException을 기준으로 Checked Exception과 Unchecked Exception을 구분한다.
Checked Exception은 컴파일러가 예외 처리 여부를 확인하는 예외다.
예외가 발생할 수 있는 코드를 작성했다면 다음 중 하나를 반드시 선택해야 한다.
try-catch로 직접 처리throws로 호출한 곳에 처리 책임 전달처리하지 않으면 컴파일이 되지 않는다.
대표적인 Checked Exception은 다음과 같다.
IOExceptionSQLExceptionClassNotFoundExceptionInterruptedExceptionClass.forName("com.example.Sample");
Class.forName()은 ClassNotFoundException이 발생할 수 있으므로 반드시 처리해야 한다.
public class CheckedExceptionExample {
public static void main(String[] args) {
try {
Class.forName(
"com.example.Sample"
);
} catch (ClassNotFoundException e) {
System.out.println(
"클래스를 찾을 수 없습니다."
);
}
System.out.println("프로그램 정상 종료");
}
}
Unchecked Exception은 컴파일러가 예외 처리 여부를 강제하지 않는 예외다.
RuntimeException과 그 하위 클래스가 해당한다.
대표적인 예는 다음과 같다.
NullPointerExceptionArithmeticExceptionNumberFormatExceptionClassCastExceptionIndexOutOfBoundsExceptionIllegalArgumentExceptionint number =
Integer.parseInt("Java");
위 코드는 NumberFormatException을 발생시킬 수 있지만 처리 코드가 없어도 컴파일된다.
public class UncheckedExceptionExample {
public static void main(String[] args) {
int number =
Integer.parseInt("Java");
System.out.println(number);
}
}
실행 시 예외가 발생하면 프로그램이 중단된다.
| 구분 | Checked Exception | Unchecked Exception |
|---|---|---|
| 상속 계층 | RuntimeException 이외의 Exception 하위 | RuntimeException 하위 |
| 컴파일러 검사 | 처리 여부 검사 | 처리 여부 강제하지 않음 |
| 처리 방법 | try-catch 또는 throws 필수 | 필요에 따라 처리 |
| 대표 사례 | 파일, DB, 네트워크 등 외부 자원 | 잘못된 인수, null, 인덱스 오류 |
| 주된 의미 | 복구 가능한 외부 문제 | 프로그래밍 오류나 잘못된 상태 |
Checked Exception이라고 항상 복구 가능한 것은 아니고, Unchecked Exception이라고 절대 처리하면 안 되는 것도 아니다.
중요한 것은 예외의 종류보다 현재 계층에서 의미 있게 처리할 수 있는가다.
예외가 발생할 수 있는 코드를 try 블록에 작성하고, 예외가 발생했을 때 실행할 코드를 catch 블록에 작성한다.
try {
// 예외가 발생할 수 있는 코드
} catch (ExceptionType e) {
// 예외가 발생했을 때 처리할 코드
}
문자열을 숫자로 변환하는 예제를 살펴보자.
public class TryCatchExample {
public static void main(String[] args) {
String input = "Java";
try {
int number =
Integer.parseInt(input);
System.out.println(number);
} catch (NumberFormatException e) {
System.out.println(
"숫자로 변환할 수 없습니다."
);
}
System.out.println(
"프로그램을 계속 실행합니다."
);
}
}
실행 결과는 다음과 같다.
숫자로 변환할 수 없습니다.
프로그램을 계속 실행합니다.
try 블록에서 예외가 발생하지 않으면 catch 블록은 실행되지 않는다.
try 시작
→ 정상 실행
→ catch 건너뜀
→ 다음 코드 실행
예외가 발생하면 해당 지점 이후의 try 코드는 실행되지 않는다.
try 시작
→ 예외 발생
→ 예외 객체 생성
→ 일치하는 catch로 이동
→ catch 실행
→ 다음 코드 실행
public class ExceptionFlowExample {
public static void main(String[] args) {
try {
System.out.println("A");
int result = 10 / 0;
System.out.println("B");
} catch (ArithmeticException e) {
System.out.println("C");
}
System.out.println("D");
}
}
실행 결과는 다음과 같다.
A
C
D
10 / 0에서 예외가 발생했으므로 "B"는 출력되지 않는다.
catch (NumberFormatException e)
e에는 JVM이 던진 예외 객체가 전달된다.
예외 객체를 통해 어떤 문제가 발생했는지 확인할 수 있다.
catch (NumberFormatException e) {
System.out.println(e.getMessage());
}
모든 예외 클래스는 Throwable을 상속하므로 Throwable이 제공하는 메서드를 사용할 수 있다.
예외에 포함된 상세 메시지를 반환한다.
try {
Integer.parseInt("Java");
} catch (NumberFormatException e) {
System.out.println(e.getMessage());
}
출력되는 메시지는 예외가 발생한 원인과 입력값에 대한 정보를 포함할 수 있다.
현재 예외의 원인이 된 다른 예외 객체를 반환한다.
원인이 설정되어 있지 않다면 null을 반환한다.
Throwable cause = e.getCause();
예외를 다른 예외로 변환해 던질 때 원래 예외를 연결해두면 getCause()로 확인할 수 있다.
예외가 발생한 위치와 메서드 호출 흐름을 출력한다.
catch (Exception e) {
e.printStackTrace();
}
Stack Trace에는 다음 정보가 포함된다.
java.lang.NumberFormatException
at java.lang.Integer.parseInt(...)
at Example.convert(Example.java:15)
at Example.main(Example.java:7)
Stack Trace는 디버깅할 때 매우 중요한 정보다.
다만 실제 서버 애플리케이션에서는 printStackTrace()보다 로깅 프레임워크를 통해 예외를 기록하는 경우가 많다.
하나의 try 블록에서 여러 종류의 예외가 발생할 수 있다면 여러 catch 블록을 작성할 수 있다.
public class MultipleCatchExample {
public static void main(String[] args) {
String[] values = {
"10",
"Java"
};
try {
int index = 2;
int number = Integer.parseInt(
values[index]
);
System.out.println(number);
} catch (
ArrayIndexOutOfBoundsException e
) {
System.out.println(
"배열 인덱스를 확인하세요."
);
} catch (
NumberFormatException e
) {
System.out.println(
"숫자 형식을 확인하세요."
);
}
}
}
발생한 예외와 일치하는 catch 블록 하나만 실행된다.
예외 클래스도 상속 관계를 가진다.
Exception
↑
RuntimeException
↑
NumberFormatException
catch 블록을 찾을 때 다형성이 적용되므로 자식 예외부터 부모 예외 순서로 작성해야 한다.
try {
Integer.parseInt("Java");
} catch (NumberFormatException e) {
System.out.println(
"숫자 변환 오류"
);
} catch (Exception e) {
System.out.println(
"그 밖의 예외"
);
}
상위 예외를 먼저 작성하면 하위 예외의 catch 블록은 실행될 기회가 없다.
try {
Integer.parseInt("Java");
} catch (Exception e) {
System.out.println("전체 예외 처리");
}
// 컴파일 오류
// catch (NumberFormatException e) {
// }
Exception이 NumberFormatException까지 먼저 처리하기 때문에 뒤의 블록은 도달할 수 없는 코드가 된다.
처리 방법이 같은 여러 예외를 하나의 catch 블록에서 처리할 수 있다.
try {
// 예외 발생 가능 코드
} catch (
NumberFormatException
| NullPointerException e
) {
System.out.println(
"입력값을 확인하세요."
);
}
|로 연결한 예외는 서로 부모와 자식 관계이면 안 된다.
// 사용할 수 없음
// catch (RuntimeException
// | NumberFormatException e) {
// }
NumberFormatException이 이미 RuntimeException에 포함되기 때문이다.
가능하면 예외 상황에 맞는 구체적인 처리를 제공하는 것이 좋다.
catch (NumberFormatException e) {
System.out.println(
"숫자만 입력할 수 있습니다."
);
}
모든 예외를 무조건 Exception 하나로 처리하면 사용자에게 정확한 원인을 설명하기 어렵다.
catch (Exception e) {
System.out.println(
"오류가 발생했습니다."
);
}
그러나 모든 예외를 지나치게 세분화하면 코드가 복잡해질 수 있다.
같은 방식으로 처리할 예외들은 멀티 catch나 공통 상위 타입으로 묶는 것이 적절하다.
finally 블록은 예외 발생 여부와 관계없이 try-catch가 끝날 때 실행된다.
try {
System.out.println("작업 실행");
} catch (Exception e) {
System.out.println("예외 처리");
} finally {
System.out.println("항상 실행");
}
예외가 발생하지 않아도 실행되고, 예외가 발생해 catch가 실행된 뒤에도 실행된다.
public class FinallyReturnExample {
public static int test() {
try {
System.out.println("try");
return 1;
} finally {
System.out.println("finally");
}
}
public static void main(String[] args) {
int result = test();
System.out.println(result);
}
}
실행 결과는 다음과 같다.
try
finally
1
return으로 메서드가 종료되기 전에 finally가 먼저 실행된다.
finally를 일반적으로 항상 실행된다고 설명하지만 절대적인 보장은 아니다.
다음과 같은 경우 실행되지 않을 수 있다.
System.exit()로 JVM이 종료된 경우try {
System.exit(0);
} finally {
System.out.println(
"실행되지 않을 수 있음"
);
}
finally는 주로 사용한 자원을 정리하기 위해 사용한다.
예를 들어 다음 자원은 사용 후 반납이 필요하다.
자원을 반납하지 않으면 시스템 자원이 계속 점유되는 Resource Leak이 발생할 수 있다.
FileInputStream input = null;
try {
input = new FileInputStream(
"data.txt"
);
int value = input.read();
System.out.println(value);
} catch (IOException e) {
e.printStackTrace();
} finally {
if (input != null) {
try {
input.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
하지만 자원을 닫는 close()에서도 예외가 발생할 수 있기 때문에 코드가 복잡해진다.
이를 개선하기 위해 try-with-resources가 도입되었다.
try-with-resources는 try 선언부에서 생성한 자원을 자동으로 닫아주는 문법이다.
try (
Resource resource = new Resource()
) {
// 자원 사용
} catch (Exception e) {
// 예외 처리
}
try 블록을 벗어날 때 자동으로 close()가 호출된다.
import java.io.FileInputStream;
import java.io.IOException;
public class ResourceExample {
public static void main(String[] args) {
try (
FileInputStream input =
new FileInputStream(
"data.txt"
)
) {
int value = input.read();
System.out.println(value);
} catch (IOException e) {
System.out.println(
"파일 처리 중 오류가 발생했습니다."
);
e.printStackTrace();
}
}
}
finally에서 직접 close()를 호출하지 않아도 된다.
try-with-resources에서 사용할 객체는 AutoCloseable 인터페이스를 구현해야 한다.
public interface AutoCloseable {
void close() throws Exception;
}
FileInputStream, BufferedReader, JDBC의 Connection과 같은 많은 자원 클래스가 AutoCloseable을 구현한다.
여러 자원을 함께 선언할 수 있다.
try (
FileInputStream input =
new FileInputStream(
"input.txt"
);
FileOutputStream output =
new FileOutputStream(
"output.txt"
)
) {
output.write(input.read());
}
자원은 선언한 순서의 반대로 닫힌다.
input 생성
output 생성
작업 종료
output close
input close
try 선언부의 자원 변수는 다시 대입할 수 없다.
try (
FileInputStream input =
new FileInputStream(
"data.txt"
)
) {
// input = new FileInputStream("other.txt");
// 재할당 불가
}
사실상 final처럼 취급된다.
Java 9부터는 외부에서 선언된 final 또는 사실상 final인 변수를 try 선언부에서 사용할 수 있다.
FileInputStream input =
new FileInputStream("data.txt");
try (input) {
System.out.println(input.read());
}
단, 해당 변수는 이후 다른 값으로 재할당되지 않은 상태여야 한다.
throw는 예외 객체를 직접 발생시키는 키워드다.
throw new IllegalArgumentException(
"나이는 음수일 수 없습니다."
);
예외 상황을 개발자가 직접 정의하여 발생시킬 수 있다.
public class Member {
private int age;
public void setAge(int age) {
if (age < 0) {
throw new IllegalArgumentException(
"나이는 음수일 수 없습니다."
);
}
this.age = age;
}
}
throws는 메서드에서 발생한 예외를 직접 처리하지 않고 호출한 곳으로 전달한다고 선언하는 키워드다.
public void readFile()
throws IOException {
}
예외가 사라지는 것이 아니라 처리 책임이 호출자에게 이동한다.
public static void readFile()
throws IOException {
FileInputStream input =
new FileInputStream(
"data.txt"
);
input.close();
}
호출한 메서드는 다시 처리하거나 위임해야 한다.
public static void main(String[] args) {
try {
readFile();
} catch (IOException e) {
System.out.println(
"파일을 처리할 수 없습니다."
);
}
}
| 구분 | throw | throws |
|---|---|---|
| 역할 | 예외 객체 직접 발생 | 예외 처리 책임 전달 |
| 위치 | 메서드 실행 블록 내부 | 메서드 선언부 |
| 뒤에 오는 것 | 예외 객체 | 예외 클래스 |
| 개수 | 한 번에 하나의 객체 | 여러 예외 클래스 선언 가능 |
throw new IOException();
void read()
throws IOException,
SQLException {
}
Checked Exception은 반드시 다음 중 하나로 처리해야 한다.
try-catch로 직접 처리
또는
throws로 호출자에게 위임
public void loadClass()
throws ClassNotFoundException {
Class.forName(
"com.example.Sample"
);
}
Unchecked Exception은 throws를 작성하지 않아도 자동으로 호출 스택을 따라 전달된다.
public void validate(int age) {
if (age < 0) {
throw new IllegalArgumentException(
"잘못된 나이"
);
}
}
다음처럼 선언할 수도 있지만 컴파일러가 강제하지는 않는다.
public void validate(int age)
throws IllegalArgumentException {
}
Unchecked Exception을 throws에 작성하는 목적은 주로 메서드 사용자가 발생 가능성을 쉽게 알도록 문서화하는 것이다.
현재 메서드에서 예외를 의미 있게 처리할 수 있다면 직접 처리한다.
try {
int number =
Integer.parseInt(input);
} catch (NumberFormatException e) {
System.out.println(
"숫자를 다시 입력하세요."
);
}
현재 계층에서 처리 방법을 결정할 수 없다면 상위 계층으로 전달한다.
public Member findMember(long id) {
return repository.findById(id)
.orElseThrow(
() ->
new MemberNotFoundException(
id
)
);
}
예외를 잡았지만 할 수 있는 일이 없다면 무조건 잡기보다 적절한 계층으로 전달하는 것이 더 좋을 수 있다.
자식 클래스에서 메서드를 오버라이딩할 때 부모 메서드보다 더 넓은 Checked Exception을 선언할 수 없다.
public class Parent {
public void work()
throws IOException {
}
}
자식 클래스는 같은 예외나 더 구체적인 하위 예외를 선언할 수 있다.
public class Child extends Parent {
@Override
public void work()
throws FileNotFoundException {
}
}
다음은 사용할 수 없다.
public class Child extends Parent {
// 더 넓은 Exception 선언 불가
// @Override
// public void work()
// throws Exception {
// }
}
예외를 선언하지 않는 것도 가능하다.
@Override
public void work() {
}
Unchecked Exception은 이 제한을 직접적으로 적용받지 않는다.
하위 계층에서 발생한 기술적인 예외를 상위 계층에서 이해하기 쉬운 업무 예외로 변환할 수 있다.
예를 들어 Repository에서 데이터베이스 관련 예외가 발생했다고 가정해보자.
public Member findById(long id) {
try {
return loadMemberFromDatabase(id);
} catch (SQLException e) {
throw new MemberDataAccessException(
"회원 데이터 조회 실패",
e
);
}
}
상위 계층은 SQLException 같은 데이터베이스 구현 세부사항 대신 MemberDataAccessException이라는 의미 있는 예외를 다룰 수 있다.
예외를 변환할 때 원래 예외를 함께 전달하는 것을 Exception Chaining이라고 한다.
throw new MemberDataAccessException(
"회원 데이터 조회 실패",
e
);
사용자 정의 예외 생성자에서 원인을 부모 생성자에 전달한다.
public class MemberDataAccessException
extends RuntimeException {
public MemberDataAccessException(
String message,
Throwable cause
) {
super(message, cause);
}
}
원래 예외 정보를 보존했기 때문에 Stack Trace에서 전체 원인 흐름을 확인할 수 있다.
MemberDataAccessException
Caused by: SQLException
예외를 변환하면서 원래 예외를 제거하면 실제 원인을 추적하기 어려워진다.
Java 표준 예외만으로도 많은 상황을 표현할 수 있지만, 업무 의미를 명확하게 전달하기 위해 별도의 예외를 만들 수 있다.
예를 들어 다음 상황을 표현해보자.
throw new RuntimeException(
"회원을 찾을 수 없습니다."
);
단순 RuntimeException보다 다음 예외가 의미를 더 명확하게 표현한다.
throw new MemberNotFoundException(id);
RuntimeException을 상속하면 Unchecked Exception이 된다.
public class MemberNotFoundException
extends RuntimeException {
public MemberNotFoundException(long id) {
super(
"회원을 찾을 수 없습니다. id=" + id
);
}
}
사용할 때는 다음과 같다.
public Member findMember(long id) {
if (id != 1L) {
throw new MemberNotFoundException(id);
}
return new Member(id, "김자바");
}
호출한 쪽에서 반드시 try-catch를 작성할 필요는 없다.
Exception을 직접 상속하면 Checked Exception이 된다.
public class InsufficientBalanceException
extends Exception {
public InsufficientBalanceException(
long balance,
long amount
) {
super(
"잔액이 부족합니다. "
+ "잔액="
+ balance
+ ", 출금액="
+ amount
);
}
}
이 예외를 발생시키는 메서드는 처리하거나 throws로 선언해야 한다.
public void withdraw(long amount)
throws InsufficientBalanceException {
if (amount > balance) {
throw new InsufficientBalanceException(
balance,
amount
);
}
balance -= amount;
}
Checked Exception은 호출자가 반드시 처리하도록 강제할 수 있다.
하지만 호출 단계마다 try-catch나 throws가 추가되어 코드가 복잡해질 수 있다.
Unchecked Exception은 코드가 간결하고 여러 계층을 자연스럽게 전파할 수 있다.
하지만 예외 처리가 누락될 가능성이 있다.
최근 애플리케이션 개발에서는 다음과 같은 업무 예외를 Unchecked Exception으로 설계하는 경우가 많다.
Checked Exception은 호출자가 실제로 복구하거나 다른 대안을 선택할 수 있을 때 고려하는 것이 좋다.
다음과 같이 예외를 잡고 아무 작업도 하지 않으면 문제가 사라진 것처럼 보인다.
try {
execute();
} catch (Exception e) {
}
실제로는 예외 정보가 완전히 사라져 원인을 찾기 어렵다.
최소한 로그를 남기거나, 의미 있는 처리를 하거나, 다시 던져야 한다.
catch (Exception e) {
logger.error(
"작업 실행 실패",
e
);
throw e;
}
catch (Exception e)
는 대부분의 예외를 한 번에 잡을 수 있지만 너무 광범위하다.
복구할 수 없는 프로그래밍 오류까지 잘못 처리하여 문제를 숨길 수 있다.
가능하면 필요한 예외를 구체적으로 처리한다.
catch (NumberFormatException e) {
}
최상위 계층에서 예외를 공통 처리해야 할 때는 Exception을 사용할 수 있지만, 내부 로직 곳곳에서 무분별하게 사용하지 않는 것이 좋다.
다음 코드는 예외를 반복문 종료 조건으로 사용한다.
try {
int index = 0;
while (true) {
System.out.println(
values[index++]
);
}
} catch (
ArrayIndexOutOfBoundsException e
) {
}
예외는 정상적인 분기나 반복 종료를 위한 기능이 아니다.
다음처럼 정상적인 조건식을 사용해야 한다.
for (
int index = 0;
index < values.length;
index++
) {
System.out.println(values[index]);
}
예외 객체 생성과 Stack Trace 구성에는 비용도 발생한다.
예외가 발생하기 전에 상태를 확인할 수 있다면 상태 확인 메서드를 사용할 수 있다.
Scanner scanner =
new Scanner(System.in);
if (scanner.hasNextInt()) {
int number = scanner.nextInt();
System.out.println(number);
} else {
System.out.println(
"숫자를 입력해야 합니다."
);
}
hasNextInt()로 상태를 확인한 뒤 nextInt()를 호출하면 잘못된 입력에 대한 예외 발생을 줄일 수 있다.
다만 모든 상황에서 사전 검사만으로 예외를 완전히 막을 수 있는 것은 아니다. 파일이나 네트워크처럼 검사 이후 상태가 변할 수 있는 외부 자원은 실제 작업에서 발생하는 예외도 함께 처리해야 한다.
NullPointerException, IndexOutOfBoundsException, ClassCastException 같은 예외는 무조건 try-catch로 감싸기보다 발생 원인을 수정해야 하는 경우가 많다.
try {
String name = null;
System.out.println(name.length());
} catch (NullPointerException e) {
}
다음처럼 잘못된 상태가 만들어지지 않도록 설계하는 것이 더 좋다.
if (name == null) {
throw new IllegalArgumentException(
"이름은 null일 수 없습니다."
);
}
예외가 발생했을 때 다음 정보를 확인해야 한다.
어떤 예외가 발생했는가?
예외 메시지는 무엇인가?
어느 코드에서 발생했는가?
어떤 메서드 호출을 통해 도달했는가?
원인 예외가 있는가?
Stack Trace는 보통 가장 위의 예외만 보는 것이 아니라 아래쪽의 Caused by까지 확인해야 한다.
ServiceException
at MemberService.find(...)
Caused by: SQLException
at MemberRepository.find(...)
실제 원인은 가장 아래쪽에 가까운 Caused by에 나타나는 경우가 많다.
학습 단계에서는 다음 코드를 자주 사용한다.
e.printStackTrace();
실제 프로젝트에서는 로깅 프레임워크를 사용하는 경우가 많다.
logger.error(
"회원 조회 중 예외 발생. id={}",
id,
e
);
로그에는 다음 정보가 함께 포함될 수 있다.
예외 객체 e를 문자열로만 연결하면 Stack Trace가 누락될 수 있으므로 로거에 예외 객체 자체를 전달해야 한다.
회원 등록 과정에서 발생할 수 있는 예외를 사용자 정의 예외로 처리해보자.
public class DuplicateEmailException
extends RuntimeException {
public DuplicateEmailException(
String email
) {
super(
"이미 사용 중인 이메일입니다. "
+ "email="
+ email
);
}
}
public class InvalidMemberException
extends RuntimeException {
public InvalidMemberException(
String message
) {
super(message);
}
}
public class Member {
private final String name;
private final String email;
public Member(
String name,
String email
) {
if (
name == null
|| name.isBlank()
) {
throw new InvalidMemberException(
"이름은 비어 있을 수 없습니다."
);
}
if (
email == null
|| email.isBlank()
) {
throw new InvalidMemberException(
"이메일은 비어 있을 수 없습니다."
);
}
this.name = name;
this.email = email;
}
public String getName() {
return name;
}
public String getEmail() {
return email;
}
}
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
public class MemberRepository {
private final Map<String, Member> members =
new HashMap<>();
public boolean existsByEmail(
String email
) {
return members.containsKey(email);
}
public void save(Member member) {
members.put(
member.getEmail(),
member
);
}
public Optional<Member> findByEmail(
String email
) {
return Optional.ofNullable(
members.get(email)
);
}
}
public class MemberService {
private final MemberRepository repository;
public MemberService(
MemberRepository repository
) {
this.repository = repository;
}
public Member register(
String name,
String email
) {
if (
repository.existsByEmail(email)
) {
throw new DuplicateEmailException(
email
);
}
Member member =
new Member(name, email);
repository.save(member);
return member;
}
}
public class ExceptionExample {
public static void main(String[] args) {
MemberRepository repository =
new MemberRepository();
MemberService service =
new MemberService(repository);
try {
Member first =
service.register(
"김자바",
"java@example.com"
);
System.out.println(
first.getName()
+ " 등록 완료"
);
service.register(
"이자바",
"java@example.com"
);
} catch (
DuplicateEmailException e
) {
System.out.println(
"회원 등록 실패: "
+ e.getMessage()
);
} catch (
InvalidMemberException e
) {
System.out.println(
"입력값 오류: "
+ e.getMessage()
);
} finally {
System.out.println(
"회원 등록 작업 종료"
);
}
System.out.println(
"프로그램 정상 종료"
);
}
}
실행 결과는 다음과 같다.
김자바 등록 완료
회원 등록 실패: 이미 사용 중인 이메일입니다. email=java@example.com
회원 등록 작업 종료
프로그램 정상 종료
이 예제에는 다음 개념이 포함되어 있다.
throw를 이용한 예외 발생catch 블록finallyOptionalError
→ 일반적으로 애플리케이션에서 복구하기 어려운 심각한 문제
Exception
→ 프로그램 코드에서 처리할 수 있는 예외 상황
Error까지 무조건 catch하려고 하는 것은 적절하지 않다.
Checked Exception
→ 컴파일러가 처리 여부 검사
Unchecked Exception
→ 컴파일러가 처리 여부 강제하지 않음
둘의 차이는 “심각한 예외와 가벼운 예외”가 아니다.
컴파일러가 처리 코드를 강제하는지에 대한 차이다.
throw new Exception();
실제로 예외 객체를 발생시킨다.
void method() throws Exception {
}
예외 처리 책임을 호출자에게 전달한다.
catch
→ 현재 메서드에서 예외 처리
throws
→ 호출한 메서드로 예외 전달
throws를 사용한다고 예외가 처리되거나 사라지는 것은 아니다.
finally는 예외 발생 여부와 관계없이 공통 작업을 수행한다.
try-with-resources는 AutoCloseable 자원을 자동으로 닫기 위한 전용 문법이다.
파일이나 DB 연결처럼 닫아야 하는 자원에는 가능하면 try-with-resources를 사용하는 것이 좋다.
다음은 예외 처리가 아니다.
catch (Exception e) {
}
예외를 잡았지만 아무것도 하지 않았기 때문에 문제를 숨긴 것이다.
e.getMessage()
예외 메시지만 반환한다.
e.printStackTrace()
예외 종류, 메시지, 발생 위치, 호출 경로를 함께 출력한다.
디버깅에는 Stack Trace가 훨씬 중요하다.
실무에서는 예외를 잡는 것보다 어느 계층에서 어떤 의미로 처리할지가 중요하다.
Repository 계층에서는 데이터베이스나 파일 관련 예외가 발생할 수 있다.
Service 계층에서는 이를 업무 의미가 있는 예외로 변환할 수 있다.
SQLException
→ MemberDataAccessException
Controller 계층에서는 예외를 HTTP 응답으로 변환할 수 있다.
MemberNotFoundException
→ HTTP 404 Not Found
Spring에서는 @ExceptionHandler나 @RestControllerAdvice를 사용해 예외를 공통 처리할 수 있다.
@ExceptionHandler(
MemberNotFoundException.class
)
public ResponseEntity<?> handle(
MemberNotFoundException e
) {
return ResponseEntity
.notFound()
.build();
}
예외 메시지에는 문제 해결에 필요한 정보를 포함하되 비밀번호, 인증 토큰, 주민등록번호 같은 민감한 정보는 포함하지 않아야 한다.
예외를 로그로 남길 때도 같은 예외를 여러 계층에서 반복 기록하면 로그가 중복될 수 있다.
예외를 처리하는 계층
또는
최종적으로 사용자 응답으로 변환하는 계층
처럼 로깅 위치를 명확히 정하는 것이 좋다.
이번 글에서는 Java의 예외 처리 구조와 활용 방법을 정리했다.
Throwable을 상속한다.Error는 일반적으로 애플리케이션에서 복구하기 어려운 심각한 문제다.Exception은 코드에서 처리할 수 있는 예외 상황을 표현한다.try-catch 또는 throws로 처리해야 한다.RuntimeException과 그 하위 예외가 Unchecked Exception에 해당한다.try 블록에는 예외가 발생할 가능성이 있는 코드를 작성한다.catch 블록은 발생한 예외 객체를 받아 처리한다.try 코드는 실행되지 않는다.getMessage()는 예외의 상세 메시지를 반환한다.getCause()는 현재 예외의 원인 예외를 반환한다.printStackTrace()는 예외 발생 위치와 호출 흐름을 출력한다.catch 블록을 사용할 수 있다.finally는 예외 발생 여부와 관계없이 일반적으로 실행된다.return이 있더라도 메서드 종료 전에 finally가 실행된다.System.exit()나 JVM 강제 종료 상황에서는 finally 실행이 보장되지 않는다.finally는 주로 자원을 정리하는 데 사용한다.try-with-resources는 AutoCloseable 자원을 자동으로 닫는다.try-with-resources의 자원 변수는 다시 할당할 수 없다.throw는 예외 객체를 직접 발생시킨다.throws는 예외 처리 책임을 호출자에게 전달한다.throws를 사용해도 예외가 사라지는 것은 아니다.Exception을 상속한다.RuntimeException을 상속한다.RuntimeException은 무조건 잡기보다 잘못된 코드와 상태를 수정해야 하는 경우가 많다.printStackTrace()보다 로깅 프레임워크를 사용하는 것이 일반적이다.Caused by를 확인해야 한다.다음 글에서는 Java가 파일과 데이터를 읽고 쓰는 방법인 입출력 스트림을 다룬다.
Java 심화 ⑩ 입출력 스트림
- InputStream과 OutputStream
- Reader와 Writer
- FileInputStream
- FileOutputStream
- BufferedInputStream
- BufferedReader
- File과 Path
- Files 클래스
- 직렬화
- try-with-resources
입출력 API는 Checked Exception을 많이 발생시키므로 이번 글에서 학습한 try-catch, throws, try-with-resources가 실제로 어떻게 사용되는지 자연스럽게 연결해서 이해할 수 있다.
Checked Exception은 컴파일러가 처리 여부를 검사한다.
Unchecked Exception은 처리 여부를 강제하지 않는다.
Checked
→ Exception 하위이지만 RuntimeException 계열이 아님
Unchecked
→ RuntimeException 하위
일반적으로 실행된다.
finally가 실행된 후 return이 완료된다.
단, System.exit()나 JVM 강제 종료 상황은 예외다.
finally의 return이 기존 return이나 예외를 덮어버릴 수 있다.
public int test() {
try {
return 1;
} finally {
return 2;
}
}
결과는 2다.
이런 코드는 예외와 원래 반환값을 숨기므로 사용하지 않는 것이 좋다.
throw
→ 실제 예외 발생
throws
→ 예외 처리 책임 위임
자원 객체가 AutoCloseable 또는 그 하위 인터페이스를 구현해야 한다.
선언한 순서의 반대로 닫힌다.
원래 예외를 cause로 연결해야 한다.
throw new ServiceException(
"처리 실패",
e
);
호출자가 실제로 복구할 수 있고 처리를 강제할 필요가 있다면 Checked Exception을 고려할 수 있다.
업무 규칙 위반이나 계층 간 전달이 필요한 예외는 RuntimeException으로 설계하는 경우가 많다.
catch (Exception e)는 왜 주의해야 하는가?처리할 의도가 없는 예외까지 모두 잡아 문제를 숨길 수 있기 때문이다.
최상위 경계에서 공통 처리하는 경우를 제외하면 구체적인 예외를 처리하는 것이 좋다.