feat: bootstrap lunch picker miniapp with backend, docs, and branding assets

This commit is contained in:
mingking2
2026-04-15 14:03:08 +09:00
commit 7faf251fd3
85 changed files with 31332 additions and 0 deletions

View File

@@ -0,0 +1,120 @@
# 직장인 점심 추천 미니앱 MVP 기획/기능 명세
## 1. 목표
- 사용자 현재 위치 기반으로 근처 식당을 빠르게 탐색한다.
- 점심 시간 의사결정을 줄이기 위해 카테고리 필터(한식/중식/일식/양식/기타)와 정렬(거리/평점/좋아요)을 제공한다.
- 사용자 생성 데이터(리뷰/별점/좋아요)를 누적해 추천 품질을 개선한다.
## 2. MVP 범위
### 2.1 포함
- 위치 권한 요청 및 현재 좌표 획득
- 반경 N미터 내 식당 목록 조회
- 카테고리 필터
- 식당 상세(기본 정보, 평균 평점, 좋아요 수, 최근 리뷰)
- 리뷰 작성/수정/삭제
- 별점(1~5) 작성
- 좋아요 토글
- 사용자별 내 리뷰 목록
### 2.2 제외(2차)
- 결제/예약
- 친구 초대/소셜 그래프
- AI 취향 학습 개인화 추천
- 관리자 운영 콘솔 웹
## 3. 사용자 시나리오
1. 사용자가 미니앱 실행
2. 위치 권한 동의
3. 현재 위치 기준 주변 식당 로딩
4. 카테고리(예: 일식) 선택
5. 정렬 기준(거리순/평점순) 선택
6. 식당 상세 진입
7. 리뷰와 평점 확인 후 본인 리뷰 남김
8. 마음에 드는 식당 좋아요
## 4. 화면 구조(MVP)
### 4.1 홈/목록 화면
- 상단: 현재 위치(동 이름 또는 좌표 기반 주소), 반경 선택(500m/1km/2km)
- 필터: 카테고리 탭
- 정렬: 거리순, 평점순, 좋아요순
- 리스트 카드: 식당명, 카테고리, 거리, 평균평점, 좋아요수, 리뷰수
### 4.2 식당 상세 화면
- 기본 정보: 상호명, 주소, 영업시간, 전화번호
- 통계: 평균평점, 총 리뷰수, 좋아요수
- 액션: 좋아요 버튼, 리뷰 작성 버튼
- 리뷰 리스트: 최신순 기본, 본인 리뷰 강조
### 4.3 리뷰 작성/수정 화면
- 평점(1~5)
- 텍스트(최대 500자)
- 저장/취소
### 4.4 내 활동 화면
- 내가 작성한 리뷰 목록
- 내가 좋아요한 식당 목록(선택)
## 5. 추천/정렬 규칙(MVP)
- 기본 정렬: 거리 오름차순
- 평점 정렬: 평균 평점 내림차순, 동점 시 리뷰수 내림차순
- 좋아요 정렬: 좋아요수 내림차순, 동점 시 거리 오름차순
## 6. 카테고리 정책
- 내부 표준 카테고리
- KOREAN
- CHINESE
- JAPANESE
- WESTERN
- OTHER
- 외부 장소 API 카테고리 값을 내부 표준 카테고리로 매핑
- 매핑 실패 시 OTHER
## 7. 권한/개인정보 최소 수집
- 필수: 위치 권한
- 선택: 알림(2차)
- 사용자 식별자: 토스 식별자 또는 내부 user_id
- 저장 데이터
- 리뷰 텍스트, 평점
- 좋아요 이력
- 위치는 조회 시점 중심 좌표만 사용(정밀 로그 장기 저장 금지 권장)
## 8. 비기능 요구사항
- 최초 화면 진입 10초 이내
- 목록 API p95 800ms 이하 목표
- 앱 크래시 없이 복구 가능한 오류 처리
- 네트워크 장애 시 재시도/안내 메시지 제공
## 9. 운영 정책
- 욕설/비방 리뷰 신고 플로우(간단 신고 버튼)
- 중복 리뷰 정책
- 식당당 사용자 1개 리뷰 원칙
- 작성 시 upsert(기존 리뷰 수정)
- 좋아요 정책
- 식당당 사용자 1회
- 토글 허용
## 10. 성공 지표(MVP)
- DAU
- 위치 권한 허용률
- 리뷰 작성 전환율
- 식당 상세 진입률
- 재방문율(7일)
## 11. 리스크와 대응
- 위치 권한 거부율 높음
- 대안: 수동 지역 선택(구/동) 제공
- 카테고리 오분류
- 대안: 사용자 제보/운영 보정
- 초기 리뷰 부족
- 대안: 기본 외부 평점(가능 시) + 내부 리뷰 분리 표기
## 12. 릴리즈 단위
### v0.1 (MVP)
- 위치 기반 목록/상세
- 카테고리 필터
- 리뷰/평점/좋아요
### v0.2
- 개인화 추천(최근 선택 기반)
- 점심시간 알림
- 회사/사무실 즐겨찾기

