Files

5.7 KiB

exceptions 예시

이 문서는 exceptions 기준을 코드 예시로 확인하기 위한 자료입니다.
핵심 기준은 예외를 정상 흐름 제어가 아니라 예외 상황 전달 수단으로 사용하고, catch는 번역 / 문맥 추가 / 복구 목적이 있을 때만 두는 것입니다.

좋은 예시 1: 기술 예외를 계층 예외로 번역하면서 cause 보존

try {
    HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
    return parse(response.body());
} catch (IOException exception) {
    throw new InfrastructureException(
            InfrastructureErrorCode.EXTERNAL_API_FAILED,
            "Failed to call external API",
            exception
    );
} catch (InterruptedException exception) {
    Thread.currentThread().interrupt();
    throw new InfrastructureException(
            InfrastructureErrorCode.EXTERNAL_API_FAILED,
            "External API call was interrupted",
            exception
    );
}

왜 좋은가:

  • broad catch가 아니다.
  • InterruptedException을 별도로 처리한다.
  • cause를 보존한다.
  • 기술 실패를 infrastructure 의미로 번역한다.

좋은 예시 2: try-with-resources 사용

try (InputStream in = Files.newInputStream(path)) {
    return objectMapper.readValue(in, Payload.class);
}

왜 좋은가:

  • 자원 해제를 자동화한다.
  • close 중 예외가 발생해도 suppressed exception으로 보존될 수 있다.

좋은 예시 3: 상위 경계에서만 broad catch

try {
    return useCase.execute(command);
} catch (ApplicationException exception) {
    return errorResponse(exception.getCode(), exception.getMessage());
} catch (Exception exception) {
    log.error("Unhandled exception while processing request", exception);
    return errorResponse("INTERNAL_SERVER_ERROR", "Unexpected server error");
}

왜 좋은가:

  • 최상위 boundary에서 마지막 방어선으로만 broad catch를 쓴다.
  • 내부 계층에서는 더 구체적인 예외 처리를 유지한다.
  • 예상 가능한 application 예외와 예상하지 못한 실패를 구분한다.

좋은 예시 4: checked 예외 rollback 필요 시 명시

@Transactional(rollbackFor = IOException.class)
public void importUsers(Path path) throws IOException {
    // import users from file
}

왜 좋은가:

  • Spring 기본 rollback 규칙을 명시적으로 보완한다.
  • checked exception이 rollback 대상인지 계약으로 드러난다.

좋은 예시 5: 테스트는 assertThrows 우선

IllegalArgumentException exception = assertThrows(
        IllegalArgumentException.class,
        () -> service.createUser(command)
);

왜 좋은가:

  • try-catch + fail 패턴보다 의도가 직접적이다.
  • 예외 객체를 받아 메시지나 상태를 추가로 검증할 수 있다.

나쁜 예시 1: broad catch + 삼키기

try {
    saveUser(user);
} catch (Exception exception) {
}

문제:

  • 예외가 사라진다.
  • 디버깅이 어려워진다.
  • interruption 같은 중요한 신호도 놓칠 수 있다.

개선 방향:

  • 복구할 수 있는 구체 예외만 catch한다.
  • 계층 예외로 번역하거나, 문맥을 붙여 다시 던진다.
  • 정말 무시해야 한다면 이유를 남기고 logging / metrics / 상태 기록 중 하나를 수행한다.

나쁜 예시 2: printStackTrace 후 계속 진행

try {
    sync();
} catch (IOException exception) {
    exception.printStackTrace();
}

문제:

  • 운영 로그 정책을 깨뜨린다.
  • 실패를 구조적으로 전달하지 못한다.

개선 방향:

  • logging framework로 기록한다.
  • 또는 계층 예외로 번역해 상위 boundary로 전달한다.

나쁜 예시 3: finally에서 return

try {
    return load();
} finally {
    return fallback();
}

문제:

  • try 블록 결과와 예외를 덮어쓴다.
  • 실제 실패가 호출자에게 전달되지 않을 수 있다.

개선 방향:

  • finally는 정리 작업만 수행한다.
  • fallback이 필요하면 catch나 명시적 분기에서 처리한다.

나쁜 예시 4: InterruptedException 뭉개기

try {
    queue.take();
} catch (Exception exception) {
    throw new IllegalStateException(exception);
}

문제:

  • interruption을 별도 의미로 처리하지 않는다.
  • 스레드 인터럽트 상태를 잃을 수 있다.

개선:

try {
    queue.take();
} catch (InterruptedException exception) {
    Thread.currentThread().interrupt();
    throw new IllegalStateException("Interrupted while waiting for queue item", exception);
}

나쁜 예시 5: 너무 넓은 일반 예외 던지기

throw new RuntimeException("bad request");

문제:

  • 의미가 너무 넓다.
  • 호출자가 어떤 실패인지 이해하기 어렵다.
  • 경계에서 일관된 에러 코드나 응답으로 번역하기 어렵다.

개선 방향:

  • 계약 위반이면 IllegalArgumentException 같은 구체 예외를 사용한다.
  • 계층 의미가 있으면 domain / application / infrastructure 예외로 표현한다.

나쁜 예시 6: 정상적인 결과 없음에 예외 사용

public User findUser(Long userId) {
    return userRepository.findById(userId)
            .orElseThrow(() -> new RuntimeException("user not found"));
}

문제:

  • 결과 없음이 정상적인 조회 결과일 수 있는데 예외로만 표현한다.
  • 호출자가 부재를 처리할 수 있는 선택지를 잃는다.

개선 방향:

  • 단건 조회의 부재가 정상 흐름이면 Optional<User>를 반환한다.
  • 유스케이스 계약상 반드시 있어야 하는 값이면 구체적인 application 예외로 번역한다.