LabHub

블로그

Keycloak 토큰 커스터마이징 — Protocol Mapper와 Claims 설계 실전

한국어English日本語

들어가며

Keycloak을 도입한 팀이 가장 먼저 부딪히는 실무 과제는 인증 자체가 아니라 토큰 안에 무엇을 담을 것인가입니다. 기본 설정으로 발급된 access token에는 우리 서비스가 필요로 하는 부서 코드도, 사내 권한 등급도 들어 있지 않습니다. 반대로 운영을 몇 년 하다 보면 토큰이 8KB를 넘어 HTTP 헤더 한도를 위협하는 정반대의 문제를 만나기도 합니다.

2026년 현재 Keycloak 26.6은 JWT Authorization Grant, Federated client authentication, FAPI 2.0 Security Profile Final 지원 등으로 토큰 발급 경로 자체가 다양해졌고, OAuth Client ID Metadata Document(CIMD) 실험 기능 덕분에 MCP(Model Context Protocol) 기반 AI 에이전트의 authorization server 역할까지 수행할 수 있게 되었습니다. 토큰을 소비하는 주체가 사람의 브라우저 세션을 넘어 AI 에이전트, 배치 워크로드, 외부 파트너 시스템으로 확장되면서, 클레임 설계는 더 이상 부차적인 설정이 아니라 보안 아키텍처의 핵심이 되었습니다.

이 글에서는 Protocol Mapper와 Client Scope의 동작 원리부터 커스텀 SPI 구현, audience 함정, 토큰 다이어트 전략, 그리고 검증 자동화까지 실전 순서대로 정리합니다.

Client Scope와 Protocol Mapper의 관계

Keycloak에서 토큰에 들어가는 클레임은 전부 Protocol Mapper가 만들어냅니다. 그리고 mapper는 두 곳에 붙을 수 있습니다.

  1. Client Scope에 붙는 mapper — 여러 클라이언트가 공유하는 재사용 단위
  2. 클라이언트에 직접 붙는 dedicated mapper — 해당 클라이언트 전용

토큰 발급 시점의 평가 순서를 그림으로 보면 다음과 같습니다.

+--------------------------------------------------------------+
|                      Token Issuance Pipeline                  |
+--------------------------------------------------------------+
   Authorization Request (scope=openid profile email teams)
        |
        v
 +--------------+     +---------------------------+
 | Default      |     | Optional Client Scopes    |
 | Client Scopes| <-- | (scope 파라미터로 요청 시) |
 +--------------+     +---------------------------+
        |                     |
        +----------+----------+
                   v
        +---------------------+
        | Effective Scope Set |
        +---------------------+
                   |
                   v
        +---------------------+      +--------------------+
        | Protocol Mappers    | <--- | Dedicated Mappers  |
        | (scope에 연결된 것) |      | (client 전용)      |
        +---------------------+      +--------------------+
                   |
                   v
   +-----------------------------------------+
   | ID Token / Access Token / UserInfo 응답 |
   +-----------------------------------------+

핵심 규칙은 세 가지입니다.

Keycloak 어드민 콘솔의 Client Scopes 메뉴에서 realm 전역 scope를 만들고, 각 클라이언트의 Client Scopes 탭에서 default/optional로 연결하는 흐름이 표준 운영 패턴입니다.

내장 Mapper 종류 총정리

Keycloak이 기본 제공하는 mapper 가운데 실무에서 사용 빈도가 높은 것을 정리하면 다음과 같습니다.

Mapper 타입용도대표 설정 항목
User Attribute사용자 attribute를 클레임으로속성명, 클레임명, JSON 타입
User Propertyusername, email 등 내장 필드속성명, 클레임명
Group Membership소속 그룹 목록을 클레임으로full path 여부
Role Name Mapper롤 이름을 다른 이름으로 치환원본 롤, 새 이름
User Realm Rolerealm 롤 목록을 클레임으로클레임명, 멀티밸류
User Client Role특정 클라이언트의 롤만 추출client id, 클레임명
Audienceaud 클레임에 대상 클라이언트 추가included client audience
Audience Resolve롤 기반으로 aud 자동 계산없음
Hardcoded Claim고정 값 클레임 삽입클레임명, 값, 타입
Pairwise subject identifier클라이언트별 sub 익명화sector identifier URI
Allowed Web OriginsCORS 허용 origin 주입없음

User Attribute mapper를 kcadm CLI로 만들어 보겠습니다. department라는 사용자 attribute를 access token의 dept 클레임으로 내보내는 예시입니다.

# client scope 생성
kcadm.sh create client-scopes -r myrealm \
  -s name=org-info \
  -s protocol=openid-connect \
  -s 'attributes."include.in.token.scope"=true'

