엔지니어링 노트도구와 실행

막대를 모델이 그리면 숫자와 어긋나도 모른다, 차트는 화면이 그린다

모델에게 막대 차트를 SVG로 그리게 하면, 모델은 막대마다 값을 눈금에 맞춰 높이로 바꾸고 그 좌표를 씁니다. 답에는 3분기 매출이 1,200이라고 적고 막대는 1,020 높이로 그려도 둘을 견주는 곳이 없습니다. 사용자는 숫자보다 그림을 먼저 보므로, 틀린 막대는 틀린 답과 같습니다.

그래서 그림을 누가 그릴지는 그 그림이 틀렸을 때 무엇이 깨지는지로 정했습니다. 숫자가 든 차트는 모델이 그리지 않습니다. 모델은 행과 축 이름만 넘기고 화면이 그 행으로 막대를 그립니다. 숫자가 없는 다이어그램은 모델이 SVG로 그리고, 서버가 겹친 상자와 넘친 글자를 검사합니다. 모델이 보고 판단할 문서 페이지는 렌더 사이드카(백엔드 옆에 따로 띄운 렌더 전용 컨테이너)가 실제 문서와 같게 그리고, 같게 그리지 못한 부분은 글로 알립니다. 이 글은 세 경로와, 사용자 문서를 여는 렌더 사이드카를 어떻게 가뒀는지를 설명합니다.

그림 종류마다 모델이 넘기는 것과 실제로 그리는 쪽
그림 1. 그림 종류마다 모델이 넘기는 것과 실제로 그리는 쪽

차트는 모델이 넘긴 행으로 화면이 그린다

차트 도구가 받는 것은 차트 종류(막대·선·원), 행 목록, x축과 y축으로 쓸 필드 이름, 선 차트의 계열 필드, 축 이름입니다. 좌표와 눈금, 색은 받지 않습니다. 서버는 화면의 차트 스키마와 같은 규칙으로 입력을 검사합니다. 축으로 지정한 필드가 행에 있어야 하고, 칸 값은 문자열·숫자·빈 값만 됩니다. 필드 이름이 대소문자나 앞뒤 공백만 다르면 실제 필드로 바로잡고 무엇을 고쳤는지 모델에게 알립니다. 없는 필드를 축으로 고르면 어느 자리가 틀렸는지 적어 돌려줍니다.

검사를 통과하면 서버가 위젯 ID를 발급합니다. 모델은 답 본문의 원하는 자리에 그 ID를 표시로 넣고, 화면은 같은 행으로 차트를 그립니다. 막대 높이는 행 값에서 바로 나오므로 그림과 숫자가 어긋날 수 없습니다. 행 목록은 화면으로만 가고 모델이 받는 도구 결과에는 ID만 남습니다. 행 2,000개짜리 차트가 다음 라운드 입력에 다시 실리지 않게 하려는 것입니다.

모델이 도구를 부르지 않고 그럴듯한 ID를 지어 넣을 수도 있습니다. 답을 내기 전에 서버가 본문의 ID를 이 턴에 발급한 ID와 대조하고, 없는 ID가 있으면 차트를 실제로 그려서 그 ID를 쓰라고 모델에게 되돌려 보냅니다. 끝까지 남은 표시는 답에서 지웁니다. 데이터가 애초에 차트로 그릴 수 없는 모양이면 두 번 연달아 실패한 뒤 표로 바꿔 보여 주는데, 그 동작은 「양식 복사·가이드 읽기는 도구가 하고, 모델에는 인자로 묻는다」에서 다룹니다.

다이어그램은 모델이 SVG로 그리고 서버가 모양을 검사한다

처리 흐름이나 시스템 구성처럼 관계를 보여 주는 그림에는 맞춰야 할 숫자가 없습니다. 틀리면 상자가 겹치거나 글자가 상자를 넘치고, 사용자는 그것을 바로 봅니다. 다시 그리면 고쳐지는 결함입니다. 그래서 새 다이어그램은 모델이 SVG로 직접 그립니다. 색은 쓰지 않고 상자·화살표·강조 같은 이름만 붙이면, 화면이 그 이름에 밝은 테마와 어두운 테마의 색을 입힙니다. 모델마다 색이 달라지지 않고, 어두운 테마에서 글자가 사라지지도 않습니다. 대가는 출력 토큰입니다. SVG 한 장은 1~3천 토큰이라, 사용자가 그림을 청하거나 네 단계 이상의 흐름을 글로 따라가기 어려울 때만 그리라고 도구 설명에 적어 두었습니다.

서버는 모델이 쓴 SVG를 그대로 받지 않습니다. 아는 요소와 속성만 새 문서로 옮겨 적고, 스크립트, 외부 내용을 끌어오는 요소, 링크, 외부 주소는 버립니다. 모르는 것을 버리는 방식이라 다음에 생길 위험 요소도 남지 않습니다. 화면은 이 SVG를 이미지로만 띄워 스크립트가 돌지 않지만, 사용자가 SVG 파일로 내려받아 브라우저로 열면 그 파일은 문서로 해석됩니다. 정화는 그 경우를 위한 것입니다.

