Skip to content
Published on

Keycloak 관측성 — 메트릭, 감사 로그, 이벤트 기반 모니터링

Authors

들어가며

인증 시스템은 장애가 나는 순간 모든 서비스의 장애가 됩니다. 로그인이 안 되면 사용자 입장에서는 서비스 전체가 죽은 것과 다름없고, 더 무서운 것은 "조용한 보안 사고" — credential stuffing이 며칠간 진행되는데 아무도 모르는 상황입니다. 그래서 Keycloak 운영의 성숙도는 결국 관측성(observability)의 성숙도로 수렴합니다.

다행히 2026년의 Keycloak은 관측성 측면에서 과거와 비교할 수 없을 만큼 좋아졌습니다. 26.x 라인은 micrometer 기반 메트릭, 사용자 이벤트 메트릭, OpenTelemetry tracing을 내장하고 있으며, 26.6에서는 zero-downtime rolling update 같은 운영 기능까지 더해졌습니다. 이 글에서는 Keycloak의 이벤트 시스템과 보존 정책, EventListener SPI를 통한 외부 전송, Prometheus + Grafana 모니터링 스택 구성, tracing과 로그 수집, brute force/credential stuffing 탐지, 알림 룰, 그리고 ISMS-P/SOC 2 감사 대응까지 관측성의 전체 그림을 그려보겠습니다.

Keycloak 이벤트 시스템

Login Events와 Admin Events

Keycloak은 두 종류의 이벤트를 발생시킵니다.

구분Login Events (User Events)Admin Events
발생 주체최종 사용자 행위관리자/Admin API 행위
대표 이벤트LOGIN, LOGIN_ERROR, LOGOUT, REGISTER, UPDATE_PASSWORD, TOKEN_REFRESHCREATE/UPDATE/DELETE (user, client, role, realm 설정)
핵심 용도보안 모니터링, 사용자 행동 분석변경 감사 (who changed what)
페이로드사용자, client, IP, 결과, 에러 코드리소스 경로, 변경 전후 representation

이벤트 수집은 realm 단위로 켭니다. Admin Console의 Realm Settings에서도 가능하지만, 코드로 관리합시다.

# 이벤트 설정 (kcadm.sh)
./kcadm.sh update events/config -r myrealm \
  -s eventsEnabled=true \
  -s 'eventsExpiration=2592000' \
  -s 'enabledEventTypes=["LOGIN","LOGIN_ERROR","LOGOUT","REGISTER","UPDATE_PASSWORD","UPDATE_EMAIL","REMOVE_TOTP","UPDATE_TOTP","TOKEN_EXCHANGE","REFRESH_TOKEN_ERROR"]' \
  -s adminEventsEnabled=true \
  -s adminEventsDetailsEnabled=true
  • eventsExpiration은 초 단위 보존 기간입니다. 위 예시는 30일.
  • adminEventsDetailsEnabled를 켜면 변경된 representation(JSON)이 함께 저장되어 "무엇이 어떻게 바뀌었는지"까지 추적할 수 있습니다. 감사 요건이 있다면 필수입니다.
  • 26.x에서는 admin events에도 별도 만료 설정이 적용되므로, 두 종류의 보존 기간을 모두 명시하세요.

조회는 Admin API로 가능합니다.

# 최근 로그인 실패 이벤트 조회
./kcadm.sh get events -r myrealm \
  -q type=LOGIN_ERROR -q max=50

# 특정 사용자의 admin 변경 이력
./kcadm.sh get admin-events -r myrealm \
  -q resourcePath=users/USER_UUID -q max=20

이벤트 보존과 DB 부하 — 흔한 운영 사고

이벤트는 Keycloak의 메인 DB에 저장됩니다(EVENT_ENTITY, ADMIN_EVENT_ENTITY 테이블). 여기서 두 가지 사고 패턴이 반복됩니다.

사고 패턴 1: 무한 보존
  eventsExpiration 미설정 → 테이블이 수억 행으로 비대
  → 이벤트 INSERT 지연 → 로그인 트랜잭션 전체 지연
  → "로그인이 느려요" 티켓 폭주

