📱 HandsOn 메뉴얼
전자정부프레임워크 기반 모바일 하이브리드 웹앱 시스템 운영·개발 매뉴얼
eGovFramework 4.3.1 Spring 5.3.39 MyBatis 3.5.16 PostgreSQL 16 JDK 17 LTS Tomcat 9.x v1.0
1 🏗️ 시스템 개요
📌 시스템 소개

Penta HandsOn System은 전자정부프레임워크(eGovFramework) 4.3.1 기반의 모바일 하이브리드 웹앱입니다. Android / iOS 기기에서 카메라, GPS, 바코드 스캔 등 디바이스 기능과 연동되며, 결재 워크플로우, 재고조사, 영업일지, 현장방문 등 다양한 업무 화면을 제공합니다.

🎯 주요 특징
  • 모바일 하이브리드 앱 (Android / iOS)
  • HTTP / HTTPS 동시 지원
  • 오프라인 모드 부분 지원
  • 결재 워크플로우 (기안→결재→완료)
  • 실시간 푸시 알림 (FCM)
  • GPS 기반 현장방문 기록
  • 바코드 스캔 연동 재고조사
📦 주요 업무 화면
  • 신용카드 결제내역 (SAMPLE_01)
  • 재고현황 표 (SAMPLE_02)
  • 차트 현황 (SAMPLE_03)
  • 결재 업무 (SAMPLE_04)
  • 영업활동 일지 (SAMPLE_05)
  • 자원관리·예약 (SAMPLE_05)
  • 바코드 재고조사 (SAMPLE_08)
🔗 기술 스택
  • 🔵 Backend: Java 17 / Spring 5.3 / MyBatis 3.5
  • 🔵 Frontend: JSP / jQuery / Chart.js
  • 🔵 Database: PostgreSQL 16
  • 🔵 WAS: Apache Tomcat 9.x
  • 🔵 빌드: Maven (WAR 패키징)
  • 🔵 IDE: Eclipse (eGovFrameDev 4.3.1)
🗂️ 전체 아키텍처
📱 Android / iOS App
🌐 Nginx (Web Server)
☕ Tomcat 9 (WAS)
🗄️ PostgreSQL 16 (DB)
ℹ️
개발 환경에서는 Nginx 없이 Tomcat만으로도 직접 운영 가능합니다. 운영 서버에서는 Nginx → Tomcat 리버스 프록시 구성을 권장합니다.
📁 프로젝트 디렉토리 구조
HandsOn/
├── src/main/java/egovframework/penta/ ← Java 소스
│ ├── business/ ← 업무 화면 (Controller, Service, DAO, VO)
│ ├── user/ ← 사용자 관리
│ ├── message/ ← 메시지/푸시
│ ├── system/ ← 공통기능 (파일, 로그)
│ └── board/ ← 게시판
├── src/main/resources/
│ ├── egovframework/spring/ ← Spring 설정 XML
│ ├── egovframework/sqlmap/ ← MyBatis SQL XML
│ └── globals.properties ← DB/파일서버/시스템 설정
├── src/main/webapp/WEB-INF/jsp/ ← JSP 화면
│ ├── business/ ← 업무 화면 JSP
│ ├── common/ ← 공통 레이아웃
│ └── main/ ← 홈/대시보드
└── pom.xml ← Maven 의존성 관리
2 💻 서버별 필수 하드웨어 사양
⚠️
아래 사양은 현재 운영 기준으로 산정한 권장 사양입니다. 동시 접속자 수 100명 기준이며, 첨부파일 용량·데이터 규모·BLOB 저장량에 따라 추가 증설이 필요합니다.
🖥️ WAS 서버 (Tomcat 서버)
항목현재 운영 사양권장 사양비고
CPU4 Core (2.0GHz)8 Core (2.4GHz+)가상화 환경 가능 (vCPU)
Memory (RAM)8 GB16 GBJVM Heap: 4GB 이상 확보
Disk (OS + App)50 GB SSD200 GB SSDOS 60 + 앱 20 + 로그 120
Disk (임시파일)별도 없음100 GB SSD 추가파일 업로드 임시 버퍼용
OSWindows Server 2019Windows Server 2022 / Rocky Linux 964bit 필수
Network100 Mbps1 Gbps내부망 전용 가능
JVM Heap 설정-Xms512m -Xmx1024m-Xms1g -Xmx4gsetenv.bat/sh 수정
DBCP 커넥션 풀maxTotal=20maxTotal=50context-datasource.xml 수정
포트8080 (HTTP), 8443 (HTTPS)동일방화벽 오픈 필요
🗄️ DB 서버 (PostgreSQL 16 서버)
항목현재 운영 사양권장 사양비고
CPU4 Core (2.0GHz)16 Core (2.4GHz+)무료 오픈소스 DBMS, 상업용 무제한 사용 가능
Memory (RAM)16 GB64 GBshared_buffers 16GB + work_mem 기준
Disk (OS)60 GB SSD200 GB SSDPostgreSQL 설치 및 시스템 드라이브
Disk (Data)100 GB SSD1 TB SSD (RAID-1)BLOB(현장사진·첨부파일) 포함
Disk (Archive)별도 없음500 GB HDDArchive Log, RMAN 백업용
OSRocky Linux 9Rocky Linux 9 / Ubuntu 22.0464bit 필수
shared_buffers4 GB16 GBPostgreSQL 파라미터
work_mem1 GB4 GBPostgreSQL 파라미터
포트5432 (PostgreSQL)동일WAS→DB 방화벽 오픈
💡
PostgreSQL 16을 사용합니다.
Database: handson / User: handson
PostgreSQL은 무료 오픈소스로 상업용 환경에서도 제한 없이 사용 가능합니다.
🌐 Web 서버 (Nginx / Apache)
항목현재 운영 사양권장 사양비고
CPU2 Core8 CoreWAS와 분리 구성 권장
Memory (RAM)4 GB16 GB대용량 정적 파일 캐시 포함
Disk30 GB SSD100 GB SSDSSL 인증서 + 액세스 로그
Network100 Mbps1 Gbps외부 트래픽 처리
Worker 프로세스autoCPU Core 수와 동일nginx.conf worker_processes
포트80 (HTTP), 443 (HTTPS)동일외부 방화벽 오픈
소규모 운영 시 WAS + Web 서버를 한 서버에 구성 가능합니다.
통합 구성 권장 최소: CPU 16 Core / RAM 32 GB / SSD 500 GB (WAS + Nginx 동일 서버)
📁 파일 서버 (NAS / 별도 스토리지)
항목현재 운영 사양권장 사양비고
용도파일 업로드/다운로드 물리적 저장소 (현장사진, 첨부파일, 결재문서 등)
저장 용량200 GB2 TB (RAID-5 구성)현장사진 BLOB 규모 감안
전송 속도10/100 Mbps1 Gbps NIC현장사진 다량 업로드 대응
백업 주기수동일 1회 자동 증분백업rsync 또는 NAS 내장 백업 기능
경로 설정globals.propertiesfile.upload.root=D:/fileserver/penta
접근 방식로컬 경로로컬 경로 또는 SMB/NFS 마운트마운트 시 WAS 재시작 불필요
📊 서버 구성 요약 (권장 사양 기준)
🖥️ WAS 서버
  • • CPU: 8 Core / 2.4GHz+
  • • RAM: 16 GB
  • • Disk: 300 GB SSD
  • • JVM: -Xms1g -Xmx4g
  • • DBCP Pool: 50
🗄️ DB 서버
  • • CPU: 16 Core / 2.4GHz+
  • • RAM: 64 GB
  • • Disk: 200 GB(OS) + 1TB SSD(Data)
  • • SGA: 16 GB / PGA: 4 GB
  • • 백업: RMAN 일 1회
🌐 Web 서버
  • • CPU: 8 Core
  • • RAM: 16 GB
  • • Disk: 100 GB SSD
  • • Network: 1 Gbps
  • • SSL: Let's Encrypt or 상용 인증서
📁 파일 서버
  • • 용량: 2 TB (RAID-5)
  • • NIC: 1 Gbps
  • • 백업: 일 1회 증분
  • • 접근: SMB/NFS 마운트
  • • UPS: 정전 대비 권장
3 📦 소프트웨어 환경 요구사항
⚙️ 필수 소프트웨어 버전 요약
소프트웨어필수 버전권장 버전용도
JDK17 LTS (최소 11)JDK 17 LTS (Eclipse Temurin)애플리케이션 실행 환경
Apache Tomcat9.0.x (Servlet 4.0)9.0.98+WAS (Web Application Server)
PostgreSQL14+ 16 (LTS)관계형 데이터베이스
PostgreSQL JDBCpostgresql-42.xpostgresql-42.7.5DB 연결 드라이버
Maven3.6+3.9+빌드 도구
Eclipse IDEeGovFrameDev 4.3.1동일개발 도구
Nginx1.20+1.26 (stable)Web 서버 / 리버스 프록시
Apache HTTP2.4+2.4.58+Nginx 대안 Web 서버
🚀 지원 WAS 서버
✅ Apache Tomcat 9.x 권장

Servlet 4.0 / JSP 2.3 지원. 전자정부프레임워크 공식 지원 WAS.
web.xml에서 version="4.0"으로 설정됨.
다운로드: tomcat.apache.org

🔵 Apache Tomcat 10.x 주의

Jakarta EE 9+로 패키지명이 변경됨 (javax.*jakarta.*). 현재 소스코드는 javax.servlet을 사용하므로 Tomcat 9.x 권장.

🔵 JBoss / WildFly

WAR 배포 가능하나 별도 설정 필요.
공식 지원 대상 아님.

🔵 WebLogic / WebSphere

전자정부프레임워크 호환 가능하나 라이선스 비용 발생.
엔터프라이즈 환경에서 사용.

🌐 지원 Web 서버
✅ Nginx 1.20+ 권장

경량 고성능 Web 서버. 리버스 프록시로 Tomcat 앞단 처리.
SSL 종료 처리 (HTTPS → HTTP 내부 전달).
다운로드: nginx.org

🔵 Apache HTTP 2.4+

mod_jk 또는 mod_proxy로 Tomcat과 연동.
.htaccess 등 레거시 설정 필요 시 적합.

💡
개발/테스트 환경에서는 Web 서버 없이 Tomcat 단독으로 운영 가능합니다.
URL: http://서버IP:8080/HandsOn/ 또는 http://서버IP:8080/ (context path 설정에 따라 다름)
4 JDK 설치 가이드
📌 필수 JDK 버전
⚠️
JDK 17 (LTS) 사용 이 시스템은 JDK 17 (LTS)을 기준으로 컴파일됩니다. JDK 11로도 동작하지만 pom.xml의 컴파일 레벨 설정을 맞춰야 합니다. JDK 21 이상은 미검증입니다.
🔽 JDK 다운로드 및 설치 (Windows)
  1. Eclipse Temurin JDK 17 다운로드
    Eclipse Adoptium 공식 사이트에서 Windows x64 Installer (.msi) 다운로드
    OpenJDK17U-jdk_x64_windows_hotspot_17.0.x_x.msi 실행 후 기본 경로 설치
  2. 환경변수 설정
    시스템 속성 → 환경변수 → 시스템 변수에 아래 항목 설정
    변수명값 (예시)
    JAVA_HOMEC:\Program Files\Eclipse Adoptium\jdk-17.x.x.x-hotspot
    Path (추가)%JAVA_HOME%\bin
  3. 설치 확인
    명령 프롬프트(cmd)에서 버전 확인
    cmd
    java -version
    # 출력 예시:
    openjdk version "17.0.x" 2023-xx-xx
    OpenJDK Runtime Environment Temurin-17.0.x+x (build 17.0.x+x)
    OpenJDK 64-Bit Server VM Temurin-17.0.x+x (build 17.0.x+x, mixed mode, sharing)
🔧 Eclipse 연동 설정
  1. eGovFrameDev 4.3.1 IDE 실행
    전자정부프레임워크 통합개발환경 IDE를 실행합니다.
    eGovFrameDev-4.3.1-64bit\eclipse.exe
  2. JRE 등록: Window → Preferences → Java → Installed JREs
    Add → Standard VM → JDK 17 설치 경로 지정 → Finish → 체크박스 선택
  3. 컴파일러 레벨 확인: Java → Compiler → Compiler compliance level: 18
    프로젝트 우클릭 → Properties → Java Compiler에서도 동일하게 설정
  4. pom.xml 컴파일 설정 확인
    pom.xml (발췌)
    <properties>
      <java.version>18</java.version>
      <maven.compiler.source>18</maven.compiler.source>
      <maven.compiler.target>18</maven.compiler.target>
    </properties>

5 개발환경 설정 가이드

이 섹션은 새 PC에서 처음부터 개발 환경을 구성하는 절차를 단계별로 설명합니다. 파워빌더 개발자나 비개발자도 따라할 수 있도록 다운로드 URL과 설치 옵션을 모두 명시합니다.

⚠️
설치 순서를 반드시 지켜주세요. JDK → Eclipse → Tomcat 순서로 설치해야 Eclipse가 JDK를 자동 인식합니다. 이미 설치된 항목은 건너뛰어도 됩니다.
☕ 1단계 — JDK 17 설치

왜 JDK 17인가?
이 프로젝트는 pom.xml<java.version>18</java.version>으로 설정되어 있습니다. JDK 버전이 다르면 컴파일 오류가 발생합니다.

💡
파워빌더 비유: PB IDE에 맞는 Runtime을 설치하는 것과 동일합니다. JDK가 Java의 런타임+컴파일러 역할을 합니다.

다운로드

배포사다운로드 위치비고
Eclipse Temurin (추천)adoptium.net → Temurin 17 → Windows x64 .msi무료, LTS급 안정성
Microsoft OpenJDKlearn.microsoft.com/java/openjdk/download → JDK 17무료, Azure 환경 최적화

설치 절차

  1. 다운로드한 .msi 파일 실행Next 클릭
  2. 설치 경로 확인 — 기본값(C:\Program Files\Eclipse Adoptium\jdk-17.x.x.x-hotspot\) 권장. 경로에 한글/공백이 없어야 합니다.
  3. 설치 완료 후 환경변수 확인
    # 명령 프롬프트(cmd) 실행
    java -version
    # 출력 예: openjdk version "17.0.x" ...
  4. JAVA_HOME 설정 (자동으로 안 된 경우)
    1. 시작 → 환경 변수 편집 검색 → 실행
    2. 시스템 변수 → 새로 만들기 → 변수명: JAVA_HOME, 값: C:\Program Files\Eclipse Adoptium\jdk-17.x.x.x-hotspot
    3. Path 변수 선택 → 편집새로 만들기%JAVA_HOME%\bin 추가
    4. 모든 창 확인 → cmd 재시작 후 java -version 재확인
🌙 2단계 — Eclipse 2024-03 (4.31.0) 설치
⚠️
반드시 Eclipse 2024-03 버전을 사용하세요. 다른 버전(2023-xx, 2024-06 이상)은 플러그인 호환성 문제가 발생할 수 있습니다.

다운로드

항목내용
다운로드 URLeclipse.org/downloads/packages/release/2024-03/r
패키지 선택Eclipse IDE for Enterprise Java and Web Developers (Windows x86_64)
파일 형식.zip 다운로드 (installer가 아닌 zip 권장)
버전 확인Eclipse 4.31.0 · Release 2024-03

설치 절차

  1. ZIP 파일 압축 해제C:\eclipse\ 또는 D:\dev\eclipse\ 등 경로에 한글/공백 없이 설치
  2. eclipse.exe 실행 → Workspace 경로 지정
    예: D:\project\handson\eGovFrameDev-4.3.1-64bit\workspace-egov
  3. JDK 연결 확인
    Window → Preferences → Java → Installed JREs → Add → Standard VM → JDK 경로 선택 → Finish → 체크박스 선택 → Apply
  4. Compiler 버전 확인
    Window → Preferences → Java → Compiler → Compiler compliance level: 18 선택
  5. 인코딩 설정 (UTF-8)
    Window → Preferences → General → Workspace → Text file encoding: UTF-8
    Window → Preferences → General → Content Types → Text → Default encoding: UTF-8
💡
eGovFrame 플러그인 설치
Help → Eclipse Marketplace → 검색창에 eGovFrame 입력 → eGovFrame 4.x 설치 → Eclipse 재시작
(이미 설치된 환경이라면 생략 가능)
📂 3단계 — 프로젝트 소스 Import

소스코드가 로컬 폴더에 있다고 가정합니다. 경로 예: D:\project\handson\eGovFrameDev-4.3.1-64bit\workspace-egov\handson

  1. File → Import 클릭
  2. Maven → Existing Maven Projects 선택 → Next
  3. Root Directory에 프로젝트 폴더 경로 입력 또는 Browse로 선택
    pom.xml이 자동으로 감지되면 체크박스가 표시됩니다.
  4. Finish 클릭 → Maven이 라이브러리 자동 다운로드 (최초 수분 소요)
  5. 빌드 오류 확인
    Package Explorer에서 프로젝트에 빨간 X(❌)가 없으면 성공.
    있으면 프로젝트 우클릭 → Maven → Update Project → Force Update → OK
자주 발생하는 Import 오류원인해결
Plugin … not foundMaven Central 연결 실패회사 프록시 설정 또는 VPN 연결 후 재시도
JRE mismatchJDK 버전 불일치Installed JREs에서 JDK 17 선택 후 Update Project
Facet Java version mismatch프로젝트 Facet 설정 오류프로젝트 우클릭 → Properties → Project Facets → Java 17로 변경
🐱 4단계 — Tomcat 9 설치 및 Eclipse 연동
💡
Tomcat 버전: 이 프로젝트는 Tomcat 9.0.x를 사용합니다. Tomcat 10.x는 Jakarta EE로 패키지명이 변경되어 호환되지 않습니다.

Tomcat 다운로드 및 설치

  1. 다운로드
    tomcat.apache.org → Download → Tomcat 9 → 64-bit Windows zip 다운로드
  2. 압축 해제
    C:\tomcat9\ 또는 D:\dev\tomcat9\ — 경로에 한글·공백 없이
  3. conf\server.xml 인코딩 설정 확인
    <Connector port="8080" protocol="HTTP/1.1"
               connectionTimeout="20000"
               redirectPort="8443"
               URIEncoding="UTF-8" />
    URIEncoding="UTF-8"이 없으면 한글 파라미터가 깨집니다.

Eclipse에 Tomcat 서버 등록

  1. Window → Show View → Servers 뷰 열기
  2. Servers 탭 하단 빈 공간 우클릭 → New → Server
  3. Apache → Tomcat v9.0 Server 선택 → Next
  4. Tomcat installation directory에 압축 해제한 경로 입력 → Finish
  5. Servers 탭에 Tomcat v9.0 Server at localhost 등록 확인
  6. 서버 더블클릭 → Timeouts 항목에서 Start: 180초로 설정 (초기 구동 시간 확보)
  7. 프로젝트를 서버에 추가: Servers 탭 → 서버 우클릭 → Add and Remove → handson 프로젝트 → Add → Finish
🗄️ 5단계 — PostgreSQL DB 연결 설정

DB 연결 정보는 src/main/resources/egovframework/spring/context-datasource.xml에 정의되어 있습니다.

context-datasource.xml — JDBC 연결 정보
<bean id="dataSource" class="org.apache.commons.dbcp2.BasicDataSource" destroy-method="close">
    <property name="driverClassName" value="org.postgresql.Driver"/>
    <property name="url"
              value="jdbc:postgresql://192.168.100.66:5432/handson"/>
    <property name="username" value="handson"/>
    <property name="password" value="yourpassword"/>
    <property name="initialSize" value="5"/>
    <property name="maxTotal" value="20"/>
</bean>
항목설명로컬 개발 시 변경 포인트
urlPostgreSQL 서버 주소/포트/DB명로컬 PostgreSQL이면 jdbc:postgresql://localhost:5432/handson으로 변경
usernamePostgreSQL 사용자명handson (별도 생성 필요)
password비밀번호DBA에게 문의하거나 로컬 설치 시 직접 지정

PostgreSQL JDBC 드라이버 확인
pom.xmlpostgresql-42.7.5 의존성이 이미 추가되어 있습니다.
Maven 빌드 시 자동으로 다운로드됩니다. (별도 파일 복사 불필요)

💡
로컬 DB 없을 때: 회사 개발 서버(192.168.100.66)에 VPN 접속 후 url을 그대로 사용하면 별도 로컬 PostgreSQL 설치 없이 개발 가능합니다.
🔨 6단계 — 빌드 및 로컬 실행

Maven 빌드 (clean install)

  1. Package Explorer에서 handson 프로젝트 우클릭
  2. Run As → Maven build… 클릭
  3. Goals 입력란에 clean install -DskipTests 입력 → Run
  4. Console 창에 BUILD SUCCESS가 표시되면 완료
⚠️
BUILD FAILURE 시: 오류 메시지에서 ERROR가 있는 줄을 찾아 패키지명과 오류 코드를 확인하세요. 대부분 라이브러리 다운로드 실패(네트워크) 또는 Java 버전 불일치입니다.

Tomcat에서 실행

  1. Servers 탭 → Tomcat v9.0 Server 선택 → 상단 ▶ (Start) 버튼 클릭
  2. Console 창에 Server startup in [xxxx] milliseconds 메시지 확인
  3. 브라우저에서 접속 확인
    http://localhost:8080/HandsOn/login.do
  4. 로그인 테스트: admin / admin123 (또는 DB에 등록된 계정)
자주 발생하는 기동 오류원인해결
포트 8080 이미 사용 중다른 프로그램(스카이프, IIS 등)이 점유서버 더블클릭 → HTTP port를 8090으로 변경
ClassNotFoundException: org.postgresql.Driverpostgresql jar 누락pom.xml postgresql 의존성 확인 후 Maven → Update Project
Cannot connect to DBDB 서버 미접속 또는 방화벽VPN 연결 확인, ping 192.168.100.66 테스트
404 /HandsOn/login.do프로젝트가 서버에 Deploy 안 됨Add and Remove에서 프로젝트 추가 후 재시작
한글 깨짐URIEncoding 미설정server.xml Connector에 URIEncoding="UTF-8" 추가

JSP 수정 후 반영 방법

파일 종류반영 방법서버 재시작 필요
JSP (.jsp)파일 저장(Ctrl+S) → 브라우저 새로고침(F5)❌ 불필요
Java (.java)파일 저장(Ctrl+S) → 자동 재컴파일✅ 서버 재시작 필요
XML 설정 (spring/*.xml)파일 저장 후✅ 서버 재시작 필요
CSS / JS파일 저장 → 브라우저 강제새로고침(Ctrl+Shift+R)❌ 불필요
✅ 개발환경 구성 완료 체크리스트
항목확인 방법OK?
JDK 17 설치cmd: java -version → 17.x 표시
JAVA_HOME 설정cmd: echo %JAVA_HOME% → JDK 경로 표시
Eclipse 2024-03 설치Help → About → 4.31.0 표시
Eclipse JDK 17 연결Preferences → Java → Installed JREs → JDK 18 체크
인코딩 UTF-8Preferences → General → Workspace → UTF-8
프로젝트 Import 완료Package Explorer에 handson 프로젝트 표시, 빨간 X 없음
Tomcat 9 등록Servers 탭에 Tomcat v9.0 표시
Maven 빌드 성공Console: BUILD SUCCESS
DB 연결기동 시 ORA- 오류 없음
브라우저 접속http://localhost:8080/HandsOn/login.do 로그인 화면 표시
💡
권장 Eclipse 플러그인
  • Eclipse XML Editor — Spring XML 편집 지원
  • Spring Tools 4 — Spring 빈 자동완성 (Help → Eclipse Marketplace 검색)
  • EditBox — 현재 편집 라인 하이라이트 (가독성 향상)
  • Bracketeer — 중괄호 쌍 표시
5 🗄️ PostgreSQL DB 초기 설정
📌 데이터베이스 연결 정보
항목
DB 종류PostgreSQL 16
접속 URLjdbc:postgresql://192.168.100.66:5432/handson
데이터베이스/사용자handson
포트5432 (기본)
🔧 DB 최초 생성 (PostgreSQL superuser 권한 필요)
psql / DBeaver / pgAdmin (postgres 계정으로 실행)
-- 1. 사용자(Role) 생성
CREATE USER handson WITH PASSWORD '비밀번호';

-- 2. 데이터베이스 생성
CREATE DATABASE handson
  WITH OWNER = handson
  ENCODING = 'UTF8'
  LC_COLLATE = 'ko_KR.UTF-8'
  LC_CTYPE = 'ko_KR.UTF-8'
  TEMPLATE = template0;

-- 3. 권한 부여
GRANT ALL PRIVILEGES ON DATABASE handson TO handson;

-- handson DB에 접속 후 schema 권한 부여 (psql: \c handson)
GRANT ALL ON SCHEMA public TO handson;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO handson;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO handson;
📋 DDL 실행 순서
💡
아래 순서대로 DDL을 실행해야 FK 오류가 발생하지 않습니다. 모든 DDL 스크립트는 CLAUDE.md 파일의 Database Tables 섹션에 포함되어 있습니다.
  1. 기본 테이블 생성
    TB_PENTA_USER → TB_PENTA_ROLE → TB_PENTA_PROGRAM → TB_PENTA_ROLE_PROGRAM → TB_PENTA_USER_ROLE
  2. 로그 테이블 생성
    TB_PENTA_LOGIN_LOG → TB_PENTA_PROGRAM_RUN_LOG
  3. 메시지 테이블 생성
    TB_PENTA_MESSAGE → TB_PENTA_MESSAGE_FILE
  4. 파일 테이블 생성
    TB_PENTA_FILE_LIST → TB_PENTA_FILE_UPLOAD → TB_PENTA_FILE_DOWNLOAD
  5. 게시판 테이블 생성
    TB_PENTA_BOARD → TB_PENTA_BOARD_CMT → TB_PENTA_BOARD_FILE → TB_PENTA_BOARD_READ
  6. 조직도 테이블 생성
    TB_PENTA_ORGAN → TB_PENTA_USER.ORG_CD 컬럼 추가
  7. 공통코드 테이블 생성
    TB_PENTA_CODE_MST
  8. 업무 샘플 테이블 생성
    TB_PENTA_BUSINESS_SAMPLE_01 ~ 04 (SAMPLE_04는 04_00 ~ 04_04 순서로)
  9. 초기 데이터 삽입
    관리자 계정, 역할, 프로그램 메뉴, 샘플 데이터 삽입
👤 초기 사용자 계정 생성
SQL (handson DB에서 실행)
-- 비밀번호 SHA-256 해시: 'admin1234' → 아래 값은 예시
-- 실제 해시값은 애플리케이션의 SHA256Util.encode() 함수 결과를 사용
INSERT INTO TB_PENTA_USER (
  USER_ID, USER_PASSWORD, USER_NAME, USER_EMAIL,
  DEPT_NAME, POSITION, USE_YN, LOCK_YN, FAILED_LOGIN_COUNT
) VALUES (
  'admin',
  '8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918', -- 'admin'의 SHA-256
  '관리자', 'admin@penta.com',
  'IT부서', '시스템관리자', 'Y', 'N', 0
);
COMMIT;
⚠️
비밀번호 해시값 생성 방법 애플리케이션 기동 후 브라우저 콘솔 또는 Java 코드에서 egovframework.penta.common.SHA256Util.encode("비밀번호")를 호출하여 해시값을 얻어 INSERT하거나, 초기 비밀번호를 평문으로 저장 후 첫 로그인 시 강제 변경하도록 처리합니다.
🔌 연결 설정 파일
src/main/resources/egovframework/spring/context-datasource.xml
<bean id="dataSource" class="org.apache.commons.dbcp2.BasicDataSource">
  <property name="driverClassName"  value="org.postgresql.Driver"/>
  <property name="url"             value="jdbc:postgresql://192.168.100.66:5432/handson"/>
  <property name="username"        value="handson"/>
  <property name="password"        value="비밀번호"/>
  <property name="maxTotal"        value="20"/>
  <property name="maxIdle"         value="5"/>
  <property name="minIdle"         value="2"/>
</bean>
6 ⚙️ WAS 서버 설정 (Apache Tomcat)
🔽 Tomcat 설치
  1. Apache Tomcat 9.x 다운로드
    apache.org에서 Core Windows zip 다운로드 후 적절한 위치에 압축 해제
    예: C:\servers\apache-tomcat-9.0.xx
  2. JAVA_HOME 확인
    Tomcat 기동 전 JAVA_HOME 환경변수가 JDK 18을 가리키는지 확인
  3. 포트 설정: conf/server.xml
    conf/server.xml (HTTP 포트 변경 예시)
    <Connector port="8080" protocol="HTTP/1.1"
               connectionTimeout="20000"
               redirectPort="8443"
               URIEncoding="UTF-8"/>
    
    <!-- HTTPS 설정 (자체 서명 인증서 예시) -->
    <Connector port="8443" protocol="org.apache.coyote.http11.Http11NioProtocol"
               maxThreads="150" SSLEnabled="true"
               scheme="https" secure="true"
               keystoreFile="/etc/ssl/penta.keystore"
               keystorePass="비밀번호"
               clientAuth="false" sslProtocol="TLS"
               URIEncoding="UTF-8"/>
📁 globals.properties 설정

파일 서버 경로, 최대 파일 크기 등 서버 환경에 맞게 반드시 수정해야 합니다.

src/main/resources/globals.properties
# 파일 서버 설정
file.server.host=192.168.100.66
file.server.port=8090
file.upload.root=D:/fileserver/penta
file.max.size=52428800    # 50MB (0=무제한)
file.max.count=10          # 최대 파일 수 (0=무제한)

# 앱 기본 설정
app.name=PentaMobile
app.version=1.0.0
app.context=/HandsOn

# 세션 기본 타임아웃 (분)
session.default.timeout=30
🚀 Tomcat 기동/중지
Windows
rem 기동
cd C:\servers\apache-tomcat-9.0.xx\bin
startup.bat

rem 중지
shutdown.bat
Linux
# 기동
/opt/tomcat9/bin/startup.sh

# 중지
/opt/tomcat9/bin/shutdown.sh

# 상태 확인
ps aux | grep tomcat
📝 Tomcat 메모리 설정
bin/setenv.bat (Windows) 또는 bin/setenv.sh (Linux)
rem Windows setenv.bat
set JAVA_OPTS=-Xms512m -Xmx1024m -XX:MaxMetaspaceSize=256m -Dfile.encoding=UTF-8

# Linux setenv.sh
export JAVA_OPTS="-Xms512m -Xmx1024m -XX:MaxMetaspaceSize=256m -Dfile.encoding=UTF-8"
⚠️ 주요 설정 항목 및 출처 (반드시 확인)

아래 항목들은 서버 이전·신규 구축·담당자 인수인계 시 누락되기 쉬운 설정입니다. 각 항목의 출처 파일을 직접 열어 값이 적용되어 있는지 확인하세요.

⚠️
현재 개발 환경 상태 (확인 시점 기준)
setenv.bat 파일이 존재하지 않아 힙 메모리 설정이 적용되지 않은 상태입니다. Eclipse 내 서버 VM Arguments에 --add-opens 플래그만 설정되어 있고 -Xms/-Xmx는 미설정입니다. 개발 초기에는 문제없지만 데이터가 쌓이면 OutOfMemoryError가 발생할 수 있습니다.

① JVM 힙 메모리 (Heap Memory)

항목옵션개발 권장운영 권장
초기 힙 크기-Xms512m1g
최대 힙 크기-Xmx1024m2g
Metaspace 최대-XX:MaxMetaspaceSize256m512m
파일 인코딩-Dfile.encodingUTF-8 (필수)
📁
출처: 신규 생성 필요workspace-egov/Servers/apache-tomcat-9.0.86/bin/setenv.bat (해당 파일이 현재 없음. 아래 내용으로 직접 생성해야 함)
신규 생성: workspace-egov/Servers/apache-tomcat-9.0.86/bin/setenv.bat
rem =====================================================
rem PentaMobile Tomcat JVM 설정
rem 이 파일은 Tomcat 기동 시 자동으로 읽힘
rem =====================================================

rem 힙 메모리 (개발: 1g, 운영: 2g 이상 권장)
set CATALINA_OPTS=-Xms512m -Xmx1024m
set CATALINA_OPTS=%CATALINA_OPTS% -XX:MaxMetaspaceSize=256m
set CATALINA_OPTS=%CATALINA_OPTS% -XX:+UseG1GC

rem 인코딩 (한글 깨짐 방지 — 필수)
set CATALINA_OPTS=%CATALINA_OPTS% -Dfile.encoding=UTF-8
set CATALINA_OPTS=%CATALINA_OPTS% -Dsun.stdout.encoding=UTF-8

rem JDK 17+ 모듈 접근 허용 (Spring 5.x / MyBatis 구동에 필요)
rem 출처: Eclipse 서버 launch 설정에서 확인된 실제 적용 값
rem  D:\...\workspace-egov\.metadata\.plugins\org.eclipse.debug.core\.launches\
rem  Tomcat v9.0 Server at localhost.launch 의 VM_ARGUMENTS 항목
set CATALINA_OPTS=%CATALINA_OPTS% --add-opens=java.base/java.lang=ALL-UNNAMED
set CATALINA_OPTS=%CATALINA_OPTS% --add-opens=java.base/java.io=ALL-UNNAMED
set CATALINA_OPTS=%CATALINA_OPTS% --add-opens=java.base/java.util=ALL-UNNAMED
set CATALINA_OPTS=%CATALINA_OPTS% --add-opens=java.base/java.util.concurrent=ALL-UNNAMED
set CATALINA_OPTS=%CATALINA_OPTS% --add-opens=java.rmi/sun.rmi.transport=ALL-UNNAMED
💡
CATALINA_OPTS vs JAVA_OPTS 차이
JAVA_OPTS는 startup/shutdown 명령 모두에 적용됩니다. CATALINA_OPTS는 Tomcat 기동(start/run) 시에만 적용됩니다. 힙 설정은 기동 시에만 필요하므로 CATALINA_OPTS에 지정하는 것이 올바른 방법입니다.

② JDK 17 모듈 열기 옵션 (Eclipse 서버 설정)

📁
출처 (현재 실제 적용 중)workspace-egov/.metadata/.plugins/org.eclipse.debug.core/.launches/
Tomcat v9.0 Server at localhost.launch
VM_ARGUMENTS 항목
Eclipse에서 서버 더블클릭 → Open launch configuration → Arguments 탭 → VM arguments에서 확인 가능

JDK 17 이후 모듈 시스템 강화로 인해 Spring 5.x / MyBatis 3.5가 내부 API에 접근하려면 아래 옵션이 필수입니다. 현재 Eclipse 서버에 적용되어 있으나, standalone 배포 시 setenv.bat에 누락되면 기동 오류가 발생합니다.

현재 적용된 --add-opens 5개 (launch 파일 기준)
--add-opens=java.base/java.lang=ALL-UNNAMED        ← Spring 리플렉션
--add-opens=java.base/java.io=ALL-UNNAMED           ← 파일 스트림 접근
--add-opens=java.base/java.util=ALL-UNNAMED         ← 컬렉션 내부 접근
--add-opens=java.base/java.util.concurrent=ALL-UNNAMED  ← 비동기 처리
--add-opens=java.rmi/sun.rmi.transport=ALL-UNNAMED  ← RMI 내부 접근

③ Connector 설정 (server.xml)

📁
출처workspace-egov/Servers/apache-tomcat-9.0.86/conf/server.xml
현재 적용 값: port=8080, connectionTimeout=20000, maxParameterCount=1000
미설정 항목: URIEncoding, maxThreads, acceptCount — 아래 권장 값으로 추가 필요
workspace-egov/Servers/apache-tomcat-9.0.86/conf/server.xml — Connector 권장 설정
<Connector port="8080"
           protocol="HTTP/1.1"
           connectionTimeout="20000"      <!-- 현재 적용 값 -->
           redirectPort="8443"
           maxParameterCount="1000"      <!-- 현재 적용 값 -->
           URIEncoding="UTF-8"             <!-- 한글 URL 파라미터 깨짐 방지 — 추가 필요 -->
           maxThreads="200"               <!-- 동시 요청 처리 스레드 수 (기본 200) -->
           acceptCount="100"              <!-- maxThreads 초과 시 대기 큐 크기 -->
           compression="on"               <!-- 응답 gzip 압축 (모바일 트래픽 절감) -->
           compressableMimeType="text/html,text/css,application/javascript,application/json"/>
항목현재 값권장 값영향
URIEncoding미설정UTF-8한글 GET 파라미터 깨짐
maxThreads미설정(기본 200)200동시 처리 한도 명시
acceptCount미설정(기본 100)100큐 초과 시 즉시 거절 방지
compression미설정onJS/CSS/JSON 전송량 감소
connectionTimeout20000ms20000ms현재 적용 중 (문제 없음)
maxParameterCount10001000현재 적용 중 (문제 없음)

④ 세션 타임아웃

📁
출처 1 (애플리케이션 설정, 우선 적용)src/main/webapp/WEB-INF/web.xml 61행
현재 값: <session-timeout>30</session-timeout> (30분)

출처 2 (Tomcat 기본값)workspace-egov/Servers/apache-tomcat-9.0.86/conf/web.xml 637행
현재 값: <session-timeout>30</session-timeout> (동일 30분)

두 곳에 모두 설정되어 있으며 WEB-INF/web.xml이 우선합니다. 사용자가 설정 화면에서 세션 타임아웃을 변경하면 Spring의 세션 관리 코드에서 동적으로 덮어씁니다. Tomcat 기본값(conf/web.xml)은 건드리지 않아도 됩니다.

⑤ 인코딩 필터 (CharacterEncodingFilter)

📁
출처src/main/webapp/WEB-INF/web.xml 19~36행
현재 값: encoding=UTF-8, forceEncoding=true — 적용 중
src/main/webapp/WEB-INF/web.xml (현재 적용 중인 실제 설정)
<filter>
    <filter-name>encodingFilter</filter-name>
    <filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class>
    <init-param>
        <param-name>encoding</param-name>
        <param-value>UTF-8</param-value>
    </init-param>
    <init-param>
        <param-name>forceEncoding</param-name>
        <param-value>true</param-value>  <!-- 이미 인코딩 설정된 요청도 강제 재설정 -->
    </init-param>
</filter>

forceEncoding=true가 없으면 일부 클라이언트가 Content-Type에 charset을 지정한 경우 필터가 적용되지 않아 한글이 깨질 수 있습니다. 현재 올바르게 설정되어 있습니다.

⑥ 에러 페이지 매핑

📁
출처src/main/webapp/WEB-INF/web.xml 71~89행
현재 적용 중: 404 → error404.jsp, 500/403/Exception → error500.jsp
src/main/webapp/WEB-INF/web.xml (현재 적용 중인 실제 설정)
<error-page>
    <error-code>404</error-code>
    <location>/WEB-INF/jsp/common/error404.jsp</location>
</error-page>
<error-page>
    <error-code>500</error-code>
    <location>/WEB-INF/jsp/common/error500.jsp</location>
</error-page>
<error-page>
    <error-code>403</error-code>
    <location>/WEB-INF/jsp/common/error500.jsp</location>
</error-page>
<error-page>
    <exception-type>java.lang.Exception</exception-type>
    <location>/WEB-INF/jsp/common/error500.jsp</location>
</error-page>

⑦ DispatcherServlet URL 패턴

📁
출처src/main/webapp/WEB-INF/web.xml 43~58행
현재 값: *.do 패턴만 처리. load-on-startup=1로 기동 시 즉시 초기화

모든 업무 URL은 *.do로 끝나야 Spring MVC가 처리합니다. *.do 이외의 패턴(예: /api/*)을 추가하려면 이 파일에 <servlet-mapping>을 추가해야 합니다.

⑧ Eclipse 서버 VM Arguments 위치

📁
출처workspace-egov/.metadata/.plugins/org.eclipse.debug.core/.launches/
Tomcat v9.0 Server at localhost.launch

GUI 경로: Servers 탭 → Tomcat 서버 더블클릭 → Open launch configuration → Arguments 탭 → VM arguments

Eclipse에서 직접 서버를 실행할 때만 적용됩니다. Tomcat을 standalone으로 기동할 때(startup.bat)는 적용되지 않으므로 운영/스테이징 서버에는 반드시 setenv.bat에 동일 설정을 복사해야 합니다.

현재 적용된 VM Arguments (launch 파일 실제 내용)
-Dcatalina.base="...\.metadata\...\tmp0"            ← Eclipse 임시 catalina 경로
-Dcatalina.home="...\tomcat\apache-tomcat-9.0.86"   ← Tomcat 설치 경로
-Dwtp.deploy="...tmp0\wtpwebapps"                   ← Eclipse WTP 배포 경로
--add-opens=java.base/java.lang=ALL-UNNAMED
--add-opens=java.base/java.io=ALL-UNNAMED
--add-opens=java.base/java.util=ALL-UNNAMED
--add-opens=java.base/java.util.concurrent=ALL-UNNAMED
--add-opens=java.rmi/sun.rmi.transport=ALL-UNNAMED
⚠ -Xms / -Xmx 없음 — 힙 설정 미적용 상태

✅ WAS 설정 체크리스트

#항목출처 파일현재 상태조치
1힙 메모리 (-Xms/-Xmx) bin/setenv.bat ❌ 파일 없음 setenv.bat 신규 생성
2--add-opens 5개 (JDK 18) *.launch (Eclipse) → setenv.bat △ Eclipse만 적용 setenv.bat에 동일 내용 추가
3URIEncoding=UTF-8 conf/server.xml ❌ 미설정 Connector에 URIEncoding 추가
4세션 타임아웃 30분 WEB-INF/web.xml 61행 ✅ 적용 중 변경 불필요
5UTF-8 인코딩 필터 WEB-INF/web.xml 19행 ✅ 적용 중 변경 불필요
6에러 페이지 (404/500) WEB-INF/web.xml 71행 ✅ 적용 중 변경 불필요
7maxThreads / acceptCount conf/server.xml △ 기본값(200/100) 부하 테스트 후 조정
8응답 압축 (gzip) conf/server.xml ❌ 미설정 모바일 환경 시 compression=on 추가
7 🌐 Web 서버 설정 (Nginx)
⚙️ Nginx Reverse Proxy 설정
/etc/nginx/conf.d/penta.conf (Linux 예시)
# HTTP → HTTPS 리다이렉트
server {
    listen 80;
    server_name penta.example.com;
    return 301 https://$host$request_uri;
}

# HTTPS
server {
    listen 443 ssl;
    server_name penta.example.com;

    ssl_certificate     /etc/ssl/penta.crt;
    ssl_certificate_key /etc/ssl/penta.key;
    ssl_protocols       TLSv1.2 TLSv1.3;

    client_max_body_size 60m;

    location / {
        proxy_pass         http://127.0.0.1:8080;
        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_read_timeout 120s;
    }

    # 정적 파일 직접 서빙 (옵션)
    location /HandsOn/js/ {
        alias /opt/penta/static/js/;
        expires 7d;
    }
}
🔐 SSL 인증서 발급 (Let's Encrypt)
bash
# certbot 설치 (Ubuntu 예시)
sudo apt install certbot python3-certbot-nginx

# 인증서 발급
sudo certbot --nginx -d penta.example.com

# 자동 갱신 확인
sudo certbot renew --dry-run
💡
내부망 전용인 경우 공인 도메인이 없으므로 자체 서명(Self-Signed) 인증서를 사용합니다. 모바일 기기에서 접속 시 인증서를 신뢰 목록에 추가해야 합니다.
8 🏗️ 빌드 & 배포
🔨 Maven 빌드
  1. Eclipse에서 빌드
    프로젝트 우클릭 → Run As → Maven build → Goals: clean package -DskipTests
  2. 명령행에서 빌드
    cmd
    cd D:\project\handson\eGovFrameDev-4.3.1-64bit\workspace-egov\handson
    mvn clean package -DskipTests
    
    # 성공 시 target/handson.war 파일 생성
🚢 WAR 파일 배포
  1. WAR 파일 복사
    빌드된 target/handson.war를 Tomcat의 webapps/ 디렉토리에 복사
    cmd
    copy target\handson.war C:\servers\apache-tomcat-9.0.xx\webapps\HandsOn.war
  2. Tomcat 재시작
    Tomcat을 재시작하면 WAR가 자동으로 압축 해제되어 배포됩니다
  3. 배포 확인
    브라우저에서 http://서버IP:8080/HandsOn/login/login.do 접속하여 로그인 화면 확인
📝 Eclipse에서 직접 Tomcat 실행 (개발용)
  1. Servers 뷰에서 Tomcat 추가
    Window → Show View → Servers → New Server → Apache Tomcat 9.0 → Tomcat 설치 경로 지정
  2. 프로젝트 추가
    서버 우클릭 → Add and Remove → handson 프로젝트를 Configured 측으로 이동 → Finish
  3. 서버 시작
    서버 우클릭 → Start (또는 Debug) → Console에 기동 로그 확인
9 🛠️ 신규 프로그램 개발 가이드
📋
개발 경험이 없어도 괜찮습니다. 이 섹션은 처음 개발하는 분을 기준으로 하나하나 설명합니다.
파워빌더 개발 경험이 있으신 분은 각 항목의 🔵 파워빌더 비교 박스를 참고하시면 더 빠르게 이해할 수 있습니다.
기존 업무 화면(SAMPLE_01~14)을 복사 템플릿으로 활용하여 새 화면을 만드는 방법을 화면 유형별로 상세히 설명합니다.
📐 전체 구조 이해 - MVC 패턴

웹 화면 하나를 만들려면 최소 4~6개의 파일이 필요합니다. 각각 역할이 나뉘어져 있고, 서로 연결됩니다. 전체 흐름을 먼저 이해하는 것이 중요합니다.

💡 한 문장으로 요약하면:
사용자가 버튼을 누름 → 브라우저가 서버에 요청 → Controller가 받아서 → Service/DAO가 DB 조회 → JSP가 결과를 화면에 표시
파일/개념하는 일 (쉬운 설명)🔵 파워빌더 비교실제 파일 위치
JSP
(화면)
사용자가 보는 화면을 만듭니다.
HTML + JavaScript로 버튼, 표, 입력창을 배치합니다.
Window + DataWindow 화면 디자인 src/main/webapp/WEB-INF/jsp/business/sample01view.jsp
Controller.java
(교통정리)
URL 주소를 받아서 어느 기능을 실행할지 결정합니다.
예: /business/sample01List.do 요청이 오면 목록 조회 실행
Application Object의 이벤트 스크립트 (URL별 분기) src/main/java/.../controller/BusinessController.java
Service.java
(인터페이스)
Controller와 DAO 사이에서 기능 목록을 정의합니다.
"이런 기능이 있다"고 선언만 합니다.
파워빌더에는 이런 구조가 없음 (Java의 설계 패턴) src/main/java/.../service/BusinessService.java
ServiceImpl.java
(실제 로직)
Service에서 선언한 기능을 실제로 구현합니다.
업무 규칙(검증, 계산 등)을 여기 작성합니다.
파워빌더 Business Logic 함수 src/main/java/.../service/impl/BusinessServiceImpl.java
DAO.java
(DB 연결)
실제로 DB에 SQL을 실행합니다.
SQL XML 파일과 연결됩니다.
DataStore의 Retrieve/Update 동작 src/main/java/.../dao/BusinessDAO.java
VO.java
(데이터 그릇)
DB 컬럼 하나하나를 Java 변수로 선언합니다.
데이터를 담아 화면↔DB 사이를 이동합니다.
DataWindow의 컬럼 정의 (각 컬럼이 하나의 변수) src/main/java/.../vo/BusinessSample01VO.java
SQL XML
(SQL 쿼리)
SELECT / INSERT / UPDATE / DELETE SQL을 별도 XML 파일로 관리합니다.
Java 코드 안에 SQL을 직접 쓰지 않습니다.
DataWindow의 SQL 정의 (별도 파일로 분리된 것) src/main/resources/.../sqlmap/business/BusinessSample01_SQL.xml
📌 요청 흐름 전체 그림
📱 사용자 버튼 클릭
(브라우저)
Controller.java
URL 수신·분기
ServiceImpl.java
업무 로직
DAO.java
DB 실행 요청
SQL XML
실제 SQL
PostgreSQL
데이터 반환
JSP
화면 렌더링
⚠️ 처음에 가장 헷갈리는 부분:
Controller는 "중간 교통정리"만 합니다. 실제 계산·검증은 ServiceImpl에 넣습니다.
DAO는 SQL을 "실행"만 합니다. SQL 내용은 XML 파일에 있습니다.
VO는 DB 테이블의 컬럼 수와 반드시 일치할 필요는 없습니다 — 화면에서 필요한 조회 조건용 변수도 VO에 포함됩니다.
🖥️ Eclipse에서 파일 찾고 편집하는 방법
💡
파워빌더는 IDE에서 오브젝트를 더블클릭하면 편집창이 열렸지만, Java/Eclipse는 파일을 직접 찾아서 열어야 합니다.
처음엔 파일 위치가 낯설지만 몇 번 반복하면 자연스러워집니다.
  1. Package Explorer에서 파일 찾기
    Eclipse 왼쪽 Package Explorer 패널에서 파일을 탐색합니다.
    찾는 파일이 있는 폴더를 펼쳐서 더블클릭하면 편집창이 열립니다.
    주요 파일 위치 (handson 프로젝트 기준)
    handson/
    ├── src/main/java/egovframework/penta/
    │   └── business/
    │       ├── controller/   → BusinessController.java  (URL 매핑)
    │       ├── dao/          → BusinessDAO.java          (DB 접근)
    │       ├── service/      → BusinessService.java      (인터페이스)
    │       ├── service/impl/ → BusinessServiceImpl.java  (실제 구현)
    │       └── vo/           → BusinessSample01VO.java   (컬럼 정의)
    │
    ├── src/main/resources/egovframework/sqlmap/business/
    │   └── BusinessSample01_SQL.xml  (SQL 쿼리)
    │
    └── src/main/webapp/WEB-INF/jsp/business/
        └── sample01view.jsp           (화면 HTML)
  2. 파일을 빠르게 찾는 단축키
    파일명을 알고 있을 때는 단축키가 훨씬 빠릅니다.
    단축키기능사용 예시
    Ctrl + Shift + R파일명으로 빠른 검색"BusinessSample01" 입력 → 관련 파일 목록 표시
    Ctrl + H프로젝트 전체 텍스트 검색"sample01List" 입력 → 어느 파일에서 쓰이는지 확인
    Ctrl + F현재 파일 내 검색열린 파일에서 특정 단어 찾기
    Ctrl + Z실행 취소잘못 수정했을 때 되돌리기
    Ctrl + S저장수정 후 반드시 저장
  3. Java 파일 수정 후 서버 재시작 없이 반영하기
    JSP 파일은 저장하면 브라우저 새로고침만 해도 바로 반영됩니다.
    Java 파일 (Controller, VO, DAO 등)은 Eclipse가 자동으로 컴파일하지만, 변경사항이 실행 중인 서버에 반영되려면 서버를 재시작해야 합니다.
    서버 재시작 방법
    방법 1: Eclipse 하단 Servers 탭 → 서버 우클릭 → Restart
    방법 2: Eclipse 상단 Run 메뉴 → Restart Server
    방법 3: Servers 탭에서 서버 선택 후 툴바의 재시작 버튼(■ → ▶) 클릭
    ⚠️
    자주 하는 실수: Java 파일을 수정하고 저장했는데 화면이 안 바뀐다 → 서버 재시작을 안 했기 때문입니다.
    SQL XML 파일은 서버 재시작 없이 반영되는 경우도 있지만, 안전하게는 재시작하는 것이 좋습니다.
  4. 컴파일 오류(빨간 X) 해결 방법
    Eclipse에서 파일에 빨간 X가 표시되면 문법 오류가 있는 것입니다. 개발을 진행하기 전에 반드시 해결해야 합니다.
    증상원인해결
    import 빨간 줄클래스를 찾을 수 없음해당 줄에서 Ctrl+1 → "Import 추가" 선택
    클래스명 빨간 줄VO 클래스명 오타 또는 미생성VO 파일이 있는지 확인, 클래스명 철자 확인
    메서드 빨간 줄Service/DAO에 메서드가 없음해당 인터페이스에 메서드 선언 추가
    세미콜론 누락Java 문법 오류오류 줄 끝에 ; 추가
📁 관련 파일 구조 (SAMPLE_01 예시)
src/main/ ├── java/egovframework/penta/ │ └── business/ │ ├── controller/BusinessController.java ← URL 처리 (모든 SAMPLE 공용) │ ├── service/BusinessService.java ← 인터페이스 │ ├── service/impl/BusinessServiceImpl.java ← 실제 로직 │ ├── dao/BusinessDAO.java ← DB 접근 │ └── vo/BusinessSample01VO.java ← 데이터 객체 │ ├── resources/egovframework/sqlmap/business/ │ └── BusinessSample01_SQL.xml ← SQL 쿼리 │ └── webapp/WEB-INF/jsp/business/ └── sample01view.jsp ← 화면 HTML
🚀 Step-by-Step: 새 화면 만들기 (예: 고객관리 화면)
📌 목표: 고객관리 화면 "SAMPLE_15"를 만든다고 가정합니다.
DB 테이블: TB_PENTA_BUSINESS_SAMPLE_15
URL: business/sample15view.do?progId=00019
  1. ① 데이터베이스 테이블 설계 및 생성
    DBeaver 또는 pgAdmin에서 테이블을 생성합니다. 파워빌더에서 DB 테이블 만들던 것과 동일합니다.
    SQL Developer에서 실행
    -- PK: USER_ID + REG_DATE + SEQ (시퀀스 없이 MAX+1 방식)
    CREATE TABLE TB_PENTA_BUSINESS_SAMPLE_15 (
        USER_ID      VARCHAR2(50)  NOT NULL,
        REG_DATE     DATE          NOT NULL,
        SEQ          NUMBER(10,0)  NOT NULL,
        CUST_NM      VARCHAR2(100) NOT NULL,  -- 고객명
        CUST_TEL     VARCHAR2(20),            -- 전화번호
        CUST_EMAIL   VARCHAR2(200),           -- 이메일
        NOTE         VARCHAR2(500),           -- 비고
        REG_USER     VARCHAR2(50)  NOT NULL,
        UPD_USER     VARCHAR2(50),
        UPD_DATE     DATE,
        CONSTRAINT PK_TB_PENTA_BUSINESS_SAMPLE_15
            PRIMARY KEY (USER_ID, REG_DATE, SEQ)
    );
    COMMENT ON TABLE TB_PENTA_BUSINESS_SAMPLE_15 IS '고객관리 샘플 테이블';
    COMMENT ON COLUMN TB_PENTA_BUSINESS_SAMPLE_15.CUST_NM IS '고객명';
  2. ② VO(Value Object) 클래스 생성
    파워빌더의 컬럼 정의와 같습니다. 테이블의 각 컬럼에 해당하는 Java 변수를 선언합니다.
    기존 파일 복사: BusinessSample01VO.java를 복사하여 BusinessSample15VO.java로 저장 후 수정합니다.
    src/main/java/egovframework/penta/business/vo/BusinessSample15VO.java
    package egovframework.penta.business.vo;
    
    import lombok.Data;
    
    @Data    // getter/setter 자동 생성 (파워빌더의 인스턴스변수와 유사)
    public class BusinessSample15VO {
        // PK
        private String userId;     // USER_ID
        private String regDate;    // REG_DATE (문자열 형태 "YYYY-MM-DD")
        private int    seq;        // SEQ
    
        // 업무 컬럼
        private String custNm;     // CUST_NM (DB: CUST_NM → Java: custNm, 자동 camelCase 변환)
        private String custTel;    // CUST_TEL
        private String custEmail;  // CUST_EMAIL
        private String note;       // NOTE
        private String regUser;    // REG_USER
        private String updUser;    // UPD_USER
        private String updDate;    // UPD_DATE
    
        // 조회 조건용 (DB 컬럼 아님)
        private String searchKeyword;
        private String searchDateFrom;
        private String searchDateTo;
    }
    💡
    camelCase 자동 변환: DB 컬럼 CUST_NM은 Java에서 custNm으로 자동 매핑됩니다. mybatis-config.xmlmapUnderscoreToCamelCase=true 설정이 있기 때문입니다. 밑줄(_)이 자동으로 제거되고 그 다음 글자가 대문자로 바뀝니다.
  3. ③ SQL Mapper XML 작성
    파워빌더의 DataWindow SQL과 같습니다.
    기존 파일 복사: BusinessSample01_SQL.xml을 복사하여 BusinessSample15_SQL.xml로 저장합니다.
    src/main/resources/egovframework/sqlmap/business/BusinessSample15_SQL.xml
    <?xml version="1.0" encoding="UTF-8"?>
    <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
      "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
    
    <!-- namespace = DAO 클래스의 전체 경로 (패키지명.클래스명) -->
    <mapper namespace="egovframework.penta.business.dao.BusinessDAO">
    
      <!-- 목록 조회 -->
      <select id="selectSample15List"
              parameterType="egovframework.penta.business.vo.BusinessSample15VO"
              resultType="egovframework.penta.business.vo.BusinessSample15VO">
        SELECT USER_ID, REG_DATE, SEQ,
               CUST_NM, CUST_TEL, CUST_EMAIL, NOTE,
               REG_USER, UPD_USER, UPD_DATE
        FROM TB_PENTA_BUSINESS_SAMPLE_15
        WHERE USER_ID = #{userId}
        <if test="searchKeyword != null and searchKeyword != ''">
          AND (CUST_NM LIKE '%' || #{searchKeyword} || '%'
            OR CUST_TEL LIKE '%' || #{searchKeyword} || '%')
        </if>
        <if test="searchDateFrom != null and searchDateFrom != ''">
          AND REG_DATE >= TO_DATE(#{searchDateFrom}, 'YYYY-MM-DD')
        </if>
        ORDER BY REG_DATE DESC, SEQ DESC
      </select>
    
      <!-- 단건 조회 -->
      <select id="selectSample15Detail"
              parameterType="egovframework.penta.business.vo.BusinessSample15VO"
              resultType="egovframework.penta.business.vo.BusinessSample15VO">
        SELECT * FROM TB_PENTA_BUSINESS_SAMPLE_15
        WHERE USER_ID = #{userId} AND REG_DATE = TO_DATE(#{regDate},'YYYY-MM-DD') AND SEQ = #{seq}
      </select>
    
      <!-- 등록 -->
      <insert id="insertSample15"
               parameterType="egovframework.penta.business.vo.BusinessSample15VO">
        INSERT INTO TB_PENTA_BUSINESS_SAMPLE_15
        (USER_ID, REG_DATE, SEQ, CUST_NM, CUST_TEL, CUST_EMAIL, NOTE, REG_USER, REG_DATE)
        VALUES (
          #{userId}, SYSDATE,
          (SELECT NVL(MAX(SEQ),0)+1 FROM TB_PENTA_BUSINESS_SAMPLE_15
           WHERE USER_ID=#{userId} AND TRUNC(REG_DATE)=TRUNC(SYSDATE)),
          #{custNm}, #{custTel}, #{custEmail}, #{note}, #{regUser}, SYSDATE
        )
      </insert>
    
      <!-- 수정 -->
      <update id="updateSample15"
               parameterType="egovframework.penta.business.vo.BusinessSample15VO">
        UPDATE TB_PENTA_BUSINESS_SAMPLE_15
        SET CUST_NM = #{custNm}, CUST_TEL = #{custTel}, CUST_EMAIL = #{custEmail},
            NOTE = #{note}, UPD_USER = #{updUser}, UPD_DATE = SYSDATE
        WHERE USER_ID = #{userId} AND REG_DATE = TO_DATE(#{regDate},'YYYY-MM-DD') AND SEQ = #{seq}
      </update>
    
      <!-- 삭제 -->
      <delete id="deleteSample15"
               parameterType="egovframework.penta.business.vo.BusinessSample15VO">
        DELETE FROM TB_PENTA_BUSINESS_SAMPLE_15
        WHERE USER_ID = #{userId} AND REG_DATE = TO_DATE(#{regDate},'YYYY-MM-DD') AND SEQ = #{seq}
      </delete>
    
    </mapper>
    💡
    SQL ID 명명 규칙: select/insert/update/delete + SampleNN + 기능명
    <if test="...">는 파워빌더의 IF 조건과 같습니다. 값이 있을 때만 WHERE 조건에 추가됩니다.
    #{변수명}은 파워빌더의 :변수명 바인딩과 동일합니다.
  4. ④ DAO 메서드 추가
    SQL Mapper와 Java를 연결하는 인터페이스입니다. SQL ID와 동일한 이름의 메서드를 추가합니다.
    파일: src/main/java/egovframework/penta/business/dao/BusinessDAO.java
    BusinessDAO.java에 메서드 추가 (기존 파일 열어서 추가)
    // SAMPLE_15 고객관리
    public List<BusinessSample15VO> selectSample15List(BusinessSample15VO vo);
    public BusinessSample15VO selectSample15Detail(BusinessSample15VO vo);
    public int insertSample15(BusinessSample15VO vo);
    public int updateSample15(BusinessSample15VO vo);
    public int deleteSample15(BusinessSample15VO vo);
  5. ⑤ Service 인터페이스에 메서드 추가
    파일: src/main/java/egovframework/penta/business/service/BusinessService.java
    DAO와 동일한 메서드 시그니처를 추가합니다 (보통 DAO 코드를 복사하면 됩니다).
    BusinessService.java에 추가
    // SAMPLE_15
    List<BusinessSample15VO> selectSample15List(BusinessSample15VO vo) throws Exception;
    BusinessSample15VO selectSample15Detail(BusinessSample15VO vo) throws Exception;
    int insertSample15(BusinessSample15VO vo) throws Exception;
    int updateSample15(BusinessSample15VO vo) throws Exception;
    int deleteSample15(BusinessSample15VO vo) throws Exception;
  6. ⑥ ServiceImpl에 구현 추가
    파일: src/main/java/egovframework/penta/business/service/impl/BusinessServiceImpl.java
    Service 메서드를 구현합니다. DAO를 호출하는 코드만 작성하면 됩니다.
    BusinessServiceImpl.java에 추가
    @Override
    public List<BusinessSample15VO> selectSample15List(BusinessSample15VO vo) throws Exception {
        return businessDAO.selectSample15List(vo);  // DAO 호출만 하면 됨
    }
    
    @Override
    public int insertSample15(BusinessSample15VO vo) throws Exception {
        return businessDAO.insertSample15(vo);
    }
    // ... 나머지 메서드도 동일한 패턴으로 추가
  7. ⑦ Controller에 URL 매핑 추가
    파일: src/main/java/egovframework/penta/business/controller/BusinessController.java
    파워빌더로 비유하면 Application Script에서 URL 요청을 받는 부분입니다.
    BusinessController.java에 추가 (기존 SAMPLE_01 메서드 아래에 추가)
    // =====================================================================
    // SAMPLE_15: 고객관리
    // =====================================================================
    
    /** 화면 진입 */
    @GetMapping("/business/sample15view.do")
    public String sample15View(
            @RequestParam(value="progId", required=false) String progId,
            HttpSession session, Model model) {
        String userId = (String) session.getAttribute("userId");
        log.info("[SAMPLE15] 화면 진입 userId={}", userId);
        model.addAttribute("progId", progId);
        return "business/sample15view";  // JSP 경로 (확장자 .jsp 없이)
    }
    
    /** 목록 조회 (AJAX) */
    @GetMapping("/business/sample15List.do")
    @ResponseBody
    public Map<String, Object> sample15List(BusinessSample15VO vo, HttpSession session) {
        Map<String, Object> result = new HashMap<>();
        try {
            String userId = (String) session.getAttribute("userId");
            vo.setUserId(userId);
            List<BusinessSample15VO> list = businessService.selectSample15List(vo);
            result.put("success", true);
            result.put("list", list);
        } catch (Exception e) {
            log.error("[SAMPLE15] 목록 조회 오류", e);
            result.put("success", false);
            result.put("message", e.getMessage());
        }
        return result;
    }
    
    /** 저장 (등록/수정) */
    @PostMapping("/business/sample15Save.do")
    @ResponseBody
    public Map<String, Object> sample15Save(@RequestBody BusinessSample15VO vo, HttpSession session) {
        Map<String, Object> result = new HashMap<>();
        try {
            String userId = (String) session.getAttribute("userId");
            vo.setRegUser(userId);
            vo.setUserId(userId);
            int cnt = (vo.getSeq() == 0)
                    ? businessService.insertSample15(vo)
                    : businessService.updateSample15(vo);
            result.put("success", cnt > 0);
        } catch (Exception e) {
            log.error("[SAMPLE15] 저장 오류", e);
            result.put("success", false);
            result.put("message", e.getMessage());
        }
        return result;
    }
    💡
    @GetMapping vs @PostMapping: 목록 조회는 GET, 저장/삭제는 POST를 사용합니다.
    @ResponseBody: 이 어노테이션이 있으면 JSON 데이터를 반환합니다 (AJAX 응답용).
    없으면 JSP 파일 경로를 반환합니다 (화면 이동용).
  8. ⑧ JSP 화면 파일 작성
    기존 JSP 복사: sample01view.jsp를 복사하여 sample15view.jsp로 저장.
    파워빌더의 Window 디자인과 같습니다. HTML로 화면을 구성하고 JavaScript로 동작을 처리합니다.
    sample15view.jsp 주요 구조
    <!-- 1. 페이지 상단: 헤더, CSS -->
    <style>
      /* 이 화면만의 CSS를 여기에 작성 (공통 CSS 파일 없음) */
    </style>
    
    <!-- 2. 조회조건 패널 (한 줄로 접혀있다가 클릭시 펼쳐짐) -->
    <div id="searchPanel">
      <input type="text" id="searchKeyword" placeholder="고객명 검색">
      <button onclick="loadList()">조회</button>
    </div>
    
    <!-- 3. 데이터 목록 표시 영역 -->
    <div id="listArea"></div>
    
    <!-- 4. 상세/등록 팝업 (위에서 아래로 슬라이드) -->
    <div id="detailPanel" class="side-panel">...</div>
    
    <!-- 5. JavaScript: 화면 동작 로직 -->
    <script>
      // 목록 조회 함수
      function loadList() {
        $.ajax({
          url: '/HandsOn/business/sample15List.do',  // Controller URL
          data: { searchKeyword: $('#searchKeyword').val() },
          success: function(res) {
            if (res.success) renderList(res.list);
          }
        });
      }
    </script>
  9. ⑨ 프로그램 메뉴 DB 등록
    신규 화면을 메뉴에 표시하려면 DB 테이블에 등록해야 합니다.
    SQL
    INSERT INTO TB_PENTA_PROGRAM (PROG_ID, PARENT_PROG_ID, PROG_NM, PROG_TYPE,
      PROG_URL, PROG_LEVEL, SORT_ORDER, ICON_CLASS, USE_YN, REG_ID, SYS_TYPE)
    VALUES ('00019', '00000', '고객관리', 'PROGRAM',
      '/business/sample15view.do?progId=00019',
      2, 19, 'fas fa-users', 'Y', 'admin', 'WEB');
    
    -- 역할-프로그램 권한 부여 (ADMIN 역할에 조회/추가/수정/삭제 권한)
    INSERT INTO TB_PENTA_ROLE_PROGRAM
      (ROLE_ID, PROG_ID, VIEW_YN, ADD_YN, UPD_YN, DEL_YN, SAVE_YN, SYS_TYPE)
    VALUES ('ADMIN', '00019', 'Y', 'Y', 'Y', 'Y', 'Y', 'WEB');
    
    COMMIT;
  10. ⑩ 서버 재시작 및 테스트
    Eclipse에서 Servers 뷰 → 서버 우클릭 → Restart.
    브라우저에서 http://localhost:8080/HandsOn/business/sample15view.do?progId=00019 접속 확인.
    체크리스트
    □ 화면이 정상 표시되는가?
    □ 목록 조회 시 데이터가 나오는가?
    □ 등록/수정/삭제 동작이 되는가?
    □ 로그인 없이 접근 시 로그인 페이지로 이동하는가?
    □ 메뉴에서 이 화면으로 이동이 되는가?
🔧 컬럼 추가·변경·삭제 — 가장 자주 하는 작업
📋
화면을 만든 후 가장 자주 하는 작업이 "컬럼 추가"입니다.
DB 컬럼을 추가하면 4곳을 반드시 모두 수정해야 합니다: ① DB(PostgreSQL) → ② VO.java → ③ SQL XML → ④ JSP 화면
하나라도 빠뜨리면 오류가 발생하거나 데이터가 저장·표시되지 않습니다.
✅ 컬럼 추가 시 수정해야 할 4곳
① PostgreSQL
ALTER TABLE
② VO.java
필드 변수 추가
③ SQL XML
SELECT/INSERT/UPDATE
④ JSP 화면
입력창·표시 추가
📌 케이스 1: 새 컬럼 추가 (예: 고객 등급 CUST_GRADE 추가)
상황: 기존 SAMPLE_15 고객관리 화면에 "고객 등급" 컬럼(VIP/일반/신규)을 추가하고 싶다.
① DB에 컬럼 추가 — SQL Developer에서 실행
-- 기존 테이블에 컬럼 추가 (ALTER TABLE)
ALTER TABLE TB_PENTA_BUSINESS_SAMPLE_15
  ADD CUST_GRADE VARCHAR2(10) DEFAULT '일반';

-- 컬럼 설명 추가
COMMENT ON COLUMN TB_PENTA_BUSINESS_SAMPLE_15.CUST_GRADE
  IS '고객등급 (VIP/일반/신규)';

-- 추가 확인
DESC TB_PENTA_BUSINESS_SAMPLE_15;

COMMIT;
🔵 파워빌더 비교: 파워빌더에서 DB Painter 또는 SQL문으로 컬럼을 추가하던 것과 완전히 동일합니다.
② VO.java에 필드 추가 — Eclipse에서 BusinessSample15VO.java 열기
// BusinessSample15VO.java 파일에 아래 한 줄 추가
@Data
public class BusinessSample15VO {
    private String userId;
    private String regDate;
    private int    seq;
    private String custNm;
    private String custTel;
    private String custEmail;
    private String note;
    private String custGrade;   // ← 이 줄을 추가! DB: CUST_GRADE → Java: custGrade
    private String regUser;
    private String updUser;
    private String updDate;
    private String searchKeyword;
    private String searchDateFrom;
    private String searchDateTo;
}
⚠️ 중요 — camelCase 규칙:
DB 컬럼명 CUST_GRADE는 Java에서 custGrade로 씁니다.
규칙: 밑줄(_) 제거 + 밑줄 다음 글자는 대문자. CUST_GRADEcust + Grade = custGrade
이 변환은 mybatis-config.xmlmapUnderscoreToCamelCase=true 설정이 자동으로 처리합니다.
🔵 파워빌더 비교: DataWindow에서 컬럼을 추가하고 "Column Name"에 변수명을 지정하던 것과 유사합니다.
③ SQL XML에 컬럼 추가 — BusinessSample15_SQL.xml 파일 수정
<!-- SELECT: 조회 컬럼 목록에 CUST_GRADE 추가 -->
<select id="selectSample15List" ...>
  SELECT USER_ID, REG_DATE, SEQ,
         CUST_NM, CUST_TEL, CUST_EMAIL, NOTE,
         CUST_GRADE,   <!-- ← 여기에 추가 -->
         REG_USER, UPD_USER, UPD_DATE
  FROM TB_PENTA_BUSINESS_SAMPLE_15
  ...
</select>

<!-- INSERT: 삽입 컬럼 목록과 VALUES에 추가 -->
<insert id="insertSample15" ...>
  INSERT INTO TB_PENTA_BUSINESS_SAMPLE_15
    (USER_ID, REG_DATE, SEQ, CUST_NM, CUST_TEL, CUST_EMAIL, NOTE,
     CUST_GRADE,   <!-- ← 컬럼 목록에 추가 -->
     REG_USER)
  VALUES (
    #{userId}, SYSDATE,
    (SELECT NVL(MAX(SEQ),0)+1 FROM TB_PENTA_BUSINESS_SAMPLE_15 WHERE USER_ID=#{userId} AND TRUNC(REG_DATE)=TRUNC(SYSDATE)),
    #{custNm}, #{custTel}, #{custEmail}, #{note},
    #{custGrade},  <!-- ← VALUES에도 추가 (VO 필드명 사용) -->
    #{regUser}
  )
</insert>

<!-- UPDATE: SET 절에 추가 -->
<update id="updateSample15" ...>
  UPDATE TB_PENTA_BUSINESS_SAMPLE_15
  SET CUST_NM = #{custNm},
      CUST_TEL = #{custTel},
      CUST_EMAIL = #{custEmail},
      NOTE = #{note},
      CUST_GRADE = #{custGrade},  <!-- ← SET 절에 추가 -->
      UPD_USER = #{updUser},
      UPD_DATE = SYSDATE
  WHERE USER_ID = #{userId} AND REG_DATE = TO_DATE(#{regDate},'YYYY-MM-DD') AND SEQ = #{seq}
</update>
④ JSP 화면에 컬럼 추가 — sample15view.jsp 파일 수정
<!-- 목록 표시: renderList 함수에 고객등급 컬럼 추가 -->
<script>
function renderList(list) {
  var html = '<table><thead><tr>'
    + '<th>고객명</th><th>전화번호</th><th>이메일</th>'
    + '<th>고객등급</th>'  // ← 헤더에 추가
    + '</tr></thead><tbody>';
  list.forEach(function(row) {
    html += '<tr onclick="openDetail(row)">'
      + '<td>' + row.custNm + '</td>'
      + '<td>' + (row.custTel || '-') + '</td>'
      + '<td>' + (row.custEmail || '-') + '</td>'
      + '<td>' + (row.custGrade || '일반') + '</td>'  // ← 데이터 셀에 추가
      + '</tr>';
  });
  $('#listArea').html(html + '</tbody></table>');
}

// 상세/등록 팝업: 입력 필드 추가
function openDetail(item) {
  $('#inputCustNm').val(item ? item.custNm : '');
  $('#inputCustGrade').val(item ? item.custGrade : '일반');  // ← 추가
  // ... 다른 필드들
  $('#detailPanel').show();
}

// 저장: 전송 데이터에 추가
function saveSample15() {
  $.ajax({
    url: '/HandsOn/business/sample15Save.do',
    type: 'POST',
    contentType: 'application/json',
    data: JSON.stringify({
      custNm    : $('#inputCustNm').val(),
      custGrade : $('#inputCustGrade').val(),  // ← 추가
      custTel   : $('#inputCustTel').val(),
      // ... 기존 필드들
    }),
    success: function(res) { if(res.success) loadList(); }
  });
}
</script>

<!-- 상세 팝업 HTML: 고객등급 선택 드롭다운 추가 -->
<div id="detailPanel">
  <label>고객명</label>
  <input type="text" id="inputCustNm">

  <label>고객 등급</label>   <!-- ← 추가 -->
  <select id="inputCustGrade">  <!-- ← 추가 -->
    <option value="VIP">VIP</option>
    <option value="일반">일반</option>
    <option value="신규">신규</option>
  </select>

  <button onclick="saveSample15()">저장</button>
</div>
📌 케이스 2: 컬럼명 변경 (예: CUST_TEL → CUST_PHONE으로 변경)
상황: 전화번호 컬럼명이 마음에 안 들어서 CUST_TELCUST_PHONE으로 바꾸고 싶다.
⚠️
컬럼명 변경은 DB, VO, SQL, JSP 4곳을 모두 변경해야 합니다.
Eclipse의 Ctrl+H (Find/Replace) 기능을 활용하면 Java/JSP 파일 내 일괄 변경이 가능합니다.
① DB 컬럼명 변경 — SQL Developer에서 실행
-- PostgreSQL: 컬럼명 변경
ALTER TABLE TB_PENTA_BUSINESS_SAMPLE_15
  RENAME COLUMN CUST_TEL TO CUST_PHONE;

-- 코멘트도 다시 달기
COMMENT ON COLUMN TB_PENTA_BUSINESS_SAMPLE_15.CUST_PHONE
  IS '전화번호';

COMMIT;
② VO.java: custTel → custPhone 변경
// 변경 전
private String custTel;

// 변경 후
private String custPhone;   // CUST_TEL → CUST_PHONE 으로 변경
③ SQL XML: CUST_TEL → CUST_PHONE 전체 변경
<!-- Eclipse Ctrl+H: BusinessSample15_SQL.xml 파일 안에서 일괄 변경 -->
<!-- 검색어: CUST_TEL  →  치환어: CUST_PHONE -->
<!-- "Replace All" 클릭하면 파일 내 모든 CUST_TEL이 CUST_PHONE으로 변경됨 -->

<!-- 또한 #{custTel} → #{custPhone} 도 변경 (VO 필드명) -->
④ JSP: custTel → custPhone 전체 변경 (Eclipse Ctrl+H 사용)
// Eclipse Ctrl+H → File Search 탭
// "File name patterns": *.jsp
// "Containing text": custTel
// → 검색 후 각 파일에서 Replace All 실행

// 또는 해당 JSP 파일만 열어서 Ctrl+H → 현재 파일에서 치환

// 변경 전
'<td>' + row.custTel + '</td>'
$('#inputCustTel').val(item.custTel);

// 변경 후
'<td>' + row.custPhone + '</td>'
$('#inputCustPhone').val(item.custPhone);
📌 케이스 3: 컬럼 타입/길이 변경 (예: VARCHAR2(100) → VARCHAR2(200))
① DB 컬럼 크기 변경 — SQL Developer에서 실행
-- 컬럼 길이 늘리기 (줄이면 기존 데이터 손실 위험 — 주의!)
ALTER TABLE TB_PENTA_BUSINESS_SAMPLE_15
  MODIFY (CUST_NM VARCHAR2(200));

-- NUMBER 타입: 소수점 추가 (INT → DECIMAL)
-- 기존 데이터가 있으면 MODIFY 전에 데이터 확인 필요
ALTER TABLE TB_PENTA_BUSINESS_SAMPLE_15
  MODIFY (PAY_AMT NUMBER(15,2));   -- 소수점 2자리 추가

COMMIT;
🔵 파워빌더 비교: DB Painter에서 컬럼을 선택하고 Length를 변경하던 것과 동일합니다.
VO, SQL, JSP는 보통 변경할 필요 없습니다. 타입만 바뀌면 DB-Java 간 자동으로 처리됩니다.
단, NUMBER → 소수점 추가 시 VO에서 intdouble 또는 BigDecimal로 변경해야 합니다.
NUMBER 타입 변경 시 VO 수정 필요한 경우
// PAY_AMT가 정수에서 소수점 포함으로 변경되면 VO도 변경

// 변경 전 (정수)
private int payAmt;

// 변경 후 (소수점 포함)
private double payAmt;    // 또는 BigDecimal payAmt; (금액은 BigDecimal 권장)
📌 케이스 4: 컬럼 삭제
⚠️
컬럼 삭제는 되돌릴 수 없습니다. 운영 DB에서는 먼저 USE_YN 방식으로 비활성화하거나, 반드시 백업 후 진행하세요.
컬럼 삭제 절차 (4곳 수정)
-- ① DB 컬럼 삭제
ALTER TABLE TB_PENTA_BUSINESS_SAMPLE_15
  DROP COLUMN CUST_EMAIL;
COMMIT;

-- ② VO.java: 해당 필드 줄 삭제
// private String custEmail;  ← 이 줄 삭제

-- ③ SQL XML: SELECT/INSERT/UPDATE에서 CUST_EMAIL 및 #{custEmail} 삭제
--    SELECT에서 CUST_EMAIL 제거
--    INSERT 컬럼 목록과 VALUES에서 제거
--    UPDATE SET 절에서 제거

-- ④ JSP: 이메일 관련 HTML/JS 코드 제거
--    <td>row.custEmail</td> 제거
--    <input id="inputCustEmail"> 제거
--    JSON 전송 데이터에서 custEmail: ... 제거
📌 케이스 5: DB 컬럼 없이 조회 조건만 추가 (예: 날짜 범위 검색 추가)
상황: DB 테이블에 컬럼 추가 없이, 화면의 조회조건(검색 필터)만 추가하고 싶다.
예: "등록일 From~To" 날짜 범위 검색 추가
① VO.java: 조회조건 전용 필드 추가 (DB 컬럼 아님)
// VO에는 DB 컬럼 외에 화면용 조회조건 변수를 함께 넣습니다.
@Data
public class BusinessSample15VO {
    // DB 컬럼
    private String userId;
    private String custNm;
    ...

    // 조회 조건용 (DB에 없는 변수 — SQL XML에서만 사용)
    private String searchKeyword;
    private String searchDateFrom;  // ← 추가
    private String searchDateTo;    // ← 추가
}
② SQL XML: 동적 조건(<if>) 추가
<select id="selectSample15List" ...>
  SELECT ...
  FROM TB_PENTA_BUSINESS_SAMPLE_15
  WHERE USER_ID = #{userId}
  <!-- 날짜 조건: 값이 있을 때만 추가됨 -->
  <if test="searchDateFrom != null and searchDateFrom != ''">
    AND REG_DATE >= TO_DATE(#{searchDateFrom}, 'YYYY-MM-DD')
  </if>
  <if test="searchDateTo != null and searchDateTo != ''">
    AND REG_DATE < TO_DATE(#{searchDateTo}, 'YYYY-MM-DD') + 1
  </if>
  ORDER BY REG_DATE DESC
</select>

<!-- 포인트: <if test="...조건..."> 안의 SQL은 조건이 참일 때만 실행됨 -->
<!-- 파워빌더 비교: DataWindow의 "Where" 절에 IF 조건 추가하던 것과 유사 -->
③ JSP: 날짜 입력 필드 추가 및 AJAX 파라미터에 포함
<!-- 조회조건 패널에 날짜 범위 추가 -->
<div class="search-expand">
  <label>등록일</label>
  <input type="date" id="searchDateFrom"> ~
  <input type="date" id="searchDateTo">  <!-- ← 추가 -->
  <button onclick="loadList()">조회</button>
</div>

<script>
function loadList() {
  $.ajax({
    url: '/HandsOn/business/sample15List.do',
    data: {
      searchKeyword  : $('#searchKeyword').val(),
      searchDateFrom : $('#searchDateFrom').val(),  // ← 추가
      searchDateTo   : $('#searchDateTo').val()      // ← 추가
    },
    success: function(res) { if(res.success) renderList(res.list); }
  });
}
</script>
📋 컬럼 추가·변경 시 빠른 체크리스트
작업DBVO.javaSQL XMLJSP
컬럼 추가✅ ALTER TABLE ADD✅ 필드 추가✅ SELECT/INSERT/UPDATE 추가✅ 화면 추가
컬럼명 변경✅ RENAME COLUMN✅ 필드명 변경✅ 컬럼명 + #{변수명} 변경✅ JS 변수명 변경
컬럼 길이 변경✅ MODIFY보통 불필요보통 불필요maxlength 변경
NUMBER→소수점✅ MODIFY✅ int→double보통 불필요보통 불필요
컬럼 삭제✅ DROP COLUMN✅ 필드 삭제✅ 모든 참조 삭제✅ HTML/JS 삭제
조회조건 추가불필요✅ 조건 필드 추가✅ <if> 조건 추가✅ 입력창 추가
📂 업무 화면 유형별 복사 템플릿 가이드
💡
새 화면을 만들 때는 아래 표에서 가장 유사한 기존 화면을 찾아 복사 후 수정하는 방식이 가장 효율적입니다.
각 화면 유형마다 JSP 구조, SQL 패턴, VO 설계 방식이 다르므로 유형에 맞는 템플릿을 선택하세요.
화면 유형복사 기준 파일주요 특징새 화면 시 주의점
📋 자료형 (카드/목록)sample01view.jsp카드 형태 목록 + 슬라이드 상세조회조건 접기/펼치기, 날짜 범위 필수
📊 표형 (그리드/정렬)sample02view.jsp컬럼 정렬, 반응형 테이블정렬 상태 유지, 모바일 컬럼 숨기기
📈 차트형sample03view.jspChart.js 13종 차트, 연도 선택chart.destroy() 후 재생성 필수
✅ 결재/승인형sample04view.jsp결재선 워크플로우, 상태 관리TB_PENTA_MESSAGE 발송 로직 포함
📝 입력형 (폼)sample05view.jsp다양한 입력 컨트롤, 유효성 검사필수값 체크, 저장 전 confirm
📷 바코드형sample06view.jsp카메라·바코드 스캔 연동api 패키지, Android/iOS 공통 인터페이스
🖼️ 사진형sample07view.jsp사진 촬영·BLOB 저장·썸네일BLOB 크기 제한, 압축 처리
🗺️ 지도형sample08view.jspGPS 위치, Leaflet + OpenStreetMap 지도API 키 불필요 (무료)
📋 SAMPLE_01 — 자료형 화면 개발 (신용카드 결제내역)
📁 복사 대상 파일
  • sample01view.jsp → sampleXXview.jsp
  • BusinessSample01VO.java → BusinessSampleXXVO.java
  • BusinessSample01_SQL.xml → BusinessSampleXX_SQL.xml
  • BusinessController.java → 메서드 추가 (파일 복사 아님)
🗄️ DB 테이블 PK 설계 원칙
  • PK1: USER_ID (사용자 ID)
  • PK2: 날짜/시간 컬럼 (PAY_DTTM)
  • PK3: SEQ = MAX+1 서브쿼리
  • 시퀀스 객체 사용 금지
JSP 조회조건 패널 구조 (접기/펼치기)
<!-- 조회조건: 항상 1줄 표시, 클릭하면 펼쳐짐 -->
<div id="searchBar" class="search-bar-collapsed">
  <div class="search-summary" onclick="toggleSearch()">
    <!-- 접힌 상태에서 보여줄 현재 조회조건 요약 -->
    <span id="searchSummary">최근 15일</span>
    <i class="fas fa-chevron-down"></i>
  </div>
  <div class="search-expand" id="searchExpand" style="display:none">
    <!-- 펼쳐진 조회조건 폼 -->
    <input type="date" id="dateFrom"> ~ <input type="date" id="dateTo">
    <select id="payStatus">
      <option value="">전체</option>
      <option value="APR">승인</option>
      <option value="CAN">취소</option>
    </select>
    <button onclick="doSearch()">조회</button>
  </div>
</div>

<script>
// 조회조건 패널 포커스 이탈 시 자동 닫기 + 자동 조회
function toggleSearch() {
  var $exp = $('#searchExpand');
  $exp.slideToggle(200, function() {
    if ($exp.is(':visible')) $exp.find('input,select').first().focus();
  });
}
$('#searchExpand').on('focusout', function(e) {
  if (!$.contains(this, e.relatedTarget)) {
    $(this).slideUp(200);
    updateSummary();
    loadList(); // 자동 조회
  }
});
</script>
SAMPLE_01 SQL 핵심 패턴 (BusinessSample01_SQL.xml)
<!-- 목록 조회: 최근 15일, 다중 조건 -->
<select id="selectSample01List" ...>
  SELECT USER_ID, PAY_DTTM, PAY_SEQ, CARD_NM, MERCHANT_NM,
         MERCHANT_CAT, PAY_AMT, DISCOUNT_AMT, FINAL_AMT,
         INSTALLMENT, APPROVAL_NO, PAY_STATUS
  FROM TB_PENTA_BUSINESS_SAMPLE_01
  WHERE USER_ID = #{userId}
    AND PAY_DTTM >= TO_TIMESTAMP(#{dateFrom}, 'YYYY-MM-DD')
    AND PAY_DTTM <  TO_TIMESTAMP(#{dateTo}, 'YYYY-MM-DD') + 1
  <if test="payStatus != null and payStatus != ''">
    AND PAY_STATUS = #{payStatus}
  </if>
  ORDER BY PAY_DTTM DESC
</select>

<!-- PK 자동 증가 (MAX+1): 시퀀스 없이 PK3 계산 -->
<insert id="insertSample01" ...>
  INSERT INTO TB_PENTA_BUSINESS_SAMPLE_01
    (USER_ID, PAY_DTTM, PAY_SEQ, ...)
  VALUES (
    #{userId}, SYSTIMESTAMP,
    (SELECT NVL(MAX(PAY_SEQ),0)+1
     FROM TB_PENTA_BUSINESS_SAMPLE_01
     WHERE USER_ID=#{userId}
       AND PAY_DTTM=SYSTIMESTAMP),
    ...
  )
</insert>
Controller 패턴 (SAMPLE_01)
// 화면 진입 (GET) - 세션 체크 후 JSP 반환
@GetMapping("/business/sample1View.do")
public String sample01View(@RequestParam String progId,
        HttpSession session, Model model) {
    log.info("[SAMPLE01] 진입 userId={}", session.getAttribute("userId"));
    model.addAttribute("progId", progId);
    return "business/sample01view";
}

// 목록 조회 (AJAX-GET) → JSON 반환
@GetMapping("/business/sample01List.do")
@ResponseBody
public Map<String, Object> sample01List(
        BusinessSample01VO vo, HttpSession session) {
    Map<String, Object> result = new HashMap<>();
    try {
        vo.setUserId((String) session.getAttribute("userId"));
        result.put("list", businessService.selectSample01List(vo));
        result.put("success", true);
    } catch(Exception e) { result.put("success",false); }
    return result;
}
📊 SAMPLE_02 — 표형 화면 개발 (재고현황)
📁 복사 대상 파일
  • sample02view.jsp → sampleXXview.jsp
  • BusinessSample02VO.java → 복사 후 컬럼 변경
  • BusinessSample02_SQL.xml → 복사 후 테이블명·컬럼 변경
🔑 표형 핵심 기능
  • 컬럼 헤더 클릭 → 오름/내림차순 정렬
  • 현재 정렬 컬럼 화살표(▲▼) 표시
  • 모바일: 주요 컬럼만 표시
  • PC: 모든 컬럼 표시 (반응형)
표형 컬럼 정렬 JavaScript 패턴
var sortCol = 'PROD_NM', sortDir = 'ASC';

function doSort(col) {
  if (sortCol === col) {
    sortDir = (sortDir === 'ASC') ? 'DESC' : 'ASC';
  } else {
    sortCol = col; sortDir = 'ASC';
  }
  loadList(); // 정렬 변경 후 재조회
}

function renderSortIcon(col) {
  if (sortCol !== col) return '<i class="fas fa-sort text-muted"></i>';
  return sortDir === 'ASC'
    ? '<i class="fas fa-sort-up text-primary"></i>'
    : '<i class="fas fa-sort-down text-primary"></i>';
}

// 테이블 헤더 HTML (정렬 아이콘 포함)
function renderTableHeader() {
  return '<thead><tr>'
    + '<th onclick="doSort(\'PROD_NM\')">제품명 ' + renderSortIcon('PROD_NM') + '</th>'
    + '<th onclick="doSort(\'STOCK_QTY\')">재고수량 ' + renderSortIcon('STOCK_QTY') + '</th>'
    + '</tr></thead>';
}
반응형 컬럼 제어 CSS (SAMPLE_02 패턴)
/* 모바일: 주요 컬럼만 보임 */
@media (max-width: 768px) {
  .col-optional { display: none; }
}
/* PC: 모든 컬럼 표시 */
@media (min-width: 769px) {
  .col-optional { display: table-cell; }
}
행 클릭 → 상세 슬라이드 패턴 (sample02view.jsp)
// 행 클릭 시 상세 패널 표시 (위에서 아래로 슬라이드)
function openDetail(item) {
  // 상세 패널에 데이터 채우기
  $('#detailProdNm').text(item.prodNm);
  $('#detailStockQty').val(item.stockQty);
  // 위에서 아래로 슬라이드
  $('#detailPanel').css({top:'-100%'}).show()
    .animate({top: '0'}, 300);
}

function closeDetail() {
  $('#detailPanel').animate({top:'-100%'}, 300, function() {
    $(this).hide();
  });
}
📈 SAMPLE_03 — 차트형 화면 개발 (영업 차트)

Chart.js를 활용한 13종 차트 구현 방법입니다. 각 차트는 연도 선택 드롭다운으로 데이터를 갱신합니다.

차트 유형DB 테이블데이터 특징Chart.js 타입
막대 차트 (Bar)TB_PENTA_BUSINESS_SAMPLE_03_01월별 지점 매출 (SERIES: 서울/경기/부산)type: 'bar'
꺾은선 (Line)TB_PENTA_BUSINESS_SAMPLE_03_02월별 실적/목표 (2개 시리즈)type: 'line'
파이 (Pie)TB_PENTA_BUSINESS_SAMPLE_03_03제품군 비중 (5개 슬라이스)type: 'pie'
도넛 (Doughnut)TB_PENTA_BUSINESS_SAMPLE_03_04채널 비중 (온/오프라인)type: 'doughnut'
레이더 (Radar)TB_PENTA_BUSINESS_SAMPLE_03_05영업 역량 지표 (A팀/B팀)type: 'radar'
폴라 (PolarArea)TB_PENTA_BUSINESS_SAMPLE_03_06권역별 점유율type: 'polarArea'
버블 (Bubble)TB_PENTA_BUSINESS_SAMPLE_03_07X=구매액, Y=방문수, R=충성도type: 'bubble'
산점도 (Scatter)TB_PENTA_BUSINESS_SAMPLE_03_08X=리드수, Y=전환율type: 'scatter'
누적 막대TB_PENTA_BUSINESS_SAMPLE_03_09제품군 누적 매출type: 'bar' + stacked
수평 막대TB_PENTA_BUSINESS_SAMPLE_03_10지점별 매출type: 'bar' + indexAxis:'y'
영역 라인TB_PENTA_BUSINESS_SAMPLE_03_11목표 대비 실적 (채움)type: 'line' + fill
콤보 (Bar+Line)TB_PENTA_BUSINESS_SAMPLE_03_12매출(Bar)+이익(Line)datasets별 type 혼용
게이지 (Doughnut)TB_PENTA_BUSINESS_SAMPLE_03_13목표달성률 (단일 값)반원 doughnut 커스텀
차트 공통 패턴 - 연도 변경 시 차트 갱신 (sample03view.jsp)
var barChart = null;  // 차트 인스턴스 전역 변수로 관리

function loadBarChart(year) {
  $.ajax({
    url: '/HandsOn/business/sample03BarData.do',
    data: { year: year },
    success: function(res) {
      if (!res.success) return;

      // ⚠️ 기존 차트 인스턴스 반드시 파괴 후 재생성 (메모리 누수 방지)
      if (barChart) { barChart.destroy(); barChart = null; }

      var ctx = document.getElementById('barChartCanvas').getContext('2d');
      barChart = new Chart(ctx, {
        type: 'bar',
        data: {
          labels: res.labels,           // ['1월','2월',...,'12월']
          datasets: [{
            label: '서울',
            data: res.seoulData,
            backgroundColor: 'rgba(54,162,235,0.7)'
          },{
            label: '경기',
            data: res.gyeonggiData,
            backgroundColor: 'rgba(255,99,132,0.7)'
          }]
        },
        options: { responsive: true, plugins: { legend: { position: 'top' } } }
      });
    }
  });
}

// 연도 선택 변경 시 모든 차트 갱신
$('#yearSelect').on('change', function() {
  var year = $(this).val();
  loadBarChart(year);
  loadLineChart(year);
  // ... 나머지 차트도 동일 패턴
});
Controller에서 차트 데이터를 JSON으로 가공
@GetMapping("/business/sample03BarData.do")
@ResponseBody
public Map<String, Object> sample03BarData(
        BusinessSample03VO vo, HttpSession session) {
    Map<String, Object> result = new HashMap<>();
    try {
        // DB에서 원시 데이터 조회
        List<BusinessSample03VO> rawList =
            businessService.selectSample03BarList(vo);

        // Chart.js 형식으로 가공: 레이블 배열 + 시리즈별 데이터 배열
        List<String> labels = new ArrayList<>();
        List<Double> seoulData = new ArrayList<>();
        List<Double> gyeonggiData = new ArrayList<>();

        for(BusinessSample03VO row : rawList) {
            if("서울".equals(row.getSeries())) {
                labels.add(row.getLabel());
                seoulData.add(row.getValue1());
            } else if("경기".equals(row.getSeries())) {
                gyeonggiData.add(row.getValue1());
            }
        }
        result.put("labels", labels);
        result.put("seoulData", seoulData);
        result.put("gyeonggiData", gyeonggiData);
        result.put("success", true);
    } catch(Exception e) { result.put("success", false); }
    return result;
}
✅ SAMPLE_04 — 결재/승인형 화면 개발
⚠️
결재형 화면은 구조가 복잡합니다. 반드시 sample04view.jsp와 BusinessSample04_SQL.xml 전체를 먼저 이해하고 개발하세요. 특히 결재 상태 전이(DRAFT → IN_PROGRESS → APPROVED/REJECTED)와 메시지 자동 발송 로직이 핵심입니다.
테이블역할주요 컬럼
TB_PENTA_BUSINESS_SAMPLE_04_01결재 요청 헤더REQUEST_DATE+TIME+USER+SEQ(PK), TITLE, DOC_TYPE, STATUS
TB_PENTA_BUSINESS_SAMPLE_04_00결재선+ LINE_SEQ(PK5), APPROVER_ID, LINE_STATUS, OPINION
TB_PENTA_BUSINESS_04_02요청 첨부파일FILE_ID(UUID PK), BLOB
TB_PENTA_BUSINESS_SAMPLE_04_03결재 승인 이력+ LINE_SEQ + APPROVE_NO(PK6), APPROVAL_STATUS
TB_PENTA_BUSINESS_SAMPLE_04_04결재 첨부파일FILE_ID(UUID PK), BLOB
결재 승인/반려 처리 - Controller 핵심 로직 (BusinessController.java)
@PostMapping("/business/sample04Approve.do")
@ResponseBody
public Map<String, Object> sample04Approve(
        @RequestBody BusinessSample04VO vo, HttpSession session) {
    Map<String, Object> result = new HashMap<>();
    try {
        String loginUser = (String) session.getAttribute("userId");

        // 1. 결재 이력 저장 (TB_PENTA_BUSINESS_SAMPLE_04_03)
        businessService.insertSample04ApprovalHistory(vo);

        // 2. 결재선 상태 업데이트 (TB_PENTA_BUSINESS_SAMPLE_04_00)
        businessService.updateSample04LineStatus(vo);

        // 3. 다음 결재자에게 메시지 발송 또는 최종 완료 처리
        businessService.processSample04NextStep(vo, loginUser);

        result.put("success", true);
    } catch(Exception e) {
        log.error("[SAMPLE04] 결재 처리 오류", e);
        result.put("success", false);
    }
    return result;
}
결재 상태 흐름에 따른 메시지 발송 (ServiceImpl 패턴)
public void processSample04NextStep(BusinessSample04VO vo, String loginUser) throws Exception {
    if ("REJECTED".equals(vo.getApprovalStatus())) {
        // 반려: 요청자에게 메시지 발송
        sendApprovalMessage(vo.getRequestUser(),
            "결재 반려: " + vo.getTitle(),
            "결재가 반려되었습니다. 의견: " + vo.getOpinion());
        // 요청 상태 REJECTED로 변경
        businessDAO.updateSample04RequestStatus(vo.getRequestDate(),
            vo.getRequestUser(), vo.getRequestSeq(), "REJECTED");
    } else {
        // 승인: 다음 결재선 IN_PROGRESS로 변경
        BusinessSample04VO nextLine =
            businessDAO.selectSample04NextLine(vo);
        if (nextLine != null) {
            // 다음 결재자에게 메시지 발송
            sendApprovalMessage(nextLine.getApproverId(),
                "결재 요청: " + vo.getTitle(), "결재가 도착했습니다.");
        } else {
            // 최종 완료: 요청자에게 완료 메시지
            sendApprovalMessage(vo.getRequestUser(),
                "결재 완료: " + vo.getTitle(), "모든 결재가 완료되었습니다.");
            businessDAO.updateSample04RequestStatus(vo.getRequestDate(),
                vo.getRequestUser(), vo.getRequestSeq(), "APPROVED");
        }
    }
}
결재선 표시 JSP 패턴 (sample04view.jsp)
// 결재선 렌더링 함수
function renderApprovalLines(lines) {
  return lines.map(function(line, idx) {
    var statusBadge = {
      'WAIT'       : '<span class="badge gray">대기</span>',
      'IN_PROGRESS': '<span class="badge warning">결재중</span>',
      'APPROVED'   : '<span class="badge success">승인</span>',
      'REJECTED'   : '<span class="badge danger">반려</span>'
    }[line.lineStatus] || '';

    return '<div class="approval-line">'
      + '<span class="line-num">' + (idx+1) + '차</span>'
      + '<span class="approver-name">' + line.approverName + '</span>'
      + statusBadge
      + (line.opinion ? '<p class="opinion">'+line.opinion+'</p>' : '')
      + '</div>';
  }).join('');
}
📅 SAMPLE_05 — 자원관리형 화면 개발 (회의실·차량 예약)
📁 복사 대상
  • sample05view.jsp → 신규 JSP
  • BusinessSample05VO.java → 신규 VO
  • BusinessSample05_SQL.xml → 신규 SQL
🎛️ 지원 입력 컨트롤
  • text, number, date, datetime-local
  • select (드롭다운, 공통코드 연동)
  • radio / checkbox
  • textarea (장문 입력)
  • 파일 첨부 (SystemFileController 연동)
공통코드(TB_PENTA_CODE_MST) 드롭다운 연동 패턴
<!-- JSP: 드롭다운 자리에 id 부여 -->
<select id="jobGrade" name="jobGrade">
  <option value="">직급 선택</option>
</select>

<script>
// 공통코드 로드 함수 (UPPER_CODE_ID 기준)
function loadCodeSelect(upperCode, selectId) {
  $.ajax({
    url: '/HandsOn/common/getCodeList.do',
    data: { upperCodeId: upperCode },
    success: function(res) {
      var opts = '<option value="">선택</option>';
      res.list.forEach(function(c) {
        opts += '<option value="'+c.codeId+'">'+c.codeName+'</option>';
      });
      $('#' + selectId).html(opts);
    }
  });
}
// 페이지 로드 시 직급 코드 로드
loadCodeSelect('JOB_GRADE', 'jobGrade');
</script>
저장 전 유효성 검사 패턴 (Swal.fire 활용)
function saveSample05() {
  // 필수값 체크
  var title = $('#inputTitle').val().trim();
  if (!title) {
    Swal.fire({ icon: 'warning', title: '입력 오류', text: '제목을 입력해주세요.' });
    $('#inputTitle').focus();
    return;
  }
  // 저장 확인
  Swal.fire({
    title: '저장하시겠습니까?', icon: 'question',
    showCancelButton: true, confirmButtonText: '저장', cancelButtonText: '취소'
  }).then(function(result) {
    if (result.isConfirmed) {
      $.ajax({
        url: '/HandsOn/business/sample05Save.do',
        type: 'POST',
        contentType: 'application/json',
        data: JSON.stringify({ title: title, ... }),
        success: function(res) {
          if (res.success) {
            Swal.fire({ icon: 'success', title: '저장 완료' });
            loadList(); closeDetail();
          }
        }
      });
    }
  });
}
📝 SAMPLE_06 — 입력유형 화면 개발 (현장 접수)
⚠️
SAMPLE_06(바코드), SAMPLE_07(사진), SAMPLE_08(지도) 화면은 Java 패키지가 다릅니다.
egovframework.penta.api 패키지를 사용합니다. (api.controller / api.dao / api.service / api.service.impl)
화면디바이스 기능핵심 JavaScript API주요 처리
SAMPLE_06 (바코드)카메라 → 바코드 스캔Android: pentaApp.scanBarcode()
iOS: window.webkit.messageHandlers.scanBarcode.postMessage()
스캔 결과를 JS 콜백으로 수신 → DB 조회
SAMPLE_07 (사진)카메라 → 사진 촬영Android: pentaApp.takePicture()
iOS: window.webkit.messageHandlers.takePicture.postMessage()
Base64 인코딩 → BLOB 서버 저장
SAMPLE_08 (지도)GPS 위치 정보PentaNative.getCurrentLocation()
Leaflet + OpenStreetMap
위경도 좌표 저장 → 지도 마커 표시
바코드 스캔 Android/iOS 공통 인터페이스 (sample06view.jsp)
// Android / iOS 공통 바코드 스캔 함수
function startBarcodeScam() {
  var ua = navigator.userAgent.toLowerCase();
  if (ua.indexOf('android') > -1) {
    // Android WebView 인터페이스
    if (typeof pentaApp !== 'undefined') {
      pentaApp.scanBarcode('onBarcodeResult');
    }
  } else if (ua.indexOf('iphone') > -1 || ua.indexOf('ipad') > -1) {
    // iOS WKWebView 인터페이스
    window.webkit.messageHandlers.scanBarcode
      .postMessage({ callback: 'onBarcodeResult' });
  } else {
    // 웹 브라우저: 직접 입력 폼 표시
    $('#barcodeInput').show().focus();
  }
}

// 앱에서 스캔 완료 후 호출하는 콜백
function onBarcodeResult(barcodeValue) {
  $('#barcodeDisplay').text(barcodeValue);
  searchByBarcode(barcodeValue);
}

// 바코드로 재고 조회
function searchByBarcode(code) {
  $.ajax({
    url: '/HandsOn/api/sample06Search.do',
    data: { barcode: code },
    success: function(res) {
      if (res.item) renderItemDetail(res.item);
      else Swal.fire({ icon:'warning', text:'해당 바코드 상품 없음' });
    }
  });
}
GPS 위치 취득 + 지도 표시 (sample08view.jsp) — Leaflet + OpenStreetMap
// Leaflet 지도 초기화 (페이지 로드 시 1회)
var leafletMap, leafletMarker;
function initLeafletMap(lat, lng) {
  leafletMap = L.map('mapContainer').setView([lat, lng], 15);
  L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
    attribution: '© OpenStreetMap contributors'
  }).addTo(leafletMap);
  leafletMarker = L.marker([lat, lng]).addTo(leafletMap);
}

// GPS 현재 위치 취득 (앱: PentaNative, 웹: navigator.geolocation)
function getCurrentLocation() {
  PentaNative.getCurrentLocation()
    .then(function(loc) {
      var lat = loc.latitude, lng = loc.longitude;
      $('#inputLat').val(lat);
      $('#inputLng').val(lng);
      if (leafletMap) {
        leafletMap.setView([lat, lng], 15);
        leafletMarker.setLatLng([lat, lng]);
      } else {
        initLeafletMap(lat, lng);
      }
    })
    .catch(function(err) {
      Swal.fire({ icon:'warning', text:'위치 정보 오류: ' + err });
    });
}

// Leaflet 마커 이동 헬퍼
function moveMapMarker(lat, lng) {
  if (leafletMap) {
    leafletMap.setView([lat, lng], 15);
    leafletMarker.setLatLng([lat, lng]);
  } else {
    initLeafletMap(lat, lng);
  }
}
Leaflet CSS/JS 로드: HTML <head>에 다음을 추가하세요.
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
API 키 불필요, 무료 사용 가능합니다.
사진 촬영 후 BLOB 업로드 (sample07view.jsp)
// 앱에서 사진 촬영 완료 후 콜백 (Base64 데이터 수신)
function onPhotoTaken(base64Data) {
  // 미리보기 표시
  $('#photoPreview').attr('src', 'data:image/jpeg;base64,' + base64Data);

  // 서버에 업로드
  $.ajax({
    url: '/HandsOn/api/sample07Upload.do',
    type: 'POST',
    contentType: 'application/json',
    data: JSON.stringify({ photoData: base64Data, userId: LOGIN_USER_ID }),
    success: function(res) {
      if (res.success) {
        $('#hiddenFileId').val(res.fileId);
        Swal.fire({ icon:'success', title:'사진 저장 완료', timer:1500 });
      }
    }
  });
}
🔲 SAMPLE_07 — 바코드형 화면 개발 (재고 바코드 스캔)

스마트폰 카메라로 바코드를 스캔해 상품 정보를 조회·처리하는 화면입니다. Android / iOS 앱의 네이티브 스캐너를 호출하고, 결과를 JavaScript 콜백으로 받아 서버 조회까지 연결합니다.

💡
파워빌더 비유: External Function으로 디바이스 DLL을 호출하는 것과 동일합니다. Android는 Java 인터페이스(pentaApp), iOS는 Swift 메시지 핸들러(webkit.messageHandlers)가 그 역할을 합니다.

📁 수정할 파일 목록

ApiController.java
egovframework/penta/api/controller/
바코드 조회 엔드포인트 추가
ApiDAO.java / ApiServiceImpl.java
egovframework/penta/api/dao · service.impl/
DB 조회 로직
🗄️
Api_SQL.xml
sqlmap/api/
바코드 값 기준 SELECT SQL
🖥️
sample07view.jsp
WEB-INF/jsp/business/
스캔 버튼 + 결과 표시 화면
ApiController.java — 바코드 조회 엔드포인트
// GET /HandsOn/api/sample07Search.do?barcode=ABC123
@RequestMapping(value = "/api/sample07Search.do", method = RequestMethod.GET)
@ResponseBody
public Map<String, Object> sample07Search(
        @RequestParam String barcode,
        HttpSession session) {
    Map<String, Object> result = new HashMap<>();
    try {
        logger.info("[SAMPLE07] 바코드 조회 시작: {}", barcode);
        Map<String, Object> item = apiService.getSample07ByBarcode(barcode);
        result.put("success", item != null);
        result.put("item", item);
    } catch (Exception e) {
        logger.error("[SAMPLE07] 바코드 조회 오류", e);
        result.put("success", false);
    }
    return result;
}
sample07view.jsp — 바코드 스캔 + 결과 처리
// 바코드 스캔 시작 (Android/iOS/웹 분기)
function startScan07() {
  var ua = navigator.userAgent.toLowerCase();
  if (ua.indexOf('android') > -1 && typeof pentaApp !== 'undefined') {
    pentaApp.scanBarcode('onScan07Result');          // Android
  } else if (/iphone|ipad/.test(ua)) {
    window.webkit.messageHandlers.scanBarcode
      .postMessage({ callback: 'onScan07Result' });    // iOS
  } else {
    $('#manualInput07').show().focus();               // PC 수동 입력
  }
}

// 앱이 스캔 완료 후 JS 호출
function onScan07Result(barcodeVal) {
  $('#barcodeValue').text(barcodeVal);
  $.ajax({
    url: '/HandsOn/api/sample07Search.do',
    data: { barcode: barcodeVal },
    success: function(res) {
      if (res.success && res.item) {
        showItemDetail07(res.item);
      } else {
        Swal.fire({ icon: 'warning', text: '해당 바코드 상품을 찾을 수 없습니다.' });
      }
    }
  });
}
체크 포인트확인 내용
앱 권한Android AndroidManifest.xml에 CAMERA 권한, iOS Info.plist에 NSCameraUsageDescription 추가 여부
콜백 함수 전역화onScan07Resultwindow.onScan07Result = function(v){...}로 선언해야 앱에서 호출 가능
PC 테스트앱 없이 PC 브라우저에서 테스트 시 수동 입력 폼(#manualInput07)이 표시되어야 함
📦 SAMPLE_08 — 재고조사형 화면 개발 (창고 재고 조회/수정)

창고별 품목 재고 현황을 조회하고, 실사(현물 확인) 수량을 입력해 시스템 재고와 차이를 관리하는 화면입니다. SAMPLE_02의 표 기반 화면에 편집 기능을 추가한 형태로 이해하면 됩니다.

💡
파워빌더 비유: DataWindow의 Update 기능과 동일합니다. 행 편집 후 Save 버튼 클릭 시 변경된 행만 UPDATE SQL이 실행됩니다. MyBatis의 <foreach> 또는 개별 UPDATE로 구현합니다.

📁 수정할 파일 목록

BusinessController.java
egovframework/penta/business/controller/
목록 조회 + 실사 수량 저장 엔드포인트
BusinessSample08VO.java
egovframework/penta/business/vo/
재고 항목 VO
🗄️
BusinessSample08_SQL.xml
sqlmap/
재고 SELECT + 실사 수량 UPDATE SQL
🖥️
sample08view.jsp
WEB-INF/jsp/business/
재고 목록 표 + 인라인 편집
BusinessSample08_SQL.xml — 재고 조회 + 실사 수량 UPDATE
<!-- 재고 목록 조회 -->
<select id="getSample08List" parameterType="map" resultType="BusinessSample08VO">
  SELECT ITEM_CD, ITEM_NM, WAREHOUSE_CD, SYS_QTY, REAL_QTY,
         (REAL_QTY - SYS_QTY) AS DIFF_QTY, INSP_DATE
  FROM   TB_PENTA_BUSINESS_SAMPLE_08
  WHERE  1=1
  <if test="warehouseCd != null and warehouseCd != ''">
    AND WAREHOUSE_CD = #{warehouseCd}
  </if>
  ORDER BY ITEM_CD
</select>

<!-- 실사 수량 업데이트 -->
<update id="updateSample08RealQty" parameterType="BusinessSample08VO">
  UPDATE TB_PENTA_BUSINESS_SAMPLE_08
  SET    REAL_QTY = #{realQty},
         INSP_DATE = SYSDATE,
         UPD_USER  = #{updUser},
         UPD_DATE  = SYSDATE
  WHERE  ITEM_CD      = #{itemCd}
    AND  WAREHOUSE_CD = #{warehouseCd}
</update>
sample08view.jsp — 인라인 편집 + 저장
// 수량 셀 클릭 시 input으로 전환
$('#sample08Table').on('click', '.real-qty-cell', function() {
  var $td = $(this);
  var curVal = $td.data('val');
  $td.html('<input type="number" class="qty-input" value="' + curVal + '">');
  $td.find('.qty-input').focus();
});

// 변경된 행 수집 → 서버 일괄 저장
function saveSample08() {
  var rows = [];
  $('.qty-input').each(function() {
    var $tr = $(this).closest('tr');
    rows.push({
      itemCd:      $tr.data('item'),
      warehouseCd: $tr.data('wh'),
      realQty:     $(this).val()
    });
  });
  if (rows.length === 0) { Swal.fire({ icon:'info', text:'변경된 항목이 없습니다.' }); return; }
  $.ajax({
    url:  '/HandsOn/business/sample08Save.do',
    type: 'POST',
    contentType: 'application/json',
    data: JSON.stringify({ rows: rows }),
    success: function(res) {
      if (res.success) { Swal.fire({ icon:'success', text:'저장 완료', timer:1500 }); loadSample08List(); }
    }
  });
}
🚚 SAMPLE_09 — 제품출고형 화면 개발 (출고 지시 처리)

출고 지시 목록을 조회하고, 담당자가 출고 확인(피킹 완료)을 처리하는 화면입니다. 상태 전환(대기→피킹중→출고완료)이 핵심이며, 각 단계에서 처리자/처리 시각을 기록합니다.

💡
파워빌더 비유: 상태 코드 컬럼 업데이트와 완전히 동일합니다. STATUS_CD 컬럼을 UPDATE하면서 처리자 ID(UPD_USER)와 처리 일시(UPD_DATE)를 함께 기록합니다.

📁 수정할 파일 목록

BusinessController.java
egovframework/penta/business/controller/
출고 목록 조회 + 상태 변경 엔드포인트
BusinessSample09VO.java
egovframework/penta/business/vo/
출고 지시 VO (상태코드 포함)
🗄️
BusinessSample09_SQL.xml
sqlmap/
출고 목록 SELECT + 상태 UPDATE SQL
🖥️
sample09view.jsp
WEB-INF/jsp/business/
출고 목록 카드 + 상태 버튼
BusinessSample09_SQL.xml — 출고 목록 + 상태 업데이트
<!-- 출고 지시 목록 조회 (상태별 필터) -->
<select id="getSample09List" parameterType="map" resultType="BusinessSample09VO">
  SELECT ORDER_NO, ITEM_CD, ITEM_NM, ORDER_QTY, STATUS_CD,
         REQUEST_USER, REQUEST_DATE, PICK_USER, PICK_DATE
  FROM   TB_PENTA_BUSINESS_SAMPLE_09
  WHERE  1=1
  <if test="statusCd != null and statusCd != ''">
    AND STATUS_CD = #{statusCd}
  </if>
  <if test="fromDate != null">
    AND TRUNC(REQUEST_DATE) >= TO_DATE(#{fromDate}, 'YYYYMMDD')
  </if>
  ORDER BY REQUEST_DATE DESC
</select>

<!-- 출고 상태 변경 (피킹 완료 처리) -->
<update id="updateSample09Status" parameterType="BusinessSample09VO">
  UPDATE TB_PENTA_BUSINESS_SAMPLE_09
  SET    STATUS_CD = #{statusCd},
         PICK_USER = #{pickUser},
         PICK_DATE = SYSDATE,
         UPD_USER  = #{updUser},
         UPD_DATE  = SYSDATE
  WHERE  ORDER_NO = #{orderNo}
</update>
sample09view.jsp — 출고 상태 변경 버튼
// 출고 처리 버튼 클릭
function processShipment09(orderNo, currentStatus) {
  var nextStatus = { 'WAIT': 'PICKING', 'PICKING': 'DONE' };
  var statusNm  = { 'PICKING': '피킹 시작', 'DONE': '출고 완료' };
  var toStatus  = nextStatus[currentStatus];
  if (!toStatus) { Swal.fire({ icon:'info', text:'이미 처리 완료된 건입니다.' }); return; }

  Swal.fire({
    icon: 'question',
    title: statusNm[toStatus] + ' 처리하시겠습니까?',
    showCancelButton: true
  }).then(function(r) {
    if (!r.isConfirmed) return;
    $.ajax({
      url:  '/HandsOn/business/sample09UpdateStatus.do',
      type: 'POST',
      data: { orderNo: orderNo, statusCd: toStatus },
      success: function(res) {
        if (res.success) loadSample09List();
      }
    });
  });
}
📷 SAMPLE_10 — 사진형 화면 개발 (현장 사진 촬영·저장)

스마트폰 카메라로 사진을 촬영하고 서버에 저장한 뒤, 저장된 사진 목록을 갤러리 형태로 표시하는 화면입니다. 사진 데이터는 Base64로 전송하여 서버에서 BLOB 또는 파일 시스템으로 저장합니다.

💡
파워빌더 비유: OLEObject를 사용해 BLOB 컬럼에 이미지를 저장하는 것과 유사합니다. 다만 웹에서는 Base64 문자열로 변환해 JSON으로 전송한 뒤 서버에서 디코딩합니다.

📁 수정할 파일 목록

ApiController.java
egovframework/penta/api/controller/
사진 업로드·목록 조회 엔드포인트
🗄️
Api_SQL.xml
sqlmap/api/
사진 INSERT + 목록 SELECT
🖥️
sample10view.jsp
WEB-INF/jsp/business/
촬영 버튼 + 갤러리 그리드
sample10view.jsp — 카메라 호출 + 업로드
// 카메라 촬영 시작
function takePhoto10() {
  var ua = navigator.userAgent.toLowerCase();
  if (ua.indexOf('android') > -1 && typeof pentaApp !== 'undefined') {
    pentaApp.takePicture('onPhoto10Taken');
  } else if (/iphone|ipad/.test(ua)) {
    window.webkit.messageHandlers.takePicture
      .postMessage({ callback: 'onPhoto10Taken' });
  } else {
    // PC: input[type=file] 트리거
    $('#fileInput10').click();
  }
}

// 앱 콜백 — Base64 이미지 수신 후 업로드
window.onPhoto10Taken = function(base64) {
  $('#preview10').attr('src', 'data:image/jpeg;base64,' + base64).show();
  $.ajax({
    url:  '/HandsOn/api/sample10Upload.do',
    type: 'POST',
    contentType: 'application/json',
    data: JSON.stringify({
      photoData: base64,
      refKey:    $('#refKey10').val(),   // 연결 대상 PK
      userId:    LOGIN_USER_ID
    }),
    success: function(res) {
      if (res.success) {
        Swal.fire({ icon:'success', text:'사진이 저장되었습니다.', timer:1500 });
        loadGallery10();  // 갤러리 새로고침
      }
    }
  });
};
ApiController.java — Base64 디코딩 후 BLOB INSERT
// POST /HandsOn/api/sample10Upload.do
@RequestMapping(value = "/api/sample10Upload.do", method = RequestMethod.POST)
@ResponseBody
public Map<String, Object> sample10Upload(
        @RequestBody Map<String, Object> param,
        HttpSession session) {
    Map<String, Object> result = new HashMap<>();
    try {
        String base64 = (String) param.get("photoData");
        // Base64 디코딩 → byte[]
        byte[] photoBytes = Base64.getDecoder().decode(base64);
        param.put("photoBytes", photoBytes);
        param.put("fileSize", photoBytes.length);
        apiService.insertSample10Photo(param);
        result.put("success", true);
    } catch (Exception e) {
        logger.error("[SAMPLE10] 사진 업로드 오류", e);
        result.put("success", false);
    }
    return result;
}
🔧 SAMPLE_11 — 작업처리형 화면 개발 (작업 지시 → 완료 처리)

작업 지시서를 조회하고, 작업 시작 시각과 완료 시각을 기록하면서 상태를 관리하는 화면입니다. 현장 담당자가 모바일에서 직접 작업 시작/완료 버튼을 눌러 실시간으로 진행 상황을 갱신합니다.

💡
파워빌더 비유: 공정 관리 DataWindow에서 시작/종료 시간을 NOW()로 입력하고 UPDATE하는 것과 동일합니다. 서버에서 SYSDATE를 사용하면 클라이언트 시간 오차를 방지할 수 있습니다.

📁 수정할 파일 목록

BusinessController.java
egovframework/penta/business/controller/
작업 목록 + 시작/완료 처리 엔드포인트
BusinessSample11VO.java
egovframework/penta/business/vo/
작업 지시 VO (시작·완료 시각 포함)
🗄️
BusinessSample11_SQL.xml
sqlmap/
작업 조회 + 시작/완료 UPDATE SQL
🖥️
sample11view.jsp
WEB-INF/jsp/business/
작업 카드 리스트 + 상태 버튼
BusinessSample11_SQL.xml — 작업 시작/완료 UPDATE
<!-- 작업 시작 처리 -->
<update id="startSample11Work" parameterType="map">
  UPDATE TB_PENTA_BUSINESS_SAMPLE_11
  SET    STATUS_CD  = 'IN_PROGRESS',
         START_DATE = SYSDATE,
         WORKER_ID  = #{workerId},
         UPD_USER   = #{workerId},
         UPD_DATE   = SYSDATE
  WHERE  WORK_NO = #{workNo}
    AND  STATUS_CD = 'WAIT'
</update>

<!-- 작업 완료 처리 -->
<update id="completeSample11Work" parameterType="map">
  UPDATE TB_PENTA_BUSINESS_SAMPLE_11
  SET    STATUS_CD   = 'DONE',
         END_DATE    = SYSDATE,
         RESULT_NOTE = #{resultNote},
         UPD_USER    = #{workerId},
         UPD_DATE    = SYSDATE
  WHERE  WORK_NO = #{workNo}
    AND  STATUS_CD = 'IN_PROGRESS'
    AND  WORKER_ID = #{workerId}
</update>
sample11view.jsp — 작업 시작/완료 처리 함수
// 작업 시작 버튼
function startWork11(workNo) {
  Swal.fire({ icon:'question', title:'작업을 시작하시겠습니까?', showCancelButton:true })
    .then(function(r) {
      if (!r.isConfirmed) return;
      $.post('/HandsOn/business/sample11Start.do', { workNo: workNo },
        function(res) {
          if (res.success) loadSample11List();
        });
    });
}

// 작업 완료 버튼 — 결과 메모 입력 받기
function completeWork11(workNo) {
  Swal.fire({
    icon:  'question',
    title: '작업을 완료 처리합니다.',
    input: 'textarea',
    inputLabel: '처리 결과 메모 (선택)',
    inputPlaceholder: '이상 없음 / 교체 완료 등',
    showCancelButton: true
  }).then(function(r) {
    if (!r.isConfirmed) return;
    $.post('/HandsOn/business/sample11Complete.do',
      { workNo: workNo, resultNote: r.value },
      function(res) {
        if (res.success) loadSample11List();
      });
  });
}
⚡ SAMPLE_12 — 가동/비가동형 화면 개발 (설비 상태 토글 관리)

공장·현장의 설비 가동 상태(가동/비가동)를 실시간으로 확인하고 변경 이력을 남기는 화면입니다. 토글 버튼 하나로 상태를 전환하고, 변경 사유와 담당자를 이력 테이블에 기록합니다.

💡
파워빌더 비유: RadioButton 클릭 시 상태코드를 UPDATE하고, 이력 테이블에 INSERT하는 2-step 처리입니다. Spring의 @Transactional로 묶어 원자성을 보장합니다.

📁 수정할 파일 목록

BusinessController.java
egovframework/penta/business/controller/
설비 목록 조회 + 상태 변경 엔드포인트
BusinessSample12VO.java
egovframework/penta/business/vo/
설비 상태 VO
🗄️
BusinessSample12_SQL.xml
sqlmap/
상태 UPDATE + 이력 INSERT SQL
🖥️
sample12view.jsp
WEB-INF/jsp/business/
설비 카드 + 토글 스위치
BusinessSample12_SQL.xml — 상태 변경 + 이력 기록
<!-- 설비 가동 상태 변경 -->
<update id="updateSample12Status" parameterType="map">
  UPDATE TB_PENTA_BUSINESS_SAMPLE_12
  SET    OPER_STATUS = #{operStatus},
         CHG_USER    = #{chgUser},
         CHG_DATE    = SYSDATE,
         CHG_REASON  = #{chgReason},
         UPD_USER    = #{chgUser},
         UPD_DATE    = SYSDATE
  WHERE  EQUIP_ID = #{equipId}
</update>

<!-- 상태 변경 이력 INSERT -->
<insert id="insertSample12History" parameterType="map">
  INSERT INTO TB_PENTA_BUSINESS_SAMPLE_12_HIS
    (EQUIP_ID, BEFORE_STATUS, AFTER_STATUS, CHG_USER, CHG_DATE, CHG_REASON)
  VALUES
    (#{equipId}, #{beforeStatus}, #{operStatus}, #{chgUser}, SYSDATE, #{chgReason})
</insert>
sample12view.jsp — 토글 버튼 처리
// 가동/비가동 토글 클릭
function toggleEquip12(equipId, currentStatus) {
  var newStatus = (currentStatus === 'RUN') ? 'STOP' : 'RUN';
  var msg       = (newStatus === 'RUN') ? '가동' : '비가동';
  Swal.fire({
    icon:        'question',
    title:       msg + ' 처리',
    input:       'text',
    inputLabel:  '변경 사유 입력',
    inputAttributes: { required: true },
    showCancelButton: true
  }).then(function(r) {
    if (!r.isConfirmed || !r.value) return;
    $.post('/HandsOn/business/sample12Toggle.do', {
      equipId:       equipId,
      beforeStatus:  currentStatus,
      operStatus:    newStatus,
      chgReason:     r.value
    }, function(res) {
      if (res.success) loadSample12List();
    });
  });
}
상태코드화면 표시버튼 색상다음 전환 가능 상태
RUN가동중 🟢초록STOP (비가동)
STOP비가동 🔴빨강RUN (가동)
MAINT점검중 🟡노랑RUN / STOP
🗺️ SAMPLE_13 — 지도형 화면 개발 (GPS 위치 저장 + 지도 표시)

현재 GPS 위치를 취득해 DB에 저장하고, 저장된 위치들을 지도 위에 마커로 표시하는 화면입니다. Leaflet + OpenStreetMap을 사용합니다 (무료, API 키 불필요).

💡
Leaflet 사용: 카카오맵·네이버맵은 유료 API 키가 필요하므로 무료 오픈소스인 Leaflet (leafletjs.com) + OpenStreetMap을 사용합니다. CDN으로 즉시 사용 가능합니다.

📁 수정할 파일 목록

BusinessController.java
egovframework/penta/business/controller/
위치 저장 + 목록 조회 엔드포인트
🗄️
BusinessSample13_SQL.xml
sqlmap/business/
위치 INSERT + 마커 목록 SELECT
🖥️
sample13view.jsp
WEB-INF/jsp/business/
Leaflet 지도 컨테이너 + 마커 렌더링
sample13view.jsp — Leaflet 지도 초기화 + 마커 표시
// head에 Leaflet CSS/JS 포함 (CDN)
// <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
// <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>

var map13, markers13 = [];
function initMap13(centerLat, centerLng) {
  map13 = L.map('map13Container').setView([centerLat, centerLng], 13);
  L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
    attribution: '© OpenStreetMap contributors'
  }).addTo(map13);
}

// DB 조회 후 마커 일괄 생성
function loadMarkers13() {
  $.ajax({
    url: '/HandsOn/business/sample13Markers.do',
    success: function(res) {
      $.each(res.list, function(i, item) {
        var marker = L.marker([item.lat, item.lng])
          .addTo(map13)
          .bindPopup('<b>' + item.locNm + '</b><br>' + item.regDate);
        markers13.push(marker);
      });
    }
  });
}

// 현재 위치 취득 → DB 저장 → 마커 추가
function saveCurrentLocation13() {
  PentaNative.getCurrentLocation()
    .then(function(pos) {
      $.ajax({
        url:  '/HandsOn/business/sample13SaveLoc.do',
        type: 'POST',
        data: {
          lat:   pos.lat,
          lng:   pos.lng,
          locNm: $('#inputLocNm13').val()
        },
        success: function(res) {
          if (res.success) { loadMarkers13(); Swal.fire({ icon:'success', text:'위치 저장 완료', timer:1200 }); }
        }
      });
    })
    .catch(function() { Swal.fire({ icon:'error', text:'GPS 위치 정보를 가져올 수 없습니다.' }); });
}
📍 SAMPLE_14 — 현장방문형 화면 개발 (방문 체크인·이력 관리)

현장 방문 시 GPS 위치를 자동으로 기록하고, 방문 목적·결과·사진을 함께 저장하는 화면입니다. SAMPLE_10(사진)과 SAMPLE_13(지도)의 기능을 결합한 복합 유형입니다.

💡
파워빌더 비유: 여러 DataWindow(방문정보 + 위치정보 + 사진정보)를 한 화면에서 처리하는 것과 동일합니다. 서버에서는 트랜잭션 내에서 헤더 테이블 INSERT → 사진 BLOB INSERT → 위치 INSERT를 순서대로 처리합니다.

📁 수정할 파일 목록

BusinessController.java
egovframework/penta/business/controller/
방문 등록 + 목록/상세 조회 엔드포인트
BusinessSample14VO.java
egovframework/penta/business/vo/
방문 정보 VO (lat/lng/photoData 포함)
🗄️
BusinessSample14_SQL.xml
sqlmap/
방문 헤더 + 사진 + 위치 SQL
🖥️
sample14view.jsp
WEB-INF/jsp/business/
방문 목록 + 등록 패널 (지도+사진+폼 통합)
sample14view.jsp — 체크인 처리 (GPS + 사진 + 폼 일괄 전송)
// 현장 체크인 처리
function checkIn14() {
  var locData = { lat: null, lng: null };
  // 1단계: GPS 취득
  navigator.geolocation.getCurrentPosition(function(pos) {
    locData.lat = pos.coords.latitude;
    locData.lng = pos.coords.longitude;
    // 2단계: 사진 촬영 여부 확인
    var photoBase64 = $('#hiddenPhoto14').val() || '';
    // 3단계: 폼 데이터와 함께 서버 전송
    $.ajax({
      url:  '/HandsOn/business/sample14CheckIn.do',
      type: 'POST',
      contentType: 'application/json',
      data: JSON.stringify({
        visitPurpose: $('#visitPurpose14').val(),
        custNm:       $('#custNm14').val(),
        lat:          locData.lat,
        lng:          locData.lng,
        photoData:    photoBase64,
        note:         $('#note14').val()
      }),
      success: function(res) {
        if (res.success) {
          Swal.fire({ icon:'success', title:'체크인 완료', timer:1500 });
          closeSidePanel();
          loadSample14List();
        }
      }
    });
  }, function() {
    Swal.fire({ icon:'warning', text:'GPS 권한이 필요합니다. 위치 권한을 허용해 주세요.' });
  });
}
BusinessSample14_SQL.xml — 방문 등록 트랜잭션 SQL
<!-- 방문 헤더 INSERT -->
<insert id="insertSample14Visit" parameterType="BusinessSample14VO">
  INSERT INTO TB_PENTA_BUSINESS_SAMPLE_14
    (VISIT_DATE, VISIT_USER, VISIT_SEQ,
     CUST_NM, VISIT_PURPOSE, LAT, LNG, NOTE,
     REG_USER, REG_DATE)
  VALUES
    (TRUNC(SYSDATE), #{visitUser},
     (SELECT NVL(MAX(VISIT_SEQ),0)+1
      FROM TB_PENTA_BUSINESS_SAMPLE_14
      WHERE VISIT_DATE = TRUNC(SYSDATE) AND VISIT_USER = #{visitUser}),
     #{custNm}, #{visitPurpose}, #{lat}, #{lng}, #{note},
     #{visitUser}, SYSDATE)
</insert>
구현 포인트처리 방법
GPS + 사진 동시 처리GPS 취득 콜백 안에서 Ajax 호출 — 순서 보장
사진 없이 체크인photoData가 빈 문자열이면 서버에서 사진 INSERT 스킵
체크아웃 처리별도 sample14CheckOut.doEND_DATE = SYSDATE UPDATE
오프라인 대응네트워크 미연결 시 localStorage에 임시 저장 후 온라인 복구 시 재전송
🔄 오류 발생 시 확인 순서
  1. Eclipse Console 로그 확인
    빨간색 에러 메시지를 확인합니다. 클래스명, 메서드명, 라인번호가 표시됩니다.
  2. 브라우저 개발자 도구 (F12) → Console/Network 확인
    AJAX 요청 실패 시 Network 탭에서 응답 내용을 확인합니다.
  3. 자주 발생하는 오류
    오류 메시지원인해결 방법
    Invalid bound statementSQL ID가 DAO 메서드명과 다름XML의 id="..." 와 DAO 메서드명 일치 확인
    Could not find result mapresultType 클래스 경로 오류VO 클래스 패키지 경로 확인
    404 Not FoundController URL 매핑 오류@GetMapping URL과 실제 호출 URL 일치 확인
    NullPointerException세션에서 값 없음session.getAttribute() 결과 null 체크
    ORA-00942테이블 없음테이블 생성 확인, 스키마 확인
10 📱 주요 화면 기능 설명
📋 모든 업무 화면 공통 UI 규칙
🔍 조회조건 패널 (공통)
  • 항상 상단에 1줄로 접혀서 표시
  • 클릭/포커스 시 아래로 펼쳐짐
  • 포커스 이탈 시 자동 조회 후 닫힘
  • 조건이 여러 개인 경우 포커스 시 여러 줄 표시
  • 조회 결과 수 표시 (예: 총 25건)
📜 상세 패널 (공통)
  • 목록 항목 클릭 → 위에서 아래로 슬라이드
  • 키보드가 하단 고정 → 상단부터 스크롤 가능
  • 수정/삭제/닫기 버튼 상단에 배치
  • 첨부파일 영역 하단에 표시
🔔 알림 처리 (공통)
  • Swal.fire() 전용 사용 (alert 금지)
  • 저장/삭제 전 confirm 팝업 표시
  • 성공 시 success 아이콘 + 자동 닫힘
  • 오류 시 error 아이콘 + 메시지 표시
📱 반응형 레이아웃 (공통)
  • 모바일(≤768px): 주요 컬럼만 표시
  • 태블릿(769~1024px): 중간 컬럼 추가
  • PC(≥1025px): 전체 컬럼 표시
  • CSS 클래스 .col-optional로 제어
🔐 로그인 화면 (login.jsp)
기능세부 설명관련 DB / 코드
로그인 처리SHA-256 해시된 비밀번호 비교.
성공 시 세션 저장, 실패 횟수 초기화.
TB_PENTA_USER.USER_PASSWORD
SHA256Util.encode()
실패 처리실패 시 FAILED_LOGIN_COUNT +1.
5회 도달 시 LOCK_YN='Y' 설정, 로그인 차단.
TB_PENTA_USER.FAILED_LOGIN_COUNT
TB_PENTA_USER.LOCK_YN
계정 잠금 해제관리자(admin)가 사용자 관리 화면에서 LOCK_YN='N'으로 변경.TB_PENTA_USER.LOCK_DATE
자동 로그인체크박스 선택 시 AUTO_LOGIN_YN='Y' 저장.
앱 재시작 시 토큰으로 자동 로그인. 로그아웃 시 'N' 변경.
TB_PENTA_USER.AUTO_LOGIN_YN
로그인 이력성공(S)/실패(F) 모두 이력 저장.TB_PENTA_LOGIN_LOG
3가지 디자인login_design1.jsp (기본), login_design2.jsp (다크), login_design3.jsp (그라데이션).
최종 사용 파일은 login.jsp.
WEB-INF/jsp/login/
🏠 메인(대시보드) 화면 (main.jsp)
구성 요소데이터 소스표시 내용
공지사항 섹션TB_PENTA_BOARD WHERE BOARD_TYPE='NOTICE'최근 5건, 읽음/미읽음 구분, 클릭 시 상세 표시
미결재 현황TB_PENTA_BUSINESS_SAMPLE_04_00
WHERE APPROVER_ID=로그인자 AND LINE_STATUS='IN_PROGRESS'
결재 대기 건수, 클릭 시 결재 화면(SAMPLE_04) 이동
메시지 미확인TB_PENTA_MESSAGE WHERE RECEIVER_ID=로그인자 AND READ_YN='N'읽지 않은 메시지 수, 클릭 시 메시지 화면 이동
오늘의 일정업무 화면에서 오늘 처리 내역 집계현장방문 건수, 바코드 스캔 건수 등
바텀 네비게이션TB_PENTA_PROGRAM WHERE SYS_TYPE='WEB'홈/업무/메시지/설정 탭 (DB에서 동적 로드)
3가지 디자인-main_design1.jsp / main_design2.jsp / main_design3.jsp, 최종 파일: main.jsp
💳 SAMPLE_01 신용카드 결제내역 화면
📌 접근 URL

/business/sample1View.do?progId=00004

DB: TB_PENTA_BUSINESS_SAMPLE_01
JSP: business/sample01view.jsp

🔍 기본 조회 조건
  • 현재 일자 기준 최근 15일 자동 설정
  • 결제 상태: 전체/승인(APR)/취소(CAN)
  • 가맹점 업종 필터 (CAFE/FOOD/MART…)
  • 금액 범위 검색
기능설명AJAX URL
목록 조회조건별 결제내역 목록. 날짜순 내림차순.GET /business/sample01List.do
상세 보기행 클릭 → 슬라이드 패널. 카드번호(마스킹), 가맹점명, 금액, 할부, 승인번호 표시.GET /business/sample01Detail.do
업종별 집계기간 내 업종별 총액 및 비율 표시 (CAFE/FOOD 등).GET /business/sample01Summary.do
월별 합계이번 달 총 결제액, 할인 총액 표시.목록 조회 시 함께 반환
💡
PK 구조: USER_ID + PAY_DTTM(TIMESTAMP) + PAY_SEQ
동일 사용자가 동일 밀리초에 복수 결제 시 PAY_SEQ로 구분. 시퀀스 미사용.
📦 SAMPLE_02 재고현황 화면
📌 접근 URL

/business/tableView.do?progId=00002

DB: TB_PENTA_BUSINESS_SAMPLE_02
JSP: business/sample02view.jsp

📊 표형 특화 기능
  • 전체 컬럼 정렬 가능 (클릭 시 ▲▼ 전환)
  • 현재 정렬 컬럼 색상 강조
  • 모바일: 제품명/재고수량만 표시
  • PC: 입고일/단가/위치 등 전체 표시
기능설명AJAX URL
재고 목록 조회창고/카테고리별 필터, 재고 부족 상태 색상 구분 (빨강/노랑/초록).GET /business/sample02List.do
상세 보기/수정행 클릭 → 상세 슬라이드 패널. 재고수량 직접 수정 가능.GET/POST /business/sample02Detail.do
재고 등록(+) 버튼 → 신규 재고 항목 등록.POST /business/sample02Save.do
재고 삭제상세 패널 하단 삭제 버튼. Swal confirm 후 처리.POST /business/sample02Delete.do
엑셀 내보내기현재 조회 결과를 Excel 다운로드.GET /business/sample02Excel.do
📊 SAMPLE_03 영업 차트 화면
📌 접근 URL

/business/chartView.do?progId=00003

DB: TB_PENTA_BUSINESS_SAMPLE_03_01 ~ 13
JSP: business/sample03view.jsp

📈 차트 구성
  • 13종 차트 탭으로 전환
  • 연도 선택 드롭다운 (2017~2026)
  • 연도 변경 시 전체 차트 갱신
  • Chart.js 4.x 기반
차트명내용AJAX URL
막대 (Bar)월별 지점(서울/경기/부산) 매출GET /business/sample03BarData.do
꺾은선 (Line)월별 실적 vs 목표GET /business/sample03LineData.do
파이 (Pie)제품군(가전/식품/의류/뷰티/기타) 비중GET /business/sample03PieData.do
도넛 (Doughnut)채널(오프라인/온라인/파트너) 비중GET /business/sample03DoughnutData.do
레이더 (Radar)A팀/B팀 영업 역량 5개 지표GET /business/sample03RadarData.do
폴라 (PolarArea)8개 권역 점유율GET /business/sample03PolarData.do
버블 (Bubble)고객 거래 규모 (구매액×방문수×충성도)GET /business/sample03BubbleData.do
산점도 (Scatter)영업 효율 (리드수 vs 전환율)GET /business/sample03ScatterData.do
누적 막대제품군별 누적 월매출GET /business/sample03StackedData.do
수평 막대지점별 연간 매출 순위GET /business/sample03HbarData.do
영역 라인목표 대비 실적 (면적 채움)GET /business/sample03AreaData.do
콤보 (Bar+Line)월별 매출(막대) + 이익률(꺾은선)GET /business/sample03ComboData.do
게이지연간 목표 달성률 (%)GET /business/sample03GaugeData.do
✅ SAMPLE_04 결재/승인 화면
📌 접근 URL

/business/approval.do?progId=00005

DB: TB_PENTA_BUSINESS_SAMPLE_04_00 ~ 04
JSP: business/sample04view.jsp

🔄 결재 상태 흐름
DRAFT
기안중
IN_PROGRESS
결재중
APPROVED
완료
REJECTED
반려
기능설명AJAX URL
결재 대상 목록내가 결재자인 IN_PROGRESS 목록. 조회 기간 30일, 상태 필터.GET /business/sample04List.do
내 요청 목록내가 기안한 문서 목록 (전체 상태).GET /business/sample04RequestList.do
상세 보기결재 요청 내용 + 결재선 상태 + 첨부파일.GET /business/sample04Detail.do
승인 처리APPROVED 상태로 변경. 다음 결재자에게 메시지 자동 발송.POST /business/sample04Approve.do
반려 처리REJECTED 상태로 변경. 요청자에게 반려 메시지 자동 발송.POST /business/sample04Approve.do (status=REJECTED)
결재선 추가현재 결재선에 중간/후순위 결재자 추가.POST /business/sample04AddLine.do
결재 요청 등록신규 기안 작성. 결재선 구성 후 상신.POST /business/sample04SaveRequest.do
임시저장DRAFT 상태로 저장. 나중에 수정 후 상신 가능.POST /business/sample04UpdateDraft.do
상신 취소IN_PROGRESS 상태의 문서를 DRAFT로 회수.POST /business/sample04Recall.do
결재 취소승인한 결재를 취소(IN_PROGRESS로 복귀).POST /business/sample04CancelApproval.do
의견/댓글결재 문서에 의견 추가/삭제.GET /business/sample04CommentList.do
POST /business/sample04SaveComment.do
첨부파일요청 첨부 / 결재 의견 첨부 파일 다운로드.GET /business/sample04RequestFile.do
GET /business/sample04ApprovalFile.do
💡
메시지 자동 발송 규칙:
① 결재 요청 등록 → 결재선 1번째 결재자에게 메시지 발송
② 1번 결재자 승인 → 2번 결재자에게 메시지 발송
③ 최종 결재자 승인 → 요청자에게 "결재 완료" 메시지 발송
④ 반려 → 요청자에게 "결재 반려" + 반려 의견 메시지 발송
메시지는 TB_PENTA_MESSAGE에 저장되고 FCM 푸시로 전달됩니다.
📝 SAMPLE_05 입력형 화면
기능설명AJAX URL
목록 조회등록된 항목 목록. 조회 조건: 날짜, 제목 검색.GET /business/sample05List.do
신규 등록텍스트, 날짜, 숫자, 드롭다운(공통코드 연동), 첨부파일 포함 폼.POST /business/sample05Save.do
수정상세 패널에서 수정. 수정자/수정일시 자동 기록.POST /business/sample05Update.do
삭제Swal confirm → 소프트 삭제(DEL_YN='Y') 또는 실제 삭제.POST /business/sample05Delete.do
파일 첨부SystemFileController 연동. 다중 파일 업로드, 프로그레스바.POST /system/file/upload.do
💡
드롭다운 공통코드 연동: TB_PENTA_CODE_MST에서 UPPER_CODE_ID로 조회. 예: JOB_GRADE(직급), DEPT_CODE(부서) 등. /common/getCodeList.do AJAX로 로드.
📷 SAMPLE_06~08 디바이스 연동 화면
화면URL주요 기능디바이스 API
SAMPLE_06
바코드 재고조사
/business/sample06view.do
?progId=00010
· 카메라로 바코드/QR 스캔
· 스캔 결과로 재고 즉시 조회
· 재고 조정(입고/출고) 처리
· 스캔 이력 저장 (TB_PENTA_BUSINESS_SAMPLE_06)
Android: pentaApp.scanBarcode()
iOS: WKWebView MessageHandler
브라우저: 직접 입력 폴백
SAMPLE_07
현장 사진 기록
/business/sample07view.do
?progId=00011
· 카메라 촬영 → BLOB 서버 저장
· 사진 목록 썸네일 표시
· 메타데이터(촬영일시·위치) 저장
· 사진 삭제 (소프트)
· DB: TB_PENTA_BUSINESS_SAMPLE_07 + _FILE
Android: pentaApp.takePicture()
iOS: WKWebView MessageHandler
Base64 인코딩 전송
SAMPLE_08
현장 지도 기록
/business/sample08view.do
?progId=00012
· GPS 현재 위치 취득
· Leaflet + OpenStreetMap 마커 표시
· 방문 기록 저장 (위도·경도·주소)
· 방문 이력 지도에 핀 목록 표시
· DB: TB_PENTA_BUSINESS_SAMPLE_08
PentaNative.getCurrentLocation()
Leaflet + OpenStreetMap (무료, API 키 불필요)
⚠️
SAMPLE_06~08 Java 패키지: egovframework.penta.api (api.controller, api.dao, api.service, api.service.impl)
SQL: src/main/resources/egovframework/sqlmap/api/Api_SQL.xml
지도는 Leaflet + OpenStreetMap을 사용하며 별도 API 키가 필요 없습니다.
💬 메시지 화면
기능설명관련 테이블
받은 메시지 목록RECEIVER_ID=로그인자. 읽음/미읽음 구분. 최신순.TB_PENTA_MESSAGE
메시지 상세클릭 시 READ_YN='Y' 업데이트. CLOB 내용 표시.TB_PENTA_MESSAGE
첨부파일메시지 첨부 이미지/문서 다운로드.TB_PENTA_MESSAGE_FILE
새 메시지 작성수신자 검색 (TB_PENTA_USER), 내용 작성, 파일 첨부.TB_PENTA_MESSAGE
푸시 알림수신 시 FCM 푸시 → 앱 알림 트레이 표시.MSG_TYPE: USER_MSG / WORK_LOG / SYSTEM_LOG
결재 자동 메시지SAMPLE_04 결재 흐름에서 자동 발송. MSG_TYPE='WORK_LOG'.TB_PENTA_MESSAGE
⚙️ 설정 화면
설정 항목설명저장 위치
초기 화면 설정홈/업무/메시지/설정 중 앱 시작 시 보여줄 화면 선택.TB_PENTA_USER.INITIAL_SCREEN
세션 타임아웃30분/60분/120분/무제한 선택. 선택 값은 사용자별 저장.TB_PENTA_USER.SESSION_TIMEOUT
글자 크기small/medium/large 선택. CSS 변수로 전체 폰트 크기 변경.TB_PENTA_USER.FONT_SIZE
푸시 알림메시지 수신 시 FCM 푸시 알림 ON/OFF.TB_PENTA_USER.PUSH_NOTI_YN
다크모드다크/라이트 테마 전환. CSS 클래스 동적 적용.TB_PENTA_USER.DARK_MODE_YN
근무시간 외 차단설정 시간대 외 메시지 수신 차단.TB_PENTA_USER.OFF_HOURS_START/END/BLOCK_YN
비밀번호 변경현재 비밀번호 확인 후 SHA-256 해시 저장.TB_PENTA_USER.USER_PASSWORD
자동 로그인 해제로그아웃 시 AUTO_LOGIN_YN='N' 업데이트.TB_PENTA_USER.AUTO_LOGIN_YN
📎 공통 파일 업로드/다운로드 기능
기능설명URL / 클래스
파일 업로드단일/다중 파일 업로드. 프로그레스바 표시. 파일명+속도 실시간.POST /system/file/upload.do
SystemFileController.upload()
파일 다운로드파일 ID로 다운로드. 프로그레스바 표시.GET /system/file/download.do
SystemFileController.download()
파일 목록 조회참조 테이블/키로 연결된 파일 목록 조회.GET /system/file/list.do
파일 삭제물리파일 + TB_PENTA_FILE_LIST 동시 삭제.POST /system/file/delete.do
이력 관리업로드/다운로드 이력 별도 테이블 저장.TB_PENTA_FILE_UPLOAD
TB_PENTA_FILE_DOWNLOAD
팝업 사용법openFileUploadPopup(refTable, refKey1) 함수 호출.WEB-INF/jsp/common/fileUpload.jsp
11 📄 설정 파일 레퍼런스
📁 주요 설정 파일 목록
파일위치역할
globals.propertiessrc/main/resources/파일서버 경로, 앱 설정 등 환경별 변수
mybatis-config.xmlsrc/main/resources/MyBatis 전역 설정 (camelCase 매핑 등)
context-datasource.xmlsrc/.../spring/DB 연결 풀 설정
context-servlet.xmlsrc/.../spring/MVC 설정, ViewResolver, 인터셉터
context-mapper.xmlsrc/.../spring/MyBatis SqlSessionFactory, Mapper 스캔
context-transaction.xmlsrc/.../spring/트랜잭션 설정 (select는 readOnly)
context-common.xmlsrc/.../spring/컴포넌트 스캔 설정
web.xmlsrc/main/webapp/WEB-INF/서블릿 설정, 세션 타임아웃, 인코딩 필터
⚙️ mybatis-config.xml 주요 설정
src/main/resources/mybatis-config.xml
<configuration>
  <settings>
    <!-- DB 컬럼 CUST_NM → Java 변수 custNm 자동 변환 -->
    <setting name="mapUnderscoreToCamelCase" value="true"/>
    <!-- NULL 값도 SQL에 전달 -->
    <setting name="jdbcTypeForNull" value="NULL"/>
    <!-- 로그 레벨 -->
    <setting name="logImpl" value="SLF4J"/>
  </settings>
</configuration>
🔒 보안 설정 (인터셉터)
인터셉터역할
AuthenticationInterceptor모든 요청에서 세션 체크. 로그인 안 된 경우 로그인 페이지로 이동.
ProgramRunLogInterceptor화면 접근 시 TB_PENTA_PROGRAM_RUN_LOG에 실행 이력 기록.
⚠️
인터셉터 제외 경로: /login/**, /js/**, /css/**, /images/**는 인증 없이 접근 가능합니다. 새 화면은 자동으로 인터셉터 적용 대상이 됩니다.
12 🔧 트러블슈팅
🚨 자주 발생하는 문제와 해결 방법
❌ 서버 기동 실패
  • 포트 충돌: netstat -ano | findstr 8080으로 점유 프로세스 확인 후 종료
  • PostgreSQL JDBC 드라이버 없음: pom.xml의 postgresql 의존성 확인 후 Maven → Update Project
  • DB 연결 실패: PostgreSQL 서비스 기동 여부 및 context-datasource.xml 연결 정보 확인
⚠️ 한글 깨짐
  • Tomcat URIEncoding="UTF-8" 설정 확인 (server.xml)
  • web.xml CharacterEncodingFilter 설정 확인
  • JSP 파일 상단 <%@ page contentType="text/html;charset=UTF-8" %> 확인
  • Eclipse 파일 인코딩: Window → Preferences → General → Content Types → Text → UTF-8
⚠️ 로그인 후 메인으로 안 넘어감
  • 세션 설정 확인 (web.xml session-timeout)
  • AuthenticationInterceptor 제외 경로 확인
  • 로그인 Controller redirect URL 확인
⚠️ AJAX 요청 실패 (403)
  • Spring Security CSRF 설정 확인 (사용 중인 경우)
  • 세션 만료 여부 확인 (401 → 로그인 페이지로 이동)
  • Controller @GetMapping/@PostMapping URL 정확성 확인
📋 로그 파일 위치
로그 유형위치내용
Tomcat 서버 로그$TOMCAT_HOME/logs/catalina.outJVM 오류, 기동/중지 이력
애플리케이션 로그$TOMCAT_HOME/logs/handson.logController, Service 로그 (log.info/error)
DB 실행 이력TB_PENTA_PROGRAM_RUN_LOG화면 접근 이력
로그인 이력TB_PENTA_LOGIN_LOG로그인 성공/실패 이력
🔍 개발 시 유용한 SQL
DBeaver / pgAdmin에서 실행
-- 최근 로그인 이력 확인
SELECT * FROM TB_PENTA_LOGIN_LOG ORDER BY LOGIN_DATE DESC FETCH FIRST 20 ROWS ONLY;

-- 사용자별 화면 접근 이력
SELECT * FROM TB_PENTA_PROGRAM_RUN_LOG WHERE USER_ID='admin'
ORDER BY EXEC_DATE DESC FETCH FIRST 20 ROWS ONLY;

-- 미결재 현황 확인
SELECT r.REQUEST_USER, r.TITLE, r.STATUS,
       l.APPROVER_ID, l.LINE_STATUS
FROM TB_PENTA_BUSINESS_SAMPLE_04_01 r
JOIN TB_PENTA_BUSINESS_SAMPLE_04_00 l
  ON r.REQUEST_DATE = l.REQUEST_DATE
 AND r.REQUEST_TIME = l.REQUEST_TIME
 AND r.REQUEST_USER = l.REQUEST_USER
 AND r.REQUEST_SEQ  = l.REQUEST_SEQ
WHERE l.LINE_STATUS = 'IN_PROGRESS'
ORDER BY r.REQUEST_DATE DESC;

-- 미확인 메시지 수
SELECT RECEIVER_ID, COUNT(*) AS UNREAD_CNT
FROM TB_PENTA_MESSAGE
WHERE READ_YN = 'N'
GROUP BY RECEIVER_ID;
📞 지원 및 문의
📌
개발 환경 관련 문의
· 전자정부프레임워크 공식 사이트: egovframe.go.kr
· PostgreSQL 관련: PostgreSQL 공식 문서 (postgresql.org/docs) 및 Stack Overflow
· Apache Tomcat: tomcat.apache.org/documentation
· 프로젝트 내 문의: 시스템 관리자 (admin 계정) 또는 IT팀

14 디바이스 API 연동 (카메라 · 바코드 · GPS · 푸시 · 자동로그인)

HandsOn 하이브리드 앱은 Android WebView / iOS WKWebView 위에서 동작하며, 네이티브 장치 기능(카메라, 바코드, GPS, 푸시 알림, 자동 로그인 등)을 JSP 화면에서 직접 호출할 수 있도록 penta-native.js를 통해 Promise 기반 API를 제공합니다.

13-1. 아키텍처 개요

💡
penta-native.js 한 파일로 모든 디바이스 API를 처리합니다.
JSP는 PentaNative.takePhoto() 등 Promise API만 호출하면 됩니다. 앱 환경이면 네이티브 브리지로, 웹 브라우저 환경이면 자동으로 폴백(파일선택/직접입력/localStorage)으로 처리합니다.
구성 요소AndroidiOS역할
JS → 네이티브 호출window.PentaAndroid.postMessage(json)window.webkit.messageHandlers.PentaMac.postMessage(obj)penta-native.js 내부에서 자동 처리
네이티브 → JS 응답PentaNative._resolve(id, data)PentaNative._resolve(id, data)Promise resolve/reject로 결과 반환
메시지 구조{ id: "1", action: "takePhoto", payload: {} }모든 API 공통 구조
브라우저 폴백파일선택 / 직접입력 / localStorage / navigator.geolocation앱 미설치(PC 웹) 환경 자동 대응
JSP                penta-native.js           Android (Kotlin)
─────              ─────────────────         ─────────────────
PentaNative        callNative(action,         PentaAndroidBridge
.takePhoto()   →   payload)             →    .postMessage(json)
                        │                           │
                        │               MainActivity
                        │               .handleNativeRequest()
                        │                    │
                        │               jsResolve(id, data)
                        │                    │
                   _resolve(id, data)  ←────┘
                        │
                   Promise.resolve(data)  ←── JSP에서 .then() 수신
PentaNative API 전체 목록
메서드action 값주요 payload반환값
PentaNative.takePhoto()takePhoto-{ data: base64, size }
PentaNative.pickImage(opts)pickImage{ multiple, maxCount }{ name, size, type, data: base64 }
PentaNative.scanBarcode()scanBarcode-{ code, format }
PentaNative.getCurrentLocation()getCurrentLocation-{ lat, lng, accuracy }
PentaNative.getDeviceInfo()getDeviceInfo-{ clientType, deviceIp }
PentaNative.getPushInfo()getPushInfo-{ clientType, platform, pushToken, deviceId }
PentaNative.getSavedUserId()getSavedUserId-{ userId, saveIdChecked }
PentaNative.saveSavedUserId(id, flag)saveSavedUserId{ userId, saveId }{ success }
PentaNative.getAutoLoginInfo()getAutoLoginInfo-{ userId, password }
PentaNative.saveAutoLoginInfo(id, pw)saveAutoLoginInfo{ userId, password }{ success }
PentaNative.clearAutoLoginInfo()clearAutoLoginInfo-{ success }
PentaNative.isAutoLoginEnabled()isAutoLoginEnabled-{ enabled }

13-2. Android 설정

⚠️
앱 소스 위치: D:\Project\HandsOn\WebApp\50.Source\HandsOnApp
아래 파일들이 이미 구현되어 있습니다. 새 기능이 필요할 때는 MainActivity.kthandleNativeRequest()when 분기를 추가하면 됩니다.

① 프로젝트 파일 구조

HandsOnApp/app/src/main/java/kr/co/penta/handsonapp/
├── MainActivity.kt ← WebView 설정, 브리지 핸들러, 메시지 동기화
├── PentaAndroidBridge.kt ← JS→Android 진입점 (postMessage 수신)
├── BarcodeScanActivity.kt ← 바코드/QR 스캔 전용 화면 (ML Kit + CameraX)
├── PentaFirebaseMessagingService.kt ← FCM 푸시 수신 서비스
└── LogoutService.kt ← 앱 스와이프 종료 시 처리

② PentaAndroidBridge.kt — JS postMessage 수신

// JS에서: window.PentaAndroid.postMessage(JSON.stringify({id, action, payload}))
class PentaAndroidBridge(
    private val activity: MainActivity,
    private val webView: WebView
) {
    @JavascriptInterface
    fun postMessage(json: String) {
        activity.runOnUiThread {
            activity.handleNativeRequest(json)  // MainActivity로 위임
        }
    }
}

③ MainActivity.kt — handleNativeRequest() 핸들러 분기

fun handleNativeRequest(json: String) {
    val obj     = JSONObject(json)
    val action  = obj.getString("action")
    val id      = obj.getString("id")
    val payload = obj.optJSONObject("payload") ?: JSONObject()

    when (action) {
        "getDeviceInfo"      -> handleGetDeviceInfo(id)
        "getPushInfo"        -> handleGetPushInfo(id)
        "getCurrentLocation" -> handleGetLocation(id)
        "takePhoto"          -> handleTakePhoto(id)
        "pickImage"          -> handlePickImage(id, payload)
        "scanBarcode"        -> handleScanBarcode(id)
        "getSavedUserId"     -> handleGetSavedUserId(id)
        "saveSavedUserId"    -> handleSaveSavedUserId(id, payload)
        "getAutoLoginInfo"   -> handleGetAutoLoginInfo(id)
        "saveAutoLoginInfo"  -> handleSaveAutoLoginInfo(id, payload)
        "clearAutoLoginInfo" -> handleClearAutoLoginInfo(id)
        "isAutoLoginEnabled" -> handleIsAutoLoginEnabled(id)
        else                 -> jsReject(id, "Unknown action: $action")
    }
}

// 결과를 JS Promise로 전달하는 헬퍼
private fun jsResolve(id: String, dataJson: String) {
    webView.evaluateJavascript("PentaNative._resolve('$id', $dataJson);", null)
}
private fun jsReject(id: String, message: String) {
    webView.evaluateJavascript("PentaNative._reject('$id', {message:'$message'});", null)
}

④ WebView에 Bridge 등록 (onCreate)

// Bridge 등록 — JS에서 window.PentaAndroid.postMessage() 로 호출
webView.addJavascriptInterface(PentaAndroidBridge(this, webView), "PentaAndroid")

// User-Agent에 앱 식별자 추가 (서버에서 APP/WEB 구분에 사용)
webView.settings.userAgentString = (webView.settings.userAgentString ?: "") + " HandsOn/Android"

⑤ AndroidManifest.xml 권한 및 서비스 등록

<!-- 인터넷 -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- 카메라 (사진촬영·바코드스캔) -->
<uses-permission android:name="android.permission.CAMERA" />
<!-- GPS -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<!-- 알림 (Android 13+) -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- 외부 저장소 읽기 (이미지 선택) -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
    android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />

<application ...>
    <!-- FCM 서비스 -->
    <service
        android:name=".PentaFirebaseMessagingService"
        android:exported="false">
        <intent-filter>
            <action android:name="com.google.firebase.MESSAGING_EVENT" />
        </intent-filter>
    </service>

    <!-- 앱 종료 감지 서비스 -->
    <service android:name=".LogoutService" android:exported="false" />

    <!-- FileProvider (사진 촬영용 임시 파일) -->
    <provider
        android:name="androidx.core.content.FileProvider"
        android:authorities="${applicationId}.fileprovider"
        android:exported="false"
        android:grantUriPermissions="true">
        <meta-data
            android:name="android.support.FILE_PROVIDER_PATHS"
            android:resource="@xml/file_paths" />
    </provider>
</application>

⑥ build.gradle.kts — 의존성

dependencies {
    // GPS
    implementation("com.google.android.gms:play-services-location:21.3.0")

    // 바코드 스캔 (ML Kit)
    implementation("com.google.mlkit:barcode-scanning:17.3.0")

    // CameraX (바코드 스캔 미리보기)
    implementation("androidx.camera:camera-core:1.4.0")
    implementation("androidx.camera:camera-camera2:1.4.0")
    implementation("androidx.camera:camera-lifecycle:1.4.0")
    implementation("androidx.camera:camera-view:1.4.0")
    implementation("androidx.camera:camera-extensions:1.4.0")

    // 암호화 SharedPreferences (자동 로그인 정보 저장)
    implementation("androidx.security:security-crypto:1.1.0")

    // Firebase BOM + FCM (푸시 알림)
    implementation(platform("com.google.firebase:firebase-bom:33.10.0"))
    implementation("com.google.firebase:firebase-messaging")
}

13-3. iOS 설정

① WKScriptMessageHandler 등록 (AppDelegate / ViewController)

// PentaWebViewController.swift
import WebKit
import AVFoundation
import CoreLocation

class PentaWebViewController: UIViewController,
    WKScriptMessageHandler, CLLocationManagerDelegate {

    var webView: WKWebView!
    var pendingCallback: String?
    let locationMgr = CLLocationManager()

    override func viewDidLoad() {
        super.viewDidLoad()

        let config = WKWebViewConfiguration()
        let uc     = config.userContentController

        // Bridge 핸들러 등록 (메시지명이 JS 호출명이 됨)
        uc.add(self, name: "takePicture")
        uc.add(self, name: "scanBarcode")
        uc.add(self, name: "getLocation")

        webView = WKWebView(frame: view.bounds, configuration: config)
        view.addSubview(webView)
        locationMgr.delegate = self

        // 앱 내 서버 URL 로드
        webView.load(URLRequest(url: URL(string: "https://your-server/login.do")!))
    }

    // ── 메시지 수신 ───────────────────────────────────────
    func userContentController(_ userContentController: WKUserContentController,
                                didReceive message: WKScriptMessage) {
        guard let body = message.body as? [String: Any],
              let cb   = body["callback"] as? String else { return }
        pendingCallback = cb

        switch message.name {
        case "takePicture": presentCamera()
        case "scanBarcode": presentBarcodeScanner()
        case "getLocation": requestLocation()
        default: break
        }
    }

    // ── JS 콜백 실행 헬퍼 ─────────────────────────────────
    func invokeCallback(_ result: String) {
        guard let cb = pendingCallback else { return }
        let js = "\(cb)('\(result)')"
        webView.evaluateJavaScript(js)
        pendingCallback = nil
    }
}

② Info.plist 권한 항목

<!-- Info.plist -->
<key>NSCameraUsageDescription</key>
<string>업무용 사진 촬영 및 바코드 스캔에 카메라를 사용합니다.</string>

<key>NSLocationWhenInUseUsageDescription</key>
<string>현장 위치 등록을 위해 위치 정보를 사용합니다.</string>

<key>NSPhotoLibraryUsageDescription</key>
<string>사진 첨부를 위해 갤러리에 접근합니다.</string>

13-4. 카메라 API (사진 촬영)

💡
penta-native.js가 JSP에 포함되어 있어야 합니다.
common-styles.jsp에 이미 포함되어 있으므로 업무 JSP에서 별도 include 불필요.

① JSP에서 PentaNative.takePhoto() 호출

<!-- 촬영 버튼 -->
<button type="button" class="btn btn-primary" onclick="takePhoto()">
  <i class="fas fa-camera"></i> 사진 촬영
</button>

<!-- 미리보기 -->
<img id="photoPreview" src="" alt="미리보기" style="max-width:100%;display:none;">
<input type="hidden" id="photoBase64" name="photoBase64">

<script>
function takePhoto() {
    PentaNative.takePhoto()
        .then(function(result) {
            if (result.cancelled) return;  // 취소
            // result.data  : 순수 Base64 문자열 (data: 접두사 없음)
            // result.size  : 파일 크기(bytes)
            var base64 = 'data:image/jpeg;base64,' + result.data;
            document.getElementById('photoPreview').src = base64;
            document.getElementById('photoPreview').style.display = 'block';
            document.getElementById('photoBase64').value = result.data;
        })
        .catch(function(err) {
            Swal.fire({ icon: 'error', text: '사진 촬영 실패: ' + err.message });
        });
}

// 서버 전송 시 Base64를 JSON body에 포함
function saveSample07() {
    var base64 = document.getElementById('photoBase64').value;
    if (!base64) {
        Swal.fire({ icon: 'warning', text: '사진을 먼저 촬영해 주세요.' });
        return;
    }
    $.ajax({
        url: '/HandsOn/business/sample07Save.do',
        type: 'POST',
        contentType: 'application/json',
        data: JSON.stringify({ photoBase64: base64 }),
        success: function(res) {
            if (res.success) Swal.fire({ icon: 'success', text: '저장 완료' });
            else Swal.fire({ icon: 'error', text: res.message || '저장 실패' });
        }
    });
}
</script>

② 갤러리 이미지 선택 — pickImage()

// 단일 선택
PentaNative.pickImage()
    .then(function(result) {
        if (result.cancelled) return;
        // result.name : 파일명
        // result.size : 크기(bytes)
        // result.type : MIME 타입 (image/jpeg 등)
        // result.data : 순수 Base64
        var src = 'data:' + result.type + ';base64,' + result.data;
        document.getElementById('photoPreview').src = src;
    });

// 다중 선택 (최대 5장)
PentaNative.pickImage({ multiple: true, maxCount: 5 })
    .then(function(results) {
        // results는 배열
        results.forEach(function(img) {
            console.log(img.name, img.data);
        });
    });

③ 서버(Controller)에서 Base64 수신 후 파일 저장

// BusinessController.java
@PostMapping("/business/sample07Save.do")
@ResponseBody
public Map<String, Object> sample07Save(
        @RequestBody Map<String, Object> param,
        HttpSession session) throws IOException {

    Map<String, Object> result = new HashMap<>();
    String base64 = (String) param.get("photoBase64");

    if (base64 != null && !base64.isEmpty()) {
        // penta-native.js에서 순수 Base64만 전달됨 (data: 접두사 없음)
        byte[] bytes = Base64.getDecoder().decode(base64);

        // globals.properties의 upload.root.path 에 저장
        String saveDir  = uploadRootPath + "/sample07/" +
                          new SimpleDateFormat("yyyyMM").format(new Date());
        String fileName = UUID.randomUUID().toString() + ".jpg";
        File dir = new File(saveDir);
        if (!dir.exists()) dir.mkdirs();

        try (FileOutputStream fos = new FileOutputStream(saveDir + "/" + fileName)) {
            fos.write(bytes);
        }
        param.put("filePath", saveDir);
        param.put("fileName", fileName);
        businessService.saveSample07(param);
        result.put("success", true);
    } else {
        result.put("success", false);
        result.put("message", "Base64 이미지 없음");
    }
    return result;
}

13-5. 바코드 / QR 스캔 API

① JSP에서 PentaNative.scanBarcode() 호출

// 바코드/QR 스캔 — result.cancelled 가 true이면 사용자가 취소한 것
PentaNative.scanBarcode()
    .then(function(result) {
        if (result.cancelled) return;
        // result.code   : 스캔된 값
        // result.format : "QR_CODE", "EAN_13", "CODE_128" 등
        document.getElementById('barcodeValue').value = result.code;
        searchByBarcode(result.code);
    })
    .catch(function(err) {
        Swal.fire({ icon: 'error', text: '스캔 실패: ' + err.message });
    });

function searchByBarcode(code) {
    $.ajax({
        url: '/HandsOn/business/sample07Search.do',
        data: { barcode: code },
        success: function(res) {
            if (res.success) fillFormFromBarcode(res.data);
            else Swal.fire({ icon: 'warning', text: '해당 바코드 없음' });
        }
    });
}

// 웹 브라우저 fallback: BarcodeDetector API가 없으면 직접 입력 프롬프트 표시
// (penta-native.js가 자동으로 처리하므로 JSP에서 분기 불필요)

// (penta-native.js의 fallback이 자동 처리: BarcodeDetector API → 없으면 직접 입력 프롬프트)

② 지원 바코드 포맷 (Android ML Kit)

포맷반환 format 값설명
QR코드QR_CODE일반 QR
EAN-13EAN_13상품 바코드 13자리
EAN-8EAN_8상품 바코드 8자리
Code128CODE_128물류·재고 바코드
Code39CODE_39산업용 바코드
ITFITF물류 박스 바코드
UPC-A/EUPC_A / UPC_E북미 상품 바코드
PDF417, Aztec, DataMatrixPDF417 / AZTEC / DATA_MATRIX2D 코드 계열

13-6. 위치정보(GPS) API

① JSP에서 PentaNative.getCurrentLocation() 호출

// GPS 위치 취득 — 앱: FusedLocationProvider, 웹: navigator.geolocation 자동 fallback
PentaNative.getCurrentLocation()
    .then(function(result) {
        var lat = result.lat;   // 위도
        var lng = result.lng;   // 경도
        var acc = result.accuracy;  // 정확도(m)
        updateMap(lat, lng);
        document.getElementById('gpsLat').value = lat;
        document.getElementById('gpsLng').value = lng;
    })
    .catch(function(err) {
        Swal.fire({ icon: 'error', text: 'GPS 오류: ' + err.message });
    });

② Leaflet 지도 연동 (지도형 업무 SAMPLE_08)

💡
Leaflet + OpenStreetMap을 사용합니다. API 키 발급 불필요, 완전 무료입니다.
역지오코딩(좌표→주소)은 Nominatim(OpenStreetMap) 무료 API를 사용합니다.
<!-- JSP head: Leaflet CSS/JS (CDN, 무료) -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>

<div id="mapContainer" style="width:100%;height:350px;border-radius:12px;"></div>

<!-- 폼 저장용 히든 필드 -->
<input type="hidden" id="gpsLat"  name="gpsLat">
<input type="hidden" id="gpsLng"  name="gpsLng">
<input type="hidden" id="gpsAddr" name="gpsAddr">
<input type="text"   id="gpsAddrDisplay" class="form-control" readonly>

<button type="button" class="btn btn-outline-primary" onclick="getCurrentGPS()">
  <i class="fas fa-map-marker-alt"></i> 현재 위치 가져오기
</button>

<script>
var leafletMap, leafletMarker;

// 지도 초기화 (페이지 로드 시)
$(document).ready(function() {
    leafletMap = L.map('mapContainer').setView([37.5665, 126.9780], 13);
    L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
        attribution: '&copy; OpenStreetMap contributors'
    }).addTo(leafletMap);
    leafletMarker = L.marker([37.5665, 126.9780]).addTo(leafletMap);
});

// GPS 위치 취득 후 지도 업데이트
function getCurrentGPS() {
    PentaNative.getCurrentLocation()
        .then(function(result) {
            var lat = result.lat;
            var lng = result.lng;

            // 지도 이동 + 마커
            leafletMap.setView([lat, lng], 16);
            leafletMarker.setLatLng([lat, lng]);

            document.getElementById('gpsLat').value = lat;
            document.getElementById('gpsLng').value = lng;

            // 역지오코딩: Nominatim (OpenStreetMap, 무료)
            fetch('https://nominatim.openstreetmap.org/reverse?format=json&lat=' + lat + '&lon=' + lng)
                .then(function(r) { return r.json(); })
                .then(function(data) {
                    var addr = data.display_name || '';
                    document.getElementById('gpsAddr').value        = addr;
                    document.getElementById('gpsAddrDisplay').value = addr;
                });
        })
        .catch(function(err) {
            Swal.fire({ icon: 'error', text: 'GPS 오류: ' + err.message });
        });
}
</script>

③ 서버에 좌표 저장 (Controller)

// ApiController.java
@PostMapping("/api/sample08Save.do")
@ResponseBody
public Map<String, Object> sample08Save(
        @RequestBody Map<String, Object> param) {
    // param: { gpsLat, gpsLng, gpsAddr, ... }
    Map<String, Object> result = new HashMap<>();
    try {
        sample08Service.saveSample08(param);
        result.put("resultCode", "S");
    } catch (Exception e) {
        log.error("sample08Save error", e);
        result.put("resultCode", "E");
        result.put("resultMsg", e.getMessage());
    }
    return result;
}
TB_PENTA_BUSINESS_SAMPLE_08 주요 GPS 컬럼
컬럼명타입설명
GPS_LATNUMBER(12,8)위도 (예: 37.56680000)
GPS_LNGNUMBER(12,8)경도 (예: 126.97800000)
GPS_ADDRVARCHAR2(500)역지오코딩 주소 문자열
GPS_DATEDATE위치 측정 일시

13-7. 디바이스 정보 및 푸시 정보 API

① 디바이스 정보 — getDeviceInfo()

// 앱 환경에서 사설 IP 조회 (서버 요청 시 clientType/deviceIp 자동 주입용)
PentaNative.getDeviceInfo()
    .then(function(info) {
        // info.clientType : "APP" (앱) 또는 "WEB" (브라우저)
        // info.deviceIp   : 단말 사설 IP (예: "192.168.1.100")
        console.log('clientType:', info.clientType, 'ip:', info.deviceIp);
    });

// 서버 요청에 clientType/deviceIp 자동 주입 헬퍼
$.ajax({
    url: '/HandsOn/business/sample01List.do',
    data: PentaNative.withClientParams({ searchNm: '홍길동' }),
    success: function(res) { /* ... */ }
});

② 푸시 토큰 조회 — getPushInfo()

// 로그인 성공 후 반드시 호출하여 서버에 FCM 토큰 등록
PentaNative.getPushInfo()
    .then(function(info) {
        // info.clientType : "APP" / "WEB"
        // info.platform   : "ANDROID" / "IOS" / "WEB"
        // info.pushToken  : FCM 토큰 문자열
        // info.deviceId   : 단말 고유 ID (ANDROID_ID)

        if (info.clientType === 'APP' && info.pushToken) {
            $.ajax({
                url: '/HandsOn/api/registerPushToken.do',
                type: 'POST',
                contentType: 'application/json',
                data: JSON.stringify({
                    platform: info.platform,
                    pushToken: info.pushToken,
                    deviceId: info.deviceId
                })
            });
        }
    });

13-8. FCM 푸시 알림 설정

💡
푸시 알림이란?
앱이 화면에 없거나 꺼진 상태에서도 스마트폰 상단 알림바에 메시지를 보여주는 기능입니다.
HandsOn 앱은 구글의 FCM (Firebase Cloud Messaging) 서비스를 통해 푸시를 발송합니다.
FCM은 구글이 무료로 제공하지만, 구글 Firebase 프로젝트를 직접 만들고 설정 파일을 발급받아야 작동합니다.

전체 흐름 한눈에 보기

누가무엇을결과물
고객(담당자) Firebase Console에서 프로젝트 생성 + 앱 등록 google-services.json 파일
firebase-key.json 파일 (서비스 계정 키)
앱 개발팀 받은 google-services.json을 앱 소스에 적용 후 빌드 푸시 수신 가능한 APK 완성
서버 운영팀 받은 firebase-key.json을 서버에 배치 + 경로 설정 서버에서 푸시 발송 가능

푸시 알림을 쓰려면 무엇이 필요한가?

처음 접하는 분들을 위해 각 항목이 무엇인지, 왜 필요한지 먼저 설명합니다.

필요한 것한마디 설명어디서 만드나누구에게 필요한가
구글 계정 Firebase는 구글 서비스이므로 구글 계정(Gmail)으로만 접근할 수 있습니다. 기존 Gmail 계정 사용 고객(담당자)
Firebase 프로젝트 구글 클라우드에 내 앱을 위한 공간을 만드는 것입니다. 마치 앱 전용 구글 계정을 하나 만드는 느낌입니다. 이 프로젝트 안에서 앱을 등록하고 푸시를 관리합니다. Firebase Console 웹사이트에서 직접 생성 고객(담당자) — 최초 1회
google-services.json 앱이 "어떤 Firebase 프로젝트에 연결할지"를 알려주는 설정 파일입니다. 이 파일이 없으면 앱이 Firebase에 접속하지 못해 푸시를 받을 수 없습니다. Firebase 프로젝트에 앱을 등록하면 자동으로 생성되어 다운로드됩니다. Firebase Console → 앱 등록 후 자동 생성 앱 개발팀에 전달 (앱 소스에 포함)
firebase-key.json
(서비스 계정 키)
서버(백엔드)가 Firebase에 "나는 HandsOn 서버입니다"라고 인증하는 열쇠 파일입니다. 이 파일이 있어야 서버에서 앱으로 푸시를 발송할 수 있습니다. google-services.json과는 별개 파일입니다. Firebase Console → 프로젝트 설정 → 서비스 계정에서 발급 서버 운영팀에 전달 (서버에 보관)
FCM 토큰 앱이 설치된 각 스마트폰의 고유 주소와 같습니다. 서버가 특정 사용자에게 푸시를 보내려면 그 사람의 기기 토큰이 필요합니다. 앱을 처음 실행하면 자동으로 발급되며, 앱이 로그인할 때 서버 DB에 저장됩니다. 앱 자동 생성 (개발자가 별도로 만들지 않아도 됨) 서버 DB에 자동 저장됨
📋
정리하면:
고객(담당자)은 Firebase 프로젝트를 만들고 파일 2개를 받아서 각 팀에 전달하면 됩니다.
  · google-services.json → 앱 개발팀에 전달
  · firebase-key.json → 서버 운영팀에 전달
이후 각 팀이 해당 파일을 자기 환경에 적용하면 푸시가 작동합니다.
⚠️
Firebase 프로젝트는 고객(운영 주체)이 직접 만들어야 합니다.
Firebase 프로젝트는 구글 계정에 종속되므로, 서비스 운영 주체(고객사)의 구글 계정으로 생성해야 합니다. 개발사 계정으로 만들면 나중에 이관이 어렵습니다.

STEP 1. Google 계정 준비

Firebase는 구글 서비스이므로 구글 계정(Gmail)이 필요합니다. 회사 Google Workspace 계정 또는 개인 Gmail 계정 모두 사용 가능합니다.

확인 항목내용
사용할 구글 계정서비스 운영 주체(고객사) 소유 계정 권장
브라우저Chrome 권장 (Google 계정 로그인 상태 유지)

STEP 2. Firebase Console 접속 & 프로젝트 생성

🌐
접속 주소: https://console.firebase.google.com — 이 주소를 브라우저 주소창에 입력하세요.

접속하면 아래 그림과 같이 Firebase 프로젝트 목록 화면이 나타납니다. 처음이라면 아무 프로젝트도 없고 [프로젝트 추가] 카드만 보입니다.

Firebase Console 메인 화면
▲ Firebase Console 메인 화면 — console.firebase.google.com 접속 시 나타나는 화면

① 프로젝트 만들기 클릭

화면에 보이는 것해야 할 것
왼쪽 상단 새 Firebase 프로젝트 만들기 카드 (불꽃 아이콘) 클릭합니다
오른쪽의 프로젝트 및 작업공간 드롭다운 기존 프로젝트가 있다면 여기서 선택할 수 있습니다 (신규라면 왼쪽 카드 클릭)

② 프로젝트 이름 입력

입력 칸 이름입력할 값설명
프로젝트 이름 HandsOn-Production (예시, 자유롭게 지정) 나중에 Firebase Console에서 구분하는 이름. 한글도 가능하지만 영문 권장.
프로젝트 ID (자동 생성) 자동으로 채워짐 — 수정 불필요 전 세계에서 유일한 ID로 자동 생성됩니다. 특별히 바꿀 필요 없습니다.

③ Google 애널리틱스

다음 화면에서 Google 애널리틱스 사용 여부를 묻습니다. 사용 안 함(비활성화)으로 선택해도 푸시 기능에는 전혀 영향 없습니다.

[프로젝트 만들기]를 클릭하면 약 30초 후 완료됩니다. [계속]을 눌러 프로젝트 대시보드로 이동합니다.

STEP 3. Android 앱 등록 & google-services.json 다운로드

프로젝트 대시보드 중앙에 앱 플랫폼 아이콘들(iOS, Android, 웹)이 보입니다. Android 아이콘(로봇 모양)을 클릭합니다.

앱 패키지명 확인
▲ Android Studio에서 앱 패키지명 확인 — app/build.gradle.ktsapplicationId 값을 그대로 복사합니다

① 앱 등록 화면 — 입력 항목

입력 칸 이름입력할 값어디서 확인하나
Android 패키지 이름 (필수) kr.co.penta.pentawebapp 위 스크린샷처럼 앱 소스 app/build.gradle.kts 43번째 줄의 applicationId 값 (개발팀에 문의하거나 복사해서 전달받으세요)
앱 닉네임 (선택) HandsOn Android 등 자유롭게 입력 Firebase Console에서 앱을 구분하는 표시용 이름. 기능에 영향 없음.
디버그 서명 인증서 SHA-1 (선택) 비워둬도 됩니다 푸시 기능에는 불필요합니다. 소셜 로그인 등을 쓸 때만 필요.
⚠️
패키지 이름은 대소문자와 점(.)을 정확하게 입력해야 합니다.
잘못 입력하면 앱이 Firebase에 연결되지 않아 푸시를 받을 수 없습니다.
복사-붙여넣기를 권장합니다: kr.co.penta.pentawebapp

[앱 등록] 버튼을 클릭합니다.

② google-services.json 다운로드

앱 등록 완료 후 SDK 설정 안내 단계가 나오면 [다음] → [다음] → [콘솔로 이동]을 눌러 건너뜁니다.
이후 아래 화면(프로젝트 설정)에서 파일을 다운로드합니다. 나중에 언제든지 같은 방법으로 재다운로드할 수 있습니다.

google-services.json 다운로드
▲ 프로젝트 설정 화면 — 상단 탭 일반 → 하단 내 앱 → Android 앱 선택 → 오른쪽 [google-services.json] 버튼 클릭
💡
이 화면으로 이동하는 방법:
Firebase Console 좌측 상단 ⚙️프로젝트 설정 → 상단 탭 일반

화면에서 찾는 순서:
① 아래로 스크롤하면 내 앱 섹션이 나옵니다.
② 왼쪽 앱 목록에서 Android 앱 (HandsOn, kr.co.penta.pentawebapp)를 클릭해 선택합니다.
③ 오른쪽 SDK 설정 및 구성 영역에 [google-services.json] 버튼이 나타납니다.
④ 클릭하면 파일이 바로 다운로드됩니다.

다운로드된 파일 이름은 google-services.json으로 고정입니다. 이 파일을 앱 개발팀에 전달합니다.

③ 다운로드한 파일 내용 확인

메모장으로 파일을 열면 아래와 같이 생겼습니다. 이 내용은 참고용이며, 직접 수정할 필요 없습니다.

{
  "project_info": {
    "project_number": "123456789012",        ← Firebase 프로젝트 번호 (자동)
    "project_id": "handson-production",      ← 프로젝트 ID (자동)
    "storage_bucket": "handson-production.appspot.com"
  },
  "client": [{
    "client_info": {
      "mobilesdk_app_id": "1:123456789012:android:abcdef1234567890",
      "android_client_info": {
        "package_name": "kr.co.penta.pentawebapp"   ← 등록한 패키지명
      }
    },
    "api_key": [{ "current_key": "AIzaSy..." }]     ← API 키 (자동 생성)
  }]
}
📌
이 파일을 앱 개발팀에 전달합니다. (이메일, 메신저, USB 등 안전한 방법으로)
개발팀은 파일을 앱 소스의 HandsOnApp/app/ 폴더에 복사한 뒤 앱을 빌드합니다.

나중에 파일을 잃어버려도 재다운로드 가능합니다:
Firebase Console → 프로젝트 설정(⚙️) → [내 앱] 탭 → 앱 선택 → [google-services.json 다운로드]
⚠️
보안 주의: 이 파일을 GitHub 등 공개 저장소에 올리지 마세요. API 키가 노출됩니다.

다운로드 완료 후 화면에서 [다음] → [다음] → [콘솔로 이동]을 클릭해 대시보드로 돌아갑니다.

STEP 4. 서비스 계정 키 발급 (서버에서 푸시 발송용)

앞에서 받은 google-services.json은 앱(수신)용입니다. 서버에서 푸시를 발송하려면 별도의 서버 인증 키가 필요합니다. 이것을 서비스 계정 키라고 합니다.

① 메뉴 경로 (방법 A — Firebase Console에서 직접)

Firebase Console 좌측 상단  ⚙️ (톱니바퀴 아이콘)  → 프로젝트 설정  → 상단 탭에서 서비스 계정  → 화면 하단 [새 비공개 키 생성] 버튼 클릭

① 메뉴 경로 (방법 B — Google Cloud Console에서)

Firebase Console의 서비스 계정 탭이 Google Cloud Console로 연결되는 경우 아래와 같이 진행합니다.

Google Cloud Console (console.cloud.google.com)  → 왼쪽 메뉴 IAM 및 관리자  → 서비스 계정
서비스 계정 목록
▲ Google Cloud Console → IAM 및 관리자 → 서비스 계정 — Firebase Admin SDK 서비스 계정이 목록에 보입니다

② 키 발급 방법

단계화면에 보이는 것해야 할 것
1 목록에 firebase-adminsdk-…@프로젝트명.iam.gserviceaccount.com 항목 해당 행의 맨 오른쪽 ⋮ (점 세 개) 버튼 클릭
2 드롭다운 메뉴 [키 관리] 클릭
3 키 관리 화면 [키 추가][새 키 만들기] 클릭
4 키 유형 선택 팝업 JSON 선택 후 [만들기] 클릭 → JSON 파일 자동 다운로드
💡
위 스크린샷에서 서비스 계정 정보 확인:
· 이메일: firebase-adminsdk-fbsvc@handson-4053d.iam.gserviceaccount.com
· 이름: firebase-adminsdk
· 키 ID: 기존 키가 있으면 표시됨 (키 재발급 시 이전 키 ID는 자동으로 무효화됨)
여러분의 프로젝트에서는 프로젝트 ID 부분(handson-4053d)만 다르고 형식은 동일합니다.

③ 다운로드된 파일 내용 확인

파일 이름이 길고 복잡하게 생성됩니다(예: handson-production-firebase-adminsdk-xxxxx-xxxxxxxxxx.json). 파일 이름을 firebase-key.json으로 바꾸세요.

메모장으로 열면 아래와 같이 생겼습니다. 서버에서 이 파일을 읽어 Firebase에 접속합니다.

{
  "type": "service_account",
  "project_id": "handson-production",          ← 프로젝트 ID
  "private_key_id": "abc123...",               ← 키 식별자
  "private_key": "-----BEGIN RSA PRIVATE KEY-----\n...",  ← 인증 키 (절대 외부 노출 금지)
  "client_email": "firebase-adminsdk-xxxxx@handson-production.iam.gserviceaccount.com",
  "client_id": "123456789012345678901",
  "auth_uri": "https://accounts.google.com/o/oauth2/auth",
  "token_uri": "https://oauth2.googleapis.com/token"
}
📌
이 파일을 서버 운영팀에 전달합니다.
서버 담당자는 이 파일을 서버의 보안 경로에 저장하고, 다음 STEP 5에서 설정 파일에 경로를 등록합니다.
⚠️
서비스 계정 키 보안 주의 (매우 중요)
이 파일의 private_key 값이 있으면 누구든 여러분의 Firebase 프로젝트로 푸시를 발송할 수 있습니다.
· GitHub, 이메일 첨부 등 외부 공개 경로로 전달하지 마세요.
· 분실 시에는 즉시 Firebase Console에서 해당 키를 삭제하고 새로 발급하세요.
· 새 키 발급 후 서버에 파일을 교체하고 서버를 재시작해야 합니다.

STEP 5. 서버에 서비스 계정 키 배치 (서버 운영팀 담당)

서버 담당자가 수행합니다. 두 가지만 하면 됩니다: ① 파일 저장, ② 경로 설정.

① firebase-key.json 파일 서버에 저장

전달받은 firebase-key.json을 서버의 아무 폴더에나 저장합니다. 단, 웹 브라우저에서 접근 가능한 폴더(웹 루트) 안에는 넣지 마세요.

서버 OS권장 저장 경로 (예시)
Linux/opt/handson/config/firebase-key.json
WindowsC:\handson\config\firebase-key.json
💡
경로는 자유롭게 정해도 됩니다. 중요한 건 경로를 기억해 두는 것입니다. 다음 단계에서 그 경로를 설정 파일에 입력합니다.

② globals.properties 파일에 경로 등록

globals.properties는 HandsOn 서버 설정 파일입니다. 아래 경로에 있습니다:

HandsOn (웹 프로젝트 소스)/
└── src/main/resources/
    └── egovframework/egovProps/
        └── globals.properties ← 이 파일을 열어 수정

이 파일을 텍스트 편집기(메모장, Notepad++ 등)로 열고, 아래 항목을 찾아서 경로를 수정합니다:

# ① 파일에서 아래 항목을 찾습니다
firebase.service.account.path=

# ② 저장한 경로를 입력합니다 (Linux 예시)
firebase.service.account.path=/opt/handson/config/firebase-key.json

# ② 저장한 경로를 입력합니다 (Windows 예시, 역슬래시는 / 또는 \\ 사용)
firebase.service.account.path=C:/handson/config/firebase-key.json
⚠️
경로 입력 시 주의사항
· 경로 앞뒤에 공백이 없어야 합니다.
· Windows 경로는 역슬래시(\) 대신 슬래시(/)로 입력하거나, 역슬래시를 두 번(\\) 쓰세요.
· 파일 이름 오타에 주의하세요 (firebase-key.json).

③ 서버 재시작 후 정상 동작 확인

확인 방법정상 시오류 시
서버 로그(콘솔) 확인 Firebase 관련 오류 없이 서버 기동 완료 FileNotFoundException → 경로 오타 확인
IOException → 파일 읽기 권한 확인
테스트 푸시 발송 API 호출 앱에 알림 도달 로그에 FirebaseMessagingException → 서비스 계정 키 재발급 필요
FcmService.java — 서버 시작 시 자동 초기화 (이미 구현됨, 수정 불필요)
@Service
public class FcmService {

    @Value("${firebase.service.account.path}")   // globals.properties의 값을 읽음
    private String serviceAccountPath;

    @PostConstruct
    public void init() throws IOException {
        // 서버 시작 시 firebase-key.json 파일을 읽어 Firebase에 연결
        FileInputStream serviceAccount = new FileInputStream(serviceAccountPath);
        FirebaseOptions options = FirebaseOptions.builder()
            .setCredentials(GoogleCredentials.fromStream(serviceAccount))
            .build();
        if (FirebaseApp.getApps().isEmpty()) {
            FirebaseApp.initializeApp(options);
        }
    }

    // 푸시 발송 메서드 (서버 로직에서 호출)
    public void sendPush(String pushToken, String title, String body, String targetUrl)
            throws FirebaseMessagingException {
        Message message = Message.builder()
            .setToken(pushToken)                     // 수신 기기의 FCM 토큰 (DB에서 조회)
            .setNotification(Notification.builder()
                .setTitle(title)                     // 알림 제목
                .setBody(body)                       // 알림 내용
                .build())
            .putData("targetUrl", targetUrl)         // 알림 탭 시 이동할 URL
            .build();
        FirebaseMessaging.getInstance().send(message);
    }
}
📦
pom.xml — Firebase Admin SDK 의존성
<dependency>
  <groupId>com.google.firebase</groupId>
  <artifactId>firebase-admin</artifactId>
  <version>9.2.0</version>
</dependency>

STEP 6. FCM 토큰 — 앱 ↔ 서버 연동

FCM 푸시는 각 기기별 고유 토큰으로 발송됩니다. 앱이 처음 설치되거나 재설치되면 새 토큰이 발급됩니다. 이 토큰을 서버 DB에 저장해야 해당 사용자에게 푸시를 보낼 수 있습니다.

흐름설명
① 앱 최초 실행 FCM이 이 기기만의 고유 토큰을 생성하여 PentaFirebaseMessagingService.onNewToken()에 전달
② 토큰 앱 내 저장 앱이 토큰을 SharedPreferences("PentaPrefs", "pushToken")에 보관
③ 로그인 시 서버 전송 로그인 성공 후 JS에서 PentaNative.getPushInfo()로 토큰 조회 → 서버 API 호출로 저장
④ 서버 DB 저장 TB_PENTA_USER 또는 별도 테이블에 userId + pushToken 매핑하여 저장
⑤ 푸시 발송 시 서버에서 수신 대상 userId로 pushToken을 조회 → FcmService.sendPush() 호출
로그인 성공 후 토큰 서버 등록 (login.jsp 또는 공통 JS)
// 로그인 성공 콜백에서 호출
function onLoginSuccess(userId) {
    PentaNative.getPushInfo()
        .then(function(info) {
            if (!info.pushToken) return;   // 웹 환경이면 토큰 없음
            // 서버에 토큰 등록/갱신
            return $.ajax({
                url: '/HandsOn/api/registerPushToken.do',
                type: 'POST',
                contentType: 'application/json',
                data: JSON.stringify({
                    userId:    userId,
                    pushToken: info.pushToken,
                    platform:  info.platform   // "android"
                })
            });
        })
        .catch(function() { /* 토큰 등록 실패는 무시 */ });
}

STEP 7. 앱 빌드 & 배포 (앱 개발팀 담당)

앱 개발팀은 전달받은 google-services.json을 아래 위치에 넣고 앱을 빌드합니다.

HandsOnApp/
├── app/
│ ├── google-services.json이 위치에 파일 배치
│ ├── build.gradle.kts
│ └── src/
│ └── main/
│ └── java/.../PentaFirebaseMessagingService.kt

빌드 후 Google Play 또는 사내 MDM을 통해 앱을 배포합니다.

최종 확인 체크리스트

확인 항목담당완료
Firebase Console에서 프로젝트 생성 완료고객(담당자)
Android 앱 패키지명(kr.co.penta.pentawebapp) 등록 완료고객(담당자)
google-services.json 다운로드 → 앱 개발팀 전달 완료고객(담당자)
서비스 계정 키(firebase-key.json) 발급 → 서버 운영팀 전달 완료고객(담당자)
google-services.jsonapp/ 폴더에 배치 후 앱 빌드 완료앱 개발팀
firebase-key.json을 서버 보안 경로에 배치 완료서버 운영팀
globals.propertiesfirebase.service.account.path 경로 등록 완료서버 운영팀
서버 재시작 후 FcmService 초기화 오류 없음 확인서버 운영팀
앱 로그인 후 pushToken이 DB에 저장되는지 확인개발팀
테스트 푸시 발송 후 기기 알림 수신 확인개발팀

자주 묻는 문제 (FAQ)

증상원인해결 방법
앱 빌드 시 "google-services.json not found" 오류 파일 위치 잘못됨 app/google-services.json 위치 확인 (프로젝트 루트가 아닌 app/ 폴더)
서버 시작 시 "FileNotFoundException" 오류 firebase-key.json 경로 오류 globals.properties의 경로와 실제 파일 위치 일치 여부 확인
푸시가 일부 기기에만 도달 DB에 저장된 토큰이 만료됨 로그인 시마다 토큰을 재등록하도록 서버 로직 확인
앱을 재설치 후 푸시 미수신 재설치 시 토큰 새로 발급됨 재로그인 시 신규 토큰이 DB에 업데이트되는지 확인
앱이 백그라운드일 때만 알림 표시됨 포그라운드 메시지 처리 누락 onMessageReceived()에서 직접 알림 빌드 (이미 구현됨)
Firebase Console에서 "패키지명 불일치" 오류 앱의 applicationId와 등록 패키지명 다름 Firebase Console 등록 시 kr.co.penta.pentawebapp 정확히 입력 확인

⑦ 메시지 자동 동기화 이벤트 (앱 포그라운드)

앱이 화면에 표시되어 있는 동안(onResume) 15초 간격으로 서버에서 미확인 메시지를 폴링합니다. 새 메시지가 감지되면 로컬 알림을 표시하고 아래 JS 이벤트를 발송합니다.

// JSP에서 메시지 동기화 이벤트 구독
// pentaMessageSync : 새 메시지 도착 시 발생
window.addEventListener('pentaMessageSync', function(e) {
    var msg = e.detail;
    // msg.counterpartName : 발신자명
    // msg.msgContent      : 메시지 내용
    // msg.roomId          : 채팅방 ID
    // msg.unreadCount     : 미읽음 수
    showNewMessageToast(msg.counterpartName + ': ' + msg.msgContent);
});

// pentaMessageCount : 15초마다 총 미읽음 수 업데이트
window.addEventListener('pentaMessageCount', function(e) {
    var count = e.detail.unreadCount;
    var badge = document.getElementById('msgBadge');
    if (badge) badge.textContent = count > 0 ? count : '';
});

13-9. 아이디 저장 & 자동 로그인 API

💡
앱 환경에서는 EncryptedSharedPreferences(AES256-GCM)에 암호화 저장됩니다.
웹 브라우저 환경에서는 localStorage를 fallback으로 사용합니다.
비밀번호는 추가로 Base64+XOR 인코딩을 거쳐 저장됩니다.

① 아이디 저장 (로그인 화면)

// 로그인 화면 초기화: 저장된 아이디 불러오기
PentaNative.getSavedUserId()
    .then(function(res) {
        // res.userId       : 저장된 아이디 (없으면 "")
        // res.saveIdChecked: 아이디 저장 체크 여부 (boolean)
        if (res.saveIdChecked && res.userId) {
            document.getElementById('userId').value    = res.userId;
            document.getElementById('chkSaveId').checked = true;
        }
    });

// 로그인 버튼 클릭 시: 체크박스 상태에 따라 저장/삭제
function doLogin() {
    var userId = document.getElementById('userId').value;
    var saveId = document.getElementById('chkSaveId').checked;
    // 아이디 저장 상태 업데이트 (saveId=false이면 저장된 아이디 삭제)
    PentaNative.saveSavedUserId(userId, saveId);
    // ... 실제 로그인 서버 요청
}

② 자동 로그인 (로그인 화면)

// 앱 시작 시 자동 로그인 시도
$(document).ready(function() {
    PentaNative.isAutoLoginEnabled()
        .then(function(res) {
            if (!res.enabled) return;
            return PentaNative.getAutoLoginInfo();
        })
        .then(function(info) {
            if (!info || !info.userId) return;
            // 저장된 정보로 자동 로그인 실행
            document.getElementById('userId').value   = info.userId;
            document.getElementById('password').value = info.password;
            document.getElementById('chkAutoLogin').checked = true;
            $('#loginForm').submit();
        });
});

// 로그인 성공 후: 자동 로그인 체크 시 정보 저장
function onLoginSuccess(userId, password) {
    var autoLogin = document.getElementById('chkAutoLogin').checked;
    if (autoLogin) {
        PentaNative.saveAutoLoginInfo(userId, password);
    } else {
        PentaNative.clearAutoLoginInfo();
    }
}

// 로그아웃 시: 자동 로그인 정보 삭제
function onLogout() {
    PentaNative.clearAutoLoginInfo();
}

13-10. 디버깅 체크리스트

증상원인해결
PentaNative is not defined penta-native.js 로드 안 됨 common-styles.jsp include 여부 확인
Android에서 사진 촬영 응답 없음 CAMERA 권한 미허용 또는 FileProvider 설정 오류 앱 설정 → 권한 → 카메라 허용; AndroidManifest FileProvider 확인
GPS 권한 오류 ACCESS_FINE_LOCATION 미허용 또는 HTTPS 미적용 반드시 HTTPS; 첫 실행 시 위치 권한 허용 필요
FCM 푸시 수신 안 됨 google-services.json 없음 또는 패키지명 불일치 google-services.json이 app/ 폴더에 있는지, 패키지명이 Firebase Console과 일치하는지 확인
getPushInfo()에서 pushToken이 빈 값 FCM 초기화 전 호출 또는 google-services.json 없음 로그인 후 2~3초 후 호출; Firebase Console에서 앱 등록 확인
자동 로그인 정보 저장 안 됨 EncryptedSharedPreferences 초기화 실패 Logcat에서 "EncryptedSharedPreferences 초기화 실패" 확인; 앱 데이터 초기화 후 재시도
바코드 스캔 후 result.cancelled=true 사용자가 뒤로가기로 취소 정상 동작; cancelled 체크 후 재시도 안내

13-11. penta-native.js 파일 위치 및 포함 방법

HandsOn/src/main/webapp/js/
└── penta-native.js ← 디바이스 API 통합 파일
<!-- common-styles.jsp에 이미 포함되어 있음 -->
<!-- 업무 JSP에서는 별도 include 불필요 -->
<jsp:include page="/WEB-INF/jsp/common/common-styles.jsp"/>

13-12. penta-native.js — 내부 구조 요약

① /js/penta-native.js (참고용)

/**
 * PentaMobile 디바이스 API 유틸리티
 * 모든 업무 JSP의 <head>에 포함:
 *   <script src="/js/penta-device.js"></script>
 */
(function (global) {
    'use strict';

    var ua = navigator.userAgent.toLowerCase();

    var PentaDevice = {
        isAndroid : /android/.test(ua),
        isIOS     : /iphone|ipad|ipod/.test(ua),

        // Android Bridge 사용 가능 여부
        get hasAndroidBridge() {
            return typeof global.pentaApp !== 'undefined';
        },
        // iOS Bridge 사용 가능 여부
        get hasIOSBridge() {
            return !!(global.webkit
                   && global.webkit.messageHandlers);
        },
        // 앱 환경 여부 (Android or iOS 앱 내 WebView)
        get isNativeApp() {
            return this.hasAndroidBridge || this.hasIOSBridge;
        }
    };

    // ── 카메라 ────────────────────────────────────────────
    PentaDevice.takePicture = function (callbackFn) {
        if (this.hasAndroidBridge) {
            global.pentaApp.takePicture(callbackFn);
        } else if (this.hasIOSBridge
                   && global.webkit.messageHandlers.takePicture) {
            global.webkit.messageHandlers.takePicture
                  .postMessage({ callback: callbackFn });
        } else {
            _fileFallback('image/*', 'environment', callbackFn);
        }
    };

    // ── 바코드 ────────────────────────────────────────────
    PentaDevice.scanBarcode = function (callbackFn) {
        if (this.hasAndroidBridge) {
            global.pentaApp.scanBarcode(callbackFn);
        } else if (this.hasIOSBridge
                   && global.webkit.messageHandlers.scanBarcode) {
            global.webkit.messageHandlers.scanBarcode
                  .postMessage({ callback: callbackFn });
        } else {
            Swal.fire({
                title: '바코드 직접 입력', input: 'text',
                inputPlaceholder: '바코드 값 입력',
                showCancelButton: true
            }).then(function (r) {
                if (r.isConfirmed && r.value) global[callbackFn](r.value);
            });
        }
    };

    // ── GPS ───────────────────────────────────────────────
    PentaDevice.getLocation = function (callbackFn, opts) {
        opts = opts || { enableHighAccuracy: true, timeout: 12000 };
        if (this.hasAndroidBridge
            && typeof global.pentaApp.getLocation === 'function') {
            global.pentaApp.getLocation(callbackFn);
        } else if (this.hasIOSBridge
                   && global.webkit.messageHandlers.getLocation) {
            global.webkit.messageHandlers.getLocation
                  .postMessage({ callback: callbackFn });
        } else if (navigator.geolocation) {
            navigator.geolocation.getCurrentPosition(
                function (p) { global[callbackFn](p.coords.latitude, p.coords.longitude); },
                function (e) { _gpsError(e); },
                opts
            );
        } else {
            Swal.fire({ icon: 'warning', text: 'GPS 기능을 사용할 수 없습니다.' });
        }
    };

    // ── Private helpers ───────────────────────────────────
    function _fileFallback(accept, capture, cbFn) {
        var inp   = document.createElement('input');
        inp.type    = 'file';
        inp.accept  = accept;
        inp.capture = capture;
        inp.onchange = function () {
            if (!this.files[0]) return;
            var r = new FileReader();
            r.onload = function (e) { global[cbFn](e.target.result); };
            r.readAsDataURL(this.files[0]);
        };
        inp.click();
    }

    function _gpsError(err) {
        var msg = { 1:'위치 권한 거부', 2:'위치 취득 불가', 3:'타임아웃' }[err.code]
                || '알 수 없는 GPS 오류 (' + err.code + ')';
        Swal.fire({ icon: 'error', title: 'GPS 오류', text: msg });
    }

    global.PentaDevice = PentaDevice;
})(window);

② 사용 예 요약표

기능JSP 호출콜백 형식비고
사진 촬영 PentaDevice.takePicture('onPhotoTaken') function onPhotoTaken(base64DataUrl) Base64 DataURL 반환
바코드 스캔 PentaDevice.scanBarcode('onBarcodeScanned') function onBarcodeScanned(value) 바코드 문자열 반환
위치 정보 PentaDevice.getLocation('onGPSReceived') function onGPSReceived(lat, lng) 위도/경도 숫자 반환

③ 디버깅 체크리스트

증상원인해결
Android에서 pentaApp is not defined addJavascriptInterface 미등록 또는 타이밍 이슈 WebView가 완전히 로드된 후 호출; onPageFinished 이후 검증
iOS Bridge 무응답 messageHandlers 핸들러명 오타 Swift와 JS의 핸들러명 정확히 일치 확인 (대소문자 구분)
GPS 권한 없음 오류 HTTPS 미적용 or 권한 미수락 반드시 HTTPS; 첫 호출 시 사용자 권한 허용 필요
Base64 이미지 너무 큼 원본 해상도가 높을 때 Canvas를 이용해 리사이즈 후 전송 (1280px 이하 권장)
바코드 스캔 후 콜백 미실행 콜백 함수명이 전역 스코프에 없음 콜백 함수를 window.xxx = function(){}로 전역 등록 확인
Leaflet 지도 미표시 leaflet.css / leaflet.js 미로드 또는 #mapContainer 높이 0 <head>에 Leaflet CSS·JS 태그 추가 및 mapContainer에 height 스타일 지정

④ Base64 이미지 리사이즈 (Canvas 활용)

/**
 * Base64 DataURL 이미지를 지정 최대 폭으로 리사이즈
 * @param {string}   dataUrl  - 원본 Base64 DataURL
 * @param {number}   maxWidth - 최대 폭(px), 기본 1280
 * @param {Function} callback - callback(resizedDataUrl)
 */
function resizeBase64Image(dataUrl, maxWidth, callback) {
    maxWidth = maxWidth || 1280;
    var img  = new Image();
    img.onload = function () {
        var ratio  = Math.min(1, maxWidth / img.width);
        var canvas = document.createElement('canvas');
        canvas.width  = img.width  * ratio;
        canvas.height = img.height * ratio;
        canvas.getContext('2d').drawImage(img, 0, 0, canvas.width, canvas.height);
        callback(canvas.toDataURL('image/jpeg', 0.8)); // 80% 품질
    };
    img.src = dataUrl;
}

// 사용 예: 촬영 후 자동 리사이즈
function onPhotoTaken(rawBase64) {
    resizeBase64Image(rawBase64, 1280, function(resized) {
        document.getElementById('photoBase64').value = resized;
        document.getElementById('photoPreview').src  = resized;
        document.getElementById('photoPreview').style.display = 'block';
    });
}

15 화면별 기능 설명

PentaMobile 앱의 각 화면 구성과 주요 기능을 스크린샷과 함께 설명합니다. 모든 화면은 모바일 세로 레이아웃 기준이며, 하단 내비게이션(홈·업무·메시지·설정)으로 이동합니다.

📌 공통 UI 규칙
• 상단 헤더: 햄버거 메뉴(좌), 앱 로고, 사용자 아바타(우)
• 조회 조건 바: 탭하면 펼쳐지고, 포커스를 잃으면 자동으로 닫히며 자동 조회
• 통계 바: 상태별 건수를 한눈에 표시
• 상세 보기: 위→아래 슬라이드 패널로 표시 (화면 이탈 없음)
• 알림: Swal.fire() 팝업 사용 (브라우저 기본 alert 사용 안 함)

14-1. 홈(대시보드)

화면설명
홈화면 상단 상단 영역

공지사항 섹션 — 최신 공지 5건을 카드 형태로 표시. 구분(중요/이벤트/일반) 뱃지와 NEW 표시 포함.

요약 통계 카드 (3개 가로 배치)
 • 미읽음 메시지 수
 • 재고 경고 건수
 • 결재 대기 건수

월별 매출·이익 콤보 차트 — 막대(매출)+선(이익) 복합 차트, 연간 추이 한눈 파악.
홈화면 하단 하단 영역 (스크롤)

제품군 비중 파이 차트 — 전자의료/일반의약/생활용품/건강기능/사무소모 5개 제품군 구성 비율.

최근 접속 이력 — 최근 방문한 프로그램 목록(아이콘+프로그램명+일시) 표시. 빠른 재접속 지원.
홈화면 메뉴 좌측 슬라이드 메뉴

⑥ 헤더 햄버거(☰) 버튼 클릭 시 DB에서 권한 기반으로 로드된 프로그램 트리 표시.

카테고리 구성:
 • 자체 기능 구현 (공지사항, 조직도, 자료형·표·차트형·승인처리·자원관리·입력유형 업무)
 • 외부 API 적용 (바코드·재고조사·제품출고·사진형·작업처리·가동비가동·지도형·현장방문 업무)
 • AI 관련 (Gemini AI 챗봇, On-premise AI 챗봇)

14-2. 업무화면

화면설명
업무화면 목록 업무 프로그램 목록

하단 내비게이션 [업무] 탭 선택 시 표시. TB_PENTA_PROGRAM 테이블에서 사용자 권한(ROLE)에 따라 허용된 메뉴만 동적 로드.

그룹 카테고리:
 • 자체 기능 구현: 공지사항, 조직도 업무, 자료형/표/차트형/승인처리/자원관리/입력유형 업무 (Sample 1~6)
 • 외부 API 적용: 바코드/재고조사/제품출고/사진형/작업처리/가동비가동/지도형/현장방문 업무 (Sample 7~14)
 • AI 관련: Gemini AI 챗봇, On-premise AI 챗봇

각 항목은 아이콘·명칭·설명(Sample 번호)을 표시하며, 탭하면 해당 업무 화면으로 이동.

14-3. 메시지

화면설명
메시지 목록 메시지 목록

수신한 메시지를 발신자별 최신 메시지 카드로 표시. 카드에 이름·직급·소속·미리보기 내용·수신 시각 포함.
우하단 [+] 버튼으로 새 대화 시작.
메시지 대화 1:1 채팅 화면

카드 탭 시 채팅 UI 전환. 내 메시지(우측 파란 말풍선) / 상대방 메시지(좌측) 구분. 하단 입력창 + 전송 버튼.
파일 첨부 버튼([+])으로 이미지/파일 첨부 가능. 읽음 여부 표시(숫자 1 = 미읽음).
대화상대 선택 대화 상대 선택

[+] 탭 시 조직도 트리와 전체 사용자 목록 표시. 조직 트리를 접기/펼치기하여 부서 단위 탐색. 체크박스로 다중 선택 후 [대화 시작].

14-4. 설정

화면설명
설정 화면 설정 항목

계정 섹션
 • 비밀번호 변경
 • 세션 타임아웃 (30분/60분/120분/무제한 선택)
 • 자동 로그인 ON/OFF 토글

앱 섹션
 • 초기화면 설정 (홈/업무/메시지/설정)
 • 글자 크기 (작게/보통/크게)
 • 다크모드 ON/OFF 토글
 • 캐시 초기화

정보 섹션
 • 앱 버전 표시
 • 이용약관 링크

모든 설정값은 TB_PENTA_USER 테이블에 사용자별로 저장.

14-5. 공지사항

화면설명
공지사항 목록 목록 화면

TB_PENTA_BOARD (BOARD_TYPE='NOTICE') 데이터를 조회. 컬럼: No·구분·제목·등록일·조회수.
 • 📌 고정 게시글은 목록 최상단 고정
 • NEW 뱃지: 7일 이내 등록 항목에 자동 표시
 • 구분 뱃지: 중요(빨강)/이벤트(초록)/일반(회색)
 • 컬럼 헤더 클릭 시 정렬 (▲▼)
 • 조회 조건 바: 등록일자 범위 필터
 • 우하단 [+]: 게시물 작성 (권한 있는 경우)
공지사항 작성 게시물 작성

슬라이드 패널로 표시. 입력 항목: 유형(일반/중요/이벤트), 제목, 내용, 게시 시작일/종료일, 상단 고정 여부, 파일 첨부.
저장 시 TB_PENTA_BOARD에 BOARD_TYPE+BOARD_DATE+BOARD_SEQ 복합 PK로 등록.
공지사항 상세 상세 보기

슬라이드 패널로 전환. 제목·구분·등록자·등록일·조회수 표시 후 본문 표시.
하단에 첨부파일 목록과 댓글 섹션. 댓글은 TB_PENTA_BOARD_CMT 테이블. 수정/삭제 아이콘(권한 있는 경우 표시).

14-6. 조직도 업무

화면설명
조직도 조직도 트리 + 구성원 카드

TB_PENTA_ORGAN 테이블을 CONNECT BY PRIOR로 계층 조회. 트리 노드 탭으로 접기/펼치기. 우측에 소속 인원 수 표시.

하단 패널에 선택 조직의 구성원 카드(이름·직급·소속·연락처) 스크롤 리스트. 연락처 탭 시 전화 앱 연동.

14-7. 자료형 업무 (신용카드 결제내역 / Sample 1)

화면설명
자료형 목록 결제 내역 목록

TB_PENTA_BUSINESS_SAMPLE_01 조회. 날짜별 그룹 구분선, 가맹점명·결제 방식·할인 여부·금액·시각 표시.
상단 통계 바: 전체·승인·취소 건수 / 총 결제액·할인액.
조회 조건: 기간(기본 최근 15일). 조건 바를 탭하면 날짜 범위 수정 가능.
자료형 상세1 결제 상세 (슬라이드 패널)

항목 탭 시 위→아래 슬라이드 패널. 가맹점명·업종, 최종금액·상태(승인/취소) 상단 강조 표시.
세부: 결제일시, 카드명·번호(마스킹), 승인번호, 결제유형, 할부, 결제금액·할인·최종금액.
자료형 상세2 비고 편집 및 저장

슬라이드 패널 하단에 비고(메모) 입력 영역. 내용 수정 후 [저장] 버튼으로 UPD_USER·UPD_DATE 업데이트.

14-8. 표 업무 (재고현황 / Sample 2)

화면설명
표 업무 목록 재고현황 테이블

TB_PENTA_BUSINESS_SAMPLE_02 조회. 컬럼: 품목코드▲▼·품목명▲▼·상태▲▼·재고▲▼·분류▲▼·단가▲▼·재고금액▲▼.
 • 현재 정렬 컬럼에 화살표 아이콘 강조 표시
 • 상태 뱃지: ✅ 정상(초록) / ⚠ 부족(주황) / ▲ 과잉(파랑) / ❌ 소진(빨강)
 • 상단 통계: 전체·정상·부족·과잉·소진 건수 / 총 재고금액
 • 더보기 버튼으로 페이징 없이 추가 로드
표 업무 검색 검색 조건 패널 (펼침)

조회 조건 바를 탭하면 펼쳐짐: 품목명(텍스트), 분류(드롭다운), 창고(드롭다운), 재고상태(드롭다운). 조건 변경 후 자동 조회.
표 업무 상세 재고 상세 (슬라이드 패널)

행 탭 시 슬라이드 패널. 품목명·상태 아이콘 상단, 재고 수량 프로그레스 바(최소~최대 범위). 품목코드·분류·창고·단가·재고금액·최근 입출고일·등록일. 비고 편집 후 [저장].

14-9. 차트형 업무 (영업 차트 / Sample 3)

화면설명
차트 Line/Bar Line · Bar 차트

연도 선택(드롭다운)으로 전체 차트 일괄 갱신.
 • 매출 추이 Line: 월별 실적 vs 목표 비교. 최근 매출·증가율 KPI 카드 표시.
 • 월별 매출 Bar: 지점별 월간 매출 비교.
차트 Doughnut/Radar Doughnut · Radar 차트

 • 채널 비중 Doughnut: 직판/대리점/온라인/공공입찰 비중.
 • 영업 역량 Radar: 팀별 제품지식·리드·전환·유지·협상 역량 방사형 비교.
차트 PolarArea/Bubble PolarArea · Bubble 차트

 • 지역 점유율 PolarArea: 서울/경기/충청/영남/호남 등 권역별 시장 점유율.
 • 고객 거래 규모 Bubble: X=구매액, Y=방문수, 버블 크기=충성도.

이 외에도 Pie·Scatter·Stacked Bar·Horizontal Bar·Area Line·Combo·Gauge 등 총 13종 차트 구성.

14-10. 승인처리 업무 (전자결재 / Sample 4)

화면설명
승인처리 목록 결재 목록

날짜별 그룹 구분선. 각 항목에 제목·문서유형·결재상태 뱃지·작성자·결재 단계(n/m) 표시.
상태 뱃지: 작성중(회색)·결재중(주황)·결재완료(초록)·반려(빨강).
상단 통계: 전체·상신·결재·완료·반려 건수.
승인처리 상세 결재 상세 (슬라이드 패널)

결재선을 카드 흐름으로 시각화 (사람 → 화살표 → 사람). 각 카드에 이름·직급·결재상태 표시.
결재의견 섹션에 각 결재자의 의견 표시. 문서유형·제목·내용 본문 및 첨부파일 확인 가능.
결재 요청 등록 결재 요청 등록

[+] 탭 시 등록 패널. 결재선·합의선 구성(결재자 선택 팝업), 문서유형 드롭다운, 제목·내용·비고 입력, 첨부파일.
하단 버튼: [취소] / [저장(임시)] / [상신(결재 요청 발송)].
결재자 선택 결재자 선택 팝업

조직도 트리 탐색 + 사용자 체크박스 다중 선택. 상신자 본인은 회색 비활성 표시. [대상자 선택] 확인 후 결재선에 추가.

14-11. 자원관리 업무 (회의실·차량 예약 / Sample 5)

화면설명
자원관리 목록 자원 목록 + 예약 현황

자원 목록(회의실·차량 등)과 오늘의 예약 내역을 함께 표시. 자원별 현재 예약 건수·예약 가능 여부 표시. 우하단 [+]로 신규 예약.
자원 예약 등록 예약 등록 패널

예약일자(날짜 피커), 자원 선택(드롭다운), 사용 시작~종료 시간(시간 피커), 사용 목적 입력. 중복 예약 시 서버 검증 후 오류 안내.

14-12. 입력유형 업무 (현장 접수 / Sample 6)

화면설명
입력유형 목록 접수 목록

현장 접수 건 카드 리스트. 각 카드: 접수명·상태(진행중/완료)·처리유형(장애처리/설치지원 등)·방문구분(원격/방문/혼합) 뱃지, 수량·긴급도·중요도, 방문일·시간, 요약 내용.
입력유형 상세 입력 폼 (다양한 입력 타입)

 • 날짜 피커 / 시간 피커
 • 처리 유형 드롭다운 (공통코드)
 • 방문 구분 버튼 그룹 (원격/방문/혼합)
 • 체크박스 (개인정보 동의, 야간 작업)
 • 별점 (고객 중요도 1~5)
 • 정산 대상월 (월 피커)
 • 처리 예정 주차 (주 피커)
 • 필요 자재 체크박스
 • QR/바코드 스캔 입력
 • 우편번호 + 주소 자동 검색

14-13. 바코드 업무 (바코드 스캔 이력 / Sample 7)

화면설명
바코드 업무 스캔 이력 테이블

TB_PENTA_BUSINESS_SAMPLE_06에서 스캔 이력 조회. 컬럼: No·스캔일시·바코드구분(EAN13/QR 등)·바코드값·방식(단건/연속).
우하단 버튼:
 • 📷 카메라 버튼: 카메라 바코드 스캔 실행 (PentaDevice.scanBarcode)
 • 🔄 새로고침 버튼: 목록 재조회
스캔 성공 시 자동으로 서버 저장 후 목록 갱신.

14-14. 재고조사 업무 (Sample 8)

화면설명
재고조사 재고조사 목록 + 스캔 입력

상단 테이블: 조사일·제품정보·수량·전체재고 목록. 컬럼 정렬 가능.

화면 하단 고정 패널: 바코드 스캔 결과 자동 입력. 바코드·조사일자·조사시간·제품명·단위·제조사·수량·비고 입력 후 [저장] 또는 [수정].
우하단 📷 바코드 스캔 버튼.

14-15. 제품출고 업무 (Sample 9)

화면설명
제품출고 출고 이력 + 바코드 처리

날짜별 그룹 출고 카드: 제품명·바코드·도착지·출고 담당자·현재 재고수량 표시.
상단 통계: 출고건수·출고수량·도착지수·미확정 건수.
우하단 📷 버튼: 바코드 스캔으로 즉시 출고 처리. 스캔 후 도착지·수량 입력 패널 표시.

14-16. 사진형 업무 (Sample 10)

화면설명
사진형 업무 사진 갤러리

날짜별 그룹 + 썸네일 그리드(3열) 표시. 각 사진에 촬영 시각 오버레이.
사진 탭 시 상세 보기(전체화면 확대). 우하단 📷 버튼: 카메라 촬영(PentaDevice.takePicture) → Base64 → 서버 업로드(TB_PENTA_BUSINESS_SAMPLE_07_FILE).
촬영 후 자동으로 갤러리 목록 갱신.

14-17. 작업처리 업무 (Sample 11)

화면설명
작업처리 목록 거래처별 작업 목록

거래처 그룹(BIZ-XXXX·담당자)별로 접기/펼치기. 각 요청 항목: 제목·처리상태(처리중/처리완료/처리보완) 뱃지·사진 수·첨부 수·요약 내용·시각.
작업처리 상세 작업 상세 (슬라이드 패널)

요청내용·처리내용·비고 입력, 작업사진 촬영(PentaDevice.takePicture) 추가, 파일 첨부.
처리 위치 지도: Leaflet(OpenStreetMap) 지도에 GPS 좌표 마커 표시, 위도·경도·정확도 표기.
하단 버튼: [닫기] / [삭제] / [저장].

14-18. 가동/비가동 업무 (설비 관리 / Sample 12)

화면설명
가동비가동 목록 설비별 가동/비가동 이력

설비(포장기 1호기/2호기, 냉각기 등) 그룹 카드. 각 설비 우측에 가동·비가동 건수 뱃지.
이력 항목: 시간대(HH:MM ~ HH:MM)·상태(▶ 가동 / ⏸ 비가동)·사진 수·사유 내용.
상단 통계: 설비수·가동·비가동·가동율(%).
우하단 [+]: 가동/비가동 기록 추가(사진 첨부 포함).

14-19. 지도형 업무 (GPS 위치 / Sample 13)

화면설명
지도형 업무 GPS 위치 지도

Leaflet(OpenStreetMap) 지도에 현재 위치 마커 표시. PentaDevice.getLocation() → 위도·경도·정확도 취득.
하단 위치 정보 패널: 위도·경도·정확도·최종 업데이트 일시.
[현재 위치] 버튼: 위치 재측정 후 지도 중심 이동.
[새로고침] 버튼: DB에 저장된 최신 위치 재조회.
위치 데이터는 TB_PENTA_BUSINESS_SAMPLE_08의 GPS_LAT·GPS_LNG·GPS_ADDR에 저장.

14-20. 현장방문 업무 (Sample 14)

화면설명
현장방문 목록 방문 이력 목록

날짜별 그룹 방문 카드: 방문명·방문 장소(📍 아이콘+주소)·방문 내용 요약·방문 시각.
상단 통계: 방문건수·오늘방문·사진첨부·평균 정확도(m).
현장방문 상세 방문 상세 (슬라이드 패널)

방문 장소·방문 내용·비고 입력. 하단 지도(Leaflet) 방문 GPS 좌표 마커. 등록일시 표시.
[+] 버튼: 신규 방문 등록 — GPS 자동 취득 후 주소 역지오코딩 → 입력 폼 자동 채움.

14-21. 설문조사 업무 (Sample 15)

화면설명
설문조사 투표 화면 설문 목록 & 투표

진행 중(🟢 진행중) 상태 뱃지 + 투표수 뱃지 표시.
설문 제목·기간·질문 설명이 카드 형태로 나열되며, 진행 중인 설문은 라디오버튼 선택지 + [투표하기] 버튼으로 바로 투표 가능.
여러 설문이 목록으로 표시되고 스크롤로 탐색.

URL: /business/sample15view.do?progId=00019
DB 테이블: TB_PENTA_BUSINESS_SAMPLE_12 (설문), TB_PENTA_BUSINESS_SAMPLE_12_OPT (선택지), TB_PENTA_BUSINESS_SAMPLE_12_VOTE (투표 결과)
설문조사 결과 차트 투표 결과

이미 투표한 설문은 🔴 참여완료 뱃지로 표시.
도넛형 파이 차트로 선택지별 득표수 시각화.
차트 아래 범례(항목명·득표수)와 바 형태의 퍼센트 표시줄로 상세 결과 확인 가능.
총 투표수는 차트 중앙에 숫자로 표시.

14-22. AI 챗봇

화면설명
Gemini AI

Gemini AI 대화
Gemini AI 챗봇 (Google)

Google Gemini API 연동 채팅 인터페이스. 초기 화면에 추천 질문 3개 표시.
질문 입력 → 서버(Spring Controller) → Gemini API 호출 → 스트리밍 응답 표시.
응답은 마크다운 렌더링(##·**·목록 등) 지원.

On-premise AI 챗봇 (Ollama)

내부망 Ollama 로컬 AI 모델(llama3 등) 연동. 외부 네트워크 불필요.
동일한 채팅 UI, 초기 추천 질문(오늘 날씨/업무 효율/코딩 예제 등) 제공.
API 엔드포인트: 내부 Ollama 서버 주소(globals.properties에 설정).
전체 화면 구성 요약
번호화면명주요 기능사용 디바이스 API
대시보드공지사항·통계·차트·최근접속-
업무프로그램 목록권한 기반 DB 메뉴 트리-
메시지1:1 채팅조직도 기반 사용자 선택·채팅-
설정개인 설정세션·자동로그인·UI 설정-
10공지사항CRUD·댓글·첨부파일-
11조직도계층 트리·구성원 카드-
12자료형(Sample1)신용카드 결제내역 조회·메모-
13표형(Sample2)재고현황 정렬·필터·상세-
14차트형(Sample3)영업 차트 13종-
15승인처리(Sample4)전자결재·결재선·메시지 자동발송-
16자원관리(Sample5)회의실·차량 예약-
17입력유형(Sample6)현장접수·다양한 입력 폼바코드 스캔
18바코드(Sample7)바코드 스캔 이력카메라(바코드)
19재고조사(Sample8)바코드 스캔 재고 수량 입력카메라(바코드)
20제품출고(Sample9)출고 처리 바코드 연동카메라(바코드)
21사진형(Sample10)사진 촬영·갤러리·BLOB 저장카메라(사진)
22작업처리(Sample11)작업처리·사진·GPS 지도카메라(사진)·GPS
23가동비가동(Sample12)설비 가동 이력·사진카메라(사진)
24지도형(Sample13)GPS 현위치 지도 표시GPS
25현장방문(Sample14)방문 기록·GPS 지도GPS
28설문조사(Sample15)설문 목록·투표·결과 파이 차트-
26Gemini AI 챗봇Google Gemini API 연동 채팅-
27On-premise AI 챗봇Ollama 로컬 AI 채팅-

16 샘플화면 신규 개발 가이드

🔄 파워빌더 개발자를 위한 개념 매핑

파워빌더(PowerBuilder)로 개발하던 분들이 이 프로젝트를 처음 접할 때 가장 어려운 점은 "하나의 DataWindow에서 하던 일을 왜 이렇게 많은 파일에 나눠서 하는가?"입니다. 아래 표를 먼저 이해하면 전체 구조가 보입니다.

파워빌더 개념이 프로젝트에서의 역할실제 파일
DataWindow (화면 레이아웃)화면 HTML 구성 · 입력폼 · 목록 표시sample##view.jsp
DataWindow SQL (Retrieve 쿼리)DB에서 데이터를 가져오는 SELECT 문BusinessSample##_SQL.xml
DataWindow SQL (Update/Insert)DB에 저장하는 INSERT/UPDATE/DELETE 문BusinessSample##_SQL.xml
Window Script (버튼 클릭 이벤트)URL 요청 처리 · 응답 반환BusinessController.java
Non-Visual User Object (비즈니스 로직)복잡한 업무 로직 처리BusinessServiceImpl.java
DataStore (DB 직접 조회 객체)실제 SQL을 실행하는 인터페이스BusinessDAO.java
DW Column/Variable데이터를 담는 그릇 (Java 클래스)BusinessSample##VO.java
MessageBox()사용자 알림 팝업JSP 내 Swal.fire()
dw_1.Retrieve()목록 데이터 AJAX 요청JSP 내 $.ajax({ url: '...List.do' })
dw_1.Update()저장 AJAX 요청JSP 내 $.ajax({ url: '...Save.do' })
응용프로그램 설정 (INI)DB접속정보·서버설정 파일globals.properties
💡
파일이 많은 이유 파워빌더는 DataWindow 하나에 SQL·화면·로직이 모두 들어 있습니다. 이 프로젝트는 그 역할을 화면(JSP) / 로직(Controller+Service) / SQL(XML) / 데이터구조(VO) 4개로 분리합니다. 분리하면 팀이 나눠서 개발할 수 있고, SQL만 바꿔도 화면 코드를 건드리지 않아도 됩니다.
🔁 요청 처리 흐름 (모든 화면 공통)

화면에서 버튼을 누르면 어떤 경로로 처리되는지 이해해야 합니다. 파워빌더의 dw_1.Retrieve()가 아래 전체 흐름을 한 번에 처리했다면, 여기서는 각 단계가 별도 파일로 나뉩니다.

📱 브라우저 (JSP)
AJAX 요청 (URL: *.do)
Controller.java
URL 매핑·세션 확인
ServiceImpl.java
업무 로직
DAO.java
SQL 실행
SQL XML
쿼리 정의
🗄️ PostgreSQL

결과는 역순으로 올라와 Controller에서 JSON으로 변환되어 JSP의 JavaScript로 전달됩니다. JavaScript가 받은 JSON을 화면에 그립니다.

📂 수정/생성해야 하는 파일 위치 (업무 패키지 기준)
HandsOn/src/main/java/egovframework/penta/business/
├── controller/
│ └── BusinessController.java기존 파일에 메서드 추가 (새 파일 만들지 않음)
├── service/
│ ├── BusinessService.java기존 파일에 메서드 선언 추가
│ └── impl/
│ └── BusinessServiceImpl.java기존 파일에 구현 추가
├── dao/
│ └── BusinessDAO.java기존 파일에 메서드 선언 추가
└── vo/
└── BusinessSample##VO.java새 파일 생성 (기존 VO 복사 후 수정)

HandsOn/src/main/resources/egovframework/sqlmap/business/
└── BusinessSample##_SQL.xml새 파일 생성 (기존 XML 복사 후 수정)

HandsOn/src/main/webapp/WEB-INF/jsp/business/
└── sample##view.jsp새 파일 생성 (기존 JSP 복사 후 수정)
⚠️
중요: 기존 4개 Java 파일(Controller·Service·ServiceImpl·DAO)은 절대 새로 만들지 않습니다. 이미 존재하는 파일의 끝 부분에 내용을 추가하는 방식입니다. 새로 만들면 Spring이 두 개의 같은 역할 Bean을 등록하려다 오류가 납니다.
🖥️ Eclipse에서 파일 여는 방법
  • Package Explorer에서 파일 찾기
    Eclipse 왼쪽 Package Explorer 탭 → src/main/javaegovframework.penta.business 패키지를 펼치면 파일이 보입니다. 더블클릭하면 편집기가 열립니다.
  • 빠른 파일 검색 (Ctrl+Shift+R)
    Ctrl+Shift+R을 누르고 파일명 일부(예: BusinessController)를 입력하면 즉시 찾을 수 있습니다.
  • 파일 끝으로 이동
    파일을 열고 Ctrl+End를 누르면 파일의 맨 끝으로 이동합니다. Java 파일에 메서드를 추가할 때는 마지막 } 바로 앞에 추가합니다.
  • 저장 단축키
    Ctrl+S로 저장합니다. Eclipse는 저장 시 자동으로 Java 코드를 컴파일합니다. 빨간 줄이 생기면 문법 오류가 있는 것입니다.
  • 파일 복사 (JSP, XML, VO 신규 생성 시)
    Package Explorer에서 원본 파일 우클릭 → Copy → 대상 폴더 우클릭 → Paste → 새 이름 입력. 또는 Windows 탐색기에서 복사 후 Eclipse에서 F5(Refresh).
📌 모든 화면 공통 규칙
테이블 이름 규칙

반드시 TB_PENTA_로 시작
메인: TB_PENTA_SAMPLE##
부속: TB_PENTA_SAMPLE##_01
PK명: PK_TB_PENTA_SAMPLE##
인덱스: IDX_TB_PENTA_SAMPLE##_01

PK는 시퀀스 사용 금지

PK = 사용자ID + 등록일시 + 순번 조합
같은 사람이 같은 시각에 등록 → 순번이 다르므로 PK 중복 없음
순번 = MAX(순번)+1 서브쿼리로 자동 채번

공통 컬럼 (필수)

REG_USER - 등록자
REG_DATE DATE - 등록일시
UPD_USER - 수정자
UPD_DATE DATE - 수정일시

UI 규칙

팝업: alert() 금지 → Swal.fire() 사용
URL: 반드시 *.do 패턴
CSS: 화면별 전용 CSS 작성 (공통 파일에 넣지 않음)
인코딩: UTF-8

💳 16-1. 자료형 화면 개발 가이드 (SAMPLE_01 패턴)

어떤 화면인가?
카드(card) 형태로 목록이 날짜별로 그룹지어 표시되고, 항목을 누르면 상세 내용이 위에서 아래로 슬라이드되어 내려오는 화면입니다. 신용카드 결제내역, 업무 이력, 지출 내역처럼 "내 거래 기록을 날짜순으로 보고 싶다"는 업무에 적합합니다.

🖥️
실제 화면 구성 — sample01view.jsp 기준
  • 조회조건 패널: sticky 접이식. 기간(기본 15일), 가맹점명, 업종 select, 결제상태 select, 결제유형 select + 조회/초기화 버튼
  • 통계 바: 전체건수 · 승인 · 취소 · 결제액 · 할인 5개 항목을 상단에 한 줄로 요약
  • 카드 리스트: 날짜별 구분선 헤더 → 상태 원형 아이콘, 가맹점명, 상태 배지, 금액(우측), 시간 → 더보기 버튼
  • 상태 색상: 승인 #2e7d32(초록), 취소 #c62828(빨강). 주색상 #667eea(라벤더)
  • 슬라이드 상세 패널: 위→아래 슬라이드. 큰 금액+상태 배지 → 결제일시·카드·승인번호·할부(회색 섹션) → 결제금액·할인금액·최종금액 → 비고 입력(노란 배경)
  • 주요 함수: doSearch(), loadList(), renderList(), renderDetail(), saveNote(), escHtml()
💡
파워빌더와 비교 파워빌더에서는 DataWindow 하나에 Retrieve 조건, 화면 레이아웃, 이벤트 스크립트가 모두 들어 있었습니다. 이 프로젝트에서는 그 역할을 7개 작업으로 나눠서 합니다. 처음에는 번거로워 보이지만, 각 역할이 명확하게 분리되어 있어 SQL을 바꿔도 화면 코드를 건드릴 필요가 없습니다.
참조(복사 원본) 파일: SAMPLE_01 — 신용카드 결제내역 화면
역할원본 파일 (복사 원본)새로 만들 파일명작업 유형
화면 데이터 구조vo/BusinessSample01VO.javavo/BusinessSample##VO.java📋 복사 후 수정
SQL 쿼리 정의sqlmap/business/BusinessSample01_SQL.xmlsqlmap/business/BusinessSample##_SQL.xml📋 복사 후 수정
화면 HTMLjsp/business/sample01view.jspjsp/business/sample##view.jsp📋 복사 후 수정
URL 처리(요청 수신)controller/BusinessController.java동일 파일✏️ 기존 파일에 추가
업무 로직 인터페이스service/BusinessService.java동일 파일✏️ 기존 파일에 추가
업무 로직 구현service/impl/BusinessServiceImpl.java동일 파일✏️ 기존 파일에 추가
DB 쿼리 인터페이스dao/BusinessDAO.java동일 파일✏️ 기존 파일에 추가
  • Step 1 — DB 테이블 생성
    왜 필요한가? 화면에 표시할 데이터를 담을 그릇입니다. 파워빌더에서 DataWindow의 SELECT 대상 테이블을 먼저 만드는 것과 같습니다.

    psql, DBeaver 또는 pgAdmin에서 아래 SQL을 실행합니다. ##를 실제 번호로 바꾸세요.
    PostgreSQL SQL
    CREATE TABLE TB_PENTA_SAMPLE## (
        USER_ID     VARCHAR2(50)  NOT NULL,  -- PK1: 등록 사용자ID
        REG_DATE    DATE          NOT NULL,  -- PK2: 등록일시 (SYSDATE)
        REG_SEQ     NUMBER(5)     NOT NULL,  -- PK3: 순번 (MAX+1 자동채번)
        -- ↑ 이 3개 조합이 PK. 같은 사람이 같은 초에 등록해도 SEQ가 다름
        FIELD_A     VARCHAR2(200),           -- 업무 컬럼 (내 테이블에 맞게 교체)
        FIELD_B     NUMBER(15),              -- 업무 컬럼
        STATUS_CD   VARCHAR2(20),            -- 상태 코드
        NOTE        VARCHAR2(500),           -- 비고
        REG_USER    VARCHAR2(50)  NOT NULL,  -- 등록자
        UPD_USER    VARCHAR2(50),            -- 수정자
        UPD_DATE    DATE,                    -- 수정일시
        CONSTRAINT PK_TB_PENTA_SAMPLE## PRIMARY KEY (USER_ID, REG_DATE, REG_SEQ)
    );
    COMMENT ON TABLE  TB_PENTA_SAMPLE## IS '샘플 자료형 테이블';
    COMMENT ON COLUMN TB_PENTA_SAMPLE##.USER_ID  IS '사용자ID';
    COMMENT ON COLUMN TB_PENTA_SAMPLE##.REG_DATE IS '등록일시';
    COMMENT ON COLUMN TB_PENTA_SAMPLE##.REG_SEQ  IS '등록순번';
    COMMIT;
    ⚠️
    시퀀스(SEQUENCE) 사용 금지 — 이 프로젝트의 규칙입니다. REG_SEQ는 INSERT 시 MAX+1 서브쿼리로 채번합니다.
  • Step 2 — VO(데이터 구조) 파일 생성
    왜 필요한가? VO(Value Object)는 DB 컬럼과 화면 사이에서 데이터를 담아 전달하는 그릇입니다. 파워빌더의 Structure(구조체)와 같은 개념입니다. DB SELECT 결과가 이 클래스의 필드에 자동으로 채워집니다.

    Eclipse 작업 순서:
    ① Package Explorer에서 src/main/java/egovframework/penta/business/vo/BusinessSample01VO.java를 찾습니다.
    ② 우클릭 → Copy
    ③ 같은 vo 폴더 우클릭 → Paste → 파일명을 BusinessSample##VO.java로 입력
    ④ 새 파일을 열고 아래 항목을 수정합니다.
    BusinessSample##VO.java — 수정 위치
    // ① 클래스 선언 줄 변경 (파일 첫 부분)
    package egovframework.penta.business.vo;  // 그대로 유지
    
    import com.fasterxml.jackson.annotation.JsonFormat;
    import lombok.Data;
    import java.io.Serializable;
    import java.util.Date;
    
    @Data  // Lombok: getter/setter/toString 자동 생성 (파워빌더 instance variable과 같음)
    public class BusinessSample##VO implements Serializable {
        private static final long serialVersionUID = ##L;  // ## 자리에 숫자 입력
    
        // ── PK 필드 (DB 컬럼명을 camelCase로 변환) ──
        private String userId;    // DB: USER_ID
        @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Seoul")
        private Date   regDate;   // DB: REG_DATE (날짜는 반드시 @JsonFormat 붙임)
        private Integer regSeq;   // DB: REG_SEQ
    
        // ── 업무 필드 (내 테이블 컬럼에 맞게 교체) ──
        private String fieldA;    // DB: FIELD_A
        private Long   fieldB;    // DB: FIELD_B
        private String statusCd;  // DB: STATUS_CD
        private String note;      // DB: NOTE
    
        // ── SQL에서 AS로 만들어지는 화면 표시용 필드 ──
        private String statusNm;    // SQL: CASE STATUS_CD WHEN... AS STATUS_NM
        private String regDateStr;  // SQL: TO_CHAR(REG_DATE,'YYYY-MM-DD') AS REG_DATE_STR
    
        // ── 공통 Audit 필드 ──
        private String regUser;
        private String updUser;
        private Date   updDate;
    
        // ── 검색 조건 (DB에 저장 안 함, transient = MyBatis 매핑 제외) ──
        private transient String startDate;
        private transient String endDate;
        private transient String searchKeyword;
    
        // ── 페이징 ──
        private transient int pageIndex;
        private transient int pageSize;
        private transient int startRow;
        private transient int totalCount;
    }
    💡
    컬럼명 → 필드명 변환 규칙: DB 컬럼명 USER_ID는 Java 필드명 userId로 씁니다(언더바 제거 후 다음 글자 대문자). MyBatis가 자동으로 매핑합니다.
  • Step 3 — SQL Mapper 파일 생성
    왜 필요한가? 파워빌더의 DataWindow SQL(Retrieve 구문)을 별도 파일로 관리합니다. 이 파일에 SELECT/UPDATE/INSERT/DELETE 쿼리를 작성하면 Java 코드가 ID로 호출합니다.

    Eclipse 작업 순서:
    src/main/resources/egovframework/sqlmap/business/BusinessSample01_SQL.xml을 복사
    ② 같은 폴더에 붙여넣기 → BusinessSample##_SQL.xml로 이름 변경
    ③ 파일을 열고 아래와 같이 namespace는 그대로 유지하고 SQL id와 내용만 교체
    BusinessSample##_SQL.xml
    <mapper namespace="egovframework.penta.business.dao.BusinessDAO">
    <!-- ↑ 이 namespace는 절대 바꾸지 마세요. DAO 인터페이스와 연결되는 핵심 설정입니다 -->
    
    <!-- 공통 검색 조건 조각 (반복 사용) -->
    <sql id="sample##SearchWhere">
        AND A.USER_ID = #{userId}
        <if test="startDate != null and startDate != ''">
            AND TRUNC(A.REG_DATE) >= TO_DATE(#{startDate},'YYYY-MM-DD')
        </if>
        <if test="endDate != null and endDate != ''">
            AND TRUNC(A.REG_DATE) <= TO_DATE(#{endDate},'YYYY-MM-DD')
        </if>
        <if test="searchKeyword != null and searchKeyword != ''">
            AND A.FIELD_A LIKE '%'||#{searchKeyword}||'%'
        </if>
    </sql>
    
    <!-- 목록 조회 (id = DAO 인터페이스 메서드명과 정확히 일치해야 함) -->
    <select id="selectSample##List" parameterType="map"
            resultType="egovframework.penta.business.vo.BusinessSample##VO">
        SELECT A.USER_ID, A.REG_DATE, A.REG_SEQ,
               A.FIELD_A, A.FIELD_B, A.STATUS_CD,
               CASE A.STATUS_CD
                   WHEN 'CODE1' THEN '코드1 이름'
                   WHEN 'CODE2' THEN '코드2 이름'
                   ELSE A.STATUS_CD
               END AS STATUS_NM,
               TO_CHAR(A.REG_DATE,'YYYY-MM-DD HH24:MI') AS REG_DATE_STR
        FROM   TB_PENTA_SAMPLE## A
        WHERE  1=1
        <include refid="sample##SearchWhere"/>
        ORDER BY A.REG_DATE DESC, A.REG_SEQ DESC
        OFFSET #{startRow} ROWS FETCH NEXT #{pageSize} ROWS ONLY
    </select>
    
    <!-- 건수 조회 (페이징용) -->
    <select id="selectSample##ListCnt" parameterType="map" resultType="int">
        SELECT COUNT(*) FROM TB_PENTA_SAMPLE## A WHERE 1=1
        <include refid="sample##SearchWhere"/>
    </select>
    
    <!-- 상세 조회 -->
    <select id="selectSample##Detail" parameterType="map"
            resultType="egovframework.penta.business.vo.BusinessSample##VO">
        SELECT A.*, TO_CHAR(A.REG_DATE,'YYYY-MM-DD HH24:MI:SS') AS REG_DATE_STR
        FROM   TB_PENTA_SAMPLE## A
        WHERE  A.USER_ID = #{userId}
          AND  A.REG_DATE = TO_DATE(#{regDateStr},'YYYY-MM-DD HH24:MI:SS')
          AND  A.REG_SEQ  = #{regSeq}
    </select>
    
    <!-- 비고 수정 -->
    <update id="updateSample##Note" parameterType="map">
        UPDATE TB_PENTA_SAMPLE##
        SET    NOTE = #{note}, UPD_USER = #{loginUserId}, UPD_DATE = SYSDATE
        WHERE  USER_ID = #{userId}
          AND  REG_DATE = TO_DATE(#{regDateStr},'YYYY-MM-DD HH24:MI:SS')
          AND  REG_SEQ  = #{regSeq}
    </update>
    
    </mapper>
    💡
    XML 특수문자 주의: SQL에서 <, >&lt;, &gt;로 써야 합니다. 예: AND qty &lt;= 100
  • Step 4 — DAO 메서드 추가
    왜 필요한가? DAO(Data Access Object)는 SQL XML과 Java 코드를 연결하는 다리 역할입니다. 파워빌더의 DataStore 객체에 해당합니다. SQL XML의 id와 DAO 메서드명이 정확히 일치해야 MyBatis가 연결합니다.

    Eclipse 작업: Ctrl+Shift+RBusinessDAO 입력 → 파일 열기 → Ctrl+End로 맨 끝으로 이동 → 마지막 } 바로 앞에 추가
    BusinessDAO.java — 맨 끝 } 바로 앞에 추가
    // ============================================================
    // SAMPLE_##: [업무명] — 자료형
    // ============================================================
    List<BusinessSample##VO> selectSample##List(Map<String, Object> params) throws Exception;
    int selectSample##ListCnt(Map<String, Object> params) throws Exception;
    BusinessSample##VO selectSample##Detail(Map<String, Object> params) throws Exception;
    int updateSample##Note(Map<String, Object> params) throws Exception;
    파일 상단 import 구역에도 추가:
    import egovframework.penta.business.vo.BusinessSample##VO;
  • Step 5 — Service 인터페이스 메서드 추가
    왜 필요한가? Service 인터페이스는 "이런 기능을 제공하겠다"는 약속 목록입니다. Controller는 이 약속(인터페이스)만 보고 ServiceImpl의 실제 구현은 알 필요가 없습니다. 파워빌더의 사용자 함수 선언과 비슷한 개념입니다.

    Eclipse 작업: Ctrl+Shift+RBusinessService.java 열기 → 맨 끝 } 앞에 추가
    BusinessService.java — 맨 끝 } 바로 앞에 추가
    // ============================================================
    // SAMPLE_##: [업무명] — 자료형
    // ============================================================
    List<BusinessSample##VO> selectSample##List(Map<String, Object> params) throws Exception;
    int selectSample##ListCnt(Map<String, Object> params) throws Exception;
    BusinessSample##VO selectSample##Detail(Map<String, Object> params) throws Exception;
    int updateSample##Note(Map<String, Object> params) throws Exception;
  • Step 6 — ServiceImpl 구현 추가
    왜 필요한가? ServiceImpl이 실제 업무 로직을 처리하고 DAO를 호출합니다. 지금은 단순히 DAO를 그대로 호출하지만, 나중에 트랜잭션·권한체크·계산 로직이 들어올 자리입니다.

    Eclipse 작업: BusinessServiceImpl.java 열기 → 맨 끝 } 앞에 추가
    BusinessServiceImpl.java — 맨 끝 } 바로 앞에 추가
    // ============================================================
    // SAMPLE_##: [업무명] — 자료형
    // ============================================================
    @Override
    public List<BusinessSample##VO> selectSample##List(Map<String, Object> params) throws Exception {
        return businessDAO.selectSample##List(params);
    }
    @Override
    public int selectSample##ListCnt(Map<String, Object> params) throws Exception {
        return businessDAO.selectSample##ListCnt(params);
    }
    @Override
    public BusinessSample##VO selectSample##Detail(Map<String, Object> params) throws Exception {
        return businessDAO.selectSample##Detail(params);
    }
    @Override
    public int updateSample##Note(Map<String, Object> params) throws Exception {
        return businessDAO.updateSample##Note(params);
    }
  • Step 7 — Controller 메서드 추가
    왜 필요한가? Controller는 브라우저 URL 요청을 받아서 Service를 호출하고 결과를 JSON으로 반환합니다. 파워빌더의 버튼 클릭 이벤트 스크립트와 같은 역할입니다. URL이 여기서 정의됩니다.

    Eclipse 작업: BusinessController.java 열기 → 맨 끝 } 앞에 추가
    BusinessController.java — 맨 끝 } 바로 앞에 추가
    // ============================================================
    // SAMPLE_##: [업무명] — 자료형
    // ============================================================
    
    /** 화면 진입 (URL: /business/sample##view.do) — JSP 파일을 반환 */
    @RequestMapping(value = "/sample##view.do", method = RequestMethod.GET)
    public String sample##View(
            @RequestParam(value = "progId", required = false) String progId,
            Model model, HttpSession session) throws Exception {
        UserVO loginUser = (UserVO) session.getAttribute("userInfo");
        if (loginUser == null) return "redirect:/login/login.do";
        // setProgram(): TB_PENTA_PROGRAM에서 프로그램 정보 조회해 model에 담음
        setProgram(progId, model);
        model.addAttribute("loginUser", loginUser);
        return "business/sample##view";  // → WEB-INF/jsp/business/sample##view.jsp 반환
    }
    
    /** 목록 조회 AJAX (URL: /business/sample##List.do) — JSON 반환 */
    @RequestMapping(value = "/sample##List.do", method = RequestMethod.POST)
    @ResponseBody  // 이 어노테이션이 있으면 JSP가 아닌 JSON 문자열을 반환
    public Map<String, Object> sample##List(
            @RequestBody Map<String, Object> params, HttpSession session) throws Exception {
        Map<String, Object> result = new HashMap<>();
        try {
            UserVO loginUser = (UserVO) session.getAttribute("userInfo");
            if (loginUser == null) {
                result.put("success", false); result.put("message", "세션이 만료되었습니다."); return result;
            }
            int pageIndex = params.get("pageIndex") != null ? Integer.parseInt(params.get("pageIndex").toString()) : 1;
            int pageSize  = params.get("pageSize")  != null ? Integer.parseInt(params.get("pageSize").toString())  : 20;
            params.put("userId",    loginUser.getUserId());
            params.put("startRow",  (pageIndex - 1) * pageSize);
            params.put("pageSize",  pageSize);
            result.put("success",    true);
            result.put("list",       businessService.selectSample##List(params));
            result.put("totalCount", businessService.selectSample##ListCnt(params));
        } catch (Exception e) {
            log.error("sample##List error", e);
            result.put("success", false); result.put("message", "조회 중 오류가 발생했습니다.");
        }
        return result;
    }
    
    /** 상세 조회 AJAX */
    @RequestMapping(value = "/sample##Detail.do", method = RequestMethod.POST)
    @ResponseBody
    public Map<String, Object> sample##Detail(
            @RequestBody Map<String, Object> params, HttpSession session) throws Exception {
        Map<String, Object> result = new HashMap<>();
        try {
            UserVO loginUser = (UserVO) session.getAttribute("userInfo");
            if (loginUser == null) {
                result.put("success", false); result.put("message", "세션이 만료되었습니다."); return result;
            }
            params.put("userId", loginUser.getUserId());
            result.put("success", true);
            result.put("data",    businessService.selectSample##Detail(params));
        } catch (Exception e) {
            log.error("sample##Detail error", e);
            result.put("success", false); result.put("message", "조회 중 오류가 발생했습니다.");
        }
        return result;
    }
    
    /** 비고 저장 AJAX */
    @RequestMapping(value = "/sample##NoteSave.do", method = RequestMethod.POST)
    @ResponseBody
    public Map<String, Object> sample##NoteSave(
            @RequestBody Map<String, Object> params, HttpSession session) throws Exception {
        Map<String, Object> result = new HashMap<>();
        try {
            UserVO loginUser = (UserVO) session.getAttribute("userInfo");
            if (loginUser == null) {
                result.put("success", false); result.put("message", "세션이 만료되었습니다."); return result;
            }
            params.put("userId", loginUser.getUserId());
            params.put("loginUserId", loginUser.getUserId());
            int cnt = businessService.updateSample##Note(params);
            result.put("success", cnt > 0);
            result.put("message", cnt > 0 ? "저장되었습니다." : "수정할 항목이 없습니다.");
        } catch (Exception e) {
            log.error("sample##NoteSave error", e);
            result.put("success", false); result.put("message", "저장 중 오류가 발생했습니다.");
        }
        return result;
    }
  • Step 8 — JSP 화면 파일 작성
    왜 필요한가? JSP는 사용자가 실제로 보는 화면입니다. 파워빌더의 Window 화면 레이아웃 + 스크립트에 해당합니다. HTML로 화면을 구성하고 JavaScript로 Controller에 AJAX 요청을 보냅니다.

    Eclipse 작업:
    src/main/webapp/WEB-INF/jsp/business/sample01view.jsp를 복사
    ② 같은 폴더에 sample##view.jsp로 붙여넣기
    ③ 아래 항목을 수정 수정 항목 ①: 화면 제목
    <title>내 화면 이름</title>
    수정 항목 ②: 조회 조건 항목 교체 (검색 패널 내부)
    <input type="text" id="searchKeyword" class="search-input" placeholder="검색어">
    <select id="statusCd" class="search-select">
        <option value="">전체</option>
        <option value="CODE1">상태1</option>
    </select>
    수정 항목 ③: 목록 카드 렌더링 JavaScript 함수 교체
    // 목록 1개 항목을 카드 HTML로 변환하는 함수
    function renderItem(item) {
        return `<div class="list-item" onclick="showDetail('${item.regDateStr}','${item.regSeq}')">
            <div class="item-title">${item.fieldA}</div>
            <div class="item-amount">${formatNumber(item.fieldB)}</div>
            <span class="badge">${item.statusNm}</span>
        </div>`;
    }
    수정 항목 ④: 모든 AJAX URL 변경 (파일 내 sample01sample## 전체 치환)
    // Eclipse에서 Ctrl+H → Find/Replace
    // Find:    sample01
    // Replace: sample##
    // 현재 파일에만 적용 (Current Document 선택)
    수정 항목 ⑤: 상세 패널 HTML 항목 교체
    <div class="detail-row">
        <span class="label">항목명</span>
        <span id="dtFieldA" class="value"></span>
    </div>
    // 상세 패널 데이터 채우는 함수
    function fillDetail(data) {
        $('#dtFieldA').text(data.fieldA);
        $('#dtFieldB').text(formatNumber(data.fieldB));
        $('#dtStatusNm').text(data.statusNm);
    }
  • Step 9 — 메뉴 등록
    왜 필요한가? 업무 화면의 메뉴는 하드코딩이 아니라 DB 테이블(TB_PENTA_PROGRAM)에서 동적으로 로드됩니다. 메뉴 등록을 하지 않으면 앱에서 이 화면으로 접근할 수 없습니다.
    PostgreSQL SQL — 메뉴 및 권한 등록
    -- 1. 현재 최대 번호 확인
    SELECT MAX(PROG_ID) FROM TB_PENTA_PROGRAM WHERE SYS_TYPE = 'WEB';
    
    -- 2. 메뉴 등록 (## = 새 번호, 예: 00015)
    INSERT INTO TB_PENTA_PROGRAM (
        PROG_ID, PARENT_PROG_ID, PROG_NM, PROG_TYPE, PROG_URL,
        PROG_LEVEL, SORT_ORDER, ICON_CLASS, ICON_COLOR, USE_YN,
        REG_ID, REG_DT, SYS_TYPE
    ) VALUES (
        '000##', '00000', '화면 이름', 'PROGRAM',
        'business/sample##view.do?progId=000##',
        2, ##, 'fas fa-아이콘명', '#007bff', 'Y',
        'admin', SYSDATE, 'WEB'
    );
    
    -- 3. 권한 등록 (모든 역할에 부여)
    INSERT INTO TB_PENTA_ROLE_PROGRAM (
        ROLE_ID, PROG_ID, VIEW_YN, ADD_YN, UPD_YN, DEL_YN, SAVE_YN,
        REG_DATE, REG_USER, SYS_TYPE
    )
    SELECT ROLE_ID, '000##', 'Y','Y','Y','Y','Y', SYSDATE, 'admin', 'WEB'
    FROM   TB_PENTA_ROLE WHERE SYS_TYPE = 'WEB';
    COMMIT;

파일 구성

파일경로역할
BusinessController.javaegovframework.penta.business기존 파일에 메서드 추가
BusinessSampleNNVO.javaegovframework.penta.businessVO (Lombok @Data)
BusinessSampleNNDAO.javaegovframework.penta.business.daoMyBatis DAO 인터페이스
BusinessSampleNN_SQL.xmlsqlmap/MyBatis SQL
sampleNNview.jspWEB-INF/jsp/business/화면 JSP

테이블 설계 원칙

-- PK: 사용자ID + 발생일시(TIMESTAMP) + 순번
-- 순번 = 같은 사용자+일시 내 MAX(순번)+1
CREATE TABLE TB_PENTA_BUSINESS_SAMPLENN (
    USER_ID      VARCHAR2(50)   NOT NULL,          -- PK1
    EVT_DTTM     TIMESTAMP(3)   NOT NULL,          -- PK2
    EVT_SEQ      NUMBER(5,0)    NOT NULL,          -- PK3
    /* 업무 컬럼들 */
    NOTE         VARCHAR2(500),
    REG_USER     VARCHAR2(50)   NOT NULL,
    REG_DATE     DATE           DEFAULT SYSDATE NOT NULL,
    UPD_USER     VARCHAR2(50),
    UPD_DATE     DATE,
    CONSTRAINT PK_TB_PENTA_BUSINESS_SAMPLENN
        PRIMARY KEY (USER_ID, EVT_DTTM, EVT_SEQ)
);

SQL XML 핵심 패턴

<!-- 목록 조회: 날짜 범위 + 현재 사용자 -->
<select id="selectSampleNNList" parameterType="map"
        resultType="egovframework.penta.business.BusinessSampleNNVO">
    SELECT USER_ID, EVT_DTTM, EVT_SEQ
          ,/* 업무 컬럼 */
          ,NOTE, REG_USER, REG_DATE
    FROM   TB_PENTA_BUSINESS_SAMPLENN
    WHERE  USER_ID = #{userId}
      AND  CAST(EVT_DTTM AS DATE)
               BETWEEN TO_DATE(#{fromDate},'YYYY-MM-DD')
               AND     TO_DATE(#{toDate},'YYYY-MM-DD')
    ORDER BY EVT_DTTM DESC
</select>

<!-- 비고 수정: UPD_USER/UPD_DATE 갱신 -->
<update id="updateSampleNNNote" parameterType="map">
    UPDATE TB_PENTA_BUSINESS_SAMPLENN
    SET    NOTE     = #{note}
          ,UPD_USER = #{loginUserId}
          ,UPD_DATE = SYSDATE
    WHERE  USER_ID  = #{userId}
      AND  EVT_DTTM = #{evtDttm}
      AND  EVT_SEQ  = #{evtSeq}
</update>

Controller 패턴

// 목록 AJAX
@GetMapping("/business/sampleNNList.do")
@ResponseBody
public Map<String,Object> sampleNNList(
        @RequestParam Map<String,Object> param, HttpSession session) {
    String userId = (String) session.getAttribute("userId");
    param.put("userId", userId);
    // 기본 기간: 최근 15일
    if (param.get("fromDate") == null) {
        param.put("fromDate", LocalDate.now().minusDays(15).toString());
        param.put("toDate",   LocalDate.now().toString());
    }
    List<BusinessSampleNNVO> list = sampleNNService.selectSampleNNList(param);
    Map<String,Object> result = new HashMap<>();
    result.put("list", list);
    result.put("totalCnt", list.size());
    return result;
}

// 비고 저장 AJAX
@PostMapping("/business/sampleNNSaveNote.do")
@ResponseBody
public Map<String,Object> sampleNNSaveNote(
        @RequestBody Map<String,Object> param, HttpSession session) {
    param.put("loginUserId", session.getAttribute("userId"));
    Map<String,Object> result = new HashMap<>();
    try {
        sampleNNService.updateSampleNNNote(param);
        result.put("resultCode","S");
    } catch(Exception e) {
        log.error("sampleNNSaveNote error", e);
        result.put("resultCode","E");
        result.put("resultMsg", e.getMessage());
    }
    return result;
}

JSP 핵심 패턴 (검색바 접기/펼치기 + 카드 목록 + 슬라이드 패널)

// ① 검색 조건 바 접기/펼치기
document.getElementById('searchBar').addEventListener('focusout', function(e) {
    if (!this.contains(e.relatedTarget)) {
        this.classList.remove('expanded');
        loadSampleNNList(); // 포커스 벗어나면 자동 조회
    }
});

// ② 목록 조회
function loadSampleNNList() {
    var fromDate = document.getElementById('fromDate').value;
    var toDate   = document.getElementById('toDate').value;
    fetch('/business/sampleNNList.do?fromDate=' + fromDate + '&toDate=' + toDate)
        .then(r => r.json())
        .then(function(res) {
            renderCardList(res.list);
            updateStatBar(res.list);
        });
}

// ③ 슬라이드 상세 패널 열기
function openDetailPanel(userId, evtDttm, evtSeq) {
    fetch('/business/sampleNNDetail.do?userId='+userId
          +'&evtDttm='+evtDttm+'&evtSeq='+evtSeq)
        .then(r => r.json())
        .then(function(data) {
            fillDetailPanel(data);
            document.getElementById('detailPanel').classList.add('slide-in');
        });
}

// ④ 비고 저장
function saveNote() {
    var payload = {
        userId:  currentItem.userId,
        evtDttm: currentItem.evtDttm,
        evtSeq:  currentItem.evtSeq,
        note:    document.getElementById('noteInput').value
    };
    fetch('/business/sampleNNSaveNote.do', {
        method: 'POST',
        headers: {'Content-Type':'application/json'},
        body: JSON.stringify(payload)
    }).then(r => r.json()).then(function(res) {
        if (res.resultCode === 'S') {
            Swal.fire({icon:'success', text:'저장되었습니다.', timer:1200, showConfirmButton:false});
        } else {
            Swal.fire({icon:'error', text: res.resultMsg || '저장 실패'});
        }
    });
}
📊 16-2. 표형 화면 개발 가이드 (SAMPLE_02 패턴)

어떤 화면인가?
Excel 같은 테이블 형태로 데이터가 표시되고, 컬럼 헤더를 클릭하면 정렬이 바뀝니다. 모바일에서는 주요 컬럼만 보이다가 화면이 커지면 모든 컬럼이 나타나는 반응형 화면입니다. 재고현황, 인원 목록, 자산 현황 등 "여러 항목을 표로 비교하고 싶다"는 업무에 적합합니다.

🖥️
실제 화면 구성 — sample02view.jsp 기준
  • 조회조건 패널: sticky 접이식. 품목명, 분류 select, 창고 select, 재고상태 select + 조회/초기화
  • 통계 바: 전체 · 정상 · 부족 · 과잉 · 소진 건수 + 재고금액 — 6항목
  • 반응형 테이블: 모바일 4컬럼(품목코드·명·상태·재고) → 480px 이상 7컬럼 → 768px 이상 13컬럼 전체. 헤더에 fa-sort / fa-sort-up / fa-sort-down 아이콘
  • 재고상태 배지: 정상(초록) · 부족(주황) · 과잉(파랑) · 소진(빨강)
  • 슬라이드 상세 패널: 상태 원형 아이콘+품목명 → 재고 게이지바(최소~최대) → 기본정보(회색 섹션) → 단가·재고금액·최근입출고·등록일 → 비고 입력
  • 주요 함수: doSearch(), loadList(), renderList(), sortBy(), renderDetail(), saveNote()
  • CSS 클래스: .search-panel-wrap, .stat-bar, .stock-tbl, .detail-panel, .badge-normal/.badge-low/.badge-over/.badge-zero
💡
파워빌더와 비교 파워빌더의 Grid 스타일 DataWindow와 가장 유사합니다. 헤더 클릭 정렬은 파워빌더의 Sort()와 같지만, 여기서는 서버에서 정렬합니다. 컬럼명이 SQL에 직접 들어가므로 보안을 위해 반드시 허용 컬럼 목록을 검증해야 합니다.
참조(복사 원본) 파일: SAMPLE_02 — 재고현황 표 화면
역할원본 파일 (복사 원본)새로 만들 파일명작업 유형
화면 데이터 구조vo/BusinessSample02VO.javavo/BusinessSample##VO.java📋 복사 후 수정
SQL 쿼리 정의sqlmap/business/BusinessSample02_SQL.xmlsqlmap/business/BusinessSample##_SQL.xml📋 복사 후 수정
화면 HTMLjsp/business/sample02view.jspjsp/business/sample##view.jsp📋 복사 후 수정
URL 처리controller/BusinessController.java동일 파일✏️ 기존 파일에 추가
업무 로직 인터페이스service/BusinessService.java동일 파일✏️ 기존 파일에 추가
업무 로직 구현service/impl/BusinessServiceImpl.java동일 파일✏️ 기존 파일에 추가
DB 쿼리 인터페이스dao/BusinessDAO.java동일 파일✏️ 기존 파일에 추가
  • Step 1 — DB 테이블 생성
    표형 테이블은 자료형과 달리 마스터성 데이터(변경이 적고 여러 사람이 조회하는 데이터)를 담습니다. 재고현황처럼 품목 코드가 PK가 되는 경우입니다.
    CREATE TABLE TB_PENTA_SAMPLE## (
        ITEM_CD     VARCHAR2(50)   NOT NULL,  -- PK1: 품목코드
        ITEM_NM     VARCHAR2(200)  NOT NULL,  -- 품목명
        CATEGORY    VARCHAR2(50),             -- 분류
        UNIT        VARCHAR2(20),             -- 단위
        STOCK_QTY   NUMBER(12),               -- 재고수량
        UNIT_PRICE  NUMBER(15),               -- 단가
        STATUS_CD   VARCHAR2(20),             -- 상태코드
        NOTE        VARCHAR2(500),            -- 비고
        REG_USER    VARCHAR2(50)  NOT NULL,
        REG_DATE    DATE          DEFAULT SYSDATE NOT NULL,
        UPD_USER    VARCHAR2(50),
        UPD_DATE    DATE,
        CONSTRAINT PK_TB_PENTA_SAMPLE## PRIMARY KEY (ITEM_CD)
    );
    CREATE INDEX IDX_TB_PENTA_SAMPLE##_01 ON TB_PENTA_SAMPLE## (CATEGORY);
    COMMIT;
  • Step 2 — VO 파일 생성
    표형 VO는 정렬 컬럼과 방향 필드를 반드시 포함해야 합니다. 이 필드가 없으면 헤더 클릭 정렬이 동작하지 않습니다.

    Eclipse: vo/BusinessSample02VO.java 복사 → vo/BusinessSample##VO.java 붙여넣기 → 아래와 같이 수정
    public class BusinessSample##VO implements Serializable {
        // ── PK 필드 ──
        private String itemCd;
        // ── 업무 필드 ──
        private String itemNm;
        private String category;
        private String unit;
        private Long   stockQty;
        private Long   unitPrice;
        private String statusCd;
        private String statusNm;   // SQL CASE 변환값
        private String note;
        // ── 정렬 (표형 필수) ──
        private transient String sortCol;  // 정렬 컬럼명
        private transient String sortDir;  // ASC / DESC
        // ── 검색 ──
        private transient String searchKeyword;
        private transient String searchCategory;
        // ── 페이징 ──
        private transient int pageIndex;
        private transient int pageSize;
        private transient int startRow;
        private transient int totalCount;
    }
  • Step 3 — SQL Mapper 파일 생성 (정렬 보안 필수)
    표형의 가장 중요한 주의사항: 정렬 컬럼명은 SQL에 ${sortCol}로 직접 삽입됩니다. 이것은 SQL 인젝션 위험이 있으므로 Controller에서 반드시 허용 목록을 검증한 후 넣어야 합니다(Step 7 참고).

    Eclipse: BusinessSample02_SQL.xml 복사 → BusinessSample##_SQL.xml 붙여넣기 → 수정
    <mapper namespace="egovframework.penta.business.dao.BusinessDAO">
    
    <sql id="sample##SearchWhere">
        <if test="searchKeyword != null and searchKeyword != ''">
            AND (A.ITEM_CD LIKE '%'||#{searchKeyword}||'%'
              OR A.ITEM_NM LIKE '%'||#{searchKeyword}||'%')
        </if>
        <if test="searchCategory != null and searchCategory != ''">
            AND A.CATEGORY = #{searchCategory}
        </if>
    </sql>
    
    <select id="selectSample##List" parameterType="map"
            resultType="egovframework.penta.business.vo.BusinessSample##VO">
        SELECT A.ITEM_CD, A.ITEM_NM, A.CATEGORY, A.UNIT,
               A.STOCK_QTY, A.UNIT_PRICE,
               A.STOCK_QTY * A.UNIT_PRICE AS STOCK_AMT,
               CASE A.STATUS_CD WHEN 'OK' THEN '정상' WHEN 'LOW' THEN '부족' ELSE '불명' END AS STATUS_NM
        FROM   TB_PENTA_SAMPLE## A
        WHERE  1=1
        <include refid="sample##SearchWhere"/>
        ORDER BY ${sortCol} ${sortDir}
        <!-- ${sortCol}은 Controller에서 화이트리스트 검증 후 사용 -->
        OFFSET #{startRow} ROWS FETCH NEXT #{pageSize} ROWS ONLY
    </select>
    
    <select id="selectSample##ListCnt" parameterType="map" resultType="int">
        SELECT COUNT(*) FROM TB_PENTA_SAMPLE## A WHERE 1=1
        <include refid="sample##SearchWhere"/>
    </select>
    
    <select id="selectSample##Detail" parameterType="map"
            resultType="egovframework.penta.business.vo.BusinessSample##VO">
        SELECT A.* FROM TB_PENTA_SAMPLE## A WHERE A.ITEM_CD = #{itemCd}
    </select>
    
    </mapper>
  • Step 4 — DAO 메서드 추가
    Eclipse: BusinessDAO.java 열기 → Ctrl+End → 마지막 } 앞에 추가
    // SAMPLE_##: [업무명] — 표형
    List<BusinessSample##VO> selectSample##List(Map<String, Object> params) throws Exception;
    int selectSample##ListCnt(Map<String, Object> params) throws Exception;
    BusinessSample##VO selectSample##Detail(Map<String, Object> params) throws Exception;
    파일 상단 import에도 추가:
    import egovframework.penta.business.vo.BusinessSample##VO;
  • Step 5 — Service 인터페이스 메서드 추가
    Eclipse: BusinessService.java 열기 → 맨 끝 } 앞에 추가
    // SAMPLE_##: [업무명] — 표형
    List<BusinessSample##VO> selectSample##List(Map<String, Object> params) throws Exception;
    int selectSample##ListCnt(Map<String, Object> params) throws Exception;
    BusinessSample##VO selectSample##Detail(Map<String, Object> params) throws Exception;
  • Step 6 — ServiceImpl 구현 추가
    Eclipse: BusinessServiceImpl.java 열기 → 맨 끝 } 앞에 추가
    @Override
    public List<BusinessSample##VO> selectSample##List(Map<String, Object> params) throws Exception {
        return businessDAO.selectSample##List(params);
    }
    @Override
    public int selectSample##ListCnt(Map<String, Object> params) throws Exception {
        return businessDAO.selectSample##ListCnt(params);
    }
    @Override
    public BusinessSample##VO selectSample##Detail(Map<String, Object> params) throws Exception {
        return businessDAO.selectSample##Detail(params);
    }
  • Step 7 — Controller 메서드 추가 (정렬 보안 검증 포함)
    표형 Controller의 핵심: 정렬 컬럼명이 SQL에 그대로 들어가는 ${sortCol}은 허용 목록(Set)에 없는 값이 들어오면 SQL 인젝션이 될 수 있습니다. 아래처럼 반드시 검증합니다.

    Eclipse: BusinessController.java 열기 → 맨 끝 } 앞에 추가
    // ── 표형 ## 정렬 컬럼 허용 목록 (클래스 상단에 상수로 정의) ──
    private static final Set<String> SAMPLE##_SORT_COLS =
        Collections.unmodifiableSet(new HashSet<>(
            Arrays.asList("ITEM_CD", "ITEM_NM", "STOCK_QTY", "UNIT_PRICE", "CATEGORY")
        ));
    
    /** 화면 진입 */
    @RequestMapping(value = "/sample##view.do", method = RequestMethod.GET)
    public String sample##View(
            @RequestParam(value="progId", required=false) String progId,
            Model model, HttpSession session) throws Exception {
        UserVO loginUser = (UserVO) session.getAttribute("userInfo");
        if (loginUser == null) return "redirect:/login/login.do";
        setProgram(progId, model);
        model.addAttribute("loginUser", loginUser);
        return "business/sample##view";
    }
    
    /** 목록 조회 AJAX — 정렬 보안 검증 포함 */
    @RequestMapping(value = "/sample##List.do", method = RequestMethod.POST)
    @ResponseBody
    public Map<String, Object> sample##List(
            @RequestBody Map<String, Object> params, HttpSession session) throws Exception {
        Map<String, Object> result = new HashMap<>();
        try {
            UserVO loginUser = (UserVO) session.getAttribute("userInfo");
            if (loginUser == null) {
                result.put("success", false); result.put("message", "세션이 만료되었습니다."); return result;
            }
            // ▼ 정렬 컬럼 보안 검증 (허용 목록에 없으면 기본값으로 강제 설정)
            String sortCol = params.get("sortCol") != null ? params.get("sortCol").toString().toUpperCase() : "ITEM_CD";
            String sortDir = params.get("sortDir") != null ? params.get("sortDir").toString().toUpperCase() : "ASC";
            if (!SAMPLE##_SORT_COLS.contains(sortCol)) sortCol = "ITEM_CD";
            if (!"ASC".equals(sortDir) && !"DESC".equals(sortDir)) sortDir = "ASC";
            params.put("sortCol", sortCol);
            params.put("sortDir", sortDir);
            int pageIndex = params.get("pageIndex") != null ? Integer.parseInt(params.get("pageIndex").toString()) : 1;
            int pageSize  = params.get("pageSize")  != null ? Integer.parseInt(params.get("pageSize").toString())  : 20;
            params.put("startRow", (pageIndex - 1) * pageSize);
            params.put("pageSize", pageSize);
            result.put("success",    true);
            result.put("list",       businessService.selectSample##List(params));
            result.put("totalCount", businessService.selectSample##ListCnt(params));
        } catch (Exception e) {
            log.error("sample##List error", e);
            result.put("success", false); result.put("message", "조회 중 오류가 발생했습니다.");
        }
        return result;
    }
    
    /** 상세 조회 AJAX */
    @RequestMapping(value = "/sample##Detail.do", method = RequestMethod.POST)
    @ResponseBody
    public Map<String, Object> sample##Detail(
            @RequestBody Map<String, Object> params, HttpSession session) throws Exception {
        Map<String, Object> result = new HashMap<>();
        try {
            UserVO loginUser = (UserVO) session.getAttribute("userInfo");
            if (loginUser == null) {
                result.put("success", false); result.put("message", "세션이 만료되었습니다."); return result;
            }
            result.put("success", true);
            result.put("data", businessService.selectSample##Detail(params));
        } catch (Exception e) {
            log.error("sample##Detail error", e);
            result.put("success", false); result.put("message", "조회 중 오류가 발생했습니다.");
        }
        return result;
    }
  • Step 8 — JSP 화면 파일 작성 (반응형 테이블 + 정렬 아이콘)
    Eclipse: sample02view.jsp 복사 → sample##view.jsp 붙여넣기 후 수정

    수정 항목 ①: 테이블 헤더 (클릭 시 정렬, 모바일에서 숨길 컬럼은 CSS class 추가)
    <table class="data-table">
      <thead><tr>
        <!-- 항상 표시되는 컬럼 -->
        <th onclick="sortBy('ITEM_CD')">품목코드 <i id="sortIcon_ITEM_CD" class="fas fa-sort"></i></th>
        <th onclick="sortBy('ITEM_NM')">품목명 <i id="sortIcon_ITEM_NM" class="fas fa-sort"></i></th>
        <th onclick="sortBy('STOCK_QTY')">수량 <i id="sortIcon_STOCK_QTY" class="fas fa-sort"></i></th>
        <!-- 480px 이상에서만 표시 (class="col-sm") -->
        <th class="col-sm" onclick="sortBy('UNIT_PRICE')">단가 <i id="sortIcon_UNIT_PRICE" class="fas fa-sort"></i></th>
        <!-- 768px 이상에서만 표시 (class="col-lg") -->
        <th class="col-lg">상태</th>
      </tr></thead>
      <tbody id="tableBody"></tbody>
    </table>
    수정 항목 ②: CSS — 모바일 반응형
    .col-sm { display: none; }
    .col-lg { display: none; }
    @media (min-width: 480px) { .col-sm { display: table-cell; } }
    @media (min-width: 768px) { .col-lg { display: table-cell; } }
    수정 항목 ③: 정렬 JavaScript 함수
    var currentSortCol = 'ITEM_CD';
    var currentSortDir = 'ASC';
    
    function sortBy(col) {
        if (currentSortCol === col) {
            currentSortDir = currentSortDir === 'ASC' ? 'DESC' : 'ASC';
        } else {
            currentSortCol = col; currentSortDir = 'ASC';
        }
        // 모든 정렬 아이콘 초기화 후 현재 컬럼만 방향 아이콘 표시
        $('[id^="sortIcon_"]').attr('class', 'fas fa-sort');
        $('#sortIcon_' + col).attr('class',
            currentSortDir === 'ASC' ? 'fas fa-sort-up' : 'fas fa-sort-down');
        loadList();
    }
    
    function loadList() {
        $.ajax({
            url: '/business/sample##List.do',
            method: 'POST',
            contentType: 'application/json',
            data: JSON.stringify({
                searchKeyword:  $('#searchKeyword').val(),
                searchCategory: $('#searchCategory').val(),
                sortCol: currentSortCol,
                sortDir: currentSortDir,
                pageIndex: 1, pageSize: 30
            }),
            success: function(res) {
                if (!res.success) { Swal.fire({icon:'error', text: res.message}); return; }
                renderTable(res.list);
            }
        });
    }
    수정 항목 ④: AJAX URL 전체 치환 — Eclipse Ctrl+Hsample02sample##로 교체 (현재 파일에만)
  • Step 9 — 메뉴 등록
    자료형(16-1) Step 9와 동일한 SQL로 진행합니다. PROG_URL은 business/sample##view.do?progId=000##로 설정합니다.

파일 구성

파일역할
BusinessController.java (추가)AJAX 목록·상세·저장 엔드포인트
BusinessSampleNNVO.java정렬 컬럼·방향 필드 포함
BusinessSampleNN_SQL.xml동적 ORDER BY, 페이징(ROWNUM)
sampleNNview.jsp반응형 테이블 + 슬라이드 패널

SQL XML — 동적 정렬 + 페이징

<select id="selectSampleNNList" parameterType="map" resultType="BusinessSampleNNVO">
    SELECT * FROM (
        SELECT T.*, ROWNUM AS RNUM FROM (
            SELECT ITEM_CODE, ITEM_NM, STATUS, STOCK_QTY,
                   CATEGORY, UNIT_PRICE,
                   STOCK_QTY * UNIT_PRICE AS STOCK_AMT
            FROM   TB_PENTA_BUSINESS_SAMPLENN
            WHERE  1=1
            <if test="itemNm != null and itemNm != ''">
              AND  ITEM_NM LIKE '%' || #{itemNm} || '%'
            </if>
            <if test="category != null and category != '' and category != '전체'">
              AND  CATEGORY = #{category}
            </if>
            <if test="status != null and status != '' and status != '전체'">
              AND  STATUS = #{status}
            </if>
            ORDER BY
            <choose>
                <when test="sortCol == 'itemCode'">ITEM_CODE</when>
                <when test="sortCol == 'itemNm'">ITEM_NM</when>
                <when test="sortCol == 'stockQty'">STOCK_QTY</when>
                <when test="sortCol == 'unitPrice'">UNIT_PRICE</when>
                <when test="sortCol == 'stockAmt'">STOCK_AMT</when>
                <otherwise>ITEM_CODE</otherwise>
            </choose>
            <choose>
                <when test="sortDir == 'DESC'">DESC</when>
                <otherwise>ASC</otherwise>
            </choose>
        ) T WHERE ROWNUM <= #{endRow}
    ) WHERE RNUM > #{startRow}
</select>

JSP — 정렬 토글 + 더보기 + 슬라이드 패널

var sortState = { col: 'itemCode', dir: 'ASC' };
var pageState = { startRow: 0, pageSize: 20, total: 0 };

// 헤더 클릭 정렬
function toggleSort(col) {
    if (sortState.col === col) {
        sortState.dir = (sortState.dir === 'ASC') ? 'DESC' : 'ASC';
    } else {
        sortState.col = col;
        sortState.dir = 'ASC';
    }
    updateSortIcons();
    pageState.startRow = 0;
    loadSampleNNList(false);
}

// 정렬 아이콘 갱신
function updateSortIcons() {
    document.querySelectorAll('th[data-col]').forEach(function(th) {
        th.querySelector('.sort-icon').textContent =
            (th.dataset.col === sortState.col)
            ? (sortState.dir === 'ASC' ? ' ▲' : ' ▼')
            : ' ⇅';
    });
}

// 더보기 (무한스크롤 대신 버튼)
function loadMore() {
    pageState.startRow += pageState.pageSize;
    loadSampleNNList(true); // append=true
}

function loadSampleNNList(append) {
    var params = new URLSearchParams({
        itemNm:   document.getElementById('srchItemNm').value,
        category: document.getElementById('srchCategory').value,
        status:   document.getElementById('srchStatus').value,
        sortCol:  sortState.col,
        sortDir:  sortState.dir,
        startRow: pageState.startRow,
        endRow:   pageState.startRow + pageState.pageSize
    });
    fetch('/business/sampleNNList.do?' + params)
        .then(r => r.json())
        .then(function(res) {
            if (append) {
                appendRows(res.list);
            } else {
                renderTable(res.list);
                updateStatBar(res.statMap);
            }
            pageState.total = res.totalCnt;
            document.getElementById('moreBtn').style.display =
                (pageState.startRow + pageState.pageSize < pageState.total)
                ? 'block' : 'none';
        });
}

// 행 클릭 → 슬라이드 상세 패널
function openDetailPanel(itemCode) {
    fetch('/business/sampleNNDetail.do?itemCode=' + itemCode)
        .then(r => r.json())
        .then(function(d) {
            document.getElementById('dtlItemNm').textContent  = d.itemNm;
            document.getElementById('dtlStockQty').textContent = d.stockQty;
            // ... 나머지 필드 바인딩
            document.getElementById('detailPanel').classList.add('slide-in');
        });
}
📈 16-3. 차트형 화면 개발 가이드 (SAMPLE_03 패턴)

어떤 화면인가?
매출, 통계 데이터를 막대·선·원형·도넛 등 다양한 차트로 시각화합니다. 차트 1개 = DB 테이블 1개 = SQL 1개 = AJAX 호출 1개 구조입니다. 조건(기간, 지점 등)이 바뀌면 모든 차트를 한 번에 다시 그립니다.

🖥️
실제 화면 구성 — sample03view.jsp 기준
  • 조회조건 패널: 접이식. 연도 select만 사용 (단순 조건)
  • 차트 13종: 선형 차트+미니 통계(최근매출·증가율), 막대, 파이·도넛(2열 그리드), 레이더·폴라에어리어(2열), 버블·스캐터(2열), 스택형막대·가로막대(2열), 영역선·콤보(2열), 게이지(도넛형—중앙에 % 수치)
  • 차트 컨테이너: border-radius:16px + box-shadow 카드 스타일
  • 상세 패널 없음: 차트는 모두 inline 표시. 슬라이드 패널 없음
  • 주요 함수: clearCharts(), renderCharts(), 차트별 renderLineChart() / renderBarChart() 등 13개 함수
  • 라이브러리: Chart.js. 차트 인스턴스는 전역 변수로 보관, 재조회 시 chart.destroy() 호출 필수
💡
파워빌더와 비교: 파워빌더에서 Graph DataWindow를 사용하던 것과 같습니다. 여기서는 Chart.js 라이브러리가 그래프를 그리며, 데이터는 AJAX로 서버에서 가져옵니다. chart 인스턴스를 전역 변수로 보관하고, 재조회 시 chart.destroy()로 먼저 파괴한 뒤 새로 생성해야 합니다(파괴하지 않으면 메모리 누수).
참조(복사 원본) 파일: SAMPLE_03 — 차트 화면
역할원본 파일새 파일명작업
VOvo/BusinessSample03VO.javavo/BusinessSample##VO.java📋 복사 후 수정
SQLsqlmap/business/BusinessSample03_SQL.xmlsqlmap/business/BusinessSample##_SQL.xml📋 복사 후 수정
JSPjsp/business/sample03view.jspjsp/business/sample##view.jsp📋 복사 후 수정
Controller/Service/ServiceImpl/DAO기존 파일들동일 파일✏️ 추가
  • Step 1 — DB 테이블 생성 (차트 1개당 테이블 1개)
    차트 종류가 3개이면 테이블이 3개입니다. 각 테이블에 그 차트에 필요한 데이터를 넣습니다.
    -- 막대차트용 (월별 매출)
    CREATE TABLE TB_PENTA_SAMPLE##_01 (
        MONTH_NM   VARCHAR2(7)   NOT NULL,  -- PK: 'YYYY-MM'
        CATEGORY   VARCHAR2(50)  NOT NULL,  -- PK: 시리즈 구분
        VALUE1     NUMBER(15,2),            -- 실적
        VALUE2     NUMBER(15,2),            -- 목표
        REG_USER   VARCHAR2(50)  NOT NULL,
        REG_DATE   DATE DEFAULT SYSDATE NOT NULL,
        UPD_USER   VARCHAR2(50), UPD_DATE DATE,
        CONSTRAINT PK_TB_PENTA_SAMPLE##_01 PRIMARY KEY (MONTH_NM, CATEGORY)
    );
    -- 원형차트용 (제품별 비중)
    CREATE TABLE TB_PENTA_SAMPLE##_02 (
        PROD_NM    VARCHAR2(100) NOT NULL,
        VALUE1     NUMBER(15,2),
        REG_USER   VARCHAR2(50) NOT NULL,
        REG_DATE   DATE DEFAULT SYSDATE NOT NULL,
        UPD_USER   VARCHAR2(50), UPD_DATE DATE,
        CONSTRAINT PK_TB_PENTA_SAMPLE##_02 PRIMARY KEY (PROD_NM)
    );
    COMMIT;
  • Step 2 — VO 파일 생성
    차트용 VO는 범용 필드를 사용합니다. 차트마다 컬럼 의미가 다르므로 label/value1/value2 같이 일반적인 이름을 씁니다.

    Eclipse: BusinessSample03VO.java 복사 → BusinessSample##VO.java 붙여넣기 → 클래스명만 변경
    public class BusinessSample##VO implements Serializable {
        private String label;     // X축 레이블 또는 범례 이름
        private String series;    // 시리즈 구분 (실적/목표 등)
        private String category;
        private Double value1;    // 주 값
        private Double value2;    // 보조 값
        // 검색 조건
        private transient String searchYear;
        private transient String searchMonth;
    }
  • Step 3 — SQL Mapper 파일 생성 (차트별 SELECT 추가)
    Eclipse: BusinessSample03_SQL.xml 복사 → BusinessSample##_SQL.xml 붙여넣기 → id와 쿼리 교체
    <mapper namespace="egovframework.penta.business.dao.BusinessDAO">
    <!-- 차트01: 월별 매출 막대차트 데이터 -->
    <select id="selectSample##Chart01" parameterType="map" resultType="egovframework.penta.business.vo.BusinessSample##VO">
        SELECT MONTH_NM AS LABEL, CATEGORY AS SERIES, VALUE1, VALUE2
        FROM   TB_PENTA_SAMPLE##_01
        <where>
            <if test="searchYear != null and searchYear != ''">
                SUBSTR(MONTH_NM,1,4) = #{searchYear}
            </if>
        </where>
        ORDER BY MONTH_NM, CATEGORY
    </select>
    <!-- 차트02: 제품별 비중 원형차트 데이터 -->
    <select id="selectSample##Chart02" parameterType="map" resultType="egovframework.penta.business.vo.BusinessSample##VO">
        SELECT PROD_NM AS LABEL, VALUE1 FROM TB_PENTA_SAMPLE##_02 ORDER BY VALUE1 DESC
    </select>
    </mapper>
  • Step 4~6 — DAO / Service / ServiceImpl 추가
    BusinessDAO.java 맨 끝 } 앞에:
    List<BusinessSample##VO> selectSample##Chart01(Map<String,Object> params) throws Exception;
    List<BusinessSample##VO> selectSample##Chart02(Map<String,Object> params) throws Exception;
    // 차트가 N개면 selectSample##ChartNN 형태로 N개 선언
    BusinessService.java 맨 끝 } 앞에: (DAO와 동일한 시그니처 복사)
    BusinessServiceImpl.java 맨 끝 } 앞에: (각 메서드에 return businessDAO.selectSample##ChartNN(params);)
  • Step 7 — Controller 메서드 추가
    BusinessController.java 맨 끝 } 앞에:
    /** 화면 진입 */
    @RequestMapping(value="/sample##view.do", method=RequestMethod.GET)
    public String sample##View(@RequestParam(value="progId",required=false) String progId,
            Model model, HttpSession session) throws Exception {
        UserVO u = (UserVO) session.getAttribute("userInfo");
        if (u==null) return "redirect:/login/login.do";
        setProgram(progId, model); model.addAttribute("loginUser", u);
        return "business/sample##view";
    }
    /** 차트01 데이터 AJAX */
    @RequestMapping(value="/sample##Chart01.do", method=RequestMethod.POST)
    @ResponseBody
    public Map<String,Object> sample##Chart01(@RequestBody Map<String,Object> params, HttpSession session) throws Exception {
        Map<String,Object> result = new HashMap<>();
        try {
            if (session.getAttribute("userInfo")==null) { result.put("success",false); return result; }
            result.put("success", true);
            result.put("list", businessService.selectSample##Chart01(params));
        } catch(Exception e) { log.error("sample##Chart01 error",e); result.put("success",false); }
        return result;
    }
    // 차트 개수만큼 위 패턴으로 메서드 추가 (Chart02, Chart03 ...)
  • Step 8 — JSP 파일 작성 (Chart.js 패턴)
    Eclipse: sample03view.jsp 복사 → sample##view.jsp → 차트 캔버스 HTML과 JavaScript 교체
    <!-- HTML: 차트 캔버스 -->
    <div class="chart-section">
      <h3>월별 매출 현황</h3>
      <div style="position:relative;height:280px"><canvas id="chart01"></canvas></div>
    </div>
    // JavaScript: 전역 변수 + 로드 함수
    var chart01 = null;  // 인스턴스를 전역에서 보관
    
    function loadChart01() {
        $.ajax({ url:'/business/sample##Chart01.do', method:'POST',
            contentType:'application/json',
            data: JSON.stringify({ searchYear: $('#searchYear').val() }),
            success: function(res) {
                if (!res.success) return;
                var labels = [...new Set(res.list.map(d => d.label))];
                var data   = res.list.map(d => d.value1);
                if (chart01) { chart01.destroy(); chart01 = null; } // 파괴 필수
                chart01 = new Chart(document.getElementById('chart01').getContext('2d'), {
                    type: 'bar',
                    data: { labels: labels, datasets: [{ label:'매출', data: data,
                        backgroundColor:'rgba(102,126,234,0.7)' }] },
                    options: { responsive:true, maintainAspectRatio:false }
                });
            }
        });
    }
    // 조건 변경 시 모든 차트 갱신
    function loadAllCharts() { loadChart01(); loadChart02(); }
    $('#searchYear').on('change', loadAllCharts);
    $(document).ready(loadAllCharts);
    💡
    Chart.js에서 지원하는 type: 'bar'(막대), 'line'(선), 'pie'(원형), 'doughnut'(도넛), 'radar'(레이더), 'polarArea'(극좌표), 'bubble'(버블), 'scatter'(산점도)
  • Step 9 — 메뉴 등록
    16-1 Step 9와 동일합니다. PROG_URL은 business/sample##view.do?progId=000##

파일 구성

파일역할
BusinessController.java (추가)차트별 데이터 AJAX 엔드포인트 (차트당 1개)
BusinessSampleNNVO.javalabel, series, value1~3, xValue, yValue, rValue, year 공통 필드
BusinessSampleNN_SQL.xml차트 테이블별 조회 쿼리
sampleNNview.jspChart.js 초기화 + 조건 변경 이벤트

테이블 설계 원칙 (차트 1개 = 테이블 1개)

-- TB_PENTA_BUSINESS_SAMPLENN_01 (첫 번째 차트용)
CREATE TABLE TB_PENTA_BUSINESS_SAMPLENN_01 (
    ROW_ID     VARCHAR2(50) NOT NULL,
    LABEL      VARCHAR2(100),       -- X축 레이블 (월, 지점명 등)
    SERIES     VARCHAR2(50),        -- 범례 구분 (실적/목표, 지점A/B 등)
    CATEGORY   VARCHAR2(50),        -- 카테고리 분류
    VALUE1     NUMBER(14,2),        -- 주 값
    VALUE2     NUMBER(14,2),        -- 보조 값 (목표 등)
    X_VALUE    NUMBER(14,2),        -- 산점도/버블 X
    Y_VALUE    NUMBER(14,2),        -- 산점도/버블 Y
    R_VALUE    NUMBER(14,2),        -- 버블 반지름
    YEAR       VARCHAR2(4) NOT NULL,
    SORT_ORDER NUMBER(5,0),
    REG_DATE   DATE DEFAULT SYSDATE,
    CONSTRAINT PK_SAMPLENN_01 PRIMARY KEY (ROW_ID)
);

Controller — 차트 데이터 JSON 변환

@GetMapping("/business/sampleNNChart01.do")
@ResponseBody
public Map<String,Object> sampleNNChart01(@RequestParam String year) {
    List<BusinessSampleNNVO> rawList = sampleNNService.selectChart01(year);
    // Chart.js datasets 형태로 변환
    Map<String, List<Number>> seriesMap = new LinkedHashMap<>();
    List<String> labels = new ArrayList<>();
    String lastLabel = "";
    for (BusinessSampleNNVO v : rawList) {
        if (!v.getLabel().equals(lastLabel)) { labels.add(v.getLabel()); lastLabel = v.getLabel(); }
        seriesMap.computeIfAbsent(v.getSeries(), k -> new ArrayList<>()).add(v.getValue1());
    }
    List<Map<String,Object>> datasets = new ArrayList<>();
    seriesMap.forEach((name, data) -> {
        Map<String,Object> ds = new LinkedHashMap<>();
        ds.put("label", name);
        ds.put("data",  data);
        datasets.add(ds);
    });
    Map<String,Object> result = new HashMap<>();
    result.put("labels",   labels);
    result.put("datasets", datasets);
    return result;
}

JSP — Chart.js 초기화 + 재생성

var chartInstances = {}; // { chartId: Chart 인스턴스 }

function renderChart(chartId, type, data, options) {
    // 기존 차트 반드시 파괴 후 재생성
    if (chartInstances[chartId]) {
        chartInstances[chartId].destroy();
    }
    var ctx = document.getElementById(chartId).getContext('2d');
    chartInstances[chartId] = new Chart(ctx, {
        type   : type,
        data   : data,
        options: Object.assign({
            responsive         : true,
            maintainAspectRatio: false,
            plugins: { legend: { position: 'bottom' } }
        }, options || {})
    });
}

// 연도 변경 시 모든 차트 일괄 갱신
function loadAllCharts() {
    var year = document.getElementById('srchYear').value;
    Promise.all([
        fetch('/business/sampleNNChart01.do?year=' + year).then(r => r.json()),
        fetch('/business/sampleNNChart02.do?year=' + year).then(r => r.json()),
        // ...
    ]).then(function([data01, data02]) {
        renderChart('chart01', 'line', data01);
        renderChart('chart02', 'bar',  data02);
        // ...
    });
}

document.getElementById('srchYear').addEventListener('change', loadAllCharts);
window.addEventListener('DOMContentLoaded', loadAllCharts);
✅ 16-4. 승인처리형 화면 개발 가이드 (SAMPLE_04 패턴)

어떤 화면인가?
A 사용자가 문서를 기안하여 결재자 B에게 요청하면, B가 승인하거나 반려하는 전자결재 화면입니다. 다단계 결재선을 지원하며, 각 결재 처리 시 다음 결재자에게 메시지가 자동 발송됩니다. 결재 상태: 임시저장(DRAFT) → 결재요청(REQ) → 승인(APR) / 반려(REJ)

🖥️
실제 화면 구성 — sample04view.jsp 기준
  • 접근 URL: business/approval.do?progId=00005
  • 조회조건 패널: 접이식(sticky). 조회일자(최근 30일), 결재상태(미결재·완료), 결재대상자(본인고정), 제목·내용 검색
  • 통계 바: 전체 · 작성중 · 결재중 · 완료 · 반려 건수
  • 결재 목록: 날짜별 구분선 + 결재 카드 아이템(상태 배지, 제목, 문서유형, 요청자, 결재선 요약)
  • 슬라이드 상세/등록 패널: 사이드 패널 방식(위→아래 슬라이드). 상세 조회 · 결재 의견 입력 · 승인/반려 버튼 · 결재선 추가 · 첨부파일 확인
  • 관련 테이블: TB_PENTA_SAMPLE04_00(결재선) · _01(헤더) · _02(요청파일) · _03(이력) · _04(승인파일) · _05(의견)
  • 주요 엔드포인트: sample04List.do, sample04Detail.do, sample04Approve.do, sample04AddLine.do, sample04SaveRequest.do
⚠️
승인처리형은 여러 테이블을 동시에 수정합니다. 중간에 오류가 나면 이전 상태로 모두 되돌아가야 합니다. 이를 위해 ServiceImpl의 처리 메서드에 반드시 @Transactional을 붙여야 합니다. 파워빌더의 SQLCA.BeginTrans()/CommitTrans()/RollBack()과 같은 역할입니다.
참조(복사 원본) 파일: SAMPLE_04 — 전자결재 화면
역할원본 파일새 파일명작업
VOvo/BusinessSample04VO.javavo/BusinessSample##VO.java📋 복사 후 수정
SQLsqlmap/business/BusinessSample04_SQL.xmlsqlmap/business/BusinessSample##_SQL.xml📋 복사 후 수정
JSPjsp/business/sample04view.jspjsp/business/sample##view.jsp📋 복사 후 수정
Controller/Service/ServiceImpl/DAO기존 파일들동일 파일✏️ 추가
  • Step 1 — DB 테이블 생성 (6개 테이블)
    승인처리는 여러 테이블이 연동됩니다.
    -- 결재선 (누가 어떤 순서로 결재하는지)
    CREATE TABLE TB_PENTA_SAMPLE##_00 (
        LINE_TMPL_ID VARCHAR2(50) NOT NULL,
        LINE_SEQ     NUMBER(3)    NOT NULL,
        APPROVER_ID  VARCHAR2(50) NOT NULL,
        APPROVER_NM  VARCHAR2(100),
        REG_USER VARCHAR2(50) NOT NULL, REG_DATE DATE DEFAULT SYSDATE NOT NULL,
        UPD_USER VARCHAR2(50), UPD_DATE DATE,
        CONSTRAINT PK_TB_PENTA_SAMPLE##_00 PRIMARY KEY (LINE_TMPL_ID, LINE_SEQ)
    );
    -- 결재 요청 헤더
    CREATE TABLE TB_PENTA_SAMPLE##_01 (
        REQ_DATE  DATE          NOT NULL,  -- PK1
        REQ_TIME  VARCHAR2(6)   NOT NULL,  -- PK2: 'HH24MISS'
        REQ_USER  VARCHAR2(50)  NOT NULL,  -- PK3
        REQ_SEQ   NUMBER(5)     NOT NULL,  -- PK4
        TITLE     VARCHAR2(500) NOT NULL,
        DOC_TYPE  VARCHAR2(50),
        CONTENT   CLOB,
        STATUS_CD VARCHAR2(20)  DEFAULT 'DRAFT',
        REG_USER VARCHAR2(50) NOT NULL, REG_DATE DATE DEFAULT SYSDATE NOT NULL,
        UPD_USER VARCHAR2(50), UPD_DATE DATE,
        CONSTRAINT PK_TB_PENTA_SAMPLE##_01 PRIMARY KEY (REQ_DATE, REQ_TIME, REQ_USER, REQ_SEQ)
    );
    -- 요청 첨부파일, 결재 승인 이력, 승인 첨부파일, 의견
    -- → SAMPLE_04의 TB_PENTA_SAMPLE04_02~05 구조를 동일하게 복사하여 ##로 번호만 변경
    COMMIT;
  • Step 2 — VO 파일 생성
    Eclipse: BusinessSample04VO.java 복사 → BusinessSample##VO.java → 클래스명 변경, 업무에 맞는 필드로 교체.
    PK 필드(reqDate, reqTime, reqUser, reqSeq)는 유지하되, 업무 내용 필드(title, docType, content 등)를 내 업무에 맞게 바꿉니다.
  • Step 3 — SQL Mapper 파일 생성
    Eclipse: BusinessSample04_SQL.xml 복사 → BusinessSample##_SQL.xml → 테이블명과 id의 sample04sample##으로 전체 치환. namespace는 그대로 유지.
  • Step 4 — DAO 메서드 추가
    BusinessDAO.java 맨 끝 } 앞에:
    List<BusinessSample##VO> selectSample##List(Map<String,Object> p) throws Exception;
    BusinessSample##VO selectSample##Detail(Map<String,Object> p) throws Exception;
    List<BusinessSample##VO> selectSample##LineList(Map<String,Object> p) throws Exception;
    int insertSample##Request(Map<String,Object> p) throws Exception;
    int insertSample##Line(Map<String,Object> p) throws Exception;
    int insertSample##Approval(Map<String,Object> p) throws Exception;
    int updateSample##Status(Map<String,Object> p) throws Exception;
    Map<String,Object> selectSample##NextApprover(Map<String,Object> p) throws Exception;
  • Step 5~6 — Service 인터페이스 / ServiceImpl 추가
    DAO와 동일한 시그니처를 BusinessService.java에 추가.
    BusinessServiceImpl.java에 구현 추가. 승인 처리 메서드에는 @Transactional 필수!
    @Override
    @Transactional(rollbackFor = Exception.class)  // 이 한 줄이 트랜잭션을 보장
    public int processSample##Approve(Map<String,Object> params) throws Exception {
        businessDAO.insertSample##Approval(params);    // 1. 이력 저장
        businessDAO.updateSample##Status(params);      // 2. 요청 상태 변경
        Map<String,Object> next = businessDAO.selectSample##NextApprover(params);
        if (next != null) messageService.sendMessage(next); // 3. 다음 결재자 메시지 발송
        return 1;
        // 오류 시 위 3개 작업 모두 자동 롤백
    }
  • Step 7 — Controller 메서드 추가
    BusinessController.java 맨 끝 } 앞에 화면진입/목록조회/상세조회/결재처리 메서드 추가.
    sample04view.do 패턴을 sample##view.do로 교체. 메서드명도 sample04 → sample##으로 교체.
  • Step 8 — JSP 파일 작성
    Eclipse: sample04view.jsp 복사 → sample##view.jspCtrl+Hsample04sample##으로 전체 치환(현재 파일만) → 화면 제목·컬럼 라벨·상태코드 교체.
  • Step 9 — 메뉴 등록
    16-1 Step 9와 동일합니다. PROG_URL은 business/approval.do?progId=000## 형식.

테이블 구성 (5개)

테이블역할PK 구성
TB_PENTA_BUSINESS_SAMPLENN_01결재 요청 헤더REQUEST_DATE + REQUEST_TIME + REQUEST_USER + REQUEST_SEQ
TB_PENTA_BUSINESS_SAMPLENN_00결재선헤더PK + LINE_SEQ
TB_PENTA_BUSINESS_SAMPLENN_02요청 첨부파일FILE_ID (UUID)
TB_PENTA_BUSINESS_SAMPLENN_03결재 승인 이력헤더PK + LINE_SEQ + APPROVE_NO
TB_PENTA_BUSINESS_SAMPLENN_04결재 승인 첨부파일FILE_ID (UUID)

결재 상태 흐름

DRAFT(임시저장)
    │  [상신]
    ▼
IN_PROGRESS(결재중) ──→ 1차 결재자 IN_PROGRESS
    │  [승인]                  │  [반려]
    ▼                          ▼
2차 결재자 IN_PROGRESS    REJECTED ──→ 요청자에게 메시지
    │  [승인]
    ▼
APPROVED(완료) ──→ 요청자에게 완료 메시지

Controller — 승인/반려 처리

@PostMapping("/business/sampleNNApprove.do")
@ResponseBody
public Map<String,Object> sampleNNApprove(
        @RequestBody Map<String,Object> param, HttpSession session) {
    String loginId   = (String) session.getAttribute("userId");
    String action    = (String) param.get("action"); // APPROVED or REJECTED
    Map<String,Object> result = new HashMap<>();
    try {
        param.put("approverId", loginId);
        param.put("approveDate", new Date());

        // 1) 결재선 LINE_STATUS 갱신
        sampleNNService.updateLineStatus(param);

        // 2) 이력 테이블 삽입
        sampleNNService.insertApprovalHistory(param);

        // 3) 다음 단계 처리
        if ("APPROVED".equals(action)) {
            boolean isFinal = sampleNNService.advanceApproval(param);
            if (isFinal) {
                // 최종 승인 → 요청자에게 완료 메시지
                messageService.sendSystemMessage(
                    loginId,
                    (String)param.get("requestUser"),
                    "결재 완료",
                    "'" + param.get("title") + "' 건이 최종 승인되었습니다."
                );
            } else {
                // 다음 결재자에게 메시지
                String nextApproverId = sampleNNService.getNextApproverId(param);
                messageService.sendSystemMessage(
                    loginId, nextApproverId, "결재 요청",
                    "'" + param.get("title") + "' 건의 결재를 요청합니다."
                );
            }
        } else { // REJECTED
            sampleNNService.updateRequestStatus(param); // 헤더 REJECTED
            messageService.sendSystemMessage(
                loginId,
                (String)param.get("requestUser"),
                "결재 반려",
                "'" + param.get("title") + "' 건이 반려되었습니다."
            );
        }
        result.put("resultCode","S");
    } catch(Exception e) {
        log.error("sampleNNApprove error", e);
        result.put("resultCode","E");
        result.put("resultMsg", e.getMessage());
    }
    return result;
}

JSP — 결재선 시각화 + 승인/반려 버튼

// 결재선 렌더링 (카드 흐름)
function renderApprovalLine(lines) {
    var html = '';
    lines.forEach(function(line, idx) {
        var statusClass = { WAIT:'wait', IN_PROGRESS:'ing', APPROVED:'ok', REJECTED:'ng' }[line.lineStatus] || '';
        html += '<div class="approver-card ' + statusClass + '">'
             + '  <div class="approver-name">' + line.approverName + '</div>'
             + '  <div class="approver-title">' + line.approverDept + '/' + line.approverTitle + '</div>'
             + '  <div class="approver-status">' + getStatusLabel(line.lineStatus) + '</div>'
             + '</div>';
        if (idx < lines.length - 1) html += '<i class="fas fa-chevron-right arrow"></i>';
    });
    document.getElementById('approvalLineWrap').innerHTML = html;
}

// 승인 처리
function doApprove(action) {
    var opinion = document.getElementById('opinionInput').value;
    if (action === 'REJECTED' && !opinion.trim()) {
        Swal.fire({icon:'warning', text:'반려 시 의견을 입력해 주세요.'}); return;
    }
    Swal.fire({
        icon: action === 'APPROVED' ? 'question' : 'warning',
        title: action === 'APPROVED' ? '승인하시겠습니까?' : '반려하시겠습니까?',
        showCancelButton: true, confirmButtonText: '확인', cancelButtonText: '취소'
    }).then(function(res) {
        if (!res.isConfirmed) return;
        fetch('/business/sampleNNApprove.do', {
            method: 'POST',
            headers: {'Content-Type':'application/json'},
            body: JSON.stringify({
                action     : action,
                requestDate: currentItem.requestDate,
                requestTime: currentItem.requestTime,
                requestUser: currentItem.requestUser,
                requestSeq : currentItem.requestSeq,
                lineSeq    : currentItem.currentLineSeq,
                opinion    : opinion,
                title      : currentItem.title
            })
        }).then(r => r.json()).then(function(r) {
            if (r.resultCode === 'S') {
                Swal.fire({icon:'success', text: action==='APPROVED'?'승인되었습니다.':'반려되었습니다.',
                           timer:1200, showConfirmButton:false});
                closeDetailPanel();
                loadSampleNNList();
            } else {
                Swal.fire({icon:'error', text: r.resultMsg||'처리 실패'});
            }
        });
    });
}
📅 16-5. 자원관리형 화면 개발 가이드 (SAMPLE_05 패턴)

어떤 화면인가?
회의실·차량·장비 등 공유 자원을 특정 시간대에 예약하고, 시간 중복 예약을 방지하는 화면입니다. 자원 마스터 목록과 예약 테이블 두 개가 연동됩니다.

🖥️
실제 화면 구성 — sample05view.jsp 기준
  • 화면 제목: 자원관리 업무
  • 조회조건 패널: 접이식. 예약일자, 자원유형 select, 검색어(자원명/위치) + 조회/초기화
  • 통계 바: 자원수 · 예약수 · 내예약수 — 3항목
  • 이중 목록 구성: 상단 자원 목록(카드형 — 원형 아이콘, 자원명, 오늘예약건수, 상태배지) + 하단 예약 목록(항목형 — 시간대, 자원명, 내예약 태그, 사용자, 목적)
  • 슬라이드 상세 패널: 예약 기본정보(카드) → 사용목적(카드) → 하단 액션 버튼(취소·수정·삭제)
  • 예약 등록 패널: 별도 슬라이드. 예약일자, 자원 select, 시작~종료 시간, 사용목적 입력
  • FAB 버튼: 화면 우하단 + 버튼 → 예약 등록 슬라이드 오픈
  • 주요 함수: loadList(), renderResourceList(), renderReservationList(), saveReservation(), deleteReservation(), openDetailPanel()
참조 파일: SAMPLE_05 (자원관리 파트)
역할원본 파일새 파일명작업
VOvo/BusinessSample05VO.javavo/BusinessSample##VO.java📋 복사 후 수정
SQLsqlmap/business/BusinessSample05_SQL.xmlsqlmap/business/BusinessSample##_SQL.xml📋 복사 후 수정
JSPjsp/business/sample05view.jspjsp/business/sample##view.jsp📋 복사 후 수정
Controller/Service/ServiceImpl/DAO기존 파일들동일 파일✏️ 추가
  • Step 1 — DB 테이블 생성 (2개)
    -- 자원 마스터
    CREATE TABLE TB_PENTA_SAMPLE##_RSC (
        RSC_ID   VARCHAR2(50)  NOT NULL,
        RSC_NM   VARCHAR2(200) NOT NULL,
        RSC_TYPE VARCHAR2(20),    -- ROOM/CAR/EQUIP
        LOCATION VARCHAR2(200),
        CAPACITY NUMBER(5),
        USE_YN   CHAR(1) DEFAULT 'Y',
        REG_USER VARCHAR2(50) NOT NULL, REG_DATE DATE DEFAULT SYSDATE NOT NULL,
        UPD_USER VARCHAR2(50), UPD_DATE DATE,
        CONSTRAINT PK_TB_PENTA_SAMPLE##_RSC PRIMARY KEY (RSC_ID)
    );
    -- 예약 테이블
    CREATE TABLE TB_PENTA_SAMPLE## (
        RSV_DATE  DATE         NOT NULL,  -- PK1
        RSV_USER  VARCHAR2(50) NOT NULL,  -- PK2
        RSV_SEQ   NUMBER(5)    NOT NULL,  -- PK3 (MAX+1)
        RSC_ID    VARCHAR2(50) NOT NULL,
        START_TIME DATE NOT NULL,
        END_TIME   DATE NOT NULL,
        PURPOSE   VARCHAR2(500),
        STATUS_CD VARCHAR2(20) DEFAULT 'CONF',
        REG_USER VARCHAR2(50) NOT NULL, REG_DATE DATE DEFAULT SYSDATE NOT NULL,
        UPD_USER VARCHAR2(50), UPD_DATE DATE,
        CONSTRAINT PK_TB_PENTA_SAMPLE## PRIMARY KEY (RSV_DATE, RSV_USER, RSV_SEQ)
    );
    COMMIT;
  • Step 2~6 — VO / SQL / DAO / Service / ServiceImpl
    SAMPLE_05 원본 파일을 복사하여 sample05sample##으로 일괄 치환합니다.
    중복 예약 체크 SQL은 반드시 포함:
    <select id="checkSample##Duplicate" parameterType="map" resultType="int">
        SELECT COUNT(*) FROM TB_PENTA_SAMPLE##
        WHERE  RSC_ID = #{rscId} AND STATUS_CD = 'CONF'
          AND  NOT (END_TIME <= TO_DATE(#{startTime},'YYYY-MM-DD HH24:MI')
                 OR START_TIME >= TO_DATE(#{endTime},'YYYY-MM-DD HH24:MI'))
    </select>
  • Step 7 — Controller 메서드 추가
    BusinessController.java 맨 끝 } 앞에. 예약 저장 시 중복 체크 후 저장:
    @RequestMapping(value="/sample##RsvSave.do", method=RequestMethod.POST)
    @ResponseBody
    public Map<String,Object> sample##RsvSave(@RequestBody Map<String,Object> params, HttpSession session) throws Exception {
        Map<String,Object> result = new HashMap<>();
        try {
            UserVO u = (UserVO) session.getAttribute("userInfo");
            if (u==null) { result.put("success",false); result.put("message","세션 만료"); return result; }
            params.put("rsvUser", u.getUserId());
            int dupCnt = businessService.checkSample##Duplicate(params);
            if (dupCnt > 0) { result.put("success",false); result.put("message","해당 시간에 이미 예약이 있습니다."); return result; }
            businessService.insertSample##Rsv(params);
            result.put("success", true); result.put("message", "예약되었습니다.");
        } catch(Exception e) { log.error("sample##RsvSave",e); result.put("success",false); result.put("message","오류"); }
        return result;
    }
  • Step 8 — JSP 파일 작성 / Step 9 — 메뉴 등록
    sample05view.jsp 복사 → sample##view.jspCtrl+Hsample05sample## 일괄치환.
    메뉴 등록은 16-1 Step 9와 동일합니다.

테이블 설계

-- 자원 마스터
CREATE TABLE TB_PENTA_BUSINESS_SAMPLENN_RSC (
    RSC_ID      VARCHAR2(20)  NOT NULL,           -- PK
    RSC_NM      VARCHAR2(100) NOT NULL,
    RSC_TYPE    VARCHAR2(20),                     -- ROOM / CAR / EQUIP
    LOCATION    VARCHAR2(200),
    CAPACITY    NUMBER(5,0),
    USE_YN      CHAR(1) DEFAULT 'Y',
    CONSTRAINT PK_SAMPLENN_RSC PRIMARY KEY (RSC_ID)
);

-- 예약 테이블 (PK: 예약일자+사용자+순번)
CREATE TABLE TB_PENTA_BUSINESS_SAMPLENN (
    RSV_DATE    DATE          NOT NULL,           -- PK1
    RSV_USER    VARCHAR2(50)  NOT NULL,           -- PK2
    RSV_SEQ     NUMBER(10,0)  NOT NULL,           -- PK3 (MAX+1)
    RSC_ID      VARCHAR2(20)  NOT NULL,
    START_TIME  DATE          NOT NULL,
    END_TIME    DATE          NOT NULL,
    PURPOSE     VARCHAR2(500),
    STATUS      VARCHAR2(10)  DEFAULT 'CONFIRMED',
    REG_USER    VARCHAR2(50)  NOT NULL,
    REG_DATE    DATE          DEFAULT SYSDATE NOT NULL,
    UPD_USER    VARCHAR2(50),
    UPD_DATE    DATE,
    CONSTRAINT PK_SAMPLENN PRIMARY KEY (RSV_DATE, RSV_USER, RSV_SEQ)
);

Controller — 중복 예약 검증

@PostMapping("/business/sampleNNSaveRsv.do")
@ResponseBody
public Map<String,Object> sampleNNSaveRsv(
        @RequestBody Map<String,Object> param, HttpSession session) {
    param.put("loginUserId", session.getAttribute("userId"));
    Map<String,Object> result = new HashMap<>();
    try {
        // 중복 예약 체크
        int dupCnt = sampleNNService.checkDuplicate(param);
        if (dupCnt > 0) {
            result.put("resultCode","D");
            result.put("resultMsg","해당 시간에 이미 예약이 있습니다.");
            return result;
        }
        sampleNNService.insertReservation(param);
        result.put("resultCode","S");
    } catch(Exception e) {
        log.error("sampleNNSaveRsv error", e);
        result.put("resultCode","E");
        result.put("resultMsg", e.getMessage());
    }
    return result;
}
/* SQL — 겹치는 예약 존재 여부
   SELECT COUNT(*) FROM TB_PENTA_BUSINESS_SAMPLENN
   WHERE  RSC_ID = #{rscId}
     AND  STATUS = 'CONFIRMED'
     AND  NOT (END_TIME <= #{startTime} OR START_TIME >= #{endTime})
*/
📝 16-6. 입력유형 화면 개발 가이드 (SAMPLE_05 입력 파트)

어떤 화면인가?
텍스트·날짜·드롭다운·라디오·체크박스·별점·파일첨부 등 다양한 입력 컨트롤이 있는 등록/수정/삭제(CRUD) 폼 화면입니다. 목록에서 항목을 선택하면 수정 모드로 전환되고, 하단에 신규 등록 폼이 있습니다.

🖥️
실제 화면 구성 — sample06view.jsp 기준 (현장 접수 업무)
  • 화면 제목: 현장 접수 업무
  • 조회조건: 접이식. 시작일~종료일, 상태 select, 검색어(제목/고객명/메모)
  • 통계 바: 접수건수 · 완료 · 진행중 · 평균긴급도 — 4항목
  • 카드 리스트: 제목+날짜 / 상태·유형·방식 칩 배지 / 수량·긴급도·중요도·방문일 / 현장메모
  • 슬라이드 등록/상세 패널: 텍스트 입력, 음성입력(SpeechRecognition), 장애분류(바텀시트), 수량 스테퍼(±), 긴급도 range 슬라이더, 날짜/시간, 유형 select, 방문구분 라디오, 개인정보동의 체크박스, 고객중요도 별점(5개), 정산월(month), 처리예정주(week), datetime-local, 필요자재 체크박스
  • 특수 기능: QR/바코드 스캔 버튼, 우편번호 검색 팝업+도로명 주소, GPS 버튼+지도 미리보기, 파일선택/갤러리/사진촬영 버튼, 고객 서명 캔버스(canvas drawing)+지우기, 표시색상 color picker
  • FAB 버튼: + (신규 등록 슬라이드 오픈)
  • 주요 함수: doSearch(), loadList(), renderList(), resetForm(), fillForm(), saveForm(), deleteForm()
💡
파워빌더와 비교: DataWindow의 Detail 밴드에 입력 컨트롤을 배치하고 Insert Row / Update / Delete 버튼을 만들었던 것과 같습니다. 여기서는 JSP에 <input> 태그로 폼을 만들고, 저장 버튼이 AJAX POST로 서버에 전송합니다.
참조 파일: SAMPLE_05 — 영업활동 일지 화면
역할원본 파일새 파일명작업
VOvo/BusinessSample05VO.javavo/BusinessSample##VO.java📋 복사 후 수정
SQLsqlmap/business/BusinessSample05_SQL.xmlsqlmap/business/BusinessSample##_SQL.xml📋 복사 후 수정
JSPjsp/business/sample05view.jspjsp/business/sample##view.jsp📋 복사 후 수정
Controller/Service/ServiceImpl/DAO기존 파일들동일 파일✏️ 추가
  • Step 1 — DB 테이블 생성
    CREATE TABLE TB_PENTA_SAMPLE## (
        USER_ID  VARCHAR2(50)  NOT NULL,  -- PK1
        REG_DATE DATE          NOT NULL,  -- PK2
        REG_SEQ  NUMBER(5)     NOT NULL,  -- PK3
        FIELD_A  VARCHAR2(200),           -- 텍스트 입력
        FIELD_B  DATE,                    -- 날짜 입력
        FIELD_C  VARCHAR2(20),            -- 드롭다운 코드
        FIELD_D  NUMBER(3),               -- 숫자(별점 등)
        FIELD_E  CHAR(1),                 -- 체크박스 Y/N
        NOTE     VARCHAR2(2000),          -- 장문 텍스트
        REG_USER VARCHAR2(50) NOT NULL, UPD_USER VARCHAR2(50),
        UPD_DATE DATE,
        CONSTRAINT PK_TB_PENTA_SAMPLE## PRIMARY KEY (USER_ID, REG_DATE, REG_SEQ)
    );
    COMMIT;
  • Step 2 — VO 파일 생성
    Eclipse: BusinessSample05VO.java 복사 → BusinessSample##VO.java → 클래스명 변경, 필드 교체.
    수정/삭제 구분을 위해 PK 필드를 transient가 아닌 일반 필드로 선언합니다.
  • Step 3 — SQL Mapper 파일 생성 (INSERT/UPDATE/DELETE 포함)
    입력유형은 조회(SELECT) 외에 INSERT/UPDATE/DELETE SQL도 필요합니다.
    <mapper namespace="egovframework.penta.business.dao.BusinessDAO">
    <!-- 신규 등록 -->
    <insert id="insertSample##" parameterType="map">
        INSERT INTO TB_PENTA_SAMPLE## (USER_ID, REG_DATE, REG_SEQ, FIELD_A, FIELD_B, FIELD_C, NOTE, REG_USER)
        VALUES (#{userId}, SYSDATE,
            (SELECT NVL(MAX(REG_SEQ),0)+1 FROM TB_PENTA_SAMPLE##
             WHERE USER_ID=#{userId} AND TRUNC(REG_DATE)=TRUNC(SYSDATE)),
            #{fieldA}, TO_DATE(#{fieldBStr},'YYYY-MM-DD'), #{fieldC}, #{note}, #{userId})
    </insert>
    <!-- 수정 -->
    <update id="updateSample##" parameterType="map">
        UPDATE TB_PENTA_SAMPLE## SET FIELD_A=#{fieldA}, FIELD_B=TO_DATE(#{fieldBStr},'YYYY-MM-DD'),
        FIELD_C=#{fieldC}, NOTE=#{note}, UPD_USER=#{userId}, UPD_DATE=SYSDATE
        WHERE USER_ID=#{userId} AND REG_DATE=TO_DATE(#{regDateStr},'YYYY-MM-DD HH24:MI:SS') AND REG_SEQ=#{regSeq}
    </update>
    <!-- 삭제 -->
    <delete id="deleteSample##" parameterType="map">
        DELETE FROM TB_PENTA_SAMPLE##
        WHERE USER_ID=#{userId} AND REG_DATE=TO_DATE(#{regDateStr},'YYYY-MM-DD HH24:MI:SS') AND REG_SEQ=#{regSeq}
    </delete>
    </mapper>
  • Step 4~6 — DAO / Service / ServiceImpl 추가
    각 파일에 List/Detail/Insert/Update/Delete 메서드를 추가합니다. 메서드명은 SQL id와 동일해야 합니다.
  • Step 7 — Controller 메서드 추가 (저장/삭제 통합 패턴)
    BusinessController.java 맨 끝 } 앞에. 신규/수정을 하나의 URL로 통합:
    /** 저장 (신규/수정 통합): regDateStr 있으면 수정, 없으면 신규 */
    @RequestMapping(value="/sample##Save.do", method=RequestMethod.POST)
    @ResponseBody
    public Map<String,Object> sample##Save(@RequestBody Map<String,Object> params, HttpSession session) throws Exception {
        Map<String,Object> result = new HashMap<>();
        try {
            UserVO u = (UserVO) session.getAttribute("userInfo");
            if (u==null) { result.put("success",false); result.put("message","세션 만료"); return result; }
            params.put("userId", u.getUserId());
            String regDateStr = (String) params.get("regDateStr");
            int cnt = (regDateStr != null && !regDateStr.isEmpty())
                ? businessService.updateSample##(params)
                : businessService.insertSample##(params);
            result.put("success", cnt > 0);
            result.put("message", cnt > 0 ? "저장되었습니다." : "저장 실패");
        } catch(Exception e) { log.error("sample##Save",e); result.put("success",false); result.put("message","오류"); }
        return result;
    }
    /** 삭제 */
    @RequestMapping(value="/sample##Delete.do", method=RequestMethod.POST)
    @ResponseBody
    public Map<String,Object> sample##Delete(@RequestBody Map<String,Object> params, HttpSession session) throws Exception {
        Map<String,Object> result = new HashMap<>();
        try {
            UserVO u = (UserVO) session.getAttribute("userInfo");
            if (u==null) { result.put("success",false); return result; }
            params.put("userId", u.getUserId());
            int cnt = businessService.deleteSample##(params);
            result.put("success", cnt > 0);
            result.put("message", cnt > 0 ? "삭제되었습니다." : "삭제 실패");
        } catch(Exception e) { log.error("sample##Delete",e); result.put("success",false); result.put("message","오류"); }
        return result;
    }
  • Step 8 — JSP 파일 작성 (alert 금지, Swal.fire 사용)
    Eclipse: sample05view.jsp 복사 → sample##view.jsp → 치환 및 폼 항목 교체.
    <!-- 삭제 확인 — alert/confirm 대신 Swal.fire 사용 (프로젝트 규칙) -->
    function deleteSample() {
        Swal.fire({
            icon: 'warning', title: '삭제 확인',
            text: '선택한 항목을 삭제하시겠습니까?',
            showCancelButton: true, confirmButtonText: '삭제', cancelButtonText: '취소'
        }).then(function(r) {
            if (r.isConfirmed) {
                $.ajax({ url: '/business/sample##Delete.do', method: 'POST',
                    contentType: 'application/json',
                    data: JSON.stringify({ regDateStr: currentRegDateStr, regSeq: currentRegSeq }),
                    success: function(res) {
                        if (res.success) { Swal.fire({icon:'success', text:res.message}); loadList(); }
                        else Swal.fire({icon:'error', text:res.message});
                    }
                });
            }
        });
    }
  • Step 9 — 메뉴 등록
    16-1 Step 9와 동일합니다.

입력 컨트롤별 구현 패턴

컨트롤HTML처리
날짜<input type="date">value = YYYY-MM-DD, 서버는 TO_DATE(#{val},'YYYY-MM-DD')
시간<input type="time">value = HH:MM, 서버 VARCHAR2(5) 저장
버튼 그룹radio처럼 .active 클래스 토글hidden input으로 선택값 전달
별점클릭 가능한 ★ 5개1~5 숫자로 hidden 저장
공통 코드 드롭다운<select>페이지 진입 시 AJAX로 TB_PENTA_CODE_MST 조회
바코드 스캔텍스트 input + [스캔] 버튼PentaDevice.scanBarcode('callbackFn') 호출
주소 검색텍스트 input + [주소검색] 버튼Kakao 우편번호 API 팝업

공통 코드 로드 패턴

// 페이지 진입 시 공통코드 AJAX 조회
function loadCommonCodes(upperCodeId, selectId) {
    fetch('/common/getCodeList.do?upperCodeId=' + upperCodeId)
        .then(r => r.json())
        .then(function(codes) {
            var sel = document.getElementById(selectId);
            sel.innerHTML = '<option value="">선택</option>';
            codes.forEach(function(c) {
                sel.innerHTML += '<option value="'+c.codeId+'">'+c.codeName+'</option>';
            });
        });
}
// 사용 예: loadCommonCodes('PROC_TYPE', 'selProcType');

폼 유효성 검증 패턴 (Swal 사용)

function validateAndSave() {
    var errors = [];
    if (!document.getElementById('reqTitle').value.trim())
        errors.push('접수명을 입력해 주세요.');
    if (!document.getElementById('procType').value)
        errors.push('처리 유형을 선택해 주세요.');
    if (document.getElementById('starRating').value === '0')
        errors.push('고객 중요도를 선택해 주세요.');

    if (errors.length > 0) {
        Swal.fire({icon:'warning', title:'입력 오류',
                   html: errors.map(e => '• ' + e).join('<br>')});
        return;
    }
    saveForm();
}

function saveForm() {
    var payload = collectFormData(); // 폼 전체 직렬화
    fetch('/business/sampleNNSave.do', {
        method : 'POST',
        headers: {'Content-Type':'application/json'},
        body   : JSON.stringify(payload)
    }).then(r => r.json()).then(function(res) {
        if (res.resultCode === 'S') {
            Swal.fire({icon:'success', text:'저장되었습니다.',
                       timer:1200, showConfirmButton:false})
                .then(() => { closePanel(); loadList(); });
        } else {
            Swal.fire({icon:'error', text: res.resultMsg||'저장 실패'});
        }
    });
}
🔲 16-7. 바코드형 화면 개발 가이드 (SAMPLE_06 패턴)

어떤 화면인가?
스마트폰 카메라로 바코드/QR코드를 스캔하고 서버에서 품목 정보를 조회하거나 입고·출고를 기록하는 화면입니다. 네이티브 앱(Android/iOS)과 연동되며, PC 브라우저에서는 수동 입력으로 테스트합니다.

🖥️
실제 화면 구성 — sample07view.jsp 기준 (바코드 스캔)
  • 화면 제목: 바코드 스캔
  • 조회조건: 접이식. 스캔일(from~to), 바코드구분 select, 바코드 검색어
  • 테이블 리스트: No · 스캔일시 · 바코드구분 · 바코드번호 · 방식(배지) — 5컬럼. 헤더 정렬 아이콘. 좁은 화면에서 col-date-hide/col-mobile-hide로 컬럼 숨김
  • 슬라이드 상세 패널: 기본정보(스캔일시, 방식, 구분, 번호, 처리결과) + 바코드 저장값(상품코드, 상품명, 카테고리, 제조사, 단위, 단가, 판매가, 재고수량, 재고상태, RAW값)
  • FAB 버튼 2개: 단건 스캔 / 연속 스캔 토글(ON=초록, OFF=빨강)
  • 주요 함수: loadList(), sortBy(), openDetail(), startSingleScan(), runContinuousLoop(), submitScan()
⚠️
바코드형은 api 패키지를 사용합니다. business 패키지가 아닌 egovframework.penta.api 패키지에 파일을 추가합니다. 수정 대상 파일이 달라집니다.
참조 파일: SAMPLE_06 — 바코드 스캔 화면
역할원본 파일새 파일명작업
VOapi/vo/ApiSample06VO.javaapi/vo/ApiSample##VO.java📋 복사 후 수정
SQLsqlmap/api/Api_SQL.xml동일 파일✏️ 기존 파일에 쿼리 추가
JSPjsp/business/sample06view.jspjsp/business/sample##view.jsp📋 복사 후 수정
Controllerapi/controller/ApiController.java동일 파일✏️ 추가
Service (인터페이스)api/service/ApiService.java동일 파일✏️ 추가
Service (구현)api/service/impl/ApiServiceImpl.java동일 파일✏️ 추가
DAOapi/dao/ApiDAO.java동일 파일✏️ 추가
  • Step 1 — DB 테이블 생성
    -- 품목 마스터 (바코드로 조회할 대상)
    CREATE TABLE TB_PENTA_SAMPLE## (
        BARCODE_NO  VARCHAR2(100) NOT NULL,  -- PK
        ITEM_CD     VARCHAR2(50),
        ITEM_NM     VARCHAR2(200) NOT NULL,
        CATEGORY    VARCHAR2(50),
        UNIT        VARCHAR2(20),
        STOCK_QTY   NUMBER(12) DEFAULT 0,
        REG_USER VARCHAR2(50) NOT NULL, REG_DATE DATE DEFAULT SYSDATE NOT NULL,
        UPD_USER VARCHAR2(50), UPD_DATE DATE,
        CONSTRAINT PK_TB_PENTA_SAMPLE## PRIMARY KEY (BARCODE_NO)
    );
    -- 스캔 이력
    CREATE TABLE TB_PENTA_SAMPLE##_LOG (
        USER_ID   VARCHAR2(50) NOT NULL,  -- PK1
        SCAN_DATE DATE         NOT NULL,  -- PK2
        SCAN_SEQ  NUMBER(5)    NOT NULL,  -- PK3
        BARCODE_NO  VARCHAR2(100),
        SCAN_TYPE   VARCHAR2(20),  -- IN / OUT / CHECK
        SCAN_RESULT VARCHAR2(500),
        REG_USER VARCHAR2(50) NOT NULL, REG_DATE DATE DEFAULT SYSDATE NOT NULL,
        UPD_USER VARCHAR2(50), UPD_DATE DATE,
        CONSTRAINT PK_TB_PENTA_SAMPLE##_LOG PRIMARY KEY (USER_ID, SCAN_DATE, SCAN_SEQ)
    );
    COMMIT;
  • Step 2 — VO 파일 생성
    Eclipse: api/vo/ApiSample06VO.java 복사 → api/vo/ApiSample##VO.java → 클래스명 변경, 필드 교체.
  • Step 3 — Api_SQL.xml에 쿼리 추가
    주의: api SQL은 새 XML 파일을 만들지 않고 sqlmap/api/Api_SQL.xml에 추가합니다.
    <!-- SAMPLE_##: 바코드 조회 -->
    <select id="selectSample##ByBarcode" parameterType="map"
            resultType="egovframework.penta.api.vo.ApiSample##VO">
        SELECT * FROM TB_PENTA_SAMPLE## WHERE BARCODE_NO = #{barcodeNo}
    </select>
    <!-- 스캔 이력 저장 -->
    <insert id="insertSample##ScanLog" parameterType="map">
        INSERT INTO TB_PENTA_SAMPLE##_LOG (USER_ID, SCAN_DATE, SCAN_SEQ, BARCODE_NO, SCAN_TYPE, SCAN_RESULT, REG_USER)
        VALUES (#{userId}, SYSDATE,
            (SELECT NVL(MAX(SCAN_SEQ),0)+1 FROM TB_PENTA_SAMPLE##_LOG
             WHERE USER_ID=#{userId} AND TRUNC(SCAN_DATE)=TRUNC(SYSDATE)),
            #{barcodeNo}, #{scanType}, #{scanResult}, #{userId})
    </insert>
  • Step 4~6 — ApiDAO / ApiService / ApiServiceImpl 추가
    Eclipse: api/dao/ApiDAO.java, api/service/ApiService.java, api/service/impl/ApiServiceImpl.java 각각 열기 → 맨 끝 } 앞에 메서드 추가. (패키지만 다르고 패턴은 business와 동일)
  • Step 7 — ApiController.java 메서드 추가
    Eclipse: api/controller/ApiController.java 열기 → 맨 끝 } 앞에 추가.
    @RequestMapping(value="/api/sample##Scan.do", method=RequestMethod.POST)
    @ResponseBody
    public Map<String,Object> sample##Scan(@RequestBody Map<String,Object> params, HttpSession session) throws Exception {
        Map<String,Object> result = new HashMap<>();
        try {
            UserVO u = (UserVO) session.getAttribute("userInfo");
            if (u==null) { result.put("success",false); result.put("message","세션 만료"); return result; }
            params.put("userId", u.getUserId());
            ApiSample##VO item = apiService.selectSample##ByBarcode(params);
            apiService.insertSample##ScanLog(params);  // 스캔 이력 항상 기록
            result.put("success", true);
            result.put("found", item != null);
            result.put("data", item);
        } catch(Exception e) { log.error("sample##Scan",e); result.put("success",false); result.put("message","오류"); }
        return result;
    }
  • Step 8 — JSP 파일 작성 (네이티브 앱 연동 패턴)
    Eclipse: sample06view.jsp 복사 → sample##view.jsp → 치환.
    // 바코드 스캔 시작 (네이티브 앱 ↔ 웹 연동)
    function startScan() {
        if (window.PentaMobile && typeof window.PentaMobile.startBarcodeScan === 'function') {
            // 네이티브 앱 카메라 호출 (앱이 설치된 경우)
            window.PentaMobile.startBarcodeScan('onBarcodeResult');
        } else {
            // PC/브라우저 테스트: Swal로 수동 입력
            Swal.fire({ title:'바코드 입력(테스트)', input:'text', inputPlaceholder:'바코드 값',
                showCancelButton:true }).then(function(r) {
                if (r.isConfirmed && r.value) onBarcodeResult(r.value);
            });
        }
    }
    // 앱에서 스캔 완료 후 이 함수를 호출 (콜백 이름을 startBarcodeScan 첫 파라미터와 일치시킬 것)
    function onBarcodeResult(barcodeValue) {
        if (!barcodeValue) return;
        $.ajax({ url:'/api/sample##Scan.do', method:'POST', contentType:'application/json',
            data: JSON.stringify({ barcodeNo: barcodeValue, scanType:'CHECK' }),
            success: function(res) {
                if (!res.success) { Swal.fire({icon:'error', text:res.message}); return; }
                if (res.found) fillItemInfo(res.data);
                else Swal.fire({icon:'warning', text:'등록되지 않은 바코드입니다.'});
            }
        });
    }
  • Step 9 — 메뉴 등록
    16-1 Step 9와 동일합니다. PROG_URL은 business/sample##view.do?progId=000##

파일 구성

파일패키지/경로역할
ApiController.javaegovframework.penta.api.controller바코드 엔드포인트
ApiService.java / ApiServiceImpl.javaegovframework.penta.api.service(.impl)비즈니스 로직
ApiDAO.javaegovframework.penta.api.daoMyBatis DAO
Api_SQL.xmlsqlmap/api/바코드 CRUD SQL
sampleNNview.jspWEB-INF/jsp/business/화면

바코드 스캔 → 저장 전체 흐름

// 1) 스캔 요청
document.getElementById('btnScan').addEventListener('click', function() {
    PentaDevice.scanBarcode('onBarcodeResult');
});

// 2) 스캔 콜백 — 서버에 저장 후 목록 갱신
function onBarcodeResult(value) {
    if (!value) return;

    // 바코드 타입 자동 감지 (자리수 기준)
    var barcodeType = detectBarcodeType(value);

    fetch('/api/sampleNNScanSave.do', {
        method : 'POST',
        headers: {'Content-Type':'application/json'},
        body   : JSON.stringify({ barcodeValue: value, barcodeType: barcodeType })
    }).then(r => r.json()).then(function(res) {
        if (res.resultCode === 'S') {
            Swal.fire({icon:'success', text:'스캔 저장 완료', timer:800, showConfirmButton:false});
            loadScanList(); // 목록 재조회
        } else {
            Swal.fire({icon:'error', text: res.resultMsg||'저장 실패'});
        }
    });
}

function detectBarcodeType(value) {
    if (/^\d{13}$/.test(value)) return 'EAN13';
    if (/^\d{8}$/.test(value))  return 'EAN8';
    if (/^\d{12}$/.test(value)) return 'UPCA';
    return 'QR';
}

Controller

// ApiController.java
@PostMapping("/api/sampleNNScanSave.do")
@ResponseBody
public Map<String,Object> sampleNNScanSave(
        @RequestBody Map<String,Object> param, HttpSession session) {
    param.put("userId", session.getAttribute("userId"));
    param.put("scanDttm", new Timestamp(System.currentTimeMillis()));
    param.put("method", "CAMERA"); // CAMERA or MANUAL
    Map<String,Object> result = new HashMap<>();
    try {
        apiService.insertSampleNNScan(param);
        // 바코드로 연관 데이터 조회 (재고, 제품 정보 등)
        Map<String,Object> item = apiService.findItemByBarcode(
                (String) param.get("barcodeValue"));
        result.put("resultCode","S");
        result.put("item", item);
    } catch(Exception e) {
        log.error("sampleNNScanSave error", e);
        result.put("resultCode","E");
        result.put("resultMsg", e.getMessage());
    }
    return result;
}
📷 16-8. 사진형 화면 개발 가이드 (SAMPLE_07/10 패턴)

어떤 화면인가?
스마트폰 카메라로 사진을 찍거나 갤러리에서 선택한 후 서버에 업로드하고, 업로드된 사진을 썸네일 목록으로 표시하는 화면입니다. 사진은 공통 파일 서비스(SystemFileService)를 통해 서버에 저장됩니다.

🖥️
실제 화면 구성 — sample08view.jsp 기준 (재고조사 업무)
  • 화면 제목: 재고조사 업무
  • 조회조건: 접이식. 조사일자(from~to) + 조회/초기화
  • 스플리터 레이아웃: 상단(목록 테이블) + 하단(상세 폼)을 드래그로 높이 조정 가능 (mouse/touch 이벤트 모두 지원)
  • 상단 목록 테이블: sticky 헤더. 일자·제품정보(item-col)·수량(qty-val)·전체재고 컬럼. 정렬 아이콘. 선택 행 active 하이라이트
  • 하단 상세 폼: 바코드, 조사일자, 조사시간, 제품명, 단위, 제조사, 수량(input number), 비고(input) + 제품등록/수정 버튼 + 저장 버튼
  • FAB 버튼: 바코드 스캔 (주황색)
  • 수량 팝업: 스캔 후 수량 입력 팝업 — 스피너 + 빠른 선택 버튼(1/5/10/20)
  • 제품등록 팝업: 신규 제품 정보 등록용 별도 팝업
  • 주요 함수: loadList(), renderList(), sortBy(), loadDetail(), saveAdjust(), startScan(), promptScanQuantity()
💡
api 패키지 사용: 바코드형과 마찬가지로 egovframework.penta.api 패키지의 Controller/Service/DAO를 수정합니다.
참조 파일: SAMPLE_07 또는 SAMPLE_10 (현장 사진 화면)
역할원본 파일새 파일명작업
VOapi/vo/ApiSample06VO.javaapi/vo/ApiSample##VO.java📋 복사 후 수정
SQLsqlmap/api/Api_SQL.xml동일 파일✏️ 추가
JSPjsp/business/sample07view.jspjsp/business/sample##view.jsp📋 복사 후 수정
Controller/Service/ServiceImpl/DAOapi 패키지 파일들동일 파일✏️ 추가
  • Step 1 — DB 테이블 생성
    -- 사진 메타 테이블 (사진 자체는 서버 파일로 저장, DB엔 경로만)
    CREATE TABLE TB_PENTA_SAMPLE## (
        USER_ID   VARCHAR2(50)  NOT NULL,  -- PK1
        REG_DATE  DATE          NOT NULL,  -- PK2
        REG_SEQ   NUMBER(5)     NOT NULL,  -- PK3
        PHOTO_TITLE VARCHAR2(200),
        FILE_PATH   VARCHAR2(500),  -- 서버 저장 경로
        ORIG_NM     VARCHAR2(200),  -- 원본 파일명
        FILE_SIZE   NUMBER(15),
        REG_USER VARCHAR2(50) NOT NULL, UPD_USER VARCHAR2(50), UPD_DATE DATE,
        CONSTRAINT PK_TB_PENTA_SAMPLE## PRIMARY KEY (USER_ID, REG_DATE, REG_SEQ)
    );
    COMMIT;
  • Step 2~6 — VO / SQL / DAO / Service / ServiceImpl (api 패키지)
    바코드형(16-7) Step 2~6과 동일한 방법으로 api 패키지 파일들에 추가합니다.
  • Step 7 — ApiController에 업로드/목록/삭제 메서드 추가
    사진 업로드: multipart 요청을 처리합니다.
    @RequestMapping(value="/api/sample##PhotoUpload.do", method=RequestMethod.POST)
    @ResponseBody
    public Map<String,Object> sample##PhotoUpload(
            @RequestParam("photoFile") MultipartFile file, HttpSession session) throws Exception {
        Map<String,Object> result = new HashMap<>();
        try {
            UserVO u = (UserVO) session.getAttribute("userInfo");
            if (u==null) { result.put("success",false); result.put("message","세션 만료"); return result; }
            // 공통 파일 서비스로 서버에 저장
            String fileId = systemFileService.uploadFile(file, "sample##");
            Map<String,Object> params = new HashMap<>();
            params.put("userId", u.getUserId());
            params.put("fileId", fileId);
            params.put("origNm", file.getOriginalFilename());
            params.put("fileSize", file.getSize());
            apiService.insertSample##Photo(params);
            result.put("success", true); result.put("fileId", fileId);
        } catch(Exception e) { log.error("sample##PhotoUpload",e); result.put("success",false); result.put("message","업로드 오류"); }
        return result;
    }
  • Step 8 — JSP 파일 작성 (카메라 연동 + 썸네일)
    Eclipse: sample07view.jsp 복사 → sample##view.jsp → 치환
    // 카메라 열기 (네이티브 앱/브라우저 공통)
    function openCamera() {
        if (window.PentaMobile && typeof window.PentaMobile.openCamera === 'function') {
            window.PentaMobile.openCamera('onPhotoResult');
        } else {
            $('#photoFileInput').click();  // 브라우저: file input 클릭
        }
    }
    // 앱에서 찍은 사진을 Base64로 전달받음
    function onPhotoResult(base64Data) {
        $('#photoPreview').attr('src', 'data:image/jpeg;base64,' + base64Data);
        uploadBase64Photo(base64Data);
    }
    // 썸네일 목록 렌더링
    function renderPhotoList(list) {
        var html = '';
        list.forEach(function(item) {
            html += '<div class="photo-item" onclick="showPhotoDetail(\'' + item.fileId + '\')">'
                 + '<img src="/common/fileView.do?fileId=' + item.fileId + '"'
                 + ' onerror="this.src=\'/images/no-image.png\'">'
                 + '<div class="photo-date">' + item.regDateStr + '</div></div>';
        });
        $('#photoGrid').html(html);
    }
  • Step 9 — 메뉴 등록
    16-1 Step 9와 동일합니다.

테이블 설계 (본문 + BLOB 분리)

-- 사진 메타 테이블 (PK: 사용자+촬영일시)
CREATE TABLE TB_PENTA_BUSINESS_SAMPLENN (
    USER_ID    VARCHAR2(50)  NOT NULL,
    PHOTO_DTTM TIMESTAMP(3)  NOT NULL,
    PHOTO_SEQ  NUMBER(5,0)   NOT NULL,
    FILE_PATH  VARCHAR2(500),           -- 파일 시스템 저장 경로
    FILE_NM    VARCHAR2(255),
    FILE_SIZE  NUMBER(20,0),
    NOTE       VARCHAR2(500),
    REG_USER   VARCHAR2(50)  NOT NULL,
    REG_DATE   DATE DEFAULT SYSDATE NOT NULL,
    CONSTRAINT PK_SAMPLENN PRIMARY KEY (USER_ID, PHOTO_DTTM, PHOTO_SEQ)
);

-- BLOB 별도 테이블 (대용량 분리)
CREATE TABLE TB_PENTA_BUSINESS_SAMPLENN_FILE (
    USER_ID    VARCHAR2(50)  NOT NULL,
    PHOTO_DTTM TIMESTAMP(3)  NOT NULL,
    PHOTO_SEQ  NUMBER(5,0)   NOT NULL,
    PHOTO_DATA BLOB,
    CONSTRAINT PK_SAMPLENN_FILE PRIMARY KEY (USER_ID, PHOTO_DTTM, PHOTO_SEQ)
);

사진 촬영 → 리사이즈 → 업로드

// 촬영 버튼
document.getElementById('btnCamera').addEventListener('click', function() {
    PentaDevice.takePicture('onPhotoTaken');
});

// 촬영 콜백
function onPhotoTaken(rawBase64) {
    if (!rawBase64) return;
    // 리사이즈 후 업로드 (Section 13 참조)
    resizeBase64Image(rawBase64, 1280, function(resized) {
        uploadPhoto(resized);
    });
}

function uploadPhoto(base64DataUrl) {
    fetch('/api/sampleNNPhotoSave.do', {
        method : 'POST',
        headers: {'Content-Type':'application/json'},
        body   : JSON.stringify({ photoBase64: base64DataUrl, note: '' })
    }).then(r => r.json()).then(function(res) {
        if (res.resultCode === 'S') {
            Swal.fire({icon:'success', text:'사진이 저장되었습니다.',
                       timer:1000, showConfirmButton:false});
            loadPhotoGallery(); // 갤러리 재로드
        } else {
            Swal.fire({icon:'error', text: res.resultMsg||'저장 실패'});
        }
    });
}

// 갤러리 렌더링 (날짜 그룹 + 3열 그리드)
function loadPhotoGallery() {
    var fromDate = document.getElementById('srchFrom').value;
    fetch('/api/sampleNNPhotoList.do?fromDate=' + fromDate)
        .then(r => r.json())
        .then(function(res) {
            var grouped = groupByDate(res.list, 'photoDttm');
            var html = '';
            Object.keys(grouped).sort().reverse().forEach(function(date) {
                html += '<div class="date-group"><div class="date-label">'+date+'</div>';
                html += '<div class="photo-grid">';
                grouped[date].forEach(function(p) {
                    html += '<div class="thumb" onclick="openPhoto(\''+p.userId+'\',\''+p.photoDttm+'\','+p.photoSeq+')">'
                          + '  <img src="/api/sampleNNPhotoThumb.do?userId='+p.userId+'&photoDttm='+p.photoDttm+'&seq='+p.photoSeq+'" loading="lazy">'
                          + '  <span>'+p.timeStr+'</span>'
                          + '</div>';
                });
                html += '</div></div>';
            });
            document.getElementById('galleryArea').innerHTML = html;
        });
}
⚡ 16-9. 가동/비가동형 화면 개발 가이드 (SAMPLE_12 패턴)

어떤 화면인가?
설비 마스터와 시간대별 가동·비가동 이력을 관리하고 가동률(%)을 계산하는 화면입니다. 설비목록, 이력 추가, 가동률 차트로 구성됩니다.

⚠️
화면 구성 확인 필요
이 섹션은 SAMPLE_12 참조 화면을 기준으로 작성합니다. 개발 전에 해당 JSP 파일을 직접 열어 실제 화면 구조(조회조건, 목록, 상세 패널, 버튼 구성)를 확인한 후 아래 단계를 진행하세요. 위 sample01~08 섹션의 "실제 화면 구성" 상자가 참고 형식입니다.
참조 파일: SAMPLE_12 — api 패키지
역할원본 파일새 파일명작업
VOapi/vo/ApiSample06VO.java (범용)api/vo/ApiSample##VO.java📋 복사 후 수정
SQLsqlmap/api/Api_SQL.xml동일 파일✏️ 추가
JSPjsp/business/sample12view.jspjsp/business/sample##view.jsp📋 복사 후 수정
Controller/Service/ServiceImpl/DAOapi 패키지 파일들동일 파일✏️ 추가
  • Step 1 — DB 테이블 생성 (2개)
    -- 설비 마스터
    CREATE TABLE TB_PENTA_SAMPLE##_MST (
        EQP_ID   VARCHAR2(50)  NOT NULL,
        EQP_NM   VARCHAR2(200) NOT NULL,
        EQP_TYPE VARCHAR2(50),
        LOCATION VARCHAR2(200),
        USE_YN   CHAR(1) DEFAULT 'Y',
        REG_USER VARCHAR2(50) NOT NULL, REG_DATE DATE DEFAULT SYSDATE NOT NULL,
        UPD_USER VARCHAR2(50), UPD_DATE DATE,
        CONSTRAINT PK_TB_PENTA_SAMPLE##_MST PRIMARY KEY (EQP_ID)
    );
    -- 가동 이력
    CREATE TABLE TB_PENTA_SAMPLE## (
        EQP_ID     VARCHAR2(50) NOT NULL,  -- PK1
        START_TIME DATE         NOT NULL,  -- PK2
        STATUS_CD  VARCHAR2(10) NOT NULL,  -- RUN / STOP
        END_TIME   DATE,
        REASON     VARCHAR2(500),
        REG_USER VARCHAR2(50) NOT NULL, REG_DATE DATE DEFAULT SYSDATE NOT NULL,
        UPD_USER VARCHAR2(50), UPD_DATE DATE,
        CONSTRAINT PK_TB_PENTA_SAMPLE## PRIMARY KEY (EQP_ID, START_TIME)
    );
    COMMIT;
    가동률 계산 SQL (서비스에서 사용):
    SELECT EQP_ID,
           ROUND(SUM(CASE STATUS_CD WHEN 'RUN' THEN
               (NVL(END_TIME, SYSDATE) - START_TIME) * 24 * 60 ELSE 0 END)
               / (SUM(NVL(END_TIME,SYSDATE)-START_TIME)*24*60) * 100, 1) AS RUN_RATE
    FROM TB_PENTA_SAMPLE## WHERE TRUNC(START_TIME) = TRUNC(SYSDATE) GROUP BY EQP_ID
  • Step 2~9 — VO / SQL / DAO / Service / ServiceImpl / Controller / JSP / 메뉴 등록
    바코드형(16-7) 패턴과 동일합니다. api 패키지 파일들에 추가하고, sample12view.jsp를 복사하여 sample##view.jsp로 만든 후 Ctrl+Hsample12sample## 치환합니다.

테이블 설계

-- 설비 마스터
CREATE TABLE TB_PENTA_BUSINESS_SAMPLENN_EQP (
    EQP_ID    VARCHAR2(20)  NOT NULL,
    EQP_NM    VARCHAR2(100) NOT NULL,
    EQP_CODE  VARCHAR2(20),
    USE_YN    CHAR(1) DEFAULT 'Y',
    CONSTRAINT PK_SAMPLENN_EQP PRIMARY KEY (EQP_ID)
);

-- 가동/비가동 이력 (PK: 설비+날짜+시작시각)
CREATE TABLE TB_PENTA_BUSINESS_SAMPLENN (
    EQP_ID      VARCHAR2(20)  NOT NULL,       -- PK1
    LOG_DATE    DATE          NOT NULL,        -- PK2
    START_TIME  DATE          NOT NULL,        -- PK3
    STATUS      VARCHAR2(10)  NOT NULL,        -- RUN(가동) / STOP(비가동)
    END_TIME    DATE,
    REASON      VARCHAR2(500),
    NOTE        VARCHAR2(500),
    PHOTO_CNT   NUMBER(5,0)   DEFAULT 0,
    REG_USER    VARCHAR2(50)  NOT NULL,
    REG_DATE    DATE DEFAULT SYSDATE NOT NULL,
    UPD_USER    VARCHAR2(50),
    UPD_DATE    DATE,
    CONSTRAINT PK_SAMPLENN PRIMARY KEY (EQP_ID, LOG_DATE, START_TIME)
);

Controller — 가동률 통계 계산

@GetMapping("/api/sampleNNStatSummary.do")
@ResponseBody
public Map<String,Object> sampleNNStatSummary(@RequestParam Map<String,Object> param) {
    List<Map<String,Object>> rawList = apiService.selectSampleNNList(param);

    long totalMinutes = 0, runMinutes = 0;
    for (Map<String,Object> row : rawList) {
        Date start = (Date) row.get("startTime");
        Date end   = (Date) row.get("endTime");
        if (start != null && end != null) {
            long mins = (end.getTime() - start.getTime()) / 60000;
            totalMinutes += mins;
            if ("RUN".equals(row.get("status"))) runMinutes += mins;
        }
    }
    double runRate = totalMinutes > 0
                   ? Math.round(runMinutes * 1000.0 / totalMinutes) / 10.0
                   : 0.0;

    Map<String,Object> result = new HashMap<>();
    result.put("runRate",   runRate);
    result.put("runCnt",    rawList.stream().filter(r -> "RUN".equals(r.get("status"))).count());
    result.put("stopCnt",   rawList.stream().filter(r -> "STOP".equals(r.get("status"))).count());
    result.put("list",      rawList);
    return result;
}

JSP — 설비별 그룹 + 상태 아이콘

function renderEqpGroup(eqpId, eqpNm, logs) {
    var runCnt  = logs.filter(l => l.status === 'RUN').length;
    var stopCnt = logs.filter(l => l.status === 'STOP').length;

    var html = '<div class="eqp-group">'
        + '<div class="eqp-header">'
        + '  <span class="eqp-nm">' + eqpNm + '</span>'
        + '  <span class="badge run">가동 ' + runCnt + '</span>'
        + '  <span class="badge stop">비가동 ' + stopCnt + '</span>'
        + '</div>';

    logs.forEach(function(log) {
        var icon  = log.status === 'RUN' ? '▶' : '⏸';
        var cls   = log.status === 'RUN' ? 'run' : 'stop';
        html += '<div class="log-item ' + cls + '" onclick="openLogDetail(\'' + log.eqpId + '\',\'' + log.startTime + '\')">'
             + '  <span class="log-icon">' + icon + '</span>'
             + '  <span class="log-time">' + formatTime(log.startTime) + ' ~ ' + formatTime(log.endTime) + '</span>'
             + '  <span class="log-reason">' + (log.reason||'') + '</span>'
             + '</div>';
    });
    html += '</div>';
    return html;
}
🗺️ 16-10. 지도형 화면 개발 가이드 (SAMPLE_08/13 패턴)

어떤 화면인가?
GPS로 현재 위치를 파악하고 지도에 마커를 표시하거나, 특정 위치를 지도에서 선택해 저장하는 화면입니다. Leaflet.js + OpenStreetMap을 사용하므로 별도의 API 키가 필요 없어 내부망 환경에 적합합니다.

⚠️
화면 구성 확인 필요
이 섹션은 SAMPLE_08/13 참조 화면을 기준으로 작성합니다. 해당 JSP 파일을 직접 열어 실제 지도 컴포넌트, 마커 렌더링 방식, 목록 구성을 확인한 후 아래 단계를 진행하세요.
💡
api 패키지 사용: 지도형도 egovframework.penta.api 패키지를 사용합니다. GPS는 브라우저 표준 API(navigator.geolocation)로 취득하므로 별도 앱 연동이 없습니다.
참조 파일: SAMPLE_08 또는 SAMPLE_13 (지도 화면)
역할원본 파일새 파일명작업
VOapi/vo/ApiSample06VO.javaapi/vo/ApiSample##VO.java📋 복사 후 수정
SQLsqlmap/api/Api_SQL.xml동일 파일✏️ 추가
JSPjsp/business/sample08view.jsp 또는 sample13view.jspjsp/business/sample##view.jsp📋 복사 후 수정
Controller/Service/ServiceImpl/DAOapi 패키지동일 파일✏️ 추가
  • Step 1 — DB 테이블 생성 (위도/경도 컬럼 포함)
    CREATE TABLE TB_PENTA_SAMPLE## (
        USER_ID  VARCHAR2(50) NOT NULL,  -- PK1
        REG_DATE DATE         NOT NULL,  -- PK2
        REG_SEQ  NUMBER(5)    NOT NULL,  -- PK3
        CUST_NM  VARCHAR2(200),
        ADDRESS  VARCHAR2(500),
        LAT      NUMBER(10,7),  -- 위도 (예: 37.5665174)
        LNG      NUMBER(10,7),  -- 경도 (예: 126.9779692)
        VISIT_MEMO VARCHAR2(1000),
        REG_USER VARCHAR2(50) NOT NULL, REG_DATE_LOG DATE DEFAULT SYSDATE NOT NULL,
        UPD_USER VARCHAR2(50), UPD_DATE DATE,
        CONSTRAINT PK_TB_PENTA_SAMPLE## PRIMARY KEY (USER_ID, REG_DATE, REG_SEQ)
    );
    COMMIT;
  • Step 2~6 — VO / SQL / DAO / Service / ServiceImpl
    바코드형(16-7) Step 2~6과 동일하게 api 패키지 파일들에 추가합니다.
  • Step 7 — ApiController 메서드 추가
    바코드형 패턴과 동일한 방식으로 목록조회·상세조회·저장 메서드를 추가합니다.
  • Step 8 — JSP 파일 작성 (GPS + Leaflet 지도)
    Eclipse: sample08view.jsp 복사 → sample##view.jsp → 치환
    // ① GPS 위치 취득
    function getCurrentLocation(callback) {
        if (!navigator.geolocation) {
            Swal.fire({icon:'error', text:'이 브라우저는 GPS를 지원하지 않습니다.'}); return;
        }
        navigator.geolocation.getCurrentPosition(
            function(pos) { callback(pos.coords.latitude, pos.coords.longitude); },
            function(err) {
                console.warn('GPS 오류:', err.message);
                callback(37.5665, 126.9780);  // 실패 시 서울 시청 기본값
            },
            { enableHighAccuracy:true, timeout:10000, maximumAge:60000 }
        );
    }
    
    // ② Leaflet 지도 초기화
    var map = null;
    function initMap(lat, lng) {
        if (map) { map.remove(); }  // 기존 지도 파괴 후 재생성
        map = L.map('mapContainer').setView([lat, lng], 14);
        L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
            attribution: '© OpenStreetMap'
        }).addTo(map);
    }
    
    // ③ 목록 조회 후 마커 표시
    function plotMarkers(list) {
        list.forEach(function(item) {
            if (!item.lat || !item.lng) return;
            var marker = L.marker([item.lat, item.lng]).addTo(map);
            marker.bindPopup('<b>' + item.custNm + '</b><br>' + item.address);
            marker.on('click', function() { showDetail(item); });
        });
    }
  • Step 9 — 메뉴 등록
    16-1 Step 9와 동일합니다.

테이블 설계

CREATE TABLE TB_PENTA_BUSINESS_SAMPLENN (
    USER_ID    VARCHAR2(50)  NOT NULL,
    GPS_DTTM   TIMESTAMP(3)  NOT NULL,
    GPS_SEQ    NUMBER(5,0)   NOT NULL,
    GPS_LAT    NUMBER(12,8),           -- 위도 (37.56800000)
    GPS_LNG    NUMBER(12,8),           -- 경도 (126.97800000)
    GPS_ACC    NUMBER(10,2),           -- 정확도 (미터)
    GPS_ADDR   VARCHAR2(500),          -- 역지오코딩 주소
    NOTE       VARCHAR2(500),
    REG_USER   VARCHAR2(50)  NOT NULL,
    REG_DATE   DATE DEFAULT SYSDATE NOT NULL,
    UPD_USER   VARCHAR2(50),
    UPD_DATE   DATE,
    CONSTRAINT PK_SAMPLENN PRIMARY KEY (USER_ID, GPS_DTTM, GPS_SEQ)
);

JSP — Leaflet 초기화 + GPS 취득 + 저장

var leafletMap, leafletMarker;
var currentGPS = {};

// 지도 초기화 (DOMContentLoaded)
function initMap() {
    leafletMap = L.map('mapContainer').setView([37.5665, 126.9780], 14);
    L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
        attribution: '© OpenStreetMap contributors'
    }).addTo(leafletMap);
    leafletMarker = L.marker([37.5665, 126.9780]).addTo(leafletMap);
}

// GPS 취득 버튼
document.getElementById('btnGetGPS').addEventListener('click', function() {
    PentaDevice.getLocation('onGPSReceived');
});

// GPS 콜백 — 지도 이동 + 저장
function onGPSReceived(lat, lng, accuracy) {
    lat = parseFloat(lat); lng = parseFloat(lng);
    currentGPS = { lat: lat, lng: lng, acc: accuracy || 0 };

    // 지도 업데이트
    leafletMap.setView([lat, lng], 16);
    leafletMarker.setLatLng([lat, lng]);

    // 화면 표시
    document.getElementById('dispLat').textContent = lat.toFixed(6);
    document.getElementById('dispLng').textContent = lng.toFixed(6);
    document.getElementById('dispAcc').textContent = (accuracy || 0).toFixed(1) + 'm';
    document.getElementById('dispUpd').textContent = new Date().toLocaleString('ko-KR');

    // 서버 저장
    saveGPS(lat, lng, accuracy);
}

function saveGPS(lat, lng, acc) {
    fetch('/api/sampleNNGpsSave.do', {
        method : 'POST',
        headers: {'Content-Type':'application/json'},
        body   : JSON.stringify({ gpsLat: lat, gpsLng: lng, gpsAcc: acc,
                                   note: document.getElementById('noteInput').value||'' })
    }).then(r => r.json()).then(function(res) {
        if (res.resultCode !== 'S') {
            Swal.fire({icon:'error', text: res.resultMsg||'위치 저장 실패'});
        }
    });
}

Leaflet 라이브러리 JSP 포함

<!-- JSP head: CDN 또는 내부 서버 호스팅 -->
<link  rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>

<!-- 지도 컨테이너 (높이 고정 필수) -->
<div id="mapContainer" style="width:100%;height:300px;border-radius:8px;"></div>
📍 16-11. 현장방문형 화면 개발 가이드 (SAMPLE_14 패턴)

어떤 화면인가?
GPS로 현재 위치를 취득하고, Nominatim 역지오코딩으로 주소를 자동으로 채운 뒤 방문 내용을 기록하는 화면입니다. 저장된 방문 이력은 날짜별 카드 목록으로 표시되며, 각 항목을 누르면 Leaflet 지도와 함께 상세 슬라이드 패널이 열립니다. 사진 첨부도 지원합니다.

💡
참조 화면: sample14view.jsp/business/sample14view.do?progId=00018
GPS + 역지오코딩 + Leaflet 지도를 모두 포함하는 가장 복합적인 패턴입니다. 지도형(16-10)에 방문 입력 폼과 사진 업로드가 추가된 형태입니다.
참조 파일: SAMPLE_14 (현장방문)
역할원본 파일새 파일명작업
VOapi/vo/ApiSample14VO.javaapi/vo/ApiSample##VO.java📋 복사 후 수정
SQLsqlmap/api/Api_SQL.xml동일 파일✏️ 추가
JSPjsp/business/sample14view.jspjsp/business/sample##view.jsp📋 복사 후 수정
Controller/Service/ServiceImpl/DAOapi 패키지동일 파일✏️ 추가
  • Step 1 — DB 테이블 생성
    CREATE TABLE TB_PENTA_SAMPLE## (
        SEQ        NUMBER        NOT NULL,   -- PK (시퀀스)
        USER_ID    VARCHAR2(50)  NOT NULL,   -- 방문자 ID
        VISIT_NM   VARCHAR2(200),            -- 방문명
        ADDRESS    VARCHAR2(500),            -- 방문 주소 (역지오코딩 결과)
        LAT        NUMBER(12,8),             -- 위도
        LNG        NUMBER(12,8),             -- 경도
        GPS_ACC    NUMBER(10,2),             -- GPS 정확도 (미터)
        VISIT_MEMO VARCHAR2(1000),           -- 방문 내용
        REMARK     VARCHAR2(500),            -- 비고
        PHOTO_FILE_ID VARCHAR2(100),         -- 첨부 사진 파일 ID
        REG_USER   VARCHAR2(50) NOT NULL,
        REG_DATE   DATE DEFAULT SYSDATE NOT NULL,
        UPD_USER   VARCHAR2(50),
        UPD_DATE   DATE,
        CONSTRAINT PK_TB_PENTA_SAMPLE## PRIMARY KEY (SEQ)
    );
    CREATE SEQUENCE SEQ_TB_PENTA_SAMPLE## START WITH 1 INCREMENT BY 1;
    COMMIT;
  • Step 2~6 — VO / SQL / DAO / Service / ServiceImpl
    지도형(16-10) Step 2~6과 동일 방식으로 api 패키지에 추가합니다. VO 필드에 lat, lng, gpsAcc, address, photoFileId 포함.
  • Step 7 — ApiController 메서드 추가
    // 목록 조회
    @RequestMapping("/api/sample##List.do")
    public ModelAndView sample##List(ApiSample##VO vo, HttpSession session) { ... }
    
    // 저장 (GPS + 역지오코딩 결과 포함)
    @RequestMapping("/api/sample##Save.do")
    @ResponseBody
    public Map<String, Object> sample##Save(@RequestBody ApiSample##VO vo) { ... }
  • Step 8 — JSP 파일 작성 (GPS + 역지오코딩 + Leaflet)
    Eclipse: sample14view.jsp 복사 → sample##view.jsp → 치환
    // ① GPS 취득 + 역지오코딩 (PentaNative 사용, 앱/웹 공통)
    function getLocationAndFill() {
        PentaNative.getCurrentLocation()
            .then(function(loc) {
                var lat = loc.latitude, lng = loc.longitude, acc = loc.accuracy;
                // 폼에 좌표 저장
                $('#hidLat').val(lat); $('#hidLng').val(lng); $('#hidAcc').val(acc);
                // 지도 업데이트
                moveMapMarker(lat, lng);
                // 역지오코딩 (Nominatim): 좌표 → 주소
                return fetch('https://nominatim.openstreetmap.org/reverse?format=json&lat='
                             + lat + '&lng=' + lng + '&accept-language=ko');
            })
            .then(function(r) { return r.json(); })
            .then(function(geo) {
                var addr = geo.display_name || '';
                $('#inputAddress').val(addr);
            })
            .catch(function(err) {
                Swal.fire({icon:'warning', text:'위치 취득 실패: ' + err});
            });
    }
    
    // ② Leaflet 지도 초기화 / 마커 이동
    var leafletMap, leafletMarker;
    function initMap(lat, lng) {
        leafletMap = L.map('mapContainer').setView([lat, lng], 16);
        L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
            attribution:'© OpenStreetMap contributors'
        }).addTo(leafletMap);
        leafletMarker = L.marker([lat, lng]).addTo(leafletMap);
    }
    function moveMapMarker(lat, lng) {
        if (leafletMap) {
            leafletMap.setView([lat, lng], 16); leafletMarker.setLatLng([lat, lng]);
        } else { initMap(lat, lng); }
    }
    
    // ③ 방문 저장
    function saveVisit() {
        var data = {
            visitNm  : $('#inputVisitNm').val(),
            address  : $('#inputAddress').val(),
            lat      : $('#hidLat').val(),
            lng      : $('#hidLng').val(),
            gpsAcc   : $('#hidAcc').val(),
            visitMemo: $('#inputMemo').val()
        };
        $.ajax({
            url: '/HandsOn/api/sample##Save.do',
            type:'POST', contentType:'application/json',
            data: JSON.stringify(data),
            success: function(res) {
                if (res.resultCode === 'S') {
                    Swal.fire({icon:'success', title:'저장 완료', timer:1200});
                    loadList();
                }
            }
        });
    }
    💡
    Leaflet CSS/JS 로드:
    <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
    <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
  • Step 9 — 메뉴 등록 (TB_PENTA_PROGRAM)
    기존 샘플 등록 방식과 동일. 업무화면(00002) 하위에 신규 PROG_ID 채번 후 INSERT.
📋 16-12. 설문조사형 화면 개발 가이드 (SAMPLE_15 패턴)

어떤 화면인가?
진행 중인 설문 목록을 카드로 보여주고, 라디오버튼으로 즉시 투표할 수 있는 화면입니다. 이미 투표한 설문은 도넛형 파이 차트로 결과를 시각화합니다. 설문·선택지·투표 결과를 3개 테이블로 관리합니다.

💡
참조 화면: sample15view.jsp/business/sample15view.do?progId=00019
Chart.js로 도넛 차트를 렌더링하며, 투표 여부를 서버에서 판단해 같은 화면에서 투표 폼과 결과 차트를 전환합니다.
DB 구조 (3개 테이블)
테이블역할주요 컬럼
TB_PENTA_SAMPLE## 설문 마스터 SURVEY_ID(PK), SURVEY_TITLE, SURVEY_DESC, START_DT, END_DT, STATUS(진행중/종료)
TB_PENTA_SAMPLE##_OPT 선택지 SURVEY_ID(FK), OPT_SEQ, OPT_TEXT
TB_PENTA_SAMPLE##_VOTE 투표 결과 SURVEY_ID(FK), OPT_SEQ(FK), USER_ID, VOTE_DATE
  • Step 1 — DB 테이블 3개 생성
    -- 설문 마스터
    CREATE TABLE TB_PENTA_SAMPLE## (
        SURVEY_ID    NUMBER        NOT NULL,
        SURVEY_TITLE VARCHAR2(300) NOT NULL,
        SURVEY_DESC  VARCHAR2(1000),
        START_DT     DATE          NOT NULL,
        END_DT       DATE          NOT NULL,
        STATUS       VARCHAR2(10)  DEFAULT '진행중',  -- 진행중 / 종료
        REG_USER     VARCHAR2(50)  NOT NULL,
        REG_DATE     DATE          DEFAULT SYSDATE,
        CONSTRAINT PK_TB_PENTA_SAMPLE## PRIMARY KEY (SURVEY_ID)
    );
    -- 선택지
    CREATE TABLE TB_PENTA_SAMPLE##_OPT (
        SURVEY_ID NUMBER       NOT NULL,
        OPT_SEQ   NUMBER(3)    NOT NULL,
        OPT_TEXT  VARCHAR2(300) NOT NULL,
        CONSTRAINT PK_TB_PENTA_SAMPLE##_OPT PRIMARY KEY (SURVEY_ID, OPT_SEQ)
    );
    -- 투표 결과
    CREATE TABLE TB_PENTA_SAMPLE##_VOTE (
        SURVEY_ID NUMBER      NOT NULL,
        OPT_SEQ   NUMBER(3)   NOT NULL,
        USER_ID   VARCHAR2(50) NOT NULL,
        VOTE_DATE DATE DEFAULT SYSDATE,
        CONSTRAINT PK_TB_PENTA_SAMPLE##_VOTE PRIMARY KEY (SURVEY_ID, USER_ID)
    );
    CREATE SEQUENCE SEQ_TB_PENTA_SAMPLE## START WITH 1 INCREMENT BY 1;
    COMMIT;
  • Step 2~6 — VO / SQL / DAO / Service / ServiceImpl
    api 패키지에 설문 목록 조회, 투표 여부 확인, 투표 저장, 결과 집계용 SQL을 추가합니다.
    VO에는 surveyId, optSeq, isVoted(boolean), 결과 목록(List<SurveyResultVO>) 포함.
  • Step 7 — ApiController 메서드 추가
    // 설문 목록 + 투표 여부 조회 (로그인 사용자 기준)
    @RequestMapping("/api/sample##List.do")
    public ModelAndView sample##List(HttpSession session) { ... }
    
    // 투표 처리
    @RequestMapping("/api/sample##Vote.do")
    @ResponseBody
    public Map<String, Object> sample##Vote(@RequestBody Map<String,Object> param) { ... }
    
    // 결과 집계 조회
    @RequestMapping("/api/sample##Result.do")
    @ResponseBody
    public Map<String, Object> sample##Result(@RequestParam Long surveyId) { ... }
  • Step 8 — JSP 파일 작성 (투표 폼 + 도넛 차트)
    Eclipse: sample15view.jsp 복사 → sample##view.jsp → 치환
    // ① 설문 목록 렌더링 (투표 여부에 따라 폼 / 차트 전환)
    function renderSurveyList(surveys) {
        surveys.forEach(function(s) {
            var isVoted   = s.isVoted;           // true: 투표 완료 → 차트 표시
            var isExpired = s.status === '종료'; // true: 종료 → 차트만 표시
    
            var cardHtml = isVoted || isExpired
                ? buildResultCard(s)   // 도넛 차트 카드
                : buildVoteCard(s);    // 라디오버튼 투표 카드
            $('#surveyList').append(cardHtml);
        });
        renderCharts();  // Chart.js 도넛 차트 초기화
    }
    
    // ② 투표 처리
    function doVote(surveyId) {
        var optSeq = $('input[name="opt_' + surveyId + '"]:checked').val();
        if (!optSeq) { Swal.fire({icon:'warning', text:'선택지를 선택해주세요.'}); return; }
        $.ajax({
            url:'/HandsOn/api/sample##Vote.do', type:'POST',
            contentType:'application/json',
            data: JSON.stringify({ surveyId: surveyId, optSeq: optSeq }),
            success: function(res) {
                if (res.resultCode === 'S') {
                    Swal.fire({icon:'success', title:'투표 완료!', timer:1200,
                               showConfirmButton:false});
                    loadSurveyList();  // 목록 새로고침 → 결과 차트로 전환
                }
            }
        });
    }
    
    // ③ Chart.js 도넛 차트 렌더링
    function renderCharts() {
        $('.survey-chart').each(function() {
            var canvas   = this;
            var surveyId = $(canvas).data('survey-id');
            var labels   = $(canvas).data('labels').split('|');
            var counts   = $(canvas).data('counts').split('|').map(Number);
            new Chart(canvas, {
                type: 'doughnut',
                data: {
                    labels  : labels,
                    datasets: [{ data: counts,
                        backgroundColor:['#e67e22','#1abc9c','#3498db','#e74c3c'] }]
                },
                options: { plugins:{ legend:{ position:'bottom' } } }
            });
        });
    }
    💡
    Chart.js CDN 로드:
    <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
  • Step 9 — 메뉴 등록 (TB_PENTA_PROGRAM)
    기존 샘플 등록 방식과 동일. 업무화면(00002) 하위에 신규 PROG_ID 채번 후 INSERT.
🤖 16-13. AI 챗봇형 화면 개발 가이드 (AI 챗봇 패턴)

어떤 화면인가?
외부 AI API(Google Gemini) 또는 내부망 AI 서버(Ollama)와 연동하여 채팅 인터페이스를 제공하는 화면입니다. 보안을 위해 AI API 키는 서버(globals.properties)에만 보관하고, 브라우저는 Spring Controller를 통해 간접적으로 AI에 접근합니다.

⚠️
화면 구성 확인 필요
AI 챗봇 화면은 별도 JSP 파일로 구현됩니다. 해당 JSP 파일을 직접 열어 채팅 UI 구성(말풍선 렌더링 방식, 입력 바, API 엔드포인트 연동 방식)을 확인한 후 이 가이드의 코드를 참고하세요.
💡
파워빌더와 비교: 파워빌더에서 외부 서버와 HTTP 통신하던 것과 같습니다. 여기서는 Spring Controller가 AI 서버에 HTTP 요청을 보내고 응답을 받아 브라우저에 전달합니다.
참조 파일: AI 챗봇 화면 — api 패키지
역할파일작업
JSPjsp/business/aiview.jsp (원본)📋 복사 후 수정
Controllerapi/controller/ApiController.java✏️ 추가
설정globals.properties✏️ API Key/URL 추가
  • Step 1 — globals.properties에 AI 설정 추가
    Eclipse: src/main/resources/globals.properties 열기 → 맨 끝에 추가
    # AI 설정 (Gemini)
    Globals.geminiApiKey=AIzaSy...여기에API키입력
    Globals.geminiApiUrl=https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent
    
    # AI 설정 (내부망 Ollama)
    Globals.ollamaApiUrl=http://192.168.100.50:11434/api/generate
    Globals.ollamaModel=gemma2
  • Step 2 — ApiController.java에 AI 프록시 메서드 추가
    Eclipse: ApiController.java 열기 → 클래스 상단 필드에 AI 설정 주입, 맨 끝 } 앞에 메서드 추가
    // 클래스 상단 @Value로 properties 주입
    @Value("${Globals.geminiApiKey}")   private String geminiApiKey;
    @Value("${Globals.geminiApiUrl}")   private String geminiApiUrl;
    @Value("${Globals.ollamaApiUrl:}")  private String ollamaApiUrl;
    @Value("${Globals.ollamaModel:gemma2}") private String ollamaModel;
    
    /** Gemini AI 챗봇 */
    @RequestMapping(value="/api/geminiChat.do", method=RequestMethod.POST)
    @ResponseBody
    public Map<String,Object> geminiChat(@RequestBody Map<String,Object> param) throws Exception {
        String userMsg = (String) param.get("message");
        Map<String,Object> result = new HashMap<>();
        try {
            // Gemini API 요청 본문 구성
            String body = "{\"contents\":[{\"parts\":[{\"text\":\""
                + userMsg.replace("\"","\\\"") + "\"}]}]}";
            URL url = new URL(geminiApiUrl + "?key=" + geminiApiKey);
            HttpURLConnection conn = (HttpURLConnection) url.openConnection();
            conn.setRequestMethod("POST");
            conn.setRequestProperty("Content-Type", "application/json");
            conn.setDoOutput(true);
            conn.getOutputStream().write(body.getBytes("UTF-8"));
            String response = new String(conn.getInputStream().readAllBytes(), "UTF-8");
            // JSON에서 text 추출 (간단 파싱)
            String aiMsg = extractGeminiText(response);
            result.put("resultCode", "S"); result.put("aiMessage", aiMsg);
        } catch(Exception e) { log.error("geminiChat error",e); result.put("resultCode","E"); result.put("resultMsg","AI 서버 오류"); }
        return result;
    }
    
    private String extractGeminiText(String json) {
        int idx = json.indexOf("\"text\":");
        if (idx < 0) return "응답 없음";
        int start = json.indexOf('"', idx+7) + 1;
        int end   = json.indexOf('"', start);
        return json.substring(start, end).replace("\\n", "\n");
    }
  • Step 3 — JSP 파일 작성 (채팅 UI)
    Eclipse: 기존 AI 화면 JSP 복사 → 새 이름으로 붙여넣기 → 치환
    // 메시지 전송
    function sendMessage() {
        var msg = $('#chatInput').val().trim();
        if (!msg) return;
        appendBubble('user', msg);
        $('#chatInput').val('');
        $.ajax({ url:'/api/geminiChat.do', method:'POST',
            contentType:'application/json',
            data: JSON.stringify({ message: msg }),
            success: function(res) {
                if (res.resultCode === 'S') appendBubble('ai', res.aiMessage);
                else appendBubble('ai', '오류: ' + res.resultMsg);
            }
        });
    }
    $('#chatInput').on('keydown', function(e) {
        if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
    });
  • Step 4 — 메뉴 등록
    16-1 Step 9와 동일합니다.

파일 구성

파일역할
AiChatController.java채팅 요청 수신 → AI API 호출 → 응답 반환
AiChatService.java / ImplGemini/Ollama API 호출 비즈니스 로직
globals.propertiesAPI Key, Ollama 서버 URL 설정
geminiChatView.jsp / ollamaChatView.jsp채팅 UI (공통 구조 동일)

globals.properties 설정

# Gemini AI
gemini.api.key=AIza...your_key_here
gemini.api.url=https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent

# Ollama On-premise
ollama.api.url=http://localhost:11434/api/generate
ollama.model=llama3

Controller — Gemini 프록시

@PostMapping("/api/geminiChat.do")
@ResponseBody
public Map<String,Object> geminiChat(
        @RequestBody Map<String,Object> param) throws IOException {
    String userMsg = (String) param.get("message");
    Map<String,Object> result = new HashMap<>();
    try {
        // Gemini API 요청 JSON 구성
        String requestBody = "{"
            + "\"contents\":[{\"parts\":[{\"text\":\"" + escapeJson(userMsg) + "\"}]}]"
            + "}";

        URL url = new URL(geminiApiUrl + "?key=" + geminiApiKey);
        HttpURLConnection conn = (HttpURLConnection) url.openConnection();
        conn.setRequestMethod("POST");
        conn.setRequestProperty("Content-Type","application/json");
        conn.setDoOutput(true);
        conn.getOutputStream().write(requestBody.getBytes(StandardCharsets.UTF_8));

        // 응답 파싱
        String responseStr = new String(conn.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
        JSONObject json     = new JSONObject(responseStr);
        String aiText       = json.getJSONArray("candidates")
                                  .getJSONObject(0)
                                  .getJSONObject("content")
                                  .getJSONArray("parts")
                                  .getJSONObject(0)
                                  .getString("text");
        result.put("resultCode","S");
        result.put("aiMessage", aiText);
    } catch(Exception e) {
        log.error("geminiChat error", e);
        result.put("resultCode","E");
        result.put("resultMsg","AI 응답 오류: " + e.getMessage());
    }
    return result;
}

// Ollama 버전
@PostMapping("/api/ollamaChat.do")
@ResponseBody
public Map<String,Object> ollamaChat(
        @RequestBody Map<String,Object> param) throws IOException {
    String userMsg = (String) param.get("message");
    Map<String,Object> result = new HashMap<>();
    try {
        String requestBody = "{\"model\":\"" + ollamaModel + "\","
            + "\"prompt\":\"" + escapeJson(userMsg) + "\","
            + "\"stream\":false}";

        URL url = new URL(ollamaApiUrl);
        HttpURLConnection conn = (HttpURLConnection) url.openConnection();
        conn.setRequestMethod("POST");
        conn.setRequestProperty("Content-Type","application/json");
        conn.setDoOutput(true);
        conn.getOutputStream().write(requestBody.getBytes(StandardCharsets.UTF_8));

        String responseStr = new String(conn.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
        JSONObject json = new JSONObject(responseStr);
        result.put("resultCode","S");
        result.put("aiMessage", json.getString("response"));
    } catch(Exception e) {
        log.error("ollamaChat error", e);
        result.put("resultCode","E");
        result.put("resultMsg","AI 응답 오류: " + e.getMessage());
    }
    return result;
}

JSP — 채팅 UI 공통 패턴

var chatHistory = []; // { role:'user'|'ai', text:'...' }

// 메시지 전송
function sendMessage() {
    var input = document.getElementById('chatInput');
    var msg   = input.value.trim();
    if (!msg) return;

    // 사용자 말풍선 추가
    appendBubble('user', msg);
    chatHistory.push({ role:'user', text:msg });
    input.value = '';

    // AI 응답 로딩 표시
    var loadingId = appendLoadingBubble();

    fetch('/api/geminiChat.do', {           // Ollama면 /api/ollamaChat.do
        method : 'POST',
        headers: {'Content-Type':'application/json'},
        body   : JSON.stringify({ message: msg })
    }).then(r => r.json()).then(function(res) {
        removeLoadingBubble(loadingId);
        if (res.resultCode === 'S') {
            appendBubble('ai', res.aiMessage);
            chatHistory.push({ role:'ai', text:res.aiMessage });
        } else {
            appendBubble('ai', '⚠ ' + (res.resultMsg||'오류가 발생했습니다.'));
        }
        scrollToBottom();
    });
}

// 마크다운 간이 렌더링 (굵기, 목록)
function renderMarkdown(text) {
    return text
        .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
        .replace(/^### (.+)$/gm, '<h5>$1</h5>')
        .replace(/^## (.+)$/gm,  '<h4>$1</h4>')
        .replace(/^\* (.+)$/gm,  '<li>$1</li>')
        .replace(/\n/g, '<br>');
}

// Enter 키 전송
document.getElementById('chatInput').addEventListener('keydown', function(e) {
    if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
});
업무 유형별 개발 가이드 요약 (섹션 번호 16-1 ~ 16-11)
섹션유형참조 샘플핵심 패턴파일 패키지주요 주의사항
16-1 자료형 SAMPLE_01 날짜 그룹 카드 + 위→아래 슬라이드 상세 패널 egovframework.penta.business 복합PK(사용자ID+등록일시), 기간 기본 15일, Swal 필수
16-2 표형 SAMPLE_02 동적 정렬(sortCol 화이트리스트) + 무한스크롤/더보기 + 슬라이드 패널 egovframework.penta.business sortCol SQL인젝션 방지 필수, 반응형 col-sm/col-lg
16-3 차트형 SAMPLE_03 차트 1종 = 테이블 1개, chart.destroy() 후 재생성 egovframework.penta.business 전역 차트 인스턴스 변수 유지, 조건 변경 시 loadAllCharts() 호출
16-4 승인처리형 SAMPLE_04 6개 테이블 + 결재 상태 머신 + @Transactional + 자동 메시지 발송 egovframework.penta.business DRAFT→REQ→APR/REJ 상태 전이, 결재선 시각화, 트랜잭션 필수
16-5 자원관리형 SAMPLE_05 응용 자원마스터 + 예약 테이블, 시간 중복 서버 검증 egovframework.penta.business 중복 예약 NOT (END <= startTime OR START >= endTime) SQL 패턴
16-6 입력유형(CRUD) SAMPLE_05 다양한 입력 컨트롤 + 통합 저장(INSERT/UPDATE 분기) + Swal 검증 egovframework.penta.business 공통코드 AJAX 로드, Swal.fire로 삭제 확인 필수, alert/confirm 금지
16-7 바코드형 SAMPLE_06 네이티브 앱 스캔 → DB 저장 → 목록 자동 갱신 egovframework.penta.api window.PentaMobile.startBarcodeScan(), 브라우저 fallback, 스캔 로그 필수
16-8 사진형 SAMPLE_07/10 카메라 촬영 → Canvas 리사이즈 → MultipartFile 업로드 → BLOB/경로 저장 egovframework.penta.api 1280px 80% 품질 압축, SystemFileService.uploadFile() 활용
16-9 가동/비가동형 SAMPLE_12 설비마스터 + 가동이력 테이블, 가동률 SQL 산출 egovframework.penta.api 가동률 = SUM(가동시간) / 전체시간 × 100, 바코드 패턴 Steps 2~9 재사용
16-10 지도형 SAMPLE_08/13 GPS → Leaflet.js 마커 → DB 저장, OpenStreetMap 사용 egovframework.penta.api API키 불필요, map.remove() 후 재초기화, LAT/LNG = NUMBER(10,7)
16-11 AI 챗봇형 AI 챗봇 Spring Controller 프록시 → Gemini/Ollama 호출, API키 서버에만 보관 egovframework.penta.api @Value로 globals.properties 주입, 브라우저에 API키 노출 절대 금지