Post

spring mircoservices-03

위 동영상을 베이스로 Maven 이 아니라 Gradle 사용
최신 스프링 버전 사용
비즈니스 로직 추가

msa02_struct

로컬 프로젝트 환경

  • MySQL 8.2.0
  • MongoDB
  • Spring Boot 3.1.5
  • Gradle 8.3
  • JDK 17 (temurin-17-jdk)

깃허브 주소

MySQL, MongoDB docker-compose 로 실행하기

docker-compose.yml

폴더 구조

1
2
3
4
5
├── mysql
│   ├── conf.d
│   │   └── my.cnf
│   └── initdb.d
│       └── create_db.sql

설정파일, 컨테이너 실행과 동시에 실행되어야 하는 sql 문을 작성한다.

  • create_db.sql
    1
    2
    
    create database db_orders;
    create database db_inventory;
    

    Spring Data JPA 를 사용하기에, 데이터베이스만 실행과 동시에 생성되도록 한다.

  • my.cnf
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    
    [client]
    default-character-set = utf8mb4
      
    [mysql]
    default-character-set = utf8mb4
      
    [mysqld]
    character-set-client-handshake = FALSE
    character-set-server           = utf8mb4
    collation-server               = utf8mb4_unicode_ci
    
  • .env
    1
    
    MYSQL_ROOT_PASSWORD=root
    

docker-compose.yml 파일이 존재하는 경로에서 다음 명령어로 실행시킨다.

1
2
3
4
5
6
7
8
9
10
11
# 실행
docker-compose up -d
# 종료
docker-compose down
# 컨테이너 접근
# 컨테이너 이름은 docker-compose.yml 파일에서 설정한 값
# 혹은 docker ps 명령어로 확인할 수 있는 컨테이너 ID
docker exec -it docker_container /bin/bash
# 컨테이너 접속 후 mysql 접근
mysql -u root -p
# docker-compose.yml 에 설정한 MYSQL_ROOT_PASSWORD, 여기선 'root' 입력

Gradle 을 이용한 멀티 모듈 프로젝트 생성

단일 프로젝트로 테스트를 다 마친 후에 작업을 시작했다.

수정 전

msa-gradle-multi-module drawio

MySQL 을 사용하는 곳은 core-module, MongoDB 를 사용하는 곳은 mongo-module, 나중을 위해 api 는 api-module 로 따로 분리했다.

수정 이후

spring-cloud-gateway, netflix-eureka 의존성을 추가하며 모듈 구조를 변경하였다.

gradle_multi_changed

서브모듈간 통신

모듈 간 통신을 위해서 스프링부트 6 에서 새로 등장한 HTTP Interface 를 사용한다.

상황: 주문을 하는 과정에서 재고가 있는지 확인 후 요청을 처리한다.

  • 수정 전

만약, 아이템 1000개를 주문하는 경우 1000개의 재고를 확인하려면 쿼리를 1000번 날리게 된다. 아이템의 이름을 리스트에 문자열로 저장하고, inventory-service 에서 다시 리스트로 재고여부를 담아서 보내게 수정한다. 쿼리는 1번만 수행되면 된다.

  • 수정 이후

Eureka 적용

netflix eureka 를 사용해서 서비스들을 등록시켜보자.

  • implementation('org.springframework.cloud:spring-cloud-starter-netflix-eureka-server') 의존성을 추가한다.
  • 유레카 서버 관련 설정을 추가한다.

[주의사항] 유레카 서버의 Health Check 는 30s 마다 발생하니, 서비스를 종료하고 다시 시작했다면
30s 기다린 후에 동작함을 유의하자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
eureka:
  instance:
    hostname: localhost
  client:
    fetch-registry: false 
    register-with-eureka: false # 유레카 서버 자신이 등록되는 것을 방지

server:
  port: 8761

spring:
  application:
    name: discovery-server

  • @EnableEurekaServer 어노테이션으로 유레카서버를 활성화시킨다.
1
2
3
4
5
6
7
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Import(EurekaServerMarkerConfiguration.class)
public @interface EnableEurekaServer {

}

서버가 아닌 클라이언트에는

  • org.springframework.cloud:spring-cloud-starter-netflix-eureka-client 의존성을 추가한다.
  • @EnableDiscoveryClient 어노테이션으로 활성화시킨다.
1
2
3
4
eureka:
  client:
    serviceUrl:
      defaultZone: http://localhost:8761/eureka/

나중 테스트를 고려하여, main 함수에 선언하지 말고 따로 config 패키지에 분리하여 선언한다.

1
2
3
@EnableDiscoveryClient
public class EurekaClientConfig {
}

로드밸런싱 설정

spring-starter-cloud 의존성을 추가하고, ReactorLoadBalancerExchangeFilterFunction 를 WebClient 에 등록하니 추가적인 설정을 해주지 않아도 로드밸런싱이 동작한다. 그냥 넘어가기에는 찝찝해서 잘 정리된 글 을 참고해 로드밸런싱에 대해서 정리해본다.

  • 별 다른 설정없이 관련 필터만 추가하면 동작한다.

image

이전 글을 검색해보면 Netflix Ribbon 이라는 단어를 종종 만났는데, 이는 HttpClient 중 RestTemplate 만 지원한다고 한다. 스프링 프레임워크 버전이 6.x 대로 업그레이드 되면서 Spring WebClient 를 지원하는 Spring Cloud LoadBalancer 를 사용한다. spring-starter-cloud 의존성을 추가하면 로드밸런싱을 담당하는 라이브러리가 같이 설치된다.
org.springframework.cloud.client.loadbalancer.reactive

