이 연재글은 Spring Cloud 알아보기의 1번째 글입니다.

과거에 분산시스템이란 개인이 접근할 수 없는 전문적인 분야로, 복잡하고 정교한 시스템으로 구성되어 있어 개인 개발자 혼자 구축하거나 운영할 수 없는 영역이었습니다. 그러나 Spring Cloud가 나오면서 가장 보편적인 분산 시스템 패턴에 대하여 간단하고 접근 가능한 프로그래밍 모델을 제공하게 되었고. 개발자가 복원력 있고 안정적인 분산 시스템을 구축할 수 있게 되었습니다. Spring Cloud는 Spring Boot 위에 구축되므로 기존의 Boot 개발자가 손쉽게 접근하여 시작하고 생산성을 높일 수 있게 되었습니다.

각설하고, Spring Cloud는 다음과 같은 Feature로 구성되어 있습니다.

Configuration Server
시스템의 환경 설정을 중앙화 하고 실시간으로 설정을 변경하고 전파하는 기능을 제공합니다..
API Gateway
다양한 서비스를 사용하는 클라이언트에게 단일 경로의 api 엔드포인트를 제공하여 백단 서비스를 통합할 수 있도록 지원합니다.
Service Discovery
가용한 서비스 인스턴스들의 위치 정보들을 동적으로 등록하고 클라이언트에게 제공하여 유연한 시스템 관리 및 확장을 지원 합니다.
Load Balancing
클라이언트 로드밸런싱을 지원합니다.
Circuit Breakers
Gateway에 적용하여 서비스 장애시 계단식 오류로부터 서비스를 보호하고 대체 응답을 제공할 수 있는 기능을 제공합니다.

Configuration Server

분산 시스템에서 사용되는 환경설정을 Configuration서버에서 관리할 수 있도록 환경을 제공합니다. 각각의 시스템에 중복으로 존재했던 설정들을 Configuraion 서버에서 일원화 하여 체계적으로 관리 할수 있으며 무중단으로 설정 변경 가능하여 기존처럼 설정이 변경될때마다 연관된 시스템의 배포가 필요 없게 됩니다. 또한 DB 접속정보나 패스워드같은 민감한 정보들을 서비스 프로젝트에 노출시킬 필요가 없어 보안에 대한 부분도 개선할 수 있습니다.

실습에서는 Configuration서버를 구축하고 2개의 SpringBoot Instance가 서버의 설정값을 사용하도록 구축할 것입니다. Configuration 서버에서 환경 설정 파일을 관리하는 방법은 로컬파일, Git, Database등이 있는데 실습에서는 로컬파일과 Git으로 관리하는 방법을 사용해 보겠습니다.

신규 프로젝트를 초기 구성하는데 어려움이 있다면 Spring initializr를 통해 간편하게 프로젝트를 생성할 수 있습니다. 다음 포스팅을 참고해 주세요.
>> Spring initializr로 Spring 프로젝트 생성하기

build.gradle

라이브러리는 org.springframework.cloud:spring-cloud-config-server 를 사용합니다.

plugins {
    id 'org.springframework.boot' version '2.1.4.RELEASE'
    id 'java'
}

apply plugin: 'io.spring.dependency-management'

group = 'com.spring'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '1.8'

repositories {
    mavenCentral()
}

ext {
    set('springCloudVersion', 'Greenwich.SR1')
}

dependencies {
    implementation 'org.springframework.cloud:spring-cloud-config-server'
}

dependencyManagement {
    imports {
        mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
    }
}

Application 설정

@EnableConfigServer를 선언하여 ConfigServer를 활성화 합니다.

package com.spring.msa;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.config.server.EnableConfigServer;

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

bootstrap 작성

/resources 아래에 application.properties를 삭제하고 bootstrap.yml을 생성합니다.

공통 환경설정

# bootstrap.yml 공통 환경설정 파일. 기본 프로파일은 native로 지정
server:
  port: 9000
spring:
  profiles:
    active: native

local 환경설정 – 로컬 환경설정 파일을 사용하는 방법

