Spring 데이터 접근 예외
Spring DataAccessException 계층을 구조화된 제약·작업·시도·기한과 함께 분류해 충돌, 단 한 번의 읽기 재시도, 중단 행동을 결정합니다.
Spring의 DataAccessException 계층은 JDBC와 공급자별 런타임을 공통 기술 언어로 정규화합니다. 그러나 DuplicateKeyException이 곧 업무 중복을 뜻하거나 TransientDataAccessException이 곧바로 재시도를 허용하는 것은 아닙니다.
어댑터가 소유한 구조화된 제약 식별자와 현재 작업의 안전 조건을 함께 보아야 최종 행동을 결정할 수 있습니다.
DataAccessException은 구조화된 문맥과 함께 다음 행동으로 분류한다
요청 키 중복·무결성·안전한 첫 읽기·나머지 실패를 순서대로 판정하고 첫 일치 행동에서 멈추는 알고리즘이다.
FAILURE TAXONOMY · ORDERED ACTION POLICY
예외 범주와 작업 문맥을 함께 보고 행동을 고른다
분류 순서를 지킵니다. 정확한 요청 키 중복을 먼저 확인한 뒤 일시 실패의 작업·시도 횟수·기한을 검사합니다.
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 관계가 같다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | DataAccessException은 구조화된 문맥과 함께 다음 행동으로 분류한다 |
| 6 | 원래 classifier의 순서대로 네 판정 조건과 다섯 종료 노드, 여덟 방향 간선을 그린다. 요청 키 중복→그 밖의 무결성→안전한 첫 읽기/100 ms→나머지 일시/자원 실패 순서다. 중단 조사는 서로 다른 두 분기의 같은 행동을 두 종료로 표시한다. 예외 범주와 문맥을 함께 판정하며 이 그림은 재시도 실행을 대신하지 않는다. 두 배치의 조건 순서와 yes-no 관계가 같다. |
| 11 | 아니요 |
| 18 | 100 ms 이상? |
| 36 | + READ · 이전 시도 0 · 기한 ≥ 100 ms |
| 48 | 100 ms와 한 번의 재시도는 이 교재의 정책입니다. 예외 메시지 문자열은 분류 근거가 아닙니다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 중복·무결성 판정 classifier가 요청 키 중복과 그 밖의 무결성을 먼저 확인합니다. | 우선: 요청 키 중복 · 다음: 그 밖의 무결성 | 첫 일치 행동에서 판정을 멈춥니다. |
| 안전한 읽기 조건 미일치 입력은 안전한 첫 읽기와 100 ms 조건을 확인합니다. | 조건: 안전한 첫 읽기·100 ms · 재시도 실행: 별도 | 문맥이 허용하는 다음 행동을 좁힙니다. |
| 나머지 실패 행동 남은 일시·자원 실패를 분류해 행동을 반환합니다. | 결과: 분류된 행동 · 중단 조사: 독립 종료 | 예외 이름만으로 반복 실행을 시작하지 않습니다. |
DataAccessException은 구조화된 문맥과 함께 다음 행동으로 분류한다
요청 키 중복·무결성·안전한 첫 읽기·나머지 실패를 순서대로 판정하고 첫 일치 행동에서 멈추는 알고리즘이다.
FAILURE TAXONOMY · ORDERED ACTION POLICY
예외 범주와 작업 문맥을 함께 보고 행동을 고른다
분류 순서를 지킵니다. 정확한 요청 키 중복을 먼저 확인한 뒤 일시 실패의 작업·시도 횟수·기한을 검사합니다.
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 관계가 같다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | DataAccessException은 구조화된 문맥과 함께 다음 행동으로 분류한다 |
| 6 | 원래 classifier의 순서대로 네 판정 조건과 다섯 종료 노드, 여덟 방향 간선을 그린다. 요청 키 중복→그 밖의 무결성→안전한 첫 읽기/100 ms→나머지 일시/자원 실패 순서다. 중단 조사는 서로 다른 두 분기의 같은 행동을 두 종료로 표시한다. 예외 범주와 문맥을 함께 판정하며 이 그림은 재시도 실행을 대신하지 않는다. 두 배치의 조건 순서와 yes-no 관계가 같다. |
| 11 | 아니요 |
| 18 | 100 ms 이상? |
| 36 | + READ · 이전 시도 0 · 기한 ≥ 100 ms |
| 48 | 100 ms와 한 번의 재시도는 이 교재의 정책입니다. 예외 메시지 문자열은 분류 근거가 아닙니다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 중복·무결성 판정 classifier가 요청 키 중복과 그 밖의 무결성을 먼저 확인합니다. | 우선: 요청 키 중복 · 다음: 그 밖의 무결성 | 첫 일치 행동에서 판정을 멈춥니다. |
| 안전한 읽기 조건 미일치 입력은 안전한 첫 읽기와 100 ms 조건을 확인합니다. | 조건: 안전한 첫 읽기·100 ms · 재시도 실행: 별도 | 문맥이 허용하는 다음 행동을 좁힙니다. |
| 나머지 실패 행동 남은 일시·자원 실패를 분류해 행동을 반환합니다. | 결과: 분류된 행동 · 중단 조사: 독립 종료 | 예외 이름만으로 반복 실행을 시작하지 않습니다. |
Spring 분류는 행동 판단의 입력이다
| Spring 범주 | 추가로 필요한 사실 | 가능한 행동 |
|---|---|---|
DuplicateKeyException | 정확히 소유한 요청 키 제약인가 | 기존 결과 확인 또는 충돌 |
DataIntegrityViolationException | NOT NULL·외래 키·확인 제약 중 무엇인가 | 성공으로 꾸미지 않고 중단 |
TransientDataAccessException | 읽기인가, 첫 실패인가, 기한이 남았는가 | 조건부 단 한 번 재시도 |
DataAccessResourceFailureException | 결과가 확정되었는가 | 사용 불가 보고 |
그 밖의 DataAccessException | 알려진 정책이 있는가 | 조사 가능한 실패로 닫기 |
예외 메시지의 제약 이름을 정규식으로 찾지 않습니다. 드라이버와 언어 설정이 바뀌면 메시지도 바뀝니다. 공급자 API나 스키마 메타데이터가 제공하는 구조화 필드를 어댑터가 안정적인 OwnedConstraint로 바꾼 뒤 분류기에 전달합니다.
행동 문맥을 명시적인 값으로 만든다
문맥에는 작업 종류, 소유 제약, 이미 수행한 재시도 횟수, 남은 기한만 들어갑니다. 쓰기 작업은 네트워크 응답을 잃었을 때 커밋 여부가 불명확할 수 있으므로 이 정책에서 자동 재시도하지 않습니다.
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의 일시적 예외 정의는 재시도가 성공할 수 있다는 분류이며 자동 재시도 정책 자체가 아닙니다.
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 실행 결과가 아닙니다.
반례로 오분류를 막는다
테스트는 같은 예외 메시지라도 구조화된 제약이 다르면 행동이 달라지고, 일시적 하위 타입이라도 작업·시도·기한 조건이 모두 맞아야 재시도됨을 증명합니다.
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이나 지연 객체를 경계 밖으로 보관하지 않습니다. 스트림을 사용한다면 닫는 범위를 호출 계약에 명시합니다.
이 자원 수명 규칙과 실패 분류는 서로 보완하지만 같은 책임은 아닙니다. 템플릿이 기술 예외를 정규화하고, 어댑터가 스키마 의미를 확인하며, 사용 사례가 허용된 행동만 실행합니다. 프록시 롤백과 전파 상세는 뒤 절에서 별도로 다룹니다.