
이번 작업은 CoreERP 프론트엔드에서 여러 조회 페이지에 CSV Export 기능을 공통 구조로 연결하는 작업이었다. 단순히 버튼 하나를 추가하는 수준이 아니라, 현재 페이지에서 사용 중인 검색 조건과 정렬 조건을 그대로 유지한 채 백엔드 export API와 연결되도록 프론트 구조를 정리한 것이 핵심이다.
이번 작업은 Frontend / React API integration 영역에 해당한다. 화면에서 사용자가 입력한 filter state와 pagination state를 유지하고, 그 값을 백엔드 export endpoint에 그대로 전달한 뒤, JWT 인증이 포함된 요청으로 CSV를 다운로드하는 흐름을 완성하는 것이 목표였다.
기존 CoreERP는 각 기능별 조회 화면이 잘 정리되어 있었고, 백엔드에서도 CSV export API가 순차적으로 연결되고 있었다. 하지만 프론트에서는 아직 각 페이지의 검색 조건과 export 기능이 연결되지 않은 상태였다. 그래서 이번 단계에서는 조회 화면에서 보이는 조건 그대로 CSV를 내려받을 수 있도록 프론트 export 구조를 공통화하는 작업을 진행했다.
즉, 이번 작업은 단순 다운로드 기능 추가가 아니라, 프론트 상태와 백엔드 API를 자연스럽게 연결하는 공통 export 흐름을 만드는 작업이었다.
이번에 CSV Export를 연결한 주요 페이지는 다음과 같다.
각 페이지는 구조가 조금씩 달랐지만, 핵심 흐름은 모두 동일했다.
이번 작업에서 가장 먼저 정리한 부분은 export UI와 export API 호출 로직을 분리하는 것이었다. 조회 페이지마다 버튼을 따로 만들고 다운로드 코드를 페이지 내부에 직접 작성하면, 페이지 수가 늘어날수록 유지보수가 급격히 어려워진다. 그래서 공통 구조를 먼저 잡고 각 페이지는 자신의 상태만 넘겨주는 방식으로 정리했다.
먼저 공통 버튼 UI를 만들었다. 초기에는 단순 버튼으로 생각했지만, 나중에 CSV 외에 Excel, PDF 같은 export 형식이 추가될 수 있다는 점을 고려해 dropdown 구조로 설계했다.
최종적으로는 다음과 같은 방향으로 정리했다.
UI도 너무 무겁지 않게 구성했고, 텍스트 화살표 대신 SVG chevron을 사용해 좀 더 정돈된 느낌으로 맞췄다. 처음에는 다운로드 아이콘도 붙였지만, 실제 ERP 화면 톤과 비교해보면 오히려 없는 편이 더 깔끔해서 제거했다. 최종적으로는 Export + chevron 형태로 정리했다.
버튼만 공통화해서는 충분하지 않았다. 실제로 CSV 다운로드를 수행하는 로직도 모든 페이지에서 거의 동일했기 때문에, 이를 별도의 API helper로 분리했다.
이 helper가 담당하는 역할은 다음과 같다.
즉, 페이지별 export 함수는 endpoint와 params만 정리해 helper에 넘겨주면 되도록 만들었다. 이 방식 덕분에 모든 feature에서 동일한 다운로드 패턴을 유지할 수 있게 되었다.
이번 작업의 핵심은 페이지마다 따로 존재하는 draft filter와 applied filter 흐름을 export에도 그대로 반영하는 것이었다. 대부분의 페이지는 사용자가 화면에서 조건을 입력하는 draft state와, 실제 조회에 적용된 applied state를 분리해서 사용하고 있었다.
이 구조를 그대로 export에 반영하지 않으면 문제가 생긴다. 예를 들어 사용자가 화면에 조건을 입력만 하고 아직 검색 버튼을 누르지 않았는데 export가 draft 기준으로 동작하면, 실제 테이블에 보이는 데이터와 다운로드 결과가 달라질 수 있다. 그래서 이번 작업에서는 모든 export를 반드시 applied state 기준으로 동작하도록 연결했다.
흐름은 다음과 같다.
현재 페이지 state → applied filter / sort / page / size → feature API 함수 → 공통 downloadCsv helper → backend export endpoint → CSV 다운로드
이 구조로 정리하면서 조회와 export 조건이 어긋나는 문제를 예방할 수 있었다.
Backend와 통신하는 실제 다운로드 로직은 별도의 helper로 분리했다. 이 부분은 Frontend / API utility layer에 해당한다.
import apiClient from "@/apis/axios";
import { readApiError } from "@/mappers/apiErrorMapper";
type DownloadCsvOptions = {
url: string;
params?: Record<string, string>;
fileName: string;
};
export async function downloadCsv({
url,
params,
fileName,
}: DownloadCsvOptions): Promise<void> {
try {
const response = await apiClient.get(url, {
params,
responseType: "blob",
});
const blob = new Blob([response.data], { type: "text/csv;charset=utf-8;" });
const downloadUrl = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = downloadUrl;
link.download = fileName;
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(downloadUrl);
} catch (error) {
throw new Error(`csv export failed: ${readApiError(error)}`);
}
}
이 코드는 각 페이지가 중복해서 가질 필요가 없는 공통 로직이다. 실제로 feature API는 endpoint와 query parameter, 파일명만 다르기 때문에, 이 공통 helper가 있으면 확장성이 훨씬 좋아진다.
버튼 UI도 재사용할 수 있도록 common 컴포넌트로 분리했다. 이 컴포넌트는 export 대상 API나 페이지 상태를 전혀 알지 못하고, 단순히 onCsvExport 함수를 받아 실행하는 역할만 담당한다. 즉, UI와 비즈니스 로직을 분리한 구조다.
import { useEffect, useRef, useState } from "react";
type Props = {
onCsvExport: () => void;
};
function ChevronDownIcon() {
return (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
>
<path
d="M6 9L12 15L18 9"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export default function ExportDropdown({ onCsvExport }: Props) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const toggle = () => {
setOpen((prev) => !prev);
};
const handleCsv = () => {
setOpen(false);
onCsvExport();
};
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
return (
<div className="export-dropdown" ref={ref}>
<button
className="btn btn-sm export-trigger"
type="button"
onClick={toggle}
>
<span>Export</span>
<span className={`export-trigger-chevron${open ? " is-open" : ""}`}>
<ChevronDownIcon />
</span>
</button>
{open && (
<div className="export-menu">
<button className="export-item" type="button" onClick={handleCsv}>
CSV
</button>
</div>
)}
</div>
);
}
이렇게 만들고 나니 각 페이지는 ExportDropdown에 자신만의 onCsvExport 함수만 넘기면 되었다. 즉, 컴포넌트는 UI만 담당하고 실제 export 로직은 각 feature API와 페이지 state가 처리하게 된다.
각 페이지는 이미 목록 조회용 API 함수를 가지고 있었기 때문에, export 함수도 가능하면 기존 query builder를 그대로 재사용하는 방향으로 정리했다. 이 점이 중요했다. 왜냐하면 목록 조회와 export가 서로 다른 query builder를 사용하면 조건이 틀어질 수 있기 때문이다.
예를 들어 품목 조회는 기존 buildQuery를 그대로 활용했고, 발주 이력은 buildPurchaseOrderQuery, 입고는 buildInboundHistoryQuery, 출고는 buildHistoryQuery를 그대로 재사용했다. 이렇게 하면 조회 API와 export API가 같은 규칙으로 움직이게 된다.
예를 들어 품목 API는 다음과 같이 정리했다.
export async function exportItems(
filter: FilterState,
page: number,
size: number
): Promise<void> {
const params = buildQuery(filter, page, size);
await downloadCsv({
url: "/items/export",
params: Object.fromEntries(params.entries()),
fileName: buildItemExportFileName(),
});
}
이 구조를 모든 feature에 동일하게 적용했다. 즉, feature별 차이는 endpoint와 params builder 정도만 남기고, 다운로드 자체는 공통 helper가 담당하도록 통일했다.
페이지에서는 공통 ExportDropdown을 panel-actions 영역에 배치했다. 이 위치를 선택한 이유는 명확했다. 현재 페이지에서 pagination, size 변경, 등록 버튼 등이 모두 panel-actions에 모여 있었기 때문에, export 역시 목록 전체에 대한 주요 액션으로 보는 것이 자연스러웠다.
예를 들어 창고 페이지에서는 다음과 같은 형태로 연결했다.
const handleExportCsv = async () => {
try {
await apiExportWarehouses(appliedFilter, page, size);
showToast("CSV 내보내기가 완료되었습니다.", "success");
} catch (error) {
console.error(error);
showToast("CSV 내보내기에 실패했습니다.", "error");
}
};
<div className="panel-actions" style={{ display: "flex", alignItems: "center", gap: 10 }}>
<div style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
<button className="btn btn-sm">처음</button>
<button className="btn btn-sm">이전</button>
<span className="badge">{pageLabel}</span>
<button className="btn btn-sm">다음</button>
<button className="btn btn-sm">마지막</button>
<select className="input" />
</div>
<ExportDropdown onCsvExport={handleExportCsv} />
<button className="btn btn-sm soft-primary" type="button">
창고 등록
</button>
</div>
이 패턴을 다른 페이지에도 거의 동일하게 적용했다. 즉, 각 페이지는 자기 상태만 준비하고, export 함수만 호출하면 되는 구조가 되었다.
창고 조회는 현재 filter, page, size를 그대로 export endpoint에 전달하도록 연결했다. 여기서는 서버에서 정렬 가능한 필드와 프론트 전용 정렬 필드가 나뉘어 있었기 때문에, 기존 buildWarehouseQuery 규칙을 그대로 재사용했다. 즉, 현재 목록 조회와 export가 완전히 같은 조건으로 동작하게 만들었다.
창고 이동 이력은 fromWarehouse, toWarehouse를 warehouseCode에서 warehouseId로 변환하는 과정이 필요했다. 이 부분도 기존 buildTransferQuery 안에 이미 정리되어 있었기 때문에, export에서도 동일한 변환 로직을 그대로 사용했다. 이 덕분에 창고 선택 조건까지 자연스럽게 export와 연동할 수 있었다.
출고와 입고 이력은 날짜 범위, keyword, manager, warehouse 조건이 핵심이었다. 기존 URLSearchParams 기반 query builder가 이미 있었기 때문에, export에서도 같은 builder를 그대로 쓰도록 정리했다. 이 구조 덕분에 화면에서 적용된 검색 결과와 CSV 결과가 항상 일치하도록 만들 수 있었다.
발주 이력은 정렬 가능한 컬럼이 많았고, 백엔드에서 허용하는 정렬 key와 프론트 표시 컬럼이 다를 수 있었다. 그래서 기존 backendSortable 매핑 구조를 그대로 재사용했다. 즉, 사용자는 화면에서 동일한 정렬 기준으로 보면서, export도 같은 기준으로 받을 수 있게 되었다.
품목과 업체 조회는 마스터 데이터 화면이기 때문에 export 활용도가 높다. 특히 필터 조건이 단순하면서도 실사용에서 자주 쓰는 화면이라, 이번 구조가 가장 잘 체감되는 페이지였다. 두 화면 모두 기존 buildQuery를 재사용하도록 정리했다.
재고 화면은 가장 까다로운 편이었다. 정렬 키 normalization, 상태값 변환, optionMode 변환, warehouseId 처리 같은 전처리가 이미 들어가 있었기 때문이다. 그래서 이 부분도 무조건 기존 params builder를 재사용하는 방향으로 맞췄다. 결과적으로 현재 재고, 장기 재고, 안전 재고 모두 같은 패턴으로 export를 연결할 수 있었다.
문제는 draft filter와 applied filter가 분리된 페이지에서 export를 잘못 draft 기준으로 연결하면, 사용자가 실제로 보고 있는 테이블과 CSV 결과가 달라질 수 있다는 점이었다.
예를 들어 사용자가 화면 입력창만 수정하고 아직 검색 버튼을 누르지 않은 상태에서 export를 실행하면, draft 기준으로 내려받는 경우 의도하지 않은 결과가 내려올 수 있다.
해결은 간단하지만 중요했다. 모든 export는 반드시 현재 화면 조회 기준인 applied state만 사용하도록 통일했다.
const handleExportCsv = async () => {
try {
await exportVendors(appliedFilter, page, size);
showToast("CSV 내보내기가 완료되었습니다.", "success");
} catch (error) {
console.error(error);
showToast("CSV 내보내기에 실패했습니다.", "error");
}
};
이렇게 정리하면서 테이블과 다운로드 결과가 일치하도록 만들 수 있었다.
초기 Export dropdown 스타일에서는 min-width를 크게 잡아둔 상태라, CSV 메뉴가 짧은 텍스트임에도 불구하고 메뉴 칸이 과하게 넓어 보였다. UI 톤상 이 부분이 꽤 어색했다.
원인은 CSS에서 min-width를 강제로 크게 잡아둔 것이었다. 그래서 width를 content 기준으로 줄이고, padding도 조정해서 더 자연스럽게 보이도록 수정했다.
.export-menu {
position: absolute;
top: calc(100% + 4px);
right: 0;
background: #fff;
border: 1px solid #d9dee7;
border-radius: 8px;
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.08);
padding: 4px;
width: max-content;
min-width: 0;
z-index: 100;
}
.export-item {
display: block;
width: 100%;
padding: 8px 12px;
text-align: left;
background: transparent;
border: none;
border-radius: 6px;
font-size: 14px;
line-height: 1.2;
cursor: pointer;
white-space: nowrap;
}
이 수정 이후 메뉴가 지나치게 넓어 보이는 현상이 사라졌고, 현재 페이지 버튼 스타일과도 훨씬 잘 맞게 되었다.
처음에는 Export ▼ 형태로 단순하게 구현했지만, 실제 화면에 올려보니 텍스트 화살표가 예상보다 투박하고 어색하게 느껴졌다.
해결 방법은 텍스트 화살표 대신 SVG chevron을 사용하고, dropdown 열림 상태에 따라 회전 애니메이션만 적용하는 방식으로 정리하는 것이었다. 다운로드 아이콘도 한때 넣어보았지만, 화면 톤상 오히려 없는 편이 더 깔끔해서 제거했다.
결국 Export 텍스트와 chevron만 남긴 구조가 가장 안정적이었다.
이번 작업의 가장 큰 의미는, 단순히 CSV 버튼을 여러 페이지에 붙인 것이 아니라 CoreERP 프론트 전반에서 사용할 수 있는 공통 export 구조를 만들었다는 점이다. 이 구조가 잡히고 나니, 새로운 목록 페이지가 추가되더라도 다음 흐름만 지키면 같은 방식으로 손쉽게 붙일 수 있게 되었다.
즉, 기능 추가 비용이 크게 낮아졌고, 조회 화면들 사이의 UX도 더 일관되게 정리되었다.
처음에는 CSV export가 단순 버튼 작업처럼 보였지만, 실제로는 프론트 상태 관리 방식과 API 설계를 얼마나 일관되게 가져가고 있는지를 확인하게 되는 작업이었다. 특히 draft와 applied state를 나눠 쓰는 현재 구조에서는, export가 어떤 상태를 기준으로 움직여야 하는지 명확하게 정리하지 않으면 화면과 다운로드 결과가 쉽게 어긋날 수 있다는 점을 다시 느꼈다.
또한 이번 작업을 하면서 공통 컴포넌트와 공통 helper를 잘 분리해두면, 비슷한 기능을 여러 페이지에 붙일 때 생산성이 크게 올라간다는 점도 체감했다. 지금 CoreERP는 기능이 계속 늘어나는 구조이기 때문에, 이런 공통 레이어를 초기에 잘 정리해두는 것이 나중에 유지보수에서 훨씬 중요해질 것 같다.
이번 단계는 단순한 CSV 다운로드 기능 구현이 아니라, CoreERP 프론트엔드의 조회 화면들을 하나의 규칙 안에서 정리해 나가는 과정이었다. 앞으로 Excel export나 추가적인 다운로드 포맷이 필요해져도, 이번에 만든 구조를 기반으로 훨씬 자연스럽게 확장할 수 있을 것 같다.