profile명은 native로 설정합니다. local의 경우 환경설정 파일이 git이나 db등에 있지않고 local에 위치하여 마음대로 수정하면서 개발하기 위함입니다. 아래 내용을 보면 설정 파일의 위치도 native.search-location으로 설정하도록 되어있습니다. ${user.home}은 사용자의 홈 디렉터리를 의미합니다.( 리눅스 – /home/username , 윈도우 – C:\Users\username ) 윈도우즈에서 호출시는 file:///로 /가 한번 더 붙습니다. 관리의 편의성을 위해 프로젝트 내 /resources 하위로 설정해도 되지만 이렇게 하면 설정 변경시 configuration 서버를 리스타트 해야 반영됩니다.

# bootstrap-local.yml 로컬 환경설정 파일
spring:
  profile: native
  cloud:
    config:
      server:
        native: # 서버 실행시 profile 설정시 native를 지정해야 적용됩니다.
          search-locations: file:///${user.home}/server-configs # window - C:\Users\계정명\server-configs
          #search-locations: file://${user.home}/server-configs # mac, linux
          #search-locations: classpath:/server-configs

서비스별 환경 설정 추가

위에서 지정한 file:///${user.home}/server-configs 디렉터리를 로컬 pc에 생성하고 {서비스이름}-{환경profile}.yml 형태로 환경설정 파일을 작성합니다. 아래는 제 Mac에 세팅한 내용입니다. member-service-local.yml, contents-service-local.yml 두개의 파일을 작성하였습니다.

$ pwd
/Users/happydaddy/server-configs
$ ls
member-service-local.yml        contents-service-local.yml
# member-service-local.yml
spring:
  profiles: local
  message: Hello Spring MemberService Local Server!!!!!

# contents-service-local.yml
spring:
  profiles: local
  message: Hello Spring ContentsService Local Server!!!!!

환경설정 확인

Config Server를 실행하고 콘솔에서 다음과 같이 실행하여 설정값을 확인합니다. 다음의 주소를 호출하면 member-service, contents-service에 대한 설정이 어떻게 세팅되었는지 확인 가능합니다.
http://{config서버주소}/{확인할서비스이름}/{환경profile}

$ curl http://localhost:9000/member-service/local
{
  "name": "member-service",
  "profiles": [
    "local"
  ],
  "label": null,
  "version": null,
  "state": null,
  "propertySources": [
    {
      "name": "file:///Users/happydaddy/server-configs/member-service-local.yml",
      "source": {
        "server.port": 8080,
        "spring.profiles": "local",
        "spring.message": "Hello Spring MemberService Local Server!!!!!"
      }
    }
  ]
}
$ curl http://localhost:9000/contents-service/local
{
  "name": "contents-service",
  "profiles": [
    "local"
  ],
  "label": null,
  "version": null,
  "state": null,
  "propertySources": [
    {
      "name": "file:///Users/happydaddy/server-configs/contents-service-local.yml",
      "source": {
        "server.port": 8081,
        "spring.profiles": "local",
        "spring.message": "Hello Spring ContentsService Local Server!!!!!"
      }
    }
  ]
}

Client Service Service 만들기

Configuration 서버에서 설정값을 읽어 시스템을 구성하는 Client 서버를 2대 더 생성합니다. 각각 member-service, contents-service로 어플리케이션을 설정하고 8080, 8081 포트로 서버를 실행하여 테스트를 진행합니다.

build.gradle

build.gradle의 내용은 두 서비스가 동일합니다. spring-cloud-starter-config는 config서버와 통신을 하기 위한 라이브러리입니다.

plugins {
    id 'org.springframework.boot' version '2.1.4.RELEASE'
    id 'java'
}

apply plugin: 'io.spring.dependency-management'

group = 'com.spring'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '1.8'

repositories {
    mavenCentral()
}

ext {
    set('springCloudVersion', 'Greenwich.SR1')
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.cloud:spring-cloud-starter-config'
    implementation 'org.springframework.boot:spring-boot-starter-actuator'
}

dependencyManagement {
    imports {
        mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
    }
}

bootstrap 작성

