로깅·재시도 AOP
구조화 로깅 애스펙트와 제한된 재시도 애스펙트를 구현하고 일시적 분류·백오프·지터·최대 시도·쓰기 멱등성·어드바이저 순서·메트릭을 실행 결과로 검증합니다.
로깅과 재시도는 대표적인 횡단 관심이지만 위험도는 다릅니다.
로깅은 대상을 한 번 관찰해야 하고 재시도는 같은 대상을 여러 번 실행합니다.
@RetryableOperation을 붙이기 전에 재실행 가능한 상태 변경인지, 첫 시도가 커밋됐을 가능성은 없는지, 요청 기한과 풀 예산이 남았는지 답해야 합니다.
구조화 로그 필드
런타임 클래스 이름과 인자 전체를 메시지 문자열로 붙이면 검색과 마스킹이 어렵습니다.
애노테이션 또는 애플리케이션 포트에서 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, 검증, 인증, 프로그래밍 결함은 재시도하지 않습니다.
일시적 인프라 계층을 좁게 정의하고 원인 체인을 분류합니다.
재시도·트랜잭션 순서
재시도 애스펙트가 트랜잭션 어드바이저보다 바깥 순서를 가지면 proceed() 한 번마다 내부 트랜잭션을 새로 시작할 수 있습니다.
다음 순수 Java 예제는 세 번째 시도에 성공하는 개수 규칙을 보여 줍니다.
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실제 어드바이스는 기한에서 대기 시간을 빼고 취소·인터럽트를 보존합니다.
Thread.sleep을 애스펙트에 고정하지 않고 대기 전략과 지터 소스를 주입해 테스트를 결정적으로 만듭니다.
백오프·지터
즉시 반복은 장애 난 DB에 더 많은 부하를 보냅니다. 지수 백오프에 임의 지터를 더하고 서버의 재시도 이후 신호가 있으면 상한 안에서 존중합니다. 요청 기한을 넘길 다음 시도는 시작하지 않습니다.
동시 요청별 최대 횟수만 제한하면 전체 재시도 폭증을 막지 못합니다. 재시도 예산, 회로 차단기, 격벽과 결합하되 중첩 라이브러리가 각각 재시도해 횟수가 곱해지지 않게 한 계층이 소유합니다.
메트릭은 시도 횟수, 소진, 복구, 비멱등 건너뜀, 예산 만료를 구분합니다. 예외 클래스를 무제한 태그로 쓰지 않고 일시적 오류 범주를 제한합니다.
시도·전체 작업 로그
전체 로그가 재시도 바깥에 있으면 사용자 체감 지연 시간과 최종 결과를 기록합니다.
시도 로그는 트랜잭션 안쪽에서 각 실패와 지속 시간을 봅니다.
같은 작업 이름을 쓰더라도 scope=total|attempt처럼 낮은 카디널리티 차원을 둡니다.
스택 트레이스는 마지막 실패 또는 표본 추출된 시도에만 남겨 잡음을 줄입니다. 첫 두 일시적 실패도 개수와 트레이스 이벤트로는 연결하되 같은 예외를 여러 계층에서 오류 로그하지 않습니다.
로깅 수집 대상이 느릴 때 재시도 스레드를 막으면 기한이 줄어듭니다. 용량이 제한된 비동기 어펜더의 폐기·대기 정책을 운영 요구에 맞게 선택합니다.
재시도 통합 검증
시도마다 트랜잭션 ID와 연결 동일성을 기록하고 실패 행이 롤백됐는지 확인합니다. 세 번째 성공에서 행이 하나만 있어야 하며 요청 키 고유 제약 조건으로 중복을 방지합니다. 재시도 애스펙트가 트랜잭션 안쪽이면 테스트가 롤백 전용 상태 또는 같은 연결 반복을 드러내야 합니다.
네트워크 장애 주입은 SQL 실행 전 실패와 커밋 응답 유실을 나눕니다. 후자는 서버 커밋 가능성이 있어 같은 멱등성 키로 결과를 조회해야 합니다.
연습 문제
주간 보고서 조회에 최대 3회 재시도를 적용하세요. 전체 기한 400ms, 백오프 40ms와 지터, 일시적 읽기 타임아웃만 허용하고 전체·시도 메트릭 횟수를 검증하는 가짜 시계 테스트를 작성하세요.
해설 보기
다음 지연 시간이 남은 예산보다 크면 횟수 소진이 아니라 예산 만료로 중단합니다. 인터럽트 상태는 다시 설정하고 즉시 호출자에게 전달합니다.
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 타임아웃보다 짧게 끝나야 합니다.