← Posts

Scaling · Resilience · Circuit Breaker

서킷 브레이커와 백프레셔 - 번지는 것을 끊는다

한 서비스의 장애는 그걸 부르는 쪽으로 번지고, 빠른 생산자는 느린 소비자를 무너뜨린다. 그 번짐을 반대 방향에서 끊는 두 장치를 본다.

목차
  1. 한 곳이 느려지면 전체가 죽는다
  2. 장애는 왜 번지나: 기다리다 같이 묶인다
  3. 서킷 브레이커: 안 되는 곳은 그만 부른다
  4. 세 상태: 닫힘·열림·반열림
  5. 열렸을 때 뭘 돌려주나
  6. 재시도는 불에 기름이 된다
  7. 백프레셔: 못 따라가면 되민다
  8. 큐는 문제를 미룰 뿐이다
  9. 되미는 네 가지: 막기·버리기·거절·늦추기
  10. 실무에서: 타임아웃이 맨 앞이다
  11. 정리

부하와 장애는 한자리에 머물지 않는다. 옆으로 번진다. 이 글은 그 번짐을 끊는 두 장치에 관한 것이다.

한 곳이 느려지면 전체가 죽는다

서비스가 여럿으로 나뉘어 서로를 부른다고 하자. 주문 서비스가 결제 서비스를 부른다.

어느 날 결제 서비스가 느려진다. 죽은 게 아니라 그냥 느리다. 평소 0.1초에 답하던 게 10초씩 걸린다. 주문 서비스는 결제의 답을 기다린다. 기다리는 동안 그 요청을 처리하던 스레드는 아무것도 못 하고 붙잡혀 있다.

요청이 계속 들어온다. 하나씩 결제를 기다리며 스레드가 묶인다. 곧 주문 서비스의 스레드가 전부 결제 대기에 붙잡힌다. 이제 주문 서비스는 결제와 상관없는 요청조차 받지 못한다. 결제만 느려졌는데 주문이 통째로 죽었다.

diagramdiagram

이걸 연쇄 장애(cascading failure)라고 한다. 한 부품의 고장이 그 부품에 기댄 모든 것으로 퍼진다. 이 글의 두 장치는 이 번짐을 두 방향에서 끊는다.

장애는 왜 번지나: 기다리다 같이 묶인다

번짐의 정체는 방금 장면에 다 있다. 기다림이다. 느린 상대를 기다리는 쪽의 자원이 그 기다림에 묶여버린다.

여기서 묶이는 자원이 스레드 풀이다. 앱은 요청을 처리할 스레드를 정해진 수만큼만 갖고 있다. 그 스레드가 전부 느린 호출을 기다리며 붙잡히면, 새 요청은 큐에서 대기하다 그 큐마저 차면 거부된다. 커넥션 풀도 똑같이 마른다.

핵심은 문제의 원인과 죽는 곳이 다르다는 점이다. 결제가 느린 게 원인인데, 죽는 건 주문이다. 그래서 주문 서비스의 로그만 봐서는 원인을 못 찾는다. 이 어긋남이 연쇄 장애를 고약하게 만든다.

그러니 막는 방법도 여기서 나온다. 느린 상대를 무한정 기다리지 않게 만들면 된다. 서킷 브레이커가 하는 일이 정확히 이것이다.

서킷 브레이커: 안 되는 곳은 그만 부른다

이름은 전기 차단기에서 왔다. 집 안 회로에 과전류가 흐르면 차단기가 스스로 회로를 끊는다. 안 끊으면 전선이 타고 불이 난다. 끊어두면 문제가 그 회로에 갇힌다.

소프트웨어 서킷 브레이커도 같다. 주문 서비스가 결제를 부르는 길목에 차단기를 하나 둔다. 이 차단기는 결제 호출이 얼마나 실패하는지 센다. 실패가 일정 수준을 넘으면 차단기가 열린다. 열린 뒤로는 주문 서비스가 결제를 아예 부르지 않는다. 부르지 않고 그 자리에서 즉시 실패로 처리한다.

이게 왜 도움이 되나. 두 가지다.

  • 호출자가 안 묶인다. 10초를 기다리는 대신 즉시 실패하니 스레드가 붙잡히지 않는다. 주문 서비스가 산다.
  • 죽어가는 쪽에 숨통을 준다. 결제가 느린 이유가 부하 과다라면, 호출을 끊는 것 자체가 부하를 덜어줘 회복을 돕는다.

문 앞에 “지금 점검 중입니다”를 붙여 손님을 안 받는 것과 같다. 안을 비워둬야 정비를 한다.

세 상태: 닫힘·열림·반열림

차단기는 세 상태를 오간다.

diagramdiagram
  • 닫힘(closed) - 평소 상태. 호출이 그대로 통과한다. 차단기는 실패율만 조용히 센다. 이름이 헷갈리는데, 전기 회로처럼 닫혀 있어야 전류가 흐른다(호출이 통한다).
  • 열림(open) - 실패가 문턱을 넘어 회로를 끊은 상태. 호출을 안 보내고 즉시 실패시킨다.
  • 반열림(half-open) - 열린 뒤 일정 시간이 지나면 여기로 온다. 시험 삼아 호출을 한둘만 흘려본다. 성공하면 상대가 회복했다고 보고 닫힘으로 돌아간다. 또 실패하면 다시 열림으로 가서 더 기다린다.

