본문 바로가기

Spring 데이터 접근 예외

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

Spring의 DataAccessException 계층은 JDBC와 공급자마다 다른 런타임 예외를 공통의 기술 언어로 정리합니다. 그렇다고 DuplicateKeyException이 곧 업무상의 중복을 뜻하거나, TransientDataAccessException이 곧바로 재시도를 허용하는 것은 아닙니다. 어댑터가 가진 구조화된 제약 식별자와, 지금 하는 작업의 안전 조건을 함께 보아야 최종 행동을 정할 수 있습니다.

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

요청 키 중복·무결성·안전한 첫 읽기·나머지 실패를 순서대로 판정하고 첫 일치 행동에서 멈추는 알고리즘이다.

FAILURE TAXONOMY · ORDERED ACTION POLICY

예외 범주와 작업 문맥을 함께 보고 행동을 고른다

분류 순서를 지킵니다. 정확한 요청 키 중복을 먼저 확인한 뒤 일시 실패의 작업·시도 횟수·기한을 검사합니다.

범주와 문맥의 순서를 지키는 분류원 classifier의순서대로네판정조건과다섯종료노드·여덟방향간선을그린다. 요청키중복→그밖의무결성→안전한첫읽기/100ms→나머지일시/자원실패순서다. 중단조사는두서로다른분기의같은행동을두종료로표시한다. 예외범주와문맥을함께판정하며재시도실행을이그림이대신하지않는다. 두layout의조건순서/yes-no관계가같다.요청 키중복인가?DUPLICATE_REQUEST예아니오그 밖의무결성?ABORT_AND_INVESTIGATE예아니오일시 실패 +첫 READ·0회남은 기한100ms 이상?RETRY_READ_ONCE예아니오일시 실패또는 자원사용 불가?REPORT_UNAVAILABLE예아니오중단 · 조사
같은 네 조건의 모바일 분기원 classifier의순서대로네판정조건과다섯종료노드·여덟방향간선을그린다. 요청키중복→그밖의무결성→안전한첫읽기/100ms→나머지일시/자원실패순서다. 중단조사는두서로다른분기의같은행동을두종료로표시한다. 예외범주와문맥을함께판정하며재시도실행을이그림이대신하지않는다. 두layout의조건순서/yes-no관계가같다.요청 키중복인가?요청 중복예아니오그 밖의무결성?중단 · 조사예아니오일시 실패 +첫 READ·0회남은 기한100ms 이상?읽기 1회재시도예아니오일시 실패또는 자원사용 불가?사용 불가예아니오중단 · 조사

1 · 정확한 요청 키 중복

DuplicateKeyException

+ 소유한 요청 키 제약

DUPLICATE_REQUEST

기존 결과 확인 또는 충돌 응답입니다. 다른 무결성 위반과 구별합니다.

2 · 한 번의 안전한 읽기

TransientDataAccessException

+ READ · 이전 시도 0 · 기한 ≥ 100ms

RETRY_READ_ONCE

어댑터 안에서 한 번만 재시도합니다. 두 번째 실패도 현재 범주와 문맥으로 다시 분류합니다.

3 · 자동 재시도 불가

그 밖의 일시 실패

또는 DataAccessResourceFailureException

REPORT_UNAVAILABLE

쓰기·이미 재시도한 읽기·짧은 기한에는 자동 반복하지 않습니다. 원인을 보존합니다.

4 · 나머지 실패

다른 무결성 위반

또는 알 수 없는 범주

ABORT · INVESTIGATE

중복이나 사용자 실수로 추측하지 않습니다. 미분류 표식과 진단 원인을 유지합니다.

