검사 예외 전파
SQLException 전파 문제를 재현하고 복구 가능성과 기술 독립성을 기준으로 예외 번역 위치를 정합니다.
Java의 예외 문법은 실패를 숨기지 못하게 하지만, 모든 호출자가 같은 기술 예외를 알아야 한다는 뜻은 아닙니다.
회원 게시판의 저장소가 SQLException을 그대로 노출하면 게시글을 등록하는 서비스와 HTTP 어댑터까지 JDBC에 묶입니다.
먼저 호출 스택과 예외 계약을 분리해서 읽어야 합니다.
발생한 위치는 원인이고, 처리할 위치는 복구 결정을 내릴 수 있는 경계입니다.
검사·비검사 예외
검사 예외는 컴파일러가 예외 처리 또는 throws를 요구합니다.
비검사 예외는 선언하지 않아도 전파됩니다.
이 차이는 문법적 강제일 뿐, 네트워크 단절이 복구 가능하고 잘못된 인자가 복구 불가능하다는 결론을 주지 않습니다.
같은 타임아웃도 멱등 조회에서는 재시도할 수 있지만 결제와 연결된 쓰기에서는 결과 확인이 먼저입니다.
Error는 애플리케이션이 정상 복구를 약속할 대상이 아닙니다.
RuntimeException도 무조건 장애하라는 뜻이 아니며 요청 경계에서 4xx·5xx로 변환할 수 있습니다.
분류할 때는 다음 질문을 순서대로 답합니다.
| 질문 | 판단 근거 | 대표 처리 |
|---|---|---|
| 현재 계층이 의미 있는 대안을 갖는가 | 대체·재입력·재시도 가능 | 여기서 예외 처리 |
| 호출자가 실패 종류를 업무적으로 구분하는가 | 중복·미발견·충돌 | 계약 예외로 번역 |
| 기술 원인이 진단에 필요한가 | SQLState·공급자 코드·원인 | 원인 보존 |
| 즉시 회복할 방법이 없는가 | 연결 손실·설정 오류 | 런타임으로 전파 |
검사 예외를 쓴다는 이유만으로 안정성이 생기지 않습니다.
호출자가 catch (Exception ignored)로 삼키면 오히려 데이터 손실을 가립니다.
복구 행동 없는 예외 처리는 대개 로그 중복과 잘못된 성공 응답을 만듭니다.
JDBC 예외 누출
아래 프로그램은 저장소의 검사 예외가 서비스와 진입점까지 이동하는 최소 사례입니다.
세 계층 모두 JDBC 실패를 문법적으로 알아야 실행됩니다.
package board.failure;
import java.sql.SQLException;
public final class CheckedLeakDemo {
interface PostRepository {
long save(String title) throws SQLException;
}
static final class FailingJdbcRepository implements PostRepository {
@Override
public long save(String title) throws SQLException {
throw new SQLException("connection refused", "08001", 90067);
}
}
static final class RegistrationService {
private final PostRepository repository;
RegistrationService(PostRepository repository) {
this.repository = repository;
}
long register(String title) throws SQLException {
if (title.isBlank()) {
throw new IllegalArgumentException("title is blank");
}
return repository.save(title);
}
}
public static void main(String[] args) {
var service = new RegistrationService(new FailingJdbcRepository());
try {
service.register("transaction");
System.out.println("registered");
} catch (SQLException failure) {
System.out.printf("http=503 sqlState=%s vendor=%d%n",
failure.getSQLState(), failure.getErrorCode());
}
}
}http=503 sqlState=08001 vendor=90067RegistrationService가 JDBC 연결을 복구하지도 않는데 SQLException을 선언합니다.
저장 기술을 HTTP 클라이언트나 파일로 바꾸면 인터페이스와 서비스 시그니처도 바뀝니다.
검사 예외의 정보가 많아서가 아니라 번역 경계가 없어서 생긴 결합입니다.
예외 처리의 의미
예외 처리할 수 있다는 사실과 처리할 수 있다는 사실은 다릅니다.
현재 계층이 선택할 수 있는 행동은 제한적입니다.
사용자가 고칠 입력이면 검증 결과를 만들고, 중복 요청이면 기존 결과를 반환하거나 충돌로 응답하고, 일시 장애면 재시도 예산 안에서 다시 시도합니다.
그 밖의 실패는 상위 경계로 전달합니다.
트랜잭션 안에서 예외 처리한 뒤 성공처럼 반환하는 행동은 특히 위험합니다.
SQL 한 건이 실패했는데 후속 작업을 계속하면 일부 상태만 남거나 트랜잭션이 롤백-전용이 된 뒤 늦게 실패합니다.
예외를 흡수하려면 트랜잭션 상태와 대체 결과를 함께 책임져야 합니다.
로그도 처리 행동이 아닙니다.
리포지토리, 서비스, 컨트롤러가 같은 스택 트레이스를 모두 기록하면 장애 하나가 세 건으로 보입니다.
낮은 계층은 필요한 컨텍스트를 예외에 담고, 최종 요청 경계가 요청 ID와 함께 한 번 기록하는 편이 추적하기 쉽습니다.
원인·업무 문맥 보존
런타임 계약으로 바꾸더라도 원인을 잃으면 안 됩니다.
다음 예제는 어댑터가 SQLState를 분류하고, 서비스는 JDBC 타입을 가져오기하지 않은 채 저장 실패를 전달합니다.
package board.failure;
import java.sql.SQLException;
public final class TranslatedFailureDemo {
static class PostStoreFailure extends RuntimeException {
private final String operation;
PostStoreFailure(String operation, Throwable cause) {
super("post store failed: " + operation, cause);
this.operation = operation;
}
String operation() {
return operation;
}
}
static final class StoreUnavailable extends PostStoreFailure {
StoreUnavailable(String operation, Throwable cause) {
super(operation, cause);
}
}
static long insert(String title) {
try {
return executeJdbc(title);
} catch (SQLException failure) {
if (failure.getSQLState().startsWith("08")) {
throw new StoreUnavailable("insert post", failure);
}
throw new PostStoreFailure("insert post", failure);
}
}
private static long executeJdbc(String title) throws SQLException {
throw new SQLException("socket closed", "08006", 17002);
}
public static void main(String[] args) {
try {
insert("exception design");
} catch (StoreUnavailable failure) {
var sql = (SQLException) failure.getCause();
System.out.printf("type=%s operation=%s state=%s%n",
failure.getClass().getSimpleName(),
failure.operation(), sql.getSQLState());
}
}
}어댑터가 공급자 코드 전체를 서비스 계약으로 끌어올리지는 않습니다.
다만 메시지와 원인 연쇄에는 작업과 원래 SQLException이 남습니다.
운영 환경 로그는 게시글 제목·본문처럼 민감할 수 있는 값을 넣지 않고 게시글 ID, 작업, SQLState 정도를 구조화합니다.
경계별 예외 선언
외부 SDK가 검사 예외를 제공해도 내부 포트까지 같은 타입을 복제할 필요는 없습니다.
어댑터에서 기술 실패를 애플리케이션 실패로 바꾸고, 사용 사례는 사용자가 선택할 수 있는 실패만 명시적인 결과로 반환합니다.
예를 들어 제목 중복은 DuplicateTitle로, 데이터베이스 중단은 StoreUnavailable로 구별할 수 있습니다.
반대로 파일 내보내기처럼 호출자가 실제로 다른 파일을 선택할 수 있는 작업은 검사 규칙이 유용할 수 있습니다.
중요한 기준은 “검사 예외라서 선언”이 아니라 “바로 위 호출자가 복구 선택지를 가짐”입니다.
비동기 메시지 핸들러라면 반환 대신 재시도 대기열과 배달 실패 정책이 그 선택지가 됩니다.
API 오류 매핑은 마지막 번역입니다.
DuplicateTitle는 409, 잘못된 형식 입력은 400, StoreUnavailable은 503이 될 수 있습니다.
원시 SQL 메시지를 응답에 노출하지 않습니다.
사용자 표현과 운영 진단은 같은 예외에서 출발하되 서로 다른 필드를 사용합니다.
실패 규칙 테스트
단위 테스트는 예외 클래스만 확인하지 말고 원인 보존, 작업 컨텍스트, 비밀 정보 비노출을 검사합니다.
통합 테스트에서는 실제 제약 조건과 연결 실패가 예상한 계약 예외로 번역되는지 봅니다.
가짜 리포지토리가 아무 예외도 내지 않으면 운영 환경 어댑터의 중요한 계약이 검증되지 않습니다.
운영에서는 실패 타입별 개수와 재시도 결과를 관찰합니다.
모든 PostStoreFailure를 재시도하면 중복 쓰기나 긴 지연을 만들 수 있습니다.
연결 계열처럼 명시적으로 일시적라고 판정한 실패만 멱등성 키와 짧은 백오프 조건 아래 재시도합니다.
연습 문제
CSV 내보내기 어댑터가 IOException을 던지는 상황을 설계하세요.
사용자가 다른 경로를 고를 수 있는 실패와 디스크 자체를 사용할 수 없는 실패를 나누고, 원인을 보존하면서 CLI 종료 코드가 달라지는 실행 예제를 작성하세요.
해설 보기
복구 가능한 선택 오류는 값 결과로 돌려도 되고, 인프라 장애는 런타임 예외로 번역할 수 있습니다.
아래 예시는 두 표현을 섞지 않고 원인만 공통으로 보존합니다.
package board.failure;
import java.io.IOException;
import java.nio.file.Path;
public final class ExportFailureAnswer {
sealed interface ExportResult permits Exported, ChooseAnotherPath { }
record Exported(Path path) implements ExportResult { }
record ChooseAnotherPath(Path path, String reason) implements ExportResult { }
static final class ExportInfrastructureFailure extends RuntimeException {
ExportInfrastructureFailure(IOException cause) {
super("export storage unavailable", cause);
}
}
static ExportResult export(Path path, boolean directoryMissing)
throws IOException {
if (directoryMissing) {
return new ChooseAnotherPath(path, "directory missing");
}
throw new IOException("read-only file system");
}
public static void main(String[] args) {
try {
ExportResult result = export(Path.of("report.csv"), true);
System.out.println(result);
} catch (IOException failure) {
throw new ExportInfrastructureFailure(failure);
}
}
}실제 어댑터에서는 NoSuchFileException, AccessDeniedException, 파일 시스템 상태를 근거로 분류합니다.
메시지 문자열 비교에 의존하면 JDK와 운영체제 변경에 취약합니다.