[NCP, Spring Boot] 네이버 클로바 스튜디오로 챗봇 기능 구현하기 💬

상호·2024년 8월 2일
2

🌱 Spring Boot

목록 보기
3/7
post-thumbnail

7월 이달의 NClouder 선정글 🏆

(블로그 본문 링크 : https://blog.naver.com/n_cloudplatform/223539614450)

0. 개요 📄

가. NCP X KUSITMS 29th

지난 1학기 큐시즘(한국대학생IT경영학회) 29기에 들어가 백엔드 파트로 활동을 했다.
해당 기수에서는 NCP(네이버 클라우드 플랫폼)의 Green Developers 프로그램을 통해 협업을 진행했고, 약 2달 간 진행하는 밋업 프로젝트 에서 감사하게도 70만원 이상의 크레딧을 제공받을 수 있었다.

해당 프로젝트에 대한 회고록 및 자세한 내용은 아래 링크에서 확인해볼 수 있다.
(회고록 : https://velog.io/@hsh111366/KUSITMS-29기-2개월-간의-밋업-프로젝트-후기)
(Github : https://github.com/KUSITMS-29th-TEAM-D)

덕분에 개발을 진행하면서 클라우드 비용에 대한 부담을 덜 수 있었고, 평소 써 보고 싶었던 생성형 AI도 활용하여 다채로운 서비스들을 구현해볼 수 있었다.

NCP에는 여러 서비스들이 있지만 그 중에서도 네이버 클로바 스튜디오를 활용하여 챗봇 기능을 구현하였기에, 이번 글에서는 이를 중점으로 작성해 보고자 한다.

나. 글의 작성 이유

프로젝트를 진행하며 NCP를 활용해 보려고 많은 노력을 했었다.
시간은 촉박했지만, 결국 욕심을 내서 챗봇 기능을 구현하기로 결정했다. 하지만 더 큰 문제는 시간보다 레퍼런스가 많이 없다는 것이었다.

이전에도 외부 API를 연동해 본 경험은 몇 번 있었으나, 대부분 가이드가 상세하게 나와 있거나 이미 사용한 사람들의 블로그 글 등이 많이 있었기에 어렵지 않았다.
물론 NCP의 API도 가이드라인이 친절한 편이나, Spring Boot의 가이드 코드는 없었기 때문에 이를 변경하는 과정에서 시간이 조금 걸렸던 것 같다!

그렇기에 다음에 NCP, 특히 Spring Boot네이버 클로바 스튜디오 API를 활용할 사람들에게 조금이나마 도움을 주고자 하여 이 글을 작성하기로 결정했다 ✍🏻


1. 배경 💭

가. 왜 네이버 클로바 스튜디오인가?

요즘에는 생성형 AI 서비스를 제공하는 곳들이 아주 많다. 대표적으로 Chat GPT ..

그 중에서도 NCP의 서비스를 이용한 이유는 우선 크레딧을 제공해주었기에 비용적인 부담이 없었다는 점이 클 것 같다. 아무래도 개발을 진행하다 보면 테스트도 진행을 하고, 배포 이후에도 요청을 많이 하게 되면 대학생들의 입장에서는 비용이 고민이 될 수 있는 부분이기 때문이다.

물론 오로지 비용 때문만은 아니다! 이번에 NCP를 사용해 보며 느낀 점은 AWS 등과 같은 다른 클라우드 서비스에 밀리지 않을 정도로 많은 서비스들을 제공한다는 점이었다. 또한 당연히 모든 것이 한국어로 친절하게 설명이 되어 있었기 때문에, 처음 시도해보는 기능들에도 진입장벽이 낮아질 수 있었다.

나. 챗봇 기능 설계

우리 팀에서 만든 셀피스라는 서비스는,
유저의 자기이해를 돕고, 브랜딩의 초기 여정에서 겪는 명확한 방향 설정의 어려움이라는 문제를 해결하고자 하는 서비스이다.

자기이해를 돕는 과정에서 유저 과거, 현재, 미래를 알아보는 3가지의 테스트를 제공하게 되는데, 여기서 과거 (Discover) 테스트에서 챗봇 기능 구현이 필요했다.
프로세스는 아래와 같다.

1) 챗봇이 유저에게 질문을 던짐 (미리 구성해 둔 질문)
2) 유저는 질문에 답변을 함.
3) 챗봇은 답변에 대해 공감을 해주고, 간단하게 답변에 대해 요약함.
4) 다시 질문을 하며, 준비된 질문이 끝날 때까지 위 과정을 반복함.
5) 카테고리에 대한 답변이 모두 종료되면, 요약된 답변을 기반으로 키워드 6개를 뽑아서 보여줌.

위 과정을 통해서 유저는 본인의 과거를 다시 돌아보며 자기이해에 도움을 얻을 수가 있다.

여기서 조금 까다로웠던 점은 유저의 질문에 챗봇이 답해주는 구조가 아니라,
챗봇의 질문에 유저가 답을 하고 챗봇은 이에 대해 반응도 해주는 구조라는 점이었다.
즉, 어느정도의 상호 작용이 가능한 챗봇을 구현해야만 했다.