100ms와 한 번의 재시도는 이 교재의 정책입니다. 예외 메시지 문자열은 분류 근거가 아닙니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 원래 classifier의 순서대로 네 판정 조건과 다섯 종료 노드, 여덟 방향 간선을 그린다. 요청 키 중복→그 밖의 무결성→안전한 첫 읽기/100 ms→나머지 일시/자원 실패 순서다. 중단 조사는 서로 다른 두 분기의 같은 행동을 두 종료로 표시한다. 예외 범주와 문맥을 함께 판정하며 이 그림은 재시도 실행을 대신하지 않는다. 두 배치의 조건 순서와 yes-no 관계가 같다.
관계도의 보충 설명
원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1DataAccessException은 구조화된 문맥과 함께 다음 행동으로 분류한다
6원래 classifier의 순서대로 네 판정 조건과 다섯 종료 노드, 여덟 방향 간선을 그린다. 요청 키 중복→그 밖의 무결성→안전한 첫 읽기/100 ms→나머지 일시/자원 실패 순서다. 중단 조사는 서로 다른 두 분기의 같은 행동을 두 종료로 표시한다. 예외 범주와 문맥을 함께 판정하며 이 그림은 재시도 실행을 대신하지 않는다. 두 배치의 조건 순서와 yes-no 관계가 같다.
11아니요
18100 ms 이상?
36+ READ · 이전 시도 0 · 기한 ≥ 100 ms
48100 ms와 한 번의 재시도는 이 교재의 정책입니다. 예외 메시지 문자열은 분류 근거가 아닙니다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
중복·무결성 판정

classifier가 요청 키 중복과 그 밖의 무결성을 먼저 확인합니다.

우선: 요청 키 중복 · 다음: 그 밖의 무결성첫 일치 행동에서 판정을 멈춥니다.
안전한 읽기 조건

미일치 입력은 안전한 첫 읽기와 100 ms 조건을 확인합니다.

조건: 안전한 첫 읽기·100 ms · 재시도 실행: 별도문맥이 허용하는 다음 행동을 좁힙니다.
나머지 실패 행동

남은 일시·자원 실패를 분류해 행동을 반환합니다.

결과: 분류된 행동 · 중단 조사: 독립 종료예외 이름만으로 반복 실행을 시작하지 않습니다.

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

요청 키 중복·무결성·안전한 첫 읽기·나머지 실패를 순서대로 판정하고 첫 일치 행동에서 멈추는 알고리즘이다.

FAILURE TAXONOMY · ORDERED ACTION POLICY

예외 범주와 작업 문맥을 함께 보고 행동을 고른다

분류 순서를 지킵니다. 정확한 요청 키 중복을 먼저 확인한 뒤 일시 실패의 작업·시도 횟수·기한을 검사합니다.

범주와 문맥의 순서를 지키는 분류원 classifier의순서대로네판정조건과다섯종료노드·여덟방향간선을그린다. 요청키중복→그밖의무결성→안전한첫읽기/100ms→나머지일시/자원실패순서다. 중단조사는두서로다른분기의같은행동을두종료로표시한다. 예외범주와문맥을함께판정하며재시도실행을이그림이대신하지않는다. 두layout의조건순서/yes-no관계가같다.요청 키중복인가?DUPLICATE_REQUEST예아니오그 밖의무결성?ABORT_AND_INVESTIGATE예아니오일시 실패 +첫 READ·0회남은 기한100ms 이상?RETRY_READ_ONCE예아니오일시 실패또는 자원사용 불가?REPORT_UNAVAILABLE예아니오중단 · 조사
같은 네 조건의 모바일 분기원 classifier의순서대로네판정조건과다섯종료노드·여덟방향간선을그린다. 요청키중복→그밖의무결성→안전한첫읽기/100ms→나머지일시/자원실패순서다. 중단조사는두서로다른분기의같은행동을두종료로표시한다. 예외범주와문맥을함께판정하며재시도실행을이그림이대신하지않는다. 두layout의조건순서/yes-no관계가같다.요청 키중복인가?요청 중복예아니오그 밖의무결성?중단 · 조사예아니오일시 실패 +첫 READ·0회남은 기한100ms 이상?읽기 1회재시도예아니오일시 실패또는 자원사용 불가?사용 불가예아니오중단 · 조사

1 · 정확한 요청 키 중복

DuplicateKeyException

+ 소유한 요청 키 제약

DUPLICATE_REQUEST

기존 결과 확인 또는 충돌 응답입니다. 다른 무결성 위반과 구별합니다.

2 · 한 번의 안전한 읽기

TransientDataAccessException

+ READ · 이전 시도 0 · 기한 ≥ 100ms

RETRY_READ_ONCE

어댑터 안에서 한 번만 재시도합니다. 두 번째 실패도 현재 범주와 문맥으로 다시 분류합니다.

3 · 자동 재시도 불가

그 밖의 일시 실패

또는 DataAccessResourceFailureException

REPORT_UNAVAILABLE

