Skip to content

feat: NL 기반 선수 벌크 등록 API 구현#431

Merged
TAEW00KIM merged 6 commits intomainfrom
feature/nl-bulk-player-register
Mar 7, 2026
Merged

feat: NL 기반 선수 벌크 등록 API 구현#431
TAEW00KIM merged 6 commits intomainfrom
feature/nl-bulk-player-register

Conversation

@TAEW00KIM
Copy link
Contributor

@TAEW00KIM TAEW00KIM commented Mar 7, 2026

이슈 배경


기존의 문제

  • 매니저가 선수를 등록할 때 한 명씩 수동 입력해야 하는 불편함이 있었습니다.
  • 엑셀/스프레드시트에서 복붙한 텍스트의 형식이 다양해 정규식만으로 파싱이 어려웠습니다.

해결 방식

  • Gemini Function Calling 기반 자연어 파싱 + 학번 원본 대조 검증으로 선수 벌크 등록 API를 구현했습니다.
  • 2-step API 구조: POST /nl/process(파싱+검증+프리뷰) → POST /nl/execute(등록 실행)
  • 기존 PlayerService, TeamService를 재사용하여 신규 비즈니스 로직 없이 구현했습니다.

전체 구조 개요

2단계 API로 구성됩니다. process(미리보기) → execute(실제 등록) 순서로 호출합니다.


1단계: POST /nl/process — 미리보기

요청:

{
  "leagueId": 186,
  "teamId": 1,
  "history": [],
  "message": "홍길동 202600001 10\n김철수 202600002 7"
}

흐름:
[클라이언트] 자연어 텍스트 전송

[NlController.process()]

[NlService.process()]
① 권한 검증: league 멤버인지, team이 해당 league 소속인지 확인

② NlClient.parsePlayers(message, history) 호출
- NlClient 인터페이스를 통해 LLM에 파싱 요청
- 현재 구현체: NlGeminiClient (Gemini Function Calling)
- NlParseResult로 변환하여 반환 (LLM 구현체 무관)

③ 파싱 실패 시 → 텍스트 메시지 그대로 반환
(예: "선수 정보를 입력해주세요.")

④ 원본 텍스트에서 9자리 숫자 직접 추출 (정규식)
→ LLM이 학번을 "보정"하는 hallucination 방지용 대조 집합

⑤ 파싱된 선수 목록 순회하며 각각 상태 분류:
- 원본에 없는 학번 / 9자리 아님 → parseFailedLines에 추가
- 입력 내 중복 학번 → DUPLICATE_IN_INPUT
- DB에 없음 → NEW
- DB에 있지만 팀에 없음 → EXISTS (existingPlayerId 포함)
- DB에 있고 이미 팀 소속 → ALREADY_IN_TEAM

⑥ 미리보기 응답 반환

응답:

  {
    "displayMessage": "정치외교학과 DPS에 2명의 선수를 등록합니다. 확인해주세요.",
    "preview": {
      "type": "REGISTER_PLAYERS_BULK",
      "teamId": 1,
      "teamName": "정치외교학과 DPS",
      "players": [
        { "name": "홍길동", "studentNumber": "202600001", "jerseyNumber": 10, "status": "NEW",
  "existingPlayerId": null },
        { "name": "김철수", "studentNumber": "202600002", "jerseyNumber": 7,  "status": "EXISTS",
  "existingPlayerId": 42 }
      ],
      "summary": { "total": 2, "newPlayers": 1, "existingPlayers": 1, "alreadyInTeam": 0 },
      "parseFailedLines": []
    }
  }

2단계: POST /nl/execute — 실제 등록

클라이언트가 미리보기를 사용자에게 보여주고, 확인 후 이 API를 호출합니다.

요청:

  {
    "leagueId": 186,
    "teamId": 1,
    "players": [
      { "name": "홍길동", "studentNumber": "202600001", "jerseyNumber": 10 },
      { "name": "김철수", "studentNumber": "202600002", "jerseyNumber": 7 }
    ]
  }

흐름:
[클라이언트] 확인된 선수 목록 전송

[NlService.execute()]
① 동일한 권한 검증

② 학번 목록으로 DB 일괄 조회 (N+1 방지)