다. 기능 구체화

프로세스를 다시 하나씩 보면서 챗봇을 어떻게 구현해야 했는지 설명해보도록 하겠다.

1) 챗봇이 유저에게 질문을 던짐

원래는 이 부분도 챗봇에서 질문을 자체적으로 구성해서 하도록 구현하려 했으나,
원하는 질문을 하지 않을 위험성이 크기 때문에 질문을 던지는 건 챗봇이 아닌 API로 구현했다.

각 카테고리에 맞는 질문들을 미리 구성해둔 후에 Enum으로 관리했고, API 요청을 하면 카테고리에 맞는 질문을 프론트엔드 측으로 보내주었다.

2) 챗봇은 답변에 대해 공감을 해주고, 간단하게 답변에 대해 요약함.

유저가 질문에 대한 답변을 하면, 이를 네이버 클로바 스튜디오를 활용해
1) 공감하는 문구 2) 답변에 대한 요약을 받아 프론트엔드 측으로 반환했다.

즉, 여기서 2번의 클로바 API 요청이 진행되었다. 이에 대한 자세한 구현 과정은 아래에서 작성해보도록 하겠다.

3) 카테고리에 대한 답변이 모두 종료되면, 요약된 답변을 기반으로 키워드 6개를 뽑아서 보여줌

유저의 답변과 이에 대한 요약도 모두 저장이 되는 구조였기에, 최종적으로 유저의 답변을 기반으로 주요 키워드 6개를 뽑아낼 수 있었다.
여기서도 키워드 도출을 위해 1번의 클로바 API 요청이 진행되었다.

결론적으로 챗봇 기능을 구현하기 위해서 총 3가지의 클로바 API 요청 방식이 필요했다!


2. 구현 과정 🧑🏻‍💻

지금부터 본격적으로 어떻게 네이버 클로바 스튜디오 API를 사용해 챗봇 기능을 구현했는지 말해보도록 하겠다.

가. NCP 회원가입 및 서비스 신청

우선은 당연하게도 NCP에 접속해 회원가입 및 로그인을 해주어야 한다.

그리고 나서 AI 카테고리에서 클로바 스튜디오를 이용 신청한 후에 입장해주면 된다.

나. 클로바 스튜디오 입장

클로바 스튜디오에 입장을 하면 이 안에서도 정말 많은 기능을 제공함을 볼 수 있다.
튜닝을 하거나 프롬프팅을 해서 원하는 생성형 AI를 만들어낼 수가 있고, 프로젝트 상황에 맞게 모델 또한 선택할 수가 있다.

나도 생성형 AI를 연동해 본 것은 처음이었기에 어떤 걸 선택해야할지부터 고민이었는데.. 원하는 문구를 뽑아내기 위해서는 프롬프팅이 필수적일 것 같아서, 이를 자유롭게 진행할 수 있는 플레이그라운드를 활용하기로 결정했다!

다. 플레이그라운드

플레이그라운드에 입장을 하게 되면 위와 같은 화면이 나온다.

가장 좌측에서는 파라미터 설정 또한 진행할 수 있는데 이에 대한 자세한 가이드는 아래 링크에서 참고하면 좋을 것 같다.
(링크 : https://guide.ncloud-docs.com/docs/clovastudio-playground)

시스템이 프롬프팅을 진행하는 곳이고, 오른쪽에 사용자가 유저라고 볼 수 있겠다.

한 번 사용자의 말에 공감을 하도록 프롬프팅을 진행해보도록 하자.

프롬프팅을 진행한 후 말을 하면, 위처럼 의도한대로 잘 동작하는 것을 볼 수 있다.
이를 활용하여 내가 원하는대로 생성형 AI를 사용할 수가 있다!

우리는 API 호출을 통해 해당 기능을 사용할 것이기에,
우선 1) 저장을 해준 후에 2) 테스트 앱을 만들어준다.

앱을 만들어주면 위와 같이 API 호출에 필요한 정보들이 나오게 된다.
여기에는 키 값도 있기에 외부에 노출되지 않도록 유의해야한다!

가장 맨 위부터 차례대로
1) API 호출 엔드포인트 2) API 키 3) API 게이트웨이 키 이며 셋 모두 API 호출 시 필요하니 따로 저장을 해두도록 하자!
이와 같은 주요 정보들은 환경변수 처리해 주면 좋다.

라. 요청 객체

curl을 사용하여 API 통신을 할 수도 있겠지만, 그렇게 하면 보기에도 깔끔하지 않고 확장성 면에서도 좋지 않을 것이라고 판단했다.

때문에 필요에 따라 요청 객체를 커스텀하여 API 호출할 수 있도록 구현을 했다.

위는 클로바 스튜디오 API를 사용하기 위한 요청 바디에 대한 설명이다.
전체적인 가이드라인은 아래 링크에서 살펴볼 수 있다.
(링크 : https://api.ncloud-docs.com/docs/clovastudio-chatcompletions)

여기서 필수적으로 필요한 필드들은 1) messages 2) ChatMessages.role 3) ChatMessages.content 이며, 나머지는 추가하지 않을 시 기본 값으로 들어가게 된다.