관련 부분을 요약하면 다음과 같다.

  • Spring Cloud LoadBalancer 을 사용하면 (= spring-cloud 를 사용하는 환경이면)
    • ReactorLoadBalancerExchangeFilterFunction 의 구현체 Bean 이 default 로 활성화된다.
    • 이는 ExchangeFilterFunction 인터페이스를 상속하며
      • ExchangeFilterFunctionWebClient 요청을 인터셉트해서 제어한다.
    • ReactorLoadBalancerExchangeFilterFunction
      • LoadBalancer 로 부터 Property 에 명시한 인스턴스 정보 중 임의로 하나를 받아서 해당 인스턴스에 request 를 보낸다.
      • 해당 필터는 Retry 기능이 없기 때문에
      • spring.cloud.loadbalancer.retry.enabled = true 를 추가하면
        • RetryableLoadBalancerExchangeFilterFunction 이 활성화되어 retry 기능을 사용할 수 있다.
    • ReactiveLoadBalancer 은 로드밸런싱 정책을 담당한다.
      • ServiceInstanceListSupplier 로 부터 인스턴스 목록을 받아와서 로드밸런싱 정책에 부합하는 인스턴스를 반환한다.
      • 정책은
        • RoundRobin
        • Random
        • 두 가지가 존재하며, default 로 RoundRobin 방식이 사용된다.

공식문서 가 제공하는 예제는 다음과 같다. 쭉 읽어보니 느낀 점은 다음과 같다.

  • 설정하는 부분, 호출해서 사용하는 부분이 혼재되어 커플링 된 상태이다.
  • 포트가 랜덤하게 생성되는 상황에 대응할 수 없다.
    • 단순 예시기 때문에 포트가 하드코딩 되어있음
  • @LoadBalancerClient(name = “ANY-NAME”, configuration = SOMECLASS.class) 를 사용함

해당 글을 좀 더 꼼꼼하게 읽어보니, Eureka Server 를 사용하지 않는 경우에는 @LoadBalancerClient 를 사용한다. Eureka Server 를 사용한다면, 해당하는 부분은 유레카에서 맡게되는 듯 하다.

API Gateway 적용

org.springframework.cloud:spring-cloud-starter-gateway 의존성을 추가한다.

cloud-gateway

gateway 가 모든 api 호출을 받고 라우팅 해준다고 이해하자. 라우팅 과정에서 필터를 적용하여 헤더나 쿠키를 추가하는 등의 작업을 할 수 있다. 여기서 api 콜을 받을지 말지를 결정하기 때문에 인증 및 인가와 관련한 보안 설정은 게이트웨이에 적용한다.

  • msa 첫번째 와 다르게 Okta 가 아닌 오픈소스 인가서버 Keycloak 을 사용한다.

Java 로 설정

application.yml 로도 설정을 할 수 있지만, Bean 을 등록하는 방식으로도 동일하게 할 수 있다.

application.yml 은 띄어쓰기, 오타 등을 잡기가 어렵지만 Java 객체로 생성해서 등록하면 실수를 줄일 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
@Configuration
public class SpringCloudGatewayRouting {
    @Bean
    public RouteLocator configureRoute(RouteLocatorBuilder rlb) {
        return rlb.routes()
            .route("productService", r -> r.path("/api/v1/product").uri("lb://product-service"))
            .route("orderService", r -> r.path("/api/v1/order").uri("lb://order-service"))
            .route("inventoryService", r -> r.path("/api/v2/inventory").uri("lb://inventory-service"))
            .route("discoveryServer", r -> r.path("/eureka/web").filters(i -> i.setPath("/")).uri("http://eureka:password@localhost:8761/eureka"))
            .route("discoveryStaticResources", r -> r.path("/eureka/**").uri("http://eureka:password@localhost:8761/eureka"))
            .build();
    }
}

KeyCloak 설정

상세한 사용방법은 아래 영상을 참고한다. <iframe class=”embed-video youtube” loading=”lazy” src=”https://www.youtube.com/embed/vmEWywGzWbA” title=”YouTube video player” frameborder=”0” allow=”accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture” allowfullscreen </iframe>

직접 설치할 필요 없이, docker 로 실행시킨다. 포트: 8080spring-gateway 가 사용 중이다. 따라서 8181 로 포트를 변경한 뒤 실행한다.

1
docker run -p 8181:8080 -e KEYCLOAK_ADMIN=admin -e KEYCLOAK_ADMIN_PASSWORD=admin quay.io/keycloak/keycloak:22.0.5 start-dev

ID: admin, Password: admin 입력 후, realm, client 를 생성한다.

image

테스트하기 위한 최소한의 세팅만 거쳐서 postman 으로 테스트해본다.

image

아래 Generate Token 을 누르면 토큰이 생성되는 것을 확인할 수 있다.

image

물론, 이는 아이디, 패스워드 입력하는 과정을 생략하고 직접 token 을 생성하는 엔드포인트로 직접 접근해서 간단한 테스트만 해본 것이고 추가설정이 필요하다.

Spring Cloud Gateway 에 Security 설정

api-gateway 모듈에 spring security 설정

OAuth 2.0 는 외부 인가 서버 사용 시, issuer-uri 만 제공하면 모든 설정을 자동으로 불러온다.

OAuth2ResourceServerjwt 설정에 변화가 생겨 기록해둔다.

  • .oauth2ResourceServer(OAuth2ResourceServerSpec::jwt) 아래 코드는 @Deprecated 되었다.

(🤔 스프링 시큐리티는 현재 람다 DSL 로 모든 표현식을 바꾸도록 권장하고 있다.)

jwt 관련 설정을 하기 위해선 아래 객체를 제공해야한다.

1
2
3
4
public class JwtSpec {
    private ReactiveAuthenticationManager authenticationManager;
    private ReactiveJwtDecoder jwtDecoder;
    private Converter<Jwt, ? extends Mono<? extends AbstractAuthenticationToken>> jwtAuthenticationConverter;

하지만, 뭔가 이상하다. 분명 공식 문서에서는 spring.security.oauth2.resourceserver.jwt.issuer-uri 값을 설정하면 나머지 값은 자동으로 스프링이 요청을 보내서 가져온다고 되어있다.

기대하는 흐름은 다음과 같다.