# scope에 user attribute mapper 추가
kcadm.sh create client-scopes/SCOPE_ID/protocol-mappers/models -r myrealm \
  -s name=dept-mapper \
  -s protocol=openid-connect \
  -s protocolMapper=oidc-usermodel-attribute-mapper \
  -s 'config."user.attribute"=department' \
  -s 'config."claim.name"=dept' \
  -s 'config."jsonType.label"=String' \
  -s 'config."access.token.claim"=true' \
  -s 'config."id.token.claim"=false' \
  -s 'config."userinfo.token.claim"=true'

# 클라이언트에 optional scope로 연결
kcadm.sh update clients/CLIENT_UUID/optional-client-scopes/SCOPE_ID -r myrealm

Group Membership mapper에서 한 가지 주의할 점은 Full group path 옵션입니다. 켜면 /engineering/platform/sre 같은 전체 경로가, 끄면 sre처럼 말단 그룹명만 들어갑니다. 소비 측 애플리케이션이 경로 파싱을 하고 있다면 이 옵션을 바꾸는 순간 권한 체크가 전부 깨지므로, 처음부터 팀 표준을 정해 두는 것이 좋습니다.

Hardcoded Claim mapper는 환경 식별에 유용합니다. 예를 들어 스테이징 realm의 모든 토큰에 env 클레임을 staging으로 박아 두면, 토큰이 환경을 넘어 오용되는 사고를 리소스 서버에서 한 줄로 차단할 수 있습니다.

커스텀 ProtocolMapper SPI 구현

내장 mapper로 해결되지 않는 요구 — 예를 들어 외부 HR 시스템 조회 결과를 클레임으로 넣거나, 여러 attribute를 조합해 하나의 구조화된 클레임을 만드는 경우 — 에는 ProtocolMapper SPI를 구현합니다.

Maven 의존성은 provided 스코프로 선언합니다.

<dependencies>
  <dependency>
    <groupId>org.keycloak</groupId>
    <artifactId>keycloak-services</artifactId>
    <version>26.6.2</version>
    <scope>provided</scope>
  </dependency>
  <dependency>
    <groupId>org.keycloak</groupId>
    <artifactId>keycloak-server-spi</artifactId>
    <version>26.6.2</version>
    <scope>provided</scope>
  </dependency>
</dependencies>

구현 클래스는 AbstractOIDCProtocolMapper를 상속하고 세 개의 마커 인터페이스를 구현합니다.

package com.example.keycloak.mapper;

import org.keycloak.models.ClientSessionContext;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.ProtocolMapperModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.protocol.oidc.mappers.AbstractOIDCProtocolMapper;
import org.keycloak.protocol.oidc.mappers.OIDCAccessTokenMapper;
import org.keycloak.protocol.oidc.mappers.OIDCAttributeMapperHelper;
import org.keycloak.protocol.oidc.mappers.OIDCIDTokenMapper;
import org.keycloak.protocol.oidc.mappers.UserInfoTokenMapper;
import org.keycloak.provider.ProviderConfigProperty;
import org.keycloak.representations.IDToken;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;

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

    public static final String PROVIDER_ID = "employee-grade-mapper";
    private static final List<ProviderConfigProperty> CONFIG = new ArrayList<>();

    static {
        // 어드민 콘솔에 노출되는 토큰 포함 여부 토글 3종을 자동 추가
        OIDCAttributeMapperHelper.addTokenClaimNameConfig(CONFIG);
        OIDCAttributeMapperHelper.addIncludeInTokensConfig(CONFIG, EmployeeGradeMapper.class);
    }

    @Override
    public String getId() {
        return PROVIDER_ID;
    }

    @Override
    public String getDisplayType() {
        return "Employee Grade Mapper";
    }

    @Override
    public String getDisplayCategory() {
        return TOKEN_MAPPER_CATEGORY;
    }

    @Override
    public String getHelpText() {
        return "Combines job-code and grade attributes into one structured claim.";
    }

    @Override
    public List<ProviderConfigProperty> getConfigProperties() {
        return CONFIG;
    }

    @Override
    protected void setClaim(IDToken token, ProtocolMapperModel mappingModel,
                            UserSessionModel userSession, KeycloakSession session,
                            ClientSessionContext clientSessionCtx) {
        String jobCode = userSession.getUser().getFirstAttribute("jobCode");
        String grade = userSession.getUser().getFirstAttribute("grade");
        if (jobCode == null || grade == null) {
            return; // 값이 없으면 클레임 자체를 넣지 않는다
        }
        Map<String, Object> value = Map.of(
                "jobCode", jobCode,
                "grade", Integer.parseInt(grade));
        OIDCAttributeMapperHelper.mapClaim(token, mappingModel, value);
    }
}

서비스 등록 파일을 잊으면 mapper가 콘솔에 나타나지 않습니다.

src/main/resources/META-INF/services/org.keycloak.protocol.ProtocolMapper
--------------------------------------------------------------------------
com.example.keycloak.mapper.EmployeeGradeMapper