사고 패턴 2: 일괄 삭제 폭탄
  뒤늦게 만료 설정 → 만료 작업이 수억 행 삭제 시도
  → DB lock 경합, WAL/redo 폭증 → 운영 중 DB 마비

실무 가이드라인은 다음과 같습니다.

  • 이벤트 보존은 **운영 조회용 단기(7~30일)**로 짧게 잡고, 장기 보관은 외부 시스템(아래 SPI 절)으로 내보냅니다. Keycloak DB는 감사 로그 아카이브가 아닙니다.
  • 이미 비대해진 테이블은 만료 설정에 맡기지 말고, 점검 시간에 배치 삭제(파티션 단위, LIMIT 반복)로 정리한 후 만료를 설정합니다.
  • enabledEventTypes를 비워두면 모든 타입이 저장됩니다. CODE_TO_TOKEN, TOKEN_REFRESH 같은 고빈도 이벤트가 필요한지 검토하고, 필요한 타입만 명시하세요. 토큰 갱신이 잦은 SPA가 많은 환경에서는 이 차이가 수십 배의 행 수 차이로 나타납니다.

EventListener SPI — 이벤트를 밖으로 보내기

DB 폴링 대신 이벤트 발생 시점에 외부 시스템(Kafka, Loki, SIEM)으로 푸시하는 것이 EventListener SPI입니다. 내장 listener로는 jboss-logging(로그 출력)과 email(사용자 알림)이 있고, 커스텀 구현을 JAR로 배포할 수 있습니다.

Kafka로 전송하는 커스텀 Listener

public class KafkaEventListenerProvider implements EventListenerProvider {

  private final KafkaProducer<String, String> producer;
  private final String topic;
  private final ObjectMapper mapper = new ObjectMapper();

  public KafkaEventListenerProvider(KafkaProducer<String, String> producer, String topic) {
    this.producer = producer;
    this.topic = topic;
  }

  @Override
  public void onEvent(Event event) {
    // LOGIN_ERROR 등 보안 이벤트를 비동기 전송
    try {
      String payload = mapper.writeValueAsString(Map.of(
          "category", "USER_EVENT",
          "type", event.getType().name(),
          "realmId", event.getRealmId(),
          "clientId", event.getClientId(),
          "userId", event.getUserId(),
          "ipAddress", event.getIpAddress(),
          "error", event.getError(),
          "time", event.getTime(),
          "details", event.getDetails()
      ));
      producer.send(new ProducerRecord<>(topic, event.getRealmId(), payload));
    } catch (Exception e) {
      // 절대 인증 흐름을 깨뜨리지 않는다 — 로깅 후 진행
      LoggerFactory.getLogger(getClass()).warn("event publish failed", e);
    }
  }

  @Override
  public void onEvent(AdminEvent adminEvent, boolean includeRepresentation) {
    try {
      String payload = mapper.writeValueAsString(Map.of(
          "category", "ADMIN_EVENT",
          "operation", adminEvent.getOperationType().name(),
          "resourceType", String.valueOf(adminEvent.getResourceTypeAsString()),
          "resourcePath", adminEvent.getResourcePath(),
          "realmId", adminEvent.getRealmId(),
          "authUserId", adminEvent.getAuthDetails().getUserId(),
          "time", adminEvent.getTime()
      ));
      producer.send(new ProducerRecord<>(topic, adminEvent.getRealmId(), payload));
    } catch (Exception e) {
      LoggerFactory.getLogger(getClass()).warn("admin event publish failed", e);
    }
  }

  @Override
  public void close() {
  }
}

Factory와 등록 파일도 필요합니다.

public class KafkaEventListenerProviderFactory implements EventListenerProviderFactory {

  private KafkaProducer<String, String> producer;
  private String topic;

  @Override
  public EventListenerProvider create(KeycloakSession session) {
    return new KafkaEventListenerProvider(producer, topic);
  }

