프로그래밍/생각나는대로 프로젝트

[프로젝트] 07. 가계부 SNS - 리치메이커

Churnobyl 2023. 5. 31. 21:13
728x90
반응형

Rich Maker

 수입/지출을 기록하면 일반적인 소비 성향과 유저의 소비 성향을 비교해 그래프로 보여주고 유저들이 만든 관심 가는 챌린지에 함께 도전해 목돈을 모으는 가계부 SNS 서비스

 


프로젝트 정보


  • 프로젝트: 가계부 SNS 서비스 - 리치 메이커
  • 개발기간: 2023.05.22 - 2023.05.29 ( 7일 )
  • 주요 기능: 영수증 AI 인식 기능, 지출 관리, 챌린지
  • 역할
    • 영수증 AI 인식 기능
    • 그래프 기능
    • 메인 페이지 개발
  • 기술스택:  
    3.11
     
    4.2
     
     
     
     
     
     
    8.0.33

프로젝트 구성


★ 프로젝트 구조

 BE : Django Rest Framework

 FE : Vanilla JavaScript 

 

 


주요 코드 리뷰


01. 쿼리스트링으로 response 다르게 받기

 프론트엔드에서 백엔드인 장고로 챌린지 리스트 api를 요청할 때 view를 늘리고 싶지 않았다. 메인 페이지 index.html에 들어가는 챌린지 리스트와 모든 챌린지 리스트를 출력하는 list.html 챌린지 리스트에 필요한 api가 크게 다르지 않았으므로 쿼리스트링을 통해 다른 요청을 적용했다.

메인 페이지의 챌린지 목록

 

리스트 페이지의 챌린지 목록

 리스트 페이지는 메인 페이지에 보이는 목록 오른쪽의 +버튼을 통해 접근할 수 있다. 각 버튼을 누르면 신규 챌린지와 참여율이 높은 상위 챌린지를 각각 보여주는데 query=top, query=new 쿼리스트링을 통해 다른 결과를 가져오도록 했다.

 우선 프론트엔드쪽 코드를 보자.

 

/* list.js */

window.onload = () => {
  let token = localStorage.getItem("access")
  const urlParams = new URLSearchParams(window.location.search).get("query");
  const pageParams = new URLSearchParams(window.location.search).get("page");
  ChallengeList(urlParams, pageParams);
};

