Spring

[회고록] SpringBoot에 AI 서비스 도입기

우러억 2025. 1. 19. 14:07

나는 다양한 프로젝트에 AI 서비스를 도입했다.

 

졸업작품에서는 교수님의 추천과 매칭 서비스의 고도화, 해커톤에서는 애초에 주제가 AI 해커톤이였고, 후원사가 NCP라서 Clova AI를 활용해야했고, 다른 프로젝트 역시 Clova AI를 도입했다.

 

좀 지난 일이긴 하지만 AI를 어떤 방식으로 적용했는지 기록하고자 한다.

 

 

1. ChatGPT(OpenAI)


첫째로 아주 잘 알고있는 OpenAI의 ChatGPT이다.

 

https://platform.openai.com/docs/overview

 

위의 링크에 들어가보면 아주 친절하게 API문서 부터 어떤 모델이 있고 Playground에서 예시로 모델을 사용해볼 수 있다.

 

나는 해당 API를 졸업작품때 도입했기 때문에 그때는 없던 신기한 기능들도 있다..(TTS, Realtime 같은..)

 

백엔드 개발자였던 내가 AI 서비스를 사용하는 것이 아닌 내 프로젝트에 처음으로 도입하게된 첫 모델이다.

 

아마 모델은 3-5-turbo로 기억한다.

 

사용하려면 일단 .yml 파일에 API의 정보를 기입해야 한다.

openai:
  api:
    key: ${OPENAI_KEY}
    url: https://api.openai.com/v1/chat/completions
  model: gpt-3.5-turbo

 

제품을 등록하며 받은 KEY값과 API를 호출할 url, 그리고 어떤 모델을 사용할지에 대한 모델의 정보이다.

 

그 다음 SpringBoot 기준으로 API 호출시 필요한 데이터를 전송하는 DTO를 작성했다.

 

아래는 서버에서 요청을 보낼 시 전송하는 DTO이다.

public class GPTRequest {
    private String model;
    private List<Message> messages;
    private int temperature;
    private int maxTokens;
    private int topP;
    private int frequencyPenalty;
    private int presencePenalty;

    public GPTRequest(String model,
                      String prompt,
                      int temperature,
                      int maxTokens,
                      int topP,
                      int frequencyPenalty,
                      int presencePenalty) {
        this.model = model;
        this.messages = new ArrayList<>();
        this.messages.add(new Message("user", prompt));
        this.temperature = temperature;
        this.maxTokens = maxTokens;
        this.topP = topP;
        this.frequencyPenalty = frequencyPenalty;
        this.presencePenalty = presencePenalty;
    }
}

 

그리고 위 DTO의 내부에 있는 messages의 필드이다.

 

public class Message {
    private String role;
    private String content;
}

 

그 다음 Service 클래스에서 사용하였다.

   public String gptConvert(String questionContent, String mentorInfo, Interests interests) {
        if (interests==Interests.WEBAPP) {
            GPTRequest gptRequest = new GPTRequest(model, questionContent+FIRST+mentorInfo+FINAL,
                    1, 4000, 1, 2, 2);
            GPTResponse response = restTemplate.postForObject(apiURL, gptRequest, GPTResponse.class);
            return response.getChoices().get(0).getMessage().getContent();

        }else {
            GPTRequest gptRequest = new GPTRequest(model, questionContent + FIRST + mentorInfo + FINAL,
                    1, 4000, 1, 2, 2);
            GPTResponse response = restTemplate.postForObject(apiURL, gptRequest, GPTResponse.class);
            return response.getChoices().get(0).getMessage().getContent();

        }
    }

 

간단하게 요약하면 질문의 내용, 멘토의 정보들, 관심분야를 해당 메서드에서 파라미터로 받은 후 위의 DTO를 설계한다.

그 후 RestTemplate을 이용해 외부 API로 호출을 보내는 순서로 진행된다.

 

그 후 응답을 받아와서 원하는 데이터를 뽑아내어 반환한다.

 

여기서 조금 애먹었던 부분이 있었는데 

 

나의 요구사항은 멘토Id(예: 1,2,3) 일치률(51.5, 65.3) 로 GPT에게 응답을 받아와서 클라이언트에게 넘겨줘야 했다.. 추가로 멘토의 일치률은 무조건 50%를 넘어야했다.

 

하지만 어쩔때는 잘 넘겨주다 갑자기 이상한 응답을 던져줄때가 많았다. 

 

이를 해결하기 위해서 프롬프트를 여러번 수정했지만 아무리 잘 수정이 되었어도 무조건 한번은 원하는 패턴과 다르게 응답을 했다..

 

그래서 나는 정규표현식을 이용해서 원하는 응답이 나올때까지 호출을 하기로 했다..

 

