TIL

24.09.26 TIL - 2차 프로젝트 trouble shooting

개발공명 2024. 9. 27. 19:18

오늘 배운 것

  • Spring Cloud Gateway와 OpenFeign 사용 시 문제점
  • FeignClient 사용 시 header 문제

 

개요

 

MSA에서 인증 인가를 진행할 때 발생한 문제들이였다. 

 

우선 MSA에서 인증 인가를 위와 같이 진행했다. 

  • Gateway에서 하는 일
    • 들어오는 요청에 Authorization Header에 JWT가 있는지 확인 
    • JWT가 있다면 JWT가 유효한 JWT인지 검증
    • JWT 검증되었다면 JWT에서 추출한 정보로 필요한 정보 얻어옴
    • 필요한 정보 = username, roles를 Header에 넣어 요청 뒤로 진행시킴
    • 회원가입, 로그인 요청은 JWT 검증 로직 진행하지 않음
  • Auth 서버에서 하는 일
    • 회원 가입 진행
    • Spring Security 이용해 인증 진행 
      • 이 과정에서 User 서비스에게 username, password 요청
    • 인증 진행되면 JWT 생성해 response의 Header에 담아 반환
  • User 및 다른 서비스에서 하는 일
    • 필터가 존재
    • 필터에서 Gateway에서 넘어온 요청의 Header에서 username, roles를 추출
    • 추출한 정보를 SecurityContextHolder에 넣어 권한 처리할 수 있도록 함

 

이렇게 인증 인가를 진행하겠다고 생각한 후 발생한 문제들이였다. 

 

Spring Cloud Gateway와 OpenFeign 사용 시 문제

위의 그림에서 Gateway에서 User 서비스로 Header에 넣을 정보를 요청하는 부분이 있다. 

 

Header에는 Gateway 뒤의 서비스들에서 권한 처리를 할 때 필요한 username과 roles들을 넣어야 했다.

 

강의에서 자연스럽게 User의 정보를 받아와서 username과 roles를 얻었다.

 

그래서 나도 당연하게 User 서비스에 FeignClient로 요청을 해서 값을 받아와야겠다고 생각을 했다. 

 

추후에 생각해보니 username과 roles 정보는 JWT에 담겨 있는 정보라 그냥 JWT에서 꺼내서 Header에 넣어도 되지 않나 생각이 들었다. 

 

그런데 다시 생각해보니 JWT를 가진 사람의 roles가 만료되거나 수정되었을 때 문제를 방지하기 위해 그때마다 User의 정보를 받아오는 것 같다. 

 

그래서 아래처럼 Gateway에서 FeignClient로 User 서비스에게 요청을 보내 UserDto를 받아오도록 했다.

java UserDto userDto = Optional.ofNullable(userServiceClient.getUserByUsername(username)) 
		.orElseThrow(() -> new UsernameNotFoundException("User " + username + " not found"));

 

이렇게 하고 진행하는데 JWT를 검증한 후 아래와 같은 오류가 계속 발생하였다. 

java.lang.IllegalStateException: block()/blockFirst()/blockLast() are blocking, which is not supported in thread parallel-2
	at reactor.core.publisher.BlockingSingleSubscriber.blockingGet(BlockingSingleSubscriber.java:87) ~[reactor-core-3.6.9.jar:3.6.9]
	Suppressed: reactor.core.publisher.FluxOnAssembly$OnAssemblyException: 
...

 

왜 이런 오류가 발생하는지 알아보니 원인은 아래와 같았다.

  • Spring Cloud Gateway와 Feign 클라이언트를 함께 사용할 때 발생할 수 있는 문제다.
  • 주된 이유는 Feign 클라이언트에서 동기적으로 데이터를 가져오려 할 때 block() 메소드를 사용하게 된다. 
  • 그런데 이는 비동기 방식으로 동작하는 Reactor 스레드 (예: parallel-2)에서 호출될 수 없기 때문이다.
  • Reactor는 non-blocking 방식으로 동작해야 하며, block() 메소드 사용은 금지되어 있다.

 

처음 들어보는 소리였고 무슨 소리인지 몰랐다.

 

Spring 공식 사이트를 가보니 아래와 같은 글이 있었다.

 

Spring Cloud Gateway는 비동기 기반으로 구축되어 있고 FeignClient는 동기 기반으로 구축되어 있다고 한다.

 

그래서 Spring Cloud Gateway에서 비동기 처리가 필요한 부분에서 동기적 처리가 이루어지려 할 때 발생하는 오류인 것이다.

 

해결 방안으로는 비동기 방식으로 FeignClient 사용하는 ReactiveFeign 사용하는 등 굉장히 어려운 방식들이 있었다.

 

이런 방식들에 대해 공부하고 진행할 시간이 부족하였기 때문에 특강에서 진행한 방법이 떠올랐다.

 

