Hyun's Wonderwall

[졸업프로젝트] 백엔드 개발 회고 - ERD, Spring AI 라이브러리로 OpenAI 사용, 똑똑소비봇 기능을 위한 GPT 프롬프트 엔지니어링 본문

Subjects

[졸업프로젝트] 백엔드 개발 회고 - ERD, Spring AI 라이브러리로 OpenAI 사용, 똑똑소비봇 기능을 위한 GPT 프롬프트 엔지니어링

Hyun_! 2025. 5. 19. 23:55

졸업프로젝트 그로쓰도 어느덧 막바지에 이르렀습니다. 지난 1년 동안 진행한 기획 및 개발 과정을 회고해보겠습니다.

우리 팀은 "예산 내 소비에 어려움을 겪는 Z세대를 위한 시계열 예측 AI 기반 맞춤형 절약 챌린지 가계부 서비스"를 주제로 서비스 개발을 진행했습니다.

저는 기획(공동), 백엔드(Spring Boot에서 챌린지 도메인과 월별리포트 기능 제외 전체) 개발 및 서버 구축, UX/UI 디자인, AI(OpenAI API)를 담당했습니다.

서비스 아키텍처는 다음과 같습니다.


1. ERD 설계

 

ERD 설계 시 가장 어려웠던 부분은 거래내역(초록색) 관련 테이블 구조였습니다.

  • 수입과 지출은 공통 필드가 많으나, 완전히 일치하지 않습니다.
  • 일별·월별 거래내역과 분석 화면에서 특정 기간 동안의 수입·지출을 함께 표시해야 합니다.

이에 따라 수입과 지출이 Transaction이라는 부모를 상속받도록 모델링했고, Spring Boot에서는 @Inheritance(strategy = InheritanceType.JOINED)와 @DiscriminatorColumn을 활용하여 구현했습니다.

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Inheritance(strategy = InheritanceType.JOINED) // 상속 전략 설정
@DiscriminatorColumn(name = "transaction_type") // 구분 컬럼 추가
public abstract class Transaction extends BaseTimeEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long transactionId;

    private long amount;

    @NotNull
    @Column(nullable = false)
    private LocalDateTime transactionTime;

    private String transactionSource;

    private String sourceApp;

    @Setter
    private String note;

    @Setter
    @ManyToOne
    @JoinColumn(name = "scheduled_transaction_id")
    private ScheduledTransaction scheduledTransaction;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", nullable = false, updatable = false)
    @NotNull
    @OnDelete(action = OnDeleteAction.CASCADE)
    private User user;

    public Transaction(long amount, LocalDateTime transactionTime, String transactionSource,
                       String sourceApp, User user) {
        this.amount = amount;
        this.transactionTime = transactionTime;
        this.transactionSource = transactionSource;
        this.sourceApp = sourceApp;
        this.user = user;
    }

    public void updateAmount(long newAmount) {
        this.amount = (newAmount < 0) ? 0L : newAmount;
    }
}

 

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@DiscriminatorValue("EXPENSE") // 구분자 추가 (Joined Table 전략)
public class Expense extends Transaction {

    private String payee;

    @Column(nullable = false)
    @Enumerated(EnumType.ORDINAL)  // DB에 숫자로 저장
    private ExpenseCategory expenseCategory;

    @Builder
    public Expense(String payee, ExpenseCategory expenseCategory,
                   long amount, LocalDateTime transactionTime, String transactionSource, String sourceApp, User user) {
        super(amount, transactionTime, transactionSource, sourceApp, user);
        this.payee = payee;
        this.expenseCategory = expenseCategory;
    }

    public void updateExpenseCategory(ExpenseCategory expenseCategory) {
        this.expenseCategory = expenseCategory;
    }
}
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@DiscriminatorValue("INCOME") // 구분자 추가 (Joined Table 전략)
public class Income extends Transaction {

    private String payer;

    @Builder
    public Income(String payer, long amount, LocalDateTime transactionTime,
                  String transactionSource, String sourceApp, User user) {
        super(amount, transactionTime, transactionSource, sourceApp, user);
        this.payer = payer;
    }
}

 

