KIM-JINO5 2 päivää sitten
vanhempi
sitoutus
c4cc49cf02

+ 427 - 0
.claude/PRPs/plans/completed/global-commodity-pages.plan.md

@@ -0,0 +1,427 @@
+# Plan: 국제 시세 커모디티 페이지 신설 + 금/석유/배출권 무데이터 원인 대응
+
+## Summary
+Alpha Vantage Commodities API로 국제 시세 메뉴 7종(구리·알루미늄·밀·섬유(면화)·설탕·커피·원자재 지수) 페이지를 단일 파라미터화 라우트(`Price/Global/{commodity}`)로 구현하고, 주석 처리된 `_Layout.cshtml`의 국제 시세 드롭다운을 복원한다. 아울러 조사에서 확인된 심각 결함 3건(요청마다 새 MemoryCache라 캐시가 전혀 동작하지 않아 무료 쿼터 25회/일이 즉시 소진되는 문제, `int.TryParse`로 인한 원화 환산 항상 0 문제, 페이지네이션 링크의 interval 파라미터 이름 오류)을 함께 수정한다.
+
+금/석유/배출권 페이지 무데이터는 **코드 결함이 아니라 data.go.kr 인증 문제**(아래 조사 결과 참조)로, 코드 측 과제는 "조용한 실패를 사용자에게 표시"까지만이다.
+
+## User Story
+As a 사이트 방문자, I want 국제 원자재(구리·알루미늄·밀·면화·설탕·커피·종합지수) 시세를 달러/원화로 조회, so that 국내 시세와 함께 국제 가격 흐름을 한 곳에서 확인할 수 있다.
+
+## Problem → Solution
+- **현재**: 국제 시세 메뉴가 `_Layout.cshtml`에서 통째로 주석 처리됨(66~98행). 주석 해제해도 7개 항목이 전부 `Flower/Index`(화훼)로 잘못 연결됨. 천연가스만 페이지 존재.
+- **변경 후**: `GlobalController`에 화이트리스트 기반 단일 액션 `Commodity(...)` 추가 → 7개 커모디티가 공용 뷰 1개로 서비스됨. 메뉴 복원 및 링크 수정. IMemoryCache DI로 API 호출 캐싱(쿼터 보호).
+
+## Metadata
+- **Complexity**: Medium
+- **Source PRD**: N/A (free-form)
+- **PRD Phase**: N/A
+- **Estimated Files**: 9 (CREATE 4, UPDATE 5)
+
+---
+
+## 조사 결과 (2026-07-22 실측) — 반드시 읽을 것
+
+### A. 금/석유/배출권 페이지 무데이터 원인 (코드로 해결 불가)
+실측 호출 결과:
+
+| 호출 | 결과 |
+|---|---|
+| `GetGeneralProductInfoService/getGoldPriceInfo` + 현재 키 `13c11eac...` | **HTTP 403 Forbidden** |
+| 같은 엔드포인트 + 교체 전 키 `ee10c328...` (git diff에서 확인) | **HTTP 403 Forbidden** |
+| 같은 엔드포인트 + 코드 주석의 예시 키 `vQz8tIxr...==` | HTTP 401 Unauthorized (키 자체 무효) |
+| `SpcdeInfoService/getHoliDeInfo`(공휴일) + 현재 키 `13c11eac...` | **HTTP 200 정상** |
+
+**결론**: 키 자체는 살아있음(공휴일 API 정상). `1160100/GetGeneralProductInfoService`(금융위원회_일반상품시세정보)에 대한 **활용신청이 없거나 만료**되어 게이트웨이가 403을 반환. 금·석유·배출권 3개 페이지가 모두 이 서비스 하나를 쓰므로 동시에 죽은 것.
+**사용자 조치 필요**: data.go.kr 로그인 → 「금융위원회_일반상품시세정보」(서비스 1160100) 활용신청/연장 → 승인 후 즉시 복구됨. 코드 변경 불필요.
+**코드 측 과제(Task 8)**: 현재 `DomesticModel`이 `EnsureSuccessStatusCode()` 예외를 `Console.WriteLine`으로 삼켜 화면엔 "No data."만 나옴 → 업스트림 오류를 화면에 표시.
+
+### B. Alpha Vantage Commodities API (신규 페이지 데이터 소스)
+- Base: `https://www.alphavantage.co/query?function={FUNC}&interval={interval}&apikey={key}` — 키는 `Models/Clients.cs`의 `Alpha_API.APIKey`(`EFOBUHL5SFLBF2LN`), 실측 정상 동작.
+- 응답 스키마는 **전 커모디티 동일**: `{ name, interval, unit, data: [{date, value}] }` → 기존 `NaturalGas.Response`와 같은 형태. 공용 `Commodity.Response` 1개로 충분.
+
+KEY_INSIGHT: 메뉴 7종 전부 공식 함수 존재 — COPPER(달러/메트릭톤), ALUMINUM, WHEAT, COTTON(섬유=면화), SUGAR, COFFEE, ALL_COMMODITIES(지수 2016=100).
+APPLIES_TO: Task 3 화이트리스트 매핑.
+GOTCHA: **COPPER 이하 7종의 interval은 `monthly|quarterly|annual`만 유효** (NATURAL_GAS/WTI/BRENT만 daily/weekly/monthly). 실측: `COPPER&interval=daily` 요청 시 에러 없이 **조용히 monthly로 대체 응답**하므로 서버측 검증 필수.
+
+KEY_INSIGHT: 무료 티어 25 요청/일, 5 요청/분. 초과 시 **HTTP 200 + `{"Information": "..."}`** 본문 반환(HTTP 에러 아님) → 역직렬화하면 `Data == null`이 되어 조용히 빈 화면.
+APPLIES_TO: Task 2 캐싱(필수), Task 3 rate-limit 감지.
+GOTCHA: 현재 `GlobalModel`은 (1) 인스턴스마다 `new MemoryCache(...)` 생성 + (2) 컨트롤러 액션마다 `new GlobalModel(...)` 생성 = **캐시가 요청 간 절대 유지되지 않음**. 게다가 캐시 키가 `"ExchangeValue"` 고정이라 interval별 구분도 없음. 반드시 DI `IMemoryCache`(싱글턴)로 교체.
+
+KEY_INSIGHT: ALL_COMMODITIES는 가격이 아니라 **지수(index 2016=100)** — 원화 환산이 무의미하므로 매핑에 `HasKrw=false` 플래그를 두고 KRW 열을 숨긴다.
+
+### C. 기존 코드의 확인된 버그 (이번 작업 범위에 포함)
+1. [GlobalController.cs:74](Controllers/Price/GlobalController.cs:74) — `int.TryParse(row.Value, ...)`: 값이 `"3.15"` 같은 소수라 파싱 항상 실패 → **원화 열이 항상 0**. `decimal.TryParse(..., NumberStyles.Any, CultureInfo.InvariantCulture, ...)`로 교체.
+2. [GlobalController.cs:24](Controllers/Price/GlobalController.cs:24) — 생성자에서 `InitializeAsync()`를 await 없이 호출(fire-and-forget) → 액션 실행 시점에 `_exchangeValue`가 0일 수 있는 레이스. 환율 조회를 액션 내부에서 `await`로 이동.
+3. [GlobalController.cs:88-91](Controllers/Price/GlobalController.cs:88) — 페이지네이션 queryString이 `new { date = request.Interval }`인데 바인딩 이름은 `interval`([NaturalGas.cs:23](Models/Request/Price/Global/NaturalGas.cs:23)) → **2페이지로 넘어가면 interval이 daily로 초기화**. `new { interval = request.Interval }`로 수정.
+
+---
+
+## UX Design
+
+### Before
+```
+네비게이션: [국내 시세▾] [공휴일/특일] [환율] ...   ← 국제 시세 메뉴 자체가 없음(주석)
+/Price/Global/NaturalGas 직접 접근 시: 원화 열 전부 0
+/Price/Domestic/Gold: "No data." (원인 표시 없음)
+```
+
+### After
+```
+네비게이션: [국내 시세▾] [국제 시세▾] [공휴일/특일] ...
+             국제 시세 ▾
+             ├ 천연가스  → /Price/Global/NaturalGas   (기존 유지)
+             ├ 구리      → /Price/Global/copper
+             ├ 알루미늄  → /Price/Global/aluminum
+             ├ 밀        → /Price/Global/wheat
+             ├ 섬유      → /Price/Global/cotton
+             ├ 설탕      → /Price/Global/sugar
+             ├ 커피      → /Price/Global/coffee
+             └ 원자재    → /Price/Global/allcommodities
+
+커모디티 페이지: [interval 선택: 월간/분기/연간] [perPage 선택]
+표: 번호 | 날짜 | 달러($) 또는 지수 | 원화(₩)  (단위는 caption에 API unit 표시)
+API 장애/쿼터 초과 시: alert(오류 메시지)  ← _Layout의 ViewBag.isError 패턴
+```
+
+### Interaction Changes
+| Touchpoint | Before | After | Notes |
+|---|---|---|---|
+| 국제 시세 드롭다운 | 없음(주석) | 8개 항목 노출 | _Layout.cshtml 66~98행 복원+링크 수정 |
+| 커모디티 페이지 | 없음 | interval/perPage 변경 시 자동 재검색 | gold.js 패턴 |
+| NaturalGas 원화 열 | 항상 0 | 환산값 표시 | 버그수정 |
+| 금/석유/배출권 오류 | 조용히 "No data." | alert로 업스트림 오류 표시 | 403 원인 인지 가능 |
+
+---
+
+## Mandatory Reading
+
+| Priority | File | Lines | Why |
+|---|---|---|---|
+| P0 | `Controllers/Price/GlobalController.cs` | all | 확장 대상. 기존 NaturalGas 액션 = 액션 구조 원형 |
+| P0 | `Models/Price/GlobalModel.cs` | all | Alpha 호출 원형 + 교체할 캐시 로직 |
+| P0 | `Models/Response/Price/Global/NaturalGas.cs` | all | Commodity.Response가 복제할 스키마 |
+| P0 | `Views/Shared/_Layout.cshtml` | 66-98, 145-150 | 주석 메뉴 원문 + ViewBag.isError alert 패턴 |
+| P1 | `Views/Price/Domestic/Gold.cshtml` | 10-27, 97-99 | fSearch 폼 + 셀렉트/인풋 `form="fSearch"` 패턴 |
+| P1 | `Models/Request/Price/Global/NaturalGas.cs` | all | Request 바인딩 어트리뷰트 패턴 (`[ViewData]`는 무의미하므로 복사 금지) |
+| P1 | `wwwroot/js/gold.js` | all | 페이지 JS 원형 (class + jQuery change 바인딩) |
+| P2 | `Models/View.cs`, `Models/Pagination.cs`, `Helpers/Common.cs` | all | ViewModel/페이징/번호계산 헬퍼 |
+| P2 | `Program.cs` | 8-22 | DI 등록 위치 (`AddMemoryCache()` 추가 지점) |
+
+## External Documentation
+
+| Topic | Source | Key Takeaway |
+|---|---|---|
+| Commodities API | https://www.alphavantage.co/documentation/#commodities | 함수명·interval·unit. COPPER류는 monthly/quarterly/annual만 |
+| Rate limit | 무료 키 정책 | 25/일·5/분, 초과 시 HTTP 200 + `Information` 필드 |
+| data.go.kr 일반상품시세 | https://www.data.go.kr/data/15094805/openapi.do | 금/석유/배출권 데이터원. **활용신청 필요(사용자 조치)** |
+
+---
+
+## Patterns to Mirror
+
+### NAMING_CONVENTION
+```
+// SOURCE: 프로젝트 전반
+// 모델: Models/{Request|Response}/Price/Global/{Feature}.cs, namespace economy.Models.Price.Global.{Feature}
+// 클래스명은 항상 Request / Response / Data
+// 컨트롤러: economy.Controllers.Price.Global, 뷰는 View("/Views/...cshtml", viewModel) 절대경로 반환
+// 쿼리 파라미터: page, perPage, interval (BindProperty Name 소문자)
+```
+
+### CONTROLLER_ACTION (SOURCE: Controllers/Price/GlobalController.cs:43-97)
+```csharp
+[HttpGet("Price/Global/NaturalGas")]
+public async Task<IActionResult> NaturalGas(NaturalGas.Request request)
+{
+    if (!ModelState.IsValid) return BadRequest(ModelState);
+    if (!new[] { "daily", "weekly", "monthly" }.Contains(request.Interval)) return BadRequest("Invalid input provided.");
+    GlobalModel globalModel = new GlobalModel(_alphaAPI);
+    NaturalGas.Response itemList = await globalModel.GetNaturalGasPriceInfo(request);
+    // ... Skip/Take 페이징 + Num 역순번호 + KRW 환산 → View<Req,Res> + Pagination
+}
+```
+
+### API_CALL_AND_ERROR_HANDLING (SOURCE: Models/Price/GlobalModel.cs:30-63)
+```csharp
+var uriBuilder = new UriBuilder(_alphaAPI.APIUrl) { Path = "/query", Query = $"function=NATURAL_GAS&interval={request.Interval}&apikey={_alphaAPI.APIKey}" };
+var response = await _alphaAPI.httpClient.GetAsync(uriBuilder.Uri);
+if (response.IsSuccessStatusCode) { var jsonString = await response.Content.ReadAsStringAsync(); /* Deserialize */ }
+response.EnsureSuccessStatusCode();
+// catch (HttpRequestException e) { Console.WriteLine($"Request error: {e.Message}"); }
+```
+
+### RESPONSE_MODEL (SOURCE: Models/Response/Price/Global/NaturalGas.cs — 전 커모디티 공통 스키마)
+```csharp
+public class Response {
+    [JsonPropertyName("name")] public string Name { get; set; }
+    [JsonPropertyName("interval")] public string Interval { get; set; }
+    [JsonPropertyName("unit")] public string Unit { get; set; }
+    [JsonPropertyName("data")] public List<Data> Data { get; set; }
+}
+public class Data {
+    public int Num { get; set; }
+    [JsonPropertyName("date")] public string Date { get; set; }
+    [JsonPropertyName("value")] public string Value { get; set; }
+    public decimal KRW { get; set; } = 0;   // 신규 모델은 int가 아닌 decimal
+}
+```
+
+### VIEW_SEARCH_FORM (SOURCE: Views/Price/Domestic/Gold.cshtml:14-27, 97-99)
+```html
+<select name="perPage" id="perPage" class="form-select" asp-for="SelectedListPerPage" asp-items="Model.ListPerPage" form="fSearch"></select>
+...
+<form id="fSearch" method="get" accept-charset="UTF-8" rel="search" autocomplete="off" asp-controller="Gold" asp-action="Index">
+    <input type="hidden" name="page" value="@Model.Request.PageNo" />
+</form>
+```
+
+### PAGE_JS (SOURCE: wwwroot/js/gold.js)
+```javascript
+class GoldList {
+    constructor() { this.form = document.getElementById("fSearch"); }
+    ChangePerPage(e) { this.form.elements["page"].value = 1; this.submit(); }
+    submit() { this.form.submit(); }
+}
+const goldList = new GoldList();
+$(document).on("change", "#perPage", (e) => goldList.ChangePerPage(e));
+```
+
+### ERROR_ALERT (SOURCE: Views/Shared/_Layout.cshtml:145-150)
+```razor
+@if (ViewBag != null && ViewBag.isError == true) {
+    <script> alert('@Html.Raw(ViewBag.errorMessage)'); </script>
+}
+```
+
+### DI_CACHE (SOURCE: Program.cs:11-17 + FinancialModel.cs:138-146의 캐시 키/TryGetValue 관용구)
+```csharp
+builder.Services.AddHttpClient<Alpha_API>();   // 기존
+builder.Services.AddMemoryCache();             // 신규 — IMemoryCache 싱글턴
+// 사용: if (_cache.TryGetValue(cacheKey, out T cached)) return cached; ... _cache.Set(cacheKey, data, TimeSpan.FromDays(1));
+```
+
+---
+
+## Files to Change
+
+| File | Action | Justification |
+|---|---|---|
+| `Models/Request/Price/Global/Commodity.cs` | CREATE | 공용 요청 모델 (page/perPage/interval + 라우트값 Commodity) |
+| `Models/Response/Price/Global/Commodity.cs` | CREATE | 공용 응답 모델 (NaturalGas.Response 스키마 복제, KRW는 decimal) |
+| `Views/Price/Global/Commodity.cshtml` | CREATE | 8종 공용 뷰 (interval 셀렉트 + unit caption + KRW 열 토글) |
+| `wwwroot/js/commodity.js` | CREATE | gold.js 패턴의 폼 자동제출 |
+| `Controllers/Price/GlobalController.cs` | UPDATE | 매핑 딕셔너리 + `Commodity` 액션 추가, KRW/interval/레이스 버그 수정 |
+| `Models/Price/GlobalModel.cs` | UPDATE | `GetCommodityPriceInfo` 추가, IMemoryCache 주입, 캐시 키 수정, rate-limit 감지 |
+| `Program.cs` | UPDATE | `builder.Services.AddMemoryCache();` |
+| `Views/Shared/_Layout.cshtml` | UPDATE | 국제 시세 메뉴 주석 해제 + 8개 링크 교정 |
+| `Controllers/Price/Domestic/GoldController.cs` 외 Oil/Emission | UPDATE(선택) | API 오류를 ViewBag.isError로 표출 (Task 8) |
+
+## NOT Building
+- data.go.kr 활용신청/키 재발급 — 포털에서 사용자 본인이 처리해야 함 (코드로 불가)
+- 기존 NaturalGas 액션의 generic 액션 통합/삭제 — 리터럴 라우트가 우선 매칭되므로 공존 가능, 별도 리팩터링으로 미룸
+- WTI/BRENT(국제 유가) 페이지 — 메뉴에 없음. 매핑 dict에 항목만 추가하면 되는 구조로 설계
+- 금 국제 시세(Alpha Vantage Gold/Silver Spot) — 국내 금(KRX)과 별개 상품. Notes 참조
+- 차트/그래프, DB 저장, 테스트 프로젝트 신설 (repo에 테스트 없음)
+
+---
+
+## Step-by-Step Tasks
+
+### Task 1: 공용 요청/응답 모델 생성
+- **ACTION**: `Models/Request/Price/Global/Commodity.cs`, `Models/Response/Price/Global/Commodity.cs` 생성. namespace `economy.Models.Price.Global.Commodity`.
+- **IMPLEMENT**: Request는 NaturalGas.Request 복제 후 수정 — `Interval` 기본값 `"monthly"`, `[ViewData]` 어트리뷰트 제거, 라우트 바인딩용 `[BindProperty(Name = "commodity", SupportsGet = true)] public string? Commodity { get; set; }` 추가. Response는 위 RESPONSE_MODEL 스니펫 그대로(KRW는 `decimal`).
+- **MIRROR**: RESPONSE_MODEL, `Models/Request/Price/Global/NaturalGas.cs`
+- **IMPORTS**: `Microsoft.AspNetCore.Mvc`, `System.ComponentModel.DataAnnotations`, `System.Text.Json.Serialization`
+- **GOTCHA**: 기존 NaturalGas.Request의 `required string Interval`은 기본값이 있으면 쿼리 생략 시에도 동작 — 동일하게 기본값 `= "monthly"` 유지.
+- **VALIDATE**: `dotnet build economy.sln` 통과.
+
+### Task 2: GlobalModel에 GetCommodityPriceInfo + 진짜 캐시
+- **ACTION**: `Models/Price/GlobalModel.cs` 수정.
+- **IMPLEMENT**:
+  1. 생성자 시그니처 `public GlobalModel(Alpha_API alphaAPI, IMemoryCache cache)` — `new MemoryCache(...)` 제거하고 주입값 사용. (기존 호출부: NaturalGas 액션도 함께 갱신)
+  2. 신규 메서드:
+```csharp
+// 국제 원자재 시세 조회
+public async Task<Commodity.Response> GetCommodityPriceInfo(string function, string interval)
+{
+    string cacheKey = $"Alpha_{function}_{interval}";
+    if (_cache.TryGetValue(cacheKey, out Commodity.Response cachedData)) return cachedData;
+    Commodity.Response parseData = new();
+    try
+    {
+        var uriBuilder = new UriBuilder(_alphaAPI.APIUrl) { Path = "/query", Query = $"function={function}&interval={interval}&apikey={_alphaAPI.APIKey}" };
+        var response = await _alphaAPI.httpClient.GetAsync(uriBuilder.Uri);
+        if (response.IsSuccessStatusCode)
+        {
+            var jsonString = await response.Content.ReadAsStringAsync();
+            parseData = JsonSerializer.Deserialize<Commodity.Response>(jsonString);
+            if (parseData.Data is null) return new Commodity.Response();   // rate-limit(Information 응답) 포함
+            _cache.Set(cacheKey, parseData, TimeSpan.FromDays(1));          // 데이터 있을 때만 캐시
+        }
+        response.EnsureSuccessStatusCode();
+    }
+    catch (HttpRequestException e) { Console.WriteLine($"Request error: {e.Message}"); }
+    return parseData;
+}
+```
+  3. 기존 `GetNaturalGasPriceInfo`의 캐시 키 `"ExchangeValue"` → `$"Alpha_NATURAL_GAS_{request.Interval}"`로 수정(interval 미구분 버그).
+- **MIRROR**: API_CALL_AND_ERROR_HANDLING, DI_CACHE
+- **IMPORTS**: `Microsoft.Extensions.Caching.Memory`(기존), `using Commodity = economy.Models.Price.Global.Commodity;` 별칭 권장
+- **GOTCHA**: 쿼터 초과 시 HTTP 200이므로 `IsSuccessStatusCode`로 못 거른다 — `Data is null` 체크가 유일한 방어선. **빈 응답을 캐시하지 말 것**(하루 종일 빈 화면 고착 방지).
+- **VALIDATE**: build 통과.
+
+### Task 3: GlobalController — 매핑 + 공용 액션 + 기존 버그 수정
+- **ACTION**: `Controllers/Price/GlobalController.cs` 수정.
+- **IMPLEMENT**:
+  1. IMemoryCache 주입: `public GlobalController(Alpha_API alphaAPI, KoreaEximGoKR koreaEximGoKR, IMemoryCache cache)`; 필드 `_cache` 저장. 생성자의 `InitializeAsync()` 호출과 메서드 자체 **삭제**(레이스 버그) — 환율 조회는 액션 내부 `await`로.
+  2. 정적 매핑:
+```csharp
+// (Alpha Vantage 함수, 화면 제목, 원화 표시 여부)
+private static readonly Dictionary<string, (string Function, string Title, bool HasKrw)> CommodityMap = new(StringComparer.OrdinalIgnoreCase)
+{
+    ["copper"]         = ("COPPER",          "구리 시세",          true),
+    ["aluminum"]       = ("ALUMINUM",        "알루미늄 시세",      true),
+    ["wheat"]          = ("WHEAT",           "밀 시세",            true),
+    ["cotton"]         = ("COTTON",          "섬유(면화) 시세",    true),
+    ["sugar"]          = ("SUGAR",           "설탕 시세",          true),
+    ["coffee"]         = ("COFFEE",          "커피 시세",          true),
+    ["allcommodities"] = ("ALL_COMMODITIES", "원자재 종합지수",    false),  // 지수라 원화 환산 없음
+};
+```
+  3. 공용 액션 — NaturalGas 액션 구조를 그대로 따르되:
+```csharp
+[HttpGet("Price/Global/{commodity}")]
+public async Task<IActionResult> Commodity(Commodity.Request request)
+{
+    if (!ModelState.IsValid) return BadRequest(ModelState);
+    if (request.Commodity is null || !CommodityMap.TryGetValue(request.Commodity, out var meta)) return NotFound();
+    if (!new[] { "monthly", "quarterly", "annual" }.Contains(request.Interval)) return BadRequest("Invalid input provided.");
+
+    int exchangeValue = meta.HasKrw ? await new FinancialModel(_koreaEximGoKR).GetExchangeValue() : 0;   // 액션 내 await (레이스 제거)
+
+    GlobalModel globalModel = new GlobalModel(_alphaAPI, _cache);
+    Commodity.Response itemList = await globalModel.GetCommodityPriceInfo(meta.Function, request.Interval);
+    // total/CalcListNumber/Skip/Take 페이징: NaturalGas 액션 61~81행과 동일. 단 KRW 환산은:
+    //   if (decimal.TryParse(row.Value, NumberStyles.Any, CultureInfo.InvariantCulture, out decimal value) && value > 0)
+    //       row.KRW = Math.Round(value * exchangeValue);
+    if (itemList.Data is null || itemList.Data.Count == 0)
+    {
+        ViewBag.isError = true;
+        ViewBag.errorMessage = "국제 시세 데이터를 가져오지 못했습니다. (API 한도 초과 또는 일시 장애)";
+    }
+    ViewBag.Title = meta.Title;
+    ViewBag.HasKrw = meta.HasKrw;
+    // View<Commodity.Request, Commodity.Response> + Pagination(queryString: new { interval = request.Interval }) — "date" 아님!
+    return View("/Views/Price/Global/Commodity.cshtml", viewModel);
+}
+```
+  4. 기존 NaturalGas 액션 버그 수정: 74행 `int.TryParse` → `decimal.TryParse(..., NumberStyles.Any, CultureInfo.InvariantCulture, ...)`(모델 KRW가 int면 `(int)Math.Round(...)` 캐스팅), 88~91행 `date =` → `interval =`, 환율 조회를 액션 첫머리 `await`로 이동, `new GlobalModel(_alphaAPI)` → `new GlobalModel(_alphaAPI, _cache)`.
+- **MIRROR**: CONTROLLER_ACTION, ERROR_ALERT
+- **IMPORTS**: `System.Globalization`, `Microsoft.Extensions.Caching.Memory`, `using Commodity = economy.Models.Price.Global.Commodity;`
+- **GOTCHA**: ① 리터럴 라우트 `Price/Global/NaturalGas`가 템플릿 `Price/Global/{commodity}`보다 우선 매칭되므로 천연가스 기존 URL은 안전. 단 CommodityMap에 `naturalgas`를 넣지 말 것(이중 정의 혼란 방지). ② `{commodity}` 라우트값은 `[BindProperty(Name="commodity")]`로 Request에 바인딩됨. ③ 매핑에 없는 값은 400이 아닌 **404** 반환.
+- **VALIDATE**: build 통과 후 실행: `/Price/Global/copper` 200, `/Price/Global/xyz` 404, `/Price/Global/copper?interval=daily` 400.
+
+### Task 4: 공용 뷰 Commodity.cshtml
+- **ACTION**: `Views/Price/Global/Commodity.cshtml` 생성.
+- **IMPLEMENT**: `Views/Price/Global/NaturalGas.cshtml`를 기반으로:
+  - `@model economy.Models.View<economy.Models.Price.Global.Commodity.Request, economy.Models.Price.Global.Commodity.Response>`
+  - `ViewData["Title"] = (string)ViewBag.Title` (컨트롤러 주입)
+  - perPage 셀렉트 옆에 interval 셀렉트 추가(`form="fSearch"`): `monthly=월간, quarterly=분기, annual=연간` — `<select name="interval" id="interval">`에서 `Model.Request.Interval`로 selected 처리
+  - `<caption class="caption-top">단위: @Model.Response.Unit</caption>`
+  - 원화 열: `@if ((bool?)ViewBag.HasKrw == true)` 로 `<th>원화(₩)</th>`/`<td>@row.KRW.ToString("N0")</td>` 토글, HasKrw==false면 달러 열 헤더를 `지수`로 표기
+  - fSearch 폼: Gold.cshtml 97-99행 패턴 — 단 `asp-controller/asp-action` 대신 `action="/Price/Global/@Model.Request.Commodity"` (라우트 파라미터 유지)
+  - Scripts 섹션: `~/js/commodity.js`
+- **MIRROR**: VIEW_SEARCH_FORM, `Views/Price/Global/NaturalGas.cshtml`
+- **GOTCHA**: NaturalGas.cshtml에는 fSearch 폼 자체가 없다(`form="fSearch"` 참조만 있고 폼 부재 = 미동작) — Gold.cshtml의 폼 패턴을 쓸 것. 페이지네이션 partial은 `Model.Pagination` 전달.
+- **VALIDATE**: 페이지 렌더 후 interval 변경 → URL에 `?interval=quarterly` 유지되며 재조회.
+
+### Task 5: commodity.js
+- **ACTION**: `wwwroot/js/commodity.js` 생성.
+- **IMPLEMENT**: PAGE_JS 스니펫 복제, 클래스명 `CommodityList`, 바인딩 `$(document).on("change", "#perPage, #interval", ...)` — interval 변경 시에도 page=1 리셋.
+- **MIRROR**: PAGE_JS
+- **VALIDATE**: 셀렉트 변경 시 폼 자동 제출.
+
+### Task 6: Program.cs — AddMemoryCache
+- **ACTION**: [Program.cs:17](Program.cs:17) 아래에 `builder.Services.AddMemoryCache();` 추가.
+- **GOTCHA**: Program.cs의 한글 주석이 깨져 보이는 것(`���`)은 파일 인코딩 문제 — 건드리지 말고 새 줄만 추가.
+- **VALIDATE**: build + 같은 페이지 재요청이 Alpha API를 재호출하지 않음.
+
+### Task 7: _Layout.cshtml 메뉴 복원
+- **ACTION**: 66~98행 `@* ... *@` 주석 제거, 링크 교정.
+- **IMPLEMENT**: 천연가스는 기존 그대로(`asp-controller="Global" asp-action="NaturalGas"`). 나머지 7개는 `asp-controller="Global" asp-action="Commodity" asp-route-commodity="copper"` 형태(각각 aluminum/wheat/cotton/sugar/coffee/allcommodities).
+- **GOTCHA**: 이 파일에는 이미 커밋 안 된 수정(타이틀/푸터)이 있음 — 해당 변경을 보존한 채 메뉴 블록만 수정.
+- **VALIDATE**: 드롭다운 8개 항목이 각기 다른 URL로 이동.
+
+### Task 8 (선택, 권장): 금/석유/배출권 조용한 실패 표출
+- **ACTION**: Gold/Oil/Emission 3개 컨트롤러에서 응답이 비어있을 때 사용자에게 알림.
+- **IMPLEMENT**: 각 Index 액션에서 `if (itemList.Body is null) { ViewBag.isError = true; ViewBag.errorMessage = "시세 API 호출이 거부되었습니다. data.go.kr 활용신청 상태를 확인해주세요."; }` — ERROR_ALERT 패턴이 _Layout에서 자동 렌더.
+- **GOTCHA**: 근본 해결은 코드가 아니라 data.go.kr 「금융위원회_일반상품시세정보」 활용신청 (조사 결과 A). 승인 전까지는 이 알림이 뜨는 게 정상.
+- **VALIDATE**: `/Price/Domestic/Gold` 접근 시 alert 표시.
+
+---
+
+## Testing Strategy
+
+repo에 테스트 프로젝트가 없으므로 수동 검증 중심 (신규 테스트 프로젝트 생성은 범위 외).
+
+### Edge Cases Checklist
+- [ ] 매핑에 없는 commodity (`/Price/Global/xyz`) → 404
+- [ ] 잘못된 interval (`?interval=daily`) → 400 (Alpha가 조용히 monthly로 대체하기 전에 차단)
+- [ ] Alpha 쿼터 초과(HTTP 200 + Information) → 빈 데이터 + alert, **캐시에 저장 안 됨**
+- [ ] ALL_COMMODITIES → 원화 열 미표시
+- [ ] 페이지네이션 2페이지 이동 시 interval 유지
+- [ ] 환율 API 실패(exchangeValue=0) → KRW 0 표시(크래시 없음)
+- [ ] perPage=500 등 큰 값 → Skip/Take 정상
+
+## Validation Commands
+
+### Static Analysis / Build
+```bash
+dotnet build economy.sln
+```
+EXPECT: 0 errors
+
+### Run & Smoke Test
+```bash
+dotnet run
+```
+```bash
+curl -s -o /dev/null -w "%{http_code}\n" -k https://localhost:5001/Price/Global/copper
+```
+EXPECT: copper/aluminum/wheat/cotton/sugar/coffee/allcommodities 모두 200, `/Price/Global/xyz` 404, `/Price/Global/NaturalGas` 200(기존 유지)
+
+### Manual Validation
+- [ ] 국제 시세 드롭다운 8개 링크 각각 클릭 → 표에 데이터·단위 caption 표시
+- [ ] 구리 페이지에서 원화 열이 0이 아닌 환산값
+- [ ] 원자재(지수) 페이지에 원화 열 없음
+- [ ] 같은 페이지 새로고침 연타 → Alpha API 호출 1회만 발생 (캐시 동작; 25회/일 보호)
+- [ ] 천연가스 페이지 원화 열이 더 이상 0이 아님
+- [ ] 금/석유/배출권 → alert 안내 (data.go.kr 활용신청 후엔 데이터 표시)
+
+## Acceptance Criteria
+- [ ] 7개 커모디티 페이지 + 메뉴 복원 완료
+- [ ] 캐시 실동작 (요청 간 유지, interval별 분리)
+- [ ] NaturalGas KRW/interval 페이지네이션 버그 수정
+- [ ] 빌드 0 에러, 기존 페이지 회귀 없음
+
+## Completion Checklist
+- [ ] 코드가 기존 컨벤션(절대경로 View 반환, View<Req,Res>, Common.CalcListNumber, 한국어 주석)과 구분 불가
+- [ ] 에러 처리: HttpRequestException catch + Console.WriteLine, 화면은 ViewBag.isError
+- [ ] 하드코딩 없음(매핑 dict가 유일한 상수 테이블)
+- [ ] 불필요한 범위 확장 없음 (WTI/BRENT/차트 미구현)
+
+## Risks
+| Risk | Likelihood | Impact | Mitigation |
+|---|---|---|---|
+| Alpha 무료 쿼터 25/일 소진 (운영 트래픽) | 높음 | 페이지 빈 화면 | 1일 캐시 필수(Task 2·6). 함수 7×interval 3=최대 21키/일로 쿼터 내 수렴. 장기적으로 프리미엄 키 검토 |
+| data.go.kr 활용신청 미처리 | 확실 | 금/석유/배출권 계속 무데이터 | 사용자 포털 조치 필요 — 코드 범위 밖 (Task 8로 가시화) |
+| `{commodity}` 라우트가 다른 리터럴 라우트와 충돌 | 낮음 | 라우팅 오작동 | ASP.NET Core는 리터럴 우선 매칭. `Price/Global/*` 리터럴은 NaturalGas뿐임을 확인함 |
+| API 키가 소스에 하드코딩 | 기존 관행 | 보안 | 기존 패턴 유지(범위 외). 별도 개선 과제로 인지 |
+
+## Notes
+- **섬유 = COTTON(면화)**: Alpha Vantage에 "섬유" 일반 지수는 없음. 메뉴 라벨은 사용자 제공 그대로 "섬유" 유지, 페이지 제목만 "섬유(면화) 시세".
+- Alpha Vantage에 **Gold & Silver Spot/History** 엔드포인트가 신설되어 있음 — 국내(KRX) 금 시세와는 별개지만, data.go.kr 복구가 늦어지면 "국제 금 시세" 페이지 대안으로 CommodityMap에 1줄 추가로 확장 가능.
+- CORN(옥수수)도 지원되나 메뉴에 없어 미구현 — 필요 시 매핑 1줄.
+- `Clients.cs`/`_Layout.cshtml`에 커밋 안 된 변경이 이미 존재(키 교체, 타이틀 변경) — 구현 시 보존할 것.
+- 조사 중 Alpha Vantage 키로 5회 실호출 소모함(2026-07-22). 남은 당일 쿼터 유의.

