토스트 드라이브 프로젝트에서 실제 구현해야 했던 명세 중 Generator 적용 효율이 제일 높았던 명세를 조금 수정했다. 파일 또는 폴더를 선택한 후 다른 폴더로 이동할 때의 명세다.
다음은 웹 기반 파일 시스템 구현 프로젝트의 일부 기능이다.
파일 목록에서 선택한 다수의 파일 및 폴더를 다른 폴더로 이동할 수 있다.
이때, 이동 대상 폴더에 이름이 같은 파일 및 폴더가 이미 존재할 경우 모든 건에 대해서 사용자에게 확인한다.
예를 들어 ‘새 폴더’라는 이름이 중복될 경우, ‘이동하려는 폴더에 이름이 중복된 폴더가 있습니다.
[새 폴더(1)]이라는 이름으로 변경 후 이동하시겠습니까?’라는 문구의 확인용 디자인 레이어를 띄운다.
'확인'을 누르면 그 이름으로 이동시킨다. 이때 '이후의 모든 항목에 적용'
체크박스를 누르면 이후 중복 건은 모두 '확인' 처리한다.
'취소'를 누르면 그 파일에 대해 취소한다. 마찬가지로 '이후의 모든 항목에 적용'
체크박스를 누르면 이후 중복 건을 모두 '취소' 처리한다.
사용자가 모든 중복 건에 대해 확인하면 모든 건에 대해 이동 처리한다.
서버에서 제공하는 API 목록
GET '/api/check-duplication'
[이름 중복 확인]
- 특정 폴더의 id와 생성할 이름을 전달
- 중복되는 경우 사용 가능한 새 이름과 함께 409 응답
- 가능한 경우 200 응답
POST '/api/move'
[파일 및 폴더 이동]
- 이동 대상 파일 및 폴더의 id 목록과 이동 대상 폴더의 id 전달
- 오류가 없으면 모든 이동을 처리 후 200 응답
파일 및 폴더 이동 API에서 중복을 함께 처리하는 것이 이상적이지만, 이 글에서 중요한 부분은 아니므로 당장은 두 개의 API를 사용해야 한다고 가정한다.
또 실제 API는 이동의 결과를 웹 소켓을 통해 클라이언트에게 알리지만. 설명을 쉽게 하려고 단일 HTTP 요청, 응답을 이용하는 것으로 가정한다.
먼저 현재 선택된 파일과 폴더(이하 '개체' 라 함)의 이름과 이동 대상 폴더의 id를 조회해 [이름 중복 확인] API를 호출해야 한다. 200 응답을 받으면 바로 [파일 및 폴더 이동] API를 호출하면 되지만 중복 오류가 응답할 경우 모든 중복에 대해 사용자 응답을 수집해야 한다.
모든 이름 중복 건에 대해 사용자의 응답을 수집할 때 window.confirm
을 사용하면 프로세스가 멈추기 때문에 모든 항목에 대해 수월하게 사용자의 선택을 수집할 수 있다.
하지만 '이후 모든 항목에 적용'이라는 체크박스 기능을 지원해야 하므로 사용할 수 없다. 결국, 별도의 '디자인 레이어'를 구현해야 한다. 이 '디자인 레이어'에서 사용자가 응답하기까지 기다리는 행위도 일종의 비동기 처리이므로 구현하기 까다로운 부분에 속한다.
정리하면 '이름 중복 확인', '중복되는 모든 항목의 사용자 응답 수집', '개체 이동'의 비동기 처리를 순차적으로 수행해야 한다.
요구사항의 '디자인 레이어'를 콜백 패턴으로 구현하면 다음과 같다. jsConfirm
은 원래 메시지를 받아서 보여주어야 하지만. 중요한 것이 아니므로 처리했다고 가정하자.
<div id="layer">
<input type="checkbox" id="checkbox" />
<button type="button" id="ok">ok</button>
<button type="button" id="cancel">cancel</button>
</div>
<script>
const layer = $("#layer");
const checkbox = $("#checkbox");
/**
* @param {string} msg - confirm 에 출력할 메시지 내용
* @param {function} cb - callback 함수
*/
function jsConfirm(msg, cb = () => {}) {
checkbox.prop("checked", false);
layer.show().on("click", ev => {
const target = $(ev.target);
const checked = checkbox.prop("checked");
if (target.is("#ok")) {
layer.hide().off();
cb({ confirmed: true, checked });
} else if (target.is("#cancel")) {
layer.hide().off();
cb({ confirmed: false, checked });
}
});
}
</script>
그다음은 중복 건에 대해 반복적으로 사용자의 확인을 쌓아야 한다. 먼저 이름 중복 데이터의 Mock을 준비한다. 이 객체는 [중복 확인] API의 응답이라고 가정할 객체이다.
// [이름 중복 확인] API의 응답 데이터 (요청한 이름, 사용가능한 이름 쌍)
const dupList = [["foo", "foo(2)"], ["bar", "bar(2)"], ["baz", "baz(2)"]];
단순히 사용자의 응답을 쌓기 위해서는 콜백을 재귀적으로 구성하면 된다. 하지만 콜백을 중첩을 직접 작성하는 것은 하드 코딩이기 때문에 동적인 중복 항목 리스트에 대응할 수 없다. 따라서 재귀를 사용한다.
// 이 변수에 확인 항목을 모은다
let resolved = [];
function resolveDuplicates(idx) {
const item = dupList[idx];
jsConfirm(`msg${idx}`, ({ confirmed, checked }) => {
if (confirmed) {
// A '확인'
resolved.push(item);
}
idx += 1;
if (checked) {
if (confirmed) {
// B '이후 모든 항목에 적용', '확인'
resolved = [...resolved, ...dupList.slice(idx)];
} else {
// C '이후 모든 항목에 적용', '취소'
}
return;
}
if (!dupList[idx]) {
// D 모든 항목 완료
return;
}
resolveDuplicates(idx);
});
}
resolveDuplicates(0);
아직 API 호출 처리는 시작조차 하지 않았다. [중복 확인] API를 먼저 호출한 후의 결과에 따라 resolveDuplicates
를 실행해야 하므로 위의 코드는 이미 depth가 증가한 상태일 것이다.
또 코드의 A, B, C, D 부분에서도 API 호출을 위한 콜백이 추가될 것이다. 함수를 추출하는 방법 등의 리팩토링을 한다고 해도 콜백 패턴으로는 한계가 있다. 만들어지는 코드는 알아보기 어렵고 유지 보수하기 어렵다.
콜백 중첩 문제는 오래전부터 등장한 Promise를 이용하면 어느 정도 해결할 수 있다. Promise란 미래의 어떤 값을 받을 수 있는 객체이다. Kyle Simpson은 햄버거를 주문하고 받는 대기표로 비유했는데 웃기지만 정확한 비유다. Promise는 어떤 값 하나를 받을 수 있는 것을 보장하는 인터페이스이다.
'디자인 레이어'의 확인, 취소도 이 Promise를 사용할 수 있다. '디자인 레이어'가 Promise를 반환하도록 구현해 보자. 이 과업은 이 글의 끝에 Generator를 사용한 코드를 작성하기 위한 밑거름이기도 하다.
/**
* @param {string} msg - confirm 에 출력할 메시지 내용
* @returns {Promise}
*/
function jsConfirm(msg) {
// Return Promise
return new Promise((resolve, reject) => {
checkbox.prop("checked", false);
layer.show().on("click", ev => {
const target = $(ev.target);
const checked = checkbox.prop("checked");
if (target.is("#ok")) {
layer.hide().off();
resolve({ confirmed: true, checked });
} else if (target.is("#cancel")) {
layer.hide().off();
resolve({ confirmed: false, checked });
}
});
});
}
jsConfirm은 이제 Promise 객체를 반환한다. 사용자의 응답을 객체로 만들어 resolve를 실행하고 있다. Promise의 기본 스펙에 궁금한 독자는 아래 링크를 확인 바란다.
이를 바탕으로 중복항목 체크를 Promise로 리팩토링 해 보자. 목록에 대해 순차적으로 jsConfirm을 호출해야 하므로 Promise를 이어 붙이는 방법으로 구현한다. 이 방법은 배열의 요소에 대해서 Promise를 순차 처리할 때 유용하다.
let resolved = [];
dupList
.reduce((promise, item, idx) => {
return promise.then(() => {
return jsConfirm().then(({ confirmed, checked }) => {
if (confirmed) {
// A '확인'
resolved.push(item);
}
idx += 1;
if (checked) {
if (confirmed) {
// B '이후 모든 항목에 적용', '확인'
resolved = [...resolved, ...dupList.slice(idx)];
} else {
// C '이후 모든 항목에 적용', '취소'
}
// Promise 체인을 완전히 탈출하기 위함
throw new Error("checked");
}
});
});
}, Promise.resolve())
.then(() => {
// D 모든 항목 완료
console.log(resolved);
});
전보다 훨씬 보기 쉬워졌는가? 개인적으로 크게 달라진 점은 없어 보인다. 재귀 코드가 없어지고 모든 항목의 완료 처리를 맨 마지막 then
체인에서 할 수 있다는 것과, catch
를 사용할 수 있게 되었단 점 정도이다. 이 catch
는 Promise 체인의 어느 곳에서라도 발생한 오류가 모이므로 사용자 오류, XHR오류 등등을 한 곳에서 처리할 수 있게 된 것이다.
사실 그리 알아보기 쉬운 편은 아니다. Promise 자체에 이해가 없는 개발자에게는 더더욱 그럴 것이고 무엇보다도 앞서 이야기했던 '비동기 코드를 동기 코드처럼 쓸 수 있다'라는 이야기를 설명할 수 없다.
Generator 명세가 나오기 전까지만 해도 이 방법이 최선이었지만 지금은 아니다. 리팩토링해 보자.
1부에서 사용했던 Promise Runner와 이전 장에서 구현했던 Promise base jsConfirm을 그대로 사용한다.
run(function*() {
let resolved = [];
for (let i = 0, len = dupList.length; i < len; i += 1) {
const item = dupList[i];
const { confirmed, checked } = yield jsConfirm();
if (confirmed) {
// A 확인
resolved.push(item);
}
if (checked) {
if (confirmed) {
// B '이후 모든 항목에 적용', '확인'
resolved = [...resolved, ...dupList.slice(i + 1)];
} else {
// C '이후 모든 항목에 적용', '취소'
}
break;
}
}
// D 모든 항목 완료
console.log(resolved);
});
Promise Runner 함수 덕분에 jsConfirm
가 반환하는 Promise의 응답이 올 때까지 일시 정지할 수 있게 되었다. 그 덕분에 일반적인 for 반복문으로도 비동기 처리를 할 수 있게 되었다.
코드의 주석 표기는 A, B, C, D로 나뉘어 있지만, 결과적으로 D로 수렴하기 때문에 [파일 및 폴더 이동] API는 D 위치에서 일괄적으로 요청할 수 있다. 계속해서 API 호출까지 구현해 본다.
function* moveObjects(destId, targetIdList) {
// [이름 중복 확인] API 호출
const dupList = yield axios.get("/api/check-duplicate?dest...");
// [중복되는 모든항목의 사용자 응답 수집]
let resolved = [];
for (let i = 0, len = dupList.length; i < len; i += 1) {
const item = dupList[i];
const { confirmed, checked } = yield jsConfirm();
if (confirmed) {
// A 확인
resolved.push(item);
}
if (checked) {
if (confirmed) {
resolved = [...resolved, ...dupList.slice(i + 1)];
}
break;
}
}
// [개체 이동] API 호출
yield axios.post("/api/move", { resolved /* request data */ });
}
요구사항을 moveObjects Generator 함수 하나로 구현했다. Callback, Promise보다 훨씬 간단하게 구현할 수 있었다. 오류 처리는 어떻게 할까? 그냥 try...catch
를 사용하면 된다. 실행을 보장하는 finally
는 덤이다.
function* moveObjects(destId, targetIdList) {
try {
/* 구현 */
} catch (err) {
// API 호출 오류 처리
// 기타 오류 처리
} finally {
// 필요의 경우 구현 (React의 경우 이전 상태로 복원 등...)
}
}
사실 위의 에러 처리를 하려면 run 코드는 약간 수정이 필요하다. 1부에서 소개했던 Promise Runner를 수정해 보자
function run(gen) {
const iter = gen();
(function iterate({ value, done }) {
if (done) {
return value;
}
if (value.constructor === Promise) {
value
.then(data => iterate(iter.next(data)))
.catch(err => iter.throw(err)); // 여기가 추가된 코드
} else {
iterate(iter.next(value));
}
})(iter.next());
}
에러 처리 코드는 같은 스코프 코드까지만 유효하다. 비동기 코드는 현재 스코프보다 이후 스코프 또는 프레임에서 실행되기 때문에 비동기 코드 바깥에서 에러를 잡으려면 직접 전달해주어야 한다.
try {
jQuery.ajax({
success: () => {
//여기서 오류가 발생해도
}
});
} catch (err) {
// 여기서 잡을 수 없다.
}
run
내부의 iterate
실행 타이밍과 Promise 처리 코드가 그 예다. 그래서 Promise의 오류를 직접 Iterator의 throw
로 전달하도록 수정한 것이다. 이제 오류 처리를 포함해 Generator를 다룰 수 있게 되었다.
서비스 지원 범위가 Chrome, Firefox 최신 버전이면 바로 사용할 수 있다. 하지만 그렇지 않을 경우 트랜스파일러를 사용해야 한다. 8/8일 현재 Generator를 es5기반 코드로 변환해주는 트랜스파일러는 Facebook 의 regenerator가 유일하다. 이 regenerator는 runtime과 transpiler로 구성되어 있는데 runtime은 압축 시 1KB 미만이므로 부담이 없다.
이미 서버에 많은 자바스크립트 코드가 있고 이 중 Generator를 적용하고 싶은 코드가 있다고 가정하자.
// 서비스 코드
<script>
// 네임스페이스 설정
window.ne = { toastDrive: {} };
</script>
<script src="src/js/generators.js"></script>
<script>
ne.toastDrive.logList(["a", "b"]);
</script>
아래 generators.src.js
는 트랜스파일되기 전 Generator 함수들이 모여있는 파일이다.
// src/js/generators.src.js
function run(gen) {/* runner */}
ne.toastDrive.logList = function(dupList) {
run(function*() {
for (let item of dupList) {
console.log(item);
yield item;
}
});
};
이제 regenerator를 이용해 이 파일을 트랜스파일한다.
// regenerator 설치
npm install -g regenerator
// --include-runtime을 통해 의존 모듈을 포함하여 트랜스파일
regenerator --include-runtime src/js/generators.src.js > src/js/generators.js
이렇게 하면 기존 코드를 유지하면서도 필요한 부분에만 Generator를 사용할 수 있다. 저 regenerator실행 스크립트를 배포 시 자동으로 실행하도록 하면 수동으로 배포 전 실행하지 않아도 된다.
Generator를 사용하면 '비동기 코드를 동기 코드처럼' 작성할 수 있다. 복잡한 비동기 흐름을 알아보기 쉽게 작성할 수 있고 이는 흐름이 복잡하면 복잡할수록 빛을 발한다.
Promise, Generator, BabelJS 의 개념과 사용법을 익혀야 하지만. 충분히 그럴만한 가치가 있다. 만약 조금 더 욕심이 있다면 이 글에서 간략하게 다루었던 Callback, Promise, Generator 순서의 패턴의 등장 배경과 단점, 다음 방법이 가져다주는 이점을 파악한다면 어떤 JavaScript 요구사항도 어렵지 않게 해결할 수 있게 될 것이다.