학습 자료

자를 만든다 — 골든셋 30명으로 채점한다


Article

앞 편까지 추천이 나오게 만들었다. 그런데 그게 좋은 추천인지는 한 번도 안 세어봤다.

판단하는 두 가지 방법

눈으로 본다

지금까지 해온 것

  • 「그럴듯한데?」
  • 고칠 때마다 매번 다시 읽는다
  • 두 방법 중 어느 쪽이 나은지 말할 수 없다
  • 본 사람이 바뀌면 판정도 바뀐다

센다

오늘 만들 것

  • 「30명 중 4명 맞혔다 — 13.3%」
  • 고치고 다시 돌리면 숫자가 움직인다
  • 어느 쪽이 나은지 말할 수 있다
  • 누가 돌려도 같은 값이 나온다
오늘 만드는 것은 추천이 아니라 자다. 이 자가 있어야 다음 편부터 하는 일이 헛일이 아니게 된다.

1. 숨겨둔 300건에 이름을 붙인다

2편에서 is_holdout 이라는 낯선 칸을 봤다. 그 300건에 이제 이름을 붙인다.

골든셋 (golden set)
무엇
정답이 붙어 있는 묶음
is_holdout = 1 · 300건
규칙
학습·검색에 안 쓴다
쓰면 유출이다
쓰임
점수를 잴 때만 꺼낸다
자에 해당한다

답안지를 꺼내본다

20_golden.py
import sqlite3

con = sqlite3.connect("shop.db")
cur = con.cursor()

print("골든셋:", cur.execute("SELECT COUNT(*) FROM purchases WHERE is_holdout = 1").fetchone()[0], "건")
print("이력  :", cur.execute("SELECT COUNT(*) FROM purchases WHERE is_holdout = 0").fetchone()[0], "건")

# dict 로 감싸면 [(고객, 상품), ...] 이 {고객: 상품} 이 된다.
# 고객 번호로 바로 정답을 꺼낼 수 있어 채점할 때 편하다
answers = dict(cur.execute(
    "SELECT customer_id, product_id FROM purchases WHERE is_holdout = 1").fetchall())

print("고객 수:", len(answers))
print("C001 의 정답:", answers["C001"])

# 정답 상품이 몇 종류인가. 한 상품에 몰려 있으면 그것만 찍어도 점수가 나온다
print("정답 상품 종류:", cur.execute(
    "SELECT COUNT(DISTINCT product_id) FROM purchases WHERE is_holdout = 1").fetchone()[0])
터미널
  • 골든셋: 300 건
  • 이력 : 1200 건
  • 고객 수: 300
  • C001 의 정답: P106
  • 정답 상품 종류: 153

자를 쓰기 전에 자를 검사한다

정답이 이력에도 들어 있으면 채점이 헐거워진다. 이미 산 것을 또 추천해도 맞은 것이 되기 때문이다.

20_golden.py — 이어서
duplicated = 0

for customer_id, answer in answers.items():
    bought = {r[0] for r in cur.execute(
        "SELECT product_id FROM purchases WHERE customer_id = ? AND is_holdout = 0",
        (customer_id,))}
    if answer in bought:
        duplicated += 1

print("이력에도 있는 정답:", duplicated, "건")
터미널
  • 이력에도 있는 정답: 0 건

재는 도구가 틀리면 그 뒤에 하는 일이 전부 무의미해진다. 그런데 재는 도구가 틀렸다는 것은 아무 에러도 안 낸다 — 숫자는 멀쩡하게 나온다. 그래서 이 편의 마지막 미션이 채점기가 맞는지 채점하는 법이다.

2. db.py — 흩어진 연결을 한 곳으로 모은다

채점기를 만들기 전에 정리를 한 번 한다. 지금 만든 파일들을 세어보면 이렇다.

지금 폴더 상태
  • ai-eng-svc
    • 02_load_db.pysqlite3.connect("shop.db")
    • 03_query.pysqlite3.connect("shop.db")
    • 07_embed_products.pysqlite3.connect("shop.db")
    • 09_embed_customers.pysqlite3.connect("shop.db")
    • 10_ask_json.pysqlite3.connect("shop.db")
    • config.py모델 접속은 이미 여기 하나로 모아뒀다

같은 두 줄이 다섯 군데에 있다. 지금은 아무 문제가 없다. 문제는 다음 주에 생긴다.