+ 388 - 0
.claude/PRPs/plans/completed/price-page-charts.plan.md

@@ -0,0 +1,388 @@
+# Plan: 국내/국제 시세 페이지 상단 미니 차트
+
+## Summary
+금·석유·배출권(국내)과 천연가스·커모디티 7종(국제) 페이지 상단에 Chart.js 라인 차트를 추가한다. 공용 `ChartData` 모델 + `Views/Component/Chart.cshtml` 파셜 + `price-chart.js` 하나로 전 페이지를 처리한다(Pagination 파셜과 동일한 구성 패턴). 국제 페이지는 이미 메모리에 있는 전체 시계열을 재사용(추가 API 0회), 국내 페이지는 같은 검색조건으로 차트용 데이터 1회를 추가 조회한다.
+
+**가능 여부: 가능** — 모든 대상 페이지가 날짜-가격 시계열을 이미 서버에서 받고 있어 렌더링만 추가하면 된다.
+
+## User Story
+As a 사이트 방문자, I want 표를 읽기 전에 가격 흐름을 그래프로 한눈에 보기, so that 최근 추세(상승/하락)를 즉시 파악할 수 있다.
+
+## Problem → Solution
+현재: 모든 시세 페이지가 표만 표시 → 추세 파악에 스크롤·암산 필요.
+변경 후: 표 위에 240px 높이 라인 차트(국내: 종목별 다중 라인, 국제: 단일 라인) 표시. 데이터 없으면 차트 영역 자체가 렌더링되지 않음.
+
+## Metadata
+- **Complexity**: Medium
+- **Source PRD**: N/A (free-form)
+- **PRD Phase**: N/A
+- **Estimated Files**: 13 (CREATE 3, UPDATE 10)
+
+---
+
+## UX Design
+
+### Before
+```
+[제목]  [검색조건 셀렉트]
+┌─────────── 표 ───────────┐
+│ 번호 | 날짜 | 가격 ...    │
+└──────────────────────────┘
+[페이지네이션]
+```
+
+### After
+```
+[제목]  [검색조건 셀렉트]
+┌───────── 차트 (240px) ────────┐
+│  ╭─╮    ← 라인차트             │
+│ ╱   ╰─╮  국내: 종목별 여러 라인 │
+│╱      ╰─ 국제: 단일 라인       │
+└───────────────────────────────┘
+┌─────────── 표 ───────────┐  ← 기존 그대로
+└──────────────────────────┘
+[페이지네이션]
+```
+
+### Interaction Changes
+| Touchpoint | Before | After | Notes |
+|---|---|---|---|
+| 시세 페이지 상단 | 없음 | 라인 차트 + 호버 툴팁 + 범례(다중 시리즈 시) | Chart.js 기본 동작 |
+| 검색조건 변경 | 표만 갱신 | 차트도 같은 조건으로 갱신 | 서버 렌더라 자동 |
+| 데이터 없음/API 실패 | "No data." | 차트 미표시 + 기존 "No data." | 파셜이 null/빈 데이터면 아무것도 출력 안 함 |
+
+---
+
+## Mandatory Reading
+
+| Priority | File | Lines | Why |
+|---|---|---|---|
+| P0 | `Views/Component/Pagination.cshtml` | all | 파셜 구성 원형. **주의: 이 파일의 `@section` 블록은 파셜에서 무시됨(작동 안 함)** — 새 Chart 파셜은 인라인 `<script>` 사용 |
+| P0 | `Controllers/Price/GlobalController.cs` | Commodity 액션 | 차트 데이터를 Skip/Take **이전에** 추출해야 하는 위치 |
+| P0 | `Controllers/Price/Domestic/GoldController.cs` | all | 국내 컨트롤러 원형 (Oil/Emission 동일 구조) |
+| P1 | `Models/View.cs` | 1-15, 78 | `Pagination` 프로퍼티처럼 `Chart` 프로퍼티 추가할 위치 |
+| P1 | `Models/Price/DomesticModel.cs` | GetGoldPriceInfo | 차트용 추가 조회에 재사용할 메서드 (이미 존재, 수정 불필요) |
+| P1 | `Models/Response/Price/Domestic/Gold.cs` | 45-84 | 필드명: BasDt(yyyyMMdd 문자열), ItmsNm, Clpr(문자열 숫자). Emission도 동일 구조 |
+| P1 | `Models/Response/Price/Domestic/Oil.cs` | 45-66 | 필드명: BasDt, OilCtg(유종), WtAvgPrcCptn(가중평균가격 문자열) |
+| P2 | `Helpers/Common.cs` | 46-49 | `StringToDateFormat("20260721")` → "2026-07-21" 라벨 변환 |
+| P2 | `Views/Price/Global/Commodity.cshtml` | all | 파셜 include 위치 참고 |
+
+## External Documentation
+
+| Topic | Source | Key Takeaway |
+|---|---|---|
+| Chart.js 4.x | https://www.chartjs.org/docs/latest/ | UMD 단일 파일 CDN: `https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js`. 카테고리 X축은 문자열 라벨이면 어댑터 불필요 |
+
+KEY_INSIGHT: Chart.js 4.x는 시간축(time scale)을 쓰려면 date 어댑터가 별도로 필요하지만, 라벨을 문자열 배열(category)로 주면 어댑터 없이 동작한다.
+APPLIES_TO: price-chart.js — labels는 "yyyy-MM-dd" 문자열 배열로 전달.
+GOTCHA: `maintainAspectRatio: false` + 부모 div 고정 높이(240px) 조합이 아니면 캔버스가 무한히 커지는 고전 버그가 있다.
+
+KEY_INSIGHT: `wwwroot/lib`에 차트 라이브러리 없음(bootstrap/jquery뿐). 사이트는 이미 외부 스크립트(googletagmanager, coupang iframe)를 로드하므로 CDN 사용에 제약 없음.
+APPLIES_TO: Chart 파셜의 스크립트 태그.
+
+---
+
+## Patterns to Mirror
+
+### SHARED_VIEWMODEL_PROPERTY (SOURCE: Models/View.cs:78)
+```csharp
+public Pagination Pagination { get; set; }
+// → 동일하게 public ChartData? Chart { get; set; } 추가
+```
+
+### PARTIAL_INCLUDE (SOURCE: Views/Price/Global/Commodity.cshtml:86)
+```razor
+@await Html.PartialAsync("~/Views/Component/Pagination.cshtml", Model.Pagination)
+```
+
+### PARTIAL_EMPTY_GUARD (SOURCE: Views/Component/Pagination.cshtml:4)
+```razor
+@if (Model.TotalRows > 0)
+{
+<nav id="pagination" ...>
+```
+
+### RAW_VALUE_PARSE (SOURCE: Controllers/Price/GlobalController.cs — Commodity 액션)
+```csharp
+if (decimal.TryParse(row.Value, NumberStyles.Any, CultureInfo.InvariantCulture, out decimal value) && value > 0)
+```
+
+### DOMESTIC_EXTRA_FETCH (SOURCE: Controllers/Price/Domestic/GoldController.cs:39-40 변형)
+```csharp
+DomesticModel domesticModel = new DomesticModel(_dataGoKR, null);
+Response itemList = await domesticModel.GetGoldPriceInfo(request);
+// 차트용: 같은 모델 메서드를 numOfRows 크게 잡아 1회 더 호출 (request 복제본 사용)
+```
+
+### DATE_LABEL (SOURCE: Helpers/Common.cs:46-49)
+```csharp
+Common.StringToDateFormat(row.BasDt) // "20260721" → "2026-07-21"
+```
+
+### PAGE_JS (SOURCE: wwwroot/js/gold.js) — 클래스 + 전역 인스턴스 스타일. price-chart.js도 같은 톤 유지.
+
+---
+
+## Files to Change
+
+| File | Action | Justification |
+|---|---|---|
+| `Models/ChartData.cs` | CREATE | 공용 차트 모델 (Labels + Series) |
+| `Views/Component/Chart.cshtml` | CREATE | 캔버스 + JSON 직렬화 + 스크립트 로드 파셜 |
+| `wwwroot/js/price-chart.js` | CREATE | JSON 읽어 Chart.js 라인차트 렌더 |
+| `Models/View.cs` | UPDATE | `Chart` 프로퍼티 추가 |
+| `Controllers/Price/GlobalController.cs` | UPDATE | NaturalGas·Commodity 두 액션에 차트 데이터 구성 |
+| `Controllers/Price/Domestic/GoldController.cs` | UPDATE | 차트용 추가 조회 + 그룹핑 |
+| `Controllers/Price/Domestic/OilController.cs` | UPDATE | 〃 (OilCtg/WtAvgPrcCptn) |
+| `Controllers/Price/Domestic/EmissionController.cs` | UPDATE | 〃 (ItmsNm/Clpr) |
+| `Views/Price/Domestic/Gold.cshtml` | UPDATE | 표 위에 파셜 include |
+| `Views/Price/Domestic/Oil.cshtml` | UPDATE | 〃 |
+| `Views/Price/Domestic/Emission.cshtml` | UPDATE | 〃 |
+| `Views/Price/Global/NaturalGas.cshtml` | UPDATE | 〃 |
+| `Views/Price/Global/Commodity.cshtml` | UPDATE | 〃 |
+
+## NOT Building
+- 품목별(Item)·화훼(Flower) 페이지 차트 — 행이 시계열이 아니라 카탈로그 성격이라 제외
+- 클라이언트측 기간 변경/줌 등 인터랙션 — 기존 검색조건(서버 렌더)에 따름
+- 캔들차트/거래량 보조축 — "간단하게" 요구에 맞춰 종가 라인만
+- Chart.js 로컬 번들링 — CDN 사용 (오프라인 필요 시 후속으로 wwwroot/lib/chartjs에 복사만 하면 됨)
+
+---
+
+## Step-by-Step Tasks
+
+### Task 1: 공용 차트 모델
+- **ACTION**: `Models/ChartData.cs` 생성, namespace `economy.Models` (View/Pagination과 동급).
+- **IMPLEMENT**:
+```csharp
+namespace economy.Models
+{
+    // 시세 페이지 상단 미니 차트 데이터
+    public class ChartData
+    {
+        public List<string> Labels { get; set; } = [];       // X축 날짜 (오름차순, "yyyy-MM-dd")
+        public List<ChartSeries> Series { get; set; } = [];
+    }
+
+    public class ChartSeries
+    {
+        public string Name { get; set; } = "";               // 범례 이름 (종목명/유종)
+        public List<decimal?> Values { get; set; } = [];     // Labels와 같은 길이, 결측은 null
+    }
+}
+```
+- **MIRROR**: SHARED_VIEWMODEL_PROPERTY 위치 감각
+- **VALIDATE**: `dotnet build economy.sln` 0 에러
+
+### Task 2: View 모델에 Chart 프로퍼티
+- **ACTION**: [Models/View.cs](Models/View.cs)의 `Pagination` 프로퍼티(78행) 위에 `public ChartData? Chart { get; set; }` 추가.
+- **GOTCHA**: nullable — 차트 없는 페이지(FIFA 등)도 이 제네릭을 공유하므로 기본 null이어야 함.
+- **VALIDATE**: build 0 에러
+
+### Task 3: Chart 파셜 + JS
+- **ACTION**: `Views/Component/Chart.cshtml`, `wwwroot/js/price-chart.js` 생성.
+- **IMPLEMENT** (파셜):
+```razor
+@model economy.Models.ChartData
+
+@if (Model != null && Model.Labels.Count > 1 && Model.Series.Count > 0)
+{
+    <div class="mt-3" style="position: relative; height: 240px;">
+        <canvas id="priceChart"></canvas>
+    </div>
+    <script type="application/json" id="priceChartData">@Json.Serialize(Model)</script>
+    <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
+    <script src="~/js/price-chart.js" asp-append-version="true"></script>
+}
+```
+- **IMPLEMENT** (price-chart.js): `#priceChartData`의 JSON을 파싱해 라인차트 생성.
+```javascript
+class PriceChart {
+    constructor() {
+        const el = document.getElementById("priceChartData");
+        if (!el || typeof Chart === "undefined") return;
+
+        const data = JSON.parse(el.textContent);
+
+        new Chart(document.getElementById("priceChart"), {
+            type: "line",
+            data: {
+                labels: data.labels,
+                datasets: data.series.map((s) => ({
+                    label: s.name,
+                    data: s.values,
+                    borderWidth: 2,
+                    pointRadius: 2,
+                    tension: 0.2,
+                    spanGaps: true
+                }))
+            },
+            options: {
+                responsive: true,
+                maintainAspectRatio: false,
+                plugins: {
+                    legend: { display: data.series.length > 1 }
+                },
+                scales: {
+                    y: { beginAtZero: false }
+                }
+            }
+        });
+    }
+}
+
+const priceChart = new PriceChart();
+```
+- **MIRROR**: PARTIAL_EMPTY_GUARD, PAGE_JS
+- **GOTCHA**: ① 파셜 안에서 `@section Scripts`는 **무시됨**(Pagination.cshtml 전례) — 반드시 인라인 `<script>`. ② `@Json.Serialize`는 camelCase(labels/series/name/values)로 직렬화 — JS는 camelCase로 접근. ③ `Labels.Count > 1` 조건 — 점 1개짜리 라인은 무의미. ④ 부모 div 고정높이 + `maintainAspectRatio:false` 조합 필수.
+- **VALIDATE**: 아래 Task 4 이후 브라우저 확인
+
+### Task 4: 국제 페이지 (GlobalController — 액션 2개)
+- **ACTION**: NaturalGas·Commodity 액션에서 `itemList.Data = ... Skip/Take` **재할당 이전에** 차트 데이터 구성.
+- **IMPLEMENT** (두 액션 공통, private 헬퍼로 추출):
+```csharp
+// 국제 시세 응답을 차트 데이터로 변환 (최신 60개, 오름차순)
+private static ChartData BuildChart(string name, IEnumerable<(string Date, string Value)> rows)
+{
+    var points = rows
+        .Select(r => (r.Date, Parsed: decimal.TryParse(r.Value, NumberStyles.Any, CultureInfo.InvariantCulture, out decimal v) ? v : (decimal?)null))
+        .Where(p => p.Parsed.HasValue)
+        .Take(60)
+        .Reverse()
+        .ToList();
+
+    if (points.Count < 2)
+    {
+        return new ChartData();
+    }
+
+    return new ChartData
+    {
+        Labels = points.Select(p => p.Date).ToList(),
+        Series = [new ChartSeries { Name = name, Values = points.Select(p => p.Parsed).ToList() }]
+    };
+}
+```
+호출부(예: Commodity 액션, `if (itemList.Data != null && itemList.Data.Any())` 블록 첫 줄에서 보관 후, viewModel 구성 시 할당):
+```csharp
+ChartData chart = BuildChart(meta.Title, itemList.Data.Select(d => (d.Date, d.Value)));
+// ... 기존 Skip/Take 페이징 그대로 ...
+viewModel.Chart = chart;
+```
+NaturalGas 액션도 동일하게 `BuildChart("천연가스", ...)`.
+- **MIRROR**: RAW_VALUE_PARSE
+- **IMPORTS**: 기존 파일에 이미 `System.Globalization` 있음(2026-07-22 작업에서 추가됨)
+- **GOTCHA**: ① 반드시 Skip/Take **이전** — 이후에는 Data가 페이지 조각으로 교체됨. ② Alpha 데이터는 최신순이므로 `Take(60)` 후 `Reverse()`. ③ 추가 API 호출 없음(전체 데이터가 이미 메모리에 있음 — 캐시 덕분에 쿼터 영향 0).
+- **VALIDATE**: `/Price/Global/copper` 렌더 후 캔버스 존재 + 라인 1개
+
+### Task 5: 국내 페이지 (Gold/Oil/Emission 컨트롤러 3개)
+- **ACTION**: 각 Index 액션에서 표 조회와 별개로 차트용 데이터 1회 추가 조회(페이지와 무관하게 같은 날짜 범위 전체).
+- **IMPLEMENT** (GoldController 예 — Oil/Emission은 필드만 치환):
+```csharp
+// 차트용 데이터 조회 (같은 검색조건, 최대 300행)
+var chartRequest = new Request
+{
+    PageNo = 1,
+    NumOfRows = 300,
+    StartDate = request.StartDate,
+    EndDate = request.EndDate,
+    LikeSrtnCd = request.LikeSrtnCd   // Oil Request에는 이 필드 없음 — 제외
+};
+Response chartList = await domesticModel.GetGoldPriceInfo(chartRequest);
+
+if (chartList.Body?.Items?.ItemList is not null)
+{
+    var rows = chartList.Body.Items.ItemList;
+    var labels = rows.Select(r => r.BasDt).Distinct().OrderBy(d => d).ToList();
+
+    var chart = new ChartData
+    {
+        Labels = labels.Select(d => Common.StringToDateFormat(d)).ToList(),
+        Series = rows.GroupBy(r => r.ItmsNm).Select(g => new ChartSeries
+        {
+            Name = g.Key,
+            Values = labels.Select(d =>
+            {
+                var row = g.FirstOrDefault(r => r.BasDt == d);
+                return (row is not null && decimal.TryParse(row.Clpr, NumberStyles.Any, CultureInfo.InvariantCulture, out decimal v)) ? v : (decimal?)null;
+            }).ToList()
+        }).ToList()
+    };
+
+    viewModel.Chart = chart;
+}
+```
+필드 매핑: Gold/Emission → `ItmsNm`/`Clpr`, Oil → `OilCtg`/`WtAvgPrcCptn`.
+- **MIRROR**: DOMESTIC_EXTRA_FETCH, DATE_LABEL
+- **IMPORTS**: `System.Globalization` 추가 필요 (세 컨트롤러 모두 현재 없음)
+- **GOTCHA**: ① 차트 조회는 **원본 request를 변경하지 말고 복제본** 사용 — request는 뷰/페이지네이션이 그대로 씀. ② 표 데이터(`itemList`)는 컨트롤러가 NumberFormat으로 **문자열 포맷을 덮어쓴 후**라 재사용 불가 — 반드시 별도 조회분(raw)으로 계산. ③ 그룹별 결측 날짜는 null(→ JS `spanGaps`가 이어줌). ④ data.go.kr 쿼터는 넉넉하므로 +1회/페이지뷰 무방. 실패 시 Body null → 차트만 생략(표 영향 없음). ⑤ Gold/Emission의 `Request.Interval` 같은 필드는 없음 — 각 페이지 Request 클래스 필드만 복제.
+- **VALIDATE**: `/Price/Domestic/Gold` 렌더 → 종목 수만큼 라인 + 범례
+
+### Task 6: 뷰 5개에 파셜 include
+- **ACTION**: 각 뷰의 `<div class="table-responsive">` **바로 위**에 한 줄 추가:
+```razor
+@await Html.PartialAsync("~/Views/Component/Chart.cshtml", Model.Chart)
+```
+- **MIRROR**: PARTIAL_INCLUDE
+- **GOTCHA**: Model.Chart가 null이어도 파셜 첫 줄 `@if (Model != null ...)` guard가 렌더를 생략 — 페이지별 조건문 불필요 (guard는 Task 3에 포함됨).
+- **VALIDATE**: 5개 페이지 전부 표 위 차트 확인
+
+---
+
+## Testing Strategy
+
+repo에 테스트 프로젝트 없음 — 빌드 + 통합 스모크로 검증 (기존 방식).
+
+### Edge Cases Checklist
+- [ ] API 실패/빈 데이터 → 차트 없이 표의 "No data."만 (기존 alert 동작 유지)
+- [ ] 데이터 1건뿐 → 차트 미표시 (Labels.Count > 1 guard)
+- [ ] 국내: 날짜별 종목 결측 (신규 상장 등) → null 갭 처리
+- [ ] 국제: interval=annual (점 수 적음) → 정상 렌더
+- [ ] perPage/페이지 변경 → 차트는 동일 (페이지와 무관한 데이터)
+- [ ] CDN 차단 환경 → `typeof Chart === "undefined"` guard로 JS 에러 없이 차트만 생략
+
+## Validation Commands
+
+### Build
+```bash
+dotnet build economy.sln
+```
+EXPECT: 0 errors
+
+### Run & Smoke (기존 절차: 별도 출력 빌드로 사용자 실행 인스턴스 무간섭, 포트 5099)
+```bash
+curl -s http://localhost:5099/Price/Global/copper | grep -c "priceChartData"
+```
+EXPECT: copper/NaturalGas/Gold/Oil/Emission 각각 1 (차트 JSON 존재), 무관 페이지(FIFA 등) 0
+
+### Manual Validation
+- [ ] 5개 페이지에서 차트 렌더 + 호버 툴팁
+- [ ] 금 페이지: 종목별 라인 + 범례 표시
+- [ ] 원자재(지수) 페이지: 단일 라인
+- [ ] 기간/interval 변경 시 차트 갱신
+
+## Acceptance Criteria
+- [ ] 국내 3종 + 국제(8개 메뉴, 뷰 2개) 페이지 상단 차트 표시
+- [ ] 데이터 없는 상황에서 레이아웃 깨짐/JS 에러 없음
+- [ ] 빌드 0 에러, 기존 표/페이지네이션 동작 무변화
+- [ ] Alpha Vantage 추가 API 호출 0회 (국제), data.go.kr +1회/뷰 (국내)
+
+## Completion Checklist
+- [ ] 파셜/모델/JS가 Pagination 구성 패턴과 동형
+- [ ] 하드코딩 없음 (CDN 버전 문자열은 파셜 1곳)
+- [ ] 범위 외 페이지(품목별/화훼/FIFA 등) 무영향
+- [ ] Self-contained — 구현 중 추가 탐색 불필요
+
+## Risks
+| Risk | Likelihood | Impact | Mitigation |
+|---|---|---|---|
+| CDN 차단/오프라인 | 낮음 | 차트만 미표시 | JS에 `typeof Chart` guard — 페이지는 정상. 필요 시 로컬 번들 후속 |
+| 국내 차트 추가 호출로 페이지 지연 | 중간 | 응답 +0.5~2초 | 지연 체감 시 후속으로 표 조회와 Task.WhenAll 병렬화 |
+| 화훼/품목별에 차트 기대 | 낮음 | 기대 불일치 | NOT Building에 명시 — 필요 시 별도 계획 |
+| 국내 300행 캡 초과(긴 기간 검색) | 낮음 | 차트가 일부만 표시 | 기본 검색창 5일 기준 충분. 캡 초과 시 최신 300행 |
+
+## Notes
+- 국제 페이지는 2026-07-22 배포된 IMemoryCache(1일 TTL) 위에서 동작 — 차트 추가로 인한 Alpha 쿼터 소모 없음.
+- `Json.Serialize`는 MVC 기본 camelCase — JS 키는 `labels/series/name/values`.
+- 배출권 응답 모델은 Gold와 동일 구조(ItmsNm/Clpr/BasDt) — EmissionController가 이미 동일 필드를 포맷팅 중.
+- 운영 배포는 main 푸시만 하면 Jenkins `economy.web.or.kr` job이 5분 내 자동 배포(2026-07-22 구성).