  @Override
  public void init(Config.Scope config) {
    Properties props = new Properties();
    props.put("bootstrap.servers", config.get("bootstrapServers", "kafka:9092"));
    props.put("key.serializer", StringSerializer.class.getName());
    props.put("value.serializer", StringSerializer.class.getName());
    props.put("acks", "1");
    props.put("linger.ms", "20");
    this.producer = new KafkaProducer<>(props);
    this.topic = config.get("topic", "keycloak-events");
  }

  @Override
  public String getId() {
    return "kafka-event-listener";
  }

  @Override
  public void close() {
    if (producer != null) producer.close();
  }
}
META-INF/services/org.keycloak.events.EventListenerProviderFactory
파일 내용: com.example.keycloak.KafkaEventListenerProviderFactory

JAR를 providers 디렉토리에 넣고 빌드 후, realm의 event listener에 등록합니다.

cp keycloak-kafka-listener.jar /opt/keycloak/providers/
/opt/keycloak/bin/kc.sh build

./kcadm.sh update events/config -r myrealm \
  -s 'eventsListeners=["jboss-logging","kafka-event-listener"]'

구현 시 철칙이 하나 있습니다. listener의 실패가 인증을 실패시키면 안 됩니다. onEvent는 인증 트랜잭션 경로에서 호출되므로, 외부 전송은 비동기/버퍼링으로 하고 예외는 삼켜야 합니다. Kafka가 죽었다고 전사 로그인이 막히는 설계는 최악입니다.

Loki로 보내는 가벼운 대안

커스텀 JAR 없이 가볍게 시작하려면, jboss-logging listener가 남기는 이벤트 로그를 구조화 JSON으로 출력하고 Promtail/Alloy가 Loki로 수집하는 구성이 실용적입니다.

# JSON 로그 출력 + 이벤트 로그 레벨 조정
bin/kc.sh start \
  --log-console-output=json \
  --log-level=INFO,org.keycloak.events:DEBUG
