gRPC

gRPC 인터셉터를 사용한 JWT 인증과 Spring Security 연동하기

Stark97 2025. 1. 12. 01:13

시작하며


안녕하세요. 개발자 Stark입니다. 오늘 포스팅은 제가 야심 차게 준비 중인 2개의 시리즈(트랜잭션, gRPC) 중 gRPC시리즈입니다.

 

내용을 간단히 설명드리자면 스프링에서 gRPC를 사용하면서 JWT 토큰 인증 기능을 구현하기 위해 토큰 인증을 담당하는 grpc 인터셉터를 구현하고 spring security의 SecurityContextHolder와 통합시켰습니다. 서버에 gRPC 요청이 들어오면 요청을 처리하기 전에 gRPC 인터셉터가 호출되면서 (GrpcAuthenticationReader)를 호출하게 됩니다. 저는 이 Reader 클래스를 Jwt 전용으로 커스텀해서 빈으로 등록하여 매번 요청을 받을 때마다 제가 만든 빈 클래스가 호출되도록 해서 JWT 인증을 진행하고 있습니다.

 

근데 이상한 현상이 있었는데 header로 JWT를 잘 받고 내부 로직에서도 spring security의 인증객체(Authenticate)를 잘 생성하고 반환하는데 요청을 보낸 gRPC 클라이언트의 콘솔에는 security 인증에 실패했다는 Authenticate fail 로그를 지속적으로 출력하고 있었으며 실제로 클라이언트에서는 서버에서 조회된 데이터도 응답받지 못했습니다.

 

뭔가 시큐리티와 gRPC의 통합이 제대로 안 된 것 같은데 이 현상이 무엇 때문인지가 너무 궁금해서 저는 퇴근을 하자마자 문제 해결방법을 찾으며 밤새 연구를 했습니다. 이 과정에서 gRPC 인터셉터는 어떻게 동작하는지 분석할 수 있었습니다. 항상 느끼는 건 이렇게 직접 찾아가는 과정에서 얻게 된 지식들이 상당히 많다는 것입니다. 그래서 저와 같은 문제를 겪고 계신 분들께 제가 찾은 방법이 도움이 될 수 있으면 좋을 것 같아 내용을 정리하여 공유합니다.

 

열심히 정리 중인 gRPC 시리즈는 조만간 프로젝트 구성부터 "성능 테스트, 인터셉터 설정"으로 공식적으로 찾아뵙도록 하겠습니다. 어떻게 보면 이번 편이 시큐리티 설정입니다. 다들 많이 기대해주세요 ㅎㅎㅎ (글을 작성하고 보니 프로젝트 구성을 먼저 작성할걸 그랬습니다.)

 

이 포스트에서 설명하는 코드는 grpc-spring-boot-starter 프로젝트의 일부로, Apache License 2.0에 따라 제공됩니다.
원본 프로젝트: https://github.com/grpc-ecosystem/grpc-spring
원작자: Michael Zhang (yidongnan@gmail.com)
 

GitHub - grpc-ecosystem/grpc-spring: Spring Boot starter module for gRPC framework.

Spring Boot starter module for gRPC framework. Contribute to grpc-ecosystem/grpc-spring development by creating an account on GitHub.

github.com

 

 

문제상황 이해하기: 대략적인 상황 파악


제가 이 프로젝트를 구성한 이유는 gRPC와 HTTP 요청의 성능 차이가 궁금해서 비교하기 위해서였습니다. 그렇기에 가능한 MSA 환경과 비슷하게 구성하기 위해 (네트워크는 환경은 제외) "gRPC 서버, gRPC 클라이언트 서버" 이렇게 2개의 SpringBoot 프로젝트를 구성했습니다. 

 

각 프로젝트는 역할을 가지고 있습니다. 먼저 gRPC 서버는 회원가입, 로그인, jwt 재발행 등을 처리하는 일반적인 "회원 서버"의 역할을 합니다. 그리고 gRPC 클라이언트 서버는 회원 정보를 요청해서 받아가는 외부의 다른 MSA 서버들을 표현했으며 필요시 회원정보를 "조회"하는 역할을 합니다. 참고로 각 서버들은 gRPC 통신뿐만 아니라 일반적인 http 요청 컨트롤러도 포함하고 있습니다. (회원 생성, 회원 조회 등)

 

문제상황은 gRPC 클라이언트 서버에서 gRPC 서버에 회원조회 요청을 보냈을 때 발생했습니다. 일반적으로 gRPC는 서버 간 통신에 사용됩니다. 저 또한 그렇게 가정하고 useCase를 구성하였습니다. 그 내용을 간단히 설명드리겠습니다.

 

1. 사용자가 회원 조회 요청을 합니다. (웹 기준: 회원 상세정보 조회 버튼 클릭)

2. front에서는 gRPC 클라이언트 서버에 "회원 조회 http 요청"을 보냅니다. (httpie 활용해서 테스트)

3. 회원 조회 컨트롤러가 호출되면 내부에서 gRPC 서버조회 요청을 보냄 (http가 아닌 gRPC요청)

4. gRPC 서버는 gRPC 클라이언트의 요청을 받아서 데이터를 조회하고 응답을 gRPC 클라이언트에 반환

5. gRPC 클라이언트는 gRPC 서버로부터 받은 응답을 가공 또는 조립해서 http 메서드에 최종 전달 (전용 응답객체로 변환)

6. http 메서드는 받은 응답객체를 완성해서 사용자에게 응답 (메서드 호출 1 cycle 종료)

 

즉, http 요청을 받아서 메서드 내부에서 다른 서버와의 통신을 gRPC로 해서 회원정보를 받아오는 상황을 구현한 것입니다.

 

이제 어떻게 이것이 이루어지는지 그 과정을 살펴봅시다.

저는 http 요청을 보낼 때 header에 JWT를 담아서 요청을 보냈습니다. 그러면 요청을 받은 gRPC 클라이언트 서버에서는 spring security의 jwtFilter가 동작해서 header에서 bearer 토큰(JWT)을 추출해서 스레드의 SecurityContextHolder를 세팅합니다. 그러고 나서 메서드 내부에서 gRPC 서버에 요청을 보내게 될 것이고 이때 gRPC 클라이언트의 요청이 호출되기 직전에 gRPC 인터셉터가 동작하면서 jwt토큰을 추출해서 Metadata(io.grpc 패키지에 있는 gRPC 전용 클래스)를 세팅하고 header에 담아서 보냅니다.

 

대략 이런식으로 인터셉터 로깅이 됩니다. (아래는 실제 로깅된 정보입니다)