db.py
"""데이터베이스에 닿는 자리를 여기 하나로 모은다.

다른 파일은 전부 이렇게 쓴다 ─
    from db import query, one
"""

import sqlite3

DB_PATH = "shop.db"

con = sqlite3.connect(DB_PATH)


def query(sql, params=()):
    """여러 줄을 꺼낸다."""
    return con.execute(sql, params).fetchall()


def one(sql, params=()):
    """한 줄만 꺼낸다. 없으면 None 이 온다."""
    return con.execute(sql, params).fetchone()
낯선 이름 풀이
이름무슨 뜻인가왜 여기 나오나
DB_PATH파일 이름을 담아둔 값대문자는 「바꾸지 않는 값」이라는 표시다
params=()안 넘기면 빈 묶음물음표가 없는 SQL 도 그냥 부를 수 있다
con.execute(...)cur 없이 바로 실행sqlite 는 손을 알아서 만들어준다
query / one여러 줄 / 한 줄fetchall · fetchone 을 매번 안 적으려고

이제 다른 파일에서는 이렇게 쓴다.

from db import query, one

customer = one("SELECT age, gender, skin_type FROM customers WHERE customer_id = ?", ("C007",))
history = query("SELECT product_id, rating FROM purchases WHERE customer_id = ?", ("C007",))
다음 주에 고칠 자리

지금 안 모으면

파일 다섯 개

  • 다섯 곳을 똑같이 고친다
  • 하나 빠뜨리면 에러 없이 옛날 데이터를 본다
  • 앞으로 파일이 더 늘어난다

모아두면

db.py 한 줄

  • connect 한 줄만 갈아끼운다
  • SQL 문장은 그대로 간다
  • 물음표(?)가 %s 로 바뀌는 정도
2편에서 SQL 을 배운 보람이 그 순간에 온다. 데이터베이스를 바꿔도 물어보는 말은 안 바뀐다.

3. 채점기 — 맞았는지 세는 한 줄

맞았다는 것을 먼저 정한다. 추천 다섯 개 안에 정답이 있으면 맞은 것이다.

hit@k — 이렇게 읽는다
이름무슨 뜻인가무엇을 말해주나
hit@1첫 번째 추천이 정답이었다순위까지 잘 매겼나
hit@3위 셋 안에 정답이 있었다화면 첫 줄에 뜨나
hit@5위 다섯 안에 정답이 있었다이번 시리즈의 기준 자
@at — 「몇 개까지 봤을 때」k 를 안 적으면 아무 뜻이 없다
hit@5 는 hit@1 보다 항상 크거나 같다. 더 많이 보고 판정하니 당연하다. 그래서 두 숫자를 나란히 봐야 한다.

왜 30명인가

채점기 코드

21_grade.py
import json
import time

import numpy as np

from config import client, MODEL
from db import query, one          # 이제 sqlite3 를 여기서 안 부른다

N = 30                  # 채점할 고객 수
N_CANDIDATES = 10       # 모델에게 줄 후보 개수
N_PICK = 5              # 그중 고르게 할 개수

# 저장해둔 벡터를 꺼낸다 (4·5편)
products = query("SELECT product_id, vector FROM product_vectors")
product_ids = [p for p, _ in products]
V = np.array([json.loads(v) for _, v in products], dtype="float32")

customers = query("SELECT customer_id, vector FROM customer_vectors")
customer_ids = [c for c, _ in customers]
CV = np.array([json.loads(v) for _, v in customers], dtype="float32")

# 답안지 (골든셋)
answers = dict(query("SELECT customer_id, product_id FROM purchases WHERE is_holdout = 1"))


def candidates_for(customer_id, index):
    """이미 산 것을 빼고, 뜻이 가까운 상품 열 개를 고른다. 5편에서 만든 것이다."""
    bought = {r[0] for r in query(
        "SELECT product_id FROM purchases WHERE customer_id = ? AND is_holdout = 0",
        (customer_id,))}

    scores = V @ CV[index]
    picked = [product_ids[i] for i in scores.argsort()[::-1]
              if product_ids[i] not in bought][:N_CANDIDATES]

    # 번호만으로는 모델에게 줄 수 없다. 이름·브랜드·가격을 붙여 온다.
    # 물음표를 개수만큼 만들어 IN 에 넣는다 — 1편에서 한 것과 같은 방법이다
    marks = ",".join("?" * len(picked))
    rows = query(
        f"SELECT product_id, name, brand, price FROM products WHERE product_id IN ({marks})",
        tuple(picked))

    # IN 으로 꺼내면 순서가 뒤섞여 온다. 가까운 순서대로 되돌려 놓는다
    order = {p: i for i, p in enumerate(picked)}
    return sorted(rows, key=lambda row: order[row[0]])