③ 선수 목록 순회:
- 중복 학번 → skipped++
- DB에 없음 → PlayerService.register()로 신규 생성 → created++
- DB에 있고 이미 팀 소속 → skipped++
- DB에 있고 팀 미소속 → 기존 ID 사용 → assigned++

④ TeamService.addPlayersToTeam()으로 일괄 팀 배정

⑤ 결과 반환

응답:
{
"displayMessage": "정치외교학과 DPS에 2명의 선수가 등록되었습니다.",
"result": { "created": 1, "assigned": 2, "skipped": 0 }
}


학번 원본 대조 검증

입력: "홍길동 20260001 10" ← 8자리 (오타)

LLM이 "202600001"로 보정해서 반환할 수 있음 (hallucination)

원본에서 9자리 숫자 추출 → {} (비어있음)

"202600001" ∉ 원본 집합 → parseFailedLines에 추가

LLM이 임의로 학번을 만들어내는 것을 원본 텍스트와 대조해서 차단하는 안전장치입니다.


설계 포인트

  • NlClient 인터페이스: NlService는 LLM 구현체를 모릅니다. 현재 Gemini, 추후 다른 LLM으로 교체 가능.
  • PlayerStatus enum: 선수 상태를 타입 안전하게 관리 (NEW, EXISTS, ALREADY_IN_TEAM, DUPLICATE_IN_INPUT)
  • 기존 서비스 재사용: PlayerService.register(), TeamService.addPlayersToTeam() 그대로 사용

확인해야 할 부분

  • process 응답의 선수 status 분류가 정상 동작하는지
  • execute 시 선수 생성 + 팀 배정이 하나의 트랜잭션으로 처리되는지
  • 권한 검증(JWT + PermissionValidator) 및 리그-팀 소속 검증이 정상 동작하는지

