커넥션을 반환하지 않는 실수, 단순한 실수로 끝나지 않는다.
DB 커넥션을 사용할 때, 단순히 연결만 하고 release 혹은 close를 하지 않은 채 방치한다면 어떤 일이 벌어질까? 커넥션 풀을 사용할 경우, 해당 커넥션은 반환되지 않고 계속 점유된 상태로 남는다. 이 상태가 누적되면 결국 커넥션 풀이 고갈되고, 새로운 커넥션을 할당받지 못해 애플리케이션은 "Too many connections" 같은 에러를 만나게 된다.
처음엔 단순한 실수처럼 보일 수 있지만, 이는 시스템 전체의 병목으로 이어지는 심각한 문제다. 커넥션이 마치 ‘돌려주지 않은 대여물’처럼 쌓여가다 결국 자원이 소진되는 셈이다. 특히 커넥션은 무한정 유지되지 않고, 최대 연결 시간이라는 제약도 갖고 있기 때문에, 일정 시간이 지나면 자동 종료되기도 하지만 그 시간 동안은 여전히 자원을 점유하고 있다는 사실은 변하지 않는다.
MongoDB: 비동기 I/O 기반의 효율적인 커넥션 처리
MongoDB처럼 비동기 I/O 기반으로 동작하는 DB는 이러한 상황을 조금 다르게 처리한다. Node.js 환경에서 MongoDB 드라이버는 내부적으로 이벤트 루프와 epoll(kqueue, IOCP 등 OS 레벨의 비동기 I/O 시스템 호출)을 사용해 커넥션을 처리한다. 즉, 하나의 스레드로 수천 개의 I/O 요청을 핸들링할 수 있도록 설계되어 있다. 이는 전통적인 Thread-per-Connection 모델이 아닌, 이벤트 기반 논블로킹 방식 덕분에 가능한 일이다. 덕분에 고부하 상황에서도 안정적인 처리가 가능하며, 응답 대기 중인 커넥션들이 모두 스레드를 점유하지 않기 때문에 시스템 자원을 보다 효율적으로 사용할 수 있다.
이벤트 루프와 MongoDB의 응답 흐름
이벤트 루프는 다양한 큐(Timer, IO 콜백, Check, Close 등)를 돌며 지속적으로 이벤트 발생 여부를 확인하고 콜백을 실행한다. MongoDB의 응답도 이러한 I/O 큐를 통해 전달되며, 이벤트 루프는 이 흐름을 자연스럽게 소화한다. 단, 여기에도 한계는 있다. CPU 바운드 작업이 많아지면 이벤트 루프가 블로킹되어 I/O까지 지연될 수 있다. 그래서 고트래픽 환경에서는 Worker Thread를 도입하거나, Message Queue 시스템을 통해 처리 흐름을 분리하는 구조가 필요하다.
MySQL: Thread-per-Connection 구조의 한계
반면 MySQL은 구조 자체가 다르다. MySQL은 기본적으로 Thread-per-Connection 방식이다. 커넥션 하나당 OS 스레드가 생성되고, 이 스레드가 해당 커넥션을 관리한다. max_connections 설정값을 올리면 더 많은 동시 접속을 받을 수 있긴 하다. 하지만 이건 곧바로 스레드 수 증가로 이어지고, 단순히 수치를 늘렸다는 이유로 성능이 향상되지는 않는다.
OS 스레드는 각각 수백 KB에서 수 MB의 스택 메모리를 차지한다. 예를 들어 10,000개의 커넥션을 처리하기 위해 같은 수의 스레드가 생성되면, 단순 스택 메모리만으로도 수 GB가 소모된다. 여기에 더해 스레드 간 컨텍스트 스위칭 비용이 커지면서 CPU 캐시가 무효화되고, 처리량은 급격히 떨어진다. 결국 CPU는 사용률만 높고 실질적인 처리는 못 하는 Thrashing 상태에 빠지기 쉽다.
무작정 max_connections를 늘리는 것의 위험
그렇기 때문에 단순히 max_connections 값을 올리는 접근은 위험하다. MySQL에서 max_connections를 무작정 늘리는 것은 단기적인 대응은 될 수 있지만, 장기적으로는 시스템 리소스를 과도하게 사용하게 되어 서버 다운이나 스레드 스케줄링 지연을 초래할 수 있습니다. 왜냐하면 MySQL은 커넥션마다 스레드를 생성하는 구조이기 때문이다. 수천 개의 스레드는 메모리 뿐 아니라 CPU 캐시 무효화, 컨텍스트 스위칭 비용을 유발합니다. 해당 방식의 위험성을 인지하고 커넥션 풀, 큐잉 시스템(BullMQ, Kafka), 그리고 아키텍처 차원에서의 비동기 설계를 함께 고민해야 합니다.
효율적인 시스템 설계를 위한 전략
적정한 커넥션 수는 서버 CPU 코어 수, Node.js의 워커 수, DB의 Thread 수 등을 종합적으로 고려해서 결정해야 한다. 대부분의 경우 DB에 직접 동시접속을 늘리기보단, 애플리케이션 레벨에서 커넥션 풀을 효율적으로 운영하거나, Message Queue를 통해 Write/Read 트래픽을 적절한 수준으로 유입시켜 Worker가 순차적으로 DB 작업을 수행하게 하는 구조가 훨씬 안정적이다.
필요하다면 스레드 풀 방식의 구조도 고려해야 한다. 1:N 모델로 스레드를 관리해 컨텍스트 스위칭을 줄이고, 처리 효율을 높이는 방식이다. 이는 결국 "스레드는 적게, 이벤트 루프는 가볍게, 커넥션은 짧게 유지"라는 원칙을 지키는 방향으로 시스템을 설계해야 한다는 이야기다.
커넥션 타임아웃이 MongoDB 실시간 시스템에 미치는 영향
MongoDB를 도입하면서 내가 가장 먼저 고민했던 건 커넥션과 소켓 타임아웃 설정이었다. 특히 socketTimeoutMS를 무제한(기본값 0)으로 둘 경우 어떤 문제가 발생할 수 있는지에 대해 깊게 생각해보게 됐다.
이벤트 루프 기반 아키텍처의 장점과 위험
MongoDB는 전통적인 RDBMS와는 다르게 비동기 I/O와 이벤트 루프 기반의 모델을 사용한다. 이건 확실히 고부하 환경에서 강력한 장점이 될 수 있다. 쓰레드당 커넥션 모델이 아니라, Node.js처럼 논블로킹 방식으로 이벤트 큐에 쌓인 작업을 처리하니까, 수천 개의 커넥션도 상대적으로 적은 자원으로 감당 가능하다.
socketTimeoutMS 무제한 설정의 리스크
하지만 socketTimeoutMS가 무제한이라면 이야기가 조금 달라진다. 예를 들어, 클라이언트가 find나 aggregate 같은 쿼리를 MongoDB로 보냈는데, 네트워크 장애나 서버 쪽 병목으로 인해 응답이 지연된다면? 클라이언트는 이 응답을 영원히 기다리게 된다. 이건 단순한 성능 저하 문제가 아니라, 커넥션 풀 고갈로 이어질 수 있다. 특히, Node.js처럼 이벤트 루프 기반의 시스템에선 이 기다림 자체가 이벤트 루프를 잠식하는 문제로 확대될 수 있다.
실전에서 마주한 Change Stream 병목 사례
실제로 Change Stream을 사용할 때 이런 문제를 겪은 적이 있다. 특정 컬렉션에 대한 변경 이벤트를 감지하기 위해 watch()를 사용했는데, 어느 순간부터 응답이 멈추더니 전체 시스템이 묘하게 느려지기 시작했다. 알고 보니 socket이 무한정 열려 있고, 응답이 없으니 풀에서 커넥션이 반환되지 않으면서 전체 요청이 block되고 있었던 것이다.
socketTimeout이 무제한일 때 발생 가능한 문제 시나리오
이런 상황은 다음과 같은 경우에 특히 치명적이다:
- 대용량 이벤트가 쏟아진 직후, aggregate 연산이 병목을 유발할 때
- Replica 간 네트워크 단절이 발생해서, 클라이언트는 응답을 기다리지만 실제 서버는 failover 중일 때
- Change Stream에서 resume token 없이 리스닝만 하고 있다가 응답이 끊기는 경우
- 커넥션 풀에서 응답을 기다리는 커넥션이 너무 많아 새 커넥션이 생성되지 못할 때
- 클러스터 재구성 중에 클라이언트가 계속 primary를 찾지 못해 무한 대기하는 경우
결국 이 문제는 단순히 "응답이 없을 수도 있다"라는 수준이 아니라, 시스템 전체의 안정성과 직결되는 리스크였다.
타임아웃 기반 안정성 확보 전략
그래서 나는 MongoDB 클라이언트 설정을 명확히 튜닝하는 쪽으로 방향을 잡았다:
MongoClient.connect(uri, {
socketTimeoutMS: 10000, // 응답 없으면 10초 뒤 소켓 종료
connectTimeoutMS: 3000, // 연결 시도 제한
serverSelectionTimeoutMS: 5000, // 서버 탐색 시간 제한
maxPoolSize: 100 // 커넥션 풀 최대 크기
})
// 해당 내역은 모니터링을 통해서 명확히 파악해야한다. 감으로 때려 잡으면 안된다.
애플리케이션 레벨 타임아웃 제어도 필수
여기에 더해, 애플리케이션 레벨에서도 타임아웃을 이중으로 제어하고 있다. Promise.race로 강제 타임아웃을 주거나, AbortController를 활용해 특정 시점 이후에는 요청을 취소하는 식이다. socketTimeoutMS는 어디까지나 드라이버 단의 보장이고, 앱 레벨에서의 강제 중지도 반드시 필요하다는 걸 실전에서 체감했다.
실시간 MongoDB 시스템에서 고려해야 할 설계 원칙
결국 MongoDB를 실시간 시스템에서 안정적으로 쓰기 위해선, 단순한 커넥션 수치나 설정만 볼 게 아니라, "예상치 못한 대기"가 시스템에 어떤 영향을 줄 수 있는가에 대한 고민이 꼭 선행되어야 한다. 특히나 이벤트 스트림 기반의 구조나 고동시성 요청이 많은 환경이라면, socket 하나가 닫히지 않는 문제는 눈에 띄지 않게 전체 병목으로 이어질 수 있다.
그래서 지금 내가 지향하는 구조는 다음과 같다:
- 클라이언트: 커넥션/소켓 타임아웃 명확히 설정
- 비즈니스 로직: 타임아웃, fallback 로직 명확히 설계
- 아키텍처: MongoDB → Queue(BullMQ 등) → Worker 구조로 요청 decouple
- 변경 이벤트 처리: resume token + backoff 전략 도입
- 대량 요청 처리: write/read traffic 분산, 필요시 샤딩 고려
이런 고민을 통해 지금의 시스템은 단순히 "빠르게 동작한다"가 아니라, "예측 가능하고, 실패를 복구할 수 있는 구조"로 점점 진화하고 있다. MongoDB가 아무리 강력하더라도, 그 특성을 이해하고 주도적으로 대응하는 쪽이 결국 시스템의 품질을 좌우하게 된다.
'CS > 데이터베이스' 카테고리의 다른 글
| Connection Pool에 대한 생각 (0) | 2025.05.22 |
|---|---|
| TPS(Transaction Per Second)가 높아지기 시작했다면? (0) | 2025.05.22 |
| (1) 데이터베이스에 관해서 (0) | 2022.05.06 |
| 정규화(Normalization) (0) | 2022.01.20 |
| 트랜잭션(Transaction)의 격리수준(Isolation)에 대해서 알아봅시다 (0) | 2022.01.19 |