[Nexacro N] 토큰 인증
Nexacro N 애플리케이션에서 JWT 기반 토큰 인증을 구현하는 방법을 설명합니다. addServiceGroup으로 Authorization 헤더를 자동 첨부하고, refresh_token으로 access_token을 자동 갱신하는 패턴을 다룹니다.
지난 글에서 SSO 연동 구조를 살펴보았다. 이번에는 JWT(JSON Web Token) 기반 토큰 인증을 Nexacro N 트랜잭션과 통합하는 방법을 다룬다. 세션 쿠키 방식이 아닌 토큰 방식은 서버리스나 마이크로서비스 환경에서 선호된다. Nexacro의 addServiceGroup()으로 모든 트랜잭션에 Authorization 헤더를 자동으로 첨부하는 것이 핵심이다.
JWT 인증 전체 흐름
로그인 시 서버가 access_token(단기)과 refresh_token(장기)을 발급한다. 이후 모든 API 호출에 Authorization: Bearer {access_token} 헤더를 포함한다. access_token이 만료되면 refresh_token으로 재발급한다.
토큰 저장
토큰은 application 전역 변수(메모리)에 저장하는 것이 안전하다. localStorage는 XSS 공격으로 탈취될 수 있고, sessionStorage는 탭을 닫으면 사라진다. Nexacro 앱은 새로고침 시 다시 시작되므로 메모리 저장으로 충분한 경우가 많다.
function fn_loginCallback(sId, nErrorCode, sErrorMsg) {
if (nErrorCode < 0 || ds_tokenInfo.rowcount === 0) {
alert("로그인 실패");
return;
}
// 토큰 저장 (메모리)
application.gv_accessToken = ds_tokenInfo.getColumn(0, "ACCESS_TOKEN");
application.gv_refreshToken = ds_tokenInfo.getColumn(0, "REFRESH_TOKEN");
application.gv_userId = ds_tokenInfo.getColumn(0, "USER_ID");
application.mainframe.changeForm("FrmMain::main/FrmMain.xfdl");
}
Authorization 헤더 자동 첨부
addServiceGroup()으로 ServiceID별 HTTP 헤더를 설정한다. 모든 트랜잭션에 적용하려면 공통 트랜잭션 래퍼 함수를 만들어 호출한다.
function fn_tx(svcId, url, input, output, callback) {
// Authorization 헤더 설정
this.addServiceGroup(svcId, "http",
"Authorization=Bearer " + application.gv_accessToken
);
this.transaction(svcId, url, input, output, "", callback);
}
// 사용
function fn_search() {
fn_tx(
"svcSearch",
"/api/orders",
"",
"out:ds_list=LIST",
"fn_searchCallback"
);
}
addServiceGroup()은 해당 ServiceID로 발행되는 트랜잭션에 헤더를 추가한다. 동일 ServiceID를 재사용하면 이전 설정이 덮어씌워지므로 매번 호출 전 재설정하는 방식이 안전하다.
토큰 만료 감지와 자동 갱신
서버가 토큰 만료 시 401을 반환하면, 공통 에러 처리에서 감지하고 refresh_token으로 새 access_token을 요청한 뒤 원래 요청을 재시도한다.
var g_pendingRequest = null;
var g_isRefreshing = false;
function fn_txCallback_common(sId, nErrorCode, sErrorMsg, origArgs) {
if (nErrorCode === -401) {
if (!g_isRefreshing) {
g_isRefreshing = true;
g_pendingRequest = origArgs;
fn_refreshToken();
}
return;
}
// 일반 에러 처리
}
function fn_refreshToken() {
ds_refresh.clearData();
ds_refresh.addRow();
ds_refresh.setColumn(0, "REFRESH_TOKEN", application.gv_refreshToken);
this.transaction(
"svcRefresh", "/api/auth/refresh",
"in:ds_refresh=REFRESH",
"out:ds_newToken=TOKEN",
"",
"fn_refreshCallback"
);
}
function fn_refreshCallback(sId, nErrorCode, sErrorMsg) {
g_isRefreshing = false;
if (nErrorCode >= 0) {
application.gv_accessToken = ds_newToken.getColumn(0, "ACCESS_TOKEN");
// 원래 요청 재시도
if (g_pendingRequest) {
fn_tx(
g_pendingRequest.svcId,
g_pendingRequest.url,
g_pendingRequest.input,
g_pendingRequest.output,
g_pendingRequest.callback
);
g_pendingRequest = null;
}
} else {
// refresh_token도 만료 → 강제 로그아웃
fn_forceLogout();
}
}
g_isRefreshing 플래그로 동시에 여러 요청이 실패했을 때 refresh 요청이 중복 발행되는 것을 막는다.
토큰 만료 사전 점검
매번 요청 시 서버 응답을 기다리지 않고, access_token의 만료 시각을 미리 점검해 갱신할 수도 있다. JWT 페이로드의 exp 클레임을 파싱한다.
function fn_getTokenExpiry(token) {
try {
var parts = token.split(".");
var payload = JSON.parse(atob(parts[1]));
return payload.exp * 1000; // ms
} catch(e) {
return 0;
}
}
function fn_tx(svcId, url, input, output, callback) {
var expiry = fn_getTokenExpiry(application.gv_accessToken);
var now = new Date().getTime();
if (expiry - now < 60000) { // 만료 1분 전이면 갱신
g_pendingRequest = { svcId, url, input, output, callback };
fn_refreshToken();
return;
}
this.addServiceGroup(svcId, "http",
"Authorization=Bearer " + application.gv_accessToken
);
this.transaction(svcId, url, input, output, "", callback);
}
서버 측 JWT 검증
// Spring Security JWT 필터 (핵심 부분)
@Component
public class JwtAuthFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest req,
HttpServletResponse res, FilterChain chain)
throws ServletException, IOException {
String header = req.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
String token = header.substring(7);
if (jwtUtil.validate(token)) {
Authentication auth = jwtUtil.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(auth);
} else {
res.setStatus(401);
return;
}
}
chain.doFilter(req, res);
}
}
지난 글: SSO 연동
다음 글: XSS 방어
읽어주셔서 감사합니다. 😊