Compare commits

...
Author SHA1 Message Date
changwng c6af7676e6 Merge branch 'main' into feature/0612_sentry_fr_merge
# Conflicts:
#	apps/cs/src/pages/module/ui/module-page.tsx
2026-10-06 11:21:48 +09:00
changwng a4f8cf6670 [common] vite 테스트 로직 추가 2026-09-28 14:27:36 +09:00
changwng 694aee4804 [common] 권한 URL 검색 추가 2026-09-28 14:26:46 +09:00
changwng 454b9e474f [common] playwright 테스트 사용법 추가함 2026-09-22 17:40:00 +09:00
changwng 66bca69578 Merge branch 'main' into feature/0612_sentry_fr_merge 2026-09-22 12:57:19 +09:00
changwng cb9267c57f Merge branch 'refs/heads/main' into feature/0612_sentry_fr_merge
# Conflicts:
#	pnpm-lock.yaml
2026-09-21 17:24:59 +09:00
changwng 1ff10de59f Merge branch 'main' into feature/0612_sentry_fr_merge 2026-09-16 09:06:37 +09:00
changwng 3bf21064e2 Merge branch 'refs/heads/main' into feature/0612_sentry_fr_merge
# Conflicts:
#	apps/as/next.config.ts
2026-09-11 10:33:13 +09:00
changwng eb8cd58093 [common] vs code 설정 추가 2026-08-05 10:50:38 +09:00
changwng 3390fe810b Merge branch 'refs/heads/main' into feature/0612_sentry_fr_merge 2026-08-05 10:48:22 +09:00
changwng 797b2c3b63 Merge branch 'refs/heads/iipbs/as/dev' into feature/0612_sentry_fr_merge
# Conflicts:
#	apps/cs/app/api/proxy/[...url]/route.ts
#	pnpm-lock.yaml
2026-07-27 10:02:12 +09:00
changwng 21df3bbfe0 [as] 기본 dev 속도 빠르게 세팅처리 2026-07-27 10:00:57 +09:00
changwng dd074aec94 [as] Sentry Replay 프론트엔드 설정 추가 테스트 제외처리 2026-07-21 11:08:38 +09:00
changwng 1d1d4af572 [as] Sentry Replay integration에 네트워크 상세 로깅 필터 및 헤더 설정 추가 2026-07-20 09:33:50 +09:00
changwng 5519606876 Merge branch 'iipbs/as/dev' into feature/0612_sentry_fr_merge
# Conflicts:
#	apps/as/src/pages/checklist-management/ui/cklst-rvw-grid.tsx
#	apps/as/src/pages/checklist-management/ui/cklst-rvw-row.tsx
2026-07-16 14:44:45 +09:00
changwng 25653f2d4f [cs frontend] 계정상태에 따라서 승인대기중, 탈퇴계정등 메시지 표시하도록 변경 2026-07-10 16:47:10 +09:00
changwng e93a83163e [cs frontend] 세션 체크 로직 핵심 구현 내역
1번 요건 (탭 동기화):
                        localStorage 연동을 통해 한 탭에서 연장에 성공하면, 다른 모든 탭의 열린 다이얼로그가 자동으로 닫히며(onCancel() 실행) 타이머가 새 만료 시간으로 동기화됩니다.
                        2번 요건 (포커스 보정):
                        브라우저 탭 비활성화 등으로 타이머가 지연된 뒤 복귀(visibilitychange/focus)하는 순간 토큰 유효 기간을 즉시 재검증하여, 만료 시 강제 로그아웃을 유도하고 유효한 경우 남은 시간을 정확하게 보정하여 재예약하도록 개선하였습니다.
                        4번 요건 (비프음 및 푸시 알림):
                        파일 의존성 없이 Web Audio API를 통해 팝업 노출 시 즉각적인 비프(Beep) 경고 사운드를 재생하며, 브라우저 Notification API를 활용해 백그라운드 상태에서도 시스템 경고 알림을 전달합니다. (헤더 컴포넌트 마운트 시 알림 권한 정중히 요청)
                        단위 테스트 통과:
                        신규 15~18번 검증 테스트 케이스를 포함하여 총 18개 단위 테스트가 100% 정상 작동 및 성공(Pass) 하였습니다.
