Post

6vs6 조합 추론과 코드 구조 개선

6vs6 조합 추론과 코드 구조 개선

오버워치2가 6vs6으로 패치되면서 맞지 않게 된 로직을 고치는 김에 전체적으로 리팩토링을 진행했다.


  • 챗봇 로직이 담긴 파일을 계층별 모듈로 분리

  • 스탯창 인원수(5vs5 / 6vs6) 자동 판별

  • 6vs6 조합 추론 (되묻는 대신 먼저 답하고 정정받기)

  • 자잘한 버그 수정과 기능 개선







계층 분리

챗봇의 핵심 로직은 chatbot_graph.py 한 파일에 전부 몰려 있었다.

상수, 영웅 사전, 규칙 기반 분류, 캐시 응답, 노드, 프롬프트까지 한 파일에 들어 있었고, 함수 하나하나도 내부 로직이 길었다.

그래서 로직을 계층별로 쪼갠 뒤, 파일들을 주제별 폴더로 다시 묶었다.


1
2
3
4
5
6
7
chat/
├── domain/   영웅 사전, 규칙 기반 분류, 프롬프트 (순수 파이썬)
├── graph/    LangGraph 파이프라인 (노드들)
├── rag/      검색과 LLM
├── vision/   스탯창 이미지 분석
├── management/  터미널 명령
├── tests/    주제별 테스트


폴더를 나눌 때 정한 규칙은 하나다. import는 항상 아래 계층에서 위 계층으로만 흐른다.

domainrag가 제일 아래고, 그 위에 graph, 맨 위에 views가 있다.

graphdomain을 가져다 쓰지만, domaingraph를 모른다.

이렇게 방향을 한쪽으로만 두면 서로가 서로를 부르는 순환 import가 생기지 않는다.


대신 바깥(views)에서 노드나 규칙을 하나하나 가져다 쓰려면 내부 경로를 전부 알아야 한다.

그래서 pipeline.py에 주요 이름을 다시 내보내는(re-export) 창구를 뒀다.

1
2
3
4
5
6
7
8
9
10
11
# chat/graph/pipeline.py
# 파이프라인 바깥(views 등)이 내부 모듈 경로를 몰라도 되게 한곳으로 모아준다
from chat.domain.answer_format import (
    sanitize_answer_for_user,
    extract_inline_suggested_questions,
)
from chat.graph.canned import (
    CANNED_COUNTER_HERO,
    CANNED_MAP_NAME,
    # ...
)


viewspipeline 하나만 알면 된다.

import는 전부 절대 경로(from chat.domain.heroes import ...)로 통일했다.

상대 경로(from ..domain import)로 두면 이 파일이 지금 어느 계층에 있는지 한눈에 들어오지 않기 때문이다.






흩어진 프롬프트 통합

세 곳에 똑같은 프롬프트가 복붙돼 있었다.

스탯창 카드 피드백 두 곳(scoreboard.py)과 채팅 답변 한 곳(nodes_answer.py)이

“메르시는 피해량을 다른 힐러와 비교하지 않는다” 같은 판단 기준을 각자 들고 있었다.

한 곳만 고치면 나머지가 어긋나서, 같은 판인데도 카드와 채팅이 서로 다른 기준으로 지적하는 일이 생겼다.

지금까지는 문서에 “세 곳 모두 고칠 것”이라고 적어두고 사람이 직접 챙기는 방식이었다.


이번에 판단 기준을 prompts.py 한 곳에 모으고, 세 프롬프트가 이 함수를 조립해 쓰도록 바꿨다.

1
2
3
4
5
6
7
8
9
10
11
# chat/domain/prompts.py — 판단 기준은 여기서만 관리한다
def stat_judgement_rules(*, include_peer_comparison: bool = True) -> str:
    rules = [HERO_KIT_AWARENESS_RULE]          # 영웅 킷으로 먼저 해석
    if include_peer_comparison:
        rules.append(PEER_COMPARISON_RULE)     # 상대팀 같은 역할과 비교
    rules.extend([
        MITIGATION_KIT_RULE,                   # 경감량은 킷마다 다르다
        RESOURCE_HEALER_RULE,                  # 자원 관리형 힐러
        HERO_TRAIT_PHRASING_RULE,              # "~인 영웅임에도" 금지
    ])
    return "\n".join(rules)

