Spring 7 내장 Resilience 사용기
들어가며
Spring Framework 7 에 Resilience 기능이 들어왔습니다. 메서드에 어노테이션 하나로 동시 실행 수를 묶는 @ConcurrencyLimit, 재시도를 거는 @Retryable, 코드로 재시도를 쓰는 RetryTemplate 입니다. 그동안 재시도는 spring-retry 라는 별도 라이브러리가 맡아 왔는데, 이제 본체에 있습니다.
저는 퇴근하고 혼자 신나게마을이라는 동네 데이터 사이트를 만들고 있습니다. 매일 새벽 배치가 공공데이터 API 를 수천 번 부르는데, 지금은 재시도는 spring-retry, 동시성 제한은 Spring 7 의 @ConcurrencyLimit 을 섞어 쓰고 있습니다.
이 글은 두 부분입니다.
- 1부: 지금 실제로 쓰고 있는 모습입니다. 어노테이션마다 속성을 설명하고, 써 보면서 밟은 함정을 붙였습니다.
- 2부: 이걸 Spring 7 내장으로 옮긴다면 무엇을 어떤 순서로 바꿔야 하는지, 단계마다 전후 코드로 보여 줍니다. 아직 운영 코드를 옮긴 것은 아니지만, 2부의 코드는 전부 Spring 7.0.3 에서 시험으로 컴파일하고 돌려 본 것입니다.
예시 코드는 실제 코드를 바탕으로 이름과 세부를 바꾼 것입니다. 속성 이름과 기본값은 Spring 7.0.3 jar 와 공식 Javadoc 에서 확인했습니다.
환경
| 항목 | 내용 |
|---|---|
| 언어, 프레임워크 | Java 25, Spring Boot 4.0.2 (Spring Framework 7.0.3), Spring Batch 6.0.2 |
| 스레드 | spring.threads.virtual.enabled: true |
| HTTP 클라이언트 | RestClient + HTTP Interface(@GetExchange) |
| 재시도 | spring-retry 2.0.12 |
| 동시성 제한 | Spring 7 @ConcurrencyLimit |
---
1부. 지금 쓰고 있는 모습
1-1. 어노테이션이 두 갈래다
| spring-retry | Spring Framework 7 내장 | |
|---|---|---|
| 정체 | Spring 본체와 따로 있는 별도 프로젝트 | Spring 7 에 새로 들어온 Resilience 기능 |
| 패키지 | org.springframework.retry.annotation | org.springframework.resilience.annotation |
| 켜는 스위치 | @EnableRetry | @EnableResilientMethods |
| 재시도 | @Retryable, @Backoff, @Recover | @Retryable (이름만 같고 다른 어노테이션) |
| 동시성 제한 | 없음 | @ConcurrencyLimit |
저는 spring-retry 를 직접 넣은 적이 없습니다. 확인해 보니 Spring Batch 6.0.2 가 spring-retry 2.0.12 를 끌고 들어와 있었고, 예전부터 쓰던 @Retryable 이 그쪽 것이었습니다. 그 위에 Spring 7 의 @ConcurrencyLimit 을 얹으면서 두 갈래가 섞였습니다. 어느 쪽인지는 import 를 보면 바로 압니다.
import org.springframework.retry.annotation.Retryable; // spring-retry
import org.springframework.resilience.annotation.Retryable; // Spring 7 내장
1-2. 켜기
@EnableRetry // spring-retry 의 @Retryable 을 켠다
@EnableResilientMethods // Spring 7 의 @ConcurrencyLimit 을 켠다
@SpringBootApplication
public class BatchApplication {
public static void main(String[] args) {
SpringApplication.run(BatchApplication.class, args);
}
}
@EnableRetry: spring-retry 의 @Retryable 을 켭니다. 없으면 @Retryable 이 그냥 무시됩니다.
@EnableResilientMethods: Spring 7 의 @ConcurrencyLimit 과 @Retryable 을 같이 켭니다. 하나만 켜고 싶으면 ConcurrencyLimitBeanPostProcessor 나 RetryAnnotationBeanPostProcessor 를 빈으로 직접 등록해도 됩니다.
써 보며 알게 된 것: 스위치를 빼먹으면 조용히 무시된다
처음에는 @EnableRetry 만 있었습니다. 그 상태로 @ConcurrencyLimit(limit = 10) 을 붙였는데, 컴파일도 되고 에러도 로그도 없이 동시성 제한이 아예 동작하지 않았습니다. 배치 파티션 20개가 공공 API 를 동시에 부르다 게이트웨이 차단을 받았고, 값을 10에서 2로 낮춰도 그대로였습니다. 값을 바꿔도 아무것도 안 바뀐다는 게 단서였습니다.
출신이 다른 어노테이션은 켜는 스위치도 다릅니다. 그리고 켜졌는지는 동작으로 확인하는 게 확실합니다. 1초 걸리는 메서드에 limit = 1 을 걸고 동시에 두 번 부르면, 켜져 있을 때 차례로 돌아 약 2초, 꺼져 있을 때 동시에 돌아 약 1초가 걸립니다. 실제로 돌려 보니 2.01초였습니다.
@SpringBootTest
@Import(ConcurrencyActiveTest.SlowProbe.class)
class ConcurrencyActiveTest {
@Autowired
private SlowProbe probe;
@Test
void 동시성_제한이_실제로_켜져_있다() throws Exception {
long t0 = System.nanoTime();
Thread a = Thread.ofVirtual().start(probe::slow);
Thread b = Thread.ofVirtual().start(probe::slow);
a.join();
b.join();
long ms = (System.nanoTime() - t0) / 1_000_000;
// 꺼져 있으면 두 호출이 동시에 돌아 약 1초에 끝난다
assertThat(ms).isGreaterThanOrEqualTo(1800);
}
static class SlowProbe {
@ConcurrencyLimit(limit = 1)
public void slow() {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
}
1-3. 호출기 전체 모습
공공 API 페이지 한 건을 부르는 컴포넌트입니다. 지금 배치의 실제 모양과 같은 구조입니다.
@Slf4j
@Component
public class PublicApiPageCaller {
/** 동시 호출 수. 재빌드 없이 조정하려고 설정값으로 받는다(지금 운영값 4). */
private static final String CONCURRENCY = "${api.public-data.apt.concurrency:2}";
/** 오류 응답을 받은 뒤 잠깐 쉰다. 되먹임 가속을 끊는 최소한의 브레이크. */
private static final long ERROR_PAUSE_MILLIS = 500L;
private final PublicDataClient client;
/** 지금 이 컴포넌트에서 진행 중인 호출 수. 동시성 제한이 먹는지 로그로 보이게 한다. */
private final AtomicInteger inFlight = new AtomicInteger();
/** 다음 호출이 나갈 수 있는 시각(nanoTime). 모든 스레드가 같이 쓴다. */
private final AtomicLong nextSlotNanos = new AtomicLong(System.nanoTime());
/** 호출 사이 최소 간격. 초당 호출 수에서 계산한다. */
private final long slotIntervalNanos;
public PublicApiPageCaller(PublicDataClient client,
@Value("${api.public-data.apt.requests-per-second:10}") int requestsPerSecond) {
this.client = client;
this.slotIntervalNanos = 1_000_000_000L / Math.max(1, requestsPerSecond);
}
@ConcurrencyLimit(limitString = CONCURRENCY)
@Retryable(
retryFor = { Exception.class },
maxAttempts = 5,
backoff = @Backoff(delay = 2000, multiplier = 3)
)
public String fetchPage(String regionCode, String yearMonth, int pageNo) {
acquireSlot();
int concurrent = inFlight.incrementAndGet();
try {
String body = client.getPage(regionCode, yearMonth, pageNo, 1000).getBody();
if (isErrorResponse(body)) {
log.warn("오류 응답. region={} page={} 동시={}", regionCode, pageNo, concurrent);
pause(ERROR_PAUSE_MILLIS);
throw new IllegalStateException("API 오류 응답 region=" + regionCode + " page=" + pageNo);
}
return body;
} finally {
inFlight.decrementAndGet();
}
}
@Recover
public String recover(Exception e, String regionCode, String yearMonth, int pageNo) {
// 재시도를 다 쓰고도 실패. null 을 돌려주면 호출부가 페이지 루프를 끝내므로
// 이 페이지부터 뒤가 빠진 채 job 은 "성공" 으로 끝난다. 조용한 누락이라 여기서는 반드시 ERROR 와 스택을 남긴다.
log.error("최종 실패 - 이 페이지 이후가 수집에서 빠집니다. region={} ym={} page={}",
regionCode, yearMonth, pageNo, e);
return null;
}
/** 자기 차례가 올 때까지 기다린다. 설정한 초당 호출 수를 넘지 않게 한다. */
private void acquireSlot() {
long now = System.nanoTime();
long myTurn = nextSlotNanos.updateAndGet(prev -> Math.max(prev, now) + slotIntervalNanos);
long waitNanos = (myTurn - slotIntervalNanos) - now;
if (waitNanos > 0) {
pause(waitNanos / 1_000_000L);
}
}
private void pause(long millis) {
try {
Thread.sleep(millis); // 가상 스레드라 OS 스레드를 붙잡지 않는다
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("대기 중 인터럽트", e);
}
}
private boolean isErrorResponse(String body) {
return body == null || body.contains("<errMsg>") || body.contains("\"resultCode\":\"99\"");
}
}
설정은 이렇습니다.
api:
public-data:
apt:
requests-per-second: 40 # 초당 상한. acquireSlot() 이 쓴다
concurrency: 4 # 동시 호출 수. @ConcurrencyLimit 이 쓴다
이 클래스를 부르는 쪽(페이지를 도는 루프)은 서비스에 따로 있습니다.
for (int pageNo = 1; ; pageNo++) {
String body = caller.fetchPage(regionCode, yearMonth, pageNo);
if (body == null) {
break; // @Recover 가 null 을 돌려줬다 = 최종 실패
}
List<Deal> deals = parse(body);
save(deals);
if (deals.size() < 1000) {
break; // 마지막 페이지
}
}
왜 별도 빈인가. @ConcurrencyLimit 과 @Retryable 은 둘 다 Spring AOP 프록시로 동작합니다. 같은 클래스 안의 메서드가 자기 클래스의 메서드를 부르면 프록시를 안 거쳐서 재시도도, 동시성 제한도 안 걸립니다. 그래서 루프는 서비스에, 한 건 호출은 PublicApiPageCaller 라는 별도 컴포넌트에 둡니다.
1-4. 이렇게 동작한다
호출 한 건이 지나가는 길
서비스가 caller.fetchPage(...) 를 부르면, 실제로는 Spring 이 만든 프록시를 거쳐 이 순서로 지나갑니다.
서비스의 페이지 루프
│ caller.fetchPage(region, ym, page)
▼
① 재시도 (spring-retry @Retryable) ← 가장 바깥. 예외가 나오면 여기서 잡아 다시 들여보낸다
│
▼
② 동시성 제한 (@ConcurrencyLimit, 2자리) ← 자리가 없으면 여기서 기다린다
│
▼
③ fetchPage 본문
acquireSlot() 초당 40건을 넘지 않게 자기 차례까지 기다린다
HTTP 호출 연결 5초, 응답 15초 넘으면 예외
오류 응답이면 0.5초 쉬고 예외를 던진다
정상이면 본문을 돌려준다
호출기 코드에서 해당하는 줄만 뽑으면 이렇습니다.
@ConcurrencyLimit(limitString = CONCURRENCY) // ② 동시성 제한
@Retryable(retryFor = { Exception.class }, maxAttempts = 5,
backoff = @Backoff(delay = 2000, multiplier = 3)) // ① 재시도
public String fetchPage(String regionCode, String yearMonth, int pageNo) { // ③ 본문
acquireSlot(); // 초당 차례 기다리기
...
if (isErrorResponse(body)) {
pause(ERROR_PAUSE_MILLIS); // 오류 뒤 0.5초 쉬기
throw new IllegalStateException(...); // → ① 이 잡아서 다시 시도
}
return body;
}
코드에 적힌 어노테이션 순서는 감싸는 순서와 상관없습니다. 위에서는 @ConcurrencyLimit 이 먼저 적혀 있지만 실제로는 ①이 바깥, ②가 안쪽입니다. 어느 쪽이 바깥인지는 Spring 이 프록시를 만드는 방식이 정하므로 돌려 봐야 압니다. 시험 코드는 아래 "재시도 대기 동안 동시성 자리를 쥐고 있으면?" 에 있습니다. 이 순서 덕분에 실패한 호출은 ②의 자리를 놓고 ①에서 대기합니다.
여러 건이 한꺼번에 올 때
배치 파티션 20개가 동시에 fetchPage 를 부른다고 해 보겠습니다. 지금 설정은 동시성 4, 초당 40건(호출 간격 25ms)입니다.
| 시점 | 일어나는 일 |
|---|---|
| 0ms | 20개가 거의 동시에 도착. 4개만 ② 를 통과하고 16개는 ② 앞에서 기다린다 |
| 0ms | 통과한 4개가 acquireSlot() 에서 차례를 잡는다. 0ms, 25ms, 50ms, 75ms 차례 |
| 0~75ms | 각자 차례에 HTTP 를 보낸다 |
| 첫 응답이 올 때 | ② 의 자리 하나가 빈다. 기다리던 16개 중 하나가 들어와 다음 차례(100ms)를 잡는다 |
| 이후 | 응답이 올 때마다 하나씩 들어가, 동시에 4개씩 흘러간다 |
평소에는 ② 가 속도를 정합니다. 실제로 측정해 보니 동시성 2면 초당 24.3건, 4면 초당 37.2건이었습니다. 응답 시간이 일정하니 동시에 몇 개를 띄우느냐가 곧 속도입니다.
acquireSlot() 이 일하는 건 응답이 갑자기 빨라질 때입니다. 거부 응답은 14ms 만에 돌아오기 때문에 동시성만으로는 초당 140건 넘게 뛸 수 있습니다. 이때 acquireSlot() 이 25ms 간격, 즉 초당 40건에서 자릅니다. 동시성을 6으로 올려 봤더니 초당 38.6건에서 멈춘 것도 이 상한 때문입니다. 두 장치가 맡은 상황이 다른 겁니다.
알아 둘 점이 하나 있습니다. acquireSlot() 과 오류 뒤 pause(500) 은 본문 안, 즉 ② 안쪽에 있습니다. 그래서 차례를 기다리거나 오류 뒤 쉬는 동안에는 ② 의 자리를 쥐고 있습니다. 둘 다 1초도 안 되는 짧은 대기라 막힘이 되지는 않고, 오히려 오류가 몰릴 때 다른 호출까지 같이 늦춰 주는 브레이크가 됩니다.
한 페이지가 계속 실패할 때
maxAttempts = 5, @Backoff(delay = 2000, multiplier = 3) 인 지금 설정에서 한 페이지가 계속 실패하면 이렇게 흘러갑니다.
| 시각(대략) | 일어나는 일 | ② 의 자리 |
|---|---|---|
| 0초 | 1번째 시도 → 실패 | 쥐었다가 놓음 |
| 2초 | 2초 대기 뒤 2번째 시도 → 실패 | 대기 중에는 놓고 있음 |
| 8초 | 6초 대기 뒤 3번째 시도 → 실패 | 〃 |
| 26초 | 18초 대기 뒤 4번째 시도 → 실패 | 〃 |
| 80초 | 54초 대기 뒤 5번째 시도 → 실패 | 〃 |
| 80초 | 재시도를 다 썼다. @Recover 가 ERROR 를 남기고 null 을 돌려준다 | |
| 80초 | 서비스 루프가 null 을 보고 이 지역의 페이지 돌기를 멈춘다 |
마지막 두 줄에 해당하는 코드입니다.
// PublicApiPageCaller: 재시도를 다 쓰면 불린다
@Recover
public String recover(Exception e, String regionCode, String yearMonth, int pageNo) {
log.error("최종 실패 - 이 페이지 이후가 수집에서 빠집니다. ...", ..., e);
return null;
}
// 서비스의 페이지 루프
String body = caller.fetchPage(regionCode, yearMonth, pageNo);
if (body == null) {
break; // 이 지역의 페이지 돌기를 멈춘다
}
- 대기 시간은 2, 6, 18, 54초로 세 배씩 늘어 합치면 80초입니다. 여기에 시도마다 응답 시간(타임아웃이면 최대 15초)이 더해집니다.
- 그 80초 동안 ② 의 자리는 비어 있어서 다른 페이지 호출은 계속 진행됩니다. 실패 한 건이 전체를 붙잡지 않습니다.
- 도중에 한 번이라도 성공하면 그 자리에서 본문을 돌려주고 끝납니다.
1-5. 어노테이션 하나씩
@ConcurrencyLimit (Spring 7)
메서드 단위로 거는 세마포어입니다. 스레드 풀이나 커넥션 풀의 크기 제한과 같은 효과를 메서드 하나에 줍니다.
| 속성 | 기본값 | 뜻 |
|---|---|---|
value / limit | 없음(꼭 준다) | 동시에 실행할 수 있는 수. 1 이면 사실상 락, -1 이면 제한 없음 |
limitString | "" | 설정값(${...})이나 SpEL 로 줄 때. 주면 limit 보다 우선한다 |
policy | BLOCK | 한도에 다다르면 BLOCK 은 자리가 날 때까지 기다리고, REJECT 는 바로 InvocationRejectedException 을 던진다(7.0.3 부터) |
- 한도는 빈 인스턴스마다 하나입니다. 공식 문서도
limit = 1을 "대상 인스턴스를 잠그는 것" 이라고 설명합니다. 싱글톤 빈이면 앱 전체에 하나입니다. - 가상 스레드와 같이 쓸 때 특히 쓸모가 있습니다. 가상 스레드에는 스레드 풀이 없어서, 스레드 수가 동시 호출 수를 저절로 묶어 주지 않습니다.
@Retryable (spring-retry)
| 속성 | 뜻 |
|---|---|
retryFor | 이 예외가 나면 재시도한다 |
noRetryFor | 이 예외는 재시도하지 않는다 |
maxAttempts | 첫 시도를 포함한 전체 시도 횟수. 기본 3. 5 면 처음 1번 + 재시도 4번 |
backoff | 재시도 사이 대기 규칙. @Backoff 로 준다 |
@Backoff (spring-retry)
| 속성 | 뜻 |
|---|---|
delay | 첫 재시도 전 대기(ms) |
multiplier | 재시도마다 대기를 몇 배로. delay = 2000, multiplier = 3 이면 2초, 6초, 18초, 54초 |
maxDelay | 대기의 상한(ms) |
random | true 면 대기에 무작위를 섞어, 여러 스레드가 같은 순간에 다시 몰리는 것을 흩는다 |
@Recover (spring-retry)
재시도를 다 쓰고도 실패했을 때 대신 불리는 메서드입니다.
- 같은 클래스 안에 있어야 합니다.
- 반환 타입이
@Retryable메서드와 같아야 합니다. - 첫 번째 인자는 예외, 그 뒤는
@Retryable메서드의 인자와 순서·타입이 같아야 합니다. @Recover가 아예 없으면 마지막 예외가 호출한 쪽으로 나갑니다. 있는데 인자가 안 맞으면 "복구 메서드를 못 찾았다" 는 예외가 납니다.
1-6. 써 보며 알게 된 것
동시성을 묶어도 "1초에 몇 번" 은 안 묶인다
스위치를 켜고 동시성을 2로 묶었는데도 차단이 쉽게 안 멈췄습니다. 응답 시간을 측정해 보니 정상은 약 115ms, 거부는 약 14ms 였습니다. 거부가 8배 빨리 오니, 막히기 시작하면 같은 동시성 2에서도 초당 호출이 17건에서 140건으로 뛰었습니다. 공공 API 의 제한은 대개 "1초에 몇 번" 인데, @ConcurrencyLimit 은 "한 번에 몇 개" 만 묶습니다.
그래서 호출기 안의 acquireSlot() 으로 초당 호출 수를 따로 묶습니다. "다음 호출이 나갈 수 있는 시각" 하나를 모든 스레드가 같이 쓰고, 각자 그 시각을 한 칸씩 밀어 자기 차례를 잡습니다. updateAndGet 한 번이라 락이 필요 없습니다. 오류 응답 뒤에는 pause(500) 으로 잠깐 쉬어 되먹임을 끊습니다.
동시성 값은 측정해서 정한다
처음 동시성 2를 정할 때 근거는 "동시 2개로 초당 31건까지 올렸더니 300건쯤에서 막혔다" 는 하루치 기록이었습니다. 그런데 그날은 공공 쪽도 작업 중이던 때라, 수집이 느리다고 느낀 김에 다시 측정해 봤습니다.
먼저 동시성을 코드 상수에서 설정값으로 바꿨습니다. @ConcurrencyLimit 은 limitString 으로 설정값을 받을 수 있어서, 값을 바꿀 때마다 다시 빌드할 필요가 없습니다.
그다음 DB 에는 아무것도 저장하지 않고 호출기만 실제로 부르는 측정용 시험을 만들었습니다. 배치 파티션처럼 가상 스레드 20개가 목록을 나눠 호출기 빈을 부르니, 동시성 제한·초당 상한·재시도가 운영과 똑같이 걸립니다.
@SpringBootTest
@EnabledIfSystemProperty(named = "runThroughput", matches = "true") // 평소 시험에서는 건너뛴다
class ApiThroughputProbeTest {
@Autowired
private PublicApiPageCaller caller;
@Test
void 동시성별_처리_속도() throws Exception {
ConcurrentLinkedQueue<String[]> jobs = buildJobs(); // 시군구 45곳 x 9개월 = 405건
int total = jobs.size();
List<Thread> workers = new ArrayList<>();
long t0 = System.nanoTime();
for (int w = 0; w < 20; w++) { // 배치 파티션 수와 같게
workers.add(Thread.ofVirtual().start(() -> {
String[] job;
while ((job = jobs.poll()) != null) {
caller.fetchPage(job[0], job[1], 1);
}
}));
}
for (Thread t : workers) {
t.join();
}
double sec = (System.nanoTime() - t0) / 1e9;
System.out.printf("호출 %d건 %.1f초 = 초당 %.1f건%n", total, sec, total / sec);
}
}
동시성 값은 실행할 때 -Dapi.public-data.apt.concurrency=4 처럼 넘깁니다. 호출기의 WARN·ERROR 로그도 같이 세어 오류 응답이 나는지 봤습니다.
| 동시성 / 초당 상한 | 호출 수 | 초당 처리 | 오류 |
|---|---|---|---|
| 2 / 40 | 405 | 24.3건 | 0 |
| 4 / 40 | 405 | 37.2건 | 0 |
| 6 / 40 | 405 | 38.6건 (상한 40 에 걸림) | 0 |
| 6 / 60 | 1,215 | 51.4건 | 0 |
예전에 막혔다던 초당 31건을 넘겨도 오류가 없었습니다. 그래서 초당 상한 40 은 그대로 두고 동시성만 2에서 4로 올렸습니다. 포털이 다시 막히더라도 폭주는 40에서 잘립니다. 한 번 전부 수집하는 약 2만 건 기준으로 약 13.5분이 약 9분으로 줄어드는 셈입니다.
Gradle 함정 하나. ./gradlew test -Dfoo=bar 로 준 값은 테스트를 실제로 돌리는 JVM 에 저절로 넘어가지 않습니다. build.gradle 의 test 설정에서 systemProperty 로 넘겨줘야 합니다. 안 넘기면 @EnabledIfSystemProperty 시험이 조용히 "건너뜀" 으로 끝나는데, 이걸 "통과" 로 읽기 쉽습니다.
tasks.named('test') {
['runThroughput', 'api.public-data.apt.concurrency'].each { k ->
if (System.getProperty(k) != null) systemProperty k, System.getProperty(k)
}
}
타임아웃이 없으면 자리가 영원히 묶인다
@ConcurrencyLimit 은 기본으로 자리가 날 때까지 기다립니다. 어떤 호출이 응답을 영영 못 받으면 그 자리는 영원히 안 납니다. 동시성 2라면 멈춘 호출 둘에 수집 전체가 멈춥니다. 어느 API 를 측정해 보니 성공은 0.3초 안팎, 실패(504)는 5~60초였습니다.
@Bean
public PublicDataClient publicDataClient() {
HttpClient httpClient = HttpClient.newBuilder()
.executor(Executors.newVirtualThreadPerTaskExecutor())
.connectTimeout(Duration.ofSeconds(5)) // 연결이 안 될 때
.build();
JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(httpClient);
requestFactory.setReadTimeout(Duration.ofSeconds(15)); // 응답이 안 올 때
RestClient restClient = RestClient.builder()
.baseUrl("https://apis.example.go.kr")
.requestFactory(requestFactory)
.build();
return HttpServiceProxyFactory.builderFor(RestClientAdapter.create(restClient))
.build()
.createClient(PublicDataClient.class);
}
15초는 성공과 실패의 응답 시간을 각각 측정한 뒤 그 사이에 그은 선입니다. 성공은 전부 통과하고 멈춘 호출만 끊깁니다. 끊긴 호출은 예외가 되어 재시도됩니다. @ConcurrencyLimit 을 쓴다면 타임아웃은 짝으로 꼭 겁니다.
재시도 대기 동안 동시성 자리를 쥐고 있으면?
한 메서드에 @ConcurrencyLimit 과 @Retryable 을 같이 붙이면 누가 바깥에서 감싸는지가 중요합니다.
- 동시성 제한이 바깥이면: 재시도 대기(2초, 6초, 18초, 54초, 최대 80초) 동안 자리를 쥐고 있습니다. 동시성 2라면 실패 두 건에 다른 호출이 80초 동안 멈춥니다.
- 동시성 제한이 안쪽이면: 실패하면 자리를 놓고 대기합니다.
공식 레퍼런스에는 둘을 같이 쓸 때의 순서가 적혀 있지 않아서 시험으로 확인합니다.
@SpringBootTest
@Import(ConcurrencyRetryOrderTest.OrderProbe.class)
class ConcurrencyRetryOrderTest {
@Autowired
private OrderProbe probe;
@Test
void 재시도_대기_중에는_동시성_자리를_놓는다() throws Exception {
// 1) 계속 실패해서 재시도 대기에 들어가는 호출
Thread.ofVirtual().start(() -> probe.alwaysFails());
Thread.sleep(500); // 재시도 대기에 확실히 들어가게 한다
// 2) 바로 성공하는 호출. 자리를 못 얻으면 여기서 막힌다
long t0 = System.nanoTime();
probe.succeedsImmediately();
long ms = (System.nanoTime() - t0) / 1_000_000;
assertThat(ms).isLessThan(1500);
}
static class OrderProbe {
@ConcurrencyLimit(limit = 1)
@Retryable(retryFor = Exception.class, maxAttempts = 4,
backoff = @Backoff(delay = 2000, multiplier = 3))
public String alwaysFails() {
throw new IllegalStateException("의도적 실패");
}
@Recover
public String recover(Exception e) {
return "recovered";
}
@ConcurrencyLimit(limit = 1)
public String succeedsImmediately() {
return "ok";
}
}
}
지금 조합(spring-retry @Retryable + Spring 7 @ConcurrencyLimit)에서는 빠른 호출이 0ms 에 통과했습니다. 동시성 제한이 안쪽이라 재시도 대기 중에는 자리를 놓습니다.
여기서 한 번 더 확인할 게 있습니다. 이 시험은 동시성 제한이 아예 꺼져 있어도 0ms 로 통과합니다. 그래서 "1초짜리 호출 두 번이 약 2초 걸리는지" 보는 켜짐 확인 시험을 같이 돌려, 제한이 켜진 상태에서 나온 0ms 임을 확인했습니다. 통과하는 시험은 "작동해서 조용한 것" 인지 "죽어서 조용한 것" 인지 따로 확인해야 합니다.
재시도를 빼먹으면 실패가 "데이터 부족" 으로 위장한다
새 수집기 몇 개를 만들며 @Retryable 을 하나도 안 붙인 적이 있습니다. 재시도가 없어도 수집은 되기 때문에 아무도 몰랐습니다. 지역 수백 곳을 도는 중에 500 오류 한 번이 나서 한 지역이 통째로 빠졌고, 화면에는 그냥 "그 지역은 데이터가 적다" 로 보였습니다.
그 뒤로 두 가지를 합니다.
- 호출기의
@Recover처럼 최종 실패는 WARN 이 아니라 ERROR 와 스택으로 남기고, 수집이 끝나면 기대한 건수와 받은 건수를 대조해 모자라면 알립니다. - 외부 API 클라이언트(
@GetExchange인터페이스)를 부르는 클래스에@Retryable이 없으면 빌드가 실패하는 시험을 둡니다. 소스 파일을 훑는 간단한 텍스트 검사입니다. 이 검사는 어노테이션이 붙었는지만 보고 켜졌는지는 모르므로, 켜짐 확인 시험과 짝으로 둡니다.
---
2부. Spring 7 내장으로 옮긴다면
옮기면 좋은 점은 둘입니다.
- 켜는 스위치가
@EnableResilientMethods하나로 통일됩니다. 스위치 둘 중 하나를 빼먹어 동시성 제한이 조용히 꺼지는 사고가 구조적으로 사라집니다. - 남이 끌고 들어온 spring-retry 에 기대지 않아도 됩니다.
다만 Spring 7 의 @Retryable 은 spring-retry 를 옮겨 온 게 아니라 새로 설계한 것이라 이름만 바꾸면 동작이 달라집니다. 그래서 한 번에 바꾸지 않고 단계를 나눕니다.
단계 1. 시험부터 깐다
1부에 나온 두 시험, 즉 켜짐 확인 시험(동시성 제한이 실제로 켜져 있나)과 순서 시험(재시도 대기 중에 동시성 자리를 놓나)을 먼저 만들고, 지금 상태에서 통과하는 것을 확인합니다. 옮긴 뒤 같은 시험을 다시 돌려 동작이 그대로인지 봅니다.
단계 2. import 와 속성을 옮긴다
전 (spring-retry)
import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.Retryable;
@ConcurrencyLimit(limitString = CONCURRENCY)
@Retryable(
retryFor = { Exception.class },
maxAttempts = 5,
backoff = @Backoff(delay = 2000, multiplier = 3)
)
public String fetchPage(String regionCode, String yearMonth, int pageNo) { ... }
후 (Spring 7 내장)
import org.springframework.resilience.annotation.Retryable;
@ConcurrencyLimit(limitString = CONCURRENCY)
@Retryable(
includes = { Exception.class },
maxRetries = 4, // maxAttempts 5 = 첫 시도 1 + 재시도 4
delay = 2000,
multiplier = 3
)
public String fetchPage(String regionCode, String yearMonth, int pageNo) { ... }
바뀐 것은 셋입니다.
maxAttempts→maxRetries, 그리고 1을 뺀다. spring-retry 는 첫 시도를 포함해 세고, Spring 7 은 첫 시도를 빼고 셉니다. 숫자를 그대로 옮기면 모든 곳이 한 번씩 더 시도합니다. 옮길 때 가장 놓치기 쉬운 곳입니다.@Backoff가 없어지고 속성이@Retryable로 올라온다.delay,multiplier,maxDelay를 바로 줍니다.retryFor→includes.noRetryFor는excludes입니다.
Spring 7 @Retryable 의 속성 전체는 이렇습니다(기본값은 7.0.3 jar 에서 확인).
| 속성 | 기본값 | 뜻 |
|---|---|---|
value / includes | 없음(모든 예외) | 재시도할 예외 타입 |
excludes | 없음 | 재시도하지 않을 예외 타입 |
predicate | 없음 | 예외를 보고 재시도할지 직접 정하는 MethodRetryPredicate |
maxRetries | 3 | 첫 시도를 뺀 재시도 횟수. 전체 시도 = 1 + maxRetries |
delay | 1000 | 재시도 전 대기 |
multiplier | 1.0 | 재시도마다 대기를 몇 배로 늘릴지 |
maxDelay | 제한 없음 | 대기의 상한 |
jitter | 0 | 대기에 섞을 무작위 폭(spring-retry @Backoff(random = true) 와 비슷한 역할) |
timeout | 0(없음) | 첫 시도부터 재시도와 대기까지 합친 전체 시간 상한(7.0.2 부터) |
timeUnit | MILLISECONDS | delay, jitter, maxDelay 의 단위 |
…String 변형 | maxRetriesString, delayString 처럼 설정값이나 SpEL 로 줄 때 |
단계 3. @Recover 를 걷어낸다
Spring 7 에는 @Recover 가 없습니다. 재시도를 다 쓰면 마지막 예외가 호출한 쪽으로 그대로 나갑니다. 그래서 @Recover 가 하던 일을 호출하는 쪽으로 옮깁니다.
전: 호출기의 @Recover 가 ERROR 를 남기고 null 을 돌려주면, 루프가 null 을 보고 멈춥니다.
// PublicApiPageCaller
@Recover
public String recover(Exception e, String regionCode, String yearMonth, int pageNo) {
log.error("최종 실패 - 이 페이지 이후가 수집에서 빠집니다. region={} ym={} page={}",
regionCode, yearMonth, pageNo, e);
return null;
}
// 서비스의 루프
String body = caller.fetchPage(regionCode, yearMonth, pageNo);
if (body == null) {
break;
}
후: @Recover 를 지우고, 루프가 예외를 직접 잡습니다.
// 서비스의 루프
String body;
try {
body = caller.fetchPage(regionCode, yearMonth, pageNo);
} catch (Exception e) {
log.error("최종 실패 - 이 페이지 이후가 수집에서 빠집니다. region={} ym={} page={}",
regionCode, yearMonth, pageNo, e);
break;
}
놓치면 안 되는 점: @Recover 만 지우고 호출하는 쪽을 안 고치면, 예전에는 null 로 조용히 끝나던 실패가 예외가 되어 job 전체를 실패시킵니다. 그게 더 나은 경우도 있지만, 동작이 바뀐다는 걸 알고 골라야 합니다. @Recover 가 있던 곳을 전부 찾아 하나씩 판단합니다.
단계 4. 중간 실패 기록은 MethodRetryEvent 로
호출하는 쪽은 마지막 예외 하나만 봅니다. 중간에 몇 번, 무슨 이유로 실패했는지 남기고 싶으면, @Retryable 이 실패마다 발행하는 이벤트를 받습니다.
@Component
public class RetryLogger {
private static final Logger log = LoggerFactory.getLogger(RetryLogger.class);
@EventListener
public void onRetry(MethodRetryEvent event) {
log.warn("재시도 예정 method={} 원인={}{}",
event.getMethod().getName(),
event.getFailure().toString(),
event.isRetryAborted() ? " (재시도 중단)" : "");
}
}
호출기 안에 흩어져 있던 "재시도 예정" 로그를 이 리스너 한 곳으로 모을 수 있습니다.
단계 5. 스위치를 정리한다
@EnableResilientMethods // 이것 하나로 @ConcurrencyLimit 과 @Retryable(Spring 7) 이 켜진다
@SpringBootApplication
public class BatchApplication { ... }
@EnableRetry 는 spring-retry 의 @Retryable 이 프로젝트에 하나도 안 남았을 때 지웁니다. 옮기는 도중에 지우면 아직 안 옮긴 곳의 재시도가 조용히 꺼집니다. 반대로 @EnableResilientMethods 없이 옮기면 새 @Retryable 이 조용히 무시됩니다. 스위치를 빼먹어 조용히 꺼지는 사고가 방향만 바뀌어 나는 셈입니다. import org.springframework.retry 로 검색해 0건인지 확인하고 지웁니다.
단계 6. 시험을 다시 돌린다
켜짐 확인 시험과 순서 시험을 다시 돌립니다. 저는 옮긴 뒤의 코드를 시험으로 만들어 Spring 7.0.3 에서 돌려 봤습니다.
| 조합 | 순서 시험(빠른 호출) | 뜻 |
|---|---|---|
spring-retry @Retryable + Spring 7 @ConcurrencyLimit (지금) | 0ms 에 통과 | 동시성 제한이 안쪽 |
Spring 7 @Retryable + Spring 7 @ConcurrencyLimit (옮긴 뒤) | 0ms 에 통과 | 같다 |
옮긴 뒤에도 재시도 대기 중에 자리를 놓았습니다. 다만 공식 문서가 순서를 약속한 것은 아니라서, 버전을 올릴 때마다 이 시험을 같이 돌립니다.
옮기면서 같이 쓸 수 있는 것
지금은 안 쓰지만, Spring 7 내장으로 옮기면 바로 쓸 수 있는 기능들입니다. REJECT 와 RetryTemplate 예시는 시험으로 돌려 봤고, timeout 은 7.0.3 에 속성이 있는 것만 확인했습니다.
재시도 전체 시간 상한 (timeout): 재시도 대기가 길어져 한 건이 몇 분씩 잡아먹는 것을 막습니다.
@Retryable(maxRetries = 4, delay = 2000, multiplier = 3, timeout = 90_000) // 다 합쳐 90초까지만
기다리지 않고 바로 거절 (policy = REJECT): limit = 1 인 메서드를 하나가 쥐고 있을 때 하나를 더 불러 보니, 0ms 만에 InvocationRejectedException 이 났습니다. 배치처럼 "늦어도 해야 하는 일" 은 BLOCK, 웹 요청처럼 "늦게 답할 바엔 빨리 거절하는 게 나은 일" 은 REJECT 가 맞습니다.
@ConcurrencyLimit(limit = 40, policy = ConcurrencyLimit.ThrottlePolicy.REJECT)
public Summary loadSummary(String regionCode) { ... }
try {
return service.loadSummary(regionCode);
} catch (InvocationRejectedException e) { // RejectedExecutionException 의 하위 타입
return Summary.busy();
}
코드로 재시도 (RetryTemplate): 어노테이션을 쓸 수 없는 곳(남의 클래스, 람다 한 줄)이나, 실패 처리를 그 자리에서 하고 싶을 때 씁니다.
RetryPolicy policy = RetryPolicy.builder()
.includes(HttpServerErrorException.class, ResourceAccessException.class)
.maxRetries(4)
.delay(Duration.ofSeconds(2))
.multiplier(3)
.maxDelay(Duration.ofMinutes(1))
.jitter(Duration.ofMillis(500))
.build();
RetryTemplate retryTemplate = new RetryTemplate(policy);
// invoke: 다 실패하면 원래 예외를 그대로 던진다
String body = retryTemplate.invoke(() -> client.getPage(regionCode, yearMonth, 1, 1000).getBody());
// execute: 다 실패하면 RetryException 으로 감싸 던진다. 중간 예외까지 다 볼 수 있다
try {
String page = retryTemplate.execute(() -> client.getPage(regionCode, yearMonth, 1, 1000).getBody());
} catch (RetryException e) {
log.error("최종 실패. 마지막 원인={}, 실패 수={}", e.getLastException(), e.getExceptions().size());
}
돌려 보니 maxRetries(2) 로 계속 실패시키면 3번 불리고, RetryException.getExceptions() 에 예외 3개가 다 들어 있었습니다. 정책 없이 new RetryTemplate() 으로 만들면 RetryPolicy.withDefaults()(재시도 3번, 1초 대기)를 씁니다.
정리
| 지금 (spring-retry + Spring 7) | 옮긴 뒤 (Spring 7 내장) | |
|---|---|---|
| 스위치 | @EnableRetry + @EnableResilientMethods | @EnableResilientMethods 하나 |
| 횟수 | maxAttempts = 5 (첫 시도 포함) | maxRetries = 4 (첫 시도 제외) |
| 대기 | backoff = @Backoff(delay, multiplier) | delay, multiplier 를 바로 |
| 예외 지정 | retryFor / noRetryFor | includes / excludes / predicate |
| 다 실패했을 때 | @Recover 가 null 을 돌려줌 | 예외가 나감. 호출하는 쪽에서 잡는다 |
| 중간 실패 기록 | 호출기 안의 로그 | MethodRetryEvent 리스너 |
| 동시성 제한과의 순서 | 안쪽 (7.0.3 에서 확인) | 안쪽 (7.0.3 에서 확인) |
어느 쪽이든 공통으로 기억할 것은 셋입니다.
- 스위치를 빼먹으면 조용히 무시된다. 켜졌는지는 동작으로 확인한다.
@ConcurrencyLimit은 "한 번에 몇 개" 만 묶는다. 초당 호출 수는 따로, 타임아웃은 짝으로.- 통과하는 시험은 "살아서 통과" 인지도 확인한다.
마치며
@ConcurrencyLimit 은 한 줄이면 되는 편한 기능입니다. 다만 써 보니 그 한 줄이 켜졌는지, 켜졌다면 정말 원하는 것을 묶는지, 재시도와 어떻게 겹치는지는 결국 시험으로 확인해야 했습니다. spring-retry 를 Spring 7 내장으로 옮기는 것도 같습니다. 이름만 바꾸면 끝날 것 같지만, 횟수 세는 법과 실패 처리 방식이 달라서 시험 없이 옮기면 동작이 조용히 바뀝니다.
덧붙임: 신나게마을 구경 오세요
이 배치가 매일 새벽 모으는 데이터는 신나게마을에서 볼 수 있습니다. 우리 동네 아파트 실거래가와 전월세, 미세먼지, 인구 변화 같은 공공데이터를 동네 단위로 한 화면에 모아 보여 주는 사이트입니다. 이사 갈 동네를 알아보시거나, 지금 사는 동네가 숫자로는 어떤지 궁금하실 때 한번 들러 주세요.
https://sinnake-town.com