비동기 요청-응답의 타임아웃 감시 — Check-in과 Check-out 타이머 패턴

NOTE

네트워크 분산 시스템이나 IPC 통신에서 요청을 비동기(Asynchronous)로 전송할 때, 상대방의 무응답이나 패킷 유실로 인해 세션이 영구히 미아(Leak)가 되는 것을 방지하는 **“Check-in / Check-out 트랜잭션 타이머 패턴”**을 정리한다.


0. 왜 비동기 통신에 타이머 관리가 필수인가?

동기식(Synchronous) 소켓 통신에서는 send() 후 상대방의 recv()가 올 때까지 스레드가 멈추어 기다리므로, 소켓 타임아웃(SO_RCVTIMEO)만으로도 시간 초과를 쉽게 감지할 수 있습니다. 하지만 이 방식은 스레드가 묶여 대규모 트래픽을 처리할 수 없습니다.

반면 고성능 비동기(Asynchronous) 시스템에서는:

  1. 워커 스레드가 요청 메시지를 소켓/큐에 밀어 넣고 즉시 다른 일감으로 복귀합니다.
  2. 응답은 나중에 별도의 수신 스레드가 받아서 처리합니다.

비동기 통신의 맹점

상대방 서버가 죽었거나, 네트워크 단절로 응답이 영영 오지 않는다면?

  • 보냈던 요청 컨텍스트(메모리)를 언제 정리해야 할지 알 수 없습니다.
  • 결국 메모리 누수(Memory Leak)가 발생하고 리소스가 고갈됩니다.
  • 이를 해결하기 위해 “보낼 때 타이머 등록(Check-in), 받을 때 타이머 취소(Check-out)” 메커니즘이 반드시 필요합니다.

1. Check-in / Check-out 아키텍처 동작 원리

이 패턴은 호텔 체크인/체크아웃처럼 동작합니다:

[정상 응답 케이스: Check-in ➡️ Check-out]
1. 송신 스레드 ──(1. check_in 등록)──► [트랜잭션 타이머 테이블 (Map)]
       │                                  │
       ▼ (2. 패킷 전송)                    │ (3초 타임아웃 감시 중...)
[외부 서버]                                │
       │                                  │
       ▼ (3. 응답 패킷 도착)               │
2. 수신 스레드 ──(4. check_out 해제)──► [타이머 취소 & 메모리 회수!]
 
 
[타임아웃 만료 케이스: Check-in ➡️ Expire]
1. 송신 스레드 ──(1. check_in 등록)──► [트랜잭션 타이머 테이블 (Map)]
       │                                  │
       ▼ (2. 패킷 전송)                    │
[외부 서버 (다운/응답 없음)]               │ (3초 초과!)
                                          ▼
                                   [expire_hdlr() 만료 콜백 호출!]
                                   👉 클라이언트에게 504 Gateway Timeout 반환
                                   👉 할당된 요청 메모리 안전하게 Free

2. 3대 핵심 라이프사이클 단계

(1) 송신 시점: check_in (타이머 등록)

메시지를 소켓으로 쏘기 직전에 고유 메시지 ID(msg_id)를 키로 타이머 매니저에 등록합니다.

/* 송신 스레드 루프 내부 */
msg_t *msg = fetch_from_queue();
 
// 1. 송신 직전 타이머 등록: "3초 안에 응답 안 오면 on_timeout_expire()를 불러라!"
ret = timer_check_in(
    g_timer_mgr, 
    msg->msg_id,            /* 키 값 (Transaction ID) */
    3000,                   /* 타임아웃 (ms) */
    on_timeout_expire,      /* 만료 콜백 함수 */
    msg                     /* 콜백에 넘겨줄 컨텍스트 데이터 */
);
 
// 2. 소켓 전송
ret = socket_send(sock, msg->data, msg->len);
if (ret < 0) {
    /* ⚠️ 전송 실패 시: 등록했던 타이머를 즉시 취소하고 메모리 해제 */
    timer_check_out(g_timer_mgr, msg->msg_id);
    free_msg(msg);
}

(2) 응답 수신 시점: check_out (타이머 취소)

상대방이 제시간에 응답을 주면, 수신 스레드가 타이머를 취소시킵니다.

/* 수신 스레드 루프 내부 */
resp_t *resp = socket_recv();
 
// 1. 응답의 msg_id로 대기 중이던 타이머를 즉시 취소!
timer_check_out(g_timer_mgr, resp->msg_id);
 
// 2. 후속 비즈니스 로직 수행
process_response(resp);

(3) 타임아웃 만료 시점: expire (자원 정리 및 에러 응답)

정해진 시간 동안 check_out이 불리지 않으면, 타이머 관리 스레드가 만료 콜백을 실행합니다.

/* 시간 초과 시 시스템에 의해 자동 호출되는 콜백 */
void on_timeout_expire(void *uarg)
{
    msg_t *msg = (msg_t *)uarg;
 
    LOG_ERR("[TIMEOUT] msg_id=%u 응답 시간 초과 발생!\n", msg->msg_id);
 
    // 1. 요청자에게 타임아웃 에러(504 Gateway Timeout) 반환
    send_error_response(msg->client_fd, 504);
 
    // 2. 미아가 된 메시지 메모리 해제
    free_msg(msg);
}

3. 동시성 제어와 뮤텍스 락 (Race Condition 방어)

타이머 테이블은 여러 스레드가 동시에 접근하므로 반드시 뮤텍스 락으로 보호해야 합니다:

  • 송신 스레드: check_in (테이블 쓰기)
  • 수신 스레드: check_out (테이블 삭제)
  • 타이머 휠 스레드: expire 검사 (테이블 조회/삭제)
/* 타이머 조작 시 뮤텍스 보호 관용구 */
pthread_mutex_lock(&g_timer_mutex);
ret = timer_check_in(mgr, msg_id, timeout, expire_cb, uarg);
pthread_mutex_unlock(&g_timer_mutex);

4. 실무 구현 시 맞닥뜨리는 함정 (Pitfalls)

  1. 타임아웃과 응답 도착이 1마이크로초 차이로 경합할 때 (Race Condition):
    • 타이머가 만료되어 expire 콜백을 호출하려는 찰나에 응답 패킷이 도착할 수 있습니다.
    • 해결: 타이머 테이블에서 노드를 꺼내는 동작을 원자적(Atomic/Mutex)으로 수행하여, check_out에 성공한 스레드만 메모리 해제 권한을 갖도록 소유권을 엄격히 제어해야 합니다.
  2. 소켓 전송 실패 시 check_out 누락:
    • check_in을 먼저 해놓고 send()를 불렀는데 네트워크 에러로 전송이 실패했다면, 반드시 즉시 check_out을 불러 타이머를 취소해야 합니다. 그렇지 않으면 몇 초 뒤 존재하지 않는 요청에 대해 엉뚱한 타임아웃 처리가 발생합니다.
  3. msg_id Wraparound (중복 키 문제):
    • 32비트 정수 msg_id가 42억 번을 돌아 다시 1번으로 돌아왔을 때, 이전 요청이 아직 타이머 테이블에 남아있다면 키 충돌이 납니다. 유효 수명 주기보다 충분히 큰 ID 공간을 설계해야 합니다.

관련 문서