[Node.js/Express.js] Interceptor 개발

djawnstj·2023년 4월 8일
0

블로그 이전

지난 포스팅까지 세션 기능 개발을 완성했다.
요청이 왔을때, 서비스 로직을 수행하기 전 세션을 체크해서 권한이 있는 이용자의 요청인지 체크를 해주면 된다.

일전에 개발한 기존 세션기능은 모든 핸들러 함수 시작부분에 세션 Map을 뒤져 세션이 존재하는지 체크하는 로직을 일일이 넣어줬었다. 이번에 그런 수고를 줄이고자 인터셉터를 개발하기로 했다.

HandlerInterceptor

class HandlerInterceptor {

    /**
     * @param {Request} req
     * @param {Response} res
     * @return {boolean}
     */
    preHandle = (req, res) => {
        return true;
    }

    /**
     * @param {Request} req
     * @param {Response} res
     * @return {boolean}
     */
    postHandle = (req, res) => {
    }

}

우선 preHandle(), postHandle() 함수를 선언해둔 기본 클래스를 만들었다.

preHandle()

요청이 핸들러 함수에 도달하기 전에 호출되는 함수이다.
리턴값이 true이면 다음 단계로 진행된다.

postHandle()

핸들러 함수가 호출되고 응답이 클라이언트에 전달되고 난 후에 호출되는 함수이다.

경우에 따라 핸들러 전, 핸들러 후, 전/후 모두 인터셉터를 사용해야 할 수 있기 때문에 이전 세션과 달리 함수를 재정의하지 않아도 에러를 유발하지 않게했다.

HandlerInterceptor를 상속받아 필요한 로직을 추가하여 인터셉터를 개발하면 된다.
이제 개발된 인터셉터를 요청시에 호출되도록 등록을 해줘야한다.

InterceptorRegistryInfo

class InterceptorRegistryInfo {

    /**
     * @type {HandlerInterceptor}
     */
    #interceptor;
    /**
     * @type {number}
     */
    #order;
    /**
     * @type {string[]}
     */
    #paths;
    /**
     * @type {string[]}
     */
    #excludePaths;

    /**
     * @param {HandlerInterceptor} interceptor
     */
    constructor(interceptor) {
        if (!(interceptor instanceof HandlerInterceptor)) throw new Error("must be use HandlerInterceptor");
        this.#interceptor = interceptor;
        this.#paths = ["/*"];
        this.#excludePaths = [];
    }

    /**
     * @param {Request} req
     * @param {Response} res
     * @param {function[]} resCallbacks
     * @return {Promise<boolean>}
     */
    intercept = async (req, res, resCallbacks) => {
        const b = await this.#interceptor.preHandle(req, res);

        resCallbacks.push(this.#interceptor.postHandle);
        return b;
    }

    /**
     * @param {number} order
     * @return {InterceptorRegistryInfo}
     */
    setOrder = (order) => {
        this.#order = order;
        return this;
    }

    /**
     * @return {number}
     */
    getOrder = () => this.#order;

    /**
     * @param {...string} paths
     * @return {InterceptorRegistryInfo}
     */
    addPaths = (...paths) => {
        if (paths.length === 1 && Array.isArray(paths[0])) paths = paths[0];
        this.#paths = paths;
        return this;
    }

    /**
     * @param {...string} paths
     * @return {InterceptorRegistryInfo}
     */
    excludePaths = (...paths) => {
        if (paths.length === 1 && Array.isArray(paths[0])) paths = paths[0];
        this.#excludePaths = paths;
        return this;
    }

    /**
     * @param {string} url
     * @return {Promise<boolean>}
     */
    support = async (url) => {
        if (url.endsWith("/")) url = url.slice(0, -1);

        const queryStringIndex = url.indexOf('?'); // 쿼리스트링 시작 인덱스 찾기

        // 쿼리스트링이 있는 경우
        if (queryStringIndex !== -1) url = url.substring(0, queryStringIndex); // 쿼리스트링을 제외한 경로 추출

        const first = this.#excludePaths.some(path => path === "/*" || path === url || url + "/*" === path || (path.length < url.length && path.endsWith("/*") && url.startsWith(path.slice(0, -1))));

        if (first) return false;

        return this.#paths.some(path => path === "/*" || path === url || url + "/*" === path || (path.length < url.length && path.endsWith("/*") && url.startsWith(path.slice(0, -1))));
    }

}

InterceptorRegistryInfo객체는 각각 인터셉터에 대한 정보를 설정하는 역할을 하도록 했다.

intercept()

요청과 응답객체를 파라미터로 받는다. 추가로 응답시에 호출할 postHandle()함수들을 담는 배열을 같이 받아 배열에 postHandle()를 추가한다.

setOrder()

인터셉터가 호출될 순서를 설정한다.

addPaths()

해당 인터셉터를 적용할 url 패턴을 설정한다.
스프링처럼 많은 패턴을 지원하진 못하고 명확한 url, *을 붙여 하위 url은 모두 적용하는 패턴을 적용할 수 있다.

excludePaths()

인터셉터를 적용하지 않을 url 패턴을 설정한다.

support()

요청이 들어오면 해당 요청을 인터셉터가 처리하는지 체크하는 함수이다.

InterceptorRegistryInfo 정보들을 통해 요청시에 호출할 역할을 갖는 객체를 개발하면 된다.

InterceptorRegister

class InterceptorRegister {

