개주 훈련일지/🏋️ 전집중 호흡 훈련

관리자 대시보드 제작기: 더미 데이터 기반 화면을 서버 연동 구조로 전환하기

lshfood2 2026. 2. 14. 18:00
 

관리자 대시보드 제작기: 캐시 충전 데이터로 인사이트 만들기

[ 관리자 대시보드 만들기: 캐시 지표 ]프로젝트를 진행하면서 운영 관점의 지표를한눈에 확인할 수 있는 관리자 대시보드가 필요했다. 특히 캐시 충전 데이터는이번 달 상태만 보여주면 끝이

lshfood2.tistory.com

[ 서버 연동 구조로 전환하기 ]

지난 포스팅에서는 관리자 대시보드에서

캐시 충전 데이터를 KPI/비율/월별 추이로

시각화해 인사이트 지표를 만드는 작업을 다뤘다.

 

다만 그때는 서버 연동 없이

화면 구성을 먼저 완성하는 단계였기 때문에,

대시보드 데이터는 더미 데이터(vm)로

하드코딩해 두고 구현했다.

 

이번 포스팅에서는

기존 화면 렌더링 로직은 그대로 유지하면서,

나중에 API만 연결하면 자연스럽게

서버 연동으로 전환될 수 있도록

‘연동 준비 구조’를 먼저 만들어두는 과정을 정리한다.

 

이번 글에서 바꾼 포인트는 3가지다.

  1. JSP가 JS에게 프로젝트 루트 경로(ctx)를
    전달하도록 window.APP_CTX를 추가했다
  2. 커맨드객체 바인딩을 위해
    요청 파라미터 키를 year/month로 고정했다
  3. 서버 응답(JSON)에서 DTO 키
    (provider, month, cashAmount)를 그대로 읽고,
    화면용 vm으로 변환(toVM)하는 레이어를 준비했다

 1. JS는 JSP의 ${ctx}를 모른다: window.APP_CTX가 필요한 이유

JSP 안에서는 ${ctx}를 그대로 사용할 수 있다.
하지만 assets/js/admindashboardcash.js 같은

정적 JS 파일은 서버 렌더링(EL) 대상이

아니라서 ${ctx}를 알 수 없다.

 

그래서 JSP가 한 번만 ctx 값을 전역 변수로 내려주고,

JS는 그 값을 이용해 API URL을 만든다.

 

JSP에 추가 (admindashboardcash.js 로드 직전)

<script>
  // 프로젝트 컨텍스트 경로
  // 예) /animale
  window.APP_CTX = '${ctx}';
</script>

 

이제 JS에서 안전하게 URL 생성 가능

const url = window.APP_CTX + '/api/admin/cash/admindashboard?year=2026&month=2';
fetch(url);

2. 초기 조회 기준값은 '브라우저 날짜'로 잡아둔다: window.DASH_INIT

대시보드 API는 year/month가 있어야 조회가 된다.
현재 서버에서 '초기 기준 year/month'를

내려주지 않는 상태라면 브라우저 기준 날짜로

기본값을 잡아도 된다.

 

중요한 점은 이 값이

'화면에 날짜를 표시'하는 게 아니라,

'API 호출 파라미터 기본값'이라는 점이다.

 

JSP에 추가

<script>
  window.APP_CTX = '${ctx}';

  // 대시보드 초기 조회 기준값(연/월)
  // 서버 작업 완료 시 EL로 바꿔서 서버 기준으로 통일 가능
  window.DASH_INIT = {
    year: new Date().getFullYear(),
    month: new Date().getMonth() + 1 // JS month는 0~11이라 +1 필요
  };
</script>

 

2-1. ‘요청 파라미터 키를 year/month로 고정’

‘DTO 통일’이라고 해서 DTO 전체 필드가

프론트에 그대로 보이는 게 아니다.


프론트에서 DTO와 직접 맞물리는 지점은

결국 ‘요청을 보낼 때의 키 이름’과

‘응답을 읽을 때의 키 이름’이다.

 

우리 대시보드 API는

year/month로 조회가 결정되기 때문에 프론트는

쿼리스트링 키를 year/month로 고정해 두면 된다.

 

@RequestParam을 쓰는 컨트롤러의 키