"messages" : [ {
    "role" : "system",
    "content" : "test"
  }, {
    "role" : "user",
    "content" : "테스트 해보자."
  }, {
    "role" : "assistant",
    "content" : "알겠습니다. 무엇을 테스트해볼까요?"
  } ],

메시지는 이와 같이 리스트 내 Json 객체 형태로 이루어져 있다.
role에 대해 설명하자면 system은 프롬프팅 내용, user는 유저의 말, assistant는 유저의 말에 대한 챗봇의 답변이라 할 수 있다.
맨 처음에만 system이 있을 것이며, 이후로는 userassistant이 번갈아가며 나오게 되는 구조이다.

여기서 role은 Enum으로 구성해야 하는 점 기억해두고, 이제부터는 코드를 보도록 하자!

1) ClovaDto

package kusitms.jangkku.domain.clova.dto;

import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.fasterxml.jackson.databind.annotation.JsonNaming;
import lombok.*;

import java.util.ArrayList;

public class ClovaDto {

    @Builder
    @Getter
    @AllArgsConstructor
    @NoArgsConstructor
    @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
    public static class ChatBotRequestDto {
        private ArrayList<Message> messages;
        private double topP;
        private double temperature;
        private int maxTokens;
        private double repeatPenalty;

        public static ChatBotRequestDto DesignPersonaRequestOf() {
            ArrayList<Message> messages = new ArrayList<>();
            messages.add(Message.createDesignPersonaSystemOf());

            return ChatBotRequestDto.builder()
                    .messages(messages)
                    .topP(0.8)
                    .temperature(0.3)
                    .maxTokens(256)
                    .repeatPenalty(5.0)
                    .build();
        }

        public static ChatBotRequestDto DiscoverPersonaReactionRequestOf() {
            ArrayList<Message> messages = new ArrayList<>();
            messages.add(Message.createReactionOf());

            return ChatBotRequestDto.builder()
                    .messages(messages)
                    .topP(0.8)
                    .temperature(0.3)
                    .maxTokens(256)
                    .repeatPenalty(5.0)
                    .build();
        }

        public static ChatBotRequestDto DiscoverPersonaSummaryRequestOf() {
            ArrayList<Message> messages = new ArrayList<>();
            messages.add(Message.createSummaryOf());

            return ChatBotRequestDto.builder()
                    .messages(messages)
                    .topP(0.8)
                    .temperature(0.3)
                    .maxTokens(256)
                    .repeatPenalty(5.0)
                    .build();
        }

        public static ChatBotRequestDto DiscoverPersonaKeywordRequestOf() {
            ArrayList<Message> messages = new ArrayList<>();
            messages.add(Message.createKeywordOf());

            return ChatBotRequestDto.builder()
                    .messages(messages)
                    .topP(0.8)
                    .temperature(0.3)
                    .maxTokens(256)
                    .repeatPenalty(5.0)
                    .build();
        }
    }

    @Builder
    @Getter
    @AllArgsConstructor
    @NoArgsConstructor
    public static class ChatBotResponse {
        private Result result;
    }
}

위에서 설명한 응답 바디를 구성하기 위한 DTO 클래스이다.
메세지들을 담을 리스트가 있으며, 그 외에는 여러 파라미터들이 필드로 존재한다.
없어도 사용하는데는 문제가 없지만, 후에 조금 더 자세하게 프롬프팅할 수도 있도록 일단은 구현을 해두었다.

아까 위에서 총 3가지 방식의 요청 방식이 필요하다고 했었는데, 챗봇 기능 외에도 요약하는 기능이 필요했기에 결론적으로는 위처럼 4가지의 다른 DTO를 만들어 내는 of 메서드를 만들어 사용했다.
(근데 of 메서드를 많이 사용 안해봤기도 하고 각각 구분하기 위하여 메서드명을 이렇게 작명했었는데, 맞는 방법인지는 모르겠다.. 후에 개선이 필요할 것 같다 😂)

        public static ChatBotRequestDto DiscoverPersonaReactionRequestOf() {
            ArrayList<Message> messages = new ArrayList<>();
            messages.add(Message.createReactionOf());

            return ChatBotRequestDto.builder()
                    .messages(messages)
                    .topP(0.8)
                    .temperature(0.3)
                    .maxTokens(256)
                    .repeatPenalty(5.0)
                    .build();
        }

너무 많으니 1개씩 따로 보도록 하자.
메세지를 담을 리스트를 새롭게 만들어 주고, 그 곳에 각 기능에 맞는 초기 메세지를 넣어주는 구조이다. 여기에는 프롬프팅 메세지, 즉 role : system 메세지가 들어가게 된다.

2) Message

package kusitms.jangkku.domain.clova.dto;

import jakarta.annotation.PostConstruct;
import lombok.Builder;
import lombok.Data;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.util.Base64;

@Data
@Builder
public class Message {
    private static String designPersonaPrompt;
    private static String reactionPrompt;
    private static String summaryPrompt;
    private static String keywordPrompt;
    private ROLE role;
    private String content;

