Java 예외 처리 | try-catch, throws, 커스텀 예외

이 글의 핵심

catch 블록에서 예외를 삼켜 버려서 장애 원인을 한참 찾지 못하는 일은 실무에서 자주 벌어집니다. Checked와 Unchecked 중 무엇을 던질지 고르는 기준, 예외 체이닝으로 원인을 보존하는 방법, 예외를 흐름 제어에 쓰면 안 되는 이유를 짚어 복구할 수 있는 예외와 없는 예외를 구분하게 합니다.

들어가며

Java의 예외 처리는 실패를 타입으로 표현하는 쪽에 가깝습니다. Checked Exception은 컴파일러가 try/throws 누락을 잡아 주는 특징이 있습니다. 이 글에서는 예외 계층, try–catch, throws, 커스텀 예외, try-with-resources까지 실무 패턴으로 정리합니다.

왜 중요한가?

  • 컴파일 타임 안전성: Checked Exception으로 예외 처리 누락 방지
  • 리소스 관리: try-with-resources로 파일 핸들·DB 커넥션 누수 예방
  • 디버깅: 스택 트레이스로 오류 원인 추적
  • API 설계: 예외를 통한 명확한 에러 시그널링

예외 처리를 공부할 때 문법보다 중요한 질문은 “이 예외를 여기서 잡아야 하는가”입니다. 예외를 잡는다는 것은 그 자리에서 의미 있는 대응(재시도, 기본값, 사용자에게 알림, 다른 예외로 변환)을 할 수 있다는 뜻입니다. 할 수 있는 게 없다면 잡지 말고 위로 던지는 편이 낫습니다. 실무에서 장애 분석을 가장 어렵게 만드는 코드는 예외가 없는 코드가 아니라, 중간에서 예외를 잡아 로그 한 줄 없이 null을 반환하는 코드입니다. 증상은 몇 단계 뒤에서 엉뚱한 NullPointerException으로 나타나고, 원래 원인은 스택 트레이스 어디에도 남지 않습니다.


Throwable·Exception·Error 예외 계층 구조

Throwable
├── Error (시스템 오류, 처리 불가)
│   ├── OutOfMemoryError
│   └── StackOverflowError
└── Exception (처리 가능한 예외)
    ├── IOException (Checked)
    ├── SQLException (Checked)
    └── RuntimeException (Unchecked)
        ├── NullPointerException
        ├── ArithmeticException
        └── IndexOutOfBoundsException

이 트리에서 기억할 경계는 두 개입니다. 첫째, Error는 JVM 자체가 더 이상 정상 동작을 보장할 수 없는 상황이라 애플리케이션 코드가 잡아서 복구하려 하면 안 됩니다. catch (Throwable t)로 OutOfMemoryError까지 삼키면 반쯤 망가진 상태로 서버가 계속 돌면서 더 이해하기 어려운 장애를 만듭니다. 둘째, Exception 아래에서 RuntimeException과 그 하위만 Unchecked이고 나머지는 전부 Checked입니다. 컴파일러는 Checked 예외를 던질 수 있는 메서드를 호출할 때 try/catch로 잡거나 throws로 선언하라고 강제하며, 둘 다 없으면 “unreported exception java.io.IOException; must be caught or declared to be thrown” 에러를 냅니다.

Checked 예외는 Java에만 있는 특징이고 논쟁도 많습니다. 호출자가 실패 가능성을 무시할 수 없게 만든다는 장점이 있지만, 람다와 스트림 안에서는 Function 같은 표준 함수형 인터페이스가 Checked 예외를 선언하지 않아 쓸 수 없다는 불편이 큽니다. Kotlin, C#, Scala가 Checked 예외를 채택하지 않은 것도, Spring이 SQLException을 Unchecked인 DataAccessException으로 바꿔 던지는 것도 이 비용 때문입니다.


try-catch-finally로 예외 잡기

기본 예외 처리

public class ExceptionExample {
    public static void main(String[] args) {
        try {
            int result = 10 / 0;
            System.out.println(result);
        } catch (ArithmeticException e) {
            System.out.println("에러: " + e.getMessage());
        } finally {
            System.out.println("항상 실행");
        }
    }
}

여러 예외 처리

try {
    String str = null;
    System.out.println(str.length());
    
    int[] arr = {1, 2, 3};
    System.out.println(arr[10]);
} catch (NullPointerException e) {
    System.out.println("Null 참조");
} catch (ArrayIndexOutOfBoundsException e) {
    System.out.println("배열 범위 초과");
} catch (Exception e) {
    System.out.println("기타 예외: " + e.getMessage());
}

다중 예외 처리 (Java 7+)

