목차
추천 서비스가 느려졌다. 그런데 멈춘 건 추천 영역이 아니라 주문 페이지 전체였다.
느려진 건 한 곳인데 전부 멈췄다
주문 상세 화면이 추천 상품을 같이 보여준다고 하자. 주문 서비스가 추천 서비스를 부른다.
public OrderDetail detail(Long orderId) {
Order order = orders.findById(orderId);
List<Product> recommended = recommendClient.recommend(orderId); // 추천을 기다린다
return new OrderDetail(order, recommended);
}추천 서비스가 죽은 것도 아니다. 그냥 느려졌다. 응답이 오긴 오는데 오래 걸린다. 그런데 결과는 주문 상세가 통째로 안 뜨는 것이다. 주문 정보는 이미 손에 있는데도.
앞 편에서 “프로세스가 갈리니 장애 범위가 갈린다”고 했다. 그런데 실제로는 안 갈렸다. 호출로 이어져 있으면 갈린 게 아니기 때문이다.
동기 호출은 기다린다는 뜻이다
먼저 말을 정확히 하자. 동기 호출은 응답이 올 때까지 부른 쪽이 멈춰서 기다리는 방식이다. HTTP든 다른 방식이든 상관없다. 기다리면 동기다.
기다리는 동안 그 요청을 처리하던 스레드는 아무 일도 못 한다. 여기서 문제가 시작된다.
기다리는 게 왜 나쁜가. 기다리는 자원이 유한하기 때문이다. 요청을 처리하는 스레드나 연결은 정해진 수만큼 있고, 하나가 오래 붙잡히면 다른 요청이 쓸 게 줄어든다.
느린 상대는 죽은 상대보다 나쁘다
직관과 반대라 짚고 간다. 상대가 죽었다는 걸 바로 알 수 있으면 연결이 즉시 거부되고, 부른 쪽은 실패를 알고 스레드를 돌려준다. 아프지만 빠르게 아프다.
다만 늘 그렇지는 않다. 서버가 통째로 사라지거나 중간에서 요청을 조용히 버리면 거절조차 안 돌아온다. 그러면 아래에서 볼 “느린 상대”와 똑같은 상태가 된다. 그래서 연결을 기다리는 시간에도 따로 상한이 필요하다.
상대가 느려지면 요청마다 스레드가 붙잡힌다. 초당 들어오는 요청은 그대로인데 나가는 속도만 느려지니, 붙잡힌 스레드가 계속 쌓인다. 어느 순간 처리할 스레드가 남지 않는다.
여기가 핵심이다. 스레드가 다 잡히면 추천과 아무 상관 없는 요청까지 처리를 못 한다. 주문 취소도, 주문 목록도 같이 막힌다. 느려진 건 추천 하나인데 주문 서비스 전체가 멈춘다.
그리고 주문 서비스를 부르던 게이트웨이에서도 같은 일이 벌어진다. 이렇게 한 칸씩 앞으로 번진다.
이 번짐은 호출을 따라 거꾸로 올라간다. 맨 끝의 작은 서비스 하나가 느려졌는데, 사용자 눈에는 사이트 전체가 멈춘 것으로 보인다. 나눴는데 오히려 한 덩어리보다 더 넓게 멈추는 상황이 이렇게 만들어진다.
타임아웃이 없으면 방어의 시작점이 없다
가장 먼저 할 일은 얼마나 기다릴지 정하는 것이다.
타임아웃을 안 걸면 라이브러리 기본값을 따르는데, 그 기본값이 무한이거나 수십 초인 경우가 흔하다. 그러면 위 그림이 그대로 재현된다.
타임아웃은 두 종류를 따로 잡아야 한다.
| 무엇을 재나 | |
|---|---|
| 연결 타임아웃 | 상대에게 연결되기까지 기다리는 시간 |
| 응답 타임아웃 | 연결된 뒤 응답이 다 올 때까지 기다리는 시간 |
연결만 걸어두고 응답 타임아웃을 안 걸면, 연결은 됐는데 답을 안 주는 상대에게 영원히 붙잡힌다.
값은 무엇으로 정하나. 부르는 쪽의 사정으로 정한다. 이 화면이 사용자에게 몇 초 안에 나가야 하는지, 그 안에서 이 호출에 얼마를 줄 수 있는지다. 상대의 평소 응답 시간보다 조금 넉넉하게 잡되, 사용자가 기다려줄 시간을 넘지 않게 한다.
호출이 사슬로 이어질 때는 안쪽 타임아웃이 바깥쪽보다 짧아야 한다. 바깥이 3초인데 안쪽이 5초면, 안쪽이 타임아웃을 내기 전에 바깥이 먼저 포기한다. 안쪽 서비스는 아무도 안 기다리는 응답을 계속 만들고 있게 된다.
재시도는 부하를 증폭한다
타임아웃이 나면 다시 불러보고 싶어진다. 일시적인 문제였다면 두 번째는 성공할 테니까. 맞는 생각이지만, 상대가 느려진 원인이 과부하일 때는 정확히 반대로 작동한다.
각 호출을 세 번까지 시도한다고 하자. 상대가 느려져서 대부분 타임아웃이 나면, 나가는 요청 수는 세 배가 된다. 버거워하는 상대에게 세 배를 보내는 것이다. 그래서 더 느려지고, 더 많이 타임아웃 나고, 더 많이 재시도한다.
호출 사슬이 길면 더 나쁘다. 세 단계가 각각 3회 재시도하면 맨 끝에는 최대 27번이 도달한다. 재시도는 단계마다 곱해진다.
그래서 재시도에는 조건이 붙는다.
- 재시도해도 되는 것만 재시도한다. 조회는 안전하다. 무언가를 바꾸는 호출은 상대가 이미 처리했을 수 있다
- 간격을 두고, 점점 늘린다. 즉시 재시도는 몰아치기다. 실패할 때마다 대기 시간을 늘린다
- 간격을 흩뜨린다. 모든 인스턴스가 같은 간격으로 재시도하면 파도가 되어 같은 순간에 몰린다. 대기 시간에 무작위 값을 섞는다
- 한 단계에서만 한다. 사슬의 모든 단계가 재시도하면 곱해진다
“이미 처리했을 수 있다”는 문제는 재시도를 포기할 이유가 아니라 호출 대상을 여러 번 불러도 결과가 같게 만들 이유다. 이를 위해 요청에 식별자를 붙여 중복을 걸러내는 방법이 멱등성이다. 서비스 간 호출에서는 이게 선택이 아니라 기본에 가깝다. 네트워크가 낀 순간 중복은 언제든 일어난다.
반대편에서 보면 이건 받는 쪽이 속도를 알려주는 문제이기도 하다. 부하를 감당 못 할 때 무엇으로 답하고 언제 다시 오라고 할지는 Rate Limiting 글에 있다.
상대가 아플 땐 부르지 않는다
타임아웃과 재시도를 잘 걸어도 남는 문제가 있다. 상대가 계속 실패하는 동안에도 우리는 계속 부른다. 부를 때마다 타임아웃만큼 스레드가 붙잡히고, 상대는 회복할 틈이 없다.
그래서 일정 비율 이상 실패하면 한동안 아예 부르지 않는 장치를 둔다. 이 스위치를 차단기(서킷 브레이커)라고 부른다.
열린 동안에는 네트워크를 타지 않고 즉시 실패한다. 그래서 스레드가 안 붙잡힌다. 앞에서 본 “느린 상대가 죽은 상대보다 나쁘다”를, 느린 상대를 죽은 상대처럼 취급해서 푸는 것이다.
바로 닫지 않고 반열림 상태를 두는 이유는, 회복했는지 확인하려면 불러봐야 하는데 회복 안 된 상대에게 갑자기 전량을 보내면 다시 무너지기 때문이다. 소수만 보내 본다.
못 부르면 무엇을 보여줄 것인가
차단기가 열렸다. 그래서 사용자에게 무엇을 주나. 이 질문에 답이 없으면 방어 장치는 에러 화면을 빨리 띄우는 도구일 뿐이다.
맨 앞 장면으로 돌아가자. 추천을 못 불렀다고 주문 상세를 통째로 못 주는 건 과한 결정이다.
List<Product> recommended;
try {
recommended = recommendClient.recommend(orderId);
} catch (CallFailedException e) {
recommended = List.of(); // 추천 없이 간다
}
return new OrderDetail(order, recommended);추천 영역만 비우고 주문 정보는 그대로 준다. 이렇게 일부 기능이 빠진 채로 계속 돌아가게 만드는 걸 우아한 성능 저하라고 부른다. 대안은 여러 가지다.
- 빈 값이나 기본값으로 대체한다
- 조금 오래된 캐시 값을 보여주고 그렇다고 표시한다
- 그 영역만 “잠시 후 다시” 상태로 둔다
물론 전부 이렇게 할 수는 없다. 결제 승인을 못 받았는데 주문을 완료로 보여줄 수는 없다. 그래서 호출마다 미리 정해야 한다. 이 호출이 실패하면 기능이 무너지는가, 아니면 조금 초라해질 뿐인가.
| 호출의 성격 | 실패했을 때 |
|---|---|
| 없으면 업무가 성립 안 됨 (결제 승인) | 요청 전체를 실패시킨다 |
| 화면을 풍성하게 하는 것 (추천, 배지) | 빼고 준다 |
| 나중에 해도 되는 것 (메일, 통계) | 애초에 기다리지 않는다 |
맨 아래 줄이 다음 편으로 이어진다.
사슬이 길어지면 확률이 곱해진다
동기 호출은 이어 붙이기 쉽다. 그래서 어느새 A가 B를, B가 C를, C가 D를 부르고 있게 된다.
여기서 두 가지가 곱해진다.
지연이 더해진다. 사용자가 기다리는 시간은 사슬 전체의 합이다. 각 단계가 조금씩 느려지면 끝에서는 크게 느려진다.
성공률이 곱해진다. 각 단계가 대부분 성공해도, 전부 성공해야 하는 사슬이 길어질수록 전체 성공률은 계속 떨어진다. 곱셈이라 단계가 늘수록 나빠지는 속도가 빨라진다.
그래서 사슬 길이 자체를 줄이는 게 방어다. 호출 단계를 줄이거나, 굳이 지금 필요 없는 호출을 사슬 밖으로 빼거나, 여러 호출을 동시에 보내 합계 대신 최댓값이 되게 만든다.
무엇을 동기로 둘 것인가
여기까지의 장치들은 전부 동기 호출의 위험을 줄이는 것이지 없애는 게 아니다. 근본적인 질문은 따로 있다. 이 호출이 정말 지금 응답을 받아야 하는가.
기준은 하나다. 응답 내용이 지금 사용자에게 줄 답을 바꾸는가.
- 재고가 없으면 주문을 거절해야 한다. 답을 바꾼다. 동기
- 결제 승인이 나야 주문이 완료다. 답을 바꾼다. 동기
- 주문 확인 메일은 보내든 말든 사용자에게 줄 답이 같다. 동기일 이유가 없다
- 통계 집계는 지금 결과가 필요 없다. 동기일 이유가 없다
실무에서 응답이 느린 이유를 파보면, 답을 안 바꾸는 호출을 사슬 안에 넣어둔 경우가 많다. 방어 장치를 더 붙이기 전에 이 목록부터 나눠야 한다.
타임아웃·재시도·차단기를 매번 손으로 짜지는 않는다. 대개 HTTP 클라이언트 설정과 회복 관련 라이브러리, 또는 호출 경로 앞단의 프록시가 이 역할을 맡는다. 어느 도구를 쓰든 정해야 할 값은 같다. 얼마나 기다릴지, 몇 번 다시 부를지, 언제 끊을지, 끊겼을 때 무엇을 줄지. 도구가 결정을 대신해 주지는 않는다.
정리
| 동기 호출이란 | 응답이 올 때까지 기다리는 것. 기다리는 동안 자원이 붙잡힌다 |
| 가장 위험한 상대 | 죽은 상대가 아니라 느린 상대 |
| 번지는 방향 | 호출을 거슬러 앞으로. 작은 서비스 하나가 전체를 멈추게 한다 |
| 타임아웃 | 연결과 응답을 따로. 안쪽이 바깥쪽보다 짧게 |
| 재시도 | 단계마다 곱해진다. 간격을 늘리고 흩뜨리고, 한 단계에서만 |
| 차단기 | 실패가 계속되면 부르지 않는다. 느린 상대를 죽은 상대처럼 |
| 폴백 | 호출마다 “실패하면 무엇을 줄지”를 미리 정한다 |
| 줄이는 법 | 답을 바꾸지 않는 호출은 애초에 사슬에서 뺀다 |
마지막 줄에 답이 필요하다. 지금 응답이 필요 없는 일을 사슬에서 빼면, 그 일은 누가 언제 하나. 다음 글에서 일을 쌓아두고 나중에 처리하는 장치를 본다.