JOINED 전략은 정규화 관점에서 테이블을 분리할 수 있어 선택했으나, 개발 도중 현 설계가 항상 JOIN이 발생해 읽기·쓰기 비용을 두배로 증가시킨다는 단점을 알게 되었습니다. 리팩토링이 필요하다고 판단하여, 이후 SINGLE_TABLE + GSI 전략을 적용할 계획입니다.

 

DB 설계 과정에서 NoSQL이나 JSONB 도입도 검토했으나, 거래내역은 정형화된 테이블로 관리하는 것이 적합하다고 결론을 내렸습니다.

 

반복 지출·예정 내역, 알림 기능 등은 프로젝트 주제가 수정되면서 우선순위가 변경되어 후속 개발로 보류했습니다. 핵심 기능의 설계 및 구현, 고도화에 집중했습니다.


2. Spring AI 라이브러리

겨울방학 중 Spring Boot에서 OpenAI API를 연동하기 위해 조사해보니, 대부분의 예제가 대부분 RestTemplate 또는 WebClient를 직접 사용하는 방식이었습니다. AI API를 더 편리하게 사용할 수 있는 라이브러리가 있는지 찾아보았고, Spring AI 라이브러리를 알게 되어 적용했습니다.

구현된 클래스들로 OpenAI API 호출을 간단히 할 수 있고, 타임아웃과 에러핸들링도 라이브러리의 기본 기능으로 커버되어 좋았습니다. 또한 라이브러리의 추상화를 통해서 모델 교체를 쉽게 할 수 있는 것 또한 장점이었습니다.

 

이 라이브러리를 사용할 때 힘들었던 점은, 최신이어서 개발 과정 중 라이브러리의 큰 업데이트가 있었다는 것입니다. 스타트/그로쓰 레포지토리를 따로 파서 작업했는데, 라이브러리의 패키지 구성이 달라져 실행되었던 코드가 실행되지 않았습니다😢 인터넷에서 최신 버전의 예제를 찾지 못하여서, 결국 Spring AI 패키지를 열어보고 폴더 및 파일 구성을 보면서 분투하며 해결했습니다.

 

Spring AI 라이브러리 사용 방법 튜토리얼을 진행해보겠습니다.

 

1. build.gradle 의존성 추가

- 제가 사용한 버전은 1.0.0-M6이나, 최신 버전에 맞게 사용하시기 바랍니다.

dependencies {
	implementation 'org.springframework.ai:spring-ai-openai:1.0.0-M6'
}

 

2. application.yml에 모델 키 작성

open-ai:
  api:
    key: # 나의 키

 

3. OpenAI 서비스 코드 작성

- 아래는 제가 작성한 OpenAIService.java 코드입니다.

package tamtam.mooney.global.openai;

import lombok.RequiredArgsConstructor;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class OpenAIService {
    private final OpenAiChatModel openAiChatModel;

    public String generateGPTResponse(String message, OpenAIOptionEnum optionType) {
        Prompt prompt = new Prompt(
                new UserMessage(message),
                optionType.toChatOptions() // 옵션 전달
        );
        return openAiChatModel.call(prompt).getResult().getOutput().getText();
    }
}

 

간단하지요? generateGPTResponse()처럼 메소드를 작성해 OpenAIChatModel을 프롬프트를 넣어서 call하고 텍스트를 꺼내면 끝입니다.

 

이제부터는 제가 진행한 것을 추가로 말씀드리겠습니다.

 

OpenAIChatModel을 call 할 때는 Prompt 객체를 넘겨주어야 합니다.

public class OpenAiChatModel extends AbstractToolCallSupport implements ChatModel {
   //생략
    public ChatResponse call(Prompt prompt) {
        Prompt requestPrompt = this.buildRequestPrompt(prompt);
        return this.internalCall(requestPrompt, (ChatResponse)null);
    }
    //생략

 

Spring AI의 Prompt.class를 직접 열어보니 생성자 오버로딩이 활용된 것을 확인할 수 있었습니다.

//생략
public class Prompt implements ModelRequest<List<Message>> {
    private final List<Message> messages;
    @Nullable
    private ChatOptions chatOptions;

