Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c6c8d181d | |||
| 2240a0d604 | |||
| 78606514ad | |||
| 6dee34e085 | |||
| f1b67df867 | |||
| 7677f23767 | |||
| dd7c8a29bf | |||
| bb8a91f5f2 | |||
| 0f02f4d261 |
+3
-1
@@ -142,7 +142,9 @@ dependencies {
|
||||
compileOnly 'javax.resource:javax.resource-api:1.7'
|
||||
compileOnly 'javax.jms:javax.jms-api:2.0.1'
|
||||
|
||||
compileOnly group: 'com.fasterxml.jackson.dataformat', name: 'jackson-dataformat-xml', version: '2.13.1'
|
||||
// jackson-dataformat-xml 제거 (2026-08-27)
|
||||
// XmlMapper/JacksonXml* 사용처가 전 소스에 0건이고, 선언 버전(2.13.1)이
|
||||
// 실제 해석되는 jackson-core/databind(2.12.7)와 마이너 불일치라 승격 시 위험했다.
|
||||
|
||||
api "org.springframework.security:spring-security-jwt:1.1.1.RELEASE"
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import com.eactive.eai.common.message.EAIMessage;
|
||||
import com.eactive.eai.common.property.PropManager;
|
||||
import com.eactive.eai.common.util.Logger;
|
||||
import com.eactive.eai.common.util.MessageUtil;
|
||||
import com.eactive.eai.common.util.MessageUtil;
|
||||
import com.eactive.eai.message.StandardItem;
|
||||
import com.eactive.eai.message.StandardMessage;
|
||||
import com.google.gson.JsonElement;
|
||||
@@ -171,7 +172,10 @@ public class TemplateAdapterErrorMsgHandler implements AdapterErrorMessageHandle
|
||||
} else {
|
||||
// ${path} / ${callprop.키} / ${exception.필드} 스칼라 변수
|
||||
String expr = matcher.group(3).trim();
|
||||
replacement = resolveScalar(expr, msg, callProp, exception);
|
||||
// 치환값에 제어문자가 섞이면 렌더 결과가 깨진 JSON/XML 이 된다.
|
||||
// (개행이 든 값 → {"outpMsgDesc":"오류상세<개행>..."} → 수신측 파싱 실패)
|
||||
// 템플릿 포맷을 알 수 없으므로 이스케이프 대신 제어문자를 걸러낸다.
|
||||
replacement = MessageUtil.stripControlChars(resolveScalar(expr, msg, callProp, exception));
|
||||
}
|
||||
matcher.appendReplacement(result, Matcher.quoteReplacement(replacement));
|
||||
}
|
||||
@@ -400,7 +404,8 @@ public class TemplateAdapterErrorMsgHandler implements AdapterErrorMessageHandle
|
||||
String value = "";
|
||||
StandardItem item = row.get(fieldName);
|
||||
if (item != null) {
|
||||
value = StringUtils.defaultString(item.getValue());
|
||||
// render() 의 스칼라 치환과 동일한 이유로 제어문자를 걸러낸다
|
||||
value = MessageUtil.stripControlChars(StringUtils.defaultString(item.getValue()));
|
||||
}
|
||||
varMatcher.appendReplacement(result, Matcher.quoteReplacement(value));
|
||||
}
|
||||
|
||||
+6
-2
@@ -6,6 +6,7 @@ import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
import com.eactive.eai.common.message.EAIMessage;
|
||||
import com.eactive.eai.common.property.PropManager;
|
||||
import com.eactive.eai.common.util.JacksonUtil;
|
||||
import com.eactive.eai.common.util.Logger;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@@ -19,7 +20,8 @@ public class TemplateCodeConvertAdapterErrorMsgHandler extends TemplateAdapterEr
|
||||
/** PropManager 에서 코드 변환 설정을 조회할 프로퍼티 그룹 이름 */
|
||||
static final String PROP_GROUP = "AdapterErrorMessageHandler{CODE_CONVERT}";
|
||||
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
// 기본 ObjectMapper 는 응답 JSON 왕복에서 100000000.00 을 1.0E8 로 바꿔버린다.
|
||||
private static final ObjectMapper OBJECT_MAPPER = JacksonUtil.newNumberSafeMapper();
|
||||
|
||||
@Override
|
||||
public Object generateNonStandardErrorResponseMessage(
|
||||
@@ -58,7 +60,9 @@ public class TemplateCodeConvertAdapterErrorMsgHandler extends TemplateAdapterEr
|
||||
return responseMsessage;
|
||||
}
|
||||
|
||||
JsonNode rootNode = OBJECT_MAPPER.readTree(jsonStr);
|
||||
// 상대 시스템이 개행 등 제어문자를 이스케이프하지 않고 보내는 경우가 있어 정규화 후 파싱한다.
|
||||
// (이미 표준을 지킨 JSON 이면 escapeControlChars 는 원본을 그대로 반환한다)
|
||||
JsonNode rootNode = OBJECT_MAPPER.readTree(JacksonUtil.escapeControlChars(jsonStr));
|
||||
boolean modified = false;
|
||||
|
||||
for (String rawField : fieldsValue.split(",")) {
|
||||
|
||||
+5
-2
@@ -7,6 +7,7 @@ import com.eactive.eai.adapter.http.client.HttpClientAdapterVO;
|
||||
import com.eactive.eai.common.TransactionContextKeys;
|
||||
import com.eactive.eai.common.message.MessageType;
|
||||
import com.eactive.eai.common.util.CommonLib;
|
||||
import com.eactive.eai.common.util.JacksonUtil;
|
||||
import com.eactive.eai.common.util.TxFileLogger;
|
||||
|
||||
import org.apache.commons.httpclient.HttpStatus;
|
||||
@@ -60,8 +61,10 @@ public class HttpClient5AdapterServiceBody extends HttpClient5AdapterServiceSupp
|
||||
}
|
||||
|
||||
if(MessageType.JSON.equals(prop.getProperty("messageType"))) {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
ObjectNode jsonNode = (ObjectNode) mapper.readTree(sendData);
|
||||
// 파싱 후 재직렬화하므로 숫자 자릿수가 유실되지 않는 mapper 를 쓴다.
|
||||
// 기본 ObjectMapper 는 100000000.00 을 1.0E8 로 바꿔버린다.
|
||||
ObjectMapper mapper = JacksonUtil.newNumberSafeMapper();
|
||||
ObjectNode jsonNode = (ObjectNode) mapper.readTree(JacksonUtil.escapeControlChars(sendData));
|
||||
ObjectNode headerPart = (ObjectNode) jsonNode.get("header_part");
|
||||
|
||||
if( headerPart.get("mciIntfId") != null && headerPart.get("mciIntfId").asText().trim().length() > 0 ) {
|
||||
|
||||
+3
-1
@@ -9,6 +9,7 @@ import org.json.simple.JSONObject;
|
||||
import com.eactive.eai.adapter.http.client.HttpClientAdapterServiceKey;
|
||||
import com.eactive.eai.adapter.http.client.impl.filter.HttpClient5AdapterFilterFactory;
|
||||
import com.eactive.eai.adapter.http.client.impl.filter.HttpClientAdapterFilter;
|
||||
import com.eactive.eai.common.util.JacksonUtil;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
@@ -37,7 +38,8 @@ public class HttpClient5AdapterServiceRestAddFilter extends HttpClient5AdapterSe
|
||||
// // Adapter에서 Exception이 발생한 경우, 처리할 필터
|
||||
// static final String EXCEPTION_FILTER = "EXCEPTION_FILTER";
|
||||
|
||||
private ObjectMapper mapper = new ObjectMapper();
|
||||
// 기본 ObjectMapper 는 JsonNode 직렬화 시 작은 소수를 1.2E-7 로 바꿔버린다.
|
||||
private ObjectMapper mapper = JacksonUtil.newNumberSafeMapper();
|
||||
|
||||
/**
|
||||
* 1. 기능 : HttpClient 호출 전후 Filter 적용용 2. 처리 개요 : <br>
|
||||
|
||||
@@ -19,6 +19,7 @@ import com.eactive.eai.adapter.http.dynamic.filter.HttpAdapterFilterType;
|
||||
import com.eactive.eai.adapter.http.dynamic.filter.JwtAuthException;
|
||||
import com.eactive.eai.common.TransactionContextKeys;
|
||||
import com.eactive.eai.common.server.EAIServerManager;
|
||||
import com.eactive.eai.common.util.JacksonUtil;
|
||||
import com.eactive.eai.common.util.Logger;
|
||||
import com.eactive.eai.common.util.TxSiftContext;
|
||||
import com.eactive.eai.common.util.UUIDGenerator;
|
||||
@@ -31,7 +32,8 @@ public abstract class HttpAdapterServiceSupport implements HttpAdapterService, H
|
||||
public static final String HEADER_NAME_CLIENT_ID = "x-elink-client-id";
|
||||
public static final String PROPERTIES_NAME_CLIENT_ID = "clientId";
|
||||
static Logger logger = Logger.getLogger(Logger.LOGGER_ADAPTER);
|
||||
private ObjectMapper mapper = new ObjectMapper();
|
||||
// 기본 ObjectMapper 는 JsonNode 직렬화 시 작은 소수를 1.2E-7 로 바꿔버린다.
|
||||
private ObjectMapper mapper = JacksonUtil.newNumberSafeMapper();
|
||||
EAIServerManager eaiServerManager;
|
||||
String instid = null;
|
||||
|
||||
|
||||
+2
-1
@@ -6,6 +6,7 @@ import java.util.Properties;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
|
||||
import com.eactive.eai.common.util.JacksonUtil;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonMappingException;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
@@ -66,6 +67,6 @@ public class JsonToSetStatusFilter implements HttpAdapterFilter {
|
||||
orgMessageString = (String) message;
|
||||
}
|
||||
|
||||
return mapper.readTree(orgMessageString);
|
||||
return mapper.readTree(JacksonUtil.escapeControlChars(orgMessageString));
|
||||
}
|
||||
}
|
||||
|
||||
+5
-2
@@ -13,12 +13,15 @@ import org.apache.commons.lang3.StringUtils;
|
||||
import com.eactive.eai.adapter.AdapterManager;
|
||||
import com.eactive.eai.adapter.http.dynamic.HttpAdapterServiceKey;
|
||||
import com.eactive.eai.adapter.http.dynamic.filter.HttpAdapterFilter;
|
||||
import com.eactive.eai.common.util.JacksonUtil;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
|
||||
public class JsonToStdConverterFilter implements HttpAdapterFilter {
|
||||
private ObjectMapper mapper = new ObjectMapper();
|
||||
// 파싱 후 rootNode.toString() 으로 재직렬화하므로 숫자 자릿수가 유실되지 않는 mapper 를 쓴다.
|
||||
// 기본 ObjectMapper 는 100000000.00 을 1.0E8 로 바꿔버린다.
|
||||
private ObjectMapper mapper = JacksonUtil.newNumberSafeMapper();
|
||||
|
||||
@Override
|
||||
public Object doPreFilter(String adptGrpName, String adptName, Object message, Properties prop,
|
||||
@@ -104,7 +107,7 @@ public class JsonToStdConverterFilter implements HttpAdapterFilter {
|
||||
orgMessageString = (String) message;
|
||||
}
|
||||
|
||||
return mapper.readTree(orgMessageString);
|
||||
return mapper.readTree(JacksonUtil.escapeControlChars(orgMessageString));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+6
-3
@@ -11,6 +11,7 @@ import org.apache.commons.lang3.StringUtils;
|
||||
|
||||
import com.eactive.eai.adapter.AdapterManager;
|
||||
import com.eactive.eai.adapter.http.dynamic.filter.HttpAdapterFilter;
|
||||
import com.eactive.eai.common.util.JacksonUtil;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonMappingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
@@ -19,7 +20,9 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
|
||||
public class KbankEaiJsonParseFilter implements HttpAdapterFilter {
|
||||
|
||||
private ObjectMapper mapper = new ObjectMapper();
|
||||
// 파싱 후 재직렬화하므로 숫자 자릿수가 유실되지 않는 mapper 를 쓴다.
|
||||
// 기본 ObjectMapper 는 100000000.00 을 1.0E8 로 바꿔버린다.
|
||||
private ObjectMapper mapper = JacksonUtil.newNumberSafeMapper();
|
||||
|
||||
@Override
|
||||
public Object doPreFilter(String adptGrpName, String adptName, Object message, Properties prop,
|
||||
@@ -30,7 +33,7 @@ public class KbankEaiJsonParseFilter implements HttpAdapterFilter {
|
||||
for(Iterator<String> it = rootNode.fieldNames(); it.hasNext();) {
|
||||
String fieldName = it.next();
|
||||
String value = rootNode.get(fieldName).asText();
|
||||
JsonNode jsonNode = mapper.readTree(value);
|
||||
JsonNode jsonNode = mapper.readTree(JacksonUtil.escapeControlChars(value));
|
||||
replacedJson.set(fieldName, jsonNode);
|
||||
}
|
||||
|
||||
@@ -62,7 +65,7 @@ public class KbankEaiJsonParseFilter implements HttpAdapterFilter {
|
||||
orgMessageString = (String) message;
|
||||
}
|
||||
|
||||
return mapper.readTree(orgMessageString);
|
||||
return mapper.readTree(JacksonUtil.escapeControlChars(orgMessageString));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+2
-1
@@ -16,6 +16,7 @@ import javax.servlet.http.HttpServletResponse;
|
||||
|
||||
import com.eactive.eai.adapter.AdapterManager;
|
||||
import com.eactive.eai.common.property.PropManager;
|
||||
import com.eactive.eai.common.util.JacksonUtil;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonMappingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
@@ -203,7 +204,7 @@ public class KbankHmacSha256VerifyFilter implements HttpAdapterFilter {
|
||||
orgMessageString = (String) message;
|
||||
}
|
||||
|
||||
return mapper.readTree(orgMessageString);
|
||||
return mapper.readTree(JacksonUtil.escapeControlChars(orgMessageString));
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.eactive.eai.common.hsm;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* HsmCryptoService 키 캐시의 진단 정보. 키 원본(바이트/인코딩)은 절대 포함하지 않고
|
||||
* alias, 종류, 알고리즘, 캐시 시각/만료 여부만 노출한다.
|
||||
*/
|
||||
@Data
|
||||
public class HsmCachedKeyInfo {
|
||||
|
||||
/** SECRET(대칭키) / PUBLIC(공개키) */
|
||||
String keyType;
|
||||
|
||||
/** HSM KeyStore alias */
|
||||
String alias;
|
||||
|
||||
/** AES, RSA 등 */
|
||||
String algorithm;
|
||||
|
||||
/** 캐시에 등록된 시각 (epoch millis) */
|
||||
long cachedAt;
|
||||
|
||||
/** 캐시 등록 후 경과 시간 (millis) */
|
||||
long ageMillis;
|
||||
|
||||
/** CACHE_TTL_SEC 기준 만료 여부. 만료되어도 HSM 장애 시 fallback 으로 사용된다. */
|
||||
boolean expired;
|
||||
|
||||
/** 키 길이(바이트). non-extractable 등으로 알 수 없으면 -1. 키 값 자체는 노출하지 않는다. */
|
||||
int keyLength;
|
||||
}
|
||||
@@ -7,6 +7,10 @@ import java.security.PrivateKey;
|
||||
import java.security.Provider;
|
||||
import java.security.PublicKey;
|
||||
import java.security.cert.Certificate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import javax.annotation.PostConstruct;
|
||||
@@ -199,6 +203,52 @@ public class HsmCryptoService implements PropertyChangeListener {
|
||||
logger.warn("HsmCryptoService] 키 캐시 초기화 완료");
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 캐싱된 키 목록의 진단 정보를 반환합니다. 키 원본은 포함하지 않습니다.
|
||||
* HSM 상태 조회 API(/manage/hsm/status)에서 사용합니다.
|
||||
*/
|
||||
public List<HsmCachedKeyInfo> getCachedKeyInfos() {
|
||||
List<HsmCachedKeyInfo> infos = new ArrayList<>();
|
||||
|
||||
for (Map.Entry<String, CachedKey<SecretKey>> entry : secretKeyCache.entrySet()) {
|
||||
SecretKey key = entry.getValue().key;
|
||||
byte[] encoded = (key == null) ? null : key.getEncoded();
|
||||
infos.add(toInfo("SECRET", entry.getKey(), entry.getValue(),
|
||||
(key == null) ? null : key.getAlgorithm(),
|
||||
(encoded == null) ? -1 : encoded.length));
|
||||
}
|
||||
|
||||
for (Map.Entry<String, CachedKey<PublicKey>> entry : publicKeyCache.entrySet()) {
|
||||
PublicKey key = entry.getValue().key;
|
||||
byte[] encoded = (key == null) ? null : key.getEncoded();
|
||||
infos.add(toInfo("PUBLIC", entry.getKey(), entry.getValue(),
|
||||
(key == null) ? null : key.getAlgorithm(),
|
||||
(encoded == null) ? -1 : encoded.length));
|
||||
}
|
||||
|
||||
infos.sort(Comparator.comparing(HsmCachedKeyInfo::getKeyType)
|
||||
.thenComparing(HsmCachedKeyInfo::getAlias));
|
||||
return infos;
|
||||
}
|
||||
|
||||
private HsmCachedKeyInfo toInfo(String keyType, String alias, CachedKey<?> cached,
|
||||
String algorithm, int keyLength) {
|
||||
HsmCachedKeyInfo info = new HsmCachedKeyInfo();
|
||||
info.setKeyType(keyType);
|
||||
info.setAlias(alias);
|
||||
info.setAlgorithm(algorithm);
|
||||
info.setCachedAt(cached.cachedAt);
|
||||
info.setAgeMillis(System.currentTimeMillis() - cached.cachedAt);
|
||||
info.setExpired(cached.isExpired());
|
||||
info.setKeyLength(keyLength);
|
||||
return info;
|
||||
}
|
||||
|
||||
/** 현재 적용된 키 캐시 TTL(밀리초). HSM.CACHE_TTL_SEC 프로퍼티로 변경된다. */
|
||||
public long getCacheTtlMs() {
|
||||
return CACHE_TTL_MS;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// RSA
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.eactive.eai.common.hsm;
|
||||
|
||||
import java.beans.PropertyChangeEvent;
|
||||
import java.beans.PropertyChangeListener;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.File;
|
||||
import java.io.InputStream;
|
||||
@@ -12,6 +14,10 @@ import java.security.NoSuchAlgorithmException;
|
||||
import java.security.Provider;
|
||||
import java.security.Security;
|
||||
import java.security.UnrecoverableKeyException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
@@ -19,6 +25,7 @@ import java.util.concurrent.TimeUnit;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import com.eactive.eai.agent.encryption.EncryptionManager;
|
||||
@@ -27,6 +34,7 @@ import com.eactive.eai.common.lifecycle.Lifecycle;
|
||||
import com.eactive.eai.common.lifecycle.LifecycleException;
|
||||
import com.eactive.eai.common.lifecycle.LifecycleListener;
|
||||
import com.eactive.eai.common.lifecycle.LifecycleSupport;
|
||||
import com.eactive.eai.common.property.PropGroupVO;
|
||||
import com.eactive.eai.common.property.PropManager;
|
||||
import com.eactive.eai.common.util.ApplicationContextProvider;
|
||||
import com.eactive.eai.common.util.Logger;
|
||||
@@ -35,8 +43,11 @@ import com.eactive.eai.common.util.Logger;
|
||||
* SafeNet ProtectServer HSM 연동 관리자 (JDK 8 / SunPKCS11)
|
||||
*
|
||||
* PropManager 그룹 "HSM" 에서 읽는 키:
|
||||
* PKCS11_CONFIG - pkcs11.cfg 파일 내용
|
||||
* PIN - HSM 슬롯 PIN
|
||||
* PKCS11_CONFIG - primary pkcs11.cfg 파일 내용
|
||||
* PIN - primary 슬롯 PIN
|
||||
* PKCS11_CONFIG_SECONDARY - secondary pkcs11.cfg 파일 내용 (미설정 시 이중화 비활성)
|
||||
* PIN_SECONDARY - secondary 슬롯 PIN (미설정 시 PIN 재사용)
|
||||
* RELOAD_INTERVAL_MINUTES - KeyStore 주기적 재로드 간격(분)
|
||||
*
|
||||
* PKCS11_CONFIG 값 예시 (개행은 \n 으로 입력):
|
||||
* name = ProtectServer
|
||||
@@ -48,27 +59,46 @@ import com.eactive.eai.common.util.Logger;
|
||||
* slotListIndex = 0
|
||||
*
|
||||
* ---------------------------------------------------------------------
|
||||
* [세션 누적 방지 / 회로차단 로직 추가]
|
||||
* 기존 구현은 재로드마다 KeyStore.getInstance(...).load(null, pin) 을 새로
|
||||
* 호출하여 PKCS11 세션(C_OpenSession)이 계속 누적되고, 결국 HSM 파티션의
|
||||
* 최대 세션 수를 초과하면서 reload 가 영구적으로 실패하는 문제가 있었다.
|
||||
* 이를 방지하기 위해:
|
||||
* 1) 정상 상황에서는 "기존 keyStore 인스턴스"에 다시 load() 하여 세션 재사용
|
||||
* [설정 이중화 / primary 자동 복귀]
|
||||
* 기동(init) 과 재연결 시 항상 primary -> secondary 순서로 연결을 시도하고,
|
||||
* 최초로 성공한 설정을 사용한다. secondary 로 절체된 뒤에는 주기적 재로드마다
|
||||
* primary 재연결을 먼저 시도하므로 primary 가 복구되면 자동으로 되돌아온다.
|
||||
*
|
||||
* [세션 누적 방지 / 회로차단 로직]
|
||||
* 재로드마다 KeyStore.getInstance(...).load(null, pin) 을 새로 호출하면
|
||||
* PKCS11 세션(C_OpenSession)이 계속 누적되고, 결국 HSM 파티션의 최대 세션 수를
|
||||
* 초과하면서 reload 가 영구적으로 실패한다. 이를 방지하기 위해:
|
||||
* 1) primary 로 동작 중이고 설정도 그대로면 "기존 keyStore 인스턴스"에 다시
|
||||
* load() 하여 세션을 재사용한다 (= primary 우선 시도와 동일한 의미)
|
||||
* 2) 실제 키 조회(probe)로 세션이 살아있는지 검증
|
||||
* 3) 연속 실패가 임계치를 넘으면 Provider 자체를 logout 후 완전히 재생성
|
||||
* 3) 재로드가 실패하거나 프로퍼티가 변경되면 primary -> secondary 순서로
|
||||
* Provider 를 완전히 재생성
|
||||
* 4) 재생성마저 실패하면 마지막으로 성공한 keyStore 를 유지 (서비스 연속성 우선)
|
||||
*
|
||||
* [프로퍼티 즉시 반영]
|
||||
* PropManager 의 PropertyChangeListener 로 등록되어 있어, 관리 포털에서 HSM 그룹을
|
||||
* reload 하면 다음 스케줄을 기다리지 않고 즉시 설정을 다시 읽어 재연결을 시도한다.
|
||||
* 이 경우에도 신규 연결이 완전히 성공한 뒤에만 keyStore 멤버변수를 교체한다.
|
||||
* ---------------------------------------------------------------------
|
||||
*/
|
||||
@Component
|
||||
public class HsmManager implements Lifecycle {
|
||||
public class HsmManager implements Lifecycle, PropertyChangeListener {
|
||||
|
||||
static Logger logger = Logger.getLogger(Logger.LOGGER_DEFAULT);
|
||||
|
||||
private static final String GROUP_NAME = "HSM";
|
||||
private static final String PROP_CONFIG = "PKCS11_CONFIG";
|
||||
private static final String PROP_PIN = "PIN";
|
||||
private static final String PROP_CONFIG_SECONDARY = "PKCS11_CONFIG_SECONDARY";
|
||||
private static final String PROP_PIN_SECONDARY = "PIN_SECONDARY";
|
||||
private static final String PROP_RELOAD_INTERVAL_MINUTES = "RELOAD_INTERVAL_MINUTES";
|
||||
|
||||
/** 설정 구분자. HSM 상태 조회 API 응답에도 그대로 노출된다. */
|
||||
public static final String CONFIG_PRIMARY = "PRIMARY";
|
||||
public static final String CONFIG_SECONDARY = "SECONDARY";
|
||||
|
||||
private static final long DEFAULT_RELOAD_INTERVAL_MINUTES = 10;
|
||||
|
||||
private Provider pkcs11Provider;
|
||||
private volatile KeyStore keyStore;
|
||||
private boolean started;
|
||||
@@ -77,10 +107,23 @@ public class HsmManager implements Lifecycle {
|
||||
|
||||
private volatile char[] pin;
|
||||
|
||||
// 재로드 주기 (분 단위). 필요시 PropManager로 외부화 가능.
|
||||
private static long RELOAD_INTERVAL_MINUTES = 1;
|
||||
/** 현재 연결에 사용 중인 설정. 미연결이면 null. */
|
||||
private volatile HsmConfig activeConfig;
|
||||
|
||||
// ---- 모니터링용 상태값 (HsmStatusController 에서 조회) ----
|
||||
private volatile long activeSince;
|
||||
private volatile long lastReloadAt;
|
||||
private volatile String lastReloadResult;
|
||||
private volatile String lastErrorMessage;
|
||||
private volatile long lastErrorAt;
|
||||
private volatile int reloadSuccessCount;
|
||||
private volatile int reloadFailCount;
|
||||
private volatile int failoverCount;
|
||||
|
||||
private volatile long reloadIntervalMinutes = DEFAULT_RELOAD_INTERVAL_MINUTES;
|
||||
private ScheduledExecutorService scheduler;
|
||||
private ScheduledFuture<?> reloadFuture;
|
||||
private volatile boolean propListenerRegistered;
|
||||
|
||||
private HsmManager() {
|
||||
}
|
||||
@@ -89,6 +132,112 @@ public class HsmManager implements Lifecycle {
|
||||
return ApplicationContextProvider.getContext().getBean(HsmManager.class);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 설정 후보 (primary / secondary)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 한 번의 연결 시도에 필요한 설정 한 벌. PropManager 에서 읽어 복호화까지 마친 값이다.
|
||||
*/
|
||||
static final class HsmConfig {
|
||||
|
||||
final String name;
|
||||
final String configContent;
|
||||
final char[] pin;
|
||||
|
||||
HsmConfig(String name, String configContent, char[] pin) {
|
||||
this.name = name;
|
||||
this.configContent = configContent;
|
||||
this.pin = pin;
|
||||
}
|
||||
|
||||
/** PropManager 값이 바뀌었는지 판단하기 위한 비교. */
|
||||
boolean sameAs(HsmConfig other) {
|
||||
if (other == null) {
|
||||
return false;
|
||||
}
|
||||
return name.equals(other.name)
|
||||
&& configContent.equals(other.configContent)
|
||||
&& Arrays.equals(pin, other.pin);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 연결 시도 결과. Security 등록 전 상태이며, 완전히 성공한 경우에만
|
||||
* applyConnection() 을 통해 멤버변수로 승격된다.
|
||||
*/
|
||||
private static final class HsmConnection {
|
||||
|
||||
final HsmConfig config;
|
||||
final Provider provider;
|
||||
final KeyStore keyStore;
|
||||
|
||||
HsmConnection(HsmConfig config, Provider provider, KeyStore keyStore) {
|
||||
this.config = config;
|
||||
this.provider = provider;
|
||||
this.keyStore = keyStore;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PropManager 에서 매번 새로 읽어 primary -> secondary 순서의 연결 후보를 만든다.
|
||||
* 스케줄러/프로퍼티 변경 이벤트 모두 이 메서드를 통하므로 변경된 값이 즉시 반영된다.
|
||||
* PKCS11_CONFIG 가 비어 있는 후보는 제외한다.
|
||||
*/
|
||||
private List<HsmConfig> loadConfigCandidates() {
|
||||
PropManager propManager = PropManager.getInstance();
|
||||
EncryptionManager encManager = EncryptionManager.getInstance();
|
||||
|
||||
List<HsmConfig> candidates = new ArrayList<>();
|
||||
|
||||
String primaryConfig = decrypt(encManager, propManager.getProperty(GROUP_NAME, PROP_CONFIG));
|
||||
String primaryPin = decrypt(encManager, propManager.getProperty(GROUP_NAME, PROP_PIN));
|
||||
if (StringUtils.isNotBlank(primaryConfig)) {
|
||||
candidates.add(new HsmConfig(CONFIG_PRIMARY, normalizeConfig(primaryConfig), toPin(primaryPin)));
|
||||
}
|
||||
|
||||
String secondaryConfig = decrypt(encManager, propManager.getProperty(GROUP_NAME, PROP_CONFIG_SECONDARY));
|
||||
if (StringUtils.isNotBlank(secondaryConfig)) {
|
||||
// PIN_SECONDARY 미설정이면 primary PIN 을 재사용한다 (슬롯만 이중화하는 구성 지원)
|
||||
String rawSecondaryPin = propManager.getProperty(GROUP_NAME, PROP_PIN_SECONDARY);
|
||||
String secondaryPin = StringUtils.isNotBlank(rawSecondaryPin) ? decrypt(encManager, rawSecondaryPin) : primaryPin;
|
||||
candidates.add(new HsmConfig(CONFIG_SECONDARY, normalizeConfig(secondaryConfig), toPin(secondaryPin)));
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
private static String decrypt(EncryptionManager encManager, String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
return encManager.decryptDBData(value);
|
||||
}
|
||||
|
||||
private static String normalizeConfig(String configContent) {
|
||||
return configContent.trim().replace("\\n", "\n");
|
||||
}
|
||||
|
||||
private static char[] toPin(String pinStr) {
|
||||
return (pinStr != null) ? pinStr.toCharArray() : null;
|
||||
}
|
||||
|
||||
private static HsmConfig findByName(List<HsmConfig> candidates, String name) {
|
||||
if (name == null) {
|
||||
return null;
|
||||
}
|
||||
for (HsmConfig candidate : candidates) {
|
||||
if (name.equals(candidate.name)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Lifecycle
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Override
|
||||
public void start() throws LifecycleException {
|
||||
if (started) {
|
||||
@@ -99,6 +248,7 @@ public class HsmManager implements Lifecycle {
|
||||
try {
|
||||
init();
|
||||
startReloadScheduler();
|
||||
registerPropertyChangeListener();
|
||||
} catch (Exception e) {
|
||||
throw new LifecycleException(ExceptionUtil.getErrorCode(e, "RECEAIHSM002"));
|
||||
}
|
||||
@@ -109,38 +259,133 @@ public class HsmManager implements Lifecycle {
|
||||
|
||||
private void init() throws Exception {
|
||||
|
||||
String reloadIntervalStr = PropManager.getInstance().getProperty(GROUP_NAME, PROP_RELOAD_INTERVAL_MINUTES, "10");
|
||||
RELOAD_INTERVAL_MINUTES = Long.parseLong(reloadIntervalStr);
|
||||
EncryptionManager encManager = EncryptionManager.getInstance();
|
||||
applyReloadInterval();
|
||||
|
||||
String configContent = encManager.decryptDBData(PropManager.getInstance().getProperty(GROUP_NAME, PROP_CONFIG));
|
||||
String pinStr = encManager.decryptDBData(PropManager.getInstance().getProperty(GROUP_NAME, PROP_PIN));
|
||||
|
||||
if (configContent == null || configContent.trim().isEmpty()) {
|
||||
List<HsmConfig> candidates = loadConfigCandidates();
|
||||
if (candidates.isEmpty()) {
|
||||
logger.warn("HsmManager] PKCS11_CONFIG 가 설정되지 않았습니다. HSM 초기화를 건너뜁니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
pkcs11Provider = createProvider(configContent.trim().replace("\\n", "\n"));
|
||||
HsmConnection connection = connectFirstAvailable(candidates);
|
||||
applyConnection(connection);
|
||||
|
||||
// 이미 등록된 Provider 가 있으면 제거 후 재등록
|
||||
Provider existing = Security.getProvider(pkcs11Provider.getName());
|
||||
if (existing != null) {
|
||||
Security.removeProvider(existing.getName());
|
||||
}
|
||||
Security.addProvider(pkcs11Provider);
|
||||
|
||||
keyStore = KeyStore.getInstance("PKCS11", pkcs11Provider);
|
||||
this.pin = (pinStr != null) ? pinStr.toCharArray() : null;
|
||||
keyStore.load(null, pin);
|
||||
|
||||
logger.warn("HsmManager] 초기화 완료. Provider=" + pkcs11Provider.getName());
|
||||
|
||||
logKeyStore(this.keyStore);
|
||||
logger.warn("HsmManager] 초기화 완료. config=" + connection.config.name
|
||||
+ ", Provider=" + connection.provider.getName());
|
||||
}
|
||||
|
||||
private void logKeyStore(KeyStore keyStore) throws KeyStoreException, NoSuchAlgorithmException, UnrecoverableKeyException {
|
||||
java.util.Enumeration<String> aliases = keyStore.aliases();
|
||||
/**
|
||||
* RELOAD_INTERVAL_MINUTES 를 다시 읽어 적용한다.
|
||||
*
|
||||
* @return 값이 변경되어 스케줄 재등록이 필요하면 true
|
||||
*/
|
||||
private boolean applyReloadInterval() {
|
||||
long newInterval = reloadIntervalMinutes;
|
||||
try {
|
||||
String value = PropManager.getInstance().getProperty(GROUP_NAME, PROP_RELOAD_INTERVAL_MINUTES,
|
||||
String.valueOf(DEFAULT_RELOAD_INTERVAL_MINUTES));
|
||||
if (StringUtils.isNotBlank(value)) {
|
||||
newInterval = Long.parseLong(value.trim());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.warn("HsmManager] " + PROP_RELOAD_INTERVAL_MINUTES + " 값이 올바르지 않아 기존 값("
|
||||
+ reloadIntervalMinutes + "분)을 유지합니다: " + e.getMessage());
|
||||
return false;
|
||||
}
|
||||
|
||||
if (newInterval <= 0) {
|
||||
newInterval = DEFAULT_RELOAD_INTERVAL_MINUTES;
|
||||
}
|
||||
if (newInterval == reloadIntervalMinutes) {
|
||||
return false;
|
||||
}
|
||||
reloadIntervalMinutes = newInterval;
|
||||
return true;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 연결
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 후보 설정을 primary -> secondary 순서로 시도하여 최초로 성공한 연결을 반환한다.
|
||||
* 각 후보는 Provider 생성 + KeyStore.load + 키 목록 조회(probe)까지 모두 성공해야
|
||||
* 정상으로 간주한다. 전부 실패하면 마지막 예외를 던진다.
|
||||
*/
|
||||
private HsmConnection connectFirstAvailable(List<HsmConfig> candidates) throws Exception {
|
||||
Exception lastException = null;
|
||||
|
||||
for (HsmConfig config : candidates) {
|
||||
try {
|
||||
HsmConnection connection = connect(config);
|
||||
logger.warn("HsmManager] HSM 연결 성공. config=" + config.name
|
||||
+ ", Provider=" + connection.provider.getName());
|
||||
return connection;
|
||||
} catch (Exception e) {
|
||||
lastException = e;
|
||||
recordError(config.name + " 설정 연결 실패: " + e.getMessage());
|
||||
logger.warn("HsmManager] HSM 연결 실패. config=" + config.name + " : " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
if (lastException != null) {
|
||||
throw lastException;
|
||||
}
|
||||
throw new HsmException("HSM 연결 후보 설정이 없습니다.");
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 설정으로 Provider/KeyStore 를 새로 생성한다. Security 에는 아직 등록하지 않으므로
|
||||
* 여기서 실패해도 현재 사용 중인 Provider/keyStore 는 영향을 받지 않는다.
|
||||
*/
|
||||
private HsmConnection connect(HsmConfig config) throws Exception {
|
||||
Provider provider = createProvider(config.configContent);
|
||||
KeyStore ks = KeyStore.getInstance("PKCS11", provider);
|
||||
ks.load(null, config.pin);
|
||||
logKeyStore(ks);
|
||||
return new HsmConnection(config, provider, ks);
|
||||
}
|
||||
|
||||
/**
|
||||
* 신규 연결을 멤버변수로 승격한다. 기존 Provider 는 이 시점에서만 logout/제거된다.
|
||||
*/
|
||||
private void applyConnection(HsmConnection connection) {
|
||||
Provider oldProvider = this.pkcs11Provider;
|
||||
HsmConfig oldConfig = this.activeConfig;
|
||||
|
||||
if (oldProvider != null && oldProvider != connection.provider) {
|
||||
if (oldProvider instanceof AuthProvider) {
|
||||
try {
|
||||
((AuthProvider) oldProvider).logout();
|
||||
} catch (Exception logoutEx) {
|
||||
logger.warn("HsmManager] 기존 세션 logout 실패(무시하고 진행): " + logoutEx.getMessage());
|
||||
}
|
||||
}
|
||||
Security.removeProvider(oldProvider.getName());
|
||||
}
|
||||
|
||||
// 동일 이름으로 이미 등록된 Provider 가 있으면 제거 후 재등록
|
||||
Provider registered = Security.getProvider(connection.provider.getName());
|
||||
if (registered != null && registered != connection.provider) {
|
||||
Security.removeProvider(registered.getName());
|
||||
}
|
||||
Security.addProvider(connection.provider);
|
||||
|
||||
this.pkcs11Provider = connection.provider;
|
||||
this.keyStore = connection.keyStore;
|
||||
this.activeConfig = connection.config;
|
||||
this.pin = connection.config.pin;
|
||||
this.activeSince = System.currentTimeMillis();
|
||||
|
||||
if (oldConfig != null && !oldConfig.name.equals(connection.config.name)) {
|
||||
failoverCount++;
|
||||
logger.warn("HsmManager] HSM 설정 절체: " + oldConfig.name + " -> " + connection.config.name);
|
||||
}
|
||||
}
|
||||
|
||||
private void logKeyStore(KeyStore keyStore)
|
||||
throws KeyStoreException, NoSuchAlgorithmException, UnrecoverableKeyException {
|
||||
java.util.Enumeration<String> aliases = keyStore.aliases();
|
||||
StringBuilder aliasList = new StringBuilder();
|
||||
while (aliases.hasMoreElements()) {
|
||||
if (aliasList.length() > 0) aliasList.append(", ");
|
||||
@@ -157,7 +402,11 @@ public class HsmManager implements Lifecycle {
|
||||
}
|
||||
}
|
||||
logger.warn("HsmManager] HSM 키 목록: [" + aliasList + "]");
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 주기적 재로드
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 별도 스레드에서 주기적으로 KeyStore 를 다시 로드하여
|
||||
@@ -172,12 +421,29 @@ public class HsmManager implements Lifecycle {
|
||||
|
||||
reloadFuture = scheduler.scheduleWithFixedDelay(
|
||||
this::reloadKeyStoreSafely,
|
||||
RELOAD_INTERVAL_MINUTES,
|
||||
RELOAD_INTERVAL_MINUTES,
|
||||
reloadIntervalMinutes,
|
||||
reloadIntervalMinutes,
|
||||
TimeUnit.MINUTES
|
||||
);
|
||||
|
||||
logger.warn("HsmManager] KeyStore 주기적 재로드 스케줄러 시작. interval=" + RELOAD_INTERVAL_MINUTES + "분");
|
||||
logger.warn("HsmManager] KeyStore 주기적 재로드 스케줄러 시작. interval=" + reloadIntervalMinutes + "분");
|
||||
}
|
||||
|
||||
/** RELOAD_INTERVAL_MINUTES 변경 시 스케줄을 다시 등록한다. */
|
||||
private synchronized void rescheduleReload() {
|
||||
if (scheduler == null || scheduler.isShutdown()) {
|
||||
return;
|
||||
}
|
||||
if (reloadFuture != null) {
|
||||
reloadFuture.cancel(false);
|
||||
}
|
||||
reloadFuture = scheduler.scheduleWithFixedDelay(
|
||||
this::reloadKeyStoreSafely,
|
||||
reloadIntervalMinutes,
|
||||
reloadIntervalMinutes,
|
||||
TimeUnit.MINUTES
|
||||
);
|
||||
logger.warn("HsmManager] KeyStore 재로드 주기 변경 적용: " + reloadIntervalMinutes + "분");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -196,79 +462,181 @@ public class HsmManager implements Lifecycle {
|
||||
* KeyStore 를 다시 로드한다. 외부(getSecretKey 등)에서 키 미스 발생 시
|
||||
* 즉시 재시도용으로 직접 호출할 수도 있다.
|
||||
*
|
||||
* 세션 누적 방지를 위해 새 KeyStore 인스턴스를 만들지 않고,
|
||||
* 기존 keyStore 객체에 다시 load() 하여 기존 PKCS11 세션을 재사용한다.
|
||||
* 연속 실패가 임계치를 넘으면 Provider 자체를 재생성한다.
|
||||
* 처리 순서:
|
||||
* 1) PropManager 에서 설정을 매번 새로 읽는다 (변경값 즉시 반영)
|
||||
* 2) 설정이 변경되었거나 아직 연결이 없으면 primary -> secondary 순으로 전체 재연결
|
||||
* 3) 현재 secondary 로 동작 중이면 primary 복귀를 먼저 시도
|
||||
* 4) 그 외에는 기존 keyStore 인스턴스에 다시 load() (PKCS11 세션 재사용).
|
||||
* 실패하면 primary -> secondary 순으로 전체 재연결
|
||||
*
|
||||
* 어느 경로든 신규 연결이 완전히 성공한 경우에만 keyStore 멤버변수를 교체한다.
|
||||
*/
|
||||
public synchronized void reloadKeyStoreIfNeeded() throws Exception {
|
||||
if (pkcs11Provider == null) {
|
||||
return; // HSM 비활성화 상태
|
||||
|
||||
List<HsmConfig> candidates = loadConfigCandidates();
|
||||
if (candidates.isEmpty()) {
|
||||
logger.warn("HsmManager] PKCS11_CONFIG 가 설정되지 않아 재로드를 건너뜁니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
HsmConfig active = this.activeConfig;
|
||||
HsmConfig currentCandidate = findByName(candidates, active == null ? null : active.name);
|
||||
|
||||
// 2) 미연결 상태이거나 현재 사용 중인 설정값 자체가 변경된 경우
|
||||
if (active == null || pkcs11Provider == null || currentCandidate == null
|
||||
|| !currentCandidate.sameAs(active)) {
|
||||
logger.warn("HsmManager] HSM 설정 변경 또는 미연결 상태 감지 → 전체 재연결을 수행합니다.");
|
||||
reconnect(candidates);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3) secondary 로 동작 중이면 primary 복귀를 우선 시도
|
||||
HsmConfig preferred = candidates.get(0);
|
||||
if (!preferred.name.equals(active.name)) {
|
||||
try {
|
||||
HsmConnection connection = connect(preferred);
|
||||
applyConnection(connection);
|
||||
recordReloadSuccess(preferred.name + " 설정으로 복귀 성공");
|
||||
logger.warn("HsmManager] " + preferred.name + " 설정으로 복귀했습니다.");
|
||||
return;
|
||||
} catch (Exception e) {
|
||||
logger.warn("HsmManager] " + preferred.name + " 복귀 시도 실패. 현재 " + active.name
|
||||
+ " 설정을 유지합니다: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// 4) 현재 연결 유지 재로드 (세션 재사용)
|
||||
try {
|
||||
if (keyStore != null) {
|
||||
keyStore.load(null, pin);
|
||||
logger.warn("HsmManager] KeyStore 재로드 완료 (기존 세션 재사용).");
|
||||
logKeyStore(keyStore);
|
||||
logger.warn("HsmManager] KeyStore 재로드 완료 (config=" + active.name + ", 기존 세션 재사용).");
|
||||
} else {
|
||||
KeyStore ks = KeyStore.getInstance("PKCS11", pkcs11Provider);
|
||||
ks.load(null, pin);
|
||||
logKeyStore(ks);
|
||||
this.keyStore = ks;
|
||||
logger.warn("HsmManager] KeyStore 신규 생성 완료.");
|
||||
logger.warn("HsmManager] KeyStore 신규 생성 완료 (config=" + active.name + ").");
|
||||
}
|
||||
recordReloadSuccess(active.name + " 설정 재로드 성공");
|
||||
|
||||
logKeyStore(keyStore);
|
||||
|
||||
} catch (Exception e) {
|
||||
} catch (Exception e) {
|
||||
logger.warn("HsmManager] KeyStore 재로드 실패:" + e.getMessage(), e);
|
||||
logger.warn("HsmManager] Provider 전체 재초기화를 시도합니다.");
|
||||
fullReinitialize();
|
||||
logger.warn("HsmManager] 후보 설정(PRIMARY→SECONDARY) 전체 재연결을 시도합니다.");
|
||||
reconnect(candidates);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 세션 누적, 네트워크 단절 등으로 일반 재로드가 더 이상 복구되지 않을 때
|
||||
* 기존 세션을 정리하고 Provider 를 완전히 새로 생성한다.
|
||||
* 세션 누적, 네트워크 단절, 설정 변경 등으로 일반 재로드가 더 이상 복구되지 않을 때
|
||||
* primary -> secondary 순서로 Provider 를 완전히 새로 생성한다.
|
||||
*
|
||||
* 신규 Provider/KeyStore 준비가 완전히 성공한 후에만 기존 Provider 를 제거하고 교체한다.
|
||||
* 재초기화 중 예외가 발생하면 기존 Provider 와 keyStore 를 그대로 유지한다
|
||||
* 모든 후보가 실패하면 기존 Provider 와 keyStore 를 그대로 유지한다
|
||||
* (서비스 중단보다 마지막 정상 상태 보존을 우선).
|
||||
*/
|
||||
private void fullReinitialize() throws Exception {
|
||||
Provider oldProvider = this.pkcs11Provider;
|
||||
private void reconnect(List<HsmConfig> candidates) throws Exception {
|
||||
try {
|
||||
// 1. 신규 Provider 인스턴스 생성 (Security 미등록 상태)
|
||||
String configContent = EncryptionManager.getInstance()
|
||||
.decryptDBData(PropManager.getInstance().getProperty(GROUP_NAME, PROP_CONFIG));
|
||||
Provider newProvider = createProvider(configContent.trim().replace("\\n", "\n"));
|
||||
|
||||
// 2. 신규 Provider 로 KeyStore 로드 테스트
|
||||
// KeyStore.getInstance(type, providerInstance) 는 Security 등록 없이도 동작하므로
|
||||
// 여기서 실패해도 oldProvider/keyStore 는 변경되지 않은 상태를 유지함
|
||||
KeyStore ks = KeyStore.getInstance("PKCS11", newProvider);
|
||||
ks.load(null, pin);
|
||||
|
||||
// 3. 신규 연결 성공 → 기존 Provider 정리 후 교체
|
||||
if (oldProvider instanceof AuthProvider) {
|
||||
try {
|
||||
((AuthProvider) oldProvider).logout();
|
||||
} catch (Exception logoutEx) {
|
||||
logger.warn("HsmManager] 기존 세션 logout 실패(무시하고 진행): " + logoutEx.getMessage());
|
||||
}
|
||||
}
|
||||
Security.removeProvider(oldProvider.getName());
|
||||
Security.addProvider(newProvider);
|
||||
|
||||
this.pkcs11Provider = newProvider;
|
||||
this.keyStore = ks;
|
||||
|
||||
logger.warn("HsmManager] Provider 전체 재초기화 성공.");
|
||||
|
||||
HsmConnection connection = connectFirstAvailable(candidates);
|
||||
applyConnection(connection);
|
||||
recordReloadSuccess(connection.config.name + " 설정 재연결 성공");
|
||||
logger.warn("HsmManager] HSM 재연결 성공. config=" + connection.config.name);
|
||||
} catch (Exception e) {
|
||||
logger.warn("HsmManager] Provider 전체 재초기화 실패. 이전 keyStore 를 그대로 유지합니다: " + e.getMessage(), e);
|
||||
recordReloadFailure(e.getMessage());
|
||||
logger.warn("HsmManager] HSM 재연결 실패. 이전 keyStore 를 그대로 유지합니다: " + e.getMessage(), e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private void recordReloadSuccess(String detail) {
|
||||
lastReloadAt = System.currentTimeMillis();
|
||||
lastReloadResult = "SUCCESS - " + detail;
|
||||
reloadSuccessCount++;
|
||||
}
|
||||
|
||||
private void recordReloadFailure(String detail) {
|
||||
lastReloadAt = System.currentTimeMillis();
|
||||
lastReloadResult = "FAIL - " + detail;
|
||||
reloadFailCount++;
|
||||
recordError(detail);
|
||||
}
|
||||
|
||||
private void recordError(String message) {
|
||||
lastErrorMessage = message;
|
||||
lastErrorAt = System.currentTimeMillis();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// PropManager 변경 즉시 반영
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private void registerPropertyChangeListener() {
|
||||
if (propListenerRegistered) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
PropManager.getInstance().addPropertyChangeListener(this);
|
||||
propListenerRegistered = true;
|
||||
logger.warn("HsmManager] PropManager PropertyChangeListener 등록 완료");
|
||||
} catch (Exception e) {
|
||||
logger.warn("HsmManager] PropManager PropertyChangeListener 등록 실패(주기적 재로드로 대체): "
|
||||
+ e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void unregisterPropertyChangeListener() {
|
||||
if (!propListenerRegistered) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
PropManager.getInstance().removePropertyChangeListener(this);
|
||||
} catch (Exception e) {
|
||||
logger.warn("HsmManager] PropManager PropertyChangeListener 해제 실패(무시): " + e.getMessage());
|
||||
}
|
||||
propListenerRegistered = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리 포털에서 PropManager.reload("HSM") 또는 setProperty 를 호출하면 수신된다.
|
||||
* 재로드 자체는 HSM 통신을 수반하므로 호출 스레드를 막지 않도록 스케줄러 스레드에 위임한다.
|
||||
*/
|
||||
@Override
|
||||
public void propertyChange(PropertyChangeEvent evt) {
|
||||
if (!isHsmGroupEvent(evt)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (applyReloadInterval()) {
|
||||
rescheduleReload();
|
||||
}
|
||||
|
||||
ScheduledExecutorService currentScheduler = this.scheduler;
|
||||
if (currentScheduler == null || currentScheduler.isShutdown()) {
|
||||
return;
|
||||
}
|
||||
logger.warn("HsmManager] HSM 프로퍼티 변경 감지 → 설정 재적용을 요청합니다.");
|
||||
currentScheduler.execute(this::reloadKeyStoreSafely);
|
||||
}
|
||||
|
||||
/**
|
||||
* PropManager 는 reload(group) 시 propertyName 에 그룹명을, setProperty 시 키명을 담고
|
||||
* source 에는 항상 해당 그룹의 PropGroupVO 를 담는다. 두 경우를 모두 인식한다.
|
||||
*/
|
||||
private boolean isHsmGroupEvent(PropertyChangeEvent evt) {
|
||||
if (evt == null) {
|
||||
return false;
|
||||
}
|
||||
if (GROUP_NAME.equals(evt.getPropertyName())) {
|
||||
return true;
|
||||
}
|
||||
Object source = evt.getSource();
|
||||
return (source instanceof PropGroupVO) && GROUP_NAME.equals(((PropGroupVO) source).getName());
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Provider 생성
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* JDK 버전에 따라 SunPKCS11 Provider 를 생성한다.
|
||||
*
|
||||
@@ -304,6 +672,8 @@ public class HsmManager implements Lifecycle {
|
||||
}
|
||||
lifecycle.fireLifecycleEvent(STOPING_EVENT, this);
|
||||
|
||||
unregisterPropertyChangeListener();
|
||||
|
||||
if (reloadFuture != null) {
|
||||
reloadFuture.cancel(false);
|
||||
}
|
||||
@@ -323,6 +693,8 @@ public class HsmManager implements Lifecycle {
|
||||
}
|
||||
pkcs11Provider = null;
|
||||
keyStore = null;
|
||||
activeConfig = null;
|
||||
activeSince = 0;
|
||||
started = false;
|
||||
|
||||
lifecycle.fireLifecycleEvent(STOPPED_EVENT, this);
|
||||
@@ -375,4 +747,98 @@ public class HsmManager implements Lifecycle {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 모니터링용 조회 (HsmStatusController)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** 현재 연결에 사용 중인 설정 이름(PRIMARY/SECONDARY). 미연결이면 null. */
|
||||
public String getActiveConfigName() {
|
||||
HsmConfig config = this.activeConfig;
|
||||
return (config == null) ? null : config.name;
|
||||
}
|
||||
|
||||
/** 현재 secondary 설정으로 절체된 상태인지 여부. */
|
||||
public boolean isUsingSecondaryConfig() {
|
||||
return CONFIG_SECONDARY.equals(getActiveConfigName());
|
||||
}
|
||||
|
||||
/** 현재 연결에 실제로 사용된 pkcs11.cfg 내용. PIN 은 포함되지 않는다. */
|
||||
public String getActiveConfigContent() {
|
||||
HsmConfig config = this.activeConfig;
|
||||
return (config == null) ? null : config.configContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 PropManager 값 기준으로 실제 적용될 pkcs11.cfg 내용을 후보별로 반환한다.
|
||||
* PIN 은 포함하지 않는다. (설정이름 -> cfg 내용, primary 우선순)
|
||||
*/
|
||||
public java.util.Map<String, String> getResolvedConfigContents() {
|
||||
java.util.Map<String, String> contents = new java.util.LinkedHashMap<>();
|
||||
try {
|
||||
for (HsmConfig config : loadConfigCandidates()) {
|
||||
contents.put(config.name, config.configContent);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
logger.warn("HsmManager] 연결 후보 설정 조회 실패: " + e.getMessage());
|
||||
}
|
||||
return contents;
|
||||
}
|
||||
|
||||
/** 현재 KeyStore 의 alias 목록. HSM 통신이 발생한다. */
|
||||
public List<String> getKeyAliases() throws Exception {
|
||||
KeyStore ks = this.keyStore;
|
||||
if (ks == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<String> aliases = new ArrayList<>();
|
||||
java.util.Enumeration<String> e = ks.aliases();
|
||||
while (e.hasMoreElements()) {
|
||||
aliases.add(e.nextElement());
|
||||
}
|
||||
return aliases;
|
||||
}
|
||||
|
||||
public String getProviderName() {
|
||||
Provider provider = this.pkcs11Provider;
|
||||
return (provider == null) ? null : provider.getName();
|
||||
}
|
||||
|
||||
public long getReloadIntervalMinutes() {
|
||||
return reloadIntervalMinutes;
|
||||
}
|
||||
|
||||
/** 현재 설정으로 연결된 시각(epoch millis). 미연결이면 0. */
|
||||
public long getActiveSince() {
|
||||
return activeSince;
|
||||
}
|
||||
|
||||
public long getLastReloadAt() {
|
||||
return lastReloadAt;
|
||||
}
|
||||
|
||||
public String getLastReloadResult() {
|
||||
return lastReloadResult;
|
||||
}
|
||||
|
||||
public String getLastErrorMessage() {
|
||||
return lastErrorMessage;
|
||||
}
|
||||
|
||||
public long getLastErrorAt() {
|
||||
return lastErrorAt;
|
||||
}
|
||||
|
||||
public int getReloadSuccessCount() {
|
||||
return reloadSuccessCount;
|
||||
}
|
||||
|
||||
public int getReloadFailCount() {
|
||||
return reloadFailCount;
|
||||
}
|
||||
|
||||
/** primary <-> secondary 절체가 발생한 횟수. */
|
||||
public int getFailoverCount() {
|
||||
return failoverCount;
|
||||
}
|
||||
}
|
||||
@@ -717,7 +717,8 @@ public class EAIFileLogger
|
||||
sb.appendAndDelimeter( NullControl.addSpace(message.getRspnsChngMsgType())); // 응답변환유형
|
||||
|
||||
// log level 'E' or 'F'
|
||||
if ("E".equals(logRspErrCd.substring(1, 2)) || "F".equals(logRspErrCd.substring(1, 2))) {
|
||||
String logErrLvl = StringUtils.substring(logRspErrCd, 1, 2);
|
||||
if ("E".equals(logErrLvl) || "F".equals(logErrLvl)) {
|
||||
sb.appendAndDelimeter( NullControl.addSpace(logRspErrCd)); //EAI에러코드
|
||||
sb.appendAndDelimeter( StringUtil.chunkString(message.getRspErrMsg(),1000)); //EAI에러내용
|
||||
}
|
||||
|
||||
@@ -661,8 +661,8 @@ public class EAILogDAO {
|
||||
eaiLog.setRspnschngmsgtype(message.getRspnsChngMsgType());
|
||||
|
||||
// log level 'E' or 'F'
|
||||
if ("E".equals(logRspErrCd.substring(1, 2))
|
||||
|| "F".equals(logRspErrCd.substring(1, 2))) {
|
||||
String logErrLvl = StringUtils.substring(logRspErrCd, 1, 2);
|
||||
if ("E".equals(logErrLvl) || "F".equals(logErrLvl)) {
|
||||
// EAI에러코드
|
||||
eaiLog.setEaierrcd(logRspErrCd);
|
||||
// EAI에러내용
|
||||
@@ -770,8 +770,8 @@ public class EAILogDAO {
|
||||
// 수동시스템어댑터업무그룹명
|
||||
eaiErrorLog.setPsvsysadptrbzwkgroupname(message.getCurrentSvcMsg().getPsvSysItfTp());
|
||||
|
||||
if ("E".equals(logRspErrCd.substring(1, 2))
|
||||
|| "F".equals(logRspErrCd.substring(1, 2))) {
|
||||
String logErrLvl = StringUtils.substring(logRspErrCd, 1, 2);
|
||||
if ("E".equals(logErrLvl) || "F".equals(logErrLvl)) {
|
||||
// EAI에러코드
|
||||
eaiErrorLog.setEaierrcd(logRspErrCd);
|
||||
// EAI에러내용
|
||||
|
||||
@@ -292,7 +292,7 @@ public class EAIServiceMonitor implements Lifecycle {
|
||||
msgPssTm = msg.getMsgPssTm();
|
||||
msgRcvTm = msg.getMsgRcvTm();
|
||||
logPssSno = msg.getLogPssSno();
|
||||
rspErrCd = msg.getLogRspErrCd();
|
||||
rspErrCd = StringUtils.defaultString(msg.getLogRspErrCd());
|
||||
eaiSvcCd = msg.getEAISvcCd();
|
||||
svcOgNo = msg.getSvcOgNo();
|
||||
bwkCls = msg.getBwkCls();
|
||||
@@ -317,7 +317,7 @@ public class EAIServiceMonitor implements Lifecycle {
|
||||
error = rspErrCd.substring(1, 2);
|
||||
}
|
||||
|
||||
if (rspErrCd.equals("RECEAIINA001")) {
|
||||
if ("RECEAIINA001".equals(rspErrCd)) {
|
||||
error = "S";
|
||||
}
|
||||
|
||||
@@ -326,7 +326,8 @@ public class EAIServiceMonitor implements Lifecycle {
|
||||
iErrorCode = 1;
|
||||
|
||||
// Timeout
|
||||
if (timeOutCodes.indexOf(rspErrCd) > 0) {
|
||||
// 목록 첫 번째 코드는 indexOf 가 0 이므로 '> 0' 이면 매칭되지 않는다. (isTimeOutCodes() 와 동일하게 '>= 0')
|
||||
if (timeOutCodes.indexOf(rspErrCd) >= 0) {
|
||||
iErrorCode = 2;
|
||||
}
|
||||
// 업무에러코드에 없을 경우 통신(시스템)에러로 처리함
|
||||
|
||||
@@ -5,12 +5,17 @@ import java.util.List;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.core.json.JsonReadFeature;
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.JsonMappingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import com.fasterxml.jackson.databind.node.TextNode;
|
||||
|
||||
import org.json.simple.JSONObject;
|
||||
|
||||
@@ -35,9 +40,133 @@ public final class JacksonUtil {
|
||||
private static final Pattern TOKEN_PATTERN = Pattern.compile("([^\\[\\]]*)((?:\\[\\d+\\])*)");
|
||||
private static final Pattern INDEX_PATTERN = Pattern.compile("\\[(\\d+)\\]");
|
||||
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
private static final ObjectMapper OBJECT_MAPPER = newNumberSafeMapper();
|
||||
|
||||
/**
|
||||
* JSON 숫자를 double 로 좁히지 않고 BigDecimal 로, 수신한 자릿수 그대로 유지하는 ObjectMapper.
|
||||
*
|
||||
* 입력측(파싱) - 2개 옵션이 함께 필요하다.
|
||||
* readTree() 로 파싱한 뒤 writeValueAsString() 으로 다시 문자열을 만드는 왕복에서,
|
||||
* 기본 설정이면 100000000.00 이 1.0E8 로 변형된다.
|
||||
* USE_BIG_DECIMAL_FOR_FLOATS 만 켜고 withExactBigDecimals(true) 를 빼면 기본
|
||||
* JsonNodeFactory 가 stripTrailingZeros() 를 적용해 scale 이 음수가 되어 1E+8 이 된다.
|
||||
* 두 옵션을 함께 켜야 수신한 값이 그대로 보존된다.
|
||||
*
|
||||
* 출력측(직렬화) - WRITE_BIGDECIMAL_AS_PLAIN 이 추가로 필요하다.
|
||||
* DecimalNode 직렬화는 결국 BigDecimal.toString() 이고, 이것은
|
||||
* scale 이 음수이거나 adjusted exponent 가 -6 미만일 때 지수 표기를 쓴다.
|
||||
* 즉 위 2개 옵션으로 파싱을 제대로 해도, 내보낼 때 작은 소수가 깨진다.
|
||||
* 0.00000012 -> 1.2E-7 , -0.0000005 -> -5E-7
|
||||
* 금액처럼 scale 이 0 이상인 큰 값은 영향이 없지만, 이율/환율은 깨진다.
|
||||
* (Jackson 2.12.7 실측, 2026-08-27)
|
||||
*
|
||||
* 선행 0 허용 - ALLOW_LEADING_ZEROS_FOR_NUMBERS.
|
||||
* JSON 표준은 숫자의 선행 0 을 금지하므로, 상대가 0 패딩된 코드값을 따옴표 없이 보내면
|
||||
* 파서가 아래 오류로 거부한다.
|
||||
* Invalid numeric value: Leading zeroes not allowed
|
||||
* 전문 자체를 못 읽고 실패하는 것보다 값을 받아들이는 쪽이 낫다고 판단해 옵션으로 허용한다.
|
||||
* ⚠ 이 옵션은 00001 을 숫자 1 로 만든다. 즉 선행 0 은 보존되지 않는다.
|
||||
* - 표준전문 항목이 NUMBER/LL_NUMBER 로 선언돼 있으면 StandardItem.toTypeValue() 가
|
||||
* 어차피 선행 0 을 깎으므로 결과가 같다.
|
||||
* - STRING/ZZ_STRING 으로 선언된 0 패딩 코드값이라면 자릿수가 사라진다.
|
||||
* 그런 항목은 상대에게 따옴표를 붙여 보내달라고 요청하는 것이 정답이다.
|
||||
* (Jackson 2.12.7 실측, 2026-08-28)
|
||||
*/
|
||||
public static ObjectMapper newNumberSafeMapper() {
|
||||
ObjectMapper objectMapper = new ObjectMapper();
|
||||
objectMapper.enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS);
|
||||
objectMapper.setNodeFactory(JsonNodeFactory.withExactBigDecimals(true));
|
||||
objectMapper.enable(JsonGenerator.Feature.WRITE_BIGDECIMAL_AS_PLAIN);
|
||||
objectMapper.getFactory().configure(
|
||||
JsonReadFeature.ALLOW_LEADING_ZEROS_FOR_NUMBERS.mappedFeature(), true);
|
||||
return objectMapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON 문자열 리터럴 안의 이스케이프되지 않은 제어문자(0x00~0x1F)를 JSON 이스케이프로 바꾼다.
|
||||
*
|
||||
* JSON 표준은 문자열 안의 제어문자를 반드시 이스케이프하도록 요구하므로, 파서는 raw 개행 등을
|
||||
* 만나면 아래 오류로 파싱을 거부한다.
|
||||
* Illegal unquoted character ((CTRL-CHAR, code 10)): has to be escaped using backslash
|
||||
* 그런데 연동 상대 시스템들이 개행을 이스케이프하지 않고 그대로 보내는 사례가 있다.
|
||||
* 파서 옵션(ALLOW_UNESCAPED_CONTROL_CHARS)으로 푸는 대신, 입력을 표준 JSON 으로
|
||||
* 정규화해서 파서는 strict 로 유지한다.
|
||||
*
|
||||
* 중요: 문자열 리터럴 "안" 에 있는 것만 바꾼다. JSON 은 토큰 사이의 개행/탭을 공백으로
|
||||
* 허용하므로, 구조적 공백까지 치환하면 pretty-print 된 JSON 이 오히려 깨진다.
|
||||
*
|
||||
* 값 자체는 보존된다. raw 개행은 \n 으로 바뀌어 파싱 후 다시 개행 문자가 된다.
|
||||
* 제어문자가 데이터가 아니라 쓰레기 값(고정길이 전문의 0x00 패딩 등)이라면
|
||||
* 이 메서드에 의존하지 말고 파싱 전에 제거해야 한다.
|
||||
*
|
||||
* @param json 원본 JSON 문자열. null 이면 null 반환
|
||||
* @return 제어문자가 이스케이프된 JSON. 바꿀 게 없으면 원본을 그대로 반환
|
||||
*/
|
||||
public static String escapeControlChars(String json) {
|
||||
if (json == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 빠른 경로: 제어문자가 아예 없으면 원본 그대로 (대부분의 전문이 여기 해당)
|
||||
boolean found = false;
|
||||
for (int i = 0; i < json.length(); i++) {
|
||||
if (json.charAt(i) < 0x20) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
return json;
|
||||
}
|
||||
|
||||
StringBuilder sb = new StringBuilder(json.length() + 16);
|
||||
boolean inString = false;
|
||||
boolean escaped = false;
|
||||
|
||||
for (int i = 0; i < json.length(); i++) {
|
||||
char c = json.charAt(i);
|
||||
|
||||
if (!inString) {
|
||||
// 문자열 밖: 구조적 공백(개행/탭 등)은 건드리지 않는다
|
||||
if (c == '"') {
|
||||
inString = true;
|
||||
}
|
||||
sb.append(c);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (escaped) {
|
||||
// 백슬래시 뒤 한 글자는 그대로 통과 (이스케이프된 따옴표/백슬래시,
|
||||
// 유니코드 이스케이프의 선두 u 등). 이미 이스케이프된 것은 건드리지 않는다.
|
||||
sb.append(c);
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (c == '\\') {
|
||||
sb.append(c);
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (c == '"') {
|
||||
sb.append(c);
|
||||
inString = false;
|
||||
continue;
|
||||
}
|
||||
if (c < 0x20) {
|
||||
switch (c) {
|
||||
case '\n': sb.append("\\n"); break;
|
||||
case '\r': sb.append("\\r"); break;
|
||||
case '\t': sb.append("\\t"); break;
|
||||
case '\b': sb.append("\\b"); break;
|
||||
case '\f': sb.append("\\f"); break;
|
||||
default: sb.append(String.format("\\u%04x", (int) c)); break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
sb.append(c);
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private JacksonUtil() {
|
||||
// 인스턴스화 방지
|
||||
@@ -77,7 +206,9 @@ public final class JacksonUtil {
|
||||
return null;
|
||||
}
|
||||
|
||||
return objectMapper.readTree(jsonStr);
|
||||
// 상대 시스템이 제어문자를 이스케이프하지 않고 보내는 경우가 있어 정규화 후 파싱한다.
|
||||
// 이미 표준을 지킨 JSON 이면 원본을 그대로 반환하므로 사실상 무해하다.
|
||||
return objectMapper.readTree(escapeControlChars(jsonStr));
|
||||
}
|
||||
|
||||
public static JsonNode readTree(Object jsonData) throws JsonMappingException, JsonProcessingException {
|
||||
@@ -240,7 +371,9 @@ public final class JacksonUtil {
|
||||
if (target == null || !target.isArray() || lastIdx < 0 || lastIdx >= target.size()) {
|
||||
return false;
|
||||
}
|
||||
((ArrayNode) target).set(lastIdx, value);
|
||||
// set(int, String) 오버로드는 jackson-databind 2.13 부터다. 실제 런타임은 2.12.7 이므로
|
||||
// TextNode 로 감싸 set(int, JsonNode) 에 바인딩해야 NoSuchMethodError 가 나지 않는다.
|
||||
((ArrayNode) target).set(lastIdx, TextNode.valueOf(value));
|
||||
return true;
|
||||
} else {
|
||||
// 객체 필드 값 교체 (target은 fieldName으로 이미 이동된 상태이므로, parent 기준 재설정 필요)
|
||||
|
||||
@@ -9,7 +9,6 @@ import com.eactive.eai.common.messagekey.MessageKeyGroupVO;
|
||||
import com.eactive.eai.common.messagekey.MessageKeyManager;
|
||||
import com.eactive.eai.common.messagekey.MessageKeyVO;
|
||||
import com.eactive.eai.transformer.message.ISO8583MessageFactory;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.solab.iso8583.IsoMessage;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.json.simple.JSONValue;
|
||||
@@ -23,8 +22,6 @@ public final class MessageKeyExtractor {
|
||||
|
||||
static Logger logger = Logger.getLogger(Logger.LOGGER_DEFAULT);
|
||||
|
||||
private static final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
/**
|
||||
* Private 생성자
|
||||
* Instance를 생성하지 못함
|
||||
|
||||
@@ -47,6 +47,61 @@ public final class MessageUtil {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 문자열에서 제어문자(0x00~0x1F, 0x7F)를 걸러낸다.
|
||||
*
|
||||
* 값이 JSON / XML / 고정길이 전문 중 어디로 나갈지 모르는 자리 - 대표적으로 템플릿 치환 -
|
||||
* 에서 쓴다. 출력 포맷마다 이스케이프 방식이 달라 포맷을 알아야 하는데, 제어문자를 아예
|
||||
* 걷어내면 포맷과 무관하게 안전해진다.
|
||||
*
|
||||
* 각 포맷에서 제어문자가 일으키는 문제:
|
||||
* - JSON : raw 제어문자는 문자열 안에 올 수 없다
|
||||
* ("Illegal unquoted character ((CTRL-CHAR, code 10))")
|
||||
* - XML : 0x09/0x0A/0x0D 를 제외한 제어문자는 문자 참조로도 표현할 수 없어
|
||||
* 수신측 파서가 거부한다
|
||||
* - 전문 : 제어문자도 1바이트를 차지해 고정길이 자리수가 어긋난다
|
||||
*
|
||||
* 처리 규칙
|
||||
* - 탭/개행/캐리지리턴(0x09/0x0A/0x0D) : 구분 의미가 있으므로 공백 1칸으로 치환
|
||||
* - 그 외 제어문자 및 DEL(0x7F) : 제거
|
||||
*
|
||||
* 연속 공백을 합치지는 않는다(CRLF 는 공백 2칸이 된다). 값 변형을 최소화하기 위함이다.
|
||||
*
|
||||
* @param s 원본 문자열. null/빈 문자열이면 그대로 반환
|
||||
* @return 제어문자가 걸러진 문자열. 걸러낼 게 없으면 원본을 그대로 반환
|
||||
*/
|
||||
public static String stripControlChars(String s) {
|
||||
if (s == null || s.isEmpty()) {
|
||||
return s;
|
||||
}
|
||||
|
||||
// 빠른 경로: 제어문자가 없으면 원본 그대로 (대부분의 값이 여기 해당)
|
||||
boolean found = false;
|
||||
for (int i = 0; i < s.length(); i++) {
|
||||
char c = s.charAt(i);
|
||||
if (c < 0x20 || c == 0x7F) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
return s;
|
||||
}
|
||||
|
||||
StringBuilder sb = new StringBuilder(s.length());
|
||||
for (int i = 0; i < s.length(); i++) {
|
||||
char c = s.charAt(i);
|
||||
if (c == '\t' || c == '\n' || c == '\r') {
|
||||
sb.append(' ');
|
||||
} else if (c < 0x20 || c == 0x7F) {
|
||||
continue;
|
||||
} else {
|
||||
sb.append(c);
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
// ASCII Bytes에서 특정길이의 값을 추출하는 Method
|
||||
public static String getAscBytes(byte[] message, int startPos, int length) {
|
||||
if (message == null || message.length < startPos) {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package com.eactive.eai.manage.hsm;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.Callable;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* HSM 연동 현황 조회 API. (ToolsController 와 동일한 응답 형태)
|
||||
*
|
||||
* PIN 등 비밀값과 키 원본은 응답에 포함하지 않는다.
|
||||
*
|
||||
* GET /manage/hsm/status → 연결 설정(PRIMARY/SECONDARY), 적용 프로퍼티,
|
||||
* KeyStore alias, 캐싱된 키 목록 등 전체 현황
|
||||
* ?healthCheck=true → 실제 HSM 통신으로 세션 생존까지 확인
|
||||
* GET /manage/hsm/properties → HSM 프로퍼티 그룹의 현재 값 (PIN 류 마스킹)
|
||||
* GET /manage/hsm/keystore/aliases → 현재 KeyStore 의 alias 목록 (HSM 통신 발생)
|
||||
* GET /manage/hsm/cache/keys → HsmCryptoService 에 캐싱된 키 목록
|
||||
* POST /manage/hsm/reload → 즉시 재로드 (PRIMARY → SECONDARY 순 연결 시도)
|
||||
* POST /manage/hsm/cache/clear → 키 캐시 초기화
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/manage/hsm")
|
||||
public class HsmStatusController {
|
||||
|
||||
private static final MediaType APPLICATION_JSON_UTF8 = new MediaType("application", "json", StandardCharsets.UTF_8);
|
||||
|
||||
@Autowired
|
||||
private HsmStatusService hsmStatusService;
|
||||
|
||||
@GetMapping("/status")
|
||||
public ResponseEntity<?> status(
|
||||
@RequestParam(name = "healthCheck", required = false, defaultValue = "false") boolean healthCheck) {
|
||||
return respond(() -> hsmStatusService.getStatus(healthCheck));
|
||||
}
|
||||
|
||||
@GetMapping("/properties")
|
||||
public ResponseEntity<?> properties() {
|
||||
return respond(() -> hsmStatusService.getMaskedProperties());
|
||||
}
|
||||
|
||||
@GetMapping("/keystore/aliases")
|
||||
public ResponseEntity<?> keyStoreAliases() {
|
||||
return respond(() -> hsmStatusService.getKeyAliases());
|
||||
}
|
||||
|
||||
@GetMapping("/cache/keys")
|
||||
public ResponseEntity<?> cachedKeys() {
|
||||
return respond(() -> hsmStatusService.getCachedKeys());
|
||||
}
|
||||
|
||||
@PostMapping("/reload")
|
||||
public ResponseEntity<?> reload() {
|
||||
return respond(() -> hsmStatusService.reloadNow());
|
||||
}
|
||||
|
||||
@PostMapping("/cache/clear")
|
||||
public ResponseEntity<?> clearCache() {
|
||||
return respond(() -> hsmStatusService.clearKeyCache());
|
||||
}
|
||||
|
||||
private ResponseEntity<?> respond(Callable<Object> action) {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
try {
|
||||
result.put("success", true);
|
||||
result.put("data", action.call());
|
||||
} catch (Exception e) {
|
||||
result.put("success", false);
|
||||
result.put("message", e.getMessage());
|
||||
}
|
||||
return ResponseEntity.ok().contentType(APPLICATION_JSON_UTF8).body(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.eactive.eai.manage.hsm;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import com.eactive.eai.common.hsm.HsmCachedKeyInfo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* HSM 연동 현황 진단 정보.
|
||||
*
|
||||
* PIN 등 비밀값과 키 원본은 포함하지 않는다. PKCS11_CONFIG 는 라이브러리 경로/슬롯 정보만
|
||||
* 담고 있어 그대로 노출한다.
|
||||
*/
|
||||
@Data
|
||||
public class HsmStatusDTO {
|
||||
|
||||
// ---- 기동/연결 상태 ----
|
||||
|
||||
/** HsmManager Lifecycle 기동 여부 */
|
||||
boolean started;
|
||||
|
||||
/** Provider 와 KeyStore 가 모두 준비된 상태인지 (HSM 통신 없음) */
|
||||
boolean ready;
|
||||
|
||||
/** 실제 HSM 키 조회까지 성공하는지 (HSM 통신 발생, 조회 요청 시에만 채움) */
|
||||
Boolean healthy;
|
||||
|
||||
/** 현재 연결에 사용 중인 설정: PRIMARY / SECONDARY / null(미연결) */
|
||||
String activeConfigName;
|
||||
|
||||
/** secondary 로 절체된 상태인지 */
|
||||
boolean usingSecondaryConfig;
|
||||
|
||||
/** 현재 등록된 SunPKCS11 Provider 이름 */
|
||||
String providerName;
|
||||
|
||||
/** 현재 설정으로 연결된 시각 */
|
||||
String activeSince;
|
||||
|
||||
// ---- 설정 ----
|
||||
|
||||
/** PropManager 기준 연결 후보 설정과 실제 적용될 pkcs11.cfg 내용 (primary 우선순) */
|
||||
Map<String, String> resolvedConfigs;
|
||||
|
||||
/** HSM 프로퍼티 그룹의 현재 값 (PIN 류는 마스킹) */
|
||||
Map<String, String> properties;
|
||||
|
||||
/** KeyStore 주기적 재로드 간격(분) */
|
||||
long reloadIntervalMinutes;
|
||||
|
||||
// ---- 재로드 이력 ----
|
||||
|
||||
String lastReloadAt;
|
||||
String lastReloadResult;
|
||||
String lastErrorAt;
|
||||
String lastErrorMessage;
|
||||
int reloadSuccessCount;
|
||||
int reloadFailCount;
|
||||
|
||||
/** primary <-> secondary 절체 발생 횟수 */
|
||||
int failoverCount;
|
||||
|
||||
// ---- 키 ----
|
||||
|
||||
/** 현재 KeyStore 의 alias 목록 (HSM 통신 발생) */
|
||||
List<String> keyAliases;
|
||||
|
||||
/** alias 조회 실패 시 사유. 성공이면 null */
|
||||
String keyAliasError;
|
||||
|
||||
/** HsmCryptoService 에 캐싱된 키 목록 (키 원본 미포함) */
|
||||
List<HsmCachedKeyInfo> cachedKeys;
|
||||
|
||||
/** 키 캐시 TTL(밀리초) */
|
||||
long cacheTtlMs;
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package com.eactive.eai.manage.hsm;
|
||||
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Properties;
|
||||
import java.util.TreeMap;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.eactive.eai.common.hsm.HsmCachedKeyInfo;
|
||||
import com.eactive.eai.common.hsm.HsmCryptoService;
|
||||
import com.eactive.eai.common.hsm.HsmManager;
|
||||
import com.eactive.eai.common.property.PropManager;
|
||||
|
||||
/**
|
||||
* HSM 연동 현황 조회 서비스.
|
||||
*
|
||||
* HsmManager 의 연결 상태(primary/secondary), 적용 중인 HSM 프로퍼티, HsmCryptoService 의
|
||||
* 키 캐시 목록을 한 곳에 모아 진단용으로 제공한다.
|
||||
*/
|
||||
@Service
|
||||
public class HsmStatusService {
|
||||
|
||||
private static final String PROP_GROUP = "HSM";
|
||||
|
||||
/** 이 문자열이 포함된 프로퍼티 키의 값은 마스킹한다. */
|
||||
private static final String[] SECRET_KEY_TOKENS = { "PIN", "PASSWORD", "PASSWD", "SECRET" };
|
||||
|
||||
private static final String DATE_FORMAT = "yyyy-MM-dd HH:mm:ss";
|
||||
|
||||
@Autowired
|
||||
private HsmCryptoService hsmCryptoService;
|
||||
|
||||
/**
|
||||
* @param includeHealthCheck true 이면 실제 HSM 통신으로 세션 생존까지 확인한다.
|
||||
*/
|
||||
public HsmStatusDTO getStatus(boolean includeHealthCheck) {
|
||||
HsmManager hsmManager = HsmManager.getInstance();
|
||||
|
||||
HsmStatusDTO status = new HsmStatusDTO();
|
||||
status.setStarted(hsmManager.isStarted());
|
||||
status.setReady(hsmManager.isReady());
|
||||
status.setActiveConfigName(hsmManager.getActiveConfigName());
|
||||
status.setUsingSecondaryConfig(hsmManager.isUsingSecondaryConfig());
|
||||
status.setProviderName(hsmManager.getProviderName());
|
||||
status.setActiveSince(formatTime(hsmManager.getActiveSince()));
|
||||
|
||||
status.setResolvedConfigs(hsmManager.getResolvedConfigContents());
|
||||
status.setProperties(getMaskedProperties());
|
||||
status.setReloadIntervalMinutes(hsmManager.getReloadIntervalMinutes());
|
||||
|
||||
status.setLastReloadAt(formatTime(hsmManager.getLastReloadAt()));
|
||||
status.setLastReloadResult(hsmManager.getLastReloadResult());
|
||||
status.setLastErrorAt(formatTime(hsmManager.getLastErrorAt()));
|
||||
status.setLastErrorMessage(hsmManager.getLastErrorMessage());
|
||||
status.setReloadSuccessCount(hsmManager.getReloadSuccessCount());
|
||||
status.setReloadFailCount(hsmManager.getReloadFailCount());
|
||||
status.setFailoverCount(hsmManager.getFailoverCount());
|
||||
|
||||
try {
|
||||
status.setKeyAliases(hsmManager.getKeyAliases());
|
||||
} catch (Exception e) {
|
||||
status.setKeyAliases(Collections.<String>emptyList());
|
||||
status.setKeyAliasError(e.getMessage());
|
||||
}
|
||||
|
||||
status.setCachedKeys(getCachedKeys());
|
||||
status.setCacheTtlMs(hsmCryptoService.getCacheTtlMs());
|
||||
|
||||
if (includeHealthCheck) {
|
||||
status.setHealthy(Boolean.valueOf(hsmManager.isHealthy()));
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
/** HsmCryptoService 에 캐싱된 키 목록 (키 원본 미포함). */
|
||||
public List<HsmCachedKeyInfo> getCachedKeys() {
|
||||
return hsmCryptoService.getCachedKeyInfos();
|
||||
}
|
||||
|
||||
/** 현재 KeyStore 의 alias 목록. HSM 통신이 발생한다. */
|
||||
public List<String> getKeyAliases() throws Exception {
|
||||
return HsmManager.getInstance().getKeyAliases();
|
||||
}
|
||||
|
||||
/**
|
||||
* HSM 프로퍼티 그룹의 현재 값. PIN 등 비밀값은 마스킹한다.
|
||||
* PropManager 에 저장된 원본(암호화 저장 시 암호문) 그대로이며, 실제 적용되는
|
||||
* 복호화 config 내용은 HsmStatusDTO.resolvedConfigs 에서 확인한다.
|
||||
*/
|
||||
public Map<String, String> getMaskedProperties() {
|
||||
Map<String, String> masked = new TreeMap<>();
|
||||
try {
|
||||
Properties properties = PropManager.getInstance().getProperties(PROP_GROUP);
|
||||
for (String key : properties.stringPropertyNames()) {
|
||||
masked.put(key, mask(key, properties.getProperty(key)));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
masked.put("_error", "HSM 프로퍼티 그룹 조회 실패: " + e.getMessage());
|
||||
}
|
||||
return masked;
|
||||
}
|
||||
|
||||
/**
|
||||
* 재로드를 즉시 수행한다. primary -> secondary 순으로 연결을 시도하며,
|
||||
* 신규 연결이 완전히 성공한 경우에만 KeyStore 가 교체된다.
|
||||
*/
|
||||
public String reloadNow() throws Exception {
|
||||
HsmManager hsmManager = HsmManager.getInstance();
|
||||
hsmManager.reloadKeyStoreIfNeeded();
|
||||
return "재로드 완료. activeConfig=" + hsmManager.getActiveConfigName()
|
||||
+ ", result=" + hsmManager.getLastReloadResult();
|
||||
}
|
||||
|
||||
/** HsmCryptoService 키 캐시를 비운다. */
|
||||
public String clearKeyCache() {
|
||||
hsmCryptoService.clearKeyCache();
|
||||
return "키 캐시 초기화 완료";
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private String mask(String key, String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String upperKey = key.toUpperCase();
|
||||
for (String token : SECRET_KEY_TOKENS) {
|
||||
if (upperKey.contains(token)) {
|
||||
return "****(len=" + value.length() + ")";
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private String formatTime(long epochMillis) {
|
||||
if (epochMillis <= 0) {
|
||||
return null;
|
||||
}
|
||||
return new SimpleDateFormat(DATE_FORMAT).format(new Date(epochMillis));
|
||||
}
|
||||
}
|
||||
@@ -8,13 +8,13 @@ import org.apache.commons.lang3.StringUtils;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
import com.eactive.eai.common.util.JacksonUtil;
|
||||
import com.eactive.eai.message.EncodingVar;
|
||||
import com.eactive.eai.message.StandardItem;
|
||||
import com.eactive.eai.message.StandardMessage;
|
||||
import com.eactive.eai.message.StandardType;
|
||||
import com.fasterxml.jackson.core.JsonFactory;
|
||||
import com.fasterxml.jackson.core.JsonParser;
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
@@ -22,6 +22,11 @@ import com.fasterxml.jackson.databind.node.JsonNodeType;
|
||||
|
||||
public class JsonReader implements StandardReader {
|
||||
static Logger logger = LoggerFactory.getLogger(JsonReader.class);
|
||||
|
||||
// ObjectMapper 는 설정이 끝나면 thread-safe 하고 생성 비용이 크므로 재사용한다.
|
||||
// 숫자 보존 옵션(입력/출력)은 JacksonUtil.newNumberSafeMapper() 참조.
|
||||
private static final ObjectMapper MAPPER = JacksonUtil.newNumberSafeMapper();
|
||||
|
||||
private char FIELD_SEPARATOR = '.';
|
||||
private boolean ZERO_BASE_INDEX = true;
|
||||
|
||||
@@ -37,12 +42,16 @@ public class JsonReader implements StandardReader {
|
||||
jsonString = (String) obj;
|
||||
}
|
||||
|
||||
// 상대 시스템이 개행 등 제어문자를 이스케이프하지 않고 그대로 보내는 경우가 있다.
|
||||
// 그대로 파싱하면 "Illegal unquoted character ((CTRL-CHAR, code 10))" 로 실패하므로
|
||||
// 문자열 리터럴 안의 제어문자만 표준 이스케이프로 정규화한 뒤 파싱한다.
|
||||
// (파서 옵션으로 푸는 대신 입력을 표준 JSON 으로 맞추는 방식)
|
||||
jsonString = JacksonUtil.escapeControlChars(jsonString);
|
||||
|
||||
JsonNode jsonNode = null;
|
||||
ObjectMapper mapper = null;
|
||||
ObjectMapper mapper = MAPPER;
|
||||
JsonFactory factory = null;
|
||||
JsonParser parser = null;
|
||||
mapper = new ObjectMapper();
|
||||
mapper.enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS);
|
||||
factory = mapper.getFactory();
|
||||
try {
|
||||
parser = factory.createParser(jsonString);
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.eactive.eai.util;
|
||||
|
||||
import org.json.simple.JSONObject;
|
||||
|
||||
import com.eactive.eai.common.util.JacksonUtil;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
@@ -15,7 +16,10 @@ public class JsonPathUtil {
|
||||
|
||||
}
|
||||
|
||||
private static final ObjectMapper objectMapper = new ObjectMapper();
|
||||
// readTree() 로 파싱한 뒤 writeValueAsString() 으로 되돌리는 왕복이 잦으므로,
|
||||
// 숫자 자릿수가 유실되지 않는 mapper 를 쓴다.
|
||||
// 기본 ObjectMapper 는 100000000.00 을 1.0E8 로 바꿔버린다.
|
||||
private static final ObjectMapper objectMapper = JacksonUtil.newNumberSafeMapper();
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// JsonNode 기반 단일 파싱 API — 연속 get/set 시 파싱 횟수 절감
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.eactive.eai.util.json;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import com.eactive.eai.common.util.JacksonUtil;
|
||||
import com.eactive.eai.common.util.Logger;
|
||||
import com.eactive.eai.util.json.transformer.ValueTransformer;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
@@ -14,7 +15,9 @@ import com.jayway.jsonpath.spi.mapper.JacksonMappingProvider;
|
||||
|
||||
public class JsonPathsTransform {
|
||||
static Logger logger = Logger.getLogger(Logger.LOGGER_ADAPTER);
|
||||
private static final ObjectMapper objectMapper = new ObjectMapper();
|
||||
// 파싱 후 재직렬화하므로 숫자 자릿수가 유실되지 않는 mapper 를 쓴다.
|
||||
// 기본 ObjectMapper 는 100000000.00 을 1.0E8 로 바꿔버린다.
|
||||
private static final ObjectMapper objectMapper = JacksonUtil.newNumberSafeMapper();
|
||||
|
||||
public static <T, R> String modifyValuesAtPaths(String jsonString,
|
||||
Map<String, ValueTransformer<T, R>> pathTransformerMap, boolean isPretty) {
|
||||
@@ -23,9 +26,11 @@ public class JsonPathsTransform {
|
||||
// DocumentContext documentContext = JsonPath.parse(jsonString);
|
||||
|
||||
// 변경헤도 별차이가 없음.
|
||||
// provider 에 mapper 를 넘기지 않으면 json-path 가 자체 기본 ObjectMapper 를 쓰게 되어
|
||||
// documentContext.jsonString() 단계에서 이미 숫자 자릿수가 유실된다.
|
||||
Configuration conf = Configuration.builder()
|
||||
.jsonProvider(new JacksonJsonNodeJsonProvider())
|
||||
.mappingProvider(new JacksonMappingProvider())
|
||||
.jsonProvider(new JacksonJsonNodeJsonProvider(objectMapper))
|
||||
.mappingProvider(new JacksonMappingProvider(objectMapper))
|
||||
.build();
|
||||
DocumentContext documentContext = JsonPath.using(conf).parse(jsonString);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.eactive.eai.util.json;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import com.eactive.eai.common.util.JacksonUtil;
|
||||
import com.eactive.eai.common.util.Logger;
|
||||
import com.eactive.eai.util.json.transformer.ValueTransformer;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
@@ -11,7 +12,9 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
|
||||
public class JsonSimplePathsTransform {
|
||||
static Logger logger = Logger.getLogger(Logger.LOGGER_ADAPTER);
|
||||
private static final ObjectMapper objectMapper = new ObjectMapper();
|
||||
// 파싱 후 재직렬화하므로 숫자 자릿수가 유실되지 않는 mapper 를 쓴다.
|
||||
// 기본 ObjectMapper 는 100000000.00 을 1.0E8 로 바꿔버린다.
|
||||
private static final ObjectMapper objectMapper = JacksonUtil.newNumberSafeMapper();
|
||||
|
||||
public static <T, R> String modifyValuesAtPaths(String jsonString,
|
||||
Map<String, ValueTransformer<T, R>> pathTransformerMap, boolean isPretty) {
|
||||
|
||||
Reference in New Issue
Block a user