본문으로 건너뛰기

안동민 개발노트

본문 시작

Spring 데이터 접근 예외

Spring DataAccessException 계층을 구조화된 제약·작업·시도·기한과 함께 분류해 충돌, 단 한 번의 읽기 재시도, 중단 행동을 결정합니다.

Spring의 DataAccessException 계층은 JDBC와 공급자별 런타임을 공통 기술 언어로 정규화합니다. 그러나 DuplicateKeyException이 곧 업무 중복을 뜻하거나 TransientDataAccessException이 곧바로 재시도를 허용하는 것은 아닙니다.

어댑터가 소유한 구조화된 제약 식별자와 현재 작업의 안전 조건을 함께 보아야 최종 행동을 결정할 수 있습니다.

DataAccessException은 구조화된 문맥과 함께 다음 행동으로 분류한다

FAILURE TAXONOMY · ACTION POLICY

DataAccessException은 구조화된 문맥과 함께 다음 행동으로 분류한다

Spring 하위 타입 하나만 보고 업무 결론을 내리지 않는다. 소유 제약, 작업 종류, 이전 시도, 남은 기한을 함께 확인해 충돌·단 한 번의 읽기 재시도·사용 불가·중단을 고른다.

Spring 데이터 접근 예외와 구조화된 문맥을 행동으로 분류하는 흐름 위쪽 DataAccessException에서 네 개의 좁은 조건으로 흐름이 갈라진다. 소유한 요청 키 중복은 충돌, 첫 읽기의 일시 실패와 충분한 기한은 한 번 재시도, 안전하지 않은 일시 실패는 사용 불가, 나머지 무결성 또는 미분류 실패는 중단과 조사로 끝난다. SPRING CATEGORY DataAccessException PRIORITY 1 정확한 요청 키 중복 DuplicateKey + owned constraint PRIORITY 2 안전한 일시 실패 READ · attempt 0 · budget ≥ 100ms PRIORITY 3 안전하지 않은 일시 실패 write · exhausted · under budget FAIL CLOSED 그 밖의 실패 integrity · unknown category DUPLICATE_REQUEST 기존 결과 확인 또는 충돌 다른 integrity 위반과 구별 RETRY_READ_ONCE 내부에서 정확히 한 번 반복 transient 실패는 unavailable REPORT_UNAVAILABLE 자동 반복 없이 원인 보존 쓰기 결과 불명확성 보호 ABORT · INVESTIGATE 중복이나 사용자 오류로 추측 금지 미분류 지표와 진단 원인 유지 NO MESSAGE MATCHING · NO BLANKET RETRY · UNKNOWN FAILURES STAY UNKNOWN
  1. 정확한 요청 키 중복

    DuplicateKeyException과 소유한 구조화 제약이 함께 맞을 때만 기존 결과 확인 또는 충돌로 분류합니다.

  2. 안전한 읽기 재시도

    TransientDataAccessException인 첫 읽기 실패이고 남은 기한이 100밀리초 이상일 때만 내부에서 한 번 다시 시도합니다.

  3. 사용 불가 보고

    쓰기, 이미 재시도한 읽기, 부족한 기한의 일시 실패는 자동 반복하지 않습니다.

  4. 알 수 없는 실패

    다른 무결성 위반과 미분류 예외는 중복으로 추측하지 않고 중단과 조사로 닫습니다.

Spring 하위 타입은 출발점이다. 구조화된 제약과 작업 안전 조건을 더한 뒤에만 재시도나 충돌 같은 행동을 선택한다.


Spring 분류는 행동 판단의 입력이다

Spring 범주추가로 필요한 사실가능한 행동
DuplicateKeyException정확히 소유한 요청 키 제약인가기존 결과 확인 또는 충돌
DataIntegrityViolationExceptionNOT NULL·외래 키·확인 제약 중 무엇인가성공으로 꾸미지 않고 중단
TransientDataAccessException읽기인가, 첫 실패인가, 기한이 남았는가조건부 단 한 번 재시도
DataAccessResourceFailureException결과가 확정되었는가사용 불가 보고
그 밖의 DataAccessException알려진 정책이 있는가조사 가능한 실패로 닫기

예외 메시지의 제약 이름을 정규식으로 찾지 않습니다. 드라이버와 언어 설정이 바뀌면 메시지도 바뀝니다. 공급자 API나 스키마 메타데이터가 제공하는 구조화 필드를 어댑터가 안정적인 OwnedConstraint로 바꾼 뒤 분류기에 전달합니다.


행동 문맥을 명시적인 값으로 만든다

문맥에는 작업 종류, 소유 제약, 이미 수행한 재시도 횟수, 남은 기한만 들어갑니다. 쓰기 작업은 네트워크 응답을 잃었을 때 커밋 여부가 불명확할 수 있으므로 이 정책에서 자동 재시도하지 않습니다.