try {
    // 코드
} catch (IOException | SQLException e) {
    System.out.println("I/O 또는 DB 에러: " + e.getMessage());
}

여러 catch 블록은 위에서부터 순서대로 검사되고 처음 맞는 하나만 실행됩니다. 두 번째 예제에서 str.length()가 NPE를 던지는 순간 try 블록의 나머지(배열 접근)는 실행되지 않으므로 “배열 범위 초과”는 출력되지 않습니다. 멀티 catch(|)는 처리 방식이 같은 예외들을 묶을 때 쓰며, 서로 상속 관계인 타입은 함께 쓸 수 없습니다(IOException | FileNotFoundException은 “Alternatives in a multi-catch statement cannot be related by subclassing” 에러). 멀티 catch의 e는 암묵적으로 final이라 다른 예외를 대입할 수 없다는 점도 알아 두면 좋습니다.

finally는 try나 catch가 return으로 빠져나가도 실행되지만 “항상”은 아닙니다. System.exit()가 호출되거나 JVM이 강제 종료되면 실행되지 않습니다. 또 finally 안에서 또 다른 예외가 나면 원래 예외는 사라지고 새 예외만 전파되는데, 이 문제를 구조적으로 해결한 것이 아래 try-with-resources입니다.


throws로 호출자에게 예외 넘기기

기본 throws

import java.io.*;
public class FileHandler {
    public void readFile(String path) throws IOException {
        FileReader fr = new FileReader(path);
        BufferedReader br = new BufferedReader(fr);
        String line = br.readLine();
        br.close();
    }
    
    public static void main(String[] args) {
        FileHandler handler = new FileHandler();
        
        try {
            handler.readFile("file.txt");
        } catch (IOException e) {
            System.out.println("파일 읽기 실패: " + e.getMessage());
        }
    }
}

throws는 “이 메서드는 이 예외를 처리하지 않고 호출자에게 넘긴다”는 선언입니다. 위 readFile은 파일이 없을 때 무엇을 해야 할지 알 수 없으므로 판단을 호출자에게 맡기는 것이 맞습니다. 다만 이 예제에는 교과서적인 버그가 하나 있습니다. readLine()에서 예외가 나면 br.close()에 도달하지 못해 파일 핸들이 닫히지 않습니다. 한두 번이면 문제가 드러나지 않지만, 요청마다 파일을 여는 서버에서는 결국 “Too many open files” 에러로 멈춥니다. 이 문제는 아래 try-with-resources로 고칩니다.

throws를 선언할 때는 추상화 수준도 고려해야 합니다. 저장소 인터페이스의 메서드가 throws SQLException을 선언하면, 나중에 저장소를 파일이나 원격 API로 바꿀 때 시그니처까지 바꿔야 하고 모든 호출자가 영향을 받습니다. 뒤에서 다룰 예외 변환 패턴이 이 문제를 다룹니다.


Checked·Unchecked 커스텀 예외 만들기

Checked 예외

class InvalidAgeException extends Exception {
    public InvalidAgeException(String message) {
        super(message);
    }
}
public class User {
    private int age;
    
    public void setAge(int age) throws InvalidAgeException {
        if (age < 0 || age > 150) {
            throw new InvalidAgeException("나이는 0~150 사이여야 합니다");
        }
        this.age = age;
    }
}

Unchecked 예외

class InvalidEmailException extends RuntimeException {
    public InvalidEmailException(String message) {
        super(message);
    }
}
public class User {
    private String email;
    
    public void setEmail(String email) {
        if (!email.contains("@")) {
            throw new InvalidEmailException("유효하지 않은 이메일");
        }
        this.email = email;
    }
}

두 예제의 차이는 호출자에게 주는 부담입니다. setAge를 호출하는 모든 코드는 InvalidAgeException을 잡거나 다시 선언해야 하고, setEmail은 아무 표시 없이 호출할 수 있습니다. 나이 검증처럼 입력값 오류는 대부분 호출자가 미리 검사할 수 있는 “사용 계약 위반”이라 요즘은 IllegalArgumentException 같은 Unchecked로 두는 경우가 더 많습니다. 커스텀 예외를 만들 때는 (String message, Throwable cause) 생성자도 함께 만들어 두세요. 이 생성자가 없으면 하위 예외를 감쌀 때 원인을 전달할 방법이 없어 체이닝이 끊깁니다. 또 커스텀 예외를 무작정 늘리기보다, 호출자가 다르게 처리해야 하는 경우만 별도 타입으로 나누는 편이 관리하기 쉽습니다.


try-with-resources

