junit-benchmarks로 HTTP API의 응답 시간 측정하기

JUnit 4 확장인 junit-benchmarks로 HTTP API에 동시 요청을 보내 응답 시간을 측정하던 2012년경의 예제를 설명하고, 지금 같은 일을 한다면 어떤 도구를 쓸지 정리합니다.

예전 예제 저장소들을 정리하면서 benelog/benchmark 저장소를 이 블로그 저장소의 examples/benchmark-test로 옮겼습니다. 원래 저장소에 README가 없어서 어떤 코드인지 이 글로 설명을 남깁니다. 이 예제는 JUnit 4 확장 라이브러리인 junit-benchmarks로 HTTP API에 동시 요청을 보내 응답 시간을 측정합니다. 2012년경 사내 API 서버가 동시 요청을 받았을 때의 응답 시간을 확인하려고 작성했습니다.

junit-benchmarks 소개

junit-benchmarks는 Carrot Search에서 만든 JUnit 4 확장입니다. 평범한 JUnit 테스트 메서드에 애너테이션만 붙여서 벤치마크로 바꿔줍니다.

  • @BenchmarkOptions : 반복 횟수(benchmarkRounds), 워밍업 횟수(warmupRounds), 동시 실행 스레드 수(concurrency)를 지정합니다.

  • @BenchmarkMethodChart : 메서드별 실행 시간을 HTML 차트로 생성합니다. 결과를 H2 DB에 누적해서 차트를 그리는 방식이라, 예제의 pom.xmlh2 의존성이 들어 있는 이유이기도 합니다.

전용 부하 테스트 도구를 준비할 정도는 아니고, 개발 중에 IDE나 `mvn test`로 바로 돌려볼 간단한 측정이 필요할 때 편했습니다.

예제 구조

SampleBenchmarkTest.java가 핵심입니다. 핵심 부분만 옮기면 아래와 같습니다.

SampleBenchmarkTest.java
@BenchmarkMethodChart(filePrefix = "benchmark-sample")
public class SampleBenchmarkTest extends AbstractBenchmark {

	@Test
	@BenchmarkOptions(benchmarkRounds = 8, warmupRounds = 0, concurrency = 8)
	public void testCreateApk() throws ClientProtocolException, IOException, InterruptedException {
		String requestUrl = "http://localhost/create";
		HttpPost request = new HttpPost(requestUrl);
		request.setEntity(createEntity());

		HttpResponse response = httpClient.execute(request);

		int statusCode = response.getStatusLine().getStatusCode();
		assertThat(statusCode, is(200));
	}
}

구성 요소별로 보면 이렇습니다.

  • AbstractBenchmark 상속 : junit-benchmarks의 측정 Rule이 적용된 기반 클래스입니다. 이 클래스를 상속하면 @Test 메서드가 벤치마크 대상이 됩니다.

  • @BenchmarkOptions(benchmarkRounds = 8, warmupRounds = 0, concurrency = 8) : 총 8회 호출을 8개 스레드로 나눠 실행합니다. 즉 동시에 8건의 요청이 서버로 나갑니다. 측정 대상이 JVM 내부 코드가 아니라 외부 HTTP 서버라서 워밍업은 0으로 두었습니다.

  • 요청 내용 : 로컬 서버의 APK 생성 API(/create)에 versionCode 파라미터를 담아 POST를 보내고, 응답 코드가 200인지 확인합니다.

HTTP 호출에는 Apache HttpClient 4.1을 사용했습니다. 동시 요청 8건이 커넥션을 기다리며 막히지 않도록, `ThreadSafeClientConnManager`로 커넥션 풀의 최대치를 스레드 수보다 큰 9로 잡았습니다.

SampleBenchmarkTest.java
private static void createHttpClient(int connectTimeoutMilsec, int readTimeoutMilsec) {
	connManager = new ThreadSafeClientConnManager();
	connManager.setMaxTotal(9);
	connManager.setDefaultMaxPerRoute(9);

	HttpClient client = new DefaultHttpClient(connManager);
	client.getParams().setIntParameter(CoreConnectionPNames.CONNECTION_TIMEOUT, connectTimeoutMilsec);
	client.getParams().setIntParameter(CoreConnectionPNames.SO_TIMEOUT, readTimeoutMilsec);
	httpClient = client;
}

같이 들어 있는 SimpleRequestTest.java는 벤치마크 없이 GET 요청 한 번을 보내는 단순 테스트입니다. 다만 지금 보면 BasicHttpParams`에 `query 값을 넣어 `request.setParams()`로 전달하는 부분은 의도대로 동작하지 않는 코드입니다. `HttpParams`는 타임아웃 같은 클라이언트 실행 옵션을 담는 자리라서, 이렇게 넣은 값은 URL 쿼리 스트링으로 전송되지 않습니다.

실행 방법

`mvn test`나 IDE에서 일반 JUnit 테스트처럼 실행하면 콘솔에 라운드별 실행 시간의 평균과 표준편차가 아래 형태로 출력됩니다.

SampleBenchmarkTest.testCreateApk: [measured 8 out of 8 rounds, threads: 8 (concurrent)]
 round: 0.52 [+- 0.06], round.block: 0.01 [+- 0.02], round.gc: 0.00 [+- 0.00],
 GC.calls: 0, GC.time: 0.00, time.total: 0.61, time.warmup: 0.00, time.bench: 0.61

`@BenchmarkMethodChart`의 HTML 차트까지 받으려면 결과를 H2 DB에 쌓도록 시스템 프로퍼티를 추가합니다.

mvn test -Djub.consumers=CONSOLE,H2 -Djub.db.file=.benchmarks

지금 다시 쓴다면

이 예제는 JUnit 4.8, HttpClient 4.1, junit-benchmarks 0.3 시절에 작성했습니다. 지금 기준으로는 구성 요소 전부가 세대교체되었습니다.

  • junit-benchmarks는 2013년의 0.7.2 릴리스를 끝으로 개발이 중단되었고, 제작사도 JVM 마이크로벤치마크 용도로는 JMH를 권합니다. 다만 JMH는 JVM 내부 코드 측정에 특화된 도구라, 이 예제처럼 외부 HTTP 서버에 부하를 거는 용도와는 초점이 다릅니다.

  • HTTP 부하 테스트가 목적이라면 지금은 k6, Gatling, JMeter 같은 전용 도구가 우선 선택지입니다. 이 예제 수준의 간단한 측정이라면 hey나 wrk 같은 CLI 도구가 코드 작성 없이 더 빠릅니다.

  • HttpClient 4.1의 `DefaultHttpClient`와 `ThreadSafeClientConnManager`는 4.3에서 deprecated 되었습니다. Java에서 직접 호출 코드를 짠다면 HttpClient 5.x의 `PoolingHttpClientConnectionManager`나, JDK 11부터 표준으로 들어온 `java.net.http.HttpClient`를 씁니다.

  • "동시 요청 시에도 응답이 정상인지"를 회귀 테스트로 유지하고 싶다면, 응답 시간 측정과 기능 검증을 분리해서 후자만 CI에 두는 편이 안정적입니다. 응답 시간은 실행 환경에 따라 흔들려서 테스트 통과 기준으로 삼으면 거짓 실패가 잦습니다.

Spring Retry로 구현하는 재시도와 서킷 브레이커

Spring Retry의 RetryTemplate과 @Retryable, @Recover, @CircuitBreaker로 재시도, 리커버, 서킷 브레이커 정책을 적용하고 테스트 코드로 검증하는 방법을 정리합니다.

원격 호출처럼 일시적으로 실패할 수 있는 작업에는 재시도가 필요합니다. Spring Retry는 재시도 정책과 실행 구조를 제공하는 모듈입니다. 이 글에서는 RetryTemplate 을 직접 호출하는 방법과 애너테이션 기반의 AOP를 이용하는 방법으로 재시도, 리커버, 서킷 브레이커 정책을 적용해보고, 그 동작을 테스트 코드로 확인합니다.

Note

이 글은 Spring Retry가 Spring Framework에 흡수되기 전, 독립된 모듈이던 시절의 사용법을 정리한 글입니다.

Spring Batch에 내장되어 있던 재시도 기능은 Spring Batch 2.2.0부터 Spring Retry라는 독립된 모듈로 분리되었습니다. 그 뒤로는 Spring Integration이나 Spring Cloud Netflix 같은 다른 프로젝트에서도 이 모듈을 의존했습니다. Spring Retry의 기능은 Spring Framework 7.0부터 프레임워크 안으로 흡수되었습니다. 재시도의 핵심이 되는 클래스들은 'spring-core' 모듈에 들어갔습니다. @Retryable 애너테이션과 이를 처리하는 AOP 기능은 'spring-context' 모듈에 들어갔습니다. Spring Batch 6.0, Spring Boot 4.0 이상에서는 'spring-core' 모듈에 포함된 재시도 기능을 활용합니다.

최신 버전에서의 유효한 사용법은 Resilience Features 문서를 참고할 수 있습니다. 이 글에 소개된 @Recover@CircuitBreaker는 Spring Framework 7.0의 재시도 기능에서는 지원되지 않습니다.

