카테고리 없음

라이브러리가 쓸데없이 비대하게 커지는 과정

hyuckkim 2025. 1. 16. 23:39

(개구린 자작 라이브러리 설명임)

<!DOCTYPE html>
<button onclick="resolveQueue()">버튼</button>
<p>텍스트</p>
<script>
const promiseQueue = [];
const addQueue = (fun) => {
    return new Promise((resolve, reject) => {
        fun();
        promiseQueue.push(() => resolve());
    });
};
const resolveQueue = () => {
    if (promiseQueue.length === 0) return;
    promiseQueue.pop()();
}

function text (t) {
    return addQueue(() => {
        document.querySelector('p').innerText = t;
    });
}

(async () => {
for (;;) {
    await text('1번 텍스트 입니다');
    await text('2번 텍스트 입니다');
    await text('3번 텍스트 입니다');
}
})();

</script>

 

이 코드를 짜고서 일주일 정도 어떻게 해야 더 유용하고 재사용성 있어질지 생각해봤다.

class OrderedPromise {
    tasks = [];

    defer(fun) {
        return new Promise((resolve, reject) => {
            this.tasks.push(async (...args) => {
                try { const result = await fun(...args); resolve(result); return result; }
                catch (e) { reject(e); throw e; }
        }); });
    }
    
    run(...args) { if (this.tasks.length === 0) return; return this.tasks.shift()(...args); }
    hasAny () { return this.tasks.length !== 0; }
}

최종적으로 함수 이름을 바꾸고, class를 사용하고, 무언가 기다리고 있는지 확인하는 함수를 만들어서 끝냈다.

 

예제는 여기: https://codepen.io/hyuckkim/pen/OPLawZr

 

OrderedPromise

...

codepen.io

 

그러니까, 이 아주 작은 라이브러리는 내가 처음 코딩을 시작했을 때를 떠올리며 만들었다.

 

스크래치엔 질문하기라는 함수가 있다. 아무 텍스트나 내뱉고, 입력창에 뭔가를 쓸 때까지 무한정 기다리는 것이다. 답변 버튼을 눌러야 다음 함수가 실행되고, 답변이 없으면 일단 기다린다.

 

이 기다리는 동작을 만들고 싶었다.

 

기다려야 될 곳에서 defer를 사용하고, 입력창 버튼에서 run을 실행하면 내가 원하는 동작을 할 수 있다.

  function move(object, x, y, speed = 100) {
    moves.push({ object, x, y, speed });
    return runner.defer(() => {});
  }
  function wait(ms = 1000) {
    setTimeout(() => {
      runner.run();
    }, ms);
    return runner.defer(() => {});
  }

각 함수는 OrderedPromise.defer로 함수를 반환하고, 어딘가에서 OrderedPromise.run을 호출하도록 적당히 감싸 둔다.

await wait(500);
await move(red, 500, 100, 1000);
await move(yellow, 500, 200, 2000);
await move(blue, 500, 300, 3000);

await Promise.all([
  move(red, 100, canvas.height - 100, 500),
  move(yellow, 800, canvas.height - 100, 750),
  move(blue, 100, 100, 600)
]);

그러면 움직일 때까지 기다릴 거라고 동작을 예상할 수 있다. (그런 동작을 안 하면 참사가 터질 것이다)

 

 

 

그리고 벌써 코드에 문제가 생겼다.

run() 메서드는 tasks 배열에서 shift()를 사용해서 맨 앞에 있는 요소를 빼내기 때문에, 단순히 run을 실행하면 가장 먼저 실행한 defer를 해소한다. (이 예제에서는 move(yellow)가 가장 먼저 도착하지만 코드상으로 가장 먼저 호출된 move(red)가 가장 먼저 해소된다) 물론 이 상황에서는 어차피 셋 다 기다려야 하는 상황이라서 별 문제는 없고, 코드 전체를 이렇게만 쓰면 문제될 일이야 없겠지만 다른 모든 상황들에서 라이브러리의 다른 기능들과는 충돌하게 된다.

 

예는 안 들었지만, defer로 전달한 함수에 인수가 있을 수도 있고, 반환값이 있을 수도 있다. 인수는 run()에서 전달하므로, 만약 run이 잘못된 함수에서 전달되었다면 인수는 증발해 버리고 반환값은 버려질 것이다.

 

이렇게 생각하고 나서는, id를 만들어야겠다고 생각했다.

하지만 어떻게? run(...args) 는 이미 인수 전체를 함수로 넘기고 있다. 첫 번째 인수를 id로 쓴다? 그거 정말 더러운 발상이다.

 

한참 머리를 굴리고 나서 생각난 건 팩토리 패턴.

이 경우는 팩토리가 아니지만, 어쨌든 defer가 하는 일은 함수를 생성하는 것 아닌가? 메서드 체이닝을 곁들여 보기 좋게 만들어볼 수 있을 것 같다.