  • issuer: http://localhost:8080/realms/spring-msa-realm 으로 설정한다.
    • http://localhost:8080/realms/spring-msa-realm/.well-known/openid-configuration 으로 HTTP Template 을 통해 요청을 보낸다.
      • token_endpoint
      • authorization_endpoint
      • jwks_uri
      • grant_types_supported
      • 등 많은 정보를 가져온다.
    • 따라서 SecurityWebFilterChain 에 등록하지 않고, application.yml 설정만으로 많은 설정이 간소화된다.

실제로 디버깅을 통해 다음과 같은 사실을 알 수 있었다.

.oauth2ResourceServer(i -> i.jwt(Customizer.withDefaults())) 설정을 한 경우

SecurityWebFilterChain 에 등록되기 직전 ServerHttpSecurity 객체를 살펴보면, 다음과 같다.

image

여러 개의 필터가 등록되고, 그 중에 AuthenticationWebFilter 에서 jwt 관련 정보를 확인할 수 있다.

image

1
2
3
4
@Import({ ServerHttpSecurityConfiguration.class, WebFluxSecurityConfiguration.class,
		ReactiveOAuth2ClientImportSelector.class })
public @interface EnableWebFluxSecurity {
}
  • ServerHttpSecurityConfiguration.class
  • WebFluxSecurityConfiguration.class
  • ReactiveOAuth2ClientImportSelector.class

@EnableWebFluxSecurity 어노테이션을 통해 위와 같은 설정 클래스를 불러오게 된다.

그럼 아래의 authenticationManagerResolver 메서드가 실행되며, 정보를 등록한다. 아래 정보를 보면 받아온 jwt 정보 중 claim 에 관해 파싱하는 claimTypeConverters 를 확인할 수 있다.

만약, jwt 스펙에 정의된 claim 에 더해서 추가적으로 부여했다면 해당 로직을 추가한 JwtDecoder 를 생성해 빈으로 등록해줘야 한다. (기본적으로 구현체는 Nimbus 사용)

image

jwtProcessor 를 확인해보면

  • jwkSetUri 값에 application.yml 에서 읽어 온 값을 주입한다.

그러니, .yml 파일에 인가서버에 대한 값이 등록되는데도 401 에러를 내뿜는게 이해가 되지 않는다. image

추가로, JwtIssuserValidatoriss 가 기본으로 등록되어 있다. 도커 이미지로 keycloak 이라는 컨테이너가 실행 중일 때, http://keycloak:8080/... 이렇게 컨테이너 이름으로 요청을 보내면 당연히 에러가 난다. 따라서 /etc/hosts127.0.0.1keycloak 을 추가해야 한다.

image

1
2
3
4
5
6
7
8
9
10
11
##
# Host Database
#
# localhost is used to configure the loopback interface
# when the system is booting.  Do not change this entry.
##
127.0.0.1	localhost
## ONLY FOR TEST PURPOSE, REMOVE AFTER TEST
127.0.0.1       keycloak
255.255.255.255	broadcasthost
::1             localhost

맘에 걸리는 점은 arg$3MonoCachedTime

  • jwtSetURL
    • result: "..."

캐싱 처리가 된다는 점인데, 혹시 SecurityWebFilterChain 이 빈으로 등록되는 과정에서 캐싱된 값이 사라지는 건 아닐까 하고 모든 빈 생성 과정을 추적해봤지만 그렇지는 않았다.

그래서 결론적으로는, 분명 설정 파일에 값을 주입했지만, jwt 설정에 또 다시 jwkSetUri 값을 넣어줘야만 동작했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
  @Value("${security.jwks_uri}")
  private String JWKS_URL;

  @Bean
  public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity httpSecurity) {
      httpSecurity
          .csrf(ServerHttpSecurity.CsrfSpec::disable)
          .authorizeExchange(
              exchange -> exchange
                  // 유레카 서버에 대한 접근은 항상 허용
                  .pathMatchers("/eureka/**").permitAll()
                  .anyExchange().authenticated()
          )
          .oauth2ResourceServer(i -> i.jwt(j -> j.jwkSetUri(JWKS_URL)))
      ;
      return httpSecurity.build();
  }

image

빈으로 설정

application.ymlroutes 관련 설정을 하는 것도 물론 좋지만, 오타 등 휴먼 에러의 영향이 있을거라고 생각한다. 따라서 동일한 기능을 하지만 자바 객체를 생성해 빈으로 등록할 수 있는 방법으로 변경했다. 테스트, 배포, 운영 등 설정 파일이 달라질 것이기 때문에, @Profile 어노테이션을 추가했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Configuration
@Profile("default")
public class SpringCloudGatewayRouting {

  @Bean
  public RouteLocator configureRoute(RouteLocatorBuilder rlb) {
    return rlb.routes()
      .route("productService", r -> r.path("/api/v1/product").uri("lb://product-service"))
      .route("orderService", r -> r.path("/api/v1/order").uri("lb://order-service"))
      .route("inventoryService", r -> r.path("/api/v2/inventory").uri("lb://inventory-service"))
      .route("discoveryServer", r -> r.path("/eureka/web").filters(i -> i.setPath("/")).uri("http://eureka:password@localhost:8761/eureka"))
      .route("discoveryStaticResources", r -> r.path("/eureka/**").uri("http://eureka:password@localhost:8761/eureka"))
      .build();
  }
}

MongoDB

NoSQL 에 대한 개념은 숙지했고, 실전에서 사용해보자.

msa-mongo-explain drawio

docker 로 설정

