안녕하세요, Nextunicorn 소프트웨어 엔지니어 Mino 입니다.
이번에는 크롤링한 데이터를 내려주는 API 서버를 AWS Lambda와 AWS S3 스토리지를 이용해서 빠르게 구성해본 내용에 대해서 공유드리려고 합니다.
Nextunicorn 팀이 점차 성장해가면서, 서버 코드도 같이 몸집을 키우게 됐습니다. (...) 그래서 서비스의 핵심 기능이 아닌 이상, 몇 몇 새로운 서비스들은 Microservice로 분리하기로 결정했습니다. 크롤링한 데이터를 반환하는 API 서버를 만들어야했고, 나름대로 몇 번 구성해봤기 때문에 AWS Lambda를 이용했습니다.
그리고 어떤 데이터베이스를 사용하느냐가 문제였는데,
- RDS는 잘 알려져있는 connection 문제가 있었고, (최근 RDS proxy라는 서비스로 이에 대한 해결책을 내어놨지만, 아쉽게도 현재 서울 리전에서는 사용할 수 없습니다. 2020년 3월 기준 )
- DynamoDB의 경우 크롤링한 데이터의 특성을 고려할 때 효율이 떨어지는 문제가 있었습니다. (주기적인 read, 단기간 높은 write)
그래서 결론은 S3를 이용하기로 했습니다. 사실 그리고 두 DB 모두 단순 크롤링 데이터를 다루기위해서라면 다소 무거운 서비스가 아닐까 싶습니다. (효율대비 돈이...)
물론 S3를 온전히 DB로서 그대로 프로덕션에 이용한다면 문제가 있을 수 있지만, 별도의 캐시 레이어를 추가할 예정이기 때문에 데이터를 저장하고 읽어 올 수만 있다면 큰 문제가 없습니다.
결론적으로 아래와 같은 스택으로 구성할 수 있겠네요.
Microservice Crawler API
Crawler : Python Scrapy ( 크롤러에서 S3로 바로 업로드합니다.)
API Server : AWS lambda ( Node.js + express )
Database : AWS S3
사실 단순히 이론적으로 봤을 때, 복잡한 기술이 들어간다던가 하는 것은 아니지만, S3를 데이터베이스 처럼 이용한다면 크롤러의 설계가 가장 중요했습니다.
대부분의 RDS의 경우 데이터 설계가 다소 부족해도 쿼리를 이용해서 어느정도 미비점을 보완해줄 수 있지만, S3의 경우 데이터를 통째로 받아와서 처리한 후 API에서 내려줘야하므로 어떻게 크롤링 설계를 했냐에 따라서 API가 할 일이 크게 달라집니다.
AWS Service 계정과 S3 버킷 그리고 Webstorm과 Webstorm AWS Plugin을 사용했습니다. 아래 포스팅에서 AWS 관련 설정 내용을 확인하실 수 있습니다.
본 포스팅에서는 자세한 상세한 설계나 로직을 제시하지는 않습니다. 조금 큰 틀에서 시나리오 위주로 진행합니다.
시나리오 0 : 크롤러는 주기적으로 실행된다.
Python Crawler Framework라고 구글에 검색하면 Scrapy가 제일 먼저 나옵니다. 나름 입지도 좋고 규모도 큰 오픈소스이기에 Scrapy를 사용했습니다.
그리고 보통 크롤러는 프로젝트 관리를 할 생각을 못했는데, Scrapinghub라는 곳에서는 프로젝트 버전관리 뿐만 아니라, 스케줄 실행, 환경 변수 등 다양한 기능을 제공해주며, 특정 조건 하에 무료로 사용 가능합니다. 자세한 내용은 아래 포스팅을 참고하시면 좋을 것 같습니다.
시나리오 1 : 크롤러에서 S3로 수집한 데이터를 업로드한다.
class SpiderPipeline(object):
def process_item(self, item, spider):
s3 = boto3.client(
's3',
region_name = REGION,
aws_access_key_id = ACCESS_KEY,
aws_secret_access_key= SECRET_KEY
)
s3.put_object(
Body=json.dumps(item, ensure_ascii=False),
Bucket = BUCKET_NAME,
Key='path/to/file.json'
)
return item
Scrapy에는 수집한 data를 처리하는 역할을 하는 pipeline이라는 class가 있습니다. 주로 이 부분에서 데이터를 저장하게 되는데, 그러한 크롤러 로직에 따라서 boto3 라이브러리를 이용해 S3로 업로드해줍니다. 이 때 Key를 설계에 따라서 적절히 부여해줍니다.
Key = 'apple/20/02/03/sales.json'
예를 들어서 위와 같이 사과의 판매 기록이 매 년, 매 월마다 게시되는 경우, 경로에 날짜를 넣어서 반복문을 통해 쉽게 데이터를 가져올 수 있도록 해줍니다. 이렇게 해주면, 특정 기간 동안의 판매량 등을 비교적 쉽게 얻을 수 있습니다.
시나리오 2 : 요청에 따라 S3 데이터를 받아오고 적절히 빌드하여 내려주는 API 서버를 작성한다.
S3에 저장되어있는 파일을 불러와서 읽고, 프론트엔드에서 필요한 정보들로 예쁘게 가공해서 내려줘야합니다.
세세하게 나눠보면 이러한 과정을 거칩니다. (위의 사과 예시를 그대로 들겠습니다)
S3는 객체기반의 스토리지이기 때문에 일반적으로 디렉토리의 개념이 아니라, Key라고 부릅니다. 그래서 버킷 이름과 객체를 가져올 Key만 있으면 해당 객체 즉, 파일을 가져올 수 있습니다.
파일을 요청하기 위한 Key 만들기
const item = 'apple';
const keys = [];
let i = 1;
const getYearMonth = (date) => ({
year: date.format('YYYY/MM').split('/')[0],
month: date.format('YYYY/MM').split('/')[1],
});
// 루프를 돌면서 key를 위해 연도/월의 형태로 날짜를 만듭니다.
while (true) {
const date = getYearMonth(moment().subtract(i++, 'month'));;
keys.push(`${item}/${date.year}/${date.month}/sales.json`);
if (필요한 시간에) break
}
// keys = ['apple/2020/02/sales.json', 'apple/2020/01/sales.json' ... ]
Key를 토대로 S3에 파일 요청
// promise를 담아둘 배열을 만듭니다.
const promises = [];
// 위에서 만들었던 key를 순회하면서 key를 기반으로 S3에 요청을 보냅니다.
// getS3Objects는 아래 쪽에 선언되어 있습니다.
keys.map((key) => (promises.push(getS3Objects(key))));
//promise를 한 번에 resovle합니다.
const response = await Promise.all(promises);
const getS3Objects = (key) => {
return new Promise((resolve, reject) => {
S3.getObject({
Bucket: BUCKET_NAME,
Key: key
}, (err, data) => {
if (err){
resolve(err)
} else {
// 필요한 형태로 resolve 한다.
resolve(JSON.parse(data.Body))
}
})
})
};
받아온 파일을 파싱해서 예쁘게 리턴
// 이 코드는 테스트되지 않은 예시 코드입니다
const responses = await Promise.all(promises);
// 매달 사과의 가격 및 기타 정보
const apples = responses.map((response) => ({
name: response.name,
category: response.category,
price: response.price,
date: response.date,
}))
// 특정 기간 사과의 평균 가격
const average_price = sum(apples.map((apple) => apple.price)) / apples.length
return {
name: 'apple',
average_price: average_price,
}
이렇게 Lambda API 서버를 작성해서 배포해주면, 간단한 크롤러 API를 Microservice로 구축할 수 있습니다. 간단한 테스트 및 공부 용도 혹은 서비스에 캐시를 위한 서비스를 이미 운영 중이라면 빠르게 구축해보기 좋은 환경인 것 같습니다.
구축 후기
S3에 대한 이해가 조금 생긴 것 같습니다. 왜 S3에서는 그 단순한 디렉토리 목록조차도 쉽게 가져올 수 없는 건지 의아했지만, S3는 우리가 평소에 접하는 Database와는 다른, 오로지 특정한 객체를 HTTP 프로토콜을 이용해서 키로 관리할 수 있는 스토리지입니다. 그래서 우리가 디렉토리라고 생각할 수 있는
items/food/apple.json
위와 같은 값도 items/food/apple.json 자체가 결국 apple.json의 데이터를 가리키는 하나의 키입니다. 그래서 디렉토리를 가져온다거나 하는 기능은 실제로 aws-sdk에서 찾아볼 수 없습니다. 물론, S3에 요청을 하는 서비스 단에서 이를 구현할 수 는 있습니다.
기존에 제가 구현한 크롤러가 세심하게 설계가 되어있지 않아서, API에서 대신 해야할 일들이 많았습니다. (data의 key-value 설계 등..) 역시나, 모든 건 설계가 제일 중요하다는 것을 또 느끼고 갑니다.
아, 혹시...
Nextunicorn에서는 Medium 팀블로그를 운영하고 있습니다.
개발, 스타트업과 관련된 다양한 소스들로 구성할 예정입니다.
관심있으신 분은 참고하시면 좋을 것 같습니다