로깅·재시도 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·시계: 테스트에 없음 | 전체 호출에 대한 성공 로그가 남습니다. |
그림은 아래 RetryProxyTest에서 일시적 실패를 두 번으로 계획한 한 실행의 예상 이벤트입니다. 바깥의 LogAspect는 한 번 들어와 시작을 기록하고, 안쪽의 재시도와 대상은 첫 시도와 둘째 시도가 일시적으로 실패한 뒤 셋째 시도에서 12를 반환합니다. 그 결과를 받은 LogAspect가 최종 성공을 한 번 기록하고 끝납니다. 화살표는 이 계획에서 다음 이벤트로 이어지는 흐름일 뿐 모든 재시도 분기를 담은 일반 알고리즘은 아니며, 이 테스트에는 실제 트랜잭션·대기·시계가 없습니다.
구조화 로그 필드
런타임 클래스 이름과 인자 전체를 메시지 문자열에 붙이면 검색과 마스킹이 어렵습니다. 애노테이션이나 애플리케이션 포트에서 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, 게시글 제목과 본문, 예외 메시지는 기본 로그에서 빼고, 꼭 필요한 진단 이벤트에서만 마스킹을 적용해 남깁니다.
이 레코드는 기록할 필드의 모양을 정의합니다. 뒤의 프록시 예제는 순서를 읽기 위해 고정된 이벤트 코드만 목록에 남기며, 실제 로그 저장과 트레이스 연결, 경과 시간 수집을 붙일 때 이 필드 규칙을 함께 적용합니다.
재시도 애노테이션
최대 횟수, 백오프(backoff), 재시도할 예외를 메타데이터로 표현할 수 있습니다. 하지만 애노테이션만 보고는 쓰기가 멱등인지 알 수 없습니다. 명령에 요청 키가 있고 데이터베이스의 고유 제약 조건이 같은 결과를 보장하는지는 따로 테스트로 확인해야 합니다.
타임아웃 예외가 났다고 해서 서버가 작업을 끝내지 않았다고 단정할 수도 없습니다. 읽기는 비교적 안전하지만, 삽입은 결과 확인 엔드포인트나 멱등성 키 없이 재시도하지 않습니다. 낙관적 잠금 충돌은 최신 값을 다시 읽어 사용자 의도를 다시 계산해야 하므로, 단순한 재시도 대상이 아닐 수 있습니다.
Error, 검증 실패, 인증 실패, 프로그래밍 결함은 재시도하지 않습니다. 일시적인 인프라 실패의 범위를 좁게 정의하고, 원인 체인을 따라 분류합니다.
먼저 반복 규칙을 분리하기
다음 RetryExecutor는 콜백을 직접 반복하는 순수 자바 모델입니다. 첫 실행을 포함해 최대 세 번 호출하고, 허용한 TransientStoreFailure만 다시 시도합니다. Spring 빈이나 어드바이스와는 아직 연결되지 않았습니다. 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);
}
}catch가 실패를 last에 담기만 하고 출력하지 않으므로, 실패한 시도는 람다가 찍은 attempt= 줄로만 드러납니다. 셋째 시도의 return이 반복을 끝내므로 throw last에는 닿지 않습니다. 첫 두 시도는 TransientStoreFailure를 던지고 셋째 시도가 41을 반환하므로, 소스를 기준으로 예상한 출력은 다음과 같습니다.
attempt=1
attempt=2
attempt=3
id=41이제 콜백 대신 ProceedingJoinPoint.proceed()를 호출하도록 바꿔 연결합니다. proceed()는 대상 메서드와 남아 있는 안쪽 어드바이스로 진행하므로, 재시도 어드바이스가 호출할 때마다 그 안쪽 경로가 다시 실행됩니다.
작은 컨텍스트에 로그와 재시도 연결하기
Java 25와 Spring Boot에서 만든 누적 프로젝트를 기준으로 합니다. 기존의 Boot 4.1.2 BOM과 테스트 의존성은 그대로 두고, build.gradle의 dependencies 블록에 다음 의존성을 추가합니다. 버전은 BOM이 관리하므로 개별 Spring 버전을 따로 적지 않습니다.
implementation 'org.springframework:spring-context'
implementation 'org.springframework:spring-aop'
implementation 'org.aspectj:aspectjweaver'여기서 aspectjweaver는 AspectJ 애노테이션과 포인트컷을 처리하는 데 필요합니다. 이 의존성을 추가한다고 해서 자기 호출까지 가로채는 위빙이 켜지지는 않습니다.
앞에서 작성한 RetryExecutor.java를 같은 프로젝트에 두고 다음 테스트 파일을 추가합니다. 구성 클래스가 필요한 빈만 등록하므로 @SpringBootTest, 웹 서버, 데이터베이스 없이 작은 Spring 컨텍스트로 동작합니다. Observed는 로그를 남길 작업에, RetryTransient는 재시도를 허용한 메서드에 붙이며, 두 어드바이스 모두 공개 메서드의 패키지 범위와 애노테이션을 함께 요구합니다. 애노테이션이 있다고 해서 멱등성이 보장되지는 않습니다.
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은 프록시 대상의 제약과는 관계가 없습니다. 예제의 이벤트 목록과 실패 계획은 컨텍스트마다 한 스레드로 호출하는 설명용 도구이며, 운영용 로깅이나 동시 요청을 위한 저장소가 아닙니다.
로그 어드바이스는 proceed()를 한 번 호출합니다. 안쪽의 재시도 어드바이스는 첫 실행을 포함해 최대 세 번 호출하고 TransientStoreFailure만 잡습니다. 검증 실패를 비롯한 다른 예외와 Error는 그 자리에서 밖으로 전파되며, 횟수를 모두 쓴 일시적 실패도 마지막 객체가 그대로 전달됩니다. Throwable을 받는 로그 어드바이스는 이를 기록하고 다시 던질 뿐 재시도하지 않습니다. 포인트컷의 argNames는 애노테이션을 바인딩할 이름을 명시하므로, 포인트컷의 변수 이름과 어드바이스의 파라미터 이름을 함께 바꿔야 합니다.
누적 프로젝트에서 이 예제만 실행하려면 다음 명령을 씁니다.
./gradlew test --tests board.resilience.RetryProxyTestWindows의 PowerShell에서는 같은 위치에서 .\gradlew.bat test --tests board.resilience.RetryProxyTest를 씁니다. 아래 표와 이벤트는 코드가 요구하는 예상값이며, 실행 로그를 옮긴 것이 아닙니다. 테스트는 이벤트 목록을 assertEquals로 직접 비교하므로, 자바의 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를 먼저 봅니다. 대상은 실행되는데 어드바이스 이벤트가 없다면, new ReportService(...)나 자기 호출로 프록시를 우회했는지, 패키지와 애노테이션 조건이 모두 맞는지 확인합니다. 로그가 시도마다 반복된다면 @Order(10)과 @Order(20)이 뒤바뀌지 않았는지 봅니다.
@Aspect 구성의 예제는 board.aspect의 대상이 board.application 조건 밖에 있어서 프록시가 만들어지지 않는 반례였습니다. 여기서는 범위와 애노테이션을 모두 맞춘 대상을 등록했다는 점이 다릅니다.
트랜잭션은 따로 다룹니다
위 테스트에는 트랜잭션 관리자도 트랜잭션 어드바이스도 없습니다. target:1, target:2, target:3은 대상 호출 횟수일 뿐, 롤백이 세 번 일어났다거나 물리 트랜잭션이 세 개였다는 뜻이 아닙니다.
재시도를 트랜잭션 어드바이스 바깥에 두면, 시도할 때마다 안쪽 트랜잭션 어드바이스를 다시 지날 수 있습니다. 상위 트랜잭션이 없고, 안쪽의 REQUIRED가 매번 새 트랜잭션을 시작하고 끝내며, 그 실패가 롤백 조건에 맞을 때에야 실패한 시도가 정리된 뒤 새 시도가 시작됩니다.
이미 상위 트랜잭션이 있다면 안쪽의 REQUIRED는 같은 물리 트랜잭션에 참여할 수 있습니다. 이때는 재시도의 순서만 바꿔서는 새 트랜잭션이 생기지 않고, 실패로 롤백 전용이 된 상태는 다음 시도의 성공으로 되돌릴 수 없습니다. REQUIRES_NEW는 독립된 트랜잭션을 만들지만, 상위 트랜잭션의 연결을 쥔 채 새 연결이 필요하므로 커넥션 풀 용량과 업무의 원자성을 함께 판단해야 합니다.
기한과 대기 정책도 이 어드바이스에는 없습니다. 운영 코드로 확장할 때는 대상의 수행 시간과 대기 시간을 남은 기한에서 빼고, 취소와 인터럽트를 보존해야 합니다. Thread.sleep을 코드에 고정하기보다 대기 전략과 지터(jitter)의 값 공급원을 분리하면, 실제로 기다리지 않고도 정책을 시험할 수 있습니다.
백오프와 지터
즉시 반복하면 장애가 난 DB에 더 많은 부하를 보냅니다. 시도 사이의 대기를 두 배씩 늘리는 지수 백오프(exponential backoff)에 임의의 지터를 더하고, 서버가 다시 시도할 시점을 알려 준다면 상한 안에서 그 신호를 따릅니다. 요청의 기한을 넘길 다음 시도는 시작하지 않습니다.
요청마다 최대 횟수만 제한하면 동시 요청 전체의 재시도 폭증은 막지 못합니다. 재시도 예산, 회로 차단기(circuit breaker), 격벽(bulkhead)과 결합하되, 여러 라이브러리가 각각 재시도해 횟수가 곱해지지 않도록 재시도는 한 계층에서만 합니다.
메트릭은 시도 횟수, 소진, 복구, 멱등이 아니어서 건너뛴 경우, 예산 만료를 구분합니다. 예외 클래스를 제한 없이 태그로 쓰지 않고, 일시적 오류의 범주를 몇 가지로 제한합니다.
시도 로그와 전체 작업 로그
전체 로그를 재시도 바깥에 두면 사용자가 체감하는 지연 시간과 최종 결과를 기록합니다. 시도 로그를 각 시도의 안쪽에 두면 시도마다의 실패와 소요 시간을 봅니다. 시도 로그를 트랜잭션의 안과 밖 가운데 어디에 둘지는 커밋 시간까지 잴지에 따라 정합니다. 같은 작업 이름을 쓰더라도 scope=total|attempt처럼 카디널리티가 낮은 차원을 둡니다.
스택 트레이스는 마지막 실패나 표본으로 고른 시도에만 남겨 잡음을 줄입니다. 앞의 두 일시적 실패도 개수와 트레이스 이벤트로는 연결하되, 같은 예외를 여러 계층에서 오류 로그로 반복해 남기지 않습니다.
로그 수집이 느릴 때 재시도 스레드가 그 때문에 막히면 남은 기한이 줄어듭니다. 용량이 제한된 비동기 어펜더(appender)의 폐기·대기 정책을 운영 요구에 맞게 고릅니다.
실제 저장소로 확장할 때 확인할 것
다음 항목은 위 프록시 예제가 제공하지 않는 운영용 확장입니다. 대상 호출 횟수만으로 저장과 커밋의 성공을 판단하지 않습니다.
시도마다 트랜잭션 ID와 연결의 동일성을 기록하고, 실패한 시도의 행이 롤백됐는지 확인합니다. 세 번째 시도가 성공했을 때 행이 하나만 있어야 하며, 요청 키의 고유 제약 조건으로 중복을 막습니다. 재시도 애스펙트가 트랜잭션 안쪽에 있다면, 테스트가 롤백 전용 상태나 같은 연결의 반복을 드러내야 합니다.
네트워크 장애를 주입할 때는 SQL을 실행하기 전의 실패와 커밋 응답의 유실을 나눕니다. 커밋 응답이 유실된 경우는 서버에서 이미 커밋됐을 수 있으므로, 같은 멱등성 키로 결과를 조회해야 합니다.
더 깊이: Spring Framework 7에 들어온 @Retryable
이 글은 재시도를 직접 애스펙트로 만들었지만, Spring Framework 7.0부터는 핵심 모듈에 재시도 기능이 있습니다. Spring 레퍼런스의 복원력 기능에 따르면 @EnableResilientMethods를 구성 클래스에 붙이면 메서드의 @Retryable과 @ConcurrencyLimit이 처리되고, 코드 블록을 재시도할 때는 RetryPolicy를 받는 RetryTemplate을 씁니다. 기본값은 이 글의 애스펙트와 다릅니다. 아무 설정이 없으면 모든 예외에서 재시도하고, 첫 실패 뒤 최대 3번(maxRetries = 3)을 1초 간격으로 다시 시도하므로 전체 호출은 최대 4번입니다. 그래서 이 글의 원칙대로 일시적 실패만 다시 시도하려면 includes나 excludes로 예외 범위를 좁혀야 하며, 이 목록은 던져진 예외뿐 아니라 그 원인 예외와도 맞춰 봅니다.
이 기능도 빈 후처리기가 만드는 프록시 어드바이스이므로, 이 장에서 본 자기 호출과 빈 등록의 제약을 그대로 받습니다. 대신 트랜잭션과의 순서는 정해져 있습니다. @Retryable의 API 문서는 이 어드바이스가 기본으로 @Transactional과 @Cacheable의 바깥에 놓여 시도마다 트랜잭션을 새로 시작하거나 캐시를 다시 확인하고, @Async의 안쪽에 놓여 모든 시도가 비동기 실행 스레드에서 돌아간다고 설명합니다. @Async와의 순서는 바꿀 수 있지만 @Transactional, @Cacheable과의 순서는 고정입니다. 다만 상위 트랜잭션에 참여하는 경우 시도가 같은 물리 트랜잭션에 묶인다는 이 글의 경고는, 어느 구현을 쓰든 전파 속성이 정하는 문제로 그대로 남습니다.
정리
- 로그 애스펙트를 바깥(
@Order(10)), 재시도 애스펙트를 안쪽(@Order(20))에 두면 요청의 시작과 끝은 한 번, 대상은 최대 횟수만큼 호출됩니다. - 재시도 어드바이스는
proceed()를 여러 번 호출할 수 있고, 허용한 일시적 실패만 잡아 마지막 실패 객체를 그대로 전달합니다. - 애노테이션은 멱등성을 보장하지 않으므로, 쓰기를 재시도하려면 요청 키와 고유 제약 조건, 결과 조회 방법을 먼저 갖춥니다.
- 트랜잭션, 기한, 백오프, 지터는 이 예제에 없는 별도의 정책이며, 상위 트랜잭션과 전파 속성에 따라 재시도의 효과가 달라집니다.
연습 문제
1. 기한과 백오프를 갖춘 재시도
주간 보고서 조회에 최대 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 타임아웃보다 먼저 끝나야 합니다.
2. RetryBudget의 경곗값
new RetryBudget(100)으로 만든 예산에서 reserve(40), reserve(60), reserve(0)을 차례로 호출합니다. 각 호출의 반환값과 호출 뒤의 remainingMillis()를 구하세요. 이 클래스만으로 대상의 수행 시간까지 차감했다고 말할 수 있는지도 답하세요.
해설 보기
reserve(40)은 40이 100보다 작으므로 true이고 남은 값은 60입니다. reserve(60)은 남은 값과 같아 delayMillis >= remainingMillis 조건에 걸리므로 false이며 값은 60 그대로입니다. reserve(0)은 true이지만 값은 줄지 않습니다. 음수, 남은 값과 같은 값, 0이라는 세 경곗값을 함께 읽어야 이 클래스가 무엇을 예약하는지 보입니다.
이 클래스에는 시계를 읽거나 대상을 실행하는 코드가 없으므로, reserve만 호출해서는 대상의 수행 시간까지 차감했다고 할 수 없습니다. 수행 시간의 차감은 호출하는 쪽이 따로 해야 합니다.
3. 가짜 시계로 세는 예산 만료
가짜 시계에서 전체 400 ms로 시작합니다. 첫 대상 호출은 일시적인 읽기 타임아웃으로 150 ms 뒤에 실패합니다. 다음 대기는 백오프 40 ms에 고정 지터 10 ms를 더한 50 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 ms와 180 ms를 차감하는 메서드가 없으므로, 이 정책을 구현하려면 호출하는 쪽이 가짜 시계의 경과 시간도 반영해야 합니다. 이 계산은 손으로 따라간 모델이며, 실제 타임아웃을 실행하거나 저장소가 복구된 결과는 아닙니다.
다음 글 내부 호출과 프록시에서는 같은 객체 안에서 this로 부른 메서드에 어드바이스가 붙지 않는 이유와, 그 문제를 구조로 푸는 방법을 다룹니다.