[번역] 재미와 이익을 위한 자바스크립트 최적화

Sonny·2024년 4월 10일
73

Article

목록 보기
21/22
post-thumbnail

재미와 이익을 위한 자바스크립트 최적화

원문 : https://romgrk.com/posts/optimizing-javascript

일반적으로 자바스크립트 코드가 제대로 최적화되지 않아서 실행 속도가 훨씬 느리게 느껴질 때가 많습니다. 다음은 제가 유용하다고 생각한 일반적인 최적화 기법들을 요약한 것입니다. 성능의 트레이드 오프는 가독성인 경우가 많기 때문에 성능과 가독성 중 어느 쪽을 선택할지는 독자가 판단할 문제입니다. 또한 최적화에 대해 이야기하려면 반드시 벤치마킹에 대해 이야기해야 한다는 점도 말씀드리고 싶습니다. 함수를 몇 시간 동안 세밀하게 최적화하여 100배 빠르게 실행되도록 하더라도 그 함수가 실제로는 전체 실행 시간의 일부에 불과하다면 의미가 없습니다. 최적화를 하는 경우, 가장 중요한 첫 번째 단계는 벤치마킹입니다. 이 주제는 이후에서 다루겠습니다. 또한 마이크로 벤치마크들은 결함이 있는 경우가 많으며 이 글에서 제시된 벤치마크도 결함이 있을 수 있다는 점에 유의하세요. 이러한 함정을 피하기 위해 최선을 다했지만, 벤치마킹 없이 여기에 제시된 사항을 맹목적으로 적용해서는 안 됩니다.

가능한 모든 경우에 대해 실행 가능한 예제를 포함했습니다. 기본적으로 제 컴퓨터에서 얻은 결과를 보여드리지만(archlinux의 brave 122버전) 직접 실행해 보실 수 있습니다. 이런 말 하기 싫지만, Firefox는 최적화 경쟁에서 약간 뒤쳐져 있고 현재 트래픽의 아주 작은 부분을 차지하기 때문에 Firefox에서 얻은 결과를 유용한 지표로 사용하는 것을 권장하지 않습니다.

0. 작업 피하기

최적화를 시도한다면 먼저 불필요한 작업을 피하는 것이 중요합니다. 이는 당연한 말처럼 들릴 수도 있지만, 최적화의 첫 번째 단계이기 때문에 반드시 명심해야 합니다. 여기에는 메모이제이션, lazy처리(laziness), 증분 계산(incremental computation)과 같은 개념이 포함됩니다. 이는 상황에 따라 다르게 적용됩니다. 예를 들어 리액트에서는 memo(), useMemo() 및 기타 적용 가능한 프리미티브를 적용하는 것을 의미합니다.

1. 문자열 비교 피하기

자바스크립트를 사용하면 문자열 비교의 실제 비용이 쉽게 드러나지 않습니다. C에서 문자열을 비교해야 하는 경우 strcmp(a, b) 함수를 사용하면 됩니다. 자바스크립트는 ===를 대신 사용하므로 strcmp가 보이지 않습니다. 하지만 문자열 비교는 일반적으로(항상 그런 것은 아니지만) 문자열의 각 문자를 다른 문자열의 문자와 비교해야 하며, 문자열 비교는 O(n)입니다. 피해야 할 일반적인 자바스크립트 패턴 중 하나는 열거형 문자열(strings-as-enums)입니다. 하지만 타입스크립트의 등장으로 enum은 기본적으로 정수이기 때문에 쉽게 피할 수 있습니다.

// No
enum Position {
  TOP    = 'TOP',
  BOTTOM = 'BOTTOM',
}
// Yeppers
enum Position {
  TOP,    // = 0
  BOTTOM, // = 1
}

다음은 비용 비교입니다.

// 1. string compare
const Position = {
  TOP: 'TOP',
  BOTTOM: 'BOTTOM',
}

let _ = 0
for (let i = 0; i < 1000000; i++) {
  let current = i % 2 === 0 ?
    Position.TOP : Position.BOTTOM
  if (current === Position.TOP)
    _ += 1
}
// 2. int compare
const Position = {
  TOP: 0,
  BOTTOM: 1,
}

let _ = 0
for (let i = 0; i < 1000000; i++) {
  let current = i % 2 === 0 ?
    Position.TOP : Position.BOTTOM
  if (current === Position.TOP)
    _ += 1
}

ℹ️ 벤치마크 정보
백분율 결과는 1초 이내에 완료된 작업 수를 가장 높은 점수를 받은 사례의 작업 수로 나눈 값입니다. 높을수록 좋습니다.

보시다시피 그 차이는 상당할 수 있습니다. 엔진이 스트링 풀(String Pool)을 사용하고 참조로 비교할 수 있기 때문에 이 차이는 반드시 strcmp 비용 때문만은 아니며, JS 엔진에서 정수는 일반적으로 값으로 전달되는 반면 문자열은 항상 포인터로 전달되고 메모리 액세스 비용이 많이 든다는 사실 때문이기도 합니다(섹션 5 참조). 문자열을 많이 사용하는 코드에서 이는 큰 영향을 미칠 수 있습니다.

