클라이언트에서 서버 스파이크를 늦출 수는 없을까
서버 스파이크 상황에서 클라이언트가 할 수 있는 일은 없을까? 클라이언트도 서버와 인프라를 도울 수 있지 않을까?
서론
웹 프론트엔드 애플리케이션에서는 서버 상태의 관리와 캐싱의 편의를 위해 TanStack Query를 사용하는 패턴이 흔해졌다. 이 경우 적어도 브라우저 애플리케이션 레벨에서는 Query Key를 이용한 캐싱 구조 덕분에 동일한 상태의 동일한 API에서 데이터를 매번 읽을 필요가 줄어들고, 심지어는 더 나은 UX를 위한 낙관적 업데이트도 빠르게 구현할 수 있다. 확실히 편리한 도구다.
하지만 캐시는 이미 받아온 걸 재사용하기에, ‘첫 요청’은 꼭 필요하다. 이 경우, 서버의 상태가 여유로운 상태라면 해피 패스일 것이다. 그렇지만 서버에 트래픽 스파이크가 이미 치고 있다면 어떨까? 드랍, 티켓팅, 이벤트, NFT 민팅 등 짧은 시간 안에 사용자가 동시다발적으로 몰리는 패턴은 많은 서비스에서 흔한 형태고, 이 지점에서 실패 이후의 재시도도 함께 몰리는 경우가 많다. 그런데 ‘재시도’도 ‘첫 요청’이다. 통상 이 경우 웹 프론트엔드 애플리케이션에서는 ‘접속자가 많다’, ‘서버가 불안정하다’ 등의 카피를 이용해 다시 시도하도록 하는 UX 패턴이 일반적일 것 같다. 그런데 ‘다시 시도’한다는 이 의미, 서버 상태를 체크하기 위해 서버에 또 부하를 주게 되는 모습은 아닐까? 사용자를 위한 UX가 retry, refetch, polling, reconnect의 형태로 이미 힘들어 하는 서버에 짐을 더 지우는 건 아닐까?
그렇다면 어떻게 풀 수 있을까? 스케일 업과 스케일 아웃은 트래픽 수용치를 늘리는 기본적인 방법이다. 그러나 이 시점에서 서버 애플리케이션의 스케일 업은 상당히 어렵다. 스케일 아웃을 위해 오토스케일링을 사용하더라도 Pod, Node가 준비되는 시간, 이미지를 가져오는 시간, 로드밸런싱에 반영되는 시간이 필요하다. SSR, BFF, Edge Runtime처럼 프론트엔드 역시 서버 실행 환경을 갖는 경우라면 확장의 복잡도도 더 커진다. 결국 인프라 확장은 필요하지만, 사용자가 떠나지 않을 골든 타임을 항상 보장하지는 못한다.
무엇보다 여기서의 조치들은 비용 문제로 직결된다. 이 이야기들은 모두 서버와 인프라의 이야기들인데, 정말 클라이언트에서 할 수 있는 조치와 노력은 없을까? 클라이언트에서 어떤 조치가 가능하다고 하면 보다 즉각적일 것이고, 서버에 부하 자체를 덜 줄 수도 있으므로 더 견고할 거고, 설사 인프라의 확장이 필요하더라도 그 시간을 벌어줄 수 있지 않을까?
몇 가지 접근
브라우저 레벨부터 중복 요청 줄이기
사용자는 어떤 서비스에 렉이나 장애가 발생하면 새로고침, 혹은 새 탭과 새 창에서 재시도하는 패턴이 흔하다. 나는 최근 NDC(Nexon developer conference) 참관 신청을 위해 선착순 신청을 시도했다. 신청 후 호기심에 열었던 두 번째 신청 탭, ‘이미 다른 탭에서 진행 중인 설문입니다.’ 라는 메시지를 발견했다. 서비스의 디테일에 따라 다소 상이할 수 있지만, 이렇게 하나의 브라우저에서 여러 탭을 통해 진입하는 브라우저 컨텍스트를 하나로 만들면 동시다발적으로 오는 시도들의 중복을 소거하고, UX의 큰 훼손 없이 서버는 브라우저 레벨에서 유일해진 요청만 받게 된다.
가장 간단한 건 localStorage, sessionStorage 같은 Browser Storage API를 이용한 lock 구조를 구현하는 방법이다. 특정 작업이 이미 진행 중인지 저장소에 기록한다. 간단하고, 중복 제출을 대부분 줄일 수 있지만, 완전하지 않다. 거의 동시에 getItem 후, setItem을 실행하는 경우 race condition이 생길 수 있다.
const LOCK_KEY = '락_키'
const LOCK_TTL = 30_000
function acquireLock() {
const now = Date.now()
const lock = localStorage.getItem(LOCK_KEY)
if (lock && now - Number(lock) < LOCK_TTL) {
return false
}
localStorage.setItem(LOCK_KEY, String(now))
return true
}
function releaseLock() {
localStorage.removeItem(LOCK_KEY)
}
async function submitOnce() {
if (!acquireLock()) {
alert('이미 다른 탭에서 진행 중입니다.')
return
}
try {
await submitEvent()
} finally {
releaseLock()
}
}BroadcastChannel을 이용한 탭 간 상태 공유 방법도 있다. 여러 탭이 서로 지금 누가 요청 중인지 알려주는 방식이다. 탭 간 상태 전파가 명시적이라 이해하기 쉽지만, 새로 열린 탭은 이전 상태를 모를 수도 있다. 그래서 localStorage의 lock 관리와 같이 쓰는 방법도 있다. 현재 lock 상태는 저장하고, 상태 변화는 BroadcastChannel로 알림을 전파하는 것이다. 그러면 탭이 늦게 열려도 현재 상태를 읽을 수 있고, 이미 열린 탭들은 상태 변화 알림을 받을 수 있다.
const channel = new BroadcastChannel('submit-channel')
let isAnotherTabSubmitting = false
channel.onmessage = (event) => {
if (event.data.type === 'SUBMIT_STARTED') {
isAnotherTabSubmitting = true
}
if (event.data.type === 'SUBMIT_FINISHED') {
isAnotherTabSubmitting = false
}
}
async function submitOnce() {
if (isAnotherTabSubmitting) {
alert('이미 다른 탭에서 진행 중입니다.')
return
}
channel.postMessage({ type: 'SUBMIT_STARTED' })
try {
await submitEvent()
} finally {
channel.postMessage({ type: 'SUBMIT_FINISHED' })
}
}SharedWorker를 이용해서 단일 연결을 관리하는 방법도 있다. SharedWorker는 같은 오리진의 여러 탭이 하나의 워커를 공유할 수 있도록 만들고, 그래서 여러 탭이 각각의 WebSocket을 열지 않고, 워커 안에서 하나의 연결만 유지하도록 만들 수 있다. 이 방식은 WebSocket, SSE, polling처럼, 연결 자체가 중복될 수 있는 구조라면 특히 유용할 것 같다. 각 탭이 소켓을 물고 연결하던 구조에서 공유 워커를 이용해 활성 소켓을 하나로 모으는 구조가 되기 때문이다.
// main thread
const worker = new SharedWorker('/shared-worker.js')
worker.port.start()
worker.port.onmessage = (event) => {
console.log('message from server:', event.data)
}// shared-worker.js
const ports = []
let socket = null
function connectOnce() {
if (socket) return
socket = new WebSocket('wss://example.com/realtime')
socket.onmessage = (event) => {
// 서버 메시지를 모든 탭에 전달
ports.forEach((port) => {
port.postMessage(event.data)
})
}
socket.onclose = () => {
socket = null
}
}
self.onconnect = (event) => {
const port = event.ports[0]
ports.push(port)
port.start()
connectOnce()
port.onmessage = (event) => {
if (event.data.type === 'SEND') {
socket?.send(JSON.stringify(event.data.payload))
}
}
}몰랐지만, Web Locks API를 이용한 방법도 있다고 한다. 브라우저가 제공하는 lock 메커니즘으로, 같은 오리진 안에서 특정 작업이 동시에 실행되지 않도록 만들 수 있다고 한다. 브라우저의 종류와 버전에 따라 지원 여부는 다르겠지만, 이 방식은 lock 메커니즘을 따라하는 게 아니라 브라우저가 lock을 제공하는 것을 그대로 사용한다.
async function submitOnce() {
await navigator.locks.request('event-submit', async () => {
await submitEvent()
})
}이렇게 하면 같은 origin 안에서 event-submit lock을 획득한 작업만 실행된다. 다른 탭에서 lock을 요청하면 앞선 작업이 끝날 때까지 대기한다. 아래처럼 lock을 얻지 못했을 때 대기하지 않고 바로 포기하는 방법도 있다.
async function submitOnce() {
await navigator.locks.request(
'event-submit',
{ ifAvailable: true },
async (lock) => {
if (!lock) {
alert('이미 다른 탭에서 진행 중입니다.')
return
}
await submitEvent()
},
)
}위의 방식들은 동일 브라우저에서 발생하는 중복 실행을 줄이는 데 도움이 된다. 다만 서버 관점의 멱등성을 대체할 수는 없고, 결국 다른 브라우저로부터의 진입이나 디바이스가 달라지면 고유함을 식별하기도 어려워진다. 웹 프론트엔드 기술을 이용하는 부분이다 보니 네이티브 클라이언트에서의 한계도 있다. 다른 방법은 없을까?
지수적 백오프(Exponential backoff)
단순 재시도는 서버로의 DoS와 유사한 부하 패턴으로 작용할 수도 있다고 했다. 그럼 요청 간의 텀을 두면 어떨까? 지수적으로 백오프를 적용해서—재시도 간격을 점차 늘리면서 서버가 회복할 수 있는 시간을 벌어주는 것이다. 그렇지만 모든 클라이언트가 같은 백오프 공식을 사용하면 재시도 시점도 비슷하게 겹칠 수 있다. 클라이언트 입장에서는 간격을 둔 것처럼 보여도, 결국 서버 인프라에서 짧은 구간에 요청이 몰리게 된다는 사실은 변하지 않는다.
const delay = Math.min(30_000, 1000 * 2 ** attempt)
setTimeout(retry, delay)
/**
1초 뒤 재시도
2초 뒤 재시도
4초 뒤 재시도
8초 뒤 재시도
...
모두 비슷한 재시도 시점을 갖게 됨
*/지터(Jitter)
지수적 백오프를 통해 서버가 회복할 수 있는 시간 자체에 대한 확보는 성공했다. 그럼 이 확보의 의미를 더 만들어 내려면 어떻게 하는 게 좋을까? 모두 ‘비슷한’ 재시도 시점을 갖는 걸, 산발적으로 흩뿌리면 서로가 ‘퍼져 있는’ 재시도 시점을 갖도록 만들 수 있지 않을까? 간단하게는 난수화를 이용해볼 수 있겠다.
function getRetryDelay(attempt: number) {
const base = 1000
const cap = 30_000
const maxDelay = Math.min(cap, base * 2 ** attempt)
return Math.random() * maxDelay
}클라이언트 사이드 서킷 브레이커(Client-side Circuit breaker)
서킷 브레이커는 위의 방법들과 궤가 살짝 다르다. 위의 방법들은 ‘재시도를 어떻게 할 것인가’에 대해 생각하는데, 결국 재시도에 대해서 서버를 한 번씩 두드리게 된다. 서킷 브레이커는 ‘재시도를 할 것인가, 말 것인가’에 대한 디자인이다. 실패가 연속되어 임계치를 넘으면 일정 시간 동안 서버를 아예 두드리지 않는 것이다.
서킷 브레이커는 통상적으로 서버 사이드의 개념이지만, 클라이언트 사이드에서도 서킷 브레이커를 둘 수 있다. 연속된 실패를 ‘상태’로 두는 것이다.
let failureCount = 0
let openedUntil = 0
async function requestWithCircuitBreaker() {
const now = Date.now()
if (now < openedUntil) {
throw new Error('Circuit is open')
}
try {
const result = await request()
failureCount = 0
return result
} catch (error) {
failureCount += 1
if (failureCount >= 3) {
openedUntil = now + 30_000
}
throw error
}
}그러나 결국 재시도 자체를 하지 않게 되므로, 이 부분에 대해 서버 측과 계약을 현명하게 정리하는 편이 좋을 것이다. HTTP 429 Too Many Requests, 503 Service Unavailable 같은 표준 계약을 이용해 서버의 상태를 단일 진실로 취급하는 방법도 있을 거고, 단순 n초 지연 후 완전히 열어버리는 방법, 지터 패턴을 이용해 요청 재개 자체도 산발적으로 만들어서 분산시키는 방법도 있을 것이다.
결론
클라이언트는 근원적으로 서버 스파이크를 해결할 수 없다. 다만, 스파이크를 더 완만하게 만들고, 서버와 인프라의 1차적 방파제로 작용할 수 있다. 서킷 브레이커처럼 극적인 패턴까지 가지 않아도 중복 요청을 줄이고, 실패 요청의 재시도를 늦추고, 재시도 시점을 흩뿌려서 서버의 회복탄력성에 기여할 수 있다.
브라우저 레벨에서 Lock을 이용하는 것, 백오프를 두는 것, 지터를 통해 백오프를 산발적으로 배치하는 것, 서킷 브레이커, 이 모든 방법은 추상화하면 결국 ‘독립적인 클라이언트들의 상태 전이가 우연히 정렬되는 것을 방지하는’ 일이다. 단순 웹 브라우저와 애플리케이션 레벨로부터 더 높은 층위로 이동해 동시성의 정렬을 관리하는 것이다. 이런 것이 대단한 알고리즘이 아님에도 서버와 인프라의 부하, 비용을 줄이면서 사용자의 경험 수준은 올려줄 수 있는 좋은 엔지니어링 디자인이라고 생각한다.
여담
- 대기하던 클라이언트가 회복 신호에 동시에 몰리는—재시도가 동시에 몰리는 현상을 Thundering herd 문제라고 한다.
- 재시도가 부하를 증폭시켜 서버를 더 힘들게 만드는 양의 피드백 루프를 Retry storm이라고 한다.
- 트래픽이 정상으로 돌아와도 Retry storm의 관성으로 시스템이 회복하지 못하는 상태를 Metastable failure라고 한다.
- 근본적으로 이 글에서 다루는 클라이언트 측의 능동적인 조치들이 지속되는 루프를 끊어 metastable 상태로 빠지는 걸 방지하는 시도라고 한다.