바로 Redis를 사용하는 방법이였다. 

 

Gateway에서 User DB를 연결할까도 생각했지만 뭔가 맞지 않는 것 같아 Redis를 사용하기로 했다.

 

Auth 서버와 Gateway가 같은 Redis를 연결하고 있는 것이다.

 

Auth 서버에서도 Gateway와 똑같이 UserDto가 필요했는데 Auth 서버에서는 FeignClient를 사용하고 있었다.

 

그래서 Auth 서버에서 로그인에 성공하면 User 서비스로부터 FeignClient로 받아온 UserDto를 Redis에 넣는다.

 

그리고 Gateway에서는 JWT를 검증할 때 Redis에 있는 이 UserDto를 가져와서 정보를 꺼내는 것이다.

 

이렇게 Redis를 사용하지 위의 문제들을 겪지 않고도 Gateway에서 JWT에서 정보 가져와 Header로 넣어줄 수 있었다.

 

FeignClient 사용 시 header 문제

위에서 봤듯이 Gateway에서 Header에 username, roles를 넣어 뒤의 서비스들로 요청을 넘겨준다.

 

이때 다른 서비스들 (ex: Hub, Logistics 등)은 모두 같은 필터를 가지고 있다. 

 

필터가 하는 일은 Request에서 username, roles를 추출해 Spring Security의 SecurityContextHolder에 넣는 것이다.

 

SecurityContextHolder에 Roles를 넣는 이유는 권한 처리를 쉽게 아래의 annotation으로 하기 위해서이다. 

  • @PreAuthorize
  • @Secured
  • ...

이렇게 진행하도록 필터를 구현하고 모든 팀원들에게 각 서비스에 필터를 적용하면 된다고 하였다. 

 

나는 User 서비스를 맡았는데 User 서비스에서는 회원 가입과 UserDto를 가져오는 API가 있었다.

 

API로 들어오는 요청들은 Gateway에서 Header들을 넣기 전에 발생하는 요청이라 필터 적용을 제외 시켰다. 

@Slf4j
@Component
public class CustomPreAuthFilter extends OncePerRequestFilter {

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        // /sign-up 또는 /user/{username} 경로에 대해서는 필터 적용 제외
        String requestURI = request.getRequestURI();
        return requestURI.equals("/api/v1/users/sign-up") || requestURI.startsWith("/api/v1/users/user/");
    }
	
    ...
}

 

그래서 내가 Auth 서버에서 User 서비스로 FeignClient를 사용해 요청을 보낼 때에는 문제가 없었다. 

 

그런데 팀원들의 이야기를 들어보니 FeignClient 요청을 보내는데 계속 401 Unauthorized가 뜬다고 했다. 

 

내가 Auth 서버에서 FeignClient를 사용하는데는 문제가 없는데 다른 팀원이 FeignClient를 사용할 때는 문제가 생기니 팀원의 코드에서 문제가 생겼거니 했다. 

 

그런데 401 Unauthorized가 뜰만한 곳이 내가 개발한 부분 밖에 없어서 긴급하게 알아보기 시작했다. 

 

401 Unauthorized만 뜨고 뭔가 응답 값이나 로그가 없어서 디버깅하는데 굉장히 애를 먹었다. 

 

또 다른 팀원이 개발한 것을 테스트 하려고 하니 필요한 데이터도 넣고 이런 과정이 굉장히 복잡했다. 

 

그래서 401 Unauthorized가 뜨는 곳을 찾아보니 각 서비스에 적용되어 있는 필터였다. 

 

필터에서 Header로 넘어온 username이나 roles가 없으면 401 Unauthorized이 뜨도록 되어 있었다. 

 

아래 코드의 doFilterInternal의 else 부분이였다. 

@Slf4j
@Component
public class CustomPreAuthFilter extends OncePerRequestFilter {

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        // /sign-up 또는 /user/{username} 경로에 대해서는 필터 적용 제외
        String requestURI = request.getRequestURI();
        return requestURI.equals("/api/v1/users/sign-up") || requestURI.startsWith("/api/v1/users/user/");
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {

        // 헤더에서 사용자 정보와 역할(Role)을 추출
        String username = request.getHeader("X-User-Name");
        String rolesHeader = request.getHeader("X-User-Roles");

        if (username != null && rolesHeader != null) {
            // rolesHeader에 저장된 역할을 SimpleGrantedAuthority로 변환
            List<SimpleGrantedAuthority> authorities = Arrays.stream(rolesHeader.split(","))
                    .map(role -> new SimpleGrantedAuthority(role.trim()))
                    .collect(Collectors.toList());

            // 사용자 정보를 기반으로 인증 토큰 생성
            UsernamePasswordAuthenticationToken authenticationToken =
                    new UsernamePasswordAuthenticationToken(username, null, authorities);

            // SecurityContext에 인증 정보 설정
            SecurityContextHolder.getContext().setAuthentication(authenticationToken);
        } else {
        	// -----------------------401 Unauthorized 발생 부분--------------------------------
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);  // 헤더가 없거나 역할 정보가 없으면 401 응답
            // ---------------------------------------------------------------------------------
            return;
        }