실제 예시로, 문자열 상수를 숫자로 대체하는 것만으로 이 JSON5 자바스크립트 파서를 2배 더 빠르게 실행*할 수 있었습니다.

*안타깝게도 해당 PR이 병합되지는 않았지만, 오픈소스는 어쩔 수 없습니다.

2. 다른 형태 피하기

자바스크립트 엔진은 객체가 특정 형태를 가지고 있고 함수가 동일한 형태의 객체로 받는다고 가정하여 코드를 최적화하려고 합니다. 그렇게 하면 해당 형태의 모든 객체에 대해 형태의 키를 한 번만 저장하고 값은 별도의 평면 배열에 저장할 수 있습니다. 이를 자바스크립트로 이렇게 표현할 수 있습니다.

const objects = [
  {
    name: 'Anthony',
    age: 36,
  },
  {
    name: 'Eckhart',
    age: 42
  },
]
const shape = [
  { name: 'name', type: 'string' },
  { name: 'age',  type: 'integer' },
]

const objects = [
  ['Anthony', 36],
  ['Eckhart', 42],
]

ℹ️ 용어에 대한 참고 사항
이 개념에 '형태(shape)'라는 단어를 사용했지만 엔진에 따라 '히든 클래스' 또는 '맵'이라는 용어를 사용할 수도 있다는 점에 유의하세요.

예를 들어, 런타임에 다음 함수가 { x: number, y: number } 형태를 가진 두 개의 객체를 받으면 엔진은 향후 객체가 동일한 형태를 가질 것이라고 추측하고 해당 형태에 최적화된 머신 코드를 생성합니다.

function add(a, b) {
  return {
    x: a.x + b.x,
    y: a.y + b.y,
  }
}

대신 { x, y } 형태가 아닌 { y, x } 형태로 객체를 전달하면 엔진이 추측을 취소해야 하고 함수가 갑자기 상당히 느려집니다. 더 자세한 내용을 원하시면 mraleph의 훌륭한 게시글을 읽어보셔야 하기 때문에 여기서는 설명을 제한하겠습니다만, 특히 V8에는 monomorphic(1개의 형태), polymorphic(2~4개의 형태), megamorphic(5개 이상의 형태)의 3가지 모드가 있다는 점을 강조하고 싶습니다. 속도 저하가 심하기 때문에 monomorphic을 굉장히 유지하고 싶다고 가정해 보겠습니다.

// setup
let _ = 0
// 1. monomorphic
const o1 = { a: 1, b: _, c: _, d: _, e: _ }
const o2 = { a: 1, b: _, c: _, d: _, e: _ }
const o3 = { a: 1, b: _, c: _, d: _, e: _ }
const o4 = { a: 1, b: _, c: _, d: _, e: _ }
const o5 = { a: 1, b: _, c: _, d: _, e: _ } // 모든 형태가 동일합니다.
// 2. polymorphic
const o1 = { a: 1, b: _, c: _, d: _, e: _ }
const o2 = { a: 1, b: _, c: _, d: _, e: _ }
const o3 = { a: 1, b: _, c: _, d: _, e: _ }
const o4 = { a: 1, b: _, c: _, d: _, e: _ }
const o5 = { b: _, a: 1, c: _, d: _, e: _ } // 이 형태만 다릅니다.
// 3. megamorphic
const o1 = { a: 1, b: _, c: _, d: _, e: _ }
const o2 = { b: _, a: 1, c: _, d: _, e: _ }
const o3 = { b: _, c: _, a: 1, d: _, e: _ }
const o4 = { b: _, c: _, d: _, a: 1, e: _ }
const o5 = { b: _, c: _, d: _, e: _, a: 1 } // 모든 형태가 다릅니다.
// test case
function add(a1, b1) {
  return a1.a + a1.b + a1.c + a1.d + a1.e +
         b1.a + b1.b + b1.c + b1.d + b1.e }

let result = 0
for (let i = 0; i < 1000000; i++) {
  result += add(o1, o2)
  result += add(o3, o4)
  result += add(o4, o5)
}

도대체 어떻게 해야 하나요?

말처럼 쉽지는 않지만 모든 객체를 정확히 똑같은 형태로 만드세요. 리액트 컴포넌트 props를 다른 순서로 작성하는 것과 같이 사소한 것조차도 이 문제를 유발할 수 있습니다.

예를 들어, 다음은 리액트의 코드베이스에서 찾은 간단한 사례이지만 몇 년 전에 이미 정수로 객체를 초기화한 다음, 나중에 부동 소수점으로 저장했기 때문에 훨씬 더 큰 영향을 미치는 동일한 문제가 발생한 사례입니다. 네, 타입을 변경하면 형태도 변경됩니다. 맞습니다, number 뒤에 숨겨진 정수 타입과 부동 소수점 타입이 있습니다. 이러한 경우를 처리하세요.

ℹ️ 숫자 표현
엔진은 일반적으로 정수를 값으로 인코딩할 수 있습니다. 예를 들어 V8은 32비트로 값을 표현하며, 정수는 콤팩트 Smi(Small Integer) 값으로 표현하지만 부동 소수점과 큰 정수는 문자열이나 객체처럼 포인터로 전달합니다. JSC는 64비트 인코딩, 이중 태깅을 사용하여 SpiderMonkey와 마찬가지로 모든 숫자를 값으로 전달하고 나머지는 포인터로 전달합니다.

