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<>());
}
단계별로 다시 정리
- DB 단계
- UserEntity 사용
- 목적: 저장/조회
- 인증 엔진 단계
- UserDetails(User) 사용
- 목적: Spring Security 인증 파이프라인 처리
- 인증 후 도메인 단계
- principal에서 username(email) 획득
- 도메인 조회 후 userId로 JWT subject 구성
즉 email은 Entity에서 온 원천 데이터가 맞지만, 인증 시점에는 Security 표준 모델로 변환해서 사용한다.
그래서 “같은 값”과 “같은 역할”은 다르다.
'Spring > Cloud' 카테고리의 다른 글
| [Spring] Spring Cloud Bus: 수동 refresh의 고통에서 벗어나는 법 (0) | 2026.04.23 |
|---|---|
| [Spring] MSA 설정 중앙화: Spring Cloud Config와 다중 프로필 전략 (0) | 2026.04.23 |
| [Spring] API Gateway부터 Service Discovery까지: Spring Cloud Gateway 전체 구조와 동작 흐름 (0) | 2026.04.15 |
| [Spring] 서비스 디스커버리의 시작: Spring Cloud Netflix Eureka (1) | 2026.04.10 |
| Spring Cloud로 MSA 흐름 잡기 (0) | 2026.04.10 |