서비스끼리의 반복 호출
MSA 환경을 구성하다 보면 각 서비스끼리의 호출이 많이 일어난다.
특히 사용자 정보가 필요한 서비스마다 회원 관리 서비스에서
사용자 정보를 Rest API나 gRPC로 요청하게 되면 너무나 많은 반복호출이 일어나게 된다.
이를 해결하기 위해 각 서비스마다 간소화된 사용자 테이블을 같이 배치하는 전략을 생각할 수도 있다.
하지만 이는 회원 정보 변경, 삭제 등에 따라 이벤트를 수신하면서 회원 정보를 관리해야 하고
데이터 동기화 문제가 발생할 수 있다.
Passport 패턴 기반 사용자 컨텍스트 전파
이를 해결하기 위해 Passport 패턴 기반 사용자 컨텍스트 전파를 사용할 수 있다.
아래의 그림을 실제 토스 테크 블로그에 존재하는 Gateway에서의 Passport 전파 방식이다.
토스에서 Passport는 사용자 기기 정보와 유저 정보를 담은 하나의 토큰으로 관리된다.
앱에서 유저 식별키와 함께 API를 요청하게 되면 Gateway에서 이 키를 토대로
인증 서버에 Passport를 요청하고 Gateway에서 이를 직렬화하여 서비스에 전파하는 구조이다.

위와 같은 구조를 통해 유저 정보가 필요한 서비스는 유저 정보에 대한 호출 없이
Passport 정보를 통하여 유저 정보가 필요한 다른 서비스에서도 유저 정보를 사용할 수 있다.
현재 MSA 프로젝트를 진행하고 있고, 해당 Toss의 아키텍처를 참고하여 구성한 것은 다음과 같다.

