AWS, Lambda 기반 Serverless 한글 형태소 분석기(MeCab) 개발편
앞 포스트에서 AWS Lambda 에 MeCab 와 Python 스크립트를 통해 “아버지가방에들어가신다”를 성공적(?)으로 분석했다. 가장 기본적인 형태로 한글 형태소 분석기가 Lambda 에서 정상 동작 여부를 확인할 수 있었다.
활용적인 측면을 배제하고, 가능성만 확인했으니, 이제 조금 실용적인 측면으로 접근해 보고자 한다.
개선이 필요한 부분
언제까지 “아버지가방에들어가신다” 만 분석하고 있을 수는 없다. (1) 문장을 사용자로 부터 입력 받아 분석해야 한다. 뿐만 아니라, (2) 사전 데이터가 갱신될 때 마다 Lambda 에 아카이빙된 파일을 등록하는건 매우 비효율적이다. 또한, (3) 분석된 데이터를 어디엔가 저장해야 하고, (4) 단어가 일정 수 이상 반복되어 언급된다면 중요도가 높을지도 모른다. 담당자에게 알려준다면 트랜드 분석에 도움이 된다.
1/2번은 Lambda 스크립트의 기본적 개선 사항이고, 3/4번은 응용단계다. 이번 포스트에서는 1/2번을 개선하고자 한다. 전반적 처리 구조의 예제는 이전 포스트와 큰 차이가 없다.
경로 최적화
외부 요청을 받아들이는 통로는 API Gateway 가 담당한다. RESTFul 형태의 EndPoint 로 들어온 요청을 Lambda Function 과 연결한다.
API 개발할 때 중요한건 효과적인 계층 구조의 설계와 요청 형태에 대한 적절한 대응이다. 먼저. 명확한 계층 구조는 설명을 하지 않아도 대략 어떤 형태의 요청을 받아들이는지 알 수 있다. 개발자에 따라 차이가 있지만, 이번 포스트 예제는 다음의 구조로 진행할 것이다.
즉, 배포 버전에서의 최종 URI 는 /v1/syntax/morpheme 가 된다. API Gateway 설정은아래에서 다시 확인하고 스크립트 내 지정 방식은 다음과 같다. Lambda 핸들러의 Event 값 안에는 Request Method 와 Query String 등을 모두 포함하고 있다. 즉, URI를 판단하고, 내부 함수로 분기시켜주는 간단한 원리다.
route_map = { '/v1/syntax/morpheme': { # Stage 'GET': syntax_morpheme, 'POST': syntax_morpheme }, '/syntax/morpheme': { # Development/Test 'GET': syntax_morpheme, 'POST': syntax_morpheme } } def router(event): callFunction = route_map[event['path']][event['httpMethod']] if not callFunction: return { 'body': { 'Error': 'Invalid Path' } } return callFunction(event)
URI 와 동일한 값이 Dictionary 타입의 route_map 에 존재한다면 해당 처리르 위한 함수명을 회신하고, 존재하지 않는다면 경로 오류를 회신한다. Key 에 포함된 ‘v1’ 는 스테이지를 의미한다. 배포 전 내부 테스트 시에는 포함되어 있지 않기 때문에 경로는 ‘배포용’과 ‘개발/테스트용’ 으로 분기 되어야 한다.
사용자 입력
사용자는 문장(sentence)를 API 로 전달할 것이다. Request Method는 GET 또는 POST 둘 중하나일 것이다. GET 이면 QueryString에, POST 면 RequestBody 에 문장 정보가 있다. 간단히 GET 으로 문장을 전달했을때를 가정하고 스크립트를 작성해보자.
def syntax_morpheme(event): if event['httpMethod'] is 'GET': reqSentence = event['queryStringParameters']['sentence'] reqBody = '' else : reqSentence = event['queryStringParameters']['sentence'] reqBody = event['body'] return reqSentence
Lambda 가 전달한 event 내 queryStringParameters 의 sentence 안 값을 resSentence 에 넣는다. 즉 요청의 전체 URI 는 “[GET] /v1/syntax/morpheme?sentence=아버지가방에들어가실까요” 를 추측해 볼 수 있다.
사전 데이터 S3에 넣기
AWS Lambda 는 용도에 맞게 저장 공간을 분리해 놓았고(=파티셔닝) 사용할 수 있는 용량이 매우 제한적이다.
- 업로드 할 수 있는 압축된 파일의 최대 크기는 50MiB 다.
- 전체 프로그램 크기는 250MiB 를 넘을 수 없다.
- 임시 폴더(/tmp) 의 크기는 512MiB 다.
- 인라인 편집기로 편집할 수 있는 파일의 최대 크기는 3MiB 다.
앞서 Lambda 에 올린 lambda_function.zip 의 파일크기는 49.2MiB(51MB)였기 때문에 Lambda 에 올릴 수 있었다. 만약 패키지/모듈을 추가해 크기가 늘어나면 당연히도 업로드 자체가 불가하다. (10MiB 가 넘을 때 S3 를 사용하라는것과는 별개의 이야기다) 자. 은전한닢의 사전은 107MiB(압축을 풀었을 때)다.
- https://docs.aws.amazon.com/ko_kr/lambda/latest/dg/limits.html
또하나. 동적으로 바뀔 수 있는 외부 데이터를 프로그램 패키지에 넣는건 매우 비효율적인 일이다. 우리가 웹 서비스를 개발할 때 CSS 와 Javascript 전문을 HTML 에 작성하지 않는것과 같다. (jQuery 를 HTML 내 넣은 경우를 본 적이 있는가)
AWS 는 S3 라는 훌륭한 Object Store 를 제공한다. 즉, 사전은 S3 에 저장하고, 프로그램 가동 시 사전을 Lambda 로 복사해 오자. 전반적으로 추가된 부분은 아래의 점선 영역이다.
boto3 설치
pip 를 통해 boto3를 설치하자. 이제 프로젝트 폴더가 복잡해 지기 시작한다.
$ cd $NPD_SYNTAX $ pip install boto3 -t .
S3 사전 복사
이 항목은 2개의 작업으로 나뉜다. 사전을 S3 에 등록하고, 스크립트 실행 시 사전 데이터를 Lambda 로 복사해야 한다.
특별히 수정된 내용이 없다면 사전은 프로젝트폴더 하위 mecab-ko-dic 에 위치한다. 사전을 압축해 S3 버킷에 복사하자. (아래 사전 파일명은 추후 복사 파일 목록으로 사용되기 때문에 기록해 놓아야함)
$ cd $NPD_SYNTAX/local/lib/mecab/dic/mecab-ko-dic/ $ ls -al total 109580 drwxr-xr-x 2 root root 176 Jul 9 14:55 . drwxr-xr-x 3 root root 17 Jul 9 14:55 .. -rw-r--r-- 1 root root 262560 Jul 9 14:55 char.bin -rw-r--r-- 1 root root 1419 Jul 9 14:55 dicrc -rw-r--r-- 1 root root 76393 Jul 9 14:55 left-id.def -rw-r--r-- 1 root root 20585296 Jul 9 14:55 matrix.bin -rw-r--r-- 1 root root 10583428 Jul 9 14:55 model.bin -rw-r--r-- 1 root root 1550 Jul 9 14:55 pos-id.def -rw-r--r-- 1 root root 2479 Jul 9 14:55 rewrite.def -rw-r--r-- 1 root root 114511 Jul 9 14:55 right-id.def -rw-r--r-- 1 root root 80558854 Jul 9 14:55 sys.dic -rw-r--r-- 1 root root 4170 Jul 9 14:55 unk.dic $ zip -r ./mecab-dic.zip ./
버킷에 등록된 사전. 나중에 사전 관리를 위해 버전과 배포 날짜를 포함해 폴더를 생성하면 펀리하다.
사전을 S3 로 부터 갖고 온다면 배포 파일(zip)에 사전을 포함할 필요가 없게 된다. exclude.lst 에 사전 폴더를 추가하자.
$ cd $NPD_SYNTAX $ vi exclude.lst . . local/lib/mecab/dic/mecab-ko-dic/* MeCab/dic/*
테스트로 압축해 보면 boto3 가 포함되었음에도 불구하고 파일의 크기가 크게 감소(51 MB > 10MB)했음을 확인할 수 있다.
$ cd $NPD_SYNTAX $ zip -r9 ./lambda_function.zip * [email protected] $ ls -al -rw-r--r-- 1 root root 10604133 Jul 10 05:50 lambda_function.zip
자, 이제 스크립트 실행 시 S3 로 부터 사전을 복사해오자. 이 작업을 위해선 반드시 S3 에 접근 권한이 있는 IAM 의 접근 키(Access Key) 와 비밀 접근 키(Secret Access Key)가 필요하다.
- 주의 : s3의 폴더는 / 로 시작하면 안됩니다.
import boto3 AWS_S3_BUCKET = '{S3 버킷 이름}' # S3 Bucket 연결 정보 형성 BOTOCONF = { 'aws_access_key_id': '{접근 ID}', 'aws_secret_access_key': '{비밀 접근 키}', 'region_name': '{리전}' } boto3.setup_default_session(**BOTOCONF) s3 = boto3.client('s3') MECAB_DIC_FILES = [ 'char.bin', 'dicrc', 'left-id.def', 'matrix.bin', 'model.bin', 'pos-id.def', 'rewrite.def', 'right-id.def', 'sys.dic', 'unk.dic',] DICDIR = '/tmp/mecab-ko-dic/' # 저장될 사전 위치 def prepareMecabDic(): # 사전을 불러온다. if not os.path.exists(DICDIR): os.mkdir(DICDIR) for mdic in MECAB_DIC_FILES: dest_dic = DICDIR + mdic mdic = "syntax/mecab-ko-dic-2.1.1-20180720/" + mdic if not os.path.exists(dest_dic) or os.path.getsize(dest_dic) == 0: with open(dest_dic, 'wb') as f: s3.download_file(AWS_S3_BUCKET, mdic, dest_dic) prepareMecabDic() MECABRC = os.path.join(os.getcwd(), 'local', 'etc', 'mecabrc') t = MeCab.Tagger("-r %s" % MECABRC)
위 코드를 추가해 로컬에서 실행하면 /tmp/mecab-ko-dic 폴더가 생성되고, 사전 파일이 S3 로 부터 복사 되었음을 확인할 수 있다.
$ ls -al /tmp total 12 drwxrwxrwt 9 root root 216 Jul 10 05:55 . dr-xr-xr-x 18 root root 257 Jul 8 07:05 .. -rw------- 1 ec2-user root 9265 Jul 8 07:17 amzn2extras-1000 drwxrwxrwt 2 root root 6 Jul 8 07:05 .font-unix drwxrwxrwt 2 root root 6 Jul 8 07:05 .ICE-unix drwxr-xr-x 2 root root 176 Jul 10 05:55 mecab-ko-dic drwxrwxrwt 2 root root 6 Jul 8 07:05 .X11-unix drwxrwxrwt 2 root root 6 Jul 8 07:05 .XIM-unix $ ls -al /tmp/mecab-ko-dic total 109580 drwxr-xr-x 2 root root 176 Jul 10 05:55 . drwxrwxrwt 9 root root 216 Jul 10 05:55 .. -rw-r--r-- 1 root root 262560 Jul 10 05:55 char.bin -rw-r--r-- 1 root root 1419 Jul 10 05:55 dicrc -rw-r--r-- 1 root root 76393 Jul 10 05:55 left-id.def -rw-r--r-- 1 root root 20585296 Jul 10 05:55 matrix.bin -rw-r--r-- 1 root root 10583428 Jul 10 05:55 model.bin -rw-r--r-- 1 root root 1550 Jul 10 05:55 pos-id.def -rw-r--r-- 1 root root 2479 Jul 10 05:55 rewrite.def -rw-r--r-- 1 root root 114511 Jul 10 05:55 right-id.def -rw-r--r-- 1 root root 80558854 Jul 10 05:55 sys.dic -rw-r--r-- 1 root root 4170 Jul 10 05:55 unk.dic
Lambda 스크립트 수정
앞 포스트에서 생성했던 lambda_function.py 를 수정하자. 코드는 다음과 같고, 위에 정리된 내용이 포함되어 있다.
$ cat $NPD_SYNTAX/lambda_function.py #!/usr/bin/python # -*- coding: utf-8 -*- import MeCab import sys import os import boto3 import json AWS_S3_BUCKET = '{S3 버킷 이름}' # S3 Bucket 연결 정보 형성 BOTOCONF = { 'aws_access_key_id': '{접근 ID}', 'aws_secret_access_key': '{비밀 접근 키}', 'region_name': '{리전}' } boto3.setup_default_session(**BOTOCONF) s3 = boto3.client('s3') MECAB_DIC_FILES = [ 'char.bin', 'dicrc', 'left-id.def', 'matrix.bin', 'model.bin', 'pos-id.def', 'rewrite.def', 'right-id.def', 'sys.dic', 'unk.dic',] DICDIR = '/tmp/mecab-ko-dic/' # 저장될 사전 위치 def prepareMecabDic(): # 사전을 불러온다. if not os.path.exists(DICDIR): os.mkdir(DICDIR) for mdic in MECAB_DIC_FILES: dest_dic = DICDIR + mdic mdic = "syntax/mecab-ko-dic-2.1.1-20180720/" + mdic if not os.path.exists(dest_dic) or os.path.getsize(dest_dic) == 0: with open(dest_dic, 'wb') as f: s3.download_file(AWS_S3_BUCKET, mdic, dest_dic) prepareMecabDic() MECABRC = os.path.join(os.getcwd(), 'local', 'etc', 'mecabrc') tagger = MeCab.Tagger("-r %s" % MECABRC) def syntax_morpheme(event): if event['httpMethod'] is 'GET': reqSentence = event['queryStringParameters']['sentence'] else : reqSentence = event['queryStringParameters']['sentence'] return reqSentence route_map = { '/v1/syntax/morpheme': { 'GET': syntax_morpheme, 'POST': syntax_morpheme }, '/syntax/morpheme': { 'GET': syntax_morpheme, 'POST': syntax_morpheme } } def router(event): callFunction = route_map[event['path']][event['httpMethod']] if not callFunction: return { 'body': { 'Error': 'Invalid Path' } } return callFunction(event) def lambda_handler(event, context): getSentence = getMorpheme(router(event)) return {'body':json.dumps(getSentence)} def getMorpheme(sentence): m = tagger.parseToNode(sentence) k = {} while m: k[m.surface] = m.feature m = m.next return k
Lambda 에 등록되는 사전의 위치가 변경되었다. mecabrc 에 등록된 사전 위치를 /tmp/mecab-ko-dic 로 변경하자.
$ cat ./local/etc/mecabrc . . dicdir = /tmp/mecab-ko-dic #New #dicdir = /opt/syntax/local/lib/mecab/dic/mecab-ko-dic #Old . .
지금까지 작업한 내용을 Lambda 에 등록하자. 등록 방법은 앞서 설명한 방법을 참고하자.
$ cd $NPD_SYNTAX $ zip -r9 ./lambda_function.zip * [email protected] $ ls -al -rw-r--r-- 1 root root 10604133 Jul 10 05:50 lambda_function.zip
Lambda 스크립트 테스트
새롭게 수정된 스크립트를 등록했으니, API Gateway 에 등록하기 전 테스트를 통해 코드에 문제가 없나 확인하자. 기본적으로 입력된 lambda_function.lambda_handler 를 부러 처리할 때 event 에 값이 있어야 한다.
MeCab 에서 분석된것 같은 느낌의 내용을 확인할 수 있다. 문제가 없다면 외부와 통신할 인터페이스를 만들자.
※ dumps 에 ensure_ascii=False 를 추가하면 한글이 그대로 보인다.
API Gateway 리소스 생성
Lambda Function 은 AWS 내부에서 동작하는 ‘보이지 않는’ 함수다. EC2 Instance 가 외부와 통신하려면 Internet Gateway 가 필요한것 처럼 Lambda Function을 외부에서 접근하려면 Interface, API Gateway를 생성해야 한다.
우린 앞선 규칙에 “/v1/syntax/morpheme” 의 규칙을 선언했다. v1 은 스테이지다. 즉, API Gateway 에서 생성하는 리소스는 syntax 부터 시작 되어야 한다.
lambda_function.py 는 GET Method 에서 QueryString을 받아 처리한다. 통합 유형에 Lambda 함수를 선택하고, 반드시 “Lambda 프록시 통합 사용”을 선택하자. (그래야 event 데이터 처리가 가능하다) Lambda 함수는 앞서 생성한 함수를 선택하는 것으로 끝.
생성된 API Gateway 를 테스트해 보면, 앞서 Lambda Function 테스트와 달리 한글이 디코딩 되어 보일것이다.
API Gateway 배포
개발이 완료 됐다. 테스트가 완료 되었다면 배포를 해야한다. 처음 버전인 만큼 v1 을 할당해 스테이지를 성성하자.
/v1/syntax/morpheme 에 GET Method 로 sentence 를 요청하면 다음과 같은 결과를 확인할 수 있다.
결론
과거 형태소 분석기를 만들어 배포하는 과정을 생각해보자. 개발 환경을 만들어야 하고, 배포 환경도 만들어야 한다. 중간에 관리요소가 상당해 다수의 사람이 프로젝트에 참여해야 산출물을 만들 수 있었다. 위에서 설명하지 않았지만 우리는 마우스 몇번의 클릭으로 RESTFul API 를 만들 수 있었다. 비록 개발 환경을 만들어야 했지만, 운영 환경은 별도 구성이 필요 없었다. 이게 바로 Serverless 의 매력이라 생각한다. 확실히 손이 덜간다.
물론, IDE 및 배포가 여러모로 귀찮다. awscli 를 통해 배포를 해야 하고, 로컬에서 동작한다고 해서 Lambda 에서도 동작한다 보장하긴 어렵다. Lambda 내에서 직접 수정하려면 코드가 작아야 한다. 그래도, 서버 없이 AWS 의 다양한 리소스로의 확대를 생각해보자. 서버, 미들웨어 등의 관리 요소가 없다. 그렇다면 당연히 서버리스로 개발하는게 낫지 않을까하는 생각이 든다.
우와… 뭔지는 모르겠지만 대단합니다. 좋은 정보 많네요.
상세한 설명 정말 감사드립니다!
덕분에 차근차근 따라해서 어떻게든 성공했네요 😂
혹시 이상태로 s3의 사전파일을 재배포해서 올릴수 있는 기능이 있다면
lambda 수정 없이 실시간으로 사전을 갱신하는게 가능할까요??
실시간 까지는 아니더라도 가능합니다. 예제 스크립트를 상용화 또는 실제 응용하려면 변경해야 하는 부분들이 몇 있습니다.
1. 일부 변수가 정적으로 선언되어 있어 관리가 불편합니다. 정기적으로 사전 폴더를 검사해 새로운 사전이 있다면 이를 갱신하는 구조로 만들면 됩니다.
2. Queue를 만들어 처리과정을 관리해야 합니다. (물론 자주 요청하지 않는다면 상관 없습니다) Queue는 Redis를 쓰면 편리합니다.
등등 개선 및 응용해 사용하면 되는 부분들이 몇 있습니다. 지금은 1번을 해결 하시려는 걸텐데요, 사전 검색 및 갱신 스크립트를 만들고, 이를 cloudWatch에서 스케줄링 형태로 호출해 갱신하도록 하고, 버전이 업데이트 되었다면 갱신된 사전 데이터를 다시 메모리에 로드하면 됩니다. ^^