
이번 작업에서는 CoreERP 백엔드에 여러 관리 페이지의 CSV export 기능을 순차적으로 추가했다. 단순히 파일 다운로드 기능만 붙인 것이 아니라, 실제 프론트 테이블에서 사용자가 보고 있는 컬럼 순서와 다운로드되는 CSV 컬럼 순서를 일치시키는 방향으로 정리했다. 이 과정에서 공통 CSV 유틸 구조를 먼저 만들고, 각 도메인별 조회 API 흐름에 맞춰 export endpoint를 추가하는 방식으로 확장했다.
이번 작업의 핵심은 “목록 조회 화면을 그대로 CSV로 내보낼 수 있는 구조”를 만드는 것이었다. 실무에서 ERP 화면은 조회만으로 끝나는 경우가 거의 없고, 검색한 결과를 그대로 엑셀이나 CSV로 내려받아 공유하거나 가공하는 일이 자주 발생한다. 그래서 이번 기능은 단순 부가기능이 아니라, 실제 업무 흐름을 지원하는 중요한 기능이라고 볼 수 있다.
특히 이번 작업에서는 아래 기준을 지키는 것을 우선으로 삼았다.
여러 페이지에 같은 방식으로 export 기능을 추가하려면 먼저 공통 기반이 필요했다. 그래서 CSV 생성 로직을 각 서비스마다 중복 작성하지 않고, 공통 유틸 클래스로 분리했다. 이 부분은 Backend 공통 유틸 레이어에 해당한다.
각 DTO를 CSV의 한 줄 데이터로 바꾸기 위한 인터페이스를 만들었다. 즉, 어떤 페이지의 응답 DTO든 List<String> 형태의 row로 변환만 하면 공통 CSV 유틸이 그대로 재사용될 수 있게 했다.
package com.coreerp.common.export;
import java.util.List;
@FunctionalInterface
public interface CsvRowMapper<T> {
List<String> map(T source);
}
실제 CSV 바이트 데이터를 생성하는 핵심 유틸이다. 헤더와 데이터 목록을 받아 CSV 문자열로 변환하고, 엑셀 한글 깨짐 방지를 위해 UTF-8 BOM도 선택적으로 포함할 수 있게 했다. 또한 쉼표, 큰따옴표, 줄바꿈이 포함된 값은 escape 처리하도록 만들었다.
package com.coreerp.common.export;
import java.nio.charset.StandardCharsets;
import java.util.List;
public final class CsvUtils {
private static final byte[] UTF8_BOM =
new byte[]{(byte)0xEF, (byte)0xBB, (byte)0xBF};
private CsvUtils() {}
public static <T> byte[] toCsv(
List<String> headers,
List<T> data,
CsvRowMapper<T> mapper,
boolean includeBom
) {
StringBuilder sb = new StringBuilder();
appendRow(sb, headers);
for (T row : data) {
appendRow(sb, mapper.map(row));
}
byte[] csv = sb.toString().getBytes(StandardCharsets.UTF_8);
if (!includeBom) {
return csv;
}
byte[] result = new byte[UTF8_BOM.length + csv.length];
System.arraycopy(UTF8_BOM, 0, result, 0, UTF8_BOM.length);
System.arraycopy(csv, 0, result, UTF8_BOM.length, csv.length);
return result;
}
private static void appendRow(StringBuilder sb, List<String> values) {
for (int i = 0; i < values.size(); i++) {
sb.append(escape(values.get(i)));
if (i < values.size() - 1) {
sb.append(",");
}
}
sb.append("\n");
}
private static String escape(String value) {
if (value == null) {
return "";
}
boolean needQuote =
value.contains(",") ||
value.contains("\"") ||
value.contains("\n") ||
value.contains("\r");
String escaped = value.replace("\"", "\"\"");
return needQuote ? "\"" + escaped + "\"" : escaped;
}
}
생성된 CSV byte[]를 실제 다운로드 응답으로 감싸는 역할을 담당한다. Content-Disposition을 attachment로 설정하고 파일명을 내려주도록 구성했다. 이렇게 하면 각 서비스에서는 “데이터 조회 → CsvUtils 생성 → CsvResponseFactory 반환” 흐름만 신경 쓰면 된다.
package com.coreerp.common.export;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import java.nio.charset.StandardCharsets;
public final class CsvResponseFactory {
private CsvResponseFactory() {}
public static ResponseEntity<byte[]> download(
String fileName,
byte[] data
) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(
MediaType.parseMediaType("text/csv")
);
headers.setContentDisposition(
ContentDisposition.attachment()
.filename(fileName, StandardCharsets.UTF_8)
.build()
);
return ResponseEntity
.ok()
.headers(headers)
.body(data);
}
}
이번 작업에서는 각 페이지별로 아래 패턴을 반복했다.
이 방식의 장점은 확실했다. 새로운 페이지를 export 대상으로 추가할 때도 구조를 새로 고민할 필요 없이, 이미 정리된 패턴을 그대로 적용할 수 있었다.
Item 페이지는 가장 먼저 export 기능을 붙인 대상이었다. 기존 목록 API의 검색 조건인 keyword, itemType, category, status, sortKey, sortOrder를 그대로 export에도 전달하도록 만들었다. 초기에는 CSV 컬럼 순서를 엔티티 중심으로 구성했지만, 이후 프론트 테이블 기준으로 다시 정렬했다.
최종적으로 Item CSV는 아래 순서로 맞췄다.
여기서 관리 컬럼은 버튼 영역이므로 CSV에서는 제외했다.
@Transactional(readOnly = true)
public ResponseEntity<byte[]> export(
String keyword,
String itemType,
String category,
String status,
String sortKey,
String sortOrder
) {
String kw = normalizeExact(keyword);
String it = normalizeExact(itemType);
ItemCategory ct = parseCategory(category);
String st = normalizeExact(status);
Sort sort = buildSafeSort(sortKey, sortOrder);
List<ItemResponse> items = itemRepository.searchList(kw, it, ct, st, sort).stream()
.map(this::toResponse)
.toList();
List<String> headers = List.of(
"상태",
"바코드",
"품번",
"품명",
"제조사",
"분류",
"유형",
"규격",
"단위",
"비고"
);
byte[] csv = CsvUtils.toCsv(
headers,
items,
item -> List.of(
nullToEmpty(item.status()),
nullToEmpty(item.barcode()),
nullToEmpty(item.itemCode()),
nullToEmpty(item.itemName()),
nullToEmpty(item.manufacturer()),
item.category() != null ? item.category().name() : "",
nullToEmpty(item.itemType()),
nullToEmpty(item.spec()),
nullToEmpty(item.unit()),
nullToEmpty(item.memo())
),
true
);
return CsvResponseFactory.download("items.csv", csv);
}
Vendor 페이지는 option 값에 따라 조회 모드가 달라지는 구조였다. 즉 기본 목록, 활성 거래처만, 주소 없는 거래처, 사업자번호 중복 거래처 등 분기가 있었기 때문에 export도 이 흐름을 그대로 따라가야 했다. 이 부분이 Item보다 한 단계 더 신경 써야 했던 부분이다.
최종 컬럼 순서는 프론트 기준으로 아래처럼 맞췄다.
@Transactional(readOnly = true)
public ResponseEntity<byte[]> export(
String keyword,
String status,
String option,
String sortKey,
String sortOrder
) {
String kw = trimOrNull(keyword);
String st = trimOrNull(status);
String opt = (option == null || option.isBlank()) ? "DEFAULT" : option.trim();
Sort sort = buildSort(sortKey, sortOrder);
boolean onlyActive = "ONLY_ACTIVE".equals(opt);
boolean noAddress = "NO_ADDRESS".equals(opt);
boolean dupBiz = "DUPLICATE_BIZNO".equals(opt);
List<VendorResponse> vendors = (dupBiz
? vendorRepository.searchDuplicateBizNoList(kw, st, sort)
: vendorRepository.searchList(kw, st, onlyActive, noAddress, sort))
.stream()
.map(this::toResponse)
.toList();
List<String> headers = List.of(
"상태",
"업체코드",
"업체명",
"대표자",
"전화번호",
"이메일",
"사업자번호",
"주소",
"비고"
);
byte[] csv = CsvUtils.toCsv(
headers,
vendors,
vendor -> List.of(
nullToEmpty(vendor.status()),
nullToEmpty(vendor.vendorCode()),
nullToEmpty(vendor.vendorName()),
nullToEmpty(vendor.ceoName()),
nullToEmpty(vendor.phone()),
nullToEmpty(vendor.email()),
nullToEmpty(vendor.bizNo()),
nullToEmpty(vendor.address()),
nullToEmpty(vendor.memo())
),
true
);
return CsvResponseFactory.download("vendors.csv", csv);
}
Purchase Order는 기존 목록 조회가 `PurchaseOrderQueryService`에서 관리되고 있었기 때문에, export도 같은 QueryService에 붙였다. 즉 발주 이력 페이지의 검색 로직을 그대로 재사용하면서 CSV만 별도로 생성하는 방식으로 구성했다.
특히 이 페이지는 ordered, received, remain, totalAmount 같은 집계 필드가 이미 리스트에 포함되어 있었기 때문에, CSV 역시 단순 헤더 출력이 아니라 실제 비즈니스 지표를 그대로 내릴 수 있었다.
최종 순서는 다음과 같다.
@Transactional(readOnly = true)
public ResponseEntity<byte[]> export(
LocalDate poFrom,
LocalDate poTo,
String keyword,
String status,
String manager,
String sortKey,
String sortOrder
) {
PurchaseOrderStatus internalStatus = PurchaseOrderStatusMapper.toInternal(status);
LocalDateTime fromDt = (poFrom == null) ? null : poFrom.atStartOfDay();
LocalDateTime toDt = (poTo == null) ? null : poTo.plusDays(1).atStartOfDay();
List<PurchaseOrderRowResponse> rows = purchaseOrderRepository
.searchRowsList(
fromDt,
toDt,
blankToNull(keyword),
internalStatus,
blankToNull(manager),
toSort(sortKey, sortOrder)
)
.stream()
.map(this::toRowResponse)
.toList();
List<String> headers = List.of(
"상태",
"발주번호",
"품번",
"품명",
"발주일시",
"입고예정일",
"거래처명",
"담당자",
"발주수량",
"입고수량",
"발주잔량",
"총금액",
"비고"
);
byte[] csv = CsvUtils.toCsv(
headers,
rows,
row -> List.of(
nullToEmpty(row.statusLabel()),
nullToEmpty(row.poNo()),
nullToEmpty(row.itemCode()),
nullToEmpty(row.itemName()),
nullToEmpty(row.poDate()),
nullToEmpty(row.expectedInboundDate()),
nullToEmpty(row.vendorName()),
nullToEmpty(row.manager()),
valueOf(row.totalOrderedQty()),
valueOf(row.totalReceivedQty()),
valueOf(row.totalRemainQty()),
valueOf(row.totalAmount()),
nullToEmpty(row.memo())
),
true
);
return CsvResponseFactory.download("purchase-orders.csv", csv);
}
입고와 출고는 각각 InboundQueryService, OutboundQueryService에 export 메서드를 붙였다. 이 두 페이지는 구조가 비슷하면서도 컬럼 구성이 다르기 때문에 프론트 기준 정렬이 중요했다.
Inbound는 아래 순서로 맞췄다.
Outbound는 아래 순서로 맞췄다.
이 과정에서 DTO 필드명과 실제 accessor가 다를 경우 컴파일 오류가 발생했고, record의 선언 순서와 accessor 이름을 다시 맞추는 작업도 함께 진행했다. 이 부분은 단순 문법 문제가 아니라 DTO 설계와 export 매핑이 정확히 연결되어 있어야 한다는 점을 다시 확인한 계기였다.
재고 쪽은 현재 재고 요약, 안전재고, 장기재고 세 가지를 따로 export 대상으로 잡았다. 이 영역은 단순 엔티티 조회가 아니라 집계와 계산이 포함된 조회 결과를 내보내야 했기 때문에, 기존 QueryService 내부 helper 흐름을 그대로 재사용하는 방향으로 구성했다.
Inventory Current Summary 최종 컬럼 순서:
Safety Stock 최종 컬럼 순서:
Long-term Stock 최종 컬럼 순서:
@Transactional(readOnly = true)
public ResponseEntity<byte[]> exportCurrentSummary(
String keyword,
String stockStatus,
String optionMode,
String from,
String to,
String sortKey,
String sortOrder
) {
List<InventoryCurrentSummaryRowResponse> rows = loadCurrentSummaryRows(
keyword, stockStatus, optionMode, from, to, sortKey, sortOrder
);
List<String> headers = List.of(
"상태",
"품번",
"품명",
"규격",
"단위",
"현재고",
"가용재고",
"안전재고",
"주 소요량",
"월 소요량",
"년 소요량",
"창고수",
"최종 입고일시"
);
byte[] csv = CsvUtils.toCsv(
headers,
rows,
row -> List.of(
nullToEmpty(row.itemStatus()),
nullToEmpty(row.itemCode()),
nullToEmpty(row.itemName()),
nullToEmpty(row.spec()),
nullToEmpty(row.unit()),
valueOf(row.currentQty()),
valueOf(row.availableQty()),
valueOf(row.safetyQty()),
valueOf(row.weeklyUsage()),
valueOf(row.monthlyUsage()),
valueOf(row.yearlyUsage()),
valueOf(row.warehouseCount()),
valueOf(row.lastInboundAt())
),
true
);
return CsvResponseFactory.download("inventory-current-summary.csv", csv);
}
@Transactional(readOnly = true)
public ResponseEntity<byte[]> exportSafety(
String keyword,
Long warehouseId,
String stockStatus,
String sortKey,
String sortOrder
) {
List<InventorySafetyRowResponse> rows = getSafetyRows(
keyword,
warehouseId,
stockStatus,
sortKey,
sortOrder
);
List<String> headers = List.of(
"상태",
"창고명",
"품번",
"품명",
"규격",
"단위",
"가용재고",
"주 소요량",
"월 소요량",
"년 소요량",
"안전재고",
"부족수량"
);
byte[] csv = CsvUtils.toCsv(
headers,
rows,
row -> List.of(
nullToEmpty(row.stockStatus()),
nullToEmpty(row.warehouseName()),
nullToEmpty(row.itemCode()),
nullToEmpty(row.itemName()),
nullToEmpty(row.spec()),
nullToEmpty(row.unit()),
valueOf(row.availableQty()),
valueOf(row.weeklyUsage()),
valueOf(row.monthlyUsage()),
valueOf(row.yearlyUsage()),
valueOf(row.safetyQty()),
valueOf(row.shortageQty())
),
true
);
return CsvResponseFactory.download("inventory-safety-stock.csv", csv);
}
@Transactional(readOnly = true)
public ResponseEntity<byte[]> exportLongTerm(
String keyword,
Long warehouseId,
String itemStatus,
int daysCut,
String sortKey,
String sortOrder
) {
List<LongTermStockRowResponse> rows =
longTermPage(keyword, warehouseId, itemStatus, daysCut, sortKey, sortOrder, 0, Integer.MAX_VALUE)
.getContent();
List<String> headers = List.of(
"상태",
"품번",
"품명",
"규격",
"단위",
"현재고",
"월 소요량",
"창고",
"최종 입고일",
"최종 출고일",
"경과일"
);
byte[] csv = CsvUtils.toCsv(
headers,
rows,
row -> List.of(
nullToEmpty(row.itemStatus()),
nullToEmpty(row.itemCode()),
nullToEmpty(row.itemName()),
nullToEmpty(row.spec()),
nullToEmpty(row.unit()),
valueOf(row.currentQty()),
valueOf(row.monthlyUsage()),
nullToEmpty(row.warehouseName()),
valueOf(row.lastInboundAt()),
valueOf(row.lastOutboundAt()),
valueOf(row.agingDays())
),
true
);
return CsvResponseFactory.download("inventory-longterm-stock.csv", csv);
}
Warehouse와 Transfer도 마지막 단계에서 export를 붙였다. 창고 관리 페이지는 상태, 코드, 이름, 담당자, SKU 수, 총재고, 연락처, 주소, 비고 순으로 정리했고, Transfer 페이지는 이동번호, 처리일시, 출발 창고, 도착 창고, 품번, 품명, 수량, 담당자, 비고 순서로 맞췄다.
여기서 Warehouse는 단순 Warehouse 엔티티만으로는 보관 SKU, 총재고 값을 알 수 없기 때문에, 기존 창고 재고 집계 조회 결과를 매핑해서 CSV에 포함시키는 방식으로 처리했다. 즉 단순 필드 export가 아니라 다른 조회 결과와 조합된 export라는 점에서 의미가 있었다.
처음 export API를 테스트할 때 브라우저 주소창으로 바로 호출했더니 401이 발생했다. 이 문제는 CSV 로직 자체가 아니라 인증 구조 때문에 발생한 것이었다. CoreERP는 JWT 기반 인증 구조이기 때문에, 브라우저 주소창처럼 Authorization 헤더를 직접 넣을 수 없는 방식으로는 테스트가 어려웠다. 이후 Postman에서 Bearer Token을 포함해서 요청하면서 정상적으로 확인할 수 있었다.
초기 테스트 중 GET으로 만들어진 export endpoint를 POST로 호출해 405가 발생하기도 했다. 이 부분을 통해 export는 리소스 변경이 아닌 조회 기반 다운로드 기능이므로 REST 관점에서 GET이 맞다는 점을 다시 점검했다.
Purchase Order나 Inbound 작업 중에는 DTO field 이름과 export에서 호출하는 accessor 이름이 달라 컴파일 오류가 발생했다. 예를 들어 `totalQtyOrdered()`를 호출했지만 실제 record에는 `totalOrderedQty()`로 선언되어 있었던 식이다. 이 문제는 단순 오타처럼 보일 수 있지만, 실제로는 record 선언과 export 매핑이 정확히 맞아야 한다는 점을 보여주는 부분이었다.
초기 구현은 엔티티 중심 또는 응답 DTO 중심으로 컬럼을 잡았기 때문에, 프론트에서 보는 순서와 다운로드 파일 순서가 다소 어긋나는 문제가 있었다. 결국 사용자는 화면에서 보는 순서 그대로 내려받는 것을 기대하기 때문에, 각 페이지의 프론트 테이블 컬럼 순서를 기준으로 CSV 헤더와 row 매핑 순서를 전부 다시 정렬했다. 이 작업이 이번 단계에서 가장 중요했던 정리 작업이었다.
이번 작업을 하면서 가장 크게 느낀 점은, export 기능은 단순 보조 기능이 아니라 화면 조회 구조와 데이터를 한 번 더 검증하는 역할을 한다는 점이었다. 목록 조회가 잘 설계되어 있으면 export는 자연스럽게 따라붙지만, 조회 DTO나 정렬 기준, 상태값 표현 방식이 정리되어 있지 않으면 export 단계에서 바로 문제가 드러난다.
즉 이번 CSV export 작업은 파일 다운로드 기능을 추가한 것이기도 하지만, 동시에 각 페이지의 조회 구조와 프론트-백엔드 데이터 표현을 다시 정리하는 과정이기도 했다.
이번 단계에서는 CoreERP 여러 목록 페이지에 CSV export 기능을 추가하면서, 공통 CSV 유틸 기반 구조를 만들고 각 도메인별 export endpoint를 정리했다. 또한 프론트 테이블 컬럼 순서와 CSV 출력 순서를 일치시키면서 사용자 경험까지 고려한 형태로 마무리했다.
이 작업으로 CoreERP는 단순 CRUD 수준을 넘어서, 실제 업무 데이터 조회 결과를 외부로 전달할 수 있는 실용적인 ERP 기능을 한 단계 더 갖추게 되었다.