Configuration서버와 동일하게 application.properties를 삭제하고 bootstrap.yml을 작성합니다. 여기에 설정한 내용은 application.yml보다 먼저 로드되어 적용됩니다.

공통 환경설정

application.name은 config서버에서 설정값을 조회할 때 구분하기 위한 서비스 이름입니다. management.endpoint.web.exposure.include: refresh 설정은 클라이언트 서버의 /actuator/refresh endpoint를 활성화하기 위한 설정입니다. Config 서버에서 환경설정을 변경할 경우 클라이언트 서버에는 설정이 바로 적용되지 않습니다. 변경사항은 클라이언트 서버에 actuator를 활성화하고 refresh endpoint로 요청을 보내야 갱신됩니다. 변경된 설정을 다시 로드하는 테스트는 아래에서 진행해 보겠습니다.

bootstrap.yml

# member-service bootstrap.yml
server:
  port: 8080
spring:
  profiles:
    active: local
  application:
    name: member-service
management:
  endpoints:
    web:
      exposure:
        include: info, refresh

# contents-service bootstrap.yml
server:
  port: 8081
spring:
  profiles:
    active: local
  application:
    name: contents-service
management:
  endpoints:
    web:
      exposure:
        include: info, refresh

local 환경설정

서버의 환경 설정은 Config 서버에서 받아와야 하므로 Config 서버의 주소를 명시합니다. fail-fast:true는 클라이언트 서버를 시작할 때 Config 서버에서 정보를 로드할 수 없을 경우엔 서버를 실행하지 않겠다는 설정입니다.

# member-service, contents-service bootstrap-local.yml 
spring:
  profiles: local
  cloud:
    config:
      uri: http://localhost:9000
      fail-fast: true

Application 설정

일반적인 boot서버와 동일하고 추가되는 설정은 없습니다.

// member-service
@SpringBootApplication
public class MemberApplication {
    public static void main(String[] args) {
        SpringApplication.run(MemberApplication.class, args);
    }
}
// contents-service
@SpringBootApplication
public class ContentsApplication {
    public static void main(String[] args) {
        SpringApplication.run(ContentsApplication.class, args);
    }
}

Controller 작성

Config 서버에서 환경 설정 내용을 불러올 수 있는지 확인을 위한 코드를 작성합니다. 여기서 중요한 점은 Controller 선언 시 @RefreshScope도 같이 선언해 줘야 한다는 것입니다. 일반적인 환경설정은 서버가 스타트될 때 한번 로드되고 캐싱되어 변하지가 않습니다. Config 서버를 통해 외부에서 환경설정이 변경되었을 때 해당 설정이 다시 로드되어야 하는 부분은 코드상에 어노테이션으로 표시를 해주어야 합니다.

# member-service 
@RestController
@RequestMapping("/v1")
@RefreshScope
public class MemberController {

    @Value("${server.port}")
    private int port;

    @Value("${spring.message}")
    private String message;

    @GetMapping("/member/detail")
    public String member() {
        return "Member Detail - Port " + port + " - " + message;
    }
}
# contents-service 
@RestController
@RequestMapping("/v1")
@RefreshScope
public class ContentsController {

    @Value("${server.port}")
    private int port;

    @Value("${spring.message}")
    private String message;

    @GetMapping("/contents/detail")
    public String contents() {
        return "Contents Detail - Port " + port + " - " + message;
    }
}

Test

Config Server(port 9000)을 실행하고. boot 서버 두대를 차례로 실행합니다.(port 8080,8081) 서버 스타트 시 로그를 보면 Config 서버에서 설정값을 조회하는 것을 볼 수 있습니다.

2019-05-27 00:09:33.384  INFO 10220 --- [           main] c.c.c.ConfigServicePropertySourceLocator : 
Fetching config from server at : http://localhost:9000
2019-05-27 00:09:36.596  INFO 10220 --- [           main] c.c.c.ConfigServicePropertySourceLocator : 
Located environment: name=contents-service, profiles=[local], label=null, version=null, state=null

console에서 8080, 8081서버를 차례로 호출해 봅니다.
결과를 보면 Configuration 서버의 설정값으로 결과가 출력되는 것을 확인 할 수 있습니다!

