조용한 ML 버그: train과 val이 같은 폴더를 가리키면 mAP가 거짓말을 한다

YOLO 학습에서 mAP가 높게 찍히는데 실제 배포 시 정확도가 안 나온다면 `data.yaml`을 먼저 확인해 보세요. train과 val이 같은 폴더를 가리키는 "조용한 ML 버그"의 전말과 80/20 재현 가능 분할 수정기입니다.

조용한 ML 버그: train과 val이 같은 폴더를 가리키면 mAP가 거짓말을 한다

1. 문제 상황: "학습은 잘 되는데 배포하면 틀려요"

이미지 라벨링 플랫폼에서 사용자가 직접 데이터를 라벨링하고 YOLO 모델을 파인튜닝할 수 있게 하는 기능이 있습니다. 학습을 돌리면 진행 상황이 SSE로 스트리밍되고, 완료되면 mAP50, precision, recall 같은 메트릭이 화면에 찍힙니다.

그런데 어느 날 사용자에게서 이런 피드백이 왔습니다.

"학습을 끝내면 mAP50이 0.95 나오는데, 실제로 새 이미지에 돌려 보면 절반도 못 맞춥니다."

학습 로그를 봤습니다. 분명 mAP가 에포크마다 올라가고 있었고, precision/recall도 함께 증가했습니다. 과적합을 의심할 만한 학습 손실 곡선의 이상도 없었습니다. 코드를 열어 data.yaml 생성 로직을 찾았습니다. 그리고 범인을 발견했습니다.

path: /abs/path/to/train_workspace/<train_id>
train: images
val: images     # ← 이 줄이 범인
nc: 3
names: [cat, dog, bird]

trainval이 같은 폴더를 가리키고 있었습니다. 학습 이미지와 검증 이미지가 같은 파일들이었다는 뜻입니다.

2. 왜 이것이 "조용한" 버그인가

일반적인 버그라면 어디선가 에러가 터지거나, 결과가 눈에 띄게 이상합니다. 하지만 이 버그는 두 가지 이유로 전혀 눈에 띄지 않았습니다.

① YOLO는 data.yaml에 지정된 경로를 그대로 믿는다

Ultralytics YOLO는 data.yamltrainval을 읽어 "학습 루프용 데이터"와 "검증 루프용 데이터"로 구분해 사용합니다. 두 경로가 달라야 한다는 제약은 없습니다. 같은 경로를 써도 "학습자가 의도했을 수도 있으니" 에러 없이 그대로 돌립니다.

② 메트릭은 진짜 같아 보인다

학습 중 출력되는 mAP50, precision, recall은 검증 데이터에 대한 예측 정확도입니다. 그런데 검증 데이터가 학습 데이터와 동일하면, 모델은 "이미 본 이미지"를 평가하게 됩니다. 즉, 학습 메트릭이 아니라 학습 데이터 외움 정도를 보고하고 있었던 것입니다.

수치는 매끈하게 올라갑니다. 과적합 조짐도 없습니다. 학습 loss와 검증 loss가 똑같은 값으로 찍힙니다. 숙련된 ML 엔지니어라면 "train/val loss가 수렴하는 게 너무 완벽하다"고 눈치챘겠지만, 일반적인 제품 지표 대시보드에서는 자연스러운 결과로 보입니다.

③ 배포 시점에서만 드러난다

진짜 한 번도 본 적 없는 이미지(테스트 세트)를 넣어야 이 거짓말이 드러납니다. 사용자 피드백이 나오기 전까지, 어쩌면 수개월 동안 아무도 몰랐을 수 있습니다.

이것이 "조용한 ML 버그"의 정체입니다. 빌드는 통과하고, 테스트도 없고(있어도 잡기 어렵고), 메트릭은 좋아 보이는데, 현실에서만 거짓말이 드러나는 종류의 버그입니다.

3. Before: 단일 디렉토리 구조

기존 학습 준비 로직은 이랬습니다.

