Outsider's Dev Story: JavaScript 카테고리 글 목록https://blog.outsider.ne.kr/Stay Hungry. Stay Foolish. Don't Be Satisfied.2024-03-15T10:12:17+09:00Textcube 1.10.7 : Tempo primoAbortController로 요청 취소하기Outsiderhttps://blog.outsider.ne.kr/16022022-06-26T18:42:14+09:002022-06-26T18:42:14+09:00<p><a href="https://developer.mozilla.org/ko/docs/Web/API/AbortController">AbortController</a>는 웹 요청을 취소할 수 있게 해주는 기능이다. 보통 웹에서 요청을 일단 보내면 이후에 필요 없어져도 취소할 방법이 없어서 그냥 요청은 그대로 두고 응답받은 내용을 사용 안 하는 식으로만 구현했다. 간단한 HTTP 요청을 응답이 꽤 빠르기 때문에 괜찮을 수도 있지만 무거운 요청의 경우는 불필요한 네트워크 트래픽을 낭비하게 되거나 연결을 차지하고 있으므로 취소하는 것이 좋다.</p>
<p>아직 실험적 기능이지만 현재 IE를 제외한 <a href="https://developer.mozilla.org/ko/docs/Web/API/AbortController#%EB%B8%8C%EB%9D%BC%EC%9A%B0%EC%A0%80_%ED%98%B8%ED%99%98%EC%84%B1">모든 메이저 브라우저에서 지원</a>하고 있고 IE는 지난 15일 지원이 완전히 종료되었기 때문에 사용하기가 훨씬 수월해진 상태이다.<br />
<br></p>
<h1>AbortController 사용방법</h1>
<p>AbortController는 <code>new AbortController()</code>를 사용해서 생성해서 사용하고 생성된 인스턴스에서 <code>signal</code> 프로퍼티로 <a href="https://developer.mozilla.org/ko/docs/Web/API/AbortSignal"><code>AbortSignal</code></a>을 받아서 <code>fetch()</code> 같은 DOM 요청과 통신할 수 있다.</p>
<pre class="line-numbers"><code class="language-js">const controller = new AbortController();
const signal = controller.signal;
signal.addEventListener('abort', () => {
console.log('Aborted? ', signal.aborted);
})
setTimeout(() => {
controller.abort();
}, 100);
</code></pre>
<p><code>controller.abort()</code>를 실행하면 <code>controller.signal</code>이 <code>abort</code> 이벤트를 발생시키고 이 이벤트가 발생했다는 의미로 <code>controller.signal.aborted</code>가 <code>true</code>가 된다. 이를 실행하면 다음과 같이 출력된다.</p>
<p style="text-align: center;"><img src="//blog.outsider.ne.kr/attach/1/8015018750.png" width="750" height="27" alt="Console에 Aborted? true가 출력됨" title="" /></p>
<p><br></p>
<h1>브라우저에서 <code>AbortController</code>로 <code>fetch()</code> 취소하기</h1>
<p><code>fetch()</code>로 <a href="https://httpbin.org/">httpbin</a>을 통해 5초 걸리는 요청을 보내는 예제를 작성했다.</p>
<pre class="line-numbers"><code class="language-html"><!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Abort Controller Demo</title>
</head>
<body>
<script>
const URL = 'https://httpbin.org/delay/5';
fetch(URL)
.then((res) => {
console.log(`Received: ${res.status}`);
}).catch((e) => {
console.error(e);
});
</script>
</body>
</html>
</code></pre>
<p>당연히 5초 뒤에 응답 로그가 출력된다.</p>
<p style="text-align: center;"><img src="//blog.outsider.ne.kr/attach/1/7128454462.png" width="750" height="343" alt="개발자 도구에 5초 걸리는 요청을 받은 화면" title="" /></p>
<p>이 응답을 받으려면 5초가 걸리지만 이후 비즈니스 로직에 따라 이 요청을 받을 필요가 없어졌다면<a href="https://developer.mozilla.org/ko/docs/Web/API/AbortController">AbortController</a>를 이용해서 취소할 수 있다.</p>
<p>간단한 GET 요청은 <code>fetch(resource)</code>처럼 간단하게 URL을 지정해서 사용하지만 <code>fetch(resource, init)</code>처럼 두 번째 파라미터로 초기화 옵션을 줄 수 있다. 이 <a href="https://developer.mozilla.org/en-US/docs/Web/API/fetch#parameters">파라미터 문서</a>를 보면 <code>signal</code>을 옵션으로 받을 수 있는 걸 알 수 있다.</p>
<blockquote>
<p>An AbortSignal object instance; allows you to communicate with a fetch request and abort it if desired via an AbortController.</p>
</blockquote>
<p>즉, <code>AbortSignal</code>의 인스턴스를 받고 <code>AbortController</code>를 이용해서 원할 때 <code>fetch</code> 요청을 취소할 수 있다.(위 예제에서 <code><script></code>부분만 작성했다.)</p>
<pre class="line-numbers"><code class="language-js">const controller = new AbortController();
setTimeout(() => {
controller.abort();
}, 2_000);
const URL = 'https://httpbin.org/delay/5';
fetch(URL, { signal: controller.signal })
.then((res) => {
console.log(`Received: ${res.status}`);
}).catch((err) => {
if (err.name === 'AbortError') {
console.error('Aborted: ', err);
return;
}
throw err;
});
</code></pre>
<p>앞에서 <code>AbortController</code>를 살펴본 것처럼 5초 걸리는 GET 요청을 <code>fetch()</code>로 보낸 후 2초 후에 <code>controller.abort()</code>를 실행해서 <code>fetch()</code>를 취소시켰다. <code>fetch()</code>가 취소되면 <code>AbortError</code>라는 <code>DOMException</code>을 던지기 때문에 취소된 오류와 다른 오류를 구분해서 처리할 수 있다.</p>
<p style="text-align: center;"><img src="//blog.outsider.ne.kr/attach/1/6803201138.png" width="750" height="344" alt="5초 걸리는 요청이 2초만에 취소되고 오류가 출력됨" title="" /></p>
<p>위 화면을 보면 <code>https://httpbin.org/delay/5</code> 요청이 2초 후에 취소된 것을 볼 수 있다.<br />
<br></p>
<h1><code>AbortSignal.timeout</code></h1>
<p>위에서 예제를 위해 특정 시간 뒤에 <code>abort()</code>가 호출되도록 작성했지만, 요청에 타임아웃을 지정하려면 <a href="https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/timeout"><code>AbortSignal.timeout</code></a>를 사용하는 게 더 간편하다. 다만, 문서의 호환 표처럼 아직은 Firefox에서만 제대로 지원하고 있고 <a href="https://chromestatus.com/feature/5768400507764736">Chrome은 103버전에서 지원할 예정</a>이다.(현재 테스트할 때는 Chrome 102이다.)</p>
<p style="text-align: center;"><img src="//blog.outsider.ne.kr/attach/1/8177741791.png" width="750" height="387" alt="AbortSignal.timeout 브라우저 호환표에 데스크탑에서는 Firefox만 지원한다" title="" /></p>
<p><code>AbortSignal.timeout</code>이 정적 메소드이므로 앞에서처럼 <code>AbortController</code>를 생성할 필요 없이 간단히 타임아웃을 지정할 수 있다.</p>
<pre class="line-numbers"><code class="language-js">const URL = 'https://httpbin.org/delay/5';
fetch(URL, { signal: AbortSignal.timeout(2_000) })
.then((res) => {
console.log(`Received: ${res.status}`);
}).catch((err) => {
if (err.name === 'AbortError') {
console.error('Aborted: ', err);
return;
}
throw err;
});
</code></pre>
<p>이렇게 하면 앞의 예제와 동일하게 동작한다. 다만 <a href="https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/timeout">문서에 따르면</a> 이 경우에는 <code>AbortError</code> 대신 <code>TimeoutError</code>이 발생해야 하는데 내가 테스트했을 때는 <code>AbortError</code>가 발생했다. Chrome 105에서 테스트해도 동일한 걸 보면 Firefox의 문제는 아닌 것 같은데 왜 그런지는 아직 찾지 못했다.</p>
<p>어쨌든 이 기능을 브라우저에서 쓰려면 다른 브라우저도 지원할 때까지 좀 더 기다려야 할 것 같다.<br />
<br></p>
<h1>Node.js Stream에서 AbortController 사용하기</h1>
<p><code>AbortController</code>가 <code>fetch</code>에만 사용하도록 만들어 진 것이 아니라 <code>AbortController</code>를 지원하는 곳이면 어디든지 사용할 수 있다. 대표적으로 Node.js의 <code>Stream</code>이 있다.</p>
<p>간단히 파일을 스트림으로 복사하는 예제를 만들어 보자. 약간 큰 파일이 필요해서 <code>mkfile -n 1g 1GB.file</code> 명령어로 1GB 용량의 파일을 생성했고 이를 읽어서 <code>output.file</code> 파일로 복사하는 예제이다.</p>
<pre class="line-numbers"><code class="language-js">// stream-file.js
const { createReadStream, createWriteStream } = require('node:fs');
const input = createReadStream('./1GB.file');
const output = createWriteStream('./output.file');
input.pipe(output);
output.on('error', (err) => {
console.error(err);
});
output.on('finish', () => {
console.log('Finished');
});
</code></pre>
<p>위 파일을 실행하면 아래처럼 <code>Finished</code>가 출력되고 1GB의 <code>output.file</code>이 생성된 것을 볼 수 있다.</p>
<pre class="line-numbers"><code class="language-bash">$ node stream-file.js
Finished
</code></pre>
<p>여기에 AbortController를 적용해보자. Node.js에서도 15.4.0부터 <a href="https://nodejs.org/dist/latest-v16.x/docs/api/globals.html#class-abortcontroller">AbortController</a>를 전역으로 제공하기 때문에 그대로 사용할 수 있다.</p>
<pre class="line-numbers"><code class="language-js">// stream-abortcontroller.js
const { createReadStream, createWriteStream } = require('node:fs');
const { addAbortSignal } = require('node:stream');
const controller = new AbortController();
setTimeout(() => {
controller.abort();
}, 300);
const input = createReadStream('./1GB.file');
const output = addAbortSignal(
controller.signal,
createWriteStream('./output.file')
);
input.pipe(output);
output.on('error', (err) => {
if (err.name === 'AbortError') {
console.error('Aborted: ', err);
return;
}
throw err;
});
output.on('finish', () => {
console.log('Finished');
});
</code></pre>
<p><code>AbortController</code>의 사용 방법은 같지만 <code>fetch</code>처럼 <code>stream</code>에 시그널을 전달해 주어야 하므로 <code>stream</code>에서는 <a href="https://nodejs.org/dist/latest-v16.x/docs/api/stream.html#streamaddabortsignalsignal-stream"><code>stream.addAbortSignal(signal, stream)</code></a>를 제공한다. 여기서는 새로운 <code>AbortController</code> 인스턴스를 생성해서 300ms 후에 <code>abort()</code>가 호출되도록 했고 <code>addAbortSignal</code>를 이용해서 이 컨트롤러의 시그널과 <code>createWriteStream('./output.file')</code>를 지정해서 해당 스트림에 시그널이 추가되도록 했다.</p>
<pre class="line-numbers"><code class="language-js">$ node stream-abortcontroller.js
Aborted: AbortError: The operation was aborted
at AbortSignal.onAbort (node:internal/streams/add-abort-signal:37:20)
at AbortSignal.[nodejs.internal.kHybridDispatch] (node:internal/event_target:643:20)
at AbortSignal.dispatchEvent (node:internal/event_target:585:26)
at abortSignal (node:internal/abort_controller:284:10)
at AbortController.abort (node:internal/abort_controller:315:5)
at Timeout._onTimeout (/Users/outsider/Dropbox/projects/temp/abort-controller/stream.js:6:16)
at listOnTimeout (node:internal/timers:559:17)
at processTimers (node:internal/timers:502:7) {
code: 'ABORT_ERR'
}
</code></pre>
<p>이를 실행하면 Abort가 잘 실행된 것을 확인할 수 있다.</p>
<p>Node.js은 16.14.0부터 <code>AbortSignal.timeout(delay)</code>를 지원하기 때문에 이 버전 이상이라면 타임아웃만을 위해서는 앞에서 본 것처럼 <code>AbortController</code>를 생성할 필요 없이 <code>AbortSignal.timeout()</code>을 사용할 수 있다.</p>
<pre class="line-numbers"><code class="language-js">// stream-timeout.js
// 생략
const output = addAbortSignal(
AbortSignal.timeout(300),
createWriteStream('./output.file')
);
// 생략
</code></pre>
<p>앞뒤 부분은 똑같아서 생략했지만, 이 코드도 똑같이 동작한다.</p>
<pre class="line-numbers"><code class="language-js">$ node stream-timeout.js
Aborted: AbortError: The operation was aborted
at AbortSignal.onAbort (node:internal/streams/add-abort-signal:37:20)
at AbortSignal.[nodejs.internal.kHybridDispatch] (node:internal/event_target:643:20)
at AbortSignal.dispatchEvent (node:internal/event_target:585:26)
at abortSignal (node:internal/abort_controller:284:10)
at Timeout._onTimeout (node:internal/abort_controller:112:7)
at listOnTimeout (node:internal/timers:559:17)
at processTimers (node:internal/timers:502:7) {
code: 'ABORT_ERR'
}
</code></pre>
<p><strong><a href="https://blog.outsider.ne.kr/1602?commentInput=true#entry1602WriteComment">댓글 쓰기</a></strong></p>실제 웹사이트에서 Web Vitals 디버깅하기Outsiderhttps://blog.outsider.ne.kr/15482021-04-28T04:23:31+09:002021-04-28T04:23:31+09:00<p>web.dev에 올라온 <a href="https://web.dev/debug-web-vitals-in-the-field/">Debug Web Vitals in the field</a>의 번역이다. 해당 글에 명시된 <a href="https://creativecommons.org/licenses/by/4.0/">Creative Commons Attribution 4.0 라이센스</a> 하에 번역하고 이 글도 같은 라이센스로 배포한다.</p>
<hr />
<p>구글은 현재 Web Vitals를 측정하고 디버깅하는 두 가지 범주의 도구를 제공하고 있다.</p>
<ul>
<li>실험실 도구(Lab tools): 다양한 조건(예를 들어 느린 네트워크나 저가 휴대폰)을 흉내 내는 시뮬레이션 된 환경에서 페이지를 로드하는 Lighthouse 같은 도구를 말한다.</li>
<li>현장 도구(Field tools): 실제 사용자로부터 Chrome이 수집한 데이터에 기반을 둔 <a href="https://developers.google.com/web/tools/chrome-user-experience-report">크롬 사용자 경험 보고서(Chrome User Experience Report)</a>(CrUX) 같은 도구를 말한다.(<a href="https://developers.google.com/speed/pagespeed/insights/">PageSpeed Insights</a>나 <a href="https://support.google.com/webmasters/answer/9205520">Search Console</a> 같은 도구에서 보고된 현장의 데이터는 CrUX의 데이터이다)</li>
</ul>
<p>현장의 도구가 더 정확한 데이터(실제 사용자의 경험이 실제로 보여주는 데이터)를 제공하지만, 실험실 도구가 문제를 찾아서 수정하기에 더 좋은 경우가 있다.</p>
<p>CrUX 데이터는 페이지의 실제 성능을 더 잘 보여주지만 CrUX 점수를 알아도 성능을 어떻게 개선하는지 알게 되지는 않을 것이다.</p>
<p>반면 Lighthouse는 문제를 찾아서 어떻게 개선하면 되는지 구체적으로 제안한다. 하지만 Lighthouse는 페이지를 로딩할 때 발견되는 성능 이슈에 대해서만 제안하고 사용자가 페이지에서 스크롤 하거나 버튼을 클릭하는 상호작용에 의해서면 발생하는 문제는 탐지하지 않는다.</p>
<p>이로 인해 중요한 질문이 생긴다. <strong>현장에서 실제 사용자의 Web Vitals 매트릭 데이터에 대한 디버그 정보를 어떻게 알아낼 수 있는가?</strong></p>
<p>이 글에서는 현재 핵심 Web Vitals 매트릭의 추가적인 디버깅 정보를 수집하는데 어떤 API를 사용할 수 있는지 설명하고 기존의 분석 도구(Analytics tools)에서 이 데이터를 어떻게 가져올 수 있는지에 관한 아이디어를 제공할 것이다.</p>
<h1>속성(attribution)과 디버깅을 위한 API</h1>
<h2>CLS</h2>
<p>모든 핵심 Web Vitals 매트릭 중에서 <a href="https://web.dev/cls/">CLS</a>는 실제 웹사이트에서 디버그 정보를 수집하는 것이 가장 중요한 매트릭 중 하나일 것이다. CLS는 페이지의 전체 수명에 걸쳐서 수집되므로 사용자가 페이지에서 상호작용하는 방식(얼마나 스크롤하고 무엇을 클릭하는지 등)이 레이아웃의 변경과 어떤 요소가 변경되는지에 상당한 영향을 줄 수 있다.</p>
<p><a href="https://web.dev/measure/">web.dev/measure</a> URL에 대한 PageSpeed Insights의 보고서를 보자.</p>
<p style="text-align: center;"><img src="//blog.outsider.ne.kr/attach/1/3017673995.png" width="750" height="551" alt="여러 CLS 값을 가진PageSpeed Insights 보고서" title="" /></p>
<p>실험(Lighthouse)에서 보고된 CLS 값을 실제 웹사이트(CrUX 데이터)에서 보고된 CLS 값과 비교하면 아주 다르다. <a href="https://web.dev/measure/">web.dev/measure</a> 페이지의 Lighthouse에서는 테스트 되지 않는 많은 상호작용 콘텐츠가 있다는 점을 생각하면 이해할 수 있다.</p>
<p>하지만 사용자의 상호작용이 실사용 데이터에 영향을 준다고 이해했더라고 페이지의 어떤 요소가 움직여서 75번째 퍼센타일에서 0.45의 점수가 되었는지 알아야 한다.</p>
<p><a href="https://web.dev/debug-layout-shifts/#layoutshiftattribution">LayoutShiftAttribution</a> 인터페이스로 이를 알 수 있다.</p>
<h3>레이아웃 이동 속성 가져오기</h3>
<p><a href="https://web.dev/debug-layout-shifts/#layoutshiftattribution">LayoutShiftAttribution</a> 인터페이스는 <a href="https://wicg.github.io/layout-instability">레이아웃 불안정성 API(Layout Instability API)</a>가 발생시킨 각 <code>layout-shift</code> 항목에 노출된다.</p>
<p>이 두 가지 인터페이스에 관한 자세한 설명은 <a href="https://web.dev/debug-layout-shifts/#layoutshiftattribution">레이아웃 변경 디버깅</a>을 참고해라. 이 글의 목적에 따라 개발자가 알아야 하는 점은 페이지에서 발생하는 모든 레이아웃 변경과 어떤 요소가 움직였는지를 관측할 수 있다는 것이다.</p>
<p>다음은 각 레이아웃 변경과 변경된 요소를 로깅 하는 예제 코드이다.</p>
<pre class="line-numbers"><code class="language-js">new PerformanceObserver((list) => {
for (const {value, startTime, sources} of list.getEntries()) {
// 이동량과 다른 항목 정보를 기록한다.
console.log('Layout shift:', {value, startTime});
if (sources) {
for (const {node, curRect, prevRect} of sources) {
// 움직인 요소를 기록한다.
console.log(' Shift source:', node, {curRect, prevRect});
}
}
}
}).observe({type: 'layout-shift', buffered: true});
</code></pre>
<p>발생하는 모든 레이아웃의 변경을 측정하고 분석 도구에 데이터를 보내는 것은 실용적이지 않다. 하지만 모든 변경을 모니터링해서 가장 안 좋은 변경을 추적한 뒤 이러한 것의 정보만 보고할 수 있다.</p>
<p>사용자에게 발생하는 모든 레이아웃 변경을 찾아내서 고치는 것은 목표가 아니다. 목표는 가장 많은 사용자에게 영향을 미치는 변경을 찾아내서 페이지 CLS의 75번째 퍼센타일을 크게 개선하는 것이다.</p>
<p>또한, 변경이 생길 때마다 가장 큰 소스 요소를 계산할 필요가 없고 분석 도구에 CLS값을 보낼 준비가 되었을 때만 계산하면 된다.</p>
<p>다음 코드는 CLS에 기여하는 <code>layout-shift</code> 항목의 목록을 받아서 가장 크게 변경된 소스 요소를 반환한다.</p>
<pre class="line-numbers"><code class="language-js">function getCLSDebugTarget(entries) {
const largestShift = entries.reduce((a, b) => {
return a && a.value > b.value ? a : b;
});
if (largestShift && largestShift.sources) {
const largestSource = largestShift.sources.reduce((a, b) => {
return a.node && a.previousRect.width * a.previousRect.height >
b.previousRect.width * b.previousRect.height ? a : b;
});
if (largestSource) {
return largestSource.node;
}
}
}
</code></pre>
<p>가장 큰 변화를 일으키는 요소를 찾았다면 분석 도구에 이를 보고할 수 있다.</p>
<p>해당 페이지에서 CLS에 가장 크게 영향을 주는 요소는 사용자마다 다르지만 모든 사용자에게서 수집한다면 가장 많은 사용자에게 영향을 미치는 요소의 이동 목록을 만들 수 있을 것이다.</p>
<p>이러한 요소 이동의 근본 원인을 찾아내서 수정하면 분석 코드가 그다음으로 작은 이동 요소를 "가장 나쁜" 이동으로 보고하기 시작할 것이다. 결국, 보고된 페이지의 모든 이동이 <a href="https://web.dev/cls/#what-is-a-good-cls-score">0.1의 "좋은" 기준</a>안에 들게 될 것이다.</p>
<p>가장 크게 이동하는 요소와 함께 보고하면 유용한 다른 메타데이터로는 다음의 데이터가 있다.</p>
<ul>
<li>가장 큰 이동의 시간</li>
<li>가장 큰 이동이 일어날 때의 URL 경로(SPA처럼 URL을 동적으로 바꾸는 사이트에서)</li>
</ul>
<h2>LCP</h2>
<p>실제 웹사이트에서 LCP를 디버그할 때 필요한 주요 정보로는 해당 페이지를 로드할 때 어떤 요소가 가장 큰 요소인가가 있다.(이를 LCP 후보 요소라 한다)</p>
<p>같은 페이지에서조차 LCP 후보 요소는 사용자마다 다를 수도 있다. 이는 당연히 가능하고 일반적이기까지 하다.</p>
<p>다음과 같은 이유 때문이다.</p>
<ul>
<li>사용자 기기는 다른 스크린 해상도를 가지므로 다른 페이지 레이아웃으로 인해 뷰포트 내에 다른 요소가 보인다.</li>
<li>사용자가 항상 가장 상위로 스크롤 된 상태로 페이지를 로드하는 것이 아니다. 때로는 링크는 프레그먼트 식별자나 텍스트 프레그먼트를 가지고 있어서 페이지의 어떤 스크롤 위치에서도 페이지가 로드되거나 표시될 수 있다.</li>
<li>콘텐츠가 현재 유저에 맞게 개인화되어 LCP 후보 요소가 사용자마다 다를 수 있다.</li>
</ul>
<p>이는 특정 페이지에서 어떤 요소가 공통적인 LCP 후보 요소라고 가정할 수 없다는 의미이다. 실제 사용자의 동작에 기반을 두고 측정해야 한다.</p>
<h3>LCP 후보 요소 찾기</h3>
<p>LCP 시간 값을 찾을 때 사용했던 것과 같은 <a href="https://wicg.github.io/largest-contentful-paint/">Largest Contentful Paint API</a>를 사용해서 JavaScript로 LCP 후보 요소를 찾을 수 있다.</p>
<p><code>largest-contentful-paint</code> 항목의 목록을 받으면 마지막 항목을 검사해서 현재 LCP 후보 요소를 알아낼 수 있다.</p>
<pre class="line-numbers"><code class="language-js">function getLCPDebugTarget(entries) {
const lastEntry = entries[entries.length - 1];
return lastEntry.element;
}
</code></pre>
<p><strong>주의</strong>: <a href="https://web.dev/lcp/">LCP 매트릭 문서</a>에 나와 있듯이 페이지가 로드되면서 LCP 후보 요소가 바뀔 수 있으므로 "최종" LCP 후보 요소를 찾아내려면 추가 작업이 필요하다. "최종" LCP 후보 요소를 찾아서 측정하는 가장 쉬운 방법은 <a href="https://web.dev/debug-web-vitals-in-the-field/#usage-with-the-web-vitals-javascript-library">아래 예제</a>에 나와 있듯이 <a href="https://github.com/GoogleChrome/web-vitals/">web-vitals</a> JavaScript 라이브러리를 사용하는 것이다.</p>
<p>LCP 후보 요소를 알고 나면 분석 도구에 매트릭 값과 함께 보낼 수 있다. CLS와 마찬가지로 이는 어떤 요소를 가장 먼저 최적화해야 하는지 결정하는 데 도움이 된다.</p>
<p>LCP 후보 요소와 함께 다른 메타데이터도 수집하면 유용할 것이다.</p>
<ul>
<li>이미지 소스 URL(LCP 후보 요소가 이미지인 경우)</li>
<li>텍스트 폰트 패밀리(LCP 후보 요소가 텍스트고 페이지가 웹 폰트를 사용하는 경우)</li>
</ul>
<h2>FID</h2>
<p>실제 웹사이트에서 FID를 디버그하려면 FID가 첫 입력 이벤트 시간의 <a href="https://web.dev/fid/#fid-in-detail">지연 시간만</a>을 측정한다는 점이 중요하다. 즉, 사용자가 무엇과 상호작용하는지는 해당 시기에 메인 스레드에서 일어나는 다른 작업만큼 중요하지 않다는 뜻이다.</p>
<p>예를 들어 서버 사이드 렌더링(SSR)을 지원하는 많은 JavaScript 애플리케이션은 사용자 입력에 반응하기 전에 화면에 렌더링 될 수 있는 정적 HTML을 제공한다. 즉, 이는 콘텐츠가 상호작용할 수 있게 만드는데 필요한 JavaScript의 로딩이 끝나기 전이다.</p>
<p>이러한 유형의 애플리케이션에서는 첫 입력이 <a href="https://en.wikipedia.org/wiki/Hydration_(web_development)">하이드레이션</a> 이전에 일어나는지 이후에 일어나는지를 아는 것이 아주 중요하다. 하이드레이션이 완료되기 전에 많은 사람이 페이지와 상호작용을 시도한다는 것을 알게 된다면 상호작용을 할 수 있는 것처럼 보여주는 대신 비활성 상태나 로딩 상태로 페이지를 렌더링하는 것이 좋다.</p>
<p>애플리케이션 프레임워크가 하이드레이션 타임 스탬프를 노출한다면 이를 <code>first-input</code> 항목의 타임 스탬프와 비교해서 첫 입력이 하이드레이션 이전에 발생했는지 이후에 발생했는지 쉽게 알 수 있다.. 프레임워크가 하이드레이션 타임 스탬프를 노출하지 않거나 하이드레이션을 전혀 사용하지 않는다면 입력이 JavaScript의 로딩이 끝나기 전에 일어났는지 후에 일어났는지가 유용한 신호로 사용할 수 있다.</p>
<p>동기 스크립트, 지연 스크립트, (정적으로 임포트된 모든 모듈을 포함한) 모듈 스크립트를 로드하려고 기다리는 것을 포함해서 페이지의 HTML이 완전히 로드되고 파싱되면 <code>DOMContentLoaded</code> 이벤트가 발생한다. 그러므로 언제 FID가 발생하는지 알기 위해 해당 이벤트의 시기를 이용해서 비교할 수 있다.</p>
<p>다음 코드는 <code>first-input</code> 항목을 받아서 <code>DOMContentLoaded</code> 이벤트가 끝나기 전에 첫 입력이 발생하면 <code>true</code>를 반환한다.</p>
<pre class="line-numbers"><code class="language-js">function wasFIDBeforeDCL(fidEntry) {
const navEntry = performance.getEntriesByType('navigation')[0];
return navEntry && fidEntry.startTime < navEntry.domContentLoadedEventStart;
}
</code></pre>
<p><strong>페이지가 JavaScript를 로드하려고 <code>async</code> 스크립트나 동적 <code>import()</code>를 사용한다면 <code>DOMContentLoaded</code> 이벤트는 유용한 신호가 아닐 수 있다. 대신 <code>load</code> 이벤트의 사용하거나 실행하는 데 시간이 걸리는 특정 스크립트가 있다면 해당 스크립트의 <a href="https://developer.mozilla.org/en-US/docs/Web/API/Resource_Timing_API/Using_the_Resource_Timing_API">Resource Timing</a> 항목을 사용할 수 있다.</strong></p>
<h3>FID 대상 요소 찾아내기</h3>
<p>또 하나의 잠재적으로 유용한 디버그 신호는 상호작용하는 요소이다. 상호작용하는 요소 자체는 FID에 영향을 주지 않지만(FID는 이벤트 시간의 지연 부분일 뿐이라는 것을 생각해라) 사용자가 어떤 요소와 상호작용하는 것이 알면 FID를 얼마나 잘 개선할 수 있는지 결정하는 데 유용할 것이다.</p>
<p>예를 들어 사용자의 첫 상호작용의 대부분이 특정 요소에서 발생한다면 해당 요소에 필요한 JavaScript 코드를 HTML에 인라인으로 추가하고 나머지는 지연 로딩하는 걸 고려해 볼 수 있다.</p>
<p>첫 입력 이벤트와 관련된 요소를 찾으려면 <code>first-input</code> 항목의 <code>target</code> 프로퍼티를 참조하면 된다.</p>
<pre class="line-numbers"><code class="language-js">function getFIDDebugTarget(entries) {
return entries[0].target;
}
</code></pre>
<p>FID 타켓 요소와 함께 수집하면 유용한 메타데이터로는 다음의 데이터들이 있다.</p>
<ul>
<li>이벤트의 타입(<code>mousedown</code>, <code>keydown</code>, <code>pointerdown</code> 등등)</li>
<li>첫 입력이 발생할 때 실행되는 긴 태스크와 관련된 <a href="https://w3c.github.io/longtasks/#taskattributiontiming">Long Tasks 속성</a> 데이터.(페이지가 서드파티 스크립트를 로드할 때 유용하다)</li>
</ul>
<h1>web-vitals JavaScript 라이브러리의 사용방법</h1>
<p>위에서는 분석 도구에 보낼 데이터에 포함할 추가적인 디버깅 정보를 설명했다. 위의 각 예제에는 특정 Web Vitals 매트릭과 관련된 성능 항목을 사용해서 해당 매트릭에 영향을 주는 이슈를 디버그하는 데 도움이 될 수 있는 DOM 요소를 반환하는 코드가 나와 있다.</p>
<p>이러한 예제는 <a href="https://github.com/GoogleChrome/web-vitals">web-vitals</a> JavaScript 라이브러리와 잘 동작하도록 설계되었고 각 콜백 함수에 전달되는 <a href="https://github.com/GoogleChrome/web-vitals#api"><code>Metric</code></a> 객체에 성능 항목의 목록을 노출한다.</p>
<p>위에 나온 예제를 <code>web-vitals</code> 매트릭 함수와 합치면 다음과 같은 코드가 될 것이다.</p>
<pre class="line-numbers"><code class="language-js">import {getLCP, getFID, getCLS} from 'web-vitals';
function getSelector(node, maxLen = 100) {
let sel = '';
try {
while (node && node.nodeType !== 9) {
const part = node.id ? '#' + node.id : node.nodeName.toLowerCase() + (
(node.className && node.className.length) ?
'.' + Array.from(node.classList.values()).join('.') : '');
if (sel.length + part.length > maxLen - 1) return sel || part;
sel = sel ? part + '>' + sel : part;
if (node.id) break;
node = node.parentNode;
}
} catch (err) {
// Do nothing...
}
return sel;
}
function getLargestLayoutShiftEntry(entries) {
return entries.reduce((a, b) => a && a.value > b.value ? a : b);
}
function getLargestLayoutShiftSource(sources) {
return sources.reduce((a, b) => {
return a.node && a.previousRect.width * a.previousRect.height >
b.previousRect.width * b.previousRect.height ? a : b;
});
}
function wasFIDBeforeDCL(fidEntry) {
const navEntry = performance.getEntriesByType('navigation')[0];
return navEntry && fidEntry.startTime < navEntry.domContentLoadedEventStart;
}
function getDebugInfo(name, entries = []) {
// 일부 경우 항목이 없으므로(CLS가 0이거나 bfcache 복구 후의 LCP의 경우)
// 먼저 검사해야 한다.
if (entries.length) {
if (name === 'LCP') {
const lastEntry = entries[entries.length - 1];
return {
debug_target: getSelector(lastEntry.element),
event_time: lastEntry.startTime,
};
} else if (name === 'FID') {
const firstEntry = entries[0];
return {
debug_target: getSelector(firstEntry.target),
debug_event: firstEntry.name,
debug_timing: wasFIDBeforeDCL(firstEntry) ? 'pre_dcl' : 'post_dcl',
event_time: firstEntry.startTime,
};
} else if (name === 'CLS') {
const largestEntry = getLargestLayoutShiftEntry(entries);
if (largestEntry && largestEntry.sources) {
const largestSource = getLargestLayoutShiftSource(largestEntry.sources);
if (largestSource) {
return {
debug_target: getSelector(largestSource.node),
event_time: largestEntry.startTime,
};
}
}
}
}
// 항목이 없는 경우 기본 혹은 빈 파라미터를 반환한다.
return {
debug_target: '(not set)',
};
}
function sendToAnalytics({name, value, entries}) {
navigator.sendBeacon('/analytics', JSON.stringify({
name,
value,
...getDebugInfo(name, entries)
});
}
getLCP(sendToAnalytics);
getFID(sendToAnalytics);
getCLS(sendToAnalytics);
</code></pre>
<p>데이터를 보내는데 필요한 특정 형식은 분석 도구에 따라 다르겠지만 형식과 상관없이 위 코드는 필요한 데이터를 가져오는 데 충분하다.</p>
<p>또한, 위 코드는 (앞에서 언급하지 않은)<code>getSelector()</code> 함수를 포함하고 있는데 이 함수는 DOM 노드를 받아서 해당 DOM의 위치를 나타내는 CSS 셀렉터를 반환한다. 이는 분석 도구가 데이터의 길이 제한을 가진 경우 이벤트에서 최대 길이도 파라미터로 받는다.(기본값은 100자다)</p>
<h1>데이터를 보고하고 시각화하기</h1>
<p>Web Vitals 매트릭과 디버그 정보를 수집했다면 다음 단계는 전체 사용자의 패턴과 트랜드를 파악할 데이터를 수집하는 것이다.</p>
<p>위에서 얘기했듯이 사용자가 접하는 모든 이슈를 반드시 해결할 필요는 없다. 가장 많은 수의 사용자에게 영향을 주면서 핵심 Web Vitals 점수에 가장 부정적으로 영향을 주는 이슈를 먼저 해결하고자 할 것이다.</p>
<h2>Web Vitals 보고 도구</h2>
<p><a href="https://github.com/GoogleChromeLabs/web-vitals-report">Web Vitals 보고서</a> 도구를 사용한다면<br />
각 핵심 Web Vitals 매트릭에 관해 <a href="https://github.com/GoogleChromeLabs/web-vitals-report#debug-dimension">디버그 디멘션의 보고</a>하도록 최근에 업데이트된 것을 알 수 있다.</p>
<p>다음은 Web Vitals 보고서의 디버그 정보 섹션의 스크린숏으로 Web Vitals 리포트 도구 웹사이트의 데이터를 보여준다.</p>
<p style="text-align: center;"><img src="//blog.outsider.ne.kr/attach/1/4382593723.png" width="750" height="502" alt="디버깅 정보를 보여주는 Web Vitals 보고서" title="" /></p>
<p>위 데이터에서 <code>section.Intro</code> 요소의 이동이 페이지의 CLS에 가장 큰 영향을 주는 것을 볼 수 있다. 그러므로 해당 이동의 원인을 찾아서 고치면 점수에 가장 큰 개선을 할 수 있을 것이다.</p>
<h1>요약</h1>
<p>이 글이 기존의 성능 API를 사용해서 실제 웹사이트에서 사용자의 상호작용에 따른 핵심 Web Vitals 매트릭의 디버그 정보를 얻는 방법을 이해하는 데 도움이 되었기를 바란다. 여기서는 핵심 Web Vitals에 집중했지만 같은 개념을 JavaScript로 측정할 수 있는 모든 성능 매트릭을 디버깅할 때도 적용할 수 있다.</p>
<p>Google Analytics 사용자이면서 성능을 측정하고자 한다면 핵심 Web Vitals 매트릭의 디버그 정보 보고를 이미 지원하는 Web Vitals 보고서 도구가 좋은 시작점이 될 것이다.</p>
<p>만약 분석회사라서 분석 도구를 개선하고 사용자에게 더 많은 디버깅 정보를 제공하고자 한다면 여기서 얘기한 방법을 염두에 두더라도 이 아이디어에만 갇히지 말기를 바란다. 이 글은 모든 분석 도구에서 범용적으로 사용할 수 있도록 의도한 것이라 각 분석 도구는 더 많은 정보를 보고할 수 있을 것이다.</p>
<p>마지막으로 API에 기능이나 정보가 누락되어 이러한 매트릭을 디버깅하기 어렵다고 느낀다면 <a href="web-vitals-feedback@googlegroups.com">web-vitals-feedback@googlegroups.com</a>에 피드백을 주기 바란다.</p>
<p><strong><a href="https://blog.outsider.ne.kr/1548?commentInput=true#entry1548WriteComment">댓글 쓰기</a></strong></p>2captcha를 이용해서 사이트의 CAPTCHA 자동화로 처리하기Outsiderhttps://blog.outsider.ne.kr/15472021-04-26T22:09:53+09:002021-04-18T03:37:15+09:00<p><strong>이 글은 <a href="https://2captcha.com/?utm_medium=content&utm_source=outsider&utm_campaign=korea">2Captcha</a>로부터 비용을 받고 작성하는 글이다.</strong></p>
<p>광고성 글은 거의 안 쓰는 편이라 비용을 받고 블로그에 글을 쓰는 건 처음인데 서비스 자체가 종종 필요한 경우가 있어서 도움이 될 부분이 있어 보였기에 작성하기로 했다. 글을 받아서 올리는 건 아니고 2capthca에 대한 소개 글을 써달라는 요청만 받고 글의 구성이나 예제는 모두 직접 작성했다.<br />
<br></p>
<h1>CAPTCHA</h1>
<p>발음도 어려운 CAPTCHA는 "<strong>C</strong>ompletely <strong>A</strong>utomated <strong>P</strong>ublic <strong>T</strong>uring test to tell <strong>C</strong>omputers and <strong>H</strong>umans <strong>A</strong>part"의 약자로 사람과 컴퓨터를 구분하는 완전 자동화된 튜링 테스트를 말한다.</p>
<p>보통 로그인이나 회원 가입 같은 사용자 액션에 보통 봇이라고 부르는 자동화 프로그램을 막기 위해서 CAPTCHA를 넣고 일반적으로 컴퓨터는 읽기 어려운 문자를 보여주고 사람이 입력하게 한다.(때로는 사람도 읽기가 어렵다) 최근에는 다양한 CAPTCHA가 개발되어 여러 이미지 중에 선택하거나 비슷한 이미지를 고르거나 이미지를 회전시키는 등의 방법도 사용되고 있다.</p>
<p>CAPTCHA를 이용해서 자동화된 봇을 막는 이유도 있고 이를 뚫으려는 이유도 있다. 남의 사이트를 악의적으로 조작하는 것은 아니라고 하더라도 CAPTCHA 때문에 자동화를 못 한 경험이 쉽게 할 수 있다. 나 같은 경우도 사용하는 서비스에서 보고서 관련 API를 제공하지 않아서 매달 혹은 매주 사람이 수동으로 데이터를 정리해야 했기에 이를 자동화해보려고 했지만, CAPTCHA에 막혀서 제대로 하지 못한 기억이 있다.<br />
<br></p>
<h1><a href="https://2captcha.com/?utm_medium=content&utm_source=outsider&utm_campaign=korea">2Captcha</a></h1>
<p style="text-align: center;"><img src="//blog.outsider.ne.kr/attach/1/3777893615.jpg" width="750" height="404" alt="2Captcha 홈페이지" title="" /></p>
<p><a href="https://2captcha.com/?utm_medium=content&utm_source=outsider&utm_campaign=korea">2Captcha</a>는 이러한 Captcha를 해결해주는 유료 서비스다.</p>
<blockquote>
<p>2Captcha's main purpose is solving your CAPTCHAs in a quick and accurate way by human employees, but the service is not limited only to CAPTCHA solving.</p>
</blockquote>
<p><a href="https://2captcha.com/2captcha-api?utm_medium=content&utm_source=outsider&utm_campaign=korea">문서</a>를 보면 CAPTCHA를 사람이 해결해 주는 것으로 보인다. CAPTCHA 해결을 요청하면 사람이 이를 보고 직접 해결하고 그 처리된 값을 나한테 다시 알려주면 이를 이용해서 CAPTCHA를 해결할 수 있는 것이다. reCaptcha, hCaptcha, KeyCaptcha, RotateCaptcha등 다양한 CAPTCHA를 지원하고 여러 언어의 SDK도 제공하지만, HTTP API를 제공하므로 어떤 언어에서든 사용할 수 있다.</p>
<p>다음과 같은 과정으로 진행된다.</p>
<ol>
<li>해결할 CAPTCHA를 2captcha에 요청하면 응답으로 해당 요청의 ID를 알려준다.</li>
<li>이 요청 ID로 해당 작업이 완료되었는지 반복해서 확인한다.</li>
<li>20~50초 후 요청이 완료되면 응답 값을 알려주고 이 값을 이용해서 CAPTHCA를 풀 수 있다.</li>
</ol>
<p>그리고 2Captcha는 유료서비스이다.</p>
<p style="text-align: center;"><img src="//blog.outsider.ne.kr/attach/1/6588251213.jpg" width="750" height="519" alt="2Captcha의 CAPTCHA별 가격표" title="" /></p>
<p>CAPTCHA 종류에 따라 다른데 <a href="https://2captcha.com/2captcha-api?utm_medium=content&utm_source=outsider&utm_campaign=korea#rates">가격표</a>를 보면 많이 쓰는 reCaptcha가 1,000번 요청에 2.99달러이므로 크게 부담될 가격은 아니라고 생각한다.<br />
<br></p>
<h1>Walmart 로그인 데모</h1>
<p>Walmart는 로그인 시에 <a href="https://developers.google.com/recaptcha/docs/display">reCaptcha v2</a>를 사용하고 있다. 사실 서비스마다 CAPTCHA를 사용하는 패턴이 다르기에 적당한 예제를 찾는데도 꽤 오래 걸렸다. 많은 사이트가 CAPTHCA를 항상 보여주는 게 아니라(그런 사이트도 있지만...) 여러 조건을 검사하고 보여줬다 안 보여줬다 하고 봇을 막으로면 CAPTCHA에만 의존하는 것이 아니라 추가적인 방법도 함께 사용하기 때문에(폼의 DOM 변형을 준다거나 유저 에이전트로 차단한다거나...) 실제로 자동화를 하려면 이러한 부분도 다 찾아서 해결해야 한다.</p>
<p>여기서는 2captcha로 어떻게 reCaptcha v2를 해결하는지를 보여줄 것이고 실제로 사용하려면 대상 사이트의 패턴을 알아야 자동화를 할 수 있을 것이다.</p>
<p style="text-align: center;"><img src="//blog.outsider.ne.kr/attach/1/6072456334.jpg" width="500" height="764" alt="월마트 로그인 페이지" title="" /></p>
<p><a href="https://www.walmart.com/account/login">Walmart의 로그인 페이지</a>는 CAPTCHA를 요구한다. 주로 사용하는 브라우저에서는 보통 자동으로 넘어가지만, 시크릿 모드로 접속하면 다음과 같이 reCaptcha v2가 나타난다.</p>
<p style="text-align: center;"><img src="//blog.outsider.ne.kr/attach/1/7595094470.jpg" width="500" height="548" alt="로그인 했을 때 나오는 reCapthca 화면으로 I'm not a robot를 확인하라고 나온다" title="" /></p>
<p>"I'm not a robot"을 입력하면 보통 많이 보았을 여러 사진 중 버스를 고르라거나 신호등을 고르라거나 해서 이미지 경계에 살짝 버스가 걸리면 이 이미지도 선택해야 하나 말아야 하나 고민하게 만드는 과정이 진행된다.<br />
<br></p>
<h2>로그인 자동화</h2>
<p>단순히 CAPTCHA만 푸는 것이 아니라 로그인 자체를 완전히 자동화하기 위해 <a href="https://playwright.dev/">Playwright</a>를 사용했다. Playwright는 브라우저를 조작하는 API를 사용해서 자동화할 수 있게 하는 프로젝트고 Microsoft에서 만들었다. 주로 <a href="https://pptr.dev/">Puppeteer</a>를 사용했지만 궁금해서 이번에는 Playwright를 사용했다. 전체 예제는 <a href="https://github.com/outsideris/2captcha-example">GitHub에 올려두었다</a>.</p>
<pre class="line-numbers"><code class="language-js">const playwright = require('playwright');
const got = require('got');
const ID = process.env.ID;
const PASSWORD = process.env.PASSWORD;
</code></pre>
<p><code>playwright</code>를 사용했고 HTTP 라이브러리로 <a href="https://www.npmjs.com/package/got"><code>got</code></a>을 사용했다. 월마트에 로그인할 계정정보가 필요하므로 환경변수에서 가져오도록 했다.</p>
<pre class="line-numbers"><code class="language-js">const browser = await playwright.chromium.launch({});
const context = await browser.newContext();
const page = await context.newPage();
// login page
await page.goto('https://www.walmart.com/account/login');
</code></pre>
<p><code>playwright</code>를 사용해서 크롬 브라우저를 실행하고 월마트 로그인 페이지에 접속했다. 이 코드로는 헤드레스 크롬이 실행되고 실제 크롬으로 실행하려면 <code>playwright.chromium.launch({ headless: false })</code>처럼 <a href="https://playwright.dev/docs/api/class-browsertype?_highlight=launch#browsertypelaunchoptions">옵션</a>을 주어야 한다.</p>
<pre class="line-numbers"><code class="language-js">// enter email
await page.type('#email', ID, {delay: 100});
await page.screenshot({ path: 'img/id.png' });
// check password field in a form
const hasPassword = await page.$('#sign-in-form');
if (hasPassword) {
console.log('has password field')
// enter password
await page.type('#password', PASSWORD, {delay: 100});
} else {
console.log('no password field')
await page.click('#sign-in-with-email-validation [type=submit]');
// enter password
await page.type('#sign-in-password-no-otp', PASSWORD, {delay: 100});
// login
await page.click('#sign-in-with-password-form [type=submit]');
}
// login
await page.click('#sign-in-form [type=submit]');
</code></pre>
<p>로그인 페이지에서 이메일과 비밀번호를 입력하고 로그인 버튼을 누르는 코드이다.</p>
<p style="text-align: center;"><img src="//blog.outsider.ne.kr/attach/1/7881752395.jpg" width="354" height="537" alt="playwright로 이메일, 비밀번호를 입력한 화면" title="" /></p>
<p>이메일과 비밀번호가 잘 입력되는 것을 확인할 수 있다. 위 코드에서 <code>if (hasPassword) {}</code> 조건절이 있는 이유는 가끔 위 스크린숏과 달리 이메일만 입력하고 로그인 버튼을 누르면 패스워드 입력란이 나오는 UI가 등장한다. 이 부분도 아마 자동화를 막기 위해 변형을 준 것 같아서 처리를 했는데 테스트했을 때는 <code>else</code> 조건으로 빠지는 UI는 거의 등장하지 않아서 사실 이 부분은 제대로 처리하지 않았다.(동작하지 않는다는 의미이다) 여기 예제에서는 월마트의 구현이 중요한 건 아니라서 상관없다.</p>
<h2>2captcha로 reCaptcha 해결 자동화</h2>
<p><a href="https://2captcha.com/demo/recaptcha-v2?utm_medium=content&utm_source=outsider&utm_campaign=korea">2captcha에서 제공하는 데모 페이지</a>에서 테스트해보면서 사용 방법을 알 수 있다.</p>
<pre class="line-numbers"><code class="language-js">// retrive data from recaptcha
const recaptcha = await page.waitForSelector('.g-recaptcha');
const sitekey = await recaptcha.getAttribute('data-sitekey');
const currentUrl = await page.url();
</code></pre>
<p>reCaptcha v2의 정보가 필요하므로 <code>.g-recaptcha</code> 요소에 <code>data-sitekey</code> 속성으로 저장된 키가 필요하고 현재 URL이 필요하기 때문에 이 값을 가져왔다.</p>
<p>이제 이를 이용해서 2captcha에 요청을 보낼 차례이다.</p>
<pre class="line-numbers"><code class="language-js">// 2captcha
const APIKEY = process.env.APIKEY;
const twoCaptchaURL = `https://2captcha.com/in.php?key=${APIKEY}&method=userrecaptcha&googlekey=${sitekey}&pageurl=${currentUrl}`;
let requestId;
try {
const response = await got(twoCaptchaURL);
const result = response.body;
if (result.startsWith('OK|')) {
requestId = result.split('|')[1];
console.log('RequestId: ' + requestId);
} else {
throw new Error(`Wrong response: ${result}`);
}
} catch(e) {
console.log(e.response.body);
await browser.close();
}
</code></pre>
<p>2captcha의 API Key는 <a href="https://2captcha.com/enterpage?utm_medium=content&utm_source=outsider&utm_campaign=korea">대시보드</a>에서 볼 수 있다. 이를 이용해서 CAPTCHA 해결 요청 URL인 <code>https://2captcha.com/in.php</code>에 보내면서 앞에서 저장해 놓은 <code>sitekey</code>와 <code>currentUrl</code>을 파라미터로 보낸다.</p>
<p>응답이 제대로 처리된 경우 <code>OK|66648692707</code> 같은 형식의 응답을 받으므로 앞의 <code>OK|</code> 부분을 잘라내고 뒷부분인 요청 ID를 따로 저장했다. 해당 요청이 처리되었는지 확인하려면 이 요청 ID가 필요하다.</p>
<pre class="line-numbers"><code class="language-js">const resultUrl = `https://2captcha.com/res.php?key=${APIKEY}&action=get&id=${requestId}`;
const getCaptchaResult = () => {
return new Promise((resolve, reject) => {
setTimeout(async () => {
try {
const response = await got(resultUrl);
resolve(response.body);
} catch(e) {
reject(e.response.body);
}
}, 10000);
});
};
let answer;
let isNotSolved = true;
while(isNotSolved) {
try {
const result = await getCaptchaResult();
if (result.startsWith('OK|')) {
answer = result.split('|')[1];
isNotSolved = false;
} else {
throw new Error(`Wrong response: ${result}`);
}
console.log(result);
} catch(e) {
console.log('error')
console.log(e);
}
}
</code></pre>
<p>해당 CAPTCHA의 해결은 실제로 사람이 하므로 시간이 걸리고 언제 완료되는지 알 수가 없다. 그러므로 요청 ID를 가지고 주기적으로 확인해 보면서 해당 요청이 처리되었는지 확인해야 한다. 이를 확인하는 함수가 <code>getCaptchaResult()</code>이고 이를 <code>while(isNotSolved) {}</code>으로 10초마다 계속 호출하면서 요청이 처리되었는지를 확인한다. 요청이 아직 처리 안 되었을 때는 <code>CAPCHA_NOT_READY</code> 응답이 오고 요청이 완료되면 <code>OK|03AGdBq25rA1ntkj4Q67Zj...</code>처럼 <code>OK|</code> 뒤에 결과값이 오므로 이를 추출해서 가져온다.</p>
<pre class="line-numbers"><code class="language-js">// enter recaptcha answer
await page.$eval('#g-recaptcha-response', el => {
el.style.display = 'block';
});
await page.fill('#g-recaptcha-response', answer, {delay: 30});
await page.evaluate((answer) => {
handleCaptcha(answer);
}, answer);
setTimeout(async () => {
await page.screenshot({ path: 'img/complated.png' });
await browser.close();
}, 5000);
</code></pre>
<p>이제 해결된 reCaptcha의 결과값을 처리해줘야 한다 reCaptcha v2의 경우 <code>g-recaptcha-response</code>를 ID로 가진 <code><textarea></code>에 이 결과값(<code>answer</code>)를 입력해주어야 한다. 해당 <code><textarea></code>가 보이지 않으면 입력할 수 없으므로 <code>display: block;</code>으로 보이게 처리한 뒤에 값을 입력한다.</p>
<p>기본적으로는 해당 폼을 제출하면 처리되어야 하지만 월마트의 경우 자동화를 막기 위해서인지 그냥 <code><form></code> 제출로는 처리되지 않고 해당 <code><form></code>을 처리하는 별도의 함수 <code>handleCaptcha()</code>를 만들어 두었기 때문에 이 함수에 <code>answer</code>를 전달해서 호출한다. 호출할 때는 페이지에서 JavaScript가 실행되도록 <code>page.evaluate()</code>를 사용했다. 폼 제출만으로 처리되면 좋겠지만 월마트의 경우처럼 대부분의 사이트는 자동화를 막기 위한 다양한 추가 트릭을 제공할 것이므로 이 부분은 상황에 맞게 각자 사이트를 분석해서 처리해야 한다.</p>
<p>마지막은 로그인된 페이지의 스크린숏을 찍기 위해서 <code>setTimeout()</code>으로 대기한 후에 스크린숏을 찍고 <code>browser.close()</code>로 브라우저를 종료했다.(해당 URL을 대기하는 식으로 구현해도 되지만 CAPTCHA 해결에 집중했기 때문에 이 부분은 테스트용으로 작성했던 것을 그대로 두었다.)</p>
<p style="text-align: center;"><img src="//blog.outsider.ne.kr/attach/1/5182446275.gif" width="749" height="441" alt="월마트 로그인을 자동화한 영상" title="" /></p>
<p>headless 옵션을 풀고 실행하면 실제로 크롬을 실행해서 동작하는 것을 볼 수 있는데 위처럼 동작한다. 로그가 찍히는 것을 볼 수 있도록 뒤에 터미널의 로그를 볼 수 있게 했고 응답을 기다리다가 완료되면 처리하는 것을 볼 수 있다. 마지막에 페이지가 뜨자마자 끝나기는 하는데 아래처럼 로그인된 화면이 뜨는 것을 확인할 수 있다.</p>
<p style="text-align: center;"><img src="//blog.outsider.ne.kr/attach/1/7784079971.jpg" width="750" height="422" alt="로그인된 월마트 페이지" title="" /></p>
<p>CAPTCHA를 직접 해결하거나 우회하려면 비용이 많이 들고 창과 방패처럼 자동화를 피하려는 사이트의 작업이 계속되는 만큼 이를 해결하려는 노력도 계속해야 하는데 그 가운데 다루기 어려운 CAPTCHA는 2captcha를 이용하면 적은 비용으로 쉽게 해결할 수 있다.</p>
<p><strong><a href="https://blog.outsider.ne.kr/1547?commentInput=true#entry1547WriteComment">댓글 쓰기</a></strong></p>정적 사이트 생성기 GatsbyOutsiderhttps://blog.outsider.ne.kr/14262019-06-26T13:53:13+09:002019-02-08T22:35:45+09:00<p>최근 웹사이트를 정적사이트로 만들 일이 있어서 어떤 도구를 이용해서 만들면 좋을지 좀 찾아봤다. 여기서 정적사이트(static site)라고 말하는 것은 HTML, CSS, JavaScript로만 만들어진 사이트이다. 이런 용어의 정의가 아주 엄격한 것은 아니지만 보통 정적사이트는 사용하는 문서도 빌드할 때 모두 HTML로 만들고 가장 많이 사용하는 방식이 Markdown으로 글을 쓰고 정적사이트 생성기를 통해서 블로그로 만드는 것이다. 이렇게 사이트를 만들면 HTML, CSS, JavaScript, 이미지 등의 정적 파일만 CDN 등을 통해서 배포하면 별도의 서버를 운영할 필요도 없고 사용자가 많아져서 서버에 부하가 생길 걱정도 할 필요가 없다.(물론 정적 파일을 배포하는 서버 부하는 생각해야 한다.)</p>
<p>최근 흐름에 따라 정적사이트와 비슷한 맥락의 용어가 몇 가지 더 있다. 용어의 경계를 너무 명확히 할 필요는 없고 이마저도 시간이 지나면서 달라지기 마련이지만 내가 이해한 맥락에서 설명을 해보려고 한다. (잘못된 내용이 있으면 댓글로 의견 주시면 감사하겠습니다.)</p>
<p><a href="https://jamstack.org/">JAMstack</a>사이트를 보면 다음과 같이 정의되어 있다.</p>
<blockquote>
<p>Modern web development architecture based on client-side JavaScript, reusable APIs, and prebuilt Markup</p>
</blockquote>
<p>위 설명대로 <strong>JavaScript, API, Markup</strong>의 앞글자를 딴 스택이다.(CSS를 안 쓴다는 의미는 아니다.) 템플릿은 미리 만들어서 JavaScript와 함께 배포하고 API로 데이터를 불러오는 사이트를 말한다. 이렇게 보면 API를 전혀 안 쓰는(다고는 했지만, Google Analytics 등 일부 API는 쓸 수도 있다.) 정적사이트는 JAMstack이라고 할 수 있지만 모든 JAMstack이 정적사이트는 아니다.(데이터를 API로 불러오므로...)</p>
<p>또 하나는 Single Page Application을 의미하는 SPA라는 형태가 있다. SPA는 <a href="https://en.wikipedia.org/wiki/Single-page_application">위키피디아</a>에 따르면 다음과 같이 정의되어 있다.</p>
<blockquote>
<p>a web application or web site that interacts with the user by dynamically rewriting the current page rather than loading entire new pages from a server.</p>
</blockquote>
<p>React나 Vue가 인기를 얻으면서 더욱 많이 사용하는 방식인데 기본적으로 어떤 경로로 접속을 하던 항상 똑같은 HTML과 JavaScript를 받는다. 이후 이 JavaScript가 실행되면서 API로 데이터를 불러오고 마크업으로 화면을 보여주고 사용자가 이동하는 것에 맞추어서 Push state로 히스토리를 바꿔주면서 페이지를 보여주지만 실제로는 모두 프론트엔드에서 처리된다. 이런 면에서 보면 SPA도 JAMstack이라고 할 수 있지만 모든 JAMstack이 SPA는 아니다. JAMstack으로 구성했지만, Single Page로 구성하지 않을 수도 있으므로... 물론 현실에서는 SEO나 성능상의 이유로 SPA와 서버사이드 렌더링을 섞어서 쓰기도 한다.<br />
<br></p>
<h1>정적 사이트 생성기(Static Site Generator)</h1>
<p>설명이 길어졌는데 여기서 내가 만들고자 하는 것은 정적사이트였고 최신 정적사이트 생성기를 살펴봤다. 프론트엔드를 주로 하지 않은지 좀 되었기에 내가 아는 정적사이트 생성기는 <a href="https://jekyllrb.com/">Jekyll</a>에서 머물러 있다. 정적사이트의 시대를 만든 것이나 다름없는 대표 프로젝트고 <a href="https://pages.github.com/">GitHub Pages</a>도 기본은 Jekyll을 사용한다.</p>
<p>물론 Node.js 프로젝트에서 <a href="https://www.staticgen.com/metalsmith">Metalsmith</a>를 쓰고 있고, <a href="https://nodejs.github.io/nodejs-ko/">Node.js 한국 커뮤니티</a>에서는 <a href="https://hexo.io/">Hexo</a>를 쓰고 최근 <a href="https://mochajs.org/">mocha 웹사이트</a>는 <a href="https://www.11ty.io/">11ty</a>로 갈아탔지만 대부분 사용방법은 크게 다르지 않다. 문서는 Markdown으로 관리하면서 생성기에서 지원하는 몇 가지 문법으로 HTML 템플릿과 연결해서 빌드하면 정적사이트가 만들어진다.</p>
<p>정적 사이트 생성기가 하는 일이 뻔하고 기능의 차이도 크지 않기 때문에 대부분은 사용성이나 언어의 선호도 정도로 골라서 쓰게 되는 편이라고 생각하고 새로운 생성기가 나와도 크게 신경 쓰고 있지 않았다. 말했다시피 각 도구가 하는 일이 도긴개긴이고 러닝 커브도 높지 않아서 미리 찾아보거나 공부해 볼 필요성을 못 느끼고 있었다.<br />
<br></p>
<h1>Gatsby란?</h1>
<p>기술 트랜드는 어느 정도 보고 있었기에 정적 사이트 관련해서 <a href="https://netlify.com/">Netlify</a>와 <a href="https://www.gatsbyjs.org/">Gatsby</a>를 중심으로 인기를 끌고 있다는 것은 어느 정도 알고 있었다. 내 주변에 쓰는 사람이 없어서 정확히 각 도구/서비스의 기능을 모르고 있었다가 얼마 전 Netlify를 써보고는 반해버렸고(<a href="https://blog.outsider.ne.kr/1417">netlify로 정적 사이트 배포하기</a> 참고) 이번에는 Gatsby를 써보고 왜 사람들이 계속 Gatsby를 언급했는지 이해하게 되었다.</p>
<p style="text-align: center;"><img src="//blog.outsider.ne.kr/attach/1/3340012696.gif" width="750" height="327" alt="Gatsby 웹사이트" title="" /></p>
<p>처음에는 이게 뭔가? 하고 보고 있었는데 동작하는 구조를 이해하고 나서는 최근 몇 년간 정적 사이트의 발전이 너무 커서 놀라울 정도였다. 그동안 정적 사이트에 대한 요구가 없었던 관계로 React, Vue등을 이용한 SPA 쪽에 더 관심이 있었지(실제로 내가 보는 프로젝트도 이쪽이 많았고...) 정적 사이트는 안 보고 있었는데 안 보는 사이에 (내 처지에서) 놀라울 정도의 발전이 있었다. Netlify도 사실상 이런 흐름을 타고 인기를 얻고 있는 거고(실제로도 잘 만들었지만) 정적사이트 생성기를 정리해 놓은 <a href="https://www.staticgen.com/">StaticGen</a>도 Netlify에서 운영하고 있다.</p>
<p>Gatsby를 한마디로 정의하기가 어렵다. 앞에서 정적사이트 생성기를 설명하면서 Gatsby까지 왔지만, Gatsby를 단순히 정적 사이트 생성기라고 말하기에는 Gatsby가 아깝다고 생각한다.</p>
<blockquote class="twitter-tweet" data-lang="en"><p lang="en" dir="ltr">What's your preferred description of Gatsby? (if other, post in replies)</p>— Gatsby (@gatsbyjs) <a href="https://twitter.com/gatsbyjs/status/1090650859619246080?ref_src=twsrc%5Etfw">January 30, 2019</a></blockquote>
<p>Gatsby가 <a href="https://twitter.com/gatsbyjs">트위터</a>에서 Gatsby를 어떻게 설명하면 좋을지 설문한 것을 보면 <strong>Website generator</strong>나 <strong>Website development tool</strong>가 우세하다. 한쪽으로 치우치지 않고 Gatsby 쪽도 저렇게 애매한 단어로만 설문한 걸 보면 한마디로 정의하기 힘들어하는 것으로 보인다.</p>
<p>Gatsby는 React를 사용하는데 웹사이트를 쉽게 제작할 수 있도록 편의 기능을 많이 제공하고 있다. React를 잘 알지는 못하지만, React의 사용방법을 크게 헤치지 않으면서도 쉽게 웹사이트를 만들 수 있도록 잘 감싸서 제공하고 있다. React를 잘 아는 사람은 어떻게 느낄지 모르겠는데 나한테는 꽤 재밌고 잘 만들어진 도구로 느껴졌고 정적사이트는 정적 사이트 나름의 한계가 있지만 웬만한 사이트는 Gatsby를 먼저 고려해도 괜찮겠다 싶을 정도였다.</p>
<p style="text-align: center;"><img src="//blog.outsider.ne.kr/attach/1/9607203875.gif" width="750" height="674" alt="Gatsby 동작 방식" title="" /></p>
<p>위 그림을 <a href="https://www.gatsbyjs.org/">Gatsby 홈페이지</a>에서 가져온 그림으로 동작 방식을 설명하고 있다.</p>
<p>블로그뿐만 아니라 웹사이트를 만들려면 데이터가 필요하다. 이 데이터를 가져오는 곳은 Gatsby에서는 <strong>데이터소스</strong>라고 부르는데 여기서 데이터 소스는 <a href="https://wordpress.org/">Wordpress</a>같은 CMS 도구가 될 수도 있고 다른 정적 사이트 생성기처럼 Markdown 파일이 될 수도 있고 API 등을 통해서 다른 곳에서 가져올 수도 있다. 플러그인 시스템이 잘 되어 있어서 <a href="https://www.gatsbyjs.org/plugins/?=source">다양한 데이터소스</a>에서 데이터를 가져올 수 있다.</p>
<p>Gatsby는 기본적으로 <strong><a href="https://graphql.org/">GraphQL</a>을 사용해서 데이터소스에서 데이터를 가져온다</strong>. 플러그인을 이용해서 RESTful API에서 데이터를 가져올 수도 있지만 GraphQL이 Gatsby에 포함되어 있어서 훨씬 쉽게 쓸 수 있고 원하는 데이터를 선택해서 가져올 수 있는 GraphQL이 Gatsby의 개념에도 더 어울린다고 생각한다.</p>
<p>웹사이트는 React를 이용해서 만든다. 코드는 <strong>React를 이용</strong>해서 컴포넌트 작성하듯이 사용하지만, 공통 레이아웃을 관리하거나 페이지를 생성하거나 데이터소스와 컴포넌트를 연결하거나 하는 기능을 Gatsby에서 제공하고 있어서 쉽게 만들 수 있다.</p>
<p>이를 <strong>빌드하면 정적 사이트로 만들어 준다.</strong> 여기서 SPA라고 하지 않고 정적사이트로 만든다는 부분이 중요한데 CMS나 파일 등의 데이터 소스에서 GraphQL로 가져온 데이터를 빌드할 때 모두 가져와서 정적파일의 데이터로 포함한다. SPA라고 한다면 API로 가져오는 로직이 소스에 들어있고 사용자가 SPA 사이트를 실행할 때 API로 가져오게 되지만 Gatsby는 정적 사이트를 만들어주므로 <strong>빌드 시에 GraphQL로 데이터를 가져와서 빌드된 배포 파일에 포함 시킨다.</strong> 그러므로 사이트를 운영할 때 데이터소스로 이용한 API 서버나 파일은 제공하지 않아도 된다. <strong>이 부분이 다른 도구들과 Gatsby를 가장 다르게 만들어주는 특징이라고 생각한다.</strong></p>
<p>복잡한 사이트를 아직 안 만들어봤지만, 이 전체 흐름이 아주 깔끔하게 만들어져있다. 필요한 API를 이용하거나 정적 사이트에 필요한 기능이나 빌드 과정까지 기존에 없던 새로운 기술은 아니지만 아주 우아하게 잘 조합하고 감싸서 제공하고 있다. 몇 번 테스트로 만져본 정도지만 사용 경험이 꽤 만족스럽다. 자세한 기능은 <a href="https://www.gatsbyjs.org/features/">문서</a>에서 확인할 수 있다.<br />
<br></p>
<h1>Gatsby의 동작 예시</h1>
<p>(아직 나도 잘 모르는) 자세한 사용법은 다음 기회에 설명하고 간단한 보일러 플레이트 프로젝트로 Gatsby가 어떻게 동작하는지 이해해 보자.</p>
<p>최근 대부분의 JavaScript 프로젝트처럼 사용하기 쉽게 <a href="https://www.npmjs.com/package/gatsby-cli">gatsby-cli</a>를 제공하고 있다.</p>
<pre class="line-numbers"><code class="language-bash">$ npx gatsby-cli new gatsby-example
npx: 211개의 패키지를 15.471초만에 설치했습니다.
info Creating new site from git: https://github.com/gatsbyjs/gatsby-starter-default.git
Cloning into 'gatsby-example'...
remote: Enumerating objects: 1, done.
remote: Counting objects: 100% (1/1), done.
remote: Total 1176 (delta 0), reused 1 (delta 0), pack-reused 1175
Receiving objects: 100% (1176/1176), 2.55 MiB | 2.43 MiB/s, done.
Resolving deltas: 100% (678/678), done.
success Created starter directory layout
info Installing packages...
yarn install v1.10.1
[1/4] Resolving packages...
[2/4] Fetching packages...
[3/4] Linking dependencies...
[4/4] Building fresh packages...
success Saved lockfile.
Done in 16.16s.
</code></pre>
<p><code>npx gatsby-cli new gatsby-example</code> 명령어로 기본 웹사이트를 생성했다. 참고로 <a href="https://www.npmjs.com/package/gatsby-cli">gatsby-cli</a>를 전역으로 설치(<code>-g</code>)하면 <code>gatsby</code> 명령어를 사용할 수 있지만, 전역 설치를 안 하기 위해 <a href="https://www.npmjs.com/package/npx">npx</a>를 이용해서 <code>gatsby-cli</code>를 바로 실행했다. 그래서 <code>npx gatsby-cli new gatsby-example</code>는 <code>gatsby new gatsby-example</code> 명령어와 같다.(이후에는 그냥 <code>gatsby</code> 명령어를 사용한다.)</p>
<p><code>gatsby new PROJECT_NAME STARTER_URL</code> 명령어는 새로운 Gatsby 프로젝트를 만드는 명령어다. 위에서는 <code>STARTER_URL</code>을 생략했으므로 기본값인 <a href="https://github.com/gatsbyjs/gatsby-starter-default">gatsby-starter-default</a>를 사용해서 사이트가 생성되었다. 커뮤니티가 만든 <a href="https://www.gatsbyjs.org/starters/">다양한 구성의 Starter가 있으므로</a> 원하는 구성에 맞춰서 선택하거나 사용방법을 알아보기 좋다.</p>
<p>이제 <code>gatsby-example</code> 폴더에서 <code>npm run develop</code> 명령어(실제로는 <code>gatsby develop</code>)를 실행하면 개발 모드로 서버를 실행할 수 있다.</p>
<pre class="line-numbers"><code class="language-bash">$ npm run develop
DONE Compiled successfully in 3279ms 21:08:13
You can now view gatsby-starter-default in the browser.
http://localhost:8000/
View GraphiQL, an in-browser IDE, to explore your site's data and schema
http://localhost:8000/___graphql
Note that the development build is not optimized.
To create a production build, use gatsby build
ℹ 「wdm」:
ℹ 「wdm」: Compiled successfully.
</code></pre>
<p>기본 스타터의 디자인은 중요하지 않지만 <a href="http://localhost:8000/">http://localhost:8000/</a>로 접속하면 아래와 같은 간단한 데모 페이지를 볼 수 있다. 기본 스타터는 2페이지로 구성되어 있다.</p>
<p style="text-align: center;"><img src="//blog.outsider.ne.kr/attach/1/8151873884.gif" width="500" height="657" alt="Gatsby 기본 스타터 웹페이지" title="" /></p>
<p>개발 모드에서는 GraphQL을 테스트하기 쉽게 <a href="https://github.com/graphql/graphiql">GraphiQL</a>이 포함되어 있다.</p>
<p style="text-align: center;"><img src="//blog.outsider.ne.kr/attach/1/2328007222.gif" width="750" height="455" alt="GraphiQL 페이지" title="" /></p>
<p>소스 파일이 있는 <code>src</code> 디렉터리를 보면 React로 작성한 웹사이트를 만든 정적 파일들이 있다. 상세 소스는 <a href="https://github.com/gatsbyjs/gatsby-starter-default/tree/master/src">GitHub 저장소</a>에서 볼 수 있다.</p>
<pre class="line-numbers"><code class="language-bash">src
├── components
│ ├── header.js
│ ├── image.js
│ ├── layout.css
│ ├── layout.js
│ └── seo.js
├── images
│ ├── gatsby-astronaut.png
│ └── gatsby-icon.png
└── pages
├── 404.js
├── index.js
└── page-2.js
</code></pre>
<p>이를 프로덕션용 정적 사이트를 빌드하기 위해 <code>npm run build</code> 명령어(실제로는 <code>gatsby build</code>)를 실행해 보자.</p>
<pre class="line-numbers"><code class="language-bash">npm run build
> gatsby-starter-default@0.1.0 build /Users/outsider/temp/gatsby/gatsby-example
> gatsby build
success open and validate gatsby-configs — 0.010 s
success load plugins — 0.153 s
success onPreInit — 0.584 s
success delete html and css files from previous builds — 0.009 s
success initialize cache — 0.010 s
success copy gatsby files — 0.045 s
success onPreBootstrap — 0.003 s
success source and transform nodes — 0.040 s
success building schema — 0.173 s
success createPages — 0.000 s
success createPagesStatefully — 0.024 s
success onPreExtractQueries — 0.003 s
success update schema — 0.082 s
success extract queries from components — 0.076 s
success run graphql queries — 0.066 s — 7/7 109.09 queries/second
success write out page data — 0.002 s
success write out redirect data — 0.000 s
⠂ onPostBootstrapdone generating icons for manifest
success onPostBootstrap — 0.104 s
info bootstrap finished - 3.851 s
success Building production JavaScript and CSS bundles — 5.356 s
success Building static HTML for pages — 0.464 s — 4/4 27.16 pages/second
info Done building in 9.673 sec
</code></pre>
<p>빌드 결과는 <code>public</code> 디렉터리에 생기는데 위의 <code>src</code>와 비교했을 때 파일도 많아지고 훨씬 복잡해진 것을 알 수 있다.</p>
<pre class="line-numbers"><code class="language-bash">public/
├── 0-3a2bd5c9a235c2c48f7f.js
├── 0-3a2bd5c9a235c2c48f7f.js.map
├── 404
│ └── index.html
├── 404.html
├── app-d044d5a4358eaf1a0738.js
├── app-d044d5a4358eaf1a0738.js.map
├── chunk-map.json
├── component---src-pages-404-js-1af22cf06cd461fb89b9.js
├── component---src-pages-404-js-1af22cf06cd461fb89b9.js.map
├── component---src-pages-404-js.a1e3226de724a2570055.css
├── component---src-pages-index-js-20cc3b8fc80f26983ef6.js
├── component---src-pages-index-js-20cc3b8fc80f26983ef6.js.map
├── component---src-pages-index-js.a1e3226de724a2570055.css
├── component---src-pages-page-2-js-b55efdbe24802f10f9a8.js
├── component---src-pages-page-2-js-b55efdbe24802f10f9a8.js.map
├── component---src-pages-page-2-js.a1e3226de724a2570055.css
├── icons
│ ├── icon-144x144.png
│ ├── icon-192x192.png
│ ├── icon-256x256.png
│ ├── icon-384x384.png
│ ├── icon-48x48.png
│ ├── icon-512x512.png
│ ├── icon-72x72.png
│ └── icon-96x96.png
├── index.html
├── manifest.webmanifest
├── page-2
│ └── index.html
├── pages-manifest-4666a1fb53ffe6f02316.js
├── pages-manifest-4666a1fb53ffe6f02316.js.map
├── static
│ ├── 6d91c86c0fde632ba4cd01062fd9ccfa
│ │ ├── 360b8
│ │ │ └── gatsby-astronaut.png
│ │ ├── 5c9de
│ │ │ └── gatsby-astronaut.png
│ │ ├── 7e26c
│ │ │ └── gatsby-astronaut.png
│ │ ├── 893cf
│ │ │ └── gatsby-astronaut.png
│ │ ├── a2541
│ │ │ └── gatsby-astronaut.png
│ │ └── fdadc
│ │ └── gatsby-astronaut.png
│ └── d
│ ├── 1025518380.json
│ ├── 164
│ │ └── path---404-html-516-62a-NZuapzHg3X9TaN1iIixfv1W23E.json
│ ├── 173
│ │ └── path---index-6a9-NZuapzHg3X9TaN1iIixfv1W23E.json
│ ├── 2011440971.json
│ ├── 44
│ │ └── path---404-22-d-bce-NZuapzHg3X9TaN1iIixfv1W23E.json
│ ├── 53
│ │ └── path---page-2-fbc-5a8-NZuapzHg3X9TaN1iIixfv1W23E.json
│ ├── 755544856.json
│ └── 920
│ └── path---dev-404-page-5-f-9-fab-NZuapzHg3X9TaN1iIixfv1W23E.json
├── webpack-runtime-586c75e86997621faa61.js
├── webpack-runtime-586c75e86997621faa61.js.map
└── webpack.stats.json
</code></pre>
<p>로컬에서 <code>8000</code> 포트로 웹서버를 띄워서 빌드한 파일을 제공한다고 했을 때 이 프로젝트에는 <code>http://localhost:8000/</code>와 <code>http://localhost:8000/page-2/</code> 두 개의 페이지가 있다. 아래는 이 두 페이지를 불러온 소스 파일이다.</p>
<p style="text-align: center;"><img src="//blog.outsider.ne.kr/attach/1/9612958136.gif" width="750" height="161" alt="두 페이지의 소스 보기 비교" title="" /></p>
<p>작아서 잘 안 보이지만 각 페이지를 직접 URL로 접근하면 완전한 HTML 페이지가 다운로드 된다. 위 빌드 파일에서 <code>index.html</code>과 <code>page-2/index.html</code>이 다운로드 된 것이다.</p>
<p>정적 사이트를 빌드했으므로 당연한 얘기인데 위처럼 빌드 결과로 많은 파일이 나온 이유는 최적화를 해주기 때문이다. 다음 화면을 보면 루트 페이지를 불러온 다음에 <code>/page-2</code>로 가는 링크 위에 마우스 커서를 올리면 <code>page-2</code>에 필요한 파일을 미리 다운로드 받는데 이 파일도 전체 HTML 파일과 관련 파일이 아니라 필요한 부분만 분리된 파일이 다운로드 된다. 미리 다운로드를 다 했으므로 클릭했을 때는 당연히 아무 파일도 다운로드하지 않고 바로 <code>/page-2</code>를 불러온다.</p>
<p style="text-align: center;"><img src="//blog.outsider.ne.kr/attach/1/4697788821.gif" width="669" height="665" alt="사용자 삽입 이미지" title="" /></p>
<p>개인적으로 이렇게 특별히 신경 쓰지 않아도 쉽게 최적화해주는 부분이 Gatsby의 매력이라고 생각한다. <a href="https://www.gatsbyjs.org/plugins/">플러그인</a>이 많이 존재하기 때문에 필요한 기능을 추가하면 쉽게 오프라인을 지원하거나 PWA로 만드는 등의 작업을 쉽게 할 수 있다.</p>
<p>최근에 <a href="https://blog.outsider.ne.kr/1422">Vue.js를 사용해 보고 나서</a> Vue.js에 끌리고 있었는데 Gatsby 때문에 React를 해야겠다고 생각하는 중이다.</p>
<p><strong><a href="https://blog.outsider.ne.kr/1426?commentInput=true#entry1426WriteComment">댓글 쓰기</a></strong></p>JavaScript 함수 파라미터에서 destructuring assignment 이용하기Outsiderhttps://blog.outsider.ne.kr/13482018-02-05T17:48:18+09:002018-02-05T17:48:18+09:00<p>ES2015에는 <a href="https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment">destructuring assignment</a>라는 기능이 있다. 객체나 배열에서 바로 값을 가져와서 변수에 할당할 수 있는 기능인데 처음 보면 좀 헷갈릴 수도 있지만 쓰다 보면 꽤 편해서 자주 사용하고 있다.</p>
<p>다음과 같은 함수를 보자. 파라미터를 2개 받는 함수인데(로직(?)은 여기선 의미 없다.) 이젠 파라미터에 기본값을 지정할 수 있으므로 코드가 훨씬 간단해졌다.</p>
<pre class="line-numbers"><code class="language-javascript">const paginate = (page = 1, perPage = 15) => {
return {
offset: (page - 1) * perPage,
limit: perPage,
};
};
</code></pre>
<p>이렇게 일일이 파라미터로 받아도 되지만 조건이 많아지면 객체로 받는 게 더 편한 때도 있다. 다음처럼 <code>options</code>라는 객체로 인자를 받으면 이전에는 기본값을 지정하기 위해서 <code>Object.assign()</code> 혹은 <a href="https://lodash.com/">lodash</a>의 <a href="https://lodash.com/docs#assign"><code>_.assign</code></a> 같은 메서드를 사용해서 기본값을 지정해서 사용할 때 모든 속성을 다 지정하지 않도록 할 수 있다.</p>
<pre class="line-numbers"><code class="language-javascript">const paginate = (options) => {
options = Object.assign({
page: 1,
perPage: 15,
}, options);
return {
offset: (options.page - 1) * options.perPage,
limit: options.perPage,
};
};
paginate({ page: 4});
// { offset: 45, limit: 15 }
</code></pre>
<p>이렇게 파라미터를 객체로 받을 때 destructuring assignment를 사용하면 코드를 더 간단하게 작성할 수 있다. 첫 예제와 똑같아 보이지만 파라미터를 받을 때 <code>{}</code>로 감싸서 destructuring assignment를 사용하면서 기본값을 지정했다. 이제 사용하는 쪽에서 필요한 속성만 지정하면 나머지는 기본값으로 들어가게 된다.</p>
<pre class="line-numbers"><code class="language-javascript">const paginate = ({ page = 1, perPage = 15 }) => {
return {
offset: (page - 1) * perPage,
limit: perPage,
};
};
paginate({ page: 4});
// { offset: 45, limit: 15 }
</code></pre>
<p>하지만 이때 <code>paginate()</code>처럼 아예 인자를 주지 않으면 <code>TypeError: Cannot destructure property</code>page<code>of 'undefined' or 'null'.</code> 오류가 발생한다. 함수가 호출될 때 destructuring assignment를 시도하지만 아무런 값도 전달되지 않았으므로 오류가 발생한 것이다.</p>
<pre class="line-numbers"><code class="language-javascript">const paginate = ({ page = 1, perPage = 15 } = {}) => {
return {
offset: (page - 1) * perPage,
limit: perPage,
};
};
paginate();
// { offset: 0, limit: 15 }
</code></pre>
<p>처음 보면 약간 헷갈릴 수 있는데 파라미터를 destructuring assignment 할 때 기본값으로 <code>{}</code> 빈 객체를 지정해서 호출할 때 인자를 주지 않아도 오류가 발생하지 않게 한 것이다.</p>
<p>destructuring assignment가 편해서 파라미터를 여럿 받을 때 점점 많이 사용하게 된다.</p>
<p><strong><a href="https://blog.outsider.ne.kr/1348?commentInput=true#entry1348WriteComment">댓글 쓰기</a></strong></p>