$ curl http://localhost:8080/v1/member/detail
Member Detail - Port 8080 - Hello Spring MemberService Local Server!!!!!
$ curl http://localhost:8081/v1/contents/detail
Contents Detail - Port 8081 - Hello Spring ContentsService Local Server!!!!!

Alpha 서버 환경 테스트

local서버는 local 환경의 파일을 읽어 설정 값을 가져왔다면. alpha서버는 github에 설정된 값을 이용하도록 실습해 보겠습니다.

config 서버 alpha 설정 추가

알파환경으로 서버를 띄울경우 github의 특정 디렉터리 하위의 파일을 읽도록 설정합니다. 실습에서는 공개된 Gihub Repository를 사용하여 인증이 필요없지만 비공개 Repository를 사용할경우 아래 설정에 인증 내용도 추가해야 합니다. 실습은 인증 없이 진행합니다.

# bootstrap-alpha.yml
spring:
  profile: alpha
  cloud:
    config:
      server:
        git:
          uri: https://github.com/codej99/SpringCloudMsa
          search-paths: server-configs

GitHub에 설정 추가

github에서 content-service-alpha.yml, member-service-alpha.yml 두개를 생성하고 커밋합니다.

# contents-service-alpha.yml
spring:
  profiles: alpha
  message: Hello Spring ContentsService Alpha Server!!!!!
# member-service-alpha.yml
spring:
  profiles: alpha
  message: Hello Spring MemberService Alpha Server!!!!!

alpha 환경설정 확인

config 서버를 alpha환경으로 세팅( -Dspring.profiles.active=alpha )하고 재시작 합니다.

console에서 클라이언트 서비스의 설정값을 확인해봅니다. github의 설정 파일에서 정보를 읽어오는것을 확인할수 있습니다.

$ curl http://localhost:9000/member-service/alpha
{
  "name": "member-service",
  "profiles": [
    "alpha"
  ],
  "label": null,
  "version": "1dcee1575d90d4e1b51b34261f9118f366ee3204",
  "state": null,
  "propertySources": [
    {
      "name": "https://github.com/codej99/Spring-Cloud-Msa/server-configs/member-service-alpha.yml",
      "source": {
        "spring.profiles": "alpha",
        "spring.message": "Hello Spring MemberService Alpha Server!!!!!"
      }
    }
  ]
}
$ curl http://localhost:9000/contents-service/alpha
{
  "name": "contents-service",
  "profiles": [
    "alpha"
  ],
  "label": null,
  "version": "6ec6f184c4e39d5fc56b4a819c6c03cd2f694482",
  "state": null,
  "propertySources": [
    {
      "name": "https://github.com/codej99/Spring-Cloud-Msa/server-configs/contents-service-alpha.yml",
      "source": {
        "spring.profiles": "alpha",
        "spring.message": "Hello Spring ContentsService Alpha Server!!!!!"
      }
    }
  ]
}

Client Service에 alpha환경 추가

member-sevice, contents-service에 bootstrap-alpha.yml을 추가합니다. 서비스 둘다 내용은 동일합니다. 확인을 위해 서비스 profile을 alpha로 세팅( -Dspring.profiles.active=alpha ) 하고 재시작합니다.

spring:
  profiles: alpha
  cloud:
    config:
      uri: http://localhost:9000
      fail-fast: true

테스트

console에서 서비스 주소를 호출하여 alpha설정이 적용되었는지 확인합니다.

$ curl http://localhost:8080/v1/member/detail
Member Detail - Port 8080 - Hello Spring MemberService Alpha Server!!!!!
$ curl http://localhost:8081/v1/contents/detail
Contents Detail - Port 8081 - Hello Spring ContentsService Alpha Server!!!!!

설정값 실시간 변경 테스트

Config 서버를 따로 분리했는데 설정값을 실시간으로 변경할 수 없다면 아무런 의미가 없습니다. GitHub의 설정 파일 내용을 수정하고 커밋합니다.

# member-service-alpha.yml
spring:
  profiles: alpha
  message: Hello Spring MemberService Alpha Server@@@@@
