Make 노코드 & AI 자동화 전문가 과정

2주차: 데이터 처리와 시나리오 설계

코딩쉐프 2025. 9. 23. 12:52
728x90

1. 들어가며

2주차에서는 자동화를 더 단단하게 만드는 핵심인 데이터 처리와 시나리오 설계를 다룬다.
구체적으로는 문자열 정규화, 중복 제거, 키워드 기반 분류, 조건 분기(라우터) 설계, 로깅과 에러 처리, 일일 리포트 생성까지 실습한다.
1~2주차는 코드 중심으로 원리를 익히고, 3주차부터는 동일한 로직을 Make의 필터·라우터로 본격 구현한다.


2. 학습 목표

  1. 원시 데이터를 정규화하고 중복을 제거하는 실무 패턴 이해
  2. 키워드 규칙으로 카테고리를 분류하고, 카테고리별 분기 처리 설계
  3. Google Sheets에 정리본을 누적 저장하고, Slack으로 요약 리포트 발송
  4. 나중에 Make 라우터/필터로 옮길 수 있는 구조로 함수화

3. 실행 전 준비(튜토리얼로 분리)

아래 항목은 이미 구성되어 있거나, 별도 튜토리얼에서 다룬다. 본문에서는 상세 설치 안내를 생략한다.

이 글은 위 준비가 끝났다는 전제에서 코드를 바로 실행한다고 가정한다.


4. 시나리오 개요

가상의 “고객 문의” 데이터를 정리·분류해 시트에 저장하고, 그날 처리 현황을 Slack으로 요약한다.

흐름

  1. 원시 데이터 수집(예시 데이터 또는 기존 시트)
  2. 정규화(공백·대소문자·특수문자 처리) 및 중복 제거
  3. 키워드 매칭으로 카테고리 분류
  4. 카테고리별 분기 처리(예: 긴급은 즉시 알림, 일반은 누적 저장)
  5. Google Sheets에 정리본 저장
  6. Slack으로 일일 요약 리포트 발송

5. 실습 코드: 데이터 처리 → 시트 저장 → Slack 리포트

파일명 예시: week2_pipeline.py
필요 라이브러리: gspread, oauth2client, requests (1주차에서 설치 완료)
설치 안내는 별도 튜토리얼로 분리

# week2_pipeline.py
# ---------------------------------------------
# 2주차 실습: 데이터 처리 + 시나리오 설계 파이프라인
# - 가상의 "고객 문의" 데이터 정리/분류/저장/리포트
# - Google Sheets: 서비스 계정 JSON, 사전 공유 권한 필요
# - Slack: Incoming Webhook URL 필요
# ---------------------------------------------

import os
import re
import json
import hashlib
from datetime import datetime, date
from collections import Counter, defaultdict

import requests
import gspread
from oauth2client.service_account import ServiceAccountCredentials

# ---------------------------------------------
# 0) 환경설정
#    - CREDENTIALS_FILE: 서비스 계정 키 파일명
#    - SPREADSHEET_NAME: 사용할 구글 시트 이름
#    - SLACK_WEBHOOK_URL: 발급받은 Webhook URL
# ---------------------------------------------

CREDENTIALS_FILE = "credentials.json"  # 서비스 계정 키 파일
SPREADSHEET_NAME = "자동화_테스트"              # 1주차와 동일 시트 사용 가능
SLACK_WEBHOOK_URL = "https://hooks.slack.com/services/XXXX/YYYY/ZZZZ"  # 별도 튜토리얼

# 시트 내 워크시트(탭) 명칭
RAW_SHEET  = "원본_문의"     # 원시 데이터를 참고로 저장(옵션)
CLEAN_SHEET = "정리_문의"    # 정규화/분류 적용된 결과 저장
REPORT_SHEET = "리포트_일일"  # 일일 요약 수치 저장

# ---------------------------------------------
# 1) Google Sheets 인증 및 핸들 준비
#    - 서비스 계정 JSON 사용
#    - 시트를 열고, 필요한 워크시트가 없다면 생성
# ---------------------------------------------

def auth_sheets():
    scope = [
        "https://spreadsheets.google.com/feeds",
        "https://www.googleapis.com/auth/drive"
    ]
    creds = ServiceAccountCredentials.from_json_keyfile_name(CREDENTIALS_FILE, scope)
    client = gspread.authorize(creds)
    return client