src/main/java/board/tx/failure/policy/FailureContext.java
package board.tx.failure.policy;

import java.time.Duration;
import java.util.Objects;

public record FailureContext(
        Operation operation,
        OwnedConstraint constraint,
        int priorAttempts,
        Duration remaining
) {
    public enum Operation {
        READ,
        CREATE,
        UPDATE,
        DELETE
    }

    public enum OwnedConstraint {
        POST_REQUEST_KEY,
        OTHER,
        UNKNOWN
    }

    public FailureContext {
        Objects.requireNonNull(operation, "operation");
        Objects.requireNonNull(constraint, "constraint");
        Objects.requireNonNull(remaining, "remaining");
        if (priorAttempts < 0) {
            throw new IllegalArgumentException(
                    "priorAttempts must not be negative");
        }
        if (remaining.isNegative()) {
            throw new IllegalArgumentException(
                    "remaining must not be negative");
        }
    }

    boolean permitsSingleReadRetry(Duration minimumBudget) {
        return operation == Operation.READ
                && priorAttempts == 0
                && remaining.compareTo(minimumBudget) >= 0;
    }
}

좁고 순서가 있는 분류기를 둔다

분류 순서가 계약입니다. DuplicateKeyException은 무결성 예외의 하위 타입이므로 정확한 요청 키 제약을 먼저 확인합니다. 알려지지 않은 무결성 실패는 사용자 중복으로 추측하지 않습니다.

일시적 범주도 첫 읽기와 최소 100밀리초의 남은 기한일 때만 한 번 재시도합니다. 쓰기, 두 번째 실패, 부족한 기한은 즉시 사용 불가 행동으로 닫습니다.

src/main/java/board/tx/failure/policy/FailureActionClassifier.java
package board.tx.failure.policy;

import java.time.Duration;
import java.util.Objects;

import org.springframework.dao.DataAccessException;
import org.springframework.dao.DataAccessResourceFailureException;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.dao.TransientDataAccessException;

public final class FailureActionClassifier {
    private static final Duration MINIMUM_RETRY_BUDGET =
            Duration.ofMillis(100);

    public enum Action {
        DUPLICATE_REQUEST,
        RETRY_READ_ONCE,
        REPORT_UNAVAILABLE,
        ABORT_AND_INVESTIGATE
    }

    public Action classify(
            DataAccessException failure,
            FailureContext context
    ) {
        Objects.requireNonNull(failure, "failure");
        Objects.requireNonNull(context, "context");

        if (failure instanceof DuplicateKeyException
                && context.constraint()
                == FailureContext.OwnedConstraint.POST_REQUEST_KEY) {
            return Action.DUPLICATE_REQUEST;
        }
        if (failure instanceof DataIntegrityViolationException) {
            return Action.ABORT_AND_INVESTIGATE;
        }
        if (failure instanceof TransientDataAccessException
                && context.permitsSingleReadRetry(
                        MINIMUM_RETRY_BUDGET)) {
            return Action.RETRY_READ_ONCE;
        }
        if (failure instanceof TransientDataAccessException
                || failure instanceof DataAccessResourceFailureException) {
            return Action.REPORT_UNAVAILABLE;
        }
        return Action.ABORT_AND_INVESTIGATE;
    }
}

DUPLICATE_REQUEST일 때만 어댑터가 회원 ID와 클라이언트 요청 ID를 담은 B91 DuplicatePostRequestException으로 좁힐 수 있습니다. REPORT_UNAVAILABLEABORT_AND_INVESTIGATE은 원인을 보존한 PostPersistenceException으로 전달하되, 전자는 가용성 지표, 후자는 미분류 장애 지표로 나눕니다.

RETRY_READ_ONCE은 외부 예외가 아니라 내부 행동입니다. 재시도 뒤 실패도 같은 순서로 다시 분류하지만 priorAttempts가 1이므로 일시적 예외는 REPORT_UNAVAILABLE로 끝나고 다시 시도하지 않습니다. 두 번째 실패가 무결성 또는 미분류 범주로 달라지면 해당 범주의 ABORT_AND_INVESTIGATE 판단을 유지합니다.


반례로 오분류를 막는다

테스트는 같은 예외 메시지라도 구조화된 제약이 다르면 행동이 달라지고, 일시적 하위 타입이라도 작업·시도·기한 조건이 모두 맞아야 재시도됨을 증명합니다.

src/test/java/board/tx/failure/policy/FailureActionClassifierTest.java
package board.tx.failure.policy;

import static org.junit.jupiter.api.Assertions.assertEquals;

import java.time.Duration;

import org.junit.jupiter.api.Test;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.dao.DataAccessResourceFailureException;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.dao.QueryTimeoutException;

class FailureActionClassifierTest {
    private final FailureActionClassifier classifier =
            new FailureActionClassifier();

