전체 구조 요약
auth-service
→ RSA 개인키로 JWT 서명 (RS256)
→ /.well-known/jwks.json 으로 공개키 노출
각 서비스
→ JWKS 엔드포인트에서 공개키 캐싱
→ Auth 호출 없이 로컬에서 JWT 검증
설계 배경과 기존 `/validate-token` 구조의 문제점
[PlantiFy] MSA 환경에서 Kakao Social Login 구현하기 - 서비스 개요 / Auth 병목 문제와 JWKS 기반 분산 인증 구조 개선
[PlantiFy] MSA 환경에서 Kakao Social Login 구현하기 - 서비스 개요 / Auth 병목 문제와 JWKS 기반 분산 인증
서비스 개요 및 상황Gateway 병목 피하려고 Auth 서버 분리Gateway 중앙화: 인증, 라우팅, 로깅, rate limit 등 공통 책임이 과도하게 집중되면 병목 지점이 될 수 있음특히 Gateway 단에서만 인증하고 내부
debug.tistory.com
1. auth-service: RS256 전환
RSA 키 로딩 (`RsaKeyProvider`)
PEM 파일이 있으면 로드하고, 없으면 로컬 실험용 키 쌍 자동 생성
키가 새로 생성될 때마다 `kid`도 `UUID`로 새로 발급해서 리소스 서비스가 stale 캐시 키를 사용하는 문제 방지
@Component
public class RsaKeyProvider {
public RsaKeyProvider(@Value("${jwt.private-key}") Resource privateKeyResource,
@Value("${jwt.public-key}") Resource publicKeyResource,
@Value("${jwt.key-id}") String keyId) {
KeyPair fallbackKeyPair = null;
if (!privateKeyResource.exists() || !publicKeyResource.exists()) {
fallbackKeyPair = generateLocalExperimentKeyPair();
}
this.privateKey = fallbackKeyPair == null
? loadPrivateKey(privateKeyResource)
: (RSAPrivateKey) fallbackKeyPair.getPrivate();
this.publicKey = fallbackKeyPair == null
? loadPublicKey(publicKeyResource)
: (RSAPublicKey) fallbackKeyPair.getPublic();
this.keyId = fallbackKeyPair == null
? keyId
: "local-generated-" + UUID.randomUUID();
}
}
JWT 발급 (`JwtAuthProvider`)
HS256에서 RS256으로 전환하면서 변경된 2가지
- `signWith(secretKey)` → `signWith(rsaKeyProvider.getPrivateKey(), SignatureAlgorithm.RS256)`
- `role`이 하드코딩 "USER"였던 것 → 실제 사용자 `role`을 클레임에 포함
- `role`이 클레임에 없으면 리소스 서비스는 ROLE_* 권한을 매핑할 수 없음
- HS256: `/validate-token`은 DB에서 `role`을 조회했지만,
JWKS 로컬 검증으로 전환하면 Auth 호출이 없으므로 반드시 토큰 발급 시점에 role이 포함되어야 함
public String createAccessToken(long userId, Role role) {
return createToken(userId, role, "Access", accessTokenExpiration);
}
private String createToken(Long userId, Role role, String type, long tokenValidTime) {
Claims claims = Jwts.claims().setSubject(type);
claims.put("userId", userId);
if (role != null) {
claims.put("role", role.name()); // 실제 role 클레임 포함
}
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis() + tokenValidTime))
.setHeaderParam("kid", rsaKeyProvider.getKeyId()) // kid 포함
.signWith(rsaKeyProvider.getPrivateKey(), SignatureAlgorithm.RS256)
.compact();
}
JWKS 엔드포인트 (`JwksController`)
리소스 서비스들이 공개키를 가져갈 수 있도록 표준 JWKS 형식으로 노출
@RestController
@RequiredArgsConstructor
public class JwksController {
private final RsaKeyProvider rsaKeyProvider;
@GetMapping("/.well-known/jwks.json")
public Map<String, Object> jwks() {
RSAKey rsaKey = new RSAKey.Builder(rsaKeyProvider.getPublicKey())
.keyID(rsaKeyProvider.getKeyId())
.algorithm(JWSAlgorithm.RS256)
.keyUse(KeyUse.SIGNATURE)
.build();
return new JWKSet(rsaKey).toJSONObject();
}
}
응답 예시
{
"keys": [
{
"kty": "RSA",
"kid": "plantify-local-rsa-key-1",
"use": "sig",
"alg": "RS256",
"n": "...",
"e": "AQAB"
}
]
}
2. common-auth-lib: 공통 인증 모듈
`JwtFilter`를 서비스마다 복붙하는 대신 Spring Boot AutoConfiguration으로 의존성 추가만으로 동작하도록 구성
자동 설정 (`PlantifyResourceServerAutoConfiguration`)
`NimbusJwtDecoder`: 내부적으로 JWKS 캐싱/ `kid`가 캐시에 없으면 자동으로 JWKS 엔드포인트 재조회
@AutoConfiguration
@EnableConfigurationProperties(PlantifyAuthProperties.class)
public class PlantifyResourceServerAutoConfiguration {
@Bean
@ConditionalOnProperty(prefix = "auth", name = "jwk-set-uri")
JwtDecoder jwtDecoder(PlantifyAuthProperties properties) {
return NimbusJwtDecoder.withJwkSetUri(properties.getJwkSetUri()).build();
}
@Bean
JwtAuthenticationConverter jwtAuthenticationConverter() {
Converter<Jwt, Collection<GrantedAuthority>> authoritiesConverter = jwt -> {
String role = jwt.getClaimAsString("role");
if (role == null || role.isBlank()) return List.of();
return List.of(new SimpleGrantedAuthority("ROLE_" + role));
};
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(authoritiesConverter);
converter.setPrincipalClaimName("userId"); // principal = userId
return converter;
}
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http,
PlantifyAuthProperties properties,
JwtAuthenticationConverter jwtAuthenticationConverter) throws Exception {
String[] permitAll = properties.getPermitAll().toArray(String[]::new);
return http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(r -> r
.requestMatchers(permitAll).permitAll()
.anyRequest().authenticated()
)
.oauth2ResourceServer(o -> o.jwt(j -> j.jwtAuthenticationConverter(jwtAuthenticationConverter)))
.build();
}
}
AutoConfiguration으로 등록하려면 파일 필요
// resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
com.plantify.authlib.PlantifyResourceServerAutoConfiguration
설정 프로퍼티 (`PlantifyAuthProperties`)
@ConfigurationProperties(prefix = "auth")
public class PlantifyAuthProperties {
private String jwkSetUri;
private List<String> permitAll = new ArrayList<>(List.of("/actuator/**", "/health"));
}
3. 리소스 서비스 적용
의존성 추가 + `yml` 설정
build.gradle
dependencies {
implementation 'com.plantify:common-auth-lib'
}
settings.gradle
includeBuild '../common-auth-lib'
application.yml
auth:
jwk-set-uri: ${AUTH_JWK_SET_URI:http://localhost:8081/.well-known/jwks.json}
permit-all:
- /actuator/**
- /health
시퀀스 다이어그램
JWKS 발견 흐름 (최초 1회)

보호된 API 흐름 (이후 매 요청)

테스트
환경: MacBook, Docker Compose, k6 v0.53.0, 20 VU, 1분
1) 베이스라인 부하 테스트
Main
1. `main` 브랜치 이동
git checkout main
2. Auth + demo 서버 실행
docker compose up --build auth-service demo-resource-service
3. 다른 터미널에서 k6 실행
docker compose run --rm k6 run /scripts/baseline-auth-call.js
테스트 목적
- k6가 Auth 서버의 실험용 토큰 발급 API를 호출
- 받은 JWT를 들고 demo 서버의 `/api/demo/me` 호출
- demo 서버는 매 요청마다 Auth 서버의 `/v1/auth/validate-token`을 호출
- Auth가 토큰을 검증하고 userId, role을 반환
- demo 서버가 응답
- k6가 latency, 실패율, p95 같은 지표 확인
결과
- k6 -> Auth `/v1/auth/validate-token` 직접 호출
- `auth_call_duration` 측정용
- k6 -> demo `/api/demo/me` 호출
- demo 내부에서 다시 Auth `/v1/auth/validate-token` 호출
=> `iterations`보다 `http_reqs`가 약 2배

- `checks: 100.00%`
- `http_req_failed: 0.00%`
- HTTP 요청 실패 X
- basline 정상 상태에서는 안정적으로 동작함
- `iterations: 1160`
- `/api/demo/me` 테스트 루프가 1160번 수행됨
- 보호 API를 약 1160번 호출
- `http_reqs: 2321`
- 총 HTTP 요청 수
- 계산: 1160 iterations * 2 requests + setup dev-token 1 = 2321
- `http_req_duration p95: 52.3ms`
- 전체 HTTP 요청 중 95%가 52.3ms 안에 끝남
- `auth_call_duration p95: 40.02ms`
- Auth `/validate-token` 직접 호출만 따로 측정한 p95
- basline에서 인증 검증 네트워크 호출이 어느 정도 비중을 가지는지 보여줌
Refactor
1. `refactor/jwks-local-validation` 브랜치 이동
git checkout refactor/jwks-local-validation
2. Auth + demo 서버 실행
docker compose up --build auth-service demo-resource-service
3. 다른 터미널에서 k6 실행
docker compose run --rm k6 run /scripts/baseline-auth-call.js
테스트 목적
- Auth가 RS256 JWT 발급
- Auth가 JWKS endpoint 제공
- demo-resource-service가 Auth `/validate-token`을 매번 호출하지 않음
- demo-resource-service가 JWKS 공개키로 JWT를 로컬 검증
- `/api/demo/me` 요청 latency, 실패율, p95 확인
결과

- `checks: 100.00%`
- `http_req_failed: 0.00%`
- `iterations: 1200`
- `http_reqs: 1201`
- 계산: 1200 iterations * 1 requests + setup dev-token 1 = 1201
- `http_req_duration p95: 17.77ms`
요약

- `main`에서는 한 `iteration`마다 k6가 요청 2번 보냄
- `refactor`에서는 한 `iteration`마다 k6가 요청 1번 보냄
- `iterations`와 `http_reqs`가 거의 동일
=> `refactor`에서는 demo가 매 요청마다 Auth `/validate-token` 호출 X, JWKS로 로컬 검증
2) 장애 테스트
docker compose run --rm k6 run /scripts/auth-failure-window.js
실행한 상태에서 다른 터미널에서 Auth 멈추기
docker stop plantify-auth-service
테스트 목적
JWKS가 이미 캐시된 상태라면 Auth가 내려가도 demo-resource-service의 `/api/demo/me`는 계속 200 반환
결과

- `checks: 100.00%`
- `http_req_failed: 0.00%`
- `iterations: 2380`
- `http_reqs: 2381`
- 계산: 2380 iterations + setup dev-token 1 = 1201
- `http_req_duration p95: 17.54ms`
참고
GitHub - seunzu/plantify-msa-auth-refactor: JWKS 기반 분산 인증 설계 및 Auth SPOF 제거
JWKS 기반 분산 인증 설계 및 Auth SPOF 제거. Contribute to seunzu/plantify-msa-auth-refactor development by creating an account on GitHub.
github.com
'💻 > 프로젝트' 카테고리의 다른 글
| [PlantiFy/v3] MSA 환경에서 결제 시스템 리팩토링 - 리팩토링 개요 / 구조 재설계와 정합성 문제 해결 (0) | 2026.02.05 |
|---|---|
| [PlantiFy] 숲 꾸미기 & 캐시 서비스 - 아이템 구매 시 캐시 차감 동시성 처리 (0) | 2025.11.28 |
| [PlantiFy] 숲 꾸미기 서비스 - UI 행위 단위 GraphQL 설계 (0) | 2025.11.28 |
| [PlantiFy/v2] MSA 환경에서 결제 시스템 구축하기 2 - Redis 분산 락과 멱등성 설계로 동시성 리팩토링 (0) | 2025.11.21 |
| [PlantiFy] MSA 환경에서 Kakao Social Login 구현하기 - 서비스 개요 / Auth 병목 문제와 JWKS 기반 분산 인증 구조 개선 (0) | 2025.11.14 |