서버리스로 CronJob 만들기 (코로나 크롤러)

서버리스로 CronJob 만들기 (코로나 크롤러)

현재 진행하고 있는 프로젝트에서는 크로나 현황을 간단하게 보여주는 섹션이 존재하는데, 이 부분을 누군가가 (API 형식으로다가?) 제공해주고 있는 것으로 알고 있었다. 그렇지만 아직 나온 건 없었고, 어쩔 수 없이 간단한 크롤러를 만들고 업데이트 해주기로 했다. 현재 프로젝트가 서버리스로 돌고 있기도 하고, Scheduled 된 작업을 돌리기 위해서는 람다가 적합하지 않을라나 싶어서 같은 서버리스 프로젝트에서 크론잡을 돌리는 함수를 만들어보기로 했다.

이 글에서 정리하고 싶은 건, DynamoDB 리소스를 정해주는 작업, 크론잡을 만드는 것 정도가 될 것 같다. 프로젝트가 팀의 큰 프로젝트의 일부라, 그 외 디테일한 구조라든지, 세세한 과정을 모두 생략했다. 자세한 튜토리얼을 기대하고 읽으시는 분들은 원하는 만큼의 정보를 얻지 못할 것 같고, 간단하게 플로우를 확인하고자 하시는 분들에게 적절한 도움을 드릴 수 있지 않을까 싶다. 아마 서버리스로 간단한 TODO리스트 만들기를 데모로 처음부터 끝까지 써보려고 하는데, 그 글에서 자세한 서버리스 튜토리얼을 볼 수 있을 것 같다.

크롤링하기

이 부분은 네이버 검색에 나오는 부분을 크롤링 하기로 했다. 정확하게 우리가 원하는 데이터만 보여주는 화면이 있어서, 그 화면을 크롤링 해서 숫자만 가져가려고 한다. 크롤러는 cheerio를 사용했다. 크롤링 하는 부분은 이번 주제의 핵심이 아니니 간단하게 코드만 보고 가자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// src/services/utils/index.ts

import * as cheerio from "cheerio";
import Axios from "axios";
import { ScheduledEvent } from "aws-lambda";
import * as CoronaStatusModel from "../../models/coronaStatusModel";
import { getCoronaData } from "../../utils";
//...

export const cronCrawler = async (event: ScheduledEvent) => {
const data = await getCoronaData(event.time);
const latest = await CoronaStatusModel.getLatest();
if (latest) await CoronaStatusModel.updateLatest(latest.date, event.time);
await CoronaStatusModel.create(data as CoronaStatusTableFields);
};

우선, getCoronaData 함수에서는 테이블에 들어갈 데이터를 만들어온다. 이 부분에서 크롤링이 동작한다. 그 다음 현재 사용되고 있던 latest 버전을 찾아온 다음, usage 필드를 교체되는 시간으로 바꿔준다. 위와 같이 동작하게 한 이유는 다이나모디비 사용에 미숙한 이유가 있다. Date로 내림차순 한 다음, 맨 첫 번째 값만 가져오면 되는데, 이 부분을 아무리 찾아봐도 디테일한 레퍼런스를 찾지 못해서 (정말 흔한 케이스임에도 불구하고…), 특히 다이나모디비에서 오더링에 관한 내용이 LocalSecondaryIndexes를 설정해줘야 하는 걸로 나오게 되는데, 추가적인 비용이 나오기도 하고, 딱 이 기능 하나로 그 부분을 추가해야 하나 싶기도 하고 해서 … (변명이지만, 더 깊게 공부할 타이밍이 아니였다.) 아무튼 위와 같은 방식으로 latest를 항상 하나로 유지하게 하는 것으로 이후, 최근 값을 찾기 위해서는 usage = "latest"인 값만 찾게 했다. CoronaStatusModel은 나중에 살펴보도록 하자.

