본문 바로가기
프로젝트/FitTrip

[MSA] FitTrip에서 MSA 설계 With Spring Cloud

by 진꿈청 2024. 7. 14.

📄 Describe

사전 설명

우리 FitTrip은 프로젝트 초반 제가 진행한 MSA 세미나를 통해 MSA에 관해 알아보았고,
해당 내용을 프로젝트에 적용했습니다.

 

MSA

image

 

FitTrip의 MSA 적용

  • API Gateway
  • Service Discovery
  • Container Management
  • Backing Services
    • DB(Redis, MySQL, MariaDB, MongoDB)
    • Kafka <- MOM
  • CI/CD Automation
  • Telemetry
    • Grafana
    • Loki

Config Store와 관련하여 Spring Cloud Config Server를 이용하고 싶었습니다.

하지만, Github Actions 사용시 발생하는 보안적인 문제가 있었습니다.
-> Config Server를 외부에서 접속하는 위험

  • 우리 도메인과 Config Server Port를 알고 있다면 누구나 접속 가능
  •  

해결을 위해 Spring SecurityBasic Auth를 사용하려 하였지만, 계속 연동에 오류가 생겼습니다.
(이 부분은 추후에 다시 시도해보려 합니다.)

 

 

아쉽게도 MSA의 Config Store를 구현하진 못했지만, 그래도 MSA 구성요소의 상당 부분을 다뤘다고 생각합니다.


FitTrip MSA 연동 서비스 구현

저는 MSA의 중심 서비스가 되는 Service DiscoveryAPI Gateway 구현을 위해
Spring Cloud 라이브러리를 활용해 구현했습니다.

  • Spring Netflix Eureka Server
  • Spring Netflix Eureka Client
  • Spring Cloud Gateway

image

 

image

Spring Netflix EurekaService Registration & Discovery 역할을 해주는 아주 편리한 친구입니다.\

 

FitTrip은 Spring Netflix Eureka를 활용하여 각 서비스를 Service Discovery에 등록하였고,
클라이언트의 요청은 Spring Cloud Gateway를 활용하여 라우팅을 해줍니다.

 

 

Netflix Eureka Server(Service Discovery)에 등록된 서비스들

image

 

 

Spring Cloud Gateway가 클라이언트의 요청을 전담하게 되면서 생기는 몇 가지 이점이 존재합니다.

  1. 각 서비스들은 Service Discovery에 등록이 되므로 API Gateway는 각 서비스를 확인할 필요가 없음
  2. Scale-Out시 용이함(서비스의 접속 경로와 포트 번호는 Service Discovery가 알고 있음)
  3. 인증/인가 작업
  4. 로깅

