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)
├── 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 의존성 관리
| 항목 | 현재 운영 사양 | 권장 사양 | 비고 |
|---|---|---|---|
| CPU | 4 Core (2.0GHz) | 8 Core (2.4GHz+) | 가상화 환경 가능 (vCPU) |
| Memory (RAM) | 8 GB | 16 GB | JVM Heap: 4GB 이상 확보 |
| Disk (OS + App) | 50 GB SSD | 200 GB SSD | OS 60 + 앱 20 + 로그 120 |
| Disk (임시파일) | 별도 없음 | 100 GB SSD 추가 | 파일 업로드 임시 버퍼용 |
| OS | Windows Server 2019 | Windows Server 2022 / Rocky Linux 9 | 64bit 필수 |
| Network | 100 Mbps | 1 Gbps | 내부망 전용 가능 |
| JVM Heap 설정 | -Xms512m -Xmx1024m | -Xms1g -Xmx4g | setenv.bat/sh 수정 |
| DBCP 커넥션 풀 | maxTotal=20 | maxTotal=50 | context-datasource.xml 수정 |
| 포트 | 8080 (HTTP), 8443 (HTTPS) | 동일 | 방화벽 오픈 필요 |
| 항목 | 현재 운영 사양 | 권장 사양 | 비고 |
|---|---|---|---|
| CPU | 4 Core (2.0GHz) | 16 Core (2.4GHz+) | 무료 오픈소스 DBMS, 상업용 무제한 사용 가능 |
| Memory (RAM) | 16 GB | 64 GB | shared_buffers 16GB + work_mem 기준 |
| Disk (OS) | 60 GB SSD | 200 GB SSD | PostgreSQL 설치 및 시스템 드라이브 |
| Disk (Data) | 100 GB SSD | 1 TB SSD (RAID-1) | BLOB(현장사진·첨부파일) 포함 |
| Disk (Archive) | 별도 없음 | 500 GB HDD | Archive Log, RMAN 백업용 |
| OS | Rocky Linux 9 | Rocky Linux 9 / Ubuntu 22.04 | 64bit 필수 |
| shared_buffers | 4 GB | 16 GB | PostgreSQL 파라미터 |
| work_mem | 1 GB | 4 GB | PostgreSQL 파라미터 |
| 포트 | 5432 (PostgreSQL) | 동일 | WAS→DB 방화벽 오픈 |
Database:
handson / User: handsonPostgreSQL은 무료 오픈소스로 상업용 환경에서도 제한 없이 사용 가능합니다.
| 항목 | 현재 운영 사양 | 권장 사양 | 비고 |
|---|---|---|---|
| CPU | 2 Core | 8 Core | WAS와 분리 구성 권장 |
| Memory (RAM) | 4 GB | 16 GB | 대용량 정적 파일 캐시 포함 |
| Disk | 30 GB SSD | 100 GB SSD | SSL 인증서 + 액세스 로그 |
| Network | 100 Mbps | 1 Gbps | 외부 트래픽 처리 |
| Worker 프로세스 | auto | CPU Core 수와 동일 | nginx.conf worker_processes |
| 포트 | 80 (HTTP), 443 (HTTPS) | 동일 | 외부 방화벽 오픈 |
통합 구성 권장 최소: CPU 16 Core / RAM 32 GB / SSD 500 GB (WAS + Nginx 동일 서버)
| 항목 | 현재 운영 사양 | 권장 사양 | 비고 |
|---|---|---|---|
| 용도 | 파일 업로드/다운로드 물리적 저장소 (현장사진, 첨부파일, 결재문서 등) | ||
| 저장 용량 | 200 GB | 2 TB (RAID-5 구성) | 현장사진 BLOB 규모 감안 |
| 전송 속도 | 10/100 Mbps | 1 Gbps NIC | 현장사진 다량 업로드 대응 |
| 백업 주기 | 수동 | 일 1회 자동 증분백업 | rsync 또는 NAS 내장 백업 기능 |
| 경로 설정 | globals.properties → file.upload.root=D:/fileserver/penta | ||
| 접근 방식 | 로컬 경로 | 로컬 경로 또는 SMB/NFS 마운트 | 마운트 시 WAS 재시작 불필요 |
- • CPU: 8 Core / 2.4GHz+
- • RAM: 16 GB
- • Disk: 300 GB SSD
- • JVM: -Xms1g -Xmx4g
- • DBCP Pool: 50
- • CPU: 16 Core / 2.4GHz+
- • RAM: 64 GB
- • Disk: 200 GB(OS) + 1TB SSD(Data)
- • SGA: 16 GB / PGA: 4 GB
- • 백업: RMAN 일 1회
- • 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: 정전 대비 권장
| 소프트웨어 | 필수 버전 | 권장 버전 | 용도 |
|---|---|---|---|
| JDK | 17 LTS (최소 11) | JDK 17 LTS (Eclipse Temurin) | 애플리케이션 실행 환경 |
| Apache Tomcat | 9.0.x (Servlet 4.0) | 9.0.98+ | WAS (Web Application Server) |
| PostgreSQL | 14+ | 16 (LTS) | 관계형 데이터베이스 |
| PostgreSQL JDBC | postgresql-42.x | postgresql-42.7.5 | DB 연결 드라이버 |
| Maven | 3.6+ | 3.9+ | 빌드 도구 |
| Eclipse IDE | eGovFrameDev 4.3.1 | 동일 | 개발 도구 |
| Nginx | 1.20+ | 1.26 (stable) | Web 서버 / 리버스 프록시 |
| Apache HTTP | 2.4+ | 2.4.58+ | Nginx 대안 Web 서버 |
Servlet 4.0 / JSP 2.3 지원. 전자정부프레임워크 공식 지원 WAS.
web.xml에서 version="4.0"으로 설정됨.
다운로드: tomcat.apache.org
Jakarta EE 9+로 패키지명이 변경됨 (javax.* → jakarta.*).
현재 소스코드는 javax.servlet을 사용하므로 Tomcat 9.x 권장.
WAR 배포 가능하나 별도 설정 필요.
공식 지원 대상 아님.
전자정부프레임워크 호환 가능하나 라이선스 비용 발생.
엔터프라이즈 환경에서 사용.
경량 고성능 Web 서버. 리버스 프록시로 Tomcat 앞단 처리.
SSL 종료 처리 (HTTPS → HTTP 내부 전달).
다운로드: nginx.org
mod_jk 또는 mod_proxy로 Tomcat과 연동.
.htaccess 등 레거시 설정 필요 시 적합.
URL:
http://서버IP:8080/HandsOn/ 또는 http://서버IP:8080/
(context path 설정에 따라 다름)
-
Eclipse Temurin JDK 17 다운로드Eclipse Adoptium 공식 사이트에서 Windows x64 Installer (.msi) 다운로드
OpenJDK17U-jdk_x64_windows_hotspot_17.0.x_x.msi실행 후 기본 경로 설치 -
환경변수 설정시스템 속성 → 환경변수 → 시스템 변수에 아래 항목 설정
변수명 값 (예시) JAVA_HOME C:\Program Files\Eclipse Adoptium\jdk-17.x.x.x-hotspot Path (추가) %JAVA_HOME%\bin -
설치 확인명령 프롬프트(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)
-
eGovFrameDev 4.3.1 IDE 실행전자정부프레임워크 통합개발환경 IDE를 실행합니다.
eGovFrameDev-4.3.1-64bit\eclipse.exe -
JRE 등록: Window → Preferences → Java → Installed JREsAdd → Standard VM → JDK 17 설치 경로 지정 → Finish → 체크박스 선택
-
컴파일러 레벨 확인: Java → Compiler → Compiler compliance level: 18프로젝트 우클릭 → Properties → Java Compiler에서도 동일하게 설정
-
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 17인가?
이 프로젝트는 pom.xml에 <java.version>18</java.version>으로 설정되어 있습니다. JDK 버전이 다르면 컴파일 오류가 발생합니다.
다운로드
| 배포사 | 다운로드 위치 | 비고 |
|---|---|---|
| Eclipse Temurin (추천) | adoptium.net → Temurin 17 → Windows x64 .msi | 무료, LTS급 안정성 |
| Microsoft OpenJDK | learn.microsoft.com/java/openjdk/download → JDK 17 | 무료, Azure 환경 최적화 |
설치 절차
- 다운로드한 .msi 파일 실행 → Next 클릭
- 설치 경로 확인 — 기본값(
C:\Program Files\Eclipse Adoptium\jdk-17.x.x.x-hotspot\) 권장. 경로에 한글/공백이 없어야 합니다. - 설치 완료 후 환경변수 확인
# 명령 프롬프트(cmd) 실행 java -version # 출력 예: openjdk version "17.0.x" ...
- JAVA_HOME 설정 (자동으로 안 된 경우)
- 시작 → 환경 변수 편집 검색 → 실행
- 시스템 변수 → 새로 만들기 → 변수명:
JAVA_HOME, 값:C:\Program Files\Eclipse Adoptium\jdk-17.x.x.x-hotspot - Path 변수 선택 → 편집 → 새로 만들기 →
%JAVA_HOME%\bin추가 - 모든 창 확인 → cmd 재시작 후
java -version재확인
다운로드
| 항목 | 내용 |
|---|---|
| 다운로드 URL | eclipse.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 |
설치 절차
- ZIP 파일 압축 해제 —
C:\eclipse\또는D:\dev\eclipse\등 경로에 한글/공백 없이 설치 - eclipse.exe 실행 → Workspace 경로 지정
예:D:\project\handson\eGovFrameDev-4.3.1-64bit\workspace-egov - JDK 연결 확인
Window → Preferences → Java → Installed JREs → Add → Standard VM → JDK 경로 선택 → Finish → 체크박스 선택 → Apply - Compiler 버전 확인
Window → Preferences → Java → Compiler → Compiler compliance level: 18 선택 - 인코딩 설정 (UTF-8)
Window → Preferences → General → Workspace → Text file encoding: UTF-8
Window → Preferences → General → Content Types → Text → Default encoding: UTF-8
Help → Eclipse Marketplace → 검색창에 eGovFrame 입력 → eGovFrame 4.x 설치 → Eclipse 재시작
(이미 설치된 환경이라면 생략 가능)
소스코드가 로컬 폴더에 있다고 가정합니다. 경로 예: D:\project\handson\eGovFrameDev-4.3.1-64bit\workspace-egov\handson
- File → Import 클릭
- Maven → Existing Maven Projects 선택 → Next
- Root Directory에 프로젝트 폴더 경로 입력 또는 Browse로 선택
pom.xml이 자동으로 감지되면 체크박스가 표시됩니다. - Finish 클릭 → Maven이 라이브러리 자동 다운로드 (최초 수분 소요)
- 빌드 오류 확인
Package Explorer에서 프로젝트에 빨간 X(❌)가 없으면 성공.
있으면 프로젝트 우클릭 → Maven → Update Project → Force Update → OK
| 자주 발생하는 Import 오류 | 원인 | 해결 |
|---|---|---|
| Plugin … not found | Maven Central 연결 실패 | 회사 프록시 설정 또는 VPN 연결 후 재시도 |
| JRE mismatch | JDK 버전 불일치 | Installed JREs에서 JDK 17 선택 후 Update Project |
| Facet Java version mismatch | 프로젝트 Facet 설정 오류 | 프로젝트 우클릭 → Properties → Project Facets → Java 17로 변경 |
Tomcat 다운로드 및 설치
- 다운로드
tomcat.apache.org → Download → Tomcat 9 → 64-bit Windows zip 다운로드 - 압축 해제
C:\tomcat9\또는D:\dev\tomcat9\— 경로에 한글·공백 없이 - conf\server.xml 인코딩 설정 확인
<Connector port="8080" protocol="HTTP/1.1" connectionTimeout="20000" redirectPort="8443" URIEncoding="UTF-8" />URIEncoding="UTF-8"이 없으면 한글 파라미터가 깨집니다.
Eclipse에 Tomcat 서버 등록
- Window → Show View → Servers 뷰 열기
- Servers 탭 하단 빈 공간 우클릭 → New → Server
- Apache → Tomcat v9.0 Server 선택 → Next
- Tomcat installation directory에 압축 해제한 경로 입력 → Finish
- Servers 탭에 Tomcat v9.0 Server at localhost 등록 확인
- 서버 더블클릭 → Timeouts 항목에서 Start: 180초로 설정 (초기 구동 시간 확보)
- 프로젝트를 서버에 추가: Servers 탭 → 서버 우클릭 → Add and Remove → handson 프로젝트 → Add → Finish
DB 연결 정보는 src/main/resources/egovframework/spring/context-datasource.xml에 정의되어 있습니다.
<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>
| 항목 | 설명 | 로컬 개발 시 변경 포인트 |
|---|---|---|
| url | PostgreSQL 서버 주소/포트/DB명 | 로컬 PostgreSQL이면 jdbc:postgresql://localhost:5432/handson으로 변경 |
| username | PostgreSQL 사용자명 | handson (별도 생성 필요) |
| password | 비밀번호 | DBA에게 문의하거나 로컬 설치 시 직접 지정 |
PostgreSQL JDBC 드라이버 확인pom.xml에 postgresql-42.7.5 의존성이 이미 추가되어 있습니다.
Maven 빌드 시 자동으로 다운로드됩니다. (별도 파일 복사 불필요)
Maven 빌드 (clean install)
- Package Explorer에서 handson 프로젝트 우클릭
- Run As → Maven build… 클릭
- Goals 입력란에
clean install -DskipTests입력 → Run - Console 창에
BUILD SUCCESS가 표시되면 완료
ERROR가 있는 줄을 찾아 패키지명과 오류 코드를 확인하세요. 대부분 라이브러리 다운로드 실패(네트워크) 또는 Java 버전 불일치입니다.Tomcat에서 실행
- Servers 탭 → Tomcat v9.0 Server 선택 → 상단 ▶ (Start) 버튼 클릭
- Console 창에
Server startup in [xxxx] milliseconds메시지 확인 - 브라우저에서 접속 확인
http://localhost:8080/HandsOn/login.do
- 로그인 테스트: admin / admin123 (또는 DB에 등록된 계정)
| 자주 발생하는 기동 오류 | 원인 | 해결 |
|---|---|---|
| 포트 8080 이미 사용 중 | 다른 프로그램(스카이프, IIS 등)이 점유 | 서버 더블클릭 → HTTP port를 8090으로 변경 |
| ClassNotFoundException: org.postgresql.Driver | postgresql jar 누락 | pom.xml postgresql 의존성 확인 후 Maven → Update Project |
| Cannot connect to DB | DB 서버 미접속 또는 방화벽 | 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-8 | Preferences → 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 XML Editor — Spring XML 편집 지원
- Spring Tools 4 — Spring 빈 자동완성 (Help → Eclipse Marketplace 검색)
- EditBox — 현재 편집 라인 하이라이트 (가독성 향상)
- Bracketeer — 중괄호 쌍 표시
| 항목 | 값 |
|---|---|
| DB 종류 | PostgreSQL 16 |
| 접속 URL | jdbc:postgresql://192.168.100.66:5432/handson |
| 데이터베이스/사용자 | handson |
| 포트 | 5432 (기본) |
-- 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;
-
기본 테이블 생성TB_PENTA_USER → TB_PENTA_ROLE → TB_PENTA_PROGRAM → TB_PENTA_ROLE_PROGRAM → TB_PENTA_USER_ROLE
-
로그 테이블 생성TB_PENTA_LOGIN_LOG → TB_PENTA_PROGRAM_RUN_LOG
-
메시지 테이블 생성TB_PENTA_MESSAGE → TB_PENTA_MESSAGE_FILE
-
파일 테이블 생성TB_PENTA_FILE_LIST → TB_PENTA_FILE_UPLOAD → TB_PENTA_FILE_DOWNLOAD
-
게시판 테이블 생성TB_PENTA_BOARD → TB_PENTA_BOARD_CMT → TB_PENTA_BOARD_FILE → TB_PENTA_BOARD_READ
-
조직도 테이블 생성TB_PENTA_ORGAN → TB_PENTA_USER.ORG_CD 컬럼 추가
-
공통코드 테이블 생성TB_PENTA_CODE_MST
-
업무 샘플 테이블 생성TB_PENTA_BUSINESS_SAMPLE_01 ~ 04 (SAMPLE_04는 04_00 ~ 04_04 순서로)
-
초기 데이터 삽입관리자 계정, 역할, 프로그램 메뉴, 샘플 데이터 삽입
-- 비밀번호 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;
egovframework.penta.common.SHA256Util.encode("비밀번호")를 호출하여 해시값을 얻어 INSERT하거나,
초기 비밀번호를 평문으로 저장 후 첫 로그인 시 강제 변경하도록 처리합니다.
<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>
-
Apache Tomcat 9.x 다운로드apache.org에서 Core Windows zip 다운로드 후 적절한 위치에 압축 해제
예:C:\servers\apache-tomcat-9.0.xx -
JAVA_HOME 확인Tomcat 기동 전
JAVA_HOME환경변수가 JDK 18을 가리키는지 확인 -
포트 설정: conf/server.xmlconf/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"/>
파일 서버 경로, 최대 파일 크기 등 서버 환경에 맞게 반드시 수정해야 합니다.
# 파일 서버 설정 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
rem 기동 cd C:\servers\apache-tomcat-9.0.xx\bin startup.bat rem 중지 shutdown.bat
# 기동 /opt/tomcat9/bin/startup.sh # 중지 /opt/tomcat9/bin/shutdown.sh # 상태 확인 ps aux | grep tomcat
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)
| 항목 | 옵션 | 개발 권장 | 운영 권장 |
|---|---|---|---|
| 초기 힙 크기 | -Xms | 512m | 1g |
| 최대 힙 크기 | -Xmx | 1024m | 2g |
| Metaspace 최대 | -XX:MaxMetaspaceSize | 256m | 512m |
| 파일 인코딩 | -Dfile.encoding | UTF-8 (필수) | |
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
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=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 — 아래 권장 값으로 추가 필요
<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 | 미설정 | on | JS/CSS/JSON 전송량 감소 |
| connectionTimeout | 20000ms | 20000ms | 현재 적용 중 (문제 없음) |
| maxParameterCount | 1000 | 1000 | 현재 적용 중 (문제 없음) |
④ 세션 타임아웃
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 — 적용 중
<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
<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.launchGUI 경로: Servers 탭 → Tomcat 서버 더블클릭 → Open launch configuration → Arguments 탭 → VM arguments
Eclipse에서 직접 서버를 실행할 때만 적용됩니다. Tomcat을 standalone으로 기동할 때(startup.bat)는 적용되지 않으므로 운영/스테이징 서버에는 반드시 setenv.bat에 동일 설정을 복사해야 합니다.
-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에 동일 내용 추가 |
| 3 | URIEncoding=UTF-8 | conf/server.xml |
❌ 미설정 | Connector에 URIEncoding 추가 |
| 4 | 세션 타임아웃 30분 | WEB-INF/web.xml 61행 |
✅ 적용 중 | 변경 불필요 |
| 5 | UTF-8 인코딩 필터 | WEB-INF/web.xml 19행 |
✅ 적용 중 | 변경 불필요 |
| 6 | 에러 페이지 (404/500) | WEB-INF/web.xml 71행 |
✅ 적용 중 | 변경 불필요 |
| 7 | maxThreads / acceptCount | conf/server.xml |
△ 기본값(200/100) | 부하 테스트 후 조정 |
| 8 | 응답 압축 (gzip) | conf/server.xml |
❌ 미설정 | 모바일 환경 시 compression=on 추가 |
# 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; } }
# certbot 설치 (Ubuntu 예시) sudo apt install certbot python3-certbot-nginx # 인증서 발급 sudo certbot --nginx -d penta.example.com # 자동 갱신 확인 sudo certbot renew --dry-run
-
Eclipse에서 빌드프로젝트 우클릭 → Run As → Maven build → Goals:
clean package -DskipTests -
명령행에서 빌드cmd
cd D:\project\handson\eGovFrameDev-4.3.1-64bit\workspace-egov\handson mvn clean package -DskipTests # 성공 시 target/handson.war 파일 생성
-
WAR 파일 복사빌드된
target/handson.war를 Tomcat의webapps/디렉토리에 복사cmdcopy target\handson.war C:\servers\apache-tomcat-9.0.xx\webapps\HandsOn.war
-
Tomcat 재시작Tomcat을 재시작하면 WAR가 자동으로 압축 해제되어 배포됩니다
-
배포 확인브라우저에서
http://서버IP:8080/HandsOn/login/login.do접속하여 로그인 화면 확인
-
Servers 뷰에서 Tomcat 추가Window → Show View → Servers → New Server → Apache Tomcat 9.0 → Tomcat 설치 경로 지정
-
프로젝트 추가서버 우클릭 → Add and Remove → handson 프로젝트를 Configured 측으로 이동 → Finish
-
서버 시작서버 우클릭 → Start (또는 Debug) → Console에 기동 로그 확인
파워빌더 개발 경험이 있으신 분은 각 항목의 🔵 파워빌더 비교 박스를 참고하시면 더 빠르게 이해할 수 있습니다.
기존 업무 화면(SAMPLE_01~14)을 복사 템플릿으로 활용하여 새 화면을 만드는 방법을 화면 유형별로 상세히 설명합니다.
웹 화면 하나를 만들려면 최소 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 |
(브라우저)
URL 수신·분기
업무 로직
DB 실행 요청
실제 SQL
데이터 반환
화면 렌더링
Controller는 "중간 교통정리"만 합니다. 실제 계산·검증은 ServiceImpl에 넣습니다.
DAO는 SQL을 "실행"만 합니다. SQL 내용은 XML 파일에 있습니다.
VO는 DB 테이블의 컬럼 수와 반드시 일치할 필요는 없습니다 — 화면에서 필요한 조회 조건용 변수도 VO에 포함됩니다.
처음엔 파일 위치가 낯설지만 몇 번 반복하면 자연스러워집니다.
-
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) -
파일을 빠르게 찾는 단축키파일명을 알고 있을 때는 단축키가 훨씬 빠릅니다.
단축키 기능 사용 예시 Ctrl + Shift + R 파일명으로 빠른 검색 "BusinessSample01" 입력 → 관련 파일 목록 표시 Ctrl + H 프로젝트 전체 텍스트 검색 "sample01List" 입력 → 어느 파일에서 쓰이는지 확인 Ctrl + F 현재 파일 내 검색 열린 파일에서 특정 단어 찾기 Ctrl + Z 실행 취소 잘못 수정했을 때 되돌리기 Ctrl + S 저장 수정 후 반드시 저장 -
Java 파일 수정 후 서버 재시작 없이 반영하기JSP 파일은 저장하면 브라우저 새로고침만 해도 바로 반영됩니다.
Java 파일 (Controller, VO, DAO 등)은 Eclipse가 자동으로 컴파일하지만, 변경사항이 실행 중인 서버에 반영되려면 서버를 재시작해야 합니다.서버 재시작 방법방법 1: Eclipse 하단 Servers 탭 → 서버 우클릭 → Restart 방법 2: Eclipse 상단 Run 메뉴 → Restart Server 방법 3: Servers 탭에서 서버 선택 후 툴바의 재시작 버튼(■ → ▶) 클릭
자주 하는 실수: Java 파일을 수정하고 저장했는데 화면이 안 바뀐다 → 서버 재시작을 안 했기 때문입니다.
SQL XML 파일은 서버 재시작 없이 반영되는 경우도 있지만, 안전하게는 재시작하는 것이 좋습니다. -
컴파일 오류(빨간 X) 해결 방법Eclipse에서 파일에 빨간 X가 표시되면 문법 오류가 있는 것입니다. 개발을 진행하기 전에 반드시 해결해야 합니다.
증상 원인 해결 import 빨간 줄 클래스를 찾을 수 없음 해당 줄에서 Ctrl+1 → "Import 추가" 선택 클래스명 빨간 줄 VO 클래스명 오타 또는 미생성 VO 파일이 있는지 확인, 클래스명 철자 확인 메서드 빨간 줄 Service/DAO에 메서드가 없음 해당 인터페이스에 메서드 선언 추가 세미콜론 누락 Java 문법 오류 오류 줄 끝에 ;추가
DB 테이블:
TB_PENTA_BUSINESS_SAMPLE_15URL:
business/sample15view.do?progId=00019
-
① 데이터베이스 테이블 설계 및 생성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 '고객명';
-
② VO(Value Object) 클래스 생성파워빌더의 컬럼 정의와 같습니다. 테이블의 각 컬럼에 해당하는 Java 변수를 선언합니다.
기존 파일 복사:BusinessSample01VO.java를 복사하여BusinessSample15VO.java로 저장 후 수정합니다.src/main/java/egovframework/penta/business/vo/BusinessSample15VO.javapackage 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.xml에mapUnderscoreToCamelCase=true설정이 있기 때문입니다. 밑줄(_)이 자동으로 제거되고 그 다음 글자가 대문자로 바뀝니다. -
③ 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 조건에 추가됩니다.
#{변수명}은 파워빌더의:변수명바인딩과 동일합니다. -
④ DAO 메서드 추가SQL Mapper와 Java를 연결하는 인터페이스입니다. SQL ID와 동일한 이름의 메서드를 추가합니다.
파일:src/main/java/egovframework/penta/business/dao/BusinessDAO.javaBusinessDAO.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);
-
⑤ 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;
-
⑥ 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); } // ... 나머지 메서드도 동일한 패턴으로 추가
-
⑦ 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 파일 경로를 반환합니다 (화면 이동용). -
⑧ 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>
-
⑨ 프로그램 메뉴 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; -
⑩ 서버 재시작 및 테스트Eclipse에서 Servers 뷰 → 서버 우클릭 → Restart.
브라우저에서http://localhost:8080/HandsOn/business/sample15view.do?progId=00019접속 확인.체크리스트
□ 화면이 정상 표시되는가?
□ 목록 조회 시 데이터가 나오는가?
□ 등록/수정/삭제 동작이 되는가?
□ 로그인 없이 접근 시 로그인 페이지로 이동하는가?
□ 메뉴에서 이 화면으로 이동이 되는가?
DB 컬럼을 추가하면 4곳을 반드시 모두 수정해야 합니다: ① DB(PostgreSQL) → ② VO.java → ③ SQL XML → ④ JSP 화면
하나라도 빠뜨리면 오류가 발생하거나 데이터가 저장·표시되지 않습니다.
ALTER TABLE
필드 변수 추가
SELECT/INSERT/UPDATE
입력창·표시 추가
-- 기존 테이블에 컬럼 추가 (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;
// 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; }
DB 컬럼명
CUST_GRADE는 Java에서 custGrade로 씁니다.규칙: 밑줄(_) 제거 + 밑줄 다음 글자는 대문자.
CUST_GRADE → cust + Grade = custGrade이 변환은
mybatis-config.xml의 mapUnderscoreToCamelCase=true 설정이 자동으로 처리합니다.🔵 파워빌더 비교: DataWindow에서 컬럼을 추가하고 "Column Name"에 변수명을 지정하던 것과 유사합니다.
<!-- 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>
<!-- 목록 표시: 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>
CUST_TEL을 CUST_PHONE으로 바꾸고 싶다.
Eclipse의 Ctrl+H (Find/Replace) 기능을 활용하면 Java/JSP 파일 내 일괄 변경이 가능합니다.
-- 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;
// 변경 전 private String custTel; // 변경 후 private String custPhone; // CUST_TEL → CUST_PHONE 으로 변경
<!-- Eclipse Ctrl+H: BusinessSample15_SQL.xml 파일 안에서 일괄 변경 --> <!-- 검색어: CUST_TEL → 치환어: CUST_PHONE --> <!-- "Replace All" 클릭하면 파일 내 모든 CUST_TEL이 CUST_PHONE으로 변경됨 --> <!-- 또한 #{custTel} → #{custPhone} 도 변경 (VO 필드명) -->
// 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);
-- 컬럼 길이 늘리기 (줄이면 기존 데이터 손실 위험 — 주의!) 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;
VO, SQL, JSP는 보통 변경할 필요 없습니다. 타입만 바뀌면 DB-Java 간 자동으로 처리됩니다.
단, NUMBER → 소수점 추가 시 VO에서
int를 double 또는 BigDecimal로 변경해야 합니다.
// PAY_AMT가 정수에서 소수점 포함으로 변경되면 VO도 변경 // 변경 전 (정수) private int payAmt; // 변경 후 (소수점 포함) private double payAmt; // 또는 BigDecimal payAmt; (금액은 BigDecimal 권장)
-- ① 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: ... 제거
예: "등록일 From~To" 날짜 범위 검색 추가
// 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; // ← 추가 }
<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 조건 추가하던 것과 유사 -->
<!-- 조회조건 패널에 날짜 범위 추가 --> <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>
| 작업 | DB | VO.java | SQL XML | JSP |
|---|---|---|---|---|
| 컬럼 추가 | ✅ 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.jsp | Chart.js 13종 차트, 연도 선택 | chart.destroy() 후 재생성 필수 |
| ✅ 결재/승인형 | sample04view.jsp | 결재선 워크플로우, 상태 관리 | TB_PENTA_MESSAGE 발송 로직 포함 |
| 📝 입력형 (폼) | sample05view.jsp | 다양한 입력 컨트롤, 유효성 검사 | 필수값 체크, 저장 전 confirm |
| 📷 바코드형 | sample06view.jsp | 카메라·바코드 스캔 연동 | api 패키지, Android/iOS 공통 인터페이스 |
| 🖼️ 사진형 | sample07view.jsp | 사진 촬영·BLOB 저장·썸네일 | BLOB 크기 제한, 압축 처리 |
| 🗺️ 지도형 | sample08view.jsp | GPS 위치, Leaflet + OpenStreetMap 지도 | API 키 불필요 (무료) |
- sample01view.jsp → sampleXXview.jsp
- BusinessSample01VO.java → BusinessSampleXXVO.java
- BusinessSample01_SQL.xml → BusinessSampleXX_SQL.xml
- BusinessController.java → 메서드 추가 (파일 복사 아님)
- PK1: USER_ID (사용자 ID)
- PK2: 날짜/시간 컬럼 (PAY_DTTM)
- PK3: SEQ = MAX+1 서브쿼리
- 시퀀스 객체 사용 금지
<!-- 조회조건: 항상 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>
<!-- 목록 조회: 최근 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>
// 화면 진입 (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; }
- sample02view.jsp → sampleXXview.jsp
- BusinessSample02VO.java → 복사 후 컬럼 변경
- BusinessSample02_SQL.xml → 복사 후 테이블명·컬럼 변경
- 컬럼 헤더 클릭 → 오름/내림차순 정렬
- 현재 정렬 컬럼 화살표(▲▼) 표시
- 모바일: 주요 컬럼만 표시
- PC: 모든 컬럼 표시 (반응형)
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>'; }
/* 모바일: 주요 컬럼만 보임 */ @media (max-width: 768px) { .col-optional { display: none; } } /* PC: 모든 컬럼 표시 */ @media (min-width: 769px) { .col-optional { display: table-cell; } }
// 행 클릭 시 상세 패널 표시 (위에서 아래로 슬라이드) 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(); }); }
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_07 | X=구매액, Y=방문수, R=충성도 | type: 'bubble' |
| 산점도 (Scatter) | TB_PENTA_BUSINESS_SAMPLE_03_08 | X=리드수, 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 커스텀 |
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); // ... 나머지 차트도 동일 패턴 });
@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; }
| 테이블 | 역할 | 주요 컬럼 |
|---|---|---|
| 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 |
@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; }
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"); } } }
// 결재선 렌더링 함수 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(''); }
- sample05view.jsp → 신규 JSP
- BusinessSample05VO.java → 신규 VO
- BusinessSample05_SQL.xml → 신규 SQL
- text, number, date, datetime-local
- select (드롭다운, 공통코드 연동)
- radio / checkbox
- textarea (장문 입력)
- 파일 첨부 (SystemFileController 연동)
<!-- 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>
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(); } } }); } }); }
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 공통 바코드 스캔 함수 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:'해당 바코드 상품 없음' }); } }); }
// 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); } }
<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 키 불필요, 무료 사용 가능합니다.
// 앱에서 사진 촬영 완료 후 콜백 (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 }); } } }); }
스마트폰 카메라로 바코드를 스캔해 상품 정보를 조회·처리하는 화면입니다. Android / iOS 앱의 네이티브 스캐너를 호출하고, 결과를 JavaScript 콜백으로 받아 서버 조회까지 연결합니다.
pentaApp), iOS는 Swift 메시지 핸들러(webkit.messageHandlers)가 그 역할을 합니다.📁 수정할 파일 목록
// 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; }
// 바코드 스캔 시작 (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 추가 여부 |
| 콜백 함수 전역화 | onScan07Result를 window.onScan07Result = function(v){...}로 선언해야 앱에서 호출 가능 |
| PC 테스트 | 앱 없이 PC 브라우저에서 테스트 시 수동 입력 폼(#manualInput07)이 표시되어야 함 |
창고별 품목 재고 현황을 조회하고, 실사(현물 확인) 수량을 입력해 시스템 재고와 차이를 관리하는 화면입니다. SAMPLE_02의 표 기반 화면에 편집 기능을 추가한 형태로 이해하면 됩니다.
<foreach> 또는 개별 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>
// 수량 셀 클릭 시 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(); } } }); }
출고 지시 목록을 조회하고, 담당자가 출고 확인(피킹 완료)을 처리하는 화면입니다. 상태 전환(대기→피킹중→출고완료)이 핵심이며, 각 단계에서 처리자/처리 시각을 기록합니다.
STATUS_CD 컬럼을 UPDATE하면서 처리자 ID(UPD_USER)와 처리 일시(UPD_DATE)를 함께 기록합니다.📁 수정할 파일 목록
<!-- 출고 지시 목록 조회 (상태별 필터) --> <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>
// 출고 처리 버튼 클릭 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(); } }); }); }
스마트폰 카메라로 사진을 촬영하고 서버에 저장한 뒤, 저장된 사진 목록을 갤러리 형태로 표시하는 화면입니다. 사진 데이터는 Base64로 전송하여 서버에서 BLOB 또는 파일 시스템으로 저장합니다.
📁 수정할 파일 목록
// 카메라 촬영 시작 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(); // 갤러리 새로고침 } } }); };
// 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; }
작업 지시서를 조회하고, 작업 시작 시각과 완료 시각을 기록하면서 상태를 관리하는 화면입니다. 현장 담당자가 모바일에서 직접 작업 시작/완료 버튼을 눌러 실시간으로 진행 상황을 갱신합니다.
SYSDATE를 사용하면 클라이언트 시간 오차를 방지할 수 있습니다.📁 수정할 파일 목록
<!-- 작업 시작 처리 --> <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>
// 작업 시작 버튼 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(); }); }); }
공장·현장의 설비 가동 상태(가동/비가동)를 실시간으로 확인하고 변경 이력을 남기는 화면입니다. 토글 버튼 하나로 상태를 전환하고, 변경 사유와 담당자를 이력 테이블에 기록합니다.
@Transactional로 묶어 원자성을 보장합니다.📁 수정할 파일 목록
<!-- 설비 가동 상태 변경 --> <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>
// 가동/비가동 토글 클릭 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 |
현재 GPS 위치를 취득해 DB에 저장하고, 저장된 위치들을 지도 위에 마커로 표시하는 화면입니다. Leaflet + OpenStreetMap을 사용합니다 (무료, API 키 불필요).
📁 수정할 파일 목록
// 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 위치 정보를 가져올 수 없습니다.' }); }); }
현장 방문 시 GPS 위치를 자동으로 기록하고, 방문 목적·결과·사진을 함께 저장하는 화면입니다. SAMPLE_10(사진)과 SAMPLE_13(지도)의 기능을 결합한 복합 유형입니다.
📁 수정할 파일 목록
// 현장 체크인 처리 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 권한이 필요합니다. 위치 권한을 허용해 주세요.' }); }); }
<!-- 방문 헤더 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.do로 END_DATE = SYSDATE UPDATE |
| 오프라인 대응 | 네트워크 미연결 시 localStorage에 임시 저장 후 온라인 복구 시 재전송 |
-
Eclipse Console 로그 확인빨간색 에러 메시지를 확인합니다. 클래스명, 메서드명, 라인번호가 표시됩니다.
-
브라우저 개발자 도구 (F12) → Console/Network 확인AJAX 요청 실패 시 Network 탭에서 응답 내용을 확인합니다.
-
자주 발생하는 오류
오류 메시지 원인 해결 방법 Invalid bound statement SQL ID가 DAO 메서드명과 다름 XML의 id="..." 와 DAO 메서드명 일치 확인 Could not find result map resultType 클래스 경로 오류 VO 클래스 패키지 경로 확인 404 Not Found Controller URL 매핑 오류 @GetMapping URL과 실제 호출 URL 일치 확인 NullPointerException 세션에서 값 없음 session.getAttribute() 결과 null 체크 ORA-00942 테이블 없음 테이블 생성 확인, 스키마 확인
- 항상 상단에 1줄로 접혀서 표시
- 클릭/포커스 시 아래로 펼쳐짐
- 포커스 이탈 시 자동 조회 후 닫힘
- 조건이 여러 개인 경우 포커스 시 여러 줄 표시
- 조회 결과 수 표시 (예: 총 25건)
- 목록 항목 클릭 → 위에서 아래로 슬라이드
- 키보드가 하단 고정 → 상단부터 스크롤 가능
- 수정/삭제/닫기 버튼 상단에 배치
- 첨부파일 영역 하단에 표시
Swal.fire()전용 사용 (alert 금지)- 저장/삭제 전 confirm 팝업 표시
- 성공 시 success 아이콘 + 자동 닫힘
- 오류 시 error 아이콘 + 메시지 표시
- 모바일(≤768px): 주요 컬럼만 표시
- 태블릿(769~1024px): 중간 컬럼 추가
- PC(≥1025px): 전체 컬럼 표시
- CSS 클래스
.col-optional로 제어
| 기능 | 세부 설명 | 관련 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/ |
| 구성 요소 | 데이터 소스 | 표시 내용 |
|---|---|---|
| 공지사항 섹션 | 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 |
/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 |
| 월별 합계 | 이번 달 총 결제액, 할인 총액 표시. | 목록 조회 시 함께 반환 |
동일 사용자가 동일 밀리초에 복수 결제 시 PAY_SEQ로 구분. 시퀀스 미사용.
/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 |
/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 |
/business/approval.do?progId=00005
DB: TB_PENTA_BUSINESS_SAMPLE_04_00 ~ 04
JSP: business/sample04view.jsp
기안중
결재중
완료
반려
| 기능 | 설명 | 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 푸시로 전달됩니다.
| 기능 | 설명 | 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로 로드.
| 화면 | 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 키 불필요) |
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 |
| 파일 | 위치 | 역할 |
|---|---|---|
| globals.properties | src/main/resources/ | 파일서버 경로, 앱 설정 등 환경별 변수 |
| mybatis-config.xml | src/main/resources/ | MyBatis 전역 설정 (camelCase 매핑 등) |
| context-datasource.xml | src/.../spring/ | DB 연결 풀 설정 |
| context-servlet.xml | src/.../spring/ | MVC 설정, ViewResolver, 인터셉터 |
| context-mapper.xml | src/.../spring/ | MyBatis SqlSessionFactory, Mapper 스캔 |
| context-transaction.xml | src/.../spring/ | 트랜잭션 설정 (select는 readOnly) |
| context-common.xml | src/.../spring/ | 컴포넌트 스캔 설정 |
| web.xml | src/main/webapp/WEB-INF/ | 서블릿 설정, 세션 타임아웃, 인코딩 필터 |
<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/**는 인증 없이 접근 가능합니다.
새 화면은 자동으로 인터셉터 적용 대상이 됩니다.
- 포트 충돌:
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 확인
- Spring Security CSRF 설정 확인 (사용 중인 경우)
- 세션 만료 여부 확인 (401 → 로그인 페이지로 이동)
- Controller @GetMapping/@PostMapping URL 정확성 확인
| 로그 유형 | 위치 | 내용 |
|---|---|---|
| Tomcat 서버 로그 | $TOMCAT_HOME/logs/catalina.out | JVM 오류, 기동/중지 이력 |
| 애플리케이션 로그 | $TOMCAT_HOME/logs/handson.log | Controller, Service 로그 (log.info/error) |
| DB 실행 이력 | TB_PENTA_PROGRAM_RUN_LOG | 화면 접근 이력 |
| 로그인 이력 | TB_PENTA_LOGIN_LOG | 로그인 성공/실패 이력 |
-- 최근 로그인 이력 확인 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. 아키텍처 개요
JSP는
PentaNative.takePhoto() 등 Promise API만 호출하면 됩니다. 앱 환경이면 네이티브 브리지로, 웹 브라우저 환경이면 자동으로 폴백(파일선택/직접입력/localStorage)으로 처리합니다.
| 구성 요소 | Android | iOS | 역할 |
|---|---|---|---|
| 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() 수신
| 메서드 | 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.kt의 handleNativeRequest()에 when 분기를 추가하면 됩니다.
① 프로젝트 파일 구조
├── 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 (사진 촬영)
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-13 | EAN_13 | 상품 바코드 13자리 |
| EAN-8 | EAN_8 | 상품 바코드 8자리 |
| Code128 | CODE_128 | 물류·재고 바코드 |
| Code39 | CODE_39 | 산업용 바코드 |
| ITF | ITF | 물류 박스 바코드 |
| UPC-A/E | UPC_A / UPC_E | 북미 상품 바코드 |
| PDF417, Aztec, DataMatrix | PDF417 / AZTEC / DATA_MATRIX | 2D 코드 계열 |
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)
역지오코딩(좌표→주소)은 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: '© 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;
}
| 컬럼명 | 타입 | 설명 |
|---|---|---|
| GPS_LAT | NUMBER(12,8) | 위도 (예: 37.56680000) |
| GPS_LNG | NUMBER(12,8) | 경도 (예: 126.97800000) |
| GPS_ADDR | VARCHAR2(500) | 역지오코딩 주소 문자열 |
| GPS_DATE | DATE | 위치 측정 일시 |
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 프로젝트는 구글 계정에 종속되므로, 서비스 운영 주체(고객사)의 구글 계정으로 생성해야 합니다. 개발사 계정으로 만들면 나중에 이관이 어렵습니다.
STEP 1. Google 계정 준비
Firebase는 구글 서비스이므로 구글 계정(Gmail)이 필요합니다. 회사 Google Workspace 계정 또는 개인 Gmail 계정 모두 사용 가능합니다.
| 확인 항목 | 내용 |
|---|---|
| 사용할 구글 계정 | 서비스 운영 주체(고객사) 소유 계정 권장 |
| 브라우저 | Chrome 권장 (Google 계정 로그인 상태 유지) |
STEP 2. Firebase Console 접속 & 프로젝트 생성
접속하면 아래 그림과 같이 Firebase 프로젝트 목록 화면이 나타납니다. 처음이라면 아무 프로젝트도 없고 [프로젝트 추가] 카드만 보입니다.
① 프로젝트 만들기 클릭
| 화면에 보이는 것 | 해야 할 것 |
|---|---|
| 왼쪽 상단 새 Firebase 프로젝트 만들기 카드 (불꽃 아이콘) | 클릭합니다 |
| 오른쪽의 프로젝트 및 작업공간 드롭다운 | 기존 프로젝트가 있다면 여기서 선택할 수 있습니다 (신규라면 왼쪽 카드 클릭) |
② 프로젝트 이름 입력
| 입력 칸 이름 | 입력할 값 | 설명 |
|---|---|---|
| 프로젝트 이름 | HandsOn-Production (예시, 자유롭게 지정) |
나중에 Firebase Console에서 구분하는 이름. 한글도 가능하지만 영문 권장. |
| 프로젝트 ID (자동 생성) | 자동으로 채워짐 — 수정 불필요 | 전 세계에서 유일한 ID로 자동 생성됩니다. 특별히 바꿀 필요 없습니다. |
③ Google 애널리틱스
다음 화면에서 Google 애널리틱스 사용 여부를 묻습니다. 사용 안 함(비활성화)으로 선택해도 푸시 기능에는 전혀 영향 없습니다.
[프로젝트 만들기]를 클릭하면 약 30초 후 완료됩니다. [계속]을 눌러 프로젝트 대시보드로 이동합니다.
STEP 3. Android 앱 등록 & google-services.json 다운로드
프로젝트 대시보드 중앙에 앱 플랫폼 아이콘들(iOS, Android, 웹)이 보입니다. Android 아이콘(로봇 모양)을 클릭합니다.
app/build.gradle.kts의 applicationId 값을 그대로 복사합니다① 앱 등록 화면 — 입력 항목
| 입력 칸 이름 | 입력할 값 | 어디서 확인하나 |
|---|---|---|
| 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 설정 안내 단계가 나오면 [다음] → [다음] → [콘솔로 이동]을 눌러 건너뜁니다.
이후 아래 화면(프로젝트 설정)에서 파일을 다운로드합니다. 나중에 언제든지 같은 방법으로 재다운로드할 수 있습니다.
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 키 (자동 생성)
}]
}
개발팀은 파일을 앱 소스의
HandsOnApp/app/ 폴더에 복사한 뒤 앱을 빌드합니다.나중에 파일을 잃어버려도 재다운로드 가능합니다:
Firebase Console → 프로젝트 설정(⚙️) → [내 앱] 탭 → 앱 선택 → [google-services.json 다운로드]
다운로드 완료 후 화면에서 [다음] → [다음] → [콘솔로 이동]을 클릭해 대시보드로 돌아갑니다.
STEP 4. 서비스 계정 키 발급 (서버에서 푸시 발송용)
앞에서 받은 google-services.json은 앱(수신)용입니다. 서버에서 푸시를 발송하려면 별도의 서버 인증 키가 필요합니다. 이것을 서비스 계정 키라고 합니다.
① 메뉴 경로 (방법 A — Firebase Console에서 직접)
① 메뉴 경로 (방법 B — Google Cloud Console에서)
Firebase Console의 서비스 계정 탭이 Google Cloud Console로 연결되는 경우 아래와 같이 진행합니다.
② 키 발급 방법
| 단계 | 화면에 보이는 것 | 해야 할 것 |
|---|---|---|
| 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 |
| Windows | C:\handson\config\firebase-key.json |
② globals.properties 파일에 경로 등록
globals.properties는 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 → 서비스 계정 키 재발급 필요 |
@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);
}
}
<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() 호출 |
// 로그인 성공 콜백에서 호출
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을 아래 위치에 넣고 앱을 빌드합니다.
├── 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.json을 app/ 폴더에 배치 후 앱 빌드 완료 | 앱 개발팀 | ☐ |
firebase-key.json을 서버 보안 경로에 배치 완료 | 서버 운영팀 | ☐ |
globals.properties에 firebase.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 파일 위치 및 포함 방법
└── 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 앱의 각 화면 구성과 주요 기능을 스크린샷과 함께 설명합니다. 모든 화면은 모바일 세로 레이아웃 기준이며, 하단 내비게이션(홈·업무·메시지·설정)으로 이동합니다.
• 상단 헤더: 햄버거 메뉴(좌), 앱 로고, 사용자 아바타(우)
• 조회 조건 바: 탭하면 펼쳐지고, 포커스를 잃으면 자동으로 닫히며 자동 조회
• 통계 바: 상태별 건수를 한눈에 표시
• 상세 보기: 위→아래 슬라이드 패널로 표시 (화면 이탈 없음)
• 알림:
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일). 조건 바를 탭하면 날짜 범위 수정 가능. |
|
결제 상세 (슬라이드 패널) 항목 탭 시 위→아래 슬라이드 패널. 가맹점명·업종, 최종금액·상태(승인/취소) 상단 강조 표시. 세부: 결제일시, 카드명·번호(마스킹), 승인번호, 결제유형, 할부, 결제금액·할인·최종금액. |
|
비고 편집 및 저장 슬라이드 패널 하단에 비고(메모) 입력 영역. 내용 수정 후 [저장] 버튼으로 UPD_USER·UPD_DATE 업데이트. |
14-8. 표 업무 (재고현황 / Sample 2)
| 화면 | 설명 |
|---|---|
|
재고현황 테이블 TB_PENTA_BUSINESS_SAMPLE_02 조회. 컬럼: 품목코드▲▼·품목명▲▼·상태▲▼·재고▲▼·분류▲▼·단가▲▼·재고금액▲▼. • 현재 정렬 컬럼에 화살표 아이콘 강조 표시 • 상태 뱃지: ✅ 정상(초록) / ⚠ 부족(주황) / ▲ 과잉(파랑) / ❌ 소진(빨강) • 상단 통계: 전체·정상·부족·과잉·소진 건수 / 총 재고금액 • 더보기 버튼으로 페이징 없이 추가 로드 |
|
검색 조건 패널 (펼침) 조회 조건 바를 탭하면 펼쳐짐: 품목명(텍스트), 분류(드롭다운), 창고(드롭다운), 재고상태(드롭다운). 조건 변경 후 자동 조회. |
|
재고 상세 (슬라이드 패널) 행 탭 시 슬라이드 패널. 품목명·상태 아이콘 상단, 재고 수량 프로그레스 바(최소~최대 범위). 품목코드·분류·창고·단가·재고금액·최근 입출고일·등록일. 비고 편집 후 [저장]. |
14-9. 차트형 업무 (영업 차트 / Sample 3)
| 화면 | 설명 |
|---|---|
|
Line · Bar 차트 연도 선택(드롭다운)으로 전체 차트 일괄 갱신. • 매출 추이 Line: 월별 실적 vs 목표 비교. 최근 매출·증가율 KPI 카드 표시. • 월별 매출 Bar: 지점별 월간 매출 비교. |
|
Doughnut · Radar 차트 • 채널 비중 Doughnut: 직판/대리점/온라인/공공입찰 비중. • 영업 역량 Radar: 팀별 제품지식·리드·전환·유지·협상 역량 방사형 비교. |
|
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=00019DB 테이블: TB_PENTA_BUSINESS_SAMPLE_12 (설문), TB_PENTA_BUSINESS_SAMPLE_12_OPT (선택지), TB_PENTA_BUSINESS_SAMPLE_12_VOTE (투표 결과) |
|
투표 결과 이미 투표한 설문은 🔴 참여완료 뱃지로 표시. 도넛형 파이 차트로 선택지별 득표수 시각화. 차트 아래 범례(항목명·득표수)와 바 형태의 퍼센트 표시줄로 상세 결과 확인 가능. 총 투표수는 차트 중앙에 숫자로 표시. |
14-22. 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) | 설문 목록·투표·결과 파이 차트 | - |
| 26 | Gemini AI 챗봇 | Google Gemini API 연동 채팅 | - |
| 27 | On-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 |
화면에서 버튼을 누르면 어떤 경로로 처리되는지 이해해야 합니다. 파워빌더의 dw_1.Retrieve()가 아래 전체 흐름을 한 번에 처리했다면, 여기서는 각 단계가 별도 파일로 나뉩니다.
URL 매핑·세션 확인
업무 로직
SQL 실행
쿼리 정의
결과는 역순으로 올라와 Controller에서 JSON으로 변환되어 JSP의 JavaScript로 전달됩니다. JavaScript가 받은 JSON을 화면에 그립니다.
├── 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 복사 후 수정)
-
Package Explorer에서 파일 찾기Eclipse 왼쪽 Package Explorer 탭 →
src/main/java→egovframework.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 = 사용자ID + 등록일시 + 순번 조합
같은 사람이 같은 시각에 등록 → 순번이 다르므로 PK 중복 없음
순번 = MAX(순번)+1 서브쿼리로 자동 채번
REG_USER - 등록자
REG_DATE DATE - 등록일시
UPD_USER - 수정자
UPD_DATE DATE - 수정일시
팝업: alert() 금지 → Swal.fire() 사용
URL: 반드시 *.do 패턴
CSS: 화면별 전용 CSS 작성 (공통 파일에 넣지 않음)
인코딩: UTF-8
어떤 화면인가?
카드(card) 형태로 목록이 날짜별로 그룹지어 표시되고, 항목을 누르면 상세 내용이 위에서 아래로 슬라이드되어 내려오는 화면입니다.
신용카드 결제내역, 업무 이력, 지출 내역처럼 "내 거래 기록을 날짜순으로 보고 싶다"는 업무에 적합합니다.
- 조회조건 패널: sticky 접이식. 기간(기본 15일), 가맹점명, 업종 select, 결제상태 select, 결제유형 select + 조회/초기화 버튼
- 통계 바: 전체건수 · 승인 · 취소 · 결제액 · 할인 5개 항목을 상단에 한 줄로 요약
- 카드 리스트: 날짜별 구분선 헤더 → 상태 원형 아이콘, 가맹점명, 상태 배지, 금액(우측), 시간 → 더보기 버튼
- 상태 색상: 승인 #2e7d32(초록), 취소 #c62828(빨강). 주색상 #667eea(라벤더)
- 슬라이드 상세 패널: 위→아래 슬라이드. 큰 금액+상태 배지 → 결제일시·카드·승인번호·할부(회색 섹션) → 결제금액·할인금액·최종금액 → 비고 입력(노란 배경)
- 주요 함수:
doSearch(),loadList(),renderList(),renderDetail(),saveNote(),escHtml()
| 역할 | 원본 파일 (복사 원본) | 새로 만들 파일명 | 작업 유형 |
|---|---|---|---|
| 화면 데이터 구조 | vo/BusinessSample01VO.java | vo/BusinessSample##VO.java | 📋 복사 후 수정 |
| SQL 쿼리 정의 | sqlmap/business/BusinessSample01_SQL.xml | sqlmap/business/BusinessSample##_SQL.xml | 📋 복사 후 수정 |
| 화면 HTML | jsp/business/sample01view.jsp | jsp/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 SQLCREATE 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에서<,>는<,>로 써야 합니다. 예:AND qty <= 100 - Step 4 — DAO 메서드 추가왜 필요한가? DAO(Data Access Object)는 SQL XML과 Java 코드를 연결하는 다리 역할입니다. 파워빌더의 DataStore 객체에 해당합니다. SQL XML의 id와 DAO 메서드명이 정확히 일치해야 MyBatis가 연결합니다.
Eclipse 작업:Ctrl+Shift+R→BusinessDAO입력 → 파일 열기 →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+R→BusinessService.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 변경 (파일 내sample01→sample##전체 치환)// 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.java | egovframework.penta.business | 기존 파일에 메서드 추가 |
| BusinessSampleNNVO.java | egovframework.penta.business | VO (Lombok @Data) |
| BusinessSampleNNDAO.java | egovframework.penta.business.dao | MyBatis DAO 인터페이스 |
| BusinessSampleNN_SQL.xml | sqlmap/ | MyBatis SQL |
| sampleNNview.jsp | WEB-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 || '저장 실패'});
}
});
}
어떤 화면인가?
Excel 같은 테이블 형태로 데이터가 표시되고, 컬럼 헤더를 클릭하면 정렬이 바뀝니다. 모바일에서는 주요 컬럼만 보이다가 화면이 커지면 모든 컬럼이 나타나는 반응형 화면입니다.
재고현황, 인원 목록, 자산 현황 등 "여러 항목을 표로 비교하고 싶다"는 업무에 적합합니다.
- 조회조건 패널: 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
Sort()와 같지만, 여기서는 서버에서 정렬합니다. 컬럼명이 SQL에 직접 들어가므로 보안을 위해 반드시 허용 컬럼 목록을 검증해야 합니다.
| 역할 | 원본 파일 (복사 원본) | 새로 만들 파일명 | 작업 유형 |
|---|---|---|---|
| 화면 데이터 구조 | vo/BusinessSample02VO.java | vo/BusinessSample##VO.java | 📋 복사 후 수정 |
| SQL 쿼리 정의 | sqlmap/business/BusinessSample02_SQL.xml | sqlmap/business/BusinessSample##_SQL.xml | 📋 복사 후 수정 |
| 화면 HTML | jsp/business/sample02view.jsp | jsp/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+H →sample02를sample##로 교체 (현재 파일에만) - 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');
});
}
어떤 화면인가?
매출, 통계 데이터를 막대·선·원형·도넛 등 다양한 차트로 시각화합니다. 차트 1개 = DB 테이블 1개 = SQL 1개 = AJAX 호출 1개 구조입니다. 조건(기간, 지점 등)이 바뀌면 모든 차트를 한 번에 다시 그립니다.
- 조회조건 패널: 접이식. 연도 select만 사용 (단순 조건)
- 차트 13종: 선형 차트+미니 통계(최근매출·증가율), 막대, 파이·도넛(2열 그리드), 레이더·폴라에어리어(2열), 버블·스캐터(2열), 스택형막대·가로막대(2열), 영역선·콤보(2열), 게이지(도넛형—중앙에 % 수치)
- 차트 컨테이너: border-radius:16px + box-shadow 카드 스타일
- 상세 패널 없음: 차트는 모두 inline 표시. 슬라이드 패널 없음
- 주요 함수:
clearCharts(),renderCharts(), 차트별renderLineChart()/renderBarChart()등 13개 함수 - 라이브러리: Chart.js. 차트 인스턴스는 전역 변수로 보관, 재조회 시
chart.destroy()호출 필수
chart.destroy()로 먼저 파괴한 뒤 새로 생성해야 합니다(파괴하지 않으면 메모리 누수).
| 역할 | 원본 파일 | 새 파일명 | 작업 |
|---|---|---|---|
| VO | vo/BusinessSample03VO.java | vo/BusinessSample##VO.java | 📋 복사 후 수정 |
| SQL | sqlmap/business/BusinessSample03_SQL.xml | sqlmap/business/BusinessSample##_SQL.xml | 📋 복사 후 수정 |
| JSP | jsp/business/sample03view.jsp | jsp/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.java | label, series, value1~3, xValue, yValue, rValue, year 공통 필드 |
| BusinessSampleNN_SQL.xml | 차트 테이블별 조회 쿼리 |
| sampleNNview.jsp | Chart.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);
어떤 화면인가?
A 사용자가 문서를 기안하여 결재자 B에게 요청하면, B가 승인하거나 반려하는 전자결재 화면입니다.
다단계 결재선을 지원하며, 각 결재 처리 시 다음 결재자에게 메시지가 자동 발송됩니다.
결재 상태: 임시저장(DRAFT) → 결재요청(REQ) → 승인(APR) / 반려(REJ)
- 접근 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
@Transactional을 붙여야 합니다. 파워빌더의 SQLCA.BeginTrans()/CommitTrans()/RollBack()과 같은 역할입니다.
| 역할 | 원본 파일 | 새 파일명 | 작업 |
|---|---|---|---|
| VO | vo/BusinessSample04VO.java | vo/BusinessSample##VO.java | 📋 복사 후 수정 |
| SQL | sqlmap/business/BusinessSample04_SQL.xml | sqlmap/business/BusinessSample##_SQL.xml | 📋 복사 후 수정 |
| JSP | jsp/business/sample04view.jsp | jsp/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의sample04를sample##으로 전체 치환. 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.jsp→ Ctrl+H로sample04를sample##으로 전체 치환(현재 파일만) → 화면 제목·컬럼 라벨·상태코드 교체. - 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||'처리 실패'});
}
});
});
}
어떤 화면인가?
회의실·차량·장비 등 공유 자원을 특정 시간대에 예약하고, 시간 중복 예약을 방지하는 화면입니다. 자원 마스터 목록과 예약 테이블 두 개가 연동됩니다.
- 화면 제목: 자원관리 업무
- 조회조건 패널: 접이식. 예약일자, 자원유형 select, 검색어(자원명/위치) + 조회/초기화
- 통계 바: 자원수 · 예약수 · 내예약수 — 3항목
- 이중 목록 구성: 상단 자원 목록(카드형 — 원형 아이콘, 자원명, 오늘예약건수, 상태배지) + 하단 예약 목록(항목형 — 시간대, 자원명, 내예약 태그, 사용자, 목적)
- 슬라이드 상세 패널: 예약 기본정보(카드) → 사용목적(카드) → 하단 액션 버튼(취소·수정·삭제)
- 예약 등록 패널: 별도 슬라이드. 예약일자, 자원 select, 시작~종료 시간, 사용목적 입력
- FAB 버튼: 화면 우하단 + 버튼 → 예약 등록 슬라이드 오픈
- 주요 함수:
loadList(),renderResourceList(),renderReservationList(),saveReservation(),deleteReservation(),openDetailPanel()
| 역할 | 원본 파일 | 새 파일명 | 작업 |
|---|---|---|---|
| VO | vo/BusinessSample05VO.java | vo/BusinessSample##VO.java | 📋 복사 후 수정 |
| SQL | sqlmap/business/BusinessSample05_SQL.xml | sqlmap/business/BusinessSample##_SQL.xml | 📋 복사 후 수정 |
| JSP | jsp/business/sample05view.jsp | jsp/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 / ServiceImplSAMPLE_05 원본 파일을 복사하여
sample05를sample##으로 일괄 치환합니다.
중복 예약 체크 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.jsp→ Ctrl+H로sample05→sample##일괄치환.
메뉴 등록은 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})
*/
어떤 화면인가?
텍스트·날짜·드롭다운·라디오·체크박스·별점·파일첨부 등 다양한 입력 컨트롤이 있는 등록/수정/삭제(CRUD) 폼 화면입니다. 목록에서 항목을 선택하면 수정 모드로 전환되고, 하단에 신규 등록 폼이 있습니다.
- 화면 제목: 현장 접수 업무
- 조회조건: 접이식. 시작일~종료일, 상태 select, 검색어(제목/고객명/메모)
- 통계 바: 접수건수 · 완료 · 진행중 · 평균긴급도 — 4항목
- 카드 리스트: 제목+날짜 / 상태·유형·방식 칩 배지 / 수량·긴급도·중요도·방문일 / 현장메모
- 슬라이드 등록/상세 패널: 텍스트 입력, 음성입력(SpeechRecognition), 장애분류(바텀시트), 수량 스테퍼(±), 긴급도 range 슬라이더, 날짜/시간, 유형 select, 방문구분 라디오, 개인정보동의 체크박스, 고객중요도 별점(5개), 정산월(month), 처리예정주(week), datetime-local, 필요자재 체크박스
- 특수 기능: QR/바코드 스캔 버튼, 우편번호 검색 팝업+도로명 주소, GPS 버튼+지도 미리보기, 파일선택/갤러리/사진촬영 버튼, 고객 서명 캔버스(canvas drawing)+지우기, 표시색상 color picker
- FAB 버튼: + (신규 등록 슬라이드 오픈)
- 주요 함수:
doSearch(),loadList(),renderList(),resetForm(),fillForm(),saveForm(),deleteForm()
<input> 태그로 폼을 만들고, 저장 버튼이 AJAX POST로 서버에 전송합니다.
| 역할 | 원본 파일 | 새 파일명 | 작업 |
|---|---|---|---|
| VO | vo/BusinessSample05VO.java | vo/BusinessSample##VO.java | 📋 복사 후 수정 |
| SQL | sqlmap/business/BusinessSample05_SQL.xml | sqlmap/business/BusinessSample##_SQL.xml | 📋 복사 후 수정 |
| JSP | jsp/business/sample05view.jsp | jsp/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||'저장 실패'});
}
});
}
어떤 화면인가?
스마트폰 카메라로 바코드/QR코드를 스캔하고 서버에서 품목 정보를 조회하거나 입고·출고를 기록하는 화면입니다.
네이티브 앱(Android/iOS)과 연동되며, PC 브라우저에서는 수동 입력으로 테스트합니다.
- 화면 제목: 바코드 스캔
- 조회조건: 접이식. 스캔일(from~to), 바코드구분 select, 바코드 검색어
- 테이블 리스트: No · 스캔일시 · 바코드구분 · 바코드번호 · 방식(배지) — 5컬럼. 헤더 정렬 아이콘. 좁은 화면에서 col-date-hide/col-mobile-hide로 컬럼 숨김
- 슬라이드 상세 패널: 기본정보(스캔일시, 방식, 구분, 번호, 처리결과) + 바코드 저장값(상품코드, 상품명, 카테고리, 제조사, 단위, 단가, 판매가, 재고수량, 재고상태, RAW값)
- FAB 버튼 2개: 단건 스캔 / 연속 스캔 토글(ON=초록, OFF=빨강)
- 주요 함수:
loadList(),sortBy(),openDetail(),startSingleScan(),runContinuousLoop(),submitScan()
egovframework.penta.api 패키지에 파일을 추가합니다. 수정 대상 파일이 달라집니다.
| 역할 | 원본 파일 | 새 파일명 | 작업 |
|---|---|---|---|
| VO | api/vo/ApiSample06VO.java | api/vo/ApiSample##VO.java | 📋 복사 후 수정 |
| SQL | sqlmap/api/Api_SQL.xml | 동일 파일 | ✏️ 기존 파일에 쿼리 추가 |
| JSP | jsp/business/sample06view.jsp | jsp/business/sample##view.jsp | 📋 복사 후 수정 |
| Controller | api/controller/ApiController.java | 동일 파일 | ✏️ 추가 |
| Service (인터페이스) | api/service/ApiService.java | 동일 파일 | ✏️ 추가 |
| Service (구현) | api/service/impl/ApiServiceImpl.java | 동일 파일 | ✏️ 추가 |
| DAO | api/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.java | egovframework.penta.api.controller | 바코드 엔드포인트 |
| ApiService.java / ApiServiceImpl.java | egovframework.penta.api.service(.impl) | 비즈니스 로직 |
| ApiDAO.java | egovframework.penta.api.dao | MyBatis DAO |
| Api_SQL.xml | sqlmap/api/ | 바코드 CRUD SQL |
| sampleNNview.jsp | WEB-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;
}
어떤 화면인가?
스마트폰 카메라로 사진을 찍거나 갤러리에서 선택한 후 서버에 업로드하고, 업로드된 사진을 썸네일 목록으로 표시하는 화면입니다. 사진은 공통 파일 서비스(SystemFileService)를 통해 서버에 저장됩니다.
- 화면 제목: 재고조사 업무
- 조회조건: 접이식. 조사일자(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()
egovframework.penta.api 패키지의 Controller/Service/DAO를 수정합니다.
| 역할 | 원본 파일 | 새 파일명 | 작업 |
|---|---|---|---|
| VO | api/vo/ApiSample06VO.java | api/vo/ApiSample##VO.java | 📋 복사 후 수정 |
| SQL | sqlmap/api/Api_SQL.xml | 동일 파일 | ✏️ 추가 |
| JSP | jsp/business/sample07view.jsp | jsp/business/sample##view.jsp | 📋 복사 후 수정 |
| Controller/Service/ServiceImpl/DAO | api 패키지 파일들 | 동일 파일 | ✏️ 추가 |
- 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;
});
}
어떤 화면인가?
설비 마스터와 시간대별 가동·비가동 이력을 관리하고 가동률(%)을 계산하는 화면입니다. 설비목록, 이력 추가, 가동률 차트로 구성됩니다.
이 섹션은 SAMPLE_12 참조 화면을 기준으로 작성합니다. 개발 전에 해당 JSP 파일을 직접 열어 실제 화면 구조(조회조건, 목록, 상세 패널, 버튼 구성)를 확인한 후 아래 단계를 진행하세요. 위 sample01~08 섹션의 "실제 화면 구성" 상자가 참고 형식입니다.
| 역할 | 원본 파일 | 새 파일명 | 작업 |
|---|---|---|---|
| VO | api/vo/ApiSample06VO.java (범용) | api/vo/ApiSample##VO.java | 📋 복사 후 수정 |
| SQL | sqlmap/api/Api_SQL.xml | 동일 파일 | ✏️ 추가 |
| JSP | jsp/business/sample12view.jsp | jsp/business/sample##view.jsp | 📋 복사 후 수정 |
| Controller/Service/ServiceImpl/DAO | api 패키지 파일들 | 동일 파일 | ✏️ 추가 |
- 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+H로sample12→sample##치환합니다.
테이블 설계
-- 설비 마스터
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;
}
어떤 화면인가?
GPS로 현재 위치를 파악하고 지도에 마커를 표시하거나, 특정 위치를 지도에서 선택해 저장하는 화면입니다.
Leaflet.js + OpenStreetMap을 사용하므로 별도의 API 키가 필요 없어 내부망 환경에 적합합니다.
이 섹션은 SAMPLE_08/13 참조 화면을 기준으로 작성합니다. 해당 JSP 파일을 직접 열어 실제 지도 컴포넌트, 마커 렌더링 방식, 목록 구성을 확인한 후 아래 단계를 진행하세요.
egovframework.penta.api 패키지를 사용합니다. GPS는 브라우저 표준 API(navigator.geolocation)로 취득하므로 별도 앱 연동이 없습니다.
| 역할 | 원본 파일 | 새 파일명 | 작업 |
|---|---|---|---|
| VO | api/vo/ApiSample06VO.java | api/vo/ApiSample##VO.java | 📋 복사 후 수정 |
| SQL | sqlmap/api/Api_SQL.xml | 동일 파일 | ✏️ 추가 |
| JSP | jsp/business/sample08view.jsp 또는 sample13view.jsp | jsp/business/sample##view.jsp | 📋 복사 후 수정 |
| Controller/Service/ServiceImpl/DAO | api 패키지 | 동일 파일 | ✏️ 추가 |
- 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>
어떤 화면인가?
GPS로 현재 위치를 취득하고, Nominatim 역지오코딩으로 주소를 자동으로 채운 뒤 방문 내용을 기록하는 화면입니다.
저장된 방문 이력은 날짜별 카드 목록으로 표시되며, 각 항목을 누르면 Leaflet 지도와 함께 상세 슬라이드 패널이 열립니다.
사진 첨부도 지원합니다.
sample14view.jsp — /business/sample14view.do?progId=00018GPS + 역지오코딩 + Leaflet 지도를 모두 포함하는 가장 복합적인 패턴입니다. 지도형(16-10)에 방문 입력 폼과 사진 업로드가 추가된 형태입니다.
| 역할 | 원본 파일 | 새 파일명 | 작업 |
|---|---|---|---|
| VO | api/vo/ApiSample14VO.java | api/vo/ApiSample##VO.java | 📋 복사 후 수정 |
| SQL | sqlmap/api/Api_SQL.xml | 동일 파일 | ✏️ 추가 |
| JSP | jsp/business/sample14view.jsp | jsp/business/sample##view.jsp | 📋 복사 후 수정 |
| Controller/Service/ServiceImpl/DAO | api 패키지 | 동일 파일 | ✏️ 추가 |
- 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.
어떤 화면인가?
진행 중인 설문 목록을 카드로 보여주고, 라디오버튼으로 즉시 투표할 수 있는 화면입니다.
이미 투표한 설문은 도넛형 파이 차트로 결과를 시각화합니다.
설문·선택지·투표 결과를 3개 테이블로 관리합니다.
sample15view.jsp — /business/sample15view.do?progId=00019Chart.js로 도넛 차트를 렌더링하며, 투표 여부를 서버에서 판단해 같은 화면에서 투표 폼과 결과 차트를 전환합니다.
| 테이블 | 역할 | 주요 컬럼 |
|---|---|---|
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 / ServiceImplapi 패키지에 설문 목록 조회, 투표 여부 확인, 투표 저장, 결과 집계용 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.
어떤 화면인가?
외부 AI API(Google Gemini) 또는 내부망 AI 서버(Ollama)와 연동하여 채팅 인터페이스를 제공하는 화면입니다. 보안을 위해 AI API 키는 서버(globals.properties)에만 보관하고, 브라우저는 Spring Controller를 통해 간접적으로 AI에 접근합니다.
AI 챗봇 화면은 별도 JSP 파일로 구현됩니다. 해당 JSP 파일을 직접 열어 채팅 UI 구성(말풍선 렌더링 방식, 입력 바, API 엔드포인트 연동 방식)을 확인한 후 이 가이드의 코드를 참고하세요.
| 역할 | 파일 | 작업 |
|---|---|---|
| JSP | jsp/business/aiview.jsp (원본) | 📋 복사 후 수정 |
| Controller | api/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 / Impl | Gemini/Ollama API 호출 비즈니스 로직 |
| globals.properties | API 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 | 자료형 | 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키 노출 절대 금지 |