1. 실습 프로젝트 초기 설정

이 실습에서는 Spring Retry의 기능들을 테스트 코드를 통해 확인해보겠습니다. IDE에서 새로운 Gradle 프로젝트를 생성합니다. 프로젝트 이름은 'retry' 등으로 편하게 붙입니다. 필요한 의존성 선언 등을 담은 build.gradle 파일은 다음과 같이 작성합니다.

build.gradle
plugins {
    id 'java'
    id 'org.springframework.boot' version '3.2.1'
    id 'io.spring.dependency-management' version '1.1.4'
}

group = 'net.benelog.retry'
version = '1.0-SNAPSHOT'
repositories {
    mavenCentral()
}

java {
    sourceCompatibility = '21'
}

dependencies {
    implementation 'org.springframework.retry:spring-retry' // (1)
    implementation 'ch.qos.logback:logback-classic'
    implementation 'org.springframework:spring-core'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

test {
    useJUnitPlatform()
}
  1. 이 실습의 핵심인 spring-retry 모듈 의존성입니다.

이 글의 예제에서 스프링 부트의 기능을 활용한 요소는 없습니다. 그럼에도 라이브러리 버전을 편리하게 지정하기 위해 Gradle의 스프링 부트 플러그인과 spring-boot-starter-test 모듈을 추가했습니다. 테스트에서도 꼭 필요한 의존성만을 명시하고 싶다면 JUnit5와 AssertJ를 직접 추가해도 됩니다.

아래와 같이 정의한 NotificationService 인터페이스를 재시도 대상으로 삼겠습니다.

NotificationService.java
public interface NotificationService {
  boolean send(String message);
}

'src/main/' 폴더 하위에 NotificationService.java 파일을 추가합니다. 패키지는 net.benelog.retry 등 편리한 대로 지정합니다. 실제 알림을 보내는 기능은 실습 대상이 아닙니다. 이 인터페이스에 맞추어 알림을 보내기 위해 원격 호출을 하는 구현 클래스가 있다고 상상만 합니다.

실제로 원격 호출을 하지 않고 의도적으로 지정한 횟수만큼 실패하는 클래스를 UnstableNotificationService라는 이름으로 만들겠습니다. 테스트 용도의 클래스이므로 'src/test/' 폴더 하위에 패키지를 만들어서 추가합니다. send(String) 메서드를 호출했을 때 지정된 횟수만큼은 예외를 내도록 구현합니다.

UnstableNotificationService.java
public class UnstableNotificationService implements NotificationService {
	private final Logger logger = LoggerFactory.getLogger(UnstableNotificationService.class);
	private final int failures;
	private int tryCount = 0;

	public UnstableNotificationService(int failures) { // (1)
		this.failures = failures;
	}

	@Override
	public boolean send(String message) {
		this.tryCount++;
		if (this.tryCount <= this.failures) { // (2)
			throw new RuntimeException("실패 : " + tryCount);
		}
		logger.info("성공 : {}, {}", this.tryCount, message);
		return true;
	}

	public int getTryCount() {
		return this.tryCount; // (3)
	}
}
  1. 의도적으로 실패할 횟수를 생성자 파라미터로 받습니다.

  2. 의도한 횟수보다 작거나 같으면 예외를 발생시킵니다.

  3. send(String) 메서드가 실제로 몇 번 호출되었는지 알려줍니다.

send(String) 메서드 호출을 재시도하는 코드를 이어지는 단락에서 작성하겠습니다.

2. RetryTemplate과 정책 클래스 활용

2.1. RetryTemplate과 RetryPolicy, BackOffPolicy

RetryTemplate 은 Spring Retry의 핵심 기능을 구현한 클래스입니다. 이 클래스는 4개의 execute() 메서드를 제공하는데, 모두 RetryCallback 인터페이스 타입을 파라미터로 받습니다. 이 메서드들은 RetryOperations 라는 인터페이스로도 따로 정의되어 있습니다.

RetryTemplate.execute() 메서드
  public final <T, E extends Throwable> T execute(RetryCallback<T, E> retryCallback) throws E {
  }

RetryCallback은 아래와 같이 하나의 메서드만 정의된 인터페이스로, 재시도 대상이 되는 작업을 구현하는 용도입니다.

RetryCallback 인터페이스
public interface RetryCallback<T, E extends Throwable> {
  T doWithRetry(RetryContext context) throws E;
}

앞 단락의 NotificationService.send(String) 메서드를 재시도하는 클래스를 NotificationRetryDecorator 라는 이름으로 만들어보겠습니다. 디자인 패턴 중 Decorator 패턴을 응용한 클래스임을 이름으로 표현했습니다. Decorator는 기존 객체를 감싸는 방식으로 유연하게 책임을 추가하는 패턴입니다. RetryTemplate이 꼭 Decorator 패턴을 통해 적용되어야 하는 것은 아닙니다. RetryCallback 안에서 호출하는 재시도 대상은 내부 private 메서드나 직접 기술한 긴 로직 등 무엇이든 될 수 있습니다.

구현하려는 재시도 요구사항은 다음과 같습니다.

  • 최대 시도 횟수는 NotificationRetryDecorator 클래스의 생성자 파라미터로 전달받는다.

  • 재시도 사이에는 200ms에서 시작해 2배씩 늘어나는 시간 간격을 둔다. 600ms를 최대 간격으로 한다.

'src/main/' 디렉터리 아래 NotificationService 인터페이스와 같은 패키지에 NotificationRetryDecorator.java 파일을 추가합니다. 요구사항대로 RetryTemplate을 생성해주는 private 메서드부터 먼저 만들겠습니다.

NotificationRetryDecorator.java에서 RetryTemplate을 생성하는 메서드
import java.util.Map;

import org.springframework.retry.RetryOperations;
import org.springframework.retry.backoff.ExponentialBackOffPolicy;
import org.springframework.retry.policy.SimpleRetryPolicy;
import org.springframework.retry.support.RetryTemplate;

public class NotificationRetryDecorator {
	private RetryOperations buildRetryOperations(int maxAttempts) { // (1)
		var retryPolicy = new SimpleRetryPolicy( // (2)
			maxAttempts,
			Map.of(RuntimeException.class, true)
		);
		var backOffPolicy = new ExponentialBackOffPolicy(); // (3)
		backOffPolicy.setInitialInterval(200L);
		backOffPolicy.setMultiplier(2d);
		backOffPolicy.setMaxInterval(600L);

		var retryTemplate = new RetryTemplate(); // (4)
		retryTemplate.setRetryPolicy(retryPolicy);
		retryTemplate.setBackOffPolicy(backOffPolicy);
		return retryTemplate;
	}
}
  1. RetryTemplate 클래스가 구현한 인터페이스인 RetryOperations를 반환합니다.

  2. RuntimeException이 발생하면 'maxAttempts' 값으로 지정된 횟수만큼 재시도하도록 정책을 정의합니다.

  3. 이어지는 세터 호출을 통해 최초 200ms, 이후 2배씩, 최대 600ms까지만 재시도 간격을 늘리는 정책을 정의합니다.

  4. 앞서 정의한 RetryPolicy, BackOffPolicy를 참조할 RetryTemplate의 인스턴스를 생성합니다.

반환형으로 RetryOperations 인터페이스를 활용했기에 buildRetryOperations(int) 메서드를 호출해서 사용하는 쪽에서는 RetryTemplate 클래스 레벨에서만 정의된 setBackOffPolicy(..) 같은 초기 설정 메서드를 호출할 수 없게 됩니다. RetryOperations만 의존하는 코드에서는 execute(…​) 메서드만 신경 쓰면 됩니다. 혹시 모를 실수를 막고, IDE 자동 완성의 추천 대상도 줄어든다는 장점이 있습니다.

여러 클래스에서 RetryTemplate을 동일한 정책으로 쓴다면 애플리케이션 컨텍스트에 등록하고 재시도가 필요한 클래스에서 주입받는 방식도 가능합니다. 그럴 경우에도 이를 참조해서 쓰는 쪽에서 RetryOperations 인터페이스에만 의존한다면 여러 클래스에서 공유되는 정책이 수정되지 않도록 방어됩니다.

NotificationRetryDecorator 실습을 이어가겠습니다. Decorator 패턴을 구현하기 위해 NotificationService 인터페이스를 이 클래스가 구현합니다. 생성자에서는 재시도 대상 객체와 재시도 횟수를 주입받고, 앞선 예제의 buildRetryOperations(…​) 메서드를 호출하는 등 재시도에 필요한 구성요소를 만들어 갑니다.

NotificationRetryDecorator.java
import org.springframework.retry.RetryCallback;
import org.springframework.retry.RetryContext;

public class NotificationRetryDecorator implements NotificationService { // (1)
	private final Logger logger = LoggerFactory.getLogger(NotificationRetryDecorator.class);
	private final NotificationService target;
	private final RetryOperations retryOperations;

	public NotificationRetryDecorator(NotificationService target, int maxAttempts) { // (2)
		this.target = target;
		this.retryOperations = buildRetryOperations(maxAttempts); // (3)
	}

	@Override
	public boolean send(String message) {
		RetryCallback<Boolean, RuntimeException> retryCallback = (RetryContext context) -> { // (4)
			logger.info("{}", context);
			return target.send(message);
		};
		return this.retryOperations.execute(retryCallback);
	}
    // buildRetryOperations(...) 메서드 생략
}
  1. NotificationService 인터페이스를 구현함으로써 재시도 기능이 없었던 기존의 사용처에도 바로 적용할 수 있습니다.

  2. 재시도의 대상 객체와 재시도 횟수를 생성자의 파라미터로 받습니다.

  3. 생성자를 통해 받은 횟수만큼 재시도하는 RetryOperations 객체를 생성합니다.

  4. 재시도 대상이 되는 동작을 람다 표현식으로 정의했습니다.

위의 예제에서는 파라미터 타입을 더 명확히 드러내고자 'retryCallback’을 로컬 변수로 할당하고, 실행 여부를 인지하기 쉽도록 콜백 안에서 로깅을 하는 코드도 넣었습니다. 실무에서는 아래와 같이 람다 표현식을 더 간결하게 쓰는 방법도 고려할 수 있습니다.

RetryCallback 객체를 더 간결하게 정의
	@Override
	public boolean send(String message) {
		return this.retryOperations.execute((context) -> target.send(message));
	}

RetryTemplate 클래스는 builder() 메서드도 제공합니다. 직접 RetryPolicy, BackOffPolicy를 지정해서 RetryTemplate을 생성했던 buildRetryOperations(int) 메서드는 아래와 같이 대체될 수 있습니다.

builder()를 이용한 정책 지정 간소화
		this.retryOperations = RetryTemplate.builder()
			.retryOn(RuntimeException.class)
			.maxAttempts(maxAttempts)
			.exponentialBackoff(200L, 2d, 600L)
			.build();

builder()를 쓰면서도 .customPolicy(RetryPolicy), .customBackOff(BackOffPolicy) 메서드를 통해 직접 Policy 구현 클래스를 지정할 수도 있습니다.

테스트 코드를 통해 재시도가 지정한 정책대로 되었는지 확인해보겠습니다. 앞에서 만든 테스트 전용 클래스인 UnstableNotificationService로 의도적으로 실패할 횟수를 지정하고, 이 횟수를 넘어선 재시도로 결국에는 성공함을 테스트 코드로 표현합니다.

NotificationRetryDecoratorTest.java
class NotificationRetryDecoratorTest {
	@Test
	void successByRetry() {
		var target = new UnstableNotificationService(3); // (1)
		var decorator = new NotificationRetryDecorator(target, 4); // (2)

		boolean success = decorator.send("hello");

		assertThat(success).isTrue();
		assertThat(target.getTryCount()).isEqualTo(4); // (3)
	}
}
  1. 최초 3회 호출은 예외를 내는 UnstableNotificationService 인스턴스를 만듭니다.

  2. 대상 객체를 총 4회까지 재시도하도록 decorator를 생성합니다.

  3. 총 4회까지 UnstableNotificationService.send(String) 메서드가 호출되었음이 확인됩니다.

위의 테스트 코드를 실행하면 UnstableNotificationService.send(String) 호출이 3번 실패하고 4번째에 성공하는 과정이 콘솔의 로그로도 확인됩니다.

NotificationRetryDecoratorTest가 실행될 때의 콘솔 로그
INFO net.benelog.retry.NotificationRetryDecorator -- [RetryContext: count=0, lastException=null, exhausted=false]
INFO net.benelog.retry.NotificationRetryDecorator -- [RetryContext: count=1, lastException=java.lang.RuntimeException: 실패 : 1, exhausted=false]
INFO net.benelog.retry.NotificationRetryDecorator -- [RetryContext: count=2, lastException=java.lang.RuntimeException: 실패 : 2, exhausted=false]
INFO net.benelog.retry.NotificationRetryDecorator -- [RetryContext: count=3, lastException=java.lang.RuntimeException: 실패 : 3, exhausted=false]
INFO net.benelog.retry.UnstableNotificationService -- 성공 : 4, hello

로그 메시지에서 RetryContext가 가진 재시도 횟수(count), 마지막으로 발생한 예외(lastException)도 확인됩니다. 각각 RetryContext.getRetryCount(), RetryContext.getLastThrowable() 메서드로 참조할 수 있는 값입니다. 필요하다면 RetryCallback.doWithRetry(RetryContext) 메서드를 구현한 메서드나 람다 표현식에서 이를 활용할 수도 있습니다.

2.2. Recover 동작 정의

재시도를 했음에도 마지막 시도까지 실패했을 때 다른 동작을 실행할 수도 있습니다. RetryTemplateexecute(RetryCallback, RecoveryCallback) 메서드를 통해 이를 지원합니다. 재시도할 동작은 RetryCallback에, 최종 실패 시의 동작은 RecoveryCallback에 담아서 넘기는 형식입니다. RecoveryCallback 인터페이스도 메서드 1개만 단순하게 정의되어 있습니다.

RecoveryCallback 인터페이스
public interface RecoveryCallback<T> {
  T recover(RetryContext context) throws Exception;
}

NotificationRetryDecorator에서도 이를 활용해보겠습니다.

NotificationRetryDecorator.send(String) 메서드에서 RecoveryCallback 지정
	@Override
	public boolean send(String message) {
		return retryOperations.execute(
			(context) -> this.target.send(message),
			(context) -> recover(context.getLastThrowable(), message) // (1)
		);
	}

	private boolean recover(Throwable error, String message) { // (2)
		logger.warn("메시지 전송 최종 실패 후 recover : {}", message, error);
		return false;
	}
  1. 같은 클래스의 recover(…​) 메서드를 호출하도록 RecoveryCallback을 람다 표현식으로 정의했습니다.

  2. 추가한 메서드입니다. 전송을 요청한 메시지와 예외를 로깅하고 false 값을 반환합니다.

NotificationRetryDecoratorTest에 메서드를 추가하여 recover(…​) 메서드가 의도대로 실행됨을 확인합니다.

recover 동작 수행 테스트
	@Test
	void recover() {
		var target = new UnstableNotificationService(2); // (1)
		var decorator = new NotificationRetryDecorator(target, 2); // (2)

		boolean success = decorator.send("hello");

		assertThat(success).isFalse(); // (3)
		assertThat(target.getTryCount()).isEqualTo(2);
	}
  1. 처음 2번째 호출까지는 예외를 내는 NotificationService를 생성합니다.

  2. 2번까지 재시도하도록 'decorator’를 만듭니다. 결과적으로 모든 시도가 예외로 실패합니다.

  3. 마지막 시도가 실패했어도 예외가 발생하지 않고 recover(…​) 메서드가 실행되어 false 값이 반환되었습니다.

추가한 테스트 메서드를 실행하면 로그 메시지를 통해서도 recover(…​) 메서드가 실행되었음이 확인됩니다.

RecoveryCallback으로 유도한 실행 흐름은 마치 try - catch 구문의 catch 블럭과 비슷한 역할을 합니다. 작업을 멈추는 것보다 차선의 기능으로 대체하는 것이 나은 동작이 있다면 이 구조를 활용해볼 만합니다. 예를 들면 사용자의 프로필 이미지를 HTTP 호출로 읽어오는 동작이 실패했을 때 디폴트 이미지로 대체하는 기능 같은 것들입니다.

2.3. CircuitBreakerRetryPolicy

RetryPolicy 구현체 중 하나인 CircuitBreakerRetryPolicy 클래스는 실패하는 요청을 계속하지 않도록 하여 장애 확산을 방어하는 서킷 브레이커 개념을 지원합니다. 특정 시간 내에 특정 횟수의 시도가 실패하면 '차단기’가 열려서 한동안은 실제 동작을 수행하는 메서드를 호출하지 않습니다.

예제를 통해 다음 정책을 구현해보겠습니다.

  • 200ms 동안 지정된 횟수 이상 메서드 호출이 실패하면 차단기가 열린다. 차단기가 열려 있는 동안은 실제 기능을 수행하는 메서드를 호출하지 않고 리커버 동작만 수행한다.

  • 차단기는 300ms 동안 유지되고, 해당 시간이 지나면 닫힌다.

서킷 브레이커가 열리고 다시 닫히는 시간이 정책에 들어갑니다. 앞에서 했던 단순히 횟수를 지정하는 정책과 다르게 호출이 실패한 시간 정보 등이 기록되어야 합니다. RetryTemplate에서는 내부적으로 RetryContextCache 라는 인터페이스를 통해 상태 정보를 저장합니다. 기본값으로는 메모리에 상태를 저장하는 MapRetryContextCache 구현체가 쓰입니다. 여러 서버 간에 재시도 기준 정보를 공유하고 싶다면 RetryContextCache를 직접 구현해서 Redis 같은 글로벌 캐시에 저장하는 것도 가능합니다.

이러한 상태 정보를 관리하는 데 필요한 키값 등은 RetryState 인터페이스를 통해 전달됩니다. 기본 구현체로 DefaultRetryState 클래스가 제공되고 이를 통해 다음의 속성을 전달합니다.

  • key : 재시도 상태를 관리하는 기준이 되는 키

  • forceRefresh : 완전히 새로운 상태로 시도할 경우 true

  • rollbackClassifier : 트랜잭션 롤백을 위해 특정 예외를 전파할지를 결정하는 역할을 합니다. org.springframework.classify.Classifier 인터페이스를 파라미터 타입으로 씁니다.

RetryTemplate 클래스와 RetryOperations 인터페이스에는 RetryState를 포함하는 execute(…​) 메서드가 있습니다. 서킷 브레이커 정책을 적용하기 위해서는 이 메서드를 호출해야 합니다.

RetryState가 파라미터에 포함된 execute(…​) 메서드
	<T, E extends Throwable> T execute(RetryCallback<T, E> retryCallback, RecoveryCallback<T> recoveryCallback,
			RetryState retryState) throws E;

위의 메서드를 활용하여 서킷 브레이커 정책을 적용하는 예제도 Decorator 패턴으로 먼저 구현해보겠습니다. 앞선 실습의 NotificationRetryDecorator 클래스와 구조가 거의 비슷하므로 복사해서 만들어도 좋습니다. 먼저 RetryTemplate을 생성하는 buildRetryOperations() 메서드부터 정의하겠습니다.

NotificationCircuitDecorator.java에서 RetryTemplate을 생성하는 메서드
public class NotificationCircuitDecorator implements NotificationService {
	// 중간 생략

	private RetryOperations buildRetryOperations(int maxAttempts) {
		var simplePolicy = new SimpleRetryPolicy(
			maxAttempts,
			Map.of(RuntimeException.class, true)
		);
		var circuitBreakerPolicy = new CircuitBreakerRetryPolicy(simplePolicy); // (1)
		circuitBreakerPolicy.setOpenTimeout(200L); // (2)
		circuitBreakerPolicy.setResetTimeout(300L); // (3)

		var retryTemplate = new RetryTemplate();
		retryTemplate.setRetryPolicy(circuitBreakerPolicy); // (4)
		return retryTemplate;
	}
}
  1. 서킷 브레이커의 발동 조건이 되는 대상 예외와 발동 전 최대 호출 횟수는 SimpleRetryPolicy로 정의해서, CircuitBreakerRetryPolicy 내부에서 참조하도록 생성자의 인자로 전달합니다.

  2. 200ms 동안 SimpleRetryPolicy에서 지정된 횟수의 시도가 실패하면 서킷 브레이커가 발동합니다.

  3. 300ms가 지나면 서킷 브레이커가 닫혀서 다시 원래의 대상 메서드를 호출합니다.

  4. 앞에서 구성한 정책을 RetryTemplate에 넣습니다.

NotificationCircuitDecorator의 나머지 부분도 완성합니다.

NotificationCircuitDecorator.java
public class NotificationCircuitDecorator implements NotificationService {

	private final Logger logger = LoggerFactory.getLogger(NotificationCircuitDecorator.class);
	private final NotificationService target;
	private final RetryOperations retryOperations;
	private final RetryState state; // (1)

	public NotificationCircuitDecorator(NotificationService target, int maxAttempts) {
		this.target = target;
		this.retryOperations = buildRetryOperations(maxAttempts);
		String stateKey = "NotificationService.send"; // (2)
		var rollbackClassifier = new BinaryExceptionClassifier(false); // (3)
		this.state = new DefaultRetryState(stateKey, rollbackClassifier);
	}

	@Override
	public boolean send(String message) {
		return this.retryOperations.execute(
			(context) -> this.target.send(message),
			(context) -> recover(context.getLastThrowable(), message),
			this.state // (4)
		);
	}
	// recover 메서드는 NotificationRetryDecorator의 것과 동일
}
  1. NotificationRetryDecorator 클래스 대비 추가된 멤버 변수입니다.

  2. 재시도를 위한 상태 정보의 키를 재시도할 메서드의 이름으로 썼습니다.

  3. 원래의 메서드를 호출해서 생기는 예외를 다시 전파하지 않도록 합니다.

  4. 상태를 유지하는 재시도를 위해 여러 execute(…​) 메서드 중 RetryState를 파라미터로 받는 것을 호출합니다.

테스트 코드로 서킷 브레이커가 잘 동작하는지 확인합니다.

NotificationCircuitDecoratorTest.java
class NotificationCircuitDecoratorTest {
	UnstableNotificationService target = new UnstableNotificationService(2); // (1)
	NotificationCircuitDecorator decorator = new NotificationCircuitDecorator(target, 2); // (2)

	private void callAndAssert(boolean expectedResult, int expectedTryCount) { // (3)
		boolean success = decorator.send("Hello!");
		assertThat(success).isEqualTo(expectedResult);
		assertThat(target.getTryCount()).isEqualTo(expectedTryCount);
	}

	@Test
	void recoveredWithCircuit() throws InterruptedException {
		callAndAssert(false, 1); // (4)
		callAndAssert(false, 2);
		callAndAssert(false, 2); // (5)
		TimeUnit.MILLISECONDS.sleep(310); // (6)
		callAndAssert(true, 3); // (7)
	}
}
  1. 처음 2번은 예외를 내는 target 객체를 만듭니다.

  2. 2번 호출이 실패하면 서킷이 열리는 decorator 객체를 만듭니다.

  3. 반복되는 호출과 검증 동작을 private 메서드로 추출했습니다. 첫 번째 파라미터는 decorator.send(…​) 메서드의 기대 결과값이고, 두 번째는 target.send(String) 메서드가 실제 호출된 누적 횟수입니다.

  4. 첫 호출에도 리커버 메서드가 호출되어서 decorator.send(…​) 메서드에서는 false를 반환했습니다.

  5. 세 번째 시도이지만 서킷이 열려서 target.send(String) 메서드의 실제 호출 횟수는 2에서 더 증가하지 않았습니다.

  6. 서킷이 닫히는 상태가 될 때까지 대기합니다. circuitBreakerPolicy.setResetTimeout(300L) 코드로 지정한 시간보다 긴 310ms를 기다립니다.

  7. 다시 target.send(String)가 실행되어 누적 호출 횟수가 3으로 증가했습니다.

특정 횟수를 단순히 재시도했던 SimpleRetryPolicy와는 최대 호출 횟수와 리커버 동작의 실행 시점 등 전체적인 흐름이 다르다는 것을 알 수 있습니다.

3. 애너테이션과 AOP를 이용한 재시도

직접 RetryTemplate을 호출하지 않고도 애너테이션과 AOP를 이용해서 재시도를 할 수 있습니다. @Retryable, @Recover, @CircuitBreaker가 여기에 쓰이는 애너테이션입니다. 이를 통해 앞에서 만든 Decorator 클래스들로 구성한 재시도 정책을 동일하게 적용하면서도 더 짧은 코드로 구현해보겠습니다.

먼저 build.gradledependencies 단락에 다음과 같이 spring-aspects 모듈을 추가합니다.

build.gradle에서 spring-aspects 모듈 의존성 추가
    implementation 'org.springframework:spring-aspects'

Spring Retry의 AOP 관련 기능이 aspectjweaver 모듈을 런타임에 의존하고 있기 때문입니다. 위 선언이 없으면 이어질 실습을 진행할 때 org.aspectj.lang.annotation.Pointcut 클래스를 찾을 수 없다는 에러가 나옵니다.

3.1. @Retryable

@Retryable을 붙인 인터페이스를 NotificationRetryService로 정의하겠습니다. 앞 단락의 예제를 보존하기 위해서 NotificationService를 상속한 인터페이스를 따로 만들었습니다. NotificationService와 같은 패키지에 추가합니다.

@Retryable을 활용한 NotificationRetryService.java
import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.Retryable;

interface NotificationRetryService extends NotificationService {
	@Retryable(
		retryFor = RuntimeException.class, maxAttempts = 4,
		backoff = @Backoff(delay = 200L, multiplier = 2d, maxDelay = 600L)
	)
	boolean send(String message);
}

RetryTemplate.builder() 로 했을 때와 유사하게 애너테이션의 속성으로 대상이 되는 예외, 재시도 횟수와 BackOff 정책을 정의했습니다. @Retryable 애너테이션은 구현 클래스에 직접 붙일 수도 있습니다. 이 실습 프로젝트에서는 실제 원격 호출로 알림을 전송하는 구현 클래스가 상상 속에만 존재하기 때문에 인터페이스에 붙였습니다. 테스트용 구현체인 UnstableNotificationService 클래스에 직접 @Retryable을 붙이는 것도 자연스럽지 않습니다. 인터페이스를 따로 정의했기에 이를 구현한 여러 클래스에 동일한 재시도 정책을 적용할 수 있다는 장점이 생겼습니다.

테스트를 위해서 아래와 같이 UnstableNotificationService가 구현하는 인터페이스를 NotificationRetryService로 바꿉니다.

UnstableNotificationService의 implements 절 변경
public class UnstableNotificationService implements NotificationRetryService {

의도대로 재시도가 되는지 다시 테스트 코드로 확인하겠습니다. 원래의 UnstableNotificationService 클래스의 생성자로 바로 만든 객체가 아닌, 스프링의 AOP에 의해서 재시도 기능이 덧붙여진 객체를 만들어야 합니다. Spring Retry에서 제공하는 @EnableRetry 애너테이션이 이를 위해 필요합니다. 그리고 UnstableNotificationService를 애플리케이션 컨텍스트에 등록해서 스프링 컨테이너가 이 클래스에 AOP를 적용할 기회를 주어야 합니다. 이를 위해 테스트 전용 애플리케이션 컨텍스트를 구성하는 TestServiceConfig.java 파일을 'src/test/java' 디렉터리의 하위 경로에 추가합니다. 실제 운영에 쓰일 코드도 동일한 방식으로 적용이 가능합니다.

AOP 테스트를 위한 애플리케이션 컨텍스트 정의
@Configuration
@EnableRetry(proxyTargetClass = true) // (1)
public class TestServiceConfig {
	@Bean
	public UnstableNotificationService fail3Service() {
		return new UnstableNotificationService(3); // (2)
	}
}
  1. @Retryable 애너테이션이 붙은 클래스가 애플리케이션 컨텍스트에 등록되면 재시도 기능을 추가한 프록시 객체를 만들어줍니다.

  2. 3번째 시도까지 예외를 내는 테스트용 객체를 생성합니다.

위의 예제에서는 proxyTargetClass = true 속성을 통해서 인터페이스가 아닌 클래스를 확장한 프록시 객체로 AOP가 적용되도록 유도했습니다. 테스트에서 UnstableNotificationService 클래스 수준에서 정의된 getTryCount() 메서드를 활용하기 위해서입니다. 이 속성이 없으면 인터페이스 기반의 JDK 다이내믹 프록시가 생성되어, 인터페이스에 정의되지 않은 메서드는 프록시 객체에서 호출할 수 없습니다.

TestServiceConfig에서 정의한 애플리케이션 컨텍스트를 올려서 AOP가 적용된 UnstableNotificationService를 테스트하는 코드를 만들어보겠습니다. NotificationRetryServiceTest.java 파일로 이름을 붙였습니다.

NotificationRetryServiceTest에서 AOP가 적용된 객체를 대상으로 테스트
@SpringJUnitConfig(TestServiceConfig.class) // (1)
class NotificationRetryServiceTest {
	@Test
	void retry(@Qualifier("fail3Service") UnstableNotificationService fail3Service) { // (2)
		boolean success = fail3Service.send("Hello!");
		assertThat(fail3Service.getTryCount()).isEqualTo(4); // (3)
		assertThat(success).isTrue();
	}
}
  1. 앞서 만든 테스트용 애플리케이션 컨텍스트를 참조합니다.

  2. 이후에 같은 타입으로 여러 개의 빈(bean)을 등록할 계획이 있기에 @Qualifier로 이름을 명시했습니다.

  3. send(String) 메서드를 테스트에서 직접 호출한 것은 1번이지만 재시도가 3번 되어서 총 4번 실행되었습니다.

위의 결과를 보면 implements NotificationRetryService 선언만으로, NotificationRetryDecorator 클래스에서 직접 RetryTemplate을 써서 재시도 정책을 정의한 것과 동일한 효과를 낸다는 것을 알 수 있습니다. Decorator 클래스를 Spring Retry 모듈이 실행 시점에 자동으로 만들어준다고 이해할 수 있습니다. Spring Retry 안의 RetryOperationsInterceptor 클래스가 내부적으로 그런 역할을 합니다.

3.2. @Recover

RecoveryCallback 안에 담겨서 호출될 메서드도 @Recover 애너테이션으로 지정할 수 있습니다. NotificationRetryService 인터페이스에 false 값을 반환하는 디폴트 메서드를 추가하고 그 위에 @Recover를 붙입니다.

NotificationRetryService에 @Recover 메서드 추가
	Logger logger = LoggerFactory.getLogger(NotificationRetryService.class);

	@Recover // (1)
	default boolean recover(Throwable error, String message) { // (2)
		logger.warn("메시지 전송 최종 실패 후 recover : {}", message, error);
		return false;
	}
  1. 최종 재시도가 실패할 시 대신 호출될 메서드로 지정합니다.

  2. 마지막 재시도에서 발생한 예외와, 재시도 대상 메서드로 넘어왔던 파라미터의 값들을 전달받습니다.

@Recover 가 붙은 메서드의 첫 번째 파라미터를 Throwable 타입으로 선언하면 @Retryable 이 붙은 메서드에서 발생한 에러 객체를 전달받을 수 있습니다. 이후의 파라미터들은 @Retryable 이 붙은 메서드의 파라미터를 순서대로 선언하면 같은 값을 넘겨받을 수 있습니다.

@Recover를 붙인 메서드가 의도대로 호출되는지 테스트로 확인해보겠습니다. 앞서 NotificationRetryService.send(String) 메서드는 @Retryable 선언으로 총 4번까지 시도를 하므로, 호출이 4번까지 실패해야 recover(…​) 메서드가 호출됩니다. 이 테스트 시나리오를 위해 일부러 4번까지 예외를 내는 UnstableNotificationService를 애플리케이션 컨텍스트에 등록합니다. TestServiceConfig.java에 메서드를 추가하면 됩니다.

TestServiceConfig.java에서 4번 실패하는 UnstableNotificationService 등록
	@Bean
	public UnstableNotificationService fail4Service() {
		return new UnstableNotificationService(4);
	}

NotificationRetryServiceTest에서 위에서 만든 'fail4Service’를 호출해서 4번 재시도되는 결과를 확인합니다.

NotificationRetryServiceTest에 recover까지 검증하는 테스트 추가
	@Test
	void retryAndRecover(@Qualifier("fail4Service") UnstableNotificationService fail4Service) {
		boolean success = fail4Service.send("Hello!");
		assertThat(fail4Service.getTryCount()).isEqualTo(4);
		assertThat(success).isFalse(); // (1)
	}
  1. 예외 대신 recover(…​) 메서드의 결과인 false 값이 반환됩니다.

테스트를 실행하면 로그 메시지 중에서도 '메시지 전송 최종 실패 후 recover’라는 문자열이 보일 것입니다.

3.3. @CircuitBreaker

마지막으로 @CircuitBreaker 애너테이션을 알아보겠습니다. 앞에 나왔던 CircuitBreakerRetryPolicy를 활용했던 NotificationCircuitDecorator 예제와 동일한 기능을 더 간결한 코드로 만들 수 있습니다. NotificationCircuitService라는 새로운 인터페이스를 정의해서 @CircuitBreaker의 속성으로 대상 예외와 타임아웃 시간 등을 지정합니다.

@CircuitBreaker를 붙인 NotificationCircuitService 인터페이스
interface NotificationCircuitService {

	@CircuitBreaker(
		retryFor = RuntimeException.class, // (1)
		maxAttempts = 2, openTimeout = 200L, resetTimeout = 300L // (2)
	)
	boolean sendOnCircuit(String message); // (3)
}
  1. RuntimeException이 발생한 경우를 서킷 브레이커가 발동될 대상으로 합니다.

  2. 최대 2회 시도 후 실패하면 서킷이 열리고, 300ms가 지나면 서킷이 닫혀서 다시 실제 재시도 대상 메서드가 호출됩니다.

  3. 메서드명도 앞선 예제와 구분하기 위해 sendOnCircuit(String)으로 다르게 붙였습니다.

테스트로 이 동작을 확인해볼 FragileService라는 클래스를 만들겠습니다. UnstableNotificationService와 동일한 동작을 하는 클래스가 필요한데, 앞선 예제를 보존하기 위해 클래스를 하나 더 만듭니다. 메서드의 구현부는 동일하므로 실습 예제의 간결함을 위해 UnstableNotificationService 클래스를 상속하겠습니다.

FragileService.java
public class FragileService extends UnstableNotificationService implements NotificationCircuitService {
	public FragileService(int failures) {
		super(failures);
	}

	@Override
	public boolean sendOnCircuit(String message) {
		return super.send(message);
	}
}

AOP가 적용되도록 앞선 실습에서 만들어 둔 TestServiceConfig 클래스에서 FragileService 클래스의 인스턴스를 @Bean으로 등록합니다.

FragileService를 bean으로 등록
    @Bean
    public FragileService fail2Service() {
      return new FragileService(2); // (1)
    }
  1. 두 번은 의도적인 에러를 내도록 FragileService의 인스턴스를 생성합니다.

애플리케이션 컨텍스트가 로딩되는 순간 'fail2Service' 객체를 감싼, 서킷 브레이커 정책이 적용된 객체가 자동으로 생성됩니다. Spring Retry 내부의 StatefulRetryOperationsInterceptor 클래스가 그 역할을 합니다.

Decorator로 서킷 브레이커를 적용했던 NotificationCircuitDecoratorTest와 동일한 시나리오로 테스트를 해보겠습니다.

NotificationCircuitServiceTest.java
@SpringJUnitConfig(TestServiceConfig.class) // (1)
class NotificationCircuitServiceTest {

	@Autowired
	FragileService fail2Service;

	private void callAndAssert(boolean expectedResult, int expectedTryCount) { // (2)
		boolean success = fail2Service.sendOnCircuit("Hello!");
		assertThat(success).isEqualTo(expectedResult);
		assertThat(fail2Service.getTryCount()).isEqualTo(expectedTryCount);
	}

	@Test
	void recoveredWithCircuit() throws InterruptedException {
		callAndAssert(false, 1); // (3)
		callAndAssert(false, 2);
		callAndAssert(false, 2); // (4)
		TimeUnit.MILLISECONDS.sleep(310); // (5)
		callAndAssert(true, 3); // (6)
	}
}
  1. AOP가 적용된 FragileService를 참조할 수 있도록 애플리케이션 컨텍스트를 로딩합니다.

  2. NotificationCircuitDecoratorTest 예제의 것과 동일한 메서드입니다.

  3. 실제 sendOnCircuit(String) 메서드 호출에서는 예외가 났지만 recover(…​) 가 실행되어서 반환값은 false가 나옵니다.

  4. 차단기가 열린 이후부터는 더 이상 원래의 sendOnCircuit(String) 메서드가 호출되지 않아서 호출 횟수가 증가하지 않습니다.

  5. @CircuitBreakerresetTimeout 속성값보다 큰 시간이 지나갑니다. 이후로는 차단기가 닫힙니다.

  6. 더 이상 호출이 차단되지 않아서 원래의 sendOnCircuit(String) 메서드가 실행되고 실행 횟수가 증가합니다. 의도한 2번의 예외가 발생한 이후이므로 호출도 성공합니다.

4. RetryListener

RetryListenerRetryTemplate에 지정하면 재시도 과정의 각 시점에 개입할 수 있습니다.

RetryListener 인터페이스에는 다음과 같이 정의되어 있습니다.

RetryListener 인터페이스
package org.springframework.retry;

public interface RetryListener {
  default <T, E extends Throwable> boolean open(RetryContext context, RetryCallback<T, E> callback) {}
  default <T, E extends Throwable> void onError(RetryContext context, RetryCallback<T, E> callback, Throwable throwable) {}
  default <T, E extends Throwable> void close(RetryContext context, RetryCallback<T, E> callback, Throwable throwable) {}
  default <T, E extends Throwable> void onSuccess(RetryContext context, RetryCallback<T, E> callback, T result) {}
}

이 메서드들을 활용하면 재시도 사이클이 시작되는 시점, 재시도 중 에러가 나는 시점, 성공하는 시점, 재시도가 끝나는 시점에 개입하여 확장된 기능을 넣을 수 있습니다. 예를 들면 특정 조건에 따라서 재시도를 하지 않거나 멈추는 것도 가능합니다. RetryListener.open(…​) 메서드에서 false를 반환하거나 onError(…​) 안에서 RetryContext.setExhaustedOnly() 메서드를 호출하면 재시도는 더 이상 진행되지 않습니다.

RetryListener의 메서드가 호출되는 시점을 확인하는 예제를 만들어보겠습니다. RetryLoggingListener 라는 이름으로 각 메서드에서 로그 메시지를 남기는 클래스를 구현합니다.

RetryLoggingListener.java
import org.springframework.retry.RetryCallback;
import org.springframework.retry.RetryContext;
import org.springframework.retry.RetryListener;

public class RetryLoggingListener implements RetryListener {
	private final Logger logger = LoggerFactory.getLogger(RetryLoggingListener.class);

	@Override
	public <T, E extends Throwable> boolean open(RetryContext context, RetryCallback<T, E> callback) {
		logger.info("open : {}", context);
		return true;
	}

	@Override
	public <T, E extends Throwable> void onError(RetryContext context, RetryCallback<T, E> callback,
		Throwable throwable) {
		logger.warn("onError: {}", context, throwable);
	}

	@Override
	public <T, E extends Throwable> void close(RetryContext context, RetryCallback<T, E> callback,
		Throwable throwable) {
		logger.info("close : {}", context);
	}

	@Override
	public <T, E extends Throwable> void onSuccess(RetryContext context, RetryCallback<T, E> callback, T result) {
		logger.info("onSuccess : {}", result);
	}
}

이번 글에서 만든 NotificationRetryDecorator에 위의 리스너를 추가합니다.

NotificationRetryDecorator에서 리스너 추가
	public NotificationRetryDecorator(NotificationService target, int maxAttempts) {
		this.target = target;
		this.retryOperations = RetryTemplate.builder()
            // 중략
			.withListener(new RetryLoggingListener()) // 추가된 라인
			.build();
	}

RetryTemplate.setListeners(…​) 메서드를 호출하는 방법으로도 리스너를 지정할 수 있습니다.

여러 상황을 비교하기 위해 NotificationRetryDecoratorTest에 실패 없이 바로 성공하는 테스트 메서드도 추가합니다.

NotificationRetryDecoratorTest에서 한 번에 성공하는 테스트 케이스 추가
	@Test
	void successOnFirstTry() {
		NotificationService target = (message) -> {
			System.out.println("Message : " + message);
			return true;
		};
		var decorator = new NotificationRetryDecorator(target, 2);

		boolean success = decorator.send("hello");
		assertThat(success).isTrue();
	}

위의 테스트를 실행하면 콘솔에서 RetryLoggingListener가 남기는 로그 메시지가 보일 것입니다. 이를 통해 RetryContext가 어떤 정보를 담고 있는지도 대략 알 수 있습니다.

NotificationRetryDecoratorTest의 여러 메서드를 통해서 다음을 확인해볼 수 있습니다. 한 번에 타깃 메서드 호출이 성공하는 경우는 다음과 같이 RetryListener의 메서드가 호출됩니다. (NotificationRetryDecoratorTest.successOnFirstTry())

  1. RetryListener.open(…​)

  2. 재시도 대상 호출

  3. RetryListener.onSuccess(…​)

  4. RetryListener.close(…​)

한 번 이상 실패했지만 재시도로 성공한 경우의 호출 순서는 다음과 같습니다. (NotificationRetryDecoratorTest.successByRetry())

  1. RetryListener.open(…​)

  2. 재시도 대상 메서드 호출했으나 실패

  3. RetryListener.onError(…​)

  4. 2 ~ 3번 과정을 성공할 때까지 반복

  5. RetryListener.onSuccess(…​)

  6. RetryListener.close(…​)

재시도를 했어도 최종 실패하고 리커버 메서드가 실행되면 다음과 같습니다. (NotificationRetryDecoratorTest.recover())

  1. RetryListener.open(…​)

  2. 재시도 대상 메서드 호출했으나 실패

  3. RetryListener.onError(…​)

  4. 2 ~ 3번 과정을 최대 재시도 허용 횟수까지 반복

  5. 리커버 메서드 실행

  6. RetryListener.close(…​)

Note

Spring Batch에서도 StepBuilder.faultTolerant()listener(RetryListener)를 호출해서 재시도 리스너를 가진 스텝을 구성할 수 있습니다. 그런 스텝에서 참조되는 RetryListener의 메서드는 ItemProcessor, ItemWriter의 호출 전후에 2번 호출된다는 점도 유의해야 합니다.

5. 정리

이 글에서는 RetryTemplate을 직접 호출하는 방식과 AOP를 통한 방식, 두 가지 방법으로 재시도와 리커버, 서킷 브레이커 정책을 적용해보았습니다. AOP가 편리한 면이 있지만 트랜잭션 등 다른 AOP와 함께 적용되면 적용 순서 등도 신경 써야 합니다. 재시도를 명시적인 코드로 표현하기 위해 직접 RetryTemplate을 호출하는 방법도 유용할 때가 있습니다. AOP를 쓰더라도 RetryTemplate과 그와 연관된 콜백 인터페이스, 재시도 정책 구현체를 잘 알고 있으면 의도대로 정책이 적용되지 않을 때 문제를 빠르게 파악할 수 있습니다. Spring Retry 내부에서 재시도가 적용되는 프록시 객체를 만들어주는 RetryOperationsInterceptor, StatefulRetryOperationsInterceptor 클래스에 브레이크 포인트를 걸어서 'retryOperations' 멤버 변수에 어떤 정책이 어떤 속성으로 지정되었는지 추적해보는 문제 해결 방법도 유용합니다.

실패 후 재시도 등의 동작이 의도대로 동작하는지를 확인하는 테스트도 중요합니다. 평소가 아닌 문제나 장애가 발생할 때를 대비한 코드이므로, 의식적으로 실패 상황을 만들지 않는다면 제대로 동작할지 알 수 없습니다. 검증되지 않은 방어 코드는 실제 상황에서 오히려 문제를 더 심각하게 만들기도 합니다. 다양한 방법으로 테스트를 해야겠지만, 이 글에서 소개한 테스트용 가짜 객체를 만드는 방법도 선택지 중의 하나입니다.

6. 참고 자료


이 포스트는 Claude Code와 정상혁이 함께 작성했습니다.

Dell XPS 13 웹캠을 살린 커널 모듈 한 줄 패치

Dell XPS 13 9350(Lunar Lake)에서 웹캠이 동작하지 않던 원인을 한 달간 추적해서 Intel CVS 드라이버의 한 줄 버그를 찾아 DKMS로 패치하고, 업스트림에 반영되기까지의 과정을 정리합니다.

Dell XPS 13 9350(Lunar Lake)에 Ubuntu 24.04를 설치한 이후로 웹캠이 동작하지 않았습니다. Ubuntu 24.04 설치Ubuntu에서의 OEM 커널 설치 글에서 언급했던 그 문제입니다. 2026년 3월 6일부터 4월 10일까지 약 한 달간 원인을 추적한 끝에, Intel CVS 드라이버의 한 줄 버그가 원인임을 찾아 DKMS 패치로 해결했습니다. 이후 업스트림에 PR을 보냈고 2026년 5월에 머지되었습니다. 이 글에서는 그 과정에서 알게 된 Lunar Lake의 카메라 아키텍처, 잘못 짚었던 가설, 진짜 원인을 찾은 방법, 그리고 버그가 만들어진 업스트림 커밋 이력을 정리합니다.

1. Lunar Lake의 카메라 아키텍처

원인을 이해하려면 먼저 이 플랫폼의 카메라 구조를 알아야 합니다.

1.1. 전통적인 노트북 카메라 구조

일반적인 노트북 카메라는 단순한 경로를 따릅니다.

센서(OV02C10) ──MIPI CSI-2──→ ISP(IPU) ──→ V4L2 ──→ 앱
       ↑
  INT3472 (전원 관리: GPIO로 dvdd/avdd 제어)

INT3472 컨트롤러가 GPIO를 통해 센서에 전원을 공급하고, 센서는 MIPI CSI-2 레인으로 ISP(Image Signal Processor)에 직접 연결됩니다. 드라이버 스택이 성숙해서 대부분의 Linux 배포판에서 잘 동작합니다.

1.2. Lunar Lake의 Connected Camera(CVS) 모드

Intel Core Ultra 200V(Lunar Lake)는 Computer Vision Sensing(CVS)이라는 새로운 카메라 서브시스템을 도입했습니다. AI 비전 처리를 위한 저전력 전용 컨트롤러가 추가된 구조입니다.

센서(OV02C10) ──MIPI CSI-2──→ IPU7 ISP ──→ Camera HAL ──→ 앱
       ↑                          ↑
  CVS 컨트롤러(INTC10DE)          │
       ↑                     소유권 이전
  USBIO 브리지(INTC10B5)    (GPIO 핸드셰이크)
       ↑
    USB I2C

CVS 모드에서는 세 가지가 달라집니다.

  1. 전원 경로가 다릅니다. 센서는 INT3472가 아닌 CVS 컨트롤러 경로로 전원을 받습니다.

  2. 소유권 협상이 필요합니다. CVS 컨트롤러가 먼저 센서를 초기화하고, I2C 프로토콜 협상을 마친 뒤, GPIO 핸드셰이크로 IPU7에 센서 소유권을 넘깁니다.

  3. 프로토콜 버전 협상이 있습니다. CVS 컨트롤러와 센서 펌웨어 간에 매직 넘버(0xCAFEB0BA)로 프로토콜 버전(1.0 vs 2.0+)을 식별합니다.

1.3. 이 머신의 구성

ACPI 테이블을 직접 읽어서 확인한 이 머신의 카메라 구성은 다음과 같습니다.

ACPI 속성 의미

LCHS

1

Connected Camera 모드 활성

L1CL

0xFF

OVTI02C1은 INT3472에 의존하지 않음

LNK1._DEP

{CVSS, VIC1}

CVS 컨트롤러 + USB I2C 브리지에 의존

이 설정에서 OVTI02C1(OV02C10 센서)은 전원과 초기화를 전적으로 CVS 경로에 의존합니다. CVS probe가 실패하면 센서에 전원이 공급되지 않고, 그 뒤의 모든 단계가 멈춥니다.

2. 증상과 실패 체인

웹캠이 동작하지 않는 상태에서 /dev/video0 은 v4l2loopback 가상 디바이스일 뿐, 실제 카메라 디바이스는 없었습니다. dmesg에는 다음의 에러가 남았습니다.

Intel CVS driver i2c-INTC10DE:00: magic number in dev response not supported
Intel CVS driver i2c-INTC10DE:00: cvs_find_magic_num_support:Device protocol is 1.0
Intel CVS driver i2c-INTC10DE:00: cvs_common_probe:set_host_identifier cmd failed
Intel CVS driver i2c-INTC10DE:00: probe with driver Intel CVS driver failed with error -5
ov02c10 i2c-OVTI02C1:00: failed to find sensor: -6

실패의 연쇄를 따라가면 다음과 같습니다.

  1. CVS probe가 시작되고, cvs_find_magic_num_support() 함수가 이 장치의 펌웨어를 "프로토콜 1.0"으로 판정합니다(magic_num_support = false).

  2. 그런데도 드라이버는 cvs_write_i2c(SET_HOST_IDENTIFIER, NULL, 0) 명령을 무조건 호출합니다.

  3. 프로토콜 1.0 펌웨어는 이 명령을 이해하지 못해서 -EIO를 반환하고, CVS probe가 실패합니다.

  4. 센서 소유권 이전이 일어나지 않아 센서에 전원이 공급되지 않습니다.

  5. ov02c10 드라이버가 I2C로 센서 접근을 시도하지만 전원이 없어 -ENXIO로 실패하고, 웹캠을 쓸 수 없게 됩니다.

근본 원인은 한 줄입니다. 프로토콜 1.0 장치에 프로토콜 2.0+ 전용 명령(SET_HOST_IDENTIFIER)을 무조건 보내는 것입니다.

3. 잘못 짚었던 가설

처음부터 이 원인을 찾은 것은 아닙니다. dmesg에는 CVS 에러와 함께 int3472-discrete: GPIO type 0x02 unknown 이라는 에러도 있었습니다. 처음에는 이 INT3472 GPIO 에러를 원인으로 보고, discrete.c 에 'OVTI02C1 + 0x02 → DOVDD' 매핑을 추가하는 DKMS 패치를 작성했습니다. 빌드와 설치까지 했으나 재부팅 후에도 증상이 그대로였습니다.

이후 ACPI NVS 메모리를 직접 읽고 나서야 두 가지를 알게 되었습니다.

  • GPIO type 0x02 unknown 에러는 이 노트북에 함께 달려 있는 다른 센서(HIMX1092)에 대한 것이었고, OVTI02C1과는 무관했습니다.

  • LCHS=1 (Connected Camera 모드)에서 OVTI02C1은 INT3472가 아닌 Intel CVS 경로로 전원을 받습니다.

에러 메시지가 여러 개 보일 때, 눈에 먼저 띄는 에러가 내 문제의 원인이라는 보장이 없다는 교훈을 얻었습니다. INT3472 패치는 폐기하고 CVS 드라이버 분석으로 방향을 바꿨습니다.

그 외에도 시도했다가 막힌 경로들이 있었습니다.

시도 결과

커널 6.11/6.14로 다운그레이드

동일한 원인으로 동일한 증상

media-ctl + v4l2-ctl --stream-mmap 직접 캡처

STREAMON 실패 (IPU7은 PSys 경유가 필요)

libcamera (cam --list)

IPU7 pipeline handler가 아직 없음

4. 버그가 만들어진 과정

Intel이 공개한 intel/vision-drivers 저장소의 커밋 이력을 추적하면 버그가 만들어진 과정이 보입니다.

날짜 커밋 내용 SET_HOST_IDENTIFIER 상태

2024-10-22

93244d7

SET_HOST_IDENTIFIER 명령 최초 추가

if (!i2c_shared) 가드 (부분적 보호)

2024-11-26

23f3733

magic_num_support 필드 도입, 매직 넘버 검증 추가

변경 없음

2025-03-12

69d20a1

코드 정리 중 i2c_shared 가드 제거

무조건 호출로 변경

2025-06-04

14d0181

"Fix I2C read for protocol 1.0 firmware"

cvs_get_device_cap 만 가드, SET_HOST_IDENTIFIER 누락

버그는 두 단계에 걸쳐 만들어졌습니다.

첫 번째 단계는 69d20a1 (2025-03-12)입니다. "Remove LJCA WA | Code Cleanup"이라는 커밋에서 코드 정리를 하면서 i2c_shared 조건 분기를 제거했습니다.

-  if (!icvs->i2c_shared) {
-      ret = cvs_write_i2c(SET_HOST_IDENTIFIER, NULL, 0);
-      ...
-  }
+  ret = cvs_write_i2c(SET_HOST_IDENTIFIER, NULL, 0);

원래 i2c_shared는 완벽한 가드는 아니었지만, 일부 장치에서 SET_HOST_IDENTIFIER 호출을 건너뛸 수 있게 해줬습니다. 이 제거로 SET_HOST_IDENTIFIER가 모든 장치에서 무조건 실행되게 바뀌었습니다.

두 번째 단계는 14d0181 (2025-06-04)입니다. "Fix I2C read for protocol 1.0 firmware"라는 제목으로 프로토콜 1.0 지원을 추가한 커밋입니다. cvs_find_magic_num_support() 함수를 만들어 프로토콜 버전을 감지하고, cvs_get_device_cap() 호출은 magic_num_support 조건으로 감쌌습니다. 하지만 바로 아래의 SET_HOST_IDENTIFIER 호출은 같은 조건으로 감싸는 것을 빠뜨렸습니다.

+  ret = cvs_find_magic_num_support(icvs);
+  if (ret)
+      goto exit;
+
+  if (icvs->magic_num_support) {
+      ret = cvs_get_device_cap(&icvs->cv_fw_capability);
+      if (ret)
+          goto exit;
+  }
+
   ret = cvs_write_i2c(SET_HOST_IDENTIFIER, NULL, 0);  // ← 가드 없음

같은 함수 안에서, 같은 패턴의 가드가 필요한 두 호출 중 하나만 수정한 전형적인 누락입니다.

5. 패키지 업데이트로는 해결되지 않는 이유

Ubuntu의 linux-modules-vision-*-oem 패키지는 Intel 업스트림의 특정 시점 스냅샷을 빌드한 것입니다. 당시 설치되어 있던 6.17.0-1017.17 패키지는 업스트림 HEAD를 기반으로 했지만, 업스트림 자체에 버그가 있으므로 패키지를 아무리 업데이트해도 수정되지 않는 상황이었습니다.

패키지 모듈에 수정이 포함되었는지는 바이너리 역어셈블(objdump)로 확인했습니다. Ubuntu 패키지의 intel_cvs.ko와 패치를 적용한 DKMS 빌드의 cvs_common_probe 함수를 비교하면, SET_HOST_IDENTIFIER 호출(mov edi, 0x805) 직전에 magic_num_support 검사와 조건 점프가 있는지가 다릅니다. 이 확인 기법은 Linux 바이너리 역어셈블로 커널 모듈 패치 확인하기 글에서 따로 정리했습니다.

6. 해결 방법

6.1. 한 줄 패치

수정 자체는 한 줄의 if 문 추가입니다. 기존 cvs_get_device_cap() 가드와 완전히 동일한 패턴입니다.

-		ret = cvs_write_i2c(SET_HOST_IDENTIFIER, NULL, 0);
-		if (ret) {
-			dev_err(cvs->dev, "%s:set_host_identifier cmd failed", __func__);
-			goto exit;
+		if (icvs->magic_num_support) {
+			ret = cvs_write_i2c(SET_HOST_IDENTIFIER, NULL, 0);
+			if (ret) {
+				dev_err(cvs->dev, "%s:set_host_identifier cmd failed", __func__);
+				goto exit;
+			}
 		}

6.2. DKMS로 패키지 모듈 오버라이드

업스트림 소스에 위의 패치를 적용하고 DKMS(vision-driver/1.0.0)로 빌드해서 설치했습니다. DKMS 모듈은 /lib/modules/<커널 버전>/updates/dkms/ 경로에 설치되는데, 이 경로가 패키지 모듈 경로(ubuntu/vision/)보다 우선 로드됩니다. 패키지를 건드리지 않고 모듈만 바꿔치기할 수 있고, 커널이 업데이트되어도 DKMS가 자동으로 다시 빌드해줍니다.

install-vision-dkms.sh
#!/bin/bash
set -e

SRC="$HOME/work/vision-drivers"  # 패치 적용한 소스 위치
DEST="/usr/src/vision-driver-1.0.0"

sudo cp -r "$SRC" "$DEST"
sudo rm -rf "$DEST/.git"

sudo dkms add vision-driver/1.0.0
sudo dkms build vision-driver/1.0.0
sudo dkms install vision-driver/1.0.0

재부팅 후 실제 로드된 모듈이 DKMS 빌드인지 확인합니다.

modinfo intel_cvs | grep filename
# OK:   /lib/modules/.../updates/dkms/intel_cvs.ko.zst
# FAIL: /lib/modules/.../ubuntu/vision/intel_cvs.ko.zst (패키지 원본)

journalctl -k -b 0 --no-pager | grep -E "cvs|CVS|INTC10DE" | head -10
# "Transfer of ownership success" 확인

6.3. 권한 설정과 캡처 확인

패치만으로는 부족하고 권한 설정도 필요했습니다. 사용자를 video 그룹에 추가하고, IPU7 PSys 장치의 권한을 udev 규칙으로 지정합니다.

sudo usermod -aG video $USER

echo 'SUBSYSTEM=="intel-ipu7-psys", MODE="0660", GROUP="video"' \
  | sudo tee /etc/udev/rules.d/50-ipu7.rules

IPU7은 아직 libcamera pipeline handler가 없어서 cam --list 에 표시되지 않습니다. Intel Camera HAL(libcamhal-ipu7x)과 GStreamer의 icamerasrc 플러그인으로 캡처를 확인했습니다.

gst-launch-1.0 icamerasrc device-name=ov02c10-uf num-buffers=3 \
  ! "video/x-raw,format=NV12" ! jpegenc ! filesink location=/tmp/test.jpg

이 세 가지를 모두 적용한 뒤에 1920x1080 캡처에 성공했습니다.

7. 왜 전에는 됐다가 안 됐을까

Dell이 이 모델을 출시할 때는 당연히 카메라가 동작하는 상태로 테스트했을 것입니다. 무엇이 바뀌었을까요?

가장 유력한 추정은 Ubuntu OEM 커널의 vision-drivers 스냅샷 시점입니다. Ubuntu OEM 커널 패키지가 빌드되는 시점에 따라 포함되는 vision-drivers 버전이 달라집니다. Dell 출하 시점의 Ubuntu는 SET_HOST_IDENTIFIER가 추가되기 전이거나 i2c_shared 가드가 있던 버전의 vision-drivers를 사용했을 가능성이 높습니다. 이후 OEM 커널 업데이트로 새 스냅샷이 적용되면서, i2c_shared 가드가 제거된(69d20a1 이후) 버전이 들어온 것으로 보입니다.

센서 펌웨어 업데이트나 모듈 로드 순서 변경 같은 다른 가능성도 검토했지만, 펌웨어를 업데이트한 이력이 없고 에러의 성격도 달라서 가능성이 낮다고 판단했습니다.

8. 왜 널리 보고되지 않았을까

이 버그가 광범위하게 보고되지 않은 이유도 짐작해볼 수 있습니다.

  1. Lunar Lake 자체가 새로운 플랫폼입니다. 2024년 하반기에 출시되어 Linux 사용자 기반이 아직 작습니다.

  2. CVS 모드는 일부 구성에서만 활성화됩니다. 같은 Lunar Lake라도 LCHS=0 (전통 모드)이면 CVS 드라이버를 거치지 않습니다.

  3. 프로토콜 1.0이 구형입니다. 최신 CVS 펌웨어는 프로토콜 2.0+를 지원하므로 SET_HOST_IDENTIFIER가 성공합니다. Dell XPS 13 9350의 OV02C10은 프로토콜 1.0 펌웨어를 사용하는, 상대적으로 드문 조합입니다.

  4. Windows에서는 드라이버 스택이 다릅니다. Intel은 Windows용 드라이버를 별도로 관리하므로, Linux 전용 업스트림 저장소의 버그가 Windows에서는 나타나지 않습니다.

Intel 내부 개발팀이 주로 프로토콜 2.0+ 장치로 테스트하기 때문에, 프로토콜 1.0 장치에서만 발생하는 이 문제를 발견하지 못한 것으로 추정됩니다.

9. 업스트림 반영

로컬 DKMS 패치는 커널 업데이트 때마다 유지보수 부담이 남습니다. 근본적인 해결을 위해 2026년 4월 10일에 intel/vision-drivers#35로 수정 PR을 제출했습니다. 이후 같은 수정에 인접한 수정 두 건(cvs_init() 에러 전파, find_oem_prod_id 에서의 ACPI_HANDLE() 사용)을 묶은 #38로 정리해서 다시 올렸고, 2026년 5월 7일에 머지되었습니다.

업스트림에 머지된 수정이 Ubuntu의 vision-drivers 스냅샷 갱신을 거쳐 OEM 커널 패키지로 릴리스되면 DKMS 모듈은 제거할 수 있습니다. 패키지 모듈에 수정이 포함되었는지는 앞서 소개한 역어셈블 방법이나 strings 명령으로 확인할 수 있습니다.

10. 참고 자료


이 포스트는 Claude Code와 정상혁이 함께 작성했습니다.