// 현재 형태: 쿼리 파라미터 year/month를 각각 받음
@GetMapping("/admindashboard")
public Map<String, Object> dashboard(@RequestParam int year, @RequestParam int month) {
    return cashChargeService.getDashboardSummary(year, month);
}

 

커맨드객체(DTO)로 파라미터 통일하는 컨트롤러

// 최종 형태(권장): 쿼리 파라미터 year/month -> DTO.year/DTO.month 자동 바인딩
@GetMapping("/admindashboard")
public Map<String, Object> dashboard(CashChargeDTO dto) {
    return cashChargeService.getDashboardSummary(dto.getYear(), dto.getMonth());
}

 

둘 다 공통점은 하나다.

프론트가 보내는 키가 year/month이면

서버가 @RequestParam을 쓰든,

DTO 커맨드객체를 쓰든

매핑이 안정적으로 유지된다.

 

그래서 fetch에서도 year/month로 고정한다.

const qs = new URLSearchParams({
  year: String(year),
  month: String(month)
});

3. 'DTO 명칭 통일'은 DTO 전체가 아니라, 연결 지점만 해당된다

대시보드 화면에서 DTO와

직접 연결되는 지점은 아래 2군데다.

 

1) 요청 파라미터 키 (커맨드객체 바인딩)

  • CashChargeDTO에 year/month가 있고
  • 요청도 year/month로 보내면 바인딩이 안정적이다.

2) 응답 JSON 키 (DTO 리스트를 JSON으로 받는 구간)

  • providerList/yearMonthly가
    CashChargeDTO 리스트로 내려오면
  • 프론트에서는 row.provider, row.month,
    row.cashAmount로 그대로 읽어야 한다.

반면, 화면용으로 가공된 값들은

ViewModel(vm) 성격이라 DTO 필드와

1:1로 같을 필요가 없다.

  • thisMonthTotal, lastMonthTotal, momPercent
  • kakaoPercent, tossPercent
  • monthlyTotals(12칸 배열)

4. 서버 응답을 화면용 vm으로 바꾸는 toVM 레이어를 준비한다

핵심은 '기존 렌더링 로직은 vm만 바라보게' 유지하는 것이다.
서버를 붙이는 날에는 vm 하드코딩만 제거하고

fetch/toVM을 켜면 된다.

 

현재는 서버 연동을 바로 붙이지 않기 때문에

아래 블록을 주석으로 보관해 두는 방식이 안전하다.

 

admindashboardcash.js 준비 코드 (주석 보관용)