def open_or_create_ws(sh, title, rows=1000, cols=20):
    """스프레드시트 객체(sh)에서 워크시트(title)를 열거나 없으면 새로 만든다."""
    try:
        return sh.worksheet(title)
    except gspread.WorksheetNotFound:
        return sh.add_worksheet(title=title, rows=rows, cols=cols)

# ---------------------------------------------
# 2) 유틸: 정규화, 해시, 분류 규칙
# ---------------------------------------------

def normalize_text(s: str) -> str:
    """
    텍스트 정규화:
      - 앞뒤 공백 제거
      - 여러 공백을 단일 공백으로
      - 특수문자 일부 제거
      - 소문자 변환(영문 기준)
    """
    if s is None:
        return ""
    s = s.strip()
    s = re.sub(r"\s+", " ", s)
    s = re.sub(r"[^\w\s\-@.\(\)\[\]]", "", s)  # 필요에 맞게 조정
    return s.lower()

def make_dedupe_key(record: dict) -> str:
    """
    중복 판별을 위한 해시 키 생성.
    제목/보낸사람/본문요약 등 핵심 필드를 정규화하여 연결한 뒤 SHA1 해시.
    """
    key_src = f"{normalize_text(record.get('subject',''))}|{normalize_text(record.get('sender',''))}|{normalize_text(record.get('body',''))}"
    return hashlib.sha1(key_src.encode("utf-8")).hexdigest()

# 카테고리 규칙 예시: 필요에 맞게 계속 추가/수정
CATEGORY_RULES = {
    "결제":      ["결제", "카드", "환불", "영수증", "billing", "payment", "refund"],
    "계정":      ["로그인", "비밀번호", "계정", "가입", "탈퇴", "password", "account", "sign in"],
    "기능요청":   ["기능", "요청", "개선", "feature", "request"],
    "버그":      ["오류", "버그", "에러", "error", "bug", "동작 안함", "안돼"],
    "기타":      []
}

def classify(text: str) -> str:
    """
    매우 단순한 규칙 기반 분류기.
    우선순위는 상단 카테고리부터. 여러 키워드가 걸리면 먼저 매칭된 카테고리로.
    """
    t = normalize_text(text)
    for cat, keywords in CATEGORY_RULES.items():
        for kw in keywords:
            if kw in t:
                return cat
    return "기타"

# ---------------------------------------------
# 3) 예시 원시 데이터
#    - 실제 환경에서는 Gmail API, 폼 응답, 기존 시트 등에서 수집
#    - 여기서는 즉시 실행 가능하도록 코드 내부에 샘플 정의
# ---------------------------------------------

def sample_raw_data():
    return [
        {"date": "2025-09-23", "sender": "user1@example.com", "subject": "결제 영수증 발급 부탁드립니다", "body": "지난 주문에 대한 영수증이 필요합니다."},
        {"date": "2025-09-23", "sender": "user2@example.com", "subject": "로그인 오류로 접속이 안됩니다", "body": "비밀번호 재설정도 안돼요."},
        {"date": "2025-09-23", "sender": "user3@example.com", "subject": "신규 기능 요청", "body": "대시보드 필터가 있으면 좋겠습니다."},
        {"date": "2025-09-23", "sender": "user2@example.com", "subject": "로그인 오류로 접속이 안됩니다", "body": "중복 테스트를 위해 동일 건을 한 번 더 넣습니다."},
        {"date": "2025-09-22", "sender": "user4@example.com", "subject": "앱이 자꾸 멈춰요", "body": "어제 밤부터 오류가 발생합니다."}
    ]

# ---------------------------------------------
# 4) 정규화 + 중복 제거 + 분류
# ---------------------------------------------

def clean_and_classify(records):
    """
    입력: 원시 레코드 리스트
    출력: 정리된 레코드 리스트 (중복 제거, 카테고리, 해시 포함)
    """
    seen = set()
    cleaned = []
    for r in records:
        item = {
            "date": r.get("date") or date.today().isoformat(),
            "sender": normalize_text(r.get("sender", "")),
            "subject": normalize_text(r.get("subject", "")),
            "body": normalize_text(r.get("body", "")),
        }
        item["category"] = classify(item["subject"] + " " + item["body"])
        item["hash"] = make_dedupe_key(item)

        if item["hash"] in seen:
            continue  # 중복 제거
        seen.add(item["hash"])
        cleaned.append(item)
    return cleaned