    /**
     * @type { InterceptorRegistryInfo[] }
     */
    #interceptors;

    constructor() {
        this.#interceptors = [];
    }

    /**
     * @param { HandlerInterceptor } interceptor
     * @return { InterceptorRegistryInfo }
     */
    addInterceptor = (interceptor) => {
        const info = new InterceptorRegistryInfo(interceptor);
        this.#interceptors.push(info);
        return info;
    }

    #sortInterceptors = () => {
        const withOrder = this.#interceptors
            .filter((interceptor) => interceptor.getOrder() !== undefined)
            .sort((f, s) => f.getOrder() - s.getOrder());

        const withoutOrder = this.#interceptors
            .filter((interceptor) => interceptor.getOrder() === undefined);

        this.#interceptors = [...withOrder, ...withoutOrder];
    }

    /**
     * @param { Express } app
     */
    registerInterceptor = (app) => {
        this.#sortInterceptors();
        app.use(this.#launchInterceptor);
    }

    /**
     * @param { Request } req
     * @param { Response } res
     * @param { function } next
     */
    #launchInterceptor = async (req, res, next) => {
        const responseCallback = [];

        let success = true;
        for (let i = 0; i < this.#interceptors.length; i++) {
            const interceptorInfo = this.#interceptors[i];

            const support = await interceptorInfo.support(req.url);
            if (support) success = await interceptorInfo.intercept(req, res, responseCallback);

            if (!success) break;
        }

        if (success) next();

        responseCallback.reverse().forEach(fun => res.on("finish", () => fun(req, res)));
    }

}

InterceptorRegistryInfo를 추가, 각각 설정된 순서에 맞게 정렬, 애플리케이션 로드 시점에 express.js프레임워크에 미들웨어로 등록, 인터셉터 호출 역할을 한다.

addInterceptor()

config.jsaddInterceptors() 함수를 정의할 때 호출될 함수이다.
이 함수를 통해 인터셉터를 등록시킬 수 있다.

sortInterceptors()

InterceptorRegistryInfo에 설정한 순서에 맞게 정렬한다. order를 설정하지 않는다면 order를 설정한 인터셉터를 모두 정렬한 후 추가된 순서에 맞게 정렬된다.

registerInterceptor()

express.js 미들웨어로 인터셉터 실행 함수를 등록시켜 요청이 핸들러로 가기 전 인터셉터를 거치도록 만들어준다.

launchInterceptor()

인터셉터 배열을 돌리며 이번 요청을 처리할 수 있는 인터셉터라면 인터셉터의 preHandle() 함수를 호출해준다.
호출해주면서 postHandle()을 배열에 담아둔다.
preHandle()의 응답이 false라면 반복을 종료한다.