모델에게 물어보는 부분은 앞 편에서 만든 것을 함수 둘로 묶기만 하면 된다.

21_grade.py — 이어서
def prompt_for(customer_id, candidates):
    """9편의 08_safe_prompt.py 그대로다. 이름·전화·메일은 SELECT 에 아예 없고,
    후기에만 mask_text 를 건다."""
    age, gender, skin_type = one(
        "SELECT age, gender, skin_type FROM customers WHERE customer_id = ?", (customer_id,))

    history = query("""
        SELECT products.name, products.price, purchases.rating, purchases.review
        FROM purchases JOIN products ON purchases.product_id = products.product_id
        WHERE purchases.customer_id = ? AND purchases.is_holdout = 0
    """, (customer_id,))

    lines = "\n".join(f"- {name} {price}원 (별점 {rating}) {mask_text(review)}"
                      for name, price, rating, review in history)
    catalog = "\n".join(f"{i} | {n} | {b} | {p}원" for i, n, b, p in candidates)

    return f"""아래 고객에게 어울릴 상품 {N_PICK}개를 후보에서 골라라.

[고객] {age}세 {gender} · {skin_type}
[구매 이력]
{lines}

[후보]
{catalog}

좋은 순서대로 {N_PICK}개를 고른다."""


def ask_model(customer_id, candidates):
    """10편의 12_schema.py 를 함수로 묶었다.

    추천만 꺼내(res.choices[0].message.parsed) 돌려주면 깔끔해 보이는데,
    그러면 토큰 수(usage)가 같이 버려진다. 재려고 만드는 코드에서
    잴 것을 버리면 안 되므로 응답을 통째로 돌려준다.
    """
    return client.chat.completions.parse(
        model=MODEL,
        messages=[
            {"role": "system", "content": SYSTEM},
            {"role": "user", "content": prompt_for(customer_id, candidates)},
        ],
        response_format=Recommend,        # 10편에서 만든 pydantic 클래스
    )

이제 30명을 도는 부분이다.

21_grade.py — 이어서
hits = {1: 0, 3: 0, 5: 0}       # hit@1 · hit@3 · hit@5 를 한 번에 센다
in_candidates = 0                # 후보 안에 정답이 있었던 횟수
started_all = time.time()

log = open("runs.jsonl", "a", encoding="utf-8")     # a = 이어 붙이기

for index in range(N):
    customer_id = customer_ids[index]
    candidates = candidates_for(customer_id, index)
    candidate_ids = [c[0] for c in candidates]
    answer = answers[customer_id]

    # 정답이 후보에 아예 없으면 모델이 무엇을 하든 못 맞힌다. 그걸 따로 센다
    in_candidates += answer in candidate_ids

    started = time.time()
    res = ask_model(customer_id, candidates)      # 10편에서 만든 것. 응답을 그대로 돌려준다
    took = time.time() - started

    recommend = res.choices[0].message.parsed

    # 후보 밖 번호는 버린다 (10편). 남은 것이 진짜 추천이다
    picks = [it.product_id for it in recommend.items if it.product_id in candidate_ids]

    for k in hits:
        hits[k] += answer in picks[:k]

    # 관측 기록 — 한 줄이 한 번의 호출이다
    log.write(json.dumps({
        "customer_id": customer_id,
        "took": round(took, 2),
        "in_tokens": res.usage.prompt_tokens,
        "out_tokens": res.usage.completion_tokens,
        "picks": picks,
        "answer": answer,
        "hit": answer in picks[:5],
    }, ensure_ascii=False) + "\n")
    log.flush()          # 바로 파일에 쓴다. 중간에 멈춰도 여기까지는 남는다

    print(f"{index + 1:2d}/{N} {customer_id} {took:5.1f}초 "
          f"{'HIT' if answer in picks[:5] else ''}", flush=True)

