Intro
지난번 [SpreadJS 최적화 포스팅]에서는 대량의 데이터를 화면에 그리는 프론트엔드 성능 문제로 골머리를 앓았었다. 그런데 이번에는 백엔드 로직에서 또 다른 벽을 만났다. 바로 "데이터의 변경 이력(History)을 관리하고, 서로 다른 페이지 간에 데이터를 이동시키는" 작업이었다.
개인 프로젝트나 간단한 서비스에서는 보통 데이터를 INSERT 하고 SELECT 하면 끝이다. 하지만 실무에서의 요구사항은 훨씬 구체적이고 까다로웠다.
- 변경 이력(Revision) 시각화: "버전별 데이터를 비교해서, 삭제된 건 빨간색, 추가된 건 파란색, 수치가 바뀐 건 초록색으로 보여주세요."
- 데이터 이관(Import) 시 스키마 매핑: "A 페이지(설계)에서 만든 A 데이터를 B 페이지(구매)로 가져오고 싶은데, 두 페이지의 컬럼명이 달라도 알아서 매핑해서 넣어주세요."
단순히 데이터를 넣고 빼는 수준을 넘어서, 데이터의 흐름과 상태를 추적해야 하는 문제였다. 처음 요구사항을 들었을 때는 막막했지만, 기존 시스템을 분석하면서 해답을 찾았다. 기존 코드는 정적인 DTO 대신 JSON 객체를 적극적으로 활용하고 있었는데, 처음엔 의아했지만 "왜 이렇게 짰을까?"를 역추적하며 그 유연함을 이해하게 되었다. 이 구조를 기반으로 복잡한 Diff 로직과 매핑 기능을 풀어낸 과정을 정리해 본다.
1. JsonNode vs JSONObject & JSONArray
구현 초기에는 Spring Boot의 표준 라이브러리인 Jackson의 JsonNode를 사용하려 했다. 프로젝트 내 다른 로직에서도 JsonNode를 사용하고 있었고, 최신 스프링 생태계에 더 적합하다고 생각했기 때문이다.
하지만 설계를 검토해주시던 수석님께서 뜻밖의 조언을 주셨다. "데이터 구조를 계속 변경해야 하는 이 로직에서는 JsonNode보다 JSONObject가 관리하기 훨씬 편할 거야. 기존에 작성된 JSONObject 활용 코드를 한번 분석해 봐."
처음엔 의아했다. '굳이 옛날 라이브러리(org.json)를?' 하는 생각이 들었지만, 두 라이브러리를 직접 비교해 보니 이유를 알 수 있었다.
- JsonNode (Jackson): 기본적으로 불변(Immutable)에 가깝다. 데이터를 수정하거나 필드를 추가하려면 ObjectNode로 캐스팅해야 하고, 구조를 변경하는 코드가 장황해진다. (읽기 전용에 유리)
- JSONObject & JSONArray (org.json): Map 그리고 List와 거의 동일하게 동작한다.
- JSONObject: HashMap처럼 Key-Value를 자유롭게 put(), remove() 할 수 있다. (행 데이터 역할)
- JSONArray: ArrayList처럼 인덱스로 접근하고, 순회(Iteration)가 편하다. (전체 그리드 데이터 역할)
특히 이번 작업처럼 엑셀 데이터 1만 건이 넘어오는 경우, 굳이 List<ItemDto> 같은 래퍼(Wrapper) 클래스를 만들 필요 없이 JSONArray로 받아서 바로 JSONObject로 꺼내 쓰는 방식이 훨씬 직관적이었다.
// 기존 방식: DTO 리스트 (클래스 정의 필요)
List<ItemDto> items = ...;
// 변경 방식: JSONArray (클래스 없이 바로 사용)
JSONArray jsonArray = new JSONArray(jsonString); // 프론트에서 받은 배열 문자열
for (int i = 0; i < jsonArray.length(); i++) {
JSONObject row = jsonArray.getJSONObject(i); // i번째 행을 바로 꺼냄
// ... 로직 수행
}
이번 프로젝트의 핵심은 정해지지 않은 엑셀 컬럼을 받아 DB 컬럼으로 매핑(수정)하고, 없는 필드를 추가(Add)하는 것이었다. 결국 수석님의 말씀대로, 정적 타입의 안전성보다는 데이터 가공의 유연성(Flexibility)이 압도적으로 중요한 상황이었다. 나는 이 의도를 파악한 후, JSONArray와 JSONObject 조합을 채택하여 로직을 설계했다.
2. 성능 최적화: Diff 로직을 O(N)으로 만들기
데이터 비교(Diff) 기능의 핵심은 속도다. 처음에는 단순하게 "리스트 두 개니까 이중 for문 돌려서 찾으면 되겠지?"라고 생각했다. 하지만 테스트 데이터 1만 건을 넣자마자 서버 응답이 눈에 띄게 느려졌다. 이는 시간 복잡도가 O(N^2)이 되기 때문이다.
그래서 DB에 저장된 데이터(From)와 엑셀로 업로드된 데이터(To)를 모두 Unique Key 기반의 Map으로 변환하는 작업을 선행했다.
// 리스트를 Map으로 변환하여 검색 속도 확보 -> O(N)
public Map<String, JSONObject> getToItemMap(String projectNo, List<ItemDto> dtos) {
return dtos.stream()
.filter(item -> /* 필요시 조건 필터링 */)
.collect(Collectors.toMap(
ItemDto::getUniqueKey, // Key: 유니크한 ID (ItemNo 등)
value -> {
JSONObject json = new JSONObject();
json.put("reqNo", value.getReqNo());
/* ... 필요한 데이터 Put ... */
return json;
}
));
}
이렇게 데이터를 Map<String, JSONObject> 형태로 미리 가공해두니, 비교 로직에서 map.get(key)로 바로 접근할 수 있어 데이터 양이 늘어나도 O(N)에 가까운 속도를 유지할 수 있었다.
3. Revision 로직: 변화를 시각화하기
이제 두 개의 Map(fromMap, toMap)을 순회하며 데이터의 상태를 결정해야 했다. 현업에서 요청한 "빨강, 파랑, 초록"을 구현하기 위해, 백엔드에서 명확한 상태 값(CUD Type)을 내려주기로 했다.
public ItemCompareResult createCompareResult(String key, JSONObject fromObj, JSONObject toObj) {
ItemCompareResult result = new ItemCompareResult();
result.setCompareKey(key);
// 1. Delete (빨강): DB엔 있었는데 엑셀에서 사라짐
if (!fromObj.isEmpty() && toObj.isEmpty()) {
result.setCudType(CudType.DELETED);
}
// 2. Add (파랑): DB엔 없었는데 엑셀에 새로 생김
else if (fromObj.isEmpty() && !toObj.isEmpty()) {
result.setCudType(CudType.ADDED);
}
// 3. Revised (초록): 둘 다 있는데 값이 다름
else {
result.setCudType(CudType.REVISED);
// 필드 단위로 상세 비교 (값이 바뀐 것만 체크)
result.setDetailMap(createDetailDiff(keys, fromObj, toObj));
}
return result;
}
단순히 행(Row)의 존재 여부만 보는 게 아니라, createDetailDiff 메서드 안에서 셀(Cell) 단위로 값을 비교하고 isChanged 플래그를 달아줬다. 덕분에 프론트엔드에서는 복잡한 로직 없이 이 플래그만 보고 바로 CSS 클래스를 적용할 수 있었다.
4. 난관: 페이지마다 컬럼명이 다르다 (Schema Mapping)
Diff 기능을 끝내고 나니 또 다른 요구사항이 들어왔다. "설계(Engineering) 페이지의 데이터를 구매(Purchasing) 페이지로 불러오고 싶은데, 데이터가 안 들어와요."
확인해보니 두 페이지가 다루는 데이터의 실체(프로젝트 번호, 자재 코드 등)는 같은데, DB 컬럼명(Key)이 제각각이었다.
- 설계 페이지: project_no, material_code
- 구매 페이지: pjt_id, item_cd
이걸 코드에 하드코딩(if page == 'purchase')으로 박아넣기 시작하면, 나중에 페이지가 늘어날 때마다 감당이 안 될 것 같았다. 그래서 스키마 매핑 정보를 별도의 JSON 설정이나 DB로 분리해서 관리하기로 했다.
// 스키마 매핑 정의 (Schema Definition)
{
"engineering": {
"std_key_1": "project_no",
"std_key_2": "material_code"
},
"purchasing": {
"std_key_1": "pjt_id",
"std_key_2": "item_cd"
}
}
그리고 데이터를 이관할 때 이 '지도'를 보고 변환해주는 범용 메서드를 만들었다.
public JSONObject convertDataSchema(JSONObject sourceData, String sourcePage, String targetPage) {
JSONObject targetData = new JSONObject();
// 각 페이지의 매핑 정보 로딩
Map<String, String> sourceSchema = schemaMap.get(sourcePage);
Map<String, String> targetSchema = schemaMap.get(targetPage);
// 표준 키(std_key)를 매개체로 데이터 변환
// 설계의 'project_no' 값을 꺼내서 -> 구매의 'pjt_id' 키로 넣음
for (String stdKey : sourceSchema.keySet()) {
String sourceCol = sourceSchema.get(stdKey);
String targetCol = targetSchema.get(stdKey);
if (sourceData.has(sourceCol)) {
targetData.put(targetCol, sourceData.get(sourceCol));
}
}
return targetData;
}
이렇게 추상화해두니 새로운 페이지가 추가되어도 매핑 정보만 등록하면 코드를 수정할 필요가 없었다. 유지보수 측면에서 정말 잘한 선택이었다고 생각한다.
Outro
이번 작업을 하면서 느낀 건, 백엔드 로직을 어느 정도 다룰 줄 알면 프론트엔드 코드가 훨씬 깔끔해진다는 점이다. 예전 같았으면 프론트에서 억지로 끼워 맞췄을 로직들을, 백엔드 단계에서 미리 정제해서 내려주니 화면에서는 그리기에만 집중할 수 있었다.
물론 아직 쿼리 최적화나 복잡한 트랜잭션 처리는 낯설고 어렵다. 하지만 이렇게 데이터의 흐름을 처음부터 끝까지 한번 훑어본 경험은, 앞으로 프론트엔드 개발을 할 때 API 구조를 제안하거나 백엔드 개발자와 소통하는 데 큰 자산이 될 것 같다. 다음에는 이 데이터를 받아 리액트(React) 상태로 관리할 때 고민했던 부분들을 정리해봐야겠다. 끝!
'Backend' 카테고리의 다른 글
| API Proxy와 Kafka 이벤트, 상황에 맞는 MSA 데이터 공유 방식 선택하기 (0) | 2026.01.17 |
|---|---|
| AWS S3 Pre-signed URL로 보안과 성능 다 잡기 (feat. 동영상 스트리밍 아키텍처) (1) | 2025.11.18 |
| MSA 환경에서 대용량 분할 저장 시 발생한 서버 종료 문제, 폴링 기반 헬스 체크 & 복구(Recovery) 구현 (0) | 2025.05.21 |