mongo-express 설정을 추가하니, 자꾸 오류가 나서 해당 부분은 지웠다.
Studio 3T 라고 하는 GUI 프로그램을 사용한다. (개인은 무료로 사용할 수 있다)

터미널에서 작업하는 방법은 여기 에서 정리중이다. CRUD 작업은 Spring Data JPA 와 동일하게 사용할 수 있다.

테스트를 위해서 앱 실행과 동시에 몇 가지 데이터를 집어넣었다.

보안적용

테스트 목적으로 간단한 스프링 시큐리티를 적용한다.

  • Netflix-Eureka-Server 앱에 아이디 eureka, 비밀번호 password 인 유저를 InMemoryUser 로 추가한다.
  • Netflix-Eureka-Client 앱들에 다음과 유레카 서버 url 을 수정한다.
    • defaultZone: http://eureka:password@localhost:8761/eureka

최종적으로, 서비스들이 유레카 서버에 잘 등록되었다. image

CircuitBreaker

서비스의 health check 을 위한 디자인 패턴

여러 서비스가 배포된 msa 구조에서, 특정 서비스 인스턴스에 문제가 생긴 경우를 감지하여 클라이언트에게 오류 메시지를 전달한다.

  • 폴백 로직을 적용한다.
  • Netflix Hysterix 는 2018년 이후 개발이 중단되어
  • Resilience4J 를 사용한다.

msa-CircuitBreaker drawio

msa-CircuitBreaker drawio2

circuit

앱에 적용하기

  • implementation('org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j')

추가 설정

TimeLimiter, Retry 옵션을 통해서 연결 재시도 여부를 설정할 수 있다.

Distributed Tracing

Request 의 전체 라이프 사이클을 추적하는 디자인 패턴

스프링부트 2.x.x 에서 쓰이던 Sleuth 는 3.x.x 버전에선 사용이 불가능하기에, 아래 의존성을 추가한다.

1
2
3
4
5
6
// -- Spring Actuator --
implementation('org.springframework.boot:spring-boot-starter-actuator')
// -- Micrometer --
implementation ('io.micrometer:micrometer-tracing-bridge-brave')
// -- Zipkin --
implementation ('io.zipkin.reporter2:zipkin-reporter-brave')

Zipkin 은 굳이 로컬에서 설치하지 않고 도커 이미지 가져와서 실행시키면 된다.

1
docker run -d -p 9411:9411 openzipkin/zipkin

그리고, gateway 의 설정 파일에 다음 설정을 추가한다. (127.0.0.1 작업 기준)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
spring:
  application:
    name: api-gateway

  zipkin:
    baseUrl: "http://127.0.0.1:9411"

management:
  tracing:
    sampling:
      probability: 1.0
    propagation:
      consume: b3
      produce: b3_multi
  zipkin:
    tracing:
      endpoint: "http://127.0.0.1:9411/api/v2/spans"

당연하게도 모든 서비스 인스턴스의 로그를 추적해야하므로, 다른 서비스에 모두 동일한 설정을 등록해야한다. 서비스가 많아지면 관리가 힘들어지는 것이 분명하기에 중앙에서 설정만을 따로 분리해서 관리하는 것이 효율적일 것이다.

Spring Cloud Config 도입

  • implementation('org.springframework.cloud:spring-cloud-config-server')

관련 글 을 참고하여 그대로 따라하면 된다. 중요한 포인트만 기록해둔다.

  • 더 이상 RSA-SHA1 방식을 지원하지 않는다.
    • ecdsa, ed25519, RSA-SHA2 등 다른 키를 새롭게 생성해야한다.
  • baseDir 설정
    • CentOS 를 사용하는 경우, 클론 받은 깃허브 파일이 변하지 않아 불필요한 파일이라고 여겨 Cron 작업으로 청소한다.

위 글과 한가지 다르게 한 것은, hostKey 를 사용하지 않았다는 것이다. 깃헙에 SSH keys 를 등록하면 자동으로 생성되며 Config Server 에서 요청 시 이를 찾는다고 한다.

맥북으로 작업하는 기준, ssh-keygen -m PEM -t ecdsa -b 256 -C "YOUR_EMAIL@gmail.com" 로 공개키, 비밀키를 생성한다. 그리고 깃헙 Settings 에 들어가서 SSH keys 에 공개키를 등록한다.

image

그럼 나머지 비밀키는 config server 에 등록하면 된다. 그리고 이 설정을 가져다 쓸 서비스에서는 config.import 를 통해서 설정 파일에 등록한 해주면 끝이다.

cloud client 들은 아래 설정을 그대로 가져가 사용하게 된다.

zipkin 에 traceId 를 통해서 request, response 상태를 확인할 수 있다.

위 2가지 자료를 통해서 Open Telemetry 등과 연동해 span 을 개발자가 좀 더 세밀하게 컨트롤 할 수 있다.

에러발생

멀티 모듈 프로젝트를 만들고 가장 많이 봤던 에러는 다음과 같다. 컴파일 시점에 동일한 클래스가 Bean 으로 중복으로 등록되려고 하니, 앱이 실행되지 않는다.

1차로 검색한 해결책

  • Bean 으로 등록하려는 클래스 이름 변경
  • spring.main.allow-bean-definition-overriding=true 설정
1
2
3
4
5
6
7
8
9
10
11
***************************
APPLICATION FAILED TO START
***************************

Description:

The bean 'conversionServicePostProcessor', defined in class path resource [org/springframework/security/config/annotation/web/configuration/WebSecurityConfiguration.class], could not be registered. A bean with that name has already been defined in class path resource [org/springframework/security/config/annotation/web/reactive/WebFluxSecurityConfiguration.class] and overriding is disabled.

Action:

Consider renaming one of the beans or enabling overriding by setting spring.main.allow-bean-definition-overriding=true

