카카오 챗봇 응답 템플릿 작성하기

2023. 2. 14. 15:49카테고리 없음

이번 포스팅에선 ‘채용공고 크롤러’ 사이드 프로젝트를 진행하면서 카카오 챗봇을 구현한 내용을 상세히 기록하고자 한다.

카카오 챗봇

출처: https://i.kakao.com/docs/skill-build#스킬-서버-이해하기

카카오 봇 시스템은 유저로부터 스킬 요청을 받고 스킬 서버와 통신하여 알맞은 데이터를 응답해준다.

여기서 스킬이란 카카오 챗봇 시스템내에서 사용하는 동적 데이터 요청 기능 단위를 말한다.

스킬은 스킬 서버 API와 1대1 매핑된다.

다음의 순서로 개발해보도록 하겠다.

  1. API 생성
  2. 스킬 등록
  3. 블록에 매핑
  4. 테스트

1. API 생성

스킬 타입으로 ListCard와 SimpleText를 선택했다.

ListCard는 한번의 요청으로 데이터를 간략한 블록 리스트 형태로 제공할 수 있다.

한번의 요청으로 여러 데이터를 응답해야하므로 ListCard 타입을 선택했다.

만약 반환할 데이터가 없다면 SimpleText로 반환할 데이터가 없다는 알림을 출력했다.

응답 JSON 포맷은 아래처럼 공식 문서에 정의된 형식에 맞춰서 응답해줘야 한다.

{
  "version": "2.0",
  "template": {
    "outputs": [
      {
        "listCard": {
          "header": {
            "title": "챗봇 관리자센터를 소개합니다."
          },
          "items": [
            {
              "title": "챗봇 관리자센터",
              "description": "새로운 AI의 내일과 일상의 변화",
              "imageUrl": "<https://t1.kakaocdn.net/openbuilder/sample/img_001.jpg>",
              "link": {
                "web": "<https://namu.wiki/w/%EB%9D%BC%EC%9D%B4%EC%96%B8(%EC%B9%B4%EC%B9%B4%EC%98%A4%ED%94%84%EB%A0%8C%EC%A6%88)>"
              }
            },
            {
              "title": "챗봇 관리자센터",
              "description": "카카오톡 채널 챗봇 만들기",
              "imageUrl": "<https://t1.kakaocdn.net/openbuilder/sample/img_002.jpg>",
              "action": "block",
              "blockId": "62654c249ac8ed78441532de",
              "extra": {
                "key1": "value1",
                "key2": "value2"
              }
            },
            {
              "title": "Kakao i Voice Service",
              "description": "보이스봇 / KVS 제휴 신청하기",
              "imageUrl": "<https://t1.kakaocdn.net/openbuilder/sample/img_003.jpg>",
              "action": "message",
              "messageText": "Kakao i Voice Service",
              "extra": {
                "key1": "value1",
                "key2": "value2"
              }
            }
          ],
          "buttons": [
            {
              "label": "구경가기",
              "action": "block",
              "blockId": "62654c249ac8ed78441532de",
              "extra": {
                "key1": "value1",
                "key2": "value2"
              }
            }
          ]
        }
      }
    ]
  }
}
{
    "version": "2.0",
    "template": {
        "outputs": [
            {
                "simpleText": {
                    "text": "간단한 텍스트 요소입니다."
                }
            }
        ]
    }
}

예제 코드

Recruit

채용공고 엔티티 클래스이다.

mybatis는 reflection을 통해 필드값에 접근하기 때문에 기본 생성자를 생성해준다.

@Getter
public class Recruit {
    private Long id;
    private String title;
    private String career;
    private String dueDate;
    private String company;
    private String address;
    private String workerType;
    private String link;
    private LocalDateTime addDateTime;
    private LocalDateTime updateDateTime;
    
    public Recruit() {}
    
    private Recruit(Builder builder) {
        id = builder.id;
        title = builder.title;
        career = builder.career;
        dueDate = builder.dueDate;
        company = builder.company;
        address = builder.address;
        workerType = builder.workerType;
        link = builder.link;
        addDateTime = builder.addDateTime;
        updateDateTime = builder.updateDateTime;
    }

    public static class Builder {
        // 필수 매개변수
        private final String title;
        private final String company;
        private final String link;

        // 선택 매개변수
        private Long id = 0L;
        private String career = null;
        private String dueDate = null;
        private String address = null;
        private String workerType = null;
        private LocalDateTime addDateTime = null;
        private LocalDateTime updateDateTime = null;

        public Builder(String title, String company, String link) {
            this.title = title;
            this.company = company;
            this.link = link;
        }

        public Builder id(Long id) {
            this.id = id;
            return this;
        }

