들어가며
Keycloak의 진짜 힘은 "기본 기능이 많다"가 아니라 "모든 것을 갈아끼울 수 있다"에 있습니다. 인증 단계, 이벤트 처리, 사용자 저장소, REST API까지 거의 모든 내부 동작이 SPI(Service Provider Interface)로 추상화되어 있어, 표준 기능으로 부족한 요구사항을 자바 코드로 메울 수 있습니다.
2026년 시점에서 SPI 개발의 수요는 오히려 늘고 있습니다. 사내 레거시 시스템과의 통합(레거시 DB의 사용자로 로그인), 국가별 규제 대응(SMS 본인인증), 감사 로그의 실시간 스트리밍(Kafka), AI 에이전트 아이덴티티 발급 자동화 같은 요구는 표준 설정만으로 해결되지 않기 때문입니다. 이 글에서는 다음을 다룹니다.
- SPI 아키텍처: Provider와 ProviderFactory의 관계
- 개발 환경 구성 (Java 17+, Maven)
- 커스텀 Authenticator 전체 구현 — 사내 SMS 게이트웨이 기반 OTP
- RequiredAction으로 전화번호 등록 강제하기
- EventListener로 감사 로그를 Kafka에 스트리밍
- RealmResourceProvider로 커스텀 REST 엔드포인트 만들기
- User Storage SPI로 레거시 DB 연동
- 배포, Testcontainers 통합 테스트, 버전 업그레이드 호환성 관리
SPI 아키텍처 — Provider와 ProviderFactory
Keycloak의 모든 확장점은 동일한 3계층 패턴을 따릅니다.
+------------------------------------------------------------+
| Spi (확장점 정의) |
| 예: AuthenticatorSpi, EventListenerSpi |
| |
| +------------------------------------------------------+ |
| | ProviderFactory (싱글턴, 서버 수명 동안 1개) | |
| | - init(Config.Scope): 설정 로드 | |
| | - create(KeycloakSession): Provider 인스턴스 생성 | |
| | - getId(): 고유 식별자 | |
| | | |
| | +------------------------------------------------+ | |
| | | Provider (요청/트랜잭션 단위로 생성) | | |
| | | - 실제 비즈니스 로직 | | |
| | | - close(): 요청 종료 시 정리 | | |
| | +------------------------------------------------+ | |
| +------------------------------------------------------+ |
+------------------------------------------------------------+
핵심 규칙은 다음과 같습니다.
- ProviderFactory는 서버 기동 시 한 번 생성되는 싱글턴입니다. 무거운 초기화(커넥션 풀, HTTP 클라이언트)는 여기서 합니다.
- Provider는 KeycloakSession 단위(대략 HTTP 요청 단위)로 생성됩니다. 상태를 갖지 않거나 세션 범위의 상태만 가져야 합니다.
- 구현체 등록은 Java의 ServiceLoader 규약을 따릅니다. META-INF/services 디렉토리에 인터페이스 전체 이름의 파일을 만들고 구현 클래스를 적습니다.
개발 환경 구성
Keycloak 26.x는 Java 17 이상을 요구하며 21에서도 동작합니다. Maven 의존성은 provided 스코프로 잡습니다. 서버가 이미 들고 있는 클래스를 JAR에 중복 포함하지 않기 위해서입니다.
<!-- Kafka EventListener용 (JAR에 포함되어야 하므로 compile 스코프) -->
spi-private에 있는 인터페이스는 마이너 버전에서도 바뀔 수 있다는 점을 기억해 두시기 바랍니다. 뒤의 호환성 관리 절에서 다시 다룹니다.
커스텀 Authenticator — 사내 SMS 게이트웨이 OTP
가장 수요가 많은 확장입니다. 비밀번호 인증 후 사내 SMS 게이트웨이로 6자리 코드를 보내고 검증하는 Authenticator를 만들어 보겠습니다.
1. Authenticator 구현
package com.example.iam.sms;
public class SmsOtpAuthenticator implements Authenticator {
private static final String OTP_NOTE = "sms-otp-code";
private static final String PHONE_ATTR = "phoneNumber";
@Override
public void authenticate(AuthenticationFlowContext context) {
UserModel user = context.getUser();
String phone = user.getFirstAttribute(PHONE_ATTR);
if (phone == null || phone.isBlank()) {
// 전화번호 미등록 -> RequiredAction으로 유도
user.addRequiredAction("register-phone-number");
context.attempted();
return;
}
String code = generateCode();
// auth note는 이번 인증 세션에서만 유효한 임시 저장소
context.getAuthenticationSession().setAuthNote(OTP_NOTE, code);
SmsGateway gateway = SmsGateway.fromConfig(
context.getAuthenticatorConfig());
gateway.send(phone, "인증 코드: " + code + " (5분 내 입력)");
context.challenge(
context.form()
.setAttribute("phoneHint", maskPhone(phone))
.createForm("sms-otp.ftl"));
}
@Override
public void action(AuthenticationFlowContext context) {
String expected = context.getAuthenticationSession()
.getAuthNote(OTP_NOTE);
String submitted = context.getHttpRequest()
.getDecodedFormParameters()
.getFirst("otp");
if (expected != null && expected.equals(submitted)) {
context.success();
return;
}
context.failureChallenge(
AuthenticationFlowError.INVALID_CREDENTIALS,
context.form()
.setError("invalidOtpCode")
.createForm("sms-otp.ftl"));
}
@Override
public boolean requiresUser() {
return true; // 비밀번호 인증 뒤에 와야 함
}
@Override
public boolean configuredFor(KeycloakSession session,
RealmModel realm, UserModel user) {
return user.getFirstAttribute(PHONE_ATTR) != null;
}
@Override
public void setRequiredActions(KeycloakSession session,
RealmModel realm, UserModel user) {
user.addRequiredAction("register-phone-number");
}
@Override
public void close() { }
private String generateCode() {
return String.format("%06d", new SecureRandom().nextInt(1_000_000));
}
private String maskPhone(String phone) {
int len = phone.length();
return phone.substring(0, 3) + "****" + phone.substring(len - 2);
}
}
2. AuthenticatorFactory 구현
package com.example.iam.sms;
public class SmsOtpAuthenticatorFactory implements AuthenticatorFactory {
public static final String PROVIDER_ID = "sms-otp-authenticator";
private static final SmsOtpAuthenticator SINGLETON =
new SmsOtpAuthenticator();
@Override
public String getId() { return PROVIDER_ID; }
@Override
public String getDisplayType() { return "SMS OTP (internal gateway)"; }
@Override
public Authenticator create(KeycloakSession session) {
return SINGLETON; // 상태가 없으므로 싱글턴 재사용 가능
}
@Override
public AuthenticationExecutionModel.Requirement[] getRequirementChoices() {
return new AuthenticationExecutionModel.Requirement[] {
AuthenticationExecutionModel.Requirement.REQUIRED,
AuthenticationExecutionModel.Requirement.ALTERNATIVE,
AuthenticationExecutionModel.Requirement.CONDITIONAL,
AuthenticationExecutionModel.Requirement.DISABLED
};
}
@Override
public boolean isConfigurable() { return true; }
@Override
public List<ProviderConfigProperty> getConfigProperties() {
ProviderConfigProperty url = new ProviderConfigProperty(
"gateway.url", "SMS Gateway URL",
"사내 SMS 게이트웨이 엔드포인트",
ProviderConfigProperty.STRING_TYPE,
"https://sms.internal.example.com/v1/send");
return List.of(url);
}
@Override
public boolean isUserSetupAllowed() { return true; }
@Override
public String getHelpText() {
return "Sends a 6-digit OTP via the internal SMS gateway.";
}
@Override
public void init(Config.Scope config) { }
@Override
public void postInit(KeycloakSessionFactory factory) { }
@Override
public void close() { }
}
3. ServiceLoader 등록
파일 경로:
src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory
com.example.iam.sms.SmsOtpAuthenticatorFactory
이후 관리 콘솔에서 browser flow를 복제해 Username Password Form 뒤에 SMS OTP execution을 추가하면 적용됩니다. 인증 플로우 엔진의 동작 원리는 아키텍처 편에서 다뤘으므로 생략합니다.
RequiredAction — 전화번호 등록 강제
위 Authenticator는 전화번호가 없으면 register-phone-number라는 RequiredAction을 추가합니다. RequiredAction은 "로그인은 성공했지만 계속하기 전에 사용자가 반드시 해야 하는 일"입니다.
package com.example.iam.sms;
public class RegisterPhoneRequiredAction implements RequiredActionProvider {
public static final String PROVIDER_ID = "register-phone-number";
@Override
public void evaluateTriggers(RequiredActionContext context) {
// 매 로그인 시 자동 트리거 조건 평가 (여기서는 미사용)
}
@Override
public void requiredActionChallenge(RequiredActionContext context) {
context.challenge(
context.form().createForm("register-phone.ftl"));
}
@Override
public void processAction(RequiredActionContext context) {
String phone = context.getHttpRequest()
.getDecodedFormParameters()
.getFirst("phoneNumber");
if (phone == null || !phone.matches("^\\+?[0-9]{10,15}$")) {
context.challenge(
context.form().setError("invalidPhone")
.createForm("register-phone.ftl"));
return;
}
context.getUser().setSingleAttribute("phoneNumber", phone);
context.success();
}
@Override
public void close() { }
}
Factory와 ServiceLoader 등록(org.keycloak.authentication.RequiredActionFactory)은 Authenticator와 동일한 패턴이므로 생략합니다.
EventListener — 감사 로그를 Kafka로
보안 감사 요구사항에서 가장 흔한 것이 "모든 로그인/관리 이벤트를 실시간으로 SIEM에 보내라"입니다. EventListener SPI로 구현합니다.
package com.example.iam.audit;
public class KafkaEventListener implements EventListenerProvider {
private final KafkaProducer<String, String> producer;
private final String topic;
public KafkaEventListener(KafkaProducer<String, String> producer,
String topic) {
this.producer = producer;
this.topic = topic;
}
@Override
public void onEvent(Event event) {
// LOGIN, LOGIN_ERROR, LOGOUT, TOKEN_REFRESH ...
send(event.getRealmId(), toJson(event));
}
@Override
public void onEvent(AdminEvent adminEvent, boolean includeRepresentation) {
// 관리 콘솔/Admin API 변경 이력
send(adminEvent.getRealmId(), toJson(adminEvent));
}
private void send(String key, String value) {
// 비동기 전송: 인증 경로를 블로킹하지 않는 것이 중요
producer.send(new ProducerRecord<>(topic, key, value), (md, ex) -> {
if (ex != null) {
// Kafka 장애가 로그인 장애가 되어선 안 됨: 로그만 남김
org.jboss.logging.Logger
.getLogger(KafkaEventListener.class)
.errorf(ex, "audit event publish failed: key=%s", key);
}
});
}
private String toJson(Object o) {
try {
return JsonSerialization.writeValueAsString(o);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
@Override
public void close() {
// 요청 단위 close: producer는 Factory가 소유하므로 여기선 닫지 않음
}
}
Factory에서 KafkaProducer를 싱글턴으로 관리하는 것이 포인트입니다.
package com.example.iam.audit;
public class KafkaEventListenerFactory
implements EventListenerProviderFactory {
private KafkaProducer<String, String> producer;
private String topic;
@Override
public String getId() { return "kafka-audit"; }
@Override
public void init(Config.Scope config) {
// spi-events-listener-kafka-audit-* 옵션에서 로드
String bootstrap = config.get("bootstrapServers",
"kafka.internal:9092");
topic = config.get("topic", "keycloak-audit");
Properties props = new Properties();
props.put("bootstrap.servers", bootstrap);
props.put("key.serializer",
"org.apache.kafka.common.serialization.StringSerializer");
props.put("value.serializer",
"org.apache.kafka.common.serialization.StringSerializer");
props.put("acks", "1");
props.put("linger.ms", "20");
producer = new KafkaProducer<>(props);
}
@Override
public EventListenerProvider create(KeycloakSession session) {
return new KafkaEventListener(producer, topic);
}
@Override
public void postInit(KeycloakSessionFactory factory) { }
@Override
public void close() {
if (producer != null) producer.close();
}
}
설정 값은 kc.sh 옵션으로 주입합니다.
bin/kc.sh start --optimized \
--spi-events-listener-kafka-audit-bootstrap-servers=kafka.internal:9092 \
--spi-events-listener-kafka-audit-topic=keycloak-audit
활성화는 관리 콘솔의 Realm settings에서 Events 탭의 Event listeners에 kafka-audit를 추가하면 됩니다.
커스텀 REST 엔드포인트 — RealmResourceProvider
Admin API에 없는 기능(예: 사용자 일괄 조회 최적화, 사내 포맷 헬스 리포트)을 realm 하위 경로의 REST API로 노출할 수 있습니다.
package com.example.iam.rest;
public class StatsResourceProvider implements RealmResourceProvider {
private final KeycloakSession session;
public StatsResourceProvider(KeycloakSession session) {
this.session = session;
}
@Override
public Object getResource() { return this; }
@GET
@Path("summary")
@Produces(MediaType.APPLICATION_JSON)
public Map<String, Object> summary() {
// 토큰 검증: Bearer 토큰 없으면 401
AuthenticationManager.AuthResult auth =
new AppAuthManager.BearerTokenAuthenticator(session)
.authenticate();
if (auth == null) {
throw new jakarta.ws.rs.NotAuthorizedException("Bearer");
}
long userCount = session.users()
.getUsersCount(session.getContext().getRealm());
return Map.of(
"realm", session.getContext().getRealm().getName(),
"users", userCount
);
}
@Override
public void close() { }
}
Factory의 getId가 stats라면 이 엔드포인트의 최종 URL은 다음과 같습니다.
GET /realms/production/stats/summary
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...
커스텀 REST는 강력한 만큼 위험합니다. 반드시 토큰 검증과 role 검사를 직접 구현해야 하며, 빠뜨리면 인증 없는 공개 API가 됩니다.
User Storage SPI — 레거시 DB 연동
기존 사용자 DB(예: 오래된 회원 테이블)를 마이그레이션 없이 Keycloak의 사용자 소스로 쓰는 확장점입니다. 핵심 인터페이스는 UserStorageProvider이고, 기능별 capability 인터페이스를 조합합니다.
package com.example.iam.legacy;
public class LegacyDbStorageProvider implements UserStorageProvider,
UserLookupProvider, CredentialInputValidator {
private final KeycloakSession session;
private final ComponentModel model;
private final LegacyUserDao dao; // JDBC 접근 계층
public LegacyDbStorageProvider(KeycloakSession session,
ComponentModel model,
LegacyUserDao dao) {
this.session = session;
this.model = model;
this.dao = dao;
}
@Override
public UserModel getUserByUsername(RealmModel realm, String username) {
LegacyUser u = dao.findByUsername(username);
return u == null ? null
: new LegacyUserAdapter(session, realm, model, u);
}
@Override
public UserModel getUserById(RealmModel realm, String id) {
String externalId = StorageId.externalId(id);
LegacyUser u = dao.findById(externalId);
return u == null ? null
: new LegacyUserAdapter(session, realm, model, u);
}
@Override
public UserModel getUserByEmail(RealmModel realm, String email) {
LegacyUser u = dao.findByEmail(email);
return u == null ? null
: new LegacyUserAdapter(session, realm, model, u);
}
@Override
public boolean supportsCredentialType(String type) {
return PasswordCredentialModel.TYPE.equals(type);
}
@Override
public boolean isConfiguredFor(RealmModel realm, UserModel user,
String type) {
return supportsCredentialType(type);
}
@Override
public boolean isValid(RealmModel realm, UserModel user,
CredentialInput input) {
if (!supportsCredentialType(input.getType())) return false;
// 레거시 해시(bcrypt 등) 검증 위임
return dao.verifyPassword(
StorageId.externalId(user.getId()),
input.getChallengeResponse());
}
@Override
public void close() { }
}
실무 팁 세 가지입니다.
- import 모드(Keycloak DB로 사용자 복사) vs 비-import 모드(항상 외부 조회)를 초기에 결정해야 합니다. 장기적으로는 import 후 레거시를 폐기하는 마이그레이션 경로를 권장합니다.
- 레거시 비밀번호 해시가 검증되는 첫 로그인 시점에 Keycloak 표준 해시로 재해시해 저장하면(credential 갱신), 점진적 마이그레이션이 됩니다.
- 외부 DB 장애가 로그인 전체 장애로 번지지 않도록 타임아웃과 서킷 브레이커를 DAO 계층에 넣어야 합니다.
테마 커스터마이징과의 구분
로그인 화면의 모양을 바꾸고 싶을 뿐이라면 SPI가 아니라 테마를 써야 합니다.
| 요구사항 | 수단 |
| --- | --- |
| 로고/색상/CSS 변경 | 테마 (FreeMarker 템플릿 + CSS) |
| 로그인 폼에 필드 추가 + 검증 로직 | Authenticator SPI + 테마 템플릿 |
| 다국어 메시지 추가 | 테마의 messages 프로퍼티 |
| 인증 단계 자체 추가/변경 | Authenticator SPI |
| 회원가입 항목 커스텀 | User Profile 설정 (코드 불필요한 경우 多) |
위 SMS OTP 예제의 sms-otp.ftl처럼, SPI와 테마는 함께 쓰일 때가 많습니다. 템플릿은 테마 디렉토리의 login 하위에 둡니다.
배포 — providers 디렉토리와 빌드
확장 JAR는 providers 디렉토리에 넣고 build를 다시 실행합니다.
FROM quay.io/keycloak/keycloak:26.6 AS builder
COPY target/keycloak-extensions-1.0.0.jar /opt/keycloak/providers/
kafka-clients 같은 외부 의존성도 함께 (shaded JAR를 쓰면 불필요)
COPY target/dependency/kafka-clients-3.9.0.jar /opt/keycloak/providers/
ENV KC_DB=postgres
ENV KC_HEALTH_ENABLED=true
RUN /opt/keycloak/bin/kc.sh build
FROM quay.io/keycloak/keycloak:26.6
COPY --from=builder /opt/keycloak/ /opt/keycloak/
ENTRYPOINT ["/opt/keycloak/bin/kc.sh", "start", "--optimized"]
확인은 기동 로그 또는 관리 콘솔의 Provider info에서 합니다.
등록된 provider 확인
bin/kc.sh show-config
kubectl logs keycloak-0 | grep -i "sms-otp"
Testcontainers 통합 테스트
SPI는 단위 테스트만으로 부족합니다. 실제 Keycloak 컨테이너에 JAR를 올려 검증하는 통합 테스트가 필수입니다. 커뮤니티 표준인 dasniko의 testcontainers-keycloak을 사용합니다.
package com.example.iam;
@Testcontainers
class SmsOtpAuthenticatorIT {
@Container
static KeycloakContainer keycloak =
new KeycloakContainer("quay.io/keycloak/keycloak:26.6")
.withProviderClassesFrom("target/classes")
.withRealmImportFile("test-realm.json");
@Test
void authenticatorFactoryIsRegistered() {
Keycloak admin = keycloak.getKeycloakAdminClient();
var info = admin.serverInfo().getInfo();
var authenticators = info.getProviders()
.get("authenticator").getProviders();
assertThat(authenticators)
.containsKey("sms-otp-authenticator");
}
@Test
void loginWithoutPhoneTriggersRequiredAction() {
// test-realm.json에 sms-otp가 포함된 flow와
// 전화번호 없는 사용자를 미리 정의해 두고,
// OAuth 코드 플로우를 RestAssured로 시뮬레이션
// (지면상 생략 — 저장소의 전체 예제 참고)
}
}
CI에서 Keycloak 버전을 매트릭스로 돌리면(26.5, 26.6 등) 업그레이드 호환성 회귀를 조기에 잡을 수 있습니다.
버전 업그레이드 호환성 관리
SPI 개발의 최대 리스크는 Keycloak 업그레이드 시 내부 API 변경입니다. 실전 수칙을 정리합니다.
1. 공개 SPI(keycloak-server-spi)에 의존하고, private SPI 사용은 최소화합니다. private은 마이너 버전에서도 깨질 수 있습니다.
2. pom의 keycloak.version을 서버 버전과 잠그고, 서버 업그레이드와 확장 빌드를 한 파이프라인에서 처리합니다.
3. 릴리스 노트의 deprecation 섹션을 업그레이드 전에 반드시 읽습니다. 예를 들어 26에서 레거시 콘솔 테마 제거, jakarta 네임스페이스 전환(22) 같은 변화가 확장 코드를 깨뜨려 왔습니다.
4. Testcontainers 통합 테스트를 차기 버전 이미지로 미리 돌려보는 카나리 잡을 둡니다.
5. 확장 JAR에 너무 많은 책임을 담지 않습니다. Kafka 전송 같은 로직은 가능하면 얇게 유지하고, 가공/보강은 다운스트림에서 합니다.
업그레이드 절차 (권장)
1. 차기 버전 릴리스 노트 / deprecation 확인
2. keycloak.version 올려서 컴파일 -> API 깨짐 확인
3. Testcontainers IT를 신규 이미지로 실행
4. 스테이징에 신규 이미지 + 확장 JAR 배포
5. 26.6+ 패치 버전이면 rolling, 아니면 recreate로 프로덕션 적용
마치며
SPI는 Keycloak을 "제품"에서 "플랫폼"으로 바꿔 주는 장치입니다. Provider/ProviderFactory 패턴 하나만 손에 익히면 Authenticator, EventListener, REST 리소스, User Storage까지 동일한 구조로 확장할 수 있습니다. 다만 확장 코드는 인증 경로의 한가운데에서 실행되므로, 외부 시스템 장애 격리(타임아웃, 비동기화)와 버전 호환성 관리가 코드 작성만큼 중요합니다. 이 글의 예제를 토대로, 반드시 통합 테스트와 함께 운영 환경에 들어가시기 바랍니다.
참고 자료
- [Keycloak Server Development 가이드](https://www.keycloak.org/docs/latest/server_development/index.html)
- [Keycloak 공식 문서](https://www.keycloak.org/documentation)
- [Keycloak 릴리스 노트](https://www.keycloak.org/docs/latest/release_notes/index.html)
- [Keycloak 26.6.0 릴리스 공지](https://www.keycloak.org/2026/04/keycloak-2660-released)
- [Keycloak 서버 설정 가이드 (providers)](https://www.keycloak.org/server/configuration-provider)
- [testcontainers-keycloak (GitHub)](https://github.com/dasniko/testcontainers-keycloak)
- [Testcontainers 공식 문서](https://java.testcontainers.org/)
- [Apache Kafka 공식 문서](https://kafka.apache.org/documentation/)
- [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html)
- [OAuth 2.0 (RFC 6749)](https://datatracker.ietf.org/doc/html/rfc6749)
- [JWT (RFC 7519)](https://datatracker.ietf.org/doc/html/rfc7519)
- [Keycloak GitHub 저장소](https://github.com/keycloak/keycloak)
현재 단락 (1/430)
Keycloak의 진짜 힘은 "기본 기능이 많다"가 아니라 "모든 것을 갈아끼울 수 있다"에 있습니다. 인증 단계, 이벤트 처리, 사용자 저장소, REST API까지 거의 모든 내...