Repository 예외 변환
@Repository와 영속성 예외 변환 후처리기가 공급자 실패를 Spring 분류로 바꾸고 어댑터가 B91 애플리케이션 경계로 좁히는 조건을 검증합니다.
@Repository는 그 자체로 공급자 예외를 바꾸는 마법이 아닙니다. 대상 빈, PersistenceExceptionTranslationPostProcessor, 해당 예외를 이해하는 PersistenceExceptionTranslator가 함께 있을 때 공개 리포지토리 호출의 번역 경계가 만들어집니다.
그 경계가 만든 DataAccessException은 중간 언어입니다. 어댑터는 한 번 더 좁혀 B91의 PostPersistenceException 또는 DuplicatePostRequestException만 애플리케이션 쪽으로 내보냅니다.
SPRING TRANSLATION · TWO-STAGE BOUNDARY
@Repository 번역 경계는 표식·후처리기·변환기가 함께 만든다
빈 생성 때 후처리기가 프록시에 변환 advisor를 설치한다. 호출 때 advisor는 대상 실패를 translator에 맡기고, 어댑터 경계는 Spring 범주와 미분류 공급자 실패를 원인 보존 B91 예외로 닫는다.
-
설정 시점
PersistenceExceptionTranslationPostProcessor가@Repository대상을 찾아 노출 프록시에 변환 advisor를 설치합니다. -
호출 시점
공개 호출이 프록시를 통과하고 대상이 실패하면 advisor가 translator 체인에 번역을 요청합니다.
-
두 기술 결과
알려진 공급자 코드는
DataAccessException이 되고, 모르는 공급자 코드는null뒤 원래 예외로 돌아옵니다. -
B91 애플리케이션 경계
어댑터가 Spring 예외와 자신이 소유한 공급자 예외만 중립적으로 감싸고 원인을 보존합니다.
후처리기는 프록시를 준비하고 advisor가 호출 실패를 번역한다. 모르는 공급자 코드는 추측하지 않되 raw 타입으로 공개 경계를 넘기지 않는다.
세 조건이 모두 있어야 번역된다
| 구성 요소 | 소유한 책임 | 소유하지 않는 책임 |
|---|---|---|
@Repository 대상 | 영속성 경계 표시 | 업무 예외 자동 생성 |
| 번역 후처리기 | 적격 호출에 번역 인터셉터 적용 | 알 수 없는 예외 추측 |
PersistenceExceptionTranslator | 구조화된 공급자 실패를 Spring 범주로 변환 | 메시지 문자열 분류 |
| 어댑터 경계 | Spring 범주를 B91 계약으로 좁힘 | 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;
}
}후처리기가 감싸는 대상 인터페이스는 기술 예외를 선언하지 않습니다. 이것은 교육용 저장소 프로브이며 애플리케이션 포트를 대체하지 않습니다.
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가 원래 실패를 다시 던집니다. 이때 어댑터의 공개 경계는 자신이 소유한 공급자 타입만 중립적인 B91 영속성 예외로 감싸고, 무관한 프로그래밍 결함은 저장소 장애로 꾸미지 않습니다.
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만 잡아 원인을 보존한 B91 예외를 만듭니다. 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로 오분류됩니다.
B91 포트의 기존 결과 의미도 그대로 유지합니다. 조회 없음은 Optional.empty()인 정상 카디널리티이고, 수정·삭제의 false는 조건에 맞는 행이 없다는 뜻입니다. 사용 사례가 필요하면 최신 값을 다시 읽어 충돌 표현을 결정합니다. 같은 회원·같은 요청 키만 좁은 중복 예외가 되며, 가용성 실패는 원인을 가진 일반 영속성 예외로 남습니다. Spring 타입이나 ConcurrentChange 같은 별도 호환 계층을 포트에 다시 만들지 않습니다.
설정 시점과 호출 시점을 분리한다
PersistenceExceptionTranslationPostProcessor는 매 호출마다 지나가는 런타임 단계가 아닙니다. 빈 생성 시점에 @Repository 대상임을 확인하고, 컨테이너가 노출할 프록시에 예외 변환 advisor를 설치합니다.
호출 시점에는 공개 호출이 그 프록시로 들어갑니다. advisor가 대상 메서드를 호출하고, 대상에서 런타임 예외가 빠져나올 때 translator 체인에 번역을 요청합니다. 알려진 공급자 코드는 DataAccessException으로 바뀌고, null이면 원래 예외가 advisor 밖으로 다시 나옵니다. 두 경우 모두 어댑터의 애플리케이션 경계가 자신이 소유한 실패만 B91 계약으로 닫습니다.
두 단계 원인 연쇄를 계약 테스트로 고정한다
첫 테스트는 @Repository 대상 호출이 Spring 범주로 바뀌는지 확인합니다. 둘째는 그 범주가 B91 공개 경계를 넘을 때 공급자 타입을 직접 노출하지 않는지 확인합니다. 셋째는 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);
}
}계약 테스트는 변환된 타입뿐 아니라 정확한 원인 연쇄도 검사합니다. 전달 계층은 이 내부 원인을 로그 진단에 사용할 수 있지만 응답 본문에는 공급자 메시지나 제약 이름을 복사하지 않습니다.
이 번역 테스트는 B91 공통 영속성 계약 테스트를 대체하지 않습니다. 기존 suite가 생성 identity·version, 동일 회원·동일 요청 키의 두 번째 생성에서 좁은 예외와 원본 행이 유지되는지, 낡은 변경이 false와 비변경 결과로 끝나는지를 계속 고정합니다. 이 장의 테스트는 공급자 → Spring → 애플리케이션 원인 연쇄와 외부 비노출만 추가로 고정합니다.