본 포스팅을 하게 된 이유

현재 많은 기업에서 MSA를 사용하고 있습니다.
그렇기에 신입으로 취업하게 되면 막연하게 회사에 들어가서 학습하는 것보다는, “미리 공부를 하고 실무에 뛰어들면 업무에 원활하지 않을까?” 라는 생각이었고, 이를 위해 조만간 진행할 개인 프로젝트에서 MSA를 적용하여 구축해보자는 마음에 공부하게 되었습니다.

MSA

**MSA(Microservice Architecture)**는 애플리케이션을 여러 개의 독립적인 마이크로서비스로 나누어 개발하고 배포하는 아키텍처 스타일입니다.

각 마이크로서비스는 하나의 기능에 집중하고, 다른 서비스들과 독립적으로 동작하며 독립적으로 배포될 수 있습니다. 마이크로서비스들은 서로 API나 메시지 큐를 통해 통신하며 각각의 서비스는 자신만의 데이터 저장소와 로직을 관리합니다.

MSA의 등장

과거의 대부분의 애플리케이션은 모놀리식 아키텍처로 개발되었습니다.
모놀리식 애플리케이션은 하나의 큰 애플리케이션으로, 모든 기능과 로직이 단일 코드베이스에 포함되어 있습니다.

모놀리식 아키텍처의 특징

  • 하나의 앱으로 배포됩니다.
  • 모든 기능이 강하게 결합되어 있어 작은 변경 사항이라도 전체 애플리케이션을 다시 빌드하고 배포해야 합니다.
  • 확장성의 한계가 있습니다. 특정 기능만 확장할 수 없으며 전체 시스템을 확장해야 합니다.
  • 새로운 기술 도입이 제한적입니다. 자바로 구성된 애플리케이션에 파이썬 같은 다른 언어로 된 기능을 추가하려면 시스템의 재설계가 필요합니다.

이러한 모놀리식 아키텍처의 한계를 해결하기 위해 MSA가 등장했습니다.

MSA의 특징

  1. 독립적인 배포

MSA에서는 각 마이크로서비스가 독립적으로 개발 및 배포됩니다. 특정 서비스에만 업데이트가 필요한 경우, 전체 시스템을 배포하지 않고 해당 서비스만 수정할 수 있습니다.

  1. 유연한 확장성

모놀리식에서는 전체 시스템을 확장해야 하지만 MSA에서는 특정 마이크로서비스만 확장할 수 있습니다. 예를 들어 트래픽이 많이 몰리는 특정 서비스만 인스턴스를 추가해 확장할 수 있습니다.

  1. 다양한 기술 스택 도입 가능

각 마이크로서비스는 독립적으로 동작하기 때문에 서비스마다 다른 기술 스택을 사용할 수 있습니다. 한 서비스는 자바로, 다른 서비스는 파이썬으로 개발할 수 있습니다.

  1. 장애 격리

모놀리식 애플리케이션에서는 하나의 오류가 전체 시스템에 영향을 미칠 수 있지만 MSA에서는 한 마이크로서비스가 장애를 겪더라도 다른 서비스에는 영향을 주지 않습니다. 문제를 겪는 서비스만 해결하면 됩니다.

  1. 자동화된 배포 파이프라인

마이크로서비스는 자주 배포되기 때문에 CI/CD와 같은 자동화된 배포 파이프라인을 통해 효율적으로 배포할 수 있습니다. 이로 인하여 빠른 개발과 신속한 배포를 가능하게 합니다.

이러한 MSA는는 Spring Cloud를 활용하여 구축할 수 있습니다.

Spring Cloud

스프링 클라우드에서 제공하는 여러 구성요소들을 살펴봅시다.

  1. Centralized Configuration (중앙 집중형 구성 관리)

Spring Cloud Config Server - 여러 마이크로서비스의 프로젝트 환경번수 등의 구성을 중앙 Git 저장소에 저장하고 관리합니다. 이로 인해 모든 마이크로 서비스의 설정을 통합적으로 관리할 수 있습니다.

  1. Load Balancing (로드 밸런싱)

Spring Cloud LoadBalancer - 요청을 동적으로 여러 활성화된 마이크로서비스 인스턴스에 분산시킵니다. 각 서비스에 걸리는 부하를 고르게 나누어 성능을 최적화하는 데 사용됩니다.

  1. Service Discovery (서비스 디스커버리)

Spring Cloud Eureka - 마이크로서비스가 자동으로 서로를 찾을 수 있도록 지원합니다. IP 주소나 서비스 위치를 동적으로 찾는 데 유용합니다.

  1. Distributed Tracing (분산 트레이싱)

Spring Cloud Zipkin - 마이크로서비스 간의 요청을 추적하여 어디에서 문제가 발생했는지, 성능 병목 지점이 무엇인지 등을 파악할 수 있습니다.

  1. Edge Server (엣지 서버)

Spring Cloud Gateway - 마이크로서비스를 보호하는 단일 진입점 역할을 합니다. 주로 인증과 같은 공통 기능을 구현하는 데 사용됩니다.

  1. Fault Tolerance (장애 내성)

Resilience4J - 한 마이크로서비스에 장애가 발생하더라도 다른 마이크로서비스에 연쇄적으로 장애가 전파되지 않도록 방지합니다. 서킷 브레이커 등의 패턴이 이에 해당합니다.

Config Server를 활용하여 프로젝트 환경변수 등의 구성을 중앙에서 제어하고 관리하는 법을 알아보겠습니다.
그리고, Config Server에 정의 해놓은 환경변수를 다른 마이크로 서비스가 사용하기 위해서는 Config Client를 사용하면 됩니다.

Config Client

Config Client 프로젝트를 생성할 때 필요한 의존성입니다.
서킷 브레이커의 상태를 모니터링하기 위해 actuator도 추가해줍니다.

이후 config client에서 config server에 정의된 환경변수를 사용하기 위해 간단한 API를 작성해주도록 하겠습니다.

Controller

@RestController
@RequiredArgsConstructor
public class LimitsController {

    private final Configuration configuration;

    @GetMapping("/limits")
    public Limits retrieveLimits() {
        return new Limits(configuration.getMinimum(), configuration.getMaximum());
    }
}

Configuration

@Component
@Getter
@Setter
@ConfigurationProperties("limits-service")
public class Configuration {

    private int minimum;
    private int maximum;
}

Application.yml에서 자체 프로젝트의 환경변수를 주입받기 위해 위 클래스를 작성합니다.

DTO

@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
public class Limits {

    private int minimum;
    private int maximum;
}

application.yml

spring:
  config:
    import: optional:configserver:http://localhost:8888 
    # config server 의 주소를 입력한다. optional은 config server 없이도 limits service 애플리케이션을 구동하기 위함

  application:
    name: msa-hahaha # git repo 의 application.yml 과 파일명이 일치해야한다. ( 애플리케이션 이름을 다르게 하고싶다면 spring.cloud.config.name 에서 일치시키자)

  cloud:
    config:
      profile: local
      name: limits-service

limits-service:
  minimum: 0
  maximum: 1000

가장 중요한 부분입니다.
spring.config.import - 어떤 config server를 사용할 것인지 값을 입력하면됩니다.

  • optional:configserver:http://localhost:8888 -> optional은 config server 없이도 프로젝트가 잘 돌아갈수 있게 해주는 설정이라고 보시면 됩니다. configserver: 를입력하고 뒤에 config server의 주소를 입력합니다. config server의 포트는 8888을 관례적으로 사용한다고 합니다.
  • spring.application.name -> 추후에 진행할 git repo에서 관리되는 환경변수가 지정된 파일이름과 같게 해야합니다. 하지만, spring.cloud.config.name 프로퍼티에서 따로 명시해주면 프로젝트 이름은 원하는대로 입력해도 됩니다. 즉, 필수는 아닙니다.
  • spring.cloud.config.name -> 바로 위에서 설명드린대로입니다. 애플리케이션 이름을 다르게 하고싶다면, 해당 cloud.config.name 프로퍼티에서 git repo에서 관리되는 파일명과 일치시켜주면 됩니다.
  • spring.cloud.config.profile -> application-prod.yml 혹은 application-local.yml 과 같이 프로덕트 환경이냐, 로컬 환경이냐 명시해주면 됩니다. local이면 git repo에서는 limits-service-local.yml의 환경변수를 가져오게 되겠죠.
  • limits-service.minumum or maximum -> config server에서 가져오고싶은 프로퍼티 name을 일치시켜줍니다. 밸류값은 그냥 임의대로 지정했습니다.

config client에서 할 것은 끝났습니다. 다음으로 git repo와 Config Server를 구축해보겠습니다.

Config Server

이미지에 보이는 Spring Web, Config Server 의존성을 추가하고 바로 프로젝트 생성을 합니다.

다음으로, 프로젝트의 구성들이 중앙에서 관리될 수 있도록 git repository를 만듭니다.

새 디렉토리를 만들고 해당 디렉토리에서 터미널을 열고 저장소 초기화 명령어만 입력하면 됩니다.

이후 vscode를 실행해서 해당 폴더를 엽니다.
그리고 limits-service.yml 이라는 이름 으로 새 파일을 생성합니다. (config server에서 정의한 spring.cloud.config.name 속성값과 일치 시켜야함)

그 후,

이미지와 같이 환경변수를 지정해줍니다.

그리고 생성했던 Config Server 프로젝트를 열고,


spring:
  application:
    name: spring-cloud-config-server


  cloud:
    config:
      server:
        git:
          uri: file:///Users/user1/Desktop/dev/msa/localconfig # git repo 연결 file://디렉토리위치



server:
  port: 8888 # config server 의 포트는 8888 사용

위와 같이 프로퍼티값들을 정의해줍니다.

  • spring.cloud.config.server.git.uri -> 생성했던 Git repo와 연결하기 위한 프로퍼티입니다.
    • file:// + /Users/usrname/Desktop/gitrepo폴더명 과 같이 git repo의 디렉토리 url을 입력하면됩니다.
  • server.port -> config server의 포트를 8888로 설정합니다.

그럼 이제 모든 준비가 끝났습니다.
바로 테스트를 해봅시다.

아까 작성했던 /limits 엔드포인트로 접근을 해보면,

아까 application.yml에서는 minimum과 maximum을 각각 0과 1000으로 명시해놨는데, 위 이미지와 같이 값이 minimum은 1, maximum은 999로 응답되는 것을 볼 수 있습니다. 중앙 관리소(git repo)에서 명시했던 값을 정상적으로 가져왔기 때문이죠.

다음으로 마이크로 서비스 간 통신에 대하여 알아보겠습니다.

Open Feign

마이크로서비스 간에 통신은 어떻게 이루어질까요? 일반적으로 생각해보면 RestTemplate을 사용하여 외부 서비스에 요청을 보낼 수 있겠죠.

하지만, 스프링 클라우드에서 제공하는 OpenFeign이라는 마이크로서비스 간에 REST API 호출을 간편하게 할 수 있도록 해주는 라이브러리가 있습니다.

Open Feign을 사용하여 다른 서비스의 API에 접근하는 방법에 대해 알아보겠습니다. 먼저 이를 위해 서로 통신하는 두 가지의 마이크로 서비스를 예로 들어 정의하겠습니다.

  • currency-exchange-service -> 환율 데이터를 제공하는 역할을 하는 마이크로서비스입니다.
  • currency-conversoin-service -> 사용자가 입력한 금액을 특정 통화에서 다른 통화로 환산하는 역할을 하는 마이크로서비스입니다.

위의 두 마이크로 서비스가 존재한다고 생각하고, currency-conversion이 currency-exchange 서비스에 두 통화 간의 환율을 요청한다고 가정하겠습니다.

먼저 환율 서비스인 currency-exchange 프로젝트를 생성합니다.

환율 정보를 DB에 저장하기 위해서, 추가적으로 Data jpa와 h2 db 의존성도 가져옵니다.

spring:
  application:
    name: currency-exchange
  config:
    import: optional:configserver:http://localhost:8888

  jpa:
    show-sql: true
    defer-datasource-initialization: true

  h2:
    console:
      enabled: true
  datasource:
    url: jdbc:h2:mem:testdb
server:
  port: 8000

위와 같이 프로젝트 환경설정을 해줍니다.

이 후에, 환율 정보를 응답해주는 api를 작성합니다.

@RestController
@RequiredArgsConstructor
public class CurrencyExchangeController {

    private final Environment environment;
    private final CurrencyExchangeRepository currencyExchangeRepository;

    @GetMapping("/currency-exchange/from/{from}/to/{to}")
    public CurrencyExchange getExchangeValue(
            @PathVariable("from") String from,
            @PathVariable("to") String to) {
        String property = environment.getProperty("local.server.port");
        CurrencyExchange finded = currencyExchangeRepository.findByFromAndTo(from, to);
        finded.setEnvironment(property);
        if (finded == null) {
            throw new RuntimeException(from + " & " + to + " 가 존재하지 않습니다.");
        }
        return finded;
    }
}

엔티티 등과 같은 구현부는 생략하면서 진행하겠습니다.

그 다음으로 통화 변환 서비스 프로젝트를 생성합니다.

Open Feign을 사용하여 환율 서비스쪽으로 간편하게 요청을 보낼 것이기에 이미지와 같은 의존성들을 추가합니다.

spring:
  application:
    name: currency-conversion

  config:
    import: optional:configserver:http://localhost:8888

server:
  port: 8100

위와 같이 프로젝트 환경설정을 해줍니다.

그리고,

@SpringBootApplication
@EnableFeignClients
public class CurrencyConversionServiceApplication {

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

}

애플리케이션이 시작되는 메인 클래스로 이동하여 @EnableFeignClients 어노테이션을 붙여 Open Feign을 활성화 시켜줍니다.

package com.microservices.currencyconversionservice;

import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;

@FeignClient(name = "currency-exchange", url = "localhost:8000")
public interface CurrencyExchangeProxy {

    @GetMapping("/currency-exchange/from/{from}/to/{to}")
    public CurrenyConversion getExchangeValue(
            @PathVariable("from") String from,
            @PathVariable("to") String to);

}

위와 같이 interface를 하나 생성하고,
@FeignClient(name = “currency-exchange”, url = “localhost:8000”) 어노테이션을 붙여줍니다.
name 속성은 요청을 보낼 서비스의 이름이고, url은 해당 서비스의 주소와 포트번호입니다.

그리고 환율 서비스의 환율 정보를 받아오는 api 메서드를 그대로 가져와 선언합니다.

이 후, 환율 서비스로 요청을 보내고 응답으로 받아온 데이터를 통해 통화 변환 데이터를 반환하는 api를 작성하겠습니다.

@RestController
public class CurrencyCoversionController {

    private final CurrencyExchangeProxy currencyExchangeProxy;

    public CurrencyCoversionController(CurrencyExchangeProxy currencyExchangeProxy) {
        this.currencyExchangeProxy = currencyExchangeProxy;
    }

    @GetMapping("/currency-conversion-feign/from/{from}/to/{to}/quantity/{quantity}")
    public CurrenyConversion currenyConversionFeign(
            @PathVariable String from,
            @PathVariable String to,
            @PathVariable BigDecimal quantity
            ) {

        CurrenyConversion currenyConversion = currencyExchangeProxy.getExchangeValue(from, to);

        return new CurrenyConversion(currenyConversion.getId(),
                from,
                to,
                currenyConversion.getConversionMultiple(),
                quantity,
                quantity.multiply(currenyConversion.getConversionMultiple()),
                currenyConversion.getEnvironment());
    }
}

위에서 작성했던 FeignClients 로 등록한 인터페이스를 주입받고 해당 api를 사용하고 통화변환을 하여 응답을 보냅니다.
응답이 잘 오는지 테스트 해보겠습니다.

http://localhost:8100/currency-conversion-feign/from/USD/to/KRW/quantity/10 로 접근했을 때,
환율 서비스에서 환율 정보를 받아온 후 최종적으로 통화 변환을 한 응답 데이터를 성공적으로 반환하는 것을 볼 수 있습니다. environment 필드를 보시면 환율 서비스의 포트번호인 8000번을 확인할 수 있습니다.

Open Feign의 특징을 몇 가지 정리해보자면,

  • 복잡한 HTTP 클라이언트 코드 없이 인터페이스만 정의하면 자동으로 HTTP 요청이 이루어집니다. 아주 간편합니다.
  • OpenFeign은 Spring Cloud Eureka와 함께 사용할 수 있습니다. 서비스의 URL을 직접 명시하지 않고도 서비스 이름만으로 서비스 디스커버리를 통해 동적으로 호출할 수 있습니다. 이 부분은 바로 다음으로 진행될 Eureka Server를 구축하며 자세히 알아보겠습니다.
  • Spring Cloud LoadBalancer와 함께 사용되어 여러 인스턴스 중 하나를 선택하여 요청을 보내는 로드 밸런싱 기능을 지원합니다.

다음으로 Spring Cloud Eureka에 대해 알아보겠습니다.

Eureka Server

Eureka Server는 서비스 등록 및 검색을 담당합니다. 마이크로 서비스의 인스턴스들을 중앙 서버에 등록 및 관리하는 역할을 한다고 보시면 되겠습니다.
풀어 말하자면, 각 마이크로 서비스가 Eureka Server에 자신의 상태(인스턴스)를 등록하면 다른 서비스가 Eureka Server를 통해 해당 인스턴스를 검색할 수 있습니다. 주로 부하 분산, 서비스 인스턴스 검색 등에 사용됩니다.

추가로, Eureka Client는 부하 분산 요청을 받는 서버입장에서 해당 Eureka Client 의존성을 추가하여 사용합니다. (유레카 서버에 등록되는 인스턴스 서버라고 보시면 됩니다!)

바로 Eureka Server와 Client를 사용해봅시다.

Eureka Server 의존성을 추가하여 프로젝트를 생성합니다.

spring:
  application:
    name: naming-server

server:
  port: 8761

# 해당 애플리케이션이 네이밍서버이기에 자기 자신을 네이밍서버에 등록할 필요 X
eureka:
  client:
    register-with-eureka: false
    fetch-registry: false

프로젝트 환경설정을 합니다.

  • server.port -> 유레카 서버의 포트는 관례적으로 8761입니다.
  • eureka.client.register-with-eureka -> 유레카 클라이언트가 유레카 서버에 자신을 등록할지 여부를 결정합니다. default 값은 true입니다.
    • Eureka Server 자체나 Eureka Client로만 서비스를 검색하는 마이크로서비스는 굳이 등록할 필요가 없기 때문에 false로 설정할 수 있습니다.
  • eureka.client.fetch-registry -> 유레카 클라이언트가 유레카 서버로부터 다른 마이크로서비스의 등록된 정보를 가져올지 여부를 결정합니다.
    • Eureka Server 자체는 보통 서비스 디스커버리가 필요하지 않기 때문에 이 값을 false로 설정합니다.

그러고나서

@EnableEurekaServer
@SpringBootApplication
public class NamingServerApplication {

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

}

메인 클래스에 @EnableEurekaServer 어노테이션을 추가하여 활성화 해줍니다.
이 설정이 끝입니다. 그럼 바로 애플리케이션을 시작하고 htt://localhost:8761/ 로 접근 해보겠습니다.

접속하면 위와같은 페이지가 나옵니다.

여기서! 등록된 서비스(유레카 클라이언트)를 확인할 수 있습니다.
하지만 아직 유레카 클라이언트로 등록한 서비스가 없기에 아무 것도 없는 것을 확인할 수 있습니다.

그럼 바로 유레카 클라이언트를 등록하러 가봅시다.

Eureka Client

아까 전에 생성했던 환율 서비스와 통화 변환 서비스를 유레카 클라이언트로 등록해보겠습니다.
참고로 유레카 서버에 등록된 클라이언트 api에 접근할 때 내부적으로 spring cloud loadbalancer로 인해 부하 분산이 이루어집니다.

환율 서비스, 통화변환 서비스 둘 다 위와 같이 Eureka Discovery Client 의존성을 추가 해주도록 합니다.

eureka:
  client:
    service-url:
      defaultZone: http://localhost:8761/eureka  # eureka client 종속성만 추가해도 되지만, 이와 같이 네이밍서버의 url을 명시하는게 좋다.

그리고 두 프로젝트의 application.yml로 가서 위 프로퍼티를 추가해줍니다.
이미 주석에 쓰여 있지만, eureka client 의존성을 추가만 해도 유레카 서버에 서비스 등록이 됩니다.
하지만, 프로덕트 환경에서는 유레카 서버가 여러대 있을 경우가 있기때문에 해당 프로퍼티에 유레카 서버의 Url을 명시해주어야 합니다.
그리고 /eureka 경로는 클라이언트가 유레카 서버에 등록할 때 사용되는 경로입니다.
server.servlet.contextpath: /custom-eureka 와 같이 커스터마이징 할 수 있습니다.

여기까지 모두 했다면 유레카 서버와 유레카 서버에 등록된 두 서비스 모두 실행 시키고 유레카 서버로 다시 접근해보겠습니다.

위 이미지와 같이 환율 서비스와 통화 변환 서비스 모두 유레카 서버에 등록된 것을 확인할 수 있습니다!

다음은 Spring Cloud Gateway에 대하여 알아보고 적용해보겠습니다.

Spring Cloud Gateway

Spring Cloud Gateway는 Spring Webflux로 빌드 되었으며, Spring Cloud에서 제공하는 API Gateway 솔루션으로 마이크로서비스 아키텍처에서 클라이언트 요청을 적절한 마이크로서비스로 라우팅하고 보안, 모니터링, 필터링 등의 공통 기능을 제공하는 역할을 합니다.
인증, 인가 등과 같이 서비스 전역에서 공통으로 사용되는 기능을 API Gateway 서버에 모아두면 좋겠죠.

요청 라우팅 과정

http://localhost:8765/CURRENCY-EXCHANGE/currency-exchange/from/USD/to/KRW와 같은 url로 요청을 보낼 때 Api Gateway가 수행하는 역할은 다음과 같습니다

  1. 클라이언트 요청 수신 - Api Gateway는 클라이언트로부터 요청을 받습니다. 여기서 /CURRENCY-EXCHANGE는 마이크로서비스의 ID입니다. 마이크로서비스는 Eureka 서버에 CURRENCY-EXCHANGE라는 이름으로 등록되어 있습니다.
  2. 유레카 서버와 통신 - Api Gateway는 CURRENCY-EXCHANGE라는 서비스가 유레카 서버에 등록되어 있는지 확인합니다. 유레카 서버는 CURRENCY-EXCHANGE 서비스의 여러 인스턴스가 실행 중이라면 그 목록을 API Gateway에 반환합니다.
  3. 로드 밸런싱을 통한 인스턴스 선택 - Api gateway는 등록된 인스턴스들 중 하나를 선택합니다(로드 밸런싱). 가령 CURRENCY-EXCHANGE 서비스가 여러 인스턴스에서 실행 중이라면 API Gateway는 그 중 한 서버로 요청을 보냅니다.
  4. 요청 전달 - API Gateway는 선택된 인스턴스의 /currency-exchange/from/USD/to/INR 경로로 클라이언트 요청을 전달합니다. 클라이언트는 이를 통해 직접 CURRENCY-EXCHANGE 마이크로서비스에 접근하는 것처럼 느끼지만 사실상 요청은 API Gateway를 통해 간접적으로 이루어진 것입니다.
  5. 응답 반환 - 선택된 인스턴스의 마이크로서비스가 요청을 처리하고 API Gateway는 그 결과를 클라이언트에게 다시 전달합니다.

이제 API Gateway 서버를 구축해봅시다.

위와 같은 의존성을 추가하여 프로젝트를 생성합니다.

spring:
  application:
    name: api-gateway


  cloud:
    gateway:
      discovery:
        locator:
          enabled: true
          lower-case-service-id: true


server:
  port: 8765

eureka:
  client:
    service-url:
      defaultZone: http://localhost:8761/eureka

프로젝트 환경설정을 해줍니다.

  • spring.cloud.gateway.discovery.locator.enabled -> true로 설정하여 gateway가 유레카 서버에서 마이크로서비스를 동적으로 탐색하여 클라이언트 요청을 해당 서비스로 라우팅합니다.
    • 즉, 수동으로 각 마이크로서비스의 경로를 설정할 필요 없이 유레카에 등록된 서비스들을 자동으로 gateway에서 탐색하고 라우팅할 수 있습니다.
  • spring.cloud.gateway.discovery.locator.lower-case-service-id -> 서비스 이름을 소문자로 변환합니다.
  • server.port -> api gateway 서버에서 관례적으로 사용되는 8765포트를 설정해줍니다.
  • eureka.client.service-url.defaultZone -> 유레카 서버에 등록하는 프로퍼티입니다. 다른 마이크로서비스들과 원활하게 통신하려면 유레카 서버에 등록해야겠죠.

위와 같이 API Gateway 서버를 유레카 서버에 등록하고 활성화 시켰다면, 요청을 하여 다른 서비스로 라우팅이 되는지 확인 해봅시다.
http://localhost:8765/CURRENCY-EXCHANGE/currency-exchange/from/USD/to/KRW” 와 같은 url로 요청을 합니다.
url 패턴을 보면,

http://api-gateway서버주소/환율서비스이름/환율서비스API요청URL 와 같이 되어있습니다. api gateway 서버 주소와 환율서비스의 요청 api url 사이에 환율서비스 이름으로 /CURRENCY-EXCHANGE 라고 되어있는게 보일겁니다. 해당 url은 유레카 서버에 등록되어 있는 서비스로 라우팅 하기위해 명시하는 것입니다. 이름은 유레카 서버에 등록되어있는 환전 서비스의 이름과 일치해야 합니다. 등록하면 대문자로 변환되어있기 때문에 대문자로 입력합니다. 하지만, api gateway서버의 프로젝트 환경설정에서 spring.cloud.gateway.discovery.locator.lower-case-service-id 와 같은 프로퍼티 밸류로 true로 설정했기 때문에, “http://localhost:8765/currency-exchange/currency-exchange/from/USD/to/KRW” 와 같은 url로 요청을 해야합니다.

라우팅이 되어 응답결과까지 반환된 모습입니다.

통화 변환 서비스(currency-conversion) 으로도 잘 되는 모습입니다.

이제 url 경로를 변경하거나 특정 필터를 적용시키는 등을 하기위해 라우팅을 커스터마이징 해보겠습니다.

라우팅 커스터마이징

@Configuration
public class ApiGatewayConfig {

    @Bean
    public RouteLocator gatewayRouter(RouteLocatorBuilder builder) {

        return builder.routes()
                .route(p -> p.path("/get")
                        .filters(f ->
                                f.addRequestHeader("MyHeader", "MyURI")
                                        .addRequestParameter("Param", "MyValue"))
                        .uri("http://httpbin.org:80"))

                .route(p -> p.path("/currency-exchange/**")
                        .uri("lb://currency-exchange"))

                .route(p -> p.path("/currency-conversion/**")
                        .uri("lb://currency-conversion"))

                .route(p -> p.path("/currency-conversion-feign/**")
                        .uri("lb://currency-conversion"))

                .route(p -> p.path("currency-conversion-new/**")
                        .filters(f ->
                                f.rewritePath(
                                        "/currency-conversion-new/(?<segment>.*)",
                                        "/currency-conversion-feign/${segment}"
                                ))
                        .uri("lb://currency-conversion"))
                .build();
    }
}

라우팅을 커스터마이징 하기위해 설정 클래스를 만듭니다.
RouteLocator를 생성하는 RouteLocatorBuilder를 사용하여 커스터마이징합니다.

builder.routes() 다음에 각 요청 경로마다 .route() 메서드 내부에서 람다식으로 규칙을 정의합니다.

.route(p -> p.path("/get")
                        .filters(f ->
                                f.addRequestHeader("MyHeader", "MyURI")
                                        .addRequestParameter("Param", "MyValue"))
                        .uri("http://httpbin.org:80"))

위의 규칙으로 설명을 해보겠습니다.

  • path()
    • url 패턴을 입력합니다. 입력한 url 경로로 들어오는 요청을 처리합니다.
  • filters()
    • addRequestHeader() -> 요청 헤더에 MyHeader 라는 헤더와 MyURI 라는 밸류를 추가합니다.
    • addRequestParameter -> 요청 파라미터에 Param이라는 파라미터와 MyValue라는 밸류를 추가합니다.
  • uri() -> 입력된 url로 요청을 보냅니다.

즉 http://localhost:8765/get 으로 접근을 하면 http://httpbin.org:80/get 으로 라우팅됩니다.

보시는 바와 같이 파라미터와 헤더 값을 확인할 수 있습니다.

또 다른 route를 보겠습니다.

.route(p -> p.path("/currency-exchange/**")
                        .uri("lb://currency-exchange"))

.route(p -> p.path("/currency-conversion/**")
                        .uri("lb://currency-conversion"))

.route(p -> p.path("/currency-conversion-feign/**")
                        .uri("lb://currency-conversion"))

위와 같이 각기 다른 세 가지의 라우트 규칙이 있습니다.
path의 /currency-exchange/ 및 /currency-conversion/, /currency-conversion-feign/ 의 경로로 들어오는 모든 요청을 처리합니다.
근데 여기서 uri에 lb:// 가 있습니다. 이 패턴은 로드밸런싱을 의미하며 유레카 서버에 등록된 환율서비스와 통화 변환 서비스의 인스턴스를 찾고 로드밸런싱을 통해 적절한 인스턴스로 요청을 전달하기 위함입니다.

환율 서비스 라우팅 접근 결과입니다. 정상적이네요.

다음으로 제일 끝 라인에 구현되어 있는 라우트 코드를 보겠습니다.

.route(p -> p.path("currency-conversion-new/**")
        .filters(f ->
                f.rewritePath(
                        "/currency-conversion-new/(?<segment>.*)",
                        "/currency-conversion-feign/${segment}"
                ))
        .uri("lb://currency-conversion"))

path 에 currency-conversion-new/ 라고 되어있습니다. 해당 경로에 들어오는 요청을 처리하겠죠.
근데 필터 메서드 내부에 rewritePath 라는 메서드가 있습니다.
이는 요청 경로 /currency-conversion-new/ 뒤에 오는 경로(변수 segment)를 /currency-conversion-feign/ 경로로 재작성합니다.
예를 들어서 클라이언트가 /currency-conversion-new/from/USD/to/KRW 로 요청하면, 이 요청은 실제로 /currency-conversion-feign/from/USD/to/KRW 로 변경되어 전달됩니다.

잘 됩니다.

이제까지 커스터마이징을 통해 하나의 요청 경로마다 규칙을 지정해주었었죠.
여기서 filters 메서드를 통해서 헤더를 추가하거나 파라미터를 추가하는 라우트가 있었습니다.
이 외에도, 전역 필터를 설정하는 법도 있습니다.

전역 필터 설정

모든 요청 url의 경로를 로그로 출력하는 전역 필터 클래스를 구현해보겠습니다.

@Component
public class LoggingFilter implements GlobalFilter {

    private Logger log = LoggerFactory.getLogger(LoggingFilter.class);

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        URI uri = exchange.getRequest().getURI();
        log.info("요청 URL 경로: {}", uri);
        return chain.filter(exchange);
    }
}

스프링 클라우드 게이트웨이에서 제공하는 GlobalFilter 인터페이스를 구현하는 클래스를 하나 만들어줍니다.
다음으로 로거를 정의하고, filter 라는 메서드를 오버라이딩합니다.

  • ServerWebExchange
    • 요청 및 응답과 관련된 모든 정보를 담고 있는 객체입니다.
  • GatewayFilterChain
    • 다음 필터로 요청을 넘기는 역할을 합니다. 이 필터에서 요청을 처리한 후 다음 필터로 넘겨줘야 하기 때문에 사용합니다.
  • Mono
    • 리액티브 프로그래밍 방식에서 사용하는 비동기 반환 타입입니다.

위의 코드와 같이 요청의 URI를 가져와서 로그를 출력하는 전역 필터를 설정했습니다.
이제, api gateway를 통해 여러 마이크로서비스의 url로 접근을 해보고 로그를 확인 해보겠습니다.

로그가 잘 출력됩니다.
이렇듯 모든 마이크로서비스에서 공통으로 사용되는 기능은 GlobalFilter를 구현하면 됩니다.
여기까지 Api Gateway 서버를 구축하는 법을 간단하게 알아보았고,
다음으로 MSA에서 발생할 수 있는 서비스 장애와 관련된 문제와 그에 대한 해결책에 대해 알아보겠습니다.

보이는 이미지와 같이 여러개의 마이크로서비스가 존재합니다.
각각의 마이크로서비스는 서로 통신하며 클라이언트의 요청을 처리하죠.
여기서 a서비스가 b서비스의 기능을 사용하려 요청을 했을 때, b서비스 서버가 다운되면 어떻게 될까요?
서로 연관 돼있는 마이크로 서비스들에게도 영향이 가게되어 전체 시스템에 문제가 생길 수도 있겠죠.
해당하는 문제의 해결방안으로는 Resilience4j 라는 프레임워크를 사용하는 것입니다.

Resilience4j

Resilience4j 는 스프링 부트와 통합 돼있고, Spring Cloud와도 함께 사용하여 마이크로서비스에서 쉽게 적용할 수 있습니다.
주요 기능으로는 여러가지가 있습니다.

주요 기능

  • Circuit Breaker
    • 서킷 브레이커는 디자인 패턴중 하나입니다. 특정 기능이 실패할 때 해당 서비스로의 추가 요청을 차단하여 시스템의 안정성을 유지하는 패턴입니다. 장애가 발생한 서비스에 더 이상 요청을 보내지 않음으로써 추가적인 장애를 방지하게 됩니다.
  • Retry
    • API 요청에 실패했을 때, 요청을 재시도할 수 있게 하는 기능입니다. 요청 횟수나 지연 시간을 설정할 수도 있습니다.
    • 추가적으로, Spring 프레임워크에서도 Retry를 제공하는데 왜 Resilience4j에서 제공하는 것을 사용하는지 의문이 생길 수도 있습니다.
      이에 대한 답은, Spring Cloud와도 통합이 잘되어있어 MSA를 구축하는데 더 편리하면서, 애플리케이션의 상태관리 메트릭 지원을 모니터링이 용이합니다.
  • Bulkhead
    • Bulkhead도 디자인 패턴중 하나입니다. 시스템 자원을 격리하여 다른 서비스나 시스템에 문제가 생겼을 때, 그 영향이 전체 시스템에 전파되지 않도록 방지하는 기능입니다.
    • 서비스마다 별도의 스레드 풀이나 자원을 사용하여, 특정 서비스가 문제가 생겨도 나머지 서비스는 정상적으로 동작할 수 있도록 합니다.
  • Fallback
    • 특정 서비스의 요청이 실패하여 응답하지 못했을 때 대체 응답을 반환하는 기능입니다.

Retry와 Fallback을 구현하는 방법에 대해 알아보겠습니다.
통화 변환 서비스 currency-conversion이 환율 서비스 currency-exchange로 요청을 보내니깐,
환율 서비스의 해당 api에 대한 retry와 fallback을 적용해보도록 합시다.

Retry & Fallback

환율 서비스의 build.gradle로 이동해 resilience4j 의존성을 추가해줍니다.

retry와 fallback을 활용해보기 위해 간단한 api를 하나 작성하겠습니다.

import io.github.resilience4j.retry.annotation.Retry;
@RestController
public class CircuitBreakerController {

    private Logger log = LoggerFactory.getLogger(CircuitBreakerController.class);

    @GetMapping("/sample-api")
    @Retry(name = "sample-api")
    public String sampleApi() {
        log.info("sample api 접근");
        ResponseEntity<String> forEntity = new RestTemplate().getForEntity("http://localhost:8080/unknown-api",         String.class);
        return forEntity.getBody();
    }
}

@Retry 어노테이션을 추가해줍니다. 패키지명 io.github 로 시작하는 것을 import 합시다.
name 속성에는 특정할 수 있는 이름을 명시해주면 됩니다.
임의적으로 오류를 터뜨리기 위해 존재하지 않는 외부 서비스의 api를 요청한다고 가정하겠습니다.
그리고 Retry기능을 통해 해당 api가 몇 번 호출되는지 테스트 해보기위하여 로그출력을 해보겠습니다.

해당 api로 접근해본 결과로, 해당 외부 api 요청에 실패하여 총 3번 재시도 된 것을 로그를 통해 확인할 수 있습니다.
재시도 횟수 default 값은 3회 인 것 같네요. 재요청마다 시간 차이는 각각 500ms정도 지연 되는 것도 확인할 수 있습니다.
그럼 재요청 횟수와 지연시간을 커스터마이징 해봅시다.

resilience4j:
  retry:
    instances:
      sample-api:
        max-attempts: 5
        wait-duration: 1s
        enable-exponential-backoff: true

application.yml로 이동하여 해당 프로퍼티들을 정의해줍니다.

resilience4j.retry.instaces 까지는 동일하며 그 다음의 sample-api는 해당하는 api에 retry 어노테이션의 name 속성의 밸류와 일치 시켜주면 됩니다.

  • max-attempts - 재요청 횟수입니다. 5번으로 지정하겠습니다.
  • wait-duration - 재요청 지연시간입니다. 1초로 설정했습니다.
  • enable-exponential-backoff - 재요청의 지연시간을 점진적으로 증가시키는 프로퍼티입니다. 지연시간이 1초 - 2초 - 4초 - 8초 - 16초와 같이 지수적으로 증가합니다. 이 기능은 시스템에 부담을 줄이고 서비스가 복구될 시간을 주기 위해 사용됩니다. AWS 같은 cloud 기능이 포함된 api에서 자주 사용된다고 합니다.

위와 같이 프로퍼티를 정의했고 다시 로그를 확인해봅시다.

재요청이 5번 일어나게 되었고 각 요청마다 지연시간이 늘어나는 것을 확인할 수 있습니다.
이제 fallback을 해봅시다.

@GetMapping("/sample-api")
    @Retry(name = "sample-api", fallbackMethod = "fallback")
    public String sampleApi() {
        log.info("sample api 접근");
        ResponseEntity<String> forEntity = new RestTemplate().getForEntity("http://localhost:8080/unknown-api", String.class);
        return forEntity.getBody();
    }

    public String fallback(Exception ex) {
        return "fallback-response";
    }

Retry 어노테이션의 attribute로 fallbackMethod 를 지정해줍니다. value 값은 대체 응답을 하는 메서드의 이름을 명시하면됩니다.
해당 api 아래에 Fallback 기능을 수행하여 문자열만 반환하는 메서드를 작성했습니다.
그리고 매개변수에 Exception 객체를 넣었는데,
이를 넣는 이유는 Retry 기능이 작동할 때 원래 메서드가 예외를 발생시키면 이를 폴백 메서드에서 처리하기 위해 예외 정보를 전달합니다. 따라서 폴백 메서드가 원래 메서드와 매개변수가 같고, 마지막 매개변수로 Exception 또는 Throwable을 받아야 정상적으로 동작하게 됩니다. 예외 정보가 없으면 Resilience4j는 어떤 예외가 발생했는지 알 수 없기 때문에 폴백이 제대로 작동하지 않게 됩니다.
원래 메서드에는 매개변수가 없으니 폴백 메서드에는 Exception만 포함시켜준 모습입니다.

테스트 해보면,

5번의 retry를 거치고 폴백 메서드가 작동되어 문자열이 정상적으로 반환됩니다.

이와 같이 일시적인 오류를 retry와 fallback을 활용하여 극복하는 방법에 대해 알아보았습니다.
다음으로는 서킷 브레이커 패턴을 적용하여 서비스가 오랫동안 다운되어 있을 때 해결하는 법에 알아보겠습니다.

서킷 브레이커

@RestController
public class CircuitBreakerController {

    private Logger log = LoggerFactory.getLogger(CircuitBreakerController.class);

    @GetMapping("/sample-api")
    @CircuitBreaker(name = "sample-api", fallbackMethod = "fallback")
    public String sampleApi() {
        log.info("sample api 접근");
        ResponseEntity<String> forEntity = new RestTemplate().getForEntity("http://localhost:8080/unknown-api", String.class);
        return forEntity.getBody();
    }

    public String fallback(Exception ex) {
        return "fallback-response";
    }
}

@Retry를 제거하고 @CircuitBreaker 어노테이션을 추가합니다. attribute는 retry와 똑같습니다.

위와 같이 작성한 후, 서버를 실행시키고 터미널을 열어준 뒤

위 curl 명령어를 입력합니다. -n 옵션은 시간 간격을 지정하는 옵션입니다. 0.1초로 지정하여 수많은 요청을 해봅시다.

요청이 계속해서 보내지고 있고,

서버 로그를 보면 수많은 요청으로 인해 로그가 아주 많이 찍힙니다.
하지만 빨간박스 안의 시간을 자세히 봐보면 요청이 잠시 중지되었다가, 다시 요청을 계속 보내고, 다시 중지되는 모습을 확인할 수 있습니다.
이는 바로 서킷 브레이커의 동작 방식입니다.

서킷 브레이커는 세가지 상태로 구분되어 동작을 합니다.

  • Closed (닫힘 상태)
    • 서비스가 정상적으로 동작하고 있을 때, 모든 외부요청이 정상적으로 전달됩니다. (장애가 발생하지 않은 상태)
    • 하지만, 여러번 실패가 감지되면 Open 상태로 전환됩니다. -> RestTemplate으로 비정상적인 외부 api에 접근하고 있기에 장애 발생
  • Open (열림 상태)
    • 서비스에 지속적으로 장애가 발생하여 요청을 더이상 보내지 않도록 차단합니다.
    • 장애가 발생한 동안 요청을 즉시 실패 처리하며 이 상태에서 추가적인 요청(RestTemplate으로 외부 API 요청) 은 서비스로 전달되지 않고 즉시 실패 응답을 반환합니다.
    • 이 상태에서 일정 시간이 지나면 Half-Open 상태로 전환됩니다.
  • Half-Open (반쯤 열림 상태)
    • 일정 시간이 지난 후에 서킷 브레이커가 일부 요청을 허용하여 서비스가 복구 되었는지 확인을 합니다.
    • 일부 요청이 성공되면 다시 Closed 상태로 전환되어 모든 요청을 처리하게 됩니다.
    • 반대로 여전히 장애가 발생하면 다시 Open 상태로 전환됩니다.

이와 같은 서킷브레이커의 내부 동작방식으로 인해 로그가 출력되다가 중지되고 다시 출력되다가 다시 중지되는 것입니다.

다시 플로우를 훑어보자면,

  • 초기 상태 - Closed
    • /sample-api에 접근하면 초기에는 Closed 상태이므로 요청이 보내지게 됩니다.
    • 이 상태에서 지속적인 실패가 발생하면 실패 횟수가 설정된 임계치를 넘어서면서 서킷 브레이커가 Open 상태로 전환됩니다.
      -> 임계치는 application.yml에서 프로퍼티로 임의지정할 수 있습니다.
  • Open 상태
    • 서킷 브레이커가 Open 상태로 전환되면, 더 이상 원본 메서드 실행 자체가 차단되므로 로그도 찍히지 않습니다.
      -> RestTemplate을 사용한 외부 api 접근 로직은 아예 차단됩니다. 실행이 되질 않습니다.
      이 후 **폴백 메서드(fallback)**가 호출됩니다.
    • Open 상태에서는 일정 시간 동안 요청을 차단한 후, Half-Open 상태로 전환됩니다.
  • Half-Open 상태
    • 일정 시간이 지나면 서킷 브레이커는 Half-Open 상태로 전환됩니다. 이 상태에서는 일부 요청을 허용하여 서비스가 복구되었는지 확인합니다. -> 로그출력이 중지 되었다가 다시 출력되었던 이유
    • 만약 서비스가 성공적으로 복구되었다고 판단되면 서킷 브레이커는 다시 Closed 상태로 돌아가고 모든 요청이 정상적으로 처리됩니다.
    • 반면, 서비스가 여전히 실패한다면, 다시 Open 상태로 전환되어 요청이 차단됩니다. -> 다시 출력되던 로그가 또 다시 중지된 이유
      • 위 과정에서 요청의 임계치를 20% 혹은 30% 와 같이 설정하여 임계치만큼 요청이 성공하면 Closed 전환, 실패하면 Open 전환

자세한 속성 값들을 설정하는 법은 Resilience4j docs에서 확인하실 수 있습니다. 위에서 말했던 요청 임계치를 설정하는 것 외에도 Open 상태에서 요청을 차단하는 시간 등도 정의할 수 있습니다.

다음은 특정 시간 내에 특정 횟수만큼만 요청을 받을수 있게 해주는 RateLimiter와
동시처리 가능 한 요청 수를 지정할 수 있게 해주는 Bulkhead에 대하여 알아보겠습니다.

Bulkhead & RateLimiter

Bulkhead는 시스템 자원을 격리하여 하나의 서비스에서 발생한 과부하나 장애가 다른 서비스에 영향을 주지 않도록 하는 벌크헤드 패턴을 구현하는데 사용 됩니다.
Bulkhead는 일반적으로 스레드 풀 격리 또는 동시 실행 제한을 구현하는 데 사용됩니다.
MSA 구축을 주요목적이니, 스레드 풀은 다음에 알아보고 동시 요청을 제한하는 것에 대하여 알아보겠습니다.

RateLimiter는 앞서 말했 듯, 특정 시간내에 특정 횟수만큼만의 요청을 받을수 있게 해주는 기능입니다.
RateLimiter 부터 살펴보겠습니다.

@RestController
public class CircuitBreakerController {

    private Logger log = LoggerFactory.getLogger(CircuitBreakerController.class);

    @GetMapping("/sample-api")
    @RateLimiter(name = "sample-api")
    public String sampleApi() {
        return "sample-api";
    }

}

아주 간단합니다. “sample-api” 를 리턴하는 엔드포인트에 @RateLimiter 어노테이션을 추가합니다. name 속성에 밸류를 명시해줍니다.

resilience4j:
  ratelimiter:
    instances:
      sample-api:
        limit-for-period: 2
        limit-refresh-period: 10s # 10초간 두 건의 요청만 허용

limit-for-period -> 받을 요청 횟수를 2로 지정해줍니다.
limit-refresh-priod -> 시간을 10초로 지정해줍니다.
이렇게 되면 10초간 두 건의 요청만 허용하게 됩니다.

바로 테스트 해보면 10초안에 3번의 새로고침을 눌러보면 sample-api 가 잘 반환 되다가, 3번째 새로고침부터는 에러페이지를 보여주게됩니다.
해당 사항은 간단하니 이미지 없이 넘어가겠습니다.

다음은 Bulkhead입니다.

@RestController
public class CircuitBreakerController {

    private Logger log = LoggerFactory.getLogger(CircuitBreakerController.class);

    @GetMapping("/sample-api")
    @Bulkhead(name = "sample-api")
    public String sampleApi() {
        return "sample-api";
    }
}

bulkhead도 마찬가지로 어노테이션을 추가하고 name 속성만 정의해줍니다.

resilience4j:
  bulkhead:
    instances:
      sample-api:
        max-concurrent-calls: 10

마무리

지금까지 마이크로서비스 아키텍처를 구축하는 데 필요한 주요 구성 요소들과 그 기능들을 다루었습니다.
아주 간략하게 요약 및 정리를 해보자면,

  • Eureka Server -> 마이크로서비스를 자동으로 검색하고 부하 분산을 통해 유연한 서비스 디스커버리를 제공하는 역할을 했습니다.
  • Config Server -> 여러 마이크로서비스의 환경 변수를 중앙 집중식으로 관리하여 각 서비스가 변경된 설정을 자동으로 적용할 수 있도록 구현했습니다.
  • API Gateway -> 모든 클라이언트 요청의 단일 진입점으로서 라우팅과 보안, 로드 밸런싱을 처리하는 핵심 역할을 했습니다.
  • Resilience4j -> 서킷 브레이커, Retry, Bulkhead, RateLimiter 등의 패턴을 적용해 서비스 간의 장애 전파를 막고 시스템의 안정성을 높여주었습니다.

이번 학습을 바탕으로 추후에 진행하게 될 개인 프로젝트에서 빛이 되주었으면 하는 바램입니다… 그 때는 위의 4가지 요소들과 그 외의 기능들에 대해서 더 깊게 들어가며 학습하게 되겠네요.

참고
udemy 강의
https://spring.io/projects/spring-cloud
https://resilience4j.readme.io/docs/circuitbreaker