로깅·재시도 AOP
로그는 한 번 남기고 대상은 제한된 횟수로 재시도하는 Spring 프록시 예제를 구성합니다. 예상 이벤트로 순서를 읽고 트랜잭션·기한·대기 정책의 별도 책임을 구분합니다.
보고서 조회가 일시적인 저장소 오류로 두 번 실패하고 세 번째에 성공한다고 가정하겠습니다.
요청의 시작과 최종 결과는 한 번 남기되, 보고서를 읽는 동작은 세 번 호출해야 합니다. 로그를 재시도 안에 두면 같은 요청의 시작과 끝이 시도마다 반복됩니다.
이번 절에서는 로그 애스펙트를 바깥에, 재시도 애스펙트를 안쪽에 두고 Spring이 돌려준 프록시에서 호출하는 경로를 연결합니다. Advice 종류와 순서의 @Order와 @Aspect 구성의 빈 등록이 필요한 이유도 같은 이벤트로 확인할 수 있습니다.
예제는 정해진 횟수만큼 테스트용 오류를 발생시키는 조회입니다. 실제 저장소 장애, 트랜잭션, 기한 차감과 재시도 대기를 구현한 코드는 아닙니다. 쓰기를 반복하려면 첫 시도가 이미 커밋됐을 가능성과 중복 효과부터 따로 판단해야 합니다.
로그는 한 번, 허용한 실패만 최대 세 번 시도한다
실패 두 번 뒤 성공하는 원 테스트에서 바깥 로그 한 번·target 세 번의 실제 이벤트 열이 있다.
SPRING PROXY · ONE LOG AND LIMITED RETRY
로그는 한 번, 대상은 최대 세 번 호출한다
RetryProxyTest는 작은 컨텍스트에서 받은 프록시를 호출합니다. LogAspect order 10이 LimitedRetryAspect order 20을 감싸며, 아래 이벤트는 소스가 요구하는 예상입니다.
프록시로 들어오는 한 요청
- 빈 등록 → 컨텍스트의 프록시
@EnableAspectJAutoProxy와 대상·두 애스펙트를 등록합니다. context.getBean으로 받은 ReportService에서 load()를 호출합니다.
- LogAspect 10 · 바깥에서 한 번
log:begin:post.weekly를 남기고 proceed()를 한 번 호출합니다.
- LimitedRetryAspect 20 · 첫 실행 포함 최대 3회
retry:1 → target:1 뒤 TransientStoreFailure라면 다음 시도로 진행합니다. retry:2와 retry:3도 같은 안쪽 대상 경로를 다시 호출합니다.
- 최종 결과 → 바깥 로그 종료
성공은 log:success, 최종 실패는 log:failure를 남깁니다. 둘 다 log:end로 끝나며 반환값·원본 예외를 전달합니다.
예상 개수와 구현 경계
- 정상 1회 · 두 실패 뒤 성공 3회
두 경우 모두 값 12를 반환하고 log:begin과 log:end는 각각 1회입니다. 이벤트 목록에서 정확한 순서를 비교합니다.
- 소진 3회 · 검증 실패 1회
소진하면 마지막 TransientStoreFailure 그대로, 검증 실패면 같은 IllegalArgumentException 그대로 전파합니다. 다른 예외·Error는 재시도하지 않습니다.
- 순수 모델과 프록시 예제
RetryExecutor는 콜백 횟수 모델입니다. RetryProxyTest는 Spring advice를 연결하며, 테스트용 실패 계획과 이벤트 목록은 실제 저장소·운영 로그가 아닙니다.
- TX·기한·대기 정책은 별도
트랜잭션·기한 차감·백오프·지터·취소 정책은 구현하지 않습니다. target 이벤트 수로 rollback이나 commit을 판단하지 않습니다.
Retry가 TX 바깥이어도 상위 REQUIRED에 참여하면 같은 물리 트랜잭션일 수 있습니다. 쓰기 재시도에는 멱등성과 커밋 응답 유실을 따로 판단하며, 기한·풀 예산을 횟수 제한으로 대신하지 않습니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- RetryProxyTest의 transientFailures=2인 한 계획의 실제 예상 이벤트 순서다. 전체 LogAspect는 한 번 들어와 최종 성공을 기록하고, 안쪽 retry와 target은 세 번 실행한다. 화살표는 이 계획에서 다음 이벤트로 이어지는 흐름이며 모든 재시도 분기를 담은 일반 알고리즘이 아니다. 이 테스트에는 실제 트랜잭션·대기·시계가 없다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | 로그는 한 번, 허용한 실패만 최대 세 번 시도한다 |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 로그와 첫 시도 전체 LogAspect가 한 번 들어오고 첫 target 호출이 일시 실패합니다. | target 호출: 1 · 일시 실패 계획: 2 · Log 진입: 1 | 첫 실패가 재시도 경로를 엽니다. |
| 두 번째 시도도 실패 같은 바깥 Log 안에서 retry가 두 번째 target을 호출합니다. | target 호출: 2 · 일시 실패: 두 번째 · Log 진입: 1 | target 호출 수만 늘고 전체 Log는 다시 진입하지 않습니다. |
| 세 번째 시도 성공 세 번째 target 호출이 성공해 retry 밖으로 결과를 반환합니다. | target 호출: 3 · 결과: 성공 · Log 진입: 1 | 실패 반복이 끝나고 최종 결과가 바깥으로 돌아갑니다. |
| 최종 성공 기록 바깥 LogAspect가 최종 성공을 한 번 기록합니다. | target 총 호출: 3 · 성공 로그: 1 · 실제 TX·시계: 테스트에 없음 | 전체 호출에 대한 성공 로그가 남습니다. |
로그는 한 번, 허용한 실패만 최대 세 번 시도한다
실패 두 번 뒤 성공하는 원 테스트에서 바깥 로그 한 번·target 세 번의 실제 이벤트 열이 있다.
SPRING PROXY · ONE LOG AND LIMITED RETRY
로그는 한 번, 대상은 최대 세 번 호출한다
RetryProxyTest는 작은 컨텍스트에서 받은 프록시를 호출합니다. LogAspect order 10이 LimitedRetryAspect order 20을 감싸며, 아래 이벤트는 소스가 요구하는 예상입니다.
프록시로 들어오는 한 요청
- 빈 등록 → 컨텍스트의 프록시
@EnableAspectJAutoProxy와 대상·두 애스펙트를 등록합니다. context.getBean으로 받은 ReportService에서 load()를 호출합니다.
- LogAspect 10 · 바깥에서 한 번
log:begin:post.weekly를 남기고 proceed()를 한 번 호출합니다.
- LimitedRetryAspect 20 · 첫 실행 포함 최대 3회
retry:1 → target:1 뒤 TransientStoreFailure라면 다음 시도로 진행합니다. retry:2와 retry:3도 같은 안쪽 대상 경로를 다시 호출합니다.
- 최종 결과 → 바깥 로그 종료
성공은 log:success, 최종 실패는 log:failure를 남깁니다. 둘 다 log:end로 끝나며 반환값·원본 예외를 전달합니다.
예상 개수와 구현 경계
- 정상 1회 · 두 실패 뒤 성공 3회
두 경우 모두 값 12를 반환하고 log:begin과 log:end는 각각 1회입니다. 이벤트 목록에서 정확한 순서를 비교합니다.
- 소진 3회 · 검증 실패 1회
소진하면 마지막 TransientStoreFailure 그대로, 검증 실패면 같은 IllegalArgumentException 그대로 전파합니다. 다른 예외·Error는 재시도하지 않습니다.
- 순수 모델과 프록시 예제
RetryExecutor는 콜백 횟수 모델입니다. RetryProxyTest는 Spring advice를 연결하며, 테스트용 실패 계획과 이벤트 목록은 실제 저장소·운영 로그가 아닙니다.
- TX·기한·대기 정책은 별도
트랜잭션·기한 차감·백오프·지터·취소 정책은 구현하지 않습니다. target 이벤트 수로 rollback이나 commit을 판단하지 않습니다.
Retry가 TX 바깥이어도 상위 REQUIRED에 참여하면 같은 물리 트랜잭션일 수 있습니다. 쓰기 재시도에는 멱등성과 커밋 응답 유실을 따로 판단하며, 기한·풀 예산을 횟수 제한으로 대신하지 않습니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- RetryProxyTest의 transientFailures=2인 한 계획의 실제 예상 이벤트 순서다. 전체 LogAspect는 한 번 들어와 최종 성공을 기록하고, 안쪽 retry와 target은 세 번 실행한다. 화살표는 이 계획에서 다음 이벤트로 이어지는 흐름이며 모든 재시도 분기를 담은 일반 알고리즘이 아니다. 이 테스트에는 실제 트랜잭션·대기·시계가 없다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 1 | 로그는 한 번, 허용한 실패만 최대 세 번 시도한다 |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 로그와 첫 시도 전체 LogAspect가 한 번 들어오고 첫 target 호출이 일시 실패합니다. | target 호출: 1 · 일시 실패 계획: 2 · Log 진입: 1 | 첫 실패가 재시도 경로를 엽니다. |
| 두 번째 시도도 실패 같은 바깥 Log 안에서 retry가 두 번째 target을 호출합니다. | target 호출: 2 · 일시 실패: 두 번째 · Log 진입: 1 | target 호출 수만 늘고 전체 Log는 다시 진입하지 않습니다. |
| 세 번째 시도 성공 세 번째 target 호출이 성공해 retry 밖으로 결과를 반환합니다. | target 호출: 3 · 결과: 성공 · Log 진입: 1 | 실패 반복이 끝나고 최종 결과가 바깥으로 돌아갑니다. |
| 최종 성공 기록 바깥 LogAspect가 최종 성공을 한 번 기록합니다. | target 총 호출: 3 · 성공 로그: 1 · 실제 TX·시계: 테스트에 없음 | 전체 호출에 대한 성공 로그가 남습니다. |
구조화 로그 필드
런타임 클래스 이름과 인자 전체를 메시지 문자열로 붙이면 검색과 마스킹이 어렵습니다.
애노테이션 또는 애플리케이션 포트에서 post.create 같은 제한된 작업 코드를 얻고 결과, 경과, 트레이스 ID를 구조화 필드로 기록합니다.
package board.resilience;
public record OperationLog(
String operation,
String outcome,
long elapsedNanos,
String traceId
) {
public OperationLog {
if (operation.isBlank() || outcome.isBlank() || elapsedNanos < 0) {
throw new IllegalArgumentException("invalid operation log");
}
}
public static OperationLog success(
String operation,
long started,
String traceId
) {
return new OperationLog(
operation, "success",
System.nanoTime() - started, traceId);
}
}트레이스 ID는 로그 상관관계 분석에 쓰되 메트릭 태그로 사용하지 않습니다.
구성원 ID와 게시글 제목·본문, 예외 메시지는 기본 로그에서 제외하고 필요한 진단 이벤트에 마스킹을 적용합니다.
이 레코드는 기록할 필드의 형태를 정의합니다. 뒤의 프록시 예제는 순서를 읽기 위해 고정 이벤트 코드만 목록에 남깁니다. 실제 로그 저장, 트레이스 연결과 경과 시간 수집을 붙일 때는 이 필드 규칙을 함께 적용합니다.
재시도 애노테이션
최대 횟수, 백오프, 재시도할 예외를 메타데이터로 표현할 수 있습니다.
하지만 애노테이션만 보고 쓰기가 멱등인지 알 수 없습니다.
명령에 요청 키가 있고 데이터베이스 고유 제약 조건이 같은 결과를 보장하는지 계약 테스트가 필요합니다.
타임아웃 예외도 서버가 작업을 완료했는지 모를 수 있습니다.
읽기는 비교적 안전하지만 삽입은 결과 확인 엔드포인트 또는 멱등성 키 없이 재시도하지 않습니다.
낙관적 충돌은 최신 값을 읽어 사용자 의도를 다시 계산해야 하므로 단순 재시도 대상이 아닐 수 있습니다.
Error, 검증, 인증, 프로그래밍 결함은 재시도하지 않습니다.
일시적 인프라 계층을 좁게 정의하고 원인 체인을 분류합니다.
먼저 반복 규칙을 분리하기
다음 RetryExecutor는 콜백을 직접 반복하는 순수 Java 모델입니다. 첫 실행까지 포함해 최대 세 번 호출하고, 허용한 TransientStoreFailure만 다시 시도합니다. Spring 빈이나 advice가 연결됐다는 뜻은 아닙니다.
remainingBudget은 양수인지 검사할 뿐 경과 시간을 차감하지 않으며 트랜잭션·백오프·인터럽트 처리는 구현하지 않습니다.
package board.resilience;
import java.time.Duration;
import java.util.Objects;
public final class RetryExecutor {
public <T> T execute(
int maximumAttempts,
Duration remainingBudget,
Attempt<T> target
) {
Objects.requireNonNull(target);
if (maximumAttempts < 1 || remainingBudget.isNegative()
|| remainingBudget.isZero()) {
throw new IllegalArgumentException("invalid retry budget");
}
TransientStoreFailure last = null;
for (int attempt = 1; attempt <= maximumAttempts; attempt++) {
try {
return target.call(attempt);
} catch (TransientStoreFailure failure) {
last = failure;
}
}
throw last;
}
@FunctionalInterface
public interface Attempt<T> {
T call(int attempt);
}
public static final class TransientStoreFailure extends RuntimeException {
public TransientStoreFailure(String message) { super(message); }
}
public static void main(String[] args) {
var retry = new RetryExecutor();
long id = retry.execute(3, Duration.ofSeconds(1), attempt -> {
System.out.println("attempt=" + attempt);
if (attempt < 3) throw new TransientStoreFailure("temporary");
return 41L;
});
System.out.println("id=" + id);
}
}attempt=1
attempt=2
attempt=3
id=41이제 콜백 대신 ProceedingJoinPoint.proceed()를 호출하도록 연결하겠습니다. proceed()는 대상 메서드와 남은 안쪽 advice로 진행하므로 재시도 advice가 호출할 때마다 그 안쪽 경로가 다시 실행됩니다.
작은 컨텍스트에 로그·재시도 연결하기
Java 25와 Spring Boot에서 만든 누적 프로젝트를 기준으로 합니다. 기존 Boot 4.1.1 BOM과 테스트 의존성은 유지하고 build.gradle의 dependencies 블록에 다음 의존성을 추가합니다. 개별 Spring 버전을 따로 지정해 BOM과 섞지 않습니다.
implementation 'org.springframework:spring-context'
implementation 'org.springframework:spring-aop'
implementation 'org.aspectj:aspectjweaver'aspectjweaver는 여기서 AspectJ 애노테이션과 포인트컷을 처리하는 데 필요합니다. 이 의존성을 추가한다고 자기 호출까지 가로채는 위빙이 활성화되지는 않습니다.
앞에서 작성한 RetryExecutor.java를 같은 프로젝트에 두고 다음 테스트 파일을 추가합니다. 설정 클래스가 필요한 빈만 등록하므로 @SpringBootTest, 웹 서버, 데이터베이스 없이 작은 Spring 컨텍스트를 구성합니다.
Observed는 로그를 남길 작업, RetryTransient는 재시도를 허용한 메서드에 붙입니다. 두 advice는 공개 메서드의 패키지 범위와 애노테이션을 함께 요구합니다. 애노테이션의 존재가 멱등성을 보장하지는 않습니다.
package board.resilience;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.ArrayList;
import java.util.List;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.junit.jupiter.api.Test;
import org.springframework.aop.support.AopUtils;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.EnableAspectJAutoProxy;
import org.springframework.core.annotation.Order;
import board.resilience.RetryExecutor.TransientStoreFailure;
public final class RetryProxyTest {
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Observed { String value(); }
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RetryTransient { int maximumAttempts(); }
public static final class Events {
private final List<String> values = new ArrayList<>();
void add(String value) { values.add(value); }
List<String> snapshot() { return List.copyOf(values); }
}
@Aspect
@Order(10)
public static final class LogAspect {
private final Events events;
public LogAspect(Events events) { this.events = events; }
@Around(value = "execution(public * board.resilience..*(..)) && "
+ "@annotation(operation)", argNames = "operation")
public Object observe(ProceedingJoinPoint point, Observed operation)
throws Throwable {
events.add("log:begin:" + operation.value());
try {
Object result = point.proceed();
events.add("log:success");
return result;
} catch (Throwable failure) {
events.add("log:failure");
throw failure;
} finally {
events.add("log:end");
}
}
}
@Aspect
@Order(20)
public static final class LimitedRetryAspect {
private final Events events;
public LimitedRetryAspect(Events events) { this.events = events; }
@Around(value = "execution(public * board.resilience..*(..)) && "
+ "@annotation(retry)", argNames = "retry")
public Object retry(ProceedingJoinPoint point, RetryTransient retry)
throws Throwable {
int maximum = retry.maximumAttempts();
if (maximum < 1) {
throw new IllegalArgumentException("maximumAttempts must be positive");
}
for (int attempt = 1; attempt <= maximum; attempt++) {
events.add("retry:" + attempt);
try {
return point.proceed();
} catch (TransientStoreFailure failure) {
if (attempt == maximum) throw failure;
}
}
throw new IllegalStateException("unreachable retry state");
}
}
public static class ReportService {
private final Events events;
private int calls;
private int transientFailures;
private boolean invalid;
private TransientStoreFailure lastFailure;
private final IllegalArgumentException validationFailure =
new IllegalArgumentException("invalid report range");
public ReportService(Events events) { this.events = events; }
public void plan(int transientFailures, boolean invalid) {
this.transientFailures = transientFailures;
this.invalid = invalid;
}
public int calls() { return calls; }
public TransientStoreFailure lastFailure() { return lastFailure; }
public IllegalArgumentException validationFailure() { return validationFailure; }
@Observed("post.weekly")
@RetryTransient(maximumAttempts = 3)
public int load() {
events.add("target:" + ++calls);
if (invalid) throw validationFailure;
if (calls <= transientFailures) {
lastFailure = new TransientStoreFailure("temporary store failure");
throw lastFailure;
}
return 12;
}
}
@Configuration(proxyBeanMethods = false)
@EnableAspectJAutoProxy(proxyTargetClass = true)
public static class Config {
@Bean Events events() { return new Events(); }
@Bean LogAspect logAspect(Events events) { return new LogAspect(events); }
@Bean LimitedRetryAspect retryAspect(Events events) {
return new LimitedRetryAspect(events);
}
@Bean ReportService reportService(Events events) {
return new ReportService(events);
}
}
@Test
void logWrapsLimitedRetry() {
verifyCase(0, false, 1, List.of(
"log:begin:post.weekly", "retry:1", "target:1",
"log:success", "log:end"));
verifyCase(2, false, 3, List.of(
"log:begin:post.weekly", "retry:1", "target:1",
"retry:2", "target:2", "retry:3", "target:3",
"log:success", "log:end"));
verifyCase(3, false, 3, List.of(
"log:begin:post.weekly", "retry:1", "target:1",
"retry:2", "target:2", "retry:3", "target:3",
"log:failure", "log:end"));
verifyCase(0, true, 1, List.of(
"log:begin:post.weekly", "retry:1", "target:1",
"log:failure", "log:end"));
}
private static void verifyCase(int failures, boolean invalid, int calls,
List<String> expected) {
try (var context = new AnnotationConfigApplicationContext(Config.class)) {
ReportService report = context.getBean(ReportService.class);
assertTrue(AopUtils.isAopProxy(report));
report.plan(failures, invalid);
if (invalid) {
var failure = assertThrows(IllegalArgumentException.class, report::load);
assertSame(report.validationFailure(), failure);
} else if (failures >= 3) {
var failure = assertThrows(TransientStoreFailure.class, report::load);
assertSame(report.lastFailure(), failure);
} else {
assertEquals(12, report.load());
}
assertEquals(calls, report.calls());
assertEquals(expected, context.getBean(Events.class).snapshot());
}
}
}ReportService와 load()는 클래스 프록시가 재정의할 수 있도록 final이 아닙니다. 바깥 테스트 클래스와 애스펙트의 final은 프록시 대상의 제약과 별개입니다. 예제의 이벤트 목록과 실패 계획은 각 컨텍스트에서 한 스레드로 호출하는 설명 도구이며, 운영 로깅이나 동시 요청용 저장소가 아닙니다.
로그 advice는 proceed()를 한 번 호출합니다. 안쪽 재시도 advice는 첫 실행을 포함해 최대 세 번 호출하고 TransientStoreFailure만 잡습니다. 검증 실패를 비롯한 다른 예외와 Error는 그 자리에서 밖으로 전파되며, 소진한 일시 실패도 마지막 객체 그대로 전달됩니다. Throwable을 받는 로그 advice는 이를 기록하고 다시 던질 뿐 재시도하지 않습니다.
./gradlew test --tests board.resilience.RetryProxyTestWindows에서는 같은 위치에서 gradlew.bat test --tests board.resilience.RetryProxyTest를 사용합니다. 다음은 코드가 요구하는 예상 이벤트이며 실행 로그를 옮긴 것이 아닙니다. 이벤트 목록을 직접 비교하므로 Java의 기본 비활성 assert 설정에 의존하지 않습니다.
| 입력 계획 | 대상 호출 | 최종 결과 | 로그 시작·종료 |
|---|---|---|---|
| 일시 실패 0회 | 1회 | 12 반환 | 각각 1회 |
| 일시 실패 2회 | 3회 | 12 반환 | 각각 1회 |
| 일시 실패 3회 | 3회 | 마지막 TransientStoreFailure 전파 | 각각 1회 |
| 검증 실패 | 1회 | 같은 IllegalArgumentException 전파 | 각각 1회 |
log:begin:post.weekly
retry:1
target:1
retry:2
target:2
retry:3
target:3
log:success
log:end프록시 확인부터 실패한다면 Config의 빈 등록과 @EnableAspectJAutoProxy를 먼저 봅니다. 대상은 실행되는데 advice 이벤트가 없다면 new ReportService(...)나 자기 호출로 우회했는지, 패키지와 애노테이션 조건이 모두 맞는지 확인합니다. 로그가 시도마다 반복된다면 @Order(10)과 @Order(20)이 뒤바뀌지 않았는지 봅니다. argNames는 애노테이션 바인딩 이름을 명시하므로 포인트컷의 변수 이름과 advice 파라미터 이름을 함께 바꿔야 합니다.
@Aspect 구성의 예제는 board.aspect 대상이 board.application 조건 밖에 있어서 프록시가 만들어지지 않는 부정 사례였습니다. 여기서는 범위와 애노테이션을 모두 맞춘 대상을 등록한다는 차이가 있습니다.
트랜잭션은 별도 경계입니다
위 테스트에는 트랜잭션 매니저나 트랜잭션 advice가 없습니다. target:1, target:2, target:3은 대상 호출 횟수이지 rollback 세 번이나 물리 트랜잭션 세 개의 증거가 아닙니다.
재시도를 트랜잭션 advice 바깥에 두면 시도마다 안쪽 트랜잭션 경계를 다시 통과할 수 있습니다. 상위 트랜잭션이 없고 안쪽 REQUIRED가 매번 새 트랜잭션을 시작·종료하며 해당 실패가 rollback 조건에 맞아야 실패한 시도가 정리된 뒤 새 시도가 시작됩니다.
이미 상위 트랜잭션이 있다면 안쪽 REQUIRED는 같은 물리 트랜잭션에 참여할 수 있습니다. 이때 retry의 순서만 바꿔도 새 트랜잭션이 생기지는 않으며, 실패로 rollback-only가 된 상태는 다음 성공으로 되돌릴 수 없습니다. REQUIRES_NEW는 독립 경계를 만들지만 상위 연결을 보유한 채 새 연결이 필요하므로 풀 용량과 업무 원자성을 함께 판단해야 합니다.
기한·대기 정책도 이번 advice에 들어 있지 않습니다. 운영 코드로 확장할 때는 대상 수행 시간과 대기 시간을 남은 기한에서 빼고 취소·인터럽트를 보존해야 합니다. Thread.sleep을 고정하기보다 대기 전략과 지터 소스를 분리하면 시간을 기다리지 않고 정책을 시험할 수 있습니다.
백오프·지터
즉시 반복은 장애 난 DB에 더 많은 부하를 보냅니다.
지수 백오프에 임의 지터를 더하고 서버의 재시도 이후 신호가 있으면 상한 안에서 존중합니다.
요청 기한을 넘길 다음 시도는 시작하지 않습니다.
동시 요청별 최대 횟수만 제한하면 전체 재시도 폭증을 막지 못합니다.
재시도 예산, 회로 차단기, 격벽과 결합하되 중첩 라이브러리가 각각 재시도해 횟수가 곱해지지 않게 한 계층이 소유합니다.
메트릭은 시도 횟수, 소진, 복구, 비멱등 건너뜀, 예산 만료를 구분합니다.
예외 클래스를 무제한 태그로 쓰지 않고 일시적 오류 범주를 제한합니다.
시도·전체 작업 로그
전체 로그가 재시도 바깥에 있으면 사용자 체감 지연 시간과 최종 결과를 기록합니다.
시도 로그를 각 시도의 안쪽에 두면 시도별 실패와 지속 시간을 봅니다. 트랜잭션과의 안팎 위치는 커밋 시간까지 측정할지에 따라 따로 정합니다.
같은 작업 이름을 쓰더라도 scope=total|attempt처럼 낮은 카디널리티 차원을 둡니다.
스택 트레이스는 마지막 실패 또는 표본 추출된 시도에만 남겨 잡음을 줄입니다.
첫 두 일시적 실패도 개수와 트레이스 이벤트로는 연결하되 같은 예외를 여러 계층에서 오류 로그하지 않습니다.
로깅 수집 대상이 느릴 때 재시도 스레드를 막으면 기한이 줄어듭니다.
용량이 제한된 비동기 어펜더의 폐기·대기 정책을 운영 요구에 맞게 선택합니다.
실제 저장소로 확장할 때 확인할 것
다음 항목은 위 프록시 예제가 제공하지 않는 운영 확장입니다. 대상 호출 횟수만으로 저장과 커밋의 성공을 대신 판단하지 않습니다.
시도마다 트랜잭션 ID와 연결 동일성을 기록하고 실패 행이 롤백됐는지 확인합니다.
세 번째 성공에서 행이 하나만 있어야 하며 요청 키 고유 제약 조건으로 중복을 방지합니다.
재시도 애스펙트가 트랜잭션 안쪽이면 테스트가 롤백 전용 상태 또는 같은 연결 반복을 드러내야 합니다.
네트워크 장애 주입은 SQL 실행 전 실패와 커밋 응답 유실을 나눕니다.
후자는 서버 커밋 가능성이 있어 같은 멱등성 키로 결과를 조회해야 합니다.
연습 문제
주간 보고서 조회에 최대 3회 재시도를 적용하세요.
전체 기한 400 ms, 백오프 40 ms와 지터, 일시적 읽기 타임아웃만 허용하고 전체·시도 메트릭 횟수를 검증하는 가짜 시계 테스트를 작성하세요.
해설 보기
다음 지연 시간이 남은 예산보다 크면 횟수 소진이 아니라 예산 만료로 중단합니다.
인터럽트 상태는 다시 설정하고 즉시 호출자에게 전달합니다.
package board.resilience;
public final class RetryBudget {
private long remainingMillis;
public RetryBudget(long remainingMillis) {
if (remainingMillis < 1) throw new IllegalArgumentException("empty budget");
this.remainingMillis = remainingMillis;
}
public boolean reserve(long delayMillis) {
if (delayMillis < 0 || delayMillis >= remainingMillis) return false;
remainingMillis -= delayMillis;
return true;
}
public long remainingMillis() {
return remainingMillis;
}
}실제 대상 지속 시간도 시계에서 차감해야 하며 재시도 정책이 HTTP 타임아웃보다 짧게 끝나야 합니다.
예시 원 RetryBudget(100)에서 reserve(40)은 참이고 남은 값은 60입니다. 이어 reserve(60)은 남은 값과 같아 거짓이며 값은 여전히 60입니다. reserve(0)은 참이지만 값은 줄지 않습니다. 음수·같은 값·0의 경계를 함께 읽어야 이 보조 클래스가 무엇을 예약하는지 보입니다. 이 클래스에는 시계 읽기나 대상 실행이 없으므로 reserve만 호출해서 대상 수행 시간까지 차감했다고 할 수 없습니다.
독립 확인: 가짜 시계에서 전체 400 ms를 시작합니다. 첫 대상 호출은 일시적 읽기 타임아웃으로 150 ms 후 실패합니다. 다음 대기는 백오프 40 ms+고정 지터 10 ms이고, 두 번째 대상은 180 ms 후 같은 실패를 냅니다. 다음 대기도 50 ms입니다. 최대 총 시도는 3이며 다른 시간 비용은 0이라고 가정합니다. 시간과 대기를 모두 차감하면 세 번째 호출을 시작할 수 있나요? 전체 관측·시도 관측 수와 중단 이유를 구하세요.
풀이 확인
첫 실패 뒤 400−150=250 ms, 대기 뒤 250−50=200 ms, 두 번째 실패 뒤 200−180=20 ms입니다. 다음 50 ms 대기를 예약할 수 없어 세 번째 대상은 시작하지 않습니다. 전체 관측 1·시도 관측 2이고 중단 이유는 총 횟수 3회 소진이 아니라 예산 만료입니다. 원 RetryBudget에는 대상 150/180 ms를 차감하는 메서드가 없으므로 이 정책을 구현하려면 호출자가 가짜 시계의 경과 시간도 반영해야 합니다. 이 유한 계산은 실제 타임아웃 실행이나 저장소 복구 결과가 아닙니다.