View File

@@ -0,0 +1,148 @@
# 직장인 점심 추천 미니앱 구현 계획
## 1. 기술 스택 제안
- MiniApp Frontend: Apps in Toss + Granite RN
- Backend API: Node.js (NestJS/Express) or Java Spring Boot
- DB: PostgreSQL
- Cache: Redis (선택)
- 지도/장소 데이터: Kakao/Google/Naver Place API 중 1개 선택
## 2. 프론트엔드 디렉터리 구조 제안
```
template/src/
_app.tsx
pages/
index.tsx # 홈/목록
restaurants/[id].tsx # 식당 상세
reviews/edit.tsx # 리뷰 작성/수정
me/reviews.tsx # 내 리뷰
_404.tsx
features/
location/
useCurrentLocation.ts
restaurants/
api.ts
models.ts
hooks.ts
reviews/
api.ts
hooks.ts
likes/
api.ts
components/
RestaurantCard.tsx
CategoryFilter.tsx
SortSelector.tsx
RatingStars.tsx
EmptyState.tsx
lib/
httpClient.ts
queryClient.ts
error.ts
constants/
category.ts
sort.ts
```
## 3. 백엔드 모듈 구조 제안
- auth
- 토스 사용자 식별자 검증/매핑
- restaurants
- 주변 조회, 상세 조회
- reviews
- 리뷰 upsert/delete/list
- likes
- 좋아요 토글
- users
- 내 리뷰 조회
- integrations
- 장소 API 클라이언트, 카테고리 매핑
## 4. 핵심 유스케이스별 처리
### 4.1 주변 식당 조회
1. FE가 현재 좌표(lat,lng) 획득
2. GET /v1/restaurants/nearby 호출
3. BE는 내부 캐시 우선 조회
4. 캐시 미스 시 장소 API 호출 + 표준 카테고리 매핑 + DB upsert
5. 통계(join restaurant_stat) 결합 후 반환
### 4.2 리뷰 작성/수정
1. FE가 rating/content 전송
2. BE가 (restaurant_id, user_id) unique 기준 upsert
3. refresh_restaurant_stat(restaurant_id) 호출
4. 최신 리뷰/평점 반환
### 4.3 좋아요 토글
1. row 존재 여부 확인
2. 있으면 delete, 없으면 insert
3. refresh_restaurant_stat 호출
4. liked 상태와 like_count 반환
## 5. API 에러 규약
- 공통 포맷
```json
{
"code": "INVALID_INPUT",
"message": "rating must be between 1 and 5",
"traceId": "..."
}
```
- 주요 코드
- UNAUTHORIZED
- FORBIDDEN
- NOT_FOUND
- INVALID_INPUT
- RATE_LIMITED
- INTERNAL_ERROR
## 6. 보안/악용 방지
- 리뷰/좋아요 API 인증 필수
- 리뷰 작성 rate limit (예: 사용자당 분당 5회)
- 욕설 필터(간단 키워드 + 신고 플래그)
- SQL injection/XSS 대비(파라미터 바인딩, escaping)
## 7. QA 체크리스트
- 위치 권한 허용/거부 둘 다 정상 동작
- 권한 거부 시 수동 지역 선택 제공
- 0개 결과(empty state) UI
- 느린 네트워크에서 skeleton/loading 표시
- 리뷰 저장 후 목록/평점 즉시 반영
- 좋아요 토글 연타 시 데이터 일관성 확인
- 앱 백그라운드 전환 후 복귀 시 상태 복원
## 8. 출시 전 운영 준비
- 개인정보처리방침/이용약관 페이지
- 고객 문의 채널(이메일/채팅 URL)
- 모니터링 대시보드
- API 성공률
- p95 latency
- 에러율
- 장애 대응 runbook
## 9. 개발 일정(예시, 2주)
### Day 1-2
- 프로젝트 구조 확장
- 공통 HTTP 클라이언트/에러 처리
### Day 3-5
- 식당 목록/상세 API + 화면
- 위치 권한/좌표 처리
### Day 6-8
- 리뷰 CRUD + 좋아요 토글
- 통계 반영
### Day 9-10
- 내 활동 화면
- QA/버그 수정
### Day 11-12
- 성능/로그 점검
- 검수 대응 문서 정리
## 10. 바로 시작할 개발 순서
1. `granite.config.ts`의 appName/brand 값 실서비스 값으로 변경
2. 홈 목록 화면에 위치 권한 + nearby API 연결
3. 식당 상세와 리뷰 작성 페이지 연결
4. 리뷰/좋아요 API 붙이고 optimistic update 적용
5. 검수 체크리스트 기준 점검 후 배포

