오늘은 Resilience4j의 서킷브레이커와 RateLimiter의 설정을 건드려봤습니다.
오늘의 목표는 두 가지였습니다.
1. 네트워크 에러와 RateLimit 에러에는 서킷이 열리지 않도록 하기
2. RateLimiter 최적값 탐색
테스트를 하다보니 키워드 INSERT 쿼리가 단일로 나간다는 점을 파악해서
3. JdbcTemplate를 활용한 배치 쿼리
까지 목표로 잡았습니다.
1. 네트워크 에러와 Rate Limit 에러는 서킷 카운트에서 배제
팀원과 서킷 브레이커 설정에 대해 의논을 했었는데
단순한 네트워크 장애에서 서킷이 열리는 건 막아야된다고 결론이 났습니다.
물론 failure-rate-threshold 설정값을 좀 더 관대하게 하는 방법도 있었지만,
차라리 예외를 try-catch로 잡은 뒤에 커스텀 예외를 던져
ignore-exceptions을 활용하면 좋겠다는 생각이 들었습니다.
이렇게 할 경우 서킷 카운트에서 관련 예외들이 배제되어 잘못 서킷이 열리는 일이 발생하지 않는다는 장점이 있습니다.
public class LlmException extends RuntimeException {
public LlmException(String message) {
super(message);
}
public LlmException(String message, Throwable cause) {
super(message, cause);
}
}
위는 서킷 카운트에 들어가는 진짜 API 에러에서 던져질 예외입니다.
public class LlmNetworkException extends RuntimeException {
public LlmNetworkException(String message) {
super(message);
}
public LlmNetworkException(String message, Throwable cause) {
super(message, cause);
}
}
다음은 네트워크 예외를 만들고,
public class LlmRateLimitException extends RuntimeException {
public LlmRateLimitException(String message) {
super(message);
}
public LlmRateLimitException(String message, Throwable cause) {
super(message, cause);
}
}
마지막으로 RateLimit 예외도 만들었습니다.
public String call(String systemPrompt, String userPrompt) {
try {
Prompt prompt = new Prompt(List.of(
new SystemMessage(systemPrompt),
new UserMessage(userPrompt)
));
return chatModel.call(prompt).getResult().getOutput().getText();
} catch (HttpClientErrorException e) {
if (e.getStatusCode() == HttpStatus.TOO_MANY_REQUESTS) {
log.warn("OpenAI API Rate Limit exceeded: {}", e.getMessage());
throw new LlmRateLimitException("OpenAI API rate limit exceeded", e);
}
log.error("OpenAI API client error: {}", e.getMessage());
throw new LlmException("OpenAI API client error: " + e.getStatusCode(), e);
} catch (ResourceAccessException e) {
log.warn("OpenAI API network error: {}", e.getMessage());
throw new LlmNetworkException("OpenAI API network error", e);
} catch (Exception e) {
log.error("OpenAI API unexpected error: {}", e.getMessage(), e);
throw new LlmException("OpenAI API unexpected error", e);
}
}
}
그리고 이제 이런 식으로 try-catch 절로 감싸주었습니다.
각 llm 구현체에 이런 식으로 명시하였습니다.
여기서 ResourceAccessException은 I/O Exception을 잡아서 스프링에서 던지는 에러입니다.
ResourceAccessException (Spring Framework 7.0.2 API)
Construct a new ResourceAccessException with the given message.
docs.spring.io
관련 정의는 이렇게 api docs에 명시되어 있습니다.
protected <T> T doExecute(URI url, @Nullable String uriTemplate, @Nullable HttpMethod method, @Nullable RequestCallback requestCallback,
@Nullable ResponseExtractor<T> responseExtractor) throws RestClientException {
Assert.notNull(url, "url is required");
Assert.notNull(method, "HttpMethod is required");
ClientHttpRequest request;
try {
request = createRequest(url, method);
}
catch (IOException ex) {
throw createResourceAccessException(url, method, ex);
}
// ...
org.springframework.web.client.RestTemplate를 보면 IOException을 잡은 뒤 생성하여 던지는 것을 확인할 수 있습니다.
RateLimit 에러의 경우 Retry-After 헤더를 통해 다시 실행하는 시간도 처리를 할 수 있지만
RateLimiter로 RateLimit를 예방한 뒤 Retry를 통해 지수적 백오프로 단순하게 처리하는 게 낫겠다는 생각이 들어
거기까지 처리를 하진 않았습니다.
resilience4j:
circuitbreaker:
configs:
default:
sliding-window-type: COUNT_BASED
sliding-window-size: 10
minimum-number-of-calls: 5
failure-rate-threshold: 60
slow-call-rate-threshold: 70
slow-call-duration-threshold: 45s
wait-duration-in-open-state: 60s
permitted-number-of-calls-in-half-open-state: 3
automatic-transition-from-open-to-half-open-enabled: true
record-exceptions:
- com.techfork.global.llm.exception.LlmException
ignore-exceptions:
- com.techfork.global.llm.exception.LlmNetworkException
- com.techfork.global.llm.exception.LlmRateLimitException
retry:
configs:
exponential-backoff-multiplier: 2
exponential-max-wait-duration: 10s
retry-exceptions:
- com.techfork.global.llm.exception.LlmNetworkException
- com.techfork.global.llm.exception.LlmRateLimitException
이렇게 서킷에서 일반 api 예외가 던져질경우 카운트하도록 하였고,
네트워크 장애와 RateLimit가 발생했을경우 재시도하도록 yml을 구성하였습니다.
2. RateLimiter와 taskExecutor의 최적값 탐색
RateLimiter는 api 호출의 수를 제한하는 것이고,
taskExecutor는 병렬처리를 통해 api 호출을 단시간에 많이 시도하도록 처리하는 로직입니다.
이 둘은 상반되는 값이기에 최적값을 찾는 것이 중요했습니다.
ratelimiter:
configs:
default:
limit-for-period: 15
limit-refresh-period: 1m
timeout-duration: 10s
먼저 기존 코드입니다.
llm api와 임베딩 api 구분없이 단순하게 하나의 설정으로 진행했습니다.
이 결과 임베딩 api에서 심각한 병목이 발생하여 크롤링이 제대로 진행되지 않았습니다.

Gpt api tier1 기준 4o-mini의 제한은 다음과 같습니다.

그리고 임베딩 모델은 다음과 같습니다.
두 모델의 limit이 다르다는 것을 파악하고 설정을 분리하고 각 요청의 TPM과 RPM을 고려하여 설정을 개선했습니다.
ratelimiter:
configs:
default:
limit-for-period: 37
limit-refresh-period: 1m
timeout-duration: 10s
instances:
llmSummary:
base-config: default
# OpenAI gpt-4o-mini: 500 RPM, 200k TPM
# Summary: 평균 4350 tokens/req → ~46 req/min
# 안전 마진 20% 적용: 37 req/min
limit-for-period: 37
limit-refresh-period: 1m
timeout-duration: 15s
llmEmbedding:
base-config: default
# OpenAI text-embedding-3-large: 3k RPM, 1M TPM
# 계산: 1,000,000 / 1350 ≈ 740 req/min
# 안전 마진 20% 적용: 590 req/min
limit-for-period: 590
limit-refresh-period: 1m
timeout-duration: 5s
두 모델 다 RPM보다는 TPM에서 제한이 걸릴 것이라고 생각하고 계산을 진행했고,
마진을 20% 적용해 Rate Limit 에러가 걸리지 않도록 설정했습니다.
@Bean
public TaskExecutor summaryTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(2);
executor.setQueueCapacity(10);
executor.setThreadNamePrefix("summary-");
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(60);
executor.initialize();
return executor;
}
@Bean
public TaskExecutor embeddingTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(3);
executor.setMaxPoolSize(5);
executor.setQueueCapacity(20);
executor.setThreadNamePrefix("embedding-");
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(60);
위는 기존 코드인데 summaryTaskExecutor는 제한값에 조금 보수적이지만 잘 설정되어있었고,
embeddingTaskExector는 턱없이 모자랐습니다.
@Bean
public TaskExecutor embeddingTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10);
executor.setMaxPoolSize(20);
executor.setQueueCapacity(50);
executor.setThreadNamePrefix("embedding-");
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(60);
다음과 같이 코어 풀을 대폭 향상시켜 속도 증진을 해낼 수 있었습니다.
3. SummaryWriter의 saveAll을 JdbcTemplate 배치 쿼리로 리팩터링
기존에는 단순히 JPA의 영속성 컨텍스트를 활용해 saveAll을 진행했는데,
이 경우 쿼리가 1개 게시글마다 15개의 키워드가 insert되다 보니 매우 비효율적이었습니다.
이를 각각 배치 쿼리로 변경했습니다.
@Override
public void write(Chunk<? extends Post> chunk) {
List<? extends Post> posts = chunk.getItems();
if (posts.isEmpty()) {
return;
}
updatePostSummaries(posts);
deleteOldKeywords(posts);
insertNewKeywords(posts);
log.info("PostSummaryWriter: {}개 게시글 처리 완료", posts.size());
entityManager.clear();
}
성능 개선 효과
100개 게시글, 각 5개 키워드 시:
구분기존 (JPA)개선 (JDBC Batch)개선율
| UPDATE 쿼리 | 100개 | 1개 (배치) | 99% 감소 |
| DELETE 쿼리 | 500개 | 1개 (배치) | 99.8% 감소 |
| INSERT 쿼리 | 500개 | 1개 (배치) | 99.8% 감소 |
| 총 쿼리 수 | 1,100개 | 3개 | 99.7% 감소 |
| 예상 처리 시간 | ~5-10초 | ~0.5초 미만 | 90% 이상 단축 |
부가 효과:
- DB 커넥션 획득/해제 횟수: 1,100회 → 3회
- 네트워크 Round-trip: 1,100회 → 3회
- 메모리 사용량 감소 (EntityManager 클리어)
- 대량 데이터 처리 안정성 향상
이때 entityManager.clear() 코드가 처음에는 누락되어 있었습니다만.
이 경우에는 JdbcTemplate로 이미 쿼리가 나갔음에도
영속성 컨텍스트 상에서 변동이 있어 dirty checking에 의해 중복된 단일 쿼리들이 나갔습니다.
이를 해결하기 위해 clear()를 명시해주었습니다.
감사합니다.
'프로젝트 > Techfork' 카테고리의 다른 글
| [26/01/14] 오늘의 개발 일지 - 카카오 소셜 로그인 구현 (2) | 2026.01.15 |
|---|---|
| [26/01/10] 오늘의 개발일지 - 온보딩 API 수정, 테스트 코드 작성, 엔티티 생성 성능 최적화 (0) | 2026.01.11 |
| [26/01/06] 오늘의 개발일지 - AsyncItemProcessor의 도입과 Spring Batch 메타데이터 활용 (0) | 2026.01.07 |
| [26/01/05] 오늘의 개발 일지 - Resilience4j 도입 (1) | 2026.01.06 |
| [26/01/03] 오늘의 개발 일지 - Spring Batch JobExecutionListener 도입 (0) | 2026.01.04 |