    @Test
    void ownedRequestKeyDuplicateBecomesAContractConflict() {
        FailureContext context = context(
                FailureContext.Operation.CREATE,
                FailureContext.OwnedConstraint.POST_REQUEST_KEY,
                0,
                500);

        assertEquals(
                FailureActionClassifier.Action.DUPLICATE_REQUEST,
                classifier.classify(
                        new DuplicateKeyException("vendor text ignored"),
                        context));
    }

    @Test
    void duplicateMessageDoesNotReplaceStructuredConstraintIdentity() {
        FailureContext context = context(
                FailureContext.Operation.CREATE,
                FailureContext.OwnedConstraint.UNKNOWN,
                0,
                500);

        assertEquals(
                FailureActionClassifier.Action.ABORT_AND_INVESTIGATE,
                classifier.classify(
                        new DuplicateKeyException(
                                "uk_post_request duplicate"),
                        context));
    }

    @Test
    void genericIntegrityFailureIsNotCalledADuplicateRequest() {
        FailureContext context = context(
                FailureContext.Operation.CREATE,
                FailureContext.OwnedConstraint.OTHER,
                0,
                500);

        assertEquals(
                FailureActionClassifier.Action.ABORT_AND_INVESTIGATE,
                classifier.classify(
                        new DataIntegrityViolationException(
                                "check constraint"),
                        context));
    }

    @Test
    void firstReadTimeoutWithBudgetRetriesExactlyOnce() {
        FailureContext context = context(
                FailureContext.Operation.READ,
                FailureContext.OwnedConstraint.UNKNOWN,
                0,
                100);

        assertEquals(
                FailureActionClassifier.Action.RETRY_READ_ONCE,
                classifier.classify(
                        new QueryTimeoutException("read timeout"),
                        context));
    }

    @Test
    void transientWriteIsNeverAutomaticallyRepeated() {
        FailureContext context = context(
                FailureContext.Operation.CREATE,
                FailureContext.OwnedConstraint.UNKNOWN,
                0,
                500);

        assertEquals(
                FailureActionClassifier.Action.REPORT_UNAVAILABLE,
                classifier.classify(
                        new QueryTimeoutException("commit reply lost"),
                        context));
    }

    @Test
    void exhaustedOrUnderBudgetReadReportsUnavailable() {
        QueryTimeoutException timeout =
                new QueryTimeoutException("read timeout");

        assertEquals(
                FailureActionClassifier.Action.REPORT_UNAVAILABLE,
                classifier.classify(
                        timeout,
                        context(
                                FailureContext.Operation.READ,
                                FailureContext.OwnedConstraint.UNKNOWN,
                                1,
                                500)));
        assertEquals(
                FailureActionClassifier.Action.REPORT_UNAVAILABLE,
                classifier.classify(
                        timeout,
                        context(
                                FailureContext.Operation.READ,
                                FailureContext.OwnedConstraint.UNKNOWN,
                                0,
                                99)));
    }

    @Test
    void nonTransientResourceFailureIsReportedWithoutRetry() {
        FailureContext context = context(
                FailureContext.Operation.READ,
                FailureContext.OwnedConstraint.UNKNOWN,
                0,
                500);

        assertEquals(
                FailureActionClassifier.Action.REPORT_UNAVAILABLE,
                classifier.classify(
                        new DataAccessResourceFailureException(
                                "database offline"),
                        context));
    }

    private static FailureContext context(
            FailureContext.Operation operation,
            FailureContext.OwnedConstraint constraint,
            int priorAttempts,
            long remainingMillis
    ) {
        return new FailureContext(
                operation,
                constraint,
                priorAttempts,
                Duration.ofMillis(remainingMillis));
    }
}

템플릿 책임과 카디널리티는 별도로 유지한다

JdbcTemplate은 트랜잭션에 묶인 연결 획득, 구문 실행, 행 매핑, 자원 종료, SQLException 번역을 공통 흐름으로 고정합니다. 리포지토리 콜백은 SQL·파라미터·완전한 행 복원만 제공합니다.

0건이 정상인 조회는 목록이나 DataAccessUtils.optionalResult()처럼 0 또는 1을 표현하고, 정확히 1건인 계약만 queryForObject()로 둡니다. RowMapperResultSet이나 지연 객체를 경계 밖으로 보관하지 않습니다. 스트림을 사용한다면 닫는 범위를 호출 계약에 명시합니다.

이 자원 수명 규칙과 실패 분류는 서로 보완하지만 같은 책임은 아닙니다. 템플릿이 기술 예외를 정규화하고, 어댑터가 스키마 의미를 확인하며, 사용 사례가 허용된 행동만 실행합니다. 프록시 롤백과 전파 상세는 뒤 절에서 별도로 다룹니다.