본문으로 건너뛰기

안동민 개발노트

본문 시작

검사 예외 전파

검사 예외의 문법과 회복 가능성을 분리하고 JDBC 실패를 어댑터의 행동 경계에서 원인을 보존해 번역합니다.

검사 예외는 호출자에게 처리 문법을 강제하지만 실패가 회복 가능한지는 알려 주지 않습니다.

회원 게시판의 애플리케이션 포트가 SQLException을 선언하면 저장 기술을 바꾸는 일이 사용 사례와 전달 계층의 시그니처 변경으로 번집니다. 반대로 어댑터 내부의 가장 좁은 행동 경계에서 기술 실패를 번역하면 애플리케이션은 B91에서 정한 영속성 계약만 봅니다.

검사 예외는 어댑터 안에서 끝나고 행동 계약만 경계를 넘는다

CHECKED FAILURE · ACTION BOUNDARY

검사 예외는 어댑터 안에서 끝나고 행동 계약만 경계를 넘는다

JDBC의 SQLException은 발생 정보다. 어댑터가 원인을 보존해 B91 영속성 예외로 번역하고, 사용 사례와 전달 경계는 기술 타입이 아니라 가능한 다음 행동만 선택한다.

JDBC 검사 예외가 어댑터 행동 경계에서 계약 예외로 바뀌는 흐름 왼쪽 JDBC 동작에서 발생한 SQLException은 바로 옆 어댑터 행동 경계에서 잡힌다. 원인이 보존된 B91 영속성 계약만 사용 사례로 전달되며, 마지막 전달 경계는 충돌이나 사용 불가 같은 안전한 외부 표현을 고른다. ADAPTER INTERNAL · CHECKED TYPE STOPS HERE JDBC ACTION SQLException 발생 state · vendor code · cause TRANSLATION 행동 경계에서 catch 작업 이름 고정 · cause 보존 B91 CONTRACT 영속성 실패 계약 JDBC type 없는 application 경계 CALLER ACTION 다음 행동 선택 충돌 · 중단 · 안전한 응답 SIGNATURE 검사 문법은 내부에만 application port에 throws SQLException 없음 저장 기술 교체가 caller를 바꾸지 않음 DIAGNOSTICS 원인 연쇄는 그대로 SQLState와 공급자 코드는 cause에서 관찰 요청 값은 새 메시지에 복사하지 않음 DELIVERY 외부 표현은 안전하게 중복은 충돌, 가용성 실패는 503 후보 공급자 메시지는 응답에서 제외 CATCH ONLY WHERE A CONCRETE NEXT ACTION EXISTS · NEVER SWALLOW OR STRING-MATCH
  1. JDBC 동작

    SQLException의 state·공급자 코드·원인은 어댑터 내부에서 발생합니다.

  2. 행동 경계

    작업 이름을 고정하고 원인을 보존해 B91 PostPersistenceException으로 번역합니다.

  3. 사용 사례

    기술 타입 없이 중복 확인, 중단, 제한된 읽기 재시도처럼 실제 가능한 행동만 고릅니다.

  4. 전달 경계

    안전한 오류 코드만 내보내고 SQL·요청 값·공급자 메시지는 응답에서 제거합니다.

checked와 unchecked는 복구 가능성의 판정표가 아니다. 발생 위치와 처리 위치를 분리하고, 원인을 보존한 안정적인 계약만 경계를 넘긴다.


문법과 다음 행동을 분리한다

checked와 unchecked는 Java 시그니처의 차이입니다. 복구 여부는 현재 계층이 실제로 선택할 수 있는 다음 행동으로 판단합니다.

관찰한 상황현재 경계의 행동외부 계약
입력 자체가 잘못됨저장 호출 전 거절검증 결과
동일 회원·동일 요청 키커밋된 기존 결과 확인 또는 충돌DuplicatePostRequestException
읽기의 일시 장애멱등성·시도 횟수·기한을 확인제한된 내부 재시도
원인을 알 수 없거나 결과가 불명확함성공으로 꾸미지 않고 중단PostPersistenceException

로그 기록만 하고 계속하는 것은 복구가 아닙니다. 낮은 계층에서 예외를 삼키면 호출자는 실패한 저장을 성공으로 오해할 수 있습니다. 공급자 메시지를 검사하거나 모든 Exception을 잡는 방식도 행동 근거가 아니므로 사용하지 않습니다.


JDBC checked 타입은 어댑터 안에서 끝낸다

JdbcAction은 JDBC 어댑터 내부 콜백입니다. 이 타입의 SQLException 선언은 공개 애플리케이션 포트가 아니라 기술 경계 안에만 존재합니다.

src/main/java/board/tx/failure/checked/JdbcAction.java
package board.tx.failure.checked;

