루프백 웹소켓이 열리지 않던 이유: 영업점 단말 화면 호출과 크롬 Local Network Access 실측
에이전트 답변과 현황판에서 영업점 단말 업무 화면을 여는 루프백 웹소켓 연동을 방향별로 판정해 구현했습니다. 시연 PC에서 연결이 1ms 만에 실패하던 원인을 크롬 Local Network Access로 확정해 정책으로 풀었고, 그 뒤 드러난 채팅 코드펜스 파서 결함까지 고쳤습니다.
Key Results
- 1ms error → 2ms open
- 루프백 웹소켓 연결시연 PC 크롬 153, LocalNetworkAccessAllowedForUrls 정책에 데모 출처 등록 전후, 자동화 권한 없음
- 버튼 0 → 정상 표시
- 답변 속 화면 호출 버튼닫는 코드펜스 뒤에 글이 이어진 실제 답변, 파서 수정 배포 후 같은 경로 재실측
은행 영업점 직원은 전용 브라우저 기반의 단말 프레임워크에서 계좌 조회, 고객 등록 같은 업무 화면을 엽니다. XGEN 현황판과 에이전트 답변이 "이 계좌를 확인하세요"라고 알려 주는 데서 끝나지 않고, 그 계좌의 단말 업무 화면까지 바로 열어 달라는 요구가 나왔습니다. 협의 단계에서 단말 프레임워크는 ActiveX를 쓸 수 없다는 점이 확인됐고, 같은 PC 안에서 단말이 루프백 주소(127.0.0.1)의 XGEN 전용 포트로 웹소켓 서버를 열어 두면 XGEN 페이지가 그 소켓으로 호출 메시지를 보내는 방식으로 정리됐습니다.
이 글은 은행이 보내 준 샘플 페이지를 받아 연동 방향별로 가능 여부를 판정하고 구현한 과정, 그리고 시연에서 "단말 프로그램에 연결하지 못했습니다"라는 오류가 났을 때 시연 PC에 원격으로 접속해 원인을 확정한 과정을 다룹니다. 원인은 XGEN 코드가 아니라 크롬이 새로 적용한 Local Network Access(LNA) 정책이었습니다. 정책을 풀자 이번에는 채팅 렌더러의 코드펜스 파서가 답변 뒷부분을 코드 블록으로 삼키는 결함이 드러났습니다.
두 문제는 증상만 보면 "단말과 통신이 안 된다"로 같았지만 실제 원인은 브라우저 정책과 마크다운 파서로 서로 무관했습니다. 단계마다 무엇을 근거로 가설을 지웠는지를 중심으로 정리합니다.
영업점 PC 한 대 안의 통신 구조
은행 샘플의 규격은 단순했습니다. 브라우저 페이지가 클라이언트, 단말의 로컬 서버가 서버이고 주소는 ws://127.0.0.1:{전용 포트}입니다. 개발·테스트·운영 환경마다 포트가 하나씩 정해져 있고, 같은 PC 안의 통신이라 구간 암호화나 토큰 같은 인증은 없습니다. 페이지는 JSON 문자열 한 건을 보내며 핵심 필드는 열 화면 ID(TARGET.SCREENID), 화면에 넘길 파라미터(TARGET.SCREEN_TRAN_DATA), 데이터 형식, 고정값인 프레임 종류입니다. 샘플의 기본 데이터는 법인 고객 등록 화면의 49개 필드를 채우는 예시였지만, 협의한 1차 범위는 "화면 호출과 파라미터 전달"까지였습니다.
영업점 PC 한 대 안
┌──────────────────────────────┐ ┌─────────────────────────────┐
│ 크롬 / 엣지 │ │ 단말 프레임워크 │
│ XGEN 페이지 (HTTPS, 폐쇄망) │ ws │ 로컬 웹소켓 서버 │
│ 현황판 카드 [화면 열기] ─────┼───────▶│ 127.0.0.1:<전용 포트> │
│ 답변 속 [화면 호출] 버튼 ────┼───────▶│ → 화면 ID로 업무 화면 오픈 │
│ onmessage ◀─────────────────┼────────┤ ← 응답 / (규격 확정 시) 푸시 │
└──────────────────────────────┘ └─────────────────────────────┘요구는 두 방향이었습니다. XGEN에서 단말 업무 화면을 여는 것, 그리고 단말 쪽에서 XGEN을 부르는 것입니다. 샘플, 고객 배포 라인의 코드, 직접 만든 모의 단말 서버로 방향별 판정을 먼저 내렸습니다.
| 방향 | 판정 | 근거 |
|---|---|---|
| XGEN → 단말 화면 호출 | 가능 | 미들웨어 CSP의 connect-src가 이미 ws://127.0.0.1:*를 허용(외부망 데모 응답 헤더로 확인). 크롬은 루프백을 안전한 출처로 취급해 HTTPS 페이지의 평문 ws를 혼합 콘텐츠로 막지 않음 |
| 단말 → XGEN 페이지 열기 | 가능 | 현황판 짧은 주소와 로그인 후 원래 주소로 복귀하는 처리가 이미 있음. 채팅 프리필 파라미터만 없음 |
| 단말 데이터를 XGEN에 표시 | 조건부 | 페이지가 연 소켓으로 서버가 먼저 보낸 메시지를 onmessage로 받는 것은 실측됨. 단말이 무엇을 언제 보내는지 규격이 없음 |
| 단말 화면을 XGEN 안에 표시 | 불가 | 단말 화면은 전용 브라우저의 XML과 스크립트라 iframe 대상이 아니고, 소켓으로는 화면 ID와 데이터만 오감 |
XGEN에는 팀즈 실시간 기능과 ML 워크벤치 터미널이라는 웹소켓 클라이언트 전례가 있어, 새 의존성 없이 브라우저 내장 API로 충분하다는 것도 이 단계에서 확인했습니다.
open도 error도 오지 않는 9초
판정의 근거를 실측으로 만들기 위해 수신 메시지를 기록하고, 응답을 보내고, 5초 뒤 푸시를 보내는 모의 단말 서버를 Python으로 만들었습니다. 첫 실측은 순조로웠습니다. 은행 샘플 페이지에서 메시지를 보내자 모의 서버가 화면 ID, 콜백 키, 49개 키의 데이터를 그대로 받았고, 샘플 페이지는 응답과 푸시를 모두 표시했습니다. 규격 해석은 맞았습니다.
문제는 같은 메시지를 XGEN 페이지에서 보낼 때 나왔습니다. 외부망 데모에 배포된 고객 배포 라인 페이지 안에서 new WebSocket('ws://127.0.0.1:<포트>')를 열자 9초를 기다려도 open, error, close 어느 이벤트도 오지 않았습니다. 콘솔에는 "WebSocket is closed before the connection is established" 경고만 남았습니다.
가장 중요한 단서는 서버 쪽 기록이었습니다. 모의 서버에는 TCP 연결이 들어왔지만 웹소켓 핸드셰이크에 해당하는 HTTP 업그레이드 요청은 오지 않았습니다. 서버가 떠 있지 않았다면 TCP 연결도 없었을 것이고, CSP는 이미 허용 목록에 있다는 것을 응답 헤더로 확인한 상태였습니다. 연결 경로는 열려 있는데 브라우저가 요청을 내보내지 않고 쥐고 있다고 판단했습니다.
그래서 브라우저 권한을 의심했고, 자동화 브라우저 컨텍스트에 local-network-access 권한을 XGEN 출처로 부여한 뒤 같은 호출을 반복했습니다. 이번에는 2ms 만에 open이 왔고, 모의 서버는 XGEN 출처의 origin으로 화면 ID와 계좌번호 파라미터를 받았으며, 페이지는 응답 JSON과 푸시 JSON을 모두 받았습니다. 두 실행의 차이는 권한 하나뿐이었습니다.
그 권한이 크롬의 Local Network Access입니다. 공개 출처의 페이지가 루프백이나 사설망으로 요청하면 사용자에게 허용을 묻는 정책이고, 웹소켓은 크롬·엣지 147(2026년 4월 안정판)부터 대상에 들어갔습니다. 실측한 크롬은 152였습니다. 폐쇄망 서버도 브라우저 기준으로는 "공개" 출처라 같은 규칙을 받습니다. 허용은 출처별로 기억되고, 관리되는 PC라면 엔터프라이즈 정책 LocalNetworkAccessAllowedForUrls에 XGEN 출처를 넣어 허용 창 없이 통과시킬 수 있습니다.
| 영업점 PC 브라우저 | 동작 |
|---|---|
| 크롬·엣지 147 미만 | 창 없이 바로 연결 |
| 147 이상, 정책 없음 | 첫 연결 때 허용 창 한 번. 거부하면 사이트 설정에서 다시 켜야 함 |
| 147 이상, 정책에 XGEN 출처 등록 | 창 없음 |
이 표를 검토 문서에 넣고, 은행 IT에 그룹정책 등록을 요청할 항목과 폐쇄망 반입 체크리스트에 추가했습니다. 이 예측은 일주일 뒤 실제 시연 PC에서 그대로 확인됩니다.
화면 ID를 코드에서 떼어 낸 설계
구현은 판정 당일 오후에 진행했습니다. 기능 이름은 은행이 회의에서 쓴 말을 따라 "단말 화면 호출"로 정했습니다. 화면을 XGEN 안에 띄우는 것이 아니라 단말의 그 화면으로 보내는 기능이라, "연동"보다 동작을 정확히 나타내는 이름이 필요했습니다.
설계의 축은 화면 ID를 프롬프트나 코드에 박지 않는 것이었습니다. 관리자가 관리 설정에서 화면 사전(키, 이름, 화면 ID, 파라미터)을 관리하고, 에이전트와 현황판 결과는 키만 다루며, 렌더링 시점에 사전에서 화면 ID로 바꿉니다. 은행 규격의 메시지 생성은 함수 하나에 모아 규격이 바뀌면 그 파일만 고치게 했습니다. 버튼 컴포넌트도 하나로 두어 채팅 답변, 현황판 카드, 표 셀, 알림이 같이 쓰고, 실패는 단말 미기동, 시간 초과, 미등록 화면으로 구분해 버튼 옆에 사유를 잠깐 표시합니다. 버튼은 껍데기이고 실제 동작은 키와 파라미터를 받는 함수 하나라, XGEN 페이지 안이라면 어디든 같은 한 줄로 연동됩니다.
가장 고민한 지점은 답변 속에서 무엇이 단말 호출을 일으키느냐였습니다.
| 선택지 | 장점 | 단점 | 판단 |
|---|---|---|---|
| 답변 속 링크로 함수 호출 | 은행 샘플 방식 그대로 | 채팅 렌더러가 링크를 http·https·mailto·tel만 남기고 javascript:는 #로 바꿈(XSS 방어). 이를 풀면 방어선이 약해짐 |
제외 |
| 답변 수신 즉시 자동 호출 | 클릭이 필요 없음 | 직원 단말을 임의로 바꿈. LNA 허용 창도 사용자 클릭 맥락이 필요 | 제외 |
| 답변 텍스트 마커를 버튼으로 치환 | 문서 다운로드 마커를 버튼으로 바꾸는 전례가 있고 링크 살균 규칙을 그대로 유지 | 모델이 마커를 답변에 그대로 옮겨 적어야 함 | 채택 |
마커 방식의 위험은 모델이 마커를 변형하는 경우였습니다. 이후 캔버스 도구 노드로 확장할 때, 관리자가 등록한 화면 중 사용할 화면을 노드에서 고르면 에이전트가 call_terminal_screen(screen_key, params, label) 도구를 받아 마커를 반환하고 LLM이 답변 끝에 옮겨 적는 구조로 했습니다. 필수 파라미터가 빠지면 도구가 누락을 알려 에이전트가 고객번호를 되묻고, 답을 받은 뒤 버튼을 만드는 것까지 데모에서 실측했고, 버튼이 나와야 하는 두 턴 모두 모델이 마커를 그대로 옮겼습니다. 기존 현황판 에이전트에는 프롬프트를 바꾸지 않고 노드와 엣지만 추가했는데, 도구 설명에 화면 목록과 파라미터가 들어 있어 모델이 스스로 도구를 골라 호출했습니다. 에코가 불안정해지면 SSE 이벤트로 버튼을 직접 내려보내는 길을 후속 후보로 남겼습니다.
같은 에이전트가 채팅에서는 버튼을 만들고 현황판에서는 만들지 못하는 문제도 이때 찾았습니다. 현황판 에이전트는 봉투 JSON 뒤에 마커 줄을 추가로 쓰는데, 현황판은 봉투만 읽고 그 줄을 버리고 있었습니다. 봉투를 읽은 뒤 원문에서 마커를 찾아 카드의 후속 실행 버튼으로 추가하고, 채팅 렌더러와 같은 정규식을 쓰게 했습니다.
설정 저장 위치는 한 번 바꿨습니다. 처음에는 화면 사전을 현황판 에이전트 설정의 필드로 두었는데, 그 키는 현황판 열람 권한이 있는 사용자만 읽을 수 있었습니다. 채팅 답변은 현황판 권한이 없는 사용자도 보므로 그 사용자에게는 버튼이 풀리지 않습니다. 그래서 전용 설정 키로 분리하고, 담긴 값이 화면 ID와 이름뿐이라 비밀이 없다는 점을 근거로 로그인 사용자 읽기, 현황판 관리 권한 쓰기로 두었습니다. 사이드 컨테이너에 백엔드를 올려 게이트웨이 신원 헤더로 직접 호출해, 권한 없는 사용자의 새 키 조회는 200이고 기존 현황판 설정 조회는 403으로 유지되며, 권한 없는 쓰기는 403, 관리 권한으로 쓴 값을 권한 없는 사용자가 읽는 경우까지 네 경우를 확인했습니다.
이후 요구로 두 가지가 더해졌습니다. 하나는 역할별 노출입니다. 관리 설정의 행마다 노출 대상 역할을 지정하면, 노드의 화면 목록 API는 요청자 신원 헤더로, 도구 실행은 도구 생성 시점의 실행 사용자 역할로 한 번 더 거릅니다. 실행자를 알아낼 수 없으면 공개 화면만 허용하고, 걸러진 화면은 도구 설명에도 싣지 않습니다. 판정 규칙은 프론트엔드와 같게 맞췄습니다(역할이 비면 전체 공개, 슈퍼유저는 전부). 다른 하나는 도구 설명의 하드코딩 제거입니다. 설명에 "특정 계좌·고객 화면을 바로 열어 주는 게 도움이 될 때" 같은 여신 업무 전제 문장이 들어 있어, 호출 판단의 근거가 화면 사전이 아니라 코드 문장이 되고 있었습니다. 문장을 지우고 기능 설명, 마커 규약, 사전 목록만 남긴 뒤 Claude Sonnet과 Qwen 계열 모델에 같은 질문을 넣어, 명시 요청 2건의 키·파라미터와 무관한 질문에서 도구를 부르지 않는 동작이 전후 같음을 확인했습니다. 지금 LLM이 보는 것은 키, 이름, 파라미터 이름뿐이고, 화면 ID는 클릭 시점에 브라우저만 씁니다.
시연 PC에서 다시 만난 LNA, 그리고 두 번째 결함
일주일 뒤 시연에서 버튼을 누르자 "단말 프로그램에 연결하지 못했습니다"가 표시됐습니다. 그 PC에 원격으로 접속해 끝까지 직접 확인하기로 했습니다. 그 PC의 크롬 153을 헤드리스와 원격 디버깅 모드로 띄워 CDP로 제어하고, 모의 단말 서버는 그 PC에 두고 로그온 시 자동 시작되는 예약 작업으로 상주시켰습니다. SSH 세션에서 띄운 프로세스는 세션이 끝나면 함께 종료되기 때문입니다.
데모 페이지에서 루프백 웹소켓을 열자 이번에는 9초 대기가 아니라 1ms 만에 error가 왔고, 모의 단말에는 연결 기록조차 없었습니다. 자동화 컨텍스트에 local-network-access 권한만 주자 11ms 만에 open이 왔습니다. 같은 차단이 크롬 버전과 환경에 따라 무응답 대기로도, 즉시 실패로도 나타난 셈입니다. 그 PC의 크롬 프로필에는 데모 출처의 루프백 접근이 이미 허용으로 저장돼 있었는데, LNA는 그와 별개의 권한이라 그 설정은 소용이 없었습니다.
그 PC의 크롬 정책 LocalNetworkAccessAllowedForUrls에 데모 출처를 추가하고 재시작한 뒤 정책 페이지에서 반영을 확인했습니다. 자동화 권한 없이 정책만으로 원시 연결이 2ms에 열렸고, 관리 설정의 연결 확인과 호출 테스트도 통과해 모의 단말이 규격 필드가 일치하는 화면 ID를 받았습니다.
그런데 채팅에서는 여전히 버튼이 나오지 않았습니다. 연결 문제가 풀렸으니 남은 차이는 렌더링이었습니다. 상담 에이전트의 실제 답변을 열어 보니, 모델이 JSON 봉투 코드 블록을 닫는 펜스 바로 뒤에 줄바꿈 없이 설명 문장을 이어 쓰고 있었습니다. 채팅 렌더러의 코드 블록 탐색 함수는 "그 줄이 백틱뿐"일 때만 닫는 펜스로 인정했습니다. 백틱 뒤에 글이 이어진 줄은 중첩 블록의 시작으로 보고 스택에 쌓았고, 블록이 끝나지 않으니 뒤의 본문과 화면 호출 마커가 전부 pre 안으로 들어갔습니다. 실측한 버튼 수는 0개였습니다.
고칠 때 지킨 조건은 정상적인 중첩 펜스를 망가뜨리지 않는 것이었습니다. 같은 길이의 펜스 뒤에 남는 글이 언어 표기 한 단어라면 종전대로 중첩 블록의 시작으로 보고, 그렇지 않으면 닫힘으로 보아 백틱 바로 뒤에서 블록을 끝내고 남는 글은 본문으로 넘깁니다.
같은 길이 백틱으로 시작하는 줄을 만났을 때
백틱 뒤가 비어 있음 → 블록 닫힘
백틱 뒤가 한 단어 (예: json) → 중첩 블록 시작 (종전 동작)
그 밖 (설명 문장 등) → 블록 닫힘, 남는 글은 본문으로파서 단위 시험은 글이 이어진 닫기, 정상 닫기, 중첩 언어 펜스, 끝나지 않은 블록의 4종으로 구성했습니다. 수정본 빌드를 데모 백엔드에 연결해 채팅의 [화면 호출] 버튼을 누르자 모의 단말이 화면 ID와 계좌번호 파라미터를 받았습니다.
배포 후 같은 경로로 다시 측정한 결과
수정을 배포한 뒤 같은 날 밤 같은 경로(시연 PC 크롬 153, 그 PC의 모의 단말, 그룹정책만 적용하고 자동화 권한 없음)로 다시 측정했습니다. 현황판 카드는 저장된 결과가 아니라 새 질문을 실행해, 단말이 받은 계좌번호로 새 실행임을 구분했습니다. 역할 필터는 다음 날 사이드 컨테이너에 공개·역할 A·역할 B 화면 3건을 시드해 확인했습니다.
| 지표 | 이전 | 이후 | 측정 조건 |
|---|---|---|---|
| 루프백 웹소켓 연결 | error, 1ms | open, 2ms | 시연 PC 크롬 153, 정책에 데모 출처 등록 전후 |
| 단말 연결 | 시연 버튼에서 "단말 프로그램에 연결하지 못했습니다" | 관리 설정 연결 확인·호출 테스트 통과, 모의 단말이 규격 필드 일치 수신 | 같은 PC, 정책만 적용 |
| 채팅 답변 속 화면 호출 버튼 | 0개 | 정상 표시, 클릭 시 단말이 계좌 파라미터 수신 | 닫는 펜스 뒤에 글이 이어진 실제 답변, 파서 수정 배포 후 |
| 현황판 카드 버튼 | 미노출 (봉투만 읽음) | 새 실행에서 alert 1건과 버튼, 클릭 시 단말 수신 | 배포 후 같은 경로 |
| 역할 필터 목록 API | 역할 무시 | 슈퍼유저 3 · 역할 A 2 · 역할 없음 1 · 역할 B 2 · 둘 다 3 | 사이드 컨테이너, 화면 3건 시드 |
회고와 남은 과제
이번 건에서 결정적이었던 근거는 브라우저 콘솔이 아니라 서버 쪽 기록이었습니다. "TCP는 오는데 업그레이드 요청이 없다"는 한 줄이 서버 가설을 배제하고 브라우저를 지목했고, 권한 하나만 바꾼 대조 실행이 원인을 확정했습니다. 시연 PC에서는 같은 원인이 즉시 error로 나타나 증상이 달랐지만, 같은 대조 방법으로 같은 결론에 도달했습니다. 정책 문제를 풀고 나서야 렌더러 결함이 보였다는 점도 기억해 둘 만합니다. 앞의 원인이 뒤의 원인을 가리고 있었습니다.
원격 PC 작업에서는 두 번의 실수가 있었습니다. 정책 목록을 먼저 읽지 않고 첫 번째 자리에 값을 써 넣어, 그 자리에 다른 URL이 있었다면 덮어썼을 수 있었습니다. 곧바로 다른 자리로 옮기고 그 사실을 기록에 남겼습니다. 또 헤드리스 크롬만 내리려다 크롬 프로세스 전체를 종료하는 명령을 실행했는데, 세션이 달라 데스크톱에 열려 있던 탭은 영향을 받지 않았지만 의도 자체가 위험했습니다. 이후로는 직접 띄운 인스턴스를 명령줄 인자로 식별해 그것만 종료합니다. 원격 PC를 다룰 때는 읽기 전에 쓰지 않고, 대상을 좁혀서 종료한다는 원칙을 이 일로 분명히 했습니다.
남은 과제는 다음과 같습니다. 은행 배포 때는 IT 부서가 같은 정책을 그룹정책으로 배포해야 하며, 이번에 실물로 확인한 표를 그대로 전달합니다. 127.0.0.1은 그 PC 자신이므로 이 기능은 XGEN 페이지 안에서만 동작하고 그룹웨어나 메일 본문에서는 쓸 수 없습니다. 실제 단말과의 확인은 폐쇄망 반입 이후로 남아 있고, 콜백 키의 의미, 응답 JSON 규격, 단말 푸시 유무, 화면 ID 목록과 필수 파라미터 같은 은행 측 확인 사항도 회신을 기다리고 있습니다.