토스 러너스 하이 2기 3주차 회고
3주차에는 설계(Deployment Diagram)에서 정한 범위를 실제로 구현해 보고, tk-*.log 기반으로 에러 감지 → 시그니처 생성(중복 회피) → 로그 묶음 → PMS 티켓 생성 까지의 최소 동작을 확인했다.
구현은 PMS Desktop client(sgtk) 환경 전용으로 백그라운드 앱(tk-incidentreporter) 형태로 만들었다.
sgtk는 PMS Desktop 개발을 위한 환경(프레임워크)이며, 오픈소스로 공개되어 있다.
전체 구조
아래 이미지는 배포 다이어그램으로, 클라이언트(엔진/sgtk/DCC)와 중앙 PMS 관계, 그리고 내가 개발할 범위(tk-incidentreporter)를 보여준다.

개발환경을 쉽게 보여주고자 시각화하였다.
이번 주 목표(MVP 스코프)
- 멀티 OS(Windows/macOS/Linux)에서 공통 방식으로 지정 로그 경로 하위의
tk-*.log를 스캔하고 tail/follow 한다.- Windows:
%APPDATA%\Shotgun\logs\ - Linux:
~/.shotgun/logs/ - Mac:
~/Library/Logs/Shotgun/
- Windows:
- 로그 레벨 중
CRITICAL/ERROR를 감지하면, 엔진 단위로 로그를 묶고 실행 환경 정보를 수집한 뒤 PMS에 첨부하여 티켓을 자동 생성한다. - 최소 동작(MVP)으로 엔드투엔드 흐름(감지 → 수집/묶음 → 첨부 → 티켓 생성)을 검증한다.
전체 구조
레포의 핵심 파일/모듈은 다음과 같다.
tk-incidentreporter/
├─ info.yml, app.py
└─ python/tk_incident/
├─ bootstrap.py, autostart.py
├─ singleton.py # 프로세스 중복 방지
├─ tail_worker.py # 로그 tail/follow + 롤오버 처리
├─ matcher.py # ERROR/키워드 감지
├─ agent.py # 매칭 → 업로드 오케스트레이션
├─ uploader.py # sgtk로 PMS Ticket 생성 + 로그 첨부
└─ utils.py # 경로/환경 유틸
개발하면서 고려했던 점들
1) 싱글톤 (중복 실행 방지)
같은 머신에서 Desktop 앱이 여러 개 띄워질 수도 있고, 개발 중 에이전트를 여러 번 실행해 버리는 상황이 발생했다. 그래서 에이전트가 한 프로세스만 동작하도록 싱글톤을 넣었다.
처음에는 파일락을 생각했는데(PID), Desktop 앱이 Qt 기반이라 로컬 IPC인 QLocalServer를 사용했다. 로컬 소켓(Windows는 네임드파이프, Unix는 도메인 소켓)을 이용하면 플랫폼에 관계없이 간단하고 안전하게 이름(name)으로 락을 잡을 수 있다.
from Qt import QtNetwork
class SingletonLock(object):
def __init__(self, name):
self.name = name
self._server = None
def acquire(self):
server = QtNetwork.QLocalServer()
try:
server.removeServer(self.name) # stale socket cleanup
except Exception:
pass
ok = server.listen(self.name)
if ok:
self._server = server
return True
return False
def release(self):
if self._server:
try:
self._server.close()
except Exception:
pass
self._server = None
2) 로그 tail (TailWorker) + 롤오버 처리
로그 시스템은 파일 회전(rotate)이나 truncate가 발생할 수 있으므로, 각 파일에 대해 바이트 오프셋을 유지하고 inode와 파일 크기를 비교해 안전하게 follow 하도록 했다.
# tail_worker.py
from pathlib import Path
from Qt import QtCore
import time
class TailWorker(QtCore.QThread):
line_detected = QtCore.Signal(dict)
def _scan_and_read(self):
for f in self.log_folder.glob("tk-*.log"):
pstr = str(f)
if pstr not in self._files:
self._files[pstr] = {"pos": f.stat().st_size, "inode": getattr(f.stat(), "st_ino", None)}
for pstr, info in list(self._files.items()):
try:
p = Path(pstr)
st = p.stat()
inode = getattr(st, "st_ino", None)
if info.get("inode") and inode != info.get("inode"):
info["pos"] = 0
info["inode"] = inode
if st.st_size < info.get("pos", 0):
info["pos"] = 0
with open(pstr, "r", encoding="utf-8", errors="ignore") as fh:
fh.seek(info["pos"])
while True:
line = fh.readline()
if not line:
break
info["pos"] = fh.tell()
payload = {"path": pstr, "line": line.rstrip("\n"), "pos": info["pos"], "ts": time.time()}
self.line_detected.emit(payload)
except FileNotFoundError:
try:
del self._files[pstr]
except KeyError:
pass
except Exception:
pass
3) 매치 및 티켓 생성 정책(중복 방지 · 쓰로틀링)
import re
class Matcher(object):
"""
Minimal matcher: detect ERROR/CRITICAL only.
"""
def __init__(self, settings=None):
self.settings = settings or {}
self.severity_re = re.compile(r"\b(ERROR|CRITICAL)\b")
def match(self, line):
m = self.severity_re.search(line)
if m:
return {"reason": "severity", "level": m.group(1), "matched_line": line}
return None
클라이언트 환경은 동일한 오류가 매우 짧은 시간 안에 여러 번 발생할 수 있다. 특히 수정 전의 버전이 배포되어 있는 동안에는 동일한 오류가 여러 클라이언트에서 반복 발생해 티켓이 폭주할 위험이 크다. 이를 막기 위해 Uploader는 다음 원칙으로 설계 및 수정되었다.
핵심 아이디어
- 시그니처:
"<user_login> - <ErrorName or short message>"동일 사용자·동일 오류는 동일 제목으로 묶어, 중복 티켓 생성 방지. - 제목 기반 중복 검사: 생성 전에 PMS에 동일 제목 티켓이 있는지 확인. 있으면 생성/첨부를 건너뛴다.
시그니처 생성 패턴
# 시그니처 패턴
# "<user_login> - <ErrorName or short message>"
# 예: "alice - ValueError" 또는 "bob - Unknown error: file open failed"
def make_signature(user_login: str, matched_line: str) -> str:
core = extract_error_name(matched_line) or short_text(strip_ts(matched_line), max_len=80)
title = f"{sanitize(user_login)} - {sanitize(core)}"
return title
타임스탬프/프리픽스 제거 + 에러 이름 추출
import re, hashlib
def strip_ts(text: str) -> str:
t = re.sub(r'^\s*\d{4}-\d{2}-\d{2}\s+\d{1,2}:\d{2}:\d{2}(?:,\d+)?\s*', '', text, count=1)
t = re.sub(r'^\s*\[[^\]]+\]\s*', '', t, count=1)
return t
def extract_error_name(text: str) -> str | None:
m = re.search(r'([A-Za-z_][A-Za-z0-9_]*(?:Error|Exception))', text)
return m.group(1) if m else None
def short_text(text: str, max_len=80) -> str:
s = " ".join(text.strip().split())
return s[:max_len] if s else hashlib.sha1(text.encode('utf-8')).hexdigest()[:10]
def sanitize(s: str) -> str:
return re.sub(r'[\r\n\t]+', ' ', str(s)).strip()
업로드 플로우(요약)
def upload_log_flow(sg, user_login, log_path, trigger):
# 1) 시그니처 생성
title = make_signature(user_login, trigger.get("matched_line", ""))
# 2) 동일 제목 티켓 존재 확인 -> 있으면 종료(중복 방지)
existing = find_ticket_by_title(sg, "Ticket", title)
if existing:
return True
# 3) 티켓 생성
payload = {
"project": {"type": "Project", "id": 190},
"title": title,
"description": f"Matched line:\n{trigger.get('matched_line','')}\nDetected at: {trigger.get('detected_ts')}"
}
created = sg.create("Ticket", payload)
ticket_id = created.get("id")
# 4) 로그 첨부
ok = attach_log_with_retries(sg, "Ticket", ticket_id, log_path)
return ok
테스트
- Desktop에서
tk-incidentreporter앱을 로드한 뒤,tk-desktop.log/tk-maya.log에ERROR라인을 추가 → Agent가 감지하고 티켓을 생성함을 확인했다. - 동일 에러를 여러 번 발생시키는 시나리오에서 동일 제목의 티켓이 단 한 건만 생성됨을 확인했다.
- 로그 파일 rotate / truncate 상황에서 TailWorker가 재열거·재오픈하여 새 로그를 추적하는 것도 확인했다.
실제 PMS에 올라간 모습(현장 검증)
구현 후 가장 체감된 변화는 별도의 로그 요청 없이도 문제가 발생하면 자동으로 티켓이 생성되어 운영자가 바로 확인할 수 있게 된 점이다. 테스트 환경에서 로그를 트리거로 여러 건의 티켓이 PMS에 자동 생성되는 것을 확인했다. 각 티켓에는 감지된 매치 라인과 탐지 시각, 원본 로그 파일이 첨부되어 있어, 별도 로그 요청이나 전달 과정 없이도 즉시 원인 확인과 우선순위 판단이 가능해보인다.

스크린샷1 — 티켓 목록 동일한 사용자명-에러명 패턴으로 생성된 티켓들이 목록에 쌓여 있다.

스크린샷2 — 티켓 상세 티켓 본문에서 더 자세한 내용을 확인할 수 있다.
- 운영적 효과: 작업자가 오류를 리포트하고, 담당자가 “로그 어디 있나요?”를 묻고 회신을 기다리는 과정이 사라졌다. 덕분에 문제 인지부터 대응 착수까지의 시간이 단축되었고, 로그 수집·정리로 인한 운영 부담이 눈에 띄게 줄 것으로 기대된다.
- 자동 생성된 티켓 패턴: 여러 건의 티켓이 동일한 사용자명-에러명 패턴으로 생성되며, 현재는 이 패턴을 이용해 중복 티켓 폭주를 방지하고 있다.
감지 → 시그니처 생성(중복 회피) → 수집/첨부 → 티켓 생성/기록
이 흐름이 안정적으로 돌아가면, 이후에는 세부적인 운영 정책 수립과 품질 개선에 집중할 수 있으로 기대된다.
Leave a comment