+ 81 - 0
.claude/PRPs/reports/global-commodity-pages-report.md

@@ -0,0 +1,81 @@
+# Implementation Report: 국제 시세 커모디티 페이지 + 캐시 수리
+
+## Summary
+Alpha Vantage 기반 국제 시세 페이지 7종(구리·알루미늄·밀·섬유(면화)·설탕·커피·원자재 지수)을 단일 라우트 `Price/Global/{commodity}` + 공용 모델/뷰로 구현. `_Layout.cshtml` 국제 시세 드롭다운 복원(8개 링크). IMemoryCache DI 도입으로 캐시가 실제로 동작하게 수리(2차 요청 5ms 실측). 기존 버그 3건(원화 항상 0, 페이지네이션 interval 초기화, 환율 조회 레이스) 수정. 금/석유/배출권 조용한 실패에 alert 표출 추가.
+
+## Assessment vs Reality
+
+| Metric | Predicted (Plan) | Actual |
+|---|---|---|
+| Complexity | Medium | Medium |
+| Confidence | 8/10 | 적중 (통합테스트에서 환경 이슈 1건 발견·수정) |
+| Files Changed | 9 | 11 |
+
+## Tasks Completed
+
+| # | Task | Status | Notes |
+|---|---|---|---|
+| 1 | Commodity Request/Response 모델 | ✅ | |
+| 2 | GlobalModel GetCommodityPriceInfo + 캐시 | ✅ | 빈 응답 미캐시, JsonDocument 래퍼 제거 |
+| 3 | GlobalController 매핑+공용 액션+버그수정 | ✅ | GetExchangeValueAsync 헬퍼 추가(하단 Deviations) |
+| 4 | Commodity.cshtml 공용 뷰 | ✅ | interval 셀렉트, unit caption, KRW 열 토글 |
+| 5 | commodity.js | ✅ | interval 변경 시 page=1 리셋 |
+| 6 | Program.cs AddMemoryCache | ✅ | 인코딩 보존 위해 sed로 삽입(19행) |
+| 7 | _Layout.cshtml 메뉴 복원 | ✅ | 8개 링크, 기존 미커밋 변경 보존 |
+| 8 | 금/석유/배출권 오류 표출 | ✅ | else 분기 + ViewBag.isError |
+
+## Validation Results
+
+| Level | Status | Notes |
+|---|---|---|
+| Build | ✅ Pass | `dotnet build` 0 errors (신규 경고: 뷰 CS8602 1건 — 기존 패턴과 동일 수준) |
+| Unit Tests | N/A | repo에 테스트 프로젝트 없음(계획에서 범위 외로 명시) |
+| Integration | ✅ Pass | 별도 출력 빌드 + localhost:5099 스모크 |
+| Edge Cases | ✅ Pass | 아래 표 |
+
+### 통합 테스트 실측 (2026-07-22)
+
+| 케이스 | 결과 |
+|---|---|
+| `/Price/Global/copper` 1차 | 200, 3.7s (환율 3s 타임아웃 + Alpha 호출) |
+| `/Price/Global/copper` 2차 | 200, **0.005s** (캐시 적중) |
+| wheat/cotton/sugar/coffee/aluminum | 전부 200, ~0.2s |
+| `/Price/Global/allcommodities` | 200, 원화 열 없음·"지수" 헤더 확인 |
+| `/Price/Global/xyz` | 404 |
+| `/Price/Global/copper?interval=daily` | 400 (사전 차단) |
+| `/Price/Global/copper?interval=quarterly` | 200 |
+| `/Price/Global/NaturalGas` | 200 (기존 URL 유지) |
+| `/Price/Domestic/Gold` | 200 + 실데이터(20260721 행 확인 — data.go.kr 승인 반영) |
+| `/Price/Domestic/Oil`, `/Emission` | 200 |
+| 구리 페이지 본문 | 제목 "구리 시세", 단위 "dollar per metric ton", 값 "13,552.04" 포맷 확인 |
+
+## Files Changed
+
+| File | Action |
+|---|---|
+| `Models/Request/Price/Global/Commodity.cs` | CREATED |
+| `Models/Response/Price/Global/Commodity.cs` | CREATED |
+| `Views/Price/Global/Commodity.cshtml` | CREATED |
+| `wwwroot/js/commodity.js` | CREATED |
+| `Controllers/Price/GlobalController.cs` | UPDATED (매핑, 공용 액션, 버그 3건, 환율 헬퍼) |
+| `Models/Price/GlobalModel.cs` | UPDATED (IMemoryCache 주입, GetCommodityPriceInfo, 캐시 키 수정) |
+| `Program.cs` | UPDATED (AddMemoryCache, 19행) |
+| `Views/Shared/_Layout.cshtml` | UPDATED (국제 시세 메뉴 복원) |
+| `Controllers/Price/Domestic/GoldController.cs` | UPDATED (오류 표출) |
+| `Controllers/Price/Domestic/OilController.cs` | UPDATED (오류 표출) |
+| `Controllers/Price/Domestic/EmissionController.cs` | UPDATED (오류 표출) |
+
+## Deviations from Plan
+1. **환율 조회 3초 타임아웃 + 실패값 5분 네거티브 캐시 추가** — 통합 테스트에서 한국수출입은행 API가 이 환경에서 SSL 오류/무응답 행(hang)을 일으켜 페이지가 HttpClient 기본 100초까지 지연되는 문제 발견. `Task.WhenAny` 3초 상한으로 페이지 지연 차단, 실패 시 원화 0 표시(기존 동작과 동일). 기존 코드는 fire-and-forget 레이스 버그 때문에 우연히 안 막혔던 것.
+2. **feature 브랜치 미생성(main 유지)** — 사용자 워크플로가 main 직커밋이고, 더티 트리에 이 기능의 전제인 미커밋 변경(data.go.kr 키 교체)이 존재. 커밋은 사용자 지시 대기.
+3. **NaturalGas 액션에도 오류 alert 추가** — 계획은 Commodity에만 명시했으나 일관성 위해 동일 적용.
+4. Program.cs는 CP949로 추정되는 파일 인코딩 보존을 위해 Edit 대신 `sed` 바이트 삽입 사용.
+
+## Issues Encountered
+- 빌드 시 `economy.exe` 잠김(사용자 실행 중 인스턴스 PID 75640) → 별도 출력 폴더(`-o`) 빌드로 우회, 실행 중 앱 무간섭. **사용자 앱 재시작 전까지 로컬 실행 인스턴스에는 변경이 반영되지 않음.**
+- 한국수출입은행 환율 API가 이 환경에서 SSL 실패/행 → Deviation 1로 해결. 운영 환경에서 정상이면 원화 열 자동 표시됨.
+
+## Next Steps
+- [ ] 사용자 로컬 앱 재시작 후 메뉴 확인
+- [ ] 커밋 + 운영 배포 (data.go.kr 신 키가 운영에 반영되어야 금/석유/배출권 정상화)
+- [ ] (선택) FinancialModel의 per-instance 캐시도 IMemoryCache 주입으로 전환

