Node.js + MySQL 메모리 누수를 찾아서

Node.js + MySQL 환경에서 메모리 누수가 발생한 원인을 찾고, 개선한 과정을 소개합니다
nodejsBackendMySQLJavaScriptTypescript
avatar
2025.05.25
·
16 min read

들어가기 전에

6403

저희 회사에서 있었던 일을 세미나의 형태로 발표한 내용을 글로 재구성하였습니다. 재밌게 봐주세요

1장 - Kafka to MySQL 구현하기

현재 아키텍처

6393

현재 우리 회사의 아키텍처는, 외환 시세 데이터를 받아서 Kafka에 저장한 뒤, 해당 메시지를 읽어 Mongo에 저장하는 구조다.

주어진 미션

팀장님이 내게 지령을 내렸다.

DB를 MySQL로 옮기려고 하니, Kafka to MongoDB를 참고해서 Kafka to MySQL을 구현해라!

현재 로직 살펴보기

  1. Kafka에 연결한다

  2. MongoDB에 연결한다

  3. Kafka에서 토픽을 consume해서, 메시지가 들어올 때마다 전역 변수에 저장한다

  4. 10ms마다 전역 변수의 값을 MongoDB에 Bulk Insert한 후, 전역 변수를 초기화 한다.

데이터베이스.. 「추상화」 할 수 있을 것 같은데?

The Database is a Detail

중요한 비즈니스 로직은 Kafka에서 메시지를 읽어서 DB에 영속화하는 것.

어떤 데이터베이스를 사용하는지는 '구현 세부사항'이다.

interface Connection {
    bulkInsert(dbKey: string, rows: BulkInsertItem[]): Promise<void>
    /**
     * 데이터베이스의 테이블을 식별할 수 있는 키 생성
     * @example mongo: 'm22', 'fx1010tb' -> 'm22@fx1010tb'
     * @example mysql: 'm22', 'fx1010tb' -> 'm22.fx1010tb'
     */
    getDbKey(db: string, table: string): string
}

기존 로직에서 데이터베이스가 가지는 역할 (= 메소드)을 정리한다.

역할이 정해졌다면, 역할에 맞는 데이터베이스 연결 객체를 인터페이스로 추상화할 수 있다. 그런다면 Bulk로 저장할 DB의 종류에 따라 알맞게 내부를 구현하면 된다.

이걸로 끝?

class MySQLConnection implements Connection {
    private readonly pool: Pool;

    constructor(config: mysql.PoolOptions) {
        this.pool = mysql.createPool({
            waitForConnections: true,
            connectionLimit: 10,
            queueLimit: 0,
            ...config,
        });
    }

    override async bulkInsert(dbKey: string, rows: BulkInsertItem[]): Promise<void> {
        const columns = [...new Set(rows.flatMap(row => Object.keys(row)))];

        const query = this.createInsertQuery(dbKey, columns, rows);
        const params = rows.flatMap(row => columns.map(col => row[col]));

        await this.pool.execute(query, params);
    }
    
    override getDbKey(db: string, table: string): string {
        return `${db}.${table}`;
    }
}

이렇게 MySQL 연결부 구현 끝! 나머지는 기존 소스 복붙만 하면 끝이겠지?

앞으로 다른 DB를 붙이는 기능이 추가되더라도, 기존 코드 변경 없이 간단히 구현할 수 있도록 확장성도 갖췄다구요. (개방-폐쇄 원칙)

2장 - EC2 메모리 이슈

EC2 메모리 100%

내가 만든 스크립트를 실행하니까, 서버 EC2가 메모리가 100% 되면서 서버가 죽어버렸다는 전보를 들었다.

js 스크립트 하나가 램을 2GB 넘게 먹었다는게 어떻게 가능하지?

ClinicJS로 메트릭 확인하기

http://clinicjs.org/

Clinic은 Node.js 런타임 분석도구로, 원하는 분석 요소에 따라 다른 4가지 분석 도구를 지원해준다.

  • Doctor (종합적)

  • Flame (CPU 사용량)

  • Bubbleprof (비동기 I/O)

  • Heap Profiler (힙 메모리)

npm install -g clinic  // 설치
clinic doctor -- node kafkaToMySql.js  // node 실행 명령어 앞에 clinic을 붙여준다
6397

메모리가 계속해서 누적되며 우상향하는 모습을 보인다. (우측 상단)

실행한 지 30분도 되지 않아 메모리가 1.5GB를 찍는 모습이 보인다. 중간중간 GC가 돌아서 낙폭이 있기는 하지만, 쌓이는 양이 더 큰 모습이다.

로직 다시 살펴보기

  1. Kafka에 연결한다

  2. MongoDB에 연결한다

  3. Kafka에서 토픽을 consume해서, 메시지가 들어올 때마다 전역 변수에 저장한다

  4. 10ms마다 전역 변수의 값을 MongoDB에 Bulk Insert한 후, 전역 변수를 초기화 한다.

