Single-Flight 기반 캐시 미스 병합
Single-Flight는 같은 key로 겹쳐 들어온 호출을 하나의 실행에 합치는 동시성 패턴이다.
연습 메모 · 31 min read · Easy
Single-Flight는 같은 key로 겹쳐 들어온 호출을 하나의 실행에 합치는 동시성 패턴이다. 캐시가 비어 있는 순간 상품 하나를 찾는 요청이 100개 몰렸다면 DB 조회는 한 번만 수행하고, 나머지 요청은 그 실행의 값 또는 오류를 함께 받는다.
Cache-Aside같은 패턴은 평소에는 단순하지만 인기 key가 만료되는 짧은 구간에 약점이 드러난다.1여러 요청이 같은 cache miss를 보고 각자 DB나 외부 API로 내려가면, 캐시가 막아 주던 부하가 한꺼번에 원본 저장소로 쏟아진다.
이 현상을 cache stampede 또는 thundering herd라고 부른다. Google Zanzibar도 이 문제를 막기 위해 서버별 lock table을 두고, 같은 cache key를 쓰는 요청 가운데 하나만 처리를 시작하게 했다.2
Go 프로젝트의 보조 모듈 golang.org/x/sync에는 이 동작을 그대로 구현한 singleflight 패키지가 있다. 표준 라이브러리는 아니며, 패키지 문서는 이를 "duplicate function call suppression"으로 설명한다. 같은 key의 작업이 진행 중이면 뒤따른 호출은 최초 실행이 끝날 때까지 기다리고 같은 값과 오류를 받는다.3
내부 실행 방식도 비교해 볼 만한데, Group.Do에서는 최초 호출자가 자신의 goroutine에서 fn을 직접 실행한다. 같은 key로 들어온 duplicate goroutine은 map을 보호하는 mutex를 놓은 뒤 WaitGroup.Wait()에서 결과를 기다린다. Loader를 실행하는 동안 전역 mutex를 계속 잡고 있는 구조는 아니다.
반면 DoChan은 go g.doCall(...)로 작업을 별도 goroutine에 올리고 결과 channel을 바로 반환한다. "Go singleflight는 background goroutine을 만들지 않는다"는 설명은 Do에는 맞지만 DoChan까지 포함하면 잘못 된 이해다.(개인적으로 아직도 헷갈리는 지점이다 GO에 대해서는 아직 능숙하지 않아서)4
이 메모에서 보관하는 것은 완료된 값이 아니라 아직 끝나지 않은 실행이다.
작업이 끝나면 in-flight entry를 지운다. TTL, eviction, stale data는 캐시 정책으로 남고, Single-Flight는 겹쳐 있는 시간 동안의 중복 실행만 줄인다.
핵심 공식:
Single-Flight
= 같은 Key의 동시 작업 N개
→ 실제 Loader 실행 1개
→ 결과를 N명의 Waiter가 공유
In-Flight Registry
= Key → 현재 실행 중인 Promise / Task / Future
Cache
= 완료된 값을 일정 기간 보존
Single-Flight
= 완료 전 작업만 일시적으로 공유
불변식:
동일 key에는 동시에 loader가 하나만 실행된다.
다른 key의 loader는 서로 독립적으로 실행된다.
loader가 끝나면 성공과 실패에 관계없이 entry를 제거한다.
waiter 하나가 떠나도 다른 waiter가 쓰는 loader는 계속 실행된다.1. 문제 상황(시나리오)
상품 상세 조회 서비스가 다음 cache-aside 흐름을 사용한다고 하자.
상품 조회 요청
→ cache 조회
→ cache hit이면 즉시 반환
→ cache miss이면 DB 조회
→ cache 저장
→ 결과 반환평상시에는 문제가 없을 것이다.
그러나 인기 상품 product-42의 cache가 만료된 순간 요청 100개가 동시에 들어오면 다음과 같이 동작할 수 있다.(삶과 우주,그리고 모든 것에 대한 답은 42이니까 인기상품일 수밖에 없다)
요청 1 cache miss → DB 조회
요청 2 cache miss → DB 조회
요청 3 cache miss → DB 조회
...
요청 100 cache miss → DB 조회원하는 동작은 다음과 같다.
요청 1
→ product-42 loader 실행권 획득
→ DB 조회와 cache 저장 수행
요청 2~100
→ 같은 product-42 작업이 진행 중임을 확인
→ 별도 DB 조회 없이 기존 작업 대기
→ 동일 결과 공유다른 상품은 병렬로 처리할 수 있어야 한다.
product-42 → loader 1개
product-77 → loader 1개
두 key의 loader는 동시에 실행 가능이 문서의 범위는 프로세스 내부에서 동일 cache key의 동시 cache miss를 하나의 loader 실행으로 병합하는 데까지다. 여러 서버를 가로지르는 조정은 뒤에서 별도로 다룬다.
2. 핵심 표현
공통 API는 다음과 같다.
SingleFlight.run(key, loader, callerCancellation)
key
= 병합 단위
loader
= 실제 DB·API 조회와 cache 저장을 수행하는 공유 작업
callerCancellation
= 개별 호출자의 대기만 중단
serviceShutdown
= 공유 loader 자체를 중단할 수 있는 상위 수명 신호한 호출자가 연결을 끊었다는 이유로 다른 호출자 99명이 기다리는 공유 loader까지 취소해서는 안 된다. 따라서 호출자 취소와 공유 작업 취소를 분리한다.
반환값이 참조형이라면 모든 waiter가 같은 객체를 받을 수도 있다. 공유 결과는 불변 값으로 취급하거나, 호출 경계에서 복사해야 한다. 한 waiter가 결과 객체를 수정하고 다른 waiter가 그 흔적을 보는 상황은 Single-Flight가 해결해 주지 않는다.
C#
using System;
using System.Collections.Generic;
using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;
public sealed class SingleFlight<TKey, TValue>
where TKey : notnull
{
private readonly ConcurrentDictionary<
TKey,
Lazy<Task<TValue>>> inFlight = new();
private readonly CancellationToken shutdownToken;
public SingleFlight(
CancellationToken shutdownToken = default)
{
this.shutdownToken = shutdownToken;
}
public async Task<TValue> RunAsync(
TKey key,
Func<CancellationToken, Task<TValue>> loaderAsync,
CancellationToken callerToken = default)
{
ArgumentNullException.ThrowIfNull(loaderAsync);
Lazy<Task<TValue>>? candidate = null;
candidate = new Lazy<Task<TValue>>(
() => this.ExecuteAndReleaseAsync(
key,
candidate!,
loaderAsync),
LazyThreadSafetyMode.ExecutionAndPublication);
Lazy<Task<TValue>> selected =
this.inFlight.GetOrAdd(key, candidate);
return await selected.Value
.WaitAsync(callerToken)
.ConfigureAwait(false);
}
public int GetInFlightCount()
{
return this.inFlight.Count;
}
private async Task<TValue> ExecuteAndReleaseAsync(
TKey key,
Lazy<Task<TValue>> entry,
Func<CancellationToken, Task<TValue>> loaderAsync)
{
try
{
return await loaderAsync(this.shutdownToken)
.ConfigureAwait(false);
}
finally
{
this.RemoveIfCurrent(key, entry);
}
}
private void RemoveIfCurrent(
TKey key,
Lazy<Task<TValue>> entry)
{
// key만 보고 지우면, 교체된 새 entry까지 제거할 수 있다.
// key와 value가 모두 같은 경우에만 현재 entry를 삭제한다.
this.inFlight.TryRemove(new KeyValuePair<
TKey,
Lazy<Task<TValue>>>(key, entry));
}
}ConcurrentDictionary.GetOrAdd()의 value factory는 경쟁 중 여러 번 호출될 수 있다. 사전에 만든 값 가운데 하나만 dictionary에 들어갈 뿐, factory 자체가 단 한 번 실행되는 것은 아니다.5그래서 실제 loader를 Lazy<Task<TValue>> 안에 넣고, dictionary가 선택한 Lazy의 Value만 시작한다.
개별 호출자는 WaitAsync(callerToken)으로 자신의 대기만 취소한다. 공유 loader에는 서비스 종료용 shutdownToken만 전달한다.
정리할 때는 TryRemove(KeyValuePair<TKey,TValue>)를 사용한다. 이 overload는 key와 value가 모두 현재 entry와 일치할 때만 제거하므로, 같은 key에 새 작업이 등록된 뒤 오래된 작업이 그 entry까지 지우는 일을 막는다.6
서비스 종료 토큰과 별개로 loader timeout도 두는 편이 좋다. 모든 waiter가 하나의 작업을 기다리므로 loader가 끝없이 멈추면 entry와 waiter가 함께 남는다. 다만 CancellationToken은 강제 중단 장치가 아니다. DB driver나 HTTP client가 토큰을 실제 I/O에 전달해야 timeout이 효력을 가진다.
TypeScript
export type AsyncLoader<TValue> = (
shutdownSignal?: AbortSignal,
) => Promise<TValue>;
export class SingleFlight<TValue>
{
readonly #inFlight =
new Map<string, Promise<TValue>>();
readonly #shutdownSignal: AbortSignal | undefined;
public constructor(
shutdownSignal?: AbortSignal,
)
{
this.#shutdownSignal = shutdownSignal;
}
public async runAsync(
key: string,
loaderAsync: AsyncLoader<TValue>,
callerSignal?: AbortSignal,
): Promise<TValue>
{
validateKey(key);
callerSignal?.throwIfAborted();
const sharedPromise = this.#getOrCreate(
key,
loaderAsync,
);
return await waitForSharedAsync(
sharedPromise,
callerSignal,
);
}
public getInFlightCount(): number
{
return this.#inFlight.size;
}
#getOrCreate(
key: string,
loaderAsync: AsyncLoader<TValue>,
): Promise<TValue>
{
const existing = this.#inFlight.get(key);
if (existing !== undefined) {
return existing;
}
const created = Promise.resolve().then(() =>
{
return loaderAsync(this.#shutdownSignal);
});
this.#inFlight.set(key, created);
this.#registerCleanup(key, created);
return created;
}
#registerCleanup(
key: string,
promise: Promise<TValue>,
): void
{
const cleanup = (): void =>
{
if (this.#inFlight.get(key) === promise) {
this.#inFlight.delete(key);
}
};
void promise.then(cleanup, cleanup);
}
}
function waitForSharedAsync<TValue>(
sharedPromise: Promise<TValue>,
callerSignal?: AbortSignal,
): Promise<TValue>
{
if (callerSignal === undefined) {
return sharedPromise;
}
return new Promise<TValue>((resolve, reject) =>
{
let settled = false;
const cleanup = (): void =>
{
callerSignal.removeEventListener(
"abort",
onAbort,
);
};
const onAbort = (): void =>
{
if (settled) {
return;
}
settled = true;
cleanup();
reject(callerSignal.reason);
};
callerSignal.addEventListener(
"abort",
onAbort,
{ once: true },
);
// 사전 throwIfAborted()와 listener 등록 사이에 abort가 발생할 수 있다.
// listener를 붙인 뒤 상태를 다시 확인해 그 틈을 닫는다.
if (callerSignal.aborted) {
onAbort();
return;
}
sharedPromise.then(
(value) =>
{
if (settled) {
return;
}
settled = true;
cleanup();
resolve(value);
},
(error: unknown) =>
{
if (settled) {
return;
}
settled = true;
cleanup();
reject(error);
},
);
});
}
function validateKey(key: string): void
{
if (key.trim().length === 0) {
throw new RangeError(
"Single-Flight key는 비어 있을 수 없습니다.",
);
}
}JavaScript에서는 Map.get()과 Map.set() 사이에 await가 없으므로 같은 event loop 안에서 entry 등록이 동기적으로 끝난다. Loader 실행은 Promise.resolve().then(...) 이후로 미뤄 entry가 먼저 등록되도록 한다.
호출자의 AbortSignal은 waitForSharedAsync()의 대기만 중단한다. 이미 실행 중인 공유 Promise 자체는 다른 waiter를 위해 계속 수행된다. AbortSignal 기반 Promise API는 아직 끝나지 않은 작업을 signal의 reason으로 거절해야 한다는 MDN 지침도 이 구분과 맞는다.7
처음의 throwIfAborted()만으로는 충분하지 않고, 그 검사 직후, abort listener를 등록하기 직전에 취소가 들어오면 event를 놓칠 수 있다. 예제는 listener 등록 뒤 aborted를 한 번 더 확인하는 방식으로 했다. 그게 보통은 안전하다.
Python
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable
from typing import Generic, TypeVar
TValue = TypeVar("TValue")
AsyncLoader = Callable[[], Awaitable[TValue]]
class SingleFlight(Generic[TValue]):
def __init__(self) -> None:
self._in_flight: dict[
str,
asyncio.Task[TValue],
] = {}
self._lock = asyncio.Lock()
async def run(
self,
key: str,
loader_async: AsyncLoader[TValue],
) -> TValue:
_validate_key(key)
task = await self._get_or_create(
key,
loader_async,
)
return await asyncio.shield(task)
def get_in_flight_count(self) -> int:
return len(self._in_flight)
async def _get_or_create(
self,
key: str,
loader_async: AsyncLoader[TValue],
) -> asyncio.Task[TValue]:
async with self._lock:
existing = self._in_flight.get(key)
if existing is not None:
return existing
task = asyncio.create_task(
self._execute_and_release(
key,
loader_async,
),
name=f"single-flight:{key}",
)
self._in_flight[key] = task
task.add_done_callback(
_observe_task_completion
)
return task
async def _execute_and_release(
self,
key: str,
loader_async: AsyncLoader[TValue],
) -> TValue:
try:
return await loader_async()
finally:
current_task = asyncio.current_task()
async with self._lock:
current = self._in_flight.get(key)
if current is current_task:
self._in_flight.pop(key, None)
def _observe_task_completion(
task: asyncio.Task[object],
) -> None:
if task.cancelled():
return
task.exception()
def _validate_key(key: str) -> None:
if key.strip() == "":
raise ValueError(
"Single-Flight key는 비어 있을 수 없습니다."
)asyncio.shield()는 개별 waiter가 취소되더라도 공유 task가 함께 취소되는 것을 막는다. Python 문서가 shield()에 넘긴 task의 강한 참조를 보관하라고 경고하는 이유도 여기에 있다. event loop는 task에 약한 참조만 유지할 수 있으므로, 이 예제에서는 _in_flight dictionary가 공유 task를 붙잡는다.8
정리는 loader의 finally에서 수행한다. 완료 callback이 별도 cleanup task를 예약하는 방식보다 entry 제거 시점이 분명하므로, 모든 waiter가 떠난 뒤 loader가 실패하는 경우에도 예외를 관측하도록 done callback은 남겨 둔다.
Rust
use std::collections::HashMap;
use std::future::Future;
use std::hash::Hash;
use std::sync::atomic::{
AtomicU64,
Ordering,
};
use std::sync::Arc;
use tokio::sync::{
Mutex,
Notify,
OnceCell,
};
#[derive(Clone, Debug)]
pub enum SingleFlightError<TError> {
Loader(TError),
LoaderTaskFailed(String),
}
struct Entry<TValue, TError> {
id: u64,
result: OnceCell<
Result<TValue, SingleFlightError<TError>>
>,
notify: Notify,
}
impl<TValue, TError> Entry<TValue, TError> {
fn new(id: u64) -> Self {
Self {
id,
result: OnceCell::new(),
notify: Notify::new(),
}
}
}
pub struct SingleFlight<TKey, TValue, TError> {
in_flight:
Mutex<HashMap<TKey, Arc<Entry<TValue, TError>>>>,
next_id: AtomicU64,
}
impl<TKey, TValue, TError>
SingleFlight<TKey, TValue, TError>
where
TKey:
Eq
+ Hash
+ Clone
+ Send
+ 'static,
TValue:
Clone
+ Send
+ Sync
+ 'static,
TError:
Clone
+ Send
+ Sync
+ 'static,
{
pub fn new() -> Arc<Self> {
Arc::new(Self {
in_flight: Mutex::new(HashMap::new()),
next_id: AtomicU64::new(1),
})
}
pub async fn run<TLoader, TFuture>(
self: &Arc<Self>,
key: TKey,
loader: TLoader,
) -> Result<TValue, SingleFlightError<TError>>
where
TLoader:
FnOnce() -> TFuture
+ Send
+ 'static,
TFuture:
Future<Output = Result<TValue, TError>>
+ Send
+ 'static,
{
let (entry, is_leader) =
self.get_or_create(&key).await;
if is_leader {
self.spawn_loader(
key,
Arc::clone(&entry),
loader,
);
}
wait_for_result(entry).await
}
pub async fn get_in_flight_count(&self) -> usize {
self.in_flight.lock().await.len()
}
async fn get_or_create(
&self,
key: &TKey,
) -> (Arc<Entry<TValue, TError>>, bool) {
let mut in_flight = self.in_flight.lock().await;
if let Some(entry) = in_flight.get(key) {
return (Arc::clone(entry), false);
}
let id = self.next_id.fetch_add(
1,
Ordering::Relaxed,
);
let entry = Arc::new(Entry::new(id));
in_flight.insert(
key.clone(),
Arc::clone(&entry),
);
(entry, true)
}
fn spawn_loader<TLoader, TFuture>(
self: &Arc<Self>,
key: TKey,
entry: Arc<Entry<TValue, TError>>,
loader: TLoader,
)
where
TLoader:
FnOnce() -> TFuture
+ Send
+ 'static,
TFuture:
Future<Output = Result<TValue, TError>>
+ Send
+ 'static,
{
let owner = Arc::clone(self);
tokio::spawn(async move
{
// loader를 한 단계 안쪽 task로 실행한다.
// loader 내부 panic!이나 task abort는 JoinError가 된다.
// 이를 terminal error로 공유해야 waiter가 영구 대기하지 않는다.
let loader_task = tokio::spawn(async move {
loader().await
});
let result = match loader_task.await {
Ok(Ok(value)) => Ok(value),
Ok(Err(error)) => {
Err(SingleFlightError::Loader(error))
}
Err(join_error) => {
Err(SingleFlightError::LoaderTaskFailed(
join_error.to_string(),
))
}
};
let _ = entry.result.set(result);
entry.notify.notify_waiters();
owner.remove_if_current(
&key,
entry.id,
).await;
});
}
async fn remove_if_current(
&self,
key: &TKey,
entry_id: u64,
) {
let mut in_flight = self.in_flight.lock().await;
let should_remove = in_flight
.get(key)
.is_some_and(|entry| entry.id == entry_id);
if should_remove {
in_flight.remove(key);
}
}
}
async fn wait_for_result<TValue, TError>(
entry: Arc<Entry<TValue, TError>>,
) -> Result<TValue, SingleFlightError<TError>>
where
TValue: Clone,
TError: Clone,
{
loop {
if let Some(result) = entry.result.get() {
return result.clone();
}
let notified = entry.notify.notified();
if let Some(result) = entry.result.get() {
return result.clone();
}
notified.await;
}
}필요한 의존성:
[dependencies]
tokio = {
version = "1",
features = [
"macros",
"rt-multi-thread",
"sync"
]
}Rust 구현은 loader를 별도 Tokio task로 실행한다. 개별 waiter future가 drop되어도 loader는 계속 수행되며, 다른 waiter가 동일 결과를 받을 수 있다.
loader가 panic하면 원래 task는 OnceCell을 채우거나 entry를 지우기 전에 끝날 수 있다. 그러면 waiter는 영원히 깨어나지 않고 map에도 entry가 남는다. 예제에서는 loader를 내부 task에서 실행하고 JoinError를 LoaderTaskFailed로 바꾸어, 성공과 업무 오류뿐 아니라 panic과 abort도 모든 waiter가 볼 수 있는 종료 상태로 만든다.
wait_for_result()가 notified()를 만든 뒤 결과를 다시 확인하는 순서도 의도적이다. Tokio의 notify_waiters()는 Notified future가 생성된 뒤 발생한 알림이라면 아직 poll되지 않았어도 전달한다고 보장한다.9이 보장에 기대지 않고 notify_one()으로 바꾸면 별도의 enable() 처리까지 검토해야 한다.
3. 호출부
Single-Flight는 cache-aside의 cache miss 경계에 배치한다.
type Product = Readonly<{
productId: string;
displayName: string;
priceCents: number;
}>;
type ProductCache = Readonly<{
getAsync: (
cacheKey: string,
signal?: AbortSignal,
) => Promise<Product | undefined>;
trySetAsync: (
cacheKey: string,
product: Product,
ttlSeconds: number,
signal?: AbortSignal,
) => Promise<boolean>;
}>;
type ProductRepository = Readonly<{
getByIdAsync: (
productId: string,
signal?: AbortSignal,
) => Promise<Product>;
}>;
export class ProductQueryService
{
readonly #cache: ProductCache;
readonly #repository: ProductRepository;
readonly #singleFlight: SingleFlight<Product>;
readonly #cacheTtlSeconds: number;
public constructor(
cache: ProductCache,
repository: ProductRepository,
singleFlight: SingleFlight<Product>,
cacheTtlSeconds: number,
)
{
validateCacheTtl(cacheTtlSeconds);
this.#cache = cache;
this.#repository = repository;
this.#singleFlight = singleFlight;
this.#cacheTtlSeconds = cacheTtlSeconds;
}
public async getByIdAsync(
productId: string,
callerSignal?: AbortSignal,
): Promise<Product>
{
validateProductId(productId);
const cacheKey = productCacheKey(productId);
const cached = await this.#cache.getAsync(
cacheKey,
callerSignal,
);
if (cached !== undefined) {
return cached;
}
return await this.#singleFlight.runAsync(
cacheKey,
(sharedSignal) =>
{
return this.#loadAndCacheAsync(
cacheKey,
productId,
sharedSignal,
);
},
callerSignal,
);
}
async #loadAndCacheAsync(
cacheKey: string,
productId: string,
sharedSignal?: AbortSignal,
): Promise<Product>
{
const cached = await this.#cache.getAsync(
cacheKey,
sharedSignal,
);
if (cached !== undefined) {
return cached;
}
const product = await this.#repository.getByIdAsync(
productId,
sharedSignal,
);
// 원본 조회가 성공했다면 cache write 실패가
// 전체 읽기 실패로 번지지 않게 best-effort로 처리한다.
await this.#cache.trySetAsync(
cacheKey,
product,
this.#cacheTtlSeconds,
sharedSignal,
);
return product;
}
}
function productCacheKey(productId: string): string
{
return `product:detail:v1:${productId}`;
}
function validateProductId(productId: string): void
{
if (!/^[A-Za-z0-9_-]{1,64}$/.test(productId)) {
throw new RangeError(
"productId 형식이 유효하지 않습니다.",
);
}
}
function validateCacheTtl(cacheTtlSeconds: number): void
{
if (!Number.isSafeInteger(cacheTtlSeconds)
|| cacheTtlSeconds < 1
|| cacheTtlSeconds > 86_400) {
throw new RangeError(
"cache TTL은 1초 이상 86,400초 이하이어야 합니다.",
);
}
}Loader 내부에서 cache를 한 번 더 확인한다. 최초 cache miss와 loader 실행 사이에 다른 프로세스가 cache를 채웠을 수 있기 때문이다.
Single-Flight registry와 cache는 같은 canonical key를 사용한다. 이 둘의 key builder가 갈라지면 cache에서는 같은 항목인데 registry에서는 다른 작업으로 보거나, 반대로 서로 다른 결과를 하나로 합칠 수 있다.
캐시 저장은 best-effort 경계로 두었다. DB 조회가 성공했는데 Redis 쓰기 한 번이 실패했다는 이유로 모든 waiter에게 조회 실패를 반환하면 캐시가 가용성을 낮추는 셈이다.
trySetAsync() 구현은 실패를 로그와 metric으로 남기되 false를 반환한다. 반면 원본 저장소 오류는 loader 오류로 전파한다.
보통의 책임 분리:
ProductCache
= 완료된 값을 TTL 동안 저장
ProductRepository
= 실제 DB·외부 API 조회 I/O
SingleFlight
= 동일 key의 진행 중 loader를 공유
ProductQueryService
= cache hit/miss와 loader 호출 순서 조립
Caller Signal
= 개별 요청의 대기 취소
Shared Shutdown Signal
= 프로세스 종료 시 공유 loader 취소
Cache TTL Policy
= 데이터 신선도와 만료 시점 결정Single-Flight는 Product의 업무 의미나 TTL을 알지 못한다. Cache는 현재 loader가 몇 명의 waiter에게 공유되는지 알지 못한다.
사실 반드시 이럴 필요는 없지만 대부분의 예제를 개인적으로 종합해봤을때 나온 것들을 모아둔 것이다. 즉 평균적인 모양인데, 규칙만 이해하면 된다.
개별 Caller의 취소가 공유 loader를 취소해서는 안 된다.
나머지는 프로젝트의 스케일에 따라서 어떻게 변하느냐일 뿐이다.
4. 핵심 순서
병합 key는 무엇인가
→ 같은 업무 요청이 정말 같은 key를 사용하는가
→ in-flight entry 등록이 loader 시작보다 먼저인가
→ 동일 key에서 loader가 하나만 실행되는가
→ 다른 key는 병렬 실행 가능한가
→ 완료·실패·취소 후 entry가 제거되는가
→ loader 실패가 모든 waiter에게 동일하게 전달되는가
→ 개별 waiter 취소가 공유 loader를 취소하지 않는가
→ loader 안에서 cache를 다시 확인하는가
→ 이 registry가 process-local인지 distributed인지 명확한가가장 먼저 확인할 것은 lock의 존재가 아니라 무엇을 동일 작업으로 간주하는 key 설계다.
5. 경계와 오해
Single-Flight는 cache가 아니라는 걸 주의해야한다. 비슷하지만 결이다르다.
캐시는 일종의 '값(value)'을 저장하고, Single-Flight은 '작업(Task)'을 저장한다.
Cache:
완료된 결과를 TTL 동안 재사용
Single-Flight:
현재 실행 중인 동일 작업만 공유
완료 후 entry 제거따라서 loader가 끝난 직후 새 요청이 들어오면 cache가 결과를 보관하고 있어야 한다. Cache 저장 없이 Single-Flight만 사용하면 순차적으로 들어온 요청은 매번 loader를 다시 실행한다.
병합 key는 결과에 영향을 주는 모든 조건을 포함해야 한다.
나쁜 key:
productId
실제 결과가 다음에 따라 달라진다면:
- tenantId
- locale
- currency
- permission scope
- API version
권장 key:
tenantId + productId + locale + currency + policyVersion서로 다른 권한의 요청을 같은 key로 병합하면 한 사용자의 결과가 다른 사용자에게 전달될 수 있다. 이는 단순 성능 버그가 아니라 데이터 노출 문제라고 할 수 있다.
이 카드의 구현은 프로세스 내부에서만 동작한다.
서버 instance A:
product-42 loader 1개
서버 instance B:
product-42 loader 1개
전체 시스템:
동시에 loader 2개 실행 가능여러 instance 전체에서 하나의 loader만 허용해야 한다면 distributed lock, lease, cache provider의 원자적 primitive 또는 별도 coordinator가 필요하다. 다만 분산 락은 실행권만 정할 뿐 결과까지 전달하지 않는다. Leader가 cache를 채우고 follower가 cache를 다시 읽을지, pub/sub로 결과를 알릴지, lease가 만료된 뒤 늦게 끝난 leader를 fencing token으로 막을지까지 정해야 한다. process-local Single-Flight에 Redis lock 하나를 덧붙였다고 같은 성질이 생기지는 않는다.
개별 waiter 취소 정책도 중요하다. 첫 번째 요청이 loader를 시작한 뒤 연결을 끊었다고 공유 loader까지 취소하면 다른 waiter가 모두 실패한다. 이번 구현은 caller cancellation이 대기만 중단하게 한다.
반대 비용도 있다.
모든 waiter가 취소됨
→ loader는 계속 실행될 수 있음Loader 비용이 매우 크다면 waiter reference count를 두어 마지막 waiter가 떠날 때 취소하는 정책을 고려할 수 있다. 하지만 취소와 재진입 경쟁이 복잡해지므로 측정 없이 추가하지 않는 편이 낫다.
실패도 공유되는 지점이 있다.
동일 key loader가 DB timeout
→ 모든 waiter가 같은 timeout을 받음
→ in-flight entry 제거
→ 다음 요청은 새 loader 실행 가능실패 결과를 in-flight registry에 영구 저장하면 안 된다. 일시적 장애가 복구된 뒤에도 실패가 계속 재생될 수 있다. 반대로 존재하지 않는 상품의 반복 조회가 문제라면 그것은 Single-Flight가 아니라 negative caching 정책으로 다뤄야 한다.
실패 직후에도 작은 파도가 생길 수 있다.
loader 1회 실패
→ waiter 500명이 동시에 실패를 받음
→ 상위 계층이 모두 즉시 재시도
→ 다음 single-flight loader에 다시 500명이 몰림재시도 횟수 제한, exponential backoff, jitter, 짧은 failure damping 또는 negative cache를 함께 검토한다. Single-Flight는 중복 실행을 줄이지만 재시도 정책을 대신하지 않는다.
Loader에는 상한 시간이 필요하다. 모든 waiter가 같은 완료 객체를 기다리므로, 종료되지 않는 loader 하나가 registry entry와 waiter를 함께 붙잡는다. Timeout은 개별 waiter가 아니라 공유 loader의 I/O 경계에도 적용해야 한다.
Key cardinality도 무제한으로 두지 않는다. 외부 입력이 그대로 key가 되고 공격자가 매번 다른 값을 보내면, 각 key는 병합되지 않은 새 작업이 된다. in_flight 최대 크기, key 검증, 요청 rate limit, waiter 상한, admission control 중 필요한 장치를 둔다.
프로덕션에서 실패하는 사례:
- cache miss마다 loader를 새로 생성함
- in-flight entry 등록 전에 loader가 시작됨
- 완료 후 entry를 제거하지 않아 메모리가 증가함
- 실패한 Promise나 Task를 영구 보존함
- caller cancellation이 공유 loader까지 취소함
- tenant와 권한 정보를 key에서 제외함
- object reference를 key로 써서 의미상 같은 요청이 병합되지 않음
- process-local 병합을 distributed 보장으로 오해함
- loader 내부에서 cache를 다시 확인하지 않음
- Single-Flight가 TTL과 stale 정책까지 해결한다고 오해함
- loader timeout이 없어 entry와 waiter가 끝없이 남음
- 공격자 입력으로 in-flight key cardinality가 계속 증가함
- 공유된 mutable 결과를 waiter가 서로 수정함
- loader 실패 뒤 모든 waiter가 즉시 재시도함
- distributed lock이 결과 공유까지 해 준다고 가정함6. 잘못된 예제
async function getProductBadAsync(
productId: string,
cache: ProductCache,
repository: ProductRepository,
signal?: AbortSignal,
): Promise<Product>
{
const cacheKey = productCacheKey(productId);
const cached = await cache.getAsync(
cacheKey,
signal,
);
if (cached !== undefined) {
return cached;
}
const product = await repository.getByIdAsync(
productId,
signal,
);
await cache.trySetAsync(
cacheKey,
product,
300,
signal,
);
return product;
}단일 요청만 보면 올바른 cache-aside 코드다. 그러나 동시 요청에서는 다음 경쟁이 발생한다.
요청 A cache miss
요청 B cache miss
요청 C cache miss
A DB 조회
B DB 조회
C DB 조회나쁜 이유:
- cache miss와 DB 조회 사이에 동시성 조정이 없다.
- 인기 key가 만료될 때 backend 요청이 순간적으로 폭증한다.
- cache를 채우는 동일 작업이 여러 번 실행된다.
- 모든 요청이 DB connection과 network slot을 각각 소비한다.
- 평상시 테스트에서는 문제를 발견하기 어렵다.함수 전체에 global mutex 하나를 걸어도 좋은 해결이 아니다.
global lock:
product-42 조회 중
→ product-77 조회도 대기Single-Flight는 전체 서비스를 직렬화하지 않고 동일 key만 직렬화해야 한다는 걸 잊으면 안된다.
7. 프로덕션 확장
핵심 계약은 동시성 테스트로 고정한다.
import assert from "node:assert/strict";
import test from "node:test";
test("같은 key의 동시 요청은 loader를 한 번만 실행한다", async () =>
{
const singleFlight = new SingleFlight<string>();
let loaderCallCount = 0;
const loaderAsync = async (): Promise<string> =>
{
loaderCallCount += 1;
await delayAsync(20);
return "product-42";
};
const results = await Promise.all(
Array.from(
{ length: 50 },
() => singleFlight.runAsync(
"product-42",
loaderAsync,
),
),
);
assert.equal(loaderCallCount, 1);
assert.deepEqual(
new Set(results),
new Set(["product-42"]),
);
assert.equal(
singleFlight.getInFlightCount(),
0,
);
});
test("서로 다른 key는 각각 loader를 실행한다", async () =>
{
const singleFlight = new SingleFlight<string>();
let loaderCallCount = 0;
const loadAsync = async (
value: string,
): Promise<string> =>
{
loaderCallCount += 1;
await delayAsync(10);
return value;
};
const [first, second] = await Promise.all([
singleFlight.runAsync(
"product-42",
() => loadAsync("first"),
),
singleFlight.runAsync(
"product-77",
() => loadAsync("second"),
),
]);
assert.equal(loaderCallCount, 2);
assert.equal(first, "first");
assert.equal(second, "second");
});
test("실패한 entry는 제거되어 다음 요청이 재시도할 수 있다", async () =>
{
const singleFlight = new SingleFlight<string>();
let attemptCount = 0;
await assert.rejects(() =>
{
return singleFlight.runAsync(
"product-42",
async () =>
{
attemptCount += 1;
throw new Error("temporary failure");
},
);
});
const result = await singleFlight.runAsync(
"product-42",
async () =>
{
attemptCount += 1;
return "recovered";
},
);
assert.equal(attemptCount, 2);
assert.equal(result, "recovered");
});
test("한 waiter의 취소는 공유 loader를 취소하지 않는다", async () =>
{
const singleFlight = new SingleFlight<string>();
const controller = new AbortController();
let loaderCallCount = 0;
const loaderAsync = async (): Promise<string> =>
{
loaderCallCount += 1;
await delayAsync(30);
return "shared-result";
};
const cancelledWaiter = singleFlight.runAsync(
"product-42",
loaderAsync,
controller.signal,
);
const survivingWaiter = singleFlight.runAsync(
"product-42",
loaderAsync,
);
controller.abort(
new Error("caller disconnected"),
);
await assert.rejects(
() => cancelledWaiter,
);
assert.equal(
await survivingWaiter,
"shared-result",
);
assert.equal(loaderCallCount, 1);
});
function delayAsync(
milliseconds: number,
): Promise<void>
{
return new Promise((resolve) =>
{
setTimeout(resolve, milliseconds);
});
}운영 metric:
single_flight.request.count
single_flight.loader.started.count
single_flight.waiter.shared.count
single_flight.loader.succeeded.count
single_flight.loader.failed.count
single_flight.in_flight
single_flight.waiter.duration_ms
single_flight.loader.duration_ms파생값은 이름과 식을 함께 고정한다. coalescing ratio라는 이름만 쓰면 팀마다 역수를 뜻하는 경우가 있다.
requests per loader
= 전체 Single-Flight 요청 수 / 실제 loader 실행 수
예:
요청 1,000건
loader 50건
requests per loader = 20
loader suppression ratio
= 1 - (실제 loader 실행 수 / 전체 Single-Flight 요청 수)
loader suppression ratio = 95%Metric attribute에는 raw product ID나 cache key 전체를 넣지 않는다. operationName, result, cacheRegion처럼 값의 종류가 제한된 항목만 사용한다. OpenTelemetry도 user ID나 raw URL처럼 고유 조합이 계속 늘어나는 attribute가 메모리 비용과 cardinality overflow를 만들 수 있다고 경고한다.10
운영에서 함께 확인할 항목:
- cache hit ratio
- 동일 key 동시 waiter 수
- loader p95/p99 latency
- DB connection pool 사용량
- loader 실패율
- instance별 requests per loader
- in-flight key 수와 key 생성 거부 수
- key당 현재 waiter 수와 waiter 상한 도달 수Single-Flight 적용 후 loader 호출 수는 줄었지만 waiter latency가 지나치게 증가한다면 하나의 느린 loader에 너무 많은 요청이 묶이는 hot-key 문제가 있을 수 있다.
8. C# / TypeScript / Python / Rust 비교 메모
언어 | 관용적 표현 | 주요 주의점 |
|---|---|---|
C# |
| factory 중복 호출과 |
TypeScript |
| loader 시작 전에 Promise를 map에 등록해야 함 |
Python |
| 하나의 event loop 안에서 사용한다는 전제가 필요함 |
Rust |
| waiter drop, loader panic, entry 정리를 함께 다뤄야 함 |
- 언어
C#
- 관용적 표현
ConcurrentDictionary<TKey, Lazy<Task<T>>>- 주요 주의점
factory 중복 호출과
Count의 관측 비용을 구분해야 함
- 언어
TypeScript
- 관용적 표현
Map<string, Promise<T>>- 주요 주의점
loader 시작 전에 Promise를 map에 등록해야 함
- 언어
Python
- 관용적 표현
asyncio.Lock, 공유Task,asyncio.shield- 주요 주의점
하나의 event loop 안에서 사용한다는 전제가 필요함
- 언어
Rust
- 관용적 표현
Arc<Entry>,Mutex<HashMap>,Notify, background task- 주요 주의점
waiter drop, loader panic, entry 정리를 함께 다뤄야 함
C#에서는 ConcurrentDictionary.GetOrAdd()의 value factory가 경쟁 상황에서 여러 번 호출될 가능성을 고려해야 한다. 따라서 실제 작업을 Lazy<Task<T>> 안에 넣고 선택된 entry만 실행한다.
예제의 GetInFlightCount()는 디버깅과 저빈도 관측을 위한 메서드다. ConcurrentDictionary.Count는 내부 lock을 사용하므로 요청마다 호출하면 경합이 생길 수 있다.11
고빈도 metric이 필요하면 Interlocked 기반 별도 counter를 두거나, 수명 관리에 지장이 없는 근사값을 사용한다. Counter는 entry 등록 성공 시 증가하고 현재 entry 제거 성공 시 감소시켜야 한다.
TypeScript에서는 Promise 자체가 공유 가능한 완료 표현이다. 다만 loader를 먼저 호출하고 나중에 map에 넣으면 동기 구간에서 예외가 발생하거나 중복 진입할 수 있으므로, entry 등록과 loader 시작 순서를 고정해야 한다.
Python에서는 한 waiter의 task cancellation이 await 중인 공유 task로 전파될 수 있다. asyncio.shield()로 공유 작업 수명을 개별 waiter 수명과 분리한다.
Python 예제의 get_in_flight_count()는 객체가 하나의 event loop와 thread에 한정되어 사용된다는 전제에서 len()을 바로 읽는다. 같은 loop에서는 이 메서드 안에 await가 없으므로 다른 coroutine이 중간에 map을 바꾸지 못한다. 다른 thread에서 _in_flight나 asyncio.Lock에 접근하는 사용법은 지원하지 않는다. 이 안전성을 CPython의 GIL에 기대는 것으로 설명해서도 안 된다. Python은 free-threaded build를 지원하며, asyncio 동기화 primitive 자체도 OS thread 사이의 동기화를 위한 도구가 아니다.12
Rust에서는 waiter가 future를 drop하면 해당 future 안의 작업도 함께 drop될 수 있다. Loader를 별도 Tokio task로 spawn하고 결과 entry를 Arc로 공유하면 개별 waiter와 공유 작업의 수명을 분리할 수 있다. 다만 spawn만으로 끝나지 않는다. loader panic을 terminal result로 바꾸지 않으면 waiter가 영원히 남을 수 있다.
일반적으로 실패 표현에 대해서는 C#, TypeScript, Python에서는 loader의 예외가 공유 Task, Promise, asyncio.Task의 실패 상태로 자연스럽게 전달된다. Rust의 spawn된 task에서 발생한 panic은 업무 오류 TError가 아니므로, 예제는 JoinError를 SingleFlightError::LoaderTaskFailed로 승격한다. 이 구분 덕분에 호출자는 loader가 반환한 오류와 task 자체의 비정상 종료를 따로 기록할 수 있다.
공통적으로 구현의 공통점은 다음으로 요약할 수 있다.
동일 key의 진행 중 작업은 하나의 완료 객체로 표현하고,
모든 waiter가 그 동일한 완료 객체를 기다리게 한다.9. 추가로 생각해보기
결과가 tenant, locale, 권한에 따라 달라진다면 Single-Flight key에 어떤 값을 반드시 포함해야 하는가?
한 waiter의 취소가 공유 loader를 중단해야 하는가, 아니면 자신의 대기만 중단해야 하는가?
모든 waiter가 떠난 뒤에도 loader를 계속 실행해 cache를 채우는 것이 이득인가?
process-local 병합으로 충분한가, 여러 instance를 아우르는 distributed coordination이 필요한가?
Loader 실패를 즉시 재시도할 것인가, 짧은 negative cache나 backoff가 필요한가?
Hot key 하나의 느린 loader에 수천 waiter가 매달릴 때 메모리와 tail latency를 어떻게 제한할 것인가?
Single-Flight가 늘 필요한 것은 아니다. Loader가 싸고 호출량도 적다면 registry와 취소 정책이 더 비싸다. 서로 독립적인 요청을 억지로 같은 key에 묶으면 latency만 늘고, 쓰기 작업을 병합하면 호출별 부수 효과나 감사 기록을 잃을 수 있다. 결제, 메시지 발행, 순번 발급처럼 "같아 보이는 요청도 각각 실행되어야 하는가"부터 따져야 한다.
10. 요약
Single-Flight는 동일 key의 동시 loader 실행을 하나로 병합한다.
Cache는 완료된 값을 저장하고, Single-Flight는 진행 중 작업만 공유한다.
Entry는 loader 시작 전에 등록하고 성공·실패 이후 반드시 제거해야 한다.
개별 호출자의 취소와 공유 loader의 취소 수명을 분리해야 한다.
Key에는 결과에 영향을 주는 tenant, 권한, locale, 정책 version을 포함해야 한다.
Loader timeout, key 수, waiter 수, 재시도 파동에 상한을 둬야 한다.
캐시 쓰기 실패가 성공한 원본 조회까지 실패로 바꾸지 않도록 경계를 정한다.
프로세스 내부 Single-Flight는 여러 서버 instance 전체의 중복 실행까지 막지는 않는다.
암기 규칙:
Cache
작업 완료 후 값이 들어감
TTL, eviction, invalidation까지 유지
이후 요청도 값을 재사용
Single-Flight
작업 실행 중에만 존재
완료·실패·취소되면 항목 제거
이후 요청은 새 작업을 시작
같은 cache key가 동시에 비었다면,
각자 backend로 내려가지 말고 하나가 읽고 나머지는 그 결과를 기다려라.개인메모: 이번에 가져온 핵심 논리는 중국쪽 코드로 공부하다가 알게된 패턴이다.
Cache<K, V>
K → 완료된 VSingleFlight<K, Task<V>>
K → 현재 실행 중인 Task<V>
이다. 사실 좀 어려운 지점이 있다.사실 프로그래밍의 기본 센스는 관리와 동일성의 최소 단위를 무엇으로 잡는가다. 작업(Task)단위를 최소단위로 했을때의 관리법인것이다.
저 위에 있는 긴 것들은 사실 메뉴얼을 다 하나하나 요약한거고 단순한 건 여러 호출을 각각의 요청으로 보지 않고, 동일한 결과를 생산하는 하나의 진행 중 작업으로 묶는 것이다.
대부분의 프로그래밍 문제는 다음 질문으로 환원된다.
무엇을 하나로 볼 것인가?
무엇을 독립적으로 관리할 것인가?
어디까지 원자적(최소 단위) 상태 전이로 볼 것인가?
어떤 대상에 identity(정체성)와 lifetime(수명)을 부여할 것인가?
쉽다면 쉽고 어렵다면 어렵다.
각주
- Microsoft Azure Architecture Center. Cache-Aside pattern ↩
- Pang, R. et al. Zanzibar: Google's Consistent, Global Authorization System (PDF). 2019 USENIX Annual Technical Conference. 논문의 cache stampede 절에서 서버별 lock table과 동일 cache key 요청의 병합을 설명한다. ↩
- Go Project. `golang.org/x/sync/singleflight`: duplicate function call suppression ↩
- Go Project. `x/sync/singleflight` source.
Do의 직접 실행과WaitGroup대기,DoChan의 goroutine 실행을 확인할 수 있다. ↩ - Microsoft. `ConcurrentDictionary<TKey,TValue>.GetOrAdd` Remarks.
valueFactory가 lock 밖에서 실행되며 경쟁 중 여러 번 호출될 수 있음을 설명한다. ↩ - Microsoft. `ConcurrentDictionary<TKey,TValue>.TryRemove(KeyValuePair<TKey,TValue>)`. Key와 value가 모두 일치하는 entry만 제거한다. ↩
- MDN Web Docs. `AbortSignal`. Promise API의 abort 처리와
signal.reason전달 예제를 포함한다. ↩ - Python Software Foundation. `asyncio.shield`. 호출자 취소와 공유 task 취소의 분리, task 강한 참조 보관에 관한 주의를 설명한다. ↩
- Tokio. `tokio::sync::Notify`.
notify_waiters()와 생성된Notifiedfuture 사이의 wake-up 보장을 설명한다. ↩ - OpenTelemetry. Metrics cardinality limits. 고유한 attribute 조합이 metric state와 메모리 사용량을 늘리는 이유를 설명한다. ↩
- Microsoft. Slow `ConcurrentDictionary.Count` lookup.
Count의 lock 비용과 고빈도 호출 시 대안을 설명한다. ↩ - Python Software Foundation. asyncio and free-threaded Python. Event loop와 task를 thread 사이에서 공유하지 말아야 하며,
asyncio.Lock같은 primitive가 thread synchronization 용도가 아님을 설명한다. ↩