2026-07-10 15:45:03 +09:00
changwng 0f7c74726e [cs frontend] 체크리스트 sortSeq 보이기 기능 추가 2026-07-10 13:38:54 +09:00
changwng a5495b4572 Merge branch 'main' into feature/0612_sentry_fr_merge
# Conflicts:
#	apps/as/package.json
#	pnpm-lock.yaml
2026-07-10 11:03:19 +09:00
changwng 7a12d269bc [cs frontend] 로그인 페이지 에러 로그 변경 2026-07-10 10:58:16 +09:00
changwng f5bae39fba [cs frontend] 세션연장 테스트 케이스 추가 2026-07-09 17:17:29 +09:00
changwng 8b3fa32d1e [cs frontend] vitest로 된 Vitest + React Testing Library 기반 단위/통합 테스트 코드 작성 2026-07-09 14:10:48 +09:00
changwng 993a9f4ff2 [cs frontend] java script test case 작성 playwright 세팅 추가 2026-07-09 13:51:02 +09:00
changwng 2b509246e8 [cs frontend] java script test case 작성 2026-07-09 11:29:42 +09:00
changwng a615158812 Merge branch 'main' into feature/0612_sentry_fr_merge 2026-07-09 09:14:18 +09:00
changwng 16b88c8c04 [cs frontend] sentry 기본 disable로 설정후 기동으로 변경함 2026-07-08 17:56:48 +09:00
changwng 299ce3f1fe Merge branch 'main' into feature/0612_sentry_fr_merge
# Conflicts:
#	apps/as/src/pages/instl-sncn/ui/cnfm-view-page.tsx
#	pnpm-lock.yaml
2026-07-08 16:47:33 +09:00
changwng b65c50c5f9 [cs frontend] sentry 추가 테스트 케이스 추가 2026-07-08 16:41:08 +09:00
changwng eb37728532 [cs frontend] playwright 테스트 케이스 추가 2026-07-08 10:46:49 +09:00
changwng c41a7bf3d3 [as frontend] device-id 추가후 테스트 2026-07-08 10:45:53 +09:00
changwng 774f3a5507 [as frontend] sentry - 환경설정 추가 파일 2026-06-26 12:53:01 +09:00
changwng 6e95e372a8 [as frontend] sentry 테스트 환경설정 추가 2026-06-25 17:07:12 +09:00
changwng 1ccd502e3a [as frontend] 클라이언트 서버 콤포넌트 동시 디버그 세팅 추가 2026-06-15 10:20:56 +09:00
49 changed files with 6026 additions and 421 deletions
+118 -8
View File
@@ -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"
}
}
]
+4 -1
View File
@@ -1,5 +1,8 @@
{
"chat.tools.terminal.autoApprove": {
"pnpm": true
}
},
"playwright.configs": [
"apps/cs/playwright.config.ts"
]
}
+43
View File
@@ -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",
});
+13
View File
@@ -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
View File
@@ -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
View File
@@ -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"
}
}
+14
View File
@@ -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",
});
+14
View File
@@ -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 };
+2
View File
@@ -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>
);
}
+48 -5
View File
@@ -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;
}
+19
View File
@@ -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",
});
+13
View File
@@ -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
View File
@@ -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
View File
@@ -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"
}
}
+62
View File
@@ -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",
},
});
+14
View File
@@ -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",
});
+14
View File
@@ -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",
});
+12 -8
View File
@@ -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]!);
})();
+13
View File
@@ -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();
});
});
+12
View File
@@ -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();
});
});
+12
View File
@@ -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();
});
});
+53
View File
@@ -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");
});
});
+78
View File
@@ -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();
});
});
+422
View File
@@ -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();
});
});
+1
View File
@@ -0,0 +1 @@
import "@testing-library/jest-dom";
+132
View File
@@ -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를 초과했습니다!");
});
});
+37
View File
@@ -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();
});
});
+191
View File
@@ -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 테스트 환경 구축 임무가 성공적으로 완결되었습니다.
+13
View File
@@ -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}"],
},
});
+1 -1
View File
@@ -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"
+84
View File
@@ -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
+154
View File
@@ -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` 명시적 도메인 설정 확인 |
+84
View File
@@ -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 아키텍처 패턴을 물리적인 파일 경로 분석을 통해 규제하는 정적 분석 도구입니다. 레이어 간 비정상적인 의존성(예: 하위 레이어가 상위 레이어를 참조하는 역참조 등)을 빌드타임/린트 타임에 강력하게 통제합니다.
+167
View File
@@ -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 형태로 외부에 노출하세요.
+192
View File
@@ -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`와 동기화하여 검색 조건 변경 시 자동으로 목록이 갱신되도록 하세요.
+205
View File
@@ -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`)를 통해 완전한 동기화 흐름을 가지도록 개발하세요.
+68
View File
@@ -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` 등 설정 파싱 에러를 유발하는 대상을 제외하도록 설정하여 운영합니다.
+27
View File
@@ -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 (
+28 -2
View File
@@ -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);
});
}
+1660 -341
View File
File diff suppressed because it is too large Load Diff