쓰기·이미 재시도한 읽기·짧은 기한에는 자동 반복하지 않습니다. 원인을 보존합니다.

4 · 나머지 실패

다른 무결성 위반

또는 알 수 없는 범주

ABORT · INVESTIGATE

중복이나 사용자 실수로 추측하지 않습니다. 미분류 표식과 진단 원인을 유지합니다.

100ms와 한 번의 재시도는 이 교재의 정책입니다. 예외 메시지 문자열은 분류 근거가 아닙니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 원래 classifier의 순서대로 네 판정 조건과 다섯 종료 노드, 여덟 방향 간선을 그린다. 요청 키 중복→그 밖의 무결성→안전한 첫 읽기/100 ms→나머지 일시/자원 실패 순서다. 중단 조사는 서로 다른 두 분기의 같은 행동을 두 종료로 표시한다. 예외 범주와 문맥을 함께 판정하며 이 그림은 재시도 실행을 대신하지 않는다. 두 배치의 조건 순서와 yes-no 관계가 같다.

관계도의 보충 설명

원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1DataAccessException은 구조화된 문맥과 함께 다음 행동으로 분류한다
6원래 classifier의 순서대로 네 판정 조건과 다섯 종료 노드, 여덟 방향 간선을 그린다. 요청 키 중복→그 밖의 무결성→안전한 첫 읽기/100 ms→나머지 일시/자원 실패 순서다. 중단 조사는 서로 다른 두 분기의 같은 행동을 두 종료로 표시한다. 예외 범주와 문맥을 함께 판정하며 이 그림은 재시도 실행을 대신하지 않는다. 두 배치의 조건 순서와 yes-no 관계가 같다.
11아니요
18100 ms 이상?
36+ READ · 이전 시도 0 · 기한 ≥ 100 ms
48100 ms와 한 번의 재시도는 이 교재의 정책입니다. 예외 메시지 문자열은 분류 근거가 아닙니다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
중복·무결성 판정

classifier가 요청 키 중복과 그 밖의 무결성을 먼저 확인합니다.

우선: 요청 키 중복 · 다음: 그 밖의 무결성첫 일치 행동에서 판정을 멈춥니다.
안전한 읽기 조건

미일치 입력은 안전한 첫 읽기와 100 ms 조건을 확인합니다.

조건: 안전한 첫 읽기·100 ms · 재시도 실행: 별도문맥이 허용하는 다음 행동을 좁힙니다.
나머지 실패 행동

남은 일시·자원 실패를 분류해 행동을 반환합니다.

결과: 분류된 행동 · 중단 조사: 독립 종료예외 이름만으로 반복 실행을 시작하지 않습니다.

그림은 분류기가 정해진 순서로 네 가지를 판정하는 흐름을 보여 줍니다. 먼저 요청 키의 중복이면 DUPLICATE_REQUEST, 그 밖의 무결성 위반이면 ABORT_AND_INVESTIGATE이고, 일시적인 실패이면서 첫 READ이고 남은 기한이 100ms 이상이면 RETRY_READ_ONCE, 남은 일시적 실패나 자원 실패이면 REPORT_UNAVAILABLE, 그 밖은 다시 중단과 조사입니다. 예외의 범주와 작업 문맥을 함께 판정하며, 이 그림이 재시도를 실행하지는 않습니다. 100ms와 한 번의 재시도는 이 교재가 정한 정책이고, 예외 메시지의 문자열은 분류의 기준이 아닙니다.


Spring의 분류는 행동을 정하는 입력입니다

Spring 범주더 필요한 사실할 수 있는 행동
DuplicateKeyException어댑터가 가진 요청 키 제약이 정확히 맞는가기존 결과를 확인하거나 충돌
DataIntegrityViolationExceptionNOT NULL, 외래 키, 확인 제약 가운데 무엇인가성공으로 꾸미지 않고 중단
TransientDataAccessException읽기인가, 첫 실패인가, 기한이 남았는가조건이 맞으면 단 한 번 재시도
DataAccessResourceFailureException결과가 확정되었는가쓸 수 없다고 보고
그 밖의 DataAccessException알려진 정책이 있는가조사할 수 있는 실패로 닫기

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

더 깊이: JdbcTemplate은 어떤 기준으로 범주를 고를까

