스킬 품질 관리를 위한 루브릭 설계 및 시스템 구현

발행: (2026년 6월 8일 AM 10:00 GMT+9)
12 분 소요
원문: Toss Tech

Source: Toss Tech

안녕하세요, 토스 AI DX Team의 Server Developer 조민규입니다.

저는 AI DX Team에서 토스 서버의 하네스를 만들면서, 그 기능 중 하나로 사내 공용 Skill을 만들어 배포하고 있습니다. 여기서 다루는 Skill은 실제 서비스 런타임에서 동작하는 것이 아니라, 개발자가 코딩 에이전트로 개발할 때 이를 보조하는 개발 단계의 도구입니다.

토스 내부에서는 많은 Skill들이 만들어지고 공유되고 있는데요, 정성껏 만들어서 제공되는 Skill이 한 번도 호출되지 않는 경우도 많았습니다. 본문에 사용 케이스를 여러 개 적어두고 트리거 키워드도 깔아뒀는데도, 정작 스킬은 제대로 호출이 되지 않았죠.

근본적으로 Skill의 품질 문제라는 생각이 들었습니다. 이를 해결하기 위해 Skill의 품질을 관리하기 위한 6 섹션 30 항목의 Rubric을 만들게 되었는데, 이 과정에 대해 정리해 보려고 합니다.

Skill 평가가 어려운 두 가지 이유

Skill은 본질적으로 ‘LLM이 호출하고 LLM이 읽는 산출물’입니다. 코드라면 컴파일러와 테스트가 1차 게이트가 되어 주지만, Skill에는 통과/실패를 딱 떨어지게 검증해 주는 도구가 없습니다.

그래서 결함이 호출되지 않거나, 호출돼도 효과가 없는 형태로 조용히 누적됩니다. 가장 자주 발견되는 Skill 관련 문제 두 가지를 먼저 짚어보겠습니다.

1. 트리거 실패

Skill의 설명 작성이 바람직하지 못해서 코딩 에이전트가 Skill을 올바른 타이밍에 호출하지 못하는 경우입니다. 본문에 아무리 좋은 가이드를 적어둬도 호출이 안 되면 무의미하죠.

대표적으로 작성자가 호출 조건을 Description이 아닌 Skill 본문에 적어두는 패턴이 있습니다. 본문에 “Use when …” 같은 시점 정보를 넣어두면 충분할 거라 생각하기 쉽지만, 코딩 에이전트는 Skill을 호출할지 결정할 때 Description만 봅니다. 본문은 호출이 결정된 다음에야 읽힙니다. 작성자 입장에서는 분명히 트리거 조건을 명시했다고 생각하는데, 정작 코딩 에이전트에게는 보이지 않는 영역에 적어둔 셈이 되죠.

문제는 이 결함이 사람의 눈으로 잡히지 않는다는 점입니다. 코드처럼 빨간 줄이 그어지지도 않고, 작성자 본인은 자기 Skill이 호출되지 않는다는 사실 자체를 모릅니다.

2. 형식 위반으로 인한 호출 실패

name이 kebab-case가 아니거나, name과 폴더명이 일치하지 않으면 코딩 에이전트가 Skill의 존재 자체를 인식하지 못합니다. 트리거 실패와 결과는 비슷하지만, 원인이 형식적이라 정규식으로 즉시 잡아낼 수 있는 결함입니다.

작성자 입장에서는 본인 Skill이 호출되지 않는 이유를 본문에서 찾느라 시간을 낭비하기 쉽습니다. 정작 원인은 frontmatter 한 줄에 있는 경우인데도요.

이러한 사내 Skill의 문제들은 Rubric 설계의 출발점이 됐습니다.

결정적인 것은 규칙 기반으로, 의미적인 것은 모델 기반으로

두 결함의 검증 방식은 정반대였습니다. 첫 번째는 의미 판단이 본질이라 LLM 모델 판정이 필수이고, 두 번째는 형식 기반이라 정규식 한 줄로 결정적으로 잡히는 결함인 것입니다.

이러한 통찰을 바탕으로, 둘을 구분해서 관리해야 한다는 Rubric 전체를 관통하는 단 하나의 설계 원칙을 고안하게 되었습니다. 두 영역을 섞어 처리하면 양쪽 모두 망가지기 때문입니다. 결정적 결함을 LLM 에 맡기면 “거의 맞는 것 같은데…” 로 통과시키는 False Negative가 생기고, 반대로 의미적 판정을 정규식으로 처리하면 키워드 매칭의 한계 때문에 False Positive가 폭발합니다.

그래서 30개 항목을 17개 규칙 / 13개 모델 검사로 명시적으로 구분했습니다. 규칙 검사는 정규식·카운트·AST 파싱처럼 결정적인 도구만 쓰고, 모델 검사에는 LLM만 사용합니다. 두 단계의 책임 영역이 겹치지 않게 만드는 게 핵심입니다.

이 분리는 운영 비용 측면에서도 효과적입니다. 규칙 검사는 무료에 가깝고 매 PR 마다 돌아도 부담이 없습니다. 모델 판정은 비용이 들지만 규칙 검사가 통과한 케이스에만 호출되므로, BLOCKER 단계에서 막힌 Skill 에는 LLM 비용을 쓰지 않습니다.

6 섹션 30 항목 Rubric의 구조

Skill을 6개 섹션, 30개 항목으로 평가하도록 구조를 설계했습니다. 각 항목은 BLOCKER / MAJOR / MINOR 심각도를 가지고, 결과는 S~F 5단계 등급으로 정리됩니다.

