웹 · 앱 · 프로그램 개발
Tech notes
무엇을 왜 썼는지사내 통합 포털
화면 맥락을 반영한 RAG 임베딩 검색, FastAPI 연동 엑셀 처리, ComfyUI 이미지 파이프라인, SSE 실시간 알림, 경로 탈출을 막은 파일 스토리지 — 사내 포털을 만들며 다룬 기술 요소들입니다.
화면 컨텍스트 인식 + RAG 임베딩 검색
레이아웃 팝업에서 보내는 메시지는 페이지 내용과 질문이 하나로 합쳐져 오는데, 서버에서 구분자를 기준으로 이를 분리해 페이지 내용은 system 메시지로, 질문은 user 메시지로 각각 주입합니다. 페이지 내용은 세션별로 저장해두고 매 요청마다 최신 화면 기준으로 갱신하기 때문에, 사용자가 다른 화면으로 이동한 뒤 질문해도 항상 지금 보고 있는 화면 기준으로 답합니다.
임베딩은 Ollama의 /api/embeddings 엔드포인트로 생성합니다. 문서가 길면 1200자 단위로 청크를 나눠 각각 임베딩한 뒤 평균 벡터를 구하고, PostgreSQL(pgvector) 컬럼에 저장합니다. 질의 시에는 코사인 거리 연산자(<=>)로 가장 가까운 문서 3건을 가져와 답변의 근거로 삼습니다.
이 내부 문서 검색 도구(rag_search)는 일반 사용자 세션의 도구 목록에서는 아예 제외하고, 관리자 세션에서만 노출해 RAG 사용 범위를 세션 단위로 통제합니다.
AI 엑셀 처리 — Java ↔ FastAPI SSE 프록시
엑셀 처리 자체는 pandas가 훨씬 유리해서, 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 로그가 저장될 때마다 위치 정보(건물/실/좌석)를 태그로 붙여 저장하고, 연결된 모든 관리자 화면에 Server-Sent Events로 그대로 밀어줍니다. 여러 관리자가 동시에 화면을 열어놔도 폴링 없이 실시간으로 로그가 쌓이는 걸 볼 수 있습니다.
연결된 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를 호출해 검색→검색 무한 루프에 빠질 수 있기 때문입니다.
교육 포트폴리오 전시 플랫폼
한 엔티티로 파일형·웹사이트형을 나눈 이원화 설계, 한글 파일명과 Zip Slip을 함께 막은 ZIP 안전 해제, 쿠키+IP 화이트리스트 관리자 인증 — 전시 플랫폼을 만들며 다룬 기술 요소들입니다.
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가 지정된 사내 대역이면 쿠키를 자동 발급해 로그인 자체를 생략하고, 그 외에서 들어오면 잠금 화면에서 비밀번호를 받아 통과 시 쿠키를 내주는 방식으로, 편의와 최소한의 접근 통제를 함께 잡았습니다.
훈련과정 출결 전광판
자체 바이너리 프로토콜을 바이트 단위로 분석해 만든 디코더·인코더, 시각·강의일 기준의 출결 재판정, 10초 폴링과 SSE 브로드캐스트 — 전광판을 만들며 다룬 기술 요소들입니다.
자체 바이너리 프로토콜(SSV) 디코더·인코더 직접 구현
이 프로젝트에서 가장 큰 벽이었습니다. 대상 행정시스템은 JSON도 XML도 아닌, 데스크톱 UI 런타임이 쓰는 자체 바이너리 포맷으로만 통신해서 일반 파서로는 응답을 읽을 수조차 없었습니다. 실제 트래픽의 바이트 배열을 직접 뜯어 구조를 파악했습니다.
본문은 2바이트 시그니처(FF AD) 뒤에 zlib으로 압축돼 있고, 압축을 풀면 변수 블록과 데이터셋 블록이 이어지는 레코드 스트림이 나옵니다. 각 데이터셋은 이름 → 컬럼 정의 → 행이 반복되고 행 끝은 4바이트 트레일러로 구분되는데, 이걸 u8·u16 빅엔디안 단위로 한 바이트씩 읽어 나가는 파서를 만들었습니다. 값 타입이 0이면 null, 아니면 길이만큼 UTF-8로 읽는 식입니다.
조회만이 아니라 요청도 같은 포맷으로 만들어 보내야 해서 인코더까지 함께 구현했고, 캡처해둔 요청 템플릿을 디코딩해 조회 조건(과정 ID·회차·날짜)만 바꿔 다시 인코딩하는 방식으로 재사용합니다.
출결 판정 규칙 — 원본 상태값을 그대로 쓰지 않은 이유
행정시스템이 주는 원본 상태값만으로는 화면을 만들 수 없었습니다. 오전 10시에 입실 기록이 없는 사람이 "아직 안 온 것"인지 "결석"인지 구분되지 않고, 퇴실 시각이 비어 있으면 조퇴인지 아직 수업 중인지 알 수 없기 때문입니다.
그래서 현재 시각과 과정 설정(강의일 여부, 시작·종료 시각, 점심시간, 퇴실 유예)을 넣어 상태를 다시 계산합니다. 수업 진행 중이면 '입실 대기', 종료 시각이 지나도 입실이 없으면 '결석'으로 갈리고, 종료 후 유예 시간까지 지나야 '미퇴실'로 판정합니다. 오늘이 강의일이 아닌 과정은 아예 판정하지 않아 주말반·격일반이 결석으로 잘못 잡히지 않습니다.
출석·지각 인원이 1명 이상이고 그 인원만큼 퇴실이 찍히면 '전원 퇴실 완료'로 보고 카드를 목록 아래로 내립니다.
폴링 → 스냅샷 → SSE 브로드캐스트
남의 시스템을 10초마다 두드리는 구조라 부하와 안정성에 신경 썼습니다. fixedRate가 아니라 fixedDelay를 써서 응답이 느려도 요청이 겹쳐 쌓이지 않게 했고, 과정을 순차 조회하면서 사이에 간격을 둬 순간적으로 몰리지 않게 했습니다. 오늘 열린 과정 목록은 10분마다만 다시 탐색하고 그 사이 폴링은 캐시된 목록을 쓰며, 탐색이 실패하면 설정값으로 폴백합니다.
세션 만료 감지는 응답의 앞 2바이트로 합니다. 시그니처가 아니면 로그인 페이지 HTML이 돌아온 것이므로 전용 예외를 던져 나머지 과정 조회를 즉시 중단하고 상태 메시지로 노출합니다 — 만료된 세션으로 수십 번 더 요청하는 걸 막기 위해서입니다.
완성된 결과는 스냅샷 하나로 만들어 volatile 필드에 통째로 교체하고(읽는 쪽은 락 없이 안전하게 읽습니다), 연결된 모든 화면에 SSE로 밀어줍니다. 전송에 실패한 연결은 즉시 목록에서 제거합니다. 아무도 보지 않는 야간에는 폴링 자체를 자동으로 멈춥니다.
개인정보 최소 노출 & 자동 파기
행정시스템 응답에는 마스킹된 주민등록번호가 포함돼 있는데, 이걸 그대로 브라우저로 내려보내면 화면 소스에 남습니다. 그래서 화면 전송용 DTO에는 주민번호 필드를 아예 두지 않고, 동명이인을 구분할 최소 정보인 생년월일 6자리만 뽑아 담도록 분리했습니다.
훈련생 전화번호는 결석자 연락에 필요해 담당자가 직접 입력하는데, 칸을 비워 저장하면 바로 삭제되고, 종료일이 지났거나 오랫동안 확인되지 않은 과정은 매일 새벽 스케줄러가 과정 설정과 전화번호를 함께 파기합니다. 필요한 기간에만 보관되도록 만든 장치입니다.
HexaStay — 호텔 통합 관리 시스템
자기참조 트리로 짠 본사·지사·지점 구조, 행으로 언어를 늘리는 다국어 콘텐츠 테이블, 옵션까지 담는 3단 장바구니, 스케줄러 기반 설문·정산 — 팀으로 만든 호텔 시스템의 설계 이야기입니다.
다단계 조직 구조 & 권한 설계
여러 호텔을 한 시스템에서 운영하려면 조직 구조가 먼저 서야 했습니다. 본사·지사·지점을 각각 테이블로 나누는 대신 회사 엔티티 하나에 타입과 부모 회사 번호를 둬서 자기참조 트리로 표현했습니다. 조직 단계가 늘어나거나 지점이 추가돼도 스키마를 바꿀 필요가 없습니다.
권한은 조직 계층에 맞춰 설계했습니다. 본사(임원진·팀장·사원), 지사와 지점(총괄·관리자·직원·협력업체), 매장(매니저·부매니저·매장직원), 그리고 전체 접근이 가능한 슈퍼관리자까지 나뉩니다. 각 등급을 enum으로 정의하면서 화면에 보여줄 한글 표시명을 같이 담아, 코드값과 라벨이 따로 놀지 않게 했습니다.
주문·객실·매출 데이터는 모두 소속 회사 번호로 묶이기 때문에, 지점 직원은 자기 지점 것만 보고 본사는 전체를 봅니다.
다국어 콘텐츠 테이블 설계
메뉴 이름을 nameKo·nameEn·nameJa처럼 컬럼으로 늘리면 언어를 추가할 때마다 스키마를 고쳐야 합니다. 그래서 번역만 별도 테이블로 빼고 (메뉴, 언어코드) 조합으로 한 행씩 저장했습니다. 언어를 추가하는 일이 곧 데이터를 넣는 일이 되어 코드 변경이 필요 없습니다.
가격·사진처럼 언어와 무관한 값은 원본 테이블에만 두고, 조회할 때 요청 언어로 번역 행을 찾아 이름·설명·카테고리만 덮어씁니다. 번역이 없으면 덮어쓰지 않고 원본을 그대로 두는 폴백 구조라, 번역 누락이 화면 오류로 이어지지 않습니다.
장바구니 · 옵션 모델링과 주문 상태
"치즈버거 + 감자튀김 라지 + 콜라 변경"처럼 같은 메뉴라도 옵션 조합이 다르면 다른 항목이어야 합니다. 그래서 장바구니를 장바구니 → 담긴 항목 → 항목별 선택 옵션 3단으로 나눠, 옵션 조합까지 정확히 표현되도록 했습니다.
룸서비스와 매장 주문은 주문 주체도, 처리 담당자도, 정산 대상도 달라서 장바구니부터 주문까지 계열을 아예 분리했습니다. 덕분에 매장 직원 화면에서 자기 매장 주문만 조회하는 것이 자연스럽게 처리됩니다.
주문 상태는 접수 → 처리 → 완료로 흐르되 중간에 취소가 가능하고, 주문 시점의 가격과 옵션은 주문 항목에 복사해 남깁니다. 나중에 메뉴 가격이 바뀌어도 과거 주문 내역과 정산 금액이 흔들리지 않게 하기 위해서입니다.
설문 자동 발송 & 지점별 정산
매일 정해진 시각에 스케줄러가 전날 체크아웃한 객실을 하루 구간으로 조회해 설문 메일을 보냅니다. 객실에서 소속 회사를 거슬러 올라가 그 지점에 활성화된 설문 템플릿을 찾고, 등록된 설문이 없으면 기본 템플릿으로 발송해 어느 지점도 누락되지 않게 했습니다.
메일 발송은 수신자 한 명 단위로 예외를 잡습니다. 주소 오류 같은 이유로 한 건이 실패해도 로그만 남기고 나머지 발송은 계속 진행되도록 해서, 한 명 때문에 그날 전체 발송이 멈추는 상황을 막았습니다.
정산은 조직 트리를 그대로 집계 범위로 씁니다. 회사 번호로 객실을 찾고, 객실에 묶인 투숙 건에서 룸서비스와 매장 주문을 합산해 지점 매출을 냅니다.
사내 AI 어시스턴트
판단을 LLM에 맡긴 MCP 도구 레지스트리, GPU 하나를 세 모델이 나눠 쓰는 VRAM 오케스트레이션, 브라우저 종료까지 잡는 4단계 취소 전파 — 사내 AI 플랫폼의 설계와 시행착오입니다.
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를 키로 격리하고, 갱신 방식에 특히 신경 썼습니다.
파일명과 이미지 캐시를 개별적으로 하나씩 갱신하면 "파일명은 새것인데 캐시는 옛것"인 중간 상태가 다른 스레드에 노출될 수 있습니다. 그래서 생성 완료 후에는 관련 상태를 한 메서드에서 한 번에 바꾸도록 통일했습니다.
대화 메모리는 최근 것만 남기고 오래된 것부터 버려 컨텍스트 한도를 넘지 않게 했고, 캐시가 비어 있고 파일명만 남은 경우에는 디스크에서 다시 읽어 채우도록 해서 상태가 어긋나도 다음 요청에서 스스로 회복되게 만들었습니다.
PC 통합 제어·모니터링 시스템
MAC을 절대 식별자로 삼은 무계정 인증, 바뀐 타일만 보내는 델타 화면 캡처, UDP 홀펀칭 P2P 전송, 실행 중인 자기 자신을 교체하는 무인 업데이트 — .NET 데스크톱 시스템의 설계와 시행착오입니다.
신원 = MAC(좌석) — 계정 테이블을 지운 인증 모델
처음에는 User 테이블과 로그인 화면을 두고 시작했습니다. 그런데 강의실 PC는 앉는 사람이 매일 바뀌고 자리는 고정입니다. 계정을 발급·회수하는 운영 비용이 시스템이 주는 가치보다 커졌고, 결정적으로 유동 IP 환경이라 IP로는 같은 PC를 계속 같은 PC로 볼 수 없었습니다.
그래서 계정·로그인·하드코딩 비밀번호를 전부 걷어내고, 랜카드 MAC을 절대 식별자(PK)로 삼았습니다. 인증이라는 단계가 따로 없고 핸드셰이크가 그 역할을 합니다. 클라이언트가 MAC과 현재 IP를 보내면 서버는 좌석을 upsert하고, 그 좌석의 Role과 세션 토큰을 응답합니다. 등록·인증·권한 조회가 한 번의 왕복으로 끝납니다.
재연결과 IP 변경 때마다 같은 핸드셰이크를 다시 하기 때문에, 유동 IP가 자동으로 갱신되고 관리자가 바꾼 권한도 이 경로로 즉시 내려갑니다. 콘솔은 재시작 없이 메뉴와 버튼 노출을 다시 계산합니다.
대신 MAC은 위조할 수 있습니다. 이 설계는 사내 폐쇄망이라는 전제 위에서만 성립하고, 클라이언트 임의 종료 같은 실질적인 방어는 서버가 검증하는 별도의 관리 비밀번호로 처리했습니다. 편의를 얻고 무엇을 포기했는지를 분명히 알고 고른 선택입니다.
DXGI Desktop Duplication + 128px 델타 타일
화면 전송에 H.264 같은 영상 스트리밍을 쓰지 않았습니다. 코드·엑셀 화면은 정지 상태가 대부분인데 영상 코덱은 매 프레임 CPU를 쓰고, 무엇보다 글자가 뭉개집니다. 대신 화면을 128px 타일로 나눠 이전 프레임과 비교하고, 바뀐 타일만 JPEG로 인코딩해 보냅니다. 정적인 화면에서는 전송량이 사실상 0에 수렴합니다.
처음엔 GDI CopyFromScreen으로 캡처했는데 프레임당 수십 ms가 들어 20석 그리드에서 캡처 자체가 병목이었습니다. GPU가 이미 합성해 둔 데스크톱을 가져오는 DXGI Desktop Duplication으로 바꾸면서, 픽셀 소스만 교체하고 검증된 타일 비교 로직은 그대로 재사용했습니다. 캡처 방식을 바꾸면서 화면이 깨지는 위험을 같이 떠안지 않기 위해서였습니다.
타일 변경 판정은 unsafe 포인터로 8바이트씩 읽어 비교하고 남는 꼬리만 바이트 단위로 처리합니다. 판정은 매 프레임 전 타일에 대해 도는 연산이라 여기가 가장 자주 실행되는 코드입니다.
해상도 변경이나 UAC 보안 데스크톱 전환에서는 duplication이 ACCESS_LOST로 끊깁니다. 예외로 프로세스가 죽지 않도록 흡수하고, duplication을 재생성한 뒤 다음 프레임을 키프레임으로 보내 화면을 복구합니다. DXGI 초기화가 실패하는 환경(가상머신 등)은 팩토리가 조용히 GDI 캡처러로 폴백합니다.
UDP 홀펀칭 P2P — 서버는 신호만, 프레임은 직접
모든 화면 프레임이 서버를 거치면 뷰어가 늘어날수록 서버 대역폭이 그대로 곱해집니다. 그래서 1:1 화면 보기는 송출자와 뷰어가 UDP로 직접 주고받게 하고, 서버는 세션 개설과 후보 엔드포인트 중계라는 신호 역할만 하도록 분리했습니다.
서로 다른 서브넷·NAT 뒤에 있어도 붙어야 해서, 서버에 간단한 STUN 반사기를 넣었습니다. 양쪽이 자기 공인 엔드포인트를 알아낸 뒤 후보 목록을 교환하고, 동시에 PUNCH를 쏴 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초 무작위 지터를 두고 다운로드를 흩뜨립니다.
푸드트럭 운영 통합 시스템
헤더+라인으로 뒤집은 정산 스키마, 두 번 빠지지 않는 델타 기반 재고 원장, 라이브러리 없이 그린 SVG 차트, WebView 껍데기 앱에서 만난 문제들 — 실제로 돌아가는 시스템을 만들며 부딪힌 것들입니다.
정산 저장 구조 — 헤더+라인, 그리고 이름 스냅샷
정산 한 건에 들어가는 품목 수가 직원마다 다릅니다. 트럭과 지역에 따라 취급 품목이 달라서, 컬럼을 늘려가는 방식은 처음부터 불가능했습니다. 그래서 인보이스 패턴으로 뒤집었습니다 — 헤더(그날의 정산)와 라인(품목 한 줄)을 나누고, 제품을 컬럼이 아니라 행으로 저장합니다. 기타 지출도 같은 테이블에 구분값만 다르게 넣어서, 표 하나로 정산 한 건이 완결됩니다.
가장 신경 쓴 결정은 품목 이름과 단가를 참조가 아니라 값으로 복사해 넣은 것입니다. 체크리스트 틀을 FK로 걸었다면, 사장이 "닭꼬치 4,000 → 4,500"으로 단가를 고치는 순간 지난달 정산 금액까지 소급해 바뀌었을 겁니다. 회계 성격의 데이터는 "그때 그 값"이 남아야 해서, 정규화를 일부러 포기했습니다.
저장은 라인을 전부 지우고 다시 넣습니다. 어떤 줄이 바뀌었는지 추적해 부분 갱신하는 것보다 단순하고, 한 정산의 라인이 수십 개라 비용도 문제되지 않습니다.
재고 원장 — 재저장해도 두 번 빠지지 않게
재고를 정산에 연결하면서 바로 부딪힌 문제가 있습니다. 직원은 하루에도 임시저장을 여러 번 합니다. 저장할 때마다 "판매 180개"를 그대로 빼면 세 번 저장한 날은 540개가 빠집니다.
그래서 정산의 품목별 수량을 그날의 스냅샷으로 따로 보관하고, 저장할 때마다 이전 스냅샷과의 차이만 원장에 반영합니다. 150에서 180으로 고쳤으면 −30만 움직입니다. 원장 연산 자체가 부호 있는 델타를 받도록 만들어져 있어서, 수정도 취소도 같은 경로로 처리됩니다.
재고는 잔량 테이블과 로그 테이블을 함께 둡니다. 잔량만 있으면 "왜 이 숫자인지" 설명할 수 없고, 로그만 있으면 매번 합산해야 합니다. 음수 재고는 일부러 막지 않았습니다 — 막으면 재고 입력이 밀린 날 정산 자체가 실패하는데, 장부가 현장을 막아서는 안 된다고 봤습니다. 음수는 "입력이 밀렸다"는 신호로 남습니다.
여기서 실제 버그도 하나 잡았습니다. 품목에 그룹을 붙일 때 컨트롤러에서 필드 수정과 그룹 지정을 각각 다른 서비스 메서드로 호출했는데, 서로 다른 트랜잭션이라 그룹 검증이 실패하면 이름·단가만 커밋되고 400이 떨어졌습니다. 사용자 눈에는 "실패했다는데 값은 바뀐" 상태였습니다. 한 메서드 안에서 그룹을 먼저 검증하고 필드를 바꾸도록 합치고, 회귀 테스트로 고정했습니다.
그룹으로 묶을 때는 이름이 아니라 id를 기준으로 삼았습니다. 그룹명에 유니크 제약이 없어서 이름으로 묶으면 동명의 다른 그룹이 한 칸에 합쳐지고, 사용자가 '미분류'라는 이름의 그룹을 만들면 진짜 미지정 품목과 섞였습니다.
권한이 두 층에 있으면 위층이 이긴다
지점장에게 "물류센터 관리" 권한을 켜줬는데 계속 로그인 화면으로 튕겼습니다. DB의 권한 플래그도 정상이고 컨트롤러의 조건도 정상이었습니다.
원인은 그 화면 주소가 /ceo/stock이었다는 것입니다. 보안 설정에 /ceo/**는 대표 이상이라는 URL 규칙이 있었고, 이건 필터 단계에서 먼저 걸립니다. 컨트롤러의 세밀한 권한 판정은 실행조차 되지 못했습니다. 권한을 아무리 정교하게 짜도 그 앞에 굵은 규칙이 있으면 소용이 없다는 걸 확인한 자리입니다.
화면을 /ceo 밖으로 옮겨(/stock, /warehouses) 게이트를 메서드 권한 한 곳에만 두었습니다. URL은 역할이 아니라 위치를 뜻하게 하고, 위임 가능한 화면은 전부 이 관례를 따르게 했습니다.
그 아래에 조회 범위 층이 따로 있습니다. 같은 화면이라도 지점장은 자기 지점 행만 봅니다. 직원 위치·출결 승인·발주·회원·장사지역 조회가 전부 뷰어를 받아 지점으로 거르고, "전체 지점 조회" 권한을 켜면 그때 넓어집니다. 역할을 올리지 않고 권한 플래그로 범위만 넓히는 구조입니다.
위치는 두 갈래로 나눠 쓰고, 표시에서만 뭉친다
같은 GPS 신호를 두 곳에 나눠 저장합니다. "지금 어디 있는가"(지도 칩용 현재 위치)와 "어디를 지났는가"(동선 기록)는 수명도 조회 방식도 다릅니다. 처음엔 한 곳에서 처리했는데 이중 기록과 경합이 생겨 갈라냈고, 오래 열려 있던 동선 구간은 스케줄러가 정리합니다.
추적은 로그인이 아니라 출근에서 시작해 정산 마감에서 끝납니다(클라이언트와 상의해 정한 지점입니다). 15분 무응답이면 자동으로 꺼집니다.
낭비도 몇 가지 걷어냈습니다. 위치가 이동했는지 판정하면서 주소 변환 API를 두 번 부르던 것을 한 번으로 줄였고, 앱에서 첫 위치가 최대 10분 늦게 잡히던 공백은 마지막으로 알려진 위치를 즉시 한 건 올려 메웠습니다. 창고 이름 조회가 위치마다 반복되던 N+1도 한 번 조회해 넘기도록 고쳤습니다.
지도에서 겹친 마커는 처음에 좌표를 반올림해 묶었습니다. 그런데 확대해도 계속 뭉쳐 있어 개별 직원을 고를 수 없었습니다. 기준을 화면상 픽셀 거리로 바꾸고 줌이 바뀔 때 다시 계산하니, 확대하면 자연스럽게 흩어지고 축소하면 다시 묶입니다. 동선의 근접점도 그리는 단계에서만 병합하고 저장된 원본은 건드리지 않습니다.
WebView 껍데기 앱에서 만난 것들
앱에는 화면을 넣지 않았습니다. APK 안에는 접속 주소·아이콘·네이티브 기능(위치·다운로드·외부 앱 연동)만 있고 화면은 전부 서버가 그립니다. 덕분에 기능 수정은 서버만 다시 올리면 되고, 앱 재설치는 네이티브를 건드릴 때만 필요합니다. 대신 웹만 하면 안 만났을 문제들을 만났습니다.
로그인만 하면 앱이 죽던 문제. 직원·지점장 계정에서만 크래시했습니다. 로그인 직후 위치 추적을 시작하는데, 최신 안드로이드는 위치 권한 없이 위치용 포그라운드 서비스를 띄우면 프로세스를 즉시 종료시킵니다. 권한을 확인한 뒤에만 시작하도록 고쳤습니다.
길찾기 버튼이 안 눌리던 문제. 지도 위 정보창의 버튼이 PC·모바일 모두 무반응이었습니다. 함수는 멀쩡했고, 클릭이 정보창을 통과해 지도로 내려가고 있었습니다(오버레이의 클릭 수신 옵션 누락). 고친 건 한 줄이었지만 "함수가 정상인데 안 눌린다"를 의심하는 데 가장 오래 걸렸습니다.
내비 앱이 안 열리던 문제. 원인이 둘이었습니다. 지도 앱 주소 규약을 쓰고 있어 지도와 내비가 같이 열리던 것(내비는 공식 URL 규약이 없어 공식 SDK 호출이 유일한 정식 경로였습니다), 그리고 안드로이드 11부터는 실행하려는 앱을 매니페스트에 미리 선언해야 보인다는 것입니다.
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