본문 바로가기
Spring/Cloud

[Spring] API Gateway와 User Service로 이해하는 JWT 인증 흐름

by coding_whale 2026. 4. 21.
반응형

MSA 보안의 핵심은 '인증(신원 확인)'과 '인가(권한 검증)'를 분리하여 시스템의 효율성을 높이는 데에 있다.
API Gateway는 모든 외부 요청의 관문으로서 유효한 토큰인지 1차 검증(인가)하여 부적절한 트래픽을 입구에서 차단하게 된다.
라우팅 설정 시 로그인/회원가입은 공개하고, 그 외 API는 검증 필터를 거치게 하는 것 자체가 우리 서비스의 보안 정책이 되고, 결국 "누가 신분증을 발급하고, 누가 입구에서 검사할 것인가"라는 역할 분담을 이해하는 것이 MSA 보안 설계의 시작입니다.


1) 인증/인가/세션/JWT: 먼저 용어부터 정확히 잡자

왜 이 개념을 먼저 배우나

대부분의 혼란은 “로그인은 했는데 왜 또 검증하지?” 같은 질문에서 생긴다. 이건 코드 문제가 아니라 용어가 섞여서 생기는 문제다. 용어를 먼저 분리하면 뒤 코드가 훨씬 쉽게 읽힌다.

핵심 개념

  • 인증(Authentication): “너 누구야?”를 확인하는 과정
  • 인가(Authorization): “너 이 API 써도 돼?”를 확인하는 과정
  • 세션 기반 인증: 서버가 로그인 상태를 저장
  • JWT 기반 인증: 클라이언트가 토큰을 들고 다니고 서버는 토큰 검증

이 프로젝트는 JWT 기반이다. 즉 로그인은 한 번이지만, 보호 API 호출 때마다 토큰 검증은 반복된다.
그래서 “로그인 이후 검증”은 중복 작업이 아니라 정상적인 보안 동작이다.

JWT 최소 구성

  • Header: 알고리즘 메타정보
  • Payload(Claims): 사용자 식별 정보(이 프로젝트는 subject=userId)
  • Signature: 위변조 방지

헷갈리는 질문 1
“로그인을 이미 했는데 왜 GET 요청마다 또 인증하나요?”
답: 세션이 아니라 JWT 구조라서, 매 요청마다 토큰 유효성을 확인해야 한다. 이게 무상태(stateless) 인증의 기본 동작이다.


2) 왜 Gateway와 User Service를 분리했나

왜 이 개념을 배우나

MSA에서 인증 책임을 어디에 둘지 애매하면, 서비스마다 중복 구현이 생기고 운영이 어려워진다. 이 프로젝트는 그 문제를 역할 분리로 해결한다.

역할 분리 원칙

  • API Gateway
    • 외부 진입점
    • 라우팅/요청 전처리
    • 보호 API에 대한 JWT 1차 검증
  • User Service
    • 실제 로그인 인증(email/password 검증)
    • JWT 발급
    • 직접 접근 제어(IP 기반)

즉 Gateway는 입구 보안, User Service는 신원 확인과 발급 주체다.


3) 라우트 분기 자체가 보안 설계다

왜 이 개념을 배우나

많은 사람이 라우트 설정을 “URL 매핑” 정도로만 보는데, 실제로는 라우트 자체가 인증 정책이다. 어느 경로에 어떤 필터를 붙이느냐가 보안 정책 그 자체다.

원본 코드: application.yml