# Before: app.py / run_yolo_train()
def run_yolo_train(uid, train_id, labeled_imgs, ...):
    try:
        # A. 학습 전용 작업 디렉토리
        train_root = DATA_DIR / uid / "train_workspace" / train_id
        img_dir = train_root / "images"
        lbl_dir = train_root / "labels"
        for d in [img_dir, lbl_dir]:
            d.mkdir(parents=True, exist_ok=True)

        # B. 클래스 정보
        class_data = get_user_classes(uid)
        classes = class_data.get("classes", [])
        class_map = {name: i for i, name in enumerate(classes)}

        # C. 데이터 변환 (JSON 어노테이션 → YOLO TXT 포맷)
        for img_info in labeled_imgs:
            src_img = get_user_upload_dir(uid) / img_info['name']
            if not src_img.exists():
                continue
            shutil.copy(src_img, img_dir / img_info['name'])
            txt_name = Path(img_info['name']).stem + ".txt"
            with open(lbl_dir / txt_name, "w") as f:
                for a in img_info.get('annotations', []):
                    cls_id = class_map.get(a['className'], 0)
                    cx = a['x'] + (a['w'] / 2)
                    cy = a['y'] + (a['h'] / 2)
                    f.write(f"{cls_id} {cx} {cy} {a['w']} {a['h']}\n")

        # D. data.yaml 생성
        yaml_path = train_root / "data.yaml"
        with open(yaml_path, "w") as f:
            f.write(f"path: {train_root}\n")
            f.write("train: images\n")
            f.write("val: images\n")   # ← ❌ 같은 폴더
            f.write(f"nc: {len(classes)}\n")
            f.write(f"names: {classes}\n")

모든 이미지를 train_workspace/<train_id>/images/에 복사하고, 라벨도 labels/에 통으로 넣고, data.yaml에서 trainval을 모두 images로 설정했습니다. 의도가 아니라 단순히 split을 잊어버린 구조 였습니다.

4. 해결 방향: 80/20 재현 가능 분할

YOLO가 기대하는 표준 디렉토리 레이아웃은 이렇습니다.

train_workspace/<train_id>/
├── data.yaml
├── train/
│   ├── images/
│   └── labels/
└── val/
    ├── images/
    └── labels/

그리고 data.yaml이 이 두 폴더를 각각 가리킵니다.

path: /abs/path/to/train_workspace/<train_id>
train: train/images
val: val/images
nc: 3
names: [cat, dog, bird]

여기에 두 가지 추가 요구가 있었습니다.

  1. 재현 가능해야 한다. 같은 입력으로 학습을 돌렸을 때 매번 다른 mAP가 나오면 곤란합니다. 즉, 데이터 셔플에 고정 seed를 써야 합니다.
  2. 작은 데이터셋도 동작해야 한다. 사용자가 이미지 2~4장으로도 학습을 시작할 수 있는데, 이때 val이 0장이 되면 YOLO가 에러를 냅니다.

5. After: 구조 분할 + 고정 seed 분할

디렉토리 구조 재설계

# After: app.py / run_yolo_train()
import random as _rng

def run_yolo_train(uid, train_id, labeled_imgs, ...):
    try:
        # A. 학습 전용 작업 디렉토리 (train/val 분리)
        train_root = DATA_DIR / uid / "train_workspace" / train_id
        train_img_dir = train_root / "train" / "images"
        train_lbl_dir = train_root / "train" / "labels"
        val_img_dir   = train_root / "val" / "images"
        val_lbl_dir   = train_root / "val" / "labels"
        for d in [train_img_dir, train_lbl_dir, val_img_dir, val_lbl_dir]:
            d.mkdir(parents=True, exist_ok=True)

4개 디렉토리로 분리됐습니다. YOLO 표준 레이아웃을 따릅니다.

80/20 분할 + 고정 seed

        # C. 데이터 분리 (80% train / 20% val)
        shuffled = list(labeled_imgs)
        _rng.seed(42)            # ← 재현 가능성 확보
        _rng.shuffle(shuffled)
        split_idx = max(1, int(len(shuffled) * 0.8))
        train_imgs = shuffled[:split_idx]
        val_imgs = shuffled[split_idx:] if len(shuffled) >= 5 else shuffled[:1]

핵심 3줄 설명:

  1. _rng.seed(42): Python 표준 random 모듈을 별칭(_rng)으로 import 해 글로벌 상태를 건드리지 않고 고정 seed를 씁니다. 같은 입력에 대해 매번 동일한 train/val 분할이 생성됩니다.
  2. split_idx = max(1, int(len(shuffled) * 0.8)): 일반적 80/20 분할. max(1, ...)로 극단 상황에서 0이 되지 않도록 방어합니다.
  3. val_imgs = shuffled[split_idx:] if len(shuffled) >= 5 else shuffled[:1]: 이미지 수가 5장 미만이면 별도 처리. 자세한 엣지 케이스 설명은 다음 섹션에서 하겠습니다.

