세션 유지 기능 추가 및 FAQ 관리 개선:

- 비운영 환경 + DB 설정 활성 시 세션 유지(타임아웃 무시) 지원
- FAQ 단건 조회 API 추가 (`FaqFacade`, `FaqFacadeImpl`)
- 헤더 세션 타이머 UI 개선 및 유지 옵션 체크박스 추가
This commit is contained in:
Rinjae
2026-07-10 16:36:11 +09:00
parent 7d6aabe122
commit 99f765507b
16 changed files with 268 additions and 71 deletions
@@ -13,6 +13,7 @@ import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
@Controller
public class FaqController {
@@ -47,4 +48,16 @@ public class FaqController {
return "apps/community/mainFaqList";
}
@GetMapping("/faq_view")
public String view(@RequestParam(value = "id", required = false) String id, Model model) {
if (id == null) {
return "redirect:/faq_list";
}
FaqDTO faq = faqFacade.getFaq(id);
model.addAttribute("faq", faq);
return "apps/community/mainFaqDetail";
}
}
@@ -7,4 +7,6 @@ import org.springframework.data.domain.Pageable;
public interface FaqFacade {
Page<FaqDTO> getFaqs(FaqSearch search, Pageable pageable);
FaqDTO getFaq(String id);
}
@@ -34,4 +34,9 @@ public class FaqFacadeImpl implements FaqFacade {
return faqPage.map(faqMapper::map);
}
@Override
public FaqDTO getFaq(String id) {
return faqMapper.map(faqService.findById(id));
}
}
@@ -23,6 +23,10 @@ public class UserSessionService {
private static final String PROPERTY_NAME = "session.timeout.minutes";
private static final String DEFAULT_TIMEOUT_MINUTES = "15";
/** 세션 유지(타임아웃 무시) 기능 활성화 여부 프로퍼티 (true/false). 비운영 전용 — prod 가드는 상위(GlobalControllerAdvice)에서 적용 */
private static final String KEEPALIVE_PROPERTY_NAME = "session.keepalive.enabled";
private static final String DEFAULT_KEEPALIVE_ENABLED = "false";
/** 논리적 만료(lastAccessTime + 타임아웃) 이후 물리적 삭제까지 두는 안전 마진(분) */
private static final long CLEANUP_MARGIN_MINUTES = 1;
@@ -133,6 +137,20 @@ public class UserSessionService {
}
}
/**
* DB(PortalProperty)에서 세션 유지(타임아웃 무시) 기능 활성화 여부 조회.
* ⚠️ prod 가드는 포함하지 않는다 — prod 차단은 호출부(GlobalControllerAdvice)에서 {@code !prod && isKeepAliveEnabled()}로 처리.
*/
public boolean isKeepAliveEnabled() {
String value = portalPropertyService.getOrCreateProperty(
PROPERTY_GROUP,
KEEPALIVE_PROPERTY_NAME,
DEFAULT_KEEPALIVE_ENABLED,
"세션 유지(타임아웃 무시) 기능 활성화 여부 (true/false, 비운영 전용)"
);
return value != null && Boolean.parseBoolean(value.trim());
}
/**
* 세션의 잔여 시간(초) 계산. 세션 레코드가 없으면 0.
*/
@@ -4,6 +4,8 @@ import java.util.List;
import java.util.Map;
import javax.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.env.Environment;
import org.springframework.core.env.Profiles;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ModelAttribute;
import com.eactive.apim.portal.config.PortalProperties;
@@ -25,6 +27,9 @@ public class GlobalControllerAdvice {
@Autowired
private ClientGuardService clientGuardService;
@Autowired
private Environment environment;
@ModelAttribute("breadcrumb")
public List<Map> addBreadcrumbToModel(HttpServletRequest request) {
String currentPath = request.getRequestURI();
@@ -60,6 +65,17 @@ public class GlobalControllerAdvice {
return userSessionService.getSessionTimeoutMinutes();
}
/**
* 세션 유지(타임아웃 무시) 기능 사용 가능 여부.
* 비운영(!prod) + DB property(Portal/session.keepalive.enabled=Y)일 때만 true.
* prod 환경에서는 property 값과 무관하게 항상 false → UI 체크박스 미렌더.
*/
@ModelAttribute("sessionKeepAliveAllowed")
public boolean sessionKeepAliveAllowed() {
boolean prod = environment.acceptsProfiles(Profiles.of("prod"));
return !prod && userSessionService.isKeepAliveEnabled();
}
/**
* 클라이언트 가드 - 우클릭(contextmenu) 차단 활성화 여부.
* PortalProperty(Portal/djb.client-guard.contextmenu.enabled)에서 조회.
@@ -122,6 +122,10 @@ public class PortalAuthenticationSuccessHandler implements AuthenticationSuccess
userSessionService.registerSession(sessionId, String.valueOf(user.getId()), normalizedUsername,
clientIp, request.getHeader("User-Agent"));
// 물리 세션 타임아웃을 DB property(Portal/session.timeout.minutes)와 일치시킴.
// yml/weblogic.xml 기본값을 이 세션에 대해 override → 물리=논리 단일화(CSRF 수명 포함).
session.setMaxInactiveInterval(userSessionService.getSessionTimeoutMinutes() * 60);
// 로그인 성공 시 세션 정보 로깅
logLoginSuccess(request, session, username);
+8 -1
View File
@@ -2,7 +2,11 @@ server:
servlet:
context-path: /
session:
timeout: 10m
# 물리 세션 타임아웃은 DB PortalProperty(Portal/session.timeout.minutes)로 관리한다.
# 로그인 성공 시 PortalAuthenticationSuccessHandler 가
# session.setMaxInactiveInterval(session.timeout.minutes * 60) 으로 적용 → 물리=논리 일치.
# 익명/로그인 전 세션은 컨테이너 기본값으로 fallback (weblogic.xml <timeout-secs>1800).
# timeout: 10m
cookie:
name: JSESSIONID_PORTAL
encoding:
@@ -314,6 +318,9 @@ page:
faq_list:
name: FAQ
path: "/faq_list"
faq_detail:
name: FAQ
path: "/faq_view"
inquiry:
name: "Q&A"
path: "/inquiry"
+35 -5
View File
@@ -5264,14 +5264,44 @@ select.form-control {
font-weight: 700;
}
.session-timer-mobile {
margin-right: 8px;
font-size: 12px;
.session-float {
position: fixed;
top: 16px;
right: 20px;
z-index: 1000;
display: flex;
align-items: center;
gap: 8px;
padding: 6px 14px;
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
}
.session-timer-mobile .session-timer-text {
min-width: 34px;
.session-float .session-keepalive {
display: inline-flex;
align-items: center;
gap: 4px;
margin: 0;
padding-left: 8px;
border-left: 1px solid #e0e0e0;
font-size: 12px;
color: #64748B;
cursor: pointer;
white-space: nowrap;
}
.session-float .session-keepalive input {
margin: 0;
cursor: pointer;
}
@media (max-width: 768px) {
.session-float {
top: 12px;
right: 72px;
padding: 4px 10px;
}
}
.data-table {
width: 100%;
background: #FFFFFF;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -79,7 +79,9 @@
li.innerHTML = ''
+ '<div class="djb-comment-meta">'
+ ' <div class="djb-comment-meta-left">'
+ ' <span class="djb-comment-writer">' + escapeHtml(c.writerName || '(알 수 없음)') + '</span>'
+ (c.adminYn === 'Y'
? ' <span class="djb-comment-admin-badge">관리자</span>'
: ' <span class="djb-comment-writer">' + escapeHtml(c.writerName || '(알 수 없음)') + '</span>')
+ ' </div>'
+ ' <div class="djb-comment-meta-right">'
+ ' <span class="djb-comment-date">' + escapeHtml(formatDate(c.createdDate)) + '</span>'
@@ -32,12 +32,51 @@
}
}
// 모바일 헤더(햄버거 버튼 좌측)용 타이머
.session-timer-mobile {
margin-right: $spacing-sm;
font-size: $font-size-xs;
// -----------------------------------------------------------------------------
// 우측 상단 Float 세션 타이머 위젯
// 데스크톱: 헤더 우측(유저 메뉴 우측)에 상단 정렬로 고정(fixed).
// 모바일: 헤더 우측 햄버거(메뉴 버튼) 바로 왼쪽에 배치.
// -----------------------------------------------------------------------------
.session-float {
position: fixed;
top: 16px;
right: 20px;
z-index: 1000;
display: flex;
align-items: center;
gap: $spacing-sm;
padding: 6px 14px;
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
.session-timer-text {
min-width: 34px;
// 세션 유지 체크박스 (비운영 전용, 조건부 렌더)
.session-keepalive {
display: inline-flex;
align-items: center;
gap: 4px;
margin: 0;
padding-left: 8px;
border-left: 1px solid #e0e0e0;
font-size: $font-size-xs;
color: $text-gray;
cursor: pointer;
white-space: nowrap;
input {
margin: 0;
cursor: pointer;
}
}
}
// 모바일: 헤더 우측 햄버거(메뉴 버튼) 바로 왼쪽에 나란히 배치.
// right = 헤더 padding(20px) + 햄버거 폭(~32px) + 간격(~20px) ≈ 72px
@media (max-width: $breakpoint-sm) {
.session-float {
top: 12px;
right: 72px;
padding: 4px 10px;
}
}
@@ -11,7 +11,7 @@
</section>
<th:block layout:fragment="contentFragment">
<section class="user-management-container">
<section class="user-management-container notice-list-container">
<!-- Search and Filter Controls -->
<form name="faqForm" th:action="@{/faq_list}" method="post" th:object="${search}" onsubmit="return false;">
@@ -38,21 +38,36 @@
</div>
</form>
<!-- FAQ Accordion -->
<div class="faq-accordion" th:if="${!#lists.isEmpty(page.content)}">
<div class="faq-item" th:each="faq : ${page.content}">
<div class="faq-question">
<span class="faq-question-text" th:text="${faq.faqQuestion}">질문 내용이 여기에 표시됩니다.</span>
<span class="faq-icon">
<svg width="14" height="8" viewBox="0 0 14 8" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 1L7 7L13 1" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</span>
</div>
<div class="faq-answer">
<div class="faq-answer-content editor-content" th:utext="${faq.faqAnswer}">
답변 내용이 여기에 표시됩니다.
<!-- FAQ Table -->
<div class="list-table" th:if="${!#lists.isEmpty(page.content)}">
<!-- Table Header -->
<div class="list-table-header">
<div class="header-cell" style="width: 80px;">NO</div>
<div class="header-cell" style="flex: 1; min-width: 200px;">질문</div>
<div class="header-cell" style="width: 120px;">등록일</div>
</div>
<!-- Table Body -->
<div class="list-table-body">
<div class="list-table-row"
th:each="faq, status : ${page.content}"
th:onclick="'location.href=\'' + @{/faq_view(id=${faq.id})} + '\''"
style="cursor: pointer;">
<div class="row-cell row-cell--number" style="width: 80px;" data-label="NO"
th:text="${page.totalElements - (page.number * page.size) - status.index}">1</div>
<div class="row-cell row-cell--title" style="flex: 1; min-width: 200px;" data-label="질문">
<a th:href="@{/faq_view(id=${faq.id})}" class="notice-title-link">
<span class="notice-number" th:text="|[${page.totalElements - (page.number * page.size) - status.index}]|">[1]</span>
<span th:text="${faq.faqQuestion}">FAQ 질문</span>
<span class="file-icon" th:if="${faq.fileId != null and !faq.fileId.isEmpty()}">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 15.75C18 17.4833 17.3917 18.9583 16.175 20.175C14.9583 21.3917 13.4833 22 11.75 22C10.0167 22 8.54167 21.3917 7.325 20.175C6.10833 18.9583 5.5 17.4833 5.5 15.75V6.5C5.5 5.25 5.9375 4.1875 6.8125 3.3125C7.6875 2.4375 8.75 2 10 2C11.25 2 12.3125 2.4375 13.1875 3.3125C14.0625 4.1875 14.5 5.25 14.5 6.5V15.25C14.5 16.0167 14.2333 16.6667 13.7 17.2C13.1667 17.7333 12.5167 18 11.75 18C10.9833 18 10.3333 17.7333 9.8 17.2C9.26667 16.6667 9 16.0167 9 15.25V6H11V15.25C11 15.4667 11.0708 15.6458 11.2125 15.7875C11.3542 15.9292 11.5333 16 11.75 16C11.9667 16 12.1458 15.9292 12.2875 15.7875C12.4292 15.6458 12.5 15.4667 12.5 15.25V6.5C12.4833 5.8 12.2375 5.20833 11.7625 4.725C11.2875 4.24167 10.7 4 10 4C9.3 4 8.70833 4.24167 8.225 4.725C7.74167 5.20833 7.5 5.8 7.5 6.5V15.75C7.48333 16.9333 7.89167 17.9375 8.725 18.7625C9.55833 19.5875 10.5667 20 11.75 20C12.9167 20 13.9083 19.5875 14.725 18.7625C15.5417 17.9375 15.9667 16.9333 16 15.75V6H18V15.75Z" fill="currentColor"/>
</svg>
</span>
</a>
</div>
<div class="row-cell" style="width: 120px;" data-label="등록일"
th:text="${#temporals.format(faq.createdDate, 'yyyy.MM.dd')}">2025.01.01</div>
</div>
</div>
</div>
@@ -95,37 +110,6 @@
document.faqForm.page.value = 1;
document.faqForm.submit();
}
function decodeHTMLEntities(text) {
var textArea = document.createElement('textarea');
textArea.innerHTML = text;
return textArea.value;
}
document.addEventListener('DOMContentLoaded', function() {
// Decode HTML entities in FAQ answers
document.querySelectorAll('.faq-answer-content').forEach(function(element) {
element.innerHTML = decodeHTMLEntities(element.innerHTML);
});
// FAQ Accordion functionality
document.querySelectorAll('.faq-question').forEach(function(question) {
question.addEventListener('click', function(e) {
e.preventDefault();
const faqItem = this.closest('.faq-item');
const isActive = faqItem.classList.contains('active');
// Close all other items (optional - for single open behavior)
// document.querySelectorAll('.faq-item.active').forEach(function(item) {
// item.classList.remove('active');
// });
// Toggle active state
faqItem.classList.toggle('active');
});
});
});
</script>
</th:block>
@@ -157,6 +157,27 @@
$(function () {
// CSRF 토큰은 세션 기반 → 세션 만료(server.servlet.session.timeout=10m) 전에
// 메인 페이지로 이동시켜 stale 토큰 제출(403) 방지. 이동은 서버 요청이라 세션 idle도 리셋됨.
// 단, 입력 중에는 이탈 금지 → idle 타이머(활동 시 리셋, 입력값/포커스 있으면 보류).
// 만료 시간은 하드코딩하지 않고 서버 세션 타임아웃(초)을 렌더 시점에 읽어 파생 → yml 변경 시 자동 반영.
var SESSION_TIMEOUT_SEC = /*[[${#request.session.maxInactiveInterval}]]*/ 600;
var LOGIN_IDLE_LIMIT_MS = Math.max(60, SESSION_TIMEOUT_SEC - 120) * 1000; // 세션 만료 2분 전
var loginIdleTimer;
function scheduleIdleRedirect() {
clearTimeout(loginIdleTimer);
loginIdleTimer = setTimeout(function () {
var idEl = document.getElementById('id');
var pwEl = document.getElementById('password');
var busy = document.activeElement === idEl || document.activeElement === pwEl
|| (idEl && idEl.value) || (pwEl && pwEl.value);
if (busy) { scheduleIdleRedirect(); return; } // 입력 중/입력값 있음 → 이탈 보류
window.location.href = /*[[@{/}]]*/ '/';
}, LOGIN_IDLE_LIMIT_MS);
}
$('#id, #password, #checkId').on('input keydown focus click', scheduleIdleRedirect);
scheduleIdleRedirect();
var successMsg = [[${success}]];
console.log("Success message:", successMsg);
if (successMsg) {
@@ -54,11 +54,6 @@
<!-- Logout State (Authenticated) -->
<div class="auth-group authenticated" sec:authorize="isAuthenticated()">
<div class="header-user-info">
<span class="session-timer" id="sessionTimer" title="남은 세션 시간">
<i class="fas fa-clock session-timer-icon"></i>
<span class="session-timer-text" id="sessionRemainingTime">--:--</span>
</span>
<span class="divider"></span>
<div class="user-identity">
<img th:src="@{/img/user_icon.svg}" alt="User" class="user-icon">
<span class="user-name">[[${#authentication.principal.userName}]]님</span>
@@ -97,10 +92,6 @@
</div>
<div class="mobile-right">
<span class="session-timer session-timer-mobile" sec:authorize="isAuthenticated()" title="남은 세션 시간">
<i class="fas fa-clock session-timer-icon"></i>
<span class="session-timer-text">--:--</span>
</span>
<button class="mobile-menu-btn" id="mobileMenuToggle" aria-label="메뉴">
<span class="hamburger-line"></span>
<span class="hamburger-line"></span>
@@ -113,6 +104,19 @@
</div>
</header>
<!-- 우측 상단 Float 세션 타이머 (인증 사용자). 모바일에선 헤더/햄버거 아래로 내려 겹침 방지 -->
<div class="session-float" sec:authorize="isAuthenticated()">
<span class="session-timer" id="sessionTimer" title="남은 세션 시간">
<i class="fas fa-clock session-timer-icon"></i>
<span class="session-timer-text" id="sessionRemainingTime">--:--</span>
</span>
<!-- 세션 유지(타임아웃 무시) 체크박스: 비운영 + property 활성 시에만 렌더 (prod 미노출) -->
<label class="session-keepalive" th:if="${sessionKeepAliveAllowed}" title="세션 자동 유지 (비운영 전용)">
<input type="checkbox" id="sessionKeepAliveToggle">
<span>세션 유지</span>
</label>
</div>
<!-- Mobile Drawer/Modal -->
<div class="mobile-drawer" id="mobileDrawer">
<div class="drawer-overlay" id="drawerOverlay"></div>
@@ -260,6 +264,7 @@
var countdownTimer = null;
var promptShown = false; // 연장 확인 모달 중복 표시 방지
var redirecting = false;
var keepAliveActive = false; // 세션 유지 체크 시 true → 카운트다운 정지, 무제한(∞) 표시
function getCookie(name) {
var m = document.cookie.match('(^|;)\\s*' + name + '\\s*=\\s*([^;]+)');
@@ -287,6 +292,14 @@
function render() {
if (!timerTexts.length) return;
// 세션 유지 활성 시 카운트다운 대신 무제한(∞) 표시
if (keepAliveActive) {
for (var k = 0; k < timerTexts.length; k++) {
timerTexts[k].textContent = '∞';
timerTexts[k].classList.remove('session-timer-warning');
}
return;
}
var label = formatTime(remainingSeconds);
var warn = remainingSeconds <= WARNING_SECONDS;
for (var i = 0; i < timerTexts.length; i++) {
@@ -350,6 +363,11 @@
}
function tick() {
// 세션 유지 활성 시 로컬 카운트다운·만료 처리 중단 (heartbeat가 세션 연장)
if (keepAliveActive) {
render();
return;
}
remainingSeconds--;
if (remainingSeconds <= 0) {
goTo(EXPIRED_URL);
@@ -387,6 +405,44 @@
render();
countdownTimer = setInterval(tick, 1000);
setInterval(pollStatus, POLL_INTERVAL_MS);
// 세션 유지(타임아웃 무시): 비운영 + DB property 활성 시에만 동작(prod는 서버가 false 주입 → 체크박스도 미렌더).
// 체크 시 주기적 heartbeat로 세션을 계속 연장. 탭을 닫으면 heartbeat 중단 → 정상 만료.
var KEEPALIVE_ALLOWED = /*[[${sessionKeepAliveAllowed}]]*/ false;
var KEEPALIVE_LS_KEY = 'sessionKeepAlive';
var KEEPALIVE_INTERVAL_MS = 60000; // 세션 타임아웃보다 충분히 짧게
var keepAliveTimer = null;
var keepAliveToggle = document.getElementById('sessionKeepAliveToggle');
function startKeepAlive() {
keepAliveActive = true;
render(); // 즉시 ∞ 표시
if (keepAliveTimer) return;
keepAliveTimer = setInterval(extendSession, KEEPALIVE_INTERVAL_MS);
extendSession(); // 즉시 1회 연장
}
function stopKeepAlive() {
keepAliveActive = false;
if (keepAliveTimer) { clearInterval(keepAliveTimer); keepAliveTimer = null; }
render(); // 카운트다운 표시 복귀
pollStatus(); // 정지 중 stale 해소: 서버 잔여시간 즉시 재동기화
}
if (KEEPALIVE_ALLOWED && keepAliveToggle) {
if (localStorage.getItem(KEEPALIVE_LS_KEY) === 'true') {
keepAliveToggle.checked = true;
startKeepAlive();
}
keepAliveToggle.addEventListener('change', function () {
if (keepAliveToggle.checked) {
localStorage.setItem(KEEPALIVE_LS_KEY, 'true');
startKeepAlive();
} else {
localStorage.removeItem(KEEPALIVE_LS_KEY);
stopKeepAlive();
}
});
}
})();
</script>