import java.sql.SQLException;

@FunctionalInterface
public interface JdbcAction<T> {
    T execute() throws SQLException;
}

행동 경계는 작업 이름을 닫힌 enum으로 받고, 원래 예외를 cause로 보존한 B91의 PostPersistenceException을 만듭니다. 제목·본문·SQL 파라미터 같은 요청 데이터는 새 메시지에 넣지 않습니다.

src/main/java/board/tx/failure/checked/PersistenceActionBoundary.java
package board.tx.failure.checked;

import java.sql.SQLException;
import java.util.Objects;

import board.application.PostPersistenceException;

public final class PersistenceActionBoundary {
    public enum Operation {
        CREATE_POST("create post"),
        LOAD_POST("load post");

        private final String diagnosticName;

        Operation(String diagnosticName) {
            this.diagnosticName = diagnosticName;
        }
    }

    public <T> T execute(Operation operation, JdbcAction<T> action) {
        Objects.requireNonNull(operation, "operation");
        Objects.requireNonNull(action, "action");
        try {
            return action.execute();
        } catch (SQLException failure) {
            throw new PostPersistenceException(
                    "post persistence failed: " + operation.diagnosticName,
                    failure);
        }
    }
}

이 메서드 밖에는 throws SQLException이 없습니다. 사용 사례는 JDBC, SQLState, 공급자 코드를 가져오기하지 않으며, 어댑터 교체 뒤에도 같은 실패 경계를 사용합니다.


원인은 내부 진단에, 표현은 요청 경계에 둔다

번역은 원인을 지우는 포장이 아닙니다. PostPersistenceException.getCause()에는 원래 SQLException이 남아 운영 진단이 SQLState와 공급자 코드를 읽을 수 있습니다. 외부 응답은 고정 오류 코드와 안전한 설명만 사용합니다.

중복 요청처럼 호출자가 다른 행동을 할 수 있는 실패만 더 좁은 B91 계약 예외로 번역합니다. 연결 손실이나 분류되지 않은 무결성 실패를 중복으로 바꾸지 않습니다. HTTP 어댑터는 중복을 409, 저장소 사용 불가를 503으로 표현할 수 있지만 예외 타입 자체에 HTTP 상태를 넣지 않습니다.

검사 예외를 그대로 올리는 방식은 리포지토리, 서비스, 전달 경계가 모두 기술 타입을 알아야 합니다. 이 장의 경계는 그 전파 사슬을 어댑터 한 곳에서 끊습니다. 프록시 진입, 롤백 규칙, 전파 속성은 뒤 절의 별도 책임이며 여기서는 예외 계약만 고정합니다.


경계 계약을 실행으로 검증한다

테스트는 정상 반환, 원인 동일성, 안전한 메시지를 각각 확인합니다. 손으로 적은 실행 결과 대신 JUnit이 실제 경계를 호출합니다.

src/test/java/board/tx/failure/checked/PersistenceActionBoundaryTest.java
package board.tx.failure.checked;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;

import java.sql.SQLException;

import board.application.PostPersistenceException;
import org.junit.jupiter.api.Test;

class PersistenceActionBoundaryTest {
    private final PersistenceActionBoundary boundary =
            new PersistenceActionBoundary();

    @Test
    void returnsTheJdbcActionResult() {
        long postId = boundary.execute(
                PersistenceActionBoundary.Operation.CREATE_POST,
                () -> 41L);

        assertEquals(41L, postId);
    }

    @Test
    void translatesTheCheckedFailureAndKeepsTheExactCause() {
        SQLException source = new SQLException(
                "socket closed for private-title",
                "08006",
                17002);

        PostPersistenceException failure = assertThrows(
                PostPersistenceException.class,
                () -> boundary.execute(
                        PersistenceActionBoundary.Operation.CREATE_POST,
                        () -> {
                            throw source;
                        }));

        assertSame(source, failure.getCause());
        assertEquals(
                "post persistence failed: create post",
                failure.getMessage());
    }

    @Test
    void doesNotCopyProviderDetailsIntoTheContractMessage() {
        SQLException source = new SQLException(
                "password=secret",
                "08001",
                90067);

        PostPersistenceException failure = assertThrows(
                PostPersistenceException.class,
                () -> boundary.execute(
                        PersistenceActionBoundary.Operation.LOAD_POST,
                        () -> {
                            throw source;
                        }));

        assertFalse(failure.getMessage().contains("password"));
        assertFalse(failure.getMessage().contains("08001"));
    }
}

이 계약은 checked 타입을 숨기면서도 진단 가능성을 잃지 않습니다. 예외를 잡는 위치는 발생 위치가 아니라 구체적인 다음 행동을 소유한 경계입니다.