Toss의 아키텍처와 유사한 구조를 가지며 아키텍처를 재설계 하면서
여러 시스템이 회원정보를 호출하는 시스템 복잡성과 리소스 낭비를 막을 수 있었다.
해당 동작의 흐름은 다음과 같다.
- Cloud Gateway가 클라이언트의 JWT를 파싱해 userKey를 추출하고, 유효성을 검사한다.
- 검증 성공 시 Auth Service에 userKey와 사용자 Jwt Token으로 Passport를 요청한다.
- Auth Service는 유저 정보를 포함한 Passport를 생성한 후 Gateway로 반환한다.
이 과정에서 gRPC를 사용하면 네트워크 오버헤드를 줄여 응답 속도를 향상시킬 수 있다. - Gateway는 Passport Payload를 직렬화하고 Passport Key와 함께 하위 Service들에게 전파한다.
- 각 서비스는 별도의 회원 API 호출 없이 Passport만으로
사용자 컨텍스트(id, role … )를 해석하여 비즈니스 로직을 수행한다.
이 구조를 적용함으로써 각 서비스에서 개별적으로
Spring Security를 구성해 JWT를 검증할 필요가 없어졌다.
Gateway가 JWT를 검증한 뒤 Passport와 Passport Secret을 발급하고,
각 서비스는 이를 통해 요청의 유효성을 확인한다.
이러한 단일 인증 흐름(JWT 검증 → Passport 발급 → 서비스 전달)을 확립함으로써
전체 요청 처리 과정의 지연 시간, 운영 비용, 복잡도를 크게 줄일 수 있었다.
Spring Cloud Gateway 필터 구성
이제 해당 기능을 수행하는 Spring Cloud Gateway의 Fitler를 간략하게 살펴보면 다음과 같다.
@Component
@RequiredArgsConstructor
@Slf4j
@Order(2)
public class AuthenticationFilter implements GlobalFilter {
@Value("${app.secret.jwt}")
private String secretKey;
private static final String AUTHORIZATION_HEADER = "Authorization";
private static final String BEARER_PREFIX = "Bearer ";
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String path = exchange.getRequest().getURI().getPath();
String token = resolveToken(exchange);
if (token == null){
throw new CustomException(ErrorCode.UNAUTHORIZED);
}
try{
DecodedJWT jwt = JWT.require(Algorithm.HMAC512(secretKey))
.build()
.verify(token);
String key = jwt.getClaim("key").asString();
log.info("Jwt verification successfully. UserKey : {}", key);
exchange.getAttributes().put("key",key);
} catch (TokenExpiredException e){
throw new CustomException(ErrorCode.EXPIRED_TOKEN);
} catch (JWTVerificationException e){
throw new CustomException(ErrorCode.INVALID_TOKEN);
}
return chain.filter(exchange);
}
private String resolveToken(ServerWebExchange exchange) {
String bearerToken = exchange.getRequest().getHeaders().getFirst(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
return bearerToken.substring(BEARER_PREFIX.length());
}
return null;
}
}
AuthenticationFilter는 사용자 요청에 포함된 JWT 토큰의 유효성을 검증하는 필터이다.
이 필터는 @Order(2)로 설정되어 있으며,
그보다 앞선 @Order(1)의 필터는 prefilter로서 요청 URI와 관련된 로깅을 담당한다.
따라서 실질적으로 사용자 인증을 수행하는 첫 번째 필터는 AuthenticationFilter가 된다.
이 필터는 요청에 포함된 JWT 토큰이 유효하지 않거나, 만료되었거나,
존재하지 않는 경우 즉시 에러 코드와 함께 응답을 반환하도록 설계되어 있다.
반면, 정상적인 JWT 토큰일 경우에는 토큰에 포함된 사용자 key(식별자)를 추출하여
SecurityContext 혹은 Request Attribute에 저장한 뒤, 다음 필터 체인으로 요청을 전달한다.
이러한 구조를 통해 인증 로직을 명확히 분리하고,
각 단계에서 발생 가능한 예외를 효율적으로 처리할 수 있다.
@Component
@RequiredArgsConstructor
@Slf4j
@Order(3)
public class PassportFilter implements GlobalFilter {
@Value("${app.secret.passport}")
private String SECRET;
private final PassportGrpcService passportGrpcService;
private final ObjectMapper objectMapper;
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String key = exchange.getAttribute("key");
Passport.PassportResponse passport = passportGrpcService.getPassportInfo(key);
if (passport == null) throw new CustomException(ErrorCode.PASSPORT_ERROR);
PassportResponse passportEntity = new PassportResponse(passport);
String payload;
try{
payload = objectMapper.writeValueAsString(passportEntity);
}catch (JsonProcessingException e){
throw new CustomException(ErrorCode.JSON_PROCESSING_ERROR);
}
String encodedPayload = Base64.getEncoder().encodeToString(payload.getBytes(StandardCharsets.UTF_8));
ServerHttpRequest mutatedRequest = exchange.getRequest()
.mutate()
.headers(httpHeaders -> {
httpHeaders.remove("X-Passport-Secret");
httpHeaders.add("X-Passport-Secret", SECRET);
httpHeaders.remove("X-Passport");
httpHeaders.add("X-Passport", encodedPayload);
})
.build();
ServerWebExchange mutatedExchange = exchange.mutate()
.request(mutatedRequest)
.build();
return chain.filter(mutatedExchange);
}
}
AuthenticationFilter에서 사용자의 JWT 토큰이 정상적으로 검증되면,
다음 단계로 회원 서비스(Auth Service) 에 Passport 발급 요청을 보낸다.
이때 통신 속도를 높이기 위해 REST 대신 gRPC를 사용해 요청을 전달하며,
회원 서비스는 사용자 정보를 기반으로 Passport를 발급한다.
발급받은 Passport는 직렬화 과정을 거쳐 Base64로 인코딩한 뒤,
서버에 저장되어 있는 Passport Secret과 함께 HTTP 헤더에 포함시킨다.
이후 이 헤더 정보는 필터 체인을 따라 다음 서비스로 전달된다.
이렇게 하면 각 서비스는 별도의 인증 절차 없이
헤더에 포함된 Passport 정보를 통해 곧바로 사용자 정보를 활용할 수 있다.
결과적으로 인증 과정이 단순해지고, 서비스 간 통신 속도도 크게 향상된다.
다른 Service에서의 Passport 검증 및 사용
위와 같은 흐름으로 요청이 진행될 때,
각 서비스로 전달되는 요청 헤더에는 직렬화된 Passport 값과 Passport Secret이 함께 포함된다.
이후 각 서비스에서는 Passport 인증이 필요한 라우터에
Interceptor를 적용하여 Passport의 유효성을 검증한다.
이를 통해 Gateway에서 발급된 Passport가 실제로 변조되지 않았는지,
그리고 올바른 Secret을 가진 요청인지 확인할 수 있다.
결과적으로 서비스 간 인증 절차가 통합되고,
불필요한 JWT 재검증 과정이 제거되어 시스템 효율성이 향상된다.
@Component
@Slf4j
@RequiredArgsConstructor
public class AuthenticationInterceptor implements HandlerInterceptor {
@Value("${app.secret.passport}")
private String PASSPORT_SECRET_KEY;
private final ObjectMapper objectMapper;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String passportKey = request.getHeader("X-Passport-Secret");
if (passportKey == null || !passportKey.equals(PASSPORT_SECRET_KEY)) throw new CustomException(ErrorCode.UNAUTHORIZED);
String passportString = request.getHeader("X-Passport");
String decodedString = new String(Base64.getDecoder().decode(passportString), StandardCharsets.UTF_8);
Passport passport;
try{
passport = objectMapper.readValue(decodedString, Passport.class);
}catch (JsonProcessingException e){
log.error("Error during parsing passport token - {}, {}",passportString,e.getMessage()) ;
throw new CustomException(ErrorCode.JSON_PROCESSING_ERROR);
}
request.setAttribute("passport", passport);
request.setAttribute("role",passport.getRole());
return true;
}
}
Interceptor에서는 먼저 Gateway에서 전달된 Passport Secret이 존재하는지 확인하고,
해당 값의 유효성을 검증한다. 그 후 헤더에 포함된 직렬화된 Passport 문자열을 역직렬화 하여
HttpServletRequest 객체에 사용자 정보로 저장한다. 이렇게 하면 이후 컨트롤러 단에서
@AuthenticationPrincipal과 유사하게 매개변수로 바로 사용자 정보 객체를 주입받아 활용할 수 있다.
이를 통해 사용자 정보가 필요한 API에서는 별도의 인증 처리 없이
Passport 객체를 통해 신뢰성 있는 사용자 정보를 직접 사용할 수 있다.
또한 Gateway에서 전달된 Secret 값 검증을 통해 요청의 무결성을 보장할 수 있다.
이제 다음으로, 이 과정을 더욱 간결하게 만들어주는 커스텀 어노테이션을 살펴보자.
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface PassportMember {}
@Component
@RequiredArgsConstructor
@Slf4j
public class PassportMemberArgumentResolver implements HandlerMethodArgumentResolver {
@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.hasParameterAnnotation(PassportMember.class)
&& parameter.getParameterType().equals(Passport.class);
}
@Override
public Object resolveArgument(MethodParameter parameter,
ModelAndViewContainer mavContainer,
NativeWebRequest webRequest,
WebDataBinderFactory binderFactory){
HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();
return request.getAttribute("passport");
}
}
이제 컨트롤러에서 Passport 정보를 파라미터로 직접 받을 수 있도록 커스텀 어노테이션을 선언한다.
그리고 HandlerMethodArgumentResolver를 구현한 클래스를 생성하여,
해당 어노테이션이 붙은 파라미터에 대해 Passport 객체를 주입하도록 설정한다.
이렇게 하면 컨트롤러 메서드에서 @PassportMember Passport passport처럼
간단히 매개변수를 선언하기만 해도,
자동으로 Interceptor에서 저장해 둔 Passport 정보를 사용할 수 있게 된다.
@PostMapping("/join/{roomId}")
public ResponseEntity<ApiResponse<ChatRoomResponse>> joinChatRoom(
@PathVariable @DecryptId Long roomId,
@RequestParam("nickname") String nickname,
@PassportMember Passport passport
) throws InterruptedException {
return ResponseEntity.status(201).body(new ApiResponse<>(true,201,
chatRoomJoinFacade.joinChatRoom(roomId,nickname,passport.getKey())));
}
위와 같이 어노테이션을 설정하고 Resolver를 등록해 두면,
컨트롤러에서 Passport 객체를 손쉽게 주입받아 바로 사용할 수 있다.
이제 각 컨트롤러마다 문자열 형태의 Passport를 직접 역직렬화할 필요가 없으며,
단순히 어노테이션 한 줄만으로
Passport에 포함된 사용자 Key나 기타 사용자 정보를 바로 접근할 수 있다.
덕분에 코드가 훨씬 간결해지고, 인증 관련 로직을 컨트롤러에서 완전히 분리할 수 있게 된다.
참고자료
https://toss.tech/article/slash23-server
'IT > Spring boot' 카테고리의 다른 글
| [Spring boot] 암호화된 PK의 복호화 구현 (4) | 2024.10.04 |
|---|---|
| [Spring boot] PK Id 노출을 막는 Rest API (7) | 2024.10.03 |
| [Spring boot] JPA fetch join 2개 이상의 Collection Join 해결법 (1) | 2024.09.23 |
| [Spring boot] JPA N+1 발생 케이스 및 해결 방법 (5) | 2024.09.22 |
| [Spring boot] JPA FetchType (2) | 2024.09.19 |