View File

@@ -0,0 +1,50 @@
# 토스 미니앱 배포 체크리스트 (점심 추천 서비스)
## 1. 앱 기본 정보
- [ ] `appName`이 콘솔 등록 값과 정확히 일치
- [ ] 앱 이름/아이콘/브랜드 컬러 반영
- [ ] 고객 문의 이메일/채널 기입
- [ ] 이용 연령, 카테고리 설정 완료
## 2. 권한/정책
- [ ] 위치 권한 요청 사유를 사용자에게 명확히 설명
- [ ] 권한 거부 시 대체 경로(수동 지역 선택) 제공
- [ ] 외부 링크 유도/자사앱 설치 유도 없음
- [ ] 민감 정보 과수집 없음
## 3. UX/기능
- [ ] 첫 화면 10초 이내 진입
- [ ] Safe Area 미침범
- [ ] 뒤로가기/닫기 동작 정상
- [ ] 빈 결과/오류 상태 UI 제공
- [ ] 리뷰 작성/수정/삭제 정상
- [ ] 좋아요 토글 정상 및 중복 방지
## 4. 데이터 품질
- [ ] 카테고리 매핑 정확도 점검(일식/중식/양식)
- [ ] 거리 계산 오차 허용 범위 점검
- [ ] 평균 평점/좋아요 수 집계 검증
## 5. 성능/안정성
- [ ] 목록 API p95 지연 측정
- [ ] 동일 요청 과다 호출 방지(debounce/throttle)
- [ ] 앱 백그라운드 복귀 시 상태 복원
- [ ] 장애 로그/추적 ID 수집
## 6. 보안
- [ ] 인증 없는 쓰기 API 차단
- [ ] 입력 검증(rating 범위, content 길이)
- [ ] SQL injection/XSS 방어
- [ ] rate limit 적용
## 7. 배포 산출물
- [ ] `npm run build` 성공
- [ ] `.ait` 산출물 생성 확인
- [ ] RN 0.84.0 번들 포함 확인
- [ ] 콘솔 업로드 및 테스트 QR 검증
## 8. 운영 준비
- [ ] 개인정보처리방침 URL
- [ ] 이용약관 URL
- [ ] 신고/문의 프로세스
- [ ] 장애 대응 담당자 지정

View File

