스프링 클라우드를 활용한 마이크로서비스 아키텍처 구축

모놀리식(Monolithic) 아키텍처는 모든 기능 모듈이 단일 프로젝트 내에서 개발되고 배포 시 함께 컴파일 및 패키징되는 구조입니다. 이는 초기 단계의 프로젝트나 소규모 팀에는 단순하고 효율적일 수 있습니다. 하지만 비즈니스 규모가 확장되고 개발 인력이 증가함에 따라 여러 문제점이 드러납니다.

모놀리식 아키텍처의 한계:

  • 높은 팀 협업 비용: 모든 모듈이 한 프로젝트에 있어 코드 간의 물리적 경계가 모호해지며, 기능 통합 시 충돌 해결에 많은 시간이 소요됩니다.
  • 낮은 시스템 배포 효율성: 어떤 모듈이든 변경되면 전체 시스템을 재배포해야 하며, 사소한 문제도 전체 배포 실패로 이어질 수 있습니다.
  • 취약한 시스템 가용성: 특정 인기 기능이 시스템 자원을 과도하게 소모하여 다른 서비스의 가용성을 저하시킬 수 있습니다.

마이크로서비스 아키텍처:

마이크로서비스 아키텍처는 모놀리식 애플리케이션의 기능 모듈을 독립적인 여러 서비스로 분리하여 배포하는 방식입니다. 이 아키텍처는 다음과 같은 특징을 가집니다.

  • 단일 책임 원칙: 각 마이크로서비스는 특정 비즈니스 기능의 일부를 담당하며, 핵심 데이터는 다른 모듈에 의존하지 않습니다.
  • 자율적인 팀 운영: 각 서비스는 독립적인 개발, 테스트, 배포, 운영 인력을 가지며, 팀 규모는 일반적으로 10명 이내로 유지됩니다.
  • 독립적인 서비스 운영: 각 마이크로서비스는 독립적으로 패키징 및 배포되며, 자체 데이터베이스를 사용합니다. 서비스 간 격리를 통해 다른 서비스에 미치는 영향을 최소화합니다.

스프링 클라우드:

마이크로서비스 분리 후 발생하는 다양한 문제에 대한 해결책과 컴포넌트들이 존재하며, 스프링 클라우드(Spring Cloud) 프레임워크는 자바(Java) 생태계에서 가장 포괄적인 마이크로서비스 컴포넌트 집합을 제공합니다.

현재 스프링 클라우드의 최신 버전은 2022.0.x (코드명 Kilburn)이며, 이는 스프링 부트(Spring Boot) 3.x 버전과 JDK 17을 요구합니다. 본 문서에서는 기업에서 비교적 널리 사용되는 Spring Cloud 2021.0.x 및 Spring Boot 2.7.x 버전을 기준으로 설명합니다.

Spring Cloud 버전 Spring Boot 버전
2022.0.x (Kilburn) 3.0.x
2021.0.x (Jubilee) 2.6.x, 2.7.x (2021.0.3부터)
2020.0.x (Ilford) 2.4.x, 2.5.x (2020.0.3부터)
Hoxton 2.2.x, 2.3.x (SR5부터)
Greenwich 2.1.x
Finchley 2.0.x
Edgware 1.5.x
Dalston 1.5.x

마이크로서비스 분리 전략

마이크로서비스를 분리할 때는 서비스의 세분화 정도를 신중하게 고려해야 합니다. 다음 두 가지 원칙을 중심으로 분리합니다.

  • 높은 응집도(High Cohesion): 각 마이크로서비스는 담당하는 역할이 최대한 단일해야 하며, 포함하는 비즈니스 기능의 연관성이 높고 완전해야 합니다.
  • 낮은 결합도(Low Coupling): 각 마이크로서비스의 기능은 상대적으로 독립적이어야 하며, 다른 서비스에 대한 의존성을 최소화하거나 의존하는 인터페이스의 안정성이 강해야 합니다.

서비스 분리에는 일반적으로 두 가지 방식이 있습니다.

  • 수직 분리(Vertical Splitting): 프로젝트의 기능 모듈별로 분리하는 방식입니다. 이는 서비스의 응집도를 최대한 높일 수 있습니다.
  • 수평 분리(Horizontal Splitting): 여러 기능 모듈 간에 공통된 비즈니스 부분이 있는지 확인하고, 있다면 이를 범용 서비스로 추출하는 방식입니다. 이는 비즈니스 재사용성을 높이고 중복 개발을 방지할 수 있습니다. 또한, 범용 비즈니스는 일반적으로 인터페이스 안정성이 강하여 서비스 간 과도한 결합을 유발하지 않습니다.

상품 카탈로그 서비스 분리

마이크로서비스 프로젝트는 일반적으로 두 가지 프로젝트 구조를 가집니다.

  • 완전 분리형: 각 마이크로서비스를 독립적인 프로젝트로 생성하며, 서로 다른 개발 언어를 사용할 수도 있어 완벽한 분리가 가능합니다.
    • 장점: 서비스 간 결합도가 매우 낮습니다.
    • 단점: 각 프로젝트가 독립적인 저장소를 가지므로 관리가 복잡합니다.
  • 메이븐(Maven) 어그리게이션: 전체 프로젝트는 하나의 메인 프로젝트이며, 각 마이크로서비스는 이 메인 프로젝트의 하위 모듈로 구성됩니다.
    • 장점: 코드 집중화로 관리 및 운영이 용이합니다.
    • 단점: 서비스 간 결합도가 약간 존재하며, 컴파일 시간이 길어질 수 있습니다.

여기서는 메이븐 어그리게이션 구조를 사용하며, 기존의 메인 프로젝트(예: app-root) 내에서 직접 서비스를 분리합니다. (app-root 프로젝트에 스프링 부트 및 스프링 클라우드 의존성 버전이 이미 정의되어 있어 분리가 용이합니다.)

app-root 프로젝트 내에 product-catalog-service 모듈을 생성하고 JDK 버전은 11로 설정합니다.

의존성 추가:

<dependencies>
    <!-- 공통 모듈 -->
    <dependency>
        <groupId>com.myapp</groupId>
        <artifactId>app-common-lib</artifactId>
        <version>1.0.0</version>
    </dependency>
    <!-- 웹 모듈 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <!-- 데이터베이스 -->
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
    </dependency>
    <!-- Mybatis Plus -->
    <dependency>
        <groupId>com.baomidou</groupId>
        <artifactId>mybatis-plus-boot-starter</artifactId>
    </dependency>
    <!-- 단위 테스트 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>
<build>
    <finalName>${project.artifactId}</finalName>
    <plugins>
        <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
        </plugin>
    </plugins>
</build>

시작 클래스 작성:

@MapperScan("com.myapp.product.mapper")
@SpringBootApplication
public class ProductCatalogApplication {
    public static void main(String[] args) {
        SpringApplication.run(ProductCatalogApplication.class, args);
    }
}

애플리케이션 설정 파일: 기존 설정 파일을 복사하여 수정합니다. application.yaml은 다음과 같이 변경합니다.

server:
  port: 8081
spring:
  application:
    name: product-catalog-service
  profiles:
    active: local
  datasource:
    url: jdbc:mysql://${app.db.host}:3306/app_product_db?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&serverTimezone=Asia/Seoul
    driver-class-name: com.mysql.cj.jdbc.Driver
    username: root
    password: ${app.db.password}
mybatis-plus:
  configuration:
    default-enum-type-handler: com.baomidou.mybatisplus.core.handlers.MybatisEnumTypeHandler
  global-config:
    db-config:
      update-strategy: not_null
      id-type: auto
logging:
  level:
    com.myapp: error
  pattern:
    dateformat: HH:mm:ss:SSS
  file:
    path: "logs/${spring.application.name}"
knife4j:
  enable: true
  openapi:
    title: 상품 카탈로그 서비스 API 문서
    description: "상품 정보 관리"
    version: v1.0.0
    group:
      default:
        group-name: default
        api-rule: package
        api-rule-resources:
          - com.myapp.product.controller

기존 서비스에서 상품 관리 관련 코드를 product-catalog-service로 복사합니다. (패키지 경로를 재조정해야 할 수 있습니다.) 예를 들어 ProductCatalogServiceImpl 클래스의 adjustInventory 메서드는 다음과 같이 변경될 수 있습니다.

