SLA 를 정의하는 글은 이미 많다. 그런데 대부분은 “99.9% 가용성” 같은 숫자를 정의하는 데서 멈춘다. 그 숫자를 어떻게 만들어내고 어떻게 지켜내는가 는 잘 다뤄지지 않는다.

응답 속도 자체가 상품인 저지연 서비스를 다루다 보면 이 질문에 자연스럽게 도달한다. 여기서 SLA 는 가용성(up/down) 문제가 아니라 지연(latency) 문제로 바뀐다. 서버가 살아 있어도 응답이 예산을 넘기면 그건 이미 실패다. 그래서 SLA 는 두 축에서 다뤄야 한다. 설계가 지연 예산을 만들고, 운영이 그 예산을 지킨다.

flowchart LR
  subgraph design["설계로 만들기"]
    D1[Timeout budget]
    D2[핫패스 캐싱]
    D3[Graceful degradation]
    D4[장애 격리]
    D5[Load shedding]
  end
  subgraph ops["운영으로 지키기"]
    O1[p99 SLO]
    O2[Error Budget 소진율]
    O3[Capacity headroom]
    O4[배포 지연 게이트]
    O5[Error Budget 회고]
  end
  design --> SLA[(지연 SLA)]
  ops --> SLA

SLA, SLO, SLI, Error Budget

네 용어는 사슬처럼 이어진다.

SLI(Service Level Indicator, 서비스 수준 지표) 는 실제로 측정하는 값이다. 요청의 지연, 에러율 같은 것.

SLO(Service Level Objective, 서비스 수준 목표) 는 그 지표에 잡는 목표다. “p99 지연을 200ms 아래로” 같은.

SLA(Service Level Agreement, 서비스 수준 협약) 는 그 목표를 외부에 약속한 것이고, 어기면 대가가 따른다.

Error Budget(오차 예산) 은 SLO 를 뒤집은 값으로, SLO 가 99.9% 라면 남은 0.1% 가 허용된 실패치, 즉 마음껏 태워도 되는 예산이다.

이 관계가 중요한 이유는 Error Budget 이 의사결정의 화폐가 되기 때문이다. 예산이 남아 있으면 배포를 밀어붙일 수 있고, 예산이 바닥나면 신뢰성에 투자해야 한다. SLO 는 목표를 넘어, 예산의 잔고를 정하는 선이 된다.

저지연 서비스에서 달라지는 건 SLI 다. 가용성 중심 SLA 는 SLI 를 “성공 응답 비율” 로 잡지만, 저지연 서비스는 SLI 를 지연 분위수로 잡는다. 지표가 “얼마나 자주 살아 있었나” 에서 “얼마나 자주 충분히 빨랐나” 로 옮겨간다. 이 전환이 뒤따르는 모든 설계와 운영을 바꾼다.

지연 SLA 의 세 난점

첫째, 꼬리 지연(tail latency) 은 합쳐지면서 증폭된다. 한 요청이 여러 하위 서비스를 부채꼴로 호출하면, 전체 응답은 가장 느린 하위 응답에 발목이 잡힌다. 각 하위 서비스의 p99 가 나쁘지 않아도, 여러 개를 동시에 기다리면 그중 하나가 p99 에 걸릴 확률이 커진다. 개별로는 드문 느림도 여러 요청에 걸쳐 모이면 자주 나타난다.

둘째, 트래픽 폭증은 양성 피드백 루프를 만든다. 요청이 몰리면 지연이 늘고, 지연이 늘면 연결과 스레드가 오래 점유되고, 점유가 길어지면 처리량이 떨어져 큐가 더 쌓인다. 오토스케일링이 개입해도 새 인스턴스가 준비되는 데 시간이 걸린다. 그 사이에 이미 느려진 서비스가 더 느려지고, 이렇게 느려짐이 다시 느려짐을 부르며 폭증 구간이 길어진다.

셋째, 지연 SLO 위반은 가용성 위반과 회복 양상이 다르다. 서버가 죽었다 살아나면 가용성은 즉시 회복되지만, 지연은 그렇지 않다. 폭증 구간에 밀린 요청이 백로그로 남아, 트래픽이 정상으로 돌아온 뒤에도 한동안 예산을 계속 태운다. 그래서 지연 SLA 는 사후 대응이 늦다. 터진 뒤에 손쓰면 이미 예산은 많이 태워진 뒤다. 설계로 예산을 미리 만들어두지 않으면, 운영으로 지킬 잔고 자체가 없는 셈이다.

설계가 지연 예산을 만드는 방식은 하나의 태도로 수렴한다. 기다려서 완전한 답을 주기보다, 예산 안에서 할 수 있는 만큼 주고 나머지는 degrade 한다.

Timeout budget

저지연 서비스의 설계는 전체 응답 예산을 정하는 데서 시작한다. “이 요청은 200ms 안에 답한다” 를 정하면, 그 예산을 하위 호출들에 나눠 배분해야 한다. 이것이 timeout budget 이다.