/*
(async function () {
  // 0) 서버 API 호출
  // - 요청 파라미터 키는 year/month로 고정 (커맨드객체 바인딩 대비)
  const fetchDashboard = async (year, month) => {
    const qs = new URLSearchParams({
      year: String(year),
      month: String(month)
    });

    const url = window.APP_CTX + '/api/admin/cash/admindashboard?' + qs.toString();

    const res = await fetch(url, { headers: { Accept: 'application/json' } });
    if (!res.ok) throw new Error('dashboard api failed: ' + res.status);

    return res.json();
  };

  // 1) 서버 응답(JSON)을 화면용 vm으로 변환
  // - DTO 키(provider, month, cashAmount)는 응답에서 그대로 읽는다
  const toVM = (api, fallbackYear, fallbackMonth) => {
    const year = Number(api.year ?? fallbackYear);
    const month = Number(api.month ?? fallbackMonth);

    const thisMonthTotal = Number(api.thisMonthTotal ?? 0);
    const lastMonthTotal = Number(api.lastMonthTotal ?? 0);

    // 서버가 momPercent를 내려주면 사용, 없으면 null로 둬서 프론트 분기 로직이 처리
    const momPercent = api.momPercent == null ? null : Number(api.momPercent);

    // providerList: [{provider:'KAKAOPAY', cashAmount:합계}, ...]
    let kakaoTotal = 0;
    let tossTotal = 0;
    const providerList = Array.isArray(api.providerList) ? api.providerList : [];

    for (const row of providerList) {
      const p = String(row.provider ?? '');
      const total = Number(row.cashAmount ?? 0); // DTO 키 cashAmount 그대로 사용
      if (p === 'KAKAOPAY') kakaoTotal = total;
      if (p === 'TOSSPAY') tossTotal = total;
    }

    // 퍼센트는 이번달 합계 기준
    let kakaoPercent = 0;
    let tossPercent = 0;
    if (thisMonthTotal > 0) {
      kakaoPercent = Math.round((kakaoTotal * 100) / thisMonthTotal);
      tossPercent = Math.max(0, 100 - kakaoPercent); // 반올림 오차 보정
    }

    // yearMonthly: [{month:1, cashAmount:합계}, ...] -> 12칸 배열
    const monthlyTotals = new Array(12).fill(0);
    const yearMonthly = Array.isArray(api.yearMonthly) ? api.yearMonthly : [];

    for (const row of yearMonthly) {
      const m = Number(row.month);               // DTO 키 month 그대로 사용
      const total = Number(row.cashAmount ?? 0); // DTO 키 cashAmount 그대로 사용
      if (m >= 1 && m <= 12) monthlyTotals[m - 1] = total;
    }

    return {
      year,
      month,
      thisMonthTotal,
      lastMonthTotal,
      momPercent,
      kakaoPercent,
      tossPercent,
      monthlyTotals
    };
  };

  // 2) 초기 기준값: JSP에서 주입한 DASH_INIT 사용
  const init = window.DASH_INIT || {};
  const initYear = Number(init.year || new Date().getFullYear());
  const initMonth = Number(init.month || new Date().getMonth() + 1);

  // 3) 최초 1회 로딩 + 실패 대비 fallback
  let vm;
  try {
    const api = await fetchDashboard(initYear, initMonth);
    vm = toVM(api, initYear, initMonth);
  } catch (e) {
    console.error(e);
    vm = {
      year: initYear,
      month: initMonth,
      thisMonthTotal: 0,
      lastMonthTotal: 0,
      momPercent: null,
      kakaoPercent: 0,
      tossPercent: 0,
      monthlyTotals: new Array(12).fill(0)
    };
  }

  // 이후 기존 렌더링 로직은 vm만 사용하면 된다
})();
*/

5. 연도 선택 시 '월별 그래프만 교체'를 유지하는 캐시 설계

초기 컨셉이 '연도 선택 시 월별 그래프만 바뀐다'라면,

연도별 월간합계를 캐시에 저장해 두는 구조가 깔끔하다.

  • monthlyByYear[year]에 12칸 배열을 저장
  • 선택한 연도가 캐시에 없을 때만 API로 받아서 저장
  • renderMonthly(year)로 그래프만 갱신

주석 보관용 코드

/*
const monthlyByYear = {};
monthlyByYear[vm.year] = normalizeMonthly(vm.monthlyTotals);

yearSelect.addEventListener('change', async function () {
  const year = Number(this.value);

  if (!monthlyByYear[year]) {
    try {
      // month는 초기 month 유지 (월별 그래프만 교체 컨셉 유지)
      const apiY = await fetchDashboard(year, vm.month);
      const vmY = toVM(apiY, year, vm.month);
      monthlyByYear[year] = normalizeMonthly(vmY.monthlyTotals);
    } catch (e) {
      console.error(e);
      monthlyByYear[year] = new Array(12).fill(0);
    }
  }

  renderMonthly(year);
  vm.year = year;
});
*/

6. 서버 연동을 켜는 날 체크리스트

현재 상태(더미 vm 유지 + 연동 블록 주석 보관)에서

서버 연동으로 전환할 때는 아래만 하면 된다.

  • IIFE를 async로 전환한다
    : (function) -> (async function)
  • 더미 vm 하드코딩을 제거한다
  • fetchDashboard/toVM 주석을 해제한다
  • monthlyByYear를 캐시 구조로 바꾸고
    yearSelect 이벤트를 async 버전으로 교체한다
  • API 실패 대비 fallback(vm 0값)은 유지한다

[ 마무리 ]

이번 작업은 서버 연동을

지금 당장 붙이는 게 아니라,

 

화면 로직을 안정적으로 유지한 채

전환할 수 있는 '구조'를 먼저 만드는 단계였다.

  • JS가 ctx를 모르는 문제는
    window.APP_CTX로 해결한다
  • 요청 파라미터는 year/month로 고정해
    커맨드객체 바인딩 기반을 만든다
  • 응답에서는 DTO 키(provider, month, cashAmount)를
    그대로 읽고, 화면용 vm으로 변환(toVM)해
    기존 렌더링을 그대로 유지한다