2026년 10월 27일 화요일
미단재

AI PHP 개발자가 TypeScript + React Three Fiber로 데스크톱 앱 만들기 (AICella)

작성자 웅파 작성일 2026-10-26 12:30:23 조회수 19

2026년 10월 26일 · @martin

시작은 질문 하나였다

"방 안에 가구를 배치해 보는 프로그램을 만들고 싶은데, 어떤 언어가 좋을까? 윈도우·맥 둘 다 되게."

AICella는 이 한 줄에서 출발했다. 26년 가까이 PHP를 써 왔고, 지금도 거의 모든 서비스를 CodeIgniter 4로 만든다. 그런데 이번 것은 서버가 HTML을 내려주는 웹 서비스가 아니었다. 사용자 PC에서 돌아가는 프로그램이고, 가구를 끌어다 놓고 돌려 보다가 최종적으로는 3D로 방 안을 둘러보는 데까지 가야 했다.

그래서 처음으로 PHP가 아닌 언어로 본격적인 앱을 만들었다. TypeScript와 React Three Fiber(R3F), 데스크톱 패키징은 Tauri 2. 이 글은 그 전환에서 무엇을 정했고, 무엇이 낯설었고, 무엇은 그대로 통했는지에 대한 기록이다.

왜 PHP가 아니라 TypeScript + R3F였나

결정은 "3D까지 간다"는 목표 하나로 정리됐다. 처음 펼쳐 놓은 선택지는 다섯 갈래였다.

선택지

장점

걸리는 점

TypeScript + Three.js (웹)

설치 없이 실행, glTF 가구 모델·드래그·회전 충분

브라우저 3D를 새로 익혀야 함

Tauri / Electron

웹앱을 윈도우·맥 설치 프로그램으로 패키징

단독으로는 화면 기술이 아님