모델은 그림이 흐름·구조 다이어그램인지도 밝혀야 합니다. 다이어그램이면 서버가 상자끼리 겹쳤는지, 글자가 상자를 넘쳤는지 봅니다. 서버에는 글자 폭을 잴 브라우저가 없어서 폭은 글자 수로 어림합니다. 문제가 있으면 그 자리를 몇 건까지 돌려주고 모델이 좌표를 고쳐 다시 그립니다. 이 검사는 놓치는 쪽으로 기울여 두었습니다. 잘못 잡으면 멀쩡한 그림이 다음 단계로 밀려나기 때문입니다.

그 다음 단계는 Mermaid입니다. 같은 다이어그램이 SVG로 두 번 연달아 실패하면, 서버가 지시를 「다시 그려라」에서 「같은 그림을 Mermaid로 그려라」로 바꿉니다. Mermaid에서는 모델이 노드와 연결만 텍스트로 적고 배치는 Mermaid가 정하므로, 겹침과 넘침이 모델의 좌표 실수에서 나오지 않습니다. 검사가 놓친 그림은 사용자가 Mermaid로 다시 그리기를 눌러 같은 길로 보냅니다. Mermaid는 화면이 그립니다. 라벨 속 HTML과 클릭 동작을 막는 엄격 보안 수준으로 돌리고, 코드 안의 설정 지시가 그 수준을 바꾸지 못하게 했으며, 결과는 역시 이미지로만 띄웁니다. Mermaid로도 두 번 실패하면 모델은 그림 대신 단계를 번호 목록으로 적습니다.

문서 페이지는 렌더 사이드카가 실제와 같게 그린다

모델이 만든 워드·파워포인트·엑셀·한글 파일과 사용자가 올린 문서는 렌더 사이드카가 페이지 이미지로 그립니다. 이 이미지는 사용자가 보는 미리보기가 되고, 모델의 다음 입력에도 붙습니다(「앱이 못 여는 오피스 파일은 검증기가 막고, 모양은 모델이 그림으로 본다」). 모델은 이 그림을 보고 무엇을 고칠지 정합니다. 그래서 여기서 깨지면 안 되는 것은 그림이 실제 문서와 같다는 점입니다. 없는 결함이 그림에 보이면 모델은 없는 결함을 고치려 듭니다.

변환은 두 단계입니다. 포맷별 변환기가 PDF를 만들고, poppler가 그 PDF를 PNG로 바꿉니다.

포맷 PDF를 만드는 변환기
워드·엑셀·파워포인트 LibreOffice(soffice)
한글(hwp, hwpx) soffice + 한글 필터 확장
HTML WeasyPrint

HTML만 따로 둔 것도 같은 이유입니다. 같은 HTML을 soffice로 그리면 플렉스박스가 세로로 무너지고 CSS 그리드와 표 테두리가 사라졌습니다. WeasyPrint는 카드 가로 배치와 3열 그리드, 표 테두리를 그대로 그렸고, 백엔드가 HTML로 PDF 파일을 만들 때도 WeasyPrint를 씁니다. 미리보기와 실제 산출물이 같은 엔진에서 나옵니다.

실제와 같게 그리지 못하는 경우도 있습니다. 한글 필터가 열지 못하는 hwpx 파일은 순수 파이썬으로 짠 근사 조판으로 대신 그립니다. 이때 글꼴, 자간, 쪽 나눔이 실제와 다르고 쪽수도 맞지 않습니다. 실측에서 13쪽 문서가 PDF 18쪽으로 나왔습니다. 한글 문서 속 사진이 빈 칸으로 그려지는 경우도 있습니다. 렌더 사이드카는 이런 사실을 응답의 진단 목록에 싣고, 백엔드는 진단마다 한 줄씩 모델 입력에 붙입니다. 진단이 없으면 모델은 근사 조판을 실제 조판으로 읽고 그 쪽수를 근거로 분량을 판단합니다. 일부 쪽만 그렸을 때도 응답에는 순번이 아니라 실제 쪽 번호를 씁니다. 순번을 쓰면 7쪽을 2쪽이라고 말하게 됩니다.

렌더 사이드카는 밖으로 나가는 길이 없는 네트워크에서 문서를 연다

렌더러를 백엔드 프로세스 안에 라이브러리로 두면 컨테이너와 서비스 간 호출이 하나씩 줄어듭니다. 그래도 별도 컨테이너로 뺀 것은 soffice가 다루기 어렵고, 렌더러가 믿을 수 없는 문서를 열기 때문입니다.

soffice는 깨진 입력에서 끝없이 기다리는 경우가 있습니다. 기본 사용자 프로필이 하나뿐이라 동시에 부르면 잠금을 두고 다투고, 진 쪽은 빈 PDF를 남긴 채 조용히 끝납니다. 그래서 요청마다 프로필을 새로 만들고, 시간이 다 되면 프로세스 트리 전체를 죽입니다. WeasyPrint는 프로세스 안에서 도는 라이브러리라 스레드를 끊을 수 없어서 자식 프로세스로 돌려 같은 시간 제한을 받게 했습니다. 메모리도 문제입니다. 한글 필터는 변환마다 Java를 띄우고, 그림이 많은 11MB hwpx 하나가 763MiB까지 썼습니다. 상한이 없으면 렌더러가 아니라 같은 호스트의 데이터베이스나 백엔드가 OOM으로 죽습니다. 그래서 컨테이너에 메모리 3GB와 CPU 2개를 주고 동시 변환을 2건으로 묶었습니다.

