대표님이 “KPI를 한번 정해보라”고 했다. 나는 KPI가 회사가 위에서 내려주는 숫자인 줄 알았는데, 직접 세우는 거였다. 스스로 목표를 정하고 달성하고 싶어졌고, 그러려면 먼저 내 작업 능력과 속도를 잴 수단이 필요했다. 이 툴은 거기서 시작했다.
슬랙에 올라온 “이 화면 이상한 것 같아요” 한 줄은 대개 흘러가 사라진다. SWING은 HR·재무·업무를 하나로 묶은 SaaS라 기능이 늘수록 버그도 늘었고, 이슈 추적을 정비해야 했다. Linear·Jira·노션 데이터베이스 다 후보였지만, 사내에서 실사용으로 검증하면서 나중에 SWING의 제품 기능으로 키울 수 있다고 생각해서 SWING 안에 직접 만들기로 했다.
왜 직접 만들었나
컨텍스트가 한곳에 있어야 한다. 업무 요청인지, 모바일 연차인지, 구독 관리인지 드롭다운 하나로 고르면 트리아지가 빨라진다. 외부 툴에서는 이 컨텍스트를 다 설정해줘야 할 것이다.
워크플로우가 배포 사이클과 같아야 한다. 대기중 → 확인중 → 처리중 → 수정완료 → 배포완료 → 해결됨. 이 흐름은 우리 팀의 실제 배포 사이클을 그대로 옮긴 것이다. 외부 툴 상태값을 여기 맞추는것보다는 처음부터 우리 언어로 만드는 편이 낫다.
MTTR을 직접 재고 싶었다. 등록부터 해결까지 걸린 평균 시간을 우리 DB에서 바로 계산하면 정밀하게, 원하는 축으로 슬라이싱할 수 있다.
상태 흐름과 MTTR
대기중(pending) │ ▼확인중(reviewing) ──→ 반려됨(rejected) ← [admin, 중복 연결] │ ▼처리중(in_progress) ─→ 취소됨(canceled) ← [리포터 본인 or admin] │ ▼수정완료(fixed) │ ▼배포완료(deployed) │ ▼해결됨(resolved) ← resolved_at 기록, MTTR 측정 종료resolved로 전환되는 순간 resolved_at이 찍힌다. MTTR은 resolved_at - created_at의 평균이다.
등록은 모든 직원이 한다. 관찰자가 많을수록 버그가 빨리 보이기 때문이다. 반면 상태 변경은 admin_employee만 한다. 아무나 상태를 바꾸면 워크플로우가 의미를 잃는다.
이력은 기억을 대신한다
이력 기능은 처음엔 굳이 싶었다. 내부 툴이고 팀끼리 맞춰가면 되지 않나. 생각을 바꾼 건, 시간이 지나면 누구도 정확히 기억하지 못하기 때문이다. “이 이슈 제목이 언제 바뀌었더라”, “상태가 처리중으로 넘어간 게 며칠이지”. 사소한 물음인데 기록이 없으면 사람의 기억에 기대야 하고, 그게 신뢰의 영역으로까지 번질 것 같았다.
issue_histories와 issue_comment_histories가 모든 변경의 발자국을 남긴다. 드로어 케밥 메뉴의 “수정 이력 보기”를 누르면 누가 언제 무엇을 바꿨는지 타임라인으로 펼쳐진다. 누굴 추궁하려는 게 아니라, 굳이 기억하지 않아도 되게 하려는 것이다.
반려와 중복 연결
반려할 때 상태만 바꾸는 게 아니라 어떤 이슈의 중복인지를 연결한다.
duplicate_of_id CHAR(36) NULL -- self-referencing FKrejected_reason VARCHAR(500) NULL같은 버그가 여러 사람에게 보인다는 건 그만큼 눈에 띈다는 뜻이다. duplicate_of_id로 연결된 수를 세면 “몇 명이 독립적으로 발견했는가”가 나오고, 그 수를 가중치로 우선순위를 자동 조정하는 데까지 쓸 수 있다.
알림 — 역할이 아니라 구독으로
이슈가 등록될 때 개발자에게 텔레그램 알림을 보내야 했다. 첫 생각은 “employee_role에 developer를 추가하면 되지 않나”였다. 하지만 employee_role은 권한 제어용이다. 알림 책임까지 얹으면 역할이 두 관심사를 동시에 진다. 직책(company_positions)으로 거르는 방법도 봤지만, 직책은 자유 텍스트라(“개발자”, “Frontend Dev”, “백엔드 엔지니어”…) 일관되게 필터링하기 어렵다.
결국 기존 알림 설정 테이블을 확장했다.
ALTER TABLE employee_notification_settings ADD COLUMN issue_alert_enabled BOOLEAN NOT NULL DEFAULT FALSE, ADD COLUMN issue_telegram_enabled BOOLEAN NOT NULL DEFAULT TRUE;기본값은 FALSE다. 일반 직원은 이슈 알림을 받지 않고, 어드민이 받을 사람만 토글을 켠다. 상위 알림 마스터 스위치가 꺼지면 이 토글도 자동으로 무력화돼, 알림 정책이 한 곳에서 일관되게 적용된다. 역할은 권한, 직책은 조직 구조, 알림은 알림 설정. 각 관심사가 제 테이블에 있다.
알림이라는 책임을 이미 알림을 담당하는 자리에 두니, 권한·조직 테이블은 깨끗이 남고 발송도 자연히 기존 알림 경로(TelegramService·EmployeeMessengerConnection)를 탄다. 별도 알림 시스템을 세우는 게 더 쉬웠을 수도 있다. 하지만 그건 알림 책임을 두 곳에 두는 것이다. 옳은 경계에 컬럼 하나를 더하는 쪽이 중복 없이 확장하는 길이다.
에픽 — 평면 목록을 프로젝트로 묶다
이슈가 쌓이자 평면 목록만으론 부족했다. “이 버그들은 다 연차 개편의 일부”처럼 묶을 상위 단위가 필요해, Jira의 Epic 같은 그룹을 더했다.
추가 비용은 또 컬럼 하나였다. issue_epics 테이블 하나와, issues에 붙인 epic_id FK 한 컬럼(nullable)이면 됐다. 게다가 ON DELETE SET NULL이라 에픽을 지워도 그 이슈들은 사라지지 않고 epic_id만 풀린다. 묶는 단위를 없애는 게 묶인 것들을 파괴하지 않는다.
에픽엔 진행률이 붙는데, 그걸 내려고 속한 이슈를 다 가져와 세지 않는다. 개수는 DB가 세게 둔다. GROUP BY 집계다.
issueCount = epic_id = :id AND deleted_at IS NULLresolvedCount = 위 + status IN ('resolved', 'deployed')progress = round(resolvedCount / issueCount * 100)이슈 목록은 에픽·상태·우선순위를 다중 선택 토글로 거른다. 평면 버그 목록이 에픽으로 묶이는 순간, 이 도구는 QA 게시판에서 작은 프로젝트 트래커로 넘어간다. 제품으로 키우려던 방향으로 한 걸음 더 간 셈이다.
내부 도구에서 제품 이야기로
이름을 issue로 둔 덕에 확장은 대개 컬럼 하나로 끝난다. reporter_type을 더하면 외부 출처를 갈라 고객 피드백 포털이 되고, project_id를 더하면 여러 프로덕트를 한 테이블에서 다룬다. 처음부터 그렇게 키울 수 있게 열어둔 자리다.
사내에서 매일 쓰다 보니 기능이 자연히 다듬어졌고, 팀장이 이걸 더 발전시켜 제품으로 내보면 어떻겠냐고 했다. 내부에서 쓰려고 만든 도구가 제품 이야기로 이어질 줄은 처음엔 몰랐다.