database by narae :p

Annotation과 Reflection을 이용한 챗봇 컨트롤러 만들기 본문

개발 노트

Annotation과 Reflection을 이용한 챗봇 컨트롤러 만들기

dbbymoon 2019. 6. 7. 06:47

챗봇은 입력받은 메시지에 대한 기능을 수행하고 답장을 보내며 사용자와 대화합니다. 저는 이번에 스프링 부트로 가계부 챗봇을 개발하며, 명령어에 대한 요청을 처리하는 '챗봇 컨트롤러'를 만들게 되었습니다.

 

 

 

LINE Messaging API에는 @LineMessageHandler라는 어노테이션이 있어, 해당 어노테이션을 붙인 클래스에서 채팅방에서 일어나는 Event에 대해 다음과 같이 EventMapping을 하여 기능을 수행할 수 있게 합니다. 이렇게 해서 사용자에게 메시지가 들어오는 MessageEvent를 처리할 수 있습니다. 

 

 

 

 

챗봇을 개발하던 초기에 저는 메시지에 맞게 기능을 처리하기 위해 MessageHandler의 handleTextContent 라는 메서드에 다음과 같은 코드를 작성했습니다.

 

이 코드에는 여러가지 문제점이 있습니다.

 

첫 번째, 명령어가 하나 추가 되면 분기문이 하나 더 늘어날 것으로 보입니다.

이번 프로젝트에서 제가 가장 많이 신경을 쓴 점은 분기문 사용을 최대한 줄이자는 점이었습니다. 분기문 사용이 무조건 좋지 않다는 것은 아니지만 적어도 기존의 코드 변경에는 닫혀있고 클래스나 모듈 등의 확장에는 열려있는 OCP 원칙을 지키고자 하였습니다. 즉, 조건이 하나 더 생기면 해당 분기문 코드를 수정하는 것 대신에 클래스나 모듈을 확장해서 사용할 수 있도록 하였습니다. 그리고 위의 방식대로 한다면 한 눈에 들어오지 않는 지저분한 코드가 생길 수 있기도 하구요.

 

두 번째, 저 메시지가 아니면 명령어로 인식하지 못합니다.

따라서 명령어 관리가 시급했습니다. 형태소 분석을 해서 더 유연한 서비스를 제공하면 좋겠지만 시간 상 거기까지는 힘들었고 그래도 저렇게 딱 한가지 메시지만 인식하는 것은 불편하게 느껴졌습니다.

 

세 번째, MessageHandler라는 클래스가 너무 많은 역할을 담당하고 있습니다.

MessageHandler는 사용자에게 메시지를 받아오는 역할과 답장을 해주는 역할만 담당하는 것이 가장 이상적이라고 생각했습니다. 하지만 현재 이 클래스에서는 메시지를 받고, 메시지에 따라 기능을 처리하고, 답장할 메시지도 만들고, 답장도 하네요.

 

네 번째, case문 내에도 분기문이 존재합니다.

챗봇 서비스는 꽤나 까다로웠습니다. 저는 가계부 서비스를 개인 채팅방과 그룹 채팅방 둘 다 제공했습니다. 그러다 보니 어떤 기능은 개인 가계부에만 제공하는 기능이, 또 어떤 기능은 그룹 가계부에만 제공하는 기능이 있었습니다. 그러면 저렇게 가계부 타입을 비교하는 분기문도 존재했습니다. 가계부 타입 뿐만이 아니라 무언가를 비교해야 하는 복잡한 기능 처리가 존재하기 때문에 코드는 점점 더 복잡해져갑니다.

 

 

 

Spring MVC

스프링 MVC에는 @Controller 어노테이션과 @RequestMapping 어노테이션이 있습니다. 

Controller에는 클라이언트의 요청을 처리할 메서드를 작성하고, 각 메서드에는 @RequestMapping 어노테이션이 있어 클라이언트가 입력한 요청 URL을 어떤 메서드가 처리할지 매핑시켜줄 수 있습니다. 이러한 동작에 대한 이해와 함께 웹 서비스와 챗봇에서 요청에 대한 처리를 비교하며 저만의 챗봇 컨트롤러를 만들어 보았습니다.

 