Unity (C#)

3D 품질과 AR 확장이 가장 강력

에디터 학습, 웹 개발 방식과 거리가 멂

Godot

무료, 가벼움

생태계가 작음

Konva.js / Fabric.js

2D 평면도는 가장 빠름

3D로 가면 다시 만들어야 함

2D 평면도 전용 라이브러리로 시작했다면 3D 단계에서 데이터 모델을 두 벌 들고 가야 했다. 그래서 three.js 씬 하나에 카메라 두 개를 두는 구조를 택했다. 위에서 내려다보는 직교 카메라가 편집 모드, 원근 카메라가 3D 둘러보기다. 데이터가 하나라서 평면과 3D가 어긋날 일이 없다.

배포는 웹을 먼저 만들고, 설치형이 필요해지면 Tauri로 감싸는 순서로 잡았다. Electron보다 Tauri를 고른 이유는 용량이 작고 가볍다는 것이었다. 결과적으로 같은 코드가 브라우저와 데스크톱 양쪽에서 돈다.

코드보다 CLAUDE.md를 먼저 썼다

모르는 언어로 Claude Code와 일할 때 가장 위험한 건, 내가 코드를 검토하지 못하는 사이 규칙이 흐트러지는 것이다. 그래서 첫 세팅에서 기능보다 CLAUDE.md에 규칙을 먼저 박았다. 핵심은 네 가지였다.

  1. 단위는 mm 정수 하나. 도메인의 모든 길이는 mm, 각도는 라디안이다. three.js 씬은 1 unit = 1 m인데, 이 변환은 scene/ 경계에서 mmToUnit 한 함수로만 한다. 가구 치수가 1500×2000처럼 사람이 읽는 숫자 그대로 저장된다.
  2. 의존 방향. ui·scene → store → domain, api → domain. 역방향 import는 금지다. 특히 domain/은 three.js도 React도 모르는 순수 TypeScript로 둔다.
  3. Undo 단위. 레이아웃 변경은 store action으로만 한다. 드래그는 beginInteraction()과 endInteraction() 사이를 한 번의 Undo로 묶고, 5mm 미만으로 움직인 단순 클릭은 히스토리에 남기지 않는다.
  4. 완료 조건. npm run typecheck && npm test && npm run build가 모두 통과해야 작업이 끝난 것이다.

그려 놓고 보니 익숙한 모양이다. 화면 층, 상태를 바꾸는 유일한 창구, 프레임워크를 모르는 규칙 층, 그리고 공통 응답 형식(envelope)({ status, data, message })의 API 계약. CI4에서 Controller가 Model을 직접 건드리지 못하게 Service를 두던 감각을 그대로 옮긴 셈이다.

버전도 규칙에 넣었다. 세팅 당시 @react-three/fiber 9.7의 peer 범위가 React 19.3 미만이라 React를 ~19.2로 고정했고, R3F가 따라오기 전까지 올리지 말라고 적어 뒀다. 이런 한 줄이 없으면 AI는 "최신으로 올려 드렸습니다"를 성실하게 반복한다.

첫 세팅 결과물은 바로 실행되는 1단계 앱이었다. 박스 가구 6종, 격자·벽 스냅, 벽 관통 방지, 겹침 경고, Undo/Redo, JSON 저장까지 들어 있었고 단위 테스트 19개가 통과했다.

PHP 감각으로 낯설었던 것, 그대로 통한 것

가장 큰 차이는 요청이 끝나지 않는다는 점이다. PHP는 요청 하나 처리하고 메모리를 버린다. 데스크톱 앱은 켜져 있는 내내 상태를 들고 있고, 3D 씬은 초당 수십 번 다시 그려진다. 그래서 규칙이 생긴다. useFrame 안에서 React 상태를 바꾸지 않고, 프레임마다 new Vector3()를 만들지 않고 모듈 상수나 useRef를 재사용한다. PHP에서는 생각해 본 적 없는 종류의 성능 감각이다.

두 번째는 불변 갱신이다. 가구 하나를 옮겨도 기존 객체를 고치지 않고 새 레이아웃을 만든다. 처음엔 번거로워 보였는데, 이게 있어야 zundo가 이전 상태를 그대로 쥐고 Undo를 공짜로 해 준다.

세 번째는 물리 엔진 없이 푸는 기하였다. 가구끼리 겹치는지는 바닥에 투영한 회전 사각형 두 개를 분리축 정리(SAT)로 판정한다. 벽 스냅은 벽 안쪽 법선 방향으로 밀어 붙이는 2D 계산이다. 이 함수들이 전부 domain/에 순수 함수로 있어서 브라우저 없이 vitest로만 검증된다.

반대로 그대로 통한 것도 많았다.

  • 경계와 계약. 외부 입력(JSON 파일, API 응답)은 반드시 parseLayout()을 거친다. CI4에서 Validation을 컨트롤러 입구에 두던 것과 같다.
  • 스키마 버전. Layout에 schemaVersion을 두고, 구조를 바꾸면 버전을 올리고 마이그레이션을 추가한다. DB 마이그레이션과 같은 습관이다.
  • 엄격한 타입. strict에 noUncheckedIndexedAccess까지 켰다. PHPStan 레벨을 올려 두던 이유와 똑같다. 내가 못 읽는 코드일수록 기계가 먼저 잡아 줘야 한다.

로드맵을 이슈로 쪼개 Claude Code에 맡겼다

개발은 docs/ROADMAP.md의 5단계 체크리스트를 따라갔다. 평면 배치 기본기 → 방 형태와 문·창문 → 3D 경험 → 저장·공유 → 배포 확장 순서다. 각 항목은 GitHub 이슈가 됐고, Claude Code에는 커스텀 명령 두 개를 만들어 뒀다.

  • /roadmap-next — 다음 미완료 항목을 골라 변경 파일, 추가할 순수 함수와 테스트, UI 변화를 계획으로 먼저 보여 주고 구현한다.
  • /check — typecheck, test, build를 순서대로 돌리고, 실패하면 고쳐서 처음부터 다시 돌린다. 테스트를 지우거나 skip해서 통과시키는 건 금지다.

이슈를 닫을 때는 작업 내역과 검증 결과를 댓글로 남겼다. 예를 들어 로그인(#14)은 httpClient.ts를 새로 만들어 Bearer 토큰 부착과 401 시 토큰 폐기를 한 곳으로 모으고, 인증 스토어에 단위 테스트 3건을 붙였다. Tauri 패키징(#18)은 Windows MSI·macOS DMG 번들 타깃을 추가했고, 닫을 때 테스트는 106개로 늘어 있었다. 데스크톱 앱의 로컬 저장은 새로 만들지 않고 기존 JSON 저장/불러오기를 그대로 썼다.

그 뒤로 코드 사이닝(Windows 인증서, Apple Developer ID + 공증), GitHub Releases 기반 자동 업데이트, 모바일 WebXR AR 미리보기까지 이슈로 닫았다. 업데이트 파일 서명 키는 GitHub Actions Secrets에만 두고, 개인 키는 저장소에 커밋하지도 로그에 찍지도 않는다는 규칙을 README에 적었다. 지금 닫힌 이슈는 38개다.

계획과 달라진 것, 남은 것

처음 API 계약서(docs/API.md)에는 백엔드를 CI4로 적었다. 프론트엔드가 스키마의 기준이고, 서버는 레이아웃 JSON을 해석하지 않고 JSON 컬럼에 그대로 저장한다는 원칙이었다. 지금 저장소의 백엔드는 Fastify(Node.js)이고, 운영 서버에서 PM2로 돈다. main에 머지되면 GitHub Actions가 SSH로 접속해 프론트와 백엔드를 빌드하고 PM2 프로세스를 reload한다.

돌아보면 오래 간 건 언어가 아니라 계약이었다. 봉투 응답 형식과 "서버는 JSON을 그대로 저장"이라는 원칙은 그대로 두고, 프론트는 여전히 layoutApi.ts와 그 아래 httpClient.ts 한 곳에서만 서버를 부른다.

로드맵에 적어 둔 알려진 제약도 아직 남아 있다.

  • 벽 관통 방지는 가구 꼭짓점 4개만 검사해서, 꼭짓점은 방 안인데 변이 오목한 구석을 가로지르는 경우는 못 잡는다.
  • 1인칭 걷기의 벽 충돌이 방 폴리곤 기준이라 문 개구부로 걸어 나갈 수 없다.
  • 가구 겹침 판정은 O(n²)라 수백 개를 넘으면 공간 분할이 필요하다.

제약을 숨기지 않고 문서 한 곳에 모아 두니, 다음 작업을 고를 때 Claude Code도 나도 같은 목록을 본다.

마치며: 언어를 바꿔도 가져간 것

TypeScript와 R3F는 새로 배워야 했지만, 일하는 방식은 거의 그대로였다. 경계를 먼저 긋고, 모듈간 규약을 문서로 남기고, 통과 조건을 기계가 확인하게 하는 것. 26년 동안 PHP로 해 온 일이 그대로 쓸모 있었다.

오히려 모르는 언어라서 더 엄격해졌다. 내가 한 줄 한 줄 읽지 못하는 코드라면 규칙과 테스트가 대신 읽어 줘야 한다. CLAUDE.md, /check, 이슈 댓글의 검증 기록이 그 역할을 했다.

PHP 개발자가 데스크톱 앱이나 3D를 만들고 싶다면, 언어를 고르기 전에 이것부터 정하길 권한다. 단위와 데이터 모델 하나, 의존 방향, 그리고 "끝났다"의 정의. 나머지는 Claude Code와 함께 하나씩 닫아 가면 된다.

댓글 0개