(16) Spring Official Guide - Client-Side Load-Balancing with Spring Cloud LoadBalancer

HEYDAY7·2022년 11월 1일
0

Learn Kotlin + Spring

목록 보기
17/25
post-thumbnail

Client-Side Load-Balancing with Spring Cloud LoadBalancer

https://spring.io/guides/gs/spring-cloud-loadbalancer/

한줄 요약

Spring Cloud LoadBalancer를 이용해 LoadBalancing 구현하는 법.(다만 LoadBalancing에 대한 개념을 가르쳐주진 않는다.)

프로젝트 구성

일단 해당 가이드는 1개의 Root 프로젝트와 2개의 서브 프로젝트로 구성되어 있어 처음 시작이 좀 헷갈린다.
추천하는 구성법은 다음과 같다.

  1. root가 될 "gs-spring-cloud-loadbalancer" directory를 만들어주고 아래 파일을 작성한다.
## settings.gradle.kts
rootProject.name = "gs-spring-cloud-loadbalancer"

include("say-hello")
include("user")
  1. 그 후 say-hello와 user 프로젝트를 만들어준다.
    • say-hello 프로젝트는 initializer로 Spring Web dependency를 추가해서 만들어준다.
    • user 프로젝트는 initializer로 Cloud Loadbalancer와 Spring Reactive Web를 dependency로 추가해 만들어준다.
  2. 2에서 만든 두개의 프로젝트를 각각 압축을 풀고 root directory 내부로 옮겨준다.

    물론 실제로 제일 추천하는 방법은 그냥 작성된 코드를 받아서 따라가보는 것이다. 일반적으로 나도 모든 코드를 직접 써보긴 하는데, 이번 가이드의 경우 다른 것들 보다 약간 귀찮은 부분이 있기에 git clone 해보는 것도 추천하긴 한다.
    github 주소

코드 작업

say hello 프로젝트

서버역할을 해 줄 "say hello" service의 경우 코드가 매우 간단하다. 따로 파일을 생성하지 말고 application 파일을 수정해주자. 그리고 포트를 정해주는 yml 파일을 추가해주자.

## ~Application.kt
@RestController
@SpringBootApplication
class SayHelloApplication {
	private val logger = LoggerFactory.getLogger(SayHelloApplication::class.java)

	@GetMapping("/greeting")
	fun greet(): String {
		logger.info("Access /greeting")
		val greetings  = listOf("Hi there", "Greetings", "Salutations")
		val rand = Random.Default
		val randomNum = rand.nextInt(greetings.size)
		return greetings[randomNum]
	}

	@GetMapping("/")
	fun home(): String {
		logger.info("Access /")
		return "Hi!"
	}
}
## src/main/resources/application.yml
spring:
  application:
    name: say-hello

server:
  port: 8090

user

user의 경우 "hi", "hello" 두 개의 endpoint를 가지는 client 역할을 한다. 가이드에서 알려주는 파일의 순서는 그다지 추천하지 않고 아래와 같은 순서로 작성하는 걸 추천한다.

SayHelloConfiguration.kt

해당 코드는 load balancing에 사용될 여러 ServiceInstance를 직접 하드코딩 해둔 것 이라고 보면 된다. 일단 그 의미만 알아두자.

class SayHelloConfiguration {

    @Bean
    @Primary
    fun serviceInstanceListSupplier(): ServiceInstanceListSupplier {
        return DemoServiceInstanceListSuppler("say-hello")
    }
}

class DemoServiceInstanceListSuppler(
        private val serviceId: String
): ServiceInstanceListSupplier {

    override fun getServiceId(): String {
        return serviceId
    }

    override fun get(): Flux<MutableList<ServiceInstance>> {
        return Flux.just(
                mutableListOf(
                        DefaultServiceInstance(serviceId + "1", serviceId, "localhost", 8090, false),
                        DefaultServiceInstance(serviceId + "2", serviceId, "localhost", 9092, false),
                        DefaultServiceInstance(serviceId + "3", serviceId, "localhost", 9999, false),
                )
        )
    }
}