@@ -0,0 +1,53 @@
# Local Development Guide
## 1) Backend 실행
```bash
cd /home/mingking2/pick-lunch/template/backend
npm install
cp .env.example .env
npm start
```
기본 주소: `http://localhost:4000`
## 2) MySQL 연결 (선택)
MySQL 없이도 메모리 모드로 동작합니다.
MySQL을 쓰려면:
1. DB 생성
```sql
create database lunch_picker character set utf8mb4 collate utf8mb4_0900_ai_ci;
```
2. 스키마 반영
```bash
mysql -u root -p lunch_picker < /home/mingking2/pick-lunch/template/docs/db/schema.sql
```
3. `backend/.env``MYSQL_HOST`, `MYSQL_USER`, `MYSQL_PASSWORD`, `MYSQL_DATABASE` 설정
## 3) MiniApp API 연결
파일: `src/features/restaurants/service.ts`
```ts
const API_BASE_URL = 'http://localhost:4000';
```
주의: 실제 토스 인앱 테스트/배포에서는 `localhost` 대신
외부 접근 가능한 HTTPS 백엔드 도메인을 사용해야 합니다.
## 4) 프론트 실행
```bash
cd /home/mingking2/pick-lunch/template
npm run dev
```
## 5) 프론트 빌드
```bash
npm run build
```

8
template/docs/README.md Normal file
View File

@@ -0,0 +1,8 @@
# Lunch MiniApp Docs
- `01_MVP_PRODUCT_SPEC.md`: 제품/기능 명세
- `02_IMPLEMENTATION_PLAN.md`: 구현 구조/일정/QA 계획
- `03_RELEASE_CHECKLIST.md`: 배포 전 점검표
- `04_LOCAL_DEV.md`: 로컬 실행/연동 가이드
- `api/openapi.yaml`: API 계약(OpenAPI 3.0)
- `db/schema.sql`: DB 스키마(MySQL 8.0)

View File

@@ -0,0 +1,356 @@
openapi: 3.0.3
info:
title: Lunch MiniApp API
version: 0.1.0
description: |
직장인 점심 추천 미니앱 MVP API
servers:
- url: https://api.example.com
tags:
- name: Health
- name: Restaurants
- name: Reviews
- name: Likes
- name: Users
paths:
/v1/health:
get:
tags: [Health]
summary: 헬스 체크
responses:
'200':
description: OK
content:
application/json:
schema:
type: object
properties:
status:
type: string
example: ok
/v1/restaurants/nearby:
get:
tags: [Restaurants]
summary: 주변 식당 목록 조회
parameters:
- in: query
name: lat
required: true
schema:
type: number
format: double
- in: query
name: lng
required: true
schema:
type: number
format: double
- in: query
name: radiusMeters
required: false
schema:
type: integer
default: 1000
enum: [500, 1000, 2000]
- in: query
name: category
required: false
schema:
$ref: '#/components/schemas/RestaurantCategory'
- in: query
name: sort
required: false
schema:
type: string
enum: [distance, rating, likes]
default: distance
- in: query
name: cursor
required: false
schema:
type: string
- in: query
name: size
required: false
schema:
type: integer
minimum: 1
maximum: 50
default: 20
responses:
'200':
description: 조회 성공
content:
application/json:
schema:
type: object
properties:
items:
type: array
items:
$ref: '#/components/schemas/RestaurantListItem'
nextCursor:
type: string
nullable: true
/v1/restaurants/{restaurantId}:
get:
tags: [Restaurants]
summary: 식당 상세 조회
parameters:
- in: path
name: restaurantId
required: true
schema:
type: string
format: uuid
responses:
'200':
description: 조회 성공
content:
application/json:
schema:
$ref: '#/components/schemas/RestaurantDetail'
'404':
description: 식당 없음
/v1/restaurants/{restaurantId}/reviews:
get:
tags: [Reviews]
summary: 식당 리뷰 목록 조회
parameters:
- in: path
name: restaurantId
required: true
schema:
type: string
format: uuid
- in: query
name: cursor
required: false
schema:
type: string
- in: query
name: size
required: false
schema:
type: integer
default: 20
minimum: 1
maximum: 50
responses:
'200':
description: 조회 성공
content:
application/json:
schema:
type: object
properties:
items:
type: array
items:
$ref: '#/components/schemas/ReviewItem'
nextCursor:
type: string
nullable: true
post:
tags: [Reviews]
summary: 리뷰 작성 또는 수정(upsert)
parameters:
- in: path
name: restaurantId
required: true
schema:
type: string
format: uuid
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/ReviewUpsertRequest'
responses:
'200':
description: 저장 성공
content:
application/json:
schema:
$ref: '#/components/schemas/ReviewItem'
'400':
description: 유효성 오류
'401':
description: 인증 필요
/v1/reviews/{reviewId}:
delete:
tags: [Reviews]
summary: 리뷰 삭제
parameters:
- in: path
name: reviewId
required: true
schema:
type: string
format: uuid
responses:
'204':
description: 삭제 성공
'401':
description: 인증 필요
'403':
description: 본인 리뷰만 삭제 가능
/v1/restaurants/{restaurantId}/like:
post:
tags: [Likes]
summary: 좋아요 토글
parameters:
- in: path
name: restaurantId
required: true
schema:
type: string
format: uuid
responses:
'200':
description: 토글 성공
content:
application/json:
schema:
type: object
properties:
liked:
type: boolean
likeCount:
type: integer
/v1/users/me/reviews:
get:
tags: [Users]
summary: 내 리뷰 목록
parameters:
- in: query
name: cursor
schema:
type: string
- in: query
name: size
schema:
type: integer
default: 20
minimum: 1
maximum: 50
responses:
'200':
description: 조회 성공
content:
application/json:
schema:
type: object
properties:
items:
type: array
items:
$ref: '#/components/schemas/ReviewItem'
nextCursor:
type: string
nullable: true
components:
schemas:
RestaurantCategory:
type: string
enum:
- KOREAN
- CHINESE
- JAPANESE
- WESTERN
- OTHER
RestaurantListItem:
type: object
properties:
id:
type: string
format: uuid
name:
type: string
category:
$ref: '#/components/schemas/RestaurantCategory'
distanceMeters:
type: integer
averageRating:
type: number
format: float
reviewCount:
type: integer
likeCount:
type: integer
likedByMe:
type: boolean
required: [id, name, category, distanceMeters, averageRating, reviewCount, likeCount, likedByMe]
RestaurantDetail:
allOf:
- $ref: '#/components/schemas/RestaurantListItem'
- type: object
properties:
address:
type: string
phone:
type: string
nullable: true
openingHours:
type: string
nullable: true
lat:
type: number
format: double
lng:
type: number
format: double
ReviewUpsertRequest:
type: object
properties:
rating:
type: integer
minimum: 1
maximum: 5
content:
type: string
minLength: 1
maxLength: 500
required: [rating, content]
ReviewItem:
type: object
properties:
id:
type: string
format: uuid
restaurantId:
type: string
format: uuid
userId:
type: string
format: uuid
nickName:
type: string
rating:
type: integer
content:
type: string
createdAt:
type: string
format: date-time
updatedAt:
type: string
format: date-time
mine:
type: boolean
required: [id, restaurantId, userId, nickName, rating, content, createdAt, updatedAt, mine]

127
template/docs/db/schema.sql Normal file
View File

