대시보드 → n8n → Slack 정기 리포트 Implementation Plan#
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 문자ON 대시보드의 매출/회원/문자발송 지표를 토큰 인증 JSON API로 노출하고, n8n이 스케줄로 호출해 Slack에 정기 요약을 게시한다.
Architecture: 신규 모듈 itn.let.mjo.dashboardapi에 @RestController 1개 + Service/Impl + 응답 DTO + 토큰 인증 인터셉터를 추가한다. 집계는 기존 검증된 서비스(mjonPayService, conectStatsService, mjonMsgService)를 재사용하며 신규 SQL/DAO는 없다. Slack 표현(콤마/원화/Block Kit)은 n8n이 담당하고 앱은 raw 숫자 JSON만 반환한다.
Tech Stack: Java 1.8, Spring MVC 4.3 (eGovFramework 3.9.0), iBATIS(SqlMapClient), Jackson 2.5.1, Lombok 1.18.24, Maven(WAR). n8n(Schedule + HTTP Request + Code + Slack).
Global Constraints#
- Java 1.8 / Spring 4.3 문법만 사용. 신규 라이브러리 추가 금지(기존 pom.xml 의존성만).
- 신규 SQL/DAO 작성 금지 — 기존 서비스 메서드만 호출.
- 기존 4종 패턴 준수:
web(@Controller/@RestController) /service(인터페이스+VO) /service.impl(@Service). - 빈 스캔 분리 규칙: 컨트롤러·인터셉터=디스패처 컨텍스트(
*-servlet.xml), 서비스=루트 컨텍스트(context-*.xml). - 관리자 로그 AOP(
itn..*Controller.select*등) 회피를 위해 컨트롤러 메서드명은getDashboardSummary(=select*/insert*/update*/delete*아님)로 짓는다. - 토큰 헤더명:
X-DASHBOARD-TOKEN. 프로퍼티 키:Globals.dashboard.api.token. - 테스트 인프라 없음: 본 저장소는
src/test가 없고 surefireskipTests=true다. 따라서 각 태스크의 검증은mvn compile성공 + 수동 curl/실행 확인으로 수행한다(자동 단위테스트는 본 작업 범위 아님 — 스펙에서 수동 검증으로 합의됨). - 인코딩 UTF-8. 토큰 값은 로그/응답에 출력 금지.
Task 1: 토큰 프로퍼티 추가#
Files:
- Modify:
src/main/resources/egovframework/egovProps/globals.properties(끝에 추가) - Modify:
src/main/resources/egovframework/egovProps/globals_local.properties - Modify:
src/main/resources/egovframework/egovProps/globals_dev.properties - Modify:
src/main/resources/egovframework/egovProps/globals_prod.properties
Interfaces:
Consumes: 없음
Produces: 프로퍼티 키
Globals.dashboard.api.token(이후 Task 5 인터셉터가globalSettings로 읽음)Step 1:
globals.properties끝에 기본 토큰 추가
globals.properties 파일 맨 끝(line 101 이후)에 아래 블록 추가:
# 대시보드 요약 API(n8n→Slack) 공유 토큰
Globals.dashboard.api.token=CHANGE_ME_DEV_TOKEN
- Step 2: 환경별 프로퍼티에 동일 키 추가(override)
globals_local.properties, globals_dev.properties, globals_prod.properties 각 파일 끝에 아래를 추가(운영 값은 배포 시 교체):
# 대시보드 요약 API(n8n→Slack) 공유 토큰
Globals.dashboard.api.token=CHANGE_ME_TOKEN
활성 프로파일은
-Dspring.profiles.active로 결정되며globals_<profile>.properties가 실제 사용된다(context-properties.xml의globalSettings).globals.properties는 공통 기본값.
- Step 3: 추가 확인
Run: grep -rn "Globals.dashboard.api.token" src/main/resources/egovframework/egovProps/
Expected: 4개 파일 모두에서 키가 출력됨.
- Step 4: Commit
git add src/main/resources/egovframework/egovProps/globals*.properties
git commit -m "feat(dashboard-api): 대시보드 요약 API 토큰 프로퍼티 추가"
Task 2: 응답 DTO (DashboardSummaryVO)#
Files:
- Create:
src/main/java/itn/let/mjo/dashboardapi/service/DashboardSummaryVO.java
Interfaces:
Consumes: 없음
Produces: 아래 타입들(이후 Task 3 서비스가 채우고 Task 4 컨트롤러가 반환). Lombok
@Getter @Setter사용.DashboardSummaryVO{boolean isSuccess,String msg,String baseDate,Sales sales,Members members,Sms sms}DashboardSummaryVO.Sales{long todayAmt,List<AmtPoint> dayChart,List<AmtPoint> monthChart,long yearAmtSum}DashboardSummaryVO.AmtPoint{String label,long amt}DashboardSummaryVO.Members{int userNorCnt, userComCnt, mberNorTot, mberBlockTot, mberSecessTot, cmpApply, cmpApprove, cmpReject, todayExpireCnt, todayStopCnt}DashboardSummaryVO.Sms{List<AgentStat> agents,int todaySignupCnt}DashboardSummaryVO.AgentStat{String agentCode,int succCnt,int failCnt,int succRate}
Step 1: DTO 파일 작성
src/main/java/itn/let/mjo/dashboardapi/service/DashboardSummaryVO.java:
package itn.let.mjo.dashboardapi.service;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
import lombok.Getter;
import lombok.Setter;
/**
* 대시보드 요약 API(n8n→Slack) 응답 DTO.
* 앱은 표현 포맷 없이 raw 숫자만 제공한다(콤마/원화는 n8n에서 처리).
*/
@Getter
@Setter
public class DashboardSummaryVO implements Serializable {
private static final long serialVersionUID = 1L;
private boolean isSuccess = true;
private String msg = "";
private String baseDate;
private Sales sales = new Sales();
private Members members = new Members();
private Sms sms = new Sms();
/** 매출(VAT 포함, 단위: 원) */
@Getter
@Setter
public static class Sales implements Serializable {
private static final long serialVersionUID = 1L;
private long todayAmt;
private List<AmtPoint> dayChart = new ArrayList<AmtPoint>();
private List<AmtPoint> monthChart = new ArrayList<AmtPoint>();
private long yearAmtSum;
}
/** 금액 시계열 한 점 (label = "MM-DD" 또는 "N월") */
@Getter
@Setter
public static class AmtPoint implements Serializable {
private static final long serialVersionUID = 1L;
private String label;
private long amt;
public AmtPoint() {
}
public AmtPoint(String label, long amt) {
this.label = label;
this.amt = amt;
}
}
/** 회원 현황 */
@Getter
@Setter
public static class Members implements Serializable {
private static final long serialVersionUID = 1L;
private int userNorCnt;
private int userComCnt;
private int mberNorTot;
private int mberBlockTot;
private int mberSecessTot;
private int cmpApply;
private int cmpApprove;
private int cmpReject;
private int todayExpireCnt;
private int todayStopCnt;
}
/** 문자 발송 현황 */
@Getter
@Setter
public static class Sms implements Serializable {
private static final long serialVersionUID = 1L;
private List<AgentStat> agents = new ArrayList<AgentStat>();
private int todaySignupCnt;
}
/** 전송사별 발송 통계 */
@Getter
@Setter
public static class AgentStat implements Serializable {
private static final long serialVersionUID = 1L;
private String agentCode;
private int succCnt;
private int failCnt;
private int succRate;
}
}
주의: Jackson 2.5.1은
boolean isSuccess게터를success로 직렬화할 수 있다. 기존 코드와 동일하게 키를isSuccess로 노출하려면 Task 4에서 확인한다(필요 시@com.fasterxml.jackson.annotation.JsonProperty("isSuccess")를 게터에 부여). 본 DTO는 우선 lombok 기본 게터로 두고 Task 4 검증 단계에서 키를 확정한다.
- Step 2: 컴파일 확인
Run: mvn -q -o compile (오프라인 불가 시 mvn -q compile)
Expected: BUILD SUCCESS. DashboardSummaryVO 컴파일 에러 없음.
- Step 3: Commit
git add src/main/java/itn/let/mjo/dashboardapi/service/DashboardSummaryVO.java
git commit -m "feat(dashboard-api): 요약 응답 DTO 추가"
Task 3: 집계 서비스 (Service + Impl)#
Files:
- Create:
src/main/java/itn/let/mjo/dashboardapi/service/DashboardSummaryService.java - Create:
src/main/java/itn/let/mjo/dashboardapi/service/impl/DashboardSummaryServiceImpl.java
Interfaces:
Consumes:
DashboardSummaryVO(Task 2)- 기존 빈:
mjonPayService(itn.let.mjo.pay.service.MjonPayService),conectStatsService(itn.let.sts.cst.service.EgovConectStatsService),mjonMsgService(itn.let.mjo.msg.service.MjonMsgService)
Produces:
DashboardSummaryService.getDashboardSummary(String baseDate) -> DashboardSummaryVOStep 1: 서비스 인터페이스 작성
src/main/java/itn/let/mjo/dashboardapi/service/DashboardSummaryService.java:
package itn.let.mjo.dashboardapi.service;
/**
* 대시보드 요약 API 집계 서비스.
* 기존 대시보드 서비스 메서드를 조합해 raw 숫자 요약을 만든다(신규 SQL 없음).
*/
public interface DashboardSummaryService {
/**
* @param baseDate "yyyy-MM-dd" 또는 null(오늘)
* @return 매출/회원/문자 요약
*/
DashboardSummaryVO getDashboardSummary(String baseDate) throws Exception;
}
- Step 2: 구현체 작성
src/main/java/itn/let/mjo/dashboardapi/service/impl/DashboardSummaryServiceImpl.java:
package itn.let.mjo.dashboardapi.service.impl;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.List;
import javax.annotation.Resource;
import org.springframework.stereotype.Service;
import itn.let.mjo.dashboardapi.service.DashboardSummaryService;
import itn.let.mjo.dashboardapi.service.DashboardSummaryVO;
import itn.let.mjo.msg.service.MjonMsgService;
import itn.let.mjo.msg.service.MjonMsgStatVO;
import itn.let.mjo.msg.service.MjonMsgVO;
import itn.let.mjo.pay.service.MjonPayService;
import itn.let.mjo.pay.service.MjonPayVO;
import itn.let.sts.cst.service.EgovConectStatsService;
import itn.let.uss.umt.service.MberManageVO;
@Service("dashboardSummaryService")
public class DashboardSummaryServiceImpl implements DashboardSummaryService {
@Resource(name = "mjonPayService")
private MjonPayService mjonPayService;
@Resource(name = "conectStatsService")
private EgovConectStatsService conectStatsService;
@Resource(name = "mjonMsgService")
private MjonMsgService mjonMsgService;
@Override
public DashboardSummaryVO getDashboardSummary(String baseDate) throws Exception {
DashboardSummaryVO vo = new DashboardSummaryVO();
if (baseDate == null || baseDate.trim().isEmpty()) {
baseDate = new SimpleDateFormat("yyyy-MM-dd").format(new Date());
}
vo.setBaseDate(baseDate);
// 지표 블록별 try/catch: 개별 실패가 전체 응답을 막지 않도록 한다.
try {
fillSales(vo.getSales());
} catch (Exception e) {
vo.setSuccess(false);
vo.setMsg(appendMsg(vo.getMsg(), "sales:" + e.getMessage()));
}
try {
fillMembers(vo.getMembers());
} catch (Exception e) {
vo.setSuccess(false);
vo.setMsg(appendMsg(vo.getMsg(), "members:" + e.getMessage()));
}
try {
fillSms(vo.getSms());
} catch (Exception e) {
vo.setSuccess(false);
vo.setMsg(appendMsg(vo.getMsg(), "sms:" + e.getMessage()));
}
return vo;
}
/** 매출(일/월/연) — selectPayDayChartDashboard, selectPayMonthChartDashboard, selectPayYearChart 재사용 */
private void fillSales(DashboardSummaryVO.Sales sales) throws Exception {
// 일별(최근 4일, 최신순)
MjonPayVO dayReq = new MjonPayVO();
dayReq.setFirstIndex(0);
dayReq.setRecordCountPerPage(4);
dayReq.setSearchSortCnd("regDate");
dayReq.setSearchSortOrd("desc");
List<MjonPayVO> dayList = mjonPayService.selectPayDayChartDashboard(dayReq);
if (dayList != null) {
for (MjonPayVO p : dayList) {
String label = p.getRegDate();
if (label != null && label.length() >= 10) {
label = label.substring(5, 10); // MM-DD
}
sales.getDayChart().add(new DashboardSummaryVO.AmtPoint(label, toLong(p.getAmtTotSum())));
}
if (!dayList.isEmpty()) {
sales.setTodayAmt(toLong(dayList.get(0).getAmtTotSum())); // 최신순 첫 행 = 당일
}
}
// 월별(최근 4개월, 최신순)
MjonPayVO monthReq = new MjonPayVO();
monthReq.setFirstIndex(0);
monthReq.setRecordCountPerPage(4);
monthReq.setSearchSortCnd("regDate");
monthReq.setSearchSortOrd("desc");
List<MjonPayVO> monthList = mjonPayService.selectPayMonthChartDashboard(monthReq);
if (monthList != null) {
for (MjonPayVO p : monthList) {
String label = p.getRegDate();
if (label != null && label.length() >= 7) {
label = Integer.parseInt(label.substring(5, 7)) + "월";
}
sales.getMonthChart().add(new DashboardSummaryVO.AmtPoint(label, toLong(p.getAmtTotSum())));
}
}
// 연간 누적
MjonPayVO yearInfo = mjonPayService.selectPayYearChart(new MjonPayVO());
if (yearInfo != null) {
sales.setYearAmtSum(toLong(yearInfo.getAmtYearSum()));
}
}
/** 회원 현황 — EgovMainController.selectMemberStatsAjax 로직 재현 */
private void fillMembers(DashboardSummaryVO.Members m) throws Exception {
MberManageVO req = new MberManageVO();
req.setHstSttus("01");
List<MberManageVO> apply = conectStatsService.selectMberCmpHstStatsDashboard(req);
req.setHstSttus("02");
List<MberManageVO> approve = conectStatsService.selectMberCmpHstStatsDashboard(req);
req.setHstSttus("03");
List<MberManageVO> reject = conectStatsService.selectMberCmpHstStatsDashboard(req);
m.setCmpApply((apply == null || apply.isEmpty()) ? 0 : apply.get(0).getTotCnt());
m.setCmpApprove((approve == null || approve.isEmpty()) ? 0 : approve.get(0).getTotCnt());
m.setCmpReject((reject == null || reject.isEmpty()) ? 0 : reject.get(0).getTotCnt());
MberManageVO totReq = new MberManageVO();
totReq.setDept("P");
List<MberManageVO> priv = conectStatsService.selectMberTotStats(totReq);
totReq.setDept("C");
List<MberManageVO> comp = conectStatsService.selectMberTotStats(totReq);
if (priv != null) {
for (MberManageVO v : priv) {
accumulateMember(m, v, true);
}
}
if (comp != null) {
for (MberManageVO v : comp) {
accumulateMember(m, v, false);
}
}
String statStr = conectStatsService.selectTodaySttCountString(totReq);
if (statStr != null) {
String[] split = statStr.split("_");
if (split.length == 3) {
m.setTodayExpireCnt(toInt(split[0]));
m.setTodayStopCnt(toInt(split[1]));
// split[2]=환불신청수는 1차 범위 제외
}
}
}
private void accumulateMember(DashboardSummaryVO.Members m, MberManageVO v, boolean isPrivate) {
String sttus = v.getMberSttus();
int cnt = v.getTotCnt();
if ("Y".equals(sttus)) {
if (isPrivate) {
m.setUserNorCnt(cnt);
} else {
m.setUserComCnt(cnt);
}
m.setMberNorTot(m.getMberNorTot() + cnt);
} else if ("B".equals(sttus)) {
m.setMberBlockTot(m.getMberBlockTot() + cnt);
} else if ("N".equals(sttus)) {
m.setMberSecessTot(m.getMberSecessTot() + cnt);
}
}
/** 문자 발송 현황 — selectAgentSmsCountStatMainList, selectMemDayChartDashboard 재사용 */
private void fillSms(DashboardSummaryVO.Sms s) throws Exception {
MjonMsgStatVO statReq = new MjonMsgStatVO();
statReq.setFirstIndex(0);
statReq.setRecordCountPerPage(3);
List<MjonMsgStatVO> agents = mjonMsgService.selectAgentSmsCountStatMainList(statReq);
if (agents != null) {
for (MjonMsgStatVO a : agents) {
DashboardSummaryVO.AgentStat stat = new DashboardSummaryVO.AgentStat();
stat.setAgentCode(a.getAgentCode());
int succ = a.getSumSuccCnt();
int fail = a.getSumFailCnt();
stat.setSuccCnt(succ);
stat.setFailCnt(fail);
int total = succ + fail;
stat.setSuccRate(total == 0 ? 0 : (int) Math.round(succ * 100.0 / total));
s.getAgents().add(stat);
}
}
MjonMsgVO memReq = new MjonMsgVO();
memReq.setFirstIndex(0);
memReq.setRecordCountPerPage(4);
memReq.setSearchSortCnd("regDate");
memReq.setSearchSortOrd("desc");
List<MjonMsgVO> memDay = mjonMsgService.selectMemDayChartDashboard(memReq);
if (memDay != null && !memDay.isEmpty()) {
s.setTodaySignupCnt(memDay.get(0).getRegCount());
}
}
/** "12345", "12,345", "12345.0", null → long(0) 안전 변환 */
private long toLong(String s) {
if (s == null) {
return 0L;
}
String digits = s.replaceAll("[^0-9-]", "");
if (digits.isEmpty() || "-".equals(digits)) {
return 0L;
}
try {
return Long.parseLong(digits);
} catch (NumberFormatException e) {
return 0L;
}
}
private int toInt(String s) {
return (int) toLong(s);
}
private String appendMsg(String base, String add) {
if (base == null || base.isEmpty()) {
return add;
}
return base + "; " + add;
}
}
검증 포인트(구현 중 확인):
MberManageVO.getTotCnt(),MjonMsgStatVO.getSumFailCnt(),MjonMsgVO.getRegCount()가 존재해야 한다(기존EgovMainController/VO에서 사용 중). 시그니처가 다르면 해당 게터명으로 맞춘다.vo.setSuccess(false)는 lombok이boolean isSuccess필드에 대해 생성하는 setter다(필드명 기준setSuccess).
- Step 3: 컴파일 확인
Run: mvn -q compile
Expected: BUILD SUCCESS. 메서드/게터 미스매치 없음(에러 시 위 검증 포인트의 실제 게터명으로 수정).
- Step 4: Commit
git add src/main/java/itn/let/mjo/dashboardapi/service/
git commit -m "feat(dashboard-api): 매출/회원/문자 요약 집계 서비스 추가"
Task 4: REST 컨트롤러#
Files:
- Create:
src/main/java/itn/let/mjo/dashboardapi/web/DashboardApiController.java
Interfaces:
Consumes:
DashboardSummaryService.getDashboardSummary(String)(Task 3),DashboardSummaryVO(Task 2)Produces: HTTP
GET /api/dashboard/summary.do→ JSON 직렬화된DashboardSummaryVOStep 1: 컨트롤러 작성
src/main/java/itn/let/mjo/dashboardapi/web/DashboardApiController.java:
package itn.let.mjo.dashboardapi.web;
import javax.annotation.Resource;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import itn.let.mjo.dashboardapi.service.DashboardSummaryService;
import itn.let.mjo.dashboardapi.service.DashboardSummaryVO;
/**
* 대시보드 요약 JSON API(n8n→Slack 전용).
* 인증은 DashboardApiAuthInterceptor(토큰 헤더)가 담당한다.
* 메서드명은 select* 가 아니어서 관리자 로그 AOP 대상에서 제외된다.
*/
@RestController
public class DashboardApiController {
@Resource(name = "dashboardSummaryService")
private DashboardSummaryService dashboardSummaryService;
@RequestMapping(value = "/api/dashboard/summary.do", method = RequestMethod.GET)
public DashboardSummaryVO getDashboardSummary(
@RequestParam(value = "date", required = false) String date) throws Exception {
return dashboardSummaryService.getDashboardSummary(date);
}
}
- Step 2: 빌드(WAR) 확인
Run: mvn -q clean package
Expected: BUILD SUCCESS, target/mjon.war 생성.
- Step 3: 로컬 기동 후 JSON 키 확인(인증 인터셉터 적용 전, 임시)
이 시점엔 아직 인증이 없으므로 토큰 없이 호출된다. JSON 직렬화 키만 확인한다.
Run(터미널 A): mvn tomcat7:run
Run(터미널 B): curl -s "http://localhost/api/dashboard/summary.do?date=2026-06-23"
Expected: sales/members/sms 블록이 있는 JSON. 성공 플래그 키가 isSuccess인지 success인지 확인.
키가
success로 나오면DashboardSummaryVO의 게터에@com.fasterxml.jackson.annotation.JsonProperty("isSuccess")를 추가:@com.fasterxml.jackson.annotation.JsonProperty("isSuccess") public boolean isSuccess() { return isSuccess; }(lombok 게터와 충돌 시 lombok
@Getter를 필드에서 제거하고 위 명시 게터만 둔다.)확인 후 터미널 A 종료(Ctrl+C).
Step 4: Commit
git add src/main/java/itn/let/mjo/dashboardapi/web/DashboardApiController.java src/main/java/itn/let/mjo/dashboardapi/service/DashboardSummaryVO.java
git commit -m "feat(dashboard-api): /api/dashboard/summary.do RestController 추가"
Task 5: 토큰 인증 인터셉터 + 매핑 + 시큐리티 예외#
Files:
- Create:
src/main/java/itn/com/cmm/interceptor/DashboardApiAuthInterceptor.java - Modify:
src/main/webapp/WEB-INF/config/egovframework/springmvc/egov-interceptor-servlet.xml - Modify:
src/main/resources/egovframework/spring/com/context-security.xml:14(security="none" 블록 인근에 추가)
Interfaces:
Consumes: 프로퍼티
Globals.dashboard.api.tokenviaglobalSettings(Task 1)Produces:
/api/dashboard/**요청에 대한 토큰 검증(불일치/누락 시 401 JSON, 컨트롤러 진입 차단)Step 1: 인터셉터 작성
src/main/java/itn/com/cmm/interceptor/DashboardApiAuthInterceptor.java:
package itn.com.cmm.interceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;
/**
* 대시보드 요약 API 토큰 인증.
* X-DASHBOARD-TOKEN 헤더가 설정 토큰과 일치할 때만 통과시킨다.
*/
public class DashboardApiAuthInterceptor extends HandlerInterceptorAdapter {
private static final String HEADER = "X-DASHBOARD-TOKEN";
@Value("#{globalSettings['Globals.dashboard.api.token']}")
private String apiToken;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
String token = request.getHeader(HEADER);
boolean ok = apiToken != null && !apiToken.trim().isEmpty()
&& apiToken.equals(token);
if (!ok) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // 401
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"isSuccess\":false,\"msg\":\"unauthorized\"}");
return false; // 컨트롤러 진입 차단 (토큰 값은 로그/응답에 출력하지 않음)
}
return true;
}
}
- Step 2: 인터셉터 매핑 등록
egov-interceptor-servlet.xml의 <interceptors> 블록 안(첫 <interceptor> 위, line 10 직후)에 추가:
<!-- 대시보드 요약 API 토큰 인증 -->
<interceptor>
<mapping path="/api/dashboard/**"/>
<beans:bean class="itn.com.cmm.interceptor.DashboardApiAuthInterceptor"/>
</interceptor>
기존
IPIgnoreInterceptorHandler는/**매핑에/cmm/**,/js/**,/publish/**만 제외한다./api/dashboard/**도 그 인터셉터를 거치지만, 해당 핸들러는 특정 URI(errorLogInsert,*Ajax.do등)에만 동작하므로 본 API 흐름에는 영향 없음(통과). 빌드 후 Step 6에서 실제 통과를 확인한다.
- Step 3: 시큐리티 예외 추가
context-security.xml의 security="none" 목록(line 9~14 인근)에 한 줄 추가:
<security:http pattern="/api/dashboard/**" security="none"/>
이 엔드포인트는 토큰으로 자체 보호하므로 Spring Security 필터 체인/세션이 필요 없다. DB 기반 URL 권한(
secured-object)에 걸리는 것을 방지한다.
- Step 4: 빌드 확인
Run: mvn -q clean package
Expected: BUILD SUCCESS, target/mjon.war 생성.
- Step 5: 토큰 없는 호출 → 401 확인
Run(터미널 A): mvn tomcat7:run
Run(터미널 B): curl -s -o /dev/null -w "%{http_code}\n" "http://localhost/api/dashboard/summary.do"
Expected: 401
- Step 6: 올바른 토큰 → 200 + JSON 확인
토큰 값은 활성 프로파일의
globals_<profile>.properties에 설정된 값. 로컬 기동은 보통local프로파일.
Run(터미널 B):
curl -s -H "X-DASHBOARD-TOKEN: CHANGE_ME_TOKEN" "http://localhost/api/dashboard/summary.do" | head -c 600
Expected: HTTP 200, sales/members/sms 블록이 채워진 JSON. (틀린 토큰으로는 401)
확인 후 터미널 A 종료.
Step 7: Commit
git add src/main/java/itn/com/cmm/interceptor/DashboardApiAuthInterceptor.java \
src/main/webapp/WEB-INF/config/egovframework/springmvc/egov-interceptor-servlet.xml \
src/main/resources/egovframework/spring/com/context-security.xml
git commit -m "feat(dashboard-api): 토큰 인증 인터셉터 및 시큐리티 예외 추가"
Task 6: n8n 워크플로우 + Slack 산출물#
Files:
- Create:
docs/superpowers/n8n/n8n-workflow.json - Create:
docs/superpowers/n8n/code-node.js - Create:
docs/superpowers/n8n/README.md
Interfaces:
Consumes:
GET /api/dashboard/summary.do응답 JSON(Task 2~5)Produces: import 가능한 n8n 워크플로우 + Slack Block Kit 변환 스니펫
Step 1: Code 노드 변환 스니펫 작성
docs/superpowers/n8n/code-node.js:
// n8n Code 노드: 도메인 JSON → Slack Block Kit
// 입력: 이전 HTTP Request 노드의 JSON 응답
const d = $input.first().json;
const won = (n) => (n == null ? '0' : Number(n).toLocaleString('ko-KR')) + '원';
const sales = d.sales || {};
const m = d.members || {};
const sms = d.sms || {};
const agentLines = (sms.agents || [])
.map(a => `• ${a.agentCode}: 성공 ${Number(a.succCnt).toLocaleString('ko-KR')} / 실패 ${Number(a.failCnt).toLocaleString('ko-KR')} (성공률 ${a.succRate}%)`)
.join('\n') || '데이터 없음';
const dayLines = (sales.dayChart || [])
.map(p => `${p.label} ${won(p.amt)}`)
.join(' | ') || '데이터 없음';
const blocks = [
{ type: 'header', text: { type: 'plain_text', text: `📊 문자ON 일일 리포트 — ${d.baseDate}`, emoji: true } },
{ type: 'section', fields: [
{ type: 'mrkdwn', text: `*당일 매출*\n${won(sales.todayAmt)}` },
{ type: 'mrkdwn', text: `*연간 누적*\n${won(sales.yearAmtSum)}` },
] },
{ type: 'section', text: { type: 'mrkdwn', text: `*최근 매출(일별)*\n${dayLines}` } },
{ type: 'divider' },
{ type: 'section', fields: [
{ type: 'mrkdwn', text: `*개인/기업 회원*\n${m.userNorCnt} / ${m.userComCnt}` },
{ type: 'mrkdwn', text: `*기업 신청/승인/반려*\n${m.cmpApply} / ${m.cmpApprove} / ${m.cmpReject}` },
{ type: 'mrkdwn', text: `*금일 탈퇴/정지*\n${m.todayExpireCnt} / ${m.todayStopCnt}` },
{ type: 'mrkdwn', text: `*일 가입자수*\n${sms.todaySignupCnt}` },
] },
{ type: 'divider' },
{ type: 'section', text: { type: 'mrkdwn', text: `*전송사별 발송*\n${agentLines}` } },
];
return [{ json: { blocks } }];
- Step 2: 워크플로우 JSON 작성
docs/superpowers/n8n/n8n-workflow.json:
{
"name": "mjon-dashboard-daily-slack",
"nodes": [
{
"parameters": {
"rule": { "interval": [ { "field": "cronExpression", "expression": "0 9 * * *" } ] }
},
"id": "schedule",
"name": "Schedule Trigger",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1,
"position": [ 240, 300 ]
},
{
"parameters": {
"url": "https://CHANGE_ME_HOST/api/dashboard/summary.do",
"options": {},
"sendHeaders": true,
"headerParameters": {
"parameters": [
{ "name": "X-DASHBOARD-TOKEN", "value": "={{ $env.MJON_DASH_TOKEN }}" }
]
}
},
"id": "http",
"name": "HTTP Request",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4,
"position": [ 460, 300 ]
},
{
"parameters": {
"language": "javaScript",
"jsCode": "// docs/superpowers/n8n/code-node.js 내용을 여기에 붙여넣는다"
},
"id": "code",
"name": "Format Slack Blocks",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [ 680, 300 ]
},
{
"parameters": {
"resource": "message",
"operation": "post",
"channelId": "CHANGE_ME_CHANNEL",
"blocksUi": "={{ $json.blocks }}",
"text": "문자ON 일일 리포트"
},
"id": "slack",
"name": "Slack",
"type": "n8n-nodes-base.slack",
"typeVersion": 2,
"position": [ 900, 300 ]
}
],
"connections": {
"Schedule Trigger": { "main": [ [ { "node": "HTTP Request", "type": "main", "index": 0 } ] ] },
"HTTP Request": { "main": [ [ { "node": "Format Slack Blocks", "type": "main", "index": 0 } ] ] },
"Format Slack Blocks": { "main": [ [ { "node": "Slack", "type": "main", "index": 0 } ] ] }
},
"settings": {}
}
- Step 3: README 작성
docs/superpowers/n8n/README.md:
# 대시보드 일일 리포트 → Slack (n8n)
## 구성
- `n8n-workflow.json` : import용 워크플로우 (Schedule → HTTP Request → Code → Slack)
- `code-node.js` : Code 노드에 붙여넣을 변환 스니펫
## 설정 순서
1. n8n에서 Settings → Variables/Environment에 `MJON_DASH_TOKEN` 추가
(값 = 앱 `globals_<profile>.properties`의 `Globals.dashboard.api.token`).
2. `n8n-workflow.json` import.
3. HTTP Request 노드의 `CHANGE_ME_HOST`를 관리자 호스트로 교체.
4. Code 노드(`Format Slack Blocks`)에 `code-node.js` 내용 붙여넣기.
5. Slack 노드: 자격증명 연결 후 `CHANGE_ME_CHANNEL`을 대상 채널로 교체.
6. 스케줄: 기본 매일 09:00(`0 9 * * *`). 필요 시 cron 수정.
## 수동 검증
- 워크플로우 상단 **Execute Workflow** 실행 → 대상 Slack 채널에 리포트 수신 확인.
- 토큰 불일치 시 HTTP Request 노드가 401로 실패하는지 확인.
- Step 4: 산출물 확인
Run: ls docs/superpowers/n8n/
Expected: README.md code-node.js n8n-workflow.json
- Step 5: Commit
git add docs/superpowers/n8n/
git commit -m "feat(dashboard-api): n8n 워크플로우 및 Slack 변환 산출물 추가"
검증(통합) 시나리오#
모든 태스크 완료 후:
mvn clean package→target/mjon.war생성(BUILD SUCCESS).mvn tomcat7:run기동.curl -s -o /dev/null -w "%{http_code}" http://localhost/api/dashboard/summary.do→401.curl -s -H "X-DASHBOARD-TOKEN: <토큰>" http://localhost/api/dashboard/summary.do→200+ 매출/회원/문자 JSON.- n8n:
MJON_DASH_TOKEN설정 → 워크플로우 import → Execute Workflow → Slack 채널 수신 확인. - 운영 배포:
globals_prod.properties의 토큰을 실제 값으로 교체 후 WAR 배포.
자체 검토 결과#
- 스펙 커버리지: 엔드포인트 계약(Task 2·4) / 인증(Task 1·5) / Java 컴포넌트(Task 2~5) / n8n·Slack(Task 6) / 에러처리·검증(서비스 블록별 try/catch, 통합 시나리오) — 스펙 8개 결정사항 모두 태스크에 매핑됨.
- placeholder:
CHANGE_ME_*는 의도된 사용자 치환값(호스트/채널/토큰)이며 코드 placeholder 아님. - 타입 일관성:
getDashboardSummary(String),DashboardSummaryVO/Sales/Members/Sms/AmtPoint/AgentStat, 빈 이름(dashboardSummaryService,mjonPayService,conectStatsService,mjonMsgService)이 태스크 간 일치.setSuccess(lombok,boolean isSuccess) 사용 일관. - 리스크: 기존 VO 게터명(
getTotCnt/getSumFailCnt/getRegCount/getAmtTotSum) 미스매치 가능성 → Task 3 Step 3 컴파일에서 검출·수정. Jackson의isSuccess/success키 차이 → Task 4 Step 3에서 확정.