데이터 복사 로직 함수화

        # D. 데이터 변환 (사용자 JSON → YOLO TXT 포맷)
        def _copy_yolo_data(img_list, dest_img_dir, dest_lbl_dir):
            for img_info in img_list:
                src_img = get_user_upload_dir(uid) / img_info['name']
                if not src_img.exists():
                    continue
                shutil.copy(src_img, dest_img_dir / img_info['name'])
                txt_name = Path(img_info['name']).stem + ".txt"
                with open(dest_lbl_dir / txt_name, "w") as f:
                    for a in img_info.get('annotations', []):
                        cls_id = class_map.get(a['className'], 0)
                        cx = a['x'] + (a['w'] / 2)
                        cy = a['y'] + (a['h'] / 2)
                        f.write(f"{cls_id} {cx} {cy} {a['w']} {a['h']}\n")

        _copy_yolo_data(train_imgs, train_img_dir, train_lbl_dir)
        _copy_yolo_data(val_imgs, val_img_dir, val_lbl_dir)
        logger.info(f"[TRAIN DATA] train={len(train_imgs)}, val={len(val_imgs)}")

복사 로직을 클로저 함수로 빼서 train/val에 두 번 호출합니다. 로직 중복을 피하고, 읽는 사람 입장에서 "이 함수는 입력 리스트를 받아 한 쌍의 폴더에 복사한다"는 의도를 명확하게 드러냅니다.

data.yaml 재설정

        # E. data.yaml 생성
        yaml_path = train_root / "data.yaml"
        with open(yaml_path, "w") as f:
            f.write(f"path: {train_root}\n")
            f.write("train: train/images\n")  # ← 이제 진짜 train
            f.write("val: val/images\n")      # ← 이제 진짜 val
            f.write(f"nc: {len(classes)}\n")
            f.write(f"names: {classes}\n")

두 줄이 달라졌습니다. train: imagestrain: train/images, val: imagesval: val/images. 이 두 줄 수정만으로 mAP의 거짓말이 진짜 값으로 바뀌었습니다.

6. 엣지 케이스: 5장 미만

사용자가 이미지 2장만 가지고 학습을 시작할 수 있어야 합니다. 일반적으로 이런 초소형 데이터셋은 ML 관점에서 의미가 없지만, "일단 파이프라인이 동작하는지 확인"하는 데는 유효합니다. 교육용·데모용 사용도 많습니다.

이 경우 80/20 분할을 그대로 적용하면 val이 0장이 되어 YOLO가 에러를 냅니다. 그래서 이렇게 처리했습니다.

val_imgs = shuffled[split_idx:] if len(shuffled) >= 5 else shuffled[:1]
  • 5장 이상: 정상적인 80/20 분할 적용
  • 5장 미만: train_imgs에 전부 들어가고, val_imgsshuffled[:1]로 첫 1장을 공유

5장 미만일 때는 "분할의 엄밀함"보다 "학습 파이프라인이 돌아가는 것"을 우선했습니다. 이 경우 val 메트릭은 과적합 값을 돌려주지만, UI에 "이미지가 너무 적어 val 메트릭 신뢰도 낮음" 경고를 띄울 여지가 있습니다. 최소한 사용자가 4장이 있을 때 3장은 train, 1장은 val이 되어 완전한 동일성은 피할 수 있습니다.

왜 5장인가?

80/20 분할에서 5장은 int(5 * 0.8) = 4, val = 5 - 4 = 1장의 경계점입니다. 4장 이하에서는 int(4 * 0.8) = 3, val = 4 - 3 = 1장이 되긴 하지만, 데이터가 매우 적을 때는 분할 자체의 의미가 약해지므로 안전하게 5장을 분기점으로 잡았습니다. 이 숫자는 "작은 데이터셋 허용"이라는 도메인 요구와 "분할의 의미 유지"라는 통계적 타당성 사이의 타협입니다.

