Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@ SecurityFilterChain filterChain(HttpSecurity http, MvcRequestMatcher.Builder mvc
mvc.pattern(HttpMethod.POST, "/players"),
mvc.pattern(HttpMethod.PATCH, "/players/{playerId}"),
mvc.pattern(HttpMethod.DELETE, "/players/{playerId}"),
mvc.pattern(HttpMethod.PATCH, "/cheer-talks/**")
mvc.pattern(HttpMethod.PATCH, "/cheer-talks/**"),
mvc.pattern(HttpMethod.POST, "/nl/**")
)
.authenticated()
.anyRequest().permitAll()
Expand Down
250 changes: 250 additions & 0 deletions src/main/java/com/sports/server/command/nl/application/NlService.java
Original file line number Diff line number Diff line change
@@ -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 본문에 남겨주심 좋을 것 같아요


Copy link
Contributor

Choose a reason for hiding this comment

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

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

import com.sports.server.command.league.domain.League;
import com.sports.server.command.league.domain.LeagueTeamRepository;
import com.sports.server.command.member.domain.Member;
import com.sports.server.command.nl.dto.NlExecuteRequest;
import com.sports.server.command.nl.dto.NlExecuteResponse;
import com.sports.server.command.nl.dto.NlProcessRequest;
import com.sports.server.command.nl.dto.NlProcessResponse;
import com.sports.server.command.nl.dto.NlProcessResponse.*;
import com.sports.server.command.nl.domain.PlayerStatus;
import com.sports.server.command.nl.infra.GeminiFunctionCallResponse;
import com.sports.server.command.nl.infra.NlGeminiClient;
import com.sports.server.command.player.domain.Player;
import com.sports.server.command.player.domain.PlayerRepository;
import com.sports.server.command.player.dto.PlayerRequest;
import com.sports.server.command.team.domain.Team;
import com.sports.server.command.team.domain.TeamPlayerRepository;
import com.sports.server.command.team.dto.TeamRequest;
import com.sports.server.command.team.application.TeamService;
import com.sports.server.command.player.application.PlayerService;
import com.sports.server.command.nl.exception.NlErrorMessages;
import com.sports.server.common.application.EntityUtils;
import com.sports.server.common.application.PermissionValidator;
import com.sports.server.common.exception.BadRequestException;
import com.sports.server.common.util.StudentNumber;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;


@Service
@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 와 맥락이 깊게 연관돼 있는 것 같은데 한층 추상화 해둬주시면 좋을 것 같아요~!

private final PlayerRepository playerRepository;
private final TeamPlayerRepository teamPlayerRepository;
private final LeagueTeamRepository leagueTeamRepository;
private final EntityUtils entityUtils;
private final PlayerService playerService;
private final TeamService teamService;

private static final Pattern NINE_DIGIT_PATTERN = Pattern.compile("(?<!\\d)\\d{9}(?!\\d)");

@Transactional(readOnly = true)
public NlProcessResponse process(NlProcessRequest request, Member member) {
League league = entityUtils.getEntity(request.leagueId(), League.class);
PermissionValidator.checkPermission(league, member);
Team team = entityUtils.getEntity(request.teamId(), Team.class);
validateTeamBelongsToLeague(league, team);

// 1. Gemini Function Calling으로 텍스트 파싱
GeminiFunctionCallResponse geminiResponse = nlGeminiClient.parsePlayers(
request.message(), request.history()
);

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 체크 등 명확할 경우 사용해도 괜찮다.

}

// 2. Function Call 결과에서 선수 목록 추출
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로 바로 바인딩

if (parsedPlayers == null || parsedPlayers.isEmpty()) {
return new NlProcessResponse(NlErrorMessages.NO_PLAYER_INFO, null);
}

// 3. 원본 텍스트에서 9자리 숫자 추출 (학번 대조 검증용)
Set<String> originalNineDigits = extractNineDigitNumbers(request.message());

// 4. 각 선수 검증 + status 분류
List<PlayerPreview> playerPreviews = new ArrayList<>();
List<FailedLine> failedLines = new ArrayList<>();
Set<String> seenStudentNumbers = new HashSet<>();