문서는 연결된 그림이나 외부 데이터처럼 바깥 주소를 가리킬 수 있습니다. 렌더러가 그 주소를 따라가면 문서가 고른 주소로 요청이 나가고(SSRF), 그 주소에는 내부망과 클라우드 인스턴스 메타데이터 주소도 들어갈 수 있습니다. 그래서 두 가지를 겹쳐 두었습니다. 렌더 사이드카는 soffice에 넘기기 전에 오피스 파일에서 외부 대상을 가리키는 관계를 걷어 낸 사본을 만듭니다. 미리보기는 그림이라 링크가 사라져도 잃는 것이 없고, 연결된 그림은 빈 칸으로 그려집니다. 그리고 렌더 사이드카는 게이트웨이가 없는 내부 전용 네트워크에 둡니다. 함께 붙는 것은 호출자인 백엔드와 워커뿐이고, 데이터베이스는 없습니다. 모델이 쓴 코드가 도는 실행 사이드카와도 네트워크를 나눴습니다.

권한도 좁혔습니다. 요청에는 파일 바이트 대신 샌드박스 경로를 싣고, 렌더 사이드카는 샌드박스 볼륨을 읽기 전용으로만 봅니다. 컨테이너의 루트 파일시스템도 읽기 전용입니다. 포트는 밖에 열지 않고 서비스 간 토큰으로 호출자를 확인합니다. 한 번에 그리는 쪽수는 기본 30쪽, 설정으로 최대 100쪽입니다. 100쪽짜리 덱도 변환 6초, PNG 변환 9초 정도라 렌더러가 병목은 아닙니다. 상한을 정한 기준은 모델의 이미지 토큰입니다. 100쪽을 실으면 컨텍스트가 그림으로 찹니다.

처리할 수 없는 문서는 422로 돌려 다시 시도하지 않는다

렌더 실패는 응답 코드로 가릅니다.

상황 응답 코드
처리할 수 없는 문서, 변환 시간 초과 422
렌더러의 예상 못 한 예외 500
경로 문제(호출자 입력 오류) 400

깨진 문서를 500으로 돌려주면 안 됩니다. 백엔드의 HTTP 클라이언트는 5xx를 받으면 다시 시도해 모두 세 번까지 부르므로, 깨진 파일 하나에 soffice가 세 번 돌고 그동안 두 개뿐인 변환 자리를 막아 다른 사용자의 렌더까지 멈춥니다. 시간 초과를 422에 넣은 것도 같은 판단입니다. 알려진 원인은 깨진 입력이고, 부하 탓이었더라도 같은 문서를 곧바로 다시 돌리면 부하만 커집니다. 예상 못 한 예외는 다시 시도할 만할 수 있어서 500으로 둡니다.

렌더가 실패해도 턴은 멈추지 않습니다. 그 파일만 그림 확인을 건너뛰고 경고 로그를 남깁니다. 파일 자체는 이미 검증을 통과해 저장된 상태라 사용자는 파일을 그대로 받습니다.

설계에서 고른 것

차트가 막는 것은 막대와 행 값의 어긋남입니다. 행 값을 조회 결과에서 옮겨 적는 일은 여전히 모델이 하므로, 행이 맞는지는 그 값을 낸 SQL과 도구 결과가 정합니다. 차트는 채팅 화면 안의 표시라 파일로 남지 않고 플레이북 단계에서도 쓰지 않습니다.

다이어그램을 SVG로 먼저 그리게 한 대가는 출력 토큰과 어림 검사입니다. 글자 폭을 글자 수로 어림하므로 넘침을 놓칠 수 있고, 놓친 그림은 사용자가 누르는 다시 그리기 버튼으로 Mermaid가 다시 그립니다. Mermaid로 그리면 배치를 손댈 수 없고, Mermaid 문법 오류는 화면이 그릴 때야 드러나 그 자리에는 그림 대신 코드가 보입니다.

문서 렌더는 LibreOffice 하나로 합니다. 구축형 설치 하나에서 워드·엑셀·파워포인트·한글 문서를 같은 사이드카로 그릴 수 있기 때문입니다. 대가로 글꼴 대체나 일부 기능에서 실제 앱과 작은 차이가 생기고, 근사 조판처럼 큰 차이는 진단으로 모델에게 알립니다.

모델이 그림을 내는 에이전트라면, 먼저 그 그림이 틀렸을 때 무엇이 깨지는지 물어볼 만합니다. 숫자가 틀리는 그림이면 모델에게서 좌표를 빼앗고 데이터만 받습니다. 모양만 틀리는 그림이면 모델이 그리게 두고 서버가 검사합니다. 모델이 근거로 삼는 그림이면 렌더러가 실제와 다르게 그린 부분을 글로 함께 돌려줍니다.