많은 무료 API는 무료 플랜에 대해 요청 횟수 제한(Rate Limit)을 둔다. 대표적으로 YouTube Data API, Brave Search API 등은 하루, 혹은 초당 요청 횟수를 제한 하는 경우가 생긴다. 무료 유저로서는 슬픈 상황이다. 물론 지원 예산이 있긴 하지만 가능하면 이런 비용 절약 측면에서 설계를 하는 것도 좋다는 생각이 들어서 이번에 실험해보게 되었다.
.env
나 application.yml
같은 설정 파일에 하나의 API 키만 고정해 사용하는 방식으로 인해 키가 만료되거나 한도에 도달하면 서버를 닫아야 한다는 점이다.429 Too Many Requests
에러가 발생하고, 서버를 재시작하지 않으면 해결할 방법이 없다.예시 오류 로그:
{"code":"RATE_LIMITED","rate_limit":1,"rate_current":1,"quota_limit":2000,"quota_current":111}
이는 서비스 중단을 유발하며, 테스트나 운영 환경에서는 큰 문제가 될 것이다.
API 키가 여러 개 있을 때 이를 자동으로 순환해서 사용하는 방식이 필요하다. 가장 단순한 방법은 큐(Queue) 자료구조를 사용할 것이다
하지만 단순 JSON 파일이나 메모리 기반 큐는 서버 재시작 시 초기화되고, 설정 변경 시 반영이 어려우며, 운영 중 키를 수정하기 어려운 문제를 해결해 줄 좋은 툴이 있다.
간단히 요약하자면 Zookeeper는 Apache 재단에서 제공하는 분산 환경을 위한 설정 관리 시스템이라고 한다.
ext {
springCloudVersion = "2025.0.0"
}
dependencyManagement {
imports {
mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
}
}
dependencies {
// Zookeeper + Curator
implementation 'org.springframework.cloud:spring-cloud-starter-zookeeper-config'
implementation 'org.apache.curator:curator-framework:5.5.0'
implementation 'org.apache.curator:curator-recipes:5.5.0'
}
zookeeper:
image: zookeeper:3.8
ports:
- "2181:2181"
networks:
- devmountain-net
ZookeeperConfig.java
)Zookeeper와의 연결을 설정하고, 설정된 키의 변경 사항을 실시간으로 감지하기 위한 Watcher를 등록하는 구성 파일
@Configuration
public class ZookeeperConfig {
@Value("${spring.cloud.zookeeper.connect-string}")
private String connectString;
@Bean
public CuratorFramework curatorFramework() {
CuratorFramework client = CuratorFrameworkFactory.builder()
.connectString(connectString)
.retryPolicy(new ExponentialBackoffRetry(1000, 3))
.build();
client.start();
return client;
}
@Bean(initMethod = "registerWatcher")
public BraveKeyWatcher braveKeyWatcher(CuratorFramework client, BraveSearchProperties properties) {
return new BraveKeyWatcher(client, properties);
}
@Bean(initMethod = "registerWatcher")
public McpKeyWatcher mcpKeyWatcher(CuratorFramework client) {
return new McpKeyWatcher(client);
}
}
CuratorFramework
: Zookeeper 클라이언트를 생성하고 연결한다BraveKeyWatcher
: 설정 변경을 감지하고 키 값을 실시간으로 갱신한다.McpKeyWatcher
: MCP 서버 설정 변경을 감지하고 환경 변수를 갱신한 뒤 MCP 서버를 재시작BraveSearchProperties.java
)Zookeeper로부터 Brave API 키를 가져와 관리하는 클래스. 키 값이 변경될 때 실시간으로 반영된다
@Getter
@Setter
@Component
@ConfigurationProperties(prefix = "brave.search.api")
public class BraveSearchProperties {
private String key;
}
/config/devmountain/brave.search.api.key
에 연결된 값을 실시간으로 갱신하여 애플리케이션에 제공하여 처리하도록 한다.BraveKeyWatcher.java
, McpKeyWatcher.java
)Zookeeper의 키 변경 이벤트를 감지하는 Watcher 클래스
Brave Search API 키 갱신:
@Slf4j
@RequiredArgsConstructor
public class BraveKeyWatcher implements CuratorWatcher {
private final CuratorFramework client;
private final BraveSearchProperties properties;
public void registerWatcher() {
try {
client.getData().usingWatcher(this).forPath("/config/devmountain/brave.search.api.key");
} catch (Exception e) {
log.error("Failed to register watcher", e);
}
}
@Override
public void process(WatchedEvent event) {
if (event.getType() == Event.EventType.NodeDataChanged) {
try {
byte[] newData = client.getData().usingWatcher(this).forPath(event.getPath());
String newKey = new String(newData);
properties.setKey(newKey);
log.info("Brave Search API key updated: {}", newKey);
} catch (Exception e) {
log.error("Failed to update Brave API key", e);
}
}
}
}
MCP 서버 환경 변수 갱신 및 재시작:
@Slf4j
@RequiredArgsConstructor
public class McpKeyWatcher implements CuratorWatcher {
private final CuratorFramework client;
public void registerWatcher() {
try {
client.getData().usingWatcher(this).forPath("/config/devmountain/mcp.server.env");
} catch (Exception e) {
log.error("Failed to register watcher", e);
}
}
@Override
public void process(WatchedEvent event) {
if (event.getType() == Event.EventType.NodeDataChanged) {
try {
byte[] newData = client.getData().usingWatcher(this).forPath(event.getPath());
String newEnv = new String(newData);
System.setProperty("MCP_SERVER_ENV", newEnv);
restartMcpServer();
log.info("MCP 서버 환경 변수 업데이트 및 서버 재시작: {}", newEnv);
} catch (Exception e) {
log.error("Failed to update MCP server environment", e);
}
}
}
private void restartMcpServer() {
// MCP 서버 재시작 로직 구현
}
}
비교 항목 | 이전 방식 | 변경된 방식 |
---|---|---|
API 키 관리 위치 | .env 또는 application.yml 에 단일 키 고정 | Zookeeper의 Znode(/config/devmountain/... )에 다중 키 저장 및 관리 |
키 수 | 하나 | Queue 자료구조로 순환 |
키 변경 반영 | 키 변경 시 서버 재시작 필요 | Watcher 감지 시 실시간 반영 (서버 재시작 불필요) |
Rate Limit 초과 시 대응 | 429 Too Many Requests 발생 → 서버 재시작 후 복구 | 현재 키 사용 한도 초과 시 즉각적으로 대응하여 키에서 제거 |
MCP 서버 환경 변수 갱신 | 환경 변수 수정 → 수동 재배포/재시작 | Zookeeper Watcher 감지 → System.setProperty 후 자동 재시작 로직 실행 |
가용성 및 확장성 | 낮음 (단일 포인트 장애, 수동 관리) | 높음 (분산 설정 관리, 자동화된 키 순환 및 감시) |
장애 대응 속도 | 느림 (수동 확인 및 재시작) | 빠름 (Watcher 이벤트 기반 자동 처리) |
실시간 키 갱신: Watcher가 Zookeeper 설정 변경을 즉각 감지하여 서버 재시작 없이 키 변경 가능
MCP 서버 자동 관리: MCP 서버 환경 설정 변경 시 서버 자동 재시작
Zookeeper 사용 이유:
이 구조는 API Rate Limit 관리 및 환경 변수 관리에 매우 효과적이며, 운영 중 무중단 키 관리 및 설정 변경에 최적화할 수 있었다.
생각보다 Spring zookeeper 구현 자료들이 없어서 공식 자료를 참고를 많이 했다. 확실히 Zookeeper는 배포 단계에서 매우 유용한 설정이 될 것이다. 무엇보다 여러 인스턴스를 띄우면 이 진가가 잘 발휘될 것 같다. 확실히 이건 테스트를 해 볼 필요도 없는 명백한 기능 향상 방향이라 이전이랑 비교하는게 맞을지는 모르겠지만 정량적 지표를 사용하는게 좋다고 생각하니 나중에 표로 정리할 예정이다