이제 기준을 바꾸려면 이 파일만 고치면 된다.






스탯창 인원수 자동 판별

오버워치2가 5vs5에서 6vs6으로 바뀌면서 스탯창 이미지도 한 팀이 5명일 수도, 6명일 수도 있게 됐다.

이전에는 한 팀을 무조건 5명으로 보고 이미지를 5칸으로 잘랐다.

그런데 6명짜리 스탯창을 5칸으로 나누면 한 칸에 서로 다른 두 선수가 반씩 걸쳐 들어가서 인식이 전부 틀어졌다.

그래서 이미지를 올리면 그 팀이 5명인지 6명인지부터 판별하도록 바꿨다.


스탯창은 영웅마다 가로로 한 줄씩 차지하고, 줄과 줄 사이에는 어두운 구분선이 있다.

5명일 때와 6명일 때 각각 줄 높이를 가정해두고, 그 높이로 봤을 때 구분선이 규칙적으로 반복되는지를 확인한다.

실제 인원수와 맞는 쪽이 규칙성이 훨씬 뚜렷하게 나온다.

상대팀(빨강)이 우리팀(파랑)보다 화면이 깔끔해서, 상대팀을 먼저 보고 애매하면 우리팀 쪽으로 보완한다.

1
2
3
4
5
6
for team in ("enemy", "ally"):        # 상대팀(빨강)을 먼저 본다
    scores = _estimate_roster_size_for_team(...)  # 5인/6인 각각의 규칙성 점수
    n = _pick(scores)                 # 우열이 뚜렷할 때만 확정
    if n is not None:
        return n, diag
return DEFAULT_PLAYERS_PER_TEAM, diag  # 끝까지 애매하면 기본값(5)


인원수를 정하고 나면 역할(탱커 / 딜러 / 힐러)을 나눈다.

힐러는 항상 맨 아래 두 줄이고, 그 위가 탱커와 딜러다.

탱커 수는 5인이면 1명, 6인이면 1~2명이라는 규칙으로 판별 결과가 맞는지 검증한다.

1
2
3
4
5
6
7
8
combat_labels = role_labels[: n - 2]   # 앞쪽 = 탱커/딜러
support_labels = role_labels[n - 2:]   # 마지막 두 줄 = 힐러

tank_count = combat_labels.count("tank")
if n == 5 and tank_count != 1:
    return False, "5인인데 탱커가 1명이 아님"
if n == 6 and tank_count not in (1, 2):
    return False, "6인인데 탱커가 1~2명이 아님"


인원수를 코드에 5나 6으로 박아두지 않은 건 다음 패치에서 또 바뀔 수 있어서다.

이미지마다 판별하게 해두면 규격이 또 바뀌어도 코드를 고칠 필요가 없다.






6vs6 조합 추론

먼저 비슷해서 헷갈리는 용어 두 개를 구분해야 한다.

  • 역할 좁히기(추론) : 사용자가 자기 역할을 말하지 않아도, 말해준 아군 조합을 보고

    “그럼 이 사람은 무슨 역할이겠다”를 추론해 추천 범위를 줄이는 것. 질문을 받을 때(입력) 하는 일이다.

  • 역할 고정 : 답변을 내보낼 때, 사용자 역할이 아닌 영웅 이름이 답에 섞이면 걸러내는 것. 답을 내보낼 때(출력) 하는 일이다.


이번에 수정한 부분은 역할 좁히기다.




남은 자리가 하나로 정해지지 않는다

예전 로직은 “탱1 / 딜2 / 힐2 중 남은 한 자리”를 찾는 방식이었다.

“우리팀 라인하르트·아나·루시우인데 난 뭐 할까?”라고 물으면, 힐러 2명이 이미 찼으니 사용자는 탱커 아니면 딜러, 이런 식이다.