    public Prompt(String contents) {
        this((Message)(new UserMessage(contents)));
    }

    public Prompt(Message message) {
        this(Collections.singletonList(message));
    }

    public Prompt(List<Message> messages) {
        this((List)messages, (ChatOptions)null);
    }

    public Prompt(Message... messages) {
        this((List)Arrays.asList(messages), (ChatOptions)null);
    }

    public Prompt(String contents, ChatOptions chatOptions) {
        this((Message)(new UserMessage(contents)), chatOptions);
    }
    //생략


저의 경우 AI 모델에게 유저 메시지 뿐 아니라 옵션을 전달하는게 중요해, 두 파라미터를 따로 넣도록 했습니다.

 

Spring AI의 ChatOptions는 아래와 같은 모습입니다. 사실 이러한 옵션은 유저메시지에 적어서 보내도 되지만, 인터페이스가 있기에 한번 사용해보았습니다. 

public interface ChatOptions extends ModelOptions {
    @Nullable
    String getModel();

    @Nullable
    Double getFrequencyPenalty();

    @Nullable
    Integer getMaxTokens();

    @Nullable
    Double getPresencePenalty();

    @Nullable
    List<String> getStopSequences();

    @Nullable
    Double getTemperature();

    @Nullable
    Integer getTopK();

    @Nullable
    Double getTopP();
    // 생략

 

Spring AI에 OpenAI 관련으로 구현체 OpenAiChatOptions 클래스가 있었습니다.

저는 OpenAI의 옵션값들을 매번 새로 작성하지 않고, 묶어서 세팅해 사용하고 싶었습니다. 이에 커스텀으로, OpenAIOptionEnum 열거형을 만들어 아래처럼 사용했습니다. 다양한 프리셋을 만들어놓고 후술할 똑똑소비봇 기능에서 응답 퀄리티를 비교했습니다.

@Getter
public enum OpenAIOptionEnum {
    CREATIVE("gpt-4o-mini", 0.9, 700, 1.0, 0.0, 0.0),  // 창의적 답변
    BALANCED("gpt-4o-mini", 0.7, 500, 0.8, 0.0, 0.0),  // 균형 잡힌 답변
    LOGICAL("gpt-4o-mini", 0.2, 500, 0.8, 0.0, -0.5); // 논리적인 답변

    private final String model;
    private final double temperature;
    private final int maxTokens;
    private final double topP;
    private final double frequencyPenalty;
    private final double presencePenalty;

    OpenAIOptionEnum(String model, double temperature, int maxTokens, double topP, double frequencyPenalty, double presencePenalty) {
        this.model = model;
        this.temperature = temperature;
        this.maxTokens = maxTokens;
        this.topP = topP;
        this.frequencyPenalty = frequencyPenalty;
        this.presencePenalty = presencePenalty;
    }

    // OpenAiChatOptions 객체로 변환
    public OpenAiChatOptions toChatOptions() {
        return OpenAiChatOptions.builder()
                .model(this.model)
                .temperature(this.temperature)
                .maxTokens(this.maxTokens)
                .topP(this.topP)
                .frequencyPenalty(this.frequencyPenalty)
                .presencePenalty(this.presencePenalty)
                .build();
    }
}

 


3. 똑똑소비봇 기능을 위한 GPT 프롬프트 설계

 

GPT에게 전달되는 똑똑소비봇 프롬프트 구성은 다음과 같습니다.

  1. 역할 주입 (캐릭터의 성격*, 소비 조언 상황)
  2. 질문에 언급된 상품의 예산 카테고리 파악
  3. 해당 예산 카테고리의 남은 금액을 확인
  4. 향후 소비를 예상
  5. 예산 내로 소비가 가능한지, 소비가 적절할지 응답 생성

*캐릭터의 성격: 서비스에서 현재 적용중인 캐릭터에 따라 성격, 말투가 다르게 적용됩니다. 기본 캐릭터는 무니입니다.

