본문 바로가기
인증 · 보안

Keycloak SPI 정석 가이드 - 빌드·배포까지

코딩패트릭 2026. 7. 19.

앞의 두 글(소셜 로그인 원리, Keycloak SSO 구축기)에서 예고한 SPI 이야기다.

Keycloak SPI는 한마디로, Keycloak이 제공하는 인터페이스를 오버라이드(override)해서 나만의 커스텀 기능을 만들거나 기존 기능을 확장하는 것이다. 로그인 검증, 토큰에 담기는 정보, 사용자 조회 방식 같은 걸 Keycloak 기본 동작 대신 내가 만든 코드로 바꾸는 방식이라고 보면 된다.

이 글은 설명을 길게 늘어놓기보다, 개념 → 구조 → SPI별 코드와 콘솔 설정 → 빌드·배포 → 주의사항 순서로 실제 만들 때 필요한 것만 정리한다. 공식 문서는 Keycloak Server Development를 같이 보면 좋다.

코드·경로·이름은 모두 예시(com.example, custom-*)로 바꿔 뒀다. 실제 프로젝트에서는 본인 패키지·네이밍으로 맞추면 된다.

먼저 알아야 할 3가지 — Realm, Client, User

SPI를 만지기 전에 Keycloak의 뼈대 개념 셋을 알아야 한다. 이 셋을 모르면 콘솔 설정에서 길을 잃는다.

개념 한 줄 정의 비유
Realm(렐름) 독립된 인증 세계 하나 한 번 로그인으로 함께 오갈 수 있는 사이트들의 울타리
Client(클라이언트) 그 울타리에 로그인을 맡긴 각 서비스 울타리 안에 들어와 있는 개별 사이트/앱
User(유저) 그 울타리에 소속된 계정 울타리 안에서 로그인하는 실제 사람
  • Realm — 사용자, 인증 정책, 세션을 함께 관리하는 독립된 인증 영역이다. 예를 들어 service-a, service-b, service-c를 한 렐름에 묶어 두면, 이 서비스들끼리 SSO(한 번 로그인으로 함께 이용)를 구성할 수 있다. 렐름이 다르면 사용자도 설정도 완전히 남남이다. 다만 한 가지 주의할 점이 있다. 렐름이 같다고 무조건 SSO가 되는 건 아니다. 실제 SSO가 성립하려면 각 Client 설정, 쿠키 도메인, OIDC redirect 주소 같은 조건이 함께 맞아야 한다. 렐름은 "SSO를 구성할 수 있는 울타리"이지, 그 자체가 SSO를 보장하는 스위치는 아니다.
  • Client — 렐름 안에 등록하는 개별 서비스다. OIDC의 client_id가 바로 이 클라이언트를 가리킨다. 서비스가 셋이면 클라이언트도 셋 등록한다.
  • User — 렐름에 소속된 계정이다. 렐름 단위로 관리되며, 로그인하는 실제 사람이다. (뒤에서 다룰 UserStorage SPI를 쓰면, Keycloak 내장 테이블 대신 외부 DB의 사용자를 이 자리에 끌어올 수 있다.)

셋의 관계와 "각 SPI가 어디에 꽂히는지"를 한 그림으로 보면 이렇다.

Realm  (인증 영역 하나)
├── Client: service-a         ← 로그인을 렐름에 맡기는 각 서비스
├── Client: service-b
├── Client: service-c
├── Users                     ← 렐름이 다루는 사용자
│      ▲
│      │ (외부 사용자를 연결)   (▶ UserStorage SPI)
│   External DB               ← 사용자 원본은 렐름 밖에 둘 수 있다
├── Authentication Flow       ← 로그인 절차            (▶ Authenticator SPI)
├── Client Scope / Mapper     ← 토큰에 담을 정보        (▶ ProtocolMapper SPI)
└── Login Theme               ← 로그인 화면            (▶ Theme)

정리하면, 한 번 로그인(SSO)을 구성하는 단위가 렐름이고, 그 안의 각 서비스가 클라이언트, 로그인하는 사람이 유저다. 그리고 이 렐름의 로그인 절차·토큰·화면·사용자 소스를 우리 입맛대로 바꾸는 도구가 SPI다. UserStorage만은 사용자 원본을 렐름 밖(외부 DB)에 두고 연결하는 방식이라, 그림에서도 렐름 밖에서 사용자를 끌어오는 형태로 그렸다.

