🔍 토이 프로젝트 부하테스트의 배경
토이 프로젝트 개발 완료 후, 동시에 처리 가능한 사용자 수와 응답 속도를 확인할 필요가 있었습니다. 이는 시스템 성능을 평가하고, 성능 저하가 발생할 경우 원인을 분석하여 문제를 해결함으로써 성능 향상 수준을 측정하기 위함입니다.
우선 아래는 현재 토이프로젝트의 인프라 아키텍처 입니다. NLB, ALB, 각각의 서버가 1대씩 배포되어 있는 상황입니다. 이 때 SSL Offloading은 ALB에서 처리하고 있습니다.
현재 아키텍처는 간단하고 초기 배포 및 운영에 적합하지만, 비용 제약으로 인해 고가용성과 확장성을 갖추지 못한 상태입니다. 따라서, 부하 테스트를 통해 병목 구간을 식별하고, 향후 성능 개선 및 아키텍처 확장을 위한 기초 데이터를 확보할 필요가 있습니다.
📝 K6를 통한 부하테스트
K6란 Grafana에서 제공하는 성능 테스트 및 부하 테스트를 위한 오픈 소스 도구 입니다.
웹 애플리케이션의 API 성능을 측정하는데에 사용됩니다. 동시 접속, 가상의 유저, 반복 횟수 등을 설정해서 서버 응답 시간, 처리량 등을 확인할 수 있습니다. 또한 가장 큰 장점은 Javascript 코드를 통해 테스트 시나리오를 작성할 수 있다는 것이고 Grafana에서 개발되어 Grafana와 연동이 수월합니다. 자세한 내용은 다음 K6 공식 링크에서 확인 가능합니다.
아래는 테스트 시나리오를 Javascript로 작성한 코드입니다.
options을 살펴보면 동시에 요청을 보낼 가상의 사용자(vus) 수와 테스트 동안 수행될 총 요청 횟수(iterations)를 지정합니다. iterations는 가상 사용자들이 요청을 나누어 실행하므로 각 가상 사용자는 평균적으로 iterations / vus 만큼 요청을 보냅니다.
해당 테스트에서는 사용자가 동시에 1번씩 주문 요청 API를 보내 최대 몇 명의 동접자가 접근이 가능하고 응답 속도는 어느 정도 나오는지 확인합니다.
요청을 보낼 API와 Body(Payload) 값을 지정하고 POST 요청을 전송합니다.
import http from 'k6/http';
export const options = {
vus: 200,
iterations: 200
};
export default function () {
const BASE_URL = 'https://imcommerce.shop/api/orders'; // 주문 API 엔드포인트
const payload = JSON.stringify({
productId: 22,
quantity: 1
});
const params = {
headers: {
'Content-Type': 'application/json',
}
};
// POST 요청 전송
const res = http.post(BASE_URL, payload, params);
}
동접자 200명의 경우
동시 접속자가 200명인 경우, 약 20%의 실패율이 발생했으며, http_req_duration(응답 시간)은 평균 2초로 확인되어 응답 속도가 상당히 느린 상황이었습니다. 이와 관련된 애플리케이션 로그를 확인한 결과, 다음과 같은 로그가 발생한 것을 확인할 수 있었습니다.
ERROR | | Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: java.lang.IllegalMonitorStateException: attempt to unlock lock, not locked by current thread by node id: 09e2b09d-ea0d-4816-8bb9-f89b7b88bd63 thread-id: 602] with root cause
attempt to unlock lock, not locked by current thread by node id 이슈 해결하기
상기의 로그는 현재 스레드가 락을 소유하지 않은 상태에서 락 해제를 시도했음을 경고하는 메시지 입니다. 이는 락을 소유하지 않은 상태에서 락을 해제하려는 비정상적인 동작을 나타내며, 일반적으로 코드에서 락 관리가 제대로 이루어지지 않았음을 의미합니다.
Lock 해제와 관련된 코드를 확인해보니 finally에서 현재 스레드가 락을 소유하고 있는지 확인하는 과정없이 락 해제를 시도하고 있었습니다.
@Aspect
@Component
@RequiredArgsConstructor
public class RedissonLockAspect {
private final RedissonClient redissonClient;
private static final Logger log = LoggerFactory.getLogger(RedissonLockAspect.class);
@Around("@annotation(com.sparta.common.annotation.RedissonLock)")
public void redissonLock(ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
RedissonLock annotation = method.getAnnotation(RedissonLock.class);
String lockKey = method.getName() + CustomSpringELParser.getDynamicValue(signature.getParameterNames(), joinPoint.getArgs(), annotation.value());
RLock lock = redissonClient.getLock(lockKey);
try {
boolean lockable = lock.tryLock(annotation.waitTime(), annotation.leaseTime(), TimeUnit.MILLISECONDS);
if (!lockable) {
log.info("Lock 획득 실패={}", lockKey);
return;
}
log.info("로직 수행");
joinPoint.proceed();
} catch (InterruptedException ex) {
log.info("에러 발생");
throw ex;
} finally {
log.info("락 해제");
lock.unlock();
}
}
}
해당 이슈를 해결하기 위해 RLock의 isHeldByCurrentThread() 메소드를 이용하여 현재 락이 스레드에 의해 소유되고 있는지 확인하는 조건문을 넣었습니다.
@Aspect
@Component
@RequiredArgsConstructor
public class RedissonLockAspect {
private final RedissonClient redissonClient;
private static final Logger log = LoggerFactory.getLogger(RedissonLockAspect.class);
@Around("@annotation(com.sparta.common.annotation.RedissonLock)")
public void redissonLock(ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
RedissonLock annotation = method.getAnnotation(RedissonLock.class);
String lockKey = method.getName() + CustomSpringELParser.getDynamicValue(signature.getParameterNames(), joinPoint.getArgs(), annotation.value());
RLock lock = redissonClient.getLock(lockKey);
try {
boolean lockable = lock.tryLock(annotation.waitTime(), annotation.leaseTime(), TimeUnit.MILLISECONDS);
if (!lockable) {
log.info("Lock 획득 실패={}", lockKey);
return;
}
log.info("로직 수행");
joinPoint.proceed();
} catch (InterruptedException ex) {
log.info("에러 발생");
throw ex;
} finally {
log.info("락 해제 시도");
if (lock.isHeldByCurrentThread()) {
lock.unlock();
log.info("락 해제 완료");
} else {
log.warn("현재 스레드가 락을 소유하고 있지 않아 해제하지 않음");
}
}
}
}
Lock과 관련된 AOP 로직을 변경하고 다시 200명의 부하테스트를 진행하였습니다. 그 결과 200명 모두 정상적으로 200 status를 반환하는 것을 확인했습니다. 하지만 여전히 응답 속도는 개선되지 않았습니다. 오히려 평균 속도가 2초에서 4초로 증가하였습니다.
응답속도 개선하기
응답 속도가 증가한 원인이 인프라 과부하인지 확인하기 위해 메트릭을 모니터링했지만, 별다른 병목 현상은 발견되지 않았습니다.
1. AWS RDS for MySQL 메트릭
CPU 사용률, 연결 수(Connection), IOPS 등을 확인했지만, 이상 징후나 급격한 변화는 발견되지 않았습니다.
2. 애플리케이션 서버(EC2) 메트릭
CPU, Read/Write latency 등을 확인했지만, 이상 징후나 급격한 변화는 발견되지 않았습니다.
서버 메모리 사용률도 2,000명 및 10,000명 기준에서 높지 않았던 점을 고려할 때, 인프라 문제는 아닌 것으로 판단했습니다.
로그 모니터링에서 주문 등록 처리 시 요청에서 응답까지의 소요시간(ms)을 확인해 본 결과 처음엔 1초 이내로 처리되다가 5초까지 시간이 늘어나는 것을 볼 수 있었습니다.
DB Connection 수 변경
애플리케이션에서 요청을 처리하는 시간이 DB Connection 수 제한으로 인한 병목인지 확인하기 위해 Connection 수를 30에서 60으로 증가시켜 테스트했습니다.
그러나 200명의 트래픽을 보냈을 때, 실제 연결된 커넥션 수는 증가하지 않았으며, 응답 시간에도 변화가 없었습니다.
Tomcat Thread 수 변경
이번에는 Tomcat 쓰레드 부족이 응답 속도에 영향을 주는지 확인하기 위해 EC2를 Scale-up한 후, 200명 트래픽을 보냈을 때의 쓰레드 수를 모니터링했습니다. 하지만 여전히 응답속도가 개선되지 않았습니다.
2 vcpu(2 쓰레드) / 4GiB
4 vcpu(2 쓰레드) / 16GiB
Redisson Lock 획득 시간 변경
마지막으로 애플리케이션 코드 문제로 인해 응답 시간이 증가한 것은 아닌지 확인하기 위해 다시 점검한 결과, Lock 획득 시간이 5초로 설정되어 있는 것을 발견했습니다.
import java.lang.annotation.*;
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface RedissonLock {
String value(); // Lock의 이름 (고유값)
long waitTime() default 5000L; // Lock획득을 시도하는 최대 시간 (ms)
long leaseTime() default 2000L; // 락을 획득한 후, 점유하는 최대 시간 (ms)
}
락 대기시간을 3초대로 변경하고 다시 200명 동접자 트래픽을 발생시켰더니 응답속도가 3초대로 개선되는것을 확인할 수 있었습니다.
추후 과제
락 대기 시간을 무작정 줄이는 것이 최적의 응답 속도 개선 방법인지 검토해야 하며, Redisson이 아닌 다른 툴을 활용한 대안도 고려할 예정입니다.
Redisson을 사용할 경우, 적절한 락 대기 시간과 점유 시간을 결정하기 위해 추가적인 분석이 필요합니다.
또한, 과도한 트래픽이 몰리는 사이트에서 대기번호를 부여하는 점을 고려하면 Queue를 활용하는 방식으로 동시성 제어가 가능할 것으로 예상됩니다.
'테스트' 카테고리의 다른 글
성능테스트 K6 결과 이해하기 (1) | 2025.01.06 |
---|