# promtail 설정 발췌 — Keycloak 이벤트만 라벨링
scrape_configs:
  - job_name: keycloak
    static_configs:
      - targets: [localhost]
        labels:
          job: keycloak
          __path__: /var/log/keycloak/*.log
    pipeline_stages:
      - json:
          expressions:
            logger: loggerName
            message: message
      - match:
          selector: '{job="keycloak"} |= "org.keycloak.events"'
          stages:
            - labels:
                logger:

Kafka 경로는 SIEM/실시간 탐지 파이프라인에, Loki 경로는 운영 조회와 중기 보관에 적합합니다. 둘은 배타적이지 않으며 함께 쓰는 조직이 많습니다.

메트릭 엔드포인트 — Micrometer와 /metrics

Keycloak은 micrometer 기반 메트릭을 관리 포트(기본 9000)의 /metrics로 노출합니다.

bin/kc.sh start \
  --metrics-enabled=true \
  --event-metrics-user-enabled=true \
  --event-metrics-user-tags=realm,clientId,idp \
  --http-metrics-histograms-enabled=true
  • metrics-enabled: JVM, DB 커넥션 풀(Agroal), HTTP, Infinispan 캐시 메트릭을 노출합니다.
  • event-metrics-user-enabled: 26.x의 사용자 이벤트 메트릭입니다. 로그인 성공/실패 같은 이벤트가 카운터로 집계되어, DB 이벤트 테이블을 긁지 않고도 실시간 통계를 얻을 수 있습니다.
  • event-metrics-user-tags: 메트릭에 붙는 태그를 제어합니다. clientId, idp 태그는 카디널리티를 키우므로 client 수가 많은 환경에서는 신중히 선택하세요.

Prometheus 스크레이프 설정 예시입니다.

# prometheus.yml 발췌
scrape_configs:
  - job_name: keycloak
    metrics_path: /metrics
    static_configs:
      - targets: ['keycloak-0.mgmt:9000', 'keycloak-1.mgmt:9000']
    scheme: https
    tls_config:
      insecure_skip_verify: false

봐야 할 핵심 메트릭

영역메트릭 (예시)의미와 경보 기준
로그인keycloak_user_events_total (event 태그 login, login_error)실패율 급증 = 공격 또는 장애
토큰keycloak_user_events_total (event 태그 refresh_token, code_to_token)발급률 급변 = 클라이언트 이상
HTTPhttp_server_requests_seconds (uri, status 태그)p99 지연, 5xx 비율
DB 풀agroal_available_count, agroal_blocking_time_average풀 고갈 = 전면 장애 전조
캐시vendor_statistics 계열 (Infinispan sessions, realms 캐시)히트율 하락, 클러스터 리밸런싱
JVMjvm_memory_used_bytes, jvm_gc_pause_secondsGC pause와 로그인 지연 상관

특히 **DB 커넥션 풀(agroal)**은 Keycloak 장애의 최전선 지표입니다. 풀 대기 시간이 늘기 시작하면 수 분 내에 로그인 타임아웃으로 번지는 경우가 많으므로, available_count 고갈과 blocking_time 상승에 경보를 걸어두세요.

Grafana 대시보드 구성

대시보드는 "보안 운영"과 "시스템 건강" 두 장으로 나누는 것을 권장합니다.

+----------------------------------------------------------------+
| Keycloak Security Operations                                   |
+------------------------+---------------------------------------+
| 로그인 성공/실패 (rate)  | 실패율 % (성공+실패 대비)                |
| realm/client별 스택     | 임계선 표시 (5%, 20%)                   |
+------------------------+---------------------------------------+
| LOGIN_ERROR 사유 분포   | IP별 실패 Top 10 (Loki 쿼리)            |
| invalid_user_credentials| user_not_found 비율 급증 = enumeration |
+------------------------+---------------------------------------+
| 신규 등록/비밀번호 변경  | admin 이벤트 타임라인                    |
+------------------------+---------------------------------------+

+----------------------------------------------------------------+
| Keycloak System Health                                         |
+------------------------+---------------------------------------+
| HTTP p50/p95/p99       | 5xx 비율                               |
+------------------------+---------------------------------------+
| Agroal 풀 사용률/대기   | Infinispan 캐시 히트율/엔트리 수          |
+------------------------+---------------------------------------+
| JVM 힙/GC pause        | 인스턴스별 CPU/스레드                    |
+----------------------------------------------------------------+

자주 쓰는 PromQL 몇 가지를 적어둡니다.

# 5분 윈도우 로그인 실패율 (%)
100 *
  sum(rate(keycloak_user_events_total{event="login_error"}[5m]))
/
  sum(rate(keycloak_user_events_total{event=~"login|login_error"}[5m]))

# 토큰 엔드포인트 p99 지연
histogram_quantile(0.99,
  sum by (le) (rate(http_server_requests_seconds_bucket{uri=~".*token.*"}[5m])))

# DB 풀 대기 평균 (ms)
avg(agroal_blocking_time_average_milliseconds)

OpenTelemetry Tracing

"로그인이 느린데 어디가 느린지 모르겠다"에 대한 답이 분산 추적입니다. Keycloak 26.x는 OpenTelemetry tracing을 내장 지원합니다.

bin/kc.sh start \
  --tracing-enabled=true \
  --tracing-endpoint=http://otel-collector:4317 \
  --tracing-protocol=grpc \
  --tracing-sampler-type=traceidratio \
  --tracing-sampler-ratio=0.05 \
  --tracing-service-name=keycloak-prod
  • 트레이스에는 HTTP 요청, DB 쿼리, LDAP 호출 등의 span이 포함되어, "로그인 2.5초 중 LDAP bind가 2.1초"를 한눈에 볼 수 있습니다.
  • 프로덕션에서는 샘플링 비율을 낮게(1~5%) 시작하세요. parentbased 샘플러를 쓰면 게이트웨이부터 이어지는 trace context를 존중합니다.
  • Collector에서 tail-based sampling으로 "느린 요청과 에러만 전량 보존"하는 구성이 인증 시스템과 특히 궁합이 좋습니다.
  • 로그에 trace_id가 함께 찍히도록 JSON 로그와 조합하면, Grafana에서 메트릭 → 트레이스 → 로그로 드릴다운하는 삼위일체가 완성됩니다.

이상 징후 탐지 — Brute Force와 Credential Stuffing

Keycloak 내장 Brute Force Detection

realm 단위로 켜는 내장 방어 기능입니다.

./kcadm.sh update realms/myrealm \
  -s bruteForceProtected=true \
  -s failureFactor=5 \
  -s waitIncrementSeconds=60 \
  -s maxFailureWaitSeconds=900 \
  -s maxDeltaTimeSeconds=43200 \
  -s permanentLockout=false

실패 횟수가 임계치를 넘으면 계정을 점증적으로 잠급니다. 단, 이것만으로는 부족합니다. 내장 기능은 계정 단위 방어이기 때문입니다.

Credential Stuffing은 패턴이 다르다

공격패턴내장 방어 효과추가 대응
Brute force한 계정에 다수 비밀번호효과적 (계정 잠금)내장 기능 + 알림
Credential stuffing다수 계정에 각 1~2회 시도거의 무력IP/ASN 단위 분석, WAF, 봇 탐지
Password spraying다수 계정에 같은 비밀번호거의 무력시도 비밀번호 패턴 분석, MFA

credential stuffing은 계정당 실패가 1~2회라 계정 잠금에 걸리지 않습니다. 탐지의 단서는 전역 패턴입니다.

  • LOGIN_ERROR 전체 비율의 급증 (특히 user_not_found, invalid_user_credentials의 동반 상승)
  • 단일 IP/대역에서 다수의 서로 다른 username 시도 (Loki에서 ipAddress 기준 집계)
  • 평소와 다른 지역/ASN, 비정상 User-Agent 분포
  • 새벽 시간대의 균일한 요청 간격 (봇의 시그니처)

이벤트를 Kafka로 흘리고 있다면 스트림 처리(Flink/ksqlDB)로 "IP별 고유 username 수가 N분에 M개 초과" 같은 룰을 실시간 평가할 수 있습니다. 근본 대응은 결국 MFA/passkeys 확대(26.6의 로그인 폼 passkeys 통합이 도입 장벽을 크게 낮췄습니다)와 유출 비밀번호 차단 정책입니다.

알림 룰 예제 (Prometheus Alertmanager)

groups:
  - name: keycloak-security
    rules:
      - alert: KeycloakLoginFailureRateHigh
        expr: |
          100 *
            sum(rate(keycloak_user_events_total{event="login_error"}[5m]))
          /
            sum(rate(keycloak_user_events_total{event=~"login|login_error"}[5m]))
          > 20
        for: 10m
        labels:
          severity: warning
          team: identity
        annotations:
          summary: "로그인 실패율 20% 초과 (10분 지속)"
          description: "공격 또는 인증 경로 장애 가능성. 보안 대시보드를 확인하세요."

      - alert: KeycloakLoginErrorSpike
        expr: |
          sum(rate(keycloak_user_events_total{event="login_error"}[5m]))
          > 4 * sum(rate(keycloak_user_events_total{event="login_error"}[5m] offset 1d))
        for: 15m
        labels:
          severity: critical
        annotations:
          summary: "로그인 실패가 전일 동시간 대비 4배 급증"

  - name: keycloak-health
    rules:
      - alert: KeycloakDbPoolExhausted
        expr: min(agroal_available_count) == 0
        for: 2m
        labels:
          severity: critical
        annotations:
          summary: "DB 커넥션 풀 고갈 — 로그인 전면 장애 임박"

      - alert: KeycloakTokenLatencyHigh
        expr: |
          histogram_quantile(0.99,
            sum by (le) (rate(http_server_requests_seconds_bucket{uri=~".*token.*"}[5m])))
          > 1
        for: 10m
        labels:
          severity: warning
        annotations:
          summary: "토큰 엔드포인트 p99가 1초 초과"

      - alert: KeycloakInstanceDown
        expr: up{job="keycloak"} == 0
        for: 1m
        labels:
          severity: critical
        annotations:
          summary: "Keycloak 인스턴스 다운"

알림 설계의 원칙은 "보안 알림과 가용성 알림의 수신자를 분리"하는 것입니다. 실패율 급증은 보안팀 채널로, 풀 고갈은 플랫폼 온콜로 가야 하며, 모든 알림에는 대시보드/런북 링크를 첨부합니다.

감사 컴플라이언스 — ISMS-P / SOC 2 관점

관측성 스택은 그대로 감사 대응 자산이 됩니다. 심사에서 반복적으로 요구되는 항목과 매핑하면 다음과 같습니다.

감사 요구 (ISMS-P / SOC 2 공통)Keycloak에서의 대응
인증 성공/실패 기록 보존login events + 외부 장기 보관 (SIEM/오브젝트 스토리지)
관리자 행위 추적admin events (details 포함) + 변경 전후 representation
로그의 위변조 방지외부 전송 후 불변 스토리지 (WORM, 버킷 잠금)
보존 기간 정책내부 단기 + 외부 1년 이상 (규제별 상이) 문서화
접근 권한 검토 (정기)Admin API로 role/group 멤버십 정기 추출 리포트
이상 접근 모니터링실패율/지역/IP 기반 알림 룰 + 대응 런북
시각 동기화NTP — 이벤트 타임스탬프 신뢰성의 전제

실무 팁 몇 가지를 덧붙입니다.

  • 감사인이 요구하는 것은 "로그가 있다"가 아니라 **"로그를 보고 행동한 증적"**입니다. 알림 발생 → 확인 → 조치 기록이 남는 워크플로(티켓 연동)를 만들어 두세요.
  • admin events의 representation에는 민감 정보가 포함될 수 있습니다. 외부 전송 파이프라인에서 마스킹 정책을 정의하고, 개인정보 보존 기간 규정과 로그 보존 기간이 충돌하지 않는지 법무와 확인이 필요합니다.
  • realm 설정/정책의 변경 자체를 IaC(Terraform, keycloak-config-cli)로 관리하면 "변경 통제" 요건의 상당 부분이 Git 이력으로 증빙됩니다. admin events는 콘솔을 통한 비정상 경로 변경을 잡아내는 보조 수단이 됩니다.

운영 베스트 프랙티스 요약

  • 이벤트는 켜되, DB 보존은 짧게 — 장기 보관은 SPI/로그 파이프라인으로 외부화.
  • listener 구현은 절대 인증 경로를 막지 않게 비동기 + 예외 격리.
  • metrics-enabled + event-metrics-user-enabled를 기본 기동 옵션으로.
  • 대시보드는 보안 운영과 시스템 건강을 분리하고, agroal 풀 지표에 경보 필수.
  • tracing은 낮은 샘플링으로 시작해 tail-based sampling으로 고도화.
  • 계정 단위 brute force 방어와 전역 패턴 기반 stuffing 탐지를 구분해 설계.
  • 알림에는 런북 링크, 보안/가용성 수신자 분리, 주기적 알림 피로도 리뷰.
  • 감사 대응은 "수집"이 아니라 "대응 증적"까지 — 워크플로와 불변 보관을 갖출 것.

마치며

Keycloak의 관측성은 "이벤트(무슨 일이 있었나), 메트릭(지금 어떤 상태인가), 트레이스(왜 느린가), 로그(상세 맥락)"라는 네 기둥이 서로를 보완하는 구조로 설계할 때 완성됩니다. 26.x에 들어 메트릭과 tracing이 내장된 덕분에 진입 장벽은 크게 낮아졌고, 남은 것은 조직의 운영 규율 — 보존 정책, 알림 설계, 대응 런북 — 입니다.

인증 시스템의 모니터링은 단순한 인프라 운영을 넘어 보안 탐지와 컴플라이언스의 교차점에 있습니다. 이 글의 구성 요소들을 하나씩 쌓아 올리면, "로그인 장애를 사용자보다 먼저 아는" 그리고 "공격을 공격자가 성공하기 전에 아는" 운영 체계에 도달할 수 있을 것입니다.

참고 자료