분산 처리 환경

스프링 분산 처리 환경 7: Spring Cloud Gateway 환경에서 JWT 인증

ohji52 2025. 11. 17. 00:55

1. 핵심 목표: WebFlux 환경에 맞는 인증 파이프라인 구축

AuthIntegrationTests

package com.example.distributed;

import com.example.distributed.domain.User;
import com.example.distributed.dto.LoginRequest;
import com.example.distributed.repository.UserRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.http.MediaType;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.transaction.annotation.Transactional;


@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
@Transactional
public class AuthIntegrationTests {


    @Autowired
    private WebTestClient webTestClient;

    @Autowired
    private ObjectMapper objectMapper;

    private static final String VALID_USER_ID = "valid_user";
    private static final String VALID_PASSWORD_PLAINTEXT = "correct_password_123";
    private static final String WRONG_PASSWORD_PLAINTEXT = "11111111_CompletelyDifferent";


    @TestConfiguration
    static class TestDataSetup {
        @Bean
        public User testUser(UserRepository userRepository, PasswordEncoder passwordEncoder) {
            String encodedPassword = passwordEncoder.encode(VALID_PASSWORD_PLAINTEXT);
            User testUser = new User(VALID_USER_ID, encodedPassword);
            return userRepository.save(testUser);
        }
    }

    @Test
    @DisplayName("유효한_자격증명으로_로그인_요청에_성공해야_하고_JWT를_받아야_한다")
    void 유효한_자격증명으로_로그인_요청에_성공해야_하고_JWT를_받아야_한다() throws Exception {

        LoginRequest loginRequest = new LoginRequest(VALID_USER_ID, VALID_PASSWORD_PLAINTEXT);
      webTestClient.post().uri("/login")
                .contentType(MediaType.APPLICATION_JSON)
                .bodyValue(loginRequest)
                .exchange()
                .expectStatus().isOk()
                .expectBody()
                .jsonPath("$.token").exists()
                .jsonPath("$.token").isNotEmpty();
    }

    @Test
    @DisplayName("잘못된_비밀번호로_로그인_시도_시_401_에러가_발생해야_한다")
    void 잘못된_비밀번호로_로그인_시도_시_401_에러가_발생해야_한다() throws Exception {

        LoginRequest wrongRequest = new LoginRequest(VALID_USER_ID, WRONG_PASSWORD_PLAINTEXT);

        webTestClient.post().uri("/login")
                .contentType(MediaType.APPLICATION_JSON)
                .bodyValue(wrongRequest)
                .exchange()
                .expectStatus().isUnauthorized();
    }

    @Test
    @DisplayName("인증되지_않은_상태로_보호된_리소스에_접근_시_401_에러가_발생해야_한다")
    void 인증되지_않은_상태로_보호된_리소스에_접근_시_401_에러가_발생해야_한다() throws Exception {

        webTestClient.post().uri("/api/protected")
                .exchange()
                .expectStatus().isUnauthorized();
    }
}

WebTestClient 활용

실제 서버를 띄운 것과 유사한 환경에서 인증 흐름을 테스트

  • WebTestClient: 논블로킹 방식으로 API에 요청을 보내고 응답을 검증하는 도구로, WebFlux 환경 테스트에 최적화되어 있음.
  • 테스트 시나리오: 유효한 로그인(JWT 발급), 잘못된 비밀번호(401), 토큰 없는 접근(401) 등 발생 가능한 모든 상황을 자동화하여 검증

 

 

AuthController

package com.example.distributed.controller;

import com.example.distributed.dto.LoginRequest;
import com.example.distributed.dto.LoginResponse;
import com.example.distributed.service.UserService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;

@RestController
public class AuthController {

    private final UserService userService;

    public AuthController(UserService userService) {
        this.userService = userService;
    }

    @PostMapping("/login")
    public Mono<ResponseEntity<LoginResponse>> login(@RequestBody LoginRequest request) {

        return Mono.fromCallable(() ->
                        userService.authenticateAndGenerateToken(
                                request.getUsername(),
                                request.getPassword()
                        )
                )
                .subscribeOn(Schedulers.boundedElastic())

                .map(jwtToken -> {
                    LoginResponse response = LoginResponse.builder()
                            .success(true)
                            .message("로그인 성공 및 JWT 토큰 발급")
                            .token(jwtToken)
                            .build();
                    return ResponseEntity.ok(response);
                })
                .onErrorResume(RuntimeException.class, e -> {
                    return Mono.error(new ResponseStatusException(HttpStatus.UNAUTHORIZED, e.getMessage()));
                });
    }
}

비동기 로그인 API

동기식으로 작동하던 기존 로직을 Mono로 감싸 비동기 스트림으로 전환

  • Mono.fromCallable & Schedulers.boundedElastic(): [DB 조회나 비밀번호 매칭처럼 CPU/IO 소모가 큰 동기 로직이 WebFlux의 이벤트 루프를 방해하지 않도록, 별도의 스레드 풀(boundedElastic)에서 실행되도록 격리
  • onErrorResume: 인증 실패 시 발생하는 예외를 401 Unauthorized 상태 코드로 변환하여 클라이언트에게 명확한 에러를 반환

 

 

 

1. 유효한_자격증명으로_로그인_요청에_성공해야_하고_JWT를_받아야_한다

 

올바른 사용자 정보로 로그인 인증에 성공하는지 확인, 그리고 로그인이 성공했을 시에 응답으로 JWT 토큰을 정확히 발급받는지를 검증한다. 비동기 환경에서 사용자 조회, 비밀번호 비교, 토큰 생성까지 성공하는지를 확인 

 

2. 잘못된_비밀번호로_로그인_시도_시_401_에러가_발생해야_한다

 

잘못된 비밀번호가 입력되었을 때 인증 실패 로직 작동 확인. UserService가 null을 반환하고 AuthController가 401로 올바르게 변환하는지를 확인 

 

3. 인증되지_않은_상태로_보호된_리소스에_접근_시_401_에러가_발생해야_한다

JWT 토큰 없이 보호된 경로로 요청을 보내면 Security로 진입한다. JwtAuthenticationFilter가 요청 헤더 검사하고 토큰을 찾지 못하면 SecurityConfig 설정에 따라 접근 불가 결정이 내려지고 401을 생성하고 반환함을 확인