
(MySQL=회원 주DB/세션 백업, Redis=세션 캐시, PostgreSQL=이벤트로그)

(우분투 3대, IP 고정: VM1=192.168.80.110 / VM2=192.168.80.120 / VM3=192.168.80.130)
구성 목표:
핵심 데모:
각 VM에서:
sudo hostnamectl set-hostname vm1-app
sudo hostnamectl set-hostname vm2-data
sudo hostnamectl set-hostname vm3-log
세 VM 모두에서:
sudo tee -a /etc/hosts >/dev/null <<'EOF'
192.168.80.110 vm1-app
192.168.80.120 vm2-data
192.168.80.130 vm3-log
EOF
sudo apt update
sudo apt install -y mysql-server redis-server
sudo systemctl enable --now mysql
sudo systemctl enable --now redis-server
기본은 로컬만 열려있을 수 있어요. 아래로 수정:
sudo sed -i 's/^bind-address.*/bind-address = 0.0.0.0/' /etc/mysql/mysql.conf.d/mysqld.cnf
sudo systemctl restart mysql
리스닝 확인:
sudo ss -lntp | grep 3306
Redis 기본은 로컬만 바인드일 수 있습니다.
sudo sed -i "s/^bind .*/bind 0.0.0.0 ::1/" /etc/redis/redis.conf
sudo sed -i "s/^protected-mode yes/protected-mode no/" /etc/redis/redis.conf
sudo systemctl restart redis-server
리스닝 확인:
sudo ss -lntp | grep 6379
MySQL root로 실행:
sudo mysql <<'SQL'
CREATE DATABASE IF NOT EXISTS auth_lab DEFAULT CHARACTER SET utf8mb4;
CREATE USER IF NOT EXISTS 'app'@'192.168.80.%' IDENTIFIED BY 'apppass';
GRANT ALL PRIVILEGES ON auth_lab.* TO 'app'@'192.168.80.%';
FLUSH PRIVILEGES;
USE auth_lab;
CREATE TABLE IF NOT EXISTS users (
user_id BIGINT AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Redis 장애 대비 "세션 백업" 테이블(자동 폴백에서 핵심)
CREATE TABLE IF NOT EXISTS sessions (
token VARCHAR(128) PRIMARY KEY,
user_id BIGINT NOT NULL,
data JSON NULL,
expires_at DATETIME NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_sessions_expires (expires_at),
KEY idx_sessions_user_id (user_id)
);
SQL
sudo apt update
sudo apt install -y postgresql
sudo systemctl enable --now postgresql
sudo -u postgres psql -c "CREATE DATABASE monitor_lab;"
sudo -u postgres psql -c "CREATE USER pgapp WITH PASSWORD 'pgpass';"
sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE monitor_lab TO pgapp;"
sudo sed -i "s/^#listen_addresses =.*/listen_addresses = '*'/" /etc/postgresql/*/main/postgresql.conf
sudo tee -a /etc/postgresql/*/main/pg_hba.conf >/dev/null <<'EOF'
host all all 192.168.80.0/24 md5
EOF
재시작:
sudo systemctl restart postgresql
리스닝 확인:
sudo ss -lntp | grep 5432
sudo -iu postgres psql -d monitor_lab <<'SQL'
CREATE TABLE IF NOT EXISTS auth_events (
event_id BIGSERIAL PRIMARY KEY,
ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
event_type TEXT NOT NULL,
actor TEXT NULL,
ip INET NULL,
payload JSONB NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_auth_events_ts ON auth_events (ts DESC);
CREATE INDEX IF NOT EXISTS idx_auth_events_type ON auth_events (event_type);
CREATE INDEX IF NOT EXISTS idx_auth_events_payload_gin ON auth_events USING GIN (payload);
SQL
권한 추가
sudo -u postgres psql -d monitor_lab <<'SQL'
GRANT USAGE ON SCHEMA public TO pgapp;
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE public.auth_events TO pgapp;
-- serial/identity 사용 시 시퀀스 권한도 필요할 수 있음
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO pgapp;
-- 앞으로 생성되는 테이블에도 자동으로 권한 부여(권장)
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO pgapp;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT USAGE, SELECT ON SEQUENCES TO pgapp;
SQL
sudo apt update
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install -y nodejs npm
node -v
npm -v
mkdir -p ~/signup-lab/{db,store,public}
cd ~/signup-lab
npm init -y
npm install express mysql2 redis bcrypt dotenv pg
.env 생성cat > ~/signup-lab/.env <<'EOF'
PORT=8080
MYSQL_HOST=192.168.80.120
MYSQL_PORT=3306
MYSQL_USER=app
MYSQL_PASSWORD=apppass
MYSQL_DATABASE=auth_lab
REDIS_HOST=192.168.80.120
REDIS_PORT=6379
SESSION_STORE=fallback
SESSION_TTL_SECONDS=1800
PG_HOST=192.168.80.130
PG_PORT=5432
PG_USER=pgapp
PG_PASSWORD=pgpass
PG_DATABASE=monitor_lab
EOF
db/mysql.jscat > ~/signup-lab/db/mysql.js <<'EOF'
const mysql = require("mysql2/promise");
let pool;
function getMysqlPool(env) {
if (!pool) {
pool = mysql.createPool({
host: env.MYSQL_HOST,
port: Number(env.MYSQL_PORT || 3306),
user: env.MYSQL_USER,
password: env.MYSQL_PASSWORD,
database: env.MYSQL_DATABASE,
connectionLimit: 10
});
}
return pool;
}
module.exports = { getMysqlPool };
EOF
db/pg.jscat > ~/signup-lab/db/pg.js <<'EOF'
const { Pool } = require("pg");
let pool;
function getPgPool(env) {
if (!pool) {
pool = new Pool({
host: env.PG_HOST,
port: Number(env.PG_PORT || 5432),
user: env.PG_USER,
password: env.PG_PASSWORD,
database: env.PG_DATABASE,
max: 10
});
}
return pool;
}
module.exports = { getPgPool };
EOF
store/redisSessionStore.jscat > ~/signup-lab/store/redisSessionStore.js <<'EOF'
function createRedisSessionStore(redisClient) {
return {
async set(token, payloadObj, ttlSec) {
await redisClient.setEx(`sess:${token}`, ttlSec, JSON.stringify(payloadObj));
},
async get(token) {
const v = await redisClient.get(`sess:${token}`);
return v ? JSON.parse(v) : null;
},
async del(token) {
await redisClient.del(`sess:${token}`);
}
};
}
module.exports = { createRedisSessionStore };
EOF
store/mysqlSessionStore.jscat > ~/signup-lab/store/mysqlSessionStore.js <<'EOF'
function createMysqlSessionStore(mysqlPool) {
return {
async set(token, payloadObj, ttlSec) {
await mysqlPool.query(
`INSERT INTO sessions(token, user_id, data, expires_at)
VALUES(?, ?, ?, DATE_ADD(NOW(), INTERVAL ? SECOND))
ON DUPLICATE KEY UPDATE
user_id=VALUES(user_id),
data=VALUES(data),
expires_at=VALUES(expires_at)`,
[token, payloadObj.user_id, JSON.stringify(payloadObj), ttlSec]
);
},
async get(token) {
const [rows] = await mysqlPool.query(
`SELECT data, expires_at
FROM sessions
WHERE token = ?`,
[token]
);
if (rows.length === 0) return null;
const r = rows[0];
if (new Date(r.expires_at).getTime() <= Date.now()) return null;
if (r.data == null) return null;
// mysql2가 JSON 컬럼을 string 또는 object로 반환할 수 있음
if (typeof r.data === "string") return JSON.parse(r.data);
return r.data;
},
async del(token) {
await mysqlPool.query(`DELETE FROM sessions WHERE token=?`, [token]);
},
async cleanupExpired() {
await mysqlPool.query(`DELETE FROM sessions WHERE expires_at < NOW()`);
}
};
}
module.exports = { createMysqlSessionStore };
EOF
store/fallbackSessionStore.js (자동 폴백 : redis 장애시 mysql에서 인증)cat > ~/signup-lab/store/fallbackSessionStore.js <<'EOF'
const { createRedisSessionStore } = require("./redisSessionStore");
const { createMysqlSessionStore } = require("./mysqlSessionStore");
function createFallbackSessionStore({ redisClient, mysqlPool, breakerMs = 3000 }) {
const redisStore = redisClient ? createRedisSessionStore(redisClient) : null;
const mysqlStore = createMysqlSessionStore(mysqlPool);
let redisDownUntil = 0;
const redisUsableNow = () => redisStore && Date.now() >= redisDownUntil;
const markRedisDown = () => { redisDownUntil = Date.now() + breakerMs; };
return {
async set(token, payloadObj, ttlSec) {
// 장애 대비: MySQL에 항상 저장
await mysqlStore.set(token, payloadObj, ttlSec);
// 성능: Redis는 가능하면 저장(실패해도 로그인 성공)
if (redisUsableNow()) {
try {
await redisStore.set(token, payloadObj, ttlSec);
} catch (e) {
markRedisDown();
console.error("[fallback] Redis set failed -> keep MySQL only:", e.message);
}
}
},
async get(token) {
// Redis 우선
if (redisUsableNow()) {
try {
const v = await redisStore.get(token);
if (v) return v;
} catch (e) {
markRedisDown();
console.error("[fallback] Redis get failed -> fallback to MySQL:", e.message);
}
}
// MySQL 폴백
return await mysqlStore.get(token);
},
async del(token) {
try { await mysqlStore.del(token); } catch (e) {
console.error("[fallback] MySQL del failed:", e.message);
}
if (redisUsableNow()) {
try { await redisStore.del(token); } catch (e) {
markRedisDown();
console.error("[fallback] Redis del failed:", e.message);
}
}
},
async cleanupExpired() {
await mysqlStore.cleanupExpired();
}
};
}
module.exports = { createFallbackSessionStore };
EOF
store/sessionStore.jscat > ~/signup-lab/store/sessionStore.js <<'EOF'
const { createRedisSessionStore } = require("./redisSessionStore");
const { createMysqlSessionStore } = require("./mysqlSessionStore");
const { createFallbackSessionStore } = require("./fallbackSessionStore");
function createSessionStore({ type, redisClient, mysqlPool }) {
const t = (type || "redis").toLowerCase().trim();
if (t === "mysql") return createMysqlSessionStore(mysqlPool);
if (t === "fallback") return createFallbackSessionStore({ redisClient, mysqlPool, breakerMs: 3000 });
return createRedisSessionStore(redisClient);
}
module.exports = { createSessionStore };
EOF
public/index.htmlcat > ~/signup-lab/public/index.html <<'EOF'
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Auth Lab</title>
<link rel="stylesheet" href="/public/style.css" />
</head>
<body>
<header class="header">
<h1>Auth Lab: Redis 자동 폴백 + PostgreSQL(JSONB) 이벤트 로그</h1>
<div class="sub">
세션은 Redis 우선, Redis 장애 시 MySQL로 자동 폴백. 이벤트는 PostgreSQL JSONB로 저장/분석.
</div>
</header>
<main class="grid">
<section class="card">
<h2>회원가입</h2>
<form id="formSignup" class="stack">
<input name="email" placeholder="email" required />
<input name="password" type="password" placeholder="password" required />
<button type="submit">Signup</button>
</form>
<pre id="outSignup" class="pre"></pre>
</section>
<section class="card">
<h2>로그인 / 인증</h2>
<form id="formLogin" class="stack">
<input name="email" placeholder="email" required />
<input name="password" type="password" placeholder="password" required />
<button type="submit">Login</button>
</form>
<div class="row">
<button id="btnMe" type="button">/me 조회</button>
<button id="btnLogout" type="button">Logout</button>
</div>
<div class="hint">* 토큰은 브라우저 localStorage에 저장됩니다.</div>
<pre id="outLogin" class="pre"></pre>
</section>
<section class="card wide">
<h2>PostgreSQL 이벤트 로그/분석(체감)</h2>
<div class="row">
<button id="btnEvents" type="button">최근 이벤트 50개</button>
<button id="btnStats" type="button">이벤트 통계(60분)</button>
</div>
<div class="row">
<input id="searchKey" placeholder="payload key 예: reason" />
<input id="searchValue" placeholder="value 예: bad_password" />
<button id="btnSearch" type="button">JSONB 검색</button>
</div>
<pre id="outEvents" class="pre"></pre>
</section>
<section class="card wide">
<h2>운영 확인 포인트</h2>
<ul>
<li><b>Redis 정상</b>: <code>redis-cli keys "sess:*"</code>로 세션 존재 확인</li>
<li><b>Redis 다운</b>: <code>sudo systemctl stop redis-server</code> 후에도 <code>/me</code>가 되면 폴백 성공</li>
<li><b>MySQL 세션</b>: <code>SELECT token, expires_at FROM sessions ORDER BY created_at DESC;</code></li>
<li><b>PostgreSQL 이벤트</b>: <code>SELECT event_type, count(*) FROM auth_events GROUP BY event_type;</code></li>
</ul>
<pre id="outInfo" class="pre"></pre>
</section>
</main>
<script src="/public/app.js"></script>
</body>
</html>
EOF
public/style.csscat > ~/signup-lab/public/style.css <<'EOF'
* { box-sizing: border-box; }
body { margin:0; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; background:#0b0f17; color:#e8eefc; }
.header { padding:18px 20px; border-bottom:1px solid #24304b; background:#0f1626; }
.header h1 { margin:0 0 6px 0; font-size:18px; }
.sub { color:#a9b7d6; font-size:12px; }
.grid { display:grid; grid-template-columns: 1fr 1fr; gap:14px; padding:14px; }
.card { background:#0f1626; border:1px solid #24304b; border-radius:12px; padding:14px; }
.card.wide { grid-column: 1 / span 2; }
h2 { margin:0 0 10px 0; font-size:16px; }
.stack { display:flex; flex-direction:column; gap:10px; }
.row { display:flex; gap:10px; margin-top:10px; flex-wrap:wrap; }
input { background:#111a2e; color:#e8eefc; border:1px solid #24304b; border-radius:8px; padding:10px; min-width: 220px; }
button { background:#1f6feb; color:white; border:none; border-radius:8px; padding:10px 12px; cursor:pointer; }
button:hover { filter: brightness(1.05); }
.pre { background:#0b0f17; border:1px solid #24304b; border-radius:10px; padding:12px; overflow:auto; max-height:320px; }
.hint { color:#a9b7d6; font-size:12px; margin-top:8px; }
code { color:#a9b7d6; }
EOF
public/app.jscat > ~/signup-lab/public/app.js <<'EOF'
async function api(path, opts) {
const r = await fetch(path, opts);
const ct = r.headers.get("content-type") || "";
const body = ct.includes("application/json") ? await r.json() : await r.text();
if (!r.ok) throw new Error(typeof body === "string" ? body : JSON.stringify(body));
return body;
}
function setOut(sel, v) {
document.querySelector(sel).textContent =
typeof v === "string" ? v : JSON.stringify(v, null, 2);
}
function getToken() {
return localStorage.getItem("token");
}
function setToken(t) {
localStorage.setItem("token", t);
}
async function main() {
try {
const info = await api("/api/info");
setOut("#outInfo", info);
} catch (e) {
setOut("#outInfo", e.message);
}
document.querySelector("#formSignup").addEventListener("submit", async (e) => {
e.preventDefault();
const body = Object.fromEntries(new FormData(e.target).entries());
try {
const out = await api("/api/signup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
});
setOut("#outSignup", out);
} catch (err) {
setOut("#outSignup", err.message);
}
});
document.querySelector("#formLogin").addEventListener("submit", async (e) => {
e.preventDefault();
const body = Object.fromEntries(new FormData(e.target).entries());
try {
const out = await api("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
});
setToken(out.token);
setOut("#outLogin", out);
} catch (err) {
setOut("#outLogin", err.message);
}
});
document.querySelector("#btnMe").addEventListener("click", async () => {
try {
const token = getToken();
const out = await api("/api/me", {
headers: { "Authorization": `Bearer ${token}` }
});
setOut("#outLogin", out);
} catch (err) {
setOut("#outLogin", err.message);
}
});
document.querySelector("#btnLogout").addEventListener("click", async () => {
try {
const token = getToken();
const out = await api("/api/logout", {
method: "POST",
headers: { "Authorization": `Bearer ${token}` }
});
localStorage.removeItem("token");
setOut("#outLogin", out);
} catch (err) {
setOut("#outLogin", err.message);
}
});
document.querySelector("#btnEvents").addEventListener("click", async () => {
try {
const out = await api("/api/events?limit=50");
setOut("#outEvents", out);
} catch (err) {
setOut("#outEvents", err.message);
}
});
document.querySelector("#btnStats").addEventListener("click", async () => {
try {
const out = await api("/api/events/stats?minutes=60");
setOut("#outEvents", out);
} catch (err) {
setOut("#outEvents", err.message);
}
});
document.querySelector("#btnSearch").addEventListener("click", async () => {
try {
const key = document.querySelector("#searchKey").value.trim();
const value = document.querySelector("#searchValue").value.trim();
const out = await api(`/api/events/search?key=${encodeURIComponent(key)}&value=${encodeURIComponent(value)}`);
setOut("#outEvents", out);
} catch (err) {
setOut("#outEvents", err.message);
}
});
}
main();
EOF
server.jscat > ~/signup-lab/server.js <<'EOF'
require("dotenv").config();
const express = require("express");
const path = require("path");
const bcrypt = require("bcrypt");
const crypto = require("crypto");
const { createClient } = require("redis");
const { getMysqlPool } = require("./db/mysql");
const { getPgPool } = require("./db/pg");
const { createSessionStore } = require("./store/sessionStore");
const env = process.env;
const app = express();
app.use(express.json());
app.use("/public", express.static(path.join(__dirname, "public")));
function storeType() {
return (env.SESSION_STORE || "redis").toLowerCase().trim();
}
function extractBearerToken(req) {
const h = req.headers["authorization"] || "";
const m = h.match(/^Bearer\s+(.+)$/i);
return m ? m[1] : null;
}
function getClientIp(req) {
return req.socket?.remoteAddress || null;
}
(async () => {
try {
const st = storeType();
console.log("[BOOT] SESSION_STORE =", st);
const mysqlPool = getMysqlPool(env);
console.log("[BOOT] MySQL pool ready");
const pgPool = getPgPool(env);
console.log("[BOOT] PostgreSQL pool ready");
// Redis: fallback/redis 모드에서만 연결 시도
// - redis 모드: Redis 없으면 종료(fail-closed)
// - fallback 모드: Redis 없으면 MySQL로 계속 서비스
let redis = null;
if (st === "redis" || st === "fallback") {
try {
redis = createClient({
socket: { host: env.REDIS_HOST, port: Number(env.REDIS_PORT || 6379) }
});
redis.on("error", (e) => console.error("Redis error:", e.message));
await redis.connect();
console.log("[BOOT] Redis connected");
} catch (e) {
console.error("[BOOT] Redis connect failed:", e.message);
if (st === "redis") {
console.error("[BOOT] SESSION_STORE=redis 이므로 종료(fail-closed)");
process.exit(1);
}
console.log("[BOOT] fallback 모드 → Redis 없이 MySQL로 계속 서비스");
redis = null;
}
}
const sessionStore = createSessionStore({
type: st,
redisClient: redis,
mysqlPool
});
async function logEvent({ eventType, actor, ip, payload }) {
try {
await pgPool.query(
`INSERT INTO auth_events(event_type, actor, ip, payload)
VALUES ($1, $2, $3, $4::jsonb)`,
[eventType, actor || null, ip || null, JSON.stringify(payload || {})]
);
} catch (e) {
console.error("[event] insert failed:", e.message);
}
}
app.get("/", (req, res) => res.sendFile(path.join(__dirname, "public", "index.html")));
app.get("/api/info", (req, res) => {
res.json({
session_store: st,
session_ttl_seconds: Number(env.SESSION_TTL_SECONDS || 1800),
mysql: `${env.MYSQL_HOST}:${env.MYSQL_PORT || 3306}/${env.MYSQL_DATABASE}`,
postgres: `${env.PG_HOST}:${env.PG_PORT || 5432}/${env.PG_DATABASE}`,
redis: redis ? `${env.REDIS_HOST}:${env.REDIS_PORT || 6379}` : "(not connected)",
behavior: [
"fallback 모드: 로그인 시 세션을 MySQL에 항상 저장하고 Redis가 있으면 Redis에도 저장",
"인증(/me): Redis 우선 조회 → 실패 시 MySQL로 자동 폴백",
"이벤트는 PostgreSQL JSONB로 저장되어 검색/집계 실습 가능"
]
});
});
app.post("/api/signup", async (req, res) => {
const ip = getClientIp(req);
const { email, password } = req.body || {};
if (!email || !password) return res.status(400).send("email/password required");
try {
const password_hash = await bcrypt.hash(password, 10);
await mysqlPool.query(
`INSERT INTO users(email, password_hash) VALUES(?, ?)`,
[email, password_hash]
);
await logEvent({ eventType: "signup", actor: email, ip, payload: { email, result: "ok" } });
res.json({ ok: true });
} catch (e) {
const msg = String(e.message || "");
if (msg.toLowerCase().includes("duplicate")) {
await logEvent({ eventType: "signup", actor: email, ip, payload: { email, result: "duplicate" } });
return res.status(409).send("email already exists");
}
console.error("[signup] error:", e);
await logEvent({ eventType: "signup", actor: email, ip, payload: { email, result: "error", error: msg.slice(0, 200) } });
res.status(500).send("signup failed");
}
});
app.post("/api/login", async (req, res) => {
const ip = getClientIp(req);
const { email, password } = req.body || {};
if (!email || !password) return res.status(400).send("email/password required");
try {
const [rows] = await mysqlPool.query(
`SELECT user_id, email, password_hash FROM users WHERE email=?`,
[email]
);
if (rows.length === 0) {
await logEvent({ eventType: "login_fail", actor: email, ip, payload: { reason: "no_user" } });
return res.status(401).send("invalid credentials");
}
const user = rows[0];
const ok = await bcrypt.compare(password, user.password_hash);
if (!ok) {
await logEvent({ eventType: "login_fail", actor: email, ip, payload: { reason: "bad_password" } });
return res.status(401).send("invalid credentials");
}
const token = crypto.randomBytes(24).toString("hex");
const ttl = Number(env.SESSION_TTL_SECONDS || 1800);
await sessionStore.set(token, { user_id: user.user_id, email: user.email }, ttl);
await logEvent({
eventType: "login_success",
actor: email,
ip,
payload: { user_id: user.user_id, ttl_seconds: ttl, session_store: st }
});
res.json({ token, ttl_seconds: ttl, session_store: st });
} catch (e) {
console.error("[login] error:", e);
await logEvent({ eventType: "login_error", actor: email, ip, payload: { error: String(e.message || "").slice(0, 200) } });
res.status(500).send("login failed");
}
});
async function auth(req, res, next) {
const ip = getClientIp(req);
try {
const token = extractBearerToken(req);
if (!token) return res.status(401).send("missing token");
const sess = await sessionStore.get(token);
if (!sess) {
await logEvent({ eventType: "auth_fail", actor: null, ip, payload: { reason: "no_session" } });
return res.status(401).send("invalid/expired session");
}
req.user = sess;
req.token = token;
next();
} catch (e) {
console.error("[auth] error:", e);
await logEvent({ eventType: "auth_error", actor: null, ip, payload: { error: String(e.message || "").slice(0, 200) } });
res.status(500).send("auth failed");
}
}
app.get("/api/me", auth, async (req, res) => {
res.json({ ok: true, user: req.user, session_store: st });
});
app.post("/api/logout", auth, async (req, res) => {
const ip = getClientIp(req);
await sessionStore.del(req.token);
await logEvent({ eventType: "logout", actor: req.user?.email, ip, payload: { user_id: req.user?.user_id } });
res.json({ ok: true });
});
app.get("/api/events", async (req, res) => {
const limit = Math.min(Number(req.query.limit || 50), 200);
const type = req.query.type || null;
const params = [];
let where = "";
if (type) {
where = "WHERE event_type = $1";
params.push(type);
}
const sql =
`SELECT event_id, ts, event_type, actor, ip, payload
FROM auth_events
${where}
ORDER BY ts DESC
LIMIT ${limit}`;
const r = await pgPool.query(sql, params);
res.json({ ok: true, rows: r.rows });
});
app.get("/api/events/stats", async (req, res) => {
const minutes = Math.min(Math.max(Number(req.query.minutes || 60), 1), 1440);
const r = await pgPool.query(
`SELECT event_type, COUNT(*)::int AS cnt
FROM auth_events
WHERE ts >= NOW() - ($1 || ' minutes')::interval
GROUP BY event_type
ORDER BY cnt DESC`,
[String(minutes)]
);
res.json({ ok: true, minutes, rows: r.rows });
});
app.get("/api/events/search", async (req, res) => {
const key = req.query.key;
const value = req.query.value;
if (!key || !value) return res.status(400).send("key/value required");
const r = await pgPool.query(
`SELECT event_id, ts, event_type, actor, ip, payload
FROM auth_events
WHERE payload ->> $1 = $2
ORDER BY ts DESC
LIMIT 100`,
[key, value]
);
res.json({ ok: true, key, value, rows: r.rows });
});
app.post("/api/admin/sessions/cleanup", async (req, res) => {
if (typeof sessionStore.cleanupExpired !== "function") {
return res.status(400).send("cleanup not supported in this mode");
}
await sessionStore.cleanupExpired();
res.json({ ok: true });
});
const port = Number(env.PORT || 8080);
app.listen(port, "0.0.0.0", () => {
console.log(`[BOOT] Listening on http://0.0.0.0:${port}`);
});
} catch (e) {
console.error("[FATAL] server crashed:", e);
process.exit(1);
}
})();
EOF
cd ~/signup-lab
node server.js
포트 확인:
sudo ss -lntp | grep 8080
http://192.168.80.110:8080
/me 정상VM2에서 Redis 키 확인:
redis-cli keys "sess:*"
VM2에서 MySQL 세션 확인:
mysql -uroot -e "USE auth_lab; SELECT token, expires_at FROM sessions ORDER BY created_at DESC LIMIT 5;"
VM2에서:
sudo systemctl stop redis-server
이 상태에서 VM1 웹에서:
/me 버튼을 다시 눌러도 정상이어야 함VM2에서:
sudo systemctl start redis-server
reasonbad_passwordVM3에서:
sudo -u postgres psql -d monitor_lab -c "SELECT event_type, count(*) FROM auth_events GROUP BY event_type ORDER BY 2 DESC;"
결론
“운영 중에 로그에 새로운 정보가 필요해졌다.
그런데 이미 서비스는 돌아가고 있다.
이때 PostgreSQL JSONB는 테이블 변경 없이 바로 확장할 수 있다.”
auth_events 테이블payload JSONB 컬럼 하나에 이벤트 정보 저장{
"reason": "bad_password"
}
{
"reason": "bad_password",
"attempt": 3,
"user_agent": "Mozilla/5.0 ...",
"geo": { "country": "KR", "city": "Seoul" }
}
→ DDL 없음, 서비스 중단 없음
보안팀에서 요청이 왔다
“로그인 실패 시 몇 번째 실패인지(attempt) 와 브라우저 정보(user_agent) 도 같이 남겨달라”
📍 VM1 – server.js
await logEvent({
eventType: "login_fail",
actor: email,
ip,
payload: { reason: "bad_password" }
});
await logEvent({
eventType: "login_fail",
actor: email,
ip,
payload: {
reason: "bad_password",
attempt: 1,
user_agent: req.headers["user-agent"]
}
});
✔️ 중요
- DB 테이블 수정 ❌
- 컬럼 추가 ❌
- 인덱스 변경 ❌ 👉 코드만 바꿈
“이 상태로 바로 서버 재시작”
node server.js
sudo -u postgres psql -d monitor_lab
SELECT payload
FROM auth_events
WHERE event_type = 'login_fail'
ORDER BY ts DESC
LIMIT 3;
{
"reason": "bad_password",
"attempt": 1,
"user_agent": "Mozilla/5.0 (X11; Linux x86_64)..."
}
👉 컬럼 추가 없이 필드가 늘어남
SELECT ts, actor, payload->>'user_agent'
FROM auth_events
WHERE payload->>'reason' = 'bad_password'
ORDER BY ts DESC;
SELECT ts, actor, payload
FROM auth_events
WHERE payload->>'attempt' = '1';
✔️ JSONB 내부 필드를 컬럼처럼 검색
payload: {
reason: "bad_password",
attempt: 2,
user_agent: req.headers["user-agent"],
geo: {
country: "KR",
city: "Seoul"
}
}
SELECT
payload->'geo'->>'country' AS country,
payload->'geo'->>'city' AS city
FROM auth_events
WHERE event_type = 'login_fail';
👉 RDB인데 NoSQL처럼 쓸 수 있음.
GIN 인덱스 생성:
CREATE INDEX idx_auth_events_payload_gin
ON auth_events USING GIN (payload);
EXPLAIN ANALYZE
SELECT *
FROM auth_events
WHERE payload->>'reason' = 'bad_password';
👉 인덱스 없을 때 / 있을 때 비교 설명
| 항목 | MySQL(RDB) | PostgreSQL(JSONB) |
|---|---|---|
| 필드 추가 | ALTER TABLE 필요 | ❌ 필요 없음 |
| 서비스 중단 위험 | 있음 | 없음 |
| 로그/이벤트 | 부적합 | 매우 적합 |
| 구조 변경 | 어려움 | 자유로움 |
| 인덱스 | 제한적 | GIN으로 JSON 내부까지 |