  • 무니 (정석적이고 친절한 가계부 전문가)
    • MONTHLY_FEEDBACK - 이번 달 지출을 꼼꼼히 분석해봤어요. 🍀 절약할 수 있는 부분을 체크해볼까요?
    • BUDGET_WARNING - ⚠️ 이번 달 예산을 초과했어요. 다음 달 계획을 다시 세워볼까요?
  • 써니 (활발하고 쾌활한 치어리더)
    • MONTHLY_FEEDBACK - 와! 이번 달도 멋지게 소비했네요! 🎉 그래도 저축도 조금 해두는 게 어때요?
    • BUDGET_WARNING - 괜찮아요! 🎈 다음 달에는 조금 더 신중하게 써보는 것도 좋을 것 같아요!
  • 티타 (깐깐하고 엄격한 절약 전문가)
    • MONTHLY_FEEDBACK - 이건 낭비입니다. 불필요한 소비를 줄이지 않으면 미래가 힘들어질 수 있어요.
    • BUDGET_WARNING - 이건 심각합니다. 지출 패턴을 바로 수정해야 해요. 💢
  • 에피 (감성적이고 위로를 잘하는 힐링 캐릭터)
    • MONTHLY_FEEDBACK - 이번 달도 고생 많았어요. 💕 소비가 조금 많아도, 행복을 위한 소비라면 괜찮아요!
    • BUDGET_WARNING - 요즘 힘든 일 있었나요? 💖 나 자신을 위한 소비도 필요하죠. 하지만 균형도 중요해요!

 

똑똑소비봇 채팅 기능 개발 중 겪은 이슈는 주로 프롬프트 엔지니어링 관련입니다.

  1. 처음에 GPT가 프롬프트를 따르지 않을 것을 우려해 옵션(OpenAIOptionEnum)을 LOGICAL 세팅으로 설정했었는데, 너무 제공한 글 형식에 끼워맞추어 대답하느라 올바르지 않게 대답을 하는 문제가 있었습니다. 이에, BALANCED 세팅으로 변경했습니다.
  2. 초기 똑똑소비봇의 응답은 졸프 팀원들에게 '너무 추상적'이라는 피드백을 받아서, 팀원들에게 바라는 이상적인 응답이 어떠한지 물었습니다. 어떻게 해야 생성형 AI의 추측 능력을 활용하며 사용 이점을 크게 할 수 있을지 고민했습니다. 여러 번의 테스트를 통해 프롬프트를 고도화한 결과 만족스러운 응답을 내도록 할 수 있었습니다.

 

변경 전 (LOGICAL + 구버전 프롬프트)

{
  "response": "Test2님, 현재 식비 예산은 170,000원이 남아있어요. 15,000원짜리 비싼 덮밥은 충분히 예산 내에서 구매 가능해요! 😊 하지만, 다른 필수 지출도 고려해보시는 것이 좋을 것 같아요. 만약 이번 달에 추가적인 지출 계획이 있다면, 조금 더 저렴한 메뉴를 선택하는 것도 현명한 소비가 될 수 있습니다. 어떤 결정을 하시든, 항상 현명한 소비를 응원합니다!"
}

 

변경 후 (BALANCED + 개선한 프롬프트):

1.

{
  "response": "Test2님, 현재 식비 예산이 170,000원이 남아있어요. 15,000원짜리 덮밥은 잔여 예산의 약 8.8%를 차지하고, 3,000원짜리 참치김밥은 약 1.8%를 차지해요. 덮밥은 좀 더 만족스러운 한 끼가 될 수 있지만, 가격이 상대적으로 높은 편이어서 다음 주 예상 식비 지출(적어도 40,000원 이상 예상)에 대한 부담이 생길 수 있어요.\n\n반면, 참치김밥은 저렴하면서도 간편하게 식사를 해결할 수 있어 남은 예산을 더 여유롭게 사용할 수 있게 도와줄 거예요. 오늘은 참치김밥으로 아끼고, 다음에 덮밥을 드시면 더 맛있고 기분 좋게 즐길 수 있을 거라 생각해요! 😊"
}

 

2.

{
  "response": "Test2님, 현재 패션/쇼핑 예산이 23,900원 남아있어요. 100,000원짜리 옷을 구매하시면 예산을 76,100원 초과하게 됩니다. 현재 월 초임에도 불구하고, 예산이 부족한 상황이라 이번 구매는 조금 부담스러울 수 있어요.\n\n다음 달에도 추가 지출이 있을 것으로 예상되니, 이번 달 다른 지출을 최소화하거나 여유를 모아 다음 달 초에 구매하는 것도 좋은 방법이에요. 예를 들어, 식비나 카페비에서 30,000원 정도 절약하면 조금 더 여유를 가지고 쇼핑할 수 있을 거예요. 😊\n\n옷을 사는 것은 기분 전환에 좋지만, 조금 더 여유를 모아 다음에 더 만족스럽게 구매하실 수 있도록 응원할게요!"
}

 

또, 처음 기능 기획 단계에서는 똑똑소비봇에게 제안해야 하는 질문의 형식을 "AorB 두가지 비교"와 "A(1개)를 살지 말지" 로 한정했었는데 테스트 과정에서 GPT가 여러개 비교도 잘 수행하는 것을 확인해서 해당 방향으로 프롬프트를 수정했습니다.

 

ChatService 코드 (프롬프트 포함)

- 채팅 대화 내역은 Redis에 저장 및 관리됩니다.

// 생략

@Service
@Transactional
@RequiredArgsConstructor
public class ChatService {
    private final UserService userService;
    private final UserAgentRepository userAgentRepository;
    private final OpenAIService openAIService;
    private final MonthlyBudgetService monthlyBudgetService;
    private final CategoryBudgetService categoryBudgetService;
    private final ExpenseService expenseService;
    private final GenericRedisRepository<ChatMessage> chatRedisRepository;

