![]()
문제: 버그 리포트가 카카오톡으로 온다
1인 개발로 학원 출결 관리 시스템을 운영하고 있다. 선생님들과 학부모들이 매일 쓰는 앱인데, 테스트를 혼자 다 하기엔 한계가 있다. 실사용 중 버그가 발생하면 리포트가 카카오톡이나 전화로 온다.
“그 화면에서 뭔가 안 돼요.”
어떤 화면인지, 무엇을 하다가 발생한 건지, 어떤 상태였는지 — 맥락이 전부 빠져 있다. 스크린샷이 오기도 하는데, 컴퓨터 모니터를 핸드폰으로 찍은 사진이다. 해상도가 낮은 건 둘째 치고, 모아레 패턴이 겹쳐서 텍스트가 안 읽힌다.
이걸 받고 나면 역추적이 시작된다. “그게 어떤 페이지였나요?” “로그인은 어떤 계정으로 하셨어요?” “브라우저가 크롬인가요?” — 질문 몇 번 주고받으면 하루가 간다.
버그를 고치는 것보다 버그를 재현하는 데 시간이 더 걸리는 상황이 반복됐다.
Ybug.io나 Usersnap 같은 유료 피드백 도구가 있다는 건 알고 있었다. 하지만 월 수십 달러 비용에, GitHub Issue 직접 연동이 안 되고, 해결 알림까지 이어지는 파이프라인은 없다. 필요한 건 딱 “스크린샷 + 맥락 → GitHub Issue → 해결 시 Slack 알림”이었다.
해결: 인앱 피드백 위젯 (F278)
앱 안에서 바로 스크린샷을 찍고, 그 위에 그림을 그려 어디가 문제인지 표시한 뒤, 메모와 함께 제출하면 GitHub Issue로 생성되는 위젯을 만들었다.
개발/테스트 환경에서만 화면 우하단에 보라색 플로팅 버튼이 뜬다. 누르면 브라우저가 현재 탭 캡처 권한을 요청하고, 허용하면 바로 어노테이션 모드로 전환된다.
동작 흐름은 네 단계다:
- idle — 플로팅 버튼 대기
- capturing — Display Media API로 브라우저 탭 캡처
- annotating — 캡처된 이미지 위에 그리기 + 메모 작성
- submitting — GitHub에 스크린샷 업로드 + Issue 생성
상태 머신으로 관리하니 각 단계별 UI 전환이 깔끔하다.
type WidgetState = 'idle' | 'capturing' | 'annotating' | 'submitting';
Display Media API를 선택한 이유
처음에는 html2canvas를 검토했다. DOM을 canvas로 변환해주니 사용자 동의 없이 캡처할 수 있어서 UX가 좋을 것 같았다. 하지만 실제 앱에 적용하면 문제가 많다.
- cross-origin iframe — 외부 서비스를 iframe으로 임베드하면 tainted canvas가 된다
- canvas/WebGL 요소 — 차트 라이브러리 같은 게 렌더링한 canvas는 비어 보인다
- CSS 호환성 — backdrop-filter, clip-path 같은 최신 CSS가 무시되거나 깨진다
- 성능 — DOM이 복잡한 페이지에서는 캡처에 수 초가 걸린다
navigator.mediaDevices.getDisplayMedia()는 브라우저가 실제로 렌더링한 화면을 그대로 캡처한다. 사용자에게 탭 선택 다이얼로그가 뜨긴 하지만, 정확한 캡처가 보장된다. preferCurrentTab: true 옵션을 주면 현재 탭이 기본 선택되어 클릭 한 번이면 끝난다.
const stream = await navigator.mediaDevices.getDisplayMedia({
video: { displaySurface: 'browser' } as MediaTrackConstraints,
audio: false,
preferCurrentTab: true,
} as DisplayMediaStreamOptions);
const track = stream.getVideoTracks()[0];
const video = document.createElement('video');
video.srcObject = stream;
video.muted = true;
await video.play();
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
canvas.getContext('2d')!.drawImage(video, 0, 0);
track.stop();
비디오 트랙에서 한 프레임만 추출한 뒤 즉시 track.stop()으로 스트림을 끊는다. 캡처 표시기(빨간 테두리, 공유 중 표시)가 잠깐 깜빡이고 사라진다.
어노테이션 캔버스: Pointer Events로 통일
캡처한 이미지 위에 직접 그림을 그릴 수 있어야 한다. 도구는 세 가지 — 자유 그리기(freehand), 사각형(rectangle), 화살표(arrow). 색상은 빨강, 파랑, 검정 세 가지.
마우스와 터치를 동시에 지원하기 위해 Pointer Events API를 사용했다. mousedown/touchstart를 따로 처리하지 않아도 된다.
const handlePointerDown = (e: React.PointerEvent) => {
e.preventDefault();
(e.target as HTMLElement).setPointerCapture(e.pointerId);
setIsDrawing(true);
const point = getCanvasPoint(e);
setCurrentStroke({ tool, color, points: [point] });
};

