[Spring Cloud] Eureka, Gateway를 활용한 MSA 개발

이준영·2024년 11월 28일

Spring MSA 프로젝트

목록 보기
6/15
post-thumbnail

개요

이제 모듈이 여러개로 나누어져도 통일 된 설정 값을 사용할 수 있게 되었다.(이전 Config 관련 포스팅 참조)
본격적으로 기능을 추가하기에 앞서 Eureka와 Gateway를 적용 해보자.

+추가로 서비스 간 통신을 위해서 OpenFeign을 사용하는데 당장에는 User Service 밖에 없으니 추후 새로운 기능을 개발하게 되면 사용해보도록 하겠다.

Eureka

참조: https://ksh-coding.tistory.com/137
위 블로그에서 Monolithic 프로젝트를 MSA 프로젝트로 전환하는 과정이 포스팅 되어 있는데 Service Discovery 패턴과 Eureka에 대한 내용까지 잘 정리되어 있어 참조하였다.

결론은 인프라 작업인 앞단에 Load Balancer를 배치하는 작업을 하지 않고, 애플리케이션 코드 단에서 Service Discovery 패턴을 상대적으로 간단하게 구현할 수 있는 Client-Side Registry를 구현하기 위해 Eureka 라이브러리를 활용하였다.

적용 방법은 Eureka Server를 만들고 Eureka Client를 등록하는 방식으로 진행된다.

Eureka Server

implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-server'
@EnableEurekaServer
@SpringBootApplication
public class EurekaServerApplication {

	public static void main(String[] args) {
		SpringApplication.run(EurekaServerApplication.class, args);
	}

}
  eureka:
    instance:
      prefer-ip-address: true
    client:
      register-with-eureka: true
      fetch-registry: true
      service-url:
        defaultZone: http://username:password@localhost:8761/eureka/

먼저는 새로운 프로젝트를 생성하여 eureka-server 의존성을 추가해준다.
기본적으로 8761 포트를 사용하며 @EnableEurekaServer 어노테이션을 추가해두면 끝이다.

간단히 설정에 대한 설명만 보고 넘어가자.

  • eureka.instance.prefer-ip-address : 서비스의 호스트 이름이 아닌, IP 주소를 Eureka Server에 등록할지 여부
  • eureka.client.register-with-eureka : Eureka Server에 자기 자신을 등록할지의 여부
  • eureka.client.fetch-registry : Eureka Server의 등록되어 있는 서비스들을 캐싱해둘지의 여부
  • security login 설정을 했다면 defaultZone에 username과 password를 명시해줘야한다.
http.formLogin(Customizer.withDefaults());

필자는 spring securiry 설정을 추가하여 기본 formLogin을 통해 Eureka 대쉬보드에 접속가능하도록 했다.(자세한 코드는 GitHub 참조)

참고로 유레카는 spring.application.name을 기준으로 서비스를 인식한다.

Eureka Client

implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client'
@EnableDiscoveryClient
@SpringBootApplication
public class ApiGatewayApplication {

	public static void main(String[] args) {
		SpringApplication.run(ApiGatewayApplication.class, args);
	}

}

client도 비슷하게, eureka client 의존성 추가 후 @EnableDiscoveryClient 어노테이션을 추가해주면 된다.

yml 설정은 동일하게 작성해준다.

이후 /localhost:8761 로 접속하면 유레카 대쉬보드가 펼쳐지며 각 마이크로 서비스들의 활성화 상태를 확인할 수 있다.

Gateway

참조: https://ksh-coding.tistory.com/138?category=1107116
이제 Spring Cloud Gateway를 적용 해보자.

Gateway를 적용하게 되면 모든 요청은 Gateway가 받게 된다. 이후 설정된 라우팅 주소로 전송해주는데 다양한 필터를 적용하여 권한을 부여하거나, 토큰 검증, 반환값 변경 등의 작업을 수행할 수도 있다.

이번 프로젝트에서는 Gateway에서 로그인 토큰 검증과 라우팅 처리를 담당하도록 할 예정이다.

