global-commodity-pages.plan.md 28 KB

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): 현재 DomesticModelEnsureSuccessStatusCode() 예외를 Console.WriteLine으로 삼켜 화면엔 "No data."만 나옴 → 업스트림 오류를 화면에 표시.

B. Alpha Vantage Commodities API (신규 페이지 데이터 소스)

  • Base: https://www.alphavantage.co/query?function={FUNC}&interval={interval}&apikey={key} — 키는 Models/Clients.csAlpha_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:74int.TryParse(row.Value, ...): 값이 "3.15" 같은 소수라 파싱 항상 실패 → 원화 열이 항상 0. decimal.TryParse(..., NumberStyles.Any, CultureInfo.InvariantCulture, ...)로 교체.
  2. GlobalController.cs:24 — 생성자에서 InitializeAsync()를 await 없이 호출(fire-and-forget) → 액션 실행 시점에 _exchangeValue가 0일 수 있는 레이스. 환율 조회를 액션 내부에서 await로 이동.
  3. GlobalController.cs:88-91 — 페이지네이션 queryString이 new { date = request.Interval }인데 바인딩 이름은 interval(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)

[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)

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 — 전 커모디티 공통 스키마)

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)

<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)

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)

@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 관용구)

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. 신규 메서드:

      // 국제 원자재 시세 조회
      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. 정적 매핑:

      // (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 액션 구조를 그대로 따르되:

      [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.TryParsedecimal.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 아래에 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

dotnet build economy.sln

EXPECT: 0 errors

Run & Smoke Test

dotnet run
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, 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). 남은 당일 쿼터 유의.