feat: 초기 프로젝트 설정 및 LunchPick 앱 구현
LunchPick(오늘 뭐 먹Z?) Flutter 앱의 초기 구현입니다. 주요 기능: - 네이버 지도 연동 맛집 추가 - 랜덤 메뉴 추천 시스템 - 날씨 기반 거리 조정 - 방문 기록 관리 - Bluetooth 맛집 공유 - 다크모드 지원 기술 스택: - Flutter 3.8.1+ - Riverpod 상태 관리 - Hive 로컬 DB - Clean Architecture 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
589
doc/03_architecture/code_convention.md
Normal file
589
doc/03_architecture/code_convention.md
Normal file
@@ -0,0 +1,589 @@
|
||||
# 코드 컨벤션 문서
|
||||
|
||||
## 1. 개요
|
||||
|
||||
이 문서는 "오늘 뭐 먹Z?" 프로젝트의 코드 작성 규칙과 스타일 가이드를 정의합니다. 일관된 코드 스타일은 가독성을 높이고 유지보수를 용이하게 합니다.
|
||||
|
||||
## 2. 일반 원칙
|
||||
|
||||
### 2.1 기본 규칙
|
||||
- **DRY (Don't Repeat Yourself)**: 코드 중복 최소화
|
||||
- **KISS (Keep It Simple, Stupid)**: 단순하고 명확한 코드 작성
|
||||
- **YAGNI (You Aren't Gonna Need It)**: 필요하지 않은 기능 미리 구현 금지
|
||||
- **단일 책임 원칙**: 하나의 클래스/함수는 하나의 책임만
|
||||
|
||||
### 2.2 언어별 규칙
|
||||
- **코드**: 영어 (변수명, 함수명, 클래스명)
|
||||
- **주석**: 한국어
|
||||
- **커밋 메시지**: 한국어
|
||||
- **문서**: 한국어
|
||||
|
||||
## 3. 네이밍 컨벤션
|
||||
|
||||
### 3.1 기본 네이밍 규칙
|
||||
|
||||
| 요소 | 스타일 | 예시 |
|
||||
|------|--------|------|
|
||||
| 클래스 | PascalCase | `NaverUrlProcessor`, `RestaurantRepository` |
|
||||
| 인터페이스 | PascalCase | `IRestaurantRepository` (선택적 I 접두사) |
|
||||
| 함수/메서드 | camelCase | `processNaverUrl`, `searchRestaurants` |
|
||||
| 변수 | camelCase | `restaurantName`, `maxDistance` |
|
||||
| 상수 | UPPER_SNAKE_CASE | `MAX_RETRY_COUNT`, `API_TIMEOUT` |
|
||||
| 파일명 | snake_case | `naver_url_processor.dart` |
|
||||
| 폴더명 | snake_case | `data_sources`, `use_cases` |
|
||||
|
||||
### 3.2 의미있는 이름 작성
|
||||
|
||||
```dart
|
||||
// ❌ 나쁜 예
|
||||
String n = "김밥천국";
|
||||
int d = 500;
|
||||
List<Restaurant> r = [];
|
||||
|
||||
// ✅ 좋은 예
|
||||
String restaurantName = "김밥천국";
|
||||
int maxDistanceInMeters = 500;
|
||||
List<Restaurant> nearbyRestaurants = [];
|
||||
```
|
||||
|
||||
### 3.3 Boolean 변수 네이밍
|
||||
|
||||
```dart
|
||||
// ❌ 나쁜 예
|
||||
bool loading = true;
|
||||
bool error = false;
|
||||
|
||||
// ✅ 좋은 예
|
||||
bool isLoading = true;
|
||||
bool hasError = false;
|
||||
bool canDelete = true;
|
||||
bool shouldRefresh = false;
|
||||
```
|
||||
|
||||
### 3.4 함수/메서드 네이밍
|
||||
|
||||
```dart
|
||||
// 동사로 시작
|
||||
Future<void> saveRestaurant(Restaurant restaurant);
|
||||
Future<List<Restaurant>> fetchNearbyRestaurants();
|
||||
bool validateUrl(String url);
|
||||
void clearCache();
|
||||
|
||||
// 상태 확인 메서드는 is/has/can으로 시작
|
||||
bool isValidNaverUrl(String url);
|
||||
bool hasLocationPermission();
|
||||
bool canProcessUrl(String url);
|
||||
```
|
||||
|
||||
## 4. 파일 구조 및 조직
|
||||
|
||||
### 4.1 파일 구조 템플릿
|
||||
|
||||
```dart
|
||||
// 1. 라이브러리 선언 (필요한 경우)
|
||||
library restaurant_repository;
|
||||
|
||||
// 2. Dart 임포트
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
// 3. 패키지 임포트 (알파벳 순)
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
// 4. 프로젝트 임포트 (알파벳 순)
|
||||
import 'package:lunchpick/core/constants/api_keys.dart';
|
||||
import 'package:lunchpick/domain/entities/restaurant.dart';
|
||||
|
||||
// 5. Part 파일 (있는 경우)
|
||||
part 'restaurant_repository.g.dart';
|
||||
|
||||
// 6. 전역 상수
|
||||
const int kDefaultTimeout = 30;
|
||||
|
||||
// 7. 클래스 정의
|
||||
class RestaurantRepository {
|
||||
// 구현
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 클래스 내부 구조
|
||||
|
||||
```dart
|
||||
class NaverUrlProcessor {
|
||||
// 1. 정적 상수
|
||||
static const int maxRetryCount = 3;
|
||||
|
||||
// 2. 정적 변수
|
||||
static String? _cachedUrl;
|
||||
|
||||
// 3. 인스턴스 변수 (private 먼저)
|
||||
final NaverMapParser _mapParser;
|
||||
final NaverLocalApiClient _apiClient;
|
||||
late final Logger _logger;
|
||||
|
||||
// 4. 생성자
|
||||
NaverUrlProcessor({
|
||||
required NaverMapParser mapParser,
|
||||
required NaverLocalApiClient apiClient,
|
||||
}) : _mapParser = mapParser,
|
||||
_apiClient = apiClient {
|
||||
_logger = Logger('NaverUrlProcessor');
|
||||
}
|
||||
|
||||
// 5. 팩토리 생성자
|
||||
factory NaverUrlProcessor.create() {
|
||||
return NaverUrlProcessor(
|
||||
mapParser: NaverMapParser(),
|
||||
apiClient: NaverLocalApiClient(),
|
||||
);
|
||||
}
|
||||
|
||||
// 6. getter/setter
|
||||
bool get isReady => _mapParser != null && _apiClient != null;
|
||||
|
||||
// 7. public 메서드
|
||||
Future<Restaurant> processUrl(String url) async {
|
||||
// 구현
|
||||
}
|
||||
|
||||
// 8. private 메서드
|
||||
Future<String> _resolveUrl(String url) async {
|
||||
// 구현
|
||||
}
|
||||
|
||||
// 9. 정적 메서드
|
||||
static bool isValidUrl(String url) {
|
||||
// 구현
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 5. 코딩 스타일
|
||||
|
||||
### 5.1 들여쓰기 및 정렬
|
||||
|
||||
```dart
|
||||
// 2 스페이스 들여쓰기 사용
|
||||
class Restaurant {
|
||||
final String id;
|
||||
final String name;
|
||||
|
||||
Restaurant({
|
||||
required this.id,
|
||||
required this.name,
|
||||
});
|
||||
}
|
||||
|
||||
// 긴 파라미터는 세로 정렬
|
||||
final restaurant = Restaurant(
|
||||
id: generateId(),
|
||||
name: '김밥천국',
|
||||
category: '분식',
|
||||
roadAddress: '서울특별시 강남구 테헤란로 123',
|
||||
latitude: 37.123456,
|
||||
longitude: 127.123456,
|
||||
);
|
||||
```
|
||||
|
||||
### 5.2 줄 길이
|
||||
|
||||
- 최대 80자 권장 (하드 리밋: 120자)
|
||||
- 긴 문자열은 여러 줄로 분할
|
||||
|
||||
```dart
|
||||
// ❌ 나쁜 예
|
||||
String errorMessage = '네이버 지도 URL 파싱 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.';
|
||||
|
||||
// ✅ 좋은 예
|
||||
String errorMessage = '네이버 지도 URL 파싱 중 오류가 발생했습니다. '
|
||||
'잠시 후 다시 시도해주세요.';
|
||||
```
|
||||
|
||||
### 5.3 중괄호 사용
|
||||
|
||||
```dart
|
||||
// 한 줄이어도 중괄호 사용
|
||||
if (isValid) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 여러 조건은 읽기 쉽게 정렬
|
||||
if (restaurant.name.isNotEmpty &&
|
||||
restaurant.category.isNotEmpty &&
|
||||
restaurant.latitude != 0) {
|
||||
// 처리
|
||||
}
|
||||
```
|
||||
|
||||
### 5.4 빈 줄 사용
|
||||
|
||||
```dart
|
||||
class RestaurantService {
|
||||
final RestaurantRepository _repository;
|
||||
final CacheManager _cache;
|
||||
|
||||
RestaurantService(this._repository, this._cache);
|
||||
|
||||
// 메서드 사이에 빈 줄
|
||||
Future<List<Restaurant>> getNearbyRestaurants() async {
|
||||
// 구현
|
||||
}
|
||||
|
||||
Future<void> addRestaurant(Restaurant restaurant) async {
|
||||
// 구현
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 6. 주석 작성 규칙
|
||||
|
||||
### 6.1 문서 주석
|
||||
|
||||
```dart
|
||||
/// 네이버 단축 URL을 처리하여 식당 정보를 추출합니다.
|
||||
///
|
||||
/// [url]은 네이버 지도 또는 naver.me 단축 URL이어야 합니다.
|
||||
///
|
||||
/// 처리 과정:
|
||||
/// 1. URL 유효성 검증
|
||||
/// 2. 단축 URL 리다이렉션
|
||||
/// 3. HTML 스크래핑
|
||||
/// 4. 네이버 로컬 API 검색
|
||||
/// 5. 정보 병합
|
||||
///
|
||||
/// 실패 시 [NaverUrlException]을 던집니다.
|
||||
Future<Restaurant> processNaverUrl(String url) async {
|
||||
// 구현
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 인라인 주석
|
||||
|
||||
```dart
|
||||
// 단축 URL인 경우 리다이렉션 처리
|
||||
if (url.contains('naver.me')) {
|
||||
url = await _resolveShortUrl(url);
|
||||
}
|
||||
|
||||
// TODO: 캐싱 로직 추가 필요
|
||||
// FIXME: 타임아웃 처리 개선 필요
|
||||
// HACK: CORS 우회를 위한 임시 방법
|
||||
```
|
||||
|
||||
### 6.3 주석 작성 원칙
|
||||
|
||||
- **Why, not What**: 코드가 무엇을 하는지가 아닌 왜 그렇게 하는지 설명
|
||||
- **한국어 사용**: 모든 주석은 한국어로 작성
|
||||
- **최신 상태 유지**: 코드 변경 시 주석도 함께 업데이트
|
||||
|
||||
## 7. 에러 처리
|
||||
|
||||
### 7.1 예외 정의
|
||||
|
||||
```dart
|
||||
// 계층별 예외 클래스 정의
|
||||
abstract class AppException implements Exception {
|
||||
final String message;
|
||||
final String? code;
|
||||
final dynamic originalError;
|
||||
|
||||
AppException(this.message, {this.code, this.originalError});
|
||||
}
|
||||
|
||||
class NetworkException extends AppException {
|
||||
NetworkException(String message, {String? code})
|
||||
: super(message, code: code);
|
||||
}
|
||||
|
||||
class ParseException extends AppException {
|
||||
ParseException(String message, {dynamic originalError})
|
||||
: super(message, originalError: originalError);
|
||||
}
|
||||
```
|
||||
|
||||
### 7.2 에러 처리 패턴
|
||||
|
||||
```dart
|
||||
Future<Restaurant?> fetchRestaurant(String id) async {
|
||||
try {
|
||||
final response = await _api.getRestaurant(id);
|
||||
return Restaurant.fromJson(response);
|
||||
} on DioException catch (e) {
|
||||
// 네트워크 에러 처리
|
||||
_logger.error('네트워크 에러 발생', error: e);
|
||||
throw NetworkException('식당 정보를 가져올 수 없습니다');
|
||||
} on FormatException catch (e) {
|
||||
// 파싱 에러 처리
|
||||
_logger.error('데이터 파싱 실패', error: e);
|
||||
throw ParseException('잘못된 데이터 형식입니다');
|
||||
} catch (e) {
|
||||
// 예상치 못한 에러
|
||||
_logger.error('알 수 없는 에러', error: e);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 8. 비동기 프로그래밍
|
||||
|
||||
### 8.1 async/await 사용
|
||||
|
||||
```dart
|
||||
// ✅ 좋은 예
|
||||
Future<void> processRestaurants() async {
|
||||
final restaurants = await fetchRestaurants();
|
||||
for (final restaurant in restaurants) {
|
||||
await processRestaurant(restaurant);
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ 나쁜 예 (불필요한 then 체이닝)
|
||||
void processRestaurants() {
|
||||
fetchRestaurants().then((restaurants) {
|
||||
restaurants.forEach((restaurant) {
|
||||
processRestaurant(restaurant);
|
||||
});
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 8.2 동시 실행
|
||||
|
||||
```dart
|
||||
// 병렬 실행이 가능한 경우
|
||||
Future<void> initializeApp() async {
|
||||
final results = await Future.wait([
|
||||
_loadUserSettings(),
|
||||
_fetchRestaurants(),
|
||||
_checkLocationPermission(),
|
||||
]);
|
||||
}
|
||||
|
||||
// 순차 실행이 필요한 경우
|
||||
Future<void> processInOrder() async {
|
||||
final user = await _loadUser();
|
||||
final settings = await _loadUserSettings(user.id);
|
||||
final restaurants = await _fetchUserRestaurants(user.id);
|
||||
}
|
||||
```
|
||||
|
||||
## 9. 상태 관리 (Riverpod)
|
||||
|
||||
### 9.1 Provider 네이밍
|
||||
|
||||
```dart
|
||||
// Provider 이름은 제공하는 값 + Provider
|
||||
final restaurantListProvider = FutureProvider<List<Restaurant>>((ref) {
|
||||
return ref.watch(restaurantRepositoryProvider).getAllRestaurants();
|
||||
});
|
||||
|
||||
// StateNotifierProvider는 notifier 추가
|
||||
final restaurantFilterNotifierProvider =
|
||||
StateNotifierProvider<RestaurantFilterNotifier, RestaurantFilter>((ref) {
|
||||
return RestaurantFilterNotifier();
|
||||
});
|
||||
```
|
||||
|
||||
### 9.2 Provider 구조화
|
||||
|
||||
```dart
|
||||
// providers/restaurant_provider.dart
|
||||
final restaurantRepositoryProvider = Provider<RestaurantRepository>((ref) {
|
||||
return RestaurantRepositoryImpl();
|
||||
});
|
||||
|
||||
final nearbyRestaurantsProvider = FutureProvider<List<Restaurant>>((ref) {
|
||||
final location = ref.watch(locationProvider);
|
||||
final repository = ref.watch(restaurantRepositoryProvider);
|
||||
|
||||
return repository.getNearbyRestaurants(
|
||||
latitude: location.latitude,
|
||||
longitude: location.longitude,
|
||||
radius: 500,
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
## 10. 테스트 코드 작성
|
||||
|
||||
### 10.1 테스트 파일 구조
|
||||
|
||||
```dart
|
||||
// test/data/datasources/remote/naver_url_processor_test.dart
|
||||
void main() {
|
||||
// 테스트 대상 선언
|
||||
late NaverUrlProcessor processor;
|
||||
late MockNaverMapParser mockMapParser;
|
||||
late MockNaverLocalApiClient mockApiClient;
|
||||
|
||||
// 셋업
|
||||
setUp(() {
|
||||
mockMapParser = MockNaverMapParser();
|
||||
mockApiClient = MockNaverLocalApiClient();
|
||||
processor = NaverUrlProcessor(
|
||||
mapParser: mockMapParser,
|
||||
apiClient: mockApiClient,
|
||||
);
|
||||
});
|
||||
|
||||
// 테스트 그룹화
|
||||
group('processNaverUrl', () {
|
||||
test('유효한 단축 URL을 처리해야 함', () async {
|
||||
// Given
|
||||
const url = 'https://naver.me/abc123';
|
||||
when(() => mockMapParser.parseUrl(any())).thenAnswer(
|
||||
(_) async => testRestaurant,
|
||||
);
|
||||
|
||||
// When
|
||||
final result = await processor.processNaverUrl(url);
|
||||
|
||||
// Then
|
||||
expect(result.name, equals('테스트 식당'));
|
||||
verify(() => mockMapParser.parseUrl(url)).called(1);
|
||||
});
|
||||
|
||||
test('유효하지 않은 URL은 예외를 던져야 함', () async {
|
||||
// Given
|
||||
const invalidUrl = 'https://google.com';
|
||||
|
||||
// When & Then
|
||||
expect(
|
||||
() => processor.processNaverUrl(invalidUrl),
|
||||
throwsA(isA<NaverUrlException>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 10.2 테스트 네이밍
|
||||
|
||||
```dart
|
||||
// 테스트 이름은 한국어로 명확하게
|
||||
test('레스토랑 이름이 비어있으면 예외를 던져야 함', () {});
|
||||
test('중복된 레스토랑은 추가되지 않아야 함', () {});
|
||||
test('거리 계산이 정확해야 함', () {});
|
||||
```
|
||||
|
||||
## 11. 성능 최적화 규칙
|
||||
|
||||
### 11.1 위젯 최적화
|
||||
|
||||
```dart
|
||||
// const 생성자 사용
|
||||
class RestaurantCard extends StatelessWidget {
|
||||
const RestaurantCard({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Card(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: Text('Restaurant'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 무거운 빌드 메서드 분리
|
||||
class RestaurantList extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return ListView.builder(
|
||||
itemBuilder: (context, index) => RestaurantItem(index: index),
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 11.2 메모리 관리
|
||||
|
||||
```dart
|
||||
class RestaurantService {
|
||||
// 캐시 크기 제한
|
||||
final _cache = LruMap<String, Restaurant>(maximumSize: 100);
|
||||
|
||||
// 리소스 정리
|
||||
void dispose() {
|
||||
_cache.clear();
|
||||
_subscription?.cancel();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 12. Git 커밋 규칙
|
||||
|
||||
### 12.1 커밋 메시지 형식
|
||||
|
||||
```
|
||||
타입(범위): 제목
|
||||
|
||||
본문 (선택사항)
|
||||
|
||||
이슈: #123
|
||||
```
|
||||
|
||||
### 12.2 커밋 타입
|
||||
|
||||
- `feat`: 새로운 기능
|
||||
- `fix`: 버그 수정
|
||||
- `refactor`: 리팩토링
|
||||
- `style`: 코드 스타일 변경
|
||||
- `test`: 테스트 추가/수정
|
||||
- `docs`: 문서 수정
|
||||
- `chore`: 빌드, 설정 변경
|
||||
|
||||
### 12.3 커밋 예시
|
||||
|
||||
```
|
||||
feat(restaurant): 네이버 단축 URL 처리 기능 추가
|
||||
|
||||
- NaverUrlProcessor 클래스 구현
|
||||
- 네이버 로컬 API 클라이언트 추가
|
||||
- URL 매칭 알고리즘 구현
|
||||
|
||||
이슈: #42
|
||||
```
|
||||
|
||||
## 13. 코드 리뷰 체크리스트
|
||||
|
||||
### 13.1 기능 확인
|
||||
- [ ] 요구사항을 모두 충족하는가?
|
||||
- [ ] 엣지 케이스를 처리하는가?
|
||||
- [ ] 에러 처리가 적절한가?
|
||||
|
||||
### 13.2 코드 품질
|
||||
- [ ] 네이밍 컨벤션을 따르는가?
|
||||
- [ ] 주석이 적절히 작성되었는가?
|
||||
- [ ] 중복 코드가 없는가?
|
||||
- [ ] 함수/클래스가 단일 책임을 가지는가?
|
||||
|
||||
### 13.3 테스트
|
||||
- [ ] 테스트가 작성되었는가?
|
||||
- [ ] 테스트 커버리지가 충분한가?
|
||||
- [ ] 테스트가 의미있는가?
|
||||
|
||||
### 13.4 성능
|
||||
- [ ] 불필요한 리빌드가 없는가?
|
||||
- [ ] 메모리 누수 가능성이 없는가?
|
||||
- [ ] 비동기 처리가 적절한가?
|
||||
|
||||
## 14. 프로젝트별 특별 규칙
|
||||
|
||||
### 14.1 네이버 관련 코드
|
||||
- 모든 네이버 관련 클래스는 `Naver` 접두사 사용
|
||||
- API 응답은 항상 null 체크
|
||||
- 스크래핑 선택자는 상수로 정의
|
||||
|
||||
### 14.2 Restaurant 엔티티
|
||||
- 불변 객체로 유지
|
||||
- copyWith 메서드 제공
|
||||
- 좌표는 항상 유효성 검증
|
||||
|
||||
### 14.3 로컬 저장소
|
||||
- Hive 박스 이름은 상수로 정의
|
||||
- 마이그레이션 전략 문서화
|
||||
- 트랜잭션 단위로 처리
|
||||
Reference in New Issue
Block a user