빌드한 JAR는 providers 디렉토리에 넣고 build 단계를 다시 실행합니다.

cp target/employee-grade-mapper.jar /opt/keycloak/providers/
/opt/keycloak/bin/kc.sh build
/opt/keycloak/bin/kc.sh start --optimized

운영 관점에서 두 가지를 강조하고 싶습니다. 첫째, setClaim 안에서 외부 API를 동기 호출하는 설계는 피해야 합니다. 토큰 발급은 로그인 임계 경로라서 외부 지연이 곧 전사 로그인 지연이 됩니다. 외부 데이터는 사용자 attribute로 미리 동기화해 두고 mapper는 읽기만 하는 패턴이 안전합니다. 둘째, Keycloak 메이저 업그레이드 때 SPI 시그니처가 바뀔 수 있으므로 커스텀 mapper는 업그레이드 체크리스트에 반드시 포함시켜야 합니다.

Audience 설정의 함정 — aud 검증 실패 사례

토큰 커스터마이징에서 가장 자주 장애를 일으키는 지점이 aud 클레임입니다. 전형적인 사고 시나리오는 이렇습니다.

  1. 프론트엔드 SPA가 web-app 클라이언트로 로그인하고 access token을 받습니다.
  2. 그 토큰으로 백엔드 order-api를 호출합니다.
  3. order-api는 Spring Security resource server로 구성되어 있고 audience 검증을 켜 두었습니다.
  4. 토큰의 aud에는 account만 들어 있어서 401 invalid_token 이 떨어집니다.

Keycloak은 기본적으로 "이 토큰을 어떤 리소스 서버가 소비할지"를 모르기 때문에 aud를 자동으로 채워 주지 않습니다. 해결책은 Audience mapper를 명시적으로 추가하는 것입니다.

# order-api를 aud에 추가하는 client scope + audience mapper
kcadm.sh create client-scopes -r myrealm \
  -s name=order-api-audience -s protocol=openid-connect

kcadm.sh create client-scopes/SCOPE_ID/protocol-mappers/models -r myrealm \
  -s name=order-api-aud \
  -s protocol=openid-connect \
  -s protocolMapper=oidc-audience-mapper \
  -s 'config."included.client.audience"=order-api' \
  -s 'config."access.token.claim"=true' \
  -s 'config."id.token.claim"=false'

검증 측(Spring) 코드는 다음 글에서 자세히 다루지만, 핵심만 보면 이렇습니다.

OAuth2TokenValidator<Jwt> audienceValidator = new JwtClaimValidator<List<String>>(
        "aud", aud -> aud != null && aud.contains("order-api"));

자주 빠지는 함정을 추리면 다음과 같습니다.

토큰 비대화 문제와 다이어트 전략

운영 2~3년 차 Keycloak에서 흔한 증상은 access token이 수 KB로 부풀어 오르는 것입니다. 원인은 대체로 다음과 같습니다.

토큰이 커지면 모든 HTTP 요청 헤더가 함께 커지고, 8KB를 넘는 순간 NGINX의 기본 large_client_header_buffers나 AWS ALB의 16KB 헤더 한도에 부딪혀 502가 산발적으로 발생합니다. 다이어트 전략은 우선순위 순으로 다음과 같습니다.

  1. scope 분리: 자주 쓰는 클레임만 default scope에 두고 나머지는 optional scope로 옮깁니다. 클라이언트가 필요할 때만 scope 파라미터로 요청합니다.
  2. 롤 필터링: 클라이언트 설정의 Scope 탭에서 Full Scope Allowed를 끄고, 해당 클라이언트에 필요한 롤만 명시적으로 할당합니다. 이것 하나로 resource_access가 극적으로 줄어드는 경우가 많습니다.
  3. 그룹 대신 파생 클레임: 그룹 전체 목록 대신, 커스텀 mapper로 "권한 등급" 같은 요약 값을 계산해 넣습니다.
  4. userinfo로 이동: 화면 표시용 프로필 정보는 access token에서 빼고 userinfo 엔드포인트 조회로 돌립니다.
  5. lightweight access token: Keycloak 24부터 도입된 경량 access token 기능을 켜면 access token에서 기본 클레임 다수가 빠지고, 리소스 서버는 token introspection으로 상세 정보를 조회하게 됩니다.

클레임 분배 설계 — ID Token vs Access Token vs UserInfo

세 가지 전달 채널의 역할을 명확히 구분하면 설계가 단순해집니다.

채널소비 주체넣어야 할 것넣지 말아야 할 것
ID Token클라이언트 앱인증 사실, 화면용 최소 프로필API 인가용 권한 정보
Access Token리소스 서버aud, 롤, 인가 판단용 클레임화면 표시용 상세 프로필
UserInfo클라이언트 앱상세 프로필, 변동 잦은 정보인가 판단의 근거

