타임아웃
기다림에 상한을 두는 것입니다. 상한을 넘긴 기다림은 거기서 그만둡니다. 그리고 그 기다림을 실패로 처리합니다.
상세
약속 장소에서 삼십 분을 기다리다 상대가 안 오면 자리를 뜹니다. 삼십 분이라는 숫자에 특별한 근거는 없습니다. 언제까지 기다릴지를 미리 정해 두지 않으면 언제 일어설지도 정할 수 없기 때문에 정해 둔 것입니다.
타임아웃은 어떤 기다림에 시간 상한을 걸어 두고, 그 상한을 넘기면 기다리기를 그만두고 실패로 처리하는 장치입니다. 성립하려면 세 가지가 정해져야 합니다. 무엇을 기다리는지, 얼마나 기다리는지, 넘겼을 때 무엇을 할 것인지입니다. 세 번째가 빠지면 상한은 걸었지만 아무 일도 안 일어납니다.
여기서 중요한 것은 상한을 넘겼다는 사실이 무엇을 뜻하지 않느냐입니다. 상한을 넘겼다는 것은 상대가 죽었다는 뜻이 아닙니다. 정해 둔 시간 안에 답이 도착하지 않았다는 뜻뿐입니다. 답이 늦은 것인지 상대가 사라진 것인지는 기다리는 쪽에서 구별할 수 없습니다. 타임아웃은 그 구별을 해 주는 장치가 아니라, 구별할 수 없는 채로 결정을 내리기 위해 두는 장치입니다.
배경
부탁을 보내고 답을 기다리는 쪽에는 두 상황이 똑같이 보입니다. 상대가 아직 일하는 중이라 답이 늦는 상황과, 상대가 멈췄거나 중간 길이 끊겨서 답이 영영 안 오는 상황입니다. 밖에서 이 둘을 가르는 방법이 없습니다. 그래서 답이 올 때까지 기다린다는 규칙만 두면 영영 안 끝나는 경우가 생깁니다.
남은 길은 하나입니다. 기다림에 시간 경계를 긋고, 그 경계에서 답 대신 결정을 내리는 것입니다. 분산 시스템 쪽에서 이 자리를 정면으로 짚은 글이 Eric Brewer 가 CAP(Consistency, Availability, Partition tolerance, 일관성·가용성·분할 내성) 정리를 십이 년 뒤에 다시 정리한 글입니다. 고전적인 해석의 CAP 정리는 지연을 무시하지만 실무에서는 지연과 분할이 깊이 관련돼 있다고 적습니다. 그리고 운영의 관점에서 CAP 의 본질은 타임아웃 동안 일어난다고 못 박습니다. 프로그램이 근본적인 결정을 내려야 하는 구간이라는 것입니다.
flowchart TD
A["요청을 보낸다"] --> B["응답이 아직 없다"]
B --> C{"상한을 넘겼나"}
C -->|아직| B
C -->|넘겼다| D["연산을 취소한다 · 가용성을 내준다"]
C -->|넘겼다| E["그대로 진행한다 · 불일치를 감수한다"]
이 글은 결정을 미루는 것이 결정을 없애 주지 않는다는 것도 같이 적습니다. 일관성을 맞추려고 통신을 다시 시도하는 것은 결정을 늦출 뿐이고, 어느 시점에는 프로그램이 결정을 내려야 한다고 합니다. 무한정 재시도하는 것 자체가 사실상 가용성 대신 일관성을 고른 것이라는 설명입니다. 그래서 실무적으로 분할이란 통신에 걸린 시간 경계라고 정리합니다. 그 경계에 이름을 붙여 값으로 꺼내 놓은 것이 타임아웃입니다.
갈래
무엇을 재느냐가 축입니다. 같은 한 번의 주고받음 안에도 잴 구간이 여럿이라서, 어느 구간에 상한을 걸었느냐로 이름이 갈립니다.
sequenceDiagram
participant 클라이언트
participant 서버
클라이언트->>서버: 연결 맺기
Note over 클라이언트,서버: 연결 구간
클라이언트->>서버: 요청 보내기
Note over 클라이언트,서버: 쓰기 구간
서버-->>클라이언트: 응답 조각
Note over 클라이언트,서버: 읽기 구간은 조각과 조각 사이를 잰다
Note over 클라이언트: 전체 마감은 이 전부를 하나로 잰다
연결
상대와 연결을 맺는 단계만 재는 상한입니다. 연결 단계만 제한하므로, 주어진 시간 안에 연결되면 그 뒤로는 계속 진행합니다. 연결 단계가 끝났다고 보는 시점도 정해져 있습니다. DNS(Domain Name System) 조회와 요청한 TCP(Transmission Control Protocol) · TLS(Transport Layer Security) · QUIC 핸드셰이크가 끝난 때입니다.
읽기와 쓰기
연결을 맺은 뒤 데이터가 오가는 구간에 거는 상한입니다. 이 자리는 읽기 쪽과 쓰기 쪽으로 나뉩니다. 읽기 타임아웃은 연속된 두 번의 읽기 연산 사이에만 걸리고, 응답 전체의 전송에는 안 걸립니다. 그 시간 안에 상대가 아무것도 안 보내면 연결을 닫습니다. 쓰기 타임아웃도 마찬가지로 연속된 두 번의 쓰기 연산 사이이고, 요청 전체의 전송에는 안 걸립니다. 그 시간 안에 상대가 아무것도 받지 않으면 연결을 닫습니다.
운영체제의 소켓에도 같은 자리가 있습니다. socket(7) 의 SO_RCVTIMEO 와 SO_SNDTIMEO 는
오류를 보고하기까지의 수신·송신 타임아웃을 지정하고, 인자는 struct timeval 입니다. 입출력
함수가 그 시간만큼 막혀 있었고 데이터가 하나도 오가지 않았으면 -1 을 돌려주면서 errno 를
[[EAGAIN]] 이나 EWOULDBLOCK 으로, connect(2) 라면 EINPROGRESS 로 놓습니다. 소켓을 논블로킹으로
지정했을 때와 똑같이 만드는 것입니다. 걸리는 범위도 못 박혀 있습니다. 소켓 입출력을 수행하는
accept(2) · connect(2) · read(2) · recvmsg(2) · send(2) · sendmsg(2) 같은
시스템콜에만 효과가 있고, select(2) · poll(2) · epoll_wait(2) 에는 아무 효과가 없습니다.
유휴
주고받는 것 없이 놀고 있는 시간에 거는 상한입니다. 클라이언트가 N 초 동안 유휴 상태이면 연결을 닫는 값이 가장 단순한 꼴입니다. 여기에 조건을 하나 더 붙인 것도 있습니다. 트랜잭션이 열린 채로, 클라이언트 질의를 기다리며 유휴 상태로 지정된 시간을 넘긴 세션을 종료하는 쪽입니다.
전체 마감
구간 하나가 아니라 일 전체에 긋는 선입니다. 각 전송이 걸려도 되는 최대 시간을 정하는 값이 그 꼴이고, 네트워크가 늦거나 회선이 끊긴 탓에 배치 작업이 몇 시간씩 매달려 있는 것을 막아 줍니다.
이 자리를 데드라인이라고 부르는 쪽도 있습니다. 데드라인과 타임아웃의 관계는 이렇게 갈립니다. 데드라인은 클라이언트가 서버 응답을 더는 기다리지 않겠다고 정한 시점입니다. 어떤 언어의 API(Application Programming Interface)는 데드라인 개념을 쓰고 어떤 것은 타임아웃 개념을 씁니다. 데드라인을 요구하는 API 에는 호출이 넘어가면 안 되는 시각을 주고, 타임아웃은 호출이 걸려도 되는 최대 지속 시간입니다. 그래서 호출을 시작하는 시점의 현재 시각에 타임아웃을 더하면 데드라인이 됩니다. 같은 것을 상대 시간으로 적느냐 절대 시각으로 적느냐의 차이입니다.
전파도 이 갈래에 붙어 있습니다. 서버가 응답을 만들려고 또 다른 서버를 부르는 경우, 원래 클라이언트가 정한 데드라인을 존중하고 싶어집니다. 그런데 데드라인은 정해진 시점이라 그대로 넘기면 두 서버의 시계가 안 맞을 때 문제가 될 수 있습니다. 그래서 데드라인을 이미 지난 시간만큼 뺀 타임아웃으로 바꿔서 넘기는 gRPC 같은 구현이 있습니다. 시계 오차로부터 시스템을 지키기 위해서입니다.
서버 쪽 실행과 잠금
지금까지가 부르는 쪽이 거는 상한이라면, 받는 쪽이 자기 일에 거는 상한도 있습니다. 문장 실행에 거는 상한은 지정한 시간을 넘긴 문장을 중단시킵니다. 시간은 명령이 서버에 도착한 때부터 서버가 그것을 끝낼 때까지로 잽니다. 단순 질의 메시지 하나에 SQL(Structured Query Language) 문장이 여럿 들어 있으면 문장마다 따로 적용됩니다.
잠금 대기에 거는 상한은 대상을 좁힙니다. 테이블 · 인덱스 · 행 같은 데이터베이스
객체의 잠금을 얻으려고 지정한 시간보다 오래 기다린 문장을 중단시킵니다. 이 제한은 잠금 획득
시도마다 따로 걸리고, LOCK TABLE 이나 NOWAIT 없는 SELECT FOR UPDATE 같은 명시적 잠금
요청뿐 아니라 암묵적으로 얻는 잠금에도 걸립니다. 문장 실행에 거는 상한과 달리 이 상한은 잠금을
기다리는 동안에만 일어날 수 있습니다.
대가
상한을 걸면 상한을 넘긴 것을 실패로 처리하게 됩니다. 그런데 넘겼다는 것과 실패했다는 것은 같은 말이 아닙니다. 그래서 값을 어느 쪽으로 밀든 내주는 것이 생깁니다.
짧게 잡으면 살아 있는 일이 죽습니다. 서버가 클라이언트로부터 비현실적으로 짧은 데드라인이 붙은 원격 호출을 받을 수 있습니다. 서버가 제시간에 응답할 여지 자체를 안 주는 값입니다. 이 경우 부르는 쪽에는 실패로 보이지만 받는 쪽은 아무 문제 없이 일하고 있었습니다.
포기하는 것과 상대의 일이 멈추는 것도 다릅니다. 클라이언트가 정한 데드라인이 지나면 호출을 자동으로 취소 상태로 만드는 구현이 있습니다. 다만 그 호출을 처리하려고 띄운 활동을 멈추는 것은 서버 애플리케이션의 책임입니다. 오래 도는 처리라면 그것을 시작시킨 호출이 취소됐는지 주기적으로 확인하고, 취소됐으면 처리를 멈춰야 합니다. 확인하지 않으면 부르는 쪽은 이미 손을 뗐는데 받는 쪽 자원은 계속 물려 있습니다.
sequenceDiagram
participant 클라이언트
participant 서버
클라이언트->>서버: 호출
Note over 서버: 처리를 시작한다
Note over 클라이언트: 데드라인이 지나 포기한다
Note over 서버: 취소 상태로 표시된다
Note over 서버: 확인하지 않으면 처리는 계속 돈다
받는 쪽이 직접 끊어 주는 경우에는 다른 값을 치릅니다. 문장 실행에 거는 상한은 서버가 그 문장을 실제로 중단시킵니다. 오류 로깅을 켜 두면 타임아웃된 문장이 로그에도 남습니다. 하는 일이 확실한 대신 중단되는 것도 확실합니다. 유휴 트랜잭션에 거는 상한은 더 큽니다. 문장 하나가 아니라 세션을 통째로 종료합니다.
길게 잡으면 자원이 그동안 묶입니다. 이 대가는 유휴 트랜잭션 자리에서 드러납니다. 유휴 세션이 잠금을 부당하게 오래 붙잡고 있지 않게 하려고 이 상한을 쓸 수 있고, 의미 있는 잠금을 하나도 안 쥐고 있을 때조차 열린 트랜잭션이 문제가 됩니다. 그 트랜잭션에만 보일지도 모르는 최근에 죽은 튜플을 청소하지 못하게 막기 때문입니다. 그래서 오랫동안 유휴로 남아 있는 것이 테이블 팽창에 기여할 수 있습니다.
값을 어떻게 잡느냐와 별개로, 걸었다고 믿은 자리에 실제로는 안 걸리는 경우가 있습니다. 소켓
타임아웃은 소켓 입출력을 수행하는 시스템콜에만 효과가 있어서 select(2) · poll(2) ·
epoll_wait(2) 로 기다리는 코드에는 아무 효과가 없습니다. 읽기 타임아웃은 연속된 두 읽기 사이만
재기 때문에 응답 전체가 오래 걸리는 것은 안 잡습니다. 전체 마감은 재시도와 겹치면 성격이
바뀝니다. 재시도를 켜면 최대 시간 카운터가 재시도할 때마다 초기화되는 구현이 있습니다. 전체
마감이라고 믿은 값이 전체를 안 덮게 되는 것입니다.
기본값이 상한 없음인 것도 대가로 볼 수 있습니다. SO_RCVTIMEO 와 SO_SNDTIMEO 는 0 이
기본값입니다. socket(7) 은 값이 0 이면 그 연산은 절대 타임아웃되지 않는다고 적습니다. 앞서 본
서버 쪽 상한 셋도 값 0 이 기본입니다. 그 값은 해당 타임아웃을 끕니다. 아무것도 안 하면 무한정
기다리는 쪽이 기본이라는 뜻입니다.
예시
nginx
proxy_connect_timeout 60s;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
셋 다 기본값이 60초입니다. http · server · location 문맥에서 쓸 수 있습니다.
proxy_connect_timeout 에는 상한이 하나 더 붙습니다. 문서는 이 타임아웃이 대개 75초를 넘을 수
없다는 점을 유의해야 한다고 적습니다.
PostgreSQL
statement_timeout = 0
lock_timeout = 0
idle_in_transaction_session_timeout = 0
셋 다 정수입니다. 단위를 안 적으면 밀리초로 받습니다. 값 0 이 기본입니다. 그 값은 해당 타임아웃을 끕니다. 서버가 자기 일에 거는 상한이라 값을 0 이 아닌 것으로 바꾸는 순간 서버가 문장이나 세션을 능동적으로 끊기 시작합니다.
Redis
# Close the connection after a client is idle for N seconds (0 to disable)
timeout 0
Redis 가 배포에 함께 넣는 설정 템플릿의 한 줄입니다. 클라이언트가 N 초 동안 유휴 상태이면 연결을 닫는 값이고, 0 이면 끕니다. 기본값이 0 이라 아무것도 안 하면 유휴 연결이 저절로 안 닫힙니다.
Kubernetes 프로브
timeoutSeconds: 1
프로브가 타임아웃되기까지의 초 수입니다. 기본값은 1초입니다. 최솟값도 1 입니다. 앞의 셋과 달리 기본값이 무제한이 아니라 아주 짧은 값입니다. 상한이 안 걸린 상태로 두는 선택지가 없는 자리입니다.
관련 항목
타임아웃이 남기는 오류 값
ETIMEDOUT · EAGAIN · EWOULDBLOCK · EINPROGRESS · HTTP 408(Request Timeout, 요청 시간 초과) · HTTP 504(Gateway Timeout, 게이트웨이 시간 초과)
연결 단계를 이루는 통신 계층
DNS · TCP · TLS · QUIC · 핸드셰이크
소켓 타임아웃을 알리는 운영체제 신호
소켓 · 시스템콜 · errno · 논블로킹
서버 쪽 타임아웃이 건드리는 데이터베이스 요소
트랜잭션 · 테이블 · 인덱스 · 데이터베이스 · SQL · 로그 · 테이블 팽창
값이 실려 오가는 형태
타임아웃과 짝지어 쓰는 회복 기법
재시도 · 백오프 · 서킷 브레이커 · keep-alive · 멱등성 · 커넥션풀
타임아웃을 실제로 구현·채택한 제품
nginx · curl · PostgreSQL · Redis · Kubernetes · gRPC
타임아웃이 비롯된 배경
헷갈리는 이웃
타임아웃이 속하는 상위 분류
다른 이름: timeout · time-out · 시간 초과