프로젝트/프로젝트 정리

[개발 프로젝트] JUSTTALK

잔디🌿 2024. 11. 28. 00:10

프로젝트를 하게 된 계기

내가 이제까지 구현해 본 적이 없는 것이 무엇인가에 대한 생각을 해 보았을 때 통신기능을 해보지 않았다는 것을 알게 되었다.

마침 생성형 AI 활용대회가 열린다는 것을 알고 이 공모전을 위한 프로젝트를 해봐야겠다고 생각했다. 내가 해보고싶은 기능을 구현하는 프로젝트이기에 나 혼자 프로젝트를 진행했다.

결과를 먼저 말하자면 140개 팀 중 15팀 내에 들어서 본선까지 진출했지만! 상은 받지 못했다ㅠㅠ

 

프로젝트 소개

 사회성이 부족해서 어려움을 겪는 사람들이 많다. 이런 분들의 특징은 본인은 이러한 특성을 극복하고자 하고자 하는 의지가 크지만, 대화 경험을 쌓을 기회가 부족하다. 실제 사람과의 대화에서는 다른 사람들이 자신을 어떻게 생각할지에 대해 눈치를 보게 되어 제대로 된 대화를 하지 못할 가능성이 있고, 이야기를 할 사람이 부족한 경우도 있기 때문이다.

 이를 극복하게 하기 위해서 나는 챗봇 서비스를 만들었다.

챗봇은 위와 같이 4가지 캐릭터로 구성된다. 

처음 만나는 친구 컨셉의 우끼끼, 오랜 친구 아웅, 설명을 해줘야 하는 뿌뿌, 평가하지 않고 하고싶은 말을 들어주는 꼬꼬가 있다.

 

이들은 사용자와 대화하면서 대화 내용을 저장하고, 해당 대화내용에 대한 피드백을 담은 리포트를 생성한다.

리포트는 데이터베이스에 저장되어, 사용자가 얼마든지 대화내용과 함께 열람할 수 있도록 하였다.

 

ERD

erd는 다음과 같다

 

프론트엔드

이 프로젝트는 처음부터 끝까지 오직 나 혼자 한 개인프로젝트이다. 그래서 프론트엔드가 따로 없었다ㅜㅜ

어떻게 할지 고민하다가 카카오테크캠퍼스에서 배운 타임리프로 구현해보았다.

(근데 api 관련 구현은 배우지 않아서 지피티의 도움을 좀 받았다ㅎㅎ)

 

화면은 위와 같이 4개의 파일로 구성되었다.

 

이번 프로젝트를 통해서 프론트엔드가 얼마나 소중하고 대단한 존재인지 깨닫게 되었다. 이제 개인프로젝트는 안할 것 같다ㅎㅎ

 

백엔드

이제 본격적으로 백엔드 개발한 내역에 대해서 작성해보겠다.

패키지 구조는 위와 같이 구성된다.

웹소켓 통신

이번 프로젝트에서 가장 크게 시도한 부분이다. 

프론트와 백의 연결

// 모달이 열릴 때 자동으로 connect 호출
    $('#chatModal1').on('shown.bs.modal', function () {
      connect1();
    });
    $('#chatModal2').on('shown.bs.modal', function () {
      connect2();
    });
    $('#chatModal3').on('shown.bs.modal', function () {
      connect3();
    });
    $('#chatModal4').on('shown.bs.modal', function () {
      connect4();
    });

위 코드는 타임리프의 코드이다. 사용자가 캐릭터를 선택해서 버튼을 누르면 

위와 같은 모달이 나온다. 위 함수는 모달이 나올 때 자동으로 호출되는 부분이다. 여기를 보면 connection 함수가 호출되는 것을 볼 수 있다.

 function connect1() {
    if (!stompClient || !stompClient.connected) {
      var socket = new SockJS('/chat-websocket');
      stompClient = Stomp.over(socket);
      stompClient.connect({}, function(frame) {
        console.log('Connected: ' + frame);
        stompClient.subscribe('/topic/messages', function(response) {
          showMessage1(JSON.parse(response.body));
        });
      });

      // 사용자 이름을 localStorage에 저장
      var username = $('#username').val();
      localStorage.setItem('username', username); // 입력된 사용자 이름 저장
    } else {
      console.log('Already connected');
    }
  }

우선 아직 연결이 되어있지 않다면, new SockJS를 통해서 웹 소켓 연결을 한다. '/chat-websocket'는 서버의 웹소켓 엔드포인트이다. 

