0장. 결제사 한 곳이 느려졌는데 회원가입 메일이 안 나갑니다
외부 결제사 한 곳의 응답이 느려집니다. 장애가 발생해 아예 다운된 것은 아닙니다. 30초쯤 뒤에 응답이 오긴 옵니다. 에러가 쏟아지는 상황도 아니므로 겉보기에는 시스템이 평온하게 돌아가는 듯합니다.
그런데 몇 분 뒤 모니터링 대시보드에 뜨는 현상은 결제 실패가 아닙니다. 갑자기 신규 회원가입 처리가 멈춥니다. 메인 화면의 상품 목록이 뜨지 않습니다. 곧이어 서버의 헬스 체크가 실패하기 시작하고, 로드 밸런서는 살아있는 서버를 타겟 그룹에서 하나씩 빼버립니다. 결제와 아무 상관 없는 기능이 전부 죽어버립니다.
여기서 이런 의문이 듭니다.
결제사가 느려졌는데 왜 회원가입이 안 되지? 무슨 상관이 있었던 걸까요?
1장. 왜 무관한 것까지 멈추나
결제사가 느려졌는데 도대체 왜 회원가입이 실패할까요?
사실 둘은 목적이 완전히 다른 기능입니다. 하지만 그것은 개발자들의 입장입니다. 의미를 알지 못하는 서버 입장에서는 그저 똑같이 처리해야 할 'HTTP 요청'일 뿐이죠. 기본적으로 웹 서버는 어떤 API 엔드포인트로 요청이 들어오든, 자신이 가진 단 하나의 워커 스레드 풀에서 스레드를 꺼내어 할당합니다.
스프링 부트로 띄운 서버를 떠올려 봅시다. 그 안에 API가 열 개든 백 개든, 결국 기반이 되는 웹 서버(Tomcat 등)가 쥐고 있는 커다란 '워커 스레드 풀' 하나를 다 같이 나누어 씁니다.
그러면 아예 서버를 찢어 놓으면 되지 않느냐고 물을 수 있습니다. 그것도 방법입니다. 다만 그것 역시 자원을 나누는 여러 방법 중 하나일 뿐이고, 그중에서 값이 가장 비쌉니다. 이 이야기는 뒤에서 제대로 하겠습니다.
여기서 짚고 갈 점은, 우리가 의도적으로 방어선을 긋지 않는 이상 기본 상태의 애플리케이션은 무조건 자원이라는 커다란 통 하나를 통째로 공유하는 운명이라는 사실입니다.
이러한 상황에서 결제 쪽 API가 지연되어 이 공용 스레드를 전부 물고 놓지 않으면 어떻게 될까요? 결제와 아무 상관 없는 단순 회원가입 요청은 빈 통만 긁다가 거절당합니다. 단 하나의 병목이 애플리케이션 전체를 멈춘 것입니다.
그런데 여기서 조금 이상하지 않나요? 결제사의 응답을 기다리는 동안 그 스레드는 아무 일도 하지 않습니다. CPU도 쓰지 않고 그냥 멍하니 서 있습니다. 그렇게 놀고 있는 스레드라면 그동안 회원가입 요청 하나쯤 처리해 줄 수도 있지 않을까요?
그렇게 되면 좋겠지만 구조가 그것을 허락하지 않습니다. 전통적인 서블릿 방식에서 요청 하나는 스레드 하나를 통째로 배정받고, 그 스레드는 응답을 다 만들어 내보낼 때까지 다른 요청에 재사용되지 않습니다. 결제사에게 요청을 보낸 그 스레드는 소켓에서 응답 데이터가 도착할 때까지 코드의 그 줄에 멈춰 서 있습니다. 이것을 블로킹(blocking) 입출력이라고 부릅니다.
놀고 있는 것과 비어 있는 것은 다릅니다. 그 스레드는 일을 하지 않지만 결제 요청에 묶여 있고, 톰캣 입장에서는 이미 나가 있는 자원입니다. 남은 것은 199개입니다.

여기서 우리는 이 현상의 본질을 관통하는 가장 중요한 질문을 마주합니다. 그렇다면 과연 이 텅 빈 시스템에서 '무엇이 먼저 바닥나는가'입니다.
마이크로소프트의 아키텍처 문서는 이 고갈의 과정을 정확하게 짚어냅니다.
"When the consumer sends a request to a misconfigured or unresponsive service, the resources that the client's request uses might remain unavailable for an extended period. As requests to the service continue, those resources might be exhausted. For example, the client's connection pool might be exhausted. At that point, the consumer's requests to other services are affected. Eventually, the consumer can't send requests to any other services, not only the original unresponsive service."
소비자가 잘못 설정되었거나 응답하지 않는 서비스로 요청을 보내면, 그 클라이언트 요청이 사용하는 자원이 오랫동안 반환되지 않을 수 있다. 요청이 계속되면 그 자원은 고갈될 수 있다. 예를 들어 클라이언트의 커넥션 풀이 고갈될 수 있다. 그 시점부터는 다른 서비스로 보내는 요청들도 영향을 받는다. 결국 소비자는 원래 응답하지 않던 그 서비스뿐 아니라 다른 어떤 서비스에도 요청을 보낼 수 없게 된다.
- Microsoft Azure Architecture Center, Bulkhead pattern, Context and problem
이 마지막 문장에서 위의 질문이 풀리네요. 결제사가 응답을 미루며 자원을 쥐고 놓지 않으면, 결국 회원가입 메일을 보내거나 상품 목록을 불러오는 다른 어떤 요청도 처리할 수 없게 됩니다. 고갈은 양방향으로 일어납니다. 같은 문서에서는 반대 방향의 고갈도 경고합니다.
"Resource exhaustion affects services that have multiple consumers. Many requests from one client might exhaust available resources in the service. Resource exhaustion can mean that other consumers can't consume the service, which causes a cascading failure effect."
자원 고갈은 소비자가 여럿인 서비스에도 영향을 준다. 한 클라이언트에서 오는 많은 요청이 그 서비스의 가용 자원을 고갈시킬 수 있다. 자원 고갈은 다른 소비자들이 그 서비스를 쓸 수 없게 되는 것을 뜻할 수 있고, 그것이 연쇄 장애로 이어진다.
- 같은 문서, 같은 절
내가 부르는 쪽이 느려서 내가 죽을 수도 있고, 나를 부르는 쪽이 지나치게 시끄러워서 내 자원이 말라붙고 다른 호출자들까지 다 같이 죽을 수도 있다는 것입니다.
이쯤에서 아까 미뤄두었던 질문을 다시 꺼내 봅시다. "아예 서버를 찢어 놓으면 되지 않나?". 그런가요?! 마이크로서비스 아키텍처를 도입해 서버들을 잘게 쪼개면 과연 이 고갈의 늪에서 벗어날 수 있을까요?
오히려 그 반대입니다. 구글 SRE 책이 연쇄 장애를 정의하는 방식을 보면 이유가 분명해집니다.
"A cascading failure is a failure that grows over time as a result of positive feedback. It can occur when a portion of an overall system fails, increasing the probability that other portions of the system fail. For example, a single replica for a service can fail due to overload, increasing load on remaining replicas and increasing their probability of failing, causing a domino effect that takes down all the replicas for a service."
연쇄 장애란 양의 피드백 때문에 시간이 지날수록 커지는 장애다. 전체 시스템의 한 부분이 실패하면서 다른 부분들이 실패할 확률을 높일 때 발생한다. 예를 들어 어떤 서비스의 복제본 하나가 과부하로 죽으면 남은 복제본들의 부하가 올라가고 그것들이 죽을 확률도 함께 올라가서, 결국 그 서비스의 복제본 전체를 쓰러뜨리는 도미노 효과가 생긴다.
- Google SRE Book, Addressing Cascading Failures
정의 안에 답이 들어 있습니다. 연쇄 장애는 "한 부분이 다른 부분의 실패 확률을 높이는" 현상입니다. 그러니 이 장애가 성립하려면 부분이 여럿이어야 합니다. 서버를 잘게 쪼갠다는 것은 그 부분의 개수를 늘린다는 뜻이고, 부분이 늘어나면 서로의 실패 확률을 높일 경로도 함께 늘어납니다.
서버를 분리한다는 것은 결국 네트워크를 타고 수많은 외부 의존성을 호출해야 한다는 뜻입니다. 내가 부르는 쪽이 느려서 내가 죽고, 나를 부르는 쪽 때문에 남들도 죽는 이 연쇄 장애의 무대가 단일 서버 안에서 네트워크 전체로 넓어질 뿐입니다.
넷플릭스는 이 문제에 대해 냉정한 계산 결과를 제시합니다. 시스템이 복잡해질수록 연쇄 장애의 위험은 단순한 덧셈이 아니라 곱셈으로 증폭된다는 것입니다. 그들은 서비스 30개에 의존하는 애플리케이션에서 각 서비스의 가용성이 99.99퍼센트라고 가정하더라도, 실제 우리가 마주할 현실은 다음과 같다고 꼬집습니다.
- 0.9999의 30제곱은 99.7004퍼센트 가용성을 뜻합니다.
- 10억 건의 요청 중 0.3퍼센트는 300만 건의 실패를 의미합니다.
- 한 달을 720시간으로 계산하면 그 0.3퍼센트는 2.16시간의 다운타임입니다.
모든 의존성이 훌륭한 상태를 유지하는데도 이 정도의 실패를 감수해야 합니다. 심지어 느려지는 상황은 실패보다 전파 속도가 빠릅니다.
"With high volume traffic a single backend dependency becoming latent can cause all resources to become saturated in seconds on all servers."
트래픽이 많을 때는 백엔드 의존성 하나가 느려지는 것만으로 모든 서버의 모든 자원이 몇 초 만에 포화될 수 있다.
- Netflix Hystrix Wiki, What Problem Does Hystrix Solve?
'몇 초'라는 표현은 결코 과장이 아닙니다. 실제 웹 애플리케이션에서 이 포화가 어떻게 일어나는지 직접 계산해 보겠습니다.
먼저 네 가지 전제가 필요합니다. 첫째, HTTP 클라이언트는 아파치 HttpClient 5를 씁니다. 스프링 부트에 기본으로 탑재되지 않으므로 의존성에 명시적으로 추가하여 클래스패스에 존재하는 상황입니다. 둘째, 동기 서블릿 스택이며 플랫폼 스레드를 씁니다. 가상 스레드를 켜면 스레드 천장 자체가 사라지므로 가상 스레드는 꺼둔 상태입니다. 셋째, 결제 호출 한 건은 30초씩 응답을 붙잡고 있습니다. 넷째, 커넥션을 얻기 위해 대기하는 리스(lease) 상한 시간은 기본값인 3분입니다.
공식 문서에서 확인한 실제 기본값은 아래와 같습니다.
server.tomcat.threads.max: 200- Apache HttpClient 5
DEFAULT_MAX_CONNECTIONS_PER_ROUTE: 5 - Apache HttpClient 5
DEFAULT_MAX_TOTAL_CONNECTIONS: 25
0장의 상황이 벌어지면 톰캣 워커 스레드 200개는 다음과 같은 운명을 맞이합니다.
결제사로 나가는 HTTP 커넥션은 라우트당 5개가 상한입니다. 여기서 라우트(route)란 쉽게 말해 목적지 하나를 뜻합니다. 결제사 도메인으로 나가는 커넥션이 한 라우트이고, 배송사 도메인으로 나가는 커넥션은 또 다른 라우트입니다. 결제사가 느려지면 이 5개의 커넥션은 즉시 점유됩니다. 여섯 번째 결제 요청부터는 커넥션을 빌리려고 대기열에 서서 기다립니다. 이 리스 대기의 상한은 3분이므로 사실상 무한정 기다리는 셈입니다. 문제는 대기하는 동안 톰캣 워커 스레드 하나를 꼼짝없이 붙잡고 있다는 점입니다.
결제 요청이 초당 20건씩 들어오고, 한 건이 30초씩 스레드를 쥐고 있다면 어떻게 될까요? 10초 안에는 아무도 스레드를 반납하지 않습니다. 200개의 스레드가 전부 묶이기까지는 200을 20으로 나눈 10초면 충분합니다. 10초도 채 되지 않아 회원가입에도, 상품 목록 조회에도, 헬스 체크에도 내어줄 스레드가 단 하나도 남지 않게 됩니다.
실제로 돌려본 결과
여기까지는 계산입니다. 그래서 실제로 그렇게 되는지 확인할 수 있는 코드를 만들어 돌려봤습니다.
java IsolationLab.java
숫자는 위 설명을 그대로 10분의 1로 줄였습니다. 워커 스레드 200개 대신 20개, 30초 지연 대신 3초, 초당 20건 대신 10건입니다. 리틀의 법칙은 어느 규모에서나 같은 말을 합니다. 10 곱하기 3은 30인데 가진 것은 20개뿐입니다.
프로그램은 느린 의존성 역할을 할 로컬 HTTP 서버를 띄우고, 결제 요청과 회원가입 요청을 같은 워커 풀에 흘려보냅니다. 그리고 매초 워커 풀이 얼마나 찼는지를 그려줍니다. 격리 없이 한 번, 세마포어 벌크헤드를 5로 걸고 한 번, 두 번 돌린 뒤 결과를 나란히 보여줍니다.
제 맥미니에서 실제로 돌린 화면입니다.