@Service
public class ProductCatalogServiceImpl extends ServiceImpl<ProductMapper, Product> implements IProductCatalogService {
    @Override
    public void adjustInventory(List<OrderItemDTO> items) {
        String sqlStatement = "com.myapp.product.mapper.ProductMapper.updateStock";
        boolean success = false;
        try {
            success = executeBatch(items, (sqlSession, entity) -> sqlSession.update(sqlStatement, entity));
        } catch (Exception e) {
            throw new BusinessException("재고 업데이트 중 오류 발생, 재고 부족 가능성!", e);
        }
        if (!success) {
            throw new BusinessException("재고 부족!");
        }
    }
    @Override
    public List<ProductDetailDTO> retrieveProductsByIds(Collection<Long> productIds) {
        return BeanUtils.copyList(listByIds(productIds), ProductDetailDTO.class);
    }
}

app_product_db.sql 스크립트를 실행하여 데이터베이스 테이블을 임포트합니다. (운영 환경에서는 각 마이크로서비스가 독립적인 데이터베이스 서비스를 가지는 것이 일반적입니다.)

ProductCatalogApplication을 시작하고, http://localhost:8081/doc.html 에서 상품 서비스의 Swagger API 문서를 확인하고 테스트할 수 있습니다.

장바구니 서비스 분리

상품 서비스와 유사하게 app-root 아래에 shopping-cart-service라는 새 모듈을 생성합니다.

의존성 추가:

<dependencies>
    <!-- 공통 모듈 -->
    <dependency>
        <groupId>com.myapp</groupId>
        <artifactId>app-common-lib</artifactId>
        <version>1.0.0</version>
    </dependency>
    <!-- 웹 모듈 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <!-- 데이터베이스 -->
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
    </dependency>
    <!-- Mybatis Plus -->
    <dependency>
        <groupId>com.baomidou</groupId>
        <artifactId>mybatis-plus-boot-starter</artifactId>
    </dependency>
    <!-- 단위 테스트 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>
<build>
    <finalName>${project.artifactId}</finalName>
    <plugins>
        <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
        </plugin>
    </plugins>
</build>

시작 클래스 작성:

@MapperScan("com.myapp.cart.mapper")
@SpringBootApplication
public class ShoppingCartApplication {
    public static void main(String[] args) {
        SpringApplication.run(ShoppingCartApplication.class, args);
    }
}

애플리케이션 설정 파일: application.yaml을 다음과 같이 수정합니다.

server:
  port: 8082
spring:
  application:
    name: shopping-cart-service
  profiles:
    active: local
  datasource:
    url: jdbc:mysql://${app.db.host}:3306/app_cart_db?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&serverTimezone=Asia/Seoul
    driver-class-name: com.mysql.cj.jdbc.Driver
    username: root
    password: ${app.db.password}
mybatis-plus:
  configuration:
    default-enum-type-handler: com.baomidou.mybatisplus.core.handlers.MybatisEnumTypeHandler
  global-config:
    db-config:
      update-strategy: not_null
      id-type: auto
logging:
  level:
    com.myapp: debug
  pattern:
    dateformat: HH:mm:ss:SSS
  file:
    path: "logs/${spring.application.name}"
knife4j:
  enable: true
  openapi:
    title: 장바구니 서비스 API 문서
    description: "장바구니 관리"
    version: v1.0.0
    group:
      default:
        group-name: default
        api-rule: package
        api-rule-resources:
          - com.myapp.cart.controller

기존 서비스에서 장바구니 관련 기능을 복사합니다. com.myapp.cart.service.impl.ShoppingCartService에서 두 가지 부분을 처리해야 합니다.

  • 로그인 사용자 정보 획득: 현재는 로그인 검증 기능이 복사되지 않았으므로, 임시로 고정된 사용자 ID를 사용합니다.
  • 장바구니 조회 시 상품 정보 조회: 상품 정보는 현재 서비스에 없으므로, 이 부분의 코드를 우선 주석 처리합니다.

retrieveMyCartItems 메서드와 processCartItemDetails 메서드를 다음과 같이 변경합니다.

@Service
public class ShoppingCartService extends ServiceImpl<ShoppingCartMapper, CartEntry> implements IShoppingCartService {
    @Override
    public List<ShoppingCartEntryVO> retrieveMyCartItems() {
        // 1. 나의 장바구니 목록 조회
        List<CartEntry> cartEntries = lambdaQuery().eq(CartEntry::getUserId, 1L /*TODO 사용자 ID 획득*/).list();
        if (CollectionUtils.isEmpty(cartEntries)) {
            return Collections.emptyList();
        }
        // 2. VO로 변환
        List<ShoppingCartEntryVO> vos = BeanUtils.copyList(cartEntries, ShoppingCartEntryVO.class);
        // 3. VO 내 상품 정보 처리 (초기에는 주석 처리)
        processCartItemDetails(vos);
        // 4. 반환
        return vos;
    }

    private void processCartItemDetails(List<ShoppingCartEntryVO> vos) {
        // 1. 상품 ID 획득
        /* Set<Long> productIds = vos.stream().map(ShoppingCartEntryVO::getProductId).collect(Collectors.toSet());
        // 2. 상품 조회
        // List<ProductDetailDTO> products = productCatalogService.retrieveProductsByIds(productIds);
        if (CollectionUtils.isEmpty(products)) {
            throw new BusinessException("장바구니에 없는 상품이 포함되어 있습니다!");
        }
        // 3. ID를 키로 하는 상품 맵으로 변환
        Map<Long, ProductDetailDTO> productMap = products.stream().collect(Collectors.toMap(ProductDetailDTO::getId, Function.identity()));
        // 4. VO에 상품 정보 기입
        for (ShoppingCartEntryVO vo : vos) {
            ProductDetailDTO product = productMap.get(vo.getProductId());
            if (product == null) {
                continue;
            }
            vo.setUnitPrice(product.getPrice());
            vo.setProductStatus(product.getStatus());
            vo.setAvailableStock(product.getStock());
        }*/
    }
}

app_cart_db.sql 스크립트를 실행하여 데이터베이스 테이블을 임포트합니다.

ShoppingCartApplication을 시작하고, http://localhost:8082/doc.html 에서 Swagger API 문서를 확인하고 테스트할 수 있습니다.

서비스 간 통신

장바구니 조회 시 상품 정보가 현재 서비스에 없어, 원래 로컬 메서드 호출이었던 것을 마이크로서비스 간 원격 호출(RPC, Remote Procedure Call)로 변경해야 합니다.

shopping-cart-service는 브라우저를 모방하여 HTTP 요청을 product-catalog-service로 전송하여 정보를 얻을 수 있습니다. 스프링(Spring)은 HTTP 요청 전송을 편리하게 해주는 RestTemplate API를 제공합니다.

RestTemplate:

RestTemplate은 HTTP 요청을 보내는 다양한 메서드를 제공합니다. 일반적인 GET, POST, PUT, DELETE 요청을 모두 지원하며, 복잡한 요청 파라미터는 exchange 메서드를 사용하여 구성할 수 있습니다.

shopping-cart-service에 설정 클래스 RemoteServiceConfig를 정의하여 RestTemplate을 빈(Bean)으로 등록합니다.

@Configuration
public class RemoteServiceConfig {

    @Bean
    public RestTemplate restTemplate() {
        return new RestTemplate();
    }
}

shopping-cart-serviceShoppingCartServiceprocessCartItemDetails 메서드를 수정하여 product-catalog-service로 HTTP 요청을 보냅니다.

private final RestTemplate restTemplate;

public ShoppingCartService(RestTemplate restTemplate) {
    this.restTemplate = restTemplate;
}

private void processCartItemDetails(List<ShoppingCartEntryVO> vos) {
    // 1. 상품 ID 획득
    Set<Long> productIds = vos.stream().map(ShoppingCartEntryVO::getProductId).collect(Collectors.toSet());
    if (CollectionUtils.isEmpty(productIds)) {
        return;
    }
    // 2. 상품 조회 (RestTemplate을 이용한 HTTP 요청)
    ResponseEntity<List<ProductDetailDTO>> response = restTemplate.exchange(
            "http://localhost:8081/products?ids={ids}",
            HttpMethod.GET,
            null,
            new ParameterizedTypeReference<List<ProductDetailDTO>>() {},
            Map.of("ids", String.join(",", productIds.stream().map(Object::toString).collect(Collectors.toList())))
    );

    // 2.2. 응답 분석
    if (!response.getStatusCode().is2xxSuccessful()) {
        // 조회 실패 시, 처리를 중단
        return;
    }
    List<ProductDetailDTO> products = response.getBody();
    if (CollectionUtils.isEmpty(products)) {
        return;
    }

    // 3. ID를 키로 하는 상품 맵으로 변환
    Map<Long, ProductDetailDTO> productMap = products.stream().collect(Collectors.toMap(ProductDetailDTO::getId, Function.identity()));
    // 4. VO에 상품 정보 기입
    for (ShoppingCartEntryVO vo : vos) {
        ProductDetailDTO product = productMap.get(vo.getProductId());
        if (product == null) {
            continue;
        }
        vo.setUnitPrice(product.getPrice());
        vo.setProductStatus(product.getStatus());
        vo.setAvailableStock(product.getStock());
    }
}

