시험기간은 2주 하루에 예약 수용 가능한 인원은 최대 600 여 명
오전 8시에 예약이 오픈됩니다. 모든 스터디룸은 시험기간인 이유로 개인석 단위로 예약이 진행됩니다.
이전 스터디룸을 오픈 톡방을 통해서 예약을 진행했었는데 150명 가량 신청했던 기억이 있습니다.
모든 시간대를 동시다발적으로 예약한다면 저희 프로젝트의 WAS는 부하를 버틸 수 있는지 테스트를 해야합니다.
하루에 예약이 가능한 인원은 방 별로 SQL 문으로 확인해보니 아래와 같았습니다.
아래 데이터에 따르면 600여명이 예약이 가능합니다. 과연 우리 서버는 모든 인원을 수용할 수 있을까라는 의문이 들었습니다.
SELECT room_number, SUM(capacity)
FROM schedule
GROUP BY room_number
+-----------+-------------+
|room_number|SUM(capacity)|
+-----------+-------------+
|305-1 |96 |
|305-2 |72 |
|305-3 |48 |
|305-4 |48 |
|305-5 |48 |
|305-6 |48 |
|305-7 |48 |
|409-1 |96 |
|409-2 |96 |
+-----------+-------------+
현재 저희 시스템 아키텍쳐는 아래와 같습니다.
로그인이 필요한 API 부하테스트가 필요한가?
- 구글링하다가 나온 인프런 강사 분의 대답..
사실 생각해보면 당연히 필요한 테스트입니다. 우리 프로젝트는 학과 내 스터디룸 예약 시스템으로써 150명 이상의 유저가 몰릴 수 있습니다. 특히나 학과의 시험기간에는 트래픽이 몰릴 것이라고 예상이 되었습니다. 그렇기에 얼마나 많은 유저를 버틸 수 있는지 확인이 필요했고 궁금했습니다.
실제로 프로덕션에서 퍼포먼스가 얼마나 나올지 너무 궁금하고, 서버가 어느정도로 버티는지 알 수 있어야 스케일 아웃과 같은 대책을 세울 수 있을 것입니다.
생성된 스터디룸 예약을 동시다발적으로 예약하는 경우에 서버가 전부 버틸 수 있는지 확인해보기로 했습니다.
부하테스트 툴: K6 선택
- 메모리를 적게 사용하면서 비교적 많은 요청 수를 보낼 수 있음
- 사용법이 다른 부하테스트에 비해 러닝커브가 낮음
K6 부하테스트를 실행할 인스턴스 생성
- 배포된 서버에서 K6를 실행시키지않는 이유는 아래와 같습니다
- 우선 K6는 자체 OS를 활용해서 메모리를 활용해서 서버에 부하를 줘야함
- 따라서 배포된 WAS에 영향을 줄 수 있기에 독립적인 공간을 갖춰야함
- 스팩은 충분한 부하를 주고자 t3a.small로 설정해주었습니다.
이후 K6를 설치해주었다.
$ sudo gpg -k && /
sudo gpg --no-default-keyring --keyring /usr/share/keyrings/k6-archive-keyring.gpg --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69 && /
echo "deb [signed-by=/usr/share/keyrings/k6-archive-keyring.gpg] https://dl.k6.io/deb stable main" | sudo tee /etc/apt/sources.list.d/k6.list && /
sudo apt-get update && /
sudo apt-get install k6
K6 설치
테스트용 EC2 서버에서 WAS 구동
현재 제가 구성한 아키텍쳐는 아래와 같습니다.(ELB도 설치해주었습니다.)
- CORS에 ALB DNS 주소를 추가해주었습니다.
- 이후에 테스트용 DB 서버에 실제 스터디룸 예약 레코드를 삽입해주었습니다.
문제점: Target Group의 Health Check 설정 문제
K6 인스턴스에서 백엔드 어플리케이션에 접근 못하는 오류가 발생했습니다.
문제 상황
- 처음에 Health Check Path(
/health
)가 설정되지 않았음. - ALB는 Health Check를 통과한 EC2에만 요청을 보내기 때문에, Health Check가 실패하면 요청이 전달되지 않음.
해결 방법
Target Group에서 Health Check Path를 /health
설정 후 EC2에서 정상 응답(200 OK
)을 반환하자 Health Check가 통과되고 서비스가 정상 작동함.
K6가 설치된 EC2에서 ALB를 통해서 호출한 Spring 서버의 API
이제 로그인이 필요한 기능인 예약 시스템을 테스트해봐야합니다.
어떻게 하면 테스트를 진행할 수 있을까요?
저희 서비스는 인증 및 인가 방법으로 JWT를 사용하고 있습니다.
public class JwtTokenProvider {
// .. 다른 코드
public JwtToken generateToken(Authentication authentication) {
// 권한 가져오기
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.map(role -> role.startsWith("ROLE_") ? role : "ROLE_" + role)
.collect(Collectors.joining(","));
long now = (new Date()).getTime();
// Access Token 생성
Date accessTokenExpiresIn = new Date(now + ACCESS_TOKEN_EXPIRE_TIME);
String accessToken = Jwts.builder()
.setSubject(authentication.getName())
.claim("auth", authorities)
.setExpiration(accessTokenExpiresIn)
.signWith(key, SignatureAlgorithm.HS256)
.compact();
// Refresh Token 생성
String refreshToken = generateRandomRefreshToken();
return JwtToken.builder()
.grantType("Bearer")
.accessToken(accessToken)
.refreshToken(refreshToken)
.role(authorities)
.build();
}
따라서 스크립트에서 JWT를 생성해줘야합니다. 또한 JWT에 담긴 이메일 정보에 대한 유효성 검증이 이뤄지고 있기 때문에 JWT와 이에 대한 회원정보가 담겨있어야했습니다.
@RequiredArgsConstructor
public class ReservationController {
private final ReservationService reservationService;
// .. 다른 코드
@PostMapping("/reservations/individual")
public ResponseEntity<ResponseDto<String>> reserveIndividual(
@RequestHeader("Authorization") String authorizationHeader,
@Valid @RequestBody CreateReservationRequest request
) {
return ResponseEntity
.status(StatusCode.OK.getStatus())
.body(ResponseDto.of(reservationService.createIndividualReservation(authorizationHeader, request)));
}
로그인은 비동기적으로 사람들이 수행할 것이라 예상되어 미리 로그인되었다는 가정 하에 테스트를 하고 싶었습니다.
따라서 아래와 같은 방법으로 테스트를 해보고자 합니다.
회원 테이블에 더미 회원 정보를 저장해둔 뒤, 이 회원정보를 이용해서 JWT를 스크립트에서 직접 만들고 예약을 해보는 프로세스를 만들어보기로 했습니다.
600명의 가상 유저 만들기
import json
# 사용자 리스트 생성
users = []
for i in range(1, 601):
user = {
"username": f"user{i}",
"password": "password123",
"role": "ROLE_USER"
}
users.append(user)
# JSON 파일로 저장
with open("users.json", "w", encoding="utf-8") as f:
json.dump(users, f, indent=4, ensure_ascii=False)
MySQL의 회원 테이블에 가상 유저 정보를 저장하기 위한 INSERT 쿼리를 생성하기
import json
from datetime import datetime
# JSON 파일 로드
with open("users.json", "r", encoding="utf-8") as f:
users = json.load(f)
# SQL 파일 저장
sql_file = "users.sql"
with open(sql_file, "w", encoding="utf-8") as f:
f.write("INSERT INTO member (email, name, password, student_num, is_penalty, created_at, updated_at) VALUES\n")
values = []
for i, user in enumerate(users):
email = f"{user['username']}@hufs.ac.kr"
name = user['username']
password = user['password']
student_num = f"S{1000 + i}"
is_penalty = 0
created_at = updated_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
values.append(f"('{email}', '{name}', '{password}', '{student_num}', {is_penalty}, '{created_at}', '{updated_at}')")
f.write(",\n".join(values) + ";\n")
생성된 가상 유저들
스터디룸 예약 정보는 DataGrip을 이용해서 JSON으로 Export 해주었습니다.
이제 사용자 정보와 예약 정보를 바탕으로 스크립트를 작성해주려고 합니다.
JWT 생성 함수
import encoding from "k6/encoding";
import crypto from "k6/crypto";
// HMAC SHA256 서명 생성 (Spring과 동일한 Base64 디코딩)
const sign = (data, base64Secret) => {
const secret = encoding.b64decode(base64Secret, "rawstd"); // Base64 디코딩
const hasher = crypto.createHMAC("sha256", secret);
hasher.update(data);
return hasher.digest("base64")
.replace(/\//g, "_")
.replace(/\+/g, "-")
.replace(/=/g, ""); // JWT의 URL-safe 변환
};
// JWT 생성 (Spring 서버와 일치하도록 수정)
export const encodeJWT = (username, role, base64Secret) => {
const headerData = { "alg": "HS256" }; // typ 제거 (Spring과 동일)
const exp = Math.floor(Date.now() / 1000) + (2 * 60 * 60); // 2시간 후 만료
const payloadData = {
"sub": username, // 사용자 식별자
"auth": role.startsWith("ROLE_") ? role : "ROLE_" + role, // ROLE 접두어 추가
"exp": exp
};
// Base64 URL-safe 인코딩
const header = encoding.b64encode(JSON.stringify(headerData), "rawurl");
const payload = encoding.b64encode(JSON.stringify(payloadData), "rawurl");
const signature = sign(`${header}.${payload}`, base64Secret);
return `${header}.${payload}.${signature}`;
};
부하 테스트 스크립트
import http from 'k6/http';
import { check, sleep } from 'k6';
import { encodeJWT } from './util.js';
import { SharedArray } from 'k6/data';
import { Trend, Counter } from 'k6/metrics';
// Base64 인코딩된 시크릿 키
const JWT_SECRET = "c2VjcmV0LWtleS1mb3ItaWNlLXN0dWR5cm9vbS1hcHBsaWNhdGlvbi1zZWNyZXQta2V5LWZvci1pY2Utc3R1ZHlyb29tLWFwcGxpY2F0aW9u";
// 유저 정보 로딩
const users = new SharedArray('users', function () {
return JSON.parse(open('./users.json'));
});
const schedules = new SharedArray('schedules', function () {
return JSON.parse(open('./icestudyroom_schedule.json'))
.filter(s => s.status === "AVAILABLE");
});
// JWT 생성 및 사전 로딩 (Spring과 동일한 필드 사용)
export function setup() {
const tokens = users.map(user => {
return encodeJWT(user.username, user.role, JWT_SECRET);
});
return { tokens };
}
// 부하 테스트 옵션 (600 VUs, 2분간 실행)
export let options = {
vus: 600,
duration: '2m',
};
// 메트릭 생성
let reservationSuccess = new Counter('reservation_success_count');
let reservationFail = new Counter('reservation_fail_count');
let reservationTrend = new Trend('reservation_response_time');
// 개별 예약 요청 테스트
export default function (data) {
const token = data.tokens[(__VU - 1) % data.tokens.length];
const shuffledSchedules = schedules.slice().sort(() => 0.5 - Math.random());
const selectedSchedule = shuffledSchedules[__VU % shuffledSchedules.length];
if (!selectedSchedule) {
console.error("예약 가능한 스터디룸 없음!");
return;
}
const reservationPayload = JSON.stringify({
scheduleId: [selectedSchedule.id],
participantEmail: [],
roomNumber: selectedSchedule.room_number,
startTime: selectedSchedule.start_time,
endTime: selectedSchedule.end_time
});
const reservationHeaders = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
};
const res = http.post(
'http://icestudy-test-loadbalancing-1202239019.ap-northeast-2.elb.amazonaws.com/api/reservations/individual',
reservationPayload,
{ headers: reservationHeaders }
);
reservationTrend.add(res.timings.duration);
if (res.status === 200) {
reservationSuccess.add(1);
} else {
reservationFail.add(1);
}
check(res, {
'예약 성공': (r) => r.status === 200,
});
sleep(0.2);
}
K6 서버에 필요한 부하 코드 세팅
- users.json: 가상 유저의 정보가 담긴 JSON 파일
- icestudyroom_schedule.json: 실제 스터디룸 예약 정보
- script.js: 부하테스트 스크립트
부하테스트 수행
K6_WEB_DASHBOARD=true k6 run --http-debug="full" script.js <-- 네트워크 오류 로그 확인 옵션 추가 명령어
K6_WEB_DASHBOARD=true k6 run script.js
결과
- 600명의 예약 API 부하테스트를 서버는 버틸 수 있다는 것을 확인했다.
K6 Report
- 이제 모니터링을 추가하여 병목현상을 파악하고 해결하려고 한다.
'WEB > 트러블슈팅' 카테고리의 다른 글
[트러블 슈팅] 예외 전역 처리기 구현 및 응답, 예외 책임 분리 (0) | 2025.04.05 |
---|---|
[트러블 슈팅] 외부에서의 redis 접근으로 인한 복제 노드로 변환되는 문제 (0) | 2025.03.21 |
[트러블 슈팅] 복합키 인덱스 최적화 (0) | 2025.01.28 |
[트러블 슈팅] RTR 도입기 (0) | 2025.01.27 |
[트러블 슈팅] MySQL 시간대(Timezone) 설정 이슈 (0) | 2025.01.07 |