계정 정보 다이얼로그 추가 및 전체 목록 페치 개선

This commit is contained in:
JiWoong Sul
2025-10-22 01:05:47 +09:00
parent 6b58effc83
commit f4dc83d441
44 changed files with 1636 additions and 362 deletions

View File

@@ -1,79 +1,97 @@
# 백엔드 수정 요청서 (2025-10-16 갱신)
# 백엔드 수정 요청서 (2025-10-20 갱신)
## 1. 배경
- Flutter 프런트엔드(`superport_v2`)와 최신 백엔드(`superport_api_v2`) 사이 계약을 점검한 결과, 다수의 엔드포인트가 미구현이거나 응답 스키마가 상이해 실사용 플로우를 마무리할 수 없다.
- 프런트는 Clean Architecture 구조 및 DTO를 백엔드 스펙(v4)에 맞춰 구현한 상태이며, 실연동 전까지 계약 정합성을 확보해야 한다.
- 본 문서는 백엔드 측 추가 개발/수정을 요청하기 위한 정리 문서이다.
- 프런트엔드는 `.env.development`에서 `API_BASE_URL=http://43.201.34.104:8080`을 사용해 실서버 로그인 API를 호출한다.
- 동일 API를 사용하는 운영 환경에서는 CORS 문제가 없지만, 로컬 웹 개발 시 브라우저가 `http://localhost:<port>` 오리진으로 사전 요청(preflight)을 보내면서 403 대신 CORS 차단이 발생한다.
- 프런트(`superport_v2`)와 백엔드(`superport_api_v2`) 양쪽 구현을 재검토한 결과, 로그인 계약은 일치하나 실서버에서 CORS 응답 헤더가 전혀 내려오지 않는 것으로 확인되었다.
- 로컬 개발 및 QA가 모두 실서버를 바라보고 있어, 백엔드에서 CORS 허용 정책을 명시적으로 정비해야 한다.
## 2. 주요 이슈 요약
- 로그인 및 대시보드 핵심 엔드포인트(`/api/v1/auth/**`, `/api/v1/dashboard/summary`)가 존재하지 않아 애플리케이션 초기 진입이 불가능하다.
- 보고서 다운로드 화면이 호출하는 `/api/v1/reports/**` 엔드포인트가 미구현 상태다.
- 결재·재고 API 응답 키가 프런트 DTO와 불일치하여 승인 상태, 요청자, 제품/벤더 정보 등이 전부 기본값으로 표시되며, 단계/상태 전환 이후 최신 데이터를 확보할 수 없다.
- 결재 단계(`approval-steps`) API가 단계 CRUD/액션 수행 후 적절한 본문을 반환하지 않고, 목록 필터(승인자·상태·검색)도 지원하지 않는다.
- 그룹-메뉴 권한 API가 라우팅 정보를 제공하지 않고, 삭제 항목 조회 파라미터가 프런트와 불일치해 권한 동기화가 깨진다.
## 2. 현상 및 재현 절차
- 브라우저 콘솔 오류:
```
Access to XMLHttpRequest at 'http://43.201.34.104:8080/api/v1/auth/login' from origin 'http://localhost:50408'
has been blocked by CORS policy: Response to preflight request doesn't pass access control check:
No 'Access-Control-Allow-Origin' header is present on the requested resource.
```
- curl 재현(사전 요청):
## 3. 상세 요청
```bash
curl -i -X OPTIONS \
http://43.201.34.104:8080/api/v1/auth/login \
-H 'Origin: http://localhost:50408' \
-H 'Access-Control-Request-Method: POST'
```
### 3.1 로그인/세션 및 대시보드 API 구현
- 엔드포인트
- `POST /api/v1/auth/login`: `identifier`, `password`, `remember_me`(bool) 입력을 받아 `{ "data": { "access_token", "refresh_token", "expires_at", "user", "permissions" } }` 구조를 반환해야 한다. `user` 객체는 `{ id, name, employee_no, email, primary_group { id, name } }` 필드를 포함하고, `permissions``resource``actions[]`(소문자 문자열)로 구성된다.
- `POST /api/v1/auth/refresh`: `refresh_token`으로 세션을 갱신하며 응답 스키마는 로그인과 동일하다.
- `GET /api/v1/dashboard/summary`: `{ "data": { "generated_at", "kpis": [], "recent_transactions": [], "pending_approvals": [] } }` 형태로 내려 KPI 카드, 최근 전표, 결재 대기 목록을 채울 수 있어야 한다.
- 요구 사항
- `kpis[]` 항목은 `{ key, label, value, trend_label, delta }` 필드를 제공해 프런트 차트 증감률을 계산할 수 있도록 한다.
- `recent_transactions[]``{ transaction_no, transaction_date, transaction_type, status_name, created_by }` 문자열 필드로 구성한다.
- `pending_approvals[]``{ approval_no, title, step_summary, requested_at }`을 포함하며 `requested_at`은 ISO8601 UTC 문자열로 반환한다.
- 로그인 실패 시 `invalid credentials`, 비활성 계정 접근 시 `account is inactive`, 갱신 토큰 만료는 `token expired`, 재사용·서명 오류는 `invalid token` 메시지를 반환해 프런트 알림 문구와 동일하게 맞춘다.
- 인증 실패(401), 세션 만료·권한 거부(403) 시 `{ "error": { "code": <http-status>, "message": "...", "details": [...] } }` 규격을 사용하고, 만료/재사용 토큰별 메시지를 문서화한다.
실제 응답: `HTTP/1.1 404 Not Found` + 헤더 없음 → CORS 미적용.
### 3.2 보고서 Export API 구현
- 엔드포인트
- `GET /api/v1/reports/transactions/export`
- `GET /api/v1/reports/approvals/export`
- 요구 사항
- 공통 쿼리: `from`, `to`, `format(xlsx|pdf)`, `transaction_status_id`, `approval_status_id`, `requested_by_id`.
- 트랜잭션 보고서 `from`·`to` 값은 `yyyy-MM-dd` 형식, 결재 보고서는 ISO8601 UTC 타임스탬프를 지원한다.
- 응답은 기본적으로 파일 스트림(`Content-Type`은 선택한 포맷의 MIME, `Content-Disposition: attachment; filename="<name>"`)이며, 스토리지 연계 시 `{ "data": { "download_url", "filename", "mime_type", "expires_at" } }` 메타데이터로 대체할 수 있다.
- `format=pdf` 요청도 정상 처리하고, 지원 불가 시 명확한 4xx 코드·메시지를 반환하도록 문서화한다.
- 모든 다운로드 요청에 대해 접근 권한·감사 로그 정책을 명시한다.
- 실제 요청도 동일하게 헤더가 비어 있음:
### 3.3 결재/재고 응답 스키마 정합성
- 결재 목록·단건 응답은 프런트 도메인 모델과 동일한 키를 사용한다.
- 단건 응답은 `{ "data": { ... } }` 혹은 `{ "data": { "approval": { ... } } }` 구조를 유지하고, `approval_no`, `transaction_no`, `status { id, name, color }`, `requester { id, employee_no, name }`, `current_step { id, step_order, status { id, name, is_blocking_next, is_terminal }, approver { id, employee_no, name }, assigned_at, decided_at, note }`, `steps[]`, `histories[]`, `created_at`, `updated_at`을 포함한다.
- 모든 단계·이력 항목은 `status` 키로 정규화하고(`step_status` 금지), `histories[]`에는 `action { id, name }`, `from_status`, `to_status`, `approver`, `action_at`, `note`를 내려준다.
- `approval` 객체에는 필요 시 `transaction { id, transaction_no }`, `template_name` 등을 함께 포함해 단계 목록에서도 동일 데이터를 재사용할 수 있도록 한다.
- 재고 트랜잭션 응답은 중첩 객체 구조를 보장한다.
- 헤더: `transaction_type { id, name }`, `transaction_status { id, name }`, `warehouse { id, warehouse_code, warehouse_name, zipcode { ... } }`, `created_by { id, employee_no, employee_name }`, `expected_return_date`.
- 라인: `lines[].product { id, product_code, product_name, vendor { id, vendor_name }, uom { id, uom_name } }`, `quantity`, `unit_price`, `note`.
- 고객: `customers[].customer { id, customer_code, customer_name }`, `note`.
- 결재 요약: `approval { id, approval_no, status { id, name, is_blocking_next } }`.
- `quantity`, `unit_price`는 BigDecimal 직렬화 그대로 전달하되 `null`은 키를 생략하지 말 것(프런트 DTO가 숫자·문자열 모두를 파싱한다).
- `warehouse.zipcode`는 최소 `zipcode`, `road_name`을 포함하고 추가 주소 필드가 있으면 그대로 노출한다.
- 상태 전환(Submit/Approve/Reject/Cancel/Complete) 응답은 최신 `data.transaction` 전체 또는 최소한 `data.transaction_status`, `data.updated_at`, `data.approval`을 포함해 UI가 즉시 갱신되도록 한다.
```bash
curl -i -X POST \
http://43.201.34.104:8080/api/v1/auth/login \
-H 'Origin: http://localhost:50408' \
-H 'Content-Type: application/json' \
-d '{"identifier":"test","password":"test"}'
```
### 3.4 결재 단계/행위 API 정합성
- `GET /api/v1/approval-steps``approver_id`, `approval_id`, `status_id`(또는 `step_status_id`), `q`(결재번호·승인자 키워드)를 지원하고, 항상 `include=approval,approver,status` 형태의 확장을 처리한다. 응답 항목에는 `approval { id, approval_no, transaction_no, template_name }`이 포함되어야 한다.
- 단계 생성·수정·복구 응답은 `{ "data": { ... } }` 형태로 단계 요약을 반환하고, 단계 행위·일괄 배정 응답은 최신 결재 데이터를 `data.approval` 또는 `data.approval.steps`/`data.histories`에 담아 돌려준다.
- 모든 단계·행위 응답에서 단계 상태 키는 `status`로 통일하고, `step_status_id`는 요청/응답에서 보조 필드로만 유지한다.
응답: `401 Unauthorized` 본문은 내려오지만 `Access-Control-Allow-Origin` 헤더가 없음.
### 3.5 그룹-메뉴 권한 응답 확장
- `GET /api/v1/group-menu-permissions` 및 단건 응답의 `menu` 객체에 `route_path`(가능하면 `path` 보조 필드 포함)를 항상 채운다.
- `deleted=true`(또는 `include_deleted=true`) 파라미터를 허용해 소프트 삭제 항목을 조회할 수 있게 하고, 응답 항목에 `is_deleted`를 노출한다.
- `include=group,menu` 확장을 공식화해 그룹/메뉴 요약을 한 번에 받을 수 있도록 한다.
## 3. 프런트/백엔드 로그인 계약 점검
- **프런트 요청 구조**
- 경로: `POST ${ApiRoutes.apiV1}/auth/login` → `/api/v1/auth/login`
- 페이로드: `{ identifier, password, remember_me }` (`lib/features/auth/data/repositories/auth_repository_remote.dart:18`)
- 응답 파싱: `{ data: { access_token, refresh_token, expires_at, user, permissions[] } }`
(`lib/features/auth/data/dtos/auth_session_dto.dart`)
- **백엔드 구현**
- 라우트: `#[post("/login")]` (`backend/src/api/v1/auth.rs:17`) → `web::scope("/api/v1")`
- 요청 모델: `LoginRequest { identifier: String, password: String, remember_me: bool }`
(`backend/src/domain/auth.rs:9`)
- 응답 모델: `AuthSessionResponse { data: AuthSessionData { ... } }`
- **계약 비교 결론**
- 필드 명/자료형 모두 일치, remember_me 기본값 및 데이터 매핑도 호환.
- 로그인 자체는 401/403 흐름이 정상이나, 브라우저 오리진이 차단되어 요청이 전달되지 못함.
### 3.6 결재 생성/수정 응답 보강
- `POST /api/v1/approvals`/`PATCH /api/v1/approvals/{id}` 응답은 `{ "data": { "approval": { ... } } }` 형태로 최신 결재 요약과 `steps[]`, 필요 시 `histories[]`를 포함해야 한다.
- `approval_status_id`가 생략되면 자동으로 기본 대기 상태를 설정하는 규칙을 명시하고, `approval_no` 포맷·중복 검증 실패 시 상세 에러 메시지를 반환한다.
## 4. 근본 원인 분석
- 백엔드 `App::new()` 정의는 `build_cors(&config.cors)` 미들웨어를 `wrap`하고
`default_service`에서 `OPTIONS` 가드를 204로 처리하도록 구현되어 있음 (`backend/src/app/mod.rs:36-132`).
- `config/default.toml`의 `[cors]` 설정은 `allowed_origins = []`로 전체 허용이 기본이지만,
실제 운영 환경에서는 `APP_ENV`에 대응하는 설정 또는 환경 변수로 제한된 오리진만 등록한 것으로 추정된다.
- 하지만 허용 목록에서 로컬 호스트가 빠진 경우라면 CORS 미들웨어가 `403`을 반환해야 하는데,
현재는 단순 404/401 응답으로 보아 **미들웨어가 동작하지 않거나, 리버스 프록시/로드밸런서 구간에서 CORS 헤더가 삭제**되고 있다.
- 결과적으로 브라우저는 `Access-Control-Allow-Origin`을 받지 못하고 사전 요청 단계에서 차단된다.
### 3.7 응답/에러 문서화 및 테스트
- `stock_approval_system_api_v4.md`에 변경된 요청/응답 예시를 모두 반영하고, 인증/대시보드/결재 단계/보고서 섹션을 최신 상태로 유지한다.
- 회귀 테스트(`cargo test`, 통합 시나리오 스크립트)에 신규 계약을 검증하는 케이스를 추가한다.
## 5. 요청 사항
1. **백엔드 Actix CORS 설정 재점검**
- `build_cors`가 실제 배포 바이너리에도 적용되는지 확인하고, 필요한 경우 `Cors::default()` 대신
`Cors::permissive()` 또는 `allowed_origin_fn` 로깅을 추가해 런타임에서 허용 여부를 추적한다.
- `supports_credentials()`를 유지하면서도 최소 `http://localhost` 기반 개발 포트 전체를 허용하도록
`allowed_origin_fn`에서 와일드카드 검사를 추가하거나, 설정 파일에 와일드카드 표기를 허용하도록 개선한다.
```rust
.allowed_origin_fn(move |origin, _| {
if allow_all {
return true;
}
if origin.as_bytes().starts_with(b"http://localhost") {
return true;
}
allowed_list.iter().any(|allowed| allowed == origin)
})
```
- 운영 배포용 설정(`APP_ENV=production`)에도 `cors.allowed_origins`에
`https://{prod-domain}` + `http://localhost` (또는 사내 VPN 도메인)을 명시한다.
2. **리버스 프록시/로드밸런서 검증**
- Nginx/ALB 등 중간 계층이 `OPTIONS` 메서드를 백엔드로 전달하는지 확인하고, 차단 시 `proxy_set_header Access-Control-Allow-Origin` 등을 설정한다.
- 모든 사전 요청이 최소 `204` 혹은 `200`과 함께 `Access-Control-Allow-Origin`, `Access-Control-Allow-Methods`, `Access-Control-Allow-Headers`를 반환하도록 보장한다.
3. **로그인 핸들러 응답 헤더 확인**
- 인증 성공/실패 여부와 관계없이 `Access-Control-Allow-Origin`이 반드시 포함되도록 통합 테스트를 추가한다.
- 예시: `cargo test cors_allows_login_origin` 형태의 통합 테스트에서 `Origin: http://localhost:50408` 헤더를 넣고 응답 헤더를 검증.
## 4. 수용 기준
- 상기 엔드포인트 및 스키마 변경이 구현되고, 요청/응답이 문서와 일치해야 한다.
- 기존 204 응답은 JSON 응답으로 교체되고, 키(`data.approval`, `data.transaction` 등)가 프런트 기대와 동일해야 한다.
- `cargo fmt`, `cargo check`, `cargo test` 및 CI 파이프라인이 통과한다.
## 6. 검증 및 수용 기준
- `curl -X OPTIONS` 및 `curl -X POST` 재현 시 `Access-Control-Allow-Origin: http://localhost:50408` 헤더가 내려오고 브라우저 CORS 에러가 사라질 것.
- `flutter run -d chrome --web-port 50408`에서 로그인 성공/실패 흐름이 정상 동작.
- 백엔드 `cargo fmt`, `cargo test` 모두 통과.
- `stock_approval_system_api_v4.md` 또는 운영 문서에 허용 오리진 정책 및 설정 방법을 명시.
## 5. 후속 조치
- 백엔드 담당자가 개발 일정·우선순위를 산출해 프런트 팀과 공유.
- 구현 완료 후 샌드박스 환경에서 계약 검증 → 프런트엔드 실연동 검증 착수.
## 7. 후속 조치
- 백엔드 담당자가 실제 배포 서버의 환경 변수/리버스 프록시 설정을 확인 후 조치 내용을 공유.
- 수정 배포 이후 프런트 팀이 실서버 연결 테스트를 수행하고, 필요한 경우 추가 허용 오리진 목록을 요청.

View File

@@ -9,7 +9,7 @@
## 0. 구현 현황 요약 (2025-09-18 기준)
- 마스터 데이터: `/vendors`, `/uoms`, `/transaction-types`, `/transaction-statuses`, `/approval-statuses`, `/approval-actions`, `/warehouses`, `/customers`, `/products`, `/employees`, `/groups`, `/menus`, `/group-menu-permissions`, `/zipcodes`
- 각 자원은 `/api/v1/<resource>` 패턴을 따르며, 목록 필터·페이지네이션·`include` 확장을 지원한다.
- 그룹 권한은 `/api/v1/group-menu-permissions``/api/v1/groups/{id}/permissions` 일괄 갱신 엔드포인트로 관리한다. `group-menu-permissions` 응답의 `menu` 객체에는 `route_path`가 포함되며, `include=group` 쿼리로 그룹 요약을 함께 받을 수 있다.
- 그룹 권한은 `/api/v1/group-menu-permissions``/api/v1/groups/{id}/permissions` 일괄 갱신 엔드포인트로 관리한다. `group-menu-permissions` 응답의 `menu` 객체에는 `route_path`와 동일 값을 가진 `path`가 포함되며 각 항목은 `is_deleted`를 노출한다. `include=group,menu` 확장과 `include_deleted=true` 파라미터로 삭제 권한을 함께 조회할 수 있다.
- 우편번호 검색 `/api/v1/zipcodes`는 부분 일치 검색(`q`, `zipcode`, `road_name`)과 단건 조회를 제공한다.
---
@@ -17,6 +17,7 @@
## 1. 공통 규칙
- **URI 규칙:** 복수형 리소스 명 사용. 기본 경로 예) `/api/v1/vendors`.
- **표준 응답 구조:** 목록은 `{ items: [], page, page_size, total }`, 단건은 `{ data: { ... } }`.
- **헬스 체크:** `GET /health``{ status, build_version, error? }` 구조를 반환하며 `build_version` 값은 `config/default.toml``[app].build_version`에서 로딩된다. 원격 배포 시 `script/deploy_remote.sh`가 배포 아카이브 파일명에서 버전을 추출해 해당 값을 갱신한다.
- **시간대:** 모든 날짜·시간은 ISO8601 UTC 문자열.
- **소프트 삭제:** `DELETE /{res}/{id}` 호출 시 서버는 `is_deleted=true`, `is_active=false`로 처리하고 응답 바디는 `{ data: { id, deleted_at } }` 형식을 사용.
- **복구:** `POST /{res}/{id}/restore`.
@@ -34,6 +35,7 @@
- `409 CONFLICT` — 유니크 제약, 결재 단계 상태 충돌.
- `422 UNPROCESSABLE_ENTITY` — 비즈니스 규칙 위반(출고 고객 누락, blocking 상태 전이 등).
- 에러 응답 예: `{ "error": { "code": 422, "message": "출고 트랜잭션에는 고객이 최소 1건 필요합니다.", "details": [...] } }`.
- **CORS 정책:** 서버는 `config/default.toml``[cors]` 설정을 사용해 허용 오리진을 제어한다. `allowed_origins`가 비어 있으면 모든 오리진을 허용하고, 값에 `http://localhost` 또는 `https://web.example.com:*`처럼 포트 와일드카드(`:*`)를 포함하면 동적 포트 환경에서도 `Access-Control-Allow-Origin`이 요청 오리진과 동일하게 반환된다. 허용 오리진에 일치하지 않으면 `400 BAD_REQUEST`가 응답된다.
---
@@ -60,6 +62,7 @@
"total": 1
}
```
- `delta` 값은 전일 대비 증감률(비율)로 반환되며 `1.0`은 100% 증가, `-0.5`는 50% 감소를 의미한다. 값이 계산되지 않는 KPI는 `delta`를 생략한다.
### 2.2 단건 조회
`GET /{type}/{id}`
@@ -129,6 +132,8 @@
## 3. 마스터 데이터 API
리소스: `/vendors`, `/warehouses`, `/customers`, `/employees`, `/products`, `/menus`, `/groups`, `/zipcodes`
> 기본 정렬: 별도 `sort` 파라미터가 없으면 항상 `id` 오름차순으로 응답을 정렬한다. (`order` 기본값도 `asc`)
### 3.1 목록 조회
`GET /vendors?page=1&q=한빛`
```json
@@ -1120,6 +1125,7 @@
"approval_id": 5001,
"step_order": 1,
"approver_id": 21,
"status_id": 1,
"step_status_id": 1,
"status": {
"id": 1,
@@ -1137,6 +1143,7 @@
"approval_id": 5001,
"step_order": 2,
"approver_id": 34,
"status_id": 1,
"step_status_id": 1,
"status": {
"id": 1,
@@ -1152,6 +1159,7 @@
],
"approval": {
"id": 5001,
"transaction_no": "TXN-2025-0001",
"status": {
"id": 1,
"name": "대기",
@@ -1199,6 +1207,7 @@
"approval_id": 5001,
"step_order": 1,
"approver_id": 21,
"status_id": 1,
"step_status_id": 1,
"status": {
"id": 1,
@@ -1216,6 +1225,7 @@
"approval_id": 5001,
"step_order": 2,
"approver_id": 35,
"status_id": 1,
"step_status_id": 1,
"status": {
"id": 1,
@@ -1231,6 +1241,7 @@
],
"approval": {
"id": 5001,
"transaction_no": "TXN-2025-0001",
"status": {
"id": 1,
"name": "대기",
@@ -1263,6 +1274,7 @@
"data": {
"approval": {
"id": 5001,
"transaction_no": "TXN-2025-0001",
"status": {
"id": 2,
"name": "진행중",
@@ -1317,6 +1329,7 @@
"approval_id": 5001,
"step_order": 1,
"approver_id": 21,
"status_id": 2,
"step_status_id": 2,
"status": {
"id": 2,
@@ -1485,17 +1498,17 @@
### 5.10 단계 개별 CRUD
- `GET /approval-steps?approval_id=5001&include=approval,approver,status``{ items: [], page, page_size, total }` 형태로 반환하며, 각 항목은 `approval`, `approver`, `status` 서브 오브젝트를 선택적으로 포함한다.
- `GET /approval-steps/7001?include=approval,approver,status``{ data: { ... } }`.
- `POST /approval-steps` → 단일 단계를 생성하고 `{ data: { ... } }` 형태로 생성된 요약을 반환한다. `step_status_id`를 생략하면 자동으로 `대기` 상태가 지정된다.
- `POST /approval-steps` → 단일 단계를 생성하고 `{ data: { ... } }` 형태로 생성된 요약을 반환한다. `status_id`(구 버전 호환용 `step_status_id`)를 생략하면 자동으로 `대기` 상태가 지정된다.
- `PATCH /approval-steps/{id}` → 갱신된 단계 요약을 반환한다.
- `DELETE /approval-steps/{id}``{ data: { id, deleted_at } }`.
- `POST /approval-steps/{id}/restore``{ data: { id, restored_at } }`.
주요 필터 및 확장 파라미터:
- `approval_id`, `approval_step_id`, `approver_id`, `approval_action_id`
- `action_from`, `action_to` (ISO8601)
- `approval_id`, `approval_step_id`, `approver_id`, `approval_action_id`, `status_id`
- `q`(결재번호·승인자 검색), `action_from`, `action_to` (ISO8601)
- `sort=action_at|created_at|updated_at`, `order=asc|desc`
- `include` 기본값은 `approver,approval_action,from_status,to_status`; `approval`, `step` 토큰으로 확장
- `include` 기본값은 `approver,approval_action,from_status,to_status`; `approval`, `step`, `status` 토큰으로 확장
`GET /approval-histories/91001?include=approval,step`
```json

View File

@@ -683,3 +683,4 @@ zipcodes ||--o{ customers : addressed
- `updated_at` 자동 갱신 트리거, 소프트 삭제 처리 트리거 권장.
- 낙관적 잠금(선택): `version`(int) + ETag.
- 병렬 결재 확장(선택): `approval_steps``group_no`, `approval_mode(all|any)` 도입.
- `/health` 응답의 `build_version``config/default.toml``[app].build_version`을 사용하며, `script/deploy_remote.sh`가 배포 아카이브 파일명에서 버전을 추출해 값을 주입한다.