개인 공부용/loadbalancer

직접 만든 로드밸런서로 다중인스턴스 관리하기

pon9 2025. 1. 20. 20:29

로드밸런싱은 서버에 가해지는 부하(로드)를 분산(밸런싱) 해주는 기술이다. 클라이언트와 서버 풀 사이에 위치하며, 한 대의 서버로 부하가 집중되지 않도록 트래픽을 관리해서 각각의 서버가 최적의 퍼포먼스를 보일 수 있도록 해준다.

 

ELB로 그냥 사용하면 되지만 어떻게 모르고 사용할 수 있겠는가 싶어서(사실 튜터님한테 과제받음 ㅋ) 직접 구현해보고, 어떤 식으로 흘러가는지 알아보도록 하자.

 

내가 구현할 로드밸런서는 모듈화된 단 하나의 로드밸런서고, 어떤 프로젝트에든 적용해볼수있는걸로 만들어보자.

@Slf4j
@Component
public class LoadBalancer {

    private final HealthCheckService healthCheckService;
    private int currentIndex = 0;

    public LoadBalancer(HealthCheckService healthCheckService) {
        this.healthCheckService = healthCheckService;
    }

    //현재 정상서버 리스트 가져옴
    //동기화 블록 사용해 멀티스레드 환경에서의 동시성 문제 방지
    public synchronized String getNextServer() {
        List<String> healthyServers = healthCheckService.getHealthyServers();

        //정상서버가 하나도 없는경우
        if (healthyServers.isEmpty()) {
            throw new IllegalStateException("No healthy servers available.");
        }

        //라운드로빈 방식으로 서버 순회 -> 다음 서버 선택
        String server = healthyServers.get(currentIndex);
        //인덱스는 리스트 크기를 초과하지 않도록 순환됨
        currentIndex = (currentIndex + 1) % healthyServers.size();
        return server;
    }

    //다음 정상서버를 가져오고 클라이언트 요청 전달 준비
    public String forwardRequest(String clientRequest) throws Exception {
        String server = getNextServer();

        //클라이언트 요청을 안전하게 인코딩
        String encodedRequest = java.net.URLEncoder.encode(clientRequest, StandardCharsets.UTF_8);
        //get 요청 준비
        URL url = new URL(server + encodedRequest);
        HttpURLConnection connection = (HttpURLConnection) url.openConnection();
        connection.setRequestMethod("GET");

        //서버의 응답데이터를 읽어와서 문자열로 반환
        //try-with-resources 를 사용해 자원을 자동으로 닫음
        try (var in = new java.io.BufferedReader(new java.io.InputStreamReader(connection.getInputStream()))) {
            StringBuilder response = new StringBuilder();
            String line;
            while ((line = in.readLine()) != null) {
                response.append(line);
            }
            log.info("Request forwarded to {} with response: {}", server, response);
            return response.toString();
        }
    }
}

 

우선 로드밸런서 코드다.
헬스체크서비스에서 정상적인 서버 리스트를 관리하고, 라운드 로빈 방식으로 요청을 라우팅하도록 구현해봤다.

 

라운드로빈이란 가장 기본적인 로드밸런싱 방식인데 급식 나눠주듯이 순서대로 서버를 나눠주는(?) 여튼 그런 알고리즘이다.

라운드로빈 말고 다른 방식을 사용할 가능성이 있다면 저 로직만 분리하면 될거다..

 

한다면 가중치방식을 해보고싶다.

 

그리고 synchronized는, 멀티스레드 환경에서 currentIndex의 동시 수정으로 인한 문제를 방제하기 위해서 사용했다.

getNextServer()메서드는 currentIndex값을 읽고 수정하니까 데이터 레이스가 발생할 가능성이있고 이를 방지하기 위해 동기화 블록을 사용했다.

@Slf4j
@Component
public class HealthCheckService {

    //healthCheck 대상 서버 url 리스트
    private final List<String> servers = List.of(
        "http://localhost:8081",
        "http://localhost:8082",
        "http://localhost:8083"
    );
    //정상 서버들을 저장하는 스레드 안전 리스트
    private final CopyOnWriteArrayList<String> healthyServers = new CopyOnWriteArrayList<>();

    //정상 서버들을 읽기 전용으로 외부에 제공
    public List<String> getHealthyServers() {
        return List.copyOf(healthyServers); //변경 불가능한 리스트 반환
    }

    @Scheduled(fixedRate = 5000) //healthCheck 5초마다 실행
    public void performHealthCheck() {
        //정상 서버 리스트 저장용
        CopyOnWriteArrayList<String> updatedHealthyServers = new CopyOnWriteArrayList<>();

        //모든 서버를 순회하며, 정상 서버를 리스트에 추가
        for (String server : servers) {
            if (isServerHealthy(server)) {
                updatedHealthyServers.add(server);
            }
        }

        //기존 정상서버 리스트 지우고, 새로운 리스트 갱신
        healthyServers.clear();
        healthyServers.addAll(updatedHealthyServers);

        //현재 정상서버 리스트 출력
        log.info("Healthy servers: {}", healthyServers);
    }

    //개별서버에 요청을 보내 상태 확인
    private boolean isServerHealthy(String serverUrl) {
        try {
            //http get 요청을 보내고
            URL url = new URL(serverUrl + "/actuator/health");
            HttpURLConnection connection = (HttpURLConnection) url.openConnection();
            connection.setRequestMethod("GET");
            //2초 이내에 200 응답 반환하면 정상으로 인식
            connection.setConnectTimeout(2000);
            connection.setReadTimeout(2000);
            return connection.getResponseCode() == 200;
        } catch (Exception e) {
            //예외 시 해당 서버를 비정상으로 간주하고 에러 출력
            log.error("Health check failed for {}: {}", serverUrl, e.getMessage());
            return false;
        }
    }
}

헬스체킹 서비스다. get요청을 5초마다 보내고 2초안에 200응답이 없으면 비정상적인 서버라는거다.

대상 서버 url리스트도 따로 빼야되는데 일단 내 로컬에서만 할거라 저렇게 해놨땅

 

근데 전공자분이랑 이야기 하다가 synchronized가 부하가 올수도있다는 말씀을 해주셔서 좀 찾아보니까

synchronized는 락을 사용하므로 멀티스레드 환경에서 경합이 많아질 경우에 컨텍스트 스위칭 비용이 증가한다.

따라서 스레드가 많아질수록 성능이 저하될 가능성이 있을 것이다.

 

그렇다면 락을 사용하는 / 멀티스레드 환경에서 / 경합이 많아질 경우에 / 컨텍스트 비용이 증가할까?

언뜻 보면 당연한 이야기지만 나는 cs를 모르니까 정리해보자

 

 

락 경합(Contention)과 컨텍스트 스위칭(Context Switching)

여러 스레드가 동시에 공유 자원에 접근하려 시도할 때, 자원이 이미 다른 스레드에 의해 락이 걸려있다면 다른 스레드들은 대기 상태로 전환된다. 이 대기 상태를 처리하기 위해 운영체제는 스레드 간 컨텍스트 스위칭을 수행한다.

 

컨텍스트 스위칭은 CPU가 현재 실행중인 스레드의 상태(레지스터 값, 스택 포인터, 프로그램 카운터)를 저장하고, 대기 중인 다른 스레드의 상태를 복원하여 실행을 전환하는 과정이다.

(이거 완전 로드밸런서..? 운영체제 스케줄러가 마치 로드밸런서처럼 CPU자원을 여러 스레드에 효율적으로 분배하려 하는..)

컨텍스트 스위칭 과정에는,

1.현재 실행 중인 스레드의 상태 저장(헬스체킹??)

2.대기 중인 스레드의 상태 복원(눈물뚞뚝흘리며 서버를 복원하는 개발자??)

3.CPU의 실행 컨텍스트 변경

 