implementation 'org.springframework.cloud:spring-cloud-starter-gateway'
spring:
  cloud:
    gateway:
      routes:
        - id: user-service
          uri: lb://user-service
          predicates:
            - Path=/api/users, /api/users/**
            - Method=GET,PUT,DELETE
        - id: user-service
          uri: lb://user-service
          predicates:
            - Path=/api/login
            - Method=POST
          filters:
            - RewritePath=/api/login, /login
        - id: user-service
          uri: lb://user-service
          predicates:
            - Path=/api/users/register
            - Method=POST
          filters:
            - RewritePath=/api/users/register, /api/users
      default-filters:
        - name: GlobalAuthFilter

의존성 추가 후 설정 파일을 작성하였다.

- id: user-service
  uri: lb://user-service
  predicates:
    - Path=/api/login
    - Method=POST
  filters:
    - RewritePath=/api/login, /login

이 부분이 라우팅 처리하는 부분이다.

  • id는 라우팅 id를 지정해준다.
  • uri에 위와 같이 lb 프로토콜(lb://)의 경로를 지정하면 기본적으로 Eureka Server에서 호스트에 해당하는 서비스를 찾고 로드밸런싱을 수행한다.
  • Predicates는 라우팅을 적용할 조건이다.
    예를들면, localhost:8080/api/users로 들어온 요청은 user-service/api/users로 라우팅 된다.
  • filters.RewritePath를 작성하게 되면 /api/login을 /login으로 경로를 재설정한다. /register의 경우 user-service의 post메서드의 /users로 매핑되어 있지만 로그인 검증 필터 예외 처리를 위해 위와 같이 경로를 /register로 받도록 하였다.
  • 설정 값 중 최하단 default-filters:에 GlobalAuthFilter를 적용하였다. 해당 필터는 기본적으로 모든 요청마다 실행되어 로그인 토큰 유효성 검증을 수행하도록 하였다.

토큰 유효성 검증 필터(GlobalAuthFilter)

우선 로그인 기능은 user-service에서 spring security filter를 적용하였다.

  • /login 경로로 username과 password를 받아 db user테이블에 존재하는지와 패스워드 검증을 진행 한다.
  • 검증 성공 시 JWT 토큰을 발급한다.
  • 발급 된 토큰은 Redis 저장소에 저장 한다.

위와 같은 과정으로 인증/인가 작업을 마치면 Redis 저장소에 토큰 값이 저장된다.

이후에는 API-GATEWAY에서 토큰 값을 받아 username을 추출하고 username으로 저장된 토큰과 요청 받은 토큰을 비교한다. 모든 검증이 통과하면 이후 라우팅 처리를 진행한다.

@Slf4j
@Component
public class GlobalAuthFilter extends AbstractGatewayFilterFactory<GlobalAuthFilter.Config> {

    private final RedisTokenService tokenService;
    private final ObjectMapper objectMapper;

    public GlobalAuthFilter(RedisTokenService redisTokenService, ObjectMapper objectMapper) {
        super(Config.class);
        this.tokenService = redisTokenService;
        this.objectMapper = objectMapper;
    }

    public static class Config {
        // 필터의 설정값을 정의할 수 있습니다
    }

    private final List<String> excludeUris = List.of("/api/login", "/api/users/register");

    @Value("${spring.jwt.prefix}")
    private String prefix;


    @Override
    public GatewayFilter apply(Config config) {
        return (ServerWebExchange exchange, GatewayFilterChain chain) -> {
            log.info("[GLOBAL_AUTH_FILTER] Start");

            ServerHttpRequest request = exchange.getRequest();
            String path = request.getURI().getPath();
            log.info("[GLOBAL_AUTH_FILTER] Path = {}", path);
            for (String excludeUri : excludeUris) {
                if (excludeUri.equals(path)) {
                    log.info("[GLOBAL_AUTH_FILTER] Excluded Filter");
                    return chain.filter(exchange);
                }
            }

            String token = exchange.getRequest().getHeaders().getFirst("Authorization");
            log.info("[GLOBAL_AUTH_FILTER] Token = {}", token);

            if (token == null || !token.startsWith(prefix)) {
                log.info("[GLOBAL_AUTH_FILTER] Invalid Token");
                return sendResponse(exchange, ApiResponseCode.INVALID_TOKEN);
            }

            token = token.replace(prefix, "");
            log.info("[GLOBAL_AUTH_FILTER] Token = {}", token);

            boolean isValid = tokenService.validateToken(token);
            log.info("[GLOBAL_AUTH_FILTER] Token is Valid = {}", isValid);
            if (!isValid) {
                return sendResponse(exchange, ApiResponseCode.INVALID_TOKEN);
            }

            log.info("[GLOBAL_AUTH_FILTER] End");
            return chain.filter(exchange);
        };
    }

    private Mono<Void> sendResponse(ServerWebExchange exchange, ApiResponseCode responseCode) {
        try {
            String responseBody = objectMapper.writeValueAsString(ApiResponse.fail(responseCode));

            exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
            exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON);

            return exchange.getResponse().writeWith(Mono.just(exchange.getResponse()
                .bufferFactory().wrap(responseBody.getBytes())));
        } catch (Exception e) {
            throw new CustomException(ApiResponseCode.FAIL);
        }
    }

}

gateway는 webflux 기반으로 동작하기 때문에 Mono를 활용해야 한다.

마치며

이렇게 API-GATEWAY 적용을 마쳤고 토큰 검증 기능까지 추가 되어 주요 기능 개발을 진행하면 될 것 같다.

그전에 현재 Redis도 활용하고 있기 때문에 리프레시 토큰 방식을 추가로 적용하고 사용자 삭제 스케줄러 구현, 현재까지 개발 된 기능, 아키텍처 구성도 작성, 나름의 코드 컨벤션과 같은 내용을 한번 정리하고 넘어가려고 한다.

profile
환영합니다!

0개의 댓글