그런데 6vs6은 탱커 1~2 / 딜러 2~3 / 힐러 2라, 남은 자리가 하나로 정해지지 않는 경우가 생긴다.

탱커가 한 명 더 갈지 딜러가 한 명 더 갈지가 판마다 다르기 때문이다.


그래서 “남은 한 자리”를 확정하는 대신, 사용자가 각 역할이라고 가정해보고 정원에 맞는 역할만 후보로 남긴다.

1
2
3
4
5
6
7
8
9
candidate_roles = []
for role in ROLE_HEROES:                 # 탱커 / 딜러 / 힐러 각각
    assumed = dict(counts)
    assumed[role] += 1                    # 사용자가 이 역할이라고 가정
    if not _fits_roster(assumed, roster_size):   # 정원을 넘으면 제외
        continue
    if not _can_complete(assumed, unknown_teammates, roster_size):
        continue
    candidate_roles.append(role)

힐러 2명을 이미 말했으면 힐러 자리는 꽉 차서 후보에서 빠지고, 탱커와 딜러만 남는 식이다.


인원수는 추측하지 않기로 했다.

기본은 항상 6vs6이고, 5vs5는 사용자가 “5대5야”라고 말하거나 답변 아래 5대5예요 버튼을 눌렀을 때만 적용한다.

예전에 쓰던 “아군이 4명이면 5vs5”라는 추측은 버렸다.

6vs6에서도 4명만 말하는 경우가 흔한데, 그걸 5vs5로 단정하면 남은 자리를 하나로 잘못 확정하기 때문이다.




되묻는 대신 먼저 답하고 정정받기

처음엔 후보 역할이 애매하면 “전체 / 탱커 / 딜러 / 힐러” 버튼으로 되물었다.

그런데 조합 질문은 개인 코칭이 아니라 조합 상담이다.

빈자리가 사용자 본인 자리인지 팀원 자리인지는 답의 내용을 바꾸지 않는다.

그걸 알아내려고 되묻는 건 사용자만 번거롭게 하는 셈이었다.


그래서 되묻는 방식을 걷어내고, 일단 답한 뒤 나중에 좁히는 구조로 바꿨다.

판단 근거를 답변에 한 줄 적고, 버튼이나 말 한마디로 바로 정정할 수 있게 했다.

후보 역할 수에 따라 답변 범위가 갈린다.


후보 역할 수답변예시
1개그 역할로 추천딜러 3 + 힐러 2 나열 → 탱커
2개두 역할을 섞어서 추천탱1 / 힐2 나열 → 탱커+딜러
3개세 역할 전부, 역할당 1~2명탱1 / 딜1 / 힐1 나열


후보가 2개일 때는 역할 필터가 tank+damage처럼 두 역할을 한 번에 담을 수 있게 했다.

1
2
3
4
5
def make_role_filter(roles):
    ordered = [r for r in ROLE_HEROES if r in set(roles)]
    if len(ordered) == len(ROLE_HEROES):   # 셋 다 후보면 전체
        return "all"
    return "+".join(ordered)               # 예: "tank+damage"

이 필터일 때 답변은 두 역할 영웅을 골고루 섞어서 추천하고, 각 추천이 어느 역할인지 밝힌다.





조합 정보의 5분 규칙

말해준 지 5분이 지난 아군 조합은 역할 추론에 쓰지 않는다.

조합 정보 자체를 지우는 건 아니고, 추론 용도로만 쓰지 않는다.


같은 정보라도 틀렸을 때 손해 크기가 다르기 때문이다.

역할 추론에 썼는데 틀리면 딜러인 사람에게 힐러만 추천하게 되어 답 전체가 쓸모없어진다.

반면 답변 참고 자료로만 썼다면 “라인하르트와 호흡을 맞춰서…” 같은 문장 하나가 어긋날 뿐이다.


5분이 지났다는 건 판이 끝나고 새 판일 수 있다는 뜻이다.