처음 SPI를 접하면 "내가 하려는 게 어느 SPI인지"부터 헷갈린다. 목적별로 이 표만 봐도 감이 잡힌다.

하고 싶은 일 써야 할 SPI
로그인 과정(검증·에러 처리) 바꾸기 Authenticator
JWT(토큰)에 정보 넣기 ProtocolMapper
외부 DB 사용자를 그대로 쓰기 UserStorageProvider
로그인 화면 바꾸기 Theme

Keycloak SPI란? — 시작 전 경고 (Quarkus)

SPI(Service Provider Interface)는 Keycloak이 미리 뚫어 둔 확장 지점이다. 정해진 인터페이스를 구현해 jar로 넣으면, Keycloak이 그 자리에서 우리 코드를 불러 쓴다. 로그인 검증, 토큰 클레임, 사용자 조회 같은 걸 기본 동작 대신 우리 것으로 갈아 끼우는 방식이다.

시작 전에 딱 하나만 기억하면 된다. Keycloak core는 Quarkus(CDI) 기반이다.

🚨 그래서 SPI에서는 Spring Framework(spring-context, spring-boot, spring-data-*)를 함께 쓰는 것을 권장하지 않는다. Spring Context를 함께 올리면 ClassLoader·CDI·Bean 충돌로 기동 실패나 런타임 오류가 나기 쉽다. (드물게 동작하는 경우도 있지만 실무에서는 순수 Java와 Keycloak API만 쓰는 게 일반적이다.)

이 원칙(의존성 충돌 금지)은 SPI 개발 내내 따라다닌다. 뒤의 "주의사항"에서 다시 정리한다.

전체 구조 한눈에 — 모듈 구성

SPI별로 모듈을 나눴다. 각 SPI를 담는 모듈과 화면 테마로 구성된다.

keycloak-extension/
├── authenticator/      # 로그인 인증 흐름 제어 (Authenticator SPI)
├── protocol-mapper/    # JWT 클레임 커스터마이즈 (ProtocolMapper SPI)
├── user-storage/       # 외부 DB 사용자 조회 (UserStorageProvider SPI)
└── themes/custom/      # 로그인 UI (Theme)

실제 프로젝트에서는 이 위에 공통 유틸이나 DB 접근용 내부 모듈을 각자 편한 대로 두게 되는데, 그건 프로젝트 취향의 영역이고 SPI 자체와는 직접 관련이 없어 여기서는 다루지 않는다.

이 글의 4개 SPI를 실제로 빌드·실행되는 예제로 정리해 뒀다 →
scientia36/keycloak-sample-26
(외부 사용자 DB는 설치 없이 돌려볼 수 있도록 H2 인메모리로 대체).

git clone https://github.com/scientia36/keycloak-sample-26.git
cd keycloak-sample-26
./gradlew clean build   # (선택) 로컬 컴파일·테스트 검증
cd docker && docker compose up --build   # http://localhost:8080 (admin/admin)

docker compose up --build 는 컨테이너 안에서 provider jar 를 다시 빌드하므로, 위 로컬 빌드는 검증용일 뿐이다.

시드 계정: alice(정상) / bob(비밀번호 초기화 필요 데모), 둘 다 비번 password123.

모듈 역할 Provider ID SPI
authenticator 로그인 인증 흐름 제어(비밀번호 검증·에러 처리) custom-db-authenticator AuthenticatorFactory
protocol-mapper 토큰에 authorities·user_id 등 커스텀 클레임 추가 custom-jwt-mapper ProtocolMapper
user-storage 외부 DB에서 사용자 조회(내장 user 테이블 미사용) custom-user-storage UserStorageProviderFactory
themes/custom 로그인·에러 페이지 커스텀 UI custom Theme(parent=base)

⚠️ 입문자가 가장 많이 하는 실수: jar를 복사하면 Provider 목록에는 보이는데 "왜 안 동작하지?" 하고 헤맨다. 기억할 한 줄은 이것이다. Provider가 등록되었다 ≠ 활성화되었다. 목록에 뜨는 건 등록됐다는 뜻일 뿐이고, 아래 각 SPI의 "콘솔 설정"까지 해야 실제로 켜진다.

