Загружаем каталог…
Загружаем каталог…
해당 PR을 날리고 정리한 글 이전 포스팅에서는 Redisson 분산 락(Distributed Lock)과 Facade 패턴을 도입하여 JUnit 다중 스레드 환경에서 동시성 문제(Race Condition)를 해결하는 과정을 다뤘다. 하지만 테스트 코드가 통과했다고 해서 실서비스에서도 안전하다고 단정할 수 있을까? 단일 JVM 메모리 안에서 스레드 풀을 돌리는 것과, 실제 수많은 유저가 네트워크(HTTP)를 타고 들어오는 부하 환경은 다르다. 실제 트래픽 환경에서는: 톰캣(Tomcat) 스레드 풀 경합과 실제 TCP 네트워크 커넥션 비용이 발생한다. Spring Security와 JWT 인증 필터 체인을 통과하는 CPU 오버헤드가 추가된다. 요청 역직렬화(Jackson) 및 네트워크 지연(Latency)이 동시성 경합 시간에 변수를 만든다. 이번 글에서는 오픈소스 부하 테스트 도구인 Locust 를 도입하여, 실제 HTTP 트래픽 환경에서 선착순 예매 API의 동시성 정합성(오버부킹 0%)을 검증하고, 튜닝 전 대조군(락 미적용, Offset 페이징)과 튜닝 후의 성능 격차를 하나의 대시보드에서 실측 비교한 전 과정을 기록한다. 파이썬 문법을 전혀 모르는 백엔드 개발자라도 이 글 하나만 보고 그대로 복사해 실행하면 스스로 부하 테스트 스크립트를 작성하고 튜닝 결과를 검증할 수 있도록 기초부터 트러블슈팅까지 아주 세세하게 작성했다. 1. 부하 테스트 도구 선정: 왜 JMeter가 아닌 Locust인가? 부하 테스트를 시작할 때 가장 많이 거론되는 3대장은 Apache JMeter , nGrinder , Locust 다. 비교 항목 Apache JMeter nGrinder Locust 시나리오 작성 방식 복잡한 GUI / 마우스 클릭 / XML Jython / Groovy 스크립트 순수 Python 코드 (Code-as-Test) 동시성 모델 1 User = 1 OS 스레드 (Heavy) 스레드 기반 (Heavy) 경량 비동기 코루틴 (gevent Event-loop) 형상 관리 (Git) XML 포맷이라 Diff/리뷰 어려움 별도 에이전트/컨트롤러 DB 관리 단일 .py 파일로 Git 버전 관리 완벽 로컬 리소스 소모 높음 (JVM 힙 메모리 부담) 보통 (인프라 구성 필요) 매우 낮음 (맥북 한 대로 수천 가상 유저 가능) 학습 곡선 GUI 메뉴 위치 외우느라 지침 환경 구축이 비교적 까다로움 파이썬 기초만 알면 10분 만에 스크립트 작성 내가 Locust를 선택한 결정적 이유 코드 기반의 테스트 (Code-as-Test) GUI 환경에서 클릭하며 헤더를 넣고 변수를 바인딩하는 방식은 형상 관리가 어렵고 협업하기 번거롭다. Locust는 익숙한 코드로 모든 로직(로그인 토큰 발급, 헤더 주입, 특정 상태 코드 예외 분기)을 완벽하게 통제할 수 있다. 비동기 코루틴( gevent )의 압도적 효율 JMeter는 가상 유저 1,000명을 만들려면 1,000개의 무거운 OS 스레드를 띄워야 한다. 그러다 보면 부하를 받는 서버가 아니라 부하를 쏘는 내 로컬 노트북이 먼저 뻗어버린다. 반면 Locust는 파이썬의 비동기 경량 코루틴 라이브러리인 gevent 를 사용하므로, 단일 스레드에서도 수천 개의 가상 유저(Greenlet)를 거뜬하게 시뮬레이션 한다. 3. 직관적인 실시간 Web UI 별도 플러그인 없이도 초당 처리량(RPS), 응답 시간 퍼센타일(p50, p95, p99), 에러율을 실시간 차트로 시각화해 준다. 2. Locust 설치 및 Mac 환경 트러블슈팅 2.1 Mac (Homebrew) 설치 터미널에서 아래 명령어를 입력하면 1분 만에 설치가 완료된다. # Homebrew를 통한 설치 (가장 추천) brew install locust # 설치 확인 locust --version # Locust 2.x.x 출력 확인 💡 트러블슈팅: pip install locust 시 PEP 668 에러 발생 최신 macOS(Python 3.11 이상)에서 pip3 install locust 를 날리면 다음과 같은 에러가 뜨면서 설치가 거부된다. error: externally-managed-environment 이는 Homebrew 파이썬 환경이 시스템 패키지 오염을 방지하기 위해 강제한 안전장치(PEP 668) 때문이다. 해결책 : 시스템 전역 pip 대신 위처럼 brew install locust 로 설치하거나, 별도 가상환경( python3 -m venv venv && source venv/bin/activate )을 만들어 설치하면 된다. 3. locustfile.py 해부 프로젝트 루트 디렉토리에 locustfile.py 라는 이름으로 파일을 만들면 Locust가 이를 자동으로 테스트 시나리오로 인식한다. 직접 작성한 전체 코드를 보고, 파이썬 문법을 라인별로 분석해 본다. 3.1 전체 스크립트 ( locustfile.py ) from locust import HttpUser, task, between # 전역 토큰 캐시 (모든 유저가 토큰을 공유하여 중복 로그인 방지) CACHED_TOKEN = None class TicketingUser(HttpUser): # 가상 유저 간 대기 시간 (0.1 ~ 0.5초) wait_time = between(0.1, 0.5) def on_start(self): """ 가상 유저 생성 시: 1. base_url 끝의 슬래시(/)를 안전하게 제거하여 더블 슬래시(//api/v1) 방지 2. JWT 토큰을 획득하여 세션 헤더에 주입 """ global CACHED_TOKEN if self.client.base_url: self.client.base_url = self.client.base_url.rstrip("/") if not CACHED_TOKEN: res = self.client.post( "/api/v1/users/login", json={"email": "admin@test.com", "password": "password123!"}, name="[Setup] JWT 토큰 발급 로그인" ) if res.status_code == 200: CACHED_TOKEN = res.json().get("data", {}).get("accessToken") print(f"[Locust] JWT 인증 토큰 발급 완료: {CACHED_TOKEN[:25]}...") elif res.status_code == 400 or res.status_code == 404: # 계정이 없는 경우 회원가입 후 재로그인 self.client.post( "/api/v1/users/signup", json={"email": "admin@test.com", "password": "password123!", "provider": "LOCAL"}, name="[Setup] 회원가입" ) res2 = self.client.post( "/api/v1/users/login", json={"email": "admin@test.com", "password": "password123!"}, name="[Setup] 재로그인" ) if res2.status_code == 200: CACHED_TOKEN = res2.json().get("data", {}).get("accessToken") if CACHED_TOKEN: self.client.headers["Authorization"] = f"Bearer {CACHED_TOKEN}" @task(3) def reserve_ticket(self): """ [최적화] 선착순 티켓 예매 (Redisson 분산 락 적용) """ ticket_id = 148 with self.client.post( f"/api/v1/orders/{ticket_id}", catch_response=True, name="/api/v1/orders/[ticketId] (분산 락 적용)" ) as response: if response.status_code == 200: response.success() elif response.status_code == 400: res_json = response.json() msg = res_json.get("message", "") if "매진" in msg or "SOLD_OUT" in msg or "락" in msg: response.success() else: response.failure(f"비즈니스 에러: {msg}") elif response.status_code == 401: response.failure("인증 실패 (401)") else: response.failure(f"서버 에러: {response.status_code}") @task(2) def reserve_ticket_without_lock(self): """ [대조군] 선착순 티켓 예매 (분산 락 미적용 - 동시성 충돌 유발) """ ticket_id = 148 with self.client.post( f"/api/v1/orders/{ticket_id}/no-lock", catch_response=True, name="/api/v1/orders/[ticketId]/no-lock (락 미적용)" ) as response: if response.status_code == 200: response.success() elif response.status_code == 400: res_json = response.json() msg = res_json.get("message", "") if "매진" in msg or "SOLD_OUT" in msg: response.success() else: response.failure(f"동시성 충돌/비즈니스 에러: {msg}") elif response.status_code == 401: response.failure("인증 실패 (401)") else: response.failure(f"서버 에러 (충돌): {response.status_code}") @task(1) def get_orders_no_offset(self): """ [최적화] 100만 건 대용량 주문 목록 No-Offset 커서 페이징 조회 (O(1) Seek 속도 검증) """ self.client.get( "/api/v1/orders?size=20", name="/api/v1/orders (No-Offset 커서 페이징)" ) @task(1) def get_orders_offset(self): """ [대조군] 100만 건 대용량 주문 목록 Offset 페이징 조회 (뒤쪽 4만 번째 페이지 Full Scan 지연 비교용) """ self.client.get( "/api/v1/orders/offset?page=40000&size=20", name="/api/v1/orders/offset (Offset 페이징 - page 40000)" ) @task(2) def get_ticket_detail(self): """ [공통] 티켓 단건 상세 조회 """ ticket_id = 148 self.client.get( f"/api/v1/tickets/{ticket_id}", name="/api/v1/tickets/[ticketId] (티켓 상세 조회)" ) 3.2 파이썬 문법 및 Locust API 해설 1. class TicketingUser(HttpUser) HttpUser 는 Locust에서 HTTP 통신을 수행하는 가상 유저(Virtual User)의 기본 뼈대 클래스 다. 자바의 class TicketingUser extends HttpUser 와 동일하다. 이 클래스를 상속받으면 각 가상 유저 객체마다 self.client 라는 내장 HTTP 클라이언트 세션( requests.Session 기반)을 자동으로 갖게 된다. 2. wait_time = between(0.1, 0.5) 실제 사용자는 API 요청을 0초 간격으로 무한 연타하지 않는다. 화면을 보거나 클릭을 망설이는 시간(Pacing)이 존재한다. between(0.1, 0.5) 는 하나의 작업을 마친 가상 유저가 다음 작업을 수행하기 전까지 0.1초~0.5초 사이의 랜덤한 시간 동안 휴식(Sleep) 하게 만든다. 3. def on_start(self): (초기화 라이프사이클 훅 & URL 정규화) JUnit의 @BeforeEach 와 완전히 동일한 역할을 한다. 가상 유저가 스폰(생성)될 때 딱 1번 실행된다. 가상 유저가 활동하기 전에 필요한 로그인, JWT 토큰 발급, 세션 헤더 주입 등의 사전 준비(Setup) 작업을 이곳에 배치한다. 특히 self.client.base_url = self.client.base_url.rstrip("/") 를 넣어 UI Host 끝에 실수로 붙은 슬래시를 자동 제거하여 //api/v1/... 로 인한 Spring Security 401 차단을 사전에 방어했다. 4. global CACHED_TOKEN (전역 토큰 캐싱을 통한 인증 부하 격리) 파이썬에서는 함수 안에서 전역 변수의 값을 수정하려면 global 변수명 을 명시해야 한다. 100명의 가상 유저가 모두 각자 로그인을 날리면 서버가 불필요한 인증 부하를 받으므로, 최초 1명의 유저가 토큰을 받아 전역 변수에 넣어두고 나머지 유저들이 이 토큰을 재사용하도록 설계했다. 5. 계정 자동 생성 Fallback 로직 ( elif res.status_code in (400, 404): ) DB가 방금 초기화되었거나 로컬 환경에 admin@test.com 계정이 아직 없다면 최초 로그인 호출 시 400 또는 404 가 발생한다. 이때 테스트가 에러를 뿜으며 멈추지 않도록, elif res.status_code == 400 or res.status_code == 404: 분기에서 POST /api/v1/users/signup 으로 관리자 계정을 즉시 자동 생성한 뒤 곧바로 재로그인( res2 )하여 토큰을 발급 받도록 처리했다. 즉, DB 데이터 상태와 무관하게 스크립트 스스로 계정을 만들고 복구하여 테스트를 완주시키는 방어 코드 다. 6. @task(가중치) 데코레이터 가상 유저가 수행할 반복 작업(시나리오)을 지정한다. 괄호 안의 숫자는 실행 확률(가중치, Weight) 을 뜻한다: 예매(분산 락 적용): @task(3) (비중: 3 / 9 = 33.3%) 예매(락 미적용 대조군): @task(2) (비중: 2 / 9 = 22.2%) 티켓 상세 조회: @task(2) (비중: 2 / 9 = 22.2%) No-Offset 커서 페이징: @task(1) (비중: 1 / 9 = 11.1%) Offset 페이징 대조군: @task(1) (비중: 1 / 9 = 11.1%) 이렇게 가중치를 두면 실제 사용자들이 티켓 상세를 구경하고, 목록을 보면서, 선착순 예매 버튼을 집중적으로 누르는 실제 트래픽 패턴을 완벽히 모사할 수 있다. 7. name="..." 파라미터 (URL 동적 파라미터 라벨 그룹화) 만약 self.client.post(f"/api/v1/orders/{ticket_id}") 를 그냥 호출하면, Locust 대시보드에 /api/v1/orders/1 , /api/v1/orders/2 처럼 URL별로 라인이 갈라져 통계가 엉망이 된다. name="/api/v1/orders/[ticketId]" 처럼 이름을 명시해 주면, 동적 파라미터가 포함된 요청도 하나의 통계 행으로 예쁘게 묶인다. 8. catch_response=True 와 response.success() 기본적으로 Locust는 HTTP 상태 코드가 4xx 나 5xx 이면 무조건 '에러(Failures)'로 집계한다. 하지만 선착순 티켓팅에서는 100장이 다 팔린 뒤 101번째 유저부터 400 Bad Request (티켓이 모두 매진되었습니다) 를 받는 것은 정상적인 비즈니스 로직의 성공 이다. 이때 catch_response=True 를 컨텍스트 매니저( with )와 함께 쓰면 Locust의 자동 판정을 가로채서 개발자가 직접 판단할 수 있다: with self.client.post(..., catch_response=True) as response: if response.status_code == 400 and ("매진" in msg or "SOLD_OUT" in msg or "락" in msg): response.success() # 400 코드지만 통계상 성공으로 기록! else: response.failure("진짜 서버 장애") 4. 실전 부하 테스트 중 마주친 6가지 트러블슈팅 실제 서버를 띄우고 테스트를 진행하면서 수많은 에로사항을 마주쳤다. 이 문제들을 하나씩 파헤치고 해결한 과정이 이번 테스트의 가장 큰 수확이었다. 🔥 Issue 1. 가상 유저 100명이 동시 가입을 시도하여 BCrypt CPU 100% 폭주 현상 : 테스트 시작 버튼을 누르자마자 스프링 부트 서버의 CPU가 100%로 치솟으며 서버가 뻗었다. 원인 : 각 가상 유저마다 고유 계정을 만들어 주려고 on_start() 안에서 일제히 POST /api/v1/users/signup 을 때리도록 짰었다. 스프링 시큐리티의 BCryptPasswordEncoder 는 무차별 대입 공격을 막기 위해 의도적으로 수천 번의 해시 연산을 돌리는 무거운 CPU 바운드 작업이다. 100명이 찰나의 순간에 동시 가입을 시도하자 톰캣 스레드 100개가 일제히 암호화 연산에 매달리며 CPU를 장악하고 DB 커넥션 풀을 고갈시켰다. 해결 : 본 테스트의 목적은 '분산 락 선착순 예매' 와 '페이징 조회' 를 검증하려는 것이지, 암호화 알고리즘 벤치마크를 하는 것이 아니다. 사전 등록된 관리자 계정으로 로그인하여 토큰을 획득하고 메모리에 캐싱( CACHED_TOKEN )하여 전원이 공유하도록 변경했다. 🔥 Issue 2. Host 끝 슬래시( / )로 인한 100% 401 예외 (응답 크기 70 bytes) 현상 : Locust Web UI의 Host 입력창에 http://localhost:8080/ 를 적고 실행했더니 모든 요청이 100% 실패(Failures 100%)하고, 응답 크기가 정확히 70 bytes 로 찍혔다. 원인 : Host 끝에 / 가 붙은 상태에서 코드의 self.client.post("/api/v1/orders/148") 와 결합되면서 실제 요청 주소가 http://localhost:8080//api/v1/orders/148 처럼 더블 슬래시( // )가 되었다. Spring Security의 AntPathMatcher 는 // 가 들어간 비정상 URL을 화이트리스트( /api/v1/users/** )와 매칭하지 못하고 기본 규칙인 anyRequest().authenticated() 로 넘겨버렸다. 결국 인증되지 않은 요청으로 간주되어 CustomAuthenticationEntryPoint 가 동작했고, 70바이트짜리 에러 응답( {"code":"401","message":"Full authentication is required..."} )을 뱉었던 것이다. 해결 : Locust 코드 시작 시 self.client.base_url = self.client.base_url.rstrip("/") 를 넣어 주소 끝의 슬래시를 무조건 강제 제거하도록 방어 코드를 작성했다. 🔥 Issue 3. 파이썬 프로세스 미재시작으로 인한 '코드 미반영' 버그 현상 : locustfile.py 코드를 고쳤는데도 계속해서 이전과 똑같은 401 에러가 발생했다. 원인 : 백그라운드에서 이미 실행 중이던 파이썬 Locust 프로세스는 파일이 수정되어도 자동으로 리로드되지 않는다. 메모리 상에는 이전에 띄워둔 구버전 코드가 그대로 돌고 있었기 때문이다. 해결 : 실행 중이던 Locust 프로세스( ps -ef | grep locust )를 완전히 kill -9 로 종료한 뒤 새 코드로 다시 띄워 해결했다. 🔥 Issue 4. DB 초기화 후 비밀번호 해시 불일치로 인한 401 에러 현상 : 테스트 후 DB를 다시 복구하는 과정에서 admin@test.com 계정의 비밀번호 해시를 임의의 문자열로 넣었더니, Locust가 로그인할 때 "이메일 또는 비밀번호가 올바르지 않습니다(401)"가 발생했다. 원인 : 로그인이 실패하니 가상 유저들에게 JWT 토큰이 발급되지 못했고, 토큰 없이 보호된 API를 때리다 보니 연쇄적으로 모든 테스트가 401을 맞았다. 해결 : 스
То, что RADAR обнаружил и классифицировал для этой возможности. Это опубликованный источником текст, а не подтверждение, что предложение ещё действует.
Locust로 선착순 100명 티켓팅 부하 테스트하기: 파이썬 코드로 검증한 분산 락과 튜닝 전/후 13,000건 실측 비교. 해당 PR을 날리고 정리한 글 이전 포스팅에서는 Redisson 분산 락(Distributed Lock)과 Facade 패턴을 도입하여 JUnit 다중 스레드 환경에서 동시성 문제(Race Condition)를 해결하는 과정을 다뤘다. 하지만 테스트 코드가 통과했다고 해서 실서비스에서도 안전하다고 단정할 수 있을까? 단일 JVM 메모리 안에서 스레드 풀을 돌리는 것과, 실제 수많은 유저가 네트워크(HTTP)를 타고 들어오는 부하 환경은 다르다. 실제 트래픽 환경에서는: 톰캣(Tomcat) 스레드 풀 경합과 실제 TCP 네트워크 커넥션 비용이 발생한다. Spring Security와…
Открыть источник