아래는 최종적으로 수정된 코드이다.

public List<AiMatchingMemberResponse> getMentorListByAi(Long questionId) {
        Map<Long, Double> mentorMatchMap = new HashMap<>();
        List<AiMatchingMemberResponse> memberResponses = new ArrayList<>();

        MemberListResponse mentorList = getMentorList(questionId);

        Question question = questionRepository.findById(questionId)
                .orElseThrow(() -> new RuntimeException("not found question"));

        String questionContent = question.getContent();

        Interests interests = question.getInterests();

        String mentorInfo = convertToJsonString(mentorList);

        String message = gptConvert(questionContent, mentorInfo, interests);
        boolean isMatch = patternMatch(message);

        log.info("content:{}",message);
        log.info("isMatch:{}",isMatch);

        boolean hasLowMatch = true;

        // 정규표현식 검증 로직 + 멘토 일치률 검증
        while (!isMatch || hasLowMatch) {
            if (!isMatch) {
                message = gptConvert(questionContent, mentorInfo, interests);
                isMatch = patternMatch(message);
                log.info("Change message :{}", message);
                log.info("Change isMatch:{}", isMatch);
            } else {
                Pattern compile = Pattern.compile("(\\d+)\\s(\\d+\\.\\d+)");
                Matcher matcher = compile.matcher(message);
                hasLowMatch = false;
                mentorMatchMap.clear();

                while (matcher.find()) {
                    Long mentorId = Long.parseLong(matcher.group(1));
                    double matchPercentage = Double.parseDouble(matcher.group(2));
                    if (matchPercentage < 50) {
                        hasLowMatch = true;
                        break;
                    }
                    mentorMatchMap.put(mentorId, matchPercentage);
                }
                if (hasLowMatch) {
                    message = gptConvert(questionContent, mentorInfo, interests);
                    isMatch = patternMatch(message);
                    log.info("Retrying due to low match, message :{}", message);
                    log.info("isMatch:{}", isMatch);
                }
            }
        }
        for (Map.Entry<Long, Double> entry : mentorMatchMap.entrySet()) {
            log.info("memberId: {}", entry.getKey());
            log.info("memberPercent: {}", entry.getValue());

            Long mentorId = entry.getKey();
            Double mentorPercent = entry.getValue();
            Member member = memberRepository.findById(mentorId)
                    .orElseThrow(() -> new RuntimeException("not found member"));
            List<Review> reviewList = reviewRepository.findAllByMentor(member.getNickname());
            AiMatchingMemberResponse aiMatchingMemberResponse = matchingMemberMapper.toResponseForAi(member, mentorPercent,reviewList);
            memberResponses.add(aiMatchingMemberResponse);
        }
        return memberResponses;
    }

 

while문을 이용해서 내가 원하는 패턴이 아니면 다시 호출하고 패턴이 정확할시 50%가 넘는지 검증한다..

 

해당 방법으로 해결하긴 했지만 리소스가 많이 낭비되어 보인다..

 

이렇게 챗봇을 이용해 처음으로 프로젝트에 AI를 넣어보았다.

 

 

 

2. Clova(NCP)


두번째로 NCP의 Clova AI 이다.

 

Clova AI는 SPARCS 의 AI 스타트업 해커톤때 처음으로 접해보았다.

 

https://clova.ai/

 

CLOVA

하이퍼스케일 AI로 플랫폼 경쟁력을 강화하고 비즈니스 시너지를 확장합니다.

clova.ai

https://www.ncloud.com/product/aiService/clovaStudio

 

해커톤시 NCP 크래딧을 무려 100만원을 주었기 때문에 무조건 NCP에서 작업을 했어야 했다.

 

우리 팀은 Clova AI를 사용해 고령자들을 위한 챗봇 기반 직업추천 및 자소서 작성 서비스를 구현하기로 했다.

 

이전에 GPT를 이용해 서비스를 구현한 적이 있어 할만하지 않을까? 라고 생각했다..

 

하지만 이전은 그냥 외부 API를 호출해 응답을 받아와서 전달하는 방식(비동기) 이였다면 이번에는 챗봇 형태(예: GPT에게 질문을 입력할시 스트리밍 형식으로 텍스트를 출력해주는)로 개발을 진행해야 했기 때문에 어떻게 구현을 하면 좋을지 고민을 많이 했었다.

 

NCP의 공식문서에 따르면 Clova에게서 받아온 응답값을 서버에서 클라이언트로 전송할 시TEXT_EVENT_STREAM_VALUE 의 형식으로 전달해줘야 한다고 나와있었다.

 

그래서 난 Spring WebFlux를 이용해 Clova에게 요청을 보낸 후 받아온 응답값을 Flux 형태로 전달하였다.

 

public Flux<String> firstSetting(String token) throws Exception {
        String email = tokenProvider.getEmailFromToken(token);
        Member member = memberRepository.findByEmail(email);

        String redisAiKey = "AI" + email;
        String redisUserKey = "USER" + email;
        String redisRecommendJob = "AI_RECOMMEND_JOB" + email;

        if (Boolean.TRUE.equals(redisTemplate.hasKey(redisAiKey))) {
            redisTemplate.delete(redisAiKey);
            log.info("Deleted existing redisAiKey key: {}", redisAiKey);
        }
        if (Boolean.TRUE.equals(redisTemplate.hasKey(redisUserKey))) {
            redisTemplate.delete(redisUserKey);
            log.info("Deleted existing redisUserKey key: {}", redisUserKey);
        }
        if (Boolean.TRUE.equals(redisTemplate.hasKey(redisRecommendJob))) {
            redisTemplate.delete(redisRecommendJob);
            log.info("Deleted existing redisRecommendJob key: {}", redisRecommendJob);
        }

        ClovaRequestList clovaRequestList = clovaMapper.firstQuestion(member);

        String body = objectMapper.writeValueAsString(clovaRequestList);

        return clovaWebClient.post()
                .uri(apiUrl)
                .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
                .header(HttpHeaders.ACCEPT, MediaType.TEXT_EVENT_STREAM_VALUE)
                .header("X-NCP-CLOVASTUDIO-API-KEY", apiKey)
                .header("X-NCP-APIGW-API-KEY", gatewayKey)
                .bodyValue(body)
                .retrieve()
                .bodyToFlux(String.class)
                .delayElements(Duration.ofMillis(200))
                .doOnNext(response -> {
                    log.info("RESPONSE: {}", response);
                    if (response.contains("seed")) {
                        try {
                            JsonNode jsonNode = objectMapper.readTree(response);
                            JsonNode messageNode = jsonNode.get("message");
                            JsonNode contentNode = messageNode.get("content");
                            String content = contentNode.asText();
                            log.info("최종컨텐츠:{}", content);
                            redisTemplate.opsForList().rightPush("AI" + email, content);
                            log.info("RedisContentAI:{}", redisTemplate.opsForList().range("AI" + email, 0, -1));
                        } catch (JsonProcessingException e) {
                            throw new RuntimeException(e);
                        }
                    }
                })
                .doFinally(signalType -> {
                    if (signalType == SignalType.ON_COMPLETE) {
                        log.info("Streaming completed.");
                    }
                });
    }

 

해당 로직으로 요구사항에 맞게 챗봇을 구현할 수 있었다.

 

사실 WebFlux에 대해서 엄청 깊게 공부를 한것이 아닌 잠깐 쓴 용도? 였다.. 나중에 다시 찾아보니 러닝커브가 굉장히 높은것 같았다...

 

또한 프롬프팅 작업도 만만치 않았다.. 이전에 했던 프로젝트에는 그렇게 양질의 프롬프팅 작업이 필요가 없었지만 해당 프로젝트에서는 AI가 담당해야 하는 일이 많기에 계속해서 프롬프트를 고치고 수정하는 작업이 이루어졌다. 해당 작업은 기획자와 내가 맡았다.

 

수정과 삭제를 거친 끝에 마침내 원하는대로 응답을 하는 AI를 구현할 수 있었다.

 

추가로 TTS 기술도 사용되었는데 해당 기술은 프론트엔드쪽에서 NCP의 TTS와 연동시켜서 구현하였다.

 

 

두번째로 프론트엔드 지인과 함께한 사이드 프로젝트에서 사용했다. 

 

해커톤때 진행했던 프로젝트에서는 ClovaX의 챗봇만을 사용했다면 해당 프로젝트에는 STT(텍스트 추출), 요약(텍스트 요약) 기능을 사용하였다. 

 

요구사항으로

사용자가 장문의 음성녹음을 한다(40분) → 해당 음성녹음 파일을 NCP의 Object Storage에 저장한다(서버에는 해당 파일에 대한 필수 정보만 저장)  → 서버는 음성녹음 파일의 Key값을 이용해서 텍스트를 추출한 후 RedisHash를 이용해 관리한다 마지막으로 추출한 텍스트를 요약한다

 

이런식으로 진행되었다.

 

여기선 위 프로젝트처럼 이벤트 스트림 방식이 필요하지 않기 때문에 WebFlux를 사용하지 않았다.

 

 

장문녹음 텍스트 추출 소스코드이다.

    public ClovaSpeechResponseList clovaSpeech(Long fileId, Long memberId) throws JsonProcessingException {
        Member trainer = memberService.getMember();
        Long trainerId = trainer.getId();

        Member member = memberRepository.findById(memberId).orElseThrow(NotFoundMemberException::new);

        memberListRepository.findByTrainerAndMemberEmail(trainer, member.getEmail())
                .orElseThrow(NotFoundChartMemberException::new);

        VoiceFile voiceFile = voiceFileRepository.findById(fileId).orElseThrow(NotFoundFileException::new);
        String domainPath = "/";
        String fullPath = voiceFile.getUploadFileUrl();

        String url = invokeUrl + "/recognizer/object-storage";

        String dataKey = extractDataKey(fullPath, domainPath);

        ClovaSpeechRequest request = ClovaSpeechRequest.builder()
                .dataKey(dataKey)
                .language("ko-KR")
                .completion("sync")
                .build();

        // 헤더
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_JSON);
        headers.set("X-CLOVASPEECH-API-KEY", clovaSecret);

        HttpEntity<ClovaSpeechRequest> httpEntity = new HttpEntity<>(request, headers);

        ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.POST, httpEntity, String.class);
        String voiceResponse = response.getBody();

        List<ClovaSpeechResponse> responses = parseClovaSpeechResponse(voiceResponse, trainerId, memberId);

        UUID uuid = UUID.randomUUID();

        ClovaVoiceText voiceText = ClovaVoiceText.builder()
                .id(uuid)
                .voiceList(responses)
                .memberId(memberId)
                .trainerId(trainerId)
                .build();

        clovaVoiceTextRepository.save(voiceText);

        voiceFile.enableTransformation();
        voiceFile.confirmVoiceFileId(String.valueOf(uuid));

        return ClovaSpeechResponseList.builder()
                .memberName(member.getName())
                .list(responses)
                .createAt(voiceFile.getCreatedAt())
                .voiceListId(uuid)
                .build();
    }

 

공식문서에 나와있는데로 필요한 헤더, 바디 부분을 서버에서 만들어 요청을 보낸 후 원하는 값을 DTO에 넣어 클라이언트에게 전송한다.

 

 

추출한 텍스트를 요약해주는 소스코드이다.

 public SummarizationResponse summationTextFile(UUID textId) throws JsonProcessingException {
        ClovaVoiceText clovaVoiceText = clovaVoiceTextRepository.findById(textId)
                .orElseThrow(NotFoundVoiceDataException::new);

        Member trainer = memberService.getMember();

        Long memberId = clovaVoiceText.getMemberId();
        Member member = memberRepository.findById(memberId).orElseThrow(NotFoundMemberException::new);

        // 추출한 텍스트 요약본
        List<ClovaSpeechResponse> voiceList = clovaVoiceText.getVoiceList();

        String[] textArray = voiceList.stream()
                .map(response -> response.getSpeaker() + ": " + response.getText())
                .toArray(String[]::new);

        HashMap<String, String[]> bodyMap = new HashMap<>();
        bodyMap.put("texts", textArray);
        String texts = objectMapper.writeValueAsString(bodyMap);
        HttpHeaders headers = buildHeaders();
        HttpEntity<String> entity = new HttpEntity<>(texts, headers);

        ResponseEntity<String> response = restTemplate.exchange(apiUrl, HttpMethod.POST, entity, String.class);

        String responseBody = response.getBody();

        JsonNode rootNode = objectMapper.readTree(responseBody);
        String textData = rootNode
                .path("result")
                .path("text")
                .asText();

        Summarization summarization = Summarization.builder()
                .member(member)
                .voiceTextId(clovaVoiceText.getId())
                .trainer(trainer)
                .summarizationTexts(textData)
                .build();

        summarizationRepository.save(summarization);

        return SummarizationResponse.builder()
                .texts(summarization.getSummarizationTexts())
                .summarizationId(summarization.getId())
                .trainerId(trainer.getId())
                .createAt(summarization.getCreatedAt())
                .memberId(memberId)
                .voiceListId(clovaVoiceText.getId())
                .build();
    }

 

요약하자면 추출한 텍스트를 보내기 좋은 형태로 파싱한 후 위와 똑같이 헤더와, 바디에 넣어서 호출을 보낸다. 그 후 원하는 데이터만 추출해서 DTO에 넣어 만들어준 후 클라이언트에게 전송한다.

 

이렇게 여러가지 서비스를 사용하면서 처음 사용했을때는 코드 가독성도 떨어지고 이해하는데도 많은 시간이 걸렸지만 나중에는 숙달? 이 되다보니 엄청 어렵지 않게 사용할 수 있게 된 것 같다.

 

사실 OpenAI와 다르게 NCP는 한국기업이다 보니 공식문서도 아주 보기좋게 되어있었고, 구현하기 어렵지 않았다. Clova를 이용하면서 구글링은 거의 안한 것 같다.(사실 레퍼런스도 그렇게 많지 않았다..)