SPI 종류별 구현과 콘솔 설정

여기가 본론이다. SPI 종류별로 (1) 역할 (2) 코드 뼈대 (3) 등록 파일 (4) 콘솔 설정 순서로 본다.

① Authenticator — 로그인 흐름 제어

역할: 로그인 요청 때 호출되는 커스텀 인증기. 외부 DB로 사용자·비밀번호를 검증하고, "비밀번호 초기화 필요·계정 잠금" 같은 에러를 처리한다.

Authenticator(실제 동작)와 AuthenticatorFactory(생성·등록) 둘을 구현한다.

public class CustomAuthenticatorFactory implements AuthenticatorFactory {
    public static final String PROVIDER_ID = "custom-db-authenticator";
    private static final Authenticator INSTANCE = new CustomAuthenticator();

    @Override public String getId() { return PROVIDER_ID; }
    @Override public Authenticator create(KeycloakSession session) { return INSTANCE; }
    @Override public String getDisplayType() { return "Custom DB Authenticator"; }
    @Override public boolean isConfigurable() { return false; }
    @Override public Requirement[] getRequirementChoices() {
        return new Requirement[]{ Requirement.REQUIRED, Requirement.DISABLED };
    }
    // getHelpText, getConfigProperties, init, postInit, close ...
}

public class CustomAuthenticator implements Authenticator {
    @Override public void authenticate(AuthenticationFlowContext ctx) {
        // 1) 폼에서 username/password 추출
        // 2) 외부 DB로 사용자·비밀번호 검증
        // 3) 성공 → ctx.success();  실패 → ctx.failureChallenge(...) 로 에러 화면
    }
    @Override public void action(AuthenticationFlowContext ctx) { }
    @Override public boolean requiresUser() { return false; }
    @Override public void close() { }
}

등록META-INF/services/org.keycloak.authentication.AuthenticatorFactory

com.example.keycloak.authenticator.CustomAuthenticatorFactory

콘솔 설정 (Admin Console):

  1. Authentication → Flows → 기본 browser 복제 → custom browser
  2. 복제본에서 Username Password Form의 Requirement를 DISABLED
  3. Add step → Custom DB Authenticator 추가 → Requirement = REQUIRED
  4. Authentication → Bindings → Browser Flowcustom browser로 변경

📄 전체 코드: CustomAuthenticator.java · CustomAuthenticatorFactory.java

② ProtocolMapper — JWT 커스텀 클레임

역할: 로그인 자체는 Authenticator가 담당하지만, 로그인 이후 서비스가 쓸 사용자 정보를 토큰에 넣는 건 ProtocolMapper의 몫이다. 발급되는 Access Token / ID Token / UserInfo 응답에 authorities, user_id 같은 커스텀 클레임을 주입한다.

AbstractOIDCProtocolMapper를 상속하고 토큰 종류별 인터페이스를 함께 구현한다.

public class CustomProtocolMapper extends AbstractOIDCProtocolMapper
        implements OIDCAccessTokenMapper, OIDCIDTokenMapper, UserInfoTokenMapper {

    public static final String PROVIDER_ID = "custom-jwt-mapper";

    @Override public String getId() { return PROVIDER_ID; }
    @Override public String getDisplayType() { return "Custom JWT Mapper"; }
    @Override public String getDisplayCategory() { return TOKEN_MAPPER_CATEGORY; }

    @Override
    protected void setClaim(IDToken token, ProtocolMapperModel model,
                            UserSessionModel userSession, KeycloakSession session,
                            ClientSessionContext ctx) {
        token.getOtherClaims().put("authorities", /* 조회한 권한 목록 */ null);
        token.getOtherClaims().put("user_id",   /* 사용자 고유 id */ null);
    }
}

등록META-INF/services/org.keycloak.protocol.ProtocolMapper

com.example.keycloak.mapper.CustomProtocolMapper

⚠️ ProtocolMapper는 예외다. ProtocolMapper는 Factory를 거치지 않고 매퍼 구현체 자체를 Provider로 등록하는 구조라, 다른 SPI처럼 Factory가 아니라 매퍼 클래스 자체를 등록한다.

