졸업 프로젝트로 카드 다보유자를 위한 결제 전 최대 할인 카드를 추천해 주는 AI 챗봇 서비스, ONCE를 구현 중에 있다.
원스는 사용자가 보유한 카드 중 특정 결제처에서 최대 할인을 받을 수 있는 카드를 추천함으로써, 소비자가 모든 카드의 혜택을 최대한 손쉽게 활용할 수 있는 솔루션을 제공한다.
가장 핵심적인 서비스인 "카드 추천"의 구현을 위한 과정과 그 기술들을 정리해보고자 한다.
최대 혜택을 주는 카드 추천 챗봇을 구현하기 위해 많은 시행착오와 결정이 있었다.
주어진 여러 카드들의 조합에서 최대의 혜택을 주는 카드를 "추천"한다는 점에서 추천 모델 사용을 고려하였지만, 사용자의 선호도 또는 과거의 기록과는 상관없는 단발적인 선택에 해당되기에, 생성형 ai를 사용하고, 가능하다면 파인튜닝하여 해당 작업에 대한 적합도를 높이기로 하였다.
보유 카드의 크롤링한 혜택 정보 전부를 GPT와 Gemini에 입력하였다.
너는 사용자의 결제 금액과 전월실적을 고려해 결제처에 가장 좋은 카드와 해당 혜택을 20글자 이내로 요약해 알려주는 카드 추천 보조야.
혜택 정보에서 각 카드는 \n로 구분되고, 카드의 분야별 혜택은 ###로 구분돼
이후 user가 내가 가진 카드 혜택이뭐야?
라고 하면, 챗봇에 해당하는 assistant가 각 카드들의 정보를 나열하였다.InvalidRequestError: This model's maximum context length is 16385 tokens.
에러를 마주치게 된다. GPT로 보낼 수 있는 context의 길이가 최대 16385 토큰이라는 것이다.줄바꿈으로 구분되어 입력된 여러 카드들 중 어떤 카드의 어떤 혜택이 결제처에서 최고의 할인을 가져다줄지 골라주세요.\n
결제처, 결제금액, 여러 카드의 정보가 쉼표로 구분되어 입력됩니다.
입력된 결제처는 브랜드일수도 특정 분야일수도 있으니 카드 헤택 정보에서 관련이 있는 분야와 브랜드를 모두 잘 찾아야 합니다.
카드 정보는 여러 개의 {카드이름, 카드 고유 번호, 혜택 문자열}로 이루어집니다.
각 카드들은 줄바꿈으로 구분되어있으니 줄이 바뀐다면 그 다음 카드의 정보를 나타낸다는 것을 기억하세요.
한 카드 안에서 분야별 혜택은 ###로 구분되어 있습니다.
각각의 카드가 입력된 결제처에 해당되는 혜택을 가지고 있다면 할인 금액을 계산하고,
여러 카드 중 가장 할인 금액이 큰 카드의 고유번호, 결제처에 해당되는 혜택의 20글자 이내 요약 내용,
해당 혜택 적용 시 받게되는 할인 금액을 쉼표로 구분해 출력하세요.
할인이 n원이라고 써있으면 n원, n%라고 써있으면 결제금액의 n%를 직접 계산해야 합니다.
여러 입력으로 테스트를 해보던 중 gemini가 카드들이 여러장이고, 각각의 혜택이 존재함을 인식하지 못하고 있다는 느낌이 들어, 두가지 카드의 혜택 정보를 순서만 바꾸어 입력해 본 결과 다음과 같은 결과가 출력되었다.무조건 앞에 있는 카드의 혜택을 찾아 출력한다.이러한 과정을 통해 Gemini는 카드 추천에 적합하지 않다고 판단하여, GPT를 사용하기로 결정하였다.
GPT를 선택하였다면, 다음으로 구체적으로 GPT의 어떤 모델을 사용할지 결정해야 한다.
GPT-4가 가장 최신 버전으로, GPT-3.5보다 더 높은 정확도와 언어 이해력을 가지고 있지만, 일반적으로 더 느린 응답 시간을 가진다.
실시간 추천 시스템에서 응답 시간은 매우 중요한 요소이기 때문에, 각 모델을 사용했을 때 걸리는 시간을 비교해보았다.
로컬에서 python으로 호출해 걸리는 시간은 실제 서비스와는 다를 수 있기 때문에, 백엔드 서버에서 각 모델을 활용해 호출 했을 때의 응답 시간을 확인하였다.
또한, GPT-4모델과 GPT-3.5모델은 가격 차이도 크다.
따라서 비용과 성능의 균형을 맞춘 GPT-3.5 Turbo모델을 선택하였다.
Gemini 대신 정확도가 높은 GPT 모델을 사용하되, 토큰이 부족한 문제를 해결하기 위해 크롤링한 결과를 요약하여 카드 추천을 진행하기로 하였다.
카드 혜택 요약 또한 GPT를 이용하였다. 처음 작성한 system은 다음과 같다.
너는 크롤링한 카드의 혜택 정보을 분야별로 정리하는 데이터 추출 전문가야.
데이터를 다음 키로 JSON 형식의 list로 제공해 줘: 혜택 분야-benefit_field, 혜택 정보-content.
예를들어, ‘<대중교통(버스, 지하철)> [대중교통(버스, 지하철)10% 청구할인] 대중교통(버스, 지하철)10% 청구할인 표의 칼럼은
서비스 구분, 전월 이용실적 구간에 따른 할인률, 전월 이용실적 구간에 따른 월 할인한도, 1구간(40만원이상) 로 이루어져 있습니다.
표의 1번째 행은 대중교통(버스 지하철) 청구할인, 10%, 7천원 로 이루어져 있습니다.대상 : 버스, 지하철택시, 시외버스, 고속버스 제외
버스/지하철 요금할인은 실제 카드 사용일이 아닌 이용대금명세서상 기재된 이용일을 기준으로 서비스 제공
<편의점(GS25, CU)>[편의점(GS25, CU)10% 청구할인] 편의점(GS25, CU)10% 청구할인 표의 칼럼은 서비스 구분,
전월 이용실적 구간에 따른 할인률, 전월 이용실적 구간에 따른 월 할인한도, 1구간(40만원이상) 로 이루어져 있습니다.
표의 1번째 행은 편의점(GS25 CU) 청구할인, 10%, 5천원 로 이루어져 있습니다.대상 : GS25, CU백화점, 대형쇼핑몰,
역사(지하철, 철도, KTX 등) 내 입점한 가맹점의 경우, 할인대상에서 제외될 수 있음’은
‘[{“benefit_field”: “대중교통(버스,지하철)”,“content”: “10%할인, 최대 7천원“},
{“benefit_field”: “편의점(GS25, CU)”,“content”: “10%할인”}]’로 요약해줘
하지만 여러 카드를 요약해 보니, 터무니없는 결과를 반환한 카드들이 있어 분석해 보았다.
아래 사진처럼, 카드의 혜택 정보에 표가 존재하는 경우가 있다.
크롤링 할 때, table은 아래의 코드를 통해 표임을 알리고, "표의 칼럼은 ~ 로 이루어져 있습니다." "행은 ~ 로 이루어져 있습니다"등의 문구를 붙여 표를 기술하였다.
def findtableortext(str): # 테이블 추출
try:
thead = str.find('thead').findAll('th')
sentence = " 표의 칼럼은 "
for columnname in thead:
columnname=columnname.text
sentence+=columnname.replace('\n','')+", "
sentence=sentence[0:-2]
sentence += " 로 이루어져 있습니다."
j=1
tbodies = str.find('tbody').findAll('tr')
for tbody in tbodies:
tds = tbody.findAll('td')
sentence += f"표의 {j}번째 행은 "
for td in tds:
sentence+=td.text.replace(",","").replace('\n','')+", "
sentence=sentence[0:-2]
sentence += " 로 이루어져 있습니다."
j+=1
return sentence
except:
sentence=str.text.replace('\n', '')
return sentence
하지만 해당 표는 셀들이 병합되어 있기 때문에, html을 한줄씩 읽었을 때, 병합된 칸은 한번만 읽히고, 제대로 데이터를 수집할 수 없다.
따라서 다음과 같은 요약 결과가 생성된다.
셀을 병합한 경우를 모두 처리하는 코드를 작성하는 것은 불가능하고, 병합되지 않은 경우에도 gpt가 말로 표현된 표를 잘 이해하지 못하고 있기 때문에, table은 html을 그대로 전체 혜택에 포함하도록 크롤링 코드를 수정하였다.
크롤링한 결과는 다음과 같다.
[ 청구 할인 서비스 ] <table cellpadding="0" cellspacing="0" class="tblH mT20">
<caption>청구 할인 서비스</caption> <thead> <tr> <th scope="col">구분</th> <th colspan="2" scope="col">세부 영역</th>
<th scope="col">할인율</th> <th scope="col">월 할인한도</th> <th scope="col">전월 실적</th> </tr> </thead>
<tbody> <tr> <td>대중교통 할인</td> <td colspan="2">버스, 지하철</td> <td>10%</td>
...생략...
합 계</td> <td>1만5천원</td> <td></td> </tr> </tbody> </table>
대중교통 : 택시, 시외버스, 고속버스, 공항버스 제외* 버스/지하철 요금할인은 실제 카드 사용일이 아닌 이용대금명세서 상 기재된
이용일을 기준으로 서비스 제공 실적 유예 제공 : 최초 카드 사용 등록 후 다음 달 말일까지 전월 실적 조건 미달 시에도 서비스 제공
이를 요약하면 이러한 결과가 나온다. 이전과는 다르게, 셀이 병합된 커피·약국·편의점·영화 모두 혜택 정보가 잘 담겨있는 모습이다.
이에 따라 예시로 표가 들어있던 system 또한 변경하였다. GPT가 요약은 잘 하기에, input예시는 제거하고 output의 예시를 들어 형식을 고정시키기 위해 노력하였다.
입력된 데이터를 key를 가지는 list로 요약하여 제공해 줘 [benefit_field, content]\\
nbenefit_field는 혜택의 분야, content는 혜택 할인율 정보를 핵심만 나타냄.\\
혜택과 관련없는 내용은 지우고, 혜택 분야는 최대한 세분화 할 것\\
output 형식의 예시는 다음과 같음
[{\n \"benefit_field\": \"편의점\",\n \"content\": \"4대 편의점 이용 시 10% 적립\"\n },\n
{\n \"benefit_field\": \"커피 업종\",\n \"content\": \"10% 적립\"\n },\n
{\n \"benefit_field\": \"해외 이용금액\",\n \"content\": \"1% 적립\"\n },\n
{\n \"benefit_field\": \"디지털 구독(넷플릭스, 쿠팡플레이)\",\n \"content\": \"10% 적립\"\n},\n} ]
예시를 구체적으로 명시하고 출력 형식을 json으로 지정하니, 카드 혜택 정보가 정형화되고 단순한 혜택 정보를 포함하게 되었다.
3월 한달에 걸쳐 카드 혜택 정보 요약의 구현을 완료한 후, 다시 카드 추천 구현을 시작하였다.
첫번째 시도
요약 후 첫번째로 test한 system은 다음과 같다.
결제처, 결제금액, 카드들의 혜택 정보를 Input으로 하여 결제처에서 최적의 혜택을 누릴 수 있는 카드 번호, 혜택 정보, 할인 금액을 알려주어야 함.\n카드들의 혜택 정보에서 각 카드는 ‘/////’ 로 구분되고, 각 카드가 입력된 결제처에 해당되는 혜택을 가지고 있다면 할인 금액을 계산하고, 여러 카드 중 가장 할인 금액이 큰 카드의 고유번호 숫자, 결제처에 해당되는 혜택 정보 요약 텍스트(특수문자 없어야 함), 해당 혜택 적용 시 받게되는 할인 금액 숫자를 쉼표로 구분하여 제공해야 함. \n
user에 넣는 카드 혜택 정보는 이전과 다르게 요약한 혜택 정보로, 요약된 내용을 ‘/////’로 구분하여 입력하였다.
그 결과, 결제처에 따른 카드 추천 결과가 예상되로 출력되었으며, 전보다 높은 정확도를 확인할 수 있었다.
JSON 형식 도입
카드 혜택을 요약하는 과정에서 JSON같은 key, value 형식을 지정하므로써 출력 형태를 고정하고 원하는 결과를 얻는데 도움이 된다는 교수님의 조언을 받았었다. 이는 카드 혜택 요약 과정에서 큰 도움이 되었다.
카드 추천 또한 JSON 형식을 사용하면 더 높은 정확도와 정형화된 형식을 얻을 수 있겠다는 생각이 들었고, 바로 적용해 보았다. (왜 진작에 json을 사용하지 않았나 아쉽기도 했다:)))
반환값의 json 형식을 지정한 system은 다음과 같다.
결제처, 결제 금액, 카드들의 혜택정보를 Input으로 하여, 결제처에서 최적의 혜택을 누릴 수 있는 카드를 ”카드번호”,
“혜택 정보”, “할인 금액”을 키로 가지는 json형식으로 반환. output에 ``` 붙이지 말 것.\
각 카드가 입력된 결제처에 해당되는 혜택을 가지고 있다면 할인 금액을 계산하고, 여러 카드 중 가장 할인 금액이 큰 카드를 찾아낼 것\
”카드번호” 는 해당 카드의 '카드 고유 번호', “혜택 정보”는 결제처에 해당되는 혜택 정보 요약 텍스트(특수문자 없이 20자 이내)
결제처와 결제금액, 카드들의 혜택 정보 또한 JSON으로 제공하였다. 기존에 각 카드들의 혜택정보를 독립적으로 이해하지 못하는 경우에 대해, Json의 list로 카드들을 분리하는 것이 큰 도움이 될 것이라 예상되었다.
GPT에 전송되는 입력인 user 프롬프트는 다음과 같다.
{
”결제 금액”: 20000,
“결제처”: “CU”,
“카드들의 혜택 정보”:[
{
”이름”: “다담카드(비 OTP)”,
“카드 고유 번호” : 696,
“혜택”: “...생략...”
},
{
”이름”: “My WE:SH 카드”,
“카드 고유 번호” : 649 ,
“혜택”: “...생략...”
]
}
요청을 보낸 결과, json으로 정형화된 형식과 훨씬 정확한 카드 추천 결과가 반환되었다.
마지막으로, 카드 추천이라는 downstream task에 적합한 모델을 생성하기 위하여 GPT-3.5 Turbo 모델의 파인튜닝을 진행했다.
데이터셋 구축
총 100개의 데이터셋 중 training dataset, validation dataset, test dataset을 각각 6:2:2의 비율로 나누어 사용했다. 데이터가 충분하지 않은 경우, 주로 이러한 비율을 선택한다.
정확도가 높은 파인튜닝 모델을 위해 특정 결제처와 관련된 혜택을 가진 카드들을 비교하여, 가장 많은 할인을 제공하는 카드를 직접 정답으로 선택하는 방식으로 데이터셋을 생성했다.
1인당 신용카드 보유 수는 평균 4.4장이므로, 각 데이터마다 3-5장의 카드를 가지도록 데이터셋을 구축했다. 이때 실제 카드 사용자들이 많을 것으로 예상되는 6개 카드사의 인기 카드의 비중을 높게 책정했다.
아래의 이미지는 직접 구축한 데이터셋의 일부이며, 최종 발표 전까지 카드 추천 모델의 정확도 향상을 위해 더 많은 데이터셋을 구축할 계획이다.
파인튜닝
위에서 생성한 training dateset과 validation dataset을 이용해 모델 학습을 위한 json 파일을 생성했다.해당 프롬프트 string를 생성하기 위해, 임의의 api를 만들어 큰 도움을 받았다. 결제처, 결제금액, 사용할 카드들과 함께 예상 답변인 카드 id와 혜택 정보, 할인 금액을 입력하면 이를 프롬프트 형식에 넣어서 반환하게 하였다.구체적인 파인튜닝 구현 코드는 아래에서 설명하겠다.
파인튜닝 결과 확인
OpenAI API 사이트의 Playground에서 파인튜닝한 모델의 id를 선택하여, 해당 모델을 테스트해 볼 수 있다.
다음 이미지는 앞서 생성한 파인튜닝 모델에 ‘이마트24’에서 최대 할인을 받을 수 있는 카드 추천을 요청한 결과이다.보유 중인 ‘신한카드 SOL 트래블 체크’, ‘#MY WAY(샵 마이웨이) 카드’, ‘LIKIT all 체크카드’ 중에 서 ‘이마트24’와 관련된 혜택을 찾은 후, ‘#MY WAY(샵 마이웨이) 카드’를 사용하면 가장 많은 할인을 받을 수 있다는 결과가 반환되었다.
파인튜닝 성능 측정
구체적으로 20개의 test dataset에 대하여, 파인튜닝된 모델과 파인튜닝 없이 gpt-3.5-turbo-
0125 모델에 프롬프트로 요청을 보내 테스트한 결과를 비교해 보았다.
아래 표에서 빨간색 배경으로 표시한 부분이 잘못된 결과를 응답한 경우를 나타낸다.
결과를 분석해 보면, 파인튜닝된 모델은 20개의 test dataset에 대하여 예상과 다른 결과가 3번 출력되었다. 반면, 프롬프트의 경우, 11번의 오류가 발생하였다.
즉, 파인튜닝 없이 프롬프트만을 사용할 경우, 요청을 보낼 때마다 반환되는 결과가 달라지며 모든 카드의 혜택을 정확히 이해하지 못하여 할인 금액이 가장 크지 않은 카드를 추천하는 등 잘못된 응답 결과가 발생하였다. 특히, 대중교통과 관련된 키워드를 입력할 경우에 ‘지하철’, ‘버스’ 등의 단어들을 교통 카테고리와 연관짓지 못하여 전혀 다른 분야의 혜택을 반환하는 것을 발견했다. 또한, 해당 카드가 가지지 않은 혜택 정보를 출력하거나, 올바른 혜택을 찾아냈음에도 할인 금액을 잘못 계산하는 오류도 발견되었다.
즉, 파인튜닝된 모델을 사용할 경우에 카드 추천 결과의 정확도가 높다는 것이 검증되었다.
따라서 Once의 주요 기능을 개발하기 위해 파인튜닝된 GPT-3.5 Turbo 모델을 최종 선택해 기술을 구현했다.
위의 과정을 거쳐 선택된 카드 추천 모델을 구현하기 위한 구체적인 기술과 코드들에 대해 설명하겠다.
Web상에 존재하는 Contents를 수집 하는 작업이다. (프로그래밍으로 자동화 가능)
원스에서는 카드들의 혜택 정보를 직접 크롤링해, 정확한 최신 버전의 카드 혜택을 가져온다.
우선적으로 국민 카드, 현대 카드, 삼성 카드, 신한 카드, 롯데 카드, 하나 카드의 카드들을 활용하며, 추후 서비스를 발전시키며 이외의 카드사들도 연결할 예정이다.
6개 카드사의 혜택 정보를 3명의 팀원이 2개씩 나눠 크롤링을 진행했으며, 나는 국민 카드와 롯데 카드의 크롤링을 담당하였다.
html의 구조를 파악하여 원하는 값을 추출하는 과정은 이전 포스트에 있어 생략하고, 추가되거나 발전된 내용을 소개하겠다.
카드들의 정보는 csv로 저장한 뒤 db에 접속해 한번에 저장한다.
csv의 열이 될 정보들의 리스트를 선언한다
name = []
img_url = []
benefits = []
created_at = []
국민카드의 경우 전체 카드를 카테고리에 따라 분류해 제공하는데, 카테고리마다 중복되는 카드들이 존재한다. 이는 name 리스트 안의 중복여부에 따라 크롤링 진행 여부를 결정하여 해결한다.
cardName=card_bs.find('h1',{'class','tit'}).text # 카드 이름 추출
if cardName not in name: # 카드 존재 여부 확인
name.append(cardName)
print(datetime.now().strftime("%Y-%m-%d %H:%M:%S")+" ["+cardName+"] --- 웹 페이지에 접속 중... ")
else: # 이미 존재하는 경우 continue(크롤링 진행하지 않음)
print(datetime.now().strftime("%Y-%m-%d %H:%M:%S")+" ["+cardName+"] --- 이미 존재하는 카드입니다 ")
continue
카드별로 크롤링 완료할때마다 위에 정의한 list에 append한다.
모든 카드들의 크롤링이 완료되면, 데이터들을 모아 csv로 저장한다.
data = {"card_company_id":card_company_id, "name" : name, "img_url" : img_url, "benefits": benefits, "created_at": created_at,"type":type}
df = pd.DataFrame(data)
df.to_csv("./credit_benefit.csv", encoding = "utf-8-sig", index=False)
저장된 csv는 다음과 같다.
다른 카드 사이트의 경우 일관되게 가로로 누운 카드 모양이거나, 세로로 긴 카드 모양을 가지고 있어 카드사 종류에 따라 처리하면 되었다.
하지만 롯데 카드 사이트는 카드들 이미지의 가로/세로 비율이 무작위로 가로되어있다는 문제점이 존재했다.
이에 따라 크롤링 과정에서 이미지가 세로인 경우에만 aws S3에 변환해 저장한 뒤 해당 url을 카드 이미지로 사용하였다.
S3 연결
config = configparser.ConfigParser()
config.read('/crawling/config.ini')
AWS_S3_ACCESSKEY = config['s3']['AWS_S3_ACCESSKEY']
AWS_S3_SECRETKEY = config['s3']['AWS_S3_SECRETKEY']
AWS_S3_BUCKET = config['s3']['AWS_S3_BUCKET']
AWS_S3_REGION = config['s3']['AWS_S3_REGION']
S3의 ACCESSKEY, SECRETKEY등의 정보는 인터넷 상에 공개되어서는 안되기 때문에, config.ini
파일로 분리하였다.
def s3_connection():
try:
s3 = boto3.client(
service_name="s3",
region_name="ap-northeast-2",
aws_access_key_id="{AWS_S3_ACCESSKEY}",
aws_secret_access_key="{AWS_S3_SECRETKEY}",
)
except Exception as e:
print(e)
else:
print("s3 연결 성공")
return s3
이미지 저장 함수 호출
크롤링을 통해 얻은 카드 이미지의 url을 매개변수로 넣어 s3_put_object함수를 호출하였다.
cardImg= "https:" + card.find('img').get('src')
img_url.append(s3_put_object(cardImg,cardNo))
s3_put_object 함수
받은 이미지 url을 직접 이미지 객체로 변환한 뒤, 이미지의 가로폭이 세로폭보다 짧은 경우에만 90° 회전한 뒤 바이너리 스트림에 저장하였다.이미지를 AWS S3에 업로드한 뒤, 해당 url을 반환하였다.
이미지가 가로로 되어있다면, 그대로 받은 이미지 url을 반환하였다.
from io import BytesIO
from PIL import Image
import boto3
def s3_put_object(cardImg,cardNo):
try:
data = urlopen(cardImg).read()
img = Image.open(BytesIO(data))
w, h = img.size
if w < h : # 이미지가 세로인 경우
img = img.rotate(90, expand=True) # 회전
image_fileobj = BytesIO()
img.save(image_fileobj, format='PNG')
image_fileobj.seek(0)
# S3에 업로드
s3.upload_fileobj(image_fileobj, "{AWS_S3_BUCKET}", "lottecard/"+cardNo+".png",ExtraArgs={"ContentType": "image/jpg", "ACL": "public-read"})
return "https://{AWS_S3_BUCKET}.s3.{AWS_S3_REGION}.amazonaws.com/lottecard/"+cardNo+".png"
else:
return cardImg
except Exception as e:
return False
csv에 저장된 데이터는 일괄적으로 RDS에 업로드하였다. 코드의 재사용성을 높이기 위해, 카드 회사 이름과 신용카드/체크카드를 명령줄인수로 받아 사용하였다. 즉, 서로 다른 코드로 모인 각 카드사의 카드 혜택 정보는 모두 이 코드를 통해 rds에 저장된다.
rds 연결
config = configparser.ConfigParser()
config.read('./crawling/config.ini')
db_host = config['database']['host']
db_user = config['database']['user']
db_password = config['database']['password']
db_database = config['database']['database']
db_charset = config['database']['charset']
rds또한 s3와 마찬가지로, config.ini
파일을 통해 계정정보를 보호한다.
connection = pymysql.connect(
host=db_host,
user=db_user,
password=db_password,
database=db_database,
charset=db_charset,
cursorclass=pymysql.cursors.DictCursor
)
csv 읽기
with open(f'./{csv_file}_benefit.csv', 'r', encoding='utf-8') as csvfile:
csvreader = csv.reader(csvfile)
next(csvreader)
for row in csvreader:
card_company_id, name, img_url, benefits, created_at, type = row
for문으로 모든 row에 접근중이며, card_company_id, name, img_url, benefits, created_at, type변수에 현재 row의 값들이 들어있다.
rds에 삽입
insert_query = """
INSERT INTO card
(card_company_id, name, img_url, benefits, created_at, type)
VALUES (%s, %s, %s, %s, %s, %s)
"""
cursor.execute(insert_query, (card_company_id, name, img_url, benefits, created_at, type))
원하는 동작의 mysql문을 작성한 뒤, cursor.execute()
로 실행할 수 있다.
크롤링 최종 코드는 깃허브에서 확인 가능하다.
카드들의 부가서비스는 간혹 변경되기도 하고, 카드 자체가 단종되기도 한다.
이러한 데이터의 변화를 원스 서비스의 데이터 베이스에 즉각적으로 반영하는것이 중요하다.
따라서 주기적으로 카드 혜택 업데이트를 진행한다.
각 카드들의 혜택 정보 이외에 카드사별 이벤트성 혜택 또한 크롤링 하여 사용한다.
카드의 혜택 정보는 주 1회, 이벤트성 혜택은 매일 업데이트를 진행한다.
가장 핵심인 카드 혜택 업데이트를 설명하겠다.
Spring Boot에서는 @Scheduled 어노테이션을 이용하여 스케쥴러를 구현할 수 있다.
먼저, @Scheduled를 사용하기 위해 Application 클래스에 @EnableScheduling을 추가해준다.
@SpringBootApplication
@EnableScheduling
public class OnceApplication {
public static void main(String[] args) {
SpringApplication.run(OnceApplication.class, args);
}
}
스케쥴러는 스프링 빈에 등록되어야 한다. @Service 애노테이션을 이용해서 빈에 등록하였다.
@Service
@Slf4j
@RequiredArgsConstructor
public class CrawlingService {
// 매주 월요일 00:00 카드 혜택 크롤링
@Scheduled(cron = "0 0 0 ? * 1")
public void cardCrawling() throws CustomException {
String[] cardCompanyList = {"Kookmin", "Hana", "Samsung", "Shinhan", "Lotte", "Hyundai"};
for (String cardCompany : cardCompanyList){
crawling(cardCompany);
}
}
}
@Scheduled 속성에는 크게 fixedDelay, fixedRate, initDelay, cron 등이 있는데,
난 Scheduling의 정규 표현식인 cron 표현식을 사용하였다.
cron 표현식
cron은 아래와 같이 총 6개의 필드로 구성된다.
필드 명 | 값의 허용 범위 | 허용된 특수문자 |
---|---|---|
초 (Seconds) | 0 ~ 59 | , - * / |
분 (Minutes) | 0 ~ 59 | , - * / |
시 (Hours) | 0 ~ 23 | , - * / |
일 (Day) | 1 ~ 31 | , - * ? / L W |
월 (Month) | 1 ~ 12 or JAN ~ DEC | , - * / |
요일(Week) | 0 ~ 6 or SUN ~ SAT | , - * ? / L # |
특수 문자
특수 문자 | 설명 | 예제 |
---|---|---|
* | 모든 값 의미 | |
? | 특정한 값이 없음을 의미 | |
- | 범위를 나타낼 때 사용 | 월요일부터 수요일까지 -> MON-WED |
, | 특정 값을 여러 개 나열할 때 사용 | 월,수,금 -> MON,WED,FRI |
/ | 시작 시간 / 단위 | 0분부터 매 5분 -> 0/5 |
L | 일에서 사용하면 마지막 일, 요일에서 사용하면 마지막 요일(토요일) | |
W | 가장 가까운 평일 | 15W -> 15일에서 가장 가까운 평일 |
# | 몇째 주의 무슨 요일을 표현 | 3#2 -> 2번째주 수요일 |
private static void crawling(String cardCompany) throws CustomException{
LOG.info(cardCompany+" 크롤링 시작");
executeFile(cardCompany+"/credit.py");
executeInsertData(cardCompany,"Credit");
executeFile(cardCompany+"/debit.py");
executeInsertData(cardCompany,"Debit");
}
crawling함수에서는 크롤링 시작을 알리는 로그를 찍은 뒤,
신용카드 크롤링 코드 실행->DB에 반영->체크카드 크롤링 코드 실행->DB에 반영
의 과정을 거친다.
스프링 부트는 Java 프레임워크이기 때문에, python을 실행하기 위해 몇가지 설정이 필요하다.
우선, 크롤링 코드(python)는 src/main/resources/crawling/{카드사 이름}/
경로 안에 위치한다.
1) 도커 설정
스프링부트로 구현된 백엔드 서버는 도커를 통해 컨테이너화하고, 깃허브 액션을 통해 CI/CD 파이프라인을 구축하여 배포를 자동화하고 있다.
즉, EC2 서버에 SSH로 접속했을 때 애플리케이션 파일이 서버 파일 시스템에 직접 보이지 않고 도커 컨테이너 내부에 애플리케이션 파일이 존재한다.
스프링 부트 JAR 파일 내에서 Python 파일을 직접 실행하는 것은 지원이 되지 않기 때문에, jar파일이 아닌 도커 환경에서 python을 실행해야 한다. 이를 위해 python 관련 라이브러리 설치, 크롤링 코드 도커 안에 복사의 과정이 필요하다.
이 작업은 도커파일에서 진행된다. python 실행과 관련된 코드들만 가져와 설명하겠다.
RUN apt-get update && apt-get install -y python3 python3-pip wget unzip curl
파이썬을 설치한다.
RUN wget https://dl.google.com/linux/chrome/deb/pool/main/g/google-chrome-stable/google-chrome-stable_114.0.5735.198-1_amd64.deb && \
apt -y install ./google-chrome-stable_114.0.5735.198-1_amd64.deb
RUN wget -O /tmp/chromedriver.zip https://chromedriver.storage.googleapis.com/114.0.5735.90/chromedriver_linux64.zip && \
unzip /tmp/chromedriver.zip -d /usr/bin && \
chmod +x /usr/bin/chromedriver
동적 크롤링을 위해 크롬을 설치해야 한다.
chromedriver과 google-chrome 모두 다운로드 해준다.
COPY ./requirements.txt .
RUN pip install --no-cache-dir --upgrade pip && \
pip install -r requirements.txt
크롤링을 위해 필요한 python의 라이브러리가 정리된 requirements.txt파일을 가져와 모두 install 해준다.
COPY ./src/main/resources/crawling /crawling
깃허브의 크롤링 코드들을 도커 안에 그대로 copy해준다.
이제 도커 안에서 python 파일을 실행할 준비가 완료됐다.
2) 크롤링 코드 수정
크롤링 코드들은 로컬에서 돌릴때와는 파일의 위치가 달라졌다. 이에 따라 python 코드 안에서 csv를 저장하고, 가져오기 위해 명시된 경로 또한 변경해 주어야 한다
./credit_benefit.csv
-> /app/src/main/resources/crawling/Kookmin/credit_benefit.csv
그냥 바로 가져다 쓰던 파일들 앞에 모두 /app/src/main/resources/crawling/
라는 도커 안에서의 절대 경로를 적어준다.
웹 드라이버를 사용하기 위해 로컬에서는 다음과 같이 코드를 작성하였다.
chrome_options = Options()
chrome_options.add_argument('--headless')
chrome_options.add_argument('--disable-web-security')
driver = webdriver.Chrome(service=Service(ChromeDriverManager().install()), options=chrome_options)
하지만 도커에서 돌리기 위해서는 다음과 같이 option을 추가하고, Service를 위해 실행 경로를 명시해 주어야 한다.
chrome_options = Options()
chrome_options.add_argument('--headless')
chrome_options.add_argument('--no-sandbox')
chrome_options.add_argument("--disable-dev-shm-usage")
service = Service(executable_path=r'/usr/bin/chromedriver')
driver = webdriver.Chrome(service=service,options=chrome_options)
3) spring boot에서 python 실행
Java의 ProcessBuilder를 활용하면 다른 외부 프로세스를 실행시키거나 컨트롤 할 수 있다.
ProcessBuilder pb = new ProcessBuilder("python3", "-u", "/crawling/"+path);
pb.redirectErrorStream(true);
Process p = pb.start();
BufferedReader br = new BufferedReader(new InputStreamReader(p.getInputStream()));
String line;
while ((line = br.readLine()) != null) {
// 실행 결과 처리
LOG.info(line);
}
p.waitFor();
이 작업을 완료하면, 월요일 00:00이후 EC2 서버에 들어가 sudo docker logs web
명령어를 입력해 다음과 같이 크롤링 자동화가 진행되고 있는 것을 확인할 수 있다.
크롤링 자동화는 전체 개발 과정중에서도 가장 큰 이슈 중 하나였다.
로컬에서는 어렵지 않게 구현하였지만, 서버에서 실행하는 것은 차원이 다른 문제였다.
수많은 오류를 겪었지만 큰 에러들을 정리해보겠다
1) csv 파일 위치
서버에서 돌아갈 때 깃허브에 있는 파일이 자꾸 없다고 오류가 떠서 당황스러웠던 기억이 있다.
docker exec -it web sh
명령어를 통해 직접 도커 컨테이너의 쉘로 접속하고,
jar tf app.jar
로 파일이 존재하는지 확인했다.
결과적으로는 jar 안이 아닌 도커 컨테이너 안에서 python을 실행해 해결할 수 있었고, 그러기 위해 도커에 crawling파일들을 복사하였다.
2) webdriver
동적 크롤링인 국민 카드의 크롤링까진 돌아가는 것을 확인했는데, 웹드라이버를 사용하기 위해 수많은 수정과 커밋과 커밋의 reset과(^^) 시도를 했다.
DevToolsActivePort file doesn't exist
chrome_options.add_argument('--headless')
chrome_options.add_argument('--no-sandbox')
옵션 추가
WebDriverError: unknown error: session deleted because of page crash
chrome_options.add_argument("--disable-dev-shm-usage")
옵션 추가
chrome not reachable
selenium.common.exceptions.SessionNotCreatedException: Message: session not created: This version of ChromeDriver only supports Chrome version 114
chromedriver와 google-chrome의 버전이 맞지 않아 진행이 되지 않았다.
chromedriver --version
google-chrome --version
명령어로 버전을 확인하였고, 안정적인 버전을 찾아 114.0.5735
버전으로 다운로드했다.
session not created
service = Service(executable_path=r'/usr/bin/chromedriver')
로 웹 드라이버 절대경로로 호출
3) python
모든 설정이 된 것 같은데, python이 실행되지 않았다.
ProcessBuilder pb = new ProcessBuilder("python", "-u", "/crawling/"+path);
코드를
ProcessBuilder pb = new ProcessBuilder("python3", "-u", "/crawling/"+path);
로 수정해 해결되었다.
도커 이미지에서 Python 3이 설치된 경우, 인터프리터는 "python3"로 접근해야 할 수 있다.
월요일 00:00에 카드 혜택 정보 업데이트를 진행한 뒤, 매주 월요일 6:00에 크롤링한 헤택 정보의 요약 작업이 진행된다.
해당 service코드는 다음과 같다.
@Scheduled(cron = "0 0 6 ? * 1")
public void updateBenefitSummary() throws CustomException, JsonProcessingException {
List<Card> cardList = cardRepository.findAll();
int index = 1;
for (Card card : cardList) {
// 기존의 BenefitSummary 삭제
List<BenefitSummary> existingSummaries = benefitSummaryRepository.findByCard(card);
benefitSummaryRepository.deleteAll(existingSummaries);
log.info("[" + card.getName() + "] - 카드 혜택 요약 중... (" + index + "/" + cardList.size() + ")");
BenefitDto[] benefitJson = openaiService.gptBenefitSummary(card.getBenefits());
for (BenefitDto benefit : benefitJson) {
BenefitSummary benefitSummary = BenefitSummary.builder()
.benefitField(benefit.getBenefit_field())
.benefitContents(benefit.getContent())
.card(card)
.build();
benefitSummaryRepository.save(benefitSummary);
}
index++;
}
log.info("전체 카드 혜택 요약 완료");
}
모든 카드들에 대해 for문을 돌며, 기존의 요약된 혜택들 삭제하고, openaiService의 gptBenefitSummary함수를 호출해 요약을 진행한 뒤 요약된 혜택을 BenefitSummary로 저장한다.
GPT와 연결하기 위한 openaiService는 다음과 같다.
@Service
@Slf4j
@RequiredArgsConstructor
public class OpenaiService {
@Qualifier("openaiRestTemplate")
@Autowired
private RestTemplate restTemplate;
@Value("${openai.model}")
private String model;
@Value("${openai.api.url}")
private String apiUrl;
}
test를 위해 gpt와 gemini모두 백엔드 서버와 연결되어 있어 여러 개의 RestTemplate 빈이 사용된다.
@Qualifier("openaiRestTemplate")를 사용하여 "openaiRestTemplate"이라는 이름의 빈을 주입한다.
RestTemplate은 Spring에서 HTTP 요청을 보내고 응답을 받는 데 사용되는 클래스이다.
openai에 사용할 model과 apiUrl는 환경변수로 설정하였다.
gptBenefitSummary함수는 다음과 같다.
public BenefitDto[] gptBenefitSummary(String benefits) throws CustomException, JsonProcessingException {
String prompt = "입력된 데이터를 [] 사이에 주어진 key를 가지는 JSON 형식의 list로 요약하여 제공해 줘 [benefit_field, content]\\nbenefit_field는 혜택의 분야, content는 혜택 할인율 정보를 핵심만 나타냄. \\ output 형식은 다음과 같음. [{\n \"benefit_field\": \"편의점\",\n \"content\": \"4대 편의점 이용 시 10% 적립\"\n" + },\n {\n" \"benefit_field\": \"커피 업종\",\n" \"content\": \"커피 업종 이용 시 10% 적립\"\n },\n {\n \"benefit_field\": \"해외 이용금액\",\n\"content\": \"해외 이용금액 1% 적립\"\n" },\n {\n \"benefit_field\": \"디지털 구독\",\n \"content\": \"디지털 구독 영역 이용 시 10% 적립\"\n },\n {\n \"benefit_field\": \"One Pick 쇼핑몰\",\n \"content\": \"One Pick 온라인 쇼핑몰 가맹점 최대 3천 포인트 적립\"\n" } ]";
// gpt 요청 보내는 부분
OpenaiChatRequest request = new OpenaiChatRequest(model, prompt, benefits);
OpenaiChatResponse response = restTemplate.postForObject(apiUrl, request, OpenaiChatResponse.class);
if (response == null || response.getChoices() == null || response.getChoices().isEmpty()) {
throw new CustomException(ResponseCode.FAILED_TO_OPENAI);
}
String result = response.getChoices().get(0).getMessage().getContent();
ObjectMapper objectMapper = new ObjectMapper();
BenefitDto[] benefitJson = objectMapper.readValue(result, BenefitDto[].class);
return benefitJson;
}
시스템 프롬프트에 해당하는 prompt와 유저 프롬프트에 해당하는 benefits, GPT 모델을 OpenaiChatRequest에 넣어 GPT에 전달할 요청 객체를 생성한다.
RestTemplate을 사용하여 GPT에 POST 요청을 보낸다. 이 요청은 apiUrl로 지정된 엔드포인트에 요청을 전송하고, 요청 본문으로는 OpenaiChatRequest 객체를 사용한다. 응답으로는 OpenaiChatResponse 객체가 반환된다.
요청에 대한 응답을 처리하고 예외가 발생하지 않았다면 결과를 가공하고, ObjectMapper를 사용하여 JSON 형식의 문자열을 BenefitDto 배열로 변환한다.
최종적으로 요약된 혜택 데이터를 나타내는 BenefitDto 배열을 반환한다.
카드 추천은 결제처
/ 결제금액
/ 보유카드목록
을 입력으로 가지고
최대혜택카드id
/ 해당혜택요약
/ 할인금액
을 출력한다.
현재 데이터의 양과 기술로는 카드 추천 모델의 training을 위한 데이터를 직접 만드는 수밖에 없다.
각 카드사의 인기 카드를 중심으로 데이터 베이스에서 요약된 카드 혜택 정보들을 직접 읽어보며 데이터를 채워 나간다.
수작업이라 속도가 느린 만큼, 데이터들은 정확해야 한다. 추후 서비스를 통해 모이는 데이터를 활용할 수 있으면 좋겠다..
나는 데이터 작성을 위해, 키워드별로 해당하는 혜택을 가진 카드들을 나열하는 작업을 진행하였다. 위에 언급한대로, 각 카드사별 인기 카드의 비중을 높게 하였다.
편의점과 관련한 데이터들을 만들어보자. "편의점" 키워드의 혜택을 가진 카드들을 무작위로 배치한다. 중간중간 관련 혜택을 가지지 않은 카드들도 섞어서 값을 채워준다.
카드들의 혜택을 비교하고, 정답에 해당되는 최대혜택카드id
/ 해당혜택요약
/ 할인금액
을 도출해낸다.
ai 모델인만큼 training 데이터가 많아야 성능을 크게 높일 수 있고, test를 통해 얻게되는 정확도는 원스 서비스의 신뢰도와 직결되기 때문에, 계속해서 더 많은 데이터를 생성하는 것이 중요하다.
만든 데이터 셋을 통해 파인튜닝 모델을 생성할 차례이다.
100개 중 60개의 training data를 ex_tran.jsonl
에,
20개의 validation data를 ex_val.jsonl
에 저장해 두었다.
import json
import openai
import os
import pandas as pd
from pprint import pprint
openai.api_key="{오픈AI의 API 키}"
GPT 모델을 파인튜닝(fine-tuning)하기 위해 ex_val.jsonl
와 ex_tran.jsonl
을 OpenAI에 업로드한다
validation_file_name = "ex_val.jsonl"
training_file_name="ex_tran.jsonl"
training_response = openai.File.create(
file=open(training_file_name, "rb"), purpose="fine-tune"
)
training_file_id = training_response["id"]
validation_response = openai.File.create(
file=open(validation_file_name, "rb"), purpose="fine-tune"
)
validation_file_id = validation_response["id"]
OpenAI API 홈페이지의 storage에서 업로드된 파일을 확인하고 관리할 수 있다.
response = openai.FineTuningJob.create(
training_file=training_file_id,
validation_file=validation_file_id,
model="gpt-3.5-turbo",
suffix="recipe-ner",
)
job_id = response["id"]
openai.FineTuningJob.create()
메서드로 GPT 모델을 파인튜닝하기 위한 작업을 생성한다.
이를 위해 훈련 데이터 파일과 검증 데이터 파일의 ID를 지정하고, 사용할 GPT 모델의 버전과 작업 이름에 접미사를 추가로 지정해준다.
파인튜닝 작업의 진행 상태, 훈련 진행 상황을 조회한다.
response = openai.FineTuningJob.retrieve("ftjob-iSlcJEn3kB5VOUhVDkhznTSR")
print("Job ID:", response["id"])
print("Status:", response["status"])
print("Trained Tokens:", response["trained_tokens"])
Job ID
는 작업을 식별하고, Status
는 작업의 진행 상태를 나타내며, Trained Tokens
는 작업의 훈련 진행 상황을 알려준다. job의 status가 succeeded가 되었을 때 다음 작업을 진행하면 된다.
파인튜닝 작업에 대한 이벤트 조회,역순으로 출력
response = openai.FineTuningJob.list_events(id=job_id, limit=50)
events = response["data"]
events.reverse()
for event in events:
print(event["message"])
반드시 필요한 부분은 아니다.작업의 상태 변화나 진행 상황을 더 자세히 추적하고 싶을 때 유용하다.
파인튜닝된 모델의 ID 확인
response = openai.FineTuningJob.retrieve(job_id)
fine_tuned_model_id = response["fine_tuned_model"]
if fine_tuned_model_id is None:
raise RuntimeError("Fine-tuned model ID not found. Your job has likely not been completed yet.")
print("Fine-tuned model ID:", fine_tuned_model_id)
OpenAI API 사이트에 접속하여 파인튜닝 현황 및 생성된 모델 결과를 확인할 수 있다.
파인튜닝된 모델을 사용하여 챗봇 대화를 생성하고, 생성된 대화의 응답을 출력해보자.
responseTest = openai.ChatCompletion.create(
model = fine_tuned_model_id,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": testuser},
],
temperature=0
)
print(responseTest["choices"][0]["message"]["content"])
model에 파인튜닝한 gpt모델의 id를 써주면 된다. 파인튜닝한 gpt 모델의 아이디는 ft:
로 시작한다.
결과는 다음과 같다.
이제 파인튜닝한 gpt 모델을 백엔드단에서 사용하면, 결제 전 최대 할인 카드를 찾아주는 모델은 완성이다!
이 과정은 카드 혜택 요약 과정에서 사용한 코드와 크게 다르지 않다.
메인 화면에서 사용자가 결제처와 결제 금액을 입력하면, 현재 로그인한 사용자 nowUser
와 입력한 정보 keyword
, paymentAmount
를 매개변수로 넣어 cardRecommend()를 호출한다.
String response = openaiService.cardRecommend(nowUser, keyword, paymentAmount);
cardRecommend는 다음과 같다.
@Service
@Slf4j
@RequiredArgsConstructor
public class OpenaiService {
@Qualifier("openaiRestTemplate")
@Autowired
private RestTemplate restTemplate;
private final BenefitSummaryRepository benefitSummaryRepository;
private final OwnedCardRepository ownedCardRepository;
private final CardRepository cardRepository;
@Value("${openai.model}")
private String model;
@Value("${openai.api.url}")
private String apiUrl;
// 결제할 카드 추천
public String cardRecommend(Users nowUser, String keyword, int paymentAmount) throws CustomException {
String prompt = "결제 금액, 결제처, 카드들의 혜택 정보를 입력으로 받아, 각 카드별로 결제처에 해당하는 혜택이 있다면 할인 금액을 계산합니다. 가장 큰 할인을 받을 수 있는 카드의 ”카드번호”, “혜택 정보”, “할인 금액”을 JSON 형식으로 반환합니다.\\\\```를 붙이지 않습니다. 결제처에 해당하는 카드의 혜택이 없거나, 결제처가 분야·브랜드명이 아니라면, 모든 value에 0을 넣어 반환합니다.\\\\”카드번호” 는 해당 카드의 '카드 고유 번호', “혜택 정보”는 결제처에 해당되는 혜택 정보 요약 텍스트(특수문자 없이 20자 이내)를 의미합니다.";
List<OwnedCard> ownedCards = ownedCardRepository.findOwnedCardByUsers(nowUser);
String userInput = "{”결제 금액”: " + paymentAmount + ", “결제처”: “" + keyword + "“, “카드들의 혜택 정보“: [";
for (OwnedCard ownedCard : ownedCards) {
String name = ownedCard.getCard().getName();
String id = ownedCard.getCard().getId().toString();
Card card = ownedCard.getCard();
userInput = userInput +"{”이름”: ”"+ name + "”, " + "”카드 고유 번호” : " + id + ", “혜택“: [ ";
List<BenefitSummary> beneList = benefitSummaryRepository.findByCard(card);
for( BenefitSummary benefit : beneList){
userInput += "“"+benefit.getBenefitField()+" "+benefit.getBenefitContents()+"“,";
}
userInput = userInput.substring(0, userInput.length() - 1);
userInput += "” },";
}
userInput = userInput.substring(0, userInput.length() - 1);
userInput += "]}";
// gpt 요청 보내는 부분
OpenaiChatRequest request = new OpenaiChatRequest(model, prompt, userInput);
OpenaiChatResponse response = restTemplate.postForObject(apiUrl, request, OpenaiChatResponse.class);
if (response == null || response.getChoices() == null || response.getChoices().isEmpty()) {
throw new CustomException(ResponseCode.FAILED_TO_OPENAI);
}
String result = response.getChoices().get(0).getMessage().getContent();
log.info(result);
return result;
}
}
for문을 돌며 사용자의 ownedCards들의 혜택 정보를 json에 삽입해 Input을 완성하고,
OpenaiChatRequest에 넣은 뒤, RestTemplate을 사용하여 GPT에 POST 요청을 보낸다.
model에 파인튜닝한 모델의 id인 ft:gpt-3.5-turbo-0125:personal:recipe-ner:9Op9RHfV
만 넣어주면 된다. 이는 스프링 부트의 application.properties에, 깃허브의 레포지토리 시크릿에 저장하고 있기 때문에, 파인튜닝을 추후 진행하더라도 코드의 수정 없이, 환경 변수만 다시 설정해주면 된다!
기술 검증을 해가며 AI 모델을 선택하는 것부터, 구현하는 것까지 많은 것을 새롭게 시도하고 배운 것 같다. 수많은 오류들을 만나고 밤을 새웠었는데, 그 모든 내용들을 이렇게 글로 정리하니 스스로 대견하게 느껴지기도 한다. 감도 잡히지 않던 카드 추천 챗봇이 정상적으로 카드를 추천해 주고 있고, 끝나지 않을 것 같던 졸업 프로젝트가 끝이 보인다...! 남은 기간에 더 많은 데이터 셋을 구축해 완벽한 카드 추천을 구현 완료해야겠다. 원스 짱 루스 짱