저는 웹의 URL과 챗봇의 메시지는 다른 듯 하지만 둘 다 사용자의 '요청'이고, 챗봇에도 이러한 요청에 대해 응답을 해줄 수 있는 '컨트롤러'와 같은 역할이 필요하다고 생각했습니다.

 

스프링에서 Controller 어노테이션을 이용해서 구현한 컨트롤러 예제를 보겠습니다.

 1 ) @Controller 어노테이션을 통해 해당 클래스가 컨트롤러 역할을 하는 클래스임을 스프링 컨테이너에게 알립니다.

 2 ) @RequestMapping 어노테이션의  value로 해당 URL로 요청이 들어오면 openAccountBook() 메서드를 실행합니다.

 3 ) ModelAndView는 클라이언트에게 보여줄 화면입니다. 보여줄 화면과 데이터를 동시에 설정할 수 있습니다. 

 4 ) addObject를 통해 반환할 데이터를 ModelAndView 객체에 담습니다.

@Controller
public class AccountBookController {
	
    @RequestMapping(value = "/accountBooks/{accountBookSeq}")
    public ModelAndView openAccountBook(@PathVariable("accountBookSeq") long accountBookSeq) {
    	
        // 반환할 view
        ModelAndView mv = new ModelAndView("accountBook");
        
        // view에 담을 객체
        AccountBook accountBook = accountBookService.getAccountBook(accountBookSeq);
        mv.addObject("accountBook", accountBook);
        
        // view를 반환
        return mv;
        
    }

}

 

 

위의 Controller를 스프링 MVC 는 다음의 process로 처리합니다.

 

클라이언트에게 요청(url)이 들어오면, DispatcherServlet은 요청을 가로채어, HandlerMapping에게 URL과 매핑되는 컨트롤러를 받아옵니다. 그리고 Controller는 해당 URL이 해야 할 기능을 수행하여 결과를 리턴합니다.

 

이와 비슷한 flow를 가지는 Controller를 챗봇에서도 구현해보았습니다. 그 전에 일단 enum을 이용해 명령어 관리를 할 수 있도록 했습니다.

 

 

 

enum을 이용한 명령어 관리

가계부 챗봇에 조회 기능이 있는데, 딱 '조회'라는 메시지로만 기능을 수행할 수 있는 것은 챗봇의 장점을 살릴 수 없습니다. 명령어를 다 기억하기도 쉽지 않고, 한국어로만 제공하는 것이 아니라 영어, 일본어 등 다양하게 서비스를 제공할 수 있는데 한 기능에 명령어가 하나라면 너무 불편할 것 같습니다.

 

따라서, Command라는 enum을 만들어 하나의 명령으로 인식할 수 있는 여러 message 값을 담아서 관리했습니다. 만약 이렇게 하지 않았더라면, 조금 극단적이긴 하지만 다음과 같이 코드를 작성했을 것입니다.

if (text.equals("조회") || text.equals("조회해줘") || text.equals("가계부") || text.equals("가계부 조회") || text.equals("info") {
	// 조회 기능 
}

 

위와 같은 명령어 비교 대신 enum을 좀 더 잘 쓰기 위해, enum 내부에 다음과 같이 message에 해당하는 Command를 찾아 반환하는 메소드를 작성했습니다.

그리고 사용자가 입력한 메시지를 이렇게 Command로 변환할 수 있었습니다. 이제 '조회'를 입력하든 '조회해줘'를 입력하든 ACCOUNTBOOK_INFO라는 Command로 사용할 수 있습니다.

 

하지만 이 정도까지는 여러 메시지여도 한 가지 명령어로 인식할 수 있다는 장점 말고는 커다란 장점은 없습니다. 분기문도 그대로 존재하구요.

 

 

 

 

 

Annotation과 Reflection을 이용한 Controller 만들기

이 부분이 제가 가장 해보고 싶었던 부분입니다. 명령어와 명령어가 수행할 메소드를 매핑할 수 있도록 하는 것입니다.

일단 아쉽게도 챗봇은 웹이 아니기 때문에 @Controller와 @RequestMapping을 쓸 수 없습니다. 그래서 @CommandMapping이라는 어노테이션을 먼저 만들어 보았습니다.

 

1 ) Annotation 만들기

 