WebClientConfig.kt

WebClient.Builder에 대해 @Bean을 제공하기 위한 클래스 코드이다. 여기서 @LoadBalancerClient를 달아주는 것으로 @LoadBalnced WebClient.Builder를 제공해주게 된다.

@Configuration
@LoadBalancerClient(name="say-hello", configuration = [SayHelloConfiguration::class])
class WebClientConfig {
    @LoadBalanced
    @Bean
    fun webClientBuilder(): WebClient.Builder {
        return WebClient.builder()
    }
}

여기서 집고 넘어갈 점이 있다면, 일반적으론 직접 @LoadBalancerClient 를 이용하고 custom configuration을 작성할 필요는 없다. 가장 전형적인 방법은 Spring Cloud LoadBalancer를 service discovery와 같이 사용하는 것이다. classpath에 DiscoveryClient가 있다면 Spring Colud LoadBalancer configuartion이 이를 통해 service instances를 체크하고, 정상 작동중인 친구들만 사용하게 된다.
이 내용은 ServiceDiscovery 관련 가이드에서 익힐 수 있다.

UserApplication.kt

자동적으로 생성되는 Application class를 수정해주자. WebClient.Builder의 경우 직전에 작성한 WebClientConfig를 통해 @LoadBalanced 되어 가져와질 것이다. 코드를 살펴보면 "/hi"와 "/hello" 구현이 약간 다르다. hi의 경우 @LoadBalanced WebClient.Builder를 통해 client를 생성해서 사용하고, hello의 경우 기본 WebClient를 생성한 후 "load-balancer exchange filter function"(이하 lbFunction)을 이용해서 client를 생성해 사용한다. 동작 자체는 완전히 동일하다고 하니 알아두면 될 것 같다.

@SpringBootApplication
@RestController
class UserApplication(
		@Autowired private val loadBalancedWebClientBuilder: WebClient.Builder,
		@Autowired private val lbFunction: ReactorLoadBalancerExchangeFilterFunction
) {

	@RequestMapping("/hi")
	fun hi(
			@RequestParam(value = "name", defaultValue = "Mary") name: String
	): Mono<String> {
		return loadBalancedWebClientBuilder.build().get().uri("http://say-hello/greeting")
				.retrieve()
				.bodyToMono(String::class.java)
				.map { greeting ->
					"$greeting, $name!"
				}
	}

	@RequestMapping("/hello")
	fun hello(
			@RequestParam(value = "name", defaultValue = "John") name: String
	): Mono<String> {
		return WebClient.builder()
				.filter(lbFunction)
				.build().get().uri("http://say-hello/greeting")
				.retrieve()
				.bodyToMono(String::class.java)
				.map { greeting ->
					"$greeting, $name!"
				}
	}
}

application.yml

user의 경우에도 yml 파일을 통해 이름과 port를 지정해주자.

spring:
  application:
    name: user

server:
  port: 8888

Run & Test

확실한 테스트를 위해 여러개의 termianl(나는 git bash를 이용했다.)를 이용하는 것을 추천한다. 테스트 순서는 다음과 같다.

  1. 우선 server service인 say-hello를 3가지 다른 포트로 3번 켜주자.

    // 1. 기존의 8090 port로 첫번째를 킨다.
    ./gradlew bootRun 
    
    // 2. 다음과 같은 방식으로 다른 두 port(9092, 9999)의 서버를 킨다.
    SERVER_PORT = 9092 ./gradlew bootRun (
    
  2. 세 개의 터미널을 나란히 켜두고 client로 curl을 반복해서 날려보고 결과를 지켜보자

    // 아래를 반복해보자.
    curl localhost:8888/hi
    curl localhost:8888/hello
  3. 그러면 각 포트의 서버에서 한번씩 돌아가며 "Access /greeting" log가 찍히는 것을 볼 수 있다.

이것으로 테스트 및 본 가이드는 끝이다. 결국 여러 serverInstance를 만들어 각 서버마다 분산되어 요청이 들어가는 것을 볼 수 있다. 여기서는 한 서버에 한번 씩 돌아가며 요청을 하고 있는데 이 balancing 알고리즘도 여러가지 중 우선순위 없이 차례차례 배정하는 라운드 로빈이 아닐까 생각해본다.

Load Balancing(Balancer)

이대로 끝내기가 아쉬워 Load Balancing(Balancer)에 대해 간략하게 나마 찾아보았다.

기본 개념

Load Balancing이란 서버에 가해지는 load(부하)를 balancing(분산)시켜 서버 퍼포먼스를 유지하기 위한 기술이다.

뭐 항상 필요한 것은 아니지만(소규모의 경우), 서비스가 성장하고 그에 따라 트래픽이 많아지면 꼭 고려해야 할 사항이라고 한다. 해당 시점이 오면 Scale-up이나 Scale-out 중 선택을 해야 하는데 Scale-up의 경우 서버의 성능을 올리는 것이고 Scale-out이 서버를 다수로 늘리는 것을 의미한다.

Load Balancing Algorithm

앞서 언급했들이 여러 알고리즘이 존재했다. 가볍게 리스트 업 정도만 해둔다.

  • 라운드 로빈 : 여러 동일 스펙의 서버에 순서대로 배정하는 방식
  • 가중 라운드 로빈 : 서버에 가중치를 둬 이에 비례하여 배분하는 방식. 서버간의 성능이 다를 때 주로 활용
  • IP 해시 방식 : 클라이언트의 IP를 특정 서버로 매핑하는 방식. 유저가 항상 같은 서버로 연결됨을 보장한다.
  • 최소 연결 방식 : 요청 시점에 가장 연결상태가 적은(least Connection) 서버로 연결시킨다.
  • 최소 리스폰 타임 : 연결 상태와 응답시간을 모두 고려해 최적인 곳으로 배분한다.

L4/L7 ?

로드밸런서 자체를 L? 로드밸런서라고 구분하는 것 같다. 여기서 L?은 네트워크 통신 시스템의 계층을 의미한다고 한다. 이런식으로 구분하는 이유는 좀 더 상위 계층의 로드밸런서를 쓸 수록 더 정교한 로드밸런싱이 가능하기 때문이라고 한다.
간단히 L4와 L7을 요약해보면 L4는 Ip주소, 포트번호 등을 이용하여 트래픽 분산이 가능하고, L7은 url이나 헤더를 통해서도 분산이 가능하다. 또 L7의 경우 바이러스를 감지해 네트워크롤 보호할 수 도 있다고 한다. 이 내용은 추후에 필요한 타이밍에 깊게 다시 파보자

health check

이번 가이드를 하면서 그 필요성을 몸소 뼈저리게 느꼈던 것이다. 가이드를 별다른 고민 없이 진행하다 say hello 서버를 하나만 키고 curl을 계속 보내다 보니 500 error가 간간히 발생했다. 그 때는 이해가 적어 이게 뭔가 싶었는데 알고보니 9092, 9999 포트 서버를 키지도 않았는데 load balancer에 의해 그 쪽으로 요청이 계속 분산되어 가고 있던 것이다. 그래서 이 health check가 확실히 필요하다는걸 우연히 깨닫게 되었다.
위 글에서 언급했던 아마 DiscoveryClient를 통해서 health check를 구현할 수 있는 것 같다. 이 요소를 잊지말고 기억해두자.

마무리

생각했던 것 만큼 중요하고 복잡한 개념이었다. 당장의 개인 공부에서는 개념정도만 알아두면 될 것 같지만, 입사를 하게 된다면 1~2년 내에 꼭 잘 알아야할 파트라고 느꼈다. 뭐 항상 그렇듯, 지금은 이정도 개념과 인지만 가지고 있어도 충분하다 생각한다. 여유가 될 때 이를 좀 더 파보자

코드는 여기서 확인할 수 있다.

profile
(전) Junior Android Developer (현) Backend 이직 준비생

0개의 댓글