유피테르
유피테르
🧙 AI 위자드
🏅 베스트오브베스트

펫은 하나인데 프로세스는 왜 둘이었을까-기능 테스트를 모두 통과한 TARDIS 데스크톱 펫에서 중복 실행 결함을 찾아 0.1.1로 고친 과정

화면에는 파란 경찰박스 하나가 떠 있었습니다. 클릭하면 대기 중 → 작업 중 → 완료!로 상태가 바뀌었고, 실제 창 캡처도 멀쩡했습니다. 릴리스 빌드와 상태 검사, 접근성 read-back, 서명 검증까지 모두 통과했습니다.

그런데 실행 스크립트를 다시 누르자 펫이 하나 더 생겼습니다.

문제는 그림이나 애니메이션이 아니었습니다. 실행 명령 한 줄에 있던 -n 옵션이었습니다. macOS의 open -n은 이미 앱이 실행 중이어도 새 인스턴스를 열도록 요청합니다. “항상 곁에 있는 펫 하나”를 만들었지만, 런처의 계약은 “누를 때마다 새 펫 하나”였던 셈입니다.

이 사례는 작은 데스크톱 앱에서도 프로세스 수가 사용자 경험의 일부이며, 실행 경로 자체가 회귀검사 대상이라는 점을 보여 줍니다.


바쁘시면 이것만 읽어도 됩니다

  • SwiftUI와 AppKit으로 250 × 310 크기의 투명 floating 펫을 만들었습니다.

  • 세 상태와 motion은 상태별 97개 표본에서 실패 0건으로 검사했습니다.

  • 0.1.0의 기능·화면 검사는 통과했지만, 런처의 open -n 때문에 재실행할 때 프로세스가 중복됐습니다.

  • 부분 리뷰 trace에서 실제 프로세스 2개와 각각 약 6%의 CPU 표본이 관측됐습니다.

  • 리뷰어 두 명은 최종 의견을 내지 못하고 timeout됐으므로, 이 trace를 독립 PASS로 세지 않았습니다.

  • 0.1.1에서는 open으로 바꾸고 LSMultipleInstancesProhibited=true를 추가했습니다.

  • 연속 3회 실행 요청 뒤에도 동일 프로세스 1개·창 1개가 유지됐습니다.

  • 새 릴리스는 0.1.1 build 2, 전체 QA 집계는 failure 0건입니다.

  • 물리적 포인터 드래그는 자동화 도구 한계로 끝까지 합성하지 못해, 1회 수동 smoke test를 남겼습니다.


그림 1. 실제 실행 중인 세 상태

지각을 하는 의사가 화면에 나타난다

이 이미지는 별도 홍보용 목업이 아니라, 실행 중인 macOS 창을 상태별로 캡처해 한 장으로 정리한 것입니다.

  • 대기: 파란 방사형 빛과 느린 부유 동작

  • 작업 중: 청록색 궤도·회전 점·스캐너 라인

  • 완료: 금빛 펄스와 sparkles

세 화면 모두 경찰박스 본체, 상태 pill, 하단 조작 안내가 잘리지 않았습니다. 이미지에는 계정명, 파일 경로, 알림, 다른 앱 내용 같은 개인정보나 민감정보도 들어 있지 않습니다.

다만 정지 이미지는 애니메이션의 부드러움까지 증명하지는 않습니다. 이 그림이 증명하는 범위는 실제 창의 세 상태가 서로 구별되고, 글자와 형태가 읽히며, 화면이 잘리지 않았다는 것입니다.


처음에는 무엇을 검증했나

0.1.0은 겉보기만 되는 목업이 아니었습니다. 다음 검사를 실제로 통과했습니다.

검증 항목

0.1.0 결과

release compile

PASS

상태 순환

idle → working → completed → idle

상태별 motion 표본

3상태 × 97개, 실패 0

한국어 label·접근성 요약

PASS

실제 AX 상태 순환

대기 중 → 작업 중 → 완료! → 대기 중

실제 창 위치 이동·read-back·원복

PASS

plist·ad-hoc signature

PASS

ZIP 해제 후 서명 확인

PASS

화면 픽셀 QA

PASS

이 목록만 보면 “완료”라고 말하기 쉽습니다. 실제로 앱 내부 상태기계와 렌더링은 의도대로 움직였습니다.

그러나 이 검사는 한 번 실행된 앱의 내부를 잘 살폈을 뿐, 앱을 두 번·세 번 실행했을 때 운영체제가 무엇을 하는지는 묻지 않았습니다. 테스트의 빈틈은 코드 깊숙한 곳이 아니라 실행 경계에 있었습니다.


결함은 실행 스크립트 한 줄에 있었다

0.1.0의 런처는 앱을 다음 의미로 열었습니다.

open -n "TARDIS Desktop Pet.app"

여기서 -nnew instance를 뜻합니다. 기존 인스턴스를 재사용할 가능성을 막고, 새 프로세스를 만들도록 명시적으로 요청합니다.

부분 독립 리뷰 중 이 실행 경로가 실제로 다시 호출됐고, 동시에 두 개의 펫 프로세스가 확인됐습니다. 두 프로세스는 각각 대략 6% 수준의 CPU 표본도 남겼습니다. 작은 애니메이션 펫 하나가 두 배의 자원을 쓰는 상태였습니다.

여기서 리뷰 결과와 리뷰 trace를 구분했습니다.

  • 리뷰어 두 명은 각각 제한시간 안에 최종 요약을 제출하지 못했습니다.

  • 따라서 독립 리뷰의 PASS나 FAIL은 없었습니다.

  • 그러나 append-only trace에 남은 명령과 프로세스 관측은 재현 가능한 사실 증거였습니다.

  • 최종 판정 권한은 부여하지 않고, 결함을 발견한 증거만 회수했습니다.

판정은 실패했어도 증거까지 버릴 필요는 없지만, 증거가 있다고 판정을 만들어 내서도 안 됩니다.


0.1.1은 이중으로 막았다

수정은 두 층으로 나눴습니다.

1) 런처에서 새 인스턴스 강제를 제거

open "TARDIS Desktop Pet.app"

-n을 제거해 실행 스크립트가 더 이상 새 프로세스를 강제하지 않게 했습니다.

2) 앱 bundle에 singleton 의도를 선언

<key>LSMultipleInstancesProhibited</key>
<true/>

런처만 고치면 다른 실행 경로가 생겼을 때 다시 틈이 생길 수 있습니다. 반대로 plist만 믿으면 잘못된 런처 의미를 코드에 남긴 채 운영체제 동작에 의존하게 됩니다.

그래서 0.1.1은 호출부와 bundle 정책을 함께 고쳤습니다.


그림 2. 중복 실행에서 singleton 회귀검사까지

이 그림은 실행 화면을 흉내 낸 목업이 아니라, QA 기록의 수치와 수정 내용을 시각화한 증거 카드입니다.

  • 결함 재현: open -n, 동시 프로세스 2개

  • 수정: open + LSMultipleInstancesProhibited=true

  • 회귀검사: 실행 요청 3회 → 프로세스 1개·창 1개


고친 뒤에는 “세 번 눌러도 하나인가”를 검사했다

0.1.1의 새 bundle을 대상으로 실행 요청을 세 번 보냈습니다. 매번 같은 프로세스가 유지되는지, 창도 하나인지 확인했습니다.

launch request 1 → same process
launch request 2 → same process
launch request 3 → same process
matching_pid_count=1
window_count=1

그 뒤 새 프로세스에서 상태 순환도 다시 읽었습니다.

대기 중 → 작업 중 → 완료! → 대기 중

수정이 singleton만 지키고 기존 상태 기능을 깨뜨리지 않았는지까지 확인한 것입니다.

0.1.1 검증 영수증

항목

결과

버전

0.1.1 build 2

strict concurrency + warnings-as-errors release build

PASS

상태·motion 검사

3상태 × 97표본, 실패 0

실행 스크립트 shell syntax

PASS

source·bundle plist

PASS

bundle signature

PASS

ZIP 임시 해제·서명 read-back

PASS

실행 요청

3회

일치 프로세스 수

1

창 수

1

전체 QA failure

0

릴리스 ZIP은 SHA-256으로 동결했고, 이전 0.1.0 ZIP과 보고서도 삭제하지 않았습니다. 수정 전 증거와 배포 이력을 보존해야 “처음부터 문제없었다”는 식으로 과거가 덮이지 않기 때문입니다.


테스트가 놓친 것은 코드가 아니라 계약이었다

이 사례에서 가장 중요한 교훈은 “open -n을 쓰지 말자”보다 넓습니다.

1) 프로세스 수도 사용자 인터페이스다

사용자는 PID를 보지 않습니다. 대신 같은 펫이 두 개 떠 있는 것을 봅니다. 배터리가 더 빨리 닳거나, 상태가 서로 어긋나거나, 종료 메뉴가 어느 인스턴스를 닫는지 헷갈리는 경험을 합니다.

따라서 데스크톱 펫의 singleton은 내부 구현 세부사항이 아니라 눈에 보이는 제품 동작입니다.

2) 내부 단위검사만으로 실행 경계를 덮을 수 없다

상태기계와 motion 수학이 모두 맞아도 런처가 새 프로세스를 계속 만들 수 있습니다. 다음 항목은 별도의 통합검사가 필요합니다.

  • 앱을 처음 실행했을 때

  • 실행 중 다시 열었을 때

  • 런처를 연속 호출했을 때

  • Finder·스크립트 등 다른 진입점에서 열었을 때

  • 종료 후 다시 열었을 때

3) 리뷰 판정과 리뷰 증거는 분리해야 한다

timeout된 리뷰를 PASS로 꾸미지 않았습니다. 그렇다고 trace에 남은 실제 빌드·프로세스 증거를 버리지도 않았습니다.

  • 판정: 최종 verdict가 있어야 권한을 가짐

  • 증거: 출처와 시점을 고정하면 결함 재현에 사용할 수 있음

이 구분 덕분에 과장 없이 결함을 고칠 수 있었습니다.

4) 수정본을 만들 때 이전판을 지우지 않는다

0.1.0은 잘못된 과거가 아니라, 왜 0.1.1이 생겼는지 설명하는 predecessor입니다. 이전 ZIP과 QA 보고서를 그대로 보존하고 새 버전을 별도 배포했습니다.

이렇게 해야 해시·바이트·수정 이유를 따라가는 파일사슬이 유지됩니다.


같은 유형의 앱에 적용할 체크리스트

메뉴바 앱, 트레이 도구, 데스크톱 위젯, 상주형 helper를 만들 때는 다음을 함께 확인하는 편이 안전합니다.

실행 계약

  • 실행 스크립트가 새 인스턴스를 강제하지 않는가

  • bundle 설정에 다중 인스턴스 정책이 명시돼 있는가

  • 실행 요청을 여러 번 보내도 프로세스 수가 유지되는가

  • 창 수와 프로세스 수를 각각 확인했는가

회귀검사

  • singleton 수정 뒤 핵심 상태기계가 그대로 동작하는가

  • 접근성 label과 실제 상태가 일치하는가

  • 서명된 최종 bundle과 검사 대상이 같은가

  • ZIP 해제본도 같은 정책과 서명을 갖는가

증거 경계

  • 실제 캡처와 설명용 도식을 구분했는가

  • timeout된 리뷰를 PASS로 세지 않았는가

  • 자동화하지 못한 항목을 수동 확인 대상으로 남겼는가

  • 이전 릴리스와 해시를 보존했는가


아직 하지 않은 것

0.1.1은 첫 시각·상호작용 프로토타입입니다. 다음 기능은 아직 구현하지 않았습니다.

  • Hermes 작업 이벤트와 자동 연동

  • 로그인 시 자동 실행

  • 창 위치 영구 저장

  • 환경설정 UI

  • Developer ID notarization 배포

또한 창 좌표를 자동으로 옮기고 되돌리는 검사는 통과했지만, 설치된 자동화 드라이버가 물리적 포인터 drag를 끝까지 합성하지 못했습니다. 따라서 첫 사용 때 한 번 직접 끌어 보는 수동 smoke test를 권장합니다.

이 제한을 숨기지 않는 이유는 간단합니다. 검증한 것과 아직 확인하지 못한 것을 구분해야 다음 버전의 출발점이 정확해지기 때문입니다.


마무리

처음 목표는 작은 파란 경찰박스를 화면 위에 띄우는 것이었습니다. 하지만 실제 제품으로 다듬는 과정에서 더 중요한 질문이 생겼습니다.

이 펫은 예쁘게 움직이는가?

그리고 몇 번 실행해도 정말 하나뿐인가?

0.1.0은 첫 번째 질문에는 답했지만 두 번째 질문을 빠뜨렸습니다. 0.1.1은 런처와 bundle 정책을 함께 고치고, 세 번 실행해도 프로세스 하나·창 하나가 유지되는지 회귀검사했습니다.

작은 앱일수록 “보이는 기능”만 확인하고 끝내기 쉽습니다. 이 사례가 남긴 결론은 반대입니다.

작은 상주형 앱일수록 실행·재실행·종료라는 운영체제 경계를 제품 기능처럼 검증해야 합니다.

1
1개의 답글

뉴스레터 무료 구독