1차 검색은 해결이 되지 않았다. 혹시 몰라 Invalidate Cache 했는데 뭘 잘못 눌렀는지 프로젝트 로드가 안된다. 다시 처음부터 만들고, 검색 키워드를 수정해서 검색해서 해결책을 찾았다.

1
2
3
spring:
  main:
    web-application-type: reactive

에러 로그를 보니 web, webflux 가 충돌이 난다는 로그가 찍히는데, dependencies 를 봐도 그렇지 않으니 귀신이 곡할 노릇이다.

image

어찌됐건 여기까지 유레카에 모든 서비스들이 잘 등록됨을 확인하였다. image

Kafka

Event Driven Architecture

일단, confluent 에서 제공하는 docker compose 파일을 복사하는 것 부터 시작한다. 링크는 여기

RabbitMQ 와 유사한 메시지 브로커 시스템이다. Queue 에 요청을 저장했다가, 분배하는 식인데 혹시 운영 중인 서버에 문제가 생겨도 그 사이 요청을 큐에 저장했다가 알맞은 서비스 인스턴스에 보낼 수 있다.

파일을 복사하다보니 docker-compose.yml 의 version 이 뭘까 궁금해졌다. docker 버전이 올라감에 따라, 점점 더 상세한 파라미터를 적용시킬 수 있다고 한다. 링크는 여기 가장 최근은 3.8이며, 1.0 은 더 이상 지원하지 않는다.

zookeeper 를 먼저 실행시키고, broker 를 실행시킨다. 다만, 용량이 도커 이미지 치고 큰 편이고 시간도 오래 걸린다.

설정

스프링 앱 설정은 그렇게 할 게 없는데, 카프카를 도커 이미지로 실행시키니 문제가 발생한다. 공식 사이트를 자세히 살펴보니 Zookeeper 에서 KRaft 로 옮겨갔다고 한다.

image

카프카 관련 메타데이터 보관소인 zookeeper 의 힘을 빌리는 것이 아니라, 카프카 자체에 포함시킨 것이 가장 큰 차이라고 한다. 그러니 더이상 zookeeper 는 필요 없다는 뜻. (대신 이미지 용량이 엄청 증가했다.)

약간의 시행착오 끝에 연결에 성공할 수 있었다. 하지만 테스트 환경 치곤 너무 무거워서 기존 zooper 를 사용했다.

image

Confluent 에서 제공하는 서비스를 다 설치하면 localhost:8082 에서 카프카 대시보드를 사용할 수 있다. 테스트용으로 생성한 notifiacationId 가 잘 생성되었다.

사용자가 order-service 에 요청을 보내면, inventory-service 와 통신한다. 이 때, (새롭게 추가한) notifiacation-service 에 알림을 보낸다.

  • 알림을 주고, 받기 위해서 serializer 관련 설정을 해야한다.
  • 또한, 전달되는 java 객체의 전체 경로
    • com.example.com.example.orderservice.event.OrderPlacedEvent 를 명시해야 한다.
    • 앞에 원하는 식별자를 붙여서 아래와 같이 설정한다.
      • spring.json.type.mapping: event:com.example.orderservice.event.OrderPlacedEvent

메세지를 받는 consumer 쪽에서, @KafkaListener 어노테이션을 통해 특정 topic 을 받을 수 있고 이후 필요한 로직을 추가하면 된다. 상품을 주문하는 경우이므로 이메일 알림, 문자 알림, 카카오톡 알림 등을 추가하면 된다.

  • 주문한 사람은 api-gateway 를 거쳐서 주문을 하게 되고, 인증을 받은 사용자이다.
  • 따라서
    • 주문한 사람의 이메일 혹은 전화번호
    • 주문번호
    • 결제금액
  • 등을 OrderPlacedEvent 객체에 넣어서 보내면 된다. [숙제]
1
2
3
4
5
6
@KafkaListener(topics = "notificationTopic")
public void handleNotification(OrderPlacedEvent orderPlacedEvent) {
    // [숙제1] 이메일 발송
    // [숙제2] 카카오 알림톡 발송 (이건 유료)
    log.info("Received Notification for Order [{}],", orderPlacedEvent.getOrderNumber());
}

Dockerize

모든 서비스를 docker 이미지로 바꾼다.

빌드 된 이미지 용량을 줄이기 위한 단계적 빌드

1
2
3
REPOSITORY                                  TAG           IMAGE ID       CREATED          SIZE
apigateway-layered                          latest        707998567d86   2 minutes ago    343MB
apigateway-dockerfile                       latest        adfbf3cf5588   17 minutes ago   584MB

Jib 라이브러리 도입

도커 설치 없이 도커 이미지를 만들 수 있다고?

image

도커 이미지가 많아질 수록 관리가 중요한데, 반복적인 과정을 많이 줄여주는 라이브러리이다.

다만, docker hub 에 이미지를 보낼 때는 인증 과정을 반드시 거쳐야 한다.

가장 간단한 방법

  • docker hub 에서 access token 을 발급받고,
  • 터미널에서 docker login -u ${도커허브 로그인할 때 쓰는 아이디}
  • 로, 패스워드는 access token 을 복사, 붙여넣기 해주면 된다.

그리고 (맥 os 기준) cd ~/.docker 이후 cat config.json 로 관련 정보를 확인할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
cat config.json
{
	"auths": {
		"https://index.docker.io/v1/": {}
	},
	"credsStore": "desktop",
	"currentContext": "desktop-linux",
	"plugins": {
		"-x-cli-hints": {
			"enabled": "true"
		}
	}
}%