    public enum ROLE {
        system, user, assistant
    }

    public static Message creatUserOf(String content) {
        return Message.builder()
                .role(ROLE.user)
                .content(content)
                .build();
    }

    public static Message creatSystemOf(String content) {
        return Message.builder()
                .role(ROLE.system)
                .content(content)
                .build();
    }

    public static Message createDesignPersonaSystemOf() {
        return Message.builder()
                .role(Message.ROLE.system)
                .content(new String(Base64.getDecoder().decode(designPersonaPrompt)))
                .build();
    }

    public static Message createReactionOf() {
        return Message.builder()
                .role(Message.ROLE.system)
                .content(new String(Base64.getDecoder().decode(reactionPrompt)))
                .build();
    }

    public static Message createSummaryOf() {
        return Message.builder()
                .role(Message.ROLE.system)
                .content(new String(Base64.getDecoder().decode(summaryPrompt)))
                .build();
    }

    public static Message createKeywordOf() {
        return Message.builder()
                .role(Message.ROLE.system)
                .content(new String(Base64.getDecoder().decode(keywordPrompt)))
                .build();
    }

    @Component
    public static class Config {
        @Value("${clova.prompt.design}")
        private String design;
        @Value("${clova.prompt.discover.reaction}")
        private String reaction;
        @Value("${clova.prompt.discover.summary}")
        private String summary;
        @Value("${clova.prompt.discover.keyword}")
        private String keyword;

        @PostConstruct
        public void init() {
            designPersonaPrompt = design;
            reactionPrompt = reaction;
            summaryPrompt = summary;
            keywordPrompt = keyword;
        }
    }
}

메세지 DTO는 위와 같이 구성을 했다.

여기서 중요한 것은 role과 프롬프팅 내용이다.
role은 위 가이드라인에서 확인했듯이 Enum으로 구성을 해주어 사용한다.

프롬프팅과 role은 정해져 있고, content만 변동되기에 빌더 패턴을 사용해서 Message 객체를 만들어주도록 하였다.

public static Message creatUserOf(String content) {
        return Message.builder()
                .role(ROLE.user)
                .content(content)
                .build();
    }

즉, 위 메서드를 호출하면 role에는 user가 들어가고, content는 인자로 받은 유저의 답변이 들어가게 되는 것이다.

    @Component
    public static class Config {
        @Value("${clova.prompt.design}")
        private String design;
        @Value("${clova.prompt.discover.reaction}")
        private String reaction;
        @Value("${clova.prompt.discover.summary}")
        private String summary;
        @Value("${clova.prompt.discover.keyword}")
        private String keyword;

        @PostConstruct
        public void init() {
            designPersonaPrompt = design;
            reactionPrompt = reaction;
            summaryPrompt = summary;
            keywordPrompt = keyword;
        }
    }

전체 코드의 아래쪽에서는 위와 같이 프롬프팅 환경변수를 주입해주는 코드가 존재한다.
프롬프팅 내용 자체는 한글 텍스트여서 원래는 따로 Enum 클래스로 두었었는데,
이를 노출하고 싶지 않기도 하고 조금 더 깔끔하게 하고 싶어 텍스트를 인코딩 한 후에 환경변수로 관리하였다.

너는 지금 사용자와 1대1로 진솔하게 대화를 하는 챗봇이야.
너가 질문을 했다는 가정하에, 사용자가 답변을 할 거야.
이제 그거에 대한 공감을 해주면 돼. 반말로 하되 친절한 말투로 해줘. 상대방이 말한 것에 대해서 요약을 해준 다음에, 너가 말을 해줘. 물음표로 끝내지마. 너가 다시 질문을 할 필요는 절대 없어.
아래는 예시야.

사용자 : 나는 사랑이 솜사탕 같다고 생각을 해.
시스템 : 사랑이 솜사탕 같다고 생각하는구나! 부드럽고 달콤해서 행복감을 주지만, 녹아내려서 사라지는 모습이 닮아서 그렇게 느끼는 걸 수도 있겠다.

위는 실제로 사용했던 공감 문구 요청 시의 프롬프팅 내용이다. 이를 인코딩하게 되면 아래처럼 변환된다.