후기: 이 엣지 케이스 처리는 PR #40 리뷰 라운드 2에서 추가로 수정됐습니다. 5장 미만일 때 "train과 val 중복 복사"가 발생할 수 있어, 최종 버전에서는 무조건 proper split을 쓰되 5장 미만 시 최소 이미지 수를 높이는 방향(2장 → 각 1장씩)으로 조정됐습니다. 엣지 케이스는 언제나 여러 번의 반복 검토가 필요합니다.

7. 내보내기 ZIP의 dataset.yaml도 수정

이 프로젝트는 라벨링된 데이터를 YOLO 표준 ZIP으로 내보내는 기능도 있습니다. ZIP 안에 포함되는 dataset.yaml도 같은 버그가 있었습니다.

Before

# dataset.yaml in exported ZIP (Before)
path: .
train: images
val: images    # ← 여기도 같은 문제
nc: 3
names: ["cat", "dog", "bird"]

After

# dataset.yaml in exported ZIP (After)
path: .
train: train/images
val: val/images
nc: 3
names: ["cat", "dog", "bird"]

Python 코드에서 생성하는 부분도 수정:

# routes/export.py (또는 해당 서비스)
yaml_content = f"""path: .
train: train/images
val: val/images
nc: {len(classes)}
names: {json.dumps(classes, ensure_ascii=False)}
"""

내보낸 ZIP을 사용자가 다른 환경에서 학습에 쓸 때도 같은 버그가 재발하지 않도록, 내부 학습 파이프라인외부 내보내기 양쪽을 일관되게 수정해야 합니다. 한 곳만 고치면 내부에서는 해결됐지만 사용자 손에 들어가면 다시 같은 메트릭 거짓말이 발생할 수 있습니다.

8. 재현 가능성을 위한 seed 고정

_rng.seed(42)를 추가한 이유는 단순합니다. 같은 입력으로 두 번 학습했을 때 결과가 재현돼야 합니다. 고정 seed 없이 random.shuffle()을 쓰면 매번 다른 분할이 만들어지고, 리그레션 테스트가 불가능해집니다.

