티스토리 뷰

내가 가장 최근에 했던 일은 텍스트 분석, 이미지 분석 등의 일이었다. 그러나 기반 지식이 부족하고 경험도 부족하여 결과가 좋지 않았다. 그래도 그동안 했던 일을 시리즈 형태로 정리하고 공유하면서 더 나은 방법이 있는지 고민해보려고 한다.

이 포스팅에서 다룰 내용

이번 포스팅은 첫번째로써 형태소 분석, 그중에서도 품사 태깅을 하는 AWS lambda 함수를 만들어 보고자 한다.

형태소 분석

이 일을 하기 전까지 형태소가 무엇인지, 형태소 분석이 무엇인지 개념도 잘 모르고 있었다. 우선 형태소란 무엇인지 알아보고 형태소 분석을 왜 해야하는지는 다음 포스팅에서 확인해볼 예정이다.

네이버 사전에서 형태소를 찾아보았다.

형태소

  1. 뜻을 가진 가장 작은 말의 단위. ‘이야기책’의 ‘이야기’, ‘책1’ 따위이다.
  2. 문법적 또는 관계적인 뜻만을 나타내는 단어나 단어 성분. 프랑스의 언어학자 마르티네(Martinet, A.)가 제시하였다. [비슷한 말] 형태질.

즉 말의 가장 작은 단위를 형태소라고 한다.

형태소 분석 대해서는 KoNLPy에 잘 정리되어 있다.

형태소 분석 이란 형태소를 비롯하여, 어근, 접두사/접미사, 품사(POS, part-of-speech) 등 다양한 언어적 속성의 구조를 파악하는 것입니다.

형태소 분석은 말의 가장 작은 단위인 형태소로 문장을 해체하여 분석하는 것을 의미한다. 내가 이번에 하려고 하는 것은 그중에서도 품사를 태깅해주는 기능이다. 우선 품사에 대해서 알아보자

품사

  • <언어> 단어를 기능, 형태, 의미에 따라 나눈 갈래. 현재 우리나라의 학교 문법에서는 명사, 대명사, 수사, 조사, 동사, 형용사, 관형사, 부사, 감탄사의 아홉 가지로 분류한다.

결국 형태소로 문장을 해체하고 형태소가 어떤 품사인지 태깅하는 기능이다. 텍스트 분석을 하기 전에 품사 태깅을 하는 이유는 불필요한 품사들을 제거하는 전처리를 하기 위함이다. 왜 불필요한 품사들이 있고 그를 제거해야하는지는 다음 포스팅에서 다뤄보겠다.

형태소 분석 라이브러리

KoNLPy에서 확인할 수 있듯이 한국어 형태소 분석 라이브러리는 네개 정도 있고 품사 태깅 관련된 벤치마크도 아까 링크에서 확인할 수 있다. 나는 그중에서 komoran을 사용할 예정이다.