핵심은 timeout 이 정적인 상수가 아니라 남은 예산에서 파생된다는 점이다. 상류가 이미 120ms 를 썼다면 하위 호출에 남은 건 80ms 뿐이다. 이 남은 예산을 호출 체인을 따라 전달하는 것을 deadline propagation 이라 한다. 각 단계는 전체 마감까지 얼마 남았는지를 알고, 그 안에서만 시도한다.

이걸 하지 않으면 두 방향으로 무너진다. 상류 timeout 이 하류보다 짧으면, 하류는 아직 일하고 있는데 상류가 이미 포기해 그 작업이 통째로 낭비된다. 반대로 상류가 하류보다 길면, 하류가 실패해도 상류는 끝까지 기다려 전체 예산을 초과한다. timeout 은 그냥 안전장치라기보다 예산의 분배 규칙에 가깝다고 봤다.

핫패스 캐싱

지연 예산을 만드는 가장 직접적인 수단은 캐싱이다. 캐시 히트는 하위 호출 하나를 통째로 건너뛰는 것이고, 건너뛴 만큼이 곧 확보한 예산이다. 저지연 서비스에서 캐싱은 성능 최적화 이전에 SLO 를 성립시키는 전제 조건에 가깝다.

그래서 설계의 초점은 캐시를 둘지가 아니라 무엇을 얼마나 낡게 두어도 괜찮은지로 옮겨간다. 무효화 주기와 stale 허용 범위가 곧 SLO 협상이다. 완벽하게 최신인 데이터를 고집하면 캐시 히트율이 떨어지고 예산이 사라진다. 오래된 값을 얼마간 허용하면 예산이 생긴다. 저지연 서비스는 대개 후자를 택한다. 약간의 stale 은 느린 정답보다 낫다고 판단하는 경우가 많다.

Graceful degradation

완전한 응답을 예산 안에 만들 수 없을 때, 저지연 서비스는 실패 대신 열화된 응답을 택한다. 개인화 추천을 제때 못 가져오면 기본 목록을 내보내고, 부가 정보를 못 채우면 핵심만 담아 응답한다. 사용자는 조금 덜 풍부한 화면을 보지만, 어쨌든 빠르게 본다.

이건 예외 처리로 두지 않고 기본 응답 경로의 일부로 설계한다. “부가 기능은 지연되면 버린다” 를 미리 정해두면, 예산을 넘길 것 같은 순간에 시스템이 스스로 응답의 완성도를 낮춰 예산을 맞춘다. 완전함과 속도가 충돌할 때 저지연 서비스는 속도를 지키고 완성도를 양보한다. 이 우선순위를 코드에 흩어두지 않고 설계 단계에서 정해 두어야 한다.

장애 격리

한 의존성의 느림이 전체 예산을 잡아먹지 않게 벽을 세우는 것이 격리다. 두 가지 패턴이 축을 이룬다. Bulkhead 는 의존성별로 자원 풀(연결, 스레드) 을 분리해, 한쪽이 느려져 풀을 다 점유해도 다른 경로는 멀쩡히 흐르게 한다. 배의 격벽처럼 한 칸이 침수돼도 배 전체가 가라앉지 않는다.

Circuit breaker 는 반복 실패하거나 느린 의존성으로 가는 호출을 아예 차단한다. 이미 답을 못 줄 게 뻔한 하위 서비스를 계속 호출하며 예산을 태우는 대신, 회로를 열어 즉시 실패시키고 fallback 으로 넘긴다. 격리가 없으면 하나의 느린 의존성이 연쇄적으로 전체를 끌어내린다. 앞서 말한 느려짐이 느려짐을 부르는 루프의 시작점이 대개 여기다.

Load shedding

예산을 지킬 수 없을 만큼 트래픽이 몰리면, 저지연 서비스는 초과분을 빨리 버린다. 이것이 load shedding 이다. 얼핏 역설적이다. 요청을 처리하려고 만든 서비스가 요청을 거절한다. 하지만 대안은 더 나쁘다. 모두를 받아 큐에 쌓으면 큐가 길어질수록 모든 응답이 느려지고, 결국 전부가 예산을 넘긴다.

핵심 판단은 이것이다. 어차피 예산 안에 처리 못 할 요청이라면, 늦게 실패시키는 것보다 일찍 거절하는 편이 낫다고 봤다. 일찍 거절하면 그 자원으로 나머지 요청의 SLO 를 지킬 수 있다. 큐가 무한히 쌓이도록 두는 대신 받아들일 수 있는 양의 경계를 정하고, 그 너머는 즉시 돌려보낸다. load shedding 은 degrade 의 가장 단호한 형태에 가깝다. 응답의 완성도를 낮추는 대신 응답 자체를 포기해 시스템 전체를 지킨다.

설계가 예산을 만들었다면, 운영은 그 예산을 지킨다.

p99 SLO