어노테이션의 본질적인 목적은 소스 코드에 메타데이터를 표현하는 것입니다. 하지만 자바의 Reflection을 이용하면 어노테이션 지정만으로도 원하는 주석 그 이상의 역할을 할 수 있습니다.

 

제가 만든 어노테이션에 @Target과 @Retention을 설정한 것이 있습니다.

 

@Target 

=> 어노테이션이 적용될 위치

=> PACKAGE (패키지), TYPE (타입), CONSTRUCTOR (생성자), FIELD (멤버 변수), METHOD (메소드), ANNOTATION_TYPE (어노테이션 타입), VARIABLE (지역 변수), PARAMETER (파라미터), TYPE_PARAMETER (파라미터 타입), TYPE_USE (타입 사용) 등 여러 곳에 적용될 수 있습니다.

=> 저는 Command와 해당 Command가 수행할 메소드를 지정해야 하기 때문에, Target을 ElementType.METHOD 로 설정했습니다.

 

@Retention

=> 어노테이션이 영향을 미치는 범위

  • SOURCE : 어노테이션 정보가 컴파일 시점까지 존재합니다.
  • CLASS : 컴파일러가 클래스를 참조할 때까지 존재합니다.
  • RUNTIME : 컴파일 이후에도 JVM에 의해 참조가 가능합니다. 즉, 런타임 시에도 존재합니다.

=> 단순한 정보 제공이 목적이 아니라, 어플리케이션을 실행하고 어노테이션의 정보를 가져와 어노테이션이 달린 메소드나 그 메소드의 클래스 정보 등을 알아오기 위해 Reflection을 이용해야 하는 상황입니다. Reflection은 실행 중인 자바 프로그램 내부를 검사할 수 있습니다. 객체를 통해 클래스의 정보를 분석해 내는 프로그램 기법이라고도 합니다.

아무튼, 실행 시에도 어노테이션 정보가 살아있어야 하니까 Retention은 RetentionPolicy.RUNTIME 으로 설정합니다.

 

다음으로는 어노테이션의 정보를 표현하기 위해 필드를 만들었습니다. 

명령어를 의미하는 Command와 해당 Command가 수행할 기능이 무엇인지 설명하는 description 두 개만 사용합니다.

 

 

이렇게 만든 CommandMapping이라는 어노테이션을 다음과 같이 메소드 위에 적용합니다.

 

 

아까 보았던 Controller 클래스의 url과 메소드를 매핑하는 @RequestMapping 어노테이션처럼, @CommandMapping 어노테이션은 Command와 메소드를 매핑할 것입니다. ACCOUNTBOOK_INFO라는 Command가 들어오면, getAccountBookInfo() 라는 메소드를 실행시킬 것이기 때문에 이렇게 함께 썼습니다.

 

하지만, 여기에서 끝난다면 이 어노테이션은 별다른 역할을 하지 못합니다. 이제 Reflection을 사용해 어노테이션을 효과적으로 사용할 것입니다.

 

 

2 ) Reflection 사용하기

@PostConstruct 어노테이션을 사용해서, 클래스가 비즈니스 로직을 수행하기 이전에 @CommandMapping 어노테이션이 붙은 메소드를 탐색하여, key는 Command, value는 Command가 실행할 메소드로 되어있는 Map을 셋팅합니다.

 

@PostConstruct

현재 제 코드는 생성자에 작성하여 해당 클래스가 생성될 때 같이 수행되어도 되지만, @PostConstruct를 사용하면 생성자와는 달리 의존성 주입이 이루어진 후에 초기화가 수행되기 때문에 의존성이 주입된 Bean 객체를 사용할 수 있습니다. 하지만 생성자는 Bean이 초기화되지 않아 주입된 의존성이 없는 상태입니다. 저는 Command와 그 메소드만을 셋팅하지만, PostConstruct 어노테이션을 붙인 메소드에서 CommandMapping이 된 Bean을 셋팅한다거나 Bean을 사용할 수 있습니다. 

 