영향 범위 / 사이드 이펙트

  • 신규 API 추가만 포함, 기존 API 변경 없음
  • SecurityConfig에 /nl/** 인증 경로 추가
  • PlayerRepository에 findByStudentNumber, findByStudentNumberIn 쿼리 메서드 추가

@TAEW00KIM TAEW00KIM requested a review from Jin409 March 7, 2026 07:12
@TAEW00KIM TAEW00KIM self-assigned this Mar 7, 2026
@gemini-code-assist
Copy link

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

이 Pull Request는 매니저가 선수 정보를 수동으로 한 명씩 입력하거나 다양한 형식의 텍스트를 파싱하는 데 겪었던 불편함을 해소하기 위해 자연어 기반의 선수 벌크 등록 기능을 도입합니다. Gemini Function Calling을 활용하여 유연하게 선수 정보를 추출하고, 2단계 프로세스를 통해 등록 전 검증 및 미리보기를 제공함으로써 사용자 경험을 개선하고 데이터 정확성을 높이는 데 중점을 두었습니다.

Highlights

  • NL 기반 선수 벌크 등록 API 구현: 매니저가 자연어 텍스트를 통해 여러 선수를 한 번에 등록할 수 있는 새로운 API를 구현했습니다.
  • Gemini Function Calling 활용: 선수 정보 파싱을 위해 Gemini Function Calling을 활용하여 다양한 형식의 텍스트에서 이름, 학번, 등번호를 추출합니다.
  • 2단계 등록 프로세스: 선수 등록은 process (파싱, 검증, 프리뷰)와 execute (실제 등록 실행)의 두 단계로 나뉘어 진행됩니다.
  • 학번 원본 대조 검증: 파싱된 학번이 원본 입력 텍스트에 실제로 존재하는지 대조하여 잘못된 정보 추출을 방지하는 검증 로직을 추가했습니다.
  • 기존 서비스 재사용: 선수 생성 및 팀 배정 로직은 기존 PlayerServiceTeamService를 재사용하여 신규 비즈니스 로직 추가를 최소화했습니다.
  • 보안 설정 및 Repository 확장: 새로운 /nl/** 경로에 대한 인증 설정을 추가하고, PlayerRepository에 학번 기반 조회 메서드를 추가했습니다.

🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Changelog
  • src/main/java/com/sports/server/auth/config/SecurityConfig.java
    • 새로운 NL API 경로 /nl/**에 대한 인증 설정을 추가했습니다.
  • src/main/java/com/sports/server/command/nl/application/NlService.java
    • 자연어 기반 선수 정보 처리 및 등록 실행을 담당하는 NlService를 추가했습니다.
    • Gemini를 통한 선수 정보 파싱, 학번 원본 대조 검증, 선수 상태 분류(NEW, EXISTS, ALREADY_IN_TEAM, DUPLICATE_IN_INPUT) 로직을 구현했습니다.
    • 신규 선수 생성 및 기존 선수 팀 배정 로직을 포함하는 execute 메서드를 구현했습니다.
  • src/main/java/com/sports/server/command/nl/dto/NlExecuteRequest.java
    • NL 기반 선수 등록 실행 요청을 위한 NlExecuteRequest DTO를 추가했습니다.
  • src/main/java/com/sports/server/command/nl/dto/NlExecuteResponse.java
    • NL 기반 선수 등록 실행 응답을 위한 NlExecuteResponse DTO를 추가했습니다.
  • src/main/java/com/sports/server/command/nl/dto/NlProcessRequest.java
    • NL 기반 선수 정보 처리 요청을 위한 NlProcessRequest DTO를 추가했습니다.
  • src/main/java/com/sports/server/command/nl/dto/NlProcessResponse.java
    • NL 기반 선수 정보 처리 응답 및 미리보기를 위한 NlProcessResponse DTO를 추가했습니다.
  • src/main/java/com/sports/server/command/nl/exception/NlErrorMessages.java
    • NL 기능 관련 에러 메시지를 정의하는 NlErrorMessages 클래스를 추가했습니다.
  • src/main/java/com/sports/server/command/nl/infra/GeminiFunctionCallResponse.java
    • Gemini API의 Function Call 응답을 파싱하기 위한 DTO를 추가했습니다.
  • src/main/java/com/sports/server/command/nl/infra/NlGeminiClient.java
    • Gemini API와 통신하여 자연어 텍스트에서 선수 정보를 추출하는 NlGeminiClient를 추가했습니다.
    • 선수 정보 추출을 위한 시스템 프롬프트와 함수 스키마를 정의했습니다.
  • src/main/java/com/sports/server/command/nl/presentation/NlController.java
    • NL 기반 선수 벌크 등록 API 엔드포인트 (/nl/process, /nl/execute)를 제공하는 NlController를 추가했습니다.
  • src/main/java/com/sports/server/command/player/domain/PlayerRepository.java
    • 학번으로 선수를 조회하는 findByStudentNumber와 여러 학번으로 선수를 일괄 조회하는 findByStudentNumberIn 메서드를 추가했습니다.
  • src/test/java/com/sports/server/command/nl/application/NlServiceTest.java
    • 새로 추가된 NlService의 핵심 로직에 대한 단위 테스트를 추가했습니다.
  • src/test/java/com/sports/server/command/nl/infra/NlGeminiClientManualTest.java
    • Gemini 외부 API 호출을 검증하기 위한 수동 통합 테스트 클래스를 추가했습니다.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements an NL-based bulk player registration API, featuring well-structured Gemini API integration and clear business logic. However, critical vulnerabilities were identified, including prompt injection via the user-controlled history parameter and potential cross-site scripting (XSS) from unsanitized LLM output. Beyond security, there are opportunities to enhance performance, stability, and maintainability, particularly concerning blocking behavior during Gemini API calls, the safety of DTOs, and the efficiency of certain logic. Addressing these security concerns by restricting roles in conversation history and implementing proper sanitization for all LLM-extracted data is crucial, alongside reviewing the identified performance and maintainability aspects.

.bodyValue(body)
.retrieve()
.bodyToMono(GeminiFunctionCallResponse.class)
.block(Duration.ofSeconds(30));

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

block(Duration.ofSeconds(30)) 호출은 HTTP 요청을 처리하는 스레드를 최대 30초 동안 차단할 수 있습니다. Gemini API 응답이 지연될 경우, 웹 서버의 스레드 풀이 고갈되어 전체 서비스의 응답성에 심각한 영향을 줄 수 있습니다.

이 기능을 사용하는 동시 사용자 수가 많아질 경우를 대비하여, @AsyncCompletableFuture를 사용한 비동기 처리 방식을 도입하여 요청 처리 스레드가 즉시 반환되도록 하는 것을 고려해보세요. 이는 시스템의 확장성과 안정성을 크게 향상시킬 것입니다.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

현재 사용자 수로는 이상 없을 것 같습니당

Comment on lines +97 to +108
for (Map<String, String> entry : history) {
String role = entry.get("role");
String content = entry.get("content");
if (role != null && content != null) {
String geminiRole = "assistant".equals(role) ? "model" : "user";
contents.add(Map.of(
"role", geminiRole,
"parts", List.of(Map.of("text", content))
));
}
}
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

The history parameter in the NlProcessRequest is directly used to construct the conversation history sent to the Gemini LLM. The application allows the user to specify the role for each history entry, including the assistant role (which is mapped to the LLM's model role). This enables a malicious user to spoof previous model responses, potentially bypassing system instructions or manipulating the LLM into returning incorrect data (e.g., associating a different name with a valid student number). While the application validates student numbers against the original message, other fields like the player's name remain vulnerable to manipulation.

Comment on lines +109 to +115
failedLines.add(new FailedLine(
i + 1,
name != null ? name + " " + studentNumber : "라인 " + (i + 1),
StudentNumber.isInvalid(studentNumber)
? NlErrorMessages.STUDENT_NUMBER_INVALID
: NlErrorMessages.STUDENT_NUMBER_NOT_IN_ORIGINAL
));

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

The process method returns a list of failedLines that includes the name and studentNumber extracted by the LLM from the user-provided message. These values are reflected back to the user in the response without sanitization. If an attacker crafts a message that causes the LLM to return a malicious script in the name field, and this script is rendered by the frontend application, it could lead to reflected XSS.

Comment on lines +206 to +208
playerId = playerService.register(
new PlayerRequest.Register(playerData.name(), playerData.studentNumber())
);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

The execute method registers new players using names and student numbers provided in the request. These values are typically derived from the LLM output in the previous process step. The application does not sanitize the player's name before passing it to playerService.register, which stores it in the database. If an attacker (or a malicious manager) provides a name containing a script, it will be stored and potentially executed when other users view the player's information in the application.

Comment on lines +138 to +142
int newCount = (int) playerPreviews.stream().filter(p -> "NEW".equals(p.status())).count();
int existsCount = (int) playerPreviews.stream().filter(p -> "EXISTS".equals(p.status())).count();
int alreadyInTeamCount = (int) playerPreviews.stream().filter(p -> "ALREADY_IN_TEAM".equals(p.status())).count();

Summary summary = new Summary(playerPreviews.size(), newCount, existsCount, alreadyInTeamCount);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

현재 Summary를 생성하기 위해 playerPreviews 리스트를 여러 번 순회하고 있습니다. 이는 리스트가 길어질 경우 성능 저하의 원인이 될 수 있습니다. Collectors.groupingBy를 사용하여 한 번의 순회로 모든 카운트를 계산하는 것이 더 효율적입니다.

Suggested change
int newCount = (int) playerPreviews.stream().filter(p -> "NEW".equals(p.status())).count();
int existsCount = (int) playerPreviews.stream().filter(p -> "EXISTS".equals(p.status())).count();
int alreadyInTeamCount = (int) playerPreviews.stream().filter(p -> "ALREADY_IN_TEAM".equals(p.status())).count();
Summary summary = new Summary(playerPreviews.size(), newCount, existsCount, alreadyInTeamCount);
Map<String, Long> counts = playerPreviews.stream()
.collect(Collectors.groupingBy(PlayerPreview::status, Collectors.counting()));
int newCount = counts.getOrDefault("NEW", 0L).intValue();
int existsCount = counts.getOrDefault("EXISTS", 0L).intValue();
int alreadyInTeamCount = counts.getOrDefault("ALREADY_IN_TEAM", 0L).intValue();
Summary summary = new Summary(playerPreviews.size(), newCount, existsCount, alreadyInTeamCount);

Copy link
Contributor

@Jin409 Jin409 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

리뷰가 좀 있어서 한번 끊고 갑시당

@RequiredArgsConstructor
public class NlService {

private final NlGeminiClient nlGeminiClient;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

개인적인 생각이긴 한데, NlClient 로 추상화 하고 gemini 라는 구체적인 맥락은 추상화하는 게 어떨까요?
요금 정책이나.. 여러가지 상황에 따라 어떤 LLM 을 사용하는지는 실제로 상황에 따라 다양하게 달라지지 않을까 싶습니당

클라이언트 뿐만 아니라 응답 자체도 지금 gemini 와 맥락이 깊게 연관돼 있는 것 같은데 한층 추상화 해둬주시면 좋을 것 같아요~!

Comment on lines +63 to +69
if (!geminiResponse.hasFunctionCall()) {
return new NlProcessResponse(
geminiResponse.getText().isEmpty()
? NlErrorMessages.PARSE_FAILED
: geminiResponse.getText(),
null
);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

개인적인 생각이긴한데, 삼항연산자 가독성이 좀 많이 떨어지는 것 같아요.
이정도는 if 문으로 얼마든지 커버 가능하니.. 개선해보심이 어떨까 싶어요.

클린코드에서도 실제로 삼항연산자는 지양하라는 내용이 있어요.

삼항 연산자
기본적으로 클린코드 책에서는 삼항 연산자 사용을 지양한다.
단, 삼항 연산 자체가 null 체크 등 명확할 경우 사용해도 괜찮다.

@@ -0,0 +1,250 @@
package com.sports.server.command.nl.application;
Copy link
Contributor

@Jin409 Jin409 Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  ---                                                                                                                                                                                                                              
  전체 구조 개요                                                                                                                                                                                                                   
   
  2단계 API로 구성됩니다. process(미리보기) → execute(실제 등록) 순서로 호출합니다.                                                                                                                                                
                                                                                                                                                                                                                                 
  ---
  1단계: POST /nl/process — 미리보기

  요청 형태:
  {
    "leagueId": 186,
    "teamId": 1,
    "history": [],          // 이전 대화 히스토리 (multi-turn 지원)
    "message": "홍길동 202600001 10\n김철수 202600002 7"
  }

  흐름:

  [클라이언트] 자연어 텍스트 전송
        ↓
  [NlController.process()]
        ↓
  [NlService.process()]
      ① 권한 검증: league 멤버인지, team이 해당 league 소속인지 확인
        ↓
      ② NlGeminiClient.parsePlayers(message, history) 호출
           - Gemini API에 System Prompt + 대화 히스토리 + 현재 메시지 전송
           - Function Calling 모드로 강제 호출 (mode: "ANY")
           - Gemini가 parse_players({players: [...]}) 형태로 응답
        ↓
      ③ Gemini가 Function Call을 안 했으면 → 텍스트 메시지 그대로 반환
           (예: "선수 정보를 입력해주세요.")
        ↓
      ④ 원본 텍스트에서 9자리 숫자 직접 추출 (정규식)
           → Gemini가 학번을 "보정"하는 hallucination 방지용 대조 집합
        ↓
      ⑤ 파싱된 선수 목록 순회하며 각각 상태 분류:
           - 원본에 없는 학번 / 9자리 아님    → parseFailedLines에 추가
           - 입력 내 중복 학번               → DUPLICATE_IN_INPUT
           - DB에 없음                       → NEW
           - DB에 있지만 팀에 없음            → EXISTS (existingPlayerId 포함)
           - DB에 있고 이미 팀 소속           → ALREADY_IN_TEAM
        ↓
      ⑥ 미리보기 응답 반환

  응답 형태:
  {
    "displayMessage": "정치외교학과 DPS에 2명의 선수를 등록합니다. 확인해주세요.",
    "preview": {
      "type": "REGISTER_PLAYERS_BULK",
      "teamId": 1,
      "teamName": "정치외교학과 DPS",
      "players": [
        { "name": "홍길동", "studentNumber": "202600001", "jerseyNumber": 10, "status": "NEW", "existingPlayerId": null },
        { "name": "김철수", "studentNumber": "202600002", "jerseyNumber": 7,  "status": "EXISTS", "existingPlayerId": 42 }
      ],
      "summary": { "total": 2, "newPlayers": 1, "existingPlayers": 1, "alreadyInTeam": 0 },
      "parseFailedLines": []
    }
  }

  ---
  2단계: POST /nl/execute — 실제 등록

  클라이언트가 미리보기를 사용자에게 보여주고, 확인 후 이 API를 호출합니다.

  요청 형태:
  {
    "leagueId": 186,
    "teamId": 1,
    "players": [
      { "name": "홍길동", "studentNumber": "202600001", "jerseyNumber": 10 },
      { "name": "김철수", "studentNumber": "202600002", "jerseyNumber": 7 }
    ]
  }

  흐름:

  [클라이언트] 확인된 선수 목록 전송
        ↓
  [NlService.execute()]
      ① 동일한 권한 검증
        ↓
      ② 학번 목록으로 DB 일괄 조회 (N+1 방지)
        ↓
      ③ 선수 목록 순회:
           - 중복 학번 → skipped++
           - DB에 없음  → PlayerService.register() 로 신규 생성 → created++
           - DB에 있고 이미 팀 소속 → skipped++
           - DB에 있고 팀 미소속  → 기존 ID 사용
           → teamPlayerRegisters 리스트에 (playerId, jerseyNumber) 추가 → assigned++
        ↓
      ④ TeamService.addPlayersToTeam() 으로 일괄 팀 배정
        ↓
      ⑤ 결과 반환

  응답 형태:
  {
    "displayMessage": "정치외교학과 DPS에 2명의 선수가 등록되었습니다.",
    "result": { "created": 1, "assigned": 2, "skipped": 0 }
  }

  ---
  Gemini 연동 핵심 포인트

  NlGeminiClient가 Gemini API와 통신하는 방식입니다.

  요청 구조:
    systemInstruction: "너는 선수 등록 어시스턴트야..." (고정)
    contents: [ ...history, { role: "user", parts: [message] } ]
    tools: [ parse_players 함수 스키마 ]
    toolConfig: { mode: "ANY" }  ← 반드시 함수 호출하도록 강제

  응답 구조 (GeminiFunctionCallResponse):
    candidates[0].content.parts[0].functionCall
      .name: "parse_players"
      .args: { players: [ {name, studentNumber, jerseyNumber}, ... ] }

  mode: "ANY" 덕분에 Gemini는 항상 parse_players를 호출해야 합니다. 만약 텍스트로만 응답하면 hasFunctionCall() == false가 되어 에러 메시지를 반환합니다.

  ---
  학번 원본 대조 검증의 역할

  입력: "홍길동 20260001 10"   ← 8자리 (오타)
                                 ↓
  Gemini가 "202600001"로 보정해서 반환할 수 있음 (hallucination)
                                 ↓
  원본에서 9자리 숫자 추출 → {}  (비어있음)
                                 ↓
  "202600001" ∉ 원본 집합 → parseFailedLines에 추가

  LLM이 임의로 학번을 만들어내는 것을 원본 텍스트와 대조해서 차단하는 안전장치입니다.

이런 식으로 플로우를 정리해서 문서화의 측면에서.. PR 본문에 남겨주심 좋을 것 같아요

@@ -0,0 +1,250 @@
package com.sports.server.command.nl.application;

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

전체적으로 메서드 하나가 너무 길고 메서드별로 분리가 안 돼 있어서 가독성이 너무 떨어져요 ㅠㅠ 신경 써주심 좋을 것 같아요!

Map<String, Object> args = geminiResponse.getFunctionCall().args();
@SuppressWarnings("unchecked")
List<Map<String, Object>> parsedPlayers = (List<Map<String, Object>>) args.get("players");

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이거 warning 을 supress 하도록 하지 말고, Map, Object 를 묶어서 적절한 dto 로 매핑해서 타입 안정성을 지켜보심이 어떨까 싶어요!

예시..

 GeminiFunctionCallArgs args = geminiResponse.getArgsAs(objectMapper, GeminiFunctionCallArgs.class);
  List<GeminiFunctionCallArgs.ParsedPlayer> parsedPlayers = args.players();

  // 필드 접근도 타입 안전
  String name = parsed.name();
  String studentNumber = parsed.studentNumber();
  Integer jerseyNumber = parsed.jerseyNumber();  // Jackson이 Integer로 바로 바인딩

@@ -0,0 +1,121 @@
package com.sports.server.command.nl.infra;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

테스트에서 프린트문보다는 assert문을 활용해보는 게 어떨까요~
디버깅 할 때 이외에는 프린트문을 제거해주시면 좋을 것 같아요!

Comment on lines +23 to +43
너는 스포츠 리그 관리 시스템의 선수 등록 어시스턴트야.
사용자가 입력한 텍스트에서 선수 정보를 추출하는 것이 너의 역할이야.

각 선수에 대해 다음 정보를 추출해:
- name: 선수 이름 (한글)
- studentNumber: 학번 (정확히 9자리 숫자)
- jerseyNumber: 등번호 (1~99 사이 숫자, 없으면 생략)

입력 텍스트는 다양한 형태일 수 있어:
- 공백/탭/쉼표로 구분된 형태
- 괄호나 슬래시가 포함된 형태
- 순서가 뒤바뀐 형태 (학번이 먼저 올 수도 있음)
- 등번호가 없는 경우도 있음

중요 규칙:
- studentNumber는 반드시 원본 텍스트에 존재하는 9자리 연속 숫자여야 해
- 9자리가 아닌 숫자를 학번으로 추측하거나 보정하지 마
- 빈 줄이나 의미 없는 텍스트는 무시해
- 파싱할 수 있는 선수 정보만 추출해

반드시 parse_players 함수를 호출해서 응답해.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

요론 것들도 환경변수로 빼봅시다~
그렇게 되면 매번 코드 상의 수정을 거치지 않아도 프롬프트를 깎을 수 있다는 장점이 있어요

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이거 빼서 be-config에 pr 올려뒀습니당

@@ -0,0 +1,395 @@
package com.sports.server.command.nl.application;
Copy link
Contributor

@Jin409 Jin409 Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • 실제로 gemini client 호출이 잘 되는지
  • 자연어 기반의 요청을 어라만 이해하는지
  • DB 에 정확히 잘 반영하는지

지금 테스트들이 전체적으로 모킹하는 방식이라, 위의 세 가지에 대한 검증을 장담할 수 없을 것 같아요.

  • 실제로 API 호출하는 코드를 작성하고 @Disabled 를 붙여서 매번 돌지 않게 하거나,
  • 실제로 API 요청 해보시고 관련된 요청 / 응답값을 공유해주시면 좋을 것 같아요.

저는 전자의 방식을 더 선호하는데요, 코드를 읽는 사람 입장에서도 이 기능이 어떤 식으로 요청이 오가고 동작하는지 더 빠르게 이해할 수 있을 것 같아요. 빈약하지만 일종의 예시..

Copy link
Contributor Author

@TAEW00KIM TAEW00KIM Mar 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Disabled로 ManualTest 작성해뒀습니다! 추가로 pr 본문에 응답/요청 예시도 적어뒀어요

@TAEW00KIM TAEW00KIM force-pushed the feature/nl-bulk-player-register branch from 43f2779 to caeed00 Compare March 7, 2026 08:37
Copy link
Contributor

@Jin409 Jin409 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 코멘트들만 개선해주시면 한번 머지해보고 더 프롬프트 깎아 봐요..!

}

@Transactional
public NlExecuteResponse execute(NlExecuteRequest request, Member member) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

execute 쪽도 메서드 분리가 필요해 보여요~!

return new NlExecuteResponse(displayMessage, new NlExecuteResponse.Result(created, assigned, skipped));
}

private NlProcessResponse buildPreview(NlProcessRequest request, Team team, List<ParsedPlayer> parsedPlayers) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

여기도 내부에 메서드 더 쪼갤 수 있어 보여요

@TAEW00KIM TAEW00KIM merged commit 4031134 into main Mar 7, 2026
1 check passed
@TAEW00KIM TAEW00KIM deleted the feature/nl-bulk-player-register branch March 7, 2026 14:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants