Repository 예외 변환
@Repository와 영속성 예외 변환 후처리기가 공급자 실패를 Spring 분류로 바꾸고 어댑터가 공개 애플리케이션 경계로 좁히는 조건을 검증합니다.
@Repository는 그 자체로 공급자의 예외를 바꿔 주는 마법이 아닙니다. 대상 빈과 PersistenceExceptionTranslationPostProcessor, 그 예외를 이해하는 PersistenceExceptionTranslator가 함께 있을 때, 공개 저장소 호출에서 예외가 바뀌는 지점이 만들어집니다. 그렇게 만들어진 DataAccessException은 중간 단계의 언어입니다. 어댑터는 이것을 한 번 더 좁혀, 공개된 PostPersistenceException이나 DuplicatePostRequestException만 애플리케이션 쪽으로 내보냅니다.
@Repository 번역 경계는 표식·후처리기·변환기가 함께 만든다
Repository 대상 선정·후처리기 advisor 설치·프록시 노출은 빈 생성 시점의 실제 세 단계이다.
SPRING TRANSLATION · SETUP → RUNTIME
설정 때 advisor를 설치하고 호출 때 실패를 번역한다
@Repository 표시는 대상 지정입니다. 후처리기와 translator가 함께 있어야 노출 프록시에 변환 경계가 생깁니다.
1 · 빈 생성과 설정
- 대상을 식별
@Repository가 붙은 빈을 후처리기가 찾습니다.
- BeanFactory를 가진 advisor 설치
PersistenceExceptionTranslationPostProcessor가 BeanFactory를 가진 번역 advisor를 설치합니다.
- 프록시를 노출
변환 advisor를 기존 또는 새 프록시에 붙입니다. 공개 호출 전에 설치됩니다.
2 · 공개 호출과 실패
- 프록시 → 대상 호출
대상에서 나온 RuntimeException을 번역할 때 translator 체인이 없으면 빈들을 탐색·캐시하고 번역을 요청합니다.
- 알려진 코드 / 미분류 코드
번역 가능한 실패는 DataAccessException입니다. translator가 null을 주면 원래 공급자 실패가 남습니다.
- 어댑터에서 중립 계약으로
Spring 실패와 자신이 소유한 공급자 실패만 cause를 보존해 PostPersistenceException으로 닫습니다.
중복은 정확한 제약으로 판단
요청 키에 해당하는 고유 제약만 중복 요청으로 분류합니다. 다른 무결성 실패를 중복이라고 추측하지 않습니다.
무관한 결함은 통과
RuntimeException 전체를 잡아 공급자 오류로 숨기지 않습니다. JdbcTemplate의 JDBC 번역은 템플릿 자체 경로입니다.
후처리기는 설정 시점, advisor와 translator는 호출 실패 시점입니다. 두 시점의 책임을 구분합니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 빈 생성 시점의 세 노드와 두 간선이다. Repository 대상 선정→후처리기의 advisor 설치→프록시 노출이다. 각 요청마다 후처리기를 호출한다는 실행 순서가 아니다.
- 호출 실패 시점의 일곱 노드와 일곱 간선이다. 기술 예외를 선언하지 않는 원래 프로브 조건에서 translator가 아는 코드면 Spring 범주로, 모르는 코드면 null을 반환하여 원래 ProviderFailureException으로 나온다. 둘 다 어댑터 경계에서 cause를 보존한 공개 예외로 합류한다. 무관한 Java 결함은 원래 패널처럼 catch되지 않으며 이 공급자 실패 그림의 대상은 아니다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | @Repository 번역 경계는 표식·후처리기·변환기가 함께 만든다 |
| 7 | 빈 생성 시점의 세 노드와 두 간선이다. Repository 대상 선정→후처리기의 advisor 설치→프록시 노출이다. 각 요청마다 후처리기를 호출한다는 실행 순서가 아니다. |
| 14 | 호출 실패 시점의 일곱 노드와 일곱 간선이다. 기술 예외를 선언하지 않는 원래 프로브 조건에서 translator가 아는 코드면 Spring 범주로, 모르는 코드면 null을 반환하여 원래 ProviderFailureException으로 나온다. 둘 다 어댑터 경계에서 cause를 보존한 공개 예외로 합류한다. 무관한 Java 결함은 원래 패널처럼 catch되지 않으며 이 공급자 실패 그림의 대상은 아니다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| Repository 대상 선정 빈 생성 중 Repository 대상을 고릅니다. | 시점: 빈 생성 · 대상: Repository | 후처리할 빈이 결정됩니다. |
| advisor 설치 후처리기가 해당 대상에 번역 advisor를 설치합니다. | 구성: 번역 advisor · 요청별 후처리: 아님 | 빈의 호출 경계가 준비됩니다. |
| 프록시 노출 컨테이너가 번역 경계를 가진 프록시를 노출합니다. | 노출: proxy · 협력자: 변환기 필요 | 업무 호출에 사용할 최종 참조가 생깁니다. |
@Repository 번역 경계는 표식·후처리기·변환기가 함께 만든다
Repository 대상 선정·후처리기 advisor 설치·프록시 노출은 빈 생성 시점의 실제 세 단계이다.
SPRING TRANSLATION · SETUP → RUNTIME
설정 때 advisor를 설치하고 호출 때 실패를 번역한다
@Repository 표시는 대상 지정입니다. 후처리기와 translator가 함께 있어야 노출 프록시에 변환 경계가 생깁니다.
1 · 빈 생성과 설정
- 대상을 식별
@Repository가 붙은 빈을 후처리기가 찾습니다.
- BeanFactory를 가진 advisor 설치
PersistenceExceptionTranslationPostProcessor가 BeanFactory를 가진 번역 advisor를 설치합니다.
- 프록시를 노출
변환 advisor를 기존 또는 새 프록시에 붙입니다. 공개 호출 전에 설치됩니다.
2 · 공개 호출과 실패
- 프록시 → 대상 호출
대상에서 나온 RuntimeException을 번역할 때 translator 체인이 없으면 빈들을 탐색·캐시하고 번역을 요청합니다.
- 알려진 코드 / 미분류 코드
번역 가능한 실패는 DataAccessException입니다. translator가 null을 주면 원래 공급자 실패가 남습니다.
- 어댑터에서 중립 계약으로
Spring 실패와 자신이 소유한 공급자 실패만 cause를 보존해 PostPersistenceException으로 닫습니다.
중복은 정확한 제약으로 판단
요청 키에 해당하는 고유 제약만 중복 요청으로 분류합니다. 다른 무결성 실패를 중복이라고 추측하지 않습니다.
무관한 결함은 통과
RuntimeException 전체를 잡아 공급자 오류로 숨기지 않습니다. JdbcTemplate의 JDBC 번역은 템플릿 자체 경로입니다.
후처리기는 설정 시점, advisor와 translator는 호출 실패 시점입니다. 두 시점의 책임을 구분합니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 빈 생성 시점의 세 노드와 두 간선이다. Repository 대상 선정→후처리기의 advisor 설치→프록시 노출이다. 각 요청마다 후처리기를 호출한다는 실행 순서가 아니다.
- 호출 실패 시점의 일곱 노드와 일곱 간선이다. 기술 예외를 선언하지 않는 원래 프로브 조건에서 translator가 아는 코드면 Spring 범주로, 모르는 코드면 null을 반환하여 원래 ProviderFailureException으로 나온다. 둘 다 어댑터 경계에서 cause를 보존한 공개 예외로 합류한다. 무관한 Java 결함은 원래 패널처럼 catch되지 않으며 이 공급자 실패 그림의 대상은 아니다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | @Repository 번역 경계는 표식·후처리기·변환기가 함께 만든다 |
| 7 | 빈 생성 시점의 세 노드와 두 간선이다. Repository 대상 선정→후처리기의 advisor 설치→프록시 노출이다. 각 요청마다 후처리기를 호출한다는 실행 순서가 아니다. |
| 14 | 호출 실패 시점의 일곱 노드와 일곱 간선이다. 기술 예외를 선언하지 않는 원래 프로브 조건에서 translator가 아는 코드면 Spring 범주로, 모르는 코드면 null을 반환하여 원래 ProviderFailureException으로 나온다. 둘 다 어댑터 경계에서 cause를 보존한 공개 예외로 합류한다. 무관한 Java 결함은 원래 패널처럼 catch되지 않으며 이 공급자 실패 그림의 대상은 아니다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| Repository 대상 선정 빈 생성 중 Repository 대상을 고릅니다. | 시점: 빈 생성 · 대상: Repository | 후처리할 빈이 결정됩니다. |
| advisor 설치 후처리기가 해당 대상에 번역 advisor를 설치합니다. | 구성: 번역 advisor · 요청별 후처리: 아님 | 빈의 호출 경계가 준비됩니다. |
| 프록시 노출 컨테이너가 번역 경계를 가진 프록시를 노출합니다. | 노출: proxy · 협력자: 변환기 필요 | 업무 호출에 사용할 최종 참조가 생깁니다. |
그림의 위쪽은 빈을 만들 때의 설정 단계로, 후처리기가 @Repository 대상을 찾아 변환 어드바이저(advisor)를 설치하고 프록시를 노출합니다. 이것은 요청마다 후처리기가 호출되는 순서가 아닙니다. 아래쪽은 호출이 실패한 시점으로, 변환기(translator)가 아는 코드면 Spring의 범주로 바뀌고 모르면 null이 되어 원래의 ProviderFailureException이 그대로 나오지만, 두 경우 모두 어댑터에서 원인을 보존한 공개 예외로 모입니다. 공급자와 무관한 Java 결함은 이 경로의 대상이 아니며 잡히지 않습니다.
세 조건이 모두 있어야 변환됩니다
| 구성 요소 | 맡는 일 | 맡지 않는 일 |
|---|---|---|
@Repository 대상 | 영속성 계층이라는 표시 | 업무 예외의 자동 생성 |
| 변환 후처리기 | 알맞은 호출에 변환 인터셉터를 적용 | 모르는 예외의 추측 |
PersistenceExceptionTranslator | 구조화된 공급자 실패를 Spring 범주로 변환 | 메시지 문자열로 분류 |
| 어댑터의 변환 메서드 | Spring 범주를 애플리케이션 예외로 좁힘 | HTTP 상태의 결정 |
JdbcTemplate은 스스로 SQLException을 변환합니다. 반면 공급자 API를 직접 호출하는 저장소에는 이 후처리기 조합이 쓸모 있습니다. 어느 경우든 애노테이션을 붙였다고 업무상의 중복이 판정되지는 않습니다.
더 깊이: Spring Boot와 JPA를 함께 쓰면 세 조건 가운데 둘이 이미 준비되어 있습니다
이 글은 후처리기와 변환기를 직접 등록하지만, 보통의 Spring Boot 애플리케이션에서는 그중 일부가 이미 있습니다. Spring Boot 4.1.2의 PersistenceExceptionTranslationAutoConfiguration은 같은 타입의 빈이 없으면 PersistenceExceptionTranslationPostProcessor를 static 빈으로 등록하고, spring.persistence.exceptiontranslation.enabled를 false로 두었을 때만 등록하지 않습니다. 이때 후처리기는 spring.aop.proxy-target-class가 false가 아닌 한 클래스 기반 프록시를 만들도록 설정됩니다. 이 글처럼 후처리기를 직접 정의하면 자동 구성은 물러납니다. 다만 Spring Framework 7.0부터는 직접 만든 후처리기도 따로 정하지 않은 프록시 설정을 컨테이너의 기본 프록시 설정에서 물려받고, Spring Boot의 AOP 자동 구성은 spring.aop.proxy-target-class가 false가 아닌 한 이 기본값을 클래스 기반으로 정합니다. 그래서 이 글의 구성을 Boot 애플리케이션에 그대로 옮기면 final인 AnnotatedTranslationProbeRepository의 클래스 기반 프록시를 만들 수 없어 시작이 실패하므로, 후처리기에 setProxyTargetClass(false)를 지정하거나 클래스의 final을 뗍니다.
변환기 쪽은 JPA를 쓰면 저절로 생깁니다. 변환 인터셉터는 빈 팩토리에 있는 모든 PersistenceExceptionTranslator 빈을 찾아 하나의 체인으로 묶는데, JPA의 EntityManagerFactory를 만드는 LocalContainerEntityManagerFactoryBean의 상위 클래스가 바로 이 인터페이스를 구현합니다. 그래서 Boot와 JPA를 함께 쓰는 프로젝트에서는 @Repository만 붙여도 JPA 공급자의 예외가 DataAccessException으로 바뀌고, 이 글의 ProviderFailureException처럼 직접 만든 공급자 예외만 따로 변환기가 필요합니다. 반대로 원시 JDBC의 SQLException은 이 경로의 대상이 아닙니다. 변환기의 translateExceptionIfPossible은 RuntimeException만 받고 인터셉터도 런타임 예외만 잡으므로, 검사 예외인 SQLException은 JdbcTemplate의 내부 변환이나 어댑터의 변환 코드가 맡아야 합니다.
공급자 실패를 구조화해서 변환합니다
예제의 공급자 예외는 메시지가 아니라 닫힌 Code를 제공합니다. 원인은 보존되며, 새 타입은 전용 board.tx.failure 네임스페이스 밖으로 퍼지지 않습니다.
package board.tx.failure.translation;
import java.util.Objects;
public final class ProviderFailureException extends RuntimeException {
public enum Code {
RESOURCE_UNAVAILABLE,
UNKNOWN
}
private final Code code;
public ProviderFailureException(Code code, Throwable cause) {
super(
"persistence provider failure",
Objects.requireNonNull(cause, "cause"));
this.code = Objects.requireNonNull(code, "code");
}
public Code code() {
return code;
}
}후처리기가 감싸는 대상 인터페이스는 기술 예외를 선언하지 않습니다. 이것은 학습용 저장소 프로브이며, 애플리케이션 포트를 대신하지 않습니다.
여기서 throws를 생략한 것은 변환의 조건과도 관련이 있습니다. 기본 PersistenceExceptionTranslationInterceptor는 대상 메서드가 그 런타임 예외를 선언했다면 원래 예외를 그대로 전파합니다. 모르는 예외에 null이 돌아오는 경우와 달리, 이 경우는 메서드 시그니처의 선언 때문에 변환을 적용하지 않는 것입니다. 인터셉터의 alwaysTranslate 규칙을 함께 확인합니다.
package board.tx.failure.translation;
public interface TranslationProbeRepository {
void write();
void writeUnknown();
}구현 클래스에 @Repository를 붙이며, 공급자 예외는 이 클래스 안에서만 생깁니다. 아래 write()는 변환기가 아는 RESOURCE_UNAVAILABLE을, writeUnknown()은 변환기가 모르는 UNKNOWN을 던집니다. 그래서 두 호출을 비교하면 예외가 변환되는 경우와 원래 예외가 그대로 전파되는 경우를 함께 볼 수 있습니다.
package board.tx.failure.translation;
import org.springframework.stereotype.Repository;
@Repository
public final class AnnotatedTranslationProbeRepository
implements TranslationProbeRepository {
@Override
public void write() {
throw new ProviderFailureException(
ProviderFailureException.Code.RESOURCE_UNAVAILABLE,
new IllegalStateException("driver connection lost"));
}
@Override
public void writeUnknown() {
throw new ProviderFailureException(
ProviderFailureException.Code.UNKNOWN,
new IllegalStateException("unclassified provider failure"));
}
}변환기는 자신이 아는 코드만 처리합니다. 모르는 런타임 예외에 null을 돌려주면 Spring의 체인이 다른 변환기를 시도하고, 끝까지 변환되지 않으면 어드바이저가 원래의 실패를 다시 던집니다. 이때 어댑터의 공개 변환 메서드는 자신이 가진 공급자 타입만 중립적인 애플리케이션 영속성 예외로 감싸고, 무관한 프로그래밍 결함을 저장소 장애로 꾸미지 않습니다.
package board.tx.failure.translation;
import org.springframework.dao.DataAccessException;
import org.springframework.dao.DataAccessResourceFailureException;
import org.springframework.dao.support.PersistenceExceptionTranslator;
public final class ProviderFailureTranslator
implements PersistenceExceptionTranslator {
@Override
public DataAccessException translateExceptionIfPossible(
RuntimeException exception
) {
if (exception instanceof ProviderFailureException provider
&& provider.code()
== ProviderFailureException.Code.RESOURCE_UNAVAILABLE) {
return new DataAccessResourceFailureException(
"persistence provider unavailable",
provider);
}
return null;
}
}후처리기와 변환기를 명시적으로 등록합니다
후처리기 빈은 static 팩토리 메서드로 일찍 등록합니다. 대상 빈은 인터페이스로 노출되고, 변환기는 별도의 빈으로 탐색됩니다. 이 글은 트랜잭션 프록시나 롤백 규칙을 구성하지 않습니다.
package board.tx.failure.translation;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.dao.annotation.PersistenceExceptionTranslationPostProcessor;
import org.springframework.dao.support.PersistenceExceptionTranslator;
@Configuration(proxyBeanMethods = false)
public class FailureTranslationConfiguration {
@Bean
public static PersistenceExceptionTranslationPostProcessor
persistenceExceptionTranslationPostProcessor() {
return new PersistenceExceptionTranslationPostProcessor();
}
@Bean
public PersistenceExceptionTranslator providerFailureTranslator() {
return new ProviderFailureTranslator();
}
@Bean
public TranslationProbeRepository translationProbeRepository() {
return new AnnotatedTranslationProbeRepository();
}
@Bean
public ApplicationFailureBridge applicationFailureBridge() {
return new ApplicationFailureBridge();
}
}Spring의 범주는 애플리케이션의 최종 예외가 아닙니다. 두 번째 변환 메서드는 DataAccessException과, 어댑터가 가진 ProviderFailureException만 잡아 원인을 보존한 애플리케이션 예외를 만듭니다. Exception이나 임의의 RuntimeException을 무엇이든 잡지 않습니다.
package board.tx.failure.translation;
import java.util.Objects;
import board.application.PostPersistenceException;
import org.springframework.dao.DataAccessException;
public final class ApplicationFailureBridge {
public void execute(Runnable persistenceCall) {
Objects.requireNonNull(persistenceCall, "persistenceCall");
try {
persistenceCall.run();
} catch (DataAccessException | ProviderFailureException failure) {
throw new PostPersistenceException(
"post persistence failed",
failure);
}
}
}DataAccessException 전체를 가용성 장애라고 부르지 않습니다. 이 메서드의 메시지는 중립적이며, 구체적으로 무엇을 할지는 다음 글의 분류기가 정합니다. 변환기가 UNKNOWN 공급자 코드를 Spring 범주로 추측하지 않더라도, ProviderFailureException 자체는 어댑터가 가진 타입이므로 같은 중립 메서드에서 원인을 보존한 채 닫힙니다. 반면 IllegalArgumentException 같은 무관한 런타임 예외는 잡는 목록에 없으므로 그대로 드러납니다.
중복 요청처럼 호출한 쪽이 구별해서 할 일이 있을 때만, 어댑터가 구조화된 제약 식별자를 확인해 DuplicatePostRequestException으로 좁힙니다. 모든 DataIntegrityViolationException을 중복으로 바꾸면 NOT NULL과 외래 키, 확인 제약의 위반까지 409로 잘못 분류됩니다.
애플리케이션 포트가 돌려주는 결과의 의미도 그대로 둡니다. 조회 결과가 없는 것은 Optional.empty()라는 정상적인 결과이고, 수정과 삭제의 false는 조건에 맞는 행이 없다는 뜻입니다. 사용 사례가 필요하다면 최신 값을 다시 읽어 충돌을 어떻게 나타낼지 정합니다. 같은 회원과 같은 요청 키의 경우만 좁은 중복 예외가 되며, 가용성 실패는 원인을 가진 일반 영속성 예외로 남습니다. Spring 타입이나 동시 변경을 따로 나타내는 결과 타입 같은 별도의 호환 계층을 포트에 새로 만들지 않습니다.
설정 시점과 호출 시점을 나눕니다
PersistenceExceptionTranslationPostProcessor는 호출마다 지나가는 단계가 아닙니다. 빈을 만들 때 @Repository 대상인지 확인하고, 컨테이너가 노출할 프록시에 예외 변환 어드바이저를 설치합니다.
호출할 때는 공개 호출이 그 프록시로 들어갑니다. 어드바이저가 대상 메서드를 호출하고, 대상에서 런타임 예외가 빠져나오면 변환기 체인에 변환을 요청합니다. 알려진 공급자 코드는 DataAccessException으로 바뀌고, null이면 원래의 예외가 어드바이저 밖으로 다시 나옵니다. 두 경우 모두 어댑터의 애플리케이션 변환 메서드가 자신이 가진 실패만 애플리케이션 예외로 닫습니다.
두 단계의 원인 연쇄를 테스트로 고정합니다
첫 테스트는 @Repository 대상의 호출이 Spring 범주로 바뀌는지 확인합니다. 둘째는 그 범주가 공개 애플리케이션 메서드를 넘을 때 공급자 타입을 직접 드러내지 않는지 확인합니다. 셋째는 변환기가 모르는 공급자 코드도 원래 타입 그대로 넘어가지 않는지, 넷째는 무관한 런타임 예외를 영속성 실패로 잘못 분류하지 않는지 확인합니다.
package board.tx.failure.translation;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import board.application.PostPersistenceException;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.dao.DataAccessResourceFailureException;
import org.springframework.dao.support.PersistenceExceptionTranslator;
class RepositoryTranslationTest {
private AnnotationConfigApplicationContext context;
@BeforeEach
void openContext() {
context = new AnnotationConfigApplicationContext(
FailureTranslationConfiguration.class);
}
@AfterEach
void closeContext() {
context.close();
}
@Test
void repositoryCallUsesTheRegisteredSpringTranslator() {
TranslationProbeRepository repository =
context.getBean(TranslationProbeRepository.class);
DataAccessResourceFailureException failure = assertThrows(
DataAccessResourceFailureException.class,
repository::write);
ProviderFailureException provider = assertInstanceOf(
ProviderFailureException.class,
failure.getCause());
assertEquals(
ProviderFailureException.Code.RESOURCE_UNAVAILABLE,
provider.code());
}
@Test
void applicationBoundaryKeepsBothTranslationCauses() {
TranslationProbeRepository repository =
context.getBean(TranslationProbeRepository.class);
ApplicationFailureBridge bridge =
context.getBean(ApplicationFailureBridge.class);
PostPersistenceException failure = assertThrows(
PostPersistenceException.class,
() -> bridge.execute(repository::write));
assertEquals("post persistence failed", failure.getMessage());
DataAccessResourceFailureException springFailure =
assertInstanceOf(
DataAccessResourceFailureException.class,
failure.getCause());
assertInstanceOf(
ProviderFailureException.class,
springFailure.getCause());
}
@Test
void unknownProviderFailureIsClosedAtTheApplicationBoundary() {
PersistenceExceptionTranslator translator =
context.getBean(PersistenceExceptionTranslator.class);
TranslationProbeRepository repository =
context.getBean(TranslationProbeRepository.class);
ApplicationFailureBridge bridge =
context.getBean(ApplicationFailureBridge.class);
ProviderFailureException candidate = new ProviderFailureException(
ProviderFailureException.Code.UNKNOWN,
new IllegalStateException("unclassified provider failure"));
assertNull(translator.translateExceptionIfPossible(candidate));
PostPersistenceException failure = assertThrows(
PostPersistenceException.class,
() -> bridge.execute(repository::writeUnknown));
assertEquals("post persistence failed", failure.getMessage());
ProviderFailureException provider = assertInstanceOf(
ProviderFailureException.class,
failure.getCause());
assertEquals(ProviderFailureException.Code.UNKNOWN, provider.code());
assertInstanceOf(IllegalStateException.class, provider.getCause());
}
@Test
void unrelatedRuntimeFailureIsNotRelabeled() {
PersistenceExceptionTranslator translator =
context.getBean(PersistenceExceptionTranslator.class);
ApplicationFailureBridge bridge =
context.getBean(ApplicationFailureBridge.class);
IllegalArgumentException source =
new IllegalArgumentException("programming defect");
assertNull(translator.translateExceptionIfPossible(source));
IllegalArgumentException failure = assertThrows(
IllegalArgumentException.class,
() -> bridge.execute(() -> {
throw source;
}));
assertSame(source, failure);
}
}테스트는 변환된 타입뿐 아니라 정확한 원인의 연쇄도 검사합니다. 전달 계층은 이 내부 원인을 로그 진단에 쓸 수 있지만, 응답 본문에는 공급자의 메시지나 제약 이름을 복사하지 않습니다.
이 변환 테스트는 앞 장의 공통 영속성 테스트를 대신하지 않습니다. 기존 테스트는 생성된 식별자와 버전, 같은 회원과 같은 요청 키의 두 번째 생성에서 좁은 예외가 나고 원래 행이 유지되는지, 오래된 변경이 false와 바뀌지 않은 결과로 끝나는지를 계속 고정합니다. 이 글의 테스트는 공급자, Spring, 애플리케이션으로 이어지는 원인의 연쇄와 바깥에 드러나지 않는다는 점만 더합니다.
정리
@Repository와PersistenceExceptionTranslationPostProcessor,PersistenceExceptionTranslator가 모두 있어야 공급자 예외가DataAccessException으로 바뀝니다.- 후처리기는 빈을 만들 때 어드바이저를 설치하고, 실제 변환은 호출이 실패할 때 어드바이저와 변환기 체인이 합니다.
- 대상 메서드가 그 런타임 예외를
throws로 선언했다면, 기본 인터셉터는 변환하지 않고 그대로 전파합니다. - 어댑터는
DataAccessException과 자신이 가진 공급자 예외만 잡아 원인을 보존한 애플리케이션 예외로 닫고, 무관한 결함은 그대로 둡니다. - 모든 무결성 위반을 중복으로 보지 않고, 요청 키의 유일성 제약일 때만
DuplicatePostRequestException으로 좁힙니다.
연습 문제
1. 같은 공급자 코드가 다르게 지나가는 조건
프록시와 후처리기, 변환기를 모두 그대로 두되, 프로브 인터페이스와 구현체의 write 선언에 throws ProviderFailureException이 있다고 가정합니다. 인터셉터는 기본값인 alwaysTranslate=false입니다. RESOURCE_UNAVAILABLE 코드가 나왔을 때, 프록시 밖에서 보이는 예외와 ApplicationFailureBridge를 거친 예외의 원인 연쇄는 어떻게 달라지나요?
해설 보기
기본 인터셉터는 선언된 ProviderFailureException을 그대로 전파하므로, 선언이 없을 때처럼 Spring의 DataAccessResourceFailureException을 사이에 만들지 않습니다. 하지만 bridge는 자신이 가진 ProviderFailureException도 잡으므로, 바깥 예외는 PostPersistenceException이고 그 직접 원인은 ProviderFailureException입니다. 선언이 없는 원래 경우의 원인 연쇄는 PostPersistenceException, Spring 범주, ProviderFailureException 순입니다.
alwaysTranslate=true로 지정한 인터셉터는 이 선언에 의한 우회를 바꾸지만, 모르는 코드까지 저절로 변환해 주지는 않습니다. 변환기가 아는 구조화된 실패와 어댑터의 변환 메서드는 여전히 필요합니다.
2. 제약 이름으로 중복을 판정하기
DataIntegrityViolationException이 났을 때, 그것이 요청 키 중복인지 외래 키 위반인지 구별하려면 무엇을 확인해야 할까요? 예외 메시지에 uq_posts_member_request라는 글자가 있는지 검사하는 방법은 왜 피해야 하나요?
해설 보기
원인 연쇄에서 공급자의 구조화된 정보, 예를 들어 SQLState가 유일성 위반을 뜻하는 값인지와 드라이버나 JPA 공급자가 제공하는 제약 이름 필드가 uq_posts_member_request인지를 확인합니다. 메시지 문자열은 공급자와 버전, 로케일에 따라 바뀔 수 있고, 다른 제약 이름이 우연히 같은 글자를 포함할 수도 있어 분류의 기준으로 믿을 수 없습니다. 제약 이름을 꺼내는 방법은 DB와 드라이버마다 다르므로, 운영 DB를 쓰는 통합 테스트에서 실제로 꺼내지는지 확인합니다.
다음 글 Spring 데이터 접근 예외에서는 Spring의 DataAccessException 계층을 사용 사례가 고를 다음 행동으로 분류하는 방법을 다룹니다.