log.close()
elapsed = time.time() - started_all

print(f"\n{N}명 {elapsed / 60:.1f}분 (한 명당 {elapsed / N:.1f}초)")
for k in (1, 3, 5):
    print(f"hit@{k} {hits[k] / N * 100:.1f}%")
print(f"후보 안에 정답이 있던 비율 {in_candidates / N * 100:.1f}%")
낯선 이름 풀이
이름무슨 뜻인가왜 여기 나오나
hits = {1: 0, 3: 0, 5: 0}세 개의 세는 통세 숫자를 한 번에 센다
answer in picks[:k]앞 k 개 안에 있나이 한 줄이 채점기의 전부다
in_candidates후보에 정답이 있었나오늘의 핵심 숫자다. 뒤에서 본다
open(..., "a")이어 붙이기돌릴 때마다 밑에 쌓인다
flush()당장 파일에 쓴다중간에 멈춰도 기록이 남는다
ensure_ascii=False한글을 한글로 적는다빼면 알아볼 수 없게 저장된다
채점 코드가 한 줄인 것은 10편에서 답의 모양을 고정해뒀기 때문이다. 문장으로 받았으면 여기서 상품명을 찾아내는 코드를 짜야 했다.

4. 돌려본다

터미널 — 30명 채점 (qwen-cpu · 후보 10개 중 5개)
  • 1/30 C001 9.4초
  • 6/30 C006 11.9초 HIT
  • 7/30 C007 18.6초 HIT
  • …
  • 30/30 C030 13.4초
  • 30명 7.6분 (한 명당 15.3초)
  • hit@1 10.0%
  • hit@3 13.3%
  • hit@5 13.3%
  • 후보 안에 정답이 있던 비율 20.0%

5편에서 모델 없이 잰 값이 있으니 나란히 놓아본다.

모델을 얹으면 무엇이 달라지나 · 같은 30명
hit@1hit@3hit@5맞힌 사람
벡터검색 상위 5개 · 5편 방식6.7%13.3%13.3%4명
모델이 후보 10개 중 5개10.0%13.3%13.3%4명
맞힌 사람이 4명으로 똑같다. 한 명이 3위에서 1위로 올라온 것만 달라졌다. 윗줄이 5편에 적힌 10.3% 와 다른 것은 그때가 300명 기준이었기 때문이다 — 표본이 다르면 숫자가 다르다.

상용 모델로 바꾸면 어떻게 되나

7편의 길을 골랐다면 config.py 세 줄만 바꿔 같은 채점기를 그대로 돌릴 수 있다. 돌려봤다.

같은 30명 · 같은 채점기 · 모델만 바꿔서
내 노트북 (3B)상용 (gpt-4o-mini)
걸린 시간7.6분1.5분
hit@110.0%6.7%
hit@313.3%10.0%
hit@513.3%13.3%
5개를 다 낸 사람17/3029/30
후보 밖 번호5개1개
비용0원약 8원
형식은 확실히 좋아졌다. 그런데 hit@5 는 똑같다.

🔴 다섯 개를 시켰는데 다섯 개가 안 온다

기록을 열어보면 하나가 더 보인다.

runs.jsonl 을 세어보면 — 30명이 받은 추천 개수
  • 5개 온 사람 17명
  • 4개 온 사람 11명
  • 2개 온 사람 1명
  • 0개 온 사람 1명 (C030)
  • 같은 번호를 두 번 낸 사람 2명 (C014 · C017)
  • 후보 밖 번호 136개 중 5개

5. 🔴 점수보다 먼저 봐야 할 숫자

후보 안에 정답이 있던 비율이 20%다.

정답이 사라지는 자리
  1. 상품 200개정답이 여기 있다
  2. 벡터로 후보 10개여기서 80%가 탈락
  3. 모델이 5개 고른다없는 것은 못 고른다
  4. hit@5 13.3%천장이 20%다
모델이 완벽했어도 20%다. 나머지 80%의 사람에게는 애초에 정답이 배달되지 않았다.

30명을 셋으로 갈라보면