서비스 등록 및 검색

등록 센터 원리

마이크로서비스 원격 호출 과정에는 두 가지 역할이 있습니다.

  • 서비스 제공자: 다른 마이크로서비스가 접근할 수 있는 인터페이스를 제공합니다 (예: product-catalog-service).
  • 서비스 소비자: 다른 마이크로서비스가 제공하는 인터페이스를 호출합니다 (예: shopping-cart-service).

일반적인 서비스 통신 흐름:

  1. 서비스 시작 시 자신의 서비스 정보(서비스 이름, IP, 포트)를 등록 센터에 등록합니다.
  2. 서비스 소비자는 등록 센터로부터 원하는 서비스를 구독하여 해당 서비스의 인스턴스 목록(하나의 서비스에 여러 인스턴스가 배포될 수 있음)을 가져옵니다.
  3. 서비스 소비자는 자체적으로 인스턴스 목록에 대해 로드 밸런싱을 수행하여 하나의 인스턴스를 선택합니다.
  4. 서비스 소비자는 선택된 인스턴스로 원격 호출을 시작합니다.

서비스 제공자의 인스턴스가 다운되거나 새로운 인스턴스가 시작될 경우, 서비스 제공자와 등록 센터는 다음과 같은 작업을 수행합니다.

  • 서비스 제공자는 등록 센터에 주기적으로 요청을 보내 자신의 상태를 보고합니다 (하트비트 요청).
  • 등록 센터는 제공자의 하트비트 요청을 장시간 받지 못하면 해당 인스턴스를 다운된 것으로 간주하고 서비스 인스턴스 목록에서 제거합니다.
  • 새로운 서비스 인스턴스가 시작되면 등록 서비스 요청을 보내고, 해당 정보는 등록 센터의 서비스 인스턴스 목록에 기록됩니다.
  • 등록 센터의 서비스 목록이 변경되면 마이크로서비스에 능동적으로 알림을 보내 로컬 서비스 목록을 업데이트하도록 합니다.

Nacos 등록 센터

현재 오픈 소스 등록 센터 프레임워크는 다양하며, 널리 사용되는 것들은 다음과 같습니다.

  • Eureka: 넷플릭스(Netflix)에서 개발했으며, 스프링 클라우드에 통합되어 주로 자바 애플리케이션에 사용됩니다.
  • Nacos: 알리바바(Alibaba)에서 개발했으며, 스프링 클라우드 알리바바(Spring Cloud Alibaba)에 통합되어 주로 자바 애플리케이션에 사용됩니다. 구성 관리 기능도 함께 제공합니다.
  • Consul: 해시코프(HashiCorp)에서 개발했으며, 스프링 클라우드에 통합되어 마이크로서비스 언어에 제한을 두지 않습니다.

이러한 등록 센터들은 스프링 클라우드의 API 표준을 따르므로, 비즈니스 개발 시 사용법에는 큰 차이가 없습니다. Nacos는 구성 관리 기능도 갖추고 있어 국내에서 많이 사용됩니다.

Docker를 기반으로 Nacos 등록 센터를 배포합니다. 먼저 Nacos 데이터 저장을 위한 MySQL 데이터베이스 테이블을 준비해야 합니다. Docker 배포의 경우 nacos.sql 파일을 Docker 내 MySQL 컨테이너로 임포트해야 합니다.

nacos.tar 파일을 루트 디렉토리에 복사한 후, 다음 Docker 명령어를 실행하여 이미지를 로드합니다.

docker load -i nacos.tar

루트 디렉토리로 이동하여 다음 Docker 명령어를 실행합니다.

docker run -d \
--name nacos \
--env-file ./nacos/custom.env \
-p 8848:8848 \
-p 9848:9848 \
-p 9849:9849 \
--restart=always \
nacos/nacos-server:v2.1.0-slim

시작이 완료되면 http://[가상머신IP]:8848/nacos/ 주소로 접속합니다. (여기서 [가상머신IP]를 실제 가상머신의 IP 주소로 대체해야 합니다.) 첫 접속 시 로그인 페이지로 이동하며, 계정명과 비밀번호는 모두 nacos입니다.

서비스 등록:

product-catalog-servicepom.xml에 의존성을 추가합니다.

<!-- Nacos 서비스 등록 및 검색 -->
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>

product-catalog-serviceapplication.yaml에 Nacos 주소 설정을 추가합니다.

spring:
  application:
    name: product-catalog-service # 서비스 이름
  cloud:
    nacos:
      server-addr: [가상머신IP]:8848 # Nacos 주소

하나의 서비스에 여러 인스턴스를 테스트하기 위해 product-catalog-service의 배포 인스턴스를 여러 개 구성할 수 있습니다 (시작 클래스를 복사하고 시작 포트만 변경하면 됩니다).

서비스 검색:

서비스 소비자(shopping-cart-service)는 Nacos에 서비스를 구독해야 하며, 이 과정을 서비스 검색이라고 합니다. 단계는 다음과 같습니다.

  • 의존성 추가
  • Nacos 주소 설정
  • 서비스 검색 및 호출

서비스 검색을 위해서는 Nacos 의존성 외에 로드 밸런싱을 위해 스프링 클라우드에서 제공하는 LoadBalancer 의존성을 추가해야 합니다.

<!-- Nacos 서비스 등록 및 검색 -->
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<!--
    여기 Nacos 의존성은 서비스 등록 시와 동일합니다. 이 의존성은 서비스 등록 및 검색 기능을 모두 포함합니다.
    모든 마이크로서비스는 다른 서비스를 호출할 수도 있고, 다른 서비스에 의해 호출될 수도 있기 때문에,
    소비자이면서 제공자가 될 수 있습니다. 따라서 shopping-cart-service가 시작되면 Nacos에 등록됩니다.
 -->
<!-- 로드 밸런서 -->
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>

shopping-cart-serviceapplication.yaml에 Nacos 주소 설정을 추가합니다.

spring:
  cloud:
    nacos:
      server-addr: [가상머신IP]:8848

서비스 검색 및 호출:

서비스 소비자 shopping-cart-serviceproduct-catalog-service를 구독할 수 있습니다. 서비스 검색에는 DiscoveryClient 도구가 필요하며, 스프링 클라우드에서 자동으로 구성해주므로 직접 주입하여 사용할 수 있습니다.

private final RestTemplate restTemplate;
private final DiscoveryClient discoveryClient;

public ShoppingCartService(RestTemplate restTemplate, DiscoveryClient discoveryClient) {
    this.restTemplate = restTemplate;
    this.discoveryClient = discoveryClient;
}

private void processCartItemDetails(List<ShoppingCartEntryVO> vos) {
    Set<Long> productIds = vos.stream().map(ShoppingCartEntryVO::getProductId).collect(Collectors.toSet());
    if (CollectionUtils.isEmpty(productIds)) {
        return;
    }

    // 1. DiscoveryClient를 사용하여 'product-catalog-service' 인스턴스 목록 획득
    List<ServiceInstance> instances = discoveryClient.getInstances("product-catalog-service");
    if (CollectionUtils.isEmpty(instances)) {
        System.err.println("Product Catalog Service 인스턴스를 찾을 수 없습니다.");
        return;
    }

    // 2. 인스턴스 중 하나를 무작위로 선택하여 로드 밸런싱 (단순 예시)
    ServiceInstance chosenInstance = instances.get(new Random().nextInt(instances.size()));
    String serviceUrl = chosenInstance.getUri().toString();

    // 3. RestTemplate을 이용한 HTTP 요청
    ResponseEntity<List<ProductDetailDTO>> response = restTemplate.exchange(
            serviceUrl + "/products?ids={ids}",
            HttpMethod.GET,
            null,
            new ParameterizedTypeReference<List<ProductDetailDTO>>() {},
            Map.of("ids", String.join(",", productIds.stream().map(Object::toString).collect(Collectors.toList())))
    );

    if (!response.getStatusCode().is2xxSuccessful()) {
        System.err.println("상품 정보 조회 실패: " + response.getStatusCode());
        return;
    }
    List<ProductDetailDTO> products = response.getBody();
    if (CollectionUtils.isEmpty(products)) {
        return;
    }

    Map<Long, ProductDetailDTO> productMap = products.stream().collect(Collectors.toMap(ProductDetailDTO::getId, Function.identity()));
    for (ShoppingCartEntryVO vo : vos) {
        ProductDetailDTO product = productMap.get(vo.getProductId());
        if (product == null) {
            continue;
        }
        vo.setUnitPrice(product.getPrice());
        vo.setProductStatus(product.getStatus());
        vo.setAvailableStock(product.getStock());
    }
}