// 팀에 이미 소속된 선수 ID 조회
List<Long> existingPlayerIdsInTeam = teamPlayerRepository.findPlayerIdsByTeamId(request.teamId());
Set<Long> teamPlayerIdSet = new HashSet<>(existingPlayerIdsInTeam);

// 파싱된 학번으로 DB 일괄 조회
List<String> parsedStudentNumbers = parsedPlayers.stream()
.map(p -> (String) p.get("studentNumber"))
.filter(Objects::nonNull)
.toList();
Map<String, Player> existingPlayerMap = playerRepository.findByStudentNumberIn(parsedStudentNumbers)
.stream()
.collect(Collectors.toMap(Player::getStudentNumber, p -> p));

for (int i = 0; i < parsedPlayers.size(); i++) {
Map<String, Object> parsed = parsedPlayers.get(i);
String name = (String) parsed.get("name");
String studentNumber = (String) parsed.get("studentNumber");
Integer jerseyNumber = parsed.get("jerseyNumber") instanceof Number n ? n.intValue() : null;

// 학번 형식 + 원본 대조 검증
if (StudentNumber.isInvalid(studentNumber) || !originalNineDigits.contains(studentNumber)) {
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.

continue;
}

// 입력 내 중복 체크
if (seenStudentNumbers.contains(studentNumber)) {
playerPreviews.add(new PlayerPreview(name, studentNumber, jerseyNumber, PlayerStatus.DUPLICATE_IN_INPUT, null));
continue;
}
seenStudentNumbers.add(studentNumber);

// DB 검증
Player existingPlayer = existingPlayerMap.get(studentNumber);
if (existingPlayer == null) {
playerPreviews.add(new PlayerPreview(name, studentNumber, jerseyNumber, PlayerStatus.NEW, null));
} else if (teamPlayerIdSet.contains(existingPlayer.getId())) {
playerPreviews.add(new PlayerPreview(name, studentNumber, jerseyNumber, PlayerStatus.ALREADY_IN_TEAM, existingPlayer.getId()));
} else {
playerPreviews.add(new PlayerPreview(name, studentNumber, jerseyNumber, PlayerStatus.EXISTS, existingPlayer.getId()));
}
}

// 5. Summary 생성
int newCount = (int) playerPreviews.stream().filter(p -> p.status() == PlayerStatus.NEW).count();
int existsCount = (int) playerPreviews.stream().filter(p -> p.status() == PlayerStatus.EXISTS).count();
int alreadyInTeamCount = (int) playerPreviews.stream().filter(p -> p.status() == PlayerStatus.ALREADY_IN_TEAM).count();

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

String teamName = team.getName();
String displayMessage = String.format("%s에 %d명의 선수를 등록합니다. 확인해주세요.", teamName, newCount + existsCount);

Preview preview = new Preview(
"REGISTER_PLAYERS_BULK",
request.teamId(),
teamName,
playerPreviews,
summary,
failedLines
);

return new NlProcessResponse(displayMessage, preview);
}

@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 쪽도 메서드 분리가 필요해 보여요~!

League league = entityUtils.getEntity(request.leagueId(), League.class);
PermissionValidator.checkPermission(league, member);
Team team = entityUtils.getEntity(request.teamId(), Team.class);
validateTeamBelongsToLeague(league, team);

Set<Long> teamPlayerIdSet = new HashSet<>(
teamPlayerRepository.findPlayerIdsByTeamId(request.teamId())
);

// 학번으로 기존 선수 일괄 조회 (N+1 방지)
List<String> studentNumbers = request.players().stream()
.map(NlExecuteRequest.PlayerData::studentNumber)
.toList();
Map<String, Player> existingPlayerMap = playerRepository.findByStudentNumberIn(studentNumbers)
.stream()
.collect(Collectors.toMap(Player::getStudentNumber, p -> p));

int created = 0;
int assigned = 0;
int skipped = 0;

