객체에 프로퍼티 설정 시 성능 이슈

발생일: 2018.09.19

키워드: json, object, Map

문제:
배치 작업에서 약 천만 행의 데이터를 로드에 객체의 프로퍼티로 할당하는 작업을 진행하고 있었다.

대략 다음과 같이 DB에서 데이터를 읽어와 메모리에 할당하는 간단한 작업이다.

readFromDatabase().then((result) => {
    const map = {};
    const list = [];
    result.forEach((item) => {
        map[item.id] = item.value; // (A)
        list.push(item); // (B)
    });
});

헌데, 특정 시점(문제가 발생했던 데이터의 경우 약 700만 라인 즈음)부터 (A) 부분의 작업이 1초 이상 걸린다.
하지만 배열에 추가하는 (B) 코드는 아무 문제가 없었다.

단순히 맵에 할당하는 건데... 왜 이렇게 느려지는 걸까?


해결책:

건너자리 J에게 이 문제에 대해 얘기했더니, 네이티브 Map 객체를 쓰면 어떠냐고 한다.
V8에선 객체의 형태가 달라질 때 최적화 작업을 하는데, 이 비용이 꽤 크다고 알고 있다고 한다.

문제가 됐던 코드는 아래와 같이 변경했다.

readFromDatabase().then((result) => {
    const map = new Map();
    const list = [];
    result.forEach((item) => {
        map.set(item.id, item.value);
        list.push(item);
    });
});


만세! 잘 된다! ㅎㅎㅎ


논의:

위 문제를 재현하고 싶다면 아래 코드를 실행하면 된다. (테스트 환경의 node는 10.10 버전)

const map = {};
let i = 0;

for (; i < 10000000; i++) {
    const key = 'key_' + i; // (A)

    map[key] = i;
    if (i > 8388600) {
        console.log(key, process.memoryUsage().heapTotal);
    }

    if (i === 8388610) {
        break;
    }
}

결과는 다음과 같다.


내 맥에선 8,388,605번째부터 느리게 할당됐고, 실제로 이 시점부터 프로퍼티를 한 번 설정할 때마다 메모리가 크게 (무려 64MB 씩) 늘어났다.
참고로, (A) 부분의 key 값을 숫자인 i로 할당하면 전혀 느리지 않고, 메모리가 크게 증가하는 현상도 없다.

정확한 원인이 뭘까 찾아봤는데, 아래 포스팅에서 언급한 내용과 관련이 있지 않을까 싶다.



살짝 정리해보면 다음과 같다. (편의 상 영어는 발음되는 대로 표기했다)

자바스크립트 객체는 메모리 내에서 아래 그림과 같이 표현된다.



배열이나 키가 숫자인 객체는 엘리먼트(Elements)로, 키가 문자열인 객체는 네임드 프로퍼티(Named Properties)로 저장된다.
엘리먼트는 엘리먼트 스토어(elements store)에, 네임드 프로퍼티는 프로퍼티 스토어(properties store)에 저장된다.

V8의 모든 자바스크립트 객체는 객체의 형태에 대한 정보가 있는 히든 클래스(HiddenClass)를 갖고 있다.
히든 클래스에는, 프로퍼티의 이름과 인덱스에 대한 정보, 프로퍼티의 개수, 객체 프로토타입의 참조 등이 있다.

자바스크립트와 같은 프토토타입 기반의 언어는 객체의 타입을 미리 알 수 없기 때문에, V8은 객체가 업데이트 될 때마다 히든 클래스의 정보를 업데이트한다.
히든 클래스는 객체의 형태에 대한 식별자 역할을 하고, 인라인 캐시나 컴파일러 최적화에 아주 중요한 요소로 사용된다.

V8은 객체에 새 프로퍼티가 생성될 때마다 히든 클래스를 생성한다.
프로퍼티의 구조와 추가된 순서가 같다면, 객체의 히든 클래스도 동일하다. V8에서도 동일한 구조일 땐 객체간 히든 클래스를 공유한다.
동일한 구조이더라도 순서가 다를 땐, 별도의 히든 클래스가 새로 생성된다.

참고로,  { a: 1, b: 2 } 같이 네임트 프로퍼티를 가진 객체는, 외부에서 보기엔 딕셔너리 같지만 V8에선 인라인 캐시나 최적화를 위해 딕셔너리처럼 사용하지 않는다.

네임드 프로퍼티에는 3가지 타입이 있다.
In-object, Fast, Slow/dictionary

- In-object 타입은 V8에서 사용할 수 있는 가장 빠른 프로퍼티로, 미리 지정된 개수만큼 객체에 '직접' 할당된다.
    허용된 개수보다 더 많은 프로퍼티가 저장되면, 별도의 프로퍼티 스토어에 저장된다.
    프로퍼티 스토어는 한 단계 간접 참조가 있지만, 개별적으로 확장될 수 있다.

- Fast 타입은 프로퍼티 스토어에 있고, 모든 메타 정보가 히든 클래스의 디스크립터 배열(discriptor array)에 저장된다.
    일반적으로 선형 프로퍼티 스토어(linear properties store)에 저장된 프로퍼티를 빠르다고 정의한다.
    Fast 프로퍼티는 프로퍼티 저장소의 인덱스를 사용해 간단히 액세스할 수 있다. 프로퍼티 이름으로 프로퍼티 저장소의 실제 위치로 이동하려면 히든 클래스의 디스크립터 배열을 참조해야 한다.

- Slow 타입은 히든 클래스 외부의 자체(self-contained) 프로퍼티 사전을 갖는다. 메타 정보는 더이상 히든 클래스에 저장되지 않는다.
    만약 객체에 많은 프로퍼티가 추가되거나 삭제되면, 디스크립터 배열과 히든 클래스를 유지하기 위해 많은 시간과 메모리가 필요하다. V8이 slow 프로퍼티를 지원하는 이유이다.
    Slow 프로퍼티는 개별적으로 메타 정보를 저장하는 사전을 갖지 때문에, 객체를 추가하거나 삭제할 때 히든 클래스가 업데이트 되지 않으며 인라인 캐시에서도 일반적으로 빠른 프로퍼티보다 느리다.
    즉, slow 프로퍼티는 객체의 추가/삭제는 빠르지만 다른 프로퍼티에 비해 접근이 느리다.

----



위 문제랑 좀 다르긴 한데, 비슷하게 객체 삭제가 특정 시점부터 느려진다는 글도 있다.
V8 개발자의 답변으로는 flat array 형태로 되어 있던 메타 정보를 사전 형태로 바꿀지 여부를 확인하는 과정에서 발생하는 known issue란다.
위 문제랑은 조금 다른 문제이긴 하다.

----

글쎄... 정확한 원인이 뭔진 잘 모르겠다.
어쨌든, 느려지는 이유는 메모리의 급격한 상승 때문인 것 같다.
첫 번째 블로그 글을 읽고, Fast 프로퍼티가 Slow 프로퍼티로 전환되는 과정에서 그런 걸까하고 생각했는데, 느려도 너무 느려서..ㅠㅠ

여튼, 이런 케이스엔 Map()을 사용하자.


카테고리

분류 전체보기 (693)
About me. (6)
Daylogs (658)
비공개 (0)
영어공부 (0)
My works - 추억 (29)