OpenFeign

RestTemplate을 사용하여 원격 서비스를 호출했지만, 코드가 다소 복잡합니다. OpenFeign은 Spring MVC 관련 어노테이션을 활용하고 동적 프록시를 기반으로 원격 호출 코드를 자동으로 생성하여 이러한 복잡성을 줄여줍니다.

빠른 시작

shopping-cart-serviceretrieveMyCartItems 메서드에 대한 Feign 클라이언트 구현을 예로 듭니다.

shopping-cart-servicepom.xml에 OpenFeign 및 LoadBalancer 의존성을 추가합니다.

<!-- OpenFeign -->
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<!-- 로드 밸런서 -->
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>

shopping-cart-service의 시작 클래스에 @EnableFeignClients 어노테이션을 추가하여 OpenFeign 기능을 활성화합니다.

@EnableFeignClients
@MapperScan("com.myapp.cart.mapper")
@SpringBootApplication
public class ShoppingCartApplication {
    public static void main(String[] args) {
        SpringApplication.run(ShoppingCartApplication.class, args);
    }
}

shopping-cart-service에 새로운 인터페이스를 정의하여 Feign 클라이언트를 작성합니다.

@FeignClient("product-catalog-service") // 호출할 서비스의 이름
public interface ProductCatalogClient {
    @GetMapping("/products") // 요청 방식 및 경로
    List<ProductDetailDTO> retrieveProductsByIds(@RequestParam("ids") Collection<Long> productIds);
    /*
     * 여기서는 인터페이스만 선언하고 메서드를 구현할 필요가 없습니다. 핵심 정보는 다음과 같습니다.
     * @FeignClient("product-catalog-service"): 서비스 이름 선언
     * @GetMapping: 요청 방식 선언
     * @GetMapping("/products"): 요청 경로 선언
     * @RequestParam("ids") Collection<Long> productIds: 요청 파라미터 선언
     * List<ProductDetailDTO>: 반환 타입
     */
}

shopping-cart-serviceShoppingCartServiceprocessCartItemDetails 메서드에서 ProductCatalogClient의 메서드를 직접 호출합니다.

private final ProductCatalogClient productCatalogClient;

public ShoppingCartService(ProductCatalogClient productCatalogClient) {
    this.productCatalogClient = productCatalogClient;
}

private void processCartItemDetails(List<ShoppingCartEntryVO> vos) {
    Set<Long> productIds = vos.stream().map(ShoppingCartEntryVO::getProductId).collect(Collectors.toSet());
    if (CollectionUtils.isEmpty(productIds)) {
        return;
    }

    // Feign 클라이언트를 통해 상품 조회
    List<ProductDetailDTO> products = productCatalogClient.retrieveProductsByIds(productIds);
    if (CollectionUtils.isEmpty(products)) {
        return;
    }

    Map<Long, ProductDetailDTO> productMap = products.stream().collect(Collectors.toMap(ProductDetailDTO::getId, Function.identity()));
    for (ShoppingCartEntryVO vo : vos) {
        ProductDetailDTO product = productMap.get(vo.getProductId());
        if (product == null) {
            continue;
        }
        vo.setUnitPrice(product.getPrice());
        vo.setProductStatus(product.getStatus());
        vo.setAvailableStock(product.getStock());
    }
}

이제 RestTemplate은 더 이상 필요 없으며, RestTemplate을 빈으로 등록할 필요도 없습니다.

연결 풀

Feign은 내부적으로 HTTP 요청을 보내기 위해 다른 프레임워크에 의존합니다. 지원되는 HTTP 클라이언트 구현은 다음과 같습니다.

  • HttpURLConnection: 기본 구현, 연결 풀 미지원
  • Apache HttpClient: 연결 풀 지원
  • OKHttp: 연결 풀 지원

여기서는 OKHttp를 사용하여 연결 풀을 구현합니다.

shopping-cart-servicepom.xml에 OKHttp 의존성을 추가합니다.

<!-- OK Http 의존성 -->
<dependency>
  <groupId>io.github.openfeign</groupId>
  <artifactId>feign-okhttp</artifactId>
</dependency>

shopping-cart-serviceapplication.yaml 설정 파일에서 Feign의 연결 풀 기능을 활성화합니다.

feign:
  okhttp:
    enabled: true # OKHttp 기능 활성화

로그 설정

OpenFeign은 FeignClient가 있는 패키지의 로그 레벨이 DEBUG일 때만 로그를 출력합니다. 로그 레벨은 4단계로 나뉩니다.

  • NONE: 어떤 로그 정보도 기록하지 않습니다 (기본값).
  • BASIC: 요청 메서드, URL, 응답 상태 코드 및 실행 시간만 기록합니다.
  • HEADERS: BASIC에 더해 요청 및 응답 헤더 정보를 추가로 기록합니다.
  • FULL: 헤더 정보, 요청 본문, 메타데이터를 포함한 모든 요청 및 응답 상세 정보를 기록합니다.

Feign의 기본 로그 레벨은 NONE이므로, 기본적으로 요청 로그를 볼 수 없습니다.

새로운 FeignClientConfig 설정 클래스를 생성하여 Feign의 로그 레벨을 정의합니다.

public class FeignClientConfig {
    @Bean
    public Logger.Level feignLogLevel(){
        return Logger.Level.FULL;
    }
}

로그 레벨을 적용하려면 이 클래스를 구성해야 합니다. 두 가지 방법이 있습니다.

  • 부분 적용: 특정 FeignClient에 설정하여 해당 FeignClient에만 적용합니다.
    @FeignClient(value = "product-catalog-service", configuration = FeignClientConfig.class)
  • 전역 적용: @EnableFeignClients에 설정하여 모든 FeignClient에 적용합니다.
    @EnableFeignClients(defaultConfiguration = FeignClientConfig.class)

모범 사례

만약 주문 처리 서비스(order-processing-service)도 product-catalog-service의 ID를 통해 상품을 대량 조회하는 기능이 필요하다면, 이는 shopping-cart-service와 동일한 요구사항입니다. 이 경우 order-processing-service에서도 ProductCatalogClient 인터페이스를 다시 정의해야 하므로 코드 중복이 발생합니다.

코드 중복을 피하는 방법은 공통으로 사용하는 부분을 추출하는 것입니다. 두 가지 추출 방안이 있습니다.

  • 방안 1: 마이크로서비스 외부의 공통 모듈로 추출합니다. 추출이 간단하고 프로젝트 구조가 명확하지만, 전체 프로젝트의 결합도가 높아질 수 있습니다.
  • 방안 2: 각 마이크로서비스가 자체적으로 API 모듈을 추출합니다. 추출이 상대적으로 복잡하고 프로젝트 구조도 더 복잡해지지만, 서비스 간 결합도가 낮아집니다.

여기서는 방안 1을 채택합니다.

Feign 클라이언트 추출

app-root 아래에 app-shared-api라는 새 모듈을 정의합니다.

의존성 추가:

<dependencies>
    <!-- OpenFeign -->
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-openfeign</artifactId>
    </dependency>
    <!-- Load Balancer -->
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-loadbalancer</artifactId<
    </dependency>
    <!-- Swagger Annotation -->
    <dependency>
        <groupId>io.swagger</groupId>
        <artifactId>swagger-annotations</artifactId>
        <version>1.6.6</version>
        <scope>compile</scope>
    </dependency>
    <!-- 공통 모듈 (DTO 등을 포함) -->
    <dependency>
        <groupId>com.myapp</groupId>
        <artifactId>app-common-lib</artifactId>
        <version>1.0.0</version>
    </dependency>
</dependencies>

ProductDetailDTO, ProductCatalogClient 및 OpenFeign의 로그 설정 클래스를 app-shared-api 모듈로 복사합니다. 이제 어떤 마이크로서비스든 product-catalog-service의 인터페이스를 호출하려면 app-shared-api 모듈 의존성만 추가하면 되며, Feign 클라이언트를 직접 작성할 필요가 없습니다.