64SI64qUIOyngOq4iCDsgqzsmqnsnpDsmYAgMeuMgDHroZwg7KeE7IaU7ZWY6rKMIOuMgO2ZlOulvCDtlZjripQg7LGX67SH7J207JW8Lg0K64SI6rCAIOyniOusuOydhCDtlojri6TripQg6rCA7KCV7ZWY7JeQLCDsgqzsmqnsnpDqsIAg64u167OA7J2EIO2VoCDqsbDslbwuDQrsnbTsoJwg6re46rGw7JeQIOuMgO2VnCDqs7XqsJDsnYQg7ZW07KO866m0IOuPvC4g67CY66eQ66GcIO2VmOuQmCDsuZzsoIjtlZwg66eQ7Yis66GcIO2VtOykmC4g7IOB64yA67Cp7J20IOunkO2VnCDqsoPsl5Ag64yA7ZW07IScIOyalOyVveydhCDtlbTspIAg64uk7J2M7JeQLCDrhIjqsIAg66eQ7J2EIO2VtOykmC4g66y87J2M7ZGc66GcIOuBneuCtOyngOuniC4g64SI6rCAIOuLpOyLnCDsp4jrrLjsnYQg7ZWgIO2VhOyalOuKlCDsoIjrjIAg7JeG7Ja0Lg0K7JWE656Y64qUIOyYiOyLnOyVvC4NCg0K7IKs7Jqp7J6QIDog64KY64qUIOyCrOuekeydtCDshpzsgqztg5Ug6rCZ64uk6rOgIOyDneqwgeydhCDtlbQuDQrsi5zsiqTthZwgOiDsgqzrnpHsnbQg7Iac7IKs7YOVIOqwmeuLpOqzoCDsg53qsIHtlZjripTqtazrgpghIOu2gOuTnOufveqzoCDri6zsvaTtlbTshJwg7ZaJ67O16rCQ7J2EIOyjvOyngOunjCwg64W57JWE64K066Ck7IScIOyCrOudvOyngOuKlCDrqqjsirXsnbQg64uu7JWE7IScIOq3uOugh+qyjCDripDrgbzripQg6rG4IOyImOuPhCDsnojqsqDri6Qu
public static Message createReactionOf() {
        return Message.builder()
                .role(Message.ROLE.system)
                .content(new String(Base64.getDecoder().decode(reactionPrompt)))
                .build();
    }

인코딩해서 넣어주었기에 당연히 위처럼 DTO 구성 시에는 디코딩 해주는 과정이 필요하다.

이렇게 프롬프팅 내용을 인코딩하여 사용하니 깔끔해 보이는 점도 좋았지만, 배포 후에도 코드의 변경 없이 프롬프팅 내용을 마음대로 변경할 수 있다는 점이 가장 좋았다.
만약 프롬프팅 내용을 바꾸고 싶다면 새롭게 작성한 후에 인코딩을 진행하고, 배포 서버의 환경변수만 바꿔주면 되었기 때문이다.

마. WebClient

위 과정을 통해서 응답 객체 구성까지는 완료가 되었다.
지금부터는 외부 API 연동을 위해서, WebClient를 사용할 수 있도록 만들어야 한다.

여기에는 주로 WebClientRestTemplate 방식이 존재하는데, 조금 더 최신 기술이라고 여겨지는 WebClient를 사용하기로 결정했다.

1) build.gradle

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.2.4'
    id 'io.spring.dependency-management' version '1.1.4'
}

group = 'kusitms'
version = '0.0.1-SNAPSHOT'

java {
    sourceCompatibility = '17'
}

repositories {
    mavenCentral()
}

dependencies {
    // 웹
    implementation 'org.springframework.boot:spring-boot-starter-web'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    // JPA
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    runtimeOnly 'com.mysql:mysql-connector-j'
    // Redis
    implementation 'org.springframework.boot:spring-boot-starter-data-redis'
    // OAuth 2.0
    implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
    // Jwt
    implementation 'io.jsonwebtoken:jjwt-api:0.12.2'
    implementation 'io.jsonwebtoken:jjwt-impl:0.12.2'
    implementation 'io.jsonwebtoken:jjwt-jackson:0.12.2'
    // 시큐리티
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
    testImplementation 'org.springframework.security:spring-security-test'
    // 롬복
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    // h2
    runtimeOnly 'com.h2database:h2'
    // WebClient
    implementation 'org.springframework.boot:spring-boot-starter-webflux'
    // S3
    implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'
}

tasks.named('test') {
    useJUnitPlatform()
}

WebClient를 사용하기 위해서는 위와 같이 의존성 추가를 해주어야 한다!

2) WebClientConfig

package kusitms.jangkku.global.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.reactive.function.client.WebClient;

@Configuration
public class WebClientConfig {

    @Bean
    public WebClient webClient(WebClient.Builder builder) {
        return builder.build();
    }
}

또한 config 클래스도 만들어주도록 하자.

바. 클로바 스튜디오 API 통신

지금까지 앱을 생성하여 키값들을 저장하고, 가이드라인을 확인했으며, 요청 객체와 WebClient 설정까지 마쳤으므로, 이제는 클로바 스튜디오 API 통신을 할 준비가 되었다.

1) ClovaService

package kusitms.jangkku.domain.clova.application;

import org.springframework.stereotype.Service;

@Service
public interface ClovaService {
    String createDesignPersona(String message);
    String createDiscoverPersonaReaction(String message);
    String createDiscoverPersonaSummary(String message);
    String createDiscoverPersonaKeywords(String message);
}

클로바 서비스 인터페이스이다.

2) ClovaServiceImpl

package kusitms.jangkku.domain.clova.application;

import jakarta.transaction.Transactional;
import kusitms.jangkku.domain.clova.dto.ClovaDto;
import kusitms.jangkku.domain.clova.dto.Message;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;

@Service
@Transactional
@RequiredArgsConstructor
public class ClovaServiceImpl implements ClovaService {