로그를 살펴보면 1초에도 수만건이 insert 되는 스크립트다.

bulk insert 쿼리가 아무리 빨라도 10ms보단 느릴텐데, 이 bulk insert로 쳐내는 속도보다 쌓이는 속도가 더 빨라서 메모리가 쌓이는건 아닐까?

Batch Listener 활용하기

async function subscribeKafka(topics, consumer, holder, conn) {
  await consumer.subscribe({ topics });
  await consumer.run({
    autoCommit: false,
    eachMessage: async (payload) => {
      holder.appendTopicPartitionInfo(payload); // 읽은 메시지 오프셋 저장

      const message = JSON.parse(payload.message.value?.toString());
      storeMessage(holder, message, conn);
    },
  });
}

메시지가 들어올 때마다 쌓아두면서 10ms마다 주기적으로 퍼내는 대신에.. 애초에 메시지를 모아서 읽어와서 쿼리까지 한번에 처리하고, 그 동안에는 메시지를 읽지 않게 하면 어떨까?

async function subscribeKafka(topics, consumer, holder, conn) {
  await consumer.subscribe({ topics });
  await consumer.run({
    autoCommit: false,
    eachBatch: async ({ batch, resolveOffset }) => {
      const holder = new BulkDataHolder();
      for (const message of batch.messages) {
        try {
          const message = JSON.parse(payload.message.value?.toString());
          storeMessage(holder, message, conn);
        }
        catch (err) {
          // 단일 메시지로 처리할 경우와 다르게 배치로 처리할 경우
          // 한 메시지에 오류가 발생하더라도 나머지 메시지는 처리되어야 함
          // 따라서 오류 메시지 처리를 확실히 해야 메시지 유실을 방지할 수 있음
        }
      }
    },
  });
}

eachMessage 대신 eachBatch를 활용해서 일정 주기마다 여러 메시지를 한번에 읽어올 수 있다.

maxByte 용량 만큼의 메시지가 쌓이거나, maxWaitTimeInMs 만큼의 시간이 지났을 경우 배치가 실행된다.

배치가 실행될 때에는 다른 메시지를 읽어오지 않게 되어서, 한번에 메모리에 올라갈 메시지의 용량 한도를 지정해 줄 수 있었다.

결과는?

6399

확실히 사용하는 힙 메모리 양이 줄었다. 켜자마자 최소 100MB로 시작하던 전과 다르게, 켠지 3분이 지나도 30MB 단위에서 돌게 되었다.

물론 아직 우상향의 조짐은 보이는데.. 그래도 괜찮지 않을까?

3장 - 여전한 메모리 이슈, 그리고 DB의 OOM

당연히 괜찮지 않았다. 서버의 메모리가 고갈되는 걸 30분에서 6시간으로 벌었을 뿐이었다. 메모리 사용량을 줄이긴 했지만, 메모리 누수의 근본적 해결책은 아니었다.

추가로 Aurora MySQL의 메모리도 고갈시켜 버리는 문제가 있었다. 서버뿐 아니라 DB까지 메모리가 고갈된 것을 보니, 보통 문제가 아니었다.

뭘 더 해볼 수 있을까?

너무 짧은 텀의 잦은 쿼리로 인한 커넥션 풀 고갈이 문제일까 싶어, 배치를 실행하는 텀도 늘려보고.. 혹시 GC가 수집하지 못한 객체들이 있을까 하여 리팩토링을 진행해도 같았다.

뭐가 문제인걸까? 추측과 감으로 때려 맞추기는 그만해보자. 어플리케이션에서 사용되는 메모리 누수를 파악하기 위해선 힙 덤프를 뜨는 것이 최고다.

힙 덤프 확인하기

node로 js를 실행할 때, --inspect 옵션과 함께 실행할 경우, chrome://inspect에서 브라우저의 개발자 도구와 함께 디버깅할 수 있다.

Node.js — Debugging Node.js
Node.js® is a JavaScript runtime built on Chrome's V8 JavaScript engine.
https://nodejs.org/ko/learn/getting-started/debugging
Node.js — Debugging Node.js

6402

프로세스를 실행한지 5분 째에 힙 스냅샷을 촬영했는데, 이럴 수가! 메모리의 90% 가까이가 MySQL의 prepared statement에서 발생하고 있었다.

4장 - Prepared Statement

kafka to mysql에서는 카프카에서 읽어온 메시지를 DB에 insert하기 위해 동적으로 INSERT 쿼리를 만든다.

function createQuery(username: string) {
    return `INSERT INTO USERS (username) VALUES ('${username}');`;
}