# contents-service-alpha.yml
spring:
  profiles: alpha
  message: Welcome to Spring Api-02 Alpha Server#####

다시 api를 호출하면 설정값은 변경하였지만 적용이 안된것을 확인할 수 있습니다. 왜냐하면 설정값은 변경시 바로 반영되지 않게 되어있습니다. 서비스에서는 서버실행시 한번 Config서버에서 설정을 읽어 로컬에 캐시합니다. 그후에 변경사항은 refresh를 호출해야 합니다. config서버에 부담을 주지 않기 위한 일종의 안전장치로 보입니다. 환경설정 변경 내용을 적용하기위해 타겟 서버에 POST로 actuator/refresh를 호출합니다. refresh 요청을 보내고 다시 테스트하면 이번에는 설정값이 변경되어 내려오는것을 확인할 수 있습니다.

$ curl http://localhost:8080/v1/member/detail
Member Detail - Port 8080 - Hello Spring MemberService Alpha Server!!!!!
$ curl -XPOST http://localhost:8080/actuator/refresh
["config.client.version","spring.message"]
$ curl http://localhost:8080/v1/member/detail
Member Detail - Port 8080 - Hello Spring MemberService Alpha Server@@@@@
$ curl http://localhost:8081/v1/contents/detail
Contents Detail - Port 8081 - Hello Spring ContentsService Alpha Server!!!!!
$ curl -XPOST http://localhost:8081/actuator/refresh
["config.client.version","spring.message"]
$ curl http://localhost:8081/v1/contents/detail
Contents Detail - Port 8081 - Hello Spring ContentsService Alpha Server#####

Encryption and Decryption

config 서버에서 client서버에 제공하는 설정 정보는 yml 파일에 plain data로 저장됩니다. 이 말인즉슨 설정 파일에 보안과 관련한 정보가 저장돼 있을 경우 해당 파일이 탈취되면 문제가 발생할 수 있다는 것입니다. 예를 들자면 database 접속 아이디/비번, 암호화 데이터의 secret 키값 등이 이에 해당합니다. 이를 염두해서인지 Spring에서는 정보를 저장할 때 암호화된 값을 저장할 수 있도록 지원하고 있습니다.

관련한 자세한 내용은 아래 링크의 Security 항목을 확인해 주시기 바랍니다.

https://cloud.spring.io/spring-cloud-static/spring-cloud-config/1.3.0.RELEASE/

암호화에 사용할 keypair 생성

keytool -genkeypair 명령으로 keypair를 생성하며 옵션은 다음과 같습니다.

$ keytool -genkeypair -help
keytool -genkeypair [OPTION]...

키 쌍을 생성합니다.

옵션:

 -alias <alias>                  처리할 항목의 별칭 이름
 -keyalg <keyalg>                키 알고리즘 이름
 -keysize <keysize>              키 비트 크기
 -sigalg <sigalg>                서명 알고리즘 이름
 -destalias <destalias>          대상 별칭
 -dname <dname>                  식별 이름
 -startdate <startdate>          인증서 유효 기간 시작 날짜/시간
 -ext <value>                    X.509 확장
 -validity <valDays>             유효 기간 일 수
 -keypass <arg>                  키 비밀번호
 -keystore <keystore>            키 저장소 이름
 -storepass <arg>                키 저장소 비밀번호
 -storetype <storetype>          키 저장소 유형
 -providername <providername>    제공자 이름
 -providerclass <providerclass>  제공자 클래스 이름
 -providerarg <arg>              제공자 인수
 -providerpath <pathlist>        제공자 클래스 경로
 -v                              상세 정보 출력
 -protected                      보호되는 메커니즘을 통한 비밀번호
$ keytool -genkeypair -alias config-server-key -keyalg RSA \
  -dname "CN=Config Server,OU=Spring Cloud,O=daddyprogrammer" \
  -keypass keypass123 -keystore config-server.jks -storepass storepass123

위의 명령을 실행하면 config-server.jks 파일이 생성됩니다. 생성한 파일을 Configuration프로젝트의 resources에 복사합니다.

bootstrap.yml에 encrypt설정 추가