    @Value("${clova.api.url}")
    public String apiUrl;
    @Value("${clova.api.api-key}")
    private String apiKey;
    @Value("${clova.api.api-gateway-key}")
    private String apiGatewayKey;
    private final WebClient webClient;

    // 설계하기 페르소나를 CLOVA로 생성하는 메서드
    @Override
    public String createDesignPersona(String message) {
        ClovaDto.ChatBotRequestDto request = ClovaDto.ChatBotRequestDto.DesignPersonaRequestOf();
        request.getMessages().add(Message.creatUserOf(message));

        return requestWebClient(request);
    }

    // 돌아보기 페르소나 공감을 생성하는 메서드
    @Override
    public String createDiscoverPersonaReaction(String message) {
        ClovaDto.ChatBotRequestDto request = ClovaDto.ChatBotRequestDto.DiscoverPersonaReactionRequestOf();
        request.getMessages().add(Message.creatUserOf(message));

        return requestWebClient(request);
    }

    // 돌아보기 페르소나 요약을 생성하는 메서드
    @Override
    public String createDiscoverPersonaSummary(String message) {
        ClovaDto.ChatBotRequestDto request = ClovaDto.ChatBotRequestDto.DiscoverPersonaSummaryRequestOf();
        request.getMessages().add(Message.creatUserOf(message));

        return requestWebClient(request);
    }

    // 돌아보기 페르소나 키워드를 생성하는 메서드
    @Override
    public String createDiscoverPersonaKeywords(String message) {
        ClovaDto.ChatBotRequestDto request = ClovaDto.ChatBotRequestDto.DiscoverPersonaKeywordRequestOf();
        request.getMessages().add(Message.creatUserOf(message));

        return requestWebClient(request);
    }

    // CLOVA와 통신하여 답변을 가져오는 메서드
    public String requestWebClient(ClovaDto.ChatBotRequestDto request) {
        ClovaDto.ChatBotResponse message = webClient.post()
                .uri(apiUrl)
                .header("X-NCP-CLOVASTUDIO-API-KEY", apiKey)
                .header("X-NCP-APIGW-API-KEY", apiGatewayKey)
                .header("Content-Type", "application/json")
                .body(Mono.just(request), request.getClass())
                .retrieve()
                .bodyToMono(ClovaDto.ChatBotResponse.class)
                .block();

        return message.getResult().getMessage().getContent();
    }
}

인터페이스에 대한 구현체 전체 코드이다. 위부터 차근차근 살펴보도록 하자.

	@Value("${clova.api.url}")
    public String apiUrl;
    @Value("${clova.api.api-key}")
    private String apiKey;
    @Value("${clova.api.api-gateway-key}")
    private String apiGatewayKey;
    private final WebClient webClient;

아까 저장한 키 값들을 환경변수 처리한 후 주입해준다. 또한 WebClient도 사용해야 하니 만들어 준다.

	// CLOVA와 통신하여 답변을 가져오는 메서드
    public String requestWebClient(ClovaDto.ChatBotRequestDto request) {
        ClovaDto.ChatBotResponse message = webClient.post()
                .uri(apiUrl)
                .header("X-NCP-CLOVASTUDIO-API-KEY", apiKey)
                .header("X-NCP-APIGW-API-KEY", apiGatewayKey)
                .header("Content-Type", "application/json")
                .body(Mono.just(request), request.getClass())
                .retrieve()
                .bodyToMono(ClovaDto.ChatBotResponse.class)
                .block();

        return message.getResult().getMessage().getContent();
    }

위 코드가 실질적으로 클로바 스튜디오 API 통신을 진행하는 메서드이다.

요청 헤더에 대한 가이드라인을 보면 API 키, 게이트웨이 키, 컨텐츠 타입이 필수 헤더 값으로 지정되어 있다.
그러므로 URI, 요청 바디와 함께 필수적으로 넣어주어야 요청이 제대로 들어간다.

	// 돌아보기 페르소나 공감을 생성하는 메서드
    @Override
    public String createDiscoverPersonaReaction(String message) {
        ClovaDto.ChatBotRequestDto request = ClovaDto.ChatBotRequestDto.DiscoverPersonaReactionRequestOf();
        request.getMessages().add(Message.creatUserOf(message));

        return requestWebClient(request);
    }

그리고 최종적으로 구현한 메서드에서 지금까지 만들어 둔 메서드들을 호출하여 원하는 응답을 받아오게 된다.

request는 응답 바디이며, DiscoverPersonaReactionRequestOf와 같은 of 메서드를 사용함으로써 메시지 리스트에는 이미 프롬프팅 내용 (role : system)이 들어가 있게 된다.
해당 메시지 리스트에 유저의 답변 (role : user)만 추가해서 보냄으로써, 원하는 답변 (role : assistant)을 도출해낼 수가 있다.

사. 백엔드 API 구현

그럼 이제 마지막으로 지금까지 만들어 낸 클로바 서비스를 사용한 API를 구현하여, 프론트엔드 측에서 상황에 맞게 호출할 수 있도록 해보자.

질문부터 결과 키워드 도출까지 차례대로 설명해보도록 하겠다.