+ 54 - 0
.claude/PRPs/reports/price-page-charts-report.md

@@ -0,0 +1,54 @@
+# Implementation Report: 국내/국제 시세 페이지 상단 미니 차트
+
+## Summary
+금·석유·배출권(국내) + 천연가스·커모디티 7종(국제) 페이지 상단에 Chart.js 라인 차트 추가. 공용 `ChartData` 모델 + `Views/Component/Chart.cshtml` 파셜 + `price-chart.js`로 구성(Pagination 파셜과 동형). 국제는 캐시된 전체 시계열 재사용(Alpha 추가 호출 0회, 최신 60포인트), 국내는 같은 검색조건으로 차트용 1회 추가 조회 후 종목/유종별 다중 시리즈 그룹핑.
+
+## Assessment vs Reality
+
+| Metric | Predicted (Plan) | Actual |
+|---|---|---|
+| Complexity | Medium | Medium |
+| Confidence | 9/10 | 적중 — 계획 그대로, 편차 없음 |
+| Files Changed | 13 | 13 |
+
+## Tasks Completed
+
+| # | Task | Status |
+|---|---|---|
+| 1 | ChartData 모델 | ✅ |
+| 2 | View<>에 Chart 프로퍼티 | ✅ |
+| 3 | Chart.cshtml 파셜 + price-chart.js | ✅ |
+| 4 | GlobalController BuildChart + 2개 액션 연결 | ✅ |
+| 5 | Gold/Oil/Emission 차트 조회+그룹핑 | ✅ |
+| 6 | 뷰 5개 파셜 include | ✅ |
+
+## Validation Results
+
+| Level | Status | Notes |
+|---|---|---|
+| Build | ✅ Pass | 0 errors |
+| Unit Tests | N/A | repo에 테스트 프로젝트 없음(계획 명시) |
+| Integration | ✅ Pass | localhost:5099 스모크 |
+| Edge Cases | ✅ Pass | 아래 |
+
+### 스모크 실측 (2026-07-22)
+- copper/NaturalGas/Gold/Oil/Emission 5개 페이지: `priceChartData` JSON + `<canvas>` 각 1개, 라벨 오름차순 확인
+- 금 페이지: "금 99.99_1kg", "미니금 99.99_100g" 등 종목별 다중 시리즈 ✓
+- copper `interval=annual`: 1992~ 연간 포인트 정상 ✓
+- FIFA 등 무관 페이지: 차트 요소 0 ✓
+- 국내 라벨 "2026-07-20","2026-07-21" — 기본 5일 창의 영업일 2일(주말 제외), 정상
+
+## Files Changed
+CREATE: `Models/ChartData.cs`, `Views/Component/Chart.cshtml`, `wwwroot/js/price-chart.js`
+UPDATE: `Models/View.cs`, `Controllers/Price/GlobalController.cs`, `Controllers/Price/Domestic/{Gold,Oil,Emission}Controller.cs`, `Views/Price/Domestic/{Gold,Oil,Emission}.cshtml`, `Views/Price/Global/{NaturalGas,Commodity}.cshtml`
+
+## Deviations from Plan
+없음 — 계획 그대로 구현. (세션 관례에 따라 feature 브랜치 없이 main에서 작업, 커밋은 사용자 지시 대기)
+
+## Issues Encountered
+없음.
+
+## Next Steps
+- [ ] 브라우저에서 시각 확인 (호버 툴팁·범례)
+- [ ] 커밋 + main 푸시 → Jenkins `economy.web.or.kr` 자동 배포(5분 내)
+- [ ] (선택) 국내 페이지 응답 지연 체감 시 표/차트 조회 Task.WhenAll 병렬화

