EDITORCLASSBLOGLOGINimg default descriptionimg default descriptionimg default description표현한다는 것의무한한 가능성,새로운 형태로
담아내다.
img default descriptionimg default descriptionimg default descriptionimg default descriptionimg default descriptionimg default descriptionimg default description새로운 형태의 콘텐츠Opkle은 코드 없이 웹을 마음껏 만들고, 누구나 자기 화면을 그릴 수 있도록 에디터를 만들고, 그 결과물을 어디서나 즐길 수 있게 돕는 팀입니다.텍스트와 화면과의 조화를 통해, 웹을 짓는다는 것이 그저 단순한 코딩이 아닌, 상상을 펼치고 감각을 깨우는 과정이 될 수 있도록 좋은 도구를 만들어 냅니다.
옵클 에디터 개발기: Valkey 큐 서버의 기본 구조dev7옵클 에디터 개발기: Worker loop와 AI 결과 검증dev8옵클 에디터 개발기: 미리보기가 그대로 코드로dev9옵클 에디터 개발기: 그래픽 에디터도 결국 직접 만들기로 했다dev10옵클 에디터 개발기: Rust 공장을 그래픽 엔진으로 다듬다dev117891011...8 옵클 에디터 개발기: Worker loop와 AI 결과 검증Valkey 큐 서버의 기본 구조를 만든 뒤에는, 실제 worker가 어떤 방식으로 결과를 만들고 검증할지 정리해야 했습니다. 큐에 payload를 넣고 slot을 잡는 것만으로는 부족합니다. AI 작업은 결과 형식이 흔들릴 수 있고, 외부 생성 작업은 오래 걸릴 수 있으며, 이미지와 영상 결과는 provider의 임시 URL로 끝나면 안 됩니다. worker는 요청을 실행하는 곳이면서 동시에 결과를 제품이 쓸 수 있는 형태로 정리하는 마지막 방어선이어야 했습니다.저는 worker를 단일 함수 하나로 몰아넣지 않았습니다. text worker, image worker, imageEdit worker, video I2V worker, videoEdit worker를 분리했습니다. 공통적으로는 global queue를 비우고, key별 group을 만들고, route key queue에서 최신 target을 꺼내고, slot과 key progress를 잡은 뒤 비동기 작업으로 넘깁니다. 하지만 그 뒤의 실행 방식은 작업 종류마다 달라집니다. 텍스트는 prompt construction과 JSON validation이 중심이고, 이미지는 생성 결과 다운로드와 R2 업로드가 중심이며, 영상은 async task polling이 핵심입니다.이 구조에서 중요한 원칙은 하나였습니다. AI가 만든 결과도 에디터의 기존 document state와 동일한 검수 흐름을 통과해야 합니다. 텍스트 교정 결과는 block 수를 깨면 안 되고, selector 결과는 JSON patch로 파싱되어야 하며, vector/table/layout 결과는 command catalog에 맞아야 합니다. 이미지와 영상 결과는 만료 URL이 아니라 에디터가 장기적으로 참조할 수 있는 static URL이어야 합니다. queue worker는 단순히 모델 응답을 전달하는 proxy가 아니라, 에디터 계약에 맞게 결과를 정규화하는 계층입니다.Text worker텍스트 worker는 먼저 global text queue를 모두 꺼냅니다. 그 다음 payload를 `key`별로 묶고, 각 key마다 route key queue를 다시 읽습니다. 여기서 실제 target은 route queue의 마지막 payload입니다. 같은 key로 들어온 여러 요청 중 최신 target만 살아남고, 나머지는 cancelled result로 닫힙니다. 이 구조는 사용자의 빠른 반복 클릭이나 연속 요청을 자연스럽게 처리합니다.target을 잡은 뒤에는 text slot을 acquire합니다. slot을 못 잡으면 target을 requeue하고 오래된 요청만 취소합니다. slot을 잡았더라도 key progress를 mark하지 못하면 같은 key가 이미 처리 중이라는 뜻이므로 slot을 release하고 다시 requeue합니다. key progress까지 잡아야 비로소 작업을 실행합니다. 이 순서를 지키면 slot 누수와 중복 실행을 줄일 수 있습니다.실제 message construction은 mode마다 다릅니다. corrector는 “맞춤법과 비문만 고치고 내용과 구조와 문체는 건드리지 말라”는 강한 제한을 둡니다. translator는 paragraph separator를 그대로 보존하면서 영어 번역을 수행합니다. writer는 출판 작가 수준으로 문장을 업그레이드하되 원본 문체와 톤을 유지합니다. adder는 기존 본문 뒤에 자연스럽게 이어지는 내용을 씁니다.secretary는 order와 본문 상태를 함께 넣습니다. order가 비어 있으면 기본 명령을 채우고, order가 있으면 그대로 작업 지시로 사용합니다. selector, vector, table, layout은 본문 boilerplate를 넣지 않습니다. 프론트가 system addition으로 선택 context를 주입하고, user message는 요청 order만 둡니다. 이렇게 해야 모델이 “책 본문을 고쳐 쓰는 작업”과 “에디터 명령을 생성하는 작업”을 혼동하지 않습니다.seeker는 text worker 안에 있지만 실제로는 vision extraction에 가깝습니다. target.images에서 http URL만 추리고, 최대 개수를 제한합니다. 이미지가 없으면 slot과 lock을 풀고 종료합니다. schemaId가 pathTable 계열이면 기본 order와 전용 system prompt, 전용 validation 경로가 붙습니다. worker가 mode를 보고 message뿐 아니라 validation 전략까지 바꾸는 구조입니다.Command gate모든 요청을 LLM으로 보내는 것은 좋은 설계가 아니었습니다. 어떤 요청은 이미 규칙으로 확정할 수 있습니다. 예를 들어 helper, secretary, selector에서 사용자가 입력한 문장이 정확한 shortcut이나 command phrase와 일치하면 모델을 호출할 필요가 없습니다. 그 자리에서 command id를 반환하면 됩니다.그래서 text worker 앞쪽에 logic gate를 두었습니다. target.mode가 helper, selector, secretary 중 하나이고, order가 확정 명령으로 해석되면 즉시 결과를 만듭니다. helper는 command id만 반환하고, selector는 `{edits:[], commands:[id]}` 형태로 반환합니다. secretary는 escape token을 붙여 프론트가 명령 실행으로 해석할 수 있게 합니다. 같은 명령이라도 mode별 output contract가 다르기 때문에 gate 결과도 mode에 맞게 달라집니다.vector, table, layout에도 같은 방식의 gate를 두었습니다. 자연어 요청이 catalog command로 바로 resolve되면 모델을 호출하지 않고 `{commands:[cmd]}` 또는 layout의 hybrid response로 즉시 반환합니다. 프론트에서도 gate를 할 수 있지만, 백엔드에도 같은 방어선을 두었습니다. 프론트 우회나 버전 차이가 있어도 서버가 확정 명령을 안정적으로 처리할 수 있어야 하기 때문입니다.이 gate는 단순한 최적화가 아닙니다. 토큰 비용을 줄이고 latency를 낮추는 효과도 있지만, 더 중요한 것은 결정적 동작을 결정적으로 처리한다는 점입니다. “굵게”, “삭제”, “왼쪽 정렬” 같은 명령은 창의적인 생성이 필요하지 않습니다. 모델에게 맡기면 오히려 불확실성이 생깁니다. AI 기능 안에서도 deterministic path와 generative path를 분리해야 제품이 안정적입니다.gate가 성공하면 result queue에 바로 payload를 넣고 MongoDB result log에도 기록합니다. 그리고 slot, key progress, route lock을 모두 release합니다. 여기서도 cleanup path는 동일합니다. 모델 호출을 했는지 여부와 관계없이 queue worker는 항상 같은 종료 규칙을 따라야 합니다. 그래야 프론트는 결과를 받는 방식만 보면 되고, 내부에서 gate로 끝났는지 LLM으로 갔는지 알 필요가 없습니다.Separator validation교정, 번역, writer 계열에서 가장 중요한 것은 paragraph mapping이었습니다. 에디터는 block 단위로 문서를 관리합니다. 여러 문단을 한 번에 보내더라도 결과가 다시 원래 block 수와 맞아야 합니다. 모델이 두 문단을 합치거나, 하나의 문단을 둘로 나누거나, 중간 문단을 빼먹으면 에디터는 결과를 안전하게 적용할 수 없습니다.그래서 원문 배열을 하나의 문자열로 합칠 때 paragraph separator token을 사이에 넣었습니다. 각 문단 사이에 명시적 boundary를 두고, system prompt와 user message에서 이 token을 반드시 보존하라고 반복합니다. 결과가 돌아오면 token으로 split하고, parts.length가 expectedCount와 맞는지 확인합니다. 맞으면 JSON array로 감싸 result를 만듭니다. 맞지 않으면 retry합니다.retry에서는 instruction을 강화합니다. 이전 출력이 paragraph separator를 보존하지 않았고, expected paragraph unit count가 몇 개인지, separator가 몇 번 나와야 하는지를 명시합니다. 이 재시도 prompt는 모델의 자유도를 줄이고, 형식 보존을 우선하게 만듭니다. 자연스러운 문장보다 block mapping이 더 중요할 때가 있습니다. 에디터에 적용되는 AI 결과에서는 특히 그렇습니다.maxAttempts도 mode별로 다르게 봤습니다. 단락 수 보존이 필요한 작업은 최대 3회까지 재시도합니다. adder나 secretary처럼 단일 산문 출력으로 충분한 경우는 굳이 같은 방식의 separator validation을 하지 않습니다. 구조화 JSON이 필요한 mode도 별도 retry 대상입니다. 이렇게 mode별 검증 기준을 분리해야 불필요한 재시도와 형식 오류를 줄일 수 있습니다.결과적으로 교정과 번역은 단순 문자열 반환이 아니라 block-safe result가 되었습니다. 원본 `original.length`와 결과 배열 길이가 맞아야만 채택됩니다. 이 검증을 서버에서 끝내기 때문에 프론트는 결과 배열을 idMap과 매칭해 안정적으로 적용할 수 있습니다. AI가 문장을 아무리 잘 고쳐도 block mapping이 깨지면 에디터 기능으로는 실패입니다. 이 부분을 worker에서 확실히 잡았습니다.JSON validationselector, vector, table, layout, seeker는 자연어 답변을 받으면 안 됩니다. 이 mode들은 에디터가 바로 해석할 수 있는 JSON을 반환해야 합니다. 하지만 LLM은 종종 code fence를 붙이거나, 앞뒤에 설명을 넣거나, 비슷하지만 미묘하게 다른 field name을 만들 수 있습니다. 그래서 worker에는 sanitize와 parse 계층을 두었습니다.selector는 `{edits:[{id, html, css:{general, local}}], commands:[...]}` 형태를 기대합니다. 응답에서 code fence를 제거하고, 균형 잡힌 중괄호를 찾아 앞뒤 잡설을 잘라낸 뒤 JSON parse를 시도합니다. 실패하면 null을 반환하고 retry합니다. retry prompt에서는 “valid minified JSON object만 반환하라”, “edits와 commands shape를 정확히 지켜라”, “markdown이나 설명을 넣지 말라”고 강하게 지시합니다.vector와 table은 `{commands:[{command, params}]}` 형태가 중심입니다. parse 함수는 JSON이 유효한지뿐 아니라 command catalog에 맞는지도 확인합니다. 모델이 존재하지 않는 command id를 만들면 에디터가 실행할 수 없습니다. 자연어 요청을 command로 바꾸는 작업에서는 모델의 문장력이 아니라 catalog adherence가 품질 기준입니다.layout은 hybrid입니다. `{text, commands}` 형태로, 글쓰기 요청이면 text가 채워질 수 있고 편집 요청이면 commands가 채워질 수 있습니다. 둘 다 비어 있어도 유효한 무대응으로 볼 수 있습니다. 이 구조를 둔 이유는 page layout editor에서는 사용자가 “이 설명을 더 부드럽게 써줘” 같은 writing request와 “선택한 박스를 오른쪽으로 옮겨줘” 같은 editing request를 같은 assistant에게 할 수 있기 때문입니다.seeker는 JSON validation에 더해 schema별 정규화가 필요합니다. 일반 seeker는 summary, text, elements, colors, layout, metadata 같은 구조를 기대할 수 있고, pathTable grid나 diagram은 전용 parser를 통과합니다. parse가 실패하면 retry하고, pathTable grid에서는 과소추출 여부까지 봅니다. 왼쪽 열이 통째로 비거나 표의 density가 낮으면 형식은 맞아도 내용이 부족할 수 있습니다.Best resultAI extraction에서 항상 마지막 시도가 가장 좋지는 않습니다. 특히 이미지 기반 표 추출은 첫 번째 결과가 꽤 완전하고, 두 번째 결과가 JSON 형식은 더 깔끔하지만 일부 셀을 놓칠 수 있습니다. 그래서 seeker grid에서는 best result를 따로 보관했습니다. 각 시도의 결과를 fill score로 평가하고, 더 완전한 결과를 best로 저장합니다.grid under-extraction이 의심되면 retry instruction도 달라집니다. 단순히 “valid JSON을 반환하라”가 아니라, 좌측 열 누락이나 sparse extraction을 보완하라는 completeness hint를 줍니다. 형식 오류로 인한 retry와 내용 부족으로 인한 retry를 구분한 것입니다. 같은 재시도라도 이유가 다르면 prompt가 달라져야 합니다.마지막 시도가 실패했거나 더 나빠도 bestSeekerResult가 있으면 그것을 살립니다. 완전한 실패보다 부분적으로라도 쓸 수 있는 결과가 낫고, 이전 시도가 더 많은 정보를 담고 있을 수 있습니다. 이 판단은 모델이 아니라 worker가 합니다. worker는 응답을 단순히 받아 적는 곳이 아니라 후보 결과를 비교하고 채택하는 실행 계층입니다.이 best result 전략은 AI 기능의 안정감을 크게 올립니다. 사용자는 한 번의 요청만 보지만, 서버 내부에서는 여러 번의 시도와 검증, 후보 보존이 일어납니다. JSON이 깨진 응답은 버리고, 과소추출은 재시도하고, 가장 완전한 결과를 보존합니다. 특히 표나 다이어그램처럼 누락이 사용자에게 바로 보이는 작업에서는 이 차이가 큽니다.결국 seeker worker는 vision model 호출부가 아니라 visual data extraction pipeline이 되었습니다. 이미지 URL 검증, schema resolution, thinking option, retry hint, JSON parse, grid fill score, under-extraction detection, best result adoption까지 한 흐름으로 묶였습니다. 이 구조가 있어야 이미지에서 정보를 읽어 표 에디터나 레이아웃 에디터로 넘기는 작업이 안정적으로 굴러갑니다.Media workers이미지와 영상 worker는 텍스트 worker보다 I/O 성격이 강합니다. 이미지 생성 worker는 prompt와 options를 검증하고, slot과 key progress를 잡은 뒤 외부 생성 API를 호출합니다. 응답에서 이미지 URL을 받으면 그것을 그대로 쓰지 않습니다. provider가 준 URL은 만료될 수 있고, 출판 에디터의 문서 자산으로 쓰기에는 불안정합니다. 그래서 즉시 다운로드하고 R2에 업로드한 뒤 static URL을 반환합니다.이미지 생성 options도 서버에서 정규화합니다. size가 없으면 기본 size를 쓰고, n은 허용 범위 안에서만 반영합니다. promptExtend, watermark, seed, negativePrompt도 명시적 의도를 존중합니다. negativePrompt가 undefined이면 서버 기본값을 넣고, 빈 문자열이면 사용자가 비활성화한 것으로 보고 넣지 않습니다. 이런 작은 차이가 UI 옵션과 실제 생성 결과 사이의 신뢰를 만듭니다.imageEdit worker는 입력 이미지가 필수입니다. prompt가 비어 있거나 images 배열이 비어 있으면 빈 결과를 반환하고 종료합니다. 입력 이미지는 필요할 경우 구조 분석을 거쳐 prompt context로 들어갑니다. 이미지의 색, 도형, 텍스트 영역, 좌표 정보를 압축해서 모델에게 주면 편집 요청의 정확도가 올라갑니다. 이때 다운로드 크기 제한, 확장자 보정, temp 파일 정리도 함께 처리합니다.영상 생성과 영상 편집은 더 긴 흐름을 가집니다. image-to-video는 first_frame이 없으면 시작하지 않습니다. videoEdit은 원본 video가 반드시 하나 있어야 하고, reference_image는 제한된 개수만 받습니다. media validation을 먼저 끝내고, 통과하지 못하면 빈 result를 반환합니다. 잘못된 media payload가 외부 API까지 들어가면 비용과 시간이 낭비되기 때문에, worker 입구에서 끊는 것이 맞습니다.media worker에서 중요한 것은 결과 URL의 소유권입니다. 외부 provider의 임시 URL을 그대로 에디터 문서에 넣으면, 나중에 EPUB을 다시 열거나 export할 때 자산이 사라질 수 있습니다. 그래서 생성 결과는 모두 R2로 흡수합니다. 파일명에는 날짜와 queueId 일부와 unique suffix를 넣고, content type과 cache control을 설정합니다. 에디터는 provider URL이 아니라 Opkle static URL을 받습니다.Video polling영상 작업은 즉시 결과가 나오지 않습니다. 먼저 task creation request를 보내고, 응답에서 taskId를 받아야 합니다. taskId가 없거나 API error code가 있으면 실패로 종료합니다. task가 만들어지면 worker는 polling path를 만들고, 일정 간격으로 task status를 확인합니다.polling status는 `PENDING`, `RUNNING`, `SUCCEEDED`, `FAILED`, `CANCELED`, `UNKNOWN`으로 나뉩니다. PENDING과 RUNNING은 계속 기다립니다. SUCCEEDED가 오면 video_url을 얻고 loop를 종료합니다. FAILED, CANCELED, UNKNOWN은 실패로 보고 빈 결과를 반환합니다. polling 중 네트워크 오류가 나면 바로 전체 실패로 닫지 않고 다음 attempt로 넘어갑니다. 일시적인 통신 오류와 task 실패는 분리해야 합니다.polling interval과 max attempts도 명확히 잡았습니다. 15초 간격으로 최대 120회면 약 30분까지 기다릴 수 있습니다. 이 시간 동안 video slot은 점유됩니다. 그래서 video slot limit은 낮게 두고, text나 image slot과 분리했습니다. 영상 생성이 오래 걸려도 문장 교정이나 helper command가 같이 막히면 안 됩니다.video_url을 받은 뒤에도 작업은 끝나지 않습니다. worker는 그 URL에서 mp4를 stream으로 다운로드하고, R2에 업로드합니다. 영상은 수 MB에서 수십 MB가 될 수 있으므로 stream timeout과 max body 설정도 여유 있게 둡니다. content-length가 있으면 함께 넣고, cache control을 설정해 static asset으로 다룹니다. 최종 result는 R2 URL 배열입니다.이 구조가 잡히면 영상 생성과 영상 편집도 에디터의 일반 자산 처리 흐름 안으로 들어옵니다. 사용자는 긴 작업을 기다리지만, 결과가 도착하면 그것은 단순 외부 링크가 아니라 옵클 에디터가 관리하는 자산입니다. 이후 EPUB 내부에서 CSS animation용 frame sequence로 변환하든, preview에서 재생하든, export asset으로 묶든 안정적으로 다룰 수 있습니다.Cleanup path큐 서버에서 가장 중요하게 본 것 중 하나는 cleanup path였습니다. AI 작업은 실패할 수 있습니다. 외부 API가 실패할 수도 있고, JSON parsing이 실패할 수도 있고, 이미지 업로드가 실패할 수도 있습니다. 하지만 실패했다고 해서 slot이나 route lock이 남으면 다음 요청이 막힙니다. 그래서 worker의 실제 실행부는 항상 `try`와 `finally` 구조로 감쌌습니다.작업이 끝나면 성공 여부와 관계없이 unregisterInProgress를 호출합니다. key progress를 unmark하고, slot을 release하고, route lock도 release합니다. 이 순서는 모든 worker에서 반복됩니다. text worker, image worker, imageEdit worker, video worker, videoEdit worker가 서로 다른 일을 하더라도 종료 규칙은 같아야 합니다. 그래야 장애 상황에서 어디를 봐야 하는지 명확합니다.빈 결과도 결과입니다. prompt가 비어 있거나 필수 media가 없거나, 생성 결과가 없거나, 업로드에 실패하면 result는 `"[]"`가 됩니다. 중요한 것은 클라이언트가 계속 기다리지 않게 하는 것입니다. result queue에 빈 배열을 넣고 TTL을 걸어 둡니다. MongoDB result log에도 기록합니다. 사용자는 실패했더라도 완료 상태를 받습니다.result queue에는 TTL을 둡니다. 실시간 UI가 결과를 가져갈 수 있을 정도의 시간은 보장하되, Valkey에 결과가 끝없이 쌓이지 않게 합니다. 장기 기록은 MongoDB가 맡습니다. 이 역할 분리가 있어야 Valkey는 빠른 queue/runtime state에 집중하고, MongoDB는 history/log 성격을 갖습니다.processEmptyGroup도 cleanup의 일부였습니다. global queue에는 작업이 있는데 route key queue가 비어 있을 수 있습니다. 이때 slot을 이미 잡고 있는 queueId라면 아직 진행 중일 수 있으므로 건드리지 않습니다. slot이 잡혀 있지 않은 오래된 payload는 cancelled result로 닫습니다. 어떤 경우에도 pending queueId를 방치하지 않는 것이 원칙이었습니다.Worker loop최종 worker loop는 계속 도는 단순한 구조입니다. 한 tick에서 text queue, image queue, imageEdit queue, video I2V queue, videoEdit queue를 순서대로 호출합니다. 각 queue 함수는 자기 collection을 비우고, key별 grouping을 하고, route key queue에서 target을 꺼내고, slot과 lock을 잡은 뒤 실제 작업을 async로 실행합니다. loop 자체는 너무 많은 일을 하지 않고, 각 worker function의 policy만 실행합니다.loop sleep은 짧게 두었습니다. 너무 길면 사용자가 AI 버튼을 눌렀을 때 시작 지연이 커집니다. 대신 stale sweep은 매 tick마다 하지 않고 일정 tick마다 수행합니다. 예를 들어 3초 sleep에 100 tick이면 약 5분마다 sweep이 돕니다. 이렇게 하면 평소 loop는 가볍게 유지하고, 오래된 registry와 route lock은 주기적으로 정리할 수 있습니다.worker 시작 시에도 stale sweep을 한 번 수행합니다. 이전 프로세스가 비정상 종료되면서 남긴 in-progress entry나 key progress entry가 있을 수 있기 때문입니다. queue에 아직 들어 있는 작업은 다시 처리하면 되지만, stale registry가 남아 있으면 route key가 막힐 수 있습니다. 시작 시 sweep은 워커가 깨끗한 상태로 들어가기 위한 초기 정리입니다.SIGTERM과 SIGINT도 처리했습니다. 신호를 받으면 isRunning을 false로 바꾸고, 새 loop를 시작하지 않게 합니다. 이미 실행 중인 비동기 작업은 자기 finally path를 통해 정리됩니다. 재시작 안전성은 canRestart와도 연결됩니다. in-flight 작업이 없으면 안전하게 restart할 수 있고, 있으면 기다릴 수 있습니다. queue에 아직 dequeue되지 않은 작업은 Valkey에 남아 있으므로 손실 대상이 아닙니다.이 단계까지 오면서 큐 서버는 단순 background process가 아니라 AI runtime infrastructure가 되었습니다. Valkey queue, route key, slot, lock, in-progress registry, retry validation, media upload, polling, result TTL, MongoDB log, stale sweep, graceful shutdown이 하나로 연결되었습니다. 반복 테스트에서도 단락 수 보존, JSON parsing retry, command gate, 최신 요청 우선 처리, 오래된 요청 cancel, 영상 task polling, R2 업로드, 재시작 안전성, cleanup path가 안정적으로 통과했습니다. 옵클 에디터의 AI 기능은 이제 모델 호출이 아니라, 제품 환경에서 지속적으로 버틸 수 있는 worker system 위에서 작동하게 되었습니다.이전글목록으로다음글저작권 고시Copyright Notice본 웹사이트의 모든 디자인 결과물 및 영상에 대한 저작권은 Abstract Cloud에 있으며, 저작권법 및 관련 법령에 의해 보호받습니다. 웹, 영상, 본문, 표지, 내지 디자인을 포함한 모든 콘텐츠는 저작권자의 자산으로, 사전 동의 없이 무단 복제, 배포, 2차 저작물 제작, 온라인 공유 등을 금지합니다. 이를 위반할 시, 저작권법에 따라 민형사상 책임을 질 수 있습니다. 정당한 구매와 저작권 보호는 창작자의 권리를 지키며, 더 나은 작품으로 보답할 힘이 됩니다.저작권자: Abstract Cloud | 대표자: 배창규(uragen)
© Abstract Cloud. All Rights Reserved.
HOMEFAQ이용 약관개인정보 이용방침help@opkle.app
010-2747-3403
상호 :추상적 형상 디자인(Abstract cloud)  |  대표자 :배창규사업자등록번호 :249-74-00533통신판매업 신고번호 :2025-의정부송산-0634주소 :경기도 의정부시 부용로 49, 108동 402호웹의 모든 콘텐츠, 디자인, 소스 코드에 대한
저작권은 Opkle에게 있습니다.
img default description