import java.io.*;
// 기존 방식
public void readFile1(String path) {
    BufferedReader br = null;
    try {
        br = new BufferedReader(new FileReader(path));
        String line = br.readLine();
    } catch (IOException e) {
        e.printStackTrace();
    } finally {
        if (br != null) {
            try {
                br.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}
// try-with-resources (Java 7+)
public void readFile2(String path) {
    try (BufferedReader br = new BufferedReader(new FileReader(path))) {
        String line = br.readLine();
    } catch (IOException e) {
        e.printStackTrace();
    }
}

try-with-resources는 괄호 안에서 선언한 AutoCloseable 객체의 close()를 블록이 어떻게 끝나든(정상 종료, return, 예외) 자동으로 호출합니다. 코드가 짧아지는 것 외에 더 중요한 차이가 있습니다. 기존 방식에서 readLine()과 close()가 둘 다 예외를 던지면, finally에서 난 close()의 예외가 원래 예외를 덮어써서 진짜 원인이 사라집니다. try-with-resources는 본문의 예외를 주 예외로 유지하고 close()의 예외는 e.getSuppressed()에 붙여 보존합니다. 스택 트레이스에서 “Suppressed:“로 시작하는 줄이 보인다면 이 메커니즘입니다.

Java 9부터는 이미 선언된 effectively final 변수를 try (br)처럼 괄호에 넣을 수도 있습니다. 자신이 만든 클래스도 AutoCloseable만 구현하면 이 구문을 쓸 수 있어, 락 해제나 임시 파일 삭제 같은 정리 작업에도 응용할 수 있습니다.


사용자 입력 검증 예제

예제: 사용자 검증

class ValidationException extends Exception {
    public ValidationException(String message) {
        super(message);
    }
}
class User {
    private String name;
    private int age;
    private String email;
    
    public void validate() throws ValidationException {
        if (name == null || name.isEmpty()) {
            throw new ValidationException("이름은 필수입니다");
        }
        
        if (age < 0 || age > 150) {
            throw new ValidationException("나이는 0~150 사이여야 합니다");
        }
        
        if (email == null || !email.contains("@")) {
            throw new ValidationException("유효하지 않은 이메일");
        }
    }
}
public class Main {
    public static void main(String[] args) {
        User user = new User();
        user.setName("");
        user.setAge(25);
        user.setEmail("invalid");
        
        try {
            user.validate();
            System.out.println("검증 성공");
        } catch (ValidationException e) {
            System.out.println("검증 실패: " + e.getMessage());
        }
    }
}

(예제를 짧게 하려고 User의 setter는 생략했습니다.) 이 방식은 첫 번째 오류에서 멈춥니다. 이름과 이메일이 모두 틀려도 사용자는 “이름은 필수입니다”만 보고, 고친 뒤 다시 제출해야 이메일 오류를 알게 됩니다. 회원가입 폼처럼 모든 오류를 한 번에 보여줘야 하는 경우라면 오류 메시지를 리스트에 모은 뒤 마지막에 한 번 던지는 방식(뒤의 예외 집계 패턴)이 더 낫습니다. Spring 환경이라면 Bean Validation(@NotBlank, @Email)이 이 역할을 대신합니다.


구체적인 catch·예외 체이닝·Checked 선택 기준

구체적인 예외를 먼저 catch

try {
    // 코드
} catch (FileNotFoundException e) {
    // 파일이 없을 때
} catch (IOException e) {
    // 기타 I/O 오류
} catch (Exception e) {
    // 최후의 안전망
}

주의: 부모 예외(Exception)를 먼저 catch하면 하위 예외가 도달하지 않습니다. Java 컴파일러는 이를 실수로 보고 “exception FileNotFoundException has already been caught” 에러를 내므로, 순서를 잘못 쓰면 실행 전에 바로 알 수 있습니다.

예외를 무시하지 말 것

// ❌ 나쁜 예
try {
    riskyOperation();
} catch (Exception e) {
    // 아무것도 안 함
}
// ✅ 좋은 예
try {
    riskyOperation();
} catch (Exception e) {
    logger.error("Operation failed", e);
    throw new RuntimeException("Failed to process", e);
}

위 “좋은 예”는 원인을 남긴다는 점에서 빈 catch보다는 낫지만, 로그를 찍고 다시 던지기까지 하면 같은 예외가 상위 계층에서 또 로깅되어 로그에 같은 스택 트레이스가 두세 번 찍힙니다. 정리의 체크리스트에 “로깅과 예외 던지기를 동시에 하지 말 것”이 있는 이유입니다. 실무에서는 둘 중 하나만 합니다. 여기서 처리가 끝나면 로깅, 위로 넘길 거면 cause를 담아 던지기만 하고 최상위(컨트롤러 어드바이스, 스레드 경계)에서 한 번 로깅합니다. 정말 무시해도 되는 예외라면 빈 블록 대신 // 이미 닫힌 소켓이라 무시 같은 주석으로 의도를 남기세요.

예외 체이닝 활용

public void processData(String data) throws DataProcessingException {
    try {
        // 복잡한 처리
        parseJson(data);
    } catch (JsonParseException e) {
        // 원본 예외를 cause로 포함
        throw new DataProcessingException("Invalid data format", e);
    }
}

체이닝을 하면 스택 트레이스 아래쪽에 “Caused by: …JsonParseException: Unexpected character (’}’ …)”처럼 원래 예외가 이어서 출력됩니다. 반대로 new DataProcessingException("Invalid data format")처럼 cause를 빼먹으면, 로그에는 “Invalid data format”만 남고 JSON의 몇 번째 줄이 잘못됐는지 같은 결정적 정보가 사라집니다. 제가 장애 로그를 볼 때 가장 먼저 확인하는 것도 가장 아래의 “Caused by”입니다. 진짜 원인은 대개 거기에 있습니다.

리소스는 try-with-resources 사용

// ✅ 자동으로 close() 호출됨
try (FileInputStream fis = new FileInputStream("file.txt");
     BufferedInputStream bis = new BufferedInputStream(fis)) {
    // 파일 읽기
} catch (IOException e) {
    // 예외 처리
}
// close()가 자동 호출됨 (역순으로)

Checked vs Unchecked 선택 기준

상황선택이유
복구 가능한 오류 (파일 없음, 네트워크 끊김)Checked (extends Exception)호출자가 반드시 처리하도록 강제
프로그래밍 오류 (null 참조, 배열 범위 초과)Unchecked (extends RuntimeException)코드 수정으로 해결해야 함
비즈니스 로직 위반상황에 따라복구 가능하면 Checked, 아니면 Unchecked

예외 변환·예외 집계·Retry 패턴

예외 변환 (Exception Translation)

public class UserService {
    private UserRepository repository;
    
    public User findUser(Long id) throws UserNotFoundException {
        try {
            return repository.findById(id);
        } catch (SQLException e) {
            // DB 예외를 도메인 예외로 변환
            throw new UserNotFoundException("User not found: " + id, e);
        }
    }
}

예외 집계 (Exception Aggregation)

public class BatchProcessor {
    public void processBatch(List<Item> items) throws BatchProcessingException {
        List<Exception> errors = new ArrayList<>();
        
        for (Item item : items) {
            try {
                processItem(item);
            } catch (Exception e) {
                errors.add(e);
            }
        }
        
        if (!errors.isEmpty()) {
            throw new BatchProcessingException("일부 항목 처리 실패", errors);
        }
    }
}

Retry 패턴

public <T> T retryOperation(Supplier<T> operation, int maxRetries) 
        throws Exception {
    Exception lastException = null;
    
    for (int i = 0; i < maxRetries; i++) {
        try {
            return operation.get();
        } catch (TransientException e) {
            lastException = e;
            Thread.sleep(1000 * (i + 1)); // 선형 백오프: 1초, 2초, 3초...
        }
    }
    
    throw new RuntimeException("Max retries exceeded", lastException);
}

예외 변환 패턴에서 한 가지 조심할 점은 의미를 바꾸면 안 된다는 것입니다. 위 예제처럼 모든 SQLException을 UserNotFoundException으로 바꾸면, DB 연결 끊김도 “사용자 없음”으로 보고되어 404가 나가고 장애가 가려집니다. “없음”은 보통 repository.findById가 Optional.empty()나 null을 돌려주는 정상 흐름이고, SQLException은 인프라 장애를 뜻하는 별도 예외(예: DataAccessException)로 변환하는 편이 정확합니다.

재시도 패턴에서는 무엇을 재시도할지가 핵심입니다. 네트워크 타임아웃이나 일시적 연결 거부처럼 다시 하면 성공할 수 있는 오류만 재시도해야 하고, 검증 실패나 인증 오류를 재시도하면 같은 실패를 반복하며 시간만 씁니다. 위 코드는 대기 시간이 1초, 2초, 3초로 늘어나는 선형 백오프이고, 지수 백오프라면 1000L << i처럼 두 배씩 늘립니다. 여러 클라이언트가 동시에 재시도하며 서버를 다시 넘어뜨리지 않도록 무작위 지터를 더하는 것이 보통입니다. 또 Thread.sleep은 InterruptedException을 던질 수 있는데, 이를 잡는다면 Thread.currentThread().interrupt()로 인터럽트 상태를 복원해야 스레드 풀 종료가 정상적으로 동작합니다.


흐름 제어용 예외·넓은 catch·finally return 실수

예외를 흐름 제어로 사용

// ❌ 나쁜 예 - 예외를 일반 로직으로 사용
try {
    return list.get(index);
} catch (IndexOutOfBoundsException e) {
    return defaultValue;
}
// ✅ 좋은 예
return (index >= 0 && index < list.size()) ? list.get(index) : defaultValue;

예외로 흐름을 제어하면 느린 것도 문제지만, 더 큰 문제는 의도하지 않은 예외까지 같이 잡힌다는 점입니다. 위 try 블록 안에 코드가 늘어나 다른 곳에서 IndexOutOfBoundsException이 나도 조용히 기본값이 반환되어 버그가 숨습니다. 예외는 “예상 밖의 상황”에만 쓰고, 미리 확인할 수 있는 조건은 if로 확인하는 것이 원칙입니다.

너무 넓은 catch

// ❌ 나쁜 예
try {
    complexOperation();
} catch (Exception e) {
    // 모든 예외를 동일하게 처리
}
// ✅ 좋은 예
try {
    complexOperation();
} catch (IOException e) {
    // I/O 오류 처리
} catch (ValidationException e) {
    // 검증 오류 처리
}

finally에서 return

// ❌ 나쁜 예 - finally의 return이 try의 return을 덮어씀
public int getValue() {
    try {
        return 1;
    } finally {
        return 2; // 항상 2가 반환됨
    }
}

예외 생성 비용과 성능

예외는 비용이 크다

// 스택 트레이스 생성 비용
long start = System.nanoTime();
for (int i = 0; i < 10000; i++) {
    try {
        throw new Exception();
    } catch (Exception e) {
        // 처리
    }
}
long elapsed = System.nanoTime() - start;
// 일반 제어 흐름(if 분기)보다 훨씬 느림 — 비용 대부분은 스택 트레이스 수집

예외 비용의 대부분은 throw 자체가 아니라 예외 객체를 생성할 때 fillInStackTrace()가 호출 스택 전체를 기록하는 작업에서 나옵니다. 그래서 호출 스택이 깊을수록(Spring처럼 프레임워크 계층이 많을수록) 비용이 커집니다. 반대로 JIT 컴파일러가 같은 지점에서 내장 예외(NPE 등)가 반복해서 나는 것을 감지하면 스택 트레이스 없는 미리 만든 예외를 재사용하기도 해서, 운영 로그에 스택 트레이스 없이 java.lang.NullPointerException 한 줄만 찍히는 현상이 생깁니다. 이때는 JVM 옵션 -XX:-OmitStackTraceInFastThrow로 끄면 전체 스택이 다시 나옵니다. 위 루프 같은 마이크로 벤치마크는 JIT 최적화로 결과가 크게 흔들리므로, 정확한 수치가 필요하면 JMH로 측정해야 합니다.

최적화 팁:

  • 정상 흐름에서는 예외를 사용하지 말 것
  • 고빈도 경로에서는 예외 대신 Optional이나 Result 타입 고려
  • 스택 트레이스가 필요 없으면 fillInStackTrace()를 오버라이드하거나, super(message, cause, false, false) 생성자로 스택 트레이스 기록을 끔(대신 디버깅 정보도 사라지므로 흐름 제어용 예외에만 사용)

예외 처리 요약

핵심 요약

  1. Checked vs Unchecked: 복구 가능성으로 판단
  2. try-catch-finally: 예외 처리와 리소스 정리
  3. try-with-resources: AutoCloseable 리소스 자동 관리
  4. 예외 체이닝: 원본 예외를 cause로 보존
  5. 베스트 프랙티스: 구체적 catch, 예외 무시 금지, 흐름 제어 금지

실무 체크리스트

  • Checked Exception은 복구 가능한 경우만 사용
  • 예외 메시지에 충분한 컨텍스트 포함
  • 원본 예외를 cause로 전달
  • 리소스는 try-with-resources 사용
  • 로깅과 예외 던지기를 동시에 하지 말 것

다음 단계


같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. finally 블록에서 return하면 어떻게 되나요?

A. finally의 return은 try나 catch에서 반환하려던 값을 덮어쓰고, 던져지던 예외까지 삼켜 버립니다. 그래서 호출한 쪽은 예외가 났다는 사실을 모른 채 finally의 값을 받게 됩니다. finally에는 자원 정리만 두고 반환이나 예외 던지기는 하지 않는 것이 원칙이며, 자원 정리는 try-with-resources로 대신하는 편이 더 안전합니다.