콘솔 설정 (Client 또는 Client Scope 단위):

  1. Clients → 대상 Client → Client scopes → <client>-dedicated 진입
  2. Mappers → Add mapper → By configurationCustom JWT Mapper 선택 후 저장
  3. 로그인해서 받은 토큰을 디코딩해 authorities, user_id가 들어갔는지 확인

📄 전체 코드: CustomProtocolMapper.java · services 등록 파일

③ UserStorageProvider — 외부 DB 사용자 조회

역할: Keycloak 내장 사용자 테이블 대신 외부 DB(예: MariaDB)의 사용자 테이블을 연결한다. 기존 회원 DB를 그대로 두고 Keycloak에 붙일 때 쓴다.

UserStorageProvider는 구현하는 인터페이스에 따라 조회·생성·수정·삭제·Credential(비밀번호) 관리까지 할 수 있다. SPI 자체가 조회 전용인 게 아니다. 이 예제에서는 기존 DB를 읽기만 하는 조회 전용(UserLookupProvider)으로 구현했다.

public class CustomUserStorageProviderFactory
        implements UserStorageProviderFactory<CustomUserStorageProvider> {

    public static final String PROVIDER_ID = "custom-user-storage";

    @Override public String getId() { return PROVIDER_ID; }
    @Override public CustomUserStorageProvider create(KeycloakSession session, ComponentModel model) {
        return new CustomUserStorageProvider(session, model);
    }
}

public class CustomUserStorageProvider
        implements UserStorageProvider, UserLookupProvider {
    // getUserByUsername / getUserByEmail / getUserById
    //  → 외부 DB 조회 후 UserModel 어댑터로 변환해 반환
}

등록META-INF/services/org.keycloak.storage.UserStorageProviderFactory

com.example.keycloak.storage.CustomUserStorageProviderFactory

콘솔 설정 (Realm 단위):

  1. User federation → Add provider → custom-user-storage 선택
  2. 설정 저장 → Users 탭에서 외부 DB 사용자가 검색되는지 확인
  3. ⚠️ 여기 등록을 빼먹으면 ①의 Authenticator도 사용자 조회에 실패한다. 정확히는, Authenticator가 UserStorage를 직접 부르는 게 아니라 Keycloak의 User API(UserProvider)를 통해 사용자를 가져오고, 그 과정에서 등록된 UserStorageProvider가 동작한다. 구조로 보면 Authenticator → UserProvider → UserStorage SPI 순이라, User federation 등록이 되어 있어야 이 흐름이 이어진다.

📄 전체 코드: CustomUserStorageProvider.java · CustomUserStorageProviderFactory.java · CustomUserAdapter.java

④ Theme (UI 커스터마이징)

역할: 기본 로그인/에러 화면을 우리 디자인으로 교체한다. 자바 코드가 아니라 템플릿·리소스 묶음이라, 엄밀히는 앞의 셋 같은 SPI라기보다 Theme Provider에 가깝다. 다만 확장 지점이라는 점에서 함께 다룬다.

themes/custom/login/
├── theme.properties        # parent=base
├── login.ftl               # 로그인 페이지
├── error.ftl               # 에러 페이지
├── login-page-expired.ftl
└── resources/css/...        # CSS·이미지

theme.properties의 핵심은 한 줄, 기본 테마를 상속받는 설정이다.

parent=base

콘솔 설정 (Realm 단위): Realm settings → Themes → Login themecustom으로 변경 → 저장 → 로그인 페이지 새로고침.

📄 전체 코드: themes/custom/login/ (theme.properties · login.ftl)


로그인 화면

SPI 등록 규칙 — META-INF/services

