# 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 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 + 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 { 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 ... ``` ### 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) { } ``` ### DI_CACHE (SOURCE: Program.cs:11-17 + FinancialModel.cs:138-146의 캐시 키/TryGetValue 관용구) ```csharp builder.Services.AddHttpClient(); // 기존 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 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(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 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 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 + 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` - `ViewData["Title"] = (string)ViewBag.Title` (컨트롤러 주입) - perPage 셀렉트 옆에 interval 셀렉트 추가(`form="fSearch"`): `monthly=월간, quarterly=분기, annual=연간` — `