반열림이 핵심이다. 이게 없으면 사람이 “이제 결제 살아났나?” 하고 지켜보다 차단기를 손으로 닫아야 한다. 반열림은 회복을 스스로 떠보게 해서 사람을 그 자리에서 뺀다. 로드 밸런서가 죽은 서버를 헬스체크로 알아내는 것과 같은 발상을, 의존성을 부르는 쪽에서 하는 셈이다.

열렸을 때 뭘 돌려주나

차단기가 열리면 호출을 안 한다. 그럼 그 요청에 뭐라고 답하나. 그냥 에러를 던질 수도 있지만, 대개 더 나은 답이 있다. 이걸 폴백(fallback)이라고 한다.

  • 캐시된 옛 값을 준다. 추천 목록 서비스가 죽었으면, 조금 낡았어도 어제 계산해둔 목록을 보여준다.
  • 기본값을 준다. 개인화 배너를 못 불러오면 아무나 보는 기본 배너를 띄운다.
  • 정중하게 일부만 거절한다. “결제는 잠시 후 다시 시도해주세요” 한 줄을 주고, 장바구니 담기 같은 나머지 기능은 살려둔다.

폴백의 목적은 하나다. 한 기능의 고장을 그 기능에 가둔다. 결제가 안 된다고 사이트 전체가 500을 뱉으면 안 된다. 결제만 잠깐 안 되고 나머지는 돌아야 한다. 서킷 브레이커가 연쇄 장애를 끊는 장치라면, 폴백은 끊긴 자리를 덜 아프게 메우는 부분이다.

재시도는 불에 기름이 된다

실패하면 다시 해보는 게 자연스럽다. 네트워크가 잠깐 튄 거라면 재시도 한 번에 성공한다. 그래서 재시도는 좋은 습관이다 - 상대가 멀쩡할 때는.

문제는 상대가 부하로 죽어갈 때다. 느려진 결제 서비스에 대고 모든 호출자가 “실패했네, 다시” 하고 재시도하면, 죽어가는 서비스에 부하를 두 배로 얹는 꼴이 된다. 살려야 할 상대를 재시도가 마저 죽인다. 이걸 재시도 폭풍(retry storm)이라고 한다.

그래서 재시도에는 규율이 붙는다.

  • 간격을 점점 벌린다(지수 백오프). 실패할수록 다음 재시도까지 더 오래 쉰다. 죽어가는 상대에게 시간을 준다.
  • 간격을 흩뜨린다(지터). 모두가 똑같이 “1초 뒤 재시도”하면 1초 뒤에 부하가 한꺼번에 몰린다. 재시도 시각에 무작위를 조금 섞어 파도를 흩는다.
  • 횟수를 제한한다. 무한히 재시도하지 않는다.

여기서 서킷 브레이커와 만난다. 차단기가 열려 있으면 재시도 자체를 안 한다. 재시도 폭풍의 근본 해법은 “죽은 상대는 당분간 안 부르는 것”이고, 그게 서킷 브레이커다. 둘은 짝으로 쓴다 - 재시도로 일시적 장애를 넘기되, 진짜 장애는 차단기가 끊는다.

백프레셔: 못 따라가면 되민다

지금까지는 부르는 쪽(호출자)을 지키는 이야기였다. 이제 방향을 뒤집는다. 빠른 쪽이 느린 쪽을 밀어붙여 무너뜨리는 경우다.

한쪽이 초당 1000건을 만들어내는데 받는 쪽은 초당 200건밖에 처리 못 한다고 하자. 초과분 800건은 어디로 가나. 어딘가 쌓인다. 쌓이기만 하면 결국 받는 쪽의 메모리가 터진다.

백프레셔(backpressure)는 이때 받는 쪽이 보내는 쪽에게 “천천히”라고 되미는 것이다. 물을 세게 틀면 호스가 손을 밀어내는 그 저항이 백프레셔다. 소비자가 감당할 수 있는 만큼만 생산자가 만들게 강제한다.

diagramdiagram

서킷 브레이커와 짝을 이룬다. 서킷 브레이커는 아래의 장애가 위로 번지는 걸 끊고, 백프레셔는 위의 과부하가 아래를 무너뜨리는 걸 끊는다. 같은 연쇄 장애를 반대 방향에서 막는다.

큐는 문제를 미룰 뿐이다

빠른 생산자와 느린 소비자 사이에 흔히 를 둔다. 남는 걸 큐에 쌓아두고 소비자가 되는 대로 꺼내 쓰게 한다. 순간적인 몰림은 이걸로 잘 흡수된다.

