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

- Name
- Youngju Kim
- @fjvbn20031
- 들어가며
- Client Scope와 Protocol Mapper의 관계
- 내장 Mapper 종류 총정리
- 커스텀 ProtocolMapper SPI 구현
- Audience 설정의 함정 — aud 검증 실패 사례
- 토큰 비대화 문제와 다이어트 전략
- 클레임 분배 설계 — ID Token vs Access Token vs UserInfo
- 멀티 클라이언트 환경에서 클레임 일관성 유지
- 스크립트 매퍼의 위험성
- 테스트 방법 — kcadm과 Token Introspection
- 클레임 설계 안티패턴 체크리스트
- 마치며
- 참고 자료
들어가며
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는 두 곳에 붙을 수 있습니다.
- Client Scope에 붙는 mapper — 여러 클라이언트가 공유하는 재사용 단위
- 클라이언트에 직접 붙는 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 응답 |
+-----------------------------------------+
핵심 규칙은 세 가지입니다.
- Default scope는 클라이언트가 요청하지 않아도 항상 평가됩니다.
profile,email,roles,web-origins가 대표적입니다. - Optional scope는 인가 요청의 scope 파라미터에 명시될 때만 평가됩니다. 부서 정보처럼 일부 클라이언트만 필요한 클레임은 optional scope로 분리하는 것이 토큰 다이어트의 출발점입니다.
- mapper마다 Add to ID token / Add to access token / Add to userinfo 토글이 따로 있어, 같은 클레임이라도 토큰 종류별로 포함 여부를 다르게 가져갈 수 있습니다.
Keycloak 어드민 콘솔의 Client Scopes 메뉴에서 realm 전역 scope를 만들고, 각 클라이언트의 Client Scopes 탭에서 default/optional로 연결하는 흐름이 표준 운영 패턴입니다.
내장 Mapper 종류 총정리
Keycloak이 기본 제공하는 mapper 가운데 실무에서 사용 빈도가 높은 것을 정리하면 다음과 같습니다.
| Mapper 타입 | 용도 | 대표 설정 항목 |
|---|---|---|
| User Attribute | 사용자 attribute를 클레임으로 | 속성명, 클레임명, JSON 타입 |
| User Property | username, email 등 내장 필드 | 속성명, 클레임명 |
| Group Membership | 소속 그룹 목록을 클레임으로 | full path 여부 |
| Role Name Mapper | 롤 이름을 다른 이름으로 치환 | 원본 롤, 새 이름 |
| User Realm Role | realm 롤 목록을 클레임으로 | 클레임명, 멀티밸류 |
| User Client Role | 특정 클라이언트의 롤만 추출 | client id, 클레임명 |
| Audience | aud 클레임에 대상 클라이언트 추가 | included client audience |
| Audience Resolve | 롤 기반으로 aud 자동 계산 | 없음 |
| Hardcoded Claim | 고정 값 클레임 삽입 | 클레임명, 값, 타입 |
| Pairwise subject identifier | 클라이언트별 sub 익명화 | sector identifier URI |
| Allowed Web Origins | CORS 허용 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 클레임입니다. 전형적인 사고 시나리오는 이렇습니다.
- 프론트엔드 SPA가
web-app클라이언트로 로그인하고 access token을 받습니다. - 그 토큰으로 백엔드
order-api를 호출합니다. order-api는 Spring Security resource server로 구성되어 있고 audience 검증을 켜 두었습니다.- 토큰의
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"));
자주 빠지는 함정을 추리면 다음과 같습니다.
- Audience Resolve mapper에 대한 오해: 이 mapper는 토큰에 포함된 client role을 근거로 aud를 계산합니다. 해당 리소스 서버의 client role을 사용자가 하나도 갖고 있지 않으면 aud에 추가되지 않습니다.
- ID token에 aud를 넣고 안심하는 경우: ID token의 aud는 항상 요청 클라이언트 자신입니다. 리소스 서버가 검증하는 것은 access token이므로 mapper 설정에서 access token 토글을 켰는지 확인해야 합니다.
- azp와 aud의 혼동:
azp는 토큰을 요청한 클라이언트(authorized party)이고aud는 토큰의 소비 대상입니다. 게이트웨이가 azp만 보고 통과시키는 구성은 RFC 9700 OAuth Security BCP가 경고하는 토큰 재사용 공격에 취약합니다. - 더 엄밀한 대안으로는 RFC 8707 resource indicator나 토큰 교환(RFC 8693)으로 리소스 서버별 토큰을 분리하는 방법이 있습니다.
토큰 비대화 문제와 다이어트 전략
운영 2~3년 차 Keycloak에서 흔한 증상은 access token이 수 KB로 부풀어 오르는 것입니다. 원인은 대체로 다음과 같습니다.
- 그룹 수백 개가 Group Membership mapper로 전부 토큰에 포함
- realm role + 모든 client role이
realm_access,resource_access에 통째로 포함 - 모든 클레임이 default scope에 묶여 모든 클라이언트 토큰에 주입
토큰이 커지면 모든 HTTP 요청 헤더가 함께 커지고, 8KB를 넘는 순간 NGINX의 기본 large_client_header_buffers나 AWS ALB의 16KB 헤더 한도에 부딪혀 502가 산발적으로 발생합니다. 다이어트 전략은 우선순위 순으로 다음과 같습니다.
- scope 분리: 자주 쓰는 클레임만 default scope에 두고 나머지는 optional scope로 옮깁니다. 클라이언트가 필요할 때만 scope 파라미터로 요청합니다.
- 롤 필터링: 클라이언트 설정의 Scope 탭에서 Full Scope Allowed를 끄고, 해당 클라이언트에 필요한 롤만 명시적으로 할당합니다. 이것 하나로
resource_access가 극적으로 줄어드는 경우가 많습니다. - 그룹 대신 파생 클레임: 그룹 전체 목록 대신, 커스텀 mapper로 "권한 등급" 같은 요약 값을 계산해 넣습니다.
- userinfo로 이동: 화면 표시용 프로필 정보는 access token에서 빼고 userinfo 엔드포인트 조회로 돌립니다.
- 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가 공존하는 식입니다. 예방책은 다음과 같습니다.
- 공유 client scope를 단일 진실 공급원으로: 클레임 정의는 반드시 realm 수준 client scope에만 만들고, dedicated mapper 사용을 금지하는 팀 규칙을 둡니다.
- 클레임 네이밍 컨벤션 문서화: 클레임 사전(claim dictionary)을 만들어 이름, 타입, 소스 attribute, 포함 토큰을 기록합니다.
- Terraform/kcadm 코드화: 어드민 콘솔 수작업 대신 keycloak Terraform provider나 kcadm 스크립트로 mapper를 선언적으로 관리하면 환경 간 drift를 막을 수 있습니다.
# 모든 클라이언트의 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
스크립트 매퍼를 피해야 하는 이유는 명확합니다.
- 보안: 어드민 콘솔 접근 권한이 곧 서버 내 코드 실행 권한이 됩니다. 어드민 계정 탈취 시 피해 반경이 realm 설정 변경을 넘어 RCE로 확대됩니다.
- 이식성: Nashorn 계열 엔진 의존이라 Keycloak 버전 업그레이드에서 호환성이 깨지기 쉽습니다.
- 관측 불가: 스크립트 오류가 토큰 발급 실패로 이어져도 추적이 어렵습니다.
기존에 스크립트 매퍼를 쓰고 있다면, 위에서 다룬 커스텀 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
클레임 설계 안티패턴 체크리스트
마지막으로 설계 리뷰 때 바로 쓸 수 있는 안티패턴 점검 목록입니다.
- 모든 클레임을 default scope에 배치 — 토큰 비대화의 1순위 원인입니다. optional scope 분리가 기본기입니다.
- 같은 데이터를 access token과 userinfo에 중복 포함 — 채널별 역할 정의부터 다시 합의해야 합니다.
- 민감 정보를 access token에 포함 — access token은 모든 서비스와 로그 파이프라인을 통과한다는 사실을 잊기 쉽습니다.
- aud mapper만 넣고 리소스 서버 검증은 생략 — mapper 설정과 검증 코드는 항상 한 쌍입니다.
- dedicated mapper 남용 — 클라이언트마다 클레임 이름이 달라지는 엔트로피의 시작점입니다.
- 스크립트 매퍼 신규 도입 — SPI로 구현할 수 없는 이유를 먼저 문서로 증명해야 합니다.
- mapper 변경을 콘솔 수작업으로 처리 — 코드화되지 않은 변경은 환경 간 drift와 롤백 불가능을 만듭니다.
- 토큰 크기 모니터링 부재 — mapper 하나 추가가 게이트웨이 502로 이어지는 경로를 끊으려면 크기 회귀 테스트가 필요합니다.
마치며
Protocol Mapper는 작은 설정처럼 보이지만, 그 결과물인 클레임은 마이크로서비스 전체의 인가 판단과 헤더 예산, 개인정보 노출 범위를 결정합니다. 정리하면 다음과 같습니다.
- 클레임은 client scope 단위로 설계하고 default/optional을 구분해 토큰 비대화를 예방합니다.
- aud는 자동으로 채워지지 않습니다. Audience mapper를 명시하고 리소스 서버에서 반드시 검증합니다.
- ID token, access token, userinfo의 역할을 구분해 클레임을 분배합니다.
- 스크립트 매퍼 대신 커스텀 ProtocolMapper SPI를 사용합니다.
- Evaluate, kcadm, introspection으로 클레임 검증을 CI에 넣습니다.
다음 글에서는 이렇게 설계한 토큰을 Spring Security 6 리소스 서버에서 소비하는 쪽의 구현을 다룹니다.
참고 자료
- Keycloak Server Administration Guide — Protocol Mappers
- Keycloak Server Developer Guide — Service Provider Interfaces
- Keycloak Release Notes
- Keycloak 26.6.0 Released
- OpenID Connect Core 1.0
- RFC 7519 — JSON Web Token (JWT)
- RFC 9700 — Best Current Practice for OAuth 2.0 Security
- RFC 8693 — OAuth 2.0 Token Exchange
- RFC 7662 — OAuth 2.0 Token Introspection
- RFC 8707 — Resource Indicators for OAuth 2.0
- OAuth 2.1 draft