async function ChallengeList(query, page) {
  const queryOrder = query;
  const api =
    Number(page) === 0
      ? `${BACK_BASE_URL}/api/challenge/list?query=${queryOrder}`
      : `${BACK_BASE_URL}/api/challenge/list?page=${page}&query=${queryOrder}`;
  const response = await fetch(api, {
    method: "GET",
  });

  let queryPage = 0;

  if (Number(page) === 0) {
    let queryPage = 1;
  } else {
    let queryPage = Number(page);
  }

  const responseJson = await response.json();
  console.log(responseJson);
  console.log(queryPage);
  const challengeResults = responseJson.results;

  if (queryOrder === 'top') {
    document.querySelector('#challenge-category').innerHTML = '상위 챌린지 목록';
  } else if (queryOrder === 'new') {
    document.querySelector('#challenge-category').innerHTML = '신규 챌린지 목록';
  }

 

 list.js는 페이지가 로딩될 때 URLSearchParams(window.location.search).get("쿼리");를 통해 각각 어떤 페이지를 보여줄 것인가 하는 query와 페이지네이션을 적용한 page쿼리를 각각 받는다. 그리고 ChallengeList함수를 호출해 이 정보를 넘겨주는데, list.html에 처음 접근했을 때는 page정보가 없으므로 1페이지를 요청하기 위해 api변수의 값을 다르게 주었다.

 그리고 그 api변수를 통해 백엔드에 GET방식으로 fetch요청을 날린다.

 

# articles/urls.py

from django.urls import path
from articles import views

urlpatterns = [
	...
    path('challenge/list/', views.ChallengeListView.as_view(), name='challenge_list_view'),
    ...
]

 

백엔드에서는 위와 같은 URL을 통해 ChallengeListView로 연결된다.

 

# articles/views.py

class ChallengeListView(APIView):

    ## 페이지네이션 코드 생략 ##

    def get(self, request):
        if request.GET.get('query') == None:
        
            ## 메인페이지 챌린지 리스트 코드 생략 ##

        elif request.GET.get('query') == 'top':
            top_challenge = Challenge.objects.annotate(total_sum=Sum('bookmarking_people')).order_by('-total_sum')
            page = self.paginate_queryset(top_challenge)
            if page is not None:
                serializer = self.get_paginated_response(self.serializer_class(page, many=True).data)
            else:
                serializer = self.serializer_class(new_challenge, many=True)
            return Response(serializer.data, status=status.HTTP_200_OK)

        elif request.GET.get('query') == 'new':
            new_challenge = Challenge.objects.all().order_by('-created_at')
            page = self.paginate_queryset(new_challenge)
            if page is not None:
                serializer = self.get_paginated_response(self.serializer_class(page, many=True).data)
            else:
                serializer = self.serializer_class(new_challenge, many=True)
            return Response(serializer.data, status=status.HTTP_200_OK)

 ChallengeListView에서는 GET request에 함께 담겨온 'query'를 열어서 if문으로 none, top, new에 따라 각각 다른 코드를 실행하게 한다. query가 없을 경우 메인페이지에서 온 요청이라고 판단해 메인페이지에 필요한 정보를 담아서 보내주고, top일 경우 각 챌린지를 북마크한 사람들의 정보인 bookmarking_people 각각의 합을 구해 내림차순으로 response를 보내준다. new일 경우에는 간단하게 최신순으로 정보를 보내준다.

 

 이렇게 하나의 URL로 다른 response를 받도록 해줄 수 있었다.

 

 

02. AI 영수증 인식

AI 영수증 인식

 내일배움캠프 AI 백엔드 트랙이므로 AI 관련 기능을 넣는 게 이번 프로젝트 요구사항이었다. 우리는 가계부가 컨셉이었기 때문에 영수증을 AI로 인식해 입력하는 데 도움을 줄 수 있게끔 계획을 구상했다. 처음에는 이미지를 pytorch를 이용해 직접 학습시켜 모델을 만들어볼까 생각했었는데, 다양한 양식의 영수증 데이터를 얻을 수 없을 뿐 더러 베이스로 사용할 한글로 된 ocr 모델을 찾기 힘들었다. 따라서 한국 영수증 인식에 최적화된 네이버 클로바 ai의 CLOVA OCR 서비스를 이용했다. 

 

네이버 CLOVA OCR

 

 네이버 클로바 OCR 서비스는 영수증 뿐만 아니라 명함이나 신용카드, 신분증 등에 특화된 서비스를 추가로 신청할 수도 있다. 

 

 아무튼 발급받은 도메인으로 이미지 정보와 함께 api요청을 하면 아래와 같은 json 데이터가 날라온다.

 

{"version":"V2","requestId":"0f39ed74-47e2-46d4-9bbe-01699403f108","timestamp":1685533532883,"images":[{"receipt":{"meta":{"estimatedLanguage":"ko"},"result":{"storeInfo":{"name":{"text":"영통문고","formatted":{"value":"영통문고"},"boundingPolys":[{"vertices":[{"x":504.0,"y":1370.0},{"x":669.0,"y":1271.0},{"x":705.0,"y":1331.0},{"x":539.0,"y":1429.0}]}],"maskingPolys":[]},"bizNum":{"text":"237-77-00400","formatted":{"value":"237-77-00400"},"boundingPolys":[{"vertices":[{"x":716.0,"y":1309.0},{"x":966.0,"y":1159.0},{"x":998.0,"y":1212.0},{"x":749.0,"y":1363.0}]}],"maskingPolys":[]},"addresses":[{"text":"경기도 수원시 영통구 반달로7번길 6 제1층 제110호(영통동, 센타프라자)","formatted":{"value":"경기도 수원시 영통구 반달로7번길 6 제1층 제110호(영통동, 센타프라자)"},"boundingPolys":[{"vertices":[{"x":452.0,"y":1594.0},{"x":750.0,"y":1422.0},{"x":782.0,"y":1477.0},{"x":484.0,"y":1649.0}]},{"vertices":[{"x":559.0,"y":1467.0},{"x":688.0,"y":1391.0},{"x":722.0,"y":1448.0},{"x":593.0,"y":1524.0}]},{"vertices":[{"x":693.0,"y":1385.0},{"x":821.0,"y":1311.0},{"x":855.0,"y":1369.0},{"x":727.0,"y":1443.0}]},{"vertices":[{"x":732.0,"y":1426.0},{"x":961.0,"y":1286.0},{"x":997.0,"y":1345.0},{"x":768.0,"y":1485.0}]},{"vertices":[{"x":823.0,"y":1305.0},{"x":953.0,"y":1231.0},{"x":986.0,"y":1290.0},{"x":857.0,"y":1364.0}]},{"vertices":[{"x":960.0,"y":1225.0},{"x":1192.0,"y":1109.0},{"x":1223.0,"y":1171.0},{"x":991.0,"y":1287.0}]},{"vertices":[{"x":1202.0,"y":1109.0},{"x":1246.0,"y":1109.0},{"x":1246.0,"y":1152.0},{"x":1202.0,"y":1152.0}]},{"vertices":[{"x":1235.0,"y":1095.0},{"x":1341.0,"y":1048.0},{"x":1364.0,"y":1101.0},{"x":1258.0,"y":1147.0}]}],"maskingPolys":[]}],"tel":[{"text":"031-206-0594","formatted":{"value":"0312060594"},"boundingPolys":[{"vertices":[{"x":686.0,"y":1523.0},{"x":936.0,"y":1375.0},{"x":965.0,"y":1426.0},{"x":716.0,"y":1573.0}]}]}]},"paymentInfo":{"date":{"text":"2023/04/08","formatted":{"year":"2023","month":"04","day":"08"},"boundingPolys":[{"vertices":[{"x":706.0,"y":1697.0},{"x":920.0,"y":1574.0},{"x":950.0,"y":1625.0},{"x":735.0,"y":1748.0}]}],"maskingPolys":[]},"time":{"text":"10:07:36","formatted":{"hour":"10","minute":"07","second":"36"},"boundingPolys":[{"vertices":[{"x":1218.0,"y":1402.0},{"x":1387.0,"y":1317.0},{"x":1410.0,"y":1363.0},{"x":1241.0,"y":1448.0}]}]},"cardInfo":{"company":{"text":"KB국민카드","formatted":{"value":"KB국민카
드"},"boundingPolys":[{"vertices":[{"x":1063.0,"y":2115.0},{"x":1287.0,"y":1996.0},{"x":1321.0,"y":2060.0},{"x":1097.0,"y":2178.0}]}],"maskingPolys":[]},"number":{"text":"5570-42**-****-****","formatted":{"value":"5570-42**-****-****"},"boundingPolys":[{"vertices":[{"x":1100.0,"y":2169.0},{"x":1493.0,"y":1958.0},{"x":1519.0,"y":2005.0},{"x":1125.0,"y":2216.0}]}],"maskingPolys":[]}},"confirmNum":{"text":"30021252","boundingPolys":[{"vertices":[{"x":1154.0,"y":2272.0},{"x":1335.0,"y":2170.0},{"x":1365.0,"y":2222.0},{"x":1183.0,"y":2324.0}]}]}},"subResults":[{"items":[{"name":{"text":"유형+내신 고쟁이","formatted":{"value":"유형+내신 고쟁이"},"boundingPolys":[{"vertices":[{"x":651.0,"y":1963.0},{"x":847.0,"y":1860.0},{"x":880.0,"y":1923.0},{"x":684.0,"y":2026.0}]},{"vertices":[{"x":852.0,"y":1858.0},{"x":988.0,"y":1783.0},{"x":1020.0,"y":1843.0},{"x":884.0,"y":1917.0}]}],"maskingPolys":[]},"count":{"text":"1","formatted":{"value":"1"},"boundingPolys":[{"vertices":[{"x":1048.0,"y":1760.0},{"x":1076.0,"y":1760.0},{"x":1076.0,"y":1796.0},{"x":1048.0,"y":1796.0}]}]}}]}],"totalPrice":{"price":{"text":"17,100","formatted":{"value":"17100"},"boundingPolys":[{"vertices":[{"x":1420.0,"y":1676.0},{"x":1553.0,"y":1604.0},{"x":1581.0,"y":1655.0},{"x":1448.0,"y":1728.0}]}]}}}},"uid":"a427fffceae645f5884cae6473439386","name":"demo","inferResult":"SUCCESS","message":"SUCCESS","validationResult":{"result":"NO_REQUESTED"}}]}

 

 이걸 적당히 파싱해서 결과물을 프론트로 다시 돌려줬다. 하지만 이미지에 따라 인식되는 정보가 있고 인식되지 않는 정보가 있어서 인덱싱했을 때 필요한 정보가 리턴되지 않아 오류가 발생했다. DFS나 BFS를 이용해 있는 정보만 탐색해서 들고 오는 방법이 있지 않을까 하고 고민하다가 시간이 촉박해 그냥 전부 get메소드로 대체 딕셔너리를 지정해주는 방법으로 해결했다.

 

    store_name = response_dict.get('receipt', {}).get('result', {}).get('storeInfo', {}).get('name', {}).get('formatted', '')
    biz_num = response_dict.get('receipt', {}).get('result', {}).get('storeInfo', {}).get('bizNum', {}).get('formatted', '')
    address = response_dict.get('receipt', {}).get('result', {}).get('storeInfo', {}).get('addresses', [{}])[0].get('formatted', '')
    phone_number = response_dict.get('receipt', {}).get('result', {}).get('storeInfo', {}).get('tel', [{}])[0].get('formatted', '')
    payment_date = response_dict.get('receipt', {}).get('result', {}).get('paymentInfo', {}).get('date', {}).get('formatted', '')
    payment_time = response_dict.get('receipt', {}).get('result', {}).get('paymentInfo', {}).get('time', {}).get('formatted', '')
    card_company = response_dict.get('receipt', {}).get('result', {}).get('paymentInfo', {}).get('cardInfo', {}).get('company', {}).get('formatted', '')
    card_number = response_dict.get('receipt', {}).get('result', {}).get('paymentInfo', {}).get('cardInfo', {}).get('number', {}).get('formatted', '')
    confirm_number = response_dict.get('receipt', {}).get('result', {}).get('paymentInfo', {}).get('cardInfo', {}).get('confirmNum', {}).get('formatted', '')
    item_name = response_dict.get('receipt', {}).get('result', {}).get('subResults', [{}])[0].get('items', [{}])[0].get('name', {}).get('formatted', '')
    item_count = response_dict.get('receipt', {}).get('result', {}).get('subResults', [{}])[0].get('items', [{}])[0].get('count', {}).get('formatted', '')
    total_price = response_dict.get('receipt', {}).get('result', {}).get('totalPrice', {}).get('price', {}).get('formatted', '')

 엄청 보기 싫기 때문에 나중에 더 연구해서 코드를 리팩토링하려고 한다.

 


프로젝트 결과물

BackEnd

https://github.com/Churnobyl/richMaker.git

 

GitHub - Churnobyl/richMaker: 23.05.22. B2코인 머신러닝 프로젝트

23.05.22. B2코인 머신러닝 프로젝트 . Contribute to Churnobyl/richMaker development by creating an account on GitHub.

github.com

 

FrontEnd

https://github.com/Churnobyl/richMaker_front.git

 

GitHub - Churnobyl/richMaker_front: 23.05.23

23.05.23. Contribute to Churnobyl/richMaker_front development by creating an account on GitHub.

github.com

 


프로젝트 완성 후 감상

 이번 프로젝트도 매일 새벽까지 하는 코딩 끝에 어떻게 마무리 지었다.항상 백엔드보다 프론트엔드가 오래 걸린다. 

반응형