로드밸런서의 동시성 문제 해결을 위한 각종 플랜
2025.01.17 - [개인 공부용 프로젝트/loadbalancer.project] - 직접 만든 로드밸런서로 다중인스턴스 관리하기
지난 로드밸런서 글에서, synchronized와 atomic변수의 차이를 개념적으로 알아보았다면
이번에는 concurrentHashMap과 threadLocal을 이용한 전략도 추가하고, 부하테스트를 진행해서 4가지 방법의 차이를 몸소 느껴보자
synchronized
public class RoundRobinSynchronized implements LoadBalancerStrategy {
private int currentIndex = 0;
@Override
public synchronized String getNextServer(List<String> healthyServers) {
if (healthyServers.isEmpty()) {
throw new IllegalStateException("No healthy servers available.");
}
String server = healthyServers.get(currentIndex);
currentIndex = (currentIndex + 1) % healthyServers.size();
return server;
}
}
java의 기본적인 동기화 방법으로, 특정 블록이나 메서드에 접근하는 단일 스레드만 허용하는 방식이다.
모든 스레드가 순차적으로 실행되기 때문에 다중 스레드 환경에서 성능 저하가 크다. 스레드간의 락 경합이 발생하여 컨텍스트 스위칭 비용이 증가하여 결과적으로 처리속도가 느려진다.(지난 글에서 다룬 내용이다)
특히 두개 이상의 스레드가 서로 락을 점유하려고 대기하다가 데드락이 발생할 수도 있다.
단순한 읽기 작업조차도 동기화가 필요하기 때문에 락을 거는 비용이 발생해 효율성이 낮아진다.
또한, hashtable이나 hashmap을 synchronized로 동기화하면, 맵 전체에 대해 잠금이 발생한다.... (<이게 말이 되나?)
synchronized는 사실 사용하지 않는 것을 디폴트로 깔고 간다고 한다.
atomic변수
public class RoundRobinAtomic implements LoadBalancerStrategy {
private final AtomicInteger currentIndex = new AtomicInteger(0);
@Override
public String getNextServer(List<String> healthyServers) {
if (healthyServers.isEmpty()) {
throw new IllegalStateException("No healthy servers available.");
}
int index = currentIndex.getAndUpdate(i -> (i + 1) % healthyServers.size());
return healthyServers.get(index);
}
}
일전에 계산기 과제에 이것을 사용했던 것처럼, 간단한 숫자계산과 같은 기본 연산에 유용하고 내부적으로 CAS 연산을 사용하여 매우 빠르지만 복잡한 데이터 구조를 처리하기 어렵다.
예를 들자면 맵이나 리스트같은 컬렉션의 원자적 업데이트를 처리할 수 없다.
이로 인해 멀티스레드 환경에서 대량의 데이터를 처리할 때 성능 병목이 발생할 수 있다.
특정 변수의 원자적 연산만을 보장하기에 데이터의 공유나 처리 범위가 제한되기 때문이다.
ThreadLocal
public class RoundRobinThreadLocal implements LoadBalancerStrategy {
private final ThreadLocal<Integer> threadLocalIndex = ThreadLocal.withInitial(() -> 0);
@Override
public String getNextServer(List<String> healthyServers) {
if (healthyServers.isEmpty()) {
throw new IllegalStateException("No healthy servers available.");
}
int index = threadLocalIndex.get();
threadLocalIndex.set((index + 1) % healthyServers.size());
return healthyServers.get(index);
}
}
threadlocal은 각 스레드마다 독립적인 변수를 제공하기 위해 설계된 클래스다.
동일한 threadlocal인스턴스를 사용하더라도, 각 스레드가 자신의 데이터에만 접근할 수 있다. 이는 데이터 공유로 인해 발생할 수 있는 동기화 문제를 없애며 특히 context정보를 저장하는 데 유용하다.
예를 들어서 사용자 인증정보, 트랜잭션 id, 로깅과 같은 데이터가 스레드별로 독립적이어야 할 때 유용하게 사용할 수 있다.
(aop랑 아이디어가 비슷한듯. aop는 로직 별로, threadlocal은 스레드 별로.. 함께 사용한다면 좋은 시너지가 날 것 같다. 멀티스레드 환경에서 효율적으로 데이터를 처리할 수 있을 듯. 음.. 한번 해볼까?)
java의 각 스레드는 생성될 때 jvm으로부터 thread stack이라는 독립적인 스택 메모리 공간을 할당받고, 이것을 활용하여 데이터를 각 스레드의 고유한 공간에 저장한다.
따라서 스레드 간 데이터가 서로 간섭하지 않으며 동기화가 필요없다.
os관점에서 본다면 이는 cpu캐시 친화적 구조를 가지는 것이다. 공유 데이터 접근에서 발생할 수 있는 컨텍스트 스위칭 비용이 제거되기 때문이다.
물론 단점도 존재한다. threadlocal변수는 스레드의 수명 동안 유지되므로, 스레드가 종료되지 않으면 메모리에서 해제되지 않을 수 있다.(자바의 가비지컬렉터가 이를 회수하지 않을 수가 있다.)
이를 방지하려면, 작업이 끝난 후 명시적으로 remove()메서드를 호출해야 한다.
또한 thread pool 환경에서는 스레드가 재사용되기 떄문에, 이전 작업의 threadlocal값이 남아있을 수 있다. 이로 인해 원치 않는 동작이 발생할 수 있으므로 꼭 remove()를 호출하자.
지나치게 남발하면 코드를 이해하고, 디버깅하기 어렵게 만든다.(이것조차 aop와 비슷하다.)
ConcurrentHashMap - 탄생 배경
public class RoundRobinConcurrentMap implements LoadBalancerStrategy {
private final ConcurrentHashMap<Thread, Integer> threadIndex = new ConcurrentHashMap<>();
@Override
public String getNextServer(List<String> healthyServers) {
if (healthyServers.isEmpty()) {
throw new IllegalStateException("No healthy servers available.");
}
int index = threadIndex.compute
(Thread.currentThread(),
(thread, i) -> (i == null ? 0 : (i + 1) % healthyServers.size()));
return healthyServers.get(index);
}
}
concurrentHashMap은 멀티스레드 환경에서 데이터를 공유하고자 할 때 성능 저하를 최소화하면서도 안전하게 사용할 수 있는 동시성 기반 Map 자료구조이다.
오늘날 대부분의 동기화 문제는 이것을 통해 간편히 해결이 가능하다고 한다.
탄생배경을 알아보자. 2004년 java1.5버전에 도입되었으며 비교적 최근(?) 기술이다.(함께 찾아본 threadlocal이 1998년이다.)
멀티스레드 환경에서 자주 사용되던 hashmap과 hashtable에는 각각의 한계가 존재했다.
hashmap은 동기화를 지원하지 않아서 데이터 불일치 문제가 발생했고, 이를 해결하기 위해 synchronized 키워드를 사용해야 했다.
hashtable은 모든 연산에서 동기화를 제공했지만 이로 인해 전체 맵을 잠그는 방식이 스레드 경합을 초래해 성능 저하를 일으켰다.(대충 멀티스레드 환경에서 락 경합이 발생하여 컨텍스트 스위칭 비용이 증가..)
concurrentHashMap은 이러한 문제를 해결하기 위해 설계되었다. 맵 전체를 잠그는 방식 대신, 세부적인 동작 단위에서 동기화와 비동기화를 조화롭게 사용하여 성능과 안전성을 동시에 확보한다.
ConcurrentHashMap - 동작 방식
위 그림을 보면 concurrentHashMap이 어떤 문제를 어떻게 해결했는지 직관적으로 알 수 있다.
concurrentHashMap의 가장 큰 특징은 lock striping(분할 잠금)과 CAS(compare-and-swap)연산을 사용한다는 점이다. 이를 통해 기존의 hashtable과 달리 맵 전체가 아닌 일부에 대해서만 동기화를 수행하며 성능을 최적화한다.
Lock Striping(분할 잠금) 이란?
concurrentHashMap은 내부적으로 데이터를 여러 segment로 나눈다. 각각의 세그먼트는 독립적으로 잠금을 관리하므로, 여러 스레드가 동시에 다른 세그먼트에서 작업할 수 있다.
예를 들어, 기본적으로 16개의 세그먼트로 나뉘어 있다면 최대 16개의 스레드가 동시 작업을 수행할 수 있다.
이는 전체 맵을 잠그는 방식에 비해 병렬성을 크게 높인다.
CAS연산은 os와 하드웨어 수준에서 지원되는 원자적 연산이다. 기존 값과 비교하여 원하는 값으로 교체하는 작업을 수행하는데, 잠금을 사용하지 않고도 데이터의 무결성을 유지하도록 도와준다.
synchronized와 달리, 읽기 작업에서는 잠금을 사용하지 않고 최신 상태의 데이터를 보장한다. 덕분에 읽기 작업이 많은 환경에서도 높은 성능을 제공한다.
정리하자면 CAS와 세그먼트 등을 이용하여, 컨텍스트 스위칭을 최소화하는 설계로 인해 동기화에 따른 성능 저하를 크게 줄였다.
부하테스트용 로깅에 threadlocal을 사용하자
@Slf4j
@Aspect
@Component
public class LoggingAspect {
private final ThreadLocal<Long> startTimeThreadLocal = new ThreadLocal<>();
@Around("execution(* com.example.loadbalancer.common.RequestProcessor.processRequest(..))")
public Object logProcessRequest(ProceedingJoinPoint joinPoint) throws Throwable {
String methodName = joinPoint.getSignature().toShortString();
//스레드로컬 이용해 시작시간 측정 데이터 독립성 보장
startTimeThreadLocal.set(System.currentTimeMillis());
log.info("logging {} started at {}", methodName, startTimeThreadLocal.get());
try {
Object result = joinPoint.proceed();
long endTime = System.currentTimeMillis();
long startTime = startTimeThreadLocal.get();
log.info("logging {} completed at {}, total execution time: {} ms",
methodName, endTime, endTime - startTime);
log.info("logging {} returned: {}", methodName, result);
return result;
} catch (Exception e) {
long errorTime = System.currentTimeMillis();
log.error("logging {} : error at {}: {}", methodName, errorTime, e.getMessage());
throw e;
} finally {
//메모리 누수 방지 필수
startTimeThreadLocal.remove();
}
}
}
threadlocal에 대해 공부하다가 aop와 유사성을 많이 느꼈다.
aop는 부가적인 로직을 캡슐화해서 재사용성을 높이고, threadlocal은 이를 스레드별로 안전하게 관리하도록 돕도록 하기에
이들을 결합한다면 꽤 좋은 시너지가 날것같아서 부하테스트용 로깅 로직에 사용해보았다.
그리고 부하테스트 시에 멀티스레드 환경에서 많은 요청이 들어올 테니 동기화 처리가 꼭 필요할 것이다.
코드에서 각 스레드가 고유의 시작 시간 데이터를 저장/관리한다. 동일한 변수를 공유하지만, 각 스레드마다 별도로 저장되므로 데이터의 독립성이 보장될 것이다.
finally블록에서 remove()를 통해 메모리 누수를 방지해주었다.
@Component
@Slf4j
public class MetricsRecorder {
private final AtomicInteger totalRequests = new AtomicInteger(0);
private final AtomicInteger errorRequests = new AtomicInteger(0);
public void incrementRequest() {
totalRequests.incrementAndGet();
}
public void incrementError() {
errorRequests.incrementAndGet();
}
public void logMetrics() {
int total = totalRequests.get();
int errors = errorRequests.get();
double errorRate = total > 0 ? (errors / (double) total) * 100 : 0;
String logMessage = String.format("Metrics - Total Requests: %d, Errors: %d, Error Rate: %.2f%%",
total, errors, errorRate);
log.info(logMessage);
logMetricsToFile(logMessage);
}
public void resetMetrics() {
totalRequests.set(0);
errorRequests.set(0);
}
public void logMetricsToFile(String logMessage) {
try (FileWriter writer = new FileWriter("metrics.log", true)) {
writer.write(LocalDateTime.now() + " - " + logMessage + System.lineSeparator());
} catch (IOException e) {
e.printStackTrace();
}
}
}
tps와 오류 발생률을 로깅하는 클래스는 아토믹변수를 사용하고, 로그파일이 생성되도록 했다.
첫 부하테스트!
https://github.com/roqkfchqh/loadbalancer
GitHub - roqkfchqh/loadbalancer
Contribute to roqkfchqh/loadbalancer development by creating an account on GitHub.
github.com
내 인생 첫 부하테스트를 진행해보자! 라운드로빈 알고리즘을 위처럼 atomic, synchronized, threadlocal, concurrentHashMap을 각각 이용하여 구현해 보았다. 전략패턴을 사용해 각 알고리즘을 쉽게 스위칭 할 수 있도록 설계했다.
각각의 전략을 빈으로 등록하여 관리하자.
이곳 properties파일에서 설정을 건드리면 알고리즘을 바꾸고, 서버를 추가할 수 있도록 해놓았다.
오늘 사용할 부하테스트 툴은 apache jmeter이다. 한국에서 가장 많이 사용한다고 한다.
설치하고 실행하면 gui에서는 간단하게 테스트플랜 파일만 생성하고 부하테스트는 cli로 하는 것을 권장하고 있다.
하란대로 해보자.
부하테스트를 할 때의 부하의기준을 잘 몰라서 일단 사용자 100명에 텀은 10초로 뒀다. 루프도 10회로 설정하고, 내 로드밸런서 로컬 서버로 보낼 http request를 작성하면 완료된다.
부하테스트를 위해 도커에 서버 10개를 올려놓은 상태다. 설정한 테스트플랜을 이용해 바로 실행해보자!
우선 synchronized방식부터 ㅇㅅㅇ
진지하게 어떻게 해석할진 모르겠지만 아무튼 다른것도 켜보면 알게되지않을까 푸하하하
일단 synchronized라서 에러는 전혀 발생하지 않은 모습이다
근데 오늘 errorrate가 0.01%이상이 나올 것 같지가 않다 워낙 단순한 api라 ...
이번엔 atomic으로 해보자.
................................................. 왜 다 고놈이 고놈임?
아무래도 부하테스트가 처음이라 테스트플랜을 제대로 잘못 짠 것 같다. 일단 error가 0개인게 말이 안된다.. 내 눈에 보이는 콘솔창의 에러만 몇개인데........
내일 부하테스트에 관한 트러블슈팅을 진행해야겠다.