시작하며
안녕하세요. 개발자 stark입니다!
이번 포스팅에서는 gRPC를 사용한 MSA 프로젝트에 서버 간 인증 기능을 구현해 볼 것입니다. MSA 프로젝트에서는 각 서버 간의 통신이 매우 빈번하게 발생합니다. 그래서 통신에 대한 적절한 보안을 적용시켜줘야 합니다. 근데 가끔 이런 생각이 들기도 합니다. 어차피 aws 같은 클라우드 내부의 private 네트워크에서만 호출하도록 설계할 것 아닌가?
맞습니다! 그래도 보안은 설정해 주시는 게 좋습니다.
왜냐하면 서버 간 통신에서 private 네트워크라고 해서 자동으로 외부 위협이 없다고 가정하기에는 무리가 있기 때문입니다. 내부 통신에서도 악의적인 접근이나 데이터 유출, 인가 문제 등 보안 위협이 존재할 수 있습니다. 또한, MSA 구조에서는 서비스가 분산되어 있기 때문에, 각 서비스 간의 인증과 인가, 데이터 암호화를 철저히 하는 것이 필수적입니다. 따라서, 서버 간 통신에도 반드시 보안을 설정하는 것이 안전하고 바람직한 방법입니다.
근데 우리는 지금 gRPC를 사용해서 MSA를 구축하고 있습니다. 그럼 일반 http와는 다를 텐데 어떤 식으로 인증을 적용시켜야 할까요? 저는 바로 gRPC 인터셉터를 활용해서 인증을 적용했습니다. 제가 이번 포스팅에 그 과정을 상세히 설명드릴 예정입니다!
시작하기 전 지금까지 구성한 gRPC + MSA 프로젝트를 봅시다. (매우 작고 귀엽습니다 ㅎㅎ) 아직은 서버 간 통신에 인증 기능을 구현하지 않았다는 것을 알 수 있습니다.
이번 시리즈의 목차입니다. (모든 코드는 각 시리즈 포스팅에 github 주소가 적혀있습니다!)
1. SpringBoot gRPC 서버 구성하기 (회원가입, 조회 api 설계)
2. SpringBoot gRPC 클라이언트 구성하기 (회원 조회 feignClient, gRPC 클라이언트 구성)
(현 게시글) 3. MSA 서버 간 인증 적용하기 (jwt 서버 토큰 구성, gRPC 인터셉터 적용)
4. locust로 http와 gRPC의 성능 비교하기
이전 포스팅입니다! (grpc 클라이언트 구성)
[MSA] SpringBoot에 gRPC 클라이언트 구성하기
시작하며안녕하세요. 개발자 stark입니다. 이전 포스팅에서는 gRPC 서버를 구성해 봤습니다. 이번에는 gRPC 클라이언트 서버를 구성해 봅시다.지금 구성중인 프로젝트는 MSA이기 때문에 최소 2개의
curiousjinan.tistory.com
이제부터 인증을 적용시켜 봅시다.Let's go~~
어차피 Spring Security가 인증하는 거 아냐?
제가 처음 gRPC를 도입했을 때 gRPC는 http 요청 기반이니까 spring security가 알아서 처리해주지 않을까? 이런 생각을 했었습니다. 그러나 이것은 완전히 어리석은 생각이었습니다. 왜냐하면 실제 동작은 전혀 그렇지 않았기 때문입니다. 그 이유는 Feign 요청과 gRPC 요청의 전달 방식이 다르기 때문이었습니다.
Feign 요청은 HTTP 기반으로 동작합니다.
Feign은 기본적으로 HTTP API 호출을 수행합니다. Spring Boot 애플리케이션에서 Spring Security를 설정해 두었다면, Feign을 통해 들어오는 HTTP 요청도 일반 HTTP 요청과 마찬가지로 Spring Security의 필터 체인을 거치게 됩니다. 즉, 인증이나 인가 관련 설정이 있다면 Feign 요청에도 동일하게 적용됩니다.
근데 gRPC 요청은 조금 다릅니다.
gRPC는 HTTP/2를 사용하지만, REST API처럼 Spring MVC의 Filter Chain에 의존하지 않기 때문에 Spring Security가 기본적으로 적용되지 않습니다. 그래서 gRPC 서버에서 보안 처리를 하고자 한다면 별도의 gRPC 인터셉터를 구현하여, 요청의 인증 및 인가 검증을 직접 처리해야 합니다.
- 예를 들어, gRPC 메타데이터(Metadata)에 포함된 토큰을 인터셉터에서 추출하여 인증을 수행하고, 필요한 경우 Spring Security의 AuthenticationManager를 활용해 인증 객체를 생성한 후, SecurityContextHolder에 설정하는 방식으로 연동할 수 있습니다. 또는, grpc-spring-boot-starter 같은 라이브러리를 활용하면 Spring Security와의 통합을 좀 더 수월하게 구현할 수 있는 옵션도 고려해 볼 수 있습니다. (이 방식을 사용할 것입니다)
즉, Feign 요청은 HTTP API 요청으로 간주되어 Spring Security가 정상 동작하지만, gRPC 요청은 별도의 보안 인터셉터를 통해 직접 보안 로직을 구현해야 합니다. (그래서 제가 이번 포스팅을 작성하는 것입니다 ㅎㅎ)
gRPC 클라이언트에 인터셉터 추가하기
지금부터 gRPC 클라이언트 요청에 인증 토큰을 심어볼 것입니다.
gRPC 요청에 인터셉터를 적용하고 Metadata 객체의 값을 직접 설정할 수 있습니다. 저는 이 Metadata 객체에 서버 인증 토큰(jwt)을 넣어줄 것입니다. 여기까지 생각했다면 다음으로는 토큰을 어떻게 구성(생성)할지 고민해야 합니다. 일반적인 인증 과정에서는 유저가 로그인에 성공하면 유저 정보를 담은 토큰(jwt)을 생성하고 발급하지만 지금은 유저가 아니라 서버 간의 통신을 인증하는 토큰이므로 요청을 보내고 있는 서버의 정보를 담은 토큰(jwt)을 생성해야 합니다.
코드를 통해 알아봅시다. 먼저 작성 완료된 gRPC 인터셉터 코드를 봅시다.
이 인터셉터 클래스는 ServerTokenUtil과 ServerProperties 빈을 주입받아서 사용합니다. 이 빈들은 서버 토큰(jwt)을 생성하는 데 사용됩니다. 그리고 Metadata.Key<String>를 필드에 선언했습니다. 일단 인터셉터 코드의 구성을 이해하고 다음 목차에서 Metadata에 대한 상세 정보를 알아봅시다. (하단의 목차를 먼저 확인하시고 다시 돌아오셔도 됩니다.)
아래의 인터셉터 코드를 보시면 서버토큰(jwt)을 생성한 뒤 이것을 interceptCall() 메서드 내부에서 호출되는 start() 메서드의 2번째 매개변수인 Metadata 객체에 담아주고 있습니다. 코드를 상세히 보면 Metadata 객체에 put() 메서드로 토큰 정보를 담아주는데 이때 필드에 선언했던 SERVER_AUTH_KEY 객체를 key로 사용하고 value에는 생성한 서버 토큰(jwt)을 담아줍니다.
@RequiredArgsConstructor
public class GrpcServerAuthenticationInterceptor implements ClientInterceptor {
private final ServerTokenUtil serverTokenUtil;
private final ServerProperties serverProperties;
private static final Metadata.Key<String> SERVER_AUTH_KEY =
Metadata.Key.of("Server-Authorization", Metadata.ASCII_STRING_MARSHALLER);
/**
* @apiNote 서버 토큰을 생성하여 요청 헤더에 추가하는 인터셉터
*/
@Override
public <ReqT, RespT> ClientCall<ReqT, RespT> interceptCall(MethodDescriptor<ReqT, RespT> method,
CallOptions callOptions,
Channel next) {
// 다음 채널을 호출하여 ClientCall을 가져옵니다
return new ForwardingClientCall.SimpleForwardingClientCall<ReqT, RespT>(
next.newCall(method, callOptions)) {
@Override
public void start(Listener<RespT> responseListener, Metadata headers) {
// 서버 토큰 생성
String token = serverTokenUtil.generateServerToken(
serverProperties.getType(),
serverProperties.getId()
);
// 서버 토큰을 헤더에 추가
headers.put(SERVER_AUTH_KEY, "Server " + token);
// 다음 ClientCall을 호출
super.start(responseListener, headers);
}
};
}
}
서버 토큰을 생성하는 유틸 클래스를 봅시다.
저는 프로토타입을 만들었기 때문에 구성이 엄청 간단합니다. 실무에 사용하기 위해서는 지금보다 훨씬 더 보안 작업을 해서 코드를 작성해야 할 것입니다. 이 클래스에 선언된 generateServerToken() 메서드는 서버 설정값을 받아서 jwt를 만드는 게 다입니다.
@Component
public class ServerTokenUtil {
private static final String SECRET_KEY_STRING = "server_specific_secret_key_much_longer_than_user_token_key_for_security";
private SecretKey secretKey;
private final long expiration = 86400000; // 24시간 (서버 토큰은 더 긴 유효기간)
@PostConstruct
public void init() {
secretKey = Keys.hmacShaKeyFor(SECRET_KEY_STRING.getBytes());
}
/**
* @param serverType 서버 타입
* @param serverId 서버 ID
* @return 서버 토큰
* @apiNote 서버 토큰을 생성합니다.
*/
public String generateServerToken(ServerType serverType, String serverId) {
return Jwts.builder()
.subject(serverId)
.claim("serverType", serverType.getCode())
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + expiration))
.signWith(secretKey, Jwts.SIG.HS256)
.compact();
}
}
근데 그 서버 정보는 어디서 가져올까요?
바로 application.yml에 아래와 같이 작성해 둔 서버 정보를 가져오게 됩니다. 두 번째 코드에서처럼 @ConfigurationProperties 어노테이션을 통해 app.server의 값을 읽어 들입니다. (간단하쥬?)
app:
server:
type: CLIENT_SERVER # 또는 RESOURCE_SERVER
id: client-server-1 # 또는 resource-server-1
@Getter
@ConfigurationProperties(prefix = "app.server") // application.yml의 'server' 프리픽스와 매칭
public class ServerProperties {
private final ServerType type; // server.type과 자동으로 매핑됨
private final String id; // server.id와 매핑됨
// 생성자 바인딩 사용
public ServerProperties(ServerType type, String id) {
this.type = type;
this.id = id;
}
}
자 이제 열심히 만든 인터셉터를 빈 등록해 봅시다.
각 인터셉터 클래스에서 바로 @Component로 빈 등록하는 방식도 있지만 저는 한 개의 기능에 대해 여러 빈 객체가 만들어질 수 있는 경우에는 Config 클래스 한 곳에서 각 빈 객체를 등록하도록 해서 전역 관리하는 방식이 편하더군요 ㅎㅎ 그래서 Config 클래스를 만들고 내부에 빈을 등록하곤 합니다. (이건 완전히 개인 취향입니다!!)
그래서 아래처럼 설정 클래스를 만들어주었고 내부에서는 @GrpcGlobalClientInterceptor 어노테이션을 사용해서 빈 등록을 해주었습니다.
@RequiredArgsConstructor
@Configuration
public class GrpcClientConfig {
private final ServerTokenUtil serverTokenUtil;
private final ServerProperties serverProperties;
@GrpcGlobalClientInterceptor
public ClientInterceptor serverTokenInterceptor() {
// 새로 추가한 서버 간 인증 토큰을 처리하는 인터셉터
return new GrpcServerAuthenticationInterceptor(serverTokenUtil, serverProperties);
}
}
인터셉터 어노테이션은 이렇게 @Component, @Bean을 모두 가지고 있습니다 ㅎㅎ
import io.grpc.ClientInterceptor;
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Component
@Bean
public @interface GrpcGlobalClientInterceptor {
}
(중요) 메타데이터(Metadata)가 뭘까?
gRPC에서 사용되는 Metadata 클래스는 gRPC 통신에서 HTTP의 헤더와 유사한 메타데이터를 다루기 위한 핵심 객체입니다. gRPC는 요청과 응답 시 추가적인 정보를 주고받기 위해 이 메타데이터를 사용하며, 이 정보는 키-값 쌍으로 저장됩니다.
제가 선언한 인터셉터 코드에서 Metadata.Key.of("Server-Authorization", Metadata.ASCII_STRING_MARSHALLER)는 "Server-Authorization"이라는 이름의 키(Key)를 생성하여, 이 키를 통해 서버 인증 토큰과 같은 정보를 요청 헤더에 추가하고 전달할 수 있게 합니다. 즉, Metadata는 클라이언트와 서버가 gRPC 호출 과정에서 필요한 부가 정보를 안전하게 교환하는 역할을 합니다.
제가 이전에 SNS, SQS를 사용할 때 데이터를 전달하던 객체가 있었는데 굉장히 비슷한 역할을 하는 객체가 gRPC에도 있었습니다! 그럼 무엇인지는 알았으니 많이 생략된 코드를 한번 볼까요? 조금 더 설명드리고 싶은 부분이 있습니다.
package io.grpc;
@NotThreadSafe
public final class Metadata {
private static final Logger logger = Logger.getLogger(Metadata.class.getName());
// 이 값을 우리는 필드에서 사용합니다.
public static final AsciiMarshaller<String> ASCII_STRING_MARSHALLER =
new AsciiMarshaller<String>() {
@Override
public String toAsciiString(String value) {
return value;
}
@Override
public String parseAsciiString(String serialized) {
return serialized;
}
};
// 이 외에도 엄청난 코드들이 존재합니다.
// 내부의 Key 클래스가 가진 of 메서드로 우리는 Metadata를 생성합니다.
@Immutable
public abstract static class Key<T> {
/**
* Creates a key for an ASCII header.
*
* @param name Must contain only the valid key characters as defined in the class comment. Must
* <b>not</b> end with {@link #BINARY_HEADER_SUFFIX}
*/
public static <T> Key<T> of(String name, AsciiMarshaller<T> marshaller) {
return of(name, false, marshaller);
}
}
}
자 우리는 이 코드에 있는 Key 클래스 내부의 of() 메서드를 사용하고 있습니다. 그리고 2번째 매개변수가 AsciiMarshaller<T>입니다. 아래 그 코드를 가져와봤는데 이것도 Metadata 클래스 내부에 선언된 인터페이스입니다. 이름이 굉장히 생소하지 않나요? 대부분 오픈소스에는 익숙한 이름이 보이는데 이런 이름은 처음 봤습니다.
@NotThreadSafe
public final class Metadata {
public interface AsciiMarshaller<T> {
String toAsciiString(T value);
T parseAsciiString(String serialized);
}
}
그래서 더 궁금증이 생겼습니다. 아래의 인터셉터 코드를 다시 봅시다.
적어도 제가 Header에 담아줄 Metadata의 key를 생성할 때 어떤 매개변수로 넣고 있는지는 알고 있어야겠죠? 그래야 나중에 통신에서 문제가 생겼을 때 이게 원인인지 생각이라도 할 수 있을 테니까요! 그래서 제가 알아봤습니다.
// 이 코드는 제가 선언한 인터셉터 코드입니다.
@RequiredArgsConstructor
public class GrpcServerAuthenticationInterceptor implements ClientInterceptor {
// ...
private static final Metadata.Key<String> SERVER_AUTH_KEY =
Metadata.Key.of("Server-Authorization", Metadata.ASCII_STRING_MARSHALLER);
// ...
}
// 이 값을 위의 인터셉터의 Metadata.Key.of()의 두번째 매개변수로 사용합니다.
public static final AsciiMarshaller<String> ASCII_STRING_MARSHALLER =
new AsciiMarshaller<String>() {
@Override
public String toAsciiString(String value) {
return value;
}
@Override
public String parseAsciiString(String serialized) {
return serialized;
}
};
이 생소한 단어로 만들어진 ASCII_STRING_MARSHALLER라는 매개변수는 gRPC에서 메타데이터를 직렬화(serialize) 및 역직렬화(deserialize) 하기 위해 사용하는 AsciiMarshaller<String>의 구현체였습니다. (위의 코드에 적어두었습니다!)
조금 더 설명드리자면 gRPC는 통신 시 추가 정보를 메타데이터로 전달하는데, 이때 객체를 문자열 형식(ASCII)으로 변환해 보내거나, 받은 문자열을 다시 객체로 복원할 필요가 있습니다. 여기서 제공된 마샬러(MARSHALLER)는 입력된 문자열을 그대로 ASCII 문자열로 변환(toAsciiString)하고, 받은 ASCII 문자열을 그대로 반환(parseAsciiString)하는 간단한 역할을 수행합니다.
즉, 이것은 별도의 복잡한 인코딩이나 디코딩 로직 없이 문자열 데이터를 그대로 주고받기 위해 사용되는 코드였습니다.
gRPC 서버에도 인터셉터를 구성하자
gRPC 클라이언트 프로젝트에 인터셉터 구성을 완료했다면 gRPC 서버 프로젝트에도 인터셉터를 만들어야 합니다.
이번 코드는 이전보다 더 빠르게 이해하실 수 있을 것입니다. 왜냐하면 클라이언트 인터셉터 코드와 매우 유사하기 때문입니다. Metadata에 대한 필드(key)가 선언되어 있고 ServerTokenUtil과 ServerAuthenticationService를 주입받고 있습니다.
여기서 조금 다른 점은 서버의 인터셉터는 받은 서버 토큰(jwt)을 꺼내서 검증 작업을 한다는 것입니다. 또한 class명은 인터셉터인데 실제로는 implements로 인터셉터 코드를 구현하고 있지 않다는 것입니다(이름은 바꾸면 됩니다 ㅎㅎ). 대신 GrpcAuthenticationReader라는 인터페이스를 스프링 빈으로 등록하고 있습니다.
@Slf4j
@RequiredArgsConstructor
@Configuration
public class GrpcServerTokenInterceptor {
private final ServerTokenUtil serverTokenUtil;
private final ServerAuthenticationService serverAuthenticationService;
private static final Metadata.Key<String> SERVER_AUTH_KEY =
Metadata.Key.of("Server-Authorization", Metadata.ASCII_STRING_MARSHALLER);
/**
* @return gRPC 인증 리더
* @apiNote gRPC 서버에서 사용할 인증 리더를 빈으로 등록합니다.
* 이 Reader를 빈으로 등록하면 gRPC 요청이 발생하면 내부적으로 동작하는 인터셉터에서 호출되어 jwt를 추출하여 인증을 수행합니다.
* gRPC 인터셉터 내부의 interceptCall 메서드가 호출되면서 사용됩니다. (첫번째 try-catch 블록)
*/
@Bean
public GrpcAuthenticationReader grpcAuthenticationReader() {
List<GrpcAuthenticationReader> readers = new ArrayList<>();
// 서버 토큰 처리를 위한 커스텀 리더
readers.add((context, headers) -> {
try {
String authHeader = headers.get(SERVER_AUTH_KEY);
if (authHeader == null || !authHeader.startsWith("Server ")) {
log.debug("서버 인증 헤더가 없거나 잘못된 형식입니다");
return null;
}
String token = authHeader.substring(7);
ServerTokenClaims claims = serverTokenUtil.validateAndGetClaims(token);
log.trace("서버 토큰 인증 시도 - 클레임: {}", claims);
return serverAuthenticationService.authenticateServer(claims);
} catch (Exception e) {
log.info("서버 토큰 인증 실패: {}", e.getMessage());
return null;
}
});
return new CompositeGrpcAuthenticationReader(readers);
}
}
여기서도 ServerTokenUtil 클래스를 사용합니다.
다만 이번에는 조금 다른 게 있습니다. 바로 이 유틸 클래스 내부에는 토큰을 생성하는 generateServerToken()이 아니라 validateAndGetClaims()라는 메서드가 선언되어 있다는 것입니다. 이 메서드는 토큰을 추출해서 서버토큰 전용 Claims를 생성합니다.
@Component
public class ServerTokenUtil {
private static final String SECRET_KEY_STRING = "server_specific_secret_key_much_longer_than_user_token_key_for_security";
private SecretKey secretKey;
private final long expiration = 86400000; // 24시간 (서버 토큰은 더 긴 유효기간)
@PostConstruct
public void init() {
secretKey = Keys.hmacShaKeyFor(SECRET_KEY_STRING.getBytes());
}
/**
* @param token 서버 토큰
* @return 서버 토큰의 클레임
* @apiNote 서버 토큰을 검증하고 클레임을 추출합니다.
*/
public ServerTokenClaims validateAndGetClaims(String token) {
try {
// 서버 토큰의 클레임을 추출
var claims = Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload();
// 서버 토큰의 서버 타입과 서버 ID를 추출
return new ServerTokenClaims(
ServerType.valueOf(claims.get("serverType", String.class)),
claims.getSubject()
);
} catch (Exception e) {
throw new IllegalArgumentException("Invalid token: " + token);
}
}
}
아래 코드가 바로 위에서 생성되는 ServerTokenClaims입니다!
서버토큰(jwt)을 받아서 전용 인증 토큰 객체로 만들어준 것이라고 생각해 주시면 됩니다 ㅎㅎ
package com.demo.grpc.config.security.server;
public record ServerTokenClaims(
ServerType serverType,
String serverId
) {
// 생성자에서 기본 검증
public ServerTokenClaims {
if (serverType == null) {
throw new IllegalArgumentException("Server type cannot be null");
}
if (serverId == null || serverId.trim().isEmpty()) {
throw new IllegalArgumentException("Server ID cannot be null or empty");
}
serverId = serverId.trim(); // 정규화
}
}
다음으로는 인증 서비스 클래스를 봅시다. (핵심입니다!)
이 코드에는 조금 로직이 많아서 보기 힘드실 것입니다. 제가 extract method로 전부 메서드 추출을 해놔서 그런데 쉽게 이해하기 위해서는 authenticateServer() 메서드에 주석으로 적어둔 1~4번만 확인해 주시면 됩니다!
@RequiredArgsConstructor
@Service
public class ServerAuthenticationService {
private final ServerProperties serverProperties;
public Authentication authenticateServer(ServerTokenClaims claims) {
// 1. 서버 타입 유효성 검증
if (!isValidServerType(claims.serverType())) {
throw new ServerAuthenticationException("Invalid server type: " + claims.serverType());
}
// 2. 기본적인 claims 검증
validateServerClaims(claims);
// 3. 서버 타입별 특수 검증
if (isCurrentServer(ServerType.CLIENT_SERVER)) {
validateClientServerRequest(claims);
} else if (isCurrentServer(ServerType.AUTH_SERVER)) {
validateAuthServerRequest(claims);
}
// 4. 인증 객체 생성 및 반환
return new ServerAuthenticationToken(claims);
}
// 현재 서버의 타입을 확인하는 메소드
private boolean isCurrentServer(ServerType type) {
return serverProperties.getType() == type;
}
private void validateClientServerRequest(ServerTokenClaims claims) {
// 클라이언트 서버에서의 특정 검증 로직
if (claims.serverType() != ServerType.AUTH_SERVER) {
throw new ServerAuthenticationException("Client server can only accept requests from Auth server");
}
}
private void validateAuthServerRequest(ServerTokenClaims claims) {
// 인증 서버에서의 특정 검증 로직
if (claims.serverType() != ServerType.CLIENT_SERVER) {
throw new ServerAuthenticationException("Auth server can only accept requests from Client server");
}
}
private boolean isValidServerType(ServerType serverType) {
return serverType != null && Arrays.asList(
ServerType.CLIENT_SERVER,
ServerType.AUTH_SERVER
).contains(serverType);
}
private void validateServerClaims(ServerTokenClaims claims) {
// serverId가 null이거나 비어있지 않은지 확인
if (claims.serverId() == null || claims.serverId().trim().isEmpty()) {
throw new ServerAuthenticationException("Server ID is required");
}
// 여기에 추가적인 검증 로직 추가
// 예: 허용된 서버 ID 목록과 대조
// 예: 서버 상태 확인
// 예: 서버 권한 레벨 확인
}
}
참고로 이 로직을 사용하기 위해서는 서버 프로젝트에도 application.yml 설정과 코드를 작성해주셔야 합니다.
app:
server:
type: AUTH_SERVER
id: auth-server-1
@Getter
@ConfigurationProperties(prefix = "app.server") // application.yml의 'server' 프리픽스와 매칭
public class ServerProperties {
private final ServerType type; // server.type과 자동으로 매핑됨
private final String id; // server.id와 매핑됨
// 생성자 바인딩 사용
public ServerProperties(ServerType type, String id) {
this.type = type;
this.id = id;
}
}
자! 여기서 피곤한 부분이 등장합니다. 바로 시큐리티 인증과 관련된 사항입니다.
grpc-spring-boot-starter 라이브러리는 기본적으로 DefaultAuthenticatingServerInterceptor 클래스에 선언된 interceptCall() 메서드를 실행합니다. 그리고 이 메서드에서 호출되는 메서드 중 하나인 authenticate()는 spring security의 인증 메서드입니다.
그래서 만약 이 메서드가 구현되어있지 않으면 인터셉터에서 인증 예외가 발생해서 인증에 실패하게 됩니다. (라이브러리가 자동으로 gRPC 인터셉터를 사용해서 인증하면 spring-security에도 인증하도록 설계가 되어있습니다)
이 문제에 대한 해결방법은 제가 아래의 글에 자세히 적어두었습니다!
gRPC 인터셉터를 사용한 JWT 인증과 Spring Security 연동하기
시작하며안녕하세요. 개발자 Stark입니다. 오늘 포스팅은 제가 야심 차게 준비 중인 2개의 시리즈(트랜잭션, gRPC) 중 gRPC시리즈입니다. 내용을 간단히 설명드리자면 스프링에서 gRPC를 사용하면서
curiousjinan.tistory.com
우선 저는 가장 간단한 해결방법인 AuthenticationManager 인터페이스를 구현해 주었습니다.
내부 로직은 인증 토큰의 종류에 따라 처리됩니다. 저는 토큰 인증이 되었을 때 스프링 시큐리티의 Context에 인증 데이터를 담아줄 생각은 없었기에 관련 로직은 작성해두지 않았습니다. 만약 필요하시다면 로직을 추가하시면 될 것 같습니다.
@Primary
@Slf4j
@RequiredArgsConstructor
@Component
public class GrpcAuthenticationManagerImpl implements AuthenticationManager {
private final ServerAuthenticationService serverAuthenticationService;
/**
* @param authentication 인증 객체
* @return 인증된 객체
* @throws AuthenticationException 인증 예외
* @apiNote 스프링 시큐리티를 위한 gRPC 인증 처리를 수행합니다.
* gRPC 인터셉터 내부의 interceptCall 메서드가 호출되면서 사용됩니다. (두번째 try-catch 블록)
* 이 클래스가 선언되어 있어야만 gRPC 인터셉터 내부의 interceptCall 메서드가 동작할때 "Unsupported authentication Type" 오류가 발생하지 않습니다.
*/
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
try {
log.debug("Authenticating user: {}", authentication.getName());
if (authentication.getPrincipal() instanceof ServerTokenClaims) {
// 이미 앞단계(gRPC 인터셉터)에서 인증된 경우에는 바로 통과
return authentication;
} else {
// 서버 토큰을 이용한 인증 처리
return serverAuthenticationService.authenticateServer((ServerTokenClaims) authentication.getPrincipal());
}
} catch (JwtAuthenticationException e) {
log.error("JWT 인증 실패: {}", e.getMessage());
throw new BadCredentialsException("JWT 인증 실패", e);
} catch (Exception e) {
log.error("인증 처리 중 예상치 못한 오류 발생", e);
throw new AuthenticationServiceException("인증 처리 중 오류 발생", e);
}
}
}
이렇게 인증 설정은 끝났습니다! 이 로직들이 잘 동작한다면 서버 간 통신의 인증은 잘 동작하게 됩니다.
마무리하며
이제 MSA에서 gRPC 서버 간의 서버토큰 인증이 적용 완료되었습니다!
마지막으로 gRPC 요청을 보내봅시다! 깔끔하게 결과 사진 한 장으로 마무리하겠습니다.
gRPC 서버의 인터셉터 코드가 멋지게 동작해서 서버 토큰(jwt)을 잘 가져와서 인증한다는 것을 확인하실 수 있습니다.
생각보다 간단하지 않나요? gRPC 서버 간 인증에는 그냥 인터셉터만 잘 심어주면 됩니다. 스프링 시큐리티에서 필터를 잘 심어주는 것과 동일합니다. 다만 중간에 조금 찾기 힘들었던 문제(라이브러리 기본 동작)도 있긴 했지만 이렇게 동작하는 것을 보면 굉장히 뿌듯합니다.
궁금한 점이 있으시다면 질문 주시면 답변하도록 노력하겠습니다!
긴 글 읽어주셔서 감사합니다. 다음 포스팅인 gRPC vs Feign(http) 성능 테스트에서 만나요 :)
'gRPC' 카테고리의 다른 글
[MSA] SpringBoot에 gRPC 클라이언트 구성하기 (1) | 2025.02.02 |
---|---|
[MSA] SpringBoot에 gRPC 서버 구성하기: 회원 서비스 만들기 (0) | 2025.01.30 |
gRPC 인터셉터를 사용한 JWT 인증과 Spring Security 연동하기 (0) | 2025.01.12 |
개발자를 위한 gRPC 기본 개념 (0) | 2024.10.31 |
[gRPC] SpringBoot3 gRPC 예외 인터셉터 적용 (0) | 2024.07.27 |