μ¬νμ§Β·μΈμΒ·μ¬ν μ€νμΌμ μ λ ₯νλ©΄ μΌμ μ μλμΌλ‘ λ§λ€μ΄ μ£Όκ³ , λνλ‘ μ·¨ν₯μ λ§λ μ₯μλ₯Ό μΆμ²λ°μ λ°λ‘ μΌμ μ λ΄μ μ μλ μ¬ν μΌμ νλλ μΉ μλΉμ€μ λ°±μλμ λλ€.
- μ£Όμ κΈ°λ₯
- κΈ°μ μ€ν
- μμ€ν ꡬ쑰
- API
- λ°μ΄ν°λ² μ΄μ€
- νλ‘μ νΈ κ΅¬μ‘°
- μμνκΈ°
- ν μ€νΈμ νκ°
- CI/CD
- νμ κ·μΉ
| κΈ°λ₯ | μ€λͺ |
|---|---|
| μΌμ μλ μμ± | λ΄μ λ μ₯μλ₯Ό μμΉ κΈ°μ€μΌλ‘ μΌμλ³λ‘ λλλλ€. (k-means) ν루 μμμλ μ΄λ κ±°λ¦¬κ° μ΅μκ° λλ μμλ‘ μ λ ¬νκ³ μ€μ , μ μ¬, μ λ μ λ°°μΉν©λλ€. μ₯μ κ° μ΄λ μκ°μ μΉ΄μΉ΄μ€λͺ¨λΉλ¦¬ν° APIλ‘ κ³μ°ν©λλ€. |
| λνν μ₯μ μΆμ² | Gemini function callingμΌλ‘ μΉ΄μΉ΄μ€λ§΅κ³Ό νκ΅κ΄κ΄κ³΅μ¬ TourAPIλ₯Ό μ°κ²°ν©λλ€. μ€μ κ²μ κ²°κ³Όμ κ·Όκ±°ν΄ μ₯μλ₯Ό μΆμ²νλ©°, μΆμ² μΉ΄λμμ λ°λ‘ μΌμ μ λ΄μ μ μμ΅λλ€. |
| μ·¨ν₯ νκ·Έ κ²μ | λΈλ‘κ·Έ νκΈ°λ₯Ό LLMμΌλ‘ κ°μ±, μ‘°μ©ν¨, λ·°λ§μ§ λ± 11μ’
μ κ³ μ νκ·Έλ‘ λΆλ₯ν©λλ€. λ°°μΉ μμ»€κ° λ―Έλ¦¬ μνν λΆλ₯ κ²°κ³Όλ₯Ό νμ©ν΄ "μ‘°μ©ν μΉ΄ν" κ°μ μ§μλ₯Ό μ²λ¦¬ν©λλ€. |
| μ§λ κ²μ | μΉ΄μΉ΄μ€λ§΅ ν€μλ, μ’ν, μμ κ²μμ μ§μν©λλ€. μ₯μ μμΈ μ 보μ μΈλ€μΌ(og:image)μ μμ§νκ³ , μ₯μ κ° μ΄λ μκ°μ κ³μ°ν©λλ€. |
| μΌμ νΈμ§ | μ₯μ λ΄κΈ°μ μμ , λ μ§ λ° μκ°λ μ΄λ, κ°μ λ μ λ°©λ¬Έ μμ λ³κ²½μ μ§μν©λλ€. λ³κ²½ μ ν΄λΉ μΌμμ λ°©λ¬Έ μμμ μμ μκ°μ λ€μ κ³μ°ν©λλ€. |
| μΈμ¦ | JWT Access Tokenκ³Ό νμ (rotation)λλ Refresh Token(HttpOnly μΏ ν€)μ μ¬μ©ν©λλ€. λμ κ°±μ μμ²μ λμνκΈ° μν΄ μμμ νκΈ°μ μ¬μ¬μ© μ μ μκ°μ μ μ©νμ΅λλ€. |
| λΆλ₯ | μ¬μ© κΈ°μ |
|---|---|
| Language | Python 3.12 |
| Framework | FastAPI, Pydantic v2, Uvicorn |
| Database | PostgreSQL 16, SQLAlchemy 2.0 (async) + asyncpg, Alembic |
| LLM | Google Gemini (google-genai): function calling, structured output |
| μΈλΆ API | μΉ΄μΉ΄μ€λ§΅/μΉ΄μΉ΄μ€λͺ¨λΉλ¦¬ν°, νκ΅κ΄κ΄κ³΅μ¬ TourAPI, λ€μ΄λ² κ²μ(λΈλ‘κ·Έ) |
| λΉλκΈ° μμ | APScheduler (νκ·Έ λΆλ₯ λ°°μΉ) |
| μΈμ¦ | PyJWT, bcrypt |
| ν μ€νΈ λ° νμ§ | pytest, pytest-asyncio, pytest-cov, Ruff, pre-commit, SonarCloud |
| λ°°ν¬ | Docker, GitHub Actions, GitHub Container Registry |
flowchart LR
FE[Frontend] -->|REST /api/v1| API[FastAPI Router]
subgraph Backend
API --> SVC[Service]
SVC --> REPO[Repository]
REPO --> DB[(PostgreSQL)]
SVC --> CONV[ConversationService]
SCHED[APScheduler<br/>νκ·Έ λΆλ₯ λ°°μΉ] --> TAG[TagService]
TAG --> REPO
end
CONV <-->|function calling| GEMINI[Gemini]
CONV -->|search_places| KAKAO[μΉ΄μΉ΄μ€λ§΅]
CONV -->|search_tour_places| TOUR[TourAPI]
SVC --> MOBILITY[μΉ΄μΉ΄μ€λͺ¨λΉλ¦¬ν°]
TAG --> NAVER[λ€μ΄λ² λΈλ‘κ·Έ κ²μ]
TAG -->|νκ·Έ λΆλ₯| GEMINI
- Router / Service / Repository 3κ³μΈ΅μΌλ‘ λλκ³ μμ‘΄μ± μ£Όμ
(
Depends)μΌλ‘ μ°κ²°ν©λλ€.- μλΉμ€λ λͺ¨νΉν λ¨μ ν μ€νΈλ‘,
- λ ν¬μ§ν 리λ μ€μ PostgreSQL ν΅ν© ν μ€νΈλ‘ κ²μ¦ν©λλ€.
- μΉ΄μΉ΄μ€ κ²μ κ²°κ³Όλ
placesν μ΄λΈμ μΊμ±λκ³ , λ°°μΉ μμ»€κ° νκ·Έκ° μκ±°λ 90μΌμ΄ μ§λ μ₯μλ₯Ό κ³¨λΌ νκ·Έλ₯Ό μ±μλλ€.- μ¬μ©μ μμ² κ²½λ‘μμλ LLM λΆλ₯λ₯Ό νΈμΆνμ§ μμ΅λλ€.
λͺ¨λ κ²½λ‘μ μ λμ¬λ /api/v1μ
λλ€. μλ² μ€ν ν Swaggerμμ μμ²Β·μλ΅ μ€ν€λ§λ₯Ό νμΈν μ μμ΅λλ€. μΈμ¦μ΄ νμν APIλ Authorization: Bearer <access_token> ν€λλ₯Ό μ¬μ©ν©λλ€.
| Method | Path | μ€λͺ | μΈμ¦ |
|---|---|---|---|
POST |
/auth/signup |
νμκ°μ | |
POST |
/auth/login |
λ‘κ·ΈμΈ, Access Token λ°κΈ λ° Refresh Token μΏ ν€ μ€μ | |
POST |
/auth/refresh |
Refresh TokenμΌλ‘ ν ν° μ¬λ°κΈ (rotation) | μΏ ν€ |
POST |
/auth/logout |
Refresh Token νκΈ° | μΏ ν€ |
| Method | Path | μ€λͺ | μΈμ¦ |
|---|---|---|---|
POST |
/itineraries/conditions |
μ¬ν 쑰건(μ§μ, λ μ§, λͺ©μ , μ€νμΌ λ±)μΌλ‘ μΌμ μμ± | β |
GET |
/itineraries |
λ΄ μΌμ λͺ©λ‘ | β |
GET |
/itineraries/{itinerary_id} |
μΌμ μμΈ (μΌμΒ·μκ°λλ³ μ₯μ) | β |
DELETE |
/itineraries/{itinerary_id} |
μΌμ μμ | β |
POST |
/itineraries/{itinerary_id}/plans/generate |
λ΄μ μ₯μλ‘ μΌμ μλ λ°°μΉ | β |
POST |
/itineraries/{itinerary_id}/plans/save |
λ°°μΉλ μΌμ μ μ₯ | β |
| Method | Path | μ€λͺ | μΈμ¦ |
|---|---|---|---|
GET |
/itineraries/{itinerary_id}/places/recommend |
μ§μΒ·λͺ©μ Β·μ€νμΌ κΈ°λ° μ₯μ μΆμ² (category νν°) |
|
POST |
/itineraries/{itinerary_id}/places |
μ₯μ λ΄κΈ° | |
DELETE |
/itineraries/{itinerary_id}/places/{itinerary_place_id} |
λ΄μ μ₯μ μμ | |
PATCH |
/itineraries/{itinerary_id}/places/reorder |
κ°μ λ μμμ μμ λ³κ²½ | |
PATCH |
/itineraries/{itinerary_id}/places/{place_id} |
λ€λ₯Έ λ μ§Β·μκ°λλ‘ μ΄λ |
| Method | Path | μ€λͺ | μΈμ¦ |
|---|---|---|---|
GET |
/map/search |
μ₯μ κ²μ (q, xΒ·yΒ·radius, rect, category, page) |
|
GET |
/map/search/by-tags |
μΊμ±λ μ₯μλ₯Ό μ·¨ν₯ νκ·Έλ‘ κ²μ (tags, category, xΒ·yΒ·radius) |
|
GET |
/map/places/{place_id} |
μ₯μ μμΈ | |
GET |
/map/transit |
λ μ§μ κ° μ΄λ μκ° κ³μ° |
| Method | Path | μ€λͺ | μΈμ¦ |
|---|---|---|---|
POST |
/itineraries/{itinerary_id}/chat |
λνν μ₯μ μΆμ². μλ΅μ λ΅λ³ ν μ€νΈμ μ₯μ μΉ΄λ(μ’ν, νκ·Έ ν¬ν¨)λ₯Ό ν¨κ» λ°ν |
erDiagram
users ||--o{ refresh_tokens : has
users ||--o{ itineraries : owns
itineraries ||--o{ itinerary_places : contains
places ||--o{ itinerary_places : "referenced by"
itineraries ||--o{ conversation_messages : has
users {
uuid user_id PK
varchar email UK
varchar password_hash
varchar provider "LOCAL | KAKAO"
}
refresh_tokens {
uuid token_id PK
uuid user_id FK
varchar token_hash "SHA-256"
timestamptz expires_at
timestamptz revoked_at
}
itineraries {
uuid itinerary_id PK
uuid user_id FK
varchar status "DRAFT | GENERATED | SAVED"
varchar region
date start_date
date end_date
jsonb styles
}
places {
varchar place_id PK "μΉ΄μΉ΄μ€ ID λλ tour_ μ λμ¬"
varchar name
varchar category "RESTAURANT | CAFE | ACTIVITY"
float lat
float lng
jsonb tags "GIN jsonb_path_ops"
timestamp tags_synced_at
}
itinerary_places {
uuid itinerary_place_id PK
uuid itinerary_id FK
varchar place_id FK
smallint day "NULLμ΄λ©΄ λ―Έλ°°μ "
varchar time_slot
smallint order_in_day
varchar start_time
}
conversation_messages {
uuid message_id PK
uuid itinerary_id FK
varchar role
text content
}
μ€κ³ ν¬μΈνΈλ λ€μκ³Ό κ°μ΅λλ€.
- λ΄κΈ°μ λ°°μ μ ν ν
μ΄λΈμμ:
itinerary_placesμday / time_slot / order_in_dayλ nullableμ΄κ³ , λ°°μ λ μ¬λ‘―μλ§ κ±Έλ¦¬λ λΆλΆ μ λν¬ μΈλ±μ€(WHERE day IS NOT NULL)λ‘ μ€λ³΅μ λ§μ΅λλ€. - νκ·Έ κ²μ:
places.tags(JSONB)μ GIN(jsonb_path_ops) μΈλ±μ€λ₯Ό λμ΄@>ν¬ν¨ κ²μμ μ²λ¦¬ν©λλ€. - ν ν° λ³΄μ: Refresh Tokenμ μλ¬Έ λμ SHA-256 ν΄μλ§ μ μ₯ν©λλ€.
- λ§μ΄κ·Έλ μ΄μ
: μ€ν€λ§ λ³κ²½μ AlembicμΌλ‘ κ΄λ¦¬νλ©°, 컨ν
μ΄λ μμ μ
alembic upgrade headκ° μλ μ€νλ©λλ€.
app/
βββ api/v1/
β βββ router.py # λλ©μΈλ³ λΌμ°ν° λ±λ‘
β βββ endpoints/ # auth, itineraries, itinerary_place, map, chat
βββ core/
β βββ config.py # νκ²½λ³μ (pydantic-settings)
β βββ security.py # JWT, λΉλ°λ²νΈ ν΄μ±
β βββ scheduler.py # νκ·Έ λΆλ₯ λ°°μΉ (APScheduler)
β βββ *_client.py # μΉ΄μΉ΄μ€, λ€μ΄λ², TourAPI ν΄λΌμ΄μΈνΈ
βββ db/ # async μμ§Β·μΈμ
βββ models/ # SQLAlchemy λͺ¨λΈ
βββ repositories/ # DB μ κ·Ό
βββ schemas/ # μμ²Β·μλ΅ μ€ν€λ§ (Pydantic)
βββ services/ # λΉμ¦λμ€ λ‘μ§
βββ utils/
βββ itinerary_planner.py # μΌμ ν΄λ¬μ€ν°λ§, λμ μ λ ¬, μκ° λ°°μΉ
alembic/ # λ§μ΄κ·Έλ μ΄μ
scripts/
βββ seed.sql # μλ λ°μ΄ν°
βββ eval_tool_calling.py # LLM tool calling νκ° μ€ν¬λ¦½νΈ
tests/
βββ unit/ # μλΉμ€ λ¨μ ν
μ€νΈ (λͺ¨νΉ)
βββ integration/ # μ€μ PostgreSQL λμ ν΅ν© ν
μ€νΈ
cp .env.example .env| λ³μ | μ€λͺ | νμ |
|---|---|---|
DATABASE_URL |
postgresql+asyncpg://user:password@localhost:5432/betrip |
β |
JWT_SECRET_KEY |
JWT μλͺ ν€ | β |
CORS_ORIGINS |
νμ©ν νλ‘ νΈμλ μ£Όμ (κΈ°λ³Έ http://localhost:5173) |
|
COOKIE_SECURE |
λ‘컬 httpλ false, λ°°ν¬(https)λ true |
|
KAKAO_REST_API_KEY |
μΉ΄μΉ΄μ€λ§΅Β·μΉ΄μΉ΄μ€λͺ¨λΉλ¦¬ν° | β |
NAVER_CLIENT_ID, NAVER_CLIENT_SECRET |
λ€μ΄λ² λΈλ‘κ·Έ κ²μ (νκ·Έ λΆλ₯μ©) | β |
GEMINI_API_KEY |
λνν μΆμ², νκ·Έ λΆλ₯ | β |
TOUR_API_KEY |
νκ΅κ΄κ΄κ³΅μ¬ TourAPI | β |
OPENAI_API_KEY |
νμ¬ μ½λμμ μ¬μ©νμ§ μμ§λ§ μ€μ κ²μ¦μ νμ | β |
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
pip install -r requirements-dev.txt
pre-commit install # μ»€λ° μ ruff μλ μ€ν
alembic upgrade head # μ€ν€λ§ μ μ©
psql "postgresql://user:password@localhost:5432/betrip" -f scripts/seed.sql # μλ (μ ν)
uvicorn app.main:app --reload- API: http://localhost:8000
- Swagger: http://localhost:8000/docs
μ΄λ―Έμ§λ main λΈλμΉ νΈμ μ GHCRμ μλμΌλ‘ μ¬λΌκ°λλ€. 컨ν
μ΄λλ μμν λ λ§μ΄κ·Έλ μ΄μ
κ³Ό μλ μ μ© ν μλ²λ₯Ό λμλλ€.
docker pull ghcr.io/hyun7931/betrip-backend:latest
docker run --env-file .env -p 8000:8000 ghcr.io/hyun7931/betrip-backend:latestpytest tests/unit # λ¨μ ν
μ€νΈ (DB λΆνμ)
pytest # ν΅ν© ν
μ€νΈ ν¬ν¨ (PostgreSQL νμ)λνν μΆμ²μ΄ μ§μμ λ§λ λꡬμ μΈμλ₯Ό κ³ λ₯΄λμ§ νκ°μ
μΌλ‘ μΈ‘μ ν©λλ€. λꡬλ₯Ό μ€μ λ‘ μ€ννμ§ μμ DB μμ΄ GEMINI_API_KEYλ§μΌλ‘ μ€νν μ μκ³ , ν둬ννΈλ λꡬ μ€λͺ
μ λ°κΏ λ νκ· νμΈμ©μΌλ‘ μ¬μ©ν©λλ€.
python scripts/eval_tool_calling.py # κΈ°λ³Έ μ§μ 24κ°
python scripts/eval_tool_calling.py \
--cases scripts/eval_tool_calling_cases_hard.json \
--repeat 5 # κ²½κ³ μ¬λ‘ 15κ°, 5ν λ°λ³΅| νκ°μ | κ²°κ³Ό |
|---|---|
| κΈ°λ³Έ μ§μ 24κ° | 24/24 |
| κ²½κ³ μ¬λ‘ 15κ° Γ 5ν (λͺ©λ‘ λ° μ·¨ν₯, μ€νΒ·μμ΄, λꡬ 2κ° νμ, μ§μ μΈ μμ², λ€μ€ ν΄ μ μ ) |
74/75 β λꡬ μ€λͺ
μμ ν μ€ν¨ μΌμ΄μ€ 10/10 |
| μν¬νλ‘ | νΈλ¦¬κ±° | λ΄μ© |
|---|---|---|
ci.yml |
main, developλμ push, PR |
Postgres μλΉμ€ 컨ν
μ΄λ β Ruff lint & format κ²μ¬ β pytest(컀λ²λ¦¬μ§) β SonarCloud νμ§ κ²μ΄νΈ |
docker-build.yml |
main λμ push |
Docker μ΄λ―Έμ§ λΉλ ν GHCR νΈμ |
forkμμ μ¬λΌμ¨ PRμ μν¬λ¦Ώμ΄ μ λ¬λμ§ μμΌλ―λ‘ SonarCloud λ¨κ³λ§ 쑰건λΆλ‘ 건λλλλ€.
μ΄μ μμ± β μ΄μλ³ λΈλμΉ β PR(ν
νλ¦Ώ μ¬μ©) β CI ν΅κ³Ό ν developμ λ¨Έμ§ν©λλ€.
type: subject
- 무μμ μ λ³κ²½νλμ§ (ν μ€ 72μ μ΄λ΄)
Closes #μ΄μλ²νΈ
μ»€λ° νμ κ³Ό μμ± κ·μΉ
| νμ | μ€λͺ | | --- | --- | | `feat` | μλ‘μ΄ κΈ°λ₯ μΆκ° | | `fix` | λ²κ·Έ μμ | | `refactor` | κΈ°λ₯ λ³ν μλ μ½λ ꡬ쑰 κ°μ | | `docs` | λ¬Έμ μμ | | `style` | μ½λ μ€νμΌ μμ | | `test` | ν μ€νΈ μ½λ μΆκ° λ° μμ | | `chore` | μ€μ Β·λΉλΒ·ν¨ν€μ§ λ± μ μ§λ³΄μ | | `hotfix` | μ΄μ νκ²½ κΈ΄κΈ μμ |- μ λͺ©μ 50μ μ΄λ΄, λμ λ§μΉ¨ν κΈμ§
- μ λͺ©κ³Ό λ³Έλ¬Έ μ¬μ΄ ν μ€ κ³΅λ°±
- λͺ λ Ήλ¬Έ νν μ¬μ© (μ: μμ νλ€ X β μμ O)
- νλμ 컀λ°μλ νλμ μμ λ§ ν¬ν¨
- μ»€λ° λ΄μ©μ νκ΅μ΄, μ΄μ λ²νΈλ Footerμ μμ±
μ»€λ° μ pre-commitμ΄ Ruffλ₯Ό μλ μ€νν©λλ€. μλμΌλ‘ μ€ννλ €λ©΄:
ruff check . --fix && ruff format . # μλ μμ
ruff check . && ruff format --check . # κ²μ¬λ§