(재미있는 것은 https://github.com/konlpy/konlpy/tree/master/konlpy/java 여기 링크로 들어가 보면 확인할 수 있는데 내부적으로는 대부분 자바로 작성된 라이브러리라는 것이다. KoNLPy는 그것을 파이썬으로 감싼 라이브러리이다.)

정확히는 KoNLPy를 사용하지 않고 직접 komoran 3.0 자바 라이브러리를 사용할 예정이다. 우선 komoran을 선택한 이유는 사용이 쉽고 간단했기 때문이다. 사용자가 사용자 사전을 이용하여 상황에 맞게 품사를 태깅할 수 있도록 설계 되어있다. 그리고 KoNLPy의 komoran은 2.4 버전이므로 그냥 자바라이브러리를 사용하기로 했다.

AWS Lambda로 만드는 이유

하나의 외부에 노출된 서비스로 만들고 싶어서이다.

실습

코드는 여기에서 확인할 수 있다.

https://github.com/voyagerwoo/pos-tagger

코드 작성 및 패키징

0. pom.xml 작성 및 maven wrapper 가져오기

개인적으로는 intellij에서 메이븐 프로젝트를 만들고 pom.xml에 관련 의존성 및 플러그인 설정을 해주었다. 그리고 너무나도 편리한 maven wrapper를 spring boot 프로젝트에서 복사해서 가져왔다. .mvn 디렉토리와 mvnw, mvnw.cmd 파일을 복사해온다.

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>vw.ds.nlp</groupId>
    <artifactId>pos-tagger</artifactId>
    <version>0.0.1</version>

    <properties>
        <maven.compiler.target>1.8</maven.compiler.target>
        <maven.compiler.source>1.8</maven.compiler.source>
    </properties>

    <!-- komoran을 가져오기 위해서 필요한 코드 -->
    <repositories>
        <repository>
            <id>jitpack.io</id>
            <url>https://jitpack.io</url>
        </repository>
    </repositories>

    <dependencies>
        <dependency>
            <groupId>com.amazonaws</groupId>
            <artifactId>aws-lambda-java-core</artifactId>
            <version>1.1.0</version>
        </dependency>
        <dependency>
            <groupId>com.github.shin285</groupId>
            <artifactId>KOMORAN</artifactId>
            <version>3.3.2</version>
        </dependency>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter-api</artifactId>
            <version>5.0.1</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.hamcrest</groupId>
            <artifactId>hamcrest-library</artifactId>
            <version>1.3</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <!-- 의존성 까지 모두 포함한 jar를 만들기 위한 플러그인 설정 -->
    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-shade-plugin</artifactId>
                <version>3.1.1</version>
                <configuration>
                    <createDependencyReducedPom>false</createDependencyReducedPom>
                </configuration>
                <executions>
                    <execution>
                        <phase>package</phase>
                        <goals>
                            <goal>shade</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>
</project>

1. 테스트 작성

처음으로 junit5를 가지고 테스트 코드를 작성했지만 기존 junit4와 코드는 전혀 다르지 않다. 내 입장에서는 import 문만 바뀌었다.

package vw.ds.nlp.postagger;

import org.junit.jupiter.api.Test;
import java.util.List;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.*;

public class PosTaggerServiceTest {
    private PosTaggerService posTaggerService = new PosTaggerService();

    @Test
    public void testTagPos() {
        List<PosPair> posPairs = posTaggerService.tagPos("마른 땅을 달리다.");
        System.out.println(posPairs);
        assertThat(posPairs.size(), greaterThan(0));
    }
}

당연히 코드는 실행되지도 않을 뿐더러 IDE에서는 컴파일 에러 표시가 날 것이다. PosTaggerServicePosPair 클래스를 만든다.

2. 로직 구현

이 서비스는 komoran 라이브러리를 한번 감싼(wrapping)한 클래스이다. 그래서 그냥 바로 구현해버렸다.

package vw.ds.nlp.postagger;

import kr.co.shineware.nlp.komoran.constant.DEFAULT_MODEL;
import kr.co.shineware.nlp.komoran.core.Komoran;
import kr.co.shineware.nlp.komoran.model.KomoranResult;
import java.util.List;
import java.util.stream.Collectors;

public class PosTaggerService {
    private final Komoran komoran;

    public PosTaggerService() {
        komoran = new Komoran(DEFAULT_MODEL.FULL);
    }

    public List<PosPair> tagPos(String text) {
        KomoranResult result = komoran.analyze(text);
        return result.getList().stream()
                .map(pair -> new PosPair(pair.getFirst(), pair.getSecond()))
                .collect(Collectors.toList());
    }
}
package vw.ds.nlp.postagger;

public class PosPair {
    private String word;
    private String pos;

    public PosPair(String word, String pos) {
        this.word = word;
        this.pos = pos;
    }

    public String getWord() { return word; }

    public String getPos() { return pos; }

    @Override
    public String toString() {
        // toString as json format
        return String.format("[\"%s\", \"%s\"]", word, pos);
    }
}

3. 람다 핸들러 작성

참고 : http://www.baeldung.com/java-aws-lambda

com.amazonaws.services.lambda.runtime.RequestHandler를 구현한 클래스를 만든다. 제네릭으로 input과 output 타입을 정의해준다. POJO 객체로 input, output을 설정할 경우 input은 알아서 json을 파싱하고 output은 알아서 json으로 직렬화해준다.

package vw.ds.nlp.postagger;

import com.amazonaws.services.lambda.runtime.Context;
import com.amazonaws.services.lambda.runtime.RequestHandler;

import java.util.List;

public class LambdaRequestHandler implements RequestHandler<String, List<PosPair>> {
    private final PosTaggerService posTaggerService;
    public LambdaRequestHandler() {
        posTaggerService = new PosTaggerService();
    }

    public List<PosPair> handleRequest(String input, Context context) {
        context.getLogger().log("Input: " + input);
        return posTaggerService.tagPos(input);
    }
}

4. 패키징

함수를 업로드하기 전에 jar로 빌드해준다.

$ ./mvnw clean compile package
Unable to find any JVMs matching version "(null)".
No Java runtime present, try --request to install.
/Users/voyagerwoo/Study/pos-tagger
[INFO] Scanning for projects...
[INFO] 
[INFO] ------------------------< vw.ds.nlp:pos-tagger >------------------------
[INFO] Building pos-tagger 0.0.1
[INFO] --------------------------------[ jar ]---------------------------------
[INFO] 

...생략...

[INFO] Replacing original artifact with shaded artifact.
[INFO] Replacing /Users/voyagerwoo/Study/pos-tagger/target/pos-tagger-0.0.1.jar with /Users/voyagerwoo/Study/pos-tagger/target/pos-tagger-0.0.1-shaded.jar
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 7.697 s
[INFO] Finished at: 2018-07-31T21:09:45+09:00
[INFO] ------------------------------------------------------------------------

target/pos-tagger-0.0.1.jar 파일을 확인할 수 있다.

(추가) cli 프로그램 작성

CLI 프로그램으로도 사용할 수 있게 만들고 싶어서 아래와 같이 코드를 추가했다.

package vw.ds.nlp.postagger;

public class PosTaggerCli {
   public static void main(String[] args) {
       final PosTaggerService posTaggerService = new PosTaggerService();

       if (args == null || args.length == 0) {
           throw new IllegalArgumentException("Required args.");
       }

       for (String arg : args) {
           System.out.println(posTaggerService.tagPos(arg));
       }
   }
}

pom.xml의 경우는 아래처럼 plugin 코드를 변경해준다. 기본 Manifest 속성을 만들어주는 코드이다.

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-shade-plugin</artifactId>
            <version>3.1.1</version>
            <configuration>
                <createDependencyReducedPom>false</createDependencyReducedPom>
                <!-- cli 프로그램을 만들기 위한 설정 -->
                <transformers>
                    <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
                        <mainClass>vw.ds.nlp.postagger.PosTaggerCli</mainClass>
                    </transformer>
                </transformers>
            </configuration>
            <executions>
                <execution>
                    <phase>package</phase>
                    <goals>
                        <goal>shade</goal>
                    </goals>
                </execution>
            </executions>
        </plugin>
    </plugins>
</build>

jar를 실행하여 확인해볼 수 있다.

$ java -jar target/pos-tagger-0.0.1.jar "마른 하늘을 달려" "진짜  하나도 모르겠다"
[["마르", "VV"], ["ㄴ", "ETM"], ["하늘", "NNG"], ["을", "JKO"], ["달리", "VV"], ["어", "EC"]]
[["진짜", "MAG"], ["하나", "NR"], ["도", "JX"], ["모르", "VV"], ["겠", "EP"], ["다", "EC"]]

람다함수 생성 및 배포

1. 함수생성

AWS Lambda (Seoul Region): https://ap-northeast-2.console.aws.amazon.com/lambda/home?region=ap-northeast-2#/functions

우측 상단의 함수 생성을 클릭한다.

pos-tagger라는 이름을 입력하고 런타임은 Java 8을 꼭 선택해준다. 그리고 역할을 넣는 부분은 개인적인 취향인데, 나의 경우는 lambda 함수마다 역할을 다르게 주고 싶어서 템플릿에서 새 역할 생성을 선택하고 역할 이름을 입력했다. 정책 템플릿은 아직까지 필요한 정책이 없어서 빈곳으로 두었다. 나중에 필요하게 되면 IAM 콘솔에가서 역할을 추가하면 된다.

2. 함수 업로드

우측에 있는 핸들러에 헨들러 함수에 대한 레퍼런스를 정확하게 입력해야 한다. 나의 경우는 vw.ds.nlp.postagger.LambdaRequestHandler::handleRequest이다.

그리고 업로드를 클릭하여 target/pos-tagger-0.0.1.jar 파일을 업로드 한다.

3. 테스트

pos-tagger 함수 화면의 우측 상단에 있는 테스트 셀렉트 박스에서 테스트 이벤트 구성을 클릭한다.

새로운 테스트 이벤트 생성을 선택하고 이벤트 이름을 test1이라고 하고 그 아래 있는 본문에 json 포멧의 텍스트를 입력한다.

우측 상단의 테스트를 클릭하여 테스트를 수행한다. 성공하면 녹색 배경에 반환값을 확인할 수 있다.

실패할 경우 빨간 화면에 에러 메세지를 확인할 수 있다. 아래 에러는 input을 텍스트가 아닌 json 형태로 넣었을 때 발생한 에러이다. 더 자세한 로그가 보고 싶으면 실행 결과: 실패(로그)버튼을 클릭하여 로그를 확인한다.

4. 실제 실행

람다 함수를 실행하는 방법은 세가지가 있다.

첫번째는 API Gateway로 연결하여 REST API의 형태로 사용하는 것이다. 두번째는 AWS CLI를 사용하여 실행하는 것이다. 세번째는 여러 프로그래밍 언어로 작성된 AWS SDK를 사용하여 실행하는 것이다.

개인적으로는 세번째 방법을 선호한다. 첫번째는 HTTP Client를 사용해서 실행하므로 관련된 코드를 작성해야하는 불편함이 있다. 예를 들어 자바 스프링의 경우 RestTemplate 코드를 작성해줘야한다. 그리고 두번째인 CLI는 간단하게 실행하고 테스트하는 경우에는 편리하지만 어떤 프로그램 안에서 사용하려면 SDK가 편리하다.

한편 보안 관련해서는 첫번째 방법의 경우는 API gateway 보안 설정으로 보안 설정할 수 있고 CLI나 SDK의 경우 credentials을 통해서 권한이 있는 유저인지 확인하는 방식으로 보안 설정을 할 수 있다.

이 포스팅에서는 두번째와 세번째 방법으로 실행하는 것을 확인해보려고 한다.

[CLI 실행 ]

aws configure를 통해서 cli 설정을 해주어야 한다. 참고 : https://github.com/voyagerwoo/pos-tagger/blob/master/lambda_invoke.sh

aws lambda invoke \
--invocation-type RequestResponse \
--function-name pos-tagger \
--payload "\"$1\"" \
outputfile.txt

cat outputfile.txt
$ bash lambda_invoke.sh "마른 하늘을 달리다"    
{
    "StatusCode": 200,
    "ExecutedVersion": "$LATEST"
}
[{"word":"마르","pos":"VV"},{"word":"","pos":"ETM"},{"word":"하늘을 달리다","pos":"NNP"}]%   
[python sdk boto3 사용하기]
import boto3
import json
client = boto3.client('lambda')
def invoke_pos_tagger(text):
    payload = json.dumps(text)
    res = client.invoke(FunctionName="pos-tagger", Payload=payload)
    return json.loads(res['Payload'].read().decode())

print(invoke_pos_tagger("마른 하늘을 달려라"))
[{'word': '마르', 'pos': 'VV'}, {'word': 'ㄴ', 'pos': 'ETM'}, {'word': '하늘', 'pos': 'NNG'}, {'word': '을', 'pos': 'JKO'}, {'word': '달리', 'pos': 'VV'}, {'word': '어라', 'pos': 'EC'}]

awscli를 이용한 배포 스크립트 작성

참고 : https://github.com/voyagerwoo/pos-tagger/blob/master/deploy.sh

aws cli를 활용하여 배포 스크립트를 작성한다. 이걸 활용하면 간단하게 지속적 배포를 할 수 있다.

./mvnw clean compile package
aws lambda update-function-code \
--function-name pos-tagger \
--zip-file fileb://./target/pos-tagger-0.0.1.jar

커스텀 사전 등록

komoran에서는 사용자가 수정 가능한 두가지 사전파일을 제공한다. 사용자 사전기분석 사전이다.

사용자 사전

입력 문장 내에 사용자 사전에 포함된 내용이 있는 구간에 대해서는 해당 품사를 출력하게 됩니다. 형태소의 품사를 적지 않으면 기본적으로 고유명사(NNP)로 인지합니다.

기분석 사전

기분석 사전은 어절이 100% 일치하는 경우에만 적용이 됩니다. 분석된 결과의 품사열은 grammar에서 출현 가능한 형태여야 합니다. 아래는 잘못된 예를 나타냅니다.

  • 감기는 감/NNG 기/ETM 는/JKG -> grammar.in에는 NNG 다음에 ETM이 나오는 경우가 없으므로 적용 안됨

파일 형식은 아래와 같습니다.

  • [분석대상어절]\t[형태소1/품사1] [형태소2/품사2] ...

사용자 사전을 활용한 품사 태깅 수정

"광학적 문자 판독 시스템을 구축하다." 이런 문장이 있다. 이를 형태소 분석한 결과는 다음과 같다.

[{"word":"광학","pos":"NNP"},{"word":"","pos":"XSN"},{"word":"문자","pos":"NNP"},{"word":"판독","pos":"NNP"},{"word":"시스템","pos":"NNG"},{"word":"","pos":"JKO"},{"word":"구축","pos":"NNG"},{"word":"","pos":"XSV"},{"word":"","pos":"EF"},{"word":".","pos":"SF"}]

만약 (텍스트 분석이 필요한) 도메인에서 광학적 문자 판독이라는 단어를 하나의 고유 명사로 보고 싶다면 어떻게 해야할까. 사용자 사전으로 해결할 수 있다. 사용자 사전에 아래 텍스트를 추가한다.

광학적 문자 판독	NNP
광학적 문자판독	NNP
광학적문자판독	NNP
광학적문자 판독	NNP

그리고 해당 사전의 경로(String)를 komoran 객체에 설정한다.

komoran.setUserDic("dic.user");

그리고 다시 형태소 분석을 해보면 변경된 것을 확인할 수 있다.

[{"word":"광학적 문자 판독","pos":"NNP"},{"word":"시스템","pos":"NNG"},{"word":"","pos":"JKO"},{"word":"구축","pos":"NNG"},{"word":"","pos":"XSV"},{"word":"","pos":"EF"},{"word":".","pos":"SF"}]

기분석 사전을 활용한 품사 태깅 수정

위에서 예시로 보여준 품사 태깅 결과를 보면 이상한 부분을 발견할 수 있다. 마른 하늘을 달리다마른 하늘을 달려라를 비교해 보면 알 수 있다. 하늘을 달리다라는 단어가 NNP라는 품사로 되어있다. NNP가 무엇인지 한국어 품사 비교표를 확인해보자. NNP는 고유명사이다. 아마도 이적의 '하늘을 달리다'라는 노래때문에 그렇게 내부적으로 사전파일을 설정해둔 것 같다. 만약 (텍스트 분석이 필요한) 도메인에서 그게 고유명사로써 쓰이기 보다 그냥 진짜 하늘을 달리는 의미로 쓰인다면 이 부분을 바꿔줄 필요가 있다.

위의 문제는 기분석 사전을 수정하여 해결가능하다. 기분석 사전(fwd.user)에 아래 텍스트를 추가한다.

하늘을	하늘/NNG 을/JKO
달리다	달리/VV 다/EC

그리고 해당 사전의 경로(String)를 komoran 객체에 설정한다.

komoran.setFWDic("fwd.user");

그리고 다시 형태소 분석을 해보면 변경된 것을 확인할 수 있다.

[{"word":"마르","pos":"VV"},{"word":"","pos":"ETM"},{"word":"하늘","pos":"NNG"},{"word":"","pos":"JKO"},{"word":"달리","pos":"VV"},{"word":"","pos":"EC"}]

사용자 사전을 추가한 PosTaggerService 구현

보통 자바 프로젝트에서 dic.user같은 리소스 파일들은 resources 폴더에 저장하고 읽는다. 그런데 문제는 jar로 패키징 했을 때 그 파일을 읽기가 어려운 것이다. 그 문제를 해결하는 링크를 공유한다.

결론은 다음과 같다.

  • jar 내의 리소스 파일을 읽기 위해서 스프링의 ClassPathResource라는 클래스를 이용했다
  • komoran 객체에 사용자 사전을 등록하는 메서드의 파라미터가 파일의 경로 문자열인데 jar 내부의 리소스 파일은 그렇게 할 수 없어서 람다에서 가용한 /tmp 디렉토리에 복사한 다음 읽도록 코드를 수정했다.
package vw.ds.nlp.postagger;

import kr.co.shineware.nlp.komoran.constant.DEFAULT_MODEL;
import kr.co.shineware.nlp.komoran.core.Komoran;
import kr.co.shineware.nlp.komoran.model.KomoranResult;
import org.springframework.core.io.ClassPathResource;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Paths;
import java.util.List;
import java.util.stream.Collectors;

public class PosTaggerService {
    private static final String USER_DIC = "dic.user";
    private static final String USER_DIC_TMP_PATH = "/tmp/" + USER_DIC;

    private static final String USER_FWD = "fwd.user";
    private static final String USER_FWD_TMP_PATH = "/tmp/" + USER_FWD;


    private final Komoran komoran;

    public PosTaggerService() {
        copyUserDicFiles();
        komoran = new Komoran(DEFAULT_MODEL.FULL);
        komoran.setUserDic(USER_DIC_TMP_PATH);
        komoran.setFWDic(USER_FWD_TMP_PATH);
    }

    public List<PosPair> tagPos(String text) {
        KomoranResult result = komoran.analyze(text);
        return result.getList().stream()
                .map(pair -> new PosPair(pair.getFirst(), pair.getSecond()))
                .collect(Collectors.toList());
    }

    private void copyUserDicFiles() {
        ClassPathResource dicUserPath = new ClassPathResource(USER_DIC);
        ClassPathResource fwdUserPath = new ClassPathResource(USER_FWD);
        deleteSafely(USER_DIC_TMP_PATH);
        deleteSafely(USER_FWD_TMP_PATH);

        try {
            Files.copy(dicUserPath.getInputStream(), Paths.get(USER_DIC_TMP_PATH));
            Files.copy(fwdUserPath.getInputStream(), Paths.get(USER_FWD_TMP_PATH));
        } catch (IOException e) {
            throw new IllegalArgumentException("Invalid user dic files.", e);
        }
    }

    private void deleteSafely(String path) {
        try {
            Files.delete(Paths.get(path));
        } catch (NoSuchFileException e) {
            System.out.println("Not Exists File : " + path + ".");
        } catch (IOException e) {
            throw new IllegalArgumentException("Unknown io error,", e);
        }
    }
}

결론

komoran 형태소 분석 라이브러리와 람다를 이용하여 품사 태깅 서비스를 만들어 보았다. 그리고 사용자 사전으로 품사 태깅 결과를 상황에 맞게 수정하는 것도 해보았다. 한글 텍스트 분석에서 첫 걸음을 떼었다. 다음에는 이 형태소 분석기로 실제 텍스트 분석을 해보는 포스팅을 준비해보려고 한다.

댓글
댓글쓰기 폼