원칙은 단순합니다. ID token은 인증의 증거, access token은 인가의 입력, userinfo는 프로필의 소스입니다. ID token에 권한을 담아 클라이언트가 자체 인가를 수행하면, 토큰 수명 동안 권한 변경이 반영되지 않는 문제와 클라이언트 위변조 위험을 동시에 떠안게 됩니다.

또 하나 실무에서 중요한 것은 개인정보의 위치입니다. access token은 모든 마이크로서비스와 로그 파이프라인을 떠돌아다니기 때문에, 주민번호 뒷자리 같은 민감 정보는 access token에 절대 넣지 말고 userinfo 또는 별도 API로 격리해야 합니다.

멀티 클라이언트 환경에서 클레임 일관성 유지

클라이언트가 수십 개로 늘어나면 "같은 의미의 클레임이 클라이언트마다 다른 이름"인 엔트로피가 발생합니다. dept, department, org_code가 공존하는 식입니다. 예방책은 다음과 같습니다.

# 모든 클라이언트의 mapper 현황을 덤프해 drift를 감사하는 스크립트
for c in $(kcadm.sh get clients -r myrealm --fields id --format csv --noquotes); do
  kcadm.sh get clients/$c/protocol-mappers/models -r myrealm \
    --fields name,protocolMapper,config
done > mappers-audit.json

스크립트 매퍼의 위험성

Keycloak에는 JavaScript로 클레임을 계산하는 Script Mapper가 있었지만, 현재는 기본 비활성이며 preview 기능입니다. 활성화하려면 배포 시 명시 플래그가 필요합니다.

kc.sh start --features=scripts

스크립트 매퍼를 피해야 하는 이유는 명확합니다.

기존에 스크립트 매퍼를 쓰고 있다면, 위에서 다룬 커스텀 ProtocolMapper SPI(컴파일·코드리뷰·버전관리가 가능한 Java 코드)로 이전하는 것이 2026년 기준 권장 경로입니다.

테스트 방법 — kcadm과 Token Introspection

클레임 설계는 반드시 자동화된 검증과 함께 가야 합니다. 첫 단계는 어드민 콘솔의 Evaluate 기능(Client Scopes 탭)으로, 특정 사용자·scope 조합의 토큰을 발급 없이 미리 볼 수 있습니다. CI에서는 실제 토큰을 받아 검사합니다.

# 1. password grant로 테스트 토큰 발급 (테스트 전용 confidential client)
TOKEN=$(curl -s -X POST \
  "https://kc.example.com/realms/myrealm/protocol/openid-connect/token" \
  -d grant_type=password \
  -d client_id=ci-test-client \
  -d client_secret=$CLIENT_SECRET \
  -d username=testuser \
  -d password=$TEST_PASSWORD \
  -d scope="openid org-info" | jq -r .access_token)

# 2. 페이로드 디코드 후 클레임 단언
echo $TOKEN | cut -d. -f2 | base64 -d 2>/dev/null | jq .
echo $TOKEN | cut -d. -f2 | base64 -d 2>/dev/null \
  | jq -e '.dept == "platform" and (.aud | index("order-api"))'

# 3. introspection 엔드포인트로 서버 측 검증 결과 확인
curl -s -X POST \
  "https://kc.example.com/realms/myrealm/protocol/openid-connect/token/introspect" \
  -u order-api:$RS_SECRET \
  -d token=$TOKEN | jq '{active, aud, scope, dept}'

introspection은 opaque/lightweight 토큰 전략을 쓸 때 특히 중요합니다. active 필드가 false면 만료·폐기·서명 불일치 중 하나이므로, CI에서 introspection 응답까지 단언해 두면 mapper 변경이 리소스 서버 검증을 깨뜨리는 회귀를 배포 전에 잡을 수 있습니다.

마지막으로 토큰 크기 회귀 테스트도 추천합니다.

SIZE=$(echo -n $TOKEN | wc -c)
if [ "$SIZE" -gt 4096 ]; then
  echo "FAIL: access token is $SIZE bytes (limit 4096)"; exit 1
fi

클레임 설계 안티패턴 체크리스트

마지막으로 설계 리뷰 때 바로 쓸 수 있는 안티패턴 점검 목록입니다.

마치며

Protocol Mapper는 작은 설정처럼 보이지만, 그 결과물인 클레임은 마이크로서비스 전체의 인가 판단과 헤더 예산, 개인정보 노출 범위를 결정합니다. 정리하면 다음과 같습니다.

다음 글에서는 이렇게 설계한 토큰을 Spring Security 6 리소스 서버에서 소비하는 쪽의 구현을 다룹니다.

참고 자료

댓글

아직 댓글이 없습니다.

로그인하면 댓글을 쓸 수 있습니다