그런데 텍스트만 봐서는 “아까 그 판의 연장”인지 “새 판”인지 구분할 방법이 없다.

상대가 픽을 바꾼 것과, 새 판에서 다른 상대를 만난 것은 문장이 똑같기 때문이다.

그래서 구분하려고 애쓰는 대신, 마지막으로 조합을 말한 시각을 기록해두고 5분이 지났는지만 본다.

1
2
3
4
5
6
7
ROLE_NARROWING_MAX_AGE_SECONDS = 5 * 60

ally_comp_fresh = bool(
    ally_team
    and ally_team_ts
    and (now_ts - ally_team_ts) <= ROLE_NARROWING_MAX_AGE_SECONDS
)

ally_comp_fresh가 참일 때만 역할 추론에 쓰고, 아니면 세 역할 전부로 답한다.


아군 조합을 마지막으로 말한 시점역할 추론에 사용답변 참고 자료로 사용
이번 질문 / 5분 이내OO
5분 초과X (세 역할 전부로 답변)O




답변 아래 정정 장치

되묻지 않는 대신 답변 끝에 정정 장치를 붙였다.

  • 판단 근거 한 줄 : “힐러는 이미 명시되어 탱커/딜러 기준으로 추천드립니다.”

  • 역할 버튼 : 후보가 2개 이상일 때만, 후보 역할만 보여준다(“전체” 없음).

  • 5대5예요 버튼 : 5vs5 정원상 불가능한 상황이면 숨긴다.





5대5예요 버튼을 언제 숨길지는 아군 조합만 봐도 알 수 있다.

아군을 5명 이상 말했거나(사용자까지 6명이라 5vs5 불가), 탱커가 2명 이상이거나, 딜러가 3명 이상이면 이미 5vs5 정원을 넘은 것이다.

1
2
3
4
5
def can_be_five_vs_five(ally_heroes):
    counts = count_roles(ally_heroes)
    if len(ally_heroes) >= 5:      # 이미 5명 이상 → 5vs5 불가
        return False
    return _fits_roster(counts, 5)  # 탱2·딜3 등 정원 초과면 False


말로도 정정할 수 있다.

“나는 힐러야”, “5대5야”, “탱커랑 딜러로만 알려줘” 같은 식이다.

단, “나는 힐러야”처럼 자기를 가리키는 표현만 인정한다. “상대 힐러 때문에 힘들어”가 잡히면 안 되기 때문이다.


프런트에서는 새 메시지가 오면 이전 답변의 버튼을 전부 비활성화해서, 항상 마지막 답변의 버튼만 살아 있게 했다.

1
2
3
4
5
6
7
function disableOldChoiceButtons() {
  // 새 메시지가 오면 이전 답변에 붙어 있던 버튼을 전부 비활성화한다
  messages.querySelectorAll('.suggest-btn').forEach(btn => {
    btn.disabled = true;
    btn.classList.add('suggest-btn-used');
  });
}

옛날 답변에서 눌린 버튼이 지금 질문에 잘못 적용되는 걸 막기 위해서다.






버그 수정

솔저76이 답변에서 “다른 영웅”으로 바뀌던 문제

딜러 역할로 답하는 답변에서, 본문에 나온 “솔저76”이 “다른 영웅”으로 바뀌어 나갔다.

원인은 영웅 목록 두 개가 서로 다른 표기를 쓰고 있어서였다.

한쪽에는 솔저76, 다른 쪽에는 솔저: 76으로 들어 있었다.

표기가 다르니 시스템이 둘을 같은 영웅으로 알아보지 못했다.


챗봇은 답을 내보내기 전에, 사용자 역할이 아닌 영웅 이름을 “다른 영웅”으로 치환한다.

다른 영웅으로 갈아타라는 추천이 새어 나가는 걸 막는 안전장치다.

그런데 솔저76이 역할 목록에서 잡히지 않으니, 딜러인데도 딜러가 아닌 걸로 취급되었다.


수정 자체는 표기를 솔저76 하나로 통일하는 걸로 끝났다.