문제는 몰림이 순간이 아니라 꾸준할 때다. 생산이 소비를 계속 앞지르면 큐는 줄어들 새 없이 자란다. 큐에 한계가 없으면(unbounded) 결국 메모리를 다 먹고 프로세스가 죽는다. 큐는 과부하를 없앤 게 아니라 미뤄둔 것이고, 미뤄둔 만큼 나중에 더 크게 터진다.

그래서 백프레셔의 첫걸음은 큐에 한계를 두는 것이다(bounded). 한계가 있으면 큐가 찼을 때 무언가 결정을 내려야 하고, 그 결정이 곧 되미는 신호가 된다. 스레드 풀이 “큐도 차면 거부한다”로 자신을 지키는 것과 같은 원리다.

그리고 큐 길이 자체가 신호다. 큐가 계속 길어지고 있다면 소비자가 못 따라간다는 뜻이고, 그건 소비자를 늘리거나 생산을 늦춰야 한다는 경보다. 큐 길이를 안 보면 백프레셔를 걸 자리를 놓친다.

되미는 네 가지: 막기·버리기·거절·늦추기

큐가 찼을 때, 즉 소비자가 못 따라갈 때 취할 수 있는 답은 넷이다.

방법무엇을 하나대가
막기(block)생산자를 멈춰 세운다. 자리가 날 때까지 못 넣게생산자가 대기한다. 그 생산자도 누군가의 소비자면 번진다
버리기(drop)넘치는 것을 버린다. 오래된 것부터 또는 새것부터데이터를 잃는다. 잃어도 되는 것에만
거절(reject)받지 않겠다고 즉시 답한다(429)클라이언트가 나중에 다시 와야 한다
늦추기(throttle)생산 속도 자체를 소비 속도에 맞춘다처리량이 소비자 한계에 묶인다

정답은 상황이 정한다. 실시간 센서 값처럼 최신이 중요하고 옛것은 버려도 되면 버리기가 맞다. 결제 요청처럼 하나도 잃으면 안 되면 막거나 거절해서 뒤로 미룬다.

여기서 Rate Limiting과 헷갈리기 쉬운데, 방향이 다르다. Rate Limiting은 바깥에서 들어오는 요청을 문 앞에서 막는 것이고, 백프레셔는 시스템 안의 두 부품 사이 흐름을 조절하는 것이다. 표의 “거절”이 429로 Rate Limiting이 답하는 방식과 같은 얼굴이라 겹쳐 보이지만, 하나는 입구를 지키고 하나는 내부 배관을 지킨다.

실무에서: 타임아웃이 맨 앞이다

두 장치를 다 붙였어도, 그 앞에 타임아웃이 없으면 전부 무너진다.

첫 장면으로 돌아가 보자. 주문이 결제를 부르고 응답을 기다린다. 이때 “10초 안에 답이 없으면 실패로 친다”는 타임아웃이 없으면, 스레드는 결제가 언젠가 답할 때까지 무한정 기다린다. 서킷 브레이커가 실패를 세려 해도 셀 실패가 없다 - 실패한 게 아니라 아직 기다리는 중이니까. 타임아웃이 없으면 “느림”이 “실패”로 바뀌지 않아서, 실패를 세어 열리는 차단기는 열리지 않는다.(느린 호출의 비율 자체를 보고 여는 방식도 있지만, 그 역시 “얼마나 느리면 느린 것인가”라는 기준선 - 결국 타임아웃과 같은 판단 - 을 먼저 정해야 선다.)

그래서 순서가 있다.

  1. 타임아웃 - 무한 대기를 유한한 실패로 바꾼다. 모든 것의 전제다.
  2. 서킷 브레이커 - 그 실패가 쌓이면 호출을 끊는다.
  3. 재시도(백오프·지터) - 일시적 실패는 넘기되 폭풍은 안 만든다.
  4. 백프레셔 - 흐름 자체가 과하면 되민다.

실무 라이브러리들이 이걸 묶어 제공하지만, 이름을 외우는 것보다 이 순서와 이유를 아는 게 먼저다. 도구가 바뀌어도 순서는 안 바뀐다.

정리

연쇄 장애는 한 곳의 문제가 그에 기댄 모든 곳으로 번지는 것이고, 두 장치는 그 번짐을 반대 방향에서 끊는다.

무엇을 끊나어떻게
서킷 브레이커아래의 장애가 위로 번지는 것안 되는 상대는 그만 부르고, 스스로 회복을 떠본다
백프레셔위의 과부하가 아래를 무너뜨리는 것못 따라가면 막거나·버리거나·거절하거나·늦춘다

둘 다 공짜가 아니다. 서킷 브레이커는 정상인 호출까지 잠깐 막을 위험(문턱을 잘못 잡으면)을 사고, 백프레셔는 처리량이나 데이터를 판다. 그래서 문턱·타임아웃·큐 크기를 어떻게 잡느냐가 이 장치들의 진짜 알맹이다.

그럼에도 이걸 두는 이유는 하나다. 번지게 두면 전부 잃고, 끊으면 한 곳만 잃는다. 무엇을 지키려고 무엇을 버릴지 미리 정해두는 것 - 부하를 견디는 일은 결국 여기로 모인다.