표의 첫 열은 JdbcTemplate이 SQLException을 바꾼 결과인데, 그 기준이 Spring Framework 6.0에서 바뀌었습니다. 7.0.10의 JdbcAccessor를 보면 클래스 경로 루트에 사용자가 만든 sql-error-codes.xml이 있을 때만 공급자의 오류 코드 표를 쓰는 SQLErrorCodeSQLExceptionTranslator를 고르고, 그렇지 않으면 6.0부터 기본값이 된 SQLExceptionSubclassTranslator를 씁니다. 이 변환기는 JDBC 4의 하위 클래스를 먼저 봅니다. SQLTimeoutException은 QueryTimeoutException, SQLTransactionRollbackException은 비관적 잠금 실패 계열, SQLIntegrityConstraintViolationException은 무결성 위반 계열이 되는 식입니다.

드라이버가 하위 클래스 없이 SQLException만 던지면, 대체 변환기인 SQLStateSQLExceptionTranslator가 SQLState의 앞 두 글자인 클래스 값으로 범주를 정합니다. 예를 들어 23은 무결성 위반, 40은 비관적 잠금 실패, 08은 자원 실패로 분류됩니다. DuplicateKeyException을 고르는 규칙은 더 좁아서, SQLState가 23505이거나 23000이면서 공급자 오류 코드가 MySQL의 1062처럼 미리 정해 둔 값일 때만입니다. 그래서 같은 유일성 위반이라도 드라이버가 다른 SQLState를 보내면 DuplicateKeyException이 아니라 상위의 DataIntegrityViolationException으로 올 수 있습니다. 예외 타입만으로 중복을 판정하지 말고 구조화된 제약 정보를 함께 확인해야 하는 이유가 하나 더 있는 셈입니다.


행동의 문맥을 명시적인 값으로 만듭니다

문맥에는 작업의 종류와 어댑터가 가진 제약, 이미 한 재시도 횟수, 남은 기한만 들어갑니다. 쓰기는 네트워크 응답을 잃었을 때 커밋되었는지 모를 수 있으므로, 이 정책에서 자동으로 재시도하지 않습니다.

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밀리초일 때만 한 번 재시도하며, 쓰기와 두 번째 실패, 부족한 기한은 바로 쓸 수 없다는 행동으로 닫습니다.

이 분류기는 다음 행동을 돌려줄 뿐, 연결을 다시 얻거나 SQL을 실행하지 않습니다. READ는 되풀이해도 업무의 변경이나 외부 부수 효과를 만들지 않는 읽기라는 전제입니다. 실제로 재시도할 때는 실패 뒤에도 트랜잭션과 연결을 계속 쓸 수 있는지 확인합니다. 이미 끝났거나 롤백해야 하는 트랜잭션이라면 먼저 정리하고 올바른 새 실행 단위를 준비한 뒤, 남은 기한도 다시 계산합니다. 롤백해야 하는 트랜잭션 안에서 같은 SQL만 되풀이하는 것과는 다릅니다.

100ms는 이 예제에서 재시도를 허용하는 하한일 뿐, 쿼리가 그 안에 끝난다는 보장이 아닙니다. 재시도할 때 데이터가 달라질 수도 있으므로, 같은 결과까지 약속하려면 별도의 일관된 읽기 조건이 필요합니다. Spring의 일시적 예외 정의는 재시도하면 성공할 수도 있다는 분류일 뿐, 자동 재시도 정책 자체가 아닙니다.

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를 담은 공개 DuplicatePostRequestException으로 좁힐 수 있습니다. REPORT_UNAVAILABLE과 ABORT_AND_INVESTIGATE는 원인을 보존한 PostPersistenceException으로 전달하되, 앞의 것은 가용성 지표로, 뒤의 것은 분류되지 않은 장애 지표로 나눕니다.

RETRY_READ_ONCE는 바깥으로 나가는 예외가 아니라 안에서 쓰는 행동입니다. 재시도 뒤의 실패도 같은 순서로 다시 분류하지만, priorAttempts가 1이므로 일시적인 예외는 REPORT_UNAVAILABLE로 끝나고 다시 시도하지 않습니다. 두 번째 실패가 무결성이나 분류되지 않은 범주로 바뀌면 그 범주에 따라 ABORT_AND_INVESTIGATE로 판단합니다.


반례로 잘못된 분류를 막습니다