3. 배열/객체 메서드 피하기

저도 함수형 프로그래밍을 좋아하지만, 함수형 코드를 효율적인 기계어로 컴파일하는 Haskell/OCaml/Rust에서 작업하지 않는 한, 함수형은 항상 명령형보다 느릴 것입니다.

const result =
  [1.5, 3.5, 5.0]
    .map(n => Math.round(n))
    .filter(n => n % 2 === 0)
    .reduce((a, n) => a + n, 0)

이러한 방법의 문제점은 다음과 같습니다.

  1. 배열의 전체 복사본을 만들어야 하며, 나중에 가비지 컬렉터에서 해당 복사본을 해제해야 합니다. 메모리 I/O 문제에 대해서는 섹션 5에서 자세히 살펴보겠습니다.
  2. for 루프는 한 번만 반복할 수 있는 반면, 배열은 N개의 연산에 대해 N회 반복합니다.
// setup
const numbers = Array.from({ length: 10_000 }).map(() => Math.random())
// 1. functional
const result =
  numbers
    .map(n => Math.round(n * 10))
    .filter(n => n % 2 === 0)
    .reduce((a, n) => a + n, 0)
// 2. imperative
let result = 0
for (let i = 0; i < numbers.length; i++) {
  let n = Math.round(numbers[i] * 10)
  if (n % 2 !== 0) continue
  result = result + n
}

Object.values(), Object.keys(), Object.entries()와 같은 객체 메서드도 더 많은 데이터를 할당하고 메모리 액세스가 모든 성능 문제의 근원이기 때문에 비슷한 문제를 겪게 됩니다. 정말입니다. 해당 내용은 섹션 5에서 보여드리겠습니다.

4. 간접 참조(indirection) 방지하기

최적화 이득을 찾을 수 있는 또 다른 곳은 모든 간접적인 소스이며, 그 중 3가지 주요 소스를 볼 수 있습니다.

const point = { x: 10, y: 20 }

// 1.
// 프록시 객체는 최적화하기 더 어려운데 그 이유는 다음과 같습니다.
// 사용자 정의 로직을 실행하고 있을 수 있으므로 엔진이 일반적인 가정을 할 수 없습니다.
const proxy = new Proxy(point, { get: (t, k) => { return t[k] } })
// 일부 엔진은 프록시 비용을 사라지게 만들 수 있지만 이러한 최적화는 제작 비용이 많이 들고 쉽게 깨질 수 있습니다.
const x = proxy.x

// 2.
// 일반적으로 무시되지만 `.` 또는 `[]`를 통해 객체에 접근하는 것도 간접 참조입니다.
// 쉬운 경우에는 엔진이 비용을 매우 잘 최적화할 수 있습니다.
const x = point.x
// 그러나 추가 접근할 때마다 비용이 증가하고 엔진이 `point`의 상태에 대한 가정을 하기가 더 어려워집니다.
const x = this.state.circle.center.point.x

// 3.
// 마지막으로 함수 호출에도 비용이 발생할 수 있습니다. 엔진은 일반적으로 이러한 항목을 인라인하는 데 능숙합니다.
function getX(p) { return p.x }
const x = getX(p)
// 하지만 그렇다는 보장은 없습니다. 특히 함수 호출이 정적 함수가 아닌 예를 들어 인자로 오는 경우 그렇습니다.
function Component({ point, getX }) {
  return getX(point)
}

프록시 벤치마크는 현재 V8에서 특히 잔인합니다. 마지막으로 확인한 결과, 프록시 객체는 항상 JIT에서 인터프리터로 되돌아가고 있었고, 그 결과를 보면 여전히 그럴 수 있습니다.

// 1. proxy access
const point = new Proxy({ x: 10, y: 20 }, { get: (t, k) => t[k] })

for (let _ = 0, i = 0; i < 100_000; i++) { _ += point.x }
// 2. direct access
const point = { x: 10, y: 20 }
const x = point.x

for (let _ = 0, i = 0; i < 100_000; i++) { _ += x }

또한 깊게 중첩된 객체에 접근하는 것과 직접 접근하는 것을 보여주고 싶었지만, 엔진은 핫 루프와 상수의 객체가 있을 때 이스케이프 분석을 통해 객체 접근을 최적화하는 데 매우 능숙합니다. 이를 방지하기 위해 약간의 간접 참조를 삽입했습니다.

// 1. nested access
const a = { state: { center: { point: { x: 10, y: 20 } } } }
const b = { state: { center: { point: { x: 10, y: 20 } } } }
const get = (i) => i % 2 ? a : b

let result = 0
for (let i = 0; i < 100_000; i++) {
  result = result + get(i).state.center.point.x }
// 2. direct access
const a = { x: 10, y: 20 }.x
const b = { x: 10, y: 20 }.x
const get = (i) => i % 2 ? a : b

let result = 0
for (let i = 0; i < 100_000; i++) {
  result = result + get(i) }

