개요
Spring Framework 7.0에는 Resilience(회복력) 기능이 spring-core에 추가되었다. 7.0 이전 Spring 개발팀은 Spring Retry 프로젝트(https://github.com/spring-projects/spring-retry)를 별도의 프로젝트로 제공했으나 Spring Framework 7.0 버전 출시에 맞춰 불필요한 기능을 정리하고 일부 API를 개선하여 spring-core 패키지로 추가했다.
상세한 내용은 공식 문서를 참고한다.(https://docs.spring.io/spring/reference/7.0-SNAPSHOT/core/resilience.html)
이번 게시글에서는 Spring Framework 7.0에 포함된 Resilience Features 기능들에 대하여 하나씩 알아보도록 한다.
Getting Started
이번 게시글의 예제에서는 재시도(@Retryable) 기능과 동시성 제어(@ConcurrencyLimit) 기능에 대하여 Spring MVC 프로젝트를 생성하여 알아보도록 한다.
프로젝트 생성
Spring Framework 7.0은 2025년 11월 출시 예정으로 작성일 기준 7.0.0-M7 버전을 사용할 수 있고, Spring Framework 7.0을 사용하는 Spring Boot 4.0.0-SNAPSHOT 버전을 사용하면 Spring Framewrok 7.0.0-M7 버전을 사용할 수 있다.
Spring Boot 4.0 프로젝트를 생성하도록 하자.
plugins {
id 'java'
id 'org.springframework.boot' version '4.0.0-SNAPSHOT'
id 'io.spring.dependency-management' version '1.1.7'
}
group = 'org.igooo'
version = '0.0.1-SNAPSHOT'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
repositories {
mavenCentral()
maven { url = 'https://repo.spring.io/snapshot' }
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
tasks.named('test') {
useJUnitPlatform()
}
Resilience 기능 확인을 위한 코드 작성
ResilienceController
- retry 테스트를 위한 /retry API를 추가한다.
- 동시성 제어를 위한 /concurrency API를 추가한다.
ResilienceService
- retry() : 호출마다 retryCount 변수를 1씩 증가시키면서 retryCount가 3의 배수일 때 retryCount를 리턴하고 나머지는 RuntimeException을 발생시킨다.
- cocurrency() : 호출마다 cocurrencyCount 변수를 1씩 증가 시키고 cocurrencyCount값을 리턴한다.
@RestController
class ResilienceController {
private final ResilienceService resilienceService;
public ResilienceController(ResilienceService resilienceService) {
this.resilienceService = resilienceService;
}
@GetMapping("/retry")
Map<String, Object> retry() {
return Map.of("code", this.resilienceService.retry());
}
@GetMapping("/concurrency")
Map<String, Object> concurrency() {
return Map.of("code", this.resilienceService.concurrency());
}
}
@Service
class ResilienceService {
private static Logger log = LoggerFactory.getLogger(ResilienceService.class);
private int retryCount = 0;
private int concurrencyCount = 0;
int retry() {
this.retryCount++;
log.info("retryCount {}", this.retryCount);
if (this.retryCount % 3 != 0) {
throw new RuntimeException("retryCount " + this.retryCount);
}
return this.retryCount;
}
int concurrency() {
return concurrencyCount++;
}
}
서버를 실행하고 각각 API를 호출하면 아래와 같이 동작한다.
- /retry : API 호출을 3의 배수로 시도할때만 정상적으로 응답한다.
- /concurrenct : API를 호출하면 0부터 1씩 증가된 응답 결과를 확인할 수 있다.
위에 생성한 2개의 API의 작동방식을 각각 알아봤으니 Spring Framework 7.0의 Resilience 기능을 추가하여 새로운 기능에 대하여 알아본다.
@Retryable를 사용하여 회복력 있는 코드로 변경하기
@Retryable 어노테이션은 적용된 메서드에 Exception이 발생하면 지정된 횟수와 재시도 시간으로 자동으로 재시도를 한다.
RuntimeException을 발생시키는 ResilienceService 클래스의 retry() 메서드에 @Retryable 어노테이션을 추가해보자.
@Service
class ResilienceService {
private static Logger log = LoggerFactory.getLogger(ResilienceService.class);
private int retryCount = 0;
private int concurrencyCount = 0;
@Retryable
int retry() {
this.retryCount++;
추가로 @Retryable 어노테이션을 사용하기 위해서는 @EnableResilientMethods 어노테이션도 추가가 필요하다
@SpringBootApplication
@EnableResilientMethods
public class Spring7ResilienceApplication {
다시 서버를 실행하고 /retry API를 호출해 보면 기존에는 RuntimeException이 발생하고 3번 시도해야 정상적으로 응답을 받았지만 수정 후에는 첫 번째 API 호출부터 code 값이 3으로 나오고 정상적으로 응답된 것을 확인할 수 있다. (응답 시간은 약 3초 정도 걸린다.)
{"code":3}
서버 로그를 확인해 보면 retry() 메서드에서 예외가 발생되어 자동으로 1초에 한 번씩 메서드가 재실행되고, 작성된 코드에 따라서 retryCount 변수에 값이 3의 배수가 된 경우에 정상적으로 메서드가 응답한 것을 알 수 있다.
INFO 1960 --- [spring7-resilience] [nio-8080-exec-1] org.igooo.resilience.ResilienceService : retryCount 1
INFO 1960 --- [spring7-resilience] [nio-8080-exec-1] org.igooo.resilience.ResilienceService : retryCount 2
INFO 1960 --- [spring7-resilience] [nio-8080-exec-1] org.igooo.resilience.ResilienceService : retryCount 3
@Retryable 어노테이션을 추가하면 기본으로로 3번 재시도하고, 재시도 사이 간격은 1초로 재실행된다.
또한 아래와 같이 재시도를 위한 여러 옵션을 추가할 수 있고 상세한 설정은 @Retryable 문서를 참고한다.
@Retryable(maxAttempts = 5, delay = 100, jitter = 10, multiplier = 2, maxDelay = 1000)
@ConcurrencyLimit을 사용하여 동시성 제어하기
@ConcurrencyLimit 어노테이션은 개별 메서드에 대하여 동시성을 제어하는 기능을 제공한다.
ResilienceService 클래스의 concurrency() 메서드는 concurrencyCount 변수를 1씩 증가한다. 순차적으로 메서드를 호출하면 호출한 만큼 concurrencyCount 변수에 값이 증가하지만, 멀티 스레드(Multi Thread) 환경에서는 concurrencyCount 변수에 대하여 동기화(ex synchronized, ReenterantLock,...) 처리를 하지 않아서 concurrencyCount 변수의 값은 호출된 메서드 수 보다 적을 수 있다. (멀티 스레드 관련 내용은 이전 포스팅을 참고한다. https://blog.igooo.org/134)
concurrency() 메서드에 대해여 동시성을 적용하기 전에 위에 코드가 어떻게 동작하는지 확인해보자. /concurrency API에 부하 발생기를 사용하여 동시에 많은 요청을 주고, 다시 API를 호출하여 concurrencyCount 값을 확인한다.
예제에서는 이전에 소개했던 oha(https://blog.igooo.org/173) 부하 발생기를 사용하여 테스트를 진행한다. 부하 발생기를 사용하여 API를 1000번 호출하고, 실행이 끝나고 API를 호출하여 값을 확인한다.
부하 발생기 실행이 완료된 이후 API를 호출해 보면 API 응답값이 1000보다 작은 것을 볼 수 있다. (값은 테스트 환경마다 다를 수 있다. M4 MacBook Pro에서는 아래와 같이 API 응답 결과를 볼 수 있었다.)
{"code":947}
이제 Spring Framework 7.0의 @ConcurrencyLimit을 사용하여 concurrency() 메서드를 수정해 보자.
방법은 매우 간단한다. 메서드 위에 @ConcurrencyLimit 어노테이션만 추가해 주면 된다. @ConcurrencyLimit도 @EnableResilientMethods 어노테이션을 추가해야 사용가능하지만 retry() 메서드 테스트를 하면서 추가했기 때문이 넘어간다.
@ConcurrencyLimit
int concurrency() {
return concurrencyCount++;
}
다시 서버를 실행하고 동일하게 부하 발생기로 1000번 호출하고 API를 호출하여 결과를 확인한다.
1000번 부하를 주고 다시 API를 호출해 보면 이번에는 API 응답으로 1000이 응답된 것을 확인할 수 있다.
{"code":1001}
하지만 내부적으로 동기화가 로직이 추가되는 것으로 부하 발생기에 실행 시간을 비교해 보면 적용 전 0.0718 secs에서 0.0825 secs로 조금 상승한 것을 볼 수 있다.
@ConcurrencyLimit 어노테이션을 추가하면 기본적으로 1개의 스레드만 메서드를 호출할 수 있다. 그러나 아래 코드와 같이 임의에 수를 지정하여 동시에 접근하는 스레드의 수를 지정할 수도 있다.
@ConcurrencyLimit(10)
마무리
Resilience 기능은 라이브 서비스에도 유용하게 사용할 수 있는 기능이다. Spring Framework 7.0에는 Resilience 기능이 기본으로 추가된다고 하니까 7.0 버전 출시 이후에는 위 게시글 내용을 참고하여 서비스에 적용해 보도록 하자.
전체 소스코드는 GitHub에서 확인할 수 있습니다.
'dev > spring' 카테고리의 다른 글
Jackson 3.0.0 알아보기 (0) | 2025.07.15 |
---|---|
Spring Batch에서 JobParameter사용하기 (0) | 2025.06.17 |
Spring Boot에서 Project Leyden을 사용하는 방법 (2) | 2025.06.09 |
Spring AI MCP with IntelliJ IDEA (0) | 2025.05.26 |
MCP with Spring Boot (0) | 2025.04.04 |