이 과정에서 CPU와 메모리 자원이 사용되며, 오버헤드가 발생한다.

 

락이 이미 사용중인 상태에서 다른 스레드가 자원에 접근하려 하면, 해당 스레드는 대기 상태로 전환된다.

이 대기 중인 스레드는 스케줄러에 의해 실행 중단되고, 다른 실행 가능한 스레드가 CPU를 차지한다.

이 과정에서 컨텍스트 스위칭이 발생하며, 경합이 많을 수록 이런 전환과정이 더 ㅓ 덛 더 빈번히 발생해 시스템자원이 낭비되는 것이다.

 

++아 심지어.. 라운드로빈과 비슷한 스케줄링 알고리즘이 있네..

선점형 스케줄링이 특정 스레드가 지정된 시간을 다 사용하면 CPU를 다른 스레드로 전환하는데 이거 완전 라운드로빈................헉;

 

 

컨텍스트 스위칭 비용의 주요 원인

1. CPU캐시 무효화

스레드가 전환될 때, CPU캐시의 데이터가 더이상 유효하지 않으므로 새로운 스레드의 데이터를 다시 로드해야하며 성능 저하로 이어진다.

2. 운영체제 커널 오버헤드

컨텍스트 스위칭은 사용자 모드에서 커널모드로 전환되며, 커널 내부에서 작업이 수행된다. 이 과정에서 운영체제의 스케줄링 및 스레드 관리 비용이 증가한다.

3. 스레드 스케줄링 지연

락이 해제될때까지 대기해야 하는 스레드가 많아질수록, 스케줄러가 어느 스레드를 실행할지 결정하는 데 더 많은 시간과 리소스를 소모한다.

 

-->>결과적으로 발생하는 문제: 스레드 스케줄링 지연 + 캐시 무효화로 인해 CPU사용률이 비효율적으로 높아진다

 

 

해결해보자: 도와줘 아톰..

private final AtomicInteger currentIndex = new AtomicInteger(0);

일단 계산기 과제 때 아토믹레퍼런스를 사용했던 것처럼 이렇게 아토믹인티저로 해봤다

thread-safe하게 정수 값을 갱신할 수 있고, 코드도 간결해졌다.

private String getNextServer() {
    List<String> healthyServers = healthCheckService.getHealthyServers();

    if (healthyServers.isEmpty()) {
        throw new IllegalStateException("No healthy servers available.");
    }

    int index = currentIndex.getAndUpdate(i -> (i + 1) % healthyServers.size());
    return healthyServers.get(index);
}

atomicInteger는 내부적으로 Compare-And-Swap 연산을 사용하기에 락 없이 스레드 안정성을 보장한다.

 

CAS연산이란, 병렬 프로그래밍에서 동기화 문제를 해결하기 위해 사용되는 lock-free 알고리즘이다. CAS는 단일 연산으로 메모리 값을 조건부로 업데이트하는 기능을 제공하며, 주로 멀티스레드 환경에서 데이터의 동시성을 보장하는 데 사용된다.

 

메모리 위치(V), Expected Value 현재 메모리에 저장된 값으로 예상되는 값, New Value 메모리를 업데이트하려는 새 값

위의 세 가지를 비교하고, 조건에 맞으면 값을 교체하는 알고리즘이다.

 

이는 락을 사용하는 방식보다 성능이 좋고 병렬처리도 효율적으로 지원한다.

CAS연산은 CPU에서 직접 지원하는 저수준 명령어를 활용하고, 락을 사용하지 않아서 경합 시에도 성능이 더 좋다(물론 데드락도 발생 안함). 고성능 환경에서 동기화 블록보다 효율적이다.

 

하지만.. AtomicIntger는 결국 단일 변수의 값을 안전하게 갱신할 때 사용한 것이니, 동기화가 필요한 코드 블록이 복잡할 경우엔 synchronized가 적합할 것이다.

 

다른방법없을까!!!!!!!!!!!!!!!!!!!!!!!