LHJ

I'm a FE developer.

14.3.3 이벤트

21 May 2020 » js_lj

14.3.3 이벤트

이벤트는 자바스크립트에서 자주 사용됩니다.
이벤트의 개념은 간단합니다.
이벤트가 일어나면 이벤트 발생을 담당하는 개체(emitter)에서 이벤트가 일어났음을 알립니다.
필요한 이벤트는 모두 주시(listen)할 수 있습니다.
어떻게 이벤트를 주시할까요?
물론 콜백을 통해서입니다.
이벤트 시스템을 직접 만드는 것도 별로 어려운 일은 아니지만, 노드에는 이미 이벤트를 지원하는 모듈 EventEmitter가 내장돼 있습니다.

EventEmitter
브라우저에서 테스트하고 싶다면 제이쿼리의 이벤트 문서http://api.jquery.com/category/events를 읽어보십시오.

이 모듈을 써서 countdown 함수를 개선해 봅시다.
EventEmitter는 countdown 같은 함수와 함께 사용해도 되지만, 원래는 클래스와 함께 사용하도록 설계됐습니다.
그러니 먼저 countdown 함수를 Countdown 클래스로 바꿔 봅시다.

const EventEmitter = require('events').EventEmitter;

class Countdown extends EventEmitter {
    constructor(seconds, superstitious) {
        super();    // 슈퍼클래스의 생성자를 호출하는 특별한 함수
                    // 서브 클래스는 이 함수를 반드시 호출해야 됨
        this.seconds = seconds;
        this.superstitious = !!superstitious;
    }
    go() {
        const countdown = this;
        return new Promise(function(resolve, reject) {
            for (let i=countdown.seconds; i>=0; i--) {
                setTimeout(function() {
                    if (countdown.superstitious && i===13)
                        return reject(new Error('Oh my god'));
                    countdown.emit('tick', i);
                    if (i===0) resolve();
                }, (countdown.seconds-i) * 1000)
            }
        })
    }
}

EventEmitter를 상속하는 클래스는 이벤트를 발생시킬 수 있습니다.
실제로 카운트다운을 시작하고 프로미스를 반환하는 부분은 go 메서드입니다.
go 메소드 안에서 가장 먼저 한 일은 countdown에 this를 할당한 겁니다.
카운트다운이 얼마나 남았는지 알기 위해서는 this 값을 알아야 하고, 13인지 아닌지 역시 콜백 안에서 알아야 합니다.
this는 특별한 변수이고 콜백 안에서는 값이 달라집니다.
따라서 this의 현재 값을 다른 변수에 저장해야 프로미스 안에서 쓸 수 있습니다.

가장 중요한 부분은 countdown.emit('tick', i)입니다.
이 부분에서 tick 이벤트를 발생시키고, 필요하다면 프로그램의 다른 부분에서 이 이벤트를 주시할 수 있습니다(이벤트 이름은 원하는 대로 정해도 됩니다).
개선한 카운트다운은 다음과 같이 사용할 수 있습니다.

const EventEmitter = require('events').EventEmitter;

class Countdown extends EventEmitter {
    constructor(seconds, superstitious) {
        super();    // 슈퍼클래스의 생성자를 호출하는 특별한 함수
                    // 서브 클래스는 이 함수를 반드시 호출해야 됨
        this.seconds = seconds;
        this.superstitious = !!superstitious;
    }
    go() {
        const countdown = this;
        return new Promise(function(resolve, reject) {
            for (let i=countdown.seconds; i>=0; i--) {
                setTimeout(function() {
                    if (countdown.superstitious && i===13)
                        return reject(new Error('Oh my god'));
                    countdown.emit('tick', i);
                    if (i===0) resolve();
                }, (countdown.seconds-i) * 1000)
            }
        })
    }
}

const c = new Countdown(5);

