
3줄 요약
- Gateway와 Engine의 장기 WebSocket은 살아 있었지만 그 안의 종료된
sessionId설정 Map이 지워지지 않아 non-evictable memory가 약 625MiB까지 증가했다. - client routing 종료 시 Gateway가 Engine에
disconnect를 보내 Map과 진행 상태를 즉시 지우고, 메시지 유실에는 비활성 TTL sweep을 안전망으로 뒀다. - 진행 중 connect의 늦은 DB 응답이 종료 세션을 되살리지 않도록 generation token을 무효화하고, 활성 세션은 조회 때마다 활동 시각을 갱신했다.
장애: 메모리가 계단처럼 계속 올라갔다
Engine process의 메모리는 짧은 spike 뒤 내려오는 형태가 아니었다. 약 열흘 동안 non-evictable memory 기준선이 대략 136MiB에서 625MiB까지 계속 증가했다. 결국 V8 heap limit 부근에서 JavaScript heap out of memory가 발생해 worker가 종료됐다.
process manager가 수 초 뒤 재시작했지만 그 사이 연결과 처리 중 요청은 중단될 수 있다. 재시작으로 메모리가 내려갔다는 사실은 문제 해결이 아니라 process 수명에 묶인 누수라는 단서였다.
트래픽 증가만으로 설명하기 어려웠다. 활성 연결 수가 내려가도 메모리 기준선은 이전 수준으로 돌아오지 않았다. GC가 실행돼도 reachable object는 회수하지 못한다.
객체 소유 관계에서 원인을 찾았다
Gateway는 많은 client 세션을 Engine WebSocket 한 개로 multiplexing한다. Engine은 연결 객체에 sessionId → chatbot runtime config Map을 두고, 요청마다 해당 설정을 재사용했다.
Engine WebSocket
└─ session config Map
├─ active session A
├─ ended session B
├─ ended session C
└─ ... 계속 증가
client가 종료되면 Gateway의 SessionRouter에서는 세션을 제거했다. 하지만 Engine과의 장기 WebSocket은 계속 살아 있으므로 socket close cleanup은 실행되지 않았다. Engine은 client 종료 사실을 알 수 없었고 명시적인 clear message가 올 때만 Map entry를 지웠다.
즉 Map이 전역이라서가 아니라 부모 WebSocket 수명과 자식 session 수명을 같은 것으로 취급한 것이 원인이었다.
대화 history와 설정 캐시를 구분했다
세션 종료 때 모든 데이터를 지우면 재접속한 사용자의 대화 문맥까지 사라질 수 있다. 그래서 무엇을 회수하는지 먼저 나눴다.
- Engine WebSocket의 session config Map: 연결별 실행 최적화 캐시, 종료 시 회수
- 진행 중 connect Promise와 generation 상태: 종료 시 회수
- Valkey의 대화 history: 정책상 만료 시점까지 보존, disconnect만으로 삭제하지 않음
새 disconnect 메시지는 Engine의 메모리 캐시를 해제하지만 대화 history를 지우는 clear와는 다른 의미다. 이름이 비슷한 종료 동작을 하나로 합치지 않은 이유다.
1차 방어: 명시적인 수명주기 메시지
Gateway의 routing entry가 제거되는 모든 경로에서 session ID를 모아 Engine에 disconnect를 보냈다. Web client close뿐 아니라 외부 callback 완료와 끊긴 route 감지도 같은 release handler를 사용했다.
Engine은 메시지를 받으면 다음 상태를 함께 제거한다.
- session config Map entry
- 마지막 활동 시각
- 진행 중 connect token
- 동일 session의 연결 Promise 상태
정상 흐름에서는 종료 직후 회수되므로 TTL을 기다리지 않는다. 메모리 사용량도 client churn에 따라 무한히 누적되지 않고 active working set 근처로 돌아올 수 있다.
2차 방어: 비활성 TTL sweep
WebSocket message도 유실될 수 있다. Gateway process가 갑자기 죽거나 Engine 연결이 끊긴 순간 disconnect를 보내지 못하면 명시적 해제만으로는 다시 누수가 생긴다.
Engine은 session별 마지막 접근 시각을 저장하고 주기적으로 TTL보다 오래 비활성인 항목을 정리한다. 현재 구현의 기본값은 시간 단위로 충분히 길게 잡혀 있어 짧은 네트워크 단절이나 정상 재접속을 즉시 제거하지 않는다. sweep interval은 TTL보다 길어지지 않게 제한한다.
session config를 읽거나 새로 저장할 때마다 활동 시각을 갱신한다. Map에 존재한다는 이유만으로 활성으로 보지 않고 실제 사용을 기준으로 한다.
활성 세션을 잘못 지우지 않기
TTL은 누수를 막지만 잘못 설계하면 정상 대화를 중간에 끊는다. 활동 중인 session은 모든 lookup에서 timestamp가 touch되는지 테스트했다. TTL 경계 직전과 직후의 비교도 명시적으로 고정했다.
이전 버전에서 만들어져 access timestamp가 없는 entry는 첫 sweep에서 즉시 지우지 않고 현재 시각을 기록했다. rolling update 중 기존 형식의 active session을 한꺼번에 제거하지 않기 위한 호환 경계다.
Engine socket 자체가 닫히면 timer를 멈추고 모든 Map을 즉시 비운다. cleanup interval에는 unref를 적용해 timer 하나만 남아 process 종료를 막지 않게 했다.
가장 까다로운 경쟁: 종료 중인 connect
connect 요청은 DB에서 여러 설정을 읽는 비동기 작업이다. 다음 순서가 가능하다.
1. session A connect 시작
2. client 종료, disconnect 도착
3. Map과 진행 상태 제거
4. 이전 DB 조회가 늦게 완료
5. 결과를 Map에 다시 저장
disconnect가 정상 처리됐는데도 늦은 Promise가 종료 session을 부활시킨다. 단순 Map.delete만으로 해결되지 않는 이유다.
connect를 시작할 때 session별 generation token을 만들고, 결과를 저장하기 전에 그 token이 여전히 현재 세대인지 확인했다. disconnect는 token을 함께 삭제한다. 늦게 끝난 connect는 자신이 stale임을 알고 결과를 저장하지 않는다. 동일 session의 새 connect가 시작돼도 예전 작업이 새 상태를 덮지 못한다.
메모리 limit을 늘리지 않은 이유
heap limit을 올리면 장애 시점은 늦어질 수 있다. 하지만 종료 session 수에 비례해 Map이 계속 커지는 구조에서는 결국 더 큰 limit도 채운다. Pod memory를 늘리는 것은 분석과 긴급 완화에 쓸 수 있지만 객체 수명주기 누수의 근본 해결은 아니다.
Node.js의 process.memoryUsage()에서 heapUsed와 heapTotal은 V8 memory를, RSS는 JavaScript와 native object·code를 포함한 process 상주 메모리를 뜻한다. container metric 하나만 보지 않고 heap과 RSS의 장기 추세, active session 수와 Map size를 함께 봐야 한다.
검증
Gateway에서는 client 하나에 여러 session이 등록된 경우 모두 release되는지, 같은 ID가 중복돼도 disconnect가 한 번만 전달되는지 확인했다. Engine에서는 명시적 release, TTL 만료, active touch, socket close와 connect 경쟁을 각각 고정했다.
기존 connect/message/intent/clear와 외부 채널 경로의 회귀 테스트도 함께 실행했다. 새 disconnect type 추가가 기존 응답 routing이나 history 보존 의미를 바꾸지 않는지 확인하기 위해서다.
지금 다시 한다면
Map을 만들 때부터 owner와 eviction contract를 코드 옆에 적는다. “누가 add하는가, 어떤 event가 delete하는가, delete event가 유실되면 누가 회수하는가” 세 질문에 답이 없으면 장기 connection 객체에 상태를 붙이지 않는다.
관측에도 active routes, engine cached sessions, expired sessions, explicit releases의 gauge와 counter를 둔다. cached sessions - active routes 차이가 계속 커지면 OOM 전에 알 수 있다.
핵심은 Map을 쓰지 않는 것이 아니다. 장기 객체가 소유한 자식 상태에는 부모 close와 별개인 종료 신호, 유실을 견디는 TTL과 비동기 경쟁을 막는 generation이 필요하다는 점이다.