위쪽 실행을 보면, 1초입니다. 결제사가 느려지고 1초 만에 워커 풀 20개가 전부 찼고, 그 순간부터 회원가입 요청이 굶기 시작합니다.
STARVED 뒤의 숫자는 스레드를 받지 못하고 거절당한 회원가입 요청의 누적 건수이고, 8초 동안 20건까지 올라갑니다. 결제사는 아직 죽지도 않았습니다. 3초 뒤에 응답을 정상적으로 돌려주고 있습니다.
아래쪽은 같은 프로그램이 세마포어 벌크헤드를 5로 걸고 한 번 더 돌린 것입니다. 결제가 가져가는 것이 정확히 5개로 묶였고, 회원가입은 8초 내내 한 번도 굶지 않았습니다.
그리고 맨 아래 요약표의 마지막 줄을 눈여겨보면, 회원가입은 20건에서 40건으로 늘었고 굶는 건수는 0이 되었습니다. 그런데 같은 표에서 결제 거절은 40건에서 75건으로 늘었습니다. 결제 성공은 30건에서 10건으로 줄었고요.
벌크헤드는 결제를 고쳐주지 않습니다. 결제사는 여전히 느리고, 오히려 우리가 결제에 배정한 몫이 작아져서 더 많이 거절합니다. 이 장치가 한 일은 결제가 서버에서 가져갈 수 있는 몫을 정한 것뿐입니다. 무엇을 얻고 무엇을 잃었는지가 이 표에 그대로 찍혀 있고, 이 거래의 값이 얼마인지는 6장에서 다시 이야기하겠습니다.
전체 코드는 renechoi/thread-pool-exhaustion-lab에 공개해 두었습니다. 워커 수, 지연 시간, 유입 속도, 벌크헤드 크기를 바꿔가며 직접 확인해 보실 수 있습니다.
GitHub - renechoi/thread-pool-exhaustion-lab: A slow dependency exhausts the Tomcat worker pool and unrelated endpoints die with
A slow dependency exhausts the Tomcat worker pool and unrelated endpoints die with it. Then the same run behind a bulkhead. One file, no dependencies, one command. - renechoi/thread-pool-exhaustion...
github.com
0장에서 로드 밸런서가 멀쩡한 서버를 하나씩 빼버린 이유가 이것입니다. 헬스 체크도 결국 HTTP 요청이라 스레드를 하나 받아야 응답할 수 있습니다. 서버는 살아 있는데 대답할 스레드가 없으니 로드 밸런서 눈에는 죽은 서버로 보입니다. 그리고 그 서버가 빠지면 남은 서버들이 그 몫까지 받아내야 하므로, 죽는 속도는 오히려 빨라집니다.
이 식은 반드시 '필요한 동시 처리 수는 유입률 곱하기 체류시간'이라는 조건을 충족할 때 성립합니다. 초당 20건이 30초씩 잡고 있으면 동시 처리 수 600이 필요한데 가진 것은 200뿐이므로 시스템이 무너집니다.

그러면 이런 생각이 드실 겁니다. 600이 필요하다면 스레드를 600개로, 아니 넉넉하게 2000개로 늘려버리면 되지 않을까요?
그렇게 해서 풀리는 문제라면 좋겠지만, 두 가지가 걸립니다.
첫째, 스레드는 공짜가 아닙니다. 스레드 하나마다 자기 스택 메모리를 들고 있고, 개수가 늘어날수록 운영체제가 이것들을 번갈아 실행시키느라 쓰는 시간도 함께 늘어납니다. 어느 지점을 넘어가면 스레드를 늘릴수록 오히려 느려집니다.
둘째, 이쪽이 더 중요한데, 늘려도 같은 식이 그대로 따라옵니다. 2000개로 늘린 다음 결제사가 여전히 안 돌아온다면, 유입은 계속되므로 2000개도 결국 다 찹니다. 100초쯤 뒤에 똑같은 일이 벌어질 뿐입니다. 스레드를 늘리는 것은 사망 시점을 미룰 뿐 사망을 막지 못합니다. 게다가 그렇게 늘려놓은 2000개는 결제사가 멀쩡한 평소에는 대부분 놀고 있습니다.
우리에게 정말로 필요한 것은 결제가 아무리 통을 퍼가도 회원가입 몫은 남아 있게 만드는 일입니다.
비슷한 일은 데이터베이스 커넥션 풀에서도 흔히 벌어집니다. 기본값이 10인 히카리CP(HikariCP) 커넥션 풀이 비면, 이어지는 요청들은 스레드를 쥔 채로 블록됩니다.
"When the pool reaches this size, and no idle connections are available, calls to getConnection() will block for up to connectionTimeout milliseconds before timing out."
풀이 이 크기에 도달하고 유휴 커넥션이 없으면, getConnection() 호출은 최대 connectionTimeout 밀리초까지 블록된 뒤에 타임아웃된다.
- HikariCP README, maximumPoolSize 항목
이 대기가 영원하지는 않습니다.
"This property controls the maximum number of milliseconds that a client (that's you) will wait for a connection from the pool. If this time is exceeded without a connection becoming available, a SQLException will be thrown."
이 설정은 클라이언트(바로 당신)가 풀에서 커넥션을 얻으려고 기다릴 최대 밀리초를 조절한다. 이 시간을 넘겨도 커넥션이 나지 않으면 SQLException이 던져진다.
- HikariCP README, connectionTimeout 항목
히카리CP의 connectionTimeout 기본값은 30초입니다. 느린 쿼리 10개가 풀을 점유하면 나머지 요청은 30초 동안 꼼짝 않고 서서 기다린 뒤에야 에러를 돌려받습니다.
여기서 아주 중요한 통찰을 얻을 수 있습니다. 앞선 HTTP 클라이언트 예제에서 커넥션 풀은 이미 라우트별로 5개씩 나뉘어 있었습니다. 결제사가 앗아간 커넥션은 5개뿐이고, 전체 25개 중 20개의 커넥션은 여전히 멀쩡하게 남아있었습니다. 그런데도 서비스 전체가 죽었습니다. 정작 나뉘지 않은 핵심 자원, 즉 '톰캣 스레드'가 통째로 고갈되었기 때문입니다.

제 생각에는 이 지점이 격리를 설계할 때 맞닥뜨리는 가장 결정적인 문턱입니다. 어느 층에 격리를 걸었느냐가 모든 것을 결정합니다. 바닥나는 진짜 자원이 무엇인지 파악하지 못한 채 층을 잘못 고르면, 백날 나누어 보아야 안 나눈 것과 똑같은 결과를 낳습니다.
복잡한 격리의 계층 구조를 파헤치기 전에, 시스템이 갖춰야 할 가장 원초적인 방어 장치부터 점검해 보면 어떨까 합니다. 자원을 쪼개는 비싼 작업을 하기 전에 가장 먼저 할 수 있는 빠르고 간단한 조치가 있을까요?
2장. 장치 하나, 타임아웃: 나누지 않고 끊는다
단일 서버든 분산 환경이든 스레드가 말라붙은 이유는 똑같았습니다. 상대가 응답할 때까지 쥔 것을 놓지 않고 무한정 기다렸기 때문입니다.
그렇다면 기다리는 시간에 상한을 걸면 논리적으로 스레드는 풀려나지 않을까요?
그럴 거 같습니다! 바로 타임아웃(Timeout)입니다. 타임아웃은 자원을 물리적으로 나누지 않습니다. 단지 붙잡히는 시간을 끊을 뿐입니다. 아키텍처를 뜯어고치지 않아도 되니 시스템에서 가장 싼 장치입니다.
그런데 여기서 한 가지를 갈라야 합니다. "타임아웃을 걸었다"는 말에는 서로 다른 두 가지 뜻이 섞여 있습니다.
하나는 호출한 쪽이 기다리기를 그만두는 것입니다. 3초가 지나면 예외를 받고 다음 코드로 넘어갑니다.
다른 하나는 붙잡혀 있던 스레드가 실제로 풀려나는 것입니다. 그 스레드가 다시 대기열로 돌아와서 다른 요청을 받을 수 있게 되는 것이죠.
우리가 원한 것은 두 번째입니다. 1장에서 서비스가 죽은 이유가 스레드가 안 돌아왔기 때문이니까요. 그런데 우리가 흔히 거는 타임아웃은 첫 번째만 해줍니다.
왜 그런지 보려면 그 타임아웃이 실제로 어떻게 구현되어 있는지 알아야 합니다. 넷플릭스의 히스트릭스(Hystrix) 같은 라이브러리가 하는 일은 생각보다 소박합니다. 작업을 시켜놓고 옆에서 시계를 보다가, 정해진 시간이 지나면 호출한 쪽에 예외를 던져줍니다. 자바 표준으로 옮기면 이런 모양입니다.
Future<Payment> future = executor.submit(() -> paymentClient.pay(order));
Payment result = future.get(3, TimeUnit.SECONDS); // 3초 뒤 TimeoutException
3초가 지나면 get()이 예외를 던지고 호출한 쪽은 빠져나옵니다. 여기까지는 의도대로입니다. 그런데 paymentClient.pay()를 실제로 실행하고 있던 스레드는 어떻게 될까요? 아무 일도 일어나지 않습니다. 그 스레드는 여전히 결제사의 응답을 기다리고 있습니다. 시계를 본 쪽과 일을 하고 있는 쪽이 애초에 서로 다른 스레드이기 때문입니다.
히스트릭스 문서가 이 지점을 정확히 짚습니다.
"there's no way to force the latent thread to stop work"
느려진 스레드를 강제로 멈출 방법은 없습니다.
- Netflix Hystrix Wiki, How it Works
이유는 단순합니다. JVM에는 남의 스레드를 강제로 멈추게 하는 수단이 없기 때문입니다.
할 수 있는 것은 "이제 그만해달라"고 신호(InterruptedException)를 보내는 것까지이고, 그 신호를 받아들일지 말지는 그 스레드가 실행 중인 코드에 달려 있습니다. 그리고 같은 문서가 우리에게 현실을 알려줍니다.
"Most Java HTTP client libraries do not interpret InterruptedExceptions."
대부분의 자바 HTTP 클라이언트 라이브러리는 그 신호를 읽지 않습니다.
- 같은 문서
그러니까 이런 일이 벌어집니다. 3초 타임아웃이 걸려 있고, 호출한 쪽은 3초 만에 예외를 받고 떠났습니다. 로그에도 타임아웃이 찍혔습니다. 겉으로는 방어가 잘 작동한 것처럼 보입니다. 그런데 그 스레드는 여전히 결제사의 응답을 기다리며 30초를 채우고 있습니다. 우리가 지키려던 자원은 하나도 돌아오지 않았습니다.
그럼 어떻게 해야 진짜로 돌려받을 수 있을까요? 같은 문서가 마지막 줄에서 답을 줍니다.
"So make sure to correctly configure connection and read/write timeouts on the HTTP clients."
HTTP 클라이언트의 커넥션 타임아웃과 읽기/쓰기 타임아웃을 제대로 설정하십시오.
- 같은 문서
소켓 자체에 거는 타임아웃입니다. 여기서는 연결을 실제로 끊어버립니다. 기다리던 코드는 선택의 여지 없이 예외를 받고, 스레드는 그 즉시 반환됩니다.
여기 두 종류가 나오는데, 이 둘은 요청의 서로 다른 구간을 담당합니다. 외부 API 호출 한 번은 크게 두 단계로 나뉩니다. 먼저 상대 서버와 연결을 맺고, 그다음 요청을 보내고 응답이 오기를 기다립니다.
커넥션 타임아웃은 앞 단계에만 적용됩니다. 연결을 맺기까지 기다릴 시간의 상한입니다.
읽기 타임아웃은 뒷 단계에만 적용됩니다. 연결이 맺어진 다음 데이터가 오기를 기다릴 시간의 상한입니다.
그럼 0장의 결제사에는 어느 쪽이 작동할까요? 결제사는 죽은 것이 아닙니다. 서버는 멀쩡히 떠 있어서 연결은 밀리초 단위로 맺어집니다. 그러니 앞 단계는 아무 문제 없이 통과합니다. 커넥션 타임아웃을 1초로 걸어두든 100밀리초로 걸어두든 발동할 일이 없습니다. 연결을 맺는 데 그만큼 걸리지 않으니까요.
30초를 잡아먹는 곳은 뒷 단계입니다. 연결은 이미 맺어졌고, 우리 스레드는 그 열린 연결에서 응답 데이터가 오기를 기다리며 서 있습니다.
그러니 이 상황에서 스레드를 구해낼 수 있는 것은 읽기 타임아웃뿐입니다. 커넥션 타임아웃을 아무리 조여도 이 장애에는 손도 못 댑니다.

여기서 조금 뒤집힌 사실 하나가 나옵니다. 상대가 완전히 죽은 장애보다 상대가 살아 있는데 느린 장애가 우리에게 더 위험합니다.
상대 서버가 아예 내려가 있으면 연결 시도 자체가 곧바로 거부당합니다. 운영체제가 즉시 예외를 돌려주므로 우리 스레드는 몇 밀리초 만에 풀려납니다. 실패는 하지만 자원은 지킵니다. 반대로 상대가 살아서 느리기만 하면, 우리 쪽에 읽기 타임아웃이 없는 한 그 스레드는 상대가 응답할 마음이 들 때까지 계속 서 있습니다. 장애 알림도 안 뜨고, 에러율도 안 오르고, 스레드만 조용히 사라집니다. 0장에서 대시보드가 평온해 보였던 이유가 이것입니다.