Spring 라이브러리의 좀 더 자세한 내용은 다음 주소를 참고하면 됩니다.(https://hdbstn3055.tistory.com/72)

 

 

FitTrip의 아키텍처

image

FitTrip의 아키텍처는 위와 같습니다. 여기서, 설명한 서비스들(Service Discovery, API Gateway)은 어떻게 동작할까요?


Spring Netflix Eukreka Server 설정

@SpringBootApplication
@EnableEurekaServer
public class DiscoveryServiceApplication {

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

}
server:
  port: 8761

spring:
  application:
    name: discovery-service

eureka:
  client:
    register-with-eureka: false
    fetch-registry: false

위의 설정을 갖는 Service Discovery 역할의 디스커버리 서비스를 구성합니다.
해당 서비스를 통해 우리는 FitTrip의 서비스들을 식별할 수 있고 관리할 수 있습니다.

 

Spring Netflix Eukreka Client 설정

@SpringBootApplication
@EnableFeignClients
@EnableDiscoveryClient
public class GatewayServiceApplication {

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

}
eureka:
  client:
    fetch-registry: true # 유레카 클라이언트 활성화
    register-with-eureka: true # 유레카 클라이언트 활성화
    service-url:
      defaultZone: http://discovery-service:8761/eureka # 유레카 클라이언트로 등록
    registryFetchIntervalSeconds: 10 # Eureka 클라이언트가 Eureka 서버로부터 서비스 인스턴스 목록을 가져오는 간격을 10초로 설정
  instance:
    instance-id: {서비스별}
  application:
    name: {서비스별}

위의 설정을 각 서비스들에 해주게 되면 Eureka Client로서,

Service DiscoveryEureka Server에 등록이 됩니다.

 

즉, 이제는 Service Discovery를 통해 서비스들의 URL을 알 수 있습니다.


FitTrip 동작 흐름

image



nginx 설정

server {
    listen 80;
    server_name fittrip.site;
    return 301 https://fittrip.site$request_uri;
}

map $http_origin $allowed_origin {
    default "";
    "http://localhost:3000" $http_origin;
}

server {
    listen 443 ssl;
    server_name fittrip.site;

    ssl_certificate /etc/letsencrypt/live/fittrip.site/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/fittrip.site/privkey.pem;
    include /etc/letsencrypt/options-ssl-nginx.conf;

    location /api {
        if ($request_method = 'OPTIONS') {
            add_header 'Access-Control-Allow-Origin' $allowed_origin always;
            add_header 'Access-Control-Allow-Methods' 'GET, POST, DELETE, PATCH, OPTIONS';
            add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization';
            add_header 'Access-Control-Allow-Credentials' 'true';
            return 204;
        }
    add_header 'Access-Control-Allow-Origin' $allowed_origin always;
        add_header 'Access-Control-Allow-Credentials' 'true';
        proxy_pass http://localhost:8000;
    }

    location /stomp {
        proxy_pass http://localhost:8000;
        proxy_set_header Host $http_host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }

    location /api/notice {
        if ($request_method = 'OPTIONS') {
            add_header 'Access-Control-Allow-Origin' $allowed_origin always;
            add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS, DELETE';
            add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization';
            add_header 'Access-Control-Allow-Credentials' 'true';
            return 204;
        }
        add_header 'Access-Control-Allow-Origin' $allowed_origin always;
        add_header 'Access-Control-Allow-Credentials' 'true';

          add_header Cache-Control no-cache;

        proxy_pass http://localhost:8000;
        proxy_set_header Host $http_host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
          proxy_set_header X-Accel-Buffering 'no';
        proxy_set_header Connection '';
        proxy_http_version 1.1;
        proxy_buffering off;
        keepalive_timeout 7200;
    }
}

FitTrip은 위의 그림/NGINX 설정 예시처럼 유저의 요청을 라우팅하고 처리합니다.
(채팅 서비스의 경우는 STOMP 사용으로 조금 다르게 동작합니다. #113)

 

 

잠깐, 주의할 점이 있습니다.


우리는 이제 API Gateway를 사용합니다. 이제 유저의 모든 요청은 API Gateway를 거쳐 갑니다.

이때, 특별한 작업을 해주지 않으면MSA가 갖는 이점인 쉬운 Scale-Out에 작은 문제가 생길 수 있습니다.

 

 

API Gateway 사용하지 않는 경우


Spring 컨테이너에 대한 포트 번호를 지정하여 사용합니다.
NGINX는 해당 포트 번호로 라우팅을 합니다.

 

 

API Gateway 사용하는 경우
게이트웨이는 Service Discovery로부터 서비스 접속 경로PORT를 전달받습니다.


하지만, 이때 각 서비스마다 포트 번호가 지정되어 있다면?

서버 구성에 따라 Scale-Out시 포트 충돌이 일어날 것입니다.


즉, Service Discovery에 등록만 되면 API Gateway가 확인하여 라우팅 하는
아주 간단한 작업이 불가능해집니다.

 

따라서, 우리는 포트 번호를 고정하지 않도록 작업해야 합니다.

방법은 아주 간단합니다.

 

 

각 서비스
application.yml에 아래 내용을 추가해주면 됩니다.

server:
  port: 0

위 설정시 Spring 애플리케이션은 사용가능한 남는 포트를 알아서 지정합니다.

 

 

게이트웨이 서비스
routes 설정에 lb://{서비스 이름}을 추가해줍니다.

      routes:
        - id: user-service
          uri: lb://USER-SERVICE # 포워딩할 주소, http://localhost:8000/user 로 들어오면 http://localhost:64412 로 포워딩
          predicates:
            - Path=/api/user/** # 해당 gateway 서버의 /user/**로 들어오는 요은 user-service로 인식하겠다는 조건
          filters:
            - RewritePath=/api/user/?(?<segment>.*), /$\{segment}

        - id: community-service
          uri: lb://COMMUNITY-SERVICE # 포워딩 할 주소, http://localhost:8000/team 로 들어오면 http://localhost:54412 로 포워딩
          predicates:
            - Path=/api/community/** # 해당 gateway 서버의 /order/**로 들어오는 요청은 order-service로 인식하겠다는 조건
          filters:
            - RewritePath=/api/community/?(?<segment>.*), /$\{segment}

 

 

 

 

그리고 조금 특별한 경우로, 우리 FitTrip은 각 서비스마다 서버를 전부 구동하는 건 재정적인 이슈로 불가능했습니다.


따라서, 한 서버를 구동하되 조금 성능이 좋은 서버를 사용하는 것으로 하였습니다.

 

그렇기에 한 서버에 다양한 서비스를 띄워 관리하였고 그에 따라 docker-compose를 사용하였습니다.


즉, 한 서버 내에서 API Gateway를 사용하여 각 서비스로 라우트 합니다.

그에 따라 docker-compose.yml에서도 포트 번호를 지정하지 않아야 합니다.

 

docker-compose 설정

...
  notification-service:
    image: hanyoonsoo/notification-service:1.0
    container_name: notification-service
    environment:
      jasypt.encryptor.password: ${JASYPT_PASSWORD}
    depends_on:
      - zookeeper
      - kafka
      - discovery-service
      - gateway-service
      - mysql-notification
    volumes:
      - ./logs/notification-service:/logs
...

 

보통 PORTS를 통해 포트를 지정하지만, FitTrip에서는 지정하지 않았습니다.

 

이로써, 우리는 언제 Scale-Out이 되어도 문제가 없는 라우팅이 가능해졌습니다.
(무중단 배포와는 다른 의미)


API Gateway 인증/인가 관리

FitTrip은 유저 서비스에서 발급된 JWT를 이용하여 보안 작업을 진행합니다.
따라서, 그에 맞게 API Gateway는 인증/인가 처리를 해주어야 합니다.

 

인증/인가를 위해 API Gateway에는 AuthorizationFilter라는 커스텀 필터를 생성했습니다.

 

 

AuthorizationFilter

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

    private final JwtTokenValidator jwtTokenValidator;

    private final WebClient.Builder webClientBuilder;

    public AuthorizationFilter(JwtTokenValidator jwtTokenValidator, WebClient.Builder webClientBuilder){
        super(Config.class);
        this.jwtTokenValidator = jwtTokenValidator;
        this.webClientBuilder = webClientBuilder;
    }

    @Override
    public GatewayFilter apply(Config config) {
        return (exchange, chain) -> {

            String uri = exchange.getRequest().getURI().getPath();


            if(isWhiteList(uri)){
                return chain.filter(exchange);
            }

            String accessToken =
                    jwtTokenValidator.resolveAccessToken(exchange.getRequest());

            String refreshToken =
                    jwtTokenValidator.resolveRefreshToken(exchange.getRequest());


            if (StringUtils.hasText(accessToken) && jwtTokenValidator.validateToken(accessToken)) {
                return doNotLogout(accessToken, exchange.getRequest())
                        .flatMap(isValid -> {
                            if (Boolean.TRUE.equals(isValid)) {
                                // Token is valid, continue to the next filter
                                    return chain.filter(exchange);
                            } else {
                                // Token is not valid, respond with unauthorized
                                System.out.println(isValid);
                                return unauthorizedResponse(exchange);
                            }
                        })
                        .onErrorResume(e -> {
                            log.error("API Gateway - AccessToken validation error: {}", e.getMessage());
                            // Handle runtime exceptions by returning unauthorized response
                            return unauthorizedResponse(exchange);
                        });
            } else {
                // If accessToken is not valid or not present
                return unauthorizedResponse(exchange);
            }
        };
    }

    private boolean isWhiteList(String uri) {
        for (WhiteListURI whiteListURI : WhiteListURI.values()) {
            // 해당 URI가 화이트 리스트에 정의된 URI로 시작하는지 확인
            System.out.println(uri);
            if (uri.startsWith(whiteListURI.uri)) {
                return true;
            }
        }
        // 화이트 리스트에 없으면 false 반환
        return false;
    }


    private Mono<Boolean> doNotLogout(String accessToken, ServerHttpRequest request) {
        HttpHeaders headers = request.getHeaders();
//        System.out.println(accessToken);
        // accessToken을 JSON 객체로 변환
        String requestBody = accessToken;
        return webClientBuilder.build().post()
                .uri("lb://USER-SERVICE/isLogin")
//                .headers(httpHeaders -> httpHeaders.addAll(headers))
                .contentType(MediaType.APPLICATION_JSON) // JSON 컨텐츠 타입 명시
                .body(BodyInserters.fromValue(requestBody)) // JSON 객체로 변환된 본문 사용
                .retrieve()
                .bodyToMono(DataResponseDto.class)
                .map(response -> Boolean.TRUE.equals(response.getData()));
    }

    // 인증 실패 Response
    private Mono<Void> unauthorizedResponse(ServerWebExchange exchange) {
        exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);

        ErrorResponseDto errorResponseDto = ErrorResponseDto.of(Code.UNAUTHORIZED);

        ObjectMapper objectMapper = new ObjectMapper();
        byte[] bytes;

        try{
            bytes = objectMapper.writeValueAsBytes(errorResponseDto);
        } catch (JsonProcessingException e) {
            bytes = "{}".getBytes(StandardCharsets.UTF_8);
        }

        // 응답 헤더 설정
        exchange.getResponse().getHeaders().add("Content-Type", "application/json");

        DataBufferFactory bufferFactory = exchange.getResponse().bufferFactory();
        DataBuffer buffer = bufferFactory.wrap(bytes);

        return exchange.getResponse().writeWith(Mono.just(buffer));
    }

    // Config할 inner class -> 설정파일에 있는 args
    public static class Config{
    }
}

 

 

AuthorizationFilter는 헤더에 존재하는 토큰을 읽어 적절한 JWT 시크릿키로 검증을 시도합니다.

(통상적인 JWT 인증/인가 방식)

 

이때, 유저의 로그아웃 여부는 doNotLogout() 메소드를 통해 유저 서비스와 소통하며 실시간으로 확인합니다.
(MSA에 따라 유저 서비스의 Redis에는 유저 서비스만 접속가능해야 한다고 판단했기 때문입니다.)

 

 

관련된 이슈
#147
#207

 

정리

위의 작업들을 통해 FitTrip은 MSA에서 API Gateway가 갖는 이점들을 갖도록 구현했습니다.


 

MSA의 Telemetry 서비스

 

초기에는 Telemetry를 관련하여 작업을 진행할 생각이 없었습니다.


하지만, 프론트엔드와 백엔드 연동간 다양한 문제에 직면했고,

그에 따라 역할 상호간 로그 확인의 필요성을 느끼게 되었습니다.


따라서, Telemetry 환경을 구성했습니다.

 

#191


정리

FitTrip는 MSA 채택하였고 그에 따라 프로젝트를 진행해나갔습니다.
완벽한 MSA를 구축했다고 할 순 없지만, 최대한 MSA 구성요소에 걸맞도록 구현을 했습니다.

특히, 가장 핵심이 되는 Service Discovery, API Gateway를 통해 MSA가 갖는 많은 이점을 얻을 수 있었던 것 같습니다.

 

이 밖에도 MSA의 구성요소 중 하나인 Telemetry 환경도 구성하여 작업간 정보 불일치 문제를 해결했습니다.
Telemetry 구현을 통해 로그 모니터링으로 얻는 시간 단축 및 작업의 용이함을 경험했습니다.

 

앞으로 있을 프로젝트, 취업의 경우에 MSA로 서비스들이 구성되어있을지는 미지수지만,
Cloud Native Architecture가 유행으로 번지는 요즘, MSA로 프로젝트를 구성한 경험이 큰 도움이 될 것 같습니다.