배치 수행 이력 테이블을 주기적으로 조회해 실패 건을 Microsoft Teams 채널로 보내는 구성이다. 조회 → 가공 → 통보 세 단계이며, 각 단계에서 걸리기 쉬운 지점이 정해져 있다.
psycopg2 로 접속하고 결과는 pandas 로 받는다. 커넥션은 with 로 감싸 예외가 나도 닫히게 한다.
import os
import pandas as pd
import psycopg2
SQL = """
SELECT job_nm, job_end_dttm, job_state_cd, job_error_val
FROM jobexecution.job_execution
WHERE job_end_dttm::date = CURRENT_DATE
AND job_state_cd <> 'running'
AND job_nm NOT LIKE '%%발송%%'
ORDER BY job_end_dttm DESC
LIMIT 10
"""
with psycopg2.connect(
host=os.environ["PG_HOST"],
port=os.environ.get("PG_PORT", "5432"),
dbname=os.environ["PG_DB"],
user=os.environ["PG_USER"],
password=${MASKED}"PG_PASSWORD"],
) as conn:
df = pd.read_sql(SQL, conn)
두 가지를 짚어 둔다.
SELECT TOP(10) 은 SQL Server 문법이다. PostgreSQL 에서는 LIMIT 10 을 쓴다.psycopg2 의 파라미터 자리표시자는 %s 이므로, SQL 문자열 안의 리터럴 % 는 %% 로 이스케이프한다. 위의 LIKE '%%발송%%' 가 그 이유다.값은 문자열 결합이 아니라 파라미터로 넘긴다.
df = pd.read_sql("SELECT ... WHERE job_nm = %s", conn, params=(job_name,))
ImportError: cannot import name 'sql' from partially initialized module 'psycopg2'
(most likely due to a circular import) (C:\Users\user\Downloads\psycopg2.py)
괄호 안의 경로가 답이다. 작업 디렉터리에 psycopg2.py 라는 파일이 있어서 파이썬이 설치된 패키지 대신 그 파일을 읽었다. 파이썬은 스크립트가 있는 디렉터리를 모듈 검색 경로 맨 앞에 두므로, 라이브러리와 같은 이름의 파일을 만들면 항상 이런 일이 생긴다.
파일 이름을 바꾸고 옆에 생긴 __pycache__ 도 지운다. queue.py · json.py · select.py 처럼 표준 라이브러리와 겹치는 이름도 같은 함정이다.
def classify(name: str) -> str:
if name.startswith("daily_batch_anal"):
return "분석배치"
if name.startswith("ddyms_daily_"):
return "마트배치"
return "기타"
df["gubun"] = df["job_nm"].map(classify)
failed = df[df["job_state_cd"] == "fail"]
날짜 표기는 strftime 으로 만든다. 요일을 한글로 내려면 로케일에 기대지 말고 직접 매핑한다. 서버 로케일에 의존하면 실행 환경이 바뀔 때 조용히 영어로 돌아간다.
from datetime import datetime
WEEKDAY_KO = ["월", "화", "수", "목", "금", "토", "일"]
now = datetime.now()
title = f"Job Report {now:%Y-%m-%d %H:%M} ({WEEKDAY_KO[now.weekday()]})"
Teams 채널로 프로그램에서 메시지를 보내는 방법은 시기에 따라 달라졌다.
| 방식 | 상태 |
|---|---|
Office 365 Incoming Webhook (MessageCard) |
Microsoft 가 단계적 폐지를 진행했다. 신규 구성에는 쓰지 않는다 |
Power Automate 워크플로 + Post card in a chat or channel |
현재 권장 경로 |
| Microsoft Graph API (봇 · 애플리케이션 권한) | 멘션 등 기능이 가장 넓다. 테넌트 관리자 동의 필요 |
확인 필요 — 폐지 일정과 남은 유예 범위는 테넌트 설정과 시점에 따라 다르다. 도입 전에 조직의 Teams 관리자에게 현재 허용되는 방식을 확인한다.
어느 방식이든 파이썬 쪽은 JSON 본문을 POST 하는 형태로 같다. 달라지는 것은 엔드포인트 URL 과 본문 스키마다.
import json
import os
import requests
WEBHOOK_URL = os.environ["TEAMS_WEBHOOK_URL"]
def to_html_table(df: pd.DataFrame, title: str) -> str:
if df.empty:
return f"<h3>{title}</h3><p>해당 건 없음</p>"
rows = []
for _, r in df.iterrows():
status = r["job_state_cd"]
cell = (f"<td style='color:red'><b>{status}</b></td>"
if status == "fail" else f"<td>{status}</td>")
rows.append(
f"<tr><td>{r['job_nm']}</td><td>{r['job_end_dttm']}</td>"
f"<td>{r['job_error_val']}</td><td>{r['gubun']}</td>{cell}</tr>"
)
head = "<tr><th>Job</th><th>End</th><th>Error</th><th>구분</th><th>State</th></tr>"
return f"<h3>{title}</h3><table border='1'>{head}{''.join(rows)}</table>"
payload = {
"title": title,
"text": to_html_table(failed, "실패 건"),
}
resp = requests.post(
WEBHOOK_URL,
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"},
timeout=30,
)
resp.raise_for_status()
웹훅 URL 은 코드에 박지 않는다. URL 자체가 인증 수단이라 노출되면 누구나 그 채널에 글을 쓸 수 있다. 환경 변수나 별도 설정 파일에 두고 버전 관리에서 제외한다.
단순 웹훅 본문의 HTML 에 <at>이름</at> 을 넣어도 멘션으로 인식되지 않는다. 멘션은 메시지가 Adaptive Card 로 전달되고, 카드 안에 대상의 테넌트 사용자 ID(AAD Object ID 또는 UPN)가 msteams.entities 로 함께 실려야 성립한다. 이름 문자열만으로는 누구인지 특정할 수 없기 때문이다.
따라서 멘션이 필요하면 Power Automate 워크플로나 Graph API 경로로 가야 한다. 웹훅을 유지한 채 멘션만 붙이려는 시도는 성공하지 않는다.
리눅스에서는 crontab -e 로 등록한다. 형식은 분 시 일 월 요일 명령 이며 명령 앞에 공백이 필요하다.
# 매일 12:00 실행, 로그는 파일로
0 12 * * * /usr/bin/env bash -lc '/opt/batch/venv/bin/python /opt/batch/job_report.py' >> /var/log/job_report.log 2>&1
cron 은 로그인 셸의 환경을 물려받지 않는다. 가상 환경의 파이썬을 절대 경로로 지정하고, 필요한 환경 변수는 스크립트 안이나 crontab 상단에서 직접 정의한다. 웹훅 URL 같은 값을 .bashrc 에 넣어 두면 cron 에서는 읽히지 않는다.
crontab -e 는 EDITOR 환경 변수에 지정된 편집기를 연다. 기본이 vi 인 환경이 많다. 등록한 항목을 지우려면 그 줄을 삭제하고 저장한다. 전체를 지우는 crontab -r 은 되돌릴 수 없으므로 crontab -l > backup.txt 로 먼저 받아 둔다.
윈도우 작업 스케줄러는 PC 가 꺼져 있으면 실행되지 않는다. 항상 돌아야 하는 배치는 서버에 둔다.