섹션항목 수BLOCKER성격타당성
30이 Skill이 존재할 가치가 있는가
구조85Skill 파일 형식이 올바른가
트리거61코딩 에이전트가 잘 부를 수 있는가
콘텐츠30본문이 가치 있는가
리소스80파일 구조가 잘 짜여 있는가
안전성22배포해도 안전한가
합계308

등급 기준

등급조건의미
SBLOCKER 0 + MAJOR 0모범 Skill
ABLOCKER 0 + MAJOR 1-2사용 가능, 소폭 개선
BBLOCKER 0 + MAJOR 3-4개선 필요
CBLOCKER 0 + MAJOR 5+대폭 개선 필요
FBLOCKER 1+배포 불가, 재작성

핵심은 BLOCKER 가 하나라도 있으면 무조건 F 라는 점입니다. 등급은 작성자에게 보여주는 압축 신호이고, 실제 Merge 차단은 F인지 아닌지 한 비트로만 결정됩니다. 미세한 등급 차이로 줄다리기하지 않게 만드는 단순화죠.

섹션별 핵심 항목과 설계 의도

타당성 (3 / MAJOR)

이 Skill이 존재할 만한 이유가 있는지를 봅니다. 이때 세 가지 질문을 던집니다.

ID항목측정심각도
1-1반복되는 워크플로우인가?모델MAJOR
1-2프로젝트 한정이 아닌 범용성이 있는가?모델MAJOR
1-3코딩 에이전트 기본 능력으로 대체 불가능한가?모델MAJOR

하나라도 No라면 스킬의 존재 가치에 대한 확신에 의문 부호가 자동으로 붙습니다. 일회성 작업이거나 코딩 에이전트에게 그냥 시켜도 되는 일은 Skill로 만들 가치가 없기 때문입니다.

‘만들지 말았어야 할 Skill’을 잡는 항목이라 다른 섹션과 결이 좀 다릅니다.

구조 (8 / BLOCKER 5)

파일이 형식적으로 올바른지를 봅니다. 8개 항목 중 다섯 가지가 BLOCKER 입니다.

ID항목측정심각도
2-1YAML frontmatter 파싱 가능규칙BLOCKER
2-2name 이 kebab-case (≤ 64자)규칙BLOCKER
2-3name 과 폴더명 일치규칙BLOCKER
2-4description 1-1024자규칙BLOCKER
2-5description 에 XML 태그 없음규칙BLOCKER
2-6허용된 frontmatter 키만 사용규칙MAJOR
2-7claude / anthropic 예약어 없음규칙MAJOR
2-8Skill 폴더 내 README.md 없음규칙MINOR

5개 BLOCKER 중 하나만 어겨도 F 등급입니다. 형식 위반은 결정적으로 잡히는 결함이라 100% 규칙 검사로 처리하고, 모델 판정은 들어가지 않습니다.

이 검사를 한 번에 모아서 처리하는 것은 의도된 설계입니다. 결함이 여러 개 있더라도 PR 코멘트 한 번으로 전부 받게 해주는 편이 작성자 입장에서 수정 비용이 가장 적기 때문입니다. frontmatter 파싱이 깨진 경우만 즉시 리턴하고, 그 외에는 끝까지 돌려 결과 리스트를 한 번에 반환합니다.

import re
from pathlib import Path
import yaml

NAME_RE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")  # kebab-case
DESC_LEN_RANGE = (1, 1024)

def check_structure(Skill_md_path: str) -> list[str]:
    """구조 섹션의 BLOCKER 5개를 한 번에 검사."""
    content = Path(Skill_md_path).read_text(encoding="utf-8")
    folder_name = Path(Skill_md_path).parent.name

    m = re.match(r"^---\n(.*?)\n---\n(.*)", content, re.DOTALL)
    if not m:
        return ["BLOCKER: frontmatter 누락"]

    try:
        fm = yaml.safe_load(m.group(1)) or {}
    except yaml.YAMLError as e:
        return [f"BLOCKER: frontmatter 파싱 실패 - {e}"]

    body = m.group(2)
    failures = []
    name = fm.get("name", "")
    desc = fm.get("description", "")

    if not NAME_RE.match(name):
        failures.append(f"BLOCKER: name '{name}' 이 kebab-case 가 아님")
    if name != folder_name:
        failures.append(f"BLOCKER: name '{name}' 와 폴
0 조회
Back to Blog

관련 글

더 보기 »

얼굴 인식의 역사와 페이스페이의 미래

프롤로그: 얼굴이라는 열쇠 새벽 6시, 출근길 편의점. 양손에는 우산과 서류 가방이 들려 있고, 코트 주머니 어딘가에 있을 지갑을 찾기엔 시간이 없습니다. 그때, 계산대 옆 작은 화면이 당신의 얼굴을 인식합니다. 2초. 결제 완료. 커피를 들고 나서는 당신의 손은 여전히 자유롭습니다....

에이전틱 AI 생태계 주인공들, MCP Player 10 성공·다음 단계.

지난 2025년 12월 19일부터 2026년 1월 18일까지, 에이전틱Agentic AI 생태계의 저변을 넓히고 개발자분들에게 실용적인 개발 경험을 제공하고자 진행된 'PlayMCP 개발 공모전, MCP Player 10’이 뜨거운 관심과 참여로 마무리되었습니다. 150여 팀이 보여주신...