seed 42는 관습적인 더미 값입니다(Hitchhiker's Guide to the Galaxy에서 유래). 임의의 고정 정수면 무엇이든 괜찮지만, 팀 내에서 관용구로 쓰는 값이 있다면 그쪽을 따르는 편이 좋습니다.

중요한 점은 random.seed(42)글로벌 상태 를 건드린다는 것입니다. 다른 코드에서 random.random()을 쓰고 있다면 이 seed 설정이 부작용을 만들 수 있습니다. 그래서 코드에서는 import random as _rng로 별칭을 만들고, _rng.seed(42)를 부르기는 합니다만, 사실 Python의 random 모듈은 글로벌 singleton이므로 별칭을 써도 글로벌 상태에 영향을 줍니다.

더 안전한 방법은 별도 Random 인스턴스를 만드는 것입니다.

# 더 안전한 형태 (선택적 개선)
rng = random.Random(42)
shuffled = list(labeled_imgs)
rng.shuffle(shuffled)

random.Random(42)는 글로벌 상태와 독립적인 PRNG 인스턴스를 만듭니다. 이 프로젝트의 현재 코드는 글로벌 seed를 쓰지만, 다른 코드에서 random을 쓰지 않는 컨텍스트라 문제가 없었습니다. 프로덕션 수준에서는 인스턴스 기반으로 바꾸는 것이 권장됩니다.

9. 검증: 고친 뒤 실제로 달라졌나

코드를 수정한 뒤 동일한 이미지 100장으로 학습을 돌렸습니다.

지표 Before After
mAP50 (학습 로그) 0.95 0.72
mAP50 (테스트 세트) ~0.50 0.68
train loss / val loss 수렴 거의 동일 (의심 징후) 정상 간격 유지

After의 "학습 로그 mAP50"이 낮아진 것은 문제가 고쳐진 신호입니다. 이전에는 학습 데이터를 그대로 검증용으로 다시 쓰니까 0.95라는 과장된 값이 나왔고, 이제 실제 val 세트로 평가하니 0.72라는 현실적 값이 나옵니다. 그리고 테스트 세트와의 격차(0.72 → 0.68)도 훨씬 가까워졌습니다.

이 지표의 변화를 사용자에게 설명할 때는 신중해야 합니다. "전보다 수치가 나빠진 것처럼 보이지만, 이전 수치가 거짓말이었다" 는 설명이 필요합니다. 이런 종류의 수정은 사용자가 의심할 수 있는 변화이므로, 릴리스 노트에 반드시 맥락을 적어 두어야 합니다.

10. 왜 이 버그가 테스트로 잡히지 않을까

"테스트를 쓰면 이런 버그를 잡을 수 있었을 텐데"라는 반응이 나올 수 있습니다. 하지만 사실은 조금 더 복잡합니다.

단위 테스트는 이 버그를 놓친다

run_yolo_train()에 대한 단위 테스트를 작성한다면, 대개 data.yaml 내용을 직접 읽어 "train과 val 경로가 올바른가" 확인하게 됩니다. 하지만 개발자가 처음부터 두 경로가 같아도 괜찮다고 생각했다면, 테스트에도 그 잘못된 기대가 반영됩니다.

통합 테스트도 대부분 놓친다

실제 YOLO를 돌려 학습 결과를 비교하는 통합 테스트는 느리고 비결정적입니다. 게다가 에러가 나지 않으므로 "학습이 끝났다"가 성공 조건이 되어, 메트릭의 의미는 검증되지 않습니다.

이 버그를 잡으려면 "의미 검증" 이 필요하다

  • train과 val이 서로 다른 이미지 집합인가?
  • val의 이미지가 train에 포함되지 않는가?
  • val 메트릭이 train 메트릭과 통계적으로 다른 분포인가?

이것을 테스트로 쓰는 것은 가능하지만, 일반적인 단위 테스트 문화에서는 잘 안 쓰는 종류의 검증입니다. 결국 이 버그를 잡는 가장 효과적인 방법은 "사용자 피드백" 이었습니다. 재미있는 아이러니입니다.

대안으로 할 수 있는 것은 "배포 후 관찰" 레벨에서 train/val 디렉토리의 실제 파일 셋이 겹치는지 체크하는 sanity 함수를 한 번 돌리는 것입니다. 이 프로젝트는 아직 그 단계까지는 가지 못했지만, 다음 개선 과제로 기록해 두었습니다.

11. 핵심 개념 정리

개념 역할
train/val 분리 학습 데이터와 검증 데이터의 물리적 독립 — 메트릭의 의미 보존
고정 seed 분할 재현 가능성 — 같은 입력에 같은 분할 보장
YOLO data.yaml Ultralytics가 학습/검증 경로를 읽는 설정 파일
엣지 케이스 분기 5장 미만 데이터에서도 파이프라인이 동작하는 절충
내부 학습과 외부 내보내기의 일관성 같은 버그가 두 경로 모두에 복제돼 있어 양쪽을 함께 수정
random.Random(instance) 글로벌 상태를 건드리지 않는 안전한 PRNG 사용

12. 베스트 프랙티스 체크리스트

  • [ ] data.yamltrainval 경로가 실제로 서로 다른가요?
  • [ ] 데이터 분할에 고정 seed가 있어 재현 가능한가요?
  • [ ] seed를 글로벌 상태가 아닌 인스턴스(random.Random(42))에 적용했나요?
  • [ ] 소규모 데이터셋(< 5장) 엣지 케이스가 정의돼 있나요?
  • [ ] 내부 학습과 외부 내보내기가 같은 분할 규칙을 쓰나요?
  • [ ] train/val 파일 셋의 중복 여부를 sanity 체크할 수 있나요?
  • [ ] 학습 로그의 mAP가 테스트 세트와 "너무 잘 일치"하지 않나요?
  • [ ] 릴리스 노트에 "메트릭 하락은 버그 수정의 결과" 맥락이 기록돼 있나요?

13. FAQ

Q1. 왜 70/30이나 90/10이 아니라 80/20인가요?
A. 80/20은 대부분의 컴퓨터 비전 도메인에서 쓰이는 관습값이고, 특별한 통계적 이유보다 "검증 데이터가 너무 적지 않으면서 학습 데이터도 충분히 남긴다"는 균형점입니다. 학습 데이터가 아주 많으면 90/10도 괜찮고, 적으면 70/30이 더 안정적입니다. 이 프로젝트는 사용자별로 학습 데이터 규모가 크게 다르므로 중간값인 80/20을 선택했습니다.

Q2. seed 42가 아니라 다른 수를 써도 되나요?
A. 네, 어떤 고정 정수든 괜찮습니다. 42는 Douglas Adams의 Hitchhiker's Guide to the Galaxy에서 "삶과 우주, 그리고 모든 것에 대한 궁극적인 해답"으로 나온 관습적 더미 값입니다. 팀 내에 다른 관례가 있다면 그것을 따르면 됩니다. 중요한 것은 "고정돼 있다"는 점이지 특정 숫자가 아닙니다.

Q3. shuffled[:1]이 아니라 shuffled.copy()로 전부 복사하면 안 되나요?
A. 5장 미만에서 train과 val이 완전히 동일해지면, 원래의 "mAP 거짓말" 버그가 그대로 재발합니다. shuffled[:1]로 최소 1장이라도 겹치는 범위를 작게 만드는 것이 절충이었습니다. PR #40 리뷰 라운드 2에서는 이 부분이 더 개선되어, 최소 이미지 수 요구(2장)를 통해 항상 proper split이 되도록 바뀌었습니다.

Q4. YOLO가 train == val일 때 경고를 띄우지 않는 이유는?
A. Ultralytics는 "사용자가 의도적으로 그렇게 설정했을 수도 있다"는 가정으로 동작합니다. 예를 들어 전이 학습의 아주 초기 단계에서 학습 데이터 자체의 메트릭을 빠르게 보고 싶을 때가 있습니다. 그래서 강제 에러로 만들기보다 "사용자 책임"으로 두는 설계입니다. 다만 이 덕분에 우리 같은 버그가 조용히 들어올 수 있으므로, 파이프라인을 만드는 개발자 쪽에서 sanity 체크를 해야 합니다.

Q5. 테스트를 어떻게 쓸 수 있었을까요?
A. 두 가지 방향이 있습니다. ① 메타 테스트: 학습 준비가 끝난 뒤 data.yaml을 파싱해 train != val인지 확인. ② 파일 셋 중복 검사: set(os.listdir(train_dir)) & set(os.listdir(val_dir))가 비어 있는지 확인. 이 정도는 단위 테스트로 구현 가능하고, 향후 이 프로젝트에도 추가할 계획입니다.

Q6. random.Random(42)random.seed(42)의 차이가 왜 중요한가요?
A. random.seed(42)글로벌 random 모듈의 내부 상태를 바꿉니다. 같은 프로세스 안에서 다른 코드가 random.choice()를 썼다면 그 결과도 영향을 받습니다. random.Random(42)격리된 PRNG 인스턴스를 만들어 글로벌 상태를 건드리지 않습니다. 사이드 이펙트 측면에서 더 안전한 선택입니다.

14. 참고 자료

  • Ultralytics YOLOv8 공식 문서 (data.yaml 포맷): 검색 키워드 ultralytics yolov8 data yaml config
  • Python 공식 문서 (random.Random): 검색 키워드 python random Random class instance
  • Train/Val/Test Split 모범 사례: 검색 키워드 train val test split best practices
  • scikit-learn train_test_split (참고용): 검색 키워드 sklearn train_test_split stratify

15. 다음 단계

다음 편에서는 이미지 라벨링 플랫폼이 단일 task(detection)만 지원하던 것을, detection / classification / segmentation 세 가지를 모두 지원 하도록 확장한 과정을 다룹니다. 프론트엔드 드롭다운은 이미 존재했지만 백엔드가 무시하고 있던 task 파라미터를 실제로 분기 처리하고, task별 데이터셋 포맷(bbox / ImageFolder / polygon)을 자동 변환하는 구조를 만들었습니다.

🐍 Flask 백엔드 실전 시리즈 (9부작)

  1. 모놀리식 app.py를 Blueprint로 분해하기
  2. JSON 파일 DB에서 PostgreSQL로 마이그레이션
  3. ML 학습 백그라운드 실행: ProcessPoolExecutor
  4. Python/SQLAlchemy N+1 쿼리 잡기
  5. train과 val이 같은 폴더일 때의 조용한 ML 버그 (현재 글)
  6. 하나의 백엔드로 Detection/Classification/Segmentation
  7. Fail-fast 설정 검증과 Path Traversal 방어
  8. Rate Limiting을 걷어낸 날: 폐쇄망 보안
  9. 작지만 기억할 만한 네 가지 교훈