금·석유·배출권(국내)과 천연가스·커모디티 7종(국제) 페이지 상단에 Chart.js 라인 차트를 추가한다. 공용 ChartData 모델 + Views/Component/Chart.cshtml 파셜 + price-chart.js 하나로 전 페이지를 처리한다(Pagination 파셜과 동일한 구성 패턴). 국제 페이지는 이미 메모리에 있는 전체 시계열을 재사용(추가 API 0회), 국내 페이지는 같은 검색조건으로 차트용 데이터 1회를 추가 조회한다.
가능 여부: 가능 — 모든 대상 페이지가 날짜-가격 시계열을 이미 서버에서 받고 있어 렌더링만 추가하면 된다.
As a 사이트 방문자, I want 표를 읽기 전에 가격 흐름을 그래프로 한눈에 보기, so that 최근 추세(상승/하락)를 즉시 파악할 수 있다.
현재: 모든 시세 페이지가 표만 표시 → 추세 파악에 스크롤·암산 필요. 변경 후: 표 위에 240px 높이 라인 차트(국내: 종목별 다중 라인, 국제: 단일 라인) 표시. 데이터 없으면 차트 영역 자체가 렌더링되지 않음.
[제목] [검색조건 셀렉트]
┌─────────── 표 ───────────┐
│ 번호 | 날짜 | 가격 ... │
└──────────────────────────┘
[페이지네이션]
[제목] [검색조건 셀렉트]
┌───────── 차트 (240px) ────────┐
│ ╭─╮ ← 라인차트 │
│ ╱ ╰─╮ 국내: 종목별 여러 라인 │
│╱ ╰─ 국제: 단일 라인 │
└───────────────────────────────┘
┌─────────── 표 ───────────┐ ← 기존 그대로
└──────────────────────────┘
[페이지네이션]
| Touchpoint | Before | After | Notes |
|---|---|---|---|
| 시세 페이지 상단 | 없음 | 라인 차트 + 호버 툴팁 + 범례(다중 시리즈 시) | Chart.js 기본 동작 |
| 검색조건 변경 | 표만 갱신 | 차트도 같은 조건으로 갱신 | 서버 렌더라 자동 |
| 데이터 없음/API 실패 | "No data." | 차트 미표시 + 기존 "No data." | 파셜이 null/빈 데이터면 아무것도 출력 안 함 |
| 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 위치 참고 |
| 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 파셜의 스크립트 태그.
public Pagination Pagination { get; set; }
// → 동일하게 public ChartData? Chart { get; set; } 추가
@await Html.PartialAsync("~/Views/Component/Pagination.cshtml", Model.Pagination)
@if (Model.TotalRows > 0)
{
<nav id="pagination" ...>
if (decimal.TryParse(row.Value, NumberStyles.Any, CultureInfo.InvariantCulture, out decimal value) && value > 0)
DomesticModel domesticModel = new DomesticModel(_dataGoKR, null);
Response itemList = await domesticModel.GetGoldPriceInfo(request);
// 차트용: 같은 모델 메서드를 numOfRows 크게 잡아 1회 더 호출 (request 복제본 사용)
Common.StringToDateFormat(row.BasDt) // "20260721" → "2026-07-21"
| 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 | 〃 |
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 에러
Pagination 프로퍼티(78행) 위에 public ChartData? Chart { get; set; } 추가.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 이후 브라우저 확인
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("천연가스", ...).
System.Globalization 있음(2026-07-22 작업에서 추가됨)Take(60) 후 Reverse(). ③ 추가 API 호출 없음(전체 데이터가 이미 메모리에 있음 — 캐시 덕분에 쿼터 영향 0)./Price/Global/copper 렌더 후 캔버스 존재 + 라인 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.
System.Globalization 추가 필요 (세 컨트롤러 모두 현재 없음)itemList)는 컨트롤러가 NumberFormat으로 문자열 포맷을 덮어쓴 후라 재사용 불가 — 반드시 별도 조회분(raw)으로 계산. ③ 그룹별 결측 날짜는 null(→ JS spanGaps가 이어줌). ④ data.go.kr 쿼터는 넉넉하므로 +1회/페이지뷰 무방. 실패 시 Body null → 차트만 생략(표 영향 없음). ⑤ Gold/Emission의 Request.Interval 같은 필드는 없음 — 각 페이지 Request 클래스 필드만 복제./Price/Domestic/Gold 렌더 → 종목 수만큼 라인 + 범례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개 페이지 전부 표 위 차트 확인
repo에 테스트 프로젝트 없음 — 빌드 + 통합 스모크로 검증 (기존 방식).
typeof Chart === "undefined" guard로 JS 에러 없이 차트만 생략dotnet build economy.sln
EXPECT: 0 errors
curl -s http://localhost:5099/Price/Global/copper | grep -c "priceChartData"
EXPECT: copper/NaturalGas/Gold/Oil/Emission 각각 1 (차트 JSON 존재), 무관 페이지(FIFA 등) 0
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| CDN 차단/오프라인 | 낮음 | 차트만 미표시 | JS에 typeof Chart guard — 페이지는 정상. 필요 시 로컬 번들 후속 |
| 국내 차트 추가 호출로 페이지 지연 | 중간 | 응답 +0.5~2초 | 지연 체감 시 후속으로 표 조회와 Task.WhenAll 병렬화 |
| 화훼/품목별에 차트 기대 | 낮음 | 기대 불일치 | NOT Building에 명시 — 필요 시 별도 계획 |
| 국내 300행 캡 초과(긴 기간 검색) | 낮음 | 차트가 일부만 표시 | 기본 검색창 5일 기준 충분. 캡 초과 시 최신 300행 |
Json.Serialize는 MVC 기본 camelCase — JS 키는 labels/series/name/values.economy.web.or.kr job이 5분 내 자동 배포(2026-07-22 구성).