1) 질문 요청 API

	// 돌아보기 페르소나 질문을 응답 받는 API
    @GetMapping("/discover/question")
    public ResponseEntity<ApiResponse<DiscoverPersonaDto.QuestionResponse>> getNewQuestion(
            @RequestHeader("Authorization") String authorizationHeader,
            @RequestParam("category") String category) {

        DiscoverPersonaDto.QuestionResponse questionResponse = discoverPersonaService.getNewQuestion(authorizationHeader, category);

        return ApiResponse.onSuccess(PersonaSuccessStatus.CREATED_NEW_QUESTION, questionResponse);
    }

우선 어떤 카테고리의 질문이 필요한지만 파라미터로 전달받는다.

 	// 질문을 새롭게 생성하며 채팅을 시작하는 메서드
    @Override
    public DiscoverPersonaDto.QuestionResponse getNewQuestion(String authorizationHeader, String category) {
        User user = jwtUtil.getUserFromHeader(authorizationHeader);

        DiscoverPersona discoverPersona;
        if (discoverPersonaRepository.existsByUserAndCategory(user, category)) {
            discoverPersona = getDiscoverPersona(user, category);
            if (discoverPersona.getIsComplete()) {
                throw new PersonaException(PersonaErrorResult.IS_ALREADY_COMPLETED);
            }
        } else {
            discoverPersona = DiscoverPersona.builder()
                    .user(user)
                    .category(category)
                    .build();
            discoverPersonaRepository.save(discoverPersona);
        }

        List<Integer> questionNumbers = discoverPersonaChattingRepository.findQuestionNumbersByDiscoverPersona(discoverPersona);
        int newQuestionNumber = questionNumbers.size() + 1;
        if (newQuestionNumber == 3) {
            discoverPersona.updateComplete(true); // 완료 처리
            discoverPersonaRepository.save(discoverPersona);
        }
        String newQuestionContent = getConversation(category, newQuestionNumber);

        DiscoverPersonaChatting newDiscoverPersonaChatting = DiscoverPersonaChatting.builder()
                .discoverPersona(discoverPersona)
                .questionNumber(newQuestionNumber)
                .question(getQuestion(category, newQuestionNumber))
                .build();
        discoverPersonaChattingRepository.save(newDiscoverPersonaChatting);

        return DiscoverPersonaDto.QuestionResponse.of(newDiscoverPersonaChatting.getId(), newQuestionContent, discoverPersona.getIsComplete());
    }

자세한 동작과정은 복잡하니 생략하도록 하겠다.

중요한 점은
1) 준비한 질문이 완료되면 완료처리되어 더 이상 질문 요청할 수 없도록 한다.
2) 각 질문마다의 답변 & 공감 & 요약이 매칭되어야 하기에 chattingId를 반환해주어야 한다.
정도가 있을 것 같다!

🖥️ 테스트 화면

위처럼 API 호출을 하며 프론트엔드 측은 새로운 질문을 응답 받을 수 있다.
현재는 건강 카테고리의 마지막 질문을 응답 받았다.

2) 공감 & 요약 문구 요청 API

	// 돌아보기 페르소나 공감 & 요약을 응답 받는 API
    @PostMapping("/discover/answer")
    public ResponseEntity<ApiResponse<DiscoverPersonaDto.AnswerResponse>> getReactionAndSummary(
            @RequestHeader("Authorization") String authorizationHeader,
            @RequestBody DiscoverPersonaDto.AnswerRequest answerRequest) {

        DiscoverPersonaDto.AnswerResponse answerResponse = discoverPersonaService.getReactionAndSummary(authorizationHeader, answerRequest);

        return ApiResponse.onSuccess(PersonaSuccessStatus.CREATED_REACTION_AND_SUMMARY, answerResponse);
    }

답변에 대한 공감과 요약을 함께 응답 받을 수 있는 API이다.

	// 공감과 요약을 생성해 응답하는 메서드
    @Override
    public DiscoverPersonaDto.AnswerResponse getReactionAndSummary(String authorizationHeader, DiscoverPersonaDto.AnswerRequest answerRequest) {
        User user = jwtUtil.getUserFromHeader(authorizationHeader);

        DiscoverPersonaChatting discoverPersonaChatting = discoverPersonaChattingRepository.findById(answerRequest.getChattingId())
                .orElseThrow(() -> new PersonaException(PersonaErrorResult.NOT_FOUND_CHATTING));

        DiscoverPersona discoverPersona = discoverPersonaChatting.getDiscoverPersona();

        String reaction = clovaService.createDiscoverPersonaReaction(answerRequest.getAnswer());
        // 마지막 대화인 경우 마무리 멘트 추가
        if (discoverPersona.getIsComplete()) {
            String category = discoverPersona.getCategory();
            String finalComment = getConversation(category, 0);
            reaction += finalComment;
        }
        String summary = clovaService.createDiscoverPersonaSummary(answerRequest.getAnswer());

        discoverPersonaChatting.updateAnswer(answerRequest.getAnswer());
        discoverPersonaChatting.updateReaction(reaction);
        discoverPersonaChatting.updateSummary(summary);
        discoverPersonaChattingRepository.save(discoverPersonaChatting);

        // 대화가 완료된 경우 키워드 생성
        if (discoverPersona.getIsComplete()) {
            createPersonaKeywords(discoverPersona);
        }

        return DiscoverPersonaDto.AnswerResponse.of(discoverPersonaChatting.getQuestion(), reaction, summary);
    }

