한 줄 정의
연동 서비스의 장애는 우리 서비스로 그대로 번지므로, 대기를 끊고 요청을 제한하고 필요하면 아예 보내지 않는 장치를 미리 넣어 두어야 합니다.
쉽게 말하면
창구 직원이 뒤쪽 심사부서에 전화를 걸어 확인을 받아야 업무가 끝나는 은행을 떠올리면 이 장의 기법들이 하나로 꿰어집니다.
심사부서가 마비되면 직원들은 수화기를 든 채 하염없이 기다립니다. 창구가 열 개뿐인데 열 명이 모두 통화 대기 중이면, 심사와 아무 상관없는 단순 입금 손님까지 줄에서 못 움직입니다. 창구는 멀쩡한데 은행 전체가 멈추는 것입니다. 그래서 “3분 기다려도 안 받으면 끊는다”는 규칙이 필요합니다. 이것이 타임아웃 입니다.
끊었으면 다시 걸어 볼 수 있습니다. 다만 이미 서류를 접수시킨 뒤에 끊은 것이라면, 다시 거는 순간 접수가 두 번 됩니다. 재시도가 안전한지는 저쪽이 이미 일을 시작했는지에 달려 있습니다.
전화가 연결되더라도 심사부서가 동시에 세 건밖에 처리하지 못한다면 열 건을 한꺼번에 밀어 넣는 건 서로에게 손해입니다. 회선을 세 개로 묶어 두고 나머지는 바로 돌려보내는 편이 낫습니다(동시 요청 제한). 아예 심사부서가 마비된 게 확실하다면 전화를 걸어 보지도 않고 즉시 “지금은 안 됩니다”라고 안내하는 게 맞습니다(서킷 브레이커).
여기에 함정이 하나 더 있습니다. 통화하는 동안 직원이 금고 열쇠를 쥐고 있다면, 통화가 길어질수록 열쇠를 기다리는 다른 직원까지 묶입니다. DB 트랜잭션 안에서 외부 연동을 호출하면 정확히 이 일이 벌어집니다.
왜 중요한가?
마이크로서비스가 늘면서 내부든 외부든 서비스 간 연동은 계속 증가하는 추세이고, 그만큼 연동 품질이 곧 우리 서비스의 품질이 됐습니다.
A은행은 오픈에 맞춰 대규모 트래픽을 감당할 준비를 마쳤는데도 오픈과 동시에 회원 가입이 마비됐습니다. 은행 시스템 자체는 부하를 견뎠고, 문제는 가입 과정에서 정보 인증을 위해 호출하던 외부 서비스가 몰려든 트래픽을 감당하지 못한 것이었습니다. 우리 서버를 아무리 잘 만들어도 연동 지점 하나가 전체를 멈춰 세울 수 있다는 뜻입니다.
연동을 없앨 수는 없습니다. 필수인 경우가 대부분이기 때문입니다. 대신 영향을 줄이는 것 이 이 장의 목표입니다.
핵심 내용
타임아웃: 가장 먼저 넣어야 할 방어선
타임아웃이 없으면 연동 서비스보다 우리 서비스가 먼저 죽는다
톰캣 스레드 풀이 200이고 B 서비스의 응답 시간이 60초를 넘기기 시작한 상황을 따라가 보면 구조가 명확해집니다.
| 시점 | 타임아웃 없음 | 읽기 타임아웃 5초 |
|---|---|---|
| 0초 | 100개 요청이 B 응답 대기 | 100개 요청이 B 응답 대기 |
| 10초 | 200개 모두 대기, 스레드 풀 소진 | 앞선 100개는 5초에 에러 응답, 스레드 반환됨 |
| 20초 | 신규 100개는 처리 시작조차 못 함 | 신규 100개를 곧바로 처리 |
핵심은 연동과 무관한 기능까지 함께 멈춘다 는 점입니다. 세 번째 100개 요청이 B 서비스를 전혀 쓰지 않는 기능이더라도 스레드가 없어 처리되지 않습니다. 여기에 사용자가 새로고침으로 재요청을 보내면서 부하는 배가 됩니다.
타임아웃을 걸면 사용자는 에러 화면을 보게 되지만, 반응 없는 무한 대기보다 낫고 무엇보다 스레드 풀이 포화되기 전에 자원이 회수되므로 다른 기능이 살아남습니다.
연결 타임아웃과 읽기 타임아웃
통신 과정은 연결 → 요청 → 응답 → 종료의 4단계이고, 타임아웃도 두 지점에 각각 걸립니다.
| 구분 | 제한하는 구간 | 권장 초깃값 |
|---|---|---|
| 연결 타임아웃(connection timeout) | 네트워크 연결이 맺어질 때까지 | 3초 ~ 5초 |
| 읽기 타임아웃(read timeout) | 요청을 보낸 뒤 응답을 받을 때까지 | 5초 ~ 30초 |
읽기 타임아웃이 다소 길게 느껴져도 처음부터 1~3초로 조이면 안 됩니다. 연동 서비스가 정상 처리했는데도 타임아웃 에러가 나는 상황이 생기기 때문입니다. 커머스 서버가 PG 서버에 읽기 타임아웃 5초를 걸었는데 카드사 승인이 10초 걸린 경우, 고객은 카드로는 결제됐지만 상품은 구매하지 못한 상태에 빠집니다. 결제처럼 민감한 기능은 읽기 타임아웃을 넉넉히 두고, 추이를 보면서 조정하는 편이 안전합니다.
소켓 타임아웃은 전체 응답 시간이 아닙니다
Apache HttpClient가 설정하는 것은 소켓 타임아웃 이고, 이는 네트워크 패킷 단위 기준이라 전체 응답 시간에 대한 제한이 아닙니다. 소켓 타임아웃을 5초로 잡아도 패킷이 계속 조금씩 수신되면 전체 응답은 5초를 넘길 수 있습니다. OkHttp는 요청 시작부터 응답까지 전체 시간을 제한하는 호출 타임아웃(call timeout) 을 별도로 제공하므로, 라이브러리가 실제로 무엇을 제한하는지 확인하고 설정해야 합니다.
재시도: 되는 조건인지부터 확인한다
간헐적인 연결 실패나 일시적인 응답 지연은 재시도로 성공으로 바뀔 수 있습니다. 문제는 항상 재시도해도 되는 것은 아니라는 점 입니다.
재시도해도 되는 조건
| 조건 | 안전한 이유 |
|---|---|
| 단순 조회 기능 | 다시 호출해도 데이터가 바뀌지 않음 |
| 연결 타임아웃 | 아직 연결조차 안 됐으므로 연동 서비스가 요청을 처리하고 있지 않음 |
| 멱등성(idempotent)을 가진 변경 기능 | 여러 번 적용해도 결과가 같음 |
반대로 읽기 타임아웃은 재시도에 주의해야 합니다. 이미 요청이 전달되어 연동 서비스가 처리 중일 수 있기 때문입니다. 포인트 차감 API에서 읽기 타임아웃이 났다고 재호출하면 포인트 서비스는 차감 처리를 두 번 수행합니다.
멱등성이 있는 기능은 이 걱정이 없습니다. ‘좋아요’ API처럼 이미 눌렀으면 아무 동작 없이 200을 응답하도록 만들어 두면, 몇 번을 재시도해도 데이터는 정상입니다.
실패 원인도 함께 봐야 합니다. 콘텐츠 ID를 빈 값으로 보내 400을 받은 경우처럼 검증 오류는 재시도해도 똑같이 실패 하므로 재시도 자체가 낭비입니다.
횟수와 간격
무한정 재시도할 수는 없습니다. 재시도 횟수만큼 응답 시간이 함께 늘어나기 때문입니다. 대부분 1~2번이 적당하고, 2번 재시도(총 3회 시도)가 모두 실패했다면 간헐적 오류가 아니라 근본적인 문제일 가능성이 높습니다.
간격도 중요합니다. 네트워크가 6초간 불안정한 상황에서 3초 뒤 연결 타임아웃이 났을 때 곧바로 재시도하면 같은 문제로 또 실패 하지만, 3초 간격을 두면 문제가 해소된 뒤에 시도하게 됩니다. 여러 번 재시도한다면 1초, 2초 식으로 간격을 점진적으로 늘려 연동 서버의 부하도 함께 줄일 수 있습니다.
재시도 폭풍(retry storm)
연동 서비스가 느려져서 읽기 타임아웃이 난 상황에서 재시도하면, 그 서비스는 같은 요청을 두 배로 받게 됩니다. 이전 요청을 아직 처리 중인데 같은 클라이언트가 요청을 또 보내는 것이라, 이미 느려진 서비스가 더 느려집니다. 재시도를 검토할 때는 연동 서비스의 성능 상황을 반드시 함께 고려해야 합니다.
동시 요청 제한: 상대의 한계를 넘겨 보내지 않는다
연동 서비스가 동시에 100개를 처리할 수 있는데 300개를 보내면 응답 시간이 느려지고, 그 느려짐이 우리 서비스로 되돌아옵니다. 선착순 이벤트처럼 트래픽이 순간적으로 몰릴 때 전형적으로 발생합니다.
해법은 단순합니다. 연동 서비스로 보내는 동시 요청을 100개까지만 제한하고, 초과분은 연동을 시도하지 않고 바로 503(Service Unavailable)을 응답합니다. 이렇게 하면 연동 서비스는 성능 저하 없이 안정적으로 처리하고, 연동을 쓰지 않는 나머지 기능도 정상 동작합니다.
이것이 벌크헤드(Bulkhead) 패턴 입니다. 각 구성 요소를 격리해 한 요소의 장애가 다른 요소로 번지지 않게 하는 설계 패턴으로, 여기서는 연동 기능에 자원을 따로 떼어 두는 형태로 적용됩니다.
제한값은 연동 서비스마다 다르게 잡아야 합니다. 같은 시스템 안에서도 300 TPS를 안정적으로 처리하는 서비스가 있는 반면 50 TPS도 버거운 서비스가 있고, 후자에 80개를 동시에 보내면 상대 시스템 전체가 심각하게 느려지기도 합니다. 이런 서비스는 동시 30개로 제한하는 식으로 개별 조정이 필요합니다.
서킷 브레이커: 아예 보내지 않는 선택
연동 서비스에 과부하가 걸린 상태에서는 요청을 보내 봐야 계속 에러만 나고, 읽기 타임아웃까지 대기하느라 응답 시간만 길어집니다. 그렇다면 연동을 시도하지 않고 바로 에러를 응답하는 편이 낫습니다. 누전 차단기가 과전류를 감지하면 전기를 끊는 것과 같은 원리입니다.
stateDiagram-v2 [*] --> 닫힘 닫힘 --> 열림 : 실패가 임계치 초과 열림 --> 반열림 : 차단 시간 경과 반열림 --> 닫힘 : 연동 성공 반열림 --> 열림 : 연동 실패
| 상태 | 동작 |
|---|---|
| 닫힘(Closed) | 모든 요청을 연동 서비스로 전달하면서 오류가 임계치를 넘는지 확인 |
| 열림(Open) | 연동을 수행하지 않고 즉시 에러 응답. 지정한 시간 동안 유지 |
| 반열림(Half-Open) | 일부 요청만 연동을 시도해 정상화 여부를 판단 |
임계치는 보통 시간 기준 오류 비율(예: 10초 동안 오류 비율 50% 초과)이나 개수 기준 오류 비율(예: 100개 요청 중 오류 비율 50% 초과)을 사용합니다.
효과는 양쪽 모두에 있습니다. 우리 서비스는 응답 시간 증가와 처리량 감소를 피하고, 연동 서비스는 요청이 끊긴 동안 과부하에서 벗어날 기회를 얻습니다. 이렇게 실패를 빠르게 감지해 문제가 있는 기능을 즉시 중단시키는 방식을 빠른 실패(fail fast) 라고 합니다.
외부 연동과 DB 트랜잭션
실패했다고 롤백했는데 상대는 성공한 경우
트랜잭션 범위 안에서 외부 연동에 실패하면 롤백해서 DB 변경을 취소하는 것이 가장 단순한 처리입니다. 다만 읽기 타임아웃으로 인한 실패라면 연동 서비스가 실제로는 성공했을 가능성 을 염두에 둬야 합니다. 주문 서비스가 롤백했는데 포인트 서비스는 차감을 완료한 상태가 되는 것입니다.
이때 검토할 수 있는 방법은 두 가지입니다.
| 방법 | 내용 | 전제 조건 |
|---|---|---|
| 주기적 정합성 확인 | 하루 한 번 두 시스템의 내역을 비교해 불일치 건을 보정 | 없음 (항상 가능) |
| 성공 확인 / 취소 API 호출 | 일정 시간 후 실제 성공 여부를 확인하거나 취소를 요청 | 연동 서비스가 해당 API를 제공해야 함 |
연동은 성공했는데 DB가 실패한 경우
반대 방향입니다. 포인트 차감은 성공했는데 트랜잭션 커밋에 실패해 롤백했다면, 취소 API를 호출해 외부 연동을 되돌려야 합니다. DB가 실패한 상황이므로 성공 확인 API를 호출하는 것은 의미가 없습니다.
취소 API가 없거나 취소 자체가 실패할 수도 있으므로, 데이터 일관성이 중요한 기능이라면 결국 정기적으로 데이터 일치를 확인하는 프로세스를 갖추는 것이 정답 입니다.
트랜잭션 안의 외부 연동이 DB 커넥션 풀을 마르게 한다
트랜잭션 처리보다 덜 알려졌지만 더 자주 밟는 지뢰입니다. 다음 흐름을 보면 문제가 드러납니다.
커넥션 획득 → DB 쿼리 0.1초 → 외부 연동 API 4.8초 → DB 쿼리 0.1초 → 커넥션 반환
실제로 DB를 쓰는 시간은 0.2초인데 커넥션은 5초 내내 점유됩니다. 커넥션 풀 크기가 5이고 초당 1건씩 요청이 들어온다면 4초 시점에 남은 커넥션은 0개가 되고, 5초 시점에 요청 1이 끝나며 반환되는 커넥션을 요청 6이 이어받아 간신히 균형이 맞습니다.
여기서 외부 연동 시간이 4.8초에서 6.8초로 늘어나기만 해도 요청 6은 커넥션을 얻으려고 2초를 대기합니다. DB 처리 시간은 그대로인데 연동이 느려졌다는 이유만으로 커넥션 풀이 포화되는 것입니다.
DB 연동과 무관하게 실행할 수 있는 외부 연동이라면 커넥션을 사용하기 전이나 후로 빼는 것 이 근본 해법입니다. 단, 이 경우 외부 연동이 트랜잭션 범위 밖에서 실행되므로 커밋 이후 연동이 실패하면 롤백이 불가능합니다. 반영된 데이터를 되돌리는 보상 트랜잭션 이나 후보정 방식을 함께 설계해야 합니다.
HTTP 커넥션 풀
크롬 개발자 도구로 URL 처리 시간을 뜯어보면 전체 0.1초 중 서버 연결에 0.047초, 약 47% 가 쓰입니다. DB 커넥션 풀이 연결 비용을 줄이는 것과 같은 이유로 HTTP 연결도 풀링할 가치가 있습니다.
| 설정 | 판단 기준 |
|---|---|
| 풀 크기 | 연동 서비스의 처리 능력 에 맞춘다. 무턱대고 늘리면 트래픽이 몰릴 때 연동 서비스가 급격히 느려지고 그 여파가 우리 서비스 전체로 온다 |
| 커넥션 대기 시간 | 1~5초. 너무 짧으면(0.1초) 일시적 트래픽 증가에도 에러가 나고, 너무 길면(10초) 연동 서버가 느려졌을 때 전체 응답 시간이 늘어난다 |
| 커넥션 유지 시간(keep alive) | 서버가 끊은 커넥션을 쓰면 에러가 나므로, 서버의 Keep-Alive 시간보다 짧게 잡는다 |
풀 크기를 늘리는 것이 성능 개선처럼 보이지만 실제로는 연동 서비스에 더 많은 부하를 보내겠다는 결정 이라는 점이 핵심입니다.
연동 서비스 이중화
결제 서비스에 장애가 나면 그 시간 동안 쇼핑 서비스의 매출은 0원입니다. 결제 서비스를 여러 곳과 연동해 두면 한 곳이 죽어도 다른 곳으로 결제를 계속 진행할 수 있습니다.
다만 연동 대상이 늘어난 만큼 개발과 유지 비용도 배로 늘어나므로, 판단 기준은 두 가지입니다.
- 해당 기능이 서비스의 핵심인지
- 이중화 비용이 장애로 인한 손실보다 작은지
쇼핑 서비스의 결제는 핵심이므로 설득이 되지만, 로그 유실 방지를 위해 로그 수집 연동을 이중화하자는 제안은 통과되기 어렵습니다. 이중화는 기술 판단이 아니라 비용 대비 손실의 문제 입니다.
비교 / 트레이드오프
연동 방어 기법들은 각각 다른 지점을 막습니다. 하나로 전부 커버되지 않으므로 조합해서 씁니다.
| 기법 | 막는 문제 | 치르는 비용 | 적합한 상황 |
|---|---|---|---|
| 타임아웃 | 스레드·커넥션 점유로 인한 전체 마비 | 정상 처리도 에러가 될 수 있음 | 모든 연동에 예외 없이 |
| 재시도 | 간헐적 실패 | 응답 시간 증가, 중복 처리·재시도 폭풍 위험 | 조회, 연결 타임아웃, 멱등 API |
| 동시 요청 제한 | 연동 서비스 한계 초과로 인한 동반 지연 | 초과 요청은 즉시 실패 | 트래픽이 순간적으로 몰리는 기능 |
| 서킷 브레이커 | 장애 지속 중의 무의미한 대기 | 정상화 판단이 늦으면 불필요한 차단 | 장애가 일정 시간 지속되는 연동 |
| 연동을 트랜잭션 밖으로 | DB 커넥션 풀 포화 | 롤백 불가, 보상 처리 필요 | DB 처리와 독립적인 연동 |
| 연동 서비스 이중화 | 연동처 자체의 장애 | 개발·유지 비용 배증 | 매출과 직결되는 핵심 기능 |
내 생각
- 타임아웃은 성능 튜닝이 아니라 격리 장치입니다. 목적이 “빨리 응답하기”가 아니라 “스레드와 커넥션을 회수해 무관한 기능을 살리기”라는 걸 알면, 값을 정할 때 사용자 체감 시간이 아니라 자원 회전율을 기준으로 보게 됩니다.
- 재시도 여부는 라이브러리 설정이 아니라 API 설계 시점에 결정됩니다. 상대 API가 멱등하지 않으면 어떤 재시도 정책도 안전하지 않으므로, 우리가 API를 제공하는 쪽이라면 요청 키 기반 멱등성을 기본으로 넣어 두는 게 맞습니다.
- 읽기 타임아웃은 “실패”가 아니라 “결과 미상”입니다. 이 구분을 코드에 반영하지 않으면 롤백 로직이 조용히 데이터를 어긋나게 만듭니다. 연동 실패 처리 분기는 연결 실패와 읽기 타임아웃을 반드시 나눠야 합니다.
- 트랜잭션 안의 외부 호출은 리뷰에서 가장 먼저 잡아야 할 패턴입니다. DB 쿼리는 그대로인데 상대 서비스가 2초 느려졌다는 이유만으로 커넥션 풀이 마르므로, 평소에는 멀쩡하다가 장애 시점에만 재현되는 유형입니다.
- 서킷 브레이커와 동시 요청 제한은 대체재가 아닙니다. 전자는 이미 망가진 상대에게 안 보내는 것이고 후자는 멀쩡한 상대를 망가뜨리지 않는 것이라, 목적이 달라 둘 다 필요합니다.
- 이중화 논의는 SLA가 아니라 손익으로 가져가야 통과됩니다. “장애 1시간 = 매출 얼마”를 계산해 두면 결제 이중화는 설득되고 로그 수집 이중화는 자연스럽게 후순위로 밀립니다.
관련 개념
- Ch02 느려진 서비스, 어디부터 봐야 할까 — 응답 시간·처리량과 DB 커넥션 풀의 기본 개념
- Ch03 성능을 좌우하는 DB 설계와 쿼리 — 트랜잭션 범위를 어디까지 묶을 것인가