또한 /topic/messages를 구독한다.

 

package com.aichat.demo.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

  @Override
  public void configureMessageBroker(MessageBrokerRegistry config) {
    config.enableSimpleBroker("/topic");
    config.setApplicationDestinationPrefixes("/app");
  }

  @Override
  public void registerStompEndpoints(StompEndpointRegistry registry) {
    registry.addEndpoint("/chat-websocket").withSockJS();
  }
}

 

위는 백엔드의 webSocketConfig이다. 여기서는 소켓통신을 담당하는 메서드가 있다.

webSocketMessageBrokerConfigurer 인터페이스를 구현한다.

 

registerStompEndpoints는 stomp endpoint를 등록한다. /chat-websocket 엔드포인트를 통해서 처리된다.

 

configureMessageBroker은 메세지 브로커를 활성화한다.

메세지 브로커는 서버와 클라이언트 간의 메세지를 라우팅하는 역할을 한다.

 

  • enableSimpleBroker("/topic")
    • 간단한 브로커를 활성화한다.
    • 클라이언트가 구독할 수 있는 메시지 전송 경로를 설정한다.
    • 예를 들어, 클라이언트가 /topic/messages를 구독하면, 이 브로커를 통해 해당 경로로 전송된 메시지를 받을 수 있다.
  • setApplicationDestinationPrefixes("/app")
    • 클라이언트가 서버로 메시지를 보낼 때 사용할 경로 프리픽스를 지정한다
    • 클라이언트가 /app/~으로 메시지를 보내면, 이 메시지가 컨트롤러의 핸들러 메서드로 매핑된다.

 

즉, 클라이언트가 서버에게 메세지를 보낼 때에는 /app/~ 엔드포인트를 사용해야하고, 서버가 클라이언트에게 메세지를 보낼 때에는 /topic/~ 엔드포인트를 써야한다.

 

DTO

package com.aichat.demo.dto.response;

import lombok.Data;

public class ChatResponse {

  @Data
  public static class ChatMessageDTO {
    private String content;
    private String sender;

    public ChatMessageDTO(String aiResponse) {
      this.content = aiResponse;  // 응답받은 내용
      this.sender = "AI";  // 발신자를 AI 로 지정
    }
  }
}

위 dto는 서버에서 클라이언트로 ai의 응답을 전송하는 dto이다. 

@Data 어노테이션을 사용하여 getter, setter, toString, eqauls, hashcode 메서드를 자동으로 생성할 수 있도록 하였다.

 

package com.aichat.demo.dto.request;

import lombok.Data;

public class ChatRequest {

  @Data
  public static class ChatMessageDTO {

    private String content;
    private String sender;
    private String token;

  }

}

위 dto는 클라이언트에서 서버로 데이터를 전송하는 기능을 한다.

dto에 토큰이 있는 이유는 클라이언트가 로컬스토리지에 가지고 있던 토큰을 객체에 넣어 서버에게 전달하면, 서버가 해당 토큰을 검증하도록 하기 위해서이다.

 

클라이언트가 서버에게 메세지 전송

$('#sendBtn1').click(function() {
      sendMessage1();
    });

사용자가 내용을 입력하고 send 버튼을 누르면 sendMessage함수가 호출된다.

 

function sendMessage1() {
    var messageContent = $('#message1').val();
    var username = $('#username').val();
    var token = localStorage.getItem('jwtToken')
    var chatMessage = {
      content: messageContent,
      sender: username,
      token: token
    };
    stompClient.send('/app/chat.sendMessage1', {}, JSON.stringify(chatMessage));
    $('#message1').val('');
    showUserMessage1(chatMessage); // 사용자의 메시지를 바로 표시
  }

messageContent 안에 있는 내용을 chatMessage에 담아서 보낸다. 이 때 엔드포인트는 /app/chat.sendMessage1이 된다. 

이렇게 되면 

@MessageMapping("/chat.sendMessage1")
public void sendMessage1(@Payload ChatRequest.ChatMessageDTO chatMessage)
    throws JsonProcessingException {
  chatService.processMessage1(chatMessage);
}

서버의 chatController의 위 메서드가 메세지를 받는다.

chatMessage에는 메세지의 내용이 들어있다. 

 

서버가 클라이언트에게 전송

서버는 클라이언트에게 ai로 부터 받은 내용을 전송한다.

