Daylogs/Javascript

Node: String 크기 제한에 의한 RangeError

ohgyun 2018. 10. 30. 14:44
발생일: 2018.10.22

키워드: JSON.stringify, RangeError, Invalid string length, max-old-space-size, 메모리 부족, RangeError

문제:

배치 작업 과정 중에 아래와 같은 오류가 발생하면서 프로세스가 종료됐다.

RangeError: Invalid string length
    at JSON.stringify (<anonymous>)

메모리 이슈인 것 같아 노드의 max-old-space-size 파라미터로 힙 사이즈를 필요한 만큼 지정해 실행했는데도 동일하게 발생한다.

왜 그런 걸까?


해결책:

확인해보니, V8은 구조적 문제로 객체의 크기를 1.9기가로 제한하고 있다고 한다.
사이즈가 큰 객체를 stringify 하는 과정에서 문자열의 크기가 지나치게 커진 것이 문제였다.

아래 코드를 실행하면, 위 오류를 재현할 수 있다.

    let str = 'a';

    for (let i = 0; i < 30; i++) {
        str += str;
    }


위 예제에서는 단순히 문자열을 더한 것이지만,

    JSON.stringify(bigObject);

와 같이 사이즈가 큰 객체를 변경 시 JSON Syntax를 포함한 변환된 문자열의 크기가 허용치를 넘어서면서 발생할 수도 있다.


해결하려면,
- 객체를 작은 사이즈로 나누거나
- 스트림으로 직렬화하는 방법으로 우회할 수 있다.



논의:


# JSON을 스트림으로 처리
JSON을 스트림으로 처리하는 용도로 JSONStream 모듈이 있다.

아래와 같은 식으로 사용하면 된다.

    const parser = JSONStream.stringifyObject();
    parser.pipe(fs.createWriteStream('output'));

    parser.write(['key1', 'value1']);
    parser.write(['key2', 'value2']);
    parser.end();


스트림의 종료 이벤트는 좀 헷갈리는데, 
Readable 은 end 이벤트를, Writable 은 finish 이벤트를 사용하면 된다.

아래는 읽고 쓰는 샘플 예제이다.
(유틸리티 모듈로 undescore 를, 프라미스 모듈로 Q를 썼다.)


    const fs = require('fs');
    const JSONStream = require('JSONStream');

    const dumpJson = function (obj, outputFile) {
        const deferred = Q.defer();
        const parser = JSONStream.stringifyObject();
        const output = fs.createWriteStream(outputFile);

        parser.pipe(output);

        _.each(obj, (value, key) => {
            parser.write([key, value]);
        });

        parser.end();

        output.on('finish', () => {
            deferred.resolve(output);
        });

        return deferred.promise;
    };

    const restoreJson = function (inputFile) {
        const deferred = Q.defer();
        const input = fs.createReadStream(inputFile);
        const parser = JSONStream.parse('$*');
        const obj = {};

        input.pipe(parser);

        parser.on('data', (data) => {
            obj[data.key] = data.value;
        });

        parser.on('end', () => {
            deferred.resolve(obj);
        });

        return deferred.promise;
    };

    const obj = {
        foo: '1',
        bar: '2',
        baz: [1, 2, 3]
    };

    return dumpJson(obj, 'output.json').then(() => {
        return restoreJson('output.json').then((restored) => {
            console.log(_.isEqual(obj, restored));
        });
    });



위 문제에서처럼 사이즈가 큰 객체를 메모리를 늘리지 않고 처리하거나,
event loop을 방해하지 않으면서 처리하고 싶을 때 사용하면 좋다.

다만, 기본 파싱보다 느린 것이 단점이다. (대략 10배 정도 느림)
우린 서버에서도 사용하고 있는데, 큰 사이즈의 데이터를 캐시에 넣고 뺄 때 다른 요청에 방해되지 않게 하는 용도로 사용하고 있다.

JSON은 사이즈가 커질수록 파싱하는데 시간이 크게 늘어나니, 이런 종류의 데이터를 만들지 않는 것이 가장 좋은 방법일 것 같다.



----
# 메모리가 부족한 경우
이 외에 단순히 메모리가 부족한 경우엔 아래와 같은 오류가 출력된다.

    Allocation failed — process out of memory

또는,

    FATAL ERROR: JS Allocation failed - process out of memory

또는, 

    <--- Last few GCs --->

    [54774:0x103000000]       95 ms: Mark-sweep 6.2 (15.7) -> 5.6 (15.7) MB, 4.9 / 0.0 ms  (average mu = 0.205, current mu = 0.152) allocation failure GC in old space requested
    [54774:0x103000000]      100 ms: Mark-sweep 5.6 (15.7) -> 5.6 (16.2) MB, 4.8 / 0.0 ms  (average mu = 0.110, current mu = 0.004) allocation failure GC in old space requested


    <--- JS stacktrace --->

    ==== JS stack trace =========================================

        0: ExitFrame [pc: 0x16c79d5dc01d]
    Security context: 0x1bc5a131e681 <JSObject>



이런 경우엔 노드 스크립트를 실행할 때, V8의 옵션인 max-old-space-size 파라미터로 힙 사이즈를 필요한만큼 지정해주면 된다.

    $ node --max-old-space-size=10240 task.js

단위는 MB이다. 즉, 위 코드의 10240은 10GB를 의미한다.




----
# 메모리가 부족한 경우 (Grunt)
우린 태스크 러너로 그런트를 사용하고 있는데, 이럴 땐 노드 옵션을 주고 그런트를 이어서 실행하면 된다.

    $ node --max-old-space-size=10240 /usr/bin/local/grunt task.js



----
# 힙 사이즈 확인
힙 사이즈를 확인하려면 v8 모듈을 사용하면 된다.

const v8 = require('v8');
v8.getHeapStatistics();

[
  ...,
  {
    "space_name": "old_space",
    "space_size": 3090560,
    "space_used_size": 2493792,
    "space_available_size": 0,
    "physical_space_size": 3090560
  },
  ...
]



참고:

V8의 헤더 파일

쉬운 예:


반응형