본문으로 건너뛰기

안동민 개발노트

본문 시작

Repository 예외 변환

@Repository와 영속성 예외 변환 후처리기가 공급자 실패를 Spring 분류로 바꾸고 어댑터가 B91 애플리케이션 경계로 좁히는 조건을 검증합니다.

@Repository는 그 자체로 공급자 예외를 바꾸는 마법이 아닙니다. 대상 빈, PersistenceExceptionTranslationPostProcessor, 해당 예외를 이해하는 PersistenceExceptionTranslator가 함께 있을 때 공개 리포지토리 호출의 번역 경계가 만들어집니다.

그 경계가 만든 DataAccessException은 중간 언어입니다. 어댑터는 한 번 더 좁혀 B91의 PostPersistenceException 또는 DuplicatePostRequestException만 애플리케이션 쪽으로 내보냅니다.

@Repository 번역 경계는 표식·후처리기·변환기가 함께 만든다

SPRING TRANSLATION · TWO-STAGE BOUNDARY

@Repository 번역 경계는 표식·후처리기·변환기가 함께 만든다

빈 생성 때 후처리기가 프록시에 변환 advisor를 설치한다. 호출 때 advisor는 대상 실패를 translator에 맡기고, 어댑터 경계는 Spring 범주와 미분류 공급자 실패를 원인 보존 B91 예외로 닫는다.

설정 시점의 advisor 설치와 호출 시점의 예외 변환 경계 빈 생성 시 @Repository 표식과 후처리기가 노출 프록시에 변환 advisor를 설치한다. 공개 호출에서 대상이 실패하면 advisor가 translator를 조회하고, 어댑터 경계가 번역된 Spring 예외와 미분류 공급자 예외를 중립적인 B91 영속성 예외로 닫는다. RUNTIME · PUBLIC PROXY CALL RETURNS A FAILURE TARGET Repository throws provider RuntimeException INTERCEPTOR Advisor catches 대상 호출 뒤 실패 관찰 TRANSLATOR CHAIN Known code or null 아는 실패만 Spring 범주로 TECHNICAL RESULT Spring or raw provider 아직 adapter 내부 타입 ADAPTER BOUNDARY Owned failures only 중립 메시지 · cause 보존 B91 APPLICATION CONTRACT PostPersistenceException cause 보존 · provider type 직접 비노출 SETUP TIME · BEFORE THE BEAN IS EXPOSED @Repository metadata 번역 대상 bean 식별 BeanPostProcessor translator 탐색 · advisor 구성 Exposed proxy + advisor 호출 전에 설치 완료
  1. 설정 시점

    PersistenceExceptionTranslationPostProcessor@Repository 대상을 찾아 노출 프록시에 변환 advisor를 설치합니다.

  2. 호출 시점

    공개 호출이 프록시를 통과하고 대상이 실패하면 advisor가 translator 체인에 번역을 요청합니다.

  3. 두 기술 결과

    알려진 공급자 코드는 DataAccessException이 되고, 모르는 공급자 코드는 null 뒤 원래 예외로 돌아옵니다.

  4. B91 애플리케이션 경계

    어댑터가 Spring 예외와 자신이 소유한 공급자 예외만 중립적으로 감싸고 원인을 보존합니다.

후처리기는 프록시를 준비하고 advisor가 호출 실패를 번역한다. 모르는 공급자 코드는 추측하지 않되 raw 타입으로 공개 경계를 넘기지 않는다.


세 조건이 모두 있어야 번역된다

구성 요소소유한 책임소유하지 않는 책임
@Repository 대상영속성 경계 표시업무 예외 자동 생성
번역 후처리기적격 호출에 번역 인터셉터 적용알 수 없는 예외 추측
PersistenceExceptionTranslator구조화된 공급자 실패를 Spring 범주로 변환메시지 문자열 분류
어댑터 경계Spring 범주를 B91 계약으로 좁힘HTTP 상태 결정

JdbcTemplate은 자체적으로 SQLException을 번역합니다. 반면 직접 공급자 API를 호출하는 리포지토리에는 이 후처리기 조합이 유용합니다. 어느 경우에도 애노테이션만 붙였다는 이유로 업무 중복이 판정되지는 않습니다.


공급자 실패를 구조화해서 번역한다

예제 공급자 예외는 메시지가 아니라 닫힌 Code를 제공합니다. 원인은 보존되며, 새 타입은 전담 board.tx.failure 네임스페이스 밖으로 확산되지 않습니다.

src/main/java/board/tx/failure/translation/ProviderFailureException.java
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;
    }
}

후처리기가 감싸는 대상 인터페이스는 기술 예외를 선언하지 않습니다. 이것은 교육용 저장소 프로브이며 애플리케이션 포트를 대체하지 않습니다.

src/main/java/board/tx/failure/translation/TranslationProbeRepository.java
package board.tx.failure.translation;

public interface TranslationProbeRepository {
    void write();

    void writeUnknown();
}

구현 클래스에 @Repository를 둡니다. 공급자 예외는 이 클래스 안에서만 발생합니다.

src/main/java/board/tx/failure/translation/AnnotatedTranslationProbeRepository.java
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 영속성 예외로 감싸고, 무관한 프로그래밍 결함은 저장소 장애로 꾸미지 않습니다.

src/main/java/board/tx/failure/translation/ProviderFailureTranslator.java
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 팩터리로 일찍 등록합니다. 대상 빈은 인터페이스로 노출되고, 변환기는 별도 빈으로 탐색됩니다. 이 장은 트랜잭션 프록시나 롤백 규칙을 구성하지 않습니다.

src/main/java/board/tx/failure/translation/FailureTranslationConfiguration.java
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을 만능으로 잡지 않습니다.

src/main/java/board/tx/failure/translation/ApplicationFailureBridge.java
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 타입으로 경계를 넘지 않음을 확인합니다. 넷째는 무관한 런타임을 영속성 실패로 오분류하지 않음을 닫습니다.

src/test/java/board/tx/failure/translation/RepositoryTranslationTest.java
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 → 애플리케이션 원인 연쇄와 외부 비노출만 추가로 고정합니다.