Opkle(옵클) - 창작자를 위한 앱과 시스템
옵클(Opkle)은 창작자를 위한 다양한 앱과 시스템을 제공하는 개발사입니다. 전자책 에디터 앱 'Opkle editor'를 출시했고, 관련 전자책 클래스를 제공하고 있습니다.
EDITOR
CLASS
BLOG
LOGIN
표현한다는 것의
무한한 가능성,
새로운 형태로
담아내다.
새로운 형태의 콘텐츠
Opkle은 코드 없이 웹을 마음껏 만들고, 누구나 자기 화면을 그릴 수 있도록 에디터를 만들고, 그 결과물을 어디서나 즐길 수 있게 돕는 팀입니다.
텍스트와 화면과의 조화를 통해, 웹을 짓는다는 것이 그저 단순한 코딩이 아닌, 상상을 펼치고 감각을 깨우는 과정이 될 수 있도록 좋은 도구를 만들어 냅니다.
옵클 에디터 개발기: Valkey 큐 서버의 기본 구조
dev
7
옵클 에디터 개발기: Worker loop와 AI 결과 검증
dev
8
옵클 에디터 개발기: 미리보기가 그대로 코드로
dev
9
옵클 에디터 개발기: 그래픽 에디터도 결국 직접 만들기로 했다
dev
10
옵클 에디터 개발기: Rust 공장을 그래픽 엔진으로 다듬다
dev
11
7
8
9
10
11
...
7
옵클 에디터 개발기: Valkey 큐 서버의 기본 구조
책을 만들기 위한 LLM 구조를 잡고 나니, 다음 단계는 모델 호출 자체가 아니었습니다. 실제 제품 안에서 AI 기능을 돌리려면 사용자의 요청이 안정적으로 들어가고, 작업 종류에 맞는 실행 경로로 분기되고, 중간에 worker가 재시작되어도 요청이 사라지지 않아야 합니다. 그리고 결과는 다시 에디터가 이해할 수 있는 형태로 돌아와야 합니다. 이 흐름이 없으면 아무리 좋은 모델을 붙여도 사용자는 버튼을 누른 뒤 불안정한 대기 상태를 경험하게 됩니다.
옵클 에디터의 AI 기능은 처음부터 짧은 텍스트 요청 하나로 끝나는 구조가 아니었습니다. 맞춤법 교정, 번역, 문체 개선, 이어쓰기처럼 문장 단위로 처리되는 작업이 있고, 선택된 HTML/CSS를 수정하는 selector 작업도 있습니다. SVG 벡터 에디터, 표 에디터, 레이아웃 에디터처럼 자연어를 명령 catalog로 바꾸는 작업도 있고, 이미지에서 표나 다이어그램 정보를 읽어 structured JSON으로 돌려주는 seeker 작업도 있습니다. 여기에 이미지 생성, 이미지 편집, 영상 생성, 영상 편집까지 붙으면 작업 시간과 실패 양상이 완전히 달라집니다.
그래서 저는 큐 서버를 단순한 background worker로 만들지 않았습니다. 이 서버는 AI 요청을 받아서 “나중에 처리하는 곳”이 아니라, 에디터의 다양한 AI 기능을 runtime contract로 정리하는 계층입니다. 요청 payload를 표준화하고, route key를 기준으로 최신 요청을 유지하고, Valkey list와 hash와 sorted set을 이용해 slot과 lock을 관리하고, 결과를 queueId 기준으로 다시 반환합니다. 에디터 입장에서는 AI가 하나의 기능처럼 보이지만, 뒤에서는 텍스트, 이미지, 영상, command extraction, visual extraction이 서로 다른 policy로 움직입니다.
기본 저장 축은 Valkey와 MongoDB로 나누었습니다. Valkey는 사용자가 기다리는 작업 상태와 결과 반환에 맞습니다. list로 queue를 만들고, hash로 in-progress 상태를 기록하고, sorted set으로 동시성 slot을 관리할 수 있습니다. MongoDB는 result log에 가깝습니다. 작업이 끝나면 queueId별 result queue에 결과를 넣고, 동시에 결과 collection에도 기록합니다. 사용자는 빠르게 결과를 받을 수 있고, 서버는 어떤 요청이 어떤 결과로 끝났는지 추적할 수 있습니다.
Queue payload
가장 먼저 나눈 것은 payload type이었습니다. 텍스트 계열 작업은 `id`, `key`, `phone`, `queueId`, `original`, `idMap`, `system`, `order`, `date`, `mode`, `images` 같은 필드를 갖습니다. 여기서 `queueId`는 사용자가 기다리는 단일 요청의 식별자이고, `key`는 route 단위의 식별자입니다. 같은 UI 영역이나 같은 기능 route에서 반복 요청이 들어오면 `key`는 같고 `queueId`는 달라질 수 있습니다.
이 구분은 중요했습니다. 사용자가 같은 문단에서 교정을 여러 번 누르거나, 같은 이미지 생성 조건을 바꿔 다시 요청하면 서버에는 짧은 시간 안에 여러 요청이 들어옵니다. 이때 모든 queueId를 끝까지 처리하면 오래된 결과가 나중에 도착해서 최신 UI 상태를 덮을 수 있습니다. 그래서 queueId는 결과 반환 단위로 보존하되, key는 route 안에서 최신 요청을 고르는 기준으로 사용했습니다.
텍스트 작업의 `mode`도 단순한 옵션이 아니었습니다. translator, writer, corrector, adder, secretary, helper, selector, vector, table, layout, seeker는 모두 같은 AI 요청처럼 보이지만 출력 계약이 다릅니다. translator와 corrector는 단락 수를 그대로 보존해야 합니다. writer는 원문 문체와 톤을 유지하면서 출판 가능한 수준으로 다듬어야 합니다. adder는 본문 뒤에 자연스럽게 이어지는 문장을 만들어야 하고, secretary는 본문 상태와 별도 명령을 함께 읽어야 합니다.
helper는 짧은 command를 빠르게 해석하는 쪽에 가깝습니다. selector는 선택된 HTML/CSS context를 바탕으로 patch를 반환해야 하고, vector/table/layout은 자연어를 각각의 command catalog에 맞는 JSON 명령으로 바꿔야 합니다. seeker는 텍스트 생성이 아니라 이미지 기반 extraction입니다. 입력 이미지와 schemaId, thinking option, default order가 붙을 수 있고, 결과는 반드시 structured JSON이어야 합니다.
이미지와 영상 payload는 다시 완전히 분리했습니다. image queue는 prompt와 options가 중심입니다. size, n, promptExtend, watermark, negativePrompt, seed, stylePreamble 같은 옵션이 들어갈 수 있습니다. imageEdit queue는 prompt와 images 배열을 함께 갖고, vectorContext 옵션을 통해 입력 이미지의 구조 분석을 prompt에 붙일 수 있습니다. video I2V queue는 first frame, last frame, driving audio 같은 media type을 구분하고, videoEdit queue는 원본 video와 reference image를 분리해서 받습니다.
이렇게 payload를 나누면 코드가 길어집니다. 하지만 AI 기능은 코드가 짧은 것보다 계약이 명확한 것이 중요합니다. prompt 하나만 받는 범용 queue로 시작하면 처음에는 편하지만, 나중에는 어떤 작업이 어떤 필드를 필요로 하는지 알기 어려워집니다. 저는 텍스트, 이미지, 이미지 편집, 영상 생성, 영상 편집을 처음부터 다른 queue collection과 result collection으로 나누었습니다. 작업 종류별 failure mode가 다르기 때문에 result type도 다르게 잡는 것이 맞았습니다.
Mode contract
텍스트 mode는 queue 서버 안에서 inference route를 결정합니다. corrector는 맞춤법과 비문 수정만 허용하고, 내용과 구조와 문체를 바꾸지 않아야 합니다. translator는 문학 번역에 가깝게 작동해야 하고, paragraph separator를 정확히 보존해야 합니다. writer는 교정과 문장 업그레이드를 함께 하되 원본 문체를 유지해야 합니다. adder는 기존 문장 뒤에 이어지는 내용을 생성해야 합니다.
secretary는 조금 다릅니다. 사용자의 `order`가 비어 있으면 기본적으로 본문에 자연스럽게 이어지는 내용을 쓰게 하고, order가 있으면 그 명령을 본문 상태와 함께 읽게 합니다. helper는 본문보다 명령 자체가 중심입니다. 사용자가 짧게 입력한 명령을 command id로 바꾸거나, 확정 명령이 아니면 모델에게 넘기는 구조입니다. 같은 텍스트 worker 안에 있지만, secretary와 helper는 “글 생성”보다 “에디터 명령 해석”에 더 가까운 mode입니다.
selector는 선택된 DOM context를 다룹니다. 프론트가 대상별 HTML/CSS를 system addition으로 넣고, 서버는 사용자의 요청만 message로 둡니다. 결과는 산문이 아니라 `{edits, commands}` 형태의 JSON이어야 합니다. `css.general`은 공유 stylesheet 뒤에 append되는 override rule이고, `css.local`은 대상 block에 가까운 local style을 뜻합니다. 이런 계약이 있어야 모델이 단순히 예쁜 문장을 쓰지 않고, 에디터가 적용 가능한 patch를 반환합니다.
vector, table, layout도 같은 철학입니다. pathVector는 SVG/vector/image editor의 command engine처럼 작동해야 하고, pathTable은 셀 범위나 view state를 command로 바꿔야 합니다. pathLayout은 텍스트 생성과 command를 함께 반환할 수 있는 hybrid shape를 갖습니다. 그래서 layout 결과는 `{text, commands}` 형태가 됩니다. 사용자의 요청이 글쓰기인지 편집 명령인지 한쪽으로만 고정하지 않고, 둘 다 표현할 수 있게 했습니다.
seeker는 이미지 기반 extraction입니다. 일반 seeker는 이미지의 시각 정보를 하나의 JSON 객체로 구조화합니다. pathTable 전용 schema가 들어오면 grid와 diagram을 다르게 처리합니다. grid에서는 좌측 열이나 빈 칸 누락이 결과 품질에 직접 영향을 주기 때문에, 단순히 JSON이 유효한지 보는 것만으로는 부족합니다. 이 mode contract를 queue payload에 넣어 두어야 worker가 retry 기준과 validation 기준을 다르게 적용할 수 있습니다.
Route key
큐 서버의 핵심은 global queue 하나로 모든 요청을 처리하지 않는다는 점이었습니다. 저는 global queue와 route key queue를 함께 사용했습니다. 사용자가 요청을 넣으면 route key queue에는 해당 key의 최신 payload가 들어가고, global queue에는 worker가 확인할 수 있는 동일 payload가 들어갑니다. worker는 먼저 global queue를 비우고, 거기서 나온 payload를 key별로 group으로 묶습니다. 그 다음 각 key에 해당하는 route queue에서 실제 target을 다시 꺼냅니다.
이중 queue 구조를 둔 이유는 최신 요청 우선 처리 때문입니다. 같은 key에서 요청이 여러 번 들어오면 route key queue는 새 payload 하나만 유지합니다. global queue는 worker를 깨우는 신호에 가깝고, route key queue가 실제 최신 상태를 갖습니다. worker가 global queue에서 오래된 payload를 보더라도, route key queue를 다시 읽으면 최신 target만 선택할 수 있습니다.
이 구조에서는 오래된 요청을 그냥 버리지 않습니다. 오래된 queueId를 기다리는 클라이언트가 있을 수 있기 때문입니다. target이 아닌 같은 group의 요청들은 cancelled result로 반환합니다. result payload에는 `cancelled: true`, `message: "cancelled"`, `result: "[]"`, `endDate`가 들어갑니다. 이렇게 해야 프론트는 해당 queueId가 영원히 pending으로 남았다고 생각하지 않습니다. 취소도 결과입니다.
route key는 suffix 검증에도 사용됩니다. 이미지 생성 route는 image suffix, 이미지 편집 route는 imageEdit suffix, 영상 생성 route는 video suffix, 영상 편집 route는 videoEdit suffix를 갖습니다. worker는 key가 해당 suffix를 갖지 않으면 route convention 위반으로 보고 빈 결과를 반환합니다. 이 검증을 큐 서버에 둔 이유는 프론트나 라우터에서 실수한 payload가 worker 내부의 외부 API 호출까지 들어가지 않게 하기 위해서입니다.
route key queue는 사용자 경험과 비용 제어를 동시에 해결합니다. 사용자가 같은 기능을 여러 번 눌렀을 때, 서버는 가장 최신 요청을 처리하고 이전 요청을 취소합니다. 비용이 큰 이미지/영상 작업에서도 의미 없는 중복 실행을 줄일 수 있습니다. 동시에 queueId별 cancelled result가 돌아가기 때문에 UI는 모든 요청에 대해 명확한 종료 상태를 받습니다. AI 기능이 제품 안에서 안정적으로 보이려면, 이런 오래된 요청 정리 흐름이 반드시 필요했습니다.
Valkey atomic layer
Valkey 위에 큐를 만들 때 가장 신경 쓴 부분은 atomic boundary였습니다. queue를 다루는 동작은 겉으로 보면 단순합니다. route key queue를 지우고, 새 payload를 넣고, global queue에도 넣으면 됩니다. 하지만 이 세 동작이 따로 실행되면 중간 상태가 생깁니다. worker가 그 사이에 queue를 읽거나, 같은 key에 새 요청이 다시 들어오면 최신 요청 보장성이 흔들릴 수 있습니다.
그래서 enqueue는 Lua script로 묶었습니다. route key queue에 대해 `DEL`을 하고, 같은 key에 새 payload를 `RPUSH`한 뒤, global queue에도 같은 payload를 `RPUSH`합니다. 이 세 단계가 하나의 script로 실행되면 worker는 중간 상태를 보지 않습니다. route key queue는 항상 최신 payload 하나만 갖고, global queue는 worker에게 처리할 route가 있음을 알려주는 역할을 합니다.
requeue도 같은 이유로 Lua로 묶었습니다. worker가 slot을 잡지 못했거나, 같은 key가 이미 진행 중이면 target을 다시 route key queue 앞쪽에 넣어야 합니다. 동시에 global queue에도 다시 등록해야 다음 loop에서 재시도됩니다. 이때 route queue에는 `LPUSH`, global queue에는 `RPUSH`를 사용합니다. route queue에서는 방금 실패한 target을 우선 보존하고, global queue에서는 worker loop의 자연스러운 순환을 유지합니다.
slot 확보도 atomic해야 합니다. 저는 작업 종류별 sorted set을 두었습니다. text, image, imageEdit, video, videoEdit마다 별도 slot key를 갖고, member는 queueId, score는 expireMs입니다. slot을 잡을 때 script는 먼저 현재 시각보다 score가 작은 member를 제거합니다. 그 다음 `ZCARD`로 현재 사용량을 확인하고, limit보다 작을 때만 새 member를 `ZADD`합니다. 마지막으로 key 자체에도 expire를 걸어 stale slot이 시스템에 오래 남지 않게 합니다.
이 방식은 fixed semaphore와 비슷하지만, Valkey sorted set을 쓰기 때문에 slot마다 만료 시각을 갖습니다. worker가 비정상 종료되어 release를 못 하더라도 score가 지나면 다음 acquire에서 정리됩니다. text는 동시성을 높게, image와 imageEdit은 중간 수준으로, video와 videoEdit은 낮게 둘 수 있습니다. 긴 영상 작업이 text 작업을 막지 않고, 짧은 helper command가 영상 polling 뒤에 밀려 사용자 경험을 망치지 않습니다.
Slot policy
작업 종류별 slot limit은 큐 서버의 성격을 결정합니다. 텍스트 작업은 비교적 빠르고, streaming response나 JSON parsing이 중심입니다. 이미지 작업은 외부 생성 시간이 더 길고, 결과 파일을 다운로드해서 R2에 올리는 과정이 붙습니다. 영상 작업은 task 생성 후 polling을 오래 유지해야 합니다. 그래서 이 셋을 같은 concurrency로 묶으면 리소스 배분이 어긋납니다.
저는 text, image, imageEdit, video, videoEdit을 slot kind로 나눴습니다. text는 여러 개를 동시에 처리할 수 있고, image와 imageEdit은 그보다 낮게, video와 videoEdit은 더 낮게 잡습니다. slot TTL도 다르게 둡니다. text는 긴 retry와 stream timeout을 고려하면서도 너무 오래 남지 않게 하고, image는 생성과 업로드 시간을 기준으로, video는 최대 polling 시간까지 포함해 훨씬 길게 둡니다.
slot TTL과 route lock TTL은 같지 않습니다. slot TTL은 작업 종류의 실제 실행 시간을 기준으로 잡고, route lock TTL은 route key가 너무 오래 묶이지 않도록 하는 기준입니다. video 쪽은 task polling이 길기 때문에 route lock도 길게 가져가야 하고, text는 상대적으로 짧아도 됩니다. 이렇게 세분화하지 않으면 짧은 작업은 과하게 오래 막히고, 긴 작업은 중간에 lock이 풀려 중복 실행될 수 있습니다.
slot을 못 잡았을 때는 바로 실패시키지 않습니다. target을 requeue하고, 같은 group의 target이 아닌 요청들은 cancelled result로 정리합니다. slot 부족은 일시적인 backpressure이기 때문입니다. queue 서버는 이 상태를 오류로 보지 않고, 다음 loop에서 다시 시도하도록 합니다. 사용자의 최신 요청은 보존하고, 오래된 요청은 닫아 줍니다.
이 정책을 두면 큐 서버는 단순 대기열이 아니라 리소스 스케줄러가 됩니다. 텍스트, 이미지, 영상은 같은 AI 기능이라는 이름 아래 있지만 서버 입장에서는 완전히 다른 workload입니다. slot policy는 이 workload들을 서로 방해하지 않게 나누는 장치입니다. 실제 에디터에서는 사용자가 문장 교정을 기다리는 동안 다른 사용자의 영상 생성이 진행 중일 수 있고, 둘은 서로 다른 slot pool에서 움직여야 합니다.
In-progress registry
slot을 잡았다고 해서 곧바로 외부 API를 호출하면 안 됩니다. 같은 route key가 이미 처리 중인지 확인해야 합니다. 그래서 queueId 기준 registry와 key 기준 registry를 따로 두었습니다. queueId 기준 registry는 현재 worker가 실제로 수행 중인 작업 목록입니다. key 기준 registry는 같은 route key에서 동시에 두 target이 실행되는 것을 막습니다.
queueId 기준 registry에는 kind, phone, id, queueId, startedAt을 JSON으로 저장합니다. 이 정보는 canRestart 판단에 사용됩니다. 워커 프로세스를 재시작하기 전에 in-flight 작업이 없으면 안전하게 끊을 수 있고, 하나라도 있으면 보류할 수 있습니다. 아직 dequeue되지 않은 작업은 Valkey queue에 남아 있으므로 재시작 후 다시 처리하면 됩니다. 실제 위험한 것은 worker가 잡고 외부 API를 호출 중인 in-flight 작업입니다.
key 기준 registry는 `workerProgressByKey` 같은 hash로 관리합니다. field는 route key이고, value는 queueId, startedAt, startedAtMs를 담은 JSON입니다. mark 할 때 기존 값이 있으면 startedAtMs를 확인합니다. 아직 stale 기준을 넘지 않았으면 새 작업은 key를 잡지 못합니다. 이때 slot은 release하고 target은 requeue합니다. 이렇게 해야 같은 문단이나 같은 이미지 route에서 두 worker가 동시에 결과를 만들지 않습니다.
stale 기준은 길게 잡았습니다. 텍스트 작업도 retry와 timeout을 고려하면 짧지 않을 수 있고, 영상 작업은 더 길어질 수 있습니다. keyProgressStaleMs를 충분히 크게 둔 이유는 정상 진행 중인 작업을 stale로 오판하지 않기 위해서입니다. 동시에 무한히 막히지 않도록 sweep 로직도 둡니다. 비정상 종료로 registry가 남아 있더라도 시간이 지나면 회수됩니다.
route lock은 또 다른 축입니다. `SET key queueId EX ttl NX` 패턴으로 route lock을 잡고, 작업이 끝나면 release합니다. slot, key progress, route lock은 비슷해 보이지만 역할이 다릅니다. slot은 작업 종류별 동시성 제한이고, key progress는 같은 route의 중복 실행 방지이며, route lock은 route queue 처리 경계입니다. 이 셋을 분리했기 때문에 실패 상황에서 어떤 상태를 풀어야 하는지 명확해졌습니다.
이 단계에서 큐 서버의 기본 구조는 정리되었습니다. payload type, mode contract, route key queue, Valkey Lua enqueue/requeue, sorted set slot, route lock, in-progress registry가 하나의 실행 모델로 맞물렸습니다. 사용자는 버튼 하나를 누르지만, 서버는 그 요청을 route 단위로 최신화하고, 작업 종류별 slot에 태우고, in-flight 상태를 기록하고, 오래된 요청을 닫아 줍니다. 반복 테스트에서도 최신 요청 우선 처리, 중복 실행 방지, slot backpressure, restart 판단, stale registry 회수가 안정적으로 통과했습니다.
이전글
목록으로
다음글
저작권 고시
Copyright Notice
본 웹사이트의 모든 디자인 결과물 및 영상에 대한 저작권은 Abstract Cloud에 있으며, 저작권법 및 관련 법령에 의해 보호받습니다. 웹, 영상, 본문, 표지, 내지 디자인을 포함한 모든 콘텐츠는 저작권자의 자산으로, 사전 동의 없이 무단 복제, 배포, 2차 저작물 제작, 온라인 공유 등을 금지합니다. 이를 위반할 시, 저작권법에 따라 민형사상 책임을 질 수 있습니다. 정당한 구매와 저작권 보호는 창작자의 권리를 지키며, 더 나은 작품으로 보답할 힘이 됩니다.
저작권자: Abstract Cloud | 대표자: 배창규(uragen)
© Abstract Cloud. All Rights Reserved.
HOME
FAQ
이용 약관
개인정보 이용방침
help@opkle.app
010-2747-3403
상호 :
추상적 형상 디자인(Abstract cloud) |
대표자 :
배창규
사업자등록번호 :
249-74-00533
통신판매업 신고번호 :
2025-의정부송산-0634
주소 :
경기도 의정부시 부용로 49, 108동 402호
웹의 모든 콘텐츠, 디자인, 소스 코드에 대한
저작권은 Opkle에게 있습니다.