# ---------------------------------------------
# 5) 시트에 쓰기 + 리포트 생성
# ---------------------------------------------

def write_rows(ws, rows, header):
    """
    워크시트에 헤더를 보장하고, rows를 append.
    rows: 리스트[리스트] 형태
    """
    # 헤더 보장
    try:
        current = ws.get_all_values()
        if not current:
            ws.append_row(header)
        else:
            # 헤더가 다르면 교체를 고려할 수 있으나, 여기선 단순 보존
            pass
    except Exception:
        # get_all_values 실패 시에도 append_row로 진행
        ws.append_row(header)

    # 데이터 쓰기
    for row in rows:
        ws.append_row(row)

def build_daily_report_rows(cleaned, target_date=None):
    """
    일일 리포트용 집계 데이터 생성.
    target_date가 None이면 오늘 날짜 기준으로 필터링.
    """
    if target_date is None:
        target_date = date.today().isoformat()

    filtered = [r for r in cleaned if r["date"] == target_date]
    counts = Counter(r["category"] for r in filtered)
    total = sum(counts.values())

    # 리포트 행: 날짜, 총건수, 카테고리A, 카테고리B, ...
    cats = list(CATEGORY_RULES.keys())
    row = [target_date, total] + [counts.get(c, 0) for c in cats]
    header = ["date", "total"] + cats
    return header, [row]

def post_slack_report(cleaned, target_date=None):
    """
    Slack으로 간단한 텍스트 요약 발송.
    """
    if not SLACK_WEBHOOK_URL or SLACK_WEBHOOK_URL.startswith("https://hooks.slack.com/services/XXXX"):
        print("SLACK_WEBHOOK_URL 미설정. 리포트 전송 생략.")
        return

    if target_date is None:
        target_date = date.today().isoformat()

    filtered = [r for r in cleaned if r["date"] == target_date]
    counts = Counter(r["category"] for r in filtered)
    total = sum(counts.values())

    lines = [f"[{target_date}] 처리 요약", f"- 총 {total}건"]
    for cat in CATEGORY_RULES.keys():
        lines.append(f"- {cat}: {counts.get(cat, 0)}건")

    payload = {"text": "\n".join(lines)}
    resp = requests.post(SLACK_WEBHOOK_URL, json=payload, timeout=10)
    if resp.status_code != 200:
        print("Slack 전송 실패:", resp.text)

# ---------------------------------------------
# 6) 라우터(조건 분기) 예시
#    - 카테고리에 따라 다른 처리를 수행하는 분기 지점
#    - Make 3주차에서 'Router/Filter'로 동일 개념을 구현
# ---------------------------------------------

def handle_urgent(record):
    """
    예: '버그'는 긴급 알림이 필요하다고 가정.
    실제로는 담당자 채널로 즉시 슬랙 메시지를 보내거나,
    티켓 시스템 이슈를 생성하는 로직을 배치할 수 있다.
    """
    print("[긴급] 버그 신고 감지:", record["subject"])

def handle_normal(record):
    """
    일반 건은 시트에 누적 저장하는 것으로 충분하다고 가정.
    """
    print("[일반] 누적 대상:", record["subject"])

def route_by_category(record):
    if record["category"] == "버그":
        handle_urgent(record)
    else:
        handle_normal(record)

# ---------------------------------------------
# 7) 메인 파이프라인
# ---------------------------------------------

def main():
    # 1) 데이터 수집 (예시 데이터)
    raw = sample_raw_data()

    # 2) 정리/분류/중복제거
    cleaned = clean_and_classify(raw)

    # 3) 카테고리 라우팅(분기 처리)
    for rec in cleaned:
        route_by_category(rec)

    # 4) Google Sheets 쓰기
    client = auth_sheets()
    sh = client.open(SPREADSHEET_NAME)

    ws_clean = open_or_create_ws(sh, CLEAN_SHEET)
    # 정리본을 행 형태로 변환
    header_clean = ["date", "sender", "subject", "body", "category", "hash"]
    rows_clean = [[r["date"], r["sender"], r["subject"], r["body"], r["category"], r["hash"]] for r in cleaned]
    write_rows(ws_clean, rows_clean, header_clean)

    # 5) 일일 리포트 시트 갱신
    ws_report = open_or_create_ws(sh, REPORT_SHEET)
    header_rep, rows_rep = build_daily_report_rows(cleaned)
    write_rows(ws_report, rows_rep, header_rep)

    # 6) Slack으로 요약 전송
    post_slack_report(cleaned)

    print("파이프라인 완료")

