Pawloop (포루프)
반려견 산책 기록, 반려동반 장소 지도, 가족 케어·일정, 동네 커뮤니티·크루, 채팅을 묶은 앱입니다. 기획, Flutter 앱, Spring Boot 서버, AWS 인프라를 1인 개발로 진행해 2026년 9월 원스토어에 출시했고 지금 운영하고 있습니다. AI 코딩 에이전트와 함께 작성했고 커밋의 약 81%에 공저로 표기했습니다.
Role
1인 개발 — 기획, Flutter 앱, Spring Boot 서버, AWS 인프라, 원스토어 출시, 운영
Period
2026.06 – 현재 · 2026.09 원스토어 출시
2.0.0
원스토어 출시
2026-09-18 바이너리 · 09-21 업데이트
23건
설계 결정 기록(ADR)
기획·법·디자인·앱·서버 포함, 대안 비교 18건
1,994건 · 실패 0
백엔드 테스트 전체 실행
2026-09-30 GitHub main, 약 18분
화면

산책

가족 케어

커뮤니티

지도

홈
기획부터 운영까지
| 단계 | 시기 | 한 일 | 레포의 산출물 |
|---|---|---|---|
| 기획 | 07-01 피벗, 문서 레포 반영 09-01 | 감정 여행 앱에서 반려 앱으로 방향 전환, 가족 케어 문제 정의(이중 급여, 산책 누락), 위치정보법 검토 | docs/00-product(concept, personas, feature-family-care, legal-lbs-compliance), ADR-0001 |
| 설계 | 08-29 레포 반영 | 디자인 시스템, 정보 구조, 화면 명세. API 계약을 정본으로 두고 코드와 테스트로 대조(경로 88, 연산 122). 결정 기록 23건 | docs/01-design, docs/, docs/ |
| 앱 | 06-29 – | Flutter 화면 35개, 산책 저장 실패 시 초안 큐에 쌓아 재전송, 좌표 전송 정책 한 곳, 채팅 적응형 폴링, 소셜 로그인 딥링크 | app/lib/v2 |
| 서버 | 08-30 – 09-10 도메인 모듈 | 모듈러 모놀리스(최상위 모듈 22개), Flyway 표 38개, 커밋 뒤 이벤트 5종, 새벽 정리 잡, 외부 API 대리 호출 | backend/ |
| 인프라 | 09-16 – 09-17 | Terraform 운영 환경(EC2 1대, Elastic IP, Route 53, 별도 EBS, SES), 운영 설정은 SSM, Caddy 단일 진입점 | backend/ |
| 출시 | 09-17 – 09-21 | Android applicationId 변경, 약관·개인정보처리방침 공개 사이트, 원스토어 2.0.0 바이너리(09-18)와 업데이트(09-21) | store/(스토어 그래픽 원본), Caddy 정적 사이트 |
| 운영 | 09-16 – | 수동 배포 절차, 운영 설정을 바꾸는 길 하나(스크립트), CI(테스트, analyze, 문서 드리프트), 첫 운영 부팅 문제 대응 | envs/, backend/, . |
출발점: HeatTrip의 2026-06 앱 재설계 코드(비공개, 로그인·미디어 업로드 등)로 저장소를 시작했고, 2026-07-01 반려 앱으로 방향을 바꿨습니다. 지금 코드 중 그 출발 코드에서 온 줄은 서버 약 5%, 앱 약 3%입니다(git blame, 커밋 b0f89f6 기준).
기획·설계 문서는 코드와 같은 레포에서 관리했고, 다수가 AI 공저 커밋으로 들어왔습니다.
시스템 구성
- 인터넷 전체에 열린 문은 Caddy(443·80) 하나입니다. SSH(22)는 지정한 IP 대역에만 엽니다. 앱 컨테이너는 호스트의 127.0.0.1:8080에만 게시하고, DB는 밖에 열지 않습니다.
- 공공데이터 API(관광공사, 두루누비, 인허가, 기상청, 에어코리아)와 AI API의 키는 서버에만 있습니다. 앱은 서버를 거쳐 데이터를 받습니다.
- 좌표가 기기를 떠나는 자리는
LocationUploadPolicy하나입니다. 전송 여부는 컴파일 타임 상수로 정하고, 기본값은 꺼짐입니다.
서버와 데이터
- 하나의 Spring Boot 앱이고, 최상위 패키지 22개가 Spring Modulith 모듈입니다. 묶음은 반려·산책(dog, walk, walksafety), 가족·일정(group, care, schedule), 소통(chat, chatweb, crew, community, moderation, moderationweb, push), 장소(place), 계정(user, OAuth, consent), 공통(S3, security, config, error, observability)입니다.
- 모듈 경계 검사는 나중에 들인 래칫이고, 위반 기준선은 처음 620에서 지금 107입니다(모듈 간 순환 0).
- 모듈 경계를 넘는 FK는 21개이고 대부분 users와 dog로 갑니다. 테스트가 이 목록을 기준선으로 묶어 둡니다. chat 모듈은 바깥 의존이 없고(
allowedDependencies = {}), 경계를 넘는 FK가 0입니다. 방은 가족·크루를origin_kind/origin_ref로 가리키고, 일정(owner_kind/owner_ref)과 미디어(ref_type/ref_id)도 FK 없는 참조 쌍을 씁니다. 올려 두고 어디에도 붙지 않은 채 하루가 지난 미디어는 매일 04:10 정리 잡이 지웁니다. - 모바일 재전송으로 생기는 중복은 DB 유니크 제약으로 막습니다. 산책은
UNIQUE(dog_id, client_walk_id), 채팅은UNIQUE(room_id, sender_user_id, client_msg_id)입니다.
커밋 뒤 이벤트(인메모리, 발행 → 구독)
ChatMessagePosted: chat → push(FCM 발송)ScheduleAssigned: schedule → push(담당자에게 알림)UserSessionsRevoked: user → push(기기 토큰 정리)FamilyGroupCreated: group → care(기본 케어 항목 생성)ReportFiled: moderation → observability(Slack 알림, 운영 설정 미확인)
Modulith 이벤트 레지스트리는 들이지 않았습니다(ADR-0012).
설계 결정
기획·앱·서버·인프라에 걸친 결정 네 가지입니다. 앞의 셋은 결정 기록(ADR)에 있고, 운영 바깥문의 이유는 그 구성을 만든 커밋 메시지에 있습니다.
위치정보: 좌표가 기기를 떠나는 자리를 한 곳으로
위치기반서비스사업 신고 전이라, 좌표가 기기를 떠나는 9곳을 LocationUploadPolicy 하나로 모았습니다. 전송 여부는 컴파일 타임 상수 UPLOAD_LOCATION(기본값 false)으로 정합니다. 서버의 경로 저장 기능은 지우지 않았습니다.
주변 장소는 전국 카탈로그를 내려받아 기기에서 거리를 계산합니다. 서버의 walk.path(LINESTRING, SRID 4326)에는 SPATIAL INDEX를 만들지 않았습니다. 산책을 공간 조건으로 찾는 질의가 없기 때문입니다.
대가로, 상수가 꺼져 있으면 코스 남기기와 이웃 코스 화면이 숨습니다.
채팅: 범용 방, HTTP 전송과 폴링, 방별 순번
방은 가족에 묶지 않고 origin_kind/origin_ref로 붙여 가족과 크루가 같이 씁니다. 전송은 HTTP POST이고 수신은 폴링입니다. 앱은 GET …/messages?afterSeq=에 If-None-Match를 붙이고, 새 것이 없으면 304를 받습니다. 폴링 간격은 3초에서 시작해 빈 응답마다 두 배로 늘고, 최대 30초입니다.
웹소켓과 Redis는 단일 인스턴스와 방 규모를 근거로 보류했습니다. 되돌릴 기준(빈 폴링 비율, p95, 커넥션 대기)은 ADR에 적었습니다.
커서는 id가 아니라 방별 room_seq입니다. 번호는 방 행의 잠금과 세션 값으로 한 번에 받습니다.
UPDATE chat_room SET last_seq = LAST_INSERT_ID(last_seq + 1) WHERE id = ?1
-- 같은 트랜잭션에서 LAST_INSERT_ID()로 번호를 읽고 chat_message를 INSERT
실제 MySQL(Testcontainers)에서 스레드 20개가 같은 방에 동시에 보내, 순번 1–20이 한 번씩 나오는지 테스트로 고정했습니다. 테스트의 커넥션 풀은 기본값 10이라, 실제로 동시에 도는 트랜잭션은 최대 10개입니다.
지운 말과 반응은 room_seq 델타로 오지 않습니다. 그래서 방 단위 change_seq를 따로 발급합니다. room_seq를 다시 발급하는 안은 옛 말이 맨 아래로 내려가고 미읽음이 늘어서 버렸습니다.

다른 멤버가 보낸 말을 지우면, 다음 폴링에서 같은 자리가 ‘삭제된 메시지예요’로 바뀝니다.
대가는 수신 지연(최대 30초 간격)입니다. 되돌릴 기준은 아직 운영에서 잴 수 없습니다(한계 3).
공공데이터 한도: 격자 캐시와 일일 예산
관광공사 API는 하루 1,000콜 한도가 있습니다. 그 안에서 0.02° 격자 단위 스냅샷 캐시(place_cell)를 두고, 일일 호출 예산(kto_call_budget)은 DB 행 하나의 조건부 UPDATE로 셉니다.
앱은 공공데이터 키를 갖지 않습니다. 서버가 관광공사, 두루누비, 인허가, 기상청, 에어코리아 API를 대신 부릅니다. 산책 안전 지수는 격자 번호만 받아 저장 없이 넘깁니다.
카탈로그는 새벽 04:40에 예열하고, 인허가·두루누비 데이터는 매월 1일 05:20에 갱신합니다. 대가로 외부 API 장애와 호출 비용을 서버가 집니다.
운영 바깥문과 서버 한 대
운영은 Terraform으로 만든 EC2 한 대의 Docker Compose입니다. 바깥문은 nginx 대신 Caddy 2.10입니다. Caddy가 인증서 자동 발급·갱신, X-Real-IP 주입, 약관 정적 사이트를 맡고, /actuator/*는 health만 통과시킵니다.
DB는 RDS가 아니라 같은 EC2의 MySQL 8.0 컨테이너입니다. 데이터 디렉터리만 별도 EBS(prevent_destroy)에 둡니다. 운영 설정(.env, 배포 키)은 SSM Parameter Store에 두고 부팅 때 받습니다. 설정을 고치는 길은 스크립트 하나입니다.
RDB 하나로 두고 Redis는 보류했습니다. 인스턴스가 둘이 되는 날 함께 옮겨야 할 것 네 가지(일회용 인가 코드 저장소, rate limit 카운터 등)는 ADR에 목록으로 남겼습니다. 대가는 단일 장애 지점, 배포 중 짧은 중단, 백업 절차가 없다는 점이고, 아래 한계 표에 적었습니다.
출시 준비와 첫 운영 부팅에서 겪은 일
| 날짜 | 무슨 일 | 바꾼 것 |
|---|---|---|
| 09-16 | 스테이징에서 terraform apply 뒤 EC2가 부팅 단계에서 아무것도 하지 못하는 문제 다섯 가지 | 부팅 스크립트 수정 |
| 09-17 | 첫 부팅이 buildx 부재·낡은 buildx, 이미지의 자산 누락으로 실패. 로컬 CI는 캐시된 결과로 “전부 통과”를 보고(실제로는 0개 실행) | buildx는 있는지가 아니라 버전을 확인. 테스트는 매번 cleanTest --no-build-cache로 새로 돌리고 결과 XML 개수를 셈 |
| 09-17 | Caddy가 뜨지 않음. 첫 운영 부팅에서는 Caddyfile 문법 오류(handle에 경로 매처 둘)로 바로 종료되어 80·443이 응답하지 않았고, Caddyfile 문자열을 검사하던 테스트는 통과하고 있었음. 겹친 bind mount의 마운트 지점도 없었음 | 운영과 같은 Caddy 이미지로 caddy validate를 실제로 실행하는 테스트로 교체, 마운트 지점 생성과 compose 정적 검사 추가 |
원스토어 2.0.0 바이너리는 09-18, 업데이트는 09-21에 올렸습니다.
한계와 다음 단계
| 한계 | 영향 | 다음 단계 |
|---|---|---|
| 서버 1대, 부하 테스트는 하지 않음 | 인스턴스 장애가 곧 중단이고, 처리 한계를 모릅니다. 푸시 발송 큐는 인메모리입니다(스레드 2–4, 큐 1,000, 넘치면 버림) | 인스턴스를 늘리기 전에 ADR 목록의 네 가지를 옮기기 |
배포가 수동(SSH → git pull → docker compose up -d --build) | 컨테이너를 다시 만드는 동안 짧게 끊깁니다. 진입점이 sh -c라 SIGTERM이 JVM에 닿지 않을 수 있습니다 | 이미지 빌드·배포 자동화, exec 형식 진입점 |
| 운영 관측 수단이 actuator health뿐 | 커스텀 지표(chat.seq.assign 등)는 기록되지만 수집기가 없습니다. 그래서 채팅 폴링의 되돌릴 기준(빈 폴링 비율, p95)을 운영에서 잴 수 없습니다 | 메트릭 수집기 도입 |
| 채팅 읽음 처리의 잠금 순서가 전송과 반대 | 둘이 겹치면 교착이 재현됩니다(현재 500 응답) | 잠금 순서 통일 |
| DB 백업 절차가 없음 | MySQL은 같은 EC2의 컨테이너이고, 데이터 볼륨만 EBS로 분리(prevent_destroy)했습니다. 운영 Terraform과 운영 README에 백업·복구 절차가 없습니다 | EBS 스냅샷 주기와 복구 리허설 |
소스는 비공개이고 시연과 코드 워크스루는 요청하면 할 수 있습니다. 기준은 GitHub main b0f89f6(2026-09-26)입니다.