Outsider's Dev Story: JavaScript/Javascript 카테고리 글 목록https://blog.outsider.ne.kr/Stay Hungry. Stay Foolish. Don't Be Satisfied.2024-03-15T12:06:50+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>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>Ajax를 사용할 때 웹브라우저 "뒤로 가기"의 구현Outsiderhttps://blog.outsider.ne.kr/12762018-07-29T20:18:19+09:002017-02-28T10:02:00+09:00<p>전에 <a href="https://blog.outsider.ne.kr/1264">블로그 글 주제를 요청받기로 하고</a> 올라온 이슈 중에 <a href="https://github.com/outsideris/request-blog-post/issues/2">Ajax를 사용할 때 "뒤로 가기"</a>에 대한 요청이 있었다. 난이도가 아주 높은 건 아니므로 재밌겠다는 생각이 들었다.</p>
<p>일단 최근에는 Ajax를 쓸 때 페이지 뒤로 가기를 직접 구현해 본 적이 없다. 나는 "문서로서의 웹"이라는 기본 개념을 중시하기 때문에 Ajax를 본문 중심으로 헤비하게 쓰지 않는 편이고 SPA(Single Page Application)을 많이 선호하진 않지만, SPA를 하더라도 Angular.js나 React, Backbone 같은 프레임워크에서 제공하는 라우팅을 썼기 때문에 직접 구현해 본 적은 없다. 이 글은 내가 아는 지식 선에서 Ajax를 사용할 때의 "뒤로 가기"에 대해서 정리한 글이지만 그사이에 뭔가 새로운 방법이나 접근이 있을 수도 있는데 그런 경우 알려주면 좋겠다.<br />
<br></p>
<h1>글 목록을 보여주는 방식</h1>
<p><a href="https://github.com/outsideris/request-blog-post/issues/2">해당 이슈</a>를 보면 "'페이스북'처럼 목록이 로딩된 상태 (페이징 처리X) 에서 뒤로 가기 처리하는 방법"이라고 나와 있는데 내 지식 범위에서 목록에 대한 관리는 2가지로 나뉜다. 그래서 이해를 도우려면 이 2가지를 먼저 구분하고 설명하는 게 더 좋겠다.</p>
<p>전통적인 방식의 게시판 같은 것을 생각해 보자. 게시판의 글 목록이 화면에 나오고 다음 페이지로 이동하면 글 목록만 Ajax로 불러오게 된다. 이 경우 URL 등으로 정해진 각 페이지 번호에 따라 보여야 하는 글의 목록이 정해져 있다. 물론 새로운 글이 올라오면 목록이 달라지겠지만, 프로그램 관점에서는 불러와야 하는 글의 목록이 정해져 있다.</p>
<p>대신 페이스북이나 트위터 같은 경우의 목록을 생각하면 무한스크롤이다. 페이지별로 나오는 글 목록이 정해져 있는 것이 아니라 URL이 달라지지 않은 상태에서 무한 스크롤을 하게 된다. 그래서 목록의 URL을 복사해서 다른 사람에게 공유한다고 해서 같은 화면을 보지 않는다.(위에서 설명한 상황에서는 같은 주소는 같은 글 목록을 본다.) 이런건 웹사이트의 요구사항에 따라 달라지지만 보통은 URL에 따라서 보여주는 글의 목록을 정하지 않겠다는 의도이다. 그래서 페이스북이든 트위터든 글 목록에서 무한스크롤로 계속 로딩해서 보다가 특정 글을 클릭해서 이동했다가 뒤로 가기로 돌아오면 글 목록을 처음부터 다시 로딩한다.</p>
<p>후자의 경우는 기술적인 문제라기 보다는 서비스의 특성에 관한 것이다. 이어서 설명한 방법을 이용해서 이전에 어디까지 무한스크롤을 했는지 기억했다가 페이지를 로딩하고 사용자를 이 위치에 스크롤 시켜놓는 것은 가능하지만 나는 이 방식은 무한 스크롤과는 좀 맞지 않는다고 생각한다. 이런 상황이 있다면 구현하기 보다는 실제로 무한 스크롤이 정말 필요한가를 고민해 볼 필요가 있다고 생각한다. 그래도 요구하면 할수 없기는 하지만 여기서는 무한스크롤로 보여지는 목록이 아닌 페이징 처리가 된 상태에서 뒤로가기를 구현하는 방식에 대해서 설명하도록 하겠다. 이슈를 올려주신 분이 무한스크롤쪽을 원하는 것 같기도 한데 그렇다면 이슈를 새로 올려주시거나 추가 설명을 해주시면 좋겠다. 그리고 기술적으로 방식은 동일하기 때문에 무한스크롤에서 뒤로가기를 구현하더라도 이글에서 설명하는 방식을 먼저 이해해야 한다.<br />
<br></p>
<h1>링크를 이용한 방식</h1>
<p style="text-align: center;"><img src="//blog.outsider.ne.kr/attach/1/6095504246.gif" width="479" height="437" alt="링크 방식의 데모" title="" /></p>
<p>위 방식을 보자. Ajax 등을 쓰지 않고 링크로 목록을 보여주고 페이지관리를 하는 방식이다. 이 방식이 웹의 기본 방식이고 특별한 이유가 없으면 링크로 모든 페이지를 연결하면 된다. 이렇게 하면 모든 페이지에 고유 URL이 있으므로 SEO나 소셜을 대응하는데도 아무런 문제가 없다. "다음"을 클릭하면 다음 10개의 목록을 가져오고 개별 URL을 가지므로 상세 목록에 들어갔다가 뒤로 가기를 해도 이전에 보던 페이지로 잘 돌아간다.</p>
<p>동작하는 예제는 <a href="https://pushstate-vawrtytcgl.now.sh/link/page1">여기</a>에서 볼 수 있다.</p>
<p>여기서는 주소에 <code>/link/page1</code>처럼 되어 있으므로 1페이지에서 1~10의 글 목록을 보여주게 된다. 여기서 어떤 글 목록을 보여줄지는 모두 서버가 관리하고 있으므로 이 목록을 HTML로 만들어서 내려주고 다음 버튼에 대한 링크로 만들어서 내려준다. 브라우저는 다운받은 HTML을 그냥 보여주는 방식이고 "다음" 버튼을 눌러서 <code>/link/page2</code>로 이동하게 되면 서버가 11~20의 글 목록을 HTML로 만들어서 내려주게 된다. 이때는 링크방식이므로 클릭할 때마다 새로운 URL로 이동하게 되고 모든 페이지를 항상 새로 로딩한다. 항상 새로운 URL을 가지므로 "뒤로 가기"를 눌렀을 때도 아무런 문제 없이 동작한다. 이게 전통적인 방식이다.</p>
<p>그래서 <code>/link/page1</code>로 접속하면 다음과 같은 HTML 문서를 내려받게 된다.</p>
<pre class="line-numbers"><code class="language-html"><!DOCTYPE html>
<html>
<head>
<title></title>
<link rel="stylesheet" href="/stylesheets/style.css">
</head>
<body>
<h1>Ajax 뒤로 가기 예제</h1>
<h3>게시글 목록</h3>
<ul id="list">
<li><a href="/detail/1">제목 1</a></li>
<li><a href="/detail/2">제목 2</a></li>
<li><a href="/detail/3">제목 3</a></li>
<li><a href="/detail/4">제목 4</a></li>
<li><a href="/detail/5">제목 5</a></li>
<li><a href="/detail/6">제목 6</a></li>
<li><a href="/detail/7">제목 7</a></li>
<li><a href="/detail/8">제목 8</a></li>
<li><a href="/detail/9">제목 9</a></li>
<li><a href="/detail/10">제목 10</a></li>
</ul>
<a href="/link/page2" id="next">다음 </a>
</body>
</html>
</code></pre>
<p><br></p>
<h1>Ajax를 이용한 방식</h1>
<p>앞의 방식에서는 링크로 연결되어 있으므로 클릭할 때마다 항상 전체 페이지를 새로 로딩하게 된다. 이 데모 페이지는 목록 외에 스타일이나 다른 문서가 없으므로 문제가 될게 거의 없지만 복잡한 페이지의 경우 페이지마다 헤더나 사이드바, 푸터를 로딩하는 건 속도 저하의 영향을 준다. 그래서 등장한 게 Ajax(Asynchronous JavaScript and XML)다. Ajax는 이미 보편화한 기술이지만 약간 설명하지만 제시 제임스 가렛(Jesse James Garrett)이 만든 용어로 Ajax를 이용하면 웹 페이지에서 일부만 로딩해서 갈아치우는 것이 가능해졌고 이후 웹사이트 작성에 큰 영향을 주었다.</p>
<p style="text-align: center;"><img src="//blog.outsider.ne.kr/attach/1/4202989282.gif" width="479" height="437" alt="Ajax 방식의 데모" title="" /></p>
<p>이 예제의 동작을 확인하려면 <a href="https://pushstate-vawrtytcgl.now.sh/ajax">여기</a>서 볼 수 있다.</p>
<p>이 주소는 <code>/ajax</code> 주소를 가지는데 다음과 같은 HTML을 내려주게 된다.</p>
<pre class="line-numbers"><code class="language-html"><!DOCTYPE html>
<html>
<head>
<title></title>
<link rel="stylesheet" href="/stylesheets/style.css">
</head>
<body>
<h1>Ajax 뒤로 가기 예제</h1>
<h3>게시글 목록</h3>
<ul id="list">
</ul>
<a href="/pages/2" id="next">다음 </a>
</body>
</html>
</code></pre>
<p>앞의 HTML과 크게 다른 점은 <code><ul id="list"></code> 부분에 내용이 안 채워져 있다는 것이다. 이는 빈 내용이 서버에서 내려오고 JavaScript로 데이터를 가져와서 채워 넣게 된다. 이 방식이 Ajax이다. 페이지가 로딩되면 자바스크립트가 실행되는데 여기서 서버에서 글 목록을 가져와서 HTML을 갈아치우게 된다. 게시글을 보여주는 부분이 <code><ul id="list"></ul></code>이고 다음 페이지를 보는 버튼이 <code><a href="/pages/2" id="next">다음</a></code>이다.</p>
<p>뒤에도 그렇지만 여기의 예제는 데모용으로 최적화된 코드이다. 코드의 품질보다는 동작 방식을 봐주길 바란다. 마지막 두 라인을 보면 위의 다음 버튼 <code>#next</code>에 링크된 주소에서 마지막 페이지 번호를 가져와서 <code>currentPage</code> 변수에 저장한다. 최초에 로딩되었을 때는 다음 페이지는 2페이지이지만 현재 페이지에서 보여줄 페이지는 1페이지이므로 <code>-1</code>을 해서 <code>getList</code>를 호출한다. 여기서 <code>getList</code>는 서버에서 목록을 가져오는 함수인데 서버에 <code>/pages/페이지 번호</code>로 GET 요청을 보내서 받아온 결과를 <code><li></code> 목록으로 만들어서 <code>#list</code> 안에 넣어주게 된다. 그리고 <code>#next</code> 페이지도 다음 페이지로 바꿔줘서 클릭 시 다음 목록을 다시 불러오도록 한다. <code>$('#next').click()</code> 부분은 클릭 이벤트를 받아서 버튼의 URL에서 페이지 번호로 다음 목록을 불러오도록 하는 것이다. <code>#next</code>에서 굳이 이렇게 할 필요는 없지만, JavaScript가 없는 경우 최소한의 동작을 보장하기 위해서이다. 물론 이 보장을 하기 위해서는 추가적인 코딩이 더 필요하다. 여기서는 글 목록을 내려주는(여기서는 JSON으로 내려준다.) <code>/pages/페이지 번호</code>의 URL이 하나 더 필요하다. <code>/ajax</code>에서 이 URL로 요청을 보내면 요청받은 글 목록을 JSON으로 내려주게 된다. 자바스크립트는 아래와 같다.</p>
<pre class="line-numbers"><code class="language-javascript">var getList = function(page) {
$.getJSON('/pages/' + page, function(data) {
var list = $.map(data.list, function(l) {
return '<li><a href="/detail/' + l.id + '">' + l.title + '</a></li>'
}).join('');
$('#list').html(list);
$('#next').attr('href', '/pages/' + (+page+1));
});
};
$('#next').click(function(event) {
event.preventDefault();
var page = $(this).attr('href').slice(7);
getList(page);
});
var currentPage = $('#next').attr('href').slice(7);
getList(--currentPage);
</code></pre>
<p>이 방식은 앞의 링크방식과 큰 차이점이 있다. 일단 페이지의 URL이 <code>/ajax</code>가 되고 이 페이지 내에서 목록만 계속 달라진다. 그래서 "다음"을 눌러서 몇 페이지로 가든지 글 제목을 클릭해서 상세페이지로 갔다가 "뒤로 가기"를 누르면 다시 1페이지로 돌아온다. 그 이유는 위 스크립트와 링크에서 보듯이 <code>/ajax</code>가 로딩되면 최초 1페이지를 불러오게 되어 있기 때문이다. 그래서 2, 3, 4, 5 페이지를 눌러서 봤다고 하더라도 페이지가 바뀌지 않았으므로 "뒤로 가기"를 누르면 이전 페이지로 가게 된다.<br />
<br></p>
<h1>앵커를 이용한 방식</h1>
<p>앞에서 설명한 대로 Ajax를 이용하면 전체 페이지를 매번 로딩할 필요 없이 웹페이지의 핵심 콘텐츠 부분만 가져와서 로딩할 수 있다. 그래서 속도가 빠르고 사용자 편의성도 증가하지만 웹 브라우저 입장에서는 페이지의 URL은 바뀌지 않았다. 그래서 "다음" 버튼을 눌러서 새로운 목록을 보더라도 "뒤로 가기"를 누르면 사용자의 기대와 다르게 이전 목록이 나오는 게 아니라 이전 페이지 여기서는 <code>/ajax</code>에 오기 전의 페이지로 이동하게 된다. 이 부분은 사용자의 기대와는 완전히 다른 것이므로 사용자 경험을 몹시 나쁘게 만든다.</p>
<p>그래서 이를 해결하는 방법으로 이용한 것이 앵커다. 예를 들어 <a href="https://github.com/babel/babel/blob/7.0/README.md">babel 프로젝트의 README</a>를 보자. 상단에 목차가 있는데 README 의 주소는 <a href="https://github.com/babel/babel/blob/7.0/README.md">https://github.com/babel/babel/blob/7.0/README.md</a>이지만 각 목차를 누르면 <a href="https://github.com/babel/babel/blob/7.0/README.md#faq">https://github.com/babel/babel/blob/7.0/README.md#faq</a>, <a href="https://github.com/babel/babel/blob/7.0/README.md#team">https://github.com/babel/babel/blob/7.0/README.md#team</a>같은 식으로 뒤에 <code>#faq</code>, <code>#team</code> 등이 붙는다. 이를 앵커라고 하는데 문서에서 <code>#</code>뒤에 붙은 id를 가진 요소로 자동 이동하게 되는데 이는 서버에 요청을 보내는 것이 아니라 현재 페이지 내에서 이동만 하게 된다. 그리고 이 설명해서 중요한 부분은 브라우저가 이를 URL이 바뀐 것으로 인식하기 때문에 앵커를 이동할 때마다 "뒤로 가기"가 된다. 여러 번 앵커를 이동하더라도 "뒤로 가기"를 누르면 각 부분으로 알아서 이동하게 된다.</p>
<p style="text-align: center;"><img src="//blog.outsider.ne.kr/attach/1/3859598756.gif" width="479" height="437" alt="앵커방식의 데모" title="" /></p>
<p>동작하는 예제는 <a href="https://pushstate-vawrtytcgl.now.sh/hash">여기</a>서 볼 수 있고 이 페이지에 접속하면 다음과 같은 HTML을 내려주게 된다.</p>
<pre class="line-numbers"><code class="language-html"><!DOCTYPE html>
<html>
<head>
<title></title>
<link rel="stylesheet" href="/stylesheets/style.css">
</head>
<body>
<h1>Ajax 뒤로 가기 예제</h1>
<h3>게시글 목록</h3>
<ul id="list"></ul>
<a href="/pages/2" id="next">다음 </a>
</body>
</html>
</code></pre>
<p>이전에 본 ajax와 거의 비슷하다. 서버가 목록을 내려주는 게 아니라 페이지가 로딩된 후 자바스크립트에서 서버에 목록을 받아와서 화면에 보여준다. 가장 크게 다른 점은 <code>getList</code>에서 목록을 보여준 뒤 <code>window.location.hash = '#page' + page;</code> 부분이다. 그래서 <code>/hash</code>로 접속하면 주소가 <code>/hash#page1</code>로 바뀌게 되고 "다음"을 누르면 <code>/hash#page2</code>로 주소가 달라진다. 새로운 페이지 목록을 불러올 때마다 앵커를 이용해서 주소를 바꾸게 되므로 웹 브라우저는 이를 기록하고 "뒤로 가기"가 가능하게 된다.</p>
<p>여기서 주의할 점은 앵커는 서버에 전달되는 값이 아니라는 것이다. <code>/hash#page1</code>로 접속하더라도 서버에는 <code>/hash</code>만 전달되고 뒤의 <code>#page1</code>는 브라우저가 처리하는 부분이고 뒤로 가기를 할 때도 서버에 새로 요청을 보내지 않는다. 그래서 히스토리는 남지만, 서버가 인식하는 주소는 모두 같기 때문에 각 앵커에 따라 적절한 글 목록을 보여주는 부분을 따로 구현해야 한다. 그 부분이 <code>$(window).on('hashchange', function() {});</code>이다. 주소에서 해시(앵커를 얘기한다)가 달라지는 이벤트를 받아서 해시가 달라시면 해시를 파싱해서 페이지 번호를 가져오고 새로운 목록을 가져오게 된다. 이렇게 하면 매번 글 목록은 요청하게 되지만 사용자로서는 "뒤로 가기"가 기대대로 동작한다. 자바스크립트는 아래와 같다.</p>
<pre class="line-numbers"><code class="language-javascript">var getList = function(page) {
$.getJSON('/pages/' + page, function(data) {
var list = $.map(data.list, function(l) {
return '<li><a href="/detail/' + l.id + '">' + l.title + '</a></li>'
}).join('');
$('#list').html(list);
$('#next').attr('href', '/pages/' + (+page+1));
window.location.hash = '#page' + page;
});
};
$('#next').click(function(event) {
event.preventDefault();
var page = $(this).attr('href').slice(7);
getList(page);
});
$(window).on('hashchange', function() {
var page = parseInt(location.hash.slice(5));
if (!!page && currentPage !== page) {
getList(page);
}
});
var currentPage = $('#next').attr('href').slice(7);
if (location.hash) {
currentPage = parseInt(location.hash.slice(5)) + 1;
}
getList(--currentPage);
</code></pre>
<p>동작 원리는 같지만 좀 더 주소처럼 보이려고 만든 것이 <a href="https://blog.outsider.ne.kr/698">해시뱅</a>이다. 해시뱅의 차이점은 <code>/hash#page1</code>를 쓰는 대신 <code>/hash/#!/page1</code>처럼 만들어서 좀 더 URL 주소처럼 만들었다는 것뿐이고 실제 동작은 위에 설명한 앵커방식과 같다. 예전의 트위터도 이 방식이었고 Backbone이나 Angular의 기본 라우팅(HTML5 모드가 아니라면)도 이 방식이었다. 이 방식을 사용하는 이유는 브라우저 지원 폭이 좋기 때문이다. 앵커 자체야 모든 브라우저에서 동작하지만 <code>hashchange</code> 이벤트를 이용하려면 <a href="http://caniuse.com/#feat=hashchange">IE 8 이상</a>이어야 한다.<br />
<br></p>
<h1>pushState를 이용한 방식</h1>
<p><code>pushState</code>는 ajax가 등장하고 이를 해결하기 위한 편법인 해시뱅이 유행하면서 "뒤로 가기"" 문제를 해결하기 위해 HTML5에 새로 추가된 History API의 새 메서드다. 자바스크립트를 약간 다뤄봤다면 폼 처리를 하면서 <code>history.go(-1);</code>이나 <code>history.forward();</code>같은 기능을 사용해 봤을 텐데 이 History API에 새롭게 추가된 메서드라고 보면 된다. 브라우저는 URL 혹은 앵커가 달라질 때마다 이를 리스트처럼 관리하고 있고 "뒤로 가기"나 "앞으로 가기"를 할 때 이 리스트를 기준으로 이동하게 된다. 그리고 앵커가 달라진 경우가 아니라면 URL이 바뀔 때마다 항상 서버에 요청을 보내게 된다.</p>
<p>하지만 <code>pushState</code>를 사용하면 URL을 바꾸면서 서버에 요청을 보내지 않을 수 있다.</p>
<p style="text-align: center;"><img src="//blog.outsider.ne.kr/attach/1/9677470103.gif" width="519" height="438" alt="pushState 방식의 데모" title="" /></p>
<p>이 예제의 동작을 확인하려면 <a href="https://pushstate-vawrtytcgl.now.sh/pushstate/page1">여기</a>서 볼 수 있다. 이 페이지는 서버에서 다음과 같은 HTML을 내려준다.</p>
<pre class="line-numbers"><code class="language-html"><!DOCTYPE html>
<html>
<head>
<title></title>
<link rel="stylesheet" href="/stylesheets/style.css">
</head>
<body>
<h1>Ajax 뒤로 가기 예제</h1>
<h3>게시글 목록</h3>
<ul id="list">
<li><a href="/detail/1">제목 1</a></li>
<li><a href="/detail/2">제목 2</a></li>
<li><a href="/detail/3">제목 3</a></li>
<li><a href="/detail/4">제목 4</a></li>
<li><a href="/detail/5">제목 5</a></li>
<li><a href="/detail/6">제목 6</a></li>
<li><a href="/detail/7">제목 7</a></li>
<li><a href="/detail/8">제목 8</a></li>
<li><a href="/detail/9">제목 9</a></li>
<li><a href="/detail/10">제목 10</a></li>
</ul>
<a href="/pushstate/page2" id="next">다음 </a>
</body>
</html>
</code></pre>
<p>HTML을 자세히 보면 처음에 본 링크방식과 유사하게 <code><ul id="list"></code> 안에 글 목록이 내려온 것을 볼 수 있다. 이는 서버에서 받아온 HTML 이므로 글 목록은 서버에서 만들어 준 것이다. <code>pushstate</code>는 새롭게 추가된 API이지만 "뒤로 가기" 및 웹에 맞게 구현하려면 해야 할 작업이 좀 더 있다. "다음"을 눌렀을 때는 ajax와 똑같이 동작한다. "다음" 버튼에서 페이지 번호를 파싱해서 <code>getList</code>를 호출하고 서버에서 글 목록을 받아와서 <code><ul id="list"></ul></code>안에 새로운 목록을 채워주고 버튼도 다음 페이지를 가리키도록 바꿔준다. 자바스크립트는 아래와 같다.</p>
<pre class="line-numbers"><code class="language-javascript">var getList = function(page) {
$.getJSON('/pushstate/page' + page, function(data) {
var list = $.map(data.list, function(l) {
return '<li><a href="/detail/' + l.id + '">' + l.title + '</a></li>'
}).join('');
$('#list').html(list);
$('#next').attr('href', '/pushstate/page' + (+page+1));
history.pushState({list: list, page: page}, 'Page '+ page, '/pushstate/page' + page);
});
};
$('#next').click(function(event) {
event.preventDefault();
var page = $(this).attr('href').slice(15);
getList(page);
});
$(window).on('popstate', function(event) {
var data = event.originalEvent.state;
$('#list').html(data.list);
$('#next').attr('href', '/pushstate/page' + (+data.page+1));
});
</code></pre>
<p>마지막에 앵커를 바꿔주는 대신 <code>history.pushState({list: list, page: page}, 'Page '+ page, '/pushstate/page' + page);</code>가 있는걸 볼 수 있다. 여기서 다음 페이지가 2라면 주소는 <code>'/pushstate/page' + page</code>에서 <code>'/pushstate/page2</code>로 바뀌게 된다. 여기서 주소는 링크방식처럼 바뀌지만, 페이지를 새로 요청하지 않고 Ajax 요청만 발생한다. <code>pushState</code>에 전달되는 인자는 상태 객체(이는 나중에 뒤로 가기를 할 때 사용한다.), 페이지 제목, 페이지 주소가 된다. 브라우저가 관리하는 히스토리 리스트에 새로운 히스토리를 하나 추가하는 것이므로 주소와 페이지, 상태객체를 전달하게 된다.</p>
<p>그리고 <code>$(window).on('popstate', function(event) {})</code> 부분을 통해 사용자가 "뒤로 가기"를 클릭했을 때 히스토리에서 상태가 나온 것을 탐지할 수 있다. 기본이라면 <code>window.onpopstate</code>로 이벤트를 받아서 <code>event.state</code>를 사용하지만 여기서는 jQuery를 사용했으므로 <code>event.originalEvent.state</code>로 앞에서 <code>pushState</code>에서 넣은 상태객체를 가져왔다. 앞에서 이 상태객체에 목록에 넣은 리스트와 "다음" 버튼에 넣을 페이지 번호를 넣었으므로 그대로 꺼내와서 다시 화면에 보여주도록 했다. 이렇게 하면 앵커를 이용한 방식과는 달리 "뒤로 가기"를 했을 때 서버에 Ajax 요청이 전혀 발생하지 않으면서도 예전 목록을 그대로 화면에 보여줄 수 있다.</p>
<p>추가로 앞의 예제와 크게 다른 점 중 하나가 해당 페이지의 주소가 <code>/pushstate/page1</code>인데 Ajax로 목록을 받아오는 주소도 <code>/pushstate/page2</code>로 완전히 같은 것을 볼 수 있다. 앞의 Ajax나 앵커를 이용한 방식이 화면이 뜬 후에 브라우저에서 목록을 가져온 방식인데 반해 <code>pushState</code>는 URL 자체를 바꿔버리므로 <code>/pushstate/page1</code>에서 "다음"을 눌러서 <code>/pushstate/page2</code>로 이동했다고 하더라도 브라우저의 동작을 생각하면 <code>/pushstate/page2</code>를 복사해서 새 창에 붙여넣으면 똑같은 목록이 나와야 한다.</p>
<p>그러므로 서버는 주소에 따라 해당 목록을 HTML로 내려줄 수 있어야 한다. 이는 서버에서 렌더링하는 방식과 브라우저에서 Ajax로 하는 방식이 섞여 있는 것이므로 서버에서 요청의 <code>Content-Type</code>이 <code>text/html</code>이면 목록을 채운 HTML 페이지를 내려주고 <code>application/json</code>이면 해당 페이지의 목록만 JSON으로 내려주도록 구현한 것이다. <code>pushState</code>를 사용함으로써 할 일은 좀 더 복잡해졌지만 사용자가 느끼는 경험은 훨씬 자연스러워졌다. pushState는 <a href="http://caniuse.com/#search=pushstate">IE 10 이상</a>에서 사용할 수 있다.<br />
<br><br></p>
<p>Node.js에서 express와 jade를 이용해서 간단하게 작성한 예제이지만 전체 소스를 확인하고 싶다면 <a href="https://github.com/outsideris/page-back-example">저장소</a>를 참고하면 된다.</p>
<p><strong><a href="https://blog.outsider.ne.kr/1276?commentInput=true#entry1276WriteComment">댓글 쓰기</a></strong></p>Keyboard Maestro와 JXA로 특정 WiFi에서 Dropbox 앱 제어하기Outsiderhttps://blog.outsider.ne.kr/12482016-10-26T00:40:24+09:002016-10-26T00:40:24+09:00<p>오랫동안 15인치 맥북 프로를 주력으로 사용하다가 올해 서브 맥북으로 12인치 뉴 맥북을 장만하고 개인 맥북으로 코딩할 시간이 많지 않은 평일에는 맥북을 가지고 다니고 카페에 오래 있는 주말에는 맥북 프로를 사용하고 있다. 맥북 프로를 가지고 다닐 때는 <a href="https://blog.outsider.ne.kr/1212">HHKB BT</a>를 가지고 다니므로 편하고 평일에는 가방이 가벼워서 너무 좋다.(키감은 수개월이 지나도 전혀 적응이 안 되지만...)</p>
<p>맥북을 2개를 바꿔가면서 사용하다 보니 두 맥북의 동기화가 문제가 되었다. 대부분의 설정이나 이런 부분은 Dropbox를 이용해서 이미 동기화를 하고 있었고 안된 부분은 추가로 처리했다. 가장 큰 건 보통 개발을 하는 프로젝트 폴더였는데 오랫동안 쌓인 많은 프로젝트도 있고 여기에 많은 모듈도 설치되어 있으므로 용량이 12GB 정도 되었다. 이 프로젝트 폴더 전체를 동기화하는 방법을 여러 가지로 고민했는데 최종적으로는 기존의 사용하던 Dropbox를 유료로 결제해서 사용하고 있다. 초기에 두 맥북 간에 동기화를 하는데 아주 오래 걸렸지만, 지금은 작업한 내용만 동기화하므로 많은 시간이 걸리지는 않았다.(파일이 많아서 인덱싱에는 많은 시간이 걸리지만...)</p>
<p>그동안 잘 사용하고 있었는데 하나의 맥북을 사용할 때는 파일을 이리저리 계속 수정하고 모듈도 설치했다 지웠다 하는데 이게 바로바로 동기화되니까 외부에서 내 모바일 데이터가 낭비되는 기분이 들었다.(실제로도 그러고 있고..)</p>
<p>그래서 사실 두 맥북을 바꿔 쓸 때만 동기화하면 되므로 외부에서는 동기화되지 않다가 집에 왔을 때만 동기화가 되었으면 좋겠다는 생각을 했다. 내가 외부에 오면 동기화를 끄고 집에 오면 키면 좋겠지만, 이는 매우 귀찮은 일이므로 잘 될 리가 없었고 이게 자동화되었으면 좋겠다는 생각을 했다.</p>
<p>특정 WiFi에서만 동작하는 방법이 있는지 SNS에 묻자 AppleScript로 WiFi를 확인하는 방법과 <a href="https://www.controlplaneapp.com/">ControlPlane</a>와 <a href="http://www.keyboardmaestro.com/">Keyboard Maestro</a>를 추천받았다. AppleScript로 작성하는 방법은 결국 크론탭 같은 거로 계속 확인해야 하므로 실제로는 WiFi에 접속할 때 특정 액션을 호출하는 프로그램이 필요했는데 <a href="https://www.controlplaneapp.com/">ControlPlane</a>은 작년에 릴리스 이후 릴리스가 없어서(GitHub에 커밋은 되는 것 같았지만...) 불안했고 이미 사서 가지고 있던 Keyboard Maestro에 관심이 갔다.<br />
<br></p>
<h1>Keyboard Maestro</h1>
<p><a href="http://www.keyboardmaestro.com/">Keyboard Maestro</a>는 특정 상황이나 단축키를 통해서 원하는 액션을 자유롭게 실행할 수 있는 macOS 유료 앱이다.</p>
<p style="text-align: center;"><img src="//blog.outsider.ne.kr/attach/1/3525689143.gif" width="750" height="483" alt="Keyboard Maestro의 매크로" title="" /></p>
<p>Keyboard Maestro에서 새로운 매크로를 만들면 트리거로 Wireless Network Trigger를 선택할 수 있는 것을 볼 수 있다.</p>
<p style="text-align: center;"><img src="//blog.outsider.ne.kr/attach/1/2947493120.gif" width="700" height="391" alt="Keyboard Maestro의 WiFi 트리거" title="" /></p>
<p>Wireless Network Trigger를 선택하면 WiFi 이름을 체크해서 연결과 연결 해지에 따라 특정 액션을 선택할 수 있다는 것을 알게 되었다.(Keyboard Maestro를 산 지 꽤 오래되었지만 잘 사용하지는 못했다.. 이번에 7로 유료 업그레이드를 했지만..)</p>
<p>이 트리거에 연결할 수 있는 액션으로 수많은 액션이 있지만 내가 원하는 것은 Dropbox의 동기화를 키거나 끄는 기능이므로 결국은 스크립트를 실행하도록 해야 했다. Keyboard Maestro에서 실행할 수 있는 스크립트는 AppleScript, Shell Script, Swift Script, JavaScript 등 다양하게 있는데 내가 선택한 건 내가 익숙한 JavaScript이다. 트리거가 되는 건 확인했으므로 원하는 동작을 하는 스크립트만 작성하면 되는 것이었다.<br />
<br></p>
<h1>JavaScript for Automation</h1>
<p>macOS는 AppleScript를 내장하고 있지만, 요세미티부터 Automation에 JavaScript를 사용할 수 있는 <a href="https://developer.apple.com/library/content/releasenotes/InterapplicationCommunication/RN-JavaScriptForAutomation/Articles/Introduction.html">JXA(JavaScript for Automation)</a>를 지원하고 있다. Node.js와 상관없이 macOS 네이티브로 JavaScript를 사용할 수 있다는 의미이다.(물론 완전히 같지 않으므로 여기에는 수많은 삽질이 필요하고 문서 상태도 엉망이다.)</p>
<p>이 글은 JXA의 사용법을 설명하는 것은 아니므로 필요한 부분만 설명하겠다. 기본적으로 JXA는 macOS의 오토메이터에서 실행해 주는 것이므로 JavaScript면 실행이 가능하지만 오토메이터에서 매번 테스트하는 것은 귀찮으므로 파일 상단에 <code>#!/usr/bin/env osascript -l JavaScript</code>를 지정하면 터미널에서 바로 실행해 볼 수 있다.(실제 오토메이터 등으로 실행할 때는 이 부분이 필요 없다.)</p>
<pre class="line-numbers"><code class="language-javascript">#!/usr/bin/env osascript -l JavaScript
console.log('Hello World');
</code></pre>
<p>위 파일이 <code>test.js</code>라고 하면 이 파일에 실행 권한을 주고 실행하면 Node.js와 비슷하게 바로 실행할 수 있다.</p>
<pre class="line-numbers"><code class="language-bash">./test.js
Hello World
</code></pre>
<p>AppleScript를 알면 좀 더 작성하기 쉽긴 한데 AppleScript와 비슷하게 JXA로도 Mac의 애플리케이션에 접근할 수 있다.</p>
<pre class="line-numbers"><code class="language-javascript">#!/usr/bin/env osascript -l JavaScript
const itunes = Application('iTunes');
console.log( itunes.id() ); // -> com.apple.iTunes
console.log( itunes.running() ); // -> false
</code></pre>
<p>iTunes 앱을 가져와서 id를 확인하고 현재 실행 중인지를 확인할 수 있다. 여기서 <code>itunes.play()</code>, <code>itunes.pause()</code>, <code>itunes.stop()</code>같은 메서드로 아이튠스를 코드로 제어할 수 있고 비슷한 작업을 Dropbox 앱으로도 할 수 있다.</p>
<pre class="line-numbers"><code class="language-javascript">#!/usr/bin/env osascript -l JavaScript
const dropbox = Application('Dropbox');
console.log(dropbox.id()); // com.getdropbox.dropbox
console.log(dropbox.running()); // true
</code></pre>
<p><br></p>
<h1>JXA로 Dropbox를 제어하기 위한 삽질</h1>
<p>잘 동작하는 것 같았지만 Dropbox에 어떤 메서드가 노출되어 있는지 알 수가 없었다. <code>Object.keys(dropbox)</code>같은 거로 돌려봐도 아무것도 나오지 않았고 코드 실행으로는 이 내용을 테스트해보기가 어려웠다. 메서드 혹은 속성이 있는지 확인해 보려고 출력하면 <code>function () { [native code] }</code>라고 나오지만 실행하면 <code>Error: 메시지를 이해할 수 없습니다. (-1708)</code>같은 오류만 나와서 실행을 잘못한 건지 파라미터가 잘못된 건지 도저히 알 수가 없었다.</p>
<p>찾다 보니 다행히 <a href="https://github.com/wtfaremyinitials/jxa">JXA를 repl형태로 실행해 볼 수 있는 프로젝트</a>가 있어서 테스트를 해보았다.(Node.js의 repl을 <code>osascript -l JavaScript</code>로 실행한 것이다.)</p>
<pre class="line-numbers"><code class="language-javascript">> const itunes = Application('iTunes');
undefined
> itunes.running()
true
> itunes.running
[object JXAReference => function () {
[native code]
}]
> itunes.asdfzxcv
[object JXAReference => function () {
[native code]
}]
> itunes.asdfzxcv()
0:49: execution error: Error on line 1: Error: 메시지를 이해할 수 없습니다. (-1708)
undefined
</code></pre>
<p>JXA repl로 일단 많이 알려진 iTunes를 테스트해본 것인데 <code>running</code>이라는 존재하는 함수와 도저히 없을 것 같은 <code>asdfzxcv</code>가 모두 <code>function() {}</code>으로 출력된다. 내부 구조는 모르지만 내 추측으로는 모든 속성은 <code>JXAReference</code>라는 객체가 받게 되고 이를 macOS 앱으로 전달하는 것으로 보인다. 그래서 실행해 보기 전에는 존재 여부를 알 수 없고 실행을 했는데 문제없으면 결과가 나오지만 이러한 메서드가 없다면 오류가 나므로 그 결과를 보여주는 것이다.</p>
<p>결국, 속성을 찾을 수 없다는 결론에 이르고 있었는데 그럼 얼마안되는 JXA 문서에 나오는 iTunes, Mail 등 앱에 대한 JXA 예제의 코드는 어디서 나온 것인가 찾다 보니 macOS에 기본 설치된 <strong>스크립트 편집기</strong>라는 앱을 알게 되었다.</p>
<p style="text-align: center;"><img src="//blog.outsider.ne.kr/attach/1/5065959845.gif" width="250" height="568" alt="스크립트 편집기의 라이브러리" title="" /></p>
<p>이 앱에서 [윈도우] 메뉴의 [라이브러리]를 실행하면 위 화면처럼 기본으로 포함된 앱의 목록이 나온다. 여기서 iTunes를 클릭해 보면 다음과 같이 사용할 수 있는 메서드 목록을 볼 수 있다.</p>
<p style="text-align: center;"><img src="//blog.outsider.ne.kr/attach/1/8504138549.gif" width="750" height="632" alt="스크립트 편집기의 iTunes API" title="" /></p>
<p>앱에서 사용할 수 있는 인터페이스를 모두 볼 수 있고 AppleScript, JavaScript, Objective-C 별로 코드를 볼 수 있다. 저 라이브러리에서 +를 누르면 추가할 수도 있지만 Dropbox에 대한 스크립트 파일은 없으므로 추가할 수 없었다. 보통의 애플리케이션 개발을 생각했을 때 저 API는 앱을 개발할 때 퍼블릭으로 노출한 API일 테고 Dropbox에는 이러한 API 문서를 찾을 수가 없었고 세부 기능까지 퍼블릭으로 제공한다는 보장을 할 수도 없었다. 문서도 공개 안 한 거 보면 노출 안 했을 가능성이 훨씬 높아 보였다.</p>
<p>비슷한 질문은 있어도 해결책은 마땅히 없어서(아직 베타인 <a href="https://github.com/dropbox/dbxcli">dbxcli</a>가 있지만 여기선 주로 파일 제어만 한다.) 거의 포기 직전이었다. dropbox의 다른 API 문서를 보고 대충 있을 것 같은 메서드를 호출해 봤지만, 도저히 찾을 수도 없었고 단순 기능 이상의 동기화를 시작하고 멈추는 기능은 있을 것 같지 않았다.</p>
<p>그러다가 앞에서 <code>id()</code>, <code>running()</code>같은 일반적인 메서드는 존재했던 것처럼 <code>launch()</code>, <code>quit()</code>로 앱을 시작하고 종료할 수 있다는 것을 알게 되었다.<br />
<br></p>
<h1>Keyboard Maestro와 JXA로 Dropbox 연동하기</h1>
<p>원하는 것은 집에 들어가면 즉, 집에 있는 WiFi에 접속하면 Dropbox의 동기화를 시작하고 집에 있는 WiFi에 접속하지 않으면 Dropbox 동기화를 멈추는 것이었지만 동기화 대신 앱을 종료했다가 실행해도 결과는 똑같다는 생각이 들었다. 어차피 앱이 꺼져있으면 동기화를 안 할 것이고 앱이 시작하면 자동으로 동기화를 시작할 테니까...</p>
<p>그래서 Keyboard Maestro에 매크로를 2개 만들었다. 삽질한 고생에 비해서는 결과물은 아주 간단하다.</p>
<p style="text-align: center;"><img src="//blog.outsider.ne.kr/attach/1/2097250171.gif" width="750" height="606" alt="집 WiFi에서 Dropbox를 켜는 매크로" title="" /></p>
<p>간단하므로 뻔하지만 "Home"이라는 무선 네트워크에 접속하면 하단의 JXA를 실행한다. 코드가 짧아서 그냥 매크로에 넣어버렸고 결과를 볼 필요는 없으므로 비동기로 실행했다. 코드는 Dropbox 앱을 가져와서 동작하고 있는지 확인하고 꺼져있다면 실행을 한다.</p>
<p style="text-align: center;"><img src="//blog.outsider.ne.kr/attach/1/6487763371.gif" width="750" height="616" alt="집 WiFi에서 Dropbox를 끄는 매크로" title="" /></p>
<p>이번엔 반대의 매크로다. "Home"이라는 무선 네트워크의 접속이 끊기면 Dropbox가 실행 중인지 확인하고 실행 중이면 종료한다. 테스트해보면 아주 잘 된다. 며칠째 사용하고 있는 데 큰 문제는 없는 것으로 보이고 한두 번 동작 안된다고 큰 문제가 있는 것도 아니긴 하다.(동기화 중에 갑자기 종료해버렸을 때 문제가 생기는 건 아닌지 약간 걱정되기는 하다. 그래서 동기화 멈춤을 하고 싶었던 건데...)</p>
<p>결과물이 너무 간단해서 허무하지만 JXA의 맛 정도는 봤다. 조금씩 써보기 시작해야지.</p>
<p><strong><a href="https://blog.outsider.ne.kr/1248?commentInput=true#entry1248WriteComment">댓글 쓰기</a></strong></p>