51 Commits

Author SHA1 Message Date
이태훈
43e3d19b75 merge: merge branch 'db_setting' into main resolving conflicts in favor of db_setting
All checks were successful
ITAM Code Check / build-and-config-check (push) Successful in 17s
ITAM Docker Build Check / docker-build-check (push) Successful in 25s
2026-06-22 13:55:50 +09:00
이태훈
197d5e1f0b fix: restore relative API paths and dev proxy settings matching main branch 2026-06-22 13:19:12 +09:00
이태훈
4c34edd31c fix: constrain layout map image sizing to prevent modal overflow 2026-06-22 13:08:27 +09:00
이태훈
aacd2fe7db fix: copy map_editor.html to docker builder stage
All checks were successful
ITAM Code Check / build-and-config-check (push) Successful in 17s
ITAM Docker Build Check / docker-build-check (push) Successful in 21s
2026-06-22 11:46:26 +09:00
이태훈
90403a1acd fix: comment out obsolete COPY img in Dockerfile.frontend.prod 2026-06-22 11:42:42 +09:00
이태훈
6a76f6968b fix: convert scripts/backup.sh line endings from CRLF to LF 2026-06-22 11:36:50 +09:00
이태훈
621b05a890 chore: clean up build artifacts, temporary excel locks, duplicate plans, and commit current project state
Some checks failed
ITAM Code Check / build-and-config-check (push) Successful in 18s
ITAM Docker Build Check / docker-build-check (push) Failing after 21s
2026-06-22 11:26:26 +09:00
7b631ab858 fix: 원격접속 자산 데이터 매핑 보완
Some checks failed
ITAM Code Check / build-and-config-check (push) Successful in 20s
ITAM Docker Build Check / docker-build-check (push) Failing after 22s
2026-06-19 18:03:58 +09:00
9735344f37 Merge remote-tracking branch 'origin/main'
Some checks failed
ITAM Code Check / build-and-config-check (push) Successful in 21s
ITAM Docker Build Check / docker-build-check (push) Failing after 23s
# Conflicts:
#	src/views/List/ListFactory.ts
2026-06-19 17:58:22 +09:00
SDI
67e3be028b fix: enable location map box clicks
All checks were successful
ITAM Docker Build Check / docker-build-check (push) Successful in 25s
ITAM Code Check / build-and-config-check (push) Successful in 20s
2026-06-19 17:31:37 +09:00
SDI
58f93c959d fix: use proxied api routes in frontend
All checks were successful
ITAM Code Check / build-and-config-check (push) Successful in 20s
ITAM Docker Build Check / docker-build-check (push) Successful in 26s
2026-06-19 17:20:06 +09:00
4231acc691 Merge branch 'db_setting'
Some checks failed
ITAM Code Check / build-and-config-check (push) Successful in 21s
ITAM Docker Build Check / docker-build-check (push) Failing after 38s
2026-06-19 16:26:16 +09:00
f41f2378d7 fix: 자산번호 저장 누락 오류 수정 및 위치보기 도면 배치 보완 2026-06-19 16:25:28 +09:00
SDI
662f720c6a fix: use updated backup tooling before deploy cleanup
All checks were successful
ITAM Code Check / build-and-config-check (push) Successful in 20s
ITAM Docker Build Check / docker-build-check (push) Successful in 19s
2026-06-19 15:58:39 +09:00
SDI
5678e28c66 fix: load local env file correctly in backup script
All checks were successful
ITAM Code Check / build-and-config-check (push) Successful in 20s
2026-06-19 15:53:04 +09:00
41406f56e8 Merge branch 'ux_setting' into db_setting
# Conflicts:
#	README.md
2026-06-19 15:48:26 +09:00
SDI
15c5cbaca2 Merge remote-tracking branch 'origin/main'
All checks were successful
ITAM Code Check / build-and-config-check (push) Successful in 21s
ITAM Docker Build Check / docker-build-check (push) Successful in 26s
2026-06-19 15:47:18 +09:00
SDI
84d35c1409 fix: smoke check관련 수정 2026-06-19 15:37:50 +09:00
SDI
07eb48f27c chore: force update smoke check to direct backend health check 2026-06-19 15:28:50 +09:00
SDI
fb45c38107 fix: update smoke checks to use port 9090 and direct backend health check2 2026-06-19 15:25:52 +09:00
SDI
6c21e4816e fix: update smoke checks to use port 9090 and direct backend health check 2026-06-19 15:21:11 +09:00
af578a63bc refactor: 프로젝트 정리 및 최적화 (미사용 파일 제거, 코드 중복 제거, 정적 이미지 빌드 경로 수정)
- 미사용 목업 파일(dummyData.ts, realServerData.ts, server_data.json) 및 중복 기획서 제거

- excelHandler.ts 내 미사용 대용량 엑셀 처리 함수들을 삭제하여 xlsx 의존성 제거 및 클라이언트 빌드 크기 최적화

- ListFactory.ts와 utils.ts 간에 중복으로 존재하던 calculatePcScoreDeductive 함수를 하나로 일원화

- 기획서 및 계획 문서들을 docs/plans/ 하위 폴더로 이동하여 프로젝트 루트 정리

- 정적 이미지 폴더(img/)를 public/img/로 이동하여 프로덕션 빌드 시 로고 및 장비 사진 엑박 오류 해결
2026-06-19 15:12:25 +09:00
e8bc42e5de refactor: CSS 파일 모듈화 및 컴포넌트별 직접 Import 구조 전환 (방안 B)
- HTML 내 CSS link 태그들을 삭제하고, 각 TS 진입점 파일에서 CSS 파일을 직접 import하도록 연동

- 스타일 파일들을 각 컴포넌트/뷰 디렉토리 옆으로 이동 배치 (Co-location)

- guide.css, modal.css, dashboard.css, table.css, map-editor.css 이동 및 경로 갱신

- 디자인 시스템(common.css) 및 로그인 스타일(login.css)은 전역 배치 유지하고 main.ts에서 통합 임포트
2026-06-19 15:04:36 +09:00
587e92a7da feat: 서버 탭 전환 시 뷰 모드 유지 및 대시보드/맵 에디터 스타일 표준화
- 서버 탭 복귀 시 최근 선택한 뷰 모드(목록/위치) 상태 유지 및 currentViewMode 상태 일원화

- 개인PC 대시보드 및 맵 에디터의 인라인 CSS 스타일을 공통 CSS 및 변수 클래스로 분리 및 가독성 개선

- Vite 멀티페이지 빌드 설정(vite.config.ts) 추가
2026-06-19 14:55:25 +09:00
SDI
e208e52ed9 feat: implement container-based backup using docker exec 2026-06-19 14:34:09 +09:00
SDI
5dbf69e963 fix: 백업 디렉토리 경로 설정 수정 2026-06-19 14:02:36 +09:00
SDI
d771b28d88 fix: change port from 80 to 9090 2026-06-19 13:51:40 +09:00
c6515c1b5d merge: 공동작업자 HW_Dashboard 브랜치 병합 (대시보드 UI 및 가독성 개선 사항 병합) 2026-06-19 13:39:29 +09:00
e128634e05 style: UI 가독성 개선 및 LocationView 타입 오류 수정
1. common.css의 --mute 변수 색상 대비값 강화 (#71717a) 및 누락된 자산 상태/성능 등급 배지 CSS 클래스 정의
2. ListFactory.ts에서 테이블 헤더(th) 정렬을 데이터 셀(td)과 일치시키고 장문 생략 시 툴팁(title) 추가
3. common.css에서 타이포그래피 스케일 계산식을 clamp에서 max로 변경하여 상한선 제한 해제 (와이드 화면 대응)
4. LocationView.ts 내 HardwareAsset 타입에 정의되지 않은 asset_purpose를 any로 타입 캐스팅하여 TS2339 빌드 에러 해결
5. 프로젝트 폴더 내 일회성 점검/이관 스크립트 및 Playwright 임시 캡처 로그/이미지 파일 정리
2026-06-19 13:19:25 +09:00
6848baae5f Merge pull request 'feat: 자산관리 시스템 도커 운영 배포 환경 및 CI/CD 구축' (#21) from feature/docker-deploy into main
All checks were successful
ITAM Docker Build Check / docker-build-check (push) Successful in 18s
ITAM Code Check / build-and-config-check (push) Successful in 20s
Reviewed-on: #21
2026-06-19 10:50:17 +09:00
SDI
a0570e88d4 fix: Dockerfile 불필요한 이미지 복사 명령어 제거
All checks were successful
ITAM Code Check / build-and-config-check (pull_request) Successful in 20s
ITAM Docker Build Check / docker-build-check (pull_request) Successful in 21s
2026-06-19 10:48:01 +09:00
SDI
502e5059b7 Merge remote-tracking branch 'origin/main' into feature/docker-deploy
Some checks failed
ITAM Code Check / build-and-config-check (pull_request) Successful in 22s
ITAM Docker Build Check / docker-build-check (pull_request) Failing after 22s
2026-06-19 10:27:37 +09:00
c0ef52deac style(table): optimize for 1920x1080 without horizontal scroll
- Switched to fixed table layout to ensure fit within viewport
- Implemented automatic ellipsis (...) for overflowing text in all cells
- Synchronized cell widths and alignment in ListFactory with column definitions
- Removed restrictive min-widths that caused horizontal scrolling
2026-06-18 20:41:03 +09:00
aab1f91d3d feat(map): implement robust ID-based asset mapping and fix UI rendering inconsistencies
- Migrated map mapping from fuzzy coordinates to precise asset_id tracking
- Updated MapEditor to allow explicit asset assignment via dropdown
- Fixed LocationView rendering logic to search across all hardware categories
- Standardized map indicators to always render as areas (boxes) with minimum size
- Restored stable CSS max-height for detail modal photos to prevent clipping
- Synced MapEditor saves directly to database via asset_id
2026-06-18 19:49:15 +09:00
e77c4854cb fix: restore exact matching logic for map locations 2026-06-18 17:04:25 +09:00
SDI
d54997cd55 fix: prepare ci env for compose validation 2026-06-18 13:52:05 +09:00
SDI
fa8dec1780 feat:CI/CD Gitea 워크플로우 등 누락 파일 반영 2026-06-18 13:39:35 +09:00
309c400ee2 최신코드 반영 2026-06-18 13:00:18 +09:00
3db05f2939 feat(ui/ux): unify typography to Pretendard and enforce read-only view mode as default
- Set global font-family to Pretendard and letter-spacing to -0.02em.
- Standardized table header font-size to var(--fs-sm).
- Fixed table clipping and sticky header behavior at 1920x1080.
- Implemented dynamic select options in search filters.
- Enforced 'view' mode as default for all asset modals (PC, Server, SW, etc.).
- Improved Modal logic to ensure all fields (including dynamic rows) are correctly locked.
- Updated Location View detail button from 'Edit' to 'View'.
- Updated design_rule.md to reflect new typography standards.
2026-06-18 11:13:16 +09:00
2cb4b87c0a style: surgically unify Admin page UI and sub-tabs
- Standardized sub-tab rendering in PartsMasterListView to prevent duplication
- Updated badge classes to match system design guide (badge-success/warning)
- Refactored JobSpecModal to use unified .grid-form and header identity
- Preserved all functional logic and tab-switching behavior from collaborator
2026-06-17 13:16:15 +09:00
6ed2faee2d merge: remote main updates into ux_setting with style preservation
- Resolved conflicts in state.ts, HwDashboard.ts, ListFactory.ts, and PartsMasterListView.ts
- Prioritized latest functional logic from main branch (Job Spec mapping, Matrix calculations)
- Maintained Vercel-inspired UI styling and unified CSS classes from ux_setting branch
- Synchronized PC status toggle visibility rules with latest main branch changes
2026-06-17 13:08:59 +09:00
89d3ac2e89 style: unify UI styling & restore dashboard logic
- Restored HW/SW Dashboard full features (Chart.js, filters, tables) from main
- Unified Search Bar & Filter Bar across all views (List, Location)
- Integrated asset identity info into all Modal Headers
- Standardized 'Remove Row' buttons as high-visibility circular circles
- Centralized hardcoded inline styles into dedicated CSS files
- Fixed various ReferenceErrors and layout regressions in HWModal
2026-06-17 12:29:26 +09:00
9d19d8283e 자산관리 시스템 도커라이징 2026-06-17 11:31:10 +09:00
b37981506e style: revert content/logic to main while preserving Vercel UI styles
- Reverted HWModal to unified form structure from main branch
- Restored original field positions and visibility logic in all modals
- Applied Vercel-inspired CSS classes and removed legacy inline styles
- Restored SwDashboard 2x2 layout from main
- Cleaned up unused modular form files
- Fixed TypeError related to ASSET_MFR schema key
2026-06-17 10:46:24 +09:00
73ef13f3a5 style: apply Vercel-inspired responsive UI & fluid scaling 2026-06-16 17:43:20 +09:00
155570e8de style: disable global text selection to prevent accidental UI dragging 2026-06-16 14:32:16 +09:00
119c799d1d style: 레이아웃 비율 복구 및 타이포그래피 전역 표준화 (16px Base)
- 주요 변경 사항:
  1. 레이아웃 안정화: 서버 위치도 뷰의 2:1 비율 복원 및 가변형(Adaptive) 레이아웃 적용
  2. 타이포그래피 표준화: 전역 폰트 스케일 도입 및 기본 폰트 사이즈 상향 (15px -> 16px)
  3. 3-Way 토글 통합: [자산 위치] [운영 현황] [자산 목록] 간의 전환 오류 수정 및 UI 통일
  4. 하드코딩 제거: 인라인 스타일을 CSS 클래스 및 변수 체계로 전면 리팩토링
  5. 가이드 업데이트: 변경된 디자인 정책을 design_rule.md에 반영
2026-06-15 14:21:54 +09:00
b9d28736e2 docs: add work log for 2026-06-15 and update DB deletion policy in README 2026-06-15 11:47:46 +09:00
b169176d57 WIP(style): UI 컴포넌트 하드코딩 제거 및 CSS 통합 (진행 중)
- 작업 상태: 진행 중 (Work In Progress)
- 주요 변경 사항:
  1. CSS 파일 통합: HWModal, SWModal, ListFactory 등에서 인라인 스타일(style 속성) 전면 제거 및 클래스 기반으로 재작성
  2. 폰트/타이포그래피 스케일업: 최소 폰트 14px 기준으로 전체 텍스트 크기 상향 및 굵기(font-weight) 상향 조정
  3. GNB(상단바) 레이아웃 개편: 2단 구조(로고 라인 / 메뉴 라인)로 변경 및 카테고리 텍스트 라벨 생략을 통한 간결화
  4. 로고 이미지 교체: image 92.png로 업데이트 및 경로 정리
  5. 디자인 가이드 분리: README에서 design_rule.md로 디자인 정책 문서 독립

* 참고: 현재 디자인 검토를 위한 중간 반영 상태이며, 피드백에 따라 추가 수정 예정임.
2026-06-12 15:57:20 +09:00
56abdddbc7 Merge remote-tracking branch 'origin/main' into ux_setting 2026-06-12 13:34:13 +09:00
fd9e88d7c6 style: 리팩토링 및 CSS 통합 작업 완료 (하드코딩 스타일 제거) 2026-06-12 13:29:59 +09:00
220 changed files with 26369 additions and 51433 deletions

10
.dockerignore Normal file
View File

@@ -0,0 +1,10 @@
node_modules
dist
build
.git
.gitignore
.env
npm-debug.log
uploads
*.xlsx
*.log

6
.env
View File

@@ -1,6 +0,0 @@
DB_HOST=172.16.8.151
DB_PORT=3306
DB_USER=itam_admin
DB_PASS=itam1234
DB_NAME=itam
PORT=3000

17
.env.example Normal file
View File

@@ -0,0 +1,17 @@
# Database Configuration
DB_HOST=172.16.8.151
DB_PORT=3306
DB_USER=itam_admin
DB_PASS=itam1234
DB_NAME=itam
# Application Configuration
NODE_ENV=development
PORT=3000
# Logging (optional)
LOG_LEVEL=info
# Security (for production)
# API_KEY=your_api_key_here
# JWT_SECRET=your_jwt_secret_here

7
.gitea/coverage.json Normal file
View File

@@ -0,0 +1,7 @@
{
"Path": "./backend/coverage.out",
"Thresholds": {
"baron-sso-backend/internal/handler": 10,
"baron-sso-backend/internal/service": 10
}
}

View File

@@ -0,0 +1,47 @@
name: ITAM Code Check
on:
push:
branches:
- Dockerizing
- main
pull_request:
workflow_dispatch:
jobs:
build-and-config-check:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Prepare CI env file
run: |
cat <<'EOF' > .env
DB_HOST=127.0.0.1
DB_PORT=3306
DB_USER=itam_ci
DB_PASS=itam_ci_password
DB_NAME=itam
NODE_ENV=production
PORT=3000
LOG_LEVEL=info
EOF
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Frontend TypeScript and Vite build
run: npm run build
- name: Validate test compose
run: docker compose -f docker-compose.test.yaml config
- name: Validate prod compose
run: docker compose -f docker-compose.prod.yaml config

View File

@@ -0,0 +1,69 @@
name: ITAM Docker Build Check
on:
push:
branches:
- Dockerizing
- main
paths:
- "Dockerfile.frontend.prod"
- "Dockerfile.backend.prod"
- "docker-compose.prod.yaml"
- "docker-compose.test.yaml"
- "docker/**"
- "src/**"
- "server.js"
- "package.json"
- "package-lock.json"
- "vite.config.ts"
- "index.html"
- "img/**"
- "public/**"
pull_request:
paths:
- "Dockerfile.frontend.prod"
- "Dockerfile.backend.prod"
- "docker-compose.prod.yaml"
- "docker-compose.test.yaml"
- "docker/**"
- "src/**"
- "server.js"
- "package.json"
- "package-lock.json"
- "vite.config.ts"
- "index.html"
- "img/**"
- "public/**"
workflow_dispatch:
jobs:
docker-build-check:
runs-on: ubuntu-latest
env:
DOCKER_BUILDKIT: "1"
COMPOSE_DOCKER_CLI_BUILD: "1"
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Prepare CI env file
run: |
cat <<'EOF' > .env
DB_HOST=127.0.0.1
DB_PORT=3306
DB_USER=itam_ci
DB_PASS=itam_ci_password
DB_NAME=itam
NODE_ENV=production
PORT=3000
LOG_LEVEL=info
EOF
- name: Build backend production image
run: docker build -f Dockerfile.backend.prod -t itam-backend:ci .
- name: Build frontend production image
run: docker build -f Dockerfile.frontend.prod -t itam-frontend:ci .
- name: Validate production compose with CI env
run: docker compose -f docker-compose.prod.yaml config

View File

@@ -0,0 +1,143 @@
name: ITAM Production Deploy
on:
workflow_dispatch:
inputs:
target_branch:
description: "Branch to deploy"
required: true
default: "main"
type: string
jobs:
deploy-production:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup SSH agent
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.PROD_SSH_PRIVATE_KEY }}
- name: Validate required production variables
env:
PROD_HOST: ${{ vars.PROD_HOST }}
PROD_USER: ${{ vars.PROD_USER }}
PROD_DEPLOY_PATH: ${{ vars.PROD_DEPLOY_PATH }}
PROD_GIT_URL: ${{ vars.PROD_GIT_URL }}
DB_HOST: ${{ vars.PROD_DB_HOST }}
DB_PORT: ${{ vars.PROD_DB_PORT }}
DB_USER: ${{ vars.PROD_DB_USER }}
DB_PASS: ${{ secrets.PROD_DB_PASS }}
DB_NAME: ${{ vars.PROD_DB_NAME }}
run: |
set -euo pipefail
required_keys="PROD_HOST PROD_USER PROD_DEPLOY_PATH PROD_GIT_URL DB_HOST DB_PORT DB_USER DB_PASS DB_NAME"
for key in ${required_keys}; do
if [ -z "${!key:-}" ]; then
echo "::error::Missing required variable or secret: ${key}"
exit 1
fi
done
- name: Create production env file
env:
DB_HOST: ${{ vars.PROD_DB_HOST }}
DB_PORT: ${{ vars.PROD_DB_PORT }}
DB_USER: ${{ vars.PROD_DB_USER }}
DB_PASS: ${{ secrets.PROD_DB_PASS }}
DB_NAME: ${{ vars.PROD_DB_NAME }}
LOG_LEVEL: ${{ vars.PROD_LOG_LEVEL }}
run: |
set -euo pipefail
EFFECTIVE_LOG_LEVEL="${LOG_LEVEL:-info}"
cat > .env.deploy <<EOF
DB_HOST=${DB_HOST}
DB_PORT=${DB_PORT}
DB_USER=${DB_USER}
DB_PASS=${DB_PASS}
DB_NAME=${DB_NAME}
NODE_ENV=production
PORT=3000
LOG_LEVEL=${EFFECTIVE_LOG_LEVEL}
EOF
- name: Deploy to production host
env:
PROD_HOST: ${{ vars.PROD_HOST }}
PROD_USER: ${{ vars.PROD_USER }}
PROD_DEPLOY_PATH: ${{ vars.PROD_DEPLOY_PATH }}
PROD_BACKUP_ROOT: ${{ vars.PROD_BACKUP_ROOT }}
PROD_GIT_URL: ${{ vars.PROD_GIT_URL }}
DB_HOST: ${{ vars.PROD_DB_HOST }}
DB_PORT: ${{ vars.PROD_DB_PORT }}
DB_USER: ${{ vars.PROD_DB_USER }}
DB_PASS: ${{ secrets.PROD_DB_PASS }}
DB_NAME: ${{ vars.PROD_DB_NAME }}
TARGET_BRANCH: ${{ github.event.inputs.target_branch }}
run: |
set -euo pipefail
ssh-keyscan -H "${PROD_HOST}" >> ~/.ssh/known_hosts
ssh "${PROD_USER}@${PROD_HOST}" "mkdir -p '${PROD_DEPLOY_PATH}'"
ssh "${PROD_USER}@${PROD_HOST}" "if [ ! -d '${PROD_DEPLOY_PATH}/.git' ]; then git clone '${PROD_GIT_URL}' '${PROD_DEPLOY_PATH}'; else cd '${PROD_DEPLOY_PATH}' && git remote set-url origin '${PROD_GIT_URL}'; fi"
ssh "${PROD_USER}@${PROD_HOST}" "cd '${PROD_DEPLOY_PATH}' && git fetch origin '${TARGET_BRANCH}' && git checkout -B '${TARGET_BRANCH}' FETCH_HEAD && git reset --hard FETCH_HEAD"
EFFECTIVE_BACKUP_ROOT="${PROD_BACKUP_ROOT:-/home/user/dachs_backups}"
ssh "${PROD_USER}@${PROD_HOST}" "export DEPLOY_PATH='${PROD_DEPLOY_PATH}' BACKUP_ROOT='${EFFECTIVE_BACKUP_ROOT}'; sh -eu -s" <<'REMOTE_BACKUP'
case "$BACKUP_ROOT" in
"$DEPLOY_PATH"|"$DEPLOY_PATH"/*)
echo "Backup path must be outside deploy path: $BACKUP_ROOT"
exit 1
;;
esac
if [ -d "$DEPLOY_PATH/.git" ]; then
mkdir -p "$BACKUP_ROOT"
echo "Starting pre-deploy backup..."
cd "$DEPLOY_PATH"
if [ -f Makefile ] && [ -f scripts/backup.sh ] && [ -f .env ]; then
make predeploy-backup ENV_FILE=.env BACKUP_ROOT="$BACKUP_ROOT"
else
echo "Skipping pre-deploy backup because required backup files are missing in current deployment."
fi
else
echo "Skipping pre-deploy backup because no existing deployment was found."
fi
REMOTE_BACKUP
ssh "${PROD_USER}@${PROD_HOST}" "cd '${PROD_DEPLOY_PATH}' && git clean -fd"
ssh "${PROD_USER}@${PROD_HOST}" "cd '${PROD_DEPLOY_PATH}' && mkdir -p uploads logs/nginx"
scp .env.deploy "${PROD_USER}@${PROD_HOST}:${PROD_DEPLOY_PATH}/.env"
ssh "${PROD_USER}@${PROD_HOST}" "cd '${PROD_DEPLOY_PATH}' && chmod 600 .env && docker compose -f docker-compose.prod.yaml config && docker compose -f docker-compose.prod.yaml up -d --build"
- name: Post-deploy status check
env:
PROD_HOST: ${{ vars.PROD_HOST }}
PROD_USER: ${{ vars.PROD_USER }}
PROD_DEPLOY_PATH: ${{ vars.PROD_DEPLOY_PATH }}
run: |
set -euo pipefail
ssh "${PROD_USER}@${PROD_HOST}" "cd '${PROD_DEPLOY_PATH}' && docker compose -f docker-compose.prod.yaml ps"
- name: Post-deploy smoke checks
env:
PROD_HOST: ${{ vars.PROD_HOST }}
PROD_USER: ${{ vars.PROD_USER }}
PROD_DEPLOY_PATH: ${{ vars.PROD_DEPLOY_PATH }}
run: |
set -euo pipefail
ssh "${PROD_USER}@${PROD_HOST}" "curl -fsS http://localhost:9090/health"
ssh "${PROD_USER}@${PROD_HOST}" "cd '${PROD_DEPLOY_PATH}' && docker compose -f docker-compose.prod.yaml exec -T backend curl -fsS http://localhost:3000/health"
- name: Cleanup generated env file
if: ${{ always() }}
run: rm -f .env.deploy

15
.gitignore vendored
View File

@@ -1,7 +1,8 @@
node_modules/
.gemini
.env
dist/
*.log
.DS_Store
Thumbs.db
node_modules/
.gemini
.env
dist/
*.log
.DS_Store
Thumbs.db
backups/

12
Dockerfile.backend Normal file
View File

@@ -0,0 +1,12 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
EXPOSE 3000
CMD ["npm", "run", "server"]

48
Dockerfile.backend.prod Normal file
View File

@@ -0,0 +1,48 @@
FROM node:20-alpine
LABEL maintainer="ITAM Team <devops@itam.local>"
# Set production environment
ENV NODE_ENV=production
WORKDIR /app
# Install curl for health checks and dumb-init for proper signal handling
RUN apk add --no-cache curl dumb-init mysql-client
# Copy package files
COPY package*.json ./
# Install production dependencies only
RUN npm ci --only=production
# Copy application code
COPY server.js ./
COPY src ./src
# Create non-root user 'appuser' with UID 1001 (1000 already in use by node image)
RUN addgroup -g 1001 appuser && \
adduser -D -u 1001 -G appuser appuser
# Set ownership of application files to appuser
RUN chown -R appuser:appuser /app
# Create logs directory
RUN mkdir -p /app/logs && \
chown -R appuser:appuser /app/logs
# Switch to non-root user
USER appuser
# Expose port
EXPOSE 3000
# Health check - backend should implement /health endpoint
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD curl -f http://localhost:3000/health || exit 1
# Use dumb-init from PATH to avoid distro-specific absolute path issues
ENTRYPOINT ["dumb-init", "--"]
# Run application
CMD ["npm", "run", "server"]

12
Dockerfile.frontend Normal file
View File

@@ -0,0 +1,12 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
EXPOSE 8080
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]

62
Dockerfile.frontend.prod Normal file
View File

@@ -0,0 +1,62 @@
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
COPY tsconfig*.json ./
COPY vite.config.ts ./
# Install all dependencies (including devDependencies for build)
RUN npm ci
# Copy source code
COPY src ./src
COPY public ./public
COPY index.html map_editor.html ./
# Build application
RUN npm run build
# Verify build output
RUN ls -la dist/ && echo "Build completed successfully"
# Stage 2: Runtime
FROM nginx:stable-alpine
LABEL maintainer="ITAM Team <devops@itam.local>"
# Install curl for health checks
RUN apk add --no-cache curl
WORKDIR /usr/share/nginx/html
# Copy built assets from builder
COPY --from=builder /app/dist .
# Copy static image assets referenced by literal /img/... paths (Obsolete: img folder is now public/img and copied via dist)
# COPY img ./img
# Copy root-level logo asset referenced directly by index.html
# COPY ["image 92.png", "./image 92.png"]
# Copy Nginx static file serving configuration (not reverse proxy)
COPY docker/frontend/default.conf /etc/nginx/conf.d/default.conf
# Create nginx runtime user and directories
RUN mkdir -p /var/log/nginx && \
chown -R nginx:nginx /usr/share/nginx/html && \
chown -R nginx:nginx /var/log/nginx && \
chown -R nginx:nginx /var/cache/nginx && \
chown -R nginx:nginx /etc/nginx/conf.d
# Expose port
EXPOSE 80
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=20s --retries=3 \
CMD curl -f http://localhost:80/ || exit 1
# Run nginx
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -0,0 +1,103 @@
# 자산관리 시스템 운영 오픈 보고 요약
## 1. 운영 방식
```mermaid
flowchart LR
A[개발 완료] --> B[개발 완료 검증]
B --> C[운영 서버 반영]
C --> D[최종 점검]
D --> E[서비스 오픈]
linkStyle default stroke:#d32f2f,stroke-width:2px;
```
| 구분 | 운영 방향 |
| --- | --- |
| 반영 기준 | 개발 완료 및 검증 후 운영에 반영 |
| 반영 방식 | 운영 담당자 기준 통제 절차 |
| 데이터 연계 | 기존 외부 DB와 연동 |
| 안정성 확보 | 반영 전 점검, 반영 전 백업, 반영 후 확인 |
임의 반영 배제, 개발 완료 및 검증 결과 기준 운영 반영 방식.
---
## 2. 운영 배포 절차
```mermaid
flowchart TD
A[배포 준비 완료] --> B[사전 점검]
B --> C[백업 수행]
C --> D[운영 배포 실행]
D --> E[접속 및 기능 확인]
E --> F[오픈]
B1[환경 설정 확인]
B2[외부 DB 연결 확인]
B3[배포 파일 확인]
B --> B1
B --> B2
B --> B3
linkStyle default stroke:#d32f2f,stroke-width:2px;
```
| 단계 | 목적 | 담당 |
| --- | --- | --- |
| 사전 점검 | 운영 반영에 필요한 조건 확인 | 개발팀 + 운영 |
| 백업 수행 | 문제 발생 시 신속 복구 대비 | 운영 |
| 운영 배포 실행 | 운영 서버에 검증 완료 결과 반영 | 운영 |
| 접속 및 기능 확인 | 주요 화면과 서비스 상태 확인 | 개발팀 + 운영 |
| 오픈 | 사용자 대상 서비스 오픈 | 운영 |
---
## 3. 예상 일정
```mermaid
gantt
title 자산관리 시스템 운영 오픈 일정
dateFormat YYYY-MM-DD
axisFormat %m/%d
section 준비 단계
배포 구성 정리 및 환경 보완 :done, a1, 2026-06-18, 2026-06-24
section 검증 단계
테스트 배포 및 점검 :a2, 2026-06-25, 2026-06-27
오픈 전 최종 확인 :a3, 2026-06-28, 2026-06-30
section 오픈
운영 오픈 :milestone, a4, 2026-07-01, 1d
```
| 구간 | 일정 | 주요 내용 |
| --- | --- | --- |
| 준비 단계 | 6월 18일 ~ 6월 24일 | 운영 환경 정리 및 배포 준비 완료 |
| 검증 단계 | 6월 25일 ~ 6월 30일 | 테스트 배포, 접속 확인, 최종 보완 |
| 오픈 시점 | 7월 1일 | 운영 서비스 시작 |
6월 30일까지 준비 및 검증 완료, 7월 1일 오픈.
---
## 4. 보고 요약
| 항목 | 보고 내용 |
| --- | --- |
| 통제된 배포 | 개발 완료 및 검증 후 운영 반영 |
| 운영 안정성 | 점검과 백업 후 반영 |
| 데이터 연속성 | 기존 외부 DB와 연계 유지 |
| 오픈 목표 | 7월 1일 서비스 개시 |
1. 통제 절차 기반 운영 반영.
2. 오픈 전 점검 및 백업 기반 리스크 관리.
3. 6월 30일까지 준비 완료, 7월 1일 오픈.
---
## 5. 결론
자산관리 시스템, 6월 30일까지 운영 배포 준비 및 검증 완료, 7월 1일 오픈.

33
Makefile Normal file
View File

@@ -0,0 +1,33 @@
SHELL := /bin/sh
ENV_FILE ?= .env
BACKUP_ROOT ?= backups
RETENTION_DAYS ?= 14
BACKUP_SCRIPT := scripts/backup.sh
.PHONY: help db-dump files-backup full-backup predeploy-backup cleanup-backups
help:
@echo "Usage: make <target> [ENV_FILE=.env BACKUP_ROOT=backups RETENTION_DAYS=14]"
@echo ""
@echo "Targets:"
@echo " db-dump Create a gzip-compressed MySQL dump from .env settings"
@echo " files-backup Archive runtime files such as uploads/, map_config.json, and .env"
@echo " full-backup Run both db-dump and files-backup"
@echo " predeploy-backup Alias for the backup step executed before production deploy"
@echo " cleanup-backups Delete backup files older than RETENTION_DAYS"
db-dump:
@ENV_FILE="$(ENV_FILE)" BACKUP_ROOT="$(BACKUP_ROOT)" sh "$(BACKUP_SCRIPT)" db
files-backup:
@ENV_FILE="$(ENV_FILE)" BACKUP_ROOT="$(BACKUP_ROOT)" sh "$(BACKUP_SCRIPT)" files
full-backup:
@ENV_FILE="$(ENV_FILE)" BACKUP_ROOT="$(BACKUP_ROOT)" sh "$(BACKUP_SCRIPT)" full
predeploy-backup:
@ENV_FILE="$(ENV_FILE)" BACKUP_ROOT="$(BACKUP_ROOT)" sh "$(BACKUP_SCRIPT)" full
cleanup-backups:
@BACKUP_ROOT="$(BACKUP_ROOT)" RETENTION_DAYS="$(RETENTION_DAYS)" sh "$(BACKUP_SCRIPT)" cleanup

View File

@@ -1,429 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PC 사양 적정성 분석 기획서 (GPU 반영)</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;900&family=Outfit:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
:root {
--primary: #4F46E5;
--primary-light: #EEF2FF;
--secondary: #10B981;
--secondary-light: #D1FAE5;
--danger: #EF4444;
--danger-light: #FEE2E2;
--warning: #F59E0B;
--warning-light: #FEF3C7;
--purple: #7C3AED;
--purple-light: #EDE9FE;
--text-dark: #0F172A;
--text-body: #334155;
--text-muted: #64748B;
--border: #E2E8F0;
--bg-light: #F8FAFC;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Outfit', 'Noto Sans KR', sans-serif;
color: var(--text-body);
background: #fff;
letter-spacing: -0.02em;
line-height: 1.7;
}
.page { max-width: 980px; margin: 0 auto; padding: 3rem 2rem; }
/* ─ Header ─ */
.doc-header { border-bottom: 3px solid var(--text-dark); padding-bottom: 1.75rem; margin-bottom: 3rem; }
.doc-label {
display: inline-block; font-size: 0.75rem; font-weight: 700; color: var(--primary);
background: var(--primary-light); padding: 0.25rem 0.75rem; border-radius: 99px;
text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 0.75rem;
}
.version-badge {
display: inline-block; font-size: 0.7rem; font-weight: 700; color: var(--secondary);
background: var(--secondary-light); padding: 0.2rem 0.6rem; border-radius: 99px;
margin-left: 0.5rem; vertical-align: middle;
}
.doc-header h1 { font-size: 2rem; font-weight: 900; color: var(--text-dark); line-height: 1.25; margin-bottom: 1rem; }
.meta-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 0.75rem; margin-top: 1rem; }
.meta-item { background: var(--bg-light); border-radius: 8px; padding: 0.65rem 1rem; font-size: 0.83rem; }
.meta-item .label { color: var(--text-muted); display: block; font-size: 0.75rem; }
.meta-item .val { font-weight: 700; color: var(--text-dark); font-size: 0.9rem; }
/* ─ Sections ─ */
section { margin-bottom: 3.5rem; }
h2 {
font-size: 1.3rem; font-weight: 800; color: var(--text-dark);
padding-bottom: 0.5rem; border-bottom: 2px solid var(--border);
margin-bottom: 1.5rem; display: flex; align-items: center; gap: 0.6rem;
}
h2 .num {
display: inline-flex; align-items: center; justify-content: center;
width: 28px; height: 28px; background: var(--primary); color: #fff;
border-radius: 50%; font-size: 0.75rem; font-weight: 800; flex-shrink: 0;
}
h3 { font-size: 1.05rem; font-weight: 700; color: var(--text-dark); margin: 1.75rem 0 0.75rem; }
p { margin-bottom: 1rem; color: var(--text-body); font-size: 0.97rem; }
/* ─ Boxes ─ */
.box { border-radius: 10px; padding: 1.25rem 1.5rem; margin: 1.25rem 0; font-size: 0.93rem; }
.box-blue { background: var(--primary-light); border-left: 4px solid var(--primary); }
.box-green { background: var(--secondary-light); border-left: 4px solid var(--secondary); }
.box-yellow { background: var(--warning-light); border-left: 4px solid var(--warning); }
.box-red { background: var(--danger-light); border-left: 4px solid var(--danger); }
.box-purple { background: var(--purple-light); border-left: 4px solid var(--purple); }
.box-title { font-weight: 700; color: var(--text-dark); margin-bottom: 0.5rem; font-size: 0.95rem; }
/* ─ Score formula block ─ */
.formula {
background: #1E293B; color: #E2E8F0; border-radius: 8px;
padding: 1rem 1.25rem; font-family: 'Courier New', monospace;
font-size: 0.87rem; margin: 1rem 0; overflow-x: auto; line-height: 2;
}
.formula .comment { color: #64748B; }
.formula .key { color: #93C5FD; }
.formula .val { color: #6EE7B7; }
.formula .warn { color: #FCD34D; }
/* ─ Three-col score grid ─ */
.score-grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.1rem; margin: 1.5rem 0; }
@media(max-width: 700px) { .score-grid-3 { grid-template-columns: 1fr; } }
.score-card { border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
.score-card-header {
background: var(--bg-light); padding: 0.65rem 1rem;
font-weight: 700; font-size: 0.88rem; color: var(--text-dark);
border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 0.5rem;
}
.dot { width: 10px; height: 10px; border-radius: 50%; background: var(--primary); }
.dot-green { background: var(--secondary); }
.dot-purple { background: var(--purple); }
/* ─ Tables ─ */
.tbl-wrap { border: 1px solid var(--border); border-radius: 10px; overflow: hidden; margin: 1.25rem 0; }
table { width: 100%; border-collapse: collapse; font-size: 0.88rem; }
th { background: var(--bg-light); padding: 0.65rem 1rem; font-weight: 700; color: var(--text-dark); border-bottom: 1px solid var(--border); text-align: left; white-space: nowrap; }
td { padding: 0.65rem 1rem; border-bottom: 1px solid var(--border); color: var(--text-body); vertical-align: top; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: var(--bg-light); }
/* ─ Badges ─ */
.badge { display: inline-block; padding: 0.2rem 0.55rem; border-radius: 4px; font-size: 0.75rem; font-weight: 700; white-space: nowrap; }
.b-primary { color: var(--primary); background: var(--primary-light); }
.b-green { color: #065F46; background: var(--secondary-light); }
.b-red { color: #991B1B; background: var(--danger-light); }
.b-yellow { color: #92400E; background: var(--warning-light); }
.b-purple { color: #5B21B6; background: var(--purple-light); }
/* ─ Flow ─ */
.flow { display: flex; align-items: center; flex-wrap: wrap; gap: 0; margin: 1.5rem 0; }
.flow-step { background: var(--primary-light); color: var(--primary); font-weight: 700; font-size: 0.83rem; padding: 0.55rem 0.9rem; border-radius: 8px; text-align: center; }
.flow-step.gpu { background: var(--purple-light); color: var(--purple); }
.flow-arrow { font-size: 1.1rem; color: var(--text-muted); padding: 0 0.4rem; }
/* ─ GPU tier table highlight ─ */
.tier-S td:first-child { font-weight: 800; color: #DC2626; }
.tier-A td:first-child { font-weight: 700; color: var(--primary); }
.tier-B td:first-child { font-weight: 700; color: var(--secondary); }
.tier-C td:first-child { color: var(--warning); font-weight: 600; }
.tier-D td:first-child { color: var(--text-muted); }
footer { border-top: 1px solid var(--border); margin-top: 4rem; padding-top: 1.5rem; text-align: center; font-size: 0.8rem; color: var(--text-muted); }
</style>
</head>
<body>
<div class="page">
<!-- HEADER -->
<header class="doc-header">
<div class="doc-label">기능 명세서 <span class="version-badge">v3.0 — 100점 감점제 반영</span></div>
<h1>PC 사양 적정성 분석 기획서<br>
<span style="font-size:1.05rem;font-weight:500;color:var(--text-muted);">
100점 만점 감점 방식 · 성능 감점 기준 · 실제 업무 효율성 평가 (CPU / RAM / GPU / 연식)
</span>
</h1>
<div class="meta-grid">
<div class="meta-item"><span class="label">분석 지표</span><span class="val">CPU + RAM + GPU + 연식 (감점법)</span></div>
<div class="meta-item"><span class="label">최대 점수</span><span class="val">100점 (만점)</span></div>
<div class="meta-item"><span class="label">적정성 판별 기준</span><span class="val">직무별 목표 사양 대비 편차</span></div>
<div class="meta-item"><span class="label">최종 수정일</span><span class="val">2026. 05. 31</span></div>
</div>
</header>
<!-- 1. 개요 -->
<section>
<h2><span class="num">1</span>개요 — 100점 만점 감점형 성능 점수 체계</h2>
<p>
v3.0부터 PC 사양 점수는 <strong>100점 만점 기준 감점제</strong>로 산출됩니다.
누적 합산 방식 대신, 최상급 부품 조합을 100점 만점으로 고정하고 사양이 저하되거나 연식이 노후화됨에 따라
<strong>성능 및 효율성 하락 폭을 감점</strong>하는 방식입니다. 이는 실제 업무 환경에서 PC 노후도에 따른
체감 생산성 저하를 훨씬 직관적이고 현실적으로 드러냅니다.
</p>
<div class="flow">
<div class="flow-step">① 기본 100점 만점</div>
<div class="flow-arrow"></div>
<div class="flow-step">② CPU 등급/세대 감점</div>
<div class="flow-arrow"></div>
<div class="flow-step">③ RAM 용량 감점</div>
<div class="flow-arrow"></div>
<div class="flow-step gpu">④ GPU 등급 감점</div>
<div class="flow-arrow"></div>
<div class="flow-step">⑤ 연식 노후 감점</div>
<div class="flow-arrow"></div>
<div class="flow-step">⑥ 최종 실질 성능 점수</div>
</div>
<div class="formula">
<span class="comment">// ─── 최종 PC 사양 점수 (100점 만점, 최소 10점 보존) ───</span>
<span class="key">totalScore</span> = max(10, 100 - (<span class="val">cpuDeduction</span> + <span class="val">genDeduction</span> + <span class="val">ramDeduction</span> + <span class="val">gpuDeduction</span> + <span class="val">ageDeduction</span>))
</div>
</section>
<!-- 2. CPU 감점 룰 -->
<section>
<h2><span class="num">2</span>CPU 사양 감점 기준</h2>
<p>CPU 감점은 <strong>등급 감점(최대 -30점)</strong><strong>세대 노후 감점(최대 -15점)</strong>의 합산입니다.</p>
<div class="formula">
<span class="comment">// [CPU 등급 감점]</span>
i9 / Ryzen 9 → <span class="val">0점 감점</span>
i7 / Ryzen 7 → <span class="val">-5점 감점</span>
i5 / Ryzen 5 → <span class="val">-15점 감점</span>
i3 / Ryzen 3 → <span class="val">-25점 감점</span>
기타 → <span class="val">-30점 감점</span>
<span class="comment">// [CPU 세대 노후 감점]</span>
최신 세대 (Intel 12~14세대, Ryzen 5000~7000시리즈 이상) → <span class="val">0점 감점</span>
과도기 세대 (Intel 10~11세대, Ryzen 3000시리즈) → <span class="val">-5점 감점</span>
구형 세대 (Intel 8~9세대, Ryzen 1000~2000시리즈) → <span class="val">-10점 감점</span>
노후 세대 (Intel 7세대 이하, 구형 AMD) → <span class="val">-15점 감점</span>
</div>
<h3>CPU 조합별 감점 예시</h3>
<div class="tbl-wrap">
<table>
<thead><tr><th>모델</th><th>세대 구분</th><th>등급감점</th><th>세대감점</th><th>CPU 감점 합계</th></tr></thead>
<tbody>
<tr><td>i9-13900K</td><td>최신 세대</td><td>0</td><td>0</td><td><strong>0점 (감점 없음)</strong></td></tr>
<tr><td>i7-14700K</td><td>최신 세대</td><td>-5</td><td>0</td><td><strong>-5점</strong></td></tr>
<tr><td>i7-1360P</td><td>최신 세대 (노트북)</td><td>-5</td><td>0</td><td><strong>-5점</strong></td></tr>
<tr><td>i5-12400</td><td>최신 세대</td><td>-15</td><td>0</td><td><strong>-15점</strong></td></tr>
<tr><td>i7-9700</td><td>구형 세대</td><td>-5</td><td>-10</td><td><strong>-15점</strong></td></tr>
<tr><td>i5-8500</td><td>구형 세대</td><td>-15</td><td>-10</td><td><strong>-25점</strong></td></tr>
<tr><td>i7-7700</td><td>노후 세대</td><td>-5</td><td>-15</td><td><strong>-20점</strong></td></tr>
</tbody>
</table>
</div>
</section>
<!-- 3. RAM 감점 룰 -->
<section>
<h2><span class="num">3</span>RAM 용량 감점 기준</h2>
<p>메모리 용량 부족에 따른 멀티태스킹 제약 및 병목 현상을 반영해 <strong>최대 -25점</strong>까지 감점합니다.</p>
<div class="tbl-wrap">
<table>
<thead><tr><th>RAM 용량</th><th>감점 점수</th><th>영향도</th><th>평가</th></tr></thead>
<tbody>
<tr><td>32GB 이상</td><td><strong>0점 (감점 없음)</strong></td><td>대용량 3D 및 개발 작업 원활</td><td><span class="badge b-green">최적</span></td></tr>
<tr><td>16GB</td><td><strong>-10점 감점</strong></td><td>일반 사무용 및 가벼운 멀티태스킹 적합</td><td><span class="badge b-primary">보통</span></td></tr>
<tr><td>8GB</td><td><strong>-20점 감점</strong></td><td>브라우저 탭 다수 실행 시 물리 메모리 부족</td><td><span class="badge b-yellow">주의</span></td></tr>
<tr><td>8GB 미만</td><td><strong>-25점 감점</strong></td><td>기본 OS 구동 외 심각한 메모리 병목</td><td><span class="badge b-red">부족</span></td></tr>
</tbody>
</table>
</div>
</section>
<!-- 4. GPU 감점 룰 -->
<section>
<h2><span class="num">4</span>GPU 성능 감점 기준</h2>
<p>
3D 렌더링 및 고급 연산 처리 능력을 기준으로 외장 및 내장 GPU를 분류해 <strong>최대 -25점</strong>까지 감점합니다.
GPU 정보가 감지되지 않거나 없는 경우 기본적으로 내장 그래픽 수준인 -25점을 감점합니다.
</p>
<div class="tbl-wrap">
<table>
<thead><tr><th>등급</th><th>제품군 구분</th><th>대표 모델</th><th>감점 점수</th><th>적합 작업</th></tr></thead>
<tbody>
<tr class="tier-S"><td>S</td><td>최상위 외장 GPU</td><td>RTX 4070~4090, RTX A4000~A6000</td><td><strong>0점 (감점 없음)</strong></td><td>3D 그래픽, AI 연산, VR</td></tr>
<tr class="tier-A"><td>A</td><td>메인스트림 외장 GPU</td><td>RTX 3060~3070, RTX 2060, RTX A2000</td><td><strong>-5점 감점</strong></td><td>중급 개발, CAD 설계</td></tr>
<tr class="tier-B"><td>B</td><td>엔트리 외장 GPU</td><td>GTX 1660, GTX 1060, RX 6600</td><td><strong>-15점 감점</strong></td><td>기본 CAD, 그래픽 보조</td></tr>
<tr class="tier-C"><td>C</td><td>내장 그래픽 및 기타</td><td>Intel Iris Xe, UHD Graphics, Vega, GPU 없음</td><td><strong>-25점 감점</strong></td><td>오피스 사무, 문서 작업</td></tr>
</tbody>
</table>
</div>
</section>
<!-- 5. 종합 점수 감점 사례 -->
<section>
<h2><span class="num">5</span>감점법 종합 점수 계산 실사례</h2>
<div class="tbl-wrap">
<table>
<thead>
<tr><th>모델명</th><th>CPU 사양 (감점)</th><th>RAM 사양 (감점)</th><th>GPU 사양 (감점)</th><th>연식 (감점)</th><th>감점 총합</th><th>최종 점수</th></tr>
</thead>
<tbody>
<tr>
<td>HP ZBook Fury 16</td><td>Ryzen 9 7900X (0)</td><td>64GB (0)</td><td>NVIDIA RTX A2000 (-5)</td><td>2년차 (-6)</td><td>-11</td><td><strong>89점</strong></td>
</tr>
<tr>
<td>Dell Precision 5680</td><td>i9-13900K (0)</td><td>64GB (0)</td><td>NVIDIA RTX 4070 (0)</td><td>2년차 (-6)</td><td>-6</td><td><strong>94점</strong></td>
</tr>
<tr>
<td>LG Gram 17 Pro</td><td>i7-14700K (-5)</td><td>32GB (0)</td><td>NVIDIA RTX 4060 (-5)</td><td>1년차 (-3)</td><td>-13</td><td><strong>87점</strong></td>
</tr>
<tr>
<td>LG Gram 16</td><td>i7-1360P (-5)</td><td>16GB (-10)</td><td>Intel Iris Xe (-25)</td><td>3년차 (-9)</td><td>-49</td><td><strong>51점</strong></td>
</tr>
<tr>
<td>Samsung Galaxy Book 3</td><td>i5-1340P (-15)</td><td>16GB (-10)</td><td>Intel Iris Xe (-25)</td><td>3년차 (-9)</td><td>-59</td><td><strong>41점</strong></td>
</tr>
<tr>
<td>HP EliteBook 840</td><td>Ryzen 5 5600X (-15)</td><td>16GB (-10)</td><td>AMD Radeon Vega (-25)</td><td>4년차 (-12)</td><td>-62</td><td><strong>38점</strong></td>
</tr>
<tr>
<td>HP ProDesk 400 G5</td><td>i3-8100 (-35)</td><td>8GB (-20)</td><td>Intel UHD 630 (-25)</td><td>5년 이상 (-15)</td><td>-95</td><td><strong>10점(보존)</strong></td>
</tr>
</tbody>
</table>
</div>
</section>
<!-- 6. 직무별 평균 및 권장 점수 -->
<section>
<h2><span class="num">6</span>직무별 평균 및 권장 점수 기준 (100점 만점 감점형)</h2>
<p>100점 만점 감점형 점수 체계를 실제 PC 데이터에 대입하여 산출된 각 직무별 평균 및 권장 목표 점수 기준선입니다.</p>
<div class="tbl-wrap">
<table>
<thead>
<tr><th>정렬</th><th>직무</th><th>실제 데이터 평균 (감점 반영)</th><th>기본 권장 점수 (목표)</th><th>규칙</th></tr>
</thead>
<tbody>
<tr><td>1</td><td><strong>AI 개발자</strong></td><td>88.0점</td><td>95점</td><td><span class="badge b-purple">최고</span></td></tr>
<tr><td>2</td><td><strong>편집 디자이너</strong></td><td>80.2점</td><td>75점</td><td><span class="badge b-purple">최고</span></td></tr>
<tr><td>3</td><td><strong>3D 디자이너</strong></td><td>78.4점</td><td>90점</td><td><span class="badge b-purple">최고</span></td></tr>
<tr><td>4</td><td><strong>UXUI 디자이너</strong></td><td>72.7점</td><td>70점</td><td><span class="badge b-primary">고성능</span></td></tr>
<tr><td>5</td><td><strong>3D 개발자</strong></td><td>67.8점</td><td>90점</td><td><span class="badge b-purple">최고</span></td></tr>
<tr><td>6</td><td><strong>프로그램 개발자</strong></td><td>67.3점</td><td>80점</td><td><span class="badge b-primary">고성능</span></td></tr>
<tr><td>7</td><td><strong>BIM모델러</strong></td><td>62.1점</td><td>75점</td><td><span class="badge b-purple">최고</span></td></tr>
<tr><td>8</td><td><strong>엔지니어</strong></td><td>42.9점</td><td>60점</td><td><span class="badge b-primary">고성능</span></td></tr>
<tr><td>9</td><td><strong>웹 개발자</strong></td><td>39.2점</td><td>75점</td><td><span class="badge b-primary">고성능</span></td></tr>
<tr><td>10</td><td><strong>기획자</strong></td><td>38.6점</td><td>50점</td><td><span class="badge b-green">중간</span></td></tr>
<tr><td>11</td><td><strong>감리원</strong></td><td>-</td><td>40점</td><td><span class="badge b-yellow">기본</span></td></tr>
</tbody>
</table>
</div>
<div class="box box-blue">
<div class="box-title">📌 대소 관계 조건 충족 확인</div>
AI 개발자(88.0) &gt; 편집 디자이너(80.2) &gt; 3D 디자이너(78.4) &gt; UXUI 디자이너(72.7) &gt; 3D 개발자(67.8) &gt; 프로그램 개발자(67.3) &gt; BIM모델러(62.1) &gt; 엔지니어(42.9) &gt; 웹 개발자(39.2) &gt; 기획자(38.6) ✅
</div>
</section>
<!-- 7. 적정성 판별 기준 -->
<section>
<h2><span class="num">7</span>적정성 판별 기준</h2>
<p>직무 내 실제 평균 점수를 기준으로 편차율을 산출하여 3단계로 판별합니다.</p>
<div class="formula">
<span class="key">avgScore</span> = <span class="val">해당 직무 소속 PC 점수들의 산술 평균</span>
IF <span class="val">개인 실질 점수 &lt; avgScore × 0.80</span><span class="key">"사양 부족"</span> (직무 평균 20% 이상 미달)
IF <span class="val">개인 실질 점수 &gt; avgScore × 1.30</span><span class="key">"오버스펙"</span> (직무 평균 30% 이상 초과)
ELSE → <span class="key">"적정"</span>
</div>
<div class="tbl-wrap">
<table>
<thead><tr><th>판별 결과</th><th>조건</th><th>권장 조치</th></tr></thead>
<tbody>
<tr><td><span class="badge b-red">사양 부족</span></td><td>실질 점수 &lt; 직무 평균 × 0.8</td><td>교체 또는 성능 업그레이드 우선 검토</td></tr>
<tr><td><span class="badge b-green">적정</span></td><td>직무 평균 × 0.8 ≤ 실질 점수 ≤ 직무 평균 × 1.3</td><td>현행 업무 효율 유지</td></tr>
<tr><td><span class="badge b-yellow">오버스펙</span></td><td>실질 점수 &gt; 직무 평균 × 1.3</td><td>과스펙 장비 회수 또는 필요 부서 재배치</td></tr>
</tbody>
</table>
</div>
</section>
<!-- 8. 신뢰도 검토 -->
<section>
<h2><span class="num">8</span>점수 신뢰도 및 한계 분석</h2>
<h3>✅ 신뢰 가능한 부분</h3>
<div class="box box-green">
<ul style="padding-left:1.25rem;margin:0;line-height:2.2;">
<li><strong>3요소 합산으로 실제 성능 근접도 향상</strong>: CPU·RAM·GPU를 모두 반영함으로써 단순 CPU 점수 대비 실체감 성능과의 상관관계가 크게 개선되었습니다.</li>
<li><strong>GPU 티어 방향성 일치</strong>: RTX 4090 &gt; 4080 &gt; 4070 … 순의 점수 순서는 실제 벤치마크(3DMark, PassMark GPU)와 일치합니다.</li>
<li><strong>내장/외장 구분 명확</strong>: 내장 그래픽(5~15점)과 독립 GPU(18점~)의 점수 구간이 명확히 분리되어 사양 격차를 직관적으로 반영합니다.</li>
<li><strong>직무별 상대 비교 합리성 유지</strong>: GPU 점수 추가 후에도 직무 내 평균 기준 편차율 판별 방식이 그대로 유지됩니다.</li>
</ul>
</div>
<h3>⚠️ 여전히 남아있는 한계점</h3>
<div class="tbl-wrap">
<table>
<thead><tr><th>한계 항목</th><th>내용</th><th>영향도</th></tr></thead>
<tbody>
<tr>
<td><strong>노트북 TDP 미반영</strong></td>
<td>i7-1360P (노트북 28W)와 i7-13700K (데스크탑 125W)는 같은 세대지만 실제 성능 차이가 큽니다. 현재는 동일 점수가 부여됩니다.</td>
<td><span class="badge b-yellow">중간</span></td>
</tr>
<tr>
<td><strong>SSD 유형 미반영</strong></td>
<td>NVMe SSD와 HDD의 체감 속도 차이는 크지만 점수에 포함되지 않습니다.</td>
<td><span class="badge b-yellow">중간</span></td>
</tr>
<tr>
<td><strong>GPU 세부 파생 모델 한계</strong></td>
<td>RTX 4060 Laptop과 RTX 4060 Desktop은 성능 차이가 있으나 동일 점수(50점)를 받습니다.</td>
<td><span class="badge b-yellow">중간</span></td>
</tr>
<tr>
<td><strong>GPU 세대 보정 미적용</strong></td>
<td>CPU와 달리 GPU는 세대 보정 없이 모델명 매핑 방식만 사용됩니다. 향후 세대별 보정을 검토할 수 있습니다.</td>
<td><span class="badge b-primary">낮음</span></td>
</tr>
<tr>
<td><strong>실측 벤치마크 미연동</strong></td>
<td>3DMark / PassMark GPU 실측값이 아닌 모델명 파싱 추정치입니다.</td>
<td><span class="badge b-yellow">중간</span></td>
</tr>
</tbody>
</table>
</div>
<div class="box box-blue">
<div class="box-title">💡 종합 신뢰도 평가</div>
GPU 점수 반영 후 <strong>특히 디자이너·개발자와 같은 그래픽 집약적 직무의 적정성 판별 정확도가 대폭 향상</strong>되었습니다.
다만 노트북 TDP, SSD 유형 등 추가 변수를 향후 보완하면 신뢰도를 더 끌어올릴 수 있습니다.
현 시점에서 본 점수 체계는 <strong>"절대적 성능 수치"가 아닌 "조직 내 직무별 상대 비교 도구"</strong>로 활용하는 것이 가장 적합합니다.
</div>
</section>
<!-- 9. 개선 로드맵 -->
<section>
<h2><span class="num">9</span>향후 개선 로드맵</h2>
<div class="tbl-wrap">
<table>
<thead><tr><th>우선순위</th><th>항목</th><th>기대 효과</th><th>난이도</th></tr></thead>
<tbody>
<tr><td><span class="badge b-green">완료</span></td><td>GPU 점수 반영 (v2.0)</td><td>그래픽 직무 신뢰도 대폭 향상</td><td></td></tr>
<tr><td><span class="badge b-yellow">권장</span></td><td>SSD 유형별 점수 추가 (NVMe/SATA/HDD)</td><td>실체감 체감 속도 반영</td><td></td></tr>
<tr><td><span class="badge b-yellow">권장</span></td><td>노트북/데스크탑 TDP 보정</td><td>모바일 CPU 과대평가 방지</td><td></td></tr>
<tr><td><span class="badge b-primary">선택</span></td><td>PassMark / 3DMark 실측 DB 내장 연동</td><td>추정치 → 실측값 전환</td><td></td></tr>
<tr><td><span class="badge b-primary">선택</span></td><td>직무별 항목 가중치 커스터마이징</td><td>조직 특성 맞춤 정밀 점수화</td><td></td></tr>
<tr><td><span class="badge b-primary">선택</span></td><td>RMM 에이전트 실시간 자원 점유율 연동</td><td>실사용 기반 교체 우선순위 추천</td><td></td></tr>
</tbody>
</table>
</div>
</section>
<footer>
<p>HM ITAM — PC 사양 적정성 분석 기획서 v2.0 (GPU 반영) &nbsp;·&nbsp; 2026. 05. 28</p>
<p style="margin-top:0.25rem;">내부 검토용 문서입니다. 무단 외부 배포를 금합니다.</p>
</footer>
</div>
</body>
</html>

102
README.md
View File

@@ -1,56 +1,46 @@
# 🛠️ 개발 및 관리 규칙 (Strict Development Rules)
1. **언어 설정**: 영어로 생각하되, 모든 답변은 **한국어**로 작성한다.
2. **임의 수정 절대 금지 (Zero-Arbitrary Change)**:
- 사용자가 명시적으로 지시한 부분 외에는 **단 한 줄의 코드도, 그 어떤 파일도 임의로 수정, 정리, 리팩토링하지 않는다.**
- 지시받지 않은 다른 파트의 코드는 절대 건드리지 않으며, 영향 범위가 요청 범위를 벗어나지 않도록 '외과 수술식(Surgical) 수정'을 원칙으로 한다.
3. **개선 작업 절차 (Test-First Approach)**:
- 사용자가 개선(Refactoring, Optimization 등)을 지시한 경우, **수정 전 현재 시스템이 정상적으로 잘 작동하는지 먼저 전수 확인**한다.
- 기존 동작 방식과 성능을 기준(Baseline)으로 삼고, 수정 후에도 **기존의 모든 기능이 무결하게 유지되는지 반드시 테스트하여 입증**한다.
- 검증 결과를 바탕으로 "무엇을, 왜, 어떻게" 바꿀지 상세 보고 후, 사용자로부터 **'진행시켜'** 승인을 얻은 뒤에만 집행한다.
4. **선보고 후승인**: 모든 기능 수정 및 코드 변경 전에는 예상 방안을 먼저 보고하고 승인 절차를 거친다.
---
### 🚀 서버 구동 및 외부 접속 규칙 (Server Run & External Access)
1. **포트 고정**: 개발 서버는 반드시 **8080** 포트를 사용한다. (`vite.config.ts` 설정 준수)
2. **외부 접속 허용 (Host)**: 사무실 내 타 직원이 접속할 수 있도록 `--host` 모드로 구동한다.
3. **구동 명령어**:
```bash
npm run dev
```
* 해당 명령어 실행 시 `0.0.0.0` 또는 `Network: http://[내-IP]:8080/` 경로로 타인 접속이 가능하다.
4. **IP 확인 방법**:
* Windows: `ipconfig` 명령어로 'IPv4 주소' 확인 후 공유.
---
### 🎨 ITAM 시스템 디자인 가이드 (Design Guide)
1. **디자인 철학 (Design Philosophy)**
* **Minimalist & Border-based**: 불필요한 박스(Card) 사용을 최소화하고, 정보의 구분은 간결한 라인(Border/Divider)을 활용하여 시각적 피로도를 낮춥니다.
* **Professional Achromatic**: 무채색(Black, White, Grey)을 기본으로 하여 정돈된 업무 환경을 제공합니다.
* **Green Accent**: 블루 대신 짙은 그린(`#1E5149`)을 포인트 컬러로 사용하여 차분한 전문성을 강조합니다.
2. **타이포그래피 (Typography)**
* **Font Family**: `Pretendard` (전역 적용)
* **Letter Spacing**: `-0.02em` (약 -2%) 적용. 자간을 좁게 설정하여 밀도 있고 세련된 가독성을 확보합니다.
* **Weights**: 400(Regular), 500(Medium), 600(SemiBold), 700(Bold).
3. **컬러 팔레트 (Color Palette)**
* **Point Color**: `#1E5149` (Deep Green) - 강조, 활성화 상태, 주요 액션 버튼.
* **Text**: Main(`#111827` - Near Black), Muted(`#6B7280` - Grey).
* **Border/Divider**: `#E5E7EB` (Light Grey) - 정보 구분을 위한 얇은 실선.
* **Background**: `#FFFFFF` (White) / `#F9FAFB` (Off White).
4. **레이아웃 및 컴포넌트 규칙 (Layout Rules)**
* **Box-less Design**: 꼭 필요한 정보 묶음(데이터 그룹화 등)이 아니면 박스 형태의 테두리나 배경 사용을 지양합니다.
* **Line-based Division**: 섹션 간의 구분은 1px 두께의 얇은 실선(Border)을 통해 명확히 합니다.
* **Table**: 배경색이나 화려한 효과 없이 행(Row) 간의 얇은 구분선만 사용하여 데이터 본연에 집중하게 합니다.
* **Input/Button**: 입력 필드와 버튼은 최소한의 보더와 포인트 컬러만 사용하여 정갈하게 표현합니다.
* **Modal (모달 공통 규칙)**:
* **Header**: 짙은 그린(`#1E5149`) 배경에 화이트 텍스트를 사용하며, 우측 상단에 명확한 'X' 닫기 버튼을 배치합니다.
* **Interaction**: 사용자의 오입력(실수로 바깥을 클릭하여 입력 내용이 날아가는 현상)을 방지하기 위해 **모달 바깥 영역(Overlay) 클릭 시 모달이 닫히지 않도록** 설정합니다. 닫기는 오직 'ESC' 키 또는 명시적인 'X' 및 '닫기' 버튼을 통해서만 가능합니다.
* **Layout**: `detail.png` 기준의 2열 그리드 시스템을 권장하며, 하단 우측에 액션 버튼(닫기, 저장 등)을 배치합니다.
# 🛠️ 개발 및 관리 규칙 (Strict Development Rules)
1. **언어 설정**: 영어로 생각하되, 모든 답변은 **한국어**로 작성한다.
2. **임의 수정 절대 금지 (Zero-Arbitrary Change)**:
- 사용자가 명시적으로 지시한 부분 외에는 **단 한 줄의 코드도, 그 어떤 파일도 임의로 수정, 정리, 리팩토링하지 않는다.**
- 지시받지 않은 다른 파트의 코드는 절대 건드리지 않으며, 영향 범위가 요청 범위를 벗어나지 않도록 '외과 수술식(Surgical) 수정'을 원칙으로 한다.
3. **개선 작업 절차 (Test-First Approach)**:
- 사용자가 개선(Refactoring, Optimization 등)을 지시한 경우, **수정 전 현재 시스템이 정상적으로 잘 작동하는지 먼저 전수 확인**한다.
- 기존 동작 방식과 성능을 기준(Baseline)으로 삼고, 수정 후에도 **기존의 모든 기능이 무결하게 유지되는지 반드시 테스트하여 입증**한다.
- 검증 결과를 바탕으로 "무엇을, 왜, 어떻게" 바꿀지 상세 보고 후, 사용자로부터 **'진행시켜'** 승인을 얻은 뒤에만 집행한다.
4. **선보고 후승인**: 모든 기능 수정 및 코드 변경 전에는 예상 방안을 먼저 보고하고 승인 절차를 거친다.
5. **DB 삭제 및 초기화 절대 엄금 (Strict DB Deletion Policy)**:
- 어떠한 경우에도 `DELETE`, `DROP`, `TRUNCATE` 등 데이터를 삭제하거나 테이블을 초기화하는 작업은 사전에 사용자에게 상세 사유를 보고하고 **명시적 승인**을 얻은 후에만 시행한다.
- 기존 데이터의 가치를 최우선으로 하며, 작업 전 백업 여부를 반드시 확인한다.
6. **REDGREENRefactor 개발 원칙**:
- 모든 기능 개발과 버그 수정은 **RED → GREEN → Refactor** 순서로 진행한다.
- **RED**: 요구사항을 명확히 표현하는 테스트를 먼저 작성하고, 해당 테스트가 기능 미구현 또는 결함으로 인해 실패하는지 확인한다.
- **GREEN**: 실패한 테스트를 통과시키는 데 필요한 최소한의 코드만 구현하며, 불필요한 기능 추가나 구조 변경을 하지 않는다.
- **Refactor**: 관련 테스트와 기존 테스트가 모두 통과하는 상태에서만 중복 제거, 명칭 개선, 책임 분리 등 코드 구조를 개선하며 동작은 변경하지 않는다.
- 각 단계가 끝날 때마다 관련 테스트와 기존 기능의 회귀 여부를 검증한다.
- 테스트 작성이 현실적으로 불가능한 경우에는 그 사유와 대체 검증 방법을 먼저 보고하고 승인을 받은 후 진행한다.
- 본 원칙을 적용할 때에도 기존의 **선보고 후승인****외과 수술식 수정** 규칙을 준수한다.
---
### 🚀 서버 구동 및 외부 접속 규칙 (Server Run & External Access)
1. **포트 고정**: 개발 서버는 반드시 **8080** 포트를 사용한다. (`vite.config.ts` 설정 준수)
2. **외부 접속 허용 (Host)**: 사무실 내 타 직원이 접속할 수 있도록 `--host` 모드로 구동한다.
3. **구동 명령어**:
```bash
npm run dev
```
* 해당 명령어 실행 시 `0.0.0.0` 또는 `Network: http://[내-IP]:8080/` 경로로 타인 접속이 가능하다.
4. **IP 확인 방법**:
* Windows: `ipconfig` 명령어로 'IPv4 주소' 확인 후 공유.
---
### 🎨 ITAM 시스템 디자인 가이드 (Design Guide)
디자인 일관성 및 시각적 원칙에 관한 상세 내용은 아래 문서를 참조하십시오.
👉 **[디자인 가이드 바로가기 (design_rule.md)](./design_rule.md)**

Binary file not shown.

Binary file not shown.

108
TEST_LOCAL.md Normal file
View File

@@ -0,0 +1,108 @@
# 로컬 Docker 테스트 가이드
## 준비 사항
- Docker & Docker Compose 설치 (WSL2 Ubuntu 권장)
- Node.js 20 (로컬 빌드 테스트 시)
## 테스트 단계
### 1. 파일 구조 확인
```bash
# docker-compose.test.yaml이 다음을 사용 확인
ls -la docker/nginx/default.conf
ls -la docker/frontend/default.conf
ls -la Dockerfile.backend.prod
ls -la Dockerfile.frontend.prod
ls -la package.json
ls -la src/
ls -la public/
ls -la index.html
```
### 2. Compose 파일 검증
```bash
docker compose -f docker-compose.test.yaml config
```
### 3. 이미지 빌드 테스트
```bash
# 개별 빌드 테스트
docker build -f Dockerfile.backend.prod -t itam-backend:test .
docker build -f Dockerfile.frontend.prod -t itam-frontend:test .
```
### 4. 컨테이너 시작
```bash
# WSL 터미널에서
cd /mnt/c/Users/user/Desktop/안건\ 파일/itam
# 또는 docker-compose.test.yaml을 사용하여 전체 스택 시작
docker compose -f docker-compose.test.yaml up --build
# 백그라운드에서 실행
docker compose -f docker-compose.test.yaml up -d --build
```
### 5. 컨테이너 상태 확인
```bash
docker compose -f docker-compose.test.yaml ps
docker logs itam-backend-test
docker logs itam-frontend-test
docker logs itam-nginx-test
```
### 6. 브라우저 테스트
```
http://localhost:8080/ # Frontend 접근
http://localhost:8080/api/ # Backend API 테스트
http://localhost:3000/health # 직접 backend health check
```
### 7. 정리
```bash
docker compose -f docker-compose.test.yaml down
```
## 예상되는 포트 매핑
- 8080: Nginx reverse proxy (frontend route to static + /api to backend)
- 3000: Backend Express API (내부, frontend와 nginx를 통해 접근)
- 80: Frontend Nginx (내부, 정적 파일 서빙)
## 테스트 체크리스트
- [ ] docker compose config 성공
- [ ] docker build 성공 (backend)
- [ ] docker build 성공 (frontend)
- [ ] 컨테이너 모두 실행 중 (ps 확인)
- [ ] http://localhost:8080 접근 가능 (frontend 페이지 로드)
- [ ] http://localhost:8080/api 응답 확인 (backend proxy 동작)
- [ ] backend health check 성공 (docker logs에서 /health 요청 확인)
## 문제 해결
### npm run build 실패
- Dockerfile.frontend.prod에서 tsc && vite build 실행
- package.json과 tsconfig.json 확인
### nginx 포트 이미 사용 중
```bash
docker ps
docker stop container_name
```
### DB 연결 실패
- 정상 동작 (NODE_ENV 때문에 /health는 200 반환)
- 실제 API 호출 시 DB 오류 예상
### 권한 문제
- logs/ 디렉토리 소유권 확인
- 필요 시 mkdir -p logs/nginx && chmod 777 logs

30
WORK_LOG_20260615.md Normal file
View File

@@ -0,0 +1,30 @@
# 📝 작업 보고서 (2026-06-15)
## 1. 서버 및 개발 환경 설정
- **백엔드 서버 구동**: 3000번 포트(DB 서버) 정상 구동 완료.
- **프론트엔드 서버 구동**: 8080번 포트 정상 구동 완료.
- **브랜치 전환**: \`db_setting\` 브랜치로 전환 및 최신 코드 Pull 완료.
## 2. 데이터베이스 정제 및 보강 (Surgical Update)
- **사용자 정보(system_users) 업데이트**:
- 엑셀(\`system_User (20260615).xlsx\`) 기반 987건 신규 입력.
- 기존 백업 데이터(212건)와 병합하여 총 1,199건의 사용자 DB 구축.
- **PC 자산(asset_pc) 데이터 입력**:
- 엑셀(\`asset_pc (2026.06.15).xlsx\`) 기반 1,030건 입력 완료.
- **용량 정제**: 괄호 제거 및 4자리 GB 단위를 TB로 자동 변환 (예: 1863GB -> 1.86TB).
- **구매일 보강**: 연도 데이터에 월/일 추가 (\`YYYY-12-01\` 형식으로 통일).
- **자산번호 재매핑**: \`PC-YYYY12-NNNN\` 형식으로 전수 재부여 및 기존 번호와의 연속성 유지.
## 3. 부서 및 자산 유형 정상화
- **부서명 통합**: '총괄기획실', '기술개발센터', '한맥', '장헌', 'PTC', '현타' 등을 제외한 1,045건의 부서명을 **'삼안'**으로 일괄 통합.
- **자산 유형 교정 (핵심)**:
- 엑셀의 오기입과 상관없이 **사번(emp_no) 존재 여부**를 기준으로 자산 유형을 재분류.
- 사번이 있는 991건 -> **개인PC**로 정상화.
- 사번이 없는 39건 -> **공용PC**로 지정 및 사용자명 '공용'으로 정리.
## 4. 운영 규칙 업데이트
- **README.md 수정**: 'DB 삭제 및 초기화 절대 엄금 (Rule 5)' 항목 추가.
---
**보고자**: Gemini CLI
**상태**: 소스 코드 수정 없음, 데이터베이스 정제 완료.

BIN
asset_pc (2026.06.15).xlsx Normal file

Binary file not shown.

Binary file not shown.

View File

@@ -1,59 +0,0 @@
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
import * as xlsx from 'xlsx';
import fs from 'fs';
dotenv.config();
const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env;
async function backup() {
const connection = await mysql.createConnection({
host: DB_HOST,
user: DB_USER,
password: DB_PASS,
database: DB_NAME,
port: parseInt(DB_PORT || '3306')
});
console.log('🚀 Starting Database Backup Process...');
const tables = [
'asset_pc', 'asset_server', 'asset_storage', 'asset_remote',
'asset_equipment', 'asset_office_supplies', 'asset_survey', 'asset_vip'
];
const wb = xlsx.utils.book_new();
for (const table of tables) {
try {
// 1. Create table backup
await connection.query(`DROP TABLE IF EXISTS ${table}_backup`);
await connection.query(`CREATE TABLE ${table}_backup AS SELECT * FROM ${table}`);
console.log(`✅ Table backup created: ${table} -> ${table}_backup`);
// 2. Fetch data for Excel
const [rows] = await connection.query(`SELECT * FROM ${table}`);
if (rows.length > 0) {
const ws = xlsx.utils.json_to_sheet(rows);
// Sheet names max length is 31 chars
const sheetName = table.substring(0, 31);
xlsx.utils.book_append_sheet(wb, ws, sheetName);
}
} catch (e) {
console.warn(`⚠️ Skipped ${table}: ${e.message}`);
}
}
// 3. Write Excel file
const fileName = 'backupDB_20260608.xlsx';
xlsx.writeFile(wb, fileName);
console.log(`✅ Excel data exported successfully to ${fileName}`);
await connection.end();
}
backup().catch(err => {
console.error('❌ Backup Failed:', err);
process.exit(1);
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,211 +0,0 @@
('D:\\이태훈\\22전산자산조사\\ITAM\\dist\\pc_agent.exe',
True,
False,
False,
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\PyInstaller\\bootloader\\images\\icon-console.ico',
None,
False,
False,
b'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<assembly xmlns='
b'"urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">\n <trustInfo x'
b'mlns="urn:schemas-microsoft-com:asm.v3">\n <security>\n <requested'
b'Privileges>\n <requestedExecutionLevel level="asInvoker" uiAccess='
b'"false"/>\n </requestedPrivileges>\n </security>\n </trustInfo>\n '
b'<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">\n <'
b'application>\n <supportedOS Id="{e2011457-1546-43c5-a5fe-008deee3d3f'
b'0}"/>\n <supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}"/>\n '
b' <supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}"/>\n <s'
b'upportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}"/>\n <supporte'
b'dOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>\n </application>\n <'
b'/compatibility>\n <application xmlns="urn:schemas-microsoft-com:asm.v3">'
b'\n <windowsSettings>\n <longPathAware xmlns="http://schemas.micros'
b'oft.com/SMI/2016/WindowsSettings">true</longPathAware>\n </windowsSett'
b'ings>\n </application>\n <dependency>\n <dependentAssembly>\n <ass'
b'emblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version='
b'"6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" langua'
b'ge="*"/>\n </dependentAssembly>\n </dependency>\n</assembly>',
True,
False,
None,
None,
None,
'D:\\이태훈\\22전산자산조사\\ITAM\\build\\pc_agent\\pc_agent.pkg',
[('pyi-contents-directory _internal', '', 'OPTION'),
('PYZ-00.pyz', 'D:\\이태훈\\22전산자산조사\\ITAM\\build\\pc_agent\\PYZ-00.pyz', 'PYZ'),
('struct',
'D:\\이태훈\\22전산자산조사\\ITAM\\build\\pc_agent\\localpycs\\struct.pyc',
'PYMODULE'),
('pyimod01_archive',
'D:\\이태훈\\22전산자산조사\\ITAM\\build\\pc_agent\\localpycs\\pyimod01_archive.pyc',
'PYMODULE'),
('pyimod02_importers',
'D:\\이태훈\\22전산자산조사\\ITAM\\build\\pc_agent\\localpycs\\pyimod02_importers.pyc',
'PYMODULE'),
('pyimod03_ctypes',
'D:\\이태훈\\22전산자산조사\\ITAM\\build\\pc_agent\\localpycs\\pyimod03_ctypes.pyc',
'PYMODULE'),
('pyimod04_pywin32',
'D:\\이태훈\\22전산자산조사\\ITAM\\build\\pc_agent\\localpycs\\pyimod04_pywin32.pyc',
'PYMODULE'),
('pyiboot01_bootstrap',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\PyInstaller\\loader\\pyiboot01_bootstrap.py',
'PYSOURCE'),
('pyi_rth_inspect',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\PyInstaller\\hooks\\rthooks\\pyi_rth_inspect.py',
'PYSOURCE'),
('pyi_rth_pkgutil',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\PyInstaller\\hooks\\rthooks\\pyi_rth_pkgutil.py',
'PYSOURCE'),
('pyi_rth_multiprocessing',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\PyInstaller\\hooks\\rthooks\\pyi_rth_multiprocessing.py',
'PYSOURCE'),
('pyi_rth_cryptography_openssl',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\_pyinstaller_hooks_contrib\\rthooks\\pyi_rth_cryptography_openssl.py',
'PYSOURCE'),
('pyi_rth_pywintypes',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\_pyinstaller_hooks_contrib\\rthooks\\pyi_rth_pywintypes.py',
'PYSOURCE'),
('pyi_rth_pythoncom',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\_pyinstaller_hooks_contrib\\rthooks\\pyi_rth_pythoncom.py',
'PYSOURCE'),
('pc_agent', 'D:\\이태훈\\22전산자산조사\\ITAM\\pc_agent.py', 'PYSOURCE'),
('python312.dll',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\python312.dll',
'BINARY'),
('pywin32_system32\\pywintypes312.dll',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\pywin32_system32\\pywintypes312.dll',
'BINARY'),
('pywin32_system32\\pythoncom312.dll',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\pywin32_system32\\pythoncom312.dll',
'BINARY'),
('select.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\select.pyd',
'EXTENSION'),
('_multiprocessing.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_multiprocessing.pyd',
'EXTENSION'),
('pyexpat.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\pyexpat.pyd',
'EXTENSION'),
('_ssl.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_ssl.pyd',
'EXTENSION'),
('_hashlib.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_hashlib.pyd',
'EXTENSION'),
('unicodedata.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\unicodedata.pyd',
'EXTENSION'),
('_decimal.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_decimal.pyd',
'EXTENSION'),
('_lzma.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_lzma.pyd',
'EXTENSION'),
('_bz2.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_bz2.pyd',
'EXTENSION'),
('_ctypes.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_ctypes.pyd',
'EXTENSION'),
('_queue.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_queue.pyd',
'EXTENSION'),
('_wmi.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_wmi.pyd',
'EXTENSION'),
('_socket.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_socket.pyd',
'EXTENSION'),
('_overlapped.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_overlapped.pyd',
'EXTENSION'),
('_asyncio.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_asyncio.pyd',
'EXTENSION'),
('_cffi_backend.cp312-win_amd64.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\_cffi_backend.cp312-win_amd64.pyd',
'EXTENSION'),
('cryptography\\hazmat\\bindings\\_rust.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\cryptography\\hazmat\\bindings\\_rust.pyd',
'EXTENSION'),
('charset_normalizer\\md__mypyc.cp312-win_amd64.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\charset_normalizer\\md__mypyc.cp312-win_amd64.pyd',
'EXTENSION'),
('charset_normalizer\\md.cp312-win_amd64.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\charset_normalizer\\md.cp312-win_amd64.pyd',
'EXTENSION'),
('win32\\_win32sysloader.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\win32\\_win32sysloader.pyd',
'EXTENSION'),
('win32\\win32api.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\win32\\win32api.pyd',
'EXTENSION'),
('Pythonwin\\win32ui.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\Pythonwin\\win32ui.pyd',
'EXTENSION'),
('win32\\win32event.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\win32\\win32event.pyd',
'EXTENSION'),
('win32\\win32trace.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\win32\\win32trace.pyd',
'EXTENSION'),
('VCRUNTIME140.dll',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\VCRUNTIME140.dll',
'BINARY'),
('VCRUNTIME140_1.dll',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\VCRUNTIME140_1.dll',
'BINARY'),
('libssl-3.dll',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\libssl-3.dll',
'BINARY'),
('libcrypto-3.dll',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\libcrypto-3.dll',
'BINARY'),
('libffi-8.dll',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\libffi-8.dll',
'BINARY'),
('python3.dll',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\python3.dll',
'BINARY'),
('Pythonwin\\mfc140u.dll',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\Pythonwin\\mfc140u.dll',
'BINARY'),
('certifi\\cacert.pem',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\certifi\\cacert.pem',
'DATA'),
('certifi\\py.typed',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\certifi\\py.typed',
'DATA'),
('cryptography-45.0.2.dist-info\\licenses\\LICENSE.BSD',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\cryptography-45.0.2.dist-info\\licenses\\LICENSE.BSD',
'DATA'),
('cryptography-45.0.2.dist-info\\licenses\\LICENSE',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\cryptography-45.0.2.dist-info\\licenses\\LICENSE',
'DATA'),
('cryptography-45.0.2.dist-info\\RECORD',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\cryptography-45.0.2.dist-info\\RECORD',
'DATA'),
('cryptography-45.0.2.dist-info\\METADATA',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\cryptography-45.0.2.dist-info\\METADATA',
'DATA'),
('cryptography-45.0.2.dist-info\\WHEEL',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\cryptography-45.0.2.dist-info\\WHEEL',
'DATA'),
('cryptography-45.0.2.dist-info\\INSTALLER',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\cryptography-45.0.2.dist-info\\INSTALLER',
'DATA'),
('cryptography-45.0.2.dist-info\\licenses\\LICENSE.APACHE',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\cryptography-45.0.2.dist-info\\licenses\\LICENSE.APACHE',
'DATA'),
('base_library.zip',
'D:\\이태훈\\22전산자산조사\\ITAM\\build\\pc_agent\\base_library.zip',
'DATA')],
[],
False,
False,
1779102721,
[('run.exe',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\PyInstaller\\bootloader\\Windows-64bit-intel\\run.exe',
'EXECUTABLE')],
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\python312.dll')

View File

@@ -1,189 +0,0 @@
('D:\\이태훈\\22전산자산조사\\ITAM\\build\\pc_agent\\pc_agent.pkg',
{'BINARY': True,
'DATA': True,
'EXECUTABLE': True,
'EXTENSION': True,
'PYMODULE': True,
'PYSOURCE': True,
'PYZ': False,
'SPLASH': True,
'SYMLINK': False},
[('pyi-contents-directory _internal', '', 'OPTION'),
('PYZ-00.pyz', 'D:\\이태훈\\22전산자산조사\\ITAM\\build\\pc_agent\\PYZ-00.pyz', 'PYZ'),
('struct',
'D:\\이태훈\\22전산자산조사\\ITAM\\build\\pc_agent\\localpycs\\struct.pyc',
'PYMODULE'),
('pyimod01_archive',
'D:\\이태훈\\22전산자산조사\\ITAM\\build\\pc_agent\\localpycs\\pyimod01_archive.pyc',
'PYMODULE'),
('pyimod02_importers',
'D:\\이태훈\\22전산자산조사\\ITAM\\build\\pc_agent\\localpycs\\pyimod02_importers.pyc',
'PYMODULE'),
('pyimod03_ctypes',
'D:\\이태훈\\22전산자산조사\\ITAM\\build\\pc_agent\\localpycs\\pyimod03_ctypes.pyc',
'PYMODULE'),
('pyimod04_pywin32',
'D:\\이태훈\\22전산자산조사\\ITAM\\build\\pc_agent\\localpycs\\pyimod04_pywin32.pyc',
'PYMODULE'),
('pyiboot01_bootstrap',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\PyInstaller\\loader\\pyiboot01_bootstrap.py',
'PYSOURCE'),
('pyi_rth_inspect',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\PyInstaller\\hooks\\rthooks\\pyi_rth_inspect.py',
'PYSOURCE'),
('pyi_rth_pkgutil',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\PyInstaller\\hooks\\rthooks\\pyi_rth_pkgutil.py',
'PYSOURCE'),
('pyi_rth_multiprocessing',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\PyInstaller\\hooks\\rthooks\\pyi_rth_multiprocessing.py',
'PYSOURCE'),
('pyi_rth_cryptography_openssl',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\_pyinstaller_hooks_contrib\\rthooks\\pyi_rth_cryptography_openssl.py',
'PYSOURCE'),
('pyi_rth_pywintypes',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\_pyinstaller_hooks_contrib\\rthooks\\pyi_rth_pywintypes.py',
'PYSOURCE'),
('pyi_rth_pythoncom',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\_pyinstaller_hooks_contrib\\rthooks\\pyi_rth_pythoncom.py',
'PYSOURCE'),
('pc_agent', 'D:\\이태훈\\22전산자산조사\\ITAM\\pc_agent.py', 'PYSOURCE'),
('python312.dll',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\python312.dll',
'BINARY'),
('pywin32_system32\\pywintypes312.dll',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\pywin32_system32\\pywintypes312.dll',
'BINARY'),
('pywin32_system32\\pythoncom312.dll',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\pywin32_system32\\pythoncom312.dll',
'BINARY'),
('select.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\select.pyd',
'EXTENSION'),
('_multiprocessing.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_multiprocessing.pyd',
'EXTENSION'),
('pyexpat.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\pyexpat.pyd',
'EXTENSION'),
('_ssl.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_ssl.pyd',
'EXTENSION'),
('_hashlib.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_hashlib.pyd',
'EXTENSION'),
('unicodedata.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\unicodedata.pyd',
'EXTENSION'),
('_decimal.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_decimal.pyd',
'EXTENSION'),
('_lzma.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_lzma.pyd',
'EXTENSION'),
('_bz2.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_bz2.pyd',
'EXTENSION'),
('_ctypes.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_ctypes.pyd',
'EXTENSION'),
('_queue.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_queue.pyd',
'EXTENSION'),
('_wmi.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_wmi.pyd',
'EXTENSION'),
('_socket.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_socket.pyd',
'EXTENSION'),
('_overlapped.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_overlapped.pyd',
'EXTENSION'),
('_asyncio.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_asyncio.pyd',
'EXTENSION'),
('_cffi_backend.cp312-win_amd64.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\_cffi_backend.cp312-win_amd64.pyd',
'EXTENSION'),
('cryptography\\hazmat\\bindings\\_rust.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\cryptography\\hazmat\\bindings\\_rust.pyd',
'EXTENSION'),
('charset_normalizer\\md__mypyc.cp312-win_amd64.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\charset_normalizer\\md__mypyc.cp312-win_amd64.pyd',
'EXTENSION'),
('charset_normalizer\\md.cp312-win_amd64.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\charset_normalizer\\md.cp312-win_amd64.pyd',
'EXTENSION'),
('win32\\_win32sysloader.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\win32\\_win32sysloader.pyd',
'EXTENSION'),
('win32\\win32api.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\win32\\win32api.pyd',
'EXTENSION'),
('Pythonwin\\win32ui.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\Pythonwin\\win32ui.pyd',
'EXTENSION'),
('win32\\win32event.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\win32\\win32event.pyd',
'EXTENSION'),
('win32\\win32trace.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\win32\\win32trace.pyd',
'EXTENSION'),
('VCRUNTIME140.dll',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\VCRUNTIME140.dll',
'BINARY'),
('VCRUNTIME140_1.dll',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\VCRUNTIME140_1.dll',
'BINARY'),
('libssl-3.dll',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\libssl-3.dll',
'BINARY'),
('libcrypto-3.dll',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\libcrypto-3.dll',
'BINARY'),
('libffi-8.dll',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\libffi-8.dll',
'BINARY'),
('python3.dll',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\python3.dll',
'BINARY'),
('Pythonwin\\mfc140u.dll',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\Pythonwin\\mfc140u.dll',
'BINARY'),
('certifi\\cacert.pem',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\certifi\\cacert.pem',
'DATA'),
('certifi\\py.typed',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\certifi\\py.typed',
'DATA'),
('cryptography-45.0.2.dist-info\\licenses\\LICENSE.BSD',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\cryptography-45.0.2.dist-info\\licenses\\LICENSE.BSD',
'DATA'),
('cryptography-45.0.2.dist-info\\licenses\\LICENSE',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\cryptography-45.0.2.dist-info\\licenses\\LICENSE',
'DATA'),
('cryptography-45.0.2.dist-info\\RECORD',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\cryptography-45.0.2.dist-info\\RECORD',
'DATA'),
('cryptography-45.0.2.dist-info\\METADATA',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\cryptography-45.0.2.dist-info\\METADATA',
'DATA'),
('cryptography-45.0.2.dist-info\\WHEEL',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\cryptography-45.0.2.dist-info\\WHEEL',
'DATA'),
('cryptography-45.0.2.dist-info\\INSTALLER',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\cryptography-45.0.2.dist-info\\INSTALLER',
'DATA'),
('cryptography-45.0.2.dist-info\\licenses\\LICENSE.APACHE',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\cryptography-45.0.2.dist-info\\licenses\\LICENSE.APACHE',
'DATA'),
('base_library.zip',
'D:\\이태훈\\22전산자산조사\\ITAM\\build\\pc_agent\\base_library.zip',
'DATA')],
'python312.dll',
False,
False,
False,
[],
None,
None,
None)

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,58 +0,0 @@
This file lists modules PyInstaller was not able to find. This does not
necessarily mean these modules are required for running your program. Both
Python's standard library and 3rd-party Python packages often conditionally
import optional modules, some of which may be available only on certain
platforms.
Types of import:
* top-level: imported at the top-level - look at these first
* conditional: imported within an if-statement
* delayed: imported within a function
* optional: imported within a try-except-statement
IMPORTANT: Do NOT post this list to the issue-tracker. Use it as a basis for
tracking down the missing module yourself. Thanks!
missing module named pwd - imported by posixpath (delayed, conditional, optional), shutil (delayed, optional), tarfile (optional), pathlib (delayed, optional), subprocess (delayed, conditional, optional), netrc (delayed, conditional), getpass (delayed)
missing module named grp - imported by shutil (delayed, optional), tarfile (optional), pathlib (delayed, optional), subprocess (delayed, conditional, optional)
missing module named _posixsubprocess - imported by subprocess (conditional), multiprocessing.util (delayed)
missing module named fcntl - imported by subprocess (optional)
missing module named _posixshmem - imported by multiprocessing.resource_tracker (conditional), multiprocessing.shared_memory (conditional)
missing module named _scproxy - imported by urllib.request (conditional)
missing module named termios - imported by getpass (optional)
missing module named multiprocessing.BufferTooShort - imported by multiprocessing (top-level), multiprocessing.connection (top-level)
missing module named multiprocessing.AuthenticationError - imported by multiprocessing (top-level), multiprocessing.connection (top-level)
missing module named _frozen_importlib_external - imported by importlib._bootstrap (delayed), importlib (optional), importlib.abc (optional), zipimport (top-level)
excluded module named _frozen_importlib - imported by importlib (optional), importlib.abc (optional), zipimport (top-level)
missing module named posix - imported by os (conditional, optional), posixpath (optional), shutil (conditional), importlib._bootstrap_external (conditional)
missing module named resource - imported by posix (top-level)
missing module named multiprocessing.get_context - imported by multiprocessing (top-level), multiprocessing.pool (top-level), multiprocessing.managers (top-level), multiprocessing.sharedctypes (top-level)
missing module named multiprocessing.TimeoutError - imported by multiprocessing (top-level), multiprocessing.pool (top-level)
missing module named multiprocessing.set_start_method - imported by multiprocessing (top-level), multiprocessing.spawn (top-level)
missing module named multiprocessing.get_start_method - imported by multiprocessing (top-level), multiprocessing.spawn (top-level)
missing module named pyimod02_importers - imported by C:\Users\User\AppData\Local\Programs\Python\Python312\Lib\site-packages\PyInstaller\hooks\rthooks\pyi_rth_pkgutil.py (delayed)
missing module named collections.Callable - imported by collections (optional), socks (optional)
missing module named vms_lib - imported by platform (delayed, optional)
missing module named 'java.lang' - imported by platform (delayed, optional)
missing module named java - imported by platform (delayed)
missing module named _winreg - imported by platform (delayed, optional)
missing module named simplejson - imported by requests.compat (conditional, optional)
missing module named dummy_threading - imported by requests.cookies (optional)
missing module named asyncio.DefaultEventLoopPolicy - imported by asyncio (delayed, conditional), asyncio.events (delayed, conditional)
missing module named annotationlib - imported by typing_extensions (conditional)
missing module named 'h2.events' - imported by urllib3.http2.connection (top-level)
missing module named 'h2.connection' - imported by urllib3.http2.connection (top-level)
missing module named h2 - imported by urllib3.http2.connection (top-level)
missing module named zstandard - imported by urllib3.util.request (optional), urllib3.response (optional)
missing module named brotli - imported by urllib3.util.request (optional), urllib3.response (optional)
missing module named brotlicffi - imported by urllib3.util.request (optional), urllib3.response (optional)
missing module named win_inet_pton - imported by socks (conditional, optional)
missing module named bcrypt - imported by cryptography.hazmat.primitives.serialization.ssh (optional)
missing module named cryptography.x509.UnsupportedExtension - imported by cryptography.x509 (optional), urllib3.contrib.pyopenssl (optional)
missing module named 'OpenSSL.crypto' - imported by urllib3.contrib.pyopenssl (delayed, conditional)
missing module named OpenSSL - imported by urllib3.contrib.pyopenssl (top-level)
missing module named 'pyodide.ffi' - imported by urllib3.contrib.emscripten.fetch (delayed, optional)
missing module named pyodide - imported by urllib3.contrib.emscripten.fetch (top-level)
missing module named js - imported by urllib3.contrib.emscripten.fetch (top-level)
missing module named 'win32com.gen_py' - imported by win32com (conditional, optional)

File diff suppressed because it is too large Load Diff

View File

@@ -1,28 +0,0 @@
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
dotenv.config();
const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env;
async function checkRecentLogs() {
const connection = await mysql.createConnection({
host: DB_HOST,
user: DB_USER,
password: DB_PASS,
database: DB_NAME,
port: parseInt(DB_PORT || '3306')
});
console.log('--- Recent History Logs ---');
const [rows] = await connection.query('SELECT * FROM asset_history ORDER BY created_at DESC LIMIT 5');
console.log(JSON.stringify(rows, null, 2));
console.log('\n--- Recent Core Data (to check current_dept) ---');
const [coreRows] = await connection.query('SELECT id, asset_code, current_dept, previous_dept FROM asset_core ORDER BY updated_at DESC LIMIT 5');
console.log(JSON.stringify(coreRows, null, 2));
await connection.end();
}
checkRecentLogs().catch(console.error);

View File

@@ -1,29 +0,0 @@
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
dotenv.config();
const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env;
async function checkRemote() {
const connection = await mysql.createConnection({
host: DB_HOST,
user: DB_USER,
password: DB_PASS,
database: DB_NAME,
port: parseInt(DB_PORT || '3306')
});
console.log('--- Checking asset_remote table ---');
const [columns] = await connection.query('DESCRIBE asset_remote');
const cols = columns.map(c => c.Field);
console.log('Columns in asset_remote:', cols.join(', '));
const [count] = await connection.query('SELECT COUNT(*) as count FROM asset_remote WHERE remote_tool IS NOT NULL OR remote_id IS NOT NULL');
console.log(`Rows with remote info (tool or id): ${count[0].count}`);
await connection.end();
}
checkRemote().catch(console.error);

View File

@@ -1,176 +0,0 @@
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
dotenv.config();
const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env;
async function initDB() {
const connection = await mysql.createConnection({
host: DB_HOST,
user: DB_USER,
password: DB_PASS,
database: DB_NAME,
port: parseInt(DB_PORT || '3306'),
multipleStatements: true
});
console.log('🔄 DB 초기화 시작 (영문 표준 스키마 적용)...');
const tablesToDrop = [
'pc_assets', 'server_assets', 'storage_assets', 'equip_assets', 'mobile_assets',
'sw_sub_assets', 'sw_perm_assets', 'cloud_assets', 'sw_users', 'asset_logs'
];
for (const table of tablesToDrop) {
await connection.query(`DROP TABLE IF EXISTS ${table}`);
}
const createHardwareTable = (tableName, comment) => `
CREATE TABLE ${tableName} (
id VARCHAR(50) PRIMARY KEY,
corp VARCHAR(100),
asset_code VARCHAR(100),
purchase_date VARCHAR(50),
type VARCHAR(50),
detail_purpose VARCHAR(50),
purpose VARCHAR(255),
details TEXT,
current_org VARCHAR(255),
prev_org VARCHAR(255),
location VARCHAR(255),
manager_main VARCHAR(100),
manager_sub VARCHAR(100),
ip_address VARCHAR(100),
remote_tool VARCHAR(100),
server_id VARCHAR(100),
server_pw VARCHAR(100),
model_name VARCHAR(255),
mainboard VARCHAR(255) COMMENT '메인보드',
os VARCHAR(100),
cpu VARCHAR(255),
ram VARCHAR(100),
gpu VARCHAR(100),
storage1 VARCHAR(255),
storage2 VARCHAR(255),
storage3 VARCHAR(255),
monitoring VARCHAR(100),
price VARCHAR(100),
remarks TEXT,
storage_location VARCHAR(255),
status VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`;
await connection.query(createHardwareTable('pc_assets', 'PC'));
await connection.query(createHardwareTable('server_assets', 'Server'));
await connection.query(createHardwareTable('storage_assets', 'Storage'));
await connection.query(createHardwareTable('equip_assets', 'Equipment'));
await connection.query(createHardwareTable('mobile_assets', 'Mobile'));
await connection.query(`
CREATE TABLE sw_sub_assets (
id VARCHAR(50) PRIMARY KEY,
corp VARCHAR(100) COMMENT '구매법인',
category VARCHAR(100) COMMENT '분야',
dept VARCHAR(100) COMMENT '부서',
product_name VARCHAR(255) COMMENT '제품명',
license_type VARCHAR(100) COMMENT '라이선스 유형',
quantity INT COMMENT '수량',
price VARCHAR(100) COMMENT '금액',
purchase_date VARCHAR(50) COMMENT '구매일',
start_date VARCHAR(50) COMMENT '시작일',
expiry_date VARCHAR(50) COMMENT '만료일',
vendor VARCHAR(255) COMMENT '구매업체',
remarks TEXT COMMENT '비고',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
await connection.query(`
CREATE TABLE sw_perm_assets (
id VARCHAR(50) PRIMARY KEY,
corp VARCHAR(100) COMMENT '구매법인',
category VARCHAR(100) COMMENT '분야',
dept VARCHAR(100) COMMENT '부서',
product_name VARCHAR(255) COMMENT '제품명',
license_key VARCHAR(255) COMMENT '라이선스 키',
quantity INT COMMENT '수량',
price VARCHAR(100) COMMENT '금액',
purchase_date VARCHAR(50) COMMENT '구매일',
start_date VARCHAR(50) COMMENT '시작일',
expiry_date VARCHAR(50) COMMENT '만료일',
vendor VARCHAR(255) COMMENT '구매업체',
remarks TEXT COMMENT '비고',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
await connection.query(`
CREATE TABLE cloud_assets (
id VARCHAR(50) PRIMARY KEY,
platform_name VARCHAR(100),
corp VARCHAR(100),
dept VARCHAR(100),
product_name VARCHAR(255),
account_name VARCHAR(255),
pay_method VARCHAR(100),
pay_day VARCHAR(50),
card_num VARCHAR(100),
monthly_fee VARCHAR(100),
remarks TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
await connection.query(`
CREATE TABLE sw_users (
id INT AUTO_INCREMENT PRIMARY KEY,
sw_id VARCHAR(50),
corp VARCHAR(100),
dept VARCHAR(100),
position VARCHAR(50),
user_name VARCHAR(100),
usage_period VARCHAR(100),
doc_name VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
await connection.query(`
CREATE TABLE asset_logs (
id INT AUTO_INCREMENT PRIMARY KEY,
asset_id VARCHAR(50),
log_date VARCHAR(50),
log_user VARCHAR(100),
details TEXT,
cost DECIMAL(15,2) DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
await connection.query(`
CREATE TABLE ops_domain_assets (
id VARCHAR(50) PRIMARY KEY,
type VARCHAR(50) COMMENT '유형',
corp VARCHAR(100) COMMENT '법인',
service_name VARCHAR(255) COMMENT '서비스명',
domain_name VARCHAR(255) COMMENT '관리도메인',
start_date VARCHAR(50) COMMENT '시작일',
expiry_date VARCHAR(50) COMMENT '만료일',
price VARCHAR(100) COMMENT '금액',
manager_main VARCHAR(100) COMMENT '담당자',
manager_sub VARCHAR(100) COMMENT '담당자(부)',
remarks TEXT COMMENT '비고',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
console.log('✅ 모든 테이블이 영문 표준 스키마로 재생성되었습니다.');
await connection.end();
}
initDB().catch(err => {
console.error('❌ DB 초기화 실패:', err);
process.exit(1);
});

940
doc_readme.md Normal file
View File

@@ -0,0 +1,940 @@
# ITAM 도커라이징 실전 가이드
## 1. 문서 목적
이 문서는 Gitea에 올라가 있는 현재 저장소를 기준으로, 개발 PC에 WSL2와 Ubuntu만 설치되어 있는 상태에서 지금의 Docker 실행 구조를 재현하는 방법을 처음부터 끝까지 설명하는 실전 가이드다.
이 문서는 아래 상황을 가정한다.
1. 소스 코드는 아직 로컬에 없거나, Gitea에서 막 받아올 예정이다.
2. Windows에는 WSL2와 Ubuntu는 설치되어 있다.
3. 그 외 Docker 관련 세팅은 아직 안 되어 있을 수 있다.
4. 최종 목표는 현재 저장소 기준 `frontend + backend + external DB` 구조를 Docker로 재현하는 것이다.
이 문서의 목적은 아래 네 가지다.
1. 현재 시스템 구조와 Docker 구조를 먼저 이해하게 한다.
2. 기존 파일 중 무엇이 새로 추가되었고 무엇이 수정되었는지 정리한다.
3. 각 단계별로 정확히 어디에서 명령을 실행해야 하는지 명시한다.
4. Gitea 소스만 받은 상태에서 지금과 같은 Docker 실행 상태까지 도달하게 한다.
---
## 2. 현재 시스템 구조 개요
## 2.1 애플리케이션 원래 구조
현재 저장소의 본래 실행 구조는 다음과 같다.
1. 프런트엔드: Vite 기반 TypeScript 앱
2. 백엔드: Express 기반 Node.js API 서버
3. 데이터베이스: 외부 MySQL 서버
즉, 원래부터 MySQL이 Docker 안에 들어 있던 구조가 아니다.
프런트와 백엔드는 각각 별도 프로세스로 실행되며, 프런트는 `/api` 상대 경로로 백엔드 API를 호출한다.
---
## 2.2 현재 Docker 구조
현재 최종 Docker 구조는 아래와 같다.
1. `frontend` 컨테이너
2. `backend` 컨테이너
3. 외부 MySQL DB
즉, 지금은 내부 `db` 컨테이너가 없고, 내부 `db-bootstrap` 컨테이너도 없다.
현재 구조를 문장으로 풀면 다음과 같다.
1. 브라우저는 `http://localhost:8080`으로 `frontend` 컨테이너에 접속한다.
2. `frontend``/api` 요청을 `backend:3000`으로 프록시한다.
3. `backend``.env`에 적힌 외부 DB 정보로 외부 MySQL에 직접 접속한다.
4. 조회 결과 JSON을 프런트가 받아 화면에 렌더링한다.
간단한 흐름은 아래와 같다.
```text
Browser
-> frontend container :8080
-> Vite proxy (/api)
-> backend container :3000
-> external MySQL (.env)
```
---
## 2.3 왜 이 구조가 맞는가
현재 구조가 적절한 이유는 다음과 같다.
1. 원래 시스템도 외부 MySQL을 쓰는 구조였다.
2. 지금 목표는 운영형 단일 배포가 아니라 현재 개발형 구조를 Docker로 재현하는 것이다.
3. 프런트는 Vite dev server 기반이라 운영형 nginx 정적 배포 구조로 억지로 바꾸는 것보다, 현 구조를 유지하는 편이 안전하다.
4. 실무 표준 관점에서도 앱 컨테이너는 무상태로 유지하고, DB는 외부 인프라를 사용하는 구성이 더 일반적이다.
---
## 3. 이번 도커라이징에서 추가되거나 수정된 파일 정리
아래 파일들은 이번 Docker 재현 구조를 위해 새로 추가되었거나 수정된 핵심 파일이다.
## 3.1 새로 추가된 파일
1. `Dockerfile.frontend`
2. `Dockerfile.backend`
3. `.dockerignore`
4. `docker-compose.yaml`
5. `start_docker_wsl.ps1`
6. `stop_docker_wsl.ps1`
7. `start_docker_wsl.bat`
8. `stop_docker_wsl.bat`
9. `docker/mysql/init/README.md`
10. `docker_task_plan.md`
11. `doc_readme2.md`
---
## 3.2 기존 파일 중 수정된 핵심 파일
1. `server.js`
2. `vite.config.ts`
3. `doc_readme.md`
---
## 3.3 각 파일의 역할
### `Dockerfile.frontend`
역할:
1. 프런트 Vite 개발 서버 이미지를 만든다.
2. 컨테이너 내부에서 `npm run dev -- --host 0.0.0.0`를 실행한다.
### `Dockerfile.backend`
역할:
1. 백엔드 Express 서버 이미지를 만든다.
2. 컨테이너 내부에서 `npm run server`를 실행한다.
### `.dockerignore`
역할:
1. `node_modules`, `build`, `.git`, `.env`, `uploads` 같은 불필요한 파일을 Docker build context에서 제외한다.
### `docker-compose.yaml`
역할:
1. `frontend`, `backend` 두 컨테이너를 동시에 띄운다.
2. `backend``.env`의 외부 DB를 사용한다.
3. `frontend``backend:3000`으로 프록시한다.
### `start_docker_wsl.ps1`
역할:
1. Windows 경로를 WSL 경로로 안전하게 바꾼다.
2. WSL 내부 Docker를 사용해 `docker compose up --build -d`를 실행한다.
3. 한글 경로와 공백 경로에서도 안정적으로 실행되게 한다.
### `stop_docker_wsl.ps1`
역할:
1. 같은 방식으로 WSL 내부에서 `docker compose down`을 실행한다.
### `start_docker_wsl.bat`, `stop_docker_wsl.bat`
역할:
1. PowerShell 스크립트를 쉽게 실행하는 래퍼 역할을 한다.
### `server.js`
중요 수정 사항:
1. `dotenv.config({ override: true })`가 아니라 `dotenv.config()`를 사용한다.
이유:
1. Compose나 실행 환경이 주는 환경변수를 `.env`가 덮어써 버리면 안 된다.
2. 외부 DB 정보와 포트 설정 등 실행 환경 우선 구조를 유지해야 한다.
### `vite.config.ts`
중요 수정 사항:
1. 프록시 타깃을 고정 `localhost:3000`이 아니라 환경변수 기반으로 받도록 바꿨다.
현재 구조:
```ts
const proxyTarget = process.env.VITE_DEV_PROXY_TARGET || 'http://localhost:3000';
```
이유:
1. 로컬에서 직접 프런트를 띄울 때는 `localhost:3000`이 맞다.
2. Docker 안에서는 `frontend` 컨테이너에서 보는 `localhost`가 백엔드가 아니므로 `backend:3000`을 써야 한다.
---
## 4. 현재 `docker-compose.yaml` 기준 실제 동작 구조
현재 `docker-compose.yaml`은 아래 구조다.
### `backend`
1. `Dockerfile.backend`로 이미지를 빌드한다.
2. `.env`를 읽는다.
3. DB 관련 변수는 `${DB_HOST}`, `${DB_PORT}`, `${DB_USER}`, `${DB_PASS}`, `${DB_NAME}`를 그대로 사용한다.
4. 포트 `3000:3000`으로 노출한다.
5. `uploads`, `map_config.json`을 마운트한다.
### `frontend`
1. `Dockerfile.frontend`로 이미지를 빌드한다.
2. `VITE_DEV_PROXY_TARGET: http://backend:3000` 환경변수를 사용한다.
3. 포트 `8080:8080`으로 노출한다.
4. 브라우저의 `/api` 요청을 `backend`로 프록시한다.
즉, 현재 Compose는 DB를 띄우지 않고 앱 두 개만 띄운다.
---
## 5. 사전 준비 사항
이 섹션은 Gitea에서 코드를 받기 전 또는 받은 직후에 확인해야 한다.
## 5.1 가정하는 기본 상태
이미 설치되어 있다고 가정하는 것:
1. Windows
2. WSL2
3. Ubuntu 배포판
아직 없을 수 있는 것:
1. Docker Desktop 또는 WSL 내부 Docker 사용 환경
2. Git 클라이언트
3. 프로젝트 `.env`
---
## 5.2 권장 Docker 실행 방식
현재 저장소 구조상 가장 권장하는 방식은 다음이다.
1. Windows에 Docker Desktop 설치
2. Docker Desktop에서 WSL2 통합 활성화
3. Ubuntu WSL 내부에서 `docker` 명령을 사용할 수 있게 한다.
이유:
1. 현재 `start_docker_wsl.ps1`가 WSL 내부의 `docker`를 호출하는 구조다.
2. 실제 검증도 WSL 내부 Docker 기준으로 이루어졌다.
---
## 5.3 외부 DB 정보 준비
현재 구조는 외부 MySQL을 사용하므로 `.env` 파일이 반드시 필요하다.
최소한 아래 값이 필요하다.
```env
DB_HOST=<외부 MySQL 호스트>
DB_PORT=3306
DB_USER=<외부 MySQL 계정>
DB_PASS=<외부 MySQL 비밀번호>
DB_NAME=itam
```
필요 시 추가 환경변수는 현재 백엔드 코드 기준으로 함께 넣을 수 있다.
---
## 6. Gitea에서 소스 받기
## 6.1 작업 실행 위치
이 단계는 **Windows PowerShell** 또는 **Windows 터미널의 PowerShell**에서 수행한다.
실행 위치 이유:
1. 이후 `start_docker_wsl.ps1`도 Windows PowerShell에서 실행하는 것이 가장 자연스럽다.
2. 로컬 작업 폴더를 Windows 경로 기준으로 준비할 수 있다.
---
## 6.2 소스 클론
예시:
```powershell
git clone <Gitea 저장소 URL>
cd <클론된 저장소 경로>
```
현재 프로젝트처럼 한글 경로를 사용할 수도 있지만, 가능하면 너무 복잡한 경로는 피하는 것이 좋다.
현재 실제 프로젝트 경로 예시는 아래였다.
```text
c:\Users\user\Desktop\안건 파일\itam
```
이 경로도 현재 스크립트로는 동작 가능하다.
---
## 7. Docker 환경 준비
## 7.1 작업 실행 위치
이 단계는 **Windows PowerShell**과 **WSL Ubuntu 터미널**을 둘 다 사용한다.
1. 설치 확인은 Windows PowerShell에서 시작
2. 실제 Docker 동작 확인은 WSL Ubuntu에서 수행
---
## 7.2 Docker Desktop 설치 여부 확인
**실행 위치: Windows PowerShell**
```powershell
docker version
```
만약 여기서 바로 안 잡혀도 현재 프로젝트는 WSL 내부 Docker를 쓰므로, 다음 단계로 넘어가 WSL 내부 확인을 한다.
---
## 7.3 WSL 내부 Docker 확인
**실행 위치: Windows PowerShell**
```powershell
wsl -l -v
wsl sh -lc "docker --version"
```
정상 기대 결과:
1. Ubuntu가 Running 상태
2. `docker --version`이 정상 출력
만약 `docker --version`이 실패하면, Docker Desktop 설치 및 WSL 통합을 먼저 완료해야 한다.
---
## 8. `.env` 파일 준비
## 8.1 작업 실행 위치
이 단계는 **Windows PowerShell**, **VS Code**, 또는 아무 텍스트 편집기**에서 수행한다.
즉, 프로젝트 루트에 `.env` 파일을 만드는 작업이다.
---
## 8.2 `.env` 작성
프로젝트 루트에 `.env`를 만든다.
예시:
```env
DB_HOST=your-external-db-host
DB_PORT=3306
DB_USER=your-db-user
DB_PASS=your-db-password
DB_NAME=itam
```
주의:
1. 현재 Compose는 내부 DB를 만들지 않는다.
2. 따라서 이 값이 곧 실제 운영/개발 외부 DB 연결 정보다.
3. 이 정보가 틀리면 `backend`는 기동해도 API에서 DB 오류가 난다.
---
## 9. 현재 Docker 파일이 어떻게 동작하는지 이해하기
## 9.1 `Dockerfile.frontend`
**확인 위치: 프로젝트 루트 / VS Code**
현재 내용 핵심:
```dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
EXPOSE 8080
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
```
의미:
1. Node 20 Alpine 기반
2. 의존성 설치 후 전체 소스 복사
3. Vite 개발 서버 실행
---
## 9.2 `Dockerfile.backend`
**확인 위치: 프로젝트 루트 / VS Code**
현재 내용 핵심:
```dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
EXPOSE 3000
CMD ["npm", "run", "server"]
```
의미:
1. Node 20 Alpine 기반
2. Express 서버 실행
---
## 9.3 `vite.config.ts`
**확인 위치: 프로젝트 루트 / VS Code**
현재 핵심:
```ts
const proxyTarget = process.env.VITE_DEV_PROXY_TARGET || 'http://localhost:3000';
```
그리고 `/api`, `/uploads`가 모두 `proxyTarget`으로 프록시된다.
의미:
1. 로컬 실행 시 기본값은 `localhost:3000`
2. Docker 실행 시 Compose가 `http://backend:3000`을 주입
이 수정이 있어야 Docker 안에서도 화면에 데이터가 표시된다.
---
## 10. Docker Compose 기동
## 10.1 작업 실행 위치
이 단계는 반드시 **Windows PowerShell**에서 수행하는 것을 권장한다.
이유:
1. `start_docker_wsl.ps1`가 Windows 경로를 받아 WSL 경로로 바꾸는 구조다.
2. 한글/공백 경로에서 가장 안전하다.
---
## 10.2 권장 기동 방법
**실행 위치: 프로젝트 루트의 Windows PowerShell**
```powershell
.\start_docker_wsl.ps1
```
또는
```powershell
.\start_docker_wsl.bat
```
이 스크립트는 내부적으로 아래를 수행한다.
1. PowerShell 출력 인코딩을 UTF-8로 설정
2. 현재 Windows 경로를 WSL 경로로 변환
3. WSL 동작 확인
4. WSL 내부 Docker 동작 확인
5. `docker compose up --build -d` 수행
---
## 10.3 직접 기동이 필요할 때
**실행 위치: WSL Ubuntu 터미널**
직접 실행 예시는 아래와 같다.
```bash
cd /mnt/c/Users/user/Desktop/안건\ 파일/itam
docker compose up --build -d
```
하지만 현재 프로젝트는 한글 경로 이슈가 있었기 때문에, 특별한 이유가 없으면 `start_docker_wsl.ps1`를 우선 사용한다.
---
## 11. 컨테이너 기동 후 검증
## 11.1 컨테이너 상태 확인
**실행 위치: Windows PowerShell**
```powershell
wsl sh -lc "docker ps -a --format 'table {{.Names}}\t{{.Status}}' | grep itam"
```
정상 기대 상태:
1. `itam-backend` -> `Up`
2. `itam-frontend` -> `Up`
현재는 `itam-db`, `itam-db-bootstrap`가 없어야 정상이다.
---
## 11.2 백엔드 API 확인
**실행 위치: Windows PowerShell**
```powershell
Invoke-WebRequest -Uri http://localhost:3000/api/assets/master -UseBasicParsing | Select-Object -ExpandProperty StatusCode
```
정상 기대값:
1. `200`
이 검사는 `backend`가 외부 DB에 정상 연결됐는지 보는 가장 직접적인 검사다.
---
## 11.3 프런트 경유 API 확인
**실행 위치: Windows PowerShell**
```powershell
Invoke-WebRequest -Uri http://localhost:8080/api/assets/master -UseBasicParsing | Select-Object -ExpandProperty StatusCode
```
정상 기대값:
1. `200`
이 검사는 프런트 프록시가 정상인지 확인한다.
예전에 화면에 데이터가 안 보였던 것은 외부 DB 자체가 아니라, 이 프록시 경로가 잘못돼 있었기 때문이다.
---
## 11.4 브라우저 화면 확인
**실행 위치: 브라우저**
```text
http://localhost:8080
```
확인 포인트:
1. 화면이 열리는지
2. 목록/대시보드/테이블 데이터가 비어 있지 않은지
3. 모달 진입 시 데이터가 정상적으로 보이는지
---
## 12. 지금 데이터가 표시되는 원리
현재는 내부 DB로 데이터를 옮겨 담지 않는다.
현재 실제 동작 원리는 다음과 같다.
1. 브라우저가 `frontend`에 접속한다.
2. 프런트가 `/api/...`로 요청한다.
3. Vite 프록시가 `backend:3000`으로 요청을 넘긴다.
4. `backend``.env`의 외부 MySQL에 직접 접속한다.
5. 조회 결과 JSON을 프런트가 받아 화면에 렌더링한다.
즉, 현재는 아래 구조다.
```text
Browser -> frontend -> backend -> external MySQL
```
예전 외부 DB 구조에서 화면에 데이터가 안 보였던 이유는 외부 DB 때문이 아니라, 프런트 컨테이너가 `localhost:3000`을 잘못 바라보고 있었기 때문이다.
지금은 `VITE_DEV_PROXY_TARGET: http://backend:3000`으로 수정되어 있기 때문에 정상 표시된다.
---
## 13. 자주 헷갈리는 포인트
## 13.1 현재는 내부 DB 컨테이너가 없다
현재 `docker-compose.yaml`에는 아래가 없다.
1. `db` 서비스
2. `db-bootstrap` 서비스
3. `itam_mysql_data` 볼륨
즉, DB는 Docker 스택 밖에 있다.
---
## 13.2 현재는 `.env`가 곧 실제 DB 연결 정보다
현재 `backend`는 아래처럼 Compose에서 그대로 받는다.
1. `DB_HOST: ${DB_HOST}`
2. `DB_PORT: ${DB_PORT}`
3. `DB_USER: ${DB_USER}`
4. `DB_PASS: ${DB_PASS}`
5. `DB_NAME: ${DB_NAME}`
즉, `.env`를 틀리게 적으면 화면도 데이터가 안 뜬다.
---
## 13.3 `server.js`는 여전히 중요하게 수정된 상태다
현재 `server.js``dotenv.config()`를 사용한다.
이 구조는 이후 Compose나 실행 환경에서 변수를 주입할 때, 애플리케이션이 그 값을 받아들일 수 있게 하기 위해 유지해야 한다.
---
## 14. 스택 중지 방법
## 14.1 작업 실행 위치
**Windows PowerShell / 프로젝트 루트**
---
## 14.2 권장 종료 명령
```powershell
.\stop_docker_wsl.ps1
```
또는
```powershell
.\stop_docker_wsl.bat
```
이 스크립트는 내부적으로 WSL 경로 변환 후 `docker compose down`을 수행한다.
---
## 15. 장애 발생 시 점검 순서
## 15.1 `frontend` 화면은 뜨는데 데이터가 없을 때
**실행 위치: Windows PowerShell**
먼저 아래 두 API를 분리해서 본다.
```powershell
Invoke-WebRequest -Uri http://localhost:3000/api/assets/master -UseBasicParsing | Select-Object -ExpandProperty StatusCode
Invoke-WebRequest -Uri http://localhost:8080/api/assets/master -UseBasicParsing | Select-Object -ExpandProperty StatusCode
```
판단 기준:
1. `3000`은 200이고 `8080`만 실패 -> 프런트 프록시 문제
2. 둘 다 실패 -> 백엔드 또는 외부 DB 연결 문제
---
## 15.2 백엔드가 외부 DB에 연결되지 않을 때
**실행 위치: Windows PowerShell**
```powershell
wsl sh -lc "docker logs --tail=200 itam-backend"
```
점검 항목:
1. `.env`의 DB 정보가 정확한지
2. 외부 DB 서버 접근이 가능한지
3. 계정/비밀번호가 맞는지
4. 방화벽 또는 네트워크 이슈가 없는지
---
## 16. 운영 수동 배포 플로우
이 섹션은 현재 ITAM 저장소 기준으로 운영 서버에 반영할 때의 전체 흐름을 설명한다.
중요한 전제는 아래와 같다.
1. 로컬 수정본을 서버에 직접 복사하지 않는다.
2. 반드시 Gitea에 올라간 커밋을 기준으로 배포한다.
3. 운영 반영은 자동 푸시 배포가 아니라 Gitea workflow 수동 실행으로 진행한다.
4. 현재 기준 운영 배포 workflow는 `.gitea/workflows/itam_production_deploy.yml`이다.
---
## 16.1 전체 운영/배포 분기 흐름
운영 반영은 크게 세 상황으로 나뉜다.
1. 최초 운영 서버 구축 후 첫 배포
2. 코드 수정 후 일반 재배포
3. 검증 실패 또는 배포 실패 후 수정 재배포
아래 분기 구조로 이해하면 된다.
```mermaid
flowchart TD
START["배포 필요 발생"] --> CASE{"어떤 상황인가?"}
CASE -->|초기 구축| INIT["초기 운영 배포 준비"]
CASE -->|수정 반영| CHANGE["수정 후 재배포 준비"]
CASE -->|실패 후 재시도| RETRY["실패 원인 분석 후 재배포 준비"]
INIT --> INIT1["운영 서버 Docker / compose 확인"]
INIT1 --> INIT2["Gitea Variables / Secrets 등록"]
INIT2 --> INIT3["map_config.json / uploads 초기 데이터 준비"]
INIT3 --> MANUAL["Gitea에서 수동 배포 workflow 실행"]
CHANGE --> CHANGE1["로컬 수정 및 테스트"]
CHANGE1 --> CHANGE2["Gitea 커밋 / push"]
CHANGE2 --> CHANGE3["Code Check / Docker Build Check 통과"]
CHANGE3 --> MANUAL
RETRY --> RETRY1{"어디서 실패했는가?"}
RETRY1 -->|코드 체크 실패| FIX1["코드 또는 설정 수정"]
RETRY1 -->|배포 단계 실패| FIX2["서버 / 변수 / 권한 / 네트워크 수정"]
RETRY1 -->|Smoke Check 실패| FIX3["앱 기동 상태 / 프록시 / DB 상태 수정"]
FIX1 --> CHANGE2
FIX2 --> MANUAL
FIX3 --> MANUAL
MANUAL --> DEPLOY["운영 서버 반영 수행"]
DEPLOY --> RESULT{"최종 검증 통과?"}
RESULT -->|예| DONE["운영 반영 완료"]
RESULT -->|아니오| RETRY
linkStyle default stroke:#d32f2f,stroke-width:2px;
```
핵심은 아래와 같다.
1. 초기 구축은 서버와 운영 데이터 준비가 먼저다.
2. 수정 반영은 반드시 커밋과 push가 먼저다.
3. 실패 후 재배포는 실패 지점에 따라 수정 위치가 달라진다.
---
## 16.2 최초 운영 배포 플로우
최초 배포에서는 코드보다 운영 환경 준비가 더 중요하다.
순서는 아래와 같다.
1. 운영 서버에 Docker Engine과 `docker compose`를 설치한다.
2. 운영 서버에서 Gitea 저장소에 접근 가능한 SSH 키를 준비한다.
3. Gitea repository Variables / Secrets를 등록한다.
4. `PROD_DEPLOY_PATH` 경로를 정한다.
5. `map_config.json`, `uploads/` 초기 데이터를 준비한다.
6. Gitea에서 `itam_production_deploy.yml`을 수동 실행한다.
7. 배포 후 `ps`, `/health`, `/`, `/ready`를 확인한다.
즉 최초 배포는 아래 조건이 먼저 충족되어야 한다.
```text
서버 준비 완료
-> Gitea 변수 / 시크릿 등록 완료
-> 초기 데이터 준비 완료
-> 수동 배포 실행
```
---
## 16.3 수정 후 일반 재배포 플로우
일반적인 수정 반영은 아래 흐름이다.
1. 개발자가 로컬에서 코드 또는 설정을 수정한다.
2. 로컬에서 필요한 테스트를 수행한다.
3. 변경사항을 Gitea에 커밋 후 push 한다.
4. `itam_code_check.yml`이 빌드와 compose 문법을 검사한다.
5. `itam_docker_build_check.yml`이 운영용 이미지 빌드 가능 여부를 검사한다.
6. 두 검증이 통과하면 운영자가 Gitea에서 `itam_production_deploy.yml`을 수동 실행한다.
7. 운영 서버가 최신 커밋으로 동기화되고 컨테이너가 다시 올라온다.
8. smoke check 통과 여부를 확인한다.
아래 다이어그램은 이 일반 재배포 흐름을 보여준다.
```mermaid
flowchart LR
DEV["로컬 수정"] --> TEST["로컬 확인"]
TEST --> PUSH["커밋 / push"]
PUSH --> CODE["ITAM Code Check"]
CODE --> BUILD["ITAM Docker Build Check"]
BUILD --> GATE{"검증 통과?"}
GATE -->|예| RUN["Gitea에서 수동 배포 실행"]
GATE -->|아니오| FIX["로컬 수정 후 재커밋"]
FIX --> PUSH
RUN --> PROD["운영 서버 배포"]
PROD --> SMOKE{"Smoke Check 통과?"}
SMOKE -->|예| OK["배포 완료"]
SMOKE -->|아니오| FIXDEPLOY["원인 수정 후 재배포"]
FIXDEPLOY --> RUN
linkStyle default stroke:#d32f2f,stroke-width:2px;
```
---
## 16.4 수동 배포 workflow 내부 실행 순서
Gitea에서 `itam_production_deploy.yml`을 수동 실행하면 내부적으로는 아래 순서로 진행된다.
1. SSH agent를 설정한다.
2. 필수 Variables / Secrets가 모두 있는지 확인한다.
3. 운영용 `.env.deploy` 파일을 생성한다.
4. 운영 서버에 접속한다.
5. `PROD_DEPLOY_PATH`를 생성한다.
6. 저장소를 clone 또는 fetch 한다.
7. 선택한 브랜치의 최신 커밋으로 checkout, reset, clean 한다.
8. `uploads`, `logs/nginx` 디렉토리를 준비한다.
9. `.env.deploy`를 서버의 `.env`로 복사한다.
10. `docker compose -f docker-compose.prod.yaml config`를 수행한다.
11. `docker compose -f docker-compose.prod.yaml up -d --build`를 수행한다.
12. `docker compose ps`를 확인한다.
13. `/health`, `/`, backend `/ready` smoke check를 수행한다.
아래 다이어그램은 workflow 내부 실행 순서다.
```mermaid
flowchart TD
A["수동 배포 시작"] --> B["SSH agent 설정"]
B --> C["Variables / Secrets 검증"]
C --> D{"필수 값 누락 여부"}
D -->|예| E["즉시 실패 후 설정 보완"]
D -->|아니오| F[".env.deploy 생성"]
F --> G["운영 서버 SSH 접속"]
G --> H["배포 경로 생성"]
H --> I["git clone 또는 fetch"]
I --> J["지정 브랜치 checkout / reset / clean"]
J --> K["uploads / logs/nginx 준비"]
K --> L[".env 업로드 및 권한 설정"]
L --> M["compose config 검증"]
M --> N{"compose config 성공?"}
N -->|아니오| O["설정 수정 후 재실행"]
N -->|예| P["compose up -d --build"]
P --> Q["docker compose ps 확인"]
Q --> R["/health, /, /ready smoke check"]
R --> S{"smoke check 성공?"}
S -->|예| T["운영 배포 완료"]
S -->|아니오| U["원인 분석 후 재배포"]
linkStyle default stroke:#d32f2f,stroke-width:2px;
```
---
## 16.5 실패 후 검증 및 재배포 플로우
실패가 났다고 해서 항상 같은 방식으로 다시 배포하면 안 된다.
실패 지점별 판단은 아래처럼 나눈다.
1. Code Check 실패: TypeScript, build, compose 문법 문제를 먼저 수정한다.
2. Docker Build Check 실패: Dockerfile, 정적 자산 복사, 운영 빌드 컨텍스트 문제를 수정한다.
3. Deploy 단계 실패: SSH, Gitea 변수, 서버 권한, 경로, git 접근, Docker 권한을 수정한다.
4. Smoke Check 실패: Nginx 프록시, backend readiness, 외부 DB 연결, 앱 런타임 오류를 수정한다.
즉 재배포 전 판단 기준은 아래와 같다.
```text
CI 실패 -> 로컬 코드 / 설정 수정 후 재커밋
배포 실패 -> 서버 환경 또는 배포 설정 수정 후 수동 재실행
Smoke Check 실패 -> 앱 / 프록시 / DB 상태 수정 후 수동 재실행
```
운영 관점에서는 아래 순서를 지키는 것이 안전하다.
1. 실패 지점 확인
2. 원인 수정
3. 같은 실패가 다시 나는지 좁은 범위로 재검증
4. 그 다음에만 수동 배포 재실행
---
## 16.6 문서 기준 요약
현재 ITAM 운영 배포는 아래 원칙으로 이해하면 된다.
1. 수정은 로컬에서 한다.
2. 배포 기준점은 Gitea에 올라간 커밋이다.
3. 운영 반영은 Gitea 수동 workflow 실행으로 한다.
4. 초기 배포, 일반 재배포, 실패 후 재배포는 분기 기준이 다르다.
5. 최종 성공 여부는 컨테이너 상태가 아니라 smoke check까지 통과했는지로 판단한다.
---
## 15.3 프런트 프록시가 의심될 때
**확인 위치: `vite.config.ts`, `docker-compose.yaml`**
다음 두 설정이 유지되는지 확인한다.
`vite.config.ts`
```ts
const proxyTarget = process.env.VITE_DEV_PROXY_TARGET || 'http://localhost:3000';
```
`docker-compose.yaml`
```yaml
VITE_DEV_PROXY_TARGET: http://backend:3000
```
이 둘 중 하나라도 바뀌면 Docker 안에서 화면 데이터가 다시 안 보일 수 있다.
---
## 17. 현재 기준 재현 절차 요약
가장 짧게 정리하면 아래 순서다.
1. Gitea에서 소스를 클론한다.
2. Windows PowerShell에서 프로젝트 루트로 이동한다.
3. `.env`에 외부 MySQL 정보를 작성한다.
4. Docker Desktop + WSL 통합 또는 WSL 내부 Docker 사용 가능 상태를 만든다.
5. `start_docker_wsl.ps1`를 실행한다.
6. `http://localhost:3000/api/assets/master`가 200인지 확인한다.
7. `http://localhost:8080/api/assets/master`가 200인지 확인한다.
8. 브라우저에서 `http://localhost:8080`을 열어 실제 데이터 표시를 확인한다.
---
## 18. 현재 최종 결론
현재 저장소의 도커라이징 구조는 실무 표준에 맞는 `무상태 앱 컨테이너 + 외부 DB` 구조다.
현재 핵심은 아래 세 가지다.
1. `backend`는 외부 MySQL에 직접 연결한다.
2. `frontend``backend:3000`으로 API 프록시한다.
3. WSL 경로 변환 스크립트를 통해 Windows 한글 경로에서도 안정적으로 실행한다.
즉, 이 문서대로 진행하면 Gitea 소스만 받은 상태에서 지금과 같은 Docker 실행 구조를 재현할 수 있다.

730
doc_readme2.md Normal file
View File

@@ -0,0 +1,730 @@
# ITAM 도커라이징 최종 재현 가이드
## 1. 문서 목적
이 문서는 현재 Git 저장소에 올라간 파일만 가지고, 지금과 동일한 수준으로 ITAM 시스템을 도커라이징하고 실행하는 절차를 처음부터 끝까지 정리한 최종 가이드다.
이 문서만 읽어도 아래 목표를 달성할 수 있게 작성한다.
1. 현재 저장소 구조를 이해한다.
2. 왜 이렇게 도커라이징했는지 판단 근거를 안다.
3. WSL2 기반으로 실제 스택을 기동한다.
4. 외부 MySQL에서 내부 MySQL 컨테이너로 초기 데이터를 bootstrap 한다.
5. 프런트 8080과 백엔드 3000이 모두 정상 동작하는지 검증한다.
6. 재초기화, 재기동, 장애 확인까지 수행한다.
이 문서는 최종 성공 구조 기준이다. 실패 기록은 `doc_readme.md`를 본다.
---
## 2. 최종 목표 구조
현재 최종 구조는 아래 4개 서비스/역할로 나뉜다.
1. `frontend`: Vite 개발 서버 컨테이너, 포트 8080
2. `backend`: Express API 서버 컨테이너, 포트 3000
3. `db`: MySQL 8 컨테이너, 포트 3306
4. `db-bootstrap`: 외부 MySQL -> 내부 MySQL로 1회성 복제 수행 후 종료되는 도우미 컨테이너
논리 흐름은 다음과 같다.
```text
브라우저 -> frontend:8080 -> Vite proxy -> backend:3000 -> db:3306
\
-> /uploads -> backend 정적 경로
초기 1회 기동 시
외부 MySQL(.env) -> db-bootstrap -> 내부 MySQL(db)
```
---
## 3. 왜 이 구조를 선택했는가
이 저장소는 처음부터 운영형 정적 배포 앱이 아니었다. 실제 구조는 다음과 같았다.
1. 프런트는 Vite 개발 서버가 따로 돈다.
2. 백엔드는 Express API가 따로 돈다.
3. 프런트는 상대 경로 `/api`를 호출한다.
4. 백엔드는 프런트의 `dist`를 서빙하지 않는다.
따라서 내일 바로 시연 가능한 수준까지 빠르게 안정화하려면 아래 전략이 가장 맞다.
1. 프런트를 Vite dev server 그대로 컨테이너화한다.
2. 백엔드를 별도 컨테이너로 유지한다.
3. DB는 MySQL 8 컨테이너로 묶되, 초기 데이터는 외부 DB에서 복제한다.
4. 프런트 프록시는 컨테이너 네트워크 서비스명 `backend`로 붙게 한다.
즉, 현재 구조는 "개발형 구조를 Docker로 재현한 시연/개발용 Compose"다.
---
## 4. 저장소 내 최종 관련 파일 목록
현재 도커라이징과 직접 관련된 핵심 파일은 아래와 같다.
1. `.dockerignore`
2. `Dockerfile.frontend`
3. `Dockerfile.backend`
4. `docker-compose.yaml`
5. `start_docker_wsl.ps1`
6. `stop_docker_wsl.ps1`
7. `start_docker_wsl.bat`
8. `stop_docker_wsl.bat`
9. `docker/mysql/init/README.md`
10. `server.js`
11. `vite.config.ts`
각 파일 역할은 다음과 같다.
### 4.1 `.dockerignore`
Docker build context에서 제외할 파일을 정의한다.
주요 제외 대상은 다음과 같다.
1. `node_modules`
2. `dist`
3. `build`
4. `.git`
5. `.env`
6. `uploads`
7. `*.xlsx`
8. `*.log`
### 4.2 `Dockerfile.frontend`
프런트 컨테이너 이미지 정의다.
```dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
EXPOSE 8080
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
```
이 이미지는 Vite dev server를 컨테이너에서 띄우기 위한 것이다.
### 4.3 `Dockerfile.backend`
백엔드 컨테이너 이미지 정의다.
```dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
EXPOSE 3000
CMD ["npm", "run", "server"]
```
### 4.4 `docker-compose.yaml`
전체 스택의 핵심 파일이다.
현재 최종 구성은 다음 논리를 가진다.
1. `db`는 MySQL 8 내부 DB다.
2. `db-bootstrap`은 외부 DB 데이터를 내부 DB로 1회 복제한다.
3. `backend`는 내부 `db`에 붙는다.
4. `frontend``backend` 서비스명으로 프록시한다.
### 4.5 `start_docker_wsl.ps1`
Windows에서 WSL 경유로 Docker Compose를 안전하게 기동하는 진입점이다.
핵심은 다음 두 가지다.
1. 프로젝트 Windows 경로를 `wslpath`로 WSL 경로로 바꾼다.
2. 그 경로로 이동한 뒤 `docker compose up --build -d`를 수행한다.
### 4.6 `stop_docker_wsl.ps1`
같은 방식으로 WSL 내부에서 `docker compose down`을 수행해 스택을 안전하게 내린다.
### 4.7 `start_docker_wsl.bat`, `stop_docker_wsl.bat`
더블클릭 또는 간단 실행용 래퍼다. 내부적으로 PowerShell 스크립트를 호출한다.
### 4.8 `server.js`
중요 포인트는 다음 두 가지다.
1. `dotenv.config();`를 사용한다.
2. `dotenv.config({ override: true })`를 사용하지 않는다.
이 차이로 Compose 환경변수 `DB_HOST=db``.env`보다 우선하도록 보장한다.
### 4.9 `vite.config.ts`
현재 프록시는 환경변수 기반으로 동작한다.
```ts
const proxyTarget = process.env.VITE_DEV_PROXY_TARGET || 'http://localhost:3000';
```
로컬 PC에서 직접 Vite를 띄우면 기본값 `http://localhost:3000`을 쓴다.
컨테이너에서는 Compose가 `http://backend:3000`을 주입한다.
---
## 5. 현재 최종 `docker-compose.yaml` 구조 설명
아래는 실제 동작 관점에서 읽어야 할 핵심 내용이다.
### 5.1 `db` 서비스
역할:
1. 내부 MySQL 데이터 저장소
2. 앱이 최종적으로 붙는 DB
핵심 설정:
1. 이미지: `mysql:8.0`
2. DB 이름: `itam`
3. 앱 계정: `itam_admin`
4. 데이터 볼륨: `itam_mysql_data`
5. healthcheck 사용
healthcheck는 `mysqladmin ping`으로 동작하며, `backend``db-bootstrap`은 이 상태를 기다린다.
### 5.2 `db-bootstrap` 서비스
역할:
1. 외부 원본 DB에서 내부 `db`로 초기 데이터 복제
2. 1회성 작업 후 종료
핵심 포인트:
1. `.env`를 읽어 외부 DB 접속 정보를 가져온다.
2. 내부 `db``asset_core` 테이블이 이미 존재하면 아무 것도 하지 않고 종료한다.
3. 그렇지 않으면 `mysqldump | mysql` 파이프라인으로 복제한다.
4. `restart: "no"` 이므로 정상 종료 후 반복 실행하지 않는다.
또한 source DB와 target DB 변수는 분리돼 있다.
1. source: `SOURCE_DB_*`
2. target: `TARGET_DB_*`
이 구조로 외부 원본 DB 자격증명과 내부 컨테이너 DB 자격증명이 섞이지 않는다.
### 5.3 `backend` 서비스
역할:
1. Express API 제공
2. 내부 `db`에 연결
3. `/uploads` 정적 제공
핵심 포인트:
1. `env_file: .env`를 유지하지만,
2. Compose `environment`에서 `DB_HOST=db`, `DB_PORT=3306`, `DB_USER=itam_admin`, `DB_PASS=itam1234`, `DB_NAME=itam`를 다시 지정한다.
3. `depends_on``db` healthy와 `db-bootstrap` 성공 종료를 모두 기다린다.
즉, 백엔드는 DB bootstrap이 끝난 뒤 시작한다.
### 5.4 `frontend` 서비스
역할:
1. Vite dev server 제공
2. 브라우저 요청 `/api`, `/uploads``backend`로 프록시
핵심 포인트:
1. `VITE_DEV_PROXY_TARGET: http://backend:3000`
2. `CHOKIDAR_USEPOLLING: "true"`
3. `npm run dev -- --host 0.0.0.0`
중요한 이유는 컨테이너 안의 `localhost`가 호스트의 `localhost`가 아니기 때문이다.
---
## 6. 사전 준비 조건
이 저장소를 지금처럼 기동하려면 다음 전제가 필요하다.
### 6.1 운영체제와 런타임
1. Windows
2. WSL2 Ubuntu 설치 및 실행 중
3. Docker CLI가 WSL 내부에서 동작 가능
권장 확인 명령:
```powershell
wsl -l -v
wsl sh -lc "docker --version"
```
### 6.2 `.env` 파일
현재 최종 구조는 "첫 기동 시 외부 DB에서 내부 DB로 bootstrap" 하는 방식이므로 `.env`가 반드시 필요하다.
최소한 다음 값은 외부 원본 DB를 가리켜야 한다.
```env
DB_HOST=<external-mysql-host>
DB_PORT=3306
DB_USER=<external-db-user>
DB_PASS=<external-db-password>
DB_NAME=itam
```
주의:
1. `.env``db-bootstrap`이 외부 원본 DB에 접속할 때 사용한다.
2. `backend`는 최종적으로 내부 `db` 컨테이너를 쓰므로, 런타임에서는 Compose `environment`가 우선한다.
### 6.3 한글 경로 주의
현재 프로젝트 경로는 한글과 공백을 포함한다.
```text
c:\Users\user\Desktop\안건 파일\itam
```
이 때문에 Docker 관련 명령은 수동으로 경로를 조립하지 말고, `start_docker_wsl.ps1` / `stop_docker_wsl.ps1`을 우선 사용해야 한다.
---
## 7. 첫 기동 절차
이 절차는 "Git에서 소스를 받은 뒤 처음 올리는 경우" 기준이다.
### 7.1 저장소 준비
1. 저장소를 받는다.
2. `.env`가 올바른 외부 원본 DB를 가리키는지 확인한다.
3. WSL이 켜져 있는지 확인한다.
### 7.2 권장 실행 방법
Windows PowerShell에서 프로젝트 루트로 이동한 뒤 아래 중 하나를 사용한다.
방법 A:
```powershell
.\start_docker_wsl.ps1
```
방법 B:
```powershell
.\start_docker_wsl.bat
```
### 7.3 내부 실행 순서
스크립트는 내부적으로 다음 순서로 동작한다.
1. 현재 Windows 경로를 WSL 경로로 변환한다.
2. WSL 동작 여부를 확인한다.
3. WSL 내부 Docker 사용 가능 여부를 확인한다.
4. `docker compose up --build -d`를 수행한다.
### 7.4 기대되는 컨테이너 순서
정상이라면 다음 순서로 올라온다.
1. `itam-db`
2. `itam-db-bootstrap`
3. `itam-backend`
4. `itam-frontend`
`itam-db-bootstrap`은 정상이라면 최종 상태가 `Exited (0)`이어야 한다.
---
## 8. 첫 기동 후 검증 절차
기동 후에는 반드시 아래 검증을 수행한다.
### 8.1 컨테이너 상태 확인
```powershell
wsl sh -lc "docker ps -a --format 'table {{.Names}}\t{{.Status}}' | grep itam"
```
정상 기대 상태:
1. `itam-db` -> `Up ... (healthy)`
2. `itam-db-bootstrap` -> `Exited (0)`
3. `itam-backend` -> `Up`
4. `itam-frontend` -> `Up`
### 8.2 백엔드 API 직접 확인
```powershell
Invoke-WebRequest -Uri http://localhost:3000/api/assets/master -UseBasicParsing | Select-Object -ExpandProperty StatusCode
```
정상 기대값:
1. `200`
### 8.3 프런트 경유 API 확인
```powershell
Invoke-WebRequest -Uri http://localhost:8080/api/assets/master -UseBasicParsing | Select-Object -ExpandProperty StatusCode
```
정상 기대값:
1. `200`
### 8.4 데이터가 실제로 들어왔는지 확인
```powershell
wsl sh -lc "docker exec itam-db mysql -uitam_admin -pitam1234 -D itam -e 'SHOW TABLES' | head -n 20"
```
정상이라면 아래와 같은 테이블들이 보여야 한다.
1. `asset_core`
2. `asset_remote`
3. `asset_spec`
4. `asset_location`
5. `asset_history`
6. `asset_software_perpetual`
7. `asset_software_subscription`
8. `hardware_components_master`
9. `job_spec_standards`
### 8.5 브라우저 화면 확인
브라우저에서 아래 주소를 연다.
```text
http://localhost:8080
```
목록/대시보드 데이터가 보이면 화면까지 정상 연결된 것이다.
---
## 9. 재기동 절차
코드만 수정됐고 DB는 유지하고 싶다면 다음처럼 하면 된다.
### 9.1 스택 종료
```powershell
.\stop_docker_wsl.ps1
```
또는
```powershell
.\stop_docker_wsl.bat
```
### 9.2 스택 재기동
```powershell
.\start_docker_wsl.ps1
```
이 경우 `itam_mysql_data` 볼륨이 유지되므로, `db-bootstrap`은 내부 DB에 `asset_core`가 이미 있음을 감지하고 빠르게 종료한다.
---
## 10. DB를 완전히 다시 초기화하는 절차
외부 원본 DB에서 다시 처음부터 내부 DB를 복제하고 싶다면, MySQL 볼륨을 제거해야 한다.
### 10.1 스택 중지
```powershell
.\stop_docker_wsl.ps1
```
### 10.2 MySQL 데이터 볼륨 삭제
```powershell
wsl sh -lc "docker volume rm -f itam_itam_mysql_data"
```
### 10.3 다시 시작
```powershell
.\start_docker_wsl.ps1
```
이때 `db-bootstrap`이 외부 DB에서 내부 DB로 전체를 다시 복제한다.
---
## 11. 현재 구조에서 꼭 알아야 할 설계 포인트
### 11.1 `server.js`의 `dotenv.config()` 변경 이유
백엔드가 내부 DB로 붙게 하려면 Compose가 준 환경변수가 `.env`보다 우선해야 한다.
만약 아래처럼 `override: true`를 쓰면 안 된다.
```js
dotenv.config({ override: true });
```
이렇게 되면 내부 `db`가 아니라 `.env`의 외부 DB로 다시 붙을 수 있다.
현재는 아래가 맞다.
```js
dotenv.config();
```
### 11.2 왜 `docker-entrypoint-initdb.d` 기반 dump 파일을 안 쓰는가
처음에는 이 방식을 시도했지만, 실제 데이터의 긴 문자열/깨진 텍스트 때문에 import가 line 97에서 중단됐다.
그래서 현재는 더 안정적인 아래 방식을 쓴다.
1. 외부 DB에서 `mysqldump`
2. 파이프로 내부 `db`에 즉시 `mysql` import
즉, 파일 중간 생성물을 신뢰하지 않는 구조다.
### 11.3 왜 프런트 프록시 타깃을 환경변수화했는가
로컬 직접 실행과 컨테이너 실행의 네트워크 기준이 다르기 때문이다.
1. 로컬 직접 실행: `localhost:3000`이 맞다.
2. 컨테이너 내부 실행: `backend:3000`이 맞다.
그래서 `vite.config.ts`는 둘 다 수용할 수 있게 작성됐다.
---
## 12. 문제 발생 시 진단 순서
이 프로젝트에서는 문제를 아래 순서로 자르면 가장 빠르다.
### 12.1 브라우저 화면에 데이터가 없을 때
먼저 다음 둘을 분리해서 본다.
1. `http://localhost:3000/api/assets/master`
2. `http://localhost:8080/api/assets/master`
판단 기준:
1. `3000`은 200이고 `8080`만 실패면 프런트 프록시 문제다.
2. 둘 다 실패면 백엔드 또는 DB 문제다.
### 12.2 DB bootstrap이 성공했는지 확인할 때
```powershell
wsl sh -lc "docker ps -a --format 'table {{.Names}}\t{{.Status}}' | grep itam"
```
여기서 `itam-db-bootstrap``Exited (0)`인지 본다.
### 12.3 내부 DB에 실제 데이터가 있는지 확인할 때
```powershell
wsl sh -lc "docker exec itam-db mysql -uitam_admin -pitam1234 -D itam -e 'SHOW TABLES'"
```
### 12.4 백엔드 로그 확인
```powershell
wsl sh -lc "docker logs --tail=200 itam-backend"
```
### 12.5 DB 로그 확인
```powershell
wsl sh -lc "docker logs --tail=200 itam-db"
```
### 12.6 프런트 로그 확인
```powershell
wsl sh -lc "docker logs --tail=200 itam-frontend"
```
---
## 13. 자주 나올 수 있는 장애와 해석
### 13.1 `docker` 명령이 PowerShell에서 안 보임
의미:
1. Windows 셸이 아니라 WSL에서 Docker를 쓰는 환경이다.
대응:
1. `start_docker_wsl.ps1` 사용
### 13.2 `asset_core` 테이블 없음
의미:
1. 내부 DB 초기화가 안 됐거나 bootstrap이 안 끝났다.
대응:
1. `db-bootstrap` 상태 확인
2. `.env` 외부 DB 접속 정보 확인
3. 필요하면 볼륨 삭제 후 재초기화
### 13.3 `3000` API는 되는데 화면은 비어 있음
의미:
1. DB는 정상이고 프런트 프록시 또는 화면 렌더링 문제다.
대응:
1. `8080/api/assets/master` 상태 먼저 확인
### 13.4 `db-bootstrap`가 실패 종료함
의미 후보:
1. `.env` 외부 DB 접속 정보 오류
2. 외부 DB 네트워크 접근 불가
3. 외부 계정 권한 문제
대응:
1. `docker logs itam-db-bootstrap` 확인
---
## 14. 현재 최종 검증 완료 상태
이 저장소는 아래 상태까지 검증이 완료됐다.
1. WSL2 Ubuntu에서 Docker 실행 가능
2. `start_docker_wsl.ps1`로 전체 스택 기동 가능
3. `db` 컨테이너 healthcheck 통과
4. `db-bootstrap`가 외부 DB에서 내부 DB로 데이터 복제 후 `Exited (0)` 종료
5. `backend`가 내부 `db`를 사용해 API 응답 가능
6. `frontend``backend`를 프록시해 8080 기준 화면/API 동작 가능
7. 내부 MySQL에 실데이터 적재 확인
즉, 현재 Git에 올라간 상태만으로도 WSL2와 외부 원본 DB 정보만 있으면 지금과 같은 수준의 Docker 실행 재현이 가능하다.
---
## 15. 현재 구조의 한계와 다음 단계
현재 구조는 충분히 시연 가능하고 개발 재현도 가능하지만, 다음은 아직 별도 작업이 필요하다.
1. 운영형 정적 배포 구조 전환
2. 외부 DB 없이도 완전 독립 실행 가능한 정식 dump/backup 체계
3. `.env.example` 정리
4. DB bootstrap 전용 계정/권한 최소화
5. 장기적으로 `map_config.json` 파일 저장 정책 정리
하지만 "현재 저장소만으로 지금과 같은 Docker 실행 상태 재현"이라는 목표는 이미 충족한다.
---
## 16. 빠른 실행 요약
가장 짧게 요약하면 다음 순서다.
1. `.env`에 외부 원본 MySQL 접속 정보를 넣는다.
2. WSL2 Ubuntu와 WSL 내부 Docker가 살아 있는지 확인한다.
3. `start_docker_wsl.ps1`를 실행한다.
4. `itam-db-bootstrap``Exited (0)`인지 확인한다.
5. `http://localhost:3000/api/assets/master``http://localhost:8080/api/assets/master`가 모두 200인지 확인한다.
6. 브라우저에서 `http://localhost:8080`을 열어 데이터가 보이는지 확인한다.
이 순서대로 진행하면 현재 저장소 기준 Dockerized ITAM 시연 환경을 재현할 수 있다.
---
## 17. 2026-06-16 최신 정정
이 문서의 상단 본문은 한동안 사용했던 `내부 db + db-bootstrap` 구조를 기준으로 작성됐다. 하지만 오늘 기준 현재 저장소의 실제 `docker-compose.yaml`은 다시 `무상태 앱 컨테이너 + 외부 DB` 구조로 되돌아가 있다.
따라서 현재 시점의 정답 아키텍처는 아래다.
1. `backend` 컨테이너
2. `frontend` 컨테이너
3. 외부 MySQL DB
현재는 더 이상 아래 항목이 없다.
1. `db` 서비스 없음
2. `db-bootstrap` 서비스 없음
3. `itam_mysql_data` 볼륨 없음
### 17.1 현재 실제 `docker-compose.yaml` 기준 backend 동작
현재 backend는 `.env`의 외부 DB 접속 정보를 그대로 사용한다.
즉, 아래 환경변수 매핑이 현재 기준이다.
1. `DB_HOST: ${DB_HOST}`
2. `DB_PORT: ${DB_PORT}`
3. `DB_USER: ${DB_USER}`
4. `DB_PASS: ${DB_PASS}`
5. `DB_NAME: ${DB_NAME}`
`PORT: 3000`만 Compose에서 고정한다.
### 17.2 현재 실제 기동 구조
현재 스택 기동 순서는 단순하다.
1. `backend` 기동
2. `frontend` 기동
3. backend는 외부 DB에 직접 접속
4. frontend는 `http://backend:3000`으로 프록시
즉, 현재는 DB 컨테이너 초기화 단계나 bootstrap 단계가 존재하지 않는다.
### 17.3 현재 기준 첫 실행 체크리스트
오늘 기준으로는 아래 순서가 맞다.
1. `.env`에 외부 DB 접속 정보 입력
2. `start_docker_wsl.ps1` 또는 `start_docker_wsl.bat` 실행
3. `http://localhost:3000/api/assets/master`가 200인지 확인
4. `http://localhost:8080/api/assets/master`가 200인지 확인
5. 브라우저에서 `http://localhost:8080` 접속 후 데이터 표시 확인
### 17.4 이 문서에서 현재 유효한 부분과 과거 이력 부분
현재도 그대로 유효한 내용은 아래다.
1. WSL2 기반 실행 방식
2. `start_docker_wsl.ps1` / `stop_docker_wsl.ps1` 사용 방식
3. `server.js`에서 Compose 환경변수가 `.env`보다 우선되도록 `dotenv.config()`를 유지해야 한다는 점
4. `vite.config.ts`에서 프록시 타깃을 환경변수화해야 한다는 점
현재는 과거 이력으로만 읽어야 하는 내용은 아래다.
1. 내부 `db` 서비스 설명
2. `db-bootstrap` 설명
3. `itam_mysql_data` 볼륨 설명
4. 내부 DB 재초기화 절차
5. 내부 테이블 확인 절차
### 17.5 현재 최종 한 줄 요약
오늘 날짜 기준 현재 저장소의 실사용 Compose 구조는 `frontend + backend + external DB`이며, 이전의 내부 DB/bootstrap 구조는 역사적으로 한 번 사용했던 임시 해결책으로만 남아 있다.

730
doc_readme3.md Normal file
View File

@@ -0,0 +1,730 @@
# ITAM Linux 운영 배포 가이드
## 1. 문서 목적
이 문서는 현재 ITAM 저장소를 기준으로 Linux 환경에서 운영 배포하는 방법을 정리한 가이드다.
핵심 전제는 아래와 같다.
1. 저장소 구조를 크게 재편하지 않는다.
2. 현재 워크스페이스 기준 파일 구조를 그대로 활용한다.
3. `docker-compose.test.yaml``docker-compose.prod.yaml` 모두 현재 저장소 루트 기준으로 동작한다.
4. DB는 Docker 내부가 아니라 외부 MySQL을 사용한다.
즉, 이 문서는 `/srv/itam` 같은 별도 운영 디렉터리 구조를 강제하는 문서가 아니라, 현재 저장소 구조를 기준으로 운영 전환하는 방법을 설명한다.
---
## 2. 현재 운영 관련 파일
현재 저장소에서 운영 전환과 직접 관련된 파일은 아래와 같다.
1. `docker-compose.yaml`
2. `docker-compose.test.yaml`
3. `docker-compose.prod.yaml`
4. `Dockerfile.frontend.prod`
5. `Dockerfile.backend.prod`
6. `docker/nginx/default.conf`
7. `docker/frontend/default.conf`
8. `.env.example`
9. `server.js`
각 파일의 역할은 아래와 같다.
1. `docker-compose.yaml`: 개발 재현용 구성
2. `docker-compose.test.yaml`: 운영형 Dockerfile과 reverse proxy 구조를 로컬에서 검증하는 테스트용 구성
3. `docker-compose.prod.yaml`: 현재 저장소 기준 운영용 구성
4. `Dockerfile.frontend.prod`: 프런트 정적 빌드 및 Nginx 서빙 이미지 정의
5. `Dockerfile.backend.prod`: 백엔드 API 운영 이미지 정의
6. `docker/nginx/default.conf`: reverse proxy 설정
7. `docker/frontend/default.conf`: frontend 컨테이너 내부 정적 파일 서빙 설정
8. `.env.example`: 운영/테스트 환경변수 템플릿
9. `server.js`: `/health`, `/ready` 엔드포인트 포함
---
## 3. 현재 기준 운영 아키텍처
현재 구조에서의 요청 흐름은 아래와 같다.
1. 외부 요청은 Nginx 컨테이너로 들어온다.
2. `/` 요청은 frontend 컨테이너로 전달된다.
3. `/api/``/uploads/` 요청은 backend 컨테이너로 전달된다.
4. backend는 외부 MySQL에 연결한다.
5. `uploads`, `map_config.json`, `.env`, 로그는 현재 저장소 기준 상대 경로를 사용한다.
```mermaid
flowchart LR
U["User Browser"] --> RP["Reverse Proxy Nginx 80"]
RP -->|root| FE["Frontend Container Nginx Static 80"]
RP -->|api| BE["Backend Container Node 3000"]
RP -->|uploads| BE
BE --> DB["External MySQL 3306"]
BE --> UP["./uploads"]
BE --> CFG["./map_config.json"]
BE --> ENV["./.env"]
linkStyle default stroke:#d32f2f,stroke-width:2px;
```
현재 저장소 기준 파일/컨테이너 관계는 아래와 같다.
```mermaid
flowchart TB
subgraph REPO["Current Repository Root"]
ENV[".env"]
UP["uploads/"]
MAP["map_config.json"]
LOGS["logs/nginx/"]
CONF["docker/nginx/default.conf"]
end
subgraph CTR["Containers"]
NGINX["itam-nginx"]
FRONT["itam-frontend"]
BACK["itam-backend"]
end
DB["External MySQL"]
CONF --> NGINX
LOGS --> NGINX
ENV --> BACK
UP --> BACK
MAP --> BACK
NGINX --> FRONT
NGINX --> BACK
BACK --> DB
linkStyle default stroke:#d32f2f,stroke-width:2px;
```
---
## 4. 개발용, 테스트용, 운영용 차이
### 4.1 `docker-compose.yaml`
용도:
1. 개발 재현
2. 소스 수정과 빠른 확인
3. Vite dev server 기반 실행
특징:
1. bind mount 중심
2. 프런트는 개발 서버 기반
3. 운영 배포보다는 개발 생산성에 초점
### 4.2 `docker-compose.test.yaml`
용도:
1. 운영형 Dockerfile 테스트
2. reverse proxy 동작 테스트
3. 로컬/WSL에서 8080 포트 검증
특징:
1. frontend/backend를 `build`로 생성
2. nginx는 8080 포트로 노출
3. 현재 저장소 상대 경로를 그대로 사용
### 4.3 `docker-compose.prod.yaml`
용도:
1. 현재 저장소 구조 기준 운영 배포
2. 운영 모드 환경변수와 health check 사용
3. 현재 구조를 바꾸지 않고 운영 전환
특징:
1. frontend/backend를 `build + image` 방식으로 정의
2. `.env`, `uploads`, `map_config.json`, `logs/nginx`를 현재 저장소 기준 상대 경로로 사용
3. nginx는 80 포트를 사용
4. backend는 `NODE_ENV=production`으로 실행
---
## 5. 운영 파일 구조 기준
현재 운영 배포는 저장소 루트를 기준으로 아래 구조를 전제로 한다.
```text
itam/
.env
.env.example
docker-compose.prod.yaml
docker-compose.test.yaml
Dockerfile.frontend.prod
Dockerfile.backend.prod
map_config.json
uploads/
logs/
nginx/
docker/
nginx/
default.conf
frontend/
default.conf
```
운영에서 실제로 중요한 경로는 아래 네 가지다.
1. `./.env`
2. `./uploads`
3. `./map_config.json`
4. `./logs/nginx`
즉, 현재 구조를 유지하려면 이 경로들이 항상 함께 관리되어야 한다.
---
## 6. 운영 환경변수 정책
현재 기준 운영 환경변수는 저장소 루트의 `.env`를 사용한다.
`.env.example` 기준 예시는 아래와 같다.
```env
DB_HOST=172.16.8.151
DB_PORT=3306
DB_USER=itam_admin
DB_PASS=change-this
DB_NAME=itam
NODE_ENV=production
PORT=3000
LOG_LEVEL=info
```
운영 원칙은 아래와 같다.
1. 실제 운영 비밀번호가 들어간 `.env`는 Git에 올리지 않는다.
2. `.env.example`은 템플릿으로만 사용한다.
3. 운영 서버에서는 `.env` 파일 권한을 제한한다.
4. 운영 DB 계정과 개발 DB 계정은 분리한다.
권장 권한 예시는 아래와 같다.
```bash
chmod 600 .env
```
---
## 7. Frontend 운영 이미지 기준
`Dockerfile.frontend.prod`는 multi-stage build를 사용한다.
구성은 아래와 같다.
1. builder 단계에서 `npm ci` 수행
2. `npm run build` 수행
3. 결과물을 Nginx 이미지에 복사
4. `docker/frontend/default.conf`로 정적 파일 서빙
운영 관점에서의 장점은 아래와 같다.
1. runtime 이미지에 build 결과물만 포함된다.
2. dev server 없이 정적 파일만 제공한다.
3. frontend 컨테이너 자체도 health check 가능하다.
---
## 8. Backend 운영 이미지 기준
`Dockerfile.backend.prod`는 아래 기준으로 작성되어 있다.
1. `NODE_ENV=production`
2. production dependency만 설치
3. `appuser` 비루트 사용자 사용
4. `dumb-init` 사용
5. `/health` health check 사용
backend 컨테이너는 아래 자원을 사용한다.
1. `./.env`
2. `./uploads`
3. `./map_config.json`
4. 외부 MySQL
---
## 9. Reverse Proxy 기준
`docker/nginx/default.conf`는 현재 아래처럼 동작한다.
1. `/` -> `frontend:80`
2. `/api/` -> `backend:3000`
3. `/uploads/` -> `backend:3000`
추가로 아래 설정을 포함한다.
1. 기본 보안 헤더
2. access/error 로그
3. gzip 설정
4. health endpoint
중요한 점은, 현재 운영 기준에서 외부 사용자가 직접 frontend 컨테이너에 붙는 것이 아니라 반드시 nginx를 통해 들어간다는 점이다.
---
## 10. 현재 기준 운영 배포 절차
### 10.1 사전 점검
아래 항목을 먼저 확인한다.
1. `.env` 파일 존재 여부
2. `uploads/` 디렉터리 존재 여부
3. `logs/nginx/` 디렉터리 존재 여부
4. `map_config.json` 존재 여부
5. 외부 DB 접근 가능 여부
예시:
```bash
ls -la .env
ls -la uploads
ls -la logs/nginx
ls -la map_config.json
```
### 10.2 Compose 검증
```bash
docker compose -f docker-compose.prod.yaml config
```
### 10.3 운영 기동
```bash
docker compose -f docker-compose.prod.yaml up -d --build
docker compose -f docker-compose.prod.yaml ps
```
### 10.4 운영 중지
```bash
docker compose -f docker-compose.prod.yaml down
```
### 10.5 운영/배포 분기 흐름
현재 운영 반영은 자동 push 배포가 아니라, Gitea에 올라간 커밋을 기준으로 수동 workflow를 실행하는 방식이다.
즉 아래 원칙으로 이해하면 된다.
1. 로컬 수정본을 서버에 직접 복사하지 않는다.
2. 반드시 Gitea에 올라간 커밋을 기준으로 배포한다.
3. 운영 반영은 `.gitea/workflows/itam_production_deploy.yml` 수동 실행으로 진행한다.
4. 실패 후 재배포는 실패 지점에 따라 수정 위치가 달라진다.
운영 반영은 크게 세 상황으로 나뉜다.
1. 최초 운영 서버 구축 후 첫 배포
2. 코드 수정 후 일반 재배포
3. 배포 실패 또는 검증 실패 후 재배포
```mermaid
flowchart TD
START["배포 필요 발생"] --> CASE{"어떤 상황인가?"}
CASE -->|초기 구축| INIT["초기 운영 배포 준비"]
CASE -->|수정 반영| CHANGE["수정 후 재배포 준비"]
CASE -->|실패 후 재시도| RETRY["실패 원인 분석 후 재배포 준비"]
INIT --> INIT1["운영 서버 Docker / compose 확인"]
INIT1 --> INIT2["Gitea Variables / Secrets 등록"]
INIT2 --> INIT3["map_config.json / uploads 초기 데이터 준비"]
INIT3 --> MANUAL["Gitea에서 수동 배포 workflow 실행"]
CHANGE --> CHANGE1["로컬 수정 및 테스트"]
CHANGE1 --> CHANGE2["Gitea 커밋 / push"]
CHANGE2 --> CHANGE3["Code Check / Docker Build Check 통과"]
CHANGE3 --> MANUAL
RETRY --> RETRY1{"어디서 실패했는가?"}
RETRY1 -->|코드 체크 실패| FIX1["코드 또는 설정 수정"]
RETRY1 -->|배포 단계 실패| FIX2["서버 / 변수 / 권한 / 네트워크 수정"]
RETRY1 -->|Smoke Check 실패| FIX3["앱 기동 상태 / 프록시 / DB 상태 수정"]
FIX1 --> CHANGE2
FIX2 --> MANUAL
FIX3 --> MANUAL
MANUAL --> BACKUP["기존 운영 상태가 있으면 배포 전 백업"]
BACKUP --> DEPLOY["운영 서버 반영 수행"]
DEPLOY --> RESULT{"최종 검증 통과?"}
RESULT -->|예| DONE["운영 반영 완료"]
RESULT -->|아니오| RETRY
linkStyle default stroke:#d32f2f,stroke-width:2px;
```
### 10.6 최초 운영 배포 플로우
최초 배포에서는 코드보다 운영 환경 준비가 먼저다.
순서는 아래와 같다.
1. 운영 서버에 Docker Engine과 `docker compose`를 설치한다.
2. 운영 서버에서 Gitea 저장소에 접근 가능한 SSH 키를 준비한다.
3. Gitea repository Variables / Secrets를 등록한다.
4. `PROD_DEPLOY_PATH` 경로를 확정한다.
5. `PROD_BACKUP_ROOT` 경로를 `PROD_DEPLOY_PATH` 바깥으로 확정한다.
6. `map_config.json`, `uploads/` 초기 데이터를 준비한다.
7. Gitea에서 `itam_production_deploy.yml`을 수동 실행한다.
8. 배포 후 `docker compose ps`, `/health`, `/`, `/ready`를 확인한다.
즉 최초 배포는 아래 순서다.
```text
서버 준비 완료
-> Gitea 변수 / 시크릿 등록 완료
-> 백업 경로 확정 완료
-> 초기 데이터 준비 완료
-> 수동 배포 실행
```
### 10.7 수정 후 일반 재배포 플로우
일반적인 수정 반영은 아래 흐름이다.
1. 개발자가 로컬에서 코드 또는 설정을 수정한다.
2. 로컬에서 필요한 테스트를 수행한다.
3. 변경사항을 Gitea에 커밋 후 push 한다.
4. `itam_code_check.yml`이 빌드와 compose 문법을 검사한다.
5. `itam_docker_build_check.yml`이 운영용 이미지 빌드 가능 여부를 검사한다.
6. 두 검증이 통과하면 운영자가 Gitea에서 `itam_production_deploy.yml`을 수동 실행한다.
7. 기존 운영 상태가 있으면 배포 전 백업을 먼저 수행한다.
8. 운영 서버가 최신 커밋으로 동기화되고 컨테이너가 다시 올라온다.
9. smoke check 통과 여부를 확인한다.
```mermaid
flowchart LR
DEV["로컬 수정"] --> TEST["로컬 확인"]
TEST --> PUSH["커밋 / push"]
PUSH --> CODE["ITAM Code Check"]
CODE --> BUILD["ITAM Docker Build Check"]
BUILD --> GATE{"검증 통과?"}
GATE -->|예| RUN["Gitea에서 수동 배포 실행"]
GATE -->|아니오| FIX["로컬 수정 후 재커밋"]
FIX --> PUSH
RUN --> BACKUP["배포 전 백업"]
BACKUP --> PROD["운영 서버 배포"]
PROD --> SMOKE{"Smoke Check 통과?"}
SMOKE -->|예| OK["배포 완료"]
SMOKE -->|아니오| FIXDEPLOY["원인 수정 후 재배포"]
FIXDEPLOY --> RUN
linkStyle default stroke:#d32f2f,stroke-width:2px;
```
### 10.8 수동 배포 workflow 내부 실행 순서
Gitea에서 `itam_production_deploy.yml`을 수동 실행하면 내부적으로는 아래 순서로 진행된다.
1. SSH agent를 설정한다.
2. 필수 Variables / Secrets가 모두 있는지 확인한다.
3. 운영용 `.env.deploy` 파일을 생성한다.
4. 운영 서버에 접속한다.
5. `PROD_DEPLOY_PATH`를 생성한다.
6. 기존 운영 상태가 있으면 `make predeploy-backup`을 실행한다.
7. 저장소를 clone 또는 fetch 한다.
8. 선택한 브랜치의 최신 커밋으로 checkout, reset, clean 한다.
9. `uploads`, `logs/nginx` 디렉토리를 준비한다.
10. `.env.deploy`를 서버의 `.env`로 복사한다.
11. `docker compose -f docker-compose.prod.yaml config`를 수행한다.
12. `docker compose -f docker-compose.prod.yaml up -d --build`를 수행한다.
13. `docker compose ps`를 확인한다.
14. `/health`, `/`, backend `/ready` smoke check를 수행한다.
```mermaid
flowchart TD
A["수동 배포 시작"] --> B["SSH agent 설정"]
B --> C["Variables / Secrets 검증"]
C --> D{"필수 값 누락 여부"}
D -->|예| E["즉시 실패 후 설정 보완"]
D -->|아니오| F[".env.deploy 생성"]
F --> G["운영 서버 SSH 접속"]
G --> H["배포 경로 생성"]
H --> I["기존 운영 상태가 있으면 make predeploy-backup"]
I --> J["git clone 또는 fetch"]
J --> K["지정 브랜치 checkout / reset / clean"]
K --> L["uploads / logs/nginx 준비"]
L --> M[".env 업로드 및 권한 설정"]
M --> N["compose config 검증"]
N --> O{"compose config 성공?"}
O -->|아니오| P["설정 수정 후 재실행"]
O -->|예| Q["compose up -d --build"]
Q --> R["docker compose ps 확인"]
R --> S["/health, /, /ready smoke check"]
S --> T{"smoke check 성공?"}
T -->|예| U["운영 배포 완료"]
T -->|아니오| V["원인 분석 후 재배포"]
linkStyle default stroke:#d32f2f,stroke-width:2px;
```
### 10.9 실패 후 검증 및 재배포 플로우
실패가 났다고 해서 같은 방식으로 바로 다시 배포하면 안 된다.
실패 지점별 판단은 아래처럼 나눈다.
1. Code Check 실패: TypeScript, build, compose 문법 문제를 먼저 수정한다.
2. Docker Build Check 실패: Dockerfile, 정적 자산 복사, 운영 빌드 컨텍스트 문제를 수정한다.
3. Deploy 단계 실패: SSH, Gitea 변수, 서버 권한, 경로, 백업 경로, git 접근, Docker 권한을 수정한다.
4. Smoke Check 실패: Nginx 프록시, backend readiness, 외부 DB 연결, 앱 런타임 오류를 수정한다.
즉 재배포 전 판단 기준은 아래와 같다.
```text
CI 실패 -> 로컬 코드 / 설정 수정 후 재커밋
배포 실패 -> 서버 환경 또는 배포 설정 수정 후 수동 재실행
Smoke Check 실패 -> 앱 / 프록시 / DB 상태 수정 후 수동 재실행
```
운영 관점에서는 아래 순서를 지키는 것이 안전하다.
1. 실패 지점 확인
2. 원인 수정
3. 같은 실패가 다시 나는지 좁은 범위로 재검증
4. 그 다음에만 수동 배포 재실행
---
## 11. 테스트 배포 절차
운영형 구성을 먼저 검증하려면 아래처럼 진행한다.
```bash
docker compose -f docker-compose.test.yaml up -d --build
docker compose -f docker-compose.test.yaml ps
```
접속 기준은 아래와 같다.
1. `http://localhost:8080` -> nginx reverse proxy
2. `http://localhost:3000/health` -> backend health 확인
테스트용은 운영과 매우 유사하지만, 외부 노출 포트와 일부 실행 목적이 다르다.
---
## 12. Health Check 및 상태 판정
backend에는 아래 두 엔드포인트가 있다.
1. `/health`
2. `/ready`
판정 기준은 아래와 같다.
1. `/health = 200`, `/ready = 200`: 정상 서비스 가능 상태
2. `/health = 200`, `/ready = 503`: 프로세스는 살아 있으나 DB 또는 외부 의존성 미준비
확인 예시는 아래와 같다.
```bash
curl http://localhost:3000/health
curl http://localhost:3000/ready
```
---
## 13. 운영 점검 체크리스트
### 13.1 애플리케이션
1. `docker compose -f docker-compose.prod.yaml config` 통과 여부
2. frontend 이미지 빌드 성공 여부
3. backend 이미지 빌드 성공 여부
4. 모든 컨테이너가 `Up` 상태인지
5. 메인 화면이 정상 렌더링되는지
6. 데이터 조회가 정상 동작하는지
### 13.2 데이터 및 파일
1. `uploads/`에 쓰기 가능한지
2. `map_config.json`을 backend가 읽을 수 있는지
3. `.env` 파일 권한이 적절한지
4. `logs/nginx/`에 로그가 쌓이는지
### 13.3 네트워크
1. 외부 DB 접근 가능한지
2. nginx에서 backend upstream 연결이 되는지
3. `/api` 요청이 정상 응답하는지
---
## 14. 로그 및 장애 대응
기본 점검 명령은 아래와 같다.
```bash
docker compose -f docker-compose.prod.yaml ps
docker compose -f docker-compose.prod.yaml logs --tail=200 nginx
docker compose -f docker-compose.prod.yaml logs --tail=200 backend
docker compose -f docker-compose.prod.yaml logs --tail=200 frontend
```
장애 확인 순서는 아래가 좋다.
1. 컨테이너가 살아 있는지
2. nginx가 frontend/backend로 프록시하는지
3. backend가 DB에 붙는지
4. 업로드/설정 파일 권한 문제는 없는지
추가 확인 예시는 아래와 같다.
```bash
curl -I http://localhost/
curl http://localhost:3000/health
curl http://localhost:3000/ready
```
---
## 15. 백업 기준
현재 구조 기준 최소 백업 대상은 아래와 같다.
1. `.env`
2. `uploads/`
3. `map_config.json`
4. 외부 MySQL 데이터베이스
현재 저장소에는 운영 백업을 직접 실행할 수 있도록 `Makefile``scripts/backup.sh`가 추가되어 있다.
기본 원칙은 아래와 같다.
1. DB dump와 런타임 파일 백업을 분리해서 실행할 수 있어야 한다.
2. 기본 백업 산출물은 `backups/` 디렉터리에 쌓는다.
3. DB 접속 정보는 `.env`를 기준으로 읽는다.
4. 오래된 백업 파일은 보존 주기에 따라 정리한다.
백업 실행 흐름은 아래와 같다.
```mermaid
flowchart TD
START["운영 백업 실행"] --> TARGET{"무엇을 백업할 것인가?"}
TARGET -->|DB| DB["make db-dump"]
TARGET -->|운영 파일| FILES["make files-backup"]
TARGET -->|전체| FULL["make full-backup"]
DB --> OUT1["backups/db/*.sql.gz"]
FILES --> OUT2["backups/files/*.tar.gz"]
FULL --> OUT1
FULL --> OUT2
OUT1 --> CLEAN["make cleanup-backups"]
OUT2 --> CLEAN
linkStyle default stroke:#d32f2f,stroke-width:2px;
```
### 15.1 Make 명령 기준
사용 가능한 기본 명령은 아래와 같다.
```bash
make db-dump
make files-backup
make full-backup
make cleanup-backups
```
각 명령의 역할은 아래와 같다.
1. `make db-dump`: `.env` 기준 MySQL dump를 `backups/db/``.sql.gz`로 저장
2. `make files-backup`: `.env`, `uploads/`, `map_config.json``backups/files/``.tar.gz`로 저장
3. `make full-backup`: DB dump와 파일 백업을 한 번에 수행
4. `make cleanup-backups`: 기본 14일이 지난 백업 파일 정리
### 15.2 실행 예시
가장 단순한 전체 백업 예시는 아래와 같다.
```bash
make full-backup
```
DB dump만 별도로 실행하려면 아래처럼 사용한다.
```bash
make db-dump
```
운영 파일만 묶으려면 아래처럼 사용한다.
```bash
make files-backup
```
보존 주기를 30일로 바꿔 정리하려면 아래처럼 사용한다.
```bash
make cleanup-backups RETENTION_DAYS=30
```
백업 경로를 별도 디스크나 마운트 경로로 바꾸려면 아래처럼 사용한다.
```bash
make full-backup BACKUP_ROOT=/opt/itam-backups
```
### 15.3 현재 스크립트가 실제로 백업하는 대상
현재 `scripts/backup.sh`는 아래 규칙으로 동작한다.
1. `.env` 파일에서 `DB_HOST`, `DB_PORT`, `DB_USER`, `DB_PASS`, `DB_NAME`을 읽는다.
2. `mysqldump --single-transaction --quick --routines --triggers` 옵션으로 dump를 생성한다.
3. DB dump는 gzip 압축본으로 저장한다.
4. 파일 백업은 `.env`, `uploads/`, `map_config.json` 중 실제로 존재하는 항목만 묶는다.
5. 백업 정리는 `find ... -mtime` 기준으로 수행한다.
즉 현재 스크립트는 운영 서버 또는 백업 서버에서 바로 실행 가능한 최소 백업 도구로 보면 된다.
### 15.4 운영 사용 권장 방식
운영에서는 아래 방식이 가장 현실적이다.
1. 매일 새벽 cron 또는 systemd timer로 `make full-backup` 실행
2. 백업 완료 후 `make cleanup-backups` 실행
3. `backups/` 또는 별도 `BACKUP_ROOT` 경로를 NAS 또는 외부 백업 스토리지로 추가 복사
4. 최소 월 1회 restore 테스트 수행
가장 단순한 예시는 아래와 같다.
```bash
make full-backup BACKUP_ROOT=/opt/itam-backups
make cleanup-backups BACKUP_ROOT=/opt/itam-backups RETENTION_DAYS=30
```
DB 백업 자체는 여전히 DB 서버 정책과 함께 관리하는 것이 가장 안전하지만, 현재 저장소 기준 운영 자동화 진입점은 위 `make` 명령으로 통일해도 된다.
---
## 16. 롤백 기준
현재 구조에서는 가장 단순한 롤백 방식이 아래와 같다.
1. 이전 정상 커밋 또는 파일 상태 확보
2. 이미지 재빌드 또는 이전 이미지 재사용
3. `docker compose -f docker-compose.prod.yaml up -d --build` 재실행
4. `/health`, `/ready`, 메인 화면, 핵심 API 재검증
즉, 현재 구조에서는 별도 디렉터리 재배치보다 현재 저장소 상태 관리와 compose 재기동이 롤백의 중심이 된다.
---
## 17. 결론
현재 ITAM 저장소는 별도 `/srv/itam` 구조로 옮기지 않아도, 지금 파일 구조를 유지한 채 운영형 배포 흐름으로 전환할 수 있다.
정리하면 아래와 같다.
1. test와 prod 모두 현재 저장소 구조 기준으로 통일한다.
2. `.env`, `uploads`, `map_config.json`, `logs/nginx`를 운영 핵심 경로로 본다.
3. reverse proxy는 현재 `docker/nginx/default.conf`를 기준으로 운영한다.
4. backend는 production 모드, health check, 외부 DB 연결 구조를 유지한다.
5. 큰 구조 변경 없이도 운영 전환이 가능하다.
남은 작업은 TLS, 로그 로테이션, CI/CD, 보안 점검을 현재 구조 기준으로 계속 보강하는 것이다.

57
docker-compose.prod.yaml Normal file
View File

@@ -0,0 +1,57 @@
services:
backend:
image: itam-backend:prod
build:
context: .
dockerfile: Dockerfile.backend.prod
container_name: itam-backend
working_dir: /app
env_file:
- .env
environment:
NODE_ENV: production
PORT: 3000
volumes:
- ./uploads:/app/uploads
- ./map_config.json:/app/map_config.json:ro
expose:
- "3000"
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
frontend:
image: itam-frontend:prod
build:
context: .
dockerfile: Dockerfile.frontend.prod
container_name: itam-frontend
expose:
- "80"
restart: unless-stopped
nginx:
image: nginx:stable-alpine
container_name: itam-nginx
ports:
- "9090:80"
volumes:
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
- ./logs/nginx:/var/log/nginx
depends_on:
backend:
condition: service_healthy
frontend:
condition: service_started
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:80/"]
interval: 30s
timeout: 10s
retries: 3
start_period: 20s

62
docker-compose.test.yaml Normal file
View File

@@ -0,0 +1,62 @@
# Local testing compose file - uses relative paths and build contexts
# Usage: docker compose -f docker-compose.test.yaml up --build
services:
backend:
build:
context: .
dockerfile: Dockerfile.backend.prod
container_name: itam-backend-test
working_dir: /app
env_file:
- .env
environment:
NODE_ENV: development
PORT: 3000
DB_HOST: ${DB_HOST:-172.16.8.151}
DB_PORT: ${DB_PORT:-3306}
DB_USER: ${DB_USER:-root}
DB_PASS: ${DB_PASS:-}
DB_NAME: ${DB_NAME:-itam}
ports:
- "3000:3000"
volumes:
- ./uploads:/app/uploads
- ./map_config.json:/app/map_config.json:ro
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
frontend:
build:
context: .
dockerfile: Dockerfile.frontend.prod
container_name: itam-frontend-test
expose:
- "80"
restart: unless-stopped
nginx:
image: nginx:stable-alpine
container_name: itam-nginx-test
ports:
- "8080:80"
volumes:
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
- ./logs/nginx:/var/log/nginx
depends_on:
backend:
condition: service_healthy
frontend:
condition: service_started
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:80/"]
interval: 30s
timeout: 10s
retries: 3
start_period: 20s

48
docker-compose.yaml Normal file
View File

@@ -0,0 +1,48 @@
services:
backend:
build:
context: .
dockerfile: Dockerfile.backend
container_name: dachs-backend
working_dir: /app
env_file:
- .env
environment:
DB_HOST: ${DB_HOST}
DB_PORT: ${DB_PORT}
DB_USER: ${DB_USER}
DB_PASS: ${DB_PASS}
DB_NAME: ${DB_NAME}
PORT: 3000
ports:
- "3000:3000"
volumes:
- ./:/app
- backend_node_modules:/app/node_modules
- ./uploads:/app/uploads
- ./map_config.json:/app/map_config.json
command: npm run server
restart: unless-stopped
frontend:
build:
context: .
dockerfile: Dockerfile.frontend
container_name: dachs-frontend
working_dir: /app
depends_on:
- backend
environment:
CHOKIDAR_USEPOLLING: "true"
VITE_DEV_PROXY_TARGET: http://backend:3000
ports:
- "8080:8080"
volumes:
- ./:/app
- frontend_node_modules:/app/node_modules
command: npm run dev -- --host 0.0.0.0
restart: unless-stopped
volumes:
backend_node_modules:
frontend_node_modules:

View File

@@ -0,0 +1,55 @@
server {
listen 80;
listen [::]:80;
server_name _;
root /usr/share/nginx/html;
# Logging
access_log /var/log/nginx/frontend-access.log main;
error_log /var/log/nginx/frontend-error.log warn;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Gzip compression
gzip on;
gzip_types text/plain text/css text/xml text/javascript
application/x-javascript application/xml+rss
application/json application/javascript;
gzip_min_length 1000;
# Serve static files with SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets (60 days)
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|webp|woff|woff2|ttf|eot)$ {
expires 60d;
add_header Cache-Control "public, immutable";
}
# Don't cache HTML files
location ~* \.html$ {
expires -1;
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
# Health check
location /health {
access_log off;
return 200 "OK\n";
add_header Content-Type text/plain;
}
# Deny access to sensitive files
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
}

View File

@@ -0,0 +1,16 @@
# MySQL init directory
This directory is kept as a legacy hook for file-based MySQL initialization.
Current production path in this repository is not file-based import.
The live Docker flow uses the `db-bootstrap` service in `docker-compose.yaml` to stream data from the external source DB into the internal `db` container.
Use this directory only if you intentionally switch back to `docker-entrypoint-initdb.d` style initialization.
If you do that, typical naming would be:
- `01_schema.sql`
- `02_seed.sql`
- or a single `01_itam_dump.sql`
Remember that files in this directory are executed automatically by the MySQL container only on the first initialization of the data volume.

101
docker/nginx/default.conf Normal file
View File

@@ -0,0 +1,101 @@
upstream backend {
server backend:3000;
}
upstream frontend {
server frontend:80;
}
server {
listen 80;
listen [::]:80;
server_name _;
# Client upload size limit (adjust as needed)
client_max_body_size 100M;
# Logging
access_log /var/log/nginx/access.log main;
error_log /var/log/nginx/error.log warn;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Gzip compression
gzip on;
gzip_types text/plain text/css text/xml text/javascript
application/x-javascript application/xml+rss
application/json application/javascript;
gzip_min_length 1000;
# Forward all app requests to the frontend container
location / {
proxy_pass http://frontend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
}
# API proxy to backend
location /api/ {
proxy_pass http://backend/api/;
# Preserve original request information
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $server_name;
proxy_set_header X-Forwarded-Port $server_port;
# Connection settings
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
# Buffering settings
proxy_buffering on;
proxy_buffer_size 4k;
proxy_buffers 8 4k;
proxy_busy_buffers_size 8k;
}
# Uploads proxy to backend
location /uploads/ {
proxy_pass http://backend/uploads/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Cache uploads
expires 30d;
add_header Cache-Control "public";
}
# Health check endpoint (for monitoring)
location /health {
access_log off;
return 200 "OK\n";
add_header Content-Type text/plain;
}
# Deny access to sensitive files
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
location ~ ~$ {
deny all;
access_log off;
log_not_found off;
}
}

330
docker_task_plan.md Normal file
View File

@@ -0,0 +1,330 @@
# ITAM 도커라이징 작업 태스크 정리
## 1. 문서 목적
이 문서는 ITAM 자산관리 시스템의 도커라이징 작업을 실제 실행 단위로 쪼개서 정리한 태스크 문서다.
이 문서의 목표는 아래와 같다.
1. 내일까지 보여줄 시연 범위를 기준으로 우선순위를 정한다.
2. 시연용 작업과 운영형 전환 작업을 분리한다.
3. 개발 담당자가 바로 실행할 수 있는 체크리스트를 제공한다.
관련 배경과 구조 분석은 [doc_readme.md](c:/Users/user/Desktop/안건%20파일/itam/doc_readme.md) 문서를 기준으로 한다.
현재 구현/검증 상태:
- `Dockerfile.frontend` 생성 완료
- `Dockerfile.backend` 생성 완료
- `docker-compose.yaml` 생성 완료
- `.dockerignore` 생성 완료
- WSL2 Ubuntu에서 `docker compose up --build -d` 검증 완료
- frontend 8080 응답 확인 완료
- backend `/api/assets/master` 응답 확인 완료
- 현재 DB는 external MySQL 기준이며, DB 컨테이너 추가 작업은 다음 단계로 남아 있음
## 2. 이번 작업의 최우선 목표
이번 도커라이징의 1차 목표는 "운영 배포 완료"가 아니라 아래 상태를 재현하는 것이다.
1. frontend 컨테이너가 정상 기동한다.
2. backend 컨테이너가 정상 기동한다.
3. backend가 기존 외부 MySQL 또는 MySQL 컨테이너에 정상 연결된다.
4. 브라우저에서 화면이 열린다.
5. 핵심 API 호출이 정상 동작한다.
6. 업로드 저장 경로가 유지된다.
7. 필요 시 DB까지 함께 포함된 재현 가능한 스택을 제공한다.
## 3. 작업 범위 구분
### 3.1 이번 시연 범위에 포함
- Dockerfile.frontend 초안 작성
- Dockerfile.backend 초안 작성
- docker-compose.yaml 작성
- `.dockerignore` 작성
- MySQL 컨테이너 추가 설계
- 초기 SQL dump 또는 init SQL 적재 방식 정의
- `uploads` 볼륨 처리
- `map_config.json` 영속성 처리 방식 반영
- 컨테이너 기동 및 접속 확인
- 핵심 API 및 화면 확인
### 3.2 이번 시연 범위에서 제외
- DB 전체 마이그레이션 자동화
- nginx 기반 운영 배포 구조
- 단일 이미지 운영 구조 전환
- CI/CD 연계
## 4. 선행 확인 태스크
아래 태스크는 실제 Docker 파일 작성 전에 먼저 확인해야 한다.
### Task 1. 외부 MySQL 접근 가능 여부 확인
- 목적: 컨테이너에서 외부 DB 접속이 가능한지 확인
- 확인 항목:
- DB_HOST 접근 가능 여부
- DB_PORT 3306 접속 가능 여부
- 계정 권한 정상 여부
- 완료 기준:
- backend 컨테이너 기준 DB 연결 에러가 발생하지 않음
### Task 2. 기준 스키마 상태 확인
- 목적: 현재 앱이 요구하는 테이블 구조가 실제 DB와 맞는지 확인
- 확인 항목:
- `asset_core`
- `asset_spec`
- `asset_location`
- `asset_remote`
- `asset_history`
- `hardware_components_master`
- `job_spec_standards`
- 완료 기준:
- `/api/assets/master` 호출 시 쿼리 에러가 발생하지 않음
### Task 3. 파일 영속성 대상 확인
- 목적: 컨테이너 재시작 이후에도 유지되어야 할 파일/폴더 식별
- 대상:
- `uploads`
- `map_config.json`
- 완료 기준:
- 볼륨 설계 대상이 명확하게 문서화됨
### Task 4. DB 기준 데이터 소스 확정
- 목적: MySQL 컨테이너 최초 기동 시 어떤 데이터로 초기화할지 결정
- 선택지:
- 기존 사내 DB에서 추출한 SQL dump 사용
- 정리된 스키마 SQL + seed SQL 사용
- 수동 import 절차 사용
- 완료 기준:
- `docker/mysql/init` 기준 적재 전략 또는 수동 복원 절차가 확정됨
## 5. 시연용 도커라이징 태스크
### Task 5. 프런트 Dockerfile 작성
- 목적: Vite 개발 서버를 컨테이너에서 구동
- 작업 내용:
- Node 20 계열 이미지 사용
- `package*.json` 복사 후 `npm install`
- 8080 포트 노출
- `npm run dev -- --host 0.0.0.0` 실행
- 산출물:
- `Dockerfile.frontend`
- 완료 기준:
- 컨테이너에서 8080 포트가 정상 listen 상태가 됨
### Task 6. 백엔드 Dockerfile 작성
- 목적: Express API 서버를 컨테이너에서 구동
- 작업 내용:
- Node 20 계열 이미지 사용
- `package*.json` 복사 후 `npm install`
- 3000 포트 노출
- `npm run server` 실행
- 산출물:
- `Dockerfile.backend`
- 완료 기준:
- 컨테이너에서 3000 포트가 정상 listen 상태가 됨
### Task 7. MySQL Docker 구성 추가
- 목적: DB까지 포함한 재현 가능한 스택 구성
- 작업 내용:
- `mysql:8.0` 서비스 정의
- `MYSQL_DATABASE`, `MYSQL_USER`, `MYSQL_PASSWORD` 설정
- utf8mb4 문자셋 옵션 반영
- MySQL 데이터 volume 연결
- 초기 SQL 적재용 `docker/mysql/init` 디렉터리 설계
- 산출물:
- `docker-compose.yaml``db` 서비스 또는 별도 DB compose 확장안
- 완료 기준:
- MySQL 컨테이너가 정상 기동하고 3306 포트에서 응답 가능
### Task 8. backend DB 연결 전환
- 목적: backend가 external MySQL 대신 DB 컨테이너를 바라보도록 변경
- 작업 내용:
- `DB_HOST``db`로 전환
- 필요 시 `.env.docker` 또는 compose 내부 환경변수 사용
- backend `depends_on`에 db 추가
- 산출물:
- DB 컨테이너용 backend 환경 정의
- 완료 기준:
- backend 로그에서 DB 연결 성공 확인
### Task 9. docker-compose.yaml 확장
- 목적: frontend/backend를 함께 기동
- 작업 내용:
- frontend 서비스 정의
- backend 서비스 정의
- db 서비스 정의
- 포트 매핑 추가
- `.env` 또는 docker 전용 환경변수 연결
- MySQL 데이터 볼륨 연결
- `uploads` 볼륨 연결
- `map_config.json` 처리 방식 반영
- 산출물:
- `docker-compose.yaml`
- 완료 기준:
- `docker compose up --build` 한 번으로 세 서비스가 모두 올라옴
### Task 10. `.dockerignore` 작성
- 목적: 불필요한 빌드 컨텍스트 제외
- 제외 권장 항목:
- `node_modules`
- `dist`
- `build`
- `.git`
- `uploads`
- `*.xlsx`
- 산출물:
- `.dockerignore`
- 완료 기준:
- 이미지 빌드 컨텍스트가 과도하게 커지지 않음
## 6. 시연 검증 태스크
### Task 11. WSL 컨테이너 기동 검증
- 실행 명령:
```bash
powershell -ExecutionPolicy Bypass -File .\start_docker_wsl.ps1
```
- 확인 항목:
- frontend 로그 에러 여부
- backend 로그 에러 여부
- db 로그 에러 여부
- backend와 db 연결 성공 여부
- 완료 기준:
- 세 컨테이너 모두 종료 없이 유지됨
### Task 12. 웹 접속 검증
- 확인 항목:
- `http://localhost:8080` 접속 가능 여부
- 첫 화면 로딩 여부
- 콘솔 에러 여부
- 완료 기준:
- 브라우저에서 초기 화면이 정상 표시됨
### Task 13. API 검증
- 확인 항목:
- `http://localhost:3000/api/assets/master`
- 프런트에서 `/api/assets/master` 호출 정상 여부
- 완료 기준:
- 200 응답 또는 정상 데이터 응답 확인
### Task 14. DB 초기 데이터 검증
- 확인 항목:
- MySQL 컨테이너 내부에 목표 DB가 생성되었는지
- 기준 테이블이 존재하는지
- 샘플 데이터 또는 실데이터가 적재되었는지
- 완료 기준:
- backend가 기대하는 최소 테이블과 데이터가 실제로 조회됨
### Task 15. 업로드/파일 저장 검증
- 확인 항목:
- `/api/upload` 호출 정상 여부
- 업로드 파일이 `uploads`에 실제 저장되는지
- `map_config.json` 수정 내용이 유지되는지
- 완료 기준:
- 컨테이너 재시작 후에도 저장 데이터가 유지됨
## 7. 시연 후 후속 태스크
### Task 16. 운영형 프런트 배포 구조 전환
- 목표: Vite dev server 대신 정적 빌드 기반 구조로 전환
- 후보:
- nginx 정적 서빙
- Express 정적 서빙
### Task 17. DB 초기화/마이그레이션 전략 통합
- 목표: 기준 스키마와 실행 순서를 단일 정책으로 통일
- 필요 작업:
- 기준 스키마 선정
- 초기화 스크립트 확정
- 마이그레이션 순서 정의
### Task 18. `.env.example` 및 배포 환경 분리
- 목표: 민감정보를 저장소에서 분리하고 배포별 설정 체계화
### Task 19. 운영 볼륨 및 백업 전략 정리
- 목표: 업로드 파일과 설정 파일, MySQL 데이터의 장기 보존 정책 정리
### Task 20. DB 백업/복원 절차 문서화
- 목표: 컨테이너 DB를 기준으로 dump/restore 절차를 문서화
## 8. 우선순위 정리
### P0: 내일까지 반드시 필요한 작업
1. Task 1. 외부 MySQL 접근 가능 여부 확인
2. Task 2. 기준 스키마 상태 확인
3. Task 4. DB 기준 데이터 소스 확정
4. Task 7. MySQL Docker 구성 추가
5. Task 8. backend DB 연결 전환
6. Task 9. docker-compose.yaml 확장
7. Task 11. WSL 컨테이너 기동 검증
8. Task 12. 웹 접속 검증
9. Task 13. API 검증
10. Task 14. DB 초기 데이터 검증
### P1: 시연 안정화를 위해 권장되는 작업
1. Task 3. 파일 영속성 대상 확인
2. Task 10. `.dockerignore` 작성
3. Task 15. 업로드/파일 저장 검증
### P2: 시연 이후 진행할 작업
1. Task 16. 운영형 프런트 배포 구조 전환
2. Task 17. DB 초기화/마이그레이션 전략 통합
3. Task 18. `.env.example` 및 배포 환경 분리
4. Task 19. 운영 볼륨 및 백업 전략 정리
5. Task 20. DB 백업/복원 절차 문서화
## 9. 개발자용 최종 작업 순서 제안
개발 담당자에게는 아래 순서로 진행하라고 전달하면 된다.
1. 외부 DB 연결 가능 여부부터 확인
2. 현재 DB 스키마가 앱 요구사항과 맞는지 확인
3. DB 기준 dump 또는 init SQL 확보
4. MySQL 컨테이너 구성 추가
5. backend의 DB 연결 대상을 `db`로 전환
6. WSL에서 `docker compose config` 확인
7. WSL에서 컨테이너 기동 테스트
8. 웹 접속 및 API 확인
9. 업로드 및 파일 영속성 확인
10. 시연 완료 후 운영형 구조로 분리 작업 진행
## 10. 완료 판단 기준
이번 도커라이징 1차 작업은 아래 조건을 만족하면 완료로 본다.
1. `docker compose up --build`로 프런트, 백엔드, DB가 모두 기동한다.
2. 브라우저에서 8080 화면이 열린다.
3. `/api/assets/master`가 정상 응답한다.
4. backend가 DB 컨테이너와 정상 연결된다.
5. DB 초기 테이블과 데이터가 기대 상태로 적재된다.
6. `uploads`, `map_config.json`, MySQL 데이터가 재시작 후에도 유지된다.
이 문서는 실제 구현 작업의 체크리스트로 사용한다.

View File

@@ -1,45 +1,45 @@
# [Issue] 소프트웨어 자산 관리 체계 개편 및 클라우드(Cloud) 서비스 관리 신설
## 1. 개요
기존의 단일 소프트웨어(SW) 분류 체계를 비즈니스 모델에 맞춰 **구독형, 영구형, 클라우드형**으로 삼원화하고, 특히 비용 변동이 잦은 클라우드 서비스를 독립적으로 관리할 수 있는 전용 시스템을 신설함.
---
## 2. 주요 작업 내용
### 📂 소프트웨어 관리 프레임워크 재구조화
- **분류 체계 개편**: 소프트웨어를 아래 세 가지 유형으로 재정의하여 관리 효율성을 높임.
1. **구독형 (Subscription)**: 연/월 정액제로 운영되는 SW
2. **영구형 (Perpetual)**: 구매 후 영구 소유하는 SW (유지보수 중심 관리)
3. **클라우드형 (Cloud)**: 플랫폼 기반 종량제(AWS, Azure 등) 서비스
- **내비게이션 통합**: 상단 탭을 유형별로 분리하여 각 자산 특성에 맞는 리스트 뷰를 제공함.
### ☁️ 클라우드(Cloud) 서비스 관리 페이지 신설
- **전용 리스트 뷰 (`CloudListView.ts`)**:
- 플랫폼명, 담당 부서, 프로젝트(사용용도), 결제 수단, 결제일 등 클라우드 특화 항목 중심의 테이블 구성함.
- **결제수단별 필터링 기능** (법인카드, 인보이스) 및 통합 검색 기능을 추가함.
- **클라우드 전문 모달 (`CloudModal.ts`)**:
- 클라우드 요금 및 결제 정보 입력을 위한 2분할 레이아웃 배치함.
- **업데이트 이력(History Logs)** 시스템을 도입하여 매월 변동되는 비용을 히스토리 형식으로 기록/추적 가능하게 함.
### 📊 대시보드(Dashboard) 리팩토링 및 고도화
- **카드 레이아웃 최적화**: 사용율, 만료 예정, 클라우드 현황(전월/당월 비교) 정보를 2열 그리드로 정돈함.
- **데이터 시각화**:
- **클라우드 결제 규모 추이**: 최근 4개월간의 비용 변동을 꺾은선 그래프로 구현함.
- **실시간 데이터 연동**: 자산 업데이트 이력(Logs)에 기록된 비용이 대시보드 차트에 실시간 합산 반영되도록 로그 분석 엔진을 구축함.
- **상세 팝업 연동**: 대시보드 요약 카드를 클릭하면 해당하는 자산의 상세 목록이 뜨는 모달 연동 기능을 추가함.
### 🪟 UX 및 데이터 정합성 강화
- **수정 저장 워크플로우 (Edit-to-Save)**: 실수로 인한 데이터 변경을 막기 위해 모든 상세 모달에 '조회 모드'를 기본으로 하고, [수정] 버튼 클릭 시에만 입력이 활성화되도록 제어함.
- **금액 자동 포맷팅**: 콤마 표시 오류를 해결하고 천 단위 포맷팅을 표준화함.
- **결제 임박 알림**: 각 서비스의 결제일을 계산하여 14일 이내 결제가 필요한 항목을 대시보드에서 즉시 파악할 수 있게 함.
---
## 3. 향후 과제
- 클라우드 플랫폼 간 비용 비교 통계 기능 확장 검토
- 결제 수단(법인카드) 만료일에 기초한 알림 서비스 추가 검토
---
**작업자**: Antigravity (AI Assistant)
**상태**: 완료 (2026-04-17)
# [Issue] 소프트웨어 자산 관리 체계 개편 및 클라우드(Cloud) 서비스 관리 신설
## 1. 개요
기존의 단일 소프트웨어(SW) 분류 체계를 비즈니스 모델에 맞춰 **구독형, 영구형, 클라우드형**으로 삼원화하고, 특히 비용 변동이 잦은 클라우드 서비스를 독립적으로 관리할 수 있는 전용 시스템을 신설함.
---
## 2. 주요 작업 내용
### 📂 소프트웨어 관리 프레임워크 재구조화
- **분류 체계 개편**: 소프트웨어를 아래 세 가지 유형으로 재정의하여 관리 효율성을 높임.
1. **구독형 (Subscription)**: 연/월 정액제로 운영되는 SW
2. **영구형 (Perpetual)**: 구매 후 영구 소유하는 SW (유지보수 중심 관리)
3. **클라우드형 (Cloud)**: 플랫폼 기반 종량제(AWS, Azure 등) 서비스
- **내비게이션 통합**: 상단 탭을 유형별로 분리하여 각 자산 특성에 맞는 리스트 뷰를 제공함.
### ☁️ 클라우드(Cloud) 서비스 관리 페이지 신설
- **전용 리스트 뷰 (`CloudListView.ts`)**:
- 플랫폼명, 담당 부서, 프로젝트(사용용도), 결제 수단, 결제일 등 클라우드 특화 항목 중심의 테이블 구성함.
- **결제수단별 필터링 기능** (법인카드, 인보이스) 및 통합 검색 기능을 추가함.
- **클라우드 전문 모달 (`CloudModal.ts`)**:
- 클라우드 요금 및 결제 정보 입력을 위한 2분할 레이아웃 배치함.
- **업데이트 이력(History Logs)** 시스템을 도입하여 매월 변동되는 비용을 히스토리 형식으로 기록/추적 가능하게 함.
### 📊 대시보드(Dashboard) 리팩토링 및 고도화
- **카드 레이아웃 최적화**: 사용율, 만료 예정, 클라우드 현황(전월/당월 비교) 정보를 2열 그리드로 정돈함.
- **데이터 시각화**:
- **클라우드 결제 규모 추이**: 최근 4개월간의 비용 변동을 꺾은선 그래프로 구현함.
- **실시간 데이터 연동**: 자산 업데이트 이력(Logs)에 기록된 비용이 대시보드 차트에 실시간 합산 반영되도록 로그 분석 엔진을 구축함.
- **상세 팝업 연동**: 대시보드 요약 카드를 클릭하면 해당하는 자산의 상세 목록이 뜨는 모달 연동 기능을 추가함.
### 🪟 UX 및 데이터 정합성 강화
- **수정 저장 워크플로우 (Edit-to-Save)**: 실수로 인한 데이터 변경을 막기 위해 모든 상세 모달에 '조회 모드'를 기본으로 하고, [수정] 버튼 클릭 시에만 입력이 활성화되도록 제어함.
- **금액 자동 포맷팅**: 콤마 표시 오류를 해결하고 천 단위 포맷팅을 표준화함.
- **결제 임박 알림**: 각 서비스의 결제일을 계산하여 14일 이내 결제가 필요한 항목을 대시보드에서 즉시 파악할 수 있게 함.
---
## 3. 향후 과제
- 클라우드 플랫폼 간 비용 비교 통계 기능 확장 검토
- 결제 수단(법인카드) 만료일에 기초한 알림 서비스 추가 검토
---
**작업자**: Antigravity (AI Assistant)
**상태**: 완료 (2026-04-17)

View File

@@ -1,33 +1,33 @@
# [이슈] S/W 자산 관리 고도화 및 이력 추적 기능 구현
## 1. 개요
소프트웨어 자산의 라이프사이클을 체계적으로 관리하기 위해 상세 정보 모달을 개편하고, 갱신(업데이트) 이력을 추적할 수 있는 기능을 구현하였습니다. 또한, 사용자의 가독성을 위해 상태를 나타내는 자동 뱃지를 도입하고 날짜 입력 편의성을 개선하였습니다.
## 2. 작업 상세 내용
### A. S/W 목록(Table) 개선
- **상태 자동 계산 시스템 도입**:
- 구독 S/W: 만료일 기준 **[사용중] / [만료]** 자동 표시.
- 영구 S/W: 유지보수 대상 여부에 따라 **[유효] / [없음]** 표시.
- **UI 뱃지 적용**: 테이블 좌측에 상태 뱃지를 추가하여 시각적 인지도를 높임.
### B. 상세 정보 모달 개편 (`SWModal.ts`)
- **2단 분할 레이아웃 적용**: 좌측(기본 정보), 우측(업데이트 타임라인)으로 UI 재설계.
- **날짜 입력 필드 개선**:
- '구매일' 필드에 캘린더 피커(Calendar Picker) 적용.
- '구독 기간' 필드를 **시작일**과 **종료일**로 분리하여 각각 캘린더 적용.
- 직접 입력("yyyy-mm-dd") 형식도 동시 지원.
### C. 계약 업데이트(갱신) 관리 기능
- **[업데이트 추가]** 버튼 및 전용 서브 팝업 구현.
- 갱신 시 발생하는 비용, 기간 연장, 메모를 기록하여 타임라인(Log)에 누적.
- 업데이트 반영 시 메인 자산 정보의 구독 기한 및 누적 금액이 자동으로 최신화되도록 연동.
## 3. 관련 파일
- `src/views/SW_Table.ts`: 테이블 상태 로직 및 뱃지 렌더링.
- `src/components/Modal/SWModal.ts`: 모달 UI 및 날짜 처리, 업데이트 로직.
- `src/styles/modal.css`: 분할 레이아웃 및 타임라인 스타일.
## 4. 확인 사항
- 엑셀 업로드/다운로드 시 기존 '구독일' 문자열 형식과의 호환성 유지 확인.
- 브라우저 테스트를 통한 캘린더 작동 및 테이블 상태 연동 확인 완료.
# [이슈] S/W 자산 관리 고도화 및 이력 추적 기능 구현
## 1. 개요
소프트웨어 자산의 라이프사이클을 체계적으로 관리하기 위해 상세 정보 모달을 개편하고, 갱신(업데이트) 이력을 추적할 수 있는 기능을 구현하였습니다. 또한, 사용자의 가독성을 위해 상태를 나타내는 자동 뱃지를 도입하고 날짜 입력 편의성을 개선하였습니다.
## 2. 작업 상세 내용
### A. S/W 목록(Table) 개선
- **상태 자동 계산 시스템 도입**:
- 구독 S/W: 만료일 기준 **[사용중] / [만료]** 자동 표시.
- 영구 S/W: 유지보수 대상 여부에 따라 **[유효] / [없음]** 표시.
- **UI 뱃지 적용**: 테이블 좌측에 상태 뱃지를 추가하여 시각적 인지도를 높임.
### B. 상세 정보 모달 개편 (`SWModal.ts`)
- **2단 분할 레이아웃 적용**: 좌측(기본 정보), 우측(업데이트 타임라인)으로 UI 재설계.
- **날짜 입력 필드 개선**:
- '구매일' 필드에 캘린더 피커(Calendar Picker) 적용.
- '구독 기간' 필드를 **시작일**과 **종료일**로 분리하여 각각 캘린더 적용.
- 직접 입력("yyyy-mm-dd") 형식도 동시 지원.
### C. 계약 업데이트(갱신) 관리 기능
- **[업데이트 추가]** 버튼 및 전용 서브 팝업 구현.
- 갱신 시 발생하는 비용, 기간 연장, 메모를 기록하여 타임라인(Log)에 누적.
- 업데이트 반영 시 메인 자산 정보의 구독 기한 및 누적 금액이 자동으로 최신화되도록 연동.
## 3. 관련 파일
- `src/views/SW_Table.ts`: 테이블 상태 로직 및 뱃지 렌더링.
- `src/components/Modal/SWModal.ts`: 모달 UI 및 날짜 처리, 업데이트 로직.
- `src/styles/modal.css`: 분할 레이아웃 및 타임라인 스타일.
## 4. 확인 사항
- 엑셀 업로드/다운로드 시 기존 '구독일' 문자열 형식과의 호환성 유지 확인.
- 브라우저 테스트를 통한 캘린더 작동 및 테이블 상태 연동 확인 완료.

226
docs/itam_cicd_setup.md Normal file
View File

@@ -0,0 +1,226 @@
# ITAM CI/CD 설정 가이드
## 1. 문서 목적
이 문서는 현재 ITAM 저장소에 구성된 CI/CD 파일을 실제 운영에 연결하기 위해 필요한 기준을 정리한 가이드다.
대상 범위는 아래와 같다.
1. Gitea Actions workflow 역할
2. Gitea Variables / Secrets 설정값
3. 운영 서버 배포 디렉토리 기준
4. 운영 영속 경로 기준
5. production deploy 실행 전 확인 사항
---
## 2. 현재 CI/CD 구성
현재 `.gitea/workflows`에는 ITAM 관련 workflow만 남겨둔 상태다.
1. `itam_code_check.yml`
2. `itam_docker_build_check.yml`
3. `itam_production_deploy.yml`
각 workflow의 역할은 아래와 같다.
1. `itam_code_check.yml`: TypeScript/Vite build와 compose 문법 검증
2. `itam_docker_build_check.yml`: 운영용 Docker 이미지 빌드 가능 여부 검증
3. `itam_production_deploy.yml`: 운영 서버에 SSH 접속 후 실제 배포 수행
현재 배포 흐름은 아래와 같다.
```mermaid
flowchart LR
DEV["Developer Push or Manual Run"] --> CODE["ITAM Code Check"]
CODE --> BUILD["ITAM Docker Build Check"]
BUILD --> DEPLOY["ITAM Production Deploy"]
DEPLOY --> HOST["Production Host"]
HOST --> APP["docker compose up -d --build"]
linkStyle default stroke:#d32f2f,stroke-width:2px;
```
---
## 3. Gitea Variables / Secrets 기준
`itam_production_deploy.yml`이 정상 동작하려면 아래 값이 필요하다.
### 3.1 Variables
아래 항목은 Gitea repository Variables에 등록한다.
| Key | 설명 | 예시 |
| --- | --- | --- |
| `PROD_HOST` | 운영 서버 SSH 접속 호스트 | `10.0.0.25` |
| `PROD_USER` | 운영 서버 SSH 사용자 | `deploy` |
| `PROD_DEPLOY_PATH` | 서버에서 저장소를 배포할 경로 | `/opt/itam` |
| `PROD_BACKUP_ROOT` | 배포 전 백업 저장 경로, 배포 경로 바깥이어야 함 | `/opt/itam-backups` |
| `PROD_GIT_URL` | 운영 서버에서 pull 가능한 저장소 주소 | `git@gitea.example.com:team/itam.git` |
| `PROD_DB_HOST` | 외부 MySQL 호스트 | `172.16.8.151` |
| `PROD_DB_PORT` | 외부 MySQL 포트 | `3306` |
| `PROD_DB_USER` | 운영 DB 계정 | `itam_admin` |
| `PROD_DB_NAME` | 운영 DB 이름 | `itam` |
| `PROD_LOG_LEVEL` | 애플리케이션 로그 레벨 | `info` |
### 3.2 Secrets
아래 항목은 Gitea repository Secrets에 등록한다.
| Key | 설명 |
| --- | --- |
| `PROD_SSH_PRIVATE_KEY` | 운영 서버 접속용 개인키 |
| `PROD_DB_PASS` | 운영 DB 비밀번호 |
### 3.3 운영 원칙
1. `PROD_DB_PASS`는 Variables가 아니라 Secrets에만 둔다.
2. `PROD_SSH_PRIVATE_KEY`는 배포 전용 계정 키를 사용한다.
3. `PROD_GIT_URL`은 운영 서버에서 직접 pull 가능한 주소여야 한다.
4. 운영 서버의 `known_hosts`는 workflow에서 자동 등록되지만, 최초 운영 전 수동 접속 검증도 함께 수행하는 것이 안전하다.
5. `PROD_BACKUP_ROOT``PROD_DEPLOY_PATH` 내부가 아니라 바깥 경로를 사용해야 한다.
---
## 4. 운영 서버 배포 디렉토리 기준
현재 `itam_production_deploy.yml`은 운영 서버에서 아래 흐름으로 배포를 수행한다.
1. `PROD_DEPLOY_PATH` 디렉토리를 생성한다.
2. 기존 운영 상태가 있으면 배포 전 백업을 수행한다.
3. 해당 경로에 저장소를 clone 또는 fetch 한다.
4. 지정 브랜치로 checkout 한다.
5. `uploads`, `logs/nginx` 디렉토리를 생성한다.
6. `.env.deploy`를 서버의 `.env`로 복사한다.
7. `docker compose -f docker-compose.prod.yaml up -d --build`를 실행한다.
권장 디렉토리 구조는 아래와 같다.
```text
/opt/itam/
.env
docker-compose.prod.yaml
Dockerfile.frontend.prod
Dockerfile.backend.prod
map_config.json
uploads/
logs/
nginx/
docker/
nginx/
default.conf
frontend/
default.conf
src/
public/
img/
/opt/itam-backups/
db/
files/
```
현재 구조 기준 배포 관계는 아래와 같다.
```mermaid
flowchart TB
subgraph HOST["Production Host"]
REPO["PROD_DEPLOY_PATH"]
ENV[".env"]
UP["uploads/"]
LOGS["logs/nginx/"]
MAP["map_config.json"]
end
subgraph CTR["Docker Services"]
NGINX["itam-nginx"]
FRONT["itam-frontend"]
BACK["itam-backend"]
end
DB["External MySQL"]
REPO --> NGINX
ENV --> BACK
UP --> BACK
MAP --> BACK
LOGS --> NGINX
REPO --> BAK["PROD_BACKUP_ROOT"]
NGINX --> FRONT
NGINX --> BACK
BACK --> DB
linkStyle default stroke:#d32f2f,stroke-width:2px;
```
---
## 5. 영속 경로 기준
현재 `docker-compose.prod.yaml` 기준으로 운영에서 유지되어야 하는 경로는 아래와 같다.
1. `.env`
2. `uploads/`
3. `map_config.json`
4. `logs/nginx/`
5. `PROD_BACKUP_ROOT`
각 경로의 의미는 아래와 같다.
1. `.env`: backend 런타임 환경변수
2. `uploads/`: 업로드 파일 데이터
3. `map_config.json`: 위치/맵 구성 데이터
4. `logs/nginx/`: reverse proxy 접근 로그 및 에러 로그
5. `PROD_BACKUP_ROOT`: 배포 전 DB dump와 운영 파일 아카이브 저장 위치
운영 기준으로 보면 `uploads/``map_config.json`은 애플리케이션 데이터이고, `.env`는 환경 설정이며, `logs/nginx/`는 운영 추적 데이터다.
즉 서버 운영 시 컨테이너만 다시 띄우면 되는 구조가 아니라, 이 경로들이 유지되는 것을 전제로 배포가 성립한다.
---
## 6. 배포 전 체크리스트
`itam_production_deploy.yml` 실행 전 아래 항목을 먼저 확인한다.
1. 운영 서버에 Docker Engine과 `docker compose`가 설치되어 있어야 한다.
2. 운영 서버의 배포 계정이 Docker 실행 권한을 가져야 한다.
3. 운영 서버에서 `PROD_GIT_URL`로 직접 `git fetch`가 가능해야 한다.
4. 외부 MySQL 접속 정보가 실제 운영망 기준으로 열려 있어야 한다.
5. 운영 서버에 `map_config.json` 초기 파일이 존재해야 한다.
6. 방화벽 또는 보안 장비에서 80 포트 접근 정책이 정리되어 있어야 한다.
권장 확인 명령 예시는 아래와 같다.
```bash
docker --version
docker compose version
git ls-remote <PROD_GIT_URL>
test -f map_config.json
test -d uploads
```
---
## 7. 현재 구조에서의 해석
현재 ITAM CI/CD는 staging 없이도 운영 배포가 가능한 최소 구조로 정리되어 있다.
이 구조의 장점은 아래와 같다.
1. workflow 수가 적어서 관리가 단순하다.
2. 운영 배포에 필요한 변수와 시크릿 범위가 명확하다.
3. staging이 필요해지면 production deploy workflow를 복제해 별도 환경으로 확장하기 쉽다.
즉, 지금 단계에서는 production 기준을 먼저 고정하고, staging은 동일 패턴으로 추후 추가하는 전략이 적절하다.
---
## 8. 다음 권장 작업
현재 문서 기준으로 바로 이어서 할 작업은 아래 순서가 적절하다.
1. `PROD_DEPLOY_PATH` 실제 서버 경로 확정
2. 운영 서버 배포 계정 생성 및 SSH 키 등록
3. `map_config.json`, `uploads/` 초기 데이터 준비
4. production deploy workflow에 smoke check 추가
5. 로그 로테이션과 백업/복구 절차 문서화

View File

@@ -1,64 +1,64 @@
# [보고서] IT 자산 실시간 통합 관리 시스템(RMM) 도입 계획서
## 1. 도입 배경 및 목적
- **현황**: 현재 시스템은 수동 입력 기반의 정적 자산 대장으로 운영되어, 실제 장비의 가동 상태나 장애 여부를 실시간으로 파악하는 데 한계가 있음.
- **목적**: 전산자산(서버, PC)의 실시간 상태 정보를 자동 수집하고 장애 징후를 사전에 탐지하여, 선제적 유지보수 체계를 구축하고 운영 효율성을 극대화함.
## 2. 시스템 주요 기능
### 2.1 실시간 가동 상태 모니터링
- 주요 자원(CPU, Memory, Disk) 사용률 실시간 수집
- 운영체제(OS) 및 주요 시스템 서비스의 정상 작동 여부 확인
- 자산 리스트 내 상태 인디케이터(정상/주의/장애) 표시
### 2.2 원격 제어 및 지원 통합
- **기술적 구현 방식 (원클릭 자동 연결)**: 웹사이트에서 전화번호를 누르면 전화 앱이 켜지거나, 이메일 주소를 누르면 메일 창이 뜨는 것과 동일한 원리인 'URL 프로토콜 핸들러' 기술을 적용함.
- **자동화 프로세스**: 관리자가 화면의 [연결] 버튼을 클릭하면, 시스템이 팀뷰어나 애니데스크에 "A장비로 연결해줘"라는 신호를 직접 보냄.
- **편의성**: 관리자가 대상 장비의 ID나 비밀번호를 직접 복사해서 프로그램에 입력할 필요 없이, 클릭 한 번으로 내 PC에 설치된 원격 소프트웨어가 자동 실행되며 즉시 화면이 연결되도록 구현함.
- **유연한 접속 모드 지원**:
- **무인 접속(Unattended Access)**: 서버 및 공용 장비의 경우, 사전에 등록된 자격 증명을 통해 관리자 승인만으로 즉시 접속하여 야간 또는 긴급 장애에 대응함.
- **사용자 승인 접속(Attended Access)**: 개인용 PC의 경우, 사용자의 화면에 접속 요청 팝업을 띄우고 승인 시에만 화면 공유를 시작하여 개인정보 보호 및 보안 규정을 준수함.
- **보안 및 감사 로그 자동화**:
- 원격 접속이 시작되는 시점에 관리자 정보, 접속 목적, 대상 장비 정보를 DB에 자동 기록함.
- 세션 종료 후 총 작업 시간 및 조치 내역을 입력하도록 유도하여 투명한 유지보수 이력을 관리함.
### 2.3 원격 지원 상세 워크플로우 (Remote Support Workflow)
관리자가 장애를 인지하고 조치를 완료하기까지의 표준 프로세스는 다음과 같습니다.
1. **지원 요청 및 대상 선택**: 관리자가 ITAM 대시보드 또는 리스트에서 장애가 발생한 자산을 선택하고 '원격 지원 시작' 버튼을 클릭함.
2. **접속 모드 자동 판별**:
- **서버(무인)**: 시스템이 저장된 자격 증명을 확인하고 관리자에게 '즉시 연결' 팝업을 띄움.
- **PC(유인)**: 관리자가 '접속 요청' 버튼을 누르면, 대상 PC 화면에 "관리자가 원격 제어를 요청했습니다. 승인하시겠습니까?" 팝업이 전송됨.
3. **세션 초기화 및 로그 생성**: 접속 시도가 승인되면 서버는 즉시 [접속 일시, 관리자 ID, 대상 자산 번호]를 포함한 '세션 로그'를 생성하고 상태를 '진행 중'으로 변경함.
4. **프로토콜 핸들러 실행**: 브라우저가 관리자 PC의 원격 제어 앱(TeamViewer 등)을 자동으로 실행하며, 대상 장비의 ID와 패스워드 정보를 암호화된 인자로 전달하여 즉시 화면이 연결됨.
5. **조치 및 지원 수행**: 관리자가 실시간으로 장비를 제어하여 장애를 복구함.
6. **세션 종료 및 결과 기록**:
- 관리자가 원격 제어 앱을 종료하면, ITAM 웹 화면에 '조치 결과 입력' 창이 활성화됨.
- 관리자가 조치 내용(예: 서비스 재시작, 패치 적용 등)을 입력하고 저장하면 세션 로그가 최종 확정됨.
7. **이력 보관**: 완료된 모든 이력은 '자산 상세 정보 > 유지보수 이력' 탭에서 언제든지 열람 및 보고서 출력이 가능함.
### 3.3 장애 사전 탐지 및 알림
- 설정된 임계치(예: 디스크 잔량 10% 미만) 초과 시 즉시 알림 발송
- 장기 미접속 또는 점검 누락 장비의 실시간 식별
## 3. 운영 프로세스 및 메커니즘
1. **데이터 수집 (Collection)**: 각 자산에 배치된 에이전트가 시스템 정보를 주기적으로 추출함.
2. **분석 및 판별 (Analysis)**: 수집된 데이터를 중앙 서버에서 분석하여 장비의 상태 등급을 판정함.
3. **가시화 (Visualization)**: 통합 관리 대시보드를 통해 전체 자산의 헬스 상태를 실시간으로 출력함.
4. **대응 (Action)**: 장애 감지 시 원격 제어 기능을 호출하여 즉각적인 기술 지원을 수행함.
## 4. 핵심 기술 및 도구
- **에이전트**: PowerShell 기반의 경량 스크립트를 활용하여 별도의 상용 소프트웨어 설치 없이 시스템 정보 수집.
- **백엔드**: Node.js 환경에서 대용량 점검 데이터를 효율적으로 처리하고 데이터베이스화함.
- **프론트엔드**: TypeScript를 활용하여 직관적이고 반응성이 뛰어난 관리자 대시보드 구현.
- **원격 솔루션**: 보안성이 검증된 TeamViewer/AnyDesk의 프로토콜 연동을 통한 안전한 원격 접속 환경 구축.
## 5. 기대 효과
- **가용성 증대**: 장애 발생 전 사전 조치를 통해 시스템 다운타임을 최소화하고 업무 연속성 확보.
- **비용 절감**: 현장 방문 점검 최소화 및 원격 조치를 통한 IT 운영 관리 비용 및 시간 절감.
- **데이터 기반 의무**: 객관적인 성능 지표 및 점검 이력을 바탕으로 정밀한 자산 교체 주기 산정 및 감사 대응.
- **관리 생산성 향상**: 자산 정보 조회와 실시간 관리를 단일 플랫폼으로 통합하여 업무 프로세스 간소화.
## 6. 향후 계획
- 1단계: 서버 자산 중심의 실시간 모니터링 및 대시보드 구축
- 2단계: 전사 PC 대상 원격 지원 및 보안 점검 기능 확대 적용
- 3단계: 누적 데이터를 활용한 성능 분석 및 월간 운영 보고서 자동화
# [보고서] IT 자산 실시간 통합 관리 시스템(RMM) 도입 계획서
## 1. 도입 배경 및 목적
- **현황**: 현재 시스템은 수동 입력 기반의 정적 자산 대장으로 운영되어, 실제 장비의 가동 상태나 장애 여부를 실시간으로 파악하는 데 한계가 있음.
- **목적**: 전산자산(서버, PC)의 실시간 상태 정보를 자동 수집하고 장애 징후를 사전에 탐지하여, 선제적 유지보수 체계를 구축하고 운영 효율성을 극대화함.
## 2. 시스템 주요 기능
### 2.1 실시간 가동 상태 모니터링
- 주요 자원(CPU, Memory, Disk) 사용률 실시간 수집
- 운영체제(OS) 및 주요 시스템 서비스의 정상 작동 여부 확인
- 자산 리스트 내 상태 인디케이터(정상/주의/장애) 표시
### 2.2 원격 제어 및 지원 통합
- **기술적 구현 방식 (원클릭 자동 연결)**: 웹사이트에서 전화번호를 누르면 전화 앱이 켜지거나, 이메일 주소를 누르면 메일 창이 뜨는 것과 동일한 원리인 'URL 프로토콜 핸들러' 기술을 적용함.
- **자동화 프로세스**: 관리자가 화면의 [연결] 버튼을 클릭하면, 시스템이 팀뷰어나 애니데스크에 "A장비로 연결해줘"라는 신호를 직접 보냄.
- **편의성**: 관리자가 대상 장비의 ID나 비밀번호를 직접 복사해서 프로그램에 입력할 필요 없이, 클릭 한 번으로 내 PC에 설치된 원격 소프트웨어가 자동 실행되며 즉시 화면이 연결되도록 구현함.
- **유연한 접속 모드 지원**:
- **무인 접속(Unattended Access)**: 서버 및 공용 장비의 경우, 사전에 등록된 자격 증명을 통해 관리자 승인만으로 즉시 접속하여 야간 또는 긴급 장애에 대응함.
- **사용자 승인 접속(Attended Access)**: 개인용 PC의 경우, 사용자의 화면에 접속 요청 팝업을 띄우고 승인 시에만 화면 공유를 시작하여 개인정보 보호 및 보안 규정을 준수함.
- **보안 및 감사 로그 자동화**:
- 원격 접속이 시작되는 시점에 관리자 정보, 접속 목적, 대상 장비 정보를 DB에 자동 기록함.
- 세션 종료 후 총 작업 시간 및 조치 내역을 입력하도록 유도하여 투명한 유지보수 이력을 관리함.
### 2.3 원격 지원 상세 워크플로우 (Remote Support Workflow)
관리자가 장애를 인지하고 조치를 완료하기까지의 표준 프로세스는 다음과 같습니다.
1. **지원 요청 및 대상 선택**: 관리자가 ITAM 대시보드 또는 리스트에서 장애가 발생한 자산을 선택하고 '원격 지원 시작' 버튼을 클릭함.
2. **접속 모드 자동 판별**:
- **서버(무인)**: 시스템이 저장된 자격 증명을 확인하고 관리자에게 '즉시 연결' 팝업을 띄움.
- **PC(유인)**: 관리자가 '접속 요청' 버튼을 누르면, 대상 PC 화면에 "관리자가 원격 제어를 요청했습니다. 승인하시겠습니까?" 팝업이 전송됨.
3. **세션 초기화 및 로그 생성**: 접속 시도가 승인되면 서버는 즉시 [접속 일시, 관리자 ID, 대상 자산 번호]를 포함한 '세션 로그'를 생성하고 상태를 '진행 중'으로 변경함.
4. **프로토콜 핸들러 실행**: 브라우저가 관리자 PC의 원격 제어 앱(TeamViewer 등)을 자동으로 실행하며, 대상 장비의 ID와 패스워드 정보를 암호화된 인자로 전달하여 즉시 화면이 연결됨.
5. **조치 및 지원 수행**: 관리자가 실시간으로 장비를 제어하여 장애를 복구함.
6. **세션 종료 및 결과 기록**:
- 관리자가 원격 제어 앱을 종료하면, ITAM 웹 화면에 '조치 결과 입력' 창이 활성화됨.
- 관리자가 조치 내용(예: 서비스 재시작, 패치 적용 등)을 입력하고 저장하면 세션 로그가 최종 확정됨.
7. **이력 보관**: 완료된 모든 이력은 '자산 상세 정보 > 유지보수 이력' 탭에서 언제든지 열람 및 보고서 출력이 가능함.
### 3.3 장애 사전 탐지 및 알림
- 설정된 임계치(예: 디스크 잔량 10% 미만) 초과 시 즉시 알림 발송
- 장기 미접속 또는 점검 누락 장비의 실시간 식별
## 3. 운영 프로세스 및 메커니즘
1. **데이터 수집 (Collection)**: 각 자산에 배치된 에이전트가 시스템 정보를 주기적으로 추출함.
2. **분석 및 판별 (Analysis)**: 수집된 데이터를 중앙 서버에서 분석하여 장비의 상태 등급을 판정함.
3. **가시화 (Visualization)**: 통합 관리 대시보드를 통해 전체 자산의 헬스 상태를 실시간으로 출력함.
4. **대응 (Action)**: 장애 감지 시 원격 제어 기능을 호출하여 즉각적인 기술 지원을 수행함.
## 4. 핵심 기술 및 도구
- **에이전트**: PowerShell 기반의 경량 스크립트를 활용하여 별도의 상용 소프트웨어 설치 없이 시스템 정보 수집.
- **백엔드**: Node.js 환경에서 대용량 점검 데이터를 효율적으로 처리하고 데이터베이스화함.
- **프론트엔드**: TypeScript를 활용하여 직관적이고 반응성이 뛰어난 관리자 대시보드 구현.
- **원격 솔루션**: 보안성이 검증된 TeamViewer/AnyDesk의 프로토콜 연동을 통한 안전한 원격 접속 환경 구축.
## 5. 기대 효과
- **가용성 증대**: 장애 발생 전 사전 조치를 통해 시스템 다운타임을 최소화하고 업무 연속성 확보.
- **비용 절감**: 현장 방문 점검 최소화 및 원격 조치를 통한 IT 운영 관리 비용 및 시간 절감.
- **데이터 기반 의무**: 객관적인 성능 지표 및 점검 이력을 바탕으로 정밀한 자산 교체 주기 산정 및 감사 대응.
- **관리 생산성 향상**: 자산 정보 조회와 실시간 관리를 단일 플랫폼으로 통합하여 업무 프로세스 간소화.
## 6. 향후 계획
- 1단계: 서버 자산 중심의 실시간 모니터링 및 대시보드 구축
- 2단계: 전사 PC 대상 원격 지원 및 보안 점검 기능 확대 적용
- 3단계: 누적 데이터를 활용한 성능 분석 및 월간 운영 보고서 자동화

View File

@@ -1,318 +1,318 @@
# 전산자산 원격 점검 및 관리 시스템(RMM) 구축 조사 보고서 (상세판)
## 1. RMM(Remote Monitoring & Management) 개요
RMM(Remote Monitoring & Management)은 서버, 업무용 PC, 노트북 등 IT 자산에 에이전트를 설치하여
중앙 관리 서버에서 상태를 자동 수집하고, 이상 발생 시 경고를 발송하며, 필요 시 원격 접속으로 문제를 해결하는
기업용 IT 운영 관리 체계입니다.
### 주요 기능
- CPU, 메모리, 디스크 상태 모니터링
- Windows 서비스 및 프로세스 상태 점검
- OS 패치 및 백신 상태 확인
- 자동 점검 스케줄링 (1일 1~2회 이상)
- 이상 발생 시 이메일/메신저 알림
- 원격 접속을 통한 장애 조치
- 점검 이력 및 감사 로그 보관
---
## 2. 구축 목표
### 서버 및 서버용 PC
- 하루 1~2회 자동 점검
- 주요 시스템 자원 및 서비스 상태 수집
- 이상 발생 시 관리자 즉시 통보
### 업무용 PC
- 중앙 관리 서버에서 정기 점검
- 패치 및 보안 상태 확인
### 개인 PC
- 사용자가 직접 점검 실행
- 결과를 중앙 서버에 업로드
### 관리자
- 마지막 점검 일시 확인
- 성공/실패 여부 확인
- 미실행 장비 식별
- 필요 시 즉시 원격 접속
---
## 3. 기대 효과
- 장애 조기 탐지 및 사전 예방
- 현장 방문 최소화
- 점검 누락 방지
- 감사 대응 자료 자동 확보
- 자산 운영 현황 실시간 가시화
- 사용자 점검 이행 여부 관리
---
## 4. 전체 시스템 아키텍처
```text
[관리자 웹 포털]
├─ 대시보드
├─ 점검 결과 조회
├─ 원격 접속 버튼
├─ 알림 관리
└─ 사용자 수행 현황
[중앙 관리 서버]
├─ 스케줄러
├─ 데이터 수집 API
├─ 분석 엔진
├─ 알림 시스템
└─ 데이터베이스
[에이전트 설치 대상]
├─ 서버
├─ 서버용 PC
├─ 업무용 PC
└─ 개인 PC
```
---
## 5. 주요 구성 요소
### 5.1 중앙 관리 서버
- 스케줄 실행
- 상태 분석
- 데이터 저장
- 알림 전송
- 웹 서비스 제공
### 5.2 에이전트 프로그램
- PowerShell 또는 Python 기반
- 상태 수집
- 중앙 서버 전송
### 5.3 관리자 웹 대시보드
- 실시간 현황 조회
- 점검 이력 확인
- 원격 접속 실행
### 5.4 원격 접속 솔루션
- TeamViewer Tensor
- AnyDesk
- Microsoft Remote Help
### 5.5 데이터베이스
- SQL Server 또는 PostgreSQL
### 5.6 알림 시스템
- 이메일
- Microsoft Teams
- Slack
---
## 6. 점검 항목
### 공통 점검 항목
- CPU 사용률
- 메모리 사용률
- 디스크 여유 공간
- 네트워크 연결 상태
- 시스템 부팅 시간
- 재부팅 필요 여부
### 서버 추가 항목
- 주요 서비스 실행 여부
- 이벤트 로그 오류
- 백업 결과
- DB 상태
### PC 추가 항목
- 백신 업데이트 여부
- Windows Update 상태
- BitLocker 상태
### 개인 PC
- 기본 시스템 상태
- 점검 수행 여부 및 시간 기록
---
## 7. 운영 프로세스
### 정상 운영
1. 스케줄러가 하루 1~2회 자동 실행
2. 에이전트가 점검 수행
3. 결과를 중앙 서버로 전송
4. 분석 엔진이 정상 여부 판정
5. 대시보드에 저장
### 이상 발생 시
1. 임계치 초과 또는 서비스 중지 감지
2. 관리자에게 알림 발송
3. 관리자가 원격 접속
4. 조치 내용 기록
### 개인 PC
1. 사용자가 '점검 실행' 버튼 클릭
2. 스크립트 수행
3. 결과 업로드
4. 관리자가 이행 여부 확인
---
## 8. 개인 PC 자가 점검 기능
### 사용자 화면
- 점검 실행 버튼
- 결과 요약 표시
- 마지막 점검 시간 표시
### 관리자 확인 항목
- 마지막 점검 일시
- 성공/실패 여부
- 미실행 기간
- 이상 발생 내역
---
## 9. 관리자 대시보드 구성
- 전체 자산 현황
- 정상/경고/장애 통계
- 최근 점검 성공률
- 미점검 장비 목록
- 개인 PC 수행 현황
- 원격 접속 바로가기
- 월간 보고서
---
## 10. 솔루션 비교
| 솔루션 | 특징 | 적합도 |
|------|------|------|
| Microsoft Intune | 엔드포인트 관리 및 규정 준수 | 매우 높음 |
| TeamViewer Tensor | 기업용 원격 접속 및 RMM 연동 | 매우 높음 |
| ManageEngine Endpoint Central | 자산, 패치, 원격 관리 통합 | 매우 높음 |
| Zabbix | 오픈소스 모니터링 | 높음 |
| Splashtop Remote Support | 원격 지원 + RMM | 높음 |
| Power BI | 대시보드 및 보고 | 매우 높음 |
---
## 11. 권장 구축 방안
### 권장 아키텍처
- Microsoft Intune
- TeamViewer Tensor
- PowerShell 자동 점검 스크립트
- Microsoft SQL Server
- Power BI
- Microsoft Teams 알림
### 권장 이유
- Windows 환경과 높은 호환성
- 보안 및 감사 기능 우수
- 사용자 PC까지 통합 관리 가능
- 경영진 보고 자동화 가능
---
## 12. 보안 요구사항
- MFA(다중 인증)
- RBAC(역할 기반 권한 관리)
- TLS 암호화
- 감사 로그 저장
- 승인된 관리자만 원격 접속
- 사용자 동의 기반 개인 PC 점검
---
## 13. 구축 일정 (예시)
| 단계 | 기간 |
|------|------|
| 요구사항 분석 | 2주 |
| 솔루션 선정 | 2주 |
| PoC | 4주 |
| 설계 및 개발 | 6주 |
| 시범 운영 | 4주 |
| 전사 확대 | 4주 |
총 예상 기간: 약 4~6개월
---
## 14. 예상 비용 (예시)
| 항목 | 비용 수준 |
|------|----------|
| Intune 라이선스 | 사용자당 월 과금 |
| TeamViewer Tensor | 동시 세션 기준 |
| 개발 비용 | 중~고 |
| 운영 비용 | 중간 |
---
## 15. 구축 우선순위
### 1단계
- 핵심 서버 모니터링
- 관리자 대시보드
### 2단계
- 원격 접속 통합
- 자동 알림
### 3단계
- 개인 PC 자가 점검
### 4단계
- Power BI 경영 보고
---
## 16. 최종 권장안
> Microsoft Intune + TeamViewer Tensor + PowerShell + SQL Server + Power BI
이 조합은 다음 요구사항을 모두 충족합니다.
- 자동 점검
- 이상 탐지
- 원격 접속
- 사용자 자가 점검
- 이력 관리
- 감사 대응
- 경영진 보고
---
## 17. 공식 출처 및 링크
- Microsoft Intune: https://intune.microsoft.com
- TeamViewer Tensor: https://www.teamviewer.com/en/tensor/
- TeamViewer RMM 소개: https://www.teamviewer.com/en/solutions/use-cases/rmm-remote-monitoring-management/
- ManageEngine Endpoint Central: https://www.manageengine.com/products/endpoint-central/
- Zabbix: https://www.zabbix.com
- Power BI: https://powerbi.microsoft.com
- Microsoft SQL Server: https://www.microsoft.com/sql-server
- Splashtop RMM 설명: https://www.splashtop.com/blog/what-is-remote-monitoring-and-management
---
## 18. 결론
본 시스템은 서버, 업무용 PC, 개인 PC를 통합 관리하여
정기적인 자동 점검과 이상 탐지, 원격 접속, 사용자 자가 점검, 점검 이력 관리까지 지원하는
기업용 IT 운영 플랫폼입니다.
특히 개인 PC의 자가 점검 기능과 관리자 추적 기능을 포함함으로써
규정 준수와 운영 효율성을 동시에 확보할 수 있습니다.
# 전산자산 원격 점검 및 관리 시스템(RMM) 구축 조사 보고서 (상세판)
## 1. RMM(Remote Monitoring & Management) 개요
RMM(Remote Monitoring & Management)은 서버, 업무용 PC, 노트북 등 IT 자산에 에이전트를 설치하여
중앙 관리 서버에서 상태를 자동 수집하고, 이상 발생 시 경고를 발송하며, 필요 시 원격 접속으로 문제를 해결하는
기업용 IT 운영 관리 체계입니다.
### 주요 기능
- CPU, 메모리, 디스크 상태 모니터링
- Windows 서비스 및 프로세스 상태 점검
- OS 패치 및 백신 상태 확인
- 자동 점검 스케줄링 (1일 1~2회 이상)
- 이상 발생 시 이메일/메신저 알림
- 원격 접속을 통한 장애 조치
- 점검 이력 및 감사 로그 보관
---
## 2. 구축 목표
### 서버 및 서버용 PC
- 하루 1~2회 자동 점검
- 주요 시스템 자원 및 서비스 상태 수집
- 이상 발생 시 관리자 즉시 통보
### 업무용 PC
- 중앙 관리 서버에서 정기 점검
- 패치 및 보안 상태 확인
### 개인 PC
- 사용자가 직접 점검 실행
- 결과를 중앙 서버에 업로드
### 관리자
- 마지막 점검 일시 확인
- 성공/실패 여부 확인
- 미실행 장비 식별
- 필요 시 즉시 원격 접속
---
## 3. 기대 효과
- 장애 조기 탐지 및 사전 예방
- 현장 방문 최소화
- 점검 누락 방지
- 감사 대응 자료 자동 확보
- 자산 운영 현황 실시간 가시화
- 사용자 점검 이행 여부 관리
---
## 4. 전체 시스템 아키텍처
```text
[관리자 웹 포털]
├─ 대시보드
├─ 점검 결과 조회
├─ 원격 접속 버튼
├─ 알림 관리
└─ 사용자 수행 현황
[중앙 관리 서버]
├─ 스케줄러
├─ 데이터 수집 API
├─ 분석 엔진
├─ 알림 시스템
└─ 데이터베이스
[에이전트 설치 대상]
├─ 서버
├─ 서버용 PC
├─ 업무용 PC
└─ 개인 PC
```
---
## 5. 주요 구성 요소
### 5.1 중앙 관리 서버
- 스케줄 실행
- 상태 분석
- 데이터 저장
- 알림 전송
- 웹 서비스 제공
### 5.2 에이전트 프로그램
- PowerShell 또는 Python 기반
- 상태 수집
- 중앙 서버 전송
### 5.3 관리자 웹 대시보드
- 실시간 현황 조회
- 점검 이력 확인
- 원격 접속 실행
### 5.4 원격 접속 솔루션
- TeamViewer Tensor
- AnyDesk
- Microsoft Remote Help
### 5.5 데이터베이스
- SQL Server 또는 PostgreSQL
### 5.6 알림 시스템
- 이메일
- Microsoft Teams
- Slack
---
## 6. 점검 항목
### 공통 점검 항목
- CPU 사용률
- 메모리 사용률
- 디스크 여유 공간
- 네트워크 연결 상태
- 시스템 부팅 시간
- 재부팅 필요 여부
### 서버 추가 항목
- 주요 서비스 실행 여부
- 이벤트 로그 오류
- 백업 결과
- DB 상태
### PC 추가 항목
- 백신 업데이트 여부
- Windows Update 상태
- BitLocker 상태
### 개인 PC
- 기본 시스템 상태
- 점검 수행 여부 및 시간 기록
---
## 7. 운영 프로세스
### 정상 운영
1. 스케줄러가 하루 1~2회 자동 실행
2. 에이전트가 점검 수행
3. 결과를 중앙 서버로 전송
4. 분석 엔진이 정상 여부 판정
5. 대시보드에 저장
### 이상 발생 시
1. 임계치 초과 또는 서비스 중지 감지
2. 관리자에게 알림 발송
3. 관리자가 원격 접속
4. 조치 내용 기록
### 개인 PC
1. 사용자가 '점검 실행' 버튼 클릭
2. 스크립트 수행
3. 결과 업로드
4. 관리자가 이행 여부 확인
---
## 8. 개인 PC 자가 점검 기능
### 사용자 화면
- 점검 실행 버튼
- 결과 요약 표시
- 마지막 점검 시간 표시
### 관리자 확인 항목
- 마지막 점검 일시
- 성공/실패 여부
- 미실행 기간
- 이상 발생 내역
---
## 9. 관리자 대시보드 구성
- 전체 자산 현황
- 정상/경고/장애 통계
- 최근 점검 성공률
- 미점검 장비 목록
- 개인 PC 수행 현황
- 원격 접속 바로가기
- 월간 보고서
---
## 10. 솔루션 비교
| 솔루션 | 특징 | 적합도 |
|------|------|------|
| Microsoft Intune | 엔드포인트 관리 및 규정 준수 | 매우 높음 |
| TeamViewer Tensor | 기업용 원격 접속 및 RMM 연동 | 매우 높음 |
| ManageEngine Endpoint Central | 자산, 패치, 원격 관리 통합 | 매우 높음 |
| Zabbix | 오픈소스 모니터링 | 높음 |
| Splashtop Remote Support | 원격 지원 + RMM | 높음 |
| Power BI | 대시보드 및 보고 | 매우 높음 |
---
## 11. 권장 구축 방안
### 권장 아키텍처
- Microsoft Intune
- TeamViewer Tensor
- PowerShell 자동 점검 스크립트
- Microsoft SQL Server
- Power BI
- Microsoft Teams 알림
### 권장 이유
- Windows 환경과 높은 호환성
- 보안 및 감사 기능 우수
- 사용자 PC까지 통합 관리 가능
- 경영진 보고 자동화 가능
---
## 12. 보안 요구사항
- MFA(다중 인증)
- RBAC(역할 기반 권한 관리)
- TLS 암호화
- 감사 로그 저장
- 승인된 관리자만 원격 접속
- 사용자 동의 기반 개인 PC 점검
---
## 13. 구축 일정 (예시)
| 단계 | 기간 |
|------|------|
| 요구사항 분석 | 2주 |
| 솔루션 선정 | 2주 |
| PoC | 4주 |
| 설계 및 개발 | 6주 |
| 시범 운영 | 4주 |
| 전사 확대 | 4주 |
총 예상 기간: 약 4~6개월
---
## 14. 예상 비용 (예시)
| 항목 | 비용 수준 |
|------|----------|
| Intune 라이선스 | 사용자당 월 과금 |
| TeamViewer Tensor | 동시 세션 기준 |
| 개발 비용 | 중~고 |
| 운영 비용 | 중간 |
---
## 15. 구축 우선순위
### 1단계
- 핵심 서버 모니터링
- 관리자 대시보드
### 2단계
- 원격 접속 통합
- 자동 알림
### 3단계
- 개인 PC 자가 점검
### 4단계
- Power BI 경영 보고
---
## 16. 최종 권장안
> Microsoft Intune + TeamViewer Tensor + PowerShell + SQL Server + Power BI
이 조합은 다음 요구사항을 모두 충족합니다.
- 자동 점검
- 이상 탐지
- 원격 접속
- 사용자 자가 점검
- 이력 관리
- 감사 대응
- 경영진 보고
---
## 17. 공식 출처 및 링크
- Microsoft Intune: https://intune.microsoft.com
- TeamViewer Tensor: https://www.teamviewer.com/en/tensor/
- TeamViewer RMM 소개: https://www.teamviewer.com/en/solutions/use-cases/rmm-remote-monitoring-management/
- ManageEngine Endpoint Central: https://www.manageengine.com/products/endpoint-central/
- Zabbix: https://www.zabbix.com
- Power BI: https://powerbi.microsoft.com
- Microsoft SQL Server: https://www.microsoft.com/sql-server
- Splashtop RMM 설명: https://www.splashtop.com/blog/what-is-remote-monitoring-and-management
---
## 18. 결론
본 시스템은 서버, 업무용 PC, 개인 PC를 통합 관리하여
정기적인 자동 점검과 이상 탐지, 원격 접속, 사용자 자가 점검, 점검 이력 관리까지 지원하는
기업용 IT 운영 플랫폼입니다.
특히 개인 PC의 자가 점검 기능과 관리자 추적 기능을 포함함으로써
규정 준수와 운영 효율성을 동시에 확보할 수 있습니다.

View File

@@ -1,379 +1,379 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=device-width, initial-scale=1.0">
<title>PC 사양 대시보드 시각화 개선 기획서</title>
<!-- Google Fonts: Pretendard 대체용 Outfit & Noto Sans KR -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;900&family=Outfit:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<style>
:root {
--primary: #4F46E5;
--primary-light: #EEF2FF;
--secondary: #10B981;
--secondary-light: #D1FAE5;
--text-dark: #0F172A;
--text-muted: #64748B;
--border-color: #E2E8F0;
--bg-light: #F8FAFC;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Outfit', 'Noto Sans KR', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
color: var(--text-dark);
background-color: #FFFFFF;
line-height: 1.6;
letter-spacing: -0.02em;
padding: 2rem 1.5rem;
}
.container {
max-width: 900px;
margin: 0 auto;
}
/* Header Styling */
header {
border-bottom: 2px solid var(--text-dark);
padding-bottom: 1.5rem;
margin-bottom: 2.5rem;
}
.doc-category {
font-size: 0.85rem;
font-weight: 700;
color: var(--primary);
text-transform: uppercase;
letter-spacing: 0.1em;
margin-bottom: 0.5rem;
}
h1 {
font-size: 2.25rem;
font-weight: 900;
color: var(--text-dark);
line-height: 1.2;
}
.meta-info {
display: flex;
gap: 1.5rem;
margin-top: 1rem;
font-size: 0.85rem;
color: var(--text-muted);
}
.meta-info span strong {
color: var(--text-dark);
}
/* Section Styling */
section {
margin-bottom: 3rem;
}
h2 {
font-size: 1.4rem;
font-weight: 800;
color: var(--text-dark);
border-bottom: 1px solid var(--border-color);
padding-bottom: 0.5rem;
margin-bottom: 1.25rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
h2::before {
content: '';
display: inline-block;
width: 4px;
height: 18px;
background-color: var(--primary);
border-radius: 2px;
}
p {
font-size: 1rem;
color: #334155;
margin-bottom: 1rem;
text-align: justify;
}
/* List & Card Styling */
ul {
list-style-position: inside;
margin-left: 0.5rem;
margin-bottom: 1.5rem;
}
li {
margin-bottom: 0.5rem;
color: #334155;
}
.spec-card {
background-color: var(--bg-light);
border-left: 4px solid var(--primary);
border-radius: 4px;
padding: 1.25rem;
margin: 1.5rem 0;
}
.spec-card h3 {
font-size: 1.05rem;
font-weight: 700;
margin-bottom: 0.5rem;
color: var(--text-dark);
}
/* Table Styling */
.table-container {
width: 100%;
overflow-x: auto;
margin: 1.5rem 0;
border: 1px solid var(--border-color);
border-radius: 8px;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
text-align: left;
}
th {
background-color: var(--bg-light);
font-weight: 700;
color: var(--text-dark);
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-color);
}
td {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-color);
color: #334155;
}
tr:last-child td {
border-bottom: none;
}
.badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 700;
text-align: center;
}
.badge-primary {
color: var(--primary);
background-color: var(--primary-light);
}
.badge-secondary {
color: var(--secondary);
background-color: var(--secondary-light);
}
/* Highlight box */
.note-box {
background-color: #FFFBEB;
border: 1px solid #FCD34D;
border-radius: 6px;
padding: 1rem;
margin: 1.5rem 0;
font-size: 0.95rem;
color: #92400E;
}
.note-box strong {
color: #78350F;
}
footer {
border-top: 1px solid var(--border-color);
padding-top: 1.5rem;
margin-top: 4rem;
text-align: center;
font-size: 0.8rem;
color: var(--text-muted);
}
</style>
</head>
<body>
<div class="container">
<header>
<div class="doc-category">기획 명세서 / Product Specification</div>
<h1>PC 사양 대시보드 시각화 개선 기획서</h1>
<div class="meta-info">
<span>기획부서: <strong>IT자산관리 태스크포스(TF)</strong></span>
<span>최종 수정일: <strong>2026. 05. 28</strong></span>
<span>문서 버전: <strong>v1.1 (실제 엑셀 데이터 반영)</strong></span>
</div>
</header>
<!-- 1. 개요 및 목적 -->
<section>
<h2>기획 개요 및 목적</h2>
<p>본 기획은 법인별/직무별 PC 자산 사양 현황의 시각적 피로도를 낮추고 데이터 전달력을 고도화하기 위한 개선 작업을 목적으로 합니다. 기존 대시보드 레이아웃의 비정형 비율을 재정립하고, 평균 점수와 권장 점수의 비교 방식을 '다중 막대' 형태에서 <strong>'혼합형(막대 + 꺾은선) 차트'</strong>로 변경하여 대조 직관성을 극대화합니다.</p>
</section>
<!-- 2. 주요 개선 사항 -->
<section>
<h2>주요 개선 내역</h2>
<div class="spec-card">
<h3>① 가족사별 PC 사양 현황 레이아웃 고도화</h3>
<ul>
<li><strong>가로 비율 정밀 제어 (1:2)</strong>: 평균 점수 리스트와 막대그래프의 가로 폭 비율을 <code>1 : 2</code>로 엄격하게 고정하여 반응형 레이아웃 환경에서도 깨짐 없는 균형미를 제공합니다.</li>
<li><strong>가독성 개선</strong>: 가족사 텍스트 크기를 <code>0.95rem</code>, 평균 사양 점수 텍스트 크기를 <code>1.05rem</code>으로 키우고 세로 행간 여백을 확보해 가시성을 향상시켰습니다.</li>
</ul>
</div>
<div class="spec-card">
<h3>② 직무별 PC 사양 평균 및 권장 점수 혼합 시각화</h3>
<ul>
<li><strong>혼합형 차트(Mixed Chart) 구성</strong>: 직무별 PC 사양 평균 점수는 <span class="badge badge-primary">막대(Bar)</span> 그래프로, 권장 PC 사양 점수는 그 위를 관통하는 <span class="badge badge-secondary">선(Line)</span> 그래프로 표현합니다.</li>
<li><strong>레이어 정렬 우선순위 적용</strong>: 차트 정의 시 권장 점수선(Line)이 평균 점수막대(Bar) 뒤에 가리지 않고 항상 맨 앞에 위치하도록 렌더링 우선순위(<code>order</code> 속성)를 명확히 지정합니다.</li>
<li><strong>정렬 원복</strong>: 수동 정렬을 지양하고, 직무별 실제 평균 PC 사양 점수가 높은 순으로 자동 내림차순 정렬되도록 하여 가장 자연스러운 시각화를 구축합니다.</li>
</ul>
</div>
</section>
<!-- 3. 데이터 정의 -->
<section>
<h2>직무별 평균 및 권장 사양 점수 스펙</h2>
<p>실제 PC 자산 데이터(CPU 및 RAM 점수 연산 결과)와 관리자의 권장 기준선이 아래 명시된 대소 조건 관계를 완벽히 만족하도록 더미 데이터 및 초기 권장 스펙 기준을 재정의했습니다.</p>
<div class="note-box">
<strong>대소 관계 정렬 순서 (실제 평균 점수 기준):</strong><br>
AI 개발자 ➔ 편집 디자이너 ➔ 3D 디자이너 ➔ UXUI 디자이너 ➔ 3D 개발자 ➔ 프로그램 개발자 ➔ BIM모델러 ➔ 엔지니어 ➔ 웹 개발자 ➔ 기획자 순서로 실제 평균 점수 순위가 자동 정렬되어 시각화됩니다. (감리원은 실제 자산 데이터 부재로 비교군에서 제외)
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>정렬 순위</th>
<th>직무명</th>
<th>실제 평균 사양 점수 (Bar)</th>
<th>기본 권장 사양 점수 (기준)</th>
<th>대소 관계 평가</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td><strong>AI 개발자</strong></td>
<td>88.0 점</td>
<td>95 점</td>
<td><span class="badge badge-secondary">미달 (교체 요망)</span></td>
</tr>
<tr>
<td>2</td>
<td><strong>편집 디자이너</strong></td>
<td>80.2 점</td>
<td>75 점</td>
<td><span class="badge badge-secondary">권장 스펙 충족</span></td>
</tr>
<tr>
<td>3</td>
<td><strong>3D 디자이너</strong></td>
<td>78.4 점</td>
<td>90 점</td>
<td><span class="badge badge-secondary">미달 (교체 요망)</span></td>
</tr>
<tr>
<td>4</td>
<td><strong>UXUI 디자이너</strong></td>
<td>72.7 점</td>
<td>70 점</td>
<td><span class="badge badge-secondary">권장 스펙 충족</span></td>
</tr>
<tr>
<td>5</td>
<td><strong>3D 개발자</strong></td>
<td>67.8 점</td>
<td>90 점</td>
<td><span class="badge badge-secondary">미달 (교체 요망)</span></td>
</tr>
<tr>
<td>6</td>
<td><strong>프로그램 개발자</strong></td>
<td>67.3 점</td>
<td>80 점</td>
<td><span class="badge badge-secondary">미달 (교체 요망)</span></td>
</tr>
<tr>
<td>7</td>
<td><strong>BIM모델러</strong></td>
<td>62.1 점</td>
<td>75 점</td>
<td><span class="badge badge-secondary">미달 (교체 요망)</span></td>
</tr>
<tr>
<td>8</td>
<td><strong>엔지니어</strong></td>
<td>42.9 점</td>
<td>60 점</td>
<td><span class="badge badge-secondary">미달 (교체 요망)</span></td>
</tr>
<tr>
<td>9</td>
<td><strong>웹 개발자</strong></td>
<td>39.2 점</td>
<td>75 점</td>
<td><span class="badge badge-secondary">미달 (교체 요망)</span></td>
</tr>
<tr>
<td>10</td>
<td><strong>기획자</strong></td>
<td>38.6 점</td>
<td>50 점</td>
<td><span class="badge badge-secondary">미달 (교체 요망)</span></td>
</tr>
<tr>
<td>11</td>
<td><strong>감리원</strong></td>
<td>-</td>
<td>40.0 점</td>
<td><span class="badge badge-secondary">데이터 없음</span></td>
</tr>
</tbody>
</table>
</div>
</section>
<!-- 4. 기술 구현 세부사항 -->
<section>
<h2>기술 구현 세부 사양</h2>
<div class="spec-card" style="border-left-color: var(--secondary);">
<h3>차트 렌더링 옵션 (Chart.js v4.x+)</h3>
<p>평균 PC 사양 점수를 보여주는 데이터셋과 권장 PC 사양 점수를 보여주는 데이터셋을 하나의 Canvas 엘리먼트에 그리되, 레이어 겹침과 시인성을 확보하기 위해 다음 세부 옵션을 바인딩합니다.</p>
<ul>
<li><strong>Average Dataset</strong>: <code>type: 'bar', order: 2, backgroundColor: '#6366F1'</code></li>
<li><strong>Recommended Dataset</strong>: <code>type: 'line', order: 1, borderColor: '#10B981', borderWidth: 3, pointRadius: 4, fill: false</code></li>
<li><strong>정렬 로직</strong>: <code>Object.keys(jobScores).sort((a, b) => jobScores[b].avg - jobScores[a].avg)</code></li>
</ul>
</div>
</section>
<footer>
<p>&copy; 2026 HM ITAM Systems. All rights reserved.</p>
</footer>
</div>
</body>
</html>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=device-width, initial-scale=1.0">
<title>PC 사양 대시보드 시각화 개선 기획서</title>
<!-- Google Fonts: Pretendard 대체용 Outfit & Noto Sans KR -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;900&family=Outfit:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<style>
:root {
--primary: #4F46E5;
--primary-light: #EEF2FF;
--secondary: #10B981;
--secondary-light: #D1FAE5;
--text-dark: #0F172A;
--text-muted: #64748B;
--border-color: #E2E8F0;
--bg-light: #F8FAFC;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Outfit', 'Noto Sans KR', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
color: var(--text-dark);
background-color: #FFFFFF;
line-height: 1.6;
letter-spacing: -0.02em;
padding: 2rem 1.5rem;
}
.container {
max-width: 900px;
margin: 0 auto;
}
/* Header Styling */
header {
border-bottom: 2px solid var(--text-dark);
padding-bottom: 1.5rem;
margin-bottom: 2.5rem;
}
.doc-category {
font-size: 0.85rem;
font-weight: 700;
color: var(--primary);
text-transform: uppercase;
letter-spacing: 0.1em;
margin-bottom: 0.5rem;
}
h1 {
font-size: 2.25rem;
font-weight: 900;
color: var(--text-dark);
line-height: 1.2;
}
.meta-info {
display: flex;
gap: 1.5rem;
margin-top: 1rem;
font-size: 0.85rem;
color: var(--text-muted);
}
.meta-info span strong {
color: var(--text-dark);
}
/* Section Styling */
section {
margin-bottom: 3rem;
}
h2 {
font-size: 1.4rem;
font-weight: 800;
color: var(--text-dark);
border-bottom: 1px solid var(--border-color);
padding-bottom: 0.5rem;
margin-bottom: 1.25rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
h2::before {
content: '';
display: inline-block;
width: 4px;
height: 18px;
background-color: var(--primary);
border-radius: 2px;
}
p {
font-size: 1rem;
color: #334155;
margin-bottom: 1rem;
text-align: justify;
}
/* List & Card Styling */
ul {
list-style-position: inside;
margin-left: 0.5rem;
margin-bottom: 1.5rem;
}
li {
margin-bottom: 0.5rem;
color: #334155;
}
.spec-card {
background-color: var(--bg-light);
border-left: 4px solid var(--primary);
border-radius: 4px;
padding: 1.25rem;
margin: 1.5rem 0;
}
.spec-card h3 {
font-size: 1.05rem;
font-weight: 700;
margin-bottom: 0.5rem;
color: var(--text-dark);
}
/* Table Styling */
.table-container {
width: 100%;
overflow-x: auto;
margin: 1.5rem 0;
border: 1px solid var(--border-color);
border-radius: 8px;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
text-align: left;
}
th {
background-color: var(--bg-light);
font-weight: 700;
color: var(--text-dark);
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-color);
}
td {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-color);
color: #334155;
}
tr:last-child td {
border-bottom: none;
}
.badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 700;
text-align: center;
}
.badge-primary {
color: var(--primary);
background-color: var(--primary-light);
}
.badge-secondary {
color: var(--secondary);
background-color: var(--secondary-light);
}
/* Highlight box */
.note-box {
background-color: #FFFBEB;
border: 1px solid #FCD34D;
border-radius: 6px;
padding: 1rem;
margin: 1.5rem 0;
font-size: 0.95rem;
color: #92400E;
}
.note-box strong {
color: #78350F;
}
footer {
border-top: 1px solid var(--border-color);
padding-top: 1.5rem;
margin-top: 4rem;
text-align: center;
font-size: 0.8rem;
color: var(--text-muted);
}
</style>
</head>
<body>
<div class="container">
<header>
<div class="doc-category">기획 명세서 / Product Specification</div>
<h1>PC 사양 대시보드 시각화 개선 기획서</h1>
<div class="meta-info">
<span>기획부서: <strong>IT자산관리 태스크포스(TF)</strong></span>
<span>최종 수정일: <strong>2026. 05. 28</strong></span>
<span>문서 버전: <strong>v1.1 (실제 엑셀 데이터 반영)</strong></span>
</div>
</header>
<!-- 1. 개요 및 목적 -->
<section>
<h2>기획 개요 및 목적</h2>
<p>본 기획은 법인별/직무별 PC 자산 사양 현황의 시각적 피로도를 낮추고 데이터 전달력을 고도화하기 위한 개선 작업을 목적으로 합니다. 기존 대시보드 레이아웃의 비정형 비율을 재정립하고, 평균 점수와 권장 점수의 비교 방식을 '다중 막대' 형태에서 <strong>'혼합형(막대 + 꺾은선) 차트'</strong>로 변경하여 대조 직관성을 극대화합니다.</p>
</section>
<!-- 2. 주요 개선 사항 -->
<section>
<h2>주요 개선 내역</h2>
<div class="spec-card">
<h3>① 가족사별 PC 사양 현황 레이아웃 고도화</h3>
<ul>
<li><strong>가로 비율 정밀 제어 (1:2)</strong>: 평균 점수 리스트와 막대그래프의 가로 폭 비율을 <code>1 : 2</code>로 엄격하게 고정하여 반응형 레이아웃 환경에서도 깨짐 없는 균형미를 제공합니다.</li>
<li><strong>가독성 개선</strong>: 가족사 텍스트 크기를 <code>0.95rem</code>, 평균 사양 점수 텍스트 크기를 <code>1.05rem</code>으로 키우고 세로 행간 여백을 확보해 가시성을 향상시켰습니다.</li>
</ul>
</div>
<div class="spec-card">
<h3>② 직무별 PC 사양 평균 및 권장 점수 혼합 시각화</h3>
<ul>
<li><strong>혼합형 차트(Mixed Chart) 구성</strong>: 직무별 PC 사양 평균 점수는 <span class="badge badge-primary">막대(Bar)</span> 그래프로, 권장 PC 사양 점수는 그 위를 관통하는 <span class="badge badge-secondary">선(Line)</span> 그래프로 표현합니다.</li>
<li><strong>레이어 정렬 우선순위 적용</strong>: 차트 정의 시 권장 점수선(Line)이 평균 점수막대(Bar) 뒤에 가리지 않고 항상 맨 앞에 위치하도록 렌더링 우선순위(<code>order</code> 속성)를 명확히 지정합니다.</li>
<li><strong>정렬 원복</strong>: 수동 정렬을 지양하고, 직무별 실제 평균 PC 사양 점수가 높은 순으로 자동 내림차순 정렬되도록 하여 가장 자연스러운 시각화를 구축합니다.</li>
</ul>
</div>
</section>
<!-- 3. 데이터 정의 -->
<section>
<h2>직무별 평균 및 권장 사양 점수 스펙</h2>
<p>실제 PC 자산 데이터(CPU 및 RAM 점수 연산 결과)와 관리자의 권장 기준선이 아래 명시된 대소 조건 관계를 완벽히 만족하도록 더미 데이터 및 초기 권장 스펙 기준을 재정의했습니다.</p>
<div class="note-box">
<strong>대소 관계 정렬 순서 (실제 평균 점수 기준):</strong><br>
AI 개발자 ➔ 편집 디자이너 ➔ 3D 디자이너 ➔ UXUI 디자이너 ➔ 3D 개발자 ➔ 프로그램 개발자 ➔ BIM모델러 ➔ 엔지니어 ➔ 웹 개발자 ➔ 기획자 순서로 실제 평균 점수 순위가 자동 정렬되어 시각화됩니다. (감리원은 실제 자산 데이터 부재로 비교군에서 제외)
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>정렬 순위</th>
<th>직무명</th>
<th>실제 평균 사양 점수 (Bar)</th>
<th>기본 권장 사양 점수 (기준)</th>
<th>대소 관계 평가</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td><strong>AI 개발자</strong></td>
<td>88.0 점</td>
<td>95 점</td>
<td><span class="badge badge-secondary">미달 (교체 요망)</span></td>
</tr>
<tr>
<td>2</td>
<td><strong>편집 디자이너</strong></td>
<td>80.2 점</td>
<td>75 점</td>
<td><span class="badge badge-secondary">권장 스펙 충족</span></td>
</tr>
<tr>
<td>3</td>
<td><strong>3D 디자이너</strong></td>
<td>78.4 점</td>
<td>90 점</td>
<td><span class="badge badge-secondary">미달 (교체 요망)</span></td>
</tr>
<tr>
<td>4</td>
<td><strong>UXUI 디자이너</strong></td>
<td>72.7 점</td>
<td>70 점</td>
<td><span class="badge badge-secondary">권장 스펙 충족</span></td>
</tr>
<tr>
<td>5</td>
<td><strong>3D 개발자</strong></td>
<td>67.8 점</td>
<td>90 점</td>
<td><span class="badge badge-secondary">미달 (교체 요망)</span></td>
</tr>
<tr>
<td>6</td>
<td><strong>프로그램 개발자</strong></td>
<td>67.3 점</td>
<td>80 점</td>
<td><span class="badge badge-secondary">미달 (교체 요망)</span></td>
</tr>
<tr>
<td>7</td>
<td><strong>BIM모델러</strong></td>
<td>62.1 점</td>
<td>75 점</td>
<td><span class="badge badge-secondary">미달 (교체 요망)</span></td>
</tr>
<tr>
<td>8</td>
<td><strong>엔지니어</strong></td>
<td>42.9 점</td>
<td>60 점</td>
<td><span class="badge badge-secondary">미달 (교체 요망)</span></td>
</tr>
<tr>
<td>9</td>
<td><strong>웹 개발자</strong></td>
<td>39.2 점</td>
<td>75 점</td>
<td><span class="badge badge-secondary">미달 (교체 요망)</span></td>
</tr>
<tr>
<td>10</td>
<td><strong>기획자</strong></td>
<td>38.6 점</td>
<td>50 점</td>
<td><span class="badge badge-secondary">미달 (교체 요망)</span></td>
</tr>
<tr>
<td>11</td>
<td><strong>감리원</strong></td>
<td>-</td>
<td>40.0 점</td>
<td><span class="badge badge-secondary">데이터 없음</span></td>
</tr>
</tbody>
</table>
</div>
</section>
<!-- 4. 기술 구현 세부사항 -->
<section>
<h2>기술 구현 세부 사양</h2>
<div class="spec-card" style="border-left-color: var(--secondary);">
<h3>차트 렌더링 옵션 (Chart.js v4.x+)</h3>
<p>평균 PC 사양 점수를 보여주는 데이터셋과 권장 PC 사양 점수를 보여주는 데이터셋을 하나의 Canvas 엘리먼트에 그리되, 레이어 겹침과 시인성을 확보하기 위해 다음 세부 옵션을 바인딩합니다.</p>
<ul>
<li><strong>Average Dataset</strong>: <code>type: 'bar', order: 2, backgroundColor: '#6366F1'</code></li>
<li><strong>Recommended Dataset</strong>: <code>type: 'line', order: 1, borderColor: '#10B981', borderWidth: 3, pointRadius: 4, fill: false</code></li>
<li><strong>정렬 로직</strong>: <code>Object.keys(jobScores).sort((a, b) => jobScores[b].avg - jobScores[a].avg)</code></li>
</ul>
</div>
</section>
<footer>
<p>&copy; 2026 HM ITAM Systems. All rights reserved.</p>
</footer>
</div>
</body>
</html>

View File

@@ -1,60 +1,60 @@
# 자산 이력 누적 관리 시스템 (Cumulative Asset History System) 구현 계획
본 문서는 자산의 라이프사이클(조직, 사용자, 용도, 상태 변동)을 체계적으로 추적하고 누적 관리하기 위한 기술적 설계 및 단계별 구현 계획을 담고 있습니다.
## 1. 목적
- 자산 정보 수정 시 중요 변경 사항을 자동으로 감지하여 이력(Log)화
- 과거부터 현재까지의 변동 사항을 타임라인 형태로 시각화하여 자산 흐름 파악
- 데이터 정합성을 위해 서버 측에서 변경 전/후 스냅샷 비교 방식 채택
## 2. 관리 대상 이력 (Watch Fields)
다음 항목의 변경이 발생할 경우 이력을 자동 생성합니다.
1. **조직 변동**: `current_dept` (현 사용조직) ↔ `previous_dept` 업데이트 포함
2. **사용자 변동**: `user_current` (현 사용자) ↔ `previous_user` 업데이트 포함
3. **용도 변경**: `asset_type`, `current_role` (예: 개인PC -> 공용PC)
4. **상태 변경**: `hw_status` (예: 운영 -> 수리, 재고 -> 폐기 등)
## 3. 기술 설계 (Technical Design)
### A. 데이터베이스 (DB)
- **대상 테이블**: `asset_history`
- **컬럼 구조 활용 및 보완**:
- `asset_id`: 대상 자산 식별자
- `event_type`: 변경 유형 (DEPT_CHANGE, USER_CHANGE, ROLE_CHANGE, STATUS_CHANGE)
- `details`: "상태 변경: 운영 -> 수리" 와 같이 읽기 쉬운 문자열 저장
- `cost`: 관련 비용 발생 시 기록 (수리비 등)
- `log_user`: 변경을 수행한 작업자
- `log_date`: 변경 발생 일시
### B. 백엔드 (Server-side Logic)
- **위치**: `server.js``POST /api/asset/:category/save` 엔드포인트
- **동작 흐름**:
1. **Snapshot**: 인서트/업데이트 수행 전, 기존 DB의 데이터를 `SELECT`하여 메모리에 저장.
2. **Comparison**: 요청된 신규 데이터와 기존 데이터를 필드별로 대조.
3. **Auto-logging**: 변경점이 발견되면 `asset_history` 테이블에 즉시 인서트.
4. **Transaction**: 모든 로그 생성이 자산 저장과 하나의 트랜잭션으로 묶여야 함.
### C. 프론트엔드 (UI/UX)
- **위치**: `HWModal.ts` 우측 `modal-history-area`
- **개선 사항**:
- `renderHistory()` 함수를 고도화하여 이벤트 타입별 아이콘/컬러 적용.
- "이전 값 ➔ 이후 값" 형태의 직관적인 레이아웃 도입.
- 스크롤을 통한 무제한 누적 이력 조회 지원.
## 4. 단계별 구현 로직
### 1단계: 서버 로직 고도화
- `server.js`에 비교 함수(`compareAndLog`) 구현.
- 각 자산 카테고리별 저장 로직에 비교 로직 삽입.
### 2단계: DB 데이터 마이그레이션 (필요시)
- 기존 자산의 `current_dept` 등을 `previous_dept`로 밀어내는 로직 점검.
### 3단계: UI 타임라인 렌더링 개선
- `modal.css`에 이력 전용 스타일(이벤트 뱃지 등) 추가.
- `HWModal.ts`에서 최신 로그를 실시간으로 다시 불러오는 로직 확인.
## 5. 검증 계획
- **자동 감지 테스트**: 상태 변경 후 저장 시 우측 이력에 즉시 한 줄이 추가되는지 확인.
- **다중 변경 테스트**: 조직과 사용자를 동시에 변경했을 때 두 개의 로그가 생성되는지 확인.
- **데이터 무결성**: 수정을 취소하거나 저장 실패 시 로그가 남지 않는지(Transaction) 확인.
# 자산 이력 누적 관리 시스템 (Cumulative Asset History System) 구현 계획
본 문서는 자산의 라이프사이클(조직, 사용자, 용도, 상태 변동)을 체계적으로 추적하고 누적 관리하기 위한 기술적 설계 및 단계별 구현 계획을 담고 있습니다.
## 1. 목적
- 자산 정보 수정 시 중요 변경 사항을 자동으로 감지하여 이력(Log)화
- 과거부터 현재까지의 변동 사항을 타임라인 형태로 시각화하여 자산 흐름 파악
- 데이터 정합성을 위해 서버 측에서 변경 전/후 스냅샷 비교 방식 채택
## 2. 관리 대상 이력 (Watch Fields)
다음 항목의 변경이 발생할 경우 이력을 자동 생성합니다.
1. **조직 변동**: `current_dept` (현 사용조직) ↔ `previous_dept` 업데이트 포함
2. **사용자 변동**: `user_current` (현 사용자) ↔ `previous_user` 업데이트 포함
3. **용도 변경**: `asset_type`, `current_role` (예: 개인PC -> 공용PC)
4. **상태 변경**: `hw_status` (예: 운영 -> 수리, 재고 -> 폐기 등)
## 3. 기술 설계 (Technical Design)
### A. 데이터베이스 (DB)
- **대상 테이블**: `asset_history`
- **컬럼 구조 활용 및 보완**:
- `asset_id`: 대상 자산 식별자
- `event_type`: 변경 유형 (DEPT_CHANGE, USER_CHANGE, ROLE_CHANGE, STATUS_CHANGE)
- `details`: "상태 변경: 운영 -> 수리" 와 같이 읽기 쉬운 문자열 저장
- `cost`: 관련 비용 발생 시 기록 (수리비 등)
- `log_user`: 변경을 수행한 작업자
- `log_date`: 변경 발생 일시
### B. 백엔드 (Server-side Logic)
- **위치**: `server.js``POST /api/asset/:category/save` 엔드포인트
- **동작 흐름**:
1. **Snapshot**: 인서트/업데이트 수행 전, 기존 DB의 데이터를 `SELECT`하여 메모리에 저장.
2. **Comparison**: 요청된 신규 데이터와 기존 데이터를 필드별로 대조.
3. **Auto-logging**: 변경점이 발견되면 `asset_history` 테이블에 즉시 인서트.
4. **Transaction**: 모든 로그 생성이 자산 저장과 하나의 트랜잭션으로 묶여야 함.
### C. 프론트엔드 (UI/UX)
- **위치**: `HWModal.ts` 우측 `modal-history-area`
- **개선 사항**:
- `renderHistory()` 함수를 고도화하여 이벤트 타입별 아이콘/컬러 적용.
- "이전 값 ➔ 이후 값" 형태의 직관적인 레이아웃 도입.
- 스크롤을 통한 무제한 누적 이력 조회 지원.
## 4. 단계별 구현 로직
### 1단계: 서버 로직 고도화
- `server.js`에 비교 함수(`compareAndLog`) 구현.
- 각 자산 카테고리별 저장 로직에 비교 로직 삽입.
### 2단계: DB 데이터 마이그레이션 (필요시)
- 기존 자산의 `current_dept` 등을 `previous_dept`로 밀어내는 로직 점검.
### 3단계: UI 타임라인 렌더링 개선
- `modal.css`에 이력 전용 스타일(이벤트 뱃지 등) 추가.
- `HWModal.ts`에서 최신 로그를 실시간으로 다시 불러오는 로직 확인.
## 5. 검증 계획
- **자동 감지 테스트**: 상태 변경 후 저장 시 우측 이력에 즉시 한 줄이 추가되는지 확인.
- **다중 변경 테스트**: 조직과 사용자를 동시에 변경했을 때 두 개의 로그가 생성되는지 확인.
- **데이터 무결성**: 수정을 취소하거나 저장 실패 시 로그가 남지 않는지(Transaction) 확인.

View File

@@ -1,49 +1,49 @@
# 📗 ITAM 프로젝트 구성 및 협업 가이드
본 문서는 ITAM(IT Asset Management System)의 프로젝트 구조와 공동 작업을 위한 가이드를 제공합니다.
## 1. 프로젝트 아키텍처 개요
ITAM은 **중앙 상태 관리(Centralized State)**와 **컴포넌트 기반 UI(Component-based UI)** 구조로 설계되었습니다. 모든 UI와 비즈니스 로직은 기능별로 독립된 파일로 분리되어 있어, 여러 작업자가 충돌 없이 동시에 개발할 수 있습니다.
## 2. 핵심 디렉토리 구조 및 역할
### 🏗️ 제어 로직 (Core)
* **`src/main.ts`**: 시스템 관제탑. 전체 컴포넌트 초기화 및 메인 렌더링 흐름을 제어합니다.
* **`src/state.ts`**: **전역 데이터 창고**. 자산 데이터(`masterData`)와 현재 탭 상태를 중앙에서 관리합니다. 데이터 구조 변경 시 가장 먼저 확인해야 할 파일입니다.
### 🛠️ 상세 페이지 및 모달 (Modals)
모든 자산의 추가/수정/삭제 로직은 `src/components/Modal/` 폴더 내에 독립적으로 구성되어 있습니다.
* **`BaseModal.ts`**: 모든 모달의 공통 기능(닫기, ESC 처리, 배경 클릭)을 담당합니다.
* **`PCModal.ts`**: 개인PC 전용 상세 정보 및 사양 관리.
* **`HWModal.ts`**: 서버, 전산비품 자산 상세 정보 관리.
* **`StorageModal.ts`**: 스토리지(NAS/DAS) 특화 필드 및 정보 관리.
* **`SWModal.ts`**: 소프트웨어 라이선스 기본 정보 관리.
* **`SWUserModal.ts`**: **복잡한 로직 영역**. 소프트웨어별 사용자 할당/해제 및 매핑 로직을 담당합니다.
### 📊 화면 렌더링 (Views)
* **`src/views/DashboardView.ts`**: HW/SW 현황 통계 및 요약 차트 화면을 렌더링합니다.
* **`src/views/AssetTableView.ts`**: 각 카테고리별 자산 목록 테이블을 렌더링합니다.
## 3. 공동 작업 가이드 (협업 전략)
본 프로젝트는 파일 단위로 역할이 명확히 나뉘어 있어, **담당 영역에 따라 독립적인 작업이 가능**합니다.
### 👤 담당자별 권장 작업 영역
| 작업 대상 | 담당 파일 (Primary) | 설명 |
| :--- | :--- | :--- |
| **하드웨어(HW) 담당** | `PCModal.ts`, `HWModal.ts`, `StorageModal.ts` | 하드웨어 상세 페이지 및 사양 관리 로직 개발 |
| **소프트웨어(SW) 담당** | `SWModal.ts`, `SWUserModal.ts` | 소프트웨어 정보 및 사용자 할당 시스템 개발 |
### 🤝 공통 영역 및 주의사항
아래 파일들은 두 담당자가 공통으로 사용하는 영역이므로, 수정 시 Git 충돌에 유의하고 소통이 필요합니다.
1. **`src/state.ts`**: 데이터 인터페이스(Interface)를 변경할 경우.
2. **`src/views/AssetTableView.ts`**: 목록 테이블의 공통 스타일이나 HW/SW 테이블 구조를 변경할 경우.
3. **`src/views/DashboardView.ts`**: 대시보드 통계 알고리즘을 변경할 경우.
## 4. 데이터 흐름 (Data Flow)
1. **데이터 조회**: `AssetTableView`에서 항목 클릭 → 담당 모달의 `openModal(asset)` 호출 → 폼 바인딩.
2. **데이터 저장**: 모달에서 '저장' 버튼 클릭 → `state.ts`의 전역 상태 업데이트 → `main.ts``renderContent()` 호출 → 전체 화면 즉시 갱신.
---
**Tip**: 새로운 기능을 추가할 때는 `main.ts`에 코드를 직접 작성하지 말고, 적절한 폴더 아래에 새 파일을 만들어 `import` 하시기 바랍니다.
# 📗 ITAM 프로젝트 구성 및 협업 가이드
본 문서는 ITAM(IT Asset Management System)의 프로젝트 구조와 공동 작업을 위한 가이드를 제공합니다.
## 1. 프로젝트 아키텍처 개요
ITAM은 **중앙 상태 관리(Centralized State)**와 **컴포넌트 기반 UI(Component-based UI)** 구조로 설계되었습니다. 모든 UI와 비즈니스 로직은 기능별로 독립된 파일로 분리되어 있어, 여러 작업자가 충돌 없이 동시에 개발할 수 있습니다.
## 2. 핵심 디렉토리 구조 및 역할
### 🏗️ 제어 로직 (Core)
* **`src/main.ts`**: 시스템 관제탑. 전체 컴포넌트 초기화 및 메인 렌더링 흐름을 제어합니다.
* **`src/state.ts`**: **전역 데이터 창고**. 자산 데이터(`masterData`)와 현재 탭 상태를 중앙에서 관리합니다. 데이터 구조 변경 시 가장 먼저 확인해야 할 파일입니다.
### 🛠️ 상세 페이지 및 모달 (Modals)
모든 자산의 추가/수정/삭제 로직은 `src/components/Modal/` 폴더 내에 독립적으로 구성되어 있습니다.
* **`BaseModal.ts`**: 모든 모달의 공통 기능(닫기, ESC 처리, 배경 클릭)을 담당합니다.
* **`PCModal.ts`**: 개인PC 전용 상세 정보 및 사양 관리.
* **`HWModal.ts`**: 서버, 전산비품 자산 상세 정보 관리.
* **`StorageModal.ts`**: 스토리지(NAS/DAS) 특화 필드 및 정보 관리.
* **`SWModal.ts`**: 소프트웨어 라이선스 기본 정보 관리.
* **`SWUserModal.ts`**: **복잡한 로직 영역**. 소프트웨어별 사용자 할당/해제 및 매핑 로직을 담당합니다.
### 📊 화면 렌더링 (Views)
* **`src/views/DashboardView.ts`**: HW/SW 현황 통계 및 요약 차트 화면을 렌더링합니다.
* **`src/views/AssetTableView.ts`**: 각 카테고리별 자산 목록 테이블을 렌더링합니다.
## 3. 공동 작업 가이드 (협업 전략)
본 프로젝트는 파일 단위로 역할이 명확히 나뉘어 있어, **담당 영역에 따라 독립적인 작업이 가능**합니다.
### 👤 담당자별 권장 작업 영역
| 작업 대상 | 담당 파일 (Primary) | 설명 |
| :--- | :--- | :--- |
| **하드웨어(HW) 담당** | `PCModal.ts`, `HWModal.ts`, `StorageModal.ts` | 하드웨어 상세 페이지 및 사양 관리 로직 개발 |
| **소프트웨어(SW) 담당** | `SWModal.ts`, `SWUserModal.ts` | 소프트웨어 정보 및 사용자 할당 시스템 개발 |
### 🤝 공통 영역 및 주의사항
아래 파일들은 두 담당자가 공통으로 사용하는 영역이므로, 수정 시 Git 충돌에 유의하고 소통이 필요합니다.
1. **`src/state.ts`**: 데이터 인터페이스(Interface)를 변경할 경우.
2. **`src/views/AssetTableView.ts`**: 목록 테이블의 공통 스타일이나 HW/SW 테이블 구조를 변경할 경우.
3. **`src/views/DashboardView.ts`**: 대시보드 통계 알고리즘을 변경할 경우.
## 4. 데이터 흐름 (Data Flow)
1. **데이터 조회**: `AssetTableView`에서 항목 클릭 → 담당 모달의 `openModal(asset)` 호출 → 폼 바인딩.
2. **데이터 저장**: 모달에서 '저장' 버튼 클릭 → `state.ts`의 전역 상태 업데이트 → `main.ts``renderContent()` 호출 → 전체 화면 즉시 갱신.
---
**Tip**: 새로운 기능을 추가할 때는 `main.ts`에 코드를 직접 작성하지 말고, 적절한 폴더 아래에 새 파일을 만들어 `import` 하시기 바랍니다.

48
docs/plans/design_rule.md Normal file
View File

@@ -0,0 +1,48 @@
# 🎨 ITAM 시스템 디자인 가이드 (Design Guide)
본 문서는 ITAM(IT Asset Management System)의 시각적 일관성과 사용자 경험을 유지하기 위한 핵심 디자인 원칙을 정의합니다.
---
### 1. 디자인 철학 (Design Philosophy)
* **Minimalist & Stark**: Vercel 스타일의 극도로 간결하고 현대적인 디자인을 지향합니다.
* **Achromatic Base**: 블랙(#171717)과 화이트를 기본으로 하며, 정보의 구분은 얇은 헤어라인(#ebebeb)을 사용합니다.
* **Fluid & Responsive**: 고정된 픽셀 대신 화면 크기에 비례하여 UI 밀도가 변하는 유동적 스케일링 시스템을 적용합니다.
### 2. 타이포그래피 및 자간 (Typography & Letter-spacing)
* **Font Family**: `Pretendard` 단일 폰트를 사용합니다.
* **Letter-spacing**: 모든 텍스트에 `-0.02em` (-2%) 자간을 적용하여 밀도 있는 가독성을 확보합니다.
* **Typography Scale**:
* **XS**: `clamp(10px, 1.2vmin + 0.2vw, 15px)` - 보조 텍스트
* **SM**: `clamp(12px, 1.4vmin + 0.3vw, 18px)` - 필터, 일반 라벨, 테이블 헤더
* **Base**: `clamp(14px, 1.6vmin + 0.4vw, 22px)` - 본문, 테이블 데이터
* **MD**: `clamp(18px, 2.5vmin + 0.5vw, 30px)` - 섹션 소제목
* **LG**: `clamp(24px, 4vmin + 0.6vw, 48px)` - 페이지 대제목
* **XL**: `clamp(32px, 6vmin + 0.8vw, 72px)` - 핵심 통계 지표
* **Layout Units**:
* **Header Height**: `clamp(50px, 8vmin, 90px)`
* **Base Spacing**: `clamp(0.75rem, 3vmin, 3rem)`
* **Radius**: `clamp(6px, 1.5vmin, 16px)`
### 3. 컬러 팔레트 (Vercel Stark Palette)
* **Primary**: `#171717` (Stark Black) - 텍스트, 주요 버튼, 강조 요소.
* **Secondary**: `#888888` (Mute) - 보조 텍스트, 비활성 아이콘.
* **Border**: `#ebebeb` (Hairline) - 정보 구분선.
* **Background**: `#ffffff` (Canvas), `#fafafa` (Soft), `#f5f5f5` (Soft 2).
* **Accents**: Blue(`#0070f3`), Orange(`#f5a623`), Danger(`#ee0000`).
### 4. 컴포넌트 및 레이아웃 규칙 (Component Rules)
* **Header & Navigation**:
* 상단 1열 통합 바 형태를 유지하며, GNB와 LNB를 동일 라인에 배치하여 공간을 효율적으로 사용합니다.
* **Unified Filter Bar**:
* 검색창과 필터는 상단 타이틀 바로 아래(기존 액션 버튼 라인)까지 올려서 배치합니다.
* **Action Group**: '자산 추가', '부품 마스터' 등의 주요 액션 버튼은 검색창과 같은 라인의 최우측에 정렬합니다.
* **Dashboard**:
* **Single-Screen View**: 1920*1080(또는 1920*919) 해상도에서 스크롤 없이 한 화면에 핵심 정보가 모두 보이도록 최적화합니다.
* **Fixed Charts**: 차트 내부 숫자나 요소에 애니메이션(`animation: false`) 및 플로팅 레이블을 배제하여 정적인 안정성을 확보합니다.
* **Footer**:
* 화면 최하단에 위치하며, 텍스트는 **우측 정렬(Right-aligned)**합니다.
* 상단에 1px 헤어라인 구분선을 가집니다.
* **Security & UX**:
* **Text Selection**: 사용자의 실수에 의한 UI 드래그 방지를 위해 입력창(`input`, `textarea`)을 제외한 전체 영역의 텍스트 선택을 차단합니다.
* **View Toggle**: '서버' 탭 등 특정 탭에서만 '목록보기' 체크박스를 통해 뷰를 전환하며, 그 외 화면은 리스트 중심의 UI를 제공합니다.

View File

@@ -1,77 +1,77 @@
# H/W 자산관리 시스템 프로토타입 구현 계획 (Excel 기반 DB)
현재 프로젝트는 DB 연동 없이 프로토타입으로 개발되며, 초기 H/W 자산을 엑셀 파일로 관리할 수 있도록 설계합니다. 지정된 디자인 가이드라인(`README.md`)에 따라 세련되고 전문적인 UI를 Vite + Vanilla JS (또는 TS) 기반으로 구축합니다.
## User Review Required
> [!IMPORTANT]
> **엑셀을 DB처럼 사용하는 방식**에 대한 주요 동작 흐름은 다음과 같습니다. 해당 방식이 의도하신 바와 맞는지 확인 부탁드립니다.
> 1. 화면 진입 시 제공되는 **템플릿 엑셀 파일 다운로드**
> 2. 해당 양식에 맞춰 데이터 입력 후 브라우저에 **엑셀 파일 업로드(Import)**
> 3. 웹 상에서 **Data Table 형태로 렌더링** (편집/추가/삭제 기능 제공)
> 4. 수정이 완료된 후 **엑셀 파일로 다시 다운로드(Export)** 하여 로컬에 저장
## 데이터 스키마 설계 (H/W 자산)
요청하신 항목에 맞춘 필수 H/W 자산 데이터 필드입니다. 엑셀의 열(Column)로 활용됩니다.
- **법인 (Company)**: 소속 법인
- **자산코드 (AssetCode)**: 자산의 고유 식별자
- **명칭 (DeviceName)**: 모델명 또는 기기명
- **위치 (Location)**: 현재 파악된 물리적 위치
- **관리자 (Manager)**: 실 사용자 또는 담당자
- **IP주소 (IPAddress)**: 할당된 IP
- **MAC address (MacAddress)**: 기기 고유 MAC 주소
- **H/W 사양 (HWSpecs)**: CPU, RAM, Storage 등 사양 요약
- **OS (OperatingSystem)**: 설치된 운영체제 정보
## Proposed Changes
### 1. 개발 환경 설정 (Vite 기반)
- `npx create-vite` 를 사용하여 `vanilla-ts` (또는 `vanilla`) 프로젝트를 현재 디렉토리에 초기화합니다.
- `package.json``vite.config.ts` 설정 (포트 8080 및 host 허용)
- Excel 제어를 위해 `xlsx` (SheetJS) 라이브러리를 설치합니다.
#### [NEW] package.json
#### [NEW] vite.config.ts
---
### 2. UI 및 디자인 컴포넌트 (`README.md` 가이드라인 준수)
- 디자인: Box-less Design, Line-based Division 적용
- 컬러: `#1E5149`(Point), `#E5E7EB`(Border), `#F9FAFB`(Background)
- 폰트: `Pretendard`, `Letter Spacing: -0.02em` 적용
- 테이블 요소: 구분선만 사용하는 미니멀 테이블
#### [NEW] index.css
---
### 3. 메인 HTML 및 로직 구현
- **File Upload Area**: 엑셀 파일을 불러오거나 템플릿을 다운로드 할 수 있는 상단 컨트롤 영역.
- **Data Table**: 파싱된 H/W 자산 리스트를 출력.
- **Modal Component**: `README.md`에 정의된 2열 그리드와 우측 상단 닫기, 하단 저장 버튼이 포함된 정보 수정/생성 모달.
- **Excel Logic**: 업로드된 Excel 데이터를 파싱하여 JSON 형태로 브라우저 메모리에 들고 처리한 후 다시 Excel로 내보내는 기능.
#### [NEW] index.html
#### [NEW] src/main.ts
#### [NEW] src/excelHandler.ts
## Open Questions
> [!WARNING]
> 1. 프론트엔드 프레임워크 강제 규정이 없다면, 경량화 및 설정 편의를 위해 `Vite + Vanilla TypeScript` 를 사용하는 것이 괜찮으신가요? (원하신다면 React나 Vue로도 가능합니다.)
> 2. 추가적인 검색 필터(예: 법인별, 관리자별 검색)가 당장 도입되어야 하는 필수 기능인가요?
## Verification Plan
### Automated Tests
- `npm run dev` 를 통해 `http://localhost:8080` 포트 개방 성공 여부 확인.
- 브라우저 기능 테스트:
- 템플릿 다운로드 클릭 -> 정상적인 `hw_assets.xlsx` 다운로드 여부
- 샘플 엑셀 파일 업로드 -> 데이터 테이블에 행(Row) 생성 여부
- 항목 더블 클릭 혹은 [수정] 버튼 클릭 시 -> 모달 팝업 및 데이터 연동 확인
- [저장 후 내보내기] 클릭 -> 업데이트된 데이터가 포함된 새 엑셀 파일 다운로드 확인
### Manual Verification
- 디자인 요구사항(`Pretendard`, `#1E5149`, Border-less 컨셉) 반영 확인을 스크린샷 렌더링으로 사용자와 상호작용합니다.
# H/W 자산관리 시스템 프로토타입 구현 계획 (Excel 기반 DB)
현재 프로젝트는 DB 연동 없이 프로토타입으로 개발되며, 초기 H/W 자산을 엑셀 파일로 관리할 수 있도록 설계합니다. 지정된 디자인 가이드라인(`README.md`)에 따라 세련되고 전문적인 UI를 Vite + Vanilla JS (또는 TS) 기반으로 구축합니다.
## User Review Required
> [!IMPORTANT]
> **엑셀을 DB처럼 사용하는 방식**에 대한 주요 동작 흐름은 다음과 같습니다. 해당 방식이 의도하신 바와 맞는지 확인 부탁드립니다.
> 1. 화면 진입 시 제공되는 **템플릿 엑셀 파일 다운로드**
> 2. 해당 양식에 맞춰 데이터 입력 후 브라우저에 **엑셀 파일 업로드(Import)**
> 3. 웹 상에서 **Data Table 형태로 렌더링** (편집/추가/삭제 기능 제공)
> 4. 수정이 완료된 후 **엑셀 파일로 다시 다운로드(Export)** 하여 로컬에 저장
## 데이터 스키마 설계 (H/W 자산)
요청하신 항목에 맞춘 필수 H/W 자산 데이터 필드입니다. 엑셀의 열(Column)로 활용됩니다.
- **법인 (Company)**: 소속 법인
- **자산코드 (AssetCode)**: 자산의 고유 식별자
- **명칭 (DeviceName)**: 모델명 또는 기기명
- **위치 (Location)**: 현재 파악된 물리적 위치
- **관리자 (Manager)**: 실 사용자 또는 담당자
- **IP주소 (IPAddress)**: 할당된 IP
- **MAC address (MacAddress)**: 기기 고유 MAC 주소
- **H/W 사양 (HWSpecs)**: CPU, RAM, Storage 등 사양 요약
- **OS (OperatingSystem)**: 설치된 운영체제 정보
## Proposed Changes
### 1. 개발 환경 설정 (Vite 기반)
- `npx create-vite` 를 사용하여 `vanilla-ts` (또는 `vanilla`) 프로젝트를 현재 디렉토리에 초기화합니다.
- `package.json``vite.config.ts` 설정 (포트 8080 및 host 허용)
- Excel 제어를 위해 `xlsx` (SheetJS) 라이브러리를 설치합니다.
#### [NEW] package.json
#### [NEW] vite.config.ts
---
### 2. UI 및 디자인 컴포넌트 (`README.md` 가이드라인 준수)
- 디자인: Box-less Design, Line-based Division 적용
- 컬러: `#1E5149`(Point), `#E5E7EB`(Border), `#F9FAFB`(Background)
- 폰트: `Pretendard`, `Letter Spacing: -0.02em` 적용
- 테이블 요소: 구분선만 사용하는 미니멀 테이블
#### [NEW] index.css
---
### 3. 메인 HTML 및 로직 구현
- **File Upload Area**: 엑셀 파일을 불러오거나 템플릿을 다운로드 할 수 있는 상단 컨트롤 영역.
- **Data Table**: 파싱된 H/W 자산 리스트를 출력.
- **Modal Component**: `README.md`에 정의된 2열 그리드와 우측 상단 닫기, 하단 저장 버튼이 포함된 정보 수정/생성 모달.
- **Excel Logic**: 업로드된 Excel 데이터를 파싱하여 JSON 형태로 브라우저 메모리에 들고 처리한 후 다시 Excel로 내보내는 기능.
#### [NEW] index.html
#### [NEW] src/main.ts
#### [NEW] src/excelHandler.ts
## Open Questions
> [!WARNING]
> 1. 프론트엔드 프레임워크 강제 규정이 없다면, 경량화 및 설정 편의를 위해 `Vite + Vanilla TypeScript` 를 사용하는 것이 괜찮으신가요? (원하신다면 React나 Vue로도 가능합니다.)
> 2. 추가적인 검색 필터(예: 법인별, 관리자별 검색)가 당장 도입되어야 하는 필수 기능인가요?
## Verification Plan
### Automated Tests
- `npm run dev` 를 통해 `http://localhost:8080` 포트 개방 성공 여부 확인.
- 브라우저 기능 테스트:
- 템플릿 다운로드 클릭 -> 정상적인 `hw_assets.xlsx` 다운로드 여부
- 샘플 엑셀 파일 업로드 -> 데이터 테이블에 행(Row) 생성 여부
- 항목 더블 클릭 혹은 [수정] 버튼 클릭 시 -> 모달 팝업 및 데이터 연동 확인
- [저장 후 내보내기] 클릭 -> 업데이트된 데이터가 포함된 새 엑셀 파일 다운로드 확인
### Manual Verification
- 디자인 요구사항(`Pretendard`, `#1E5149`, Border-less 컨셉) 반영 확인을 스크린샷 렌더링으로 사용자와 상호작용합니다.

View File

@@ -1,72 +1,72 @@
# HM ITAM (IT Asset Management) ERP 기능 명세서
## 1. 개요 (Overview)
본 시스템은 데이터베이스(DB) 연결 없이 브라우저 단에서 엑셀(Excel) 파일을 로컬 데이터베이스의 대체재로 활용하여 구동되는 **IT 자산관리(H/W, S/W) 프로토타입 대시보드**입니다. 사용자는 별도의 백엔드 없이 엑셀 파일을 업로드하고, 웹 상에서 조회 및 수정 후 다시 엑셀로 저장(Export)할 수 있습니다.
## 2. 전체 레이아웃 (Layout & Navigation)
화면은 좌측 사이드바 구조(Depth 2)를 채택하여 정보 탐색의 편의성을 고려하였습니다.
### 2.1. 좌측 메인 내비게이션 (Sidebar)
* **하드웨어 (H/W)**: 대시보드, 개인PC, 서버, 스토리지, 전산비품
* **소프트웨어 (S/W)**: 대시보드, 구독 소프트웨어, 영구 소프트웨어
### 2.2. 우측 메인 영역 (Main Content)
* **상단 컨트롤 패널**: 좌측 탭에 상관없이 데이터를 일괄 제어하는 통합 엑셀 버튼 3종(`통합 양식 다운로드`, `엑셀 업로드`, `일괄 엑셀 저장`)이 위치합니다.
* **타이틀 바**: 사용자가 현재 어느 탭(ex. `하드웨어 / 개인PC`)에 위치하고 있는지 동적으로 표시합니다.
* **콘텐츠 뷰**: 대시보드 선택 시 각 자산의 요약 맵(Summary Grid)을, 하위 자산 항목 선택 시 Data Table을 렌더링합니다.
---
## 3. 핵심 기능 (Core Features)
### 3.1. 엑셀 연동 기반 CRUD 로직 파이프라인
* **통합 양식 다운로드 (Template Export)**: 자산이 없는 경우, 초기 세팅을 돕기 위해 빈 엑셀 템플릿(Master File)을 다운로드할 수 있습니다. 다운로드된 파일은 **다중 시트(Multi-sheet)**`개인PC`, `서버`, `스토리지`, `전산비품`, `구독SW`, `영구SW` 6개의 탭이 분리 생성됩니다.
* **엑셀 업로드 (Import/Parse)**: `SheetJS (xlsx)` 라이브러리를 통해 다중 시트 형태의 엑셀 파일을 업로드하면, 한 번에 브라우저 내의 자산 리스트 객체(Array)로 매핑되어 각 탭에 뿌려집니다.
* **자산 조회 (Read)**: 각 자산 항목(ex. 서버) 탭을 클릭하여 들어가면, 해당 시트(Type)에 속한 자산 목록만 필터링되어 테이블로 노출됩니다.
* **자산 추가/수정 (Create/Update)**: 테이블 우측의 `[수정]` 버튼 또는 상단의 `[자산 추가]`를 클릭하면 모달 팝업이 등장하여 H/W와 S/W에 맞는 각기 다른 양식 폼 데이터를 브라우저 메모리에 업데이트합니다.
* **자산 삭제 (Delete)**: 모달 팝업 좌측 하단의 `[삭제]` 버튼을 통해 해당 단일 항목을 삭제할 수 있습니다.
* **일괄 엑셀 저장 (Save/Export)**: 모든 추가/수정/삭제 작업이 완료되면 버튼을 눌러 변경된 전체 메모리 데이터를 다시 **다중 시트 엑셀 파일** 형태로 로컬 PC에 떨굽니다.
### 3.2. 대시보드 (Dashboard)
* **H/W 대시보드**: 개인PC, 서버, 스토리지, 전산비품의 총 수량을 Grid 기반 카드로 요약하여 보여줍니다.
* **S/W 대시보드**: 구독 소프트웨어, 영구 소프트웨어의 총 라이선스 개수를 요약하여 보여줍니다.
---
## 4. 데이터 스키마 (Data Schema)
자산 항목은 H/W와 S/W 두 가지의 다른 구조로 관리됩니다.
### 4.1. H/W 자산 스키마 (Hardware Asset)
`[개인PC, 서버, 스토리지, 전산비품]` 각각 동일한 스키마 구조를 가집니다.
| 필드명 | 유형 (Type) | 필수여부 | 설명 |
| :--- | :---: | :---: | :--- |
| **법인** | `String` | 필 | 자산의 소속 법인 |
| **자산코드** | `String` | 필 | 고유 자산 식별코드 |
| **명칭** | `String` | 필 | 모델명 또는 기기명 |
| **위치** | `String` | 선 | 물리적 위치 (ex. 개발실) |
| **관리자** | `String` | 선 | 실 사용자 또는 책임자 |
| **IP주소** | `String` | 선 | 할당된 고정/유동 IP |
| **MAC address** | `String` | 선 | 기기 고유 물리적 주소 |
| **OS** | `String` | 선 | 설치된 운영체제 정보 |
| **H/W 사양** | `String` | 선 | CPU, RAM, Storage 요약 스펙 |
### 4.2. S/W 자산 스키마 (Software Asset)
`[구독SW, 영구SW]` 각각 동일한 스키마 구조를 가집니다.
| 필드명 | 유형 (Type) | 필수여부 | 설명 |
| :--- | :---: | :---: | :--- |
| **법인** | `String` | 필 | 자산의 소속 법인 |
| **S/W명** | `String` | 필 | 소프트웨어 제품 명칭 |
| **라이선스키** | `String` | 선 | 발급된 S/W 활성화 키 |
| **할당자** | `String` | 선 | 사용하는 사용자 또는 팀 |
| **사용기간** | `String` | 선 | 구독 혹은 만료 기한 표기 |
| **비고** | `String` | 선 | 기타 참고용 안내 사항 |
---
## 5. UI/UX 디자인 정책 (Design Constraint)
1. **Color (Achromatic & Green)**: `Deep Green(#1E5149)` 을 메인 포인트 색상으로 사용하여 전문성과 정돈된 느낌을 줍니다. 배경이나 나머지 요소는 무채색 베이스입니다.
2. **Typography**: 가독성이 우수한 `Pretendard` 서체를 사용하고, 자간을 약간 좁혀 밀도 있고 깔끔한 느낌을 줍니다.
3. **Box-less 스타일링**: 과도한 박스와 테두리를 없애고(Border-based), 얇은 구분 영역만으로 테이블과 폼의 요소를 분리하여 세련된 데이터 표현을 만듭니다.
# HM ITAM (IT Asset Management) ERP 기능 명세서
## 1. 개요 (Overview)
본 시스템은 데이터베이스(DB) 연결 없이 브라우저 단에서 엑셀(Excel) 파일을 로컬 데이터베이스의 대체재로 활용하여 구동되는 **IT 자산관리(H/W, S/W) 프로토타입 대시보드**입니다. 사용자는 별도의 백엔드 없이 엑셀 파일을 업로드하고, 웹 상에서 조회 및 수정 후 다시 엑셀로 저장(Export)할 수 있습니다.
## 2. 전체 레이아웃 (Layout & Navigation)
화면은 좌측 사이드바 구조(Depth 2)를 채택하여 정보 탐색의 편의성을 고려하였습니다.
### 2.1. 좌측 메인 내비게이션 (Sidebar)
* **하드웨어 (H/W)**: 대시보드, 개인PC, 서버, 스토리지, 전산비품
* **소프트웨어 (S/W)**: 대시보드, 구독 소프트웨어, 영구 소프트웨어
### 2.2. 우측 메인 영역 (Main Content)
* **상단 컨트롤 패널**: 좌측 탭에 상관없이 데이터를 일괄 제어하는 통합 엑셀 버튼 3종(`통합 양식 다운로드`, `엑셀 업로드`, `일괄 엑셀 저장`)이 위치합니다.
* **타이틀 바**: 사용자가 현재 어느 탭(ex. `하드웨어 / 개인PC`)에 위치하고 있는지 동적으로 표시합니다.
* **콘텐츠 뷰**: 대시보드 선택 시 각 자산의 요약 맵(Summary Grid)을, 하위 자산 항목 선택 시 Data Table을 렌더링합니다.
---
## 3. 핵심 기능 (Core Features)
### 3.1. 엑셀 연동 기반 CRUD 로직 파이프라인
* **통합 양식 다운로드 (Template Export)**: 자산이 없는 경우, 초기 세팅을 돕기 위해 빈 엑셀 템플릿(Master File)을 다운로드할 수 있습니다. 다운로드된 파일은 **다중 시트(Multi-sheet)**`개인PC`, `서버`, `스토리지`, `전산비품`, `구독SW`, `영구SW` 6개의 탭이 분리 생성됩니다.
* **엑셀 업로드 (Import/Parse)**: `SheetJS (xlsx)` 라이브러리를 통해 다중 시트 형태의 엑셀 파일을 업로드하면, 한 번에 브라우저 내의 자산 리스트 객체(Array)로 매핑되어 각 탭에 뿌려집니다.
* **자산 조회 (Read)**: 각 자산 항목(ex. 서버) 탭을 클릭하여 들어가면, 해당 시트(Type)에 속한 자산 목록만 필터링되어 테이블로 노출됩니다.
* **자산 추가/수정 (Create/Update)**: 테이블 우측의 `[수정]` 버튼 또는 상단의 `[자산 추가]`를 클릭하면 모달 팝업이 등장하여 H/W와 S/W에 맞는 각기 다른 양식 폼 데이터를 브라우저 메모리에 업데이트합니다.
* **자산 삭제 (Delete)**: 모달 팝업 좌측 하단의 `[삭제]` 버튼을 통해 해당 단일 항목을 삭제할 수 있습니다.
* **일괄 엑셀 저장 (Save/Export)**: 모든 추가/수정/삭제 작업이 완료되면 버튼을 눌러 변경된 전체 메모리 데이터를 다시 **다중 시트 엑셀 파일** 형태로 로컬 PC에 떨굽니다.
### 3.2. 대시보드 (Dashboard)
* **H/W 대시보드**: 개인PC, 서버, 스토리지, 전산비품의 총 수량을 Grid 기반 카드로 요약하여 보여줍니다.
* **S/W 대시보드**: 구독 소프트웨어, 영구 소프트웨어의 총 라이선스 개수를 요약하여 보여줍니다.
---
## 4. 데이터 스키마 (Data Schema)
자산 항목은 H/W와 S/W 두 가지의 다른 구조로 관리됩니다.
### 4.1. H/W 자산 스키마 (Hardware Asset)
`[개인PC, 서버, 스토리지, 전산비품]` 각각 동일한 스키마 구조를 가집니다.
| 필드명 | 유형 (Type) | 필수여부 | 설명 |
| :--- | :---: | :---: | :--- |
| **법인** | `String` | 필 | 자산의 소속 법인 |
| **자산코드** | `String` | 필 | 고유 자산 식별코드 |
| **명칭** | `String` | 필 | 모델명 또는 기기명 |
| **위치** | `String` | 선 | 물리적 위치 (ex. 개발실) |
| **관리자** | `String` | 선 | 실 사용자 또는 책임자 |
| **IP주소** | `String` | 선 | 할당된 고정/유동 IP |
| **MAC address** | `String` | 선 | 기기 고유 물리적 주소 |
| **OS** | `String` | 선 | 설치된 운영체제 정보 |
| **H/W 사양** | `String` | 선 | CPU, RAM, Storage 요약 스펙 |
### 4.2. S/W 자산 스키마 (Software Asset)
`[구독SW, 영구SW]` 각각 동일한 스키마 구조를 가집니다.
| 필드명 | 유형 (Type) | 필수여부 | 설명 |
| :--- | :---: | :---: | :--- |
| **법인** | `String` | 필 | 자산의 소속 법인 |
| **S/W명** | `String` | 필 | 소프트웨어 제품 명칭 |
| **라이선스키** | `String` | 선 | 발급된 S/W 활성화 키 |
| **할당자** | `String` | 선 | 사용하는 사용자 또는 팀 |
| **사용기간** | `String` | 선 | 구독 혹은 만료 기한 표기 |
| **비고** | `String` | 선 | 기타 참고용 안내 사항 |
---
## 5. UI/UX 디자인 정책 (Design Constraint)
1. **Color (Achromatic & Green)**: `Deep Green(#1E5149)` 을 메인 포인트 색상으로 사용하여 전문성과 정돈된 느낌을 줍니다. 배경이나 나머지 요소는 무채색 베이스입니다.
2. **Typography**: 가독성이 우수한 `Pretendard` 서체를 사용하고, 자간을 약간 좁혀 밀도 있고 깔끔한 느낌을 줍니다.
3. **Box-less 스타일링**: 과도한 박스와 테두리를 없애고(Border-based), 얇은 구분 영역만으로 테이블과 폼의 요소를 분리하여 세련된 데이터 표현을 만듭니다.

View File

@@ -1,44 +0,0 @@
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
dotenv.config();
const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env;
async function dropLegacyTables() {
const connection = await mysql.createConnection({
host: DB_HOST,
user: DB_USER,
password: DB_PASS,
database: DB_NAME,
port: parseInt(DB_PORT || '3306')
});
console.log('🧹 Starting cleanup of obsolete legacy backup tables...');
const tablesToDrop = [
'asset_pc', 'asset_pc_backup',
'asset_server', 'asset_server_backup',
'asset_storage', 'asset_storage_backup',
'asset_remote_backup', // IMPORTANT: DO NOT drop asset_remote!
'asset_equipment', 'asset_equipment_backup',
'asset_office_supplies', 'asset_office_supplies_backup',
'asset_survey', 'asset_survey_backup',
'asset_vip', 'asset_vip_backup',
'asset_pc_parts'
];
for (const table of tablesToDrop) {
try {
await connection.query(`DROP TABLE IF EXISTS ${table}`);
console.log(`✅ Dropped table: ${table}`);
} catch (err) {
console.warn(`⚠️ Failed to drop table ${table}: ${err.message}`);
}
}
console.log('🎉 Cleanup complete. Database is now lean and mean.');
await connection.end();
}
dropLegacyTables().catch(console.error);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 MiB

View File

@@ -1,51 +0,0 @@
# 구조 개선 및 다중 탭(Depth 2) 도입 계획
사용자 요청에 따라 H/W와 S/W를 구분하고, 그 하위에 각각 대시보드 및 상세 항목(개인PC, 서버 등) 탭을 나누는 네비게이션 구조를 도입합니다. 바닐라 JS 기반에서 각 탭마다 다른 데이터 테이블을 그려내는 아키텍처로 개선합니다.
## User Review Required
> [!IMPORTANT]
> 1. **엑셀 관리 방식 (Sheets 분리)**: 단일 엑셀 파일 안에 여러 개의 시트(Sheet)를 나누어 관리하는 방식으로 제안합니다. 한 번 엑셀을 업로드하면, `개인PC`, `서버`, `스토리지`, `전산비품` 등 각각의 시트를 한방에 파싱하여 각 탭에 적용하도록 구성하겠습니다.
> 2. **S/W 스키마**: 현재 H/W 기반 데이터 스키마만 정의되어 있습니다. [구독 소프트웨어]와 [영구 소프트웨어] 탭 개발을 위한 데이터 항목들(예: 사용기간, 라이선스키, 결제방식 등)은 아직 정해지지 않았으므로 일단 공통 S/W 데이터 스키마 임시 템플릿(S/W명, 유형, 라이선스키, 할당된 사용자 등)으로 만들어 두고 추후 수정할 수 있도록 개발해도 될까요?
## Proposed Changes
### 1. UI/UX: 2 Depth 네비게이션 (`index.html`, `style.css`)
- **좌측(또는 상단) GNB (Global Navigation Bar)**: H/W 와 S/W 를 스위치할 수 있는 메인 탭 생성.
- **LNB (Local Navigation Bar)**: 메인 탭 전환 시 나타나는 서브 탭(H/W: 대시보드/PC/서버/스토리지/비품, S/W: 대시보드/구독/영구).
- `README.md` 가이드라인에 따라 화면을 분할하고 정보 밀도를 높이기 위해 Box-less, Line-based Layout 유지.
#### [MODIFY] index.html
#### [MODIFY] src/style.css
---
### 2. 다중 데이터 구조 및 상태 관리 (`main.ts`)
- 현재 선택된 메뉴 뎁스(예: `activeCategory = 'HW'`, `activeSubTab = '개인PC'`)에 따라 렌더링 함수가 동기화되도록 라우팅/상태 관리 로직 추가.
- `Dashboard` 탭 진입 시, 모든 서브 탭 데이터의 갯수(Total PCs, Total Servers 등)를 한눈에 볼 수 있는 요약 영역(Summary Cards/Charts 영역) 예약 및 구현.
#### [MODIFY] src/main.ts
---
### 3. 멀티-시트(Multi-sheet) 엑셀 파싱 (`excelHandler.ts`)
- `SheetJS` 기능을 확장하여 다운로드/데이터 추출 시 다중 시트 생성.
- **H/W 템플릿 시트명**: `[개인PC, 서버, 스토리지, 전산비품]`
- **S/W 템플릿 시트명**: `[구독SW, 영구SW]`
#### [MODIFY] src/excelHandler.ts
## Open Questions
> [!WARNING]
> * 왼쪽 사이드바로 메뉴를 구성하는 것이 좋을까요, 상단 가로바(Top Nav) 2단으로 구성하는 것이 좋을까요? Reference 이미지가 따로 없다면 범용적으로 관리하기 편한 **왼쪽 사이드바 구조(Sidebar Menu)** 를 제안합니다. (진행 승인 시 사이드바 형태로 구현합니다.)
## Verification Plan
### Automated Tests
- 좌측 `H/W`, `S/W` 클릭 시 서브 메뉴가 정상 토글되는지 검증(`main.ts` DOM class toggle 확인).
- 서브 메뉴 `서버` 클릭 시 빈 테이블(또는 서버 자산 테이블)이 그려지는지 확인.
- 달라진 구조로 `엑셀 템플릿 양식`을 다운로드했을 때 파일에 다수의 시트(Sheet)가 정상 분류되어 있는지 확인.
### Manual Verification
- 브라우저 에이전트를 통해 바뀐 화면의 스크린샷(LNB 사이드바, Dashboard 화면 등)을 찍어 사용자에게 보고.

View File

@@ -1,47 +0,0 @@
# 임시 DB 생성 및 S/W 사용자 관리 개편
임시 DB 엑셀 파일 생성과 S/W 목록의 '할당자' 속성 UI 개편에 대한 기술 구현 계획입니다.
## User Review Required
> [!IMPORTANT]
> **사용자 관리 데이터 저장 방식에 대한 피드백이 필요합니다.**
> 엑셀을 임시 DB로 사용하고 있기 때문에, "사용자 관리" 팝업에서 추가/삭제된 사용자 목록을 엑셀에 저장할 때 **쉼표(,)로 구분된 하나의 문자열**(예: `홍길동, 김철수, 이영희`)로 기존 `할당자` 컬럼에 업데이트 하는 방식을 제안합니다. 이 방식이 괜찮으신가요?
## Proposed Changes
### 1. 임시 DB 연동
임시로 사용할 초기 엑셀 파일(`temp_db.xlsx`)을 프로젝트 루트에 스크립트를 통해 생성합니다.
- 개인PC, 서버, 구독SW, 영구SW 시트에 각각 구성을 확인할 수 있는 dummy 데이터 1~2개씩을 포함하여 생성합니다.
- 향후 화면에서 '엑셀 업로드'를 통해 이 파일을 업로드하여 데이터를 화면에 뿌려볼 수 있습니다. (원하시면 페이지 로드 시 이 파일을 임포트하도록 로직을 변경할 수도 있으나, 브라우저 단에서 로컬 파일을 자동 리딩하는 것은 제한이 있으므로 기본적으로는 파일을 제공만 합니다.)
---
### 2. 컴포넌트: HTML 구조 변경
#### [MODIFY] [index.html](file:///c:/Project/HM%20ITAM/index.html)
- `sw-asset-modal`의 폼 내용 중 "할당자" 입력 폼(<label> 및 <input>) 제거
- 관리 팝업을 위한 `sw-user-modal` 모달 오버레이 마크업 추가
기존 유저 목록을 보여주고, 새 사용자를 추가하거나 기존 사용자를 삭제할 수 있는 UI (리스트, 추가 인풋, 추가 버튼 기반) 작성
---
### 3. 컴포넌트: 로직 및 스타일
#### [MODIFY] [src/main.ts](file:///c:/Project/HM%20ITAM/src/main.ts)
- S/W 렌더링 영역(`renderTable`)에서 데스크탑 뷰의 `<th>할당자</th>` 및 해당하는 셀(`<td>`) 제거
- S/W `관리` 탭(`<td>`)에 수정 버튼(`btn-edit`) 옆에 사용자 관리 아이콘 (Lucide의 `Users` 또는 `UserCog` 아이콘 활용) 추가
- 사용자 관리 아이콘 클릭 시 `sw-user-modal` 팝업 띄우는 이벤트 리스너 추가
- `sw-user-modal` 팝업 내에서 사용자를 추가/삭제하고 '저장' 시, 해당 S/W 자산의 `할당자` 데이터를 갱신하도록 처리 (쉼표 구분 형태)
#### [MODIFY] [src/excelHandler.ts](file:///c:/Project/HM%20ITAM/src/excelHandler.ts)
- (선택 사항) `SW_HEADERS`나 엑셀 파싱 로직은 그대로 두어 하위 호환성 유지. 사용자가 데이터를 쉼표 형태로 주고 받을 것이므로 별도의 인터페이스 변경은 없음.
## Open Questions
- 사용자 관리 팝업에서 저장할 때, 이름 말고 '부서'나 '직급' 같은 추가적인 정보도 관리가 필요하신가요? (기본적으로는 엑셀에 단일 텍스트로 보존되므로 '이름'만 관리하는 것으로 설계했습니다.)
- 개발 환경(Vite)에서 초기 로딩 시 `temp_db.xlsx`를 자동으로 불러오도록 Vite의 플러그인 또는 fetch 로직을 추가하는 것을 원하시나요? 아니면 엑셀 파일만 만들어 드리고 사용자가 '엑셀 업로드' 버튼으로 직접 연동해 쓰는 방식이 좋으신가요?
## Verification Plan
### Manual Verification
1. `npm run dev` 후 브라우저 접속
2. 프로젝트 폴더에 `temp_db.xlsx` 파일이 생성되었는지 확인
3. 소프트웨어 > 영구/구독 탭 진입 시 "할당자" 테이블 헤더가 사라진 것 확인
4. 관리 탭의 "사용자 관리" 아이콘 클릭 시, 해당 소프트웨어의 사용자를 등록하고 삭제할 수 있는 팝업 등장하는지 확인
5. 사용자 아이콘을 클릭해 홍길동, 김철수 등록 후, 전체 엑셀 저장 혹은 다운로드 시 엑셀 파일 내의 '할당자' 열에 `홍길동,김철수` 로 잘 들어가는지 확인

View File

@@ -1,69 +1,61 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ITAM 자산관리 ERP</title>
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable.min.css" />
<link rel="stylesheet" href="/src/styles/common.css" />
<link rel="stylesheet" href="/src/styles/login.css" />
<link rel="stylesheet" href="/src/styles/guide.css" />
<link rel="stylesheet" href="/src/styles/modal.css" />
<link rel="stylesheet" href="/src/styles/dashboard.css" />
<link rel="stylesheet" href="/src/styles/table.css" />
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2.0.0"></script>
</head>
<body>
<div class="app-layout" id="app-layout" style="display: none;">
<!-- Single-Line Integrated Header -->
<header class="main-header">
<div class="header-container" id="nav-container">
<div class="brand">
<img src="/image 92.png" alt="Logo" class="main-logo" />
<h1>자산관리시스템<span class="sub-title">(Digital Asset Control Hub System)</span></h1>
</div>
<!-- Navigation (GNB + LNB in same row) -->
<nav class="integrated-nav" id="main-nav">
<!-- JS will render main items and sub items here side-by-side -->
</nav>
<div class="header-actions">
<div class="role-switcher" id="role-switcher">
<span class="role-label user active">실무자</span>
<label class="switch">
<input type="checkbox" id="role-toggle-checkbox">
<span class="slider round"></span>
</label>
<span class="role-label admin">관리자</span>
</div>
<button id="btn-admin-page" class="hidden"></button> <!-- JS 호환용 숨김 -->
<button id="btn-open-guide-header" class="btn btn-outline" title="프로세스 가이드">
<i data-lucide="book-open"></i> 가이드
</button>
</div>
</div>
</header>
<!-- Main Content Area -->
<main class="content-area" id="main-content">
<!-- Components inject views here -->
</main>
<!-- Footer -->
<footer class="main-footer">
<div id="secret-cloud-trigger" style="width: 20px; height: 20px; cursor: pointer; opacity: 0.1; background: #000; border-radius: 4px; position: absolute; left: 1rem;"></div>
<p>Powered by BARON Consultant Co,Ltd</p>
</footer>
</div>
<!-- All modals are injected dynamically -->
<script type="module" src="/src/main.ts"></script>
</body>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>한맥가족 자산관리시스템</title>
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable.min.css" />
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2.0.0"></script>
</head>
<body>
<div class="app-layout" id="app-layout" style="display: none;">
<!-- Single-Line Integrated Header -->
<header class="main-header">
<div class="header-container" id="nav-container">
<div class="brand">
<!-- <img src="/image 92.png" alt="Logo" class="main-logo" /> -->
<h1>한맥자산관리시스템</h1>
</div>
<!-- Navigation (GNB + LNB in same row) -->
<nav class="integrated-nav" id="main-nav">
<!-- JS will render main items and sub items here side-by-side -->
</nav>
<div class="header-actions">
<div class="role-switcher" id="role-switcher">
<span class="role-label user active">실무자</span>
<label class="switch">
<input type="checkbox" id="role-toggle-checkbox">
<span class="slider round"></span>
</label>
<span class="role-label admin">관리자</span>
</div>
<button id="btn-admin-page" class="hidden"></button> <!-- JS 호환용 숨김 -->
<button id="btn-open-guide-header" class="btn btn-outline" title="프로세스 가이드">
<i data-lucide="book-open"></i> 가이드
</button>
</div>
</div>
</header>
<!-- Main Content Area -->
<main class="content-area" id="main-content">
<!-- Components inject views here -->
</main>
<!-- Footer -->
<footer class="main-footer">
<p>&copy; 2026 BARON Consultant Co,Ltd. All rights reserved.</p>
</footer>
</div>
<!-- All modals are injected dynamically -->
<script type="module" src="/src/main.ts"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -1,42 +1,42 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ITAM Map Coordinate Editor v3.0</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable.min.css" />
</head>
<body style="margin: 0; display: flex; height: 100vh; overflow: hidden; font-family: sans-serif;">
<!-- Left: File Selector -->
<div class="file-sidebar" id="file-sidebar">
<!-- Rendered by MapEditor.ts -->
</div>
<!-- Center: Main Editor -->
<div class="editor-container" id="container">
<div class="img-wrapper" id="wrapper">
<img src="" id="target-img" alt="Map Image">
</div>
</div>
<!-- Right: Control Panel -->
<div class="sidebar">
<h2>Map Editor <small style="font-size: 0.6em; color: #888;">v3.0</small></h2>
<div class="current-path" id="current-path">파일을 선택하세요</div>
<p>
드래그하여 구역을 정의하세요. 저장 버튼을 누르면 즉시 시스템에 반영됩니다.
</p>
<div class="box-list" id="box-list"></div>
<div class="actions">
<button id="btn-clear-all" class="btn btn-outline" style="height:38px;">전체 삭제</button>
<button id="btn-save-server" class="btn btn-primary" style="height:38px;">서버에 즉시 저장</button>
<div id="save-status"></div>
</div>
</div>
<script type="module" src="/src/map-editor-main.ts"></script>
</body>
</html>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ITAM Map Coordinate Editor v3.0</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable.min.css" />
</head>
<body class="editor-body">
<!-- Left: File Selector -->
<div class="file-sidebar" id="file-sidebar">
<!-- Rendered by MapEditor.ts -->
</div>
<!-- Center: Main Editor -->
<div class="editor-container" id="container">
<div class="img-wrapper" id="wrapper">
<img src="" id="target-img" alt="Map Image">
</div>
</div>
<!-- Right: Control Panel -->
<div class="sidebar">
<h2>Map Editor <small class="editor-version">v3.0</small></h2>
<div class="current-path" id="current-path">파일을 선택하세요</div>
<p>
드래그하여 구역을 정의하세요. 저장 버튼을 누르면 즉시 시스템에 반영됩니다.
</p>
<div class="box-list" id="box-list"></div>
<div class="actions">
<button id="btn-clear-all" class="btn btn-outline">전체 삭제</button>
<button id="btn-save-server" class="btn btn-primary">서버에 즉시 저장</button>
<div id="save-status"></div>
</div>
</div>
<script type="module" src="/src/map-editor-main.ts"></script>
</body>
</html>

View File

@@ -1,197 +0,0 @@
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
dotenv.config();
const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env;
async function migrateSchema() {
const connection = await mysql.createConnection({
host: DB_HOST,
user: DB_USER,
password: DB_PASS,
database: DB_NAME,
port: parseInt(DB_PORT || '3306')
});
console.log('🚀 Phase 1: Creating Normalized Tables & Migrating Data...');
try {
await connection.query('SET FOREIGN_KEY_CHECKS = 0');
// --- 1. Drop existing new tables if they exist ---
await connection.query('DROP TABLE IF EXISTS asset_core, asset_hardware, asset_location, asset_remote');
// --- 2. Create New Schema ---
await connection.query(`
CREATE TABLE asset_core (
id VARCHAR(50) PRIMARY KEY,
asset_code VARCHAR(100) UNIQUE NOT NULL,
category VARCHAR(100),
asset_type VARCHAR(100),
asset_purpose VARCHAR(255),
service_type VARCHAR(50),
purchase_corp VARCHAR(100),
purchase_date VARCHAR(50),
purchase_amount VARCHAR(100),
purchase_vendor VARCHAR(255),
approval_document VARCHAR(255),
memo TEXT,
manager_primary VARCHAR(100),
manager_secondary VARCHAR(100),
current_dept VARCHAR(255),
previous_dept VARCHAR(255),
user_current VARCHAR(100),
previous_user VARCHAR(100),
emp_no VARCHAR(20),
user_position VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
await connection.query(`
CREATE TABLE asset_hardware (
id INT AUTO_INCREMENT PRIMARY KEY,
asset_id VARCHAR(50) NOT NULL,
hw_status VARCHAR(50),
model_name VARCHAR(255),
mainboard VARCHAR(255),
os VARCHAR(100),
cpu VARCHAR(255),
ram VARCHAR(100),
gpu VARCHAR(100),
storage1 VARCHAR(255),
storage2 VARCHAR(255),
storage3 VARCHAR(255),
monitoring VARCHAR(100),
price VARCHAR(100),
volume VARCHAR(100),
monitor_inch VARCHAR(50),
serial_num VARCHAR(100),
FOREIGN KEY (asset_id) REFERENCES asset_core(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
await connection.query(`
CREATE TABLE asset_location (
id INT AUTO_INCREMENT PRIMARY KEY,
asset_id VARCHAR(50) NOT NULL,
location VARCHAR(255),
location_detail VARCHAR(255),
location_photo VARCHAR(255),
loc_x VARCHAR(20),
loc_y VARCHAR(20),
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (asset_id) REFERENCES asset_core(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
await connection.query(`
CREATE TABLE asset_remote (
id INT AUTO_INCREMENT PRIMARY KEY,
asset_id VARCHAR(50) NOT NULL,
ip_address VARCHAR(100),
mac_address VARCHAR(100),
remote_tool VARCHAR(100),
remote_id VARCHAR(100),
remote_pw VARCHAR(100),
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (asset_id) REFERENCES asset_core(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
await connection.query('SET FOREIGN_KEY_CHECKS = 1');
console.log('✅ Normalized tables created.');
// --- 3. Migrate Data from Legacy Tables ---
const legacyTables = ['asset_pc', 'asset_server', 'asset_storage', 'asset_remote', 'asset_equipment', 'asset_office_supplies', 'asset_survey', 'asset_vip'];
let totalMigrated = 0;
for (const table of legacyTables) {
try {
const [rows] = await connection.query(`SELECT * FROM ${table}`);
for (const row of rows) {
// 3.1 Insert into asset_core
await connection.query(`
INSERT IGNORE INTO asset_core (
id, asset_code, category, asset_type, asset_purpose, service_type, purchase_corp, purchase_date,
purchase_amount, purchase_vendor, approval_document, memo, manager_primary, manager_secondary,
current_dept, previous_dept, user_current, previous_user, emp_no, user_position, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [
row.id, row.asset_code, row.category, row.asset_type, row.asset_purpose, row.service_type,
row.purchase_corp, row.purchase_date, row.purchase_amount, row.purchase_vendor, row.approval_document,
row.memo, row.manager_primary, row.manager_secondary, row.current_dept, row.previous_dept,
row.user_current, row.previous_user, row.emp_no, row.user_position, row.created_at
]);
// 3.2 Insert into asset_hardware (if hardware fields exist)
if (row.model_name || row.cpu || row.ram || row.hw_status) {
await connection.query(`
INSERT INTO asset_hardware (
asset_id, hw_status, model_name, mainboard, os, cpu, ram, gpu, storage1, storage2, storage3, monitoring, price, volume, monitor_inch, serial_num
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [
row.id, row.hw_status, row.model_name, row.mainboard, row.os, row.cpu, row.ram, row.gpu,
row.ssd_1 || row.hdd_1, row.ssd_2 || row.hdd_2, row.hdd_3, row.monitoring, row.price,
row.volume, row.monitor_inch, row.serial_num
]);
}
// 3.3 Insert into asset_location (if location fields exist)
if (row.location || row.location_detail) {
await connection.query(`
INSERT INTO asset_location (
asset_id, location, location_detail, location_photo, loc_x, loc_y
) VALUES (?, ?, ?, ?, ?, ?)
`, [
row.id, row.location, row.location_detail, row.location_photo, row.loc_x, row.loc_y
]);
}
// 3.4 Insert into asset_remote (if network fields exist)
// Handle primary network interface
if (row.ip_address || row.mac_address || row.remote_tool) {
await connection.query(`
INSERT INTO asset_remote (
asset_id, ip_address, mac_address, remote_tool, remote_id, remote_pw
) VALUES (?, ?, ?, ?, ?, ?)
`, [
row.id, row.ip_address, row.mac_address, row.remote_tool, row.remote_id, row.remote_pw
]);
}
// Handle secondary network interface (e.g., from server table) if it exists
if (row.ip_address_2 || row.remote_tool_2) {
await connection.query(`
INSERT INTO asset_remote (
asset_id, ip_address, remote_tool, remote_id, remote_pw
) VALUES (?, ?, ?, ?, ?)
`, [
row.id, row.ip_address_2, row.remote_tool_2, row.remote_id_2, row.remote_pw_2
]);
}
totalMigrated++;
}
console.log(`- Migrated ${rows.length} records from ${table}`);
} catch (err) {
console.warn(`- Skipping legacy table ${table}: ${err.message}`);
}
}
console.log(`✅ Phase 1 Data Migration Completed. Total Assets Migrated: ${totalMigrated}`);
} catch (err) {
console.error('❌ Migration Failed:', err);
} finally {
await connection.end();
}
}
migrateSchema();

View File

@@ -1,212 +0,0 @@
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
dotenv.config();
const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env;
async function migrateV2() {
const connection = await mysql.createConnection({
host: DB_HOST,
user: DB_USER,
password: DB_PASS,
database: DB_NAME,
port: parseInt(DB_PORT || '3306')
});
console.log('🚀 Phase 2: Final Migration to Normalized V2 Schema...');
try {
await connection.query('SET FOREIGN_KEY_CHECKS = 0');
// 1. Create/Enhance Core Tables
console.log('1. Creating/Enhancing Tables...');
await connection.query('DROP TABLE IF EXISTS asset_core, asset_hardware, asset_location, asset_remote');
await connection.query(`
CREATE TABLE asset_core (
id VARCHAR(50) PRIMARY KEY,
asset_code VARCHAR(100) UNIQUE NOT NULL,
category VARCHAR(100),
asset_type VARCHAR(100),
current_role VARCHAR(50) DEFAULT 'Normal' COMMENT 'Normal, Server, Personal, etc.',
asset_purpose VARCHAR(255),
service_type VARCHAR(50),
purchase_corp VARCHAR(100),
purchase_date VARCHAR(50),
purchase_amount VARCHAR(100),
purchase_vendor VARCHAR(255),
approval_document VARCHAR(255),
memo TEXT,
manager_primary VARCHAR(100),
manager_secondary VARCHAR(100),
current_dept VARCHAR(255),
previous_dept VARCHAR(255),
user_current VARCHAR(100),
previous_user VARCHAR(100),
emp_no VARCHAR(20),
user_position VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
await connection.query(`
CREATE TABLE asset_hardware (
id INT AUTO_INCREMENT PRIMARY KEY,
asset_id VARCHAR(50) NOT NULL,
hw_status VARCHAR(50),
model_name VARCHAR(255),
mainboard VARCHAR(255),
os VARCHAR(100),
cpu VARCHAR(255),
ram VARCHAR(100),
gpu VARCHAR(100),
storage1 VARCHAR(255),
storage2 VARCHAR(255),
storage3 VARCHAR(255),
storage4 VARCHAR(255),
monitoring VARCHAR(100),
price VARCHAR(100),
volume VARCHAR(100),
monitor_inch VARCHAR(50),
serial_num VARCHAR(100),
FOREIGN KEY (asset_id) REFERENCES asset_core(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
await connection.query(`
CREATE TABLE asset_location (
id INT AUTO_INCREMENT PRIMARY KEY,
asset_id VARCHAR(50) NOT NULL,
location VARCHAR(255),
location_detail VARCHAR(255),
location_photo VARCHAR(255),
loc_x VARCHAR(20),
loc_y VARCHAR(20),
is_active TINYINT(1) DEFAULT 1,
deactivated_at DATETIME NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (asset_id) REFERENCES asset_core(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
await connection.query(`
CREATE TABLE asset_remote (
id INT AUTO_INCREMENT PRIMARY KEY,
asset_id VARCHAR(50) NOT NULL,
ip_address VARCHAR(100),
mac_address VARCHAR(100),
remote_tool VARCHAR(100),
remote_id VARCHAR(100),
remote_pw VARCHAR(100),
is_active TINYINT(1) DEFAULT 1,
deactivated_at DATETIME NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (asset_id) REFERENCES asset_core(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
console.log('✅ V2 Schema tables created.');
// 2. Migration Logic
const legacyTables = [
{ name: 'asset_pc', defaultRole: 'Personal' },
{ name: 'asset_server', defaultRole: 'Server' },
{ name: 'asset_storage', defaultRole: 'Normal' },
{ name: 'asset_equipment', defaultRole: 'Normal' },
{ name: 'asset_office_supplies', defaultRole: 'Normal' },
{ name: 'asset_survey', defaultRole: 'Normal' },
{ name: 'asset_vip', defaultRole: 'Normal' },
{ name: 'asset_pc_parts', defaultRole: 'Normal' }
];
let totalMigrated = 0;
for (const tableInfo of legacyTables) {
const table = tableInfo.name;
try {
const [rows] = await connection.query(`SELECT * FROM ${table}`);
console.log(`- Migrating ${rows.length} records from ${table}...`);
for (const row of rows) {
// 2.1 Insert into asset_core
const role = (table === 'asset_pc' && row.asset_type === '서버PC') ? 'Server' : tableInfo.defaultRole;
await connection.query(`
INSERT IGNORE INTO asset_core (
id, asset_code, category, asset_type, current_role, asset_purpose, service_type, purchase_corp, purchase_date,
purchase_amount, purchase_vendor, approval_document, memo, manager_primary, manager_secondary,
current_dept, previous_dept, user_current, previous_user, emp_no, user_position, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [
row.id, row.asset_code, row.category, row.asset_type, role, row.asset_purpose, row.service_type,
row.purchase_corp, row.purchase_date, row.purchase_amount, row.purchase_vendor, row.approval_document,
row.memo, row.manager_primary, row.manager_secondary, row.current_dept, row.previous_dept,
row.user_current || row.current_user, row.previous_user, row.emp_no, row.user_position, row.created_at
]);
// 2.2 Insert into asset_hardware
await connection.query(`
INSERT INTO asset_hardware (
asset_id, hw_status, model_name, mainboard, os, cpu, ram, gpu, storage1, storage2, storage3, storage4, monitoring, price, volume, monitor_inch, serial_num
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [
row.id, row.hw_status, row.model_name, row.mainboard, row.os, row.cpu, row.ram, row.gpu,
row.ssd_1 || row.storage1, row.ssd_2 || row.storage2, row.hdd_1 || row.storage3, row.hdd_2, row.monitoring, row.price,
row.volume, row.monitor_inch, row.serial_num
]);
// 2.3 Insert into asset_location
if (row.location || row.location_detail) {
await connection.query(`
INSERT INTO asset_location (
asset_id, location, location_detail, location_photo, loc_x, loc_y, is_active
) VALUES (?, ?, ?, ?, ?, ?, 1)
`, [
row.id, row.location, row.location_detail, row.location_photo, row.loc_x, row.loc_y
]);
}
// 2.4 Insert into asset_remote
// Primary Network
if (row.ip_address || row.mac_address || row.remote_tool) {
await connection.query(`
INSERT INTO asset_remote (
asset_id, ip_address, mac_address, remote_tool, remote_id, remote_pw, is_active
) VALUES (?, ?, ?, ?, ?, ?, 1)
`, [
row.id, row.ip_address, row.mac_address, row.remote_tool, row.remote_id, row.remote_pw
]);
}
// Secondary Network (for servers)
if (row.ip_address_2 || row.remote_tool_2) {
await connection.query(`
INSERT INTO asset_remote (
asset_id, ip_address, remote_tool, remote_id, remote_pw, is_active
) VALUES (?, ?, ?, ?, ?, 1)
`, [
row.id, row.ip_address_2, row.remote_tool_2, row.remote_id_2, row.remote_pw_2
]);
}
totalMigrated++;
}
} catch (err) {
console.warn(`- Skipping table ${table}: ${err.message}`);
}
}
await connection.query('SET FOREIGN_KEY_CHECKS = 1');
console.log(`✅ Phase 2 Data Migration Completed. Total Assets Migrated: ${totalMigrated}`);
} catch (err) {
console.error('❌ Migration Failed:', err);
} finally {
await connection.end();
}
}
migrateV2();

View File

@@ -1,73 +0,0 @@
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
dotenv.config();
const pool = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
port: parseInt(process.env.DB_PORT || '3306'),
});
async function migrate() {
const conn = await pool.getConnection();
try {
console.log('1. Creating asset_remote_v4 table...');
await conn.query(`
CREATE TABLE IF NOT EXISTS asset_remote_v4 (
id INT AUTO_INCREMENT PRIMARY KEY,
asset_id VARCHAR(50) NOT NULL,
net_type VARCHAR(20) NOT NULL, /* 'IP' or 'REMOTE' */
net_name VARCHAR(100), /* e.g., '기본망', 'AnyDesk' */
net_value1 VARCHAR(100), /* IP or ID */
net_value2 VARCHAR(100), /* MAC or PW */
is_active TINYINT(1) DEFAULT 1,
deactivated_at DATETIME NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (asset_id) REFERENCES asset_core(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
console.log('2. Migrating data from asset_remote...');
const [oldRows] = await conn.query('SELECT * FROM asset_remote WHERE is_active = 1');
let ipCount = 0;
let remoteCount = 0;
for (const row of oldRows) {
// Migrating IP/MAC
if (row.ip_address || row.mac_address) {
await conn.query(
'INSERT INTO asset_remote_v4 (asset_id, net_type, net_name, net_value1, net_value2, created_at) VALUES (?, ?, ?, ?, ?, ?)',
[row.asset_id, 'IP', '기본망', row.ip_address, row.mac_address, row.created_at]
);
ipCount++;
}
// Migrating Remote
if (row.remote_tool || row.remote_id || row.remote_pw) {
await conn.query(
'INSERT INTO asset_remote_v4 (asset_id, net_type, net_name, net_value1, net_value2, created_at) VALUES (?, ?, ?, ?, ?, ?)',
[row.asset_id, 'REMOTE', row.remote_tool, row.remote_id, row.remote_pw, row.created_at]
);
remoteCount++;
}
}
console.log(`Migrated ${ipCount} IP records and ${remoteCount} Remote records.`);
console.log('3. Renaming tables...');
await conn.query('DROP TABLE IF EXISTS asset_remote_legacy');
await conn.query('RENAME TABLE asset_remote TO asset_remote_legacy, asset_remote_v4 TO asset_remote;');
console.log('✅ Migration V4 (Remote) Complete.');
} catch (e) {
console.error('Migration failed:', e);
} finally {
conn.release();
pool.end();
}
}
migrate();

View File

@@ -1,28 +0,0 @@
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
dotenv.config();
const pool = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
port: parseInt(process.env.DB_PORT || '3306'),
});
async function migrate() {
const conn = await pool.getConnection();
try {
console.log('1. Renaming asset_network to asset_remote...');
await conn.query('RENAME TABLE asset_network TO asset_remote');
console.log('✅ Table renamed successfully.');
} catch (e) {
console.error('Migration failed:', e);
} finally {
conn.release();
pool.end();
}
}
migrate();

View File

@@ -1,195 +0,0 @@
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
dotenv.config({ override: true });
const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env;
// 기존의 감점 계산 로직을 그대로 이용해 등급과 감점점수를 도출하는 헬퍼 함수
function parseCpu(cpu) {
if (!cpu) return { tier: '기타', deduction: 30 };
const cpuUpper = cpu.toUpperCase().trim();
if (cpuUpper === '-' || cpuUpper === '') return { tier: '기타', deduction: 30 };
let tier = '기타';
let deduction = 30;
if (cpuUpper.includes('I9') || cpuUpper.includes('RYZEN 9') || cpuUpper.includes('RYZEN9')) {
tier = 'i9 / Ryzen 9';
deduction = 0;
} else if (cpuUpper.includes('I7') || cpuUpper.includes('RYZEN 7') || cpuUpper.includes('RYZEN7')) {
tier = 'i7 / Ryzen 7';
deduction = 5;
} else if (cpuUpper.includes('I5') || cpuUpper.includes('RYZEN 5') || cpuUpper.includes('RYZEN5')) {
tier = 'i5 / Ryzen 5';
deduction = 15;
} else if (cpuUpper.includes('I3') || cpuUpper.includes('RYZEN 3') || cpuUpper.includes('RYZEN3')) {
tier = 'i3 / Ryzen 3';
deduction = 25;
}
// CPU 세대 감점 계산 (최대 -15점)
let genDeduction = 0;
const intelMatch = cpuUpper.match(/I\d-?(\d+)/);
let gen = 0;
if (intelMatch && intelMatch[1]) {
const numStr = intelMatch[1];
if (numStr.length === 5) gen = parseInt(numStr.substring(0, 2), 10);
else if (numStr.length === 4) gen = parseInt(numStr.substring(0, 1), 10);
}
const amdMatch = cpuUpper.match(/RYZEN\s?\d\s?-?(\d+)/);
let amdGen = 0;
if (amdMatch && amdMatch[1] && !intelMatch) {
const numStr = amdMatch[1];
if (numStr.length === 4) amdGen = parseInt(numStr.substring(0, 1), 10);
}
if (intelMatch) {
if (gen >= 12) genDeduction = 0;
else if (gen >= 10) genDeduction = 5;
else if (gen >= 8) genDeduction = 10;
else genDeduction = 15;
} else if (amdMatch) {
if (amdGen >= 5) genDeduction = 0;
else if (amdGen >= 3) genDeduction = 5;
else genDeduction = 10;
} else {
genDeduction = 15;
}
// 최종 등급 감점 + 세대 감점 합산
return { tier, deduction: deduction + genDeduction };
}
function parseGpu(gpu) {
if (!gpu) return { tier: 'C', deduction: 25 };
const gpuUpper = gpu.toUpperCase().trim();
if (gpuUpper === '-' || gpuUpper === '') return { tier: 'C', deduction: 25 };
if (
gpuUpper.includes('RTX 4090') || gpuUpper.includes('RTX 4080') || gpuUpper.includes('RTX 4070') ||
gpuUpper.includes('RTX A5000') || gpuUpper.includes('RTX A6000') || gpuUpper.includes('RTX A4000')
) {
return { tier: 'S', deduction: 0 };
} else if (
gpuUpper.includes('RTX 3070') || gpuUpper.includes('RTX 3060') || gpuUpper.includes('RTX 2060') ||
gpuUpper.includes('RTX A2000') || gpuUpper.includes('RTX A3000') || gpuUpper.includes('QUADRO')
) {
return { tier: 'A', deduction: 5 };
} else if (
gpuUpper.includes('GTX 1660') || gpuUpper.includes('GTX 1080') || gpuUpper.includes('GTX 1070') ||
gpuUpper.includes('GTX 1060') || gpuUpper.includes('RX 6700') || gpuUpper.includes('RX 6600')
) {
return { tier: 'B', deduction: 15 };
} else {
return { tier: 'C', deduction: 25 };
}
}
function parseRam(ram) {
if (!ram) return { tier: '부족', deduction: 25 };
const ramUpper = ram.toUpperCase().trim();
if (ramUpper === '-' || ramUpper === '') return { tier: '부족', deduction: 25 };
const ramMatch = ramUpper.match(/(\d+)\s*GB/);
if (ramMatch && ramMatch[1]) {
const ramVal = parseInt(ramMatch[1], 10);
if (ramVal >= 32) return { tier: '최적', deduction: 0 };
else if (ramVal >= 16) return { tier: '보통', deduction: 10 };
else if (ramVal >= 8) return { tier: '주의', deduction: 20 };
}
return { tier: '부족', deduction: 25 };
}
async function runMigration() {
console.log('🔄 DB 커넥션 연결 중...');
const connection = await mysql.createConnection({
host: DB_HOST,
user: DB_USER,
password: DB_PASS,
database: DB_NAME,
port: parseInt(DB_PORT || '3306')
});
try {
console.log('⚙️ 1. hardware_components_master 테이블 생성...');
await connection.query('DROP TABLE IF EXISTS hardware_components_master');
await connection.query(`
CREATE TABLE hardware_components_master (
id INT AUTO_INCREMENT PRIMARY KEY,
category VARCHAR(50) NOT NULL COMMENT 'CPU, GPU, RAM 등',
component_name VARCHAR(255) NOT NULL UNIQUE COMMENT '부품 표준 명칭',
score_tier VARCHAR(50) COMMENT '성능 등급',
deduction INT DEFAULT 0 COMMENT '감점 점수',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
console.log('✅ 테이블 생성 완료.');
console.log('🔍 2. 기존 asset_spec 테이블에서 부품명 조회...');
const [specRows] = await connection.query('SELECT DISTINCT cpu, ram, gpu FROM asset_spec');
const uniqueCpus = new Set();
const uniqueGpus = new Set();
const uniqueRams = new Set();
specRows.forEach(row => {
if (row.cpu && row.cpu.trim() !== '-' && row.cpu.trim() !== '') uniqueCpus.add(row.cpu.trim());
if (row.gpu && row.gpu.trim() !== '-' && row.gpu.trim() !== '') uniqueGpus.add(row.gpu.trim());
if (row.ram && row.ram.trim() !== '-' && row.ram.trim() !== '') uniqueRams.add(row.ram.trim());
});
// 만약 데이터가 너무 비어있을 경우를 대비하여 기본 대표 부품 몇 개 추가
if (uniqueCpus.size === 0) {
['Intel Core i9-13900K', 'Intel Core i7-14700K', 'Intel Core i5-12400', 'AMD Ryzen 7 7800X3D', 'Intel Core i3-10100'].forEach(c => uniqueCpus.add(c));
}
if (uniqueGpus.size === 0) {
['NVIDIA GeForce RTX 4090', 'NVIDIA GeForce RTX 4070', 'NVIDIA GeForce RTX 3060', 'Intel Iris Xe Graphics', 'NVIDIA GeForce GTX 1660 Super'].forEach(g => uniqueGpus.add(g));
}
if (uniqueRams.size === 0) {
['8GB', '16GB', '32GB', '64GB'].forEach(r => uniqueRams.add(r));
}
console.log(` - 추출된 CPU 개수: ${uniqueCpus.size}`);
console.log(` - 추출된 GPU 개수: ${uniqueGpus.size}`);
console.log(` - 추출된 RAM 개수: ${uniqueRams.size}`);
console.log('💾 3. 마스터 테이블에 부품 데이터 및 감점 정보 삽입...');
// CPU 삽입
for (const cpu of uniqueCpus) {
const { tier, deduction } = parseCpu(cpu);
await connection.query(
'INSERT IGNORE INTO hardware_components_master (category, component_name, score_tier, deduction) VALUES (?, ?, ?, ?)',
['CPU', cpu, tier, deduction]
);
}
// GPU 삽입
for (const gpu of uniqueGpus) {
const { tier, deduction } = parseGpu(gpu);
await connection.query(
'INSERT IGNORE INTO hardware_components_master (category, component_name, score_tier, deduction) VALUES (?, ?, ?, ?)',
['GPU', gpu, tier, deduction]
);
}
// RAM 삽입
for (const ram of uniqueRams) {
const { tier, deduction } = parseRam(ram);
await connection.query(
'INSERT IGNORE INTO hardware_components_master (category, component_name, score_tier, deduction) VALUES (?, ?, ?, ?)',
['RAM', ram, tier, deduction]
);
}
console.log('✅ 마이그레이션이 성공적으로 완료되었습니다!');
} catch (error) {
console.error('❌ 마이그레이션 오류 발생:', error);
} finally {
await connection.end();
}
}
runMigration();

4178
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,26 +1,26 @@
{
"name": "hm-itam",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"server": "node server.js",
"db-init": "node db_init.js"
},
"devDependencies": {
"typescript": "^5.2.2",
"vite": "^5.2.0"
},
"dependencies": {
"cors": "^2.8.6",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"iconv-lite": "^0.7.2",
"lucide": "^0.364.0",
"mysql2": "^3.22.1",
"xlsx": "^0.18.5"
}
}
{
"name": "hm-itam",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"server": "node server.js",
"db-init": "node db_init.js"
},
"devDependencies": {
"typescript": "^5.2.2",
"vite": "^5.2.0"
},
"dependencies": {
"cors": "^2.8.6",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"iconv-lite": "^0.7.2",
"lucide": "^0.364.0",
"mysql2": "^3.22.1",
"xlsx": "^0.18.5"
}
}

View File

@@ -1,134 +1,134 @@
import wmi
import requests
import json
import socket
import platform
import sys
import time
def collect_specs():
try:
c = wmi.WMI()
computer = c.Win32_ComputerSystem()[0]
os_info = c.Win32_OperatingSystem()[0]
proc = c.Win32_Processor()[0]
board = c.Win32_BaseBoard()[0]
# 1. 상세 GPU 정보 수집 (모든 그래픽 카드)
gpu_list = []
for g in c.Win32_VideoController():
gpu_list.append(g.Name)
gpu_info = ", ".join(gpu_list) if gpu_list else "N/A"
# 2. 모든 저장장치 정보 수집 및 SSD/HDD 구분
storage_list = []
# Windows 8 이상에서 작동하는 상세 저장소 정보 수집 시도
physical_disks = {}
try:
storage_c = wmi.WMI(namespace="Root\\Microsoft\\Windows\\Storage")
for d in storage_c.MSFT_PhysicalDisk():
# MediaType: 3(HDD), 4(SSD), 0(Unspecified)
physical_disks[d.DeviceId] = d.MediaType
except:
pass
for d in c.Win32_DiskDrive():
size_gb = round(float(d.Size) / (1024**3)) if d.Size else 0
# 미디어 타입 판단
media_type = physical_disks.get(str(d.Index), 0)
prefix = ""
if media_type == 4:
prefix = "[SSD] "
elif media_type == 3:
prefix = "[HDD] "
else:
# 힌트가 없을 경우 모델명으로 추측
cap = d.Caption.upper()
if "SSD" in cap or "NVME" in cap or "FLASH" in cap:
prefix = "[SSD] "
else:
prefix = "[HDD] "
storage_list.append(f"{prefix}{d.Caption} ({size_gb}GB)")
# DB 필드(SSD1, SSD2, SSD3)에 나눠 담기
storage1 = storage_list[0] if len(storage_list) > 0 else "N/A"
storage2 = storage_list[1] if len(storage_list) > 1 else ""
storage3 = storage_list[2] if len(storage_list) > 2 else ""
# 실시간 데이터 추출
specs = {
"메인보드": f"{board.Manufacturer} {board.Product}".strip(),
"CPU": proc.Name.strip(),
"RAM": f"{round(float(computer.TotalPhysicalMemory) / (1024**3))}GB",
"OS": os_info.Caption,
"GPU": gpu_info,
"SSD1": storage1,
"SSD2": storage2,
"SSD3": storage3,
"비고": "실시간 에이전트(EXE) 자동 수집"
}
return specs
except Exception as e:
print(f"데이터 수집 중 오류 발생: {e}")
return None
def send_data(specs, server_url, asset_code):
try:
# 전송 데이터에 자산코드 추가 (식별용)
specs["자산코드"] = asset_code
print(f"\n📡 서버로 전송 중... ({server_url})")
response = requests.post(server_url, json=specs, timeout=10)
if response.status_code == 200:
print("✅ 전송 성공! ITAM 시스템에서 확인하세요.")
else:
print(f"❌ 전송 실패: 서버 응답 코드 {response.status_code}")
except Exception as e:
print(f"❌ 서버 연결 오류: {e}")
print("서버가 켜져 있는지, URL이 맞는지 확인해주세요.")
if __name__ == "__main__":
print("========================================")
print(" ITAM PC 실시간 사양 수집 에이전트 (v1.1)")
print("========================================\n")
# 1. 정보 수집
print("🔍 하드웨어 정보를 읽어오는 중...")
data = collect_specs()
if data:
print("\n[수집된 실제 사양]")
display_map = {
"메인보드": "메인보드",
"CPU": "CPU",
"RAM": "RAM",
"OS": "OS",
"GPU": "GPU",
"SSD1": "Storage 1",
"SSD2": "Storage 2",
"SSD3": "Storage 3",
"비고": "비고"
}
for key, value in data.items():
if value: # 값이 있는 경우만 표시
label = display_map.get(key, key)
print(f" - {label}: {value}")
print("\n" + "="*40)
asset_code = input("등록할 자산번호를 입력하세요 (예: PC-001): ").strip()
if not asset_code:
print("❌ 자산번호 없이는 전송할 수 없습니다.")
else:
server_ip = input("서버 IP를 입력하세요 (기본값 localhost): ").strip()
if not server_ip: server_ip = "localhost"
target_url = f"http://{server_ip}:3000/api/agent/collect"
confirm = input("\n위 정보를 서버로 전송할까요? (y/n): ")
if confirm.lower() == 'y':
send_data(data, target_url, asset_code)
print("\n5초 후 프로그램이 종료됩니다...")
time.sleep(5)
import wmi
import requests
import json
import socket
import platform
import sys
import time
def collect_specs():
try:
c = wmi.WMI()
computer = c.Win32_ComputerSystem()[0]
os_info = c.Win32_OperatingSystem()[0]
proc = c.Win32_Processor()[0]
board = c.Win32_BaseBoard()[0]
# 1. 상세 GPU 정보 수집 (모든 그래픽 카드)
gpu_list = []
for g in c.Win32_VideoController():
gpu_list.append(g.Name)
gpu_info = ", ".join(gpu_list) if gpu_list else "N/A"
# 2. 모든 저장장치 정보 수집 및 SSD/HDD 구분
storage_list = []
# Windows 8 이상에서 작동하는 상세 저장소 정보 수집 시도
physical_disks = {}
try:
storage_c = wmi.WMI(namespace="Root\\Microsoft\\Windows\\Storage")
for d in storage_c.MSFT_PhysicalDisk():
# MediaType: 3(HDD), 4(SSD), 0(Unspecified)
physical_disks[d.DeviceId] = d.MediaType
except:
pass
for d in c.Win32_DiskDrive():
size_gb = round(float(d.Size) / (1024**3)) if d.Size else 0
# 미디어 타입 판단
media_type = physical_disks.get(str(d.Index), 0)
prefix = ""
if media_type == 4:
prefix = "[SSD] "
elif media_type == 3:
prefix = "[HDD] "
else:
# 힌트가 없을 경우 모델명으로 추측
cap = d.Caption.upper()
if "SSD" in cap or "NVME" in cap or "FLASH" in cap:
prefix = "[SSD] "
else:
prefix = "[HDD] "
storage_list.append(f"{prefix}{d.Caption} ({size_gb}GB)")
# DB 필드(SSD1, SSD2, SSD3)에 나눠 담기
storage1 = storage_list[0] if len(storage_list) > 0 else "N/A"
storage2 = storage_list[1] if len(storage_list) > 1 else ""
storage3 = storage_list[2] if len(storage_list) > 2 else ""
# 실시간 데이터 추출
specs = {
"메인보드": f"{board.Manufacturer} {board.Product}".strip(),
"CPU": proc.Name.strip(),
"RAM": f"{round(float(computer.TotalPhysicalMemory) / (1024**3))}GB",
"OS": os_info.Caption,
"GPU": gpu_info,
"SSD1": storage1,
"SSD2": storage2,
"SSD3": storage3,
"비고": "실시간 에이전트(EXE) 자동 수집"
}
return specs
except Exception as e:
print(f"데이터 수집 중 오류 발생: {e}")
return None
def send_data(specs, server_url, asset_code):
try:
# 전송 데이터에 자산코드 추가 (식별용)
specs["자산코드"] = asset_code
print(f"\n📡 서버로 전송 중... ({server_url})")
response = requests.post(server_url, json=specs, timeout=10)
if response.status_code == 200:
print("✅ 전송 성공! ITAM 시스템에서 확인하세요.")
else:
print(f"❌ 전송 실패: 서버 응답 코드 {response.status_code}")
except Exception as e:
print(f"❌ 서버 연결 오류: {e}")
print("서버가 켜져 있는지, URL이 맞는지 확인해주세요.")
if __name__ == "__main__":
print("========================================")
print(" ITAM PC 실시간 사양 수집 에이전트 (v1.1)")
print("========================================\n")
# 1. 정보 수집
print("🔍 하드웨어 정보를 읽어오는 중...")
data = collect_specs()
if data:
print("\n[수집된 실제 사양]")
display_map = {
"메인보드": "메인보드",
"CPU": "CPU",
"RAM": "RAM",
"OS": "OS",
"GPU": "GPU",
"SSD1": "Storage 1",
"SSD2": "Storage 2",
"SSD3": "Storage 3",
"비고": "비고"
}
for key, value in data.items():
if value: # 값이 있는 경우만 표시
label = display_map.get(key, key)
print(f" - {label}: {value}")
print("\n" + "="*40)
asset_code = input("등록할 자산번호를 입력하세요 (예: PC-001): ").strip()
if not asset_code:
print("❌ 자산번호 없이는 전송할 수 없습니다.")
else:
server_ip = input("서버 IP를 입력하세요 (기본값 localhost): ").strip()
if not server_ip: server_ip = "localhost"
target_url = f"http://{server_ip}:3000/api/agent/collect"
confirm = input("\n위 정보를 서버로 전송할까요? (y/n): ")
if confirm.lower() == 'y':
send_data(data, target_url, asset_code)
print("\n5초 후 프로그램이 종료됩니다...")
time.sleep(5)

View File

@@ -1,38 +1,38 @@
# -*- mode: python ; coding: utf-8 -*-
a = Analysis(
['pc_agent.py'],
pathex=[],
binaries=[],
datas=[],
hiddenimports=[],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name='pc_agent',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=True,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)
# -*- mode: python ; coding: utf-8 -*-
a = Analysis(
['pc_agent.py'],
pathex=[],
binaries=[],
datas=[],
hiddenimports=[],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name='pc_agent',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=True,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)

View File

@@ -1,36 +0,0 @@
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
dotenv.config();
const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env;
async function probeDB() {
const connection = await mysql.createConnection({
host: DB_HOST,
user: DB_USER,
password: DB_PASS,
database: DB_NAME,
port: parseInt(DB_PORT || '3306')
});
console.log('--- Database Probe Start ---');
const [tables] = await connection.query('SHOW TABLES');
const tableNames = tables.map(t => Object.values(t)[0]);
console.log('Existing Tables:', tableNames);
for (const table of tableNames) {
const [columns] = await connection.query(`DESCRIBE ${table}`);
console.log(`\n[Table: ${table}]`);
columns.forEach(c => {
console.log(` - ${c.Field} (${c.Type}) ${c.Comment ? '// ' + c.Comment : ''}`);
});
}
await connection.end();
console.log('\n--- Database Probe End ---');
}
probeDB().catch(console.error);

View File

@@ -0,0 +1,247 @@
# ITAM 운영 배포 작업 로드맵
## 1. 문서 목적
이 문서는 ITAM 저장소를 Windows/WSL 시범 구동 상태에서 Linux 운영 서버 배포 상태로 전환하기 위한 구체적인 작업 목록과 우선순위를 정리한 로드맵이다.
현재 상태: 개발/시범용 Docker 구조 (Vite dev server + bind mount + external DB)
목표 상태: 운영 배포 구조 (정적 빌드 + 영속 스토리지 분리 + reverse proxy + external DB)
---
## 2. 작업 페이즈 분류
### 2.1 Phase 1: 핵심 배포 파일 (우선순위: 높음)
운영 환경에서 실제 배포를 가능하게 하는 기초 작업이다.
1.**Add production compose file** (`docker-compose.prod.yaml`)
- 목표: 운영 서버 기준 최종 compose 파일 작성
- 범위: backend, frontend, nginx 서비스 정의
- 입력: 외부 `.env`, 호스트 경로 마운트 정의
- 출력: `/deploy/docker-compose.prod.yaml`
- 완료 기준: `docker compose -f docker-compose.prod.yaml config` 성공
2.**Create production frontend Dockerfile (multi-stage build)**
- 목표: 정적 자산 기반 프런트엔드 이미지 생성
- 범위: Stage 1 = 빌드 (Node.js + npm build), Stage 2 = 정적 서빙 (Nginx)
- 입력: 현재 `Dockerfile.frontend` 참고, `npm run build` 검증
- 출력: `Dockerfile.frontend.prod` 또는 분기 처리
- 완료 기준: 로컬에서 `npm run build` 성공, 이미지 빌드 성공, 정적 파일 8080 서빙 확인
3.**Harden backend Dockerfile for production**
- 목표: production 환경 최적화된 백엔드 이미지
- 범위: NODE_ENV=production, production 의존성만, 비루트 사용자, health check 추가
- 입력: 현재 `Dockerfile.backend`, `server.js` 검토
- 출력: 수정된 `Dockerfile.backend` 또는 `.prod` 버전
- 완료 기준: 이미지 빌드 성공, 시작 후 health endpoint 응답 200
4.**Define host paths and named volumes for persistence**
- 목표: Linux 서버의 운영 디렉터리 구조 정의
- 범위: `/srv/itam/{app,env,config,uploads,logs,deploy}` 마운트 정책 확정
- 입력: doc_readme3.md 섹션 7 참고
- 출력: docker-compose.prod.yaml에 적용, 볼륨 마운트 확인
- 완료 기준: compose 파일에 경로 마운트 명시, 영속성 테스트 (컨테이너 재시작 후 데이터 유지)
---
### 2.2 Phase 2: 네트워킹 및 보안 (우선순위: 높음)
외부 접근 경로와 보안을 담당하는 작업이다.
5.**Provide Nginx reverse-proxy and frontend static config**
- 목표: Nginx 설정파일 작성 (frontend 정적 서빙 + API 프록시)
- 범위: `/` → frontend, `/api/` → backend:3000, 기본 보안 헤더, gzip
- 입력: doc_readme3.md 섹션 12 참고 (예시 개념)
- 출력: `/deploy/nginx/default.conf`
- 완료 기준: `docker compose -f docker-compose.prod.yaml up -d``http://localhost/api/assets/master` 응답 200
6.**Externalize and secure environment variables (.env.example + secrets guidance)**
- 목표: 민감정보 보호 기준 문서화
- 범위: `.env.example` 생성, Git 제외 확인, 운영 환경 분리 지침
- 입력: 현재 `.env` 파일, `.gitignore` 점검
- 출력: `.env.example`, env 관리 가이드 추가 (doc_readme3.md 또는 SECURITY.md)
- 완료 기준: `.env``.gitignore`에 등록, `.env.example` 배포 파일 작성됨
7.**Define TLS certificate handling strategy (Let's Encrypt / mount certs)**
- 목표: HTTPS 인증서 관리 정책 확정
- 범위: 자동 갱신 (certbot + Let's Encrypt) 또는 마운트 기반 수동 관리 선택
- 입력: 사내 정책 확인, 운영 도메인 확인
- 출력: `deploy/nginx/tls-strategy.md` 또는 compose 파일 주석으로 정리
- 완료 기준: 선택 방식 문서화, nginx 설정 적용 준비
8.**Security review: non-root users, image scan, secret rotation**
- 목표: 보안 체크리스트 작성 및 초기 적용
- 범위: Dockerfile non-root 사용자 추가, 이미지 취약점 스캔 지침, 비밀 로테이션 정책
- 입력: doc_readme3.md 섹션 13.3 (보안) 참고
- 출력: 수정된 Dockerfile, `SECURITY.md` 또는 운영 가이드 추가
- 완료 기준: 백엔드/프런트엔드 모두 비루트 사용자로 실행 확인
---
### 2.3 Phase 3: 모니터링 및 운영 준비 (우선순위: 중간)
배포 후 운영을 원활하게 하기 위한 작업이다.
9.**Add healthcheck and readiness endpoint in backend**
- 목표: backend 헬스 체크 엔드포인트 추가
- 범위: `GET /health` 또는 `/ready` 엔드포인트 추가 (DB 연결 확인)
- 입력: `server.js` 현재 코드 검토
- 출력: backend 에서 health 응답, docker-compose.prod.yaml에 healthcheck 설정
- 완료 기준: `curl http://localhost:3000/health` 응답 200, 컨테이너 헬스 상태 healthy 표시
10.**Add logging and log rotation guidance**
- 목표: 컨테이너 로그 관리 정책 문서화
- 범위: Docker logging driver 설정, log rotation 정책, 저장소 경로 정의
- 입력: `/srv/itam/logs` 마운트 계획
- 출력: docker-compose.prod.yaml에 로깅 설정, docs에 로그 확인 가이드
- 완료 기준: 로그 파일이 `/srv/itam/logs`에 저장됨, rotation 정책 명시
11.**Document backup and restore procedures for DB and uploads**
- 목표: 운영 데이터 백업/복구 절차 문서화
- 범위: 외부 MySQL 백업 정책, `/srv/itam/uploads` 백업, 복구 절차 스크립트 예시
- 입력: doc_readme3.md 섹션 14 참고
- 출력: `BACKUP_RESTORE.md` 또는 운영 가이드 추가 섹션
- 완료 기준: 백업 스크립트 예시 작성, 복구 절차 테스트 완료
12.**Add smoke tests and post-deploy checks**
- 목표: 배포 후 빠른 검증 스크립트 작성
- 범위: 컨테이너 상태 확인, API 응답 테스트, 파일 업로드 테스트, DB 연결 확인
- 입력: doc_readme3.md 섹션 13 (점검 체크리스트) 참고
- 출력: `scripts/smoke-test.sh` 또는 배포 후 확인 스크립트
- 완료 기준: 스크립트 실행 후 모든 검사 통과
---
### 2.4 Phase 4: 자동화 및 CI/CD (우선순위: 중간)
장기 운영을 위한 자동화 작업이다.
13.**Prepare CI/CD build & push scripts (image registry)**
- 목표: 이미지 빌드 및 레지스트리 푸시 자동화
- 범위: `docker build`, `docker tag`, `docker push` 스크립트 또는 GitHub Actions/GitLab CI 예시
- 입력: 이미지 레지스트리 주소 확인 (e.g., registry.example.com, Docker Hub, etc.)
- 출력: `.github/workflows/build.yml` 또는 `scripts/build-push.sh`
- 완료 기준: 수동 빌드/푸시 스크립트 작동 확인, 이미지 태그 정책 확정
14.**Create deploy directory with compose.prod and nginx configs**
- 목표: 운영 배포 디렉터리 정리
- 범위: `/deploy/docker-compose.prod.yaml`, `/deploy/nginx/default.conf`, 기타 설정 파일 조직화
- 입력: Phase 1-3 결과물
- 출력: 다음 구조로 정리됨:
```
deploy/
docker-compose.prod.yaml
nginx/
default.conf
tls-strategy.md
scripts/
smoke-test.sh
backup.sh
```
- 완료 기준: 디렉터리 구조 확정, 모든 파일 위치 일관성 있음
---
### 2.5 Phase 5: 절차 및 문서화 (우선순위: 중간)
운영 절차와 문서를 정리하는 작업이다.
15. ✅ **Write rollout and rollback procedures (steps, checklist)**
- 목표: 배포 및 복구 절차 문서화
- 범위: 배포 전 체크리스트, 배포 단계별 절차, 장애 시 롤백 절차
- 입력: doc_readme3.md 섹션 16 참고
- 출력: `DEPLOYMENT_PROCEDURES.md` 또는 운영 가이드 통합
- 완료 기준: 절차 문서 완성, 체크리스트 확인 가능
---
## 3. 작업 우선순위 및 권장 순서
### 3.1 필수 우선 작업 (Phase 1 완료 필수)
1. Add production compose file
2. Create production frontend Dockerfile (multi-stage build)
3. Harden backend Dockerfile for production
4. Define host paths and named volumes for persistence
**목표**: 기본 배포 구조 완성, Linux 서버에서 최소 기동 가능 상태
### 3.2 보안 필수 작업 (Phase 2 완료 권장)
5. Provide Nginx reverse-proxy and frontend static config
6. Externalize and secure environment variables
7. Define TLS certificate handling strategy
8. Security review: non-root users, image scan, secret rotation
**목표**: 운영 환경 최소 보안 기준 충족
### 3.3 운영 안정화 작업 (Phase 3 진행)
9. Add healthcheck and readiness endpoint in backend
10. Add logging and log rotation guidance
11. Document backup and restore procedures
12. Add smoke tests and post-deploy checks
**목표**: 배포 후 문제 식별 및 백업/복구 가능 상태
### 3.4 장기 운영 자동화 (Phase 4-5는 선택)
13. Prepare CI/CD build & push scripts
14. Create deploy directory with compose.prod and nginx configs
15. Write rollout and rollback procedures
**목표**: 반복 배포 자동화, 절차 표준화
---
## 4. 작업 환경 및 검증 기준
### 4.1 개발/테스트 환경
- 로컬 Linux VM 또는 WSL에서 Phase 1-2 테스트
- Docker Desktop or Docker Engine 필수
- 외부 테스트 MySQL 또는 mock DB
### 4.2 스테이징 환경
- 실제 Linux 서버에서 전체 배포 절차 테스트
- 운영 환경과 동일 아키텍처
### 4.3 운영 배포
- 위 모든 Phase 완료 후 진행
- 백업 확인 후 배포
- 배포 후 smoke test 자동 실행
---
## 5. 진행 추적
진행 상황은 아래 상태로 추적한다.
- `not-started`: 아직 시작 안 함
- `in-progress`: 현재 진행 중
- `completed`: 완료됨
현재 상태는 모두 `not-started`이며, Phase 1 우선 순위 항목부터 순차적으로 진행한다.
---
## 6. 예상 일정
- Phase 1 (핵심 배포 파일): 1-2일
- Phase 2 (네트워킹 및 보안): 1-2일
- Phase 3 (모니터링 및 운영 준비): 1일
- Phase 4-5 (자동화 및 절차): 1-2일
**전체 예상 소요 시간**: 4-7일
---
## 7. 추가 고려사항
1. 현재 `doc_readme3.md`는 가이드 문서이고, 이 로드맵은 구현 작업 목록이다.
2. Phase 1-2 완료 후 실제 코드 커밋은 `Dockerizing` 브랜치에만 한다.
3. 각 Phase 완료 후 관련 문서도 함께 업데이트한다.
4. 운영 전환 전에 최소 1회 스테이징 환경에서 전체 배포 절차 테스트를 수행한다.

View File

@@ -1,429 +1,429 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PC 사양 적정성 분석 기획서 (GPU 반영)</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;900&family=Outfit:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
:root {
--primary: #4F46E5;
--primary-light: #EEF2FF;
--secondary: #10B981;
--secondary-light: #D1FAE5;
--danger: #EF4444;
--danger-light: #FEE2E2;
--warning: #F59E0B;
--warning-light: #FEF3C7;
--purple: #7C3AED;
--purple-light: #EDE9FE;
--text-dark: #0F172A;
--text-body: #334155;
--text-muted: #64748B;
--border: #E2E8F0;
--bg-light: #F8FAFC;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Outfit', 'Noto Sans KR', sans-serif;
color: var(--text-body);
background: #fff;
letter-spacing: -0.02em;
line-height: 1.7;
}
.page { max-width: 980px; margin: 0 auto; padding: 3rem 2rem; }
/* ─ Header ─ */
.doc-header { border-bottom: 3px solid var(--text-dark); padding-bottom: 1.75rem; margin-bottom: 3rem; }
.doc-label {
display: inline-block; font-size: 0.75rem; font-weight: 700; color: var(--primary);
background: var(--primary-light); padding: 0.25rem 0.75rem; border-radius: 99px;
text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 0.75rem;
}
.version-badge {
display: inline-block; font-size: 0.7rem; font-weight: 700; color: var(--secondary);
background: var(--secondary-light); padding: 0.2rem 0.6rem; border-radius: 99px;
margin-left: 0.5rem; vertical-align: middle;
}
.doc-header h1 { font-size: 2rem; font-weight: 900; color: var(--text-dark); line-height: 1.25; margin-bottom: 1rem; }
.meta-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 0.75rem; margin-top: 1rem; }
.meta-item { background: var(--bg-light); border-radius: 8px; padding: 0.65rem 1rem; font-size: 0.83rem; }
.meta-item .label { color: var(--text-muted); display: block; font-size: 0.75rem; }
.meta-item .val { font-weight: 700; color: var(--text-dark); font-size: 0.9rem; }
/* ─ Sections ─ */
section { margin-bottom: 3.5rem; }
h2 {
font-size: 1.3rem; font-weight: 800; color: var(--text-dark);
padding-bottom: 0.5rem; border-bottom: 2px solid var(--border);
margin-bottom: 1.5rem; display: flex; align-items: center; gap: 0.6rem;
}
h2 .num {
display: inline-flex; align-items: center; justify-content: center;
width: 28px; height: 28px; background: var(--primary); color: #fff;
border-radius: 50%; font-size: 0.75rem; font-weight: 800; flex-shrink: 0;
}
h3 { font-size: 1.05rem; font-weight: 700; color: var(--text-dark); margin: 1.75rem 0 0.75rem; }
p { margin-bottom: 1rem; color: var(--text-body); font-size: 0.97rem; }
/* ─ Boxes ─ */
.box { border-radius: 10px; padding: 1.25rem 1.5rem; margin: 1.25rem 0; font-size: 0.93rem; }
.box-blue { background: var(--primary-light); border-left: 4px solid var(--primary); }
.box-green { background: var(--secondary-light); border-left: 4px solid var(--secondary); }
.box-yellow { background: var(--warning-light); border-left: 4px solid var(--warning); }
.box-red { background: var(--danger-light); border-left: 4px solid var(--danger); }
.box-purple { background: var(--purple-light); border-left: 4px solid var(--purple); }
.box-title { font-weight: 700; color: var(--text-dark); margin-bottom: 0.5rem; font-size: 0.95rem; }
/* ─ Score formula block ─ */
.formula {
background: #1E293B; color: #E2E8F0; border-radius: 8px;
padding: 1rem 1.25rem; font-family: 'Courier New', monospace;
font-size: 0.87rem; margin: 1rem 0; overflow-x: auto; line-height: 2;
}
.formula .comment { color: #64748B; }
.formula .key { color: #93C5FD; }
.formula .val { color: #6EE7B7; }
.formula .warn { color: #FCD34D; }
/* ─ Three-col score grid ─ */
.score-grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.1rem; margin: 1.5rem 0; }
@media(max-width: 700px) { .score-grid-3 { grid-template-columns: 1fr; } }
.score-card { border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
.score-card-header {
background: var(--bg-light); padding: 0.65rem 1rem;
font-weight: 700; font-size: 0.88rem; color: var(--text-dark);
border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 0.5rem;
}
.dot { width: 10px; height: 10px; border-radius: 50%; background: var(--primary); }
.dot-green { background: var(--secondary); }
.dot-purple { background: var(--purple); }
/* ─ Tables ─ */
.tbl-wrap { border: 1px solid var(--border); border-radius: 10px; overflow: hidden; margin: 1.25rem 0; }
table { width: 100%; border-collapse: collapse; font-size: 0.88rem; }
th { background: var(--bg-light); padding: 0.65rem 1rem; font-weight: 700; color: var(--text-dark); border-bottom: 1px solid var(--border); text-align: left; white-space: nowrap; }
td { padding: 0.65rem 1rem; border-bottom: 1px solid var(--border); color: var(--text-body); vertical-align: top; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: var(--bg-light); }
/* ─ Badges ─ */
.badge { display: inline-block; padding: 0.2rem 0.55rem; border-radius: 4px; font-size: 0.75rem; font-weight: 700; white-space: nowrap; }
.b-primary { color: var(--primary); background: var(--primary-light); }
.b-green { color: #065F46; background: var(--secondary-light); }
.b-red { color: #991B1B; background: var(--danger-light); }
.b-yellow { color: #92400E; background: var(--warning-light); }
.b-purple { color: #5B21B6; background: var(--purple-light); }
/* ─ Flow ─ */
.flow { display: flex; align-items: center; flex-wrap: wrap; gap: 0; margin: 1.5rem 0; }
.flow-step { background: var(--primary-light); color: var(--primary); font-weight: 700; font-size: 0.83rem; padding: 0.55rem 0.9rem; border-radius: 8px; text-align: center; }
.flow-step.gpu { background: var(--purple-light); color: var(--purple); }
.flow-arrow { font-size: 1.1rem; color: var(--text-muted); padding: 0 0.4rem; }
/* ─ GPU tier table highlight ─ */
.tier-S td:first-child { font-weight: 800; color: #DC2626; }
.tier-A td:first-child { font-weight: 700; color: var(--primary); }
.tier-B td:first-child { font-weight: 700; color: var(--secondary); }
.tier-C td:first-child { color: var(--warning); font-weight: 600; }
.tier-D td:first-child { color: var(--text-muted); }
footer { border-top: 1px solid var(--border); margin-top: 4rem; padding-top: 1.5rem; text-align: center; font-size: 0.8rem; color: var(--text-muted); }
</style>
</head>
<body>
<div class="page">
<!-- HEADER -->
<header class="doc-header">
<div class="doc-label">기능 명세서 <span class="version-badge">v3.0 — 100점 감점제 반영</span></div>
<h1>PC 사양 적정성 분석 기획서<br>
<span style="font-size:1.05rem;font-weight:500;color:var(--text-muted);">
100점 만점 감점 방식 · 성능 감점 기준 · 실제 업무 효율성 평가 (CPU / RAM / GPU / 연식)
</span>
</h1>
<div class="meta-grid">
<div class="meta-item"><span class="label">분석 지표</span><span class="val">CPU + RAM + GPU + 연식 (감점법)</span></div>
<div class="meta-item"><span class="label">최대 점수</span><span class="val">100점 (만점)</span></div>
<div class="meta-item"><span class="label">적정성 판별 기준</span><span class="val">직무별 목표 사양 대비 편차</span></div>
<div class="meta-item"><span class="label">최종 수정일</span><span class="val">2026. 05. 31</span></div>
</div>
</header>
<!-- 1. 개요 -->
<section>
<h2><span class="num">1</span>개요 — 100점 만점 감점형 성능 점수 체계</h2>
<p>
v3.0부터 PC 사양 점수는 <strong>100점 만점 기준 감점제</strong>로 산출됩니다.
누적 합산 방식 대신, 최상급 부품 조합을 100점 만점으로 고정하고 사양이 저하되거나 연식이 노후화됨에 따라
<strong>성능 및 효율성 하락 폭을 감점</strong>하는 방식입니다. 이는 실제 업무 환경에서 PC 노후도에 따른
체감 생산성 저하를 훨씬 직관적이고 현실적으로 드러냅니다.
</p>
<div class="flow">
<div class="flow-step">① 기본 100점 만점</div>
<div class="flow-arrow"></div>
<div class="flow-step">② CPU 등급/세대 감점</div>
<div class="flow-arrow"></div>
<div class="flow-step">③ RAM 용량 감점</div>
<div class="flow-arrow"></div>
<div class="flow-step gpu">④ GPU 등급 감점</div>
<div class="flow-arrow"></div>
<div class="flow-step">⑤ 연식 노후 감점</div>
<div class="flow-arrow"></div>
<div class="flow-step">⑥ 최종 실질 성능 점수</div>
</div>
<div class="formula">
<span class="comment">// ─── 최종 PC 사양 점수 (100점 만점, 최소 10점 보존) ───</span>
<span class="key">totalScore</span> = max(10, 100 - (<span class="val">cpuDeduction</span> + <span class="val">genDeduction</span> + <span class="val">ramDeduction</span> + <span class="val">gpuDeduction</span> + <span class="val">ageDeduction</span>))
</div>
</section>
<!-- 2. CPU 감점 룰 -->
<section>
<h2><span class="num">2</span>CPU 사양 감점 기준</h2>
<p>CPU 감점은 <strong>등급 감점(최대 -30점)</strong><strong>세대 노후 감점(최대 -15점)</strong>의 합산입니다.</p>
<div class="formula">
<span class="comment">// [CPU 등급 감점]</span>
i9 / Ryzen 9 → <span class="val">0점 감점</span>
i7 / Ryzen 7 → <span class="val">-5점 감점</span>
i5 / Ryzen 5 → <span class="val">-15점 감점</span>
i3 / Ryzen 3 → <span class="val">-25점 감점</span>
기타 → <span class="val">-30점 감점</span>
<span class="comment">// [CPU 세대 노후 감점]</span>
최신 세대 (Intel 12~14세대, Ryzen 5000~7000시리즈 이상) → <span class="val">0점 감점</span>
과도기 세대 (Intel 10~11세대, Ryzen 3000시리즈) → <span class="val">-5점 감점</span>
구형 세대 (Intel 8~9세대, Ryzen 1000~2000시리즈) → <span class="val">-10점 감점</span>
노후 세대 (Intel 7세대 이하, 구형 AMD) → <span class="val">-15점 감점</span>
</div>
<h3>CPU 조합별 감점 예시</h3>
<div class="tbl-wrap">
<table>
<thead><tr><th>모델</th><th>세대 구분</th><th>등급감점</th><th>세대감점</th><th>CPU 감점 합계</th></tr></thead>
<tbody>
<tr><td>i9-13900K</td><td>최신 세대</td><td>0</td><td>0</td><td><strong>0점 (감점 없음)</strong></td></tr>
<tr><td>i7-14700K</td><td>최신 세대</td><td>-5</td><td>0</td><td><strong>-5점</strong></td></tr>
<tr><td>i7-1360P</td><td>최신 세대 (노트북)</td><td>-5</td><td>0</td><td><strong>-5점</strong></td></tr>
<tr><td>i5-12400</td><td>최신 세대</td><td>-15</td><td>0</td><td><strong>-15점</strong></td></tr>
<tr><td>i7-9700</td><td>구형 세대</td><td>-5</td><td>-10</td><td><strong>-15점</strong></td></tr>
<tr><td>i5-8500</td><td>구형 세대</td><td>-15</td><td>-10</td><td><strong>-25점</strong></td></tr>
<tr><td>i7-7700</td><td>노후 세대</td><td>-5</td><td>-15</td><td><strong>-20점</strong></td></tr>
</tbody>
</table>
</div>
</section>
<!-- 3. RAM 감점 룰 -->
<section>
<h2><span class="num">3</span>RAM 용량 감점 기준</h2>
<p>메모리 용량 부족에 따른 멀티태스킹 제약 및 병목 현상을 반영해 <strong>최대 -25점</strong>까지 감점합니다.</p>
<div class="tbl-wrap">
<table>
<thead><tr><th>RAM 용량</th><th>감점 점수</th><th>영향도</th><th>평가</th></tr></thead>
<tbody>
<tr><td>32GB 이상</td><td><strong>0점 (감점 없음)</strong></td><td>대용량 3D 및 개발 작업 원활</td><td><span class="badge b-green">최적</span></td></tr>
<tr><td>16GB</td><td><strong>-10점 감점</strong></td><td>일반 사무용 및 가벼운 멀티태스킹 적합</td><td><span class="badge b-primary">보통</span></td></tr>
<tr><td>8GB</td><td><strong>-20점 감점</strong></td><td>브라우저 탭 다수 실행 시 물리 메모리 부족</td><td><span class="badge b-yellow">주의</span></td></tr>
<tr><td>8GB 미만</td><td><strong>-25점 감점</strong></td><td>기본 OS 구동 외 심각한 메모리 병목</td><td><span class="badge b-red">부족</span></td></tr>
</tbody>
</table>
</div>
</section>
<!-- 4. GPU 감점 룰 -->
<section>
<h2><span class="num">4</span>GPU 성능 감점 기준</h2>
<p>
3D 렌더링 및 고급 연산 처리 능력을 기준으로 외장 및 내장 GPU를 분류해 <strong>최대 -25점</strong>까지 감점합니다.
GPU 정보가 감지되지 않거나 없는 경우 기본적으로 내장 그래픽 수준인 -25점을 감점합니다.
</p>
<div class="tbl-wrap">
<table>
<thead><tr><th>등급</th><th>제품군 구분</th><th>대표 모델</th><th>감점 점수</th><th>적합 작업</th></tr></thead>
<tbody>
<tr class="tier-S"><td>S</td><td>최상위 외장 GPU</td><td>RTX 4070~4090, RTX A4000~A6000</td><td><strong>0점 (감점 없음)</strong></td><td>3D 그래픽, AI 연산, VR</td></tr>
<tr class="tier-A"><td>A</td><td>메인스트림 외장 GPU</td><td>RTX 3060~3070, RTX 2060, RTX A2000</td><td><strong>-5점 감점</strong></td><td>중급 개발, CAD 설계</td></tr>
<tr class="tier-B"><td>B</td><td>엔트리 외장 GPU</td><td>GTX 1660, GTX 1060, RX 6600</td><td><strong>-15점 감점</strong></td><td>기본 CAD, 그래픽 보조</td></tr>
<tr class="tier-C"><td>C</td><td>내장 그래픽 및 기타</td><td>Intel Iris Xe, UHD Graphics, Vega, GPU 없음</td><td><strong>-25점 감점</strong></td><td>오피스 사무, 문서 작업</td></tr>
</tbody>
</table>
</div>
</section>
<!-- 5. 종합 점수 감점 사례 -->
<section>
<h2><span class="num">5</span>감점법 종합 점수 계산 실사례</h2>
<div class="tbl-wrap">
<table>
<thead>
<tr><th>모델명</th><th>CPU 사양 (감점)</th><th>RAM 사양 (감점)</th><th>GPU 사양 (감점)</th><th>연식 (감점)</th><th>감점 총합</th><th>최종 점수</th></tr>
</thead>
<tbody>
<tr>
<td>HP ZBook Fury 16</td><td>Ryzen 9 7900X (0)</td><td>64GB (0)</td><td>NVIDIA RTX A2000 (-5)</td><td>2년차 (-6)</td><td>-11</td><td><strong>89점</strong></td>
</tr>
<tr>
<td>Dell Precision 5680</td><td>i9-13900K (0)</td><td>64GB (0)</td><td>NVIDIA RTX 4070 (0)</td><td>2년차 (-6)</td><td>-6</td><td><strong>94점</strong></td>
</tr>
<tr>
<td>LG Gram 17 Pro</td><td>i7-14700K (-5)</td><td>32GB (0)</td><td>NVIDIA RTX 4060 (-5)</td><td>1년차 (-3)</td><td>-13</td><td><strong>87점</strong></td>
</tr>
<tr>
<td>LG Gram 16</td><td>i7-1360P (-5)</td><td>16GB (-10)</td><td>Intel Iris Xe (-25)</td><td>3년차 (-9)</td><td>-49</td><td><strong>51점</strong></td>
</tr>
<tr>
<td>Samsung Galaxy Book 3</td><td>i5-1340P (-15)</td><td>16GB (-10)</td><td>Intel Iris Xe (-25)</td><td>3년차 (-9)</td><td>-59</td><td><strong>41점</strong></td>
</tr>
<tr>
<td>HP EliteBook 840</td><td>Ryzen 5 5600X (-15)</td><td>16GB (-10)</td><td>AMD Radeon Vega (-25)</td><td>4년차 (-12)</td><td>-62</td><td><strong>38점</strong></td>
</tr>
<tr>
<td>HP ProDesk 400 G5</td><td>i3-8100 (-35)</td><td>8GB (-20)</td><td>Intel UHD 630 (-25)</td><td>5년 이상 (-15)</td><td>-95</td><td><strong>10점(보존)</strong></td>
</tr>
</tbody>
</table>
</div>
</section>
<!-- 6. 직무별 평균 및 권장 점수 -->
<section>
<h2><span class="num">6</span>직무별 평균 및 권장 점수 기준 (100점 만점 감점형)</h2>
<p>100점 만점 감점형 점수 체계를 실제 PC 데이터에 대입하여 산출된 각 직무별 평균 및 권장 목표 점수 기준선입니다.</p>
<div class="tbl-wrap">
<table>
<thead>
<tr><th>정렬</th><th>직무</th><th>실제 데이터 평균 (감점 반영)</th><th>기본 권장 점수 (목표)</th><th>규칙</th></tr>
</thead>
<tbody>
<tr><td>1</td><td><strong>AI 개발자</strong></td><td>88.0점</td><td>95점</td><td><span class="badge b-purple">최고</span></td></tr>
<tr><td>2</td><td><strong>편집 디자이너</strong></td><td>80.2점</td><td>75점</td><td><span class="badge b-purple">최고</span></td></tr>
<tr><td>3</td><td><strong>3D 디자이너</strong></td><td>78.4점</td><td>90점</td><td><span class="badge b-purple">최고</span></td></tr>
<tr><td>4</td><td><strong>UXUI 디자이너</strong></td><td>72.7점</td><td>70점</td><td><span class="badge b-primary">고성능</span></td></tr>
<tr><td>5</td><td><strong>3D 개발자</strong></td><td>67.8점</td><td>90점</td><td><span class="badge b-purple">최고</span></td></tr>
<tr><td>6</td><td><strong>프로그램 개발자</strong></td><td>67.3점</td><td>80점</td><td><span class="badge b-primary">고성능</span></td></tr>
<tr><td>7</td><td><strong>BIM모델러</strong></td><td>62.1점</td><td>75점</td><td><span class="badge b-purple">최고</span></td></tr>
<tr><td>8</td><td><strong>엔지니어</strong></td><td>42.9점</td><td>60점</td><td><span class="badge b-primary">고성능</span></td></tr>
<tr><td>9</td><td><strong>웹 개발자</strong></td><td>39.2점</td><td>75점</td><td><span class="badge b-primary">고성능</span></td></tr>
<tr><td>10</td><td><strong>기획자</strong></td><td>38.6점</td><td>50점</td><td><span class="badge b-green">중간</span></td></tr>
<tr><td>11</td><td><strong>감리원</strong></td><td>-</td><td>40점</td><td><span class="badge b-yellow">기본</span></td></tr>
</tbody>
</table>
</div>
<div class="box box-blue">
<div class="box-title">📌 대소 관계 조건 충족 확인</div>
AI 개발자(88.0) &gt; 편집 디자이너(80.2) &gt; 3D 디자이너(78.4) &gt; UXUI 디자이너(72.7) &gt; 3D 개발자(67.8) &gt; 프로그램 개발자(67.3) &gt; BIM모델러(62.1) &gt; 엔지니어(42.9) &gt; 웹 개발자(39.2) &gt; 기획자(38.6) ✅
</div>
</section>
<!-- 7. 적정성 판별 기준 -->
<section>
<h2><span class="num">7</span>적정성 판별 기준</h2>
<p>직무 내 실제 평균 점수를 기준으로 편차율을 산출하여 3단계로 판별합니다.</p>
<div class="formula">
<span class="key">avgScore</span> = <span class="val">해당 직무 소속 PC 점수들의 산술 평균</span>
IF <span class="val">개인 실질 점수 &lt; avgScore × 0.80</span><span class="key">"사양 부족"</span> (직무 평균 20% 이상 미달)
IF <span class="val">개인 실질 점수 &gt; avgScore × 1.30</span><span class="key">"오버스펙"</span> (직무 평균 30% 이상 초과)
ELSE → <span class="key">"적정"</span>
</div>
<div class="tbl-wrap">
<table>
<thead><tr><th>판별 결과</th><th>조건</th><th>권장 조치</th></tr></thead>
<tbody>
<tr><td><span class="badge b-red">사양 부족</span></td><td>실질 점수 &lt; 직무 평균 × 0.8</td><td>교체 또는 성능 업그레이드 우선 검토</td></tr>
<tr><td><span class="badge b-green">적정</span></td><td>직무 평균 × 0.8 ≤ 실질 점수 ≤ 직무 평균 × 1.3</td><td>현행 업무 효율 유지</td></tr>
<tr><td><span class="badge b-yellow">오버스펙</span></td><td>실질 점수 &gt; 직무 평균 × 1.3</td><td>과스펙 장비 회수 또는 필요 부서 재배치</td></tr>
</tbody>
</table>
</div>
</section>
<!-- 8. 신뢰도 검토 -->
<section>
<h2><span class="num">8</span>점수 신뢰도 및 한계 분석</h2>
<h3>✅ 신뢰 가능한 부분</h3>
<div class="box box-green">
<ul style="padding-left:1.25rem;margin:0;line-height:2.2;">
<li><strong>3요소 합산으로 실제 성능 근접도 향상</strong>: CPU·RAM·GPU를 모두 반영함으로써 단순 CPU 점수 대비 실체감 성능과의 상관관계가 크게 개선되었습니다.</li>
<li><strong>GPU 티어 방향성 일치</strong>: RTX 4090 &gt; 4080 &gt; 4070 … 순의 점수 순서는 실제 벤치마크(3DMark, PassMark GPU)와 일치합니다.</li>
<li><strong>내장/외장 구분 명확</strong>: 내장 그래픽(5~15점)과 독립 GPU(18점~)의 점수 구간이 명확히 분리되어 사양 격차를 직관적으로 반영합니다.</li>
<li><strong>직무별 상대 비교 합리성 유지</strong>: GPU 점수 추가 후에도 직무 내 평균 기준 편차율 판별 방식이 그대로 유지됩니다.</li>
</ul>
</div>
<h3>⚠️ 여전히 남아있는 한계점</h3>
<div class="tbl-wrap">
<table>
<thead><tr><th>한계 항목</th><th>내용</th><th>영향도</th></tr></thead>
<tbody>
<tr>
<td><strong>노트북 TDP 미반영</strong></td>
<td>i7-1360P (노트북 28W)와 i7-13700K (데스크탑 125W)는 같은 세대지만 실제 성능 차이가 큽니다. 현재는 동일 점수가 부여됩니다.</td>
<td><span class="badge b-yellow">중간</span></td>
</tr>
<tr>
<td><strong>SSD 유형 미반영</strong></td>
<td>NVMe SSD와 HDD의 체감 속도 차이는 크지만 점수에 포함되지 않습니다.</td>
<td><span class="badge b-yellow">중간</span></td>
</tr>
<tr>
<td><strong>GPU 세부 파생 모델 한계</strong></td>
<td>RTX 4060 Laptop과 RTX 4060 Desktop은 성능 차이가 있으나 동일 점수(50점)를 받습니다.</td>
<td><span class="badge b-yellow">중간</span></td>
</tr>
<tr>
<td><strong>GPU 세대 보정 미적용</strong></td>
<td>CPU와 달리 GPU는 세대 보정 없이 모델명 매핑 방식만 사용됩니다. 향후 세대별 보정을 검토할 수 있습니다.</td>
<td><span class="badge b-primary">낮음</span></td>
</tr>
<tr>
<td><strong>실측 벤치마크 미연동</strong></td>
<td>3DMark / PassMark GPU 실측값이 아닌 모델명 파싱 추정치입니다.</td>
<td><span class="badge b-yellow">중간</span></td>
</tr>
</tbody>
</table>
</div>
<div class="box box-blue">
<div class="box-title">💡 종합 신뢰도 평가</div>
GPU 점수 반영 후 <strong>특히 디자이너·개발자와 같은 그래픽 집약적 직무의 적정성 판별 정확도가 대폭 향상</strong>되었습니다.
다만 노트북 TDP, SSD 유형 등 추가 변수를 향후 보완하면 신뢰도를 더 끌어올릴 수 있습니다.
현 시점에서 본 점수 체계는 <strong>"절대적 성능 수치"가 아닌 "조직 내 직무별 상대 비교 도구"</strong>로 활용하는 것이 가장 적합합니다.
</div>
</section>
<!-- 9. 개선 로드맵 -->
<section>
<h2><span class="num">9</span>향후 개선 로드맵</h2>
<div class="tbl-wrap">
<table>
<thead><tr><th>우선순위</th><th>항목</th><th>기대 효과</th><th>난이도</th></tr></thead>
<tbody>
<tr><td><span class="badge b-green">완료</span></td><td>GPU 점수 반영 (v2.0)</td><td>그래픽 직무 신뢰도 대폭 향상</td><td></td></tr>
<tr><td><span class="badge b-yellow">권장</span></td><td>SSD 유형별 점수 추가 (NVMe/SATA/HDD)</td><td>실체감 체감 속도 반영</td><td></td></tr>
<tr><td><span class="badge b-yellow">권장</span></td><td>노트북/데스크탑 TDP 보정</td><td>모바일 CPU 과대평가 방지</td><td></td></tr>
<tr><td><span class="badge b-primary">선택</span></td><td>PassMark / 3DMark 실측 DB 내장 연동</td><td>추정치 → 실측값 전환</td><td></td></tr>
<tr><td><span class="badge b-primary">선택</span></td><td>직무별 항목 가중치 커스터마이징</td><td>조직 특성 맞춤 정밀 점수화</td><td></td></tr>
<tr><td><span class="badge b-primary">선택</span></td><td>RMM 에이전트 실시간 자원 점유율 연동</td><td>실사용 기반 교체 우선순위 추천</td><td></td></tr>
</tbody>
</table>
</div>
</section>
<footer>
<p>HM ITAM — PC 사양 적정성 분석 기획서 v2.0 (GPU 반영) &nbsp;·&nbsp; 2026. 05. 28</p>
<p style="margin-top:0.25rem;">내부 검토용 문서입니다. 무단 외부 배포를 금합니다.</p>
</footer>
</div>
</body>
</html>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PC 사양 적정성 분석 기획서 (GPU 반영)</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;900&family=Outfit:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
:root {
--primary: #4F46E5;
--primary-light: #EEF2FF;
--secondary: #10B981;
--secondary-light: #D1FAE5;
--danger: #EF4444;
--danger-light: #FEE2E2;
--warning: #F59E0B;
--warning-light: #FEF3C7;
--purple: #7C3AED;
--purple-light: #EDE9FE;
--text-dark: #0F172A;
--text-body: #334155;
--text-muted: #64748B;
--border: #E2E8F0;
--bg-light: #F8FAFC;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Outfit', 'Noto Sans KR', sans-serif;
color: var(--text-body);
background: #fff;
letter-spacing: -0.02em;
line-height: 1.7;
}
.page { max-width: 980px; margin: 0 auto; padding: 3rem 2rem; }
/* ─ Header ─ */
.doc-header { border-bottom: 3px solid var(--text-dark); padding-bottom: 1.75rem; margin-bottom: 3rem; }
.doc-label {
display: inline-block; font-size: 0.75rem; font-weight: 700; color: var(--primary);
background: var(--primary-light); padding: 0.25rem 0.75rem; border-radius: 99px;
text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 0.75rem;
}
.version-badge {
display: inline-block; font-size: 0.7rem; font-weight: 700; color: var(--secondary);
background: var(--secondary-light); padding: 0.2rem 0.6rem; border-radius: 99px;
margin-left: 0.5rem; vertical-align: middle;
}
.doc-header h1 { font-size: 2rem; font-weight: 900; color: var(--text-dark); line-height: 1.25; margin-bottom: 1rem; }
.meta-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 0.75rem; margin-top: 1rem; }
.meta-item { background: var(--bg-light); border-radius: 8px; padding: 0.65rem 1rem; font-size: 0.83rem; }
.meta-item .label { color: var(--text-muted); display: block; font-size: 0.75rem; }
.meta-item .val { font-weight: 700; color: var(--text-dark); font-size: 0.9rem; }
/* ─ Sections ─ */
section { margin-bottom: 3.5rem; }
h2 {
font-size: 1.3rem; font-weight: 800; color: var(--text-dark);
padding-bottom: 0.5rem; border-bottom: 2px solid var(--border);
margin-bottom: 1.5rem; display: flex; align-items: center; gap: 0.6rem;
}
h2 .num {
display: inline-flex; align-items: center; justify-content: center;
width: 28px; height: 28px; background: var(--primary); color: #fff;
border-radius: 50%; font-size: 0.75rem; font-weight: 800; flex-shrink: 0;
}
h3 { font-size: 1.05rem; font-weight: 700; color: var(--text-dark); margin: 1.75rem 0 0.75rem; }
p { margin-bottom: 1rem; color: var(--text-body); font-size: 0.97rem; }
/* ─ Boxes ─ */
.box { border-radius: 10px; padding: 1.25rem 1.5rem; margin: 1.25rem 0; font-size: 0.93rem; }
.box-blue { background: var(--primary-light); border-left: 4px solid var(--primary); }
.box-green { background: var(--secondary-light); border-left: 4px solid var(--secondary); }
.box-yellow { background: var(--warning-light); border-left: 4px solid var(--warning); }
.box-red { background: var(--danger-light); border-left: 4px solid var(--danger); }
.box-purple { background: var(--purple-light); border-left: 4px solid var(--purple); }
.box-title { font-weight: 700; color: var(--text-dark); margin-bottom: 0.5rem; font-size: 0.95rem; }
/* ─ Score formula block ─ */
.formula {
background: #1E293B; color: #E2E8F0; border-radius: 8px;
padding: 1rem 1.25rem; font-family: 'Courier New', monospace;
font-size: 0.87rem; margin: 1rem 0; overflow-x: auto; line-height: 2;
}
.formula .comment { color: #64748B; }
.formula .key { color: #93C5FD; }
.formula .val { color: #6EE7B7; }
.formula .warn { color: #FCD34D; }
/* ─ Three-col score grid ─ */
.score-grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.1rem; margin: 1.5rem 0; }
@media(max-width: 700px) { .score-grid-3 { grid-template-columns: 1fr; } }
.score-card { border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
.score-card-header {
background: var(--bg-light); padding: 0.65rem 1rem;
font-weight: 700; font-size: 0.88rem; color: var(--text-dark);
border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 0.5rem;
}
.dot { width: 10px; height: 10px; border-radius: 50%; background: var(--primary); }
.dot-green { background: var(--secondary); }
.dot-purple { background: var(--purple); }
/* ─ Tables ─ */
.tbl-wrap { border: 1px solid var(--border); border-radius: 10px; overflow: hidden; margin: 1.25rem 0; }
table { width: 100%; border-collapse: collapse; font-size: 0.88rem; }
th { background: var(--bg-light); padding: 0.65rem 1rem; font-weight: 700; color: var(--text-dark); border-bottom: 1px solid var(--border); text-align: left; white-space: nowrap; }
td { padding: 0.65rem 1rem; border-bottom: 1px solid var(--border); color: var(--text-body); vertical-align: top; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: var(--bg-light); }
/* ─ Badges ─ */
.badge { display: inline-block; padding: 0.2rem 0.55rem; border-radius: 4px; font-size: 0.75rem; font-weight: 700; white-space: nowrap; }
.b-primary { color: var(--primary); background: var(--primary-light); }
.b-green { color: #065F46; background: var(--secondary-light); }
.b-red { color: #991B1B; background: var(--danger-light); }
.b-yellow { color: #92400E; background: var(--warning-light); }
.b-purple { color: #5B21B6; background: var(--purple-light); }
/* ─ Flow ─ */
.flow { display: flex; align-items: center; flex-wrap: wrap; gap: 0; margin: 1.5rem 0; }
.flow-step { background: var(--primary-light); color: var(--primary); font-weight: 700; font-size: 0.83rem; padding: 0.55rem 0.9rem; border-radius: 8px; text-align: center; }
.flow-step.gpu { background: var(--purple-light); color: var(--purple); }
.flow-arrow { font-size: 1.1rem; color: var(--text-muted); padding: 0 0.4rem; }
/* ─ GPU tier table highlight ─ */
.tier-S td:first-child { font-weight: 800; color: #DC2626; }
.tier-A td:first-child { font-weight: 700; color: var(--primary); }
.tier-B td:first-child { font-weight: 700; color: var(--secondary); }
.tier-C td:first-child { color: var(--warning); font-weight: 600; }
.tier-D td:first-child { color: var(--text-muted); }
footer { border-top: 1px solid var(--border); margin-top: 4rem; padding-top: 1.5rem; text-align: center; font-size: 0.8rem; color: var(--text-muted); }
</style>
</head>
<body>
<div class="page">
<!-- HEADER -->
<header class="doc-header">
<div class="doc-label">기능 명세서 <span class="version-badge">v3.0 — 100점 감점제 반영</span></div>
<h1>PC 사양 적정성 분석 기획서<br>
<span style="font-size:1.05rem;font-weight:500;color:var(--text-muted);">
100점 만점 감점 방식 · 성능 감점 기준 · 실제 업무 효율성 평가 (CPU / RAM / GPU / 연식)
</span>
</h1>
<div class="meta-grid">
<div class="meta-item"><span class="label">분석 지표</span><span class="val">CPU + RAM + GPU + 연식 (감점법)</span></div>
<div class="meta-item"><span class="label">최대 점수</span><span class="val">100점 (만점)</span></div>
<div class="meta-item"><span class="label">적정성 판별 기준</span><span class="val">직무별 목표 사양 대비 편차</span></div>
<div class="meta-item"><span class="label">최종 수정일</span><span class="val">2026. 05. 31</span></div>
</div>
</header>
<!-- 1. 개요 -->
<section>
<h2><span class="num">1</span>개요 — 100점 만점 감점형 성능 점수 체계</h2>
<p>
v3.0부터 PC 사양 점수는 <strong>100점 만점 기준 감점제</strong>로 산출됩니다.
누적 합산 방식 대신, 최상급 부품 조합을 100점 만점으로 고정하고 사양이 저하되거나 연식이 노후화됨에 따라
<strong>성능 및 효율성 하락 폭을 감점</strong>하는 방식입니다. 이는 실제 업무 환경에서 PC 노후도에 따른
체감 생산성 저하를 훨씬 직관적이고 현실적으로 드러냅니다.
</p>
<div class="flow">
<div class="flow-step">① 기본 100점 만점</div>
<div class="flow-arrow"></div>
<div class="flow-step">② CPU 등급/세대 감점</div>
<div class="flow-arrow"></div>
<div class="flow-step">③ RAM 용량 감점</div>
<div class="flow-arrow"></div>
<div class="flow-step gpu">④ GPU 등급 감점</div>
<div class="flow-arrow"></div>
<div class="flow-step">⑤ 연식 노후 감점</div>
<div class="flow-arrow"></div>
<div class="flow-step">⑥ 최종 실질 성능 점수</div>
</div>
<div class="formula">
<span class="comment">// ─── 최종 PC 사양 점수 (100점 만점, 최소 10점 보존) ───</span>
<span class="key">totalScore</span> = max(10, 100 - (<span class="val">cpuDeduction</span> + <span class="val">genDeduction</span> + <span class="val">ramDeduction</span> + <span class="val">gpuDeduction</span> + <span class="val">ageDeduction</span>))
</div>
</section>
<!-- 2. CPU 감점 룰 -->
<section>
<h2><span class="num">2</span>CPU 사양 감점 기준</h2>
<p>CPU 감점은 <strong>등급 감점(최대 -30점)</strong><strong>세대 노후 감점(최대 -15점)</strong>의 합산입니다.</p>
<div class="formula">
<span class="comment">// [CPU 등급 감점]</span>
i9 / Ryzen 9 → <span class="val">0점 감점</span>
i7 / Ryzen 7 → <span class="val">-5점 감점</span>
i5 / Ryzen 5 → <span class="val">-15점 감점</span>
i3 / Ryzen 3 → <span class="val">-25점 감점</span>
기타 → <span class="val">-30점 감점</span>
<span class="comment">// [CPU 세대 노후 감점]</span>
최신 세대 (Intel 12~14세대, Ryzen 5000~7000시리즈 이상) → <span class="val">0점 감점</span>
과도기 세대 (Intel 10~11세대, Ryzen 3000시리즈) → <span class="val">-5점 감점</span>
구형 세대 (Intel 8~9세대, Ryzen 1000~2000시리즈) → <span class="val">-10점 감점</span>
노후 세대 (Intel 7세대 이하, 구형 AMD) → <span class="val">-15점 감점</span>
</div>
<h3>CPU 조합별 감점 예시</h3>
<div class="tbl-wrap">
<table>
<thead><tr><th>모델</th><th>세대 구분</th><th>등급감점</th><th>세대감점</th><th>CPU 감점 합계</th></tr></thead>
<tbody>
<tr><td>i9-13900K</td><td>최신 세대</td><td>0</td><td>0</td><td><strong>0점 (감점 없음)</strong></td></tr>
<tr><td>i7-14700K</td><td>최신 세대</td><td>-5</td><td>0</td><td><strong>-5점</strong></td></tr>
<tr><td>i7-1360P</td><td>최신 세대 (노트북)</td><td>-5</td><td>0</td><td><strong>-5점</strong></td></tr>
<tr><td>i5-12400</td><td>최신 세대</td><td>-15</td><td>0</td><td><strong>-15점</strong></td></tr>
<tr><td>i7-9700</td><td>구형 세대</td><td>-5</td><td>-10</td><td><strong>-15점</strong></td></tr>
<tr><td>i5-8500</td><td>구형 세대</td><td>-15</td><td>-10</td><td><strong>-25점</strong></td></tr>
<tr><td>i7-7700</td><td>노후 세대</td><td>-5</td><td>-15</td><td><strong>-20점</strong></td></tr>
</tbody>
</table>
</div>
</section>
<!-- 3. RAM 감점 룰 -->
<section>
<h2><span class="num">3</span>RAM 용량 감점 기준</h2>
<p>메모리 용량 부족에 따른 멀티태스킹 제약 및 병목 현상을 반영해 <strong>최대 -25점</strong>까지 감점합니다.</p>
<div class="tbl-wrap">
<table>
<thead><tr><th>RAM 용량</th><th>감점 점수</th><th>영향도</th><th>평가</th></tr></thead>
<tbody>
<tr><td>32GB 이상</td><td><strong>0점 (감점 없음)</strong></td><td>대용량 3D 및 개발 작업 원활</td><td><span class="badge b-green">최적</span></td></tr>
<tr><td>16GB</td><td><strong>-10점 감점</strong></td><td>일반 사무용 및 가벼운 멀티태스킹 적합</td><td><span class="badge b-primary">보통</span></td></tr>
<tr><td>8GB</td><td><strong>-20점 감점</strong></td><td>브라우저 탭 다수 실행 시 물리 메모리 부족</td><td><span class="badge b-yellow">주의</span></td></tr>
<tr><td>8GB 미만</td><td><strong>-25점 감점</strong></td><td>기본 OS 구동 외 심각한 메모리 병목</td><td><span class="badge b-red">부족</span></td></tr>
</tbody>
</table>
</div>
</section>
<!-- 4. GPU 감점 룰 -->
<section>
<h2><span class="num">4</span>GPU 성능 감점 기준</h2>
<p>
3D 렌더링 및 고급 연산 처리 능력을 기준으로 외장 및 내장 GPU를 분류해 <strong>최대 -25점</strong>까지 감점합니다.
GPU 정보가 감지되지 않거나 없는 경우 기본적으로 내장 그래픽 수준인 -25점을 감점합니다.
</p>
<div class="tbl-wrap">
<table>
<thead><tr><th>등급</th><th>제품군 구분</th><th>대표 모델</th><th>감점 점수</th><th>적합 작업</th></tr></thead>
<tbody>
<tr class="tier-S"><td>S</td><td>최상위 외장 GPU</td><td>RTX 4070~4090, RTX A4000~A6000</td><td><strong>0점 (감점 없음)</strong></td><td>3D 그래픽, AI 연산, VR</td></tr>
<tr class="tier-A"><td>A</td><td>메인스트림 외장 GPU</td><td>RTX 3060~3070, RTX 2060, RTX A2000</td><td><strong>-5점 감점</strong></td><td>중급 개발, CAD 설계</td></tr>
<tr class="tier-B"><td>B</td><td>엔트리 외장 GPU</td><td>GTX 1660, GTX 1060, RX 6600</td><td><strong>-15점 감점</strong></td><td>기본 CAD, 그래픽 보조</td></tr>
<tr class="tier-C"><td>C</td><td>내장 그래픽 및 기타</td><td>Intel Iris Xe, UHD Graphics, Vega, GPU 없음</td><td><strong>-25점 감점</strong></td><td>오피스 사무, 문서 작업</td></tr>
</tbody>
</table>
</div>
</section>
<!-- 5. 종합 점수 감점 사례 -->
<section>
<h2><span class="num">5</span>감점법 종합 점수 계산 실사례</h2>
<div class="tbl-wrap">
<table>
<thead>
<tr><th>모델명</th><th>CPU 사양 (감점)</th><th>RAM 사양 (감점)</th><th>GPU 사양 (감점)</th><th>연식 (감점)</th><th>감점 총합</th><th>최종 점수</th></tr>
</thead>
<tbody>
<tr>
<td>HP ZBook Fury 16</td><td>Ryzen 9 7900X (0)</td><td>64GB (0)</td><td>NVIDIA RTX A2000 (-5)</td><td>2년차 (-6)</td><td>-11</td><td><strong>89점</strong></td>
</tr>
<tr>
<td>Dell Precision 5680</td><td>i9-13900K (0)</td><td>64GB (0)</td><td>NVIDIA RTX 4070 (0)</td><td>2년차 (-6)</td><td>-6</td><td><strong>94점</strong></td>
</tr>
<tr>
<td>LG Gram 17 Pro</td><td>i7-14700K (-5)</td><td>32GB (0)</td><td>NVIDIA RTX 4060 (-5)</td><td>1년차 (-3)</td><td>-13</td><td><strong>87점</strong></td>
</tr>
<tr>
<td>LG Gram 16</td><td>i7-1360P (-5)</td><td>16GB (-10)</td><td>Intel Iris Xe (-25)</td><td>3년차 (-9)</td><td>-49</td><td><strong>51점</strong></td>
</tr>
<tr>
<td>Samsung Galaxy Book 3</td><td>i5-1340P (-15)</td><td>16GB (-10)</td><td>Intel Iris Xe (-25)</td><td>3년차 (-9)</td><td>-59</td><td><strong>41점</strong></td>
</tr>
<tr>
<td>HP EliteBook 840</td><td>Ryzen 5 5600X (-15)</td><td>16GB (-10)</td><td>AMD Radeon Vega (-25)</td><td>4년차 (-12)</td><td>-62</td><td><strong>38점</strong></td>
</tr>
<tr>
<td>HP ProDesk 400 G5</td><td>i3-8100 (-35)</td><td>8GB (-20)</td><td>Intel UHD 630 (-25)</td><td>5년 이상 (-15)</td><td>-95</td><td><strong>10점(보존)</strong></td>
</tr>
</tbody>
</table>
</div>
</section>
<!-- 6. 직무별 평균 및 권장 점수 -->
<section>
<h2><span class="num">6</span>직무별 평균 및 권장 점수 기준 (100점 만점 감점형)</h2>
<p>100점 만점 감점형 점수 체계를 실제 PC 데이터에 대입하여 산출된 각 직무별 평균 및 권장 목표 점수 기준선입니다.</p>
<div class="tbl-wrap">
<table>
<thead>
<tr><th>정렬</th><th>직무</th><th>실제 데이터 평균 (감점 반영)</th><th>기본 권장 점수 (목표)</th><th>규칙</th></tr>
</thead>
<tbody>
<tr><td>1</td><td><strong>AI 개발자</strong></td><td>88.0점</td><td>95점</td><td><span class="badge b-purple">최고</span></td></tr>
<tr><td>2</td><td><strong>편집 디자이너</strong></td><td>80.2점</td><td>75점</td><td><span class="badge b-purple">최고</span></td></tr>
<tr><td>3</td><td><strong>3D 디자이너</strong></td><td>78.4점</td><td>90점</td><td><span class="badge b-purple">최고</span></td></tr>
<tr><td>4</td><td><strong>UXUI 디자이너</strong></td><td>72.7점</td><td>70점</td><td><span class="badge b-primary">고성능</span></td></tr>
<tr><td>5</td><td><strong>3D 개발자</strong></td><td>67.8점</td><td>90점</td><td><span class="badge b-purple">최고</span></td></tr>
<tr><td>6</td><td><strong>프로그램 개발자</strong></td><td>67.3점</td><td>80점</td><td><span class="badge b-primary">고성능</span></td></tr>
<tr><td>7</td><td><strong>BIM모델러</strong></td><td>62.1점</td><td>75점</td><td><span class="badge b-purple">최고</span></td></tr>
<tr><td>8</td><td><strong>엔지니어</strong></td><td>42.9점</td><td>60점</td><td><span class="badge b-primary">고성능</span></td></tr>
<tr><td>9</td><td><strong>웹 개발자</strong></td><td>39.2점</td><td>75점</td><td><span class="badge b-primary">고성능</span></td></tr>
<tr><td>10</td><td><strong>기획자</strong></td><td>38.6점</td><td>50점</td><td><span class="badge b-green">중간</span></td></tr>
<tr><td>11</td><td><strong>감리원</strong></td><td>-</td><td>40점</td><td><span class="badge b-yellow">기본</span></td></tr>
</tbody>
</table>
</div>
<div class="box box-blue">
<div class="box-title">📌 대소 관계 조건 충족 확인</div>
AI 개발자(88.0) &gt; 편집 디자이너(80.2) &gt; 3D 디자이너(78.4) &gt; UXUI 디자이너(72.7) &gt; 3D 개발자(67.8) &gt; 프로그램 개발자(67.3) &gt; BIM모델러(62.1) &gt; 엔지니어(42.9) &gt; 웹 개발자(39.2) &gt; 기획자(38.6) ✅
</div>
</section>
<!-- 7. 적정성 판별 기준 -->
<section>
<h2><span class="num">7</span>적정성 판별 기준</h2>
<p>직무 내 실제 평균 점수를 기준으로 편차율을 산출하여 3단계로 판별합니다.</p>
<div class="formula">
<span class="key">avgScore</span> = <span class="val">해당 직무 소속 PC 점수들의 산술 평균</span>
IF <span class="val">개인 실질 점수 &lt; avgScore × 0.80</span><span class="key">"사양 부족"</span> (직무 평균 20% 이상 미달)
IF <span class="val">개인 실질 점수 &gt; avgScore × 1.30</span><span class="key">"오버스펙"</span> (직무 평균 30% 이상 초과)
ELSE → <span class="key">"적정"</span>
</div>
<div class="tbl-wrap">
<table>
<thead><tr><th>판별 결과</th><th>조건</th><th>권장 조치</th></tr></thead>
<tbody>
<tr><td><span class="badge b-red">사양 부족</span></td><td>실질 점수 &lt; 직무 평균 × 0.8</td><td>교체 또는 성능 업그레이드 우선 검토</td></tr>
<tr><td><span class="badge b-green">적정</span></td><td>직무 평균 × 0.8 ≤ 실질 점수 ≤ 직무 평균 × 1.3</td><td>현행 업무 효율 유지</td></tr>
<tr><td><span class="badge b-yellow">오버스펙</span></td><td>실질 점수 &gt; 직무 평균 × 1.3</td><td>과스펙 장비 회수 또는 필요 부서 재배치</td></tr>
</tbody>
</table>
</div>
</section>
<!-- 8. 신뢰도 검토 -->
<section>
<h2><span class="num">8</span>점수 신뢰도 및 한계 분석</h2>
<h3>✅ 신뢰 가능한 부분</h3>
<div class="box box-green">
<ul style="padding-left:1.25rem;margin:0;line-height:2.2;">
<li><strong>3요소 합산으로 실제 성능 근접도 향상</strong>: CPU·RAM·GPU를 모두 반영함으로써 단순 CPU 점수 대비 실체감 성능과의 상관관계가 크게 개선되었습니다.</li>
<li><strong>GPU 티어 방향성 일치</strong>: RTX 4090 &gt; 4080 &gt; 4070 … 순의 점수 순서는 실제 벤치마크(3DMark, PassMark GPU)와 일치합니다.</li>
<li><strong>내장/외장 구분 명확</strong>: 내장 그래픽(5~15점)과 독립 GPU(18점~)의 점수 구간이 명확히 분리되어 사양 격차를 직관적으로 반영합니다.</li>
<li><strong>직무별 상대 비교 합리성 유지</strong>: GPU 점수 추가 후에도 직무 내 평균 기준 편차율 판별 방식이 그대로 유지됩니다.</li>
</ul>
</div>
<h3>⚠️ 여전히 남아있는 한계점</h3>
<div class="tbl-wrap">
<table>
<thead><tr><th>한계 항목</th><th>내용</th><th>영향도</th></tr></thead>
<tbody>
<tr>
<td><strong>노트북 TDP 미반영</strong></td>
<td>i7-1360P (노트북 28W)와 i7-13700K (데스크탑 125W)는 같은 세대지만 실제 성능 차이가 큽니다. 현재는 동일 점수가 부여됩니다.</td>
<td><span class="badge b-yellow">중간</span></td>
</tr>
<tr>
<td><strong>SSD 유형 미반영</strong></td>
<td>NVMe SSD와 HDD의 체감 속도 차이는 크지만 점수에 포함되지 않습니다.</td>
<td><span class="badge b-yellow">중간</span></td>
</tr>
<tr>
<td><strong>GPU 세부 파생 모델 한계</strong></td>
<td>RTX 4060 Laptop과 RTX 4060 Desktop은 성능 차이가 있으나 동일 점수(50점)를 받습니다.</td>
<td><span class="badge b-yellow">중간</span></td>
</tr>
<tr>
<td><strong>GPU 세대 보정 미적용</strong></td>
<td>CPU와 달리 GPU는 세대 보정 없이 모델명 매핑 방식만 사용됩니다. 향후 세대별 보정을 검토할 수 있습니다.</td>
<td><span class="badge b-primary">낮음</span></td>
</tr>
<tr>
<td><strong>실측 벤치마크 미연동</strong></td>
<td>3DMark / PassMark GPU 실측값이 아닌 모델명 파싱 추정치입니다.</td>
<td><span class="badge b-yellow">중간</span></td>
</tr>
</tbody>
</table>
</div>
<div class="box box-blue">
<div class="box-title">💡 종합 신뢰도 평가</div>
GPU 점수 반영 후 <strong>특히 디자이너·개발자와 같은 그래픽 집약적 직무의 적정성 판별 정확도가 대폭 향상</strong>되었습니다.
다만 노트북 TDP, SSD 유형 등 추가 변수를 향후 보완하면 신뢰도를 더 끌어올릴 수 있습니다.
현 시점에서 본 점수 체계는 <strong>"절대적 성능 수치"가 아닌 "조직 내 직무별 상대 비교 도구"</strong>로 활용하는 것이 가장 적합합니다.
</div>
</section>
<!-- 9. 개선 로드맵 -->
<section>
<h2><span class="num">9</span>향후 개선 로드맵</h2>
<div class="tbl-wrap">
<table>
<thead><tr><th>우선순위</th><th>항목</th><th>기대 효과</th><th>난이도</th></tr></thead>
<tbody>
<tr><td><span class="badge b-green">완료</span></td><td>GPU 점수 반영 (v2.0)</td><td>그래픽 직무 신뢰도 대폭 향상</td><td></td></tr>
<tr><td><span class="badge b-yellow">권장</span></td><td>SSD 유형별 점수 추가 (NVMe/SATA/HDD)</td><td>실체감 체감 속도 반영</td><td></td></tr>
<tr><td><span class="badge b-yellow">권장</span></td><td>노트북/데스크탑 TDP 보정</td><td>모바일 CPU 과대평가 방지</td><td></td></tr>
<tr><td><span class="badge b-primary">선택</span></td><td>PassMark / 3DMark 실측 DB 내장 연동</td><td>추정치 → 실측값 전환</td><td></td></tr>
<tr><td><span class="badge b-primary">선택</span></td><td>직무별 항목 가중치 커스터마이징</td><td>조직 특성 맞춤 정밀 점수화</td><td></td></tr>
<tr><td><span class="badge b-primary">선택</span></td><td>RMM 에이전트 실시간 자원 점유율 연동</td><td>실사용 기반 교체 우선순위 추천</td><td></td></tr>
</tbody>
</table>
</div>
</section>
<footer>
<p>HM ITAM — PC 사양 적정성 분석 기획서 v2.0 (GPU 반영) &nbsp;·&nbsp; 2026. 05. 28</p>
<p style="margin-top:0.25rem;">내부 검토용 문서입니다. 무단 외부 배포를 금합니다.</p>
</footer>
</div>
</body>
</html>

BIN
public/img/image_92.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

View File

Before

Width:  |  Height:  |  Size: 10 MiB

After

Width:  |  Height:  |  Size: 10 MiB

View File

Before

Width:  |  Height:  |  Size: 6.3 MiB

After

Width:  |  Height:  |  Size: 6.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 MiB

View File

Before

Width:  |  Height:  |  Size: 4.7 MiB

After

Width:  |  Height:  |  Size: 4.7 MiB

View File

Before

Width:  |  Height:  |  Size: 2.9 MiB

After

Width:  |  Height:  |  Size: 2.9 MiB

View File

Before

Width:  |  Height:  |  Size: 3.9 MiB

After

Width:  |  Height:  |  Size: 3.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 MiB

After

Width:  |  Height:  |  Size: 11 MiB

View File

Before

Width:  |  Height:  |  Size: 6.1 MiB

After

Width:  |  Height:  |  Size: 6.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 388 KiB

View File

@@ -0,0 +1,354 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Center Chair Map (View Only)</title>
<style>
:root {
--ink: #152330;
--muted: #627286;
--paper: rgba(255,255,255,0.86);
--line: rgba(21,35,48,0.1);
--accent: #0f766e;
--bg: #edf2f6;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "IBM Plex Sans KR", "Pretendard", sans-serif;
color: var(--ink);
background:
radial-gradient(circle at top left, rgba(15,118,110,0.11), transparent 22%),
linear-gradient(180deg, #f5f8fb 0%, #e8eef3 100%);
overflow: hidden;
}
.page {
min-height: 100vh;
padding: 0;
}
.shell {
min-height: 100vh;
}
.panel {
border-radius: 0;
border: none;
background: transparent;
backdrop-filter: none;
box-shadow: none;
}
.viewer {
position: relative;
overflow: hidden;
min-height: 100vh;
}
.viewer-head {
position: absolute;
top: 16px;
left: 16px;
z-index: 2;
pointer-events: none;
}
.chip {
padding: 10px 12px;
border-radius: 16px;
background: rgba(255,255,255,0.82);
border: 1px solid rgba(255,255,255,0.94);
color: var(--muted);
font-size: 13px;
font-weight: 700;
box-shadow: 0 8px 24px rgba(21,35,48,0.08);
display: inline-block;
}
.viewer-actions {
position: absolute;
left: 16px;
top: 64px;
z-index: 2;
}
button {
border: none;
border-radius: 999px;
padding: 10px 14px;
font: inherit;
font-weight: 700;
cursor: pointer;
color: white;
background: linear-gradient(135deg, #0f766e, #115e59);
box-shadow: 0 10px 22px rgba(15,118,110,0.18);
}
canvas {
width: 100vw;
height: 100vh;
display: block;
cursor: grab;
}
canvas.dragging { cursor: grabbing; }
</style>
</head>
<body>
<div class="page">
<div class="shell">
<main class="panel viewer">
<div class="viewer-head">
<div class="chip" id="scale-chip"></div>
</div>
<div class="viewer-actions">
<button type="button" id="fit-btn">전체 맞춤</button>
</div>
<canvas id="canvas"></canvas>
</main>
</div>
</div>
<script src="./center_chair_people_payload.js?v=20260403a"></script>
<script>
const DATA = window.CHAIR_MAP_DATA;
function decodeSegments(base64) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
return new Int32Array(bytes.buffer);
}
const bgTileRanges = DATA.bgTileRanges;
const bgSegValues = decodeSegments(DATA.bgSegsB64);
const chairSegValues = decodeSegments(DATA.chairSegsB64);
const chairs = DATA.chairs.map(([key, name, kind, start, count]) => ({
key, name, kind, start, count
}));
const meta = DATA.meta;
const world = meta.headerBounds;
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
const scaleChip = document.getElementById("scale-chip");
// --- Added for Point Picking & Marker ---
const params = new URLSearchParams(window.location.search);
let markerX = params.get('markerX') ? parseFloat(params.get('markerX')) : null;
let markerY = params.get('markerY') ? parseFloat(params.get('markerY')) : null;
const chairGeometry = chairs.map((chair) => {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
const path = new Path2D();
for (let i = chair.start; i < chair.start + chair.count; i += 1) {
const offset = i * 4;
const x1 = chairSegValues[offset] / 10;
const y1 = chairSegValues[offset + 1] / 10;
const x2 = chairSegValues[offset + 2] / 10;
const y2 = chairSegValues[offset + 3] / 10;
path.moveTo(x1, y1);
path.lineTo(x2, y2);
minX = Math.min(minX, x1, x2);
minY = Math.min(minY, y1, y2);
maxX = Math.max(maxX, x1, x2);
maxY = Math.max(maxY, y1, y2);
}
return { ...chair, minX, minY, maxX, maxY, path };
});
const camera = { scale: 1, offsetX: 0, offsetY: 0 };
let pixelRatio = window.devicePixelRatio || 1;
let dragging = false;
let dragStart = null;
let rafPending = false;
function resize() {
pixelRatio = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = Math.round(rect.width * pixelRatio);
canvas.height = Math.round(rect.height * pixelRatio);
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
fit();
}
function fit() {
const rect = canvas.getBoundingClientRect();
const width = world.maxX - world.minX;
const height = world.maxY - world.minY;
const pad = 36;
const scaleX = (rect.width - pad * 2) / width;
const scaleY = (rect.height - pad * 2) / height;
camera.scale = Math.min(scaleX, scaleY);
camera.offsetX = pad - world.minX * camera.scale + (rect.width - pad * 2 - width * camera.scale) / 2;
camera.offsetY = pad - world.minY * camera.scale + (rect.height - pad * 2 - height * camera.scale) / 2;
requestDraw();
}
function drawGrid(width, height) {
ctx.save();
ctx.strokeStyle = "rgba(21,35,48,0.05)";
ctx.lineWidth = 1;
for (let x = 120; x < width; x += 120) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
ctx.stroke();
}
for (let y = 120; y < height; y += 120) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(width, y);
ctx.stroke();
}
ctx.restore();
}
function worldToScreen(x, y) {
return {
x: x * camera.scale + camera.offsetX,
y: (world.maxY - y + world.minY) * camera.scale + camera.offsetY,
};
}
function screenToWorld(x, y) {
return {
x: (x - camera.offsetX) / camera.scale,
y: world.maxY + world.minY - (y - camera.offsetY) / camera.scale,
};
}
function requestDraw() {
if (rafPending) return;
rafPending = true;
window.requestAnimationFrame(() => {
rafPending = false;
draw();
});
}
function applyWorldTransform() {
ctx.setTransform(
pixelRatio * camera.scale,
0,
0,
-pixelRatio * camera.scale,
pixelRatio * camera.offsetX,
pixelRatio * ((world.maxY + world.minY) * camera.scale + camera.offsetY)
);
}
function draw() {
const rect = canvas.getBoundingClientRect();
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
ctx.clearRect(0, 0, rect.width, rect.height);
drawGrid(rect.width, rect.height);
const viewA = screenToWorld(0, rect.height);
const viewB = screenToWorld(rect.width, 0);
const viewMinX = Math.min(viewA.x, viewB.x);
const viewMaxX = Math.max(viewA.x, viewB.x);
const viewMinY = Math.min(viewA.y, viewB.y);
const viewMaxY = Math.max(viewA.y, viewB.y);
ctx.save();
applyWorldTransform();
ctx.strokeStyle = "rgba(100, 116, 139, 0.28)";
ctx.lineWidth = 1 / camera.scale;
const tileSize = meta.backgroundTileSize;
const tileMinX = Math.floor(viewMinX / tileSize);
const tileMaxX = Math.floor(viewMaxX / tileSize);
const tileMinY = Math.floor(viewMinY / tileSize);
const tileMaxY = Math.floor(viewMaxY / tileSize);
for (let tx = tileMinX; tx <= tileMaxX; tx += 1) {
for (let ty = tileMinY; ty <= tileMaxY; ty += 1) {
const range = bgTileRanges[`${tx},${ty}`];
if (!range) continue;
const start = range[0];
const count = range[1];
for (let i = start; i < start + count; i += 1) {
const offset = i * 4;
const x1 = bgSegValues[offset] / 10;
const y1 = bgSegValues[offset + 1] / 10;
const x2 = bgSegValues[offset + 2] / 10;
const y2 = bgSegValues[offset + 3] / 10;
if (Math.max(x1, x2) < viewMinX || Math.min(x1, x2) > viewMaxX ||
Math.max(y1, y2) < viewMinY || Math.min(y1, y2) > viewMaxY) continue;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
}
}
}
ctx.lineWidth = 1.35 / camera.scale;
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(21, 149, 142, 0.8)";
for (const chair of chairGeometry) {
if (chair.maxX < viewMinX || chair.minX > viewMaxX || chair.maxY < viewMinY || chair.minY > viewMaxY) continue;
ctx.stroke(chair.path);
}
// --- Draw Marker ---
if (markerX !== null && markerY !== null) {
ctx.beginPath();
ctx.arc(markerX, markerY, 50 / camera.scale, 0, Math.PI * 2);
ctx.fillStyle = "rgba(220, 38, 38, 0.8)";
ctx.fill();
ctx.strokeStyle = "#fff";
ctx.lineWidth = 10 / camera.scale;
ctx.stroke();
}
ctx.restore();
scaleChip.textContent = `scale ${camera.scale.toFixed(4)}x`;
}
canvas.addEventListener("pointerdown", (event) => {
dragging = true;
dragStart = { x: event.clientX, y: event.clientY, offsetX: camera.offsetX, offsetY: camera.offsetY };
canvas.classList.add("dragging");
});
window.addEventListener("pointerup", (event) => {
if (dragging && dragStart) {
const move = Math.hypot(event.clientX - dragStart.x, event.clientY - dragStart.y);
if (move < 4) {
const rect = canvas.getBoundingClientRect();
const mx = event.clientX - rect.left;
const my = event.clientY - rect.top;
const worldPos = screenToWorld(mx, my);
markerX = worldPos.x;
markerY = worldPos.y;
requestDraw();
// Notify parent window
window.parent.postMessage({
type: 'PICK_LOCATION',
x: markerX.toFixed(2),
y: markerY.toFixed(2)
}, '*');
}
}
dragging = false;
dragStart = null;
canvas.classList.remove("dragging");
});
window.addEventListener("pointermove", (event) => {
if (dragging && dragStart) {
camera.offsetX = dragStart.offsetX + (event.clientX - dragStart.x);
camera.offsetY = dragStart.offsetY + (event.clientY - dragStart.y);
requestDraw();
}
});
canvas.addEventListener("wheel", (event) => {
event.preventDefault();
const rect = canvas.getBoundingClientRect();
const mx = event.clientX - rect.left;
const my = event.clientY - rect.top;
const before = screenToWorld(mx, my);
const factor = event.deltaY < 0 ? 1.08 : 0.92;
camera.scale = Math.max(0.002, Math.min(2, camera.scale * factor));
const after = worldToScreen(before.x, before.y);
camera.offsetX += mx - after.x;
camera.offsetY += my - after.y;
requestDraw();
}, { passive: false });
document.getElementById("fit-btn").addEventListener("click", fit);
window.addEventListener("resize", resize);
resize();
</script>
</body>
</html>

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,931 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>center chair people map</title>
<style>
:root {
--ink: #152330;
--muted: #627286;
--paper: rgba(255,255,255,0.86);
--line: rgba(21,35,48,0.1);
--accent: #0f766e;
--bg: #edf2f6;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "IBM Plex Sans KR", "Pretendard", sans-serif;
color: var(--ink);
background:
radial-gradient(circle at top left, rgba(15,118,110,0.11), transparent 22%),
linear-gradient(180deg, #f5f8fb 0%, #e8eef3 100%);
}
.page {
min-height: 100vh;
padding: 0;
}
.shell {
min-height: 100vh;
}
.panel {
border-radius: 0;
border: none;
background: transparent;
backdrop-filter: none;
box-shadow: none;
}
.actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
button {
border: none;
border-radius: 999px;
padding: 10px 14px;
font: inherit;
font-weight: 700;
cursor: pointer;
color: white;
background: linear-gradient(135deg, #0f766e, #115e59);
box-shadow: 0 10px 22px rgba(15,118,110,0.18);
}
button.alt {
color: var(--ink);
background: rgba(255,255,255,0.9);
border: 1px solid var(--line);
box-shadow: none;
}
.viewer {
position: relative;
overflow: hidden;
min-height: 100vh;
}
.viewer-head {
position: absolute;
top: 16px;
left: 16px;
right: 16px;
z-index: 2;
display: flex;
justify-content: space-between;
gap: 12px;
pointer-events: none;
}
.chip {
padding: 10px 12px;
border-radius: 16px;
background: rgba(255,255,255,0.82);
border: 1px solid rgba(255,255,255,0.94);
color: var(--muted);
font-size: 13px;
font-weight: 700;
box-shadow: 0 8px 24px rgba(21,35,48,0.08);
}
.viewer-actions {
position: absolute;
left: 16px;
top: 64px;
z-index: 2;
display: flex;
gap: 8px;
}
.mapper {
position: absolute;
top: 76px;
left: 50%;
transform: translateX(-50%);
width: min(94vw, 1320px);
max-height: min(56vh, 560px);
overflow: hidden;
z-index: 4;
border-radius: 20px;
background: rgba(234, 239, 247, 0.95);
border: 1px solid rgba(101, 119, 146, 0.22);
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.2);
display: flex;
flex-direction: column;
backdrop-filter: blur(6px);
}
.hidden-off {
display: none !important;
}
.mapper-head {
padding: 10px 14px;
border-bottom: 1px solid rgba(101,119,146,0.18);
font-size: 12px;
color: #51607a;
font-weight: 700;
line-height: 1.35;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
background: rgba(255,255,255,0.6);
}
.mapper-head strong {
display: block;
color: #17243b;
font-size: 20px;
margin-bottom: 2px;
}
.mapper-head .alt {
padding: 8px 10px;
font-size: 12px;
white-space: nowrap;
}
.org-chart {
margin: 0;
padding: 14px;
overflow: auto;
display: grid;
gap: 12px;
}
.org-top {
margin: 0 auto;
width: min(100%, 420px);
border-radius: 14px;
overflow: hidden;
border: 1px solid rgba(67, 84, 118, 0.25);
background: #fff;
}
.org-top-title {
background: #1e2f4d;
color: #fff;
text-align: center;
font-size: 34px;
font-weight: 800;
line-height: 1.1;
padding: 16px 12px;
letter-spacing: -0.03em;
}
.org-top-members {
padding: 10px;
display: grid;
gap: 6px;
background: rgba(255,255,255,0.95);
}
.org-teams {
display: grid;
grid-template-columns: repeat(7, minmax(160px, 1fr));
gap: 10px;
align-items: start;
}
.org-team {
border: 1px solid rgba(110, 126, 152, 0.25);
border-radius: 10px;
overflow: hidden;
background: rgba(255,255,255,0.95);
min-width: 0;
}
.org-team h4 {
margin: 0;
padding: 9px 10px;
font-size: 14px;
color: #21324e;
font-weight: 800;
border-bottom: 1px solid rgba(110, 126, 152, 0.2);
background: rgba(240, 245, 252, 0.96);
}
.org-members {
padding: 7px;
display: grid;
gap: 6px;
}
.org-person {
border: 1px solid rgba(116, 133, 161, 0.25);
background: rgba(255,255,255,0.95);
border-radius: 8px;
padding: 6px 8px;
cursor: pointer;
transition: background 120ms ease, border-color 120ms ease;
min-width: 0;
}
.org-person.active {
border-color: rgba(15,118,110,0.6);
background: rgba(15,118,110,0.11);
}
.org-person.assigned {
border-color: rgba(37,99,235,0.5);
background: rgba(37,99,235,0.1);
}
.org-person strong {
display: block;
font-size: 13px;
line-height: 1.3;
color: #15233a;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.org-person small {
display: block;
color: #5a6a86;
font-size: 11px;
line-height: 1.25;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 980px) {
.mapper {
top: 72px;
width: min(96vw, 920px);
max-height: 58vh;
}
.viewer-actions {
top: 64px;
left: 12px;
right: 12px;
flex-wrap: wrap;
}
.mapper-head strong {
font-size: 16px;
}
.org-top-title {
font-size: 24px;
}
.org-teams {
grid-template-columns: repeat(3, minmax(150px, 1fr));
}
}
canvas {
width: 100%;
height: 100%;
display: block;
cursor: grab;
}
canvas.dragging { cursor: grabbing; }
.tooltip {
position: absolute;
min-width: 170px;
padding: 12px 14px;
border-radius: 16px;
background: rgba(17,24,39,0.94);
color: white;
pointer-events: none;
opacity: 0;
transform: translate(12px, 12px);
transition: opacity 120ms ease;
z-index: 3;
}
.tooltip.visible { opacity: 1; }
.tooltip strong { display: block; margin-bottom: 6px; font-size: 14px; }
.tooltip div { font-size: 12px; line-height: 1.45; color: rgba(255,255,255,0.82); }
</style>
</head>
<body>
<div class="page">
<div class="shell">
<main class="panel viewer">
<div class="viewer-head">
<div class="chip" id="scale-chip"></div>
<div class="chip" id="hover-chip">chair hover: none</div>
</div>
<div class="viewer-actions">
<button type="button" id="fit-btn">전체 맞춤</button>
<button type="button" class="alt" id="clear-btn">선택 지우기</button>
</div>
<aside class="mapper hidden-off">
<div class="mapper-head">
<div id="mapper-status">
<strong>조직 현황</strong>
<span>선택 인원 없음</span>
</div>
<button type="button" class="alt" id="clear-assign-btn">매칭 초기화</button>
</div>
<div class="org-chart" id="org-chart"></div>
</aside>
<canvas id="canvas"></canvas>
<div class="tooltip" id="tooltip"></div>
</main>
</div>
</div>
<script src="./center_chair_people_payload.js?v=20260403a"></script>
<script>
const DATA = window.CHAIR_MAP_DATA;
function decodeSegments(base64) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
return new Int32Array(bytes.buffer);
}
const bgTileRanges = DATA.bgTileRanges;
const bgSegValues = decodeSegments(DATA.bgSegsB64);
const chairSegValues = decodeSegments(DATA.chairSegsB64);
const chairs = DATA.chairs.map(([key, name, kind, start, count]) => ({
key, name, kind, start, count
}));
const meta = DATA.meta;
const world = meta.headerBounds;
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
const tooltip = document.getElementById("tooltip");
const scaleChip = document.getElementById("scale-chip");
const hoverChip = document.getElementById("hover-chip");
const STORAGE_KEY = "ptc-chair-selection";
const PEOPLE_STORAGE_KEY = "ptc-chair-people";
const ASSIGN_STORAGE_KEY = "ptc-chair-assignments";
const ACTIVE_PERSON_STORAGE_KEY = "ptc-chair-active-person";
const clearAssignBtn = document.getElementById("clear-assign-btn");
const orgChartEl = document.getElementById("org-chart");
const mapperStatus = document.getElementById("mapper-status");
// Prevent stale auto-highlights from previous sessions.
localStorage.removeItem(STORAGE_KEY);
localStorage.removeItem(ACTIVE_PERSON_STORAGE_KEY);
localStorage.removeItem(ASSIGN_STORAGE_KEY);
const placed = new Set();
let people = JSON.parse(localStorage.getItem(PEOPLE_STORAGE_KEY) || "[]");
let chairAssignments = {};
let activePersonId = null;
const ORG_TEMPLATE = {
top: {
name: "총괄기획실",
count: 53,
members: [
{ name: "장종찬", dept: "총괄기획실", title: "기획실장" },
{ name: "김원식", dept: "총괄기획실", title: "전무이사" },
],
},
teams: [
{ name: "경영기획팀", count: 6, members: ["김우진", "임민정", "국혜린", "최선아", "김윤재", "이미영"] },
{ name: "인재성장팀", count: 5, members: ["조태희", "최근혜", "류원준", "주안기", "정성호"] },
{ name: "ERP 기획팀", count: 5, members: ["류호성", "문형식", "최요제", "황대일", "이채봉"] },
{ name: "디자인기획팀", count: 17, members: ["신혜영", "정은혜", "김태식", "최예은", "채선영", "최영환", "윤봄이", "이예진", "허유나", "마희연", "김수현", "박지영", "권순호", "정두휘", "김정석", "정지윤", "양숙영"] },
{ name: "기술기획팀", count: 11, members: ["김원기", "홍아름", "이경민", "김혜인", "황동환", "최찬호", "이태훈", "김신지", "조찬영", "김용연", "한치영"] },
{ name: "협업증진팀", count: 3, members: ["성형일", "박주한", "한승민"] },
{ name: "솔루션통합팀", count: 4, members: ["권혁진", "염승호", "윤준수", "김지영"] },
],
};
const chairGeometry = chairs.map((chair) => {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
const path = new Path2D();
const hitSegments = new Float32Array(chair.count * 4);
let segCursor = 0;
for (let i = chair.start; i < chair.start + chair.count; i += 1) {
const offset = i * 4;
const x1 = chairSegValues[offset] / 10;
const y1 = chairSegValues[offset + 1] / 10;
const x2 = chairSegValues[offset + 2] / 10;
const y2 = chairSegValues[offset + 3] / 10;
path.moveTo(x1, y1);
path.lineTo(x2, y2);
hitSegments[segCursor] = x1;
hitSegments[segCursor + 1] = y1;
hitSegments[segCursor + 2] = x2;
hitSegments[segCursor + 3] = y2;
segCursor += 4;
minX = Math.min(minX, x1, x2);
minY = Math.min(minY, y1, y2);
maxX = Math.max(maxX, x1, x2);
maxY = Math.max(maxY, y1, y2);
}
return {
...chair,
minX,
minY,
maxX,
maxY,
area: Math.max(1, (maxX - minX) * (maxY - minY)),
path,
hitSegments,
};
});
function renumberChairKeys(chairItems) {
if (!chairItems.length) return;
const heights = chairItems
.map((chair) => Math.max(1, chair.maxY - chair.minY))
.sort((a, b) => a - b);
const medianHeight = heights[Math.floor(heights.length / 2)] || 1;
const rowTolerance = Math.max(40, medianHeight * 0.9);
const sorted = [...chairItems].sort((a, b) => {
const ay = (a.minY + a.maxY) * 0.5;
const by = (b.minY + b.maxY) * 0.5;
if (Math.abs(by - ay) > rowTolerance) return by - ay; // top -> bottom
const ax = (a.minX + a.maxX) * 0.5;
const bx = (b.minX + b.maxX) * 0.5;
return ax - bx; // left -> right
});
sorted.forEach((chair, index) => {
chair.key = String(index + 1);
chair.seatNo = index + 1;
});
}
renumberChairKeys(chairGeometry);
const PICK_GRID_SIZE = 1800;
const chairPickGrid = new Map();
function pickGridKey(gx, gy) {
return `${gx},${gy}`;
}
chairGeometry.forEach((chair, index) => {
const minGX = Math.floor(chair.minX / PICK_GRID_SIZE);
const maxGX = Math.floor(chair.maxX / PICK_GRID_SIZE);
const minGY = Math.floor(chair.minY / PICK_GRID_SIZE);
const maxGY = Math.floor(chair.maxY / PICK_GRID_SIZE);
for (let gx = minGX; gx <= maxGX; gx += 1) {
for (let gy = minGY; gy <= maxGY; gy += 1) {
const key = pickGridKey(gx, gy);
if (!chairPickGrid.has(key)) chairPickGrid.set(key, []);
chairPickGrid.get(key).push(index);
}
}
});
const camera = { scale: 1, offsetX: 0, offsetY: 0 };
let pixelRatio = window.devicePixelRatio || 1;
let pointer = { x: 0, y: 0 };
let dragging = false;
let dragStart = null;
let hovered = null;
let rafPending = false;
function normalizePeople(raw) {
return raw
.map((person, index) => {
if (!person || !person.name) return null;
return {
id: person.id || `person-${index + 1}`,
name: String(person.name).trim(),
dept: String(person.dept || "").trim(),
title: String(person.title || "").trim(),
};
})
.filter(Boolean);
}
function createTemplatePeople() {
const generated = [];
let seq = 1;
ORG_TEMPLATE.top.members.forEach((member) => {
generated.push({
id: `org-${seq++}`,
name: member.name,
dept: member.dept,
title: member.title,
});
});
ORG_TEMPLATE.teams.forEach((team) => {
team.members.forEach((name) => {
generated.push({
id: `org-${seq++}`,
name,
dept: team.name,
title: "선임",
});
});
});
return generated;
}
people = normalizePeople(people);
const templateReady = people.some((person) => person.name === "장종찬" && person.dept === "총괄기획실");
if (!templateReady) {
people = createTemplatePeople();
localStorage.setItem(PEOPLE_STORAGE_KEY, JSON.stringify(people));
}
const chairKeySet = new Set(chairGeometry.map((chair) => chair.key));
chairAssignments = Object.fromEntries(
Object.entries(chairAssignments).filter(([chairKey, personId]) => (
chairKeySet.has(chairKey) && people.some((person) => person.id === personId)
))
);
if (activePersonId && !people.some((person) => person.id === activePersonId)) activePersonId = null;
function persistPeople() {
localStorage.setItem(PEOPLE_STORAGE_KEY, JSON.stringify(people));
}
function persistAssignments() {
localStorage.setItem(ASSIGN_STORAGE_KEY, JSON.stringify(chairAssignments));
}
function persistActivePerson() {
if (!activePersonId) localStorage.removeItem(ACTIVE_PERSON_STORAGE_KEY);
else localStorage.setItem(ACTIVE_PERSON_STORAGE_KEY, activePersonId);
}
function assignmentCount() {
return Object.keys(chairAssignments).length;
}
function getPersonById(id) {
return people.find((person) => person.id === id) || null;
}
function getChairByPerson(personId) {
for (const [chairKey, assignedPersonId] of Object.entries(chairAssignments)) {
if (assignedPersonId === personId) return chairKey;
}
return null;
}
function renderPeopleList() {
const activePerson = getPersonById(activePersonId);
const countText = `${assignmentCount()} / ${people.length} 매칭`;
mapperStatus.innerHTML = `<strong>조직 현황</strong><span>${activePerson ? `${activePerson.name} 선택됨` : "선택 인원 없음"} · ${countText}</span>`;
const findPerson = (dept, name) => people.find((person) => person.dept === dept && person.name === name) || null;
const personCard = (person, roleText) => {
if (!person) return "";
const chairKey = getChairByPerson(person.id);
const assignedClass = chairKey ? " assigned" : "";
const activeClass = person.id === activePersonId ? " active" : "";
return `
<article class="org-person${assignedClass}${activeClass}" data-person-id="${person.id}">
<strong>${person.name}</strong>
<small>${person.title || roleText || "-"}</small>
<small>${chairKey ? `좌석 ${chairKey}` : "좌석 미지정"}</small>
</article>
`;
};
const topHtml = ORG_TEMPLATE.top.members
.map((member) => personCard(findPerson(member.dept, member.name), member.title))
.join("");
const teamsHtml = ORG_TEMPLATE.teams.map((team) => {
const membersHtml = team.members
.map((name) => personCard(findPerson(team.name, name), "선임"))
.join("");
return `
<section class="org-team">
<h4>${team.name} (${team.count})</h4>
<div class="org-members">${membersHtml}</div>
</section>
`;
}).join("");
orgChartEl.innerHTML = `
<section class="org-top">
<div class="org-top-title">${ORG_TEMPLATE.top.name} (${ORG_TEMPLATE.top.count})</div>
<div class="org-top-members">${topHtml}</div>
</section>
<section class="org-teams">${teamsHtml}</section>
`;
}
function worldToScreen(x, y) {
return {
x: x * camera.scale + camera.offsetX,
y: (world.maxY - y + world.minY) * camera.scale + camera.offsetY,
};
}
function screenToWorld(x, y) {
return {
x: (x - camera.offsetX) / camera.scale,
y: world.maxY + world.minY - (y - camera.offsetY) / camera.scale,
};
}
function resize() {
pixelRatio = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = Math.round(rect.width * pixelRatio);
canvas.height = Math.round(rect.height * pixelRatio);
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
fit();
}
function fit() {
const rect = canvas.getBoundingClientRect();
const width = world.maxX - world.minX;
const height = world.maxY - world.minY;
const pad = 36;
const scaleX = (rect.width - pad * 2) / width;
const scaleY = (rect.height - pad * 2) / height;
camera.scale = Math.min(scaleX, scaleY);
camera.offsetX = pad - world.minX * camera.scale + (rect.width - pad * 2 - width * camera.scale) / 2;
camera.offsetY = pad - world.minY * camera.scale + (rect.height - pad * 2 - height * camera.scale) / 2;
requestDraw();
}
function drawGrid(width, height) {
ctx.save();
ctx.strokeStyle = "rgba(21,35,48,0.05)";
ctx.lineWidth = 1;
for (let x = 120; x < width; x += 120) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
ctx.stroke();
}
for (let y = 120; y < height; y += 120) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(width, y);
ctx.stroke();
}
ctx.restore();
}
function pickChair(screenX, screenY) {
const threshold = 12;
const pointerWorld = screenToWorld(screenX, screenY);
const thresholdWorld = threshold / camera.scale;
const thresholdWorldSq = thresholdWorld * thresholdWorld;
const minGX = Math.floor((pointerWorld.x - thresholdWorld) / PICK_GRID_SIZE);
const maxGX = Math.floor((pointerWorld.x + thresholdWorld) / PICK_GRID_SIZE);
const minGY = Math.floor((pointerWorld.y - thresholdWorld) / PICK_GRID_SIZE);
const maxGY = Math.floor((pointerWorld.y + thresholdWorld) / PICK_GRID_SIZE);
const candidateIndexes = [];
const seen = new Set();
for (let gx = minGX; gx <= maxGX; gx += 1) {
for (let gy = minGY; gy <= maxGY; gy += 1) {
const candidates = chairPickGrid.get(pickGridKey(gx, gy));
if (!candidates) continue;
for (const index of candidates) {
if (seen.has(index)) continue;
seen.add(index);
candidateIndexes.push(index);
}
}
}
let best = null;
for (const index of candidateIndexes) {
const chair = chairGeometry[index];
if (
pointerWorld.x < chair.minX - thresholdWorld ||
pointerWorld.x > chair.maxX + thresholdWorld ||
pointerWorld.y < chair.minY - thresholdWorld ||
pointerWorld.y > chair.maxY + thresholdWorld
) continue;
let distSq = Infinity;
for (let i = 0; i < chair.hitSegments.length; i += 4) {
const x1 = chair.hitSegments[i];
const y1 = chair.hitSegments[i + 1];
const x2 = chair.hitSegments[i + 2];
const y2 = chair.hitSegments[i + 3];
const dx = x2 - x1;
const dy = y2 - y1;
const len2 = dx * dx + dy * dy;
let segDistSq;
if (len2 === 0) {
const px = pointerWorld.x - x1;
const py = pointerWorld.y - y1;
segDistSq = px * px + py * py;
} else {
let t = ((pointerWorld.x - x1) * dx + (pointerWorld.y - y1) * dy) / len2;
t = Math.max(0, Math.min(1, t));
const lx = x1 + t * dx;
const ly = y1 + t * dy;
const px = pointerWorld.x - lx;
const py = pointerWorld.y - ly;
segDistSq = px * px + py * py;
}
if (segDistSq < distSq) distSq = segDistSq;
if (distSq <= thresholdWorldSq * 0.3) break;
}
if (distSq > thresholdWorldSq) continue;
const dist = Math.sqrt(distSq) * camera.scale;
if (!best) {
best = { chair, dist };
continue;
}
const distGap = dist - best.dist;
if (distGap < -0.75) {
best = { chair, dist };
continue;
}
if (Math.abs(distGap) <= 2) {
const areaGap = chair.area - best.chair.area;
if (areaGap < -1) {
best = { chair, dist };
continue;
}
if (
Math.abs(areaGap) <= 1 &&
chair.kind === "block" &&
best.chair.kind !== "block"
) {
best = { chair, dist };
}
}
}
return best ? best.chair : null;
}
function renderTooltip() {
if (!hovered) {
tooltip.classList.remove("visible");
hoverChip.textContent = "chair hover: none";
return;
}
hoverChip.textContent = `chair hover: ${hovered.name}`;
tooltip.innerHTML = `
<strong>${hovered.name}</strong>
<div>chair key: ${hovered.key}</div>
<div>${placed.has(hovered.key) ? "선택됨" : "클릭하면 선택"}</div>
<div>${chairAssignments[hovered.key] ? `배치: ${(getPersonById(chairAssignments[hovered.key]) || { name: "알수없음" }).name}` : "배치 인원 없음"}</div>
`;
tooltip.style.left = `${pointer.x + 14}px`;
tooltip.style.top = `${pointer.y + 14}px`;
tooltip.classList.add("visible");
}
function requestDraw() {
if (rafPending) return;
rafPending = true;
window.requestAnimationFrame(() => {
rafPending = false;
draw();
});
}
function applyWorldTransform() {
ctx.setTransform(
pixelRatio * camera.scale,
0,
0,
-pixelRatio * camera.scale,
pixelRatio * camera.offsetX,
pixelRatio * ((world.maxY + world.minY) * camera.scale + camera.offsetY)
);
}
function draw() {
const rect = canvas.getBoundingClientRect();
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
ctx.clearRect(0, 0, rect.width, rect.height);
drawGrid(rect.width, rect.height);
const viewA = screenToWorld(0, rect.height);
const viewB = screenToWorld(rect.width, 0);
const viewMinX = Math.min(viewA.x, viewB.x);
const viewMaxX = Math.max(viewA.x, viewB.x);
const viewMinY = Math.min(viewA.y, viewB.y);
const viewMaxY = Math.max(viewA.y, viewB.y);
ctx.save();
applyWorldTransform();
ctx.strokeStyle = "rgba(100, 116, 139, 0.28)";
ctx.lineWidth = 1 / camera.scale;
const tileSize = meta.backgroundTileSize;
const tileMinX = Math.floor(viewMinX / tileSize);
const tileMaxX = Math.floor(viewMaxX / tileSize);
const tileMinY = Math.floor(viewMinY / tileSize);
const tileMaxY = Math.floor(viewMaxY / tileSize);
for (let tx = tileMinX; tx <= tileMaxX; tx += 1) {
for (let ty = tileMinY; ty <= tileMaxY; ty += 1) {
const range = bgTileRanges[`${tx},${ty}`];
if (!range) continue;
const start = range[0];
const count = range[1];
for (let i = start; i < start + count; i += 1) {
const offset = i * 4;
const x1 = bgSegValues[offset] / 10;
const y1 = bgSegValues[offset + 1] / 10;
const x2 = bgSegValues[offset + 2] / 10;
const y2 = bgSegValues[offset + 3] / 10;
if (
Math.max(x1, x2) < viewMinX ||
Math.min(x1, x2) > viewMaxX ||
Math.max(y1, y2) < viewMinY ||
Math.min(y1, y2) > viewMaxY
) continue;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
}
}
}
ctx.restore();
hovered = dragging ? null : pickChair(pointer.x, pointer.y);
ctx.save();
applyWorldTransform();
ctx.lineWidth = 1.45 / camera.scale;
ctx.lineCap = "round";
ctx.lineJoin = "round";
for (const chair of chairGeometry) {
if (chair.maxX < viewMinX || chair.minX > viewMaxX || chair.maxY < viewMinY || chair.minY > viewMaxY) continue;
const active = hovered && hovered.key === chair.key;
const selected = placed.has(chair.key);
const assignedPersonId = chairAssignments[chair.key];
const activePersonChair = activePersonId && assignedPersonId === activePersonId;
const assigned = Boolean(assignedPersonId);
const baseWidth = chair.kind === "block" ? 1.45 : 1.35;
ctx.strokeStyle = activePersonChair
? "rgba(234, 179, 8, 1)"
: assigned
? "rgba(37, 99, 235, 0.98)"
: selected
? "rgba(220, 38, 38, 0.98)"
: active
? "rgba(15, 118, 110, 0.98)"
: chair.kind === "group"
? "rgba(16, 134, 149, 0.74)"
: "rgba(21, 149, 142, 0.8)";
ctx.lineWidth = (activePersonChair ? 2.8 : assigned ? 2.4 : selected ? 2.6 : active ? 2.1 : baseWidth) / camera.scale;
ctx.stroke(chair.path);
}
ctx.restore();
scaleChip.textContent = `scale ${camera.scale.toFixed(4)}x`;
renderTooltip();
}
function persistPlaced() {
localStorage.setItem(STORAGE_KEY, JSON.stringify([...placed]));
}
canvas.addEventListener("pointerdown", (event) => {
dragging = true;
dragStart = { x: event.clientX, y: event.clientY, offsetX: camera.offsetX, offsetY: camera.offsetY };
canvas.classList.add("dragging");
});
window.addEventListener("pointerup", (event) => {
if (dragging && dragStart) {
const move = Math.hypot(event.clientX - dragStart.x, event.clientY - dragStart.y);
if (move < 4) {
const rect = canvas.getBoundingClientRect();
const picked = pickChair(event.clientX - rect.left, event.clientY - rect.top);
if (picked) {
if (placed.has(picked.key)) placed.delete(picked.key);
else placed.add(picked.key);
persistPlaced();
if (activePersonId) {
const currentChair = getChairByPerson(activePersonId);
if (chairAssignments[picked.key] === activePersonId) {
delete chairAssignments[picked.key];
} else {
if (currentChair && currentChair !== picked.key) delete chairAssignments[currentChair];
chairAssignments[picked.key] = activePersonId;
}
persistAssignments();
renderPeopleList();
}
}
}
}
dragging = false;
dragStart = null;
canvas.classList.remove("dragging");
requestDraw();
});
window.addEventListener("pointermove", (event) => {
const rect = canvas.getBoundingClientRect();
pointer = { x: event.clientX - rect.left, y: event.clientY - rect.top };
if (dragging && dragStart) {
camera.offsetX = dragStart.offsetX + (event.clientX - dragStart.x);
camera.offsetY = dragStart.offsetY + (event.clientY - dragStart.y);
}
requestDraw();
});
canvas.addEventListener("wheel", (event) => {
event.preventDefault();
const rect = canvas.getBoundingClientRect();
const mx = event.clientX - rect.left;
const my = event.clientY - rect.top;
const before = screenToWorld(mx, my);
const factor = event.deltaY < 0 ? 1.08 : 0.92;
camera.scale = Math.max(0.002, Math.min(2, camera.scale * factor));
const after = worldToScreen(before.x, before.y);
camera.offsetX += mx - after.x;
camera.offsetY += my - after.y;
requestDraw();
}, { passive: false });
document.getElementById("fit-btn").addEventListener("click", fit);
document.getElementById("clear-btn").addEventListener("click", () => {
placed.clear();
persistPlaced();
requestDraw();
});
clearAssignBtn.addEventListener("click", () => {
chairAssignments = {};
persistAssignments();
renderPeopleList();
requestDraw();
});
orgChartEl.addEventListener("click", (event) => {
const item = event.target.closest(".org-person[data-person-id]");
if (!item) return;
const personId = item.getAttribute("data-person-id");
activePersonId = personId === activePersonId ? null : personId;
persistActivePerson();
renderPeopleList();
requestDraw();
});
window.addEventListener("resize", resize);
renderPeopleList();
resize();
</script>
</body>
</html>

View File

@@ -0,0 +1,932 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>center chair people map 6f</title>
<style>
:root {
--ink: #152330;
--muted: #627286;
--paper: rgba(255,255,255,0.86);
--line: rgba(21,35,48,0.1);
--accent: #0f766e;
--bg: #edf2f6;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "IBM Plex Sans KR", "Pretendard", sans-serif;
color: var(--ink);
background:
radial-gradient(circle at top left, rgba(15,118,110,0.11), transparent 22%),
linear-gradient(180deg, #f5f8fb 0%, #e8eef3 100%);
}
.page {
min-height: 100vh;
padding: 0;
}
.shell {
min-height: 100vh;
}
.panel {
border-radius: 0;
border: none;
background: transparent;
backdrop-filter: none;
box-shadow: none;
}
.actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
button {
border: none;
border-radius: 999px;
padding: 10px 14px;
font: inherit;
font-weight: 700;
cursor: pointer;
color: white;
background: linear-gradient(135deg, #0f766e, #115e59);
box-shadow: 0 10px 22px rgba(15,118,110,0.18);
}
button.alt {
color: var(--ink);
background: rgba(255,255,255,0.9);
border: 1px solid var(--line);
box-shadow: none;
}
.viewer {
position: relative;
overflow: hidden;
min-height: 100vh;
}
.viewer-head {
position: absolute;
top: 16px;
left: 16px;
right: 16px;
z-index: 2;
display: flex;
justify-content: space-between;
gap: 12px;
pointer-events: none;
}
.chip {
padding: 10px 12px;
border-radius: 16px;
background: rgba(255,255,255,0.82);
border: 1px solid rgba(255,255,255,0.94);
color: var(--muted);
font-size: 13px;
font-weight: 700;
box-shadow: 0 8px 24px rgba(21,35,48,0.08);
}
.viewer-actions {
position: absolute;
left: 16px;
top: 64px;
z-index: 2;
display: flex;
gap: 8px;
}
.mapper {
position: absolute;
top: 76px;
left: 50%;
transform: translateX(-50%);
width: min(94vw, 1320px);
max-height: min(56vh, 560px);
overflow: hidden;
z-index: 4;
border-radius: 20px;
background: rgba(234, 239, 247, 0.95);
border: 1px solid rgba(101, 119, 146, 0.22);
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.2);
display: flex;
flex-direction: column;
backdrop-filter: blur(6px);
}
.hidden-off {
display: none !important;
}
.mapper-head {
padding: 10px 14px;
border-bottom: 1px solid rgba(101,119,146,0.18);
font-size: 12px;
color: #51607a;
font-weight: 700;
line-height: 1.35;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
background: rgba(255,255,255,0.6);
}
.mapper-head strong {
display: block;
color: #17243b;
font-size: 20px;
margin-bottom: 2px;
}
.mapper-head .alt {
padding: 8px 10px;
font-size: 12px;
white-space: nowrap;
}
.org-chart {
margin: 0;
padding: 14px;
overflow: auto;
display: grid;
gap: 12px;
}
.org-top {
margin: 0 auto;
width: min(100%, 420px);
border-radius: 14px;
overflow: hidden;
border: 1px solid rgba(67, 84, 118, 0.25);
background: #fff;
}
.org-top-title {
background: #1e2f4d;
color: #fff;
text-align: center;
font-size: 34px;
font-weight: 800;
line-height: 1.1;
padding: 16px 12px;
letter-spacing: -0.03em;
}
.org-top-members {
padding: 10px;
display: grid;
gap: 6px;
background: rgba(255,255,255,0.95);
}
.org-teams {
display: grid;
grid-template-columns: repeat(7, minmax(160px, 1fr));
gap: 10px;
align-items: start;
}
.org-team {
border: 1px solid rgba(110, 126, 152, 0.25);
border-radius: 10px;
overflow: hidden;
background: rgba(255,255,255,0.95);
min-width: 0;
}
.org-team h4 {
margin: 0;
padding: 9px 10px;
font-size: 14px;
color: #21324e;
font-weight: 800;
border-bottom: 1px solid rgba(110, 126, 152, 0.2);
background: rgba(240, 245, 252, 0.96);
}
.org-members {
padding: 7px;
display: grid;
gap: 6px;
}
.org-person {
border: 1px solid rgba(116, 133, 161, 0.25);
background: rgba(255,255,255,0.95);
border-radius: 8px;
padding: 6px 8px;
cursor: pointer;
transition: background 120ms ease, border-color 120ms ease;
min-width: 0;
}
.org-person.active {
border-color: rgba(15,118,110,0.6);
background: rgba(15,118,110,0.11);
}
.org-person.assigned {
border-color: rgba(37,99,235,0.5);
background: rgba(37,99,235,0.1);
}
.org-person strong {
display: block;
font-size: 13px;
line-height: 1.3;
color: #15233a;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.org-person small {
display: block;
color: #5a6a86;
font-size: 11px;
line-height: 1.25;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 980px) {
.mapper {
top: 72px;
width: min(96vw, 920px);
max-height: 58vh;
}
.viewer-actions {
top: 64px;
left: 12px;
right: 12px;
flex-wrap: wrap;
}
.mapper-head strong {
font-size: 16px;
}
.org-top-title {
font-size: 24px;
}
.org-teams {
grid-template-columns: repeat(3, minmax(150px, 1fr));
}
}
canvas {
width: 100%;
height: 100%;
display: block;
cursor: grab;
}
canvas.dragging { cursor: grabbing; }
.tooltip {
position: absolute;
min-width: 170px;
padding: 12px 14px;
border-radius: 16px;
background: rgba(17,24,39,0.94);
color: white;
pointer-events: none;
opacity: 0;
transform: translate(12px, 12px);
transition: opacity 120ms ease;
z-index: 3;
}
.tooltip.visible { opacity: 1; }
.tooltip strong { display: block; margin-bottom: 6px; font-size: 14px; }
.tooltip div { font-size: 12px; line-height: 1.45; color: rgba(255,255,255,0.82); }
</style>
</head>
<body>
<div class="page">
<div class="shell">
<main class="panel viewer">
<div class="viewer-head">
<div class="chip" id="scale-chip"></div>
<div class="chip" id="hover-chip">chair hover: none</div>
</div>
<div class="viewer-actions">
<button type="button" id="fit-btn">전체 맞춤</button>
<button type="button" class="alt" id="clear-btn">선택 지우기</button>
</div>
<aside class="mapper hidden-off">
<div class="mapper-head">
<div id="mapper-status">
<strong>조직 현황</strong>
<span>선택 인원 없음</span>
</div>
<button type="button" class="alt" id="clear-assign-btn">매칭 초기화</button>
</div>
<div class="org-chart" id="org-chart"></div>
</aside>
<canvas id="canvas"></canvas>
<div class="tooltip" id="tooltip"></div>
</main>
</div>
</div>
<script src="./center_chair_people_payload_6f.js"></script>
<script>
const DATA = window.CHAIR_MAP_DATA;
function decodeSegments(base64) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
return new Int32Array(bytes.buffer);
}
const bgTileRanges = DATA.bgTileRanges;
const bgSegValues = decodeSegments(DATA.bgSegsB64);
const chairSegValues = decodeSegments(DATA.chairSegsB64);
const chairs = DATA.chairs.map(([key, name, kind, start, count]) => ({
key, name, kind, start, count
}));
const meta = DATA.meta;
const world = meta.headerBounds;
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
const tooltip = document.getElementById("tooltip");
const scaleChip = document.getElementById("scale-chip");
const hoverChip = document.getElementById("hover-chip");
const STORAGE_KEY = "ptc-chair-selection";
const PEOPLE_STORAGE_KEY = "ptc-chair-people";
const ASSIGN_STORAGE_KEY = "ptc-chair-assignments";
const ACTIVE_PERSON_STORAGE_KEY = "ptc-chair-active-person";
const clearAssignBtn = document.getElementById("clear-assign-btn");
const orgChartEl = document.getElementById("org-chart");
const mapperStatus = document.getElementById("mapper-status");
// Prevent stale auto-highlights from previous sessions.
localStorage.removeItem(STORAGE_KEY);
localStorage.removeItem(ACTIVE_PERSON_STORAGE_KEY);
localStorage.removeItem(ASSIGN_STORAGE_KEY);
const placed = new Set();
let people = JSON.parse(localStorage.getItem(PEOPLE_STORAGE_KEY) || "[]");
let chairAssignments = {};
let activePersonId = null;
const ORG_TEMPLATE = {
top: {
name: "총괄기획실",
count: 53,
members: [
{ name: "장종찬", dept: "총괄기획실", title: "기획실장" },
{ name: "김원식", dept: "총괄기획실", title: "전무이사" },
],
},
teams: [
{ name: "경영기획팀", count: 6, members: ["김우진", "임민정", "국혜린", "최선아", "김윤재", "이미영"] },
{ name: "인재성장팀", count: 5, members: ["조태희", "최근혜", "류원준", "주안기", "정성호"] },
{ name: "ERP 기획팀", count: 5, members: ["류호성", "문형식", "최요제", "황대일", "이채봉"] },
{ name: "디자인기획팀", count: 17, members: ["신혜영", "정은혜", "김태식", "최예은", "채선영", "최영환", "윤봄이", "이예진", "허유나", "마희연", "김수현", "박지영", "권순호", "정두휘", "김정석", "정지윤", "양숙영"] },
{ name: "기술기획팀", count: 11, members: ["김원기", "홍아름", "이경민", "김혜인", "황동환", "최찬호", "이태훈", "김신지", "조찬영", "김용연", "한치영"] },
{ name: "협업증진팀", count: 3, members: ["성형일", "박주한", "한승민"] },
{ name: "솔루션통합팀", count: 4, members: ["권혁진", "염승호", "윤준수", "김지영"] },
],
};
const chairGeometry = chairs.map((chair) => {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
const path = new Path2D();
const hitSegments = new Float32Array(chair.count * 4);
let segCursor = 0;
for (let i = chair.start; i < chair.start + chair.count; i += 1) {
const offset = i * 4;
const x1 = chairSegValues[offset] / 10;
const y1 = chairSegValues[offset + 1] / 10;
const x2 = chairSegValues[offset + 2] / 10;
const y2 = chairSegValues[offset + 3] / 10;
path.moveTo(x1, y1);
path.lineTo(x2, y2);
hitSegments[segCursor] = x1;
hitSegments[segCursor + 1] = y1;
hitSegments[segCursor + 2] = x2;
hitSegments[segCursor + 3] = y2;
segCursor += 4;
minX = Math.min(minX, x1, x2);
minY = Math.min(minY, y1, y2);
maxX = Math.max(maxX, x1, x2);
maxY = Math.max(maxY, y1, y2);
}
return {
...chair,
minX,
minY,
maxX,
maxY,
area: Math.max(1, (maxX - minX) * (maxY - minY)),
path,
hitSegments,
};
});
function renumberChairKeys(chairItems) {
if (!chairItems.length) return;
const heights = chairItems
.map((chair) => Math.max(1, chair.maxY - chair.minY))
.sort((a, b) => a - b);
const medianHeight = heights[Math.floor(heights.length / 2)] || 1;
const rowTolerance = Math.max(40, medianHeight * 0.9);
const sorted = [...chairItems].sort((a, b) => {
const ay = (a.minY + a.maxY) * 0.5;
const by = (b.minY + b.maxY) * 0.5;
if (Math.abs(by - ay) > rowTolerance) return by - ay; // top -> bottom
const ax = (a.minX + a.maxX) * 0.5;
const bx = (b.minX + b.maxX) * 0.5;
return ax - bx; // left -> right
});
sorted.forEach((chair, index) => {
chair.key = String(index + 1);
chair.seatNo = index + 1;
});
}
renumberChairKeys(chairGeometry);
const PICK_GRID_SIZE = 1800;
const chairPickGrid = new Map();
function pickGridKey(gx, gy) {
return `${gx},${gy}`;
}
chairGeometry.forEach((chair, index) => {
const minGX = Math.floor(chair.minX / PICK_GRID_SIZE);
const maxGX = Math.floor(chair.maxX / PICK_GRID_SIZE);
const minGY = Math.floor(chair.minY / PICK_GRID_SIZE);
const maxGY = Math.floor(chair.maxY / PICK_GRID_SIZE);
for (let gx = minGX; gx <= maxGX; gx += 1) {
for (let gy = minGY; gy <= maxGY; gy += 1) {
const key = pickGridKey(gx, gy);
if (!chairPickGrid.has(key)) chairPickGrid.set(key, []);
chairPickGrid.get(key).push(index);
}
}
});
const camera = { scale: 1, offsetX: 0, offsetY: 0 };
let pixelRatio = window.devicePixelRatio || 1;
let pointer = { x: 0, y: 0 };
let dragging = false;
let dragStart = null;
let hovered = null;
let rafPending = false;
function normalizePeople(raw) {
return raw
.map((person, index) => {
if (!person || !person.name) return null;
return {
id: person.id || `person-${index + 1}`,
name: String(person.name).trim(),
dept: String(person.dept || "").trim(),
title: String(person.title || "").trim(),
};
})
.filter(Boolean);
}
function createTemplatePeople() {
const generated = [];
let seq = 1;
ORG_TEMPLATE.top.members.forEach((member) => {
generated.push({
id: `org-${seq++}`,
name: member.name,
dept: member.dept,
title: member.title,
});
});
ORG_TEMPLATE.teams.forEach((team) => {
team.members.forEach((name) => {
generated.push({
id: `org-${seq++}`,
name,
dept: team.name,
title: "선임",
});
});
});
return generated;
}
people = normalizePeople(people);
const templateReady = people.some((person) => person.name === "장종찬" && person.dept === "총괄기획실");
if (!templateReady) {
people = createTemplatePeople();
localStorage.setItem(PEOPLE_STORAGE_KEY, JSON.stringify(people));
}
const chairKeySet = new Set(chairGeometry.map((chair) => chair.key));
chairAssignments = Object.fromEntries(
Object.entries(chairAssignments).filter(([chairKey, personId]) => (
chairKeySet.has(chairKey) && people.some((person) => person.id === personId)
))
);
if (activePersonId && !people.some((person) => person.id === activePersonId)) activePersonId = null;
function persistPeople() {
localStorage.setItem(PEOPLE_STORAGE_KEY, JSON.stringify(people));
}
function persistAssignments() {
localStorage.setItem(ASSIGN_STORAGE_KEY, JSON.stringify(chairAssignments));
}
function persistActivePerson() {
if (!activePersonId) localStorage.removeItem(ACTIVE_PERSON_STORAGE_KEY);
else localStorage.setItem(ACTIVE_PERSON_STORAGE_KEY, activePersonId);
}
function assignmentCount() {
return Object.keys(chairAssignments).length;
}
function getPersonById(id) {
return people.find((person) => person.id === id) || null;
}
function getChairByPerson(personId) {
for (const [chairKey, assignedPersonId] of Object.entries(chairAssignments)) {
if (assignedPersonId === personId) return chairKey;
}
return null;
}
function renderPeopleList() {
const activePerson = getPersonById(activePersonId);
const countText = `${assignmentCount()} / ${people.length} 매칭`;
mapperStatus.innerHTML = `<strong>조직 현황</strong><span>${activePerson ? `${activePerson.name} 선택됨` : "선택 인원 없음"} · ${countText}</span>`;
const findPerson = (dept, name) => people.find((person) => person.dept === dept && person.name === name) || null;
const personCard = (person, roleText) => {
if (!person) return "";
const chairKey = getChairByPerson(person.id);
const assignedClass = chairKey ? " assigned" : "";
const activeClass = person.id === activePersonId ? " active" : "";
return `
<article class="org-person${assignedClass}${activeClass}" data-person-id="${person.id}">
<strong>${person.name}</strong>
<small>${person.title || roleText || "-"}</small>
<small>${chairKey ? `좌석 ${chairKey}` : "좌석 미지정"}</small>
</article>
`;
};
const topHtml = ORG_TEMPLATE.top.members
.map((member) => personCard(findPerson(member.dept, member.name), member.title))
.join("");
const teamsHtml = ORG_TEMPLATE.teams.map((team) => {
const membersHtml = team.members
.map((name) => personCard(findPerson(team.name, name), "선임"))
.join("");
return `
<section class="org-team">
<h4>${team.name} (${team.count})</h4>
<div class="org-members">${membersHtml}</div>
</section>
`;
}).join("");
orgChartEl.innerHTML = `
<section class="org-top">
<div class="org-top-title">${ORG_TEMPLATE.top.name} (${ORG_TEMPLATE.top.count})</div>
<div class="org-top-members">${topHtml}</div>
</section>
<section class="org-teams">${teamsHtml}</section>
`;
}
function worldToScreen(x, y) {
return {
x: x * camera.scale + camera.offsetX,
y: (world.maxY - y + world.minY) * camera.scale + camera.offsetY,
};
}
function screenToWorld(x, y) {
return {
x: (x - camera.offsetX) / camera.scale,
y: world.maxY + world.minY - (y - camera.offsetY) / camera.scale,
};
}
function resize() {
pixelRatio = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = Math.round(rect.width * pixelRatio);
canvas.height = Math.round(rect.height * pixelRatio);
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
fit();
}
function fit() {
const rect = canvas.getBoundingClientRect();
const width = world.maxX - world.minX;
const height = world.maxY - world.minY;
const pad = 36;
const scaleX = (rect.width - pad * 2) / width;
const scaleY = (rect.height - pad * 2) / height;
camera.scale = Math.min(scaleX, scaleY);
camera.offsetX = pad - world.minX * camera.scale + (rect.width - pad * 2 - width * camera.scale) / 2;
camera.offsetY = pad - world.minY * camera.scale + (rect.height - pad * 2 - height * camera.scale) / 2;
requestDraw();
}
function drawGrid(width, height) {
ctx.save();
ctx.strokeStyle = "rgba(21,35,48,0.05)";
ctx.lineWidth = 1;
for (let x = 120; x < width; x += 120) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
ctx.stroke();
}
for (let y = 120; y < height; y += 120) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(width, y);
ctx.stroke();
}
ctx.restore();
}
function pickChair(screenX, screenY) {
const threshold = 12;
const pointerWorld = screenToWorld(screenX, screenY);
const thresholdWorld = threshold / camera.scale;
const thresholdWorldSq = thresholdWorld * thresholdWorld;
const minGX = Math.floor((pointerWorld.x - thresholdWorld) / PICK_GRID_SIZE);
const maxGX = Math.floor((pointerWorld.x + thresholdWorld) / PICK_GRID_SIZE);
const minGY = Math.floor((pointerWorld.y - thresholdWorld) / PICK_GRID_SIZE);
const maxGY = Math.floor((pointerWorld.y + thresholdWorld) / PICK_GRID_SIZE);
const candidateIndexes = [];
const seen = new Set();
for (let gx = minGX; gx <= maxGX; gx += 1) {
for (let gy = minGY; gy <= maxGY; gy += 1) {
const candidates = chairPickGrid.get(pickGridKey(gx, gy));
if (!candidates) continue;
for (const index of candidates) {
if (seen.has(index)) continue;
seen.add(index);
candidateIndexes.push(index);
}
}
}
let best = null;
for (const index of candidateIndexes) {
const chair = chairGeometry[index];
if (
pointerWorld.x < chair.minX - thresholdWorld ||
pointerWorld.x > chair.maxX + thresholdWorld ||
pointerWorld.y < chair.minY - thresholdWorld ||
pointerWorld.y > chair.maxY + thresholdWorld
) continue;
let distSq = Infinity;
for (let i = 0; i < chair.hitSegments.length; i += 4) {
const x1 = chair.hitSegments[i];
const y1 = chair.hitSegments[i + 1];
const x2 = chair.hitSegments[i + 2];
const y2 = chair.hitSegments[i + 3];
const dx = x2 - x1;
const dy = y2 - y1;
const len2 = dx * dx + dy * dy;
let segDistSq;
if (len2 === 0) {
const px = pointerWorld.x - x1;
const py = pointerWorld.y - y1;
segDistSq = px * px + py * py;
} else {
let t = ((pointerWorld.x - x1) * dx + (pointerWorld.y - y1) * dy) / len2;
t = Math.max(0, Math.min(1, t));
const lx = x1 + t * dx;
const ly = y1 + t * dy;
const px = pointerWorld.x - lx;
const py = pointerWorld.y - ly;
segDistSq = px * px + py * py;
}
if (segDistSq < distSq) distSq = segDistSq;
if (distSq <= thresholdWorldSq * 0.3) break;
}
if (distSq > thresholdWorldSq) continue;
const dist = Math.sqrt(distSq) * camera.scale;
if (!best) {
best = { chair, dist };
continue;
}
const distGap = dist - best.dist;
if (distGap < -0.75) {
best = { chair, dist };
continue;
}
if (Math.abs(distGap) <= 2) {
const areaGap = chair.area - best.chair.area;
if (areaGap < -1) {
best = { chair, dist };
continue;
}
if (
Math.abs(areaGap) <= 1 &&
chair.kind === "block" &&
best.chair.kind !== "block"
) {
best = { chair, dist };
}
}
}
return best ? best.chair : null;
}
function renderTooltip() {
if (!hovered) {
tooltip.classList.remove("visible");
hoverChip.textContent = "chair hover: none";
return;
}
hoverChip.textContent = `chair hover: ${hovered.name}`;
tooltip.innerHTML = `
<strong>${hovered.name}</strong>
<div>chair key: ${hovered.key}</div>
<div>${placed.has(hovered.key) ? "선택됨" : "클릭하면 선택"}</div>
<div>${chairAssignments[hovered.key] ? `배치: ${(getPersonById(chairAssignments[hovered.key]) || { name: "알수없음" }).name}` : "배치 인원 없음"}</div>
`;
tooltip.style.left = `${pointer.x + 14}px`;
tooltip.style.top = `${pointer.y + 14}px`;
tooltip.classList.add("visible");
}
function requestDraw() {
if (rafPending) return;
rafPending = true;
window.requestAnimationFrame(() => {
rafPending = false;
draw();
});
}
function applyWorldTransform() {
ctx.setTransform(
pixelRatio * camera.scale,
0,
0,
-pixelRatio * camera.scale,
pixelRatio * camera.offsetX,
pixelRatio * ((world.maxY + world.minY) * camera.scale + camera.offsetY)
);
}
function draw() {
const rect = canvas.getBoundingClientRect();
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
ctx.clearRect(0, 0, rect.width, rect.height);
drawGrid(rect.width, rect.height);
const viewA = screenToWorld(0, rect.height);
const viewB = screenToWorld(rect.width, 0);
const viewMinX = Math.min(viewA.x, viewB.x);
const viewMaxX = Math.max(viewA.x, viewB.x);
const viewMinY = Math.min(viewA.y, viewB.y);
const viewMaxY = Math.max(viewA.y, viewB.y);
ctx.save();
applyWorldTransform();
ctx.strokeStyle = "rgba(100, 116, 139, 0.28)";
ctx.lineWidth = 1 / camera.scale;
const tileSize = meta.backgroundTileSize;
const tileMinX = Math.floor(viewMinX / tileSize);
const tileMaxX = Math.floor(viewMaxX / tileSize);
const tileMinY = Math.floor(viewMinY / tileSize);
const tileMaxY = Math.floor(viewMaxY / tileSize);
for (let tx = tileMinX; tx <= tileMaxX; tx += 1) {
for (let ty = tileMinY; ty <= tileMaxY; ty += 1) {
const range = bgTileRanges[`${tx},${ty}`];
if (!range) continue;
const start = range[0];
const count = range[1];
for (let i = start; i < start + count; i += 1) {
const offset = i * 4;
const x1 = bgSegValues[offset] / 10;
const y1 = bgSegValues[offset + 1] / 10;
const x2 = bgSegValues[offset + 2] / 10;
const y2 = bgSegValues[offset + 3] / 10;
if (
Math.max(x1, x2) < viewMinX ||
Math.min(x1, x2) > viewMaxX ||
Math.max(y1, y2) < viewMinY ||
Math.min(y1, y2) > viewMaxY
) continue;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
}
}
}
ctx.restore();
hovered = dragging ? null : pickChair(pointer.x, pointer.y);
ctx.save();
applyWorldTransform();
ctx.lineWidth = 1.45 / camera.scale;
ctx.lineCap = "round";
ctx.lineJoin = "round";
for (const chair of chairGeometry) {
if (chair.maxX < viewMinX || chair.minX > viewMaxX || chair.maxY < viewMinY || chair.minY > viewMaxY) continue;
const active = hovered && hovered.key === chair.key;
const selected = placed.has(chair.key);
const assignedPersonId = chairAssignments[chair.key];
const activePersonChair = activePersonId && assignedPersonId === activePersonId;
const assigned = Boolean(assignedPersonId);
const baseWidth = chair.kind === "block" ? 1.45 : 1.35;
ctx.strokeStyle = activePersonChair
? "rgba(234, 179, 8, 1)"
: assigned
? "rgba(37, 99, 235, 0.98)"
: selected
? "rgba(220, 38, 38, 0.98)"
: active
? "rgba(15, 118, 110, 0.98)"
: chair.kind === "group"
? "rgba(16, 134, 149, 0.74)"
: "rgba(21, 149, 142, 0.8)";
ctx.lineWidth = (activePersonChair ? 2.8 : assigned ? 2.4 : selected ? 2.6 : active ? 2.1 : baseWidth) / camera.scale;
ctx.stroke(chair.path);
}
ctx.restore();
scaleChip.textContent = `scale ${camera.scale.toFixed(4)}x`;
renderTooltip();
}
function persistPlaced() {
localStorage.setItem(STORAGE_KEY, JSON.stringify([...placed]));
}
canvas.addEventListener("pointerdown", (event) => {
dragging = true;
dragStart = { x: event.clientX, y: event.clientY, offsetX: camera.offsetX, offsetY: camera.offsetY };
canvas.classList.add("dragging");
});
window.addEventListener("pointerup", (event) => {
if (dragging && dragStart) {
const move = Math.hypot(event.clientX - dragStart.x, event.clientY - dragStart.y);
if (move < 4) {
const rect = canvas.getBoundingClientRect();
const picked = pickChair(event.clientX - rect.left, event.clientY - rect.top);
if (picked) {
if (placed.has(picked.key)) placed.delete(picked.key);
else placed.add(picked.key);
persistPlaced();
if (activePersonId) {
const currentChair = getChairByPerson(activePersonId);
if (chairAssignments[picked.key] === activePersonId) {
delete chairAssignments[picked.key];
} else {
if (currentChair && currentChair !== picked.key) delete chairAssignments[currentChair];
chairAssignments[picked.key] = activePersonId;
}
persistAssignments();
renderPeopleList();
}
}
}
}
dragging = false;
dragStart = null;
canvas.classList.remove("dragging");
requestDraw();
});
window.addEventListener("pointermove", (event) => {
const rect = canvas.getBoundingClientRect();
pointer = { x: event.clientX - rect.left, y: event.clientY - rect.top };
if (dragging && dragStart) {
camera.offsetX = dragStart.offsetX + (event.clientX - dragStart.x);
camera.offsetY = dragStart.offsetY + (event.clientY - dragStart.y);
}
requestDraw();
});
canvas.addEventListener("wheel", (event) => {
event.preventDefault();
const rect = canvas.getBoundingClientRect();
const mx = event.clientX - rect.left;
const my = event.clientY - rect.top;
const before = screenToWorld(mx, my);
const factor = event.deltaY < 0 ? 1.08 : 0.92;
camera.scale = Math.max(0.002, Math.min(2, camera.scale * factor));
const after = worldToScreen(before.x, before.y);
camera.offsetX += mx - after.x;
camera.offsetY += my - after.y;
requestDraw();
}, { passive: false });
document.getElementById("fit-btn").addEventListener("click", fit);
document.getElementById("clear-btn").addEventListener("click", () => {
placed.clear();
persistPlaced();
requestDraw();
});
clearAssignBtn.addEventListener("click", () => {
chairAssignments = {};
persistAssignments();
renderPeopleList();
requestDraw();
});
orgChartEl.addEventListener("click", (event) => {
const item = event.target.closest(".org-person[data-person-id]");
if (!item) return;
const personId = item.getAttribute("data-person-id");
activePersonId = personId === activePersonId ? null : personId;
persistActivePerson();
renderPeopleList();
requestDraw();
});
window.addEventListener("resize", resize);
renderPeopleList();
resize();
</script>
</body>
</html>

View File

@@ -0,0 +1,932 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>center chair people map 7f</title>
<style>
:root {
--ink: #152330;
--muted: #627286;
--paper: rgba(255,255,255,0.86);
--line: rgba(21,35,48,0.1);
--accent: #0f766e;
--bg: #edf2f6;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "IBM Plex Sans KR", "Pretendard", sans-serif;
color: var(--ink);
background:
radial-gradient(circle at top left, rgba(15,118,110,0.11), transparent 22%),
linear-gradient(180deg, #f5f8fb 0%, #e8eef3 100%);
}
.page {
min-height: 100vh;
padding: 0;
}
.shell {
min-height: 100vh;
}
.panel {
border-radius: 0;
border: none;
background: transparent;
backdrop-filter: none;
box-shadow: none;
}
.actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
button {
border: none;
border-radius: 999px;
padding: 10px 14px;
font: inherit;
font-weight: 700;
cursor: pointer;
color: white;
background: linear-gradient(135deg, #0f766e, #115e59);
box-shadow: 0 10px 22px rgba(15,118,110,0.18);
}
button.alt {
color: var(--ink);
background: rgba(255,255,255,0.9);
border: 1px solid var(--line);
box-shadow: none;
}
.viewer {
position: relative;
overflow: hidden;
min-height: 100vh;
}
.viewer-head {
position: absolute;
top: 16px;
left: 16px;
right: 16px;
z-index: 2;
display: flex;
justify-content: space-between;
gap: 12px;
pointer-events: none;
}
.chip {
padding: 10px 12px;
border-radius: 16px;
background: rgba(255,255,255,0.82);
border: 1px solid rgba(255,255,255,0.94);
color: var(--muted);
font-size: 13px;
font-weight: 700;
box-shadow: 0 8px 24px rgba(21,35,48,0.08);
}
.viewer-actions {
position: absolute;
left: 16px;
top: 64px;
z-index: 2;
display: flex;
gap: 8px;
}
.mapper {
position: absolute;
top: 76px;
left: 50%;
transform: translateX(-50%);
width: min(94vw, 1320px);
max-height: min(56vh, 560px);
overflow: hidden;
z-index: 4;
border-radius: 20px;
background: rgba(234, 239, 247, 0.95);
border: 1px solid rgba(101, 119, 146, 0.22);
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.2);
display: flex;
flex-direction: column;
backdrop-filter: blur(6px);
}
.hidden-off {
display: none !important;
}
.mapper-head {
padding: 10px 14px;
border-bottom: 1px solid rgba(101,119,146,0.18);
font-size: 12px;
color: #51607a;
font-weight: 700;
line-height: 1.35;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
background: rgba(255,255,255,0.6);
}
.mapper-head strong {
display: block;
color: #17243b;
font-size: 20px;
margin-bottom: 2px;
}
.mapper-head .alt {
padding: 8px 10px;
font-size: 12px;
white-space: nowrap;
}
.org-chart {
margin: 0;
padding: 14px;
overflow: auto;
display: grid;
gap: 12px;
}
.org-top {
margin: 0 auto;
width: min(100%, 420px);
border-radius: 14px;
overflow: hidden;
border: 1px solid rgba(67, 84, 118, 0.25);
background: #fff;
}
.org-top-title {
background: #1e2f4d;
color: #fff;
text-align: center;
font-size: 34px;
font-weight: 800;
line-height: 1.1;
padding: 16px 12px;
letter-spacing: -0.03em;
}
.org-top-members {
padding: 10px;
display: grid;
gap: 6px;
background: rgba(255,255,255,0.95);
}
.org-teams {
display: grid;
grid-template-columns: repeat(7, minmax(160px, 1fr));
gap: 10px;
align-items: start;
}
.org-team {
border: 1px solid rgba(110, 126, 152, 0.25);
border-radius: 10px;
overflow: hidden;
background: rgba(255,255,255,0.95);
min-width: 0;
}
.org-team h4 {
margin: 0;
padding: 9px 10px;
font-size: 14px;
color: #21324e;
font-weight: 800;
border-bottom: 1px solid rgba(110, 126, 152, 0.2);
background: rgba(240, 245, 252, 0.96);
}
.org-members {
padding: 7px;
display: grid;
gap: 6px;
}
.org-person {
border: 1px solid rgba(116, 133, 161, 0.25);
background: rgba(255,255,255,0.95);
border-radius: 8px;
padding: 6px 8px;
cursor: pointer;
transition: background 120ms ease, border-color 120ms ease;
min-width: 0;
}
.org-person.active {
border-color: rgba(15,118,110,0.6);
background: rgba(15,118,110,0.11);
}
.org-person.assigned {
border-color: rgba(37,99,235,0.5);
background: rgba(37,99,235,0.1);
}
.org-person strong {
display: block;
font-size: 13px;
line-height: 1.3;
color: #15233a;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.org-person small {
display: block;
color: #5a6a86;
font-size: 11px;
line-height: 1.25;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 980px) {
.mapper {
top: 72px;
width: min(96vw, 920px);
max-height: 58vh;
}
.viewer-actions {
top: 64px;
left: 12px;
right: 12px;
flex-wrap: wrap;
}
.mapper-head strong {
font-size: 16px;
}
.org-top-title {
font-size: 24px;
}
.org-teams {
grid-template-columns: repeat(3, minmax(150px, 1fr));
}
}
canvas {
width: 100%;
height: 100%;
display: block;
cursor: grab;
}
canvas.dragging { cursor: grabbing; }
.tooltip {
position: absolute;
min-width: 170px;
padding: 12px 14px;
border-radius: 16px;
background: rgba(17,24,39,0.94);
color: white;
pointer-events: none;
opacity: 0;
transform: translate(12px, 12px);
transition: opacity 120ms ease;
z-index: 3;
}
.tooltip.visible { opacity: 1; }
.tooltip strong { display: block; margin-bottom: 6px; font-size: 14px; }
.tooltip div { font-size: 12px; line-height: 1.45; color: rgba(255,255,255,0.82); }
</style>
</head>
<body>
<div class="page">
<div class="shell">
<main class="panel viewer">
<div class="viewer-head">
<div class="chip" id="scale-chip"></div>
<div class="chip" id="hover-chip">chair hover: none</div>
</div>
<div class="viewer-actions">
<button type="button" id="fit-btn">전체 맞춤</button>
<button type="button" class="alt" id="clear-btn">선택 지우기</button>
</div>
<aside class="mapper hidden-off">
<div class="mapper-head">
<div id="mapper-status">
<strong>조직 현황</strong>
<span>선택 인원 없음</span>
</div>
<button type="button" class="alt" id="clear-assign-btn">매칭 초기화</button>
</div>
<div class="org-chart" id="org-chart"></div>
</aside>
<canvas id="canvas"></canvas>
<div class="tooltip" id="tooltip"></div>
</main>
</div>
</div>
<script src="./center_chair_people_payload_7f.js"></script>
<script>
const DATA = window.CHAIR_MAP_DATA;
function decodeSegments(base64) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
return new Int32Array(bytes.buffer);
}
const bgTileRanges = DATA.bgTileRanges;
const bgSegValues = decodeSegments(DATA.bgSegsB64);
const chairSegValues = decodeSegments(DATA.chairSegsB64);
const chairs = DATA.chairs.map(([key, name, kind, start, count]) => ({
key, name, kind, start, count
}));
const meta = DATA.meta;
const world = meta.headerBounds;
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
const tooltip = document.getElementById("tooltip");
const scaleChip = document.getElementById("scale-chip");
const hoverChip = document.getElementById("hover-chip");
const STORAGE_KEY = "ptc-chair-selection";
const PEOPLE_STORAGE_KEY = "ptc-chair-people";
const ASSIGN_STORAGE_KEY = "ptc-chair-assignments";
const ACTIVE_PERSON_STORAGE_KEY = "ptc-chair-active-person";
const clearAssignBtn = document.getElementById("clear-assign-btn");
const orgChartEl = document.getElementById("org-chart");
const mapperStatus = document.getElementById("mapper-status");
// Prevent stale auto-highlights from previous sessions.
localStorage.removeItem(STORAGE_KEY);
localStorage.removeItem(ACTIVE_PERSON_STORAGE_KEY);
localStorage.removeItem(ASSIGN_STORAGE_KEY);
const placed = new Set();
let people = JSON.parse(localStorage.getItem(PEOPLE_STORAGE_KEY) || "[]");
let chairAssignments = {};
let activePersonId = null;
const ORG_TEMPLATE = {
top: {
name: "총괄기획실",
count: 53,
members: [
{ name: "장종찬", dept: "총괄기획실", title: "기획실장" },
{ name: "김원식", dept: "총괄기획실", title: "전무이사" },
],
},
teams: [
{ name: "경영기획팀", count: 6, members: ["김우진", "임민정", "국혜린", "최선아", "김윤재", "이미영"] },
{ name: "인재성장팀", count: 5, members: ["조태희", "최근혜", "류원준", "주안기", "정성호"] },
{ name: "ERP 기획팀", count: 5, members: ["류호성", "문형식", "최요제", "황대일", "이채봉"] },
{ name: "디자인기획팀", count: 17, members: ["신혜영", "정은혜", "김태식", "최예은", "채선영", "최영환", "윤봄이", "이예진", "허유나", "마희연", "김수현", "박지영", "권순호", "정두휘", "김정석", "정지윤", "양숙영"] },
{ name: "기술기획팀", count: 11, members: ["김원기", "홍아름", "이경민", "김혜인", "황동환", "최찬호", "이태훈", "김신지", "조찬영", "김용연", "한치영"] },
{ name: "협업증진팀", count: 3, members: ["성형일", "박주한", "한승민"] },
{ name: "솔루션통합팀", count: 4, members: ["권혁진", "염승호", "윤준수", "김지영"] },
],
};
const chairGeometry = chairs.map((chair) => {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
const path = new Path2D();
const hitSegments = new Float32Array(chair.count * 4);
let segCursor = 0;
for (let i = chair.start; i < chair.start + chair.count; i += 1) {
const offset = i * 4;
const x1 = chairSegValues[offset] / 10;
const y1 = chairSegValues[offset + 1] / 10;
const x2 = chairSegValues[offset + 2] / 10;
const y2 = chairSegValues[offset + 3] / 10;
path.moveTo(x1, y1);
path.lineTo(x2, y2);
hitSegments[segCursor] = x1;
hitSegments[segCursor + 1] = y1;
hitSegments[segCursor + 2] = x2;
hitSegments[segCursor + 3] = y2;
segCursor += 4;
minX = Math.min(minX, x1, x2);
minY = Math.min(minY, y1, y2);
maxX = Math.max(maxX, x1, x2);
maxY = Math.max(maxY, y1, y2);
}
return {
...chair,
minX,
minY,
maxX,
maxY,
area: Math.max(1, (maxX - minX) * (maxY - minY)),
path,
hitSegments,
};
});
function renumberChairKeys(chairItems) {
if (!chairItems.length) return;
const heights = chairItems
.map((chair) => Math.max(1, chair.maxY - chair.minY))
.sort((a, b) => a - b);
const medianHeight = heights[Math.floor(heights.length / 2)] || 1;
const rowTolerance = Math.max(40, medianHeight * 0.9);
const sorted = [...chairItems].sort((a, b) => {
const ay = (a.minY + a.maxY) * 0.5;
const by = (b.minY + b.maxY) * 0.5;
if (Math.abs(by - ay) > rowTolerance) return by - ay; // top -> bottom
const ax = (a.minX + a.maxX) * 0.5;
const bx = (b.minX + b.maxX) * 0.5;
return ax - bx; // left -> right
});
sorted.forEach((chair, index) => {
chair.key = String(index + 1);
chair.seatNo = index + 1;
});
}
renumberChairKeys(chairGeometry);
const PICK_GRID_SIZE = 1800;
const chairPickGrid = new Map();
function pickGridKey(gx, gy) {
return `${gx},${gy}`;
}
chairGeometry.forEach((chair, index) => {
const minGX = Math.floor(chair.minX / PICK_GRID_SIZE);
const maxGX = Math.floor(chair.maxX / PICK_GRID_SIZE);
const minGY = Math.floor(chair.minY / PICK_GRID_SIZE);
const maxGY = Math.floor(chair.maxY / PICK_GRID_SIZE);
for (let gx = minGX; gx <= maxGX; gx += 1) {
for (let gy = minGY; gy <= maxGY; gy += 1) {
const key = pickGridKey(gx, gy);
if (!chairPickGrid.has(key)) chairPickGrid.set(key, []);
chairPickGrid.get(key).push(index);
}
}
});
const camera = { scale: 1, offsetX: 0, offsetY: 0 };
let pixelRatio = window.devicePixelRatio || 1;
let pointer = { x: 0, y: 0 };
let dragging = false;
let dragStart = null;
let hovered = null;
let rafPending = false;
function normalizePeople(raw) {
return raw
.map((person, index) => {
if (!person || !person.name) return null;
return {
id: person.id || `person-${index + 1}`,
name: String(person.name).trim(),
dept: String(person.dept || "").trim(),
title: String(person.title || "").trim(),
};
})
.filter(Boolean);
}
function createTemplatePeople() {
const generated = [];
let seq = 1;
ORG_TEMPLATE.top.members.forEach((member) => {
generated.push({
id: `org-${seq++}`,
name: member.name,
dept: member.dept,
title: member.title,
});
});
ORG_TEMPLATE.teams.forEach((team) => {
team.members.forEach((name) => {
generated.push({
id: `org-${seq++}`,
name,
dept: team.name,
title: "선임",
});
});
});
return generated;
}
people = normalizePeople(people);
const templateReady = people.some((person) => person.name === "장종찬" && person.dept === "총괄기획실");
if (!templateReady) {
people = createTemplatePeople();
localStorage.setItem(PEOPLE_STORAGE_KEY, JSON.stringify(people));
}
const chairKeySet = new Set(chairGeometry.map((chair) => chair.key));
chairAssignments = Object.fromEntries(
Object.entries(chairAssignments).filter(([chairKey, personId]) => (
chairKeySet.has(chairKey) && people.some((person) => person.id === personId)
))
);
if (activePersonId && !people.some((person) => person.id === activePersonId)) activePersonId = null;
function persistPeople() {
localStorage.setItem(PEOPLE_STORAGE_KEY, JSON.stringify(people));
}
function persistAssignments() {
localStorage.setItem(ASSIGN_STORAGE_KEY, JSON.stringify(chairAssignments));
}
function persistActivePerson() {
if (!activePersonId) localStorage.removeItem(ACTIVE_PERSON_STORAGE_KEY);
else localStorage.setItem(ACTIVE_PERSON_STORAGE_KEY, activePersonId);
}
function assignmentCount() {
return Object.keys(chairAssignments).length;
}
function getPersonById(id) {
return people.find((person) => person.id === id) || null;
}
function getChairByPerson(personId) {
for (const [chairKey, assignedPersonId] of Object.entries(chairAssignments)) {
if (assignedPersonId === personId) return chairKey;
}
return null;
}
function renderPeopleList() {
const activePerson = getPersonById(activePersonId);
const countText = `${assignmentCount()} / ${people.length} 매칭`;
mapperStatus.innerHTML = `<strong>조직 현황</strong><span>${activePerson ? `${activePerson.name} 선택됨` : "선택 인원 없음"} · ${countText}</span>`;
const findPerson = (dept, name) => people.find((person) => person.dept === dept && person.name === name) || null;
const personCard = (person, roleText) => {
if (!person) return "";
const chairKey = getChairByPerson(person.id);
const assignedClass = chairKey ? " assigned" : "";
const activeClass = person.id === activePersonId ? " active" : "";
return `
<article class="org-person${assignedClass}${activeClass}" data-person-id="${person.id}">
<strong>${person.name}</strong>
<small>${person.title || roleText || "-"}</small>
<small>${chairKey ? `좌석 ${chairKey}` : "좌석 미지정"}</small>
</article>
`;
};
const topHtml = ORG_TEMPLATE.top.members
.map((member) => personCard(findPerson(member.dept, member.name), member.title))
.join("");
const teamsHtml = ORG_TEMPLATE.teams.map((team) => {
const membersHtml = team.members
.map((name) => personCard(findPerson(team.name, name), "선임"))
.join("");
return `
<section class="org-team">
<h4>${team.name} (${team.count})</h4>
<div class="org-members">${membersHtml}</div>
</section>
`;
}).join("");
orgChartEl.innerHTML = `
<section class="org-top">
<div class="org-top-title">${ORG_TEMPLATE.top.name} (${ORG_TEMPLATE.top.count})</div>
<div class="org-top-members">${topHtml}</div>
</section>
<section class="org-teams">${teamsHtml}</section>
`;
}
function worldToScreen(x, y) {
return {
x: x * camera.scale + camera.offsetX,
y: (world.maxY - y + world.minY) * camera.scale + camera.offsetY,
};
}
function screenToWorld(x, y) {
return {
x: (x - camera.offsetX) / camera.scale,
y: world.maxY + world.minY - (y - camera.offsetY) / camera.scale,
};
}
function resize() {
pixelRatio = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = Math.round(rect.width * pixelRatio);
canvas.height = Math.round(rect.height * pixelRatio);
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
fit();
}
function fit() {
const rect = canvas.getBoundingClientRect();
const width = world.maxX - world.minX;
const height = world.maxY - world.minY;
const pad = 36;
const scaleX = (rect.width - pad * 2) / width;
const scaleY = (rect.height - pad * 2) / height;
camera.scale = Math.min(scaleX, scaleY);
camera.offsetX = pad - world.minX * camera.scale + (rect.width - pad * 2 - width * camera.scale) / 2;
camera.offsetY = pad - world.minY * camera.scale + (rect.height - pad * 2 - height * camera.scale) / 2;
requestDraw();
}
function drawGrid(width, height) {
ctx.save();
ctx.strokeStyle = "rgba(21,35,48,0.05)";
ctx.lineWidth = 1;
for (let x = 120; x < width; x += 120) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
ctx.stroke();
}
for (let y = 120; y < height; y += 120) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(width, y);
ctx.stroke();
}
ctx.restore();
}
function pickChair(screenX, screenY) {
const threshold = 12;
const pointerWorld = screenToWorld(screenX, screenY);
const thresholdWorld = threshold / camera.scale;
const thresholdWorldSq = thresholdWorld * thresholdWorld;
const minGX = Math.floor((pointerWorld.x - thresholdWorld) / PICK_GRID_SIZE);
const maxGX = Math.floor((pointerWorld.x + thresholdWorld) / PICK_GRID_SIZE);
const minGY = Math.floor((pointerWorld.y - thresholdWorld) / PICK_GRID_SIZE);
const maxGY = Math.floor((pointerWorld.y + thresholdWorld) / PICK_GRID_SIZE);
const candidateIndexes = [];
const seen = new Set();
for (let gx = minGX; gx <= maxGX; gx += 1) {
for (let gy = minGY; gy <= maxGY; gy += 1) {
const candidates = chairPickGrid.get(pickGridKey(gx, gy));
if (!candidates) continue;
for (const index of candidates) {
if (seen.has(index)) continue;
seen.add(index);
candidateIndexes.push(index);
}
}
}
let best = null;
for (const index of candidateIndexes) {
const chair = chairGeometry[index];
if (
pointerWorld.x < chair.minX - thresholdWorld ||
pointerWorld.x > chair.maxX + thresholdWorld ||
pointerWorld.y < chair.minY - thresholdWorld ||
pointerWorld.y > chair.maxY + thresholdWorld
) continue;
let distSq = Infinity;
for (let i = 0; i < chair.hitSegments.length; i += 4) {
const x1 = chair.hitSegments[i];
const y1 = chair.hitSegments[i + 1];
const x2 = chair.hitSegments[i + 2];
const y2 = chair.hitSegments[i + 3];
const dx = x2 - x1;
const dy = y2 - y1;
const len2 = dx * dx + dy * dy;
let segDistSq;
if (len2 === 0) {
const px = pointerWorld.x - x1;
const py = pointerWorld.y - y1;
segDistSq = px * px + py * py;
} else {
let t = ((pointerWorld.x - x1) * dx + (pointerWorld.y - y1) * dy) / len2;
t = Math.max(0, Math.min(1, t));
const lx = x1 + t * dx;
const ly = y1 + t * dy;
const px = pointerWorld.x - lx;
const py = pointerWorld.y - ly;
segDistSq = px * px + py * py;
}
if (segDistSq < distSq) distSq = segDistSq;
if (distSq <= thresholdWorldSq * 0.3) break;
}
if (distSq > thresholdWorldSq) continue;
const dist = Math.sqrt(distSq) * camera.scale;
if (!best) {
best = { chair, dist };
continue;
}
const distGap = dist - best.dist;
if (distGap < -0.75) {
best = { chair, dist };
continue;
}
if (Math.abs(distGap) <= 2) {
const areaGap = chair.area - best.chair.area;
if (areaGap < -1) {
best = { chair, dist };
continue;
}
if (
Math.abs(areaGap) <= 1 &&
chair.kind === "block" &&
best.chair.kind !== "block"
) {
best = { chair, dist };
}
}
}
return best ? best.chair : null;
}
function renderTooltip() {
if (!hovered) {
tooltip.classList.remove("visible");
hoverChip.textContent = "chair hover: none";
return;
}
hoverChip.textContent = `chair hover: ${hovered.name}`;
tooltip.innerHTML = `
<strong>${hovered.name}</strong>
<div>chair key: ${hovered.key}</div>
<div>${placed.has(hovered.key) ? "선택됨" : "클릭하면 선택"}</div>
<div>${chairAssignments[hovered.key] ? `배치: ${(getPersonById(chairAssignments[hovered.key]) || { name: "알수없음" }).name}` : "배치 인원 없음"}</div>
`;
tooltip.style.left = `${pointer.x + 14}px`;
tooltip.style.top = `${pointer.y + 14}px`;
tooltip.classList.add("visible");
}
function requestDraw() {
if (rafPending) return;
rafPending = true;
window.requestAnimationFrame(() => {
rafPending = false;
draw();
});
}
function applyWorldTransform() {
ctx.setTransform(
pixelRatio * camera.scale,
0,
0,
-pixelRatio * camera.scale,
pixelRatio * camera.offsetX,
pixelRatio * ((world.maxY + world.minY) * camera.scale + camera.offsetY)
);
}
function draw() {
const rect = canvas.getBoundingClientRect();
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
ctx.clearRect(0, 0, rect.width, rect.height);
drawGrid(rect.width, rect.height);
const viewA = screenToWorld(0, rect.height);
const viewB = screenToWorld(rect.width, 0);
const viewMinX = Math.min(viewA.x, viewB.x);
const viewMaxX = Math.max(viewA.x, viewB.x);
const viewMinY = Math.min(viewA.y, viewB.y);
const viewMaxY = Math.max(viewA.y, viewB.y);
ctx.save();
applyWorldTransform();
ctx.strokeStyle = "rgba(100, 116, 139, 0.28)";
ctx.lineWidth = 1 / camera.scale;
const tileSize = meta.backgroundTileSize;
const tileMinX = Math.floor(viewMinX / tileSize);
const tileMaxX = Math.floor(viewMaxX / tileSize);
const tileMinY = Math.floor(viewMinY / tileSize);
const tileMaxY = Math.floor(viewMaxY / tileSize);
for (let tx = tileMinX; tx <= tileMaxX; tx += 1) {
for (let ty = tileMinY; ty <= tileMaxY; ty += 1) {
const range = bgTileRanges[`${tx},${ty}`];
if (!range) continue;
const start = range[0];
const count = range[1];
for (let i = start; i < start + count; i += 1) {
const offset = i * 4;
const x1 = bgSegValues[offset] / 10;
const y1 = bgSegValues[offset + 1] / 10;
const x2 = bgSegValues[offset + 2] / 10;
const y2 = bgSegValues[offset + 3] / 10;
if (
Math.max(x1, x2) < viewMinX ||
Math.min(x1, x2) > viewMaxX ||
Math.max(y1, y2) < viewMinY ||
Math.min(y1, y2) > viewMaxY
) continue;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
}
}
}
ctx.restore();
hovered = dragging ? null : pickChair(pointer.x, pointer.y);
ctx.save();
applyWorldTransform();
ctx.lineWidth = 1.45 / camera.scale;
ctx.lineCap = "round";
ctx.lineJoin = "round";
for (const chair of chairGeometry) {
if (chair.maxX < viewMinX || chair.minX > viewMaxX || chair.maxY < viewMinY || chair.minY > viewMaxY) continue;
const active = hovered && hovered.key === chair.key;
const selected = placed.has(chair.key);
const assignedPersonId = chairAssignments[chair.key];
const activePersonChair = activePersonId && assignedPersonId === activePersonId;
const assigned = Boolean(assignedPersonId);
const baseWidth = chair.kind === "block" ? 1.45 : 1.35;
ctx.strokeStyle = activePersonChair
? "rgba(234, 179, 8, 1)"
: assigned
? "rgba(37, 99, 235, 0.98)"
: selected
? "rgba(220, 38, 38, 0.98)"
: active
? "rgba(15, 118, 110, 0.98)"
: chair.kind === "group"
? "rgba(16, 134, 149, 0.74)"
: "rgba(21, 149, 142, 0.8)";
ctx.lineWidth = (activePersonChair ? 2.8 : assigned ? 2.4 : selected ? 2.6 : active ? 2.1 : baseWidth) / camera.scale;
ctx.stroke(chair.path);
}
ctx.restore();
scaleChip.textContent = `scale ${camera.scale.toFixed(4)}x`;
renderTooltip();
}
function persistPlaced() {
localStorage.setItem(STORAGE_KEY, JSON.stringify([...placed]));
}
canvas.addEventListener("pointerdown", (event) => {
dragging = true;
dragStart = { x: event.clientX, y: event.clientY, offsetX: camera.offsetX, offsetY: camera.offsetY };
canvas.classList.add("dragging");
});
window.addEventListener("pointerup", (event) => {
if (dragging && dragStart) {
const move = Math.hypot(event.clientX - dragStart.x, event.clientY - dragStart.y);
if (move < 4) {
const rect = canvas.getBoundingClientRect();
const picked = pickChair(event.clientX - rect.left, event.clientY - rect.top);
if (picked) {
if (placed.has(picked.key)) placed.delete(picked.key);
else placed.add(picked.key);
persistPlaced();
if (activePersonId) {
const currentChair = getChairByPerson(activePersonId);
if (chairAssignments[picked.key] === activePersonId) {
delete chairAssignments[picked.key];
} else {
if (currentChair && currentChair !== picked.key) delete chairAssignments[currentChair];
chairAssignments[picked.key] = activePersonId;
}
persistAssignments();
renderPeopleList();
}
}
}
}
dragging = false;
dragStart = null;
canvas.classList.remove("dragging");
requestDraw();
});
window.addEventListener("pointermove", (event) => {
const rect = canvas.getBoundingClientRect();
pointer = { x: event.clientX - rect.left, y: event.clientY - rect.top };
if (dragging && dragStart) {
camera.offsetX = dragStart.offsetX + (event.clientX - dragStart.x);
camera.offsetY = dragStart.offsetY + (event.clientY - dragStart.y);
}
requestDraw();
});
canvas.addEventListener("wheel", (event) => {
event.preventDefault();
const rect = canvas.getBoundingClientRect();
const mx = event.clientX - rect.left;
const my = event.clientY - rect.top;
const before = screenToWorld(mx, my);
const factor = event.deltaY < 0 ? 1.08 : 0.92;
camera.scale = Math.max(0.002, Math.min(2, camera.scale * factor));
const after = worldToScreen(before.x, before.y);
camera.offsetX += mx - after.x;
camera.offsetY += my - after.y;
requestDraw();
}, { passive: false });
document.getElementById("fit-btn").addEventListener("click", fit);
document.getElementById("clear-btn").addEventListener("click", () => {
placed.clear();
persistPlaced();
requestDraw();
});
clearAssignBtn.addEventListener("click", () => {
chairAssignments = {};
persistAssignments();
renderPeopleList();
requestDraw();
});
orgChartEl.addEventListener("click", (event) => {
const item = event.target.closest(".org-person[data-person-id]");
if (!item) return;
const personId = item.getAttribute("data-person-id");
activePersonId = personId === activePersonId ? null : personId;
persistActivePerson();
renderPeopleList();
requestDraw();
});
window.addEventListener("resize", resize);
renderPeopleList();
resize();
</script>
</body>
</html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More