전송할 때에는 simpMesssageTemplate를 사용한다. 

import org.springframework.messaging.simp.SimpMessagingTemplate;

  private final SimpMessagingTemplate messagingTemplate;
  
   ChatResponse.ChatMessageDTO aiMessage = new ChatResponse.ChatMessageDTO(aiResponse);
    messagingTemplate.convertAndSend("/topic/messages", aiMessage);

클라이언트에게 데이터를 전송하는 부분의 코드만 가져와보았다.

messageTemplate 객체를 사용하여 convertAndSend 를 통해 메세지를 전송한다. 이 때 엔드포인트는 /topic/~이런 식으로 보낸다.

(위에서 topic으로 정의해뒀으므로!)

 

인공지능과의 통신

인공지능과의 통신은 이전과 같은 방식을 사용하였다. 모델은 gpt-4o-mini를 사용하였다.

https://ethereal-coder.tistory.com/228

 

[Spring] RestTemplate로 ChetGPT 연결하기

이번 해커톤 프로젝트를 하면서 chetGPT를 연동해야 할 일이 생겼다.위와 같이 일기를 쓰면 그에 대한 코멘트를 챗지피티를 통해 보여준다.(일기 마저도 챗 지피티에서 따왔다ㅋㅋ) 팀원분이 지

ethereal-coder.tistory.com

역시 기술 하나 배워두면 정말 유용하게 쓰는 것 같다.

이번에는 이 때와 다른 점을 중점적으로 다뤄보고자 한다.

 

In-context-learning

나는 인공지능 모델을 만질 수 없으므로, gpt 모델에 나의 프롬프트 명령어를 명령과 함께 보내는 방식을 사용하였다.

여러 번 직접 테스트해보면서 프롬프트 명령을 조절하였다.

 

 Message message2 = new Message("system","너는 정말 공감을 잘해주는 상담사 꼬꼬야. "
        + "이건 채팅서비스니까 두줄 이하로 답장해야하고, assistant에 넣어둔 내용은 이전 대화내용이니까 꼭 참고해" );

 

Message message2 = new Message("system","너는 궁금한게 많은 아기 코끼리 뿌뿌야. "
        + "상대가 인사를 하면 간단한 단어에 대한 질문을 해 예를들어 사과, 공책 등등 다양하게 자꾸 테스트하는데 똑같은거 나오면 안된다."
        + " 너는 사용자의 답변 이외의 지식이 많이 없어 그래서 너가 아는 지식을 말하면 안돼."
        + " 그리고 assistant에 넣어둔 내용은 이전 대화내용이니까 꼭 참고해서 관련 질문을 계속 해."
        + " 질문은 정답이 있는걸로, 상대에 대한 질문은 하지 마.이건 채팅서비스니까 두줄 이하로 대답해 "
        + "그리고 뿌뿌: 사용자: 이런건 쓰지 마");

 

Message message2 = new Message("system","너는 아웅이야. 너는 오랜 친구와 대화한다는 컨셉의 챗봇이야. "
        + "그러니까 친근한 말투를 많이 써줬으면 좋겠고, 요즘 너의 근황을 많이 말해줘. 챗봇이니까 2줄 이내로 대답해주고,"
        + " 그리고 assistant에 넣어둔 내용은 이전 대화내용이니까 꼭 참고해"
        + "그리고 아웅: 사용자: 이런 단어 쓰지 마");

 

Message message2 = new Message("system","너는 궁금한게 많은 아기 코끼리 뿌뿌야. "
        + "상대가 인사를 하면 간단한 단어에 대한 질문을 해 예를들어 사과, 공책 등등 다양하게 자꾸 테스트하는데 똑같은거 나오면 안된다."
        + " 너는 사용자의 답변 이외의 지식이 많이 없어 그래서 너가 아는 지식을 말하면 안돼."
        + " 그리고 assistant에 넣어둔 내용은 이전 대화내용이니까 꼭 참고해서 관련 질문을 계속 해."
        + " 질문은 정답이 있는걸로, 상대에 대한 질문은 하지 마.이건 채팅서비스니까 두줄 이하로 대답해 "
        + "그리고 뿌뿌: 사용자: 이런건 쓰지 마");

생각보다 조정할 것이 많았다. 챗봇만을 위한 모델이 아니다보니 그런 것 같다.

 

 

 

이전 대화내용을 저장하기

