Skip to content

Commit 480ef14

Browse files
authored
Merge: locust 부하 테스트 스크립트 작성, make파일 수정, core앱 등록 - 커맨드 등록
[Feature/locust] locust 부하 테스트 스크립트 작성, make파일 수정, core앱 등록 - 커맨드 등록
2 parents 4109984 + 3493e5f commit 480ef14

12 files changed

+245
-8
lines changed

Makefile

+11-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ MNG = manage.py
33
MKDIR = mkdir
44
LN = ln -sf
55
EXP = export
6-
76
DJSET = DJANGO_SETTINGS_MODULE
87

98
PTR = poetry
@@ -25,6 +24,9 @@ SETTING = --settings=$(CONF).$(SETDIR).$(SET)
2524
LOCSET = --settings=$(CONF).$(SETDIR).$(LOC)
2625
PRODSET = --settings=$(CONF).$(SETDIR).$(PROD)
2726

27+
LCST = locust
28+
CTCD = create_chat_locust_data
29+
2830
.PHONY: all
2931
all:
3032
@echo "Try 'make help'"
@@ -127,3 +129,11 @@ stop: ## docker db stop
127129
.PHONY: exec
128130
exec: ## enter the container
129131
$(DCK) exec -it $(DBCONT) /bin/sh
132+
133+
.PHONY: locust
134+
locust: ## start locust runserver
135+
$(LCST) -f $(a)
136+
137+
.PHONY: setlocust
138+
setlocust: ## make user, chatroom for locust test
139+
$(PY) $(MNG) $(CTCD)

apps/chat/consumers.py

+15-7
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import asyncio
12
import logging
2-
from typing import Any, Optional
3+
from collections import defaultdict
4+
from typing import Any, DefaultDict, Optional
35

46
from asgiref.sync import sync_to_async
57
from channels.db import database_sync_to_async
@@ -18,13 +20,14 @@
1820
save_remaining_messages_to_postgres,
1921
)
2022
from apps.notification.utils import chat_notification
21-
from apps.user.models import Account
2223

2324
logger = logging.getLogger(__name__)
2425
redis_conn = get_redis_connection("default")
2526

2627

2728
class ChatConsumer(AsyncJsonWebsocketConsumer): # type: ignore
29+
disconnect_locks: DefaultDict[str, asyncio.Lock] = defaultdict(asyncio.Lock)
30+
2831
def __init__(self, *args: Any, **kwargs: Any) -> None:
2932
super().__init__(*args, **kwargs)
3033
self.chat_group_name = ""
@@ -122,11 +125,16 @@ async def receive_json(self, content: dict[str, Any], **kwargs: Any) -> None:
122125

123126
# 소켓 연결 해제
124127
async def disconnect(self, close_code: int) -> None:
125-
# 레디스에 남은 메시지들을 데이터베이스에 저장
126-
if redis_conn.exists(f"{self.chat_group_name}_messages") and not check_opponent_online(self.chat_group_name):
127-
await database_sync_to_async(save_remaining_messages_to_postgres)(self.chat_group_name)
128-
# 레디스에 남은 그룹들 모두 해제
129-
await self.channel_layer.group_discard(self.chat_group_name, self.channel_name)
128+
lock = ChatConsumer.disconnect_locks[self.chat_group_name]
129+
# print(f"user id: {self.scope["user"].id} get disconnect lock")
130+
async with lock:
131+
# 레디스에 남은 메시지들을 데이터베이스에 저장
132+
if redis_conn.exists(f"{self.chat_group_name}_messages") and not check_opponent_online(
133+
self.chat_group_name
134+
):
135+
await database_sync_to_async(save_remaining_messages_to_postgres)(self.chat_group_name)
136+
# 레디스에 남은 그룹들 모두 해제
137+
await self.channel_layer.group_discard(self.chat_group_name, self.channel_name)
130138

131139
async def alert(self, event: dict[str, Any]) -> None:
132140
try:

apps/chat/views.py

+6
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from django.db import transaction
12
from django.db.models import Q
23
from django.http import HttpRequest, HttpResponse
34
from django.shortcuts import render
@@ -42,6 +43,10 @@ def get(self, request: Request) -> Response:
4243
""",
4344
)
4445
def post(self, request: Request) -> Response:
46+
product_uuid = request.data.get("product")
47+
lender_id = request.data.get("lender")
48+
if Chatroom.objects.filter(product=product_uuid, lender_id=lender_id).exists():
49+
return Response({"msg": "이미 개설된 채팅방 내역이 존재합니다."}, status=status.HTTP_400_BAD_REQUEST)
4550
serializer = serializers.CreateChatroomSerializer(data=request.data)
4651
if serializer.is_valid():
4752
serializer.save(borrower=request.user)
@@ -75,6 +80,7 @@ def get(self, request: Request, chatroom_id: int) -> Response:
7580
if. 남은 유저가 없다면? -> 채팅방 삭제
7681
"""
7782
)
83+
@transaction.atomic
7884
def delete(self, request: Request, chatroom_id: int) -> Response:
7985
try:
8086
chatroom = Chatroom.objects.get(id=chatroom_id)

apps/core/__init__.py

Whitespace-only changes.

apps/core/apps.py

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from django.apps import AppConfig
2+
3+
4+
class CoreConfig(AppConfig):
5+
default_auto_field = "django.db.models.BigAutoField"
6+
name = "apps.core"

apps/core/management/__init__.py

Whitespace-only changes.

apps/core/management/commands/__init__.py

Whitespace-only changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import random
2+
from datetime import datetime
3+
from typing import Any
4+
5+
from django.contrib.auth import get_user_model
6+
from django.core.management.base import BaseCommand, CommandError
7+
8+
from apps.category.models import Category
9+
from apps.chat.models import Chatroom
10+
from apps.product.models import Product
11+
12+
User = get_user_model()
13+
14+
15+
class Command(BaseCommand):
16+
help = "Create test data with a specified number of chatrooms and associated users."
17+
18+
def handle(self, *args: Any, **kwargs: Any) -> None:
19+
try:
20+
self.stdout.write(self.style.MIGRATE_LABEL("테스트에 사용할 채팅방의 수를 입력해주세요."))
21+
input_num = input()
22+
if not input_num.isdigit():
23+
raise CommandError("숫자만 입력해주세요.")
24+
chatroom_number = int(input_num)
25+
if chatroom_number < 1:
26+
raise CommandError("1 이상의 수를 입력해 주세요.")
27+
for i in range(0, chatroom_number * 2, 2):
28+
# 테스트를 진행할 두 유저 생성
29+
user1, created1 = User.objects.get_or_create(
30+
email=f"test{i}@example.com",
31+
nickname=f"test_user{i}",
32+
)
33+
user1.set_password("password123@")
34+
user1.save()
35+
36+
if created1:
37+
self.stdout.write(self.style.SUCCESS(f"Successfully created user: {user1.nickname}"))
38+
else:
39+
self.stdout.write(self.style.WARNING(f"User already exists: {user1.nickname}"))
40+
41+
user2, created2 = User.objects.get_or_create(
42+
email=f"test{i+1}@example.com",
43+
nickname=f"test_user{i+1}",
44+
)
45+
user2.set_password("password123@")
46+
user2.save()
47+
48+
if created2:
49+
self.stdout.write(self.style.SUCCESS(f"Successfully created user: {user2.nickname}"))
50+
else:
51+
self.stdout.write(self.style.WARNING(f"User already exists: {user2.nickname}"))
52+
53+
# category 모델 생성
54+
category, created3 = Category.objects.get_or_create(name=f"test-category-{i}")
55+
if created3:
56+
self.stdout.write(self.style.SUCCESS(f"Successfully created category: {category.name}"))
57+
else:
58+
self.stdout.write(self.style.WARNING(f"category already exists: {category.name}"))
59+
60+
# product 모델 생성
61+
product, created4 = Product.objects.get_or_create(
62+
name=f"test product-{i/2}",
63+
product_category=category,
64+
lender=user1,
65+
purchase_date=datetime.now(),
66+
purchase_price=random.randint(10000, 100000),
67+
rental_fee=random.randint(3000, 5000),
68+
condition="good",
69+
)
70+
if created3:
71+
self.stdout.write(self.style.SUCCESS(f"Successfully created category: {product.name}"))
72+
else:
73+
self.stdout.write(self.style.WARNING(f"category already exists: {product.name}"))
74+
75+
# 테스트를 진행할 두 유저가 판매자, 대여자로 참가한 채팅방을 생성
76+
chatroom = Chatroom.objects.create(
77+
product=product, lender=user1, borrower=user2, lender_status=True, borrower_status=True
78+
)
79+
self.stdout.write(self.style.SUCCESS(f"Successfully created chatroom: {chatroom.id}"))
80+
81+
self.stdout.write(
82+
self.style.SUCCESS(f"Successfully created {chatroom_number} chatrooms with product, 2 users")
83+
)
84+
except CommandError as e:
85+
self.stdout.write(self.style.ERROR(str(e)))
86+
except Exception as e:
87+
self.stdout.write(self.style.MIGRATE_LABEL(str(e)))