    // 채팅 저장
    public void saveChatMessage(String userId, String message, String role) {
        ChatMessage chatMessage = new ChatMessage(
                UUID.randomUUID().toString(),
                userId,
                role,
                message,
                LocalDateTime.now()
        );
        chatRedisRepository.save("chat:" + userId, chatMessage);
    }
    
    @Transactional(readOnly = true)
    public List<ChatMessage> getChatHistory(String userId) {
        return chatRedisRepository.findAll("chat:" + userId);
    }

    public ChatResponseDto chat(ChatRequestDto requestDto) {
        User user = userService.getCurrentUser();

        // 예산 정보 조회
        List<ChatBudgetInfoDto> budgetInfoDtos = getCategoryBudgetRemainingAmount(user);
        String budgetInfoString = budgetInfoDtos.stream()
                .map(dto -> String.format("- %s: %d원 남음\n", dto.categoryName(), dto.remaining()))
                .collect(Collectors.joining());

        // GPT 응답 생성
        String gptResponse = generateGPTResponseForChat(user, requestDto.message(), budgetInfoString);

        // Redis에 채팅 내역 저장
        saveChatMessage(user.getUserId().toString(), requestDto.message(), "USER");
        saveChatMessage(user.getUserId().toString(), gptResponse, "GPT");

        return new ChatResponseDto(gptResponse);
    }

    @Transactional(readOnly = true)
    public List<ChatBudgetInfoDto> getCategoryBudgetRemainingAmount(User user) {
        // 이번 월의 카테고리별 예산 조회
        LocalDate startOfMonth = LocalDate.now().withDayOfMonth(1);
        MonthlyBudget monthlyBudget = monthlyBudgetService.getMonthlyBudget(user, startOfMonth);
        List<CategoryBudget> budgets = categoryBudgetService.findByMonthlyBudget(monthlyBudget);

        // 특정 기간 동안의 모든 카테고리별 총 지출
        Map<ExpenseCategory, Long> totalExpensesByCategory = expenseService.mapTotalExpenseForAllCategories(user, startOfMonth);

        // 각 카테고리의 실제 지출 계산
        return budgets.stream()
                .map(cb -> {
                    Long spent = totalExpensesByCategory.getOrDefault(cb.getExpenseCategory(), 0L);
                    long remaining = Math.max(cb.getAmount() - spent, 0);

                    return new ChatBudgetInfoDto(
                            cb.getExpenseCategory().getCategoryName(),
                            remaining
                    );
                }).collect(Collectors.toList());
    }

