소프트웨어 마에스트로/BackEnd(Django)

[Django] apple social login 구현기 3일차 + async openai in django

alpakaka 2024. 10. 19. 21:49

생각보다 오래 걸리고 있다..

얼른 해결해보자...!

 

https://njw9108.tistory.com/36

 

[Flutter] Apple Social login 방법 및 설명(email null 해결)

1. IOS 앱 배포 할때 앱에서 소셜 로그인을 사용한다면 반드시 Apple 로그인을 포함해야 한다. 2. Apple 개발자 센터(https://developer.apple.com/) 3. 애플 개발자 회원 가입 및 플랜에 가입되어야함. (어차피

njw9108.tistory.com

 

일단 여기를 참고해봤는데 저기에 마지막즈음에 나오는 token을 프론트에서 줄 것 같다.

해당 토큰은 Jwt 방식이 되어 있는 듯 보였다.

 

근데 이런식으로 작성하면 내가 작성한 코드는 맞지 않는 코드인 것 같다.

이미 저 값을 decode 하면 값이 jwt 안에 들어있는거고, 적당히 decode 를 해서 email과 device token(있다면) 을 데려오면 되기 때문이다. 

그래서 사실상 여기서 더 이상 진행을 못할 것 같고, 내일 담당 팀원분이 어떤 내용을 줄건지가 확정된 이후부터야 가능할 것 같다.

 

 

!!테스트실!!

따라서 저 내용이 확정되기 전까지는 간단하게 폴링과 기본 openai 테스트를 진행해볼 것 이다.

 

저번에 예측했던 사항은 한국 - 미국으로 이동하는 거리때문에 오래 걸린 것 이라고 생각했다.

그 예측이 맞는지, 그래서 결론적으로  시간을 줄일 수 있는지 확인해볼 것 이다!!

 

일단 그러기 위해서는 대략적인 테스트 환경이 필요하다.

그래서 간단하게 테스트 환경을 만들어볼 예정이다.

 

pytest 를 통해서 진행할 수 있는 듯 했다. 생각해보니 과제에서 본 것 같은 기분이 든다.

일단 pytest-benchmark 와 pytest-asyncio 를 설치해준다.

pip install pytest-asyncio pytest-benchmark

 

그 후에 다음과 같은 코드를 작성했다.

 

일단 위와 같이 작성하고 한번 돌려본다.

에러가 났다. 

벤치마킹과 async 가 맞지 않는다는 의미같다. 그래서 아래와 같이 수정해주었다.

 

뭔가 나왔다...!

그런데 아직도 저 에러는 계속 발생한다.. 내가 볼때는 async 관련 문제인 듯  싶으니 일단 이 문제부터 해결하면 좋을 것 같다. recommend 는 비동기 뷰라 어차피 pytest 에서 계속 오류가 나기도 했었고..

 

다음의 링크들을 참고했다.

https://libertbaek.tistory.com/entry/Python-%EB%B9%84%EB%8F%99%EA%B8%B0-%ED%85%8C%EC%8A%A4%ED%8A%B8%EB%A5%BC-%ED%95%98%EB%A0%A4%EB%A9%B4-aka-pytest-asyncio

 

[Python] 비동기 테스트를 하려면? (a.k.a pytest-asyncio)

들어가며python은 async/await 문법과 single-thread 기반 event loop를 통해 비동기적인 작업을 지원합니다. 하지만, 비동기적으로 만들어진 코드를 테스트하기 위해서는 어떻게 해야 할까요? 이번 포스팅

libertbaek.tistory.com

https://pytest-asyncio.readthedocs.io/en/latest/reference/index.html

 

 

확인해보니 비동기에 대해서 좀 알아야할 것 같다. 근데 좀 급한 사항이라. 일단 밀어붙여보고 하다가 막히면 공부해보려고 한다.

@pytest.mark.django_db
@pytest.mark.asyncio
@patch("todos.views.client.chat.completions.create")
async def test_rate_limit_exceeded(
    mock_llm, create_user, create_todo, recommend_result
):
    mock_llm.return_value = recommend_result
    url = "https://dev.stepby.one/todos/recommend/"
    access_token = str(AccessToken.for_user(create_user))
    print(recommend_result)

    async with httpx.AsyncClient() as client:
        # Attach the Authorization header with the Bearer token
        client.headers.update(
            {
                "Authorization": f"Bearer {access_token}",
                "Content-Type": "application/json",
            }
        )
        response = await client.get(url, params={"todo_id": create_todo.id})
    assert response.status_code == status.HTTP_200_OK

이런식으로 작성했는데 이 코드 자체에 문제보단 다른 곳의 로직에서 계속 문제가 발생하는 것 같다.

왜인지도 잘 모르겠다.

정말 왜인걸까...

400에러로 모델과 부르는게 맞지 않는다는 에러가 발생한다. 

한번 디버깅을 진행해보았다.

그런데 동기적인 뷰와 다르게 디버깅이 안되었다. 그냥 결과만 나와서 사실상 print 문과 다른 역할이 없었다.

그래서 또 찾아봤다.

https://kwonkyo.tistory.com/497#gsc.tab=0

 

VSCode Python Extension에서 멀티 스레드 디버깅

파이썬 코딩에 Visual Studio Code(VSCode)를 많이들 사용하시는지 잘 모르겠는데, 저는 VSCode가 무겁지 않고 아두이노 스케치뿐만 아니라 공부하고 있는 다양한 언어를 사용할 수 있어서 All in one IDE 개

kwonkyo.tistory.com

여기에서 파악할 수 있었다. 역시 이미 한사람이 있었다. 최고최고

근데 저분이 찾은 공식문서 링크가 어디있는지는 못찾았다. 진짜 어디서 찾으신거지

일단 따라해보자

이걸 넣어보자

나는 todos 앱의 views.py 에 넣고, 바로 recommend 뷰에 넣을 예정이다.

이런식으로 넣고 계속 따라해보았다.

이 블로그 덕분에 디버그 하면서 왜 콜스택이 있는지 알 수 있었다. 제어권을 확인할 수 있었구나..

좀 더 찾아보면 좋을 듯 싶다.

음 계속 에러가 발생해서 혹시나 하고 뷰에 포스트맨으로 날려본 결과

다음과 같은 에러가 발생했다.

 

이런 에러였는데 챗지피티에게 물어본 결과는 await 를 붙이지 않아서가 이유였고 await 를 붙여도 문제가 해결되지 않았다.

그래서 찾아보니 openai의 함수도 비동기로 변환해야하는 듯 싶었다. 생각보다 큰 일이였다.

 

일단 해봐야하는데 궁금증이 생긴건 다음과 같다.

일단 async 를 붙이고 polling 도 같이 붙일 수 있는가? -> 일단 챗지피티는 가능하다고 한다.

 

그러면 순서는 다음과 같다. 지금 dev 도 같은 코드라 recommend 가 맛이 간 상태이니, 우선적으로 해결해주어야할 듯 싶다.

 

흠 그러면 흐름도는 다음과 같다.

openai async 도입

도입 후 pytest 정상화

정상화 후 시간 체크

시간 체크 후 pulling 도입

시간 체크 

얼마나 차이나는지 비교

팀원들에게 전달

 

하면 될 것 같다.

그러면 일단 openai Async 를 도입해보자

https://github.com/openai/openai-python

 

GitHub - openai/openai-python: The official Python library for the OpenAI API

The official Python library for the OpenAI API. Contribute to openai/openai-python development by creating an account on GitHub.

github.com

이곳을 참고해서 진행해보았다.

이걸 참고해보았는데 나머지는 쉽게 해결했는데 asyncio.run 을 어떻게 해야하는지 좀 어려운 것 같다.

그래서 저기 나온 것 처럼 openai 에 요청보내고 받는 부분만 비동기로 진행해야 하나 고민이 된다. 

일단 우리의 친구 코파일럿에게 물어보자. 

 

코파일럿은 무시했다...?

딱히 필요 없는건가 하고 다른 친구인 챗지피티에게 물어보니 저거 event loop 라는 개념때문에 사용했다는 것 같다. 우리는 django 뷰로 사용하고 있어서 딱히 사용할 이유가 없는 것 이었다. 

일단 다음에 공부해야할 것 같다. 

 

그래서 client 변경 완료하고 await 를 붙여주고 테스트를 돌려보았다.

 

음 똑같은 에러가 발생한다.

그래서 db 쿼리를 동기적으로 불러서 문제라고 해서 다음처럼 바꿨는데

똑같은 에러가 발생한다.

그래서 다시 읽어보니까

manage.py runserver 를 사용하지 말고 uvicorn 을 사용하라는 말을 들었다. 일단 안되니까 진행해본다.

 

계속 안되다보니 이런 생각이 든다.

굳이 뷰 자체를 모두 async 로 만들어야하나?

사실상 오래 걸리는게 chatgpt 부분만 포함되는 거면 저거 받아오는 곳만 async 로 바꾸면 안되는 걸까?

일단 한번 물어본다

일단 일차적으로 해결이 된것 같다..

해결방법은 다음과 같은데,

class RecommendSubTodo(APIView):
    permission_classes = [AllowAny]

    @swagger_auto_schema(
        tags=["RecommendSubTodo"],
        manual_parameters=[
            openapi.Parameter(
                "todo_id",
                openapi.IN_QUERY,
                type=openapi.TYPE_INTEGER,
                description="todo_id",
                required=True,
            ),
        ],
        operation_summary="Recommend subtodo",
        responses={200: SubTodoSerializer},
    )
    def get(self, request):
        """
        - 이 함수는 sub todo를 추천하는 함수입니다.
        """
        set_sentry_user(request.user)

        user_id = request.GET.get("user_id")

        try:
            # 비동기적으로 OpenAI API 호출 처리
            completion = asyncio.run(self.get_openai_completion())

            return Response(
                json.loads(completion.choices[0].message.content),
                status=status.HTTP_200_OK,
            )
        except Exception as e:
            sentry_sdk.capture_exception(e)
            return Response(
                {"error": str(e)}, status=status.HTTP_400_BAD_REQUEST
            )

    # 비동기적으로 OpenAI API를 호출하는 함수
    async def get_openai_completion(self):
        loop = asyncio.get_event_loop()
        with ThreadPoolExecutor() as pool:
            # OpenAI API 호출을 스레드 풀에서 실행
            return await loop.run_in_executor(
                pool,
                lambda: openai_client.chat.completions.create(
                    model="gpt-4o-mini",
                    messages=[
                        {
                            "role": "system",
                            "content": "내가 저녁으로 먹을만한거 5개 정도 추천해줘",
                        },
                    ],
                    response_format={"type": "json_object"},
                ),
            )

이렇게 시도해봤다. 뷰자체는 동기적으로 하되, openai 를 호출하는 부분만 비동기로 했다.

그런데 이렇게 하면 비동기로 하는 이유가 있는지 갑자기 의문이 드는데, 한번 찾아보면 좋을 듯 싶다.

 

todo = Todo.objects.get_with_id(id=todo_id)
# 비동기적으로 OpenAI API 호출 처리
completion = asyncio.run(self.get_openai_completion(todo))

이렇게 작성하니까 이런 에러가 발생했다.

error You cannot call this from an async context - use a thread or sync_to_async.

그래서 느낀 점은 아마 저기서 get_oepnai_completion 일 때 todo 를 불러서 발생하는 에러로 짐작하고 있다.

이걸 참고해보자

https://docs.djangoproject.com/en/5.1/topics/async/

 

Asynchronous support | Django documentation

The web framework for perfectionists with deadlines.

docs.djangoproject.com

 

문제가 계속 돌고돌아서 정리해보자면

일단 await 를 두번해야하는상황이다. todo_id 로 쿼리문 날릴 때 한번, openai 사용할 때 한번

그래서 결국 get 자체를 async 로 처리해야하는 상황이다.

근데 이 경우에 계속 coroutine 관련으로 에러가 발생한다.

https://forum.djangoproject.com/t/assertionerror-expected-a-response-httpresponse-or-httpstreamingresponse-to-be-returned-from-the-view-but-received-a-class-coroutine/22326

 

AssertionError: Expected a `Response`, `HttpResponse` or `HttpStreamingResponse` to be returned from the view, but received a `<

I follow the instruction posted here Django has support for writing asynchronous (“async”) views, along with an entirely async-enabled request stack if you are running under ASGI. Async views will still work under WSGI, but with performance penalties,

forum.djangoproject.com

여기를 한번 참고해서 adrf 를 사용해보려고 했다

https://stackoverflow.com/questions/78452486/assertionerror-expected-a-response-httpresponse-or-streaminghttpresponse

 

AssertionError: Expected a `Response`, `HttpResponse` or `StreamingHttpResponse` to be returned from the view, but received a `<

async def send_stock_updates(symbol): while True: stock_info = get_one_stock_info(symbol) # Send stock info over WebSocket channel_layer = get_channel_layer() a...

stackoverflow.com

근데 그러면 app을 따로 만들어야 할 것 같다....?

이름이 겹쳐서 가능할지모르겠다.

# 비동기적으로 OpenAI API를 호출하는 함수
    async def get_openai_completion(self, todo_id):
        return await openai_client.chat.completions.create(
            model="gpt-4o-mini",
            messages=[
                {
                    "role": "system",
                    "content": """너는 퍼스널 매니저야.
        너가 하는 일은 이 사람이 할 이야기를 듣고 약 1시간 정도면 끝낼 수 있도록 작업을 나눠주는 식으로 진행할 거야.
        아래는 너가 나눠줄 작업 형식이야.
        { id : 1, content: "3학년 2학기 운영체제 중간고사 준비", date="2024-09-01", due_time="2024-09-24"}
        이런  형식으로 작성된 작업을 받았을 때 너는 이 작업을 어떻게 나눠줄 것인지를 알려주면 돼.
        Output a JSON object structured like:
        {id, content, date, due_time, category_id, order, is_completed, children : [
        {content, date, todo(parent todo id)}, ... ,{content, date, todo(parent todo id)}]}
        [조건]
        - date 는 부모의 date를 따를 것
        - 작업은 한 서브투두를 해결하는데 1시간 정도로 이루어지도록 제시할 것
        - 언어는 주어진 todo content의 언어에 따를 것
        """,  # noqa: E501
                },
                # {
                #     "role": "user",
                #     "content": f"id: {todo.id}, \
                # content: {todo.content}, \
                # date: {todo.date}, \
                # due_time: {todo.due_time}, \
                # category_id: {todo.category_id}, \
                # order: {todo.order}, \
                # is_completed: {todo.is_completed}",
                # },
                {
                    "role": "user",
                    "content": "id: 1, \
                content: 저녁으로 삼계탕먹어야지, \
                date: 2024-02-02, \
                due_time: None, \
                category_id: 1, \
                order: a|aaaaaa, \
                is_completed: False",
                },
            ],
            response_format={"type": "json_object"},
        )

db query 에서 발생하는 문제가 맞는 것 같다.

영롱하다.. 잘 나온다... 따이...

그래서 비동기로 db query를 받는 방법을 찾아봐야할 것 같다. 

 

이거는 sync_to_async를 사용하면 되는 것 같긴한데 아래 에러가 발생한다.

error You cannot submit onto CurrentThreadExecutor from its own thread

쓰레드 관련 에러인 것 같다. 

 

여러가지 시도를 해보고 느낀 결과는 저 함수를 잘못 사용하고 있었다. (그간 노력 : async 함수 하나더 만들기, 쓰레드 풀 늘리려고 했으나 아직 시도는 하지 않음)

그냥 저 todo 불러오는 부분에서 에러가 발생하고 있었다. 다른 걸 한번 사용해본다.

 

음 그냥 aget 을 사용하면 되었던 건가..? => 안된다.

 

음.. 아는 어떠한 모든 방식을 사용해도 안되길래 비동기뷰라고만 명시하고 안에는 response만주도록 변경해보니 그래도 coroutine 관련에러가 뜬다. 이러면 이제 uvicorn 문제인갑다.

async def get(self, request):
        """
        - 이 함수는 sub todo를 추천하는 함수입니다.
        """
        return Response(
            {"error": "todo_id must be provided"},
            status=status.HTTP_400_BAD_REQUEST,
        )

모든 걸 날리고 이상태에서도

AssertionError: Expected a `Response`, `HttpResponse` or `StreamingHttpResponse` to be returned from the view, but received a `<class 'coroutine'>`

이 에러가 발생한다.

 

모르겠다....

해결했다.......으어어악

해결 방법은 다음과 같이 했다.

class RecommendSubTodo(APIView):
    permission_classes = [AllowAny]

    @swagger_auto_schema(
        tags=["RecommendSubTodo"],
        manual_parameters=[
            openapi.Parameter(
                "todo_id",
                openapi.IN_QUERY,
                type=openapi.TYPE_INTEGER,
                description="todo_id",
                required=True,
            ),
        ],
        operation_summary="Recommend subtodo",
        responses={200: SubTodoSerializer},
    )
    def get(self, request):
        """
        - 이 함수는 sub todo를 추천하는 함수입니다.
        """
        set_sentry_user(request.user)

        user_id = request.GET.get("user_id")
        flag, message = UserLastUsage.check_rate_limit(
            user_id=user_id, RATE_LIMIT_SECONDS=RATE_LIMIT_SECONDS
        )
        if flag is False:
            return Response(
                {"error": message}, status=status.HTTP_429_TOO_MANY_REQUESTS
            )
        todo_id = request.GET.get("todo_id")
        if todo_id is None:
            sentry_sdk.capture_message("Todo_id not provided", level="error")
            return Response(
                {"error": "todo_id must be provided"},
                status=status.HTTP_400_BAD_REQUEST,
            )

        try:
            # 비동기적으로 OpenAI API 호출 처리
            todo = Todo.objects.get_with_id(id=todo_id)
            todo_data = {
                "id": todo.id,
                "content": todo.content,
                "date": todo.date,
                "due_time": todo.due_time,
                "category_id": todo.category_id,
                "rank": todo.rank,
                "is_completed": todo.is_completed,
            }
            completion = asyncio.run(self.get_openai_completion(todo_data))
            return Response(
                json.loads(completion.choices[0].message.content),
                status=status.HTTP_200_OK,
            )

        except Todo.DoesNotExist as e:
            sentry_sdk.capture_exception(e)
            return Response(
                {"error": "Todo not found"}, status=status.HTTP_404_NOT_FOUND
            )
        except Exception as e:
            sentry_sdk.capture_exception(e)
            print("error", e)
            return Response(
                {"error": str(e)}, status=status.HTTP_400_BAD_REQUEST
            )

    # 비동기적으로 OpenAI API를 호출하는 함수
    async def get_openai_completion(self, todo):
        return await openai_client.chat.completions.create(
            model="gpt-4o-mini",
            messages=[
                {
                    "role": "system",
                    "content": """너는 퍼스널 매니저야.
        너가 하는 일은 이 사람이 할 이야기를 듣고 약 1시간 정도면 끝낼 수 있도록 작업을 나눠주는 식으로 진행할 거야.
        아래는 너가 나눠줄 작업 형식이야.
        { id : 1, content: "3학년 2학기 운영체제 중간고사 준비", date="2024-09-01", due_time="2024-09-24"}
        이런  형식으로 작성된 작업을 받았을 때 너는 이 작업을 어떻게 나눠줄 것인지를 알려주면 돼.
        Output a JSON object structured like:
        {id, content, date, due_time, category_id, rank, is_completed, children : [
        {content, date, todo(parent todo id)}, ... ,{content, date, todo(parent todo id)}]}
        [조건]
        - date 는 부모의 date를 따를 것
        - 작업은 한 서브투두를 해결하는데 1시간 정도로 이루어지도록 제시할 것
        - 언어는 주어진 todo content의 언어에 따를 것
        """,  # noqa: E501
                },
                {
                    "role": "user",
                    "content": f"id: {todo["id"]}, \
                content: {todo["content"]}, \
                date: {todo["date"]}, \
                due_time: {todo["due_time"]}, \
                category_id: {todo["category_id"]}, \
                rank: {todo["rank"]}, \
                is_completed: {todo["is_completed"]}",
                },
            ],
            response_format={"type": "json_object"},
        )

이유는 모르겠지만 get은 뭘해도 비동기가 안되는 거였던 것 같다.

그래서 todo db에서 가져올때 처리한 것을 보내주도록 바꿔줬다...

일단 이렇게 처리하고 나중에 다시 생각해야겠다.... 리팩토링도 나중에....

 

이제 하나 처리했따....

테스트코드변경하고 다음에 해야할 것 같다..

TypeError("object Mock can't be used in 'await' expression")

오.....

해결해야지

해결했따이....

mock 관련 문제는 간단했는데 

뒤에 저 new_callable 붙여주니까 해결되었다.

일단 하나라도 해결되었으니까 행복해야지....


일단 담당 맡으신분이 오늘 저녁에서야 가능하시다고 해서 혹시 저번에 맡으셨던 분에게 어떤 게 올지 한번만 확인해달라고 했다.

그러니까 약 2분만에 이럴 것 같다고 답장이 오셨다. 최고최고

https://docs.expo.dev/versions/latest/sdk/apple-authentication/

 

AppleAuthentication

A library that provides Sign-in with Apple capability for iOS.

docs.expo.dev

 

여기를 사용할 것 같은데,

여기에서 보아하니 email과 jwt 를 줄 수 있는 것 같다.

그러니 email 과 추가적인 정보를 통해서 구글과 마찬가지로 진행하고, jwt 를 검증하면 될 것 같다고 하셨다.

음. 어제 만든 코드는 일단 전부 커밋만 해놓고 다 날려버려야 할 듯 싶다.

 

# 공부할 것 

- async event loop 

- Pytest를 사용한 기본적인 테스트 코드 작성 방법

- pytest scope에 대한 이해

- Python의 비동기(Async) 개념(async/await, 코루틴, event loop)

- 동기 사이에 있는 비동기 함수가 영향이 있을까...?

 


오늘은 여기까지밖에 못할 것 같다...

내일열심히 더 해야지.... 으억억