Set<String> seenStudentNumbers = new HashSet<>();
Set<Long> assignedPlayerIds = new HashSet<>();
List<TeamRequest.TeamPlayerRegister> teamPlayerRegisters = new ArrayList<>();

for (NlExecuteRequest.PlayerData playerData : request.players()) {
// 학번 중복 입력 방지
if (!seenStudentNumbers.add(playerData.studentNumber())) {
skipped++;
continue;
}

Player existingPlayer = existingPlayerMap.get(playerData.studentNumber());

Long playerId;
if (existingPlayer != null) {
playerId = existingPlayer.getId();

// 이미 팀에 소속된 선수는 스킵
if (teamPlayerIdSet.contains(playerId)) {
skipped++;
continue;
}
} else {
// 신규 선수 생성
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.

created++;
}

// 중복 playerId 방지
if (!assignedPlayerIds.add(playerId)) {
skipped++;
continue;
}

teamPlayerRegisters.add(new TeamRequest.TeamPlayerRegister(
playerId,
playerData.jerseyNumber()
));
assigned++;
}

// 팀에 선수 일괄 배정
if (!teamPlayerRegisters.isEmpty()) {
teamService.addPlayersToTeam(request.teamId(), teamPlayerRegisters);
}

String teamName = team.getName();
String displayMessage = String.format("%s에 %d명의 선수가 등록되었습니다.", teamName, assigned);

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

private void validateTeamBelongsToLeague(League league, Team team) {
leagueTeamRepository.findByLeagueAndTeam(league, team)
.orElseThrow(() -> new BadRequestException(NlErrorMessages.TEAM_NOT_IN_LEAGUE));
}

private Set<String> extractNineDigitNumbers(String text) {
Set<String> numbers = new HashSet<>();
Matcher matcher = NINE_DIGIT_PATTERN.matcher(text);
while (matcher.find()) {
numbers.add(matcher.group());
}
return numbers;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.sports.server.command.nl.domain;

public enum PlayerStatus {
NEW,
EXISTS,
ALREADY_IN_TEAM,
DUPLICATE_IN_INPUT
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.sports.server.command.nl.dto;

import java.util.List;

public record NlExecuteRequest(
Long leagueId,
Long teamId,
List<PlayerData> players
) {
public record PlayerData(
String name,
String studentNumber,
Integer jerseyNumber
) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.sports.server.command.nl.dto;

public record NlExecuteResponse(
String displayMessage,
Result result
) {
public record Result(
int created,
int assigned,
int skipped
) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.sports.server.command.nl.dto;

import java.util.List;
import java.util.Map;

public record NlProcessRequest(
Long leagueId,
Long teamId,
List<Map<String, String>> history,
String message
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package com.sports.server.command.nl.dto;

import com.sports.server.command.nl.domain.PlayerStatus;

import java.util.List;

public record NlProcessResponse(
String displayMessage,
Preview preview
) {
public record Preview(
String type,
Long teamId,
String teamName,
List<PlayerPreview> players,
Summary summary,
List<FailedLine> parseFailedLines
) {
}

public record PlayerPreview(
String name,
String studentNumber,
Integer jerseyNumber,
PlayerStatus status,
Long existingPlayerId
) {
}

public record Summary(
int total,
int newPlayers,
int existingPlayers,
int alreadyInTeam
) {
}

public record FailedLine(
int index,
String text,
String reason
) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.sports.server.command.nl.exception;

public class NlErrorMessages {
public static final String TEAM_NOT_IN_LEAGUE = "해당 팀은 이 리그에 소속되어 있지 않습니다.";
public static final String PARSE_FAILED = "선수 정보를 파싱할 수 없습니다. 이름과 학번(9자리)을 포함하여 다시 입력해주세요.";
public static final String NO_PLAYER_INFO = "선수 정보를 찾을 수 없습니다. 다시 입력해주세요.";
public static final String STUDENT_NUMBER_INVALID = "학번이 9자리가 아닙니다";
public static final String STUDENT_NUMBER_NOT_IN_ORIGINAL = "원본 텍스트에서 해당 학번을 찾을 수 없습니다";
}
Loading