// 이런 파라미터가 들어온다면?
pool.query(createQuery("'); DROP TABLE USERS;");

그렇다고 다음과 같이 문자열에 파라미터를 직접 삽입하면 SQL 인젝션을 당할 수 있다.

const param = "'); DROP TABLE USERS;";

pool.execute(`INSERT INTO USERS (username) VALUES (?);`, param);

그러니 Prepared Statement를 사용해보자.
쿼리와 파라미터가 분리되므로 가독성도 향상되고 SQL 인젝션에서도 해방될 수 있다.

...하지만 이것만이 Prepared Statement의 존재 이유가 아니다.

'준비된' Statement

Prepared Statement는 SQL 문을 미리 '준비(Prepare)'해두고, 실행할 때마다 파라미터만 바꿔서 쓰는 기능이다.

동작 방식

  1. DB에 INSERT INTO USERS (name, email) VALUES (?, ?) 이런 모양의 쿼리를 쓸 거라고 알려준다.

  2. DB는 이 쿼리 틀을 분석하고, 최적화해서 저장해둔다.

  3. 그 후 ['홍길동', 'hong@yna.co.kr']과 같이 값만 보내면 DB는 이미 준비된 틀에 값만 채워 빠르게 실행한다.

  4. 다음에 다른 사용자 정보를 넣을 때도, 값만 바꿔서 다시 실행 요청하면 틀이 재사용된다.

Prepared Statement 캐시

6392

Prepared Statements는 SQL 구문을 미리 준비해두고, 실행할 때마다 파라미터만 바꿔서 쓰는 기능이고, 보통의 백엔드 개발에서는 테이블에 CRUD 하는 쿼리가 정형화되어 있기 때문에, 이 전략이 유효하다.

따라서 DB는 커넥션마다, 사용되었던 구문들을 저장해둔다.

6400

하지만 우리의 사용사례는, Kafka에서 발행된 메시지에 들어있는 대로 마구 INSERT하는 구조다.
같은 테이블이더라도 Kafka의 매 배치마다 삽입해야 할 row의 개수가 다르다.
같은 테이블이라도 매 배치마다 삽입할 컬럼의 개수와 데이터들이 매번 다르다.
따라서 매번 새로운 형태의 쿼리가 실행된다.

위의 28건의 BULK INSERT 로그를 살펴봐도, 단 한 건의 행도 테이블과 삽입된 행의 개수가 같은 건이 없다. 따라서 구문이 거의 재사용되지 못하고, 1번 밖에 쓰이지 않은 구문들이 캐시에 주구장창 쌓이기만 하는 것이다.

DB 메모리 초과

기본적으로 mysql은 한 커넥션에 최대 16382개의 구문까지 캐싱하고, 한 커넥션 풀은 기본적으로 10개의 커넥션을 가지니, 최대 163,820개의 구문을 캐싱할 수 있다. (max_prepared_stmt_count 파라미터)

한 statement는 약 32KB 정도의 메모리를 차지하므로, 163820개의 구문을 캐싱하는 데에 5GB의 용량이 든다. (용량 참고: https://bugs.mysql.com/bug.php?id=115852)

그리고 나는, 토픽 4개에 대해 각각 프로세스를 켜두었기 때문에 총 20GB의 메모리를 차지하게 되어 DB가 OOM으로 종료된 것이었다.

서버 메모리 초과

EC2 서버의 메모리가 과부하된 것도 이걸로 설명할 수 있는데, node-mysql2 라이브러리에서도 성능을 위해 Prepared Statement를 캐싱해두기 때문이다.

6401
node-mysql2/lib/base/connection.js at 4b7d159e8715824a88f3042a0ae1d6931e7c13ae · sidorares/node-mysql2
:zap: fast mysqljs/mysql compatible mysql driver for node.js - sidorares/node-mysql2
https://github.com/sidorares/node-mysql2/blob/4b7d159e8715824a88f3042a0ae1d6931e7c13ae/lib/base/connection.js#L74
node-mysql2/lib/base/connection.js at 4b7d159e8715824a88f3042a0ae1d6931e7c13ae · sidorares/node-mysql2

해당 라이브러리는 기본적으로 커넥션 당 구문을 16000개까지 저장한다. 따라서 이 결과로 차지하게되는 메모리는 앞에서와 비슷하다.

결국 이 prepared statements로 인해 클라이언트와 DB 모두의 메모리가 박살난 것이다.

해결

node-mysql2 라이브러리의 statements는 LRU 캐시로 구성되어 있다.

따라서 사용할 수 있는 prepared statement의 양을 초과했을 때 가장 마지막에 사용된 구문이 캐시에서 제거되고, 라이브러리에서 캐시가 제거될 때 DB에도 mysql_stmt_close 구문을 실행시킨다.

Prepared Statements의 캐싱은 반복되는 쿼리의 실행을 최적화해주는 기능이다. 하지만 우리의 유스케이스에서는 쿼리 구문이 재사용 되는 일이 거의 없어 캐싱의 이득을 거의 볼 수 없었다.

따라서 캐시 최대 개수를 1개로 줄여서 모든 문제를 해결할 수 있었다.

6410

최종 형태







- 컬렉션 아티클