종우의 삶 (전체 공개)

MSA Project - 서비스 분리하기 본문

개발/Spring

MSA Project - 서비스 분리하기

jonggae 2024. 7. 16. 15:17

0.

 

모놀리식으로 개발중인 현재 프로젝트를 어떻게 분리하고 MSA로 변경할 수 있을까?

가장 중요한 사안은 서비스 분리가 수월하도록 각 서비스와 계층간 의존성을 낮춰야 한다는 것이다.

 

MSA에 대해 알아보기

Monolithic Architecture

지금까지 개발해왔던 형태를 모놀리식 아키텍쳐라고 할 수 있겠다. 하나의 프로젝트에 모든 서비스와 기능들이 들어가있다. 모듈 단위로 쪼개지는 것이 아니라 프로젝트 단위로 개발이 진행되는 것. 

 

현재 회원, 주문, 상품 등의 서비스가 존재한다면 계속해서 기능들이 추가 될 때마다 프로젝트 내의 코드의 양이 점점 증가할 것이다. 모놀리식의 장점이자 단점이 필요한 기능이 전부 한 곳에 모여있는 것인데, 초기 개발에 적합하여 빠르게 MVP를 만들 수 있지만 프로젝트의 크기가 커질수록 수정, 배포등의 관리가 어려워 질 수 있다. 

 

그러나.. 큰 프로젝트여도 여전히 모놀리식으로 개발을 하는 경우가 많이 있다. 이어서 나올 MSA가 무척이나 까다롭기 때문

 

MSA - Micro Service Architecture

기능들을 작은 독립적인 서비스로 분리하여 각 서비스 모듈 또는 프로젝트로 나누어 개발 및 관리를 진행하는 형ㅌ이다.

단순히 생각하면 도메인 별 프로젝트 자체가 다르게 구성되고 나뉘어져 있는 것이다.

서비스간의 연관되어 있는 정보들을 잘 관리한다면 독립적으로 개발 및 배포가 가능하다. 서비스 프로젝트마다 언어와 프레임워크를 다르게 구성할 수도 있고 일부 서비스가 전체 시스템에 큰 영향을 주지 않아 '고립'되어 있다고 볼 수 있다.

그러나 각 서비스의 연결이나 초기 구성에 시간이 소요된다.

 

https://www.redhat.com/ko/topics/microservices/what-are-microservices

 


Eureka와 Api Gateway

Eureka

우선 MSA를 진행하기 위해 Eureka 컴포넌트를 사용하기로 했다. Eureka는 여러개의 서비스들의 관리를 해주는 도구라고 할 수 있겠다.  Docker에 동일한 네트워크로 서비스들을 연결하면 서비스의 위치(IP 주소와 port)를 동적으로 등록해주고 찾을 수 있게 도와준다

 

Eureka Server는 하나의 독립된 애플리케이션으로 서비스 레지스트리, 즉 마이크로서비스 들을 유지 관리한다. 각 마이크로서비스들은 Client로 등록되어 접근할 수 있다.

Docker로 서비스와 데이터베이스를 컨테이너로 실행하며 (현재는 로컬 서비스로 실행, DB만 도커로 접근)

Feign 클라이언트로 서비스 간 REST API로 통신을 한다.

 

따라서 Docker 컨테이너가 시작되면, 해당 서비스는 자동으로 Eureka 서버에 등록이 된다. 그리고 Feign 클라이언트로 필요한 서비스를 동적으로 감지하여 서로 연결을 시킬 수 있는 것이다 .

Eureka 는 주기적으로 등록된 서비스의 상태를 체크하며 확장성에도 용이하다.

 

따라서 서비스들을 배포하고, 운영하려면 Eureka는 관리면에서 큰 도움이 되며, 배포하지 않는 로컬환경에서도 DB를 Docker에 연결하여 Eureka 환경에서 사용할 수 있다. 동적으로 변하는 서비스 인스턴스를 쉽게 관리할 수 있는것이다. Feign 클라이언트로 서비스간 통신을 유연하게 진행하고, 결과적으로 MSA의 기본적인 구조를 완성하게 된다.

 

