본문 바로가기

Back-end/Node.js

콜백(Callback)의 비동기 Control flow 패턴 (2) - 순차 반복(sequential iteration)

* 이 글은 Mario Casciaro, Luciano Mammino가 저서한 <Node.js 디자인 패턴 바이블> 서적을 참고한 게시글입니다.

 

서적 정보 : http://www.kyobobook.co.kr/product/detailViewKor.laf?mallGb=KOR&ejkGb=KOR&barcode=9788931464283

 

Node.js 디자인 패턴 바이블 - 교보문고

검증된 패턴과 기술을 이용한 수준 높은 Node.js | 이 책은 이미 Node.js를 처음 접한 후 이제 생산성, 디자인 품질 및 확장성 측면에서 최대한 활용하고자 하는 개발자를 대상으로 합니다. 이 책은

www.kyobobook.co.kr

 


 

지난 글에서는 웹 스파이더의 콜백 지옥을 해소하여 리팩토링하는 작업을 했었다.

 

이번엔 순차 반복(sequential iteration) 패턴에 대해 소개해보고자 한다.

 

 

 

0.  순차 실행(sequential execution)과 순차 반복(sequential iteration)

 

순차 실행은 말 그대로 '순서대로' 실행하는 것이다.

 

가령 task1, task2, task3에 해당하는 코드 작업이 놓여져 있고, 1->2->3 순서에 맞게 실행해야 한다면,

 

(그리고 그것을 콜백 기반으로 수행해야 한다면)

 

다음과 같이 구현할 수 있다.

// callback을 사용한 순차 실행
const task1 = (callback) => {
    asyncOperation(() => {
        task2(callback);
    });
}
const task2 = (callback) => {
    asyncOperation(() => {
        task3(callback);
    });
}
const task3 = (callback) => {
    asyncOperation(() => {
        callback();
    });
}

task1(() => {
    // task1, task2, task3이 완료되었을 때 실행된다.
    console.log("tasks are executed");
});

 

순차 실행 패턴은 실행될 작업의 수(3개)와 양을 미리 알고 있는 경우에는 완벽하게 동작한다.

 

하지만, 만약 특정 컬랙션의 각 항목들에 대해 작업을 해야하는 경우에는 분명 동적으로 구현해야 한다.

 

이런 경우 순차 반복 패턴이 필요한데, 우리의 웹 스파이더를 통해 알아보자.

 

 

 

 

1.  웹 스파이더 (v2)

 

우리의 웹 스파이더를 업그레이드할 건데, 

 

해당 url에 포함된 모든 링크를 재귀적으로 다운로드하는 어플리케이션으로 변경하고자 한다.

 

이를 위해 페이지의 모든 링크를 재귀적으로 다운로드하는 spiderLinks()라는 함수를 만들 것이다.

 

그리고, 파일이 이미 존재하는지 검사하는 대신, 이제 해당 파일에 대한 읽기를 먼저 시도하여 파일 내의 링크를 수집하도록 변경한다.

 

우선 기존 웹스파이더를 다음과 같이 수정해보자.

import fs from "fs";
import path from "path";
import superagent from "superagent";

const urlToFilename = (url) => {
    const filename = url.split("/").pop();
    return filename.split(".")[1] + "/" + filename;
};
// saveFile
const saveFile = (filename, contents, callback) => {
    fs.mkdir(path.dirname(filename), (err) => {
        if (err) {
            return callback(err);
        }

        fs.writeFile(filename, res.text, callback);
    });
};
// download
const download = (url, filename, callback) => {
    console.log(`Downloading ${url} into ${filename}`);
    superagent.get(url).end((err, res) => {
        if (err) {
            return callback(err);
        }

        saveFile(filename, res.text, (err) => {
            if (err) {
                return callback(err);
            }

            console.log(`Downloaded and saved: ${url}`);
            callback(null, res.text);
        });
    });
};
// spider
export const spider = (url, callback) => {
    const filename = urlToFilename(url);

    // 파일을 읽는다
    fs.readFile(filename, "utf8", (err, fileContent) => {
        if (err) {
            if (err.code !== "ENOENT") {
                return callback(err);
            }

            return download(url, filename, (err, requestContent) => {
                if (err) {
                    return callback(err);
                }

                // 재귀적으로 페이지 안의 링크들을 조회
                spiderLinks(url, requestContent, nesting, callback);
            });
        }

        spiderLinks(url, fileContent, nesting, callback);
    });
};

