[Django, React] 웹소켓을 이용한 실시간 위치추적 기능 구현

Seaniiio·2024년 7월 28일

Django

목록 보기
4/5

프로젝트에서 본인의 강아지와 산책중인 다른 유저의 위치를 실시간으로 받아오는 기능이 필요했다.
처음에 기능 구상만 할 때는, 그냥 산책중인 사람은 3초에 한 번씩 자신의 위치를 쏴주고, 견주는 3초에 한번씩 위치 받아오는 요청을 보내면 되지 않을까? 라고 생각했다. (client -> server의 요청을만을 생각하였음)

이런식으로 계속 요청을 보내는걸 HTTP Polling 방식이라고 한다. 이러한 경우, 해당 서비스를 이용하는 사람들이 많아질수록 서버의 부담이 증가한다고 한다.

그래서, 양방향 통신을 제공하는 웹소켓 통신을 기반으로 위치추적 기능을 구현하였다. 한 번 client와 server가 연결되면(handshake), 이제 실시간 양방향 통신이 가능하다는 장점이 있다. 그래서 견주 입장에서는 server에게 3초에 한번씩 요청을 보낼 필요 없이, server가 데이터를 보내주면 그걸 받아 사용자의 위치를 업데이트 하면 되는것이다.

Django에서 웹소켓 통신을 위한 setting

Django channels

Django에서 웹소켓 통신을 구현하기 위해서는 Django Channels를 사용한다.

$ pip install channels
  • 우선 django channels를 설치한다.
INSTALLED_APPS = [
    ...
    'channels',
]
  • settings.py의 installed_apps에 channels를 추가한다.

ASGI

ASGI(Asynchronous Server Gateway Interface)는 비동기 + 실시간 기능을 지원하게 해주는 인터페이스이다.
Django Channels은 ASGI를 통해 비동기 기능과 웹소켓 통신을 제공할 수 있다.

지금까지는 WSGI만 사용했는데, django channels을 사용하려면 ASGI와 관련된 설정도 추가해주어야 한다.

ASGI_APPLICATION = '프로젝트명.asgi.application'
  • settings.py에 추가한다.
import os
from django.core.asgi import get_asgi_application

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'meong_signal.settings')
django_asgi_app = get_asgi_application()

from channels.auth import AuthMiddlewareStack
from channels.routing import ProtocolTypeRouter, URLRouter
from channels.security.websocket import AllowedHostsOriginValidator
import 앱 이름.routing 

application = ProtocolTypeRouter({
    "http": get_asgi_application(),
    "websocket":
        AuthMiddlewareStack(
            AllowedHostsOriginValidator(
            URLRouter(
                앱 이름.routing.websocket_urlpatterns
            )
        ),
    ),
})
  • 앱 이름은 곧 만들 웹소켓과 관련된 앱 이름을 적어주면 된다.
  • 참고로 os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'meong_signal.settings') 이부분에서, 나는 settings를 분리했기 때문에 settings라는 폴더 안에 base.py / local.py / prod.py가 존재했는데, 배포할 때는 os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'meong_signal.settings.prod') 로 변경했다.

앱 생성

$ python manage.py createapp 앱이름
  • 웹 소켓 통신을 진행할 앱을 만들어준다.
path('walk-status/', include('walk_status.urls')),
  • 프로젝트의 urls.py에 앱의 url을 추가해주자.

모델 생성

이제 통신에 필요한 모델을 만들어보자.

from django.db import models

class Owner(models.Model): # 위치 받는 사람
    owner_email = models.EmailField(unique=True)

class WalkUser(models.Model): # 위치 보내는 사람
    walk_user_email = models.EmailField(unique=True)

class WalkRoom(models.Model):
    owner = models.ForeignKey(Owner, on_delete=models.CASCADE)
    walk_user = models.ForeignKey(WalkUser, on_delete=models.CASCADE)

    class Meta:
        unique_together = ('owner', 'walk_user')
        
class Location(models.Model):
    room = models.ForeignKey(WalkRoom, on_delete=models.CASCADE, related_name="locations")
    walk_user_email = models.EmailField() # 위치를 보내는, 산책하는 WalkUser의 이메일
    latitude = models.DecimalField(max_digits=10, decimal_places=6, null=True, blank=True)  # 위도
    longitude = models.DecimalField(max_digits=10, decimal_places=6, null=True, blank=True)  # 경도
  • 유저 두 명이 서로 위치를 보내는 것이 아니고, 한 명은 보내기만 + 한 명은 받기만 하기 때문에, 모델이 더 간결하다.
  • 유저는 email을 이용하여 구분하였다.

consumers.py

코드 설명이 잘 되어있는 블로그를 찾아서, 여기서 참고를 많이 했다.
https://velog.io/@mimijae/Django-Django-rest-framework%EB%A1%9C-%EC%9B%B9%EC%86%8C%EC%BC%93-%EC%B1%84%ED%8C%85-%EC%84%9C%EB%B2%84-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B02

Consumer는 기본적으로 Django View와 유사하지만, 비동기적으로 동작한다.

  • HTTP (views): 상태를 유지하지 않는(stateless) 프로토콜이다. 각 요청은 독립적이며, 서버는 요청 간에 상태를 유지하지 않는다.

  • 웹소켓 (consumers): 연결이 지속되는 동안 상태를 유지할 수 있다.

import json
from channels.generic.websocket import AsyncWebsocketConsumer

class MyConsumer(AsyncWebsocketConsumer):
    async def connect(self):
        await self.accept()

    async def disconnect(self, close_code):
        pass

    async def receive(self, text_data):
        text_data_json = json.loads(text_data)
        message = text_data_json['message']

        # Echo the message back to WebSocket
        await self.send(text_data=json.dumps({
            'message': message
        }))
  • consumers.py의 큰 틀은 위와 같다.
  • connect는 client가 웹소켓 연결을 시도할 때 호출된다.
  • disconnect는 client가 웹소켓 연결을 해제할 때 호출된다.
  • receive는 client로부터 메시지를 수신할 때 호출된다.

해당 틀을 기반으로, 위치 추적 서비스에 맞게 코드를 작성하면 아래와 같이 완성된다.

from channels.generic.websocket import AsyncJsonWebsocketConsumer, WebsocketConsumer
from channels.db import database_sync_to_async
from .models import *
import json

class LocationConsumer(AsyncJsonWebsocketConsumer):
    async def connect(self):
        print("웹소켓 연결 성공")
        try:          
            self.room_id = self.scope['url_route']['kwargs']['room_id']

            if not await self.check_room_exists(self.room_id): # 현재 산책중인지 확인
                raise ValueError('산책 정보가 존재하지 않습니다.')
                        
            group_name = self.get_group_name(self.room_id)

            await self.channel_layer.group_add(group_name, self.channel_name) # 현재 채널을 그룹에 추가합니다.                       
            await self.accept() # WebSocket 연결 성공

        except ValueError as e:           
            await self.send_json({'error': str(e)})
            await self.close()

    async def disconnect(self, close_code):
        try:            
            group_name = self.get_group_name(self.room_id)
            await self.channel_layer.group_discard(group_name, self.channel_name)

        except Exception as e:
            pass

    async def receive_json(self, content):
        try:
            latitude = content['latitude']
            longitude = content['longitude']
            walk_user_email = content['walk_user_email']
            owner_email = content['owner_email']

            if not walk_user_email or not owner_email:
                raise ValueError("견주 및 산책자 이메일이 모두 필요합니다.")

            room = await self.get_or_create_room(walk_user_email, owner_email)
            
            self.room_id = str(room.id)
            
            group_name = self.get_group_name(self.room_id)
            
            await self.save_location(room, latitude, longitude, walk_user_email)

            await self.channel_layer.group_send(group_name, {
                'type': 'send_location',
                'latitude': latitude,
                'longitude': longitude
            })

        except ValueError as e:
            await self.send_json({'error': str(e)})

    async def send_location(self, event):
        try:
            latitude = event['latitude']
            longitude = event['longitude']
            
            await self.send_json({'latitude': latitude, 'longitude': longitude})
        except Exception as e:
            await self.send_json({'error': '위, 경도 전송 실패'})

    @staticmethod
    def get_group_name(room_id):
        return f"chat_room_{room_id}"
    
    @database_sync_to_async
    def get_or_create_room(self, walk_user_email, owner_email):
        owner, _ = Owner.objects.get_or_create(owner_email = owner_email)
        walk_user, _ = WalkUser.objects.get_or_create(walk_user_email = walk_user_email)

        room, created = WalkRoom.objects.get_or_create(
            owner = owner,
            walk_user = walk_user
        )
        
        return room
    
    @database_sync_to_async
    def save_location(self, room, latitude, longitude, walk_user_email):
        if not latitude or not longitude or not walk_user_email:
            raise ValueError("위, 경도 정보가 필요합니다.")

        Location.objects.create(room=room, walk_user_email=walk_user_email, latitude=latitude, longitude=longitude)

    @database_sync_to_async
    def check_room_exists(self, room_id):
        return WalkRoom.objects.filter(id=room_id).exists()

routing.py

from django.urls import path, re_path

from walk_status import consumers

websocket_urlpatterns = [
    re_path(r'ws/room/(?P<room_id>\d+)/locations$', consumers.LocationConsumer.as_asgi()),
]
  • 웹소켓 라우팅을 설정해준다.

channel layer 설정

$ pip install channels_redis
  • Redis를 채널 레이어로 사용하기 위해 install해준다.
  • 비동기 작업, 웹소켓 통신을 Redis를 통해 처리할 수 있다.
CHANNEL_LAYERS = {
    'default': {
        'BACKEND': 'channels_redis.core.RedisChannelLayer',
        'CONFIG': {
            "hosts": [('127.0.0.1', 6379)],
        },
    },
}
  • settings.py에 위의 코드를 추가해주자.

views.py, serializers.py

http요청을 처리하는 api들은 views에 작성해준다. 마찬가지로 위의 블로그에서 참고한 코드이다.

views.py

from rest_framework import generics, serializers, status
from rest_framework.response import Response
from .models import *
from .serializers import WalkRoomSerializer, LocationSerializer
from rest_framework.exceptions import ValidationError
from django.http import Http404
from django.http import JsonResponse
from django.conf import settings

class ImmediateResponseException(Exception):
    def __init__(self, response):
        self.response = response

class WalkRoomListCreateView(generics.ListCreateAPIView):
    serializer_class = WalkRoomSerializer

    def get_queryset(self):
        try:
            user_email = self.request.query_params.get('email', None)

            if not user_email:
                raise ValidationError('Email 파라미터가 필요합니다.')

            return WalkRoom.objects.filter(
                owner__owner_email = user_email
            ) | WalkRoom.objects.filter(
                walk_user__walk_user_email = user_email
            ) # owner, walk_user에 알맞은 room return
        except ValidationError as e:
            content = {'detail': e.detail}
            return Response(content, status=status.HTTP_400_BAD_REQUEST)
        except Exception as e:
            content = {'detail': str(e)}
            return Response(content, status=status.HTTP_400_BAD_REQUEST)

    def get_serializer_context(self):
        context = super(WalkRoomListCreateView, self).get_serializer_context()
        context['request'] = self.request
        return context

    def create(self, request, *args, **kwargs):
        serializer = self.get_serializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        try:
            self.perform_create(serializer)
        except ImmediateResponseException as e:
            return e.response
        headers = self.get_success_headers(serializer.data)
        return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)

    def perform_create(self, serializer):
        owner_email = self.request.data.get('owner_email')
        walk_user_email = self.request.data.get('walk_user_email')

        owner, _ = Owner.objects.get_or_create(owner_email=owner_email)
        walk_user, _ = WalkUser.objects.get_or_create(walk_user_email=walk_user_email)
        
        existing_walkroom = WalkRoom.objects.filter(owner__owner_email=owner_email, walk_user__walk_user_email=walk_user_email).first()

        if existing_walkroom:
            serializer = WalkRoomSerializer(existing_walkroom, context={'request': self.request})
            raise ImmediateResponseException(Response(serializer.data, status=status.HTTP_200_OK))

        serializer.save(owner=owner, walk_user=walk_user)

class LocationListView(generics.ListAPIView):
    serializer_class = LocationSerializer

    def get_queryset(self):
        room_id = self.kwargs.get('room_id')
        
        if not room_id:
            content = {'detail': 'room_id 파라미터가 필요합니다.'}
            return Response(content, status=status.HTTP_400_BAD_REQUEST)

        queryset = Location.objects.filter(room_id=room_id)
        
        if not queryset.exists():
            raise Http404('해당 room_id로 위치 정보를 찾을 수 없습니다.')

        return queryset

serializers.py

from rest_framework import serializers
from .models import WalkRoom, Location

class LocationSerializer(serializers.ModelSerializer):
    class Meta:
        model = Location 
        fields = "__all__" 

class WalkRoomSerializer(serializers.ModelSerializer):
    owner_email = serializers.SerializerMethodField() 
    walk_user_email = serializers.SerializerMethodField()  
    locations = LocationSerializer(many=True, read_only=True, source="locations.all") 

    class Meta:
        model = WalkRoom 
        fields = ('id', 'owner_email', 'walk_user_email', 'locations')

    def get_owner_email(self, obj):  
        return obj.owner.owner_email

    def get_walk_user_email(self, obj):  
        return obj.walk_user.walk_user_email

urls.py

http 요청과 관련된 url을 설정해준다.

from django.urls import path
from . import views

urlpatterns = [
    path('rooms/', views.WalkRoomListCreateView.as_view()),
    path('<int:room_id>/locations', views.LocationListView.as_view()),
]

React에서 웹소켓 통신을 위한 코드

위치 보내는 페이지의 코드

먼저, 위치를 보내주는 사람이 접속하는 페이지의 코드이다.
아직 방 조회에 필요한 견주, 산책자 email을 받아오는 로직은 작성하지 않았다.

  useEffect(() => {
    // 견주, 산책자 이메일 얻어와서 walkUserEmail, ownerEmail 업데이트하는 로직
  }, []);

  useEffect(() => {
    const setupRoomAndSocket = async () => {
      try {
        await CreateRoom();
      } catch (error) {
        console.error("Error setting up room and socket:", error);
      }
    };

    setupRoomAndSocket();
  }, []);

  useEffect(() => {
    if (socket) {
      const intervalId = setInterval(() => {
        SendLocation();
      }, 3000); // 3초에 한 번씩 위치를 서버로 보내준다.

      return () => clearInterval(intervalId);
    }
  }, [socket]);

  const CreateRoom = async () => {
    if (socket) {
      socket.close();
    }
    const token = localStorage.getItem("accessToken");
    const response = await fetch(
      "https://도메인/앱이름/rooms/",
      {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          Authorization: `Bearer ${token}`,
        },
        body: JSON.stringify({
          owner_email: ownerEmail,
          walk_user_email: walkUserEmail,
        }),
      },
    );

    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }

    const roomData = await response.json();
    setRoomId(roomData.id);

    console.log("roomId:", roomData.id);

    SetUpWebSocket(roomData.id);
  };

  const SetUpWebSocket = (roomId) => {
    const newSocket = new WebSocket(
      `wss://도메인/ws/room/${roomId}/locations`,
    ); // 웹소켓 요청은 ws or wss로 보낸다

    console.log("newSocket=", newSocket);

    newSocket.onopen = () => {
      setSocket(newSocket);
      console.log("WebSocket 연결 성공");
    };

    newSocket.onmessage = (e) => {
      let data = JSON.parse(e.data);
    };

    newSocket.onerror = (error) => {
      console.error("WebSocket error:", error);
    };

    newSocket.onclose = () => {
      console.log("WebSocket closed");
      setSocket(null);
    };
  };

  const SendLocation = async () => {
    if (!socket || socket.readyState !== WebSocket.OPEN) {
      console.warn("WebSocket is not open. Skipping location send.");
      return;
    }

    const coordinates = await getCoordinates();
    setInitialLocation(coordinates);
    setCurrentLocation(coordinates);

    const locationPayload = {
      owner_email: ownerEmail,
      walk_user_email: walkUserEmail,
      latitude: coordinates.latitude,
      longitude: coordinates.longitude,
    };

    socket.send(JSON.stringify(locationPayload));
    console.log("소켓으로 send하는 현재 내 위치:", locationPayload); // 여기서 쏴주는 데이터가 내 위치 데이터입니다.
  };
  • room_id 확인 -> 웹소켓 연결 -> 위치 3초마다 보내기의 로직은 이런식으로 흘러간다.

