diff --git a/.claude/API.md b/.claude/API.md new file mode 100644 index 0000000..e4c011a --- /dev/null +++ b/.claude/API.md @@ -0,0 +1,218 @@ +# API.md — REST API 계약 및 컨트롤러 + +## 목표 (Goal) + +외부 클라이언트에 노출되는 검색·라우팅·관리자 REST API의 계약(URI, 파라미터, 응답 스키마)을 확정하고, 그에 대응하는 컨트롤러·DTO·예외 처리를 구현한다. 1차 프로토타입은 **인증 없이** 모든 엔드포인트를 노출한다(운영 전 Spring Security 도입 필수). + +## 선행조건 (Prerequisites) + +- [ROUTING.md](ROUTING.md) 완료: `RouteService.route(...)` 호출 가능. +- [SEARCH.md](SEARCH.md) 완료: `SearchService.autocomplete(...)`, `findById(...)` 호출 가능. +- [DATA_MODEL.md](DATA_MODEL.md) 완료: 도메인 엔티티 CRUD 가능. + +## 변경/생성 파일 (Files to Change) + +| 경로 | 종류 | 역할 | +|------|------|------| +| `../src/main/java/com/honggwart/map/search/api/SearchController.java` | 신규 | 자동완성·상세 조회 | +| `../src/main/java/com/honggwart/map/search/api/dto/*` | 신규 | 응답 DTO (SEARCH.md와 공유) | +| `../src/main/java/com/honggwart/map/routing/api/RouteController.java` | 신규 | 길찾기 | +| `../src/main/java/com/honggwart/map/routing/api/dto/*` | 신규 | RouteResponse, StepDto, PolylinePointDto 등 | +| `../src/main/java/com/honggwart/map/admin/api/AdminController.java` | 신규 | Place·Node·Edge CRUD + reload/reindex | +| `../src/main/java/com/honggwart/map/admin/api/dto/*` | 신규 | 관리자 요청·응답 DTO | +| `../src/main/java/com/honggwart/map/common/web/ApiExceptionHandler.java` | 신규 | `@RestControllerAdvice`로 400/404 매핑 | + +## 구현 (Implementation) + +### §1. 검색 API + +#### `GET /api/search/autocomplete` + +``` +GET /api/search/autocomplete?q=T&limit=10 +→ 200 +{ + "suggestions": [ + { "placeId": 101, "displayName": "공학관 501호", "kind": "ROOM", "category": "OFFICE" }, + { "placeId": 1, "displayName": "공학관", "kind": "BUILDING" } + ] +} +``` + +- 파라미터: `q`(required, 1자 이상), `limit`(optional, 기본 10, 최대 50). +- 빈 q는 400. +- 응답 단위는 `place_id`. ES 쿼리 후 중복 제거하고 weight 순 정렬은 [SEARCH.md](SEARCH.md) §5에서 처리. +- 입력 정규화(NFC, 소문자, 공백 단일화)는 ES analyzer가 담당. 컨트롤러는 trim만. + +#### `GET /api/places/{placeId}` + +``` +GET /api/places/101 +→ 200 +{ + "placeId": 101, + "kind": "ROOM", + "displayName": "공학관 501호", + "category": "OFFICE", + "parentPlace": { "placeId": 1, "displayName": "공학관" }, + "entrances": [ + { "label": "정문", "nodeId": 551, "isPrimary": true }, + { "label": "후문", "nodeId": 552, "isPrimary": false } + ] +} +``` + +- 미존재 placeId는 404. +- 데이터 원천은 PostgreSQL (`SearchService.findById`). + +### §2. 라우팅 API + +#### `GET /api/route` + +``` +GET /api/route?fromPlaceId=101&toPlaceId=250&mode=INDOOR_PREFERRED +→ 200 +{ + "summary": { + "distanceMeters": 320.5, + "estimatedSeconds": 280, + "floorChanges": 1, + "mode": "INDOOR_PREFERRED" + }, + "from": { "placeId": 101, "entranceLabel": "정문", "nodeId": 551 }, + "to": { "placeId": 250, "entranceLabel": "후문", "nodeId": 902 }, + "polyline": [ + { "lat": 37.0001, "lng": 127.0001, "floor": 1, "indoor": false } + ], + "steps": [ + { "type": "DEPART", "instruction": "공학관 정문에서 출발합니다", "polylineRange": [0, 0] }, + { "type": "TURN_LEFT", "instruction": "좌회전 후 직진하세요", "polylineRange": [0, 12] }, + { "type": "ENTER_BUILDING", "instruction": "공학관 안으로 들어가세요", "polylineRange": [12, 13] }, + { "type": "FLOOR_CHANGE", "instruction": "엘리베이터로 5층까지 올라가세요", + "meta": { "fromFloor": 1, "toFloor": 5, "transport": "ELEVATOR" }, + "polylineRange": [13, 14] }, + { "type": "ARRIVE", "instruction": "공학관 501호에 도착했습니다", "polylineRange": [14, 14] } + ] +} +``` + +**파라미터** + +- `fromPlaceId` (long, required) +- `toPlaceId` (long, required) +- `mode` (enum, optional, 기본 `SHORTEST`) — `SHORTEST` | `INDOOR_PREFERRED` | `STAIR_AVOIDANCE` + +**필드 의미** + +- `polyline`: 지도 렌더링용 전체 노드열. 곡선이면 모든 점이 포함됨. +- `steps[*].polylineRange = [startIndex, endIndex]`: 해당 step이 덮는 polyline 인덱스 구간. 곡선은 단일 step의 range 안에 흡수됨. +- `from.entranceLabel` / `to.entranceLabel`: 다중 입구 중 실제 사용된 입구의 label (RouteService가 [ROUTING.md](ROUTING.md) §4 다중 출발/도착 Dijkstra로 자동 선택). + +**StepType enum** + +`DEPART | TURN_LEFT | TURN_RIGHT | ENTER_BUILDING | EXIT_BUILDING | FLOOR_CHANGE | ARRIVE` + +**FLOOR_CHANGE.meta** + +```json +{ "fromFloor": 1, "toFloor": 5, "transport": "ELEVATOR" } +``` + +`transport`는 `STAIR` | `ELEVATOR` (해당 구간 Edge.edge_type에서 도출). + +**에러** + +- 미존재 placeId, 경로 없음(연결 안 됨) → 404 + 에러 메시지. +- 잘못된 mode → 400. + +### §3. 관리자 API (1차: 인증 없음) + +`/api/admin/**` 하위. **인증 미적용** — 1차 프로토타입 한정. 운영 전 Spring Security 도입 필수. + +#### Place CRUD + +``` +POST /api/admin/places # Place 생성 +PUT /api/admin/places/{id} +DELETE /api/admin/places/{id} +POST /api/admin/places/{id}/aliases +DELETE /api/admin/places/{id}/aliases/{aliasId} +POST /api/admin/places/{id}/entrances # PlaceEntrance 등록 +DELETE /api/admin/places/{id}/entrances/{entranceId} +``` + +#### Node/Edge CRUD + +``` +POST /api/admin/nodes +PUT /api/admin/nodes/{id} +DELETE /api/admin/nodes/{id} +POST /api/admin/edges +PUT /api/admin/edges/{id} +DELETE /api/admin/edges/{id} +``` + +#### 운영 작업 + +``` +POST /api/admin/graph/reload # GraphRegistry 수동 재빌드 +POST /api/admin/index/reindex # ES 전체 재색인 +``` + +#### 동기화 트리거 + +- Place·PlaceAlias·PlaceEntrance 변경 트랜잭션 커밋 후 → `PlacePersistedEvent` 발행 → [SEARCH.md](SEARCH.md) `PlaceIndexer`가 수신해 ES 색인 갱신. +- Node·Edge 변경 트랜잭션 커밋 후 → `GraphChangedEvent` 발행 → [ROUTING.md](ROUTING.md) `GraphRegistry.reload()` 호출. +- 이벤트 발행 책임은 `AdminController`(또는 그가 호출하는 서비스 계층)에 있다. + +**경고**: 이 영역의 모든 엔드포인트는 1차 프로토타입 한정 인증 없음. 실제 사용자에게 공개 가능한 환경에서는 반드시 인증 미들웨어를 거쳐야 한다. + +### §4. 공통 처리 + +- DTO에는 Bean Validation(`@NotNull`, `@Positive` 등)을 적용. +- `@RestControllerAdvice`로 `MethodArgumentNotValidException` → 400, `EntityNotFoundException` 등 → 404로 매핑. +- 응답 일관성: 모든 4xx/5xx는 `{ "error": { "code": "...", "message": "..." } }` 형태. + +## 검증 (Verification) + +### MockMvc 테스트 (단위·슬라이스) +- `SearchControllerTest`: `q` 누락 → 400, 정상 응답 JSON 구조 검증, `limit` 상한 초과 → 400. +- `RouteControllerTest`: 미존재 placeId → 404, mode 누락 시 기본값 `SHORTEST` 적용. +- `AdminControllerTest`: Place 생성 후 200/201과 Location 헤더, `entrances` POST 시 PlaceEntrance가 생성되는지. + +### 통합 테스트 (Testcontainers) +- `RouteIntegrationTest`: PG+ES 컨테이너 기동 → 시드 → `/api/route?...&mode=INDOOR_PREFERRED` 호출 → polyline·steps 검증. (라우팅 도메인 로직 검증은 [ROUTING.md](ROUTING.md), 본 영역은 HTTP 계약 검증.) +- `SearchIntegrationTest`: `/api/search/autocomplete?q=T` 호출 → 응답 형식·중복 제거 검증. (검색 도메인 로직 검증은 [SEARCH.md](SEARCH.md).) +- `AdminFlowTest`: + - Place 생성 → `/api/places/{id}`로 조회 가능. + - PlaceEntrance 등록 → 곧바로 `/api/route` 호출 시 새 입구가 사용 가능. + - Node/Edge 생성 → `/api/admin/graph/reload` 호출 후 그 노드를 경유하는 경로가 라우팅 결과에 등장. + - `/api/admin/index/reindex` 호출 후 자동완성이 정상 동작. + +### 수동 검증 (curl 시나리오) + +```bash +docker-compose up -d +./gradlew bootRun + +# 자동완성 +curl "http://localhost:8080/api/search/autocomplete?q=공학" + +# 상세 조회 +curl "http://localhost:8080/api/places/1" + +# 길찾기 (3가지 모드를 같은 from/to로) +curl "http://localhost:8080/api/route?fromPlaceId=1&toPlaceId=2&mode=SHORTEST" +curl "http://localhost:8080/api/route?fromPlaceId=1&toPlaceId=2&mode=INDOOR_PREFERRED" +curl "http://localhost:8080/api/route?fromPlaceId=1&toPlaceId=2&mode=STAIR_AVOIDANCE" + +# 모드별로 summary.distanceMeters와 summary.floorChanges가 달라지는지 확인. +``` + +## 참조 (References) + +- 라우팅 도메인: [ROUTING.md](ROUTING.md) +- 검색 도메인: [SEARCH.md](SEARCH.md) +- 엔티티: [DATA_MODEL.md](DATA_MODEL.md) +- 인프라(애플리케이션 기동): [INFRASTRUCTURE.md](INFRASTRUCTURE.md) +- 설계 배경: [../DRAFT.md](../DRAFT.md) §4.7, §5.6, §5.7 diff --git a/.claude/ARCHITECTURE.md b/.claude/ARCHITECTURE.md new file mode 100644 index 0000000..53cef0e --- /dev/null +++ b/.claude/ARCHITECTURE.md @@ -0,0 +1,127 @@ +# ARCHITECTURE.md — 시스템 청사진 (참조 전용) + +> 이 파일은 **참조 전용**입니다. 작업 단위(목표·선행조건·변경 파일·구현·검증) 절차는 [PLAN.md](PLAN.md) 인덱스의 각 영역 파일을 참고하세요. + +--- + +## Context + +본 시스템은 [DRAFT.md](../DRAFT.md)의 설계 초안을 바탕으로 사용자의 결정사항을 반영한 **Spring Boot 기반 교내 시설 길찾기 백엔드**다. 현재 백엔드는 Spring Boot 4.0.6 / Java 21 환경의 빈 프로젝트(`MapApplication.java`만 존재) 상태이며, DRAFT 대비 다음 핵심 차이를 반영한다. + +- **장소 모델 변경**: `Place.entranceNodeId`(1:1) → `PlaceEntrance` 조인 테이블(1:N). 하나의 장소가 정문/후문/연결통로 등 여러 출입 노드를 가질 수 있다. +- **Building 엔티티 통합**: 별도 `Building` 테이블 폐기. `Place.kind = BUILDING | ROOM | FACILITY`로 통합하고 `parent_place_id`로 강의실↔건물 관계 표현. 별칭은 `PlaceAlias` 단일 테이블. +- **검색 모드 추가**: `SHORTEST | INDOOR_PREFERRED | STAIR_AVOIDANCE` 세 모드(단일 선택). ACCESSIBLE(휠체어) 모드는 본 프로젝트에서 다루지 않음. +- **검색엔진**: Elasticsearch 채택. 단, `SearchService` 인터페이스를 두어 구현체 교체 가능 구조. +- **회전 지시 규칙**: 실외 경로에서만 회전 step 생성, 실내 회전은 지시에서 제외. + +목적: DRAFT가 정의한 검색·길찾기 도메인을 구현 가능한 코드 단위로 분해하고, 진행 순서·파일 구조·API 계약·검증 방법을 확정한다. + +--- + +## 1. 기술 스택 + +| 영역 | 선택 | 비고 | +|------|------|------| +| 프레임워크 | Spring Boot 4.0.6 / Java 21 | 현 build.gradle 유지 | +| ORM | Spring Data JPA + Hibernate | `@JdbcTypeCode(SqlTypes.JSON)`으로 JSONB 매핑 | +| DB | PostgreSQL | JSONB 컬럼 사용 | +| 마이그레이션 | Flyway | `src/main/resources/db/migration/` | +| 좌표계 | `double lat/lng` (PostGIS 미사용) | Haversine으로 거리 계산 | +| 검색엔진 | Elasticsearch | `SearchService` 인터페이스 + ES 구현체 | +| 그래프 | 기동 시 인메모리 인접 리스트 적재 | 데이터 변경 시 갱신 | +| 인증 | **없음 (1차 프로토타입)** | 운영 전 반드시 추가 | +| 로컬 환경 | docker-compose (PG + ES) | 프로젝트 루트 `docker-compose.yml` | + +세부 의존성 목록은 [INFRASTRUCTURE.md](INFRASTRUCTURE.md)를 참조한다. + +--- + +## 2. 패키지 구조 + +``` +com.honggwart.map +├── MapApplication.java +├── config/ +│ ├── ElasticsearchConfig.java +│ └── JpaConfig.java +├── domain/ +│ ├── place/ # Place, PlaceAlias, PlaceEntrance, PlaceKind +│ ├── graph/ # Node, Edge, NodeType, EdgeType +│ └── common/ # 공통 enum, 값 객체 +├── search/ +│ ├── api/ # SearchController, DTO +│ ├── service/ # SearchService(인터페이스), ESSearchService +│ ├── indexer/ # PlaceIndexer (트랜잭션 이벤트 리스너) +│ └── es/ # ES 도큐먼트, repository +├── routing/ +│ ├── api/ # RouteController, DTO +│ ├── service/ # RouteService, RoutingMode +│ ├── graph/ # GraphRegistry(인메모리 그래프), GraphLoader +│ ├── algorithm/ # Dijkstra, WeightPolicy +│ └── instruction/ # StepExtractor, BearingCalculator +└── admin/ + └── api/ # AdminController (CRUD) +``` + +--- + +## 3. 영역 경계 다이어그램 + +``` + ┌──────────────────────┐ + │ API.md (Controller) │ + │ ───────────────── │ + │ SearchController │ + │ RouteController │ + │ AdminController │ + └─────┬───────┬───────┬─┘ + │ │ │ + ┌───────▼─┐ ┌─▼──────▼──────┐ + │SEARCH.md│ │ ROUTING.md │ + │ ────── │ │ ────────── │ + │ Service │ │ Service │ + │ Indexer │ │ GraphRegistry │ + │ ES Doc │ │ Dijkstra │ + └──┬──────┘ │ WeightPolicy │ + │ │ StepExtractor │ + │ └─────┬─────────┘ + │ │ + │ ┌────────▼────────────┐ + │ │ DATA_MODEL.md │ + └──────▶│ ─────────────────── │ + │ Place / Alias / │ + │ Entrance / Node / │ + │ Edge (JPA + DDL) │ + └─────────┬───────────┘ + │ + ┌─────────▼───────────┐ + │ INFRASTRUCTURE.md │ + │ ─────────────────── │ + │ build.gradle │ + │ application.yml │ + │ docker-compose │ + │ Flyway V1 │ + └─────────────────────┘ + + 외부: PostgreSQL ◀───┘ + Elasticsearch ◀── (SEARCH.md indexer + service) +``` + +핵심 연결 지점: +- **검색 ↔ 그래프**: `Place ─< PlaceEntrance >─ Node`. 사용자는 Place를 검색·선택하고, 라우팅 서비스가 PlaceEntrance의 다중 node_id 집합을 가상 노드로 묶어 Dijkstra를 수행한다. +- **검색 색인 동기화**: Place 트랜잭션 커밋 후 `PlacePersistedEvent` → `PlaceIndexer` → ES upsert (동기). +- **그래프 동기화**: Node/Edge 트랜잭션 커밋 후 `GraphRegistry.reload()` 호출. + +--- + +## 4. 영역 파일 매핑 + +| 영역 | 책임 | 파일 | +|------|------|------| +| 인프라 부트스트랩 | 의존성·환경·DDL 초기화 | [INFRASTRUCTURE.md](INFRASTRUCTURE.md) | +| 데이터 모델 | DDL·JPA 엔티티·시드 | [DATA_MODEL.md](DATA_MODEL.md) | +| 라우팅 | 그래프·Dijkstra·step 추출 | [ROUTING.md](ROUTING.md) | +| 검색 | ES 색인·서비스·동기화 | [SEARCH.md](SEARCH.md) | +| REST API | 검색·라우팅·관리자 컨트롤러 | [API.md](API.md) | + +원본 설계 초안은 [DRAFT.md](../DRAFT.md)에 있다. diff --git a/.claude/DATA_MODEL.md b/.claude/DATA_MODEL.md new file mode 100644 index 0000000..eaee05c --- /dev/null +++ b/.claude/DATA_MODEL.md @@ -0,0 +1,180 @@ +# DATA_MODEL.md — 도메인 데이터 모델 + +## 목표 (Goal) + +`Place`·`PlaceAlias`·`PlaceEntrance`·`Node`·`Edge` 다섯 핵심 엔티티의 DB 스키마와 JPA 매핑을 정의하고, 초기 시드 데이터로 검색·라우팅 개발이 가능한 작은 캠퍼스 그래프를 구축한다. JSONB 컬럼은 동적 속성 확장을 위한 단일 매핑 전략(`@JdbcTypeCode(SqlTypes.JSON)`)으로 일관되게 처리한다. + +## 선행조건 (Prerequisites) + +- [INFRASTRUCTURE.md](INFRASTRUCTURE.md) 완료: PostgreSQL 컨테이너 기동, JPA·Flyway 의존성, application.yml datasource 설정. + +## 변경/생성 파일 (Files to Change) + +| 경로 | 종류 | 역할 | +|------|------|------| +| `../src/main/resources/db/migration/V1__init.sql` | 신규 | 본 문서 §1·§2의 DDL | +| `../src/main/resources/db/migration/V2__seed.sql` | 신규 | §4 시드 데이터 | +| `../src/main/java/com/honggwart/map/domain/place/Place.java` | 신규 | JPA 엔티티 | +| `../src/main/java/com/honggwart/map/domain/place/PlaceAlias.java` | 신규 | JPA 엔티티 | +| `../src/main/java/com/honggwart/map/domain/place/PlaceEntrance.java` | 신규 | JPA 엔티티 | +| `../src/main/java/com/honggwart/map/domain/place/PlaceKind.java` | 신규 | enum: BUILDING / ROOM / FACILITY | +| `../src/main/java/com/honggwart/map/domain/place/PlaceRepository.java` | 신규 | Spring Data JPA | +| `../src/main/java/com/honggwart/map/domain/graph/Node.java` | 신규 | JPA 엔티티 | +| `../src/main/java/com/honggwart/map/domain/graph/Edge.java` | 신규 | JPA 엔티티 | +| `../src/main/java/com/honggwart/map/domain/graph/NodeType.java` | 신규 | enum | +| `../src/main/java/com/honggwart/map/domain/graph/EdgeType.java` | 신규 | enum | +| `../src/main/java/com/honggwart/map/domain/graph/NodeRepository.java` | 신규 | Spring Data JPA | +| `../src/main/java/com/honggwart/map/domain/graph/EdgeRepository.java` | 신규 | Spring Data JPA | + +## 구현 (Implementation) + +### §1. Place / PlaceAlias / PlaceEntrance + +```sql +-- Place: 검색·길찾기의 단위. 건물·강의실·시설을 모두 표현 +CREATE TABLE place ( + id BIGSERIAL PRIMARY KEY, + kind VARCHAR(16) NOT NULL, -- BUILDING | ROOM | FACILITY + name VARCHAR(200) NOT NULL, -- 정규 표시명 + display_name VARCHAR(200) NOT NULL, -- "공학관 501호" 등 파생 가능 + parent_place_id BIGINT NULL REFERENCES place(id), -- ROOM이면 BUILDING을 가리킴 + floor INTEGER NULL, -- ROOM/FACILITY의 층(실외=null) + room_number VARCHAR(20) NULL, -- "501" + category VARCHAR(32) NULL, -- OFFICE | LECTURE_HALL | RESTAURANT ... + properties JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now() +); + +-- PlaceAlias: 건물 별칭(T동, 공학관, T)과 장소 별칭(전자공학과 사무실 등)을 모두 수용 +CREATE TABLE place_alias ( + id BIGSERIAL PRIMARY KEY, + place_id BIGINT NOT NULL REFERENCES place(id) ON DELETE CASCADE, + alias VARCHAR(200) NOT NULL, + UNIQUE(place_id, alias) +); +CREATE INDEX idx_place_alias_place ON place_alias(place_id); + +-- PlaceEntrance: 하나의 Place가 가지는 여러 출입 노드 +CREATE TABLE place_entrance ( + id BIGSERIAL PRIMARY KEY, + place_id BIGINT NOT NULL REFERENCES place(id) ON DELETE CASCADE, + node_id BIGINT NOT NULL REFERENCES node(id), + label VARCHAR(50) NOT NULL, -- "정문", "후문", "3층 연결통로" 등 + is_primary BOOLEAN NOT NULL DEFAULT false, + UNIQUE(place_id, node_id) +); +CREATE INDEX idx_place_entrance_place ON place_entrance(place_id); +``` + +규칙: +- `Place.kind = BUILDING`인 경우 `floor`, `room_number`는 NULL이며, `display_name`은 `name`과 동일. +- `Place.kind = ROOM`인 경우 `parent_place_id`로 소속 건물을 가리키고, `display_name`은 `"{parent.name} {room_number}호"`로 파생한다. +- `Place.kind = FACILITY`는 실외 시설(식당·광장 등)이거나 건물 부속 시설. `parent_place_id`는 선택적이고 `floor`도 선택적. + +### §2. Node / Edge + +```sql +CREATE TABLE node ( + id BIGSERIAL PRIMARY KEY, + lat DOUBLE PRECISION NOT NULL, + lng DOUBLE PRECISION NOT NULL, + floor INTEGER NOT NULL DEFAULT 0, -- 실외=0 + place_id BIGINT NULL REFERENCES place(id), -- 노드가 특정 Place에 종속될 때(건물 내부) + is_indoor BOOLEAN NOT NULL, + node_type VARCHAR(20) NOT NULL, -- WAYPOINT|JUNCTION|ENTRANCE|VERTICAL_LINK|POI + properties JSONB NOT NULL DEFAULT '{}' -- isStair/isElevator/landmark 등 +); +CREATE INDEX idx_node_place ON node(place_id); +CREATE INDEX idx_node_floor_indoor ON node(floor, is_indoor); + +CREATE TABLE edge ( + id BIGSERIAL PRIMARY KEY, + from_node_id BIGINT NOT NULL REFERENCES node(id), + to_node_id BIGINT NOT NULL REFERENCES node(id), + weight DOUBLE PRECISION NOT NULL, -- 미터 기반 + edge_type VARCHAR(20) NOT NULL, -- CORRIDOR|OUTDOOR_PATH|STAIR|ELEVATOR|DOOR|RAMP + bidirectional BOOLEAN NOT NULL DEFAULT true, + properties JSONB NOT NULL DEFAULT '{}' -- transport=STAIR|ELEVATOR|ESCALATOR 등 +); +CREATE INDEX idx_edge_from ON edge(from_node_id); +CREATE INDEX idx_edge_to ON edge(to_node_id); +``` + +### §3. enum (확정) + +- **`PlaceKind`**: `BUILDING`, `ROOM`, `FACILITY` +- **`NodeType`** (5개 고정): `WAYPOINT`, `JUNCTION`, `ENTRANCE`, `VERTICAL_LINK`, `POI` + - 세부 속성(`isStair`, `isElevator`, `isEntrance`, `landmark`, `accessibility` 등)은 `properties` JSONB로 표현. +- **`EdgeType`**: `CORRIDOR`, `OUTDOOR_PATH`, `STAIR`, `ELEVATOR`, `DOOR`, `RAMP` + +### §3.1 층 간 이동 모델링 + +계단/엘리베이터는 **층마다 VERTICAL_LINK 노드 하나씩** 두고, 그들을 STAIR 또는 ELEVATOR edge로 연결한다. + +``` +Node(id=701, floor=1, type=VERTICAL_LINK) +Node(id=702, floor=2, type=VERTICAL_LINK) +Node(id=703, floor=3, type=VERTICAL_LINK) + +Edge(701→702, type=STAIR, weight=4.5) +Edge(702→703, type=STAIR, weight=4.5) +Edge(701→703, type=ELEVATOR, weight=8.0, bidirectional=true) +``` + +엘리베이터는 여러 층을 직접 잇는 edge로 모델링하며, 같은 위치의 VERTICAL_LINK 노드는 계단·엘리베이터가 공유한다. + +### §3.2 JPA 매핑 규칙 + +- `properties` JSONB 필드는 Hibernate 6의 `@JdbcTypeCode(SqlTypes.JSON)`로 매핑한다. +- `@Entity`마다 `equals`/`hashCode`는 id 기반(영속화 후) 또는 Lombok `@EqualsAndHashCode(of = "id")`. +- 양방향 연관은 지연 로딩(`fetch = LAZY`)을 기본으로 한다. PlaceEntrance는 `Place → List`만 두고, 노드 ↔ 엔트런스 역방향은 두지 않는다(라우팅 서비스가 직접 조회). +- enum 컬럼은 `@Enumerated(EnumType.STRING)`. + +### §4. 시드 데이터 (V2__seed.sql) + +작은 캠퍼스 모델로 다음 규모를 시드한다. + +- 건물 2~3개 (예: "공학관"/T동, "본관") +- 강의실 5개 (각 건물 소속) +- 외부 시설 1개 (예: "중앙광장 식당") +- 노드 ~30개 (건물 내·외부 노드, VERTICAL_LINK 포함) +- 간선 ~50개 (CORRIDOR, OUTDOOR_PATH, STAIR, ELEVATOR, DOOR) + +**다중 입구 케이스**: 공학관 Place가 다음 PlaceEntrance를 갖도록 시드한다. +- (label="정문", is_primary=true) +- (label="후문", is_primary=false) +- (label="3층 연결통로", is_primary=false) + +별칭 시드: 공학관 Place에 PlaceAlias로 `"공학관"`, `"T동"`, `"T"`, `"공학"` 등. 강의실(T501)에는 `"전자공학과 사무실"` 같은 장소 별칭. + +좌표는 임의의 캠퍼스 영역(예: 위도 37.00~37.001, 경도 127.00~127.001) 안에서 일관되게 배치하여 Haversine 거리 계산이 의미를 갖도록 한다. + +## 검증 (Verification) + +### 단위 테스트 +- `PlacePersistenceTest` (`@DataJpaTest`): + - Place + PlaceAlias 2개 + PlaceEntrance 3개를 cascade로 저장하고 재조회. + - `Place.kind`에 따른 nullable 컬럼이 의도대로 NULL로 저장되는지. + - JSONB `properties`에 임의 키-값을 저장하고 그대로 읽히는지. +- `GraphPersistenceTest`: + - Node·Edge 저장 후 `EdgeRepository.findAllByFromNodeId` 조회. + - VERTICAL_LINK 노드 3층 체인 + ELEVATOR edge 1개 시나리오 검증. + +### 통합 검증 +- `./gradlew bootRun` 기동 후 PSQL로 시드 데이터 카운트 확인: + ```sql + SELECT + (SELECT count(*) FROM place) AS places, + (SELECT count(*) FROM place_entrance) AS entrances, + (SELECT count(*) FROM node) AS nodes, + (SELECT count(*) FROM edge) AS edges; + ``` + 기대치: places ≥ 8, entrances ≥ 5 (공학관 3 + 다른 건물 2 이상), nodes ≈ 30, edges ≈ 50. + +## 참조 (References) + +- 인프라 설정: [INFRASTRUCTURE.md](INFRASTRUCTURE.md) +- 그래프 사용처: [ROUTING.md](ROUTING.md) +- 검색 색인 매핑 원천: [SEARCH.md](SEARCH.md) +- 도메인 의사결정 배경: [../DRAFT.md](../DRAFT.md) §2~§5 diff --git a/.claude/INFRASTRUCTURE.md b/.claude/INFRASTRUCTURE.md new file mode 100644 index 0000000..92a5af8 --- /dev/null +++ b/.claude/INFRASTRUCTURE.md @@ -0,0 +1,124 @@ +# INFRASTRUCTURE.md — 인프라·환경 부트스트랩 + +## 목표 (Goal) + +Spring Boot 애플리케이션이 PostgreSQL과 Elasticsearch에 정상 연결되어 기동되도록 의존성·설정·로컬 개발 환경·Flyway 초기 마이그레이션을 구성한다. 이후의 모든 작업 단위가 이 환경 위에서 진행된다. + +## 선행조건 (Prerequisites) + +없음. 이 영역이 가장 첫 작업이다. + +전제: 사용자 머신에 Docker(또는 Docker Desktop)가 설치되어 있어야 한다. + +## 변경/생성 파일 (Files to Change) + +| 경로 | 종류 | 역할 | +|------|------|------| +| [../build.gradle](../build.gradle) | 수정 | JPA·ES·PostgreSQL·Flyway·Testcontainers 의존성 추가 | +| [../src/main/resources/application.yml](../src/main/resources/application.yml) | 수정 | datasource, JPA, Elasticsearch URI, Flyway 설정 | +| `../docker-compose.yml` | 신규 | 로컬 PostgreSQL + Elasticsearch 컨테이너 | +| `../src/main/resources/db/migration/V1__init.sql` | 신규 | 도메인 스키마 (실제 DDL은 [DATA_MODEL.md](DATA_MODEL.md)에서 정의된 것을 그대로 사용) | + +## 구현 (Implementation) + +### 1. build.gradle 의존성 추가 + +기존 [../build.gradle](../build.gradle)의 `dependencies` 블록에 다음을 추가한다. + +```gradle +implementation 'org.springframework.boot:spring-boot-starter-data-jpa' +implementation 'org.springframework.boot:spring-boot-starter-data-elasticsearch' +implementation 'org.springframework.boot:spring-boot-starter-validation' +implementation 'org.postgresql:postgresql' +implementation 'org.flywaydb:flyway-core' +implementation 'org.flywaydb:flyway-database-postgresql' +testImplementation 'org.testcontainers:postgresql' +testImplementation 'org.testcontainers:elasticsearch' +testImplementation 'org.testcontainers:junit-jupiter' +``` + +Spring Boot 4.0.6 / Java 21 설정(`toolchain`)은 유지한다. + +### 2. docker-compose.yml 신규 작성 + +프로젝트 `Backend/` 디렉터리에 다음 내용으로 생성한다. + +```yaml +services: + postgres: + image: postgres:16 + environment: + POSTGRES_DB: honggwart + POSTGRES_USER: honggwart + POSTGRES_PASSWORD: honggwart + ports: ["5432:5432"] + elasticsearch: + image: docker.elastic.co/elasticsearch/elasticsearch:8.13.0 + environment: + discovery.type: single-node + xpack.security.enabled: "false" + ports: ["9200:9200"] +``` + +### 3. application.yml 갱신 + +기존 application.yml(`spring.application.name: map`만 있음)에 다음 섹션을 추가한다. + +```yaml +spring: + application: + name: map + datasource: + url: jdbc:postgresql://localhost:5432/honggwart + username: honggwart + password: honggwart + driver-class-name: org.postgresql.Driver + jpa: + hibernate: + ddl-auto: validate # Flyway가 DDL을 관리, JPA는 검증만 + properties: + hibernate.format_sql: true + flyway: + enabled: true + locations: classpath:db/migration + baseline-on-migrate: false + elasticsearch: + uris: http://localhost:9200 +``` + +### 4. Flyway V1__init.sql + +[DATA_MODEL.md](DATA_MODEL.md)의 §1 (Place/PlaceAlias/PlaceEntrance)과 §2 (Node/Edge) 스키마를 그대로 옮긴다. + +**참고**: DDL 정의의 단일 출처는 [DATA_MODEL.md](DATA_MODEL.md)이다. 이 파일은 V1 SQL이 그곳의 DDL을 복사해서 사용한다는 사실만 명시한다(DDL 자체를 두 곳에 중복 정의하지 않는다). + +테이블 간 참조 순서 때문에 다음 순서로 작성해야 한다. + +1. `place` (parent_place_id self-FK 포함) +2. `node` (place_id → place.id FK 포함) +3. `edge` (from/to_node_id → node.id FK) +4. `place_alias` (place_id → place.id FK) +5. `place_entrance` (place_id → place.id, node_id → node.id FK) + +## 검증 (Verification) + +### 부트스트랩 검증 +1. `docker-compose up -d`로 PostgreSQL + Elasticsearch 기동. +2. `./gradlew bootRun`이 에러 없이 실행되어야 한다. +3. 다음 SQL로 Flyway 적용 확인: + ```sql + SELECT version, description, success + FROM flyway_schema_history + ORDER BY installed_rank DESC; + ``` + `V1__init.sql`이 `success=true`로 기록되어 있어야 한다. +4. ES `curl http://localhost:9200/_cluster/health`가 `status: green` 또는 `yellow`(단일 노드). + +### 환경 청소 +- `docker-compose down -v`로 컨테이너·볼륨 모두 제거 → 재기동 시 빈 DB에서 V1이 다시 적용되는지 확인. + +## 참조 (References) + +- 스키마 정의 원본: [DATA_MODEL.md](DATA_MODEL.md) +- 시스템 청사진: [ARCHITECTURE.md](ARCHITECTURE.md) +- 설계 초안: [../DRAFT.md](../DRAFT.md) diff --git a/.claude/PLAN.md b/.claude/PLAN.md new file mode 100644 index 0000000..2955b51 --- /dev/null +++ b/.claude/PLAN.md @@ -0,0 +1,52 @@ +# PLAN.md — 교내 시설 길찾기 시스템 (인덱스) + +## Context + +[DRAFT.md](../DRAFT.md) 설계 초안을 사용자 결정에 맞춰 확정한 Spring Boot 백엔드의 구현 인덱스. 핵심 변경점은 다음과 같다. + +- **장소 모델**: `Place.entranceNodeId`(1:1) → `PlaceEntrance` 조인 테이블(1:N). 한 장소가 정문/후문/연결통로 등 여러 출입 노드를 가진다. +- **Building 통합**: 별도 Building 테이블 폐기. `Place.kind = BUILDING | ROOM | FACILITY`로 통합, `parent_place_id`로 강의실↔건물 표현. 별칭은 `PlaceAlias`. +- **검색 모드**: `SHORTEST | INDOOR_PREFERRED | STAIR_AVOIDANCE` (단일 선택). 휠체어 모드는 비대상. +- **검색엔진**: Elasticsearch. `SearchService` 인터페이스로 구현체 교체 가능. +- **회전 지시**: 실외만 생성, 실내 회전은 제외. + +상세 설계는 [ARCHITECTURE.md](ARCHITECTURE.md), 영역별 작업 절차는 아래 인덱스의 각 파일을 참조한다. + +--- + +## 파일 인덱스 + +| 파일 | 한 줄 요약 | +|------|------------| +| [ARCHITECTURE.md](ARCHITECTURE.md) | 패키지 구조·도메인 경계·기술 스택의 전체 청사진(참조 전용) | +| [INFRASTRUCTURE.md](INFRASTRUCTURE.md) | Spring Boot 의존성·docker-compose·application.yml·Flyway V1 초기 설정 | +| [DATA_MODEL.md](DATA_MODEL.md) | Place/PlaceAlias/PlaceEntrance/Node/Edge 스키마·JPA 매핑·시드 데이터 | +| [ROUTING.md](ROUTING.md) | 인메모리 그래프·Dijkstra·가중치 모드·실내 회전 무시 step 추출 | +| [SEARCH.md](SEARCH.md) | Elasticsearch 색인·토큰 생성 규칙·트랜잭션 커밋 후 동기 색인 | +| [API.md](API.md) | 검색·라우팅·관리자 REST API 계약 및 컨트롤러 | + +원본 설계: [../DRAFT.md](../DRAFT.md) + +--- + +## 구현 순서 + +1. **[INFRASTRUCTURE.md](INFRASTRUCTURE.md)** — 의존성·환경·Flyway V1. +2. **[DATA_MODEL.md](DATA_MODEL.md)** — 엔티티·시드. +3. **[ROUTING.md](ROUTING.md)** ↔ **[SEARCH.md](SEARCH.md)** — 두 영역은 DATA_MODEL 완료 후 병행 가능. +4. **[API.md](API.md)** — 위 영역들이 모두 끝난 뒤 컨트롤러·DTO·통합 테스트. + +각 영역 파일은 자신만의 **목표 / 선행조건 / 변경 파일 / 구현 / 검증** 섹션을 가진다(ARCHITECTURE.md 제외). + +--- + +## 보류 / 후속 항목 + +- 사용자 인증(Spring Security + JWT) — 운영 전 필수. +- 초성 검색(`ㄱㅎㄱ` → `공학관`). +- 다국어(영문 명칭). +- 오타 보정(fuzzy), 형태소 분석 — ES analyzer 확장 검토. +- 곡선이 급한 경우의 슬라이딩 윈도우 회전 감지([../DRAFT.md](../DRAFT.md) §5.5 개선안). +- 관리자 GUI(편집 화면, 지도 위 노드 찍기). +- 가중치 정책 튜닝(실데이터 기반). +- 실시간 위치/재탐색(현 설계 범위 밖, 변경 없음). diff --git a/.claude/ROUTING.md b/.claude/ROUTING.md new file mode 100644 index 0000000..524586d --- /dev/null +++ b/.claude/ROUTING.md @@ -0,0 +1,154 @@ +# ROUTING.md — 길찾기 서비스 + +## 목표 (Goal) + +캠퍼스 그래프를 기동 시 메모리에 적재하고, 사용자가 선택한 모드(`SHORTEST` / `INDOOR_PREFERRED` / `STAIR_AVOIDANCE`)에 따라 최단 경로를 계산한다. 다중 입구를 가진 Place 사이의 라우팅은 가상 노드 기법으로 한 번의 Dijkstra에 처리하며, 경로상의 모든 노드열을 사용자 친화적 step 목록으로 변환한다. 실내 회전 지시는 생성하지 않는다. + +## 선행조건 (Prerequisites) + +- [DATA_MODEL.md](DATA_MODEL.md) 완료: `Node`/`Edge`/`Place`/`PlaceEntrance` 엔티티 존재, 시드 데이터 적재. +- [INFRASTRUCTURE.md](INFRASTRUCTURE.md) 완료: 애플리케이션 기동 가능. + +## 변경/생성 파일 (Files to Change) + +| 경로 | 종류 | 역할 | +|------|------|------| +| `../src/main/java/com/honggwart/map/routing/service/RoutingMode.java` | 신규 | enum: SHORTEST / INDOOR_PREFERRED / STAIR_AVOIDANCE | +| `../src/main/java/com/honggwart/map/routing/graph/GraphRegistry.java` | 신규 | 인메모리 인접 리스트, 기동·재빌드 | +| `../src/main/java/com/honggwart/map/routing/graph/GraphLoader.java` | 신규 | DB → 인접 리스트 변환 | +| `../src/main/java/com/honggwart/map/routing/algorithm/Dijkstra.java` | 신규 | 우선순위 큐 기반 단일/다중 출발 | +| `../src/main/java/com/honggwart/map/routing/algorithm/WeightPolicy.java` | 신규 | 모드별 edge_type 배수 | +| `../src/main/java/com/honggwart/map/routing/instruction/BearingCalculator.java` | 신규 | Haversine 방위각 | +| `../src/main/java/com/honggwart/map/routing/instruction/StepExtractor.java` | 신규 | 노드열 → step 목록 | +| `../src/main/java/com/honggwart/map/routing/service/RouteService.java` | 신규 | 진입점: from/to Place + mode → RouteResult | + +## 구현 (Implementation) + +### §1. RoutingMode 정의 + +```java +public enum RoutingMode { SHORTEST, INDOOR_PREFERRED, STAIR_AVOIDANCE } +``` + +### §2. WeightPolicy — 모드별 가중치 배수 + +기본 weight는 미터(거리). `WeightPolicy.multiplier(EdgeType, RoutingMode)`가 다음 표대로 배수를 반환한다. + +| edge_type | SHORTEST | INDOOR_PREFERRED | STAIR_AVOIDANCE | +|--------------|----------|------------------|-----------------| +| CORRIDOR | ×1.0 | ×1.0 | ×1.0 | +| OUTDOOR_PATH | ×1.0 | **×5.0** | ×1.0 | +| STAIR | ×1.2 | ×1.2 | **×8.0** | +| ELEVATOR | ×1.0 | ×1.0 | ×0.8 | +| DOOR | ×1.0 | ×1.0 | ×1.0 | +| RAMP | ×1.0 | ×1.0 | ×0.9 | + +- 시드 데이터로 검증 후 튜닝한다(상수는 한 곳에서 관리). + +### §3. GraphRegistry — 인메모리 그래프 + +- 애플리케이션 기동 시 `ApplicationRunner`(또는 `@PostConstruct`)에서 `GraphLoader`를 호출해 전체 Node·Edge를 인접 리스트로 적재. +- 자료구조: `Map>` 형태(노드 id → 인접 edge 목록). +- `Edge.bidirectional == true`이면 양방향 모두 등록. +- `reload()` 메서드는 전체 재빌드. 관리자 API에서 노드·간선 변경 트랜잭션 커밋 후 이벤트로 호출(이 호출 트리거는 [API.md](API.md)의 관리자 컨트롤러에서 발생시킨다). +- 캠퍼스 규모(노드 수천 이하)에서 재빌드 비용은 ms~수십 ms. + +### §4. Dijkstra — 단일/다중 출발 + +``` +입력: Set sources, Set targets, RoutingMode mode +처리: + 1) 가상 시작 노드 s* 도입. s* → each source ∈ sources 에 weight=0 edge. + 2) 가상 종료 노드 t* 도입. each target ∈ targets → t* 에 weight=0 edge. + 3) s*에서 t*까지 표준 PQ 기반 Dijkstra 실행. + 4) 결과 경로에서 s*, t*를 잘라낸다. + 5) 실제 사용된 source(=경로의 두 번째 노드), target(=경로의 마지막 노드 직전)을 식별해 반환. +출력: List nodePath, double totalWeight, Long usedSource, Long usedTarget +``` + +- `weight`는 `Edge.weight * WeightPolicy.multiplier(edge.type, mode)`. +- 가상 노드는 음수 id(예: -1, -2)로 표현하거나 별도 sentinel. +- PriorityQueue 비교는 누적 weight 오름차순. + +### §5. StepExtractor — 노드열 → 사용자 step + +`Dijkstra` 결과(노드 id 순서 리스트)에 대해 다음 규칙으로 step을 만든다. + +**이벤트 표** + +| 이벤트 | 트리거 조건 | 생성 step | +|--------|-------------|-----------| +| DEPART | 경로 시작 | "출발합니다" (출발 PlaceEntrance.label 포함) | +| ENTER_BUILDING | 인접 노드의 `is_indoor` false → true | "건물 안으로 들어가세요" | +| EXIT_BUILDING | 인접 노드의 `is_indoor` true → false | "건물 밖으로 나가세요" | +| FLOOR_CHANGE | 인접 노드의 `floor` 변화 | "{n}층까지 {계단/엘리베이터}로 이동하세요" — edge.edge_type으로 transport 결정 | +| TURN_LEFT | 회전 발생 노드의 `is_indoor=false` && Δ ≤ -τ | "좌회전 후 직진하세요" | +| TURN_RIGHT | 회전 발생 노드의 `is_indoor=false` && Δ ≥ +τ | "우회전 후 직진하세요" | +| ARRIVE | 경로 끝 (목적지 노드) | "{place.display_name}에 도착했습니다" (도착 PlaceEntrance.label 포함) | + +**회전 임계값** + +- ε = 20° (이하 직진 — 지시 없음) +- τ = 45° (이상 회전 — 좌/우 step 생성) +- ε < |Δ| < τ 구간은 직진으로 흡수. + +**실내 회전 무시 규칙** + +- 회전이 감지된 노드의 `is_indoor=true`이면 TURN_LEFT/RIGHT step을 **생성하지 않는다**. 실내 복도의 회전은 사용자에게 노출하지 않는다. + +**방위각 계산** + +`BearingCalculator.bearing(Node from, Node to)`는 Haversine 기반으로 두 좌표 사이 초기 방위각을 라디안/도 단위로 계산한다. 좌표는 단순 lat/lng이므로 위도에 따른 보정을 적용한다. + +``` +θ1 = bearing(path[i-1], path[i]) +θ2 = bearing(path[i], path[i+1]) +Δ = normalize(θ2 - θ1) // (-180°, 180°] +``` + +**run-length 압축** + +각 step은 `polylineRange = [startIndex, endIndex]`(경로 노드 인덱스 구간)를 가진다. 직진 구간 내부에 노드가 N개 있어도 step은 1개이며, 그 사이의 노드는 polyline에 그대로 남는다(곡선 처리는 이로써 자연 흡수). step 종류 enum: `DEPART | TURN_LEFT | TURN_RIGHT | ENTER_BUILDING | EXIT_BUILDING | FLOOR_CHANGE | ARRIVE`. + +### §6. RouteService — 진입점 + +``` +RouteResult route(long fromPlaceId, long toPlaceId, RoutingMode mode): + 1) Place 두 개 조회. 각각의 PlaceEntrance 목록에서 node_id 집합을 추출. + 2) Dijkstra(sources, targets, mode) → 노드 id 경로. + 3) 경로의 각 노드를 GraphRegistry에서 좌표·층·indoor로 매핑 → polyline. + 4) StepExtractor.extract(path) → steps. + 5) usedSource, usedTarget로 from/to PlaceEntrance.label 식별 → 응답에 포함. + 6) summary: 총 거리(미터, 가중치 적용 전 raw weight 합), 추정 시간(거리/평균 보행 속도, 1차는 1.2 m/s), 층 변경 횟수. +``` + +### §7. 응답 DTO + +라우팅 응답의 JSON 계약·필드명은 [API.md](API.md) §라우팅 응답 계약에 정의된 것을 따른다. 본 영역은 그 DTO를 채우는 데 필요한 도메인 로직만 책임진다. + +## 검증 (Verification) + +### 단위 테스트 +- `DijkstraTest`: 손으로 만든 5~7노드 그래프에서 최단 경로의 순서·총 가중치 정확성. +- `WeightPolicyTest`: 동일 그래프에서 3가지 모드가 서로 다른 경로(또는 동일한 경로일 경우 가중치 차이)를 선택하는지. +- `StepExtractorTest`: + - 직선 경로(3개 노드, 회전 없음) → step 2개 (DEPART, ARRIVE). + - 실외 좌회전 1회 → TURN_LEFT 1개 + DEPART/ARRIVE. + - 실내 좌회전 1회 → TURN_LEFT **미생성** (DEPART/ARRIVE만). + - 1층→5층 엘리베이터 → FLOOR_CHANGE.meta.transport=ELEVATOR. + - 완만한 곡선(5개 노드, 각 Δ < τ) → 회전 step 0개, polylineRange로 흡수 확인. +- `BearingCalculatorTest`: 동서남북 4 방향에 대한 방위각 값(0°/90°/180°/270°)이 ±1° 이내. + +### 통합 테스트 (Testcontainers) +- `RouteIntegrationTest`: PG+ES 컨테이너 기동 → 시드 데이터 → `RouteService.route(...)` 직접 호출 → polyline·steps 검증. +- `MultiEntranceTest`: 공학관(정문/후문/3층 연결통로) → T501호 라우팅 시, 동일한 목적지에 대해 사용된 `usedSource`가 항상 최단 입구 조합과 일치하는지, 출발 PlaceEntrance.label이 응답에 포함되는지. + +### 수동 검증 +- 시드 그래프에 대해 `RouteService`를 3가지 모드로 호출했을 때 `summary.distanceMeters`와 `summary.floorChanges`가 모드에 맞게 달라지는지 확인. + +## 참조 (References) + +- 엔티티 정의: [DATA_MODEL.md](DATA_MODEL.md) +- HTTP 계약: [API.md](API.md) +- 그래프 변경 시 호출 트리거: [API.md](API.md)의 관리자 API → `GraphRegistry.reload()` +- 설계 의사결정: [../DRAFT.md](../DRAFT.md) §5 diff --git a/.claude/SEARCH.md b/.claude/SEARCH.md new file mode 100644 index 0000000..de72b26 --- /dev/null +++ b/.claude/SEARCH.md @@ -0,0 +1,156 @@ +# SEARCH.md — 검색·자동완성 서비스 + +## 목표 (Goal) + +`Place`에 대한 prefix 자동완성을 Elasticsearch 기반으로 구현한다. 한 장소의 여러 표기(`'공학관 501호'` ≡ `'T동 501호'` ≡ `'T501'`)는 색인 시점에 토큰으로 미리 펼쳐 동일 `place_id`로 귀결되며, Place 데이터 변경 시 트랜잭션 커밋 후 동기 이벤트로 ES 색인을 갱신한다. 검색 서비스는 인터페이스로 추상화해 향후 다른 구현체(Trie, DB LIKE 등)로 교체할 수 있도록 한다. + +## 선행조건 (Prerequisites) + +- [INFRASTRUCTURE.md](INFRASTRUCTURE.md) 완료: ES 컨테이너 기동, `spring-boot-starter-data-elasticsearch` 의존성, application.yml의 `spring.elasticsearch.uris` 설정. +- [DATA_MODEL.md](DATA_MODEL.md) 완료: `Place`/`PlaceAlias`/`PlaceEntrance` 엔티티 및 시드 데이터. + +## 변경/생성 파일 (Files to Change) + +| 경로 | 종류 | 역할 | +|------|------|------| +| `../src/main/java/com/honggwart/map/config/ElasticsearchConfig.java` | 신규 | RestClient 빈, 인덱스 자동 생성 | +| `../src/main/java/com/honggwart/map/search/service/SearchService.java` | 신규 | 인터페이스 | +| `../src/main/java/com/honggwart/map/search/service/ESSearchService.java` | 신규 | ES 구현체 | +| `../src/main/java/com/honggwart/map/search/es/PlaceDocument.java` | 신규 | ES 도큐먼트(`@Document(indexName = "places")`) | +| `../src/main/java/com/honggwart/map/search/es/PlaceSearchRepository.java` | 신규 | Spring Data Elasticsearch 리포지토리 | +| `../src/main/java/com/honggwart/map/search/indexer/PlaceIndexer.java` | 신규 | 트랜잭션 이벤트 리스너 | +| `../src/main/java/com/honggwart/map/search/indexer/PlacePersistedEvent.java` | 신규 | 이벤트 객체 | +| `../src/main/java/com/honggwart/map/search/indexer/TokenGenerator.java` | 신규 | 색인용 토큰 펼침 | +| `../src/main/java/com/honggwart/map/search/api/dto/PlaceSuggestion.java` | 신규 | 응답 DTO (한 줄 요약) | +| `../src/main/java/com/honggwart/map/search/api/dto/PlaceDetail.java` | 신규 | 상세 응답 DTO | + +(컨트롤러와 HTTP DTO는 [API.md](API.md)에서 정의) + +## 구현 (Implementation) + +### §1. SearchService 인터페이스 + +```java +public interface SearchService { + List autocomplete(String query, int limit); + Optional findById(long placeId); +} +``` + +- 구현체: `ESSearchService` (1차 유일 구현). +- DI로 교체 가능. 향후 `InMemoryTrieSearchService` 등을 추가할 수 있도록 빈 등록 시 `@Primary` 또는 프로파일 분기 가능. + +### §2. Elasticsearch 색인 설계 + +**인덱스명**: `places` + +**도큐먼트 구조**: + +```json +{ + "place_id": 101, + "kind": "ROOM", + "display_name": "공학관 501호", + "tokens": ["공학관 501호", "T동 501호", "T501", "전자공학과 사무실"], + "ngram_tokens": ["..."], + "category": "OFFICE", + "weight": 1.0 +} +``` + +**Analyzer 설정**: + +- 한글 분석기: `nori` (Elasticsearch nori plugin) 또는 standard + ngram 조합. 1차에는 nori 사용. +- `tokens` 필드: 색인 시점에 펼친 모든 표기를 그대로 색인. analyzer는 keyword에 가까운 형태(공백·소문자 정규화 후 그대로). +- `ngram_tokens` 필드: prefix 자동완성용 `edge_ngram` analyzer (min=1, max=10). +- 동의어 처리는 **색인 시점 토큰 확장에 의존**한다. ES synonym filter는 사용하지 않는다(일관성 단일 원천). + +**입력 정규화 (질의·색인 양쪽 공통)** + +- 유니코드 NFC. +- 영문 소문자화. +- 공백 제거 또는 단일화. +- analyzer chain에서 일괄 처리하여 색인·질의 시 동일 변환 보장. + +### §3. TokenGenerator — 토큰 펼침 규칙 + +`Place(공학관 501호, building 별칭 = {공학관, T동, T})` 에 대해 생성할 토큰 목록: + +| 패턴 | 예시 | +|------|------| +| 건물별칭 + 호실 + "호" | `공학관 501호`, `T동 501호`, `T 501호` | +| 건물별칭 + 호실 (공백 변형) | `공학관501호`, `t501`, `t 501` | +| 건물별칭 + 호실(번호만) | `공학관 501`, `T501` | +| 호실 단독 (가중치 ↓) | `501호`, `501` | +| 장소 고유명 | `전자공학과 사무실` (PlaceAlias 중 ROOM에 직접 연결된 것) | + +`TokenGenerator.generate(Place place, List aliases, Place parentBuilding, List parentAliases)`는 위 패턴을 적용해 `List`을 반환한다. + +`kind = BUILDING`인 경우는 건물 별칭 자체(`공학관`, `T동`, `T`)와 `display_name`만. +`kind = FACILITY`인 경우는 장소 고유명 + 별칭. + +### §4. PlaceIndexer — 색인 동기화 + +**전략**: 트랜잭션 커밋 후 동기 이벤트 결합도 객체. + +``` +1. PlaceService(또는 AdminController의 변경 핸들러)에서 + Place/Alias/Entrance 저장 직후 PlacePersistedEvent(placeId) 발행. +2. @TransactionalEventListener(phase = AFTER_COMMIT) 메서드가 이벤트 수신. +3. PlaceRepository 등으로 Place + aliases + parent + parent aliases 조회. +4. TokenGenerator.generate(...) → tokens 리스트. +5. PlaceDocument 생성 후 PlaceSearchRepository.save(...) (upsert). +6. 동기 실행이므로 트랜잭션 호출자가 결과를 기다림. +7. 실패 시 1차에서는 로그만 남기고 알림/재시도 미구현. +``` + +**삭제**: `PlaceDeletedEvent(placeId)`도 동일한 패턴으로 처리. 리스너가 `PlaceSearchRepository.deleteById(placeId)`. + +### §5. ESSearchService — 자동완성 구현 + +``` +autocomplete(query, limit): + 1. 질의 정규화(NFC, 소문자, 공백 단일화) — analyzer가 동일 처리하므로 클라이언트 측은 trim만. + 2. ES match_phrase_prefix 또는 multi_match(tokens + ngram_tokens, type=bool_prefix) 쿼리. + 3. 결과 hits에서 place_id 추출. + 4. place_id 기준 distinct + weight 기준 정렬 (같은 place_id가 여러 토큰으로 매칭되더라도 한 줄로). + 5. 상위 limit개를 PlaceSuggestion DTO로 반환. + +findById(placeId): + - ES가 아닌 PlaceRepository(JPA)에서 단일 조회. parent·entrances 요약 동봉. + - ES는 검색용이고 상세 조회는 RDB가 진실의 원천. +``` + +### §6. 전체 재색인 + +운영 중 색인이 깨지거나 토큰 규칙이 바뀌었을 때를 대비해 전체 재색인 기능을 노출한다. 트리거(엔드포인트)는 [API.md](API.md)의 관리자 API에서 정의되며, 본 영역은 다음 메서드만 제공한다. + +```java +class PlaceIndexer { + void reindexAll(); // 전체 Place를 순회하며 색인. 비용이 크므로 운영용. +} +``` + +## 검증 (Verification) + +### 단위 테스트 +- `TokenGeneratorTest`: + - `Place(공학관 501호)` + aliases(`T동`,`T`) → 토큰에 `T501`, `공학관 501호`, `T동 501호` 등이 모두 포함. + - `Place(공학관, kind=BUILDING)` + aliases → `공학관`, `T동`, `T`만 (호실 토큰 없음). + +### 통합 테스트 (Testcontainers, ES 컨테이너 사용) +- `SearchIntegrationTest`: + - 시드 데이터 적재 후 `/api/search/autocomplete?q=T`(엔드포인트는 [API.md](API.md)에 정의) 호출 → "공학관 501호", "공학관 401호" 등이 응답에 포함되고, 같은 placeId의 토큰이 중복으로 나타나지 않는지(distinct). + - 한글 prefix(`q=공학`) 호출 → 건물·강의실이 모두 후보로 나타나는지. +- `IndexerTransactionTest`: + - PlaceService가 트랜잭션 안에서 Place를 저장하고 롤백 → ES에는 색인되지 않음. + - 정상 커밋 → 색인 반영 후 즉시 `SearchService.autocomplete`로 검색 가능. + +### 수동 검증 +- `curl "http://localhost:8080/api/search/autocomplete?q=공학"` → JSON suggestions에 공학관 등 후보 노출. + +## 참조 (References) + +- HTTP 계약: [API.md](API.md) +- 엔티티: [DATA_MODEL.md](DATA_MODEL.md) +- 설계 배경(왜 ES + 인터페이스인가): [../DRAFT.md](../DRAFT.md) §4 diff --git a/DRAFT.md b/DRAFT.md new file mode 100644 index 0000000..fdcf8c2 --- /dev/null +++ b/DRAFT.md @@ -0,0 +1,435 @@ +# DRAFT.md — 교내 시설 길찾기 시스템 설계 초안 + +> 이 문서는 검토용 초안입니다. 각 절의 **[검토 필요]** 표시와 마지막 "9. 결정이 필요한 사항"을 확인한 뒤 PLAN.md를 확정하시면 됩니다. + +--- + +## 1. 개요 + +Spring Boot 기반의 캠퍼스 건물 시설 길찾기 시스템. 네이버 지도와 유사하게 (1) 검색어 입력 → 자동완성/매핑, (2) 출발지·도착지 선택 → 경로 안내를 제공한다. + +설계의 핵심 난점은 두 가지로 정리된다. + +- **검색**: 하나의 물리적 장소가 여러 표기('공학관 501호' = 'T동 501호' = 'T501')를 가지며, 이를 단일 DB 레코드로 매핑하면서도 자동완성을 빠르게 응답해야 한다. +- **길찾기**: 그래프 상의 모든 노드를 지나가지만, 사용자에게 보여줄 "지시(instruction)"는 의미 있는 지점에서만 생성되어야 한다. 동시에 노드의 성격을 표현하는 스키마가 개발 중 동적으로 변할 수 있다. + +### 1.1 프로젝트 범위 (Scope) + +본 프로젝트는 **백엔드 전용**이다. 다음을 명확히 구분한다. + +- **범위 내**: 도메인 모델, DB 스키마, 검색/자동완성 로직, 다익스트라 길찾기, 경로 지시 추출, REST API 응답 계약(contract). +- **범위 밖**: 프론트엔드 구현, 지도 타일 렌더링, 좌표계 변환, 클라이언트 UI. 백엔드는 `polyline`·`steps` 등 **응답 계약만 책임진다.** 응답에 좌표·층 정보가 포함되는 것은 클라이언트가 경로를 그릴 수 있도록 하는 계약 설계일 뿐이며, 렌더링 자체는 클라이언트의 몫이다. +- **비대상 가정**: 본 서비스는 **실시간 사용자 위치(GPS 스트림)를 받지 않는다.** 길찾기는 사용자가 지정한 출발 노드 → 도착 노드의 정적(static) 경로 계산이며, 응답은 전체 경로를 한 번에 반환한다. 경로 재탐색(rerouting), 현재 위치 기반 단계별 추적 등 실시간 요소는 설계에 포함하지 않는다. + +--- + +## 2. 기술 스택 제안 + +| 영역 | 제안 | 이유 | +|------|------|------| +| 프레임워크 | Spring Boot 3.x / Java 17+ | 기본 | +| ORM | Spring Data JPA + Hibernate | 표준 | +| DB | PostgreSQL | `JSONB` 컬럼으로 동적 스키마 수용, 인덱싱 우수 | +| 위치 데이터 | 우선 `double` lat/lng, 필요 시 PostGIS | 캠퍼스 규모에선 단순 좌표로 충분 | +| 자동완성 캐시 | 인메모리 Trie (+ 선택적 Redis) | 캠퍼스 규모상 외부 검색엔진 불필요 | +| 마이그레이션 | Flyway 또는 Liquibase | 스키마 변경 이력 관리 | + +**[검토 필요]** 규모가 "굉장히 크지는 않다"고 하셨으므로 Elasticsearch 같은 검색엔진과 PostGIS는 일단 배제하고 시작하는 것을 권장한다. 데이터가 수천 노드 이하라면 인메모리 처리로 충분하다. + +### 2.1 DB 선택 — RDB vs NoSQL vs 그래프 DB + +DB 후보를 명시적으로 비교하면 다음과 같다. + +| 후보 | 적합한 경우 | 본 프로젝트 평가 | +|------|-------------|------------------| +| **RDB (PostgreSQL)** | 관계 중심, 조인 多, 정합성 중요 | **적합.** 채택 | +| 문서형 NoSQL (MongoDB 등) | 문서마다 스키마가 제각각, 수평 확장 필요, 조인 적음 | 부적합 | +| 그래프 DB (Neo4j 등) | 수백만 노드 이상의 대규모 그래프 탐색 | 과잉 | + +이 도메인의 데이터는 **관계 중심**이다. `Place ↔ Building ↔ BuildingAlias`, `Node ↔ Edge`는 전형적인 외래키·조인 관계이고, 그래프 편집 시 외래키 제약과 트랜잭션이 정합성을 보장한다. NoSQL이 유리한 조건 — (a) 문서별 상이한 스키마, (b) 대용량·고쓰기 수평 확장, (c) 조인 거의 없음 — 은 캠퍼스 규모 + 관계 중심 도메인에서 셋 다 해당하지 않는다. + +"동적 스키마" 요구는 NoSQL의 근거가 될 수 있으나, PostgreSQL의 `JSONB`가 **"안정적 핵심은 컬럼, 가변 속성은 문서"** 라는 하이브리드를 단일 DB 안에서 제공하므로(5.2절 참조) NoSQL로 갈 필요가 없다. 그래프 DB 역시 노드가 수천 개 규모라면 그래프를 인메모리에 적재해 Dijkstra를 돌리는 것으로 충분하며, 별도 그래프 엔진의 운영 비용이 정당화되지 않는다. → **PostgreSQL 채택.** + +--- + +## 3. 도메인 모델 개요 + +모델은 크게 **검색/장소 영역**과 **그래프 영역**으로 나뉜다. 두 영역은 `Place.entranceNodeId`로 연결된다(장소를 검색하면 그 장소의 대표 노드로 길찾기를 수행). + +``` +[검색 영역] [그래프 영역] + Building ─< BuildingAlias Node ─< Edge + │ ▲ + └─< Place ──(entranceNodeId)──────┘ + │ + └─< SearchToken (자동완성 색인) +``` + +--- + +## 4. 검색어 자동완성 및 동의어 매핑 + +### 4.1 문제 정리 + +- `'공학관 501호'`, `'T동 501호'`, `'T501'`은 **하나의 레코드**다. +- 사용자가 `'T'`만 입력해도 `'T501'`, `'T401'` 등이 자동완성되어야 하고, 동의어인 `'공학관'`도 떠야 한다. +- 자동완성 결과에서 같은 레코드를 가리키는 여러 표기는 **하나로 합쳐져** 보여야 한다. +- 어떤 표기로 검색하든 최종 응답은 **단일 레코드**다. + +### 4.2 데이터 모델 — 정규 레코드 + 별칭(alias) + +핵심 아이디어: **정규(canonical) 레코드는 하나, 표기(alias)는 여러 개**. 표기는 두 계층에서 발생한다. + +1. **건물 계층 별칭** — `'공학관'` ≡ `'T동'` ≡ `'T'` +2. **장소 계층 식별자** — 건물 별칭 × 호실 번호의 조합으로 파생 + +``` +Building + id PK + code "T" -- 건물 코드 + display_name "공학관" -- 정규 표시명 + +BuildingAlias + id PK + building_id FK -> Building + alias "공학관" | "T동" | "T" | "공학" ... + +Place -- ★ 사용자에게 응답되는 정규 레코드 + id PK + building_id FK -> Building + floor 5 + room_number "501" + name "전자공학과 사무실" (nullable) -- 호실 자체의 고유명 + category ROOM | OFFICE | LECTURE_HALL | FACILITY ... + entrance_node_id FK -> Node -- 길찾기 시 이 장소의 대표 노드 + display_name "공학관 501호" -- 정규 표시명(파생 가능) + +SearchToken -- 자동완성 색인(파생 데이터, 빌드로 생성) + id PK + token 정규화된 검색 문자열 + place_id FK -> Place + weight 랭킹 가중치 +``` + +`SearchToken`은 사용자가 직접 입력하지 않는 **파생 테이블**이다. `Building`/`BuildingAlias`/`Place`가 진실의 원천(source of truth)이고, `SearchToken`은 이들로부터 빌드된다. + +### 4.3 검색 토큰 생성 규칙 + +`Place(공학관 501호, 건물 별칭 = {공학관, T동, T})` 에 대해 다음 토큰을 생성한다. + +| 패턴 | 예시 | +|------|------| +| 건물별칭 + 호실 + "호" | `공학관 501호`, `T동 501호`, `T 501호` | +| 건물별칭 + 호실 (공백 변형) | `공학관501호`, `t501`, `t 501` | +| 건물별칭 + 호실(번호만) | `공학관 501`, `T501` | +| 호실 단독 (가중치 ↓) | `501호`, `501` | +| 장소 고유명 | `전자공학과 사무실` (name 필드가 있을 때) | + +모든 토큰은 `place_id`를 가리키므로, 어떤 토큰이 매칭되든 동일 레코드로 귀결된다. 토큰 생성은 데이터 입력/수정 시 트리거하거나 배치로 재빌드한다. + +### 4.4 자동완성 자료구조 — 인메모리 Trie + +빠른 prefix 응답을 위해 애플리케이션 기동 시 `SearchToken`을 읽어 **Trie**를 메모리에 적재한다. + +- Trie의 각 노드/리프는 해당 prefix에 도달하는 `place_id` 집합을 누적한다. +- prefix 질의 시 서브트리의 `place_id`를 모아 **중복 제거 후** weight 순으로 정렬, 상위 N개 반환. +- 데이터 변경 시 Trie를 재빌드(이벤트 또는 주기적). 캠퍼스 규모면 재빌드 비용이 무시할 만하다. + +대안: PostgreSQL `LIKE 'prefix%'` + `text_pattern_ops` 인덱스, 또는 `pg_trgm`. 구현은 단순하나 동의어 합산·중복 제거 로직을 쿼리에 녹이기 번거롭다. **권장: Trie를 1차 경로로, DB는 진실의 원천 겸 폴백.** + +#### 4.4.1 동의어 처리 — Trie 방식과 Elasticsearch 방식의 차이 + +두 방식은 **동의어를 확장하는 시점(timing)이 다르다.** + +- **Elasticsearch**: *검색 시점*에 synonym filter가 질의어를 동의어로 확장한다. 동의어 사전만 등록하면 색인은 그대로 둔다. +- **본 설계(Trie)**: *색인 생성 시점*에 동의어를 미리 펼쳐 `SearchToken`으로 저장한다. `'공학관 501호'`, `'T동 501호'`, `'T501'`을 전부 토큰으로 만들어 모두 같은 `place_id`를 가리키게 한다(4.3절). + +결과적으로 **prefix 자동완성에 한해서는 동일한 사용자 경험**이 나온다. 다만 ES가 추가 구현 없이 제공하는 것 중 Trie에는 없는 것이 있다. + +| 기능 | Elasticsearch | Trie (본 설계) | +|------|---------------|----------------| +| prefix 자동완성 + 동의어 | O | O (색인 시점 확장) | +| 오타 보정(fuzzy) | O | 별도 구현 필요 | +| 형태소 분석 | O | 별도 구현 필요 | +| 중간 단어 매칭(`'501호'`로 검색) | O | prefix 본질상 어려움 | +| 랭킹 튜닝(BM25 등) | O | weight 수동 설계 | + +오버헤드 측면에서 정리하면: + +- **운영 오버헤드**: Trie가 훨씬 작다. 별도 클러스터·색인 동기화 파이프라인이 불필요하고 메모리만 사용한다. +- **개발 오버헤드**: 요구 기능 범위에 따라 갈린다. **단순 prefix 자동완성만**이면 Trie가 더 가볍다. 반면 fuzzy·형태소·중간 매칭까지 필요하면 ES 도입이 오히려 적은 비용이 된다. + +→ 따라서 **검색에 필요한 기능 범위를 먼저 확정**해야 선택이 명확해진다(9절 1번 항목). 1차 범위가 prefix 자동완성 + 동의어뿐이라면 Trie를 권장한다. + +### 4.5 중복 제거 + +자동완성 응답의 단위는 토큰이 아니라 **`place_id`**다. + +``` +prefix "T" 매칭 토큰: ["T501", "T동 501호", "T401", "T동 401호", ...] + → place_id 로 환원: [101, 101, 102, 102, ...] + → distinct: [101, 102, ...] + → 각 place_id 의 display_name 으로 응답 +``` + +따라서 `'T501'`과 `'T동 501호'`가 동시에 매칭돼도 사용자에게는 `공학관 501호` 한 줄만 보인다. + +### 4.6 입력 정규화 (한글 처리 포함) + +토큰 저장 시와 질의 시 **동일한 정규화 함수**를 거쳐야 매칭이 일관된다. + +- 유니코드 NFC 정규화 +- 영문 소문자화 (`T` → `t`) +- 공백 제거 또는 단일화 (`T 501` ≡ `T501`) +- `호`, `동` 등 접미사 변형 흡수 + +**[검토 필요]** 초성 검색(`ㄱㅎㄱ` → `공학관`)을 지원할지. 사용자 경험은 좋아지나 토큰/매칭 로직이 늘어난다. 1차 범위에서 제외하고 후속 개선으로 두는 것을 권장. + +### 4.7 검색 API 초안 + +``` +GET /api/search/autocomplete?q=T&limit=10 +→ 200 +{ + "suggestions": [ + { "placeId": 101, "displayName": "공학관 501호", "category": "OFFICE" }, + { "placeId": 102, "displayName": "공학관 401호", "category": "ROOM" } + ] +} + +GET /api/places/{placeId} +→ 200 단일 레코드 상세 +``` + +--- + +## 5. 길찾기 + +### 5.1 그래프 모델 + +``` +Node + id PK + lat, lng double -- 모든 노드는 geocode 보유 + floor int -- 실내 층수 (실외는 0 또는 null) + building_id FK -> Building (nullable, 실외면 null) + is_indoor boolean + node_type enum -- 안정적인 상위 분류 (5.2 참조) + properties JSONB -- 동적 속성 (5.2 참조) + +Edge + id PK + from_node_id FK -> Node + to_node_id FK -> Node + weight double -- 거리(또는 소요시간) 기반 + edge_type enum -- CORRIDOR | OUTDOOR_PATH | STAIR + -- | ELEVATOR | DOOR | RAMP + bidirectional boolean + properties JSONB +``` + +층 간 이동은 `STAIR`/`ELEVATOR` 타입의 Edge가 `floor=N` 노드와 `floor=N+1` 노드를 연결하는 식으로 모델링한다. 실내/실외 경계는 `DOOR` 타입 Edge 또는 `is_indoor`가 바뀌는 인접 노드 쌍으로 표현한다. + +### 5.2 노드 성격 표현과 동적 스키마 — JSONB 하이브리드 + +요구사항: "각 노드의 성격이 스키마에 나타나야 하고, 개발 중 스키마가 동적으로 변할 수 있다." + +순수 enum 컬럼은 변경 때마다 마이그레이션이 필요해 동적 변화에 약하고, 순수 JSONB는 핵심 질의가 불편하다. **하이브리드**를 권장한다. + +- **안정적 핵심**은 컬럼으로: `lat`, `lng`, `floor`, `building_id`, `is_indoor`, `node_type`. + - `node_type`은 거의 바뀌지 않는 상위 분류만: `WAYPOINT`(단순 경유), `JUNCTION`(분기), `DOOR`(출입구), `VERTICAL_LINK`(계단/엘리베이터 접점), `POI`(목적지 후보). +- **자주 바뀌는 세부 속성**은 `properties` JSONB로: `isStair`, `isElevator`, `isEntrance`, `landmark`, `accessibility` 등. 새 속성 추가 시 마이그레이션 불필요. + +Hibernate는 `@JdbcTypeCode(SqlTypes.JSON)` 또는 hypersistence-utils로 JSONB를 매핑할 수 있다. + +**[검토 필요]** `node_type` enum의 초기 값 목록을 PLAN.md에서 확정할 것. 어디까지를 안정 컬럼으로, 어디부터를 JSONB로 둘지의 경계가 핵심 설계 결정이다. + +### 5.3 다익스트라 + +- 캠퍼스 규모상 그래프를 **기동 시 메모리에 인접 리스트로 적재**하고, DB 변경 시 갱신. +- 표준 우선순위 큐 기반 Dijkstra. `weight`는 거리(미터) 기반을 기본으로 하되 가중치 정책을 둔다. + - 계단 패널티, 실외 경로 선호/비선호 등은 `edge_type`별 배수로 조정. + - **접근성 모드**: `STAIR` 간선을 제외하면 휠체어 경로가 된다. + +``` +입력: startNodeId, endNodeId, (선택) mode = NORMAL | ACCESSIBLE +출력: Node 의 순서 리스트 (경로상 전체 노드) +``` + +### 5.4 경로 지시(instruction) 추출 — 2단계 처리 + +Dijkstra의 결과는 **경로상 모든 노드**다. 여기서 곧바로 응답하지 않고, **지시 추출(post-processing)** 단계를 거쳐 의미 있는 지점만 "step"으로 묶는다. + +> 핵심 원칙: 곡선 경로는 여러 노드·간선을 포함하지만 **하나의 step**으로 표현한다. 즉 지시 추출은 노드열에 대한 일종의 run-length 압축이다. + +**지시가 발생하는 이벤트** + +| 이벤트 | 트리거 조건 | 생성되는 지시 예 | +|--------|-------------|------------------| +| 출발 | 경로 시작 | "출발합니다" | +| 회전 | 연속 간선의 방위각 변화가 임계치 초과 | "좌회전 / 우회전 하세요" | +| 실내↔실외 전환 | 인접 노드의 `is_indoor` 변화 (또는 `DOOR` 간선) | "건물로 들어가세요 / 나가세요" | +| 층 변경 | 인접 노드의 `floor` 변화 | "6층까지 계단으로 올라가세요" | +| 도착 | 경로 끝 (목적지 노드) | "도착했습니다" | + +층 변경 지시에서 "계단/엘리베이터" 구분은 해당 구간 `Edge`의 `edge_type`으로 결정한다. + +**추출 알고리즘 (의사코드)** + +``` +steps = [] +current = 새 step (시작 노드) +for i in 1 .. path.length-1: + event = 노드 path[i] 에서 발생하는 이벤트 판정 + (회전? 층변경? 실내외전환? 도착?) + if event != NONE: + current.endIndex = i + steps.add(current) + current = 새 step (i, type = event) +steps.add(current) +``` + +각 step은 `path` 상의 **노드 인덱스 구간 [startIndex, endIndex]**을 보유한다. 직진 구간 내부에 노드가 100개여도 step은 1개다. + +### 5.5 회전 감지와 곡선 처리 + +회전은 연속한 두 간선의 **방위각(bearing) 차이**로 판정한다. + +``` +θ1 = bearing(path[i-1] → path[i]) +θ2 = bearing(path[i] → path[i+1]) +Δ = normalize(θ2 - θ1) // (-180°, 180°] +Δ > 0 → 우회전 / Δ < 0 → 좌회전 (좌표계 기준에 따라 조정) +``` + +- **직진**: `|Δ| ≤ ε` (예: 15°) → 지시 없음, 직진 step에 흡수. +- **회전**: `|Δ| ≥ τ` (예: 35°) → 회전 지시 생성. +- **완만한 곡선**: 곡선은 여러 노드로 그려지지만 각 노드의 `|Δ|`가 작다(< τ). 따라서 단일 노드 임계치만 쓰면 곡선은 자연히 회전 지시를 만들지 않고 직진으로 병합된다. → **1차 구현으로 권장.** +- **개선안(선택)**: 곡선이 급해서 중간 크기의 Δ가 연속될 경우, 슬라이딩 윈도우로 누적각을 계산해 윈도우 중심에 회전 지시 하나만 생성. 1차에서는 보류. + +**[검토 필요]** ε, τ 임계값은 실제 노드 배치 밀도에 따라 튜닝이 필요하다. PLAN.md에서 초기값을 정하고 추후 조정. + +### 5.6 Response 설계 + +응답은 **두 계층**으로 구성한다. 지도 렌더링용 전체 좌표와, 화면 안내용 step 목록을 분리한다. + +```jsonc +GET /api/route?from=PLACE_101&to=PLACE_250&mode=NORMAL +→ 200 +{ + "summary": { "distanceMeters": 320, "estimatedSeconds": 280, "floorChanges": 1 }, + + // (1) 지도에 경로를 그리기 위한 전체 노드열 — 곡선 포함 모든 점 + "polyline": [ + { "lat": 37.0001, "lng": 127.0001, "floor": 1, "indoor": false }, + { "lat": 37.0002, "lng": 127.0002, "floor": 1, "indoor": false } + // ... 모든 노드 + ], + + // (2) 사용자에게 보여줄 지시 목록 — 의미 있는 지점만 + "steps": [ + { + "type": "DEPART", + "instruction": "공학관 정문에서 출발합니다", + "distanceMeters": 0, + "location": { "lat": 37.0001, "lng": 127.0001, "floor": 1 }, + "polylineRange": [0, 0] + }, + { + "type": "TURN_LEFT", + "instruction": "좌회전 후 직진하세요", + "distanceMeters": 80, + "location": { "lat": 37.0005, "lng": 127.0004, "floor": 1 }, + "polylineRange": [0, 12] // 이 step 이 덮는 노드 구간 (곡선 흡수) + }, + { + "type": "ENTER_BUILDING", + "instruction": "공학관 안으로 들어가세요", + "location": { "lat": 37.0008, "lng": 127.0007, "floor": 1 }, + "polylineRange": [12, 13] + }, + { + "type": "FLOOR_CHANGE", + "instruction": "엘리베이터로 5층까지 올라가세요", + "meta": { "fromFloor": 1, "toFloor": 5, "transport": "ELEVATOR" }, + "polylineRange": [13, 14] + }, + { + "type": "ARRIVE", + "instruction": "공학관 501호에 도착했습니다", + "location": { "lat": 37.0009, "lng": 127.0008, "floor": 5 }, + "polylineRange": [14, 14] + } + ] +} +``` + +`polylineRange`가 곡선 문제를 해결한다. 곡선의 모든 점은 `polyline`에 남아 지도에 매끄럽게 그려지고, `steps`에서는 그 구간을 인덱스 범위로만 참조하는 **단일 지시**가 된다. + +**step `type` 후보(초안)**: `DEPART`, `STRAIGHT`, `TURN_LEFT`, `TURN_RIGHT`, `ENTER_BUILDING`, `EXIT_BUILDING`, `FLOOR_CHANGE`, `ARRIVE`. + +### 5.7 길찾기 API 초안 + +``` +GET /api/route?from={placeId|nodeId}&to={placeId|nodeId}&mode=NORMAL|ACCESSIBLE +``` + +`placeId`로 들어오면 `Place.entranceNodeId`로 환원한 뒤 노드-투-노드 Dijkstra를 수행한다. 사용자는 장소를 고르지만 내부적으로는 그래프 노드로 길을 찾는다. + +--- + +## 6. 검색 영역과 그래프 영역의 연결 + +- 사용자는 **장소(Place)**를 검색/선택한다. +- 각 `Place`는 그래프 상의 대표 노드 `entranceNodeId`(보통 그 호실의 출입문 노드)를 가진다. +- 길찾기 요청은 `Place → Node`로 변환되어 처리된다. + +이 분리 덕분에 검색 모델과 그래프 모델을 독립적으로 진화시킬 수 있다. + +--- + +## 7. 데이터 입력(authoring) + +노드·간선·장소 데이터를 어떻게 채워 넣을지가 별도 과제다. 후보: + +- 관리자용 지도 편집 화면(노드 찍기, 간선 잇기, 속성 입력) +- GeoJSON 등 외부 포맷 임포트 +- 초기에는 SQL/CSV 시드 + Flyway + +**[검토 필요]** 1차 범위에 편집 도구를 포함할지, 시드 데이터로 시작할지 결정 필요. + +--- + +## 8. 성능 및 기타 고려 + +- 그래프와 Trie는 인메모리 적재 → Dijkstra와 자동완성 모두 ms 단위 응답 기대. +- 데이터 변경 시 인메모리 구조 갱신 전략 필요(이벤트 기반 재빌드 vs 주기적). +- `SearchToken`·`polyline` 등 파생 데이터의 재생성 책임을 한 곳에 모을 것(서비스 계층). + +--- + +## 9. 결정이 필요한 사항 (PLAN.md에서 확정) + +1. **검색엔진 범위** — 인메모리 Trie로 충분한지, Redis/Elasticsearch 도입 여부. 결정의 기준은 필요 기능 범위다(4.4.1절 참조): prefix 자동완성 + 동의어뿐이면 Trie, fuzzy·형태소·중간 매칭이 필요하면 ES. +2. **초성 검색** 지원 여부 (1차 제외 권장). +3. **`node_type` enum 초기 목록**과 안정 컬럼 / JSONB 경계. +4. **층 간 연결 모델링** 방식 확정 (`VERTICAL_LINK` 노드 + `STAIR`/`ELEVATOR` 간선). +5. **가중치 정책** — 거리 기반인가 소요시간 기반인가, 계단 패널티 수치. +6. **접근성 라우팅(ACCESSIBLE 모드)** 1차 포함 여부. +7. **회전 임계값 ε, τ** 초기값. +8. **좌표계 / PostGIS** 도입 여부. +9. **데이터 입력 도구** 1차 범위 포함 여부. +10. **별칭 범위** — 건물 별칭 외에 학과명·시설명까지 alias로 둘지. +11. **다국어(영문 명칭)** 지원 여부. + +--- + +## 10. 권장 진행 순서 + +1. 도메인 모델 확정 (`Building` / `Place` / `Node` / `Edge` 스키마, JSONB 경계). +2. 시드 데이터로 작은 그래프 + 장소 셋 구성. +3. Dijkstra → 노드열 출력까지 구현·검증. +4. 지시 추출(step 변환) + Response 설계 구현. +5. `SearchToken` 생성 로직 + Trie 자동완성 구현. +6. 정규화/중복 제거 엣지 케이스 보강. +7. (선택) 접근성 모드, 데이터 편집 도구. \ No newline at end of file