[Node.js/Express.js] Session, Interceptor 기능 개발
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형태로 값을 가져야 하고 이 세션 자체를 각 클라이언트 별 고유값으로 저장해야 했기 때문에 HttpSession
의 Map
변수를 문자열로 파싱하여 Redis
에 저장하도록 했다.
문제점
RedisSessionStore
를 이렇게 구현하면 MemorySessionStore
와 다르게 값이 갱신되지 않는 문제가 발생한다.
기존 MemorySessionStore
는 HttpSession
이 참조변수로 Map
에 담기게 되어 이 세션을 핸들러로 반환해 핸들러에서 수정을 해도 별다른 저장 로직 없이 정상적으로 반영이 됐다.
하지만 Redis
에 저장된 값은 httpSession.setAttribute()
를 하면 수정된 값을 다시 저장해줘야 했기 때문에 정상적으로 작동되지 않았다.
해결
이 문제를 해결하기 위해 정말 많은 고민을 했다.
일단 가장 쉬운건 RedisSessionStore
에 업데이트 함수를 만들어 이용자가 그 함수를 호출하게끔 하는 방법이다.
하지만 이용자는 지금 세션 저장소가 Redis
인지, Memory
인지, 다른 저장소인지 신경을 안쓰고 개발할 수 있게끔 하고싶었다.
그 다음 생각한건 HttpSession
의 setAttribute()
함수에서 SessionStore
의 업데이트 함수를 호출하도록 생각했다.
이 방법은 이용자는 업데이트 함수를 따로 호출하지 않아도 돼 이용자의 고려사항을 하나 줄여줄 순 있지만, 결국 HttpSession
과 SessionStore
간의 순환참조가 발생한다는 문제에서 배제시켰다.
결국 해결한것은 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
모두 정상적으로 세션을 사용한다는 것을 확인할 수 있다.