자바 예외 처리 - checked unchecked try-with-resources 와 예외를 삼키면 안 되는 이유 (자바 중급 11단원)
이 단원에서 배우는 것
9·10단원에서 LibraryItem 계층과 OverdueNotifier 인터페이스를 만들었다. 그런데 지금까지 쓴 코드는 전부 "잘 되는 경우"만 가정한 것이다. 이미 다른 사람이 빌려간 책이면? 대출 한도를 넘겼으면? 소장자료 목록 파일이 없으면? 실무 코드의 절반은 이 "안 되는 경우"를 다루는 코드고, 그 절반을 다루는 문법이 예외다.
초급에서 try-catch 로 NumberFormatException 을 잡아보긴 했을 것이다. 이번 단원은 문법이 아니라 예외를 어디서 잡고 어디로 던질지 결정하는 기준을 다룬다. 대출 창구(LoanDesk)에 대출 처리를 붙이면서 본다.
- checked와 unchecked를 구분하고, 새 예외를 어느 쪽으로 만들지 판단한다
try-with-resources로 자원 누수를 구조적으로 막는다- 예외를 삼킨 코드가 왜 장애 원인 추적을 불가능하게 만드는지 안다
왜 필요한가
예외가 없다면 실패를 반환값으로 알려야 한다. 대출 처리 메서드를 이렇게 만들었다고 하자.
// 나쁜 예 — 실패를 반환값으로 알린다
public int loan(String memberId, String isbn) {
if (holders.containsKey(isbn)) return -1; // 이미 대출 중
if (countOf(memberId) >= MAX_LOAN_COUNT) return -2; // 한도 초과
if (!exists(isbn)) return -3; // 없는 자료
holders.put(isbn, memberId);
return 0;
}
여기엔 문제가 셋 있다. 첫째, 호출자가 -1, -2, -3 의 의미를 문서 없이 알 수 없다. 둘째, 호출자가 반환값을 확인하지 않아도 컴파일이 통과한다. loan(memberId, isbn); 한 줄만 쓰면 이미 대출 중인 책도 대출된 것처럼 화면에 뜬다. 셋째, 실패 원인에 부가 정보(누가 빌려갔는지, 한도가 몇 권인지)를 실을 수 없다.
예외는 이 셋을 한 번에 해결한다. 무시할 수 없고, 이름으로 의미를 전달하고, 필드로 정보를 실을 수 있고, 스택 트레이스로 어디서 났는지가 자동 기록된다. 정상 흐름 코드와 실패 처리 코드를 분리한다는 것도 크다. 위 코드는 반환값 검사가 정상 로직 사이에 계속 끼어들지만 예외를 쓰면 정상 흐름만 쭉 읽힌다.
문법과 예제
예외 계층
모든 예외의 조상은 Throwable 이고, 그 아래가 Error 와 Exception 으로 갈린다.
| 분류 | 대표 | catch 강제 | 실무 태도 |
|---|---|---|---|
Error | OutOfMemoryError, StackOverflowError | 안 함 | 잡지 않는다. 잡아도 할 수 있는 게 없다 |
checked (Exception 직계) | IOException, SQLException | 강제 | 호출자가 복구할 여지가 있을 때만 |
unchecked (RuntimeException) | NullPointerException, IllegalArgumentException | 안 함 | 대부분의 업무 예외는 여기로 만든다 |
checked 예외는 메서드에 throws 를 선언하게 강제하고, 호출자에게 try-catch 또는 재선언을 요구한다. 컴파일러가 강제하니 좋아 보이지만 실제로는 문제가 있다. 아래 세 계층짜리 호출을 보자.
void controller() throws IOException { service(); }
void service() throws IOException { repository(); }
void repository() throws IOException { /* 파일에서 소장자료를 읽는다 */ }
repository() 가 던지는 IOException 때문에 위의 두 계층이 전부 throws IOException 을 달게 된다. 소장자료를 파일에서 읽는지 DB에서 읽는지가 controller 의 시그니처에 새어나가고, 저장 방식을 바꾸면 시그니처가 줄줄이 바뀐다. 그래서 실무에서는 업무 예외는 unchecked로 만들고, 라이브러리가 던지는 checked 예외는 경계에서 감싸 unchecked로 바꿔 올린다.
업무 예외 만들기
public class ItemNotAvailableException extends RuntimeException {
private final String isbn;
private final String holderId;
public ItemNotAvailableException(String isbn, String holderId) {
super("대출 불가: 등록번호=" + isbn + ", 현재 대출자=" + holderId);
this.isbn = isbn;
this.holderId = holderId;
}
public String isbn() { return isbn; }
public String holderId() { return holderId; }
}
public class LoanLimitExceededException extends RuntimeException {
private final int limit;
public LoanLimitExceededException(String memberId, int current, int limit) {
super("대출 한도 초과: 회원=" + memberId + ", 현재=" + current + "권, 한도=" + limit + "권");
this.limit = limit;
}
public int limit() { return limit; }
}
메시지에 값을 넣는 것이 핵심이다. "대출할 수 없습니다" 만 있는 로그는 새벽 3시에 아무 도움이 안 된다. 어떤 등록번호를 누가 빌리려 했고 지금 누가 갖고 있는지가 있어야 원인 파악이 시작된다. 그리고 필드로도 노출하면 호출자가 "이 자료는 예약하시겠습니까" 같은 안내를 만들 수 있다.
import java.util.HashMap;
import java.util.Map;
public class LoanDesk {
private static final int MAX_LOAN_COUNT = 5;
private final Map<String, String> holders = new HashMap<>(); // 등록번호 -> 대출자
private final Map<String, Integer> counts = new HashMap<>(); // 회원 -> 대출 권수
public void loan(String memberId, String isbn) {
String holder = holders.get(isbn);
if (holder != null) {
throw new ItemNotAvailableException(isbn, holder);
}
int current = counts.getOrDefault(memberId, 0);
if (current >= MAX_LOAN_COUNT) {
throw new LoanLimitExceededException(memberId, current, MAX_LOAN_COUNT);
}
holders.put(isbn, memberId);
counts.put(memberId, current + 1);
}
}
public class LoanDeskDemo {
public static void main(String[] args) {
LoanDesk desk = new LoanDesk();
desk.loan("M-01", "B-001");
try {
desk.loan("M-02", "B-001");
} catch (ItemNotAvailableException e) {
System.out.println(e.getMessage());
System.out.println("예약 안내 대상 = " + e.holderId());
}
}
}
대출 불가: 등록번호=B-001, 현재 대출자=M-01
예약 안내 대상 = M-01
try-with-resources
파일, 소켓, DB 커넥션처럼 다 쓰면 닫아야 하는 것이 있다. 예전 방식은 이랬다.
// 옛날 방식 — 실수하기 쉽다
BufferedReader br = null;
try {
br = Files.newBufferedReader(path);
// ...
} catch (IOException e) {
// ...
} finally {
if (br != null) {
try { br.close(); } catch (IOException ignore) { } // close 도 예외를 던진다
}
}
자원이 둘이면 finally 가 중첩되고, 더 나쁜 것은 본문에서 예외가 나고 close() 에서도 예외가 나면 앞의 예외가 사라진다는 점이다. 진짜 원인은 없어지고 close 실패만 남는다.
Java 7의 try-with-resources 는 AutoCloseable 을 구현한 객체를 try(...) 괄호에 선언하면 블록을 벗어날 때 자동으로 close() 를 부른다. 선언의 역순으로 닫고, close() 에서 난 예외는 원래 예외에 suppressed 로 붙어 보존된다.
import java.io.BufferedReader;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
public class CatalogLoader {
public List<String> loadTitles(Path csv) {
List<String> titles = new ArrayList<>();
try (BufferedReader br = Files.newBufferedReader(csv, StandardCharsets.UTF_8)) {
String line;
int lineNo = 0;
while ((line = br.readLine()) != null) {
lineNo++;
if (line.isBlank()) continue;
String[] col = line.split(",", -1);
if (col.length < 3) {
throw new IllegalStateException("컬럼 수 오류 line=" + lineNo + " : " + line);
}
titles.add(col[1]);
}
} catch (IOException e) {
// checked 예외를 경계에서 unchecked 로 바꿔 올린다. 원인은 반드시 넘긴다
throw new IllegalStateException("소장자료 목록 적재 실패: " + csv, e);
}
return titles;
}
}
소장자료 목록 적재 실패: /tmp/lib/없는파일.csv
Caused by: java.nio.file.NoSuchFileException: /tmp/lib/없는파일.csv
new IllegalStateException(메시지, e) 에서 두 번째 인자가 원인(cause) 이다. 이걸 넘기면 스택 트레이스에 Caused by: 로 원래 예외가 따라붙는다. 이걸 빠뜨리면 "소장자료 목록 적재 실패"라는 문장만 남고 파일이 없는 건지 권한이 없는 건지 알 수 없다.
close 순서 확인
public class ResourceDemo {
static class Resource implements AutoCloseable {
private final String name;
Resource(String name) { this.name = name; System.out.println("open " + name); }
void use() { throw new IllegalStateException("작업 실패: " + name); }
@Override public void close() { System.out.println("close " + name); }
}
public static void main(String[] args) {
try (Resource a = new Resource("A");
Resource b = new Resource("B")) {
b.use();
} catch (IllegalStateException e) {
System.out.println("잡음: " + e.getMessage());
}
}
}
open A
open B
close B
close A
잡음: 작업 실패: B
예외가 났는데도 close 가 선언 역순으로 다 불렸다. catch 블록보다 close 가 먼저 실행된다는 점도 눈여겨본다.
실무에서 자주 틀리는 것
1. 예외를 삼킨다
이게 실무에서 만나는 최악의 코드다.
// 절대 하지 마라
try {
desk.loan(memberId, isbn);
} catch (Exception e) {
// 일단 넘어가자
}
이 코드의 문제는 단순히 "에러를 무시한다"가 아니다. 장애가 났을 때 원인을 찾을 방법 자체를 없앤다. 대출이 안 되는데 화면에는 성공이라고 뜬다는 문의를 받고 로그를 보면 아무것도 없다. 아무 일도 안 일어난 것처럼 보인다. 실제로 이런 코드 때문에 원인 파악에 며칠이 걸리는 일이 흔하다.
e.printStackTrace() 만 찍고 넘어가는 것도 거의 같은 수준이다. 운영 환경에서 printStackTrace() 는 표준 에러로 나가서 로그 수집기에 잡히지 않는 경우가 많고, 어떤 요청에서 났는지 맥락 정보가 전혀 없다. 최소한 이렇게 한다.
try {
desk.loan(memberId, isbn);
} catch (ItemNotAvailableException e) {
log.warn("대출 실패 memberId={} isbn={} holder={}", memberId, e.isbn(), e.holderId(), e);
throw e; // 처리할 수 없으면 다시 던진다
}
판단 기준은 하나다. 이 자리에서 예외를 해결할 수 있는가. 대체값을 쓰거나 재시도하거나 사용자에게 안내할 수 있으면 잡고 끝낸다. 그럴 수 없으면 로그만 남기고 다시 던지거나, 아예 잡지 않는다. "일단 잡아두면 안전하다"는 감각은 틀렸다.
2. catch (Exception e) 로 다 잡는다
try {
desk.loan(memberId, isbn);
} catch (Exception e) { // ItemNotAvailableException 도, NullPointerException 도 같이 잡힌다
return "대출에 실패했습니다";
}
ItemNotAvailableException 은 사용자 안내가 필요한 정상적인 업무 상황이고, NullPointerException 은 코드 버그다. 둘을 같은 메시지로 처리하면 버그가 "대출 실패"로 위장되어 영영 안 고쳐진다. 구체적인 것부터 순서대로 잡는다. 참고로 catch (A | B e) 로 여러 타입을 한 블록에 묶을 수 있는데, 이때 e 는 실질적으로 final 이라 재대입할 수 없다.
try {
desk.loan(memberId, isbn);
} catch (ItemNotAvailableException | LoanLimitExceededException e) {
return e.getMessage(); // 사용자에게 보여줄 수 있는 업무 예외
} catch (RuntimeException e) {
log.error("대출 처리 중 오류 memberId={} isbn={}", memberId, isbn, e);
throw e; // 버그는 감추지 않는다
}
3. finally 에서 return 하거나 예외를 던진다
static int broken() {
try {
throw new IllegalStateException("진짜 원인");
} finally {
return 42; // 예외가 사라진다
}
}
System.out.println(broken()); // 42 — 예외는 흔적도 없다
finally 의 return 은 진행 중이던 예외를 통째로 버린다. 대부분의 IDE와 정적 분석 도구가 경고를 내는데, 경고를 끄고 넘어가면 사라진 예외를 찾느라 고생한다. finally 에서는 정리 작업만 하고, 정리 작업이 자원 닫기라면 애초에 try-with-resources 를 쓴다.
4. 예외 메시지에 값을 안 넣는다
throw new IllegalArgumentException("잘못된 요청입니다"); // 쓸모없다
throw new IllegalArgumentException("대출 일수는 1 이상이어야 한다: days=" + days + ", isbn=" + isbn);
다만 회원의 연락처, 주민등록번호, 비밀번호 같은 값은 그대로 넣으면 안 된다. 로그는 여러 사람이 보고 오래 남는다. 마스킹하거나 회원번호 같은 식별자만 남긴다.
스스로 확인하기
- 다음 셋을 checked / unchecked / 예외를 쓰지 않음 중 하나로 분류하고 이유를 쓴다. (가) 회원이 입력한 등록번호에 해당하는 자료가 소장 목록에 없다. (나) 도서관 설정 파일이 없어서 애플리케이션을 기동할 수 없다. (다) 특정 회원의 연체 자료를 조회했더니 0건이다.
- 아래 코드의 출력 순서를 적어라.
try (AutoCloseable a = () -> System.out.println("close A")) { System.out.println("body"); throw new RuntimeException("boom"); } catch (Exception e) { System.out.println("catch " + e.getMessage()); } finally { System.out.println("finally"); } CatalogLoader.loadTitles()에서catch (IOException e) { throw new IllegalStateException("소장자료 목록 적재 실패: " + csv); }처럼e를 빼먹었다. 운영 중 이 예외가 났을 때 무엇을 알 수 없게 되는가.
정답
- (가) unchecked 업무 예외.
ItemNotFoundException extends RuntimeException으로 만들고 등록번호를 필드로 싣는다. 호출자가 "등록번호를 다시 확인하세요" 안내로 복구할 수 있다. (나) 예외를 던지되 잡지 않는다. 기동 실패는 복구 대상이 아니라 즉시 죽어야 하는 상황이고, 잘못된 설정으로 서비스가 뜨는 것이 훨씬 위험하다. (다) 예외가 아니다. 0건은 정상적인 조회 결과이므로 빈List를 반환한다. 정상 흐름에 예외를 쓰면 성능도 손해고 코드도 읽기 어려워진다. body→close A→catch boom→finally. 자원의close()는catch보다 먼저 실행된다.AutoCloseable은 추상 메서드가 하나뿐이라 람다로 만들 수 있다.- 실패의 진짜 원인을 알 수 없게 된다. 파일이 없는 것(
NoSuchFileException)인지, 권한이 없는 것(AccessDeniedException)인지, 디스크 문제인지가 전부 지워지고 "소장자료 목록 적재 실패"라는 문장만 남는다. 원인을 넘기면 스택 트레이스에Caused by:로 원래 예외와 그 발생 위치가 함께 기록된다.