[Node.js/Express.js] Session, Interceptor 기능 개발 - RedisSession, MemorySession

djawnstj·2023년 4월 3일
0

블로그 이전

HttpSession class

class HttpSession {

    /**
     * @type { Map<string, any> }
     */
    #map
    #id

    constructor(id, map) {
        this.#map = new Map();
        if (id) this.#id = id;
        if (map) this.#map = map;
    }

    /**
     * @param { string } name
     * @return { any }
     */
    getAttribute = (name) => {
        return this.#map.get(name);
    }

    /**
     * @param { string } name
     * @param { any } attr
     * @return { void}
     */
    setAttribute = (name, attr) => {
        this.#map.set(name, attr);
    }

    /**
     * @return { Map<string, any> }
     */
    getAllAttributes = () => {
        return this.#deepCopyMap(this.#map);
    }

    /**
     * @param {Map<string, any>} map
     * @returns {Map<string, any>}
     */
    #deepCopyMap = (map) => {
        const newMap = new Map();
        map.forEach((value, key) => {
            if (value instanceof Map) newMap.set(key, this.#deepCopyMap(value));
            else if (value instanceof Array) newMap.set(key, [...value]);
            else newMap.set(key, value);
        });
        return newMap;
    }

}

여러 세션 스토어에 저장될 세션 객체는 공통으로 사용하도록 구상하고 개발했다.
기본적으로 Map변수에 정보들을 key-value형태로 담고 꺼내도록 구상했다.

SessionStore class

class SessionStore {

    /**
     * @return { HttpSession }
     */
    #createSession = () => {
        throw new Error("#createSession is not implemented");
    }

    /**
     * @param { HttpSession } session
     */
    #saveSession = (session) => {
        throw new Error("#saveSession is not implemented");
    }

    /**
     * @param { string } key
     * @param { Response } res
     * @param { boolean } status
     * @returns { HttpSession }
     */
    getSession = (key, res, status = true) => {
        throw new Error("getSession is not implemented");
    }

    /**
     * @param { string } key
     */
    removeSession = (key) => {
        throw new Error("removeSession is not implemented");
    }

}

interface로 추상-구현 관계를 사용하고 싶었지만 만들어진 프레임워크 자체가 TS를 지원하지 않아 어쩔수 없이 비슷하게 함수들을 선언하고 재정의 없이 호출하면 Error가 나도록 구현했다.

사실 JS 장점이라면 장점인 동적 프로그래밍으로 자료형을 정해주지 않아도 위 함수들이 구현되어 있다면 동작하는데는 문제가 없다. 하지만 전글에도 적었듯이 나는 서버는 신뢰성이 중요하다 생각해 상속을 하면 함수들을 한번씩은 확인할것이라 생각해 이런 구조로 개발했다.

MemorySessionStore

const SessionStore =  require("./SessionStore");
const HttpSession =  require("./HttpSession");
const UUID = require("../util/UUID");
const config = require("../config/config");

class MemorySessionStore extends SessionStore {

    #map;

    constructor() {
        super();
        this.#map = new Map();
    }


    /**
     * @return { string }
     */
    #createSession = () => {
        const session = new HttpSession();

        let key = UUID.randomUUID();
        let isExits = true;

        while (isExits) {
            isExits = this.#isExists(key);
            if (isExits) key = UUID.randomUUID();
        }
        
        this.#saveSession(key, session)
        return key;
    }

    /**
     * @param {string} key
     * @return {boolean}
     */
    #isExists = (key) => {
        return this.#map.contains(key)
    }

    /**
     * @param { string } key
     * @param { HttpSession } session
     */
    #saveSession = (key, session) => {
        this.#map.set(key, session);
    }

    /**
     * @param { string } key
     * @param { Response } res
     * @param { boolean } status
     * @returns { HttpSession }
     */
    getSession = (key, res, status = true) => {

        let session;

        if (key) session = this.#map.get(key);

        if (!session && status) {
            key = this.#createSession();
            session = this.#map.get(key);

            res.cookie(config.sessionKey, key);
        } else if (!session && !status) {
            res.clearCookie(config.sessionKey);
            return null;
        }

        return session;

    }

    /**
     * @param { string } key
     */
    removeSession = (key) => {
        this.#map.delete(key);
    }

}

Memory에 저장하는 SessionStore이다.

createSession()