이번 챗봇을 만들 때 가장 고민했던 부분이다. restTemplate를 통해서 openai와 통신할 때에는 일회성으로 통신하기 때문에 이전 대화내용을 ai가 기억하지 못한다. 따라서 원활한 대화가 되지 않는 것을 알 수 있었다. 

이를 해결하기 위해서 조금 더 찾아 본 결과, 해결책을 찾을 수 있었다.

 

open ai에 보내는 메세지의 role은 세가지가 있다. 

이제까지의 프로젝트에서는 user만 썼었는데, 이번에는 system과 assistant를 모두 사용했다.

 

user은 사용자의 입력부분, system은 프롬프트 명령부분, assistant는 ai가 참고할 내용이다.

예를 들어, 은행 챗봇을 만들 때, user에는 사용자의 명령, system에는 user의 명령에 대한 응답을 달라는 명령어, assistant에는 은행 업무에 관련된 전반적인 지식을 넣는다고 한다.

 

나는 이 부분을 활용해보았다!

user 부분에는 클라이언트로부터 받은 user의 응답부분, system에는 각 캐릭터에 대한 프롬프트 명령, assistant에는 이제까지의 대화 내용을 넣어 openAi에 요청을 보낸다.

 

이렇게 하면 ai가 이전 대화내용을 참고하여 사용자에게 알맞은 응답을 해주는 것을 볼 수 있었다.

 

사용자 별 대화내용 저장하기, 토큰 제한 해결하기

여기서 문제가 생겼다.

  • spring은 싱글톤 방식으로 동작하기 때문에 절대 고정적인 데이터를 클래스 내에 저장하면 안된다.
  • 사용자가 특정 캐릭터와 대화하다가 다른 캐릭터와 대화할 수 있으므로 이 대화내용을 구별해야한다.
  • openAi에 넣을 수 있는 토큰(assistant에 넣을 수 있는 대화내용의 길이)가 한정적이다.

부끄럽지만 처음에는 그냥 chatService 내부에 대화 내용을 저장하려고 했다. 하지만 더 찾아본 결과, 그리고 얼마 전 김영한님의 강의를 들어본 결과 이건 정말 있을 수 없는 구현방법이라는 것을 깨달았다.

 

그래서 내가 한 방법은 member 엔티티에 각 캐릭터별 대화 내용을 저장하는 것이다.

 private String name;
  private String email;
  private String password;
  @Lob
  private String Content1 = "";
  @Lob
  private String Content2 = "";
  @Lob
  private String Content3 = "";
  @Lob
  private String Content4 = "";

이런식으로!

이 때 content의 길이는 길기 때문에 @Lob 어노테이션을 추가하는 것도 잊지 않았다.

public void processMessage1(ChatRequest.ChatMessageDTO requestDTO) throws JsonProcessingException {
    String userMessage = requestDTO.getContent();

    Member member = memberRepository.findByEmail(email).get();
    String memberContent = member.getContent1();
    String aiResponse = openAiService.getResponseFromWogigi(userMessage,memberContent);
    memberContent = memberContent + "\n사용자:" + userMessage + "\n우끼끼:" + aiResponse;

    if (memberContent.length() > 500) {
      String report = reportService.makeReport(member,"우끼끼",memberContent);
      memberContent = "";
      aiResponse = "아쉽지만 내 기억력은 여기까지야. 내가 너와의 대화가 어땠는지 이야기해줄게!\n" + report +"\n내 리포트는 나의 리포트에서 다시 한번 확인할 수 있어 즐거웠어 다음에 또 만나!";
      System.out.println(aiResponse);
    }

    member.setContent1(memberContent);
    memberRepository.save(member);

    ChatResponse.ChatMessageDTO aiMessage = new ChatResponse.ChatMessageDTO(aiResponse);
    messagingTemplate.convertAndSend("/topic/messages", aiMessage);
  }

관련 코드를 보자!

위 코드는 첫번째 캐릭터인 우끼끼로부터 응답을 받고 전송하는 코드이다.(이 부분만 자세히 보여주기 위해서 일부 생략)

우선, requestDto 안에 있는 토큰으로 유저 정보를 불러오고, 이 유저에게서 content1을 꺼낸다. 이 값은 이제까지 해당 유저가 우끼끼와대화한 내용이다. 

그 다음 memberContent에다가 유저에게 받은 내용과, ai로부터 받은 응답을 추가한다. 이 값은 다시 해당 유저의 content1에다가 저장하고, 해당 멤버의 내용이 바뀌었으니 다시 repository를 업데이트한다.

 

