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 대상 | 영속성 경계 표시 | 업무 예외 자동 생성 |
| 번역 후처리기 | 적격 호출에 번역 인터셉터 적용 | 알 수 없는 예외 추측 |
PersistenceExceptionTranslator | 구조화된 공급자 실패를 Spring 범주로 변환 | 메시지 문자열 분류 |
| 어댑터 경계 | Spring 범주를 애플리케이션 계약으로 좁힘 | HTTP 상태 결정 |
JdbcTemplate은 자체적으로 SQLException을 번역합니다. 반면 직접 공급자 API를 호출하는 리포지토리에는 이 후처리기 조합이 유용합니다. 어느 경우에도 애노테이션만 붙였다는 이유로 업무 중복이 판정되지는 않습니다.
공급자 실패를 구조화해서 번역한다
예제 공급자 예외는 메시지가 아니라 닫힌 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 규칙을 함께 확인하세요.
같은 공급자 코드가 다르게 지나가는 조건
원 코드의 프록시·후처리기·변환기를 모두 유지하되, 프로브 인터페이스와 구현체의 write 선언에 throws ProviderFailureException이 있다고 가정합니다. 인터셉터는 기본 alwaysTranslate=false입니다. RESOURCE_UNAVAILABLE 코드가 나온 경우 프록시 밖의 예외와 ApplicationFailureBridge를 거친 예외의 원인 연쇄는 어떻게 달라지나요?
선언에 따른 우회와 어댑터 닫기의 풀이
기본 인터셉터는 선언된 ProviderFailureException을 그대로 전파하므로 원 사례처럼 Spring의 DataAccessResourceFailureException을 사이에 만들지 않습니다. 그러나 bridge는 자신이 소유한 ProviderFailureException도 catch하므로 바깥은 PostPersistenceException이고 그 직접 cause는 ProviderFailureException입니다. 원 선언 없는 사례의 cause 연쇄는 PostPersistenceException→Spring 범주→ProviderFailureException입니다.
alwaysTranslate=true로 명시한 인터셉터는 이 선언 우회 조건을 바꾸지만, 모르는 코드까지 자동으로 번역되는 것은 아닙니다. translator가 아는 구조화된 실패와 어댑터의 닫기 계약은 여전히 필요합니다. 이는 선언 조건을 바꾼 소스 추적이며 새 Spring 컨텍스트 실행 결과가 아닙니다.
package board.tx.failure.translation;
public interface TranslationProbeRepository {
void write();
void writeUnknown();
}구현 클래스에 @Repository를 둡니다. 공급자 예외는 이 클래스 안에서만 발생합니다.
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 체인이 다른 변환기를 시도하고, 끝까지 번역되지 않으면 advisor가 원래 실패를 다시 던집니다. 이때 어댑터의 공개 경계는 자신이 소유한 공급자 타입만 중립적인 애플리케이션 영속성 예외로 감싸고, 무관한 프로그래밍 결함은 저장소 장애로 꾸미지 않습니다.
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 전체를 가용성 장애라고 부르지 않습니다. 이 경계의 메시지는 중립적이며, 구체 행동은 다음 절의 분류기가 정합니다. translator가 UNKNOWN 공급자 코드를 Spring 범주로 추측하지 않아도 ProviderFailureException 자체는 어댑터 소유 타입이므로 같은 중립 경계에서 원인을 보존해 닫힙니다. 반면 IllegalArgumentException 같은 무관한 런타임 예외는 이 catch 목록에 없으므로 그대로 드러납니다.
중복 요청처럼 호출자가 구별할 행동이 있는 경우에만 어댑터가 구조화된 제약 식별자를 확인해 DuplicatePostRequestException으로 좁힙니다. 모든 DataIntegrityViolationException을 중복으로 바꾸면 NOT NULL, 외래 키, 확인 제약 위반까지 409로 오분류됩니다.
애플리케이션 포트의 기존 결과 의미도 그대로 유지합니다. 조회 없음은 Optional.empty()인 정상 카디널리티이고, 수정·삭제의 false는 조건에 맞는 행이 없다는 뜻입니다. 사용 사례가 필요하면 최신 값을 다시 읽어 충돌 표현을 결정합니다. 같은 회원·같은 요청 키만 좁은 중복 예외가 되며, 가용성 실패는 원인을 가진 일반 영속성 예외로 남습니다. Spring 타입이나 ConcurrentChange 같은 별도 호환 계층을 포트에 다시 만들지 않습니다.
설정 시점과 호출 시점을 분리한다
PersistenceExceptionTranslationPostProcessor는 매 호출마다 지나가는 런타임 단계가 아닙니다. 빈 생성 시점에 @Repository 대상임을 확인하고, 컨테이너가 노출할 프록시에 예외 변환 advisor를 설치합니다.
호출 시점에는 공개 호출이 그 프록시로 들어갑니다. advisor가 대상 메서드를 호출하고, 대상에서 런타임 예외가 빠져나올 때 translator 체인에 번역을 요청합니다. 알려진 공급자 코드는 DataAccessException으로 바뀌고, null이면 원래 예외가 advisor 밖으로 다시 나옵니다. 두 경우 모두 어댑터의 애플리케이션 경계가 자신이 소유한 실패만 애플리케이션 계약으로 닫습니다.
두 단계 원인 연쇄를 계약 테스트로 고정한다
첫 테스트는 @Repository 대상 호출이 Spring 범주로 바뀌는지 확인합니다. 둘째는 그 범주가 공개 애플리케이션 경계를 넘을 때 공급자 타입을 직접 노출하지 않는지 확인합니다. 셋째는 translator가 모르는 공급자 코드도 raw 타입으로 경계를 넘지 않음을 확인합니다. 넷째는 무관한 런타임 예외를 영속성 실패로 오분류하지 않음을 확인합니다.
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);
}
}계약 테스트는 변환된 타입뿐 아니라 정확한 원인 연쇄도 검사합니다. 전달 계층은 이 내부 원인을 로그 진단에 사용할 수 있지만 응답 본문에는 공급자 메시지나 제약 이름을 복사하지 않습니다.
이 번역 테스트는 앞 장의 공통 영속성 계약 테스트를 대체하지 않습니다. 기존 suite가 생성 identity·version, 동일 회원·동일 요청 키의 두 번째 생성에서 좁은 예외와 원본 행이 유지되는지, 낡은 변경이 false와 비변경 결과로 끝나는지를 계속 고정합니다. 이 장의 테스트는 공급자 → Spring → 애플리케이션 원인 연쇄와 외부 비노출만 추가로 고정합니다.