gRPC 요청 헤더 생성전: Metadata()
gRPC 요청 헤더 생성후: Metadata(authorization=Bearer eyJhbGciOiJIUzI1NiJ9

이렇게 Metadata에 jwt를 잘 담아서 gRPC 서버에 조회 요청을 보내면 gRPC 서버에서는 이 요청을 받게 됩니다. 그러면 gRPC 서버에서도 인터셉터를 통해 요청 header를 검사해서 그 안에 담겨있는 JWT 토큰을 꺼내서 gRPC 서버의 SecurityContextHolder에 인증객체를 세팅하는 작업이 진행됩니다.

 

 

문제상황 이해하기: 코드를 섞은 설명


이제 코드를 섞어서 이해해 봅시다. gRPC 서버가 gRPC 클라이언트로부터 요청을 받으면 GrpcJwtSecurityConfig 클래스에 선언해둔 GrpcAuthenticationReader(커스텀 Reader)가 동작하면서 header에 담긴 jwt를 꺼내서 spring security의 Authentication객체를 만들어서 반환합니다. 그러면 스프링 시큐리티 인증이 완료되고 인증에 성공했으니 gRPC서버의 회원 조회 메서드가 호출되어 응답을 생성하고 gRPC 클라이언트로 응답을 반환해야 합니다.


먼저 GrpcAuthenticationReader에서 jwt를 꺼내는 과정을 로그로 남겨봤습니다.

토큰을 받음 - 토큰: eyJhbGciOiJIUzI1.....
JWT 인증 시작 - 토큰: eyJhbGciOiJIUzI1Ni...
JWT에서 추출한 사용자 이름: stark...
사용자 정보 로드 완료 - 권한: [ROLE_USER]

여기까지 확인하고 잘 동작한다고 생각해서 엄청 뿌듯했습니다. 근데 여기서 정말 큰 문제가 생겼습니다. 바로 gRPC 서버의 회원조회 메서드가 호출되지 않은 것입니다. 그래서 요청을 보낸 gRPC 클라이언트 서버에서는 아래의 에러 응답만 출력되었습니다. 

 

io.grpc.StatusRuntimeException: UNAUTHENTICATED: Authentication failed

 

...????? failed 라니.. 생각해 보니 io.grpc 패키지의 오류인 것으로 보아 gRPC관련 인증 실패인 것으로 예상했습니다. 제가 여기서 문제점을 바로 파악하지 못했던 이유는 "gRPC 클라이언트 서버 -> gRPC 서버" 이렇게 데이터를 보냈을 때 gRPC서버가 그 요청을 받은 순간을 디버깅해 보니 header에선 JWT를 잘 받았고 내부에서 토큰을 추출해서 사용하는 것에도 전혀 문제가 없었습니다. 위에 적어드린 로그에도 토큰 추출 과정이 잘 남겨져 있었습니다. 그래서 저는 이게 어느 시점에 토큰에 문제가 발생한 것인지를 파악하는 과정이 필요했습니다.

 

많은 고민과 지속적인 디버깅을 하며 저는 한 가지 상황을 떠올렸습니다. "io.grpc 오류가 발생하는 걸로 보아 뭔가 인터셉터 처리를 하는 과정에서 나도 모르게 내부적으로 오류를 발생시킨다!" 즉, jwt 추출작업을 진행하고 세팅하는 과정에서 무언가 예외가 발생하는 순간이 있을 것이라 생각했습니다. 그래서 이제 depp 하게 디버깅을 해봤는데 역시나 jwt 추출작업을 한 뒤에 인터셉터 내부에서 다른 메서드를 호출하며 작업을 진행하는데 이때 토큰(Authenticate) 객체가 null인 상태가 존재해서 "Unsupported authentication Type"이라는 오류가 발생하고 있었습니다.

 

설명이 너무 길었는데 제가 이 과정을 하나하나 정리하며 어떻게 해결해야 할지 정말 많은 고민을 하면서 여러 시도를 해봤습니다. 그리고 그 해결방법을 찾아냈습니다. 자, 이제 문제를 해결하러 가봅시다. Let's go!!

 

 

일단 제가 작성한 gRPC의 JWT 관련 코드를 이해해 봅시다.


모든 코드를 적어드리고 싶지만 내용이 너무 많아서 제가 올려둔 github의 주소를 올려두겠습니다.

gRPC 서버 코드: https://github.com/wlsdks/grpc-server-example

 

GitHub - wlsdks/grpc-server-example: SpringBoot3.x.x 버전 grpc 예제 프로젝트 (gRPC 서버 예시 코드이며 클라이

SpringBoot3.x.x 버전 grpc 예제 프로젝트 (gRPC 서버 예시 코드이며 클라이언트와 함께 봐주세요) - wlsdks/grpc-server-example

github.com

gRPC 클라이언트 코드: https://github.com/wlsdks/grpc-client-example

 

GitHub - wlsdks/grpc-client-example: SpringBoot3.x.x 이상 버전의 grpc 예제 프로젝트 (gRPC 서버 코드와 함께 확

SpringBoot3.x.x 이상 버전의 grpc 예제 프로젝트 (gRPC 서버 코드와 함께 확인해주세요) - wlsdks/grpc-client-example

github.com

저는 먼저 GrpcAuthenticationReader를 활용하여 JWT 인증을 하도록 코드를 작성하였습니다.

  • 참고로 제가 빈으로 등록한 GrpcAuthenticationReader 클래스는 grpc-spring-boot-starter에서 지원하는 익명함수 인터페이스를 구현한 것으로 Bearer관련(JWT) 인증을 간편하게 처리할 수 있도록 도와주는 역할을 합니다.

하단의 공식 가이드를 살펴보시면 관련된 내용을 설명하고 있습니다. (공식 가이드 자료입니다.)

https://yidongnan.github.io/grpc-spring-boot-starter/en/versions.html

 

Versions

Spring Boot starter module for gRPC framework.

yidongnan.github.io

@Slf4j  
@RequiredArgsConstructor  
@Configuration  
public class GrpcSecurityConfig {  
  
    private final JwtAuthenticationService jwtAuthenticationService;  
  
    @Bean  
    public GrpcAuthenticationReader grpcAuthenticationReader() {  
        List<GrpcAuthenticationReader> readers = new ArrayList<>();  
  
        // Bearer 토큰을 처리할 수 있는 리더를 추가합니다  
        readers.add(new BearerAuthenticationReader(token -> {  
            try {  
                log.info("토큰을 받음 - 토큰: {}", token);  
                Authentication auth = jwtAuthenticationService.authenticateToken(token);  
  
                // 명시적으로 인증 성공 여부 확인  
                if (auth != null && auth.isAuthenticated()) {  
                    return auth;  
                } else {  
                    return null;  
                }  
            } catch (JwtAuthenticationException e) {  
                log.info("토큰 인증 실패: {}", e.getMessage());  
                return null;  
            }  
        }));  
  
        // 모든 리더를 하나의 복합 리더로 결합합니다  
        return new CompositeGrpcAuthenticationReader(readers);  
    }  
  
}

위의 메서드에서 호출하는 jwtAuthenticationService 클래스 내부의 메서드 구조는 다음과 같습니다.

  • 단순히 토큰을 검증하고 꺼내서 토큰을 생성합니다. (유틸 클래스는 깃허브에서 확인 가능합니다)
  • 모든 과정을 하나하나 확인하기 위해서 로그를 조금 많이 남겨두었습니다 ㅎㅎ
@Slf4j  
@RequiredArgsConstructor  
@Service  
public class JwtAuthenticationService {  
  
    private final JwtUtil jwtUtil;  
    private final UserDetailsService userDetailsService;  
  
    /**  
     * @param token JWT 토큰  
     * @return Authentication 객체  
     * @apiNote JWT 토큰을 검증하고 Authentication 객체를 생성합니다.  
     */    
     public Authentication authenticateToken(String token) {
        try {
            log.info("JWT 인증 시작 - 토큰: {}", token);

            // 토큰 유효성 검사
            if (!jwtUtil.isTokenValid(token)) {
                log.warn("JWT 토큰이 유효하지 않음");
                throw new JwtAuthenticationException("유효하지 않은 JWT 토큰");
            }

            // 사용자 이름 추출
            String username = jwtUtil.extractUsername(token);
            log.info("JWT에서 추출한 사용자 이름: {}", username);

            // 사용자 정보 로드
            UserDetails userDetails = userDetailsService.loadUserByUsername(username);
            log.info("사용자 정보 로드 완료 - 권한: {}", userDetails.getAuthorities());

            return new GrpcAuthenticationToken(
                    userDetails,
                    token,
                    userDetails.getAuthorities()
            );
        } catch (JwtAuthenticationException e) {
            log.error("JWT 인증 실패: {}", e.getMessage());
            throw e;
        } catch (Exception e) {
            log.error("JWT 인증 중 알 수 없는 오류 발생: {}", e.getMessage(), e);
            throw new RuntimeException("JWT 인증 중 오류 발생", e);
        }
    }
  
}

저는 Spring Security 전용 인증 토큰 클래스를 커스텀하여 선언하였습니다.

  • 사실 이 클래스는 기본 인증토큰을 상속받은 다음 setAuthenticatedtrue로 변경하면 발생했던 인증 오류가 해결될까 싶어서 확인해 보려고 만든 커스텀 토큰인데 내부에 방어로직을 만들어두기도 좋고 이렇게 이름을 이해하기 쉽게 만들어두니 관리하기 좋은 것 같다는 생각이 들어서 그대로 사용하고 있습니다. (자유롭게 변경하셔도 되며 기본 UsernamePasswordAuthenticationToken을 사용하셔도 전혀 문제 되지 않습니다.)
public class GrpcAuthenticationToken extends UsernamePasswordAuthenticationToken {

    public GrpcAuthenticationToken(Object principal,
                                   Object credentials,
                                   Collection<? extends GrantedAuthority> authorities) {
        super(principal, credentials, authorities);
    }

    @Override
    public void setAuthenticated(boolean isAuthenticated) {
        // 수동 호출 방지
        throw new IllegalArgumentException("setAuthenticated() 호출 금지 - 생성자를 통해 설정하십시오.");
    }

}

다음으로 gRPC 클라이언트의 호출을 받아서 처리하는 gRPC 서버의 메서드를 살펴봅시다.

  • 참고로 gRPC서버와 gRPC클라이언트는 전혀 다른 프로젝트로 구성되어 있으니 주의 바랍니다. (헷갈릴 수 있음)
  • 이 메서드 내부에서도 여러 가지 로깅을 하도록 구성하였습니다.
@Slf4j
@RequiredArgsConstructor
@GrpcService
public class MemberServiceGrpcImpl extends MemberServiceGrpc.MemberServiceImplBase {

    private final MemberRepository memberRepository;

    /**
     * @param request          : 클라이언트로부터 받은 요청
     * @param responseObserver : 클라이언트로부터 받은 요청에 대한 응답을 전송하는 스트림
     * @apiNote 클라이언트로부터 받은 요청에 대한 응답을 전송하는 메서드
     */
    @Override
    public void getMemberById(MemberProto.MemberIdRequest request,
                              StreamObserver<MemberProto.MemberResponse> responseObserver) {
        // 메서드 진입 로깅 추가
        log.info(("gRPC 서버의 getMemberById 메서드 실행 시작 - ID: {}"), request.getId());

        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        log.info("gRPC 서버 메서드 내부 시큐리티 인증 정보: {}", authentication);

        try {
            MemberEntity member = memberRepository.findById(request.getId())
                    .orElseThrow(() -> {
                        log.error("회원을 찾을 수 없음 - ID: {}", request.getId());
                        return new StatusRuntimeException(
                                Status.NOT_FOUND.withDescription("Member not found")
                        );
                    });

            log.info("회원 조회 성공 - 이메일: {}", member.getEmail());

            // 기존 응답 로직
            MemberProto.MemberResponse response = MemberProto.MemberResponse.newBuilder()
                    .setId(member.getId())
                    .setEmail(member.getEmail())
                    .setName(member.getName())
                    .build();

            responseObserver.onNext(response);
            responseObserver.onCompleted();
        } catch (Exception e) {
            log.error("getMemberById 메서드 실행 중 오류 발생", e);
            responseObserver.onError(
                    Status.INTERNAL
                            .withDescription(e.getMessage())
                            .asRuntimeException()
            );
        }
    }

}

 

 

뭐가 문제일까? 왜 시큐리티 인증문제가 발생할까? (요청 분석)


문제를 파악하기 위해서 gRPC서버 프로젝트의 GrpcSecurityConfig 클래스를 살펴봅시다.

  • 아래에서 빈으로 등록한 GrpcAuthenticationReader 클래스는 gRPC 요청이 들어오면 인터셉터가 호출되면서 내부적으로 로직이 호출됩니다. 그리고 코드를 보면 new BearerAuthenticationReader를 통해 객체를 생성하면서 인자(매개변수)에 Function(함수)를 담아주고 있는데 이 함수가 나중에 인터셉터에서 호출됩니다.
@Slf4j  
@RequiredArgsConstructor  
@Configuration  
public class GrpcSecurityConfig {  
  
    private final JwtAuthenticationService jwtAuthenticationService;  
  
    @Bean  
    public GrpcAuthenticationReader grpcAuthenticationReader() {  
        List<GrpcAuthenticationReader> readers = new ArrayList<>();  
  
        // Bearer 토큰을 처리할 수 있는 리더를 추가합니다  
        readers.add(new BearerAuthenticationReader(token -> {  
            try {  
                log.info("토큰을 받음 - 토큰: {}", token);  
                Authentication auth = jwtAuthenticationService.authenticateToken(token);  
  
                // 명시적으로 인증 성공 여부 확인  
                if (auth != null && auth.isAuthenticated()) {  
                    return auth;  
                } else {  
                    return null;  
                }  
            } catch (JwtAuthenticationException e) {  
                log.info("토큰 인증 실패: {}", e.getMessage());  
                return null;  
            }  
        }));  
  
        // 모든 리더를 하나의 복합 리더로 결합합니다  
        return new CompositeGrpcAuthenticationReader(readers);  
    }  
  
}

이제 빈으로 등록한 GrpcAuthenticationReader 코드를 살펴봅시다.

  • 이 클래스는 grpc-spring-boot-starter에서 제공하는 함수형 인터페이스입니다.
  • 이 인터페이스 내부에는 readAuthentication이라는 메서드 시그니처가 1개 선언되어 있습니다.
@FunctionalInterface  
public interface GrpcAuthenticationReader {  

    @Nullable  
    Authentication readAuthentication(ServerCall<?, ?> call, Metadata headers) throws AuthenticationException;  
  
}

저는 Bearer인 JWT를 사용하기 때문에 이미 구현되어 있는 BearerAuthenticationReader를 사용하였습니다.

  • BearerAuthenticationReader는 생성 시 인자로 Function<String, Authentication>을 받고 있습니다.
  • 이 함수(Function)String을 인자로 받아서 spring security의 Authentication 객체를 반환합니다.
  • 그래서 위에 제가 작성한 코드를 보시면 String인 jwt(token)를 받아서 익명함수 내부에서 jwt 서비스를 호출하여 Authenticate 객체를 생성한 다음 이 값을 반환하고 있는 것을 확인하실 수 있습니다.
@Slf4j  
public class BearerAuthenticationReader implements GrpcAuthenticationReader {  
  
    private static final String PREFIX = BEARER_AUTH_PREFIX.toLowerCase();  
    private static final int PREFIX_LENGTH = PREFIX.length();  
  
    private Function<String, Authentication> tokenWrapper;  
  
    public BearerAuthenticationReader(Function<String, Authentication> tokenWrapper) {  
        Assert.notNull(tokenWrapper, "tokenWrapper cannot be null");  
        this.tokenWrapper = tokenWrapper;  
    }  
  
    @Override  
    public Authentication readAuthentication(final ServerCall<?, ?> call, final Metadata headers) {  
        final String header = headers.get(AUTHORIZATION_HEADER);  
  
        if (header == null || !header.toLowerCase().startsWith(PREFIX)) {  
            log.debug("No bearer auth header found");  
            return null;  
        }  
        // Cut away the "bearer " prefix  
        final String accessToken = header.substring(PREFIX_LENGTH);  
  
        // Not authenticated yet, token needs to be processed  
        return tokenWrapper.apply(accessToken);  
    }  
    
}

인자로 넣어준 이 Function이 어떻게 실행되는지 조금 더 자세히 알아봅시다.

  • gRPC 요청이 들어와서 인터셉터가 호출되면(grpc-spring-boot-starter의 기본 설정 기준) 위에 있는 BearerAuthenticationReader 클래스의 readAuthentication 메서드가 호출되며 이 메서드의 마지막 라인에 도달하면 tokenWrapper.apply(accessToken)을 호출하는데 이때 바로 제가 인자로 넣어준 Function이 실행됩니다. (아래 코드가 제가 인자로 넣어준 Function입니다.)
new BearerAuthenticationReader(token -> {
    try {  
        log.info("토큰을 받음 - 토큰: {}", token);  
        Authentication auth = jwtAuthenticationService.authenticateToken(token);  

        // 명시적으로 인증 성공 여부 확인  
        if (auth != null && auth.isAuthenticated()) {  
            return auth;  
        } else {  
            return null;  
        }  
    } catch (JwtAuthenticationException e) {  
        log.info("토큰 인증 실패: {}", e.getMessage());  
        return null;  
    } 
}

자 그럼 grpc-spring-boot-starter가 내부적으로 호출하는  gRPC 인터셉터를 살펴봅시다.

  • 코드가 생각보가 많습니다. 그래서 일부 코드는 주석처리 해두었습니다. 어차피 우리는 가장 위의 try 내부의 코드만 살펴보면 됩니다. 이 메서드를 자세히 보면 this.grpcAuthenticationReader.readAuthentication()를 호출하고 있습니다. 음.. 어디선가 본 것 같은 이름입니다. 바로 여기서 호출하는 readAuthentication() 메서드는 위에서 설명한 BearerAuthenticationReader 클래스 내부에 선언되어 있는 readAuthentication() 메서드입니다.
@Override
public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(final ServerCall<ReqT, RespT> call,
        final Metadata headers, final ServerCallHandler<ReqT, RespT> next) {
        
    Authentication authentication;
    try {
        authentication = this.grpcAuthenticationReader.readAuthentication(call, headers);
    } catch (final AuthenticationException e) {
        log.debug("Failed to read authentication: {}", e.getMessage());
        throw e;
    }
    if (authentication == null) {
        // ...
    }
    if (authentication.getDetails() == null && authentication instanceof AbstractAuthenticationToken) {
        // ...
    }
    log.debug("Credentials found: Authenticating '{}'", authentication.getName());
    try {
        authentication = this.authenticationManager.authenticate(authentication);
    } catch (final AuthenticationException e) {
        // ...
    }

    // ...
}

자 그럼 다시 BearerAuthenticationReader 내부에 선언된 readAuthentication 코드를 살펴봅시다.

  • 지금 상황은 위에 있는 gRPC 인터셉터 코드에서 readAuthentication 메서드를 호출한 상황입니다. 코드를 보면 로직들이 호출되어서 멋지게 accessToken을 만듭니다. 그리고 이 accessToken을 담아서 apply 메서드를 호출합니다. 자 그럼 어떻게 될까요? 바로 제가 열심히 작성한 Function(위에서 설명)이 호출되어서 security의 Authenticate 객체(GrpcAuthenticationToken)를 만들어서 반환합니다.
@Override
public Authentication readAuthentication(final ServerCall<?, ?> call, final Metadata headers) {
    final String header = headers.get(AUTHORIZATION_HEADER);

    if (header == null || !header.toLowerCase().startsWith(PREFIX)) {
        log.debug("No bearer auth header found");
        return null;
    }

    // Cut away the "bearer " prefix
    final String accessToken = header.substring(PREFIX_LENGTH);

    // Not authenticated yet, token needs to be processed
    return tokenWrapper.apply(accessToken);
}

자 근데 문제는 여기서 발생합니다.

  • 인터셉터 코드 내부의 첫 번째 try에서 인증객체인 Authenticate를 받아왔습니다. 근데 하단의 2번째 try문에서 문제가 발생하고 있습니다. this.authenticationManager.authenticate(authenticatino); 이 메서드인데 첫 번째로 호출된 메서드에서는 grpc 관련 인증이었는데 이번 호출은 시큐리티 자체를 의미합니다.
  • 에러 로그로 남았던 Unsupported authentication Type은 스프링 시큐리티의 AuthenticationManager가 구현되어 있지 않으니 당연히 authentication 객체가 만들어지지 않았고 코드에서는 null을 받으니 우리가 처리할 수 있는 타입이 아니다.라고 예외를 발생시키고 있었습니다.
@Override
public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(final ServerCall<ReqT, RespT> call,
        final Metadata headers, final ServerCallHandler<ReqT, RespT> next) {
        
    Authentication authentication;
    try {
        authentication = this.grpcAuthenticationReader.readAuthentication(call, headers);
    } catch (final AuthenticationException e) {
        log.debug("Failed to read authentication: {}", e.getMessage());
        throw e;
    }
    
    // ...
    
    try {
        authentication = this.authenticationManager.authenticate(authentication);
    } catch (final AuthenticationException e) {
        // ...
    }

    // ...
}

 

여기까지가 우리 눈으로 빠르게 확인할 수 있는 jwt 추출 흐름입니다.

이 과정을 따라가며 파악하는 데에는 문제가 없었습니다. 오히려 너무 술술 잘 풀려서 이상하다고 느껴질 정도였습니다. JWT를 요청 header에서 잘 추출했고 GrpcAuthenticationToken(커스텀 인증객체)가 멋지게 생성되는 것도 확인했습니다(auth 객체가 잘 생성됨).

 

그리고 문제가 gRPC 인터셉터(DefaultAuthenticatingServerInterceptor)에서 발생했다는 것도 알게 되었습니다. 그래서 문제는 제가 생각했던 것보다 더 깊은 곳에 숨겨져 있다고 판단했고 더 깊이 분석을 해보니 인터셉터 내부의 interceptCall 메서드에서 호출하는 메서드 안에서 AuthenticationManager를 호출하는데 이 구현체를 제가 선언하지 않았기 때문이라는 것도 파악할 수 있었습니다.

 

다행히 저는 이 오류를 여러 번 분석하며 해결방법을 찾았습니다.

 

 

auth를 null로 반환하기 (인증 실패)


이 방법은 제가 일부로 인증 실패 상황을 만든 거라 정리할지 고민했지만 과정을 설명하기 위해 적어둡니다.

  • 코드를 살펴보면 Function 함수로 담아준 인자 메서드가 어떻게 호출되던 마지막에는 무조건 null을 반환하도록 설계했습니다. 이렇게 해서 일부로 인증을 실패시키면 어떻게 되는가 gRPC 서버의 메서드는 호출되어 응답에 성공하는가 이것이 너무 궁금했기 때문입니다. 그래서 만약 이렇게 실패시켰는데 메서드가 호출된다면 원인은 이 Reader 또는 내부적으로 호출하는 gRPC 인터셉터에 있을 것이라고 가정하였습니다.
@Slf4j
@RequiredArgsConstructor
@Configuration
public class GrpcJwtSecurityConfig {

    private final JwtAuthenticationService jwtAuthenticationService;

    @Bean
    public GrpcAuthenticationReader grpcAuthenticationReader() {
        List<GrpcAuthenticationReader> readers = new ArrayList<>();

        // Bearer 토큰을 처리할 수 있는 리더를 추가합니다  
        readers.add(new BearerAuthenticationReader(token -> {
            try {
                log.info("토큰을 받음 - 토큰: {}", token);
                Authentication auth = jwtAuthenticationService.authenticateToken(token);
                
                return null;
            } catch (JwtAuthenticationException e) {
                log.info("토큰 인증 실패: {}", e.getMessage());
                return null;
            }
        }));

        // 모든 리더를 하나의 복합 리더로 결합합니다  
        return new CompositeGrpcAuthenticationReader(readers);
    }

}

이제 요청을 보내고 로그를 확인해 봅시다.

  • gRPC서버는 gRPC 클라이언트에서 보낸 jwt를 잘 받아서 security의 Authenticate 객체를 멋지게 생성했습니다. 그러나 gRPC서버 메서드가 실행되며 찍힌 로그를 보니 시큐리티 인증 정보(Authenticate)가 null인 것을 확인할 수 있습니다. 값이 null이면 인터셉터에서는 바로 인증을 안 했기 때문에 예외도 안 잡히고 문제없이 gRPC 서버의 메서드가 호출된 것이라고 판단했습니다.
// GrpcAuthenticationReader 에서 Bean 등록된 메서드 호출
토큰을 받음 - 토큰: eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJqaW5hbkB0ZXN0LmNvbSIsImlhdCI6MTczNjU3MzU1NCwiZXhwIjoxNzM2NTc3MTU0fQ.yXICGRPJQqzKUhokopqptrQFgrvVfi1Ghy3vQhiPlGs
JWT 인증 시작 - 토큰: eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJqaW5hbkB0ZXN0LmNvbSIsImlhdCI6MTczNjU3MzU1NCwiZXhwIjoxNzM2NTc3MTU0fQ.yXICGRPJQqzKUhokopqptrQFgrvVfi1Ghy3vQhiPlGs
JWT에서 추출한 사용자 이름: jinan@test.com
사용자 정보 로드 완료 - 권한: [ROLE_USER]

// gRPC 서버 메서드 호출
gRPC 서버의 getMemberById 메서드 실행 시작 - ID: 1
gRPC 서버 메서드 내부 시큐리티 인증 정보: null

회원을 찾을 수 없음 - ID: 1
getMemberById 메서드 실행 중 오류 발생

자 지금과 같이 인증 정보가 null인 상황 (제가 일부로 null을 반환시킨 상황)에는 gRPC 인터셉터 내부에서 이렇게 동작합니다.

  • 일반적으로 접근 권한이 필요한 리소스가 아니면 예외를 발생시키지 않고 그냥 통과시킵니다. 그래서 이전에 auth를 반환했을 때와는 달리 예외를 발생시키지 않고 바로 인증 통과처리를 해버리니 인증 자체가 없어져서 gRPC 서버 메서드 호출이 진행된 것입니다.
if (authentication == null) {  // 인증 정보가 없는 경우
    log.debug("No credentials found: Continuing unauthenticated");  // 디버그 로그 기록
    
    try {
        return next.startCall(call, headers);  // 다음 체인으로 요청을 전달
    } catch (final AccessDeniedException e) {  // 접근 거부 예외 발생시
        throw newNoCredentialsException(e);    // 미인증 예외로 변환하여 던짐
    }
}

이걸로 알 수 있는 것은 null을 반환해서 동작한다고 해서 null을 사용하면 절대 안 된다는 것입니다. 실제로는 인증이 안되고 있는 것이니 이 상황은 문제가 있습니다. 다음 도전은 gRPCServer 시큐리티 설정과 spring security의 내부적인 충돌이 있는 건가 싶어서 스프링 자동설정에서 gRPC 시큐리티 설정을 제외시켜 봤습니다.

 

 

gRPC 시큐리티 설정 스프링부트에서 제외시키기 (인증 실패)


깃허브 gRPC서버 코드 브랜치: feature/exclude-grpc-security

 

스프링 부트의 자동설정 어노테이션인 EnableAutoConfiguration로 GrpcServerSecurity를 제외시켜 보았습니다.

  • 결론적으로 이 방법도 이전과 마찬가지로 인증을 하지 않기 때문에 실패입니다. 이렇게 하면 빈으로 등록한 GrpcAuthenticationReader가 동작하지도 않습니다. 왜냐하면 이 설정을 제외시키면 grpc-spring-boot-starter가 지금까지 저희가 열심히 봐왔던 gRPC 인터셉터를 호출하지도 않습니다. 그렇다 보니 아래의 Reader는 호출되지도 않습니다.
@Slf4j
@RequiredArgsConstructor
// GrpcServerSecurityAutoConfiguration 비활성화
@EnableAutoConfiguration(exclude = {GrpcServerSecurityAutoConfiguration.class})
@Configuration
public class GrpcJwtSecurityConfig {

    private final JwtAuthenticationService jwtAuthenticationService;

    @Bean
    public GrpcAuthenticationReader grpcAuthenticationReader() {
        List<GrpcAuthenticationReader> readers = new ArrayList<>();

        // Bearer 토큰을 처리할 수 있는 리더를 추가합니다  
        readers.add(new BearerAuthenticationReader(token -> {
            try {
                log.info("토큰을 받음 - 토큰: {}", token);
                Authentication auth = jwtAuthenticationService.authenticateToken(token);

                // 명시적으로 인증 성공 여부 확인  
                if (auth != null && auth.isAuthenticated()) {
                    return auth;
                } else {
                    return null;
                }
            } catch (JwtAuthenticationException e) {
                log.info("토큰 인증 실패: {}", e.getMessage());
                return null;
            }
        }));

        // 모든 리더를 하나의 복합 리더로 결합합니다  
        return new CompositeGrpcAuthenticationReader(readers);
    }

}

이번에는 어떤 로그가 남았을까요?

  • 이전과 동일하게 인증을 하지 않으니 gRPC 서버 메서드는 잘 호출되어 동작하는데 인증된 적이 없으니 authentication값이 null로 나오고 있었습니다. 이전과 조금 다른 점은 gRPC 인터셉터가 동작했으면 Reader가 호출되면서 로그에 "토큰을 받음 - 토른: xxxxx" 이게 남았어야 하는데 아무것도 남은 것이 없습니다. 즉, 설정에서 gRPC 시큐리티를 제외시키면 애초에 인증을 진행하지도 않습니다. 그러니 당연히 문제없이 gRPC 서버의 메서드가 호출됩니다.
// gRPC 서버 메서드 호출
gRPC 서버의 getMemberById 메서드 실행 시작 - ID: 1
gRPC 서버 메서드 내부 시큐리티 인증 정보: null

회원을 찾을 수 없음 - ID: 1
getMemberById 메서드 실행 중 오류 발생

이 상황도 해결된 것이 아닙니다. 결국 이것도 인증 없이 메서드가 호출된 거나 다름없습니다. 제가 원하는 것은 인증에 성공해서 메서드가 호출되는 것입니다. 이제 다시 gRPC 시큐리티를 살리고 근본적인 문제를 해결하러 가봅시다.

 

 

해결방법 1. 스프링 시큐리티 AuthenticationManager 구현하기


깃허브 gRPC서버 코드 브랜치: feature/security-manager

 

근본적인 문제를 해결하기 위해 위의 2가지 상황(인증 우회)을 제외하고 다시 기존 로직의 문제점을 살펴봅시다.

  • gRPC 요청이 들어오면 grpc-spring-boot-starterDefaultAuthenticatingServerInterceptor 클래스의 interceptCall 메서드를 호출합니다. 지금 발생하는 인증문제(Unsupported authentication Type)는 이 interceptCall 메서드 내부에서 호출되는 2번째 try문 로직에서 발생하고 있습니다. 그렇다면 저는 여기서 호출하는 게 무엇인지를 제대로 이해한다면 해결방법을 찾을 수 있을 것이라고 판단했습니다.

 

두 번째 try문 코드를 살펴봅시다.

  • 첫 번째 try문 내부에서는 gRPC전용 grpcAuthenticationReaderreadAuthentication 메서드를 호출해서 gRPC 인증 토큰을 세팅하고 있습니다. 그리고 두 번째 try에서는 스프링 시큐리티의 authenticationManager의 authenticate 메서드를 호출해서 인증을 수행하고 있다는 것을 확인할 수 있습니다. (아래 코드 확인)

  • 이 두 가지 작업은 기능적으로는 비슷할지 몰라도 제공자가 전혀 다르다는 것이 핵심입니다. 첫 번째로 호출되는 readAuthentication 메서드는 gprc-spring-boot-starter 라이브러리에서 제공하는 메서드이고 두 번째로 호출되는 authentication 메서드는 스프링 시큐리티 라이브러리에서 자체적으로 제공하는 메서드입니다. 즉, 이 2가지는 전혀 다른 메서드라는 것입니다.
@Override
public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(final ServerCall<ReqT, RespT> call,
        final Metadata headers, final ServerCallHandler<ReqT, RespT> next) {
        
    Authentication authentication;
    try {
        authentication = this.grpcAuthenticationReader.readAuthentication(call, headers);
    } catch (final AuthenticationException e) {
        log.debug("Failed to read authentication: {}", e.getMessage());
        throw e;
    }
    
    // ...
    
    try {
        authentication = this.authenticationManager.authenticate(authentication);
    } catch (final AuthenticationException e) {
        // ...
    }

    // ...
}

그럼 어떻게 해야 이 문제가 해결될까요?

  • 해결 방법은 매우 간단합니다. 스프링 시큐리티에서 제공하는 authenticate 메서드를 직접 구현한다면 해결될 것입니다. 저는 이 방법을 찾기 위해 하루를 날렸습니다.. 저는 아래와 같이 저만의 gRPC 인증 매니저 클래스(GrpcAuthenticationManager)를 선언했고 내부에 authenticate 메서드를 오버라이드 했습니다. (참고: 꼭 스프링 시큐리티의 AuthenticationManager를 implements 해주셔야 합니다.)
@Slf4j
@RequiredArgsConstructor
@Primary
@Component
public class GrpcAuthenticationManagerImpl implements AuthenticationManager {

    private final JwtAuthenticationService jwtAuthenticationService;

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        try {
            log.debug("Authenticating user: {}", authentication.getName());

            // 토큰은 credentials에 저장되어 있습니다
            String token = (String) authentication.getCredentials();

            // JwtAuthenticationService를 통해 인증 처리
            return jwtAuthenticationService.authenticateToken(token);

        } catch (JwtAuthenticationException e) {
            log.error("JWT 인증 실패: {}", e.getMessage());
            throw new BadCredentialsException("JWT 인증 실패", e);
        } catch (Exception e) {
            log.error("인증 처리 중 예상치 못한 오류 발생", e);
            throw new AuthenticationServiceException("인증 처리 중 오류 발생", e);
        }
    }

}
  • 여기서 하나 더 꼭 알고 계셔야만 하는 중요한 것이 있는데 @Bean, @Configuration을 활용하여 빈을 등록한다면 기존 빈보다 우선순위가 높게 덮어씌워지지만 저는 @Component를 통해 빈 등록을 하고 있으니 빈이 우선순위가 밀려서 제가 만든 매니저 클래스가 스프링 빈으로 주입되지 않는 현상이 발생했습니다. 그러니 꼭 클래스 상단에 @Primary를 붙여서 이 빈을 가장 우선적으로 사용한다는 것을 명시해 주세요.

자 이제 행복한 요청 결과를 볼까요?

  • 로그를 보면 두 번의 인증 readAuthentication, authentication 메서드가 인터셉터에서 호출되므로 인증 시작이 2번 로깅되었고 (정상) gRPC 서버의 회원 조회 메서드도 잘 호출되어 gRPC 클라이언트로 응답까지 성공한 것을 확인할 수 있습니다.
// gRPC 서버 토큰 추출 및 SecurityContextHolder 세팅
GrpcAuthenticationReader 토큰을 받음 - 토큰: eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJqaW5hbkB0ZXN0LmNvbSIsImlhdCI6MTczNjU5MTM1MCwiZXhwIjoxNzM2NTk0OTUwfQ.nd_y8S_3kxADje-xcQ6klh5fUBSFVVPIUz7RBfZSA7I
JWT 인증 시작 - 토큰: eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJqaW5hbkB0ZXN0LmNvbSIsImlhdCI6MTczNjU5MTM1MCwiZXhwIjoxNzM2NTk0OTUwfQ.nd_y8S_3kxADje-xcQ6klh5fUBSFVVPIUz7RBfZSA7I
JWT에서 추출한 사용자 이름: jinan@test.com
사용자 정보 로드 완료 - 권한: [ROLE_USER]
JWT 인증 시작 - 토큰: eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJqaW5hbkB0ZXN0LmNvbSIsImlhdCI6MTczNjU5MTM1MCwiZXhwIjoxNzM2NTk0OTUwfQ.nd_y8S_3kxADje-xcQ6klh5fUBSFVVPIUz7RBfZSA7I
JWT에서 추출한 사용자 이름: jinan@test.com
사용자 정보 로드 완료 - 권한: [ROLE_USER]

// gRPC 서버의 메서드 동작 로그
gRPC 서버의 getMemberById 메서드 실행 시작 - ID: 1
gRPC 서버 메서드 내부 시큐리티 인증 정보: eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJqaW5hbkB0ZXN0LmNvbSIsImlhdCI6MTczNjU5MTM1MCwiZXhwIjoxNzM2NTk0OTUwfQ.nd_y8S_3kxADje-xcQ6klh5fUBSFVVPIUz7RBfZSA7I
Hibernate: select me1_0.id,me1_0.email,me1_0.etc_info,me1_0.name,me1_0.password,me1_0.profile_image_base64 from member me1_0 where me1_0.id=?
회원 조회 성공 - 이메일: jinan@test.com

휴.. 멋지게 성공했습니다. (사실 너무 기뻤습니다.. 이 방법은 제가 가장 마지막에 알게 된 근본적인 문제해결 방법이었습니다.)

 

 

해결방법 2. 커스텀 gRPC 인터셉터 등록하기


깃허브 gRPC서버 코드 브랜치: feature/custom-interceptor

 

두 번째 해결방법입니다. 커스텀 인터셉터를 만들어봅시다.

  • 기존에 사용 중이던 Reader 빈 등록 코드는 모두 삭제하고 Grpc의 서버 시큐리티를 비활성화시킵니다. (이제부턴 인증을 제가 직접 관리할 것이기 때문입니다.) 이후에 gRPC 인터셉터 코드를 하나 작성해서 gRPC 인터셉터로 등록하였습니다. 그래서 기존의 코드는 필요 없어졌고 이 코드만 사용해 주시면 됩니다.

  • 한 번 더 말씀드리지만 아래와 같이 GrpcServerSecurityAutoConfiguration를 꼭 제외(exclude)해주셔야 인터셉터가 제대로 동작합니다. 제외하지 않으면 grpc-spring-boot-starter가 내부적으로 Reader를 빈 주입 시도하기에 서버 실행을 하자마자 바로 관련 Bean 등록 오류가 발생할 것입니다.
@Slf4j
@GrpcGlobalServerInterceptor
@RequiredArgsConstructor
// GrpcServerSecurityAutoConfiguration 비활성화
@EnableAutoConfiguration(exclude = {GrpcServerSecurityAutoConfiguration.class})
public class GrpcJwtServerInterceptor implements ServerInterceptor {

    private final TokenExtractor tokenExtractor;
    private final JwtAuthenticationService jwtAuthenticationService;

    /**
     * 서버 호출을 인터셉트하여 JWT 토큰을 인증합니다.
     *
     * @param call    ServerCall
     * @param headers Metadata
     * @param next    ServerCallHandler
     * @param <ReqT>  요청 타입
     * @param <RespT> 응답 타입
     * @return ServerCall.Listener
     */
    @Override
    public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(ServerCall<ReqT, RespT> call,
                                                                 Metadata headers,
                                                                 ServerCallHandler<ReqT, RespT> next) {
        // Authorization 헤더 추출
        String authorizationHeader = headers.get(Metadata.Key.of("Authorization", Metadata.ASCII_STRING_MARSHALLER));
        log.info("gRPC 서버 인터셉터에서 수신한 Authorization 헤더: {}", authorizationHeader);

        // 인증 헤더가 없는 경우
        if (authorizationHeader == null || authorizationHeader.isEmpty()) {
            log.error("인증 헤더가 없습니다.");
            return closeCallWithError(call, Status.UNAUTHENTICATED.withDescription("인증 헤더가 필요합니다."));
        }

        // 토큰 추출 및 인증
        try {
            return tokenExtractor.extractFromHeader(authorizationHeader)
                    .map(token -> authenticateAndProceed(token, call, headers, next))
                    .orElseGet(() -> closeCallWithError(call,
                            Status.UNAUTHENTICATED.withDescription("유효한 토큰이 없습니다.")));
        } catch (Exception e) {
            log.error("인증 처리 중 오류 발생: {}", e.getMessage(), e);
            return closeCallWithError(call, Status.INTERNAL.withDescription("인증 처리 중 오류가 발생했습니다."));
        }
    }

    /**
     * JWT 토큰을 인증하고 요청을 처리합니다.
     *
     * @param token   JWT 토큰
     * @param call    ServerCall
     * @param headers Metadata
     * @param next    ServerCallHandler
     * @param <ReqT>  요청 타입
     * @param <RespT> 응답 타입
     * @return ServerCall.Listener
     */
    private <ReqT, RespT> ServerCall.Listener<ReqT> authenticateAndProceed(String token,
                                                                           ServerCall<ReqT, RespT> call,
                                                                           Metadata headers,
                                                                           ServerCallHandler<ReqT, RespT> next) {
        try {
            Authentication auth = jwtAuthenticationService.authenticateToken(token);
            SecurityContextHolder.getContext().setAuthentication(auth);
            log.info("JWT 인증 성공 - 사용자: {}", auth.getName());
            return next.startCall(new SimpleForwardingServerCall<>(call) {
            }, headers);
        } catch (JwtAuthenticationException e) {
            log.error("JWT 인증 실패: {}", e.getMessage());
            return closeCallWithError(call,
                    Status.UNAUTHENTICATED.withDescription(e.getMessage()));
        }
    }

    /**
     * 서버 호출을 오류로 종료합니다.
     *
     * @param call    ServerCall
     * @param status  상태
     * @param <ReqT>  요청 타입
     * @param <RespT> 응답 타입
     * @return ServerCall.Listener
     */
    private <ReqT, RespT> ServerCall.Listener<ReqT> closeCallWithError(ServerCall<ReqT, RespT> call,
                                                                       Status status) {
        call.close(status, new Metadata());
        return new ServerCall.Listener<ReqT>() {
        };
    }

}

이게 끝입니다. 그냥 인터셉터를 만들고 등록만 해두면 알아서 JWT를 추출하고 인증을 진행합니다.

  • 그러나 이 방법도 문제가 발생합니다. 디버깅을 해보면 제가 만든 커스텀 gRPC 인터셉터가 잘 동작합니다. 그래서 인증도 잘 된다고 생각했습니다. 근데 디버깅 없이 바로 요청을 마무리하고 기쁜 마음으로 로그를 봤는데 로그에서 "gRPC 서버 메서드 내부 시큐리티 인증 정보: null"을 출력하고 있었습니다.

아... 뭘까요? 이전에는 인터셉터도 동작하지 않았지만 적어도 이번에는 인터셉터가 잘 호출됩니다. 근데 인증이 안되고 있으니 null이 나오는 것이겠죠? 요청 스레드가 다른 것이 사용되고 있는 걸까요?

  • 궁금증에 천천히 디버깅을 해봤는데 신기한 현상이 있었습니다. 천천히 디버깅을 하면 그 사이에 JWT가 SecurityContextHolder에 세팅되는 건지 로그를 보니 gRPC 서버 메서드 내부 시큐리티 인증 정보: eyJhbxxxx  이렇게 토큰값이 잘 세팅되어 있었습니다. 하.. 더 헷갈립니다. 대체 뭘까요?? 이쯤 되니 조금 화가 났습니다.

곰곰이 생각해 보니 최근 겪은 사건 덕분에 이 현상에 대해 떠오른 것이 있었습니다.

  • 스프링 시큐리티에서 인터셉터 로직을 수행하는 메인 스레드와 gRPC 메서드를 처리하는 스레드가 서로 다르다면 ThreadLocal이 각자 다르기 때문에 이런 문제가 발생하곤 합니다. 그래서 SecurityContext를 복사해서 전달하는 방법을 적용해 봤습니다.

  • 참고로 gRPC는 tomcat대신 netty를 사용하기 때문에 netty 전용 스레드풀을 설정해 주면서 기존 스레드가 가진 Security Context를 전파해 주는 DelegatingSecurityContextExecutorService를 스레드풀로 등록해 주었습니다. (이건 제가 블로그에 글로 정리 중인 내용인데 신기하게 여기서도 적용하게 되었네요 ㅎㅎㅎ 나중에 잘 정리해서 올려보도록 하겠습니다.)
@Configuration
public class GrpcServerConfig {

    @Bean
    public GrpcServerConfigurer keepAliveServerConfigurer() {
        return serverBuilder -> {
            if (serverBuilder instanceof NettyServerBuilder) {
                // 기본 ExecutorService 생성
                ExecutorService executorService = Executors.newFixedThreadPool(50);

                // SecurityContext를 전파하는 ExecutorService로 래핑
                ExecutorService securityContextExecutorService =
                        new DelegatingSecurityContextExecutorService(executorService);

                ((NettyServerBuilder) serverBuilder)
                        .executor(securityContextExecutorService) // 보안 컨텍스트를 전파하는 실행자 사용
                        .maxInboundMessageSize(10 * 1024 * 1024)
                        .keepAliveTime(30, TimeUnit.SECONDS)
                        .keepAliveTimeout(5, TimeUnit.SECONDS)
                        .permitKeepAliveWithoutCalls(true);
            }
        };
    }

}

이렇게 netty 스레드 설정을 빈 등록하고 서버를 실행하면 인터셉터도 잘 동작하고 인증도 잘 되고 SecurityContextHolder도 잘 동작하고 모든 게 성공적입니다. 다만.... 뭔가 찝찝한 건 남아있습니다. 저는 아직도 왜 천천히 디버깅을 했을 때는 시큐리티 설정이 되었던 것인지 전혀 이해를 못 하고 있었습니다. 그래서 더 깊이 파봤습니다.

 

 

왜 스레드풀에 DelegatingSecurityContextExecutorService를 써야 하는가


생각해 보면 gRPC서버는 http요청과는 전혀 관계가 없습니다. 다시 요청 흐름을 봅시다.

 

1. gRPC클라이언트 서버 (http 요청받음)

2. gRPC 클라이언트 서버 내부 (gRPC서버 요청 보냄)

3. 이제 여기서 gRPC서버가 받아서 인터셉터 처리 (jwt 시큐리티 설정)

4. 인터셉터 처리한 다음 내부의 gRPC 서버 메서드 호출 (실제 비즈니스 로직)

5. gRPC 클라이언트 서버에 완성된 응답 반환

6. gRPC 클라이언트 서버는 http요청에 대한 최종 응답을 내보냄

 

음.. 뭘까요...? 제 생각은 이랬습니다. 3번과 4번은 gRPC클라이언트에서 보낸 요청을 받은 다음 1개의 스레드로 모든 작업을 처리한다. 그렇지만 실제 동작을 보면 1개의 스레드가 아닌 것 같다는 생각이 들기도 합니다. 애초에 SecurityContextHolder가 스레드 간 전달이 된다고 하더라도 인터셉터 로직에서 setAuthenticate 메서드가 호출되어 security 인증 객체인 Authenticate가 완성되어서 설정되어야만 한 묶음으로 연결되지 않을까요? (애초에 전파될 인증 객체가 완성되어야 전파가 가능하겠죠? 아니면 시차에 따라 null일 가능성이 있으니까 말이죠)

 

우리는 눈으로 보이는 것만 믿으면 됩니다. 그래서 바로 로그를 확인해 봤습니다.

Authentication.getCredentials()" because "authentication" is null, 원인: null

음.. 로그를 살펴보면 gRPC 서비스 메서드는 잘 호출되었는데 로직 내부에서 Authentication 인증객체를 확인하려고. getCredentials()을 호출하는데 이때 호출되는 Authentication 객체가 null이었습니다.. 그래서 null에 get을 찍으니 공포의 NPE가 발생했던 것입니다. 이것을 보고 거의 모든 곳에 다 분노의 로깅을 했습니다. 정말 무지성으로 엄청난 로그를 적어뒀습니다 ㅋㅋㅋ

 

신기한 건 그랬더니!!??? 원인을 찾을 수가 있었습니다.

  • 자 다시 하단의 로그를 봅시다. (엄청난 로그는 생략하고 중요한 것만 남깁니다 ㅎㅎ) 저는 인터셉터 내부에 현재 스레드 정보를 로깅해 두고 gRPC 서버 메서드 내부에도 스레드 정보를 로깅해 뒀습니다. 그랬더니!! 아래와 같이 서로 다른 스레드를 사용 중이라는 것을 확인할 수 있었습니다....... 근데 왜 이렇게 2개가 사용되는 거지? 가장 먼저 이 생각이 들었습니다.
현재 스레드 인터셉터: pool-1-thread-4

gRPC 서버 인터셉터에서 수신한 Authorization 헤더: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJqaW5hbkB0ZXN0LmNvbSIsImlhdCI6MTczNjU5NTYxMCwiZXhwIjoxNzM2NTk5MjEwfQ.SwK2Tvq8iwrkM2akSdLOlPxDSktgXHitUt-5mSQBgmw
JWT 인증 시작 - 토큰: eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJqaW5hbkB0ZXN0LmNvbSIsImlhdCI6MTczNjU5NTYxMCwiZXhwIjoxNzM2NTk5MjEwfQ.SwK2Tvq8iwrkM2akSdLOlPxDSktgXHitUt-5mSQBgmw
JWT에서 추출한 사용자 이름: jinan@test.com
사용자 정보 로드 완료 - 권한: [ROLE_USER]
JWT 인증 성공 - 사용자: jinan@test.com
gRPC 서버의 getMemberById 메서드 실행 시작 - ID: 1

현재 스레드 메서드 내부: pool-1-thread-5

이 상황을 제 뇌로는 설명할 수 없었기에 스승님께 여쭤봤습니다. (2번 스승이신 Claude)

  • gRPC 서버는 Netty를 사용하며 Netty는 이벤트 루프 모델을 사용하여 비동기 처리를 합니다. (아하!!??)
    • I/O 스레드: 요청을 최초로 받아 인터셉터를 실행하는 스레드
    • 작업 스레드: 실제 비즈니스 로직(getMemberById)을 실행하는 스레드

SecurityContextHolder는 ThreadLocal을 사용하는데 ThreadLocal은 각 스레드마다 독립적인 저장공간을 제공합니다.

  1. 인터셉터에서 설정한 인증 정보는 I/O 스레드ThreadLocal에 저장됩니다
  2. 서비스 메서드는 다른 스레드(작업 스레드)에서 실행되므로 이 정보를 볼 수 없습니다

 

먼저 gRPC 서버의 스레드 모델을 살펴보겠습니다.

  • gRPC는 Netty를 기반으로 하는데, Netty는 이벤트 루프 기반의 비동기 처리 모델을 사용합니다. 이는 다음과 같이 동작합니다.
// 1. EventLoop 스레드 (Boss 스레드)
ServerBootstrap bootstrap = new ServerBootstrap()
    .group(bossGroup, workerGroup)  // 두 개의 스레드 그룹 사용
    .channel(NioServerSocketChannel.class);

// 2. Worker 스레드
.childHandler(new ChannelInitializer<SocketChannel>() {
    @Override
    protected void initChannel(SocketChannel ch) {
        // 채널 파이프라인 설정
    }
});

이 구조에서

  1. Boss 스레드: 새로운 연결을 수락합니다.
  2. Worker 스레드: 실제 요청 처리를 담당합니다.
  3. 실행자 스레드 풀: 비즈니스 로직 실행을 담당합니다.

둘째, 디버깅 시 동작이 달라지는 이유를 살펴보겠습니다.

  • 디버깅을 천천히 할 때는 다음과 같은 일이 발생합니다.
// 1. 인터셉터에서 (Worker 스레드)
SecurityContextHolder.getContext().setAuthentication(auth);
// 브레이크포인트로 인해 잠시 멈춤

// 2. 서비스 메소드에서 (같은 Worker 스레드가 재사용될 수 있음)
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
  • 브레이크포인트로 인해 실행이 지연되면서 스레드 풀이 스레드를 재사용할 가능성이 높아지고 비동기 작업의 순서가 달라질 수 있습니다. 이것이 바로 디버깅 시 SecurityContext가 로그에 남는 이유입니다. 실제로는 다른 스레드에서 실행되어야 할 작업이, 디버깅으로 인한 지연으로 같은 스레드에서 실행될 수 있는 것입니다.

 

이 문제의 해결방법은 DelegatingSecurityContextExecutorService를 사용하는 것입니다.

아래 코드는 Spring Security 프로젝트(라이선스: Apache License 2.0)에서 가져왔고, 저작권은 원 저작자에게 있습니다. 본 블로그 글에서는 Apache License 2.0의 범위 내에서, 해당 코드를 주석 정리하여 공유합니다.
  • DelegatingSecurityContextExecutorService 코드 내부를 따라가다 보면 아래와 같은 call 메서드를 호출하며 대략 이런 식으로 동작합니다.
public V call() throws Exception {
    // 1) 현재(원래) SecurityContext를 저장
    this.originalSecurityContext = this.securityContextHolderStrategy.getContext();

    Object var1;
    try {
        // 2) 새롭게 지정할 delegateSecurityContext로 세팅
        this.securityContextHolderStrategy.setContext(this.delegateSecurityContext);

        // 3) 원래 Callable 실행
        var1 = this.delegate.call();
    } finally {
        // 4) 실행 후, 원본(저장해둔) SecurityContext로 복원
        SecurityContext emptyContext = this.securityContextHolderStrategy.createEmptyContext();
        if (emptyContext.equals(this.originalSecurityContext)) {
            // 원래 컨텍스트가 '비어있는 Context'와 같다면 clearContext()로 정리
            this.securityContextHolderStrategy.clearContext();
        } else {
            // 그렇지 않다면 기존 컨텍스트로 복원
            this.securityContextHolderStrategy.setContext(this.originalSecurityContext);
        }

        // 참조 해제
        this.originalSecurityContext = null;
    }

    return (V) var1;
}

 

 

코드를 설명하긴 복잡하니 말로 풀어서 이해해 봅시다.


상황을 정리해 보겠습니다.

  • Spring Security는 내부적으로 SecurityContextHolder라는 ThreadLocal(“각 스레드”마다 독립된 저장 공간)을 사용합니다. 따라서 “메인 스레드”에서 로그인해서 SecurityContextHolder에 인증 정보가 들어 있어도, “다른 스레드”에서는 ThreadLocal이 다르니 인증 정보가 전달되지 않습니다.

  • 이를 해결해 주는 게 DelegatingSecurityContextExecutorService, DelegatingSecurityContextRunnable, DelegatingSecurityContextCallable 등입니다. 이것들은 새 스레드(혹은 스레드풀)로 일을 넘길 때, 자동으로 SecurityContext(로그인 정보)를 “복사해서 넣어주고, 끝나면 다시 복원” 해줍니다.

마지막으로 제가 로그인하는 상황을 가정해 봤습니다.

 

1. Stark가 로그인을 합니다.

  • Stark가 웹사이트에 접속해 로그인을 합니다. 이때 Spring Security가 Stark를 인증해서, SecurityContextHolder.getContext() 안에 “Stark”라는 사용자 정보(토큰, 권한 등)를 담아 둡니다. 이제 메인 스레드“Stark의 인증 정보”를 가진 상태입니다. SecurityContextHolder.getContext().getAuthentication() 를 하면 “Stark”라는 결과를 반환합니다.
[메인 스레드] SecurityContext: Stark

2. 메인 스레드에서 “작업 스레드”를 실행 (문제 상황)

  • 이제 컨트롤러(메인 스레드)에서, “다른 메서드를 비동기로 호출”하거나 “스레드 풀”을 이용해 새로운 스레드(작업 스레드)에서 일을 시키려 합니다. 근데 기본적으로 아무 설정 없이 새로운 스레드에서 SecurityContextHolder.getContext()를 확인해 보면, 비어 있거나 anonymous 상태일 수 있습니다. 왜냐하면 ThreadLocal은 스레드마다 독립적이기 때문입니다.
[작업 스레드] SecurityContext:  (없음 or anonymous)

3. DelegatingSecurityContext로 해결

  • 드디어 나왔습니다. 이 문제를 해결하기 위해 다음과 같이 동작합니다.
    • DelegatingSecurityContextExecutorService(직접 ExecutorService 사용 시)
    • DelegatingSecurityContextAsyncTaskExecutor(@Async 사용 시)
    • 이렇게 DelegatingSecurityContext 계열 클래스가 사용됩니다.

  • 이것의 작동 원리는 간단합니다. 비동기 작업“감싸는” 객체(DelegatingSecurityContextCallable 등)가 동작합니다.
    1. 작업 실행 전에, “메인 스레드에서 쓰던 SecurityContext”를 가져와서 임시 저장하비다.
    2. 작업 스레드가 시작되기 직전, 그 SecurityContext를 “작업 스레드”의 ThreadLocal에 세팅합니다.
    3. 작업이 끝나면, 다시 원래대로 복원합니다. (또는 청소)

 

 

마무리하며


처음 정리할 때는 엄청 간단한 내용이라 생각하고 적기 시작했는데 막상 다 적고 보니 생각보다 내용이 방대해졌네요 ㅎㅎ

앞으로는 gRPC에 대해서 가능한 자세히 다뤄보려고 합니다. 어떤 상황에 써야 좋을지 왜 써야 하는지 반대로 어떤 단점이 있는지 여러 가지를 확인해보려 합니다.

 

개인적으로는 성능 테스트를 해보면서 gRPC에서 JVM 메모리 문제를 겪고 있는데 이와 관련해서도 멋지게 정리해 보도록 하겠습니다.

마무리를 길게 적기엔 이미 내용이 너무 많아서 여기까지만 하고 물러나보도록 하겠습니다.

 

긴 글 읽어주셔서 감사합니다 :)

 

 

반응형