Merge branch 'iipbs/wr/dev'

This commit is contained in:
2026-09-22 20:49:40 +09:00
2 changed files with 413 additions and 191 deletions
+193 -191
View File
@@ -1,220 +1,222 @@
/**
* JWT를 decoding하면 만료시간을 읽을 수 있다.
* JWT는 3부분으로 구성 : Header.Payload.Signature
* Payload 부분에 만료시간(exp)이 있다.
* 티베로(하수도정보시스템) 조회 콘솔
*
* - reuse-service 의 티베로 데이터소스로 조회 SQL 한 문장(SELECT, WITH)을 실행하고 결과를 표로 보여준다.
* - 서버에서 조회 문장만 통과시키고, 읽기 전용 연결과 최대 행 수(기본 100, 최대 1000) 제한을 적용한다.
* - 서버 설정 tibero.sql-console.enabled=true 일 때만 동작한다.
*
* 🔗 사용 API
* 티베로 조회 SQL 실행 POST /ones/sql body: { sql, maxRows }
*
* 이전 JWT 만료시간 테스트 화면은 같은 폴더의 page.tsx.bak 에 보관되어 있다.
*/
"use client";
import { useEffect, useRef, useState } from "react";
import { authService } from "@/src/entities/auth";
import { CLAIM_NAME } from "@/src/shared/config";
import { getCookie } from "@/src/shared/lib";
import { useCallback, useMemo, useState } from "react";
import { fetchType, postFectch } from "@/components/lib/api";
interface JwtPayload {
sub?: string; // 토큰 subject. 일반적으로 사용자 식별자나 주체 정보
exp?: number; // 만료 시각(Unix timestamp, 초 단위)
iat?: number; // 토큰 발급 시각(Unix timestamp, 초 단위)
[key: string]: unknown; // 그 외 JWT payload에 포함될 수 있는 추가 클레임
}
interface JwtState {
token: string; // 쿠키에서 읽어온 JWT 원본 문자열
payload: JwtPayload | null; // JWT를 파싱한 payload 객체
exp: number | null; // payload에서 추출한 exp 값
expDateLocal: string | null; // exp를 로컬 시간 문자열로 변환한 값
remainingMs: number | null; // 현재 시각 기준 남은 시간(ms)
error: string | null; // 쿠키 없음, 파싱 실패 등 오류 메시지
}
const initialJwtState: JwtState = {
token: "",
payload: null,
exp: null,
expDateLocal: null,
remainingMs: null,
error: null,
type TiberoSqlResult = {
success: boolean;
message: string | null;
columns: string[];
rows: (string | null)[][];
rowCount: number;
truncated: boolean;
maxRows: number;
elapsedMs: number;
};
function parseJwt(token: string): JwtPayload {
const parts = token.split(".");
if (parts.length !== 3) {
throw new Error("유효한 JWT 형식이 아닙니다.");
}
type TiberoSqlResponse = {
status: number;
data: TiberoSqlResult | null;
count: number;
msg: string | null;
};
const base64Url = parts[1] ?? ""; // Payload를 가져온다.
const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "=");
const jsonPayload = decodeURIComponent(
atob(padded)
.split("")
.map((char) => `%${char.charCodeAt(0).toString(16).padStart(2, "0")}`)
.join("")
);
const DEFAULT_SQL = `SELECT BSC_FCT_CD, BSC_FCT_NM, RGN_CD, ADST_DSTR_CD, FCT_CPC
FROM ONSEMIRO_NEW.OM_MS_VW
WHERE ROWNUM <= 20`;
return JSON.parse(jsonPayload) as JwtPayload;
}
const MAX_ROWS_LIMIT = 1000;
function formatRemainingMs(remainingMs: number | null): string {
if (remainingMs === null) return "-";
if (remainingMs <= 0) return "만료됨";
const toTsv = (columns: string[], rows: (string | null)[][]) =>
[columns, ...rows]
.map((row) => row.map((value) => (value ?? "").replace(/[\t\r\n]+/g, " ")).join("\t"))
.join("\n");
const totalSeconds = Math.floor(remainingMs / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
export default function TiberoSqlConsolePage() {
const [sql, setSql] = useState<string>(DEFAULT_SQL);
const [maxRows, setMaxRows] = useState<number>(100);
const [loading, setLoading] = useState<boolean>(false);
const [result, setResult] = useState<TiberoSqlResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState<boolean>(false);
return `${hours}시간 ${minutes}분 ${seconds}초`;
}
function readJwtState(): JwtState {
const token = getCookie(CLAIM_NAME) ?? "";
if (!token) {
return {
token: "",
payload: null,
exp: null,
expDateLocal: null,
remainingMs: null,
error: `${CLAIM_NAME} 쿠키가 없습니다.`,
};
}
try {
const payload = parseJwt(token);
if (typeof payload.exp !== "number") {
return {
token,
payload,
exp: null,
expDateLocal: null,
remainingMs: null,
error: "JWT payload에 exp 값이 없습니다.",
};
const runQuery = useCallback(async () => {
if (loading) return;
if (!sql.trim()) {
setError("SQL을 입력해주세요.");
return;
}
const expMs = payload.exp * 1000;
return {
token,
payload,
exp: payload.exp,
expDateLocal: new Date(expMs).toLocaleString(),
remainingMs: Math.max(expMs - Date.now(), 0),
error: null,
};
} catch (error) {
return {
token,
payload: null,
exp: null,
expDateLocal: null,
remainingMs: null,
error:
error instanceof Error
? error.message
: "JWT 파싱 중 오류가 발생했습니다.",
};
}
}
export default function Page() {
const [jwtState, setJwtState] = useState<JwtState>(initialJwtState);
const isRefreshingRef = useRef(false);
const handleSilentRefresh = async () => {
setLoading(true);
setError(null);
setCopied(false);
try {
await authService.silentRefresh();
} catch (error) {
console.error("Manual silent refresh failed:", error);
const response = (await postFectch(
"/ones/sql",
{ sql, maxRows },
fetchType.WR
)) as TiberoSqlResponse;
const data = response?.data ?? null;
setResult(data);
if (!data || !data.success) {
setError(response?.msg || data?.message || "조회에 실패했습니다.");
}
} catch (e) {
setResult(null);
setError(e instanceof Error ? e.message : "요청 중 오류가 발생했습니다.");
} finally {
setJwtState(readJwtState());
setLoading(false);
}
};
}, [loading, sql, maxRows]);
useEffect(() => {
const syncJwtState = async () => {
const nextState = readJwtState();
const copyTsv = useCallback(async () => {
if (!result?.success) return;
try {
await navigator.clipboard.writeText(toTsv(result.columns, result.rows));
setCopied(true);
} catch {
setError("클립보드 복사에 실패했습니다.");
}
}, [result]);
if (
nextState.remainingMs !== null &&
nextState.remainingMs > 0 &&
nextState.remainingMs < 5 * 60 * 1000 &&
!isRefreshingRef.current
) {
isRefreshingRef.current = true;
try {
await authService.silentRefresh();
} catch (error) {
console.error("JWT test silent refresh failed:", error);
}
}
setJwtState(readJwtState());
if (
nextState.remainingMs === null ||
nextState.remainingMs >= 5 * 60 * 1000 ||
nextState.remainingMs <= 0
) {
isRefreshingRef.current = false;
}
};
void syncJwtState();
const timer = window.setInterval(() => {
void syncJwtState();
}, 1000);
return () => window.clearInterval(timer);
}, []);
const summary = useMemo(() => {
if (!result?.success) return null;
return `${result.rowCount.toLocaleString()}행 · ${result.elapsedMs.toLocaleString()}ms · 최대 ${result.maxRows.toLocaleString()}행`;
}, [result]);
return (
<div className="space-y-4 p-8 text-base">
<h1 className="text-2xl font-bold">JWT Exp Test</h1>
<div className="flex h-full flex-col gap-4 p-6 text-base">
<div className="flex items-baseline justify-between">
<h1 className="text-2xl font-bold">티베로 조회 콘솔</h1>
<span className="text-sm text-gray-500">
SELECT, WITH 한 문장만 실행됩니다 · Ctrl+Enter 실행
</span>
</div>
<button
type="button"
className="rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
onClick={() => {
void handleSilentRefresh();
<textarea
className="h-48 w-full resize-y rounded border border-gray-300 p-3 font-mono text-xl focus:border-blue-500 focus:outline-none"
value={sql}
spellCheck={false}
placeholder="SELECT ... FROM ONSEMIRO_NEW.테이블명"
onChange={(e) => setSql(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
void runQuery();
}
}}
>
Silent Refresh 호출
</button>
/>
<div className="rounded border p-4">
<div className="font-semibold">쿠키명</div>
<div>{CLAIM_NAME}</div>
<div className="flex flex-wrap items-center gap-3">
<label className="flex items-center gap-2 text-sm">
최대 행 수
<input
type="number"
min={1}
max={MAX_ROWS_LIMIT}
className="w-24 rounded border border-gray-300 px-2 py-1 text-right"
value={maxRows}
onChange={(e) => {
const next = Number(e.target.value);
if (Number.isFinite(next)) {
setMaxRows(Math.min(Math.max(Math.trunc(next), 1), MAX_ROWS_LIMIT));
}
}}
/>
</label>
<button
type="button"
className="rounded bg-blue-600 px-5 py-2 text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:bg-gray-400"
disabled={loading}
onClick={() => void runQuery()}
>
{loading ? "실행 중..." : "실행"}
</button>
<button
type="button"
className="rounded border border-gray-300 px-4 py-2 hover:bg-gray-50 disabled:cursor-not-allowed disabled:text-gray-400"
disabled={!result?.success || result.rowCount === 0}
onClick={() => void copyTsv()}
>
{copied ? "복사됨" : "결과 복사(TSV)"}
</button>
{summary && <span className="text-sm text-gray-600">{summary}</span>}
</div>
<div className="rounded border p-4">
<div className="font-semibold">exp</div>
<div>{jwtState.exp ?? "-"}</div>
</div>
{error && (
<div className="whitespace-pre-wrap break-all rounded border border-red-300 bg-red-50 p-3 text-sm text-red-700">
{error}
</div>
)}
{result?.success && result.truncated && (
<div className="rounded border border-amber-300 bg-amber-50 p-3 text-sm text-amber-800">
{result.message}
</div>
)}
<div className="rounded border p-4">
<div className="font-semibold">만료시각</div>
<div>{jwtState.expDateLocal ?? "-"}</div>
</div>
<div className="rounded border p-4">
<div className="font-semibold">남은시간</div>
<div>{formatRemainingMs(jwtState.remainingMs)}</div>
</div>
<div className="rounded border p-4">
<div className="font-semibold">상태</div>
<div>{jwtState.error ?? "정상"}</div>
</div>
<div className="rounded border p-4">
<div className="font-semibold">payload</div>
<pre className="overflow-auto whitespace-pre-wrap break-all text-sm">
{jwtState.payload ? JSON.stringify(jwtState.payload, null, 2) : "-"}
</pre>
</div>
{result?.success && (
<div className="min-h-0 flex-1 overflow-auto rounded border border-gray-300">
{result.columns.length === 0 ? (
<div className="p-4 text-sm text-gray-500">컬럼이 없습니다.</div>
) : (
<table className="min-w-full border-collapse text-sm">
<thead className="sticky top-0 bg-gray-100">
<tr>
<th className="border-b border-r border-gray-300 px-2 py-1 text-right text-gray-500">#</th>
{result.columns.map((column, index) => (
<th
key={`${column}-${index}`}
className="whitespace-nowrap border-b border-r border-gray-300 px-2 py-1 text-left font-semibold"
>
{column}
</th>
))}
</tr>
</thead>
<tbody>
{result.rows.length === 0 ? (
<tr>
<td
className="px-3 py-4 text-center text-gray-500"
colSpan={result.columns.length + 1}
>
조회 결과가 없습니다.
</td>
</tr>
) : (
result.rows.map((row, rowIndex) => (
<tr key={rowIndex} className="odd:bg-white even:bg-gray-50 hover:bg-blue-50">
<td className="border-b border-r border-gray-200 px-2 py-1 text-right text-gray-400">
{rowIndex + 1}
</td>
{row.map((value, colIndex) => (
<td
key={colIndex}
className="max-w-md truncate whitespace-nowrap border-b border-r border-gray-200 px-2 py-1"
title={value ?? "NULL"}
>
{value === null ? <span className="italic text-gray-400">NULL</span> : value}
</td>
))}
</tr>
))
)}
</tbody>
</table>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,220 @@
/**
* JWT를 decoding하면 만료시간을 읽을 수 있다.
* JWT는 3부분으로 구성 : Header.Payload.Signature
* Payload 부분에 만료시간(exp)이 있다.
*/
"use client";
import { useEffect, useRef, useState } from "react";
import { authService } from "@/src/entities/auth";
import { CLAIM_NAME } from "@/src/shared/config";
import { getCookie } from "@/src/shared/lib";
interface JwtPayload {
sub?: string; // 토큰 subject. 일반적으로 사용자 식별자나 주체 정보
exp?: number; // 만료 시각(Unix timestamp, 초 단위)
iat?: number; // 토큰 발급 시각(Unix timestamp, 초 단위)
[key: string]: unknown; // 그 외 JWT payload에 포함될 수 있는 추가 클레임
}
interface JwtState {
token: string; // 쿠키에서 읽어온 JWT 원본 문자열
payload: JwtPayload | null; // JWT를 파싱한 payload 객체
exp: number | null; // payload에서 추출한 exp 값
expDateLocal: string | null; // exp를 로컬 시간 문자열로 변환한 값
remainingMs: number | null; // 현재 시각 기준 남은 시간(ms)
error: string | null; // 쿠키 없음, 파싱 실패 등 오류 메시지
}
const initialJwtState: JwtState = {
token: "",
payload: null,
exp: null,
expDateLocal: null,
remainingMs: null,
error: null,
};
function parseJwt(token: string): JwtPayload {
const parts = token.split(".");
if (parts.length !== 3) {
throw new Error("유효한 JWT 형식이 아닙니다.");
}
const base64Url = parts[1] ?? ""; // Payload를 가져온다.
const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "=");
const jsonPayload = decodeURIComponent(
atob(padded)
.split("")
.map((char) => `%${char.charCodeAt(0).toString(16).padStart(2, "0")}`)
.join("")
);
return JSON.parse(jsonPayload) as JwtPayload;
}
function formatRemainingMs(remainingMs: number | null): string {
if (remainingMs === null) return "-";
if (remainingMs <= 0) return "만료됨";
const totalSeconds = Math.floor(remainingMs / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
return `${hours}시간 ${minutes}분 ${seconds}초`;
}
function readJwtState(): JwtState {
const token = getCookie(CLAIM_NAME) ?? "";
if (!token) {
return {
token: "",
payload: null,
exp: null,
expDateLocal: null,
remainingMs: null,
error: `${CLAIM_NAME} 쿠키가 없습니다.`,
};
}
try {
const payload = parseJwt(token);
if (typeof payload.exp !== "number") {
return {
token,
payload,
exp: null,
expDateLocal: null,
remainingMs: null,
error: "JWT payload에 exp 값이 없습니다.",
};
}
const expMs = payload.exp * 1000;
return {
token,
payload,
exp: payload.exp,
expDateLocal: new Date(expMs).toLocaleString(),
remainingMs: Math.max(expMs - Date.now(), 0),
error: null,
};
} catch (error) {
return {
token,
payload: null,
exp: null,
expDateLocal: null,
remainingMs: null,
error:
error instanceof Error
? error.message
: "JWT 파싱 중 오류가 발생했습니다.",
};
}
}
export default function Page() {
const [jwtState, setJwtState] = useState<JwtState>(initialJwtState);
const isRefreshingRef = useRef(false);
const handleSilentRefresh = async () => {
try {
await authService.silentRefresh();
} catch (error) {
console.error("Manual silent refresh failed:", error);
} finally {
setJwtState(readJwtState());
}
};
useEffect(() => {
const syncJwtState = async () => {
const nextState = readJwtState();
if (
nextState.remainingMs !== null &&
nextState.remainingMs > 0 &&
nextState.remainingMs < 5 * 60 * 1000 &&
!isRefreshingRef.current
) {
isRefreshingRef.current = true;
try {
await authService.silentRefresh();
} catch (error) {
console.error("JWT test silent refresh failed:", error);
}
}
setJwtState(readJwtState());
if (
nextState.remainingMs === null ||
nextState.remainingMs >= 5 * 60 * 1000 ||
nextState.remainingMs <= 0
) {
isRefreshingRef.current = false;
}
};
void syncJwtState();
const timer = window.setInterval(() => {
void syncJwtState();
}, 1000);
return () => window.clearInterval(timer);
}, []);
return (
<div className="space-y-4 p-8 text-base">
<h1 className="text-2xl font-bold">JWT Exp Test</h1>
<button
type="button"
className="rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
onClick={() => {
void handleSilentRefresh();
}}
>
Silent Refresh 호출
</button>
<div className="rounded border p-4">
<div className="font-semibold">쿠키명</div>
<div>{CLAIM_NAME}</div>
</div>
<div className="rounded border p-4">
<div className="font-semibold">exp</div>
<div>{jwtState.exp ?? "-"}</div>
</div>
<div className="rounded border p-4">
<div className="font-semibold">만료시각</div>
<div>{jwtState.expDateLocal ?? "-"}</div>
</div>
<div className="rounded border p-4">
<div className="font-semibold">남은시간</div>
<div>{formatRemainingMs(jwtState.remainingMs)}</div>
</div>
<div className="rounded border p-4">
<div className="font-semibold">상태</div>
<div>{jwtState.error ?? "정상"}</div>
</div>
<div className="rounded border p-4">
<div className="font-semibold">payload</div>
<pre className="overflow-auto whitespace-pre-wrap break-all text-sm">
{jwtState.payload ? JSON.stringify(jwtState.payload, null, 2) : "-"}
</pre>
</div>
</div>
);
}