MSA에서 로그인 구현이 다른 이유.
MSA는 Monolithic 환경과 다르다.
Monolithic에서는 유저/게시판/결제/채팅 등등 모든 서비스가 한 서버 내에서 동작이 이루어진다면,
MSA에서는 각각의 서비스들을 담당하는 서버들이 각각 있다. 그리고 각각 독립적으로 운영되고 배포된다.
( 예를 들어, 유저와 관련한 서비스는 유저서버 / 게시글과 관련한 서비스는 게시글 서버 / 결제관련한 서비스는 결제서버가 담당하는 식의 구조. 이 때 결제서버에서 에러가 터져 서버가 돌아가지 않더라도 유저가 게시글을 쓰는 데에는 문제가 없다. 게시글과 관련한 로직을 담당하는 서버가 독립적으로 돌아가고 있기 때문이다.
그런데 모놀리식 구조에서는 한 서버에서 결제,게시글 등 모든 로직을 처리하기 때문에 결제에서 에러가 터져 서버가 돌아가지 않는다면 당연히 모든 기능이 작동되지 않을 것이다.)
따라서 Monilthic에서는 로그인 인증도 서버 내에서 한 번 만 하면 된다.
그런데 MSA에서는 각각의 서비스를 담당하는 서버가 있기 때문에 로그인 인증처리를 각 서버마다 중복으로 행해야할 것이다.
그러나 '게이트웨이'가 있기 때문에 그러지 않아도 된다.
화면 밖의 사용자가 게시글과 관련한 API를 사용하게 된다면 게시글 서버에 있는 API를 찾아가야 할 것이고
결제를 하게 된다면 결제 서버에 있는 API를 찾아가야한다.
게이트웨이는 바로 여러 서버들의 앞단에서 사용자의 요청을 받아 해당 기능이 있는 서버에 그 요청을 전달하는 문지기 같은 개념이다.
이 게이트웨이를 활용하면 사용자에 대한 로그인 인증 처리를 각 서버에서 중복으로 구현할 필요가 없어진다.
어차피 모든 사용자의 요청은 가장 앞단에 있는 게이트웨이에서 받아서 처리하므로 게이트웨이 단에서 로그인 인증처리를 하고 해당 요청에 맞는 서버에 요청을 전달하면 된다. 그러면 서버는 '아 게이트웨이에서 로그인 인증을 받은 사용자니 문제가 없네' 라고 판단할 수 있으니 말이다.
1.로그인 구현은 Monolithic에서 했던 것 처럼 한 서버에서만 진행한다.
1-1.모놀리식에서는 아래 코드처럼 security 의존성을 추가하고 SecurityConfig클래스를 만들어 cors 설정을 진행했고, JwtAuthFilter 클래스를 만들어 로그인 인증처리를 하고, jwt토큰을 생성하는 TokenProvider클래스도 있었다.
@Configuration
@EnableMethodSecurity
public class SecurityConfigs {
private final JwtAuthFilter jwtAuthFilter;
public SecurityConfigs(JwtAuthFilter jwtAuthFilter) {
this.jwtAuthFilter = jwtAuthFilter;
}
@Bean
public SecurityFilterChain myFilter(HttpSecurity httpSecurity) throws Exception {
return httpSecurity
.cors(cors->cors.configurationSource(corsConfigurationSource()))
.csrf(AbstractHttpConfigurer::disable) //csrf 비활성화
.httpBasic(AbstractHttpConfigurer::disable) //HTTP basic 비활성화
// 특정 url 패턴에 대해서는 Authentication 객체 요구하지 않음 (인증처리 제외)
.authorizeHttpRequests(a->a.requestMatchers(
, "/ttt/user/create", "/ttt/user/login")
.permitAll().anyRequest().authenticated())
.sessionManagement(s-> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) //세션방식을 사용하지 않겠다라는 의미
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
@Bean
CorsConfigurationSource corsConfigurationSource(){
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Arrays.asList("http://localhost:3000"));
configuration.setAllowedMethods(Arrays.asList("*")); //모든 HTTP 메서드 허용
configuration.setAllowedHeaders(Arrays.asList("*")); //모든 헤더값 허용
configuration.setAllowCredentials(true); //자격 증명 허용
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration); //모든 url 패턴에 대해 cors 허용 설정
return source;
}
@Bean
public PasswordEncoder makePassword(){
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
아래 코드에서 토큰검증이 끝나면 Authentication객체의 로그인 검증이 된 사용자의 정보를 넣는 것이 msa에서는 없어질 부분이니 유의깊게 보길 바란다.
@Component
public class JwtAuthFilter extends GenericFilter {
@Value("${jwt.secretKey}")
private String secretKey;
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest httpServletRequest = (HttpServletRequest)servletRequest;
HttpServletResponse httpServletResponse = (HttpServletResponse) servletResponse;
String token = httpServletRequest.getHeader("Authorization");
try {
if(token != null){
if(!token.substring(0,7).equals("Bearer ")){
throw new AuthenticationServiceException("Bearer 형식이 아닙니다");
}
String jwtToken = token.substring(7);
// 토큰 검증 및 claims 추출
Claims claims = Jwts.parserBuilder()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(jwtToken)
.getBody();
// Authentication 객체 생성
List<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(new SimpleGrantedAuthority("ROLE_"+claims.get("role")));
UserDetails userDetails = new User(claims.getSubject(), "", authorities);
Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}catch (Exception e){
e.printStackTrace();
httpServletResponse.setStatus(HttpStatus.UNAUTHORIZED.value());
httpServletResponse.setContentType("application/json");
httpServletResponse.getWriter().write("invalid token");
}
filterChain.doFilter(servletRequest, servletResponse);
}
}
@Component
public class JwtTokenProvider {
// AccessToken 관련 설정값
private final String secretKey;
private final int expiration;
private final Key SECRET_KEY;
// RefreshToken 관련 설정값
private final int expirationRt;
private final String secretKeyRt;
private final Key SECRET_KEY_RT;
public JwtTokenProvider(@Value("${jwt.secretKey}") String secretKey, @Value("${jwt.expiration}") int expiration,
@Value("${jwt.expirationRt}")int expirationRt, @Value("${jwt.secretKeyRt}")String secretKeyRt) {
this.secretKey = secretKey;
this.expiration = expiration;
this.SECRET_KEY = new SecretKeySpec(java.util.Base64.getDecoder().decode(secretKey), SignatureAlgorithm.HS512.getJcaName());
this.expirationRt = expirationRt;
this.secretKeyRt = secretKeyRt;
SECRET_KEY_RT = new SecretKeySpec(java.util.Base64.getDecoder().decode(secretKeyRt), SignatureAlgorithm.HS512.getJcaName());
}
public String createToken(String loginId, String role, String nickName){
Claims claims = Jwts.claims().setSubject(loginId);
claims.put("role", role);
// 채팅에서 필요한 nickname추가
claims.put("nickName", nickName);
Date now = new Date();
String token = Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(new Date(now.getTime()+expiration*60*1000L))
.signWith(SECRET_KEY)
.compact();
return token;
}
public String createRefreshToken(String loginId, String role, String nickName){
Claims claims = Jwts.claims().setSubject(loginId);
claims.put("role", role);
claims.put("nickName", nickName);
Date now = new Date();
String refreshToken = Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(new Date(now.getTime()+expirationRt*60*1000L))
.signWith(SECRET_KEY_RT)
.compact();
return refreshToken;
}
}
1-2. MSA는 이중 cors 설정과 로그인 인증(검증)처리만 하는 로직이 게이트웨이로 옮겨가는 것이다.
그리고 로그인을 구현하는 서버에서는 security의존성을 추가할 필요도 없다.
위 Monolithic의 SecurityConfig와 비교해보면 cors 설정 관련한 로직이 없다.
단, 회원가입을 하는 과정에서 암호화된 비밀번호를 기입해야하므로 PasswordEncoder를 만드는 부분은 남겨둔다.
@Configuration
public class SecurityConfig {
@Bean
public PasswordEncoder makePassword(){
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
TokenProvider도 똑같이 구현하면 된다.
public class JwtTokenProvider {
private String secretKey;
private int expiration;
private Key SECRET_KEY;
private String secretKeyRt;
private int expirationRt;
private Key SECRET_KEY_RT;
public JwtTokenProvider(@Value("${jwt.secretKey}")String secretKey, @Value("${jwt.expiration}") int expiration,@Value("${jwt.secretKeyRt}")String secretKeyRt, @Value("${jwt.expirationRt}")int expirationRt) {
this.secretKey = secretKey;
this.expiration = expiration;
this.SECRET_KEY = new SecretKeySpec(Base64.getDecoder().decode(secretKey), SignatureAlgorithm.HS512.getJcaName());
this.expirationRt =expirationRt;
this.secretKeyRt = secretKeyRt;
this.SECRET_KEY_RT = new SecretKeySpec(Base64.getDecoder().decode(secretKeyRt), SignatureAlgorithm.HS512.getJcaName());
}
public String createToken(String loginId, String role){
Claims claims = Jwts.claims().setSubject(loginId);
claims.put("role",role);
Date now = new Date();
String token = Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(new Date(now.getTime()+expiration*60*1000L)) //30분세팅
.signWith(SECRET_KEY)
.compact();
return token;
}
public String createRefreshToken(String loginId, String role){
Claims claims = Jwts.claims().setSubject(loginId);
claims.put("role",role);
Date now = new Date();
String refreshToken = Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(new Date(now.getTime()+expiration*60*1000L))
.signWith(SECRET_KEY_RT)
.compact();
return refreshToken;
}
}
그리고 위 SecurityConfig에서 없어진 cors설정은 게이트웨이의 yml 설정파일에 들어간다.
eureka:
client:
serviceUrl:
defaultZone: http://localhost:8761/eureka/
spring:
application:
name: api-gateway
cloud:
gateway:
# CORS공통처리
globalcors:
cors-configurations:
'[/**]':
allowedOrigins: 'http://localhost:3000'
allowedMethods: '*'
allowedHeaders: '*'
allowedCredentials: true
routes:
- id: user-service
predicates:
- Path=/user-service/**
filters:
# StripPrefix : 첫번째 접두어를 제거 후에 user-service로 http요청 전달
- StripPrefix=1
uri: lb://user-service
- id: post-service
predicates:
- Path=/post-service/**
filters:
- StripPrefix=1
uri: lb://post-service
- id: chat-service
predicates:
- Path=/chat-service/**
filters:
- StripPrefix=1
uri: lb://chat-service
jwt:
secretKey: ababab
2.로그인 인증 처리는 게이트웨이에서!
이 JwtAuthFilter 로직은 로그인을 구현한 서버에 두는 것이 아니라 게이트웨이 서버에 둔다.
그리고 아래 코드에서 주목할 점은 바로 로그인 인증처리를 한 뒤 다른 서버로 요청을 전달하기 전에 헤더부분에
loginId를 추가하는 것이다.
모놀리식에서는 한 서버내에서 모든 로직이 구현되어있기 때문에 로그인 인증처리 후 authentication객체에 로그인 인증된 사용자의 정보를 넣고 다른 여러 로직에서 SecurityContextHolder.getContext.getAuthentication을 사용해서 현재 로그인한 사용자의 정보를 편하게 꺼내왔다.
그러나 msa에서는 로그인 인증 처리 후 똑같이 authentication객체에 사용자 정보를 넣더라도 게이트웨이 서버의 authentication객체에 사용자 정보가 있을 뿐 다른 서비스를 다루는 서버에서는 사용자 정보가 없다.
그렇다면 다른 서비스를 다루는 서버에서 현재 로그인한 사용자를 찾아오기가 힘든 문제가 발생한다.
그래서 게이트웨이에서는 로그인 인증 처리를 끝내고 사용자의 요청을 다른 서버에 전달하기 전에. 그 요청의 헤더부분에다 로그인 인증 처리가 끝난 사용자의 정보를 담아다 넘기는 것이다.
그러면 요청을 전달받은 서버에서는 그 요청의 헤더부분에서 쉽게 사용자의 정보를 꺼내 사용자를 찾아올 수 있게 되는 것이다.
@Component
public class JwtAuthFilter implements GlobalFilter {
//GlobalFilter는 Spring Cloud Gateway의 모든 요청을 가로채는 필터 인터페이스
//이 필터가 있기때문에 필터가 생기는 security의존성을 게이트웨이의 build.gradle에 추가하지 않아도 됨.
@Value("${jwt.secretKey}")
private String secretKey;
// 인증이 필요 없는 경로 설정 아래쪽 코드를 보면 여기 리스트에 있는 경로에 대해서는 토큰을 꺼내 검증하는 로직에서 패스시킴
private static final List<String> ALLOWED_PATHS = List.of(
"/silverpotion/user/create",
"/silverpotion/user/login",
"/silverpotion/gathering-category",
"/silverpotion/gathering-category/detail"
);
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
//ServerWebExchange : 요청과 응답을 담고 있는 WebFlux 의 객체(HttpServletRequest/Response같은 역할), GatewayFilterChain: 다음 필터 또는 서비스로 요청을 넘기는 체인
//우리가 지금껏 해오던건 Spring MVC방식, 게이트웨이는 WebFlux방식으로 작동함
//Mono<void>= 아무 값도 없이 완료 신호만 보내는 비동기 응답 ex.Mono.error =예외 발생 , Mono.empty = 작업끝났음
//token검증
System.out.println("token 검증 시작");
String bearerToken = exchange.getRequest().getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
String path = exchange.getRequest().getPath().toString();
if (ALLOWED_PATHS.contains(path)) {
return chain.filter(exchange); //현재 요청을 담고 있는 exchange를 들고 다음 필터나 서비스로 넘어가라는 뜻
}
try {
if (bearerToken == null || !bearerToken.startsWith("Bearer ")) {
throw new IllegalArgumentException("token 관련 예외 발생");
}
String token = bearerToken.substring(7);
//게이트웨이인 이곳에서 token 검증하고 token에 있는 claim 추출
Claims claims = Jwts.parserBuilder()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token)
.getBody();
//claim에 있는 사용자 Id추출
String userId = claims.getSubject(); //우리가 tokenProvider에서 claim의subject에 loginId를 담았었으니까 아이디 추출가능
String role = claims.get("role", String.class);
//헤더에 X-User-Id변수로 id값과 role을 추가
//X를 붙이는 것은 custom header라는 것을 의미하는 것으로 널리 쓰이는 관례
ServerWebExchange modifiedExchange = exchange.mutate() // 사용자의 요청이 담겨있는 exchange의 헤더 부분을 커스텀하고 있음
.request(builder -> builder
.header("X-User-Id", userId)
.header("X-User-Role", "ROLE_" + role))
.build();
//ServerWebExchange는 불변객체로 사실 수정할 수 없다.그래서 복사본을 만드는 메서드 mutate(). 그런데 이 메서드의 리턴 타입이
//ServerWebExchange.Builder이고 .request는 요청(request)부분을 수정하겠다는 뜻
//다시 filter chain으로 되돌아 가는 로직
return chain.filter(modifiedExchange); //우리가 커스텀한 요청인 modifiedExchange를 들고 다음 필터나 서비스로 리턴
} catch (IllegalArgumentException | MalformedJwtException | ExpiredJwtException | SignatureException |
UnsupportedJwtException e) {
e.printStackTrace();
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete();
}
}
}
그러면 다른 서버의 컨트롤러단에서는 게이트웨이를 통해 전달받은 요청의 헤더부분에서 RequestHeader 어노테이션을 사용해서 쉽게 현재 로그인 사용자의 아이디를 꺼내쓸 수 있는 것이다.
그리고 서비스단에서 이 아이디를 통해 user Repository 로부터 사용자를 찾아오면 현재 로그인한 사용자 객체를 불러올 수 있게 되는 것이다.
@GetMapping("/myprofile")
public ResponseEntity<?> myProfile(@RequestHeader("X-User-LoginId")String loginId){
UserMyPageDto dto = userService.userMyPage(loginId);
return new ResponseEntity<>(new CommonDto(HttpStatus.OK.value(), "success",dto),HttpStatus.OK);
}
@PostMapping("/profileImg")
public ResponseEntity<?> postProfileImage(@RequestHeader("X-User-LoginId")String loginId,UserProfileImgDto dto){
String s3Url = userService.postProfileImage(loginId,dto);
return new ResponseEntity<>(new CommonDto(HttpStatus.OK.value(), "sucess",s3Url),HttpStatus.OK);
}
'실버케어 플랫폼 프로젝트' 카테고리의 다른 글
[Spring] 결제(포트원) 연동[결제 전/후 검증까지] (1) | 2025.04.10 |
---|---|
[FireBase]FCM 앱-스프링 연동 (3) | 2025.04.08 |
[Android]헬스커넥트 연동 앱 만들기 (2) | 2025.04.03 |
[개념]WebRTC 개념 정리 (0) | 2025.03.31 |