5. 캐시 누락 방지하기

이 점은 약간의 로우 레벨의 지식이 필요하지만 자바스크립트에도 영향을 미치므로 설명해 드리겠습니다. CPU의 관점에서 볼 때 RAM에서 메모리를 검색하는 것은 느립니다. 속도를 높이기 위해 주로 두 가지 최적화를 사용합니다.

5.1 프리페칭

첫 번째는 프리페칭으로, 사용자가 관심을 가질 만한 메모리일 것으로 예상하여 미리 더 많은 메모리를 가져오는 것입니다. 항상 하나의 메모리 주소를 요청하면 그 바로 뒤에 오는 메모리 영역에 관심이 있을 것이라고 추측합니다. 따라서 데이터를 순차적으로 접근하는 것이 핵심입니다. 다음 예제에서는 임의의 순서로 메모리에 접근하는 경우의 영향을 관찰할 수 있습니다.

// setup
const K = 1024
const length = 1 * K * K

// 이러한 포인트는 차례로 생성되므로 메모리에 순차적으로 할당됩니다.
const points = new Array(length)
for (let i = 0; i < points.length; i++) {
  points[i] = { x: 42, y: 0 }
}

// 이 배열에는 위와 *동일한 데이터*가 포함되어 있지만 무작위로 섞여 있습니다.
const shuffledPoints = shuffle(points.slice())
// 1. sequential
let _ = 0
for (let i = 0; i < points.length; i++) { _ += points[i].x }
// 2. random
let _ = 0
for (let i = 0; i < shuffledPoints.length; i++) { _ += shuffledPoints[i].x }

도대체 어떻게 해야 할까요?

자바스크립트에는 메모리에 객체를 배치하는 방법이 없기 때문에 이 측면은 실제로 적용하기 가장 어려울 수 있지만, 위의 예제에서와 같이 데이터를 재정렬하거나 정렬하기 전에 데이터를 조작하는 등 이러한 지식을 유용하게 사용할 수 있습니다. 가비지 컬렉터가 객체를 이동시킬 수 있으므로 순차적으로 생성된 객체가 일정 시간이 지난 후에도 같은 위치에 있을 것이라고 가정할 수 없습니다. 한 가지 예외가 있는데, 바로 숫자 배열, 즉 TypedArray 인스턴스입니다.

// from this
const points = [{ x: 0, y: 5 }, { x: 0, y: 10 }]

// to this
const points = new Int64Array([0, 5, 0, 10])

더 자세한 예제는 이 링크* 를 참조하세요.

  • 지금은 오래된 일부 최적화가 포함되어 있지만 전반적으로 여전히 정확하다는 점에 유의하세요.

5.2 L1/2/3 캐싱

CPU가 사용하는 두 번째 최적화는 L1/L2/L3 캐시로, 더 빠른 RAM과 비슷하지만 더 비싸기 때문에 훨씬 더 작습니다. 이들은 RAM 데이터를 가지지만 LRU 캐시 역할을 합니다. 데이터는 "hot"(작업 중) 상태일 때 들어오고, 새로운 작업 데이터에 공간이 필요할 때 메인 RAM에 다시 기록됩니다. 따라서 여기서 핵심은 가능한 한 적은 데이터를 사용하여 작업 데이터셋을 빠른 캐시에 보관하는 것입니다. 다음 예시에서는 각 캐시를 연속적으로 무효화했을 때 어떤 효과가 나타나는지 관찰할 수 있습니다.

// setup
const KB = 1024
const MB = 1024 * KB

// 이는 해당 캐시에 맞는 대략적인 크기입니다. 컴퓨터에서 동일한 결과를 얻지 못한다면 크기가 다르기 때문일 수 있습니다.
const L1  = 256 * KB
const L2  =   5 * MB
const L3  =  18 * MB
const RAM =  32 * MB

// 모든 테스트 사례에 대해 동일한 버퍼에 액세스하게 되지만, 첫 번째 사례에서는 처음 0부터 `L1` 항목에만 접근하고, 두 번째 사례에서는 0부터 `L2`까지만 접근하게 됩니다.
const buffer = new Int8Array(RAM)
buffer.fill(42)

const random = (max) => Math.floor(Math.random() * max)
// 1. L1
let r = 0; for (let i = 0; i < 100000; i++) { r += buffer[random(L1)] }
// 2. L2
let r = 0; for (let i = 0; i < 100000; i++) { r += buffer[random(L2)] }
// 3. L3
let r = 0; for (let i = 0; i < 100000; i++) { r += buffer[random(L3)] }
// 4. RAM
let r = 0; for (let i = 0; i < 100000; i++) { r += buffer[random(RAM)] }

도대체 어떻게 해야 할까요?

제거할 수 있는 모든 데이터 또는 메모리 할당을 무자비하게 제거하세요. 데이터셋이 작을수록 프로그램 실행 속도가 빨라집니다. 프로그램의 95%에서 메모리 I/O가 병목 현상을 발생시킵니다. 또 다른 좋은 전략은 작업을 작게 나누고 한 번에 작은 데이터셋으로 작업하는 것입니다.