UUID로 랜덤한 key 값을 생성하고 Map변수에 HttpSession과 함께 key-value로 저장한다.
만약 이미 key가 존재한다면 새로운 UUID를 생성해 저장한다.

getSession()

핸들러에서 쿠키값을 가지고 getSession()을 하면 Map변수에서 해당 key에 해당하는 HttpSession을 반환한다.
만약 HttpSession이 존재하지 않는다면 넘겨받은 파라미터를 통해 세션을 만들어줄지를 결정한다. 세션을 새로 만들게 되면 Response에 쿠키로 담아 클라이언트로 보내준다.

saveSession()

Map변수에 HttpSession을 저장하는 역할을 한다.

removeSession()

Map변수에서 HttpSession을 삭제하는 역할을 한다.

RedisSessionStore

const SessionStore = require("./SessionStore");
const HttpSession = require("./HttpSession");
const redis = require("redis");
const UUID = require("../util/UUID");
const RedisSession = require("./RedisSession");
const config = require("../config/config");

class RedisSessionStore extends SessionStore {

    #client;
    static #instance;

    constructor() {
        super();

        if (!RedisSessionStore.#instance) {
            RedisSessionStore.#instance = this;

            this.#client = redis.createClient(config.redis);

            this.#client.on("connect", () => {
                console.log("Redis Client Connected!")
            });

            this.#client.on("error", (err) => {
                console.error("RedisSessionStore Client Connect Error." + err)
            });

            this.#client.connect().then();
        }

        return RedisSessionStore.#instance;
    }

    /**
     * @return { string }
     */
    #createSession = async () => {
        const session = new HttpSession();

        let key = UUID.randomUUID();
        let isExits = true;
        
        while (isExits) {
            isExits = await this.#isExists(key);
            if (isExits) key = UUID.randomUUID();
        }

        const allAttr = session.getAllAttributes();
        await this.#saveSession(key, allAttr);

        return key;
    }

    /**
     * @param {string} key
     * @return {Promise<boolean>}
     */
    #isExists = async (key) => {
        return await this.#client.exists(key);
    }

    /**
     * @param { string } key
     * @param { Map } sessionAttr
     */
    #saveSession = async (key, sessionAttr) => {

        const s = JSON.stringify([...sessionAttr]);

        await this.#client.multi()
            .set(key, s)
            .expire(key, config.EXPIRE_TIME)
            .exec();
    }


    /**
     * @param { string } key
     * @param { Response } res
     * @param { boolean } status
     * @returns { Promise }
     */
    getSession = async (key, res, status) => {
        let obj;

        if (key) obj = await this.#client.get(key, (err) => {
                console.error("RedisSessionStore getAttribute error: " + err)
            });

        if (!obj && status) {
            key = await this.#createSession();
            obj = await this.#client.get(key, (err) => {
                console.error("RedisSessionStore getAttribute error: " + err)
            });

            res.cookie(config.sessionKey, key);
        } else if (!obj && !status) {
            res.clearCookie(config.sessionKey);
            return null;
        }

        await this.#client.multi()
            .expire(key, config.EXPIRE_TIME)
            .exec();

        obj = JSON.parse(obj);
        const map = new Map(obj.map(([mapKey, mapValue]) => [mapKey, mapValue]));

        return new HttpSession(key, map);
    }

    /**
     * @param { string } key
     */
    removeSession = (key) => {
        this.#client.del(key, (err) => {
            console.error("RedisSessionStore removeSession error: " + err)
        });
    }

}

MemroySessionStore와 기능이 모두 동일하지만, HttpSession을 Map변수가 아닌 Redis에 저장하도록 해 서버가 죽거나, Redis를 공유하는 다른 서버에서도 조회가 가능하도록 했다.

또한 Redis에 저장될 때 30분의 만료시간을 주고 핸들러에서 세션을 조회할때마다 30분이 갱신되도록 구현했다.

saveSession()

Redis를 사용하는것 외에 MemorySessionStore와 차이가 있는 부분은 세션을 저장할때이다.

Redis에 저장할 수 있는 자료형은 문자열, hash, list, set 등이 있는데, HttpSession을 어떻게 저장할까 고민 끝에 HttpSession이 가진 Map변수를 문자열로 변경 후 저장하고자 했다.

세션 객체도 key-value형태로 값을 가져야 하고 이 세션 자체를 각 클라이언트 별 고유값으로 저장해야 했기 때문에 HttpSessionMap 변수를 문자열로 파싱하여 Redis에 저장하도록 했다.

문제점

