대학생을 위한 로컬 맛집 추천 플랫폼
📺 클릭하여 전체 시연 영상 보기
**구르맛(Gourmet)**은 대학생들이 쉽게 주변 맛집을 찾고 공유할 수 있는 위치 기반 맛집 추천 앱입니다.
대학교를 중심으로 3km 반경 내 맛집 정보를 제공하며, Thread 스타일의 실시간 리뷰 피드를 통해 생생한 맛집 정보를 공유할 수 있습니다.
2024년 9월 2일 ~ 2024년 12월 20일 (15주)
| 이름 | 역할 |
|---|---|
| 최은서 (팀장) | Design & Frontend 개발 |
| 손주완 | Frontend,Backend 개발 |
| 전상우 | Frontend,Backend 개발 |
| 홍혜창 | Frontend,Backend 개발 |
- 위치 기반 서비스: 대학교 중심 3km 반경 내 맛집 표시
- 대학교별 필터링: 3개 주요 대학교 지원 (서울 소재)
- 실시간 위치 추적: 사용자의 현재 위치 표시
- 지도 인터랙션: 마커 클릭 시 음식점 간단 정보 표시
- Thread 스타일 피드: SNS 형태의 직관적인 리뷰 피드
- 이미지 업로드: 최대 5장의 음식 사진 업로드
- 별점 평가: 1~5점 별점 시스템
- 좋아요/싫어요: 리뷰에 대한 피드백 기능
- 리뷰 작성 제한: 대학생 인증 완료 시에만 작성 가능
- 리뷰 타임라인: 내가 작성한 리뷰 시간순 정렬
- 프로필 관리: 닉네임, 이메일, 소속 정보 표시
- 닉네임 수정: 실시간 닉네임 변경 기능
- 리뷰 삭제: 작성한 리뷰 관리
- 이메일 도메인 검증: 학교 공식 이메일 주소 확인
- 이메일 인증: Firebase Authentication을 통한 이메일 인증 링크 발송
- 60초 제한 시간: 제한 시간 내 이메일 인증 완료 필요
- 대학생: 리뷰 작성, 좋아요/싫어요, 음식점 조회
- 일반 회원: 음식점 조회만 가능 (리뷰 작성 불가)
| 패키지 | 버전 | 용도 |
|---|---|---|
google_maps_flutter |
^2.5.0 | Google Maps 지도 표시 |
geolocator |
^13.0.1 | GPS 위치 추적 |
cloud_firestore |
^5.4.4 | Firestore 데이터베이스 연동 |
firebase_auth |
^5.3.1 | Firebase 인증 |
firebase_storage |
^12.3.4 | 이미지 업로드/다운로드 |
image_picker |
^1.1.2 | 갤러리에서 이미지 선택 |
cached_network_image |
^3.3.0 | 네트워크 이미지 캐싱 |
google_fonts |
^6.2.1 | 커스텀 폰트 적용 |
주요 컬렉션 (Firestore)
users: 사용자 정보 (이메일, 닉네임, 대학교, 학생 여부)Restaurant: 음식점 정보 (이름, 위치, 카테고리, 영업시간, 메뉴)Review: 리뷰 정보 (별점, 내용, 이미지, 좋아요/싫어요 수)Menu: 메뉴 정보 (이름, 가격, 이미지)University: 대학교 정보 (이름, 이메일 도메인)
대학생 회원가입 절차: 대학생/일반인 선택 → 학교 이메일 인증 → 개인정보 입력
일반 회원가입 절차: 대학생/일반인 선택 → 직업 입력 → 개인정보 입력
기능: 대학교 선택 필터, 3km 반경 표시, 현재 위치 추적, 하단 음식점 리스트
기능: 지도 마커 클릭 시 음식점 간단 정보 (이름, 카테고리, 평점, 영업시간)
메뉴 탭: 음식 사진, 메뉴명, 가격
리뷰 탭: 별점, 리뷰 내용, 사진, 작성일
기능: 별점 선택, 리뷰 작성, 사진 추가 (최대 5장), 음식점 검색
기능: Thread 스타일 피드, 좋아요/싫어요, 실시간 업데이트
타임라인: 내가 작성한 리뷰 시간순 정렬, 리뷰 삭제
프로필: 이메일, 이름, 소속, 닉네임 수정
geolocator패키지를 활용한 실시간 GPS 추적- Google Maps API를 통한 지도 표시
- 대학교 중심 좌표 기준 3km 반경 Circle 표시
- Firestore의
Schools배열 필드를 활용한 음식점 필터링
- Authentication: 이메일 인증 기반 회원가입/로그인
- Firestore: 실시간 데이터 동기화 (
StreamBuilder+snapshots()) - Storage: 이미지 업로드 후 URL 저장
- Firestore의
snapshots()메서드를 통한 실시간 데이터 스트림 FieldValue.increment()를 활용한 좋아요/싫어요 카운터arrayUnion/arrayRemove를 통한 중복 방지
image_picker로 갤러리에서 다중 이미지 선택- Firebase Storage에 업로드 후 Firestore에 URL 저장
cached_network_image로 이미지 로딩 최적화
- ✅ 대학생 맞춤형 위치 기반 맛집 추천 시스템 구축
- ✅ 실시간 리뷰 공유를 통한 커뮤니티 활성화
- ✅ Firebase를 활용한 서버리스 백엔드 구현
- ✅ Flutter로 크로스 플랫폼 앱 개발
- 3개 대학교 주변 맛집 데이터베이스 구축
- Thread 스타일의 직관적인 리뷰 피드 구현
- 대학생 인증 시스템을 통한 신뢰성 있는 리뷰 관리
- 실시간 데이터 동기화를 통한 사용자 경험 향상
초기 데이터베이스 설계
처음에는 리뷰에 대한 좋아요/싫어요 기능을 단순하게 생각하여 개수만 저장하는 방식으로 설계했습니다.
Review 컬렉션 (초기 설계)
├── Review_number (Key, AUTO_INCREMENT)
├── Review_content (Field2, Domain)
├── Rating (Field3, Domain)
├── Date (Field6, Domain)
├── Good_rate (Field7, Domain) ← 좋아요 개수만 저장
├── Bad_rate (Field8, Domain) ← 싫어요 개수만 저장
├── User_id (Key2, Domain)
└── Restaurant_id (Key3, Domain)
발생한 문제
실제로 UI를 구현하다 보니 개수만으로는 해결할 수 없는 문제들이 발생했습니다:
-
사용자별 상태 표시 불가능
- 현재 사용자가 좋아요를 눌렀는지 안 눌렀는지 알 수 없음
- 아이콘을 채워진 상태(👍)로 표시할지, 빈 상태(👍🏻)로 표시할지 판단 불가
-
중복 방지 불가능
- 한 사용자가 좋아요를 여러 번 누를 수 있음
-
상태 유지 불가능
- 사용자가 화면을 나갔다가 다시 들어와도 자신이 눌렀던 버튼을 기억하지 못함
- 매번 초기 상태로 표시됨
예시: 부족했던 정보
// ❌ 초기 방식: 누가 눌렀는지 알 수 없음
Good_rate: 15 // 15명이 좋아요를 눌렀다는 것만 알 수 있음
Bad_rate: 3 // 하지만 "내가" 눌렀는지는 알 수 없음!개선된 데이터베이스 설계
좋아요/싫어요를 누른 사용자의 UID를 배열로 저장하는 필드를 추가했습니다.
Review 컬렉션 (개선된 설계)
├── Review_number (Key, AUTO_INCREMENT)
├── Review_content (string)
├── Rating (number)
├── Date (timestamp)
├── Good_rate (number) ← 좋아요 개수
├── Good_users (array) ← ✨ 좋아요 누른 사용자 UID 배열 (신규)
├── Bad_rate (number) ← 싫어요 개수
├── Bad_users (array) ← ✨ 싫어요 누른 사용자 UID 배열 (신규)
├── Content (string)
├── Images (array)
├── Nickname (string)
├── Restaurant_name (string)
└── UserId (string)
실제 Firestore 문서 예시
{
Bad_rate: 0,
Bad_users: [], // 싫어요 누른 사용자 목록
Good_rate: 1,
Good_users: [ // 좋아요 누른 사용자 목록
"m8exH7avJ3fAwmBpBO0fjuqwQR73" // 이 사용자가 좋아요를 눌렀음
],
Content: "여비는 진짜 레전드..맛집입니다. 아침부터 오프런했어요!!!",
Date: "2024년 12월 3일 PM 2시 58분 3초 UTC+9",
Images: [
"https://firebasestorage.googleapis.com/.../image1.jpg",
"https://firebasestorage.googleapis.com/.../image2.jpg"
],
Nickname: "전상우입니다",
Rating: 5,
Restaurant_name: "삼방매",
UserId: "OIuoJe03BkOjNU4ZLeHFiCjHiYW2"
}1. 현재 사용자의 좋아요/싫어요 상태 확인
// threadScreen.dart의 likeDislikeContainer 위젯
StreamBuilder(
stream: FirebaseFirestore.instance
.collection("Review")
.doc(widget.documentId)
.snapshots(),
builder: (context, snapshot) {
final doc = snapshot.data!.data();
// ✅ Good_users 배열에 현재 사용자 UID가 있는지 확인
isliked = doc!["Good_users"].contains(
FirebaseAuth.instance.currentUser!.uid
);
// ✅ Bad_users 배열에 현재 사용자 UID가 있는지 확인
isdisliked = doc!["Bad_users"].contains(
FirebaseAuth.instance.currentUser!.uid
);
return Container(
child: Row(
children: [
// 좋아요 버튼
IconButton(
onPressed: () async {
// 좋아요 토글
},
icon: Icon(
isliked ? Icons.thumb_up : Icons.thumb_up_outlined,
color: isliked ? Colors.blue : Colors.grey,
),
),
Text("${doc["Good_rate"]}"),
// 싫어요 버튼
IconButton(
onPressed: () async {
// 싫어요 토글
},
icon: Icon(
isdisliked ? Icons.thumb_down : Icons.thumb_down_outlined,
color: isdisliked ? Colors.red : Colors.grey,
),
),
Text("${doc["Bad_rate"]}"),
],
),
);
}
)2. 좋아요/싫어요 토글 로직
// 좋아요 버튼 클릭 시
IconButton(
onPressed: () async {
isliked = !isliked; // 상태 토글
await FirebaseFirestore.instance
.collection("Review")
.doc(widget.documentId)
.update({
// ✅ 카운트 증가/감소
"Good_rate": FieldValue.increment(isliked ? 1 : -1),
// ✅ 사용자 UID를 배열에 추가/제거
"Good_users": isliked
? FieldValue.arrayUnion([FirebaseAuth.instance.currentUser!.uid])
: FieldValue.arrayRemove([FirebaseAuth.instance.currentUser!.uid])
});
},
icon: Icon(
isliked ? Icons.thumb_up : Icons.thumb_up_outlined,
color: isliked ? Colors.blue : Colors.grey,
),
)
// 싫어요 버튼도 동일한 방식
IconButton(
onPressed: () async {
isdisliked = !isdisliked;
await FirebaseFirestore.instance
.collection("Review")
.doc(widget.documentId)
.update({
"Bad_rate": FieldValue.increment(isdisliked ? 1 : -1),
"Bad_users": isdisliked
? FieldValue.arrayUnion([FirebaseAuth.instance.currentUser!.uid])
: FieldValue.arrayRemove([FirebaseAuth.instance.currentUser!.uid])
});
},
icon: Icon(
isdisliked ? Icons.thumb_down : Icons.thumb_down_outlined,
color: isdisliked ? Colors.red : Colors.grey,
),
)Before (문제 있는 방식)
사용자 A가 리뷰 화면 진입
↓
좋아요 개수: 15개 표시
↓
❌ 내가 눌렀는지 모름 → 빈 아이콘(👍🏻) 표시
↓
좋아요 버튼 클릭
↓
❌ 중복 체크 불가 → 개수만 증가 (16개)
↓
화면 나갔다가 다시 진입
↓
❌ 상태 초기화 → 또 빈 아이콘 표시
After (개선된 방식)
사용자 A가 리뷰 화면 진입
↓
Good_users 배열 확인
↓
✅ 내 UID가 있음 → 채워진 아이콘(👍) 표시
↓
좋아요 버튼 클릭 (토글)
↓
✅ arrayRemove로 UID 제거 → 중복 방지
✅ increment(-1)로 개수 감소
↓
화면 나갔다가 다시 진입
↓
✅ Good_users 배열 확인 → 상태 유지
핵심 개선 사항
| 기능 | Before | After |
|---|---|---|
| 사용자별 상태 표시 | ❌ 불가능 (개수만 저장) | ✅ 가능 (Good_users 배열 확인) |
| 중복 방지 | ❌ 불가능 (여러 번 클릭 가능) | ✅ arrayUnion/arrayRemove로 자동 방지 |
| 상태 유지 | ❌ 화면 재진입 시 초기화 | ✅ 배열에 UID 저장으로 영구 유지 |
| 아이콘 표시 | ❌ 항상 빈 아이콘 | ✅ 눌렀으면 채워진 아이콘 |
| 동시 클릭 방지 | ❌ 좋아요+싫어요 동시 가능 | ✅ 배열 기반 상태 관리로 방지 가능 |
스키마리스(Schemaless) 구조의 장점
이번 문제를 해결하면서 Firebase Firestore의 NoSQL 특성을 제대로 활용할 수 있었습니다.
- 필드 추가의 유연성
기존 문서에 새 필드 추가 시:
RDB (MySQL, PostgreSQL)
❌ ALTER TABLE 필요
❌ 기존 데이터 마이그레이션 필요
❌ 스키마 변경 시간 소요
NoSQL (Firestore)
✅ 그냥 필드 추가하면 끝
✅ 기존 데이터 영향 없음
✅ 즉시 반영
-
점진적 마이그레이션
- 기존에 생성된 리뷰 문서:
Good_users,Bad_users필드 없음 - 새로 생성되는 리뷰 문서: 해당 필드 포함
- 둘 다 정상 작동 → 필드 없으면 빈 배열로 처리
- 기존에 생성된 리뷰 문서:
-
실제 적용 과정
// ✅ 필드가 없어도 안전하게 처리
isliked = (doc["Good_users"] ?? []).contains(
FirebaseAuth.instance.currentUser!.uid
);
// ✅ 새 문서 생성 시 필드 추가
await FirebaseFirestore.instance
.collection("Review")
.add({
"Good_rate": 0,
"Good_users": [], // 신규 필드
"Bad_rate": 0,
"Bad_users": [], // 신규 필드
// ... 기타 필드
});느낀 점
"처음에는 단순하게 좋아요 개수만 세면 된다고 생각했는데, 실제 구현하다 보니 **'누가 눌렀는지'**를 추적해야 한다는 걸 깨달았습니다.
만약 RDB였다면 테이블 구조를 변경하고 마이그레이션 스크립트를 작성해야 했겠지만, Firestore의 스키마리스 특성 덕분에 기존 데이터에 영향 없이 새로운 필드를 추가할 수 있었습니다.
이번 경험을 통해 NoSQL의 유연성과
arrayUnion/arrayRemove같은 Firestore의 강력한 배열 연산 기능을 제대로 활용할 수 있었습니다."
gourmet_app/
├── lib/
│ ├── main.dart # 앱 진입점
│ ├── firebase_options.dart # Firebase 설정
│ ├── screens/
│ │ ├── loginScreen.dart # 로그인 화면
│ │ ├── signupScreen.dart # 회원가입 화면
│ │ ├── mapScreen.dart # 지도 메인 화면
│ │ ├── restaurantDetailsScreen.dart # 음식점 상세 화면
│ │ ├── uploadScreen.dart # 리뷰 작성 화면
│ │ ├── threadScreen.dart # 리뷰 피드 화면
│ │ └── myPageScreen.dart # 마이페이지 화면
│ └── components/
│ └── restourantInfo.dart # 음식점 정보 컴포넌트
├── docs/ # 문서 및 이미지
│ ├── data-flow-diagram.png
│ ├── database-erd.png
│ ├── database-table.png
│ ├── menu-structure.png
│ └── screen-*.png
├── images/ # 앱 내 리소스
│ └── loading.png
└── pubspec.yaml # 패키지 의존성 관리
- Flutter SDK 3.24 이상
- Dart 3.5.3 이상
- Firebase 프로젝트 설정 (Authentication, Firestore, Storage 활성화)
- Google Maps API 키 발급

