CPU 및 메모리에 대한 자세한 내용은 이 링크를 참조하세요.

ℹ️ 불변 데이터 구조 정보
불변성은 명확성과 정확성 측면에서는 좋지만 성능 측면에서 보면 불변 데이터 구조를 업데이트한다는 것은 컨테이너의 복사본을 만드는 것을 의미하며, 이는 캐시를 플러시하는 메모리 I/O가 늘어나는 것을 의미합니다. 가능하면 변경 불가능한 데이터 구조는 피해야 합니다.

ℹ️ { ...spread } 연산자 정보
매우 편리하지만 사용할 때마다 메모리에 새로운 객체가 생성됩니다. 더 많은 메모리 I/O, 더 느린 캐시!

6. 큰 객체 피하기

섹션 2에서 설명한 대로 엔진은 형태를 이용하여 객체를 최적화합니다. 그러나 형태가 너무 커지면 엔진은 일반 해시맵(예: Map 객체)을 사용할 수밖에 없습니다. 섹션 5에서 살펴본 것처럼 캐시가 누락되면 성능이 크게 저하됩니다. 해시맵은 일반적으로 데이터가 차지하는 메모리 영역에 무작위로 균등하게 분산되어 있기 때문에 이러한 문제가 발생하기 쉽습니다. 동일성(ID)으로 색인된 일부 사용자의 맵에서 어떻게 작동하는지 살펴보겠습니다.

// setup
const USERS_LENGTH = 1_000
// setup
const byId = {}
Array.from({ length: USERS_LENGTH }).forEach((_, id) => {
  byId[id] = { id, name: 'John'}
})
let _ = 0
// 1. [] access
Object.keys(byId).forEach(id => { _ += byId[id].id })
// 2. direct access
Object.values(byId).forEach(user => { _ += user.id })

또한 객체 크기가 커짐에 따라 성능이 계속 저하되는 것을 관찰할 수 있습니다.

// setup
const USERS_LENGTH = 100_000

도대체 어떻게 해야 할까요?

위에서 설명한 것처럼 큰 객체에 자주 인덱싱하지 않도록 하세요. 미리 객체를 배열로 전환하는 것이 좋습니다. 모델에 동일성(ID)이 있도록 데이터를 구성하면 Object.values()를 사용할 수 있고 동일성(ID)을 얻기 위해 키 맵을 참조할 필요가 없으므로 도움이 될 수 있습니다.

7. eval 사용하기

일부 자바스크립트 패턴은 엔진에 최적화하기 어려우며, eval() 또는 그 파생 함수를 사용하면 이러한 패턴을 사라지게 할 수 있습니다. 이 예제에서는 eval()을 사용하면 동적 객체 키가 있는 객체를 생성하는 데 드는 비용을 피할 수 있음을 관찰할 수 있습니다.

// setup
const key = 'requestId'
const values = Array.from({ length: 100_000 }).fill(42)
// 1. without eval
function createMessages(key, values) {
  const messages = []
  for (let i = 0; i < values.length; i++) {
    messages.push({ [key]: values[i] })
  }
  return messages
}

createMessages(key, values)
// 2. with eval
function createMessages(key, values) {
  const messages = []
  const createMessage = new Function('value',
    `return { ${JSON.stringify(key)}: value }`
  )
  for (let i = 0; i < values.length; i++) {
    messages.push(createMessage(values[i]))
  }
  return messages
}

createMessages(key, values)

eval에 대한 또 다른 좋은 사용 사례는 필터 술어(predicate) 함수를 컴파일하여 절대로 실행하지 않을 것을 아는 분기 처리를 버리는 것입니다. 일반적으로 hot 루프에서 실행되는 모든 함수는 이러한 종류의 최적화를 위한 좋은 후보입니다.

물론 eval()에 대한 일반적인 경고가 적용됩니다. 사용자 입력을 신뢰하지 말고, eval() 코드에 전달되는 모든 것을 새니타이즈(sanitize) 처리하고, XSS 가능성을 만들지 마세요. 또한 CSP가 있는 브라우저 페이지 등 일부 환경에서는 eval()에 대한 접근을 허용하지 않습니다.

8. 신중하게 문자열 사용하기

위에서 문자열이 보이는 것보다 더 비싼 이유를 이미 살펴봤습니다. 여기서 좋은 소식과 나쁜 소식이 있는데, 논리적 순서(첫 번째는 나쁜 것, 두 번째는 좋은 것)로 말씀드리자면 문자열은 보기보다 복잡하지만 잘 활용하면 상당히 효율적으로 사용할 수 있습니다.

문자열 연산은 문맥상 자바스크립트의 핵심적인 부분입니다. 문자열이 많은 코드를 최적화하려면 엔진은 창의력을 발휘해야 합니다. 즉, 사용 사례에 따라 C++에서 String 객체를 여러 문자열 표현으로 표현해야 했습니다. 가장 일반적인 엔진인 V8에도 해당되며 다른 엔진에도 일반적으로 적용되므로 걱정해야 할 두 가지 일반적인 경우가 있습니다.