if __name__ == "__main__":
    main()

정리문의 탭
리포트 일일 탭


6. 코드 설명 요약

  • 정규화: 공백·특수문자·대소문자 처리를 통해 같은 의미의 텍스트를 동일하게 인식
  • 중복 제거: 정규화된 핵심 필드로 해시를 만들어 이미 처리된 레코드를 스킵
  • 분류: 단순 키워드 규칙으로 시작하되, 규칙 사전만 확장하면 운영 중에도 쉽게 조정 가능
  • 라우팅: 카테고리별로 다른 후속 처리 함수를 호출하는 분기 지점
  • 저장: 정리본을 하나의 워크시트에 누적, 일일 집계는 리포트 워크시트에 날짜별로 쌓기
  • 보고: Slack으로 일일 요약을 텍스트로 전송

7. 오류 처리 & 트러블슈팅 (1주차 경험 + 이번 실습 보완)

1) Service Account vs OAuth 혼동

  • 증상: ValueError: ('Unexpected credentials type', None, 'Expected', 'service_account')
  • 원인: 서비스 계정 JSON 대신 OAuth 클라이언트 JSON(credentials.json)을 gspread에 넣음
  • 해결: 서비스 계정 JSON 새로 생성, client_email을 시트 편집자로 공유
  • 정리:
    • Sheets/Drive(gspread) → 서비스 계정 JSON
    • Gmail/Calendar API → OAuth(credentials.json + token.json)

2) SpreadsheetNotFound <Response [200]>

  • 원인: (1) 시트 제목 오탈자, (2) 서비스 계정 미공유, (3) 시트 없음
  • 해결: 시트 제목 확인, 공유 확인, 없으면 새로 생성
from gspread.exceptions import SpreadsheetNotFound

def open_or_create_spreadsheet(client, name, share_to=None):
    try:
        return client.open(name)
    except SpreadsheetNotFound:
        sh = client.create(name)
        if share_to:
            sh.share(share_to, perm_type="user", role="writer")
        print("[INFO] URL:", sh.url)
        return sh

3) 파일명/경로 문제

  • 증상: FileNotFoundError: 'credentials.json'
  • 해결: JSON을 같은 폴더에 두고 코드의 CREDENTIALS_FILE과 일치시킴

4) 실행 방법/터미널

  • 증상: SyntaxError 혹은 week2_pipeline.py만 입력
  • 해결:
    cd D:\mkb\automation_lab
    python week2_pipeline.py
  • 라이브러리 미설치 시:
pip install gspread oauth2client requests

5) Slack no_team / 메시지 안 옴

  • 원인: URL 오타, 다른 워크스페이스 URL, 비공개 채널 미초대
  • 해결: Slack Webhook 새 발급 → curl 테스트 혹은 google sheet에서 json 파일의 client_email 주소로 권한 부여하기

 

curl -X POST "https://hooks.slack.com/services/T/B/KEY" \
     -H "Content-type: application/json" \
     --data "{\"text\":\"ping\"}"


6) ngrok / Interactivity

  • Flask 서버(app.py) 켜둔 상태에서 ngrok 실행
  • Slack 앱 → Interactivity Request URL에 https://<ngrok주소>/slack/actions 저장
  • ngrok 무료는 주소가 매번 바뀌므로 갱신 필요

7) 인코딩/한글

  • Windows CMD에서 깨지면:
chcp 65001

8) 멱등성/중복 삽입

  • make_dedupe_key() → 해시 생성 후 중복 방지
  • 키 기준 조절 가능 (날짜 포함 여부 등)

9) 환경변수/설정 분리(선택)

  • .env 파일로 SLACK_WEBHOOK_URL 등 관리

10) 디버그 팁

import json
with open(CREDENTIALS_FILE, "r", encoding="utf-8") as f:
    print("[DEBUG] service account:", json.load(f)["client_email"])

for s in auth_sheets().openall():
    print("[DEBUG] visible:", s.title)

