Samsung Sensor Monitor
센서 데이터를 차트 위젯으로 보여 주고 운영자가 위젯을 직접 추가하고 옮기는 Electron 데스크톱 모니터링 앱의 초기 PoC를 만들었습니다. 개발 환경과 코드 규격, 위젯 대시보드의 데이터와 배치 구조를 잡은 뒤 LG Shield에 투입됐습니다. 이후 제품화 단계에서는 구조 문서를 작성하고 후속 개발자의 작업을 리딩했습니다.
핵심 기여
| 과제 | 본인 기여 | 확인한 동작 |
|---|---|---|
| 운영자가 구성하는 대시보드 | 위젯 추가와 삭제, 드래그와 반응형 배치 저장 | 위젯 id로 데이터 항목과 화면 너비별 배치를 연결 |
| 새 위젯의 위치 | 마지막 배치 항목 기준의 위치 계산 | 열 범위를 넘으면 다음 행에 배치 |
| 센서 차트 데이터 | ECharts 래퍼와 dataset 연결 | 선택한 센서 키를 차원으로 쓰고 드래그 중에는 샘플 갱신을 멈춤 |
| 초기 개발 규격 | Biome, Stylelint, Husky, lint-staged 구성 | 커밋 전에 바뀐 파일의 포맷과 린트 검사 |
| 후속 개발의 인수인계 | PlantUML 구조 문서 4종, 후속 개발자 리딩 | Renderer, IPC, Main Process와 장비 연동의 경계 구분 |
개요
| 항목 | 내용 |
|---|---|
| 기간 | 2024.12 ~ 2025.06. 초기 PoC는 2024.12, 후속 구조 문서와 리딩은 2025.04 ~ 2025.06 |
| 역할 | 초기 개발 환경과 코드 규격, 위젯 대시보드 PoC 구현, 구조 문서화와 후속 개발 리딩 |
| 협업 | 제품화 단계의 구현은 후속 개발자가 맡았고 저는 구조 문서와 기술 자문으로 참여했습니다 |
| 기술 | Electron, React, TypeScript, Vite, Zustand, ECharts, react-grid-layout, PlantUML |
| 링크 | 공개 링크 없음 |
진행 단계
| 구간 | 시기 | 주요 작업 |
|---|---|---|
| 환경 | 2024.12 초 | Biome, Husky, lint-staged 구성과 첫 포맷 적용, Sass와 clsx, lodash 추가 |
| 샘플 | 2024.12 중순 | Stylelint와 일괄 린팅, react-grid-layout과 ECharts 샘플, 버튼 샘플, 차트 타입 인터페이스 |
| 대시보드 | 2024.12 하순 | 배치 저장, 빈 영역의 위젯 추가와 차트 메뉴, 메뉴 구조와 식별값, 헤더 드래그와 레이아웃 타입, 차트 dataset |
| 인수인계 | 2025.04 ~ 2025.06 | 제품화 저장소의 구조 도식 작성과 후속 개발 기술 자문 |
맡은 범위
- 본인 구현. 초기 저장소의 개발 환경과 코드 규격, react-grid-layout 기반 위젯 대시보드, ECharts 래퍼와 차트 옵션, 차트 메뉴와 드래그 헤더, localStorage 저장 유틸리티, 공통 버튼과 스타일 샘플.
- 리딩과 협업. 제품화 저장소에 소프트웨어 계층, 모듈, 프로세스, 시퀀스 관점의 PlantUML 도식을 작성하고 후속 개발자에게 구조 관점의 기술 자문을 했습니다.
- 후속 구현. 시리얼 통신과 USB, MessagePort 기반 프로세스 간 통신, 센서 저장소, IR 카메라 연동, 제품용 위젯 확장은 후속 개발자의 구현입니다. 도식으로 설명한 범위를 제 구현으로 옮기지 않았습니다.
문제와 해결
코드는 초기 PoC 저장소의 본인 커밋에서 발췌했습니다. 제품화 저장소의 화면과 장비 연동 코드는 발췌하지 않았습니다.
위젯의 데이터와 배치를 같은 id로 유지하기
위젯 하나에는 두 종류의 정보가 있습니다. 어떤 차트로 어떤 센서를 보여 줄지는 위젯의 데이터이고 화면의 몇 번째 칸에 얼마나 크게 놓일지는 배치입니다. react-grid-layout은 배치를 화면 너비 구간(breakpoint)마다 따로 관리하므로 한 위젯의 배치 정보가 여러 개 생깁니다. 차트를 지웠는데 어느 너비의 배치만 남거나 너비가 바뀔 때 위젯을 다른 식별자로 찾으면 대시보드 구성이 어긋납니다.
배치는 라이브러리가 받는 너비 구간별 배열 형태 그대로 두고 차트 데이터는 따로 저장했습니다. 둘을 잇는 것은 위젯 id 하나이고 데이터 항목의 id와 모든 너비 구간 배치의 i가 같은 값을 씁니다. 추가와 삭제는 두 저장소를 한 번에 갱신하고 드래그나 크기 조절이 끝난 배치는 별도로 저장해 다음 실행에서 같은 구성으로 시작하게 했습니다.
flowchart LR
Action["위젯 추가와 삭제"] --> Id["공통 위젯 id"]
Id --> Items["데이터: 차트 종류와 센서 키"]
Id --> Layouts["배치: 너비 구간별 위치와 크기"]
Drag["드래그와 크기 조절 종료"] --> Layouts
Items --> Save["localStorage 저장"]
Layouts --> Save
Save --> Restore["다음 실행의 초기 상태"]삭제는 위젯 id로 데이터 항목을 거르고 저장된 배치의 모든 너비 구간에서 같은 id를 지웠습니다.
구현 코드: 위젯과 모든 너비 구간의 배치를 함께 삭제
setItems((prevItems) => {
const newItems = _.filter(prevItems, chart => chart.id !== chartId);
const currentLayouts = loadLayoutsFromLS();
const newLayouts = _.mapValues(currentLayouts, layout =>
_.filter(layout, item => item.i !== chartId)
);
saveItemsToLS(newItems);
saveLayoutsToLS(newLayouts);
return newItems;
});초기 PoC의 본인 변경 코드에서 저장 흐름만 발췌했습니다.
이 구조로 화면 너비가 바뀌어도 해당 구간의 배치를 읽어 같은 위젯을 다시 그렸습니다. 저장 위치가 localStorage라서 사용자 단위 설정이나 여러 장비 사이의 공유는 다루지 않은 PoC였습니다.
빈 자리를 탐색하는 대신 마지막 항목 옆에 놓기
위젯을 추가하면 어딘가에 자리를 잡아야 합니다. 그리드 전체에서 들어갈 수 있는 빈 공간을 찾으면 화면을 촘촘하게 쓸 수 있지만 너비 구간마다 탐색해야 하고 위젯 크기가 다르면 계산이 복잡해집니다. PoC에서는 위치를 예측하기 쉬운 단순한 규칙을 썼습니다.
findNextEmptyPosition은 현재 배치의 마지막 항목 오른쪽을 새 위젯의 위치로 계산하고 그 위치가 열 범위를 넘으면 다음 행의 처음으로 옮깁니다. 새 위젯은 항상 마지막 위젯 뒤에 붙고 사용자는 드래그로 원하는 위치에 옮길 수 있습니다.
flowchart TD
Add["위젯 추가"] --> Last["마지막 배치 항목의 오른쪽 계산"]
Last --> Fit{"열 범위 안인가?"}
Fit -->|예| Place["같은 행에 배치"]
Fit -->|아니요| Next["다음 행의 처음에 배치"]
Place --> Save["배치 저장"]
Next --> Save이 방식은 중간에 생긴 빈칸을 다시 채우지 않습니다. 위젯을 여러 번 지우고 추가하면 화면 중간에 빈 공간이 남을 수 있어 최적 배치 알고리즘으로 설명하지 않았습니다.
차트 메뉴와 헤더 드래그로 조작 영역 나누기
대시보드에서는 위젯을 추가하는 동작과 이미 있는 위젯을 옮기는 동작이 같은 화면에서 일어납니다. 차트 메뉴로 위젯을 추가하면 새 위젯이 그리드의 빈 영역에 놓이게 했고(34dde90) 차트를 초기화할 때 메뉴 항목에 식별값을 붙였습니다(69d2ef5). 차트 메뉴의 구조는 두 차례 다시 나눴고 그 과정에서 키를 다루는 유틸리티를 추가했습니다(ee5f474, 5f3ab8c). 위젯은 헤더를 잡아 옮기도록 하고 차트 옵션을 더하면서 레이아웃의 타입을 명시했습니다(2d14ebd).
이 절은 당시 커밋 기록과 파일 구성으로 정리했습니다. 차트 메뉴와 레이아웃 유틸리티의 세부 코드는 지금 다시 대조하지 못해 발췌하지 않았습니다.
차트의 표현과 센서 샘플 갱신을 나누기
같은 센서 데이터를 선, 막대 같은 다른 차트로 보여 줄 수 있어야 했고 위젯을 옮기는 동안에도 샘플은 계속 들어옵니다. 차트 컴포넌트가 데이터 만들기와 그리기를 모두 맡으면 차트 종류가 늘 때마다 데이터 갱신 코드가 복제됩니다.
그래서 차트가 요구하는 데이터 형태는 store 성격의 컴포넌트에서 만들고 ECharts 래퍼는 표현 옵션만 받도록 나눴습니다. 막대 차트는 선택한 센서 키를 dataset의 차원(dimensions)으로 쓰고 시간과 함께 행을 추가했습니다. 샘플은 최근 300행만 유지했고 드래그 중인 차트에서는 샘플 갱신 effect를 시작하지 않았습니다.
flowchart LR
Keys["선택한 센서 키"] --> Store["store 컴포넌트: dataset 생성"]
Timer["1초 간격 샘플"] --> Store
Drag{"드래그 중인가?"} -->|예| Pause["갱신 effect 시작 안 함"]
Drag -->|아니요| Store
Store --> Wrapper["ECharts 래퍼: 표현 옵션"]구현 코드: 선택한 센서를 dataset 차원으로 연결
const [dataset, setDataset] = useState(() => ({
dimensions: ["time", ...sensorKeys],
source: [],
}));
useEffect(() => {
if (isDragging) return;
const intervalId = setInterval(updateSensorData, 1000);
return () => clearInterval(intervalId);
}, [sensorKeys, isDragging]);타입과 데이터 행을 만드는 부분은 생략했습니다. 당시 샘플은 최근 300행을 유지했습니다.
드래그 상태에 따른 갱신 중단은 차트에 표시할 샘플에만 적용했습니다. 실제 장비의 데이터 수집을 멈추는 동작이 아니었고 렌더링 성능을 측정해 얻은 개선도 아니었습니다.
첫 주에 코드 규격부터 세우기
이 저장소는 PoC로 시작했고 저는 초기 개발 환경과 규격을 잡은 뒤 다른 프로젝트로 이동했습니다. 화면을 만들기 전에 포맷과 린트 규칙부터 저장소에 넣었습니다.
첫 커밋들에서 Biome으로 TypeScript의 포맷과 린트를 맡기고 Husky와 lint-staged로 커밋 전에 바뀐 파일을 검사하게 했습니다. 기존 파일에는 포맷을 한 번에 적용했습니다. 샘플 화면을 만들기 시작한 날에는 스타일 검사용 Stylelint를 더하고 린팅을 다시 일괄 적용했습니다. 규칙을 문서로 전달하지 않고 커밋 단계에서 도구가 확인하게 했습니다.
flowchart LR
Edit["코드 수정"] --> Stage["git add"]
Stage --> Hook["Husky pre-commit"]
Hook --> Staged["lint-staged: 바뀐 파일만"]
Staged --> Biome["Biome: 포맷과 린트"]
Biome --> Commit["커밋"]이 규격이 제품화 저장소에서 어떻게 유지되거나 바뀌었는지는 제 기여로 정리하지 않았습니다. 제 커밋에는 자동 테스트 구성이 없었습니다.
후임이 이어받을 프로세스 경계를 남기기
Electron 앱은 화면을 그리는 Renderer와 운영체제 자원을 다루는 Main Process가 나뉘고 제품화 단계에서는 Main Process가 시리얼 통신, 센서 저장소, 카메라와 연결됩니다. 제가 직접 구현한 것은 Renderer 쪽의 초기 위젯 UI였습니다. 다른 프로젝트로 이동한 뒤 후속 개발자가 제품 구조를 이어받으려면 어느 계층이 어떤 데이터와 책임을 갖는지 한 장으로 볼 수 있어야 했습니다.
그래서 제품화 저장소에 같은 시스템을 소프트웨어 계층, 모듈, 프로세스, 시퀀스의 네 관점으로 나눈 PlantUML 도식을 작성했습니다(46483e9). 도식은 Renderer, IPC 브릿지와 MessagePort, Main Process, 센서 저장소, 시리얼 통신, IR 카메라가 어떤 경계로 나뉘고 어떻게 이어지는지를 관점별로 보여 줍니다.
flowchart LR
UI["Renderer: 위젯과 차트"] <--> IPC["IPC 브릿지와 MessagePort"]
IPC <--> Main["Main Process"]
Main <--> Storage["센서 저장소"]
Main <--> Serial["시리얼 통신과 USB"]
Main <--> Camera["IR 카메라 연동"]이 도식은 문서로 설명한 시스템의 책임 구분입니다. 도식에 나오는 장비 연동은 후속 개발자의 구현이고 저는 구조 관점의 자문과 리딩으로 참여했습니다.
결과와 근거
- 초기 PoC 저장소의 본인 커밋으로 개발 환경, 위젯 대시보드, 차트 데이터 연결을 확인했습니다. 주요 근거는
src/demos/dashboard.tsx,src/layout/grid/layout-grid-util.ts,src/components/echarts/chart-bar-store.tsx,src/store/local-storage/local-storage-util.ts와 커밋d6235aa,34dde90,5f3ab8c,d990324입니다. - 제품화 저장소의 구조 도식은
diagrams/아래 PlantUML 파일과 커밋46483e9로 확인했습니다. - 현장 적용 범위, 실제 장비 데이터의 처리량과 운영 효과는 측정한 값이 없어 넣지 않았습니다. PoC 코드의 정적 대조와 도식 검토를 실제 장비의 실행 검증으로 표현하지 않았습니다.
배운 것
- 위젯형 화면은 무엇을 그릴지와 어디에 그릴지를 다른 저장소에 두고 식별자 하나로만 잇는 편이 추가와 삭제, 반응형 전환을 단순하게 만들었습니다. 배치 라이브러리가 요구하는 형태를 차트 데이터 모델에 섞지 않는 것이 핵심이었습니다.
- 먼저 자리를 옮기는 사람이 남길 것은 기능 목록보다 경계였습니다. 직접 구현하지 않을 영역까지 계층과 프로세스, 시퀀스로 나눠 그려 두면 자문할 때 책임 범위를 같은 그림으로 가리킬 수 있습니다.
- 규칙은 사람이 기억하게 하기보다 커밋 단계에서 도구가 확인하게 하는 편이 짧은 투입 기간에 맞았습니다.