+ 1 - 1
Views/Shared/_Layout.cshtml

@@ -131,7 +131,7 @@
     <footer class="border-top text-muted text-center">
         <iframe src="https://ads-partners.coupang.com/widgets.html?id=825687&template=carousel&trackingCode=AF0305179&subId=economy20240901&width=680&height=140&tsource=" width="680" height="140" frameborder="0" scrolling="no" referrerpolicy="unsafe-url" browsingtopics></iframe>
         <small>※ 쿠팡 파트너스 활동을 통해 일정액의 수수료를 제공받을수 있습니다.</small>
-        <a href="https://web.or.kr" ref="author" target="_blank">Copyright &copy; 2024~@DateTime.Now.Year All rights reserved.</a>
+        <a href="https://web.or.kr" ref="author" target="_blank">&copy; 2024~@DateTime.Now.Year TOSIRO. All rights reserved.</a>
     </footer>
 
     <script src="~/lib/jquery/dist/jquery.min.js"></script>

+ 9 - 9
appsettings.json

@@ -1,12 +1,12 @@
 {
-  "Logging": {
-    "LogLevel": {
-      "Default": "Information",
-      "Microsoft.AspNetCore": "Warning"
+    "Logging": {
+        "LogLevel": {
+            "Default": "Information",
+            "Microsoft.AspNetCore": "Warning"
+        }
+    },
+    "AllowedHosts": "*",
+    "ConnectionStrings": {
+        "DefaultConnection": "Server=192.168.0.201;Database=economy;User Id=sa;Password=@@20120726KKh;MultipleActiveResultSets=true;TrustServerCertificate=True"
     }
-  },
-  "AllowedHosts": "*",
-  "ConnectionStrings": {
-    "DefaultConnection": "Server=localhost;Database=Economy;Trusted_Connection=True;MultipleActiveResultSets=true"
-  }
 }