cheerio가 동작하는 getCoronaData 함수는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
export const getCoronaData = async (
date: string
): Promise<CoronaStatusTableFields> => {
const {
data: html
} = await Axios.get(
"https://search.naver.com/search.naver?sm=top_hty&fbm=1&ie=utf8",
{ params: { query: "코로나" } }
);

const translateKeys: {
[P in keyof CoronaStatusRawData]: keyof CoronaStatusTableFields;
} = {
검사진행: "inspected",
격리해제: "isolationReleased",
사망자: "dead",
확진환자: "confirmed"
};

const $ = cheerio.load(html);
const $boxList = $("div.graph_view").children(".box");
const data = {
usage: "latest",
date
};

$boxList.map((_index, box) => {
const key = $(box)
.find("p span.txt_sort")
.text();
const value = $(box)
.find("p strong.num")
.text();

const numValue = Number(value.split(",").join(""));

data[translateKeys[key]] = numValue;
});

return data as CoronaStatusTableFields;
};

Functions 설정하기

이 부분에서 Schedule expression을 사용해서 특정 시간마다 함수가 돌 수 있게 만들어야 한다. Schedule expression의 문법은 크게 두 가지가 있다. 하나는 rate를 사용하는 것이고, 다른 하나는 cron을 사용하는 것이다.

rate

이 문법은 특정한 주기를 설정할 수가 있게 된다 (분, 시간, 일 등 단위마다 시작하는 형식으로). 기본적으로 형태는 rate(value unit) 형태를 띄고, rate를 사용하는 방법은 아래와 같다.

1
2
3
4
- events:
- schedule: rate(15 minutes)
- schedule: rate(1 hour)
- schedule: rate(2 days)

위와 같이 값과 유닛단위를 적어줘야 하는데, 지원하고 있는 유닛들은 아래와 같다.

  • minute or minutes
  • hour or hours
  • day or days

값에 따라 복수형 단수형을 지켜 써줘야 한다고 한다 (아마 안될 것 같지는 않다).

cron

이 문법은 조금 더 복잡한 방식의 리눅스의 crontab 문법을 차용하고 있다고 한다. cron(minute hour day-of-month month day-of-week year) 형식의 문법을 사용한다. 쉼표를 가지고 여러 값을 넣을 수도 있고, 와일드카드(?, * 등)를 사용할 수 있다. 각 필드에 대한 타입과 와일드 카드들은 아래와 같다.

Field Values WildCards
Minutes 0-59 , - * /
Hours 0-23 , - * /
Day-of-month 1-31 , - * ? / L W
Month 1-12 or JAN-DEC , - * /
Day-of-week 1-7 or SUN-SAT , - * ? L #
Year 1970-2199 , - * /

와일드카드들의 지원과 제한 등에 대해서는 여기 아마존 링크를 확인해보자. 영어로밖에 지원을 안해주는 페이지다.

아래는 cron의 예시이다.

1
2
3
4
5
6
- event:
- schedule: cron(15 3 ? * MON *) # 월요일 3:15AM 마다 도는 함수
- schedule: cron(1/10 * ? * W *)
# 주말 제외 시작하고나서 10분 마다 도는 함수
# (run in 10-minute increments from the start of the hour on all working days)
- schedule: cron(0 18 * * ? *) # 매일 18:00에 동작하는 함수

위 예시에서 계속 등장하는 몇 가지 와일드 카드들은 아래와 같은 의미가 있다.

  • *: 해당 필드의 모든 것을 의미한다. 예를 들어서 Hours Field의 *는, ‘모든 시간에 대해서’ 라는 의미를 갖는다. 이 와일드 카드는 day-of-week 필드와 day-of-month 둘 다에 사용할 수는 없고, 둘 중 하나는 반드시 ?를 써야 한다.

  • ?: 해석을 하자니 모호해서… 우선 구체적인 예시는, day-of-month에 7을 넣고, 그 7일이 무슨 요일이든 상관이 없으면 day-of-week?를 쓸 수 있다.


일단 내가 작업하고 있는 일은 매일 오전 10시, 오후 5시에 작업이 돌아야 한다. cron 문법을 사용하는 것이 좋겠다. 다만 cron의 시간이 UTC만 지원하기 때문에 원하는 시간에서 9시간을 뺀 값으로 적어줘야 한다.

1
2
3
4
5
6
# functions.yml

CronCoronaCrawler:
handler: ./src/services/utils/index.cronCrawler
events:
- schedule: cron(0 1,8 * * ? *) # 10시, 17시

