작업 그룹을 만들었습니다. 어디선가 사용자는 스피너만 바라보고 있고, "14개 언어로 번역 중…"이라는 문구는 맞는 말이지만 아무 도움이 되지 않습니다. 전혀 움직이지 않으니까요. 사용자 눈앞에서 숫자가 올라가야 합니다. 3개 준비 완료, 다음엔 4개, 그러다 실패한 로캘 하나, 그리고 마침내 완료까지.
작업 그룹을 폴링해도 되긴 하지만, 요청이 너무 잦고 매번 새로운 스냅샷을 받게 됩니다. 실제로 무엇이 바뀌었는지 알려면 이전 결과와 비교해야 하죠. WebSocket은 이 방식을 뒤집습니다. 한 번만 연결하면 서버가 로캘 하나가 처리될 때마다 이벤트를 푸시하고, 모든 메시지에는 항상 전체 그룹 상태가 담겨 있습니다. 그래서 델타를 맞춰볼 필요 없이 스냅샷만 렌더링하면 됩니다. 프레임을 하나 놓치든, 다시 연결하든, 탭을 새로 열든 다음 메시지가 다시 전체 진실을 전해줍니다.
GET /jobs/localization/groups/:groupId/ws비동기 로컬라이제이션이 처음이신가요? 개요부터 시작하세요. 여기서 말하는 groupId는 작업을 생성했을 때 받은 바로 그 값입니다.
이 페이지에서 다루는 내용
메시지 유형#
소켓을 통해 네 가지 메시지 유형이 오갑니다. 각 메시지는 방금 어떤 일이 일어났는지 알려주고, 동시에 전체 그룹의 현재 상태도 함께 전달합니다.
| 유형 | 시점 | 주요 필드 |
|---|---|---|
snapshot | 초기 연결 시 | 전체 그룹 상태 |
job.completed | 로캘이 성공적으로 완료될 때 | jobId, locale, 그리고 전체 그룹 상태 |
job.failed | 로캘이 실패할 때 | jobId, locale, error, 그리고 전체 그룹 상태 |
group.completed | 모든 작업이 처리 완료되었을 때 | groupId, status, 그리고 전체 그룹 상태. 이 메시지 이후 서버가 연결을 닫습니다. |
모든 메시지에는 현재 그룹 상태를 담은 snapshot 객체가 들어 있습니다. 여기에는 totalJobs, completedJobs, completedWithWarningsJobs, failedJobs와 함께, 작업 ID를 키로 하는 jobs 맵이 포함되며 각 항목에는 locale와 status가 들어 있습니다. 이 카운트는 작업 그룹 엔드포인트가 보고하는 값과 동일하므로, 소켓에서 받은 스냅샷과 REST 엔드포인트를 폴링한 결과는 그룹이 어디까지 진행됐는지 정확히 일치합니다.
스냅샷만 렌더링하고, 델타는 맞추지 마세요
어떤 이벤트를 이미 봤는지 추적할 필요도, 놓친 메시지를 재생할 필요도, 부분 업데이트를 로컬 상태에 병합할 필요도 없습니다. 매 메시지마다 snapshot를 읽고 그 상태 그대로 UI를 그리면 됩니다. 다시 연결하면 먼저 snapshot가 다시 전송되므로, 방금 합류한 클라이언트와 처음부터 계속 듣고 있던 클라이언트는 결국 같은 상태에 도달합니다.
메시지 페이로드#
아래는 서버가 실제로 보내는 정확한 프레임입니다. ID는 실제 형식(ljg_는 그룹용, ljb_는 각 작업용)을 따르며, snapshot는 이미 앞에서 보여준 구조가 반복되는 부분에서만 "..."로 줄여 표기했습니다.
연결되면 서버는 현재 상태를 보냅니다:
{
"type": "snapshot",
"snapshot": {
"groupId": "ljg_A1b2C3d4E5f6G7h8",
"totalJobs": 3,
"completedJobs": 1,
"completedWithWarningsJobs": 0,
"failedJobs": 0,
"jobs": {
"ljb_A1b2C3d4E5f6G7h8": { "locale": "de", "status": "completed" },
"ljb_B2c3D4e5F6g7H8i9": { "locale": "fr", "status": "processing" },
"ljb_C3d4E5f6G7h8I9j0": { "locale": "ja", "status": "queued" }
}
}
}각 로캘이 완료될 때마다 이벤트는 변경된 로캘을 가리키고, 업데이트된 스냅샷도 함께 포함합니다:
{
"type": "job.completed",
"jobId": "ljb_B2c3D4e5F6g7H8i9",
"locale": "fr",
"snapshot": {
"groupId": "ljg_A1b2C3d4E5f6G7h8",
"totalJobs": 3,
"completedJobs": 2,
"completedWithWarningsJobs": 0,
"failedJobs": 0,
"jobs": {
"ljb_A1b2C3d4E5f6G7h8": { "locale": "de", "status": "completed" },
"ljb_B2c3D4e5F6g7H8i9": { "locale": "fr", "status": "completed" },
"ljb_C3d4E5f6G7h8I9j0": { "locale": "ja", "status": "processing" }
}
}
}실패는 연결이 끊긴 것이 아니라 정상적인 메시지입니다. job.failed에는 로캘과 error, 그리고 동일한 전체 스냅샷이 함께 담깁니다. 실패한 로캘은 jobs 맵에서 status: "failed"로 표시되고, 다른 모든 로캘은 계속 스트리밍되며, 소켓은 group.completed까지 그대로 이어집니다:
{
"type": "job.failed",
"jobId": "ljb_C3d4E5f6G7h8I9j0",
"locale": "ja",
"error": "Model timeout after 30 seconds",
"snapshot": { "...": "..." }
}모든 작업이 처리 완료되면 서버는 마지막 이벤트를 보내고 연결을 닫습니다:
{
"type": "group.completed",
"groupId": "ljg_A1b2C3d4E5f6G7h8",
"status": "completed",
"snapshot": { "...": "..." }
}최종 status는 모든 로캘이 성공했을 때는 completed, 모든 로캘이 출력을 생성했지만 하나 이상에서 선택적 pipeline 단계가 실패했을 때는 completed_with_warnings, 일부 로캘은 성공하고 일부는 실패했을 때는 partial, 그리고 전부 실패했을 때는 failed입니다. 각 상태가 그룹 전체에 어떤 의미를 갖는지는 작업 그룹 추적에서 확인하세요.
알 수 없는 항목이 나오면 스냅샷 기준으로 렌더링하세요
알고 있는 메시지 유형은 분기 처리하고, 모르는 항목이 나오면 snapshot를 기준으로 다시 렌더링하도록 두세요. 모든 메시지에는 전체 스냅샷이 담겨 있으므로, 이를 기본으로 그리는 클라이언트는 별도 분기가 없는 프레임에서도 올바른 상태를 유지합니다.
UI에 연결하기#
그룹이 곧 진행 상황 모델입니다. 작업을 생성했을 때 202 응답으로 groupId와 jobs 배열을 받게 됩니다. 로캘마다 항목이 하나씩 있죠. 이 응답으로 진행 상황 레코드를 초기화하면, 이후 소켓이 채워 넣을 구조가 그대로 준비됩니다. 즉, 목표로 할 전체 개수와 0에서 시작하는 카운터를 갖게 되는 셈입니다.
const { groupId, jobs } = await response.json();
await db.translationProgress.create({
contentId: content.id,
groupId,
totalLanguages: jobs.length,
completedLanguages: 0,
});그다음 해당 groupId로 소켓을 열고, 메시지가 올 때마다 snapshot를 읽어 다시 그리세요. 로캘이 완료될수록 카운터가 올라가고, group.completed가 도착하면 멈추면 됩니다:
import WebSocket from "ws";
const groupId = "ljg_A1b2C3d4E5f6G7h8";
const ws = new WebSocket(
`wss://api.lingo.dev/jobs/localization/groups/${groupId}/ws`,
{ headers: { "X-API-Key": process.env.LINGO_API_KEY } }
);
ws.on("message", (raw) => {
const event = JSON.parse(raw);
const { snapshot } = event;
switch (event.type) {
case "snapshot":
console.log(`${snapshot.completedJobs}/${snapshot.totalJobs} complete`);
break;
case "job.completed":
console.log(`${event.locale} ready (${snapshot.completedJobs}/${snapshot.totalJobs})`);
break;
case "job.failed":
console.error(`${event.locale} failed: ${event.error}`);
break;
case "group.completed":
console.log(`All translations done: ${event.status}`);
ws.close();
break;
}
});로캘 3개짜리 그룹으로 실행하면 진행 상황이 실시간으로 이렇게 출력됩니다:
1/3 complete
fr ready (2/3)
ja failed: Model timeout after 30 seconds
All translations done: partial카운터는 자동으로 올라가고, 한 로캘이 실패해도 스트림은 끊기지 않으며, partial가 이번 실행의 최종 상태를 알려줍니다. 스피너를 진짜 진행 표시줄로 바꾸는 데 꼭 필요한 동작이죠. 여기서 중요한 점은 루프가 상태를 누적하지 않는다는 것입니다. 각 분기는 지금 받은 메시지의 snapshot만 읽기 때문에, 같은 코드가 첫 연결 시에도, 모든 업데이트 시에도, 다시 연결할 때도 그대로 올바르게 동작합니다.
API 키를 서버에 보관하세요#
이 소켓은 API 키로 인증하며, REST 엔드포인트와 동일한 organization-scoped key를 사용합니다. 즉, 브라우저는 이 연결을 열기에 적절한 곳이 아닙니다. 클라이언트 JavaScript에 API 키를 넣는 순간, 소스를 보는 누구나 조직의 모든 엔진에 접근할 수 있게 됩니다.
브라우저가 아니라 백엔드에서 연결하세요
이미 키가 있는 서버에서 WebSocket을 열고, 그다음 여러분이 제어하는 자체 채널(WebSocket 또는 서버 전송 이벤트 스트림)로 이벤트를 브라우저에 전달하세요. 프런트엔드는 실시간 진행 상황을 받고, 키는 인프라 밖으로 나가지 않습니다.
이 방식은 webhook 모델과도 같습니다. Lingo.dev에 직접 닿는 연결은 서버 측에 있고, 사용자에게 전달되는 것은 여러분의 앱이 내보내기로 한 내용뿐입니다.
이 기능이 들어맞는 자리#
WebSocket은 실시간 뷰입니다. 하나의 그룹에만 연결되고, 그 그룹이 완료되면 닫힙니다. 탭이 닫히거나 배포가 일어나도 살아남는 내구성 있는 서버 간 전달이 필요하다면 webhooks와 함께 사용하세요. 소켓은 실행 중인 동안 UI를 구동하고, webhook은 각 결과가 도착하는 즉시 기록합니다. 둘 다 같은 create call에서 연결하면, 사용자는 진행 상황을 실시간으로 확인하고 백엔드는 누가 보고 있든 관계없이 결과를 계속 보관할 수 있습니다.