첫째, +로 연결된 문자열은 두 입력 문자열의 복사본을 만들지 않습니다. 이 연산은 각 하위 문자열에 대한 포인터를 생성합니다. 만약 타입스크립트였다면 다음과 같은 형태가 될 것입니다.

class String {
  abstract value(): char[] {}
}

class BytesString {
  constructor(bytes: char[]) {
    this.bytes = bytes
  }
  value() {
    return this.bytes
  }
}

class ConcatenatedString {
  constructor(left: String, right: String) {
    this.left = left
    this.right = right
  }
  value() {
    return [...this.left.value(), ...this.right.value()]
  }
}

function concat(left, right) {
  return new ConcatenatedString(left, right)
}

const first = new BytesString(['H', 'e', 'l', 'l', 'o', ' '])
const second = new BytesString(['w', 'o', 'r', 'l', 'd'])

// 배열 복사본이 없어요!
const message = concat(first, second)

둘째, 문자열 슬라이스는 복사본을 만들지않고 다른 문자열의 범위를 가리키기만 합니다. 위의 예제에 이어 계속 진행합니다.

class SlicedString {
  constructor(source: String, start: number, end: number) {
    this.source = source
    this.start = start
    this.end = end
  }
  value() {
    return this.source.value().slice(this.start, this.end)
  }
}

function substring(source, start, end) {
  return new SlicedString(source, start, end)
}

// 이것은 "He"를 나타내지만 여전히 배열 복사본을 포함하지 않습니다.
// 슬라이스된 문자열에서 두 개의 바이트 문자열로 연결된 문자열입니다.
const firstTwoLetters = substring(message, 0, 2)

하지만 여기에 문제가 있습니다. 이러한 바이트의 변형을 시작해야 하는 순간부터 복사 비용이 발생하기 시작합니다. String 클래스로 돌아가서 .trimEnd 메서드를 추가한다고 가정해 보겠습니다.

class String {
  abstract value(): char[] {}

  trimEnd() {
    // `.value()`를 호출할 수 있습니다.
    // our Sliced->Concatenated->2*Bytes string!
    const bytes = this.value()

    const result = bytes.slice()
    while (result[result.length - 1] === ' ')
      result.pop()
    return new BytesString(result)
  }
}

이제 mutation을 사용하는 연산과 연결만 사용하는 연산을 비교하는 예제로 넘어가 보겠습니다.

// setup
const classNames = ['primary', 'selected', 'active', 'medium']
// 1. mutation
const result =
  classNames
    .map(c => `button--${c}`)
    .join(' ')
// 2. concatenation
const result =
  classNames
    .map(c => 'button--' + c)
    .reduce((acc, c) => acc + ' ' + c, '')

도대체 어떻게 해야 할까요?

일반적으로 가능한 한 오랫동안 mutation을 피하세요. 여기에는 .trim(), .replace() 등과 같은 메서드가 포함됩니다. 이러한 방법을 피할 수 있는 방법을 고려해 보세요. 일부 엔진에서는 문자열 템플릿이 +보다 느릴 수도 있습니다. 현재 V8에서는 그렇지만 향후에는 그렇지 않을 수도 있으므로 항상 그렇듯이 벤치마킹하세요.

위의 SlicedString 문자열에 대한 참고 사항으로, 매우 큰 문자열에 대한 작은 하위 문자열이 메모리에 살아 있으면 가비지 컬렉터가 큰 문자열을 수집하지 못할 수 있다는 점에 유의하세요! 큰 텍스트를 처리하면서 작은 문자열을 추출하는 경우 많은 양의 메모리가 누수될 수 있습니다.

const large = Array.from({ length: 10_000 }).map(() => 'string').join('')
const small = large.slice(0, 50)
//    ^ `large` 를 유지합니다.

여기서 해결책은 mutation 메서드를 유리하게 사용하는 것입니다. 이 중 하나를 small 값에 사용하면 복사본이 강제로 생성되고 large 값에 대한 이전 포인터가 손실됩니다.

// 존재하지 않는 토큰 교체
const small = small.replace('#'.repeat(small.length + 1), '')

자세한 내용은 V8의 string.h 또는 JavaScriptCore의 JSString.h를 참조하세요.

ℹ️ 문자열 복잡성
매우 빠르게 훑어보았지만 문자열에 복잡성을 더하는 구현 세부 사항이 많이 있습니다. 이러한 각 문자열 표현에는 최소 길이가 있는 경우가 많습니다. 예를 들어 아주 작은 문자열에는 연결된 문자열을 사용하지 않을 수 있습니다. 때로는 하위 문자열의 하위 문자열을 가리키지 않도록 하는 등의 제한이 있습니다. 위에 링크된 C++ 파일을 읽으면 주석만 읽어도 구현 세부 사항을 잘 파악할 수 있습니다.

9. 전문화(specialization) 사용하기

성능 최적화의 중요한 개념 중 하나는 특정 사용 사례의 제약 조건에 맞게 로직을 조정하는 전문화입니다. 이는 일반적으로 어떤 조건이 해당 사례에 해당할 가능성이 높은지 파악하고 해당 조건에 맞게 코딩하는 것을 의미합니다.

