FocusChecker 개발 회고
FocusChecker를 만든 이유

FocusChecker는 PC에서 작업할 때 내가 실제로 집중하고 있는지를 확인하려고 만든 프로그램이다.
처음부터 집중도를 정교하게 분석하려던 것은 아니다. 카메라를 쓰거나 별도의 머신러닝 모델을 붙이는 것도 생각하지 않았다.
키보드를 입력하고 있는지, 마우스를 움직이고 있는지, 휠을 사용하고 있는지 정도만 확인해도 대략적인 활동 상태는 알 수 있다고 봤다.
여기에 약간의 게임 요소를 넣었다.
활동을 하고 있으면 Point가 올라간다. 일정 시간 아무런 입력이 없으면 Point가 떨어진다. Point가 모두 떨어지면 현재 재생 중인 미디어를 정지시킨다.
그리고 이 Point를 그냥 프로그램 안에서만 사용하는 게 아니라 기존에 만들었던 Tack TODO와 연결했다.
Tack TODO에서 사용하는 Firebase 계정을 FocusChecker에서도 그대로 사용하고, 집중 세션이 끝나면 계산된 Coin을 Firestore에 지급하는 방식이다.
결과적으로 두 프로그램이 서로 다른 기술로 만들어졌지만 하나의 계정을 공유하게 됐다.
Flutter로 만들지 않은 이유
Tack TODO가 Flutter로 만들어져 있어서 처음에는 FocusChecker도 Flutter로 만들까 생각했다.
그런데 요구사항을 놓고 보니 굳이 그럴 이유가 없었다.
FocusChecker에서 필요한 화면은 크지 않다. 반면 프로그램은 계속 켜져 있으면서 키보드와 마우스 입력을 감지해야 한다.
Windows API를 직접 사용하는 부분도 많았다.
그래서 C++20과 Win32 API로 만들었다.
UI는 GDI로 그렸고 네트워크는 WinHTTP를 사용했다. Google 로그인 화면만 WebView2를 넣었다.
Firebase C++ SDK도 사용하지 않았다.
이걸 넣으면 gRPC, Protobuf, OpenSSL 같은 의존성이 같이 따라온다. FocusChecker에서 Firebase를 사용하는 목적은 복잡한 기능이 아니라 인증과 Firestore 데이터 접근 정도였기 때문에 이 정도 의존성을 가져오는 게 마음에 들지 않았다.
결국 필요한 API만 REST로 호출했다.
Win32 + WinHTTP + WebView2 정도면 필요한 기능을 만들 수 있었다.
입력을 어떻게 활동으로 볼 것인가
키보드와 마우스를 감지하는 것 자체는 어렵지 않았다.
문제는 어디까지를 활동으로 인정할 것인가였다.
예를 들어 마우스가 1~2px 움직였다고 해서 사용자가 실제로 작업했다고 보기는 어렵다.
그대로 Point를 지급하면 마우스를 살짝 흔드는 것만으로 계속 점수를 얻을 수 있다.
그래서 마우스 이동량에 임계값을 넣었다.
double distance = std::sqrt(dx * dx + dy * dy);
if (distance >= MOUSE_MOVE_THRESHOLD_PX &&
sinceLastGain >= MOUSE_MOVE_POINT_COOLDOWN_MS) {
lastMousePos = currentPos;
lastMousePointGain = now;
return true;
}
현재는 30px 이상 움직였을 때만 이동을 인정하고, 한 번 Point를 얻은 뒤에는 450ms의 쿨다운을 둔다.
키보드는 또 다른 문제가 있었다.
GetAsyncKeyState()를 그냥 사용하면 키를 계속 누르고 있는 동안에도 눌림 상태가 계속 잡힌다.
그러면 키 하나를 누르고 있는 것만으로 활동이 계속 발생한다.
그래서 keyHeld에 이전 상태를 저장하고 실제로 눌리는 순간만 처리했다.
휠은 WH_MOUSE_LL 훅으로 따로 받았다.
이 부분을 UI 스레드에서 직접 처리하면 메시지가 밀릴 때 마우스 반응에 영향을 줄 수 있어서 별도 스레드로 분리했다.
휠 이벤트가 들어오면 바로 UI를 건드리지 않고 atomic 변수에 기록한다.
if (wheelMoved.exchange(false)) {
++activityCount;
}
UI 쪽에서는 필요한 시점에 이 값을 확인한다.
입력 감지 자체보다 계속 실행되는 프로그램에서 입력을 어떻게 소비할지를 정하는 쪽이 더 신경 쓰였다.
Point 감소 방식을 바꾼 이유
처음에는 일정 시간이 지나면 일정량의 Point를 빼는 방식도 생각했다.
그런데 10초 정도 쉬는 것과 몇 분 동안 아무것도 하지 않는 것을 똑같이 처리하는 게 이상했다.
그래서 유휴 시간이 길어질수록 감소량이 커지는 형태로 만들었다.
Decay(x) = floor(x * Rate + Base)
여기서 x는 마지막으로 Point를 얻은 뒤 지나간 시간이다.
잠깐 쉬었을 때는 감소가 작고, 오래 자리를 비우면 더 빠르게 떨어진다.
Point가 0이 되었을 때는 그냥 화면에 경고만 표시하지 않았다.
현재 재생 중인 미디어를 정지시켰다.
Windows의 VK_MEDIA_PLAY_PAUSE 키 이벤트를 발생시키는 방식이다.
if (point == 0 && !isStoped) {
SimulatePlayStopKey();
isStoped = true;
}
이 기능 때문에 Point를 단순한 점수보다는 집중 상태를 유지하기 위한 여유분에 가깝게 사용하게 됐다.
Google 로그인을 Win32 프로그램 안에서 처리하기
FocusChecker는 Tack TODO와 같은 Firebase 계정을 사용해야 한다.
그래서 프로그램 안에서 Google 로그인을 처리할 필요가 있었다.
처음 생각한 방법은 외부 브라우저를 열고 callback을 받는 방식이었다. 그런데 작은 유틸리티 하나 때문에 localhost callback 서버까지 따로 만드는 건 과하다고 생각했다.
그래서 WebView2를 넣었다.
그런데 Google 로그인 페이지에서 바로 문제가 발생했다.
WebView2의 기본 User-Agent로 접근하면 403 disallowed_useragent가 발생했다. Google 쪽에서 임베디드 웹뷰의 로그인 방식을 제한하고 있었기 때문이다.
그래서 WebView2의 User-Agent를 데스크톱 Chrome 형태로 변경했다.
그리고 NavigationStarting 이벤트에서 현재 이동하려는 URL을 확인했다.
Firebase Auth Handler로 이동하는 순간 URL Fragment에 들어있는 인증 정보를 가져오고 해당 Navigation은 취소했다.
별도의 callback 서버 없이 WebView2 안에서 인증 결과를 가져오는 방식이다.
이쪽은 코드보다 WebView2와 Google 로그인 사이에서 어떤 부분이 막히는지 확인하는 데 시간이 더 걸렸다.
Firebase C++ SDK 대신 REST API 사용
로그인이 끝나면 집중 세션이 종료될 때 Coin을 지급해야 한다.
여기서 Firebase C++ SDK를 넣는 대신 WinHTTP로 Firestore REST API를 호출했다.
필요한 건 많지 않았다.
현재 인증된 사용자의 데이터를 읽고, Coin을 증가시키고, 필요한 값을 가져오는 정도다.
그래서 HTTP 요청을 직접 만드는 쪽을 선택했다.
Coin 지급에서 특히 신경 쓴 것은 증가 방식이었다.
현재 Coin을 읽어서 +1한 다음 다시 저장하면 동시 요청에서 문제가 생긴다.
예를 들어 현재 Coin이 100일 때 두 요청이 동시에 100을 읽으면 둘 다 101을 저장할 수 있다.
두 번 지급했는데 결과가 101이 된다.
그래서 Firestore의 increment를 사용했다.
"updateTransforms": [{
"fieldPath": "coins",
"increment": {
"integerValue": "..."
}
}]
값을 읽고 계산해서 덮어쓰는 게 아니라 Firestore에서 증가 연산을 처리하도록 했다.
이전 세션의 응답이 새 세션을 건드리는 문제
여기서 한 번 문제가 터졌다.
집중 세션을 종료하면 Coin 지급 요청을 별도의 워커 스레드에서 처리한다.
HTTP 요청이 바로 끝나는 게 아니기 때문에 사용자가 그 사이에 다시 집중을 시작할 수 있다.
상황은 대략 이렇다.
세션 A 종료 -> Coin 요청 전송 -> 세션 B 시작 -> A 요청 응답 도착
A의 응답이 늦게 들어왔는데 UI에서는 현재 B가 진행 중이다.
그런데 A의 응답을 그냥 반영하면 B의 상태를 A의 결과가 덮어쓰게 된다.
처음에는 UI 상태 관리 문제인 줄 알았는데 로그를 찍어보니 이전 세션의 응답이 늦게 들어오는 게 원인이었다.
그래서 세션마다 ID를 하나씩 부여했다.
g_focusSessionId를 증가시키고 워커에서 UI로 결과를 전달할 때 현재 세션 ID도 같이 보낸다.
const WPARAM packed =
(static_cast<uint64_t>(sessionId) << 1) | (ok ? 1u : 0u);
PostMessage(
hwnd,
WM_COIN_AWARDED,
packed,
earnedCoin
);
UI에서는 전달받은 세션 ID와 현재 세션 ID가 같은 경우에만 결과를 반영한다.
결과적으로 예전에 시작했던 요청이 뒤늦게 돌아와도 현재 세션을 건드리지 못하게 됐다.
Win32 UI를 직접 만들면서 생긴 문제
UI는 최대한 작게 만들 생각이었다.
그래서 별도의 UI 프레임워크를 사용하지 않고 GDI로 직접 그렸다.
DWM API를 사용해서 다크 타이틀바를 적용했고, 버튼도 직접 그렸다.
여기서 Pin 모드를 추가하면서 창 스타일을 바꿨다.
항상 위에 띄우는 모드에서는 타이틀바가 필요 없어서 WS_CAPTION을 제거했다.
그랬더니 창 크기가 미묘하게 달라졌다.
Win32에서는 Window 전체 영역과 Client 영역이 다르기 때문이다.
그래서 AdjustWindowRect를 이용해서 프레임 크기를 다시 계산했다.
내부 Client 영역은 190 x 158로 유지했다.
타이틀바를 없애고 나니 창을 드래그할 영역도 사라졌다.
그래서 WM_NCHITTEST에서 HTCLIENT를 HTCAPTION으로 처리했다.
결과적으로 창 내부를 마우스로 잡고 움직일 수 있게 했다.
프레임워크를 사용했다면 크게 신경 쓰지 않았을 부분인데 Win32에서는 이런 것까지 직접 처리해야 했다.