configuration 서비스의 bootstrap.yml에 암호화 관련 내용을 추가합니다.

encrypt:
  key-store:
    location: classpath:/config-server.jks
    password: storepass123
    alias: config-server-key
    secret: keypass123

data 암호화

위 설정까지 마치고 configuration 서버를 실행한다음 encrypt endpoint를 호출하여 문자열을 암호화 합니다. 아래에서는 “password” 문자열을 암호화한 예제입니다.

$ curl -H 'Content-Type: text/plain' --data-urlencode password http://localhost:9000/encrypt
AQBFtSUXeOe9bgCOlU8k/LTWu+/R+jTsU1LaPURGuyLPh4S0eqa+dB9iciWEc67d5al4QCMO4JAcAboD3z26qd6kwhuKW0mnkJ6rJRpsi4xRitS0l6BZpIp4lBayyXdNYdeuKX8/8uWoLtN8x2KTGSlGAotS/XLOhJJFRV9wUeOLWRu1/JsGUns8fLDANLXI+YjIxTgGy2Fn706cBnZOcVSsAKo5Cwszzy0dr7aUYbVBn8X1uM0irVSLAHXaZb8TXCJ4vkFnhzUBxQLcLncsxg15p0lsSD6Rfsd9hXseSgUtrPWDslFaBGVHrW2k2lMo/40YPyrVwiqGFFCVzai9QKGa88W6PF1QYQ/xngF2+c8631G5c0PmpvngsruYMGp/J4Q=

위에서 생성한 암호화 데이터를 올바로 사용할 수 있는지 테스트해보기 위해 configuration 서버에서 관리하는 member-service-local.yml에 해당 키를 등록합니다. 등록 시 주의할 점은 ‘{cipher}$암호화 데이터’로 등록을 해야 한다는 것입니다.

 spring:
  profiles: local
  message: Hello Spring MemberService Local Server!!!!!
eureka:
  client:
    registryFetchIntervalSeconds: 5
    serviceUrl:
      defaultZone: ${EUREKA_URI:http://localhost:8761/eureka}
  instance:
    preferIpAddress: true
management:
  endpoint:
    shutdown:
      enabled: true
  endpoints:
    web:
      exposure:
        include: shutdown,info
user:
  password: '{cipher}AQBFtSUXeOe9bgCOlU8k/LTWu+/R+jTsU1LaPURGuyLPh4S0eqa+dB9iciWEc67d5al4QCMO4JAcAboD3z26qd6kwhuKW0mnkJ6rJRpsi4xRitS0l6BZpIp4lBayyXdNYdeuKX8/8uWoLtN8x2KTGSlGAotS/XLOhJJFRV9wUeOLWRu1/JsGUns8fLDANLXI+YjIxTgGy2Fn706cBnZOcVSsAKo5Cwszzy0dr7aUYbVBn8X1uM0irVSLAHXaZb8TXCJ4vkFnhzUBxQLcLncsxg15p0lsSD6Rfsd9hXseSgUtrPWDslFaBGVHrW2k2lMo/40YPyrVwiqGFFCVzai9QKGa88W6PF1QYQ/xngF2+c8631G5c0PmpvngsruYMGp/J4Q='

configuration서버 설정은 여기까지고 member-service에서 해당 설정을 불러오도록 MemberController를 수정합니다.

@RestController
@RequestMapping("/v1")
@RefreshScope
public class MemberController {

   // 생략 ...
    @Value("${user.password}")
    private String password;

    @GetMapping("/member/password")
    public String memberPassword() {
        return "Member Password - " + password;
    }
    // 생략 ...
}

configuration, member-service를 재시작하고 해당 리소스를 호출해보면 아래와 같이 복호화된 값이 출력되는 것을 확인할 수 있습니다.

$ curl localhost:8080/v1/member/password                       
Member Password - password

실습한 소스의 최신내용은 아래 GitHub에서 확인 가능합니다.
https://github.com/codej99/SpringCloudMsa

연재글 이동
[다음글] Spring Cloud MSA(2) – Gateway(Routing & Filter) Server by Netflix zuul