setPointerCapture가 핵심이다. 이걸 호출하면 포인터가 캔버스 밖으로 나가도 이벤트가 계속 해당 엘리먼트로 전달된다. 드래그 중 마우스가 캔버스 경계를 벗어나도 그리기가 끊기지 않는다.
캔버스 다시 그리기 전략
그리기 상태를 React state로 관리한다.
strokes— 확정된 획들의 배열currentStroke— 현재 그리고 있는 진행 중 획
useEffect에서 매 변경마다 전체 캔버스를 다시 그린다: 배경 이미지 + 확정된 획들 + 현재 획.
useEffect(() => {
const ctx = canvas.getContext('2d')!;
ctx.drawImage(backgroundImage, 0, 0, canvas.width, canvas.height);
for (const stroke of strokes) {
drawStroke(ctx, stroke);
}
if (currentStroke) {
drawStroke(ctx, currentStroke);
}
}, [backgroundImage, strokes, currentStroke]);
매번 전체를 다시 그리는 게 비효율적으로 보일 수 있는데, 실제로는 스크린샷 한 장 위에 몇 개의 도형을 그리는 정도라 성능 문제가 없다. 오히려 이 방식이 Undo(Ctrl+Z)를 간단하게 만들어준다 — strokes 배열에서 마지막 원소만 제거하면 이전 상태로 돌아간다.
스크린샷을 GitHub에 저장하는 방법
GitHub Issue body에 base64 이미지를 넣을 수 있을까? 안 된다. Issue body 크기 제한이 65KB고, 스크린샷 하나가 보통 수백 KB다.
대신 GitHub Contents API로 스크린샷 파일을 리포지토리에 직접 커밋한다.
async function uploadScreenshot(config: DevFeedbackConfig, blob: Blob): Promise<string> {
const base64Content = await blobToRawBase64(blob);
const filename = `feedback-screenshots/${Date.now()}.jpg`;
await githubFetch(
`https://api.github.com/repos/${config.githubRepo}/contents/${filename}`,
config.githubToken,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: `[feedback] screenshot ${new Date().toISOString()}`,
content: base64Content,
}),
},
);
return `https://github.com/${config.githubRepo}/blob/main/${filename}?raw=true`;
}
feedback-screenshots/ 디렉토리 아래에 타임스탬프 이름으로 JPEG 파일이 쌓인다. 업로드 전에 가로 1280px 이하로 리사이즈하고 JPEG quality 80%로 압축해서 파일 크기를 줄인다.
반환값으로 ?raw=true 파라미터가 붙은 blob URL을 사용한다. download_url을 쓰면 임시 토큰이 포함되어 있어서 시간이 지나면 이미지가 깨진다.
Issue Body 구조: 정규식으로 파싱 가능하게
Issue body는 나중에 GitHub Actions에서 파싱해야 하므로 구조화된 포맷으로 작성한다.
## Feedback
출석 체크 화면에서 시간표가 안 보입니다
## Screenshot

## Context
- **Author**: 김선생
- **Account**: teacher01 (T001)
- **Slack**: @김선생
- **URL**: https://app.checkus.kr/attendance
- **Browser**: Mozilla/5.0 ...
- **Viewport**: 1920x1080
- **Timestamp**: 2026-03-31T09:30:00.000Z
- **App**: teacher-web