모델이 손댈 수 있었던 사람은 몇 명인가
어떤 사람몇 명무슨 뜻인가
후보 상위 5개 안에 정답이 있었다4명모델이 없어도 맞히는 사람
후보 6~10위에 정답이 있었다2명모델이 끌어올렸어야 할 사람
후보 10개에 정답이 아예 없었다24명모델이 무엇을 하든 못 맞히는 사람
모델이 실제로 손댈 수 있었던 것은 가운데 두 명뿐이고, 그 둘을 다 놓쳤다. 그래서 점수가 4명 그대로였다.

제일 큰 덩어리는 셋째 줄이다. 24명은 후보를 만드는 단계에서 이미 진 사람들이라, 프롬프트를 아무리 다듬어도 안 움직인다. 프롬프트로 건드릴 수 있는 것은 가운데 두 명이고, 거기에 하루를 써서 얻을 수 있는 최대치가 6.7%다.

어디에 시간을 쓸지를 숫자가 정해줬다 — 후보를 잘 고르는 일이 먼저고, 프롬프트를 다듬는 일이 그다음이다.

후보를 늘리면 천장이 올라간다

후보를 몇 개 줄 때 그 안에 정답이 있나 · 30명
후보 5개13.3%
후보 10개20%
후보 20개36.7%
후보 30개40%

6. 관측 — 재는 김에 남긴다

채점하면서 runs.jsonl 에 한 줄씩 쌓아뒀다. 한 줄이 한 번의 호출이다.

runs.jsonl — 첫 줄
  • {"customer_id": "C007", "took": 18.64, "in_tokens": 625,
  • "out_tokens": 336, "picks": ["P140", "P059", "P188", "P154", "P058"],
  • "answer": "P140", "hit": true}

이번 편에 나온 것

정리
한 것 · 안 것내용
골든셋정답이 붙어 있고 점수를 잴 때만 꺼내는 묶음. is_holdout = 1 300건
자를 먼저 검사정답이 이력에도 있나 — 0건이라 그대로 쓴다
db.py흩어진 connect 를 한 곳으로. 다음 주에 고칠 파일이 하나가 된다
hit@k위 k 개 안에 정답이 있나. k 와 표본 수를 반드시 같이 적는다
채점 코드answer in picks[:k] 한 줄. 10편에서 모양을 고정한 값이다
30명인 이유300명은 65분. 30명이면 7~8분 · 한 명이 3.3%
🔴 IN 의 순서적어 넣은 순서대로 안 온다. 다시 정렬한다
오늘 점수hit@1 10.0% · hit@3 13.3% · hit@5 13.3%
모델을 얹은 값hit@5 가 안 늘었다. 순서만 다시 매겼다
🔴 개수도 부탁5개를 시켰는데 다 낸 사람이 17/30. 0개도 하나 있었다
🔴 천장후보 안에 정답이 있던 비율 20%. 모델이 완벽해도 못 넘는다
관측runs.jsonl 에 시간·토큰·결과를 쌓아만 둔다. 읽는 것은 뒤에서
상용으로 바꿔도형식 사고는 사라지는데 hit@5 는 그대로. 천장에 갇힌다
결론점수가 안 오르면 모델이 아니라 병목을 본다

미션

미션
  1. 01

    [필수] 30명을 채점하고 숫자를 적는다

    mission_grade_1.py. 21_grade.py 를 돌려 hit@1 · hit@3 · hit@5 와 걸린 시간을 적는다. 「후보 안에 정답이 있던 비율」도 같이 적는다.

  2. 02

    [응용] 후보 개수를 바꿔 다시 잰다

    mission_grade_2.py. N_CANDIDATES 를 5 와 20 으로 바꿔 다시 돌린다. 천장과 점수가 같이 움직이나, 따로 움직이나. 걸린 시간도 같이 본다.

  3. 03

    [도전] 채점기가 맞는지 채점한다

    mission_grade_3.py. 모델을 아예 떼고 후보 앞 다섯 개를 그대로 답으로 내보낸다. 이때 나오는 hit@5 는 5편에서 한 벡터검색을 같은 30명에 돌린 값과 정확히 같아야 한다. 다르면 어딘가 틀린 것이다.

[도전] 모델을 떼면 채점기를 검산할 수 있다충분히 고민해본 뒤 꼭 필요한 경우에만 열어보세요

13.3% 가 나온다. 5편의 벡터검색을 같은 30명에 돌린 값과 똑같다. 당연하다 — 후보를 가까운 순서로 뽑아 앞에서 다섯 개를 그대로 냈으니, 그건 벡터검색 상위 5개와 정확히 같은 목록이다.