정리하면 이렇습니다. 상위에서 감싼 타임아웃은 호출한 쪽을 구하고, 소켓에 건 타임아웃은 붙잡힌 스레드를 구합니다. 자바로 개발하면서 RestTemplate이나 WebClient를 기본 설정 그대로 쓰고 있다면, 지금 걸려 있는 것이 어느 쪽인지 한번 확인해 보시는 편이 좋습니다.
그렇다면 그 시간은 몇 초로 잡아야 할까요? 3초일까요, 10초일까요? 여기에는 정해진 답이 없습니다. 구글 SRE 책은 이것을 예술이라고 표현합니다.
"Balancing these constraints to pick a good deadline can be something of an art."
이 제약들 사이에서 균형을 잡아 좋은 데드라인을 고르는 일은 예술에 가깝습니다.
- Google SRE Book, Addressing Cascading Failures
양쪽으로 다 틀릴 수 있기 때문입니다. 양쪽이 어떻게 틀리는지를 각각 보겠습니다.
길게 잡으면 무슨 일이 생기나
가장 먼저 떠오르는 문제는 앞에서 본 그대로입니다. 이미 지나간 문제 때문에 서버가 재시작될 때까지 자원이 묶여 있습니다.
그런데 실무에서 더 자주 사람을 잡는 것은 따로 있습니다. 타임아웃이 호출 사슬을 따라 쌓인다는 점입니다.
주문 서비스가 결제 서비스를 부르고, 결제 서비스가 다시 외부 결제사를 부른다고 해봅시다. 각 층의 개발자가 저마다 "30초면 넉넉하지" 하고 30초씩 걸어두면 어떻게 될까요. 사용자는 최악의 경우 그 시간들이 겹겹이 쌓인 만큼 흰 화면을 봅니다. 각 층은 자기 기준으로 합리적인 값을 골랐는데 전체는 감당 못 할 시간이 됩니다.
구글 SRE 책은 이 문제의 해법을 명확히 지시합니다.
"Rather than inventing a deadline when sending RPCs to backends, servers should employ deadline propagation."
백엔드로 RPC를 보낼 때 데드라인을 새로 지어내지 말고, 데드라인 전파를 써야 합니다.
- Google SRE Book, Addressing Cascading Failures, Deadline propagation
맨 앞에서 데드라인을 한 번 정하고, 각 층은 남은 시간을 물려받는 방식입니다. 같은 책이 예시를 들어줍니다.
"if server A selects a 30-second deadline, and processes the request for 7 seconds before sending an RPC to server B , the RPC from A to B will have a 23-second deadline"
서버 A가 30초 데드라인을 골랐고 7초 동안 요청을 처리한 뒤 서버 B로 RPC를 보낸다면, A에서 B로 가는 RPC의 데드라인은 23초가 됩니다.
- 같은 문서, 같은 절
30초에서 이미 쓴 7초를 뺀 23초를 물려주는 것입니다. 이렇게 하면 사슬이 아무리 길어져도 사용자가 기다리는 총 시간은 맨 앞에서 정한 30초를 넘지 않습니다.

그러면 짧게 잡으면 되나
짧게 잡으면 안전할 것 같지만 여기에도 대가가 있습니다. 우선 원래 오래 걸리는 무거운 요청들이 매번 실패합니다. 정산 배치나 대용량 조회처럼 원래 10초가 정상인 API에 3초를 걸어두면, 그 기능은 장애가 없어도 항상 실패합니다.
그런데 더 위험한 것은 따로 있습니다. 짧은 타임아웃은 대개 재시도와 함께 다닙니다. 3초 만에 실패했으니 한 번 더 해보자는 코드가 자연스럽게 붙습니다.
그 결과가 어떻게 되는지 구글이 계산해 두었습니다. 데이터센터가 요청 대부분을 거절하고 있는 최악의 상황을 가정한 값입니다.
"Due to the number of retries that will occur, the number of requests will grow significantly, to somewhere just below 3X"
재시도가 일어나는 횟수 때문에 요청 수가 크게 늘어나, 3X보다 조금 아래까지 갑니다.
- Google SRE Book, Handling Overload, Deciding to Retry
재시도를 세 번으로 제한해 두었는데도 전체 요청량이 세 배 가까이 됩니다. 이미 느려서 허덕이는 상대에게 세 배를 보내는 셈입니다. 상대를 살리려고 짧게 잡은 타임아웃이 상대를 확실히 죽이는 경로가 됩니다.
구글이 이 증폭을 어떻게 막았는지도 참고할 만합니다. 재시도 횟수만 제한하는 것으로는 부족했고, 재시도가 전체 요청에서 차지하는 비율에 한도를 걸었습니다.
"A request will only be retried as long as this ratio is below 10%."
이 비율이 10퍼센트 아래일 때만 요청을 재시도합니다.
- 같은 문서, 같은 절