패키지 스캔

shopping-cart-servicepom.xmlapp-shared-api 모듈을 추가합니다.

<!-- Feign API 모듈 -->
<dependency>
    <groupId>com.myapp</groupId>
    <artifactId>app-shared-api</artifactId>
    <version>1.0.0</version>
</dependency>

shopping-cart-service의 시작 클래스에 다음 두 가지 방식 중 하나로 Feign 클라이언트 스캔을 선언합니다.

방식 1: 스캔할 패키지 선언

@EnableFeignClients(basePackages = "com.myapp.api.client") // FeignClient 인터페이스가 위치한 패키지
@MapperScan("com.myapp.cart.mapper")
@SpringBootApplication
public class ShoppingCartApplication {
    public static void main(String[] args) {
        SpringApplication.run(ShoppingCartApplication.class, args);
    }
}

방식 2: 사용할 FeignClient 클래스 직접 선언

@EnableFeignClients(clients = {ProductCatalogClient.class}) // 사용할 FeignClient 클래스 직접 명시
@MapperScan("com.myapp.cart.mapper")
@SpringBootApplication
public class ShoppingCartApplication {
    public static void main(String[] args) {
        SpringApplication.run(ShoppingCartApplication.class, args);
    }
}

API 게이트웨이 라우팅

게이트웨이(Gateway)는 네트워크의 관문입니다. 데이터가 네트워크 간에 전송될 때 게이트웨이를 통해 라우팅 및 전달, 그리고 데이터 보안 검증이 이루어집니다.

프론트엔드(Frontend) 요청은 직접 마이크로서비스에 접근할 수 없으며, 반드시 게이트웨이를 통해 요청해야 합니다.

  • 게이트웨이는 보안 제어, 즉 로그인 인증 검증을 수행하며, 검증이 통과된 요청만 허용합니다.
  • 인증 후, 게이트웨이는 요청에 따라 어떤 마이크로서비스를 호출할지 판단하고 해당 서비스로 요청을 전달합니다.

스프링 클라우드에서는 두 가지 게이트웨이 구현 방안을 제공합니다.

  • Netflix Zuul: 초기 구현이었으나 현재는 사용 중단되었습니다.
  • Spring Cloud Gateway: 스프링의 WebFlux 기술을 기반으로 하며, 완전한 반응형 프로그래밍을 지원하여 처리량이 더 높습니다.

빠른 시작

게이트웨이 자체도 독립적인 마이크로서비스이며, 기능을 개발하기 위해 모듈을 생성해야 합니다.

app-root 아래에 api-gateway라는 새 모듈을 생성합니다.

api-gateway 모듈의 pom.xml 파일에 의존성을 추가합니다.

<dependencies>
    <!-- 공통 모듈 -->
    <dependency>
        <groupId>com.myapp</groupId>
        <artifactId>app-common-lib</artifactId>
        <version>1.0.0</version>
    </dependency>
    <!-- 게이트웨이 -->
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-gateway</artifactId>
    </dependency>
    <!-- Nacos Discovery -->
    <dependency>
        <groupId>com.alibaba.cloud</groupId>
        <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
    </dependency>
    <!-- 로드 밸런서 -->
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-loadbalancer</artifactId>
    </dependency>
</dependencies>
<build>
    <finalName>${project.artifactId}</finalName>
    <plugins>
        <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
        </plugin>
    </plugins>
</build>

api-gateway 모듈의 com.myapp.gateway 패키지 아래에 시작 클래스를 생성합니다.

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

api-gateway 모듈의 resources 디렉토리에 application.yaml 파일을 생성하여 라우팅을 구성합니다.

server:
  port: 8080