기억 포인트 요약

  • Sheets = 서비스 계정 JSON + 공유
  • Gmail/Calendar = OAuth(credentials.json + token.json)
  • Slack = Webhook URL 정확히, 비공개 채널은 앱 초대
  • 실행은 반드시 python 파일명.py
  • 오탈자/공백/중복 주의

8. 시나리오 설계 팁

  1. 입력 표준화: 외부 소스가 여럿이면 내부 공통 스키마로 먼저 통일
  2. 무손실 원본 보관: 원시 데이터를 별도 탭에 보관하면 회귀 분석이 가능
  3. 결정 로직 분리: 분류 규칙, 라우팅 함수를 별도 모듈로 분리하면 Make 이관이 쉬움
  4. 오류 처리: 네트워크 예외, 인증 오류, 시트 권한 오류에 대한 재시도·로깅 설계
  5. 멱등성: 해시 키로 중복 삽입을 차단해 재실행에 안전하도록 구성
  6. 가시성: Slack 보고 외에 시트의 리포트 탭을 대시보드처럼 활용

9.Mini Project: 영업 리드 자동 분류 시스템

이번 2주차 실습에서 다룬 데이터 정규화, 중복 제거, 카테고리 분류, 시트 저장, Slack 보고 흐름은 Mini Project 단계에서 바로 확장됩니다.

프로젝트 목표

영업팀으로 유입되는 리드(잠재 고객 문의) 데이터를 자동으로 정리·분류하여 담당자가 빠르게 대응할 수 있도록 지원하는 시스템 구축.

 

# week2_leads.py
# ---------------------------------------------
# Mini Project: 영업 리드 자동 분류 시스템
# - 영업팀에 유입되는 리드 데이터를 자동으로 정리/분류/저장
# - Google Sheets + Slack 알림으로 연결
# ---------------------------------------------

import re, hashlib
from datetime import date
from collections import Counter
import gspread
from oauth2client.service_account import ServiceAccountCredentials
import requests

# ---------------------------------------------
# 환경 설정
# ---------------------------------------------
CREDENTIALS_FILE = "google_credentials.json"   # 서비스 계정 JSON
SPREADSHEET_NAME = "영업리드_자동분류"          # 새 시트 이름
SLACK_WEBHOOK_URL = "https://hooks.slack.com/services/XXXX/YYYY/ZZZZ"

RAW_SHEET   = "원본_리드"
CLEAN_SHEET = "정리_리드"
REPORT_SHEET = "일일_리포트"

# ---------------------------------------------
# Google Sheets 인증 함수
# ---------------------------------------------
def auth_sheets():
    scope = [
        "https://spreadsheets.google.com/feeds",
        "https://www.googleapis.com/auth/drive"
    ]
    creds = ServiceAccountCredentials.from_json_keyfile_name(CREDENTIALS_FILE, scope)
    return gspread.authorize(creds)

def open_or_create_ws(sh, title, rows=1000, cols=20):
    try:
        return sh.worksheet(title)
    except gspread.WorksheetNotFound:
        return sh.add_worksheet(title=title, rows=rows, cols=cols)

# ---------------------------------------------
# 데이터 처리 함수
# ---------------------------------------------
def normalize_text(s: str) -> str:
    """공백, 특수문자 정리 및 소문자 변환"""
    if not s: return ""
    s = s.strip()
    s = re.sub(r"\s+", " ", s)
    return s.lower()

def make_dedupe_key(record: dict) -> str:
    """이메일+회사+메시지 기반 해시 키 생성"""
    key_src = f"{record.get('email','')}|{record.get('company','')}|{record.get('message','')}"
    return hashlib.sha1(key_src.encode("utf-8")).hexdigest()

# 카테고리 규칙 예시
CATEGORY_RULES = {
    "핫리드": ["긴급", "데모 요청", "바로 상담", "가격 문의", "urgent", "demo"],
    "관심리드": ["관심", "추후 연락", "follow up", "later", "next month"],
    "스팸": ["광고", "스팸", "unsubscribe"],
    "기타": []
}

def classify(record: dict) -> str:
    text = normalize_text(record.get("message", "")) + " " + normalize_text(record.get("note", ""))
    for cat, keywords in CATEGORY_RULES.items():
        for kw in keywords:
            if kw.lower() in text:
                return cat
    return "기타"