이 비율 예산을 얹으면 증폭이 3배에서 1.1배로 떨어진다고 같은 문서가 밝힙니다. 핵심은 전체 트래픽 중 몇 퍼센트까지 재시도로 채울지를 정하는 데 있습니다.
그리고 짧은 타임아웃에는 부하 증폭보다 더 골치 아픈 문제가 따라옵니다. 양쪽의 기록이 어긋나기 시작합니다.
3초 타임아웃이 걸린 결제 요청을 생각해 봅시다. 3초가 지나 우리 쪽에는 SocketTimeoutException이 올라옵니다. 그런데 여기서 우리가 아는 것은 무엇일까요. 우리가 아는 것은 응답이 3초 안에 오지 않았다는 사실 하나뿐입니다. 결제사가 그 요청을 받았는지, 받아서 처리했는지, 처리해서 카드사에 승인까지 올렸는지는 알 수 없습니다.
실제로 벌어지는 일은 대개 이렇습니다. 결제사는 요청을 정상적으로 받아 4초 만에 승인까지 끝냈습니다. 응답도 보냈는데, 그 시점에 우리 쪽 연결은 이미 끊어져 있었습니다. 결제사 장부에는 성공한 결제가 있고 우리 장부에는 실패한 결제가 있습니다. 사용자 카드에서는 돈이 빠져나갔는데 주문은 실패로 남습니다.
여기서 앞의 재시도가 겹치면 더 나빠집니다. 실패했다고 판단한 우리가 한 번 더 보내면, 결제사 입장에서는 멀쩡한 결제 요청이 두 번 온 것입니다. 같은 주문에 두 번 결제됩니다.
타임아웃을 짧게 잡을수록 이 어긋남이 생길 확률이 올라갑니다. 상대가 4초 만에 끝내는 일에 3초를 걸어두면 어긋남은 상시로 발생합니다.
그래서 외부 호출에 타임아웃을 거는 순간 함께 정해야 하는 것이 하나 더 생깁니다. 결과를 모르는 실패를 어떻게 처리할 것인가입니다. 흔한 해법 두 가지를 짚어두겠습니다. 하나는 요청마다 고유한 키를 붙여 보내서, 같은 키가 다시 오면 상대가 새로 처리하지 않고 앞선 결과를 그대로 돌려주게 만드는 것입니다. 결제 API들이 멱등키라는 이름으로 제공하는 기능이 이것입니다. 다른 하나는 타임아웃 난 건을 실패로 확정하지 않고 미결 상태로 남겨두었다가, 나중에 상대의 조회 API로 진짜 결과를 확인하는 것입니다.
타임아웃은 "응답이 안 왔다"까지만 알려주지 "일어나지 않았다"를 알려주지 않습니다. 이 둘을 같은 것으로 취급하면 이제 트러블슈팅이 필요하게 되죠.
그래서 얼마로 잡나
정리하면 이렇습니다. 길게 잡으면 자원이 묶이고 사슬을 따라 시간이 쌓입니다. 짧게 잡으면 정상 요청이 실패하고, 재시도와 만나 부하를 증폭시킵니다. 정답이 예술의 영역이라고 불리는 이유입니다.
다만 방향은 있습니다. 값을 고르기 전에 두 가지를 먼저 정해야 합니다. 첫째, 이 사슬 전체가 사용자에게 허용된 시간이 몇 초인가. 그것을 맨 앞에서 정하고 아래로 물려줍니다. 둘째, 실패했을 때 재시도를 할 것인가. 한다면 전체 트래픽의 몇 퍼센트까지 허용할 것인가. 이 둘을 정하지 않은 채 각 호출마다 숫자를 하나씩 적어 넣는 것이 가장 흔한 실수입니다.
값은 정해줄 수 없지만, 판단의 기준은 같은 책이 한 문장으로 정리해 놓았습니다.
"resources are spent while no progress is made: you don't get credit for late assignments with RPCs"
자원은 쓰이는데 진전은 없습니다. RPC에서는 늦게 낸 과제에 학점을 주지 않습니다.
- 같은 문서
여기서 잠깐 시점을 바꿔보겠습니다. 지금까지 우리는 결제사를 부르는 쪽이었습니다. 이번에는 우리가 결제사의 처지, 그러니까 요청을 받는 쪽이 되어 봅시다.
우리 서버가 과부하라서 대기열이 길어졌습니다. 어떤 요청이 큐에서 11초를 기다린 끝에 드디어 스레드를 배정받습니다. 그런데 그 요청을 보낸 클라이언트는 10초 타임아웃을 걸어두었고 이미 1초 전에 포기하고 돌아갔습니다. 우리는 아무도 받지 않을 응답을 만드느라 지금부터 스레드 하나를 씁니다. 그리고 그 스레드는 정말로 기다리고 있는 다른 요청이 쓸 수 있었던 스레드입니다.
같은 낭비를 양쪽에서 본 셈입니다. 히스트릭스 쪽은 우리가 남을 기다리다 스레드를 잃는 장면이었고, 이쪽은 우리를 기다리지 않는 남을 위해 스레드를 쓰는 장면입니다. 방향은 반대인데 결론은 하나로 모입니다. 아무도 기다리지 않는 일에 자원을 쓰지 않는 것. 타임아웃이 실제로 하는 일이 이것입니다.
그런데 여기까지 와도 끝나지 않는 문제가 하나 남습니다.
읽기 타임아웃을 30초로 제대로 걸어두었다고 해봅시다. 이제 스레드는 아무리 늦어도 30초 뒤에는 반드시 돌아옵니다. 그러면 1장의 사망은 막았을까요?
1장의 숫자를 그대로 대입해 보면 답이 나옵니다. 결제 요청이 초당 20건 들어오고 한 건이 30초를 쓰면 동시에 600개가 필요합니다. 가진 것은 여전히 200개입니다. 타임아웃을 제대로 걸었는데도 똑같이 죽습니다.
그럼 타임아웃을 10초로 줄이면 어떨까요. 20 곱하기 10은 200이니 아슬아슬하게 200개 안에 들어옵니다. 3초로 줄이면 60개만 쓰니 여유가 생깁니다. 계산상으로는 타임아웃을 조이는 것만으로 살아남을 수 있어 보입니다.
하지만 앞에서 본 대로 짧은 타임아웃에는 대가가 따르죠. 정상적으로 오래 걸리는 요청들이 실패하고, 재시도와 만나면 결제사에 보내는 부하가 몇 배로 불어나고, 우리와 결제사의 기록이 어긋나기 시작합니다.
여기가 타임아웃의 한계입니다. 타임아웃은 결제 호출 하나가 붙잡는 시간을 줄여줄 뿐, 결제 호출이 회원가입 몫까지 가져가는 것을 막지는 못합니다. 200개 전부가 결제에 쓰일 수 있다는 사실은 그대로입니다. 시간을 짧게 끊었을 뿐, 몫을 갈라놓지는 않았기 때문입니다.
그래서 다음 장부터는 시간이 아니라 몫을 나누는 이야기를 하겠습니다. 결제사를 부르는 요청이 아무리 많아도 회원가입 몫은 남아 있게 만드는 방법입니다.
3장. 장치 둘, 대기열을 나눈다
자원의 몫을 나눌 때, 가장 먼저 쪼개볼 수 있는 대상은 바로 '기다리는 라인'입니다.
그런데 우리 서버 어디에 대기열이 있을까요? 지금까지 스레드 이야기만 했으니 뜬금없게 느껴지실 수 있습니다. 하지만 대기열은 이미 있습니다.
톰캣의 워커 스레드 200개가 전부 일하고 있을 때, 새로 들어온 요청은 곧바로 거절당하지 않습니다. 스레드가 나기를 기다리는 대기열에 섭니다. 스프링 부트에는 이 대기열의 길이를 정하는 설정이 있습니다.
server.tomcat.accept-count: 100. 모든 처리 스레드가 사용 중일 때 들어오는 연결 요청을 몇 개까지 대기열에 세울지 정합니다.server.tomcat.max-connections: 8192. 동시에 받아들일 연결의 최대 개수입니다.
스레드는 200개인데 연결은 8192개까지 받습니다. 처리할 사람보다 훨씬 많은 손님을 일단 들여보내 놓고 대기열에 세우는 구조입니다. 카프카나 SQS 같은 메시지 큐도 하는 일은 똑같습니다. 처리 속도와 유입 속도의 차이를 잠시 저장해 두는 장치라는 점에서 이 둘은 같은 물건입니다.
그리고 라인이 하나뿐이면 이런 일이 벌어집니다. 계산대 앞에 열 명이 서 있는데 맨 앞사람의 카드가 계속 승인되지 않습니다. 뒤에 선 아홉 명은 현금을 들고 있어도 아무것도 할 수 없습니다. 앞이 막히면 뒤가 전부 막히는 이 현상을 head-of-line blocking, 그러니까 라인 맨 앞에서 생긴 정체라고 부릅니다.
정확히 0장의 상황이죠. 결제 요청이 스레드를 다 가져가면 회원가입 요청은 현금을 들고 서 있는 뒷사람이 됩니다. 처리할 일 자체는 1밀리초면 끝나는데, 앞에 선 결제 요청들이 비켜주지 않아서 못 합니다.
라인을 나눈 회사의 기록
이 문제를 정면으로 겪고 해결한 뒤 그 과정을 공개한 회사가 있습니다. 세그먼트(Segment)입니다.
세그먼트가 무슨 일을 하는 회사인지 먼저 알아야 할 것 같은데요. 보통 서비스를 운영하면 사용자 행동 데이터를 여러 곳에 보냅니다. 구글 애널리틱스에도 보내고, 앰플리튜드 같은 분석 도구에도 보내고, 마케팅 자동화 도구에도 보냅니다. 보내는 곳마다 API 규격이 다르니 연동을 각각 만들어야 합니다.
세그먼트는 이 일을 대신해 줍니다. 개발자는 세그먼트 한 곳으로만 이벤트를 보내고, 세그먼트가 그것을 수십 곳으로 대신 뿌려줍니다. 이때 뿌려지는 대상 하나하나를 세그먼트는 목적지(destination)라고 부릅니다. 구글 애널리틱스가 목적지 하나이고, 앰플리튜드가 또 다른 목적지입니다. 당시 목적지는 100종이 넘었습니다.
여기서 이 회사의 구조가 우리와 정확히 같다는 점이 보이실 겁니다. 하나의 시스템이 수많은 외부 서비스를 대신 호출해 주고, 그 외부 서비스들은 언제든 느려질 수 있습니다. 우리 서버가 결제사와 재고와 쿠폰을 부르는 것과 같은 그림이고, 다만 그 대상이 100개일 뿐입니다.
초기의 세그먼트는 이 모든 이벤트를 큐 하나에 담아 처리했습니다.
"At this point, a single queue contained both the newest events as well as those which may have had several retry attempts, across all destinations, which resulted in head-of-line blocking. Meaning in this particular case, if one destination slowed or went down, retries would flood the queue, resulting in delays across all our destinations."
이 시점에 하나의 큐에는 가장 새로운 이벤트와 재시도를 여러 번 거친 이벤트가 모든 목적지에 걸쳐 함께 들어 있었고, 그 결과 head-of-line blocking이 발생했다. 이 경우 목적지 하나가 느려지거나 죽으면 재시도가 큐를 가득 채우고, 그것이 모든 목적지의 지연으로 이어진다는 뜻이다.
- Segment Engineering Blog, "Goodbye Microservices: From 100s of problem children to 1 superstar", Why Microservices worked
목적지 100개 중 딱 하나가 느려졌을 뿐인데 나머지 99곳으로 가는 데이터까지 전부 밀립니다. 구글 애널리틱스로 갈 이벤트가, 전혀 무관한 마케팅 도구 한 곳이 느려졌다는 이유로 늦게 도착합니다.
여기서 자연스럽게 드는 생각이 있습니다. 큐가 밀리면 큐를 비우는 서버를 늘리면 되지 않을까요? 세그먼트는 이 반론에 대해서도 언급했습니다.
"Imagine destination X is experiencing a temporary issue and every request errors with a timeout. Now, not only does this create a large backlog of requests which have yet to reach destination X, but also every failed event is put back to retry in the queue. While our systems would automatically scale in response to increased load, the sudden increase in queue depth would outpace our ability to scale up, resulting in delays for the newest events."
목적지 X에 일시적인 문제가 생겨서 모든 요청이 타임아웃으로 실패한다고 하자. 이렇게 되면 목적지 X에 아직 도달하지 못한 요청이 크게 쌓일 뿐 아니라, 실패한 이벤트가 전부 큐로 되돌아와 재시도된다. 우리 시스템은 부하 증가에 자동으로 확장되지만, 큐 깊이가 급증하는 속도가 확장 속도를 앞질러서 가장 새로운 이벤트들이 지연된다.
- 같은 문서, 같은 절
핵심은 재시도가 큐로 되돌아온다는 점입니다. 보통의 부하는 밖에서 들어오는 만큼만 늘어나는데, 실패한 이벤트가 다시 대기열에 서는 구조에서는 부하가 자기 자신을 먹여 살립니다. 서버를 두 배로 늘리는 동안 큐는 그보다 빠르게 자랍니다. 2장에서 본 재시도 증폭이 큐에서 다시 나타난 셈입니다.
결국 세그먼트는 줄을 나누는 방식을 택합니다.
"To solve the head-of-line blocking problem, the team created a separate service and queue for each destination. This new architecture consisted of an additional router process that receives the inbound events and distributes a copy of the event to each selected destination. Now if one destination experienced problems, only it's queue would back up and no other destinations would be impacted."
head-of-line blocking 문제를 해결하기 위해 팀은 목적지마다 별도의 서비스와 큐를 만들었다. 새 아키텍처에는 인바운드 이벤트를 받아서 선택된 각 목적지로 이벤트 사본을 나눠주는 라우터 프로세스가 추가되었다. 이제 목적지 하나에 문제가 생기면 그 목적지의 큐만 밀리고 다른 목적지는 영향을 받지 않는다.
- 같은 문서, 같은 절
(원문의 it's는 오타지만 기록된 그대로 옮겼습니다.)

그리고 이 결정은 완벽하게 통했습니다. 목적지 하나가 죽어도 그 목적지의 큐만 밀립니다. 나머지 99곳은 아무 일도 없었던 것처럼 돌아갑니다. 격리가 정확히 의도한 대로 작동한 사례입니다.
우리 서버로 옮기면 이렇게 됩니다. 결제 요청과 회원가입 요청이 같은 라인에 서 있으니, 결제 쪽이 밀리면 회원가입도 같이 밀립니다. 두 라인으로 나누면 결제 라인이 아무리 길어져도 회원가입 라인은 짧게 유지됩니다.
그런데 나눈 라인이 길면 또 문제입니다
라인을 나눴으니 끝일까요? 여기서 또 함정이 있었습니다. 라인을 나누는 것과 라인을 길게 두는 것은 전혀 다른 이야기입니다.
앞에서 본 스프링 부트 기본값을 다시 보겠습니다. 스레드는 200개인데 연결은 8192개까지 받습니다. 이 긴 대기열은 무엇을 해줄까요. 갑자기 몰린 순간의 요청을 흘려보내지 않고 담아둡니다. 그래서 짧은 폭주에는 도움이 됩니다.
문제는 폭주가 짧지 않을 때입니다. 처리 속도보다 유입이 계속 빠르면 대기열은 계속 자라기만 합니다. 그때부터 긴 대기열은 지연을 저장해 두는 창고가 됩니다. 구글 SRE 책이 짧은 대기열을 권하는 이유입니다.
"For a system with fairly steady traffic over time, it is usually better to have small queue lengths relative to the thread pool size (e.g., 50% or less), which results in the server rejecting requests early when it can't sustain the rate of incoming requests. For example, Gmail often uses queueless servers, relying instead on failover to other server tasks when the threads are full."
시간에 따라 트래픽이 비교적 안정적인 시스템이라면, 스레드 풀 크기에 비해 큐 길이를 작게 (예를 들어 50퍼센트 이하로) 두는 편이 대체로 낫다. 그러면 서버가 들어오는 요청 속도를 감당하지 못할 때 일찍 요청을 거절하게 된다. 예를 들어 지메일은 큐가 없는 서버를 자주 쓰고, 스레드가 가득 차면 다른 서버 태스크로 넘긴다.
- Google SRE Book, Addressing Cascading Failures, Queue Management
스레드가 200개라면 대기열은 100개 이하로 두라는 이야기입니다. 기본값 8192와 비교하면 격차가 큽니다. 지메일은 아예 대기열을 없애고 스레드가 차면 다른 서버로 넘겨버립니다.
왜 이렇게까지 하는지 구글이 산술로 보여줍니다.
"Queued requests consume memory and increase latency. For example, if the queue size is 10x the number of threads, the time to handle the request on a thread is 100 milliseconds. If the queue is full, then a request will take 1.1 seconds to handle, most of which time is spent on the queue."
큐에 쌓인 요청은 메모리를 쓰고 지연을 늘린다. 예를 들어 큐 크기가 스레드 수의 10배이고 스레드에서 요청 하나를 처리하는 데 100밀리초가 걸린다고 하자. 큐가 가득 차면 요청 하나를 처리하는 데 1.1초가 걸리고, 그 시간의 대부분은 큐에서 보낸 시간이다.
- 같은 문서, 같은 절
스레드 하나 앞에 요청이 열 개 서 있으니 기다리는 데만 1,000밀리초를 씁니다. 실제 처리는 100밀리초입니다. 합치면 1.1초인데 그중 9할이 순수한 대기입니다. 100밀리초짜리 API가 1.1초짜리로 둔갑한 것입니다.

아까 2장의 질문이 되돌아오는 지점입니다. 1.1초를 기다린 클라이언트가 아직 그 응답을 기다리고 있을까요? 클라이언트 타임아웃이 1초였다면 이미 떠났습니다. 대기열에서 1초를 보낸 그 요청은 처리해 봐야 버려집니다.
그래서 구글은 줄의 순서를 뒤집는 방법까지 제시합니다.
"Changing the queuing method from the standard first-in, first-out (FIFO) to last-in, first-out (LIFO) or using the controlled delay (CoDel) algorithm [Nic12] or similar approaches can reduce load by removing requests that are unlikely to be worth processing [Mau15]. If a user's web search is slow because an RPC has been queued for 10 seconds, there's a good chance the user has given up and refreshed their browser, issuing another request: there's no point in responding to the first one, since it will be ignored!"
큐 방식을 표준적인 선입선출에서 후입선출로 바꾸거나 controlled delay(CoDel) 알고리즘 같은 접근을 쓰면, 처리할 가치가 없어 보이는 요청을 제거해서 부하를 줄일 수 있다. RPC가 큐에서 10초를 기다린 탓에 사용자의 웹 검색이 느려졌다면, 그 사용자는 이미 포기하고 브라우저를 새로고침해서 새 요청을 보냈을 가능성이 높다. 첫 번째 요청에 응답할 이유가 없다. 어차피 무시될 테니까.
- 같은 문서, Load Shedding and Graceful Degradation
먼저 온 사람을 먼저 처리하는 선입선출은 공정해 보입니다. 그런데 과부하에서는 먼저 온 사람일수록 이미 떠났을 가능성이 높습니다. 공정하게 처리한 결과가 아무도 받지 않는 응답이 되는 것이죠. 후입선출은 방금 들어와서 아직 기다리고 있을 확률이 높은 요청부터 살립니다.
저는 이것이 이 글에서 가장 반직관적인 대목이라고 생각했습니다.
라인을 나누는 것으로 끝나지 않는 이유
세그먼트는 라인을 나눠서 문제를 풀었습니다. 그러면 우리도 결제 라인과 회원가입 라인을 따로 만들면 되지 않을까요?
여기서 두 가지를 짚어야 합니다.
하나. 세그먼트가 나눈 것은 라인만이 아니었습니다. 앞의 인용을 다시 보면 "목적지마다 별도의 서비스와 큐를 만들었다"고 되어 있습니다. 라인과 함께 그 라인을 비우는 일꾼까지 따로 뒀습니다. 목적지 X가 느려지면 X의 큐가 밀리고 X의 워커들이 묶이는데, 그 워커들은 다른 목적지의 큐를 건드리지 않으므로 나머지는 멀쩡합니다.
둘. 우리 웹 서버에서는 라인만 따로 만드는 것이 기본적으로는 불가능합니다. 톰캣의 요청 대기열은 커넥터 하나에 하나뿐이고, URL별로 나뉘지 않습니다. /payments로 온 요청과 /signup으로 온 요청은 같은 대기열에 섭니다. 톰캣은 그 요청이 어느 엔드포인트로 갈지 아직 모르는 상태에서 대기열에 세우기 때문입니다.
그리고 설령 어떻게든 라인을 둘로 나눴다고 해도, 두 라인에서 나온 요청은 결국 똑같은 200개의 워커 스레드 풀로 갑니다. 결제 요청이 그 200개를 다 물고 있으면 회원가입 라인이 아무리 한산해도 처리되지 않습니다.
정리하면 이렇습니다. 라인을 나누는 것은 '누가 먼저 처리되는가'를 나눕니다. '누가 얼마나 많이 쓰는가'는 나누지 못합니다.
우리에게 필요한 것은 뒤쪽입니다. 결제가 아무리 많이 몰려와도 결제가 쓸 수 있는 스레드의 총량에 상한이 있어야 합니다. 순서와 무관하게, 대기열이 얼마나 길든 무관하게 말입니다.
4장. 장치 셋, 벌크헤드: 자원 자체를 칸으로 나눈다
총량에 상한을 거는 장치가 벌크헤드(bulkhead)입니다. 이름은 배에서 왔습니다.
"This pattern is named after the sectioned partitions (bulkheads) of a ship's hull. If the hull of a ship is compromised, only the damaged section fills with water, which prevents the ship from sinking."
이 패턴의 이름은 선체를 나누는 격벽에서 왔다. 선체가 손상되면 손상된 구획만 물이 차기 때문에 배가 가라앉지 않는다.
- Microsoft Azure Architecture Center, Bulkhead pattern
배의 격벽이 하는 일을 정확히 보면 이렇습니다. 격벽은 물이 새는 것을 막지 못합니다. 구멍이 뚫리면 그 구획에는 물이 찹니다. 격벽이 하는 일은 그 물이 찰 수 있는 부피에 상한을 두는 것입니다. 선체 전체가 하나의 공간이면 구멍 하나에 배 전체가 잠기고, 열 칸으로 나눠져 있으면 한 칸 분량만 잠깁니다.
소프트웨어에서도 똑같습니다. 결제사가 느려지는 것을 막을 방법은 없습니다. 우리가 할 수 있는 것은 그 지연이 잡아먹을 수 있는 스레드의 개수에 상한을 두는 것입니다.
그래서 앞 장의 라인 나누기와 무엇이 다른지가 여기서 갈립니다. 라인은 순서를 정합니다. 벌크헤드는 몇 개까지 동시에 들어갈 수 있는가를 정합니다. 결제 요청이 100개가 밀려와서 라인 맨 앞을 전부 차지하고 있어도, 결제 칸이 10이면 그중 열 개만 들어가고 나머지 아흔 개는 그 순간 거절당합니다. 그리고 회원가입이 쓸 190개는 그대로 남습니다.
그럼 이 상한을 어디에 걸까요. 같은 문서가 방법을 제시합니다.
"A consumer can also partition resources to ensure that resources used to call one service don't affect the resources used to call another service. For example, a consumer that calls multiple services might be assigned a connection pool for each service. If a service begins to fail, it only affects the connection pool assigned for that service. The consumer can continue to use other services."
소비자 쪽에서도 자원을 나눠서, 한 서비스를 호출하는 데 쓰는 자원이 다른 서비스를 호출하는 데 쓰는 자원에 영향을 주지 않게 할 수 있다. 예를 들어 여러 서비스를 호출하는 소비자에게 서비스마다 커넥션 풀을 하나씩 배정할 수 있다. 어떤 서비스가 실패하기 시작하면 그 서비스에 배정된 커넥션 풀만 영향을 받는다. 소비자는 다른 서비스를 계속 쓸 수 있다.
- 같은 문서, Solution
칸막이는 이미 있었습니다
그런데 여기서 이상한 점이 하나 있습니다. 1장에서 우리는 이미 이 칸막이를 가지고 있었습니다.
아파치 HttpClient의 커넥션 풀은 목적지별로 나뉩니다. 결제사로 나가는 커넥션은 5개가 상한이었고, 전체 25개 중 나머지 20개는 결제사가 아무리 느려져도 손대지 못했습니다. 문서가 권하는 그대로, 서비스마다 커넥션이 배정되어 있었던 셈입니다.
그런데도 서비스는 통째로 죽었습니다. 왜 그랬을까요?
이유는 그 칸막이가 대기로 구현되어 있었기 때문입니다.
여섯 번째 결제 요청이 왔을 때 커넥션 풀은 "칸이 찼으니 안 됩니다" 하고 돌려보내지 않았습니다. "기다리세요" 하고 대기열에 세웠습니다. 그 대기의 기본 상한이 3분이었습니다. 그리고 기다리는 그 스레드는 톰캣 워커 스레드입니다.
칸막이는 커넥션 층에 세워져 있었는데, 그 칸막이 앞에서 기다리는 행위가 스레드 층을 먹어치운 것입니다. 결제사의 지연이 커넥션 층에서 막히지 않고 그대로 위층으로 번역되어 올라왔습니다.
여기서 이 장의 첫 번째 교훈이 나옵니다. 칸막이는 거절할 때 비로소 값을 합니다. 칸이 찼을 때 기다리게 만들면, 칸막이는 지연을 막는 대신 지연을 한 층 위로 옮기는 통로가 됩니다.

그러면 무엇을 나눠야 하나
답은 이미 나와 있습니다. 실제로 바닥난 것은 톰캣 워커 스레드였으니, 나눠야 할 것도 그것입니다. 넷플릭스가 히스트릭스를 설계하며 스레드를 고른 이유를 이렇게 밝힙니다.
"Clients (libraries, network calls, etc) execute on separate threads. This isolates them from the calling thread (Tomcat thread pool) so that the caller may "walk away" from a dependency call that is taking too long."
클라이언트(라이브러리, 네트워크 호출 등)는 별도 스레드에서 실행된다. 이렇게 하면 호출하는 스레드(톰캣 스레드 풀)로부터 격리되므로, 호출자는 너무 오래 걸리는 의존성 호출로부터 '걸어 나올' 수 있다.
- Netflix Hystrix Wiki, How it Works, Isolation 절의 Threads & Thread Pools
'걸어 나온다'가 이 장의 핵심 동사입니다. 2장에서 우리는 타임아웃이 상대를 멈추지 못한다는 것을 봤습니다. 그런데 네트워크 호출을 아예 다른 스레드에게 맡겨두면, 톰캣 워커 스레드는 그 스레드가 어떻게 되든 상관없이 자기 갈 길을 갑니다.
코드로 보면 2장에서 봤던 그 모양입니다.
// 결제 전용 스레드 풀. 여기 스레드가 10개뿐이다
ExecutorService paymentPool = Executors.newFixedThreadPool(10);
// 톰캣 워커 스레드가 실행하는 코드
Future<Payment> future = paymentPool.submit(() -> paymentClient.pay(order));
Payment result = future.get(3, TimeUnit.SECONDS);
2장에서는 이 구조의 한계를 봤습니다. 3초 뒤 get()이 예외를 던져도 paymentClient.pay()를 실행하는 스레드는 계속 기다린다는 점이었죠. 그때는 그것이 결함이었습니다.
그런데 지금은 그것이 바로 우리가 원하는 동작입니다. 계속 기다리는 그 스레드는 이제 paymentPool의 것입니다. 톰캣 워커는 이미 떠났습니다. 결제사가 영영 응답하지 않아 그 10개가 전부 묶여도, 톰캣의 200개는 멀쩡합니다. 회원가입은 계속 처리됩니다.
여기서 2장에 남겨두었던 찜찜함도 풀립니다. 소켓 타임아웃이 없으면 스레드가 끝내 돌아오지 않는다고 했는데, 그럼 소켓 타임아웃이 없는 시스템은 상위에서 무엇을 해도 소용이 없는 것일까요? 그렇지는 않습니다.
"The benefits of isolation via threads in their own thread pools are: The application is fully protected from runaway client libraries. The pool for a given dependency library can fill up without impacting the rest of the application."
별도 스레드 풀로 격리해서 얻는 이점은 이렇다. 애플리케이션이 폭주하는 클라이언트 라이브러리로부터 완전히 보호된다. 특정 의존성 라이브러리의 풀은 가득 차더라도 애플리케이션의 나머지에 영향을 주지 않는다.
- Netflix Hystrix Wiki, How it Works, Benefits of Thread Pools
잃어버린 스레드를 되찾지는 못하지만, 그 손실을 한 칸 안에 가둘 수는 있습니다. 다만 이것이 소켓 타임아웃을 생략해도 된다는 뜻은 아닙니다. 같은 문서가 곧바로 덧붙입니다.
"Note: Despite the isolation a separate thread provides, your underlying client code should also have timeouts and/or respond to Thread interrupts so it can not block indefinitely and saturate the Hystrix thread pool."
주의: 별도 스레드가 제공하는 격리에도 불구하고, 하부 클라이언트 코드 역시 타임아웃을 갖거나 스레드 인터럽트에 반응해야 무한정 블록되어 히스트릭스 스레드 풀을 포화시키지 않는다.
- 같은 문서, 같은 절
두 장치는 서로를 대체하지 않고 겹쳐 쌓입니다. 소켓 타임아웃은 잡혀 있는 스레드를 구하고, 벌크헤드는 그 스레드를 잃는 동안에도 나머지 기능을 구합니다. 소켓 타임아웃이 없으면 결제 칸의 10개는 영원히 돌아오지 않고, 그때부터 결제 기능은 완전히 죽습니다. 회원가입만 살아남을 뿐입니다.
스레드를 떼어주지 않는 방법도 있습니다
스레드 풀을 따로 두는 것이 유일한 방법은 아닙니다. 자바 진영의 대표적인 격리 라이브러리인 resilience4j는 두 가지를 제공합니다.
"Resilience4j provides two implementations of a bulkhead pattern that can be used to limit the number of concurrent execution:"
resilience4j는 동시 실행 수를 제한하는 벌크헤드 패턴 구현 두 가지를 제공한다.
- resilience4j 공식 문서, Bulkhead, Introduction
첫 번째는 세마포어를 쓰는 SemaphoreBulkhead이고, 두 번째는 크기가 제한된 큐와 고정 스레드 풀을 쓰는 FixedThreadPoolBulkhead입니다. 두 번째는 방금 본 그 구조입니다. 그럼 첫 번째는 무엇일까요.
세마포어(semaphore)라는 이름이 낯설다면 입장권이 열 장 들어 있는 통 하나를 떠올리시면 됩니다. 결제사를 호출하려는 스레드는 먼저 그 통에서 입장권을 한 장 집어야 하고, 호출이 끝나면 다시 넣어둡니다. 통이 비어 있으면 그 스레드는 들어가지 못합니다.
Semaphore permits = new Semaphore(10); // 입장권 10장
if (permits.tryAcquire()) { // 한 장 집어본다
try { paymentClient.pay(order); } // 톰캣 스레드가 직접 호출한다
finally { permits.release(); } // 반드시 돌려놓는다
} else {
throw new RejectedException(); // 없으면 바로 거절
}
스레드 풀 방식과 결정적으로 다른 점이 보이실 겁니다. 여기서는 톰캣 워커 스레드가 결제사를 직접 호출합니다. 별도 스레드에게 넘기지 않습니다. 다만 그렇게 호출하는 톰캣 스레드가 동시에 열 개를 넘지 못하게 셈을 할 뿐입니다.
그래서 값이 거의 들지 않습니다. 카운터 하나면 끝이고 스레드를 새로 만들 필요도, 문맥을 전환할 필요도 없습니다. 게다가 기본 설정이 앞의 교훈과 정확히 맞습니다.
maxConcurrentCalls: 25. 벌크헤드가 허용하는 최대 병렬 실행 수입니다.maxWaitDuration: 0. 포화된 벌크헤드에 들어가려 할 때 블록될 최대 시간입니다.
대기 시간 기본값이 0입니다. 칸이 찼으면 1밀리초도 기다리지 않고 즉시 거절합니다. 1장의 커넥션 풀이 3분을 기다리게 했던 것과 정반대입니다. 이 라이브러리를 만든 사람들은 칸막이의 값이 거절에 있다는 것을 알고 있었던 셈입니다.
그런데 세마포어에는 결정적인 한계가 있습니다.
"You can use semaphores (or counters) to limit the number of concurrent calls to any given dependency, instead of using thread pool/queue sizes. This allows Hystrix to shed load without using thread pools but it does not allow for timing out and walking away."
스레드 풀이나 큐 크기 대신 세마포어(또는 카운터)를 써서 특정 의존성에 대한 동시 호출 수를 제한할 수 있다. 이 방식은 스레드 풀 없이도 부하를 차단할 수 있게 해주지만, 타임아웃을 걸고 걸어 나오는 것은 불가능하다.
- Netflix Hystrix Wiki, How it Works, Semaphores
"Note: if a dependency is isolated with a semaphore and then becomes latent, the parent threads will remain blocked until the underlying network calls timeout. Semaphore rejection will start once the limit is hit but the threads filling the semaphore can not walk away."
주의: 의존성을 세마포어로 격리한 상태에서 그 의존성이 느려지면, 부모 스레드들은 하부 네트워크 호출이 타임아웃될 때까지 계속 블록된 상태로 남는다. 한도에 도달하면 세마포어 거절이 시작되지만, 세마포어를 채우고 있는 스레드들은 걸어 나올 수 없다.
- 같은 문서, 같은 절
이게 무슨 말일까요. 앞의 입장권 비유로 돌아가 보겠습니다.
입장권 열 장이 모두 나갔습니다. 열한 번째 스레드가 통을 열어보니 비어 있고, 그래서 즉시 거절당하고 회원가입을 처리하러 돌아갑니다. 여기까지는 벌크헤드가 제 일을 한 것입니다.
문제는 입장권을 쥐고 안에 들어가 있는 열 개입니다. 그 열 개는 톰캣 워커 스레드이고, 지금 결제사의 응답을 기다리며 서 있습니다. 우리가 바깥에서 "그만 기다리고 나와"라고 말할 방법이 없습니다. 2장에서 본 그대로입니다. 그 스레드들은 결제사가 응답하거나 소켓 타임아웃이 터질 때까지 돌아오지 않습니다.
그래서 세마포어는 피해자 수를 열 명으로 묶어줄 뿐, 그 열 명을 구해내지는 못합니다. 반면 스레드 풀 방식이었다면 그 열 명은 애초에 톰캣 워커가 아니라 결제 전용 스레드였을 것이고, 톰캣 워커는 3초 만에 걸어 나왔을 것입니다.
톰캣 스레드가 직접 호출하니 당연한 결과입니다. 입장권을 쥔 그 열 개는 결제사가 응답하거나 소켓 타임아웃이 터질 때까지 꼼짝없이 묶입니다. 걸어 나올 사람이 애초에 없습니다.

정리하면 둘의 차이는 이렇습니다.
- 세마포어는 묶이는 스레드의 개수에 상한을 겁니다. 값이 거의 들지 않지만, 그 상한만큼의 톰캣 스레드는 상대와 함께 묶입니다.
- 스레드 풀은 묶이는 스레드를 아예 다른 곳에서 빌려옵니다. 톰캣 스레드는 한 개도 묶이지 않지만, 스레드를 만들고 문맥을 전환하는 값을 냅니다.
그래서 고르는 기준은 이렇게 정리할 수 있습니다. 소켓 타임아웃이 확실히 걸려 있어서 그 스레드들이 언제까지 묶일지 우리가 알고 있다면 세마포어로 충분합니다. 반대로 그 상한을 우리가 통제하지 못한다면, 값을 내고 스레드 풀로 떼어놓아야 합니다. 남의 라이브러리가 내부에서 무엇을 하는지 모를 때가 대표적입니다.
그럼 칸은 몇 개로, 각 칸은 얼마씩
여기까지 오면 실무에서 곧바로 부딪히는 질문이 남습니다. 톰캣 스레드 200개를 결제 몇 개, 재고 몇 개, 쿠폰 몇 개로 나눌 것인가.
이 질문이 이 글에서 가장 어려운 대목이고, 답은 6장으로 미루겠습니다. 미리 한 가지만 말씀드리면, 여기서 칸을 잘게 나눌수록 우리가 잃는 것이 생깁니다. 그것이 무엇인지가 6장의 내용입니다.
그런데 계속 문을 두드리고 있습니다
여담 하나를 덧붙이겠습니다. 지금까지 여러 번 인용한 히스트릭스는 이제 현역에서 물러났습니다.
"Hystrix is no longer in active development, and is currently in maintenance mode. Hystrix (at version 1.5.18) is stable enough to meet the needs of Netflix for our existing applications. Meanwhile, our focus has shifted towards more adaptive implementations that react to an application's real time performance rather than pre-configured settings (for example, through adaptive concurrency limits). For the cases where something like Hystrix makes sense, we intend to continue using Hystrix for existing applications, and to leverage open and active projects like resilience4j for new internal projects. We are beginning to recommend others do the same."
히스트릭스는 더 이상 활발히 개발되지 않으며 현재 유지보수 모드다. 히스트릭스는 (버전 1.5.18 기준) 넷플릭스의 기존 애플리케이션 요구를 충족할 만큼 안정적이다. 한편 우리의 관심은 미리 설정된 값이 아니라 애플리케이션의 실시간 성능에 반응하는, 더 적응적인 구현으로 옮겨갔다 (예를 들어 adaptive concurrency limits). 히스트릭스 같은 것이 여전히 합리적인 경우라면 기존 애플리케이션에는 계속 히스트릭스를 쓰고, 새 내부 프로젝트에는 resilience4j처럼 열려 있고 활발한 프로젝트를 활용할 생각이다. 다른 사람들에게도 그렇게 하기를 권하기 시작했다.
- Netflix/Hystrix GitHub README, Hystrix Status
두 가지를 알 수 있습니다. 신규 프로젝트라면 이제 resilience4j 쪽을 보는 편이 낫다는 것이 하나이고, 업계의 관심이 '사람이 미리 정해둔 숫자'에서 '시스템이 스스로 판단하는 숫자'로 옮겨갔다는 것이 다른 하나입니다. 뒤엣것은 글의 마지막에서 다시 꺼내겠습니다.
이제 결제 칸을 따로 세웠으니 회원가입은 안전합니다. 그런데 결제사가 계속 느린 동안 우리 시스템은 무엇을 하고 있을까요? 결제 칸의 스레드 10개는 계속 꽉 차 있고, 새 결제 요청은 계속 거절당하고, 우리는 그러면서도 계속 결제사의 문을 두드리고 있습니다. 안 열릴 것을 알면서도 말입니다.
5장. 장치 넷, 서킷 브레이커: 피해를 가두는 대신 시도를 멈춘다
왜 굳이 멈춰야 하나
벌크헤드를 세웠으니 회원가입은 안전합니다. 그런데 결제사가 계속 느린 동안 우리 시스템은 무엇을 하고 있을까요. 결제 칸의 스레드 10개가 30초씩 묶였다가 실패하고, 또 다른 10개가 들어가서 30초를 태우고 실패합니다. 안 열릴 문을 계속 두드리는 중입니다.
여기서 자연스럽게 드는 생각이 있습니다. 어차피 칸 안에 가둬놨는데 그냥 계속 두드리면 안 되나요?
세 가지 이유로 그러면 안 됩니다.
첫째, 그 칸도 자원입니다. 결제 칸 10개는 계속 30초씩 낭비되고 있습니다. 실패할 것을 이미 아는 호출에 스레드 10개가 영구히 묶여 있는 셈입니다. 시도를 멈추면 그 10개도 즉시 돌아옵니다.
둘째, 상대를 더 아프게 합니다. 결제사가 느린 이유가 과부하라면, 우리가 계속 보내는 요청이 회복을 방해합니다. 2장에서 본 재시도 증폭을 떠올려 보십시오. 실패한 요청이 다시 들어가면서 부하가 최악의 경우 세 배 가까이 부풀었습니다. 상대가 숨 돌릴 틈을 주지 않으면 그 상대는 영영 회복하지 못합니다.
셋째, 사용자를 30초씩 기다리게 합니다. 결제가 안 된다는 사실을 30초 뒤에 아는 것과 즉시 아는 것은 전혀 다른 경험입니다. 즉시 알면 다른 결제 수단을 고르거나 나중에 다시 올 수 있습니다.
그래서 앞의 세 장치와 범주가 다른 장치가 필요합니다. 타임아웃, 대기열 분리, 벌크헤드는 전부 피해를 가두는 장치였습니다. 서킷 브레이커는 시도 자체를 멈춥니다.
어떻게 생긴 물건인가
마틴 파울러가 이 패턴을 명쾌하게 정리해 두었습니다.
"The basic idea behind the circuit breaker is very simple. You wrap a protected function call in a circuit breaker object, which monitors for failures. Once the failures reach a certain threshold, the circuit breaker trips, and all further calls to the circuit breaker return with an error, without the protected call being made at all."
서킷 브레이커의 기본 발상은 아주 단순하다. 보호할 함수 호출을 서킷 브레이커 객체로 감싸면, 그 객체가 실패를 관찰한다. 실패가 일정 임계치에 도달하면 서킷 브레이커가 열리고, 그 뒤로 서킷 브레이커를 통한 모든 호출은 보호 대상 호출을 아예 하지 않은 채 에러를 반환한다.
- Martin Fowler, "CircuitBreaker", martinfowler.com/bliki/CircuitBreaker.html
이름이 전기 차단기에서 왔습니다. 집에 과전류가 흐르면 차단기가 내려가서 회로를 끊습니다. 전선이 타는 것을 막기 위해 아예 전기를 안 흐르게 하는 것이죠. 서킷 브레이커도 같습니다. 결제사로 가는 회로를 끊어버리면, 그 뒤로 오는 결제 요청은 결제사에 닿지도 못하고 우리 코드 안에서 즉시 실패합니다.
여기서 중요한 것은 즉시라는 점입니다. 회로가 열려 있는 동안 결제 요청은 30초를 기다리지 않습니다. 밀리초 안에 실패합니다. 스레드가 묶이지 않고, 결제사에 부하도 가지 않고, 사용자는 곧바로 답을 받습니다. 앞에서 든 세 가지 이유가 한 번에 해결됩니다.
언제 열리고 언제 닫히나
그렇다면 회로는 언제 열릴까요. 실패율이 높다고 무작정 열리지는 않습니다. 히스트릭스는 두 가지 조건을 동시에 요구합니다.
"The precise way that the circuit opening and closing occurs is as follows: Assuming the volume across a circuit meets a certain threshold ( HystrixCommandProperties.circuitBreakerRequestVolumeThreshold() )... And assuming that the error percentage exceeds the threshold error percentage ( HystrixCommandProperties.circuitBreakerErrorThresholdPercentage() )... Then the circuit-breaker transitions from CLOSED to OPEN . While it is open, it short-circuits all requests made against that circuit-breaker."
서킷이 열리고 닫히는 정확한 방식은 이렇다. 서킷을 지나는 요청량이 일정 임계치를 넘고 (circuitBreakerRequestVolumeThreshold), 그리고 에러 비율이 임계 에러 비율을 넘으면 (circuitBreakerErrorThresholdPercentage), 서킷 브레이커가 CLOSED에서 OPEN으로 전이한다. 열려 있는 동안에는 그 서킷 브레이커로 오는 모든 요청을 즉시 차단한다.
- Netflix Hystrix Wiki, How it Works, Circuit Breaker
에러 비율뿐 아니라 요청량 임계치가 함께 필요한 이유가 있습니다. 비율만 본다면 새벽 세 시에 결제 요청이 두 건 들어왔고 그중 한 건이 실패했을 때 실패율은 50퍼센트입니다. 그 한 건이 그냥 카드 한도 초과였을 수도 있는데 회로가 열려버립니다. 표본이 너무 작아서 생기는 오작동을 막으려고 충분히 많이 왔는가와 그중 충분히 많이 실패했는가를 둘 다 따집니다.
회로가 한번 열리면 언젠가는 다시 닫혀야 합니다. 상대가 회복했는데도 계속 막고 있으면 우리가 스스로 장애를 연장하는 셈이니까요. 그래서 세 번째 상태가 있습니다.
"Asked to call in the half-open state results in a trial call, which will either reset the breaker if successful or restart the timeout if not."
half-open 상태에서 호출이 들어오면 시험 호출이 나가고, 성공하면 브레이커가 초기화되고 실패하면 타임아웃이 다시 시작된다.
- 같은 문서
"After some amount of time (HystrixCommandProperties.circuitBreakerSleepWindowInMilliseconds()), the next single request is let through (this is the HALF-OPEN state). If the request fails, the circuit-breaker returns to the OPEN state for the duration of the sleep window. If the request succeeds, the circuit-breaker transitions to CLOSED and the logic in 1. takes over again."
일정 시간이 지나면 다음 요청 하나만 통과시킨다 (이것이 HALF-OPEN 상태다). 그 요청이 실패하면 서킷 브레이커는 sleep window 동안 다시 OPEN 상태로 돌아간다. 성공하면 CLOSED로 전이하고 원래 로직이 다시 작동한다.
- Netflix Hystrix Wiki, How it Works, Circuit Breaker
정리하면 닫힘, 열림, 반열림 세 상태를 오갑니다. 평소에는 닫혀서 통과시키고, 실패가 쌓이면 열려서 전부 차단하고, 시간이 지나면 반열림으로 하나만 흘려보내 상태를 확인합니다.
그런데 이 시험 호출 하나를 누가 감당하는지 짚어둘 필요가 있습니다. 그것은 별도로 만든 가짜 요청이 아닙니다. 그 시각에 결제를 누른 실제 사용자의 요청 하나입니다. 그 사용자는 결제사가 진짜 회복했는지를 자기 요청으로 확인해 주는 셈이고, 아직 회복하지 않았다면 30초를 기다린 뒤 실패를 받습니다.
저는 이 대목이 서킷 브레이커에서 가장 덜 이야기되는 비용인 것 같습니다. 사용자 경험이 중요한 화면이라면 반열림 시험을 사용자 요청 대신 별도의 헬스 체크 호출로 대신하는 방법도 고려할 만합니다.
무엇을 실패로 셀 것인가
여기서 이 장의 가장 중요한 설계 결정이 나옵니다. 지금까지 계속 "실패"라고 말했는데, 그 실패가 정확히 무엇인지는 우리가 정해야 합니다.
타임아웃은 실패일까요. 그렇습니다. 상대가 제때 응답하지 못한 것이니까요.
HTTP 500은 실패일까요. 그렇습니다. 상대 쪽에서 뭔가 잘못된 것이니까요.
그럼 HTTP 400은 어떨까요. 우리가 잘못된 형식으로 요청을 보내서 상대가 거절한 것입니다. 상대는 멀쩡합니다. 이것을 실패로 세면, 우리 코드의 버그 때문에 멀쩡한 결제사로 가는 회로가 닫혀버립니다.
HTTP 429는 더 미묘합니다. 상대가 "지금 너무 많이 보내고 있으니 줄여달라"고 명시적으로 알려주는 응답입니다. 저는 이것은 실패로 세는 편이 맞는 것 같습니다. 상대가 지금 우리를 감당하지 못한다는 뜻이고, 그렇다면 잠시 멈추는 것이 정확히 옳은 반응이기 때문입니다.
기준을 한 줄로 정리하면 이렇습니다. 상대의 상태에 대해 알려주는 신호만 실패로 셉니다. 우리 잘못으로 생긴 에러는 아무리 많이 나도 회로를 열면 안 됩니다.

그리고 이 기준이 중요한 이유가 하나 더 있습니다. 히스트릭스가 무엇을 집계하는지 보면 알 수 있습니다.
"Hystrix reports successes, failures, rejections, and timeouts to the circuit breaker, which maintains a rolling set of counters that calculate statistics."
히스트릭스는 성공, 실패, 거절, 타임아웃을 서킷 브레이커에 보고하고, 서킷 브레이커는 통계를 계산하는 롤링 카운터 묶음을 유지한다.
- Netflix Hystrix Wiki, How it Works, 7. Calculate Circuit Health
집계 항목에 rejections, 거절이 들어 있습니다. 그런데 이 거절은 상대가 한 것이 아닙니다. 4장에서 우리가 세운 벌크헤드가 가득 차서 우리 스스로 돌려보낸 건수입니다.
여기서 4장의 장치와 5장의 장치가 정면으로 부딪힙니다. 결제 칸을 10으로 잡아뒀는데 트래픽이 몰려서 열한 번째 요청부터 거절이 나기 시작합니다. 결제사는 완벽하게 멀쩡합니다. 그런데 그 거절들이 실패율에 쌓여서 회로를 열어버립니다. 내 칸이 좁아서 생긴 일이 상대가 죽었다는 판정으로 둔갑하는 것입니다.
그리고 회로가 열리면 결제는 아예 안 됩니다. 칸이 좁아서 일부만 거절하던 상황에서 전부 실패하는 상황으로 악화됩니다. 격리 장치를 두 개 겹쳐 쌓았는데 그 조합이 어느 쪽보다도 나쁜 결과를 낸 것입니다.
앞의 기준이 이 문제를 그대로 해결합니다. 내가 거절한 건은 상대의 실패로 세지 않습니다. 벌크헤드 포화는 내 용량 문제이지 상대 건강의 신호가 아니기 때문입니다.
그래서 무엇을 돌려주나
회로가 열려서 시도조차 하지 않는다면, 호출한 쪽에는 무엇을 줘야 할까요. 답은 폴백(fallback)입니다. 원래 하려던 일을 하지 못했을 때 대신 내놓는 응답을 말합니다.
원문의 한 문장이 이 글에서 다룬 네 장치를 전부 하나로 묶습니다.
"Hystrix tried to revert to your fallback whenever a command execution fails: when an exception is thrown by construct() or run() (6.), when the command is short-circuited because the circuit is open (4.), when the command's thread pool and queue or semaphore are at capacity (5.), or when the command has exceeded its timeout length."
히스트릭스는 커맨드 실행이 실패할 때마다 폴백으로 되돌리려 한다. construct()나 run()이 예외를 던졌을 때, 서킷이 열려 있어서 커맨드가 차단되었을 때, 커맨드의 스레드 풀과 큐 또는 세마포어가 가득 찼을 때, 또는 커맨드가 타임아웃 시간을 초과했을 때다.
- Netflix Hystrix Wiki, How it Works, 8. Get the Fallback

네 갈래가 하나의 출구로 모입니다. 타임아웃을 넘겼든, 벌크헤드가 꽉 차서 거절당했든, 회로가 열려 시도조차 못 했든, 호출한 쪽이 마주하는 질문은 똑같습니다. 앞의 네 장에서 구조가 전혀 다른 장치들을 하나씩 쌓아 올렸는데, 그것들이 실패할 때 가는 곳은 전부 같습니다.
폴백을 어떻게 써야 하는지도 같은 문서가 알려줍니다.
"Write your fallback to provide a generic response, without any network dependency, from an in-memory cache or by means of other static logic."
폴백은 네트워크 의존성 없이, 인메모리 캐시나 다른 정적인 로직으로 일반적인 응답을 내놓도록 작성하십시오.
- Netflix Hystrix Wiki, How it Works, 8. Get the Fallback
네트워크 의존성이 없어야 한다는 조건이 핵심입니다. 지금은 네트워크 호출이 실패해서 폴백으로 온 상황입니다. 그런데 폴백이 또 다른 서버를 부른다면, 그 서버도 같은 이유로 느릴 수 있고 폴백이 원래 호출보다 더 오래 걸릴 수도 있습니다. 추천 목록을 못 가져왔을 때 미리 저장해 둔 인기 상품 목록을 보여주는 것이 좋은 폴백이고, 추천 목록을 못 가져왔을 때 다른 추천 서버를 부르는 것은 위험한 폴백입니다.
다 갖췄는데도 남는 질문
여기까지 네 가지 장치를 모두 갖췄습니다. 시간을 끊고, 라인을 나누고, 칸을 세우고, 회로를 끊었습니다. 결제사가 아무리 느려져도 회원가입은 돌아가고, 결제 요청은 즉시 실패하고, 사용자는 곧바로 안내를 받습니다. 0장의 장애는 이제 일어나지 않습니다.
그런데 4장에서 미뤄둔 질문이 아직 남아 있습니다. 결제 칸을 10으로 잡을지 50으로 잡을지 우리는 아직 정하지 못했습니다. 그리고 방금 본 것처럼 그 숫자를 잘못 잡으면 멀쩡한 상대에 대해 회로가 열리기까지 합니다.
이 질문이 어려운 진짜 이유는 따로 있습니다. 칸을 세우는 순간 우리가 무언가를 잃습니다. 그것이 무엇인지가 다음 장의 내용입니다.
6장. 그런데 격리에도 값이 있습니다
결제 칸을 몇 개로 잡을 것인가.
칸을 크게 잡으면 격리가 약해지고, 작게 잡으면 멀쩡한 요청이 거절당합니다. 어느 쪽으로 가도 무언가를 잃습니다. 그리고 이 사정은 벌크헤드에만 해당하지 않습니다. 지금까지 쌓아 올린 네 장치에는 전부 값이 붙어 있습니다.
그 값을 실제로 지불해 본 회사들이 기록을 남겨두었습니다.
값 하나. 나누면 여유를 잃습니다
마이크로소프트 문서는 벌크헤드가 적합하지 않을 수 있는 경우를 두 줄로 적어두었습니다.
"This pattern might not be suitable when: Less efficient use of resources might not be acceptable in the project. The added complexity isn't necessary."
이 패턴이 적합하지 않을 수 있는 경우는 다음과 같다. 자원을 덜 효율적으로 쓰는 것이 프로젝트에서 용납되지 않을 수 있을 때. 추가되는 복잡도가 불필요할 때.
- Microsoft Azure Architecture Center, Bulkhead pattern, When to use this pattern
"자원을 덜 효율적으로 쓴다"는 표현이 이 장의 열쇠입니다. 왜 덜 효율적이 되는지는 산술로 바로 보입니다.
스레드 200개를 가진 서버가 외부 의존성 넷을 호출한다고 해봅시다. 나누지 않으면 200개 전부가 어디에나 쓰일 수 있습니다. 결제가 한가한 시간에는 그 여유를 검색이 가져다 씁니다. 반대도 마찬가지입니다.
이제 50개씩 넷으로 나눕니다. 검색에 트래픽이 몰려 검색 칸 50개가 다 찼습니다. 그런데 같은 시각 결제 칸 50개는 비어 있습니다. 검색 요청은 거절당하고, 결제 칸 50개는 그대로 놀고 있습니다. 나누기 전이라면 검색이 그 50개를 빌려 썼을 것입니다.

격리는 여유를 팔아서 안전을 사는 거래입니다. 그리고 그 거래에서 우리가 지불하는 것은 평소의 처리량입니다.
이제 앞의 질문에 답할 수 있습니다. 칸을 몇으로 잡을 것인가.
먼저 정상 상태에 얼마가 필요한지 셉니다. 1장에서 쓴 방식 그대로입니다. 결제 요청이 초당 20건 들어오고 결제사가 정상일 때 200밀리초에 응답한다면, 동시에 필요한 스레드는 20 곱하기 0.2, 즉 4개입니다. 평소에는 4개면 충분하다는 뜻입니다.
그렇다고 4로 잡으면 안 됩니다. 트래픽은 초 단위로 출렁이므로 순간적으로 6개, 8개가 필요한 때가 옵니다. 그때마다 거절이 나갑니다. 그리고 그 거절은 5장에서 본 대로 서킷 브레이커를 열어버릴 수 있습니다. 결제사는 멀쩡한데 우리가 칸을 좁게 잡아서 결제 전체가 막히는 것이죠.
그럼 넉넉하게 40으로 잡으면 될까요. 평소 필요량의 열 배이니 거절은 거의 없습니다. 대신 결제사가 죽는 날 40개가 통째로 묶입니다. 200개 중 20퍼센트가 사라진 채로 나머지를 운영해야 합니다.
그래서 칸 크기는 두 가지를 동시에 만족해야 합니다. 평소 필요량보다 넉넉해서 정상 트래픽을 거절하지 않을 것, 그리고 그 칸이 통째로 묶여도 나머지 기능이 버틸 것. 위 예시라면 10 근처가 무난합니다. 평소 필요량의 두 배 반이라 출렁임을 흡수하고, 장애가 나도 190개가 남습니다.

숫자를 정한 다음에 확인할 것이 하나 더 있습니다. 거절이 나가는 순간에 다른 칸에 남아 있던 여유가 얼마였는지를 기록해 보십시오. 그 값이 계속 크다면 너무 잘게 나눈 것입니다. 안전을 사느라 필요 이상으로 처리량을 팔고 있다는 뜻이니까요.
값 둘. 스레드를 떼어놓으면 오버헤드가 붙습니다
넷플릭스는 스레드를 분리하는 비용을 실제로 측정해서 공개했습니다. 그 값을 보기 전에 백분위수라는 단위를 짚고 가겠습니다.
백분위수(percentile)는 요청 100건을 빠른 순서대로 늘어놓았을 때 몇 번째에 있는 요청인지를 가리킵니다. 99퍼센타일은 99번째, 그러니까 거의 가장 느린 요청입니다. 평균을 쓰지 않는 이유가 있습니다. 100건 중 99건이 1밀리초이고 한 건이 1초라면 평균은 11밀리초에 불과하지만, 그 한 건을 겪은 사용자에게 이 서비스는 1초짜리입니다. 평균은 가장 느린 요청을 감춰버리므로 성능을 이야기할 때는 느린 쪽 끝을 봅니다.
넷플릭스가 측정한 조건은 이렇습니다. API 인스턴스 한 대에서 초당 60건을 처리하는 커맨드 하나를 대상으로 했고, 그 커맨드가 감싼 네트워크 호출의 실행 시간이 2밀리초에서 28밀리초로 뛴 상황이었습니다.
"At the median (and lower) there is no cost to having a separate thread. At the 90th percentile there is a cost of 3ms for having a separate thread. At the 99th percentile there is a cost of 9ms for having a separate thread. Note however that the increase in cost is far smaller than the increase in execution time of the separate thread (network request) which jumped from 2 to 28 whereas the cost jumped from 0 to 9."
중앙값과 그 이하에서는 별도 스레드를 쓰는 비용이 없다. 90퍼센타일에서는 별도 스레드를 쓰는 비용이 3밀리초다. 99퍼센타일에서는 9밀리초다. 다만 비용의 증가폭이 별도 스레드의 실행 시간(네트워크 요청) 증가폭보다 훨씬 작다는 점에 주목하라. 실행 시간은 2에서 28로 뛰었는데 비용은 0에서 9로 뛰었다.
- Netflix Hystrix Wiki, How it Works, Cost of Threads
절반의 요청에는 비용이 0입니다. 느린 쪽 끝에서만 3밀리초, 9밀리초가 붙습니다. 이 값이 부담스러운지 아닌지는 원래 그 호출이 얼마나 걸리는가에 달려 있습니다. 넷플릭스가 바로 그 기준을 제시합니다.
"For circuits that wrap very low-latency requests (such as those that primarily hit in-memory caches) the overhead can be too high and in those cases you can use another method such as tryable semaphores which, while they do not allow for timeouts, provide most of the resilience benefits without the overhead."
지연이 아주 낮은 요청(주로 인메모리 캐시를 때리는 요청 같은)을 감싸는 서킷에서는 이 오버헤드가 너무 클 수 있다. 그런 경우에는 tryable 세마포어 같은 다른 방법을 쓸 수 있다. 세마포어는 타임아웃을 허용하지 않지만, 오버헤드 없이 복원력의 이점 대부분을 제공한다.
- 같은 문서, 같은 절
수십 밀리초가 걸리는 외부 API 호출이라면 몇 밀리초는 기꺼이 낼 만합니다. 반대로 인메모리 캐시 조회처럼 원래 아주 빠른 호출이라면 같은 오버헤드가 상대적으로 훨씬 무겁게 느껴집니다. 4장에서 세마포어와 스레드 풀 중 무엇을 고를지 이야기했는데, 여기에 두 번째 기준이 붙는 셈입니다.
다만 넷플릭스 본인들의 결론도 함께 봐야 공정합니다.
"The overhead in general, however, is small enough that Netflix in practice usually prefers the isolation benefits of a separate thread over such techniques."
다만 오버헤드는 대체로 충분히 작아서, 실무에서 넷플릭스는 보통 그런 기법들보다 별도 스레드가 주는 격리의 이점을 택한다.
- 같은 문서, 같은 절
"Netflix, in designing this system, decided to accept the cost of this overhead in exchange for the benefits it provides and deemed it minor enough to not have major cost or performance impact."
넷플릭스는 이 시스템을 설계하면서 그것이 주는 이점과 맞바꾸어 이 오버헤드의 비용을 받아들이기로 했고, 큰 비용이나 성능 영향을 줄 정도는 아니라고 판단했다.
- 같은 문서, Drawbacks of Thread Pools
이 장의 논지를 한 줄로 하면 이렇습니다. 값이 있고, 대개는 낼 만하며, 낼 만하지 않은 지점이 어디인지 알아야 합니다. 넷플릭스는 그 지점을 저지연 호출로 지목했습니다.
참고로 그들이 이 판단을 내린 규모는 이렇습니다.
"The Netflix API processes 10+ billion Hystrix Command executions per day using thread isolation. Each API instance has 40+ thread-pools with 5–20 threads in each (most are set to 10)."
넷플릭스 API는 스레드 격리를 써서 하루에 100억 건 이상의 히스트릭스 커맨드를 실행한다. 각 API 인스턴스에는 스레드 풀이 40개 이상 있고, 풀마다 5개에서 20개의 스레드가 있다 (대부분 10으로 설정되어 있다).
- 같은 문서, 같은 절
인스턴스 하나에 칸이 40개, 각 칸에 10개 안팎입니다. 앞에서 우리가 계산한 결제 칸 10과 크게 다르지 않은 숫자라는 점이 눈에 띕니다.
값 셋. 칸이 늘어나면 운영도 늘어납니다
세 번째 값이 가장 무겁고, 이것을 지불하다 못해 되판 회사가 3장에 나왔던 세그먼트입니다.
세그먼트는 목적지마다 큐와 서비스를 분리해서 head-of-line blocking을 정확히 해결했습니다. 3장에서 본 대로 그 결정은 통했습니다. 그런데 회사가 자라면서 청구서가 날아오기 시작합니다.
"The number of destinations continued to grow rapidly, with the team adding three destinations per month on average, which meant more repos, more queues, and more services. With our microservice architecture, our operational overhead increased linearly with each added destination."
목적지 수는 계속 빠르게 늘어서 팀은 평균적으로 한 달에 세 개씩 목적지를 추가했고, 그것은 곧 레포가 늘고 큐가 늘고 서비스가 는다는 뜻이었다. 우리 마이크로서비스 아키텍처에서는 목적지가 하나 추가될 때마다 운영 부담이 선형으로 증가했다.
- Segment Engineering Blog, "Goodbye Microservices", Scaling Microservices and Repos
선형으로 증가한다는 말이 핵심입니다. 칸을 하나 더 세울 때마다 레포 하나, 큐 하나, 배포 하나, 모니터링 대시보드 하나, 알림 규칙 하나가 같이 생깁니다. 격리의 이점은 그렇게까지 늘어나지 않는데 운영 부담은 개수에 비례해서 늘어납니다.
여기에 작은 칸일수록 운영이 어렵다는 문제가 겹칩니다.
"The additional problem is that each service had a distinct load pattern. Some services would handle a handful of events per day while others handled thousands of events per second. For destinations that handled a small number of events, an operator would have to manually scale the service up to meet demand whenever there was an unexpected spike in load. While we did have auto-scaling implemented, each service had a distinct blend of required CPU and memory resources, which made tuning the auto-scaling configuration more art than science."
추가적인 문제는 각 서비스가 서로 다른 부하 패턴을 가졌다는 것이다. 어떤 서비스는 하루에 몇 건을 처리하고 어떤 서비스는 초당 수천 건을 처리했다. 적은 수의 이벤트를 처리하는 목적지의 경우, 예상치 못한 부하 급증이 있을 때마다 운영자가 수동으로 서비스를 확장해야 했다. 자동 확장을 구현해두긴 했지만, 서비스마다 필요한 CPU와 메모리 자원의 조합이 달라서 자동 확장 설정을 튜닝하는 일이 과학이라기보다 예술에 가까웠다.
- 같은 문서, 같은 절
하루에 몇 건 처리하는 서비스는 평소에 거의 놀고 있다가 어쩌다 몰리면 감당하지 못합니다. 칸이 작을수록 그 안의 여유도 작아서 급증을 흡수하지 못하는 것이죠. 값 하나에서 본 여유 상실이 운영 현장에서 이런 모습으로 나타납니다.
결국 세그먼트는 격리를 되팔기로 합니다.
"The first item on the list was to consolidate the now over 140 services into a single service. The overhead from managing all of these services was a huge tax on our team. We were literally losing sleep over it since it was common for the on-call engineer to get paged to deal with load spikes."
목록의 첫 번째 항목은 이제 140개가 넘는 서비스를 단일 서비스로 통합하는 것이었다. 이 모든 서비스를 관리하는 부담은 우리 팀에 엄청난 세금이었다. 온콜 엔지니어가 부하 급증을 처리하려고 호출되는 일이 흔했기 때문에 우리는 말 그대로 잠을 설치고 있었다.
- 같은 문서, Ditching Microservices and Queues
그리고 합치자 팔았던 여유가 돌아옵니다.
"With every destination living in one service, we had a good mix of CPU and memory-intense destinations, which made scaling the service to meet demand significantly easier. The large worker pool can absorb spikes in load, so we no longer get paged for destinations that process small amounts of load."
모든 목적지가 하나의 서비스 안에 살게 되자 CPU를 많이 쓰는 목적지와 메모리를 많이 쓰는 목적지가 적절히 섞였고, 덕분에 수요에 맞춰 서비스를 확장하기가 훨씬 쉬워졌다. 큰 워커 풀이 부하 급증을 흡수하기 때문에, 부하가 적은 목적지 때문에 호출당하는 일이 더는 없다.
- 같은 문서, Why a Monolith works
"큰 워커 풀이 부하 급증을 흡수한다"가 값 하나의 정확한 반대말입니다. 나눴을 때 잃었던 것이 합치자 돌아왔습니다.
물론 합치면서 잃은 것도 있고, 그들은 그것을 숨기지 않았습니다.
"Fault isolation is difficult. With everything running in a monolith, if a bug is introduced in one destination that causes the service to crash, the service will crash for all destinations."
장애 격리가 어렵다. 모든 것이 하나의 모놀리스에서 돌기 때문에, 어떤 목적지에 서비스를 죽이는 버그가 들어가면 모든 목적지에 대해 서비스가 죽는다.
- 같은 문서, Trade Offs
목적지 하나의 버그가 전체를 죽입니다. 3장에서 그들이 그토록 애써서 없앤 바로 그 문제입니다. 인메모리 캐시 적중률이 떨어지는 문제도 함께 겪었고, 그것에 대해 이렇게 적었습니다.
"In the end, we accepted this loss of efficiency given the substantial operational benefits."
결국 우리는 상당한 운영상의 이점을 감안해서 이 효율 손실을 받아들였다.
- 같은 문서, Trade Offs
격리는 상황에 따라 값이 달라지는 거래입니다
저는 이 이야기의 교훈을 이렇게 읽습니다. 세그먼트는 격리를 살 때도 옳았고, 되팔 때도 옳았습니다. 목적지가 열 개일 때는 나누는 값보다 얻는 것이 컸고, 140개가 되자 그 관계가 뒤집혔습니다. 달라진 것은 그들의 상황이었습니다.
그러니 격리는 상황이 바뀔 때마다 값을 다시 계산해야 하는 거래입니다. 한번 도입하면 끝나는 결정으로 두면 언젠가 값이 어긋납니다. 지금 우리 시스템에 세워둔 칸막이들도 몇 년 뒤에는 값이 맞지 않을 수 있습니다.
그렇다면 지금 이 순간의 값은 어떻게 계산해야 할까요? 마지막 장에서 그 순서를 정리해 보겠습니다.
7장. 그래서 어디까지 나눌 것인가
지금까지 본 장치들에는 쌓는 순서가 있습니다. 아래층부터 차례로 세워야 각 장치가 제값을 합니다. 제 생각에 가장 합리적인 순서는 이렇습니다.
첫째, 무엇이 먼저 바닥나는지부터 확인합니다. 커넥션인지, 스레드인지, 메모리인지, 큐인지를 알아야 합니다. 1장에서 커넥션은 이미 나뉘어 있었는데도 서비스가 죽었던 것을 떠올려 보십시오. 진짜 고갈되는 자원을 짚지 못한 채 엉뚱한 층을 나누면, 나눠도 안 나눈 것과 같은 결과가 나옵니다.
둘째, 제일 싼 층부터 겁니다. 소켓 타임아웃은 거의 공짜입니다. 값이 큰 벌크헤드부터 붙이기 전에 이것부터 확인하십시오. 그리고 2장에서 본 대로, 상위에서 감싼 타임아웃과 소켓에 건 타임아웃은 다른 물건입니다.
셋째, 호출 대상별로 칸을 세웁니다. 하나의 프로세스 안에서 세마포어를 쓸지 스레드 풀을 쓸지 고르는 단계입니다. 히스트릭스 위키가 기준을 한 줄로 줍니다.
"If you trust the client and you only want load shedding, you could use this approach."
클라이언트를 신뢰할 수 있고 부하 차단만 원한다면 이 방식(세마포어)을 쓸 수 있다.
- Netflix Hystrix Wiki, How it Works, Semaphores
상대를 믿을 수 있으면 값싼 세마포어로 충분하고, 언제든 걸어 나와야 한다면 값을 내고 스레드 풀을 씁니다. 칸의 크기는 6장에서 계산한 대로 평소 필요량보다 넉넉하되 통째로 묶여도 나머지가 버틸 수준으로 잡습니다.
넷째, 반복해서 실패하는 상대에게는 회로를 끊습니다. 칸에 가둬두는 것만으로는 그 칸이 계속 낭비되고 상대도 회복하지 못합니다. 이때 무엇을 실패로 셀지가 핵심이고, 5장에서 정한 기준은 상대의 상태를 알려주는 신호만 센다는 것이었습니다.
다섯째, 라인이 섞여 있다면 라인도 나눕니다. 메시지 큐로 일을 받아 처리하는 구조라면 3장의 세그먼트처럼 목적지별로 큐와 워커를 함께 나눌 수 있습니다. 다만 나눈 각 라인을 짧게 유지해야 합니다. 긴 라인은 지연을 저장하는 창고가 됩니다.
여섯째, 그래도 부족할 때만 프로세스와 서비스를 나눕니다. 여기서부터는 배포와 모니터링과 자동 확장과 온콜이 칸 개수에 비례해서 늘어납니다. 6장에서 세그먼트가 잠을 설치며 지불한 값이 이것입니다.
일곱째, 나눈 뒤에는 잃은 여유를 셉니다. 한쪽 칸이 가득 차서 거절이 나가는 그 순간에 다른 칸에 남아 있던 여유가 몇 개였는지 기록하십시오. 그 수가 계속 크다면 너무 잘게 나눈 것이고, 늘 0에 가깝다면 나눈 값을 제대로 뽑아내고 있는 것입니다.
한눈에 보는 결정 표
| 장치 | 무엇을 나누나 | 값 | 이걸 고를 때 | 주의할 점 |
|---|---|---|---|---|
| 타임아웃 | 나누지 않는다. 붙잡히는 시간을 끊는다 | 거의 공짜 | 언제나. 가장 먼저 건다 | 상위 타임아웃은 호출자만 구한다. 스레드를 구하는 것은 소켓 타임아웃이다 |
| 대기열 | 처리 순서를 나눈다 | 큐 인프라와 메모리 | 여러 종류의 일이 한 라인에 섞여 있을 때 | 나눈 라인을 길게 두면 지연 창고가 된다 |
| 세마포어 | 동시 호출 수를 나눈다 | 카운터 하나 | 상대를 신뢰할 수 있고 부하 차단만 필요할 때 | 상한만큼의 톰캣 스레드는 상대와 함께 묶인다 |
| 스레드 풀 | 실행 스레드를 나눈다 | 문맥 전환 비용 | 언제 돌아올지 모르는 상대에서 걸어 나와야 할 때 | 원래 아주 빠른 호출에는 오버헤드가 상대적으로 무겁다 |
| 서킷 브레이커 | 나누지 않는다. 시도를 멈춘다 | 상태 관리와 시험 호출 | 반복 실패가 명백하고 상대에게 회복할 틈이 필요할 때 | 내 벌크헤드의 거절을 상대의 실패로 세면 멀쩡한 상대의 회로가 열린다 |
| 프로세스, 서비스 | 전부 나눈다 | 운영 부담이 칸 개수에 비례해 증가 | 위 장치로도 격리가 부족할 때 | 여유가 칸마다 갇혀서 서로 흘러가지 못한다 |
표에서 서킷 브레이커만 "나누지 않는다"입니다. 나머지 다섯이 피해가 번지는 범위를 정한다면, 서킷 브레이커는 애초에 시도할지 말지를 정합니다. 축이 다른 장치라 같은 표에 넣되 성격은 구분해 두는 편이 낫습니다.
남는 질문 한 가지
4장에서 넷플릭스가 히스트릭스를 유지보수 모드로 돌리며 남긴 문장을 다시 꺼내 보겠습니다. 그들은 "미리 설정된 값이 아니라 애플리케이션의 실시간 성능에 반응하는, 더 적응적인 구현"으로 관심이 옮겨갔다고 적었습니다.
이 글에서 다룬 장치들은 전부 사람이 미리 숫자를 정해두는 방식입니다. 스레드를 몇 개로 할지, 동시 호출을 몇 개까지 허용할지, 실패율 몇 퍼센트에서 회로를 열지를 사람이 예측해서 적어 넣습니다. 6장에서 결제 칸을 10으로 계산할 때 우리가 쓴 것도 결국 평소 트래픽에 대한 추정이었습니다.
그런데 트래픽은 요동치고, 인프라는 수시로 늘고 줄고, 외부 의존성의 응답 시간도 계속 변합니다. 그 모든 변화 속에서 사람이 적어 넣은 고정된 숫자가 얼마나 오래 맞을 수 있을까요? 어쩌면 다음 질문은 어떤 장치를 고를지가 아니라, 시스템이 스스로 그 경계를 긋게 만드는 방법일지도 모르겠습니다.
재현 코드
이 글의 1장 계산을 직접 돌려볼 수 있는 코드를 공개해 두었습니다. JDK 11 이상이면 빌드 도구 없이 명령어 한 줄로 실행됩니다.
java IsolationLab.java
워커 스레드 수, 의존성 지연 시간, 유입 속도, 벌크헤드 크기를 파일 상단 상수로 바꿔가며 확인해 보실 수 있습니다.
참고 자료
패턴 문서
- Bulkhead pattern, Microsoft Azure Architecture Center, https://learn.microsoft.com/en-us/azure/architecture/patterns/bulkhead
- Circuit Breaker pattern, Microsoft Azure Architecture Center, https://learn.microsoft.com/en-us/azure/architecture/patterns/circuit-breaker
- Queue-Based Load Leveling pattern, Microsoft Azure Architecture Center, https://learn.microsoft.com/en-us/azure/architecture/patterns/queue-based-load-leveling
- CircuitBreaker, Martin Fowler, https://martinfowler.com/bliki/CircuitBreaker.html
운영 회고와 원칙
- Goodbye Microservices: From 100s of problem children to 1 superstar, Segment Engineering, https://segment.com/blog/goodbye-microservices/
- Addressing Cascading Failures, Google SRE Book 22장, https://sre.google/sre-book/addressing-cascading-failures/
- Handling Overload, Google SRE Book 21장, https://sre.google/sre-book/handling-overload/
구현체 문서
- Hystrix Wiki, Home, Netflix, https://github.com/Netflix/Hystrix/wiki
- Hystrix Wiki, How it Works, Netflix, https://github.com/Netflix/Hystrix/wiki/How-it-Works
- Hystrix README (유지보수 모드 선언), Netflix, https://github.com/Netflix/Hystrix
- Bulkhead, resilience4j 공식 문서, https://resilience4j.readme.io/docs/bulkhead
- CircuitBreaker, resilience4j 공식 문서, https://resilience4j.readme.io/docs/circuitbreaker
이 글에 쓴 기본값의 출처
server.tomcat.threads.max= 200,accept-count= 100,max-connections= 8192: Spring BootServerProperties.javav3.3.5, https://github.com/spring-projects/spring-boot/blob/v3.3.5/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/ServerProperties.java- HTTP 클라이언트 선호 순서(아파치가 클래스패스에 있을 때만 1순위): Spring Boot Reference, REST Clients, https://docs.spring.io/spring-boot/reference/io/rest-client.html
DEFAULT_MAX_CONNECTIONS_PER_ROUTE= 5,DEFAULT_MAX_TOTAL_CONNECTIONS= 25: Apache HttpClient 5 Constant Field Values, https://hc.apache.org/httpcomponents-client-5.4.x/current/httpclient5/apidocs/constant-values.html- 커넥션 리스 대기 기본 3분:
RequestConfig.Builder.setConnectionRequestTimeout, Apache HttpClient 5 자바독, https://hc.apache.org/httpcomponents-client-5.4.x/current/httpclient5/apidocs/org/apache/hc/client5/http/config/RequestConfig.Builder.html maximumPoolSize= 10,connectionTimeout= 30000: HikariCP README, https://github.com/brettwooldridge/HikariCP
더 읽어볼 것
- Release It!, Michael Nygard. 서킷 브레이커와 벌크헤드를 패턴으로 정리한 원전입니다. 파울러도 이 책에서 서킷 브레이커가 대중화되었다고 적었습니다.
- Timeouts, retries and backoff with jitter, Amazon Builders' Library, https://aws.amazon.com/builders-library/timeouts-retries-and-backoff-with-jitter/
- Using load shedding to avoid overload, Amazon Builders' Library, https://aws.amazon.com/builders-library/using-load-shedding-to-avoid-overload/

A Note on Graphics
이 글에 사용된 모든 다이어그램과 일러스트 이미지는 개념의 시각화를 위해 생성형 AI 도구를 활용해 제작되었습니다.
'시스템 디자인' 카테고리의 다른 글
| [시스템 설계 - 보상] Ep.7 취소했는데 쿠폰이 돌아오지 않습니다 (0) | 2026.08.06 |
|---|---|
| [시스템 설계 - 경계] Ep.6 주문 상태 값을 하나 추가했더니 매장에 돈이 더 나갔습니다 (1) | 2026.08.05 |
| [시스템 설계 - 고유 ID] Ep.4 시계가 거꾸로 갑니다: 고유 ID 만들기가 어려운 진짜 이유 (0) | 2026.07.31 |
| [시스템 설계 - DynamoDB] Ep.3 지운 물건이 되살아납니다: 다이나모 아키텍처 15년과 복잡성 보존의 법칙 (0) | 2026.07.29 |
| [시스템 설계 - 레이트 리미터] Ep.2 방패의 설계도: 트래픽 폭주 속에서 무엇을, 어디서 막을 것인가 (0) | 2026.07.28 |