Node.js API에 속도 제한 및 서킷 브레이커 적용하여 강화하기
Wenhao Wang
Dev Intern · Leapcell

소개
현대 웹 애플리케이션의 세계에서 API는 다양한 서비스를 연결하고 사용자에게 데이터를 전달하는 중추 역할을 합니다. 하지만 API의 개방적인 특성은 잠재적인 취약점과 과부하에 그대로 노출되기도 합니다. 악의적인 공격, 버그가 있는 클라이언트 측 코드, 또는 합법적이지만 대량의 트래픽으로 인해 발생하는 갑작스러운 요청 급증으로 Node.js API가 과부하되는 시나리오를 상상해 보세요. 이는 성능 저하, 서비스 비활성화, 궁극적으로는 좋지 않은 사용자 경험으로 이어질 수 있습니다. 이러한 문제를 해결하고 보다 탄력적인 시스템을 구축하기 위해 두 가지 강력한 패턴이 등장합니다. 바로 속도 제한(Rate Limiting)과 서킷 브레이커(Circuit Breakers)입니다. 이 글에서는 이러한 메커니즘의 중요성을 살펴보고, 그 기본 원리를 깊이 있게 다루며, Node.js에서의 구현 방법을 보여주고, 다양한 위협으로부터 API를 보호하는 방법을 논의할 것입니다.
핵심 개념 설명
구현 세부 사항을 살펴보기 전에, 논의를 정의하는 핵심 개념들을 명확히 해 봅시다:
- 속도 제한 (Rate Limiting): 정의된 시간 창 내에서 사용자 또는 클라이언트가 API에 할 수 있는 요청 수를 제어하는 메커니즘입니다. 주요 목표는 남용을 방지하고, 공정한 리소스 할당을 보장하며, API가 과부하되는 것을 방지하는 것입니다. 클럽의 문지기처럼, 너무 많은 사람들이 한 번에 들어오는 것을 막기 위해 특정 수의 사람만 입장시키는 것에 비유할 수 있습니다.
- 서킷 브레이커 (Circuit Breaker): 전기 회로 차단기에서 영감을 받은 이 패턴은 실패할 가능성이 높은 작업을 반복적으로 실행하려고 시도하는 것을 시스템이 방지합니다. 실패한 서비스에 계속해서 요청을 보내는 대신, 서킷 브레이커는 열리고 지정된 기간 동안 실패한 구성 요소에서 트래픽을 멀리 보냅니다. 시간 초과 후에는 닫힘을 시도하여 서비스가 복구되었는지 확인하기 위해 제한된 수의 요청을 통과시킵니다. 이는 연쇄 실패를 방지하고 실패한 서비스가 회복할 시간을 줍니다.
속도 제한 이해 및 구현
속도 제한은 API 안정성에 매우 중요합니다. 속도 제한 없이는 단일 클라이언트가 서버 리소스를 독점하여 다른 모든 사용자에게 영향을 줄 수 있습니다. 몇 가지 인기 있는 미들웨어를 사용하여 Node.js에서의 원칙과 구현 방법을 살펴보겠습니다.
속도 제한의 원리
속도 제한은 일반적으로 특정 소스(IP 주소, API 키 또는 사용자 ID로 식별)의 요청을 추적하고 정의된 제한을 시간 창 내에서 초과하면 후속 요청을 차단하는 것을 포함합니다. 일반적인 알고리즘은 다음과 같습니다:
- 고정 창 카운터 (Fixed Window Counter): 고정된 시간 창에 대해 카운터를 유지하는 간단한 접근 방식입니다. 해당 창 내의 모든 요청은 카운터를 증가시킵니다. 창이 만료되면 카운터가 재설정됩니다.
- 슬라이딩 창 로그 (Sliding Window Log): 이 방법은 각 요청의 타임스탬프 로그를 유지합니다. 새 요청이 도착하면 현재 창 내에 있는 로그의 요청 수를 확인합니다. 이는 고정 창보다 더 부드러운 제한을 제공합니다.
- 토큰 버킷 (Token Bucket): 요청은 버킷에서 "토큰"을 소비합니다. 토큰은 고정 속도로 보충됩니다. 버킷이 비어 있으면 요청이 거부됩니다. 이는 평균 속도를 강제하면서도 버스트 트래픽을 허용합니다.
Node.js에서 속도 제한 구현
Node.js의 경우, express-rate-limit
는 널리 사용되고 강력한 미들웨어입니다. Express 애플리케이션에 쉽게 통합할 수 있습니다.
먼저 패키지를 설치합니다:
npm install express-rate-limit
그런 다음 Express 애플리케이션에 구현합니다:
const express = require('express'); const rateLimit = require('express-rate-limit'); const app = express(); const port = 3000; // 모든 요청에 적용 const globalLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15분 max: 100, // 각 IP를 windowMs당 100개의 요청으로 제한 message: '이 IP에서 너무 많은 요청이 발생했습니다. 15분 후에 다시 시도해 주세요.', standardHeaders: true, // `RateLimit-*` 헤더에 속도 제한 정보 반환 legacyHeaders: false, // `X-RateLimit-*` 헤더 비활성화 }); // 특정 경로에 적용, 예를 들어 로그인 엔드포인트 const loginLimiter = rateLimit({ windowMs: 60 * 60 * 1000, // 1시간 max: 5, // 각 IP를 시간당 5개의 로그인 시도로 제한 message: '이 IP에서 로그인 시도가 너무 많습니다. 1시간 후에 다시 시도해 주세요.', handler: (req, res) => { res.status(429).json({ error: '로그인 시도 횟수가 초과되었습니다. 잠시 후 다시 시도해 주세요.' }); }, standardHeaders: true, legacyHeaders: false, }); // 모든 경로에 전역 제한기 적용 app.use(globalLimiter); app.get('/', (req, res) => { res.send('홈페이지에 오신 것을 환영합니다!'); }); app.post('/login', loginLimiter, (req, res) => { // 여기에 로그인 로직 추가 res.send('로그인 성공!'); }); app.listen(port, () => { console.log(`서버가 http://localhost:${port}에서 수신 대기 중입니다.`); });
이 예제에서는 globalLimiter
가 15분마다 IP당 100개의 요청이라는 전역 제한을 적용합니다. loginLimiter
는 IP당 시간당 5개의 로그인 시도만 허용하여 민감도에 따라 특정 엔드포인트에 맞게 제한을 사용자 정의하는 방법을 보여줍니다.
속도 제한 적용 시나리오
- DDoS 보호: 단일 IP에서의 요청 수를 제한하면 간단한 서비스 거부 공격을 완화할 수 있습니다.
- 무차별 대입 공격 방지: 로그인 시도 또는 비밀번호 재설정 요청을 제한하면 공격자가 자격 증명을 추측하는 것을 방지하는 데 도움이 됩니다.
- API 남용 방지: 단일 클라이언트가 과도한 리소스를 소비하지 않도록 하여 모든 사용자에 대한 서비스 품질을 유지합니다.
- 비용 제어: 요청당 비용이 발생하는 API(예: 타사 서비스)의 경우, 속도 제한을 통해 사용량을 관리하는 데 도움이 될 수 있습니다.
서킷 브레이커 이해 및 구현
속도 제한이 높은 트래픽을 처리하는 동안, 서킷 브레이커는 실패하는 종속성으로부터 보호합니다.
서킷 브레이커의 원리
서킷 브레이커는 일반적으로 세 가지 상태를 가집니다:
- 닫힘 (Closed): 이것은 초기 상태입니다. 요청은 정상적으로 통과합니다. 실패가 발생하면 브레이커가 이를 모니터링합니다. 실패율이 임계값을 초과하면 Open 상태로 전환됩니다.
- 열림 (Open): 이 상태에서는 보호된 작업에 대한 모든 요청이 즉시 실패(빠른 실패)합니다. 이는 실패한 서비스에 계속 부담을 주는 것을 방지하고 호출자에게 빠르게 오류를 반환합니다. 구성된 시간 초과 후에는 Half-Open 상태로 전환됩니다.
- 반 열림 (Half-Open): 제한된 수의 테스트 요청이 보호된 작업에 통과하도록 허용됩니다. 이러한 요청이 성공하면 서킷 브레이커는 서비스가 복구되었다고 가정하고 Closed 상태로 다시 전환됩니다. 실패하면 Open 상태로 다시 전환하여 시간 초과를 다시 시작합니다.
Node.js에서 서킷 브레이커 구현
Node.js에는 opossum
과 같은 서킷 브레이커를 구현하는 여러 라이브러리가 있습니다.
먼저 opossum
을 설치합니다:
npm install opossum
다음은 외부 API 호출을 보호하는 데 사용하는 예입니다:
const CircuitBreaker = require('opossum'); const axios = require('axios'); // HTTP 요청용 // 서킷 브레이커 옵션 const options = { timeout: 5000, // 함수 실행 시간이 5초 이상 걸리면 실패 트리거 errorThresholdPercentage: 50, // 요청의 50%가 실패하면 서킷이 트리핑됩니다. resetTimeout: 10000, // 10초 후 서킷을 `half-open` 상태로 이동합니다. }; // 실패할 수 있는 함수 정의 (예: 외부 API 호출) async function callExternalService() { console.log('외부 서비스 호출 시도 중...'); try { const response = await axios.get('http://localhost:8080/data'); // 외부 서비스 엔드포인트로 대체 if (response.status !== 200) { throw new Error(`외부 서비스가 상태 코드 ${response.status}로 응답했습니다.`); } console.log('외부 서비스 호출 성공!'); return response.data; } catch (error) { console.error('외부 서비스 호출 실패:', error.message); throw error; // 서킷 브레이커에 실패를 알리기 위해 다시 던집니다. } } // 함수 주위에 서킷 브레이커 생성 const breaker = new CircuitBreaker(callExternalService, options); // 로깅 및 디버깅을 위해 서킷 브레이커 이벤트 수신 대기 breaker.on('open', () => console.warn('서킷 브레이커 OPEN! 외부 서비스가 다운된 것으로 추정됩니다.')); breaker.on('halfOpen', () => console.log('서킷 브레이커 HALF-OPEN. 외부 서비스 확인 중...')); breaker.on('close', () => console.log('서킷 브레이커 CLOSED. 외부 서비스 복구됨.')); breaker.on('fallback', (error) => console.error('서킷 브레이커 폴백 모드:', error.message)); // Express 경로에서의 예제 사용 const express = require('express'); const app = express(); const port = 3000; app.get('/protected-data', async (req, res) => { try { const data = await breaker.fire(); res.json(data); } catch (error) { // 서킷이 열려 있으면 이 오류가 즉시 발생합니다. // 또는 기본 서비스가 실패하고 폴백이 제공되지 않은 경우 res.status(503).json({ error: '서비스를 일시적으로 사용할 수 없습니다. 나중에 다시 시도해 주세요.' }); } }); // 테스트를 위한 더미 외부 서비스 const mockExternalService = express(); mockExternalService.get('/data', (req, res) => { // 간헐적으로 실패 시뮬레이션 if (Math.random() < 0.6) { // 60% 확률로 실패 console.log('모의 외부 서비스 실패 중...'); return res.status(500).json({ message: '모의 서비스에서 발생한 내부 서버 오류' }); } console.log('모의 외부 서비스 성공 중...'); res.json({ message: '외부 서비스의 데이터' }); }); mockExternalService.listen(8080, () => { console.log('모의 외부 서비스가 8080 포트에서 수신 대기 중입니다.'); }); app.listen(port, () => { console.log(`메인 API 서버가 http://localhost:${port}에서 수신 대기 중입니다.`); });
이 예제에서 breaker.fire()
는 callExternalService
를 실행하려고 시도합니다. callExternalService
가 너무 자주 실패하면(이 경우 50%), 서킷이 열리고 breaker.fire()
에 대한 후속 호출은 비정상적인 외부 서비스에 대한 연속적인 호출을 방지하기 위해 즉시 오류를 발생시킵니다. resetTimeout
후에는 반열림 상태로 전환되어 서비스가 복구되었는지 여부를 확인하기 위해 몇 번의 요청을 시도합니다.
서킷 브레이커에 fallback
함수를 정의할 수도 있습니다. 이 함수는 서킷이 열려 있거나 기본 함수가 실패하고 다른 오류 처리기가 없는 경우 실행됩니다. 이를 통해 기능의 점진적인 성능 저하를 우아하게 처리할 수 있습니다.
// ... (이전 코드) ... // 폴백 함수 추가 breaker.fallback(async () => { console.log('폴백 데이터 사용 중!'); return { message: '폴백 데이터: 현재 서비스를 사용할 수 없지만, 캐시된 정보가 있습니다.' }; }); // ... (나머지 코드) ...
서킷 브레이커 적용 시나리오
- 마이크로서비스 아키텍처: 상호 연결된 서비스 간의 연쇄 실패를 방지하는 데 필수적입니다. 한 마이크로서비스가 다운되어도 전체 시스템이 중단되지 않습니다.
- 타사 API 통합: 의존하는 외부 서비스의 장애 또는 성능 저하로부터 애플리케이션을 보호합니다.
- 데이터베이스 연결: 응답하지 않는 데이터베이스에 대해 쿼리를 계속 다시 시도하는 것을 애플리케이션이 방지합니다.
- 리소스 보호: 실패하는 서비스에 대한 요청을 일시적으로 중지하여 복구할 기회를 제공합니다.
결론
속도 제한과 서킷 브레이커를 모두 구현하는 것은 단순히 모범 사례가 아니라, 견고하고 확장 가능하며 탄력적인 Node.js API를 구축하기 위한 기본 필수 사항입니다. 속도 제한은 API의 최전선 방어선 역할을 하여 공정한 사용을 보장하고 과부하를 방지하며, 서킷 브레이커는 실패하는 종속성에 대한 중요한 복원력을 제공하여 연쇄 실패를 방지하고 점진적인 성능 저하를 촉진합니다. 이러한 패턴을 전략적으로 적용함으로써 애플리케이션의 안정성과 신뢰성을 크게 향상시키고, 악조건에서도 사용자에게 일관되게 긍정적인 경험을 제공할 수 있습니다. 이러한 패턴으로 API를 보호하는 것은 장기적인 운영 성공을 위한 투자입니다.