아이디, 비밀번호를 build.gradle 에 입력하는 방법

  • 도커 인증 관련한 공식 문서
  • 를 읽어보면, docker-crendential-helpers 를 설치해야 한다.
  • 도커 인증 정보를 내 로컬 컴퓨터에 있는 자격 증명 관리자 와 맵핑한 후, 내 로컬 정보를 사용한다고 이해했다.
  • 깃허브 에서 자세한 사용 방법을 안내한다.
  • 하지만, 다른 블로그 글에서는 config.json 항목에 내 정보가 base64 인코딩 되어 있는 채로 그대로 노출이 된 것을 봤는데
  • 맥이 os 를 업데이트 하면서 보안 정책이 바뀐건지, 해당 파일에서 그 정보를 직접적으로 확인할 수는 없다.

./gradlew jib

현재 msa 구조에 등록된 모듈은 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
rootProject.name = 'msa-parent'

// Modules
include('core-module')
include('mysql-module')
include('mongo-module')

// Services
include('order-service')
include('discovery-server')
include('api-gateway')
include('config-server')
include('inventory-service')
include('product-service')
include('notification-service')

이 중에 테스트로 api-gateway 정보만을 등록했다. 이 때, 멀티 모듈 프로젝트에 관한 jib 공식 문서 를 보면, 최상위 폴더에서 ./gradlew clean build jib 시 에러가 난다.

다른 모듈에는 jib 관련 정보를 선언하지 않았기 때문이다.

다음은 ./gradlew clean build :api-gateway:jib 명령어를 실행한 결과이다.

image

공식 문서에 따라서, 다른 모듈들이 의존하는 모듈에는 다음과 같은 설정을 추가했다. 루트에 있는 build.gradle 파일이다. 멀티 모듈 프로젝트에서, 다른 모듈이 의존하는 상위 모듈은 jar 파일이 생성되어 서브 모듈에 등록된다.

1
2
3
4
5
6
7
8
9
10
project(':core-module') {
	bootJar {
		enabled = false
	}
	jar {
		enabled = true
		preserveFileTimestamps = false
		reproducibleFileOrder = true
	}
}

여기서 2가지 문제가 발생했다.

  • ./gradlew jib 명령어는 .jar 파일이 존재하는 프로젝트만 사용할 수 있다.
  • 루트 프로젝트에서 ./gradlew 명령어를 실행하면, 본인 포함 하위 모든 프로젝트에 적용되는데
  • 루트 프로젝트, core-module, mysql-module 등은 실행파일이 존재하지 않기 때문에 루트 프로젝트에서 해당 명령어 사용이 불가능하다.
1
2
3
4
5
./gradlew clean build :api-gateway:jib \
:order-service:jib \
:product-service:jib \
:inventory-service:jib \
:notification-service:jib

으로 특정 모듈을 구체적으로 지정해서 해야한다. 혹은 해당 경로로 이동해서 ./gradlew jib 하면 된다. 하지만, 멀티 모듈 프로젝트를 구성함으로서 루트 프로젝트에서 하나의 명령어로 빌드부터 이미지 푸시까지 이루어지게 해야 의미가 있다고 생각한다.

따라서, 사용하지는 않지만 에러 해결 용도로 src/main/java/com/example/ 폴더를 생성하고, 그 안에 main 클래스를 생성한다. 🤔 완벽하지는 않지만 문제 해결

다음 문제는, 인텔리제이에서 힙 메모리가 부족하다고 프로그램이 종료되어 버린다. Heap dump 보고서도 친절하게 알려줘서 로그를 살펴보니 2.51gb 가 사용되었다. (인텔리제이 디폴트는 2048mb 로 설정되어 있다.)

힙 메모리를 늘려줘야 버벅거림이 사라질 것 같다. 프로젝트 별로 VM 옵션을 줘서 최대, 최소 메모리를 설정할 수 있지만 현재 MSA 연습으로 프로젝트가 너무 많기 때문에 인텔리제이 자체 옵션을 변경한다.

Help >> Edit Custom VM Options 에서 다양한 옵션을 줄 수 있는데, 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 출처: https://inpa.tistory.com/entry/IntelliJ-%F0%9F%92%BD-JVM-%ED%9E%99-%EB%A9%94%EB%AA%A8%EB%A6%AC-%EC%82%AC%EC%9D%B4%EC%A6%88-%EB%B3%80%EA%B2%BD%ED%95%98%EA%B8%B0
-Xms2g # 초기 Heap 사이즈
-Xmx2g # 최대 Heap 사이즈
-XX:ReservedCodeCacheSize=256m # 코드 캐쉬 사이즈 Heap 메모리 사이즈와 공유하지 않는다.
-XX:+UseG1GC # G1GC 가비지 컬랙션을 사용한다.
-XX:MetaspaceSize=768m # Java8 이상의 Permanent 영역 사이즈
-XX:MaxMetaspaceSize=768m # Java8 이상의 최대 Permanent 영역 사이즈
-XX:+UseCompressedOops # 64비트 JVM에서 압축 참조를 사용 가능
-XX:MaxGCPauseMillis=200 # GC로 인한 최대 중단시간을 명시
-XX:ParallelGCThreads=4 # 다중 GC를 위해 사용되어질 GC 스레드의 수
-XX:ConcGCThreads=1 # 동시적 CMS 단계가 동작할때에 사용할 쓰레드 개수를 정의
-XX:+HeapDumpOnOutOfMemoryError # OutOfMemoryError 발생 시 자동으로 heap dump를 생성
-XX:ErrorFile=$USER_HOME/java_error_in_idea_%p.log # 에러파일 생성 위치
-XX:HeapDumpPath=$USER_HOME/java_error_in_idea.hprof # HeapDump 파일 생성 위치
-ea # assertions을 사용한다.
-server # 자바 HotSpot Server VM
-Dsun.io.useCanonCaches=false # Java의 정규화 캐시 사용여부
-Djava.net.preferIPv4Stack=true # IP4를 사용여부
-Dfile.encoding=UTF-8 # Java 소스파일 인코딩

