미래 지향적인 웹 앱 구축: The Codest의 전문가 팀이 제공하는 인사이트
The Codest가 최첨단 기술로 확장 가능한 대화형 웹 애플리케이션을 제작하고 모든 플랫폼에서 원활한 사용자 경험을 제공하는 데 탁월한 성능을 발휘하는 방법을 알아보세요. Adobe의 전문성이 어떻게 디지털 혁신과 비즈니스를 촉진하는지 알아보세요...
브라우저 기술이 발전하면서 웹 애플리케이션은 점점 더 많은 로직을 프론트엔드로 전송하기 시작했고, 이에 따라 서버의 부담을 덜어주고 수행해야 하는 작업의 수를 줄였습니다. 기본 CRUD에서 서버의 역할은 권한 부여, 유효성 검사, 데이터베이스와의 통신 및 필요한 비즈니스 로직으로 귀결됩니다. 나머지 데이터 로직은 UI 측에서 애플리케이션의 표현을 담당하는 코드에서 쉽게 처리할 수 있습니다.
이 글에서는 다음과 같은 몇 가지 예와 패턴을 보여드리려고 합니다. 코드 효율적이고 깔끔하며 빠릅니다.
구체적인 예를 자세히 살펴보기 전에이 기사에서는 제 생각에 응용 프로그램의 속도에 놀라운 방식으로 영향을 미칠 수있는 사례를 보여주는 데만 집중하고 싶습니다. 그러나 이것이 가능한 모든 경우에 더 빠른 솔루션을 사용하는 것이 최선의 선택이라는 것을 의미하지는 않습니다. 오히려 아래 팁은 캔버스에서 게임 렌더링이나 고급 그래프가 필요한 제품, 비디오 작업 또는 가능한 한 빨리 실시간으로 동기화하려는 활동 등 애플리케이션이 느리게 실행될 때 검토해야 할 사항으로 취급해야 합니다.
우리는 매핑, 정렬, 필터링, 요소 합산 등 애플리케이션 로직의 대부분을 배열에 기반합니다. 쉽고 투명하며 자연스러운 방식으로 다양한 유형의 계산, 그룹화 등을 간단하게 수행할 수 있는 내장 메서드를 사용합니다. 각 인스턴스에서 유사하게 작동하며, 대부분의 경우 반복할 때마다 요소 값, 인덱스 및 배열이 차례로 푸시되는 함수를 인수로 전달합니다. 지정된 함수는 배열의 각 요소에 대해 수행되며 결과는 메서드에 따라 다르게 해석됩니다. 많은 경우 느리게 실행되는 이유에 초점을 맞추고 싶기 때문에 Array.prototype 메서드에 대해서는 자세히 설명하지 않겠습니다.
배열 메서드는 각 요소에 대해 함수를 수행하기 때문에 속도가 느립니다. 엔진의 관점에서 호출되는 함수는 새 호출을 준비하고 적절한 범위와 기타 많은 종속성을 제공해야 하므로 특정 범위에서 특정 코드 블록을 반복하는 것보다 프로세스가 훨씬 더 오래 걸립니다. 이 정도 배경 지식만 있으면 다음 예제를 이해할 수 있을 것입니다:
(() => {
const randomArray = [...Array(1E6).keys()].map(() => ({ value: Math.random() }));
console.time('Sum by reduce');
const reduceSum = randomArray
.map(({ value }) => value)
.reduce((a, b) => a + b);
console.timeEnd('Sum by reduce');
console.time('Sum by for loop');
let forSum = randomArray[0].value;
for (let index = 1; index < randomArray.length; index++) {
forSum += randomArray[index].value;
}
console.timeEnd('Sum by for loop');
console.log(reduceSum === forSum);
})();
이 테스트가 벤치마크만큼 신뢰할 수 있는 것은 아니지만(나중에 다시 다룰 예정입니다) 경고등이 켜집니다. 제 컴퓨터의 임의의 경우, 매핑을 한 다음 동일한 효과를 내는 요소를 줄이는 것과 비교하면 for 루프가 있는 코드가 약 50배 더 빠를 수 있다는 것이 밝혀졌습니다! 이것은 특정 계산 대상에 도달하기 위해서만 만들어진 이상한 객체에 대한 연산에 관한 것입니다. 이제 배열 메서드에 대해 좀 더 객관적으로 이해할 수 있는 코드를 만들어 봅시다:
(() => {
const randomArray = [...Array(1E6).keys()].map(() => ({ value: Math.random() }));
console.time('Sum by reduce');
const reduceSum = randomArray
.reduce((a, b) => ({ value: a.value + b.value })).value
console.timeEnd('Sum by reduce');
console.time('Sum by for loop');
let forSum = randomArray[0].value;
for (let index = 1; index < randomArray.length; index++) {
forSum += randomArray[index].value;
}
console.timeEnd('Sum by for loop');
console.log(reduceSum === forSum);
})();
이 테스트가 벤치마크만큼 신뢰할 수 있는 것은 아니지만(나중에 다시 다룰 예정입니다) 경고등이 켜집니다. 제 컴퓨터에서 무작위로 테스트한 결과, for 루프가 포함된 코드가 동일한 효과를 내는 요소를 매핑한 다음 줄이는 것과 비교했을 때 약 50배 더 빠를 수 있는 것으로 나타났습니다! 왜냐하면 이 특정 경우의 합계를 줄이기 메서드를 사용하여 얻으려면 요약하려는 순수한 값에 대해 배열을 매핑해야 하기 때문입니다. 이제 배열 메서드에 대해 좀 더 객관적으로 살펴볼 수 있는 방법을 만들어 봅시다:
(() => {
const randomArray = [...Array(1E6).keys()].map(() => ({ value: Math.random() }));
console.time('Sum by reduce');
const reduceSum = randomArray
.reduce((a, b) => ({ value: a.value + b.value })).value
console.timeEnd('Sum by reduce');
console.time('Sum by for loop');
let forSum = randomArray[0].value;
for (let index = 1; index < randomArray.length; index++) {
forSum += randomArray[index].value;
}
console.timeEnd('Sum by for loop');
console.log(reduceSum === forSum);
})();
결과적으로 50배 부스트가 4배 부스트로 감소했습니다. 실망하셨다면 사과드립니다! 끝까지 객관성을 유지하기 위해 두 코드를 다시 분석해 보겠습니다. 우선, 이론적으로 계산 복잡성이 두 배로 줄어든 것은 사소해 보이는 차이입니다. 먼저 순수한 요소를 매핑한 다음 합산하는 대신 객체와 특정 필드에 대한 연산을 수행하여 최종적으로 원하는 합계를 얻습니다. 문제는 다른 프로그래머가 코드를 살펴볼 때 발생하는데, 앞서 살펴본 코드와 비교했을 때 후자는 어느 순간 추상성을 잃게 됩니다.
왜냐하면 두 번째 연산부터는 우리가 관심 있는 필드와 반복 배열의 두 번째 표준 객체라는 이상한 객체에 대해 연산하기 때문입니다. 여러분은 어떻게 생각하실지 모르겠지만, 제가 보기에는 두 번째 코드 예제에서 for 루프의 논리가 이 이상하게 보이는 reduce보다 훨씬 더 명확하고 의도가 분명합니다. 그리고 더 이상 신화적인 50은 아니지만 계산 시간 면에서는 여전히 4배 더 빠릅니다! 모든 밀리초가 소중하기 때문에 이 경우 선택은 간단합니다.
두 번째로 비교하고 싶었던 것은 Math.max 메서드 또는 더 정확하게는 백만 개의 요소를 채우고 가장 큰 요소와 가장 작은 요소를 추출하는 것과 관련이 있습니다. 코드와 시간 측정 메서드도 준비했는데 코드를 실행하니 스택 크기가 초과되었다는 매우 이상한 오류가 발생했습니다. 여기 코드가 있습니다:
(() => {
const randomValues = [...Array(1E6).keys()].map(() => Math.round(Math.random() * 1E6) - 5E5);
console.time('ES6 스프레드 연산자를 사용한 Math.max');
const maxBySpread = Math.max(...randomValues);
console.timeEnd('Math.max와 ES6 스프레드 연산자');
console.time('Math.max와 for 루프');
let maxByFor = randomValues[0];
for (let index = 1; index maxByFor) {
maxByFor = randomValues[index];
}
}
console.timeEnd('Math.max with for loop');
console.log(maxByFor === maxBySpread);
})();
(() => {
const randomValues = [...Array(1E6).keys()].map(() => Math.round(Math.random() * 1E6) - 5E5);
console.time('ES6 스프레드 연산자를 사용한 Math.max');
const maxBySpread = Math.max(...randomValues);
console.timeEnd('Math.max와 ES6 스프레드 연산자');
console.time('Math.max와 for 루프');
let maxByFor = randomValues[0];
for (let index = 1; index maxByFor) {
maxByFor = randomValues[index];
}
}
console.timeEnd('Math.max with for loop');
console.log(maxByFor === maxBySpread);
})();
네이티브 메서드는 재귀를 사용하는 것으로 밝혀졌으며, v8에서는 호출 스택에 의해 제한되며 그 수는 환경에 따라 다릅니다. 이것은 저를 많이 놀라게 한 것이지만 결론은 배열이 특정 마법 요소 수 (제 경우에는 125375 개로 판명 된)를 초과하지 않는 한 네이티브 메서드가 더 빠릅니다. 이 요소 수의 경우 루프와 비교했을 때 결과가 5배 더 빨랐습니다. 그러나 언급 된 요소 수 이상에서는 상대방과 달리 for 루프가 확실히 승리하여 올바른 결과를 얻을 수 있습니다.
이 단락에서 언급하고자 하는 개념은 재귀입니다. 이전 예제에서 Math.max 메서드와 인수 폴딩에서 스택 크기 제한으로 인해 특정 수를 초과하는 재귀 호출에 대한 결과를 얻을 수 없다는 것을 확인했습니다.
이제 내장 메서드가 아닌 JS로 작성된 코드의 맥락에서 재귀가 어떻게 보이는지 살펴보겠습니다. 여기서 보여줄 수 있는 가장 고전적인 것은 물론 피보나치 수열에서 n번째 항을 찾는 것입니다. 이제 이것을 작성해 봅시다!
(() => {
const fiboIterative = (n) => {
let [a, b] = [0, 1];
for (let i = 0; i {
if(n < 2) {
반환 n;
}
return fiboRecursive(n - 2) + fiboRecursive(n - 1);
};
console.time('for 루프에 의한 피보나치 수열');
const resultIterative = fiboIterative(30);
console.timeEnd('for 루프에 의한 피보나치 수열');
console.time('재귀에 의한 피보나치 수열');
const resultRecursive = fiboRecursive(30);
console.timeEnd('재귀에 의한 피보나치 수열');
console.log(resultRecursive === resultIterative);
})();
컴퓨터에서 시퀀스의 30번째 항목을 계산하는 이 특별한 경우, 반복 알고리즘을 사용하면 약 200배 더 짧은 시간에 결과를 얻을 수 있습니다.
그러나 재귀 알고리즘에는 꼬리 재귀라는 전략을 사용하면 훨씬 더 효율적으로 작동하는 것으로 밝혀진 한 가지 수정할 수 있는 점이 있습니다. 즉, 이전 반복에서 얻은 결과를 더 깊은 호출을 위한 인수를 사용하여 전달합니다. 이렇게 하면 필요한 호출 횟수를 줄일 수 있으므로 결과적으로 결과의 속도가 빨라집니다. 그에 따라 코드를 수정해 봅시다!
(() => {
const fiboIterative = (n) => {
let [a, b] = [0, 1];
for (let i = 0; i { {
if(n === 0) {
먼저 반환합니다;
}
return fiboTailRecursive(n - 1, second, first + second);
};
console.time('for 루프에 의한 피보나치 수열');
const resultIterative = fiboIterative(30);
console.timeEnd('for 루프에 의한 피보나치 수열');
console.time('꼬리 재귀에 의한 피보나치 수열');
const resultRecursive = fiboTailRecursive(30);
console.timeEnd('꼬리 재귀에 의한 피보나치 수열');
console.log(resultRecursive === resultIterative);
})();
여기서 제가 예상하지 못했던 일이 발생했는데, 꼬리 재귀 알고리즘의 결과가 경우에 따라 반복 알고리즘보다 거의 두 배 빠른 결과(시퀀스의 30번째 요소 계산)를 제공할 수 있었습니다. 이것이 v8의 꼬리 재귀 최적화 때문인지 아니면 이 특정 반복 횟수에 대한 for 루프 최적화가 부족했기 때문인지는 확실하지 않지만 결과는 분명합니다 - 꼬리 재귀가 승리합니다.
기본적으로 for 루프는 하위 수준의 계산 활동에 훨씬 적은 추상화를 부과하고 기본적인 컴퓨터 연산에 더 가깝다고 할 수 있기 때문에 이것은 이상합니다. 그러나 결과는 부인할 수 없습니다. 영리하게 설계된 재귀가 반복보다 더 빠른 것으로 밝혀졌습니다.
마지막 단락에서는 애플리케이션 속도에 큰 영향을 미칠 수 있는 작업 수행 방법에 대해 간략히 설명하고자 합니다. 아시다시피 JavaScript 는 이벤트 루프 메커니즘으로 모든 작업을 유지하는 단일 스레드 언어입니다. 반복해서 실행되는 사이클과 이 사이클의 모든 단계는 지정된 전용 작업에 관한 것입니다.
이 루프를 빠르게 만들고 모든 사이클이 차례를 기다리는 시간을 줄이려면 모든 요소가 가능한 한 빨라야 합니다. 메인 스레드에서 긴 연산을 실행하지 마세요. 너무 오래 걸리는 연산의 경우 이러한 연산을 WebWorker로 옮기거나 비동기적으로 실행되는 부분으로 분할하세요. 일부 작업의 속도가 느려질 수 있지만 마우스 이동이나 보류 중인 HTTP 요청 처리와 같은 IO 작업을 포함한 전체 JS 에코시스템의 속도가 향상됩니다.
결론적으로, 앞서 언급했듯이 알고리즘을 선택하여 절약할 수 있는 밀리초를 쫓는 것은 경우에 따라 무의미한 것으로 판명될 수 있습니다. 반면에 원활한 실행과 빠른 결과가 필요한 애플리케이션에서 이러한 사항을 무시하는 것은 애플리케이션에 치명적일 수 있습니다. 어떤 경우에는 알고리즘의 속도 외에도 추상화가 적절한 수준에서 작동하는지 한 가지 추가 질문을 해야 합니다. 코드를 읽는 프로그래머가 문제 없이 이해할 수 있을까요?
유일한 방법은 성능, 구현의 용이성, 적절한 추상화 사이의 균형을 유지하고 알고리즘이 소량의 데이터와 대량의 데이터 모두에서 올바르게 작동한다는 확신을 갖는 것입니다. 알고리즘을 설계할 때 다양한 경우를 고려하고 평균 실행 시 최대한 효율적으로 작동하도록 정렬하면 됩니다. 또한 테스트를 설계하는 것이 좋습니다. 알고리즘이 작동 방식에 관계없이 다양한 데이터에 대해 적절한 정보를 반환하는지 확인하세요. 메소드의 입력과 출력이 모두 읽기 쉽고 명확하며 수행 중인 작업을 정확히 반영할 수 있도록 올바른 인터페이스를 관리하세요.
앞서 위 예제에서 알고리즘 속도 측정의 신뢰성에 대해 다시 언급하겠다고 말씀드렸습니다. console.time으로 측정하는 것은 그다지 신뢰할 수 있는 방법은 아니지만 표준 사용 사례를 가장 잘 반영합니다. 어쨌든 아래 벤치마크는 특정 시간에 주어진 활동을 반복하고 루프에 v8 최적화를 사용하기 때문에 일부 벤치마크는 단일 실행과는 약간 다르게 보입니다.
여기까지입니다 - 해킹을 즐기세요!
자세히 읽어보세요: