Work
← Work

Plan Do!

할 일·캘린더·팀 협업·메시지·AI 에이전트·위치 알림을 한 앱에 묶은 생산성 앱. 기획부터 App Store 심사까지 1인으로 만들고 운영합니다.

Year
2026
Role
1인 · 기획 · 디자인 · 풀스택 · 배포 · 운영
Platform
iOS · Web
React NativeExpo SDK 56React 19Spring BootSpring AIPostgreSQLAWS Lightsail
Plan Do! 대표 이미지

한눈에

기간2025년 6월 ~ 운영 중 (15개월)
규모커밋 1,319개 · 리포 3개 (iOS 앱 · 백엔드 · 웹)
출시iOS App Store 서비스 중 — 최신 2.0.22

할 일, 캘린더, 팀 협업, 메시지, AI 에이전트, 위치 기반 알림을 한 앱 안에 묶었습니다. 웹 PWA로 시작해 iOS는 Capacitor로 감쌌다가, 모바일을 React Native로 다시 짜서 지금은 네이티브 앱으로 서비스합니다. 백엔드는 한 벌 그대로 두고 프론트만 갈아끼웠습니다.

구조

iOS 앱React Native · Expo위젯 · 지오펜스 감지 · 푸시 수신
웹React 19 SPA · PWASpring Boot jar 안에 함께 배포

AWS Lightsail · 서울 · 인스턴스 1대

nginxTLS · /api · /ws 프록시 · 배포 때 upstream 전환

Spring Boot 3.5 · Java 17

REST APIJWT 필터 · 요청 수 제한 · 워크스페이스 격리
실시간STOMP 브로커(메모리) · 구독 권한 검사 · 커밋 후 발행
AI 에이전트Spring AI ChatClient · 최대 5단계 루프 · 변경은 확인 후 실행
알림 · 예약 작업@Async 발송 · @Scheduled 6개
PostgreSQL 14같은 인스턴스 · 테이블 40개 · 매일 pg_dump
업로드 파일로컬 디스크
  • OpenAI gpt-4o-mini
  • APNs
  • Web Push
  • OAuth Google · Kakao · Apple
  • Gmail SMTP
  • Sentry
서버는 한 대, 앱과 웹은 같은 API를 씁니다. 지오펜스 진입은 기기가 감지해 서버에 알리고, 서버가 푸시를 보냅니다.

실시간 이벤트는 커밋된 뒤에 보낸다 — 할 일 변경과 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로 같이 고칩니다
  • 위젯·푸시 같은 작은 표면에 대한 반응이 가장 좋았습니다

관련 글

기술 결정은 글로 따로 남겼습니다.