운영의 출발점은 SLO 를 올바른 지표로 잡는 것이다. 저지연 서비스에서 평균 지연은 거짓말을 한다. 평균이 50ms 라도 요청의 1% 가 2초씩 걸린다면, 그 1% 를 겪는 사용자에게 서비스는 느린 서비스이고, 평균은 그런 꼬리를 드러내지 않는다.

그래서 SLO 는 분위수로 잡는다. p99 는 요청의 99% 가 그 값 아래였다는 뜻이고, p999 는 99.9% 다. 트래픽이 클수록 이 꼬리에 걸리는 절대 사용자 수가 커지므로, 규모가 큰 저지연 서비스일수록 p99 를 넘어 p999 까지 본다. SLO 를 “p99 지연 200ms 이하” 처럼 분위수로 정의해야 사용자가 실제로 겪는 경험을 지표가 담는다.

Error Budget 소진율

SLO 를 정했다면, 위반이 터진 뒤 알림을 받는 건 늦다. 운영은 Error Budget 이 얼마나 빠르게 줄고 있는지를 본다. 이것이 소진율(burn rate) 이다. 예산이 정상보다 몇 배 빠르게 타고 있으면, 지금 당장 위반은 아니어도 곧 위반한다는 신호다.

이 관점이 모니터링을 사후에서 선제로 바꾼다. 지금 SLO 를 어겼는지를 묻는 대신, 이 속도면 예산이 언제 바닥나는지를 묻는다. 소진율이 급격하면 짧은 창을 보고 즉시 대응하고, 완만하면 긴 창을 보고 추세를 관리한다. 잔고 자체보다 잔고가 줄어드는 속도가 경보의 기준이 된다.

Capacity headroom

앞서 오토스케일링이 폭증을 못 따라가는 구간을 봤다. 운영은 그 구간을 헤드룸으로 흡수한다. 사용률을 상시 100% 가까이 끌고 가면 여유가 없어, 트래픽이 조금만 튀어도 지연이 즉시 예산을 넘긴다. 저지연 서비스에서 사용률을 끝까지 끌어올리면 작은 변동에도 SLO 가 무너진다.

그래서 일정한 여유 용량을 늘 비워둔다. 이 여유가 오토스케일링이 새 용량을 준비하는 동안의 완충이 된다. 폭증이 시작되면 헤드룸이 먼저 받아내고, 그사이 스케일링이 따라붙는다. 헤드룸은 낭비처럼 보이지만, 지연 SLO 를 지키기 위해 지불하는 보험료에 가깝다고 판단한다.

배포 지연 게이트

지연 회귀는 대부분 배포에서 들어온다. 새 코드가 하위 호출을 하나 더 하거나, 직렬화가 무거워지거나, 캐시 키가 미묘하게 바뀌어 히트율이 떨어진다. 기능은 멀쩡히 도는데 p99 만 조금씩 나빠진다. 운영은 이 회귀를 배포 단계에서 막는다.

canary 로 새 버전에 일부 트래픽을 흘리고, 그 구간의 p99 를 기존 버전과 비교한다. 지연이 유의미하게 나빠지면 게이트가 배포를 멈춘다. 지연 회귀를 기능 버그와 똑같이 취급해, 릴리스를 막을 권한을 주는 것이다. 이 게이트가 없으면 지연은 배포마다 조금씩 나빠지고, 그 누적을 나중에 되짚기는 어렵다.

Error Budget 회고 사이클

마지막은 예산을 정책으로 만드는 사이클이다. Error Budget 이 남아 있으면 팀은 기능을 빠르게 배포한다. 실패해도 태울 예산이 있으니까. 예산이 바닥나면 새 기능을 멈추고 신뢰성에 투자한다. 예산이 개발 속도와 신뢰성 사이의 자동 조절 장치가 된다.

이 사이클이 돌면 신뢰성 논의가 감정이나 직관 대신 잔고를 근거로 이뤄진다. “요즘 좀 불안하다” 대신 “이번 분기 예산을 벌써 다 태웠다” 로 대화가 바뀐다. SLO 로 정의한 예산이 회고를 통해 다음 우선순위를 정하는 근거로 돌아온다.

설계로 만들고, 운영으로 지킨다

돌아보면 두 축은 분리되지 않는다. 설계로 아무리 촘촘히 예산을 만들어도 운영이 그것을 지켜보지 않으면 배포 한 번에 새어나간다. 반대로 운영이 아무리 예민하게 지켜봐도 설계가 예산을 애초에 만들어두지 않았으면 지킬 잔고 자체가 없다.

저지연 서비스에서 SLA 는 계약서에 적는 숫자가 아니라, timeout 을 나누고 캐시를 두고 degrade 를 정하는 설계 결정들과, p99 를 보고 소진율을 재고 배포를 게이팅하는 운영 습관들의 합이었다. 숫자는 결과일 뿐이고, 그 숫자를 만들고 지키는 건 이 결정들이었다.