        public Builder career(String career) {
            this.career = career;
            return this;
        }

        public Builder dueDate(String dueDate) {
            this.dueDate = dueDate;
            return this;
        }

        public Builder address(String address) {
            this.address = address;
            return this;
        }

        public Builder workerType(String workerType) {
            this.workerType = workerType;
            return this;
        }

        public Builder addDateTime(LocalDateTime addDateTime) {
            this.addDateTime = addDateTime;
            return this;
        }

        public Builder updateDateTime(LocalDateTime updateDateTime) {
            this.updateDateTime = updateDateTime;
            return this;
        }

        public Recruit build() {
            return new Recruit(this);
        }
    }
}

ListCardResponse

ListCard 타입 응답 템플릿 클래스이다.

DB에서 조회한 채용 공고 리스트를 파라미터로 넘겨주면 인스턴스를 생성하는 static 메서드를 정의한다.

ListCard는 최대 15개의 데이터만 표시할 수 있고 3개의 블록(말풍선)으로 나누어 출력한다는 제약사항이 있다.

따라서 loop을 돌면서 5개씩 묶어서 저장한다.

엔티티 데이터는 title, description, imageUrl 필드에 매핑된다.

{
    "version": "2.0",
    "template": {
        "outputs": [
            {
                "listCard": {
                    "header": {
                        "title": "6월 21일 신규 채용 공고 (1/1)"
                    },
                    "items": [
                        {
                            "title": "[Up-data!] 2022 kakao Data 경력 개발자 영입",
                            "description": "카카오/경력/정규직/2022년 7월 8일 까지/판교",
                            "imageUrl": "<https://t1.kakaocdn.net/kakaocorp/corp_thumbnail/Kakao.png>",
                            "link": {
                                "web": "<https://careers.kakao.com/jobs/P-12703?page=1>"
                            }
                        },
                        {
                            "title": "카카오의 사회공헌 서비스를 만들어갈 서버∙백엔드 개발자 모집",
                            "description": "카카오/정규직/영입종료시/판교",
                            "imageUrl": "<https://t1.kakaocdn.net/kakaocorp/corp_thumbnail/Kakao.png>",
                            "link": {
                                "web": "<https://careers.kakao.com/jobs/P-12691?page=1>"
                            }
                        }
                    ]
                }
            }
        ]
    }
}
@Getter
@Slf4j
public class ListCardResponse implements CommonResponse {
    private final String version;
    private final Template template;
    private static final Map<string, string=""> logoUrlMap = Map.of("카카오", "<https://t1.kakaocdn.net/kakaocorp/corp_thumbnail/Kakao.png>", "네이버", "<https://blog.kakaocdn.net/dn/XEAGa/btqB10c0bQ1/RZk86MYViwrP4OaBdpfhn0/img.png>");

    private ListCardResponse(String version, Template template) {
        this.version = version;
        this.template = template;
    }

    public static ListCardResponse of(List recruits) {
        String now = LocalDate.now().getMonthValue() + "월 " + LocalDate.now().getDayOfMonth() + "일";
        List outputs = new ArrayList<>();
        int totalCount = recruits.size();  // 공고 개수
        int totalPage = totalCount % 5 == 0 ? totalCount / 5 : totalCount / 5 + 1;  // 블록 수
        int currentPage = 1;  // 현재 블록 페이지
        int currentIndex = 0;  // 블록 첫 인덱스

        while(currentIndex < totalCount) {
            List items = new ArrayList<>();
            for(int i = currentIndex; i < totalCount; i++) {
                Recruit recruit = recruits.get(i);
                StringBuilder sb = new StringBuilder();
                sb.append(recruit.getCompany());

                if(recruit.getCareer() != null) {
                    sb.append("/").append(recruit.getCareer());
                }

                if(recruit.getWorkerType() != null) {
                    sb.append("/").append(recruit.getWorkerType());
                }

                if(recruit.getDueDate() != null) {
                    sb.append("/").append(recruit.getDueDate());
                }

                if(recruit.getAddress() != null) {
                    sb.append("/").append(recruit.getAddress());
                }

                Item item = new Item(recruit.getTitle(), sb.toString(), logoUrlMap.get(recruit.getCompany()), new Link(recruit.getLink()));
                items.add(item);
                if(i == totalCount - 1 || i - currentIndex == 4) {
                    outputs.add(new Output(new ListCard(new Header(now + " 신규 채용 공고 (" + currentPage++ + "/" + totalPage + ")"), items)));
                    currentIndex = i + 1;
                    break;
                }
            }
        }

        return new ListCardResponse("2.0", new Template(outputs));
    }

    @Getter
    private static class Template{
        private final List outputs;

        private Template(List outputs) {
            this.outputs = outputs;
        }
    }