이 때 토큰의 길이가 제한되어있으니, 만약 content값이 특정 값(여기서는 500)을 넘으면 리포트를 생성하는 부분으로 넘어가고, 해당 작업이 끝나면 memeberContent = " " 를 통해서 해당 값을 비운다.

 

리포트 

리포트 생성은 

if (memberContent.length() > 500) {
  String report = reportService.makeReport(member,"아웅",memberContent);
  memberContent = "";
  aiResponse = "아쉽지만 내 기억력은 여기까지야. 내가 너와의 대화가 어땠는지 이야기해줄게!\n" + report +"\n내 리포트는 나의 리포트에서 다시 한번 확인할 수 있어 즐거웠어 다음에 또 만나!";
  System.out.println(aiResponse);
}

리포트를 작성해달라는 명령과 함께 대화 내용을 보내느 것으로 처리하였다.

리포트를 사용자가 바로 채팅창에서 볼 수 있도록 위 리포트 내용을 안내문구와 함께 클라이언트에 전달하도록 구현하였다.

 

openai로부터 리포트를 받으면 reportService의 makeReport메서드를 호출한다. 이 때 사용자와 챗봇의 캐릭터명, 대화내용을 파라미터로 보낸다.

public String makeReport(Member member,String animal, String content) throws JsonProcessingException {
    String reportContent = openAiService.getResponseForReport(content);
    Date currentDate = new Date();
    Report report = new Report(member,animal,reportContent,currentDate,content);
    reportRepository.save(report);
    return reportContent;
  }

그럼 위 메서드에서 현재 시간을 기준으로 리포트를 생성하여 저장한다.

 

배운 점

우선 본선에 진출하여 발표를 한 경험 자체가 좋았던 것 같다. 높은 자리에 계신 분들 앞에서 발표하려니 너무 떨렸지만 어찌저찌 해냈다!

사실 이번 대회에 혼자 나간 사람이 거의 없었다. 다들 팀 단위로 나오셨는데 다수의 사람들이 한 만큼 퀄리티는 대단했다. 혼자 프로젝트를 하면 회의도 안해도 되고, 모든걸 나 혼자 결정할 수 있어서 편하고 좋지 않을까? 라고 생각한 적이 있었는데 전혀 아니라는 것을 깨닫게 되었다. 

왜 많은 회사들이 팀워크를 중요시하는지 알 것 같다. 팀보다 대단한 개인은 없다라는 말은 정말 맞는 말이다.

 그리고 발표 잘하는 사람도 엄청 많았다. 공대라 발표할 기회가 많지 않았는데 이번에 스피치 능력이 얼마나 중요한지 깨닫게 되었다. 앞으로 발표할 기회가 있으면 많이 도전해봐야겠다.

 

개발적인 측면에서 봤을 때에도 배운 점이 많다. 우선 내가 개발해보지 않은 분야에 대한 두려움이 많았는데 맨땅에 헤딩이라도 해보면 어떻게든 된다는 것을 알게 되었다. 앞으로는 도전에 대해 많이 두려워하지는 않을 것 같다.

그리고 openai로 gpt 연동하는 것을 배워두니 정말 모든 프로젝트에서 써먹고 있다. 기술 하나 배우면 진짜 계속 쓰는거같다. 더 많은 기술을 익히고,잘 정리해두어야겠다. 특히 이번에 user 이외에 다른 role도 사용해보니 더욱 퀄리티가 올라가는 것을 느꼈다. 이미 배운 기술이라도 이를 어떻게 더 고도화할지도 많이 생각해봐야겠다.

 

아쉬웠던 점

우선 공모전 마감기한이 굉장히 촉박했어서(근데 다들 어케한거지..?) 코드의 퀄리티보다는 구현에 초점을 맞췄던 것 같다. 그래서 매직넘버도 많고, 중복코드도 많다. 패키징도 좋지 않은 것 같다. 조만간 리팩토링 해야겠다.

그리고 챗봇을 위한 모델을 쓰지 않았다보니 대화가 완전히 원활한 것 같지는 않았다. 네이버 등에 챗봇을 위한 모델이 있는 것 같던데 다음에는 챗봇 전용 모델을 써봐야겠다.

 

비록 수상은 못했지만, 열심히 하면 계속 기회가 열리는 것 같다. 앞으로도 좋은 기회를 많이 잡아야겠다.