<이전 글>
문제 배경
원래 로그인 시 필자가 담당하는 유저 모듈에서 JWT 토큰을 발급하고 이를 통해 인증을 처리하였으나, 다른 모듈로 요청이 전달될 때 인증 및 인가 정보를 제대로 전달받지 못하는 문제가 발생했다!
이로 인해 유저 정보가 필요한 다른 모듈에서 기능이 작동되지 않는 상황..
해결 방안
문제를 해결하기 위해서 우리 팀원들이 고려한 두 가지 방안은 이렇다 :
- 모든 모듈에서 인증/인가를 구현하여 개별적으로 처리하는 방법
- api-gateway 모듈을 통해 인증/인가를 처리하는 방법
이 중 두 번째 방법을 채택하였는데 그 이유는,
api-gateway 모듈에서 인증/인가를 처리하게 된다면 코드 중복을 방지하고 다른 모듈을 추가하는 상황에서도 따로 인증/인가 로직을 추가할 필요가 없어 유지보수가 쉬워진다는 장점이 있기 때문이다.
즉! 해당 방법이 시스템 성능을 더 향상시킬 수도 있고 더 객체지향적인 방법이란 말!
구현 내용
user 모듈
- 로그인 성공 시 user 모듈에서 JWT 토큰을 발급
- 발급 된 JWT 토큰을 응답 헤더에 추가해서 반환
api-gateway 모듈
- api-gateway로 들어온 요청의 헤더에 저장되어있는 토큰을 해석하여 유저 정보를 추출
- 각 요청에 맞는 서비스 모듈로 요청을 재전송할 때 유저의 정보 'X-USER-ID' 를 헤더에 추가
구현 코드
▪️api-gateway 모듈
1. FilterConfig : 라우트 구성
회원가입과 로그인 api를 제외한 모든 요청은 AuthorizationHeaderFilter를 거쳐간다.
@Configuration
public class FilterConfig {
@Bean
public AuthorizationHeaderFilter authorizationHeaderFilter(TokenValidator tokenValidator) {
return new AuthorizationHeaderFilter(tokenValidator);
}
@Bean
public RouteLocator gatewayRoutes(RouteLocatorBuilder builder, AuthorizationHeaderFilter authorizationHeaderFilter) {
return builder.routes()
.route(r -> r.path("/api/v1/users/**")
.filters(f -> f.filter(authorizationHeaderFilter.apply(
new AuthorizationHeaderFilter.Config()
.setWhiteList(Arrays.asList("/api/v1/users/signup", "/api/v1/users/login", "/api/v1/users/verification"))))
.rewritePath("/api/v1/users/(?<segment>.*)", "/api/v1/users/${segment}"))
.uri("http://localhost:8081"))
.build();
}
}
2. AuthorizationHeaderFilter :
- TokenValidator로 토큰 검증
- 토큰에서 유저 아이디 추출
- 추출 된 아이디는 'X-USER-ID'로 헤더에 저장
- 화이트리스트(로그인, 회원가입) 설정
@Component
public class AuthorizationHeaderFilter extends AbstractGatewayFilterFactory<AuthorizationHeaderFilter.Config> {
private final TokenValidator tokenValidator;
@Autowired
public AuthorizationHeaderFilter(TokenValidator tokenValidator) {
super(Config.class);
this.tokenValidator = tokenValidator;
}
@Override
public GatewayFilter apply(Config config) {
return (exchange, chain) -> {
final ServerHttpRequest request = exchange.getRequest();
String path = request.getURI().getPath();
if (config.getWhiteList() != null && config.getWhiteList().stream().anyMatch(path::startsWith)) {
return chain.filter(exchange);
}
if (!request.getHeaders().containsKey(HttpHeaders.AUTHORIZATION)) {
return handleUnAuthorized(exchange);
}
String token = request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
if (token != null && token.startsWith("Bearer ")) {
token = token.substring(7);
}
try {
if (!tokenValidator.validateToken(token)) {
return handleUnAuthorized(exchange);
}
String userId = tokenValidator.getUserId(token);
ServerHttpRequest modifiedRequest = request.mutate()
.header("X-USER-ID", userId)
.build();
return chain.filter(exchange.mutate()
.request(modifiedRequest)
.build());
} catch (Exception e) {
return handleUnAuthorized(exchange);
}
};
}
private Mono<Void> handleUnAuthorized(ServerWebExchange exchange) {
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return response.setComplete();
}
public static class Config {
private List<String> whiteList;
public List<String> getWhiteList() {
return whiteList;
}
public Config setWhiteList(List<String> whiteList) {
this.whiteList = whiteList;
return this;
}
}
}
3. TokenValidator : 토큰 검증 로직
@Component
public class TokenValidator {
@Value("${spring.jwt.secret}")
private String secretKey;
private SecretKey key;
@PostConstruct
protected void init() {
this.key = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8));
}
public String getUserId(String token) {
return parseClaims(token).getSubject();
}
public boolean validateToken(String token) {
Claims claims = parseClaims(token);
return claims.getExpiration().after(new Date());
}
private Claims parseClaims(String token) {
return Jwts.parserBuilder().setSigningKey(key).build()
.parseClaimsJws(token)
.getBody();
}
}
▪️user 모듈
1. 로그인 API 구현
로그인 시 JwtTokenProvider에서 JWT토큰을 발급받아 인증한다.
@RestController
@RequestMapping("/api/v1/users")
@RequiredArgsConstructor
public class UserLoginController {
private final UserLoginService userLoginService;
@PostMapping("/login")
public ResponseEntity<JwtTokenDto> login(@RequestBody UserLoginRequestDto userLoginRequestDto) {
JwtTokenDto jwtTokenDto = userLoginService.login(userLoginRequestDto);
return ResponseEntity.ok(jwtTokenDto);
}
}
@Service
@RequiredArgsConstructor
public class UserLoginService {
private final UserRepository userRepository;
private final JwtTokenProvider jwtTokenProvider;
private final PasswordEncoder passwordEncoder;
public JwtTokenDto login(UserLoginRequestDto userLoginRequestDto) {
String email = userLoginRequestDto.email();
String password = userLoginRequestDto.password();
User user = userRepository.findByEmail(email)
.orElseThrow(() -> new CustomException(ErrorCode.EMAIL_OR_PASSWORD_INVALID));
if (!passwordEncoder.matches(password, user.getPassword())) {
throw new CustomException(ErrorCode.EMAIL_OR_PASSWORD_INVALID);
}
String accessToken = jwtTokenProvider.generateAccessToken(email, user.getUserId());
Long expiresTime = jwtTokenProvider.getExpiredTime(refreshToken);
return new JwtTokenDto(accessToken, refreshToken, expiresTime);
}
}
2. 각 모듈 컨트롤러 코드 수정
헤더에 저장된 'X-USER-ID'를 통해 회원 정보를 알 수 있다.
ex1) 회원 정보 수정 API
@PutMapping("/update")
public ResponseEntity<ApiResponseDto<UserUpdateResponseDto>> update(
@RequestHeader(value = "X-USER-ID") Long userId,
@Valid @RequestBody UserUpdateRequestDto userUpdateRequestDto
) {
log.info("UserController - 회원정보 수정 요청: {}", userUpdateRequestDto);
UserUpdateResponseDto userUpdateResponseDto = userService.update(userId, userUpdateRequestDto);
return ResponseEntity.ok(new ApiResponseDto<>(HttpStatus.OK, "회원 정보 수정 성공", userUpdateResponseDto));
}
ex2) 일반 쿠폰 발급 API
@PostMapping("/general")
public ResponseEntity<ApiResponseDto<CouponIssuedResponseDto>> issueCoupon(
@RequestHeader(value = "X-USER-ID") Long userId, @RequestBody CouponIssuedRequestDto request) {
CouponIssuedResponseDto issued = couponIssuedService.issueCoupon(userId, request);
return ResponseEntity.ok(new ApiResponseDto<>(HttpStatus.OK, "쿠폰이 발급되었습니다.", issued));
}
마치며
이번 리팩토링은 MSA 프로젝트의 구조적 문제를 해결하는 과정이었다.
분리되어있는 모듈을 연결하는 과정!! 이 어려운 과제였다.
그래도 이번 리팩토링을 수행하면서, api-gateway 모듈로 모든 요청을 받는다는 점, 다른 모듈로 라우팅하는 방법, 라우팅 하기 전에 필터를 구현해서 로그인 유지를 해결하는 방법 등등 많은 배움이 있었다.
팀원들 모두가 같이 고생하고 오래 걸렸지만 다음 프로젝트에서는 조금 더 빠르게 구현 할 수 있을 것 같다!!!
'Project > MSA 프로젝트' 카테고리의 다른 글
[프로젝트][ES] ElasticSearch와 Kibana 설치 (feat. docker-compose) (0) | 2024.07.11 |
---|---|
[트러블슈팅] '선착순 쿠폰 발급' 로직 - Redis를 통한 동시성 문제 해결 (2/2) (0) | 2024.06.30 |
[트러블슈팅] '선착순 쿠폰 발급' 로직 - 동시성 문제 발생 (w/ Race Condition) (1/2) (2) | 2024.06.30 |
[프로젝트] 선착순/일반 쿠폰 발급 로직 분리에 대한 고민 (0) | 2024.06.29 |
[프로젝트][MSA] API Gateway 작성으로 모듈 별 연결하기 (0) | 2024.06.25 |