await runner.with('red').defer(() => {});

runner.with('red').run();

이런 게 머릿속으로 떠올랐다. 음. 보기 썩 나쁘지 않아 보인다.

 

class OrderedPromise {
    tasks = [];
    tasksWithId = {};
    idNext = undefined;

    defer(fun) {
        const id = this.idNext;
        this.idNext = undefined;

        if (id && this.tasksWithId[id]) throw Error(`id ${id} already exists`);
        return new Promise((resolve, reject) => {
            const resolver = async (...args) => {
                try { const result = await fun(...args); resolve(result); return result; }
                catch (e) { reject(e); throw e; }
            };

            if (id) this.tasksWithId[id] = resolver;
            else this.tasks.push(resolver);
        });
    }
    
    run(...args) { 
        const id = this.idNext;
        this.idNext = undefined;
        let resolver;

        if (id) {
            if (!this.tasksWithId[id]) return;
            resolver = this.tasksWithId[id];
            delete this.tasksWithId[id];
            
        } else {
            if (this.tasks.length === 0) return;
            resolver = this.tasks.shift();
        }
        
        return resolver(...args);
    }
    with (id) {
        try { 
            const _temp = {};
            _temp[id] = '_tempstring_';
        }
        catch { 
            throw Error(`${id} cannot use to id`);
        }
        this.idNext = id;
        return this;
    }
}

기존과 호환성을 유지시키기 위해서 모든 함수를 반으로 갈랐다. 뭔가 있어야 하지 않을까~ 하고 안일하게 만들었던 hasAny()는 없앴다.

맨 아래 _temp로 유효한 id인지 한 번 테스트를 해보는 건 id로 최대한 많은 종류의 값을 자겨오려고 시도해본 흔적이지만, 인공지능이 이 코드를 보자마자 그냥 Map으로 바꿔버리면 된다고 한다. 인공지능에게 맡겨버렸다.

 

class OrderedPromise {
    tasks = [];
    tasksWithId = new Map();
    idNext = undefined;

    defer(fun) {
        const id = this.idNext;
        this.idNext = undefined;

        if (id && this.tasksWithId.has(id)) throw Error(`id ${id} already exists`);
        return new Promise((resolve, reject) => {
            const resolver = async (...args) => {
                try { const result = await fun(...args); resolve(result); return result; }
                catch (e) { reject(e); throw e; }
            };

            if (id) this.tasksWithId.set(id, resolver);
            else this.tasks.push(resolver);
        });
    }
    
    run(...args) { 
        const id = this.idNext;
        this.idNext = undefined;
        let resolver;

        if (id) {
            if (!this.tasksWithId.has(id)) return;
            resolver = this.tasksWithId.get(id);
            this.tasksWithId.delete(id);
            
        } else {
            if (this.tasks.length === 0) return;
            resolver = this.tasks.shift();
        }
        
        return resolver(...args);
    }

    with(id) {
        this.idNext = id;
        return this;
    }
}

 

run에서 해당하는 id가 없거나 배열이 비었으면 그냥 return하는데, 오류를 던지는 게 나아 보이는 것 같기도 했다.

이 부분은 constructor에서 선택할 수 있게 해주면 좋을 것 같다.

class OrderedPromise {
    tasks = [];
    tasksWithId = new Map();
    idNext = undefined;
    strict = false;

    constructor(strict) {
        this.strict = !!strict;
    }

    defer(fun) {
        const id = this.idNext;
        this.idNext = undefined;

        if (id && this.tasksWithId.has(id)) throw Error(`id ${id} already exists`);
        return new Promise((resolve, reject) => {
            const resolver = async (...args) => {
                try { const result = await fun(...args); resolve(result); return result; }
                catch (e) { reject(e); throw e; }
            };

            if (id) this.tasksWithId.set(id, resolver);
            else this.tasks.push(resolver);
        });
    }
    
    run(...args) { 
        const id = this.idNext;
        this.idNext = undefined;
        let resolver;

        if (id) {
            if (!this.tasksWithId.has(id)) {
                if (this.strict) throw Error(`No task found with id ${id}`);
                return;
            }
            resolver = this.tasksWithId.get(id);
            this.tasksWithId.delete(id);
            
        } else {
            if (this.tasks.length === 0) {
                if (this.strict) throw Error(`No tasks available to run`);
                return;
            }
            resolver = this.tasks.shift();
        }
        
        return resolver(...args);
    }

    with(id) {
        this.idNext = id;
        return this;
    }
}

 

이런 식으로.

 

constructor에 뭐.. id를 사용하도록 강제하게 설정할지라던가, with에다가 id 말고 다른 속성도 붙여서 오류 단계, 우선순위 등등등을 처리하고 싶은 욕구가 생기지만, 여기까지만 하자...