이 글은 Spring Security + JWT 기반 인증 구조를 Keycloak/OIDC 기반 구조로 전환하며 겪은 과정을 정리한 시리즈입니다.
현재 글: 3편 - issuer-uri 한 줄로 Keycloak JWT가 검증되는 과정
시리즈 순서
1. Spring Security + JWT에서 Keycloak/OIDC 구조로 전환한 이유
2. Direct Access Grants에서 Authorization Code + PKCE로 전환하기
3. issuer-uri 한 줄로 Keycloak JWT가 검증되는 과정
4. JWT에는 ADMIN이 있는데 hasRole(“ADMIN”)이 실패한 이유
5. 로그아웃에서만 500 에러가 발생한 이유: Caffeine Cache TTL 불일치
1. 기존에는 JWT 필터를 직접 구현했다
Keycloak을 도입하기 전에는 Spring 서버가 직접 JWT를 발급하고 검증했다.
로그인이 성공하면 서버가 직접 Access Token을 생성했고, 이후 요청에서는 커스텀 필터를 통해 토큰을 검증했다.
Spring Security 설정에는 아래처럼 커스텀 필터를 추가했다.
httpSecurity
.addFilterBefore(
new JwtAuthenticationFilter(jwtTokenProvider),
UsernamePasswordAuthenticationFilter.class
);
여기서 UsernamePasswordAuthenticationFilter는 기본적으로 사용자의 아이디와 비밀번호를 기반으로 인증을 처리하는 필터다.
우리는 그 앞단에 JwtAuthenticationFilter를 추가했다.
즉, 요청이 컨트롤러에 도달하기 전에 Authorization 헤더에서 JWT를 꺼내고, 토큰이 유효하다면 직접 SecurityContext에 인증 객체를 저장하는 방식이었다.
2. SecurityContext란?
Spring Security에서 인증이 성공하면 인증 정보는 SecurityContext에 저장된다.
SecurityContext는 현재 요청을 처리하는 사용자 인증 정보를 담는 공간이다.
SecurityContext
→ Authentication 저장
→ 현재 요청의 사용자 인증 정보로 사용
Spring Security는 SecurityContextHolder를 통해 이 컨텍스트를 관리한다.
웹 서버는 여러 요청을 동시에 처리하기 위해 스레드 풀을 사용한다.
Spring Security는 각 요청 스레드마다 인증 정보를 분리하기 위해 기본적으로 ThreadLocal 기반으로 SecurityContext를 관리한다.
즉, 커스텀 JWT 필터에서 직접 인증 객체를 만들어 아래처럼 저장하면, 이후 컨트롤러나 인가 로직에서 현재 사용자를 인증된 사용자로 인식할 수 있다.
SecurityContextHolder.getContext().setAuthentication(authentication);
3. 커스텀 JWT 필터 로직
기존에 사용하던 JwtAuthenticationFilter의 핵심 로직은 다음과 같았다.
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain chain
) throws IOException, ServletException {
String accessToken = resolveToken(request);
if (accessToken != null) {
if (jwtTokenProvider.validateToken(accessToken)) {
Authentication authentication =
jwtTokenProvider.getAuthentication(accessToken);
SecurityContextHolder.getContext()
.setAuthentication(authentication);
}
}
chain.doFilter(request, response);
}
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken)
&& bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}
흐름은 단순했다.
- Authorization 헤더에서 Bearer Token을 추출한다.
- JWT 유효성을 검증한다.
- 토큰이 유효하면 Authentication 객체를 생성한다.
- Authentication을 SecurityContext에 저장한다.
- 다음 필터로 요청을 넘긴다.
이 구조에서는 JWT를 검증하는 책임도 우리가 직접 작성한 JwtTokenProvider가 담당했다.
4. 기존 JwtTokenProvider의 역할
기존 JwtTokenProvider에서는 JWT 생성과 검증을 직접 처리했다.
public boolean validateToken(String token) {
try {
Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(token);
return true;
} catch (SecurityException | MalformedJwtException e) {
log.info("유효하지 않은 JWT 토큰입니다.", e);
} catch (ExpiredJwtException e) {
log.info("만료된 JWT 토큰입니다.", e);
} catch (UnsupportedJwtException e) {
log.info("지원하지 않는 JWT 토큰입니다.", e);
} catch (IllegalArgumentException e) {
log.info("JWT 문자열이 비어 있거나 잘못되었습니다.", e);
}
return false;
}
토큰 생성도 직접 구현했다.
public String generateAccessToken(String username, String userRole, Date expireDate) {
return Jwts.builder()
.setSubject(username)
.claim("role", userRole)
.setExpiration(expireDate)
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
이 구조에서는 Spring 서버가 다음 책임을 모두 가지고 있었다.
- JWT 생성
- JWT 서명
- JWT 검증
- Claim 추출
- Authentication 생성
- SecurityContext 저장
처음에는 직접 구현하면서 인증 흐름을 이해하는 데 도움이 됐다.
하지만 Keycloak을 인증 서버로 분리하면서 이 구조는 더 이상 적절하지 않았다.
5. Keycloak 도입 후 필요 없어진 것
Keycloak을 도입한 뒤에는 Spring 서버가 더 이상 토큰을 직접 발급하지 않는다.
토큰 발급은 Authorization Server인 Keycloak이 담당한다.
Spring 서버는 Resource Server로서 다음 역할만 맡으면 된다.
- 클라이언트가 보낸 Access Token을 받는다.
- 해당 토큰이 Keycloak에서 발급된 토큰인지 검증한다.
- 토큰이 유효하면 인증 객체를 만든다.
- SecurityContext에 인증 정보를 저장한다.
- API 접근 권한을 판단한다.
즉, 기존에 직접 만들었던 다음 코드들이 필요 없어졌다.
- JwtAuthenticationFilter
- JwtTokenProvider
- 직접 작성한 JWT 생성 & 검증 로직
- HS256 Secret Key 기반 검증 코드
대신 Spring Security Resource Server 설정을 사용하게 되었다.
6. 변경 후: issuer-uri 마법의 설정 한 줄
Keycloak을 Authorization Server로 사용하면서 Spring 서버에는 다음 설정을 추가했다.
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://fcraichu.inwoohub.com/auth/realms/fcraichu
처음 봤을 때는 이 설정이 조금 신기했다.
기존에는 필터도 직접 만들고, 토큰 검증 코드도 직접 작성해야 했다.
그런데 이제는 issuer-uri 설정만으로 Keycloak JWT 검증이 가능해졌다.
그렇다면 이 한 줄 뒤에서는 어떤 일이 일어나는 걸까?
전체 흐름은 다음과 같다.
- 클라이언트가 Authorization: Bearer {accessToken} 형식으로 요청한다.
- Spring Security의 BearerTokenAuthenticationFilter가 토큰을 추출한다.
- Spring Security는 issuer-uri를 기반으로 OIDC Discovery 문서를 조회한다.
- Discovery 문서에서 JWKS URI를 찾는다.
- JWKS URI에서 공개키 목록을 가져온다.
- JWT Header의 kid와 일치하는 공개키를 찾는다.
- 공개키로 JWT 서명을 검증한다.
- 검증에 성공하면 JwtAuthenticationToken을 생성한다.
- 생성된 인증 정보를 SecurityContext에 저장한다.
이 과정을 Spring Security가 자동으로 처리해준다.
7. OIDC Discovery와 JWKS
기존에는 서버가 Secret Key를 직접 가지고 있었다.
기존 방식
- HS256
- Spring 서버가 Secret Key 보관
- 같은 키로 JWT 서명과 검증 수행
하지만 Keycloak이 발급하는 Access Token은 보통 비대칭키 기반으로 서명된다.
변경 후
- RS256
- Keycloak은 개인키로 토큰 서명
- Spring 서버는 공개키로 토큰 검증
Spring 서버는 개인키를 알 필요가 없다.
대신 Keycloak이 공개하는 공개키를 가져와 서명 검증에 사용한다.
이때 사용하는 것이 OIDC Discovery와 JWKS다.
issuer-uri를 설정하면 Spring Security는 해당 issuer의 OIDC 설정 정보를 조회한다.
일반적으로 다음 경로에서 OpenID 설정 정보를 확인할 수 있다.
{issuer-uri}/.well-known/openid-configuration
따라서 우리가 띄운 키클락에서 들어가본다면 이러한 JSON 을 볼 수 있다.
https://fcraichu.inwoohub.com/auth/realms/fcraichu/protocol/openid-connect/certs
{
"keys": [
{
"kid": "bSJAGL3wcCY29G4zlnkPxVbQWon_l3WzLynK6yT3UM0",
"kty": "RSA",
"alg": "RS256",
"use": "sig",
"x5c": [
"MIICnzCCAYcCBgGdsC9o8z..."
],
"x5t": "m_tF7tO56p0NXp_U5g5EZpWnuqM",
"x5t#S256": "IckUBYg6uPMnAqu3VFxvNrLHmxvNAXDZ81I6_ggpGxo...",
"n": "0TITMY_sVkojFl88MeKA2,,,",
"e": "AQAB"
},
{
"kid": "HGth9YiRg7qNUjdqJbx10UAsJakGNVIw00hIrYsrMQk...",
"kty": "RSA",
"alg": "RSA-OAEP",
"use": "enc",
"x5c": [
"MIICnzCCAYcCBgGdsC9pi",
],
"x5t": "FLEEI6_LWYLNZj0X3vY2QwUoQ9o",
"x5t#S256": "S5snwCsPbiin3Q-XJ0PMWivI_5o71xoug3Mjerbt0A4",
"n": "05k3bDryQ0yfvN5RnC-E1...",
"e": "AQAB"
}
]
}
여기서 중요한 값은 다음과 같다.
- Kid : JWT(Header) 에도 kid 가 들어있다. 스프링 시큐리티에서 가진 공개키 목록 중에 JWT 헤더의 kid와 똑같은 key 를 찾음
- n & e : 열쇠의 모양으로 이 두 값이 사실상 공개키의 본체
- alg : 알고리즘이며 RSA256은 즉, SHA-256 해시와 RSA 서명을 조합해서 검증
즉, Spring 서버는 더 이상 Secret Key를 들고 있을 필요가 없다.
Keycloak은 개인키로 JWT에 서명하고, Spring 서버는 Keycloak이 공개한 공개키로 토큰의 서명을 검증한다.
8. 조금 더 깊게 들어가보기
그렇다면 해당 동작은 어느 필터에서 동작하는 것일까? 에 대한 답은
BearerTokenAuthenticationFilter 이다.
BearerTokenAuthenticatonFilter 란?
스프링 시큐리티 필터 체인 중에서도 꽤 앞단에 위치하고 있다.
그리고 딱 3가지 핵심 업무를 수행하고 있다.
- 토큰 추출 (Extract) : HTTP 요청 헤더 (Authorization) 에서 Bearer (공백있음) 라는 접두사로 시작하는 토큰 문자열을 찾아낸다.
- 검증 위임 (Delegate) : 추출한 토큰을 들고 AuthenticationManager 에게 가서 검증을 한다.
- 인증 완료 (Authenticate) : 검증이 성공하면 사용자의 정보 (Claims)가 담긴 JwtAuthenticationToken 객체를 만들어서 SecurityContext에 저장한다.
그렇다면 토큰에 있는
- SecurityConfig.java 일부
/** OAuth2 리소스 서버 설정 및 토큰에서 ROLE 꺼내오기 */
httpSecurity
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter))
);
- package org.springframework.security.config.annotation.web.configurers.oauth2.server.resource 일부
// JwtConfigurer 클래스 내부
public JwtConfigurer jwtAuthenticationConverter(
Converter<Jwt, ? extends AbstractAuthenticationToken> jwtAuthenticationConverter) {
this.jwtAuthenticationConverter = jwtAuthenticationConverter;
return this;
}
Converter<Jwt, ? extends AbstractAuthenticationToken> getJwtAuthenticationConverter() {
if (this.jwtAuthenticationConverter == null) {
// 1. 만약 내가 직접 등록한 컨버터가 없다면?
// 2. 스프링 빈(Bean) 중에서 JwtAuthenticationConverter타입이 있는지 찾아봄
if (this.context.getBeanNamesForType(JwtAuthenticationConverter.class).length > 0) {
this.jwtAuthenticationConverter = this.context.getBean(JwtAuthenticationConverter.class);
}
// 3. 그것도 없다면? 기본값(Default)으로 새 JwtAuthenticationConverter를 만듦
else {
this.jwtAuthenticationConverter = new JwtAuthenticationConverter();
}
}
return this.jwtAuthenticationConverter;
}
JwtConfigurer 내부를 보면 알 수 는 것은
- 사용자가 jwtAuthenticationConverter 를 직접 만들어서 넣어줄 수 있다.
- @Bean 어노테이션을 붙여 만들어도 괜찮다.
- 만약 없다면, 기본(Default) 값으로 jwtAuthenticationConverter() 를 사용한다.
그럼 Default 를 찾아보자면,
// JwtAuthenticationConverter 내부
public void setPrincipalClaimName(String principalClaimName) {
Assert.hasText(principalClaimName, "principalClaimName cannot be empty");
this.principalClaimName = principalClaimName;
}
============== 파일 구분선 ==============
// JwtGrantedAuthoritiesConverter 내부
private static final Collection<String> WELL_KNOWN_AUTHORITIES_CLAIM_NAMES = Arrays.asList("scope", "scp");
private String getAuthoritiesClaimName(Jwt jwt) {
if (this.authoritiesClaimName != null) {
return this.authoritiesClaimName;
}
for (String claimName : WELL_KNOWN_AUTHORITIES_CLAIM_NAMES) {
if (jwt.hasClaim(claimName)) {
return claimName;
}
}
return null;
}
따라서 “scope”, “scp” 2개와 사용자를 식별할 수 있는 “sub” 1개
총 3개만 Authentication 에 담기게 된다.
따라서 별도의 정보 또한 추가적으로 넣기 위해서는 별도의 컨버터를 하나 더 만들어 줘야한다.
왜냐하면 “role” 이라는 클레임도 추가해주기 위해서다.
- SpringConfig 내부
@Bean
public Converter<Jwt, ? extends AbstractAuthenticationToken> jwtAuthenticationConverter() {
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(new KeycloakRealmRoleConverter());
return converter;
}
- 기본이 되는 JwtAuthenticationConverter 를 가져온다.
- 그후 setJwtGrantedAuthoritiesConverter를 통해 새로운 컨버터를 만들어서 “role” 도 함께 담아준다.
static class KeycloakRealmRoleConverter implements Converter<Jwt, Collection<GrantedAuthority>> {
@Override
public Collection<GrantedAuthority> convert(Jwt jwt) {
Collection<GrantedAuthority> authorities = new ArrayList<>();
List<String> roles = jwt.getClaimAsStringList("role");
if (roles == null) return authorities;
if (roles.contains("ADMIN")) authorities.add(new SimpleGrantedAuthority("ROLE_ADMIN"));
if (roles.contains("USER")) authorities.add(new SimpleGrantedAuthority("ROLE_USER"));
return authorities;
}
}
이 처럼 해당 토큰에서 “role” 클레임을 리스트로 꺼내와서 해당 role 을 Spring Context에 들어가는 Authentication 에 함께 추가해주면서 권한 또한 넣어줄 수 있었다.
9. 결론
처음엔 AI와 구글링의 도움으로 필터를 직접 구현하며 '동작하는 코드'를 만드는 재미에 빠졌습니다. 하지만 한 걸음 더 나아가 표준 스펙(OAuth2)을 학습하며, 이미 검증된 견고한 로직을 활용하는 것이 시스템의 안정성과 데이터 무결성 측면에서 얼마나 중요한지 알게 되었습니다.
이번 경험을 통해 개발자의 실력은 투자한 시간뿐만 아니라, '왜?'라는 질문을 던지며 코드를 파헤친 집요함에 비례한다는 확신을 얻었습니다. 이제는 더 견고해진 필터 체인 위에서, 서비스의 본질적인 비즈니스 로직에 더 집중해 보려 합니다.