RedisSessionStore를 이렇게 구현하면 MemorySessionStore와 다르게 값이 갱신되지 않는 문제가 발생한다.

기존 MemorySessionStoreHttpSession이 참조변수로 Map에 담기게 되어 이 세션을 핸들러로 반환해 핸들러에서 수정을 해도 별다른 저장 로직 없이 정상적으로 반영이 됐다.

하지만 Redis에 저장된 값은 httpSession.setAttribute()를 하면 수정된 값을 다시 저장해줘야 했기 때문에 정상적으로 작동되지 않았다.

해결

이 문제를 해결하기 위해 정말 많은 고민을 했다.

일단 가장 쉬운건 RedisSessionStore에 업데이트 함수를 만들어 이용자가 그 함수를 호출하게끔 하는 방법이다.
하지만 이용자는 지금 세션 저장소가 Redis인지, Memory인지, 다른 저장소인지 신경을 안쓰고 개발할 수 있게끔 하고싶었다.

그 다음 생각한건 HttpSessionsetAttribute() 함수에서 SessionStore의 업데이트 함수를 호출하도록 생각했다.
이 방법은 이용자는 업데이트 함수를 따로 호출하지 않아도 돼 이용자의 고려사항을 하나 줄여줄 순 있지만, 결국 HttpSessionSessionStore간의 순환참조가 발생한다는 문제에서 배제시켰다.

결국 해결한것은 HttpSession도 다형성을 적용하는 방법이었다.

RedisSession

const HttpSession = require("./HttpSession");

class RedisSession extends HttpSession {

    /**
     * @type { Map<string, any> }
     */
    #map
    #id
    #fun

    constructor(id, map, fun) {
        super();
        this.#map = new Map();
        if (id) this.#id = id;
        if (fun && (typeof fun === "function")) this.#fun = fun;
        if (map) this.#map = map;
    }

    /**
     * @param { string } name
     * @return { any }
     */
    getAttribute = (name) => {
        return this.#map.get(name);
    }

    /**
     * @param { string } name
     * @param { any } attr
     * @return { void }
     */
    setAttribute = async (name, attr) => {
        this.#map.set(name, attr);
        if (fun) await this.#fun(this.#id, this.getAllAttributes);
    }

    /**
     * @return { Map<string, any> }
     */
    getAllAttributes = () => {
        return this.#deepCopyMap(this.#map);
    }

    /**
     * @param {Map<string, any>} map
     * @returns {Map<string, any>}
     */
    #deepCopyMap = (map) => {
        const newMap = new Map();
        map.forEach((value, key) => {
            if (value instanceof Map) newMap.set(key, this.#deepCopyMap(value));
            else if (value instanceof Array) newMap.set(key, [...value]);
            else newMap.set(key, value);
        });
        return newMap;
    }

}

HttpSession을 상속받아 생성자에서 콜백 함수를 받을 수 있게 개발했다.
setAttribute()에서 주입받은 콜백 함수를 호출하여 저장 로직을 호출할 수 있게 됐다.

이젠 핸들러로 반환하기 전 콜백 함수만 주입해주면 된다.

getSession() 수정

/**
 * @param { string } key
 * @param { Response } res
 * @param { boolean } status
 * @returns { Promise<HttpSession> }
 */
getSession = async (key, res, status) => {
    let obj;

    if (key) obj = await this.#client.get(key, (err) => {
            console.error("RedisSessionStore getAttribute error: " + err)
        });

    if (!obj && status) {
        key = await this.#createSession();
        obj = await this.#client.get(key, (err) => {
            console.error("RedisSessionStore getAttribute error: " + err)
        });

        res.cookie(config.sessionKey, key);
    } else if (!obj && !status) {
        res.clearCookie(config.sessionKey);
        return null;
	}

	await this.#client.multi()
		.expire(key, config.EXPIRE_TIME)
		.exec();

	obj = JSON.parse(obj);
	const map = new Map(obj.map(([mapKey, mapValue]) => [mapKey, mapValue]));

	return new RedisSession(key, map, this.#saveSession);
}

return 부분에서 saveSession()함수를 파라미터로 넘겨주면서 업데이트까지 문제가 없게 됐다.

이제 이 SessionStore들을 사용하는 클래스만 만들면 끝이다.

SessionFactory

const SessionStore = require("./SessionStore");
const MemorySessionStore = require("./MemorySessionStore");
const config = require("../config/config");

class SessionFactory {

