본문 바로가기

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 · 재시도 실행: 별도문맥이 허용하는 다음 행동을 좁힙니다.
나머지 실패 행동

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

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

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밀리초의 남은 기한일 때만 한 번 재시도합니다. 쓰기, 두 번째 실패, 부족한 기한은 즉시 사용 불가 행동으로 닫습니다.

이 분류기는 다음 행동을 반환할 뿐 연결을 다시 얻거나 SQL을 실행하지 않습니다. READ는 반복돼도 업무 변경이나 외부 부수 효과를 만들지 않는 읽기라는 전제입니다. 실제 재시도는 실패 뒤 트랜잭션·연결이 계속 사용 가능한지 확인합니다. 이미 종료됐거나 롤백해야 하는 경계라면 먼저 정리한 뒤 유효한 새 실행 경계를 준비하고, 남은 기한도 다시 계산합니다. 이미 롤백해야 하는 경계 안에서 같은 SQL만 반복하는 방법과 구별합니다.

100 ms는 이 예제의 재시도 허용 하한이며 쿼리가 그 안에 끝난다는 보장은 아닙니다. 재시도 때 데이터가 달라질 수도 있으므로 같은 결과값까지 약속하려면 별도의 일관된 읽기 조건이 필요합니다. 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 판단을 유지합니다.

남은 예산과 두 번째 실패를 대입하기 첫 READ의 QueryTimeoutException과 남은 예산 100ms, 이전 재시도 0회는 어떤 행동인가요? 예산만 99ms로 바꾸거나, 한 번 재시도한 뒤 새 QueryTimeoutException이 나오는 경우도 비교하세요. 두 번째 실패가 외래 키 무결성 실패로 달라지면 “이미 재시도했으니 사용 불가”로만 분류해도 되는지 답하세요.

범주와 문맥을 다시 확인하는 풀이

100 ms·0회·READ는 세 조건을 모두 만족해 RETRY_READ_ONCE입니다. 99 ms는 예산 부족이고, 두 번째 일시적 실패는 priorAttempts=1이어서 둘 다 REPORT_UNAVAILABLE입니다. 반면 새 실패가 무결성 위반이면 앞의 무결성 분기에서 ABORT_AND_INVESTIGATE를 선택합니다. 이미 재시도했다는 문맥이 새 예외 범주보다 항상 앞서는 것은 아닙니다.

WRITE는 예산이 충분해도 이 읽기 재시도 분기로 들어가지 않습니다. 응답을 잃은 쓰기는 커밋 여부를 먼저 확인해야 하므로 같은 INSERT를 무조건 반복하지 않습니다. 이 정책 대입은 새 JDBC 실행 결과가 아닙니다.


반례로 오분류를 막는다

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

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이나 지연 객체를 경계 밖으로 보관하지 않습니다. 스트림을 사용한다면 닫는 범위를 호출 계약에 명시합니다.

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