이슈노트 #2: DB 커넥션 과부하 문제
현재 서비스하는 시스템에 아주 크리티컬한 문제가 있어서 그걸 좀 해결하게 됐다.
문제
해당 서비스에서 가장 핵심이라 할 수 있는건 크롤링이었다.
크롤링을 통해 데이터를 실시간으로 수집하고, 그걸로 데이터를 가공해 비즈니스적으로 사용하는 것이다.
데이터베이스 사용 구조는 대충 이랬다.
웹서버는 당연히 데이터베이스에 직통으로 접속하는데,
크롤러마저도 데이터베이스에 직통으로 꼽고 있어서 문제가 발생한 것이다.
심지어 크롤러의 인스턴스는 엄청나게 많고, 계속 늘어나는 중이었다.
보통 크롤러를 사용할 때는 카프카 같은 고성능 큐를 중간에 넣어서 데이터베이스에 간접적으로 접근하는 방식을 사용하지만, 기존 구조를 건드리기는 여러모로 껄끄러운 상황이었다.
아무튼 그러한 상황에서...
크롤러들 때문에 데이터베이스의 커넥션 최대 제한을 넘어서는 일이 비일비재했다.
커넥션 최대 제한수를 넘으면 어떻게 될까?
데이터베이스 접속이 막힌다.
기존에 접속중인 개체들은 괜찮지만 신규접속은 불가능하다.
이 말은, 추가적인 크롤러의 가동도 불가능하고, 서버의 재배포도 불가능해진다는 것이다.
서버도 서버지만 크롤러가 핵심인데 자꾸 크롤러가 터지니, 결국 문제해결에 적극적으로 나서게 됐다.
데이터베이스 스펙을 올리면 커넥션 제한도 늘릴 수 있긴 한데, 스펙 자체는 지금도 좀 과해서 비용이 과도하게 부과되는 상태였다. 게다가 커넥션 수 자체도 비정상이긴 했고.
분석
그런데 사실 크롤러 인스턴스가 아무리 많다 해도 당시 커넥션 한계치였던 1700개를 넘어서진 않는다.
아마 아무리 많아도 500개는 안됐을 것이다.
그래서 커넥션 관련해 크롤러가 제대로 종료를 하지 않아서 커넥션 누수가 발생하는 것 같다고 계속 생각은 하고 있었다.
그런데 코드를 또 보면 finally 절로 커넥션을 반드시 해제해주고 있었다는 것이 희대의 미스테리였다.
import { createPool, format } from "mysql2/promise";
let connectionPool = createPool({
host: "...",
port: "...",
password: "...",
});
async run(query: string) {
let connection = null;
try {
connection = await connectionPool.getConnection();
await connection.query(query);
} catch (e) {
// ...
} finally {
connection?.release(); //무조건 해제
}
}
우리는 난관에 봉착했다...
해결
코드를 좀 보다보니, 결국엔 커넥션 해제 코드를 의심하게 됐다.
이놈이 해제를 해주는게 맞긴 한가?
근데 코드를 다시 유심히 보니, 커넥션을 바로 쓰는 게 아니라 createPool로 커넥션 풀을 만들어 쓰고 있었다.
그래서 결국 풀을 원인으로 보고, 공식문서를 참조해봤는데...
세상에. 지정하지 않았던 옵션값인 connectionLimit가, 기본값이 10이라고 한다.
그러니까, 아무리 풀에 커넥션을 반환해줘도 커넥션 풀은 최대 10개까지 잡고 있을 수가 있다는 것이다.
인스턴스가 500개면 커넥션을 최대 5000개까지 물고 있을 수 있는 것이고...
아무튼 그래서 저 값을 2로 고정해주는 것으로 해결을 보았다.
이렇듯 해결책은 의외로 간단한 곳에서 나오기도 한다.