WorkCASE STUDY 03OPERATIONS PLATFORM & AI
eform-ops · 운영을 연결하는 플랫폼
요청·승인부터 빌드·배포, EKS 운영, 관측과 AI 분석까지 연결했습니다. 인프라팀이 실제 사용하는 운영 플랫폼을 기획하고 설계·구현했습니다.
- 주요 작업 기간
- 2025.11–2026.09
- 팀
- FORCS - eformsign 인프라팀
- 역할
- 기획·아키텍처 설계·주요 기능 구현 주도
OUTCOME인프라팀에서 실제 사용하며, 운영 요청부터 실행·감사·AI 분석까지 하나의 흐름으로 관리하고 있습니다.
- Requests
- DB Access · 반영 요청
- Automation
- CI/CD Jobs · EKS Upgrade
- Intelligence
- AI Ops · RAG · Element
기획과 역할
60개가 넘는 온프레미스 사이트의 지원 맥락을 팀이 공유하고, 반복되는 빌드·배포와 운영 요청을 함께 다룰 수 있는 플랫폼을 기획하고 설계·구현했습니다.
내가 자리를 비워도, 이 사이트를 지원할 수 있을까?
60개가 넘는 온프레미스 사이트를 지원하면서 담당자가 휴가이거나 자리를 비웠을 때 동료가 업무를 이어받을 수 있는지가 출발점이었습니다. 사람의 기억과 개별 메일에 흩어진 환경 정보·이슈·지원 기록을 사이트 단위로 정리해, 누가 맡더라도 현재 상황과 이전 대응을 찾아볼 수 있도록 Site 페이지를 만들었습니다.
내부망 제약에서 시작한 운영 문제
2025년 11월부터 2026년 9월까지 원본 소스를 사내에 보관해야 하는 정책 아래 사이트·환경·서버·작업 기록·배포 상태를 한 흐름으로 관리하도록 설계했습니다. 사내 Jenkins·GitLab CI의 패키징과 S3 전달, CodeBuild의 이미지 빌드, 배포를 연결하고 승인과 실행 결과를 운영 데이터로 남겼습니다.
사이트 정보에서 팀의 운영 기록으로
환경·서버·라이선스와 작업 기록·일정을 사이트에 연결하고, Google Workspace Gmail의 지원 메일과 사내 Redmine 이슈트래커를 도입해 흩어진 지원 맥락을 모았습니다. 시간순 히스토리와 RAG 조회로 확장해 과거 이슈를 다시 찾고 다음 지원에 활용할 수 있도록 설계했습니다.
설계와 구현의 범위
대부분의 핵심 흐름을 계획하고 설계·구현했으며, 권한·감사·비동기 실행·실패 회복을 각 도메인의 기본 조건으로 두었습니다. 팀에서 실제로 반복 사용하는 화면과 상태 이력을 중심으로 기능을 구성해 운영자가 요청부터 결과까지 한 화면에서 따라가도록 만들었습니다.
전체 아키텍처
Portal을 통제·기록 계층으로 두고 실행 시스템과 운영 데이터의 책임을 분리했습니다.
서비스 계층
Next.js·React UI가 운영 요청을 받고 Spring Boot API가 인증, 역할 기반 권한, 상태 변경, 감사 이벤트를 처리하도록 구성했습니다. MariaDB에는 사이트·작업·요청·Job·outbox 이력을 저장하고 actor와 시각을 함께 남겨 상태 변화의 주체를 추적했습니다.
실행 위임 경계
Portal은 사내 Jenkins·GitLab CI의 소스 패키징, S3 패키지 업로드, CodeBuild 트리거와 이미지 빌드, SQS·Lambda·EKS runner의 배포를 연결합니다. 각 실행 결과는 callback으로 수집하며, durable outbox와 재조정을 통해 실행 시스템과 포털의 작업 이력이 같은 상태로 이어지도록 구현했습니다.
Site · 환경과 이슈를 같은 맥락에서
60개가 넘는 온프레미스 사이트의 환경·서버·라이선스·연락처와 이슈를 한곳에서 확인하도록 구성했습니다. 운영자가 바뀌어도 사이트의 구성과 현재 이슈를 먼저 파악할 수 있도록 환경 정보와 지원 기록을 같은 사이트 기준으로 연결했습니다. 설치 자료와 설치확인서도 해당 환경에 묶어 관리했습니다.
Gmail·Redmine · 흩어진 지원 기록 연결
Google Workspace Gmail을 연동해 지원 요청 메일을 사이트와 연결하고, 사내 Redmine을 이슈트래커로 도입했습니다. 메일·이슈·작업·지원 보고서를 따로 찾아야 했던 흐름을 사이트별 히스토리로 모아 요청의 시작부터 대응 과정까지 따라갈 수 있게 구성했습니다.
타임라인 · 무엇이 바뀌었는지 이어서 읽기
사이트 페이지의 타임라인에서 이슈·작업·메일·보고서·마일스톤을 시간순으로 읽고 유형별 필터와 검색으로 필요한 기록을 좁힐 수 있게 했습니다. AI 히스토리 요약으로 주요 이슈와 반복 작업을 살펴보고 원본 기록으로 돌아갈 수 있도록 연결했습니다. 담당자 부재 시에도 동료가 이전 대응과 남은 일을 확인할 수 있게 하는 데 초점을 맞췄습니다.
Requests · DB Access
DB Access를 일회성 계정 발급이 아니라 요청부터 회수와 감사까지 이어지는 시간 제한 권한 흐름으로 구현했습니다.
Private Subnet 접근 요청
Private Subnet의 운영 DB를 대상으로 리전·DB 종류·권한 템플릿·사용 시간·스키마 범위를 입력받아 접근 요청을 만들도록 설계했습니다. 승인 시 임시 DB 계정과 권한을 발급하고, 사용 시간이 끝나거나 관리자가 회수하면 schema 권한 제거·세션 종료·자격 증명 회전으로 계정 수명주기를 종료하도록 구현했습니다.
CloudBeaver 세션 제어
CloudBeaver 진입 전 Nginx auth_request가 포털 세션과 활성 grant를 확인하도록 연결했습니다. 활성 승인이 있을 때만 사용자·팀 헤더를 reverse proxy로 넘겨 CloudBeaver 세션을 만들고, 승인 만료나 회수 상태에서는 진입 자체를 차단했습니다.
업무 감사와 SQL 감사 분리
신청·승인·반려·회수·만료·CloudBeaver 진입은 포털 업무 감사로 기록하고, 실제 DB 연결과 SQL 실행은 RDS audit 로그 파이프라인으로 별도 수집하도록 설계했습니다. CloudWatch Logs와 Firehose·S3·Lambda를 거친 parsed 이벤트를 Athena로 조회하게 만들어 권한 결정의 이력과 데이터베이스 행위를 서로 다른 관점에서 추적했습니다.
DB Access 승인·권한 Provisioning
승인된 요청을 실제 DB 권한 발급과 연결하고, 변경 주체·만료 시각·대상을 감사 이력으로 남깁니다. 권한 발급 결과와 요청 상태를 같은 승인 흐름에서 처리합니다.
public DbAccessRequestResponse approve(String requestNo, Authentication authentication) {
// ... 요청 검증·대상 계산과 중간 grant 준비 생략 ...
if (provisioningService.isProvisioningEnabled()) {
provisioningService.provisionAccess(
target,
request.getRequesterUsername(),
temporaryPassword,
request.getPermissionTemplate(),
readPermissions(request.getCustomPermissionsJson())
);
}
// ... grant 저장과 sibling 처리 생략 ...
auditService.record(
saved,
saved.getGrant(),
"REQUEST_APPROVED",
actor,
"DB Access 요청이 승인되었습니다.",
Map.of(
"expiresAt", format(expiresAt),
"dbUsername", saved.getGrant().getDbUsername(),
"targetKey", saved.getGrant().getTargetKey(),
"provisioningEnabled", provisioningService.isProvisioningEnabled()
),
widgetAuditContext(authentication)
);
dbAccessNotifyService.notifyRequestApproved(saved, actor);
recordAudit(authentication, saved, "DB_ACCESS_APPROVE", "SUCCESS", "HIGH",
Map.of("status", DbAccessStatus.REQUESTED.name()), Map.of("status", saved.getStatus().name()),
"DB access request approved");
return toResponse(saved, actor, true, OPERATION_APPROVED);
}
Athena DB Query Audit 검색
DB 사용자·대상·시간 범위로 Athena 감사 쿼리를 구성합니다. 입력값 이스케이프와 날짜 파티션 필터, 조회 행 제한을 함께 적용했습니다.
private String buildQuery(String regionCode, String targetDb, String dbUsername, Instant from, Instant to) {
List<String> dateFilters = enumerateDateFilters(from, to);
String escapedRegion = escapeSql(regionCode);
String escapedTargetDb = escapeSql(targetDb);
String escapedDbUser = escapeSql(dbUsername);
long fromEpochMillis = from.toEpochMilli();
long toEpochMillis = to.toEpochMilli();
return """
SELECT event_time, cloudwatch_event_timestamp_ms, db_user, client_host, event_type, status, query_text, query_id, connection_id
FROM %s.%s
WHERE region = '%s'
AND target_db = '%s'
AND db_user = '%s'
AND (%s)
AND cloudwatch_event_timestamp_ms >= %d
AND cloudwatch_event_timestamp_ms <= %d
ORDER BY cloudwatch_event_timestamp_ms DESC
LIMIT %d
""".formatted(
props.getDatabaseName(),
props.getTableName(),
escapedRegion,
escapedTargetDb,
escapedDbUser,
String.join(" OR ", dateFilters),
fromEpochMillis,
toEpochMillis,
props.getMaxRows()
);
}
Requests · 반영 요청
develop 변경과 사용자 반영 요청을 연결하면서 사용자가 선택한 대상과 승인 정책을 보존하는 반영 요청 흐름을 구현했습니다.
develop 변경과 사용자 요청
develop에 push·merge된 변경에서 브랜치와 버전 정보를 준비하고, 환경·리전·애플리케이션·추가 작업을 선택한 사용자의 제출 요청에서 반영을 시작하도록 설계했습니다. 요청 단위로 대상과 실행 범위를 저장해 변경 후보와 실제 반영을 같은 이력에서 추적하도록 구현했습니다.
선택적 승인부터 기능 확인용 환경 배포까지
환경 정책에 따라 승인 또는 즉시 큐잉한 뒤 사내 Jenkins·GitLab CI의 소스 패키징 → S3 업로드 → CodeBuild 트리거·컨테이너 이미지 빌드 → 기능 확인용 환경 배포로 이어지도록 구성했습니다. 요청 snapshot, 대상 scope lock, build·deploy Job lineage를 함께 저장해 동일 대상의 중복 실행과 상태 누락을 방지했습니다.
내구성 있는 오케스트레이션
웹 요청 트랜잭션과 외부 provider 호출을 분리하고 durable work outbox가 lease·retry·reconcile을 담당하도록 구현했습니다. 빌드 callback이 늦거나 유실되어도 immutable lineage와 이벤트 타임라인을 기준으로 다시 관찰하고, 실패·취소·완료 상태를 요청 이력에 일관되게 반영했습니다.
Build reconcile SUCCESS→Job 생성
사내 패키징·S3 업로드·CodeBuild 이미지 빌드로 이어지는 build Job이 성공하면 배포 Job을 생성하고, 실패·취소는 요청 상태로 반영합니다. 외부 조회의 일시적 오류는 이후 재조정으로 넘겨 요청 흐름을 이어갑니다.
private void reconcileBuild(ReleaseRequest request) {
if (request.getBuildJobId() == null || request.getBuildJobId().isBlank()) return;
try {
BuildJob build = buildService.findForReconciliation(request.getBuildJobId());
releaseRequestService.observeBuildPhase(request.getId(), build);
if (build == null) return;
if (build.getStatus() == BuildStatus.SUCCESS) {
createJob(request, build.getId());
} else if (build.getStatus() == BuildStatus.FAILED) {
releaseRequestService.markFailed(request.getId(),
"Build finished with status " + build.getStatus());
} else if (build.getStatus() == BuildStatus.CANCELLED) {
releaseRequestService.markCancelled(request.getId(),
"Build finished with status " + build.getStatus());
}
} catch (RuntimeException exception) {
log.debug("[release-request] build reconciliation deferred requestNo={} message={}",
request.getRequestNo(), safeMessage(exception));
}
}
CI/CD와 Pipeline Jobs
원본 소스를 사내에 보관하는 정책에 맞춰 사내 패키징과 CodeBuild 이미지 빌드를 연결하고, 배포와 상태 관리를 분리했습니다.
사내 패키징과 CodeBuild 이미지 빌드
사내 정책상 원본 소스 저장소를 외부 클라우드에 둘 수 없어 Jenkins·GitLab CI가 사내에서 소스를 checkout·패키징하도록 구성했습니다. 패키지와 manifest를 S3에 업로드한 뒤 CodeBuild를 트리거하고, CodeBuild는 전달된 패키지로 컨테이너 이미지를 만들어 ECR에 푸시합니다. 사내 저장소와 외부 이미지 빌드 사이를 S3 artifact·manifest 계약으로 연결했습니다.
CD 실행 경로
CodeBuild의 이미지 빌드가 완료되면 포털이 deploy Job을 만들고 SQS·Lambda를 거쳐 EKS runner가 kubectl apply와 rollout 상태 수집을 수행하도록 구현했습니다. 이미지 빌드 결과는 build_job, 배포 결과는 ops_job에 별도 기록해 CI와 CD의 실패·재시도·완료 상태를 구분하면서 같은 요청에서 추적했습니다.
Job 상태와 이력
build_job과 ops_job을 별도 단위로 저장하고 provider callback, 실행 이벤트, dedupe key, 재시도 횟수를 연결했습니다. 포털 화면에서는 실행 중인 작업의 현재 상태와 완료된 작업의 history를 같은 lineage에서 조회하도록 구현해 빌드와 배포의 상태를 독립적으로 기록했습니다.
S3 패키지를 입력으로 CodeBuild 실행
사내 Jenkins·GitLab CI가 업로드한 S3 패키지·manifest의 bucket/key와 ECR 입력을 전달해 CodeBuild를 트리거합니다. 원본 저장소는 사내에 유지하며, CodeBuild는 전달된 패키지로 컨테이너 이미지를 만듭니다. 요청별 idempotency token과 provider build ID로 중복 실행을 제어하고 포털 작업 이력을 연결합니다.
private String startProviderBuild(
BuildJob job,
ManifestDto manifest,
String manifestBucket,
String env,
ManifestItemDto item,
int attempt,
RuntimeDispatch runtime,
BuildProviderExecution sourceExecution
) {
StartBuildResponse resp = codeBuild.startBuild(r -> r
.projectName(props.getWorkerProjectName())
.idempotencyToken(idempotencyToken(job.getId(), env, item.region(), item.app(), attempt))
.environmentVariablesOverride(
env("ARTIFACT_BUCKET", manifestBucket),
env("ARTIFACT_KEY", item.s3Key()),
env("SOURCE_MANIFEST_BUCKET", manifestBucket),
env("SOURCE_MANIFEST_KEY", sourceExecution == null ? "" : sourceExecution.getManifestKey()),
// ... 실행 대상·이미지·Runtime Contract 환경변수 중간 항목 생략 ...
env("ECR_REPO_URI", runtime.ecrRepoUri()),
env("ECR_REGION", runtime.ecrRegion())
)
);
return providerBuildId(resp);
}
EKS Upgrade
EKS 업그레이드를 단일 명령이 아니라 사전 조건과 단계별 실행·회복 상태를 갖는 상태 머신으로 구현했습니다.
실행 가드와 사전 조건
환경 플래그·Settings 실행 권한·Runner 준비 상태를 mutation guard로 묶어 세 조건이 맞지 않으면 변경을 시작하지 않게 했습니다. AWS 접근, 클러스터 버전, 호환성, addon·nodegroup·Helm·workload, baseline health를 순서대로 읽고 lane별 resource snapshot과 stage 상태를 저장했습니다.
단계별 실행과 복구
availability prepare·verify 이후 control plane, addon, Helm, nodegroup, workload를 실행하고 post health와 availability restore·restore verify로 마무리하도록 구성했습니다. NodeGroup 단계는 대상별로 병렬 처리하되 한 작업이 실패하면 신규 스케줄링을 멈추고 이미 진행 중인 작업을 정리해 부분 실행이 통제된 상태로 남게 했습니다.
값 보존과 재조정
전용 runner가 기존 Helm 사용자 값을 캡처하고 새 chart 기본값과 병합한 뒤 atomic·wait 업그레이드를 수행하며, release revision·보존 값·CloudWatch 관측 설정을 확인하도록 구현했습니다. callback은 event ID와 sequence로 중복을 제어하고, 외부 작업의 응답이 끊겨도 포털 worker가 persisted operation을 재조정해 Helm을 중복 실행하지 않도록 설계했습니다.
EKS Upgrade Mutation Run 안전장치
변경 권한과 가용성 준비 상태를 확인한 뒤 업그레이드를 진행합니다. 준비 조건이 충족되지 않으면 중단하고, 진행 중인 작업과 종료된 작업을 구분해 상태를 확정합니다.
private void executeMutationRun(String runId, List<String> laneIds, int preparationAttempt) {
try {
mutationGuard.assertMutationAllowed();
if (preparationAttempt == 0) {
stateStore.markRunRunning(runId);
}
// ... 준비 lane 실행과 중간 barrier 처리 생략 ...
EksUpgradeAvailabilityBarrierService.BarrierResult barrier =
verifyGlobalAvailabilityBarrier(runId, laneIds);
if (barrier != null && !barrier.passed()) {
String reason = barrier.validation() == null || barrier.validation().blockers().isEmpty()
? "Global availability barrier timed out"
: String.join("; ", barrier.validation().blockers());
abortPreparedLanes(laneIds, reason);
finalizeExecutionRunIfTerminal(runId);
return;
}
// ... 완료 lane 집계 전환 생략 ...
if (stateStore.allLanesTerminal(runId)) {
EksUpgradeRunStatus status = stateStore.finalizeRun(runId);
log.info("[UPGRADE][RUN][EXECUTE_DONE] runId={} status={} lanes={}", runId, status, laneIds.size());
} else {
log.info("[UPGRADE][RUN][WAITING_EXTERNAL] runId={} lanes={}", runId, laneIds.size());
}
} catch (CompletionException e) {
Throwable cause = e.getCause() == null ? e : e.getCause();
log.warn("[UPGRADE][RUN][EXECUTE_FAIL] runId={} message={}", runId, cause.getMessage(), cause);
stateStore.failRun(runId, message(cause));
} catch (Exception e) {
log.warn("[UPGRADE][RUN][EXECUTE_FAIL] runId={} message={}", runId, e.getMessage(), e);
stateStore.failRun(runId, message(e));
}
}
Observability AI Ops
알람에서 운영 증거를 수집해 원인 후보와 후속 확인 순서를 제시하는 AIOps 흐름을 구현했습니다.
알람 증거 수집기
CloudWatch alarm과 metric window를 기준으로 target health, EKS 상태, 애플리케이션 로그, deployment 이력, historical error pattern을 한 분석 snapshot으로 모았습니다. 수집기는 metric·ALB·Kubernetes·로그를 각각 독립 collector로 나누고, 일부 조회가 실패해도 경고와 수집 결과를 함께 남겨 분석 입력의 범위를 보존했습니다.
원인 후보 중심의 분석
AI에는 알람 이름만 전달하지 않고 전후 시점의 수치, 로그 패턴, 배포 변화와 대상 상태를 정리해 전달하도록 prompt를 구성했습니다. 결과는 원인 후보·근거·반대 근거·권장 확인 항목을 구조화하고, provider 실패나 timeout은 정상 분석과 분리된 상태로 저장해 자동 조치로 이어지지 않게 했습니다.
실패 Job 분석의 별도 경로
배포 Job이 terminal FAILED 상태가 된 뒤에 별도 LLM 분석을 시작하고, 실행 로그와 인프라 snapshot을 모은 뒤 민감정보를 redaction해 외부 provider에 전달하도록 구현했습니다. provider fallback, 입력 fingerprint 기반 history 재사용, durable history 저장을 적용했으며 실패 시점의 실행 이벤트를 보존해 인프라 조회 상태와 분리된 분석 입력을 구성했습니다.
실패 Job AI 지연 분석 시작
실패한 Job의 실행 정보와 인프라 데이터 수집을 분석 작업에 연결합니다. 데이터 수집은 지연 실행하고 분석 ID와 입력 fingerprint로 진행 상태를 추적합니다.
private JobAiAnalysisResponse startLocked(Long jobId, String sourceId, String actor, boolean force) {
// ... failed job 확인·중복 실행 조회·fingerprint 검증 생략 ...
ErrorLogDeepAnalysisJobResponse started;
if (evidenceCollector == null) {
// Kept for legacy embedding callers and focused tests that use the
// five-argument constructor. The Spring path always has the
// collector and uses deferred collection below.
JobExecutionLogResponse logs = executionLogService.logs(jobId, null, 300, null, null, null);
JsonNode evidenceSnapshot = collectEvidenceSnapshot(job, failureEvidence.items(), logs);
String rawLog = buildPromptLog(job, logs, failureEvidence, evidenceSnapshot);
started = deepAnalysisService.startInfrastructureAssociated(
new ErrorLogDeepAnalysisRequest(null, rawLog, evidenceSnapshot), actor,
SOURCE_TYPE, sourceId, fingerprint
);
} else {
String pendingLog = pendingPromptLog(job, failureEvidence);
Supplier<ObservabilityErrorLogDeepAnalysisService.EvidencePreparation> supplier =
() -> prepareEvidence(job, failureEvidence);
started = deepAnalysisService.startInfrastructureAssociatedDeferred(
new ErrorLogDeepAnalysisRequest(null, pendingLog), actor,
SOURCE_TYPE, sourceId, fingerprint, supplier
);
}
active.put(started.jobId(), new SourceRef(sourceId, fingerprint));
return response(started, sourceId, fingerprint, false);
}
알람 전후 운영 데이터 수집
알람 전후의 시간 범위를 정하고 EKS 상태·로그·배포 이력·과거 오류 패턴을 분석 입력으로 모읍니다. 일부 조회가 실패해도 수집 결과와 경고를 함께 반환합니다.
public Map<String, Object> collect(AlarmAnalysisClaimResponse.Item item) {
Instant center = item.stateUpdatedAt() == null ? Instant.now() : item.stateUpdatedAt();
Instant start = center.minus(Duration.ofMinutes(Math.max(1, props.getAiops().getEvidenceWindowBeforeMinutes())));
Instant end = center.plus(Duration.ofMinutes(Math.max(1, props.getAiops().getEvidenceWindowAfterMinutes())));
int periodSeconds = Math.max(60, props.getAiops().getMetricPeriodSeconds());
// ... metric·target health·EKS 수집 중간 단계 생략 ...
Map<String, Object> kubernetesEvidence = eksKubernetesEvidenceCollector.collect(item, targetHealth, toolResults, warnings);
evidence.put("eks", kubernetesEvidence.getOrDefault("eks", Map.of(
"status", "NOT_COLLECTED",
"reason", "Kubernetes evidence collector did not return EKS data."
)));
Map<String, Object> logEvidence = aiopsLogEvidenceCollector.collect(item, dimensions, start, end, toolResults, warnings);
evidence.put("logs", logEvidence);
evidence.put("deployments", kubernetesEvidence.getOrDefault("deployments", Map.of(
"status", "NOT_COLLECTED",
"reason", "Kubernetes evidence collector did not return deployment data."
)));
evidence.put("historicalErrorPatterns", historicalErrorPatternCollector.collect(logEvidence, warnings));
evidence.put("toolResults", toolResults);
evidence.put("warnings", warnings);
return evidence;
}
운영 데이터 RAG
사이트의 과거 지원 이력을 더 쉽게 찾기 위해 RAG를 도입하고, 검색 결과와 원본 기록을 함께 확인할 수 있도록 구성했습니다.
이 사이트에서 전에 비슷한 문제가 있었을까?
사이트 환경을 정리하고 지원 기록을 타임라인으로 모은 다음에는, 오래된 이력을 빠르게 찾아 다음 대응에 활용하는 문제가 남았습니다. Gmail 연동 메일, Redmine 이슈와 작업·지원 기록의 맥락을 바탕으로 히스토리를 질의할 수 있게 RAG를 도입했습니다. 담당자의 기억에 의존하던 과거 대응을 팀이 검색하고 원본으로 확인할 수 있도록 확장했습니다.
정규화부터 OpenSearch까지
MariaDB의 site history·work log·메일·요청·감사·observability 데이터를 search worker가 actor, action, status, environment, resource, case key 기준으로 정규화하고 chunk로 나눴습니다. 검색·embedding·LLM 전달 전에 비밀값을 mask_secrets로 정제하고 이메일 metadata에는 주소를 저장하지 않도록 했으며, embedding을 생성해 OpenSearch에 색인하고 원본 데이터는 MariaDB를 기준으로 유지했습니다.
검색 전 권한 필터
질문을 domain과 의도로 구조화한 뒤 현재 사용자가 접근할 수 있는 domain·environment·resource 범위를 먼저 계산하도록 설계했습니다. 이 범위 필터를 keyword 검색과 vector 검색보다 앞에 적용해, 관련도가 높아도 권한 밖의 운영 기록이 후보에 들어오지 않게 구현했습니다.
중복 제거와 출처 기반 답변
keyword·vector 후보를 record key로 dedupe하고 rerank와 MMR로 사건별 다양성과 직접 관련성을 조정한 뒤 answerability를 판단하도록 구성했습니다. 최종 답변은 선택된 source와 시간·상태 맥락을 함께 제시하고, 직접 답할 source가 없으면 답변 생성 대신 추가 확인이 필요한 상태를 반환하게 했습니다.
실행 과정과 판단 한계를 함께 표시
OPS AI Assistant에서 계획·도구·근거·답변의 실행 단계를 확인할 수 있도록 구현했습니다. 등록된 읽기 전용 도구로 이력을 조회하고, 답변에는 원본 기록과 근거의 관찰 시각·완전성을 함께 표시합니다. 과거 기록만으로 현재 상태나 완료 여부를 단정하지 않고 근거 부족과 추가 확인이 필요한 부분을 구분해 전달합니다.
RAG Hybrid Retrieval·권한 필터
키워드 검색과 벡터 검색에 같은 권한 필터를 먼저 적용합니다. 검색 결과를 합친 뒤 중복 제거와 MMR을 수행해 관련성과 사건별 다양성을 함께 확보합니다.
def search_global(self, req: OpsPlanRequest, plan: OpsQuestionPlan) -> list[dict[str, Any]]:
merged: dict[str, dict[str, Any]] = {}
rrf_k = env_int("QA_HYBRID_RRF_K", 60)
queries = list(dict.fromkeys(text(value) for value in [req.question, *plan.searchQueries] if text(value)))
filters = self.global_filters(req, plan)
for query in queries[: env_int("QA_SEARCH_MAX_QUERIES", 14)]:
lexical_body = {
"size": env_int("QA_LEXICAL_TOP_K", 30),
"query": {"bool": {"filter": filters, "must": [{"multi_match": {
"query": query,
"fields": ["title^4", "summary^3", "content_text^2", "actor^2", "resource_id^3", "correlation_id^2"],
"type": "best_fields", "operator": "or",
}}]}},
"sort": self.global_sort_clause(plan),
}
response = self.request("POST", f"/{self.global_index}/_search", lexical_body)
# ... lexical 응답 처리·RRF 병합 생략 ...
vector = self.embedding.embed_query(query)
if vector:
vector_body = {"size": env_int("QA_VECTOR_TOP_K", 30), "query": {"knn": {"embedding": {
"vector": vector,
"k": env_int("QA_VECTOR_TOP_K", 30),
"filter": {"bool": {"filter": filters}},
}}}}
response = self.request("POST", f"/{self.global_index}/_search", vector_body)
# ... vector 응답 처리·RRF 병합 생략 ...
docs = self.collapse_source_chunks(list(merged.values()))
# ... semantic score 계산과 정렬 생략 ...
return self.mmr_diversify(docs, env_int("QA_RETRIEVAL_TOP_K", 40))
Element 연동
Element를 운영 알림·AI 질의·승인 위젯과 사내 채팅방 공유 인터페이스로 설계했습니다.
AI 위젯에서 사내 채팅방으로 공유
Element의 OPS AI Assistant 위젯에서 RAG 답변과 관련 근거를 확인하고 사내 채팅방에 공유할 수 있도록 구현했습니다. 운영자가 확인한 답변을 같은 대화 공간에 전달해 동료가 원본 기록과 판단 맥락을 함께 살펴볼 수 있습니다.
멘션에서 thread 응답까지
bot mention → room policy와 user identity 인증 → durable inbox → AI gateway → durable outbox → thread reply 순서로 메시지를 처리하도록 구현했습니다. Matrix event ID와 run 연결, inbox lease·retry, outbox dedupe를 저장해 같은 멘션이 중복 실행되거나 답변이 여러 번 게시되지 않게 했습니다.
Artifact와 DB 승인 위젯
사용자가 명시적으로 요청한 형식에만 artifact를 만들고, 이미 승인된 artifact를 만료 가능한 링크로 전달하도록 AI 응답과 파일 전송을 분리했습니다. DB Access 위젯은 등록된 Element identity, 포털 session, 요청 권한과 action audit를 연결해 Element 안에서도 포털의 승인 정책을 그대로 사용하도록 구현했습니다.
권한과 감사가 이어지는 요청 흐름
정책에 등록된 일반 room과 검증된 구성원만 요청을 처리하고, 암호화 room은 정책 대상에서 제외하도록 구현했습니다. 읽기 전용 질의와 운영 알림은 thread로 전달하고, 운영 변경은 포털의 명시적 action과 권한 흐름에서 수행하도록 연결했습니다.
빌드·배포·업그레이드·DB 요청 알림
빌드와 배포 작업, EKS 업그레이드 완료, DB 요청 접수·승인·반려·회수·만료 알림을 Element로 연결했습니다. 각 이벤트는 포털의 상태 이력과 요청자 또는 운영 room의 Element 메시지가 같은 작업을 가리키도록 구성했습니다.
Element Room AI Authorization
허용된 방 정책, 봇 참여 상태, 요청자 신원과 포털 권한을 순서대로 확인합니다. 권한 모듈이 준비되지 않았거나 조건을 충족하지 않으면 요청을 거부합니다.
public Decision authorizeQuestion(String roomId, String requesterMxid, String botMxid, String question) {
Optional<ElementAiRoomPolicy> policy = allowedRoom(roomId);
if (policy.isEmpty()) return Decision.deny("이 방은 OPS AI 상세 답변 허용 방이 아닙니다.");
Map<String, String> members = matrix.joinedMembers(roomId);
if (botMxid == null || !members.containsKey(botMxid)) return Decision.deny("OPS 봇이 이 방에 참여하고 있지 않습니다.");
RequesterResolution requesterResolution = requester(members, requesterMxid);
if (!requesterResolution.allowed()) return Decision.deny(requesterResolution.reason());
PortalUser requester = requesterResolution.user();
ElementAiSharedAuthorizationGateway gateway = sharedAuthorization.getIfAvailable();
if (gateway == null) return Decision.deny("OPS AI 권한 검증 모듈이 아직 연결되지 않았습니다.");
Set<Long> requesterOnly = Set.of(requester.getId());
ElementAiSharedAuthorizationGateway.Decision decision = gateway.authorize(roomId, requester.getId(), requesterOnly, question);
return decision.allowed() ? Decision.allow(requester.getId(), requesterOnly, policy.get().allowFiles(), policy.get().revision())
: Decision.deny(decision.reason());
}
문제 해결과 결과
실제 팀 운영에 필요한 통제·실행·관측·검색 경계를 하나의 운영 포털 경험으로 묶었습니다.
하나의 운영 표면
Site에서 환경·이슈·지원 메일·Redmine·작업 기록을 같은 사이트 맥락으로 확인하고, 타임라인과 RAG로 과거 대응을 찾아볼 수 있게 했습니다. 담당자가 자리를 비워도 동료가 지원 맥락을 읽고 업무를 이어받을 수 있도록 정보를 남기는 방식을 제품에 담았습니다. DB 접근, 반영 요청, CI/CD Job, EKS 업그레이드와 AIOps도 같은 포털에서 운영하고 있습니다.
상태와 감사가 남는 실행
요청·승인·계정 권한·빌드·배포·업그레이드·AI 실행을 각각 상태와 이벤트로 저장하고, 외부 실행자와의 callback·outbox·reconcile 경계를 구현했습니다. 실패나 재시도도 최종 결과만 남기지 않고 실행 단위의 history로 축적해 다음 판단의 입력으로 사용할 수 있게 했습니다.
팀에서의 사용 방식
2025년 11월부터 2026년 9월까지 FORCS - eformsign 인프라팀의 실제 운영 흐름을 기준으로 대부분의 핵심 기능을 계획하고 설계·구현했습니다. 승인된 범위에서 실행하고 source와 이력을 남기며 실패를 다시 조정할 수 있는 운영 기준을 제품 안에 정착시켜 요청부터 결과까지 이어지는 운영 경험을 만들었습니다.