config/settings/base.py

+1
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@
6969
"apps.notification",
7070
"apps.like",
7171
"apps.mypage",
72+
"apps.core",
7273
]
7374

7475
INSTALLED_APPS = DJANGO_SYSTEM_APPS + CUSTOM_USER_APPS

config/settings/local.py

+4
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
SESSION_COOKIE_SAMESITE = "Lax"
6666
CSRF_COOKIE_SAMESITE = "Lax"
6767
CSRF_COOKIE_HTTPONLY = False
68+
# SESSION_ENGINE = 'django.contrib.sessions.backends.signed_cookies'
6869

6970
# djangorestframework-simplejwt 관련 설정
7071
SIMPLE_JWT = {
@@ -215,5 +216,8 @@
215216
DJANGO_SUPERUSER_PASSWORD = env("DJANGO_SUPERUSER_PASSWORD")
216217
REDIRECT_URI = env("REDIRECT_URI")
217218
KAKAO_CLIENT_ID = env("KAKAO_CLIENT_ID")
219+
KAKAO_CLIENT_SECRET = env("KAKAO_CLIENT_SECRET")
218220
GOOGLE_CLIENT_ID = env("GOOGLE_CLIENT_ID")
219221
GOOGLE_SECRET = env("GOOGLE_SECRET")
222+
NAVER_CLIENT_ID = env("NAVER_CLIENT_ID")
223+
NAVER_CLIENT_SECRET = env("NAVER_CLIENT_SECRET")

pyproject.toml

+2
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ django-filter = "^24.2"
3333
whitenoise = "^6.6.0"
3434
requests = "^2.32.0"
3535
sentry-sdk = {extras = ["django"], version = "^2.3.1"}
36+
locust = "^2.28.0"
37+
locust-plugins = {extras = ["websocket"], version = "^4.4.3"}
3638

3739

3840
[tool.poetry.group.dev.dependencies]

tools/test_chat_locust.py

+113
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import json
2+
import os
3+
from random import choice
4+
5+
import django
6+
import requests
7+
from django.db.models import Q
8+
from locust import between, task
9+
from locust_plugins.users.socketio import SocketIOUser
10+
from websocket import WebSocketConnectionClosedException
11+
12+
# Django 설정 모듈 지정
13+
os.environ["DJANGO_SETTINGS_MODULE"] = "config.settings.settings"
14+
15+
# Django 초기화
16+
django.setup()
17+
18+
from apps.chat.models import Chatroom
19+
20+
21+
def get_test_chatroom_info(): # type: ignore
22+
# Q 객체를 사용하여 OR 조건으로 필터링
23+
24+
filtered_chatrooms = Chatroom.objects.filter(
25+
Q(borrower__nickname__icontains="test") | Q(lender__nickname__icontains="test")
26+
)
27+
# 필터링된 채팅방의 ID, 대여자, 판매자 목록을 list[dict]로 반환
28+
return filtered_chatrooms.values("id", "borrower", "lender")
29+
30+
31+
from apps.user.models import Account
32+
33+
34+
def get_account_model(user_id: int) -> Account:
35+
36+
return Account.objects.get(id=user_id)
37+
38+
39+
# 채팅방 데이터 리스트
40+
chatrooms = get_test_chatroom_info() # type: ignore
41+
42+
43+
class WebSocketUser(SocketIOUser):
44+
wait_time = between(1, 5) # type: ignore
45+
46+
def on_start(self) -> None:
47+
# 랜덤한 채팅방과 유저를 선택
48+
chatroom_info = choice(chatrooms)
49+
self.chatroom_id = chatroom_info["id"]
50+
self.borrower = get_account_model(chatroom_info["borrower"])
51+
self.lender = get_account_model(chatroom_info["lender"])
52+
self.sender = choice([self.borrower, self.lender])
53+
# 선택된 유저로 로그인 요청 보내고 csrf, access 토큰 가져오기
54+
login_req = requests.post(
55+
f"https://{os.environ.get("BACKEND_HOST")}/api/users/login/",
56+
data={"email": self.sender.email, "password": "password123@"}
57+
)
58+
csrf = login_req.cookies.get("csrftoken")
59+
session_cookie = login_req.cookies.get("sessionid")
60+
access = login_req.cookies.get("adfdfd")
61+
print(f"csrf: {csrf}\n, session: {session_cookie}\n, access: {access}")
62+
63+
print(f"chatroom_id: {self.chatroom_id}, borrower: {self.borrower.nickname}, lender: {self.lender.nickname}")
64+
65+
if csrf and access and session_cookie:
66+
self.connect(
67+
f"ws://{os.environ.get("BACKEND_HOST")}/ws/chat/{self.chatroom_id}/",
68+
header=[f"X-CSRFToken: {csrf}", f"Authorization: Bearer {access}"],
69+
cookie="sessionid=" + session_cookie,
70+
)
71+
print(
72+
f"Connected to chatroom_id: {self.chatroom_id} as borrower: {self.borrower.nickname}, lender: {self.lender.nickname}"
73+
)
74+
else:
75+
raise ValueError("로그인 요청 실패")
76+
77+
@task
78+
def send_message(self) -> None:
79+
if self.sender == self.borrower:
80+
message = {
81+
"text": f"Hello! i want borrow your clothes!",
82+
"sender": self.sender.nickname,
83+
}
84+
self.ws.send(json.dumps(message))
85+
if self.sender == self.lender:
86+
message = {
87+
"text": f"Of course! When are you going to borrow and return it?",
88+
"sender": self.sender.nickname,
89+
}
90+
self.ws.send(json.dumps(message))
91+
92+
def on_message(self, message: bytes) -> None:
93+
try:
94+
if message:
95+
response = json.loads(message)
96+
print(f"Received: {response}")
97+
except json.JSONDecodeError:
98+
print(f"Received message is not valid JSON")
99+
100+
def on_stop(self) -> None:
101+
self.ws.close()
102+
103+
def receive_loop(self) -> None:
104+
while True:
105+
try:
106+
message = self.ws.recv()
107+
import logging
108+
109+
logging.debug(f"WSR: {message}")
110+
self.on_message(message)
111+
except WebSocketConnectionClosedException as e:
112+
print(f"소켓 연결 종료됨 \nMessage :{e}")
113+
break

0 commit comments

Comments
 (0)