price-page-charts.plan.md 19 KB

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)

public Pagination Pagination { get; set; }
// → 동일하게 public ChartData? Chart { get; set; } 추가

PARTIAL_INCLUDE (SOURCE: Views/Price/Global/Commodity.cshtml:86)

@await Html.PartialAsync("~/Views/Component/Pagination.cshtml", Model.Pagination)

PARTIAL_EMPTY_GUARD (SOURCE: Views/Component/Pagination.cshtml:4)

@if (Model.TotalRows > 0)
{
<nav id="pagination" ...>

RAW_VALUE_PARSE (SOURCE: Controllers/Price/GlobalController.cs — Commodity 액션)

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

DomesticModel domesticModel = new DomesticModel(_dataGoKR, null);
Response itemList = await domesticModel.GetGoldPriceInfo(request);
// 차트용: 같은 모델 메서드를 numOfRows 크게 잡아 1회 더 호출 (request 복제본 사용)

DATE_LABEL (SOURCE: Helpers/Common.cs:46-49)

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:

    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.csPagination 프로퍼티(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 (파셜):

    @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을 파싱해 라인차트 생성.

    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 헬퍼로 추출):

    // 국제 시세 응답을 차트 데이터로 변환 (최신 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 구성 시 할당):

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은 필드만 치환):

    // 차트용 데이터 조회 (같은 검색조건, 최대 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"> 바로 위에 한 줄 추가:

    @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

dotnet build economy.sln

EXPECT: 0 errors

Run & Smoke (기존 절차: 별도 출력 빌드로 사용자 실행 인스턴스 무간섭, 포트 5099)

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 구성).