- 발행일
설정을 localStorage에 두고 소켓 메시지 16갈래로 창끼리 맞췄다 — 상태 동기화를 새로고침으로 푼 구조
설정을 localStorage에 두고 소켓 메시지 16갈래로 창끼리 맞췄다
이 글은 2026년 9월에 당시 코드를 다시 보며 정리한 것입니다. 저장소는 퇴사 전에 스냅샷으로 보관한 것이라 커밋 이력이 없고 경력기술서와 당시 쓴 지원서·이력서에 적은 담당 범위와 코드를 대조해 썼습니다. 날짜는 코드에 남은 시기 단서 기준입니다.
첫 직장에서 맡은 공항 보안 검색 판독 시스템은 장비에 붙어 나가는 웹 화면이었습니다. 판독관 앞에 모니터가 둘이고 위에서 찍은 영상과 옆에서 찍은 영상을 각각 띄웁니다. 경력기술서2022-08-05산업 AI 솔루션 프론트엔드 2021 ~ 2022 경력 기술서산업 AI 솔루션 기업에서 관세청 불법 복제품 판독 시스템과 공항 보안 검색 판독 시스템의 화면을 맡았던 기록입니다. 판독 화면, 웹소켓 실시간 판독, React SPA 전환, 납품처별 설정, 통계 시각화가 핵심이고, 항목마다 근거가 되는 글을 링크해 두었습니다.에 "단화면·양화면 모드"라고 적은 그 화면이에요.
두 모니터는 브라우저 창 두 개였습니다. 같은 React 앱을 /와 /main_side 두 주소로 열어 둔 것이고 서로 다른 탭이니 React 상태를 공유할 방법이 없었습니다. 그런데 한쪽에서 "박스 표시 끄기"를 누르면 다른 쪽도 꺼져야 했어요.
팀은 프런트 셋에 백엔드 하나였고 2023년 말 이력서에 제 담당을 "로그인, 설정, 웹소켓 아이콘"이라고 적었습니다. 이 글에서 다루는 설정 토글과 소켓 메시지가 그 범위예요. 다만 셋이 같은 파일을 만졌으니 어느 줄이 제 손인지는 가르지 못합니다. 판단의 근거는 커밋에 남아 있지 않아서 구조가 무엇이었는지와 그게 무엇을 대가로 했는지만 적습니다.
세 층: localStorage, 소켓 문자열, storage 이벤트
설정값의 정본은 localStorage였습니다. 왜 서버가 아니라 브라우저 저장소였는지는 2022년 중반에 쓴 지원서에 두 줄로 남아 있어요. "통계 작업 시에 데이터 값을 기억할 수 있도록 설계", "페이지마다 데이터를 호출하다 보면 로딩 시간이 길어져서 해결할 수 있는 방법을 모색". 화면을 옮길 때마다 서버에 다시 묻지 않으려고 값을 브라우저에 붙들어 둔 것이고 그게 제 담당 항목이었습니다. 앱이 처음 뜰 때 App.js가 기본값을 심습니다.
if (localStorage.length === 0) {
localStorage.setItem('detect', 'on');
localStorage.setItem('alarm', 'off');
localStorage.setItem('label', 'on');
localStorage.setItem('roi', 'on');
localStorage.setItem('save_setting', 'unSave');
// … direction, only_top, bottom_top, label_top
토글 하나를 누르면 두 가지를 합니다. 내 창의 localStorage를 바꾸고(setItem('detect', 'off')), 소켓으로 문자열을 보냅니다(send('detectOff')). 서버는 그 문자열을 연결된 모든 클라이언트에 되돌려 줍니다. 받는 쪽은 index.js 맨 위, React 바깥에 있습니다.
socketConnection.on('message', function (msg) {
if (msg === 'refresh') {
window.history.go(0)
}
else if (msg === 'detectOff') {
localStorage.setItem('detect', 'off')
}
else if (msg === 'detectOn') {
localStorage.setItem('detect', 'on')
}
// … alarm, onlyTop, label, bottomTop, labelUp, roi, change_rail 짝이 이어짐
이 if가 열여섯 갈래입니다. 설정 여덟 가지에 on/off 한 쌍씩. 그 여덟 가지가 어디서 왔는지는 2022년 중반에 쓴 지원서에 있습니다. 2022년 5월 이후 납품처 한 곳의 추가 요구로 "옵션을 추가(레이블 표시, 레이블 위/아래, bbox 추가 등)", "화살표 방향을 기계에 추가 입력"이라고 적어 뒀는데, label·label_top·detect·change_rail 갈래가 그 항목들이에요. 요구가 하나 올 때마다 localStorage 키 하나와 소켓 문자열 한 쌍이 늘어난 셈입니다. 그리고 마지막 else가 소켓으로 오는 판독 결과 JSON을 받는 자리라, 제어 메시지와 데이터 메시지가 한 채널의 문자열 비교로 갈립니다.
세 번째 층은 브라우저의 storage 이벤트입니다. 다른 탭이 localStorage를 바꾸면 이 이벤트가 오는데, e.key === 'save_setting'이면 window.location.reload(true)를 부르는 리스너를 App.js에 달아 설정 저장 신호로 썼습니다. 설정 모달에서 저장을 누르면 save_setting을 'Save'로 바꾸고 1초 뒤 자기 창을 새로고침합니다. 그 쓰기가 다른 창에 storage 이벤트로 닿고 다른 창도 새로고침합니다. 동기화의 최종 수단이 새로고침이었던 거예요.
짝이 뒤집힌 메시지
열여섯 갈래를 옮겨 적다가 걸리는 게 있었습니다. detect와 alarm은 Off가 'off'를 쓰는데, 뒤의 넷은 반대입니다.
else if (msg === 'labelOff') {
localStorage.setItem('label', 'on');
}
else if (msg === 'labelOn') {
localStorage.setItem('label', 'off');
}
보내는 쪽 Setting.js를 보면 이유가 보입니다. 거기서는 label이 켜져 있으면 'off'로 쓰고 labelOff를 보내요. 즉 메시지 이름이 "지금 상태"가 아니라 "누른 버튼"이고, 받는 쪽이 그걸 다시 뒤집어 적은 겁니다. onlyTopOff가 'true'를, change_rail_right가 'left'를 쓰는 것도 같은 결입니다. 두 창의 값이 결과적으로 맞긴 하는데, 메시지 이름만 봐서는 어느 쪽이 켜지는지 알 수 없는 프로토콜이 됐습니다. 지원서대로라면 이 옵션들은 2022년 5월 이후 한 건씩 덧붙은 것이라, 앞의 짝을 안 고치고 뒤에 이어 붙이다 이름이 굳은 것으로 읽힙니다. 어느 갈래가 제 손인지는 프런트 셋이라 못 가릅니다.
깜박임은 서버에 부탁했다
index.js 맨 위에 이런 주석이 있습니다.
/*TODO: 예비용 장비 로컬에 pycharm main.py line 309 socket_io.send('refresh') 추가
* 수정 전) mainBottom에 버튼이 있었을 때는 화면 깜박임(새로고침) 없이 작동되었음
* 수정 후) Header에서는 서버에 추가한 코드로 인해 화면이 깜박인 후 다시 로드 됨.
*
* 프론트에서는 기능을 우선해서 구현할테니.. 백엔드에서 깜박이는거 없애주세여.. */
버튼이 화면 아래에 있을 때는 그 컴포넌트 안에서 상태를 바꾸면 됐는데, 헤더로 옮기면서 서버가 refresh를 쏘고 창이 통째로 다시 뜨게 된 상황입니다. 깜박임의 원인이 서버가 보내는 refresh 한 줄이라는 것까지는 적혀 있고 그걸 프런트에서 풀 방법은 적혀 있지 않습니다. 당시 저는 창 사이 상태 공유를 "새로고침 없이" 하는 방법을 몰랐고 그 사실이 주석에 그대로 남았습니다.
뷰 모드 셋과 _side 라우트 다섯
같은 파일에서 서버 설정 한 값(M1X1·M1X2·M2X2)이 isSingleView·isMultiView·isDualView 세 불리언이 되어 화면 다섯 개 전부에 prop으로 내려갑니다. 라우트는 /·/status·/search·/thumb·/detail 다섯에 _side가 붙은 다섯이 더 있어 옆 창은 같은 컴포넌트를 헤더만 바꿔 한 번 더 등록한 것입니다. 시기는 지원서에 있어요. 2022년 4월 18일 전시회에 단일 모니터 판을 냈고 4월 둘째 주에 납품처 한 곳의 다중 모니터 명세를 받았다고 적혀 있어 M1X1과 M1X2가 갈라진 게 그 무렵입니다. 박스 좌표는 두 창 모두 소켓과 별도로 EventSource(/draw_box, 옆 창은 /draw_box_side)로 받는데, 이 SSE는 지원서의 2021년 4~6월 첫 리팩토링 항목에 적힌 "SSE 구조"가 남은 것으로 읽힙니다.
검증
- 날짜 근거: 스냅샷 커밋이 2022-07-24 두 개뿐이고 코드 안 날짜 문자열은
2021-12-236건·2022-04-011건입니다. 지원서는 React 리팩토링을 2022년 3~5월, 옵션 추가를 5월 이후로 적고 있어요. 이 글의 날짜는 스냅샷 시각으로 잡았습니다. - 경력기술서·지원서·이력서와 대조:
| 경력기술서 문장 | 코드 | 지원서·이력서 | 판정 |
|---|---|---|---|
| 웹소켓 양방향 통신으로 실시간 데이터 전송 | socket.io-client로 판독 JSON 수신, 제어 문자열 송수신 | 이력서 담당 "웹소켓 아이콘" | 일치 |
| 단화면·양화면 모드 | M1X1/M1X2/M2X2 → 불리언 셋, _side 라우트 5개 | 지원서 4/18 단일 모니터 전시, 4월 둘째 주 다중 모니터 명세 | 일치, 시기 2022-04 |
| 줌/확대 | Detail.js의 캔버스 스케일·휠 줌 | 언급 없음 | 일치 |
| 2.3 옵션을 설정으로 | 토글 8종 → localStorage 키 + 소켓 문자열 | 지원서 "레이블 표시·위/아래·bbox·화살표 방향" | 일치, 시기 2022-05 이후 |
| (언급 없음) | 옆 창 박스 데이터는 EventSource | 지원서 2021-04~06 "SSE 구조" | 경력기술서에만 없음 |
- 이 글을 쓰며 센 것:
socketConnection.send(호출 38곳(8개 파일),localStorage키 20종 남짓,window.history.go(0)·reload7곳. 그날의 측정치는 없습니다.
남은 것 · 한계
- 커밋 이력이 없어 "왜"를 못 씁니다. 소켓 문자열로 갈 것인지, 서버가 상태를 들고 있을 것인지를 견줬는지 기록이 없어요. 지원서와 이력서로 담당 범위와 시기까지는 좁혔지만 프런트 셋이 같이 만진 파일이라 어느 줄이 제 것인지는 끝내 못 가릅니다.
- 제어와 데이터가 한 채널입니다.
else로 떨어진 문자열을 전부JSON.parse하므로, 새 제어 메시지가 추가되고 프런트가 못 받으면 파싱 예외가 납니다. - 동기화가 새로고침에 기대고 있습니다. 설정 저장 때마다 두 창이 다시 뜨고 판독 중이던 화면이 끊깁니다. 주석의 TODO는 스냅샷 시점까지 해결되지 않았습니다.
- 같은 문제를 2년 뒤 TripTune에서 다시 만났습니다. 채팅 연결 객체를 어디 둘지를 놓고 STOMP 클라이언트를 useState에서 useRef로2024-11-24STOMP 클라이언트를 useState에서 useRef로 — 정리 함수가 구독 객체를 못 보고 있었다채팅의 STOMP 연결과 구독 객체를 useState에 담고 있었습니다. effect의 정리 함수가 그 state를 읽어 unsubscribe하게 짜 놨는데, 정리 함수는 effect가 만들어질 때의 값을 잡고 있어서 늘 null이었어요. 다음 날 새벽 클라이언트를 useRef로 옮기고 클라이언트·구독 state 셋을 지웠습니다. 파일이 212줄에서 111줄이 됐는데, 그중 54줄은 리팩토링 커밋이 화면 JSX를 통째로 주석 하나로 바꿔 버린 탓이었습니다. 옮겼고 화면 사이 공유 상태는 스토어로 옮겼습니다2024-11-20일정에 담은 장소를 컴포넌트 state에서 스토어로 옮겼다 — userId를 nickname으로 바꾸던 날일정 만들기 화면에서 담은 장소 목록은 컴포넌트 안의 state였고, 서버 응답과 부모 props 두 곳에서 그 state를 덮어쓰고 있었습니다. 서버가 userId 대신 nickname을 주기로 한 날, 그 목록을 zustand 스토어로 옮겼어요. 상태의 주인을 하나로 만든 건 맞는데, 같은 커밋에서 드래그 항목 컴포넌트를 부모 함수 안으로 옮겼고 스토어 훅 이름을 이미 있는 것과 똑같이 지었습니다. 둘 다 지금까지 남아 있습니다.. 창 두 개를 맞추는 문제는 그 뒤로 만나지 않았습니다.
관련 글: 산업 AI 솔루션 기업 프런트엔드 2021–20222022-08-05산업 AI 솔루션 프론트엔드 2021 ~ 2022 경력 기술서산업 AI 솔루션 기업에서 관세청 불법 복제품 판독 시스템과 공항 보안 검색 판독 시스템의 화면을 맡았던 기록입니다. 판독 화면, 웹소켓 실시간 판독, React SPA 전환, 납품처별 설정, 통계 시각화가 핵심이고, 항목마다 근거가 되는 글을 링크해 두었습니다. · STOMP 클라이언트를 useState에서 useRef로2024-11-24STOMP 클라이언트를 useState에서 useRef로 — 정리 함수가 구독 객체를 못 보고 있었다채팅의 STOMP 연결과 구독 객체를 useState에 담고 있었습니다. effect의 정리 함수가 그 state를 읽어 unsubscribe하게 짜 놨는데, 정리 함수는 effect가 만들어질 때의 값을 잡고 있어서 늘 null이었어요. 다음 날 새벽 클라이언트를 useRef로 옮기고 클라이언트·구독 state 셋을 지웠습니다. 파일이 212줄에서 111줄이 됐는데, 그중 54줄은 리팩토링 커밋이 화면 JSX를 통째로 주석 하나로 바꿔 버린 탓이었습니다. · WebSocket connection to wss failed2025-01-14WebSocket connection to wss failed — 후보 열 개를 놓고 하루 종일 좁힌 기록로컬에서 잘 되던 STOMP 채팅이 배포하니까 연결이 안 됐습니다. 원인 후보가 서버 상태부터 인증서까지 열 개쯤 되는 상황에서, 하루 8번의 배포 끝에 mixed content — https 페이지에서 ws:// 차단 — 하나로 좁혔습니다. 후보를 어떤 순서로 제거했는지와, 다음에 같은 일을 겪으면 뭘 먼저 볼지를 정리했습니다. · 관계도 11개 화면을 렌더러 한 곳에서 바꿨다2026-08-10관계도 11개 화면을 렌더러 한 곳에서 바꿨다 — 클릭이 이동에서 선택으로노드를 클릭하면 곧바로 화면이 떠나서, 이 노드가 뭔지 보려다가 보던 그림을 잃었습니다. 클릭을 선택으로 바꾸고 다음 행동을 우측 패널로 옮겼어요. 공용 렌더러와 공용 본문을 고쳐 공개 6 · 관리자 5, 11개 화면에 한 번에 걸었고, 설정을 템플릿 set에 두면 include로 안 넘어간다는 함정도 같이 정리했습니다. · 바닐라 Jinja 화면과 React SPA가 한 저장소에 같이 있었다2021-12-23바닐라 Jinja 화면과 React SPA가 한 저장소에 같이 있었다 — 세 번 시도한 전환이 어디까지 갔나첫 직장에서 공항 보안 검색 판독 시스템의 화면을 React SPA로 바꾸는 일은 한 번에 끝나지 않았습니다. 당시 쓴 지원서를 보면 2021년 4월에 시작해 10월엔 옛 JS를 다시 고치고 2022년 봄에 납품용으로 또 리팩토링한 세 번의 시도였어요. 퇴사 전에 보관한 코드에는 옛 서버 템플릿 다섯 개가 그대로 남아 있었고, 옛 화면과 새 화면이 같은 메뉴를 어떻게 다르게 만들었는지를 코드로 되짚었습니다. · 메인 화면의 판독 박스는 서버 설정 셋이 그린다2022-04-01메인 화면의 판독 박스는 서버 설정 셋이 그린다 — 색·임계값·켜짐을 화면 열두 곳이 각자 물었다공항 보안 검색 판독 화면에서 AI가 찾은 물체 상자는 클래스마다 색·임계값·표시 여부 세 값으로 그려집니다. 당시 지원서를 보면 임계값을 직접 입력하는 것과 레이블 위치·상자 표시 옵션은 납품처 한 곳의 요구로 들어온 것이었어요. 그 세 값은 서버 설정 한 벌에서 오는데, 화면 쪽 열두 파일이 각자 같은 주소를 부르고 같은 조회 함수를 복사해 갖고 있었습니다. 보일지와 울릴지를 정하는 경계 비교가 파일마다 달랐던 것까지, 남은 코드로만 되짚었습니다.