nesting은 재귀의 깊이를 제한하는 용도의 인자로 선언해 두었다.

 

다음으로, spiderLinks() 함수를 살펴보기 전에, utlis.mjs를 하나 생성해서 필요한 함수들을 생성해보자.

// utils.mjs
import path from 'path'
import { URL } from 'url'
import slug from 'slug'
import cheerio from 'cheerio'

function getLinkUrl (currentUrl, element) {
  const parsedLink = new URL(element.attribs.href || '', currentUrl)
  const currentParsedUrl = new URL(currentUrl)
  if (parsedLink.hostname !== currentParsedUrl.hostname ||
    !parsedLink.pathname) {
    return null
  }
  return parsedLink.toString()
};

export function urlToFilename (url) {
  const parsedUrl = new URL(url)
  const urlPath = parsedUrl.pathname.split('/')
    .filter(function (component) {
      return component !== ''
    })
    .map(function (component) {
      return slug(component, { remove: null })
    })
    .join('/')
  let filename = path.join(parsedUrl.hostname, urlPath)
  if (!path.extname(filename).match(/htm/)) {
    filename += '.html'
  }

  return filename
}

export function getPageLinks (currentUrl, body) {
  return Array.from(cheerio.load(body)('a'))
    .map(function (element) {
      return getLinkUrl(currentUrl, element)
    })
    .filter(Boolean)
};

 

getPageLinks()는 페이지에 포함된 모든 링크를 얻는데, 동일 호스트를 가리키는 링크들만 걸러서 반환하도록 구현했다.

 

(urlToFilename도 내용을 추가해서 별도로 빼두었다)

 

원본 코드는 여기에서 확인할 수 있다.

 

이제 spiderLinks()를 구현해보자.

// spiderLinks
const spiderLinks = (currentUrl, body, nesting, callback) => {
    if (nesting === 0) {
        return process.nextTick(callback);
    }

    const links = getPageLinks(currentUrl, body);

    if (links.length === 0) {
        return process.nextTick(callback);
    }

    const iterate = (index) => {
        if (index === links.length) {
            return callback();
        }

        spider(links[index], nesting - 1, (err) => {
            if (err) {
                return callback();
            }

            iterate(index + 1);
        });
    };

    iterate(0);
};

 

process.nextTick() 함수는, 현재 진행 중인 작업의 완료 시점 뒤로 인자로 받은 함수의 실행을 지연시킨다.

 

다시 말해, 콜백을 인자로 받아 현재 진행중인 작업이 끝나고 제어가 이벤트 루프로 넘어가는 즉시 콜백을 실행시킨다.

 

iterate()는 페이지에 담긴 링크 컬랙션들을 순회하며 spider()를 반복적으로 호출해준다.

 

이제 nesting까지 조절할 수 있도록 spider-cli.mjs도 수정하자.

// spider-cli.mjs
import { spider } from "../spider.mjs";

const url = process.argv[2];
const nesting = Number.parseInt(process.argv[3], 10) || 1;

spider(url, nesting, (err) => {
    if (err) {
        console.error(err);
        process.exit(1);
    }

    console.log(`Download complete`);
});

 

 

 

 

2.  iterate()

 

앞서 잠깐 소개한 iterate()는 콜백 형태로 비동기 작업들을 순차적으로 수행하는 패턴을 보여준다.

 

좀더 일반화를 해보자면,

// iterate
const iterate = (index) => {
    if (index === tasks.length) {
        return finish();
    }
    
    const task = tasks[index];
    task(() => iterate(index + 1))
 }
 
 const finish = () => {
     // 반복 완료
 }
 
 
 // Usage
 iterate(0);

 

위와 같이, tasks 안의 각 작업들을 처음부터 순차적으로 실행하고, 재귀적으로 다음 task를 찾아가는 패턴을 보여주고 있다.