예외 처리 베스트 프랙티스(체크/언체크, 경계 설계)
예외 처리는 “무엇을 어디서 잡고, 무엇을 어디까지 전파할지”의 설계 문제입니다. 특히 체크 예외 vs 언체크 예외의 역할을 구분하고, 경계(boundary)에서 번역(translate)하는 규칙만 잡아도 안정성과 가독성이 확 올라갑니다.
1) 체크 vs 언체크: 차이와 기본 원칙
- 체크 예외(checked): 메서드 시그니처에
throws로 드러나고, 호출자가 처리/전파를 강제당합니다. 대표:IOException,SQLException. - 언체크 예외(unchecked):
RuntimeException계열. 보통 버그/계약 위반이거나 호출자가 처리할 근거가 약한 상황. 대표:IllegalArgumentException,IllegalStateException,NullPointerException.
2) 경계에서의 예외 번역(Exception Translation)
아답터/게이트웨이 계층에서 저수준 예외를 도메인 친화적 예외로 바꿔 올립니다.
// 저수준: 파일 시스템 실패를 도메인 예외로 번역
class FileUserRepository implements UserRepository {
private final Path base;
FileUserRepository(Path base) { this.base = base; }
@Override
public User findById(String id) {
try {
Path p = base.resolve(id + ".json");
String json = Files.readString(p);
return deserialize(json);
} catch (IOException e) {
// 도메인 관점의 문맥을 추가하고 원인(cause)을 보존
throw new UserAccessException("read fail: " + id, e);
}
}
}
// 도메인 친화적 언체크 예외
public class UserAccessException extends RuntimeException {
public UserAccessException(String msg, Throwable cause) { super(msg, cause); }
}
new X(msg, cause) 형태로 항상 cause를 보존하세요. 원인 체인이 끊기면 디버깅 난이도가 폭증합니다.3) try-with-resources: 누수 없이 닫기
파일/소켓/DB 커넥션 등은 AutoCloseable을 활용해 안전하게 닫습니다.
Path src = Path.of("/data/in.csv");
Path dst = Path.of("/data/out.csv");
try (BufferedReader br = Files.newBufferedReader(src);
BufferedWriter bw = Files.newBufferedWriter(dst)) {
String line;
while ((line = br.readLine()) != null) {
bw.write(transform(line));
bw.newLine();
}
} // 자동 close
4) 입력 검증: 언체크(계약 위반)로 빠르게 실패
잘못된 파라미터/상태는 초기에 감지해 명확한 언체크 예외로 실패시키세요.
public Order place(String userId, int qty) {
Objects.requireNonNull(userId, "userId");
if (qty <= 0) throw new IllegalArgumentException("qty > 0");
// ...
}
5) 재시도/타임아웃: 복구 가능한 것만
네트워크 타임아웃/일시 오류 등 일시적 장애만 재시도 대상입니다. 입력 오류/버그는 재시도 금지.
<T> T withRetry(Supplier<T> op, int max, Duration backoff) {
int attempt = 0;
while (true) {
try {
return op.get();
} catch (TransientException e) {
if (++attempt >= max) throw e;
try { Thread.sleep(backoff.toMillis() * attempt); }
catch (InterruptedException ie) { Thread.currentThread().interrupt(); throw e; }
}
}
}
6) 로깅 원칙: 딱 한 번, 문맥과 함께
- 한 번만 로그: 중간 계층에서 로깅하고 다시 던지면 중복 로그가 생깁니다(“로그 폭풍”).
- 문맥: 사용자/요청ID/리소스 키 등 탐색 가능한 정보를 함께 남깁니다.
- 레벨: 예상 가능한 실패(검증 실패)는
INFO/WARN, 시스템 장애는ERROR.
try {
// ...
} catch (UserAccessException e) {
log.warn("user.get fail uid={}, req={}", uid, reqId, e); // 한 번만
throw e; // 재로깅하지 않음
}
7) 예외 vs. 반환값: 무엇이 예외인가?
- 정상적 부재: 검색 결과 없음은 보통
Optional<T>또는null(팀 규칙)로 표현. - 비정상 상태: 계약 위반/시스템 오류는 예외.
8) 반패턴 피하기
- catch (Exception)으로 광범위 포획 후 무시/빈 리턴 → 문제 은폐
- 체크 예외를
throws Exception으로 뭉뚱그림 → API 계약이 불명확 - 스택트레이스 없애기(성능 핑계) → 운영 문제 원인 추적 불가
9) 성능 고려
- 예외는 비정상 흐름에만 사용(핫패스에서 제어 흐름 용도로 남용 금지)
- 스택 트레이스 생성 비용이 크므로 루프 내부에서 빈번히 던지지 말 것
10) 요약 체크리스트
- 경계에서 번역하고, 도메인에서는 저수준 예외를 숨긴다.
- cause는 반드시 보존(
new X(msg, cause)). - try-with-resources로 자원 누수 방지.
- 재시도는 일시 장애에만.
- 로그는 한 번, 문맥 포함.
체크 vs 언체크 & 경계설계 요약(이미지)

FAQ
Q1. 언체크만 쓰면 간단하지 않나요?
A. 전부 언체크로 통일하면 경계에서의 복구/재시도가 누락되기 쉽습니다. I/O 계층에서는 체크 예외로 호출자에게 명시적으로 책임을 요구하고, 계층을 넘길 때 번역하는 전략이 안전합니다.
Q2. 성능 때문에 예외를 최소화해야 한다는데요?
A. 예외 객체 생성/스택 캡처는 비용이 큽니다. 따라서 예외는 예외 상황에서만 던지고, 정상 흐름에는 사용하지 않는 것이 정석입니다.
관련 글
👉 1편: ArrayList vs LinkedList: 언제 무엇을 쓰나 (+O(1) 착각 5가지)
👉 2편: 제네릭 와일드카드 완전정복(PECS 암기팁 포함)
👉 3편: Stream API: for→stream 리팩터링 10가지(성능/가독 균형)
👉 4편: Optional.orElse vs orElseGet 차이와 NPE 방지
👉 5편: java.time 제대로 쓰기(타임존/포맷 실수 7가지)
👉 6편: Java Record vs Lombok DTO 선택 기준
👉 7편: NIO.2로 폴더 스캔/감시 구현(FileVisitor/WatchService)
👉 8편: ExecutorService 스레드풀 사이즈/큐 전략
'Java & Spring' 카테고리의 다른 글
| @ControllerAdvice 글로벌 예외 응답(에러코드 규격) (0) | 2025.09.29 |
|---|---|
| Bean Validation(@Valid)로 입력 검증 표준 만들기 (0) | 2025.09.29 |
| CompletableFuture allOf/anyOf로 외부 API 병렬화 (0) | 2025.09.29 |
| ExecutorService 스레드풀 사이즈/큐 전략 (0) | 2025.09.29 |
| NIO.2로 폴더 스캔/감시 구현(FileVisitor/WatchService) (0) | 2025.09.29 |