멀티 모듈 프로젝트 관련 추가

멀티 모듈 프로젝트에 관한 블로그 글 을 읽어보니, 한 곳에서 작성한 엔티티나 서비스를 가져다가 사용할 수도 있다는 것을 알게 되었다.

@SpringBootApplication 이 스캔하는 패키지 범위는 해당 파일이 위치한 하위 경로가 디폴트 값이다. 따라서 다른 값을 읽어오기 위해서는 스캔 대상이 될 베이스 패키지를 지정해줘야 사용할 수 있다. 이 과정에서 휴먼 에러가 날 수 있고, 번거롭다는 것이다.

artifactId 를 통일해서 생성하면, 서로 다른 패키지에 해당하는 값을 불러올 수 있다.

간단하게 실습한 깃허브 주소를 남긴다.

잘 되는 것을 확인했으니 리팩토링 계획을 남겨본다.

flowchart TB
  subgraph ENTITY
  COMMON_ENTITY --> SPECIFIC_ENTITY
  end

  ENTITY --> SERVICE

  subgraph SERVICE
  COMMON_INTERFACE --> SPECIFIC_INTERFACE
  COMMON_INTERFACE --> COMMON_SERVICE
  end

  subgraph EXCEPTION
  GLOBAL_ERROR --> BUSINESS_ERROR
  end

  EXCEPTION --> HANDLER

  subgraph HANDLER
  COMMON_HANDLER --> BUSINESS_HANDLER
  end

  HANDLER --> BUSINESS_LOGIC
  SERVICE --> EXCEPTION
  

  subgraph BUSINESS_LOGIC
  MANY_SERVICES
  end

조금 과한 듯 하지만, 관리해야할 서비스가 점점 증가하는 상황을 가정한다면 글로벌하게 사용되는 DTO, 에러, 인터페이스 등에 수정사항이 생겨서 바꾸는 일이 무척 어려울 것이라고 예상된다. 계층 관계를 나눠서 관리한다면 장애 대응이나 갑작스러운 수정에 좀 더 유연하게 대응하지 않을까 생각해본다.

다만, 마이크로 아키텍쳐가 아니고 모노리틱 아키텍쳐라면 static 으로 관리할 수 있으니 신경쓰지 않아도 된다.

docker-compose.yml 수정

PostgreSQL 정보를 추가하고, Keycloak realm 정보도 포함시킨다.

Keycloak realm 정보 가져오기

컨테이너가 실행중이여야 한다. 컨테이너 이름이 keycloak 이라면, 아래 커맨드로 도커 컨테이너에 접근할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 1. 도커 컨테이너 접근
docker exec -it keycloak bash
# 2. 폴더 이동
cd /opt/keycloak
# 3. 특정 폴더에 realm 정보 export 하기
/opt/keycloak/bin/kc.sh export --dir /opt/keycloak/data/import --realm ${생성된 realm 이름 적기}
# 4. /opt/keycloak/data/import 폴더로 이동해서 생성된 파일 확인
ls -al
-rw-r--r-- 1 keycloak root 68046 Nov  7 02:20 spring-msa-realm-realm.json
-rw-r--r-- 1 keycloak root   502 Nov  7 02:20 spring-msa-realm-users-0.json 
# 5. 내 로컬 pc 에 파일 복사하기
docker cp ${keycloak 컨테이너 아이디}:/opt/keycloak/data/import/${생성된 파일이름, 확장자 포함} ${파일 저장할 내 로컬 절대 경로}
# 예시
docker cp keycloak:/opt/keycloak/data/import/spring-msa-realm-realm.json /Users/jeongin/Docuement/keycloak_data
# 위 명령어 실행 시, /Users/jeongin/Docuement/keycloak_data 폴더에 spring-msa-realm-realm.json 파일이 복사된다.
# 이제와서 확인해보니 realm 생성 시, 자동으로 뒤에 '-realm' 명칭이 붙는다. 굳이 spring-msa-realm 이라고 생성할 필요가 없었다.

또한, keycloak 에 저장되는 정보를 mysql 에 저장하기 위한 설정을 해준다.

db 를 지정하면 데이터베이스도 초기화해주어야 한다. 방법이 여러가지 있는데, 가장 쉬운 방법은 src/resources/data.sql 을 생성하는 것이다.

1
CREATE DATABASE IF NOT EXISTS keycloak;

image

위 옵션을 보면, h2 처럼 embedded DB 는 자동으로 sql 을 실행하지만, 아닌 경우는 따로 옵션을 지정해주도록 스프링 2.5.x 버전부터 변경되었다고 한다.

혹은 초반에 설정했던 것 처럼,

위와 같이 폴더 하나를 만들어서 통합적으로 관리할 수도 있다.

m1 mac 위한 jib 설정

jib 는 기본적으로 linux 서버로의 배포를 가정하기 때문에, os 를 linux/amd64 가 default 값이다. 하지만 m1 맥북은 현재 linux/arm64/v8 에서 구동되기 때문에, jib 에 platform 관련 설정을 추가한다.

테스트

현재 프로젝트 구조는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
.
├── api-gateway
├── config-server
├── core-module
├── discovery-server
├── docker-compose.yml
├── inventory-service
├── mongo-module
├── mysql
├── mysql-module
├── order-service
├── product-service
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
rootProject.name = 'msa-parent'

// Modules
include('core-module')
include('mysql-module')
include('mongo-module')

// Services
include('order-service')
include('discovery-server')
include('api-gateway')
include('config-server')
include('inventory-service')
include('product-service')
include('notification-service')

docker hub 에 올라간 이미지를, docker-compose.yml 을 사용해서 빌드한다. (전체 설정은 깃허브주소 참고)