    @Getter
    private static class Output{
        private final ListCard listCard;

        private Output(ListCard listCard) {
            this.listCard = listCard;
        }
    }

    @Getter
    private static class ListCard {
        private final Header header;
        private final List items;

        private ListCard(Header header, List items) {
            this.header = header;
            this.items = items;
        }
    }

    @Getter
    private static class Header {
        private final String title;

        private Header(String title) {
            this.title = title;
        }
    }

    @Getter
    private static class Item {
        private final String title;
        private final String description;
        private final String imageUrl;
        private final Link link;

        public Item(String title, String description, String imageUrl, Link link) {
            this.title = title;
            this.description = description;
            this.imageUrl = imageUrl;
            this.link = link;
        }
    }

    @Getter
    private static class Link {
        private final String web;

        private Link(String web) {
            this.web = web;
        }
    }
}

SimpleTextResponse

SimpleText 타입 응답 템플릿 클래스이다.

조회된 데이터가 없다면 알림문구를 띄우기 위해 정의한다.

{
    "version": "2.0",
    "template": {
        "outputs": [
            {
                "simpleText": {
                    "text": "X월 X일 신규 추가된 채용공고가 존재하지 않습니다.\\n신규 채용정보는 매일 오전 8시에 업데이트됩니다."
                }
            }
        ]
    }
}
@Getter
public class SimpleTextResponse implements CommonResponse{
    private final String version;
    private final Template template;

    private SimpleTextResponse(String version, Template template) {
        this.version = version;
        this.template = template;
    }

    public static SimpleTextResponse empty() {
        String now = LocalDate.now().getMonthValue() + "월 " + LocalDate.now().getDayOfMonth() + "일";
        List<Output> outputs = new ArrayList<>();
        outputs.add(new Output(new SimpleText(now + " 신규 추가된 채용공고가 존재하지 않습니다.\\n신규 채용정보는 매일 오전 8시에 업데이트됩니다.")));

        return new SimpleTextResponse("2.0", new Template(outputs));
    }

    @Getter
    private static class Template{
        private final List<Output> outputs;

        private Template(List<Output> outputs) {
            this.outputs = outputs;
        }
    }

    @Getter
    private static class Output{
        private final SimpleText simpleText;

        private Output(SimpleText simpleText) {
            this.simpleText = simpleText;
        }
    }

    @Getter
    private static class SimpleText {
        private final String text;

        private SimpleText(String text) {
            this.text = text;
        }
    }
}

CommonResponse

ListCardResponse 객체와 SimpleTextResponse를 추상화한 인터페이스이다.

public interface CommonResponse {}

RecruitController

스킬 서버의 채용 공고 조회 API를 정의한 Controller 클래스이다.

if 절에 의한 과도한 타입 분기는 유지보수에 취약한 구조를 가져올 수 있다.

이에 관한 부분은 다음 포스팅에서 다루겠다.

@RestController
public class RecruitController {

    private final RecruitService recruitService;
    private final Map<String, String> companies = Map.of("kakao", "카카오", "naver", "네이버");

    public RecruitController(RecruitService recruitService, ObjectMapper objectMapper) {
        this.recruitService = recruitService;
        this.objectMapper = objectMapper;
    }

    @PostMapping("/api/new-recruits/{company}")  // company : {kakao, naver, all}
    public CommonResponse getNewRecruitsAfterYesterdayByListCard(@PathVariable(name = "company") String company) {
        List<Recruit> findRecruits;
        if(company.equals("all")) {
            findRecruits = recruitService.getAllRecruitsAfterYesterday();
        } else {
            findRecruits = recruitService.getRecruitsByCompanyAfterYesterday(companies.get(company));
        }

        CommonResponse result;
        if(findRecruits.size() != 0) {
            result = ListCardResponse.of(findRecruits);
        } else {
            result = SimpleTextResponse.empty();
        }

        return result;
    }
}

2. 스킬 등록

카카오 챗봇 관리자 센터에서 스킬 서버로 요청할 스킬을 등록해준다.

3. 블록에 매핑

미리 짜놓은 시나리오에 맞춰 출력할 데이터 블록을 스킬과 매핑시켜준다.

4. 테스트

아래와 같이 동적 데이터가 정상 출력되는 것을 볼 수 있다.

정리

이번 포스팅에선 카카오 챗봇을 이용해서 동적 데이터를 출력하는 방법에 대해서 정리해 보았다.

글을 작성하면서 비즈니스 레이어가 구현체에 너무 많이 의존되어서 변경에 취약한 구조를 띄는 것을 발견했다.

다음 포스팅에선 이를 어떻게 하면 개선할 수 있을지 다뤄보겠다.