여기서 확인되는 것이 셋이다. 후보를 제대로 뽑고 있고, 이미 산 것을 제대로 빼고 있고, 정답 대조가 제대로 돌고 있다.

만약 두 값이 달랐다면 점수를 보기 전에 그것부터 고쳐야 한다. 채점기가 틀린 상태에서 프롬프트를 고치면, 좋아졌는지 나빠졌는지를 틀린 자로 재게 된다.

이 방식에 이름이 있다 — 알고 있는 답이 나오는 입력을 넣어보는 것이다. 재는 도구를 만들 때마다 한 번씩 해두면 크게 헤매지 않는다.

# 모델을 부르는 줄만 빼고 나머지는 21_grade.py 와 똑같다
hit = 0

for index in range(N):
    customer_id = customer_ids[index]
    candidates = candidates_for(customer_id, index)

    picks = [c[0] for c in candidates][:5]     # 모델 대신 — 그냥 앞에서 다섯 개

    hit += answers[customer_id] in picks

print(f"가짜 추천기 hit@5 {hit / N * 100:.1f}%")

7분이 0.1초로 끝난다. 모델을 안 부르니 그렇다. 채점기를 고칠 때는 이 가짜 추천기로 먼저 돌려보면 훨씬 빠르게 다듬을 수 있다.

그리고 이 값은 다음 편부터 계속 쓸 기준선이다. 무엇을 고치든 13.3% 보다 나은지를 먼저 묻는다. 못 넘으면 모델을 부를 이유가 없다.

정리하면

오늘 만든 것은 추천이 아니라 자다. 숨겨둔 300건에 골든셋이라는 이름을 붙였고, 30명을 돌려 점수를 냈다.

자를 쓰기 전에 자부터 검사했다. 정답이 이력에도 들어 있으면 채점이 헐거워지는데, 세어보니 0건이라 그대로 썼다. 재는 도구가 틀렸다는 것은 아무 에러도 안 낸다.

그리고 db.py 를 만들었다. 같은 connect 두 줄이 파일 다섯 군데에 흩어져 있었다. 다음 주에 Supabase 로 옮길 때 고칠 자리가 한 곳이 된다 — SQL 문장은 거의 그대로 간다.

점수는 hit@5 13.3% 가 나왔다. 벡터검색만 했을 때와 똑같다. 7분을 들여 모델을 서른 번 불렀는데 맞힌 사람은 그대로 4명이다. 달라진 것은 hit@1 하나 — 한 명이 3위에서 1위로 올라왔다. 모델은 순서를 다시 매겼을 뿐, 새로운 답을 데려오지 못했다.

그리고 진짜 발견은 그 아래 줄에 있었다. 후보 열 개 안에 정답이 있던 비율이 20%다. 열 명 중 여덟 명에게는 애초에 정답이 배달되지 않았다. 모델을 아무리 좋은 것으로 바꿔도 20%를 못 넘는다.

30명을 갈라보면 더 분명해진다. 모델이 없어도 맞히는 사람이 4명, 모델이 끌어올렸어야 할 사람이 2명, 무엇을 해도 못 맞히는 사람이 24명이다. 프롬프트를 다듬어 얻을 수 있는 최대치가 두 명이라는 뜻이다.

점수 하나만 봤으면 「모델이 시원찮네」로 끝났을 것이다. 실제로 상용 모델로 바꿔 돌려봤더니 시간은 5분의 1이 되고 형식 사고는 사라졌는데 hit@5 는 13.3% 그대로였다 — 천장이 20%라 그 아래에 갇힌 것이다. 병목이 어디인지 알려주는 숫자를 같이 재는 것 — 그게 채점기를 직접 만드는 이유다.

다음 편에서 후보를 다시 고른다. 개수를 늘리는 대신 더 잘 고른다. 4편에서 「3만원 이하 선물용」에 42,900원이 1위로 왔던 그 장면을 기억할 것 — 조건은 SQL 이 자르고, 뜻은 벡터가 고른다. 그 순서를 지키면 이 20% 가 어디까지 올라가는지 본다.

Share
  • 파이썬
  • AI
  • 측정
  • SQL
  • 추천
자를 만든다 — 골든셋 30명으로 채점한다 — 디코드랩(DCODELAB)