spring:
  application:
    name: api-gateway
  cloud:
    nacos:
      server-addr: [가상머신IP]:8848
    gateway:
      routes:
        - id: product-catalog # 라우팅 규칙 ID, 사용자 정의, 고유해야 함
          uri: lb://product-catalog-service # 라우팅 대상 서비스, lb는 로드 밸런싱을 의미하며 등록 센터에서 서비스 목록을 가져옴
          predicates: # 라우팅 단언, 현재 요청이 이 규칙에 부합하는지 판단, 부합하면 대상 서비스로 라우팅
            - Path=/products/**,/search/** # 요청 경로를 판단 규칙으로 사용
        - id: shopping-cart
          uri: lb://shopping-cart-service
          predicates:
            - Path=/carts/**
        - id: user-management
          uri: lb://user-management-service
          predicates:
            - Path=/users/**,/addresses/**
        - id: order-processing
          uri: lb://order-processing-service
          predicates:
            - Path=/orders/**
        - id: payment-service
          uri: lb://payment-service
          predicates:
            - Path=/pay-orders/**

라우팅 필터

라우팅 규칙의 정의 문법은 다음과 같습니다.

spring:
    gateway:
      routes:
        - id: product-catalog
          uri: lb://product-catalog-service
          predicates:
            - Path=/products/**,/search/**

routes는 컬렉션 타입이며, 여러 라우팅 규칙을 정의할 수 있습니다. 컬렉션 내의 RouteDefinition이 구체적인 라우팅 규칙 정의이며, 일반적인 속성은 다음과 같습니다.

  • id: 라우팅의 고유 식별자
  • predicates: 라우팅 단언, 즉 매칭 조건
  • filters: 라우팅 필터 조건 (나중에 설명)
  • uri: 라우팅 대상 주소, lb://는 로드 밸런싱을 의미하며, 등록 센터에서 대상 마이크로서비스의 인스턴스 목록을 가져와 로드 밸런싱하여 접근합니다.

Spring Cloud Gateway가 지원하는 단언(Predicate) 타입은 다양합니다.

이름 설명 예시
After 특정 시점 이후의 요청 - After=2037-01-20T17:42:47.789-07:00[America/Denver]
Before 특정 시점 이전의 요청 - Before=2031-04-13T15:14:47.433+08:00[Asia/Shanghai]
Between 두 시점 사이의 요청 - Between=2037-01-20T17:42:47.789-07:00[America/Denver], 2037-01-21T17:42:47.789-07:00[America/Denver]
Cookie 요청에 특정 쿠키 포함 필수 - Cookie=session, myvalue
Header 요청에 특정 헤더 포함 필수 - Header=X-Request-ID, \d+
Host 요청 호스트(도메인) 매칭 - Host=*.example.com,*.anotherdomain.org
Method 요청 방식 매칭 - Method=GET,POST
Path 요청 경로 매칭 - Path=/api/{segment},/service/**
Query 요청 파라미터 포함 필수 - Query=name, John 또는 - Query=name
RemoteAddr 요청자 IP 범위 매칭 - RemoteAddr=192.168.1.1/24
Weight 가중치 기반 처리

Gateway 내장 필터 중 AddRequestHeaderGatewayFilterFactory는 요청에 특정 헤더를 추가하고 이를 하위 마이크로서비스로 전달하는 역할을 합니다. 사용 시 application.yaml에 다음과 같이 구성합니다.

spring:
  cloud:
    gateway:
      routes:
      - id: sample_route
        uri: lb://sample-service
        predicates:
          - Path=/sample/**
        filters:
          - AddRequestHeader=custom-key, custom-value # 쉼표 앞은 헤더 키, 뒤는 값

필터가 모든 라우팅에 적용되도록 하려면 다음과 같이 구성합니다.

spring:
  cloud:
    gateway:
      default-filters: # default-filters 아래의 필터는 모든 라우팅에 적용됩니다.
        - AddRequestHeader=custom-key, custom-value
      routes:
      - id: sample_route
        uri: lb://sample-service
        predicates:
          - Path=/sample/**

사용자 정의 필터

GatewayFilterGlobalFilter 모두 사용자 정의를 지원하지만, 코딩 방식과 사용 방식에 약간의 차이가 있습니다.

사용자 정의 GatewayFilter

사용자 정의 GatewayFilterGatewayFilter를 직접 구현하는 대신 AbstractGatewayFilterFactory를 구현해야 합니다 (클래스 이름은 반드시 GatewayFilterFactory로 끝나야 합니다!).

@Component
public class CustomLogGatewayFilterFactory extends AbstractGatewayFilterFactory<Object> {
    @Override
    public GatewayFilter apply(Object config) {
        return (exchange, chain) -> {
            // 요청 획득
            ServerHttpRequest request = exchange.getRequest();
            // 필터 로직 작성
            System.out.println("사용자 정의 필터가 실행되었습니다.");
            // 다음 필터 또는 서비스로 전달
            return chain.filter(exchange);
        };
    }
}

yaml 설정에서 다음과 같이 사용합니다.

spring:
  cloud:
    gateway:
      default-filters:
            - CustomLog # 사용자 정의 GatewayFilterFactory 클래스 이름의 접두사를 직접 사용

이러한 필터는 동적 파라미터 구성도 지원하지만, 구현이 더 복잡합니다.

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

    public ParameterizedLogGatewayFilterFactory() {
        super(Config.class);
    }

    @Override
    public GatewayFilter apply(Config config) {
        return new OrderedGatewayFilter((exchange, chain) -> {
            // Config 값 획득
            String param1 = config.getParam1();
            String param2 = config.getParam2();
            // 필터 로직 작성
            System.out.println("param1 = " + param1);
            System.out.println("param2 = " + param2);
            // 다음 필터 또는 서비스로 전달
            return chain.filter(exchange);
        }, 100); // 100은 필터 실행 우선순위 (값이 작을수록 우선순위 높음)
    }

    // 사용자 정의 설정 속성, 멤버 변수 이름이 중요합니다.
    @Data
    public static class Config {
        private String param1;
        private String param2;
    }

    // 변수 이름을 순서대로 반환합니다. 순서가 중요하며, 나중에 파라미터를 읽을 때 순서대로 획득합니다.
    @Override
    public List<String> shortcutFieldOrder() {
        return List.of("param1", "param2");
    }
}

yaml 파일에서 다음과 같이 사용합니다.

spring:
  cloud:
    gateway:
      default-filters:
            - ParameterizedLog=valueA,valueB # 여러 파라미터는 쉼표로 구분하며, shortcutFieldOrder() 메서드에 정의된 순서대로 복사됨

파라미터 이름을 직접 지정하는 방식도 있습니다.

spring:
  cloud:
    gateway:
      default-filters:
            - name: ParameterizedLog
              args: # 파라미터 이름을 수동으로 지정, 순서 상관 없음
                param1: customValueA
                param2: customValueB

사용자 정의 GlobalFilter

사용자 정의 GlobalFilter는 직접 GlobalFilter를 구현하며, 동적 파라미터를 설정할 수 없습니다.

@Component
public class AuthorizationGlobalFilter implements GlobalFilter, Ordered {
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        // 필터 로직 작성 (예: 미로그인 시 접근 불가)
        System.out.println("사용자 인증 필요, 접근 불가");
        // 요청 가로채기 (인증 실패)
        ServerHttpResponse response = exchange.getResponse();
        response.setRawStatusCode(401); // 401 Unauthorized
        return response.setComplete();
        // 또는 요청 통과 (인증 성공)
        // return chain.filter(exchange);
    }

    @Override
    public int getOrder() {
        // 필터 실행 순서, 값이 작을수록 우선순위가 높습니다.
        return 0;
    }
}

로그인 검증

로그인 검증에는 JWT(JSON Web Token)가 사용되며, JWT 암호화에는 비밀 키와 암호화 도구가 필요합니다. 이러한 기능은 app-common-lib 모듈에서 복사하여 사용합니다.

AuthPropertiesJwtProperties에 필요한 속성은 application.yaml에 구성해야 합니다.

app:
  jwt:
    secretKeyLocation: classpath:keys/app_jwt.jks # 비밀 키 파일 경로
    keyAlias: app_jwt # 키 별칭
    keyPassword: mysecretpassword # 키 파일 비밀번호
    tokenValidityDuration: 30m # 로그인 유효 기간
  auth:
    excludePaths: # 로그인 검증이 필요 없는 경로
      - /search/**
      - /users/login
      - /products/**

로그인 검증 필터를 정의합니다.

@Component
@RequiredArgsConstructor
@EnableConfigurationProperties(AppAuthProperties.class)
public class AuthGlobalFilter implements GlobalFilter, Ordered {
    private final JwtTokenUtil jwtTokenUtil; // JwtTool 대신 일반적인 JwtTokenUtil 사용
    private final AppAuthProperties appAuthProperties;
    private final AntPathMatcher antPathMatcher = new AntPathMatcher();

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        // 1. Request 획득
        ServerHttpRequest request = exchange.getRequest();
        // 2. 가로챌 필요가 없는 경로인지 판단
        if (isPathExcluded(request.getPath().toString())) {
            // 가로챌 필요 없으면 다음 필터로 전달
            return chain.filter(exchange);
        }
        // 3. 요청 헤더에서 토큰 획득
        String token = null;
        List<String> authHeaders = request.getHeaders().get("authorization");
        if (!CollectionUtils.isEmpty(authHeaders)) {
            token = authHeaders.get(0);
        }
        // 4. 토큰 검증 및 파싱
        Long userId = null;
        try {
            userId = jwtTokenUtil.parseToken(token); // JwtTool 대신 jwtTokenUtil 사용
        } catch (AuthException e) { // UnauthorizedException 대신 일반적인 AuthException 사용
            // 유효하지 않으면 요청 가로채기
            ServerHttpResponse response = exchange.getResponse();
            response.setRawStatusCode(401);
            return response.setComplete();
        }
        // TODO 5. 유효하면 사용자 정보 전달
        System.out.println("Authenticated userId = " + userId);
        // 6. 다음 필터로 전달
        return chain.filter(exchange);
    }

    private boolean isPathExcluded(String currentPath) {
        for (String pathPattern : appAuthProperties.getExcludePaths()) {
            if (antPathMatcher.match(pathPattern, currentPath)) {
                return true;
            }
        }
        return false;
    }

    @Override
    public int getOrder() {
        return 0;
    }
}

마이크로서비스에서 사용자 정보 획득

게이트웨이는 로그인 검증을 완료하고 로그인한 사용자 신원 정보를 획득할 수 있습니다. 하지만 게이트웨이가 요청을 마이크로서비스로 전달할 때, 마이크로서비스는 아직 사용자 정보를 획득할 수 없습니다.

사용자 정보를 요청 헤더를 통해 하위 마이크로서비스로 전달할 수 있습니다. 마이크로서비스는 요청 헤더에서 로그인 사용자 정보를 획득할 수 있습니다. 마이크로서비스 내부의 여러 곳에서 로그인 사용자 정보가 필요할 수 있으므로, 스프링 MVC 인터셉터(Interceptor)를 활용하여 로그인 사용자 정보를 획득하고 ThreadLocal에 저장할 수 있습니다.

로그인 검증 인터셉터의 처리 로직을 수정하여 5단계에서 사용자 정보를 요청 헤더에 저장합니다.

    // TODO 5. 유효하면 사용자 정보 전달
    // System.out.println("Authenticated userId = " + userId);
    String userInfo = userId.toString();
    ServerWebExchange modifiedExchange = exchange
            .mutate()
            .request(builder -> builder.header("X-User-Info", userInfo)) // 헤더 이름 변경
            .build();
    // 6. 다음 필터로 전달
    return chain.filter(modifiedExchange);

app-common-lib 모듈에는 이미 로그인 사용자를 저장하는 ThreadLocal 유틸리티가 있습니다. 인터셉터를 작성하여 사용자 정보를 획득하고 UserContext에 저장한 후 요청을 계속 진행합니다.

각 마이크로서비스는 로그인 사용자 정보 획득 요구사항이 있으므로, 인터셉터를 app-common-lib에 직접 작성하고 자동 구성을 준비합니다. 이렇게 하면 마이크로서비스는 app-common-lib만 의존성으로 추가하면 인터셉터 기능을 바로 사용할 수 있습니다.

app-common-lib 모듈 아래에 UserContextInterceptor 인터셉터를 정의합니다.

public class UserContextInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 1. 요청 헤더에서 사용자 정보 획득
        String userInfo = request.getHeader("X-User-Info"); // 헤더 이름 변경
        // 2. 비어있는지 판단
        if (StringUtils.hasText(userInfo)) {
            // 비어있지 않으면 ThreadLocal에 저장
            UserContext.setUser(Long.valueOf(userInfo));
        }
        // 3. 요청 계속 진행
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        // 사용자 정보 제거
        UserContext.removeUser();
    }
}

이어서 app-common-lib 모듈 아래에 스프링 MVC 설정 클래스를 작성하여 로그인 인터셉터를 구성합니다.

@Configuration
@ConditionalOnClass(DispatcherServlet.class)
public class WebMvcConfiguration implements WebMvcConfigurer { // MvcConfig 대신 WebMvcConfiguration
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new UserContextInterceptor());
    }
}

스프링 부트 자동 구성 원리에 따라 이 설정 클래스를 resources 디렉토리 아래의 META-INF/spring.factories 파일에 추가해야 합니다.

org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
  com.myapp.common.config.MyBatisConfiguration,\
  com.myapp.common.config.JsonSerializationConfiguration,\
  com.myapp.common.config.WebMvcConfiguration

이전에 shopping-cart-service 모듈의 ShoppingCartServiceretrieveMyCartItems 메서드에서 로그인 사용자 정보를 고정 값으로 사용했던 부분을 이제 원래대로 복원할 수 있습니다.

@Override
public List<ShoppingCartEntryVO> retrieveMyCartItems() {
    // 1. 나의 장바구니 목록 조회
    List<CartEntry> cartEntries = lambdaQuery().eq(CartEntry::getUserId, UserContext.getUser()).list();
    if (CollectionUtils.isEmpty(cartEntries)) {
        return Collections.emptyList();
    }
    // 2. VO로 변환
    List<ShoppingCartEntryVO> vos = BeanUtils.copyList(cartEntries, ShoppingCartEntryVO.class);
    // 3. VO 내 상품 정보 처리
    processCartItemDetails(vos);
    // 4. 반환
    return vos;
}

OpenFeign을 통한 사용자 정보 전달

요청이 마이크로서비스에 도달한 후에도 다른 마이크로서비스를 호출해야 할 수 있습니다. 마이크로서비스 간 사용자 정보 전달을 위해서는, 마이크로서비스가 호출을 시작할 때 사용자 정보를 요청 헤더에 포함시켜야 합니다. 마이크로서비스 간 호출은 OpenFeign을 기반으로 구현되므로, Feign이 제공하는 인터셉터 인터페이스 feign.RequestInterceptor를 활용할 수 있습니다. 이 인터페이스를 구현하고 apply 메서드에서 RequestTemplate 클래스를 사용하여 요청 헤더에 사용자 정보를 추가합니다.

FeignClient는 모두 app-shared-api 모듈에 있으므로, app-shared-api 모듈의 FeignClientConfig에 이 인터셉터를 빈으로 추가할 수 있습니다.

@Bean
public RequestInterceptor userContextRequestInterceptor(){
    return template -> {
        // 로그인 사용자 정보 획득
        Long userId = UserContext.getUser();
        if(userId == null) {
            // 비어있으면 건너뛰기
            return;
        }
        // 비어있지 않으면 요청 헤더에 넣어 하위 마이크로서비스로 전달
        template.header("X-User-Info", userId.toString());
    };
}

이 코드를 작성한 후, 해당 클래스를 사용하는 서비스 모듈(예: order-processing-service)에서 로드해야 합니다. @EnableFeignClients 어노테이션에 defaultConfiguration을 사용하여 FeignClientConfig에서 정의된 빈을 로드할 수 있습니다.

@EnableFeignClients(clients = {ProductCatalogClient.class, ShoppingCartClient.class}, defaultConfiguration = FeignClientConfig.class)
@MapperScan("com.myapp.order.mapper")
@SpringBootApplication
public class OrderProcessingApplication {
    public static void main(String[] args) {
        SpringApplication.run(OrderProcessingApplication.class, args);
    }
}

구성 관리

지금까지 마이크로서비스 관련 여러 문제를 해결했지만, 여전히 해결해야 할 문제들이 남아있습니다.

  • 게이트웨이 라우팅이 설정 파일에 고정되어 있어 변경 시 마이크로서비스를 재시작해야 합니다.
  • 일부 비즈니스 설정이 설정 파일에 고정되어 있어 매번 수정 시 서비스를 재시작해야 합니다.
  • 각 마이크로서비스에 많은 중복 설정이 존재하여 유지보수 비용이 높습니다.

이러한 문제들은 통합 구성 관리 서비스(Configuration Management Service)를 통해 해결할 수 있습니다. Nacos는 등록 센터 기능뿐만 아니라 구성 관리 기능도 제공합니다.

게이트웨이 라우팅과 마이크로서비스 공통 설정은 Nacos를 통해 중앙에서 관리할 수 있습니다. Nacos 콘솔에서 설정을 변경하면, Nacos는 관련 마이크로서비스에 변경 사항을 푸시(push)하고, 서비스 재시작 없이 설정이 즉시 적용되어 핫 업데이트가 가능합니다.

공유 구성

공유 구성 추가

JDBC 관련 설정, 로그 설정, Swagger 및 OpenFeign 설정 등은 공유 구성으로 추가할 수 있습니다.

Nacos 콘솔의 "구성 관리" -> "구성 목록"에서 "+"를 클릭하여 새 구성을 생성합니다.

팝업 양식에 정보를 입력합니다.

JDBC 상세 설정:

spring:
  datasource:
    url: jdbc:mysql://${app.db.host:192.168.150.101}:${app.db.port:3306}/${app.db.database}?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&serverTimezone=Asia/Seoul
    driver-class-name: com.mysql.cj.jdbc.Driver
    username: ${app.db.username:root}
    password: ${app.db.password:123}
mybatis-plus:
  configuration:
    default-enum-type-handler: com.baomidou.mybatisplus.core.handlers.MybatisEnumTypeHandler
  global-config:
    db-config:
      update-strategy: not_null
      id-type: auto

JDBC 관련 파라미터는 완전히 고정되지 않았습니다. 예를 들어:

  • 데이터베이스 IP: ${app.db.host:192.168.150.101}로 기본값을 192.168.150.101로 설정하고 ${app.db.host}를 통해 기본값을 덮어쓸 수 있습니다.
  • 데이터베이스 포트: ${app.db.port:3306}로 기본값을 3306으로 설정하고 ${app.db.port}를 통해 기본값을 덮어쓸 수 있습니다.
  • 데이터베이스 이름: ${app.db.database}를 통해 설정할 수 있으며, 기본값은 없습니다.

shared-logging.yaml이라는 이름의 로그 설정 파일 내용은 다음과 같습니다.

logging:
  level:
    com.myapp: debug
  pattern:
    dateformat: HH:mm:ss:SSS
  file:
    path: "logs/${spring.application.name}"

shared-swagger.yaml이라는 이름의 Swagger 설정 파일 내용은 다음과 같습니다.

knife4j:
  enable: true
  openapi:
    title: ${app.api.title:마이크로서비스 API 문서}
    description: ${app.api.description:공통 API 문서}
    version: v1.0.0
    group:
      default:
        group-name: default
        api-rule: package
        api-rule-resources:
          - ${app.api.package}

Swagger 관련 설정도 완전히 고정되지 않았습니다. 예를 들어:

  • title: API 문서 제목으로 ${app.api.title}을 사용하여 나중에 사용자가 직접 지정할 수 있도록 했습니다.
  • email: 연락처 이메일로 ${app.api.email:support@myapp.com}을 사용하여 기본값을 제공하면서도 ${app.api.email}을 통해 덮어쓸 수 있도록 했습니다.

공유 구성 가져오기

스프링 클라우드는 초기화 시 bootstrap.yaml (또는 bootstrap.properties) 파일을 먼저 읽습니다. Nacos 주소를 bootstrap.yaml에 구성하면 프로젝트 부트스트랩(bootstrap) 단계에서 Nacos의 설정을 읽을 수 있으며, 스프링 클라우드 마이크로서비스 초기화 시 Nacos 및 관련 설정을 먼저 초기화한 다음 application.yaml의 내용을 초기화합니다.

shopping-cart-service 모듈에 Nacos 구성 관리를 통합하는 것을 예로 들면, 먼저 의존성을 추가해야 합니다.

<!-- Nacos 구성 관리 -->
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
<!-- bootstrap 파일 로드 -->
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-bootstrap</artifactId>
</dependency>

다음으로 bootstrap.yaml 파일을 생성합니다.

spring:
  application:
    name: shopping-cart-service # 서비스 이름
  profiles:
    active: dev
  cloud:
    nacos:
      server-addr: [가상머신IP]:8848 # Nacos 주소
      config:
        file-extension: yaml # 파일 확장자
        shared-configs: # 공유 구성
          - dataId: shared-jdbc.yaml # 공유 JDBC 설정
          - dataId: shared-logging.yaml # 공유 로그 설정
          - dataId: shared-swagger.yaml # 공유 Swagger 설정

application.yaml을 수정합니다.

server:
  port: 8082
feign:
  okhttp:
    enabled: true # OKHttp 연결 풀 지원 활성화
app:
  api:
    title: 장바구니 서비스 API 문서
    package: com.myapp.cart.controller
  db:
    database: app_cart_db

마지막으로 서비스를 재시작하면 모든 설정이 적용됩니다.

구성 핫 업데이트

많은 비즈니스 관련 파라미터는 실제 상황에 따라 임시로 조정될 수 있습니다. Nacos에 구성을 추가하고 마이크로서비스에서 해당 구성을 읽도록 하면, 서비스 재시작 없이 즉시 적용되어 설정 핫 업데이트가 가능합니다.

장바구니 서비스의 최대 상품 수량을 예로 들어 설명합니다.

Nacos에 구성 파일을 추가하고 장바구니의 최대 수량을 설정에 추가합니다.

파일의 dataId 형식:

  • [서비스명]-[spring.active.profile].[확장자]

파일 이름은 세 부분으로 구성됩니다.

  • 서비스명: shopping-cart-service
  • spring.active.profile: 스프링 부트의 spring.active.profile과 동일하며, 생략 시 모든 프로필이 해당 구성을 공유합니다.
  • 확장자: 예를 들어 yaml

여기서는 shopping-cart-service.yaml이라는 이름을 사용하여 개발(dev) 및 로컬(local) 환경 모두에서 이 구성을 공유하도록 하며, 내용은 다음과 같습니다.

app:
  cart:
    maxItems: 10 # 장바구니 상품 수량 상한

shopping-cart-service에 속성 읽기 클래스를 새로 만듭니다.

@Data
@Component
@ConfigurationProperties(prefix = "app.cart")
public class ShoppingCartProperties {
    private Integer maxItems;
}

비즈니스 로직에서 이 속성 로드 클래스를 사용하여 ShoppingCartService 클래스의 checkCartCapacity 메서드를 변경합니다.

private final ShoppingCartProperties shoppingCartProperties;

public ShoppingCartService(ShoppingCartProperties shoppingCartProperties) {
    this.shoppingCartProperties = shoppingCartProperties;
}

private void checkCartCapacity(Long userId) {
    int currentCount = lambdaQuery().eq(CartEntry::getUserId, userId).count();
    if (currentCount >= shoppingCartProperties.getMaxItems()) {
        throw new BusinessException(StringUtils.format("사용자 장바구니에 담을 수 있는 상품은 {}개를 초과할 수 없습니다.", shoppingCartProperties.getMaxItems()));
    }
}

동적 라우팅

게이트웨이의 라우팅 설정은 프로젝트 시작 시 로드되어 메모리 내 라우팅 테이블에 캐시되며, 변경되거나 라우팅 변경을 수신하지 않습니다. Nacos의 구성 변경을 수신하려면 Nacos 동적 구성 리스너(Listener)의 addListener 인터페이스를 사용할 수 있습니다.

addListener 인터페이스에는 세 가지 파라미터가 필요합니다.

파라미터명 파라미터 타입 설명
dataId string 구성 ID, 전역적으로 고유해야 하며 영문자와 4가지 특수 문자(".": "-""_")만 허용됩니다. 256바이트를 초과할 수 없습니다.
group string 구성 그룹, 일반적으로 기본값인 DEFAULT_GROUP입니다.
listener Listener 리스너, 구성 변경 시 리스너의 콜백 함수가 호출됩니다.

사용하기 전에 게이트웨이 모듈에 의존성을 추가해야 합니다.

<!-- 통합 구성 관리 -->
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
<!-- bootstrap 로드 -->
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-bootstrap</artifactId>
</dependency>

그리고 게이트웨이 모듈에 bootstrap.yaml 구성 파일을 생성합니다.

spring:
  application:
    name: api-gateway
  cloud:
    nacos:
      server-addr: [가상머신IP]:8848
      config:
        file-extension: yaml
        shared-configs:
          - dataId: shared-logging.yaml # 공유 로그 설정

게이트웨이의 application.yaml 구성 파일을 수정하여 기존 라우팅 설정을 제거합니다.

server:
  port: 8080 # 포트
app:
  jwt:
    secretKeyLocation: classpath:keys/app_jwt.jks # 비밀 키 파일 경로
    keyAlias: app_jwt # 키 별칭
    keyPassword: mysecretpassword # 키 파일 비밀번호
    tokenValidityDuration: 30m # 로그인 유효 기간
  auth:
    excludePaths: # 로그인 검증이 필요 없는 경로
      - /search/**
      - /users/login
      - /products/**

구성 리스너를 정의합니다.

@Slf4j
@Component
@RequiredArgsConstructor
public class NacosDynamicRouteUpdater { // DynamicRouteLoader 대신 NacosDynamicRouteUpdater
    private final RouteDefinitionWriter routeDefinitionWriter; // writer 대신 routeDefinitionWriter
    private final NacosConfigManager nacosConfigManager;
    // 라우팅 구성 파일의 ID 및 그룹
    private final String configDataId = "api-gateway-routes.json"; // dataId 대신 configDataId
    private final String configGroup = "DEFAULT_GROUP"; // group 대신 configGroup
    // 업데이트된 라우팅 ID 저장
    private final Set<String> currentRouteIds = new HashSet<>(); // routeIds 대신 currentRouteIds

    @PostConstruct
    public void initRouteConfigListener() throws NacosException {
        // 1. 리스너 등록 및 첫 구성 가져오기
        String initialConfigInfo = nacosConfigManager.getConfigService()
                .getConfigAndSignListener(configDataId, configGroup, 5000, new Listener() {
                    @Override
                    public Executor getExecutor() {
                        return null; // 기본 스레드 풀 사용
                    }
                    @Override
                    public void receiveConfigInfo(String configInfo) {
                        updateRouteDefinitions(configInfo);
                    }
                });
        // 2. 초기 시작 시, 한 번 구성 업데이트
        updateRouteDefinitions(initialConfigInfo);
    }

    private void updateRouteDefinitions(String configInfo) {
        log.debug("라우팅 구성 변경 감지됨: {}", configInfo);
        // 1. 역직렬화
        List<RouteDefinition> newRouteDefinitions = JSONUtil.toList(configInfo, RouteDefinition.class); // JSONUtil 사용

        // 2. 업데이트 전 기존 라우팅 제거
        // 2.1. 기존 라우팅 제거
        for (String routeId : currentRouteIds) {
            routeDefinitionWriter.delete(Mono.just(routeId)).subscribe();
        }
        currentRouteIds.clear();

        // 2.2. 새로운 라우팅이 없는지 확인
        if (CollectionUtils.isEmpty(newRouteDefinitions)) {
            // 새 라우팅 구성 없음, 종료
            return;
        }

        // 3. 라우팅 업데이트
        newRouteDefinitions.forEach(routeDefinition -> {
            // 3.1. 라우팅 저장
            routeDefinitionWriter.save(Mono.just(routeDefinition)).subscribe();
            // 3.2. 라우팅 ID 기록, 향후 삭제를 위해
            currentRouteIds.add(routeDefinition.getId());
        });
    }
}

Nacos 콘솔에 라우팅을 추가합니다. 라우팅 파일명은 api-gateway-routes.json이고 타입은 json입니다.

구성 내용은 다음과 같습니다.

[
    {
        "id": "product-catalog-route",
        "predicates": [{
            "name": "Path",
            "args": {"_genkey_0":"/products/**", "_genkey_1":"/search/**"}
        }],
        "filters": [],
        "uri": "lb://product-catalog-service"
    },
    {
        "id": "shopping-cart-route",
        "predicates": [{
            "name": "Path",
            "args": {"_genkey_0":"/carts/**"}
        }],
        "filters": [],
        "uri": "lb://shopping-cart-service"
    },
    {
        "id": "user-management-route",
        "predicates": [{
            "name": "Path",
            "args": {"_genkey_0":"/users/**", "_genkey_1":"/addresses/**"}
        }],
        "filters": [],
        "uri": "lb://user-management-service"
    },
    {
        "id": "order-processing-route",
        "predicates": [{
            "name": "Path",
            "args": {"_genkey_0":"/orders/**"}
        }],
        "filters": [],
        "uri": "lb://order-processing-service"
    },
    {
        "id": "payment-service-route",
        "predicates": [{
            "name": "Path",
            "args": {"_genkey_0":"/pay-orders/**"}
        }],
        "filters": [],
        "uri": "lb://payment-service"
    }
]

태그: SpringCloud microservices nacos Gateway OpenFeign

6월 13일 22:10에 게시됨