Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6af7676e6 | ||
|
|
a4f8cf6670 | ||
|
|
694aee4804 | ||
|
|
454b9e474f | ||
|
|
66bca69578 | ||
|
|
cb9267c57f | ||
|
|
1ff10de59f | ||
|
|
3bf21064e2 | ||
|
|
eb8cd58093 | ||
|
|
3390fe810b | ||
|
|
797b2c3b63 | ||
|
|
21df3bbfe0 | ||
|
|
dd074aec94 | ||
|
|
1d1d4af572 | ||
|
|
5519606876 | ||
|
|
25653f2d4f | ||
|
|
e93a83163e | ||
|
|
0f7c74726e | ||
|
|
a5495b4572 | ||
|
|
7a12d269bc | ||
|
|
f5bae39fba | ||
|
|
8b3fa32d1e | ||
|
|
993a9f4ff2 | ||
|
|
2b509246e8 | ||
|
|
a615158812 | ||
|
|
16b88c8c04 | ||
|
|
299ce3f1fe | ||
|
|
b65c50c5f9 | ||
|
|
eb37728532 | ||
|
|
c41a7bf3d3 | ||
|
|
774f3a5507 | ||
|
|
6e95e372a8 | ||
|
|
1ccd502e3a |
Vendored
+118
-8
@@ -14,13 +14,91 @@
|
||||
"dev:as"
|
||||
],
|
||||
"skipFiles": [
|
||||
"<node_internals>/**"
|
||||
"<node_internals>/**",
|
||||
"**/node_modules/**"
|
||||
],
|
||||
"resolveSourceMapLocations": [
|
||||
"${workspaceFolder}/**",
|
||||
"!**/node_modules/**"
|
||||
],
|
||||
"cwd": "${workspaceFolder}",
|
||||
"console": "integratedTerminal",
|
||||
"sourceMaps": true,
|
||||
"env": {
|
||||
"NODE_OPTIONS": "--inspect"
|
||||
"NODE_OPTIONS": "--inspect --max-old-space-size=8192"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"name": "dev:as 서버 컴포넌트 디버그 (node-terminal)",
|
||||
"command": "pnpm --filter as dev",
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
{
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"name": "dev:as 통합 디버그 (Server + Client)",
|
||||
"command": "pnpm --filter as dev",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"serverReadyAction": {
|
||||
"action": "debugWithChrome",
|
||||
"pattern": "- Local:\\s+(https?://.+)",
|
||||
"uriFormat": "%s",
|
||||
"webRoot": "${workspaceFolder}/apps/as"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Playwright: 현재 활성화된 테스트 디버그",
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeArgs": [
|
||||
"--filter",
|
||||
"cs",
|
||||
"exec",
|
||||
"playwright",
|
||||
"test",
|
||||
"${relativeFile}"
|
||||
],
|
||||
"cwd": "${workspaceFolder}",
|
||||
"console": "integratedTerminal",
|
||||
"skipFiles": [
|
||||
"<node_internals>/**",
|
||||
"**/node_modules/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Playwright: cs 로그인 테스트 디버그",
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeArgs": [
|
||||
"--filter",
|
||||
"cs",
|
||||
"exec",
|
||||
"playwright",
|
||||
"test",
|
||||
"tests/login.spec.ts"
|
||||
],
|
||||
"cwd": "${workspaceFolder}",
|
||||
"console": "integratedTerminal",
|
||||
"skipFiles": [
|
||||
"<node_internals>/**",
|
||||
"**/node_modules/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"name": "dev:cs 통합 디버그 (Server + Client)",
|
||||
"command": "pnpm --filter cs dev",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"serverReadyAction": {
|
||||
"action": "debugWithChrome",
|
||||
"pattern": "- Local:\\s+(https?://.+)",
|
||||
"uriFormat": "%s",
|
||||
"webRoot": "${workspaceFolder}/apps/cs"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -33,13 +111,18 @@
|
||||
"dev:cs"
|
||||
],
|
||||
"skipFiles": [
|
||||
"<node_internals>/**"
|
||||
"<node_internals>/**",
|
||||
"**/node_modules/**"
|
||||
],
|
||||
"resolveSourceMapLocations": [
|
||||
"${workspaceFolder}/**",
|
||||
"!**/node_modules/**"
|
||||
],
|
||||
"cwd": "${workspaceFolder}",
|
||||
"console": "integratedTerminal",
|
||||
"sourceMaps": true,
|
||||
"env": {
|
||||
"NODE_OPTIONS": "--inspect"
|
||||
"NODE_OPTIONS": "--inspect --max-old-space-size=8192"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -49,7 +132,8 @@
|
||||
"port": 9229,
|
||||
"restart": true,
|
||||
"skipFiles": [
|
||||
"<node_internals>/**"
|
||||
"<node_internals>/**",
|
||||
"**/node_modules/**"
|
||||
],
|
||||
"sourceMaps": true,
|
||||
"resolveSourceMapLocations": [
|
||||
@@ -63,7 +147,15 @@
|
||||
"name": "dev:cs 클라이언트 디버그 (Chrome)",
|
||||
"url": "http://localhost:3000",
|
||||
"webRoot": "${workspaceFolder}/apps/cs",
|
||||
"sourceMaps": true
|
||||
"skipFiles": [
|
||||
"<node_internals>/**",
|
||||
"**/node_modules/**"
|
||||
],
|
||||
"sourceMaps": true,
|
||||
"resolveSourceMapLocations": [
|
||||
"${workspaceFolder}/apps/cs/**",
|
||||
"!**/node_modules/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "node",
|
||||
@@ -75,13 +167,31 @@
|
||||
"dev:st"
|
||||
],
|
||||
"skipFiles": [
|
||||
"<node_internals>/**"
|
||||
"<node_internals>/**",
|
||||
"**/node_modules/**"
|
||||
],
|
||||
"resolveSourceMapLocations": [
|
||||
"${workspaceFolder}/**",
|
||||
"!**/node_modules/**"
|
||||
],
|
||||
"cwd": "${workspaceFolder}",
|
||||
"console": "integratedTerminal",
|
||||
"sourceMaps": true,
|
||||
"env": {
|
||||
"NODE_OPTIONS": "--inspect"
|
||||
"NODE_OPTIONS": "--inspect --max-old-space-size=8192"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"name": "dev:st 통합 디버그 (Server + Client)",
|
||||
"command": "pnpm --filter st dev",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"serverReadyAction": {
|
||||
"action": "debugWithChrome",
|
||||
"pattern": "- Local:\\s+(https?://.+)",
|
||||
"uriFormat": "%s",
|
||||
"webRoot": "${workspaceFolder}/apps/st"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
Vendored
+4
-1
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"chat.tools.terminal.autoApprove": {
|
||||
"pnpm": true
|
||||
}
|
||||
},
|
||||
"playwright.configs": [
|
||||
"apps/cs/playwright.config.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
|
||||
|
||||
const isSentryEnabled =
|
||||
process.env.NEXT_PUBLIC_SENTRY_ENABLED === "true" &&
|
||||
process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT !== "dev" &&
|
||||
process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT !== "prd";
|
||||
|
||||
const getSentryHost = (dsn?: string) => {
|
||||
if (!dsn) return null;
|
||||
try {
|
||||
const url = new URL(dsn);
|
||||
return url.host;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const sentryHost = getSentryHost(process.env.NEXT_PUBLIC_SENTRY_DSN);
|
||||
|
||||
Sentry.init({
|
||||
enabled: isSentryEnabled,
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT ?? "local",
|
||||
tracesSampleRate: 1.0,
|
||||
replaysSessionSampleRate: 1.0,
|
||||
replaysOnErrorSampleRate: 1.0,
|
||||
integrations: [
|
||||
Sentry.replayIntegration({
|
||||
networkDetailAllowUrls: sentryHost
|
||||
? [
|
||||
new RegExp(
|
||||
`^(?!.*${sentryHost.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}).*(?:/api/|/server/).*$`,
|
||||
),
|
||||
]
|
||||
: [/\/(?:api|server)\//],
|
||||
networkRequestHeaders: ["X-Custom-Header"],
|
||||
networkResponseHeaders: ["X-Custom-Header"],
|
||||
}),
|
||||
],
|
||||
debug: process.env.NEXT_PUBLIC_SENTRY_DEBUG === "true",
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
export async function register() {
|
||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||
await import("./sentry.server.config");
|
||||
}
|
||||
|
||||
if (process.env.NEXT_RUNTIME === "edge") {
|
||||
await import("./sentry.edge.config");
|
||||
}
|
||||
}
|
||||
|
||||
export const onRequestError = Sentry.captureRequestError;
|
||||
+25
-44
@@ -1,53 +1,34 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const subDomain = process.env.NEXT_PUBLIC_SUB_DOMAIN || "http://localhost:13000";
|
||||
|
||||
const webhwpCtrlBase = (
|
||||
process.env.NEXT_PUBLIC_WEBHYP_CTRL_URL || "http://hwp.onasys.co.kr/webhwpctrl"
|
||||
).replace(/\/$/, "");
|
||||
|
||||
const webhwpAssetPrefixes = ["js", "hwpsdk", "dist", "commonFrame", "wasm"] as const;
|
||||
import { withSentryConfig } from "@sentry/nextjs";
|
||||
import path from "path";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
reactStrictMode: true,
|
||||
transpilePackages: ["@common/assets"],
|
||||
experimental: {
|
||||
globalNotFound: true,
|
||||
},
|
||||
|
||||
async rewrites() {
|
||||
return {
|
||||
beforeFiles: [
|
||||
{
|
||||
source: "/_next/static/:path*",
|
||||
destination: `${subDomain}/_next/static/:path*`,
|
||||
has: [
|
||||
{
|
||||
type: "header",
|
||||
key: "referer",
|
||||
value: "(.*)/cms(.*)",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
afterFiles: [
|
||||
{
|
||||
source: "/cms/:path*",
|
||||
destination: `${subDomain}/cms/:path*`,
|
||||
},
|
||||
...webhwpAssetPrefixes.map((prefix) => ({
|
||||
source: `/webhwpctrl/${prefix}/:path*`,
|
||||
destination: `${webhwpCtrlBase}/${prefix}/:path*`,
|
||||
})),
|
||||
{
|
||||
source: "/webhwpctrl/file/:path*",
|
||||
destination: `${webhwpCtrlBase}/file/:path*`,
|
||||
},
|
||||
],
|
||||
fallback: [],
|
||||
};
|
||||
outputFileTracingRoot: path.join(__dirname, "../../"),
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
const isSentryEnabled =
|
||||
process.env.NEXT_PUBLIC_SENTRY_ENABLED === "true" &&
|
||||
process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT !== "dev" &&
|
||||
process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT !== "prd";
|
||||
|
||||
export default withSentryConfig(nextConfig, {
|
||||
org: process.env.SENTRY_ORG,
|
||||
project: process.env.SENTRY_PROJECT,
|
||||
silent: true,
|
||||
widenClientFileUpload: true,
|
||||
tunnelRoute: "/monitoring",
|
||||
...(!isSentryEnabled ? { disable: true } : {}),
|
||||
webpack: {
|
||||
treeshake: {
|
||||
removeDebugLogging: process.env.NEXT_PUBLIC_SENTRY_DEBUG === "true" ? false : true,
|
||||
},
|
||||
automaticVercelMonitors: false,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
+14
-3
@@ -3,11 +3,13 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3003",
|
||||
"dev": "next dev --turbo -p 3003",
|
||||
"build": "next build",
|
||||
"start": "next start -p 3003",
|
||||
"lint": "next lint",
|
||||
"lint:fsd": "steiger ."
|
||||
"lint:fsd": "steiger .",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@common/assets": "workspace:*",
|
||||
@@ -21,6 +23,13 @@
|
||||
"@frontend-opensource/use-react-hooks": "^1.1.4",
|
||||
"@hookform/resolvers": "^4.1.3",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@opentelemetry/api": "^1.9.1",
|
||||
"@opentelemetry/context-zone": "^2.8.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.219.0",
|
||||
"@opentelemetry/instrumentation-fetch": "^0.219.0",
|
||||
"@opentelemetry/instrumentation-xml-http-request": "^0.219.0",
|
||||
"@opentelemetry/sdk-trace-web": "^2.8.0",
|
||||
"@sentry/nextjs": "^10.57.0",
|
||||
"@tanstack/react-query": "^5.75.5",
|
||||
"@tanstack/react-query-devtools": "^5.75.5",
|
||||
"@tanstack/react-table": "^8.21.2",
|
||||
@@ -46,6 +55,8 @@
|
||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||
"steiger": "^0.5.11",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"typescript": "^5.7.2"
|
||||
"typescript": "^5.7.2",
|
||||
"vite-tsconfig-paths": "5.1.4",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
const isSentryEnabled =
|
||||
process.env.NEXT_PUBLIC_SENTRY_ENABLED === "true" &&
|
||||
process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT !== "dev" &&
|
||||
process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT !== "prd";
|
||||
|
||||
Sentry.init({
|
||||
enabled: isSentryEnabled,
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT ?? "local",
|
||||
tracesSampleRate: 1.0,
|
||||
debug: process.env.NEXT_PUBLIC_SENTRY_DEBUG === "true",
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
const isSentryEnabled =
|
||||
process.env.NEXT_PUBLIC_SENTRY_ENABLED === "true" &&
|
||||
process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT !== "dev" &&
|
||||
process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT !== "prd";
|
||||
|
||||
Sentry.init({
|
||||
enabled: isSentryEnabled,
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT ?? "local",
|
||||
tracesSampleRate: 1.0,
|
||||
debug: process.env.NEXT_PUBLIC_SENTRY_DEBUG === "true",
|
||||
});
|
||||
@@ -0,0 +1,891 @@
|
||||
"use client";
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any, simple-import-sort/imports */
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Button } from "@common/components";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
// InstlSncnApi imported below with types
|
||||
import { PROXY_HOST } from "@/shared/config";
|
||||
import { formatFileSize } from "@/shared/lib/file-utils";
|
||||
import axios from "axios";
|
||||
import { processElaprPrgrs } from "@/entities/elapr/api/elapr-prgrs-service";
|
||||
import { ElaprDmndApi } from "@/entities/elapr/api/elapr-dmnd-service";
|
||||
import { dayjs, useUserStore, useAlertStore } from "@/shared/lib";
|
||||
import { InstlSncnApi, type InstlSncnApplySaveRequestDto } from "@/entities/instl-sncn";
|
||||
import { client } from "@/shared/api";
|
||||
|
||||
import "./cnfm-page.css";
|
||||
|
||||
type ReviewStepCode = "LOCAL" | "ENV" | "KECO";
|
||||
const REVIEW_STEP_CODES: ReviewStepCode[] = ["LOCAL", "ENV", "KECO"];
|
||||
const ROLE_TO_REVIEW_STEP: Record<string, ReviewStepCode> = {
|
||||
ROLE_USER05: "LOCAL",
|
||||
ROLE_USER04: "LOCAL",
|
||||
ROLE_USER03: "ENV",
|
||||
ROLE_USER02: "KECO",
|
||||
ROLE_USER01: "KECO",
|
||||
};
|
||||
const REVIEW_STEP_ROLE_RANK: Record<ReviewStepCode, number> = {
|
||||
LOCAL: 1,
|
||||
ENV: 2,
|
||||
KECO: 3,
|
||||
};
|
||||
const INSTL_PROGRESS_ORDER = [
|
||||
"SV",
|
||||
"ST",
|
||||
"LE",
|
||||
"ER",
|
||||
"EE",
|
||||
"TE",
|
||||
"TS",
|
||||
"TR",
|
||||
"TP",
|
||||
"EP",
|
||||
"TA",
|
||||
"TB",
|
||||
];
|
||||
|
||||
const getProgressStepCount = (prgrsSttsComCd?: string): number => {
|
||||
const idx = INSTL_PROGRESS_ORDER.indexOf(prgrsSttsComCd ?? "ST");
|
||||
if (idx <= INSTL_PROGRESS_ORDER.indexOf("ST")) return 1;
|
||||
if (idx <= INSTL_PROGRESS_ORDER.indexOf("TP")) return 2;
|
||||
return 3;
|
||||
};
|
||||
|
||||
const getRoleStepCount = (dtlInstCd?: string): number => {
|
||||
const mapped = dtlInstCd ? ROLE_TO_REVIEW_STEP[dtlInstCd] : undefined;
|
||||
return mapped ? REVIEW_STEP_ROLE_RANK[mapped] : 0;
|
||||
};
|
||||
|
||||
// 업무구분 코드 -> 표시명 매핑 (체크리스트 그룹 표시용)
|
||||
const TASK_CODE_LABELS: Record<string, string> = {
|
||||
RE: "재이용수",
|
||||
SP: "하수관로",
|
||||
SF: "하수처리시설",
|
||||
};
|
||||
|
||||
const normalizeChecklistResultMap = (
|
||||
raw: unknown,
|
||||
): Record<ReviewStepCode, any[]> => {
|
||||
const base: Record<ReviewStepCode, any[]> = { LOCAL: [], ENV: [], KECO: [] };
|
||||
if (!raw) return base;
|
||||
|
||||
if (Array.isArray(raw)) {
|
||||
raw.forEach((row: any) => {
|
||||
const step = (row?.rvwStpComCd ?? "LOCAL") as ReviewStepCode;
|
||||
if (REVIEW_STEP_CODES.includes(step)) {
|
||||
base[step].push(row);
|
||||
}
|
||||
});
|
||||
return base;
|
||||
}
|
||||
|
||||
if (typeof raw === "object") {
|
||||
REVIEW_STEP_CODES.forEach((step) => {
|
||||
const rows = (raw as Record<string, unknown>)[step];
|
||||
base[step] = Array.isArray(rows) ? (rows as any[]) : [];
|
||||
});
|
||||
}
|
||||
|
||||
return base;
|
||||
};
|
||||
|
||||
function CnfmViewPage({ id }: { id: string }) {
|
||||
const router = useRouter();
|
||||
const userInfo = useUserStore((state) => state.userInfo);
|
||||
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["instl-sncn-detail", id],
|
||||
queryFn: () => InstlSncnApi.getById(id),
|
||||
});
|
||||
|
||||
const data = (query.data ?? {}) as any;
|
||||
|
||||
// support API shape where payload includes detDto + lists
|
||||
const det = data.detDto ?? data;
|
||||
const bizSclList = data.bizSclList ?? [];
|
||||
const bizCstList = data.bizCstList ?? [];
|
||||
// API now provides a flat checklist result list: `cklstResultList`
|
||||
const rawCklst = useMemo(() => data.cklstResultList ?? [], [data.cklstResultList]);
|
||||
|
||||
// ensure we have a flat array
|
||||
const cklstAll = useMemo(() => (Array.isArray(rawCklst) ? (rawCklst as any[]) : []), [rawCklst]);
|
||||
|
||||
// build a step-keyed map from the flat list so the rest of the view can use same access pattern
|
||||
const cklstResultMap = useMemo(() => {
|
||||
const base: Record<ReviewStepCode, any[]> = { LOCAL: [], ENV: [], KECO: [] };
|
||||
(cklstAll ?? []).forEach((row: any) => {
|
||||
const step = (row?.rvwStpComCd ?? "LOCAL") as ReviewStepCode;
|
||||
if (REVIEW_STEP_CODES.includes(step)) base[step].push(row);
|
||||
});
|
||||
return base;
|
||||
}, [cklstAll]);
|
||||
const esntlFileList = data.esntlFileList ?? [];
|
||||
const etcFileList = data.etcFileList ?? [];
|
||||
const elaprDocPrgrsList = (data.elaprDocPrgrsList ?? []) as any[];
|
||||
const renderApproverCell = (code: string) => {
|
||||
const row = elaprDocPrgrsList.find((r) => r.atrzSeComCd === code);
|
||||
if (!row) return <div className="text-center">-</div>;
|
||||
const status = row.atrzPrgrsSttsComCd ?? "";
|
||||
const statusMap: Record<string, { label: string; badgeClass: string; icon: string }> = {
|
||||
ATRZ_PRGRS_001: { label: "대기", badgeClass: "bg-gray-100 text-gray-700", icon: "⏳" },
|
||||
ATRZ_PRGRS_002: { label: "요청", badgeClass: "bg-yellow-100 text-yellow-800", icon: "📨" },
|
||||
ATRZ_PRGRS_003: { label: "승인", badgeClass: "bg-green-100 text-green-800", icon: "✔️" },
|
||||
ATRZ_PRGRS_004: { label: "반려", badgeClass: "bg-red-100 text-red-800", icon: "✖️" },
|
||||
};
|
||||
const st = statusMap[status] ?? { label: status || "", badgeClass: "bg-gray-100 text-gray-700", icon: "" };
|
||||
return (
|
||||
<div className="text-center">
|
||||
<div className="font-semibold">{row.aprvrUuidNm ?? "-"}</div>
|
||||
<div className="text-[0.95rem] text-gray-500">
|
||||
{row.ognzNm ? `${row.ognzNm}` : ""}
|
||||
{row.jbpsSeNm || row.jbgdSeNm ? ` · ${row.jbpsSeNm ?? ""}${row.jbgdSeNm ? ` ${row.jbgdSeNm}` : ""}` : ""}
|
||||
</div>
|
||||
<div className="text-[0.9rem] mt-1 flex items-center justify-center gap-2">
|
||||
{row.atrzDt ? <span className="text-gray-400">{dayjs(row.atrzDt).format("YYYY-MM-DD HH:mm:ss")}</span> : null}
|
||||
{st.label ? (
|
||||
<span
|
||||
title={`${row.atrzPrgrsSttsComNm ?? st.label}${row.rjctRsn ? ` — 사유: ${row.rjctRsn}` : ""}${row.atrzDt ? ` · ${dayjs(row.atrzDt).format("YYYY-MM-DD HH:mm:ss")}` : ""}`}
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded-md text-xs font-semibold ml-1 ${st.badgeClass}`}
|
||||
>
|
||||
<span className="mr-1">{st.icon}</span>
|
||||
<span>{st.label}</span>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const orderedApprovers = useMemo(() => {
|
||||
return (elaprDocPrgrsList ?? []).slice().sort((a: any, b: any) => {
|
||||
const A = Number(a?.atrzSeqNo ?? 0);
|
||||
const B = Number(b?.atrzSeqNo ?? 0);
|
||||
return A - B;
|
||||
});
|
||||
}, [elaprDocPrgrsList]);
|
||||
|
||||
const handleApproveClick = async (elaprPrgrsId: any, atrzSeComCd: any) => {
|
||||
if (!elaprPrgrsId) {
|
||||
alert("결재 정보가 올바르지 않습니다.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await processElaprPrgrs(String(elaprPrgrsId), "ATRZ_PRGRS_003"); //진행상태를 승인으로 업데이트
|
||||
alert("결재가 완료되었습니다.");
|
||||
try {
|
||||
await query.refetch();
|
||||
} catch (e) {
|
||||
/* ignore refetch errors */
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("결재 처리 실패", err);
|
||||
const msg = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : "결재 처리 중 오류가 발생했습니다.";
|
||||
alert(msg);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssignApproverClick = async (elaprTmpltId: string) => {
|
||||
try {
|
||||
const payload = {
|
||||
elaprDocNo: det.instlSncnDocNo,
|
||||
elaprTmpltId: elaprTmpltId, /* 팝업에서 선택된 전자결제 템플릿 */
|
||||
dmndTtl: det.bizNm, /* "구례 하수도 처리시설 2026", */
|
||||
dmndCn: det.bizCn /*"구례 발주 하수도 처리시설 사업 2026년도 진행분 결제 요청 입니다."*/
|
||||
};
|
||||
// ElaprDmndApi 서비스를 통해 백엔드 연동
|
||||
await ElaprDmndApi.create(payload);
|
||||
alert("결재자 지정(결재요청 등록)이 완료되었습니다.");
|
||||
try {
|
||||
await query.refetch();
|
||||
} catch (e) {
|
||||
/* ignore refetch errors */
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("결재자 지정 실패", err);
|
||||
const msg = axios.isAxiosError(err) && err.response?.data?.message ? err.response.data.message : "결재자 지정 중 오류가 발생했습니다.";
|
||||
alert(msg);
|
||||
}
|
||||
};
|
||||
|
||||
const renderApproverRow = (row: any) => {
|
||||
if (!row) return <div className="text-center">-</div>;
|
||||
const status = row.atrzPrgrsSttsComCd ?? "";
|
||||
const statusMap: Record<string, { label: string; badgeClass: string; icon: string }> = {
|
||||
ATRZ_PRGRS_001: { label: "대기", badgeClass: "bg-gray-100 text-gray-700", icon: "⏳" },
|
||||
ATRZ_PRGRS_002: { label: "요청", badgeClass: "bg-yellow-100 text-yellow-800", icon: "📨" },
|
||||
ATRZ_PRGRS_003: { label: "승인", badgeClass: "bg-green-100 text-green-800", icon: "✔️" },
|
||||
ATRZ_PRGRS_004: { label: "반려", badgeClass: "bg-red-100 text-red-800", icon: "✖️" },
|
||||
};
|
||||
const st = statusMap[status] ?? { label: status || "", badgeClass: "bg-gray-100 text-gray-700", icon: "" };
|
||||
return (
|
||||
<div className="text-center">
|
||||
<div className="font-semibold">{row.aprvrUuidNm ?? "-"}</div>
|
||||
<div className="text-[0.95rem] text-gray-500">
|
||||
{row.ognzNm ? `${row.ognzNm}` : ""}
|
||||
{row.jbpsSeNm || row.jbgdSeNm ? ` · ${row.jbpsSeNm ?? ""}${row.jbgdSeNm ? ` ${row.jbgdSeNm}` : ""}` : ""}
|
||||
</div>
|
||||
<div className="text-[0.9rem] mt-1 flex items-center justify-center gap-2">
|
||||
{row.atrzDt ? <span className="text-gray-400">{dayjs(row.atrzDt).format("YYYY-MM-DD HH:mm:ss")}</span> : null}
|
||||
{st.label ? (
|
||||
<>
|
||||
<span
|
||||
title={`${row.atrzPrgrsSttsComNm ?? st.label}${row.rjctRsn ? ` — 사유: ${row.rjctRsn}` : ""}${row.atrzDt ? ` · ${dayjs(row.atrzDt).format("YYYY-MM-DD HH:mm:ss")}` : ""}`}
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded-md text-xs font-semibold ml-1 ${st.badgeClass}`}
|
||||
>
|
||||
<span className="mr-1">{st.icon}</span>
|
||||
<span>{st.label}</span>
|
||||
</span>
|
||||
{status === "ATRZ_PRGRS_002" && userInfo?.userUnqId === row.aprvrUuid ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleApproveClick(row.elaprPrgrsId, row.atrzSeComCd)}
|
||||
className="ml-2 px-2 py-1 bg-blue-500 text-white text-xs rounded"
|
||||
>
|
||||
결제
|
||||
</button>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const currentPrgrsSttsComCd = (det.prgrsSttsComCd as string | undefined) ?? "ST";
|
||||
const roleBasedMaxStepCount = getRoleStepCount(userInfo?.dtlInstCd);
|
||||
const progressBasedMaxStepCount = getProgressStepCount(currentPrgrsSttsComCd);
|
||||
const visibleStepCount = Math.min(progressBasedMaxStepCount, roleBasedMaxStepCount);
|
||||
const visibleReviewStepCodes = useMemo(
|
||||
() => REVIEW_STEP_CODES.slice(0, visibleStepCount),
|
||||
[visibleStepCount],
|
||||
);
|
||||
const [selectedReviewStep, setSelectedReviewStep] = useState<ReviewStepCode>("LOCAL");
|
||||
// selected task code for header filtering (null = none selected)
|
||||
const [selectedTaskCode, setSelectedTaskCode] = useState<string | null>(null);
|
||||
|
||||
// derive available task codes from checklist results
|
||||
const taskCodes = useMemo(() => {
|
||||
return Array.from(new Set((cklstAll ?? []).map((r: any) => (r as any).taskSeComCd).filter(Boolean)));
|
||||
}, [cklstAll]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visibleReviewStepCodes.length === 0) return;
|
||||
if (!visibleReviewStepCodes.includes(selectedReviewStep)) {
|
||||
setSelectedReviewStep(visibleReviewStepCodes[visibleReviewStepCodes.length - 1]);
|
||||
}
|
||||
}, [selectedReviewStep, visibleReviewStepCodes]);
|
||||
|
||||
// when checklist result task codes change, default selectedTaskCode to first available code
|
||||
useEffect(() => {
|
||||
if (!taskCodes || taskCodes.length === 0) {
|
||||
if (selectedTaskCode !== null) setSelectedTaskCode(null);
|
||||
return;
|
||||
}
|
||||
if (!selectedTaskCode || !taskCodes.includes(selectedTaskCode)) {
|
||||
setSelectedTaskCode(taskCodes[0]);
|
||||
}
|
||||
}, [taskCodes, selectedTaskCode]);
|
||||
|
||||
const selectedChecklistRows = useMemo(() => {
|
||||
if (visibleReviewStepCodes.length === 0) return [];
|
||||
const rows = (cklstResultMap[selectedReviewStep] ?? []).filter((r: any) =>
|
||||
selectedTaskCode ? (r.taskSeComCd ?? "") === selectedTaskCode : true,
|
||||
);
|
||||
return rows
|
||||
.slice()
|
||||
.sort((a: any, b: any) =>
|
||||
(a.cklstArtclSn ?? 0) - (b.cklstArtclSn ?? 0) || (a.sortSeq ?? 0) - (b.sortSeq ?? 0) || (a.cklstRvwSn ?? 0) - (b.cklstRvwSn ?? 0),
|
||||
);
|
||||
}, [cklstResultMap, selectedReviewStep, visibleReviewStepCodes.length, selectedTaskCode]);
|
||||
|
||||
const [viewCheckResults, setViewCheckResults] = useState<Record<string, string>>({});
|
||||
const [viewCheckComments, setViewCheckComments] = useState<Record<string, string>>({});
|
||||
|
||||
const cklstStats = useMemo(() => {
|
||||
if (!selectedReviewStep) return { total: 0, completed: 0, percent: 0 };
|
||||
const rows = (cklstResultMap[selectedReviewStep] ?? []).filter((r: any) => r.bchckUseYn === "Y" && (selectedTaskCode ? (r.taskSeComCd ?? "") === selectedTaskCode : true));
|
||||
const total = rows.length;
|
||||
const completed = rows.reduce((acc: number, row: any) => {
|
||||
const key = `${row.taskSeComCd ?? ""}_${row.cklstSn}_${row.cklstArtclSn ?? ""}_${row.cklstRvwSn}_${selectedReviewStep}`;
|
||||
const val = (viewCheckResults[key] ?? row.rvwRsltAnmlyYn) ?? "";
|
||||
return acc + (val ? 1 : 0);
|
||||
}, 0);
|
||||
const percent = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||
return { total, completed, percent };
|
||||
}, [cklstResultMap, viewCheckResults, selectedReviewStep, selectedTaskCode]);
|
||||
|
||||
const fnValidation = (mode: "SAVE" | "SUBMIT") => {
|
||||
// 기본 검증: 필요하면 확장
|
||||
return true;
|
||||
};
|
||||
|
||||
const hadleTechReviewSubmit = async () => {
|
||||
if (!fnValidation("SAVE")) return;
|
||||
|
||||
const cklstResultList = (cklstAll ?? [])
|
||||
.filter((r: any) => r.bchckUseYn === "Y")
|
||||
.map((row: any) => {
|
||||
const key = `${row.taskSeComCd ?? ""}_${row.cklstSn}_${row.cklstArtclSn ?? ""}_${row.cklstRvwSn}_${selectedReviewStep}`;
|
||||
const rvwRsltAnmlyYn = (viewCheckResults[key] ?? row.rvwRsltAnmlyYn) ?? null;
|
||||
const picRvwCn = (viewCheckComments[key] ?? row.picRvwCn) ?? null;
|
||||
return {
|
||||
taskSeComCd: row.taskSeComCd ?? "",
|
||||
cklstSn: row.cklstSn,
|
||||
cklstArtclSn: row.cklstArtclSn,
|
||||
cklstRvwSn: row.cklstRvwSn,
|
||||
rvwStpComCd: selectedReviewStep,
|
||||
rvwRsltAnmlyYn,
|
||||
picRvwCn,
|
||||
};
|
||||
});
|
||||
|
||||
const payload: InstlSncnApplySaveRequestDto = {
|
||||
// only map core management DTO and checklist results; other lists empty per request
|
||||
instlSncnMng: { ...(det ?? {}), prgrsSttsComCd: "ER" } as any,
|
||||
bzScaleList: [],
|
||||
bizCstList: [],
|
||||
cklstResultList,
|
||||
esntlAtchFileList: [],
|
||||
} as unknown as InstlSncnApplySaveRequestDto;
|
||||
|
||||
try {
|
||||
const issuedDocNo = await InstlSncnApi.techReview(payload);
|
||||
alert(`기술검토 등록이 완료되었습니다. 문서번호: ${issuedDocNo}`);
|
||||
} catch (error) {
|
||||
console.error("기술검토 등록 API 호출 실패", error);
|
||||
const serverMessage =
|
||||
axios.isAxiosError(error) && error.response?.data?.message
|
||||
? (error.response.data as { message: string }).message
|
||||
: "저장 중 오류가 발생했습니다.";
|
||||
alert(serverMessage);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const results: Record<string, string> = {};
|
||||
const comments: Record<string, string> = {};
|
||||
(selectedChecklistRows ?? []).forEach((row: any) => {
|
||||
const key = `${row.taskSeComCd ?? ""}_${row.cklstSn}_${row.cklstArtclSn ?? ""}_${row.cklstRvwSn}_${row.rvwStpComCd ?? "LOCAL"}`;
|
||||
if (row.rvwRsltAnmlyYn === "Y" || row.rvwRsltAnmlyYn === "N") results[key] = row.rvwRsltAnmlyYn;
|
||||
if (row.picRvwCn) comments[key] = String(row.picRvwCn);
|
||||
});
|
||||
setViewCheckResults(results);
|
||||
setViewCheckComments(comments);
|
||||
}, [selectedChecklistRows]);
|
||||
|
||||
if (query.isLoading) {
|
||||
return <div className="mt-6 text-[1.4rem]">상세 정보를 불러오는 중입니다...</div>;
|
||||
}
|
||||
|
||||
if (!query.data) {
|
||||
return <div className="mt-6 text-[1.4rem]">상세 정보를 찾을 수 없습니다.</div>;
|
||||
}
|
||||
|
||||
const fmt = (v: number | null | undefined) =>
|
||||
typeof v === "number" ? v.toLocaleString() : v === null || v === undefined ? "" : String(v);
|
||||
|
||||
const indentPx = (indNm: string | undefined) => {
|
||||
if (!indNm) return 0;
|
||||
const spaces = indNm.length - indNm.trimStart().length;
|
||||
return (spaces / 4) * 16;
|
||||
};
|
||||
|
||||
/* formatFileSize moved to shared lib: @/shared/lib/file-utils */
|
||||
|
||||
return (
|
||||
<div className="mt-6 space-y-4">
|
||||
{/* orderedApprovers.length > 0 && ( */}
|
||||
<div className="cnfm-card p-4">
|
||||
<h3 className="text-[1.5rem] font-semibold">결재 정보</h3>
|
||||
<div className="cnfm-table-wrap mt-3">
|
||||
<table className="cnfm-table w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
{orderedApprovers.length === 0 ? (
|
||||
<>
|
||||
<th className="text-center">접수자</th>
|
||||
<th className="text-center">검토자</th>
|
||||
<th className="text-center">승인자</th>
|
||||
<th className="text-center">최종승인자</th>
|
||||
</>
|
||||
) : (
|
||||
orderedApprovers.map((r: any) => (
|
||||
<th key={String(r.elaprPrgrsId ?? r.atrzSeComCd)} className="text-center">
|
||||
{r.atrzSeComNm ?? r.atrzSeComCd}
|
||||
</th>
|
||||
))
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
{orderedApprovers.length === 0 ? (
|
||||
<>
|
||||
<td>{renderApproverCell("ATRZ01")}</td>
|
||||
<td>{renderApproverCell("ATRZ02")}</td>
|
||||
<td>{renderApproverCell("ATRZ03")}</td>
|
||||
<td>{renderApproverCell("ATRZ99")}</td>
|
||||
</>
|
||||
) : (
|
||||
orderedApprovers.map((r: any) => (
|
||||
<td key={String(r.elaprPrgrsId ?? r.atrzSeComCd)}>{renderApproverRow(r)}</td>
|
||||
))
|
||||
)}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{/** 결제자 지정 버튼 */}
|
||||
<div className="mt-4 text-right">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => handleAssignApproverClick("TMPLT20260611000001")}
|
||||
>
|
||||
결재자 지정
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* )} */}
|
||||
<div className="cnfm-card p-4">
|
||||
<h2 className="cnfm-section-title">설치인가 상세보기</h2>
|
||||
<p className="mt-2 text-[1.3rem] text-gray-600">문서번호: <strong>{det.instlSncnDocNo}</strong></p>
|
||||
</div>
|
||||
|
||||
<div className="cnfm-card p-4">
|
||||
<h3 className="text-[1.5rem] font-semibold">기본 정보</h3>
|
||||
<table className="cnfm-form-table mt-3">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>사업명</th>
|
||||
<td>{det.bizNm || "-"}</td>
|
||||
<th>사업기간</th>
|
||||
<td>{(det.bizBgngYr || "-") + " ~ " + (det.bizEndYr || "-")}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>사업내용</th>
|
||||
<td colSpan={3}>{det.bizCn || "-"}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>구분</th>
|
||||
<td>{det.taskSeCdNm || det.taskSeComCd || "-"}</td>
|
||||
<th>신청지자체</th>
|
||||
<td>{det.sido || "-"} {det.gugun || "-"}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>사업구분</th>
|
||||
<td colSpan={3}>{det.sncnSeCdNm || "-"}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>진행상태</th>
|
||||
<td>{det.prgrsSttsCdNm || det.prgrsSttsComCd || "-"}</td>
|
||||
<th>예산사업코드</th>
|
||||
<td>{det.bgtBizCd || "-"}</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="cnfm-card p-4">
|
||||
<h3 className="text-[1.5rem] font-semibold">담당자 정보</h3>
|
||||
<table className="cnfm-form-table mt-3">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>신청자명</th>
|
||||
<td>{det.aplcntNm || "-"}</td>
|
||||
<th>신청자 연락처</th>
|
||||
<td>{det.aplcntMblTelno || "-"}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>신청자 소속</th>
|
||||
<td>{det.aplcntOgdpNm || "-"}</td>
|
||||
<th>신청자 이메일</th>
|
||||
<td>{det.aplcntEmlAddr || "-"}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>설계자명</th>
|
||||
<td>{det.dsgnrNm || "-"}</td>
|
||||
<th>설계자 연락처</th>
|
||||
<td>{det.dsgnrTelno || "-"}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>설계자 소속</th>
|
||||
<td>{det.dsgnrOgdpNm || "-"}</td>
|
||||
<th>설계자 이메일</th>
|
||||
<td>{det.dsgnrEmlAddr || "-"}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="cnfm-card p-4">
|
||||
<h3 className="text-[1.5rem] font-semibold">이력 정보</h3>
|
||||
<table className="cnfm-form-table mt-3">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>등록일시</th>
|
||||
<td>{det.regDt ? dayjs(det.regDt).format("YYYY-MM-DD HH:mm:ss") : "-"}</td>
|
||||
<th>수정일시</th>
|
||||
<td>{det.mdfcnDt ? dayjs(det.mdfcnDt).format("YYYY-MM-DD HH:mm:ss") : "-"}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>등록자명</th>
|
||||
<td>{det.rgtrNm || "-"}</td>
|
||||
<th>첨부파일그룹ID</th>
|
||||
<td>{det.atchFileGroupId || "-"}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 사업규모 */}
|
||||
<div className="cnfm-card p-4">
|
||||
<h3 className="mb-3 border-b pb-2 text-[1.5rem] font-bold text-gray-700">사업규모</h3>
|
||||
<div className="cnfm-table-wrap">
|
||||
<table className="cnfm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="w-[30rem]">구분</th>
|
||||
<th>기본계획(A)</th>
|
||||
<th>예산반영(B)</th>
|
||||
<th>금회설계시(C)</th>
|
||||
<th>증감(C-B)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{bizSclList.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="cnfm-td-empty">조회된 사업규모 항목이 없습니다.</td>
|
||||
</tr>
|
||||
) : (
|
||||
bizSclList.map((node: any) => {
|
||||
const pl = indentPx(node.indCnstpSeCdNm);
|
||||
const bscPlanQty = Number(node.bscPlanQty) || 0;
|
||||
const budgetReflct = Number(node.bgtRfltQty) || 0;
|
||||
const tstmDesignQty = Number(node.tstmDesignQty) || 0;
|
||||
const diff = tstmDesignQty - budgetReflct;
|
||||
return (
|
||||
<tr key={node.cnstpSeCd} className={node.treeLevel === 1 ? "bg-gray-50 font-semibold" : ""}>
|
||||
<td className="bg-gray-50 pl-2 text-left" style={{ paddingLeft: `${pl + 8}px` }}>{node.cnstpSeCdNm}</td>
|
||||
<td><div className="text-right">{fmt(bscPlanQty)}</div></td>
|
||||
<td><div className="text-right">{fmt(budgetReflct)}</div></td>
|
||||
<td><div className="text-right">{fmt(tstmDesignQty)}</div></td>
|
||||
<td><div className="text-right">{diff > 0 ? `+${fmt(diff)}` : fmt(diff)}</div></td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 총사업비 */}
|
||||
<div className="cnfm-card p-4">
|
||||
<h3 className="mb-3 border-b pb-2 text-[1.5rem] font-bold text-gray-700">총사업비 <span className="text-[1.2rem] font-normal text-gray-400">(단위: 백만원)</span></h3>
|
||||
<div className="cnfm-table-wrap">
|
||||
<table className="cnfm-table w-full table-fixed">
|
||||
<thead>
|
||||
<tr>
|
||||
<th colSpan={2} className="w-[30rem]" rowSpan={2}>
|
||||
구분
|
||||
</th>
|
||||
<th rowSpan={2}>예산반영(A)</th>
|
||||
<th rowSpan={2}>표준사업비(B)</th>
|
||||
<th rowSpan={2}>금회설계시(C)</th>
|
||||
<th colSpan={2}>증감</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="w-[12rem]">(C-B)</th>
|
||||
<th className="w-[6rem]">%</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{bizCstList.map((node: any) => {
|
||||
|
||||
const pl = indentPx(node.indAcntgSeCdNm);
|
||||
const bscPlanAmt = Number(node.bscPlanAmt) || 0;
|
||||
const budgetReflct = Number(node.bgtRfltAmt) || 0;
|
||||
const tstmDesignAmt = Number(node.tstmDesignAmt) || 0;
|
||||
const diff = tstmDesignAmt - budgetReflct;
|
||||
const showSubIcon = node.treeLevel >= 4;
|
||||
const isLeaf = !bizCstList.some((n: any) => n.upAcntgSeCd === node.acntgSeCd);
|
||||
return (
|
||||
<tr
|
||||
key={node.acntgSeCd}
|
||||
className={
|
||||
node.treeLevel === 1
|
||||
? "bg-blue-50 font-bold"
|
||||
: node.treeLevel === 2
|
||||
? "bg-gray-100 font-semibold"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{node.treeLevel <= 2 ? (
|
||||
<td colSpan={2} className="pl-2 text-left">{node.acntgSeCdNm}</td>
|
||||
) : (
|
||||
<>
|
||||
<td className="w-[4rem]"></td>
|
||||
<td
|
||||
className={`bg-gray-50 text-left ${isLeaf ? "text-gray-700" : ""}`}
|
||||
style={{ paddingLeft: `${pl + 8}px` }}
|
||||
>
|
||||
{showSubIcon && <span className="mr-1 text-gray-400">└</span>}
|
||||
{node.acntgSeCdNm}
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
|
||||
<td>
|
||||
<div className="text-right">{fmt(bscPlanAmt)}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="text-right">{fmt(budgetReflct)}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="text-right">{fmt(tstmDesignAmt)}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="text-right">{diff > 0 ? `+${fmt(diff)}` : fmt(diff)}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="text-right">{budgetReflct !== 0 ? `${Math.round((diff / budgetReflct) * 100)}%` : "-"}</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 체크리스트 */}
|
||||
<div className="cnfm-card p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-[1.5rem] font-bold text-gray-700">체크리스트</h3>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex gap-2 items-center">
|
||||
{Array.from(new Set(taskCodes)).map((code) => {
|
||||
const name = TASK_CODE_LABELS[code] ?? code;
|
||||
const active = selectedTaskCode === code;
|
||||
return (
|
||||
<button
|
||||
key={code}
|
||||
type="button"
|
||||
className={`rounded border px-3 py-1 text-[1.2rem] font-semibold ${active ? "border-blue-500 bg-blue-50 text-blue-700" : "border-gray-300 bg-white text-gray-600"
|
||||
}`}
|
||||
onClick={() => setSelectedTaskCode(code)}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="text-[1.1rem] text-gray-500">{`(${cklstStats.completed}/${cklstStats.total})`}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-3 flex flex-wrap gap-2">
|
||||
{visibleReviewStepCodes.length === 0 ? (
|
||||
<span className="text-[1.2rem] text-gray-500">
|
||||
현재 사용자 권한/진행상태 조합에서 표시 가능한 체크리스트 단계가 없습니다.
|
||||
</span>
|
||||
) : (
|
||||
visibleReviewStepCodes.map((stepCode) => (
|
||||
<button
|
||||
key={stepCode}
|
||||
type="button"
|
||||
className={`rounded border px-3 py-1 text-[1.2rem] font-semibold ${selectedReviewStep === stepCode
|
||||
? "border-blue-500 bg-blue-50 text-blue-700"
|
||||
: "border-gray-300 bg-white text-gray-600"
|
||||
}`}
|
||||
onClick={() => setSelectedReviewStep(stepCode)}
|
||||
>
|
||||
{stepCode}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="mb-2 text-[1.25rem] text-gray-500">
|
||||
체크리스트 진행상황 [{selectedReviewStep}] — 총합: <strong>{cklstStats.total}</strong> · 완료: <strong>{cklstStats.completed}</strong> · 진행률: <strong>{cklstStats.percent}%</strong>
|
||||
</div>
|
||||
<div className="cnfm-table-wrap mt-3">
|
||||
<table className="cnfm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="w-[20rem]" rowSpan={2}>검토항목</th>
|
||||
<th rowSpan={2}>주요 검토내용</th>
|
||||
<th colSpan={2}>검토 결과(✓)</th>
|
||||
{selectedReviewStep !== "LOCAL" ? (
|
||||
<th rowSpan={2} className="w-[20rem]">기술검토 의견</th>
|
||||
) : null}
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="w-[9rem]">
|
||||
<button
|
||||
type="button"
|
||||
>
|
||||
예
|
||||
</button>
|
||||
</th>
|
||||
<th className="w-[9rem]">
|
||||
<button
|
||||
type="button"
|
||||
>
|
||||
아니오
|
||||
</button>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{selectedChecklistRows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="cnfm-td-empty">조회된 체크리스트 항목이 없습니다.</td>
|
||||
</tr>
|
||||
) : (
|
||||
selectedChecklistRows.map((row: any) => {
|
||||
const key = `${row.taskSeComCd ?? ""}_${row.cklstSn}_${row.cklstArtclSn ?? ""}_${row.cklstRvwSn}_${row.rvwStpComCd ?? "LOCAL"}`;
|
||||
const hasResult = row.rvwRsltAnmlyYn === "Y" || row.rvwRsltAnmlyYn === "N";
|
||||
const showRadios = row.bchckUseYn === "Y";
|
||||
|
||||
return (
|
||||
<tr key={key}>
|
||||
<td className="text-center text-[1.2rem]">{row.cklstArtclNm}</td>
|
||||
<td className="!text-left pl-2 text-[1.2rem] text-gray-600" style={{ paddingLeft: `${Math.max(0, row.treeLevel - 1) * 16 + 8}px` }}>{row.treeLevel > 1 ? "└ " : ""}{row.rvwCn}</td>
|
||||
{!showRadios ? (
|
||||
<>
|
||||
<td className="text-center">-</td>
|
||||
<td className="text-center">-</td>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<td className="text-center">
|
||||
<input
|
||||
type="radio"
|
||||
name={`chk_${key}`}
|
||||
value="Y"
|
||||
checked={(viewCheckResults[key] ?? "") === "Y"}
|
||||
disabled={selectedReviewStep === "LOCAL"}
|
||||
onChange={() => setViewCheckResults((prev) => ({ ...prev, [key]: "Y" }))}
|
||||
/>
|
||||
</td>
|
||||
<td className="text-center">
|
||||
<input
|
||||
type="radio"
|
||||
name={`chk_${key}`}
|
||||
value="N"
|
||||
checked={(viewCheckResults[key] ?? "") === "N"}
|
||||
disabled={selectedReviewStep === "LOCAL"}
|
||||
onChange={() => setViewCheckResults((prev) => ({ ...prev, [key]: "N" }))}
|
||||
/>
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
{showRadios && selectedReviewStep !== "LOCAL" ? (
|
||||
<td className="text-left">
|
||||
<textarea
|
||||
className="cnfm-input w-full"
|
||||
value={viewCheckComments[key] ?? ""}
|
||||
onChange={(e) => setViewCheckComments((prev) => ({ ...prev, [key]: e.target.value }))}
|
||||
/>
|
||||
</td>
|
||||
) : null}
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 필수 첨부파일 */}
|
||||
<div className="cnfm-card p-4">
|
||||
<h3 className="mb-3 border-b pb-2 text-[1.5rem] font-bold text-gray-700">필수 첨부파일</h3>
|
||||
<div className="cnfm-table-wrap">
|
||||
<table className="cnfm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>필수 자료명</th>
|
||||
<th>파일명</th>
|
||||
<th className="w-[8rem] text-right">크기</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{esntlFileList.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={3} className="cnfm-td-empty">조회된 필수 첨부파일 항목이 없습니다.</td>
|
||||
</tr>
|
||||
) : (
|
||||
esntlFileList.map((f: any) => (
|
||||
<tr key={f.esntlAtchFileId}>
|
||||
<td className="pl-2">
|
||||
<div className="w-full text-left">
|
||||
{f.esntlAtchFileNm}
|
||||
{f.esntlAtchYn === "Y" ? <span className="ml-1 text-red-500">*</span> : null}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{f.atflCd ? (
|
||||
<a className="text-blue-600" href={`${PROXY_HOST}/attach-service/api/v1/atflcd/download/${f.atflCd}`} target="_blank" rel="noreferrer">{f.orgnlFileNm || "다운로드"}</a>
|
||||
) : (
|
||||
<span className="text-gray-500">첨부없음</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="w-[8rem] text-right"><div className="text-right">{formatFileSize(f.atflFlieSz)}</div></td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 기타 첨부파일 */}
|
||||
<div className="cnfm-card p-4">
|
||||
<h3 className="mb-3 border-b pb-2 text-[1.5rem] font-bold text-gray-700">기타 첨부파일</h3>
|
||||
<div className="cnfm-table-wrap">
|
||||
<table className="cnfm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>파일명</th>
|
||||
<th className="w-[8rem] text-right">크기</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{etcFileList.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={2} className="cnfm-td-empty">등록된 기타첨부파일이 없습니다.</td>
|
||||
</tr>
|
||||
) : (
|
||||
etcFileList.map((f: any) => (
|
||||
<tr key={f.atflId}>
|
||||
<td>
|
||||
{f.orgnlFileNm ? (
|
||||
<a className="text-blue-600" href={`${PROXY_HOST}/attach-service/api/v1/download/${f.atflId}`} target="_blank" rel="noreferrer">{f.orgnlFileNm}</a>
|
||||
) : (
|
||||
<span className="text-gray-700">{f.physFileNm || f.atflId}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="w-[8rem] text-right text-gray-500">{formatFileSize(f.atflFlieSz)}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="cnfm-action-row">
|
||||
<Button color="white" onClick={() => router.push("/instl-sncn")}>목록</Button>
|
||||
<Button onClick={() => router.push(`/instl-sncn/${id}/modify`)}>수정</Button>
|
||||
<Button onClick={() => void hadleTechReviewSubmit()}>기술검토등록</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { CnfmViewPage };
|
||||
@@ -40,3 +40,5 @@ next-env.d.ts
|
||||
.env*
|
||||
!.env.example
|
||||
/start_cs.log
|
||||
/playwright-report/
|
||||
/test-results/
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { useSessionExtendConfirm } from "../../../../../../packages/common/components/src/lib/use-session-extend-confirm";
|
||||
import { useState, useRef } from "react";
|
||||
|
||||
export default function TestSessionPage() {
|
||||
const [refreshCalled, setRefreshCalled] = useState(false);
|
||||
const [declineCalled, setDeclineCalled] = useState(false);
|
||||
const [timeoutCalled, setTimeoutCalled] = useState(false);
|
||||
|
||||
// 클로저 트랩 회피를 위한 Ref 선언
|
||||
const refreshCalledRef = useRef(false);
|
||||
const declineCalledRef = useRef(false);
|
||||
const timeoutCalledRef = useRef(false);
|
||||
|
||||
useSessionExtendConfirm({
|
||||
enabled: true,
|
||||
getAccessToken: () => {
|
||||
// Ref 참조를 통해 최신의 갱신 상태를 즉각 반영하여 클로저 락 회피
|
||||
if (refreshCalledRef.current || declineCalledRef.current || timeoutCalledRef.current) {
|
||||
// 리프레시/거절/만료 완료 후에는 만료 시간이 아주 충분히 남은 가상 토큰 반환 (1000초 남음)
|
||||
const payload = btoa(JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 1000 }));
|
||||
return `header.${payload}.signature`;
|
||||
}
|
||||
// 최초 실행 시에는 5분 버퍼(300초) 진입까지 정확히 2초가 남은 가상 토큰 반환 (302초 남음)
|
||||
const payload = btoa(JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 302 }));
|
||||
return `header.${payload}.signature`;
|
||||
},
|
||||
silentRefresh: async () => {
|
||||
refreshCalledRef.current = true;
|
||||
setRefreshCalled(true);
|
||||
},
|
||||
declineSessionExtend: () => {
|
||||
declineCalledRef.current = true;
|
||||
setDeclineCalled(true);
|
||||
},
|
||||
onTimeout: () => {
|
||||
timeoutCalledRef.current = true;
|
||||
setTimeoutCalled(true);
|
||||
},
|
||||
message: "로그인을 연장하시겠습니까?",
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ padding: "50px", textAlign: "center" }}>
|
||||
<h1>Session Extend Confirm Test Page</h1>
|
||||
<p>이 페이지는 useSessionExtendConfirm 훅 E2E 테스팅을 위한 격리 라우트입니다.</p>
|
||||
<div style={{ marginTop: "20px" }}>
|
||||
{refreshCalled && <p data-testid="refresh-status">Refresh Successful</p>}
|
||||
{declineCalled && <p data-testid="decline-status">Decline Successful</p>}
|
||||
{timeoutCalled && <p data-testid="timeout-status">Timeout Successful</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,3 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import {
|
||||
ACCESS_TOKEN,
|
||||
AUTH_USER_ID,
|
||||
@@ -10,6 +7,8 @@ import {
|
||||
SERVER_API_URL,
|
||||
SITE_ID,
|
||||
} from "@/shared/config";
|
||||
import { cookies } from "next/headers";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
async function handler(
|
||||
request: Request,
|
||||
@@ -21,10 +20,16 @@ async function handler(
|
||||
"Content-Type": "application/json",
|
||||
"X-Site-Id": SITE_ID,
|
||||
};
|
||||
const deviceId = request.headers.get("X-Device-Id");
|
||||
|
||||
// X-Device-Id 중계 로직 (헤더 또는 쿠키에서 획득)
|
||||
const deviceId =
|
||||
request.headers.get("x-device-id") ||
|
||||
request.headers.get("X-Device-Id") ||
|
||||
cookieStore.get("device-id")?.value;
|
||||
if (deviceId) {
|
||||
headers["X-Device-Id"] = deviceId;
|
||||
}
|
||||
|
||||
let body = {};
|
||||
const isRefreshHandler = params.url.includes("refresh");
|
||||
|
||||
@@ -50,6 +55,33 @@ async function handler(
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
console.error(`[BFF Proxy Error] Backend returned status ${result.status} for ${params.url.join("/")}`);
|
||||
let errorText = "";
|
||||
try {
|
||||
errorText = await result.text();
|
||||
console.error(`[BFF Proxy Error Body] ${errorText}`);
|
||||
} catch (err) {}
|
||||
|
||||
if (result.status === 503) {
|
||||
const serviceName = params.url[0] || "user-service";
|
||||
return NextResponse.json(
|
||||
{ message: `${serviceName} 서비스와의 연결이 불안정 합니다. 관리자에게 연락해 주십시오.` },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
if (result.status !== 500 && errorText) {
|
||||
try {
|
||||
const errorJson = JSON.parse(errorText);
|
||||
if (errorJson && errorJson.message) {
|
||||
return NextResponse.json(
|
||||
{ message: errorJson.message },
|
||||
{ status: result.status },
|
||||
);
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ message: "Invalid Credentials" },
|
||||
{ status: 401 },
|
||||
@@ -70,7 +102,7 @@ async function handler(
|
||||
response.cookies.set(REFRESH_TOKEN, refreshToken, {
|
||||
httpOnly: true,
|
||||
sameSite: "strict",
|
||||
domain: COOKIE_DOMAIN,
|
||||
domain: getCookieDomain(),
|
||||
path: "/",
|
||||
});
|
||||
}
|
||||
@@ -105,3 +137,14 @@ export async function PUT(
|
||||
const params = await props.params;
|
||||
return handler(request, params, "PUT");
|
||||
}
|
||||
|
||||
function getCookieDomain(): string | undefined {
|
||||
if (typeof window === "undefined") return undefined;
|
||||
|
||||
if (COOKIE_DOMAIN) return COOKIE_DOMAIN;
|
||||
|
||||
const { hostname } = window.location;
|
||||
if (hostname === "localhost" || hostname === "127.0.0.1") return undefined;
|
||||
|
||||
return hostname;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
|
||||
|
||||
const isSentryEnabled =
|
||||
process.env.NEXT_PUBLIC_SENTRY_ENABLED === "true" &&
|
||||
process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT !== "dev" &&
|
||||
process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT !== "prd";
|
||||
|
||||
Sentry.init({
|
||||
enabled: isSentryEnabled,
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT ?? "local",
|
||||
tracesSampleRate: 1.0,
|
||||
replaysSessionSampleRate: 1.0,
|
||||
replaysOnErrorSampleRate: 1.0,
|
||||
integrations: [Sentry.replayIntegration()],
|
||||
debug: process.env.NEXT_PUBLIC_SENTRY_DEBUG === "true",
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
export async function register() {
|
||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||
await import("./sentry.server.config");
|
||||
}
|
||||
|
||||
if (process.env.NEXT_RUNTIME === "edge") {
|
||||
await import("./sentry.edge.config");
|
||||
}
|
||||
}
|
||||
|
||||
export const onRequestError = Sentry.captureRequestError;
|
||||
+20
-1
@@ -1,4 +1,5 @@
|
||||
import type { NextConfig } from "next";
|
||||
import { withSentryConfig } from "@sentry/nextjs";
|
||||
|
||||
const subDomain =
|
||||
process.env.NEXT_PUBLIC_SUB_DOMAIN || "http://localhost:13000";
|
||||
@@ -59,4 +60,22 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
const isSentryEnabled =
|
||||
process.env.NEXT_PUBLIC_SENTRY_ENABLED === "true" &&
|
||||
process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT !== "dev" &&
|
||||
process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT !== "prd";
|
||||
|
||||
export default withSentryConfig(nextConfig, {
|
||||
org: process.env.SENTRY_ORG,
|
||||
project: process.env.SENTRY_PROJECT,
|
||||
silent: true,
|
||||
widenClientFileUpload: true,
|
||||
tunnelRoute: "/monitoring",
|
||||
disable: !isSentryEnabled,
|
||||
webpack: {
|
||||
treeshake: {
|
||||
removeDebugLogging: process.env.NEXT_PUBLIC_SENTRY_DEBUG === "true" ? false : true,
|
||||
},
|
||||
automaticVercelMonitors: false,
|
||||
},
|
||||
});
|
||||
|
||||
+15
-3
@@ -3,10 +3,14 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"dev": "next dev --turbo -p 3000",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
"lint": "next lint",
|
||||
"test:playwright": "playwright test",
|
||||
"test:unit": "vitest run",
|
||||
"test:unit:watch": "vitest",
|
||||
"test:playwright:ui": "playwright test --ui"
|
||||
},
|
||||
"dependencies": {
|
||||
"@common/assets": "workspace:*",
|
||||
@@ -27,6 +31,7 @@
|
||||
"@radix-ui/react-label": "^2.1.2",
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"@radix-ui/react-tooltip": "^1.1.8",
|
||||
"@sentry/nextjs": "^10.57.0",
|
||||
"@tanstack/react-query": "^5.75.5",
|
||||
"@tanstack/react-query-devtools": "^5.75.5",
|
||||
"@tanstack/react-table": "^8.21.2",
|
||||
@@ -50,17 +55,24 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.0.0",
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/node": "^22.13.1",
|
||||
"@types/webpack": "^5.28.5",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-config-next": "15.3.8",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"postcss": "^8.4.49",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||
"steiger": "^0.5.5",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"typescript": "^5.7.2"
|
||||
"typescript": "^5.7.2",
|
||||
"vite-tsconfig-paths": "5.1.4",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Playwright 설정 파일
|
||||
* https://playwright.dev/docs/test-configuration
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
/* Vitest 단위 테스트 파일들을 Playwright 테스트 대상에서 제외 */
|
||||
testIgnore: "**/unit/**",
|
||||
/* 하나의 테스트 파일 내에서 병렬 실행 금지 및 직렬 실행으로 세션 상태 격리 보장 */
|
||||
fullyParallel: false,
|
||||
/* Redis 및 PostgreSQL 공유 자원의 세션 정렬을 위해 워커를 1개로 제한 */
|
||||
workers: 1,
|
||||
/* 테스트 실패 시 재시도 횟수 */
|
||||
retries: 0,
|
||||
/* 각 테스트 타임아웃 (30초) */
|
||||
timeout: 30000,
|
||||
/* 전체 테스트 실행 타임아웃 */
|
||||
globalTimeout: 600000,
|
||||
/* Assertion 타임아웃 */
|
||||
expect: {
|
||||
timeout: 5000,
|
||||
},
|
||||
/* 리포터 설정 (HTML 리포트 및 터미널 요약) */
|
||||
reporter: [["html", { open: "never" }], ["list"]],
|
||||
/* 공유 설정 */
|
||||
use: {
|
||||
/* 테스트 타겟 URL */
|
||||
baseURL: process.env.PLAYWRIGHT_BASE_URL || "http://localhost:3000",
|
||||
/* 실패한 테스트에 대해서만 트레이스 수집 */
|
||||
trace: "retain-on-failure",
|
||||
/* 스크린샷 캡처 설정 */
|
||||
screenshot: "only-on-failure",
|
||||
},
|
||||
|
||||
/* 프로젝트별 브라우저 구성 (기본적으로 Chromium 위주로 동작 확인) */
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
{
|
||||
name: "firefox",
|
||||
use: { ...devices["Desktop Firefox"] },
|
||||
},
|
||||
{
|
||||
name: "webkit",
|
||||
use: { ...devices["Desktop Safari"] },
|
||||
},
|
||||
],
|
||||
|
||||
/* 로컬 서버 자동 기동 설정 */
|
||||
webServer: {
|
||||
command: "pnpm dev",
|
||||
url: "http://localhost:3000",
|
||||
reuseExistingServer: true,
|
||||
timeout: 120000,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
const isSentryEnabled =
|
||||
process.env.NEXT_PUBLIC_SENTRY_ENABLED === "true" &&
|
||||
process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT !== "dev" &&
|
||||
process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT !== "prd";
|
||||
|
||||
Sentry.init({
|
||||
enabled: isSentryEnabled,
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT ?? "local",
|
||||
tracesSampleRate: 1.0,
|
||||
debug: process.env.NEXT_PUBLIC_SENTRY_DEBUG === "true",
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
const isSentryEnabled =
|
||||
process.env.NEXT_PUBLIC_SENTRY_ENABLED === "true" &&
|
||||
process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT !== "dev" &&
|
||||
process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT !== "prd";
|
||||
|
||||
Sentry.init({
|
||||
enabled: isSentryEnabled,
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
environment: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT ?? "local",
|
||||
tracesSampleRate: 1.0,
|
||||
debug: process.env.NEXT_PUBLIC_SENTRY_DEBUG === "true",
|
||||
});
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { MenuAuthorityApi } from "@/entities/menu-authority/@x/menu";
|
||||
import { getCookie, useAlertStore, useMenuStore } from "@/shared/lib";
|
||||
import { SITE_ID } from "@/shared/config";
|
||||
import { SITE_ID, ENDPOINT } from "@/shared/config";
|
||||
import { MenuApi } from "../api/menu-service";
|
||||
import { getCurrentMenu } from "./utils";
|
||||
|
||||
@@ -16,11 +17,10 @@ const MenuWrapper = ({ children }: { children: React.ReactNode }) => {
|
||||
const { pageMenuAuthority, setCurrentMenuInfo, setPageMenuAuthority } =
|
||||
useMenuStore((state) => state);
|
||||
|
||||
const getCurrentMenuInfo = async () => {
|
||||
const menuRoles = await MenuApi.getMenuRoles(SITE_ID);
|
||||
if (!menuRoles) return;
|
||||
return getCurrentMenu(menuRoles, pathname ?? "");
|
||||
};
|
||||
const { data: menuRoles } = useQuery({
|
||||
queryKey: [ENDPOINT.CMS_SERVICE.MENU_ROLES],
|
||||
queryFn: () => MenuApi.getMenuRoles(SITE_ID),
|
||||
});
|
||||
|
||||
const getPageMenuAuthority = async () => {
|
||||
const menuCd = getCookie("menu-code-key");
|
||||
@@ -36,10 +36,14 @@ const MenuWrapper = ({ children }: { children: React.ReactNode }) => {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const currentMenu = await getCurrentMenuInfo();
|
||||
if (menuRoles) {
|
||||
const currentMenu = getCurrentMenu(menuRoles, pathname ?? "");
|
||||
if (currentMenu) setCurrentMenuInfo(currentMenu);
|
||||
}
|
||||
}, [menuRoles, pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const response = await getPageMenuAuthority();
|
||||
if (response?.length) setPageMenuAuthority(response[0]!);
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("FAQ 페이지 E2E 테스트", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// 임시 URL. 실제 라우팅 경로에 맞게 수정 필요
|
||||
await page.goto("/post/faq");
|
||||
});
|
||||
|
||||
test("1. FAQ 목록 화면이 정상적으로 렌더링되는지 확인", async ({ page }) => {
|
||||
// TODO: 실제 렌더링되는 요소에 맞게 selector 및 텍스트 수정 필요
|
||||
await expect(page.locator("body")).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("Image 게시판 페이지 E2E 테스트", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// 임시 URL. 실제 라우팅 경로에 맞게 수정 필요
|
||||
await page.goto("/post/image");
|
||||
});
|
||||
|
||||
test("1. 이미지 목록 화면이 정상적으로 렌더링되는지 확인", async ({ page }) => {
|
||||
await expect(page.locator("body")).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("Movie 게시판 페이지 E2E 테스트", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// 임시 URL. 실제 라우팅 경로에 맞게 수정 필요
|
||||
await page.goto("/post/movie");
|
||||
});
|
||||
|
||||
test("1. 동영상 목록 화면이 정상적으로 렌더링되는지 확인", async ({ page }) => {
|
||||
await expect(page.locator("body")).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("Normal 게시판 페이지 E2E 테스트", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// 임시 URL. 실제 라우팅 경로에 맞게 수정 필요
|
||||
await page.goto("/post/normal");
|
||||
});
|
||||
|
||||
test("1. 일반 게시판 목록 화면이 정상적으로 렌더링되는지 확인", async ({ page }) => {
|
||||
await expect(page.locator("body")).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("Notice 게시판 페이지 E2E 테스트", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// 임시 URL. 실제 라우팅 경로에 맞게 수정 필요
|
||||
await page.goto("/post/notice");
|
||||
});
|
||||
|
||||
test("1. 공지사항 목록 화면이 정상적으로 렌더링되는지 확인", async ({ page }) => {
|
||||
await expect(page.locator("body")).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
// 실행방법 npx playwright test tests/jwt-parse.spec.ts
|
||||
// npx playwright test tests/jwt-parse.spec.ts --project=chromium
|
||||
// npx playwright test tests/jwt-parse.spec.ts --project=chromium --reporter=list
|
||||
// npx playwright test tests/jwt-parse.spec.ts --debug
|
||||
|
||||
test.describe("JWT Token Parsing Test", () => {
|
||||
test("atob 함수를 이용한 JWT Payload 파싱 및 콘솔 출력", async () => {
|
||||
const token =
|
||||
"eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiI2NWEwMGY2NS04NDYwLTQ5YWYtOThlYy0wNDI5NzdlNTZmNGIiLCJhdXRob3JpdGllcyI6IlJPTEVfQU5PTllNT1VTLFJPTEVfQURNSU4sUk9MRV9BRE1JTl9BUyIsIm1uZ3JZbiI6IlkiLCJleHAiOjE3ODM1NjMwMzd9.mWZZ1ooiUs-8JbHCv78NEl51c1F6a7849XdTcSoSSrnqKa2SlFjbNoInHNHkXZo0InRXyg-hpFi-8w-IuOwf8g";
|
||||
|
||||
// 1. JWT 토큰의 2번째 영역인 Payload 추출
|
||||
const base64Payload = token.split(".")[1];
|
||||
expect(base64Payload).toBeDefined();
|
||||
|
||||
// 2. atob 함수를 사용하여 Base64 디코딩 실행
|
||||
const decodedJsonString = atob(base64Payload!);
|
||||
|
||||
// 3. JSON 객체로 파싱
|
||||
const payloadObj = JSON.parse(decodedJsonString);
|
||||
|
||||
// 4. 만료 여부 판단
|
||||
const currentTimeMs = Date.now();
|
||||
const expiryTimeMs = payloadObj.exp * 1000;
|
||||
const isExpired = expiryTimeMs < currentTimeMs;
|
||||
const differenceMs = Math.abs(expiryTimeMs - currentTimeMs);
|
||||
|
||||
// 5. 콘솔 출력
|
||||
console.log("\n==================================================");
|
||||
console.log("Decoded JWT Payload Object:");
|
||||
console.log(JSON.stringify(payloadObj, null, 2));
|
||||
console.log("--------------------------------------------------");
|
||||
console.log(`사용자 ID (sub): ${payloadObj.sub}`);
|
||||
console.log(`사용자 권한 (authorities): ${payloadObj.authorities}`);
|
||||
console.log(`관리자 여부 (mngrYn): ${payloadObj.mngrYn}`);
|
||||
console.log(`만료 시각 (exp): ${payloadObj.exp}`);
|
||||
console.log(
|
||||
`만료 날짜 변환: ${new Date(expiryTimeMs).toLocaleString()}`,
|
||||
);
|
||||
console.log(`현재 시각: ${new Date(currentTimeMs).toLocaleString()}`);
|
||||
console.log(`만료 여부: ${isExpired ? "만료됨 ❌" : "유효함 ✅"}`);
|
||||
if (isExpired) {
|
||||
console.log(`만료 후 경과 시간: ${Math.floor(differenceMs / 1000)}초 경과`);
|
||||
} else {
|
||||
console.log(`만료까지 남은 시간: ${Math.floor(differenceMs / 1000)}초 남음`);
|
||||
}
|
||||
console.log("==================================================\n");
|
||||
|
||||
// 간단한 검증 추가
|
||||
expect(payloadObj).toHaveProperty("exp");
|
||||
expect(payloadObj.mngrYn).toBe("Y");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
const adminId = process.env.TEST_ADMIN_ID || "admin1gmail";
|
||||
const adminPassword = process.env.TEST_ADMIN_PW || "test1234!";
|
||||
|
||||
test.describe("로그인 페이지 E2E 테스트", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// 각 테스트 시작 전 로그인 페이지로 이동
|
||||
await page.goto("/auth/login");
|
||||
});
|
||||
|
||||
test("1. 로그인 화면 UI 요소가 올바르게 렌더링되는지 확인", async ({ page }) => {
|
||||
// 메인 제목 및 하부 제목 검증
|
||||
await expect(page.locator("h1")).toContainText("유역하수도통합플랫폼");
|
||||
await expect(page.locator("h1 span")).toContainText("하수도플랫폼 통합관리(CMS)시스템");
|
||||
|
||||
// 입력 필드 및 버튼 검증
|
||||
await expect(page.locator("input#id")).toBeVisible();
|
||||
await expect(page.locator("input#password")).toBeVisible();
|
||||
await expect(page.locator("button.login_btn")).toBeVisible();
|
||||
await expect(page.locator("input#checkId")).toBeVisible();
|
||||
});
|
||||
|
||||
test("2. 빈 입력으로 로그인 시도 시 에러 메시지 검증", async ({ page }) => {
|
||||
// 값을 입력하지 않고 로그인 클릭
|
||||
await page.click("button.login_btn");
|
||||
|
||||
// 에러 메시지 영역 표시 여부 및 텍스트 검증
|
||||
const errorMessage = page.locator(".error_message");
|
||||
await expect(errorMessage).toBeVisible();
|
||||
await expect(errorMessage).not.toBeEmpty();
|
||||
});
|
||||
|
||||
test("3. 잘못된 비밀번호로 로그인 시도 시 로그인 실패 검증", async ({ page }) => {
|
||||
// 잘못된 자격 증명 입력
|
||||
await page.fill("input#id", adminId);
|
||||
await page.fill("input#password", "wrong_password_123");
|
||||
|
||||
// 로그인 시도
|
||||
await page.click("button.login_btn");
|
||||
|
||||
// 에러 메시지가 화면에 노출되는지 확인
|
||||
const errorMessage = page.locator(".error_message");
|
||||
await expect(errorMessage).toBeVisible();
|
||||
await expect(errorMessage).toContainText("아이디와 비밀번호가 일치하지 않습니다.");
|
||||
|
||||
// URL이 로그인 페이지에 그대로 머물러 있는지 확인
|
||||
await expect(page).toHaveURL(/\/auth\/login/);
|
||||
});
|
||||
|
||||
test("4. 올바른 계정 정보로 로그인 성공 시 대시보드로 이동", async ({ page }) => {
|
||||
// 올바른 자격 증명 입력
|
||||
await page.fill("input#id", adminId);
|
||||
await page.fill("input#password", adminPassword);
|
||||
|
||||
// 로그인 시도
|
||||
await page.click("button.login_btn");
|
||||
|
||||
// 대시보드 페이지로 리다이렉트 되는지 확인
|
||||
await page.waitForURL("**/dashboard");
|
||||
await expect(page).toHaveURL(/.*dashboard/);
|
||||
});
|
||||
|
||||
test("5. 아이디 저장 체크박스 상태 전환 확인", async ({ page }) => {
|
||||
const checkbox = page.locator("input#checkId");
|
||||
|
||||
// 기본적으로 체크되지 않은 상태인지 확인
|
||||
await expect(checkbox).not.toBeChecked();
|
||||
|
||||
// 체크박스 클릭
|
||||
await checkbox.check();
|
||||
await expect(checkbox).toBeChecked();
|
||||
|
||||
// 체크박스 해제 클릭
|
||||
await checkbox.uncheck();
|
||||
await expect(checkbox).not.toBeChecked();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,422 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
// 테스트 순차 실행 보장 (전 단계 상태가 영향을 줄 수 있으므로 serial 모드로 설정)
|
||||
// npx playwright test tests/multi-device-auth.spec.ts --debug
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
const adminId = process.env.TEST_ADMIN_ID || "admin1gmail";
|
||||
const adminPassword = process.env.TEST_ADMIN_PW || "test1234!";
|
||||
|
||||
const DEVICE_PC = "device-pc-chrome-001";
|
||||
const DEVICE_MOBILE = "device-mobile-safari-002";
|
||||
const DEVICE_TABLET = "device-tablet-firefox-003";
|
||||
|
||||
// 디바이스별로 완전히 분리된 HTTP 요청 컨텍스트를 생성하여 세션 격리를 모사하는 헬퍼 함수
|
||||
async function createDeviceContext(playwrightRequest: any, deviceId: string) {
|
||||
return await playwrightRequest.newContext({
|
||||
baseURL: process.env.PLAYWRIGHT_BASE_URL || "http://localhost:3000",
|
||||
extraHTTPHeaders: {
|
||||
"X-Device-Id": deviceId,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 1초 이상의 간격을 두어 토큰 발급/갱신 시 만료 시각(초)이 다르게 생성되도록 유도하는 지연 함수
|
||||
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
|
||||
// 응답의 Set-Cookie 헤더 배열에서 특정 쿠키 값을 추출하는 헬퍼 함수
|
||||
function getCookieValue(
|
||||
headers: { name: string; value: string }[],
|
||||
name: string,
|
||||
): string | null {
|
||||
const cookieHeaders = headers.filter(
|
||||
(h) => h.name.toLowerCase() === "set-cookie",
|
||||
);
|
||||
for (const header of cookieHeaders) {
|
||||
const firstPart = header.value.split(";")[0];
|
||||
if (!firstPart) continue;
|
||||
const parts = firstPart.split("=");
|
||||
const key = parts[0]?.trim();
|
||||
const val = parts[1]?.trim();
|
||||
if (key === name) {
|
||||
return val ?? null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 응답의 Set-Cookie 헤더에서 특정 쿠키의 속성(httpOnly, sameSite 등) 존재 유무를 확인하는 헬퍼 함수
|
||||
function hasCookieAttribute(
|
||||
headers: { name: string; value: string }[],
|
||||
name: string,
|
||||
attribute: string,
|
||||
): boolean {
|
||||
const cookieHeaders = headers.filter(
|
||||
(h) => h.name.toLowerCase() === "set-cookie",
|
||||
);
|
||||
for (const header of cookieHeaders) {
|
||||
const parts = header.value.split(";");
|
||||
const firstPart = parts[0];
|
||||
if (!firstPart) continue;
|
||||
const cookieName = firstPart.split("=")[0]!.trim();
|
||||
if (cookieName === name) {
|
||||
return parts
|
||||
.slice(1)
|
||||
.some((p) => p.trim().toLowerCase() === attribute.toLowerCase());
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
test.describe("다중 디바이스 인증 & Refresh Token Rotation (RTR) 테스트", () => {
|
||||
|
||||
test("1. 로그인 성공 시 httpOnly refresh-token 쿠키 설정", async ({ request }) => {
|
||||
const response = await request.post("/api/proxy/user-service/login", {
|
||||
data: { userId: adminId, password: adminPassword },
|
||||
headers: { "X-Device-Id": DEVICE_PC },
|
||||
});
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
const headers = response.headersArray();
|
||||
const refreshToken = getCookieValue(headers, "refresh-token");
|
||||
expect(refreshToken).not.toBeNull();
|
||||
expect(refreshToken).not.toBe("");
|
||||
|
||||
// httpOnly 속성이 설정되었는지 검증
|
||||
const isHttpOnly = hasCookieAttribute(headers, "refresh-token", "httponly");
|
||||
expect(isHttpOnly).toBe(true);
|
||||
});
|
||||
|
||||
test("2. PC/모바일/태블릿 독립 refresh-token 발급", async ({ playwright }) => {
|
||||
const pcContext = await createDeviceContext(playwright.request, DEVICE_PC);
|
||||
const mobileContext = await createDeviceContext(playwright.request, DEVICE_MOBILE);
|
||||
const tabletContext = await createDeviceContext(playwright.request, DEVICE_TABLET);
|
||||
|
||||
const pcRes = await pcContext.post("/api/proxy/user-service/login", {
|
||||
data: { userId: adminId, password: adminPassword },
|
||||
});
|
||||
const pcToken = getCookieValue(pcRes.headersArray(), "refresh-token");
|
||||
|
||||
await delay(1000); // 1초 간격 지연
|
||||
|
||||
const mobileRes = await mobileContext.post("/api/proxy/user-service/login", {
|
||||
data: { userId: adminId, password: adminPassword },
|
||||
});
|
||||
const mobileToken = getCookieValue(mobileRes.headersArray(), "refresh-token");
|
||||
|
||||
await delay(1000); // 1초 간격 지연
|
||||
|
||||
const tabletRes = await tabletContext.post("/api/proxy/user-service/login", {
|
||||
data: { userId: adminId, password: adminPassword },
|
||||
});
|
||||
const tabletToken = getCookieValue(tabletRes.headersArray(), "refresh-token");
|
||||
|
||||
expect(pcToken).not.toBeNull();
|
||||
expect(mobileToken).not.toBeNull();
|
||||
expect(tabletToken).not.toBeNull();
|
||||
|
||||
// 다중 세션 환경에서는 디바이스마다 리프레시 토큰이 고유해야 함
|
||||
expect(pcToken).not.toBe(mobileToken);
|
||||
expect(pcToken).not.toBe(tabletToken);
|
||||
expect(mobileToken).not.toBe(tabletToken);
|
||||
|
||||
await pcContext.dispose();
|
||||
await mobileContext.dispose();
|
||||
await tabletContext.dispose();
|
||||
});
|
||||
|
||||
test("3. Silent Refresh RTR - 새 토큰 교체", async ({ playwright }) => {
|
||||
const pcContext = await createDeviceContext(playwright.request, DEVICE_PC);
|
||||
|
||||
// 로그인으로 최초 토큰 발급
|
||||
const loginRes = await pcContext.post("/api/proxy/user-service/login", {
|
||||
data: { userId: adminId, password: adminPassword },
|
||||
});
|
||||
const firstToken = getCookieValue(loginRes.headersArray(), "refresh-token");
|
||||
expect(firstToken).not.toBeNull();
|
||||
|
||||
await delay(1000); // 1초 간격 지연
|
||||
|
||||
// 갱신 시도 (RTR 동작)
|
||||
const refreshRes = await pcContext.put("/api/proxy/user-service/api/v1/users/token/refresh");
|
||||
expect(refreshRes.status()).toBe(200);
|
||||
|
||||
const secondToken = getCookieValue(refreshRes.headersArray(), "refresh-token");
|
||||
expect(secondToken).not.toBeNull();
|
||||
|
||||
// 이전 토큰과 달라져야 함
|
||||
expect(secondToken).not.toBe(firstToken);
|
||||
|
||||
await pcContext.dispose();
|
||||
});
|
||||
|
||||
test("4. PC RTR 갱신 후 모바일 세션 독립 유지", async ({ playwright }) => {
|
||||
const pcContext = await createDeviceContext(playwright.request, DEVICE_PC);
|
||||
const mobileContext = await createDeviceContext(playwright.request, DEVICE_MOBILE);
|
||||
|
||||
await pcContext.post("/api/proxy/user-service/login", {
|
||||
data: { userId: adminId, password: adminPassword },
|
||||
});
|
||||
|
||||
await delay(1000); // 1초 간격 지연
|
||||
|
||||
await mobileContext.post("/api/proxy/user-service/login", {
|
||||
data: { userId: adminId, password: adminPassword },
|
||||
});
|
||||
|
||||
await delay(1000); // 1초 간격 지연
|
||||
|
||||
// PC 토큰을 RTR 갱신함
|
||||
const pcRefresh = await pcContext.put("/api/proxy/user-service/api/v1/users/token/refresh");
|
||||
expect(pcRefresh.status()).toBe(200);
|
||||
|
||||
// 모바일 세션은 PC와 연동되지 않고 독립적으로 정상 갱신되어야 함
|
||||
const mobileRefresh = await mobileContext.put("/api/proxy/user-service/api/v1/users/token/refresh");
|
||||
expect(mobileRefresh.status()).toBe(200);
|
||||
|
||||
await pcContext.dispose();
|
||||
await mobileContext.dispose();
|
||||
});
|
||||
|
||||
test("5. Replay Attack - 구 토큰 재주입 → 400", async ({ playwright }) => {
|
||||
const pcContext = await createDeviceContext(playwright.request, DEVICE_PC);
|
||||
|
||||
// 로그인 후 구 토큰 확보
|
||||
const loginRes = await pcContext.post("/api/proxy/user-service/login", {
|
||||
data: { userId: adminId, password: adminPassword },
|
||||
});
|
||||
const oldToken = getCookieValue(loginRes.headersArray(), "refresh-token");
|
||||
|
||||
await delay(1000); // 1초 간격 지연
|
||||
|
||||
// 1차 갱신을 수행하여 서버(Redis)의 저장 값을 갱신
|
||||
const refreshRes1 = await pcContext.put("/api/proxy/user-service/api/v1/users/token/refresh");
|
||||
expect(refreshRes1.status()).toBe(200);
|
||||
|
||||
// 구 토큰을 명시적으로 세팅하여 재사용 시도
|
||||
const replayContext = await createDeviceContext(playwright.request, DEVICE_PC);
|
||||
await replayContext.storageState({
|
||||
cookies: [
|
||||
{
|
||||
name: "refresh-token",
|
||||
value: oldToken!,
|
||||
domain: "localhost",
|
||||
path: "/",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const replayRes = await replayContext.put("/api/proxy/user-service/api/v1/users/token/refresh");
|
||||
// 사용 완료된 토큰 재주입은 거부되어야 함 (백엔드는 401 반환)
|
||||
expect(replayRes.status()).toBe(401);
|
||||
|
||||
await pcContext.dispose();
|
||||
await replayContext.dispose();
|
||||
});
|
||||
|
||||
test("6. refresh-token 없이 silent refresh → 401", async ({ playwright }) => {
|
||||
const cleanContext = await createDeviceContext(playwright.request, DEVICE_PC);
|
||||
const res = await cleanContext.put("/api/proxy/user-service/api/v1/users/token/refresh");
|
||||
|
||||
// 토큰 부재 시 401 반환
|
||||
expect(res.status()).toBe(401);
|
||||
const body = await res.json();
|
||||
expect(body.message).toBe("Can't refresh");
|
||||
|
||||
await cleanContext.dispose();
|
||||
});
|
||||
|
||||
test("7. 잘못된 비밀번호 → 401", async ({ request }) => {
|
||||
const res = await request.post("/api/proxy/user-service/login", {
|
||||
data: { userId: adminId, password: "WrongPassword999!" },
|
||||
headers: { "X-Device-Id": DEVICE_PC },
|
||||
});
|
||||
expect(res.status()).toBe(401);
|
||||
const body = await res.json();
|
||||
expect(body.message).toBe("Invalid Credentials");
|
||||
});
|
||||
|
||||
test("8. 존재하지 않는 ID → 401", async ({ request }) => {
|
||||
const res = await request.post("/api/proxy/user-service/login", {
|
||||
data: { userId: "non_existent_user_xyz", password: adminPassword },
|
||||
headers: { "X-Device-Id": DEVICE_PC },
|
||||
});
|
||||
expect(res.status()).toBe(401);
|
||||
const body = await res.json();
|
||||
expect(body.message).toBe("Invalid Credentials");
|
||||
});
|
||||
|
||||
test("9. 로그아웃 후 쿠키 전체 삭제", async ({ playwright }) => {
|
||||
const pcContext = await createDeviceContext(playwright.request, DEVICE_PC);
|
||||
await pcContext.post("/api/proxy/user-service/login", {
|
||||
data: { userId: adminId, password: adminPassword },
|
||||
});
|
||||
|
||||
// 로그아웃 호출
|
||||
const logoutRes = await pcContext.post("/api/auth/logout");
|
||||
expect(logoutRes.status()).toBe(200);
|
||||
|
||||
// 클라이언트의 인증 쿠키들이 소멸되었는지 검증
|
||||
const state = await pcContext.storageState();
|
||||
const refreshCookie = state.cookies.find((c: { name: string }) => c.name === "refresh-token");
|
||||
const accessCookie = state.cookies.find((c: { name: string }) => c.name === "access-token");
|
||||
const userCookie = state.cookies.find((c: { name: string }) => c.name === "user-id");
|
||||
|
||||
expect(refreshCookie).toBeUndefined();
|
||||
expect(accessCookie).toBeUndefined();
|
||||
expect(userCookie).toBeUndefined();
|
||||
|
||||
await pcContext.dispose();
|
||||
});
|
||||
|
||||
test("10. PC 로그아웃 후 모바일 세션 독립 유지", async ({ playwright }) => {
|
||||
const pcContext = await createDeviceContext(playwright.request, DEVICE_PC);
|
||||
const mobileContext = await createDeviceContext(playwright.request, DEVICE_MOBILE);
|
||||
|
||||
await pcContext.post("/api/proxy/user-service/login", {
|
||||
data: { userId: adminId, password: adminPassword },
|
||||
});
|
||||
await mobileContext.post("/api/proxy/user-service/login", {
|
||||
data: { userId: adminId, password: adminPassword },
|
||||
});
|
||||
|
||||
// PC 로그아웃
|
||||
await pcContext.post("/api/auth/logout");
|
||||
|
||||
// 모바일은 PC의 로그아웃과 무관하게 독립적으로 유효하게 동작해야 함
|
||||
const mobileRefresh = await mobileContext.put("/api/proxy/user-service/api/v1/users/token/refresh");
|
||||
expect(mobileRefresh.status()).toBe(200);
|
||||
|
||||
await pcContext.dispose();
|
||||
await mobileContext.dispose();
|
||||
});
|
||||
|
||||
test("11. UI 로그인 후 httpOnly 쿠키 E2E 확인", async ({ page }) => {
|
||||
// UI 로그인 페이지 접속
|
||||
await page.goto("/auth/login");
|
||||
|
||||
// Form 필드 입력
|
||||
await page.locator("input#id").fill(adminId);
|
||||
await page.locator("input#password").fill(adminPassword);
|
||||
|
||||
// 디바이스 ID 쿠키 설정 시뮬레이션
|
||||
await page.context().addCookies([
|
||||
{
|
||||
name: "device-id",
|
||||
value: DEVICE_PC,
|
||||
url: "http://localhost:3000",
|
||||
},
|
||||
]);
|
||||
|
||||
// 로그인 버튼 클릭
|
||||
await page.locator(".login_btn").click();
|
||||
|
||||
// 성공 시 대시보드 페이지 이동 대기
|
||||
await page.waitForURL("**/dashboard");
|
||||
|
||||
// E2E 보안 요구사항: 클라이언트 브라우저 JavaScript(document.cookie)로는 httpOnly 쿠키를 읽을 수 없어야 함
|
||||
const clientCookies = await page.evaluate(() => document.cookie);
|
||||
expect(clientCookies).not.toContain("refresh-token");
|
||||
|
||||
// 실제 브라우저의 HTTP Context 쿠키에는 저장되어 있어야 함
|
||||
const contextCookies = await page.context().cookies();
|
||||
const refreshCookie = contextCookies.find((c: { name: string }) => c.name === "refresh-token");
|
||||
expect(refreshCookie).toBeDefined();
|
||||
expect(refreshCookie?.httpOnly).toBe(true);
|
||||
});
|
||||
|
||||
test("12. 동일 계정 3개 브라우저 동시 로그인", async ({ playwright }) => {
|
||||
const pcContext = await createDeviceContext(playwright.request, DEVICE_PC);
|
||||
const mobileContext = await createDeviceContext(playwright.request, DEVICE_MOBILE);
|
||||
const tabletContext = await createDeviceContext(playwright.request, DEVICE_TABLET);
|
||||
|
||||
const pcRes = await pcContext.post("/api/proxy/user-service/login", {
|
||||
data: { userId: adminId, password: adminPassword },
|
||||
});
|
||||
expect(pcRes.status()).toBe(200);
|
||||
|
||||
const mobileRes = await mobileContext.post("/api/proxy/user-service/login", {
|
||||
data: { userId: adminId, password: adminPassword },
|
||||
});
|
||||
expect(mobileRes.status()).toBe(200);
|
||||
|
||||
const tabletRes = await tabletContext.post("/api/proxy/user-service/login", {
|
||||
data: { userId: adminId, password: adminPassword },
|
||||
});
|
||||
expect(tabletRes.status()).toBe(200);
|
||||
|
||||
// 3개 브라우저 세션 모두 정상 리프레시 수행 가능 확인
|
||||
const pcRef = await pcContext.put("/api/proxy/user-service/api/v1/users/token/refresh");
|
||||
expect(pcRef.status()).toBe(200);
|
||||
|
||||
const mobRef = await mobileContext.put("/api/proxy/user-service/api/v1/users/token/refresh");
|
||||
expect(mobRef.status()).toBe(200);
|
||||
|
||||
const tabRef = await tabletContext.put("/api/proxy/user-service/api/v1/users/token/refresh");
|
||||
expect(tabRef.status()).toBe(200);
|
||||
|
||||
await pcContext.dispose();
|
||||
await mobileContext.dispose();
|
||||
await tabletContext.dispose();
|
||||
});
|
||||
|
||||
test("13. sameSite=Strict 쿠키 속성 확인", async ({ request }) => {
|
||||
const response = await request.post("/api/proxy/user-service/login", {
|
||||
data: { userId: adminId, password: adminPassword },
|
||||
headers: { "X-Device-Id": DEVICE_PC },
|
||||
});
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
const headers = response.headersArray();
|
||||
const isStrict = hasCookieAttribute(headers, "refresh-token", "samesite=strict");
|
||||
expect(isStrict).toBe(true);
|
||||
});
|
||||
|
||||
test("14. 연속 3회 RTR 갱신 - 4개 고유 토큰", async ({ playwright }) => {
|
||||
const pcContext = await createDeviceContext(playwright.request, DEVICE_PC);
|
||||
const tokens: string[] = [];
|
||||
|
||||
// 로그인 (1차 토큰 발급)
|
||||
const loginRes = await pcContext.post("/api/proxy/user-service/login", {
|
||||
data: { userId: adminId, password: adminPassword },
|
||||
});
|
||||
const t1 = getCookieValue(loginRes.headersArray(), "refresh-token");
|
||||
expect(t1).not.toBeNull();
|
||||
tokens.push(t1!);
|
||||
|
||||
await delay(1000);
|
||||
|
||||
// 1차 갱신 (2차 토큰 발급)
|
||||
const r1 = await pcContext.put("/api/proxy/user-service/api/v1/users/token/refresh");
|
||||
expect(r1.status()).toBe(200);
|
||||
const t2 = getCookieValue(r1.headersArray(), "refresh-token");
|
||||
expect(t2).not.toBeNull();
|
||||
tokens.push(t2!);
|
||||
|
||||
await delay(1000);
|
||||
|
||||
// 2차 갱신 (3차 토큰 발급)
|
||||
const r2 = await pcContext.put("/api/proxy/user-service/api/v1/users/token/refresh");
|
||||
expect(r2.status()).toBe(200);
|
||||
const t3 = getCookieValue(r2.headersArray(), "refresh-token");
|
||||
expect(t3).not.toBeNull();
|
||||
tokens.push(t3!);
|
||||
|
||||
await delay(1000);
|
||||
|
||||
// 3차 갱신 (4차 토큰 발급)
|
||||
const r3 = await pcContext.put("/api/proxy/user-service/api/v1/users/token/refresh");
|
||||
expect(r3.status()).toBe(200);
|
||||
const t4 = getCookieValue(r3.headersArray(), "refresh-token");
|
||||
expect(t4).not.toBeNull();
|
||||
tokens.push(t4!);
|
||||
|
||||
// 4개 토큰이 모두 고유한지 검증 (Set의 중복 제거 크기로 판별)
|
||||
const uniqueTokens = new Set(tokens);
|
||||
expect(uniqueTokens.size).toBe(4);
|
||||
|
||||
await pcContext.dispose();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
const adminId = process.env.TEST_ADMIN_ID || "admin1gmail";
|
||||
const adminPassword = process.env.TEST_ADMIN_PW || "test1234!";
|
||||
|
||||
test.describe("세션 연장 확인 팝업 (useSessionExtendConfirm) E2E 테스트", () => {
|
||||
|
||||
test("1. 세션 만료 5분(버퍼 시간) 전 도달 시 컨펌 다이얼로그가 자동 노출되고, 승인 시 연장 처리가 수행되어야 함", async ({ page }) => {
|
||||
// 1. 로그인 수행하여 정상 세션 획득
|
||||
await page.goto("/auth/login");
|
||||
await page.fill("input#id", adminId);
|
||||
await page.fill("input#password", adminPassword);
|
||||
await page.click("button.login_btn");
|
||||
await page.waitForURL("**/dashboard");
|
||||
// 내비게이션 및 세션 적재의 미세 안정화를 위해 짧은 대기 추가
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// 2. 로그인된 상태에서 격리 테스팅 라우트로 이동
|
||||
await page.goto("/test-session");
|
||||
await page.waitForURL("**/test-session");
|
||||
|
||||
// 3. 2초 뒤 만료 버퍼 도달 시점에 노출되는 리액트 HTML 모달 텍스트 대기
|
||||
const messageLocator = page.locator("p", { hasText: "로그인을 연장하시겠습니까?" });
|
||||
await expect(messageLocator).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// 4. 모달 내부의 "확인" 버튼 클릭
|
||||
const confirmButton = page.locator("button", { hasText: "확인" });
|
||||
await confirmButton.click();
|
||||
|
||||
// 5. 모달이 정상적으로 닫혔는지 검증
|
||||
await expect(messageLocator).toBeHidden();
|
||||
|
||||
// 6. silentRefresh 성공 시 나타나는 엘리먼트 텍스트 검증
|
||||
const statusText = page.locator("[data-testid='refresh-status']");
|
||||
await expect(statusText).toHaveText("Refresh Successful");
|
||||
|
||||
// 7. 세션 연장 처리 완료 후 추가 시간이 흐르더라도 다음 팝업이 즉시 중복 재호출되지 않아야 함
|
||||
await page.waitForTimeout(2000);
|
||||
await expect(messageLocator).toBeHidden();
|
||||
});
|
||||
|
||||
test("2. 컨펌 다이얼로그를 거절(Dismiss)한 경우 세션 연장 처리가 취소되고 재차 팝업이 노출되지 않아야 함", async ({ page }) => {
|
||||
// 1. 로그인 수행하여 정상 세션 획득
|
||||
await page.goto("/auth/login");
|
||||
await page.fill("input#id", adminId);
|
||||
await page.fill("input#password", adminPassword);
|
||||
await page.click("button.login_btn");
|
||||
await page.waitForURL("**/dashboard");
|
||||
// 내비게이션 및 세션 적재의 미세 안정화를 위해 짧은 대기 추가
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// 2. 로그인된 상태에서 격리 테스팅 라우트로 이동
|
||||
await page.goto("/test-session");
|
||||
await page.waitForURL("**/test-session");
|
||||
|
||||
// 3. 컨펌 모달 대기
|
||||
const messageLocator = page.locator("p", { hasText: "로그인을 연장하시겠습니까?" });
|
||||
await expect(messageLocator).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// 4. 모달 내부의 "취소" 버튼 클릭
|
||||
const cancelButton = page.locator("button", { hasText: "취소" });
|
||||
await cancelButton.click();
|
||||
|
||||
// 5. 모달이 정상적으로 닫혔는지 확인
|
||||
await expect(messageLocator).toBeHidden();
|
||||
|
||||
// 6. declineSessionExtend 성공 시 나타나는 엘리먼트 텍스트 검증
|
||||
const statusText = page.locator("[data-testid='decline-status']");
|
||||
await expect(statusText).toHaveText("Decline Successful");
|
||||
|
||||
// 7. 거절 이후 추가 시간이 흘러도 모달이 중복 재노출되지 않아야 함
|
||||
await page.waitForTimeout(2000);
|
||||
await expect(messageLocator).toBeHidden();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
import "@testing-library/jest-dom";
|
||||
@@ -0,0 +1,132 @@
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from "vitest";
|
||||
import { LoginPage } from "@/pages/auth/login";
|
||||
import { authService } from "@/entities/auth";
|
||||
|
||||
// next/image 모킹
|
||||
vi.mock("next/image", () => ({
|
||||
default: (props: any) => {
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
return <img {...props} />;
|
||||
},
|
||||
}));
|
||||
|
||||
// @/entities/auth 모킹
|
||||
vi.mock("@/entities/auth", () => ({
|
||||
authService: {
|
||||
login: vi.fn(),
|
||||
},
|
||||
AuthWrapper: ({ children }: { children: React.ReactNode }) => children,
|
||||
tokenManager: {
|
||||
getToken: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// @common/assets 모킹
|
||||
vi.mock("@common/assets", () => ({
|
||||
images: {
|
||||
logo: "/logo.png",
|
||||
},
|
||||
}));
|
||||
|
||||
describe("LoginPage 단위 테스트 (Vitest)", () => {
|
||||
const originalLocation = window.location;
|
||||
|
||||
beforeAll(() => {
|
||||
// window.location.href 변경 감지를 위해 window.location 모킹
|
||||
// @ts-expect-error
|
||||
delete window.location;
|
||||
window.location = { ...originalLocation, href: "" } as never;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
// @ts-expect-error
|
||||
window.location = originalLocation;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
window.location.href = "";
|
||||
});
|
||||
|
||||
it("1. 로그인 화면 UI 요소가 정상 렌더링되는지 검증", () => {
|
||||
render(<LoginPage />);
|
||||
|
||||
// 타이틀 렌더링 확인
|
||||
expect(screen.getByText("유역하수도통합플랫폼")).toBeInTheDocument();
|
||||
expect(screen.getByText("하수도플랫폼 통합관리(CMS)시스템")).toBeInTheDocument();
|
||||
|
||||
// 입력 필드 존재 확인
|
||||
const idInput = screen.getByTitle("아이디를 입력하세요.");
|
||||
const passwordInput = screen.getByTitle("비밀번호를 입력하세요.");
|
||||
expect(idInput).toBeInTheDocument();
|
||||
expect(passwordInput).toBeInTheDocument();
|
||||
|
||||
// 체크박스 존재 확인
|
||||
const saveIdCheckbox = screen.getByLabelText("아이디저장");
|
||||
expect(saveIdCheckbox).toBeInTheDocument();
|
||||
expect(saveIdCheckbox).not.toBeChecked();
|
||||
|
||||
// 로그인 버튼 존재 확인
|
||||
const submitButton = screen.getByRole("button", { name: "LOGIN" });
|
||||
expect(submitButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("2. 로그인 실패 시 에러 메시지가 화면에 노출되는지 검증", async () => {
|
||||
// authService.login이 에러를 던지도록 모크 구현
|
||||
const errorMessage = "아이디와 비밀번호가 일치하지 않습니다.";
|
||||
vi.mocked(authService.login).mockRejectedValueOnce(new Error(errorMessage));
|
||||
|
||||
render(<LoginPage />);
|
||||
|
||||
const idInput = screen.getByTitle("아이디를 입력하세요.");
|
||||
const passwordInput = screen.getByTitle("비밀번호를 입력하세요.");
|
||||
const submitButton = screen.getByRole("button", { name: "LOGIN" });
|
||||
|
||||
// 값 입력 및 제출
|
||||
fireEvent.change(idInput, { target: { value: "testuser" } });
|
||||
fireEvent.change(passwordInput, { target: { value: "wrongpassword" } });
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
// 에러 메시지 렌더링 대기 및 확인
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(errorMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// authService.login 호출 확인
|
||||
expect(authService.login).toHaveBeenCalledWith({
|
||||
userId: "testuser",
|
||||
password: "wrongpassword",
|
||||
});
|
||||
|
||||
// 리다이렉트가 발생하지 않아야 함
|
||||
expect(window.location.href).toBe("");
|
||||
});
|
||||
|
||||
it("3. 로그인 성공 시 /dashboard 로 리다이렉트되는지 검증", async () => {
|
||||
// authService.login 성공 모크 구현
|
||||
vi.mocked(authService.login).mockResolvedValueOnce(undefined as never);
|
||||
|
||||
render(<LoginPage />);
|
||||
|
||||
const idInput = screen.getByTitle("아이디를 입력하세요.");
|
||||
const passwordInput = screen.getByTitle("비밀번호를 입력하세요.");
|
||||
const submitButton = screen.getByRole("button", { name: "LOGIN" });
|
||||
|
||||
// 값 입력 및 제출
|
||||
fireEvent.change(idInput, { target: { value: "admin1gmail" } });
|
||||
fireEvent.change(passwordInput, { target: { value: "test1234!" } });
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
// 리다이렉트 주소값 변경 대기 및 확인
|
||||
await waitFor(() => {
|
||||
expect(window.location.href).toBe("/dashboard");
|
||||
});
|
||||
|
||||
// authService.login 호출 확인
|
||||
expect(authService.login).toHaveBeenCalledWith({
|
||||
userId: "admin1gmail",
|
||||
password: "test1234!",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { SampleCounter } from "./sample-counter";
|
||||
|
||||
describe("SampleCounter 컴포넌트 단위 및 통합 테스트", () => {
|
||||
it("1. 기본 렌더링 상태를 정상적으로 표시하는지 확인", () => {
|
||||
render(<SampleCounter />);
|
||||
|
||||
// 제목 텍스트 존재 확인
|
||||
expect(screen.getByText("단위 테스트 샘플 카운터")).toBeInTheDocument();
|
||||
|
||||
// 카운터 초기값 0 확인
|
||||
const counterVal = screen.getByTestId("counter-value");
|
||||
expect(counterVal).toHaveTextContent("현재 값: 0");
|
||||
|
||||
// 경고 문구가 보이지 않아야 함
|
||||
expect(screen.queryByTestId("warning-message")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("2. 증가 버튼 클릭 시 값이 1씩 올라가는지 확인 (상태 변경 및 이벤트 검증)", () => {
|
||||
render(<SampleCounter />);
|
||||
|
||||
const increaseBtn = screen.getByRole("button", { name: "increase" });
|
||||
const counterVal = screen.getByTestId("counter-value");
|
||||
|
||||
// 1회 클릭
|
||||
fireEvent.click(increaseBtn);
|
||||
expect(counterVal).toHaveTextContent("현재 값: 1");
|
||||
|
||||
// 2회 클릭
|
||||
fireEvent.click(increaseBtn);
|
||||
expect(counterVal).toHaveTextContent("현재 값: 2");
|
||||
});
|
||||
|
||||
it("3. 감소 버튼 클릭 시 값이 1씩 내려가는지 확인", () => {
|
||||
render(<SampleCounter />);
|
||||
|
||||
const decreaseBtn = screen.getByRole("button", { name: "decrease" });
|
||||
const counterVal = screen.getByTestId("counter-value");
|
||||
|
||||
// 1회 클릭 (0 -> -1)
|
||||
fireEvent.click(decreaseBtn);
|
||||
expect(counterVal).toHaveTextContent("현재 값: -1");
|
||||
});
|
||||
|
||||
it("4. 값이 5를 초과할 경우 경고 문구가 조건부로 렌더링되는지 확인 (통합 로직 검증)", () => {
|
||||
render(<SampleCounter />);
|
||||
|
||||
const increaseBtn = screen.getByRole("button", { name: "increase" });
|
||||
|
||||
// 5번 클릭 -> 5 (경고 없음)
|
||||
for (let i = 0; i < 5; i++) {
|
||||
fireEvent.click(increaseBtn);
|
||||
}
|
||||
expect(screen.queryByTestId("warning-message")).not.toBeInTheDocument();
|
||||
|
||||
// 6번째 클릭 -> 6 (경고 노출되어야 함)
|
||||
fireEvent.click(increaseBtn);
|
||||
const warningMsg = screen.getByTestId("warning-message");
|
||||
expect(warningMsg).toBeInTheDocument();
|
||||
expect(warningMsg).toHaveTextContent("값이 5를 초과했습니다!");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
export function SampleCounter() {
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
return (
|
||||
<div style={{ padding: "20px", textAlign: "center" }}>
|
||||
<h2>단위 테스트 샘플 카운터</h2>
|
||||
<p data-testid="counter-value" style={{ fontSize: "24px", fontWeight: "bold" }}>
|
||||
현재 값: {count}
|
||||
</p>
|
||||
<div style={{ display: "flex", gap: "10px", justifyContent: "center" }}>
|
||||
<button
|
||||
onClick={() => setCount((prev) => prev - 1)}
|
||||
style={{ padding: "8px 16px" }}
|
||||
aria-label="decrease"
|
||||
>
|
||||
감소 (-)
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCount((prev) => prev + 1)}
|
||||
style={{ padding: "8px 16px" }}
|
||||
aria-label="increase"
|
||||
>
|
||||
증가 (+)
|
||||
</button>
|
||||
</div>
|
||||
{count > 5 && (
|
||||
<p data-testid="warning-message" style={{ color: "red", marginTop: "10px" }}>
|
||||
값이 5를 초과했습니다!
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,652 @@
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { useSessionExtendConfirm } from "../../../../packages/common/components/src/lib/use-session-extend-confirm";
|
||||
import { getRemainingMs } from "@common/auth";
|
||||
import { useConfirm } from "@frontend-opensource/use-react-hooks";
|
||||
|
||||
// useConfirm 모킹
|
||||
const mockConfirm = vi.fn();
|
||||
const mockOnCancel = vi.fn();
|
||||
vi.mock("@frontend-opensource/use-react-hooks", () => ({
|
||||
useConfirm: () => ({
|
||||
confirm: mockConfirm,
|
||||
onCancel: mockOnCancel,
|
||||
}),
|
||||
}));
|
||||
|
||||
// @common/auth 모킹
|
||||
const mockGetRemainingMs = vi.fn();
|
||||
vi.mock("@common/auth", () => ({
|
||||
getRemainingMs: (...args: any[]) => mockGetRemainingMs(...args),
|
||||
SESSION_EXTEND_CONFIRM_BUFFER_MS: 300000,
|
||||
}));
|
||||
|
||||
// 글로벌 API 모킹 정의
|
||||
const mockAudioContext = vi.fn().mockImplementation(function (this: any) {
|
||||
this.createOscillator = vi.fn().mockImplementation(() => ({
|
||||
connect: vi.fn(),
|
||||
type: "sine",
|
||||
frequency: { setValueAtTime: vi.fn() },
|
||||
start: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
}));
|
||||
this.createGain = vi.fn().mockImplementation(() => ({
|
||||
connect: vi.fn(),
|
||||
gain: { setValueAtTime: vi.fn() },
|
||||
}));
|
||||
this.destination = {};
|
||||
this.currentTime = 0;
|
||||
});
|
||||
|
||||
const mockNotification = vi.fn();
|
||||
(mockNotification as any).permission = "granted";
|
||||
|
||||
const mockLocalStorageStore: Record<string, string> = {};
|
||||
const mockLocalStorage = {
|
||||
getItem: vi.fn().mockImplementation((key) => mockLocalStorageStore[key] || null),
|
||||
setItem: vi.fn().mockImplementation((key, val) => {
|
||||
mockLocalStorageStore[key] = val;
|
||||
}),
|
||||
removeItem: vi.fn().mockImplementation((key) => {
|
||||
delete mockLocalStorageStore[key];
|
||||
}),
|
||||
clear: vi.fn().mockImplementation(() => {
|
||||
for (const k in mockLocalStorageStore) delete mockLocalStorageStore[k];
|
||||
}),
|
||||
};
|
||||
|
||||
describe("useSessionExtendConfirm 커스텀 훅 테스트 (Vitest)", () => {
|
||||
const mockGetAccessToken = vi.fn();
|
||||
const mockSilentRefresh = vi.fn();
|
||||
const mockDeclineSessionExtend = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal("AudioContext", mockAudioContext);
|
||||
vi.stubGlobal("webkitAudioContext", mockAudioContext);
|
||||
vi.stubGlobal("Notification", mockNotification);
|
||||
vi.stubGlobal("localStorage", mockLocalStorage);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("1. enabled가 false일 때는 어떤 타이머나 컨펌도 동작하지 않아야 함", () => {
|
||||
mockGetAccessToken.mockReturnValue("mock-token-123");
|
||||
mockGetRemainingMs.mockReturnValue(10000); // 10초 뒤 컨펌 필요
|
||||
|
||||
renderHook(() =>
|
||||
useSessionExtendConfirm({
|
||||
enabled: false,
|
||||
getAccessToken: mockGetAccessToken,
|
||||
silentRefresh: mockSilentRefresh,
|
||||
declineSessionExtend: mockDeclineSessionExtend,
|
||||
})
|
||||
);
|
||||
|
||||
// 타이머가 동작하지 않으므로 시간 경과 후에도 confirm이 실행되지 않아야 함
|
||||
vi.advanceTimersByTime(10000);
|
||||
expect(mockConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("2. 토큰 만료 버퍼 진입 전인 경우, 해당 시간만큼 대기 후 컨펌창이 나타나야 함", async () => {
|
||||
mockGetAccessToken.mockReturnValue("mock-token-123");
|
||||
// getRemainingMs(token, buffer) -> 10000ms (10초 후에 버퍼 시점 도달)
|
||||
// getRemainingMs(token, 0) -> 310000ms (토큰 유효함)
|
||||
mockGetRemainingMs.mockImplementation((token, buffer) => {
|
||||
return buffer === 0 ? 310000 : 10000;
|
||||
});
|
||||
mockConfirm.mockResolvedValue(true);
|
||||
|
||||
renderHook(() =>
|
||||
useSessionExtendConfirm({
|
||||
enabled: true,
|
||||
getAccessToken: mockGetAccessToken,
|
||||
silentRefresh: mockSilentRefresh,
|
||||
declineSessionExtend: mockDeclineSessionExtend,
|
||||
})
|
||||
);
|
||||
|
||||
// 9초 지났을 때는 아직 컨펌이 뜨지 않아야 함
|
||||
vi.advanceTimersByTime(9000);
|
||||
expect(mockConfirm).not.toHaveBeenCalled();
|
||||
|
||||
// 10초째에 진입했을 때 컨펌창 노출
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(mockConfirm).toHaveBeenCalled();
|
||||
expect(mockConfirm.mock.calls[0][0].props.message).toBe("로그인을 연장하시겠습니까?");
|
||||
});
|
||||
|
||||
it("3. 컨펌창에서 연장을 승인(Accept)하면 silentRefresh가 호출되고 재스케줄링되어야 함", async () => {
|
||||
let token = "mock-token-123";
|
||||
mockGetAccessToken.mockImplementation(() => token);
|
||||
mockGetRemainingMs.mockImplementation((t, buffer) => {
|
||||
return buffer === 0 ? 310000 : 10000;
|
||||
});
|
||||
|
||||
mockConfirm.mockResolvedValue(true); // 연장 승인
|
||||
mockSilentRefresh.mockImplementation(async () => {
|
||||
token = "new-mock-token-456"; // 토큰 갱신 모사
|
||||
});
|
||||
|
||||
renderHook(() =>
|
||||
useSessionExtendConfirm({
|
||||
enabled: true,
|
||||
getAccessToken: mockGetAccessToken,
|
||||
silentRefresh: mockSilentRefresh,
|
||||
declineSessionExtend: mockDeclineSessionExtend,
|
||||
})
|
||||
);
|
||||
|
||||
// 10초 대기하여 컨펌 활성화 및 연장 승인 처리 진행
|
||||
await vi.advanceTimersByTimeAsync(10000);
|
||||
|
||||
expect(mockSilentRefresh).toHaveBeenCalled();
|
||||
// 신규 발급된 토큰("new-mock-token-456")을 기반으로 getAccessToken이 재차 호출되었는지 확인
|
||||
expect(mockGetAccessToken).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("4. 컨펌창에서 연장을 거절(Decline)하면 declineSessionExtend가 실행되고, 해당 토큰에 대해 재확인하지 않아야 함", async () => {
|
||||
mockGetAccessToken.mockReturnValue("mock-token-123");
|
||||
mockGetRemainingMs.mockImplementation((t, buffer) => {
|
||||
return buffer === 0 ? 310000 : 10000;
|
||||
});
|
||||
mockConfirm.mockResolvedValue(false); // 연장 거절
|
||||
|
||||
const { rerender } = renderHook(() =>
|
||||
useSessionExtendConfirm({
|
||||
enabled: true,
|
||||
getAccessToken: mockGetAccessToken,
|
||||
silentRefresh: mockSilentRefresh,
|
||||
declineSessionExtend: mockDeclineSessionExtend,
|
||||
})
|
||||
);
|
||||
|
||||
// 10초 대기하여 거절 실행
|
||||
await vi.advanceTimersByTimeAsync(10000);
|
||||
|
||||
expect(mockDeclineSessionExtend).toHaveBeenCalled();
|
||||
expect(mockSilentRefresh).not.toHaveBeenCalled();
|
||||
|
||||
// 거절 이후 훅이 다시 실행되어도(혹은 상태가 업데이트되어 스케줄이 재호출되어도)
|
||||
// dismissedTokenRef에 동일 토큰이 설정되어 있으므로 더 이상 컨펌창이 호출되지 않아야 함
|
||||
mockConfirm.mockClear();
|
||||
rerender();
|
||||
await vi.advanceTimersByTimeAsync(10000);
|
||||
expect(mockConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("5. 이미 만료 버퍼 이하로 남은 경우, 대기 시간 없이 즉시 컨펌창이 나타나야 함", async () => {
|
||||
mockGetAccessToken.mockReturnValue("mock-token-123");
|
||||
// getRemainingMs(token, buffer) -> 0ms (이미 버퍼 시간 이내임)
|
||||
// getRemainingMs(token, 0) -> 50000ms (토큰 자체는 살아있음)
|
||||
mockGetRemainingMs.mockImplementation((t, buffer) => {
|
||||
return buffer === 0 ? 50000 : 0;
|
||||
});
|
||||
mockConfirm.mockResolvedValue(false); // 거절 처리하여 무한 루프 방지
|
||||
|
||||
renderHook(() =>
|
||||
useSessionExtendConfirm({
|
||||
enabled: true,
|
||||
getAccessToken: mockGetAccessToken,
|
||||
silentRefresh: mockSilentRefresh,
|
||||
declineSessionExtend: mockDeclineSessionExtend,
|
||||
})
|
||||
);
|
||||
|
||||
// 타이머 흐름 대기 없이 즉시 동기/비동기 마이크로태스크 단계에서 컨펌 실행
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(mockConfirm).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("6. 토큰이 아예 유효하지 않거나 이미 만료된 경우 컨펌창이 뜨지 않아야 함", () => {
|
||||
mockGetAccessToken.mockReturnValue("expired-token");
|
||||
// getRemainingMs(token, 0) -> 0ms (만료됨)
|
||||
mockGetRemainingMs.mockReturnValue(0);
|
||||
|
||||
renderHook(() =>
|
||||
useSessionExtendConfirm({
|
||||
enabled: true,
|
||||
getAccessToken: mockGetAccessToken,
|
||||
silentRefresh: mockSilentRefresh,
|
||||
declineSessionExtend: mockDeclineSessionExtend,
|
||||
})
|
||||
);
|
||||
|
||||
vi.advanceTimersByTime(10000);
|
||||
expect(mockConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("7. silentRefresh가 내부에서 실패를 처리(catch)하고 토큰을 제거하면 scheduleConfirm이 재실행되지 않아야 함", async () => {
|
||||
// 실제 auth-service.ts의 silentRefresh는 내부에서 catch하고 re-throw하지 않음
|
||||
// 대신 tokenManager.clearAuthToken()을 호출하여 토큰을 제거함
|
||||
let tokenCleared = false;
|
||||
mockGetAccessToken.mockImplementation(() =>
|
||||
tokenCleared ? undefined : "mock-token-123"
|
||||
);
|
||||
mockGetRemainingMs.mockImplementation((_t: string, buffer: number) => {
|
||||
return buffer === 0 ? 310000 : 10000;
|
||||
});
|
||||
mockConfirm.mockResolvedValue(true); // 연장 승인
|
||||
// 실제 silentRefresh처럼 내부에서 오류를 catch하고 토큰을 제거하는 동작 모사
|
||||
mockSilentRefresh.mockImplementation(async () => {
|
||||
tokenCleared = true; // clearAuthToken → getAccessToken이 undefined 반환
|
||||
});
|
||||
|
||||
renderHook(() =>
|
||||
useSessionExtendConfirm({
|
||||
enabled: true,
|
||||
getAccessToken: mockGetAccessToken,
|
||||
silentRefresh: mockSilentRefresh,
|
||||
declineSessionExtend: mockDeclineSessionExtend,
|
||||
})
|
||||
);
|
||||
|
||||
// 10초 대기하여 컨펌 활성화 및 승인 후 silentRefresh 호출
|
||||
await vi.advanceTimersByTimeAsync(10000);
|
||||
|
||||
expect(mockSilentRefresh).toHaveBeenCalled();
|
||||
// silentRefresh 후 토큰이 제거되어 scheduleConfirm에서 token이 없으므로 early return
|
||||
// 이후 추가 시간이 지나도 confirm이 다시 호출되지 않아야 함
|
||||
mockConfirm.mockClear();
|
||||
await vi.advanceTimersByTimeAsync(20000);
|
||||
expect(mockConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("8. silentRefresh 성공 후 scheduleConfirm 재호출을 통해 다음 컨펌이 차단 없이 스케줄링되어야 함", async () => {
|
||||
let callCount = 0;
|
||||
let currentToken = "mock-token-123";
|
||||
mockGetAccessToken.mockImplementation(() => currentToken);
|
||||
mockGetRemainingMs.mockImplementation((_t: string, buffer: number) => {
|
||||
return buffer === 0 ? 310000 : 5000; // 5초 뒤 컨펌 트리거
|
||||
});
|
||||
|
||||
// 첫 번째 호출은 승인, 두 번째 호출은 거절
|
||||
mockConfirm.mockImplementation(async () => {
|
||||
callCount++;
|
||||
return callCount === 1; // 첫 번째만 승인
|
||||
});
|
||||
// silentRefresh 성공 시 새 토큰 발급 모사
|
||||
mockSilentRefresh.mockImplementation(async () => {
|
||||
currentToken = "refreshed-token-456";
|
||||
});
|
||||
|
||||
renderHook(() =>
|
||||
useSessionExtendConfirm({
|
||||
enabled: true,
|
||||
getAccessToken: mockGetAccessToken,
|
||||
silentRefresh: mockSilentRefresh,
|
||||
declineSessionExtend: mockDeclineSessionExtend,
|
||||
})
|
||||
);
|
||||
|
||||
// 5초 대기하여 첫 번째 컨펌 활성화 → 승인 → silentRefresh → scheduleConfirm 재실행
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
expect(mockSilentRefresh).toHaveBeenCalledTimes(1);
|
||||
expect(callCount).toBe(1);
|
||||
|
||||
// scheduleConfirm 재호출 후 5초 뒤 두 번째 컨펌 트리거
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
|
||||
// isConfirmingRef가 올바르게 해제되어 두 번째 confirm이 호출됨
|
||||
expect(callCount).toBe(2);
|
||||
});
|
||||
|
||||
it("9. [취약점 검증] 다이얼로그 표시 중 토큰이 자연 만료되어도 확인 클릭 시 silentRefresh가 호출됨 (confirm 후 토큰 재검증 미수행)", async () => {
|
||||
// ⚠️ 이 테스트는 훅의 취약점을 문서화합니다.
|
||||
// showConfirm()은 confirm() 호출 전에만 토큰을 검증하고,
|
||||
// confirm() resolve 후에는 토큰 상태를 재검증하지 않습니다.
|
||||
let tokenExpired = false;
|
||||
mockGetAccessToken.mockReturnValue("mock-token-123");
|
||||
|
||||
mockGetRemainingMs.mockImplementation((_t: string, buffer: number) => {
|
||||
if (tokenExpired) {
|
||||
return 0; // 토큰 만료됨
|
||||
}
|
||||
return buffer === 0 ? 310000 : 0; // 즉시 컨펌 트리거, 토큰 유효
|
||||
});
|
||||
|
||||
// confirm이 호출되면, 응답 전에 토큰을 만료시킴
|
||||
mockConfirm.mockImplementation(async () => {
|
||||
tokenExpired = true; // 사용자가 응답하기 전에 토큰 만료
|
||||
return true; // "확인" 클릭
|
||||
});
|
||||
|
||||
renderHook(() =>
|
||||
useSessionExtendConfirm({
|
||||
enabled: true,
|
||||
getAccessToken: mockGetAccessToken,
|
||||
silentRefresh: mockSilentRefresh,
|
||||
declineSessionExtend: mockDeclineSessionExtend,
|
||||
})
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
|
||||
expect(mockConfirm).toHaveBeenCalled();
|
||||
// ⚠️ 훅이 confirm() 후 토큰을 재검증하지 않으므로 silentRefresh가 호출됨
|
||||
// 이것은 현재 코드의 의도적 단순화이거나 취약점임
|
||||
expect(mockSilentRefresh).toHaveBeenCalled();
|
||||
expect(mockDeclineSessionExtend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("10. [취약점 검증] 다이얼로그 대기 중 토큰이 undefined로 변경되어도 확인 클릭 시 silentRefresh가 호출됨 (confirm 후 토큰 재검증 미수행)", async () => {
|
||||
// ⚠️ 이 테스트는 훅의 취약점을 문서화합니다.
|
||||
// showConfirm() 시작 시점에 캡처한 currentToken으로만 검증하고,
|
||||
// confirm() resolve 후에는 getAccessToken()을 재호출하지 않습니다.
|
||||
let tokenRemoved = false;
|
||||
mockGetAccessToken.mockImplementation(() => {
|
||||
return tokenRemoved ? undefined : "mock-token-123";
|
||||
});
|
||||
|
||||
mockGetRemainingMs.mockImplementation((_t: string, buffer: number) => {
|
||||
return buffer === 0 ? 310000 : 0; // 즉시 컨펌 트리거
|
||||
});
|
||||
|
||||
// confirm이 호출되면, 응답 전에 토큰을 제거 (다른 탭에서 로그아웃 시뮬레이션)
|
||||
mockConfirm.mockImplementation(async () => {
|
||||
tokenRemoved = true;
|
||||
return true;
|
||||
});
|
||||
|
||||
renderHook(() =>
|
||||
useSessionExtendConfirm({
|
||||
enabled: true,
|
||||
getAccessToken: mockGetAccessToken,
|
||||
silentRefresh: mockSilentRefresh,
|
||||
declineSessionExtend: mockDeclineSessionExtend,
|
||||
})
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
|
||||
expect(mockConfirm).toHaveBeenCalled();
|
||||
// ⚠️ 훅이 confirm() 후 토큰을 재검증하지 않으므로 silentRefresh가 호출됨
|
||||
// 다른 탭에서 로그아웃되었지만 silentRefresh가 실행되어 의도치 않은 동작 발생 가능
|
||||
expect(mockSilentRefresh).toHaveBeenCalled();
|
||||
expect(mockDeclineSessionExtend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("11. confirm 대기 중 enabled가 토글되어도 isConfirmingRef 가드가 중복 호출을 차단해야 함", async () => {
|
||||
mockGetAccessToken.mockReturnValue("mock-token-123");
|
||||
mockGetRemainingMs.mockImplementation((_t: string, buffer: number) => {
|
||||
return buffer === 0 ? 310000 : 0; // 즉시 컨펌 트리거
|
||||
});
|
||||
|
||||
// confirm을 수동으로 지연 제어
|
||||
let resolveConfirm: ((value: boolean) => void) | null = null;
|
||||
mockConfirm.mockImplementation(() => {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
resolveConfirm = resolve;
|
||||
});
|
||||
});
|
||||
|
||||
let enabled = true;
|
||||
const { rerender } = renderHook(
|
||||
({ enabled: e }: { enabled: boolean }) =>
|
||||
useSessionExtendConfirm({
|
||||
enabled: e,
|
||||
getAccessToken: mockGetAccessToken,
|
||||
silentRefresh: mockSilentRefresh,
|
||||
declineSessionExtend: mockDeclineSessionExtend,
|
||||
}),
|
||||
{ initialProps: { enabled } }
|
||||
);
|
||||
|
||||
// showConfirm이 시작됨 (confirm 대기 중)
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(mockConfirm).toHaveBeenCalledTimes(1);
|
||||
|
||||
// enabled를 false → true로 토글하여 스케줄 재실행 유도
|
||||
rerender({ enabled: false });
|
||||
rerender({ enabled: true });
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
|
||||
// isConfirmingRef 가드로 인해 추가 confirm은 호출되지 않아야 함
|
||||
expect(mockConfirm).toHaveBeenCalledTimes(1);
|
||||
|
||||
// 기존 confirm을 resolve
|
||||
if (resolveConfirm) resolveConfirm(false);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
|
||||
expect(mockDeclineSessionExtend).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("12. dismiss 후 새 토큰으로 갱신되면 해당 토큰에 대해 컨펌이 재스케줄링되어야 함", async () => {
|
||||
let currentToken = "old-token-111";
|
||||
mockGetAccessToken.mockImplementation(() => currentToken);
|
||||
mockGetRemainingMs.mockImplementation((_t: string, buffer: number) => {
|
||||
return buffer === 0 ? 310000 : 0; // 즉시 컨펌 트리거
|
||||
});
|
||||
|
||||
// 첫 번째 호출은 거절
|
||||
mockConfirm.mockResolvedValueOnce(false);
|
||||
|
||||
let enabled = true;
|
||||
const { rerender } = renderHook(
|
||||
({ enabled: e }: { enabled: boolean }) =>
|
||||
useSessionExtendConfirm({
|
||||
enabled: e,
|
||||
getAccessToken: mockGetAccessToken,
|
||||
silentRefresh: mockSilentRefresh,
|
||||
declineSessionExtend: mockDeclineSessionExtend,
|
||||
}),
|
||||
{ initialProps: { enabled } }
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(mockConfirm).toHaveBeenCalledTimes(1);
|
||||
expect(mockDeclineSessionExtend).toHaveBeenCalledTimes(1);
|
||||
|
||||
// 거절 후 동일 토큰이면 재노출되지 않아야 함
|
||||
mockConfirm.mockClear();
|
||||
rerender({ enabled: false });
|
||||
rerender({ enabled: true });
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(mockConfirm).not.toHaveBeenCalled();
|
||||
|
||||
// 새 토큰으로 변경 (다른 탭에서 로그인 등)
|
||||
currentToken = "new-token-222";
|
||||
mockConfirm.mockResolvedValueOnce(false);
|
||||
|
||||
// enabled 토글로 useEffect 재실행 유도
|
||||
rerender({ enabled: false });
|
||||
rerender({ enabled: true });
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
|
||||
// 새 토큰이므로 dismissedTokenRef와 다르므로 컨펌이 다시 노출되어야 함
|
||||
expect(mockConfirm).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("13. message 파라미터를 생략하면 기본 메시지('로그인을 연장하시겠습니까?')로 컨펌이 호출되어야 함", async () => {
|
||||
mockGetAccessToken.mockReturnValue("mock-token-123");
|
||||
mockGetRemainingMs.mockImplementation((_t: string, buffer: number) => {
|
||||
return buffer === 0 ? 310000 : 0; // 즉시 컨펌 트리거
|
||||
});
|
||||
mockConfirm.mockResolvedValue(false);
|
||||
|
||||
renderHook(() =>
|
||||
useSessionExtendConfirm({
|
||||
enabled: true,
|
||||
getAccessToken: mockGetAccessToken,
|
||||
silentRefresh: mockSilentRefresh,
|
||||
declineSessionExtend: mockDeclineSessionExtend,
|
||||
// message 파라미터 의도적으로 생략
|
||||
})
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(mockConfirm).toHaveBeenCalled();
|
||||
expect(mockConfirm.mock.calls[0][0].props.message).toBe("로그인을 연장하시겠습니까?");
|
||||
});
|
||||
|
||||
it("14. 카운트다운 시간이 경과하여 SessionTimeoutMessage에서 onTimeout이 호출되면 declineSessionExtend와 onTimeout이 즉시 호출되어야 함", async () => {
|
||||
mockGetAccessToken.mockReturnValue("mock-token-123");
|
||||
mockGetRemainingMs.mockImplementation((_t: string, buffer: number) => {
|
||||
return buffer === 0 ? 300000 : 0; // 즉시 컨펌 트리거
|
||||
});
|
||||
|
||||
const mockOnTimeout = vi.fn();
|
||||
mockConfirm.mockImplementation(() => new Promise(() => {})); // confirm 대기 상태 유지
|
||||
|
||||
renderHook(() =>
|
||||
useSessionExtendConfirm({
|
||||
enabled: true,
|
||||
getAccessToken: mockGetAccessToken,
|
||||
silentRefresh: mockSilentRefresh,
|
||||
declineSessionExtend: mockDeclineSessionExtend,
|
||||
onTimeout: mockOnTimeout,
|
||||
})
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(mockConfirm).toHaveBeenCalled();
|
||||
|
||||
// confirm에 전달된 React Element 꺼내기
|
||||
const confirmElement = mockConfirm.mock.calls[0][0];
|
||||
expect(confirmElement.props.initialRemainingMs).toBe(300000);
|
||||
|
||||
// 타이머 만료 시뮬레이션: 컴포넌트의 onTimeout을 직접 트리거
|
||||
confirmElement.props.onTimeout();
|
||||
|
||||
// 연장 거부 및 타임아웃 콜백 정상 호출 검증
|
||||
expect(mockDeclineSessionExtend).toHaveBeenCalled();
|
||||
expect(mockOnTimeout).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("15. 1번 요건: 다른 탭에서 세션이 연장(storage 이벤트)되면 노출 중인 컨펌 다이얼로그가 자동으로 닫히고 재스케줄링되어야 함", async () => {
|
||||
mockGetAccessToken.mockReturnValue("mock-token-123");
|
||||
mockGetRemainingMs.mockImplementation((_t: string, buffer: number) => {
|
||||
return buffer === 0 ? 300000 : 0; // 즉시 컨펌 트리거
|
||||
});
|
||||
|
||||
renderHook(() =>
|
||||
useSessionExtendConfirm({
|
||||
enabled: true,
|
||||
getAccessToken: mockGetAccessToken,
|
||||
silentRefresh: mockSilentRefresh,
|
||||
declineSessionExtend: mockDeclineSessionExtend,
|
||||
})
|
||||
);
|
||||
|
||||
// 즉시 다이얼로그 노출 확인
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(mockConfirm).toHaveBeenCalled();
|
||||
|
||||
// 다른 탭의 연장 성공 시나리오 모사: storage 이벤트 발송
|
||||
const storageEvent = new StorageEvent("storage", {
|
||||
key: "session-extended-trigger",
|
||||
newValue: Date.now().toString(),
|
||||
});
|
||||
window.dispatchEvent(storageEvent);
|
||||
|
||||
// mockOnCancel이 호출되어 컨펌 창이 자동으로 닫혔는지 검증
|
||||
expect(mockOnCancel).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("16. 2번 요건: 브라우저 활성화(visibilitychange) 시점에 토큰이 이미 만료된 상태라면 즉시 타임아웃 처리가 수행되어야 함", async () => {
|
||||
let currentToken = "mock-token-123";
|
||||
mockGetAccessToken.mockImplementation(() => currentToken);
|
||||
|
||||
// 0 버퍼 기준, 포커스 복귀 시점에 이미 만료된 것으로 설정
|
||||
let tokenExpired = false;
|
||||
mockGetRemainingMs.mockImplementation((_t: string, buffer: number) => {
|
||||
if (tokenExpired) {
|
||||
return 0; // 만료됨
|
||||
}
|
||||
return buffer === 0 ? 300000 : 10000; // 아직 10초 뒤 팝업 예정
|
||||
});
|
||||
|
||||
const mockOnTimeout = vi.fn();
|
||||
|
||||
renderHook(() =>
|
||||
useSessionExtendConfirm({
|
||||
enabled: true,
|
||||
getAccessToken: mockGetAccessToken,
|
||||
silentRefresh: mockSilentRefresh,
|
||||
declineSessionExtend: mockDeclineSessionExtend,
|
||||
onTimeout: mockOnTimeout,
|
||||
})
|
||||
);
|
||||
|
||||
// 아직 10초 남았으므로 다이얼로그는 뜨지 않은 상태
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(mockConfirm).not.toHaveBeenCalled();
|
||||
|
||||
// 사용자가 다른 작업 후 복귀하여 탭 활성화 시뮬레이션 + 토큰 만료 상태
|
||||
tokenExpired = true;
|
||||
currentToken = "expired-token";
|
||||
|
||||
// visibilitychange 이벤트 트리거
|
||||
vi.spyOn(document, "visibilityState", "get").mockReturnValue("visible");
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
|
||||
// 즉시 타임아웃 및 세션 정지 처리되었는지 검증
|
||||
expect(mockDeclineSessionExtend).toHaveBeenCalled();
|
||||
expect(mockOnTimeout).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("17. 2번 요건: 브라우저 활성화 시점에 토큰이 아직 유효하다면 남은 시간 기준 스케줄 오차가 보정되어야 함", async () => {
|
||||
let currentToken = "mock-token-123";
|
||||
mockGetAccessToken.mockImplementation(() => currentToken);
|
||||
|
||||
// 최초에는 10초 뒤 팝업
|
||||
let confirmMs = 10000;
|
||||
mockGetRemainingMs.mockImplementation((_t: string, buffer: number) => {
|
||||
return buffer === 0 ? 310000 : confirmMs;
|
||||
});
|
||||
|
||||
renderHook(() =>
|
||||
useSessionExtendConfirm({
|
||||
enabled: true,
|
||||
getAccessToken: mockGetAccessToken,
|
||||
silentRefresh: mockSilentRefresh,
|
||||
declineSessionExtend: mockDeclineSessionExtend,
|
||||
})
|
||||
);
|
||||
|
||||
// 탭 복귀 시점에 남은 시간이 3초로 줄어듦 (시간 경과 보정)
|
||||
confirmMs = 3000;
|
||||
|
||||
// visibilitychange 이벤트 트리거
|
||||
vi.spyOn(document, "visibilityState", "get").mockReturnValue("visible");
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
|
||||
// 2초 뒤에는 팝업이 뜨지 않아야 함 (3초 남았으므로)
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
expect(mockConfirm).not.toHaveBeenCalled();
|
||||
|
||||
// 3초가 되는 시점(총 3초 경과)에 팝업이 안전하게 노출되어야 함
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(mockConfirm).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("18. 4번 요건: 다이얼로그 활성화 시 무자원 오디오 비프음 및 시스템 알림(Notification) 전송 함수가 호출되어야 함", async () => {
|
||||
mockGetAccessToken.mockReturnValue("mock-token-123");
|
||||
mockGetRemainingMs.mockImplementation((_t: string, buffer: number) => {
|
||||
return buffer === 0 ? 300000 : 0; // 즉시 컨펌 트리거
|
||||
});
|
||||
|
||||
renderHook(() =>
|
||||
useSessionExtendConfirm({
|
||||
enabled: true,
|
||||
getAccessToken: mockGetAccessToken,
|
||||
silentRefresh: mockSilentRefresh,
|
||||
declineSessionExtend: mockDeclineSessionExtend,
|
||||
})
|
||||
);
|
||||
|
||||
// 즉시 다이얼로그 호출
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(mockConfirm).toHaveBeenCalled();
|
||||
|
||||
// Web Audio API의 AudioContext가 인스턴스화되었는지 검증
|
||||
expect(mockAudioContext).toHaveBeenCalled();
|
||||
|
||||
// Notification API가 인스턴스화되었는지 검증
|
||||
expect(mockNotification).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
# cs 앱 단위(Unit) 및 통합(Integration) 테스트 구축 결과 보고서
|
||||
|
||||
cs 애플리케이션 내의 컴포넌트 로직 검증 및 렌더링 검사 기능을 수행할 수 있도록 **Vitest**와 **React Testing Library (RTL)** 설정을 완료하고 샘플 코드를 생성했습니다.
|
||||
|
||||
## 변경 및 추가 사항 요약
|
||||
|
||||
### 1. 의존성 설치 및 스크립트 추가
|
||||
* `apps/cs/package.json`의 개발용 라이브러리로 다음 패키지들을 설치했습니다:
|
||||
* `vitest`, `@testing-library/react`, `@testing-library/jest-dom`, `jsdom`, `@vitejs/plugin-react`, `vite-tsconfig-paths`
|
||||
* `scripts` 항목에 단위 테스트 실행 스크립트를 정의했습니다:
|
||||
* `"test:unit": "vitest run"` (1회 실행)
|
||||
* `"test:unit:watch": "vitest"` (실시간 Watch 모드)
|
||||
|
||||
### 2. 설정 파일 정의
|
||||
* **[vitest.config.ts](file:///Users/changwng/workspace.ona/frontend/apps/cs/vitest.config.ts)**:
|
||||
* React 플러그인 및 tsconfig alias 경로 파싱을 연결했습니다.
|
||||
* 테스트 환경으로 가상 브라우저 돔(jsdom)을 연결했습니다.
|
||||
* Playwright E2E 테스트 스펙(`*.spec.ts`)과 충돌이 나지 않도록, 단위 테스트 범위를 **`tests/unit/**/*.{test,spec}.{ts,tsx}`** 경로로 제한했습니다.
|
||||
* **[setup-tests.ts](file:///Users/changwng/workspace.ona/frontend/apps/cs/tests/setup-tests.ts)**:
|
||||
* DOM 전역 단언식인 `@testing-library/jest-dom`을 확장했습니다.
|
||||
|
||||
### 3. 샘플 및 페이지 테스트 코드 생성
|
||||
* **[sample-counter.tsx](file:///Users/changwng/workspace.ona/frontend/apps/cs/tests/unit/sample-counter.tsx)**:
|
||||
* 상태값 관리(useState) 및 이벤트 처리, 5 초과 시의 경고문 조건부 렌더링 등이 구현된 React 컴포넌트입니다.
|
||||
* **[sample-counter.test.tsx](file:///Users/changwng/workspace.ona/frontend/apps/cs/tests/unit/sample-counter.test.tsx)**:
|
||||
* **기본 렌더링 검사** (초기 값 및 제목 텍스트 검증)
|
||||
* **상태 변경 검사** (증가/감소 버튼 동작에 따른 값 업데이트 검증)
|
||||
* **통합 조건부 검사** (5 초과 시의 경고 노출 여부 검증)
|
||||
* **[login-page.test.tsx](file:///Users/changwng/workspace.ona/frontend/apps/cs/tests/unit/login-page.test.tsx)**:
|
||||
* 실제 로그인 컴포넌트(`LoginPage`)를 대상으로 한 단위/통합 테스트 스위트입니다.
|
||||
* **UI 요소 검증**: 타이틀 렌더링, ID/PW 입력 필드 및 로그인 버튼 존재 확인.
|
||||
* **로그인 실패 케이스**: `authService.login` API가 에러를 발생시킬 때 화면에 한글 에러 문구(`아이디와 비밀번호가 일치하지 않습니다.`)가 정상 표출되는지 검증.
|
||||
* **로그인 성공 케이스**: 정상적으로 로그인이 완료되었을 때 `window.location.href` 가 `"/dashboard"` 로 셋업되어 이동되는지 검증.
|
||||
* *Next.js `next/image` 컴포넌트 및 글로벌 `window.location`을 가상 돔 환경에 맞추어 적절하게 모킹 처리하였습니다.*
|
||||
* **[use-session-extend-confirm.test.ts](file:///Users/changwng/workspace.ona/frontend/apps/cs/tests/unit/use-session-extend-confirm.test.ts)**:
|
||||
* 세션 연장 팝업 제어용 공통 훅(`useSessionExtendConfirm`)에 대한 단위 테스트 스위트입니다.
|
||||
* **기본 타이머 대기 검증**: 토큰 만료 버퍼 도달 전까지는 컨펌창이 나타나지 않고, 해당 시점(예: 10초) 경과 후에 `confirm`이 정상 호출되는지 검증.
|
||||
* **연장 승인 시나리오**: 팝업에서 승인 시 `silentRefresh()`가 호출되고 갱신된 신규 토큰으로 재스케줄링되는지 검증.
|
||||
* **연장 거절 시나리오**: 팝업에서 거절 시 `declineSessionExtend()`가 호출되고, 해당 토큰에 대해서는 더 이상 팝업을 띄우지 않도록 무한 루프가 방지되는지 검증.
|
||||
* **즉시 노출 시나리오**: 이미 만료 버퍼 내인 경우 타이머 대기 없이 즉각적으로 컨펌창이 실행되는지 검증.
|
||||
* **미동작 예외 시나리오**: 비활성화 상태(`enabled: false`)이거나 이미 토큰이 완전히 만료된 경우 어떠한 스케줄도 잡지 않는지 검증.
|
||||
* *가상 타이머(`vi.useFakeTimers()`) 및 마이크로태스크 큐 제어를 결합하여 비동기 훅의 작동 방식을 정밀하고 빠르게 시뮬레이션하였습니다.*
|
||||
|
||||
### 4. Playwright E2E 테스트 코드 및 격리 스텁 뷰 추가
|
||||
* **[session-extend-confirm.spec.ts](file:///Users/changwng/workspace.ona/frontend/apps/cs/tests/session-extend-confirm.spec.ts)**:
|
||||
* 세션 연장 팝업의 만료 전 타이머 동작과 실제 화면 조작 및 훅 상호작용을 검증하는 브라우저 E2E 테스트 스펙입니다.
|
||||
* **승인 E2E 시나리오**: 만료 5분(버퍼 시간) 전 진입 시 리액트 HTML 모달 팝업이 노출되며, "확인" 버튼 클릭 시 리액트의 `silentRefresh()`가 트리거되고 모달이 닫히는 동작을 실 브라우저 상에서 검증.
|
||||
* **거절 E2E 시나리오**: 모달 팝업 노출 상태에서 "취소" 버튼 클릭 시 세션 연장 요청이 거절되고 모달이 닫히며, 이후 시간이 더 지나도 모달이 중복 재노출되지 않는 동작을 검증.
|
||||
* **[page.tsx](file:///Users/changwng/workspace.ona/frontend/apps/cs/app/(main)/(with-base-layout)/test-session/page.tsx)**:
|
||||
* 실시간 API와 계정/시간 몽키패칭 부작용으로 인한 브라우저 락(Lock) 및 로그인 튕김 현상을 완벽하게 예방하기 위해 구축한 **격리된 E2E 스텁 라우트**입니다.
|
||||
* `/test-session` 경로는 `ConfirmProvider`와 `ConfirmDialog` 래퍼가 마운트되는 기본 레이아웃 하위에서 작동하므로, 실 브라우저가 직접 마운트된 모달 HTML 요소를 안정적으로 감지하고 클릭할 수 있는 신뢰성 높은 환경을 제공합니다.
|
||||
|
||||
---
|
||||
|
||||
## 5. Playwright E2E 테스트 개념 및 실전 사용법 가이드
|
||||
|
||||
### 5.1 Playwright 핵심 개념 (What is Playwright?)
|
||||
|
||||
Playwright는 Microsoft에서 개발한 차세대 **End-to-End(E2E) 브라우저 자동화 및 테스트 프레임워크**입니다. 사용자 관점에서 브라우저를 실제로 조작하며 전체 애플리케이션 플로우(인증, 페이지 이동, 폼 입력, 서버 연동)를 검증합니다.
|
||||
|
||||
#### Vitest(단위 테스트) vs Playwright(E2E 테스트) 비교
|
||||
|
||||
| 비교 항목 | Vitest + React Testing Library | Playwright (E2E) |
|
||||
|---|---|---|
|
||||
| **실행 환경** | Node.js + 가상 DOM (jsdom) | **실제 브라우저** (Chromium, Firefox, WebKit) |
|
||||
| **속도** | 초고속 (밀리초 단위) | 상대적으로 무거움 (수 초 ~ 수십 초) |
|
||||
| **검증 대상** | 개별 함수, 훅, 단일 컴포넌트 렌더링 로직 | 브라우저 렌더링, 실제 네트워크, 전체 사용자 시나리오 |
|
||||
| **주요 역할** | 회귀 방지, 비즈니스 계산/매퍼 검증 | 최종 배포 전 시스템 통합 동작 보증 |
|
||||
|
||||
#### Playwright의 핵심 메커니즘
|
||||
1. **브라우저 컨텍스트 격리 (Browser Context Isolation)**:
|
||||
- 새 브라우저 프로세스를 매번 띄우는 대신, 가벼운 브라우저 컨텍스트를 생성하여 테스트 간 쿠키/스토리지 오염 없이 100% 독립된 환경을 제공합니다.
|
||||
2. **자동 대기 (Auto-waiting)**:
|
||||
- 요소를 클릭하거나 타이핑하기 전, 요소가 DOM에 부착(Attached), 표시(Visible), 활성화(Enabled), 애니메이션 정지 상태인지 자동으로 기다려 Flaky(불안정한) 테스트를 원천 차단합니다.
|
||||
3. **Web-first Assertions**:
|
||||
- `expect(locator).toBeVisible()`과 같은 단언문은 조건이 충족될 때까지 지정된 시간(기본 5초) 동안 자동으로 재시도합니다.
|
||||
|
||||
---
|
||||
|
||||
### 5.2 Playwright 실행 명령어 치트시트
|
||||
|
||||
`apps/cs` 폴더 또는 루트 디렉터리에서 실행합니다:
|
||||
|
||||
```bash
|
||||
# 1. 헤드리스 모드로 전체 E2E 테스트 실행 (CLI 모드)
|
||||
pnpm --filter cs test:playwright
|
||||
|
||||
# 2. 대화형 UI 모드 실행 (테스트 단계별 타임라인 탐색, 실시간 렌더링 시각화)
|
||||
pnpm --filter cs test:playwright:ui
|
||||
|
||||
# 3. 브라우저 화면을 직접 보면서 실행 (--headed)
|
||||
pnpm --filter cs exec playwright test --headed
|
||||
|
||||
# 4. 특정 테스트 파일만 지정 실행
|
||||
pnpm --filter cs exec playwright test tests/session-extend-confirm.spec.ts
|
||||
pnpm --filter cs exec playwright test tests/login.spec.ts
|
||||
|
||||
# 5. 특정 브라우저(엔진)만 지정 실행
|
||||
pnpm --filter cs exec playwright test --project=chromium
|
||||
|
||||
# 6. 코드젠 (Codegen): 브라우저 조작 시 테스트 코드를 자동 생성해주는 도구
|
||||
pnpm --filter cs exec playwright codegen http://localhost:3000
|
||||
|
||||
# 7. 마지막 테스트 실행 결과 HTML 리포트 열기
|
||||
pnpm --filter cs exec playwright show-report
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5.3 테스트 작성 표준 패턴 및 베스트 프랙티스
|
||||
|
||||
#### ① 권장 로케이터(Locator) 사용 순서
|
||||
Playwright는 사용자가 인지하는 방식을 반영하는 시맨틱 로케이터를 권장합니다:
|
||||
1. `page.getByRole('button', { name: '확인' })` (접근성 및 의미 기반 - 최우선 권장)
|
||||
2. `page.getByLabel('아이디')` (폼 레이블 기반)
|
||||
3. `page.getByPlaceholder('비밀번호를 입력하세요')`
|
||||
4. `page.getByText('저장되었습니다')`
|
||||
5. `page.locator('button.login_btn')` (CSS 선택자 - 최후의 수단)
|
||||
|
||||
#### ② 표준 테스트 구조 예시
|
||||
```typescript
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("로그인 및 대시보드 플로우", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// 1. 사전 진입
|
||||
await page.goto("/auth/login");
|
||||
});
|
||||
|
||||
test("아이디/비밀번호 입력 후 정상 대시보드로 이동해야 한다", async ({ page }) => {
|
||||
// 2. 사용자 조작 (자동 대기 내장)
|
||||
await page.getByPlaceholder("아이디").fill("admin");
|
||||
await page.getByPlaceholder("비밀번호").fill("secret123!");
|
||||
await page.getByRole("button", { name: "로그인" }).click();
|
||||
|
||||
// 3. Web-first 단언문
|
||||
await expect(page).toHaveURL(/\/dashboard/);
|
||||
await expect(page.getByText("관리자님 환영합니다")).toBeVisible();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5.4 프로젝트 설정 해설 (`playwright.config.ts`)
|
||||
|
||||
`apps/cs/playwright.config.ts`의 주요 설정 포인트:
|
||||
- `testIgnore: "**/unit/**"`: Vitest로 실행되어야 할 jsdom 단위 테스트 파일이 중복 실행되지 않도록 격리.
|
||||
- `workers: 1` & `fullyParallel: false`: 공유 세션(Redis, DB)을 사용하는 통합 인증 테스트 간의 충돌 방지.
|
||||
- `webServer`: 테스트 실행 전 개발 서버(`http://localhost:3000`)가 구동되어 있지 않으면 자동으로 구동하여 테스트 완료 후 종료.
|
||||
- `trace: "retain-on-failure"`: 테스트가 실패한 경우에만 DOM 스냅샷, 콘솔 로그, 네트워크 요청이 담긴 Trace 파일을 저장하여 디버깅을 지원.
|
||||
|
||||
---
|
||||
|
||||
## 6. 검증 결과
|
||||
|
||||
### 1. Vitest 단위 테스트 결과
|
||||
터미널에서 `pnpm --filter cs run test:unit` 명령을 실행하여 모든 단위 테스트(총 13개)가 정상 통과함을 검증하였습니다:
|
||||
|
||||
```bash
|
||||
RUN v4.1.10 /Users/changwng/workspace.ona/frontend/apps/cs
|
||||
|
||||
✓ tests/unit/use-session-extend-confirm.test.ts (6 tests) 16ms
|
||||
✓ tests/unit/sample-counter.test.tsx (4 tests) 94ms
|
||||
✓ tests/unit/login-page.test.tsx (3 tests) 152ms
|
||||
|
||||
Test Files 3 passed (3)
|
||||
Tests 13 passed (13)
|
||||
Start at 14:21:52
|
||||
Duration 819ms (transform 217ms, setup 109ms, import 367ms, tests 257ms, environment 1.04s)
|
||||
```
|
||||
속도 면에서 아주 빠르고 안정적으로 가상 돔 유닛 테스트 환경이 준비되었습니다.
|
||||
|
||||
### 2. Playwright E2E 테스트 결과
|
||||
터미널에서 `pnpm --filter cs exec playwright test tests/session-extend-confirm.spec.ts` 명령을 실행하여, `chromium`, `firefox`, `webkit` 3대 주요 실 브라우저 환경에서 연장 성공 시 다음 팝업 억제 검증을 포함한 세션 연장 E2E 테스트(총 6개 스위트)가 100% 정상 통과함을 확인하였습니다:
|
||||
|
||||
```bash
|
||||
Running 6 tests using 1 worker
|
||||
|
||||
✓ 1 [chromium] › tests/session-extend-confirm.spec.ts:8:7 › 세션 연장 확인 팝업 (useSessionExtendConfirm) E2E 테스트 › 1. 세션 만료 5분(버퍼 시간) 전 도달 시 컨펌 다이얼로그가 자동 노출되고, 승인 시 연장 처리가 수행되어야 함 (11.6s)
|
||||
✓ 2 [chromium] › tests/session-extend-confirm.spec.ts:42:7 › 세션 연장 확인 팝업 (useSessionExtendConfirm) E2E 테스트 › 2. 컨펌 다이얼로그를 거절(Dismiss)한 경우 세션 연장 처리가 취소되고 재차 팝업이 노출되지 않아야 함 (9.8s)
|
||||
✓ 3 [firefox] › tests/session-extend-confirm.spec.ts:8:7 › 세션 연장 확인 팝업 (useSessionExtendConfirm) E2E 테스트 › 1. 세션 만료 5분(버퍼 시간) 전 도달 시 컨펌 다이얼로그가 자동 노출되고, 승인 시 연장 처리가 수행되어야 함 (16.4s)
|
||||
✓ 4 [firefox] › tests/session-extend-confirm.spec.ts:42:7 › 세션 연장 확인 팝업 (useSessionExtendConfirm) E2E 테스트 › 2. 컨펌 다이얼로그를 거절(Dismiss)한 경우 세션 연장 처리가 취소되고 재차 팝업이 노출되지 않아야 함 (15.3s)
|
||||
✓ 5 [webkit] › tests/session-extend-confirm.spec.ts:8:7 › 세션 연장 확인 팝업 (useSessionExtendConfirm) E2E 테스트 › 1. 세션 만료 5분(버퍼 시간) 전 도달 시 컨펌 다이얼로그가 자동 노출되고, 승인 시 연장 처리가 수행되어야 함 (11.1s)
|
||||
✓ 6 [webkit] › tests/session-extend-confirm.spec.ts:42:7 › 세션 연장 확인 팝업 (useSessionExtendConfirm) E2E 테스트 › 2. 컨펌 다이얼로그를 거절(Dismiss)한 경우 세션 연장 처리가 취소되고 재차 팝업이 노출되지 않아야 함 (9.9s)
|
||||
|
||||
6 passed (1.3m)
|
||||
```
|
||||
이로써 단위/통합 테스트 환경과 연장 처리 성공 후 중복 팝업 억제 시나리오가 포함된 E2E 테스트 환경 구축 임무가 성공적으로 완결되었습니다.
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tsconfigPaths()],
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
globals: true,
|
||||
setupFiles: "./tests/setup-tests.ts",
|
||||
include: ["tests/unit/**/*.{test,spec}.{ts,tsx}"],
|
||||
},
|
||||
});
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3001",
|
||||
"dev": "next dev --turbo -p 3001",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
pnpm exec playwright test tests/multi-device-auth.spec.ts --project=chromium
|
||||
|
||||
|
||||
cd frontend/apps/cs
|
||||
pnpm test:playwright
|
||||
|
||||
|
||||
💡 요약 및 권장 가이드 device-id의 유지 처리
|
||||
로그아웃 시 파기 대상: 인증 토큰 쿠키 (access-token, refresh-token)
|
||||
로그아웃 시 유지 대상: 기기 식별자 쿠키 및 로컬 스토리지 데이터 (device-id)
|
||||
따라서 현재 구성하신 대로 쿠키/로컬 스토리지 상의 device-id는 로그아웃 시 지우지 않고 브라우저에 영속적으로 남겨두는 설계가 가장 안전하고 올바른 아키텍처입니다.
|
||||
|
||||
|
||||
[WebServer]
|
||||
[WebServer] > cs@0.1.0 dev /Users/changwng/workspace.ona/frontend/apps/cs
|
||||
[WebServer] > next dev
|
||||
[WebServer]
|
||||
[WebServer] ⚠ Warning: Next.js inferred your workspace root, but it may not be correct.
|
||||
[WebServer] We detected multiple lockfiles and selected the directory of /Users/changwng/package-lock.json as the root directory.
|
||||
[WebServer] To silence this warning, set `outputFileTracingRoot` in your Next.js config, or consider removing one of the lockfiles if it's not needed.
|
||||
[WebServer] See https://nextjs.org/docs/app/api-reference/config/next-config-js/output#caveats for more information.
|
||||
[WebServer] Detected additional lockfiles:
|
||||
[WebServer] * /Users/changwng/workspace.ona/frontend/pnpm-lock.yaml
|
||||
[WebServer]
|
||||
[WebServer] ▲ Next.js 15.5.18
|
||||
[WebServer] - Local: http://localhost:3000
|
||||
[WebServer] - Network: http://192.168.0.250:3000
|
||||
[WebServer] - Environments: .env.local
|
||||
[WebServer] - Experiments (use with caution):
|
||||
[WebServer] · proxyTimeout: 600000
|
||||
[WebServer]
|
||||
[WebServer] ✓ Starting...
|
||||
[WebServer] ✓ Ready in 1733ms
|
||||
[WebServer] ✓ Compiled /middleware in 114ms (161 modules)
|
||||
[WebServer] ○ Compiling /dashboard ...
|
||||
[WebServer] ✓ Compiled /dashboard in 4.3s (6804 modules)
|
||||
|
||||
Running 14 tests using 1 worker
|
||||
|
||||
[WebServer] GET /dashboard 200 in 4913ms
|
||||
[WebServer] ○ Compiling /api/proxy/[...url] ...
|
||||
[WebServer] ✓ Compiled /api/proxy/[...url] in 10.2s (7070 modules)
|
||||
[WebServer] [BFF Proxy Error] Backend returned status 401 for user-service/login
|
||||
[WebServer] [BFF Proxy Error Body] {"status":400,"message":"해당 사용자가 없습니다.","code":"B001"}
|
||||
[WebServer] POST /api/proxy/user-service/login 401 in 10766ms
|
||||
✘ 1 [chromium] › tests/multi-device-auth.spec.ts:64:7 › 다중 디바이스 인증 & Refresh Token Rotation (RTR) 테스트 › 1. 로그인 성공 시 httpOnly refresh-token 쿠키 설정 (10.8s)
|
||||
- 2 [chromium] › tests/multi-device-auth.spec.ts:81:7 › 다중 디바이스 인증 & Refresh Token Rotation (RTR) 테스트 › 2. PC/모바일/태블릿 독립 refresh-token 발급
|
||||
- 3 [chromium] › tests/multi-device-auth.spec.ts:115:7 › 다중 디바이스 인증 & Refresh Token Rotation (RTR) 테스트 › 3. Silent Refresh RTR - 새 토큰 교체
|
||||
- 4 [chromium] › tests/multi-device-auth.spec.ts:138:7 › 다중 디바이스 인증 & Refresh Token Rotation (RTR) 테스트 › 4. PC RTR 갱신 후 모바일 세션 독립 유지
|
||||
- 5 [chromium] › tests/multi-device-auth.spec.ts:161:7 › 다중 디바이스 인증 & Refresh Token Rotation (RTR) 테스트 › 5. Replay Attack - 구 토큰 재주입 → 400
|
||||
- 6 [chromium] › tests/multi-device-auth.spec.ts:195:7 › 다중 디바이스 인증 & Refresh Token Rotation (RTR) 테스트 › 6. refresh-token 없이 silent refresh → 401
|
||||
- 7 [chromium] › tests/multi-device-auth.spec.ts:207:7 › 다중 디바이스 인증 & Refresh Token Rotation (RTR) 테스트 › 7. 잘못된 비밀번호 → 401
|
||||
- 8 [chromium] › tests/multi-device-auth.spec.ts:217:7 › 다중 디바이스 인증 & Refresh Token Rotation (RTR) 테스트 › 8. 존재하지 않는 ID → 401
|
||||
- 9 [chromium] › tests/multi-device-auth.spec.ts:227:7 › 다중 디바이스 인증 & Refresh Token Rotation (RTR) 테스트 › 9. 로그아웃 후 쿠키 전체 삭제
|
||||
- 10 [chromium] › tests/multi-device-auth.spec.ts:250:7 › 다중 디바이스 인증 & Refresh Token Rotation (RTR) 테스트 › 10. PC 로그아웃 후 모바일 세션 독립 유지
|
||||
- 11 [chromium] › tests/multi-device-auth.spec.ts:272:7 › 다중 디바이스 인증 & Refresh Token Rotation (RTR) 테스트 › 11. UI 로그인 후 httpOnly 쿠키 E2E 확인
|
||||
- 12 [chromium] › tests/multi-device-auth.spec.ts:306:7 › 다중 디바이스 인증 & Refresh Token Rotation (RTR) 테스트 › 12. 동일 계정 3개 브라우저 동시 로그인
|
||||
- 13 [chromium] › tests/multi-device-auth.spec.ts:341:7 › 다중 디바이스 인증 & Refresh Token Rotation (RTR) 테스트 › 13. sameSite=Strict 쿠키 속성 확인
|
||||
- 14 [chromium] › tests/multi-device-auth.spec.ts:353:7 › 다중 디바이스 인증 & Refresh Token Rotation (RTR) 테스트 › 14. 연속 3회 RTR 갱신 - 4개 고유 토큰
|
||||
|
||||
|
||||
1) [chromium] › tests/multi-device-auth.spec.ts:64:7 › 다중 디바이스 인증 & Refresh Token Rotation (RTR) 테스트 › 1. 로그인 성공 시 httpOnly refresh-token 쿠키 설정
|
||||
|
||||
Error: expect(received).toBe(expected) // Object.is equality
|
||||
|
||||
Expected: 200
|
||||
Received: 401
|
||||
|
||||
67 | headers: { "X-Device-Id": DEVICE_PC },
|
||||
68 | });
|
||||
> 69 | expect(response.status()).toBe(200);
|
||||
| ^
|
||||
70 |
|
||||
71 | const headers = response.headersArray();
|
||||
72 | const refreshToken = getCookieValue(headers, "refresh-token");
|
||||
at /Users/changwng/workspace.ona/frontend/apps/cs/tests/multi-device-auth.spec.ts:69:31
|
||||
|
||||
Error Context: test-results/multi-device-auth-다중-디바이스--4f452-ttpOnly-refresh-token-쿠키-설정-chromium/error-context.md
|
||||
|
||||
attachment #2: trace (application/zip) ─────────────────────────────────────────────────────────
|
||||
test-results/multi-device-auth-다중-디바이스--4f452-ttpOnly-refresh-token-쿠키-설정-chromium/trace.zip
|
||||
Usage:
|
||||
|
||||
pnpm exec playwright show-trace test-results/multi-device-auth-다중-디바이스--4f452-ttpOnly-refresh-token-쿠키-설정-chromium/trace.zip
|
||||
@@ -0,0 +1,154 @@
|
||||
# Multi-Domain JWT 토큰 공유 분석
|
||||
|
||||
## 현재 구조 진단
|
||||
|
||||
### 도메인 구성
|
||||
| 앱 | 도메인 | 포트(로컬) |
|
||||
|----|--------|-----------|
|
||||
| cs | `cs.onasys.co.kr` | 3000 |
|
||||
| as | `as.onasys.co.kr` | 3003 |
|
||||
| backend | 공통 1개 | 8000 |
|
||||
|
||||
### 핵심 문제
|
||||
|
||||
브라우저 쿠키는 **Same-Origin Policy** 적용: `cs.onasys.co.kr`에서 설정한 쿠키는 `as.onasys.co.kr`에서 **읽을 수 없음**.
|
||||
|
||||
현재 두 앱 모두 `NEXT_PUBLIC_COOKIE_DOMAIN` 환경변수가 **설정되지 않음** → 쿠키 `domain` 속성 없음 → 각 앱이 자기 도메인에만 쿠키 저장.
|
||||
|
||||
### 코드 흐름 분석
|
||||
|
||||
```
|
||||
authConfig.cookieDomain = process.env.NEXT_PUBLIC_COOKIE_DOMAIN (현재 undefined)
|
||||
↓
|
||||
token-manager.ts: getCookieDomain() → undefined 반환
|
||||
↓
|
||||
setCookie(key, value, { domain: undefined, ... }) ← domain 없이 저장
|
||||
↓
|
||||
결과: cs.onasys.co.kr 쿠키 ≠ as.onasys.co.kr 쿠키 (공유 불가)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 해결 방법: Parent Domain Cookie
|
||||
|
||||
### 원리
|
||||
쿠키 `Domain` 속성을 **부모 도메인**으로 설정하면 모든 서브도메인이 공유:
|
||||
|
||||
```
|
||||
Domain=.onasys.co.kr
|
||||
→ cs.onasys.co.kr ✅ 읽기/쓰기 가능
|
||||
→ as.onasys.co.kr ✅ 읽기/쓰기 가능
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 구현 방법
|
||||
|
||||
### Step 1: 환경변수 추가
|
||||
|
||||
**apps/cs/.env.production** (또는 `.env.local` 배포용)
|
||||
```env
|
||||
NEXT_PUBLIC_COOKIE_DOMAIN=.onasys.co.kr
|
||||
```
|
||||
|
||||
**apps/as/.env.production**
|
||||
```env
|
||||
NEXT_PUBLIC_COOKIE_DOMAIN=.onasys.co.kr
|
||||
```
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 반드시 앞에 `.` (점)을 붙여야 서브도메인 전체에 적용됩니다.
|
||||
|
||||
### Step 2: 코드 변경 불필요
|
||||
|
||||
`token-manager.ts`의 `getCookieDomain()`이 이미 `config.cookieDomain`을 사용하고 있으므로, 환경변수만 설정하면 됩니다.
|
||||
|
||||
```ts
|
||||
// packages/common/auth/token-manager.ts (현재 코드)
|
||||
const getCookieDomain = (): string | undefined => {
|
||||
if (typeof window === "undefined") return undefined;
|
||||
if (config.cookieDomain) return config.cookieDomain; // ← 이미 준비됨
|
||||
return undefined;
|
||||
};
|
||||
```
|
||||
|
||||
### Step 3: 백엔드 CORS 확인
|
||||
|
||||
백엔드가 `credentials: true`와 함께 올바른 `Access-Control-Allow-Origin`을 반환하는지 확인:
|
||||
|
||||
```
|
||||
Access-Control-Allow-Origin: https://cs.onasys.co.kr (또는 as.onasys.co.kr)
|
||||
Access-Control-Allow-Credentials: true
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> `Access-Control-Allow-Origin: *` 와 `credentials: true`는 동시에 사용 불가.
|
||||
> 명시적 도메인 또는 동적 오리진 허용 필요.
|
||||
|
||||
---
|
||||
|
||||
## SameSite 주의사항
|
||||
|
||||
현재 토큰 매니저 설정:
|
||||
```ts
|
||||
setCookie(key, value, {
|
||||
secure: isHttps,
|
||||
sameSite: "lax", // ← 서브도메인 간 이동 시 쿠키 전송됨 (OK)
|
||||
domain,
|
||||
path: "/",
|
||||
});
|
||||
```
|
||||
|
||||
- `sameSite: "lax"` → 서브도메인 간 네비게이션에서 쿠키 전송 **가능** ✅
|
||||
- `sameSite: "strict"` → 다른 도메인에서의 요청에 쿠키 미전송 ❌
|
||||
- `sameSite: "none"` + `secure: true` → 크로스사이트 iframe 등에서도 전송 (필요 시)
|
||||
|
||||
배포 환경 HTTPS 사용 시 `secure: true` 자동 적용되므로 문제 없음.
|
||||
|
||||
---
|
||||
|
||||
## 세션 공유 흐름 (적용 후)
|
||||
|
||||
```
|
||||
사용자가 cs.onasys.co.kr 에서 로그인
|
||||
↓
|
||||
setCookie("access-token", jwt, { domain: ".onasys.co.kr" })
|
||||
↓
|
||||
브라우저: .onasys.co.kr 쿠키 저장
|
||||
↓
|
||||
사용자가 as.onasys.co.kr 으로 이동
|
||||
↓
|
||||
브라우저가 자동으로 ".onasys.co.kr" 쿠키 전송
|
||||
↓
|
||||
as 앱의 미들웨어: req.cookies.get("access-token") ✅ 정상 읽힘
|
||||
↓
|
||||
백엔드 API 호출 시 Authorization 헤더에 토큰 포함
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 추가 고려사항
|
||||
|
||||
### 로그아웃 처리
|
||||
한 앱에서 로그아웃 시 **도메인 지정 쿠키 삭제** 필요:
|
||||
```ts
|
||||
// removeToken 에서 domain 이 자동 적용됨 (현재 코드 OK)
|
||||
removeCookie(key, { domain: ".onasys.co.kr", path: "/" });
|
||||
```
|
||||
|
||||
### 토큰 갱신 (Refresh Token)
|
||||
`refresh-token` 쿠키도 같은 `domain` 설정 적용 → 두 앱 모두 silent refresh 가능.
|
||||
|
||||
### 로컬 개발 환경
|
||||
로컬에서는 `localhost`가 서브도메인을 지원하지 않으므로 별도 처리 불필요 (`.env.local`에는 설정하지 않음).
|
||||
|
||||
---
|
||||
|
||||
## 최소 변경 요약
|
||||
|
||||
| 위치 | 변경 내용 |
|
||||
|------|----------|
|
||||
| `apps/cs/.env.production` | `NEXT_PUBLIC_COOKIE_DOMAIN=.onasys.co.kr` 추가 |
|
||||
| `apps/as/.env.production` | `NEXT_PUBLIC_COOKIE_DOMAIN=.onasys.co.kr` 추가 |
|
||||
| 소스 코드 | **변경 없음** (이미 cookieDomain 지원 구현됨) |
|
||||
| 백엔드 | CORS `Access-Control-Allow-Origin` 명시적 도메인 설정 확인 |
|
||||
@@ -0,0 +1,84 @@
|
||||
# AS 프로젝트 기술 스택 분석 (Tech Stack)
|
||||
|
||||
본 문서는 `as` (Administrative Support / 전자결재 및 공공행정 지원 시스템) 애플리케이션의 핵심 프로그래밍 스택과 아키텍처 구조를 분석하여 정리한 문서입니다. 본 프로젝트는 Next.js 15와 FSD(Feature Sliced Design) 아키텍처를 도입하여 복잡한 비즈니스 로직을 격리하고 모노레포 환경에서 유연하게 패키지를 재사용하고 있습니다.
|
||||
|
||||
---
|
||||
|
||||
## 1. Core Framework & Runtime
|
||||
|
||||
* **Next.js 15.5.18**:
|
||||
* **App Router** 구조를 기본으로 채택하여 파일 기반 라우팅, 서버 컴포넌트(RSC), 미들웨어 등을 적극적으로 활용합니다.
|
||||
* `src/pages`와 `app/` 라우터를 조합하여 Next.js의 진입점(Layout, Page)은 최소화하고 비즈니스 모듈은 FSD 아키텍처 계층으로 격리하는 하이브리드 구성을 취하고 있습니다.
|
||||
* **React 19 & TypeScript**:
|
||||
* 최신 React의 강력한 타입 지원 기능을 활용하여 컴포넌트와 비즈니스 데이터의 정적 타입을 보장합니다.
|
||||
* **pnpm Monorepo**:
|
||||
* `workspace:*` 의존성 참조 형식을 사용하여 동일 레포지토리 내의 공통 모듈(`@common/auth`, `@common/components`, `@common/network`, `@common/assets`)을 패키지 형태로 안전하게 참조합니다.
|
||||
|
||||
---
|
||||
|
||||
## 2. Styling (CSS & Design System)
|
||||
|
||||
* **Tailwind CSS (v3.4)**:
|
||||
* 유틸리티 퍼스트 CSS 프레임워크를 기반으로 하며, `postcss` 및 `autoprefixer`를 활용하여 스타일을 구성합니다.
|
||||
* **Class Variance Authority (CVA)**:
|
||||
* 컴포넌트의 Variants(버튼 크기, 활성/비활성 색상, 디자인 테마 등)를 선언적이고 타입 안전하게 관리하기 위해 사용됩니다.
|
||||
* **Lucide React**:
|
||||
* 통합적이고 일관된 아이콘 팩으로 화면 요소를 설계할 때 기본 아이콘 라이브러리로 활용됩니다.
|
||||
|
||||
---
|
||||
|
||||
## 3. State Management (상태 관리)
|
||||
|
||||
애플리케이션 내의 상태는 성격에 따라 3가지 레이어로 명확히 구분되어 관리됩니다.
|
||||
|
||||
### ① 전역 서버 상태 (Global Server State)
|
||||
* **TanStack Query v5 (React Query)**:
|
||||
* 서버 데이터 fetch, cache, sync, update 등을 담당합니다.
|
||||
* 커스텀 훅 패턴을 활용하여 비즈니스 데이터 처리부와 UI 컴포넌트를 분리합니다.
|
||||
* `ReactQueryDevtools`를 탑재하여 개발 단계에서 쿼리 캐시 상태를 실시간 모니터링합니다.
|
||||
|
||||
### ② 전역 클라이언트 상태 (Global Client State)
|
||||
* **Zustand (Vanilla)**:
|
||||
* `zustand/vanilla`의 `createStore`를 활용하여 글로벌 상태(사용자 정보, 알림창, 사이드바 메뉴 등)를 관리합니다.
|
||||
* **Next.js SSR 친화적 상태 공유 패턴**:
|
||||
Next.js의 서버 사이드 렌더링(SSR) 시 싱글톤 상태 오염 문제를 해결하기 위해, React Context (`StoreProvider`, `StoreContext`)와 `useRef`를 조합하여 요청 단위별 독립된 스토어 인스턴스를 생성하고 훅(`useUserStore` 등)을 통해 구독합니다.
|
||||
|
||||
### ③ 폼 상태 관리 및 검증 (Form State)
|
||||
* **React Hook Form & Zod**:
|
||||
* 결재 및 행정 신청 폼이 다수 존재하는 시스템 특성상, 입력 폼의 상태 변화를 비제어 컴포넌트 기반으로 최적화하여 렌더링 성능을 개선합니다.
|
||||
* `zod` 스키마 빌더를 이용해 폼 유효성 검증 규칙을 직관적으로 선언하고 `@hookform/resolvers`로 바인딩합니다.
|
||||
|
||||
---
|
||||
|
||||
## 4. Components & Libraries
|
||||
|
||||
* **Radix UI**:
|
||||
* `@radix-ui/react-tooltip` 등 마크업 구조와 접근성이 보장된 Headless UI 라이브러리를 차용하여 스타일 종속성 없이 접근성 높은 컴포넌트를 구성합니다.
|
||||
* **TanStack Table v8 (React Table)**:
|
||||
* 다양한 필터링, 정렬, 페이지네이션 요구사항이 존재하는 목록 화면을 처리하기 위해 사용되는 Headless Table 라이브러리입니다.
|
||||
* **dnd-kit (`@dnd-kit/core`, `@dnd-kit/sortable`)**:
|
||||
* 결재선 지정 화면 등에서 마우스 및 터치 인터랙션을 이용한 행 정렬(Drag & Drop)을 매끄럽게 처리하기 위해 도입되었습니다.
|
||||
|
||||
---
|
||||
|
||||
## 5. Network & Utils
|
||||
|
||||
* **Axios**:
|
||||
* HTTP 클라이언트 라이브러리로, 인터셉터(Interceptor)를 통해 공통 인증 헤더 관리, 토큰 재발급, 에러 공통 처리를 수행합니다.
|
||||
* **Tus JS Client**:
|
||||
* 대용량 및 첨부 파일 업로드 시 네트워크 단절 상황에 대응할 수 있도록 Resumable(이어올리기) 프로토콜인 **Tus Protocol**을 사용하여 안정적인 업로드를 지원합니다.
|
||||
* **Day.js**:
|
||||
* 표준화된 시간 포맷팅 및 날짜 연산을 가볍고 빠르게 처리합니다.
|
||||
* **JS Cookie**:
|
||||
* 클라이언트 브라우저의 쿠키 설정 및 조회에 활용됩니다.
|
||||
|
||||
---
|
||||
|
||||
## 6. Monitoring & Linting (품질 관리)
|
||||
|
||||
* **OpenTelemetry**:
|
||||
* 클라이언트 및 서버 측의 네트워크 지연 시간, 주요 사용자 인터랙션 트레이싱을 위해 APM 수집 어댑터가 구현되어 있습니다 (`instrumentation-client.ts`, `instrumentation.ts`).
|
||||
* **Sentry (`@sentry/nextjs`)**:
|
||||
* 프로덕션 환경에서의 실시간 에러 추적 및 분석을 담당합니다.
|
||||
* **Steiger**:
|
||||
* FSD 아키텍처 패턴을 물리적인 파일 경로 분석을 통해 규제하는 정적 분석 도구입니다. 레이어 간 비정상적인 의존성(예: 하위 레이어가 상위 레이어를 참조하는 역참조 등)을 빌드타임/린트 타임에 강력하게 통제합니다.
|
||||
@@ -0,0 +1,167 @@
|
||||
# AS 프로젝트 교육 자료: 초급 과정 (Beginner Course)
|
||||
|
||||
**대상**: React 및 Next.js 기초를 이해하고 있으며, AS 프로젝트에 신규 참여하여 UI 컴포넌트 및 기본 화면 단위를 구현해야 하는 개발자
|
||||
**목표**: FSD 아키텍처 규칙을 준수하며 Tailwind CSS와 CVA를 사용해 UI를 개발하고, React Hook Form과 Zod로 폼 컴포넌트를 설계할 수 있다.
|
||||
|
||||
---
|
||||
|
||||
## 1. Feature-Sliced Design (FSD) 아키텍처 기초
|
||||
|
||||
FSD는 기능 기반의 디렉토리 설계 규칙으로, 코드의 유지보수성과 확장성을 극대화하기 위해 탄생한 표준 스펙입니다.
|
||||
|
||||
### ① FSD의 3단계 계층 구조
|
||||
1. **Layers (레이어)**: 가장 큰 단위로, 최상단에서 최하단까지 엄격한 단방향 의존성 규칙을 가집니다.
|
||||
* `app`: 애플리케이션의 설정, Providers, 글로벌 스타일을 정의합니다. (의존성 가장 높음)
|
||||
* `pages`: 실제 라우팅 단위 화면을 구성합니다.
|
||||
* `widgets`: 독립적인 대형 UI 블록(예: 헤더, 복합 사이드바, 특정 복합 그리드 등)을 정의합니다.
|
||||
* `features`: 사용자의 행동 및 비즈니스 기능(예: `auth-login`, `search-box`, `add-comment`)을 다룹니다.
|
||||
* `entities`: 도메인 단위의 비즈니스 개념(예: `user`, `approval-line`, `budget`)과 그에 대한 단순 조회 UI, API, 상태를 정의합니다.
|
||||
* `shared`: 특정 도메인에 종속되지 않는 범용 재사용 기술 자원(UI Kit, API Client, Util 라이브러리)을 모아둡니다. (의존성 가장 낮음)
|
||||
2. **Slices (슬라이스)**: 도메인에 따른 수직적 분리 단위입니다 (예: `entities/user`, `features/auth-login`).
|
||||
3. **Segments (세그먼트)**: 기술적 역할에 따른 물리적 파일 분류입니다.
|
||||
* `ui/`: React 컴포넌트 파일들
|
||||
* `model/`: 스토어 상태, 데이터 타입, 스키마 등
|
||||
* `api/`: 서버 통신용 Axios 함수 및 쿼리 훅
|
||||
* `lib/`: 내부 Helper 유틸 함수
|
||||
|
||||
### ② FSD 수직적 수평적 import 규칙 (중요!)
|
||||
* **규칙 1 (수직적 규칙)**: 레이어는 자신보다 **하위 레이어만 import**할 수 있습니다. 상위 레이어를 import하는 것은 엄격히 금지됩니다. (예: `features`는 `entities`를 import할 수 있으나, `entities`가 `features`나 `widgets`를 import할 수 없음)
|
||||
* **규칙 2 (수평적 규칙)**: 동일한 레이어 내부의 다른 슬라이스끼리는 **서로 직접 import할 수 없습니다**. (예: `features/auth-login`에서 `features/post-create`를 import할 수 없음. 이 경우에는 상위 레이어인 `pages`나 `widgets`에서 두 슬라이스를 조합해야 함)
|
||||
* **규칙 3 (Public API)**: 각 슬라이스 외부에서는 반드시 슬라이스 루트의 `index.ts` (Public API)를 거쳐서만 모듈을 가져와야 합니다. 슬라이스 내부 폴더 구조에 직접 접근하는 행위(`import ... from '@/features/auth/ui/LoginForm'`)는 린트 에러(`Steiger` 경고)를 발생시킵니다.
|
||||
|
||||
---
|
||||
|
||||
## 2. Tailwind CSS & Class Variance Authority (CVA) 실무
|
||||
|
||||
AS 프로젝트는 유틸리티 클래스 중복을 최소화하고, 컴포넌트의 디자인 변형(Variants)을 안전하게 관리하기 위해 **CVA**를 사용합니다.
|
||||
|
||||
### CVA 활용 예시 (`shared/ui/button.tsx`)
|
||||
```typescript
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/shared/lib"; // clsx와 tailwind-merge 조합 유틸
|
||||
|
||||
export const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-slate-400 disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-slate-900 text-white hover:bg-slate-800",
|
||||
destructive: "bg-red-500 text-white hover:bg-red-600",
|
||||
outline: "border border-slate-200 bg-transparent hover:bg-slate-100",
|
||||
ghost: "hover:bg-slate-100 hover:text-slate-950",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {}
|
||||
|
||||
export function Button({ className, variant, size, ...props }: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. React Hook Form & Zod를 활용한 폼 검증
|
||||
|
||||
AS 프로젝트는 성능 최적화를 위해 불필요한 리렌더링을 방지하는 **React Hook Form**과 타입 안전한 스키마 검증기 **Zod**를 페어로 사용합니다.
|
||||
|
||||
### ① 스키마 정의 (`lib/regist-schema.ts` 또는 `model/schema.ts`)
|
||||
```typescript
|
||||
import { z } from "zod";
|
||||
|
||||
export const registrationSchema = z.zodObject({
|
||||
bizNm: z.string().min(2, "사업명은 필수 항목이며 2글자 이상이어야 합니다."),
|
||||
bgtBizCd: z.string().min(1, "확정예산사업번호를 입력해주세요."),
|
||||
bgtAplyYr: z.string().regex(/^\d{4}$/, "예산신청연도는 4자리 숫자여야 합니다."),
|
||||
email: z.string().email("올바른 이메일 형식이 아닙니다."),
|
||||
privacyConsent: z.boolean().refine((val) => val === true, {
|
||||
message: "개인정보 제공에 동의하셔야 합니다.",
|
||||
}),
|
||||
});
|
||||
|
||||
export type RegistrationFormValues = z.infer<typeof registrationSchema>;
|
||||
```
|
||||
|
||||
### ② 컴포넌트 구현 및 바인딩
|
||||
```typescript
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { registrationSchema, RegistrationFormValues } from "./schema";
|
||||
|
||||
export function RegistrationForm() {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<RegistrationFormValues>({
|
||||
resolver: zodResolver(registrationSchema),
|
||||
defaultValues: {
|
||||
bizNm: "",
|
||||
bgtBizCd: "",
|
||||
bgtAplyYr: new Date().getFullYear().toString(),
|
||||
email: "",
|
||||
privacyConsent: false,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: RegistrationFormValues) => {
|
||||
console.log("Submit Data:", data);
|
||||
// API 호출 로직은 중급 과정에서 설명
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4 p-4 border rounded-lg">
|
||||
<div>
|
||||
<label className="block text-sm font-medium">사업명</label>
|
||||
<input {...register("bizNm")} className="mt-1 block w-full border rounded p-2" />
|
||||
{errors.bizNm && <p className="text-red-500 text-xs mt-1">{errors.bizNm.message}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium">예산사업번호</label>
|
||||
<input {...register("bgtBizCd")} className="mt-1 block w-full border rounded p-2" />
|
||||
{errors.bgtBizCd && <p className="text-red-500 text-xs mt-1">{errors.bgtBizCd.message}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center space-x-2">
|
||||
<input type="checkbox" {...register("privacyConsent")} />
|
||||
<span className="text-sm">개인정보 수집 동의</span>
|
||||
</label>
|
||||
{errors.privacyConsent && <p className="text-red-500 text-xs mt-1">{errors.privacyConsent.message}</p>}
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={isSubmitting} className="bg-blue-600 text-white p-2 rounded">
|
||||
등록하기
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 초급 실습 과제
|
||||
|
||||
### 과제 내용
|
||||
* `src/shared/ui` 하위에 CVA를 활용하여 커스텀 뱃지(Badge) 컴포넌트를 설계하세요. (예: `variant` 옵션으로 `'success'`, `'warning'`, `'error'`, `'info'` 상태를 가짐)
|
||||
* 사용자 정보 입력 폼 스키마를 `zod`로 설계하고, `react-hook-form`으로 가상의 회원가입 컴포넌트를 만들어보세요. (이메일 검증, 비밀번호 8자 이상 검증 필수)
|
||||
* **제약 조건**: 해당 모듈들을 생성할 때 FSD 레이어 규칙(`shared`와 `features` 레이어 배치 위치)을 준수하고, Steiger 린팅 규칙에 어긋나지 않도록 `index.ts`를 통해 Public API 형태로 외부에 노출하세요.
|
||||
@@ -0,0 +1,192 @@
|
||||
# AS 프로젝트 교육 자료: 중급 과정 (Intermediate Course)
|
||||
|
||||
**대상**: FSD 아키텍처와 마크업 설계에 대한 기본 이해를 갖춘 개발자
|
||||
**목표**: Next.js App Router 연동 방식을 이해하고, TanStack Query로 API 통신을 수행하며, Zustand와 Context를 결합하여 SSR 환경에 최적화된 상태 관리를 다룰 수 있다.
|
||||
|
||||
---
|
||||
|
||||
## 1. Next.js App Router와 FSD 라우팅 패턴
|
||||
|
||||
AS 프로젝트는 Next.js의 `app/` 라우터를 페이지 진입점으로 최소화하고, 모든 비즈니스 로직과 복잡한 뷰(View) 컴포넌트는 `src/pages` 레이어에 담아 관리합니다.
|
||||
|
||||
### 라우팅 흐름 예시
|
||||
* **Next.js 경로 정의**: `app/(with-base-layout)/fnrs-cnsltn/page.tsx`
|
||||
* **구현 파일**:
|
||||
```typescript
|
||||
// app/(with-base-layout)/fnrs-cnsltn/page.tsx
|
||||
export { FnrsListPage as default } from "@/pages/fnrs-cnsltn";
|
||||
```
|
||||
* **실제 뷰 컴포넌트**: `src/pages/fnrs-cnsltn/ui/fnrs-list-page.tsx`
|
||||
* Next.js `app` 폴더 내부에 로직을 구현하지 않고, 단순하게 `src/pages` 슬라이스를 re-export함으로써 라우팅 구조와 비즈니스 모듈의 종속성을 완벽히 분리합니다.
|
||||
|
||||
---
|
||||
|
||||
## 2. SSR 환경에 안전한 Zustand 상태 관리 패턴
|
||||
|
||||
Next.js와 같은 SSR 프레임워크에서 Zustand 스토어를 단순 싱글톤 객체(`export const useStore = create(...)`)로 정의하면, 서버 측 메모리 상에서 **여러 사용자 간의 전역 상태 오염(Store State Leak)**이 발생합니다.
|
||||
이를 극부하기 위해 AS 프로젝트는 **Vanilla Store + React Context + useRef** 방식을 채택하고 있습니다.
|
||||
|
||||
### ① 스토어 정의 (`src/shared/store/user-store.ts`)
|
||||
```typescript
|
||||
import { createStore } from "zustand/vanilla";
|
||||
|
||||
export interface IUserInfo {
|
||||
userId: string;
|
||||
userName: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export type UserState = {
|
||||
userInfo: IUserInfo | null;
|
||||
};
|
||||
|
||||
export type UserActions = {
|
||||
setUserInfo: (userInfo: IUserInfo | null) => void;
|
||||
};
|
||||
|
||||
export type UserStore = UserState & UserActions;
|
||||
|
||||
export const defaultInitState: UserState = {
|
||||
userInfo: null,
|
||||
};
|
||||
|
||||
export const createUserStore = (initState: UserState = defaultInitState) => {
|
||||
return createStore<UserStore>()((set) => ({
|
||||
...initState,
|
||||
setUserInfo: (userInfo) => set(() => ({ userInfo })),
|
||||
}));
|
||||
};
|
||||
```
|
||||
|
||||
### ② Context 및 Provider 정의 (`src/shared/providers/store-provider.tsx`)
|
||||
```typescript
|
||||
"use client";
|
||||
|
||||
import { createContext, useRef } from "react";
|
||||
import { createUserStore } from "../store/user-store";
|
||||
|
||||
type StoreContextType = {
|
||||
userStore: ReturnType<typeof createUserStore>;
|
||||
};
|
||||
|
||||
export const StoreContext = createContext<StoreContextType | undefined>(undefined);
|
||||
|
||||
export const StoreProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
// useRef를 사용하여 렌더링마다 스토어가 재성성되는 것을 방지하고 독립적인 인스턴스를 유지
|
||||
const storeRef = useRef<StoreContextType | null>(null);
|
||||
if (storeRef.current === null) {
|
||||
storeRef.current = {
|
||||
userStore: createUserStore(),
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<StoreContext.Provider value={storeRef.current}>
|
||||
{children}
|
||||
</StoreContext.Provider>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### ③ Custom Hook을 통한 사용 (`src/shared/lib/use-user-store.ts`)
|
||||
```typescript
|
||||
import { useContext } from "react";
|
||||
import { useStore } from "zustand";
|
||||
import { StoreContext } from "../providers/store-provider";
|
||||
import { UserStore } from "../store/user-store";
|
||||
|
||||
export function useUserStore<T>(selector: (store: UserStore) => T): T {
|
||||
const storeContext = useContext(StoreContext);
|
||||
if (!storeContext) {
|
||||
throw new Error("useUserStore는 반드시 StoreProvider 내부에서 사용되어야 합니다.");
|
||||
}
|
||||
return useStore(storeContext.userStore, selector);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. TanStack Query v5를 활용한 서버 상태(Server State) 관리
|
||||
|
||||
AS 프로젝트는 비즈니스 도메인의 API와 데이터를 처리하기 위해 React Query(TanStack Query)를 적극 활용합니다.
|
||||
|
||||
### ① API 호출 정의 (`entities/fnrs-cnsltn/api/fetcher.ts`)
|
||||
```typescript
|
||||
import { client } from "@/shared/api";
|
||||
import { FnrsDetailDto } from "../model/types";
|
||||
|
||||
// client는 Axios 기반으로 공통 Interceptor가 설정된 인스턴스입니다.
|
||||
export const fetchFnrsDetail = async (docNo: string): Promise<FnrsDetailDto> => {
|
||||
const response = await client.get(`/as-fnrs-service/api/v1/fnrs-cnsltn/${docNo}`);
|
||||
return response.data;
|
||||
};
|
||||
```
|
||||
|
||||
### ② Custom Query Hook 구현 (`entities/fnrs-cnsltn/api/use-fnrs-detail.ts`)
|
||||
```typescript
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchFnrsDetail } from "./fetcher";
|
||||
|
||||
export const fnrsKeys = {
|
||||
all: ["fnrs"] as const,
|
||||
detail: (docNo: string) => [...fnrsKeys.all, "detail", docNo] as const,
|
||||
};
|
||||
|
||||
export function useFnrsDetail(docNo: string) {
|
||||
return useQuery({
|
||||
queryKey: fnrsKeys.detail(docNo),
|
||||
queryFn: () => fetchFnrsDetail(docNo),
|
||||
enabled: !!docNo,
|
||||
staleTime: 5 * 60 * 1000, // 5분 캐싱 유지
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### ③ 컴포넌트에서의 데이터 연동 (`pages/fnrs-cnsltn/ui/detail-page.tsx`)
|
||||
```typescript
|
||||
"use client";
|
||||
|
||||
import { useFnrsDetail } from "@/entities/fnrs-cnsltn";
|
||||
import { Skeleton } from "@/shared/ui/skeleton";
|
||||
|
||||
interface DetailPageProps {
|
||||
params: { id: string };
|
||||
}
|
||||
|
||||
export function FnrsDetailPage({ params }: DetailPageProps) {
|
||||
const { data: detailData, isLoading, isError } = useFnrsDetail(params.id);
|
||||
|
||||
if (isLoading) return <Skeleton className="h-64 w-full" />;
|
||||
if (isError || !detailData) return <div>상세 데이터를 가져오는 중 에러가 발생했습니다.</div>;
|
||||
|
||||
return (
|
||||
<div className="p-6 bg-white shadow rounded-lg">
|
||||
<h2 className="text-xl font-bold mb-4">{detailData.bizNm}</h2>
|
||||
<p className="text-gray-600">예산 연도: {detailData.bgtAplyYr}년</p>
|
||||
<p className="mt-2">{detailData.bizCn}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Monorepo 공통 모듈 (`@common/*`) 사용법
|
||||
|
||||
AS 프로젝트는 모노레포 내부의 다른 앱들과 로직을 공유하기 위해 다음과 같은 패키지를 사용합니다:
|
||||
|
||||
* **`@common/network`**:
|
||||
* 공통 Axios 클라이언트 래퍼(`Client.getInstance()`)를 가져와 공통 헤더 및 환경 변수를 설정하여 프로젝트 API 클라이언트(`client.ts`)를 초기화합니다.
|
||||
* **`@common/auth`**:
|
||||
* 로그인 토큰 검증, 세션 연장 기능 등 전역 보안 절차를 제어합니다.
|
||||
* **`@common/components`**:
|
||||
* 워크스페이스 공통 UI 컴포넌트(글로벌 레이아웃 프레임, 특수 템플릿 등)를 가져와 사용합니다.
|
||||
|
||||
---
|
||||
|
||||
## 5. 중급 실습 과제
|
||||
|
||||
### 과제 내용
|
||||
* `entities/fnrs-cnsltn` 아래에 특정 재원협의 목록을 가져오는 `useFnrsList` Query Hook을 작성하세요.
|
||||
* 가져온 목록 데이터를 바탕으로 `shared/ui/table` 컴포넌트(혹은 TanStack Table)를 활용해 목록 그리드를 출력하는 `pages/fnrs-cnsltn/ui/fnrs-list-page.tsx` 페이지를 만드세요.
|
||||
* **제약 조건**: 검색 필터링(사업명 검색, 예산 신청 연도)을 React Query의 `queryKey`와 동기화하여 검색 조건 변경 시 자동으로 목록이 갱신되도록 하세요.
|
||||
@@ -0,0 +1,205 @@
|
||||
# AS 프로젝트 교육 자료: 고급 과정 (Advanced Course)
|
||||
|
||||
**대상**: 대규모 컴포넌트 설계와 복잡한 클라이언트 인터랙션을 처리해야 하는 시니어급 개발자
|
||||
**목표**: FSD 아키텍처 의존성 린팅 툴(Steiger)을 이해하고, `@dnd-kit` 기반의 결재선 드래그 앤 드롭을 구현하며, `tus-js-client`를 통한 대용량 이어올리기 및 OpenTelemetry 분산 트레이싱을 연동할 수 있다.
|
||||
|
||||
---
|
||||
|
||||
## 1. Steiger & FSD 아키텍처 아키텍처 규칙 검증
|
||||
|
||||
**Steiger**는 FSD 아키텍처 규범을 실시간으로 감시하는 파일 구조 정적 분석 린터입니다.
|
||||
|
||||
### ① FSD 핵심 위반 검출 및 조치법
|
||||
* **비정상 의존 관계 (Violation of Layer Hierarchy)**:
|
||||
* *상황*: `entities/user` 내부 컴포넌트에서 `features/auth-login` 컴포넌트를 import하려는 시도.
|
||||
* *해결*: 하위 레이어는 상위 레이어에 대해 절대 알 수 없어야 합니다. 해당 컴포넌트의 책임을 상위 레이어(`pages` 혹은 `widgets`)로 격상시키거나 하위 레이어의 성격에 맞게 분리하세요.
|
||||
* **Public API 규칙 위반 (Slicing Boundaries Bypass)**:
|
||||
* *상황*: `import { UserProfile } from "@/entities/user/ui/profile"` 형태로 직접 파일 경로로 접근하는 경우.
|
||||
* *해결*: `entities/user/index.ts`에서 `export { UserProfile } from "./ui/profile"`을 선언하고, 사용하는 곳에서는 `import { UserProfile } from "@/entities/user"` 로 Public API만 접근하도록 변경해야 합니다.
|
||||
|
||||
---
|
||||
|
||||
## 2. `@dnd-kit`을 활용한 결재선 관리 화면 및 DnD 구현
|
||||
|
||||
결재선 관리(Approval Line Management) 화면은 드래그 앤 드롭으로 결재자 순서를 변경하며, 그리드 상에서 인라인 폼 편집을 처리하는 매우 복잡한 뷰입니다.
|
||||
|
||||
### ① 핵심 설계 요구사항
|
||||
1. **단일 행 편집 잠금**: 신규 결재선 추가 혹은 특정 행 수정 시, 해당 행을 제외한 다른 행의 입력 및 드래그 인터랙션은 모두 비활성화되어야 합니다 (`editingRowKey` 상태 관리).
|
||||
2. **드래그 정렬**: `@dnd-kit`을 사용하여 결재자 순서 변경을 UI에 즉시 반영한 뒤, **[결재선 저장]** 버튼을 통해 전체 순서를 일괄 저장합니다.
|
||||
|
||||
### ② 결재선 DnD 구현 예제 (`ui/components/sortable-apln-table.tsx`)
|
||||
```typescript
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { SortableAplnRow } from "./sortable-apln-row";
|
||||
|
||||
export interface IAplnMember {
|
||||
aplnId: string;
|
||||
atrzSeqNo: number;
|
||||
atrzSeComCd: string; // 검토자, 승인자 구분
|
||||
aprvrUuid: string;
|
||||
aprvrNm: string;
|
||||
}
|
||||
|
||||
export function SortableAplnTable() {
|
||||
const [items, setItems] = useState<IAplnMember[]>([
|
||||
{ aplnId: "1", atrzSeqNo: 1, atrzSeComCd: "RVW", aprvrUuid: "user-01", aprvrNm: "김검토" },
|
||||
{ aplnId: "2", atrzSeqNo: 2, atrzSeComCd: "APR", aprvrUuid: "user-02", aprvrNm: "이승인" },
|
||||
]);
|
||||
|
||||
// 편집 중인 행 키 (null이 아니면 드래그 및 타 행 편집 금지)
|
||||
const [editingRowKey, setEditingRowKey] = useState<string | null>(null);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8, // 클릭 클릭 중 미세한 움직임으로 드래그 활성화 방지
|
||||
},
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
);
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
// 편집 중일 때는 드래그를 차단합니다.
|
||||
if (editingRowKey) return;
|
||||
|
||||
const { active, over } = event;
|
||||
if (over && active.id !== over.id) {
|
||||
setItems((prevItems) => {
|
||||
const oldIndex = prevItems.findIndex((item) => item.aplnId === active.id);
|
||||
const newIndex = prevItems.findIndex((item) => item.aplnId === over.id);
|
||||
const reordered = arrayMove(prevItems, oldIndex, newIndex);
|
||||
|
||||
// 정렬 후 시퀀스 넘버(atrzSeqNo)를 다시 인덱스 기준으로 갱신합니다.
|
||||
return reordered.map((item, index) => ({
|
||||
...item,
|
||||
atrzSeqNo: index + 1,
|
||||
}));
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="w-10">정렬</th>
|
||||
<th>순서</th>
|
||||
<th>구분</th>
|
||||
<th>결재자</th>
|
||||
<th>관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
<SortableContext
|
||||
items={items.map((item) => item.aplnId)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<SortableAplnRow
|
||||
key={item.aplnId}
|
||||
item={item}
|
||||
isEditing={editingRowKey === item.aplnId}
|
||||
isDisableDrag={!!editingRowKey} // 편집 모드가 켜져 있으면 전체 드래그 비활성화
|
||||
onEditStart={() => setEditingRowKey(item.aplnId)}
|
||||
onEditCancel={() => setEditingRowKey(null)}
|
||||
onEditSave={(updated) => {
|
||||
setItems((prev) =>
|
||||
prev.map((p) => (p.aplnId === item.aplnId ? { ...p, ...updated } : p))
|
||||
);
|
||||
setEditingRowKey(null);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</tbody>
|
||||
</table>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 대용량 파일 분할 업로드 (Tus Protocol)
|
||||
|
||||
일반적인 HTTP POST 업로드는 대용량 파일 전송 중 대역폭 변화나 일시적인 네트워크 끊김이 발생할 경우 처음부터 다시 업로드해야 하는 심각한 취약점이 있습니다.
|
||||
AS 프로젝트는 이어올리기가 가능한 **Tus Protocol** 규격의 파일 업로더인 `tus-js-client`를 활용합니다.
|
||||
|
||||
```typescript
|
||||
import * as tus from "tus-js-client";
|
||||
|
||||
export function uploadFileWithTus(file: File, onProgress: (pct: number) => void) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const upload = new tus.Upload(file, {
|
||||
endpoint: `${process.env.NEXT_PUBLIC_API_URL}/files/upload`,
|
||||
retryDelays: [0, 1000, 3000, 5000], // 연결 실패 시 재시도 간격
|
||||
metadata: {
|
||||
filename: file.name,
|
||||
filetype: file.type,
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Tus upload failed:", error);
|
||||
reject(error);
|
||||
},
|
||||
onProgress: (bytesSent, bytesTotal) => {
|
||||
const percentage = Math.round((bytesSent / bytesTotal) * 100);
|
||||
onProgress(percentage);
|
||||
},
|
||||
onSuccess: () => {
|
||||
// 서버에서 부여받은 최종 파일 접근 경로 또는 고유 ID 획득
|
||||
console.log("Upload complete:", upload.url);
|
||||
resolve(upload.url || "");
|
||||
},
|
||||
});
|
||||
|
||||
// 업로드 개시
|
||||
upload.start();
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. OpenTelemetry를 활용한 분산 트레이싱 (APM)
|
||||
|
||||
AS 프로젝트에는 애플리케이션의 엔드 투 엔드(End-to-End) 요청 성능 분석과 응답 속도 지연 병목 지점 감시를 위해 **OpenTelemetry(OTel)**가 연동되어 있습니다.
|
||||
|
||||
* `instrumentation.ts` : Next.js 서버 사이드 API 요청 및 서버 렌더링에 대한 Span을 생성하고 익스포터로 전송합니다.
|
||||
* `instrumentation-client.ts` : 브라우저 클라이언트 사이드 환경에서 API 호출(`fetch`, `xhr`) 및 주요 사용자 Interaction에 대해 브라우저 환경 성능 Span을 계측합니다.
|
||||
|
||||
### OTel 동작 핵심 컨셉
|
||||
1. **Context Propagation**: 클라이언트에서 서버로 HTTP 요청을 보낼 때 Trace Context Header(`traceparent`)를 실어 보냄으로써, 브라우저에서의 동작과 백엔드 API 서비스에서의 병목 지점 간의 연관 관계를 하나로 통합해서 관찰할 수 있게 합니다.
|
||||
|
||||
---
|
||||
|
||||
## 5. 고급 실습 과제
|
||||
|
||||
### 과제 내용
|
||||
* `ui/components/sortable-apln-table.tsx`를 확장하여, 새로운 결재선을 리스트 맨 밑에 추가하는 **[행 추가]** 기능을 구현하세요.
|
||||
* **행 추가 시** 새로 생성된 행은 즉시 **편집 상태**(`editingRowKey = 'new-temp'`)여야 하며, 사용자가 저장을 누르면 REST API(`POST /api/v1/elapr-apln`)를 호출하고 임시 키 대신 서버에서 생성된 진짜 ID로 대체되어야 합니다.
|
||||
* 사용자가 입력을 다 채우지 않고 취소를 누르면, 해당 추가되었던 행이 목록에서 사라져야 합니다.
|
||||
* **제약 조건**: 모든 API 통신은 TanStack Query의 Mutation(`useMutation`) 및 캐시 무효화(`invalidateQueries`)를 통해 완전한 동기화 흐름을 가지도록 개발하세요.
|
||||
@@ -0,0 +1,68 @@
|
||||
# Steiger 사용법 및 실제 구성 가이드
|
||||
|
||||
본 문서는 `as` 프로젝트에서 FSD(Feature Sliced Design) 아키텍처 의존성 및 구조 검증을 위해 사용되는 **Steiger** 린터의 실제 구성과 사용법을 다룹니다.
|
||||
|
||||
---
|
||||
|
||||
## 1. Steiger 개요
|
||||
* **Steiger**는 FSD 아키텍처의 철학(단방향 의존성 흐름, Public API 접근 등)이 코드베이스에서 물리적으로 올바르게 지켜지고 있는지를 검증하는 FSD 전용 정적 분석 린터입니다.
|
||||
* 일반적인 코드 포맷터나 문법 린터(ESLint, Prettier)와 달리, **파일 시스템의 폴더 구조와 파일 간의 import 관계**를 추적하여 아키텍처 위반 사항을 진단합니다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 프로젝트 내 실제 구성 및 실행 방법
|
||||
|
||||
### ① 실행 스크립트 정의
|
||||
`apps/as/package.json` 파일에 다음과 같이 린트 스크립트가 내장되어 있습니다.
|
||||
|
||||
```json
|
||||
"scripts": {
|
||||
"lint:fsd": "steiger ."
|
||||
}
|
||||
```
|
||||
|
||||
### ② 린트 실행 명령어
|
||||
터미널에서 아래 명령을 실행하여 `as` 애플리케이션의 FSD 구조를 검사합니다.
|
||||
|
||||
```bash
|
||||
# 모노레포 루트 디렉토리 기준
|
||||
pnpm --filter as lint:fsd
|
||||
|
||||
# 또는 apps/as 디렉토리 기준
|
||||
pnpm run lint:fsd
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Steiger의 기본 동작 원리
|
||||
|
||||
별도의 설정 파일(`steiger.config.ts`)이 프로젝트에 생성되어 있지 않은 경우, Steiger는 아래와 같이 동작합니다.
|
||||
|
||||
1. **기본 FSD 규칙셋(Standard Ruleset) 자동 채택**: `@feature-sliced/steiger-plugin`의 권장(recommended) 규칙들을 메모리에 로드하여 적용합니다.
|
||||
2. **프로젝트 루트 탐색**: 실행 인자로 전달된 경로(여기서는 현재 경로인 `.`) 아래의 모든 디렉토리를 순회하며 `src/` 내부의 레이어 구조를 감지합니다.
|
||||
3. **의존성 트리 분석**: `shared` ➔ `entities` ➔ `features` ➔ `widgets` ➔ `pages` ➔ `app` 순으로 올라가는 레이어 위계를 확인하여, 역참조(예: `entities`가 `features`를 가져옴)가 발생하는 위치를 찾아냅니다.
|
||||
|
||||
---
|
||||
|
||||
## 4. Steiger가 감시하는 주요 FSD 아키텍처 규칙
|
||||
|
||||
Steiger가 실시간으로 코드를 분석하며 잡아내는 대표적인 아키텍처 오류들은 다음과 같습니다.
|
||||
|
||||
| 규칙명 (Rule ID) | 설명 및 예시 |
|
||||
| :--- | :--- |
|
||||
| **`fsd/no-public-api-bypass`** | 슬라이스의 Public API(`index.ts`)를 통하지 않고 내부 파일 경로를 직접 임포트할 때 감지합니다.<br>➔ *오류*: `import { Form } from '@/features/auth/ui/form'`<br>➔ *해결*: `import { Form } from '@/features/auth'` |
|
||||
| **`fsd/no-forbidden-imports`** | 상위 레이어가 하위 레이어의 모듈을 가져와 상호 의존하거나 순환 참조가 발생할 때 감지합니다.<br>➔ *오류*: `entities/user` 슬라이스 안에서 `widgets/header`를 참조하는 경우 |
|
||||
| **`fsd/no-segmentless-slices`** | 슬라이스 디렉토리 바로 아래에 세그먼트(`ui`, `model`, `api` 등) 없이 코드 파일이 직접 위치할 때 감지합니다. |
|
||||
|
||||
---
|
||||
|
||||
## 5. 알려진 팁 및 문제 해결 (tsconfck 관련 오류)
|
||||
|
||||
일부 환경에서 `steiger .`를 실행할 때, 내부 컴파일러 설정 검사기인 `tsconfck` 라이브러리가 `.eslintrc.json`과 같은 설정 파일 내의 `"extends": "next/core-web-vitals"` 속성을 TypeScript 설정 파일의 `extends`로 오인하여 모듈 해석 오류(`MODULE_NOT_FOUND`)를 유발하는 경우가 발생할 수 있습니다.
|
||||
|
||||
이와 같은 환경적 이슈가 발생할 경우 아래의 우회법을 참고하십시오.
|
||||
|
||||
1. **빌드 파이프라인에서 파일 범위 한정**:
|
||||
Steiger 검사 시 설정 파일들이 불필요하게 스캔 대상에 포함되지 않도록 빌드 린트 파이프라인 설정을 조정하거나, 대상 소스 코드 경로(`src`)를 명확하게 지정하여 탐색 범위를 제한합니다.
|
||||
2. **steiger 설정 파일 커스텀**:
|
||||
프로젝트의 필요에 따라 `steiger.config.ts` 파일과 해당 플러그인을 프로젝트 devDependencies에 명시적으로 추가하여 `ignores` 옵션으로 `.eslintrc.json` 등 설정 파싱 에러를 유발하는 대상을 제외하도록 설정하여 운영합니다.
|
||||
@@ -0,0 +1,27 @@
|
||||
# apps/st/src/shared/config/env.ts 참고
|
||||
NEXT_PUBLIC_PORT=3000
|
||||
NEXT_PUBLIC_PROXY_HOST=http://localhost:3003
|
||||
NEXT_PUBLIC_SITE_ID=AS
|
||||
NEXT_PUBLIC_SUB_DOMAIN=http://localhost:3003
|
||||
NEXT_PUBLIC_SERVER_API_URL=http://localhost:8000
|
||||
SERVER_API_URL=http://localhost:8000
|
||||
NEXT_PUBLIC_TINY_API_KEY=t9nc4z6inquvvd5pq1vea8wcp7lb01qmqseh6egms9vpaju2
|
||||
|
||||
NEXT_PUBLIC_ST_SERVICE_URL=https://apigateway.apps.rsonesoft.com
|
||||
NEXT_PUBLIC_ST_REALGRID_KEY=upVcPE+wPOmtLjqyBIh9RkM/nBOseBrflwxYpzGZyYm9cY8amGDkiHqyYT2U1Yh3Dufv8SUhNy63kJZj9DH/cmSe3PbBoyaMZTR5mNBsNuyY63NNzIfzqxzwYuagP76LkxValpjT3ck=
|
||||
|
||||
# 401 시 브라우저 리다이렉트 대상
|
||||
NEXT_PUBLIC_AUTH_LOGIN_REDIRECT_URL=/auth/login
|
||||
|
||||
# Sentry Configuration
|
||||
NEXT_PUBLIC_SENTRY_DSN=http://f43f0aeff1cae8ea3f50a57f766ae37a@localhost:9001/2
|
||||
NEXT_PUBLIC_SENTRY_ENVIRONMENT=local
|
||||
SENTRY_ORG=sentry
|
||||
SENTRY_PROJECT=as-frontend
|
||||
SENTRY_AUTH_TOKEN=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
|
||||
|
||||
# OpenTelemetry Tracing Configuration
|
||||
OTEL_SERVICE_NAME="as-frontend"
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4320/v1/traces"
|
||||
|
||||
NEXT_PUBLIC_SENTRY_DEBUG=true
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useConfirm } from "@frontend-opensource/use-react-hooks";
|
||||
import { getRemainingMs, SESSION_EXTEND_CONFIRM_BUFFER_MS } from "@common/auth";
|
||||
|
||||
@@ -9,20 +9,101 @@ interface UseSessionExtendConfirmOptions {
|
||||
getAccessToken: () => string | undefined;
|
||||
silentRefresh: () => Promise<void>;
|
||||
declineSessionExtend: () => void;
|
||||
onTimeout?: () => void; // 시간 만료 시 호출될 콜백
|
||||
message?: string;
|
||||
bufferMs?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_MESSAGE = "로그인을 연장하시겠습니까?";
|
||||
|
||||
// 4번 요건: Web Audio API를 활용한 무자원 Beep음 재생
|
||||
function playBeepSound() {
|
||||
try {
|
||||
const audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)();
|
||||
const oscillator = audioCtx.createOscillator();
|
||||
const gainNode = audioCtx.createGain();
|
||||
|
||||
oscillator.connect(gainNode);
|
||||
gainNode.connect(audioCtx.destination);
|
||||
|
||||
oscillator.type = "sine";
|
||||
oscillator.frequency.setValueAtTime(440, audioCtx.currentTime); // A4
|
||||
gainNode.gain.setValueAtTime(0.08, audioCtx.currentTime); // 8% volume
|
||||
|
||||
oscillator.start();
|
||||
oscillator.stop(audioCtx.currentTime + 0.3); // 0.3초 재생
|
||||
} catch (error) {
|
||||
console.error("Audio Beep play failed", error);
|
||||
}
|
||||
}
|
||||
|
||||
// 4번 요건: 브라우저 시스템 푸시 알림 발송
|
||||
function showBrowserNotification(message: string) {
|
||||
if (typeof window !== "undefined" && "Notification" in window && Notification.permission === "granted") {
|
||||
const notification = new Notification("로그인 세션 만료 경고", {
|
||||
body: message,
|
||||
tag: "session-extend-confirm-alert",
|
||||
});
|
||||
|
||||
notification.onclick = () => {
|
||||
window.focus();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface SessionTimeoutMessageProps {
|
||||
message: string;
|
||||
initialRemainingMs: number;
|
||||
onTimeout: () => void;
|
||||
}
|
||||
|
||||
// 다이얼로그 내부 카운트다운 컴포넌트
|
||||
function SessionTimeoutMessage({
|
||||
message,
|
||||
initialRemainingMs,
|
||||
onTimeout,
|
||||
}: SessionTimeoutMessageProps) {
|
||||
const [remainingMs, setRemainingMs] = useState(initialRemainingMs);
|
||||
|
||||
useEffect(() => {
|
||||
const startTime = Date.now();
|
||||
const timer = setInterval(() => {
|
||||
const elapsed = Date.now() - startTime;
|
||||
const nextRemaining = Math.max(0, initialRemainingMs - elapsed);
|
||||
setRemainingMs(nextRemaining);
|
||||
|
||||
if (nextRemaining <= 0) {
|
||||
clearInterval(timer);
|
||||
onTimeout();
|
||||
}
|
||||
}, 200);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [initialRemainingMs, onTimeout]);
|
||||
|
||||
const totalSeconds = Math.ceil(remainingMs / 1000);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
const timeString = `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
|
||||
|
||||
return React.createElement("div", null,
|
||||
React.createElement("p", { className: "whitespace-pre-line text-center text-[1.4rem] text-foreground" }, message),
|
||||
React.createElement("p", {
|
||||
className: "mt-4 font-bold text-lg text-red-500 text-center",
|
||||
style: { marginTop: "1rem", fontWeight: "bold", color: "#ef4444", textAlign: "center" }
|
||||
}, `남은 시간: ${timeString}`)
|
||||
);
|
||||
}
|
||||
|
||||
function useSessionExtendConfirm({
|
||||
enabled,
|
||||
getAccessToken,
|
||||
silentRefresh,
|
||||
declineSessionExtend,
|
||||
onTimeout,
|
||||
message = DEFAULT_MESSAGE,
|
||||
}: UseSessionExtendConfirmOptions) {
|
||||
const { confirm } = useConfirm();
|
||||
const { confirm, onCancel } = useConfirm();
|
||||
const confirmTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const dismissedTokenRef = useRef<string | null>(null);
|
||||
const isConfirmingRef = useRef(false);
|
||||
@@ -49,11 +130,35 @@ function useSessionExtendConfirm({
|
||||
if (dismissedTokenRef.current === currentToken) return;
|
||||
|
||||
isConfirmingRef.current = true;
|
||||
|
||||
// 4번 요건: 비프 사운드 재생 및 푸시 알림 발송
|
||||
playBeepSound();
|
||||
showBrowserNotification(message);
|
||||
|
||||
try {
|
||||
const accepted = await confirm(message);
|
||||
const initialMs = getRemainingMs(currentToken, 0);
|
||||
|
||||
// React Element 형태로 confirm에 넘겨 동적으로 렌더링되게 함
|
||||
const confirmMessage = React.createElement(SessionTimeoutMessage, {
|
||||
message,
|
||||
initialRemainingMs: initialMs,
|
||||
onTimeout: () => {
|
||||
declineSessionExtend();
|
||||
dismissedTokenRef.current = currentToken;
|
||||
if (onTimeout) {
|
||||
onTimeout();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const accepted = await confirm(confirmMessage as any);
|
||||
if (accepted) {
|
||||
dismissedTokenRef.current = null;
|
||||
await silentRefresh();
|
||||
|
||||
// 1번 요건: 탭 간 동기화를 위해 localStorage에 타임스탬프 갱신 트리거
|
||||
localStorage.setItem("session-extended-trigger", Date.now().toString());
|
||||
|
||||
scheduleConfirm();
|
||||
return;
|
||||
}
|
||||
@@ -75,7 +180,54 @@ function useSessionExtendConfirm({
|
||||
confirmTimerRef.current = setTimeout(() => {
|
||||
void showConfirm();
|
||||
}, confirmMs);
|
||||
}, [message, confirm, getAccessToken, silentRefresh, declineSessionExtend]);
|
||||
}, [message, confirm, getAccessToken, silentRefresh, declineSessionExtend, onTimeout]);
|
||||
|
||||
// 1번 요건: 탭 간 세션 연장 동기화 이벤트 감청
|
||||
useEffect(() => {
|
||||
const handleStorageChange = (e: StorageEvent) => {
|
||||
if (e.key === "session-extended-trigger" && e.newValue) {
|
||||
if (isConfirmingRef.current) {
|
||||
if (onCancel) onCancel(); // 다른 탭에서 세션 연장 완료했으므로 다이얼로그 닫기
|
||||
isConfirmingRef.current = false;
|
||||
}
|
||||
// 다음 예약 스케줄 동기화
|
||||
scheduleConfirm();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("storage", handleStorageChange);
|
||||
return () => window.removeEventListener("storage", handleStorageChange);
|
||||
}, [onCancel, scheduleConfirm]);
|
||||
|
||||
// 2번 요건: 브라우저 탭 활성화 / 포커스 복귀 시점의 만료 체크 및 스케줄 오차 보정
|
||||
useEffect(() => {
|
||||
const handleVisibilityOrFocus = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
const token = getAccessToken();
|
||||
if (!token) return;
|
||||
|
||||
const remainingMs = getRemainingMs(token, 0);
|
||||
if (remainingMs <= 0) {
|
||||
// 이미 완전히 만료됨 → 즉시 타임아웃 로그아웃 트리거
|
||||
declineSessionExtend();
|
||||
if (onTimeout) {
|
||||
onTimeout();
|
||||
}
|
||||
} else {
|
||||
// 아직 토큰 유효함 → 남은 시간 기준으로 스케줄 오차 보정 재실행
|
||||
scheduleConfirm();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("visibilitychange", handleVisibilityOrFocus);
|
||||
window.addEventListener("focus", handleVisibilityOrFocus);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", handleVisibilityOrFocus);
|
||||
window.removeEventListener("focus", handleVisibilityOrFocus);
|
||||
};
|
||||
}, [getAccessToken, scheduleConfirm, declineSessionExtend, onTimeout]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
@@ -176,6 +176,9 @@ export function Header({
|
||||
getAccessToken: () => getCookie(appConfig.CLAIM_NAME ?? ""),
|
||||
silentRefresh: authService?.silentRefresh ?? (async () => {}),
|
||||
declineSessionExtend: authService?.declineSessionExtend ?? (() => {}),
|
||||
onTimeout: () => {
|
||||
handleLogout();
|
||||
},
|
||||
});
|
||||
|
||||
const { data: userInfo } = useQuery({
|
||||
@@ -267,6 +270,15 @@ export function Header({
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
|
||||
// 브라우저 푸시 알림 권한 정중히 요청
|
||||
if (typeof window !== "undefined" && "Notification" in window) {
|
||||
if (Notification.permission === "default") {
|
||||
Notification.requestPermission().catch((err) => {
|
||||
console.error("Notification permission request failed", err);
|
||||
});
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
|
||||
@@ -26,9 +26,23 @@ class Client {
|
||||
});
|
||||
|
||||
this.axiosInstance.interceptors.request.use((config) => {
|
||||
// X-Device-Id 자동 주입 로직 (기기에 고유한 UUID 세팅 및 쿠키/로컬스토리지 보관)
|
||||
let deviceId = Cookies.get("device-id") || Cookies.get(DEVICE_ID_KEY);
|
||||
if (!deviceId && typeof window !== "undefined") {
|
||||
deviceId = localStorage.getItem("device-id") || undefined;
|
||||
if (!deviceId) {
|
||||
deviceId = generateUUID();
|
||||
localStorage.setItem("device-id", deviceId);
|
||||
}
|
||||
Cookies.set("device-id", deviceId, { expires: 365, path: "/" });
|
||||
}
|
||||
|
||||
if (deviceId) {
|
||||
config.headers["X-Device-Id"] = deviceId;
|
||||
}
|
||||
|
||||
const token = Cookies.get(CLAIM_NAME);
|
||||
const userId = Cookies.get(AUTH_USER_ID);
|
||||
const deviceId = Cookies.get(DEVICE_ID_KEY);
|
||||
|
||||
if (!token || !userId) {
|
||||
return config;
|
||||
@@ -36,7 +50,6 @@ class Client {
|
||||
|
||||
config.headers[CLAIM_NAME] = token;
|
||||
config.headers[AUTH_USER_ID] = userId;
|
||||
config.headers["X-Device-Id"] = deviceId;
|
||||
|
||||
return config;
|
||||
});
|
||||
@@ -91,3 +104,16 @@ class Client {
|
||||
|
||||
export type { ClientConfig };
|
||||
export { Client };
|
||||
|
||||
// 기기 식별을 위한 안전한 v4 UUID 생성 폴백 헬퍼 함수
|
||||
function generateUUID(): string {
|
||||
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Generated
+1660
-341
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user