c.on('tick', function(i) {
    if (i>0) console.log(i + '...');
});
c.go()
    .then(function() {
        console.log('GO!');
    })
    .catch(function(err) {
        console.error(err.message);
    })

EventEmitter의 on 메서드가 이벤트를 주시하는 부분입니다.
이 예제에서는 tick 이벤트 전체에 콜백을 등록했습니다.
tick이 0이 아니면 출력한 다음 카운트다운을 시작하는 go를 호출합니다.
카운트다운이 끝나면 GO!를 출력합니다.
물론 GO!를 tick 이벤트 리스너 안에서 출력할 수도 있지만, 이렇게 하는 편이 이벤트와 프로미스의 차이를 더 잘 드러낸다고 생각합니다.

처음 만들었던 countdown 함수보다 훨씬 복잡한 것은 사실이지만, 그만큼 기능이 늘어났습니다.
이제 카운트다운을 어떻게 활용할지 마음대로 바꿀 수 있고, 카운트다운이 끝났을 때 완료되는 프로미스도 생겼습니다.

하지만 여전히 할 일이 남았습니다.
Countdown 인스턴스가 13에 도달했을 때 프로미스를 파기했는데도 카운트다운이 계속 진행되는 문제입니다.

const EventEmitter = require('events').EventEmitter;

class Countdown extends EventEmitter {
    constructor(seconds, superstitious) {
        super();    // 슈퍼클래스의 생성자를 호출하는 특별한 함수
                    // 서브 클래스는 이 함수를 반드시 호출해야 됨
        this.seconds = seconds;
        this.superstitious = !!superstitious;
    }
    go() {
        const countdown = this;
        return new Promise(function(resolve, reject) {
            for (let i=countdown.seconds; i>=0; i--) {
                setTimeout(function() {
                    if (countdown.superstitious && i===13)
                        return reject(new Error('Oh my god'));
                    countdown.emit('tick', i);
                    if (i===0) resolve();
                }, (countdown.seconds-i) * 1000)
            }
        })
    }
}

const c = new Countdown(15, true)
    .on('tick', function(i) {   // 체인으로 연결해도 됩니다.
        if (i>0) console.log(i + '...');
    })

c.go()
    .then(function() {
        console.log('GO!');
    })
    .catch(function(err) {
        console.error(err.message);
    })

여전히 모든 카운트가 출력되며 0이 될 때까지 진행합니다.
이 문제를 해결하기가 조금 어려운건 타임아웃이 이미 모두 만들어졌기 때문입니다(물론 ‘치트키’를 써서 미신을 믿는 타이머가 13초 이상으로 등록될 때 즉시 실패하도록 할 수는 있지만, 그렇게 하면 이 예제에서 설명하려는 요점을 놓치게 됩니다).
이 문제를 해결하려면 더 진행할 수 없다는 사실을 아는 즉시 대기중인 타임아웃을 모두 취소하면 됩니다.

const EventEmitter = require('events').EventEmitter;

class Countdown extends EventEmitter {
    constructor(seconds, superstitious) {
        super();
        this.seconds = seconds;
        this.superstitious = !!superstitious;
    }
    go() {
        const countdown = this;
        const timeoutIds = [];
        return new Promise(function(resolve, reject) {
            for (let i=countdown.seconds; i>=0; i--) {
                timeoutIds.push(setTimeout(function() {
                    if (countdown.superstitious && i===13) {
                        // 대기중인 타임아웃을 모두 취소합니다.
                        timeoutIds.forEach(clearTimeout);
                        return reject(new Error('Oh my god'));
                    }
                    countdown.emit('tick', i);
                    if (i===0) resolve();
                }, (countdown.seconds - i) * 1000))
            }
        })
    }
}


const c = new Countdown(15, true)
    .on('tick', function(i) {   // 체인으로 연결해도 됩니다.
        if (i>0) console.log(i + '...');
    })

c.go()
    .then(function() {
        console.log('GO!');
    })
    .catch(function(err) {
        console.error(err.message);
    })