    // 상황 판단 프롬프트 (GPT가 CHOICE_RECOMMENDATION 또는 YES_NO_DECISION 판단)
    private String generateScenarioPrompt() {
        return """
    ## 1. 상황 판단
    - **CHOICE_RECOMMENDATION**: 두 개 이상 상품 비교 요청 (상품 수 무관)
      - 여러 상품이 예산 내에 있다면, 남은 기간 추가 지출을 고려해 더 합리적인 선택 제안
    
    - **YES_NO_DECISION**: 특정 상품 구매 여부 결정 요청
    """;
    }

    private String generateBudgetAnalysisPrompt() {
        return """
    ## 2. 예산 분석과 구매 가능성 평가
        1) 메시지에서 상품별 관련 예산 카테고리를 추출. 예산 카테고리 종류는 [예산 카테고리별 남은 금액]에 언급됨. (ex. 상품이 음식->식비) (없으면 기타)
        2)  **[예산 카테고리별 남은 금액]**에서 해당 카테고리 잔여 금액 안내
        3) 제시 가격 또는 시장가 가정
        4) 지출 비율 계산(ex. 2만 원 = 잔여 예산의 50%)
        5) 과거 지출 내역 기반 남은 이번 달 소비 경향 추론
           [과거 지출 요약] 블록의 통계치를 참고해,
           - 다음 기간 예상 지출 규모
           - 주요 지출 패턴(빈도·증가·감소 추이)
           등을 추론해서 반영
        6) 남은 기간과 날짜 고려해 최종 소비 가능 여부·추천 옵션 산출
    
    ## 3. 답변 구성
    - **충분히 가능**
        - 구체적 수치(금액)로 설명
        - 기대 효과 언급 (만족감, 편리함)
    - **부담되는 경우**
        - 현재 부담 정도 설명
        - 대안 제시
        - 미래 소비 예측 반영
    - **불가능할 때**
        - 친절하고 설득력 있게 공감
        - 충동 지출 자제 권유
        - 감성적 동기 부여 (“다음에 더 맛있게…” 등)
    - **출력 구조**
        1) 비교·수치 분석 설명 단락 \s
        2) 최종 추천 + 감성 동기부여 단락
    ## 4. 톤 & 포맷
        - 숫자를 정확하게
        - 이모지 1~2개로 친근함 추가
        - 마크다운 사용 금지
        - 문장 끝은 모두 '~요'체로 마무리
    """;
    }

    // GPT가 참고할 사용자 예시 응답
    private String generateSampleResponse(String userNickname) {
        return String.format(
                """
                [적절한 응답 분량]: 250~350자 내외
                [상황별 응답 예시]
                    - **if CHOICE_RECOMMENDATION** (구체적 상품명을 반드시 언급):
                        "%s님, 현재 식비 예산이 30,000원 남아있어요. 고기(20,000원)는 잔여 예산의 67%%를 차지해요. 조금 큰 비중이라, 하루 식사로 지출하기엔 조금 부담스러울 수 있어요. 컵밥(3,000원)을 선택하시면 다음 주 예상 식비 지출(적어도 4~5만 원 이상 예상)에 대한 부담이 훨씬 줄어들 거예요.\\n가끔은 작은 절약이 더 큰 만족으로 돌아와요. 오늘은 컵밥으로 아끼고, 고기는 다음에 조금 더 여유 있을 때 드시면 더 맛있고 기분 좋게 드실 수 있을 거라 생각해요!😊"
                    - **if YES_NO_DECISION** (구체적 상품명을 반드시 언급):
                        "%s님, 쇼핑 예산이 50,000원 남아있어요. 100,000원짜리 옷을 사면 예산을 50,000원 초과하게 됩니다. 아직 한 달이 많이 남았으니, 이번 달 다른 지출을 최소화해 예산을 재조정하면 가능할 수도 있어요.\\n예를 들어, 외식비나 카페비에서 50,000원만 절약하면 오늘 옷 구매가 부담 없이 이루어질 거예요. 😊 그렇지만, 조금 더 여유를 모아 다음 달 초에 구매하는 것도 한 방법이에요. %s님이 기분 좋게 쇼핑하실 수 있도록 응원할게요!"
                """,
                userNickname, userNickname, userNickname
        );
    }

