본문 바로가기
Java & Spring

예외 처리 베스트 프랙티스(체크/언체크, 경계 설계)

by yamoojin83 2025. 9. 29.

예외 처리 베스트 프랙티스(체크/언체크, 경계 설계)

예외 처리는 “무엇을 어디서 잡고, 무엇을 어디까지 전파할지”의 설계 문제입니다. 특히 체크 예외 vs 언체크 예외의 역할을 구분하고, 경계(boundary)에서 번역(translate)하는 규칙만 잡아도 안정성과 가독성이 확 올라갑니다.

1) 체크 vs 언체크: 차이와 기본 원칙

  • 체크 예외(checked): 메서드 시그니처에 throws로 드러나고, 호출자가 처리/전파를 강제당합니다. 대표: IOException, SQLException.
  • 언체크 예외(unchecked): RuntimeException 계열. 보통 버그/계약 위반이거나 호출자가 처리할 근거가 약한 상황. 대표: IllegalArgumentException, IllegalStateException, NullPointerException.
규칙 1. 시스템/IO 경계(파일, 네트워크, DB)에서는 체크 예외를 받아 도메인/애플리케이션 계층의 언체크/도메인 예외로 번역하여 올립니다. (도메인 로직은 저수준 타입을 모르게)

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); }
}
규칙 2. 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 언체크 & 경계설계 요약(이미지)

Checked vs Unchecked &amp; Boundary Design
경계에서 체크 예외를 도메인 친화적 예외로 번역하고, cause를 보존합니다.

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 스레드풀 사이즈/큐 전략

👉 9편: CompletableFuture allOf/anyOf로 외부 API 병렬화

👉 10편: 예외 처리 베스트 프랙티스(체크/언체크, 경계 설계)