Reflection

스프링에서는 Reflection을 사용해서 런타임 시에 등록한 빈을 애플리케이션에서 가져와 사용할 수 있게 한다고 합니다. 이렇게 런타임 시에 동적으로 기능을 수행할 수 있게 하는 Reflection을 이용해 보았습니다.

java.lang.reflect 에도 좋은 기능이 많지만, Reflections 클래스를 이용해서 Method에 붙은 Annotation을 탐색했습니다. 

 

아래는 Reflections 클래스에 대한 Java Reference입니다. 한 번에 제 프로젝트에 있는 많은 모듈들의 정보를 가져올 수 있다고 합니다.

 

 

CommandMapping 어노테이션이 붙은 모든 메소드들을 가져와 Set<Method> 에 저장합니다. 그리고 각 Method 마다의 Command를 알아내 Command와 해당 Method를 commandMethod 라는 Map에 저장하여 준비를 마쳤습니다.

 

 

 

3 ) Method Invoke

앞서 사용자가 입력한 message를 Command로 변환했습니다. 그리고 바로 위에서는 Command와 Command가 수행할 메소드를 Map에 준비해 두었습니다.

이제는 들어온 Command에 해당하는 Method를 가져와야 합니다. Map에 모두 준비해 두었으니까, get(command)를 통해 손쉽게 수행할 메소드를 알 수 있습니다. 이제 이 메소드를 실행시키기만 하면 됩니다.

 

보통 메소드를 실행시킬 때는

// 객체.메소드(파라미터);
accountBookController.getAccountBookInfo(AccountBookDto accountBookDto);

이렇게 실행합니다. 

 

하지만 메소드만 알고 있는 경우에는 다음과 같이 java.lang.reflect.Method의 method.invoke() 를 통해 동적으로 실행시킬 수 있습니다.

// method.invoke(실행시킬 메소드가 있는 객체, 메소드가 필요로 하는 파라미터);
method.invoke(controller, accountBookDto);

 

만약 이렇게 하지 않았다면, Command에 따른 메소드를 실행시키기 위해 분기문을 계속해서 작성했을 것입니다.

 

 

3 - 1 ) 실행시킬 메소드가 있는 객체 준비

클래스가 아닌 '객체'입니다. 또한, 그냥 객체가 아니라 의존 관계가 주입되어 있는 'Bean'이어야 합니다.

 

3 - 1 . 1 . 클래스 준비

이렇게 getDeclaringClass() 를 이용해서 해당 method가 있는 Class를 알 수 있습니다.

Class classOfMethod = method.getDeclaringClass();

 

3 - 1 . 2 . 객체 준비 ? 

처음에는 바보같이 객체가 필요하니까 다음과 같이 newInstance()를 통해 객체를 준비했습니다. ㅠㅠ

Object controllerObject = methodOfClass.newInstance();

이렇게 해도 method.invoke를 하면 메소드가 실행은 될 것입니다. 하지만, 해당 Controller 클래스에 주입되어 있는 의존성 (제 Controller에는 Service Bean의 의존성이 주입되어 있었습니다.) 이 모두 무시됩니다.

Bean은 Spring Container에서 직접 생성하고 관리하는 객체들인데, newInstance()를 통해 생성된 객체는 스프링 컨테이너가 관리하는 객체와는 무관하게 제가 임의로 생성시킨 객체이기 때문에 모든 의존성이 무시되는 것입니다.

 

3 - 1 . 3 . Bean 준비 !

위의 코드를 보면 이 코드를 통해 Bean을 가져옵니다.

Object controller = BeanUtils.getBean(classOfMethod);

이 코드로 보시면 됩니다. 

ApplicationContext context = ApplicationContextProvider.getApplicationContext();

if (context != null) {
	Obejct controller = context.getBean(methodOfClass);
}

Spring Container의 Bean 객체를 얻어오기 위해 ApplicationContext의 getBean을 이용하여 Bean을 준비할 수 있었습니다.

 

 

 

3 - 2 ) 메소드가 필요로 하는 파라미터 준비