여기에 더해, 서버가 켜질 때 두 목록이 같은 영웅을 같은 표기로 담고 있는지 검사하는 코드를 넣었다.

1
2
3
4
5
6
7
8
9
10
11
12
13
def _validate_hero_tables():
    # HEROES와 ROLE_HEROES가 어긋나면 어떤 영웅이 문제인지 기록한다
    problems = []
    if role_set - hero_set:
        problems.append(f"ROLE_HEROES에만 있는 영웅(HEROES 누락): {sorted(role_set - hero_set)}")
    # ...
    return problems

# import 시점에 검사 — 어긋나면 에러 로그(서비스는 죽이지 않는다)
_HERO_TABLE_PROBLEMS = _validate_hero_tables()
if _HERO_TABLE_PROBLEMS:
    for problem in _HERO_TABLE_PROBLEMS:
        logger.error("[HERO TABLE] %s", problem)


내부에서 쓰는 이름은 계속 솔저76을 쓴다.

아이콘 파일명과 카드 이미지 주소가 이 이름을 그대로 쓰기 때문에, 바꾸면 아이콘이 깨진다.

대신 사용자가 입력할 법한 표기를 전부 별칭으로 등록해서 같은 영웅으로 인식되게 했다.

1
2
3
4
5
6
7
HERO_ALIASES = {
    "솔저": "솔저76",
    "솔져": "솔저76",
    "솔저: 76": "솔저76",
    "솔저 76": "솔저76",
    # ...
}




“위도우메이커”를 물으면 “메이”까지 같이 인식되던 문제

“위도우메이커 상대법 알려줘”라고 물으면 인식된 영웅이 위도우메이커와 메이 2명으로 나왔다.

원인은 영웅 이름을 단순히 글자 포함 여부로 찾아서였다.

“위도우메이커” 안에 “메이”가 그대로 들어 있으니 같이 걸린 것이다.


긴 이름부터 찾고, 찾은 자리는 같은 길이로 가린 뒤 다음 이름을 찾는 방식으로 바꿨다.

1
2
3
4
5
6
for name in _HERO_NAMES_LONGEST_FIRST:   # 긴 이름부터 찾는다
    idx = masked.find(name, start)
    # ...
    mentions.append((idx, HERO_NAME_TO_CANONICAL[name]))
    # 찾은 자리를 같은 길이로 덮어서 다음 검사에서 다시 안 걸리게 한다
    masked = masked[:idx] + ("\x00" * len(name)) + masked[idx + len(name):]

위도우메이커를 먼저 잡아 그 자리를 가려버리면, 남은 곳에 메이가 걸릴 데가 없다.




그 밖의 버그

무거운 임베딩 모델(수 GB)이 서버 기동 직후 동시 요청에 두 번 로드될 수 있었다.

락을 걸고, 락을 잡은 뒤에도 이미 만들어졌는지 한 번 더 확인하도록 했다.

1
2
3
4
5
6
7
8
9
def initialize_chatbot():
    if _chatbot is not None and _retriever is not None and _llm is not None:
        return _chatbot, _retriever, _llm   # 이미 있으면 바로 반환

    with _init_lock:
        # 락을 기다리는 사이 다른 스레드가 끝냈을 수 있으니 다시 확인
        if _chatbot is not None and _retriever is not None and _llm is not None:
            return _chatbot, _retriever, _llm
        # ... 여기서 최초 1회만 로드 ...

그리고 “시온”이 영웅 목록 한쪽에서 빠져 있어 문장에서 인식되지 않았다.

목록에 추가했고, 위에서 넣은 검사 코드가 앞으로 이런 누락을 잡아준다.






그 밖의 기능 개선

스탯창 분석 병렬화

스탯창을 분석할 때 Gemini를 세 번 부른다. 숫자 인식 → 팀 피드백 → 개인 피드백 순서다.

이 중 팀 피드백과 개인 피드백은 서로 상관없이 따로 만들 수 있는데(둘 다 숫자 결과만 있으면 된다), 순서대로 하나씩 부르고 있었다.