모든 메타데이터가 **Key**: Value 패턴으로 되어 있어서 정규식 한 줄로 추출할 수 있다.
m = re.search(r'\*\*Author\*\*: (.+)', body)
author = m.group(1).strip() if m else "Unknown"
이슈 닫으면 Slack 알림이 간다
피드백을 제출하는 것만으로는 반쪽짜리다. 리포트한 사람이 “내 피드백이 처리됐는지” 알 수 있어야 피드백 루프가 완성된다.
GitHub Actions 워크플로우를 하나 만들었다. feedback 라벨이 붙은 이슈가 닫히면 트리거된다.
on:
issues:
types: [closed]
jobs:
notify:
if: contains(github.event.issue.labels.*.name, 'feedback')
runs-on: ubuntu-latest
워크플로우가 하는 일:
- Issue body에서 Author, Slack 닉네임, 작성 시간을 정규식으로 추출
- 이슈의 마지막 코멘트를 가져와서 해결 내용으로 사용
- Slack Bot Token으로
users.listAPI를 호출해서 닉네임에 매칭되는 Slack user ID를 찾음 - Webhook으로 멘션 포함 알림 전송
Slack 사용자 매칭은 display_name, real_name, username 세 필드를 비교한다.
for member in users.get("members", []):
p = member.get("profile", {})
dn = p.get("display_name", "")
rn = p.get("real_name", "")
un = member.get("name", "")
if slack_nick in (dn, rn, un):
mention = f"\n👋 <@{member['id']}>"
break
최종 Slack 메시지는 이런 형태로 온다:
03/31 09:30 👋 @김선생님이 리포트한 피드백이 해결되었습니다.
내용: 출석 체크 화면에서 시간표가 안 보입니다
로그인 계정: teacher01 (T001)
해결 내용:
시간표 쿼리에서 날짜 필터 조건이 빠져 있었습니다. 수정 완료.
링크: https://github.com/.../issues/42

리포트한 사람 입장에서는 따로 GitHub에 들어가 확인할 필요 없이, Slack 알림 하나로 “아 고쳐졌구나” 하고 끝난다.
전체 흐름
사용자가 버그 발견
|
v
플로팅 버튼 클릭
|
v
Display Media API → 브라우저 탭 캡처
|
v
어노테이션 캔버스 (그리기 + 메모)
|
v
Submit 클릭
|
+→ GitHub Contents API: 스크린샷 파일 커밋
|
+→ GitHub Issues API: 이슈 생성 (feedback 라벨)
|
v
개발자가 버그 수정 → 이슈 닫기 (코멘트에 해결 내용)
|
v
GitHub Actions 트리거 (issues.closed + feedback 라벨)
|
v
Python 스크립트: Issue body 파싱 → Slack user lookup
|
v
Slack Webhook → 리포터에게 멘션 알림
컴포넌트 구조
DevFeedback/
├── DevFeedbackWidget.tsx # 상태 머신 (idle → capturing → annotating → submitting)
├── ScreenCapture.ts # Display Media API 래퍼
├── DrawingCanvas.tsx # 어노테이션 캔버스 (Pointer Events + Canvas 2D)
├── FeedbackSender.ts # GitHub API 호출 (스크린샷 업로드 + Issue 생성)
└── types.ts # 공유 타입 (DrawingTool, FeedbackPayload, FeedbackContext)
한 기능이 다섯 파일로 분리되어 있다. 각 파일이 하나의 책임만 갖고, 서로의 내부 구현을 모른다. DevFeedbackWidget이 오케스트레이터 역할을 하면서 나머지를 조합한다.
결과
도입 전후가 확연히 다르다.
도입 전: “어디서 뭐가 안 된다” → 역추적 질문 3~4회 → 재현 시도 → 수정 → “고쳤습니다” 카톡
도입 후: 정확한 스크린샷(URL, 브라우저, 뷰포트 포함) + 어노테이션 + 메모가 GitHub Issue로 자동 생성 → 수정 후 이슈 닫기 → 리포터에게 Slack 알림 자동 전송
개발자가 수동으로 하는 일은 딱 두 개다: 버그를 고치는 것, 이슈를 닫는 것. 나머지 — 맥락 수집, 리포트 정리, 해결 통보 — 는 전부 자동이다.
지금은 개발/테스트 환경에서만 활성화해 두었다. 운영 환경으로 넓힐지는 사용 패턴을 더 보고 결정할 생각이다. 위젯 자체가 React 컴포넌트 하나니까, 나중에 npm 패키지로 분리해서 다른 프로젝트에도 쓸 수 있을 것이다.
Comments