@@ -0,0 +1,127 @@
-- Lunch MiniApp MVP MySQL Schema
-- Assumption: MySQL 8.0+
-- Charset/Collation: utf8mb4
set names utf8mb4;
-- 1) users
create table if not exists app_user (
id char(36) not null,
provider varchar(32) not null default 'toss',
provider_user_id varchar(128) not null,
nick_name varchar(50) not null,
created_at timestamp not null default current_timestamp,
updated_at timestamp not null default current_timestamp on update current_timestamp,
primary key (id),
unique key uk_app_user_provider (provider, provider_user_id)
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_0900_ai_ci;
-- 2) restaurants
create table if not exists restaurant (
id char(36) not null,
external_place_id varchar(128) null,
name varchar(120) not null,
category varchar(20) not null,
address varchar(255) not null,
phone varchar(30) null,
opening_hours text null,
lat double not null,
lng double not null,
is_active tinyint(1) not null default 1,
created_at timestamp not null default current_timestamp,
updated_at timestamp not null default current_timestamp on update current_timestamp,
primary key (id),
unique key uk_restaurant_external_place_id (external_place_id),
key idx_restaurant_lat_lng (lat, lng),
key idx_restaurant_category (category),
constraint chk_restaurant_category check (category in ('KOREAN','CHINESE','JAPANESE','WESTERN','OTHER'))
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_0900_ai_ci;
-- 3) reviews (1 user : 1 restaurant)
create table if not exists restaurant_review (
id char(36) not null,
restaurant_id char(36) not null,
user_id char(36) not null,
rating tinyint not null,
content varchar(500) not null,
deleted_at timestamp null,
created_at timestamp not null default current_timestamp,
updated_at timestamp not null default current_timestamp on update current_timestamp,
primary key (id),
unique key uk_review_restaurant_user (restaurant_id, user_id),
key idx_review_restaurant_created_at (restaurant_id, created_at),
key idx_review_user_created_at (user_id, created_at),
constraint fk_review_restaurant foreign key (restaurant_id) references restaurant(id) on delete cascade,
constraint fk_review_user foreign key (user_id) references app_user(id) on delete cascade,
constraint chk_review_rating check (rating between 1 and 5)
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_0900_ai_ci;
-- 4) likes (1 user : 1 restaurant)
create table if not exists restaurant_like (
restaurant_id char(36) not null,
user_id char(36) not null,
created_at timestamp not null default current_timestamp,
primary key (restaurant_id, user_id),
key idx_like_restaurant (restaurant_id),
key idx_like_user (user_id),
constraint fk_like_restaurant foreign key (restaurant_id) references restaurant(id) on delete cascade,
constraint fk_like_user foreign key (user_id) references app_user(id) on delete cascade
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_0900_ai_ci;
-- 5) denormalized stats
create table if not exists restaurant_stat (
restaurant_id char(36) not null,
review_count int not null default 0,
like_count int not null default 0,
rating_sum int not null default 0,
average_rating decimal(3,2) not null default 0.00,
updated_at timestamp not null default current_timestamp on update current_timestamp,
primary key (restaurant_id),
constraint fk_stat_restaurant foreign key (restaurant_id) references restaurant(id) on delete cascade
) engine=InnoDB default charset=utf8mb4 collate=utf8mb4_0900_ai_ci;
-- optional seed behavior:
-- insert into restaurant_stat (restaurant_id)
-- select r.id from restaurant r
-- on duplicate key update restaurant_id = values(restaurant_id);
-- 6) stat refresh procedure (call from app service after write)
drop procedure if exists refresh_restaurant_stat;
delimiter $$
create procedure refresh_restaurant_stat(in p_restaurant_id char(36))
begin
declare v_review_count int default 0;
declare v_like_count int default 0;
declare v_rating_sum int default 0;
declare v_average decimal(3,2) default 0.00;
select count(*), ifnull(sum(r.rating), 0)
into v_review_count, v_rating_sum
from restaurant_review r
where r.restaurant_id = p_restaurant_id
and r.deleted_at is null;
select count(*)
into v_like_count
from restaurant_like l
where l.restaurant_id = p_restaurant_id;
if v_review_count = 0 then
set v_average = 0.00;
else
set v_average = round(v_rating_sum / v_review_count, 2);
end if;
insert into restaurant_stat (
restaurant_id, review_count, like_count, rating_sum, average_rating, updated_at
) values (
p_restaurant_id, v_review_count, v_like_count, v_rating_sum, v_average, current_timestamp
)
on duplicate key update
review_count = values(review_count),
like_count = values(like_count),
rating_sum = values(rating_sum),
average_rating = values(average_rating),
updated_at = current_timestamp;
end $$
delimiter ;