메소드마다 필요로 하는 파라미터는 다릅니다. 하지만 저는 각 메소드마다 다른 파라미터를 어떻게 준비해야 할지 몰라서 초기에는 모든 메소드의 파라미터를 통일시켰습니다. 실제로는 있어서 안될 일이죠 ㅠ 

 

그래서 메소드가 필요로 하는 파라미터의 정보를 받아와 동적으로 준비하는 방법으로 이전보다 그나마 발전시켜 보았습니다. 웹 서비스를 이용하면 사용자의 요청과 함께 사용자가 입력한 form 정보라던지 그런게 같이 오는데, 사실 챗봇에서는 그냥 명령어만 오니까 따로 필요로 하는 파라미터를 메소드에 따라 직접 준비해야 하기 때문에 어쩔 수 없이 어느 정도 비교하는 코드가 존재합니다.

 

다음은 파라미터를 준비하는 메소드입니다. 

 

java.lang.reflect.Method의 getParamters와 getParameterTypes를 이용하여 파라미터 정보를 가져옵니다. 

그리고 파라미터를 준비해서 담을 params라는 빈 배열을 준비합니다.

일단 가계부 정보인 accountBookDto와 회원 데이터로 취급하는 개인 가계부 정보인 personalAccountBookDto, 사용자가 입력한 메시지인 String text 자체를 파라미터로 사용하는 메소드가 많기 때문에 if문을 통해 파라미터를 담았습니다.

그 다음으로 사용자가 입력한 메시지(text)를 파싱해서 dto로 요구하는 파라미터들이 있습니다. 앞으로 분기문이 계속해서 늘어나는 것을 방지하기 위해 파라미터의 Class에 해당하는 dtoConverter를 선택해 사용자 입력 메시지를 파싱하여 dto로 변환해주어 파라미터로 담을 수 있도록 합니다.

 

 

 

이렇게 준비한 Controller Bean과 Parameter 배열을 가지고 method.invoke()에 넘겨 메소드를 실행할 수 있었습니다.

 

 

 

 

 

챗봇 Service Flow

- Request

지금까지 과정을 거친 저의 챗봇 서비스의 Flow는 이렇습니다.

 

1 ) 사용자의 메시지 입력

사용자가 메시지(Text)를 입력하면 MessageHandler에서 해당 메시지를 받습니다.

 

2 ) Text to Command

사용자가 입력한 text 메시지를 enum인 Command로 변환하여, HandlerMapping으로 보냅니다.

 

3 ) Command가 수행할 메소드 실행

HandlerMapping에서 받은 Command가 수행할 메소드를 알아냈습니다. 그리고 해당 메소드가 있는 Controller와 메소드가 필요로 하는 파라미터들을 준비하여 메소드를 실행시켰습니다.

 

4 ) Controller의 서비스 처리

Controller의 메소드가 실행되어, 비즈니스 로직을 수행합니다.

 

 

- Response

 

5 ) 답장 메시지 반환 

Controller 에서 서비스를 처리하고, 답장할 메시지를 만들어 반환합니다. 이 때, 저는 컨트롤러의 메소드에는 비즈니스 로직만 수행하게 하고 답장을 만드는 부분은 ReplyMessageSupplier라는 클래스에게 역할을 위임하여 여기에서 답장을 만듭니다. 

그리고 Controller에서는 ReplyMessageSupplier에서 만들어진 답장 메시지를 반환하기만 합니다.

 

6 ) Reply Message 전달 

메소드를 Invoke한 HandlerMapping를 거쳐 MessageHandler에서 해당 ReplyMessage를 받아옵니다.

 

7 ) 사용자에게 답장

받아온 ReplyMessage를 담아 MessageHandler에서 사용자에게 답장을 하고, 사용자는 답장 메시지를 받아볼 수 있습니다.

 

 

 

'개발 노트' 카테고리의 다른 글

user level lock  (0) 2019.06.12
JPA 정리하기  (0) 2019.06.07
Entity to DTO, DTO to Entity 그리고 ModelMapper  (10) 2019.05.16
Optional 클래스 사용하기  (1) 2019.05.16
LINE Messaging API를 활용한 챗봇 만들기  (5) 2019.05.10