때때로 제품 목록에 태그를 추가해야 하는 판매자라고 가정해 보겠습니다. 우리는 경험을 통해 태그가 보통 비어 있다는 것을 알고 있습니다. 이 정보를 알면 해당 경우에 맞게 함수를 특화할 수 있습니다.

// setup
const descriptions = ['apples', 'oranges', 'bananas', 'seven']
const someTags = {
  apples: '::promotion::',
}
const noTags = {}

// 해당되는 경우 태그와 함께 제품을 문자열로 전환합니다.
function productsToString(description, tags) {
  let result = ''
  description.forEach(product => {
    result += product
    if (tags[product]) result += tags[product]
    result += ', '
  })
  return result
}

// 지금 전문화하기
function productsToStringSpecialized(description, tags) {
  // 우리는 `tags`가 비어 있을 가능성이 있다는 것을 알고 있으므로 미리 한 번 확인한 다음 내부 루프에서 `if` 검사를 제거할 수 있습니다.
  if (isEmpty(tags)) {
    let result = ''
    description.forEach(product => {
      result += product + ', '
    })
    return result
  } else {
    let result = ''
    description.forEach(product => {
      result += product
      if (tags[product]) result += tags[product]
      result += ', '
    })
    return result
  }
}
function isEmpty(o) { for (let _ in o) { return false } return true }
// 1. not specialized
for (let i = 0; i < 100; i++) {
  productsToString(descriptions, someTags)
  productsToString(descriptions, noTags)
  productsToString(descriptions, noTags)
  productsToString(descriptions, noTags)
  productsToString(descriptions, noTags)
}
// 2. specialized
for (let i = 0; i < 100; i++) {
  productsToStringSpecialized(descriptions, someTags)
  productsToStringSpecialized(descriptions, noTags)
  productsToStringSpecialized(descriptions, noTags)
  productsToStringSpecialized(descriptions, noTags)
  productsToStringSpecialized(descriptions, noTags)
}

이러한 종류의 최적화를 통해 약간의 개선 효과를 얻을 수 있지만, 그 효과가 합산됩니다. 형태 및 메모리 I/O와 같은 더 중요한 최적화를 위한 좋은 추가 기능입니다. 하지만 조건이 변경되면 전문화가 불리하게 작용할 수 있으므로 이 기능을 적용할 때는 주의하세요.

ℹ️ 분기 예측 및 분기 없는 코드
코드에서 분기를 제거하면 성능 측면에서 매우 효율적일 수 있습니다. 분기 예측자가 무엇인지에 대한 자세한 내용은 스택 오버플로 클래식 답변인 정렬 배열을 더 빠르게 처리하는 이유를 참조하세요.

10. 데이터 구조

데이터 구조에 대해서는 별도의 포스팅이 필요하므로 자세히 설명하지 않겠습니다. 하지만 사용 사례에 잘못된 데이터 구조를 사용하면 위의 어떤 최적화보다 더 큰 영향을 미칠 수 있다는 점에 유의하세요. MapSet과 같은 기본 기능에 익숙해지고 연결 리스트, 우선순위 큐, 트리(RB 및 B+) 및 트라이에 대해 알아두는 것이 좋습니다.

하지만 간단한 예시로 작은 목록에 대해 Array.includesSet.has가 어떻게 작동하는지 비교해 보겠습니다.

// setup
const userIds = Array.from({ length: 1_000 }).map((_, i) => i)
const adminIdsArray = userIds.slice(0, 10)
const adminIdsSet = new Set(adminIdsArray)
// 1. Array
let _ = 0
for (let i = 0; i < userIds.length; i++) {
  if (adminIdsArray.includes(userIds[i])) { _ += 1 }
}
// 2. Set
let _ = 0
for (let i = 0; i < userIds.length; i++) {
  if (adminIdsSet.has(userIds[i])) { _ += 1 }
}

보시다시피 데이터 구조 선택은 매우 큰 차이를 만들어냅니다.

실제 사례로, 연결 리스트로 배열을 대체하여 함수의 런타임을 5초에서 22밀리초로 단축한 사례가 있습니다.

11. 벤치마킹

이 섹션을 마지막에 남겨둔 이유는 위의 재미있는 섹션에 대한 신뢰성을 확보해야 했기 때문입니다. 이제 벤치마킹이 최적화에서 가장 중요한 부분이라는 점에 대해 말씀드리겠습니다. 가장 중요할 뿐만 아니라 어렵기도 합니다. 20년이 지난 후에도 여전히 결함이 있는 벤치마크를 만들거나 프로파일링 도구를 잘못 사용하는 경우가 있습니다. 따라서 어떤 일을 하든 올바른 벤치마킹을 위해 최대한 노력해 주세요.

11.0 큰 것부터 시작

항상 런타임에서 가장 큰 부분을 차지하는 함수/코드 섹션을 최적화하는 것을 우선순위로 삼아야 합니다. 가장 중요한 부분이 아닌 다른 부분을 최적화하는 데 시간을 소비한다면 시간을 낭비하는 것입니다.

11.1 마이크로 벤치마크 피하기