모든 인터셉터의 preHandle()이 호출되면 preHandle()의 결과에 따라 핸들러로 넘겨줄지를 결정한다.
그 후 postHandle() 함수를 담은 배열의 역순으로 응답시에 호출해준다.

ConfigLoader

#loadInterceptorConfig = (app) => {
    if (config.addInterceptors) config.addInterceptors(this.#interceptorRegister);
    this.#interceptorRegister.registerInterceptor(app);
}

애플리케이션 로드시에 인터셉터 추가를 해주는 함수를 만들어줬다.

이제 이용자는 인터셉터를 개발하고 config.js에 등록해주면 된다.

인터셉터 이용

샘플 인터셉터

class FirstInterceptor extends HandlerInterceptor {

    /**
     * @param {Request} req
     * @param {Response} res
     * @return {boolean}
     */
    preHandle = (req, res) => {
        console.log("FirstInterceptor preHandle() called.")
        return true;
    }

    /**
     * @param {Request} req
     * @param {Response} res
     * @return {boolean}
     */
    postHandle = (req, res) => {
        console.log("FirstInterceptor postHandle() called.")
    }

}

class SecondInterceptor extends HandlerInterceptor {

    /**
     * @param {Request} req
     * @param {Response} res
     * @return {boolean}
     */
    preHandle = (req, res) => {
        console.log("SecondInterceptor preHandle() called.")
        return true;
    }

    /**
     * @param {Request} req
     * @param {Response} res
     * @return {boolean}
     */
    postHandle = (req, res) => {
        console.log("SecondInterceptor postHandle() called.")
    }

}

class ThirdInterceptor extends HandlerInterceptor {

    /**
     * @param {Request} req
     * @param {Response} res
     * @return {boolean}
     */
    preHandle = (req, res) => {
        console.log("ThirdInterceptor preHandle() called.")
        return true;
    }

    /**
     * @param {Request} req
     * @param {Response} res
     * @return {boolean}
     */
    postHandle = (req, res) => {
        console.log("ThirdInterceptor postHandle() called.")
    }

}

이렇게 순서를 로그로 남겨주는 인터셉터를 개발했다.

config.js

addInterceptors: (register) => {
    register.addInterceptor(new SecondInterceptor())
        .addPaths("/*")
        .excludePaths("/check")
        .setOrder(1)
    register.addInterceptor(new FirstInterceptor())
        .addPaths("/*")
    register.addInterceptor(new ThirdInterceptor())
        .addPaths("/*")
        .setOrder(1)
}

인터셉터를 등록해줬다.
등록하면서 순서를 무작위로 설정하고 그 중 하나는 예외 패턴도 적용해줬다.

의도한 순서는
/test 요청: Second -> Third -> First -> 핸들러 -> First -> Third -> Second
/check 요청: Third -> First -> 핸들러 -> First -> Third
이다.

핸들러 함수

app.get("/test", (req, res) => {
    console.log("/test called");
    res.send("ok");
});

app.get("/check", (req, res) => {
    console.log("/check called");
    res.send("ok");
});

이렇게 /test, /check uri를 설정해줬다.
이제 해당 경로로 각각 요청을 보내보겠다.

결과

/test 요청 결과

SecondInterceptor preHandle() called.
ThirdInterceptor preHandle() called.
FirstInterceptor preHandle() called.
/test called
FirstInterceptor postHandle() called.
ThirdInterceptor postHandle() called.
SecondInterceptor postHandle() called.

이렇게 의도한대로 인터셉터가 작동했다.

/check 요청 결과

ThirdInterceptor preHandle() called.
FirstInterceptor preHandle() called.
/check called
FirstInterceptor postHandle() called.
ThirdInterceptor postHandle() called.

예외가 존재할때도 의도한대로 인터셉터가 작동했다.

이제 보안점검에 지적사항이 됐던 세션과 관련된 기능을 인터셉터로 추가해주면 된다.

profile
이용자가 아닌 개발자가 되자!

0개의 댓글