위치 받아오는 페이지의 코드


  useEffect(() => {
    // 견주, 산책자 이메일 얻어와서 walkUserEmail, ownerEmail 업데이트하는 로직
  }, []);

  useEffect(() => {
    const setupRoomAndSocket = async () => {
      try {
        await CreateRoom();
      } catch (error) {
        console.error("Error setting up room and socket:", error);
      }
    };

    setupRoomAndSocket();
  }, []);

  const CreateRoom = async () => {
    if (socket) {
      socket.close();
    }
    const token = localStorage.getItem("accessToken");
    const response = await fetch(
      "https://도메인/앱이름/rooms/",
      {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          Authorization: `Bearer ${token}`,
        },
        body: JSON.stringify({
          owner_email: ownerEmail,
          walk_user_email: walkUserEmail,
        }),
      },
    );

    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }

    const roomData = await response.json();
    setRoomId(roomData.id);

    console.log("roomId:", roomData.id);

    OpenOrCreateRoom(roomData.id);
  };

  const OpenOrCreateRoom = (roomId) => {
    SetUpWebSocket(roomId);
  };

  const SetUpWebSocket = (roomId) => {
    const newSocket = new WebSocket(
      `wss://도메인/ws/room/${roomId}/locations`,
    );

    newSocket.onopen = () => {
      console.log("WebSocket connected");
      setSocket(newSocket);
    };

    newSocket.onmessage = (e) => {
      let data = JSON.parse(e.data);
      console.log("소켓에서 받아온 현재 강아지 위치:", data); 

      if (data.latitude && data.longitude) {
        setCurrentLocation({
          latitude: data.latitude,
          longitude: data.longitude,
        });
        console.log("setCurrentLocation 수정");
      }
    };

    newSocket.onerror = (error) => {
      console.error("WebSocket error:", error);
    };

    newSocket.onclose = () => {
      console.log("WebSocket closed");
      setSocket(null);
    };
  };

  return (
    <>
      <Header />
      <Map
        latitude={currentLocation.latitude}
        longitude={currentLocation.longitude}
        width="300px"
        height="300px"
      />
      <SelectRoute dog_id={parseInt(dogId, 10)} dog_name={dogName} /> <Footer />
    </>
  );
};

export default MapStatus;
  • room_id 확인 -> 웹소켓 연결 -> 위치 받으면 업데이트해주기의 로직은 이런식으로 흘러간다.

이후 웹소켓 서버 배포 과정은 따로 정리해야겠다.

0개의 댓글