바뀜

편집 요약 없음
1번째 줄: 1번째 줄:  
== 개요 ==
 
== 개요 ==
https://channels.readthedocs.io/en/latest/index.html<nowiki/>를 참고하였다.
+
장고 채널 기능의 활용을 위해 간단한 채팅 앱 만들어보기.
   −
장고는 기본적으로 동기식으로, 채팅을 위해선 비동기식 처리가 필요하다.
+
=== 사전 준비 ===
 +
{| class="wikitable"
 +
!과정
 +
!설명
 +
!방법
 +
|-
 +
|장고
 +
|장고가 설치되어 있다고 가정한다.
 +
|[http://id8436.iptime.org:2786/mediawiki/index.php/1.%20%EC%9E%A5%EA%B3%A0%20%EA%B0%9C%EC%9A%94 1. 장고 개요]
 +
|-
 +
|채널 설치
 +
|채널이 설치되어 있다고 가정한다.
 +
|[http://id8436.iptime.org:2786/mediawiki/index.php/%EC%9E%A5%EA%B3%A0%20channels 장고 channels]
 +
|}
 +
= 기초준비 =
   −
이를 위해 Django Channels가 마련되어 있다.
+
== 채팅어플 만들기 ==
 
  −
ASGI(Async Server Gateway Interface)프로토콜은 WSGI를 계승하여 이와 잘 호환되도록 설계되어 있다.
  −
 
  −
ASGI는 비동기 요청인 웹 소켓을 처리하는 이벤트로 connect, send, receive, disconnect가 있다.
  −
 
  −
== 기초준비 ==
  −
 
  −
=== 채팅어플 만들기 ===
   
패키지설치부터 진행하기엔 중간점검이 어려워 틀 만들기를 먼저 수행한다.
 
패키지설치부터 진행하기엔 중간점검이 어려워 틀 만들기를 먼저 수행한다.
 
{| class="wikitable"
 
{| class="wikitable"
21번째 줄: 27번째 줄:  
|어플리케이션생성
 
|어플리케이션생성
 
|채팅을 위한 앱을 생성한다.
 
|채팅을 위한 앱을 생성한다.
앱을 생성하고 __init__.py와 views.py를 제외한 모든 것들을 지운다.
   
|django-admin startapp chat
 
|django-admin startapp chat
 
|-
 
|-
28번째 줄: 33번째 줄:  
|<syntaxhighlight lang="python">
 
|<syntaxhighlight lang="python">
 
INSTALLED_APPS = [
 
INSTALLED_APPS = [
 +
    ...
 
     'chat',
 
     'chat',
 
</syntaxhighlight>
 
</syntaxhighlight>
 
|-
 
|-
|URL매핑
+
|폴더 정리
|기본이 되는 urls.py 안에서 해당 앱으로 매핑을 시켜준다.
+
| - 앱을 생성하고 생성된 __init__.py와 views.py를 제외한 모든 것들을 지운다.
|path('chat/', include('chat.urls')),
+
(채팅기능만을 위해선 나머지는 필요 없어, 지워도 된다. 근데 그냥 두자.)
 +
 
 +
- 탬플릿을 담기 위해 templates/chat 디렉토리를 생성해준다.
 +
|
 
|-
 
|-
|URL매핑2
+
|urls.py 생성 및 매핑
|그리고 안의 urls.py 작성.
+
|앱 내에서 사용되는 url을 다루기 위해 urls.py를 생성한다.
|<syntaxhighlight lang="python">
+
|chat/urls.py의 내용.<syntaxhighlight lang="python">
from dfango.urls import path
+
from django.urls import path
 
from . import views
 
from . import views
 +
app_name = 'chat'  # 보통 앱이름을 써서 url을 구분하지만,
 +
 +
urlpatterns = [
 +
    path('', views.index, name='index'),
 +
]
 +
</syntaxhighlight>기초 urls.py 수정<syntaxhighlight lang="python">
 +
from django.contrib import admin
 +
from django.urls import include, path
    
urlpatterns = [
 
urlpatterns = [
     path('/', views.index, name='index'),
+
     path('chat/', include('chat.urls')),
 +
    path('admin/', admin.site.urls),
 
]
 
]
 
</syntaxhighlight>
 
</syntaxhighlight>
|-
+
|}
|view 작성
+
여기까지 하고 runserver 후 제대로 작동하는지 확인하자.
|
  −
|<syntaxhighlight lang="python">
  −
from django.shortcuts import render
     −
def index(request):
+
== 채팅룸 구현 ==
    return render(request, 'chat/index.html', {})
+
=== 채팅 인덱스 구현 ===
</syntaxhighlight>
+
들어갈 채팅룸을 입력하는 공간.
 +
{| class="wikitable"
 +
!과정
 +
!설명
 +
!방법
 
|-
 
|-
|탬플릿 준비
+
|채팅 인덱스 탬플릿 작성
|앱 하위에 templates>chat 디렉터리까지 만든다.
+
|채팅룸을 입력하기 위한 인덱스 탬플릿.
index.html 이라는 이름으로 만들자.
+
templates/chat/index.html로 작성하자.
 
|<syntaxhighlight lang="html+django">
 
|<syntaxhighlight lang="html+django">
<!-- chat/templates/chat/index.html -->
   
<!DOCTYPE html>
 
<!DOCTYPE html>
 
<html>
 
<html>
66번째 줄: 84번째 줄:  
     <title>Chat Rooms</title>
 
     <title>Chat Rooms</title>
 
</head>
 
</head>
 +
<body>
 +
    What chat room would you like to enter?<br>
 +
    <input id="room-name-input" type="text" size="100"><br>
 +
    <input id="room-name-submit" type="button" value="Enter">
   −
<body>
  −
    What chat room would you like to enter?<br/>
  −
    <input id="room-name-input" type="text" size="100"/><br/>
  −
    <input id="room-name-submit" type="button" value="Enter"/>
  −
   
   
     <script>
 
     <script>
 
         document.querySelector('#room-name-input').focus();
 
         document.querySelector('#room-name-input').focus();
88번째 줄: 105번째 줄:  
</html>
 
</html>
 
</syntaxhighlight>
 
</syntaxhighlight>
|}
  −
여기까지 하고 runserver 후 제대로 작동하는지 확인하자.
  −
  −
=== 패키지 설치 ===
  −
{| class="wikitable"
  −
!과정
  −
!설명
  −
!방법
   
|-
 
|-
|패키지 설치
+
|작성
|Channels 패키지를 설치한다.
+
|
|pip install -U channels
  −
|-
  −
|앱 등록
  −
|settings.py에 추가.
  −
channels는 runserver 명령을 제어하여 기존 서버를 대체한다.
  −
|INSTALLED_APPS 하위, 가장 처음에 <code>'channels'</code>넣는다.<syntaxhighlight lang="python">
  −
INSTALLED_APPS = [
  −
    'channels',  # 다른 서드파티 앱과 충돌할 수 있어 가장 처음에 둔다.
  −
    ...  # 공식 튜토리얼에선 'chat'을 상위로 올리지만, 아래에 있어도 상관 없다.
  −
    ...
  −
    'chat',
  −
</syntaxhighlight>
  −
|-
  −
|라우팅 설정 작성
  −
|가장 상위의 디렉터리에 routing.py를 다음과 같이 작성한다.
  −
(취향에 따라 달리 작성해도 된다.)
   
|<syntaxhighlight lang="python">
 
|<syntaxhighlight lang="python">
from channels.routing import ProtocolTypeRouter
+
from django.shortcuts import render
   −
application = ProtocolTypeRouter({
+
def index(request):
     # (http->django views is added by default)
+
     return render(request, 'chat/index.html')
})
   
</syntaxhighlight>
 
</syntaxhighlight>
|-
  −
|설정 추가
  −
|settings.py 안에 우측의 내용을 추가한다.
  −
라우팅파일의 위치만 잘 잡아주면 된다.
  −
|<syntaxhighlight lang="python">
  −
ASGI_APPLICATION = 'routing.application'  # routing.py 파일의 application을 불러온다.
  −
</syntaxhighlight>(라우팅 파일 안의 application을 가져온다.)
   
|}
 
|}
   −
== 채팅룸 구현 ==
+
=== 채팅룸 구현 ===
 
{| class="wikitable"
 
{| class="wikitable"
 
!과정
 
!과정
 
!설명
 
!설명
 
!방법
 
!방법
|-
  −
|URL매핑
  −
|앱 내의 urls.py에 추가.
  −
|<syntaxhighlight lang="python">
  −
urlpatterns = [
  −
    ...
  −
    path('<str:room_name>/', views.room, name='room'),
  −
    ...
  −
]
  −
</syntaxhighlight>
  −
|-
  −
|뷰 작성
  −
|room 뷰 작성
  −
mark_safe와 json을 가져온다.
  −
|<syntaxhighlight lang="python">
  −
from django.shortcuts import render
  −
from django.utils.safestring import mark_safe
  −
import json
  −
  −
def room(request, room_name):
  −
    return render(request, 'chat/room.html', {
  −
        'room_name_json': mark_safe(json.dumps(room_name))
  −
    })
  −
</syntaxhighlight>
   
|-
 
|-
 
|탬플릿 작성
 
|탬플릿 작성
|/char/room.html을 만든다.
+
|templates/chat/room.html
 
|<syntaxhighlight lang="html+django">
 
|<syntaxhighlight lang="html+django">
 
<!DOCTYPE html>
 
<!DOCTYPE html>
170번째 줄: 131번째 줄:  
     <title>Chat Room</title>
 
     <title>Chat Room</title>
 
</head>
 
</head>
   
<body>
 
<body>
     <textarea id="chat-log" cols="100" rows="20"></textarea><br/>
+
     <textarea id="chat-log" cols="100" rows="20"></textarea><br>
     <input id="chat-message-input" type="text" size="100"/><br/>
+
     <input id="chat-message-input" type="text" size="100"><br>
     <input id="chat-message-submit" type="button" value="Send"/>
+
     <input id="chat-message-submit" type="button" value="Send">
</body>
+
    {{ room_name|json_script:"room-name" }}
 +
    <script>
 +
        const roomName = JSON.parse(document.getElementById('room-name').textContent);
   −
<script>
+
        const chatSocket = new WebSocket(
    var roomName = {{ room_name_json }};
+
            'ws://'
 +
            + window.location.host
 +
            + '/ws/chat/'
 +
            + roomName
 +
            + '/'
 +
        );
   −
    var chatSocket = new WebSocket(
+
        chatSocket.onmessage = function(e) {
        'ws://' + window.location.host +
+
            const data = JSON.parse(e.data);
        '/ws/chat/' + roomName + '/');
+
            document.querySelector('#chat-log').value += (data.message + '\n');
 +
        };
   −
    chatSocket.onmessage = function(e) {
+
        chatSocket.onclose = function(e) {
        var data = JSON.parse(e.data);
+
            console.error('Chat socket closed unexpectedly');
        var message = data['message'];
+
         };
         document.querySelector('#chat-log').value += (message + '\n');
  −
    };
     −
    chatSocket.onclose = function(e) {
+
         document.querySelector('#chat-message-input').focus();
         console.error('Chat socket closed unexpectedly');
+
        document.querySelector('#chat-message-input').onkeyup = function(e) {
    };
+
            if (e.keyCode === 13) {  // enter, return
 
+
                document.querySelector('#chat-message-submit').click();
    document.querySelector('#chat-message-input').focus();
+
             }
    document.querySelector('#chat-message-input').onkeyup = function(e) {
+
         };
        if (e.keyCode === 13) {  // enter, return
  −
            document.querySelector('#chat-message-submit').click();
  −
        }
  −
    };
  −
 
  −
    document.querySelector('#chat-message-submit').onclick = function(e) {
  −
        var messageInputDom = document.querySelector('#chat-message-input');
  −
        var message = messageInputDom.value;
  −
        chatSocket.send(JSON.stringify({
  −
             'message': message
  −
        }));
  −
 
  −
         messageInputDom.value = '';
  −
    };
  −
</script>
      +
        document.querySelector('#chat-message-submit').onclick = function(e) {
 +
            const messageInputDom = document.querySelector('#chat-message-input');
 +
            const message = messageInputDom.value;
 +
            chatSocket.send(JSON.stringify({
 +
                'message': message
 +
            }));
 +
            messageInputDom.value = '';
 +
        };
 +
    </script>
 +
</body>
 
</html>
 
</html>
 +
</syntaxhighlight>
 +
|-
 +
|뷰 작성
 +
|뷰에 다음 함수를 추가한다.
 +
|<syntaxhighlight lang="python">
 +
def room(request, room_name):
 +
    return render(request, 'chat/room.html', {
 +
        'room_name': room_name
 +
    })
 +
</syntaxhighlight>
 +
|-
 +
|URL
 +
|다음의 내용을 urls.py에 추가한다.
 +
|<syntaxhighlight lang="python">
 +
path('<str:room_name>/', views.room, name='room'),
 
</syntaxhighlight>
 
</syntaxhighlight>
 
|}
 
|}
 
+
=== 참가자(컨슈머) 구현 ===
=== 참가자 구현 ===
   
{| class="wikitable"
 
{| class="wikitable"
 
!과정
 
!과정
228번째 줄: 203번째 줄:  
클라이언트로부터 메시지를 받아서 그대로 전달.
 
클라이언트로부터 메시지를 받아서 그대로 전달.
 
|<syntaxhighlight lang="python">
 
|<syntaxhighlight lang="python">
 +
import json
 
from channels.generic.websocket import WebsocketConsumer
 
from channels.generic.websocket import WebsocketConsumer
import json
      
class ChatConsumer(WebsocketConsumer):
 
class ChatConsumer(WebsocketConsumer):
250번째 줄: 225번째 줄:  
|/chat/routing.py 작성
 
|/chat/routing.py 작성
 
|<syntaxhighlight lang="python">
 
|<syntaxhighlight lang="python">
from django.conf.urls import url
+
from django.urls import re_path
 +
 
 
from . import consumers
 
from . import consumers
    
websocket_urlpatterns = [
 
websocket_urlpatterns = [
     url(r'^ws/chat/(?P<room_name>[^/]+)/$', consumers.ChatConsumer),
+
     re_path(r"ws/chat/(?P<room_name>\w+)/$", consumers.ChatConsumer.as_asgi()),
 
]
 
]
 
</syntaxhighlight>
 
</syntaxhighlight>
 
|-
 
|-
 
|라우팅 등록
 
|라우팅 등록
|/routing.py 수정
+
|channels를 설치하며 작성한 agsi.py 안에 내용을 채워준다.
 
chat 안의 라우팅을 등록해준다.
 
chat 안의 라우팅을 등록해준다.
   −
그리고 임포트하는 모듈을 수정해준다.
+
그리고 이를 사용하기 위한 라이브러리를 불러온다.
 
|<syntaxhighlight lang="python">
 
|<syntaxhighlight lang="python">
 +
......
 +
 +
from channels.routing import URLRouter
 +
from channels.security.websocket import AllowedHostsOriginValidator
 
from channels.auth import AuthMiddlewareStack
 
from channels.auth import AuthMiddlewareStack
from channels.routing import ProtocolTypeRouter, URLRouter
   
import chat.routing
 
import chat.routing
   −
application = ProtocolTypeRouter({
+
application = ProtocolTypeRouter(
    # (http->django views is added by default)
+
    {
    'websocket': AuthMiddlewareStack(
+
        "http": django_asgi_app,
        URLRouter(
+
        "websocket": AllowedHostsOriginValidator(
            chat.routing.websocket_urlpatterns
+
            AuthMiddlewareStack(URLRouter(chat.routing.websocket_urlpatterns))
         )
+
         ),
     ),
+
     }
})
+
)
 
</syntaxhighlight>
 
</syntaxhighlight>
|}
+
|}여기까지 하고 점검해보자. /chat/lobby/ 에 접속한 후 채팅을 치면 떠야 한다.
여기까지 하고 채팅을 쳐 보면... 채팅이 나와야 정상.
+
 
 +
같은 브라우저를 띄워두고 한쪽에서 채팅을 치면 다른 쪽에서 안뜨는데, 다른 브라우저에선 안뜬다. 접속한 모두에게 반영될 수 있도록 채널레이어를 구현한다.
 +
 
 +
= 채널 레이어 구현 =
 +
consumer 인스턴스는 자동으로 유일한 channel name을 구성하기 때문에 서로 소통하려면 layer가 필요하다.
 +
 
 +
== 레디스 ==
 +
레디스 서버를 이용한 실시간 채팅 구현.
   −
=== 체널레이어 구현 ===
+
꽤 오래된 버전인 5를 사용하는데, 아마도 이는.. 5에서 처음으로 consumer groups이 구현된 버전인데, 이걸로 구현했기 때문이 아닐까 싶다.(최신버전에서도 될진.. 검증이 필요하다.)
 +
===사전설정===
 
{| class="wikitable"
 
{| class="wikitable"
 
!과정
 
!과정
286번째 줄: 273번째 줄:  
!방법
 
!방법
 
|-
 
|-
|패키지 설치
+
| 서버 설치
|Channels가 Redis인터페이스를 인식하도록.
+
|설치 후 딱히 옵션을 변경할 내용은 없다.
 +
어차피 로컬에서 모든 작동이 수행되기 때문에.
 +
 
 +
네이티브로 설치하는 게 아니라 도커를 사용할 거라면 아래로 넘어가자.
 +
 
 +
단순 apt-get install redis로 설치해 5 이상의 버전이 되어도 작동한다.
 +
|[http://id8436.iptime.org:8080/mediawiki/index.php/Radis Redis] 문서 참조.
 +
|-
 +
|도커
 +
|도커가 설치되어 있다면 아래 명령으로 바로 설치, 서비스가 시작된다.
 +
 
 +
docker run -p 6379:6379 -d redis:5
 +
|아래 문서를 통해 도커 설치를 참고하자.
 +
http://id8436.iptime.org:8080/mediawiki/index.php/Docker
 +
|-
 +
|연동 라이브러리 설치
 +
|서버와 채널을 연동할 라이브러리를 설치한다.
 
|pip install channels_redis
 
|pip install channels_redis
 
|-
 
|-
|settings.py 설정
+
|세팅
|ASGI 아래 추가하자.
+
|settings.py 안에 추가.
 
|<syntaxhighlight lang="python">
 
|<syntaxhighlight lang="python">
ASGI_APPLICATION = 'routing.application' # routing.py 파일의 application을 불러온다.
+
ASGI_APPLICATION = "mysite.asgi.application" # 이부분은 채널을 설치할 때 작성한 부분.
 
CHANNEL_LAYERS = {
 
CHANNEL_LAYERS = {
     'default': {
+
     "default": {
         'BACKEND': 'channels_redis.core.RedisChannelLayer',
+
         "BACKEND": "channels_redis.core.RedisChannelLayer",
         'CONFIG': {
+
         "CONFIG": {
             "hosts": [('127.0.0.1', 6379)],
+
             "hosts": [("127.0.0.1", 6379)],
 
         },
 
         },
 
     },
 
     },
 
}
 
}
 +
</syntaxhighlight>을 추가해준다.
 +
|}
 +
==기존 컨슈머 변형==
 +
위에서 작성한 컨슈머는 중간 확인을 위한 동기식 함수인데, 비동기식으로 다시 쓰이면 성능이 좋아진다. 아래와 같이 변형하자.<syntaxhighlight lang="python">
 +
import json
 +
 +
from channels.generic.websocket import AsyncWebsocketConsumer
 +
 +
 +
class ChatConsumer(AsyncWebsocketConsumer):
 +
    async def connect(self):
 +
        self.room_name = self.scope["url_route"]["kwargs"]["room_name"]
 +
        self.room_group_name = "chat_%s" % self.room_name
 +
 +
        # Join room group
 +
        await self.channel_layer.group_add(self.room_group_name, self.channel_name)
 +
 +
        await self.accept()
 +
 +
    async def disconnect(self, close_code):
 +
        # Leave room group
 +
        await self.channel_layer.group_discard(self.room_group_name, self.channel_name)
 +
 +
    # Receive message from WebSocket
 +
    async def receive(self, text_data):
 +
        text_data_json = json.loads(text_data)
 +
        message = text_data_json["message"]
 +
 +
        # Send message to room group
 +
        await self.channel_layer.group_send(
 +
            self.room_group_name, {"type": "chat_message", "message": message}
 +
        )
 +
 +
    # Receive message from room group
 +
    async def chat_message(self, event):
 +
        message = event["message"]
 +
 +
        # Send message to WebSocket
 +
        await self.send(text_data=json.dumps({"message": message}))
 +
</syntaxhighlight>
 +
 +
= 서버에 반영 =
 +
튜토리얼엔 나오지 않지만(공식문서 한참 뒤에 나온다..), 서버에 반영하게 되면 ws/ 경로에 대하여 새로운 설정을 해주어야 한다.(필자는 이걸 몰라서 이것저것 건드리다가 sql 날려먹었다.. 젠장)
 +
 +
일반적으로 WSGI 통신을 gunicorn으로 처리한다면, ASGI통신은 daphne으로 처리한다.(daphne에서도 wsgi통신이 가능하지만, 보안상 권장되진 않는다.)
 +
{| class="wikitable"
 +
!과정
 +
!설명
 +
!방법
 +
|-
 +
| 설치
 +
|daphne은 channels를 설치하면 자동으로 설치가 된다.
 +
|
 +
|-
 +
|확인
 +
|실행 명령은 우측과 같다.(asgi의 경로를 지정해주면 된다.)
 +
 +
포트번호는 필요에 따라 지정하면 된다.
 +
 +
해당 포트로 접속해보면 사이트와 채팅이 잘 작동한다.
 +
|<code>daphne -b 0.0.0.0 -p 8001 config.asgi:application</code>
 +
|-
 +
|서버에서 실행
 +
|서비스를 위해선 백그라운드로 진행해주어야 한다.
 +
실행 여부는 <code>ps -ef | grep daphne</code>로 확인해보자. 실행이 안되면 nohup 떼서 에러메시지 확인.
 +
|<code>nohup daphne -b 0.0.0.0 -p 8001 config.asgi:application &</code>
 +
|-
 +
|웹서버에 반영
 +
|/static을 반영하듯, /ws로 시작되는 주소는 다핀으로 넘겨준다.
 +
이후 관련 서비스들을 재시작하면 설정들이 반영된다.
 +
 +
채팅도 물론 성공적...!
 +
|<syntaxhighlight lang="bash">
 +
location /ws {
 +
                proxy_pass http://localhost:8001;
 +
                proxy_http_version 1.1;
 +
                proxy_set_header Upgrade $http_upgrade;
 +
                proxy_set_header Connection "upgrade";
 +
        }
 
</syntaxhighlight>
 
</syntaxhighlight>
 
|}
 
|}
[[분류:장고 기능구현(중급)]]
+
웹소켓에서도 https 인증을 적용할 수 있다.([https://victorydntmd.tistory.com/265 추후에 반영해보자.])   
 +
 
 +
== 관련에러 ==
 +
 
 +
=== The app module <module '앱이름' (<_frozen_importlib_external._NamespaceLoader object at 0x7f2faed9a8f0>)> has multiple filesystem locations (['/./앱경로', '/앱경로']); you must configure this app with an AppConfig subclass with a 'path' class attribute. ===
 +
생각지도 못한 문제점이었다... 앱은 하나의 모듈처리가 되어 디렉토리 안에 __init__.py가 담기는데, 이게 없는 경우 경로를 제대로 찾지 못해 발생하는 에러이다. 앱 디렉토리 안에 __init__.py를 제대로 넣어주면 해결됨.
 +
 
 +
= 마무리. 각 요소들이 어떤 역할을 하는지. =
 +
순서대로 어떤 경로를 거쳐 작동하는지 살펴보자.
 +
 
 +
=== asgi.py ===
 +
{| class="wikitable"
 +
!항목
 +
!설명
 +
|-
 +
| ProtocolTypeRouter
 +
|연결의 종류를 파악하여 처리. http로 넘길지, 웹소켓 처리로 넘길지.
 +
 
 +
* http 연결일 경우 보통 django_asgi_app<code>(get_asgi_application())</code> 가 실행된다.
 +
* websocket 연결(ws:// 혹은 wss://)일 경우 <code>AuthMiddlewareStack</code>으로 앱별로 <code>routing.py</code>에 적어놓은 코드로 라우팅 한다.
 +
|-
 +
|
 +
|
 +
*
 +
|}
 +
 
 +
=== routing.py ===
 +
들어온 경로와 컨슈머를 연결해준다. urls.py와 view.py의 관계.
 +
 
 +
다음과 같은 형태로 컨슈머를 연결한다.<syntaxhighlight lang="python">
 +
websocket_urlpatterns = [
 +
    re_path(r'ws/notification/(?P<center_name>\w+)/$', consumers.NotificationConsumer.as_asgi()),
 +
]
 +
</syntaxhighlight>
 +
 
 +
=== [컨슈머는 컨슈머 문서로 옮김] ===
 +
[[분류:장고 웹소켓]]