목차
부하와 장애는 한자리에 머물지 않는다. 옆으로 번진다. 이 글은 그 번짐을 끊는 두 장치에 관한 것이다.
한 곳이 느려지면 전체가 죽는다
서비스가 여럿으로 나뉘어 서로를 부른다고 하자. 주문 서비스가 결제 서비스를 부른다.
어느 날 결제 서비스가 느려진다. 죽은 게 아니라 그냥 느리다. 평소 0.1초에 답하던 게 10초씩 걸린다. 주문 서비스는 결제의 답을 기다린다. 기다리는 동안 그 요청을 처리하던 스레드는 아무것도 못 하고 붙잡혀 있다.
요청이 계속 들어온다. 하나씩 결제를 기다리며 스레드가 묶인다. 곧 주문 서비스의 스레드가 전부 결제 대기에 붙잡힌다. 이제 주문 서비스는 결제와 상관없는 요청조차 받지 못한다. 결제만 느려졌는데 주문이 통째로 죽었다.
이걸 연쇄 장애(cascading failure)라고 한다. 한 부품의 고장이 그 부품에 기댄 모든 것으로 퍼진다. 이 글의 두 장치는 이 번짐을 두 방향에서 끊는다.
장애는 왜 번지나: 기다리다 같이 묶인다
번짐의 정체는 방금 장면에 다 있다. 기다림이다. 느린 상대를 기다리는 쪽의 자원이 그 기다림에 묶여버린다.
여기서 묶이는 자원이 스레드 풀이다. 앱은 요청을 처리할 스레드를 정해진 수만큼만 갖고 있다. 그 스레드가 전부 느린 호출을 기다리며 붙잡히면, 새 요청은 큐에서 대기하다 그 큐마저 차면 거부된다. 커넥션 풀도 똑같이 마른다.
핵심은 문제의 원인과 죽는 곳이 다르다는 점이다. 결제가 느린 게 원인인데, 죽는 건 주문이다. 그래서 주문 서비스의 로그만 봐서는 원인을 못 찾는다. 이 어긋남이 연쇄 장애를 고약하게 만든다.
그러니 막는 방법도 여기서 나온다. 느린 상대를 무한정 기다리지 않게 만들면 된다. 서킷 브레이커가 하는 일이 정확히 이것이다.
서킷 브레이커: 안 되는 곳은 그만 부른다
이름은 전기 차단기에서 왔다. 집 안 회로에 과전류가 흐르면 차단기가 스스로 회로를 끊는다. 안 끊으면 전선이 타고 불이 난다. 끊어두면 문제가 그 회로에 갇힌다.
소프트웨어 서킷 브레이커도 같다. 주문 서비스가 결제를 부르는 길목에 차단기를 하나 둔다. 이 차단기는 결제 호출이 얼마나 실패하는지 센다. 실패가 일정 수준을 넘으면 차단기가 열린다. 열린 뒤로는 주문 서비스가 결제를 아예 부르지 않는다. 부르지 않고 그 자리에서 즉시 실패로 처리한다.
이게 왜 도움이 되나. 두 가지다.
- 호출자가 안 묶인다. 10초를 기다리는 대신 즉시 실패하니 스레드가 붙잡히지 않는다. 주문 서비스가 산다.
- 죽어가는 쪽에 숨통을 준다. 결제가 느린 이유가 부하 과다라면, 호출을 끊는 것 자체가 부하를 덜어줘 회복을 돕는다.
문 앞에 “지금 점검 중입니다”를 붙여 손님을 안 받는 것과 같다. 안을 비워둬야 정비를 한다.
세 상태: 닫힘·열림·반열림
차단기는 세 상태를 오간다.
- 닫힘(closed) - 평소 상태. 호출이 그대로 통과한다. 차단기는 실패율만 조용히 센다. 이름이 헷갈리는데, 전기 회로처럼 닫혀 있어야 전류가 흐른다(호출이 통한다).
- 열림(open) - 실패가 문턱을 넘어 회로를 끊은 상태. 호출을 안 보내고 즉시 실패시킨다.
- 반열림(half-open) - 열린 뒤 일정 시간이 지나면 여기로 온다. 시험 삼아 호출을 한둘만 흘려본다. 성공하면 상대가 회복했다고 보고 닫힘으로 돌아간다. 또 실패하면 다시 열림으로 가서 더 기다린다.
반열림이 핵심이다. 이게 없으면 사람이 “이제 결제 살아났나?” 하고 지켜보다 차단기를 손으로 닫아야 한다. 반열림은 회복을 스스로 떠보게 해서 사람을 그 자리에서 뺀다. 로드 밸런서가 죽은 서버를 헬스체크로 알아내는 것과 같은 발상을, 의존성을 부르는 쪽에서 하는 셈이다.
열렸을 때 뭘 돌려주나
차단기가 열리면 호출을 안 한다. 그럼 그 요청에 뭐라고 답하나. 그냥 에러를 던질 수도 있지만, 대개 더 나은 답이 있다. 이걸 폴백(fallback)이라고 한다.
- 캐시된 옛 값을 준다. 추천 목록 서비스가 죽었으면, 조금 낡았어도 어제 계산해둔 목록을 보여준다.
- 기본값을 준다. 개인화 배너를 못 불러오면 아무나 보는 기본 배너를 띄운다.
- 정중하게 일부만 거절한다. “결제는 잠시 후 다시 시도해주세요” 한 줄을 주고, 장바구니 담기 같은 나머지 기능은 살려둔다.
폴백의 목적은 하나다. 한 기능의 고장을 그 기능에 가둔다. 결제가 안 된다고 사이트 전체가 500을 뱉으면 안 된다. 결제만 잠깐 안 되고 나머지는 돌아야 한다. 서킷 브레이커가 연쇄 장애를 끊는 장치라면, 폴백은 끊긴 자리를 덜 아프게 메우는 부분이다.
재시도는 불에 기름이 된다
실패하면 다시 해보는 게 자연스럽다. 네트워크가 잠깐 튄 거라면 재시도 한 번에 성공한다. 그래서 재시도는 좋은 습관이다 - 상대가 멀쩡할 때는.
문제는 상대가 부하로 죽어갈 때다. 느려진 결제 서비스에 대고 모든 호출자가 “실패했네, 다시” 하고 재시도하면, 죽어가는 서비스에 부하를 두 배로 얹는 꼴이 된다. 살려야 할 상대를 재시도가 마저 죽인다. 이걸 재시도 폭풍(retry storm)이라고 한다.
그래서 재시도에는 규율이 붙는다.
- 간격을 점점 벌린다(지수 백오프). 실패할수록 다음 재시도까지 더 오래 쉰다. 죽어가는 상대에게 시간을 준다.
- 간격을 흩뜨린다(지터). 모두가 똑같이 “1초 뒤 재시도”하면 1초 뒤에 부하가 한꺼번에 몰린다. 재시도 시각에 무작위를 조금 섞어 파도를 흩는다.
- 횟수를 제한한다. 무한히 재시도하지 않는다.
여기서 서킷 브레이커와 만난다. 차단기가 열려 있으면 재시도 자체를 안 한다. 재시도 폭풍의 근본 해법은 “죽은 상대는 당분간 안 부르는 것”이고, 그게 서킷 브레이커다. 둘은 짝으로 쓴다 - 재시도로 일시적 장애를 넘기되, 진짜 장애는 차단기가 끊는다.
백프레셔: 못 따라가면 되민다
지금까지는 부르는 쪽(호출자)을 지키는 이야기였다. 이제 방향을 뒤집는다. 빠른 쪽이 느린 쪽을 밀어붙여 무너뜨리는 경우다.
한쪽이 초당 1000건을 만들어내는데 받는 쪽은 초당 200건밖에 처리 못 한다고 하자. 초과분 800건은 어디로 가나. 어딘가 쌓인다. 쌓이기만 하면 결국 받는 쪽의 메모리가 터진다.
백프레셔(backpressure)는 이때 받는 쪽이 보내는 쪽에게 “천천히”라고 되미는 것이다. 물을 세게 틀면 호스가 손을 밀어내는 그 저항이 백프레셔다. 소비자가 감당할 수 있는 만큼만 생산자가 만들게 강제한다.
서킷 브레이커와 짝을 이룬다. 서킷 브레이커는 아래의 장애가 위로 번지는 걸 끊고, 백프레셔는 위의 과부하가 아래를 무너뜨리는 걸 끊는다. 같은 연쇄 장애를 반대 방향에서 막는다.
큐는 문제를 미룰 뿐이다
빠른 생산자와 느린 소비자 사이에 흔히 큐를 둔다. 남는 걸 큐에 쌓아두고 소비자가 되는 대로 꺼내 쓰게 한다. 순간적인 몰림은 이걸로 잘 흡수된다.
문제는 몰림이 순간이 아니라 꾸준할 때다. 생산이 소비를 계속 앞지르면 큐는 줄어들 새 없이 자란다. 큐에 한계가 없으면(unbounded) 결국 메모리를 다 먹고 프로세스가 죽는다. 큐는 과부하를 없앤 게 아니라 미뤄둔 것이고, 미뤄둔 만큼 나중에 더 크게 터진다.
그래서 백프레셔의 첫걸음은 큐에 한계를 두는 것이다(bounded). 한계가 있으면 큐가 찼을 때 무언가 결정을 내려야 하고, 그 결정이 곧 되미는 신호가 된다. 스레드 풀이 “큐도 차면 거부한다”로 자신을 지키는 것과 같은 원리다.
그리고 큐 길이 자체가 신호다. 큐가 계속 길어지고 있다면 소비자가 못 따라간다는 뜻이고, 그건 소비자를 늘리거나 생산을 늦춰야 한다는 경보다. 큐 길이를 안 보면 백프레셔를 걸 자리를 놓친다.
되미는 네 가지: 막기·버리기·거절·늦추기
큐가 찼을 때, 즉 소비자가 못 따라갈 때 취할 수 있는 답은 넷이다.
| 방법 | 무엇을 하나 | 대가 |
|---|---|---|
| 막기(block) | 생산자를 멈춰 세운다. 자리가 날 때까지 못 넣게 | 생산자가 대기한다. 그 생산자도 누군가의 소비자면 번진다 |
| 버리기(drop) | 넘치는 것을 버린다. 오래된 것부터 또는 새것부터 | 데이터를 잃는다. 잃어도 되는 것에만 |
| 거절(reject) | 받지 않겠다고 즉시 답한다(429) | 클라이언트가 나중에 다시 와야 한다 |
| 늦추기(throttle) | 생산 속도 자체를 소비 속도에 맞춘다 | 처리량이 소비자 한계에 묶인다 |
정답은 상황이 정한다. 실시간 센서 값처럼 최신이 중요하고 옛것은 버려도 되면 버리기가 맞다. 결제 요청처럼 하나도 잃으면 안 되면 막거나 거절해서 뒤로 미룬다.
여기서 Rate Limiting과 헷갈리기 쉬운데, 방향이 다르다. Rate Limiting은 바깥에서 들어오는 요청을 문 앞에서 막는 것이고, 백프레셔는 시스템 안의 두 부품 사이 흐름을 조절하는 것이다. 표의 “거절”이 429로 Rate Limiting이 답하는 방식과 같은 얼굴이라 겹쳐 보이지만, 하나는 입구를 지키고 하나는 내부 배관을 지킨다.
실무에서: 타임아웃이 맨 앞이다
두 장치를 다 붙였어도, 그 앞에 타임아웃이 없으면 전부 무너진다.
첫 장면으로 돌아가 보자. 주문이 결제를 부르고 응답을 기다린다. 이때 “10초 안에 답이 없으면 실패로 친다”는 타임아웃이 없으면, 스레드는 결제가 언젠가 답할 때까지 무한정 기다린다. 서킷 브레이커가 실패를 세려 해도 셀 실패가 없다 - 실패한 게 아니라 아직 기다리는 중이니까. 타임아웃이 없으면 “느림”이 “실패”로 바뀌지 않아서, 실패를 세어 열리는 차단기는 열리지 않는다.(느린 호출의 비율 자체를 보고 여는 방식도 있지만, 그 역시 “얼마나 느리면 느린 것인가”라는 기준선 - 결국 타임아웃과 같은 판단 - 을 먼저 정해야 선다.)
그래서 순서가 있다.
- 타임아웃 - 무한 대기를 유한한 실패로 바꾼다. 모든 것의 전제다.
- 서킷 브레이커 - 그 실패가 쌓이면 호출을 끊는다.
- 재시도(백오프·지터) - 일시적 실패는 넘기되 폭풍은 안 만든다.
- 백프레셔 - 흐름 자체가 과하면 되민다.
실무 라이브러리들이 이걸 묶어 제공하지만, 이름을 외우는 것보다 이 순서와 이유를 아는 게 먼저다. 도구가 바뀌어도 순서는 안 바뀐다.
정리
연쇄 장애는 한 곳의 문제가 그에 기댄 모든 곳으로 번지는 것이고, 두 장치는 그 번짐을 반대 방향에서 끊는다.
| 무엇을 끊나 | 어떻게 | |
|---|---|---|
| 서킷 브레이커 | 아래의 장애가 위로 번지는 것 | 안 되는 상대는 그만 부르고, 스스로 회복을 떠본다 |
| 백프레셔 | 위의 과부하가 아래를 무너뜨리는 것 | 못 따라가면 막거나·버리거나·거절하거나·늦춘다 |
둘 다 공짜가 아니다. 서킷 브레이커는 정상인 호출까지 잠깐 막을 위험(문턱을 잘못 잡으면)을 사고, 백프레셔는 처리량이나 데이터를 판다. 그래서 문턱·타임아웃·큐 크기를 어떻게 잡느냐가 이 장치들의 진짜 알맹이다.
그럼에도 이걸 두는 이유는 하나다. 번지게 두면 전부 잃고, 끊으면 한 곳만 잃는다. 무엇을 지키려고 무엇을 버릴지 미리 정해두는 것 - 부하를 견디는 일은 결국 여기로 모인다.