흐름을 정리하자면,

  1. 로컬 환경에서 모든 테스트 완료
    1. 배포 환경 고려하여, application-docker.yml 생성 후
    2. 도커 컨테이너 이름을 넣어서 url 변경
  2. 도커 허브 (혹은 aws, gcp) 에 배포
  3. docker-compose.yml 작성
    1. 환경변수, 설정값, 포트번호 등 지정
    2. depends_on 옵션으로 실행 우선순위 지정
    3. 데이터를 저장(persist)해야 하는 DB 는 volume 지정

문제 발생

테스트를 위해, docker-compose.yml 실행 확인 후, 도커 허브에서 직접 이미지를 다운 받아서 아래와 같은 작업을 했다.

1
2
3
docker run -p 8761:8761 valorjj/msa2-discovery-server:latest -d -it \
-e "SPRING_PROFILES_ACTIVE=docker" \
--name discovery-server \

실행하니 profile 옵션도 안먹히고, 포트 포워딩도 안된다.

문제해결

충격적이게도 docker 명령어는 순서대로 실행이 된다. 실행하고자 하는 이미지 이름을 가장 마지막에 집어넣어야 한다!!

1
2
3
4
docker run -p 8761:8761 -d -it \
-e "SPRING_PROFILES_ACTIVE=docker" \
--name discovery-server \
valorjj/msa2-discovery-server

위와 같이하니 잘 실행된다. 설명을 제대로 안 읽어서 쓸데없는 곳에서 원인을 찾느라 1시간을 낭비했다..

host 에 keycloak 추가

로컬 환경이 아닌 도커 컨테이너로 돌아가는 환경에서 토큰을 발급받을 때 다음 엔드 포인트 사용 시 에러가 발생한다.

http://localhost:8181/realms/spring-msa-realm/protocol/openid-connect/token

현재 Keycloak 은 keycloak 이라는 컨테이너 이름으로 포트 8080 에서 돌아가고 있으니

http://keycloak:8080/realms/spring-msa-realm/protocol/openid-connect/token

하지만 여전히, 우리는 로컬 환경에서 테스트 하고 있다. 따라서 한가지 설정을 더 해주어야 하는데

mac 기준 /private/etc/hosts 에 위치한 파일을 수정해줘야 한다. 윈도우 기준 C:\Windows\System32\drivers\etc\hosts 에 위치한다.

1
2
3
4
5
6
7
8
9
##
# Host Database
#
# localhost is used to configure the loopback interface
# when the system is booting.  Do not change this entry.
##
127.0.0.1       localhost
255.255.255.255 broadcasthost
::1             localhost

파일을 열어보면 위와 같다. 여기에 아래와 같이 한줄 추가해준다.

1
2
3
4
5
127.0.0.1       localhost
# DNS 에러를 피하기 위해 keycloak 127.0.0.1 에 keycloak 을 추가한다.
127.0.0.1       keycloak
255.255.255.255 broadcasthost
::1             localhost

Grafana 도입

마무리

zipkin 과 유사하지만, 대시보드를 구성할 수 있는 grafana 를 도입해본다. 세트로 prometheus 와 함께 사용한다.

flowchart TD
  subgraph S
  Service-Instances
  end
  subgraph P
  PROMETHEUS
  end
  subgraph G
  Grafana
  end
  S --> P
  P --> G

비즈니스 로직이 돌아가는 서비스에서 발생하는 로그를 프로메테우스가 취합하고, 이를 그라파나로 보낸다.

http://prometheus:9000 으로 그라파나에 프로메테우스 서비스를 포함시키고,

위의 json 파일을 대시보드 생성을 위해 사용한다. (출처는 여기)

zipkin 을 통해서는 개별 서비스의 traceId 를 통해서, http request, response 의 전체적인 그림을 그릴 수 있다.

prometheus, grafana 조합으로는 msa 에 등록된 서비스 전체 상태를 점검할 수 있고, 무엇보다 (설정이 까다롭지만) 대시보드를 지원함으로서 시각적으로 한눈에 상태를 파악할 수 있다.


후기

  • 도커에 대한 이해
    • 마이크로 서비스 구조로 생성한 모든 서비스를 도커 이미지로 만드는 과정에서
    • 도커 작동 원리, 도커 네트워크, 컨테이너와 이미지 관리 등을 배웠다.
  • 멀티 모듈에 관한 이해
    • 많은 시행착오를 겪으며 멀티 모듈의 필요성과 구현 방법을 배웠다.
    • gradle 의 groovy 언어 구조와 사용법을 배웠다.
    • 엔티티, DTO, 공통 인터페이스 등을 여러 모듈에서 공통적으로 사용할 수 있다.
  • 주석의 중요성
    • 기존 설정을 변경해야하는 경우, 주석이 없다면 내 과거의 의도를 한번에 알아내기가 어려웠다.
    • 좀 길어지더라도, 읽는 사람이 오해가 없이 핵심을 파악할 수 있도록 주석을 작성하는 습관이 필요하다.
  • 카프카
    • RabbitMQ 보다 더 고가용성을 위한 카프카는 스프링부트 환경에서 사용이 쉬워서 놀라웠다.
      • 어노테이션 기반인 스프링 부트가 자체적으로 대부분의 설정 값을 자동으로 등록하며
      • 메시지 템플릿 객체를 제공하기 때문에 특정 엔드포인트로 메시지를 주고 받는 것이 어렵지 않았다.

숙제

  • 카프카를 이용해 이메일 발송
  • artifactId 통일해서 멀티 모듈 프로젝트 리팩토링
  • 전역 에러 처리 객체 생성
  • product, order 서비스 비즈니스 로직 다듬기
  • QueryDSL 적용
  • MongoDB 활용을 위한 product 서비스에 각종 CRUD 작업 추가

참고

This post is licensed under CC BY 4.0 by the author.