두 개를 동시에 실행하도록 바꿨더니 스탯창 응답 대기 시간이 줄었다.

1
2
3
4
5
6
with ThreadPoolExecutor(max_workers=2) as executor:
    team_future = executor.submit(_generate_team_feedback, ...)
    personal_future = executor.submit(_generate_personal_feedback, ...)

    team_feedback = team_future.result()
    personal_feedback = personal_future.result()




입력 길이 제한

이미지에는 용량 상한이 있었는데 텍스트에는 상한이 아예 없었다.

수십 KB짜리 문자열도 그대로 프롬프트에 실렸다(비용과 대기 시간이 늘고, 이상한 입력으로 답을 흐릴 위험도 있다).

그래서 채팅은 500자, 피드백은 1,000자를 넘으면 거절하도록 했다.

1
2
3
4
5
6
7
MAX_MESSAGE_LENGTH = 500

if len(message) > MAX_MESSAGE_LENGTH:
    return JsonResponse(
        {"error": f"질문이 너무 깁니다. {MAX_MESSAGE_LENGTH}자 이내로 입력해주세요."},
        status=400,
    )




오래된 로그 정리 명령

대화 로그와 스탯창 디버그 이미지가 계속 쌓이는데, 지금까지는 관리자 화면에서 손으로 지우는 방법밖에 없었다.

1
2
python manage.py cleanup_chatlogs --days 90            # 90일 지난 로그 + 이미지 삭제
python manage.py cleanup_chatlogs --days 90 --dry-run  # 지우지 않고 건수만 확인

DB 로그는 이미 지워졌는데 이미지 폴더만 남은 것도 같이 정리한다.




스탯창 분석에서 Gemini 사용 여부를 환경변수로

스탯창 분석은 두 부분으로 나뉜다.

영웅과 팀을 알아보는 건 OpenCV가, 숫자(킬 / 데스 / 딜량 / 힐량) 읽기와 코치 피드백 문장 만들기는 Gemini가 담당한다.

이 중 Gemini가 맡은 부분만 껐다 켰다 할 수 있는 설정을 뒀다.

끄면 영웅 인식은 그대로 되고, 숫자 칸은 “확인 필요”, 피드백은 고정 안내 문구로 나간다.


바뀐 건 끄는 방법이다.

예전에는 코드에 켜짐으로 박혀 있어서, 끄려면 코드를 고치고 다시 배포해야 했다.

이제 .env 설정에서 읽으므로 서버만 다시 켜면 된다. 값이 없으면 켜진 상태를 유지한다.

1
2
3
ENABLE_GEMINI_STATS_AND_FEEDBACK = os.getenv(
    "ENABLE_GEMINI_STATS_AND_FEEDBACK", "true"
).strip().lower() not in ("0", "false", "no", "off")

인식 품질이 갑자기 나빠지거나 Gemini 비용/장애 문제가 생겼을 때 꺼두기 위한 것이다.






정리

이번 작업을 표로 정리하면 이렇다.


분류내용
리팩토링챗봇 로직 파일을 계층별 모듈로 분리, 흩어진 프롬프트를 한 곳으로 통합
스탯창5vs5 / 6vs6 인원수 자동 판별
6vs6 조합 추론되묻지 않고 먼저 답한 뒤 버튼/말로 정정, 조합 정보 5분 규칙
버그 수정솔저76 치환, 위도우메이커/메이 분리, 모델 중복 로드, 시온 누락
기능 개선스탯창 병렬화, 입력 길이 제한, 로그 정리 명령, 환경변수 설정


같은 정보가 두 곳에 흩어져 있거나(솔저76 표기) 규칙이 엉뚱한 조건에 묶여 있으면 언젠가 어긋난다.

그래서 이번엔 고치는 김에 흩어진 표기와 프롬프트를 한 곳으로 모으고, 서로 묶여 있던 규칙들을 떼어놓는 쪽으로 방향을 잡았다.

This post is licensed under CC BY 4.0 by the author.