    @Transactional(readOnly = true)
    public String generateGPTResponseForChat(User user, String userMessage, String budgetInfo) {
        UserAgent userAgent = userAgentRepository.findByUserAndIsActiveTrue(user)
                .orElseThrow(() -> new IllegalArgumentException("No active UserAgent found."));

        String agentPrompt = generateUserAgentPrompt(userAgent);
        String finalInstruction = generateFinalInstruction(userAgent);

        String scenarioPrompt = generateScenarioPrompt();
        String budgetAnalysisPrompt = generateBudgetAnalysisPrompt();
        String sampleResponse = generateSampleResponse(user.getNickname());
        LocalDate today = LocalDate.now();

        String message = String.format(
                """
                %s
    
                %s
    
                %s
    
                [예산 카테고리별 남은 금액]:
                %s
    
                %s
                ---
                [오늘]: %s
                [사용자 %s]: "%s"
    
                %s
    
                **응답할 때 반드시 구체적인 상품명과 예산 카테고리를 명확히 언급하여 답변을 작성해주세요!**
                """,
                agentPrompt,
                scenarioPrompt,
                budgetAnalysisPrompt,
                budgetInfo,
                sampleResponse,
                today,
                user.getNickname(),
                userMessage,
                finalInstruction
        );
        return openAIService.generateGPTResponse(message, OpenAIOptionEnum.BALANCED);
    }


    // UserAgent(캐릭터 관련) 프롬프트 생성
    public String generateUserAgentPrompt(UserAgent userAgent) {
        return String.format(
                """
                너는 "%s"야.
                - %s의 성격, %s한 어조를 가진 금융 어시스턴트. 항상 이 성격과 말투를 유지.
                - 한국어로 대답, 마크다운은 절대 사용 금지
                """,
                userAgent.getAgent().getAgentName(),
                userAgent.getAgent().getPersonality(),
                userAgent.getAgentTone()
        );
    }

    // UserAgent(캐릭터) 답변 마무리 문장 생성
    public String generateFinalInstruction(UserAgent userAgent) {
        return String.format(
                "이제 %s의 개성을 반영하여 자연스럽게 답변을 생성해.",
                userAgent.getAgent().getAgentName()
        );
    }
}

 


4. 마무리

똑똑소비봇 기능은 아래 링크에서 확인하실 수 있습니다.

- 데모 영상:

https://www.youtube.com/watch?app=desktop&v=JeM7QmJTXdI

- 데모 APK:

https://drive.google.com/drive/u/0/folders/1p_svbCnWkXxEFL84kavpf2rQzHOEuP7D

 

이상으로 백엔드 개발 회고를 마칩니다. 앞으로 리팩토링과 추가 기능 구현을 통해 더욱 완성도 높은 서비스를 만들고 싶습니다.

 

추후 본 기능과 관련해 고도화할 수 있는 방안은 다음과 같이 생각해 보았습니다.

  1. 대화 context 메모리 기능 도입
    - 현재 똑똑소비봇은 이전 대화를 기억하지 못하는데, 기억할 수 있다면 더욱 맞춤형 관리를 제공할 수 있을 것으로 생각됩니다.
  2. 자체 MCP 서버 개발로 OpenAI가 DB 백엔드의 API를 직접 호출하도록 구현 + 재정 관리 에이전트로 고도화
    - 똑똑소비봇 테스트 중 똑똑소비봇이 백엔드가 제공해주는 데이터를 통해 마치 재정 에이전트처럼 기능할 수 있는 것을 알게 되었습니다. 서비스 기획 당시에는 MCP 기술을 알지 못해 생각하지 못했지만, 이 방향으로 기능을 발전시킬 수 있을 것으로 생각됩니다.