우선은 채팅 아이디로 채팅 객체를 불러온다.

String reaction = clovaService.createDiscoverPersonaReaction(answerRequest.getAnswer());

String summary = clovaService.createDiscoverPersonaSummary(answerRequest.getAnswer());

여기서 클로바 서비스의 메서드를 호출하여, 각각 공감과 요약을 받아온다.
지금까지의 과정을 통해서 클로바 스튜디오 API 호출을 위한 준비를 미리 다 해두었기 때문에, 이처럼 서비스 단에서는 간단하게 호출로 원하는 문구를 얻어올 수가 있다!

또한 마지막 질문에 대한 응답인 경우에는, 결과를 확인할 수 있도록 6개의 키워드 또한 API 호출로 생성하여 DB에 저장해 둔다.

🖥️ 테스트 화면

위 질문의 chattingIdanswer를 바디에 넣어 요청하니, 위와 같이 공감과 요약 문구를 받을 수 있었다.
마지막 질문이었기에 마무리 멘트 또한 추가가 된 상태이며, DB에도 아래와 같이 키워드 6개가 저장되었다.

다만 여기서 조금 아쉬웠던 점은, 키워드 6개를 형용사 위주로 뽑아내고 싶었는데 단순히 요약해주는 기능을 프롬프팅으로 고도화한 것이었기에 원하는대로 구현하지는 못했었다 🥲
시간이 더 있었다면 조금 더 좋은 방식을 생각해보았을 것 같은데 아쉬운 부분이다!


3. 결과 ✨


4. 느낀 점 💡

이번 프로젝트를 통해 NCP를 많이 사용해보면서 느낀 점이 몇 가지 있다.

가. 생각보다 높은 비용

크레딧을 통해서 무료로 활용했기에 이러한 부담이 있지는 않았으나, 타 서비스에 비해 생각보다 비용이 많이 드는 편이라는 걸 알 수 있었다..! 비용 책정이 어떻게 되는 것인지는 모르겠으나, 아무래도 소비자의 입장에서는 조금 더 부담이 될 수도 있을 듯 하다.

나. 레퍼런스 부족

이는 위에서도 말했지만 관련한 레퍼런스가 아직 많이 없는 상태이다. 하지만 이는 NCP의 서비스가 점점 더 발전하고, 사용자가 늘어난다면 자연스레 해결될 수 있는 부분인 것 같다.

나도 이 부분에 조금이나마 기여를 했다면 뿌듯할 것 같다 😌

다. 한글 문서

물론 좋은 점도 많았다!
모든 문서가 한글로 적혀 있고, 설명과 가이드, Q&A와 문의까지 한글로 진행할 수 있기 때문에 레퍼런스가 부족한 상태에서도 구현을 할 수 있었던 것 같다.
이러한 점은 정말 큰 메리트라고 생각을 한다 👍🏻

라. 높은 성능

속도 면에서 빠른지는 잘은 모르겠다! 호출을 하는 경우 늦으면 5초 정도가 걸릴 때도 있었기 때문이다.
물론 이는 내가 최적화를 잘 못했을 수도 있다 😅

하지만 속도보다 훨씬 중요한 것이 응답의 퀄리티라고 생각하는데, 조금 놀라울 정도로 원하는 문구를 잘 응답해줘서 신기했다. Chat GPT 와도 비교를 하며 더 높은 정확도의 서비스로 사용을 하려고 했었는데, 클로바 스튜디오 API가 개인적으로는 조금 더 원하는 바를 맞춰줄 수 있었던 것 같다.

아무래도 Chat GPT보다 한글에 익숙하기 때문이 아닐까..? 라는 생각도 들었다!


지금까지 Spring Boot와 네이버 클로바 스튜디오를 활용하여 챗봇 기능을 구현한 내용에 대해 정리해보았다.

약 2~3일 내에 구현을 마쳐서 많이 미숙했고, 중복되는 코드도 많은 것 같아 아쉬운 마음 또한 크다 🥹

하지만 생성형 AI를 처음으로 연동해서 서비스를 만들었음에 의미를 두고, 앞으로 이번 과정을 통해 얻은 지식을 잘 활용해서 더 나은 개발자가 되고 싶다! 🧑🏻‍💻

마지막으로 개발자들에게 많은 지원을 해 주는 NCP Green Developers 측에 감사를 표한다 🙇🏻‍♂️

profile
상호작용하는 백엔드 개발자

2개의 댓글

comment-user-thumbnail
2024년 8월 5일

과정을 떠먹여주니까 코드는 몰라도 대충 어떻게 된다는 게 이해가 되네;; 나도 이런식으로 정리 해야겠다 많이 배우고 갑니다~~

1개의 답글