테스트는 같은 예외 메시지라도 구조화된 제약이 다르면 행동이 달라지고, 일시적인 하위 타입이라도 작업과 시도 횟수, 기한의 조건이 모두 맞아야 재시도된다는 것을 보여 줍니다. 테스트는 Spring 컨텍스트 없이 분류기에 예외 객체와 FailureContext를 직접 넘기고, 돌려받은 Action만 비교합니다. 예를 들어 둘째 테스트의 메시지에는 제약 이름처럼 보이는 uk_post_request가 들어 있지만, 문맥의 제약이 UNKNOWN이므로 결과는 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()로 둡니다. RowMapper가 ResultSet이나 지연 객체를 밖에 보관하지 않으며, 스트림을 쓴다면 어디서 닫는지 호출하는 쪽과의 규칙에 명시합니다.

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


정리

  • DataAccessException의 범주는 행동을 정하는 입력일 뿐, DuplicateKeyException이 곧 업무 중복이나 TransientDataAccessException이 곧 재시도를 뜻하지는 않습니다.
  • 제약 이름은 메시지가 아니라 공급자의 구조화된 정보를 어댑터가 바꾼 값으로 확인합니다.
  • 분류는 정해진 순서로 하며, 요청 키의 중복을 먼저 보고 그 밖의 무결성 위반은 중복으로 추측하지 않습니다.
  • 재시도는 첫 READ이고 기한이 충분할 때 한 번만 하며, 쓰기는 커밋 여부를 모를 수 있으므로 자동으로 되풀이하지 않습니다.
  • 재시도하기 전에 트랜잭션과 연결을 계속 쓸 수 있는지 확인하고, 필요하면 새 실행 단위를 준비합니다.

연습 문제

1. 남은 기한과 두 번째 실패

첫 READ에서 QueryTimeoutException이 났고 남은 기한은 100ms, 이전 재시도는 0회입니다. 어떤 행동이 나오나요? 기한만 99ms로 바꾸거나, 한 번 재시도한 뒤 새 QueryTimeoutException이 나온 경우와 비교하세요. 두 번째 실패가 외래 키의 무결성 실패로 바뀌면, 이미 재시도했으니 쓸 수 없다고만 분류해도 되나요?

해설 보기

100ms, 0회, READ는 세 조건을 모두 만족하므로 RETRY_READ_ONCE입니다. 99ms는 기한이 부족하고, 두 번째 일시적 실패는 priorAttempts=1이므로 둘 다 REPORT_UNAVAILABLE입니다. 반면 새 실패가 무결성 위반이라면 앞쪽의 무결성 분기에서 ABORT_AND_INVESTIGATE를 고릅니다. 이미 재시도했다는 문맥이 새 예외의 범주보다 언제나 앞서는 것은 아닙니다.

CREATE나 UPDATE, DELETE 같은 쓰기 작업은 기한이 충분해도 이 읽기 재시도 분기에 들어가지 않습니다. 응답을 잃은 쓰기는 커밋되었는지를 먼저 확인해야 하므로, 같은 INSERT를 무조건 되풀이하지 않습니다.

2. 같은 메시지, 다른 제약

두 DuplicateKeyException의 메시지가 모두 duplicate key value violates unique constraint인데, 하나는 OwnedConstraint가 요청 키 제약이고 다른 하나는 UNKNOWN입니다. 분류기는 각각 어떤 행동을 돌려주고, 왜 메시지가 같은데 결과가 달라야 하나요?

해설 보기

요청 키 제약이 확인된 쪽은 DUPLICATE_REQUEST가 되고, 어댑터가 DuplicatePostRequestException으로 좁힐 수 있습니다. 제약이 확인되지 않은 쪽은 그 밖의 무결성 위반이므로 ABORT_AND_INVESTIGATE입니다. 메시지는 공급자와 버전, 로케일에 따라 바뀌고, 다른 유일성 제약에서도 같은 문구가 나올 수 있으므로 행동의 기준이 될 수 없습니다. 어댑터가 확인한 구조화된 제약만이 호출한 쪽이 기존 결과를 확인해도 되는지를 알려 줍니다.

다음 글 @Transactional 프록시에서는 @Transactional이 프록시를 통해 트랜잭션을 시작하고 끝내는 과정을 실행으로 확인합니다.