프로덕션 모드에서 코드를 실행하고 이러한 관찰 결과를 바탕으로 최적화를 진행하세요. JS 엔진은 매우 복잡하기 때문에 마이크로 벤치마크에서는 실제 시나리오와 다르게 작동하는 경우가 많습니다. 예를 들어 다음 마이크로 벤치마크를 살펴보겠습니다.

const a = { type: 'div', count: 5, }
const b = { type: 'span', count: 10 }

function typeEquals(a, b) {
  return a.type === b.type
}

for (let i = 0; i < 100_000; i++) {
  typeEquals(a, b)
}

조금만 더 일찍 주의를 기울였다면 엔진이 { type: string, count: number } 형태에 대해 함수를 특화한다는 것을 알 수 있습니다. 하지만 이것이 실제 사용 사례에서도 적용될까요? ab는 항상 같은 형태일까요, 아니면 어떤 형태이든 받을 수 있을까요? 프로덕션에서 다양한 형태를 받는다면 이 함수는 그때는 다르게 작동할 것입니다.

11.2 결과 의심하기

함수를 막 최적화하고 100배 더 빠르게 실행된다면 의심해 보세요. 결과를 반증해보고, 프로덕션 모드에서 사용해보고, 여러 가지를 시도해 보세요. 스텁을 던져보세요. 마찬가지로 도구도 의심하세요. 개발 도구로 벤치마크를 관찰한다는 사실만으로도 그 동작이 변경될 수 있습니다.

11.3 타겟 선택하기

엔진마다 특정 패턴을 다른 엔진보다 더 좋게 또는 더 나쁘게 최적화합니다. 자신에게 적합한 엔진을 벤치마킹하고 어떤 엔진이 더 중요한지 우선순위를 정해야 합니다. 다음은 바벨의 실제 사례로, V8 성능을 향상시키는 것은 JSC의 성능을 저하시키는 것을 의미합니다.

12. 프로파일링 & 도구

프로파일링 및 개발 도구에 대한 다양한 의견이 존재합니다.

12.1 브라우저 문제

브라우저에서 프로파일링하는 경우 깨끗하고 비어 있는 브라우저 프로필을 사용해야 합니다. 저는 이를 위해 별도의 브라우저를 사용하기도 합니다. 프로파일링 중이고 브라우저 확장 프로그램을 활성화한 경우 측정값이 엉망이 될 수 있습니다. 특히 리액트 개발 도구는 결과에 상당한 영향을 미치며, 렌더링 코드가 사용자에게 미러에 표시되는 것보다 느리게 표시될 수 있습니다.

12.2 샘플 vs 구조 프로파일링

브라우저 프로파일링 도구는 샘플 기반 프로파일러로, 일정한 간격으로 스택의 샘플을 채취합니다. 이는 매우 작지만 매우 빈번하게 호출되는 함수가 샘플 사이에 호출될 수 있으며, 스택 차트에서 매우 과소 보고될 수 있다는 큰 단점이 있습니다. 이 문제를 완화하려면 사용자 지정 샘플 간격이 있는 Firefox 개발자 도구 또는 CPU 스로틀링이 있는 Chrome 개발자 도구를 사용하세요.

12.3 필수 도구

일반적인 브라우저 개발 도구 외에도 다음과 같은 옵션을 알아두면 도움이 될 수 있습니다.

  • Chrome 개발도구에는 느린 이유를 파악하는 데 도움이 되는 실험적인 플래그가 꽤 많이 있습니다. 스타일 무효화 추적기는 브라우저에서 스타일/레이아웃 재계산을 디버깅해야 할 때 유용하게 사용할 수 있습니다.
    https://github.com/iamakulov/devtools-perf-features

  • deoptexplorer-vscode 확장 프로그램을 사용하면 V8/chromium 로그 파일을 로드하여 함수에 다른 모양을 전달할 때와 같이 코드에서 언제 최적화가 트리거되는지 파악할 수 있습니다. 로그 파일을 읽는 데 확장 프로그램이 필요하지는 않지만 훨씬 더 쾌적한 환경을 제공합니다.
    https://github.com/microsoft/deoptexplorer-vscode

  • 언제든지 각 JS 엔진의 디버그 셸을 컴파일하여 엔진의 작동 방식을 자세히 파악할 수 있습니다. 이를 통해 perf 및 기타 저수준 도구를 실행하고 각 엔진에서 생성된 바이트코드와 머신 코드를 검사할 수 있습니다.
    V8용 예시 | JSC용 예시 | 스파이더몽키의 예(누락)

최종 참고 사항

유용한 팁을 배웠기를 바라며, 의견이나 수정 사항이 있으면 푸터에 있는 링크를 이메일로 보내주세요.

profile
FrontEnd Developer

5개의 댓글

comment-user-thumbnail
2024년 4월 15일

좋은글 감사합니다~

답글 달기
comment-user-thumbnail
2024년 4월 16일

감사합니다

답글 달기
comment-user-thumbnail
2024년 4월 16일

감사합니다

답글 달기
comment-user-thumbnail
2024년 4월 22일

코드 가독성 없게 짜고 성능 운운하지 않았으면 하는 바람입니다. :)

답글 달기
comment-user-thumbnail
2024년 4월 23일

좋은 글 감사합니다.

답글 달기