# ---------------------------------------------
# 리드 데이터 예시
# 실제 환경에서는 웹폼, CRM, 이메일 API에서 들어옴
# ---------------------------------------------
def sample_leads():
    return [
        {"date": "2025-09-23", "email": "ceo@bigcorp.com", "company": "BigCorp", "message": "긴급: 이번주 바로 데모 요청합니다."},
        {"date": "2025-09-23", "email": "marketing@abc.com", "company": "ABC Inc.", "message": "다음달 연락 부탁드립니다."},
        {"date": "2025-09-23", "email": "spam@ads.com", "company": "ADSPAM", "message": "광고 제안드립니다"},
        {"date": "2025-09-22", "email": "user@example.com", "company": "StartupX", "message": "가격 정책이 궁금합니다."},
    ]

# ---------------------------------------------
# 정리 파이프라인
# ---------------------------------------------
def process_leads(raw_leads):
    seen = set()
    cleaned = []
    for r in raw_leads:
        rec = {
            "date": r.get("date") or date.today().isoformat(),
            "email": normalize_text(r.get("email","")),
            "company": normalize_text(r.get("company","")),
            "message": normalize_text(r.get("message","")),
        }
        rec["category"] = classify(rec)
        rec["hash"] = make_dedupe_key(rec)
        if rec["hash"] in seen:
            continue
        seen.add(rec["hash"])
        cleaned.append(rec)
    return cleaned

# ---------------------------------------------
# 저장 + 리포트
# ---------------------------------------------
def write_rows(ws, header, rows):
    if not ws.get_all_values():
        ws.append_row(header)
    for row in rows:
        ws.append_row(row)

def daily_report(cleaned, target_date=None):
    if not target_date:
        target_date = date.today().isoformat()
    filtered = [r for r in cleaned if r["date"] == target_date]
    counts = Counter(r["category"] for r in filtered)
    return target_date, counts

def post_slack_report(date_str, counts):
    lines = [f"[{date_str}] 영업 리드 요약"]
    total = sum(counts.values())
    lines.append(f"- 총 {total}건")
    for cat in CATEGORY_RULES.keys():
        lines.append(f"- {cat}: {counts.get(cat,0)}건")
    payload = {"text": "\n".join(lines)}
    requests.post(SLACK_WEBHOOK_URL, json=payload)

# ---------------------------------------------
# 메인 실행
# ---------------------------------------------
def main():
    raw = sample_leads()
    cleaned = process_leads(raw)

    client = auth_sheets()
    sh = client.open(SPREADSHEET_NAME)

    ws_clean = open_or_create_ws(sh, CLEAN_SHEET)
    header = ["date","email","company","message","category","hash"]
    rows = [[r["date"],r["email"],r["company"],r["message"],r["category"],r["hash"]] for r in cleaned]
    write_rows(ws_clean, header, rows)

    date_str, counts = daily_report(cleaned)
    ws_report = open_or_create_ws(sh, REPORT_SHEET)
    rep_header = ["date","total"] + list(CATEGORY_RULES.keys())
    rep_row = [date_str, sum(counts.values())] + [counts.get(c,0) for c in CATEGORY_RULES.keys()]
    write_rows(ws_report, rep_header, [rep_row])

    post_slack_report(date_str, counts)
    print("리드 자동 분류 완료!")

if __name__ == "__main__":
    main()

정리 리드 탭
일일 리포트 탭

전체 흐름

  • 리드 수집
  • 웹폼 응답, 이메일, CRM 등 다양한 소스에서 유입
  • Google Sheets 또는 데이터베이스에 기록
  • 데이터 처리
  • 입력값 정규화 (공백/대소문자/특수문자 처리)
  • 중복 제거 (같은 이메일·문의는 한 번만 반영)
  • 자동 분류
  • 키워드/규칙 기반 카테고리: 예) “즉시 상담 필요 / 추후 연락 / 스팸”
  • 필요 시 머신러닝/AI 모델로 고도화 가능
  • 워크플로우 실행
  • Google Sheets에 정리 저장
  • Slack으로 알림 발송 → 영업 담당자에게 자동 라우팅

 

10. 다음 주차 예고

3주차부터는 본 포스트의 파이프라인을 Make에서 라우터·필터·에러 핸들링 모듈로 구성한다.
동일한 규칙 사전을 모듈화해 코드와 노코드가 같은 결과를 내도록 설계한다.

728x90