Plan Do!
할 일·캘린더·팀 협업·메시지·AI 에이전트·위치 알림을 한 앱에 묶은 생산성 앱. 기획부터 App Store 심사까지 1인으로 만들고 운영합니다.
- Year
- 2026
- Role
- 1인 · 기획 · 디자인 · 풀스택 · 배포 · 운영
- Platform
- iOS · Web
- App Store
- iOS에서 다운로드 ↗
한눈에
| 기간 | 2025년 6월 ~ 운영 중 (15개월) |
| 규모 | 커밋 1,319개 · 리포 3개 (iOS 앱 · 백엔드 · 웹) |
| 출시 | iOS App Store 서비스 중 — 최신 2.0.22 |
할 일, 캘린더, 팀 협업, 메시지, AI 에이전트, 위치 기반 알림을 한 앱 안에 묶었습니다. 웹 PWA로 시작해 iOS는 Capacitor로 감쌌다가, 모바일을 React Native로 다시 짜서 지금은 네이티브 앱으로 서비스합니다. 백엔드는 한 벌 그대로 두고 프론트만 갈아끼웠습니다.
구조
AWS Lightsail · 서울 · 인스턴스 1대
Spring Boot 3.5 · Java 17
- OpenAI gpt-4o-mini
- APNs
- Web Push
- OAuth Google · Kakao · Apple
- Gmail SMTP
- Sentry
실시간 이벤트는 커밋된 뒤에 보낸다 — 할 일 변경과 DM은 트랜잭션이 커밋된 다음(afterCommit)에
WebSocket으로 나갑니다. 롤백될 변경이 다른 사람 화면에 먼저 뜨지 않게 하려는 것이고,
알림 발송도 @Async로 요청 트랜잭션 밖에서 처리합니다.
연결할 때 한 번, 구독할 때 한 번 더 확인한다 — STOMP 연결 시 JWT를 검사하고,
/topic/dm/{방}·/topic/workspace/{id}를 구독하는 순간 그 방 참여자인지, 워크스페이스 멤버인지 다시 봅니다.
코드 리뷰에서 인증 없이 연결되던 구멍을 찾아 막으면서 세운 규칙입니다.
서버 한 대를 전제로 하고, 그 한계는 적어 둔다 — 메시지 브로커와 접속 상태를 메모리에 둬서 구성은 가장 단순합니다. 대신 서버를 늘리는 순간 외부 브로커가 필요하다는 걸 코드에 남겨 뒀습니다. 배포는 새 포트에 띄워 헬스 체크를 통과한 뒤 nginx를 넘기는 blue/green으로 바꿔, 재시작 동안 40초 가까이 502가 나던 문제를 없앴습니다.
왜 만들었나
생산성 앱들이 저마다 한 가지에 특화돼 있어서 일정과 메시지 사이를 계속 오가야 했습니다. 혼자 쓰는 도구와 팀에서 쓰는 도구가 갈라져 있는 것도 불편했습니다.
결정
웹 PWA로 시작 — React 19 SPA 한 벌로 빠르게 만들고, 쓰면서 필요한 걸 붙여나갔습니다.
iOS는 처음엔 Capacitor — 1인 개발에서는 코드베이스를 하나로 유지하는 게 최우선이었습니다.
모바일은 결국 React Native로 리라이트 — 웹뷰 특유의 버벅임과 네이티브 감 때문에. 백엔드는 안 건드리고 프론트만 다시 짰고, OpenAPI → TS 타입 자동생성으로 API 계약이 어긋날 여지를 없애 리라이트 리스크를 줄였습니다.
AI는 역할로 나누고 도구를 제한 — 하나의 만능 비서 대신 “정리 도우미”, “점검 도우미”처럼 역할을 두고, 각 역할이 쓸 수 있는 도구를 제한했습니다. 무엇을 도와주느냐보다 무엇까지 건드릴 수 있느냐가 사용자에게 더 중요한 구분이라고 봤습니다.
예약 실행은 조회 전용 역할만 — 역할을 시각에 걸어둘 수 있는데, 데이터를 바꾸는 역할은 걸지 못하게 막았습니다. 보고 있지 않은 사이에 마감이 옮겨지면 아침에 열었을 때 누가 한 일인지 알 수 없기 때문입니다. 예약은 알려주기만 하고, 고치는 건 사람이 정합니다.
삭제는 제목이 정확히 맞을 때만 — AI가 “비슷한 제목”으로 엉뚱한 할 일을 지우던 결함을 고치면서 세운 규칙입니다. 여럿이면 되묻고, 지운 뒤엔 무엇을 지웠는지 제목으로 알립니다.
부딪힌 것
코드서명이 이름 충돌로 막힘 — 팀의 배포 인증서가 전부 이름이 같아서, 다른 프로젝트의
키체인이 검색 목록에 끼어 있으면 후보가 여러 개가 되어 codesign이 고르지 못합니다.
익스텐션이 먼저 서명되는 탓에 늘 엉뚱한 자리에서 처음 터져서 원인을 찾기 어려웠습니다.
카카오맵 네이티브 SDK가 RN 0.85에서 동작하지 않음 — 맵 엔진 델리게이트가 끝내 불리지 않아 빈 화면만 나왔습니다. SDK 버전을 내려도 같길래, 지도 탭만 WebView로 카카오 JS SDK를 띄우는 쪽으로 돌렸습니다. 한 탭에 격리하고 나머지는 네이티브로 남겼습니다.
JS가 압축 없이, 캐시 금지인 채로 나가고 있었음 — 포트폴리오에 넣을 수치를 재다가 발견했습니다.
첫 화면 JS 3.3MB가 매 방문마다 압축 없이 내려가고 있었습니다. nginx는 기본값대로 HTML만 압축했고,
모든 요청을 Spring으로 넘기다 보니 Spring Security의 no-store 헤더가 정적 파일에도 붙어 있었습니다.
압축 대상을 JS·CSS까지 넓히고, 파일 이름에 해시가 붙는 /assets/만 1년 캐시로 바꿨습니다.
첫 화면 JS·CSS 전송량은 3.38MB → 1.02MB(−70%), 재방문 때는 다시 받지 않습니다 (2026-10-13, curl로 측정).
이런 함정은 릴리즈 플레이북에 그때그때 적어둡니다. 같은 데서 두 번 막히지 않기 위해서입니다.
결과
- iOS App Store 서비스 중 — 2.0.22까지 출시. 심사 제출·출시를 App Store Connect API로 자동화
- 웹 — plando.imjaewoo.dev, 무중단 blue/green 배포
- 테스트 — 백엔드 테스트 148개를 push마다 GitHub Actions에서 실행
- 사용 안내서 — 기능을 고치면 안내 문서도 1:1로 같이 고칩니다
- 위젯·푸시 같은 작은 표면에 대한 반응이 가장 좋았습니다
관련 글
기술 결정은 글로 따로 남겼습니다.
- Capacitor를 걷어내고 React Native로 — 리라이트 리스크를 줄인 세 가지
- AI 어시스턴트 고도화 — 다건 생성과 프롬프트 인젝션 방어
- 자연어 라우팅 — 로컬 LLM과 GPT를 100회 붙여보고
- 워크스페이스 격리를 한 메서드로
- WebSocket 두 개를 한 앱에 — presence와 DM을 분리한 이유
- 데이터 모델 — Workspace 중심의 멀티테넌시
- 인프라 — Lightsail 한 인스턴스 위의 모든 것
- 출시 회고 — 혼자서 풀스택, 그리고 iOS까지