API Gateway

api gateway는 클라이언트와 마이크로서비스 사이의 중간 계층 역할을 한다. 모든 클라이언트 요청에 대한 단일 진입점을 제공하여 한 곳에서 모든 요청을 처리할 수 있게 해준다.

 

Api gateway역시 독립적인 하나의 서비스로 실행되며, Docker 컨테이너로 배포된다. (Eureka도 마찬가지)

클라이언트의 모든 요청을 먼저 받게 되는데, 따라서 Api Gateway에서 요청들을 라우팅해주면, Eureka와 연동하여 서비스의 위치를 찾아 요청을 전달하는 것이다. 이게 가장 주요한 역할이 된다.

 

이렇게 중앙에서 요청을 받아 처리하기 때문에 다른 이점이 생겨난다.

여러 인스턴스 간에 요청을 분산시키는 로드 밸런싱

중앙집중식 인증을 통해 JWT를 검사할 수 있기때문에, 각 서비스에서 복잡한 시큐리티 환경을 구축할 필요가 없어진다.

 

클라이언트의 요구 사항에 맞게 API 응답을 변환하여 전달 할 수 있으며, 여러 서비스의 응답을 합쳐서 보낼 수 있다.

캐싱으로 부하를 감소시킬 수도 있고, 모니터링, 버전 관리, 장애 격리 등 다양한 이점이 있다.

 


 

현재의 프로젝트에서도 이러한 기능들을 전부 사용하기로 한다.

 

 

간단히 이런 식의 형태를 만들어 볼 것이다. 필요한 서비스들은 새롭게 추가되거나, 또 제거될 수 있겠다.

 

 

각 서비스들을 연결하기 위해 (DB만 연결) docker-compose를 구성해준다.

 

예시)

eureka 서버의 docker-compose.yml

version: '3.8'

services:
  eureka-server:
    build:
      context: .
    container_name: eureka-server
    ports:
      - "8761:8761"
    networks:
      - eureka-network

networks:
  eureka-network:
    name: eureka-network
    driver: bridge

 

모든 서비스들이 eureka-network라는 네트워크와 연결되어 통신할 준비가 되었다.

 

Api Gateway 설정

 

각 서비스의 시큐리티 설정은 API Gateway에서 가장 주요하게 진행하고, 나머지 서비스에서는 그저 토큰을 파싱하거나 검증하는 정도로 설정해준다. 사실상 중요한 보안 설정은 Customer Service에 집중되어 있기 때문에, API Gateway에서도 그렇게 많은 설정이 필요하지 않다.

 

각 서비스에서 접근할 수 있는 권한을 설정해주고, 그 권한을 API Gateway내부의 필터에서 지정하여 보내줄 수 있다. 라우팅 시 요청 Header에 CustomerId나 CustomerName같은 Unique한 값을 함께 보내주면, 각 서비스에서 그것을 읽어내어 데이터베이스 조회를 하거나, 다른 프로세스를 진행하는 형태로 구성하였다.

 

더보기
//임포트 생략

@Component
public class ApiGatewayFilter extends AbstractGatewayFilterFactory<ApiGatewayFilter.Config> {

    private final JwtTokenUtil jwtTokenUtil;
    private static final Logger logger = LoggerFactory.getLogger(ApiGatewayFilter.class);

    public static class Config {
    }

    public ApiGatewayFilter(JwtTokenUtil jwtTokenUtil) {
        super(Config.class);
        this.jwtTokenUtil = jwtTokenUtil;
    }