만든 Provider를 Keycloak이 인식하게 하려면 등록 파일이 필요하다. Java 표준 SPI 포맷이라 규칙이 고정돼 있다. 이걸 빠뜨리면 코드가 있어도 Keycloak이 인식하지 못한다.

  • 파일명 = SPI 인터페이스의 FQCN
  • 파일 내용 = 구현 클래스의 FQCN (소스의 package 선언과 정확히 일치, 한 줄에 하나, #은 주석)
SPI 파일명(= 인터페이스 FQCN) 등록할 클래스
Authenticator org.keycloak.authentication.AuthenticatorFactory …AuthenticatorFactory
UserStorage org.keycloak.storage.UserStorageProviderFactory …ProviderStorageFactory
ProtocolMapper org.keycloak.protocol.ProtocolMapper 매퍼 클래스 자체(Factory 아님)

빌드해서 만든 jar를 Keycloak이 로드하게 하는 방법(배포 방식·Docker 구성 등)은 환경마다 달라 이 글에서는 다루지 않는다. 공식 문서의 Deploying Provider를 참고하면 된다.

 

이 글의 예제는 Dockerfile + docker-compose.yml로 provider jar와 테마를 Keycloak 26 이미지에 얹어 docker compose up 한 번으로 뜨게 해뒀다.

SPI 작성 시 주의사항 & 꿀팁

마지막으로, 실제로 만들면서 발목 잡히기 쉬운 지점만 모았다.

  • Spring 의존성 비권장: Keycloak은 Quarkus(CDI) 기반이라 spring-*을 함께 올리면 CDI·Bean 충돌로 기동이 실패하기 쉽다. SPI는 순수 Java + Keycloak API로만 짜는 게 일반적이다.
  • 중복 라이브러리 금지: Jackson, Hibernate, JBoss Logging, Resteasy, Infinispan, H2 등 Keycloak이 이미 제공하는 라이브러리를 jar에 중복 포함하면 버전 충돌로 런타임 에러가 난다. 이런 의존성은 결과물에 넣지 않도록 처리한다. 실제로 이 예제를 만들 때, 사용자 DB용으로 H2를 provider jar에 함께 넣었더니 그 H2가 Keycloak 내부 개발용 DB의 H2(≥ 2.3.230)를 덮어써 database version ... but the actual version is ... 에러로 서버가 아예 안 떴다. 결국 H2는 번들에서 빼고 Keycloak이 런타임에 제공하는 H2를 그대로 쓰도록 바꿔 해결했다(코드가 DriverManager + Class.forName만 쓰면 드라이버는 런타임에서 잡힌다). "Keycloak이 이미 주는 건 다시 넣지 않는다"는 원칙이 그냥 하는 말이 아니다.
  • META-INF/services 등록 필수: Factory(ProtocolMapper는 매퍼 클래스)를 service 파일에 FQCN으로 등록하지 않으면 Keycloak이 인식하지 못한다.
  • Provider ID 중복 금지: 같은 SPI 안에서 ID가 겹치면 기동 시 에러. custom-, jwt- 같은 접두어로 구분한다.
  • Hot reload 없음: jar를 바꾸면 Keycloak을 재시작해야 반영된다. 개발 중에는 이 점을 감안해 반복 주기를 잡는 게 좋다.
  • Java 버전 일치: Keycloak 26+ 는 Java 17+ 이므로 컴파일 타깃을 그에 맞춘다.

신규 Realm 세팅 체크리스트 — 새 렐름에 이 확장을 붙일 때 이 넷을 빠뜨리지 않으면 된다.

  1. User federation 등록 (UserStorage)
  2. Authentication Flow 바인딩 (Authenticator)
  3. Client / Scope에 Mapper 추가 (ProtocolMapper)
  4. Realm Login Theme 변경 (Theme)

이 글은 만드는 데 필요한 뼈대만 추렸다. 각 SPI의 세부 구현이나 에러 처리 디테일은 프로젝트마다 달라지니, 공식 문서와 함께 보면서 본인 상황에 맞게 채우면 된다.

정리하면, SPI는 Keycloak이 제공하는 인터페이스를 구현해서 원하는 기능으로 바꾸는 것이다. 지킬 규칙은 세 가지다. ① 인터페이스를 구현하고, ② META-INF/services 파일에 등록하고, ③ 콘솔에서 켜 준다. 물론 KeycloakSession, ProviderFactory 같은 Keycloak의 생명주기(lifecycle) 개념은 어느 정도 익혀야 한다. 하지만 그 규칙만 이해하면, 나머지는 익숙한 Java 코드로 충분히 구현할 수 있다.