spring:
  cloud:
    gateway:
      server:
        webflux:
          default-filters:
            - name: GlobalFilter
              args:
                baseMessage: Spring Cloud Gateway WebFlux Global Filter
                preLogger: true
                postLogger: true
                # 입력: 모든 요청
                # 출력: 공통 pre/post 로그
                # 보안 의도: 전 구간 관측성 확보(문제 추적)
                # 실패 케이스: 없음(검증 필터가 아니라 로깅 필터)

          routes:
            - id: user-service-register
              uri: lb://USER-SERVICE
              predicates:
                - Path=/user-service/users
                - Method=POST
              filters:
                - RemoveRequestHeader=Cookie
                - RewritePath=/user-service/(?<segment>.*), /${segment}
                # 입력: 외부 경로 /user-service/users
                # 출력: 내부 경로 /users 로 전달
                # 보안 의도: 쿠키 의존 제거 + 무상태 흐름 유지
                # 실패 케이스: Rewrite 오설정 시 404/매핑 불일치

            - id: user-service-login
              uri: lb://USER-SERVICE
              predicates:
                - Path=/user-service/login
                - Method=POST
              filters:
                - RemoveRequestHeader=Cookie
                - RewritePath=/user-service/(?<segment>.*), /${segment}
                # 보안 핵심: 로그인은 "토큰 발급 단계"라 AuthorizationHeaderFilter를 붙이지 않음

            - id: user-service
              uri: lb://USER-SERVICE
              predicates:
                - Path=/user-service/**
                - Method=GET
              filters:
                - RemoveRequestHeader=Cookie
                - RewritePath=/user-service/(?<segment>.*), /${segment}
                - AuthorizationHeaderFilter
                # 입력: 보호 API GET 요청
                # 출력: JWT 유효 시에만 서비스 전달
                # 보안 의도: 보호 리소스 사전 차단
                # 실패 케이스: 헤더 없음/토큰 불량 -> 401

 

여기서 꼭 이해해야 할 포인트

  • /users, /login은 인증 전 공개 엔드포인트
  • /user-service/** (GET)은 인증 후 접근 엔드포인트
  • 로그인 URI를 분리한 이유는 “로그인 요청 자체가 토큰 발급 요청”이기 때문

헷갈리는 질문 2
“로그인도 보안 중요한데 왜 JWT 필터를 안 붙이나요?”
답: 로그인은 아직 JWT가 없는 상태다. 로그인에 JWT 검증 필터를 붙이면 토큰을 받으러 가는 요청이 먼저 차단된다.


4) 로그인 내부: AuthenticationFilter -> AuthenticationManager -> JWT 발급

왜 이 개념을 배우나

“로그인이 어디서 실제로 이뤄지는지”를 명확히 알아야 인증 주체/도메인 모델 혼동이 줄어든다. 이 프로젝트의 로그인 핵심은 User Service Security FilterChain에 있다.

원본 코드: AuthenticationFilter.java

@Override
public Authentication attemptAuthentication(HttpServletRequest req,
                                            HttpServletResponse res) throws AuthenticationException {
    try {
        // [입력] 클라이언트 로그인 JSON: {email, password}
        RequestLogin creds = new ObjectMapper().readValue(req.getInputStream(), RequestLogin.class);

        // [처리] AuthenticationManager에 인증 위임
        // principal=email, credentials=password
        return getAuthenticationManager().authenticate(
                new UsernamePasswordAuthenticationToken(
                        creds.getEmail(),
                        creds.getPassword(),
                        new ArrayList<>()
                )
        );
        // [출력] 성공 시 Authentication 객체, 실패 시 AuthenticationException
        // [보안 의도] 비밀번호 검증 로직을 Spring Security 표준 파이프라인에 위임
        // [실패 케이스] 이메일 미존재/비밀번호 불일치/파싱 실패
    } catch (IOException e) {
        throw new RuntimeException(e);
    }
}

@Override
protected void successfulAuthentication(HttpServletRequest req, HttpServletResponse res,
                                        FilterChain chain, Authentication authResult) throws IOException, ServletException {
    // [입력] 인증 성공 principal(UserDetails)
    String userName = ((User) authResult.getPrincipal()).getUsername(); // 실제로 email

    // [처리] email 기반으로 도메인 사용자 조회 -> userId 획득
    UserDto userDetails = userService.getUserDetailsByEmail(userName);

    // [처리] JWT 생성, subject=userId
    String token = Jwts.builder()
            .subject(userDetails.getUserId())
            .expiration(...)
            .issuedAt(...)
            .signWith(secretKey)
            .compact();

    // [출력] 응답 헤더에 token/userId 전달
    res.addHeader("token", token);
    res.addHeader("userId", userDetails.getUserId());

    // [보안 의도] 이후 요청에서 사용자 식별을 subject(userId)로 일관 유지
    // [실패 케이스] secret 문제/토큰 생성 중 예외/사용자 조회 실패
}

보조 설정 원본: WebSecurity.java

.authenticationManager(authenticationManager)
.addFilter(getAuthenticationFilter(authenticationManager))

이 줄이 실제로 AuthenticationFilter를 Security 체인에 등록하는 지점이다.

헷갈리는 질문 3
“로그인 성공 시 왜 email 말고 userId를 토큰 subject로 넣나요?”
답: email은 변경 가능성이 있고 노출 민감도가 더 높다. userId(내부 식별자)를 subject로 쓰면 식별 안정성이 높아진다.


5) 로그인 후 검증: AuthorizationHeaderFilter와 ServerWebExchange

왜 이 개념을 배우나

JWT 시스템에서 보안 품질은 로그인보다 “로그인 이후 요청 검증”에서 결정된다. 이 프로젝트는 이 책임을 Gateway에 둔다.

원본 코드: AuthorizationHeaderFilter.java

@Override
public GatewayFilter apply(Config config) {
    return (exchange, chain) -> {
        ServerHttpRequest request = exchange.getRequest();

        // [입력] 보호 API 요청의 Authorization 헤더
        if (!request.getHeaders().containsKey(HttpHeaders.AUTHORIZATION)) {
            // [출력] 401 반환
            // [보안 의도] 헤더 없는 요청 즉시 차단
            // [실패 케이스] 클라이언트가 토큰 미첨부
            return onError(exchange, "No authorization header", HttpStatus.UNAUTHORIZED);
        }

        String authorizationHeader = request.getHeaders().get(HttpHeaders.AUTHORIZATION).get(0);
        String jwt = authorizationHeader.replace("Bearer ", "");

        if (!isJwtValid(jwt)) {
            // [출력] 401 반환
            // [보안 의도] 위변조/만료/파싱 오류 토큰 차단
            // [실패 케이스] secret 불일치, 서명 오류, claims 파싱 예외
            return onError(exchange, "JWT token is not valid", HttpStatus.UNAUTHORIZED);
        }

        // [출력] 유효 토큰만 다음 체인으로 전달
        return chain.filter(exchange);
    };
}

exchange를 꼭 이해해야 하는 이유

ServerWebExchange는 WebFlux 요청/응답 컨텍스트다.

  • exchange.getRequest(): 헤더/경로 접근
  • exchange.getResponse(): 상태코드/바디 즉시 작성

즉, 검증 실패 트래픽을 User Service까지 보내지 않고 Gateway에서 종료하는 기술적 기반이 exchange다.

헷갈리는 질문 4
“Gateway에서 JWT 검증하면 User Service에서는 아무 검증도 안 해도 되나요?”
답: 일반적으로는 계층 방어가 권장된다. 다만 이 프로젝트는 교육용 구조로 Gateway 1차 검증 + Service 접근제어(IP) 조합을 보여주는 예시다.


6) 직접 접근 차단과 lb://USER-SERVICE의 차이

왜 이 개념을 배우나

“이미 lb://로 라우팅하는데 왜 IP 제한까지 있지?”라는 질문이 자주 나온다. 이는 네트워크 라우팅 개념과 보안 경계 개념이 혼동된 경우다.

원본 코드: WebSecurity.java

.authorizeHttpRequests(auth -> auth
    .requestMatchers(HttpMethod.POST, "/users", "/login").permitAll()
    .anyRequest().access(
        new WebExpressionAuthorizationManager(
            "hasIpAddress('127.0.0.1') or hasIpAddress('::1') or " +
            "hasIpAddress('192.168.35.2') or hasIpAddress('::1')"))
)

개념 분리

  • lb://USER-SERVICE
    • 서비스 디스커버리/로드밸런싱
    • “어디로 보낼지” 문제 해결
  • hasIpAddress(...)
    • 접근 통제
    • “누가 접근 가능한지” 문제 해결

둘은 겹치는 설정이 아니라 계층이 다른 제어다.


7) 핵심 오해 해결: UserEntity vs UserDetails (email은 어디서 오나)

왜 이 개념을 배우나

질문하신 포인트 그대로, 초중급 학습자가 가장 많이 헷갈리는 지점이다.
“Entity에서 email을 가져오면 결국 같은 데이터 아닌가?” -> 맞다. 하지만 모델의 역할이 다르다.

원본 코드: UserServiceImpl.java
원본 코드: UserEntity.java

@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    // [1단계: 영속 모델] DB에서 UserEntity 조회
    UserEntity userEntity = userRepository.findByEmail(username);

    if (userEntity == null)
        throw new UsernameNotFoundException(username + ": not found");

    // [2단계: 인증 모델] UserEntity -> UserDetails 변환
    return new User(userEntity.getEmail(), userEntity.getEncryptedPwd(),
            true, true, true, true, new ArrayList<>());
}

단계별로 다시 정리

  1. DB 단계
    • UserEntity 사용
    • 목적: 저장/조회
  2. 인증 엔진 단계
    • UserDetails(User) 사용
    • 목적: Spring Security 인증 파이프라인 처리
  3. 인증 후 도메인 단계
    • principal에서 username(email) 획득
    • 도메인 조회 후 userId로 JWT subject 구성

즉 email은 Entity에서 온 원천 데이터가 맞지만, 인증 시점에는 Security 표준 모델로 변환해서 사용한다.
그래서 “같은 값”과 “같은 역할”은 다르다.

 

반응형