        // 필터 체인을 계속해서 진행
        filterChain.doFilter(request, response);
    }
}

 

아니 왜 같은 필터를 사용하는데 내가 Auth 서버에서 FeignClient를 사용할 때에는 문제가 안 생기는지 도무지 알 수 없었다. 

 

그래서 생각해보니 FeignClient에서 보내는 요청도 똑같은 요청이니 필터를 타는 것이다. 

 

그런데 FeignClient 요청에 Header가 없어서 401 Unauthorized이 발생하는 것이였다. 

 

프로젝트에서 3가지 요청이 있는 것이다. 

  • Client에서 보내는 요청
    • Gateway를 지나서 오기 때문에 JWT만 있다면 Header를 넣어서 요청이 온다. 
  • 회원 가입 및 UserDto를 가져오는 요청
    • 필터에서 이 API 요청은 제외 시켰기 때문에 문제가 없었다. 
  • 다른 FeignClient에서 보내는 요청
    • Header가 없어서 문제가 발생한다. 

 

이렇게 FeignClient에서 요청을 보낼 때에도 필요한 Header 값이 있다면 보내줘야 한다는 것을 알게 되었다. 

 

그래서 Header로 JWT 값 넣으면 되는가 싶었는데 FeignClient의 요청은 기본적으로 Gateway를 타지 않고, 서비스 간에 직접 통신한다고 한다. 

 

그래서 직접 username과 roles를 넣어줘야 했다. 

 

FeignClient에 헤더 넣는 방법이 3가지가 있었다. 

  1. RequestInterceptor로 모든 FeignClient 요청에 공통적으로 헤더 추가하기
  2. @RequestHeader로 각 보내는 FeignClient 인터페이스의 메서드에서 추가해주기
  3. @Headers로 각 보내는 FeignClient 인터페이스의 메서드에서 고정된 헤더 추가해주기

 

FeignClient 사용하는 부분에서 늘 헤더의 값을 받아와서 FeignClient에 넣어줘야 하는데 이게 코드가 영 지저분해지고 보기도 별로 안 좋았다. 

 

아래 코드처럼 늘 Request에서 Header 값을 꺼내 Service로 넘기고 Service에서 Header 값을 FeignClient에 넣어줘야 했다. 

// Controller 부분
    @PostMapping
    @PreAuthorize("hasAuthority('VENDOR_MANAGER') or hasAuthority('MASTER')")
    public ResponseEntity<ApiResponseDto<UUID>> createProduct(
            @RequestHeader(value = "X-User-Name") String username,
            @RequestHeader(value = "X-User-Roles") String roles,
            @Valid @RequestBody ProductCreateRequestDto request) {
        UUID productId = productService.createProduct(username, roles, request);
        return ResponseEntity
                .created(URI.create("/api/v1/products/" + productId))
                .body(new ApiResponseDto<>(HttpStatus.CREATED, "상품 생성 성공", productId));
    }
    
  // Service 부분
      // 상품 생성
    @Transactional
    public UUID createProduct(String username, String roles, ProductCreateRequestDto request) {
        // 업체 존재 여부 확인
        // getVendor 부분이 FeignClient 사용 부분 
        VendorResponseDto vendor = Optional.ofNullable(hubService.getVendor(request.getVendorId(), username, roles))
                .orElseThrow(() -> new CustomException(ErrorCode.VENDOR_NOT_FOUND));

        // 허브 존재 여부 확인
        // getHub 부분이 FeignClient 사용 부분 
        HubResponseDto hub = Optional.ofNullable(hubService.getHub(request.getHubId(), username, roles))
                .orElseThrow(() -> new CustomException(ErrorCode.HUB_NOT_FOUND));

        Product product = Product.builder()
                .productName(request.getProductName())
                .stockQuantity(request.getStockQuantity())
                .vendorId(request.getVendorId())
                .hubId(request.getHubId())
                .description(request.getDescription())
                .build();

        productRepository.save(product);
        return product.getProductId();
    }

 

RequestInterceptor 사용하려고 하니 해당 User의 username, roles를 어떻게 가져와야 할지 모르겠어서 @RequestHeader를 사용했다. 

 

추후에 권한 처리를 위해 Header를 넘기는 방법말고 다른 방법을 생각하던가 아니면 RequestInterceptor를 사용하는 방안을 더 알아봐야 할 것 같다.