이제 이 functions.ymlserverless.ymlfunctions 프로퍼티 쪽에 리스트 형식으로 ${file(path/to/functions.yml)} 이런 식으로 붙여주면 된다.

Resource 설정하기

데이터베이스를 붙여주고 CoronaStatusModel에서 사용된 함수들만 만들어주면 되겠다. (이미 프로젝트에서는 만들어둔 적이 있어서, 위에서 이미 사용된 체로 코드를 올렸다.) 다이나모디비를 붙여주자. tables를 아래와 같이 설정해줬다.

1
2
3
4
5
6
7
8
9
10
11
12
13
# tables.yml
CoronaStatus:
Type: AWS::DynamoDB::Table
DeletionPolicy: Retain
Properties:
TableName: ${self:custom.TABLE_PREFIX}corona-status

AttributeDefinitions:
- AttributeName: date
AttributeType: S
KeySchema:
- AttributeName: date
KeyType: RANGE

AttributeDefinitionKeySchema에 정의되어야 하는 필드만 넣어두었다. ProvisionedThroughputBillingModePROVISIONED인 경우에는 반드시 정해줘야 하고 PAY_PER_REQUEST인 경우엔 정할 수 없다고 한다. 기본 값은 PROVISIONED이기 때문에 읽기는 초당 15, 쓰기는 초당 5로 지정했다.

마찬가지로, 이 yml 파일은 serverless.yml에 붙여준다.

1
2
3
4
5
#...

resources:
Resources: ${file(./migrations/tables.yml)}
#...

CoronaStatusModel을 짠 곳이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import { dynamodb } from "../common/aws";
import { CORONA_STATUS_TABLE } from "../databases/dynamodb";

export const create = (data: CoronaStatusTableFields) =>
dynamodb
.put({
TableName: CORONA_STATUS_TABLE,
Item: data
})
.promise();

export const getLatest = async (): Promise<CoronaStatusTableFields> => {
const { Items } = await dynamodb
.scan({
TableName: CORONA_STATUS_TABLE,
FilterExpression: "#u = :usg",
ExpressionAttributeNames: {
"#u": "usage"
},
ExpressionAttributeValues: {
":usg": "latest"
}
})
.promise();

return Items[0] as CoronaStatusTableFields;
};

export const updateLatest = (date: string, usage: string) =>
dynamodb
.update({
TableName: CORONA_STATUS_TABLE,
Key: { date: date },
UpdateExpression: "SET #u = :usage",
ExpressionAttributeValues: {
":usage": usage
},
ExpressionAttributeNames: {
"#u": "usage"
}
})
.promise();

이제는 동작 해야 하는데, 테스트 하기 위해서 엔드포인트로 들어갔을 때 크롤링을 해서 데이터를 넣는 람다 함수도 만들어서 테스트를 해봤다.

서버리스 배포는 간단하게 sls deploy --stage development로 할 수 있다.

후기

서버리스가 크론잡을 돌리기엔 정말 간단하게 만들 수 있어서 좋았다. 앞으로도 이런 식으로 크론잡으로 데이터 모으는 프로그램은 별도로 람다를 만들 것 같다. 그렇지만 다이나모디비에 대해서는 그렇게 좋지 못한 경험이었다. 정말 간단한 것이지만 삽질한 것도 있고, 테이블 설계가 일반적이지 않아서 상당히 애를 먹었다. 물론 몇 번 더 시도 해보고 Secondary Indexes 등 구성해보는 시도를 하겠지만, 특별히 이점을 가져다주는 게 있는지 모르겠다. RDS를 연결 할 때 VPC 등 네트워크 설정을 해줘야 한다는 점? 그냥 그런식으로 연결 해주는게 오히려 정신 건강에 좋겠다 싶은 수준이었다. 물론 프로덕트에서도 많이들 쓰고 있는 것을 봤지만, 실제 메인 서비스에서 핵심 데이터베이스로 사용하고 있는 팀이 있는지 궁금하다. 다이나모디비는 여러번 더 봐야 할 필요를 느꼈다… 앞으로 몇 가지 태스크를 다이나모디비로 깨야 하는데 벌써 피로감이 드는 것 같다.

Reference

댓글

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×