    @Override
    public GatewayFilter apply(Config config) {
        return (exchange, chain) -> {
            String path = exchange.getRequest().getPath().toString();
            logger.info("Request path: {}", path);

            // 로그인 경로는 JWT 검증을 하지 않음
            if (path.equals("/api/customers/login") || path.equals("/api/customers/register")
                    || path.startsWith("/api/products/") || path.startsWith("/api/test")) {
                return chain.filter(exchange);
            }
            String token = extractToken(exchange.getRequest().getHeaders());
            if (token == null) {
                logger.warn("토큰이 존재하지 않습니다.");
                return chain.filter(exchange);
            }
            try {
                if (!jwtTokenUtil.validateToken(token)) {
                    logger.warn("Invalid JWT token");
                    return chain.filter(exchange);
                }
                Authentication authentication = jwtTokenUtil.getAuthentication(token);
                String customerId = jwtTokenUtil.getCustomerIdFromToken(token);
                String customerName = jwtTokenUtil.getCustomerNameFromToken(token);
                logger.info("Extracted customerId: {} and customerName: {}", customerId, customerName);

                exchange = exchange.mutate()
                        .request(r -> r
                                .header("customerId", customerId)
                                .header("customerName", customerName)
                        )
                        .build();

                return chain.filter(exchange)
                        .contextWrite(ReactiveSecurityContextHolder.withAuthentication(authentication));
            } catch (Exception e) {
                logger.error("Error processing JWT token", e);
                return chain.filter(exchange); // Spring Security에 처리 위임
            }
        };

    }

    private String extractToken(HttpHeaders headers) {
        String bearerToken = headers.getFirst(HttpHeaders.AUTHORIZATION);
        if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }

    private Mono<Void> onError(ServerWebExchange exchange, String err, HttpStatus httpStatus) {
        ServerHttpResponse response = exchange.getResponse();
        response.setStatusCode(httpStatus);
        DataBuffer buffer = response.bufferFactory().wrap(err.getBytes());
        return response.writeWith(Mono.just(buffer));
    }

}

 

따라서 Api Gateway의 포트인 8080으로 요청을 보내면, 적절한 라우팅으로 요청을 처리해준다. 사례 몇가지를 살펴보자

 

우선 기본적인 로그인 과정인데, Api Gateway의 application.yml 파일에서 라우팅 설정을 해주어야 한다.

  cloud:
    gateway:
      discovery:
        locator:
          enabled: true
          lower-case-service-id: true
      routes:
        - id: test-order-service
          uri: lb://order-service
          predicates:
            - Path=/api/test/time-orders/**

        - id: customer-service
          uri: lb://customer-service
          predicates:
            - Path=/api/customers/**

        - id: order-service
          uri: lb://order-service
          predicates:
            - Path=/api/orders/**

        - id: product-service
          uri: lb://product-service
          predicates:
            - Path=/api/products/**, /api/wishlist/**
      default-filters:
        - name: ApiGatewayFilter
        // cors 설정 생략

 

예를들어 localhost://8080/api/customer/login 으로 로그인 요청을 보낸다고 하면,

Customer-Service에 있는 동일한 API 엔드포인트로 라우팅을 해준다. Customer Service는 그 요청을 받아 로그인을 진행한다.

 

Customer Service 내의 JwtAuthenticationFilter에서 로그인 URL을 지정해놓는다.

public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    private final LoginProvider loginProvider;

    public JwtAuthenticationFilter(LoginProvider loginProvider, LoginSuccessHandler successHandler, LoginFailureHandler failureHandler) {
        super();
        this.loginProvider = loginProvider;
        this.setAuthenticationSuccessHandler(successHandler);
        this.setAuthenticationFailureHandler(failureHandler);

        setFilterProcessesUrl("/api/customers/login");
    }

 

요청은 8080 Port로, 로그인 URL로 접근을 시도하면,

 

API Gateway에서도 요청을 처리하고,

Customer Service에서도 요청을 받는다

 

 

토큰 발급이 성공적으로 진행된 모습이다.

 

이렇게 API Gateway를 통해 서비스들을 연결하고, 다른 요청들도 모두 Gateway를 통해 접근 가능하다.

이 외에도 많은 요청들을 전부 Api Gateway에서 처리한다

 

다음에는 서비스 별 통신을 어떻게 진행했는지 알아보자.

Comments