이번에 두 번째 프로젝트인 AI B2B 프로젝트를 진행하면서, Gateway에서 사용자 인증 및 인가 처리를 구현하게 되었다. 나는 기존에 회원가입과 로그인을 구현할 때 사용하던 방식을 그대로 적용하기로 했다.
1.1 각 MSA 서비스로 사용자 인증 정보가 전달되지 않는 문제
그래서 Gateway에서 JWT 토큰을 헤더에서 추출한 뒤, 이를 검증하고 사용자 정보를 `SecurityContextHolder`에 저장하는 방식으로 접근했다. 처음에는 이 방법이 잘 작동할 것이라고 생각했지만, MSA 아키텍처 환경에서 문제가 발생했다.
각 서비스 간에 사용자 정보가 공유되지 않는 상황이 발생한 것이다.
일일이 로그를 통해 하나씩 확인해 본 결과, Gateway에서 `SecurityContextHolder`에 저장한 사용자 객체가 다른 MSA 서비스에서는 받아와지지 않는 문제를 발견했다.
원인을 추적해 보니, 각각의 서비스가 서로 다른 포트에서 구동되고 있기 때문에 `SecurityContextHolder`에 저장된 정보가 포트 간에 공유되지 않는 것이었다.
✔️ SecurityContextHolder
`SecurityContextHolder`는 `Spring Security`에서 현재 인증된 사용자의 보안 정보를 저장하고 관리하는 데 사용되는 객체이다.
애플리케이션에서 인증이 성공하면, 사용자에 대한 인증 정보가 `SecurityContext`에 저장되고, 이 `SecurityContext`는 `SecurityContextHolder`에 의해 관리된다.
`SecurityContextHolder`는 기본적으로 `ThreadLocal` 방식을 사용해 인증 정보를 저장한다.
`ThreadLocal`은 각 쓰레드마다 고유한 데이터 저장소를 제공하는 방식이기 때문에, 현재 실행 중인 쓰레드에만 해당 보안 컨텍스트에 접근할 수 있다. 이를 통해 애플리케이션 내에서 요청 처리 시 여러 사용자의 인증 정보가 서로 섞이지 않고 안전하게 관리될 수 있다.
하지만 MSA 환경에서는 각 서비스가 서로 다른 애플리케이션으로 구동되고, 각 서비스는 독립적인 프로세스와 포트에서 실행된다. 이러한 구조에서는 각 MSA 서비스가 자신의 프로세스 내에서만 `SecurityContextHolder`의 정보를 관리할 수 있기 때문에, 다른 서비스나 프로세스 간에 `SecurityContextHolder`의 인증 정보가 공유되지 않는다!
정리하면, 하나의 프로세스 내에서는 여러 쓰레드가 동작할 수 있지만, `SecurityContextHolder`는 각 쓰레드마다 고유한 저장소를 제공한다.
따라서 각 서비스가 독립적인 프로세스에서 실행된다면, 프로세스 간에 메모리나 상태를 공유하지 않기 때문에 SecurityContextHolder에 저장된 정보는 다른 프로세스(서비스)와 공유될 수 없다.
1.2 해결책
이를 해결하기 위해 Gateway에서 JWT 토큰을 검증만 수행하고, Claims 정보를 각 마이크로서비스로 전달하는 방식으로 변경하였다.
이를 통해 각 서비스에서 독립적으로 인증 정보를 처리할 수 있게 된다.
1. Gateway 필터에서 JWT 검증
Gateway 필터에서 JWT 토큰의 유효성을 검증하고, 토큰에서 Claims 정보를 추출한다. 추출한 사용자 정보는 헤더에 저장하여 각 서비스로 전달한다.
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String path = exchange.getRequest().getURI().getPath();
if (path.equals("/api/auth/sign-up") || path.equals("/api/auth/sign-in")
|| path.contains("/v3/api-docs")) {
return chain.filter(exchange);
}
String token = extractToken(exchange);
Claims claims = validateToken(token);
if (token == null || claims.isEmpty()) {
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
// 응답을 완료하여 요청을 더 이상 처리하지 않도록
return exchange.getResponse().setComplete();
}
String id = (String) claims.get("id");
String username = (String) claims.get("username");
String role = (String) claims.get("role");
exchange.getRequest().mutate()
.header("X-User-Id", id)
.header("X-User-Name", username)
.header("X-User-Role", role)
.build();
return chain.filter(exchange);
}
2. 각 서비스에서 `SecurityContext`에 저장
각 MSA 서비스는 전달받은 헤더의 Claims 정보를 기반으로 별도의 필터를 통해 `SecurityContext`에 사용자 정보를 저장한다. 이를 통해 각 서비스에서 독립적으로 인증 상태를 관리할 수 있다.
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String id = request.getHeader("X-User-Id");
String username = request.getHeader("X-User-Name");
String role = request.getHeader("X-User-Role");
if (id != null && username != null && role != null) {
UserPrincipal userPrincipal = UserPrincipal.of(Long.valueOf(id), username, role);
GrantedAuthority authority = new SimpleGrantedAuthority(role);
Authentication authentication = new UsernamePasswordAuthenticationToken(
userPrincipal, null, List.of(authority)
);
SecurityContextHolder.getContext().setAuthentication(authentication);
} else {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return;
}
filterChain.doFilter(request, response);
}
3. 컨트롤러에서 사용자 정보 수신
이제 컨트롤러에서는 `@AuthenticationPrincipal` 어노테이션을 사용하여 SecurityContext에서 사용자 정보를 가져올 수 있게 된다.
@GetMapping("/test")
public Response<String> test(@AuthenticationPrincipal UserPrincipal userPrincipal) {
System.out.println("userPrincipal.getId() = " + userPrincipal.getId());
System.out.println("userPrincipal.getUsername() = " + userPrincipal.getUsername());
System.out.println("userPrincipal.getRole() = " + userPrincipal.getRole());
return Response.success("테스트 성공");
}
이 방식으로 SecurityContextHolder를 공유하지 않더라도, JWT를 활용한 분산 인증 처리가 가능해진다.
그런데 또 하나의 문제가 발생했다.
2.1 permitAll()이 적용되지 않는 문제
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.headers(headers -> headers.frameOptions(FrameOptionsConfig::disable))
.sessionManagement(session -> session
.sessionCreationPolicy(STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.POST, "/api/auth/**").permitAll() // 인증 없이 접근할 수 있는 경로
.requestMatchers(HttpMethod.GET, "/api/auth/**").permitAll() // 인증 없이 접근할 수 있는 경로
.anyRequest().authenticated()
)
.addFilterBefore(authenticationFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
처음에 나는 permitAll()을 설정하면 해당 경로에 대해 필터가 아예 실행되지 않을 것이라고 생각했다.
그래서 회원가입, 로그인과 같은 경로에 permitAll()을 적용했지만, 여전히 필터가 실행되는 것을 발견했다.
✔️ permitAll
알고보니 permitAll()은 필터 실행 자체를 막는 것이 아니라, 인증 객체 없이도 해당 경로에 접근할 수 있도록 허용하는 설정이라는 사실을 알게 되었다. 즉, 필터는 여전히 실행되지만, 해당 경로에 대해서는 인증 여부를 확인하지 않고도 접근을 허용하는 것이다.
이 말은, 필터 체인은 그대로 작동하며, SecurityContext나 기타 인증 관련 로직이 정상적으로 작동한다는 의미다.
다만 permitAll()로 설정된 경로에 대해서는 인증이 요구되지 않을 뿐, 다른 경로에서와 마찬가지로 필터는 계속해서 모든 요청을 통과하게 된다.
여기서 말하는 `인증`이란, 사용자가 본인이 누구인지를 증명하는 과정을 의미한다. 즉, 시스템에 접근하려는 사용자가 신뢰할 수 있는 사용자임을 확인하는 절차이다. 일반적으로 이 과정은 로그인을 통해 이루어진다.
여기서 말하는 `인증이 요구되지 않는다`는 것은 JWT 토큰, 세션, 사용자 로그인 정보 등을 확인하는 과정을 생략한다는 뜻이다. 보통 회원가입, 로그인, 비밀번호 재설정과 같은 공개된 경로에 permitAll()을 적용해, 사용자들이 인증 없이도 접근할 수 있도록 한다.
하지만 중요한 점은, 필터 자체는 여전히 실행된다는 것이다. 즉, 요청이 들어오면 필터가 JWT 토큰이 있는지, SecurityContext에 인증 정보가 있는지를 검사하지만, permitAll()로 설정된 경로에 대해서는 인증 정보가 없어도 요청을 허용하는 것.
💡 여기서 인증 정보가 없어도 요청을 허용하는 필터는 Spring Security의 기본 필터 체인의 경우에 해당한다.. 내가 정의한 커스텀 필터에서는 인증 객체가 없을 시 예외가 발생하도록 하였기 때문에 접근이 안됐던 것이다.
결국, 인증 절차는 거치지 않지만, 필터 체인은 계속 동작하게 되는 것이다.
2.2 해결책
permitAll() 경로에서는 토큰이 없어도 요청이 정상적으로 통과되길 원한다면, 커스텀 필터에서 특정 경로에 대해서는 예외 처리를 하지 않도록 추가적인 조건을 설정할 수 있어야 한다.
예를 들어, 필터 내에서 해당 경로가 permitAll()로 설정된 경로인지를 확인한 후, 토큰 검증을 건너뛰도록 구현할 수 있다.
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// 요청 URL이 permitAll()로 설정된 경로인지 확인
String requestURI = request.getRequestURI();
if (requestURI.equals("/api/auth/sign-up") || requestURI.equals("/api/auth/sign-in")) {
// permitAll 경로에서는 토큰 검증을 생략
filterChain.doFilter(request, response);
return;
}
String id = request.getHeader("X-User-Id");
String username = request.getHeader("X-User-Name");
String role = request.getHeader("X-User-Role");
if (id != null && username != null && role != null) {
UserPrincipal userPrincipal = UserPrincipal.of(Long.valueOf(id), username, role);
GrantedAuthority authority = new SimpleGrantedAuthority(role);
Authentication authentication = new UsernamePasswordAuthenticationToken(
userPrincipal, null, List.of(authority)
);
SecurityContextHolder.getContext().setAuthentication(authentication);
} else {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return;
}
filterChain.doFilter(request, response);
}
나는 이렇게 하는 대신 특정 경로에서는 아예 필터가 실행되지 않도록 설정하였다.
@Override
protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
return StringUtils.startsWithAny(request.getRequestURI(), "/api/auth/sign-in",
"/api/auth/sign-up");
}
커스텀 필터 클래스 내부에서 `shouldNotFilter` 메서드를 오버라이드하여, 특정 경로에 대해서는 필터가 동작하지 않게 만들었다.
3.1 중복 필터 구현 문제
그런데 이렇게 하면 각 MSA 서비스마다 `SecurityConfig` `AuthenticationgFilter`와 같은 별도의 필터가 중복적으로 구현해야 하는 상황이 발생한다. 이는 코드의 중복과 유지보수의 어려움을 초래할 수 있다.
아직 구현해보진 않았지만 해결할 수 있는 방법들을 찾아보았다.
3.2 해결책
💡 Spring Interceptor 사용
Spring의 `HandlerInterceptor`를 활용하여 `SecurityContextHolder`에 사용자 정보를 저장한다. 그러나 이렇게 할 경우, 각 서비스마다 Interceptor를 구현해야 할 수 있어, 여전히 중복이 발생할 수 있다.
이는 공통 모듈을 만들어 최소한의 설정으로 보안 처리 로직을 적용하여 해결할 수 있다. 이를 통해 유지보수가 용이해지며, 공통 모듈만 수정하면 모든 서비스에 반영된다. 이 방법은 Spring Security를 사용할 필요 없는 서비스에 유용하다!
💡 헤더를 통한 사용자 정보 수신
요청의 헤더에서 그냥 단순하게 사용자 정보를 수신하여 인증을 진행한다. 하지만 이 방법은 헤더 위변조에 대한 추가적인 방어가 필요하고, 번거로운 구현이 될 수 있다.
💡 Redis 사용자 정보 캐싱
각 MSA 서비스는 사용자 식별자를 전달받아 Redis에서 GW에서 검증된 사용자 정보를 조회한다. 그런데 이 방법 또한 모든 MSA 서비스에 Redis 설정 파일이 필요하며 이는 공통 모듈을 통해 해결할 수 있다.
Redis를 사용하여 사용자 정보를 캐싱하면 사용자 권한 변경, 로그아웃, 비밀번호 변경, 계정 정지 등 블랙리스트 운영이 가능해진다는 점에 이점이 있다.
나는 세 가지 방법 중에서 Redis를 사용하는 방법으로 구현해보고자 한다! Redis를 활용하면 성능을 향상시키고, 사용자 관리와 보안성을 동시에 강화할 수 있을 것으로 기대한다.
구현하고 내용을 추가해봐야겠다.
'공부 > Project' 카테고리의 다른 글
웹 프로젝트 서버 배포 흐름도 (0) | 2024.09.25 |
---|---|
Spring Cloud FeignClient N+1 호출 문제 해결하기 (0) | 2024.09.23 |
배달 레전드 프로젝트 회고 (정적 팩토리 메서드, 전략 패턴, 퍼사드 패턴, QueryDSL @Query 차이, Page, JPA N+1 문제) (3) | 2024.09.07 |
Springboot 키워드 검색 API 구현하기 (QueryDSL) (1) | 2024.09.01 |
Springboot JPA + QueryDSL 사용 시 no property found for type 오류 해결 (0) | 2024.08.31 |