    /**
     @type { SessionStore }
     */
    #sessionStore;


    /**
     * @param {SessionStore|{}} [type=MemorySessionStore]
     */
    constructor(type = MemorySessionStore) {
        if (typeof type !== "function") throw new Error("Must use the constructor.");
        const temp = new type();
        if (!(temp instanceof SessionStore)) throw new Error("Must use the SessionStore.");
        this.#sessionStore = temp;
    }

    /**
     * @param { string } key
     * @param { Response } res
     * @param { boolean } status
     * @returns { HttpSession }
     */
    getSession = async (key, res, status) => {
        return await this.#sessionStore.getSession(key, res, status);
    }

    /**
     * @param { string } key
     */
    removeSession = async (key) => {
        return await this.#sessionStore.removeSession(key);
    }

}

const sessionFactory = new SessionFactory(config.sessionStore);

이용자는 현제 세션 저장소가 Redis인지, Memory인지, 기타 커스텀 저장소인지 아무 신경을 안써도 되게끔 모든 저장소를 관리하는 객체를 만들었다.

서버의 설정정보를 명시하는 config 객체에 어떤 저장소를 사용할건지를 적어두면 SessionFactory가 알아서 해당 저장소를 생성하고 사용한다.
물론 SessoinStore를 상속받지 않은 저장소가 주입되면 에러를 발생하도록 하여 조금이라도 신뢰성을 높이고 강한 규칙을 적용하고자 했다.

이 저장소는 싱글톤으로 생성해 서버의 런타임동안 사용된다.

핸들러 세션 주입

세션 기능은 이정도로도 완료지만, 핸들러에서 세션을 사용하기 위해선 핸들러 메서드들이 있는 객체에 SessionFactory를 주입해 주거나, 핸들러 메서드에 인자값으로 넘겨줘야 한다.

이런 번거로움 없이 역시 이용자는 신경 끄고 사용해도 문제없이 돌아가도록 하는게 프레임워크라 생각하기 때문에 핸들러에서 세션을 사용하도록 하는 기능을 추가했다.

function sessionInjection(req, res, next) {
    /**
     * @param {Request} req
     * @param {Response} res
     */
    function addGetSessionMethod(req, res) {
        /**
         * @param { boolean } [status=true]
         * @return {HttpSession}
         */
        req.getSession = async (status = true) => {

            let cookieSessionKey;
            if (req.cookies) cookieSessionKey = req.cookies[config.sessionKey];

            return await sessionFactory.getSession(cookieSessionKey, res, status);
        }
    }

    /**
     * @param {Request} req
     * @param {Response} res
     */
    function addRemoveSessionMethod(req, res) {

        req.removeSession = async () => {

            let cookieSessionKey;
            if (req.cookies) cookieSessionKey = req.cookies[config.sessionKey];

            if (cookieSessionKey) await sessionFactory.removeSession(cookieSessionKey);
            res.clearCookie(config.sessionKey);
        }
    }
  
	addGetSessionMethod(req, res);
    addRemoveSessionMethod(req, res);
  
	next();
}

app.use(sessionInjection)

이렇게 Response 객체에서 쿠키 값을 읽고 Request 객체에 SessionFactory의 함수들을 호출해 세션을 반환하는 함수를 추가해줬다.

굳이 addGetSessionMethod(), addRemoveSessionMethod()처럼 함수 안에 함수를 둔것은 다음 포스팅이 될 인터셉터의 복선(?)이다.

세션 사용


app.get("/check", async (req, res) => {
    console.log("check call")
    const session = await req.getSession(false);
    let id = "null";
    if (session) id = await session.getAttribute("id");
    res.send(id);
});

app.get("/login", async (req, res) => {
    console.log("login call")
    const query = req.query;
    const session = await req.getSession();

    const id = query.id;
    const password = query.password;
    const name = query.name;

    if (session) {
        await session.setAttribute("id", id);
        await session.setAttribute("password", password);
        await session.setAttribute("name", name);
    }

    let result = "null";
    if (session) result = await session.getAttribute(id)
    res.send(result);
});

app.get("/logout", async (req, res) => {

    console.log("logout call")

    req.removeSession();
    const session = await req.getSession(false);

    let id;
    if (session) id = session.getAttribute("id");

    res.send(id);
});

이제 이렇게 login, check, logout을 해보면 Redis, Memory 모두 정상적으로 세션을 사용한다는 것을 확인할 수 있다.

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

0개의 댓글