웹 · 앱 · 프로그램 개발
Tech notes
무엇을 왜 썼는지
사내 통합 포털
부서마다 흩어져 있던 엑셀 대장과 구두 요청을 포털 하나로 통합. 게시판·관리대장·AI 기능까지 설계부터 배포까지 혼자 진행.
부서마다 흩어져 있던 엑셀 대장과 구두 요청을 포털 하나로 모은 프로젝트. 설계부터 배포까지 혼자 진행.
기능 목록이 계속 늘어날 게 뻔했기 때문에 기준을 하나만 잡음 — 기능이 늘어나도 무너지지 않을 것.
게시판 다섯 개, AI 네 종류, 관리대장 두 벌이 한 시스템에 살아야 하는데, 서로 얽히기 시작하면 나중에 감당이 안 될 거라 판단.
기능마다 독립된 서비스로 세우고, 공통으로 묶을 것(첨부파일·인증·스킨)만 골라 공유하는 방식을 끝까지 유지.
화면 컨텍스트 인식 + RAG 임베딩 검색
채팅 팝업에서 오는 메시지는 페이지 내용과 질문이 하나로 합쳐져서 도착. 서버에서 구분자로 분리해 페이지 내용은 system 메시지로, 질문은 user 메시지로 각각 주입.
페이지 내용은 세션별로 저장해두고 요청마다 최신 화면 기준으로 갱신. 이렇게 하면 사용자가 다른 화면으로 이동한 뒤 질문해도 항상 지금 보고 있는 화면 기준으로 답이 나옴.
AI 인터페이스는 Ollama를 사용하기로 결정. vLLM을 쓰기에는 들어가는 품에 비하여 해당 AI 사용량이 거의 바닥이기 때문.
임베딩은 Ollama의 /api/embeddings로 생성. 문서가 길면 1200자 단위로 잘라 각각 임베딩한 뒤 평균 벡터를 만들고, PostgreSQL(pgvector) 컬럼에 저장. 질의할 때는 코사인 거리 연산자(<=>)로 가장 가까운 문서 3건을 뽑아 답변 근거로 사용.
내부 문서 검색 도구(rag_search)는 일반 사용자 세션의 도구 목록에서는 아예 제외하고 관리자 세션에만 노출. RAG 사용 범위를 세션 단위로 통제.
AI 엑셀 처리 — Java ↔ FastAPI SSE 프록시
엑셀 처리는 pandas가 훨씬 유리해서 Java가 직접 하지 않기로 결정. Java는 업로드를 받아 taskId를 발급하고, FastAPI(pandas 서버)에 파일과 프롬프트를 그대로 넘기는 역할만 진행.
FastAPI가 보내는 token/code/done 이벤트를 Java SSE로 그대로 릴레이해서, 브라우저에서 처리 로그와 실행 코드를 실시간으로 확인 가능.
신경 쓴 건 중단 쪽. 중단 버튼, 타임아웃, 브라우저 종료를 전부 하나의 취소 신호로 묶고, 신호가 오면 Java 쪽 연결만 끊는 게 아니라 FastAPI의 /cancel까지 호출해서 실제 연산 자체를 중지.
안 그러면 사용자는 화면을 나갔는데 서버는 계속 pandas 연산을 붙잡고 있는 상황이 생김.
ComfyUI 워크플로우 동적 조작 (이미지 생성/편집)
이미지 생성과 편집을 워크플로우 JSON 하나(painter.json)로 처리. 참조 이미지가 0장이면 텍스트→이미지 노드만 남기고 편집 관련 노드를 통째로 제거, 1장이면 2차 보정(LanPaint)을 건너뛰고, 2장 이상이면 합성 모드로 노드를 재배선.
모드마다 워크플로우 파일을 따로 두는 방법도 있었지만... 관리할 파일만 늘어난다 판단. 코드에서 JSON을 그때그때 잘라내고 이어 붙이는 쪽으로 진행.
사용자 메시지에서 특정 스타일 키워드를 감지하면 맞는 LoRA를 워크플로우에 자동 주입하고, 생성이 끝나면 결과를 세션에 캐싱해서 다음 편집 요청에 바로 재사용.
소모품 관리대장 구현
가장 신경 쓴 부분은 "월별로 걸러봐도 그 시점 기준 잔여수량이 정확해야 한다"는 것.
그래서 남은 수량은 화면에서 어떤 필터를 골랐는지와 무관하게, 항상 전체 기록을 오래된 순으로 순회하며 누적 계산. 그다음에 선택한 기간(월 또는 일)에 해당하는 행만 걸러내고, 화면에는 최신순으로 뒤집어서 표시.
인쇄용 다운로드는 기간을 비우면 전체, from~to를 지정하면 해당 구간만 뽑도록 별도 메서드로 분리. 엑셀은 Apache POI로 헤더에 배경색과 흰 굵은 글씨를 입힌 XLSX를 즉석에서 생성해 다운로드.
AS 관리대장 — 엑셀 업로드 검증 파이프라인
기존에 쓰던 엑셀 장부를 그대로 올릴 수 있게 하는 게 목표. 업로드된 파일을 행 단위로 파싱하면서 "날짜" 같은 헤더 행이나 맨 위 제목 줄은 자동으로 건너뜀. (날짜로 파싱되지 않고 나머지 칸도 비어 있으면 데이터 행이 아니라고 판단)
행마다 날짜 형식과 금액이 숫자인지 검증해서, 문제가 있는 행은 사유와 함께 미리보기에 표시하고 나머지만 골라 일괄 등록 가능.
사진은 기록 날짜 기준으로 file/asimage/{년}/{월}/ 폴더에 나눠 저장해 폴더 하나에 파일이 무한정 쌓이지 않게 처리. 사진마다 정렬 순서를 따로 저장해서 첨부 사진이 늘거나 줄어도 보여주는 순서가 흐트러지지 않게 함.
PC 자산 QR 관리 구현
일련번호가 있는 PC만 모아 QR 라벨 인쇄용 시트를 만들고, 라벨의 QR을 스캔하면 로그인 없이도 모바일 뷰로 바로 연결.
문제는 이 경로가 인증 없이 열려 있다는 것. 등록·삭제 요청이 오면 먼저 SecurityContext로 관리자 여부를 확인하고, 관리자가 아니면 별도 비밀번호 검증을 통과해야만 처리되도록 진행.
코드를 살펴보다가 이 검증 비밀번호가 소스코드에 그대로 하드코딩돼 있던 걸 발견... application.properties 값으로 옮겨 외부화. 지점(건물)별 강의실 단위로 PC 목록을 엑셀로 내려받는 기능도 함께 제작.
NAS형 파일 스토리지 구현
가장 먼저 막아야 했던 건 경로 조작(path traversal). 모든 요청 경로를 정규화한 뒤 지정한 루트 경로 하위인지 검사하고, 벗어나면 SecurityException을 던져 차단.
보안자료 같은 특정 폴더는 기본적으로 목록에서 숨기고, 비밀번호를 입력하거나 슈퍼관리자로 로그인했을 때만 노출. 개별 파일·폴더 단위 잠금도 가능한데, 보호 목록은 파일 하나(.fm_protected)에 경로 문자열만 적어 저장하고, 이동이나 이름변경이 일어나면 보호 목록도 함께 갱신되도록 구현.
여러 파일을 한 번에 선택하면 ZipOutputStream으로 즉석에서 압축해 스트리밍으로 다운로드. 루트 디스크의 전체/사용/여유 용량도 계산해서 같이 표시.
채용정보 크롤러 아키텍처
사람인·잡코리아·고용24 세 사이트는 검색 조건을 각자 다른 파라미터 규칙으로 받음. 사이트별 URL 빌더를 따로 두고 지역·직무·경력·연봉 코드를 각 사이트 형식에 맞게 인코딩.
요청은 Jsoup으로 브라우저처럼 User-Agent와 Referer를 실어 전송. 매 요청마다 쿠키를 새로 받는 낭비를 막으려고 5분짜리 쿠키 캐시를 둠.
검색이 오래 걸릴 수 있어서, 사용자가 중간에 검색을 취소하면 해당 작업 id를 취소 목록에 등록해두고 크롤링 루프 중간중간 확인해서 조기 종료.
게시판 다중 카테고리 설계
영업·경영관리·시설·개인·공통 다섯 그룹을 테이블 하나에 구분 컬럼으로 몰아넣을지, 그룹마다 나눌지 고민... 결국 그룹마다 별도 엔티티·레포지토리로 분리.
그룹별로 조회 조건이 다르고(개인 게시판은 작성자로 필터링 등), 그룹이 늘어나도 기존 코드를 안 건드려도 되기 때문.
대신 이미지·파일 첨부 로직은 그룹과 무관하게 공통 서비스 하나로 묶고, group과 boardNum 조합으로 소유 게시글을 추적. 게시글을 지울 때 이 조합으로 첨부파일도 함께 정리.
SSE 실시간 로그 알림
PC 로그가 저장될 때마다 위치 정보(건물/실/좌석)를 태그로 붙여 저장하고, 연결된 모든 관리자 화면에 SSE로 그대로 전송. 여러 관리자가 동시에 화면을 열어놔도 폴링 없이 실시간으로 로그가 쌓이는 걸 볼 수 있음.
연결된 emitter들은 CopyOnWriteArrayList로 관리하고, 연결 종료·타임아웃·에러 콜백에서 자동으로 목록에서 제거. 이미 끊긴 연결에 계속 데이터를 쓰려다 에러가 쌓이는 상황 방지.
이미지 임시저장 → 영구 승격 파이프라인
이 포트폴리오 관리자 에디터에도 실제로 적용한 방식. 에디터에서 이미지를 넣으면 일단 temp 폴더에 원본 파일명 그대로 저장되고(이름이 겹치면 _2, _3 ...), 저장 버튼을 눌러야 비로소 temp에서 영구 폴더로 이동하면서 본문 속 URL도 함께 치환. 저장하지 않고 닫으면 temp에 그대로 남아 다음 정리 대상이 됨.
수정할 때는 이전 내용과 새 내용을 비교해서, 더 이상 어떤 글에서도 참조되지 않는 영구 이미지만 골라 디스크에서 삭제. 다른 글에서 같은 이미지를 재사용 중이면 지우지 않음.
회원 삭제 시 데이터 정리 전략 + 비밀번호 인코딩 버그
회원을 지울 때 연관 데이터를 다 삭제해버리면 그 사람이 올린 부서 공지·자료가 통째로 사라짐. 그래서 데이터 종류별로 다르게 처리 — 게시글·자료·메모는 작성자 참조만 해제(detachMember)해서 글 자체는 보존하고, 순전히 개인 데이터인 할 일(todo)만 함께 삭제. 삭제 자체도 관리자 본인 비밀번호를 다시 확인한 뒤에만 실행.
구현하다 실제 버그도 잡음. 가입 폼은 프로필 사진 때문에 multipart로, 로그인 폼은 urlencoded로 전송되는데, 비밀번호에 한글이 섞이면 같은 글자가 서로 다른 바이트로 인코딩돼 해시값이 어긋나면서 로그인 실패.
입력 단계에서 출력 가능한 ASCII만 허용하도록 정규식으로 차단. 인코딩 경로가 달라도 항상 같은 해시가 나오게 근본 원인을 제거.
테마 스킨 시스템 + AI 페르소나 분기
선택한 테마는 쿠키(selectedSkin)에 저장하고, @ControllerAdvice로 모든 요청의 모델에 skin 값을 자동 주입. 각 템플릿은 별도 처리 없이 skin에 맞는 CSS와 레이아웃을 로드.
같은 skin 값을 AI 채팅 요청에도 함께 실어 보내고, 서버는 시스템 프롬프트를 만들 때 skin으로 switch해 성격 문구를 골라 붙임(존댓말·삐딱한 반말·몽환·철학 등). 화면 테마 하나로 UI와 AI 말투가 동시에 바뀌는 구조.
DB 콘솔 — SQL 인젝션 방어 & 백업/복원
DB 콘솔은 스키마·테이블·사용자 이름을 SQL에 직접 끼워 넣어야 해서 인젝션 위험이 큼. 모든 식별자는 화이트리스트 정규식(^[A-Za-z0-9_$]{1,64}$ 등)을 통과한 것만, GRANT/REVOKE 권한은 허용 목록(Set)에 있는 것만 쓰도록 강제해서 백틱 브레이크아웃을 원천 차단. 비밀번호 리터럴은 따옴표·백슬래시를 이스케이프한 뒤 삽입.
백업·복원은 JDBC로 처리하기 어려워서 외부 CLI를 파이핑. 백업은 ProcessBuilder로 mysqldump를 실행해 stdout 바이트를 그대로 .sql로 다운로드, 복원은 업로드한 .sql 스트림을 mysql CLI의 stdin으로 전달. exit code가 0이 아니면 stderr를 예외로 던져 실패를 분명히 노출.
웹 검색 — SearXNG 연동 (2-turn)
날씨·환율처럼 실시간 정보가 필요하면 LLM이 web_search 도구를 호출하고, 서버는 로컬 SearXNG 인스턴스에 JSON 검색을 던져 상위 5건의 제목·요약을 추출. 검색은 SSE 스레드를 막지 않도록 별도 스레드에서 최대 10초까지만 대기.
검색 결과를 user 메시지로 주입한 뒤 2차 Ollama 호출로 답을 합성하는데, 이때는 tool_call 분기를 빼고 텍스트만 스트리밍.
왜냐? 안 그러면 LLM이 결과를 받고도 또 web_search를 호출해서 검색→검색 무한 루프에 빠질 수 있기 때문.
DB 설계 노트 — 성격이 다른 데이터는 다른 집에
이 포털의 데이터는 성격이 세 갈래. 관계형이 어울리는 업무 데이터(게시판·대장·회원)는 MariaDB에, 문서 임베딩 벡터는 PostgreSQL의 pgvector에, 첨부파일과 저장소 실물은 DB 밖 파일시스템에 저장.
파일을 BLOB으로 DB에 넣으면 백업 덤프가 파일 크기만큼 부풀고 복원도 그만큼 느려짐. DB에는 "파일이 어디 있는지"만 남기고 실물은 연/월 폴더로 관리한 것도 그래서.
벡터 컬럼에는 일부러 근사 인덱스(IVFFlat 등)를 안 걸었음. 사내 규정·매뉴얼은 많아야 수백 건 — 이 규모에서는 전수 코사인 비교가 충분히 빠르고 결과도 더 정확함. 근사 인덱스는 수십만 건부터 의미가 생기는 물건이라, "문서가 만 건을 넘으면 그때 건다"는 임계점만 적어두고 지금은 제외. 인덱스를 안 거는 것도 근거가 필요한 결정이라 생각.
백업 장치는 포털 안에 이미 있음 — DB 콘솔의 백업·복원 버튼. 화면 뒤에서 mysqldump를 돌려 .sql로 내려주기 때문에, DB 도구를 다뤄본 적 없는 담당자도 버튼 하나로 스키마 통째 백업과 복원 가능. 관리 콘솔을 만든 김에 백업까지 일반 사용자 손에 쥐여준 셈.
도구가 사람에게 맞춰야 한다고 생각함
기능을 만들 때마다 스스로에게 물었던 질문 — "쓰는 사람이 지금 하던 방식을 버려야 하는가?"
그래서 AS 대장은 쓰던 엑셀 장부를 그대로 올리면 되고, 문제 있는 행은 등록 전에 사유와 함께 표시. QR 조회는 로그인 없이 열리고, 소모품 대장은 조회하던 화면 그대로 엑셀이 됨. 시스템으로 옮겨오는 비용을 시스템이 대신 치르게 만든 장치들.
관리자에게도 같은 원칙 적용. DB 백업·복원, 권한 부여, 회원 승인 같은 일이 전부 화면 안에 있어서, 별도 도구를 배우지 않아도 포털 하나로 운영 마무리.
돌아보며
가장 크게 배운 건 분기보다 위임이 오래간다는 것.
키워드 분기로 시작한 AI 라우팅은 예외가 쌓이며 무너졌고, 판단을 LLM에 넘기고 서버는 실행만 맡게 바꾼 뒤에야 기능 추가가 두렵지 않게 됨.
또 하나는 문제가 늘 "잘 돌아가던 곳"에서 나온다는 것 — 경로 조작, 하드코딩된 비밀번호, 인코딩이 갈라져 생긴 로그인 실패까지 전부 그랬음. 기능을 늘릴수록 검증 코드를 먼저 쓰는 습관이 이 프로젝트에서 생김.
교육 포트폴리오 전시 플랫폼
기수마다 수동으로 하던 포트폴리오 정리·업로드를 전산화. 10년치가 쌓여도 담당자 혼자 운영 가능한 구조가 목표.
기수마다 포트폴리오를 정리하고 업로드하는 부분을 아무것도 없이 수동으로 하는 대신 IT 기관답게 전산화 진행.
졸업 작품은 지워지지 않고 매년 쌓이는데, 운영하는 사람은 개발자가 아님. 그래서 화려한 기능보다 "10년치가 쌓여도, 담당자 혼자서도" 굴러가는 구조를 기준으로 잡음.
NORMAL / DESIGN 이원화 설계
파일 첨부형(NORMAL)과 웹사이트형(DESIGN)을 별도 테이블로 나눌지 고민... 결국 하나의 엔티티에 portfolioType 컬럼을 둬서 구분.
목록·검색·조회 같은 공통 로직은 그대로 공유하고, 생성·수정·삭제의 진입점에서만 타입을 보고 파일 저장 방식을 NORMAL 로직과 DESIGN(HtmlService) 로직으로 분기.
DESIGN은 저장 경로에 레코드 번호(pid)가 필요해서, 먼저 saveAndFlush로 pid를 확보한 뒤 /design/{연도}/{pid} URL을 확정하는 2단계로 처리.
ZIP 안전 해제 — 인코딩 폴백 + Zip Slip 방어
수강생이 올린 웹사이트 zip은 한글 파일명이 많아서 인코딩부터 골치. EUC-KR로 먼저 압축을 풀고, 실패하면 폴더를 정리한 뒤 UTF-8로 재시도해서 두 인코딩 모두 커버.
외부에서 받은 압축 파일이라 Zip Slip(../../로 상위 폴더를 덮어쓰는 공격)도 방어 필요. 각 항목의 경로를 정규화한 뒤 대상 폴더 하위에서 시작하는지 검사하고, 벗어나면 그 항목은 건너뜀.
연도 / 과정 / 팀 계층 파일 구조
파일은 file/{연도}/{과정}/{팀}/ 3단 폴더로 나눠 저장. 여러 기수가 쌓여도 안 섞임.
과정·팀 이름을 폴더로 쓰기 전에 OS에서 금지된 문자(/ \ : * ? " < > | 등)를 밑줄로 치환해서 폴더가 깨지는 걸 방지하고, 같은 폴더 안에서 파일명이 겹치면 name_2, name_3 …으로 자동 증가.
관리자 인증 — 쿠키 + IP 화이트리스트
관리자 인증을 HttpSession으로 하면 서버를 재시작하거나 세션이 만료될 때마다 갑자기 로그인이 풀려 401 발생. 담당자 입장에선 배포할 때마다 다시 로그인해야 하는 불편이 생김.
그래서 인증 상태를 브라우저 쿠키로 관리해서 서버를 재배포해도 로그인 유지.
여기에 접근 IP가 지정된 사내 대역이면 쿠키를 자동 발급해 로그인 자체를 생략하고, 그 외에서 들어오면 잠금 화면에서 비밀번호를 받아 통과 시 쿠키 발급. 편의와 최소한의 접근 통제를 같이 확보.
DB 설계 노트 — DB보다 파일이 큰 시스템
이 시스템의 진짜 무게는 DB가 아니라 파일에 있음. DB에는 팀·과정·연도 같은 메타데이터 수백 행이 전부고, 수 GB짜리 PDF와 웹사이트 압축 해제본은 전부 연/과정/팀 폴더에 존재.
그래서 백업도 한 쌍 — DB 덤프와 파일 폴더 사본을 같이 떠야 완결. 순서는 DB를 먼저.
왜냐? DB가 가리키는 파일이 없는 상태보다, 파일은 있는데 DB가 모르는 상태가 복구하기 훨씬 쉽기 때문.
조회 화면은 사실상 하나의 흐름 — 연도를 고르고, 과정을 골라, 팀 카드를 펼치는 것. 그래서 조회 축도 (연도, 과정)에 집중. 기수마다 수십 행씩만 늘어나는 시스템이라 과한 인덱스는 두지 않음.
대신 지난 기수의 결과물이 지워지지 않고 영구히 쌓인다는 전제로, 연도가 항상 조회의 첫 축이 되도록 스키마를 잡음. 데이터가 10년치 쌓여도 이 화면 흐름은 그대로 유지.
담당자의 일을 줄이는 장치들
수강생 웹사이트는 압축 파일 그대로 올리면 압축이 풀려 실제 동작하는 사이트로 전시. 파일을 하나씩 올리거나 경로를 손볼 일이 없도록, 한글 파일명 인코딩 폴백과 금지 문자 치환까지 업로드 안에서 알아서 처리.
관리자 인증도 사내 IP에서는 자동 통과라, 매일 쓰는 자리에서는 로그인이라는 단계 자체가 없음.
돌아보며
이 프로젝트가 남긴 원칙 — 외부에서 온 파일은 신뢰하지 않는다.
Zip Slip 방어와 인코딩 폴백은 화면에 안 보이는 코드지만, 이런 시스템의 수명을 결정하는 건 결국 이런 부분이라는 걸 체감. 단순한 시스템일수록 품질은 기능 수가 아니라 운영의 매끄러움에서 갈린다는 것도.
훈련과정 출결 전광판
직원들의 편의성과 업무 효율성을 위하여...
정식 통로가 없던 실시간 출결에, 길을 내어 만든 전광판.
공식 API는 과정 정보 같은 정적 데이터만 열어줄 뿐 "지금 누가 입실했는지" 실시간 데이터를 주지 않음.
물론 고용24 전산 프로그램에서 출결 확인이 가능하지만 한번에 볼 수도 없을뿐더러 여러 메뉴를 들어가며 클릭해야 클릭 당시 한 상황의 데이터만을 가져옴. 이는 담당 직원들이 즉각적인 학생들 출결 확인하는 데 불편함과 시간적인 딜레이를 유발함.
아무리 과거에 만들어진 프로그램이라 하여도 이건 충분히 구현이 가능하고도 남을 텐데라는 생각을 가짐.
만들어만 두면 직원들이 업무를 함에 있어 편의성과 업무 효율 두 가지가 매우 향상될 거라 생각됨.
바이너리 프로토콜(SSV) 디코더·인코더 구현
이 프로젝트에서 가장 큰 벽이 될 뻔했으나... 현재 AI의 추론이 월등했음.
고용24 행정시스템은 JSON도 XML도 아닌, 데스크톱 UI 런타임이 쓰는 자체 바이너리 포맷으로만 통신해서 일반 파서로는 응답을 읽을 수조차 없었음.
이로 인해 툴킷을 통하여 요청을 보내는 주소값과 실제 오고 가는 데이터 확인.
본문은 2바이트 HEX 데이터로 되어 있었고(FF AD) 뒤에 zlib으로 압축이 되어 있었다. 압축을 풀면 변수 블록과 데이터셋 블록이 이어지는 레코드 스트림이 나오는데... 규칙이 반복적이긴 한데 해당 내용을 알 수가 없었음.
하지만 이 규칙 자체를 AI가 분석하게 지시해보니, UTF-8로 읽는 식이었음.
작업을 위해 데이터 디코더를 만들도록 하고, 조건을 바꿔 POST를 보내야 하니 인코더까지 제작.
디코딩된 템플릿을 미리 저장하여 조건 (과정 ID·회차·날짜) 만 바꿔 다시 인코딩한 후, POST를 날리는 방식으로 사용.
이로써 가장 문제가 될 2개의 부분 중 하나가 생각보다 쉽게 해결됨.
출결 판정 규칙
행정시스템이 주는 원본 상태값만으로는 화면을 만들 수 없었음. 오전 10시에 입실 기록이 없는 사람이 "아직 안 온 것"인지 "결석"인지 구분되지 않고, 퇴실 시각이 비어 있으면 조퇴인지 아직 수업 중인지 알 수 없기 때문.
그래서 현재 시각과 과정 설정(강의일 여부, 시작·종료 시각, 점심시간, 퇴실 유예)을 넣어 상태를 다시 계산하도록 설계.
수업 진행 중이면 '입실 대기', 종료 시각이 지나도 입실이 없으면 '결석'으로 갈리고, 종료 후 유예 시간까지 지나야 '미퇴실'로 판정 (항상 늦게 퇴실하는 학생이 있기 마련).
강의일이 아닌 과정은 아예 판정하지 않아 주말반·격일반이 결석으로 잘못 잡히지 않도록 조치.
출석·지각 인원이 1명 이상이고 그 인원만큼 퇴실이 찍히면 '전원 퇴실 완료'로 보고 카드를 아래로 내려버림.
폴링 → 스냅샷 → SSE 브로드캐스트
HRD 쪽에 10초마다 POST를 날리는 방식이라 여러 가지 걱정거리가 생김.
가장 기본적으로 HRD에서 지속적인 요청을 허용하는가?
원본 프로그램으로 계속 조회를 날리거나 해도 아무런 제약이 없는 부분을 확인.
서버 컴퓨터가 문제가 생겨 딜레이가 생길 경우 중복 요청이 생길 우려가 있었음.
fixedRate가 아니라 fixedDelay를 써서 응답이 느려도 요청이 겹쳐 쌓이지 않게 했고, 과정을 순차 조회하면서 사이에 간격을 둬 순간적으로 몰리지 않도록 설계.
오늘 열린 과정 목록은 10분마다만 재탐색하고 그 사이 폴링은 캐시된 목록을 쓰며, 탐색이 실패하면 설정값으로 폴백합니다.
이로 인해 실질적으로 서버가 10초마다 한 번만 데이터를 요청하며 모든 직원은 서버에 저장된 데이터를 활용.
세션 만료 감지는 응답의 앞 2바이트로 검증.
시그니처가 아니면 예외를 던져 나머지 과정 조회를 즉시 중단하고 상태 메시지로 노출하여 세션이 만료되어도 무의미하게 요청을 보내는 일을 막음.
완성된 결과는 스냅샷 하나로 만들어 volatile 필드에 통째로 교체하고, 연결된 모든 화면에 SSE로 전송.
전송에 실패한 연결은 즉시 목록에서 제거합니다. 아무도 보지 않는 야간에는 폴링 자체를 자동으로 멈춰 쓸데없는 리소스 낭비를 줄임.
개인정보 최소 노출 & 자동 파기
행정시스템 응답에는 생각보다 민감 데이터가 포함되어 있었음. 이걸 그대로 브라우저로 내려보내면 분명 문제가 생길 것이 뻔함.
DTO에는 딱 필요한 필드만 남겨두고, 동명이인을 구분할 최소 정보인 생년월일 6자리만 같이 추출.
만약 동명이인에 생년월일까지 같으면 어떻게 하는가?
처음에는 훈련생 ID나 다른 정보까지 뽑아올까 했지만... 확률상 매우 낮으므로 그 부분은 실제 그런 일이 생기거나 하면 나중에 추가 보완하도록 결정.
훈련생 전화번호는 결석자 연락에 필요해 담당자가 직접 입력하는데, 칸을 비워 저장하면 바로 삭제되고, 종료일이 지났거나 오랫동안 확인되지 않은 과정은 매일 새벽 스케줄러가 과정 설정과 전화번호를 함께 파기. 필요한 기간에만 보관되도록 만든 장치.
하지만, 여기서 재밌는 부분은... 행정시스템에 전화번호 데이터는 비어 있었음.
이건 이제 필요할 경우 직원이 직접 입력하는 방식으로 대체. (특정 인원에 대해서는 지속적인 연락이 필요할 수 있기 때문)
DB 설계 노트 — 일부러 거의 저장하지 않는 설계
이 전광판의 원본 데이터는 기관의 것이 아님. 출결 데이터는 행정시스템에 있고, 나는 10초마다 받아온 스냅샷을 화면에 뿌릴 뿐. 그래서 출결 데이터 자체는 DB에 쌓지는 않음.
확실한 데이터는 HRD이기 때문에 필요시 그때그때 HRD에서 데이터를 확인하도록 진행.
스냅샷은 메모리에서 통째로 교체되고, 서버를 재시작해도 다음 폴링 한 번이면 화면이 다시 채워짐.
DB에 남는 건 직접 만든 데이터뿐.
데이터 설계의 핵심은 서버에 데이터를 저장하지 않고 단순히 뷰어로만 이용되도록 하는 것.
종료됐거나 오래 확인되지 않은 과정의 설정과 여타 저장된 정보는 새벽 스케줄러가 자동 삭제.
애초에 전체적인 뷰어가 목적이므로 굳이 외부 데이터를 임의로 내부에 저장하는 행위는 절대 하지 않는 것을 베이스로 잡음.
(단, 로그인 정보의 경우 내 개인 로그인 정보이고, 이것으로 데이터를 뽑아오기 때문에 저장하여 활용)
담당자가 조작할 일이 없게
이 화면의 사용자는 "그냥 읽기 전용 사람"임. 아침에 저절로 켜지고 밤에 저절로 꺼지며, 신경 쓸 과정이 항상 위로 올라오고 끝난 과정은 알아서 내려감. 유일한 입력인 과정별 설정(강의 요일·점심시간·유예)도 한 번 넣으면 끝이고, 사실 이것도 부가적인 거지 이유가 없으면 넣을 필요도 없음.
전광판이 다루는 물건이 아니라 걸어두는 물건이 되도록 구상함.
돌아보며
기술적으로 가장 어려웠던 건 바이너리 해독이었지만, AI가 너무 쉽게 해결해버렸고
로그인 정보를 어떻게 서버에 담을 것이냐. 만약에 내가 이 기관에서 사라지면 남아 있는 직원들은 어떻게 본인 로그인 정보로 ID와 캐시값을 대체하느냐가 문제인데...
이건 별도로 C#을 이용한 추출기 제작, 용량 부담 없이 Go로 빌드하였고 간단한 리드미로 제작.
추출기에서 추출 시작을 한 상태로 HRD의 버튼을 누르면 자동으로 서버 API로 요청을 보내 list폼, detail폼, 로그인 정보를 서버에서 활용하도록 하였다. (물론 컴맹이 바로 성공할 정도는 아니긴 하다.)
HexaStay — 호텔 통합 관리 시스템
실 업무용이 아닌 학습을 위한 팀 프로젝트 진행
본사부터 지점, 입점 매장까지 한 플랫폼에 담은 호텔 관리 시스템. 학습을 위한 팀 프로젝트.
혼자 만드는 프로젝트와 팀 프로젝트는 설계의 무게가 다름. 스키마 하나, 권한 등급 하나가 곧 팀원 전체의 작업 전제가 되기 때문에, 코드보다 구조 합의에 시간을 더 씀.
아래는 그중 오래 남은 결정들.
다단계 조직 구조 & 권한 설계
여러 호텔을 한 시스템에서 운영하려면 조직 구조부터 정리 필요. 본사·지사·지점을 각각 테이블로 나눌까 했지만... 회사 엔티티 하나에 타입과 부모 회사 번호를 둬서 자기참조 트리로 표현.
조직 단계가 늘어나거나 지점이 추가돼도 스키마를 바꿀 필요가 없음.
권한은 조직 계층에 맞춰 설계. 본사(임원진·팀장·사원), 지사와 지점(총괄·관리자·직원·협력업체), 매장(매니저·부매니저·매장직원), 그리고 전체 접근이 가능한 슈퍼관리자.
각 등급을 enum으로 정의하면서 화면에 보여줄 한글 표시명을 같이 담아 코드값과 라벨이 따로 놀지 않게 처리.
주문·객실·매출 데이터는 모두 소속 회사 번호로 묶이기 때문에, 지점 직원은 자기 지점 것만 보고 본사는 전체를 봄.
다국어 콘텐츠 테이블 설계
메뉴 이름을 nameKo·nameEn·nameJa처럼 컬럼으로 늘리면 언어를 추가할 때마다 스키마 공사. 그래서 번역만 별도 테이블로 빼고 (메뉴, 언어코드) 조합으로 한 행씩 저장.
언어를 추가하는 일이 곧 데이터를 넣는 일이 되어 코드 변경이 필요 없음.
가격·사진처럼 언어와 무관한 값은 원본 테이블에만 두고, 조회할 때 요청 언어로 번역 행을 찾아 이름·설명·카테고리만 덮어씀. 번역이 없으면 덮어쓰지 않고 원본을 그대로 노출하는 폴백 구조라, 번역 누락이 화면 오류로 이어지지 않음.
장바구니 · 옵션 모델링과 주문 상태
"치즈버거 + 감자튀김 라지 + 콜라 변경"처럼 같은 메뉴라도 옵션 조합이 다르면 다른 항목이어야 함. 그래서 장바구니를 장바구니 → 담긴 항목 → 항목별 선택 옵션 3단으로 나눠 옵션 조합까지 정확히 표현.
룸서비스와 매장 주문은 주문 주체도, 처리 담당자도, 정산 대상도 달라서 장바구니부터 주문까지 계열을 아예 분리. 매장 직원 화면에서 자기 매장 주문만 조회하는 것도 자연스럽게 해결됨.
주문 상태는 접수 → 처리 → 완료로 흐르되 중간에 취소 가능. 주문 시점의 가격과 옵션은 주문 항목에 값으로 복사해서 보존.
왜냐? 나중에 메뉴 가격이 바뀌어도 과거 주문 내역과 정산 금액이 흔들리면 안 되기 때문.
설문 자동 발송 & 지점별 정산
매일 정해진 시각에 스케줄러가 전날 체크아웃한 객실을 하루 구간으로 조회해 설문 메일 발송. 객실에서 소속 회사를 거슬러 올라가 그 지점에 활성화된 설문 템플릿을 찾고, 등록된 설문이 없으면 기본 템플릿으로 발송해서 어느 지점도 누락되지 않게 처리.
메일 발송은 수신자 한 명 단위로 예외를 잡음. 주소 오류 같은 이유로 한 건이 실패해도 로그만 남기고 나머지 발송은 계속 진행 — 한 명 때문에 그날 전체 발송이 멈추는 상황 방지.
정산은 조직 트리를 그대로 집계 범위로 사용. 회사 번호로 객실을 찾고, 객실에 묶인 투숙 건에서 룸서비스와 매장 주문을 합산해 지점 매출 산출.
DB 설계 노트 — 트리 하나로 버티는 멀티테넌시
본사·지사·지점을 테이블 셋으로 나눴다면 "한 단계 더" 생기는 순간 스키마 공사가 됐을 것. 회사 테이블 하나에 부모 참조를 둔 자기참조 트리라서, 조직이 어떤 모양으로 자라도 행만 늘어남.
모든 주문·객실·매출이 회사 번호로 묶이므로 부모 참조와 회사 번호가 이 시스템에서 가장 중요한 조회 축. 지점 정산 집계가 결국 "회사 번호로 걸러 합산"이기 때문에 조회 경로도 전부 이 축을 타도록 맞춤.
다국어 테이블은 (메뉴, 언어코드) 조합을 유일하게 지정. 같은 메뉴에 같은 언어 번역이 두 줄 생기는 것을 DB 차원에서 막는 제약이면서, 조회가 항상 이 조합으로 들어오니 그대로 조회 축 역할까지 겸함.
주문 라인에 가격·옵션을 참조가 아니라 값으로 복사해 두는 스냅샷 설계는 "과거 주문은 불변"이라는 회계 원칙을 스키마로 강제한 것. 이때 얻은 확신이 이후 푸드트럭 시스템에서 같은 원칙을 더 밀어붙이는 출발점이 됨.
쓰는 사람을 배려한 결정들
투숙객은 회원가입 없이 QR만으로 주문과 요청 가능. 잠깐 머무는 사람에게 계정을 만들라는 건 시스템의 사정이지 사용자의 사정이 아니라고 판단.
번역이 밀린 메뉴는 빈칸 대신 원래 이름으로 나오고, 설문 메일은 한 명이 실패해도 나머지 발송이 멈추지 않음 — 전부 "일부가 어긋나도 전체는 굴러간다"는 같은 원칙.
돌아보며
이 프로젝트에서 굳어진 습관 둘.
하나는 변할 수 있는 것(조직 단계, 언어 수)을 스키마가 아니라 데이터로 표현하는 것. 다른 하나는 과거 기록(주문 시점의 가격)을 불변으로 박제하는 것.
두 원칙 모두 이후의 프로젝트들에서 반복해서 꺼내 쓰게 됨.
무엇보다 이 프로젝트를 보면 미흡한점도 많고, 문제점도 많지만
이 프로젝트를 진행한 인원들은 아무것도 모르는 상태에서 6개월 교육 후, 프로젝트를 만들었다는 점이다.
물론 중간에 막히는걸 AI한테 물어보긴 했어도, 바이브코딩을 쓰지않고 직접 손코딩으로 박치기를 했고,
그로인해 이 모든게 어떻게 돌아가는지 흐름과 패턴을 익히고 모르는 부분은 자가적으로 추가적으로 공부를 하면서라도 완성했다는것에 큰 의미가 있다고 생각한다.
또한 5인 팀 프로젝트로 예상치 못한 자기 범위 외 수정과 커밋푸쉬...로 인한 사건 사고를 겪으며 팀 프로젝트시 일어나는 사고가 뭔지.
왜 회의와 코드리뷰 및 승인 절차가 중요한지도 깨닳았을거고 이 부분은 나 또한 크게 공부가 되었다.
사내 AI 어시스턴트
GPU 한 대짜리 사내 서버에 채팅·이미지 생성·데이터 처리까지 전부 올린 로컬 AI 플랫폼. 회사 자료를 밖으로 못 보내는 환경이라 전부 사내에서 해결.
회사 자료를 밖으로 내보낼 수 없는 회사를 위해, GPU 한 대 위에 전부 올린 사내 AI 플랫폼.
언어 모델도, 이미지 생성도, 데이터 처리도 전부 사내 서버 한 대 — 그것도 GPU 하나에서 돌아야 하는 제약에서 시작.
그래서 아래 기록의 상당 부분은 "무엇을 만들었는가"보다 한정된 자원을 어떻게 나눴는가에 대한 이야기.
MCP 도구 레지스트리 — 판단을 LLM에 위임하기
처음에는 "검색"이라는 단어가 들어가면 웹 검색, "그려"가 들어가면 이미지처럼 키워드로 분기. 그런데 조건문이 계속 늘고 "검색해보니 어때?" 같은 문장에서 오작동하는 예외가 끝없이 발생.
결국 분기를 걷어내고 각 기능을 도구로 정의해 LLM에게 목록째 넘기는 구조로 변경. 어떤 도구를 쓸지는 LLM이 판단하고, 서버는 돌아온 tool_calls의 이름으로 해당 도구를 찾아 실행만 진행.
도구 구현체는 Spring DI로 자동 수집되기 때문에, 새 기능을 추가할 때 인터페이스를 구현한 클래스 하나만 만들면 등록·정의 전달·라우팅이 전부 자동으로 연결.
세션 성격에 따라 특정 도구를 목록에서 빼고 전달할 수도 있어서, 사내 문서 검색을 관리자 세션에만 노출하는 통제도 이 지점에서 처리.
단일 GPU VRAM 오케스트레이션
GPU는 한 대인데 채팅 모델, 엑셀용 모델, 이미지 생성이 같은 VRAM을 두고 경쟁. 전부 올리면 메모리가 부족하고, 작업마다 내렸다 올리면 모델 로딩에 수십 초씩 낭비.
그래서 "지금 어떤 타입이 올라와 있는지"를 한 곳에서 추적하고, 다른 타입 작업이 시작될 때만 언로드하도록 진행. 채팅은 답변이 끝나도 내리지 않고 올려둔 채로 기록만 남김 — 다음 질문에서 재로드 비용이 들지 않도록.
취소할 때도 무조건 내리지 않음. 같은 타입의 다른 세션이 아직 살아 있으면 언로드를 건너뛰어서, 한 사람이 중단했다고 다른 사용자의 대화가 끊기는 일 방지.
Heartbeat 끊김 감지 & 4단계 취소 전파
무거운 작업일수록 "사용자는 나갔는데 서버는 계속 도는" 상황이 치명적. 취소가 시작되는 세 경로 — 중지 버튼, 브라우저 종료, SSE 타임아웃·에러 — 를 전부 하나의 취소 메서드로 수렴시킴.
브라우저를 그냥 닫는 경우는 서버가 알 방법이 없는데... 5초마다 ping을 보내다 전송이 실패하는 순간을 끊김으로 판단해 자동 취소. 여러 경로에서 동시에 취소가 불릴 수 있으므로 원자적 플래그로 실제 실행은 한 번만 되게 차단.
취소가 걸리면 네 곳으로 전파 — 진행 중인 HTTP 연결을 끊고, 조건에 맞으면 언어 모델을 언로드하고, 이미지 서버에는 중단과 대기열 비우기를 요청하고, 데이터 처리 서버에는 작업 ID로 취소를 호출.
이때 세션 맵에서 자신을 먼저 제거한 뒤 전파해야 "남은 동일 타입 세션이 있는지" 판단이 정확해짐. 실제로 순서를 고쳐야 했던 부분.
LoRA 자동 감지 & 프롬프트 조합
사용자에게 "어떤 모델 파일을 쓸지" 고르게 하고 싶지 않았음. 한국어 메시지에서 화풍 키워드를 감지해 자동으로 적용하도록 진행.
화풍마다 파일명·트리거 단어·강도·발동 키워드를 enum 상수 하나에 모아두고, 선언 순서를 그대로 매칭 우선순위로 사용. 매칭되면 그 화풍의 트리거 단어를 LLM이 영어로 번역한 실제 요청 프롬프트 앞에 결합해 화풍을 고정하고, 워크플로우 JSON에 해당 LoRA 노드를 주입. 아무것도 매칭되지 않으면 LoRA 없이 기본 화풍으로 생성.
덕분에 새 화풍을 추가하는 일이 enum에 상수 한 줄 추가로 끝. 감지 로직이나 워크플로우 코드는 건드릴 필요 없음.
세션 상태 관리 & 동시성
"방금 만든 이미지에 이어서 편집해줘"가 되려면 생성 결과를 세션에 남겨둬야 함. 채팅과 이미지 서비스가 이 상태를 공유하기 때문에 세션 ID를 키로 격리하고, 갱신 방식에 특히 신경 씀.
파일명과 이미지 캐시를 개별적으로 하나씩 갱신하면 "파일명은 새것인데 캐시는 옛것"인 중간 상태가 다른 스레드에 노출될 수 있음. 그래서 생성 완료 후에는 관련 상태를 한 메서드에서 한 번에 바꾸도록 통일.
대화 메모리는 최근 것만 남기고 오래된 것부터 버려서 컨텍스트 한도 초과 방지. 캐시가 비어 있고 파일명만 남은 경우에는 디스크에서 다시 읽어 채움 — 상태가 어긋나도 다음 요청에서 스스로 회복되도록 처리.
DB 설계 노트 — 벡터를 위해 DB를 늘리지 않음
문서 검색용 임베딩을 어디 둘지 고민... 전용 벡터 DB를 세우는 선택지도 있었지만, 이 시스템은 GPU 한 대짜리 사내 서버에서 LLM·이미지 서버·데이터 처리 서버가 이미 자원을 나눠 쓰는 환경.
상주 프로세스를 하나 더 늘리는 대신 이미 있는 PostgreSQL에 pgvector 확장만 추가. 운영할 것도, 백업할 것도 pg_dump 하나로 끝나는 쪽이 이 사양에서는 맞는 답이라 판단.
긴 문서는 1200자 청크로 잘라 각각 임베딩한 뒤 평균 벡터 하나로 저장. 청크를 전부 행으로 쌓으면 검색은 세밀해지지만 행 수와 비교 비용이 청크 수만큼 증가. 사내 문서 규모에서는 문서당 한 행으로도 충분히 맞는 문서를 찾았고, 덕분에 근사 인덱스 없이 전수 코사인 비교로도 응답이 즉각적.
반면 대화 세션 상태는 DB에 넣지 않음. 브라우저를 닫으면 수명이 끝나는 데이터라, 영속화는 비용만 있고 얻는 게 없다고 판단.
버튼을 없애는 게 편의였음
이 시스템에서 사용자가 배워야 할 조작법은 없음. 모드 선택도 기능 버튼도 없이 그냥 말하면 되고, 화풍도 "지브리풍으로"라고 한국어로 말하면 됨.
이 단순함은 UI를 줄여서가 아니라 판단을 LLM에 위임한 구조에서 나온 것이라, 기능이 늘어도 화면은 복잡해지지 않음.
오래 걸리는 작업은 진행 로그를 실시간으로 보여주고 언제든 중단 가능하게 처리. AI에 대한 신뢰는 정확도만큼이나 "지금 뭘 하고 있는지 보이는 것"에서 나온다고 생각.
돌아보며
이 프로젝트의 진짜 주제는 취소였음. 만드는 것보다 멈추는 게 어려웠고, 중단 버튼·브라우저 종료·타임아웃이 전부 한 지점으로 수렴한 뒤에야 "운영해도 되는 물건"이 됨.
자원이 한정된 환경일수록 기능의 완성도는 시작이 아니라 정리에서 판가름 난다는 걸 배움.
PC 통합 제어·모니터링 시스템
몇백 대 강의실 PC를 자리에서 관리하려고 만든 .NET 데스크탑 프로그램. 화면 관제부터 원격 제어, 무인 자동 업데이트까지.
강의실 수십 대의 PC를 관리자 한 명이 자리에서 다루게 만든 .NET 데스크탑 시스템.
당장 모든 PC에 대한 관리가 필요하고, 필요할 경우 직접 PC 세팅 및 상황을 파악해야 하는데 몇백 대의 좌석을 그때그때 가서 하나하나 확인하는 건 매우 비효율적이라 생각. 이를 보완할 업무용 프로그램 기획.
웹이 아니라 데스크탑, 그것도 화면 캡처·원격 제어·P2P 전송이 얽힌 시스템이라 운영체제와 네트워크의 밑바닥을 계속 만져야 했음.
그리고 관찰과 제어가 있는 도구인 만큼, "무엇이 되는가"만큼 "무엇이 보이고, 무엇이 동의를 거치는가"를 설계의 축으로 잡음.
신원 = MAC(좌석) — 계정 테이블을 지운 인증 모델
처음에는 User 테이블과 로그인 화면을 두고 시작. 그런데 강의실 PC는 앉는 사람이 계속 바뀜. 하지만 PC 자체는 고장 나서 교체되기 전까지는 고정.
계정을 발급·회수하는 운영 비용이 시스템이 주는 가치보다 커졌고, 결정적으로 유동 IP 환경이라 IP로는 같은 PC를 계속 같은 PC로 볼 수 없었음.
그래서 계정·로그인·하드코딩 비밀번호 대신, 랜카드 MAC을 PK로 저장. 인증이라는 단계가 따로 없고 핸드셰이크가 그 역할을 수행. 물론 그렇다고 로그인 제도가 사라진 건 아님 — 로그인을 통해 고유 계정으로 어느 좌석에서든 등록된 본인 이름으로도 사용 가능.
클라이언트가 MAC과 현재 IP를 보내면 서버는 좌석을 upsert하고, 그 좌석의 Role과 세션 토큰을 응답. 등록·인증·권한 조회가 한 번의 왕복으로 끝.
재연결과 IP 변경 때마다 같은 핸드셰이크를 다시 하기 때문에 유동 IP가 자동으로 갱신되고, 관리자가 바꾼 권한도 이 경로로 즉시 반영. 콘솔은 재시작 없이 메뉴와 버튼 노출을 다시 계산.
물론 보안적인 측면에서 MAC 주소는 위조가 가능함. 하지만 학원 내부에서 민감 데이터 접근이 아닌 상호 간의 소통과 지원을 위한 프로그램인 만큼, 그만한 노력을 들여 타인의 MAC 주소를 알아내 본인 PC에 적용시켜 충돌을 일으킬 리스크 대비 얻는 게 없어 이 부분은 감수하기로 진행.
클라이언트 임의 종료 같은 실질적인 방어는 서버가 검증하는 별도의 관리 비밀번호로 처리.
편의를 얻고 무엇을 포기하는지 선택함.
DXGI Desktop Duplication + 128px 델타 타일
최초에는 이미지 캡처 방식으로 화면 공유 이용. 하지만 리프레시 효과가 매우 거슬리며, 퀄리티나 성능 면에서도 메리트가 없었음.
그래서 1:1 화면 공유와 원격 제어는 H.264 영상 스트리밍으로 전환해 품질을 높임. 하지만 여러 화면을 동시에 보는 그리드 화면에서는 H.264를 사용할 수 없어 DXGI를 이용.
그리드는 썸네일 정도로만 사용하기 때문에 퀄리티가 낮아도 큰 문제가 되지 않고, 최소한의 리소스 사용을 위해 픽셀 비교를 통해 변동이 있는 부분만을 업데이트하도록 진행.
타일 변경 판정은 unsafe 포인터로 8바이트씩 읽어 비교. 매 프레임 전 타일에 대해 도는 연산이라 여기가 가장 자주 실행되는 코드.
DXGI 초기화가 실패하는 환경에서는 GDI 캡처러로 폴백하도록 처리.
UDP 홀펀칭 P2P
모든 화면 프레임이 서버를 거치면 뷰어가 늘어날수록 서버 대역폭이 배로 증가. 서버 부담을 줄이고 화면 공유와 화면 캡처는 사용자가 직접 부담하는 것이 맞다 판단.
그래서 1:1 화면 보기는 송출자와 뷰어가 UDP로 직접 주고받게 하고, 서버는 세션 개설과 후보 엔드포인트 중계라는 신호 역할만 담당.
같은 내부망과 아예 다른 망 모두 대응하기 위해 STUN 반사기를 제작. 양쪽이 공인 IP를 알아낸 뒤 후보 목록을 교환하고, 동시에 NAT 매핑을 엶.
패킷 포맷도 직접 정의 — PUNCH/PUNCHACK/DATA/BINDREQ/BINDRESP 5종. DATA는 세션 ID·시퀀스·단편 인덱스를 헤더로 두고 1100바이트 단위로 쪼개 IP 단편화 회피.
가장 오래 잡힌 문제는 수신 루프가 계속 죽는 것. 홀펀칭은 아직 열리지 않은 포트로 패킷을 쏘는 일이라 상대가 ICMP port-unreachable을 돌려주는데, Windows는 이걸 다음 Receive에서 SocketException(10054)으로 던짐... SIO_UDP_CONNRESET 소켓 옵션을 꺼서 무시하게 만들고 나서야 안정적으로 뚫림.
세션 ID는 인가 토큰 역할도 겸함. 송출자는 서버가 승인한 세션 ID의 패킷만 받아들이고 나머지는 조용히 버림 — UDP 포트가 열려 있어도 아무나 프레임을 받아갈 수 없음.
중요한 건 P2P를 전제가 아니라 최적화로 뒀다는 점. 뷰어는 경로가 실제로 확인됐을 때만 서버에 '직접 수신 중'을 켜기 때문에, 대칭형 NAT처럼 홀펀칭이 실패하는 환경에서는 서버 중계가 그대로 유지되고 사용자는 아무 차이를 못 느낌. 환경변수 하나로 전체를 중계 전용으로 되돌릴 수도 있음.
워처 레지스트리 — 보고 있는 사람에게만, 볼 때만
캡처는 비싼 작업이라 아무도 안 볼 때 도는 건 순수한 낭비. 서버는 좌석 MAC별로 "지금 이 화면을 보고 있는 관리자 연결" 목록을 들고 있다가, 첫 워처가 등록될 때 캡처를 켜고 마지막 워처가 빠질 때만 끔.
그리드와 1:1 뷰어가 같은 좌석을 볼 때 한쪽 창을 닫아도 다른 쪽이 멈추지 않는 것도 이 카운팅 덕분.
프레임 중계도 그룹 전체가 아니라 이 목록에 있는 연결에만 전송. 고프레임 워처(1:1·원격제어·확대 보기)가 하나라도 있으면 에이전트가 캡처 주기를 올리고, 그리드만 보고 있으면 낮춰서 학생 PC의 부하를 조절.
연결 해제 처리에서 실제로 버그도 잡음. Wi-Fi가 잠깐 끊기면 새 연결이 먼저 붙고 옛 연결의 Disconnected 콜백이 뒤늦게 도착하는데, MAC을 키로 무조건 매핑을 지우면 방금 살아난 PC가 오프라인으로 사라짐. 지금은 해당 MAC의 현재 활성 연결이 '나'일 때만 제거하고, 아니면 아무것도 하지 않음.
오프라인 좌석으로 간 1:1 메시지는 좌석당 200건까지 메모리에 보관했다가 재접속 핸드셰이크 때 전달하고 비움. 전 메시지를 DB에 넣으면 스키마와 보존 정책이 따라붙는데, 여기서 필요한 건 "잠깐 꺼진 PC에 보낸 메시지가 사라지지 않는 것"까지. 서버 재시작 시 유실은 감수한, 용도에 맞춘 절충.
가시성 규칙을 서버와 클라이언트 양쪽에 둔 이유
소속과 역할에 따라 콘솔에 보이는 좌석이 다름. 학생은 자기 소속만, 강사는 담당 강의실 좌석과 관리자급, 관리자는 자기 소속과 타 소속 관리자급까지 보이는 식.
처음에는 목록 조회 응답에만 필터를 걸었는데, 실시간 푸시에서 구멍 발견. 처음 로드에는 안 보이던 타 소속 학생이, 그쪽에서 누가 접속해 OnDeviceUpdated가 날아오는 순간 콘솔 목록에 새어 들어옴.
그래서 같은 판정을 클라이언트의 푸시 수신 지점에도 추가. 두 곳의 역할은 다름 — 서버 필터는 클라이언트 코드를 믿지 않는다는 전제의 실제 신뢰 경계(권한 없는 좌석에는 데이터가 아예 도달하지 않음), 클라이언트 필터는 실시간 갱신에서도 화면이 일관되게 보이도록 하는 UX 장치. 규칙이 두 벌로 갈라지지 않게 판정 기준은 한 곳에 모아서 유지.
관측·제어 보호도 같은 층위에서 처리. 관리자급 좌석은 동의 요청이든 은밀 열람이든 그리드든 경로와 무관하게 대상에서 제외되고, 이 판정은 서버 허브에서 진행.
UAC 없는 무인 자동 업데이트 — 자기 자신을 교체하기
배포 대상이 강의실 PC 수십 대라 업데이트마다 돌아다닐 수 없음. 벽은 두 개 — Program Files에 쓰려면 관리자 권한이 필요해서 PC마다 UAC 창이 뜨고, 실행 중인 exe는 덮어쓸 수 없음.
첫 번째는 설치 시점에 SYSTEM 권한 온디맨드 예약 작업을 미리 등록해두는 것으로 해결. 클라이언트는 새 설치본을 표준 사용자도 쓸 수 있는 스테이징 폴더에 놓고 그 작업을 호출만 함. 실제 설치는 SYSTEM 권한으로 실행되므로 UAC 프롬프트가 없음. 작업이 등록되지 않은 구버전이거나 호출이 실패하면 UAC 무인 설치로 폴백.
두 번째는 Windows가 실행 중 파일의 삭제·덮어쓰기는 막아도 이름 변경은 허용한다는 점을 이용. 기존 exe를 .old-{시각}으로 옮기고 같은 이름으로 새 파일을 풂. 돌고 있던 프로세스는 옮겨진 파일을 계속 쓰고, 다음 시작부터 새 파일이 적용됨. 남은 백업은 다음 설치 때 정리.
운영하면서 붙인 것들도 있음. 설치가 시작되면 구 클라이언트를 즉시 정상 종료시키고(안 그러면 트레이 아이콘이 두 개 남음), 새 버전은 시작할 때 자기 자신을 뺀 구 인스턴스를 직접 정리 — 설치기가 놓쳐도 새 코드가 항상 보장하도록 이중으로 처리.
서버에서 '즉시 배포'를 누르면 수십 대가 동시에 파일을 받으러 오기 때문에, 각 클라이언트가 0~15초 무작위 지터를 두고 다운로드를 분산.
받은 설치본은 SHA-256으로 검증한 뒤에만 실행하고, 무인 설치 로그는 최근 5회분만 남겨 진단에 사용.
DB 설계 노트 — 자연키를 PK로 삼는다는 것
교과서는 의미 없는 대리키를 PK로 쓰라고 가르치고, 평소엔 나도 그렇게 함. 그런데 이 도메인에서는 MAC이 곧 신원이고, 신원이 곧 좌석.
대리키를 두면 모든 처리에 "MAC → id 변환"이 한 번씩 끼는데, 이 시스템의 모든 트래픽(핸드셰이크·프레임 중계·명령)이 MAC으로 도착. 그래서 MAC을 그대로 좌석의 식별자로 사용.
좌석 테이블은 커봐야 수백 행 — 여기서 중요한 건 인덱스 튜닝이 아니라, 정전 복구 직후 수십 대가 동시에 핸드셰이크할 때 등록·갱신이 안전하게 겹치는 것.
넣지 않기로 한 데이터가 설계의 절반. 메신저 대화는 서버 DB가 아니라 각 PC에 30일 보관 — 서버에 모으는 순간 전 직원 대화의 보관·열람 책임이 생기는데, 이 시스템의 목적에 그런 중앙 보관은 필요 없다고 판단. 오프라인 좌석용 미전달 메시지도 DB 대신 메모리 큐 200건으로 마무리. 서버 DB에는 좌석·권한·공지·명령 이력처럼 "관리에 필요한 최소"만 남겨서, 백업할 것이 적고 일부를 잃어도 재구성되는 시스템이 되도록 함.
관리를 줄이는 게 목적인 시스템이니까
설치하면 그 자리가 곧 신원이라 계정 발급이 없고, 새 버전은 서버에 올려두면 각 PC가 알아서 조용히 설치. 점검 결과는 도착하는 대로 채워져 그대로 엑셀이 됨.
"관리자를 편하게 하는 시스템"이 정작 새로운 관리 부담을 만들면 실패라고 생각해서, 시스템 자신의 운영 비용을 줄이는 데에 기능만큼의 시간을 씀.
돌아보며
이 프로젝트의 습관은 폴백. DXGI가 실패하면 GDI로, 홀펀칭이 막히면 서버 중계로, SYSTEM 작업 호출이 실패하면 무인 설치로 — 최적 경로는 언제든 죽을 수 있다는 전제로 다음 경로를 미리 깔아둠.
그리고 관찰 도구를 만들면서, 능력의 경계보다 동의의 경계를 먼저 긋는 것이 이런 시스템의 품위라는 생각을 하게 됨.
푸드트럭 운영 통합 시스템
푸드트럭 ERP 시스템. 직원 관제 시스템. 개발 후, 이용자가 어떻게 하면 개발자의 손을 타지 않고 장기간 스스로 활용하고 작업하고 있을까. 실제 고객사의 요구사항 이외에 안정성과 추후 자가 유지보수 및 편의를 고려하여 설계.
실제 회사가 매일 영업에 쓰는 시스템 — 만드는 것에서 끝나지 않고, 개발자의 지속적인 관여 없이 돌아가게 유지할 수 있는가.
사용자는 현장의 직원과 사무실의 관리자, 그리고 개발자가 아닌 대표입니다. 그래서 설계 기준을 셋으로 잡았습니다. 직관적인 구조로 모든 직원이 쉽게 사용. 장기간 이용해도 아무 탈 없는 프로그램. 서버의 가동 백업까지 개발자 없이 운영. 아래는 그 기준을 지키며 부딪힌 것들의 기록입니다.
정산 저장 구조 — 헤더+라인, 그리고 이름 스냅샷
정산 한 건에 들어가는 품목 수가 직원마다 차이가 있음. 트럭과 지역에 따라 취급 품목이 달라서, 컬럼을 늘려가는 방식은 처음부터 불가하다 판단.
결국 정산 로그 부분은 FK가 아닌 값을 복사하는 방식으로 결정.
체크리스트 틀을 FK로 걸었다면, 사장이 품목의 가격을 4,000 → 4,500으로 수정하는 순간 지난달 정산 금액까지 소급해 바뀌게 될 것이므로 회계 성격의 데이터는 "그때 그 값"이 남아야 해서, 정규화를 일부러 포기.
저장은 라인을 전부 지우고 다시 값을 넣는 식으로 진행. 어떤 줄이 바뀌었는지 추적해 부분 갱신하는 것보다 단순하고, 한 정산의 라인이 수십 개밖에 되지 않고, 모든 직원은 정산을 업무 마감 시에만 진행하므로 문제되지 않겠다 판단.
재고 원장 — 재저장해도 두 번 빠지지 않게
재고를 정산에 연결하면서 바로 고민했던 부분은. 직원은 중간중간 임시저장을 할 수 있음. 임시 저장할 때마다 기존 물류 데이터의 수량에 그대로 반영되는 문제가 발생.
그래서 정산의 품목별 수량을 그날의 스냅샷으로 따로 보관하는 부분으로 구상하여, 저장할 때마다 이전 스냅샷과의 차이만 원장에 반영하도록 계획. 150에서 180으로 고쳤으면 −30만 움직입니다. 원장 연산 자체가 부호 있는 델타를 받도록 만들어져 있어서, 수정도 취소도 같은 경로로 처리.
재고는 잔량 테이블과 로그 테이블을 함께 둬서, 남은 잔량에 대한 흐름, 사유 파악에 용이하도록 설계.
품목에 그룹을 붙일 때 컨트롤러에서 필드 수정과 그룹 지정을 각각 다른 서비스 메서드로 호출했는데, 서로 다른 트랜잭션이라 그룹 검증이 실패하면 이름·단가만 커밋되고 400이 떨어지는 문제 발생. 사용자 눈에는 "실패했다는데 값은 바뀐" 상태. 한 메서드 안에서 그룹을 먼저 검증하고 필드를 바꾸도록 합치고, 회귀 테스트로 고정.
그룹으로 묶을 때는 이름이 아니라 id를 기준으로 삼아서, 이름으로 묶으면 동명의 다른 그룹이 한 칸에 합쳐지고, 사용자가 '미분류'라는 이름의 그룹을 만들면 진짜 미지정 품목과 섞이도록 설계.
권한이 두 층에 있으면 위층이 이긴다
지점장에게 "물류센터 관리" 권한을 켜줬는데 계속 로그인 화면으로 튕기는 문제 발생. DB의 권한 플래그도 정상이고 컨트롤러의 조건도 정상이었으므로 이는 시큐리티 쪽 문제일 거다 예상.
결국 예상처럼 원인은 그 화면 주소가 /ceo/stock이었다는 것이었고, 컨픽에 /ceo/**는 대표 이상이라는 URL 규칙이 있었고, 이건 필터 단계에서 먼저 걸리는 문제 발생.
화면을 /ceo 밖으로 옮겨(/stock, /warehouses) 게이트를 메서드 권한 한 곳에만 두도록 진행. URL에 역할 관련된 부분이 들어가지 않도록 수정.
같은 화면이라도 지점장은 자기 지점 행만 조회되며. 직원 위치·출결 승인·발주·회원·장사지역 조회가 전부 뷰어를 받아 지점으로 거르고, "전체 지점 조회" 권한을 켜면 그때 전체 데이터 수신. 역할을 올리지 않고 권한 플래그로 범위만 넓히는 구조로 진행.
차트를 손으로 그린 이유
매출 조회의 추이 차트는 차트 라이브러리 없이 <svg>로 AI에게 생성하도록 진행. 그리드·크로스헤어·툴팁·직접 라벨까지 좌표를 계산해 배치. 이 화면이 현장 휴대폰에서 가장 자주 열리는데, 막대와 선 하나씩을 위해 외부 스크립트를 받아오면 그만큼 첫 화면이 딜레이가 생기겠으나, 이젠 차트 정도는 AI가 내가 지시한 것 이상으로 만들어내기 때문에 아무 문제없이 생성할 수 있음.
반면 투자자·관리자 대시보드는 PC에서 주로 열려서 차트 라이브러리를 그대로 사용.
PC도 동일하게 가려 하였으나 기기 자체의 리소스가 비교가 안 될뿐더러, 역시 근본 있는 라이브러리들이 왜 아직도 쓰이는가는 직접 사용해보면 알 수 있음.
집계 함수는 순수 함수로 분리해 월 경계를 넘는 주, 주 시작 요일 같은 경계 조건을 단위 검증. 이 과정에서 버튼 선택 표시를 전역 셀렉터로 지우고 있던 것도 발견 — 같은 클래스로 버튼 줄을 하나 추가했더니 서로의 선택 표시가 풀리는 문제 발생. 부모 범위로 좁혀 줄 단위로 독립.
위치는 두 갈래로 나눠 쓰고, 표시에서만 뭉친다
같은 GPS 신호를 두 곳에 나눠 저장. "지금 어디 있는가"(지도 칩용 현재 위치)와 "어디를 지났는가"(동선 기록)는 수명도 조회 방식도 다르게 구성. 처음엔 한 곳에서 처리했는데 이중 기록과 경합이 생겨 갈라냈고, 오래 열려 있던 동선 구간은 스케줄러가 정리.
추적은 로그인이 아니라 출근에서 시작해 정산 마감에서 끝남. 15분 무응답이면 자동으로 추적 종료.
여기서 중요한 건 지도의 경우 API이고 직원이 많을 경우 내 마음대로 마구마구 호출 시 그건 곧 사측의 비용 문제가 되기 때문에
최적화에 대한 고민이 컸음.
위치가 이동했는지 판정하면서 주소 변환 API를 두 번 부르던 것을 한 번으로 줄였고, 앱에서 첫 위치가 최대 10분 늦게 잡히던 공백은 마지막으로 알려진 위치를 즉시 한 건 올려서 표시. 창고 이름 조회가 위치마다 반복되던 N+1도 한 번 조회해 넘기도록 보수.
WebView 껍데기 앱에서 만난 것들
앱에는 화면을 넣지 않음. APK 안에는 접속 주소·아이콘·네이티브 기능(위치·다운로드·외부 앱 연동)만 있고 화면은 전부 서버가 그림. 덕분에 기능 수정은 서버만 다시 올리면 되고, 앱 재설치는 네이티브를 건드릴 때만 필요하므로 핫픽스나 기본적 업데이트에는 단순 jar만 금방 바꿔서 재가동 시 앱에도 즉각 적용.
로그인만 하면 앱이 죽던 문제. 직원·지점장 계정에서만 터지는 문제 발생. 로그인 직후 위치 추적을 시작하는데, 최신 안드로이드는 위치 권한 없이 위치용 포그라운드 서비스를 띄우면 프로세스를 즉시 종료. 권한을 확인한 뒤에만 시작하도록 수정.
길찾기 버튼이 안 눌리던 문제. 지도 위 정보창의 버튼이 PC·모바일 모두 무반응이었던 문제 발생. 메서드도 문제가 없고, AI로 디버깅을 시켜도 명확하게 문제를 못 잡는 상황.
알고 보니 클릭 시 길찾기 버튼이 아니라 지도 자체에 클릭되는 문제였고. 이 사소한 부분의 원인을 잡는 데 엄청난 시간 낭비.
내비 앱이 안 열리던 문제. 원인이 둘이었습니다. 지도 앱 주소 규약을 쓰고 있어 지도와 내비가 같이 열리던 것(내비는 공식 URL 규약이 없어 공식 SDK 호출이 유일한 정식 경로), 그리고 안드로이드 11부터는 실행하려는 앱을 매니페스트에 미리 선언해야 보인다는 것. (인터넷에 친절하게 설명해주신 분들께 감사)
특정 폰만 로그인이 안 되던 문제. 확실한 재현이 어려워 유력한 원인 둘을 함께 수정 — 캐시된 옛 로그인 페이지의 만료된 토큰(응답에 캐시 금지 헤더 추가)과, 공유 계정이 동시 접속 제한을 넘겨 세션이 서로를 밀어내던 현상(일반 사용자는 제한 해제, 관리자 계정만 유지).
DB 설계 일기 ① — "서버가 초호화가 아니다"에서 시작
이 시스템이 돌아갈 서버는 엔터프라이즈급의 고성능 장비가 아니라, 가정에서도 흔히 쓰는 평범한 사양의 데스크탑. 그래서 설계 첫날 최적화 방식부터 고민. 트럭 한 대가 하루 정산 1건에 품목 라인 수십 줄 — 트럭이 지금의 몇 배로 늘어도 하루 수백 행, 10년을 꽉 채워도 정산 라인은 수백만 행 규모. 결론은 명확했음. 인덱스만 필요한 곳에 제대로 걸면 평범한 MariaDB 한 대로 10년 뒤에도 감당되는 크기라는 것.
장비를 늘려야 성립하는 설계는 전부 배제하고, 대신 한 대에서 오래 버티는 구조에 초점을 두고 설계 시작. 쿼리가 데이터 양에 비례해 느려지지 않도록 인덱스와 집계를 화면에 맞추고, 방치하면 무한히 자라는 테이블(위치·동선·로그)은 스케줄러가 자동으로 정리. 동선을 30일만 남기고, 오래 열린 추적 구간을 새벽에 정리하는 것도 이 결정의 연장입니다.
규모 확장은 장비가 아니라 스키마에 초점을 두고. 품목을 컬럼이 아니라 행으로 쌓는 헤더+라인, 역할에 박지 않고 스위치로 켜는 권한, 잔량과 로그로 나눈 재고 — 트럭·지점·품목이 몇 배가 되어도 테이블을 갈아엎는 게 아니라 행만 늘어남.
DB 설계 일기 ② — 인덱스는 화면에서 거꾸로
인덱스를 "일단 많이" 걸지 않음. 사양이 좋고, SSD 크기가 초대형이라면 그냥 마구마구 인덱스를 걸어서 얼마나 데이터가 쌓이든 어떤 접근에서도 빠르게 구축하겠으나, 당장 이 프로젝트 서버의 여건은 그렇지가 못함.
그렇다면 가장 필요한 곳에만 인덱스를 걸어 최소한의 부담으로 성능을 뽑아내야 하는 문제 발생.
이 시스템에서 매일 열리는 화면은 정해져 있음 — 직원 휴대폰의 오늘 정산, 사무실의 기간별 매출 조회, 재고 현황. 그래서 자주 도는 쿼리를 먼저 적고, 그 WHERE절을 그대로 복합 인덱스로 작성. 정산은 (지점, 영업일), 재고 이동 로그는 (품목, 발생 시각), 출퇴근은 (직원, 근무일) — 전부 "누구의, 언제"로 좁혀 들어가는 실제 화면과 1:1로 대응.
상태값처럼 값 종류가 몇 개 안 되는 컬럼은 걸어봐야 별 의미도 없고, 정산 라인의 품목명처럼 검색 조건으로 안 쓰는 컬럼도 제거. 인덱스는 조회 속도를 사는 대신 쓰기 비용과 용량을 지불하는 것이므로, "안 거는 결정"이 "거는 결정"만큼 사람 고뇌하게 만듦. 다만 매출 추이 차트가 치는 기간 집계만은 데이터가 10년치가 되어도 열리는 속도가 그대로이도록, 인덱스가 조회 범위를 바로 잘라내게 맞춰둠.
DB 설계 일기 ③ — 백업은 "복원해 본 방식"만 믿기로 했습니다
매출·정산·재고는 잃으면 되돌릴 수 없는 영업 기록. 백업 방식을 고를 때 기준은 하나 — 개발자가 없는 자리에서도 복원이 되는가. 어떻게 해야 개발자 없이 일반인이 백업과 복원을 할 수 있겠는가...
결국 매시간 덤프를 떠서 세대별로 보관하는, 가장 단순하고 가장 오래 검증된 방식을 채택. 시간 단위 백업은 최근 것만, 오래된 것은 날짜 단위로 남겨 디스크를 아끼고, 사고 시 잃는 데이터가 최대 1시간이라는 한계는 감수.
여기에 추가적 기능으로 — 엑셀 다운로드. DB 백업이 시스템을 되살리는 사본이라면, 엑셀은 시스템이 통째로 사라져도 대표님 손에 남는 사본. 그리고 백업은 만들 때가 아니라 복원할 때 실패한다는 걸 알기에, 백업본으로 실제 복원하는 절차를 화면에 넣어 눌러볼 수 있게 구성.
이 모든 걸 이제 일반 사용자가 마우스 클릭만으로 진행할 수 있게 직관적으로 구성.
DB 설계 일기 ④ — 개발자를 부르지 않아도 되게 만든 장치들
이 시스템의 관리자나 사용자는 개발을 모르며, DB도 모름. 그게 정상이고, 어떻게 하면 앞으로도 문제없이 사용할 수 있을까 고민. 그래서 SQL이 필요했던 일을 전부 화면으로 구현 — 서버 상태와 자원 확인, 백업 목록과 복원, 오래된 데이터 정리 모두 쉽게 쓸 수 있도록 서버관리기 프로그램 제작 및 버튼으로 구현. 대신 되돌릴 수 없는 버튼(복원·정리·재시작)은 비밀번호를 다시 물은 뒤에만 실행. "쉽게"와 "실수로"는 한 끗 차이라, 누르기 쉽게 만들되 위험한 것만 한 번 더 묻도록 진행.
사람이 챙겨야 하는 일 자체도 최대한 감소. 백업, 동선 정리, 위치 추적 종료는 스케줄러가 알아서 돌고 사람은 결과만 확인. 운영이 "매일 해야 하는 일 목록"이 아니라 "이상할 때만 여는 화면"이 되어야, 개발자 없이도 오래 가는 시스템이 된다고 봄.
마지막으로 나중에 훨씬 크게 성장하여 새로운 장비로 데이터 이전이 필요할 수 있으므로
DB를 통째로 dump 추출 후, 다시 입히는 기능까지 서버관리기에 내장 (단순 클릭만으로도 진행 가능)
DB 설계 일기 ⑤ — 일부러 지금 만들지 않은 것들
파티셔닝, 읽기 전용 복제본, 별도 집계 테이블은 구상을 해봤으나 미진행. 지금 규모에서는 복잡해지기만 하고 이득이 없기 때문. 대신 나중에 붙일 자리는 남겨뒀는데 — 큰 테이블은 전부 날짜로 잘리도록 짜여 있어 연 단위 아카이브 분리를 구조 변경 없이 얹을 수 있고, 집계가 느려지는 날이 오면 월 마감 테이블을 추가하는 경로도 계산.
미래에 바꿔야 할 때 갈아엎지 않아도 되는 모양을 지금 골라두는 것.
통제와 현장 편의 사이에서
실사용 시스템의 편의는 "다 되게 하는 것"이 아니라 선을 잘 긋는 것. 출근은 근무지 반경 안에서만 눌리지만 사정이 있으면 예외 신청으로 열리고, 마감된 매출은 직원이 못 고치지만 수정 요청·승인이라는 길이 있음. 퇴근과 정산은 위치와 무관하게 풀어뒀고, 어디를 막고 어디를 열지 하나하나가 고객사와 상의해 정한 지점들.
휴대폰이 주 화면인 만큼 자잘한 편의성 기능도 삽입 — 다음 근무지 알림에는 주소와 길찾기를 함께 싣고, 그 알림만은 직원이 끌 수 있게 진행. 붙잡아 두는 시스템이 아니라 일을 체계적으로 더 편하게 할 수 있는 게 목표.
또한 모든 역할별 기능을 위에서부터 위임과 통제가 가능하게 함으로써 권한 구조와 운영을 고객사가 쉽게 커스텀할 수 있도록 설계.
돌아보며
omg
How I work
도구와 원칙
웹 개발
- Java · Spring Boot
- Thymeleaf · JPA
- MariaDB · PostgreSQL
- Python · FastAPI
프로그램 · 앱
- C# · .NET · WPF
- Android · Kotlin
- SignalR · SSE 실시간
- 배포 · 자동 업데이트
AI · LLM
- 로컬 LLM · Ollama
- RAG · 벡터 검색
- 이미지 생성 파이프라인
- 도구 호출 · MCP