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

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

이 글의 핵심

Java 예외 처리에 대한 실전 가이드입니다. try-catch, throws, 커스텀 예외 등을 예제와 함께 상세히 설명합니다.

들어가며

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

왜 중요한가?

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

1. 예외 계층 구조

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

2. 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());
}

3. 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());
        }
    }
}

4. 커스텀 예외

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;
    }
}

5. 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();
    }
}

6. 실전 예제

예제: 사용자 검증

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());
        }
    }
}

7. 예외 처리 베스트 프랙티스

1) 구체적인 예외를 먼저 catch

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

주의: 부모 예외(Exception)를 먼저 catch하면 하위 예외가 도달하지 않습니다.

2) 예외를 무시하지 말 것

// ❌ 나쁜 예
try {
    riskyOperation();
} catch (Exception e) {
    // 아무것도 안 함
}

// ✅ 좋은 예
try {
    riskyOperation();
} catch (Exception e) {
    logger.error("Operation failed", e);
    throw new RuntimeException("Failed to process", e);
}

3) 예외 체이닝 활용

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

4) 리소스는 try-with-resources 사용

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

5) Checked vs Unchecked 선택 기준

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

8. 실전 패턴

패턴 1: 예외 변환 (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);
        }
    }
}

패턴 2: 예외 집계 (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);
        }
    }
}

패턴 3: 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)); // 지수 백오프
        }
    }
    
    throw new RuntimeException("Max retries exceeded", lastException);
}

9. 흔한 실수와 해결

실수 1: 예외를 흐름 제어로 사용

// ❌ 나쁜 예 - 예외를 일반 로직으로 사용
try {
    return map.get(key);
} catch (NullPointerException e) {
    return defaultValue;
}

// ✅ 좋은 예
return map.getOrDefault(key, defaultValue);

실수 2: 너무 넓은 catch

// ❌ 나쁜 예
try {
    complexOperation();
} catch (Exception e) {
    // 모든 예외를 동일하게 처리
}

// ✅ 좋은 예
try {
    complexOperation();
} catch (IOException e) {
    // I/O 오류 처리
} catch (ValidationException e) {
    // 검증 오류 처리
}

실수 3: finally에서 return

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

10. 성능 고려사항

예외는 비용이 크다

// 스택 트레이스 생성 비용
long start = System.nanoTime();
for (int i = 0; i < 10000; i++) {
    try {
        throw new Exception();
    } catch (Exception e) {
        // 처리
    }
}
long elapsed = System.nanoTime() - start;
// 일반 제어 흐름보다 100~1000배 느림

최적화 팁:

  • 정상 흐름에서는 예외를 사용하지 말 것
  • 고빈도 경로에서는 예외 대신 Optional이나 Result 타입 고려
  • 스택 트레이스가 필요 없으면 fillInStackTrace() 오버라이드

정리

핵심 요약

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

실무 체크리스트

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

다음 단계

  • Java I/O
  • Java 멀티스레딩
  • Java Spring Boot

관련 글

  • C++ 예외 처리 | try/catch/throw
  • C++ 예외 처리 | try-catch-throw와 예외 vs 에러 코드, 언제 뭘 쓸지
  • JavaScript 에러 처리 | try-catch, Error 객체, 커스텀 에러
  • Python 예외 처리 | try-except, raise, 커스텀 예외 완벽 정리
  • C++ Exception Performance |