모노산달로스의 행보
[SideProject] AWS Lambda를 이용한 스크래핑 자동화 / 기술적인 이슈 - (2/3) 본문
[SideProject] AWS Lambda를 이용한 스크래핑 자동화 / 기술적인 이슈 - (2/3)
모노산달로스 2024. 11. 20. 19:13SideProject - 기룡아 밥 먹자
개발자들은 왜 사이드 프로젝트에 뛰어들까요? 일반 팀 프로젝트와 다르게 자신이 원하는 서비스를 만들어 볼 수 있다는 점이 큽니다. 또한 자유롭게 새로운 기술을 적용해 보거나 경험하면서 실력을 키우기에도 좋습니다. 실제로 수익을 목적으로 하는 팀 프로젝트와 다르게 가볍게 접근할 수 있다는 점도 한 이유로서, 많은 개발자들이 사이드 프로젝트를 수행하고 있습니다.
(2/3) 기술적인 이슈 해결
(3/3) 프로젝트 회고
1. 마주한 기술 이슈
사이드 프로젝트 진행 도중, 두 가지 기술적 문제를 마주했습니다.
1. 어떻게 하면 식단 정보를 매주 자동으로 스크랩할 수 있을까?
2. 스크랩을 한 정보를 데이터 베이스에 어떻게 자동으로 업로드할 수 있을까?
이를 해결하기 위해 고민해야 했습니다. 특히 자동화는 해당 프로젝트 유지보수에 있어 꼭 필요한 부분이었습니다. 매주 아침 데이터를 손수 가져와 업데이트하는 것은 매우 바보 같은 짓이기 때문입니다.
따라서 스크래퍼 제작과 CI/CD, AWS에 대하여 집중적으로 공부하기 시작했습니다. 식단 정보 스크래퍼를 만들고, CI/CD와 AWS에 대한 지식이 생기면 자동화 또한 가능할 것이라고 생각했습니다.
2. 필요한 기술을 배워가다
스크래퍼는 유튜브를 보며 제작하였고, Jenkins 또한 강의 하나를 구매하여 학습을 진행했습니다.
하지만 여러 가지 학습 중에서, 가장 많은 시간을 투자한 것은 AWS(Amazon Web Services)였습니다.
처음 Jenkins를 공부할 때, AWS ECS에 서버를 배포하는 과정에서 큰 어려움을 겪었습니다. 특히, 기초적인 지식 없이 계속 시도하려고 하니 너무 오랜 시간과 힘을 소모해야 했습니다. 때문에, AWS를 제대로 학습하지 않으면 앞으로도 계속 이러한 문제를 마주할 것이라는 생각이 들었습니다.
따라서, 즉시 Udemy에서 AWS Developers 강의를 구매하였습니다. 여러 가지 서비스들을 배우는 것은, 기존의 코딩 강의와 다른 새로운 학습 경험이었습니다. 특히 현재 수강하는 Serverless에 대한 내용은 정말 흥미로웠고, 다음 프로젝트에 활용해보고자 하는 마음을 가지게 되었습니다.
처음 AWS를 접했을 때는 복잡하고, 어려운 기능이 많은 스파게티와 같다고 생각했었습니다. 하지만 지금은 Serviece들 하나하나 유용하고 편리한 것들로 가득한 보물 창고가 되었습니다.
3. 설계도가 완성되다
이어지는 학습 끝에 위와 같은 최종적인 로직을 구상할 수 있었습니다. 구조에 대한 순서를 설명하자면 다음과 같습니다.
Scraping
1. EventBridge의 Scheduler를 생성합니다. 매 주 월요일 9시에 람다 함수를 호출합니다.
2. 호출된 람다 함수는 ECS task로 Scraper를 upload 합니다.
3. scraper는 스크래핑을 수행합니다. 이때, 결과물을 S3 Bucket에 저장합니다.
Data Update
1. S3 Bucket의 output.csv가 업데이트되면 트리거가 발동하여 람다 함수를 호출합니다.
2. 호출된 람다 함수는 Spring Server로 HTTPS 요청을 보냅니다.
3. 요청을 받은 서버는 S3 Bucket에서 데이터를 읽어 DB로 삽입하는 로직을 실행합니다.
더 최적화를 시킬 부분이 존재하지만, 작동에는 무리가 없을 것이라고 판단하였습니다. 이제 실제로 구현을 해 볼 일만 남아있었습니다.
4. 스크래퍼를 제작하다
스크래퍼 구현에는 방법들이 존재했는데, 파이썬의 Scrapy라는 framework를 사용하였습니다. 유튜브 강의와 같은 정보가 잘 나와있었기에 입문하기 좋았습니다. 또 Item Pipeline과 같은 기능을 사용하면 자동화도 문제없을 것이라고 판단하였습니다.
가장 먼저 한 일은 경기드림타워 홈페이지를 분석하는 것이었습니다. 그 결과 페이지의 url에서 한 가지 패턴을 알아낼 수 있었습니다.
https://dorm.kyonggi.ac.kr:446/Khostel/mall_main.php?viewform=B0001_foodboard_list&gyear=2024&gmonth=11&gday=20
쿼리 파라미터로 year, month, day를 사용하고 있었습니다. 즉, 연도, 월, 일 값만 넣어주면 원하는 주간의 식단을 가져올 수 있는 것입니다.
def start_requests(self):
today = datetime.now()
year = today.strftime("%Y")
month = today.strftime("%m")
day = today.strftime("%d")
url = f"https://dorm.kyonggi.ac.kr:446/Khostel/mall_main.php?viewform=B0001_foodboard_list&gyear={year}&gmonth={month}&gday={day}"
yield scrapy.Request(url=url, callback=self.parse)
실제 스크래퍼 코드에서는 위와 같이 구현을 할 수 있었습니다.
스크래핑하는 당일의 날짜를 사용하여 url을 지정하니, 최신 식단 정보를 스크랩할 수 있었습니다.
다음으로는 html 구조를 분석했습니다. 스크래핑을 위해서는 구조에 맞는 코드를 작성해 주어야 원하는 데이터를 가져올 수 있기 때문입니다.
def parse(self, response):
decoded_body = response.body.decode('euc-kr')
for row in response.css("table.boxstyle02 tbody tr"):
date = row.css("th a::text").get() or row.css("th::text").get(default="").strip()
meals = row.css("td")
breakfast = ""
lunch = ""
dinner = ""
if len(meals) > 0:
breakfast = "&".join([item.strip() for item in meals[0].css("::text").getall() if item.strip()])
if len(meals) > 1:
lunch = "&".join([item.strip() for item in meals[1].css("::text").getall() if item.strip()])
if len(meals) > 2:
dinner = "&".join([item.strip() for item in meals[2].css("::text").getall() if item.strip()])
yield {
"date": date,
"breakfast": breakfast if breakfast else "미운영",
"lunch": lunch if lunch else "미운영",
"dinner": dinner if dinner else "미운영",
}
기존 테이블의 일자, 아침, 점심, 저녁 형태를 그대로 유지하여 output.csv를 구성하고자 결론을 내렸습니다. 그 결과 위와 같은 코드를 작성할 수 있었습니다.
이때, Spring 코드에서 데이터를 삽입하기 쉽도록 구성하고자 노력했습니다. 이를 위해 각 메뉴들을 &(Ampersand)로 구분하도록 했습니다.
주말과 같이 값이 비어있는 칸은 미운영으로 처리했습니다. 또한 날짜에 앵커 태그가 달려있어도 데이터를 가져오도록 하여, 원하는 형태의 결과물을 얻을 수 있었습니다.
import constants
import boto3
import requests
OBJECT_NAME_TO_UPLOAD = 'output.csv'
s3_client = boto3.client(
's3',
aws_access_key_id = constants.access_key,
aws_secret_access_key = constants.secret_access_key,
region_name = 'us-east-1'
)
response = s3_client.generate_presigned_post(
Bucket = 'dorm-menu',
Key = OBJECT_NAME_TO_UPLOAD,
ExpiresIn = 60
)
files = {'file' : open(OBJECT_NAME_TO_UPLOAD, 'rb')}
r = requests.post(response['url'], data=response['fields'], files=files)
이렇게 만들어진 output.csv 파일은 boto3을 통해 자동으로 S3 bucket으로 업로드됩니다.
버킷에 접근하기 위해서는 Generate_Presigned_Post을 이용했습니다. 이 방법을 사용하면 사전에 인증된 상태로 요청을 보내기 때문에, 클라이언트가 직접 S3에 접근하기 용이합니다.
이렇게 스크래퍼의 구현을 잘 마칠 수 있었습니다. (구현한 스크래퍼 전체 코드)
5. 자동화를 수행하다
스크래퍼를 만들었으니 이제 자동화를 진행해야 했습니다. 스크래퍼의 실행과 데이터 업데이트 두 가지 모두 자동으로 실행되도록 하고자 했습니다.
초기에는 데이터 업데이트를 위해 Item Pipeline을 사용하고자 했습니다. 하지만 고민 끝에, 익숙한 Spring 코드로 데이터를 업데이트하는 것이 좋겠다는 결론을 내렸습니다. 따라서 다른 팀원이 해당 API를 제작하고, 글쓴이는 이를 어떻게 하면 자동화를 할 수 있을지에 대해서 집중적으로 고민했습니다.
주어진 문제는 이렇습니다.
스크랩을 한 정보를 데이터 베이스에 어떻게 자동으로 업로드할 수 있을까?
Spring 코드에서 output.csv 파일을 읽어 데이터 베이스에 저장하는 로직이 수행되고 있습니다. 따라서, 이를 적절한 타이밍에 요청해야 하는 상황이었습니다.
이 문제는 람다의 Trigger를 지정하여 문제를 해결했습니다. 선택한 Bucket의 object가 생성되는 경우, 람다 함수가 실행되도록 설정되었습니다.
스크래퍼가 실행되어 자동으로 S3 Bueckt에 식단을 업데이트를 하면, 자동으로 람다 함수가 이를 읽는 API를 호출하는 것입니다.
def lambda_handler(event, context):
# 로그인 데이터 예시
login_data = {
"email": "test@example.com",
"password": "12345"
}
try:
# 로그인 요청
login_response = requests.post(login_url, json=login_data)
login_response.raise_for_status()
# `token` 키로 토큰을 가져옴
token = login_response.json().get("token")
# 헤더 설정
headers = {
'Content-Type': 'application/json',
'Authorization': f'Bearer {token}'
}
body_data = {
'key': 'output.csv'
}
response = requests.post(api_url, headers=headers, json=body_data)
response.raise_for_status()
return {
'statusCode': 200,
'body': response.text
}
except requests.exceptions.RequestException as e:
return {
'statusCode': 500,
'body': str(e)
}
다음으로는 위와 같이 HTTPS 요청을 보내는 람다 코드를 작성했습니다. Spring Security가 존재했기 때문에, 로그인을 먼저 선행하는 과정이 필요했습니다.
테스트 결과, 올바르게 데이터 베이스에 값을 업데이트할 수 있었습니다.
다음으로 이러한 문제를 해결해야 합니다.
어떻게 하면 식단 정보를 매주 자동으로 스크랩할 수 있을까?
이 또한 처음에는 Python 내부에서 Scheduler를 구성하고자 했습니다. 하지만 AWS 학습을 이어나가다 보니 그럴 필요가 없다는 것을 깨달았습니다.
Scarper 자체를 ECS Task로 올리고, EventBridge와 Lambda로 스케줄링을 해주어 문제를 해결했습니다.
가장 먼저 Scrapy를 Docker Image로 만들어 ECR(Elastic Container Registry)에 업로드하였습니다.
이후 기존 Spring 서버가 돌아가던 ECS(Elastic Container Service)에 Scraper를 위한 서비스를 만들었습니다. 이로서 Scrapy Task가 돌아가기 위한 환경이 구성된 것입니다.
import boto3
import os
def lambda_handler(event, context):
client = boto3.client('ecs', region_name='us-east-1')
try:
response = client.run_task(
cluster=os.getenv('ECS_CLUSTER_NAME'),
taskDefinition=os.getenv('ECS_TASK_DEFINITION'),
launchType='FARGATE',
networkConfiguration={
'awsvpcConfiguration': {
'subnets': [os.getenv('ECS_SUBNET_ID')],
'securityGroups': [os.getenv('ECS_SECURITY_GROUP')],
'assignPublicIp': 'ENABLED'
}
},
overrides={
'containerOverrides': [
{
'name': os.getenv('ECS_CONTAINER_NAME'),
'command': ['python', os.getenv('ECS_MAIN_SCRIPT')]
}
]
}
)
return {"status": "Task started successfully"}
except Exception as e:
return {"status": "Task failed to start", "error": str(e)}
다음으로는 Task를 업로드하는 람다 코드를 작성했습니다.
스케줄러를 설정하기 전 테스트를 진행하였고, Task가 업로드됨에 따라 스크래퍼가 잘 작동하는 것을 확인했습니다.
마지막으로 EventBridge에서 람다 함수를 위해 스케줄러를 설정했습니다.
홈페이지에 식단 정보가 올라오는 정확한 시간을 알 수 없었기에, 최대한 넉넉한 시간으로 지정하였습니다.
다음 주 월요일, 테스트 결과 식단 정보를 잘 업데이트하는 것을 확인할 수 있었습니다.
이로서 스크래핑과 데이터 업데이트 모두 자동화에 성공할 수 있었습니다!
6. 인프라 결과물
초기에 걱정했던, 기술적인 문제를 모두 해결할 수 있었습니다. 이번 사이드 프로젝트에서 문제를 마주하며, 배포와 CI/CD 그리고 스크래퍼 구현에 대한 기술을 익힐 수 있었습니다.
또 무엇보다도 AWS에 대한 관심과 학습을 이어간 것이 중요한 경험이 되었습니다. 특히 인프라에 대해서 완전히 무지한 과거에서, 이제는 스스로 인프라 구조를 그려낼 수 있을 정도로 성장했기 때문입니다. 이러한 성과물을 보면 다시 학습을 이어나갈 열정을 얻을 수 있다는 생각이 듭니다.
이제 배포일이 얼마 남지 않은 만큼 프론트와 연동을 빠르게 끝낼 예정입니다. 이후에는 사용자들의 반응을 받아보며 앱을 개선하는 시간을 가지고 싶습니다. 마지막까지 힘을 내어 꼭 프로젝트를 완수한 뒤, 세 번째 포스트로 돌아오겠습니다.
'Side Project > 기룡아 밥 먹자' 카테고리의 다른 글
[SideProject] 나의 학교를 위한 서비스 개발기록 / 문제 인식 및 기획 - (1/3) (2) | 2024.10.03 |
---|