diff --git a/src/main/java/com/eactive/apim/portal/apps/apis/filter/ApiTesterAuditLogger.java b/src/main/java/com/eactive/apim/portal/apps/apis/filter/ApiTesterAuditLogger.java
new file mode 100644
index 0000000..15e5031
--- /dev/null
+++ b/src/main/java/com/eactive/apim/portal/apps/apis/filter/ApiTesterAuditLogger.java
@@ -0,0 +1,294 @@
+package com.eactive.apim.portal.apps.apis.filter;
+
+import com.eactive.apim.portal.common.util.HttpRequestUtil;
+import com.eactive.apim.portal.common.util.StringMaskingUtil;
+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.ObjectNode;
+import com.fasterxml.jackson.databind.node.TextNode;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.Set;
+import java.util.UUID;
+import javax.servlet.http.HttpServletRequest;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+
+/**
+ * API 테스트베드(/api/call-api) 감사(audit) 로그 기록기.
+ *
+ *
logback 의 {@code eapim.portal.apitester.audit} 로거(전용 파일, 1년 보관)로 기록한다.
+ * 요청 1건당 REQ/RES 두 줄을 같은 auditId 로 남긴다.
+ *
+ * 마스킹 정책:
+ *
+ * - secret 계열 키(client_secret, password, api_key, authorization 등)의 값은 전체 마스킹
+ * - 그 외 파라미터/JSON 값은 앞 일부만 남기고 마스킹
+ * - JSON 이 아닌 본문은 전체 길이(byte)와 앞 {@value #NON_JSON_PREVIEW_LENGTH}글자만 남기고 마스킹
+ *
+ */
+public final class ApiTesterAuditLogger {
+
+ private static final Logger auditLogger = LoggerFactory.getLogger("eapim.portal.apitester.audit");
+ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
+ /** 값 전체를 마스킹할 키(소문자 비교) */
+ private static final Set SECRET_KEYS = new HashSet<>(Arrays.asList(
+ "client_secret", "clientsecret", "secret", "password", "passwd", "pwd",
+ "api_key", "apikey", "access_token", "refresh_token", "authorization"));
+
+ /** 감사 로그에 남길 주요 요청 헤더 화이트리스트 */
+ private static final String[] AUDIT_HEADERS = {
+ "content-type", "accept", "referer", "origin", "x-forwarded-for",
+ "original-api-id", "authorization"};
+
+ /** 마스킹된 JSON 본문 로그 최대 길이(초과분 절단) — 대용량 본문의 로그 파일 비대화 방지 */
+ private static final int JSON_LOG_MAX_LENGTH = 2000;
+
+ /** JSON 이 아닌 본문의 노출 프리뷰 글자 수 */
+ private static final int NON_JSON_PREVIEW_LENGTH = 8;
+
+ private ApiTesterAuditLogger() {
+ }
+
+ /** REQ/RES 두 줄을 연결하는 짧은 감사 ID */
+ public static String newAuditId() {
+ return UUID.randomUUID().toString().substring(0, 8);
+ }
+
+ /**
+ * 요청 수신 시점 기록. 감사 로그 실패가 프록시 동작을 막지 않도록 예외는 삼킨다.
+ *
+ * @param targetUrl original-url 헤더 값 (없으면 null)
+ * @param gatewayMode 게이트웨이 모드명 (판별 전이면 "-")
+ * @param tokenRequest OAuth 토큰 발급 요청 여부
+ * @param body 이미 읽어 둔 요청 본문 (없으면 null/빈 문자열)
+ */
+ public static void logRequest(String auditId, HttpServletRequest request, String targetUrl,
+ String gatewayMode, boolean tokenRequest, String body) {
+ try {
+ StringBuilder sb = new StringBuilder(256);
+ sb.append("REQ [").append(auditId).append(']');
+ sb.append(" ip=").append(HttpRequestUtil.getClientIpAddress(request));
+ sb.append(" proxied=").append(HttpRequestUtil.isProxied(request));
+ sb.append(" user=").append(currentUser());
+ sb.append(" method=").append(request.getMethod());
+ sb.append(" mode=").append(gatewayMode);
+ sb.append(" token=").append(tokenRequest);
+ sb.append(" target=").append(targetUrl == null ? "-" : maskQueryValues(sanitize(targetUrl)));
+ sb.append(" ua=\"").append(sanitize(request.getHeader("User-Agent"))).append('"');
+ sb.append(" headers=").append(buildHeaderSummary(request));
+ sb.append(" body=").append(buildBodySummary(request.getContentType(), tokenRequest, body));
+ auditLogger.info(sb.toString());
+ } catch (Exception e) {
+ auditLogger.warn("REQ [{}] 감사 로그 기록 실패: {}", auditId, e.toString());
+ }
+ }
+
+ /** 처리 완료 시점 기록. type 은 처리 분기(TOKEN_GW/TOKEN_MOCK/SAMPLE/GW/MOCK 등). */
+ public static void logResult(String auditId, int status, String type, long elapsedMillis) {
+ auditLogger.info("RES [{}] status={} type={} elapsedMs={}", auditId, status, type, elapsedMillis);
+ }
+
+ // =========================================================================
+ // 요청 정보 구성
+ // =========================================================================
+
+ /** 로그인 사용자 식별자(마스킹). 미인증이면 anonymous. */
+ private static String currentUser() {
+ try {
+ Authentication auth = SecurityContextHolder.getContext().getAuthentication();
+ if (auth == null || !auth.isAuthenticated() || "anonymousUser".equals(auth.getName())) {
+ return "anonymous";
+ }
+ String name = auth.getName();
+ return name.contains("@") ? StringMaskingUtil.maskEmail(name) : partialMask(name);
+ } catch (Exception e) {
+ return "unknown";
+ }
+ }
+
+ /** 화이트리스트 헤더만 {k:"v"} 형태로 요약. secret 계열 헤더 값은 마스킹. */
+ private static String buildHeaderSummary(HttpServletRequest request) {
+ StringBuilder sb = new StringBuilder("{");
+ boolean first = true;
+ for (String name : AUDIT_HEADERS) {
+ String value = request.getHeader(name);
+ if (value == null) {
+ continue;
+ }
+ if (!first) {
+ sb.append(", ");
+ }
+ first = false;
+ sb.append(name).append(":\"").append(maskHeaderValue(name, sanitize(value))).append('"');
+ }
+ return sb.append('}').toString();
+ }
+
+ /** Authorization 등 인증 헤더는 스킴만 남기고 토큰부 마스킹. */
+ private static String maskHeaderValue(String name, String value) {
+ if (!SECRET_KEYS.contains(name.toLowerCase())) {
+ return value;
+ }
+ int space = value.indexOf(' ');
+ if (space > 0) {
+ return value.substring(0, space) + " " + partialMask(value.substring(space + 1).trim());
+ }
+ return partialMask(value);
+ }
+
+ // =========================================================================
+ // 본문 마스킹
+ // =========================================================================
+
+ private static String buildBodySummary(String contentType, boolean tokenRequest, String body) {
+ if (body == null || body.isEmpty()) {
+ return "-";
+ }
+ // 토큰 발급: form 필드 단위 마스킹 (client_secret 전체 마스킹)
+ if (tokenRequest) {
+ return "\"" + maskFormBody(body) + "\"";
+ }
+ // 일반 요청: JSON 이면 값 단위 부분 마스킹, 그 외(비 JSON)는 길이 + 프리뷰만
+ if (contentType != null && contentType.toLowerCase().contains("json")) {
+ String maskedJson = tryMaskJson(body);
+ if (maskedJson != null) {
+ return maskedJson;
+ }
+ }
+ return nonJsonSummary(body);
+ }
+
+ /** k=v&k=v 형태 본문의 값 단위 마스킹. secret 키는 전체 마스킹. */
+ private static String maskFormBody(String body) {
+ StringBuilder sb = new StringBuilder(body.length());
+ String[] pairs = body.split("&");
+ for (int i = 0; i < pairs.length; i++) {
+ if (i > 0) {
+ sb.append('&');
+ }
+ int eq = pairs[i].indexOf('=');
+ if (eq < 0) {
+ sb.append(partialMask(pairs[i]));
+ continue;
+ }
+ String key = pairs[i].substring(0, eq);
+ String value = pairs[i].substring(eq + 1);
+ sb.append(key).append('=');
+ sb.append(SECRET_KEYS.contains(key.toLowerCase()) ? "*****" : partialMask(value));
+ }
+ return sanitize(sb.toString());
+ }
+
+ /** URL 쿼리스트링 값 단위 마스킹 (경로는 그대로). */
+ private static String maskQueryValues(String url) {
+ int qs = url.indexOf('?');
+ if (qs < 0) {
+ return url;
+ }
+ return url.substring(0, qs) + "?" + maskFormBody(url.substring(qs + 1));
+ }
+
+ /** JSON 파싱 성공 시 값 단위 마스킹 문자열, 실패 시 null. */
+ private static String tryMaskJson(String body) {
+ try {
+ JsonNode masked = maskJsonNode(OBJECT_MAPPER.readTree(body));
+ String out = OBJECT_MAPPER.writeValueAsString(masked);
+ if (out.length() > JSON_LOG_MAX_LENGTH) {
+ out = out.substring(0, JSON_LOG_MAX_LENGTH) + "...(truncated)";
+ }
+ return out;
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ /** JSON 트리의 leaf 값을 재귀적으로 마스킹. secret 키 필드는 전체 마스킹. */
+ private static JsonNode maskJsonNode(JsonNode node) {
+ if (node.isObject()) {
+ ObjectNode obj = (ObjectNode) node;
+ Iterator names = obj.fieldNames();
+ Set fieldNames = new HashSet<>();
+ while (names.hasNext()) {
+ fieldNames.add(names.next());
+ }
+ for (String field : fieldNames) {
+ if (SECRET_KEYS.contains(field.toLowerCase())) {
+ obj.set(field, TextNode.valueOf("*****"));
+ } else {
+ obj.set(field, maskJsonNode(obj.get(field)));
+ }
+ }
+ return obj;
+ }
+ if (node.isArray()) {
+ ArrayNode arr = (ArrayNode) node;
+ for (int i = 0; i < arr.size(); i++) {
+ arr.set(i, maskJsonNode(arr.get(i)));
+ }
+ return arr;
+ }
+ if (node.isNull() || node.isMissingNode()) {
+ return node;
+ }
+ return TextNode.valueOf(partialMask(node.asText()));
+ }
+
+ /** 비 JSON 본문: 전체 길이(byte)와 앞 몇 글자만 노출. */
+ private static String nonJsonSummary(String body) {
+ int bytes = body.getBytes(StandardCharsets.UTF_8).length;
+ String preview = body.length() <= NON_JSON_PREVIEW_LENGTH
+ ? body : body.substring(0, NON_JSON_PREVIEW_LENGTH);
+ return "(non-json,bytes=" + bytes + ",preview=\"" + sanitize(preview) + "***\")";
+ }
+
+ // =========================================================================
+ // 공통 helper
+ // =========================================================================
+
+ /** 앞 일부(최대 4자)만 남기고 마스킹. 2자 이하는 전체 마스킹. */
+ private static String partialMask(String value) {
+ if (value == null || value.isEmpty()) {
+ return "";
+ }
+ int len = value.length();
+ if (len <= 2) {
+ return stars(len);
+ }
+ int visible = Math.min(4, Math.max(1, len / 3));
+ return value.substring(0, visible) + "***";
+ }
+
+ private static String stars(int count) {
+ char[] arr = new char[count];
+ Arrays.fill(arr, '*');
+ return new String(arr);
+ }
+
+ /** 제어문자·개행·따옴표를 치환해 한 줄 로그 형식을 보존. */
+ private static String sanitize(String value) {
+ if (value == null) {
+ return "-";
+ }
+ StringBuilder sb = new StringBuilder(value.length());
+ for (int i = 0; i < value.length(); i++) {
+ char c = value.charAt(i);
+ if (c == '"') {
+ sb.append('\'');
+ } else if (c == '\r' || c == '\n' || c == '\t') {
+ sb.append(' ');
+ } else if (c < 0x20) {
+ sb.append('?');
+ } else {
+ sb.append(c);
+ }
+ }
+ return sb.toString();
+ }
+}