[성능 최적화 + 금융공학] 분산 캐시 무효화 전략과 옵션 Greeks 계산 최적화
옵션 포트폴리오 Greeks 계산 API의 지연 문제를 분산 캐시로 해결하면서 겪은 캐시 무효화 전략의 선택과 트레이드오프를 코드와 함께 분석합니다.
옵션 포트폴리오 리스크 대시보드의 응답 시간이 시장 변동성이 높아지는 장중에 p99 기준 2초를 넘는 현상이 발생했습니다. 프로파일링 결과 Delta·Gamma·Vega를 매 요청마다 재계산하는 것이 병목임을 확인했고, Redis 기반 캐시를 도입했습니다. 그런데 캐시 무효화 시점을 잘못 설계해 오히려 오래된 Greeks 값이 서빙되는 문제가 생겼습니다. 이 사고를 통해 분산 캐시의 무효화 전략이 단순한 TTL 설정이 아니라 금융 도메인의 데이터 신선도 요구사항과 긴밀하게 연결돼야 한다는 것을 체감했습니다.
배경
Greeks 계산의 비용
Black-Scholes 모델에서 Delta, Gamma, Vega는 각각 옵션 가격의 편미분값입니다. 포트폴리오 규모가 수백 개 포지션을 넘어가면 매 요청마다 전체 포트폴리오에 대해 이 값을 계산하는 것은 비현실적입니다.
- Delta: 기초자산 가격 변화에 대한 옵션 가격의 1차 편미분
- Gamma: Delta의 기초자산 가격에 대한 편미분 (2차)
- Vega: 내재 변동성(Implied Volatility) 변화에 대한 옵션 가격의 편미분
문제는 Vega 계산에 있었습니다. 내재 변동성 서피스(IV Surface)를 보간해서 각 스트라이크-만기 조합에 맞는 IV를 먼저 구하고, 그 IV를 입력으로 Black-Scholes 편미분을 계산합니다. IV Surface 자체가 수백 개 관측점에서 스플라인 보간을 수행하는 과정이라 요청당 CPU 비용이 높습니다.
캐싱을 도입할 때의 갈등
실시간 계산과 캐싱 사이의 핵심 긴장은 데이터 신선도입니다. 금융 시장에서는 기초자산 가격과 IV Surface가 수초 단위로 변합니다. 캐시 적중률을 높이려면 TTL을 길게 잡아야 하지만, TTL이 길면 오래된 Greeks로 리스크 판단을 내리게 됩니다. 반대로 TTL을 극단적으로 짧게 잡으면 캐시 도입 효과가 없어집니다.
캐시 무효화 전략 4가지
1. TTL 기반 (Time-To-Live)
가장 단순한 전략입니다. 캐시 엔트리에 만료 시간을 설정하고 이후 요청은 재계산을 트리거합니다.
- 장점: 구현 단순, Redis에서
EXPIRE명령 하나로 설정 가능 - 단점: IV Surface가 TTL 내에 급격히 변해도 오래된 값을 서빙함
2. Write-Through
데이터 원본이 변경될 때 즉시 캐시도 갱신합니다. 캐시와 원본이 항상 동기화됩니다.
- 장점: 강한 일관성
- 단점: 모든 쓰기 경로에 캐시 갱신 로직 삽입 필요, 쓰기 지연 증가
3. Write-Behind (Write-Back)
데이터 원본 갱신과 캐시 갱신을 비동기로 처리합니다. 쓰기 성능이 중요한 고빈도 환경에 적합합니다.
- 장점: 쓰기 지연 최소화
- 단점: 비동기 갱신 실패 시 캐시와 원본 불일치 발생, 복구 로직 필요
4. Event-Driven (이벤트 기반)
IV Surface 갱신 이벤트를 발행하고, 캐시 레이어가 해당 이벤트를 구독해 관련 캐시를 무효화합니다. Greeks는 기초자산 가격과 IV에 의존하므로, 이 두 값의 변경 이벤트에 반응하도록 설계합니다.
- 장점: 데이터 신선도와 캐시 적중률을 균형 있게 확보 가능
- 단점: 이벤트 브로커(Kafka, Redis Pub/Sub 등) 인프라 필요, 이벤트 순서 보장 필요
코드 예시: TTL + Event-Driven 혼합 전략
실제로 도입한 방식은 TTL을 안전망으로 두고, IV Surface 갱신 이벤트 발생 시 즉시 무효화하는 혼합 전략입니다.
import redis
import json
import hashlib
from typing import Optional
from dataclasses import dataclass
@dataclass
class Greeks:
delta: float
gamma: float
vega: float
theta: float
class GreeksCache:
def __init__(self, redis_client: redis.Redis, ttl_seconds: int = 30):
self.redis = redis_client
self.ttl = ttl_seconds
def _make_key(self, symbol: str, strike: float, expiry: str) -> str:
raw = f"greeks:{symbol}:{strike}:{expiry}"
return hashlib.sha256(raw.encode()).hexdigest()[:16]
def get(self, symbol: str, strike: float, expiry: str) -> Optional[Greeks]:
key = self._make_key(symbol, strike, expiry)
cached = self.redis.get(key)
if cached:
data = json.loads(cached)
return Greeks(**data)
return None
def set(self, symbol: str, strike: float, expiry: str, greeks: Greeks) -> None:
key = self._make_key(symbol, strike, expiry)
self.redis.setex(key, self.ttl, json.dumps(greeks.__dict__))
def invalidate_by_symbol(self, symbol: str) -> int:
"""IV Surface 갱신 이벤트 수신 시 심볼 전체 무효화"""
pattern = f"greeks:{symbol}:*"
# SCAN으로 키 순회 후 삭제 (KEYS 명령은 운영 환경에서 사용 금지)
cursor = 0
deleted = 0
while True:
cursor, keys = self.redis.scan(cursor, match=pattern, count=100)
if keys:
self.redis.delete(*keys)
deleted += len(keys)
if cursor == 0:
break
return deleted
class IVSurfaceEventHandler:
"""Redis Pub/Sub로 IV Surface 갱신 이벤트를 수신하고 캐시를 무효화합니다."""
def __init__(self, cache: GreeksCache, redis_client: redis.Redis):
self.cache = cache
self.pubsub = redis_client.pubsub()
def subscribe_and_listen(self) -> None:
self.pubsub.subscribe("iv_surface_updated")
for message in self.pubsub.listen():
if message["type"] == "message":
payload = json.loads(message["data"])
symbol = payload.get("symbol")
if symbol:
count = self.cache.invalidate_by_symbol(symbol)
print(f"[캐시 무효화] {symbol}: {count}개 엔트리 삭제")
위 구현에서 주목할 점은 두 가지입니다. 첫째, KEYS 명령 대신 SCAN을 사용해 운영 환경에서 Redis 블로킹을 방지합니다. 둘째, TTL은 이벤트 누락의 안전망 역할을 합니다. 이벤트 브로커 장애로 무효화 이벤트를 수신하지 못하더라도 30초 후에는 자동으로 만료됩니다.
금융공학 관점
IV Surface 갱신과 Greeks 재계산 트리거
IV Surface는 시장 거래 데이터에서 역산(implied)되는 값입니다. 실시간으로 변하며, 특히 VIX가 급등하는 장중에는 수초 단위로 전체 서피스가 재보정됩니다. 이런 상황에서 30초 TTL조차 지나치게 긴 경우가 있습니다.
실무에서는 Greeks의 용도에 따라 신선도 SLA를 분리합니다.
- 실시간 헤지 (Delta hedging): 1초 이내 갱신 필요 — 캐시 없이 직접 계산 또는 TTL 5초
- 리스크 리포팅 (EOD 포지션 확인): 5분 단위 갱신 허용 — TTL 300초
- 포트폴리오 스트레스 테스트: 시나리오별 일괄 계산 — 캐시 불필요, 배치 처리
이 분리를 코드 레벨에서 강제하지 않으면, 리포팅용 캐시가 실시간 헤지 경로에서 우연히 읽히는 사고가 발생합니다. 캐시 키에 용도(purpose) 네임스페이스를 포함시키는 것이 방어적 설계의 출발점입니다.
트레이드오프
| 전략 | 일관성 | 읽기 지연 | 쓰기 지연 | 구현 복잡도 | 금융 적합성 |
|---|---|---|---|---|---|
| TTL 기반 | 약함 (stale 허용) | 낮음 | 낮음 | 매우 낮음 | 리포팅 한정 |
| Write-Through | 강함 | 낮음 | 높음 | 중간 | 중간 |
| Write-Behind | 중간 (비동기) | 낮음 | 매우 낮음 | 높음 | 고빈도 쓰기 |
| Event-Driven | 강함 (이벤트 기반) | 낮음 | 낮음 | 높음 | 실시간 리스크 |
| TTL + Event-Driven | 강함 + 안전망 | 낮음 | 낮음 | 높음 | 권장 혼합 |
결론
금융 도메인에서 캐시 무효화 전략을 선택할 때는 다음 세 가지 질문에 먼저 답해야 합니다.
첫째, 이 데이터로 내리는 결정의 시간 감도는 얼마인가? 실시간 헤지와 EOD 리포팅은 요구하는 신선도가 완전히 다릅니다.
둘째, 이벤트 브로커 장애 시 허용 가능한 stale 시간은 얼마인가? 이 값이 TTL의 하한선이 됩니다.
셋째, 캐시 무효화 실패가 금전적 손실로 직결되는가? 그렇다면 캐시 레이어에 단독으로 의존하지 않고 원본 계산 경로를 폴백으로 유지해야 합니다.
TTL 하나로 모든 Greeks 캐시를 관리하던 초기 설계의 오류는, 결국 금융 도메인의 비기능 요구사항을 시스템 설계에 반영하지 않은 데서 비롯됐습니다. 캐시 무효화는 “어떤 전략을 쓸 것인가”보다 “언제 무효화해야 정확한 결정을 내릴 수 있는가”라는 도메인 질문에서 출발해야 합니다.