-
Notifications
You must be signed in to change notification settings - Fork 0
Develop
a. Navbar.jsx (메인 로그인 관리):
- 로그인 상태 관리 및 UI 표시
- 구글 로그인 팝업 처리
- 로그아웃 처리
b. Home.jsx:
- 로그인 상태 확인 및 관리
- 로그인되지 않은 경우 설문 페이지로 리다이렉트
c. SurveyMain.jsx:
- 로그인 상태 확인 (현재 주석 처리됨)
- 로그인되지 않은 경우 설문 시작 제한
d. Recommendation1.jsx (추천 페이지):
- 사용자 정보 확인 (
/api/userinfo엔드포인트 사용) - 로그인된 사용자 이름 표시
- 추천 API 호출 시 세션 쿠키 포함
a. Navbar.jsx:
console.log("Login check response:", data);
console.error("Error checking login status:", error);b. Home.jsx:
console.log('Login check response:', data);
console.log('Login state updated:', data.loggedIn);
console.error('Login check error:', error);c. SurveyMain.jsx:
console.log('Current login state:', isLoggedIn);
console.log('Login check response:', data);
console.error('Login check error:', error);d. Recommendation1.jsx:
console.error("이름 불러오기 실패:", err);
console.error("추천 요청 실패:", err);a. 앱 설정 (app.py):
- Flask 앱 초기화 및 CORS 설정
- 세션 쿠키 설정 (크로스 사이트 요청 지원)
- 구글 OAuth 클라이언트 설정
b. 사용자 인증:
- Flask-Login을 통한 사용자 세션 관리
- 구글 OAuth2.0 인증 처리
- 사용자 정보 검증 및 저장
c. 데이터베이스 관리 (db.py):
- SQLite 데이터베이스 연결 및 초기화
- 사용자 정보 저장 및 조회
d. 사용자 모델 (user.py):
- 사용자 정보 관리 (ID, 이름, 이메일, 프로필 사진)
- 데이터베이스 CRUD 작업
print(f"Loading user with ID: {user_id}")
print(f"Loaded user: {user}")
print("Checking authentication status...")
print("Session data:", dict(session))
print("Current user:", current_user)
print("Session cookie:", request.cookies.get('session'))
print("All cookies:", request.cookies)
print("Request headers:", dict(request.headers))a. SurveyMain.jsx: 설문 시작점
- 환영 배너와 설명문 표시
- "설문 시작" 버튼으로 첫 설문 단계로 이동
- 현재는 로그인 체크 기능이 주석 처리되어 있음
b. 설문 단계별 구현
-
Survey1.jsx: 첫 번째 여행 스타일 선택- 4가지 여행 스타일 중 선택 (인증형, 맛집탐방형, 관광형, 휴식형)
- 선택된 값은
travel_style_1로 저장
-
Survey1-1.jsx: 여행 시 중요 요소 선택- 음식점, 액티비티, 관광지 중 우선순위 선택
- 1순위(15점), 2순위(10점), 3순위(5점)로 점수 부여
-
Survey1-2.jsx: 두 번째 여행 스타일 선택- 4가지 여행 스타일 중 선택 (관광형, 맛집탐방형, 쇼핑형, 휴식형)
- 선택된 값은
travel_style_2로 저장
-
Survey2.jsx: 선호하는 장소 선택- 바다, 자연, 도심, 이색거리, 역사, 휴양지 중 최대 2개 선택
- 1개 선택 시 18점, 2개 선택 시 각각 12점 부여
-
Survey2-1.jsx: 여행 목적 선택- 지식 쌓기, 체험, 힐링, 탐험 중 최대 2개 선택
- 1개 선택 시 12점, 2개 선택 시 각각 8점 부여
-
Survey2-2.jsx: 필수 방문 장소 선택- 스테디셀러, 트렌디, 홍대병 스팟 중 선택
- 필터 태그로 사용되며 점수는 부여되지 않음
a. 디자인 요소:
- 라디오 버튼 선택 방식
- 점선으로 된 진행 상태 표시바
- 배경 이미지가 있는 배너
- 이전/다음 네비게이션 버튼
- 일관된 디자인을 위한 스타일드 컴포넌트
b. 데이터 처리:
- localStorage에 응답 저장
- 백엔드로 POST 요청 전송
- 오류 발생 시 적절한 메시지 표시
a. 로그인 상태:
console.log('Current login state:', isLoggedIn);b. 설문 데이터:
console.log("Sending survey data:", { travel_style_1: selectedStyle });
console.log("Survey response:", data);a. 설문 제출 처리:
- POST 요청으로 설문 데이터 수신
- 응답에 따른 태그 점수 계산
- 사용자 프로필을 JSON 파일로 저장
- 성공/오류 응답 반환
b. 점수 계산 방식:
- 첫 번째 여행 스타일: 30점 (균등 분배)
- 중요 요소: 1순위 15점, 2순위 10점, 3순위 5점
- 두 번째 여행 스타일: 30점 (균등 분배)
- 선호 장소: 1개 선택 시 18점, 2개 선택 시 각각 12점
- 여행 목적: 1개 선택 시 12점, 2개 선택 시 각각 8점
- 필수 방문 장소: 필터 태그로만 사용 (점수 없음)
a. 여행 스타일:
style_map_1 = {
"인증형": ["사진명소", "이색"],
"맛집탐방형": ["맛집"],
"관광형": ["문화", "역사", "도심"],
"휴식형": ["힐링", "자연"]
}b. 중요 요소:
priority_map = {
"음식점": ["맛집"],
"액티비티": ["액티비티", "가족"],
"관광지": ["문화", "역사", "도심", "사진명소", "이색"]
}a. 설문 데이터 수신:
print("Received survey data:", data)
print("▶▶▶ Writing survey profile to:", user_file)b. 점수 계산:
print("Computing scores for:", survey_answers)
print(f"Style 1 scores for {style_1}:", {tag: weights[tag] for tag in style_map_1.get(style_1, [])})
print("Final weights:", dict(weights))a. SurveyLoading.jsx:
- 설문 처리 중 표시되는 로딩 컴포넌트
- 추천 페이지에서 설문 결과 처리 중에 사용
b. Home.jsx와 Navbar.jsx:
- 설문 이력 확인 로직 포함
- 설문 미완료 시 설문 페이지로 리다이렉트
- 디버깅 포인트:
console.log('User is logged in, checking survey history...'); console.log('Survey history response:', surveyData);
c. user_profile.json:
- 최종 설문 결과와 계산된 점수 저장
- 설문 데이터와 태그 가중치 포함
a. 데이터 로드
-
사용자 프로필(JSON)에서 태그별 점수(
user_tag_scores) 로드 -
tagged_contents.json에서 콘텐츠별 태그 카운트(city_tag_data) 로드 b. 점수 계산 -
각 도시(city)마다 태그별 카운트 × 사용자 점수를 곱해 총합 계산
-
계산된 점수를 기준으로 상위 N개 도시 추출 c. 결과 반환
-
/recommend/cities엔드포인트로 JSON 형태의 추천 도시 리스트 반환
# city_recommend.py
from flask import Blueprint, jsonify, session
import json
city_recommend_bp = Blueprint('city_recommend', __name__, url_prefix='/recommend')
@city_recommend_bp.route('/cities', methods=['GET'])
def recommend_cities_route():
# 1) 사용자 프로필 로드
user_file = f"profiles/{session['user_id']}.json"
with open(user_file, 'r', encoding='utf-8') as f:
user_tag_scores = json.load(f)
# 2) 콘텐츠별 태그 카운트 로드
with open("tagged_contents.json", 'r', encoding='utf-8') as f:
all_contents = json.load(f)
# city_tag_data: { "서울": Counter({...}), "부산": Counter({...}), ... }
city_tag_data = build_city_tag_data(all_contents)
# 3) 점수 계산
scores = {}
for city, tag_counter in city_tag_data.items():
scores[city] = sum(tag_counter.get(tag, 0) * user_tag_scores.get(tag, 0)
for tag in user_tag_scores if tag != "필터")
# 4) 상위 3개 도시 선택
top_cities = sorted(scores.items(), key=lambda x: -x[1])[:3]
return jsonify([{"city": city, "score": score} for city, score in top_cities])from collections import defaultdict, Counter
def build_city_tag_data(contents):
"""
전체 콘텐츠 목록에서 도시별 태그 카운트를 생성.
:param contents: List[dict] (tour API 응답 형태)
:return: Dict[str, Counter] (도시명: Counter({태그: 개수, ...}), ...)
"""
city_tag_data = defaultdict(Counter)
for item in contents:
city = extract_city(item.get("addr1", ""))
tags = item.get("tags", [])
if city and tags:
city_tag_data[city].update(tags)
return city_tag_data
def extract_city(addr: str) -> str:
"""
addr1 문자열에서 “서울”, “부산” 등 도시명 추출.
"""
# 예: "서울특별시 강남구 역삼동" → "서울"
return addr.split()[0] if addr else ""# 사용자 프로필 로드
print(f"[city_recommend] Loading user profile from: {user_file}", user_tag_scores)
# 콘텐츠·도시별 태그 데이터
print(f"[city_recommend] Built city_tag_data for {len(city_tag_data)} cities")
# 점수 계산
print(f"[city_recommend] Calculated scores:", scores)
# 응답 직전
print("[city_recommend] Top cities to return:", top_cities)a. 엔드포인트
-
GET /recommend/contents?city=<도시명> -
쿼리 파라미터
city로 요청된 도시의 콘텐츠만 필터링 b. 데이터 로드 -
사용자 프로필(JSON)에서 태그별 점수(
user_tag_scores) 로드 -
tagged_contents.json에서 전체 콘텐츠 목록(all_contents) 로드 c. 필터링 & 점수 계산 -
요청된
city와 일치하는 콘텐츠만 선별 -
각 콘텐츠의 태그 리스트를 순회하며, 사용자 점수와 곱해 합산 d. 상위 N개 선택
-
기본값: 상위 5개 콘텐츠 e. 결과 반환
-
JSON 배열로
[{"id","title","score","tags","firstimage","addr1"}, …]형태
# content_recommend.py
from flask import Blueprint, request, jsonify, session
import json
content_recommend_bp = Blueprint('content_recommend', __name__, url_prefix='/recommend')
@content_recommend_bp.route('/contents', methods=['GET'])
def recommend_contents_route():
# 1) city 파라미터
city = request.args.get('city')
if not city:
return jsonify({"error": "city parameter is required"}), 400
# 2) 사용자 프로필 로드
user_file = f"profiles/{session['user_id']}.json"
with open(user_file, 'r', encoding='utf-8') as f:
user_tag_scores = json.load(f)
# 3) 전체 콘텐츠 로드
with open("tagged_contents.json", 'r', encoding='utf-8') as f:
all_contents = json.load(f)
# 4) 필터링 및 점수 계산
scored = []
for item in all_contents:
if extract_city(item.get("addr1", "")) != city:
continue
tags = item.get("tags", [])
score = sum(user_tag_scores.get(tag, 0) for tag in tags if tag != "필터")
scored.append((item, score))
# 5) 상위 5개 콘텐츠 선택
top_n = 5
top_contents = sorted(scored, key=lambda x: -x[1])[:top_n]
# 6) JSON 응답 포맷팅
result = []
for item, score in top_contents:
result.append({
"id": item.get("contentid"),
"title": item.get("title"),
"score": score,
"tags": item.get("tags", []),
"firstimage": item.get("firstimage"),
"addr": item.get("addr1")
})
return jsonify(result)from collections import defaultdict, Counter
def extract_city(addr: str) -> str:
"""
addr1 예: "서울특별시 강남구 역삼동" → "서울"
"""
return addr.split()[0] if addr else ""print(f"[content_recommend] Requested city: {city}")
print(f"[content_recommend] Loaded profile: {user_file} → {user_tag_scores}")
print(f"[content_recommend] Total contents: {len(all_contents)}")
print(f"[content_recommend] Candidates for {city}: {len(scored)}")
print(f"[content_recommend] Top {len(top_contents)} results:", [c[0]["contentid"] for c in top_contents])a. 기본 구조
- React 컴포넌트로 구현된 챗봇 UI
- 사용자 위치 정보 수집 및 관리
- 메시지 상태 관리 (대화 내역, 로딩 상태 등)
b. 주요 기능
- 사용자 위치 정보 수집 (Geolocation API 사용)
- 메시지 전송 및 응답 처리
- 장소 추천 결과 표시
- 추가 추천 기능 (아직 구현 중)
c. API 통신
- axios를 사용하여 백엔드와 통신
- 기본 URL: 'https://localhost/api/chatbot'
- 사용자 ID를 헤더에 포함하여 요청
-
OpenAI API 활용
- GPT-4.1-nano 모델 사용
- 사용자 입력에서 다음 정보 추출:
- category: 추천받고 싶은 장소/음식 카테고리
- radius: 검색 반경 (미터 단위)
- sort_by: 정렬 기준 (distance/rating)
-
Google Maps API 통합
- 장소 검색 및 상세 정보 조회
- 대중교통 경로 정보 조회
- 거리 계산 (Haversine 공식 사용)
-
추천 로직
- 사용자 위치 기반 반경 내 장소 검색
- 카테고리별 장소 검색 (최대 3페이지)
- 거리 기반 필터링
- 평점/거리 기준 정렬
- 상위 5개 장소 추천
-
세션 관리
- 사용자별 최근 요청 정보 저장
- 추천된 장소 ID 관리
- 중복 추천 방지
a. 위치 정보 디버깅
console.error("위치 정보 오류:", error);b. API 통신 디버깅
console.log("API 요청 시작:", {
latitude: location.latitude,
longitude: location.longitude,
user_input: input
});
console.log("API 응답:", response.data);c. 에러 처리 디버깅
console.error("API Error:", error);
console.error("서버 응답:", error.response.data);
console.error("서버 응답 없음:", error.request);
console.error("요청 설정 오류:", error.message);a. 장소 검색 디버깅
print(f"Searching for category: {category}")
print(f"With radius: {radius}")
print(f"Places found: {len(items)}")b. 장소 처리 에러 디버깅
print(f"Error processing place: {str(e)}")- 필요한 패키지 설치
cd frontend
npm install --save-dev @testing-library/react @testing-library/jest-dom @testing-library/user-event jest- 테스트 디렉터리 구조
frontend/
src/
components/
Navbar.jsx
__tests__/
Navbar.test.jsx ← 단위 테스트 파일
- 테스트 코드: Navbar.test.jsx
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import Navbar from '../Navbar';
// Mock 설정
global.fetch = jest.fn();
const mockOpen = jest.fn();
window.open = mockOpen;
const mockAlert = jest.fn();
window.alert = mockAlert;
console.error = jest.fn();
describe('Navbar Component', () => {
// 테스트 환경 설정
const renderNavbar = () => {
return render(
<BrowserRouter>
<Navbar />
</BrowserRouter>
);
};
beforeEach(() => {
jest.clearAllMocks();
fetch.mockReset();
localStorage.clear();
});
// 1. 기본 UI 테스트
test('renders login button when user is not logged in', () => {
// Given: 로그인하지 않은 상태
fetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ loggedIn: false })
});
// When: 컴포넌트 렌더링
renderNavbar();
// Then: 로그인 버튼 표시
const loginButton = screen.getByText('Login');
expect(loginButton).toBeInTheDocument();
});
// 2. 로그인 기능 테스트
test('opens Google login popup when login button is clicked', () => {
// Given: 로그인하지 않은 상태
fetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ loggedIn: false })
});
// When: 로그인 버튼 클릭
renderNavbar();
const loginButton = screen.getByText('Login');
fireEvent.click(loginButton);
// Then: Google 로그인 팝업 열기
expect(mockOpen).toHaveBeenCalledWith(
'https://127.0.0.1:5000/',
'googleLogin',
'width=500,height=600'
);
});
// 3. 로그아웃 기능 테스트
test('handles logout successfully', async () => {
// Given: 로그인 상태와 로그아웃 API 응답
fetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ loggedIn: true })
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ status: 'success' })
});
// When: 로그아웃 버튼 클릭
renderNavbar();
const logoutButton = await screen.findByText('Logout');
fireEvent.click(logoutButton);
// Then: 로그아웃 API 호출 및 알림
await waitFor(() => {
expect(fetch).toHaveBeenCalledWith(
'https://127.0.0.1:5000/logout',
expect.objectContaining({
method: 'GET',
credentials: 'include',
mode: 'cors',
headers: {
'Accept': 'application/json'
}
})
);
expect(mockAlert).toHaveBeenCalledWith('로그아웃이 완료됐습니다.');
});
});
// 4. 에러 처리 테스트
test('handles login check error gracefully', async () => {
// Given: 네트워크 에러
fetch.mockRejectedValueOnce(new Error('Network error'));
// When: 컴포넌트 렌더링
renderNavbar();
// Then: 로그인 버튼 유지
const loginButton = screen.getByText('Login');
expect(loginButton).toBeInTheDocument();
});
});- 실행
cd frontend
npm testnpm test -- --coverage이 명령어를 실행하면 frontend/coverage 디렉토리에 HTML 형식의 커버리지 리포트가 생성됩니다.
-
환경 변수
-
.env.test파일을 생성하여 테스트용 환경 변수 설정 REACT_APP_API_URL=https://127.0.0.1:5000
-
-
Mock 설정
-
jest.config.js에서 필요한 mock 설정 -
setupTests.js에서 전역 mock 설정
-
-
비동기 처리
-
async/await와waitFor사용 -
act로 상태 업데이트 감싸기
-
-
테스트 커버리지
-
package.json의jest설정에서 커버리지 기준 설정 - 최소 80% 이상의 커버리지 유지 권장
-
cd backend
pip install --save-dev pytest pytest-flaskbackend/
app.py
city_recommend.py
content_recommend.py
survey_routes.py
tests/
__init__.py
conftest.py
test_city_recommend.py
test_content_recommend.py
import pytest
from collections import Counter
import backend.survey.city_recommend as city_mod
from backend.survey.city_recommend import extract_city, recommend_cities
# 1) extract_city 함수 테스트
def test_extract_city_with_metropolis_and_province():
# 특별시/광역시 매칭
assert extract_city("서울특별시 종로구") == "서울특별시"
assert extract_city("부산광역시 해운대구") == "부산광역시"
# 도 매칭
assert extract_city("경기도 수원시") == "경기도"
def test_extract_city_no_match_or_empty():
assert extract_city("") is None
assert extract_city(None) is None
assert extract_city("Unknown address") is None
# 2) recommend_cities 함수 테스트
def test_recommend_cities_basic(monkeypatch):
# fake city_tag_data 주입
fake_data = {
"A도시": Counter({"tag1": 2, "tag2": 1}),
"B도시": Counter({"tag1": 1, "tag3": 3}),
"C도시": Counter(), # 태그 없음
}
monkeypatch.setattr(city_mod, 'city_tag_data', fake_data)
# 사용자 태그 점수 정의 (필터 태그는 무시됨)
user_tag_scores = {
"tag1": 1,
"tag2": 2,
"필터": 5
}
# top_n=3 으로 계산
result = recommend_cities(user_tag_scores, top_n=3)
# A도시: (2*1 + 1*2) / (2+1) = 4/3 ≒ 1.3333
# B도시: (1*1 + 0) / (1+3) = 1/4 = 0.25
# C도시: content_count=0 이므로 avg_score=0
expected = [
("A도시", pytest.approx(4/3)),
("B도시", pytest.approx(0.25)),
("C도시", 0),
]
assert result == expectedimport json
import os
import pytest
from backend.survey.content_recommend import (
load_city_contents,
recommend_grouped_contents,
recommend_grouped_detail,
TAGGED_CONTENTS_PATH,
)
# 1) load_city_contents 함수 테스트
def test_load_city_contents_filters_by_city_and_tags(tmp_path, monkeypatch):
# 임시 JSON 파일 생성
sample = [
{"areacode": "1", "tags": ["맛집"], "title": "서울맛집", "firstimage": ""},
{"areacode": "1", "tags": [], "title": "태그없음", "firstimage": ""},
{"areacode": "2", "tags": ["액티비티"], "title": "인천액티", "firstimage": ""},
]
file = tmp_path / "tagged_contents.json"
file.write_text(json.dumps(sample, ensure_ascii=False), encoding="utf-8")
# 모듈 상수 경로를 임시 파일로 덮어쓰기
monkeypatch.setattr(
"backend.survey.content_recommend.TAGGED_CONTENTS_PATH",
str(file),
)
# 서울특별시 코드는 1
results = load_city_contents("서울특별시")
assert isinstance(results, list)
# 태그가 있는 항목만 한 건 남아야 함
assert len(results) == 1
assert results[0]["title"] == "서울맛집"
# 2) recommend_grouped_contents 함수 테스트
def test_recommend_grouped_contents_ranking_and_grouping():
# city_contents 샘플: 다양한 그룹·이미지 유무·점수 요소
city_contents = [
# group1 (맛집) + 이미지
{"tags": ["맛집", "액티비티"], "firstimage": "img1", "firstimage2": ""},
# group1 (맛집) but no 이미지
{"tags": ["맛집"], "firstimage": "", "firstimage2": ""},
# group2 (액티비티) + 이미지
{"tags": ["액티비티"], "firstimage": "", "firstimage2": "img2"},
# group2 (액티비티) no 이미지
{"tags": ["액티비티"], "firstimage": "", "firstimage2": ""},
# group3 (기타) + 이미지
{"tags": ["문화"], "firstimage": "img3", "firstimage2": ""},
# 태그 없음 → 아예 무시
{"tags": [], "firstimage": "img_ignore", "firstimage2": ""},
]
user_tags = ["맛집", "액티비티"]
out = recommend_grouped_contents(city_contents, user_tags, top_n_each=2)
# 반드시 세 개의 그룹이 존재
assert set(out.keys()) == {"group1", "group2", "group3"}
# group1: 맛집 포함된 항목만 → 2개
g1 = out["group1"]
assert all("맛집" in c["tags"] for c in g1)
# 이미지 있는 항목이 먼저 와야 함
assert g1[0]["firstimage"] == "img1"
assert g1[1]["firstimage"] == ""
# group2: 맛집 제외 + 액티비티 포함 → 2개
g2 = out["group2"]
assert all("액티비티" in c["tags"] and "맛집" not in c["tags"] for c in g2)
# 이미지 있는 항목 우선
assert g2[0]["firstimage2"] == "img2"
# group3: 위 두 그룹에 속하지 않는 나머지(태그 있고)
g3 = out["group3"]
assert all(("맛집" not in c["tags"] and "액티비티" not in c["tags"]) for c in g3)
cd backend
pytest --maxfail=1 --disable-warnings -q
# 커버리지 리포트
pytest --cov=backend --cov-report=html-
세션 설정:
client.session_transaction()로user_id를 주입해야 인증이 필요한 라우트 테스트 가능 -
파일 경로: 테스트용 임시 디렉터리(
tmp_path)를 사용해 실제 프로덕션 데이터를 건드리지 않도록 구성 -
환경변수: 필요 시
PROFILE_DIR같은 설정을conftest.py에서 오버라이드